diff --git a/examples/companion_radio/ui-new/ReaderScreen.h b/examples/companion_radio/ui-new/ReaderScreen.h index 8812676e8..7e3570ca8 100644 --- a/examples/companion_radio/ui-new/ReaderScreen.h +++ b/examples/companion_radio/ui-new/ReaderScreen.h @@ -27,9 +27,9 @@ class ReaderScreen : public UIScreen { _display->setTextSize(1); const int width = _display->width(); const int header_height = _display->textLineHeight(); - // Narrow/rotated panels need separate reference and progress lines. + // Narrow/rotated panels need separate start/end reference lines. const bool stacked_header = width < _display->getTextWidth("88:88") - + _display->getTextWidth("88/88") + 4; + + _display->getTextWidth("88:88") + 4; const int header_content_height = header_height * (stacked_header ? 2 : 1); #if UI_READER_TOUCH_BAR const int padded_header_height = mesh::ui::readerTouchHeaderHeight(header_content_height); @@ -76,62 +76,108 @@ class ReaderScreen : public UIScreen { return body.getTextWidth(filtered); }; - // At most two lookups: the current verse, then its adjacent verse when - // navigation crosses a boundary. No recursive decode or second buffer. - for (int pass = 0; pass < 2; ++pass) { - const LookupResult result = mesh::readReaderVerse( - referenceAt(pos.verse), scratch, sizeof(scratch), text); - if (result != LookupResult::Found) { - if (draw) { - body.setColor(UIColor::warning_txt); - body.drawTextEllipsized(0, top, width, "Reader unavailable"); -#if UI_BUTTON_READER_HINT -#if UI_READER_TOUCH_BAR - mesh::ui::drawReaderTouchBar(hint_text, _touch_bar); -#else - mesh::ui::drawButtonReaderHint(hint_text, hint); -#endif -#endif - } else _task->showAlert("Reader data error", 1500); - return; - } + // Stream adjacent verses through one decode buffer. Number labels are + // display-only, so existing raw-text bookmarks remain compatible. + uint16_t loaded_verse = kVerseCount; + LookupResult result = LookupResult::Found; + auto load = [&](uint16_t verse) { + if (loaded_verse == verse) return true; + result = mesh::readReaderVerse(referenceAt(verse), scratch, sizeof(scratch), text); + if (result != LookupResult::Found) return false; memmove(scratch, text, strlen(text) + 1); - const ReaderPage page = readerPage(scratch, pos.offset, width, rows, measure); - pos.offset = page.start; // clamp stale bookmarks after a layout change - if (direction > 0) { - if (scratch[page.next]) pos.offset = page.next; - else if (pos.verse + 1 < kVerseCount) { ++pos.verse; pos.offset = 0; } - direction = 0; - continue; + loaded_verse = verse; + return true; + }; + char line[kReaderLineBytes]; + auto nextLine = [&](uint16_t verse, uint16_t offset) { + char number[5] = {}; + if (!offset) + snprintf(number, sizeof(number), "%u ", referenceAt(verse).verse); + return readerLine(scratch, offset, width, measure, line, number); + }; + + if (load(pos.verse)) { + // Resume on the line containing the saved word after font/rotation + // changes, without tying the bookmark to a page or a number label. + uint16_t offset = 0; + while (scratch[offset]) { + const uint16_t next = nextLine(pos.verse, offset); + if (next > pos.offset || !scratch[next]) break; + offset = next; } + pos.offset = offset; + if (direction < 0) { - if (page.start) pos.offset = page.previous; - else if (pos.verse) { --pos.verse; pos.offset = kBlockSize - 1; } - direction = 0; - continue; + int remaining = rows; + while (load(pos.verse)) { + uint16_t count = 0; + offset = 0; + while (scratch[offset] && offset < pos.offset) { + offset = nextLine(pos.verse, offset); + ++count; + } + if (count >= remaining) { + offset = 0; + for (int skip = count - remaining; skip > 0; --skip) + offset = nextLine(pos.verse, offset); + pos.offset = offset; + break; + } + remaining -= count; + if (!pos.verse) { pos.offset = 0; break; } + --pos.verse; + pos.offset = kBlockSize - 1; + } } - _bookmark.move(pos, millis()); - if (!draw) return; - char label[32], progress[16]; - const Reference ref = referenceAt(pos.verse); - snprintf(label, sizeof(label), "Reader %u:%u", ref.chapter, ref.verse); - snprintf(progress, sizeof(progress), "%u/%u", page.part, page.parts); - const int progress_width = _display->getTextWidth(progress); - if (stacked_header || _display->getTextWidth(label) > width - progress_width - 4) - snprintf(label, sizeof(label), "%u:%u", ref.chapter, ref.verse); - _display->setColor(UIColor::title_txt); - _display->drawTextEllipsized(0, header_text_y, stacked_header ? width : width - progress_width - 4, label); - _display->drawTextRightAlign(width, header_text_y + (stacked_header ? header_height : 0), progress); - _display->drawRect(0, top - 2, width, 1); - body.setColor(UIColor::primary_txt); - uint16_t offset = page.start; - for (int row = 0; row < rows && scratch[offset]; ++row) { - char line[kReaderLineBytes], filtered[kReaderLineBytes]; - offset = readerLine(scratch, offset, width, measure, line); - body.translateUTF8ToBlocks(filtered, line, sizeof(filtered)); - body.setCursor(0, top + row * line_height); - body.print(filtered); + + Position cursor = pos, last = pos; + if (direction >= 0 && result == LookupResult::Found) { + body.setColor(UIColor::primary_txt); + for (int row = 0; row < rows && cursor.verse < kVerseCount; ++row) { + if (!load(cursor.verse)) break; + last = cursor; + cursor.offset = nextLine(cursor.verse, cursor.offset); + if (draw) { + char filtered[kReaderLineBytes]; + body.translateUTF8ToBlocks(filtered, line, sizeof(filtered)); + body.setCursor(0, top + row * line_height); + body.print(filtered); + } + if (!scratch[cursor.offset]) { ++cursor.verse; cursor.offset = 0; } + } + // At the end keep the final page visible; never persist an EOF cursor. + if (direction > 0 && cursor.verse < kVerseCount) pos = cursor; } + if (result == LookupResult::Found) { + _bookmark.move(pos, millis()); + if (!draw) return; + char label[32], progress[16]; + const Reference ref = referenceAt(pos.verse); + const Reference end = referenceAt(last.verse); + snprintf(label, sizeof(label), "Reader %u:%u", ref.chapter, ref.verse); + if (last.verse != pos.verse) + snprintf(progress, sizeof(progress), "%u:%u", + end.chapter, end.verse); + else progress[0] = 0; + const int progress_width = _display->getTextWidth(progress); + if (stacked_header || _display->getTextWidth(label) > width - progress_width - 4) + snprintf(label, sizeof(label), "%u:%u", ref.chapter, ref.verse); + _display->setColor(UIColor::title_txt); + _display->drawTextEllipsized(0, header_text_y, stacked_header ? width : width - progress_width - 4, label); + _display->drawTextRightAlign(width, header_text_y + (stacked_header ? header_height : 0), progress); + _display->drawRect(0, top - 2, width, 1); + } else if (draw) { + body.setColor(UIColor::window_bkg); + body.fillRect(0, top, width, bottom - top); + } + } + if (result != LookupResult::Found) { + if (draw) { + body.setColor(UIColor::warning_txt); + body.drawTextEllipsized(0, top, width, "Reader unavailable"); + } else _task->showAlert("Reader data error", 1500); + } + if (draw) { #if UI_BUTTON_READER_HINT #if UI_READER_TOUCH_BAR mesh::ui::drawReaderTouchBar(hint_text, _touch_bar); @@ -139,7 +185,6 @@ class ReaderScreen : public UIScreen { mesh::ui::drawButtonReaderHint(hint_text, hint); #endif #endif - return; } } diff --git a/src/helpers/bible/Reader.h b/src/helpers/bible/Reader.h index 9d80c9582..9dcaf305f 100644 --- a/src/helpers/bible/Reader.h +++ b/src/helpers/bible/Reader.h @@ -32,15 +32,20 @@ constexpr size_t kReaderLineBytes = 128; // word or cut a UTF-8 codepoint, including overlong words on narrow displays. template uint16_t readerLine(const char* text, uint16_t start, int width, - Measure measure, char (&line)[kReaderLineBytes]) { + Measure measure, char (&line)[kReaderLineBytes], + const char* prefix = "") { + // Number labels take display space but never become bookmark offsets. + size_t prefix_bytes = strlen(prefix); + if (prefix_bytes > sizeof(line) - 5) prefix_bytes = sizeof(line) - 5; + memcpy(line, prefix, prefix_bytes); size_t count = 0, last_space = 0; while (text[start + count]) { size_t bytes = 1; while (bytes < 4 && text[start + count + bytes] && (static_cast(text[start + count + bytes]) & 0xc0) == 0x80) ++bytes; - if (count + bytes >= sizeof(line)) break; - memcpy(line + count, text + start + count, bytes); - line[count + bytes] = 0; + if (prefix_bytes + count + bytes >= sizeof(line)) break; + memcpy(line + prefix_bytes + count, text + start + count, bytes); + line[prefix_bytes + count + bytes] = 0; const int pixels = static_cast(measure(line)); if (pixels > width && count != 0) break; if (text[start + count] == ' ') last_space = count; @@ -48,8 +53,8 @@ uint16_t readerLine(const char* text, uint16_t start, int width, if (pixels > width) break; // one too-wide glyph still makes progress } if (text[start + count] && last_space > 0) count = last_space; - memcpy(line, text + start, count); - line[count] = 0; + memcpy(line + prefix_bytes, text + start, count); + line[prefix_bytes + count] = 0; uint16_t next = static_cast(start + count); while (text[next] == ' ') ++next; return next; diff --git a/test/fixtures/companion_reader/test_reader_screen.cpp b/test/fixtures/companion_reader/test_reader_screen.cpp index 1995e08c3..ff1f4661e 100644 --- a/test/fixtures/companion_reader/test_reader_screen.cpp +++ b/test/fixtures/companion_reader/test_reader_screen.cpp @@ -136,6 +136,58 @@ static Position pos(uint16_t verse, uint16_t offset = 0) { Position p; p.verse = verse; p.offset = offset; return p; } +struct ExpectedLine { + Position start; + std::string display; +}; + +// Construct the visible stream independently of readerLine()/readerPage(). +// The numeric prefix consumes display width, but never bookmark text bytes. +template +static std::vector wrapVerse(uint16_t verse, const char* text, + int width, Measure measure) { + std::vector result; + const std::string source(text); + const std::string number = std::to_string(referenceAt(verse).verse) + " "; + std::string rebuilt; + for (size_t offset = 0; offset < source.size();) { + const std::string prefix = offset == 0 ? number : ""; + size_t count = 0; + while (offset + count < source.size() && prefix.size() + count < 127) { + const std::string candidate = prefix + source.substr(offset, count + 1); + if (count && measure(candidate.c_str()) > width) break; + ++count; // One body glyph always advances, even on a narrow panel. + } + assert(count); + if (offset + count < source.size()) { + const size_t space = source.rfind(' ', offset + count - 1); + if (space != std::string::npos && space > offset) count = space - offset; + } + const std::string body = source.substr(offset, count); + result.push_back({pos(verse, static_cast(offset)), prefix + body}); + rebuilt += body; + offset += count; + while (offset < source.size() && source[offset] == ' ') ++offset; + } + assert(!result.empty() && noSpaces(rebuilt) == noSpaces(source)); + assert(result.front().display.compare(0, number.size(), number) == 0); + return result; +} + +template +static Position containingLine(uint16_t verse, uint16_t offset, int width, + Measure measure) { + char scratch[kBlockSize]; + const char* text; + assert(mesh::readReaderVerse(referenceAt(verse), scratch, sizeof(scratch), text) + == LookupResult::Found); + const auto lines = wrapVerse(verse, text, width, measure); + Position result = lines.front().start; + for (const auto& line : lines) + if (line.start.offset <= offset) result = line.start; + return result; +} + int main(int argc, char** argv) { if (argc > 1) { if (argc > 2) assert(std::freopen(argv[2], "w", stdout)); @@ -168,7 +220,8 @@ int main(int argc, char** argv) { #endif return 0; } - // Every verse/part, in both directions, at common small-screen sizes. + // Every numbered verse, packed into full pages in both directions, at + // common small-screen sizes including the MeshPocket's native dimensions. const int cases[][3] = { {128,64,6}, {64,128,6}, {160,80,0}, {80,160,0}, {240,135,0}, {159,80,6}, {80,159,6}, {320,240,0}, {240,320,0}, {250,122,0}, {200,200,0}, @@ -233,47 +286,76 @@ int main(int argc, char** argv) { } } #endif - std::vector expected_pages; + std::vector expected_lines; char scratch[kBlockSize]; for (uint16_t verse = 0; verse < kVerseCount; ++verse) { const char* text; assert(mesh::readReaderVerse(referenceAt(verse), scratch, sizeof(scratch), text) == LookupResult::Found); - memmove(scratch, text, strlen(text) + 1); - std::string rebuilt; - uint16_t offset = 0; - do { - const auto page = readerPage(scratch, offset, display.width(), rows, measure); - assert(page.start == offset && page.next > offset); - expected.clear(); - uint16_t next = offset; - for (int row = 0; row < rows && scratch[next]; ++row) { - char line[kReaderLineBytes]; - next = readerLine(scratch, next, display.width(), measure, line); - rebuilt += line; - expected_text.setCursor(0, top + row * line_height); - expected_text.print(line); - } - assert(next == page.next); - display.clear(); screen.render(display); - const std::string body = display.body(); - assert(!body.empty()); - assert(body == expected.body()); - assert(display.getTextWidth("ABC") == 18); // header/driver font unchanged - expected_pages.push_back(body); - screen.handleInput(KEY_NEXT); - offset = page.next; - } while (scratch[offset]); - if (noSpaces(rebuilt) != noSpaces(scratch)) { - std::fprintf(stderr, "%dx%d verse index %u\nExpected: %s\nActual: %s\n", - display.width(), display.height(), verse, scratch, rebuilt.c_str()); - return 1; - } + const auto verse_lines = wrapVerse(verse, text, display.width(), measure); + expected_lines.insert(expected_lines.end(), verse_lines.begin(), verse_lines.end()); } + std::vector expected_pages; + std::vector expected_positions; + bool packed_verses = false, crossed_chapter = false; + const int body_rows = std::max(1, rows); + for (size_t first = 0; first < expected_lines.size(); first += body_rows) { + expected.clear(); + const size_t end = std::min(first + body_rows, expected_lines.size()); + for (size_t index = first; index < end; ++index) { + expected_text.setCursor(0, top + static_cast(index - first) * line_height); + expected_text.print(expected_lines[index].display.c_str()); + } + const Position start = expected_lines[first].start; + const Position last = expected_lines[end - 1].start; + packed_verses |= start.verse != last.verse; + crossed_chapter |= referenceAt(start.verse).chapter != referenceAt(last.verse).chapter; + display.clear(); screen.render(display); + const std::string body = display.body(); + if (body != expected.body()) { + std::fprintf(stderr, "%dx%d continuous page at verse %u offset %u\n", + display.width(), display.height(), start.verse, start.offset); + assert(false); + } + assert(!body.empty()); + const Reference first_ref = referenceAt(start.verse); + const std::string first_label = std::to_string(first_ref.chapter) + ":" + + std::to_string(first_ref.verse); + // Both full references must survive narrow headers, particularly the + // 64-pixel portrait case once chapter and verse each have two digits. + assert(std::any_of(display.lines.begin(), display.lines.end(), + [&](const Display::Line& line) { + return line.y < top && line.x == 0 + && (line.text == first_label || line.text == "Reader " + first_label); + })); + if (last.verse != start.verse) { + const Reference last_ref = referenceAt(last.verse); + const std::string last_label = std::to_string(last_ref.chapter) + ":" + + std::to_string(last_ref.verse); + assert(std::any_of(display.lines.begin(), display.lines.end(), + [&](const Display::Line& line) { + return line.y < top && line.x > 0 && line.text == last_label; + })); + } + assert(display.getTextWidth("ABC") == 18); // header/driver font unchanged + assert(screen.flush()); + Position saved; + the_mesh.loadReaderBookmark(saved); + assert(saved == start); + expected_positions.push_back(start); + expected_pages.push_back(body); + screen.handleInput(KEY_NEXT); + } + if (body_rows > 1) assert(packed_verses); + if (body_rows > 2) assert(crossed_chapter); display.clear(); screen.render(display); assert(display.body() == expected_pages.back()); // no wrap at end for (size_t page = expected_pages.size(); page > 0; --page) { display.clear(); screen.render(display); assert(display.body() == expected_pages[page - 1]); + assert(screen.flush()); + Position saved; + the_mesh.loadReaderBookmark(saved); + assert(saved == expected_positions[page - 1]); screen.handleInput(KEY_PREV); } display.clear(); screen.render(display); @@ -304,7 +386,7 @@ int main(int argc, char** argv) { touch_gesture(x, screen.readerTouchBar()->top, x, screen.readerTouchBar()->top); }; tap(2); assert(screen.flush()); - Position saved; assert(the_mesh.loadReaderBookmark(saved) && saved.verse == 1); + Position saved; assert(the_mesh.loadReaderBookmark(saved) && saved == expected_positions[1]); tap(1); assert(screen.flush()); assert(!the_mesh.loadReaderBookmark(saved) && saved.atStart()); tap(3); assert(screen.flush()); @@ -314,11 +396,11 @@ int main(int argc, char** argv) { tap(4); assert(task.closed); task.closed = false; touch_gesture(120,70,120,70); assert(screen.flush()); - assert(the_mesh.loadReaderBookmark(saved) && saved.verse == 1); + assert(the_mesh.loadReaderBookmark(saved) && saved == expected_positions[1]); touch_gesture(40,70,40,70); assert(screen.flush()); assert(!the_mesh.loadReaderBookmark(saved) && saved.atStart()); touch_gesture(120,70,40,70); assert(screen.flush()); - assert(the_mesh.loadReaderBookmark(saved) && saved.verse == 1); + assert(the_mesh.loadReaderBookmark(saved) && saved == expected_positions[1]); touch_gesture(40,70,120,70); assert(screen.flush()); assert(!the_mesh.loadReaderBookmark(saved) && saved.atStart()); touch_gesture(80,100,80,50); assert(screen.flush()); @@ -397,13 +479,11 @@ 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, migrated_display.bodyBottom()), compact_measure); - assert(compact_page.start <= legacy.next && compact_page.next > legacy.next); + const Position compact_line = containingLine(91, legacy.next, 128, compact_measure); + assert(compact_line.offset <= legacy.next); ReaderScreen migrated(&task, &migrated_display); migrated.open(); migrated.render(migrated_display); assert(migrated.flush()); - assert(the_mesh.loadReaderBookmark(saved) && saved == pos(91, compact_page.start)); - assert(compact_page.parts == 1); // reader 3:16 now fits in one V4 screen + assert(the_mesh.loadReaderBookmark(saved) && saved == compact_line); // Reflow an already-open reader when panel geometry crosses the 5px/6px // threshold, and when a tiny panel rotates into the stacked-header layout. @@ -418,14 +498,11 @@ int main(int argc, char** argv) { const bool small = changed.useSmallMessageFont(); 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.bodyBottom()) - : (changed.bodyBottom() - changed.bodyY()) / 10, - new_measure); - assert(new_page.start <= before.offset && new_page.next > before.offset); + const Position new_line = containingLine(91, before.offset, changed.width(), new_measure); + assert(new_line.offset <= before.offset); active.render(changed); assert(active.flush()); Position after; assert(the_mesh.loadReaderBookmark(after)); - assert(after == pos(91, new_page.start)); + assert(after == new_line); const auto rendered = changed.body(); ReaderScreen reopened(&task, &changed); reopened.open(); changed.clear(); reopened.render(changed); diff --git a/test/test_companion_reader.py b/test/test_companion_reader.py index 4d8ae35d1..dea8d49fe 100644 --- a/test/test_companion_reader.py +++ b/test/test_companion_reader.py @@ -103,7 +103,27 @@ class CompanionReaderTest(unittest.TestCase): f"-DUI_SMALL_MESSAGE_FONT={small_font}", "-DUI_BUTTON_READER_HINT=1", "-DUI_READER_TOUCH_BAR=0", "-c", str(FIXTURE / "test_reader_screen.cpp"), "-o", str(directory / (name + ".o"))]) - ui = frame(frames(name), "ReaderScreen::process(") + lookup + decoder_bytes + # The screen loads a verse, then wraps it: decoding and line + # measurement never nest. Keep the larger real call path, + # including emitted lambda/template helpers on each path. + screen_frames = frames(name) + entry = frame(screen_frames, "void ReaderScreen::process(int, bool)") + load = frame(screen_frames, "ReaderScreen::process(int, bool)::") + wrap = frame(screen_frames, "ReaderScreen::process(int, bool)::") + line_frames = [count for function, count in screen_frames.items() + if "mesh::bible::readerLine(" in function + and "ReaderScreen::process(" in function] + self.assertEqual(len(line_frames), 1) + measure_frames = [count for function, count in screen_frames.items() + if "ReaderScreen::process(int, bool)::" in function + and " [with " not in function] + self.assertLessEqual(len(measure_frames), 1) + # Any newly emitted helper must be placed in this call graph, + # rather than silently disappearing from the stack estimate. + self.assertEqual(sum("ReaderScreen::process(" in function for function in screen_frames), + 4 + len(measure_frames)) + ui = entry + max(load + lookup + decoder_bytes, + wrap + sum(line_frames) + sum(measure_frames)) ui_max = max(ui_max, ui) self.assertLessEqual(ui + caller_reserve, loop_bytes) self.assertEqual(packer.BLOCK_SIZE, 4096) @@ -273,6 +293,7 @@ namespace mesh { bool morseTimingActive(const Radio* radio) { return radio && radio->active; } namespace power { bool handleRegulatorCommand(const char*,char*,size_t) { return false; } } namespace leds { bool handleCommand(const char*,char*,size_t) { return false; } } +namespace esp32 { bool handlePowerTestCommand(const char*,char*,size_t,bool) { return false; } } } @PREDICATE@ struct MyMesh { diff --git a/tools/bible/README.md b/tools/bible/README.md index 609f569c3..a1ac7f12a 100644 --- a/tools/bible/README.md +++ b/tools/bible/README.md @@ -28,18 +28,19 @@ roles omit the corpus. `COMPANION_FEATURE_READER=0` excludes it from a Full buil ## On-device Reader Full Companions using the `ui-new` display (including the V4) also offer a -Reader from the radio-settings page: +Reader from the Network/radio-settings page: - Long press opens at the first reference, or resumes the saved position. -- 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. +- Single click advances one screenful; double click goes back one screenful. + Pages continue across verse and chapter boundaries to fill the available rows. - 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 alternate +Each verse starts with its subsection number. The header shows the first and +last references on the screen instead of a verse-local part count. This layout +applies to all boards with Reader support. 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 @@ -57,14 +58,14 @@ the display. 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 +5px Picopixel on smaller panels. The reference range retains its normal header font, stacking on narrow rotated screens to avoid overlap. On the V4's 128x64 display, five complete 8-pixel-spaced body rows fit above the navigation hint, and reference 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 `UI_SMALL_MESSAGE_FONT=0`, retain the regular body font. Existing bookmarks -resume at the page containing the same text offset after a font/layout change. +resume at the line 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 (five 10-pixel-spaced rows between the header and button hint). The existing color @@ -163,9 +164,9 @@ the screen reader's small cursor object is described above. The temporary The native Reader suite also cross-compiles the production Reader and C `tinf` decoder with Cortex-M4 GCC 14.2 and `-fstack-usage`. With 4 KiB blocks, -the terminal decode frames use at most 6,264 bytes including the bounded +the terminal decode frames use at most 6,304 bytes including the bounded page wrapper; the adaptive-font/button Reader screen's local decode path is -6,400 bytes. The check reserves another 1,536 bytes for outer callers within +6,456 bytes. The check reserves another 1,536 bytes for outer callers within the unchanged 8 KiB nRF52 loop stack. This is a compiler-derived bound on these paths, not a physical stack high-water measurement. Decoder frames are counted in addition to output scratch; ordinary commands never reserve it. @@ -178,10 +179,10 @@ ASCII-normalized copy of the pinned plaintext source, check references across 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 and without the button hint, using regular and adaptive +Reader tests additionally traverse full pages spanning all 879 numbered verses +in both 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 +240x135, 250x122, 320x240, 72x40, 40x72, 128x32, 32x128, 64x48 and 48x64. They compare rendered 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