diff --git a/host/origo_sdl.c b/host/origo_sdl.c index eb0e7f0..c120935 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); @@ -162,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 54c9309..d46b799 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; } @@ -1610,6 +1610,225 @@ 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. */ +/* 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[] = { + /* 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 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) +{ + 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 @@ -1633,7 +1852,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 @@ -1670,13 +1889,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; @@ -1714,7 +1933,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. */ @@ -1722,7 +1941,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 @@ -1781,7 +2000,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; @@ -1821,7 +2040,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 @@ -1848,7 +2067,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)) { @@ -1872,7 +2091,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; @@ -2202,6 +2421,22 @@ 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; + } + if (!grouped_lines_clear_the_edges()) { + 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; @@ -2243,12 +2478,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 f844644..972cf00 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; } @@ -731,13 +734,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. */ @@ -772,8 +776,20 @@ 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); - seedtool_display_nav_text(&nav, title, line1, line2, line3); + 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 + * 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,15 +830,16 @@ 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); } + /* 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 * 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 @@ -1921,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; } } @@ -2214,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_text(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)); @@ -2258,7 +2293,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 { @@ -2353,7 +2388,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_display.c b/main/seedtool_display.c index 62d8295..526d97b 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); @@ -258,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 3fa2227..618d34e 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); @@ -45,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 89e48b4..8642497 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"); @@ -389,6 +389,113 @@ 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 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) { + 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 = 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) { + 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. */ +/* 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) +{ + int width = 0; + size_t count = 0; + for (; count < limit && text[count]; ++count) { + int advance = glyph_advance(font, (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_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 + * 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) @@ -499,16 +606,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); @@ -584,7 +692,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 @@ -600,8 +708,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 @@ -623,7 +742,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; @@ -685,6 +804,23 @@ 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_in( + tft_Ubuntu16, lines[i], strlen(lines[i]), 0, SEEDTOOL_DISPLAY_WIDTH, 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) { @@ -717,8 +853,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); @@ -1142,26 +1278,69 @@ 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. */ +/* 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) +{ + 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 @@ -1178,20 +1357,27 @@ 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); + 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 @@ -1210,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) @@ -1248,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 @@ -1305,16 +1593,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); } } } @@ -1342,6 +1630,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; } @@ -1369,17 +1658,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)); } diff --git a/main/seedtool_render.h b/main/seedtool_render.h index f24fb74..c4e19d5 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); @@ -109,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; @@ -125,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 @@ -146,6 +169,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); @@ -206,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