@@ -1610,6 +1610,139 @@ static bool nav_left_slot_is_drawn(void)
16101610 return false;
16111611}
16121612
1613+ /* Grouping an address is a drawing decision, so the one thing it must never
1614+ * do is change the address. Walking a value with seedtool_render_fit_grouped
1615+ * the way page_text_impl does has to reproduce it character for character -
1616+ * a group dropped, doubled or reordered at a line break would hand the reader
1617+ * an address that is not theirs, and they would have no way to tell.
1618+ *
1619+ * Also checks the cut lands on a group boundary. That is what keeps the
1620+ * alternating ink meaningful across a break: a line ending mid-group would
1621+ * put two same-coloured groups against each other with only the gap between
1622+ * them, and the gap is the signal the colour is there to back up. */
1623+ /* A truncated row has to say so, and has to pay for saying it out of the same
1624+ * width. Two things are checked, because either alone passes while the other
1625+ * is broken: that the dots stay inside the row's own column rather than
1626+ * running under the scrollbar, and that they are drawn at all.
1627+ *
1628+ * The second is the one worth explaining. A row drawn without the ellipsis
1629+ * would be exactly its own fit_row prefix, so rendering that prefix as a row
1630+ * in its own right and comparing the two framebuffers says whether anything
1631+ * extra reached the screen. Without it this test would pass on a draw_row
1632+ * that silently cut, which is the bug it exists for. */
1633+ static bool truncated_rows_show_an_ellipsis (void )
1634+ {
1635+ static uint16_t truncated [SEEDTOOL_DISPLAY_WIDTH * SEEDTOOL_DISPLAY_HEIGHT ];
1636+ /* Longer than any row can hold, which is what every address row is: an
1637+ * index, two spaces and a full address. */
1638+ static const char * const row = " 0 bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" ;
1639+ const seedtool_nav_t nav = { .selected = 0 , .back = true };
1640+ const char * const items [] = { row };
1641+ seedtool_render_nav_list (& nav , "m/84'/0'/0'/0" , items , 1 , 0 );
1642+ memcpy (truncated , seedtool_render_pixels (), sizeof (truncated ));
1643+
1644+ int rightmost = -1 ;
1645+ for (int y = 21 ; y < 55 ; ++ y ) {
1646+ for (int x = 0 ; x < SEEDTOOL_DISPLAY_WIDTH ; ++ x ) {
1647+ if (truncated [y * SEEDTOOL_DISPLAY_WIDTH + x ] != 0x0000 && x > rightmost ) {
1648+ rightmost = x ;
1649+ }
1650+ }
1651+ }
1652+ if (rightmost < 0 ) {
1653+ return false; /* the row never reached the screen */
1654+ }
1655+ /* LIST_TEXT_X + LIST_TEXT_WIDTH in seedtool_render.c, kept in sync with
1656+ * the 6 + 222 below: the row's own column, which the ellipsis has to stay
1657+ * inside rather than run past into the scrollbar's strip. */
1658+ if (rightmost > 6 + 222 ) {
1659+ return false;
1660+ }
1661+
1662+ /* What a silent cut would have drawn. */
1663+ char prefix [64 ] = { 0 };
1664+ const size_t fit = seedtool_render_fit_row (row );
1665+ if (fit >= sizeof (prefix ) || fit >= strlen (row )) {
1666+ return false; /* the row was meant to be too long for one */
1667+ }
1668+ memcpy (prefix , row , fit );
1669+ const char * const cut [] = { prefix };
1670+ seedtool_render_nav_list (& nav , "m/84'/0'/0'/0" , cut , 1 , 0 );
1671+ return memcmp (truncated , seedtool_render_pixels (), sizeof (truncated )) != 0 ;
1672+ }
1673+
1674+ static bool grouped_paging_preserves_the_value (void )
1675+ {
1676+ static const char * const values [] = {
1677+ /* Both address shapes this firmware derives, plus lengths that land
1678+ * exactly on a group boundary and one past it. */
1679+ "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" ,
1680+ "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr" ,
1681+ "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2" ,
1682+ "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy" ,
1683+ "abcd" ,
1684+ "abcde" ,
1685+ "abcdefgh" ,
1686+ };
1687+ for (size_t v = 0 ; v < sizeof (values ) / sizeof (values [0 ]); ++ v ) {
1688+ const char * const value = values [v ];
1689+ const size_t total = strlen (value );
1690+ char rebuilt [128 ] = { 0 };
1691+ size_t used = 0 , offset = 0 , lines = 0 ;
1692+ while (offset < total && lines < 32 ) {
1693+ const size_t fit = seedtool_render_fit_grouped (value + offset , 48 );
1694+ if (!fit ) {
1695+ return false; /* no progress: the walk would never end */
1696+ }
1697+ /* A whole number of groups unless this is the value's own tail. */
1698+ if (offset + fit < total && fit % SEEDTOOL_GROUP_LEN ) {
1699+ return false;
1700+ }
1701+ if (used + fit >= sizeof (rebuilt )) {
1702+ return false;
1703+ }
1704+ memcpy (rebuilt + used , value + offset , fit );
1705+ used += fit ;
1706+ offset += fit ;
1707+ ++ lines ;
1708+ }
1709+ rebuilt [used ] = '\0' ;
1710+ if (offset != total || strcmp (rebuilt , value ) != 0 ) {
1711+ return false;
1712+ }
1713+ }
1714+ return true;
1715+ }
1716+
1717+ /* The grouped line is centred with its gaps counted in, so it can run off an
1718+ * edge in a way the plain centred line cannot. Measured, not assumed. */
1719+ static bool grouped_lines_clear_the_edges (void )
1720+ {
1721+ static const char * const lines [] = { "bc1qw508d6qejxtdg4y5r3zarvary0" , "bc1p5cyxnuxmeuwuvkwfem96lqzs" };
1722+ static const size_t first_group [] = { 0 , 7 , 14 };
1723+ for (size_t i = 0 ; i < sizeof (lines ) / sizeof (lines [0 ]); ++ i ) {
1724+ /* The widest a line can be: fit_grouped's own answer for this value. */
1725+ char line [64 ] = { 0 };
1726+ const size_t fit = seedtool_render_fit_grouped (lines [i ], 48 );
1727+ memcpy (line , lines [i ], fit );
1728+ const char * const shown [] = { line , line , line };
1729+ const seedtool_nav_t nav = { .selected = SEEDTOOL_NAV_BODY ,
1730+ .confirm = "Continue" ,
1731+ .confirm_enabled = true,
1732+ .back = true,
1733+ .counter = "1/2" };
1734+ seedtool_render_nav_grouped (& nav , "m/84'/0'/0'/0/0" , shown , first_group , 3 );
1735+ const uint16_t * const pixels = seedtool_render_pixels ();
1736+ for (int y = 30 ; y < 100 ; ++ y ) {
1737+ if (pixels [y * SEEDTOOL_DISPLAY_WIDTH ] != 0x0000
1738+ || pixels [y * SEEDTOOL_DISPLAY_WIDTH + SEEDTOOL_DISPLAY_WIDTH - 1 ] != 0x0000 ) {
1739+ return false;
1740+ }
1741+ }
1742+ }
1743+ return true;
1744+ }
1745+
16131746static bool nav_chrome_bands_do_not_collide (void )
16141747{
16151748 /* Twelve rows of the widest label review_and_confirm can build: two
@@ -2202,6 +2335,18 @@ static int self_test(void)
22022335 fputs ("Origo backup confirmation screen self-test failed\n" , stderr );
22032336 return 1 ;
22042337 }
2338+ if (!truncated_rows_show_an_ellipsis ()) {
2339+ fputs ("Origo truncated row self-test failed\n" , stderr );
2340+ return 1 ;
2341+ }
2342+ if (!grouped_paging_preserves_the_value ()) {
2343+ fputs ("Origo grouped address paging self-test failed\n" , stderr );
2344+ return 1 ;
2345+ }
2346+ if (!grouped_lines_clear_the_edges ()) {
2347+ fputs ("Origo grouped address geometry self-test failed\n" , stderr );
2348+ return 1 ;
2349+ }
22052350 if (!nav_chrome_bands_do_not_collide ()) {
22062351 fputs ("Origo nav chrome geometry self-test failed\n" , stderr );
22072352 return 1 ;
0 commit comments