Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/developer/gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 4 additions & 5 deletions docs/user/gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
8 changes: 2 additions & 6 deletions src/codex32_gui/entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
Reading,
grouped,
header_fault,
lookalike_fault,
normalize,
normalize_readback,
read,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
80 changes: 21 additions & 59 deletions src/codex32_gui/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

from __future__ import annotations

import difflib
import time
from collections.abc import Callable, Sequence
from dataclasses import dataclass
Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
*,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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(
Expand Down
37 changes: 4 additions & 33 deletions src/codex32_gui/reading.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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")


Expand Down Expand Up @@ -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))
Expand All @@ -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."
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 0 additions & 3 deletions src/codex32_gui/style.py
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
33 changes: 3 additions & 30 deletions tests/test_gui_reading.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
expected_length,
grouped,
header_fault,
lookalike_fault,
normalize,
normalize_readback,
read,
Expand Down Expand Up @@ -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") == ""

Expand Down Expand Up @@ -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
Loading
Loading