diff --git a/docs/v4_pixel5_font_trial.md b/docs/v4_pixel5_font_trial.md index ae7bd93d3..66e03b014 100644 --- a/docs/v4_pixel5_font_trial.md +++ b/docs/v4_pixel5_font_trial.md @@ -1,14 +1,17 @@ # Small-screen message fonts Small-screen Companion builds automatically select a compact font for received -message text and the channel/sender line, including `Ch 0 Public`. This covers -SSD1306 and SH1106 OLEDs, ST7735 small TFTs, and the U8g2 T-Echo Card display. -The shared renderer selects by logical panel dimensions, including rotation: +message text and the channel/sender line, including `Ch 0 Public`. SSD1306 and +SH1106 OLEDs and the U8g2 T-Echo Card display use this on panels smaller than +160 pixels on both axes. ST7735 TFTs now use their native 160x80 dimensions +and normal font. The shared renderer selects by panel dimensions, including +rotation: | Panel size | Font | Capital height | Line spacing | | --- | --- | ---: | ---: | -| At least 128 x 64, or 64 x 128 rotated | Squeezed Regular 6 | 6 pixels | 8 pixels | +| At least 128 x 64, or 64 x 128 rotated; both axes below 160 | Squeezed Regular 6 | 6 pixels | 8 pixels | | Smaller panels, including 72 x 40, 128 x 32 and 64 x 48 | Picopixel | 5 pixels | 7 pixels | +| Either axis at least 160 | Normal display font | Depends on panel | Depends on panel | Both fonts allow one pixel for descenders and one blank pixel row between lines. Character widths vary: most letters advance by 4 pixels, with narrow @@ -41,9 +44,14 @@ 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. +The hidden [John reader](../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. + Larger display classes keep their existing font. Menus, Bluetooth PINs and WiFi -setup QR codes keep their normal layout. Small TFTs retain their existing -logical to physical scaling. The normal OLED font uses 7-pixel letters, +setup QR codes keep their normal layout. TFT drivers use native panel +coordinates; the SenseCAP Indicator retains its existing scaling. The normal OLED font uses 7-pixel letters, 8-pixel line spacing and 6-pixel character advances. The preview buffer holds a complete 160-byte MeshCore message plus its diff --git a/examples/companion_radio/ui-new/JohnReaderScreen.h b/examples/companion_radio/ui-new/JohnReaderScreen.h index c0960d911..8a89749a9 100644 --- a/examples/companion_radio/ui-new/JohnReaderScreen.h +++ b/examples/companion_radio/ui-new/JohnReaderScreen.h @@ -1,7 +1,8 @@ #pragma once #include #include -#if UI_SMALL_MESSAGE_FONT +#include +#if UI_SMALL_MESSAGE_FONT || UI_BUTTON_READER_HINT #include #endif @@ -27,17 +28,30 @@ class JohnReaderScreen : public UIScreen { const bool stacked_header = width < _display->getTextWidth("88:88") + _display->getTextWidth("88/88") + 4; const int top = header_height * (stacked_header ? 2 : 1) + 2; -#if UI_SMALL_MESSAGE_FONT +#if UI_SMALL_MESSAGE_FONT || UI_BUTTON_READER_HINT // Share message font selection and metrics, including rotated/tiny panels. mesh::ui::SmallMessageText compact(*_display); +#endif + int bottom = _display->height(); +#if UI_BUTTON_READER_HINT + // Keep the hint compact on tiny displays even if the body font was + // overridden. Reserve it before pagination so no verse text is hidden. + const bool small_hint = _display->useSmallMessageFont(); + DisplayDriver& hint_text = small_hint + ? static_cast(compact) : *_display; + const auto hint = mesh::ui::makeButtonReaderHintLayout(hint_text, + small_hint ? compact.glyphHeight() : header_height, bottom); + bottom = hint.top; +#endif +#if UI_SMALL_MESSAGE_FONT const bool small = _display->useSmallMessageFont(); DisplayDriver& body = small ? static_cast(compact) : *_display; const int line_height = small ? compact.lineHeight() : header_height; - int rows = small ? compact.lineCount(top) : (_display->height() - top) / line_height; + int rows = small ? compact.lineCount(top, bottom) : (bottom - top) / line_height; #else DisplayDriver& body = *_display; const int line_height = header_height; - int rows = (_display->height() - top) / line_height; + int rows = (bottom - top) / line_height; #endif if (rows < 1) rows = 1; auto measure = [&body](const char* line) { @@ -53,9 +67,11 @@ class JohnReaderScreen : public UIScreen { referenceAt(pos.verse), scratch, sizeof(scratch), text); if (result != LookupResult::Found) { if (draw) { - _display->setColor(UIColor::warning_txt); - _display->setCursor(0, top); - _display->print("John unavailable"); + body.setColor(UIColor::warning_txt); + body.drawTextEllipsized(0, top, width, "John unavailable"); +#if UI_BUTTON_READER_HINT + mesh::ui::drawButtonReaderHint(hint_text, hint); +#endif } else _task->showAlert("John data error", 1500); return; } @@ -96,6 +112,9 @@ class JohnReaderScreen : public UIScreen { body.setCursor(0, top + row * line_height); body.print(filtered); } +#if UI_BUTTON_READER_HINT + mesh::ui::drawButtonReaderHint(hint_text, hint); +#endif return; } } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 8eb58d0b9..80161519b 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -12,10 +12,6 @@ #include "../MyMesh.h" #include "../CompanionWiFi.h" #include "target.h" -#if COMPANION_FEATURE_JOHN -#include "JohnReaderScreen.h" -#include -#endif #include #ifdef WIFI_SSID #include @@ -96,6 +92,11 @@ static uint64_t companionMessageElapsedMillis(uint64_t heard_millis) { #define UI_BUTTON_READER_HINT 0 #endif +#if COMPANION_FEATURE_JOHN +#include "JohnReaderScreen.h" +#include +#endif + #ifdef COMPANION_EXCLUSIVE_WIFI_BLE static void drawCompanionTransportChoice(DisplayDriver& display, int x, int y, int width, int height, diff --git a/src/helpers/ui/ReaderNavigationHint.h b/src/helpers/ui/ReaderNavigationHint.h index 6ff025716..fd6b777fe 100644 --- a/src/helpers/ui/ReaderNavigationHint.h +++ b/src/helpers/ui/ReaderNavigationHint.h @@ -18,6 +18,10 @@ 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() + && text.getTextWidth("<-2tap 1tap-> hold:exit") <= text.width()) { + layout.lines[0] = "<-2tap 1tap-> hold:exit"; + } if (text.getTextWidth(layout.lines[0]) > text.width()) { layout.line_count = 2; layout.lines[0] = "<- 2tap 1tap ->"; diff --git a/test/fixtures/companion_john/test_reader.cpp b/test/fixtures/companion_john/test_reader.cpp index e14a49cfa..8d2d99dee 100644 --- a/test/fixtures/companion_john/test_reader.cpp +++ b/test/fixtures/companion_john/test_reader.cpp @@ -56,12 +56,24 @@ public: class Display : public DisplayDriver { int x = 0, y = 0; + ColorVal color = 1; public: struct Line { int x, y; std::string text; }; std::vector lines; std::vector pixels; Display(int w, int h) : DisplayDriver(w, h), pixels(w * h, 0) {} int bodyY() const { return width() < 64 ? 22 : 12; } + int bodyBottom() { +#if UI_BUTTON_READER_HINT + mesh::ui::SmallMessageText compact(*this); + const bool small = useSmallMessageFont(); + DisplayDriver& hint_text = small ? static_cast(compact) : *this; + return mesh::ui::makeButtonReaderHintLayout(hint_text, + small ? compact.glyphHeight() : 10, height()).top; +#else + return height(); +#endif + } void resize(int w, int h) { setDimensions(w, h); pixels.assign(w * h, 0); clear(); } bool isOn() override { return true; } void turnOn() override {} @@ -70,7 +82,7 @@ public: void startFrame(ColorVal = 0) override { clear(); } void endFrame() override {} void setTextSize(int) override {} - void setColor(ColorVal) override {} + void setColor(ColorVal value) override { color = value; } void setCursor(int a, int b) override { x = a; y = b; } uint16_t getTextWidth(const char* str) override { return strlen(str) * 6; } void print(const char* str) override { @@ -81,7 +93,7 @@ public: void fillRect(int a, int b, int w, int h) override { assert(a >= 0 && b >= 0 && a + w <= width() && b + h <= height()); for (int row = b; row < b + h; ++row) - for (int col = a; col < a + w; ++col) pixels[row * width() + col] = 1; + for (int col = a; col < a + w; ++col) pixels[row * width() + col] = color != 0; } void drawRect(int a, int b, int w, int h) override { assert(a >= 0 && b >= 0 && a + w <= width() && b + h <= height()); @@ -89,13 +101,14 @@ public: fillRect(a, b, 1, h); fillRect(a + w - 1, b, 1, h); } void drawXbm(int, int, const uint8_t*, int, int) override {} - std::string body() const { + std::string body() { #if UI_SMALL_MESSAGE_FONT if (useSmallMessageFont()) - return std::string(pixels.begin() + bodyY() * width(), pixels.end()); + return std::string(pixels.begin() + bodyY() * width(), pixels.begin() + bodyBottom() * width()); #endif std::string result; - for (const auto& line : lines) if (line.y >= bodyY()) result += line.text; + for (const auto& line : lines) + if (line.y >= bodyY() && line.y < bodyBottom()) result += line.text; return result; } void dump() const { @@ -163,11 +176,12 @@ int main(int argc, char** argv) { if (small) assert(compact.capitalHeight() == dimensions[2]); DisplayDriver& expected_text = small ? static_cast(compact) : expected; const int line_height = small ? compact.lineHeight() : 10; - const int rows = small ? compact.lineCount(top) : (expected.height() - top) / line_height; + const int rows = small ? compact.lineCount(top, expected.bodyBottom()) + : (expected.bodyBottom() - top) / line_height; #else DisplayDriver& expected_text = expected; const int line_height = 10; - const int rows = (display.height() - top) / line_height; + const int rows = (display.bodyBottom() - top) / line_height; #endif auto measure = [&expected_text](const char* line) { return expected_text.getTextWidth(line); }; UITask task; @@ -175,6 +189,32 @@ int main(int argc, char** argv) { JohnReaderScreen screen(&task, &display); screen.open(); assert(screen.flush() && the_mesh.saves == 0); // don't create John 1:1 +#if UI_BUTTON_READER_HINT + display.clear(); screen.render(display); + Display footer(display.width(), display.height()); + mesh::ui::SmallMessageText hint_compact(footer); + const bool small_hint = footer.useSmallMessageFont(); + DisplayDriver& hint_text = small_hint + ? static_cast(hint_compact) : footer; + const auto hint = mesh::ui::makeButtonReaderHintLayout(hint_text, + small_hint ? hint_compact.glyphHeight() : 10, footer.height()); + mesh::ui::drawButtonReaderHint(hint_text, hint); + if (small_hint) { + // Compare the real reader's hint pixels independently of the body. + auto first = footer.pixels.begin() + hint.top * footer.width(); + assert(std::find(first, footer.pixels.end(), 1) != footer.pixels.end()); + assert(std::equal(first, footer.pixels.end(), + display.pixels.begin() + hint.top * display.width())); + } else { + for (const auto& hint_line : footer.lines) { + assert(std::any_of(display.lines.begin(), display.lines.end(), + [&hint_line](const Display::Line& line) { + return line.x == hint_line.x && line.y == hint_line.y + && line.text == hint_line.text; + })); + } + } +#endif std::vector expected_pages; char scratch[kBlockSize]; for (uint16_t verse = 0; verse < kVerseCount; ++verse) { @@ -257,7 +297,8 @@ int main(int argc, char** argv) { Display migrated_display(128,64); mesh::ui::SmallMessageText compact(migrated_display); const auto compact_measure = [&compact](const char* line) { return compact.getTextWidth(line); }; - const auto compact_page = readerPage(text, legacy.next, 128, compact.lineCount(12), compact_measure); + const auto compact_page = readerPage(text, legacy.next, 128, + compact.lineCount(12, migrated_display.bodyBottom()), compact_measure); assert(compact_page.start <= legacy.next && compact_page.next > legacy.next); JohnReaderScreen migrated(&task, &migrated_display); migrated.open(); migrated.render(migrated_display); assert(migrated.flush()); @@ -278,7 +319,8 @@ int main(int argc, char** argv) { DisplayDriver& new_font = small ? static_cast(small_font) : changed; auto new_measure = [&new_font](const char* line) { return new_font.getTextWidth(line); }; const auto new_page = readerPage(text, before.offset, changed.width(), - small ? small_font.lineCount(changed.bodyY()) : (changed.height() - changed.bodyY()) / 10, + small ? small_font.lineCount(changed.bodyY(), changed.bodyBottom()) + : (changed.bodyBottom() - changed.bodyY()) / 10, new_measure); assert(new_page.start <= before.offset && new_page.next > before.offset); active.render(changed); assert(active.flush()); diff --git a/test/test_companion_john.py b/test/test_companion_john.py index 3c39ed2c4..6a7ded10a 100644 --- a/test/test_companion_john.py +++ b/test/test_companion_john.py @@ -130,12 +130,14 @@ class CompanionJohnTest(unittest.TestCase): self.run_checked([cc, "-Os", *flags, "-c", str(ROOT / "src/helpers/ota/OtaTinf.c"), "-o", str(obj)]) for small_font in (0, 1): - self.run_checked([cxx, "-std=c++11", "-Os", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", - *flags, f"-DUI_SMALL_MESSAGE_FONT={small_font}", - "-I" + str(ROOT / "src"), "-I" + str(FIXTURE), - str(FIXTURE / "test_reader.cpp"), str(ROOT / "src/helpers/CompanionJohn.cpp"), - str(obj), "-o", str(binary)]) - self.run_checked([str(binary)]) + for button_hint in (0, 1): + self.run_checked([cxx, "-std=c++11", "-Os", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", + *flags, f"-DUI_SMALL_MESSAGE_FONT={small_font}", + f"-DUI_BUTTON_READER_HINT={button_hint}", + "-I" + str(ROOT / "src"), "-I" + str(FIXTURE), + str(FIXTURE / "test_reader.cpp"), str(ROOT / "src/helpers/CompanionJohn.cpp"), + str(obj), "-o", str(binary)]) + self.run_checked([str(binary)]) def test_reader_button_routing_and_profile_gate(self): ui = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text(encoding="utf-8") @@ -143,6 +145,8 @@ class CompanionJohnTest(unittest.TestCase): self.assertLess(long_press.index("isJohnReaderActive()"), long_press.index("enterCLIRescue()")) self.assertLess(long_press.index("isRadioPage()"), long_press.index("enterCLIRescue()")) self.assertIn("#if COMPANION_FEATURE_JOHN\n#include \"JohnReaderScreen.h\"", ui) + self.assertLess(ui.index("#define UI_BUTTON_READER_HINT 1"), + ui.index('#include "JohnReaderScreen.h"')) self.assertIn("&& !isJohnReaderActive()", ui) self.assertIn("else if (isJohnReaderActive())", ui) self.assertIn("c = handleLongPress(KEY_ENTER);", ui) diff --git a/tools/bible/README.md b/tools/bible/README.md index ba1ad7e42..a1f47c7a9 100644 --- a/tools/bible/README.md +++ b/tools/bible/README.md @@ -27,7 +27,12 @@ reader from the radio-settings page: - 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; there is no bottom footer. +The header shows the reference and part count. Single-button builds show +`<- 2tap 1tap -> 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. 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 @@ -39,7 +44,8 @@ Sub-160px displays use the same adaptive font selector as received messages: Squeezed Regular 6 on panels at least 128x64 (or 64x128 when rotated), and 5px Picopixel on smaller panels. The reference and part counter retain their normal header font, stacking on narrow rotated screens to avoid overlap. On -the V4's 128x64 display, six complete 8-pixel-spaced body rows fit, and John +the V4's 128x64 display, five complete 8-pixel-spaced body rows fit above the +navigation hint, and John 3:16 fits on one screen. Wrapping and pagination use the selected font's actual character widths and line height. Any panel with either dimension at least 160px uses its normal font, including rotated 80x160 panels. Larger display classes, and builds with @@ -47,7 +53,7 @@ least 160px uses its normal font, including rotated 80x160 panels. Larger displa resume at the page containing the same text offset after a font/layout change. The T096 and other ST7735 boards use native 160x80 coordinates and the normal -body font (six 10-pixel-spaced rows below the header). The existing color +body font (five 10-pixel-spaced rows between the header and button hint). The existing color framebuffer is reused, with no framebuffer RAM increase. ST7789, ST7789LCD, NV3001B and GxEPD displays likewise expose their actual panel dimensions and render pixels without coordinate scaling; portrait orientations are retained. @@ -137,7 +143,8 @@ reports a 2,088-byte frame for verse output, a 16-byte command dispatcher, and about 1,720 bytes for the nested decoder calls. Including the terminal and main-loop frames, the decode path is approximately **4.3 KiB**, before framework/task and interrupt overhead; the nRF52 loop task has an 8 KiB stack. -The small-font reader's decode/draw function has a 2,480-byte frame on RAK3401 +Before the navigation-hint addition, the small-font reader's decode/draw +function had a 2,480-byte frame on RAK3401 and a 2,496-byte frame on V4. Its nested decode path is approximately 4.3 KiB on RAK3401 and 4.7 KiB on V4, including the main-loop/UI frames but before framework/task and interrupt overhead. The ESP32-S3 V4 terminal's corresponding @@ -152,9 +159,11 @@ every block boundary, exercise malformed references and corrupted block bounds, and verify that unrelated build profiles exclude the data. Reader tests additionally traverse every part of all 879 verses in both -directions with regular and adaptive 5px/6px fonts at 128x64, 64x128, 160x80, +directions, with and without the button hint, using regular and adaptive +5px/6px fonts at 128x64, 64x128, 160x80, 240x135, 72x40, 40x72, 128x32, 32x128, 64x48 and 48x64. They compare rendered -body pixels/text, check active-reader resizing across font thresholds, and cover resume +body pixels/text separately from the hint, check active-reader resizing across +font thresholds, and cover resume within a verse, book/chapter boundaries, no writes at the start, clearing stale bookmarks, corruption, I/O failure at each replacement step, timer rollover and bookmark