diff --git a/docs/v4_pixel5_font_trial.md b/docs/v4_pixel5_font_trial.md index c230407b..ae7bd93d 100644 --- a/docs/v4_pixel5_font_trial.md +++ b/docs/v4_pixel5_font_trial.md @@ -16,13 +16,30 @@ letters taking less space and `M` and `W` taking 6 pixels. Squeezed Regular 6 is a [public-domain font by Oliver Kraus](https://github.com/olikraus/u8g2/wiki/fntgrpu8g#squeezed_r6). Its bitmap data is stored as constants without heap allocation. -On a 128 x 64 OLED, the compact channel/sender line starts at y=14 and message -text at y=22. Five complete 6px-font rows fit. The software comparison fits -three representative 160-character messages in those five rows. Capacity +On a 128 x 64 OLED with the single-button message reader, the compact +channel/sender line starts at y=8 and message text at y=16. The header uses +the same small font. Five complete 6px-font rows fit above the navigation +hint at y=56. The software comparison fits three representative 160-character +messages in those five rows. Capacity depends on the characters; messages with many wide letters can still overflow. Text wraps at character boundaries, with `...` on the last line if necessary. Unsupported characters appear as `?`. Long channel/sender names are ellipsized to stay on their own line. An enabled channel footer reserves its own space. +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 `<- 2tap 1tap -> 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 +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 +the inbox. Exiting a message preview and the WiFi setup page's hold action +remain available immediately. Larger display classes keep their existing font. Menus, Bluetooth PINs and WiFi setup QR codes keep their normal layout. Small TFTs retain their existing @@ -80,8 +97,9 @@ pio test -e native -f test_display_driver -f test_companion_message_history The native tests exercise both capital heights, automatic font selection, 160-character messages, complete rows at display edges, long sender lines, -reserved footers, overflow markers and tiny/rotated screen geometry. The -additional rendering comparison checks all 95 printable ASCII glyphs in each +navigation hints, reserved footers, overflow markers and tiny/rotated screen +geometry. The additional rendering comparison checks all 95 printable ASCII +glyphs in each font, under address/undefined-behavior sanitizers. Picopixel is compared pixel for pixel with Adafruit GFX; Squeezed Regular 6 is compared with the upstream BDF fixture independently of the converted C++ tables. This needs a cached diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index fbe61716..6a81abd2 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include #if UI_SMALL_MESSAGE_FONT == 1 #include #endif @@ -72,10 +74,22 @@ static uint64_t companionMessageElapsedMillis(uint64_t heard_millis) { #define UI_RECENT_LIST_SIZE 4 #endif -#if UI_HAS_JOYSTICK || UI_HAS_ROTARY_INPUT +#ifdef HAS_TOUCH + #define PRESS_LABEL "tap center" + #define SELECT_LABEL "TAP" +#elif UI_HAS_JOYSTICK || defined(UI_HAS_NAV_INPUT) #define PRESS_LABEL "press Enter" + #define SELECT_LABEL "ENTER" #else - #define PRESS_LABEL "long press" + #define PRESS_LABEL "hold button" + #define SELECT_LABEL "HOLD" +#endif + +#if !defined(HAS_TOUCH) && !UI_HAS_JOYSTICK && !defined(UI_HAS_NAV_INPUT) \ + && (defined(PIN_USER_BTN) || defined(PIN_USER_BTN_ANA)) + #define UI_BUTTON_READER_HINT 1 +#else + #define UI_BUTTON_READER_HINT 0 #endif #ifdef COMPANION_EXCLUSIVE_WIFI_BLE @@ -278,7 +292,7 @@ static void drawCompanionWiFiSetupPage(DisplayDriver& display) { display.drawTextCentered(display.width() / 2, 45, "WIFI SETUP"); display.setTextSize(2); display.setColor(UIColor::secondary_txt); - display.drawTextCentered(display.width() / 2, 90, "TAP TO START"); + display.drawTextCentered(display.width() / 2, 90, SELECT_LABEL " TO START"); } else { display.setTextSize(3); display.drawTextCentered(display.width() / 2, 45, "WIFI"); @@ -657,7 +671,7 @@ public: display.setColor(UIColor::secondary_txt); mesh::ui::drawTextCenteredEllipsized( display, layout.info, layout.instruction_y, - "tap center: inbox"); + PRESS_LABEL ": inbox"); #ifdef WIFI_SSID if (!isCompanionWiFiEnabled()) { @@ -713,7 +727,7 @@ public: display.setColor(UIColor::secondary_txt); display.setTextSize(3); mesh::ui::drawTextCenteredEllipsized( - display, layout.pairing, layout.pairing_label_y, "TAP"); + display, layout.pairing, layout.pairing_label_y, SELECT_LABEL); #ifdef WIFI_SSID if (!isCompanionWiFiEnabled()) { strcpy(tmp, "OFF"); @@ -776,7 +790,7 @@ public: display.setTextSize(1); display.setColor(UIColor::secondary_txt); display.drawTextCentered(display.width() / 2, 43, - "tap center: inbox"); + PRESS_LABEL ": inbox"); #ifdef UI_SHOW_CLOCK display.setTextSize(3); @@ -1206,8 +1220,10 @@ 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. #ifndef UI_MESSAGE_CHANNEL_FOOTER - #if defined(MESHCORE_HAS_SMALL_DISPLAY) + #if defined(MESHCORE_HAS_SMALL_DISPLAY) || UI_BUTTON_READER_HINT == 1 #define UI_MESSAGE_CHANNEL_FOOTER 0 #else #define UI_MESSAGE_CHANNEL_FOOTER 1 @@ -1427,19 +1443,42 @@ public: } int render(DisplayDriver& display) override { - const mesh::ui::CompanionMessageChromeLayout layout = + mesh::ui::CompanionMessageChromeLayout layout = mesh::ui::makeCompanionMessageChromeLayout( UI_COMPACT_MESSAGE_STATUS == 1); + int body_bottom = UI_MESSAGE_CHANNEL_FOOTER == 1 + ? display.height() - layout.filter_height : display.height(); + display.setCompactText(layout.compact_text); + display.setTextSize(1); +#if UI_BUTTON_READER_HINT == 1 + #if UI_SMALL_MESSAGE_FONT == 1 + mesh::ui::SmallMessageText reader_text(display); + // Eight-pixel rows for the header and origin leave five message rows and + // a navigation row on a 128x64 V4. Keep the 5px font on tiny panels. + const int hint_line_height = reader_text.lineHeight(); + layout.header_divider_y = hint_line_height - 1; + layout.origin_y = hint_line_height; + layout.message_y = 2 * hint_line_height; + #else + DisplayDriver& reader_text = display; + const int hint_line_height = layout.message_y - layout.origin_y; + #endif + const mesh::ui::ButtonReaderHintLayout hint = + mesh::ui::makeButtonReaderHintLayout( + reader_text, hint_line_height, body_bottom); + body_bottom = hint.top; + DisplayDriver& header = reader_text; +#else + DisplayDriver& header = display; +#endif char tmp[24]; int filtered_count = filteredCount(); if (view_offset >= filtered_count) view_offset = 0; - display.setCompactText(layout.compact_text); - display.setCursor(0, 0); - display.setTextSize(1); - display.setColor(UIColor::corp_blue); + header.setCursor(0, 0); + header.setColor(UIColor::corp_blue); snprintf(tmp, sizeof(tmp), display.width() < 100 ? "%d/%d" : "Message %d/%d", filtered_count == 0 ? 0 : view_offset + 1, filtered_count); - display.print(tmp); + header.print(tmp); const MsgEntry* p = filteredEntry(view_offset); @@ -1447,8 +1486,16 @@ public: display.drawRect(0, layout.header_divider_y, display.width(), 1); display.setCompactText(false); display.setColor(UIColor::secondary_txt); +#if UI_BUTTON_READER_HINT == 1 + display.setCompactText(layout.compact_text); + reader_text.drawTextEllipsized(0, layout.origin_y, display.width(), + "No buffered messages"); + mesh::ui::drawButtonReaderHint(reader_text, hint); + display.setCompactText(false); +#else display.drawTextCentered(display.width() / 2, 40, "No buffered messages"); +#endif renderChannelFilter(display); return 5000; } @@ -1459,17 +1506,15 @@ public: char* suffix = strchr(tmp, ' '); if (suffix != nullptr) *suffix = 0; } - display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0); - display.print(tmp); + 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); #if UI_SMALL_MESSAGE_FONT == 1 mesh::ui::drawSmallMessageBody(display, p->origin, p->message, - layout.origin_y, - UI_MESSAGE_CHANNEL_FOOTER == 1 - ? display.height() - layout.filter_height : display.height()); + layout.origin_y, body_bottom); #else display.setCursor(0, layout.origin_y); display.setColor(UIColor::secondary_txt); @@ -1481,9 +1526,21 @@ public: display.setColor(UIColor::primary_txt); char filtered_msg[sizeof(p->message)]; display.translateUTF8ToBlocks(filtered_msg, p->message, sizeof(filtered_msg)); + #if UI_BUTTON_READER_HINT == 1 + const int line_height = layout.message_y - layout.origin_y; + const int message_rows = (body_bottom - layout.message_y) / line_height; + mesh::ui::drawTextWrapped(display, 0, layout.message_y, display.width(), + line_height, message_rows, filtered_msg); + #else display.printWordWrap(filtered_msg, display.width()); + #endif #endif +#if UI_BUTTON_READER_HINT == 1 + display.setCompactText(layout.compact_text); + mesh::ui::drawButtonReaderHint(reader_text, hint); + display.setCompactText(false); +#endif renderChannelFilter(display); #if AUTO_OFF_MILLIS==0 // probably e-ink @@ -2067,6 +2124,9 @@ char UITask::checkDisplayOn(char c) { } char UITask::handleLongPress(char c) { + // Exit an incoming message preview even during the startup rescue window. + // The on-screen reader hint must not unexpectedly enter CLI rescue. + if (curr == msg_preview) return c; #if UI_WIFI_SETUP_HOME_PAGE == 1 // A setup AP may focus this page immediately at boot. Its documented HOLD // action must win over the otherwise-global early-boot CLI rescue gesture. diff --git a/src/helpers/ui/ReaderNavigationHint.h b/src/helpers/ui/ReaderNavigationHint.h new file mode 100644 index 00000000..6ff02571 --- /dev/null +++ b/src/helpers/ui/ReaderNavigationHint.h @@ -0,0 +1,52 @@ +#pragma once + +#include "DisplayDriver.h" + +namespace mesh { +namespace ui { + +struct ButtonReaderHintLayout { + int top; + int line_height; + int line_count; + 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. +inline ButtonReaderHintLayout makeButtonReaderHintLayout( + DisplayDriver& text, int line_height, int bottom) { + ButtonReaderHintLayout layout = { + 0, line_height, 1, {"<- 2tap 1tap -> long press:exit", nullptr, nullptr}}; + if (text.getTextWidth(layout.lines[0]) > text.width()) { + layout.line_count = 2; + layout.lines[0] = "<- 2tap 1tap ->"; + layout.lines[1] = "long press:exit"; + if (text.getTextWidth(layout.lines[0]) > text.width() + || text.getTextWidth(layout.lines[1]) > text.width()) { + layout.line_count = 3; + layout.lines[0] = "<- 2tap"; + layout.lines[1] = "1tap ->"; + layout.lines[2] = "hold:exit"; + } + } + layout.top = bottom - layout.line_count * line_height; + if (layout.top < 0) layout.top = 0; + return layout; +} + +inline void drawButtonReaderHint(DisplayDriver& text, + const ButtonReaderHintLayout& layout) { + text.setColor(UIColor::window_bkg); + text.fillRect(0, layout.top, text.width(), + layout.line_count * layout.line_height); + text.setColor(UIColor::secondary_txt); + for (int row = 0; row < layout.line_count; ++row) { + text.drawTextCentered(text.width() / 2, + layout.top + row * layout.line_height, + layout.lines[row]); + } +} + +} // namespace ui +} // namespace mesh diff --git a/test/test_display_driver/test_display_driver.cpp b/test/test_display_driver/test_display_driver.cpp index e6afb2de..18628ee4 100644 --- a/test/test_display_driver/test_display_driver.cpp +++ b/test/test_display_driver/test_display_driver.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -233,6 +234,51 @@ TEST(SmallMessageText, MessageBodyRespectsReservedFooter) { for (const auto& pixel : display.fills) EXPECT_LT(pixel.y, 56); } +TEST(SmallMessageText, ButtonHintKeepsFiveMessageRowsWithoutOverlappingText) { + TestDisplay display(128, 64); + mesh::ui::SmallMessageText text(display); + const auto hint = mesh::ui::makeButtonReaderHintLayout( + text, text.lineHeight(), display.height()); + ASSERT_EQ(1, hint.line_count); + EXPECT_EQ(56, hint.top); + EXPECT_EQ(5, text.lineCount(2 * text.lineHeight(), hint.top)); + + mesh::ui::drawSmallMessageBody(display, "Ch 0 Public [4h]:", + std::string(160, 'W').c_str(), + text.lineHeight(), hint.top); + ASSERT_FALSE(display.fills.empty()); + for (const auto& pixel : display.fills) EXPECT_LT(pixel.y, hint.top); + display.fills.clear(); + 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, ButtonHintReflowsOnTinyAndRotatedScreens) { + for (auto dimensions : {std::pair{72, 40}, {40, 72}, + {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( + 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()); + } + } +} + TEST(SmallMessageText, Full160CharacterSamplesFitInFiveRows) { class RecordingText : public mesh::ui::SmallMessageText { public: @@ -257,6 +303,15 @@ TEST(SmallMessageText, Full160CharacterSamplesFitInFiveRows) { EXPECT_EQ(5, mesh::ui::drawTextWrapped(text, 0, 22, 128, text.lineHeight(), text.lineCount(22), sample)); EXPECT_EQ(sample, text.drawn); + // The button reader moves its header/origin up to reserve the hint, so + // the same complete 160-character text still fits in five body rows. + text.drawn.clear(); + const auto hint = mesh::ui::makeButtonReaderHintLayout( + text, text.lineHeight(), display.height()); + const int message_y = 2 * text.lineHeight(); + EXPECT_EQ(5, mesh::ui::drawTextWrapped(text, 0, message_y, 128, + text.lineHeight(), text.lineCount(message_y, hint.top), sample)); + EXPECT_EQ(sample, text.drawn); } } diff --git a/test/test_heltec_v4_wifi_setup_page.py b/test/test_heltec_v4_wifi_setup_page.py index 4a4e452b..414a31af 100644 --- a/test/test_heltec_v4_wifi_setup_page.py +++ b/test/test_heltec_v4_wifi_setup_page.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 from pathlib import Path +import subprocess +import tempfile import unittest @@ -141,6 +143,60 @@ class HeltecV4WiFiSetupPageTest(unittest.TestCase): self.assertNotIn("savePrefs", loop[stop:state_service]) self.assertNotIn("saveEnabled", loop[stop:state_service]) + def test_reader_exit_wins_over_startup_rescue_but_home_keeps_rescue(self): + ui = UI.read_text(encoding="utf-8") + start = ui.index("char UITask::handleLongPress(char c) {") + end = ui.index("\nchar UITask::handleDoubleClick", start) + source = r''' +#include +#define UI_WIFI_SETUP_HOME_PAGE 1 +unsigned long now = 4000; +unsigned long millis() { return now; } +struct Screen {}; +struct HomeScreen : Screen { + bool setup = false; + bool isWiFiSetupPage() const { return setup; } +}; +struct Mesh { + int rescue_calls = 0; + void enterCLIRescue() { ++rescue_calls; } +} the_mesh; +struct UITask { + Screen* curr; + Screen* home; + Screen* msg_preview; + unsigned long ui_started_at = 0; + char handleLongPress(char c); +}; +''' + ui[start:end] + r''' +int main() { + HomeScreen home; + Screen reader; + UITask task{&reader, &home, &reader}; + assert(task.handleLongPress('E') == 'E'); + assert(the_mesh.rescue_calls == 0); + task.curr = &home; + assert(task.handleLongPress('E') == 0); + assert(the_mesh.rescue_calls == 1); + home.setup = true; + assert(task.handleLongPress('E') == 'E'); + assert(the_mesh.rescue_calls == 1); + home.setup = false; + now = 9000; + assert(task.handleLongPress('E') == 'E'); + task.curr = &reader; + assert(task.handleLongPress('E') == 'E'); + assert(the_mesh.rescue_calls == 1); +} +''' + with tempfile.TemporaryDirectory() as temp_dir: + executable = Path(temp_dir) / "reader_hold" + subprocess.run( + ["c++", "-std=c++17", "-x", "c++", "-", "-o", str(executable)], + input=source, text=True, capture_output=True, check=True, + ) + subprocess.run([str(executable)], check=True) + if __name__ == "__main__": unittest.main() diff --git a/test/test_t096_message_footer_profile.py b/test/test_t096_message_footer_profile.py index ecb63caf..af2ce85e 100644 --- a/test/test_t096_message_footer_profile.py +++ b/test/test_t096_message_footer_profile.py @@ -78,7 +78,7 @@ class T096MessageFooterProfileTest(unittest.TestCase): ui = UI.read_text(encoding="utf-8") self.assertIn( "#ifndef UI_MESSAGE_CHANNEL_FOOTER\n" - " #if defined(MESHCORE_HAS_SMALL_DISPLAY)\n" + " #if defined(MESHCORE_HAS_SMALL_DISPLAY) || UI_BUTTON_READER_HINT == 1\n" " #define UI_MESSAGE_CHANNEL_FOOTER 0\n" " #else\n" " #define UI_MESSAGE_CHANNEL_FOOTER 1\n"