t5s3 touch mapping fix; ui fixed for repeateradminscreen; highlighting fixed for notes and discovery screen; t5s3 initial virtual keyboard implementation

This commit is contained in:
pelgraine
2026-03-12 12:24:56 +11:00
parent 8b78eac17f
commit 753d125384
10 changed files with 743 additions and 38 deletions
+110 -25
View File
@@ -505,26 +505,33 @@ MyMesh the_mesh(radio_driver, fast_rng, rtc_clock, tables, store
// T5S3 touch mapping — must be after ui_task declaration
#if defined(LilyGo_T5S3_EPaper_Pro)
// Home screen tile grid — physical touch zones (960×540)
#define TILE_Y_START 150
#define TILE_Y_MID 300
#define TILE_Y_END 460
#define TILE_COL1 325
#define TILE_COL2 640
// Map a single tap based on current screen context
static char mapTouchTap(int16_t x, int16_t y) {
// --- Status bar tap (top ~80px) → go home from any non-home screen ---
if (y < 80 && !ui_task.isOnHomeScreen()) {
// Convert physical (960×540) to virtual (128×128) coordinates
int vx = (int)(x / 7.5f);
int vy = (int)(y / 4.21875f);
// --- Status bar tap (top ~18 virtual units) → go home from any non-home screen ---
if (vy < 18 && !ui_task.isOnHomeScreen()) {
ui_task.gotoHomeScreen();
return 0;
}
// Home screen FIRST page: tile taps
// Home screen FIRST page: tile taps (virtual coordinate hit test)
if (ui_task.isOnHomeScreen() && ui_task.isHomeShowingTiles()) {
if (y >= TILE_Y_START && y < TILE_Y_END) {
int col = (x < TILE_COL1) ? 0 : (x < TILE_COL2) ? 1 : 2;
int row = (y < TILE_Y_MID) ? 0 : 1;
const int tileW = 40, tileH = 32, gapX = 1, gapY = 2;
const int gridW = tileW * 3 + gapX * 2;
const int gridX = (128 - gridW) / 2; // =3
int gridY = ui_task.getTileGridVY();
// Check if tap is within the tile grid area
if (vx >= gridX && vx < gridX + gridW &&
vy >= gridY && vy < gridY + 2 * (tileH + gapY)) {
int col = (vx - gridX) / (tileW + gapX);
if (col > 2) col = 2;
int row = (vy - gridY) / (tileH + gapY);
if (row > 1) row = 1;
if (row == 0 && col == 0) { ui_task.gotoChannelScreen(); return 0; }
if (row == 0 && col == 1) { ui_task.gotoContactsScreen(); return 0; }
if (row == 0 && col == 2) { ui_task.gotoSettingsScreen(); return 0; }
@@ -550,6 +557,15 @@ MyMesh the_mesh(radio_driver, fast_rng, rtc_clock, tables, store
return KEY_ENTER; // file list: open selected
}
// Notes editing: tap → open keyboard for typing
if (ui_task.isOnNotesScreen()) {
NotesScreen* notes = (NotesScreen*)ui_task.getNotesScreen();
if (notes && notes->isEditing()) {
ui_task.showVirtualKeyboard(VKB_NOTES, "Edit Note", "", 137);
return 0;
}
}
// All other screens: tap = select
return KEY_ENTER;
}
@@ -600,9 +616,10 @@ MyMesh the_mesh(radio_driver, fast_rng, rtc_clock, tables, store
// Map a long press to a key
static char mapTouchLongPress(int16_t x, int16_t y) {
// Home screen: long press cycles pages
// Home screen: long press = activate current page action
// (BLE toggle, send advert, hibernate, GPS toggle, etc.)
if (ui_task.isOnHomeScreen()) {
return (char)KEY_NEXT;
return (char)KEY_ENTER;
}
// Reader reading: long press = close book
@@ -614,23 +631,63 @@ MyMesh the_mesh(radio_driver, fast_rng, rtc_clock, tables, store
return KEY_ENTER; // file list: open
}
// Channel screen: long press → compose to current channel
if (ui_task.isOnChannelScreen()) {
uint8_t chIdx = ui_task.getChannelScreenViewIdx();
ChannelDetails ch;
if (the_mesh.getChannel(chIdx, ch)) {
char label[40];
snprintf(label, sizeof(label), "To: %s", ch.name);
ui_task.showVirtualKeyboard(VKB_CHANNEL_MSG, label, "", 137, chIdx);
}
return 0;
}
// Contacts screen: long press → DM for chat contacts, admin for repeaters
if (ui_task.isOnContactsScreen()) {
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
if (cs) {
int idx = cs->getSelectedContactIdx();
uint8_t ctype = cs->getSelectedContactType();
if (idx >= 0 && ctype == ADV_TYPE_CHAT) {
char dname[32];
cs->getSelectedContactName(dname, sizeof(dname));
char label[40];
snprintf(label, sizeof(label), "DM: %s", dname);
ui_task.showVirtualKeyboard(VKB_DM, label, "", 137, idx);
return 0;
} else if (idx >= 0 && ctype == ADV_TYPE_REPEATER) {
ui_task.gotoRepeaterAdmin(idx);
return 0;
}
}
return KEY_ENTER;
}
// Discovery screen: long press = rescan
if (ui_task.isOnDiscoveryScreen()) {
return 'f';
}
// Contacts screen: long press = open repeater admin (if on a repeater contact)
if (ui_task.isOnContactsScreen()) {
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
if (cs) {
int idx = cs->getSelectedContactIdx();
ContactInfo ci;
if (the_mesh.getContactByIdx(idx, ci) && ci.type == ADV_TYPE_REPEATER) {
ui_task.gotoRepeaterAdmin(idx);
// Repeater admin: long press open keyboard for password or CLI
if (ui_task.isOnRepeaterAdmin()) {
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)ui_task.getRepeaterAdminScreen();
if (admin) {
RepeaterAdminScreen::AdminState astate = admin->getState();
if (astate == RepeaterAdminScreen::STATE_PASSWORD_ENTRY) {
ui_task.showVirtualKeyboard(VKB_ADMIN_PASSWORD, "Admin Password", "", 32);
return 0;
}
}
return KEY_ENTER; // non-repeater: normal select
}
// Notes screen: long press in editor → save and exit
if (ui_task.isOnNotesScreen()) {
NotesScreen* notes = (NotesScreen*)ui_task.getNotesScreen();
if (notes && notes->isEditing()) {
notes->triggerSaveAndExit();
return 0;
}
}
// Default: enter/select (settings toggle, etc.)
@@ -1396,9 +1453,10 @@ void loop() {
// Long press = finger held > 500ms without moving → edit/enter
// After processing an event, cooldown waits for finger lift before next event.
// Touch is disabled while lock screen is active.
// When virtual keyboard is active, taps route to keyboard.
// ---------------------------------------------------------------------------
#if defined(LilyGo_T5S3_EPaper_Pro)
if (!ui_task.isLocked())
if (!ui_task.isLocked() && !ui_task.isVKBActive())
{
int16_t tx, ty;
bool gotPoint = readTouchLandscape(&tx, &ty);
@@ -1484,6 +1542,33 @@ void loop() {
}
}
}
// Virtual keyboard touch routing (separate state machine, simple tap-only)
// vkbNeedLift: true after VKB opens, requires finger lift before accepting taps.
// This prevents the long press that opened VKB from registering as a keystroke.
{
static bool vkbNeedLift = true;
static unsigned long vkbLastTap = 0;
if (ui_task.isVKBActive()) {
int16_t tx, ty;
bool gotPt = readTouchLandscape(&tx, &ty);
if (!gotPt) {
vkbNeedLift = false; // Finger lifted — now taps are allowed
} else if (!vkbNeedLift && (millis() - vkbLastTap >= 300)) {
int vx = (int)(tx / 7.5f);
int vy = (int)(ty / 4.21875f);
if (ui_task.getVKB().handleTap(vx, vy)) {
ui_task.forceRefresh();
}
vkbLastTap = millis();
vkbNeedLift = true; // Require lift before next tap
}
} else {
vkbNeedLift = true; // Reset for next VKB open
}
}
#endif
// Poll touch input for phone dialer numpad
@@ -639,6 +639,10 @@ public:
display.drawRect(0, footerY - 2, display.width(), 1);
display.setCursor(0, footerY);
display.setColor(DisplayDriver::YELLOW);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Back");
const char* copyHint = "Tap:Dismiss";
#else
display.print("Q:Back");
// Show scroll hint if path is scrollable
if (msg && (msg->path_len & 63) > _pathHopsVisible && msg->path_len != 0xFF) {
@@ -648,6 +652,7 @@ public:
display.print(scrollHint);
}
const char* copyHint = "Ent:Copy";
#endif
display.setCursor(display.width() - display.getTextWidth(copyHint) - 2, footerY);
display.print(copyHint);
@@ -664,9 +669,15 @@ public:
display.setColor(DisplayDriver::LIGHT);
display.print("No messages yet");
display.setCursor(0, 30);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Swipe: Switch channel");
display.setCursor(0, 40);
display.print("Long press: Compose");
#else
display.print("A/D: Switch channel");
display.setCursor(0, 40);
display.print("C: Compose message");
#endif
display.setTextSize(1); // Restore for footer
} else {
display.setTextSize(0); // Tiny font for message body
@@ -956,8 +967,11 @@ public:
display.setColor(DisplayDriver::YELLOW);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setTextSize(0);
display.drawTextCentered(display.width() / 2, footerY, "Swipe: Scroll Tap: Select boot: home");
display.setCursor(0, footerY);
display.print("Swipe:Ch/Scroll");
const char* rtCh = "Hold:Compose";
display.setCursor(display.width() - display.getTextWidth(rtCh) - 2, footerY);
display.print(rtCh);
#else
// Left side: abbreviated controls
if (_replySelectMode) {
@@ -219,7 +219,11 @@ public:
display.setCursor(0, y);
display.print("No contacts");
display.setCursor(0, y + lineHeight);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Swipe to change filter");
#else
display.print("A/D: Change filter");
#endif
} else {
// Center visible window around selected item (TextReaderScreen pattern)
int maxVisible = (maxY - headerHeight) / lineHeight;
@@ -302,8 +306,11 @@ public:
display.setColor(DisplayDriver::YELLOW);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setTextSize(0);
display.drawTextCentered(display.width() / 2, footerY, "Swipe: Scroll Tap: Select boot: home");
display.setCursor(0, footerY);
display.print("Swipe:Filter");
const char* right = "Hold:DM/Admin";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
#else
// Left: Q:Bk
display.setCursor(0, footerY);
@@ -100,7 +100,11 @@ public:
// Highlight selected row
if (selected) {
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), lineHeight);
#else
display.fillRect(0, y + 5, display.width(), lineHeight);
#endif
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
+51 -6
View File
@@ -496,7 +496,11 @@ private:
int rightX = display.width() - display.getTextWidth(tmp) - 2;
if (_selectedFile >= 1 && _selectedFile <= (int)_fileList.size()) {
#if defined(LilyGo_T5S3_EPaper_Pro)
const char* hint = "[Hold:Rename]";
#else
const char* hint = "[R:Rename]";
#endif
int hintX = rightX - display.getTextWidth(hint) - 4;
display.setCursor(hintX, 0);
display.setColor(DisplayDriver::YELLOW);
@@ -511,7 +515,7 @@ private:
// File list with "+ New Note" at index 0
display.setTextSize(0);
int listLineH = 8;
int listLineH = 9; // Match contacts/discovery for consistent selection highlight
int startY = 14;
int totalItems = 1 + (int)_fileList.size();
int maxVisible = (display.height() - startY - _footerHeight) / listLineH;
@@ -528,7 +532,11 @@ private:
if (selected) {
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), listLineH);
#else
display.fillRect(0, y + 5, display.width(), listLineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
@@ -558,9 +566,13 @@ private:
display.drawRect(0, footerY - 2, display.width(), 1);
display.setCursor(0, footerY);
display.setColor(DisplayDriver::YELLOW);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Swipe:Nav");
const char* right = "Tap:Open";
#else
display.print("Q:Back W/S:Nav");
const char* right = "Ent:Open";
#endif
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
@@ -576,9 +588,13 @@ private:
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, footerY);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Tap:Edit");
const char* right = "Hold:Delete";
#else
display.print("Q:Bck Ent:Edit");
const char* right = "Sh+Del:Del";
#endif
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
return;
@@ -663,9 +679,15 @@ private:
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, footerY);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Swipe:Page");
const char* right = "Tap:Edit";
#else
display.print("Q:Bck Ent:Edit");
const char* right = "Sh+Del:Del";
#endif
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
@@ -766,11 +788,25 @@ private:
snprintf(status, sizeof(status), "Pg %d/%d", curPage, totalPg);
display.print(status);
#if defined(LilyGo_T5S3_EPaper_Pro)
const char* mid = "Tap:Type";
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
display.print(mid);
#endif
const char* right;
if (_bufLen == 0 || !_dirty) {
#if defined(LilyGo_T5S3_EPaper_Pro)
right = "Back";
#else
right = "Q:Back";
#endif
} else {
#if defined(LilyGo_T5S3_EPaper_Pro)
right = "Hold:Save";
#else
right = "Sh+Del:Save";
#endif
}
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
@@ -817,9 +853,13 @@ private:
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, footerY);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Back:Cancel");
const char* right = "Tap:Confirm";
#else
display.print("Q:Cancel");
const char* right = "Ent:Confirm";
#endif
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
@@ -852,9 +892,13 @@ private:
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, footerY);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Back:Cancel");
const char* right = "Tap:Delete";
#else
display.print("Q:Cancel");
const char* right = "Ent:Delete";
#endif
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
@@ -1124,6 +1168,8 @@ public:
void setSDReady(bool ready) { _sdReady = ready; }
bool isSDReady() const { return _sdReady; }
bool isDirty() const { return _dirty; }
void triggerSaveAndExit() { saveAndExit(); }
void setTimestamp(uint32_t rtcTime, int8_t utcOffset) {
_rtcTime = rtcTime;
@@ -1145,7 +1191,6 @@ public:
bool isInFileList() const { return _mode == FILE_LIST; }
bool isRenaming() const { return _mode == RENAMING; }
bool isConfirmingDelete() const { return _mode == CONFIRM_DELETE; }
bool isDirty() const { return _dirty; }
bool isEmpty() const { return _bufLen == 0; }
// ---- Cursor Navigation (called from main.cpp) ----
@@ -598,41 +598,77 @@ public:
switch (_state) {
case STATE_PASSWORD_ENTRY:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Back:Exit");
renderFooterRight(display, footerY, "Hold:Type");
#else
display.print("Sh+Del:Exit");
renderFooterRight(display, footerY, "Ent:Login");
#endif
break;
case STATE_LOGGING_IN:
case STATE_COMMAND_PENDING:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Back:Cancel");
#else
display.print("Sh+Del:Cancel");
#endif
break;
case STATE_CATEGORY_MENU:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Back:Exit");
renderFooterMidRight(display, footerY, "Back:Exit", "Tap:Open", "Swipe:Sel");
#else
display.print("Sh+Del:Exit");
renderFooterMidRight(display, footerY, "Sh+Del:Exit", "Ent:Open", "W/S:Sel");
#endif
break;
case STATE_COMMAND_MENU:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Back:Back");
renderFooterMidRight(display, footerY, "Back:Back", "Tap:Run", "Swipe:Sel");
#else
display.print("Sh+Del:Back");
renderFooterMidRight(display, footerY, "Sh+Del:Back", "Ent:Run", "W/S:Sel");
#endif
break;
case STATE_PARAM_ENTRY:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Back:Cancel");
renderFooterRight(display, footerY, "Tap:Send");
#else
display.print("Sh+Del:Cancel");
renderFooterRight(display, footerY, "Ent:Send");
#endif
break;
case STATE_CONFIRM:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Back:No");
renderFooterRight(display, footerY, "Tap:Yes");
#else
display.print("Sh+Del:No");
renderFooterRight(display, footerY, "Ent:Yes");
#endif
break;
case STATE_RESPONSE_VIEW:
case STATE_ERROR:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Back:Back");
if (_responseTotalLines > bodyHeight / 9) {
renderFooterRight(display, footerY, "Swipe:Scroll");
}
#else
display.print("Sh+Del:Back");
if (_responseTotalLines > bodyHeight / 9) {
renderFooterRight(display, footerY, "W/S:Scrll");
}
#endif
break;
}
@@ -1001,7 +1037,11 @@ private:
if (_pendingCmd && (_pendingCmd->flags & CMDF_EXPECT_TIMEOUT)) {
display.print("Timeout response is normal.");
} else {
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Tap=Yes Back=No");
#else
display.print("Enter=Yes Sh+Del=No");
#endif
}
display.setTextSize(1);
@@ -926,7 +926,7 @@ private:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setTextSize(0);
display.drawTextCentered(display.width() / 2, footerY, "Swipe: Scroll Tap: Open boot: home");
display.drawTextCentered(display.width() / 2, footerY, "Swipe: Scroll Tap: Open Boot: home");
#else
display.setCursor(0, footerY);
display.print("Q:Back W/S:Nav");
+146 -2
View File
@@ -32,7 +32,7 @@
#if UI_HAS_JOYSTICK
#define PRESS_LABEL "press Enter"
#elif defined(LilyGo_T5S3_EPaper_Pro)
#define PRESS_LABEL "hold boot btn"
#define PRESS_LABEL "long press"
#else
#define PRESS_LABEL "long press"
#endif
@@ -378,6 +378,7 @@ public:
const int gridW = tileW * 3 + gapX * 2;
const int gridX = (display.width() - gridW) / 2;
const int gridY = y + 2;
_task->setTileGridVY(gridY); // Store for touch hit testing
for (int row = 0; row < 2; row++) {
for (int col = 0; col < 3; col++) {
@@ -1372,6 +1373,9 @@ void UITask::loop() {
// Ignored while locked — long press required to unlock
if (_locked) {
c = 0;
} else if (_vkbActive) {
onVKBCancel();
c = 0;
} else if (curr == home) {
c = checkDisplayOn(KEY_NEXT);
} else {
@@ -1501,6 +1505,33 @@ if (curr) curr->poll();
if (_display != NULL && _display->isOn()) {
if (millis() >= _next_refresh && curr) {
_display->startFrame();
#if defined(LilyGo_T5S3_EPaper_Pro)
if (_vkbActive) {
_vkb.render(*_display);
_next_refresh = millis() + 500; // Moderate refresh for cursor blink
// Check if keyboard was submitted or cancelled during render cycle
if (_vkb.status() == VKB_SUBMITTED) {
onVKBSubmit();
} else if (_vkb.status() == VKB_CANCELLED) {
onVKBCancel();
}
} else {
int delay_millis = curr->render(*_display);
if (millis() < _alert_expiry) {
_display->setTextSize(1);
int y = _display->height() / 3;
int p = _display->height() / 32;
_display->setColor(DisplayDriver::DARK);
_display->fillRect(p, y, _display->width() - p*2, y);
_display->setColor(DisplayDriver::LIGHT);
_display->drawRect(p, y, _display->width() - p*2, y);
_display->drawTextCentered(_display->width() / 2, y + p*3, _alert);
_next_refresh = _alert_expiry;
} else {
_next_refresh = millis() + delay_millis;
}
}
#else
int delay_millis = curr->render(*_display);
if (millis() < _alert_expiry) { // render alert popup
_display->setTextSize(1);
@@ -1515,6 +1546,7 @@ if (curr) curr->poll();
} else {
_next_refresh = millis() + delay_millis;
}
#endif
_display->endFrame();
}
#if AUTO_OFF_MILLIS > 0
@@ -1575,7 +1607,10 @@ char UITask::handleLongPress(char c) {
c = 0; // consume event
}
#if defined(LilyGo_T5S3_EPaper_Pro)
else if (_locked) {
else if (_vkbActive) {
onVKBCancel(); // Long press while VKB → cancel
c = 0;
} else if (_locked) {
unlockScreen();
c = 0;
} else {
@@ -1645,6 +1680,115 @@ void UITask::unlockScreen() {
_next_refresh = 0;
Serial.println("[UI] Screen unlocked");
}
void UITask::showVirtualKeyboard(VKBPurpose purpose, const char* label, const char* initial, int maxLen, int contextIdx) {
_vkb.open(purpose, label, initial, maxLen, contextIdx);
_vkbActive = true;
_screenBeforeVKB = curr;
_next_refresh = 0;
display.invalidateFrameCRC(); // Force e-ink redraw (VKB may look same as last open)
_auto_off = millis() + 120000; // 2min timeout while typing
Serial.printf("[UI] VKB opened: %s\n", label);
}
void UITask::onVKBSubmit() {
_vkbActive = false;
const char* text = _vkb.getText();
VKBPurpose purpose = _vkb.purpose();
int idx = _vkb.contextIdx();
Serial.printf("[UI] VKB submit: purpose=%d idx=%d text='%s'\n", purpose, idx, text);
switch (purpose) {
case VKB_CHANNEL_MSG: {
if (strlen(text) == 0) break;
ChannelDetails channel;
if (the_mesh.getChannel(idx, channel)) {
uint32_t timestamp = rtc_clock.getCurrentTime();
int textLen = strlen(text);
if (the_mesh.sendGroupMessage(timestamp, channel.channel,
the_mesh.getNodePrefs()->node_name,
text, textLen)) {
addSentChannelMessage(idx, the_mesh.getNodePrefs()->node_name, text);
the_mesh.queueSentChannelMessage(idx, timestamp,
the_mesh.getNodePrefs()->node_name, text);
showAlert("Sent!", 1500);
} else {
showAlert("Send failed!", 1500);
}
}
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
break;
}
case VKB_DM: {
if (strlen(text) == 0) break;
if (the_mesh.uiSendDirectMessage((uint32_t)idx, text)) {
showAlert("DM sent!", 1500);
} else {
showAlert("DM failed!", 1500);
}
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
break;
}
case VKB_ADMIN_PASSWORD: {
// Feed each character to the admin screen, then Enter
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)getRepeaterAdminScreen();
if (admin) {
for (int i = 0; text[i]; i++) {
admin->handleInput(text[i]);
}
admin->handleInput('\r');
}
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
break;
}
case VKB_ADMIN_CLI: {
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)getRepeaterAdminScreen();
if (admin) {
for (int i = 0; text[i]; i++) {
admin->handleInput(text[i]);
}
admin->handleInput('\r');
}
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
break;
}
case VKB_SETTINGS_NAME: {
if (strlen(text) > 0) {
strncpy(_node_prefs->node_name, text, sizeof(_node_prefs->node_name) - 1);
_node_prefs->node_name[sizeof(_node_prefs->node_name) - 1] = '\0';
the_mesh.savePrefs();
showAlert("Name saved", 1000);
}
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
break;
}
case VKB_NOTES: {
NotesScreen* notes = (NotesScreen*)getNotesScreen();
if (notes && strlen(text) > 0) {
for (int i = 0; text[i]; i++) {
notes->handleInput(text[i]);
}
}
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
break;
}
}
_screenBeforeVKB = nullptr;
_next_refresh = 0;
display.invalidateFrameCRC();
}
void UITask::onVKBCancel() {
_vkbActive = false;
if (_screenBeforeVKB) setCurrScreen(_screenBeforeVKB);
_screenBeforeVKB = nullptr;
_next_refresh = 0;
display.invalidateFrameCRC();
Serial.println("[UI] VKB cancelled");
}
#endif
bool UITask::getGPSState() {
+16
View File
@@ -30,6 +30,10 @@
#include "WebReaderScreen.h"
#endif
#if defined(LilyGo_T5S3_EPaper_Pro)
#include "VirtualKeyboard.h"
#endif
// MapScreen.h included in UITask.cpp and main.cpp only (PNGdec headers
// conflict with BLE if pulled into the global include chain)
@@ -86,10 +90,15 @@ class UITask : public AbstractUITask {
UIScreen* map_screen; // Map tile screen (GPS + SD card tiles)
UIScreen* curr;
bool _homeShowingTiles = false; // Set by HomeScreen render when tile grid is visible
int _tileGridVY = 44; // Virtual Y of tile grid top (updated each render)
#if defined(LilyGo_T5S3_EPaper_Pro)
UIScreen* lock_screen; // Lock screen (big clock + battery + unread)
UIScreen* _screenBeforeLock = nullptr;
bool _locked = false;
VirtualKeyboard _vkb;
bool _vkbActive = false;
UIScreen* _screenBeforeVKB = nullptr;
#endif
void userLedHandler();
@@ -156,6 +165,8 @@ public:
bool isOnHomeScreen() const { return curr == home; }
bool isHomeShowingTiles() const { return _homeShowingTiles; }
void setHomeShowingTiles(bool v) { _homeShowingTiles = v; }
int getTileGridVY() const { return _tileGridVY; }
void setTileGridVY(int vy) { _tileGridVY = vy; }
bool isOnNotesScreen() const { return curr == notes_screen; }
bool isOnSettingsScreen() const { return curr == settings_screen; }
bool isOnAudiobookPlayer() const { return curr == audiobook_screen; }
@@ -166,6 +177,11 @@ public:
bool isLocked() const { return _locked; }
void lockScreen();
void unlockScreen();
bool isVKBActive() const { return _vkbActive; }
VirtualKeyboard& getVKB() { return _vkb; }
void showVirtualKeyboard(VKBPurpose purpose, const char* label, const char* initial, int maxLen, int contextIdx = 0);
void onVKBSubmit();
void onVKBCancel();
#endif
#ifdef MECK_WEB_READER
bool isOnWebReader() const { return curr == web_reader; }
@@ -0,0 +1,350 @@
#pragma once
// =============================================================================
// VirtualKeyboard — On-screen QWERTY keyboard for T5S3 (touch-only devices)
//
// Renders in virtual coordinate space (128×128). Touch hit testing converts
// physical GT911 coords (960×540) to virtual coords.
//
// Usage:
// keyboard.open("To: General", "", 137); // label, initial text, max len
// keyboard.render(display); // in render loop
// keyboard.handleTap(vx, vy); // on touch tap (virtual coords)
// if (keyboard.status() == VKB_SUBMITTED) { ... keyboard.getText() ... }
// =============================================================================
#if defined(LilyGo_T5S3_EPaper_Pro)
#ifndef VIRTUAL_KEYBOARD_H
#define VIRTUAL_KEYBOARD_H
#include <Arduino.h>
#include <helpers/ui/DisplayDriver.h>
enum VKBStatus { VKB_EDITING, VKB_SUBMITTED, VKB_CANCELLED };
// What the keyboard is being used for (dispatch on submit)
enum VKBPurpose {
VKB_CHANNEL_MSG, // Send to channel
VKB_DM, // Direct message to contact
VKB_ADMIN_PASSWORD, // Repeater admin login
VKB_ADMIN_CLI, // Repeater admin CLI command
VKB_NOTES, // Insert text into notes
VKB_SETTINGS_NAME // Edit node name
};
class VirtualKeyboard {
public:
static const int MAX_TEXT = 140;
VirtualKeyboard() : _status(VKB_CANCELLED), _purpose(VKB_CHANNEL_MSG),
_contextIdx(0), _textLen(0), _shifted(false), _symbols(false),
_rendered(false), _acceptTapsAfter(0) {
_text[0] = '\0';
_label[0] = '\0';
}
void open(VKBPurpose purpose, const char* label, const char* initial, int maxLen, int contextIdx = 0) {
_purpose = purpose;
_contextIdx = contextIdx;
_status = VKB_EDITING;
_shifted = false;
_symbols = false;
_rendered = false; // Not yet drawn
_acceptTapsAfter = 0; // Set after first render completes
_maxLen = (maxLen > 0 && maxLen < MAX_TEXT) ? maxLen : MAX_TEXT;
strncpy(_label, label, sizeof(_label) - 1);
_label[sizeof(_label) - 1] = '\0';
if (initial && initial[0]) {
strncpy(_text, initial, _maxLen);
_text[_maxLen] = '\0';
_textLen = strlen(_text);
} else {
_text[0] = '\0';
_textLen = 0;
}
}
VKBStatus status() const { return _status; }
VKBPurpose purpose() const { return _purpose; }
int contextIdx() const { return _contextIdx; }
const char* getText() const { return _text; }
int getTextLen() const { return _textLen; }
bool isActive() const { return _status == VKB_EDITING; }
// --- Render keyboard + input field ---
void render(DisplayDriver& display) {
// Mark as rendered — touch cooldown starts from first handleTap after this
_rendered = true;
// Header label (To: channel, DM: name, etc.)
display.setTextSize(0);
display.setColor(DisplayDriver::GREEN);
display.setCursor(2, 0);
display.print(_label);
// Input text field
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, 10, 128, 18); // Border
display.setCursor(2, 12);
display.setColor(DisplayDriver::LIGHT);
// Show text with cursor
char dispBuf[MAX_TEXT + 2];
snprintf(dispBuf, sizeof(dispBuf), "%s_", _text);
display.print(dispBuf);
// Character count
{
char countBuf[12];
snprintf(countBuf, sizeof(countBuf), "%d/%d", _textLen, _maxLen);
int cw = display.getTextWidth(countBuf);
display.setCursor(128 - cw - 2, 0);
display.setColor(DisplayDriver::LIGHT);
display.print(countBuf);
}
// Separator
display.drawRect(0, 30, 128, 1);
// --- Draw keyboard rows ---
const char* const* layout = getLayout();
for (int row = 0; row < 3; row++) {
int numKeys = strlen(layout[row]);
int rowY = KEY_START_Y + row * (KEY_H + KEY_GAP);
// Calculate key width and starting X for this row
int totalW = numKeys * KEY_W + (numKeys - 1) * KEY_GAP;
int startX = (128 - totalW) / 2;
for (int k = 0; k < numKeys; k++) {
int kx = startX + k * (KEY_W + KEY_GAP);
char ch = layout[row][k];
// Draw key background (inverted for special keys)
bool special = (ch == '<' || ch == '^' || ch == '~' || ch == '>' || ch == '\x01');
if (special) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(kx, rowY + 1, KEY_W, KEY_H - 1);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
display.drawRect(kx, rowY + 1, KEY_W, KEY_H - 1);
}
// Draw key label
char keyLabel[2] = { ch, '\0' };
// Remap special chars to display labels
if (ch == '<') keyLabel[0] = '<'; // Backspace
if (ch == '^') keyLabel[0] = '^'; // Shift
if (ch == '>') keyLabel[0] = '>'; // Enter
if (ch == '~') {
// Space key — don't draw individual label
} else if (ch == '\x01') {
// Symbol toggle in row — show "ab" hint
int lx = kx + KEY_W / 2 - display.getTextWidth("ab") / 2;
display.setCursor(lx, rowY + 2);
display.print("ab");
} else {
int lx = kx + KEY_W / 2 - display.getTextWidth(keyLabel) / 2;
display.setCursor(lx, rowY + 2);
display.print(keyLabel);
}
// Restore color
display.setColor(DisplayDriver::LIGHT);
}
}
// Draw row 4 with variable-width keys
int r4y = KEY_START_Y + 3 * (KEY_H + KEY_GAP);
drawRow4(display, r4y);
// Shift/symbol indicator
display.setTextSize(0);
display.setColor(DisplayDriver::GREEN);
if (_shifted) {
display.setCursor(2, 126);
display.print("SHIFT");
} else if (_symbols) {
display.setCursor(2, 126);
display.print("123");
}
}
// --- Handle touch tap (virtual coordinates) ---
// Returns true if the tap was consumed
bool handleTap(int vx, int vy) {
if (_status != VKB_EDITING) return false;
// Don't accept taps until keyboard has been rendered at least once
if (!_rendered) return true;
// Start cooldown timer on first call after render — this runs AFTER
// endFrame()'s blocking e-ink refresh, so the timer counts real
// post-render time (not time spent refreshing the display)
if (_acceptTapsAfter == 0) {
_acceptTapsAfter = millis() + 500;
return true; // consume this tap (residual from long press)
}
if (millis() < _acceptTapsAfter) return true; // still in cooldown
// Check keyboard rows 0-2
const char* const* layout = getLayout();
for (int row = 0; row < 3; row++) {
int numKeys = strlen(layout[row]);
int rowY = KEY_START_Y + row * (KEY_H + KEY_GAP);
if (vy < rowY || vy >= rowY + KEY_H) continue;
int totalW = numKeys * KEY_W + (numKeys - 1) * KEY_GAP;
int startX = (128 - totalW) / 2;
for (int k = 0; k < numKeys; k++) {
int kx = startX + k * (KEY_W + KEY_GAP);
if (vx >= kx && vx < kx + KEY_W) {
char ch = layout[row][k];
processKey(ch);
return true;
}
}
return true; // Tap was in row area but between keys — consume
}
// Check row 4 (variable width keys)
int r4y = KEY_START_Y + 3 * (KEY_H + KEY_GAP);
if (vy >= r4y && vy < r4y + KEY_H) {
return handleRow4Tap(vx);
}
return false;
}
// Swipe up on keyboard = cancel
void cancel() { _status = VKB_CANCELLED; }
private:
VKBStatus _status;
VKBPurpose _purpose;
int _contextIdx;
char _text[MAX_TEXT + 1];
int _textLen;
int _maxLen;
char _label[40];
bool _shifted;
bool _symbols;
bool _rendered;
unsigned long _acceptTapsAfter;
// Layout constants (virtual coords)
static const int KEY_W = 11;
static const int KEY_H = 19;
static const int KEY_GAP = 1;
static const int KEY_START_Y = 34;
// Key layouts — rows 0-2 as char arrays
// Special: ^ = shift, < = backspace, # = symbols, > = enter, ~ = space
const char* const* getLayout() const {
static const char* const lower[3] = { "qwertyuiop", "asdfghjkl", "^zxcvbnm<" };
static const char* const upper[3] = { "QWERTYUIOP", "ASDFGHJKL", "^ZXCVBNM<" };
static const char* const syms[3] = { "1234567890", "-/:;()@$&#", "\x01.,?!'\"_<" };
return _symbols ? syms : (_shifted ? upper : lower);
}
// Row 4: variable-width keys [#/ABC] [,] [SPACE] [.] [Enter]
// Defined by physical zones, not the char-array approach
struct R4Key { int x; int w; char ch; const char* label; };
void drawRow4(DisplayDriver& display, int y) {
// # or ABC toggle: x=4, w=20
// comma: x=26, w=11
// space: x=39, w=50
// period: x=91, w=11
// enter: x=104, w=20
const R4Key keys[] = {
{ 4, 20, '\x01', _symbols ? "ABC" : "123" },
{ 26, 11, ',', "," },
{ 39, 50, '~', "space" },
{ 91, 11, '.', "." },
{ 104, 20, '>', "Send" }
};
for (int i = 0; i < 5; i++) {
bool special = (keys[i].ch == '\x01' || keys[i].ch == '>');
if (special) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(keys[i].x, y + 1, keys[i].w, KEY_H - 1);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
display.drawRect(keys[i].x, y + 1, keys[i].w, KEY_H - 1);
}
// Center label in key
display.setTextSize(0);
int lw = display.getTextWidth(keys[i].label);
int lx = keys[i].x + (keys[i].w - lw) / 2;
display.setCursor(lx, y + 2);
display.print(keys[i].label);
display.setColor(DisplayDriver::LIGHT);
}
}
bool handleRow4Tap(int vx) {
const R4Key keys[] = {
{ 4, 20, '\x01', nullptr },
{ 26, 11, ',', nullptr },
{ 39, 50, '~', nullptr },
{ 91, 11, '.', nullptr },
{ 104, 20, '>', nullptr }
};
for (int i = 0; i < 5; i++) {
if (vx >= keys[i].x && vx < keys[i].x + keys[i].w) {
processKey(keys[i].ch);
return true;
}
}
return true; // Consume tap in row area
}
void processKey(char ch) {
if (ch == '^') {
// Shift toggle
_shifted = !_shifted;
_symbols = false;
} else if (ch == '\x01') {
// Symbol/letter toggle
_symbols = !_symbols;
_shifted = false;
} else if (ch == '<') {
// Backspace
if (_textLen > 0) {
_textLen--;
_text[_textLen] = '\0';
}
} else if (ch == '>') {
// Enter/Send
_status = VKB_SUBMITTED;
} else if (ch == '~') {
// Space
if (_textLen < _maxLen) {
_text[_textLen++] = ' ';
_text[_textLen] = '\0';
}
} else {
// Regular character
if (_textLen < _maxLen) {
_text[_textLen++] = ch;
_text[_textLen] = '\0';
// Auto-unshift after typing one character
if (_shifted) _shifted = false;
}
}
}
};
#endif // VIRTUAL_KEYBOARD_H
#endif // LilyGo_T5S3_EPaper_Pro