Fill Reader pages with numbered subsections

This commit is contained in:
mikecarper committed 2026-10-21 22:22:52 -07:00
1 parent 6236eec386
commit bee8cf7c4e
5 files changed
+268 -119

No files matched your search

+99 -54
View File
@@ -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;
}
}
+11 -6
View File
@@ -32,15 +32,20 @@ constexpr size_t kReaderLineBytes = 128;
// word or cut a UTF-8 codepoint, including overlong words on narrow displays.
template <typename Measure>
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<uint8_t>(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<int>(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<uint16_t>(start + count);
while (text[next] == ' ') ++next;
return next;
+123 -46
View File
@@ -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 <typename Measure>
static std::vector<ExpectedLine> wrapVerse(uint16_t verse, const char* text,
int width, Measure measure) {
std::vector<ExpectedLine> 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<uint16_t>(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 <typename Measure>
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<std::string> expected_pages;
std::vector<ExpectedLine> 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<std::string> expected_pages;
std::vector<Position> 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<int>(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<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.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);
+22 -1
View File
@@ -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)::<lambda(uint16_t)>")
wrap = frame(screen_frames, "ReaderScreen::process(int, bool)::<lambda(uint16_t, uint16_t)>")
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)::<lambda(const char*)>" 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 {
+13 -12
View File
@@ -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