Fix touchscreen navigation and add runtime touch diagnostics
This commit is contained in:
1 parent
23fe7c8a9d
commit
14796e6573
32 files changed
+1785
-123
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
@@ -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);",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,¢ered,nullptr);
|
||||
verify_actions(width,center,true,¢ered,nullptr);
|
||||
const auto areas=makeTouchDebugAreas(width,width,center,false,¢ered,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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user