From 7e72b6f9026512cc150149932ed3b01b279aab6f Mon Sep 17 00:00:00 2001 From: Sandmann <31391404+sandman21vs@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:54:22 +0200 Subject: [PATCH 1/6] Draw an address in fours, alternating ink A Bitcoin address is an unbroken run of base58 or bech32: no word shapes to count by, and characters chosen to look unlike each other rather than to group. It is exactly the shape a reader transcribing it loses their place in, and losing a place in an address is not a typo the reader finds out about cheaply. Drawn in groups of four now, with a gap at each boundary and the ink alternating between the body's white and the theme's orange - the same orange the back arrow and the selection bar already use, so this introduces no new colour. Two signals for one boundary on purpose: a reader who cannot tell the two inks apart still has the gap, and the grouping does not rest on colour alone. The address is never touched. Grouping happens at draw time, so what is compared, hashed and put in a QR is the same unbroken string it always was, and nothing downstream has to know to strip separators back out. Lines cut on a group boundary rather than at whatever pixel runs out, which is what keeps the alternation readable across a break - a line ending mid-group would put two same-coloured groups against each other with only the gap between them. The alternation is indexed from the start of the whole value, not from each line, for the same reason. The self-test walks four real addresses the way page_text_impl does and requires the pieces to rebuild the value character for character, plus the cut to land on a boundary. That is the invariant that matters: a group dropped or reordered at a line break would hand the reader an address that is not theirs, with nothing on screen to say so. Verified it fails when the boundary rounding is removed rather than passing vacuously. The line geometry is measured too - a grouped line is centred with its gaps counted in, so it can reach an edge a plain centred line would not. Only the address page groups. The xpub, the descriptor and the transcript are read rather than transcribed by hand, and the word list already has its own shape. --- host/origo_sdl.c | 7 ++++ host/origo_simulator.c | 90 +++++++++++++++++++++++++++++++++++++++++ main/seedtool_app.c | 33 ++++++++++++--- main/seedtool_display.c | 7 ++++ main/seedtool_display.h | 2 + main/seedtool_render.c | 90 +++++++++++++++++++++++++++++++++++++++++ main/seedtool_render.h | 25 ++++++++++++ 7 files changed, 248 insertions(+), 6 deletions(-) diff --git a/host/origo_sdl.c b/host/origo_sdl.c index eb0e7f0..8a13f21 100644 --- a/host/origo_sdl.c +++ b/host/origo_sdl.c @@ -126,6 +126,13 @@ void seedtool_display_nav_text( present(); } +void seedtool_display_nav_grouped(const seedtool_nav_t* nav, const char* title, const char* const* lines, + const size_t* first_group, const size_t count) +{ + seedtool_render_nav_grouped(nav, title, lines, first_group, count); + present(); +} + void seedtool_display_nav_rows(const seedtool_nav_t* nav, const char* title, const char* const* rows, const size_t count) { seedtool_render_nav_rows(nav, title, rows, count); diff --git a/host/origo_simulator.c b/host/origo_simulator.c index 54c9309..60f70bf 100644 --- a/host/origo_simulator.c +++ b/host/origo_simulator.c @@ -1610,6 +1610,88 @@ static bool nav_left_slot_is_drawn(void) return false; } +/* Grouping an address is a drawing decision, so the one thing it must never + * do is change the address. Walking a value with seedtool_render_fit_grouped + * the way page_text_impl does has to reproduce it character for character - + * a group dropped, doubled or reordered at a line break would hand the reader + * an address that is not theirs, and they would have no way to tell. + * + * Also checks the cut lands on a group boundary. That is what keeps the + * alternating ink meaningful across a break: a line ending mid-group would + * put two same-coloured groups against each other with only the gap between + * them, and the gap is the signal the colour is there to back up. */ +static bool grouped_paging_preserves_the_value(void) +{ + static const char* const values[] = { + /* Both address shapes this firmware derives, plus lengths that land + * exactly on a group boundary and one past it. */ + "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", + "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2", + "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy", + "abcd", + "abcde", + "abcdefgh", + }; + for (size_t v = 0; v < sizeof(values) / sizeof(values[0]); ++v) { + const char* const value = values[v]; + const size_t total = strlen(value); + char rebuilt[128] = { 0 }; + size_t used = 0, offset = 0, lines = 0; + while (offset < total && lines < 32) { + const size_t fit = seedtool_render_fit_grouped(value + offset, 48); + if (!fit) { + return false; /* no progress: the walk would never end */ + } + /* A whole number of groups unless this is the value's own tail. */ + if (offset + fit < total && fit % SEEDTOOL_GROUP_LEN) { + return false; + } + if (used + fit >= sizeof(rebuilt)) { + return false; + } + memcpy(rebuilt + used, value + offset, fit); + used += fit; + offset += fit; + ++lines; + } + rebuilt[used] = '\0'; + if (offset != total || strcmp(rebuilt, value) != 0) { + return false; + } + } + return true; +} + +/* The grouped line is centred with its gaps counted in, so it can run off an + * edge in a way the plain centred line cannot. Measured, not assumed. */ +static bool grouped_lines_clear_the_edges(void) +{ + static const char* const lines[] = { "bc1qw508d6qejxtdg4y5r3zarvary0", "bc1p5cyxnuxmeuwuvkwfem96lqzs" }; + static const size_t first_group[] = { 0, 7, 14 }; + for (size_t i = 0; i < sizeof(lines) / sizeof(lines[0]); ++i) { + /* The widest a line can be: fit_grouped's own answer for this value. */ + char line[64] = { 0 }; + const size_t fit = seedtool_render_fit_grouped(lines[i], 48); + memcpy(line, lines[i], fit); + const char* const shown[] = { line, line, line }; + const seedtool_nav_t nav = { .selected = SEEDTOOL_NAV_BODY, + .confirm = "Continue", + .confirm_enabled = true, + .back = true, + .counter = "1/2" }; + seedtool_render_nav_grouped(&nav, "m/84'/0'/0'/0/0", shown, first_group, 3); + const uint16_t* const pixels = seedtool_render_pixels(); + for (int y = 30; y < 100; ++y) { + if (pixels[y * SEEDTOOL_DISPLAY_WIDTH] != 0x0000 + || pixels[y * SEEDTOOL_DISPLAY_WIDTH + SEEDTOOL_DISPLAY_WIDTH - 1] != 0x0000) { + return false; + } + } + } + return true; +} + static bool nav_chrome_bands_do_not_collide(void) { /* Twelve rows of the widest label review_and_confirm can build: two @@ -2202,6 +2284,14 @@ static int self_test(void) fputs("Origo backup confirmation screen self-test failed\n", stderr); return 1; } + if (!grouped_paging_preserves_the_value()) { + fputs("Origo grouped address paging self-test failed\n", stderr); + return 1; + } + if (!grouped_lines_clear_the_edges()) { + fputs("Origo grouped address geometry self-test failed\n", stderr); + return 1; + } if (!nav_chrome_bands_do_not_collide()) { fputs("Origo nav chrome geometry self-test failed\n", stderr); return 1; diff --git a/main/seedtool_app.c b/main/seedtool_app.c index f844644..0bdd2d8 100644 --- a/main/seedtool_app.c +++ b/main/seedtool_app.c @@ -731,13 +731,14 @@ static void hexstr(const uint8_t* bytes, const size_t len, char* output) * the tighter line pitch the renderer uses for a third line: a third fewer * pages to review the same value. Returns true when the reader advanced past * the last page or accepted, false when they backed out. */ -static bool page_text_impl(const char* title, const char* text, const bool confirmable) +static bool page_text_impl(const char* title, const char* text, const bool confirmable, const bool grouped) { size_t start[MAX_PAGE_LINES], length[MAX_PAGE_LINES]; const size_t total = strlen(text); size_t lines = 0, offset = 0; while (lines < MAX_PAGE_LINES && (offset < total || !lines)) { - const size_t fit = seedtool_render_fit(text + offset, MAX_LINE_CHARS); + const size_t fit = grouped ? seedtool_render_fit_grouped(text + offset, MAX_LINE_CHARS) + : seedtool_render_fit(text + offset, MAX_LINE_CHARS); start[lines] = offset; length[lines] = fit; /* A glyph wider than the display would otherwise loop forever. */ @@ -773,7 +774,19 @@ static bool page_text_impl(const char* title, const char* text, const bool confi } (void)snprintf(footer, sizeof(footer), "%u/%u", (unsigned)(page + 1), (unsigned)pages); const seedtool_nav_t nav = page_nav(position, pages, footer, confirmable); - seedtool_display_nav_text(&nav, title, line1, line2, line3); + if (grouped) { + /* Where each line sits in the whole value, in groups, so the + * alternating ink carries across the line break rather than + * restarting three times a page. Every line but a final short one + * is a whole number of groups, which is what makes this exact. */ + const char* const shown[] = { line1, line2, line3 }; + const size_t first_group[] = { start[first] / SEEDTOOL_GROUP_LEN, + (first + 1 < lines ? start[first + 1] : 0) / SEEDTOOL_GROUP_LEN, + (first + 2 < lines ? start[first + 2] : 0) / SEEDTOOL_GROUP_LEN }; + seedtool_display_nav_grouped(&nav, title, shown, first_group, 3); + } else { + seedtool_display_nav_text(&nav, title, line1, line2, line3); + } switch (wait_key()) { case KEY_SELECT: if (!position) { @@ -814,7 +827,15 @@ static bool page_text_impl(const char* title, const char* text, const bool confi * caller acts on the answer, so the tick is there to give one. */ static bool page_text(const char* title, const char* text) { - return page_text_impl(title, text, true); + return page_text_impl(title, text, true, false); +} + +/* page_text for a value with no word shapes to read by - an address. Drawn in + * groups of four so the reader transcribing it has somewhere to keep their + * place; the string itself is untouched. */ +static bool page_grouped(const char* title, const char* text) +{ + return page_text_impl(title, text, true, true); } /* Paged text there is nothing to take. Returns nothing because there is @@ -822,7 +843,7 @@ static bool page_text(const char* title, const char* text) * caller that cannot ask for an answer cannot forget to use it. */ static void page_read(const char* title, const char* text) { - (void)page_text_impl(title, text, false); + (void)page_text_impl(title, text, false, false); } /* The account key carries its key origin, so a scan does not have to be told @@ -2214,7 +2235,7 @@ static void show_addresses( * make. */ char path[sizeof(prefix) + 12]; (void)snprintf(path, sizeof(path), "%s/%u", prefix, (unsigned)index); - if (page_text(path, address)) { + if (page_grouped(path, address)) { show_address_qr(path, address); } seedtool_zero(address, sizeof(address)); diff --git a/main/seedtool_display.c b/main/seedtool_display.c index 62d8295..8953130 100644 --- a/main/seedtool_display.c +++ b/main/seedtool_display.c @@ -222,6 +222,13 @@ void seedtool_display_nav_text( flush(); } +void seedtool_display_nav_grouped(const seedtool_nav_t* nav, const char* title, const char* const* lines, + const size_t* first_group, const size_t count) +{ + seedtool_render_nav_grouped(nav, title, lines, first_group, count); + flush(); +} + void seedtool_display_nav_rows(const seedtool_nav_t* nav, const char* title, const char* const* rows, const size_t count) { seedtool_render_nav_rows(nav, title, rows, count); diff --git a/main/seedtool_display.h b/main/seedtool_display.h index 3fa2227..3e827a1 100644 --- a/main/seedtool_display.h +++ b/main/seedtool_display.h @@ -33,6 +33,8 @@ void seedtool_display_splash(void); * chrome itself is already carried by seedtool_nav_t. */ void seedtool_display_nav_text( const seedtool_nav_t* nav, const char* title, const char* line1, const char* line2, const char* line3); +void seedtool_display_nav_grouped(const seedtool_nav_t* nav, const char* title, const char* const* lines, + const size_t* first_group, size_t count); void seedtool_display_nav_rows(const seedtool_nav_t* nav, const char* title, const char* const* rows, size_t count); void seedtool_display_nav_list( const seedtool_nav_t* nav, const char* title, const char* const* items, size_t count, size_t top); diff --git a/main/seedtool_render.c b/main/seedtool_render.c index 89e48b4..5ac0faf 100644 --- a/main/seedtool_render.c +++ b/main/seedtool_render.c @@ -389,6 +389,80 @@ size_t seedtool_render_fit(const char* text, const size_t limit) return fit_in_wrapped(tft_Ubuntu16, text, limit, SEEDTOOL_DISPLAY_WIDTH - 4); } +/* A Bitcoin address is a single unbroken run of base58 or bech32, which is + * exactly the shape a reader loses their place in: no word boundaries, no + * repeated shapes to count by, and characters chosen to look unlike each + * other rather than to group. Drawn in fours with a gap and an alternating + * ink, the eye gets a place to rest every four characters and two independent + * signals for where one group ends - the space and the colour change - so + * neither carries the grouping alone. + * + * The address itself is never touched. Grouping happens at draw time only: + * what is compared, hashed and put in a QR is the same unbroken string it + * always was, and nothing downstream has to know to strip separators back + * out. */ +#define GROUP_LEN SEEDTOOL_GROUP_LEN +#define GROUP_GAP 5 + +/* Ink for the group at `index`, counted from the start of the whole value so + * the alternation carries across a line break rather than restarting. */ +static uint16_t group_ink(const size_t index) { return index % 2 ? COLOR_HIGHLIGHT : COLOR_WHITE; } + +/* `length` characters of `text` drawn in groups, centred on the line as a + * whole. `first_group` is how many groups came before this line. */ +static void draw_grouped( + const uint8_t* font, const char* text, const size_t length, const int y, const size_t first_group) +{ + int width = 0; + for (size_t i = 0; i < length; ++i) { + width += glyph_advance(font, (unsigned char)text[i]); + } + /* Every gap but the trailing one: a line ending on a group boundary would + * otherwise be centred as though it carried a group it does not. */ + const size_t groups = (length + GROUP_LEN - 1) / GROUP_LEN; + if (groups > 1) { + width += (int)(groups - 1) * GROUP_GAP; + } + int x = (SEEDTOOL_DISPLAY_WIDTH - width) / 2; + if (x < 0) { + x = 0; + } + for (size_t i = 0; i < length; ++i) { + if (i && i % GROUP_LEN == 0) { + x += GROUP_GAP; + } + const uint16_t ink = group_ink(first_group + i / GROUP_LEN); + draw_glyph(font, (unsigned char)text[i], x, y, ink); + x += glyph_advance(font, (unsigned char)text[i]); + } +} + +/* How many characters of `text` fit one line once the gaps are paid for. + * Rounded down to a whole number of groups, so a group is never split across + * a line and the alternation stays readable at the break - unless the value + * ends mid-group, which is the one short group allowed. */ +size_t seedtool_render_fit_grouped(const char* text, const size_t limit) +{ + const int max_width = SEEDTOOL_DISPLAY_WIDTH - 4; + int width = 0; + size_t count = 0; + for (; count < limit && text[count]; ++count) { + int advance = glyph_advance(tft_Ubuntu16, (unsigned char)text[count]); + if (count && count % GROUP_LEN == 0) { + advance += GROUP_GAP; + } + if (width + advance > max_width) { + break; + } + width += advance; + } + if (text[count] && count >= GROUP_LEN) { + count -= count % GROUP_LEN; + } + return count; +} + + size_t seedtool_render_fit_row(const char* text) { return fit_in(tft_Ubuntu16, text, SIZE_MAX, LIST_TEXT_WIDTH); } size_t seedtool_render_fit_tail(const char* text) @@ -685,6 +759,22 @@ void seedtool_render_nav_text( nav_end(nav); } +void seedtool_render_nav_grouped(const seedtool_nav_t* nav, const char* title, const char* const* lines, + const size_t* first_group, const size_t count) +{ + nav_begin(nav, title); + /* The three-line pitch, always: an address is long enough to want the + * lines and short enough to fit them, and holding one layout keeps a + * two-line page from moving its text when a third line appears. */ + static const int y[] = { 33, 58, 83 }; + for (size_t i = 0; i < count && i < 3; ++i) { + if (lines[i] && lines[i][0]) { + draw_grouped(tft_Ubuntu16, lines[i], strlen(lines[i]), y[i], first_group[i]); + } + } + nav_end(nav); +} + void seedtool_render_nav_rows( const seedtool_nav_t* nav, const char* title, const char* const* rows, const size_t count) { diff --git a/main/seedtool_render.h b/main/seedtool_render.h index f24fb74..c668cf1 100644 --- a/main/seedtool_render.h +++ b/main/seedtool_render.h @@ -65,6 +65,17 @@ size_t seedtool_render_fit(const char* text, size_t limit); * they are paged with `seedtool_render_fit` instead, which loses nothing. */ size_t seedtool_render_fit_row(const char* text); +/* Characters per group. Four is the grouping every wallet that does this at + * all has settled on, and it divides the eye's span without making the gaps + * outnumber the text. */ +#define SEEDTOOL_GROUP_LEN 4 + +/* How many characters of `text` fit one body line when it is drawn grouped - + * see seedtool_render_nav_grouped. Fewer than seedtool_render_fit allows, + * since the gaps between groups take width, and always a whole number of + * groups unless the value itself ends mid-group. */ +size_t seedtool_render_fit_grouped(const char* text, size_t limit); + /* How many *trailing* characters of `text` fit one body line. A running * transcript is read from its end, where what was just entered is. */ size_t seedtool_render_fit_tail(const char* text); @@ -146,6 +157,20 @@ typedef struct { void seedtool_render_nav_text( const seedtool_nav_t* nav, const char* title, const char* line1, const char* line2, const char* line3); +/* The same chrome over a value drawn in groups of four, alternating between + * the body's own white and the theme's orange, with a gap at each boundary. + * For a Bitcoin address: an unbroken run of characters with no word shapes to + * count by, which is exactly what a reader transcribing it loses their place + * in. The space and the colour change each say where a group ends, so the + * grouping survives a reader who cannot tell the two inks apart. + * + * `first_group` gives, per line, how many groups came before it, so the + * alternation carries across the line break instead of restarting. The value + * passed in is never modified: this groups at draw time only, and what is + * compared or encoded elsewhere stays the unbroken string. */ +void seedtool_render_nav_grouped(const seedtool_nav_t* nav, const char* title, const char* const* lines, + const size_t* first_group, size_t count); + /* Four left-aligned rows: a numbered word list, which reads as a table rather * than as centred blocks. */ void seedtool_render_nav_rows(const seedtool_nav_t* nav, const char* title, const char* const* rows, size_t count); From 7c8137eee985c000f13379462b272592a37c2e0f Mon Sep 17 00:00:00 2001 From: Sandmann <31391404+sandman21vs@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:02:09 +0200 Subject: [PATCH 2/6] Say when a row was cut, instead of just stopping Every row of the address list is an address that does not fit, cut at whatever pixel ran out. A row that stops looks exactly like a row that ended, so the reader has nothing telling them the value continues - and on this list every value does. Rows that do not fit now end in an ellipsis. It is paid for out of the same width rather than drawn past it: the fit is recomputed against what is left after reserving room for the dots, so a truncated row is never wider than one that fits. This is in the list renderer rather than at the address list, so any row that overflows says so. In practice that is only this list - labels_fit_a_row already holds every menu label to fitting - which is why the change is narrow in effect while being general in the code. The self-test checks both halves, because either alone passes while the other is broken: that the dots stay inside the row's own column rather than running under the scrollbar, and that they are drawn at all. The second compares the rendered row against its own fit_row prefix - what a silent cut would have produced - and requires them to differ. Verified it fails when the ellipsis is removed. --- host/origo_simulator.c | 55 ++++++++++++++++++++++++++++++++++++++++++ main/seedtool_render.c | 30 +++++++++++++++++++++-- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/host/origo_simulator.c b/host/origo_simulator.c index 60f70bf..cc1f5f0 100644 --- a/host/origo_simulator.c +++ b/host/origo_simulator.c @@ -1620,6 +1620,57 @@ static bool nav_left_slot_is_drawn(void) * alternating ink meaningful across a break: a line ending mid-group would * put two same-coloured groups against each other with only the gap between * them, and the gap is the signal the colour is there to back up. */ +/* A truncated row has to say so, and has to pay for saying it out of the same + * width. Two things are checked, because either alone passes while the other + * is broken: that the dots stay inside the row's own column rather than + * running under the scrollbar, and that they are drawn at all. + * + * The second is the one worth explaining. A row drawn without the ellipsis + * would be exactly its own fit_row prefix, so rendering that prefix as a row + * in its own right and comparing the two framebuffers says whether anything + * extra reached the screen. Without it this test would pass on a draw_row + * that silently cut, which is the bug it exists for. */ +static bool truncated_rows_show_an_ellipsis(void) +{ + static uint16_t truncated[SEEDTOOL_DISPLAY_WIDTH * SEEDTOOL_DISPLAY_HEIGHT]; + /* Longer than any row can hold, which is what every address row is: an + * index, two spaces and a full address. */ + static const char* const row = " 0 bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"; + const seedtool_nav_t nav = { .selected = 0, .back = true }; + const char* const items[] = { row }; + seedtool_render_nav_list(&nav, "m/84'/0'/0'/0", items, 1, 0); + memcpy(truncated, seedtool_render_pixels(), sizeof(truncated)); + + int rightmost = -1; + for (int y = 21; y < 55; ++y) { + for (int x = 0; x < SEEDTOOL_DISPLAY_WIDTH; ++x) { + if (truncated[y * SEEDTOOL_DISPLAY_WIDTH + x] != 0x0000 && x > rightmost) { + rightmost = x; + } + } + } + if (rightmost < 0) { + return false; /* the row never reached the screen */ + } + /* LIST_TEXT_X + LIST_TEXT_WIDTH in seedtool_render.c, kept in sync with + * the 6 + 222 below: the row's own column, which the ellipsis has to stay + * inside rather than run past into the scrollbar's strip. */ + if (rightmost > 6 + 222) { + return false; + } + + /* What a silent cut would have drawn. */ + char prefix[64] = { 0 }; + const size_t fit = seedtool_render_fit_row(row); + if (fit >= sizeof(prefix) || fit >= strlen(row)) { + return false; /* the row was meant to be too long for one */ + } + memcpy(prefix, row, fit); + const char* const cut[] = { prefix }; + seedtool_render_nav_list(&nav, "m/84'/0'/0'/0", cut, 1, 0); + return memcmp(truncated, seedtool_render_pixels(), sizeof(truncated)) != 0; +} + static bool grouped_paging_preserves_the_value(void) { static const char* const values[] = { @@ -2284,6 +2335,10 @@ static int self_test(void) fputs("Origo backup confirmation screen self-test failed\n", stderr); return 1; } + if (!truncated_rows_show_an_ellipsis()) { + fputs("Origo truncated row self-test failed\n", stderr); + return 1; + } if (!grouped_paging_preserves_the_value()) { fputs("Origo grouped address paging self-test failed\n", stderr); return 1; diff --git a/main/seedtool_render.c b/main/seedtool_render.c index 5ac0faf..da91320 100644 --- a/main/seedtool_render.c +++ b/main/seedtool_render.c @@ -463,6 +463,32 @@ size_t seedtool_render_fit_grouped(const char* text, const size_t limit) } +/* A row's text, with an ellipsis when it did not all fit. A row that simply + * stops is indistinguishable from a row that ended - which is fine for a menu + * label, since those are held to fitting by a self-test, and wrong for the + * address list, where every row is a value cut short and the reader has no + * way to tell how much is missing. The three dots say "there is more" without + * claiming how much; the address's own page says the rest. + * + * The ellipsis is paid for out of the same width, not drawn past it: the fit + * is recomputed against what is left after reserving room for the dots, so a + * truncated row is never wider than one that fits. */ +#define ROW_ELLIPSIS "..." + +static void draw_row(const uint8_t* font, const char* text, const int x, const int y, const int width, + const uint16_t ink) +{ + const size_t fit = fit_in(font, text, SIZE_MAX, width); + if (!text[fit]) { + draw_line_at(font, text, fit, x, y, ink); + return; + } + const int dots = text_width(font, ROW_ELLIPSIS, strlen(ROW_ELLIPSIS)); + const size_t shortened = fit_in(font, text, SIZE_MAX, width - dots); + draw_line_at(font, text, shortened, x, y, ink); + draw_line_at(font, ROW_ELLIPSIS, strlen(ROW_ELLIPSIS), x + text_width(font, text, shortened), y, ink); +} + size_t seedtool_render_fit_row(const char* text) { return fit_in(tft_Ubuntu16, text, SIZE_MAX, LIST_TEXT_WIDTH); } size_t seedtool_render_fit_tail(const char* text) @@ -807,8 +833,8 @@ void seedtool_render_nav_list( if (highlighted) { fill_rect(LIST_BAR_X, y, LIST_BAR_WIDTH, NAV_ROW_HEIGHT - 2, COLOR_HIGHLIGHT); } - draw_line_at(tft_Ubuntu16, item, seedtool_render_fit_row(item), LIST_TEXT_X, - y + (NAV_ROW_HEIGHT - tft_Ubuntu16[1]) / 2, highlighted ? COLOR_BLACK : COLOR_WHITE); + draw_row(tft_Ubuntu16, item, LIST_TEXT_X, y + (NAV_ROW_HEIGHT - tft_Ubuntu16[1]) / 2, LIST_TEXT_WIDTH, + highlighted ? COLOR_BLACK : COLOR_WHITE); } if (count > SEEDTOOL_LIST_ROWS) { const seedtool_thumb_t thumb = seedtool_list_thumb(count, top, NAV_SCROLL_HEIGHT); From a8e458d6e312ac9df100e291774c269d4be165c9 Mon Sep 17 00:00:00 2001 From: oroderico Date: Sun, 16 Aug 2026 15:06:20 -0400 Subject: [PATCH 3/6] Say "forward" with an arrow where a tick was claiming assent A screen that hands the reader on to another one was drawing a tick, which is the glyph for agreeing to something. An address before its QR code asks nothing: the tick was answering a question the screen had not put. The confirm's look is a style now rather than a bool. Every one of the eighteen call sites set confirm_as_tick = true, so the flag distinguished nothing that existed - and a second bool for "and it points forward" would have made two flags encode three states, with one combination meaning nothing. SEEDTOOL_CONFIRM_BAR is the zero value, so a struct that names no style keeps what it had. The arrow itself is the back arrow walked from the other end: the triangle gained TRIANGLE_RIGHT, and DOWN and RIGHT are now the same mirroring of UP and LEFT. Reject on the left of the title bar and go on from the right, plainly one control pointing two ways rather than two glyphs to learn. Which screens get which: paged text always hands off - an address, an xpub or a descriptor to its QR, a transcript to the next stage - so it takes the arrow. A numbered word list is agreed to rather than passed through, and keeps the tick. Verified with the host self-test, the 52 Python tests and the self-test under ASan and UBSan, and by rendering the address screen off the framebuffer in both styles. --- host/origo_simulator.c | 26 +++++++++++++------------- main/seedtool_app.c | 37 ++++++++++++++++++++----------------- main/seedtool_render.c | 26 +++++++++++++++++++------- main/seedtool_render.h | 34 +++++++++++++++++++++++----------- 4 files changed, 75 insertions(+), 48 deletions(-) diff --git a/host/origo_simulator.c b/host/origo_simulator.c index cc1f5f0..105d77a 100644 --- a/host/origo_simulator.c +++ b/host/origo_simulator.c @@ -322,7 +322,7 @@ static bool wire_order_is_big_endian(void) .confirm = NULL, .confirm_enabled = false, .back = false, - .confirm_as_tick = true }; + .confirm_style = SEEDTOOL_CONFIRM_TICK }; seedtool_render_nav_list(&nav, "Origo", items, 3, 0); const uint16_t* const pixels = seedtool_render_pixels(); @@ -1149,7 +1149,7 @@ static seedtool_nav_t stackbit_test_nav(void) .confirm = NULL, .confirm_enabled = false, .back = true, - .confirm_as_tick = true }; + .confirm_style = SEEDTOOL_CONFIRM_TICK }; return nav; } @@ -1766,7 +1766,7 @@ static bool nav_chrome_bands_do_not_collide(void) .confirm = "Continue", .confirm_enabled = confirm_enabled, .back = true, - .confirm_as_tick = true }; + .confirm_style = SEEDTOOL_CONFIRM_TICK }; seedtool_render_nav_list(&nav, titles[i], words, 12, 0); /* Title bar ends at 20, rows run 21..116 (LIST_TOP + 3 * * NAV_ROW_HEIGHT, less the 2px each row leaves under itself), the @@ -1803,13 +1803,13 @@ static bool nav_chrome_bands_do_not_collide(void) /* A menu wears the chrome with no confirm at all: its rows are its * actions, so the arrow is the only control. Both places the confirm could * appear must stay empty - the band along the bottom, and the title bar's - * right slot. The slot is the one that caught a real bug: confirm_as_tick + * right slot. The slot is the one that caught a real bug: the tick * is set for every list, so the tick drew on the Origo and Wallet menus, * offering an answer to a question those screens never ask. Nothing here * noticed, because every check only ever asserted the tick was present. */ for (size_t s = 0; s < 2; ++s) { const seedtool_nav_t nav - = { .selected = s ? SEEDTOOL_NAV_BACK : 0, .back = true, .confirm_as_tick = true }; + = { .selected = s ? SEEDTOOL_NAV_BACK : 0, .back = true, .confirm_style = SEEDTOOL_CONFIRM_TICK }; seedtool_render_nav_list(&nav, "Word entry", words, 2, 0); if (!nav_band_is_clear(NAV_BAR_BAND_Y, SEEDTOOL_DISPLAY_HEIGHT) || nav_right_slot_is_drawn()) { return false; @@ -1847,7 +1847,7 @@ static bool nav_chrome_bands_do_not_collide(void) }; for (size_t i = 0; i < sizeof(screens) / sizeof(screens[0]); ++i) { for (int on_back = 0; on_back < 2; ++on_back) { - /* confirm_as_tick, because that is what nav_screen sets and these + /* confirm_style, because that is what nav_screen sets and these * are its screens: the confirm is a box in the title bar's right * slot and no bar is drawn. Rendering them with a bar would have * this table checking a layout the firmware stopped shipping. */ @@ -1855,7 +1855,7 @@ static bool nav_chrome_bands_do_not_collide(void) .confirm = screens[i].label, .confirm_enabled = true, .back = true, - .confirm_as_tick = true }; + .confirm_style = SEEDTOOL_CONFIRM_TICK }; seedtool_render_nav_text(&nav, screens[i].title, screens[i].line1, screens[i].line2, NULL); /* Line 2 sits at 65 and the 16px face is that tall again, so its * wraps land at 81, 97, 113 - and 113 is inside the bar's own @@ -1914,7 +1914,7 @@ static bool nav_chrome_bands_do_not_collide(void) .confirm = dice[i].label, .confirm_enabled = true, .back = true, - .confirm_as_tick = true }; + .confirm_style = SEEDTOOL_CONFIRM_TICK }; seedtool_render_nav_dice(&nav, dice[i].title, dice[i].line1, dice[i].line2, &full); if (!nav_band_is_clear(20, 21) || !nav_band_is_clear(104, 118)) { return false; @@ -1954,7 +1954,7 @@ static bool nav_chrome_bands_do_not_collide(void) .confirm = NULL, .confirm_enabled = true, .back = true, - .confirm_as_tick = true }; + .confirm_style = SEEDTOOL_CONFIRM_TICK }; seedtool_render_nav_text(&nav, notices[i].title, notices[i].line1, notices[i].line2, NULL); /* A notice's only control is now the arrow - if that failed to draw the * screen would be one the reader cannot leave at all, which is worth @@ -1981,7 +1981,7 @@ static bool nav_chrome_bands_do_not_collide(void) .confirm_enabled = true, .back = true, .counter = "8/8", - .confirm_as_tick = true }; + .confirm_style = SEEDTOOL_CONFIRM_TICK }; seedtool_render_nav_text(&paged, "Canonical transcript", "20-1-2-1-4-1-1-1-1-1-1-1-1-", "1-1-1-1-1-1-1-1-1-20-1-1-1-", "1-1-1-1-1-1-1-1-1-1"); if (!nav_band_is_clear(20, 21) || !nav_band_is_clear(116, 118)) { @@ -2005,7 +2005,7 @@ static bool nav_chrome_bands_do_not_collide(void) .confirm_enabled = true, .back = true, .counter = "6/6", - .confirm_as_tick = true }; + .confirm_style = SEEDTOOL_CONFIRM_TICK }; seedtool_render_nav_rows(&listed, "BIP39 word numbers", rows, 4); if (!nav_band_is_clear(20, 21) || !nav_band_is_clear(116, 118)) { return false; @@ -2388,12 +2388,12 @@ static int self_test(void) .confirm = NULL, .confirm_enabled = false, .back = true, - .confirm_as_tick = true }; + .confirm_style = SEEDTOOL_CONFIRM_TICK }; const seedtool_nav_t at_start = { .selected = 0, .confirm = NULL, .confirm_enabled = false, .back = true, - .confirm_as_tick = true }; + .confirm_style = SEEDTOOL_CONFIRM_TICK }; seedtool_render_nav_list(&scrolled, "Wallet", menu_labels, 7, 4); seedtool_render_nav_list(&at_start, "Word 3/12 aba", menu_labels, MENU_LABEL_COUNT, 0); /* Draw the real layouts, each opened at its centre, so one that overflows a diff --git a/main/seedtool_app.c b/main/seedtool_app.c index 0bdd2d8..2ad0ff9 100644 --- a/main/seedtool_app.c +++ b/main/seedtool_app.c @@ -260,7 +260,7 @@ static int nav_screen(const char* title, const char* one, const char* two, const * tells the reader what taking it does, on every screen here but the * two entropy verdicts, which pass no title and say it in the body * instead for the reason given at their call. */ - .confirm_as_tick = true, + .confirm_style = SEEDTOOL_CONFIRM_TICK, }; for (;;) { if (progress) { @@ -371,7 +371,7 @@ static int choose_nav(const char* title, const char* const* items, const size_t * ring has one discontinuity wherever it is put, and putting it * here sets the two ways out of a screen side by side rather than * at opposite ends of the glass. */ - .confirm_as_tick = true, + .confirm_style = SEEDTOOL_CONFIRM_TICK, }; seedtool_display_nav_list(&nav, title, items, count, top); const size_t ring = nav_ring_size(count, confirm_enabled, back); @@ -426,25 +426,28 @@ static size_t page_selection(const size_t position, const size_t pages) } /* The chrome a paged screen wears, so page_text and show_numbered_list say it - * once each rather than both spelling out the same four fields. */ -static seedtool_nav_t page_nav( - const size_t position, const size_t pages, const char* counter, const bool confirmable) + * once each rather than both spelling out the same four fields. + * + * `style` is theirs to choose because their confirms mean different things: + * paged text hands off to another screen, a numbered list is agreed to. */ +static seedtool_nav_t page_nav(const size_t position, const size_t pages, const char* counter, + const bool confirmable, const seedtool_confirm_t style) { const seedtool_nav_t nav = { .selected = page_selection(position, pages), - /* No label is what removes the tick, the same way a menu removes it: - * the reading screens have nothing for the reader to agree to, so the - * corner that would mean "I accept" is left empty rather than given a - * meaning the caller then discards. */ + /* No label is what removes the control, the same way a menu removes + * it: the reading screens have nothing for the reader to agree to or + * to go on to, so the corner is left empty rather than given a meaning + * the caller then discards. */ .confirm = confirmable ? "Continue" : NULL, .confirm_enabled = true, .back = true, .counter = counter, - /* Same tick as everywhere else. On a paged screen the ring is the - * reading order - arrow, page 1..N, then done - so the confirm being - * a corner rather than a bar changes where the cursor lands after the - * last page and nothing about how the pages are read. */ - .confirm_as_tick = true, + /* On a paged screen the ring is the reading order - arrow, page 1..N, + * then the corner - so the confirm being a corner rather than a bar + * changes where the cursor lands after the last page and nothing about + * how the pages are read. */ + .confirm_style = style, }; return nav; } @@ -773,7 +776,7 @@ static bool page_text_impl(const char* title, const char* text, const bool confi line3[length[first + 2]] = '\0'; } (void)snprintf(footer, sizeof(footer), "%u/%u", (unsigned)(page + 1), (unsigned)pages); - const seedtool_nav_t nav = page_nav(position, pages, footer, confirmable); + const seedtool_nav_t nav = page_nav(position, pages, footer, confirmable, SEEDTOOL_CONFIRM_FORWARD); if (grouped) { /* Where each line sits in the whole value, in groups, so the * alternating ink carries across the line break rather than @@ -2279,7 +2282,7 @@ static void show_stackbit(const char* mnemonic) char footer[16]; (void)snprintf(footer, sizeof(footer), "%u/%u", (unsigned)(selected + 1), (unsigned)count); const char* const word = seedtool_word(numbers[selected] - 1); - const seedtool_nav_t nav = page_nav(position, count, NULL, false); + const seedtool_nav_t nav = page_nav(position, count, NULL, false, SEEDTOOL_CONFIRM_TICK); if (layout == 0) { seedtool_display_stackbit_screen(&nav, "Stackbit 1248", numbers[selected], word, footer); } else { @@ -2374,7 +2377,7 @@ static bool show_numbered_list_impl(const char* mnemonic, const bool show_words, * the firmware build even though the host build lets it pass. */ char footer[24]; (void)snprintf(footer, sizeof(footer), "%u/%u", (unsigned)(page + 1), (unsigned)pages); - const seedtool_nav_t nav = page_nav(cursor, pages, footer, confirmable); + const seedtool_nav_t nav = page_nav(cursor, pages, footer, confirmable, SEEDTOOL_CONFIRM_TICK); const char* const rows[] = { lines[0], lines[1], lines[2], lines[3] }; seedtool_display_nav_rows(&nav, show_words ? "BIP39 words" : "BIP39 word numbers", rows, 4); switch (wait_key()) { diff --git a/main/seedtool_render.c b/main/seedtool_render.c index da91320..02945d5 100644 --- a/main/seedtool_render.c +++ b/main/seedtool_render.c @@ -599,16 +599,17 @@ size_t seedtool_list_top(const size_t count, const size_t selected, size_t previ * The `+ 1` on the step is what keeps the apex a single pixel: without it the * first two steps round to the same half-width and the point comes out as a * two-pixel spike. */ -typedef enum { TRIANGLE_UP, TRIANGLE_DOWN, TRIANGLE_LEFT } triangle_dir_t; +typedef enum { TRIANGLE_UP, TRIANGLE_DOWN, TRIANGLE_LEFT, TRIANGLE_RIGHT } triangle_dir_t; static void draw_triangle( const int x, const int y, const int width, const int height, const triangle_dir_t dir, const uint16_t color) { - const bool vertical = dir != TRIANGLE_LEFT; + const bool vertical = dir == TRIANGLE_UP || dir == TRIANGLE_DOWN; const int steps = vertical ? height : width; const int across = vertical ? width : height; for (int i = 0; i < steps; ++i) { - const int from_point = dir == TRIANGLE_DOWN ? steps - 1 - i : i; + /* DOWN and RIGHT are UP and LEFT walked from the other end. */ + const int from_point = dir == TRIANGLE_DOWN || dir == TRIANGLE_RIGHT ? steps - 1 - i : i; const int half = (from_point + 1) * across / (2 * steps); if (vertical) { fill_rect(x + width / 2 - half, y + i, 2 * half + 1, 1, color); @@ -684,7 +685,7 @@ static void draw_nav_header(const seedtool_nav_t* nav, const char* title) * its rows are its actions, and a tick there offers an answer to a * question the screen never asked - the same guard the bar had, which this * did not inherit when it replaced it. */ - if (nav->confirm_as_tick && nav->confirm) { + if (nav->confirm_style != SEEDTOOL_CONFIRM_BAR && nav->confirm) { /* Three states, the same three the bar had: filled when it is both * available and selected, outlined when available and not, and drawn * dim throughout when confirming is not possible yet - the checksum @@ -700,8 +701,19 @@ static void draw_nav_header(const seedtool_nav_t* nav, const char* title) fill_rect(x, NAV_BACK_Y, NAV_BACK_WIDTH, 1, edge); fill_rect(x, NAV_BACK_Y, 1, NAV_BACK_HEIGHT, edge); fill_rect(x + NAV_BACK_WIDTH - 1, NAV_BACK_Y, 1, NAV_BACK_HEIGHT, edge); - draw_tick(x + (NAV_BACK_WIDTH - NAV_TICK_WIDTH) / 2, NAV_BACK_Y + (NAV_BACK_HEIGHT - NAV_TICK_HEIGHT) / 2, - !nav->confirm_enabled ? COLOR_DIM : on_tick ? COLOR_BLACK : COLOR_WHITE); + const uint16_t ink = !nav->confirm_enabled ? COLOR_DIM : on_tick ? COLOR_BLACK : COLOR_WHITE; + if (nav->confirm_style == SEEDTOOL_CONFIRM_FORWARD) { + /* The back arrow's own shape, mirrored, in the slot opposite it - + * so "there is more this way" and "back the way you came" are + * plainly the same control pointing two ways, rather than two + * glyphs the reader has to learn separately. */ + draw_triangle(x + (NAV_BACK_WIDTH - NAV_ARROW_WIDTH) / 2, + NAV_BACK_Y + (NAV_BACK_HEIGHT - NAV_ARROW_HEIGHT) / 2, NAV_ARROW_WIDTH, NAV_ARROW_HEIGHT, + TRIANGLE_RIGHT, ink); + } else { + draw_tick(x + (NAV_BACK_WIDTH - NAV_TICK_WIDTH) / 2, + NAV_BACK_Y + (NAV_BACK_HEIGHT - NAV_TICK_HEIGHT) / 2, ink); + } } /* Centred between two margins the width of the arrow's box, not across the * glass: a title centred over the whole width would read as leaning right @@ -723,7 +735,7 @@ static void draw_nav_bar(const seedtool_nav_t* nav) /* Nothing along the bottom when the confirm is a tick in the header: the * control exists once, and drawing it twice would make the reader look for * the difference between them. */ - if (!nav->confirm || nav->confirm_as_tick) { + if (!nav->confirm || nav->confirm_style != SEEDTOOL_CONFIRM_BAR) { return; } const bool on_confirm = nav->selected == SEEDTOOL_NAV_CONFIRM; diff --git a/main/seedtool_render.h b/main/seedtool_render.h index c668cf1..03d553a 100644 --- a/main/seedtool_render.h +++ b/main/seedtool_render.h @@ -120,6 +120,14 @@ seedtool_thumb_t seedtool_list_thumb(size_t count, size_t top, int track); * the zero value of `confirm_enabled` dims a bar and the zero value of `back` * removes an arrow, neither of which is what a screen usually wants. The app * builds these in its own few nav helpers, not at each call site. */ +/* BAR is the zero value, so a struct that names no style keeps the behaviour + * it had before there was one to name. */ +typedef enum { + SEEDTOOL_CONFIRM_BAR, + SEEDTOOL_CONFIRM_TICK, + SEEDTOOL_CONFIRM_FORWARD, +} seedtool_confirm_t; + typedef struct { /* An item index, or one of the three sentinels above. */ size_t selected; @@ -136,18 +144,22 @@ typedef struct { bool back; /* Page counter, drawn small in the gap above the bar. NULL draws none. */ const char* counter; - /* Draw the confirm as a tick in the title bar's right slot instead of as a - * bar along the bottom. That slot is already held open - the title is - * centred between two margins the width of the arrow's box, so the right - * one has been empty since the chrome arrived - which is how Jade lays out - * a screen the reader takes or leaves: reject on the left, accept on the - * right, nothing along the bottom (ui/confirm_address.c). + /* What the confirm looks like. The title bar's right slot is already held + * open - the title is centred between two margins the width of the arrow's + * box - which is how Jade lays out a screen the reader takes or leaves: + * reject on the left, accept on the right, nothing along the bottom + * (ui/confirm_address.c). + * + * TICK and FORWARD both live in that slot and differ in what they claim. A + * tick says "yes, this one" and suits a screen whose title already names + * what confirming does. A forward arrow says "there is more this way" and + * suits a screen that hands off to another rather than accepting anything - + * an address before its QR code, say, where a tick would be agreeing to + * something nobody asked. * - * A tick says "yes" and nothing else, so this is for screens whose title - * already names what confirming does. `confirm` is still the label the - * screen would have used, kept for the self-test's width checks and for a - * caller that wants to fall back to the bar. */ - bool confirm_as_tick; + * `confirm` stays the label the screen would have used either way, kept for + * the self-test's width checks. */ + seedtool_confirm_t confirm_style; } seedtool_nav_t; /* Two or three centred body lines - three when `line3` is given, which picks From c9aa81f8cda50a416addea7b3d22210517529bce Mon Sep 17 00:00:00 2001 From: oroderico Date: Sun, 16 Aug 2026 15:06:46 -0400 Subject: [PATCH 4/6] Move the QR to the right and draw the way out of it A QR screen was the one place in the firmware whose exit was drawn nowhere at all. The code sat against the left edge with the title in the margin beside it, which left no room on the left for an arrow, so the reader had to already know the chord left the screen. The code is right-aligned now and everything that is not the code - the title, and the arrow - shares the margin. The geometry computes the code's left edge rather than taking it from a constant, so all three QR screens move together: an address, the Compact SeedQR's region map, and a zoomed region. The arrow is drawn filled rather than carrying the chrome's three states. It is not a cursor position here: every QR screen leaves on the chord from wherever the reader is, so the arrow is a label for what that chord already does, not somewhere to move to. Verified with the host self-test, the 52 Python tests and the self-test under ASan and UBSan, and by rendering an address QR off the framebuffer before and after. --- main/seedtool_render.c | 44 +++++++++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/main/seedtool_render.c b/main/seedtool_render.c index 02945d5..e9810dd 100644 --- a/main/seedtool_render.c +++ b/main/seedtool_render.c @@ -110,7 +110,7 @@ * quietly clipped by the bottom of the display. */ #define QR_VERSION 6 #define QR_MAX_MODULES (17 + 4 * QR_VERSION) -#define QR_LEFT 3 +#define QR_MARGIN 3 #define QR_TITLE_Y 50 _Static_assert(SEEDTOOL_DISPLAY_HEIGHT / (QR_MAX_MODULES + 2) >= 1, "the largest QR no longer fits the display"); @@ -1270,21 +1270,40 @@ typedef struct { int scale; int extent; int top; + int code_x; int title_x; int title_width; } qr_geometry_t; +/* The code sits against the right edge and everything that is not the code - + * the title, and the arrow back out - shares the margin left of it. It used to + * be the other way round, which left no room on the left for the arrow: a QR + * screen was the one place in the firmware whose way out was drawn nowhere at + * all, and the reader had to already know the chord left it. */ static qr_geometry_t qr_geometry(const int modules) { qr_geometry_t g; g.scale = SEEDTOOL_DISPLAY_HEIGHT / modules; g.extent = modules * g.scale; g.top = (SEEDTOOL_DISPLAY_HEIGHT - g.extent) / 2; - g.title_x = QR_LEFT + g.extent + 5; - g.title_width = SEEDTOOL_DISPLAY_WIDTH - g.title_x - 3; + g.code_x = SEEDTOOL_DISPLAY_WIDTH - QR_MARGIN - g.extent; + g.title_x = QR_MARGIN; + g.title_width = g.code_x - 2 * QR_MARGIN; return g; } +/* The way out, in the margin the code leaves. Always drawn filled rather than + * carrying the chrome's three states: every QR screen leaves on the chord from + * wherever the reader is, so the arrow is not a cursor position to move to - + * it is a label for what the chord already does. */ +static void draw_qr_back(void) +{ + fill_rect(NAV_BACK_X, NAV_BACK_Y, NAV_BACK_WIDTH, NAV_BACK_HEIGHT, COLOR_HIGHLIGHT); + draw_triangle(NAV_BACK_X + (NAV_BACK_WIDTH - NAV_ARROW_WIDTH) / 2, + NAV_BACK_Y + (NAV_BACK_HEIGHT - NAV_ARROW_HEIGHT) / 2, NAV_ARROW_WIDTH, NAV_ARROW_HEIGHT, TRIANGLE_LEFT, + COLOR_BLACK); +} + /* Shared by seedtool_render_qr and seedtool_render_qr_bytes: everything after * `qr`'s modules are populated, whichever encoder filled them. `modules` is * only needed back to zero it once drawn — it held whatever the mnemonic or @@ -1306,15 +1325,16 @@ static bool draw_qr(QRCode* qr, uint8_t* modules, const char* title) const int title_x = g.title_x; const int title_width = g.title_width; seedtool_render_clear(); - fill_rect(QR_LEFT, top, extent, extent, COLOR_WHITE); + fill_rect(g.code_x, top, extent, extent, COLOR_WHITE); for (uint8_t y = 0; y < qr->size; ++y) { for (uint8_t x = 0; x < qr->size; ++x) { if (qrcode_getModule(qr, x, y)) { - fill_rect(QR_LEFT + (x + 1) * scale, top + (y + 1) * scale, scale, scale, COLOR_BLACK); + fill_rect(g.code_x + (x + 1) * scale, top + (y + 1) * scale, scale, scale, COLOR_BLACK); } } } draw_centered_box(tft_DefaultFont, title, title_x, title_width, QR_TITLE_Y); + draw_qr_back(); memset(modules, 0, qrcode_getBufferSize(QR_VERSION)); return true; } @@ -1433,16 +1453,16 @@ bool seedtool_render_qr_bytes_map(const char* title, const uint8_t* data, const /* The code's own top-left corner, one quiet-zone module in from the white * fill's corner: where the boundary grid and the region labels are drawn * relative to, since the quiet zone itself has no region to divide. */ - const int code_left = QR_LEFT + scale; + const int code_left = g.code_x + scale; const int code_top = top + scale; const int code_extent = (int)qr.size * scale; const int block = (int)region_size * scale; seedtool_render_clear(); - fill_rect(QR_LEFT, top, extent, extent, COLOR_WHITE); + fill_rect(g.code_x, top, extent, extent, COLOR_WHITE); for (uint8_t y = 0; y < qr.size; ++y) { for (uint8_t x = 0; x < qr.size; ++x) { if (qrcode_getModule(&qr, x, y)) { - fill_rect(QR_LEFT + (x + 1) * scale, top + (y + 1) * scale, scale, scale, COLOR_BLACK); + fill_rect(g.code_x + (x + 1) * scale, top + (y + 1) * scale, scale, scale, COLOR_BLACK); } } } @@ -1470,6 +1490,7 @@ bool seedtool_render_qr_bytes_map(const char* title, const uint8_t* data, const } } draw_centered_box(tft_DefaultFont, title, title_x, title_width, QR_TITLE_Y); + draw_qr_back(); memset(modules, 0, qrcode_getBufferSize(QR_VERSION)); return true; } @@ -1497,17 +1518,18 @@ static void draw_qr_region(QRCode* qr, uint8_t* modules, const char* title, cons const size_t qx = column * region_size + x; const size_t qy = row * region_size + y; const bool set = qx < qr->size && qy < qr->size && qrcode_getModule(qr, (uint8_t)qx, (uint8_t)qy); - fill_rect(QR_LEFT + (int)x * scale, top + (int)y * scale, scale, scale, set ? COLOR_BLACK : COLOR_WHITE); + fill_rect(g.code_x + (int)x * scale, top + (int)y * scale, scale, scale, set ? COLOR_BLACK : COLOR_WHITE); } } for (size_t i = 0; i <= region_size; ++i) { - fill_rect(QR_LEFT, top + (int)i * scale, extent, 1, COLOR_DIM); - fill_rect(QR_LEFT + (int)i * scale, top, 1, extent, COLOR_DIM); + fill_rect(g.code_x, top + (int)i * scale, extent, 1, COLOR_DIM); + fill_rect(g.code_x + (int)i * scale, top, 1, extent, COLOR_DIM); } const int label_y = draw_centered_box(tft_DefaultFont, title, title_x, title_width, QR_TITLE_Y); char label[24]; (void)snprintf(label, sizeof(label), "Region %c%u", (char)('A' + row), (unsigned)(column + 1)); draw_centered_box(tft_DefaultFont, label, title_x, title_width, label_y + 4); + draw_qr_back(); memset(modules, 0, qrcode_getBufferSize(QR_VERSION)); } From 4e98211585c3c94d19bd9748548e94bb761bd682 Mon Sep 17 00:00:00 2001 From: oroderico Date: Sun, 16 Aug 2026 16:04:55 -0400 Subject: [PATCH 5/6] Let the grouped drawing take a column of its own draw_grouped centred on the display and fit_grouped measured against it, both with the body face baked in. Neither assumption holds for a caller that has a margin rather than a screen - the address drawn beside its QR code in the commit above this one. Both keep their behaviour: the paged view passes the full width and the same face, and its own checks - which walk real addresses through fit_grouped and rebuild them character for character - still pass unchanged. Verified with the host self-test and the 52 Python tests. --- main/seedtool_render.c | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/main/seedtool_render.c b/main/seedtool_render.c index e9810dd..bfc7173 100644 --- a/main/seedtool_render.c +++ b/main/seedtool_render.c @@ -408,10 +408,11 @@ size_t seedtool_render_fit(const char* text, const size_t limit) * the alternation carries across a line break rather than restarting. */ static uint16_t group_ink(const size_t index) { return index % 2 ? COLOR_HIGHLIGHT : COLOR_WHITE; } -/* `length` characters of `text` drawn in groups, centred on the line as a - * whole. `first_group` is how many groups came before this line. */ -static void draw_grouped( - const uint8_t* font, const char* text, const size_t length, const int y, const size_t first_group) +/* `length` characters of `text` drawn in groups, centred in the column at + * `column_x` of `column_width`. `first_group` is how many groups came before + * this line. */ +static void draw_grouped_in(const uint8_t* font, const char* text, const size_t length, const int column_x, + const int column_width, const int y, const size_t first_group) { int width = 0; for (size_t i = 0; i < length; ++i) { @@ -423,9 +424,9 @@ static void draw_grouped( if (groups > 1) { width += (int)(groups - 1) * GROUP_GAP; } - int x = (SEEDTOOL_DISPLAY_WIDTH - width) / 2; - if (x < 0) { - x = 0; + int x = column_x + (column_width - width) / 2; + if (x < column_x) { + x = column_x; } for (size_t i = 0; i < length; ++i) { if (i && i % GROUP_LEN == 0) { @@ -441,13 +442,14 @@ static void draw_grouped( * Rounded down to a whole number of groups, so a group is never split across * a line and the alternation stays readable at the break - unless the value * ends mid-group, which is the one short group allowed. */ -size_t seedtool_render_fit_grouped(const char* text, const size_t limit) +/* The width-and-face-agnostic half of seedtool_render_fit_grouped, so the + * address beside a QR can be fitted to its own margin with the same rounding. */ +static size_t fit_grouped_in(const uint8_t* font, const char* text, const size_t limit, const int max_width) { - const int max_width = SEEDTOOL_DISPLAY_WIDTH - 4; int width = 0; size_t count = 0; for (; count < limit && text[count]; ++count) { - int advance = glyph_advance(tft_Ubuntu16, (unsigned char)text[count]); + int advance = glyph_advance(font, (unsigned char)text[count]); if (count && count % GROUP_LEN == 0) { advance += GROUP_GAP; } @@ -462,6 +464,11 @@ size_t seedtool_render_fit_grouped(const char* text, const size_t limit) return count; } +size_t seedtool_render_fit_grouped(const char* text, const size_t limit) +{ + return fit_grouped_in(tft_Ubuntu16, text, limit, SEEDTOOL_DISPLAY_WIDTH - 4); +} + /* A row's text, with an ellipsis when it did not all fit. A row that simply * stops is indistinguishable from a row that ended - which is fine for a menu @@ -807,7 +814,8 @@ void seedtool_render_nav_grouped(const seedtool_nav_t* nav, const char* title, c static const int y[] = { 33, 58, 83 }; for (size_t i = 0; i < count && i < 3; ++i) { if (lines[i] && lines[i][0]) { - draw_grouped(tft_Ubuntu16, lines[i], strlen(lines[i]), y[i], first_group[i]); + draw_grouped_in( + tft_Ubuntu16, lines[i], strlen(lines[i]), 0, SEEDTOOL_DISPLAY_WIDTH, y[i], first_group[i]); } } nav_end(nav); From f861506cb17d0892a22b33d549905bfe50d1e896 Mon Sep 17 00:00:00 2001 From: oroderico Date: Sun, 16 Aug 2026 16:05:18 -0400 Subject: [PATCH 6/6] Put an address beside its own QR code, on one screen Opening an address showed its text, and the code was a step further in. Scanning is what a reader almost always came for, so it is the screen they land on now - with the derivation path and the address itself in the margin the right-aligned code leaves, in the same groups of four the paged view uses. Two facts about one address were on two screens; they are on one. The grid is fixed at two groups to a line rather than as many as the margin holds, so the shape is the same every time and only the last line varies. Each column is as wide as the widest group in the value: the face is proportional, so groups of equal length are not of equal width, and centring each line would start every one at its own x. A last line that does not fill its columns is centred on the block instead of left in the first one. Not every address fits. A taproot address is 62 characters against the 48 two columns hold there, so seedtool_render_qr_address measures before it draws anything and refuses what will not fit - those fall back to the code alone and then the paged text, which has the width for them. An address cut off at the margin's end looks exactly like one that ended there, which is the whole reason this returns a bool rather than drawing as far as it reaches. That refusal is checked rather than argued: the new self-test alters an address's last group and requires the margin to change with it, so a tail that was never drawn fails, and it requires the taproot address to be refused outright. Both halves were confirmed against a deliberately broken build - removing the height guard fails it - because the existing grouped-paging check walks only the paged path at the body face and says nothing about groups drawn by any other route. page_grouped loses its only caller and goes; the paged grouped view it named is reached through page_text_impl now, for the values that need it. Verified with the host self-test, the 52 Python tests, the QR smoke test and the self-test under ASan and UBSan. Every address shape the firmware derives was rendered off the framebuffer and read. --- host/origo_sdl.c | 9 +++ host/origo_simulator.c | 90 ++++++++++++++++++++++++ main/seedtool_app.c | 41 +++++++---- main/seedtool_display.c | 9 +++ main/seedtool_display.h | 1 + main/seedtool_render.c | 150 +++++++++++++++++++++++++++++++++++++--- main/seedtool_render.h | 5 ++ 7 files changed, 281 insertions(+), 24 deletions(-) diff --git a/host/origo_sdl.c b/host/origo_sdl.c index 8a13f21..c120935 100644 --- a/host/origo_sdl.c +++ b/host/origo_sdl.c @@ -169,6 +169,15 @@ bool seedtool_display_qr(const char* title, const char* text) return ok; } +bool seedtool_display_qr_address(const char* title, const char* text) +{ + const bool ok = seedtool_render_qr_address(title, text); + if (ok) { + present(); + } + return ok; +} + bool seedtool_display_qr_bytes(const char* title, const uint8_t* data, const size_t len) { const bool ok = seedtool_render_qr_bytes(title, data, len); diff --git a/host/origo_simulator.c b/host/origo_simulator.c index 105d77a..d46b799 100644 --- a/host/origo_simulator.c +++ b/host/origo_simulator.c @@ -1714,6 +1714,92 @@ static bool grouped_paging_preserves_the_value(void) return true; } +/* The QR screen draws the address beside the code, and the margin it has is + * not always enough: a taproot address is 62 characters against the 48 two + * columns of four hold there. seedtool_render_qr_address must refuse those + * outright rather than draw as far as it reaches, because an address cut off + * mid-value looks exactly like one that ended there and nothing on screen + * says otherwise. This is the check grouped_paging_preserves_the_value does + * not make: that one walks seedtool_render_fit_grouped, the paged path at the + * body face, and says nothing about groups drawn by any other route. The first + * version of this screen truncated a taproot address to 48 of its characters + * with every other check still passing. + * + * Proving the tail is drawn without restating the renderer's own geometry: + * change only the value's last group and the margin must change with it. The + * margin is everything left of the code, found by looking for the first column + * carrying a run of white tall enough to be the code itself rather than a line + * of text. */ +static int qr_code_left_edge(void) +{ + const uint16_t* const pixels = seedtool_render_pixels(); + for (int x = 0; x < SEEDTOOL_DISPLAY_WIDTH; ++x) { + int run = 0; + for (int y = 0; y < SEEDTOOL_DISPLAY_HEIGHT; ++y) { + run = pixels[y * SEEDTOOL_DISPLAY_WIDTH + x] == 0xffff ? run + 1 : 0; + if (run > 40) { + return x; + } + } + } + return -1; +} + +static bool qr_address_draws_every_group(void) +{ + static const char* const fits[] = { + "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2", + "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy", + }; + static uint16_t before[SEEDTOOL_DISPLAY_WIDTH * SEEDTOOL_DISPLAY_HEIGHT]; + + for (size_t v = 0; v < sizeof(fits) / sizeof(fits[0]); ++v) { + const char* const value = fits[v]; + if (!seedtool_render_qr_address("m/84'/0'/0'/0/0", value)) { + return false; /* these do fit; refusing them is its own failure */ + } + const int edge = qr_code_left_edge(); + if (edge <= 0) { + return false; + } + memcpy(before, seedtool_render_pixels(), sizeof(before)); + + /* The same value with its last group altered, same length so the code + * keeps its version and the margin keeps its width. */ + char altered[80] = { 0 }; + const size_t total = strlen(value); + if (total + 1 > sizeof(altered) || total < SEEDTOOL_GROUP_LEN) { + return false; + } + memcpy(altered, value, total); + for (size_t i = total - SEEDTOOL_GROUP_LEN; i < total; ++i) { + altered[i] = altered[i] == 'q' ? 'p' : 'q'; + } + if (!seedtool_render_qr_address("m/84'/0'/0'/0/0", altered)) { + return false; + } + const uint16_t* const after = seedtool_render_pixels(); + + bool margin_changed = false; + for (int y = 0; y < SEEDTOOL_DISPLAY_HEIGHT && !margin_changed; ++y) { + for (int x = 0; x < edge; ++x) { + if (before[y * SEEDTOOL_DISPLAY_WIDTH + x] != after[y * SEEDTOOL_DISPLAY_WIDTH + x]) { + margin_changed = true; + break; + } + } + } + if (!margin_changed) { + return false; /* the tail was never drawn */ + } + } + + /* And the one the margin cannot hold, which must say so rather than clip. */ + return !seedtool_render_qr_address( + "m/86'/0'/0'/0/0", "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr"); +} + /* The grouped line is centred with its gaps counted in, so it can run off an * edge in a way the plain centred line cannot. Measured, not assumed. */ static bool grouped_lines_clear_the_edges(void) @@ -2347,6 +2433,10 @@ static int self_test(void) fputs("Origo grouped address geometry self-test failed\n", stderr); return 1; } + if (!qr_address_draws_every_group()) { + fputs("Origo address-beside-QR self-test failed\n", stderr); + return 1; + } if (!nav_chrome_bands_do_not_collide()) { fputs("Origo nav chrome geometry self-test failed\n", stderr); return 1; diff --git a/main/seedtool_app.c b/main/seedtool_app.c index 2ad0ff9..972cf00 100644 --- a/main/seedtool_app.c +++ b/main/seedtool_app.c @@ -833,13 +833,6 @@ static bool page_text(const char* title, const char* text) return page_text_impl(title, text, true, false); } -/* page_text for a value with no word shapes to read by - an address. Drawn in - * groups of four so the reader transcribing it has somewhere to keep their - * place; the string itself is untouched. */ -static bool page_grouped(const char* title, const char* text) -{ - return page_text_impl(title, text, true, true); -} /* Paged text there is nothing to take. Returns nothing because there is * nothing to return: the reader pages down and leaves by the arrow, and a @@ -1945,19 +1938,35 @@ static void show_descriptor(const char* mnemonic, const char* passphrase, const seedtool_zero(value, sizeof(value)); } -/* A single address's own QR, opened from the address list: no account key in - * this view to warn about or to carousel over to, since a photo of one - * address on its own reveals nothing the address itself did not already. */ +/* A single address's own QR, and the first thing opening an address shows: no + * account key in this view to warn about or to carousel over to, since a photo + * of one address on its own reveals nothing the address itself did not + * already. Scanning is what a reader almost always came for, so the code, its + * path and the address itself share one screen rather than two. + * + * Not every address fits beside its code: a taproot address is 62 characters + * against the 48 the margin holds. seedtool_display_qr_address refuses those + * rather than drawing as far as it reaches - an address cut off mid-value + * looks exactly like one that ended there - and they fall back to the code + * alone, then the paged text, which has the width for them. */ static void show_address_qr(const char* title, const char* address) { for (;;) { + if (seedtool_display_qr_address(title, address)) { + if (wait_key() == KEY_REDRAW) { + continue; + } + return; + } if (!seedtool_display_qr(title, address)) { notice("Too long for a QR", title, "Read it as text instead"); return; } - if (wait_key() != KEY_REDRAW) { - return; + if (wait_key() == KEY_REDRAW) { + continue; } + (void)page_text_impl(title, address, false, true); + return; } } @@ -2238,9 +2247,11 @@ static void show_addresses( * make. */ char path[sizeof(prefix) + 12]; (void)snprintf(path, sizeof(path), "%s/%u", prefix, (unsigned)index); - if (page_grouped(path, address)) { - show_address_qr(path, address); - } + /* One screen: the code, its path, and the address itself beside it. + * Opening an address used to land on the text and reach the QR through + * it, which cost a step to the reader who came to scan - and left the + * two halves of the same fact on separate screens. */ + show_address_qr(path, address); seedtool_zero(address, sizeof(address)); } seedtool_zero(addresses, sizeof(addresses)); diff --git a/main/seedtool_display.c b/main/seedtool_display.c index 8953130..526d97b 100644 --- a/main/seedtool_display.c +++ b/main/seedtool_display.c @@ -265,6 +265,15 @@ bool seedtool_display_qr(const char* title, const char* text) return ok; } +bool seedtool_display_qr_address(const char* title, const char* text) +{ + const bool ok = seedtool_render_qr_address(title, text); + if (ok) { + flush(); + } + return ok; +} + bool seedtool_display_qr_bytes(const char* title, const uint8_t* data, const size_t len) { const bool ok = seedtool_render_qr_bytes(title, data, len); diff --git a/main/seedtool_display.h b/main/seedtool_display.h index 3e827a1..618d34e 100644 --- a/main/seedtool_display.h +++ b/main/seedtool_display.h @@ -47,6 +47,7 @@ void seedtool_display_value_box( void seedtool_display_keyboard(const char* title, const char* text, const char* layout, const bool* enabled, size_t selected, size_t position, size_t total); bool seedtool_display_qr(const char* title, const char* text); +bool seedtool_display_qr_address(const char* title, const char* text); bool seedtool_display_qr_bytes(const char* title, const uint8_t* data, size_t len); bool seedtool_display_qr_bytes_region(const char* title, const uint8_t* data, size_t len, size_t region_index); bool seedtool_display_qr_bytes_map(const char* title, const uint8_t* data, size_t len); diff --git a/main/seedtool_render.c b/main/seedtool_render.c index bfc7173..8642497 100644 --- a/main/seedtool_render.c +++ b/main/seedtool_render.c @@ -1304,19 +1304,43 @@ static qr_geometry_t qr_geometry(const int modules) * carrying the chrome's three states: every QR screen leaves on the chord from * wherever the reader is, so the arrow is not a cursor position to move to - * it is a label for what the chord already does. */ +/* One control's box in the margin, drawn the way the header's are: filled when + * it is where the cursor sits, outlined when it is not. */ +static void draw_qr_control(const int y, const triangle_dir_t dir, const bool selected) +{ + if (selected) { + fill_rect(NAV_BACK_X, y, NAV_BACK_WIDTH, NAV_BACK_HEIGHT, COLOR_HIGHLIGHT); + } + draw_border(NAV_BACK_X, y, NAV_BACK_WIDTH, NAV_BACK_HEIGHT, selected ? COLOR_HIGHLIGHT : COLOR_DIM); + draw_triangle(NAV_BACK_X + (NAV_BACK_WIDTH - NAV_ARROW_WIDTH) / 2, y + (NAV_BACK_HEIGHT - NAV_ARROW_HEIGHT) / 2, + NAV_ARROW_WIDTH, NAV_ARROW_HEIGHT, dir, selected ? COLOR_BLACK : COLOR_WHITE); +} + +/* The only control a QR screen with nothing after it carries. Always drawn + * filled: it is not a cursor position, since those screens leave on the chord + * from wherever the reader is - it is a label for what that chord does. */ static void draw_qr_back(void) { - fill_rect(NAV_BACK_X, NAV_BACK_Y, NAV_BACK_WIDTH, NAV_BACK_HEIGHT, COLOR_HIGHLIGHT); - draw_triangle(NAV_BACK_X + (NAV_BACK_WIDTH - NAV_ARROW_WIDTH) / 2, - NAV_BACK_Y + (NAV_BACK_HEIGHT - NAV_ARROW_HEIGHT) / 2, NAV_ARROW_WIDTH, NAV_ARROW_HEIGHT, TRIANGLE_LEFT, - COLOR_BLACK); + draw_qr_control(NAV_BACK_Y, TRIANGLE_LEFT, true); +} + +/* Back at the top of the margin, forward at the bottom, stacked the way the + * two buttons are: up reaches the way out, down reaches what comes next. For a + * QR that has somewhere to go on to - an address, whose text is a screen the + * reader may want and mostly does not. */ +#define QR_FORWARD_Y (SEEDTOOL_DISPLAY_HEIGHT - NAV_BACK_Y - NAV_BACK_HEIGHT) + +static void draw_qr_nav(const size_t selected) +{ + draw_qr_control(NAV_BACK_Y, TRIANGLE_LEFT, selected == SEEDTOOL_NAV_BACK); + draw_qr_control(QR_FORWARD_Y, TRIANGLE_RIGHT, selected == SEEDTOOL_NAV_CONFIRM); } /* Shared by seedtool_render_qr and seedtool_render_qr_bytes: everything after * `qr`'s modules are populated, whichever encoder filled them. `modules` is * only needed back to zero it once drawn — it held whatever the mnemonic or * key material was encoded as. */ -static bool draw_qr(QRCode* qr, uint8_t* modules, const char* title) +static bool draw_qr(QRCode* qr, uint8_t* modules, const char* title, const size_t* selected) { /* The code sits left with its quiet zone intact and the title goes in the * margin beside it: on a screen that steps through several codes, one that @@ -1342,12 +1366,18 @@ static bool draw_qr(QRCode* qr, uint8_t* modules, const char* title) } } draw_centered_box(tft_DefaultFont, title, title_x, title_width, QR_TITLE_Y); - draw_qr_back(); + if (selected) { + draw_qr_nav(*selected); + } else { + draw_qr_back(); + } memset(modules, 0, qrcode_getBufferSize(QR_VERSION)); return true; } -bool seedtool_render_qr(const char* title, const char* text) +/* Shared by the plain QR and the one that carries a forward control: `selected` + * NULL draws only the way out. */ +static bool render_qr_text(const char* title, const char* text, const size_t* selected) { /* Drawn at the smallest version that actually holds `text` -- like * seedtool_render_qr_bytes already does for Compact SeedQR -- rather than @@ -1366,7 +1396,109 @@ bool seedtool_render_qr(const char* title, const char* text) memset(modules, 0, sizeof(modules)); return false; } - return draw_qr(&qr, modules, title); + return draw_qr(&qr, modules, title, selected); +} + +bool seedtool_render_qr(const char* title, const char* text) { return render_qr_text(title, text, NULL); } + +/* Two groups to a line, so the address falls into a fixed block rather than + * into however many groups a margin happens to hold: a reader checking one + * against paper or a scanner reads the same shape every time, and the ragged + * last line is the only thing that varies. A 42-character bech32 address comes + * out as five full lines and a short sixth. */ +#define QR_ADDRESS_GROUPS 2 + +/* The code, its derivation path and the address itself, on one screen. The + * margin the right-aligned code leaves is tall enough to hold all three: the + * way out at the top, then the path, then the address under it in the same + * groups of four the paged view uses, so a reader checking the screen against + * a scanner or against paper reads the same shape either way. + * + * The address is drawn in the small face rather than the body one - it is here + * to be checked against something, not transcribed from. */ +bool seedtool_render_qr_address(const char* title, const char* text) +{ + const uint8_t version = qrcode_versionForText(ECC_LOW, text, QR_VERSION); + if (!version) { + return false; + } + const qr_geometry_t g = qr_geometry(17 + 4 * version + 2); + + const int text_top = NAV_BACK_Y + NAV_BACK_HEIGHT + 6 + 2 * tft_DefaultFont[1] + 2; + const size_t total = strlen(text); + + /* A real grid, not centred lines. The face is proportional, so groups of + * the same length are not the same width - centring each line would start + * every one at its own x and the block would read as ragged on both edges. + * Each column is as wide as the widest group in this value, and every + * group is drawn from its column's own left edge. */ + int column = 0; + for (size_t i = 0; i < total; i += GROUP_LEN) { + const size_t run = total - i < GROUP_LEN ? total - i : GROUP_LEN; + int width = 0; + for (size_t j = 0; j < run; ++j) { + width += glyph_advance(tft_DefaultFont, (unsigned char)text[i + j]); + } + if (width > column) { + column = width; + } + } + const int block_width = QR_ADDRESS_GROUPS * column + (QR_ADDRESS_GROUPS - 1) * GROUP_GAP; + + /* Checked before a single pixel is drawn, and the whole reason this + * returns a bool. A value that does not fit here must send the caller + * somewhere it does fit, not be drawn as far as the margin reaches: an + * address cut off at the sixth line looks exactly like an address that + * ended there, and the reader has no way to tell. A taproot address is + * the case that does not fit - 62 characters against the 48 two columns + * of four hold in this margin. */ + const int step = tft_DefaultFont[1] + 2; + const size_t lines = (total + QR_ADDRESS_GROUPS * GROUP_LEN - 1) / (QR_ADDRESS_GROUPS * GROUP_LEN); + if (block_width > g.title_width || text_top + (int)lines * step > SEEDTOOL_DISPLAY_HEIGHT) { + return false; + } + + if (!seedtool_render_qr(NULL, text)) { + return false; + } + int y = NAV_BACK_Y + NAV_BACK_HEIGHT + 6; + /* A blank line between the path and the value: they are two different + * facts, and run together they read as one wrapped string. */ + y = draw_centered_box(tft_DefaultFont, title, g.title_x, g.title_width, y) + tft_DefaultFont[1] + 2; + + int left = g.title_x + (g.title_width - block_width) / 2; + if (left < g.title_x) { + left = g.title_x; + } + + for (size_t i = 0; i < total; i += GROUP_LEN) { + const size_t run = total - i < GROUP_LEN ? total - i : GROUP_LEN; + const size_t slot = (i / GROUP_LEN) % QR_ADDRESS_GROUPS; + /* A last line that does not fill its columns is centred on the block + * instead of left in column one: the grid is there to be read down, + * and a lone group hanging off the left edge reads as a column that + * lost its partner rather than as the end of the value. */ + const bool last_line = i + GROUP_LEN >= total && slot == 0; + int x; + if (last_line) { + int width = 0; + for (size_t j = 0; j < run; ++j) { + width += glyph_advance(tft_DefaultFont, (unsigned char)text[i + j]); + } + x = left + (block_width - width) / 2; + } else { + x = left + (int)slot * (column + GROUP_GAP); + } + const uint16_t ink = group_ink(i / GROUP_LEN); + for (size_t j = 0; j < run; ++j) { + draw_glyph(tft_DefaultFont, (unsigned char)text[i + j], x, y, ink); + x += glyph_advance(tft_DefaultFont, (unsigned char)text[i + j]); + } + if (slot + 1 == QR_ADDRESS_GROUPS) { + y += tft_DefaultFont[1] + 2; + } + } + return true; } size_t seedtool_render_qr_alphanumeric_capacity(const uint8_t max_version) @@ -1404,7 +1536,7 @@ bool seedtool_render_qr_bytes(const char* title, const uint8_t* data, const size memset(modules, 0, sizeof(modules)); return false; } - return draw_qr(&qr, modules, title); + return draw_qr(&qr, modules, title, NULL); } /* 7x7 blocks for the smallest (version 1, 21x21) QR, 5x5 otherwise: Krux's own diff --git a/main/seedtool_render.h b/main/seedtool_render.h index 03d553a..c4e19d5 100644 --- a/main/seedtool_render.h +++ b/main/seedtool_render.h @@ -243,6 +243,11 @@ size_t seedtool_layout_center(const char* layout); bool seedtool_render_qr(const char* title, const char* text); +/* The same code, with its derivation path and the value itself drawn in the + * margin beside it - for an address, where the code and the text are two halves + * of one fact and were on two screens. */ +bool seedtool_render_qr_address(const char* title, const char* text); + /* How many alphanumeric-mode characters (see qrcode_versionForAlphanumeric) * fit in one frame at `max_version` and ECC_LOW -- the same error-correction * level every other QR this firmware draws already commits to. What a