From 07bcadfbe53501f3883f78d6fef2915e2060a1ec Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 4 Nov 2026 03:03:19 +0000 Subject: [PATCH] gui: Drop edit hints from card entry Recovery input gets no hint about what a wrong character should be, as in the CLI. Stop naming B, I, O and 1 with their look-alikes, stop telling the operator which header character to type, and stop marking the groups a suggested repair changed, so the whole card is compared. A header the library rejects reports the library's own message. Check a joining card with the field's header rule instead of a second copy of the backup and split checks. GUI: 1,986 -> 1,918 lines. Claude-Session: https://claude.ai/code/session_018HrKoywz6PT9c91jvKou7Q --- docs/developer/gui.md | 4 +- docs/user/gui.md | 9 ++--- src/codex32_gui/entry.py | 8 +--- src/codex32_gui/pages.py | 80 ++++++++++---------------------------- src/codex32_gui/reading.py | 37 ++---------------- src/codex32_gui/style.py | 3 -- tests/test_gui_reading.py | 33 ++-------------- tools/gui_walkthrough.py | 15 ++++--- 8 files changed, 43 insertions(+), 146 deletions(-) diff --git a/docs/developer/gui.md b/docs/developer/gui.md index b0b06927..5ce7619b 100644 --- a/docs/developer/gui.md +++ b/docs/developer/gui.md @@ -56,8 +56,8 @@ The entry widget has three internal modes: Enter activates the enabled primary action. Card displays use four four-character groups per row, matching `docs/user/recovery-card.html`. -Ordinary repair suggestions are unmarked; explicit correction may mark changed -groups, which are described as changes rather than known error locations. +Repair suggestions mark no changed group, so the whole card is compared. +Entry gives no hint about what a wrong character should be. Low-discrimination correction keeps the CLI's literal-`YES` gate. Its single warning covers both hazards: checksum completion can lock errors into new data, diff --git a/docs/user/gui.md b/docs/user/gui.md index 3fe3ce4a..a404721e 100644 --- a/docs/user/gui.md +++ b/docs/user/gui.md @@ -51,8 +51,8 @@ Bitcoin Core wallet. ## Entering cards -A card never contains **B**, **I**, **O**, or **1**. If one is entered, the GUI -reports the likely look-alike instead of silently deleting it. +A character no card can contain is not entered. The GUI does not say what it +should have been: read the card again. When recovering from several cards, use `?` for a header or card-index character you genuinely cannot read. **Check a card** requires the printed character. @@ -82,9 +82,8 @@ sees what is actually on the card. **Suggest a repair** appears whenever the input is within the supported correction bounds, including missing or extra characters. -A repair candidate may highlight groups that changed. Those are changes, not -proven error locations. Hold your card next to the screen and compare the entire -string, character by character. +A repair candidate marks no group as changed. Hold your card next to the screen +and compare the entire string, character by character. When too little checksum remains, the GUI requires literal `YES` before showing a candidate. Completing new hand-written data can lock earlier transcription diff --git a/src/codex32_gui/entry.py b/src/codex32_gui/entry.py index 5285eaa5..87b2d856 100644 --- a/src/codex32_gui/entry.py +++ b/src/codex32_gui/entry.py @@ -15,7 +15,6 @@ Reading, grouped, header_fault, - lookalike_fault, normalize, normalize_readback, read, @@ -49,7 +48,7 @@ def __init__( 0, ) self._limit = (length or TEXT_LENGTHS[-1]) + SLACK - self._dropped, self._rewriting, self._unpublishing = "", False, 0 + self._rewriting, self._unpublishing = False, 0 self._last_header_fault = "" self._stable = "" if mode == "readback" else PREFIX self.set_hexpand(True) @@ -77,13 +76,11 @@ def reading(self) -> Reading: state = read(normalize(self.get_text()), accepted=self._accepted, length=self._length) if fault := self._header_fault(): state = replace(state, artifact=None, message=fault, level="error") - if self._dropped and state.artifact is None and state.level != "error": - return replace(state, message=self._dropped, level="error") return state def clear(self) -> None: """Drop the entered recovery text.""" - self._dropped, self._last_header_fault = "", "" + self._last_header_fault = "" self._stable = "" if self._mode == "readback" else PREFIX self.set_text(self._stable) self.set_position(-1) @@ -134,7 +131,6 @@ def _reformat(self) -> bool: source = compact[len(PREFIX) :] if duplicated_prefix else raw canonical = normalizer(source)[: self._limit] shown = grouped(canonical) - self._dropped = "" if self._mode == "readback" else lookalike_fault(raw) fault = self._header_fault(canonical) if self._mode == "import" else "" if fault and self._last_header_fault and len(canonical) > len(normalize(self._stable)): self.error_bell() diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index 46219125..24432a63 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -6,7 +6,6 @@ from __future__ import annotations -import difflib import time from collections.abc import Callable, Sequence from dataclasses import dataclass @@ -178,12 +177,7 @@ def _actions(*buttons: Gtk.Widget) -> Gtk.Widget: return box -def _card( - text: str, - highlighted: frozenset[int] = frozenset(), - *, - highlight_class: str = "guessed", -) -> Gtk.FlowBox: +def _card(text: str, mismatched: frozenset[int] = frozenset()) -> Gtk.FlowBox: """Show one card the way wallets.md asks: uppercase, in four-character windows.""" text = text.upper() flow = Gtk.FlowBox(selection_mode=Gtk.SelectionMode.NONE, column_spacing=8, row_spacing=8) @@ -194,8 +188,8 @@ def _card( for start in range(0, len(text), reading.GROUP): label = Gtk.Label(label=text[start : start + reading.GROUP], halign=Gtk.Align.CENTER) label.add_css_class("card-group") - if start // reading.GROUP in highlighted: - label.add_css_class(highlight_class) + if start // reading.GROUP in mismatched: + label.add_css_class("mismatch") flow.append(label) return flow @@ -318,19 +312,6 @@ def _compare(expected: str, entered: str) -> ConfirmationResult: return ConfirmationResult(not mismatched, mismatched) -def _guessed(observed: str, corrected: str) -> frozenset[int]: - """Mark the four-character windows a correction changed, so they can be compared.""" - changed: set[int] = set() - matcher = difflib.SequenceMatcher(None, observed.lower(), corrected.lower(), autojunk=False) - for tag, _left, _right, start, end in matcher.get_opcodes(): - if tag == "equal": - continue - changed.update(range(start // reading.GROUP, (end + reading.GROUP - 1) // reading.GROUP)) - if start == end: - changed.add(min(start // reading.GROUP, (len(corrected) - 1) // reading.GROUP)) - return frozenset(changed) - - def _radio_group(group: Adw.PreferencesGroup, rows: Sequence[tuple[str, str]]) -> list[Gtk.CheckButton]: """Build one radio row per choice. Rows carry Bitcoin Core's text, so none of it is markup.""" first: Gtk.CheckButton | None = None @@ -509,7 +490,7 @@ def check() -> None: if not result.accepted: groups = frozenset(group - 1 for group in result.mismatched_groups) clear_comparison() - comparison.append(_card(state.text, groups, highlight_class="mismatch")) + comparison.append(_card(state.text, groups)) plural = "s" if len(groups) != 1 else "" message = f"Highlighted group{plural} {'do' if plural else 'does'} not match. Re-read the card." _say(status, message, "error") @@ -1017,28 +998,6 @@ def _outstanding(gathered: tuple[Artifact, ...], basis: bool, wanted: int | None return max(first.header.threshold - len(gathered), 0) -def _incompatible(artifact: Artifact | None, accepted: tuple[Artifact, ...], basis: bool) -> str: - if artifact is None: - return "" - if basis and artifact.header.threshold == 0: - return "This backup was never split into cards, so there is no card to replace." - if not accepted: - return "" - first = accepted[0] - if artifact.header.identifier != first.header.identifier: - return ( - f"This card belongs to backup {artifact.header.identifier.upper()}, " - f"not {first.header.identifier.upper()}." - ) - if artifact.header.threshold != first.header.threshold: - return "This card comes from a different split of that backup." - if len(artifact.text) != len(first.text): - return "This card is a different length from the first one." - if not basis and isinstance(artifact, Secret): - return "This is the whole backup rather than one of its cards." - return "" - - def _collect( view: Adw.NavigationView, *, @@ -1076,11 +1035,18 @@ def _collect( go = _button("Continue", lambda: proceed(), style="suggested-action") def refuse(artifact: Artifact | None) -> str: - """Say why this card cannot join the ones already entered, if it cannot.""" - if artifact is not None and artifact.header.index in blocked: - letter = artifact.header.index.upper() - return f"That would be card {letter}, which cannot be used here." - return _incompatible(artifact, accepted, basis) + """Say why this card cannot join the ones already entered, by the field's own header rule.""" + if artifact is None: + return "" + if basis and artifact.header.threshold == 0: + return "This backup was never split into cards, so there is no card to replace." + if fault := reading.header_fault(artifact.text.upper(), blocked, expected_header): + return fault + if first is not None and len(artifact.text) != len(first.text): + return "This card is a different length from the first one." + if first is not None and not basis and isinstance(artifact, Secret): + return "This is the whole backup rather than one of its cards." + return "" def update(*_arguments: object) -> None: state = field.reading() @@ -1136,7 +1102,7 @@ def deliver(result: object) -> None: return def following() -> Adw.NavigationPage: - return _repair_page(view, result, observed, lambda card: accept(card, True), page, correcting) + return _repair_page(view, result, lambda card: accept(card, True), page) gated = result.low_checksum_discrimination view.push(_guess_gate_page(view, following) if gated else following()) @@ -1204,21 +1170,17 @@ def _guess_gate_page( def _repair_page( view: Adw.NavigationView, candidate: CorrectionCandidate, - observed: str, accept: Accept, back: Adw.NavigationPage, - highlight_changes: bool, ) -> Adw.NavigationPage: - corrected = candidate.artifact.text - changed = _guessed(observed, corrected) if highlight_changes else frozenset() - shown = _card(corrected, changed) - comparison = "Highlighted groups show changes, not proven error locations. " if highlight_changes else "" + # Nothing marks what changed: the whole card is compared, not the parts a guess touched. + shown = _card(candidate.artifact.text) content = _column( _title("One possible repair", "It might not be the only one."), shown, _note( - f"Hold your card next to the screen and compare the entire string, character by character. " - f"{comparison}Accept it only if the entire string exactly matches your card." + "Hold your card next to the screen and compare the entire string, character by character. " + "Accept it only if the entire string exactly matches your card." ), ) page = _page( diff --git a/src/codex32_gui/reading.py b/src/codex32_gui/reading.py index 6a8503c8..b6864097 100644 --- a/src/codex32_gui/reading.py +++ b/src/codex32_gui/reading.py @@ -16,7 +16,7 @@ ) from codex32.bech32 import CHARSET from codex32.correction import _best -from codex32.errors import CodexError, InvalidShareIndex +from codex32.errors import CodexError from codex32.profiles.ms32 import TEXT_LENGTHS PREFIX = "MS1" @@ -25,7 +25,6 @@ GROUP = 4 SLACK = 8 CORRECTION_LENGTH_DELTAS = frozenset((0, 1, 2, 3, 4, 8)) -LOOKALIKE = {"B": "8", "I": "J or L", "O": "0, a zero", "1": "L"} _ASCII_UPPER = str.maketrans("abcdefghijklmnopqrstuvwxyz", "ABCDEFGHIJKLMNOPQRSTUVWXYZ") @@ -58,26 +57,6 @@ def normalize_readback(raw: str) -> str: return "".join(raw.split()).translate(_ASCII_UPPER) -def lookalike_fault(raw: str) -> str: - """Name a character no card can contain, rather than deleting it in silence. - - Every other character outside the alphabet is punctuation or a stray key, and - dropping it quietly is right. These four are not: bech32 leaves out B, I, O - and 1 precisely because handwriting confuses them with 8, J, L and 0, so they - are exactly what someone misreads from their own card. Swallowing them would - turn the read-back, whose whole purpose is to catch a transcription error, - into the step that hides one. - """ - typed = "".join(raw.split()).upper() - keep = max(size for size in range(len(PREFIX) + 1) if typed[:size] == PREFIX[:size]) - found = sorted({character for character in typed[keep:] if character in LOOKALIKE}) - if not found: - return "" - named = found[0] if len(found) == 1 else ", ".join(found[:-1]) + " or " + found[-1] - hints = ", ".join(f"{character} is probably {LOOKALIKE[character]}" for character in found) - return f"A card never contains {named}. Look at the card again: {hints}." - - def grouped(text: str) -> str: """Return the text in four-character windows.""" return " ".join(text[start : start + GROUP] for start in range(0, len(text), GROUP)) @@ -93,7 +72,7 @@ def header_fault( if not body or body[0] == "?": return "" if body[0] not in "023456789": - return "The character after MS1 is how many cards recovery needs: 0, or 2 through 9." + return "No card begins like this. Read your card again." if expected_header is not None: if int(body[0]) != expected_header[0]: return "This card comes from a different split of that backup." @@ -104,11 +83,6 @@ def header_fault( return "" try: Header(int(body[0]), body[1:5], body[5]) - except InvalidShareIndex: - return ( - "A backup that was never split is card S. Change the last letter to S, or change the " - "first character to how many cards recovery should need." - ) except CodexError as error: return str(error) if body[5].lower() in accepted: @@ -152,11 +126,8 @@ def read(text: str, *, accepted: tuple[str, ...] = (), length: int | None = None def readback(raw: str, length: int) -> Reading: """Describe an independently re-entered card without interpreting or repairing it.""" text = normalize_readback(raw) - unreadable = text.count("?") - fault = lookalike_fault(text) - level = "error" if fault else "" - message = fault or f"{len(text)} of {length} characters" - return Reading(text, None, message, level, length, unreadable, False) + message = f"{len(text)} of {length} characters" + return Reading(text, None, message, "", length, text.count("?"), False) def repair(text: str, length: int | None, excluded: tuple[str, ...] = ()) -> CorrectionCandidate | str: diff --git a/src/codex32_gui/style.py b/src/codex32_gui/style.py index 5ea3d7cb..2e6218a8 100644 --- a/src/codex32_gui/style.py +++ b/src/codex32_gui/style.py @@ -19,9 +19,6 @@ border-radius: 6px; background-color: alpha(currentColor, 0.09); } -.card-group.guessed { - background-color: alpha(@warning_color, 0.35); -} .card-group.mismatch { background-color: alpha(@error_color, 0.35); } diff --git a/tests/test_gui_reading.py b/tests/test_gui_reading.py index b91d37cf..566b093b 100644 --- a/tests/test_gui_reading.py +++ b/tests/test_gui_reading.py @@ -8,7 +8,6 @@ expected_length, grouped, header_fault, - lookalike_fault, normalize, normalize_readback, read, @@ -53,9 +52,9 @@ def test_readback_supplies_and_drops_nothing() -> None: def test_a_header_that_cannot_belong_to_any_card_is_reported() -> None: - assert "0, or 2 through 9" in header_fault("MS1X") - assert "0, or 2 through 9" in header_fault("MS1XNAMEA") - assert "never split is card S" in header_fault("MS10NAMEA") + assert "Read your card again" in header_fault("MS1X") + assert "Read your card again" in header_fault("MS11NAMEA") + assert "must use S" in header_fault("MS10NAMEA") assert header_fault("MS12NAMEA") == "" assert header_fault("MS10NAMES") == "" @@ -154,29 +153,3 @@ def test_a_card_with_too_little_checksum_left_demands_the_warning(card: str, che found = repair(card[:-checksum_length] + "?" * checksum_length, None) assert isinstance(found, CorrectionCandidate) assert found.low_checksum_discrimination - - -@pytest.mark.parametrize( - ("typed", "expected"), - [("B", "8"), ("I", "J or L"), ("O", "0, a zero"), ("1", "L")], -) -def test_a_character_no_card_can_carry_is_named_with_what_it_is_probably(typed: str, expected: str) -> None: - fault = lookalike_fault(f"MS12NAMEC{typed}") - assert typed in fault - assert expected in fault - - -def test_the_prefix_keeps_its_own_one() -> None: - assert lookalike_fault("MS1") == "" - assert lookalike_fault("ms12namec") == "" - - -def test_an_ordinary_stray_key_is_still_dropped_in_silence() -> None: - assert lookalike_fault("MS12NAMEC!,. ") == "" - - -def test_several_confusable_characters_are_all_named() -> None: - fault = lookalike_fault("MS12NAMEBO") - assert "B or O" in fault - assert "B is probably 8" in fault - assert "O is probably 0" in fault diff --git a/tools/gui_walkthrough.py b/tools/gui_walkthrough.py index a9bc3b6b..82ed0278 100644 --- a/tools/gui_walkthrough.py +++ b/tools/gui_walkthrough.py @@ -234,17 +234,16 @@ def typing(self) -> bool: field.set_text("MS12NAMECB") settle() check( - "a character no card can carry is named, not silently dropped", - any("never contains B" in text for text in labels(page)), - [text for text in labels(page) if "contains" in text], + "a character no card can carry is dropped without a hint", + "B" not in field.get_text(), + field.get_text(), ) - check("and it is gone from the field", "B" not in field.get_text(), field.get_text()) field.set_text("MS10NAMEA") settle() check("an impossible header stops the rest", field.get_text() == "MS10 NAME A", field.get_text()) check( "and says why", - any("never split is card S" in text for text in labels(page)), + any("must use S" in text for text in labels(page)), [text for text in labels(page) if "S" in text], ) field.set_text(SHARE_C) @@ -301,7 +300,7 @@ def four_columns(rows_y: list[float]) -> bool: check("Enter invokes repair for short input", "".join(item.get_label() for item in groups) == SHARE_C) check( "ordinary repair does not claim where the error was", - not any("guessed" in item.get_css_classes() for item in groups), + not any("mismatch" in item.get_css_classes() for item in groups), ) check( "card display uses four aligned groups per row", @@ -371,8 +370,8 @@ def candidate(self) -> bool: "".join(item.get_label() for item in groups) == SHARE_C, "".join(item.get_label() for item in groups), ) - guessed = [item.get_label() for item in groups if "guessed" in item.get_css_classes()] - check("explicit correction may highlight changed windows", guessed == ["Y6PN"], guessed) + marked = [item.get_label() for item in groups if "mismatch" in item.get_css_classes()] + check("explicit correction marks no group, so the whole card is compared", marked == [], marked) press(page, "It does not match my card") return True