Fix touchscreen navigation and add runtime touch diagnostics

This commit is contained in:
mikecarper committed 2026-09-09 17:38:41 -07:00
1 parent 23fe7c8a9d
commit 14796e6573
32 files changed
+1785 -123

No files matched your search

+10
View File
@@ -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
+1
View File
@@ -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
+25
View File
@@ -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:
@@ -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.
+27
View File
@@ -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 <on|off> (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 <name|default>\r\n");
@@ -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<DisplayDriver&>(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<DisplayDriver&>(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) {
+161 -42
View File
@@ -4,6 +4,9 @@
#include <helpers/ui/CompanionHomeLayout.h>
#include <helpers/ui/CompanionMessageHistory.h>
#include <helpers/ui/ReaderNavigationHint.h>
#ifdef HAS_TOUCH
#include <helpers/ui/TouchDebugOverlay.h>
#endif
#include <helpers/ui/DisplayTextLayout.h>
#if UI_SMALL_MESSAGE_FONT == 1
#include <helpers/ui/SmallMessageText.h>
@@ -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<uint8_t>(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<HomeScreen*>(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<HomeScreen*>(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<MsgPreviewScreen*>(msg_preview)->readerTouchBar();
#if COMPANION_FEATURE_JOHN
else if (isJohnReaderActive())
reader_touch_bar = static_cast<JohnReaderScreen*>(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()
+25 -1
View File
@@ -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();
}
+4
View File
@@ -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
@@ -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,
};
}
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <stdint.h>
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<int>(static_cast<int64_t>(panel_x) * logical_width / panel_width);
*y = static_cast<int>(static_cast<int64_t>(panel_y) * logical_height / panel_height);
return true;
}
} // namespace ui
} // namespace mesh
+12 -9
View File
@@ -1,6 +1,7 @@
#include "LGFXDisplay.h"
#include "ColorTheme.h"
#include "IndicatorRenderProfile.h"
#include "DisplayTouchCoordinates.h"
#include <lgfx/utility/lgfx_qrcode.h>
#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;
+2 -3
View File
@@ -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;
+4
View File
@@ -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
+35
View File
@@ -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
+118
View File
@@ -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
+71 -3
View File
@@ -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;
+70 -2
View File
@@ -62,9 +62,18 @@ public:
std::vector<Line> lines;
std::vector<uint8_t> 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<DisplayDriver&>(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<DisplayDriver&>(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.
+2 -1
View File
@@ -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)])
+10 -3
View File
@@ -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)"
)
+73 -3
View File
@@ -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 <helpers/ui/CompanionTransportSelectorLayout.h>
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 <helpers/ui/CompanionTransportSelectorLayout.h>
@@ -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);",
+19
View File
@@ -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<MsgPreviewScreen*>(msg_preview)->readerTouchBar()", ui)
self.assertIn("static_cast<JohnReaderScreen*>(john_reader)->readerTouchBar()", ui)
def test_indicator_enables_messages_home_page(self):
profile = PROFILE.read_text()
ui = UI.read_text()
+5 -1
View File
@@ -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)
+225 -28
View File
@@ -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 <string>
#include <vector>
#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<Line> lines;
std::vector<Rect> 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<uint8_t>(c); return true; }
bool handleInput(char c) override { ++events; key=static_cast<uint8_t>(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+1<count) { g_mock_millis+=80; task.pollButton(); }
}
g_mock_millis+=280; task.pollButton();
g_mock_millis+=MOMENTARY_BUTTON_MULTI_CLICK_MS; task.pollButton();
}
int main() {
resetArduinoMock();
@@ -120,7 +153,8 @@ int main() {
user_btn.begin(); user_btn.enableQuadrupleClick();
Display display;
UITask task(display);
Screen home,group;
HomeScreen home(&task);
Screen group;
MsgPreviewScreen messages(&task);
task.home=&home; task.msg_preview=task.curr=&messages; task.john_reader=&group;
messages.addPreview(1,"Alice","public old",0,"Public");
@@ -130,7 +164,58 @@ int main() {
auto expect=[&](const char* header,const char* body) {
assert(task.curr==&messages);
display.clear(); messages.render(display);
#if UI_MESSAGE_CHANNEL_FOOTER
std::string status="Message "; status+=strrchr(header,' ')+1;
assert(display.contains(status.c_str()) && display.contains(body));
#else
assert(display.contains(header) && display.contains(body));
#endif
display.setCompactText(UI_COMPACT_MESSAGE_STATUS);
const int bottom=display.height()-(UI_MESSAGE_CHANNEL_FOOTER ?
mesh::ui::makeCompanionMessageChromeLayout(UI_COMPACT_MESSAGE_STATUS).filter_height : 0);
#if UI_READER_TOUCH_BAR
const auto hint=mesh::ui::makeReaderTouchBar(display,bottom);
const int exit_height=messages.readerTouchBar()->exit_height;
assert(exit_height>=24);
for(const auto& line:display.lines) {
if(line.text==header) assert(line.y>0 && line.y+8<exit_height);
if(line.text==body) assert(line.y>=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;row<hint.line_count;++row) assert(display.contains(hint.lines[row]));
for(const auto& line:display.lines) {
if(line.text==body) assert(line.y+8<=hint.top);
for(int row=0;row<hint.line_count;++row)
if(line.text==hint.lines[row]) assert(line.y==hint.top+row*hint.line_height);
}
#endif
display.setCompactText(false);
assert(task.buzzer_changes==0);
};
expect("DM 1/1","direct message");
@@ -151,6 +236,47 @@ int main() {
assert(display.on); expect("DM 1/1","direct message");
gesture(task,4); expect("Ch 3 0/0","No buffered messages");
gesture(task,4); expect("Ch 2 1/1","second");
#if UI_READER_TOUCH_BAR
// Taps use the same freshly rendered rectangles and actual UI action route.
mesh::ui::TouchInput touch(true,true,70,true,false);
auto tap=[&](int cell) {
display.clear(); messages.render(display);
const auto* bar=messages.readerTouchBar();
const int visual_x=display.width()*(2*cell+1)/10;
const int raw_x=display.width()-1-visual_x;
const int y=bar->top+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<active_until+MOMENTARY_BUTTON_MULTI_CLICK_MS+300) {
g_mock_pin_levels[7]=(elapsed<active_until && elapsed%(70+gap)<70) ? LOW : HIGH;
task.pollButton();
if(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__":
+302
View File
@@ -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 <cassert>
#include <string>
#include <vector>
#include <helpers/ui/ReaderNavigationHint.h>
#include <helpers/ui/DisplayTouchCoordinates.h>
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<Label> labels;
int cx=0,cy=0,glyph_width=6,divider=-1;
LGFXDisplay() : DisplayDriver(160,160) {}
bool isOn() override { return true; }
void turnOn() override {}
void turnOff() override {}
void clear() override { labels.clear(); }
void startFrame(ColorVal=0) override { clear(); }
void endFrame() override {}
void setTextSize(int) override {}
void setColor(ColorVal) override {}
void setCursor(int x,int y) override { cx=x; cy=y; }
uint16_t getTextWidth(const char* text) override { return strlen(text)*glyph_width; }
void print(const char* text) override { labels.push_back({cx,cy,text}); }
void fillRect(int x,int y,int w,int h) override {
assert(x>=0 && y>=0 && x+w<=width() && y+h<=height());
}
void drawRect(int x,int y,int w,int h) override {
fillRect(x,y,w,h); if(h==1 && w==width()) divider=y;
}
void drawXbm(int,int,const uint8_t*,int,int) override {}
bool getTouch(int*,int*) override;
};
'''
SCENARIOS = r'''
using mesh::ui::TouchAction;
TouchAction tap(LGFXDisplay& d,const mesh::ui::TouchNavigationBar& bar,
int visual_x,int physical_y,bool mirror,int samples=2) {
// The Indicator's controller X is mirrored; TouchInput applies the board
// correction. Feed physical 0..479 samples, NOT pre-scaled 0..159 samples.
d.panel.x=mirror ? d.panel.width()-1-visual_x : visual_x;
d.panel.y=physical_y;
mesh::ui::TouchInput input(true,true,70,mirror,false);
int x=-1,y=-1;
assert(d.getTouch(&x,&y));
for(int i=0;i<samples;++i)
assert(input.update(true,x,y,d.width(),d.height(),false,nullptr,&bar)==TouchAction::None);
assert(input.update(false,-1,-1,d.width(),d.height(),false,nullptr,&bar)==TouchAction::None);
return input.update(false,-1,-1,d.width(),d.height(),false,nullptr,&bar);
}
void test_rendered_targets() {
const TouchAction expected[]={TouchAction::VerticalPrevious,TouchAction::Previous,
TouchAction::Next,TouchAction::VerticalNext,TouchAction::Select};
for(int canvas : {320,480}) for(int font : {6,8}) for(bool mirror : {false,true}) {
LGFXDisplay d;
d._coordinateScale=canvas/160; d._outputZoom=480.0f/canvas; d.glyph_width=font;
const auto bar=mesh::ui::makeReaderTouchBar(d,d.height(),d.textLineHeight()+1);
mesh::ui::drawReaderTouchBar(d,bar);
assert(d.labels.size()==5 && d.divider==bar.top);
assert(bar.top+bar.height==d.height());
// Full rendered row, including every column boundary and final panel pixel.
for(int py=d.divider*3;py<480;++py) for(int px=0;px<480;++px)
assert(tap(d,bar,px,py,mirror,1)==expected[px/96]);
// Actual drawn label centers must activate their own controls too.
for(int i=0;i<5;++i) {
const auto& label=d.labels[i];
int px=(label.x+d.getTextWidth(label.text.c_str())/2)*3;
int py=(label.y+d.textLineHeight()/2)*3;
assert(tap(d,bar,px,py,mirror)==expected[i]);
}
// Header Exit uses exactly the rendered header height, not a broad zone.
for(int px=0;px<480;++px) {
assert(tap(d,bar,px,0,mirror,1)==TouchAction::Select);
assert(tap(d,bar,px,bar.exit_height*3-1,mirror,1)==TouchAction::Select);
assert(tap(d,bar,px,bar.exit_height*3,mirror)==
(px<240 ? TouchAction::Previous : TouchAction::Next));
}
}
}
void test_missed_arrow_does_not_exit() {
LGFXDisplay d;
const auto bar=mesh::ui::makeReaderTouchBar(d,d.height());
for(int canvas : {320,480}) {
d._coordinateScale=canvas/160; d._outputZoom=480.0f/canvas;
// Reported failure: a tap near 2< falls above the footer and becomes the
// generic center/select action, which closes the message reader.
assert(tap(d,bar,144,bar.top*3-1,true)==TouchAction::Previous);
assert(tap(d,bar,240,bar.top*3-12,true)==TouchAction::Next);
assert(tap(d,bar,144,bar.top*3,true)==TouchAction::Previous);
assert(tap(d,bar,432,479,true)==TouchAction::Select);
}
}
void test_panel_bounds() {
LGFXDisplay d;
int x=-1,y=-1;
for(int bad : {-2,-1,480,481}) {
d.panel.x=bad; d.panel.y=460; assert(!d.getTouch(&x,&y));
d.panel.x=144; d.panel.y=bad; assert(!d.getTouch(&x,&y));
}
d.panel.x=479; d.panel.y=479; assert(d.getTouch(&x,&y)); assert(x==159 && y==159);
d.panel.touched=false; assert(!d.getTouch(&x,&y));
assert(x==-1 && y==-1);
assert(!d.getTouch(nullptr,&y) && !d.getTouch(&x,nullptr));
// The shared driver uses the live, rotated output dimensions, including
// non-square outputs. No stale compile-time zoom can move edge targets.
d.panel.touched=true; d.panel.w=240; d.panel.h=320;
d.panel.x=239; d.panel.y=319; assert(d.getTouch(&x,&y));
assert(x==159 && y==159);
d.panel.x=120; d.panel.y=160; assert(d.getTouch(&x,&y));
assert(x==80 && y==80);
d.panel.w=0; assert(!d.getTouch(&x,&y));
assert(x==-1 && y==-1);
}
void test_body_gestures_and_chrome_drags() {
for(int canvas : {320,480}) {
LGFXDisplay d;
d._coordinateScale=canvas/160; d._outputZoom=480.0f/canvas;
const auto bar=mesh::ui::makeReaderTouchBar(d,d.height(),12);
auto drag=[&](int sx,int sy,int ex,int ey) {
mesh::ui::TouchInput input(true,true,70,true,false);
int x,y;
d.panel.x=479-sx; d.panel.y=sy; assert(d.getTouch(&x,&y));
input.update(true,x,y,160,160,false,nullptr,&bar);
d.panel.x=479-ex; d.panel.y=ey; assert(d.getTouch(&x,&y));
input.update(true,x,y,160,160,false,nullptr,&bar);
input.update(false,-1,-1,160,160,false,nullptr,&bar);
auto action=input.update(false,-1,-1,160,160,false,nullptr,&bar);
assert(input.update(false,-1,-1,160,160,false,nullptr,&bar)==TouchAction::None);
return action;
};
assert(drag(360,220,120,220)==TouchAction::Next); // swipe left
assert(drag(120,220,360,220)==TouchAction::Previous);
assert(drag(240,300,240,120)==TouchAction::VerticalNext); // up = >>
assert(drag(240,120,240,300)==TouchAction::VerticalPrevious); // down = <<
// Starting in the body and ending over X/header remains a swipe, not Exit.
assert(drag(432,200,432,479)==TouchAction::VerticalPrevious);
assert(drag(240,200,240,0)==TouchAction::VerticalNext);
assert(drag(120,460,432,460)==TouchAction::None);
assert(drag(120,12,360,12)==TouchAction::None);
// Small movement out of a button's row/cell must not select a neighbor.
assert(drag(144,bar.top*3,144,bar.top*3-3)==TouchAction::None);
assert(drag(382,460,386,460)==TouchAction::None);
assert(drag(144,35,144,39)==TouchAction::None);
}
}
int main(int argc,char** argv) {
assert(argc==2);
if(std::string(argv[1])=="targets") test_rendered_targets();
else if(std::string(argv[1])=="miss") test_missed_arrow_does_not_exit();
else if(std::string(argv[1])=="gestures") test_body_gestures_and_chrome_drags();
else test_panel_bounds();
}
'''
class ReaderTouchCoordinatesTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temp = tempfile.TemporaryDirectory()
cls.addClassCleanup(cls.temp.cleanup)
cls.executable = Path(cls.temp.name) / "reader_touch_coordinates"
implementation = (ROOT / "src/helpers/ui/LGFXDisplay.cpp").read_text()
actual_get_touch = extract_braced(implementation, "bool LGFXDisplay::getTouch(")
result = subprocess.run(
["c++", "-std=c++17", "-O1", "-fsanitize=address,undefined",
"-fno-pie", "-no-pie", f"-I{ROOT / 'src'}", "-x", "c++", "-",
"-o", str(cls.executable)],
input=PREAMBLE + actual_get_touch + SCENARIOS,
text=True, capture_output=True)
if result.returncode:
raise AssertionError(result.stderr)
def run_scenario(self, scenario):
result = subprocess.run([str(self.executable), scenario], text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr)
def test_actual_panel_coordinates_match_rendered_footer(self):
self.run_scenario("targets")
def test_missed_arrow_never_becomes_exit(self):
self.run_scenario("miss")
def test_invalid_panel_points_do_not_round_into_edge_targets(self):
self.run_scenario("bounds")
def test_body_swipes_and_chrome_drags(self):
self.run_scenario("gestures")
def test_indicator_keeps_vertical_direction_independent_of_mirrored_x(self):
profile = (ROOT / "variants/sensecap_indicator-espnow/platformio.ini").read_text()
task = (ROOT / "examples/companion_radio/ui-new/UITask.h").read_text()
self.assertIn("-D TOUCH_REVERSE_VERTICAL_SWIPE=0", profile)
self.assertIn("TOUCH_REVERSE_VERTICAL_SWIPE != 0", task)
source = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text()
self.assertIn("layout.header_divider_y + 1", source)
self.assertIn("if (!_display->isOn()) reader_touch_bar = nullptr;", source)
reader = (ROOT / "examples/companion_radio/ui-new/JohnReaderScreen.h").read_text()
self.assertIn("makeReaderTouchBar(hint_text, bottom, top - 1)", reader)
class IndicatorTouchOrientationTest(unittest.TestCase):
def test_actual_board_driver_corrects_bottom_to_top_reports(self):
generic = extract_braced(
(ROOT / "src/helpers/ui/LGFXDisplay.cpp").read_text(),
"bool LGFXDisplay::getTouch(")
board = extract_braced(
(ROOT / "variants/sensecap_indicator-espnow/SCIndicatorDisplay.h").read_text(),
"bool getTouch(int* x, int* y) override")
scenarios = r'''
#include <helpers/ui/TouchDebugOverlay.h>
using namespace mesh::ui;
int main() {
for(int canvas : {320,480}) {
SCIndicatorDisplay d;
d._coordinateScale=canvas/160; d._outputZoom=480.0f/canvas;
const auto bar=makeReaderTouchBar(d,160,readerTouchHeaderHeight(12));
const auto regions=makeTouchDebugAreas(160,160,70,false,nullptr,&bar);
assert(bar.exit_height==24 && regions.count==8);
// Actual Indicator reports BOTH axes opposite to the visible panel.
// Exercise every physical pixel through the real board + generic drivers,
// the gesture handler and the region used for green press feedback.
for(int py=0;py<480;++py) for(int px=0;px<480;++px) {
d.panel.x=479-px; d.panel.y=479-py;
int x=-1,y=-1;
assert(d.getTouch(&x,&y));
assert(159-x==px/3 && y==py/3);
const int region=touchDebugAreaAt(regions,159-x,y);
const int expected=py<72 ? 0 : py<bar.top*3 ? (px<240 ? 1 : 2) : 3+px/96;
assert(region==expected);
TouchInput input(true,true,70,true,false);
input.update(true,x,y,160,160,false,nullptr,&bar);
input.update(true,x,y,160,160,false,nullptr,&bar);
input.update(false,-1,-1,160,160,false,nullptr,&bar);
assert(input.update(false,-1,-1,160,160,false,nullptr,&bar)==regions.areas[expected].action);
}
auto drag=[&](int sx,int sy,int ex,int ey) {
TouchInput input(true,true,70,true,false);
int x,y;
d.panel.x=479-sx; d.panel.y=479-sy; assert(d.getTouch(&x,&y));
input.update(true,x,y,160,160,false,nullptr,&bar);
d.panel.x=479-ex; d.panel.y=479-ey; assert(d.getTouch(&x,&y));
input.update(true,x,y,160,160,false,nullptr,&bar);
input.update(false,-1,-1,160,160,false,nullptr,&bar);
return input.update(false,-1,-1,160,160,false,nullptr,&bar);
};
assert(drag(240,300,240,120)==TouchAction::VerticalNext);
assert(drag(240,120,240,300)==TouchAction::VerticalPrevious);
assert(drag(360,220,120,220)==TouchAction::Next);
assert(drag(120,220,360,220)==TouchAction::Previous);
for(int bad : {-1,480}) {
int x=0,y=0;
d.panel.y=bad; assert(!d.getTouch(&x,&y));
assert(x==-1 && y==-1);
}
int x,y;
assert(!d.getTouch(nullptr,&y) && !d.getTouch(&x,nullptr));
}
}
'''
source = PREAMBLE + generic + "struct SCIndicatorDisplay : LGFXDisplay {\n" + board + "\n};\n" + scenarios
with tempfile.TemporaryDirectory() as temp:
binary = Path(temp) / "indicator_touch_orientation"
compiled = subprocess.run(["c++", "-std=c++17", "-O1",
"-fsanitize=address,undefined", "-fno-pie", "-no-pie",
f"-I{ROOT / 'src'}", "-x", "c++", "-", "-o", str(binary)],
input=source, 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)
if __name__ == "__main__":
unittest.main()
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Compile actual UI hooks and CLI branches for touch, legacy and absent UIs."""
from pathlib import Path
import re
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
UI = ROOT / "examples/companion_radio/ui-new"
MESH = ROOT / "examples/companion_radio/MyMesh.cpp"
class TouchDebugCliTest(unittest.TestCase):
def test_runtime_default_toggle_redraw_and_unsupported_targets(self):
abstract = (ROOT / "examples/companion_radio/AbstractUITask.h").read_text()
header = (UI / "UITask.h").read_text()
hooks = ("supportsTouchDebug() const", "isTouchDebugEnabled() const",
"setTouchDebugEnabled(bool enabled)")
base = "\n".join(extract_braced(abstract, "virtual bool " + hook) for hook in hooks)
overrides = "\n".join(extract_braced(header, "bool " + hook) for hook in hooks)
fields = re.search(r" bool _touch_debug_enabled[^\n]*\n"
r" int _touch_debug_x[^\n]*\n"
r" UIScreen\* _touch_debug_screen[^\n]*", header).group()
mesh = MESH.read_text()
getter = extract_braced(mesh, 'if (strcmp(command, "get display.touch") == 0)')
setter = extract_braced(mesh, 'if (strncmp(command, "set display.touch", 17) == 0')
source = r'''
#include <cassert>
#include <cstdio>
#include <cstring>
#include <string>
#include <initializer_list>
struct UIScreen {};
struct Display {};
struct AbstractUITask {
BASE_HOOKS
};
struct UITask : AbstractUITask {
Display* _display=nullptr;
unsigned long _next_refresh=999;
int wakes=0;
char checkDisplayOn(char c) { assert(c==0); ++wakes; return 0; }
FIELDS
#ifdef HAS_TOUCH
OVERRIDES
#endif
};
struct MyMesh {
AbstractUITask* _ui=nullptr;
bool command(const char* command, char* reply, size_t reply_size) {
if(!command || !reply || !reply_size) return false;
GETTER
SETTER
return false;
}
};
int main() {
Display display;
UITask ui;
MyMesh mesh;
mesh._ui=&ui;
auto command=[&](const char* text) {
char reply[160]={};
assert(mesh.command(text,reply,sizeof(reply)));
return std::string(reply);
};
assert(!ui.isTouchDebugEnabled() && !ui.supportsTouchDebug());
assert(command("get display.touch").find("unsupported")!=std::string::npos);
assert(command("set display.touch on").find("unsupported")!=std::string::npos);
ui._display=&display;
#ifdef HAS_TOUCH
assert(ui.supportsTouchDebug());
assert(command("get display.touch")=="display.touch off (off after reboot)");
assert(command("set display.touch on")=="OK - display.touch on (off after reboot)");
assert(ui.isTouchDebugEnabled() && ui.wakes==1 && ui._next_refresh==0);
assert(command("get display.touch")=="display.touch on (off after reboot)");
// Invalid inputs must not mutate state or accidentally accept a suffix.
for(const char* input : {"set display.touch", "set display.touch ",
"set display.touch toggle", "set display.touch only", "set display.touch OFF",
"set display.touch 1", "set display.touch off extra", "set display.touch on reboot"}) {
assert(command(input)=="Error: use set display.touch on|off");
assert(ui.isTouchDebugEnabled() && ui.wakes==1);
}
UIScreen screen;
ui._touch_debug_x=48; ui._touch_debug_y=145;
ui._touch_debug_area=4; ui._touch_debug_screen=&screen;
ui._next_refresh=999;
assert(command("set display.touch\t off")=="OK - display.touch off (off after reboot)");
assert(!ui.isTouchDebugEnabled() && ui.wakes==1 && ui._next_refresh==0);
assert(ui._touch_debug_x==-1 && ui._touch_debug_y==-1 && ui._touch_debug_area==-1);
assert(ui._touch_debug_screen==nullptr);
assert(command("set display.touch on").find("OK")==0);
UITask rebooted;
rebooted._display=&display;
assert(!rebooted.isTouchDebugEnabled());
#else
assert(!ui.supportsTouchDebug());
assert(command("set display.touch on").find("unsupported")!=std::string::npos);
assert(!ui.isTouchDebugEnabled() && ui.wakes==0);
#endif
char reply[160]={};
for(const char* input : {"get display.touching", "set display.touching on", "get role"})
assert(!mesh.command(input,reply,sizeof(reply)));
assert(!mesh.command(nullptr,reply,sizeof(reply)));
assert(!mesh.command("get display.touch",nullptr,sizeof(reply)));
assert(!mesh.command("get display.touch",reply,0));
// No missing/legacy UI may crash or claim it enabled an invisible feature.
AbstractUITask legacy;
for(AbstractUITask* target : {&legacy,static_cast<AbstractUITask*>(nullptr)}) {
mesh._ui=target;
assert(command("get display.touch").find("unsupported")!=std::string::npos);
assert(command("set display.touch off").find("unsupported")!=std::string::npos);
}
// Short binary reply capacities remain bounded, including a one-byte reply.
mesh._ui=&ui;
for(size_t capacity : {size_t(1),size_t(8),size_t(20)}) {
char tiny[21]; memset(tiny,'!',sizeof(tiny));
assert(mesh.command("get display.touch",tiny,capacity));
assert(tiny[capacity-1]==0 && tiny[capacity]=='!');
}
}
'''.replace("BASE_HOOKS", base).replace("OVERRIDES", overrides).replace(
"FIELDS", fields).replace("GETTER", getter).replace("SETTER", setter)
for touch in (False, True):
with self.subTest(touch=touch), tempfile.TemporaryDirectory() as temp:
binary = Path(temp) / "touch_debug_cli"
compiled = subprocess.run(["c++", "-std=c++11", "-O1",
"-fsanitize=address,undefined", "-fno-pie", "-no-pie",
*(["-DHAS_TOUCH=1"] if touch else []),
"-x", "c++", "-", "-o", str(binary)],
input=source, 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_shared_runtime_feature_is_wired_to_cli_and_rendering(self):
source = (UI / "UITask.cpp").read_text()
header = (UI / "UITask.h").read_text()
mesh = MESH.read_text()
self.assertNotIn("UI_TOUCH_DEBUG_OUTLINES", source + header)
self.assertIn('#ifdef HAS_TOUCH\n #include <helpers/ui/TouchDebugOverlay.h>', source)
self.assertIn('#ifdef HAS_TOUCH\n bool supportsTouchDebug()', header)
self.assertIn("bool _touch_debug_enabled = false;", header)
loop = extract_braced(source, "void UITask::loop()")
feedback = extract_braced(loop, "if (_touch_debug_enabled)")
self.assertIn("touchDebugAreaAt", feedback)
draw = extract_braced(loop, "if (_touch_debug_enabled && !isPairingScreenActive())")
self.assertIn("drawTouchDebugAreas", draw)
local = extract_braced(mesh, "bool MyMesh::handleLocalControlCommand(")
self.assertIn('"get display.touch"', local)
handler = extract_braced(mesh, "bool MyMesh::handleCommand(const char* command,")
self.assertIn("handleLocalControlCommand(command, reply, reply_capacity)", handler)
terminal = extract_braced(mesh, "void MyMesh::handleTerminalCommand(")
self.assertIn("handleLocalControlCommand(command, local_reply, sizeof(local_reply))", terminal)
self.assertIn('get display.touch\\r\\n', terminal)
# No persistent preference schema or write is involved.
setter = extract_braced(local, 'if (strncmp(command, "set display.touch", 17) == 0')
self.assertNotIn("savePrefs", setter)
self.assertNotIn("_prefs", setter)
for profile in (ROOT / "variants").rglob("platformio.ini"):
self.assertNotIn("UI_TOUCH_DEBUG_OUTLINES", profile.read_text())
if __name__ == "__main__":
unittest.main()
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Yellow outlines must describe real touch actions, not a second guessed UI."""
from pathlib import Path
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]
TEST = r'''
#include <helpers/ui/CompanionTransportSelectorLayout.h>
using namespace mesh::ui;
struct DebugDisplay : LGFXDisplay {
ColorVal color=0;
std::vector<ColorVal> pixels=std::vector<ColorVal>(160*160,0);
void setColor(ColorVal c) override { color=c; }
void fillRect(int x,int y,int w,int h) override {
LGFXDisplay::fillRect(x,y,w,h);
for(int py=y;py<y+h;++py) for(int px=x;px<x+w;++px)
pixels[py*160+px]=color;
}
};
void verify_actions(int width,int center,bool bottom,
const TouchSplitSelector* split,const TouchNavigationBar* reader) {
const auto regions=makeTouchDebugAreas(width,width,center,bottom,split,reader);
for(bool mirrored : {false,true}) for(int y=0;y<width;++y) for(int x=0;x<width;++x) {
TouchAction expected=TouchAction::None;
int matches=0;
for(int i=0;i<regions.count;++i) {
const auto& r=regions.areas[i];
if(x>=r.x && x<r.x+r.width && y>=r.y && y<r.y+r.height) {
expected=r.action; ++matches;
}
}
assert(matches<=1);
TouchInput input(true,true,center,mirrored,false);
int tx=mirrored ? width-1-x : x;
input.update(true,tx,y,width,width,bottom,split,reader);
input.update(true,tx,y,width,width,bottom,split,reader);
input.update(false,-1,-1,width,width,bottom,split,reader);
assert(input.update(false,-1,-1,width,width,bottom,split,reader)==expected);
}
}
int main() {
// Home: three zones. Transport: choices plus page edges, no inert gaps.
// Reader: header, two body halves, and all five footer cells.
for(int width : {137,160,320,480}) {
for(int center : {34,70,100}) {
verify_actions(width,center,false,nullptr,nullptr);
verify_actions(width,center,true,nullptr,nullptr);
}
TouchSplitSelector split={2,width/2-4,width/2+2,width/2-4,40,width-50};
verify_actions(width,70,false,&split,nullptr);
verify_actions(width,70,true,&split,nullptr);
for(int center : {34,70,100}) {
const auto layout=makeCompanionTransportSelectorLayout(width,width,center);
TouchSplitSelector centered={layout.wifi.x,layout.wifi.width,
layout.bluetooth.x,layout.bluetooth.width,layout.wifi.y,
layout.wifi.height,layout.side_nav_width};
verify_actions(width,center,false,&centered,nullptr);
verify_actions(width,center,true,&centered,nullptr);
const auto areas=makeTouchDebugAreas(width,width,center,false,&centered,nullptr);
assert(areas.count==(center==100 ? 2 : 4));
}
TouchNavigationBar bar;
bar.top=width-24; bar.height=24; bar.exit_height=12;
verify_actions(width,70,false,nullptr,&bar);
bar.top-=10; // optional space below the footer remains truthfully marked
verify_actions(width,70,true,nullptr,&bar);
}
for(int canvas : {320,480}) {
DebugDisplay d;
d._coordinateScale=canvas/160; d._outputZoom=480.0f/canvas;
auto bar=makeReaderTouchBar(d,160,10);
auto regions=makeTouchDebugAreas(160,160,70,false,nullptr,&bar);
assert(regions.count==8);
drawTouchDebugAreas(d,regions);
int dots=0,empty_edges=0;
for(int y=0;y<160;++y) for(int x=0;x<160;++x) {
bool perimeter=false;
for(int i=0;i<regions.count;++i) {
const auto& r=regions.areas[i];
if(x>=r.x && x<r.x+r.width && y>=r.y && y<r.y+r.height)
perimeter |= x==r.x || x==r.x+r.width-1 || y==r.y || y==r.y+r.height-1;
}
if(d.pixels[y*160+x]) {
assert(perimeter && d.pixels[y*160+x]==color_theme::TOUCH_OUTLINE); ++dots;
} else if(perimeter) ++empty_edges;
}
assert(dots>100 && empty_edges>dots); // dotted, not solid or filled
for(int i=0;i<regions.count;++i) {
const auto& r=regions.areas[i];
assert(d.pixels[r.y*160+r.x]==color_theme::TOUCH_OUTLINE);
assert(d.pixels[(r.y+r.height-1)*160+r.x+r.width-1]==color_theme::TOUCH_OUTLINE);
}
for(int active=0;active<regions.count;++active) {
const auto& target=regions.areas[active];
const int x=target.x+target.width/2, y=target.y+target.height/2;
assert(touchDebugAreaAt(regions,x,y)==active);
drawTouchDebugAreas(d,regions,x,y);
for(int i=0;i<regions.count;++i) {
const auto& r=regions.areas[i];
assert(d.pixels[r.y*160+r.x]==(i==active
? color_theme::TOUCH_PRESSED : color_theme::TOUCH_OUTLINE));
}
drawTouchDebugAreas(d,regions); // release restores yellow immediately
assert(d.pixels[target.y*160+target.x]==color_theme::TOUCH_OUTLINE);
}
assert(touchDebugAreaAt(regions,-1,-1)==-1);
assert(touchDebugAreaAt(regions,160,159)==-1);
}
// RGB565 yellow used to fall through to palette index 0 (black). Exercise
// the actual production indexed-colour conversion and palette entry.
assert(actualRenderColor(color_theme::TOUCH_OUTLINE)==color_theme::INDEX_TOUCH_OUTLINE);
assert(UI_PALETTE[color_theme::INDEX_TOUCH_OUTLINE]==0xFFFF00);
assert(actualRenderColor(color_theme::TOUCH_PRESSED)==color_theme::INDEX_TOUCH_PRESSED);
assert(UI_PALETTE[color_theme::INDEX_TOUCH_PRESSED]==0x00FF00);
}
'''
class TouchDebugOverlayTest(unittest.TestCase):
def test_outlines_match_every_tap_pixel_and_stay_yellow(self):
display = (ROOT / "src/helpers/ui/LGFXDisplay.cpp").read_text()
actual_touch = extract_braced(display, "bool LGFXDisplay::getTouch(")
actual_color = extract_braced(display, "uint32_t LGFXDisplay::renderColor(").replace(
"uint32_t LGFXDisplay::renderColor(ColorVal color) const",
"uint32_t actualRenderColor(ColorVal color)")
palette = extract_braced(display, "static const uint32_t UI_PALETTE[16]") + ";"
source = '#define UI_BUFFER_COLOR_DEPTH 4\n#include <helpers/ui/TouchDebugOverlay.h>\n'
source += PREAMBLE + actual_touch + actual_color + palette + TEST
with tempfile.TemporaryDirectory() as temp:
binary = Path(temp) / "touch_debug_overlay"
compiled = subprocess.run(["c++", "-std=c++17", "-O1",
"-fsanitize=address,undefined", "-fno-pie", "-no-pie",
f"-I{ROOT / 'src'}", "-x", "c++", "-", "-o", str(binary)],
input=source, 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_overlay_uses_the_same_current_controls_after_content_render(self):
source = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text()
loop = extract_braced(source, "void UITask::loop()")
self.assertIn("getTouchControls(transport_touch_selector, split_transport_selector, reader_touch_bar)", loop)
self.assertIn("getTouchControls(transport, split, reader)", loop)
render = loop.index("int delay_millis = curr->render(*_display);")
outline = loop.index("mesh::ui::drawTouchDebugAreas", render)
present = loop.index("_display->endFrame();", outline)
self.assertLess(render, outline)
self.assertLess(outline, present)
self.assertIn("if (_touch_debug_enabled && !isPairingScreenActive())", loop[render:outline])
feedback = extract_braced(loop, "if (_touch_debug_enabled)")
self.assertIn("mesh::ui::touchDebugAreaAt", feedback)
profile = (ROOT / "variants/sensecap_indicator-espnow/platformio.ini").read_text()
self.assertNotIn("UI_TOUCH_DEBUG_OUTLINES", profile)
self.assertIn("TOUCH_MIRROR_TAP_X_ENABLED ? _display->width() - 1 - touch_x : touch_x", loop)
self.assertIn("if (debug_area != _touch_debug_area || curr != _touch_debug_screen) _next_refresh = 0;", loop)
self.assertIn("_touch_debug_screen == curr ? _touch_debug_x : -1", loop)
if __name__ == "__main__":
unittest.main()
@@ -15,6 +15,76 @@ TouchAction release(TouchInput& input, int width = 137, int height = 137) {
} // namespace
TEST(TouchInput, ReaderFooterHasFiveFullWidthTargetsIncludingExit) {
using mesh::ui::TouchNavigationBar;
// Same logical positions on the Indicator's 320 and 480 rendering profiles.
for (int width : {160, 320, 480}) {
for (bool mirrored : {false, true}) {
TouchInput input(true, true, 70, mirrored);
TouchNavigationBar bar;
bar.top = width - 24;
bar.height = 24;
bar.exit_height = 12;
const TouchAction expected[] = {TouchAction::VerticalPrevious,
TouchAction::Previous, TouchAction::Next,
TouchAction::VerticalNext, TouchAction::Select};
// Every point of each cell is usable, not only the arrow glyph.
for (int visual_x = 0; visual_x < width; ++visual_x) {
const int x = mirrored ? width - 1 - visual_x : visual_x;
input.update(true, x, bar.top, width, width, true, nullptr, &bar);
EXPECT_EQ(input.update(false, -1, -1, width, width, true, nullptr, &bar), TouchAction::None);
EXPECT_EQ(input.update(false, -1, -1, width, width, true, nullptr, &bar),
expected[visual_x / (width / 5)]);
EXPECT_EQ(input.update(false, -1, -1, width, width, true, nullptr, &bar), TouchAction::None);
}
// The top header is an explicit exit target across its entire width.
for (int visual_x : {0, width / 2, width - 1}) {
const int x = mirrored ? width - 1 - visual_x : visual_x;
input.update(true, x, 0, width, width, true, nullptr, &bar);
input.update(false, -1, -1, width, width, true, nullptr, &bar);
EXPECT_EQ(input.update(false, -1, -1, width, width, true, nullptr, &bar),
TouchAction::Select);
}
}
}
}
TEST(TouchInput, ReaderFooterDragDoesNotActivateExitOrEmitAnEndpointTap) {
mesh::ui::TouchNavigationBar bar;
bar.top = 130; bar.height = 16;
TouchInput input(true, true, 70, true);
input.update(true, 10, 135, 160, 160, true, nullptr, &bar);
input.update(true, 90, 135, 160, 160, true, nullptr, &bar);
EXPECT_EQ(input.update(false, -1, -1, 160, 160, true, nullptr, &bar), TouchAction::None);
// A transient lost sample cannot end the swipe.
input.update(true, 120, 135, 160, 160, true, nullptr, &bar);
input.update(false, -1, -1, 160, 160, true, nullptr, &bar);
EXPECT_EQ(input.update(false, -1, -1, 160, 160, true, nullptr, &bar), TouchAction::None);
EXPECT_EQ(input.update(false, -1, -1, 160, 160, true, nullptr, &bar), TouchAction::None);
// A one-sample contact away from the new targets is still ignored.
input.update(true, 80, 50, 160, 160, false, nullptr, &bar);
input.update(false, -1, -1, 160, 160, false, nullptr, &bar);
EXPECT_EQ(input.update(false, -1, -1, 160, 160, false, nullptr, &bar), TouchAction::None);
}
TEST(TouchInput, RemovedChannelBarDoesNotLeaveInvisibleSelectorAboveNavigation) {
mesh::ui::TouchNavigationBar bar;
bar.top = 144; bar.height = 16;
TouchInput input(true, true, 70, true);
auto tap = [&](int visual_x, int y) {
const int raw_x = 159 - visual_x;
input.update(true, raw_x, y, 160, 160, false, nullptr, &bar);
input.update(true, raw_x, y, 160, 160, false, nullptr, &bar);
input.update(false, -1, -1, 160, 160, false, nullptr, &bar);
return input.update(false, -1, -1, 160, 160, false, nullptr, &bar);
};
EXPECT_EQ(tap(0, 135), TouchAction::Previous);
EXPECT_EQ(tap(159, 135), TouchAction::Next);
EXPECT_EQ(tap(80, 135), TouchAction::Next);
EXPECT_EQ(tap(0, 144), TouchAction::VerticalPrevious);
EXPECT_EQ(tap(159, 159), TouchAction::Select); // X now owns the bottom-right.
}
TEST(TouchInput, EmitsOnlyAfterRelease) {
TouchInput input;
EXPECT_EQ(input.update(true, 70, 50, 137, 137), TouchAction::None);
+82 -17
View File
@@ -27,6 +27,32 @@ wakes it without selecting anything.
## Touch navigation
Touch diagnostics are available on all touchscreen builds using the shared UI,
and are **off by default at every boot**. Use the CLI without rebuilding:
```text
get display.touch
set display.touch on
set display.touch off
```
Enabling wakes the screen without selecting an action. The setting is temporary
and is not written to flash; rebooting turns it off. It is available through
the Companion text terminal and framed CLI over USB, BLE or TCP, where supported.
Non-touchscreen or legacy UIs return an unsupported error.
Dotted yellow boxes outline every stationary tap area: three on ordinary main
screens, four on the WiFi/BLE selector (two choices and two page edges), and
eight in either reader (header Exit,
two body halves, five footer controls). The outlines use the active screen's
logical hit geometry and scale with the rendering; the font and actions do not
change. Turn the option off when the diagnostic overlay is no longer needed.
No boxes are shown while the pairing screen is consuming all navigation.
The box containing the currently detected touch turns dotted green on press,
returns to yellow on release, and follows a held finger between regions. This
uses the same visual-coordinate correction as the tap handler; press, release
and region changes request an immediate redraw without changing the gesture
action, which still waits for stable release.
- Swipe horizontally to move between home pages. The dots under the status bar
show the current page.
- The middle 70% of the panel is the Select tap target. The outer 15% on each
@@ -35,14 +61,50 @@ wakes it without selecting anything.
are ignored so one swipe cannot become a second, opposite endpoint tap.
- The SenseCAP controller reports a mirrored physical X coordinate. The board
profile corrects stationary visual left/right taps independently from its
existing swipe-direction correction.
existing swipe-direction correction. Its Y coordinate also runs opposite
to the rendered image; the Indicator display driver reverses Y once, before
gesture detection and green press feedback, on both canvas sizes.
- The transport page is the exception for a very quick stationary contact:
either tall, outlined WiFi/BLE box can accept one sampled point after the
same stable-release debounce. Taps in the title, center gap, or footer stay
inert, and a moving contact is still handled as a swipe first.
same stable-release debounce. Both choices fit inside the middle 70%, leaving
full-height 15% Previous/Next strips on the edges. Taps in the central title,
gap, or prompt stay inert; moving contacts are handled as swipes first.
## Reading messages
The physical button now supports the message reader controls as well as touch:
- One click goes forward; two quick clicks go back one page. This also works
on the main screens. Clicks are grouped until 450 ms after the last release,
so a double click does not first advance the page. RGB redraws wait until
the gesture finishes so they cannot interrupt polling between clicks.
- Three/four clicks select the next/previous channel in the inbox, or the
next/previous chapter in the offline reader.
- Hold for one second to exit either reader. From the main screens, holding
selects the current page's action. Three/four clicks are reader group
controls; they do not change the main screens (the Indicator has no buzzer).
- Both readers have five evenly spaced footer touch targets: `4<<`, `2<`,
`>1`, `>>3`, and `X`. Tap the full area around an arrow for its action, or
`X` to exit. The numbers indicate physical-button click counts, not required
touchscreen taps. The controls are blue with a horizontal line above them,
and occupy the bottom row without a second channel bar. The row is 72
physical pixels high and each cell is 96 pixels wide on the 480x480 panel.
Touch coordinates map to the same logical layout with either the 480px
native canvas or the 320px scaled canvas; the font size is unchanged.
- Tap the top header to exit. It is padded to at least 72 physical pixels high,
with the text centered vertically and the message starting below its divider.
In the message body (between header and footer),
tap the left/right half to move backward/forward. A left swipe moves forward,
a right swipe moves backward. Swipe up for the next channel/chapter (`>>`),
down for the previous one (`<<`). Vertical direction is independent of the
controller's mirrored X axis.
- Header and footer controls accept taps, not drags. A body tap or a slightly
missed arrow cannot fall through to Exit; only the top header or footer `X`
exits. A swipe that starts in the body stays a swipe when it crosses a bar.
- The first button gesture when the screen is off only wakes it; it does not
also navigate. A touch anywhere can still wake a sleeping reader; the main
screens retain their existing left/right/center targets.
The message preview is a non-destructive local inbox, newest first:
- A Messages home page, immediately after the status page, shows the newest
@@ -56,8 +118,9 @@ The message preview is a non-destructive local inbox, newest first:
view. Horizontal swipes continue to move among the eight home pages.
- The first home page shows the retained local `INBOX` count, which remains
useful when a USB host has already drained the protocol's unread count. Tap
the center to open the inbox and channel selector even when the count is 0.
- `Message 1/N` is the newest buffered preview.
the center to open the inbox even when the count is 0.
- `Ch 0 1/N`, `DM 1/N`, or `All 1/N` identifies the selected filter and the
newest buffered preview. The filter stays visible even when it is empty.
- Each preview retains the complete 160-byte maximum chat payload. Long UTF-8
messages wrap through the available middle of the screen instead of being
cut at the small-display 78-byte limit.
@@ -66,14 +129,14 @@ The message preview is a non-destructive local inbox, newest first:
remains available from the summary page and inbox.
- Swipe left for an older preview and right for a newer preview. Navigation
stops at the oldest and newest entries instead of wrapping.
- The bottom bar shows the active inbox filter. Its large `<` and `>` end
buttons select the previous or next filter; swipe up or down does the same.
- Tap `4<<` / `>>3` to select the previous/next filter; swipe up or down
does the same. The header shows the selected filter.
Filters include `All channels`, `Direct`, and configured channels such as
`Ch 1 #testing`. A newly received message automatically selects its source
channel. On the 480x480 Indicator, the full inbox's top status strip and
bottom selector use a compact 24-physical-pixel font; their visual heights
are 27 and 42 pixels while the forgiving arrow touch regions remain 120x120.
- Tap the center to return home without deleting the previews.
channel. On the 480x480 Indicator, the status and navigation text use the
compact font. Each of the five navigation targets is 96 physical pixels
wide and 72 pixels tall, including the empty space around its label.
- Tap the header or footer `X` to return home without deleting the previews.
- On the first home page, tap the wide center target to reopen the previews,
including when `INBOX` is zero.
@@ -81,7 +144,7 @@ Channel messages identify both the configured slot and name, such as
`Ch 0 Public` or `Ch 1 #testing`. The value in brackets after it is the route,
for example `[0h]` for a zero-hop flood or `[direct]` for a direct route.
The bottom selector filters received messages; it does not change the outbound
The reader's group controls filter received messages; they do not change the outbound
channel because the device UI does not include a text composer. Select the
outbound channel in the connected application, or list and address channel
slots from the CLI with `get_channels`, `public <message>`, or `chan <slot>
@@ -141,11 +204,13 @@ Bluetooth controller and host memory. BLE mode does not start infrastructure
WiFi, WebConfig, MQTT, OTA networking, or TCP Companion services. USB remains
available in both modes for recovery.
The Indicator transport page presents WiFi and BLE as full-height side-by-side
choices. Tapping either choice saves it and reboots only when it differs from
the active mode. WiFi is split over two size-4 rows, BLE uses size 4, and the
short `ON`/`NEXT` state uses size 3; all render at the same physical size in
the 320 and 480 profiles. On a retained native 480 canvas, the first home page
The Indicator transport page presents WiFi and BLE as tall side-by-side
choices within the middle 70% tap zone. Each choice is 156 physical pixels
wide, leaving a 72-pixel full-height page-navigation strip at each edge.
Tapping either choice saves it and reboots only when it differs from the
active mode. Labels and `ON`/`NEXT` status shrink to fit their own boxes;
all render at the same physical size in the 320 and 480 profiles.
On a retained native 480 canvas, the first home page
uses separate full-width size-4 `INBOX` and count rows plus a large lower
action or BLE-status block. Long IP addresses retain a smaller bounded row
instead of clipping.
@@ -262,6 +262,14 @@ public:
SCIndicatorDisplay() : LGFXDisplay(480, 480, disp) {}
#endif
bool getTouch(int* x, int* y) override {
if (!LGFXDisplay::getTouch(x, y)) return false;
// Touch Y runs bottom-to-top relative to this panel's rendered image.
// Correct it once before both gesture detection and debug feedback.
*y = height() - 1 - *y;
return true;
}
bool begin() {
begin_status = 1;
const bool controllers_prepared = prepareControllers();
@@ -36,6 +36,11 @@ build_flags =
; Use fractional smooth-font scaling for the full inbox's Message/age header
; and channel selector, reclaiming space without shrinking touch targets.
-D UI_COMPACT_MESSAGE_STATUS=1
; One reader navigation bar handles buttons and touch. Keep the selected
; channel in the header instead of duplicating controls in a bottom bar.
-D UI_BUTTON_READER_HINT=1
-D UI_READER_TOUCH_BAR=1
-D UI_MESSAGE_CHANNEL_FOOTER=0
-D AUTO_OFF_MILLIS=300000UL
-D USB_MESSAGE_PREVIEW_MILLIS=300000UL
; Retain every byte of a maximum-length 160-byte chat message. The generic
@@ -51,8 +56,13 @@ build_flags =
-D UI_RECENT_LIST_SIZE=9
-D UI_SENSORS_PAGE=1
-D PIN_USER_BTN=38
; Allow a deliberate three/four-click cadence on the physical top button.
; Don't let a synchronous RGB redraw swallow edges between those clicks.
-D MOMENTARY_BUTTON_MULTI_CLICK_MS=450
-D UI_DEFER_RENDER_DURING_BUTTON_GESTURE=1
-D HAS_TOUCH
-D TOUCH_REVERSE_SWIPE
-D TOUCH_REVERSE_VERTICAL_SWIPE=0
; The panel's physical X axis is mirrored. Swipe direction is corrected
; separately; stationary taps also need visual left/right hit testing.
-D TOUCH_MIRROR_TAP_X
@@ -27,9 +27,9 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager();
#ifdef DISPLAY_CLASS
DISPLAY_CLASS display;
#ifdef PIN_USER_BTN
// Touch supplies the other navigation actions, so dispatch the physical
// button's single click immediately instead of waiting for a multi-click.
MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true, false);
// The physical button uses the same 1/2/3/4-click navigation as other
// single-button companions, independently of the touchscreen controls.
MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true, true);
#endif
#endif