mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-12 12:35:40 +00:00
Add multi-tap channel navigation for messages
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user