Add button navigation hints to the hidden John reader

This commit is contained in:
mikecarper committed 2026-09-08 17:31:18 -07:00
1 parent d89d28f7ed
commit cf4dc13c62
7 files changed
+125 -38

No files matched your search

+14 -6
View File
@@ -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
@@ -1,7 +1,8 @@
#pragma once
#include <helpers/CompanionJohn.h>
#include <helpers/bible/JohnReader.h>
#if UI_SMALL_MESSAGE_FONT
#include <helpers/ui/ReaderNavigationHint.h>
#if UI_SMALL_MESSAGE_FONT || UI_BUTTON_READER_HINT
#include <helpers/ui/SmallMessageText.h>
#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<DisplayDriver&>(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<DisplayDriver&>(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;
}
}
+5 -4
View File
@@ -12,10 +12,6 @@
#include "../MyMesh.h"
#include "../CompanionWiFi.h"
#include "target.h"
#if COMPANION_FEATURE_JOHN
#include "JohnReaderScreen.h"
#include <new>
#endif
#include <time.h>
#ifdef WIFI_SSID
#include <WiFi.h>
@@ -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 <new>
#endif
#ifdef COMPANION_EXCLUSIVE_WIFI_BLE
static void drawCompanionTransportChoice(DisplayDriver& display,
int x, int y, int width, int height,
+4
View File
@@ -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 ->";
+51 -9
View File
@@ -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<Line> lines;
std::vector<uint8_t> 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<DisplayDriver&>(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<DisplayDriver&>(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<DisplayDriver&>(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<std::string> 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<DisplayDriver&>(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());
+10 -6
View File
@@ -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)
+15 -6
View File
@@ -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