Add multi-tap channel navigation for messages

This commit is contained in:
mikecarper
2026-09-08 22:47:15 -07:00
parent c690f65376
commit f778d14c76
12 changed files with 431 additions and 68 deletions
+17 -9
View File
@@ -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
@@ -40,7 +40,8 @@ class JohnReaderScreen : public UIScreen {
DisplayDriver& hint_text = small_hint
? static_cast<DisplayDriver&>(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<uint8_t>(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<uint8_t>(chapter), 1}, pos.verse)) {
_bookmark.move(pos, millis());
}
return true;
}
if (key == KEY_ENTER || key == KEY_CANCEL) { _task->closeJohnReader(); return true; }
return false;
}
+62 -31
View File
@@ -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<uint8_t>(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() {
+1 -1
View File
@@ -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;
+3 -2
View File
@@ -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;
+3
View File
@@ -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;
+14 -7
View File
@@ -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;
+32
View File
@@ -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);
@@ -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:
+207
View File
@@ -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 <Arduino.h>
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/MomentaryButton.h>
#include <helpers/ui/CompanionMessageHistory.h>
#include <helpers/ui/ReaderNavigationHint.h>
#include <helpers/ui/DisplayTextLayout.h>
#include <cassert>
#include <string>
#include <vector>
#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<Line> 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<uint8_t>(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;tap<count;++tap) {
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();
if (tap+1<count) { g_mock_millis+=80; task.pollButton(); }
}
g_mock_millis+=280; task.pollButton();
}
int main() {
resetArduinoMock();
g_mock_pin_levels[7]=HIGH;
user_btn.begin(); user_btn.enableQuadrupleClick();
Display display;
UITask task(display);
Screen home,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");
messages.addPreview(1,"Bob","public new",0,"Public");
messages.addPreview(1,"Carol","second",2,"Second");
messages.addPreview(0xFF,"Dan","direct message",-1,nullptr);
auto expect=[&](const char* header,const char* body) {
assert(task.curr==&messages);
display.clear(); messages.render(display);
assert(display.contains(header) && display.contains(body));
assert(task.buzzer_changes==0);
};
expect("DM 1/1","direct message");
gesture(task,3); expect("All 1/4","direct message");
gesture(task,3); expect("Ch 0 1/2","public new");
gesture(task,1); expect("Ch 0 2/2","public old");
gesture(task,2); expect("Ch 0 1/2","public new");
gesture(task,1); expect("Ch 0 2/2","public old");
gesture(task,4); expect("All 1/4","direct message");
gesture(task,3); expect("Ch 0 1/2","public new");
gesture(task,3); expect("Ch 2 1/1","second"); // unused slot 1 is skipped
gesture(task,3); expect("Ch 3 0/0","No buffered messages");
gesture(task,3); expect("DM 1/1","direct message");
gesture(task,3); expect("All 1/4","direct message");
gesture(task,4); expect("DM 1/1","direct message");
display.on=false;
gesture(task,4); // waking consumes the entire gesture
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");
// 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();
g_mock_millis+=1000; task.pollButton();
assert(task.curr==&home);
g_mock_pin_levels[7]=HIGH; task.pollButton();
g_mock_millis+=25; task.pollButton();
g_mock_millis+=280; task.pollButton();
assert(home.key==0);
#if COMPANION_FEATURE_JOHN
task.curr=&group;
gesture(task,3); assert(group.key==KEY_DOWN);
gesture(task,4); assert(group.key==KEY_UP);
assert(task.buzzer_changes==0);
#endif
task.curr=&home;
gesture(task,3); assert(task.buzzer_changes==1);
gesture(task,4); assert(task.buzzer_changes==2);
}
'''
class MessageNavigationTest(unittest.TestCase):
def test_button_events_change_channels_and_keep_other_actions(self):
source = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text()
start = source.index(
" if (ev == BUTTON_EVENT_CLICK) {\n c = checkDisplayOn(KEY_NEXT);",
source.index("void UITask::loop()"))
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("))
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)
if __name__ == "__main__":
unittest.main()
@@ -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();
+11 -6
View File
@@ -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