From 51bbee9eb145e59d3c7bf3037d82d3e99ba9a91c Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Mon, 28 Apr 2025 12:56:03 +0200 Subject: [PATCH] bugfix: Mk4: fix extended keys not fully visible in stories --- releases/Next-ChangeLog.md | 1 + shared/utils.py | 55 +++++++++++++++++++++----------------- shared/ux.py | 7 ++--- testing/test_unit.py | 53 +++++++++++++++++++++++++++--------- 4 files changed, 76 insertions(+), 40 deletions(-) diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md index 75a22b99..f038e875 100644 --- a/releases/Next-ChangeLog.md +++ b/releases/Next-ChangeLog.md @@ -16,6 +16,7 @@ This lists the new changes that have not yet been published in a normal release. in export loop and needs reboot to escape. - Bugfix: PUSHDATA2 in bitcoin script caused yikes. - Bugfix: Warning for unknown scripts was not shown at the top of the signing story. +- Bugfix: Part of extended keys in stories were not visible # Q Specific Changes diff --git a/shared/utils.py b/shared/utils.py index f1096887..25e6ddee 100644 --- a/shared/utils.py +++ b/shared/utils.py @@ -460,28 +460,38 @@ def call_later_ms(delay, cb, *args, **kws): uasyncio.create_task(doit()) -def txtlen(s): - # width of string in chars, accounting for - # double-wide characters which happen on Q. - rv = len(s) - - if DOUBLE_WIDE: - rv += sum(1 for ch in s if ch in DOUBLE_WIDE) - - return rv def word_wrap(ln, w): # Generate the lines needed to wrap one line into X "width"-long lines. # - tests in testing/test_unit.py + while True: + # ln_len considers DOUBLE_WIDTH chars + ln_len = 0 + idx = 0 + sp = None + for idx, ch in enumerate(ln): + if ch == ' ': + # split point on space if possible + sp = idx + if ln_len < w: + ln_len += 1 + if ch in DOUBLE_WIDE: + ln_len += 1 + else: + if (ln_len == w) and (ch in ".,:;"): + # boundary of allowed width + # if . or , allow one more character + # even if only half visible on Mk4 + # on Q it's OK as (CHARS_W-1) is used as w + sp = None + idx += 1 - if txtlen(ln) <= w: - yield ln - return + break + else: + yield ln + return - while ln: - # find a space in (width) first part of remainder - sp = ln.rfind(' ', 0, w-1) - if sp == -1: + if sp is None: if ln[0] == OUT_CTRL_ADDRESS: # special handling for lines w/ payment address in them # - add same marker to newly split lines @@ -497,8 +507,7 @@ def word_wrap(ln, w): return # bad-break the line - sp = min(txtlen(ln), w) - nsp = sp + sp = nsp = idx if ln[nsp:nsp+1] == ' ': nsp += 1 else: @@ -506,14 +515,10 @@ def word_wrap(ln, w): nsp = sp+1 left = ln[0:sp] - ln = ln[nsp:] - - if txtlen(left) + 1 + txtlen(ln) <= w: - # not clear when this would happen? final bit?? - left = left + ' ' + ln - ln = '' - yield left + ln = ln[nsp:] + if not ln: return + def parse_extended_key(ln, private=False): # read an xpub/ypub/etc and return BIP-32 node and what chain it's on. diff --git a/shared/ux.py b/shared/ux.py index c1e2eeb2..63da527b 100644 --- a/shared/ux.py +++ b/shared/ux.py @@ -17,7 +17,8 @@ DEFAULT_IDLE_TIMEOUT = const(4*3600) # (seconds) 4 hours # See ux_mk or ux_q1 for some display functions now if version.has_qwerty: from lcd_display import CHARS_W, CHARS_H - CH_PER_W = CHARS_W + # stories look nicer if we do not use the whole width + CH_PER_W = (CHARS_W - 1) STORY_H = CHARS_H from ux_q1 import PressRelease, ux_enter_number, ux_input_text, ux_show_pin from ux_q1 import ux_login_countdown, ux_dice_rolling, ux_render_words @@ -28,9 +29,9 @@ if version.has_qwerty: else: # How many characters can we fit on each line? How many lines? # (using FontSmall) .. except it's an approximation since variable-width font. - # - even 19 could work sometimes, but not when line is completely full + # - 18 can work but rightmost spot is half-width. We allow . and , in that spot. # - really should look at rendered-width of text - CH_PER_W = 19 + CH_PER_W = 17 STORY_H = 5 from ux_mk4 import PressRelease, ux_enter_number, ux_input_text, ux_show_pin from ux_mk4 import ux_login_countdown, ux_dice_rolling, ux_render_words diff --git a/testing/test_unit.py b/testing/test_unit.py index 0cabea4a..49ca06d3 100644 --- a/testing/test_unit.py +++ b/testing/test_unit.py @@ -5,6 +5,8 @@ import pytest, os, shutil from helpers import B2A +from constants import AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH, AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH +from charcodes import * def test_remote_exec(sim_exec): @@ -274,28 +276,55 @@ def test_is_dir(microsd_path, sim_exec): assert rv == "False" shutil.rmtree(microsd_path("my_dir")) -@pytest.mark.parametrize('txt, x_line2', [ - ('Disk, press \x0e to share via NFC, \x11 to share', '\x11 to share'), -]) -def test_word_wrap(txt, x_line2, sim_exec, only_q1, width=34): - # one tricky double-wide char word-wrapping case .. but add others - assert '\n' not in txt +DOUBLE_W = ['⋯', '✔', '✓', '→', '←', '↦', '◉', '◯', '◌', '※', '—', '\x0e', '\x11', '\t', '\x0f', '\x12', '\x13', '\x14', '\x16', '\x17'] +@pytest.mark.parametrize('txt, target', [ + ('Disk, press \x0e to share via NFC, \x11 to share', ['Disk, press \x0e to share via NFC,', '\x11 to share']), + ((KEY_NFC * 17)+".", [KEY_NFC * 17, '.']), + ((KEY_NFC * 17)+(17*KEY_QR), [KEY_NFC * 17, KEY_QR * 17]), + ((KEY_NFC * 17)+" "+(17*KEY_QR), [KEY_NFC * 17, KEY_QR * 17]), + ((KEY_NFC * 16)+".", [(KEY_NFC * 16)+'.']), + (f"Use {KEY_NFC}, or {KEY_F1}, {KEY_F2}, {KEY_F3}, or or or {KEY_F4}", [f"Use {KEY_NFC}, or {KEY_F1}, {KEY_F2}, {KEY_F3}, or or or {KEY_F4}"]), + ("".join(DOUBLE_W), ["".join(DOUBLE_W[:17]), "".join(DOUBLE_W[17:])]), + ("".join(6*DOUBLE_W), ["".join(6*DOUBLE_W)[i:i + 17] for i in range(0, len(6*DOUBLE_W), 17)]), +]) +def test_word_wrap_double_wide(only_q1, txt, target, sim_exec): + width = 33 # check shared/ux.py CHAR_PER_W cmd = f'from utils import word_wrap; RV.write("\\n".join(word_wrap({txt!r}, {width})))' got = sim_exec(cmd) assert 'Traceback' not in got lines = got.split('\n') - assert width*2//3 <= len(lines[0]) <= width - assert lines[1] == x_line2 + assert lines == target - want_words = [i.strip() for i in txt.split()] - got_words = [i.strip() for i in got.split()] +@pytest.mark.parametrize('txt, target, width', [ + ((17*'a')+". ccc", [(17*'a')+".", "ccc"], 17), + ((17*'a')+".", [(17*'a')+"."], 17), + ((17*'-')+". ccc", [(17*'-')+".", "ccc"], 17), + ((34 * 'A'), [33 * "A", "A"], 33), + ((33 * 'A')+". ccc", [(33 * "A")+".", "ccc"], 33), + ('Coldcard is ready to sign spending transactions!', ['Coldcard is ready to sign', 'spending transactions!'], 33), + ('Coldcard is ready to sign spending transactions!', ['Coldcard is ready', 'to sign spending', 'transactions!'], 17), + ((16*"B")+ " AAAA", [16*"B", "AAAA"], 17), + ((16*"B")+ " AAAA", [(16*"B")+" ", "AAAA"], 17), + ((17*"B")+ " AAAA", [17*"B", "AAAA"], 17), + ((17*"B")+ " AAAA", [17*"B", " AAAA"], 17), + ("(recommended), or by typing numbers.", ["(recommended), or", "by typing numbers."], 17), + ("difficult to recover your funds.", ["difficult to", "recover your", "funds."], 17), + ("USB Serial Number:", ["USB Serial Number:"], 17), + ("USB Serial Number;", ["USB Serial Number;"], 17), + ("USB Serial Number/", ["USB Serial", "Number/"], 17), +]) +def test_word_wrap(txt, target, width, sim_exec): + cmd = f'from utils import word_wrap; RV.write("\\n".join(word_wrap({txt!r}, {width})))' + got = sim_exec(cmd) + assert 'Traceback' not in got - assert want_words == got_words + lines = got.split('\n') + + assert lines == target -from constants import AF_P2WSH, AF_P2SH, AF_P2WSH_P2SH, AF_CLASSIC, AF_P2WPKH, AF_P2WPKH_P2SH @pytest.mark.parametrize('addr,net,fmt', [ ( 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', 'BTC', AF_P2WPKH ),