Skip to content

Group an address in fours, and say when a row was cut - #20

Merged
oroderico merged 2 commits into
oroderico:mainfrom
sandman21vs:address-blocks
Aug 16, 2026
Merged

Group an address in fours, and say when a row was cut#20
oroderico merged 2 commits into
oroderico:mainfrom
sandman21vs:address-blocks

Conversation

@sandman21vs

Copy link
Copy Markdown
Collaborator

A Bitcoin address is an unbroken run of base58 or bech32: no word shapes to
count by, no repeated forms to keep place with, and an alphabet chosen so that
characters look unlike each other rather than group into anything. It is
exactly the shape a reader transcribing it loses their place in — and losing a
place in an address is not a mistake that announces itself. The reader finds
out when the funds do not arrive.

Two changes, both about the same thing: telling the reader what they are
looking at.

The address page reads in fours

        m/84'/0'/0'/0/0

   bc1q w508 d6qe jxtd g4y5
   r3za rvar y0c5 xw7k v8f3
        t4

              1/1

Groups of four, a gap at each boundary, and the ink alternating between the
body's own white and the theme's orange — the same orange the back arrow and
the selection bar already use, so no new colour enters the palette.

The gap and the colour say the same thing on purpose. A reader who cannot
tell the two inks apart still has the space; the grouping never rests on
colour alone. Two independent signals for one boundary is the point, not
redundancy to be optimised away.

The address 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. There is no
formatted copy of the value in memory to drift from the real one.

Two details that are not arbitrary:

  • Lines cut on a group boundary, not at whatever pixel runs out. 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 exists to
    back up. seedtool_render_fit_grouped rounds down to a whole group, except
    for the value's own tail.
  • The alternation is indexed from the start of the whole value, not from
    each line. Otherwise line two restarts in white and the break shows two
    white groups running together, which is the one place the reader most needs
    the boundary to be obvious.

Only the address page groups. The xpub, the descriptor and the transcript are
scanned or read rather than copied out by hand, and the word list already has
a shape of its own.

The address list says when a row was cut

Every row of Addresses 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
nothing on screen tells the reader the value continues — and on this list,
every value does.

   0  bc1qw508d6qejxt...
   1  bc1q9d4ywgfnd8h...

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, and the dots never reach the
scrollbar's strip.

This went into the list renderer rather than into 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.

Testing

./tools/run-simulator.sh --self-test     # passes
python3 -m unittest discover -s tests    # 52 passed

Three new checks, and the reasoning behind each is the interesting part:

Grouping must not change the address. This is the only way this feature
can do real harm: a group dropped, doubled or reordered at a line break hands
the reader an address that is not theirs, with nothing on screen to say so.
The test walks four real addresses — both bech32 shapes and both base58 ones —
the way page_text_impl walks them, and requires the pieces to rebuild the
value character for character. It also requires each cut to land on a group
boundary.

The grouped line must clear both edges. It is centred with its gaps
counted in, so it can reach an edge a plain centred line would not. Measured
from the framebuffer rather than argued from the arithmetic.

The truncated row must both fit and be visible as truncated. Two halves,
because either alone passes while the other is broken. The second compares the
rendered row against its own fit_row prefix — precisely what a silent cut
would have produced — and requires them to differ.

Both of the load-bearing tests were checked against a deliberately broken
build before being trusted: removing the group-boundary rounding fails the
first, removing the ellipsis fails the third. Neither passes vacuously.

What none of this reaches is the same gap the nav work had: no test drives
navigation, so the flows around these screens were checked by hand in the
simulator.

The firmware build and its reproducibility check have not been run here; only
the host build and the Python suite. -Wformat-truncation at the level the
firmware build errors on is clean for both changed files.

Notes

Branched from current main, after #19. Touches
seedtool_render.{c,h}, both display backends, page_text_impl's split, and
the self-test.

SEEDTOOL_GROUP_LEN is in the header rather than private to the renderer,
because page_text_impl needs it to say which group each line begins on.
Four is what every wallet that groups at all has settled on; it divides the
eye's span without the gaps starting to outnumber the text.

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.
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.
@oroderico

Copy link
Copy Markdown
Owner

Heads up before you merge: I have work in progress that builds directly on the grouping here, and it changes where it lives. Flagging now rather than after, so nothing lands as a surprise.

What is on top of this branch locally

The address flow becomes one screen instead of two. Opening an address lands on its QR, with the derivation path and the address itself drawn in the margin beside the code, in your groups of four with the alternating ink — two groups to a line, as a fixed grid.

That reuses your work rather than replacing it: draw_grouped is generalised to take a column (draw_grouped_in) so it can centre inside the margin rather than the display, and seedtool_render_fit_grouped's body is extracted as a width-and-face-agnostic helper. Both keep their current behaviour for your paged view — the existing call passes the full width and the same face.

What this costs on your side: page_grouped loses its only caller, so the paged grouped view stops being the address screen. It stays as the fallback for values that do not fit beside a code — a taproot address is 62 characters against the 48 two columns hold in that margin — which is the one place where the full-width, large-face view is genuinely better anyway.

One thing worth knowing regardless of my branch. grouped_paging_preserves_the_value walks the value through seedtool_render_fit_grouped, which is the paged path at the body face and full width. Anything drawing groups by another route is not covered by it. I hit exactly that: my first version of the combined screen silently truncated a taproot address to 48 of its 62 characters, and every check here still passed. I am adding the equivalent test for the new path, but it is worth saying that the guarantee is narrower than the test name suggests.

No objection to this merging as it stands — it is self-contained and I would rather rebase onto it than push into it. Just did not want you to find your grouping repurposed without hearing it from me first.

@oroderico
oroderico merged commit 18884cd into oroderico:main Aug 16, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants