diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index cf915200..78da97cd 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -64,6 +64,16 @@ jobs: - name: Verify ESP32 USB sleep and G3 button wake run: python3 -B test/test_esp32_usb_sleep.py + - name: Verify message reader buttons, touch targets, and footer layouts + working-directory: test + run: >- + python3 -B -m unittest + test_message_navigation test_companion_john test_reader_touch_coordinates + test_touch_debug_overlay test_touch_debug_cli test_color_theme + test_indicator_messages_profile test_t096_message_footer_profile + test_indicator_display_profile test_indicator_render_profile + test_indicator_exclusive_transport test_companion_transport_selector + - name: Verify logging sleep guards and instructions run: | python3 -B test/test_logging_sleep_contract.py diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index dbe1da90..7d958997 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -83,6 +83,7 @@ over the normal binary USB, BLE, or TCP connection: | [`get/set wifi.powersave`](cli_commands.md#browser-configuration-portal-esp32-repeater-and-room-server) | ESP32 WiFi Companion; active transport constraints still apply | | [`get/set bluetooth.name`](cli_commands.md#view-or-change-the-independent-bluetooth-name-companion) | Companion firmware | | [`get/set bluetooth.mac`](cli_commands.md#view-or-change-the-bluetooth-address-ble-companion) | Every Companion build with Bluetooth | +| [`get/set display.touch`](cli_commands.md#show-touchscreen-touch-areas) | Touchscreen Companions using the shared UI; text terminal and framed CLI; off after reboot | | [`uf2reset`](cli_commands.md#enter-the-uf2-bootloader-nrf52-only) | Every nRF52 Companion text terminal and local command `0x42`; local only | Deprecated binary aliases remain receive-only for older clients; new clients diff --git a/docs/cli_commands.md b/docs/cli_commands.md index a27fc79e..420b5da6 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -713,6 +713,31 @@ board's compiled default. Unsupported display drivers return an error. --- +## Show touchscreen touch areas + +All touchscreen Companion builds using the shared touch UI support temporary +diagnostic outlines, including both SenseCAP Indicator rendering sizes: + +```text +get display.touch +set display.touch on +set display.touch off +``` + +The default is **off at every boot**. Enabling wakes the display without +selecting anything. Dotted yellow borders mark the actual scaled tap targets; +the area under a held finger turns green and returns to yellow on release. +Disabling removes the borders on the next redraw. Pairing screens suppress the +overlay while they consume navigation. This option does not change the font, +touch calibration, navigation actions, or saved settings, and is not persisted. + +Use the text terminal or Companion framed CLI (`0x42`) through an available +USB, BLE, or TCP connection. Non-touchscreen, missing-display, and legacy UI +builds report `Error: touchscreen diagnostics unsupported`. No Indicator-only +build flag is needed. + +--- + ## Set MQTT observer display timeout and flip MQTT observer builds with a display support a persisted inactivity timeout: diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index 354b8442..c28739e8 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -91,6 +91,14 @@ public: (void)degrees; return false; } + // Optional, runtime-only touch diagnostics. Non-touch and legacy UIs + // explicitly report unsupported rather than accepting an invisible toggle. + virtual bool supportsTouchDebug() const { return false; } + virtual bool isTouchDebugEnabled() const { return false; } + virtual bool setTouchDebugEnabled(bool enabled) { + (void)enabled; + return false; + } // Display implementations that surface an incoming BLE passkey request can // override these hooks. The default keeps setup-screen routing compatible // with older UIs that handle pairing only from their regular loop. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 3bfaf235..e48ebc59 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2546,6 +2546,31 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, return true; } + if (strcmp(command, "get display.touch") == 0) { + if (_ui == NULL || !_ui->supportsTouchDebug()) { + snprintf(reply, reply_size, "Error: touchscreen diagnostics unsupported"); + } else { + snprintf(reply, reply_size, "display.touch %s (off after reboot)", + _ui->isTouchDebugEnabled() ? "on" : "off"); + } + return true; + } + if (strncmp(command, "set display.touch", 17) == 0 + && (command[17] == 0 || command[17] == ' ' || command[17] == '\t')) { + const char* value = command + 17; + while (*value == ' ' || *value == '\t') value++; + if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { + snprintf(reply, reply_size, "Error: use set display.touch on|off"); + } else if (_ui == NULL || !_ui->supportsTouchDebug()) { + snprintf(reply, reply_size, "Error: touchscreen diagnostics unsupported"); + } else if (!_ui->setTouchDebugEnabled(strcmp(value, "on") == 0)) { + snprintf(reply, reply_size, "Error: touchscreen diagnostics unavailable"); + } else { + snprintf(reply, reply_size, "OK - display.touch %s (off after reboot)", value); + } + return true; + } + if (strcmp(command, "get display.rotation") == 0) { if (_ui == NULL || !_ui->supportsDisplayRotation()) { snprintf(reply, reply_size, "Error: display rotation is unsupported"); @@ -7897,6 +7922,8 @@ void MyMesh::handleTerminalCommand(char* command) { #endif terminalOutput().print(" get display.rotation\r\n"); terminalOutput().print(" set display.rotation <0|90|180|270>\r\n"); + terminalOutput().print(" get display.touch\r\n"); + terminalOutput().print(" set display.touch (this boot only)\r\n"); terminalOutput().print(" set {name|lat|lon|freq|tx|af} {value}\r\n"); terminalOutput().print(" get bluetooth.name\r\n"); terminalOutput().print(" set bluetooth.name \r\n"); diff --git a/examples/companion_radio/ui-new/JohnReaderScreen.h b/examples/companion_radio/ui-new/JohnReaderScreen.h index 7ffe334e..6f703963 100644 --- a/examples/companion_radio/ui-new/JohnReaderScreen.h +++ b/examples/companion_radio/ui-new/JohnReaderScreen.h @@ -14,6 +14,9 @@ class JohnReaderScreen : public UIScreen { mesh::bible::ReaderBookmark _bookmark; uint32_t _retry_at = 0; bool _loaded = false; +#if UI_READER_TOUCH_BAR + mesh::ui::TouchNavigationBar _touch_bar; +#endif __attribute__((noinline)) void process(int direction, bool draw) { using namespace mesh::bible; @@ -27,7 +30,14 @@ class JohnReaderScreen : public UIScreen { // Narrow/rotated panels need separate reference and progress lines. const bool stacked_header = width < _display->getTextWidth("88:88") + _display->getTextWidth("88/88") + 4; - const int top = header_height * (stacked_header ? 2 : 1) + 2; + const int header_content_height = header_height * (stacked_header ? 2 : 1); +#if UI_READER_TOUCH_BAR + const int padded_header_height = mesh::ui::readerTouchHeaderHeight(header_content_height); +#else + const int padded_header_height = header_content_height; +#endif + const int header_text_y = (padded_header_height - header_content_height) / 2; + const int top = padded_header_height + 2; #if UI_SMALL_MESSAGE_FONT || UI_BUTTON_READER_HINT // Share message font selection and metrics, including rotated/tiny panels. mesh::ui::SmallMessageText compact(*_display); @@ -39,11 +49,16 @@ class JohnReaderScreen : public UIScreen { const bool small_hint = _display->useSmallMessageFont(); DisplayDriver& hint_text = small_hint ? static_cast(compact) : *_display; +#if UI_READER_TOUCH_BAR + _touch_bar = mesh::ui::makeReaderTouchBar(hint_text, bottom, top - 1); + bottom = _touch_bar.top; +#else const auto hint = mesh::ui::makeButtonReaderHintLayout(hint_text, small_hint ? compact.glyphHeight() : header_height, bottom, (millis() / 3000U) % 2 != 0); bottom = hint.top; #endif +#endif #if UI_SMALL_MESSAGE_FONT const bool small = _display->useSmallMessageFont(); DisplayDriver& body = small ? static_cast(compact) : *_display; @@ -71,7 +86,11 @@ class JohnReaderScreen : public UIScreen { body.setColor(UIColor::warning_txt); body.drawTextEllipsized(0, top, width, "John unavailable"); #if UI_BUTTON_READER_HINT +#if UI_READER_TOUCH_BAR + mesh::ui::drawReaderTouchBar(hint_text, _touch_bar); +#else mesh::ui::drawButtonReaderHint(hint_text, hint); +#endif #endif } else _task->showAlert("John data error", 1500); return; @@ -101,8 +120,8 @@ class JohnReaderScreen : public UIScreen { if (stacked_header || _display->getTextWidth(label) > width - progress_width - 4) snprintf(label, sizeof(label), "%u:%u", ref.chapter, ref.verse); _display->setColor(UIColor::title_txt); - _display->drawTextEllipsized(0, 0, stacked_header ? width : width - progress_width - 4, label); - _display->drawTextRightAlign(width, stacked_header ? header_height : 0, progress); + _display->drawTextEllipsized(0, header_text_y, stacked_header ? width : width - progress_width - 4, label); + _display->drawTextRightAlign(width, header_text_y + (stacked_header ? header_height : 0), progress); _display->drawRect(0, top - 2, width, 1); body.setColor(UIColor::primary_txt); uint16_t offset = page.start; @@ -114,13 +133,20 @@ class JohnReaderScreen : public UIScreen { body.print(filtered); } #if UI_BUTTON_READER_HINT +#if UI_READER_TOUCH_BAR + mesh::ui::drawReaderTouchBar(hint_text, _touch_bar); +#else mesh::ui::drawButtonReaderHint(hint_text, hint); +#endif #endif return; } } public: +#if UI_READER_TOUCH_BAR + const mesh::ui::TouchNavigationBar* readerTouchBar() const { return &_touch_bar; } +#endif JohnReaderScreen(UITask* task, DisplayDriver* display) : _task(task), _display(display) {} void open() { if (!_loaded) { diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 911c4f58..d2cbb428 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -4,6 +4,9 @@ #include #include #include +#ifdef HAS_TOUCH + #include +#endif #include #if UI_SMALL_MESSAGE_FONT == 1 #include @@ -85,12 +88,14 @@ static uint64_t companionMessageElapsedMillis(uint64_t heard_millis) { #define SELECT_LABEL "HOLD" #endif +#ifndef UI_BUTTON_READER_HINT #if !defined(HAS_TOUCH) && !UI_HAS_JOYSTICK && !defined(UI_HAS_NAV_INPUT) \ && (defined(PIN_USER_BTN) || defined(PIN_USER_BTN_ANA)) #define UI_BUTTON_READER_HINT 1 #else #define UI_BUTTON_READER_HINT 0 #endif +#endif #if COMPANION_FEATURE_JOHN #include "JohnReaderScreen.h" @@ -114,7 +119,14 @@ static void drawCompanionTransportChoice(DisplayDriver& display, const bool large_transport_text = display.height() >= 96; const bool show_status_label = height >= 44; - if (large_transport_text) { + if (large_transport_text && width < 76) { + // Fit choices inside the central tap zone without consuming page arrows. + int size = 2; + display.setTextSize(size); + while (size > 1 && display.getTextWidth(label) > width - 4) + display.setTextSize(--size); + display.drawTextCentered(x + width / 2, y + 20, label); + } else if (large_transport_text) { display.setTextSize(4); if (strcmp(label, "WiFi") == 0) { // A single size-4 "WiFi" row is wider than one half of the screen. @@ -131,6 +143,13 @@ static void drawCompanionTransportChoice(DisplayDriver& display, } if (show_status_label && (active || selected)) { display.setTextSize(large_transport_text ? 3 : 1); + const char* status = active ? (large_transport_text ? "ON" : "ACTIVE") + : (large_transport_text ? "NEXT" : "NEXT BOOT"); + if (large_transport_text) { + int size = 3; + while (size > 1 && display.getTextWidth(status) > width - 4) + display.setTextSize(--size); + } display.drawTextCentered( x + width / 2, y + height - (large_transport_text ? 25 : 17), @@ -885,11 +904,15 @@ public: == CompanionTransportMode::WiFi; const mesh::ui::CompanionTransportSelectorLayout layout = mesh::ui::makeCompanionTransportSelectorLayout( - display.width(), display.height()); + display.width(), display.height() +#ifdef HAS_TOUCH + , TOUCH_CENTER_ZONE_PERCENT +#endif + ); // Keep this page independent of the additional native-480 text boost. - // Its deliberately reflowed size-4 choices then retain the same large - // physical dimensions in the 320 and 480 render profiles. + // Fit the choices inside the center zone at identical physical sizes + // in the 320 and 480 render profiles, leaving page edges available. display.setCompactText(true); if (layout.show_title) { display.setColor(UIColor::primary_txt); @@ -911,7 +934,7 @@ public: #ifdef HAS_TOUCH display.drawTextCentered( display.width() / 2, layout.prompt_y, - layout.show_title ? "TAP SIDE" : "tap a box"); + layout.show_title ? "TAP A BOX" : "tap a box"); #else display.drawTextCentered( display.width() / 2, layout.prompt_y, PRESS_LABEL); @@ -1089,17 +1112,19 @@ public: } bool handleInput(char c) override { - if (c == KEY_LEFT || c == KEY_PREV) { + // Navigation codes exceed 0x7f; keep them valid with signed char too. + const uint8_t key = static_cast(c); + if (key == KEY_LEFT || key == KEY_PREV) { _page = (_page + HomePage::Count - 1) % HomePage::Count; return true; } #if COMPANION_FEATURE_JOHN - if (c == KEY_ENTER && _page == HomePage::RADIO) { + if (key == KEY_ENTER && _page == HomePage::RADIO) { _task->showJohnReader(); return true; } #endif - if (c == KEY_NEXT || c == KEY_RIGHT) { + if (key == KEY_NEXT || key == KEY_RIGHT) { _page = (_page + 1) % HomePage::Count; if (_page == HomePage::RECENT) { _task->showAlert("Recent adverts", 800); @@ -1108,15 +1133,15 @@ public: } #ifdef COMPANION_EXCLUSIVE_WIFI_BLE if (_page == HomePage::TRANSPORT - && (c == KEY_ENTER || c == KEY_UP || c == KEY_DOWN)) { + && (key == KEY_ENTER || key == KEY_UP || key == KEY_DOWN)) { const CompanionTransportMode selected = getCompanionTransportMode(); const CompanionTransportMode active = isCompanionWiFiEnabled() ? CompanionTransportMode::WiFi : CompanionTransportMode::Bluetooth; CompanionTransportMode requested = selected; - if (c == KEY_UP) { + if (key == KEY_UP) { requested = CompanionTransportMode::WiFi; - } else if (c == KEY_DOWN) { + } else if (key == KEY_DOWN) { requested = CompanionTransportMode::Bluetooth; } else { requested = active == CompanionTransportMode::WiFi @@ -1133,7 +1158,7 @@ public: return true; } #else - if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) { + if (key == KEY_ENTER && _page == HomePage::BLUETOOTH) { if (_task->isBluetoothEnabled()) { // toggle Bluetooth on/off _task->disableBluetooth(); } else { @@ -1142,18 +1167,18 @@ public: return true; } #endif - if (c == KEY_ENTER && _page == HomePage::FIRST) { + if (key == KEY_ENTER && _page == HomePage::FIRST) { _task->showMessages(); return true; } #if UI_MESSAGES_HOME_PAGE == 1 - if (c == KEY_ENTER && _page == HomePage::MESSAGES) { + if (key == KEY_ENTER && _page == HomePage::MESSAGES) { _task->showMessages(); return true; } #endif #if UI_WIFI_SETUP_HOME_PAGE == 1 - if (c == KEY_ENTER && _page == HomePage::WIFI_SETUP) { + if (key == KEY_ENTER && _page == HomePage::WIFI_SETUP) { if (WebConfigServer::getSetupInfo(nullptr, 0, nullptr, 0)) { requestCompanionWiFiSetupStop(); _task->showAlert("Stopping setup AP", 1000); @@ -1164,7 +1189,7 @@ public: return true; } #endif - if (c == KEY_ENTER && _page == HomePage::ADVERT) { + if (key == KEY_ENTER && _page == HomePage::ADVERT) { _task->notify(UIEventType::ack); if (the_mesh.advert()) { _task->showAlert("Advert sent!", 1000); @@ -1174,20 +1199,20 @@ public: return true; } #if ENV_INCLUDE_GPS == 1 - if (c == KEY_ENTER && _page == HomePage::GPS) { + if (key == KEY_ENTER && _page == HomePage::GPS) { _task->toggleGPS(); return true; } #endif #if UI_SENSORS_PAGE == 1 - if (c == KEY_ENTER && _page == HomePage::SENSORS) { + if (key == KEY_ENTER && _page == HomePage::SENSORS) { _task->toggleGPS(); next_sensors_refresh=0; return true; } #endif #ifndef UI_NO_HIBERNATE - if (c == KEY_ENTER && _page == HomePage::SHUTDOWN) { + if (key == KEY_ENTER && _page == HomePage::SHUTDOWN) { _shutdown_init = true; // need to wait for button to be released return true; } @@ -1218,6 +1243,9 @@ public: class MsgPreviewScreen : public UIScreen { UITask* _task; +#if UI_READER_TOUCH_BAR + mesh::ui::TouchNavigationBar _touch_bar; +#endif static constexpr int CHANNEL_FILTER_ALL = -2; static constexpr int CHANNEL_FILTER_DIRECT = -1; @@ -1347,6 +1375,9 @@ class MsgPreviewScreen : public UIScreen { } public: +#if UI_READER_TOUCH_BAR + const mesh::ui::TouchNavigationBar* readerTouchBar() const { return &_touch_bar; } +#endif explicit MsgPreviewScreen(UITask* task) : _task(task), view_offset(0), channel_filter(CHANNEL_FILTER_ALL) {} @@ -1446,6 +1477,7 @@ public: int body_bottom = UI_MESSAGE_CHANNEL_FOOTER == 1 ? display.height() - layout.filter_height : display.height(); display.setCompactText(layout.compact_text); + int header_text_y = 0; #if UI_BUTTON_READER_HINT == 1 #if UI_SMALL_MESSAGE_FONT == 1 mesh::ui::SmallMessageText compact(display); @@ -1462,12 +1494,26 @@ public: } #else DisplayDriver& reader_text = display; + #if !UI_READER_TOUCH_BAR const int hint_line_height = display.textLineHeight(); #endif + #endif +#if UI_READER_TOUCH_BAR + const int header_extra = mesh::ui::readerTouchHeaderHeight( + layout.header_divider_y + 1) - (layout.header_divider_y + 1); + header_text_y = header_extra / 2; + layout.header_divider_y += header_extra; + layout.origin_y += header_extra; + layout.message_y += header_extra; + _touch_bar = mesh::ui::makeReaderTouchBar(reader_text, body_bottom, + layout.header_divider_y + 1); + body_bottom = _touch_bar.top; +#else const mesh::ui::ButtonReaderHintLayout hint = mesh::ui::makeButtonReaderHintLayout( reader_text, hint_line_height, body_bottom); body_bottom = hint.top; +#endif DisplayDriver& header = reader_text; #else DisplayDriver& header = display; @@ -1475,9 +1521,9 @@ public: char tmp[24]; int filtered_count = filteredCount(); if (view_offset >= filtered_count) view_offset = 0; - header.setCursor(0, 0); + header.setCursor(0, header_text_y); header.setColor(UIColor::corp_blue); -#if UI_BUTTON_READER_HINT == 1 +#if UI_BUTTON_READER_HINT == 1 && UI_MESSAGE_CHANNEL_FOOTER == 0 // Keep the selected filter visible even when that channel has no messages. char channel[12]; if (channel_filter == CHANNEL_FILTER_ALL) strcpy(channel, "All"); @@ -1504,7 +1550,7 @@ public: && header.getTextWidth(tmp) + header.getTextWidth(age) + 4 <= display.width(); const int header_width = display.width() - (show_age ? header.getTextWidth(age) + 4 : 0); - header.drawTextEllipsized(0, 0, header_width, tmp); + header.drawTextEllipsized(0, header_text_y, header_width, tmp); if (p == nullptr) { display.drawRect(0, layout.header_divider_y, display.width(), 1); @@ -1514,7 +1560,11 @@ public: display.setCompactText(layout.compact_text); reader_text.drawTextEllipsized(0, layout.origin_y, display.width(), "No buffered messages"); +#if UI_READER_TOUCH_BAR + mesh::ui::drawReaderTouchBar(reader_text, _touch_bar); +#else mesh::ui::drawButtonReaderHint(reader_text, hint); +#endif display.setCompactText(false); #else display.drawTextCentered(display.width() / 2, 40, @@ -1525,7 +1575,7 @@ public: } if (show_age) { - header.setCursor(display.width() - header.getTextWidth(age) - 2, 0); + header.setCursor(display.width() - header.getTextWidth(age) - 2, header_text_y); header.print(age); } @@ -1540,7 +1590,7 @@ public: display.setColor(UIColor::secondary_txt); char filtered_origin[sizeof(p->origin)]; display.translateUTF8ToBlocks(filtered_origin, p->origin, sizeof(filtered_origin)); - display.print(filtered_origin); + display.drawTextEllipsized(0, layout.origin_y, display.width(), filtered_origin); display.setCursor(0, layout.message_y); display.setColor(UIColor::primary_txt); @@ -1557,7 +1607,11 @@ public: #if UI_BUTTON_READER_HINT == 1 display.setCompactText(layout.compact_text); +#if UI_READER_TOUCH_BAR + mesh::ui::drawReaderTouchBar(reader_text, _touch_bar); +#else mesh::ui::drawButtonReaderHint(reader_text, hint); +#endif display.setCompactText(false); #endif renderChannelFilter(display); @@ -1928,6 +1982,17 @@ bool UITask::isButtonPressed() const { #endif } +bool UITask::isButtonGesturePending() const { +#if UI_DEFER_RENDER_DURING_BUTTON_GESTURE && defined(PIN_USER_BTN) + // RGB painting/presentation is synchronous. Service the polled button + // through debounce and the final multi-click deadline before redrawing. + // Check the raw level too, in case an edge arrived since this loop's poll. + return user_btn.needsPolling() || user_btn.isPressed(); +#else + return false; +#endif +} + void UITask::loop() { serviceWiFiToggleButton(); servicePairingState(); @@ -2025,26 +2090,33 @@ void UITask::loop() { const bool touched = _display->getTouch(&touch_x, &touch_y); mesh::ui::TouchSplitSelector transport_touch_selector = {}; const mesh::ui::TouchSplitSelector* split_transport_selector = nullptr; -#ifdef COMPANION_EXCLUSIVE_WIFI_BLE - if (curr == home - && static_cast(home)->isTransportSelectorPage()) { - const mesh::ui::CompanionTransportSelectorLayout layout = - mesh::ui::makeCompanionTransportSelectorLayout( - _display->width(), _display->height()); - transport_touch_selector = { - layout.wifi.x, - layout.wifi.width, - layout.bluetooth.x, - layout.bluetooth.width, - layout.wifi.y, - layout.wifi.height, - }; - split_transport_selector = &transport_touch_selector; + const mesh::ui::TouchNavigationBar* reader_touch_bar = nullptr; + getTouchControls(transport_touch_selector, split_transport_selector, reader_touch_bar); + // A sleeping reader still accepts an ordinary wake gesture anywhere; + // checkDisplayOn consumes it before navigation. Awake readers use their + // rendered header/footer targets and page-only body taps. + if (!_display->isOn()) reader_touch_bar = nullptr; + if (_touch_debug_enabled) { + // Highlight the detected visual point, using the same X correction as + // stationary input. Redraw on press/release or a region change only, not + // every sample, so diagnostics do not continuously stall touch polling. + _touch_debug_x = touched + ? (TOUCH_MIRROR_TAP_X_ENABLED ? _display->width() - 1 - touch_x : touch_x) : -1; + _touch_debug_y = touched ? touch_y : -1; + const auto debug_areas = mesh::ui::makeTouchDebugAreas( + _display->width(), _display->height(), touch_input.centerZonePercent(), + curr == msg_preview && UI_MESSAGE_CHANNEL_FOOTER == 1 + && touch_input.hasSeparateVerticalSwipes(), + split_transport_selector, reader_touch_bar); + const int debug_area = mesh::ui::touchDebugAreaAt(debug_areas, _touch_debug_x, _touch_debug_y); + if (debug_area != _touch_debug_area || curr != _touch_debug_screen) _next_refresh = 0; + _touch_debug_area = debug_area; + _touch_debug_screen = curr; } -#endif const mesh::ui::TouchAction action = touch_input.update( touched, touch_x, touch_y, _display->width(), _display->height(), - curr == msg_preview, split_transport_selector); + curr == msg_preview && UI_MESSAGE_CHANNEL_FOOTER == 1, + split_transport_selector, reader_touch_bar); const bool on_transport_selector = split_transport_selector != nullptr; if (c == 0) { switch (action) { @@ -2118,7 +2190,7 @@ void UITask::loop() { } if (_display != NULL && _display->isOn()) { - if (millis() >= _next_refresh && curr) { + if (millis() >= _next_refresh && curr && !isButtonGesturePending()) { _display->startFrame(); int delay_millis = curr->render(*_display); renderPairingBanner(); @@ -2135,6 +2207,21 @@ void UITask::loop() { } else { _next_refresh = millis() + delay_millis; } +#ifdef HAS_TOUCH + if (_touch_debug_enabled && !isPairingScreenActive()) { + mesh::ui::TouchSplitSelector transport = {}; + const mesh::ui::TouchSplitSelector* split = nullptr; + const mesh::ui::TouchNavigationBar* reader = nullptr; + getTouchControls(transport, split, reader); + mesh::ui::drawTouchDebugAreas(*_display, + mesh::ui::makeTouchDebugAreas(_display->width(), _display->height(), + touch_input.centerZonePercent(), + curr == msg_preview && UI_MESSAGE_CHANNEL_FOOTER == 1 + && touch_input.hasSeparateVerticalSwipes(), split, reader), + _touch_debug_screen == curr ? _touch_debug_x : -1, + _touch_debug_screen == curr ? _touch_debug_y : -1); + } +#endif _display->endFrame(); } #if AUTO_OFF_MILLIS > 0 @@ -2185,6 +2272,37 @@ void UITask::loop() { #endif } +#ifdef HAS_TOUCH +void UITask::getTouchControls(mesh::ui::TouchSplitSelector& transport_touch_selector, + const mesh::ui::TouchSplitSelector*& split_transport_selector, + const mesh::ui::TouchNavigationBar*& reader_touch_bar) { + split_transport_selector = nullptr; + reader_touch_bar = nullptr; +#ifdef COMPANION_EXCLUSIVE_WIFI_BLE + if (curr == home + && static_cast(home)->isTransportSelectorPage()) { + const mesh::ui::CompanionTransportSelectorLayout layout = + mesh::ui::makeCompanionTransportSelectorLayout( + _display->width(), _display->height(), TOUCH_CENTER_ZONE_PERCENT); + transport_touch_selector = { + layout.wifi.x, layout.wifi.width, + layout.bluetooth.x, layout.bluetooth.width, + layout.wifi.y, layout.wifi.height, layout.side_nav_width, + }; + split_transport_selector = &transport_touch_selector; + } +#endif +#if UI_READER_TOUCH_BAR + if (curr == msg_preview) + reader_touch_bar = static_cast(msg_preview)->readerTouchBar(); +#if COMPANION_FEATURE_JOHN + else if (isJohnReaderActive()) + reader_touch_bar = static_cast(john_reader)->readerTouchBar(); +#endif +#endif +} +#endif + char UITask::checkDisplayOn(char c) { if (_display != NULL) { if (!_display->isOn()) { @@ -2231,6 +2349,7 @@ char UITask::handleDoubleClick(char c) { } char UITask::handleMultiClick(char c, bool backwards) { + MESH_DEBUG_PRINTLN("UITask: %s-click triggered", backwards ? "quadruple" : "triple"); if (curr == msg_preview #if COMPANION_FEATURE_JOHN || isJohnReaderActive() diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 15b1716c..e5e684ab 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -64,12 +64,22 @@ class UITask : public AbstractUITask { #else static constexpr bool TOUCH_MIRROR_TAP_X_ENABLED = false; #endif + #ifndef TOUCH_REVERSE_VERTICAL_SWIPE + #define TOUCH_REVERSE_VERTICAL_SWIPE TOUCH_REVERSE_SWIPE_ENABLED + #endif mesh::ui::TouchInput touch_input{ TOUCH_REVERSE_SWIPE_ENABLED, TOUCH_SEPARATE_VERTICAL_SWIPES_ENABLED, TOUCH_CENTER_ZONE_PERCENT, - TOUCH_MIRROR_TAP_X_ENABLED}; + TOUCH_MIRROR_TAP_X_ENABLED, + TOUCH_REVERSE_VERTICAL_SWIPE != 0}; unsigned long next_touch_check = 0; + void getTouchControls(mesh::ui::TouchSplitSelector& transport, + const mesh::ui::TouchSplitSelector*& split, + const mesh::ui::TouchNavigationBar*& reader); + bool _touch_debug_enabled = false; // Diagnostic only; reset at every boot. + int _touch_debug_x = -1, _touch_debug_y = -1, _touch_debug_area = -1; + UIScreen* _touch_debug_screen = nullptr; #endif #ifdef PIN_STATUS_LED int led_state = 0; @@ -96,6 +106,7 @@ class UITask : public AbstractUITask { char handleLongPress(char c); char handleDoubleClick(char c); char handleMultiClick(char c, bool backwards); + bool isButtonGesturePending() const; void setCurrScreen(UIScreen* c); bool isPairingScreenActive() const; @@ -133,6 +144,19 @@ public: int getPreviewCount() const; void renderMessageSummary(DisplayDriver& display) const; bool hasDisplay() const { return _display != NULL; } +#ifdef HAS_TOUCH + bool supportsTouchDebug() const override { return _display != nullptr; } + bool isTouchDebugEnabled() const override { return _touch_debug_enabled; } + bool setTouchDebugEnabled(bool enabled) override { + if (!supportsTouchDebug()) return false; + _touch_debug_enabled = enabled; + _touch_debug_x = _touch_debug_y = _touch_debug_area = -1; + _touch_debug_screen = nullptr; + _next_refresh = 0; // Also removes already-drawn outlines when disabling. + if (enabled) checkDisplayOn(0); // Wake without selecting or navigating. + return true; + } +#endif bool supportsDisplayRotation() const override { return _display != NULL && _display->supportsRotation(); } diff --git a/src/helpers/ui/ColorTheme.h b/src/helpers/ui/ColorTheme.h index 36c560bb..0251f13d 100644 --- a/src/helpers/ui/ColorTheme.h +++ b/src/helpers/ui/ColorTheme.h @@ -32,6 +32,8 @@ constexpr ColorVal WARNING_TEXT = rgb565(248, 176, 72); // semantic slots by their RGB565 value before selecting a palette entry. constexpr ColorVal POPUP_BACKGROUND = rgb565(20, 48, 70); constexpr ColorVal ACCENT = rgb565(64, 176, 240); +constexpr ColorVal TOUCH_OUTLINE = rgb565(255, 255, 0); +constexpr ColorVal TOUCH_PRESSED = rgb565(0, 255, 0); enum IndexedColor : uint8_t { INDEX_BACKGROUND = 0, @@ -41,6 +43,8 @@ enum IndexedColor : uint8_t { INDEX_WARNING_TEXT = 4, INDEX_POPUP_BACKGROUND = 5, INDEX_ACCENT = 6, + INDEX_TOUCH_PRESSED = 8, + INDEX_TOUCH_OUTLINE = 11, }; } // namespace color_theme diff --git a/src/helpers/ui/CompanionTransportSelectorLayout.h b/src/helpers/ui/CompanionTransportSelectorLayout.h index 6773536c..5d9a54b6 100644 --- a/src/helpers/ui/CompanionTransportSelectorLayout.h +++ b/src/helpers/ui/CompanionTransportSelectorLayout.h @@ -16,10 +16,14 @@ struct CompanionTransportSelectorLayout { int title_y; int prompt_y; bool show_title; + int side_nav_width; }; inline CompanionTransportSelectorLayout makeCompanionTransportSelectorLayout( - int width, int height) { + int width, int height, int center_zone_percent = 100) { + if (center_zone_percent < 0) center_zone_percent = 0; + if (center_zone_percent > 100) center_zone_percent = 100; + const int side_nav_width = width * ((100 - center_zone_percent) / 2) / 100; const bool tall_display = height >= 96; const int margin = 2; const int gap = 4; @@ -28,15 +32,17 @@ inline CompanionTransportSelectorLayout makeCompanionTransportSelectorLayout( int box_height = prompt_y - box_y - (tall_display ? 3 : 8); if (box_height > 100) box_height = 100; if (box_height < 20) box_height = 20; - const int box_width = (width - margin * 2 - gap) / 2; - const int bluetooth_x = margin + box_width + gap; + const int available = width - side_nav_width * 2 - margin * 2 - gap; + const int box_width = available > 0 ? available / 2 : 0; + const int bluetooth_x = side_nav_width + margin + box_width + gap; return { - {margin, box_y, box_width, box_height}, + {side_nav_width + margin, box_y, box_width, box_height}, {bluetooth_x, box_y, box_width, box_height}, tall_display ? 14 : 0, prompt_y, tall_display, + side_nav_width, }; } diff --git a/src/helpers/ui/DisplayTouchCoordinates.h b/src/helpers/ui/DisplayTouchCoordinates.h new file mode 100644 index 00000000..457d89b5 --- /dev/null +++ b/src/helpers/ui/DisplayTouchCoordinates.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +namespace mesh { +namespace ui { + +// Map the rotated physical panel to the logical UI, exactly once. Both the +// native canvas and the zoomed fallback fill this same panel; their internal +// render resolution must not change the hit targets. Reject invalid samples +// before division (e.g. -1 / 3 would otherwise become a valid edge tap). +inline bool panelTouchToLogical(int panel_x, int panel_y, + int panel_width, int panel_height, + int logical_width, int logical_height, + int* x, int* y) { + if (x == nullptr || y == nullptr) return false; + *x = *y = -1; + if (panel_width <= 0 || panel_height <= 0 + || logical_width <= 0 || logical_height <= 0 + || panel_x < 0 || panel_x >= panel_width + || panel_y < 0 || panel_y >= panel_height) return false; + *x = static_cast(static_cast(panel_x) * logical_width / panel_width); + *y = static_cast(static_cast(panel_y) * logical_height / panel_height); + return true; +} + +} // namespace ui +} // namespace mesh diff --git a/src/helpers/ui/LGFXDisplay.cpp b/src/helpers/ui/LGFXDisplay.cpp index 94190b3e..5b077aea 100644 --- a/src/helpers/ui/LGFXDisplay.cpp +++ b/src/helpers/ui/LGFXDisplay.cpp @@ -1,6 +1,7 @@ #include "LGFXDisplay.h" #include "ColorTheme.h" #include "IndicatorRenderProfile.h" +#include "DisplayTouchCoordinates.h" #include #ifndef DISPLAY_ROTATION @@ -97,7 +98,9 @@ static const uint32_t UI_PALETTE[16] = { mesh::ui::color_theme::rgb888(mesh::ui::color_theme::POPUP_BACKGROUND), mesh::ui::color_theme::rgb888(mesh::ui::color_theme::ACCENT), 0xE53935, - 0x43A047, 0x1E88E5, 0x8E24AA, 0xFDD835, + mesh::ui::color_theme::rgb888(mesh::ui::color_theme::TOUCH_PRESSED), + 0x1E88E5, 0x8E24AA, + mesh::ui::color_theme::rgb888(mesh::ui::color_theme::TOUCH_OUTLINE), 0x6D4C41, 0x00ACC1, 0xF06292, 0xFF00FF, }; @@ -497,16 +500,14 @@ void LGFXDisplay::endFrame() { } bool LGFXDisplay::getTouch(int* x, int* y) { + if (x == nullptr || y == nullptr) return false; + *x = *y = -1; lgfx::v1::touch_point_t point = {}; if (display->getTouch(&point) == 0) return false; - if (_outputZoom * _coordinateScale != 1.0f) { - *x = point.x / (_outputZoom * _coordinateScale); - *y = point.y / (_outputZoom * _coordinateScale); - } else { - *x = point.x; - *y = point.y; - } - return *x >= 0 && *x < width() && *y >= 0 && *y < height(); + // LovyanGFX has already applied panel rotation and touch calibration. Use + // its live output dimensions, not the sprite resolution or build flags. + return mesh::ui::panelTouchToLogical(point.x, point.y, + display->width(), display->height(), width(), height(), x, y); } bool LGFXDisplay::installRuntimeFont(uint8_t* data, size_t size) { @@ -548,6 +549,8 @@ uint32_t LGFXDisplay::renderColor(ColorVal color) const { if (color == UIColor::warning_txt) return INDEX_WARNING_TEXT; if (color == UIColor::popup_bkg) return INDEX_POPUP_BACKGROUND; if (color == UIColor::corp_blue) return INDEX_ACCENT; + if (color == TOUCH_OUTLINE) return INDEX_TOUCH_OUTLINE; + if (color == TOUCH_PRESSED) return INDEX_TOUCH_PRESSED; return color & 0x0F; #else return color; diff --git a/src/helpers/ui/MomentaryButton.cpp b/src/helpers/ui/MomentaryButton.cpp index 14951700..9f972585 100644 --- a/src/helpers/ui/MomentaryButton.cpp +++ b/src/helpers/ui/MomentaryButton.cpp @@ -1,6 +1,5 @@ #include "MomentaryButton.h" -#define MULTI_CLICK_WINDOW_MS 280 #define BUTTON_DEBOUNCE_MS 25 #if defined(NRF52_PLATFORM) && \ @@ -31,7 +30,7 @@ MomentaryButton::MomentaryButton(int8_t pin, int long_press_millis, bool reverse _press_active = false; _click_count = 0; _last_click_time = 0; - _multi_click_window = multiclick ? MULTI_CLICK_WINDOW_MS : 0; + _multi_click_window = multiclick ? MOMENTARY_BUTTON_MULTI_CLICK_MS : 0; _pending_click = false; _candidate_level = prev; _candidate_since = 0; @@ -50,7 +49,7 @@ MomentaryButton::MomentaryButton(int8_t pin, int long_press_millis, int analog_t _press_active = false; _click_count = 0; _last_click_time = 0; - _multi_click_window = MULTI_CLICK_WINDOW_MS; + _multi_click_window = MOMENTARY_BUTTON_MULTI_CLICK_MS; _pending_click = false; _candidate_level = prev; _candidate_since = 0; diff --git a/src/helpers/ui/MomentaryButton.h b/src/helpers/ui/MomentaryButton.h index ff02f22e..6a631a14 100644 --- a/src/helpers/ui/MomentaryButton.h +++ b/src/helpers/ui/MomentaryButton.h @@ -9,6 +9,10 @@ #define BUTTON_EVENT_TRIPLE_CLICK 4 #define BUTTON_EVENT_QUADRUPLE_CLICK 5 +#ifndef MOMENTARY_BUTTON_MULTI_CLICK_MS +#define MOMENTARY_BUTTON_MULTI_CLICK_MS 280 +#endif + #ifndef MOMENTARY_BUTTON_WAKE_HOLD_MS #define MOMENTARY_BUTTON_WAKE_HOLD_MS 0 #endif diff --git a/src/helpers/ui/ReaderNavigationHint.h b/src/helpers/ui/ReaderNavigationHint.h index 603aae76..61128dcf 100644 --- a/src/helpers/ui/ReaderNavigationHint.h +++ b/src/helpers/ui/ReaderNavigationHint.h @@ -1,6 +1,7 @@ #pragma once #include "DisplayDriver.h" +#include "TouchInput.h" namespace mesh { namespace ui { @@ -52,5 +53,39 @@ inline void drawButtonReaderHint(DisplayDriver& text, } } +// Enlarge the header exit target without enlarging text. Reserve this same +// height for content, touch detection and diagnostic outlines. +inline int readerTouchHeaderHeight(int content_height) { + return content_height + 8 < 24 ? 24 : content_height + 8; +} + +// Touch readers use five equal, full-width hit cells. A 24-unit row is 72 +// physical pixels high on both Indicator render profiles; keep the same font. +inline TouchNavigationBar makeReaderTouchBar(DisplayDriver& text, int bottom, + int exit_height = 0) { + TouchNavigationBar bar; + const int line_height = text.textLineHeight(); + bar.height = line_height + 4 < 24 ? 24 : line_height + 4; + if (bar.height > bottom) bar.height = bottom; + bar.top = bottom - bar.height; + bar.exit_height = exit_height < 0 ? 0 : exit_height > bar.top ? bar.top : exit_height; + return bar; +} + +inline void drawReaderTouchBar(DisplayDriver& text, const TouchNavigationBar& bar) { + static const char* const labels[] = {"4<<", "2<", ">1", ">>3", "X"}; + text.setColor(UIColor::window_bkg); + text.fillRect(0, bar.top, text.width(), bar.height); + text.setColor(UIColor::corp_blue); + text.drawRect(0, bar.top, text.width(), 1); + const int line_height = text.textLineHeight(); + const int y = bar.top + (bar.height - line_height) / 2; + for (int cell = 0; cell < 5; ++cell) { + const int left = text.width() * cell / 5; + const int right = text.width() * (cell + 1) / 5; + text.drawTextCentered((left + right) / 2, y, labels[cell]); + } +} + } // namespace ui } // namespace mesh diff --git a/src/helpers/ui/TouchDebugOverlay.h b/src/helpers/ui/TouchDebugOverlay.h new file mode 100644 index 00000000..c19a4763 --- /dev/null +++ b/src/helpers/ui/TouchDebugOverlay.h @@ -0,0 +1,118 @@ +#pragma once + +#include "ColorTheme.h" +#include "TouchInput.h" + +namespace mesh { +namespace ui { + +struct TouchDebugArea { + int x, y, width, height; + TouchAction action; +}; + +struct TouchDebugAreas { + TouchDebugArea areas[10]; + int count = 0; + + void add(int x, int y, int width, int height, TouchAction action) { + if (width > 0 && height > 0 && count < 10) + areas[count++] = {x, y, width, height, action}; + } +}; + +// Visual (already unmirrored), logical coordinates. The UI supplies the same +// current screen geometry to this overlay and TouchInput::update(). Tests +// compare every pixel in these rectangles with the actual stationary actions. +inline TouchDebugAreas makeTouchDebugAreas(int width, int height, + int center_zone_percent, bool bottom_selector, + const TouchSplitSelector* split, const TouchNavigationBar* reader) { + TouchDebugAreas result; + if (width <= 0 || height <= 0) return result; + if (reader != nullptr) { + result.add(0, 0, width, reader->exit_height, TouchAction::Select); + result.add(0, reader->exit_height, width / 2, + reader->top - reader->exit_height, TouchAction::Previous); + result.add(width / 2, reader->exit_height, width - width / 2, + reader->top - reader->exit_height, TouchAction::Next); + const TouchAction actions[] = {TouchAction::VerticalPrevious, + TouchAction::Previous, TouchAction::Next, + TouchAction::VerticalNext, TouchAction::Select}; + for (int cell = 0; cell < 5; ++cell) { + const int left = width * cell / 5; + const int right = width * (cell + 1) / 5; + result.add(left, reader->top, right - left, reader->height, actions[cell]); + } + // Retain truthfulness for layouts with space below the footer as well. + const int below = reader->top + reader->height; + result.add(0, below, width / 2, height - below, TouchAction::Previous); + result.add(width / 2, below, width - width / 2, height - below, TouchAction::Next); + return result; + } + + const int body_bottom = bottom_selector ? height * 3 / 4 : height; + if (bottom_selector) { + result.add(0, body_bottom, width / 4, height - body_bottom, + TouchAction::VerticalPrevious); + result.add(width * 3 / 4, body_bottom, width - width * 3 / 4, + height - body_bottom, TouchAction::VerticalNext); + } + if (split != nullptr) { + result.add(0, 0, split->side_nav_width, body_bottom, TouchAction::Previous); + result.add(width - split->side_nav_width, 0, split->side_nav_width, + body_bottom, TouchAction::Next); + const int bottom = split->top_y + split->height < body_bottom + ? split->top_y + split->height : body_bottom; + result.add(split->left_x, split->top_y, split->left_width, + bottom - split->top_y, TouchAction::SelectLeft); + result.add(split->right_x, split->top_y, split->right_width, + bottom - split->top_y, TouchAction::SelectRight); + return result; + } + const int side_percent = (100 - center_zone_percent) / 2; + const int left = width * side_percent / 100; + const int right = width - left; + result.add(0, 0, left, body_bottom, TouchAction::Previous); + result.add(left, 0, right - left, body_bottom, TouchAction::Select); + result.add(right, 0, width - right, body_bottom, TouchAction::Next); + return result; +} + +inline int touchDebugAreaAt(const TouchDebugAreas& areas, int x, int y) { + for (int i = 0; i < areas.count; ++i) { + const auto& r = areas.areas[i]; + if (x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height) + return i; + } + return -1; +} + +inline void drawTouchDebugAreas(DisplayDriver& display, const TouchDebugAreas& areas, + int pressed_x = -1, int pressed_y = -1) { + const int pressed = touchDebugAreaAt(areas, pressed_x, pressed_y); + for (int i = 0; i < areas.count; ++i) { + display.setColor(i == pressed ? color_theme::TOUCH_PRESSED : color_theme::TOUCH_OUTLINE); + const auto& area = areas.areas[i]; + const int left = area.x < 0 ? 0 : area.x; + const int top = area.y < 0 ? 0 : area.y; + const int right = area.x + area.width < display.width() + ? area.x + area.width - 1 : display.width() - 1; + const int bottom = area.y + area.height < display.height() + ? area.y + area.height - 1 : display.height() - 1; + if (left > right || top > bottom) continue; + // One logical pixel per dot, two pixels of gap. Use the normal drawing + // path so native 480px and scaled 320px canvases show identical bounds. + for (int x = left; x <= right; x += 3) { + display.fillRect(x, top, 1, 1); + display.fillRect(x, bottom, 1, 1); + } + for (int y = top; y <= bottom; y += 3) { + display.fillRect(left, y, 1, 1); + display.fillRect(right, y, 1, 1); + } + display.fillRect(right, bottom, 1, 1); + } +} + +} // namespace ui +} // namespace mesh diff --git a/src/helpers/ui/TouchInput.h b/src/helpers/ui/TouchInput.h index c5a42238..d82c8759 100644 --- a/src/helpers/ui/TouchInput.h +++ b/src/helpers/ui/TouchInput.h @@ -23,6 +23,16 @@ struct TouchSplitSelector { int right_width; int top_y; int height; + // Full-height page-navigation strips, zero for legacy selector layouts. + int side_nav_width; +}; + +// Reader footer has five controls (<<, <, >, >>, X); the top header exits. +// All bounds are logical UI units supplied by the screen that draws them. +struct TouchNavigationBar { + int top = 0; + int height = 0; + int exit_height = 0; }; // Converts one-finger touch samples into actions understood by the @@ -31,6 +41,7 @@ struct TouchSplitSelector { class TouchInput { bool _active = false; bool _reverse_swipes; + bool _reverse_vertical_swipes; bool _separate_vertical_swipes; bool _mirror_tap_x; uint8_t _center_zone_percent; @@ -50,17 +61,27 @@ class TouchInput { TouchAction verticalSwipeAction(bool negative_direction) const { if (!_separate_vertical_swipes) return swipeAction(negative_direction); - const bool next = negative_direction != _reverse_swipes; + const bool next = negative_direction != _reverse_vertical_swipes; return next ? TouchAction::VerticalNext : TouchAction::VerticalPrevious; } public: + int centerZonePercent() const { return _center_zone_percent; } + bool hasSeparateVerticalSwipes() const { return _separate_vertical_swipes; } + explicit TouchInput(bool reverse_swipes = false, bool separate_vertical_swipes = false, uint8_t center_zone_percent = 34, bool mirror_tap_x = false) + : TouchInput(reverse_swipes, separate_vertical_swipes, + center_zone_percent, mirror_tap_x, reverse_swipes) {} + + TouchInput(bool reverse_swipes, bool separate_vertical_swipes, + uint8_t center_zone_percent, bool mirror_tap_x, + bool reverse_vertical_swipes) : _reverse_swipes(reverse_swipes), + _reverse_vertical_swipes(reverse_vertical_swipes), _separate_vertical_swipes(separate_vertical_swipes), _mirror_tap_x(mirror_tap_x), _center_zone_percent(center_zone_percent > 100 @@ -69,7 +90,8 @@ public: TouchAction update(bool touched, int x, int y, int width, int height, bool bottom_selector = false, - const TouchSplitSelector* split_selector = nullptr) { + const TouchSplitSelector* split_selector = nullptr, + const TouchNavigationBar* navigation_bar = nullptr) { if (touched) { _release_samples = 0; if (!_active) { @@ -98,7 +120,7 @@ public: // boxes are unambiguous stationary targets, and rejecting a quick contact // makes a normal tap disappear when it lands within one polling interval. if (_touch_samples == 0 || width <= 0 || height <= 0 - || (_touch_samples < 2 && split_selector == nullptr)) { + || (_touch_samples < 2 && split_selector == nullptr && navigation_bar == nullptr)) { return TouchAction::None; } @@ -109,10 +131,19 @@ public: const int horizontal_threshold = width / 8 > 8 ? width / 8 : 8; const int vertical_threshold = height / 8 > 8 ? height / 8 : 8; + // Explicit header/footer controls are tap targets, not swipe surfaces. + // Gestures begun in the body may cross them without activating a button. + const bool in_reader_chrome = navigation_bar != nullptr + && (_start_y < navigation_bar->exit_height + || (_start_y >= navigation_bar->top + && _start_y < navigation_bar->top + navigation_bar->height)); + if (abs_dx >= abs_dy && abs_dx >= horizontal_threshold) { + if (in_reader_chrome) return TouchAction::None; return swipeAction(dx < 0); } if (abs_dy > abs_dx && abs_dy >= vertical_threshold) { + if (in_reader_chrome) return TouchAction::None; return verticalSwipeAction(dy < 0); } @@ -122,6 +153,39 @@ public: // after movement has been ruled out so it cannot alter swipe detection. const int tap_x = _mirror_tap_x ? width - 1 - _start_x : _start_x; + // Explicit reader controls take priority over the older, broad bottom + // channel-selector zone. Swipes still win over every stationary target. + if (navigation_bar != nullptr) { + if (tap_x < 0 || tap_x >= width || _start_y < 0 || _start_y >= height) + return TouchAction::None; + if (_start_y < navigation_bar->exit_height) + return _last_y >= 0 && _last_y < navigation_bar->exit_height + ? TouchAction::Select : TouchAction::None; + if (_start_y >= navigation_bar->top + && _start_y < navigation_bar->top + navigation_bar->height) { + if (_last_y < navigation_bar->top + || _last_y >= navigation_bar->top + navigation_bar->height) + return TouchAction::None; + const int end_x = _mirror_tap_x ? width - 1 - _last_x : _last_x; + if (end_x < 0 || end_x >= width) return TouchAction::None; + const TouchAction actions[] = {TouchAction::VerticalPrevious, + TouchAction::Previous, TouchAction::Next, + TouchAction::VerticalNext, TouchAction::Select}; + for (int cell = 0; cell < 5; ++cell) { + const int left = width * cell / 5; + const int right = width * (cell + 1) / 5; + if (tap_x < right) + return end_x >= left && end_x < right ? actions[cell] : TouchAction::None; + } + } + // Missing an arrow must never fall through to the ordinary center tap + // (Select), which closes the reader. Body halves move pages instead. + if (_touch_samples < 2) return TouchAction::None; + return tap_x < width / 2 ? TouchAction::Previous : TouchAction::Next; + } + // A quick single sample is accepted only inside an explicit target. + if (_touch_samples < 2 && split_selector == nullptr) return TouchAction::None; + // Message screens may reserve the otherwise empty ends of their bottom // status bar as forgiving arrow buttons. Keep the label in the middle // inert so an imprecise arrow tap cannot accidentally close the screen. @@ -140,6 +204,10 @@ public: // selections. Swipe detection above retains priority, so page navigation // gestures cannot accidentally activate either choice. if (split_selector != nullptr) { + if (tap_x >= 0 && tap_x < split_selector->side_nav_width) + return TouchAction::Previous; + if (tap_x >= width - split_selector->side_nav_width && tap_x < width) + return TouchAction::Next; if (_start_y < split_selector->top_y || _start_y >= split_selector->top_y + split_selector->height) { return TouchAction::None; diff --git a/test/fixtures/companion_john/test_reader.cpp b/test/fixtures/companion_john/test_reader.cpp index f4fff24b..d8598994 100644 --- a/test/fixtures/companion_john/test_reader.cpp +++ b/test/fixtures/companion_john/test_reader.cpp @@ -62,9 +62,18 @@ public: std::vector lines; std::vector pixels; Display(int w, int h) : DisplayDriver(w, h), pixels(w * h, 0) {} - int bodyY() const { return width() < 64 ? 22 : 12; } + int bodyY() const { + const int content_height = width() < 64 ? 20 : 10; +#if UI_READER_TOUCH_BAR + return mesh::ui::readerTouchHeaderHeight(content_height) + 2; +#else + return content_height + 2; +#endif + } int bodyBottom() { -#if UI_BUTTON_READER_HINT +#if UI_READER_TOUCH_BAR + return mesh::ui::makeReaderTouchBar(*this, height()).top; +#elif UI_BUTTON_READER_HINT mesh::ui::SmallMessageText compact(*this); const bool small = useSmallMessageFont(); DisplayDriver& hint_text = small ? static_cast(compact) : *this; @@ -164,8 +173,12 @@ int main(int argc, char** argv) { {128,64,6}, {64,128,6}, {160,80,0}, {80,160,0}, {240,135,0}, {159,80,6}, {80,159,6}, {320,240,0}, {240,320,0}, {250,122,0}, {200,200,0}, {72,40,5}, {40,72,5}, {128,32,5}, {32,128,5}, {64,48,5}, {48,64,5}, + {160,160,0}, }; for (const auto& dimensions : cases) { +#if UI_READER_TOUCH_BAR + if (dimensions[0] != 160 || dimensions[1] != 160) continue; +#endif Display display(dimensions[0], dimensions[1]); Display expected(dimensions[0], dimensions[1]); const int top = expected.bodyY(); @@ -196,9 +209,14 @@ int main(int argc, char** argv) { const bool small_hint = footer.useSmallMessageFont(); DisplayDriver& hint_text = small_hint ? static_cast(hint_compact) : footer; +#if UI_READER_TOUCH_BAR + const auto hint = mesh::ui::makeReaderTouchBar(hint_text, footer.height()); + mesh::ui::drawReaderTouchBar(hint_text, hint); +#else const auto hint = mesh::ui::makeButtonReaderHintLayout(hint_text, small_hint ? hint_compact.glyphHeight() : 10, footer.height()); mesh::ui::drawButtonReaderHint(hint_text, hint); +#endif if (small_hint) { // Compare the real reader's hint pixels independently of the body. auto first = footer.pixels.begin() + hint.top * footer.width(); @@ -262,7 +280,57 @@ int main(int argc, char** argv) { assert(display.body() == expected_pages.front()); // no wrap before start screen.handleInput(KEY_ENTER); assert(task.closed); +#if UI_READER_TOUCH_BAR + task.closed = false; + mesh::ui::TouchInput input(true, true, 70, true, false); + auto touch_gesture = [&](int sx, int sy, int ex, int ey) { + display.clear(); screen.render(display); + const auto* bar = screen.readerTouchBar(); + input.update(true, display.width()-1-sx, sy, display.width(), display.height(), false, nullptr, bar); + input.update(true, display.width()-1-ex, ey, display.width(), display.height(), false, nullptr, bar); + input.update(false, -1, -1, display.width(), display.height(), false, nullptr, bar); + const auto action = input.update(false, -1, -1, display.width(), display.height(), false, nullptr, bar); + switch (action) { + case mesh::ui::TouchAction::Next: screen.handleInput(KEY_NEXT); break; + case mesh::ui::TouchAction::Previous: screen.handleInput(KEY_PREV); break; + case mesh::ui::TouchAction::VerticalNext: screen.handleInput(KEY_DOWN); break; + case mesh::ui::TouchAction::VerticalPrevious: screen.handleInput(KEY_UP); break; + case mesh::ui::TouchAction::Select: screen.handleInput(KEY_ENTER); break; + default: assert(false); + } + }; + auto tap = [&](int cell) { + const int x = display.width() * (2 * cell + 1) / 10; + touch_gesture(x, screen.readerTouchBar()->top, x, screen.readerTouchBar()->top); + }; + tap(2); assert(screen.flush()); + Position saved; assert(the_mesh.loadJohnBookmark(saved) && saved.verse == 1); + tap(1); assert(screen.flush()); + assert(!the_mesh.loadJohnBookmark(saved) && saved.atStart()); + tap(3); assert(screen.flush()); + assert(the_mesh.loadJohnBookmark(saved) && referenceAt(saved.verse).chapter == 2); + tap(0); assert(screen.flush()); + assert(!the_mesh.loadJohnBookmark(saved) && saved.atStart()); + tap(4); assert(task.closed); + task.closed = false; + touch_gesture(120,70,120,70); assert(screen.flush()); + assert(the_mesh.loadJohnBookmark(saved) && saved.verse == 1); + touch_gesture(40,70,40,70); assert(screen.flush()); + assert(!the_mesh.loadJohnBookmark(saved) && saved.atStart()); + touch_gesture(120,70,40,70); assert(screen.flush()); + assert(the_mesh.loadJohnBookmark(saved) && saved.verse == 1); + touch_gesture(40,70,120,70); assert(screen.flush()); + assert(!the_mesh.loadJohnBookmark(saved) && saved.atStart()); + touch_gesture(80,100,80,50); assert(screen.flush()); + assert(the_mesh.loadJohnBookmark(saved) && referenceAt(saved.verse).chapter == 2); + touch_gesture(80,50,80,100); assert(screen.flush()); + assert(!the_mesh.loadJohnBookmark(saved) && saved.atStart()); + touch_gesture(80,0,80,0); assert(task.closed); +#endif } +#if UI_READER_TOUCH_BAR + return 0; // The remaining fault-injection cases use tiny non-touch viewports. +#endif // Group navigation starts at verse one of the adjacent chapter, resets // the page offset, stops at book boundaries, and checkpoints normally. diff --git a/test/test_companion_john.py b/test/test_companion_john.py index 6a7ded10..1d508653 100644 --- a/test/test_companion_john.py +++ b/test/test_companion_john.py @@ -130,10 +130,11 @@ class CompanionJohnTest(unittest.TestCase): self.run_checked([cc, "-Os", *flags, "-c", str(ROOT / "src/helpers/ota/OtaTinf.c"), "-o", str(obj)]) for small_font in (0, 1): - for button_hint in (0, 1): + for button_hint, touch_bar in ((0, 0), (1, 0), (1, 1)): self.run_checked([cxx, "-std=c++11", "-Os", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", *flags, f"-DUI_SMALL_MESSAGE_FONT={small_font}", f"-DUI_BUTTON_READER_HINT={button_hint}", + f"-DUI_READER_TOUCH_BAR={touch_bar}", "-I" + str(ROOT / "src"), "-I" + str(FIXTURE), str(FIXTURE / "test_reader.cpp"), str(ROOT / "src/helpers/CompanionJohn.cpp"), str(obj), "-o", str(binary)]) diff --git a/test/test_companion_terminal_profile.py b/test/test_companion_terminal_profile.py index 5d7e9775..1f601d8b 100644 --- a/test/test_companion_terminal_profile.py +++ b/test/test_companion_terminal_profile.py @@ -94,9 +94,16 @@ assert re.search( # Terminal-specific handlers keep precedence, then both the generic `set` # fallthrough and the final unknown-command fallthrough delegate to the shared -# framed/rescue command surface. This keeps get/set radio, get name, and -# variant commands available without routing terminal-only commands twice. -assert terminal.count("handleCommand(command, 0, local_reply)") == 2 +# framed/rescue command surface. The explicit frequency branch also delegates +# to the strict shared frequency parser; it is not a third fallthrough. +freq_branch = re.search( + r'else if \(strncmp\(config, "freq ", 5\) == 0\) \{([^{}]*)\}', terminal +) +assert freq_branch is not None +assert "handleCommand(command, 0, local_reply)" in freq_branch.group(1) +assert terminal.replace(freq_branch.group(0), "").count( + "handleCommand(command, 0, local_reply)" +) == 2 assert terminal.index('strncmp(config, "tx ", 3)') < terminal.index( "handleCommand(command, 0, local_reply)" ) diff --git a/test/test_companion_transport_selector.py b/test/test_companion_transport_selector.py index 34ba1425..256a549b 100644 --- a/test/test_companion_transport_selector.py +++ b/test/test_companion_transport_selector.py @@ -5,12 +5,81 @@ import subprocess import tempfile import unittest +from test_reader_touch_coordinates import PREAMBLE +from test_replay_reset_integration import extract_braced ROOT = Path(__file__).resolve().parents[1] UI = ROOT / "examples" / "companion_radio" / "ui-new" / "UITask.cpp" class CompanionTransportSelectorTest(unittest.TestCase): + def test_centered_choices_fit_and_preserve_full_height_navigation(self): + actual = extract_braced(UI.read_text(), "static void drawCompanionTransportChoice(") + generic = extract_braced( + (ROOT / "src/helpers/ui/LGFXDisplay.cpp").read_text(), + "bool LGFXDisplay::getTouch(") + scenarios = r''' +#include +using namespace mesh::ui; +struct MeasuredDisplay : LGFXDisplay { + int size=1,bx=0,by=0,bw=160,bh=160,last_bottom=0; + void setTextSize(int s) override { size=s; } + uint16_t getTextWidth(const char* text) override { return strlen(text)*6*size; } + void print(const char* text) override { + assert(cx>=bx+2 && cx+getTextWidth(text)<=bx+bw-2); + assert(cy>=by && cy+8*size<=by+bh); + assert(cy>=last_bottom); + last_bottom=cy+8*size; + LGFXDisplay::print(text); + } +}; +int main() { + const auto layout=makeCompanionTransportSelectorLayout(160,160,70); + assert(layout.side_nav_width==24); + assert(layout.wifi.x==26 && layout.wifi.width==52); + assert(layout.bluetooth.x==82 && layout.bluetooth.width==52); + const TouchSplitSelector selector={layout.wifi.x,layout.wifi.width, + layout.bluetooth.x,layout.bluetooth.width,layout.wifi.y, + layout.wifi.height,layout.side_nav_width}; + for(bool mirror : {false,true}) for(int y=0;y<160;++y) for(int x=0;x<160;++x) { + TouchAction expected=TouchAction::None; + if(x<24) expected=TouchAction::Previous; + else if(x>=136) expected=TouchAction::Next; + else if(y>=40 && y<140) { + if(x>=26 && x<78) expected=TouchAction::SelectLeft; + if(x>=82 && x<134) expected=TouchAction::SelectRight; + } + TouchInput input(true,true,70,mirror,false); + const int tx=mirror ? 159-x : x; + input.update(true,tx,y,160,160,false,&selector); + input.update(false,-1,-1,160,160,false,&selector); + assert(input.update(false,-1,-1,160,160,false,&selector)==expected); + } + for(int canvas : {320,480}) for(bool wifi : {false,true}) + for(bool active : {false,true}) for(bool selected : {false,true}) { + MeasuredDisplay d; + d._coordinateScale=canvas/160; d._outputZoom=480.0f/canvas; + const auto& box=wifi ? layout.wifi : layout.bluetooth; + d.bx=box.x; d.by=box.y; d.bw=box.width; d.bh=box.height; + drawCompanionTransportChoice(d,box.x,box.y,box.width,box.height, + wifi ? "WiFi" : "BLE",active,selected); + assert(d.labels.size()==(active || selected ? 2 : 1)); + } + assert(4*6*3<=160-2*layout.side_nav_width); // MODE + assert(9*6*2<=160-2*layout.side_nav_width); // TAP A BOX +} +''' + with tempfile.TemporaryDirectory() as temp: + binary = Path(temp) / "centered_transport" + compiled = subprocess.run(["c++", "-std=c++11", "-O1", + "-fsanitize=address,undefined", "-fno-pie", "-no-pie", + f"-I{ROOT / 'src'}", "-x", "c++", "-", "-o", str(binary)], + input=PREAMBLE + generic + actual + scenarios, + text=True, capture_output=True) + self.assertEqual(compiled.returncode, 0, compiled.stderr) + tested = subprocess.run([str(binary)], text=True, capture_output=True) + self.assertEqual(tested.returncode, 0, tested.stderr) + def test_indicator_layout_has_room_for_large_transport_text(self): source = r''' #include @@ -195,12 +264,12 @@ int main() { self.assertIn('large_transport_text ? "NEXT" : "NEXT BOOT"', source) self.assertIn('display.setTextSize(3);', source) self.assertIn('display.width() / 2, layout.title_y, "MODE"', source) - self.assertIn('layout.show_title ? "TAP SIDE" : "tap a box"', source) + self.assertIn('layout.show_title ? "TAP A BOX" : "tap a box"', source) self.assertIn("makeCompanionTransportSelectorLayout", source) handler_start = source.index( "if (_page == HomePage::TRANSPORT\n" - " && (c == KEY_ENTER || c == KEY_UP || c == KEY_DOWN))" + " && (key == KEY_ENTER || key == KEY_UP || key == KEY_DOWN))" ) handler_end = source.index("#else", handler_start) handler = source[handler_start:handler_end] @@ -239,7 +308,8 @@ int main() { "->isTransportSelectorPage()", source, ) - self.assertIn("curr == msg_preview, split_transport_selector", source) + self.assertIn("curr == msg_preview && UI_MESSAGE_CHANNEL_FOOTER == 1,", source) + self.assertIn("split_transport_selector, reader_touch_bar);", source) self.assertIn( "case mesh::ui::TouchAction::SelectLeft:\n" " c = checkDisplayOn(KEY_UP);", diff --git a/test/test_indicator_messages_profile.py b/test/test_indicator_messages_profile.py index 9fb68c33..5374e08d 100644 --- a/test/test_indicator_messages_profile.py +++ b/test/test_indicator_messages_profile.py @@ -12,6 +12,25 @@ UI = ROOT / "examples" / "companion_radio" / "ui-new" / "UITask.cpp" class IndicatorMessagesProfileTest(unittest.TestCase): + def test_touch_profile_has_one_navigation_bar_and_no_hidden_channel_targets(self): + profile = PROFILE.read_text() + ui = UI.read_text() + for flag in ("UI_BUTTON_READER_HINT", "UI_READER_TOUCH_BAR"): + self.assertIn(f"-D {flag}=1", profile) + self.assertIn("-D UI_MESSAGE_CHANNEL_FOOTER=0", profile) + self.assertIn("curr == msg_preview && UI_MESSAGE_CHANNEL_FOOTER == 1", ui) + # Compile the real gate: HAS_TOUCH must not overwrite the explicit + # physical-button footer setting with zero. + start = ui.index("#ifndef UI_BUTTON_READER_HINT") + gate = ui[start:ui.index("#if COMPANION_FEATURE_JOHN", start)] + result = subprocess.run(["c++", "-E", "-P", "-x", "c++", "-"], + input="#define HAS_TOUCH 1\n#define UI_BUTTON_READER_HINT 1\n" + gate + + "\n#if UI_BUTTON_READER_HINT != 1\n#error reader hint disabled\n#endif\n", + text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("static_cast(msg_preview)->readerTouchBar()", ui) + self.assertIn("static_cast(john_reader)->readerTouchBar()", ui) + def test_indicator_enables_messages_home_page(self): profile = PROFILE.read_text() ui = UI.read_text() diff --git a/test/test_indicator_render_profile.py b/test/test_indicator_render_profile.py index 79f63bb2..6ac8cd17 100644 --- a/test/test_indicator_render_profile.py +++ b/test/test_indicator_render_profile.py @@ -200,7 +200,11 @@ int main() { self.assertIn("isCompanionWiFiConnected()", page) self.assertIn("hasCompanionWiFiCredentials()", page) self.assertIn("CompanionWiFiDisplayState::NotConfigured", page) - self.assertIn('"TAP TO START"', page) + # The shared page uses the input-specific label; Indicator's touch + # profile must still resolve it to TAP, not the button-only HOLD. + self.assertIn('SELECT_LABEL " TO START"', page) + touch_labels = ui.split("#ifdef HAS_TOUCH", 1)[1].split("#elif", 1)[0] + self.assertIn('#define SELECT_LABEL "TAP"', touch_labels) self.assertIn("wifi_connected != _wifi_was_connected", ui) self.assertIn("CompanionWiFiDisplayState::Ready", page) self.assertIn("display.clear()", page) diff --git a/test/test_message_navigation.py b/test/test_message_navigation.py index c6f707f1..78abe6fb 100644 --- a/test/test_message_navigation.py +++ b/test/test_message_navigation.py @@ -2,6 +2,8 @@ """Exercise actual button routing and message filters with a host display.""" from pathlib import Path +from itertools import product +import re import subprocess import tempfile import unittest @@ -21,16 +23,22 @@ PREAMBLE = r''' #include #include #define UI_SMALL_MESSAGE_FONT 0 +#ifndef UI_BUTTON_READER_HINT #define UI_BUTTON_READER_HINT 1 +#endif +#ifndef UI_MESSAGE_CHANNEL_FOOTER #define UI_MESSAGE_CHANNEL_FOOTER 0 +#endif +#ifndef UI_COMPACT_MESSAGE_STATUS #define UI_COMPACT_MESSAGE_STATUS 0 +#endif #define UI_MSG_PREVIEW_SIZE 161 #define MAX_GROUP_CHANNELS 4 #define AUTO_OFF_MILLIS 15000 #define MESH_DEBUG_PRINTLN(...) ColorVal UIColor::window_bkg=0, UIColor::title_bkg=0, UIColor::title_txt=1; ColorVal UIColor::primary_txt=1, UIColor::secondary_txt=1, UIColor::warning_txt=1; -ColorVal UIColor::popup_bkg=0, UIColor::popup_txt=1, UIColor::corp_blue=1; +ColorVal UIColor::popup_bkg=0, UIColor::popup_txt=1, UIColor::corp_blue=2; uint64_t companionMessageNowMillis() { return millis(); } uint64_t companionMessageElapsedMillis(uint64_t when) { return millis()-when; } struct StrHelper { @@ -46,30 +54,50 @@ struct Mesh { } } the_mesh; struct Display : DisplayDriver { - struct Line { int x,y; std::string text; }; + struct Line { int x,y; std::string text; ColorVal color; }; + struct Rect { int x,y,w,h; ColorVal color; }; std::vector lines; + std::vector rectangles; + ColorVal color=1; int x=0,y=0; - bool on=true; + bool on=true, compact=false; +#ifdef TEST_INDICATOR_CANVAS + Display() : DisplayDriver(160,160) {} + int renderWidth() const override { return TEST_INDICATOR_CANVAS; } + int renderHeight() const override { return TEST_INDICATOR_CANVAS; } +#else Display() : DisplayDriver(128,64) {} +#endif bool isOn() override { return on; } void turnOn() override { on=true; } void turnOff() override { on=false; } - void clear() override { lines.clear(); } + void clear() override { lines.clear(); rectangles.clear(); } void startFrame(ColorVal=0) override { clear(); } void endFrame() override {} void setTextSize(int) override {} - void setColor(ColorVal) override {} + void setCompactText(bool value) override { compact=value; } + void setColor(ColorVal value) override { color=value; } void setCursor(int a,int b) override { x=a;y=b; } - uint16_t getTextWidth(const char* text) override { return strlen(text)*4; } + uint16_t getTextWidth(const char* text) override { +#ifdef TEST_INDICATOR_CANVAS + // Indicator font: 18x24 physical pixels at the ordinary 3x scale; + // native non-compact text is enlarged 20%. Round bounds outwards. + return (strlen(text)*(TEST_INDICATOR_CANVAS==480 && !compact ? 72 : 60)+9)/10; +#else + return strlen(text)*4; +#endif + } void print(const char* text) override { assert(x>=0 && x+getTextWidth(text)<=width()); assert(y>=0 && y+8<=height()); - lines.push_back({x,y,text}); + lines.push_back({x,y,text,color}); } void fillRect(int a,int b,int w,int h) override { assert(a>=0 && b>=0 && a+w<=width() && b+h<=height()); } - void drawRect(int a,int b,int w,int h) override { fillRect(a,b,w,h); } + void drawRect(int a,int b,int w,int h) override { + fillRect(a,b,w,h); rectangles.push_back({a,b,w,h,color}); + } void drawXbm(int,int,const uint8_t*,int,int) override {} bool contains(const char* text) const { for (const auto& line:lines) if (line.text==text) return true; @@ -78,8 +106,9 @@ struct Display : DisplayDriver { }; struct Screen : UIScreen { uint8_t key=0; + int events=0; int render(DisplayDriver&) override { return 0; } - bool handleInput(char c) override { key=static_cast(c); return true; } + bool handleInput(char c) override { ++events; key=static_cast(c); return true; } }; MomentaryButton user_btn(7,1000,true,true,true); class UITask { @@ -95,11 +124,15 @@ public: bool isJohnReaderActive() const { return john_reader && curr==john_reader; } void gotoHomeScreen() { curr=home; } void toggleBuzzer() { ++buzzer_changes; } + void showJohnReader() { curr=john_reader; } + void showAlert(const char*,int) {} char handleLongPress(char c) { return c; } char handleMultiClick(char,bool); char handleDoubleClick(char); char checkDisplayOn(char); void pollButton(); + void routeTouch(mesh::ui::TouchAction action); + bool isButtonGesturePending() const; }; ''' @@ -112,7 +145,7 @@ void gesture(UITask& task,int count) { g_mock_millis+=25; task.pollButton(); if (tap+1exit_height; + assert(exit_height>=24); + for(const auto& line:display.lines) { + if(line.text==header) assert(line.y>0 && line.y+8=exit_height); + } + bool header_divider=false; + for(const auto& rect:display.rectangles) + if(rect.y==exit_height-1 && rect.h==1 && rect.w==display.width()) header_divider=true; + assert(header_divider); + const char* labels[]={"4<<","2<",">1",">>3","X"}; + for(int cell=0;cell<5;++cell) { + assert(display.contains(labels[cell])); + for(const auto& line:display.lines) if(line.text==labels[cell]) { + assert(line.x>=display.width()*cell/5); + assert(line.x+display.getTextWidth(labels[cell])<=display.width()*(cell+1)/5); + assert(line.y>=hint.top && line.y+8<=hint.top+hint.height); + assert(line.color==UIColor::corp_blue); + } + } + bool divider=false; + for(const auto& rect:display.rectangles) + if(rect.x==0 && rect.y==hint.top && rect.w==display.width() + && rect.h==1 && rect.color==UIColor::corp_blue) divider=true; + assert(divider); +#if !UI_MESSAGE_CHANNEL_FOOTER + assert(hint.top+hint.height==display.height()); + assert(!display.contains("<") && !display.contains(">")); +#endif + for(const auto& line:display.lines) + if(line.text==body) assert(line.y+8<=hint.top); +#else + const auto hint=mesh::ui::makeButtonReaderHintLayout(display,display.textLineHeight(),bottom); + for(int row=0;rowtop+bar->height/2; + touch.update(true,raw_x,y,display.width(),display.height(),UI_MESSAGE_CHANNEL_FOOTER,nullptr,bar); + touch.update(true,raw_x,y,display.width(),display.height(),UI_MESSAGE_CHANNEL_FOOTER,nullptr,bar); + touch.update(false,-1,-1,display.width(),display.height(),UI_MESSAGE_CHANNEL_FOOTER,nullptr,bar); + task.routeTouch(touch.update(false,-1,-1,display.width(),display.height(),UI_MESSAGE_CHANNEL_FOOTER,nullptr,bar)); + }; + tap(0); expect("Ch 0 1/2","public new"); + tap(2); expect("Ch 0 2/2","public old"); + tap(1); expect("Ch 0 1/2","public new"); + tap(3); expect("Ch 2 1/1","second"); + display.on=false; tap(4); assert(display.on && task.curr==&messages); + tap(4); assert(task.curr==&home); + task.curr=&messages; + auto body_gesture=[&](int sx,int sy,int ex,int ey) { + display.clear(); messages.render(display); + const auto* bar=messages.readerTouchBar(); + touch.update(true,display.width()-1-sx,sy,display.width(),display.height(),false,nullptr,bar); + touch.update(true,display.width()-1-ex,ey,display.width(),display.height(),false,nullptr,bar); + touch.update(false,-1,-1,display.width(),display.height(),false,nullptr,bar); + task.routeTouch(touch.update(false,-1,-1,display.width(),display.height(),false,nullptr,bar)); + }; + tap(0); expect("Ch 0 1/2","public new"); + body_gesture(120,70,120,70); expect("Ch 0 2/2","public old"); + body_gesture(40,70,40,70); expect("Ch 0 1/2","public new"); + body_gesture(120,70,40,70); expect("Ch 0 2/2","public old"); + body_gesture(40,70,120,70); expect("Ch 0 1/2","public new"); + body_gesture(80,100,80,50); expect("Ch 2 1/1","second"); + body_gesture(80,50,80,100); expect("Ch 0 1/2","public new"); + body_gesture(48,messages.readerTouchBar()->top-1,48,messages.readerTouchBar()->top-1); + expect("Ch 0 1/2","public new"); // just missing 2< cannot close the reader + body_gesture(80,0,80,0); assert(task.curr==&home); + task.curr=&messages; +#endif // Long press exits, without a delayed tap changing the home screen. g_mock_pin_levels[7]=LOW; task.pollButton(); g_mock_millis+=25; task.pollButton(); @@ -158,15 +284,58 @@ int main() { assert(task.curr==&home); g_mock_pin_levels[7]=HIGH; task.pollButton(); g_mock_millis+=25; task.pollButton(); - g_mock_millis+=280; task.pollButton(); + g_mock_millis+=MOMENTARY_BUTTON_MULTI_CLICK_MS; task.pollButton(); assert(home.key==0); #if COMPANION_FEATURE_JOHN task.curr=&group; + gesture(task,1); assert(group.key==KEY_NEXT && group.events==1); + gesture(task,2); assert(group.key==KEY_PREV && group.events==2); gesture(task,3); assert(group.key==KEY_DOWN); gesture(task,4); assert(group.key==KEY_UP); assert(task.buzzer_changes==0); +#ifdef TEST_INDICATOR_CANVAS + // Poll real pin waveforms while periodic synchronous RGB work is due. + // A redraw between clicks must wait, not erase a short press/release from + // the poller's history. Cover quick and deliberate multi-click rhythms. + for(int clicks : {3,4}) for(int gap : {80,320,430}) { + group.events=0; group.key=0; + uint32_t elapsed=0; + const uint32_t active_until=(clicks-1)*(70+gap)+70; + uint32_t redraw_at=35; int redraws=0; + while(elapsed=redraw_at && !task.isButtonGesturePending()) { + // Simulate a slow synchronous frame, including the time the poller + // cannot run. With the old unconditional render this loses clicks. + elapsed+=200; g_mock_millis+=200; redraw_at=elapsed+1000; ++redraws; + } + elapsed+=5; g_mock_millis+=5; + } + assert(group.events==1 && group.key==(clicks==3 ? KEY_DOWN : KEY_UP)); + assert(redraws>0 && !task.isButtonGesturePending()); + } +#endif #endif task.curr=&home; + // Exercise the actual home-page navigation, not just a recorded key. + gesture(task,1); assert(home._page==1 && home.events==1); + gesture(task,2); assert(home._page==0 && home.events==2); + gesture(task,2); assert(home._page==HomeScreen::Count-1 && home.events==3); + gesture(task,1); assert(home._page==0 && home.events==4); + // No premature single-click while a second press is still possible. + g_mock_pin_levels[7]=LOW; task.pollButton(); + g_mock_millis+=25; task.pollButton(); + g_mock_pin_levels[7]=HIGH; task.pollButton(); + g_mock_millis+=25; task.pollButton(); + g_mock_millis+=200; task.pollButton(); + assert(home._page==0 && home.events==4); + g_mock_pin_levels[7]=LOW; task.pollButton(); + g_mock_millis+=25; task.pollButton(); + g_mock_pin_levels[7]=HIGH; task.pollButton(); + g_mock_millis+=25; task.pollButton(); + g_mock_millis+=MOMENTARY_BUTTON_MULTI_CLICK_MS; task.pollButton(); + assert(home._page==HomeScreen::Count-1 && home.events==5); gesture(task,3); assert(task.buzzer_changes==1); gesture(task,4); assert(task.buzzer_changes==2); } @@ -182,25 +351,53 @@ class MessageNavigationTest(unittest.TestCase): button_route = source[start:source.index(" #endif", start)] implementation = "\n".join(extract_braced(source, signature) for signature in ( "char UITask::checkDisplayOn(", "char UITask::handleDoubleClick(", - "char UITask::handleMultiClick(")) + "char UITask::handleMultiClick(", "bool UITask::isButtonGesturePending(")) + self.assertIn("millis() >= _next_refresh && curr && !isButtonGesturePending()", source) implementation += "\n" + extract_braced(source, "class MsgPreviewScreen :") + ";\n" implementation += "void UITask::pollButton() { char c=0; int ev=user_btn.check();\n" implementation += button_route + "\nif(c && curr) curr->handleInput(c);\n}\n" - for enabled in (0, 1): - for signedness in ("-fsigned-char", "-funsigned-char"): - with self.subTest(feature=enabled, signedness=signedness), tempfile.TemporaryDirectory() as temp: - binary = Path(temp) / "navigation" - result = subprocess.run([ - "c++", "-std=c++17", signedness, - f"-DCOMPANION_FEATURE_JOHN={enabled}", - "-I" + str(ROOT / "src"), "-I" + str(ROOT / "test/mocks"), - "-fsanitize=address,undefined", "-fno-pie", "-no-pie", - "-x", "c++", "-", str(ROOT / "src/helpers/ui/MomentaryButton.cpp"), - "-o", str(binary), - ], input=PREAMBLE + implementation + SCENARIOS, text=True, capture_output=True) - self.assertEqual(result.returncode, 0, result.stderr) - result = subprocess.run([str(binary)], text=True, capture_output=True) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + touch_route = extract_braced(source[source.index("void UITask::loop()"):], "switch (action)") + implementation += ("void UITask::routeTouch(mesh::ui::TouchAction action) { " + "char c=0; bool on_transport_selector=false;\n" + touch_route + + "\nif(c && curr) curr->handleInput(c); }\n") + home = source[source.index("class HomeScreen :"):] + home_navigation = home[home.index(" bool handleInput(char c) override {"):] + home_navigation = home_navigation.split("#ifdef COMPANION_EXCLUSIVE_WIFI_BLE", 1)[0] + home_navigation = home_navigation.replace("{\n", "{\n Screen::handleInput(c);\n", 1) + implementation += ("class HomeScreen : public Screen { public: UITask* _task; " + "enum HomePage {FIRST,MESSAGES,RECENT,RADIO,Count}; int _page=0; " + "HomeScreen(UITask* task):_task(task){}\n" + home_navigation + + "return false; } };\n") + target = (ROOT / "variants/sensecap_indicator-espnow/target.cpp").read_text() + button = re.search(r"MomentaryButton user_btn\([^;]+;", target).group(0) + profile = (ROOT / "variants/sensecap_indicator-espnow/platformio.ini").read_text() + indicator_flags = ["-DHAS_TOUCH=1", "-DPIN_USER_BTN=7"] + for name in ("UI_BUTTON_READER_HINT", "UI_READER_TOUCH_BAR", "UI_MESSAGE_CHANNEL_FOOTER", "UI_COMPACT_MESSAGE_STATUS", + "MOMENTARY_BUTTON_MULTI_CLICK_MS", "UI_DEFER_RENDER_DURING_BUTTON_GESTURE"): + value = re.search(r"^\s*-D " + name + r"=(\d+)\s*$", profile, re.M) + self.assertIsNotNone(value, name) + indicator_flags.append(f"-D{name}={value.group(1)}") + for enabled, signedness, canvas in product( + (0, 1), ("-fsigned-char", "-funsigned-char"), (0, 320, 480)): + with self.subTest(feature=enabled, signedness=signedness, canvas=canvas), tempfile.TemporaryDirectory() as temp: + preamble = PREAMBLE + flags = [] + if canvas: + preamble = preamble.replace("MomentaryButton user_btn(7,1000,true,true,true);", button) + flags = indicator_flags + [f"-DTEST_INDICATOR_CANVAS={canvas}"] + binary = Path(temp) / "navigation" + result = subprocess.run([ + "c++", "-std=c++17", signedness, + f"-DCOMPANION_FEATURE_JOHN={enabled}", + *flags, + "-I" + str(ROOT / "src"), "-I" + str(ROOT / "test/mocks"), + "-fsanitize=address,undefined", "-fno-pie", "-no-pie", + "-x", "c++", "-", str(ROOT / "src/helpers/ui/MomentaryButton.cpp"), + "-o", str(binary), + ], input=preamble + implementation + SCENARIOS, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + result = subprocess.run([str(binary)], text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) if __name__ == "__main__": diff --git a/test/test_reader_touch_coordinates.py b/test/test_reader_touch_coordinates.py new file mode 100644 index 00000000..372fb784 --- /dev/null +++ b/test/test_reader_touch_coordinates.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +"""Exercise the real LGFX touch conversion before the rendered reader targets.""" + +from pathlib import Path +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + +PREAMBLE = r''' +#include +#include +#include +#include +#include +ColorVal UIColor::window_bkg=0, UIColor::title_bkg=0, UIColor::title_txt=1; +ColorVal UIColor::primary_txt=1, UIColor::secondary_txt=1, UIColor::warning_txt=1; +ColorVal UIColor::popup_bkg=0, UIColor::popup_txt=1, UIColor::corp_blue=2; +namespace lgfx { namespace v1 { struct touch_point_t { int x,y; }; } } +struct Panel { + int w=480,h=480,x=0,y=0; + bool touched=true; + int width() const { return w; } + int height() const { return h; } + int getTouch(lgfx::v1::touch_point_t* point) { + point->x=x; point->y=y; return touched ? 1 : 0; + } +}; +struct LGFXDisplay : DisplayDriver { + Panel panel; + Panel* display=&panel; + // Retain these fields so this test also compiles the pre-fix implementation. + int _coordinateScale=3; + float _outputZoom=1.0f; + struct Label { int x,y; std::string text; }; + std::vector