From 753d125384125718f1e777ee1a3c87f42be86203 Mon Sep 17 00:00:00 2001 From: pelgraine <140762863+pelgraine@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:24:56 +1100 Subject: [PATCH] t5s3 touch mapping fix; ui fixed for repeateradminscreen; highlighting fixed for notes and discovery screen; t5s3 initial virtual keyboard implementation --- examples/companion_radio/main.cpp | 135 +++++-- .../companion_radio/ui-new/ChannelScreen.h | 18 +- .../companion_radio/ui-new/Contactsscreen.h | 11 +- .../companion_radio/ui-new/Discoveryscreen.h | 4 + examples/companion_radio/ui-new/Notesscreen.h | 57 ++- .../ui-new/Repeateradminscreen.h | 40 ++ .../companion_radio/ui-new/Textreaderscreen.h | 2 +- examples/companion_radio/ui-new/UITask.cpp | 148 +++++++- examples/companion_radio/ui-new/UITask.h | 16 + .../companion_radio/ui-new/virtualkeyboard.h | 350 ++++++++++++++++++ 10 files changed, 743 insertions(+), 38 deletions(-) create mode 100644 examples/companion_radio/ui-new/virtualkeyboard.h diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 3b0427c4..6ca6ab1d 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -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 diff --git a/examples/companion_radio/ui-new/ChannelScreen.h b/examples/companion_radio/ui-new/ChannelScreen.h index 25cf794c..85c7968e 100644 --- a/examples/companion_radio/ui-new/ChannelScreen.h +++ b/examples/companion_radio/ui-new/ChannelScreen.h @@ -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) { diff --git a/examples/companion_radio/ui-new/Contactsscreen.h b/examples/companion_radio/ui-new/Contactsscreen.h index a99096d9..39d27ead 100644 --- a/examples/companion_radio/ui-new/Contactsscreen.h +++ b/examples/companion_radio/ui-new/Contactsscreen.h @@ -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); diff --git a/examples/companion_radio/ui-new/Discoveryscreen.h b/examples/companion_radio/ui-new/Discoveryscreen.h index 0c09b0b2..55565212 100644 --- a/examples/companion_radio/ui-new/Discoveryscreen.h +++ b/examples/companion_radio/ui-new/Discoveryscreen.h @@ -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); diff --git a/examples/companion_radio/ui-new/Notesscreen.h b/examples/companion_radio/ui-new/Notesscreen.h index d2bced7f..bad3c631 100644 --- a/examples/companion_radio/ui-new/Notesscreen.h +++ b/examples/companion_radio/ui-new/Notesscreen.h @@ -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) ---- diff --git a/examples/companion_radio/ui-new/Repeateradminscreen.h b/examples/companion_radio/ui-new/Repeateradminscreen.h index f8b39e98..79b52e6f 100644 --- a/examples/companion_radio/ui-new/Repeateradminscreen.h +++ b/examples/companion_radio/ui-new/Repeateradminscreen.h @@ -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); diff --git a/examples/companion_radio/ui-new/Textreaderscreen.h b/examples/companion_radio/ui-new/Textreaderscreen.h index c4c9c242..5988d04b 100644 --- a/examples/companion_radio/ui-new/Textreaderscreen.h +++ b/examples/companion_radio/ui-new/Textreaderscreen.h @@ -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"); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 3a2fb99d..d8f93d47 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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() { diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index c94ed4ba..8e38519c 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -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; } diff --git a/examples/companion_radio/ui-new/virtualkeyboard.h b/examples/companion_radio/ui-new/virtualkeyboard.h new file mode 100644 index 00000000..bab2fb0a --- /dev/null +++ b/examples/companion_radio/ui-new/virtualkeyboard.h @@ -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 +#include + +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 \ No newline at end of file