From f778d14c768cabdd05ef43049ed3dceebc3992ce Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 8 Sep 2026 22:47:15 -0700 Subject: [PATCH] Add multi-tap channel navigation for messages --- docs/v4_pixel5_font_trial.md | 26 ++- .../companion_radio/ui-new/JohnReaderScreen.h | 16 +- examples/companion_radio/ui-new/UITask.cpp | 93 +++++--- examples/companion_radio/ui-new/UITask.h | 2 +- src/helpers/ui/MomentaryButton.cpp | 5 +- src/helpers/ui/MomentaryButton.h | 3 + src/helpers/ui/ReaderNavigationHint.h | 21 +- test/fixtures/companion_john/test_reader.cpp | 32 +++ .../test_display_driver.cpp | 39 +++- test/test_message_navigation.py | 207 ++++++++++++++++++ .../test_momentary_button.cpp | 38 ++++ tools/bible/README.md | 17 +- 12 files changed, 431 insertions(+), 68 deletions(-) create mode 100644 test/test_message_navigation.py diff --git a/docs/v4_pixel5_font_trial.md b/docs/v4_pixel5_font_trial.md index c119f128..0731ec64 100644 --- a/docs/v4_pixel5_font_trial.md +++ b/docs/v4_pixel5_font_trial.md @@ -32,12 +32,18 @@ Readers without the button hint retain the y=14 origin and y=22 message text. On V4 and other single-button builds using this reader, the home screen says `hold button: inbox`. Hold the user button for about 1.2 seconds to open it. -The bottom of the reader shows `<- 2 tap 1 tap -> long press: exit`: double tap -the button for the previous message, tap once for the next, and hold to -return home. Advancing past the last message also returns home. The hint -appears even when the inbox is empty and reflows onto additional lines on -narrower screens. It replaces the unusable channel selector on single-button -builds. These are button taps. Touchscreen and joystick builds retain +The bottom alternates between `<- 2 tap 1 tap -> long press: exit` and +`<<- 4 tap 3 tap ->>` every three seconds. Double tap goes to the previous +message, one tap advances, three taps select the next channel, and four taps +select the previous channel. Channel selection cycles through All, configured +channels, and direct messages, starting at the newest message in each filter. +The header shows `All`, `Ch N`, or `DM`, including when a channel is empty. +Hold to return home; advancing past the last message also returns home. +The hints share the same footer space and use `hold: X` when the longer exit +label does not fit. Both fit in the V4's existing 8-pixel footer at y=56, leaving +five message rows. Narrower screens split the hint across lines; switching +hints does not change the space reserved for text. The hints appear even when +the inbox is empty. These are button taps. Touchscreen and joystick builds retain instructions appropriate to their controls. During the first eight seconds after startup, holding the button on an ordinary home page enters CLI rescue instead; wait for that startup window to finish before opening @@ -45,9 +51,11 @@ the inbox. Exiting a message preview and the WiFi setup page's hold action remain available immediately. The hidden [John reader](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/bible/README.md#on-device-reader), opened -by a long press on the radio page, uses the same button hint. It reserves the -hint before pagination and resumes saved bookmarks at the page containing -the same text, even after the available page size changes. +by a long press on the radio page, uses the same button hints. Three taps jump +to the first verse of the next chapter; four taps jump to the first verse of +the previous chapter. Chapter navigation stops at the beginning and end of +the book. It reserves the hint before pagination and resumes saved bookmarks +at the page containing the same text, even after the available page size changes. Larger display classes keep their existing font. Menus, Bluetooth PINs and WiFi setup QR codes keep their normal layout. TFT drivers use native panel diff --git a/examples/companion_radio/ui-new/JohnReaderScreen.h b/examples/companion_radio/ui-new/JohnReaderScreen.h index d3716593..7ffe334e 100644 --- a/examples/companion_radio/ui-new/JohnReaderScreen.h +++ b/examples/companion_radio/ui-new/JohnReaderScreen.h @@ -40,7 +40,8 @@ class JohnReaderScreen : public UIScreen { DisplayDriver& hint_text = small_hint ? static_cast(compact) : *_display; const auto hint = mesh::ui::makeButtonReaderHintLayout(hint_text, - small_hint ? compact.glyphHeight() : header_height, bottom); + small_hint ? compact.glyphHeight() : header_height, bottom, + (millis() / 3000U) % 2 != 0); bottom = hint.top; #endif #if UI_SMALL_MESSAGE_FONT @@ -143,11 +144,22 @@ public: const uint32_t now = millis(); if (_bookmark.due(now) && (!_retry_at || int32_t(now - _retry_at) >= 0)) flush(); } - int render(DisplayDriver&) override { process(0, true); return 5000; } + int render(DisplayDriver&) override { process(0, true); return 1000; } bool handleInput(char c) override { const uint8_t key = static_cast(c); if (key == KEY_NEXT || key == KEY_RIGHT) { process(1, false); return true; } if (key == KEY_PREV || key == KEY_LEFT) { process(-1, false); return true; } + if (key == KEY_DOWN || key == KEY_UP) { + using namespace mesh::bible; + const Reference current = referenceAt(_bookmark.position().verse); + const int chapter = current.chapter + (key == KEY_DOWN ? 1 : -1); + Position pos; + if (chapter >= 1 && chapter <= int(sizeof(kChapterVerses)) + && verseNumber({static_cast(chapter), 1}, pos.verse)) { + _bookmark.move(pos, millis()); + } + return true; + } if (key == KEY_ENTER || key == KEY_CANCEL) { _task->closeJohnReader(); return true; } return false; } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 80161519..e772f303 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1240,8 +1240,8 @@ public: #ifndef UI_COMPACT_MESSAGE_STATUS #define UI_COMPACT_MESSAGE_STATUS 0 #endif -// Single-button readers cannot operate the up/down channel selector. Use -// their footer space for the available navigation controls instead. +// Single-button screens use multi-tap hints in place of the taller channel +// selector. Three/four taps use the same up/down channel actions. #ifndef UI_MESSAGE_CHANNEL_FOOTER #if defined(MESHCORE_HAS_SMALL_DISPLAY) || UI_BUTTON_READER_HINT == 1 #define UI_MESSAGE_CHANNEL_FOOTER 0 @@ -1500,7 +1500,8 @@ public: #endif const mesh::ui::ButtonReaderHintLayout hint = mesh::ui::makeButtonReaderHintLayout( - reader_text, hint_line_height, body_bottom); + reader_text, hint_line_height, body_bottom, + (millis() / 3000U) % 2 != 0); body_bottom = hint.top; DisplayDriver& header = reader_text; #else @@ -1511,11 +1512,34 @@ public: if (view_offset >= filtered_count) view_offset = 0; header.setCursor(0, 0); header.setColor(UIColor::corp_blue); +#if UI_BUTTON_READER_HINT == 1 + // Keep the selected filter visible even when that channel has no messages. + char channel[12]; + if (channel_filter == CHANNEL_FILTER_ALL) strcpy(channel, "All"); + else if (channel_filter == CHANNEL_FILTER_DIRECT) strcpy(channel, "DM"); + else snprintf(channel, sizeof(channel), "Ch %d", channel_filter); + snprintf(tmp, sizeof(tmp), "%s %d/%d", channel, + filtered_count == 0 ? 0 : view_offset + 1, filtered_count); +#else snprintf(tmp, sizeof(tmp), display.width() < 100 ? "%d/%d" : "Message %d/%d", filtered_count == 0 ? 0 : view_offset + 1, filtered_count); - header.print(tmp); - +#endif const MsgEntry* p = filteredEntry(view_offset); + char age[16] = {}; + if (p != nullptr) { + mesh::ui::formatCompanionMessageAge( + age, sizeof(age), companionMessageElapsedMillis(p->heard_millis)); + if (display.width() < 100) { + char* suffix = strchr(age, ' '); + if (suffix != nullptr) *suffix = 0; + } + } + // On narrow rotations the channel and position take priority over age. + const bool show_age = age[0] != 0 + && 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); if (p == nullptr) { display.drawRect(0, layout.header_divider_y, display.width(), 1); @@ -1532,17 +1556,13 @@ public: "No buffered messages"); #endif renderChannelFilter(display); - return 5000; + return UI_BUTTON_READER_HINT == 1 ? 3000 : 5000; } - mesh::ui::formatCompanionMessageAge( - tmp, sizeof(tmp), companionMessageElapsedMillis(p->heard_millis)); - if (display.width() < 100) { - char* suffix = strchr(tmp, ' '); - if (suffix != nullptr) *suffix = 0; + if (show_age) { + header.setCursor(display.width() - header.getTextWidth(age) - 2, 0); + header.print(age); } - header.setCursor(display.width() - header.getTextWidth(tmp) - 2, 0); - header.print(tmp); display.drawRect(0, layout.header_divider_y, display.width(), 1); display.setCompactText(false); @@ -1578,14 +1598,15 @@ public: renderChannelFilter(display); #if AUTO_OFF_MILLIS==0 // probably e-ink - return 10000; // 10 s + return UI_BUTTON_READER_HINT == 1 ? 3000 : 10000; #else return 1000; // next render after 1000 ms #endif } bool handleInput(char c) override { - if (c == KEY_NEXT || c == KEY_RIGHT) { + const uint8_t key = static_cast(c); + if (key == KEY_NEXT || key == KEY_RIGHT) { if (view_offset + 1 < filteredCount()) { ++view_offset; } else { @@ -1596,19 +1617,19 @@ public: } return true; } - if (c == KEY_PREV || c == KEY_LEFT) { + if (key == KEY_PREV || key == KEY_LEFT) { if (view_offset > 0) --view_offset; return true; } - if (c == KEY_DOWN) { + if (key == KEY_DOWN) { cycleChannelFilter(1); return true; } - if (c == KEY_UP) { + if (key == KEY_UP) { cycleChannelFilter(-1); return true; } - if (c == KEY_ENTER) { + if (key == KEY_ENTER) { _task->gotoHomeScreen(); return true; } @@ -1626,12 +1647,17 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode #if defined(PIN_USER_BTN) user_btn.begin(); + user_btn.enableQuadrupleClick(); +#endif +#if UI_HAS_JOYSTICK + back_btn.enableQuadrupleClick(); #endif #if defined(TBEAM_1W) && defined(WIFI_SSID) && defined(PIN_WIFI_BTN) wifi_btn.begin(); #endif #if defined(PIN_USER_BTN_ANA) analog_btn.begin(); + analog_btn.enableQuadrupleClick(); #endif _node_prefs = node_prefs; @@ -1962,8 +1988,8 @@ void UITask::loop() { c = handleLongPress(KEY_RIGHT); } ev = back_btn.check(); - if (ev == BUTTON_EVENT_TRIPLE_CLICK) { - c = handleTripleClick(KEY_SELECT); + if (ev == BUTTON_EVENT_TRIPLE_CLICK || ev == BUTTON_EVENT_QUADRUPLE_CLICK) { + c = handleMultiClick(KEY_SELECT, ev == BUTTON_EVENT_QUADRUPLE_CLICK); } #elif defined(PIN_USER_BTN) int ev = user_btn.check(); @@ -1979,8 +2005,8 @@ void UITask::loop() { display.turnOff(); } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) { c = handleDoubleClick(KEY_SELECT); - } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { - c = handleTripleClick(KEY_SELECT); + } else if (ev == BUTTON_EVENT_TRIPLE_CLICK || ev == BUTTON_EVENT_QUADRUPLE_CLICK) { + c = handleMultiClick(KEY_SELECT, ev == BUTTON_EVENT_QUADRUPLE_CLICK); } #else if (ev == BUTTON_EVENT_CLICK) { @@ -1993,10 +2019,10 @@ void UITask::loop() { c = (_display != NULL && !_display->isOn()) ? checkDisplayOn(KEY_ENTER) : handleDoubleClick(KEY_PREV); - } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { + } else if (ev == BUTTON_EVENT_TRIPLE_CLICK || ev == BUTTON_EVENT_QUADRUPLE_CLICK) { c = (_display != NULL && !_display->isOn()) ? checkDisplayOn(KEY_ENTER) - : handleTripleClick(KEY_SELECT); + : handleMultiClick(KEY_SELECT, ev == BUTTON_EVENT_QUADRUPLE_CLICK); } #endif #endif @@ -2019,8 +2045,8 @@ void UITask::loop() { c = handleLongPress(KEY_ENTER); } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) { c = handleDoubleClick(KEY_PREV); - } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { - c = handleTripleClick(KEY_SELECT); + } else if (ev == BUTTON_EVENT_TRIPLE_CLICK || ev == BUTTON_EVENT_QUADRUPLE_CLICK) { + c = handleMultiClick(KEY_SELECT, ev == BUTTON_EVENT_QUADRUPLE_CLICK); } _analogue_pin_read_millis = millis(); } @@ -2239,12 +2265,17 @@ char UITask::handleDoubleClick(char c) { return c; } -char UITask::handleTripleClick(char c) { - MESH_DEBUG_PRINTLN("UITask: triple click triggered"); +char UITask::handleMultiClick(char c, bool backwards) { + if (curr == msg_preview +#if COMPANION_FEATURE_JOHN + || isJohnReaderActive() +#endif + ) { + return checkDisplayOn(backwards ? KEY_UP : KEY_DOWN); + } checkDisplayOn(c); toggleBuzzer(); - c = 0; - return c; + return 0; } bool UITask::getGPSState() { diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 7120c5d8..15b1716c 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -95,7 +95,7 @@ class UITask : public AbstractUITask { char checkDisplayOn(char c); char handleLongPress(char c); char handleDoubleClick(char c); - char handleTripleClick(char c); + char handleMultiClick(char c, bool backwards); void setCurrScreen(UIScreen* c); bool isPairingScreenActive() const; diff --git a/src/helpers/ui/MomentaryButton.cpp b/src/helpers/ui/MomentaryButton.cpp index 285583d7..14951700 100644 --- a/src/helpers/ui/MomentaryButton.cpp +++ b/src/helpers/ui/MomentaryButton.cpp @@ -210,8 +210,9 @@ int MomentaryButton::check(bool repeat_click) { event = BUTTON_EVENT_TRIPLE_CLICK; break; default: - // For 4+ clicks, treat as triple click? - event = BUTTON_EVENT_TRIPLE_CLICK; + // Screens with group navigation opt into a distinct four-tap event. + event = _quadruple_click ? BUTTON_EVENT_QUADRUPLE_CLICK + : BUTTON_EVENT_TRIPLE_CLICK; break; } _click_count = 0; diff --git a/src/helpers/ui/MomentaryButton.h b/src/helpers/ui/MomentaryButton.h index 8ed2c77f..ff02f22e 100644 --- a/src/helpers/ui/MomentaryButton.h +++ b/src/helpers/ui/MomentaryButton.h @@ -7,6 +7,7 @@ #define BUTTON_EVENT_LONG_PRESS 2 #define BUTTON_EVENT_DOUBLE_CLICK 3 #define BUTTON_EVENT_TRIPLE_CLICK 4 +#define BUTTON_EVENT_QUADRUPLE_CLICK 5 #ifndef MOMENTARY_BUTTON_WAKE_HOLD_MS #define MOMENTARY_BUTTON_WAKE_HOLD_MS 0 @@ -24,6 +25,7 @@ class MomentaryButton { uint32_t _last_click_time; int _multi_click_window; bool _pending_click; + bool _quadruple_click = false; int8_t _candidate_level; uint32_t _candidate_since; bool _debouncing; @@ -39,6 +41,7 @@ public: MomentaryButton(int8_t pin, int long_press_mills, int analog_threshold); virtual void begin(); virtual int check(bool repeat_click=false); // returns one of BUTTON_EVENT_* + void enableQuadrupleClick() { _quadruple_click = true; } void cancelClick(); // suppress next BUTTON_EVENT_CLICK (if already in DOWN state) uint8_t getPin() { return _pin; } bool isPressed() const; diff --git a/src/helpers/ui/ReaderNavigationHint.h b/src/helpers/ui/ReaderNavigationHint.h index 5141e400..a54c3ac9 100644 --- a/src/helpers/ui/ReaderNavigationHint.h +++ b/src/helpers/ui/ReaderNavigationHint.h @@ -12,28 +12,35 @@ struct ButtonReaderHintLayout { const char* lines[3]; }; -// Match the single-button UI: double click is Previous, click is Next, and -// hold is Enter/exit. Measure with the reader's font, including tiny panels. +// Alternate page and group navigation within the same footer. Measure both +// views so changing the hint never repaginates text or hides a message row. inline ButtonReaderHintLayout makeButtonReaderHintLayout( - DisplayDriver& text, int line_height, int bottom) { + DisplayDriver& text, int line_height, int bottom, bool show_groups = false) { ButtonReaderHintLayout layout = { 0, line_height, 1, {"<- 2 tap 1 tap -> long press: exit", nullptr, nullptr}}; + const char* group_line = "<<- 4 tap 3 tap ->>"; if (text.getTextWidth(layout.lines[0]) > text.width() - && text.getTextWidth("<-2 tap 1 tap-> hold: exit") <= text.width()) { - layout.lines[0] = "<-2 tap 1 tap-> hold: exit"; + && text.getTextWidth("<-2 tap 1 tap-> hold: X") <= text.width()) { + layout.lines[0] = "<-2 tap 1 tap-> hold: X"; } - if (text.getTextWidth(layout.lines[0]) > text.width()) { + if (text.getTextWidth(layout.lines[0]) > text.width() + || text.getTextWidth(group_line) > text.width()) { layout.line_count = 2; layout.lines[0] = "<- 2 tap 1 tap ->"; layout.lines[1] = "long press: exit"; if (text.getTextWidth(layout.lines[0]) > text.width() + || text.getTextWidth(group_line) > text.width() || text.getTextWidth(layout.lines[1]) > text.width()) { layout.line_count = 3; layout.lines[0] = "<- 2 tap"; layout.lines[1] = "1 tap ->"; - layout.lines[2] = "hold: exit"; + layout.lines[2] = "hold: X"; } } + if (show_groups) { + layout.lines[0] = layout.line_count < 3 ? group_line : "<<- 4 tap"; + if (layout.line_count == 3) layout.lines[1] = "3 tap ->>"; + } layout.top = bottom - layout.line_count * line_height; if (layout.top < 0) layout.top = 0; return layout; diff --git a/test/fixtures/companion_john/test_reader.cpp b/test/fixtures/companion_john/test_reader.cpp index 8d2d99de..f4fff24b 100644 --- a/test/fixtures/companion_john/test_reader.cpp +++ b/test/fixtures/companion_john/test_reader.cpp @@ -264,6 +264,38 @@ int main(int argc, char** argv) { assert(task.closed); } + // Group navigation starts at verse one of the adjacent chapter, resets + // the page offset, stops at book boundaries, and checkpoints normally. + the_mesh = FakeMesh{}; + Display chapters_display(128, 64); + UITask chapters_task; + JohnReaderScreen chapters(&chapters_task, &chapters_display); + chapters.open(); + assert(chapters.handleInput(KEY_UP)); + assert(chapters.flush() && the_mesh.saves == 0); + for (uint8_t chapter = 2; chapter <= sizeof(kChapterVerses); ++chapter) { + assert(chapters.handleInput(KEY_DOWN)); + assert(chapters.flush()); + Position saved; + assert(the_mesh.loadJohnBookmark(saved)); + assert(referenceAt(saved.verse).chapter == chapter && saved.offset == 0); + assert(referenceAt(saved.verse).verse == 1); + } + const int writes = the_mesh.saves; + assert(chapters.handleInput(KEY_DOWN)); + assert(chapters.flush() && the_mesh.saves == writes); + // Moving backwards from the middle of a chapter reaches the preceding one. + assert(the_mesh.saveJohnBookmark(pos(91, 12))); // 3:16, with a stale page offset + JohnReaderScreen middle(&chapters_task, &chapters_display); + middle.open(); + assert(middle.handleInput(KEY_UP) && middle.flush()); + Position chapter_saved; + assert(the_mesh.loadJohnBookmark(chapter_saved)); + assert(referenceAt(chapter_saved.verse).chapter == 2); + assert(referenceAt(chapter_saved.verse).verse == 1 && chapter_saved.offset == 0); + assert(middle.handleInput(KEY_UP) && middle.flush()); + assert(!the_mesh.loadJohnBookmark(chapter_saved) && chapter_saved.atStart()); + // Resume within a split verse; independent screen and store lifecycle. the_mesh = FakeMesh{}; Display display(64,32); diff --git a/test/test_display_driver/test_display_driver.cpp b/test/test_display_driver/test_display_driver.cpp index 18628ee4..839a0cc8 100644 --- a/test/test_display_driver/test_display_driver.cpp +++ b/test/test_display_driver/test_display_driver.cpp @@ -264,21 +264,40 @@ TEST(SmallMessageText, ButtonHintReflowsOnTinyAndRotatedScreens) { {128, 32}, {32, 128}, {64, 128}, {128, 64}}) { TestDisplay display(dimensions.first, dimensions.second); mesh::ui::SmallMessageText text(display); - const auto hint = mesh::ui::makeButtonReaderHintLayout( + const auto navigation = mesh::ui::makeButtonReaderHintLayout( text, text.lineHeight(), display.height()); - for (int row = 0; row < hint.line_count; ++row) - EXPECT_LE(text.getTextWidth(hint.lines[row]), display.width()); - mesh::ui::drawButtonReaderHint(text, hint); - ASSERT_FALSE(display.fills.empty()); - for (const auto& pixel : display.fills) { - EXPECT_GE(pixel.x, 0); - EXPECT_LE(pixel.x + pixel.width, display.width()); - EXPECT_GE(pixel.y, hint.top); - EXPECT_LE(pixel.y + pixel.height, display.height()); + for (bool groups : {false, true}) { + display.fills.clear(); + const auto hint = mesh::ui::makeButtonReaderHintLayout( + text, text.lineHeight(), display.height(), groups); + EXPECT_EQ(navigation.top, hint.top); + EXPECT_EQ(navigation.line_count, hint.line_count); + for (int row = 0; row < hint.line_count; ++row) + EXPECT_LE(text.getTextWidth(hint.lines[row]), display.width()); + mesh::ui::drawButtonReaderHint(text, hint); + ASSERT_FALSE(display.fills.empty()); + for (const auto& pixel : display.fills) { + EXPECT_GE(pixel.x, 0); + EXPECT_LE(pixel.x + pixel.width, display.width()); + EXPECT_GE(pixel.y, hint.top); + EXPECT_LE(pixel.y + pixel.height, display.height()); + } } } } +TEST(SmallMessageText, GroupHintFitsV4FooterWithoutLosingMessageRows) { + TestDisplay display(128, 64); + mesh::ui::SmallMessageText text(display); + const auto hint = mesh::ui::makeButtonReaderHintLayout( + text, text.lineHeight(), display.height(), true); + ASSERT_EQ(1, hint.line_count); + EXPECT_STREQ("<<- 4 tap 3 tap ->>", hint.lines[0]); + EXPECT_LE(text.getTextWidth(hint.lines[0]), 128); + EXPECT_EQ(56, hint.top); + EXPECT_EQ(5, text.lineCount(2 * text.lineHeight(), hint.top)); +} + TEST(SmallMessageText, Full160CharacterSamplesFitInFiveRows) { class RecordingText : public mesh::ui::SmallMessageText { public: diff --git a/test/test_message_navigation.py b/test/test_message_navigation.py new file mode 100644 index 00000000..c6f707f1 --- /dev/null +++ b/test/test_message_navigation.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Exercise actual button routing and message filters with a host display.""" + +from pathlib import Path +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + +PREAMBLE = r''' +#include +#include +#include +#include +#include +#include +#include +#include +#include +#define UI_SMALL_MESSAGE_FONT 0 +#define UI_BUTTON_READER_HINT 1 +#define UI_MESSAGE_CHANNEL_FOOTER 0 +#define UI_COMPACT_MESSAGE_STATUS 0 +#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; +uint64_t companionMessageNowMillis() { return millis(); } +uint64_t companionMessageElapsedMillis(uint64_t when) { return millis()-when; } +struct StrHelper { + static void strncpy(char* out,const char* in,size_t size) { snprintf(out,size,"%s",in); } +}; +struct ChannelDetails { char name[32]; }; +struct Mesh { + bool getChannel(int channel,ChannelDetails& details) const { + const char* names[]={"Public","","Second","Empty"}; + if (channel<0 || channel>=MAX_GROUP_CHANNELS) return false; + snprintf(details.name,sizeof(details.name),"%s",names[channel]); + return true; + } +} the_mesh; +struct Display : DisplayDriver { + struct Line { int x,y; std::string text; }; + std::vector lines; + int x=0,y=0; + bool on=true; + Display() : DisplayDriver(128,64) {} + bool isOn() override { return on; } + void turnOn() override { on=true; } + void turnOff() override { on=false; } + void clear() override { lines.clear(); } + void startFrame(ColorVal=0) override { clear(); } + void endFrame() override {} + void setTextSize(int) override {} + void setColor(ColorVal) override {} + void setCursor(int a,int b) override { x=a;y=b; } + uint16_t getTextWidth(const char* text) override { return strlen(text)*4; } + 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}); + } + 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 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; + return false; + } +}; +struct Screen : UIScreen { + uint8_t key=0; + int render(DisplayDriver&) override { return 0; } + bool handleInput(char c) override { key=static_cast(c); return true; } +}; +MomentaryButton user_btn(7,1000,true,true,true); +class UITask { +public: + DisplayDriver* _display; + UIScreen* curr=nullptr; + UIScreen* msg_preview=nullptr; + UIScreen* home=nullptr; + UIScreen* john_reader=nullptr; + uint32_t _auto_off=0,_next_refresh=0; + int buzzer_changes=0; + explicit UITask(DisplayDriver& display) : _display(&display) {} + bool isJohnReaderActive() const { return john_reader && curr==john_reader; } + void gotoHomeScreen() { curr=home; } + void toggleBuzzer() { ++buzzer_changes; } + char handleLongPress(char c) { return c; } + char handleMultiClick(char,bool); + char handleDoubleClick(char); + char checkDisplayOn(char); + void pollButton(); +}; +''' + +SCENARIOS = r''' +void gesture(UITask& task,int count) { + for (int tap=0;taphandleInput(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) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_momentary_button/test_momentary_button.cpp b/test/test_momentary_button/test_momentary_button.cpp index 8ae5ef3b..6c4b90a0 100644 --- a/test/test_momentary_button/test_momentary_button.cpp +++ b/test/test_momentary_button/test_momentary_button.cpp @@ -118,6 +118,44 @@ TEST_F(MomentaryButtonTest, MulticlickDisabledEmitsOnAcceptedRelease) { EXPECT_FALSE(button.needsPolling()); } +TEST_F(MomentaryButtonTest, FourClicksHaveDistinctEventOnlyWhenEnabled) { + for (bool enabled : {false, true}) { + MomentaryButton button(kPin, 1000, true, true, true); + button.begin(); + if (enabled) button.enableQuadrupleClick(); + for (int click = 0; click < 4; ++click) { + cleanPress(button); + EXPECT_EQ(BUTTON_EVENT_NONE, cleanRelease(button)); + advance(80); + EXPECT_EQ(BUTTON_EVENT_NONE, button.check()); + } + advance(200); + EXPECT_EQ(enabled ? BUTTON_EVENT_QUADRUPLE_CLICK : BUTTON_EVENT_TRIPLE_CLICK, + button.check()); + EXPECT_FALSE(button.needsPolling()); + EXPECT_EQ(BUTTON_EVENT_NONE, button.check()); + } +} + +TEST_F(MomentaryButtonTest, FourthPressBeforeDeadlineDoesNotEmitTripleEarly) { + MomentaryButton button(kPin, 1000, true, true, true); + button.begin(); + button.enableQuadrupleClick(); + g_mock_millis = UINT32_MAX - 500; + for (int click = 0; click < 3; ++click) { + cleanPress(button); + EXPECT_EQ(BUTTON_EVENT_NONE, cleanRelease(button)); + if (click < 2) advance(80); + } + advance(279); + EXPECT_EQ(BUTTON_EVENT_NONE, beginTransition(button, LOW)); + EXPECT_EQ(BUTTON_EVENT_NONE, finishDebounce(button)); + EXPECT_EQ(BUTTON_EVENT_NONE, cleanRelease(button)); + advance(280); + EXPECT_EQ(BUTTON_EVENT_QUADRUPLE_CLICK, button.check()); + EXPECT_FALSE(button.needsPolling()); +} + TEST_F(MomentaryButtonTest, LongPressEmitsOnceAndDoesNotBecomeClick) { MomentaryButton button(kPin, 1000, true, true, true); button.begin(); diff --git a/tools/bible/README.md b/tools/bible/README.md index b024ae16..bb3b8b7f 100644 --- a/tools/bible/README.md +++ b/tools/bible/README.md @@ -24,15 +24,20 @@ reader from the radio-settings page: - Single click advances one screenful, then to the next verse. - Double click goes back one screenful; at a verse boundary it shows the previous verse's last screenful. +- Three taps jump to verse one of the next chapter; four taps jump to verse + one of the previous chapter. Navigation stops at the first and last chapters. - Long press exits to the radio page. It does not invoke early-boot CLI rescue while opening or closing the reader. -The header shows the reference and part count. Single-button builds show -`<- 2 tap 1 tap -> long press: exit` at the bottom, matching the message reader. -These are taps of the user button. Narrower displays shorten or split the -hint across lines; touch/joystick builds keep their own controls. The entry -gesture stays hidden on the radio page. The footer has reserved space in the -pagination calculation, so text continues on another page when necessary. +The header shows the reference and part count. Single-button builds alternate +`<- 2 tap 1 tap -> long press: exit` and `<<- 4 tap 3 tap ->>` in the footer +every three seconds, matching the message controls (which change channels). +These are taps of the user button. Narrower displays use `hold: X` for exit +or split the hint across lines. Both hint views reserve the same height; the +V4 keeps its existing single footer row. Touch/joystick builds keep their own +controls. The entry gesture stays hidden on the radio page. The footer has +reserved space in the pagination calculation, so text continues on another +page when necessary. Text wraps at word boundaries without truncation. Both the screen and CLI use the same text with plain ASCII quotes, apostrophes, dashes and spaces; wording and capitalization are unchanged. Incoming messages are retained without