From fcb1a016b18ba57f25ee23b6ab85f046a5aa4285 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Thu, 22 Oct 2026 10:35:04 -0500 Subject: [PATCH 1/2] gui: Share read-back rules and navigate groups Use the library alignment shared with the CLI instead of maintaining a second alignment algorithm. Keep matching groups locked and let arrows, Ctrl+arrows, Tab, Home and End navigate the remaining boxes. Reuse read-back entry normalization and its clipboard and paste controls for each box. Compare invalid glyphs and missing groups directly, without the former eight-character tolerance. Preserve oversized input and report the comparison limit explicitly. Clear retained correction widgets synchronously, and leave boundary-arrow behavior to GTK when no adjacent editable group exists. Validation: GTK regression tests and the graphical walkthrough; library/CLI confirmation tests, Ruff and mypy. An independent code review found two issues, both fixed with regressions. refs #109 --- docs/developer/gui.md | 13 +- docs/user/gui.md | 6 +- src/codex32_gui/entry.py | 110 ++++++++++--- src/codex32_gui/pages.py | 126 +++++++++------ tests/test_gui_read_back.py | 300 ++++++++++++++++++++++++++++++++++++ tools/gui_walkthrough.py | 14 +- 6 files changed, 490 insertions(+), 79 deletions(-) create mode 100644 tests/test_gui_read_back.py diff --git a/docs/developer/gui.md b/docs/developer/gui.md index 06cc7f26..674d79a6 100644 --- a/docs/developer/gui.md +++ b/docs/developer/gui.md @@ -20,7 +20,8 @@ cryptography, entropy source, socket, or file storage. Review `reading.py`, `wallet_setup.py`, and `work.py` first. Their behavior is covered without a display. `tools/gui_walkthrough.py` exercises the real GTK screens under Xvfb. `tests/test_gui_boundaries.py` enforces a separate 2,250 -logical-line GUI budget. +logical-line GUI budget, including the Bails restore-identity checks and fixed +read-back boxes. The library retains its existing budget. ## Security boundaries @@ -54,6 +55,16 @@ The entry widget has three internal modes: - **Read-back:** starts empty, normalizes only spacing and ASCII case, and never reveals expected text after a mismatch. +Read-back uses `codex32._confirmation._entered_groups`, shared with the CLI. +After a mismatch, matching groups lock; only the entered text is shown. Arrows +cross editable-group boundaries, Ctrl+arrows skip groups, Tab cycles, and +Home/End reach the first/last editable group. Paste and drop are disabled. +Invalid characters and missing groups are compared directly. The former +eight-character tolerance is gone; comparison accepts up to 1,024 characters +and preserves longer input with an explicit size message. +`tests/test_gui_read_back.py` exercises these behaviors under GTK. Review +`entry.py` for keyboard and clipboard handling. + 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 diff --git a/docs/user/gui.md b/docs/user/gui.md index c349ede5..7be0996f 100644 --- a/docs/user/gui.md +++ b/docs/user/gui.md @@ -63,8 +63,10 @@ The recommended layout is three cards where any two recover the wallet. One card can be lost, and one card alone reveals nothing. Copy each card to paper, hide the on-screen original, then type the paper copy -back. Read-back starts completely empty, including `MS1`. A mismatch highlights -only the groups you typed differently; the expected text stays hidden. +back. Read-back starts empty, including `MS1`, and does not accept paste. After +a mismatch, correct the highlighted boxes from your paper card; matching groups +are locked. Arrows, Ctrl+arrows, Tab, Home and End move between editable boxes. +The expected text stays hidden. Press Enter to confirm. After all cards are confirmed, write the displayed master fingerprint on your wallet record, then re-enter it in the GUI. Wallet selection stays unavailable diff --git a/src/codex32_gui/entry.py b/src/codex32_gui/entry.py index 5285eaa5..44e5fbff 100644 --- a/src/codex32_gui/entry.py +++ b/src/codex32_gui/entry.py @@ -5,7 +5,7 @@ from dataclasses import replace from typing import Literal, cast -from gi.repository import GLib, Gtk +from gi.repository import Gdk, GLib, Gtk from codex32.profiles.ms32 import TEXT_LENGTHS from codex32_gui.reading import ( @@ -25,7 +25,34 @@ EntryMode = Literal["import", "correct", "readback"] -class Codex32Entry(Gtk.Entry): +class PrivateEntry(Gtk.Entry): + """An entry that releases its primary selection after GTK publishes it.""" + + def __init__(self, **properties: object) -> None: + super().__init__(**properties) + self._watching = 0 + self.connect("realize", lambda _entry: self._watch(True)) + self.connect("unrealize", lambda _entry: self._watch(False)) + + def _watch(self, start: bool) -> None: + primary = self.get_primary_clipboard() + if start: + self._watching = primary.connect("changed", self._unpublish) + elif self._watching: + primary.disconnect(self._watching) + self._watching = 0 + + def _unpublish(self, primary: Gdk.Clipboard) -> None: + if primary.is_local() and primary.get_content() is not None and self.get_selection_bounds(): + GLib.idle_add(self._drop, primary) + + @staticmethod + def _drop(primary: Gdk.Clipboard) -> bool: + primary.set_content(None) + return False + + +class Codex32Entry(PrivateEntry): """One grouped field with explicit import, correction, and read-back behavior.""" __gtype_name__ = "Codex32Entry" @@ -49,7 +76,7 @@ def __init__( 0, ) self._limit = (length or TEXT_LENGTHS[-1]) + SLACK - self._dropped, self._rewriting, self._unpublishing = "", False, 0 + self._dropped, self._rewriting = "", False self._last_header_fault = "" self._stable = "" if mode == "readback" else PREFIX self.set_hexpand(True) @@ -58,8 +85,8 @@ def __init__( self.set_text(self._stable) self.set_position(-1) self.connect("changed", self._schedule) - self.connect("notify::selection-bound", self._unpublish) - self.connect("notify::cursor-position", self._unpublish) + if mode == "readback": + refuse_paste(self) GLib.idle_add(self.set_position, -1) def prefill(self, text: str) -> None: @@ -98,21 +125,6 @@ def _header_fault(self, raw: str | None = None) -> str: return "? cannot be used in this header. Type the character printed on the card." return header_fault(text, self._accepted, self._expected_header) - def _unpublish(self, *_arguments: object) -> None: - # Selecting text in a Gtk.Entry hands it to the primary selection, where a - # clipboard manager would copy the card into a history file on disk. The - # selection still works; only the copy that leaves the program is taken - # back, once GTK has finished publishing it. - if self.get_selection_bounds() and not self._unpublishing: - self._unpublishing = GLib.idle_add(self._drop_primary) - - def _drop_primary(self) -> bool: - self._unpublishing = 0 - display = self.get_display() - if display is not None: - display.get_primary_clipboard().set_content(None) - return False - def _schedule(self, _entry: Gtk.Entry) -> None: # One edit reaches the buffer as a deletion and then an insertion. Reformat # once the whole edit has settled, so no intermediate state is rewritten, @@ -132,7 +144,9 @@ def _reformat(self) -> bool: return False duplicated_prefix = self._mode == "import" and compact.startswith(PREFIX + PREFIX) source = compact[len(PREFIX) :] if duplicated_prefix else raw - canonical = normalizer(source)[: self._limit] + canonical = normalizer(source) + if self._mode != "readback": + canonical = canonical[: 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 "" @@ -162,3 +176,57 @@ def _rewrite(self, shown: str, position: int) -> None: self.set_text(shown) self._rewriting = False self.set_position(position) + + +class GroupEntry(Codex32Entry): + """Only the operator's transcription of one mismatched group.""" + + def __init__(self, text: str) -> None: + super().__init__(length=GROUP, mode="readback") + self.set_width_chars(5) + self.set_max_width_chars(5) + self.set_hexpand(False) + self.set_text(text) + + +def refuse_paste(entry: Gtk.Entry) -> None: + """Require read-back from paper, including in correction boxes.""" + text = entry.get_delegate() + text.connect("paste-clipboard", lambda text: text.stop_emission_by_name("paste-clipboard")) + middle = Gtk.GestureClick(button=2, propagation_phase=Gtk.PropagationPhase.CAPTURE) + middle.connect("pressed", lambda gesture, *_: gesture.set_state(Gtk.EventSequenceState.CLAIMED)) + entry.add_controller(middle) + controllers = text.observe_controllers() + for controller in [controllers.get_item(i) for i in range(controllers.get_n_items())]: + if isinstance(controller, Gtk.DropTarget): + text.remove_controller(controller) + + +def navigate_groups(entries: list[GroupEntry]) -> None: + """Navigate editable groups while leaving character movement to GTK.""" + + def key(_controller: Gtk.EventControllerKey, value: int, _code: int, state: Gdk.ModifierType) -> bool: + current = entries.index(_controller.get_widget()) + entry = entries[current] + backwards = value in (Gdk.KEY_Left, Gdk.KEY_KP_Left, Gdk.KEY_ISO_Left_Tab) + tab = value in (Gdk.KEY_Tab, Gdk.KEY_ISO_Left_Tab) + edge = entry.get_position() == (0 if backwards else len(entry.get_text())) + if value in (Gdk.KEY_Home, Gdk.KEY_End): + target = 0 if value == Gdk.KEY_Home else len(entries) - 1 + elif tab or value in (Gdk.KEY_Left, Gdk.KEY_Right, Gdk.KEY_KP_Left, Gdk.KEY_KP_Right): + if not tab and not edge and not state & Gdk.ModifierType.CONTROL_MASK: + return False + target = current + (-1 if backwards or tab and state & Gdk.ModifierType.SHIFT_MASK else 1) + target = target % len(entries) if tab else min(max(target, 0), len(entries) - 1) + if not tab and target == current: + return False + else: + return False + entries[target].grab_focus() + entries[target].set_position(-1 if backwards or value == Gdk.KEY_End else 0) + return True + + for entry in entries: + controller = Gtk.EventControllerKey(propagation_phase=Gtk.PropagationPhase.CAPTURE) + controller.connect("key-pressed", key) + entry.add_controller(controller) diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index ab4d9159..8a12775a 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -26,10 +26,11 @@ generate_master_seed, recover_secret, ) +from codex32._confirmation import _compact, _entered_groups from codex32.errors import CodexError from codex32.generation import ORDINARY_INDICES from codex32_gui import ARTWORK, reading, wallet_setup, work -from codex32_gui.entry import Codex32Entry, EntryMode +from codex32_gui.entry import Codex32Entry, EntryMode, GroupEntry, navigate_groups from codex32_gui.wallet_setup import BitcoinCore Artifact = Share | Secret @@ -328,7 +329,7 @@ def _describe(artifact: Artifact) -> str: def _compare(expected: str, entered: str) -> ConfirmationResult: """Report which four-character groups do not match, by the library's own rule.""" - observed, wanted = "".join(entered.split()).lower(), expected.lower() + observed, wanted = _compact(entered), expected.lower() span = (max(len(observed), len(wanted)) + reading.GROUP - 1) // reading.GROUP mismatched = tuple( group + 1 @@ -504,68 +505,91 @@ def _read_back_page( confirm: Callable[[str], ConfirmationResult], after: Callable[[], None], ) -> Adw.NavigationPage: - """Read the card back from the paper, with the original off the screen.""" + """Read from paper; compare with the CLI's library alignment.""" field = Codex32Entry(length=len(card.text), mode="readback") - status = _note("") - comparison = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - accept = _button("Confirm card", lambda: None, style="suggested-action") + boxes: list[Gtk.Label | GroupEntry] = [] + status, comparison = _note(""), Adw.Bin() + accept = _button("Confirm card", lambda: check(), style="suggested-action") + + def clear_boxes() -> None: + previous = boxes.copy() + boxes.clear() + comparison.set_child(None) + field.set_visible(True) + for box in previous: + box.clear() if isinstance(box, GroupEntry) else box.set_text("") - def clear_comparison() -> None: - while (child := comparison.get_first_child()) is not None: - comparison.remove(child) + def entered() -> str: + return "".join(box.get_text() for box in boxes) if boxes else field.get_text() def update(*_arguments: object) -> None: - clear_comparison() - state = field.reading() - accept.set_sensitive(state.complete) - _say(status, state.message, state.level) + if field._rewriting: + return + if not field.get_text(): + clear_boxes() + text = reading.normalize_readback(entered()) + accept.set_sensitive(0 < len(text) <= 1024) + message = f"{len(text)} of {len(card.text)} characters" + _say(status, "Compare at most 1,024 characters at a time." if len(text) > 1024 else message) def check() -> None: - try: - state = field.reading() - result = confirm(state.text) - except CodexError as error: - _failure(view, error, CARDS_SAFE) - return - 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")) - 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") - return - field.clear() - after() + text = reading.normalize_readback(entered()) + if text == card.text.upper(): + try: + result = confirm(text) + except CodexError as error: + _failure(view, error, CARDS_SAFE) + return + if result.accepted: + field.clear() + after() + return + if boxes: + pieces = [reading.normalize_readback(box.get_text()) for box in boxes] + changed = { + index + for index, piece in enumerate(pieces) + if piece != card.text[index * 4 : index * 4 + 4].upper() + } + else: + pieces, changed = _entered_groups(text, card.text) + clear_boxes() + grid = Gtk.Grid(column_spacing=8, row_spacing=8, halign=Gtk.Align.CENTER) + for index, piece in enumerate(pieces): + piece = reading.normalize_readback(piece) + box = GroupEntry(piece) if index in changed else Gtk.Label(label=piece) + box.add_css_class("card-group") + if isinstance(box, GroupEntry): + box.add_css_class("mismatch") + box.connect("changed", update) + box.connect("activate", lambda _box: check() if accept.get_sensitive() else None) + boxes.append(box) + grid.attach(box, index % 4, index // 4, 1, 1) + editable = [box for box in boxes if isinstance(box, GroupEntry)] + navigate_groups(editable) + comparison.set_child(grid) + field.set_visible(False) + if editable: + editable[0].grab_focus() + _say(status, "Re-read the highlighted groups from your card.", "error") - accept.connect("clicked", lambda _button: check()) field.connect("activate", lambda _entry: check() if accept.get_sensitive() else None) field.connect("changed", update) - content = _column( - _title("Now type it back from the card", _counted(card.header.index.upper(), position, count)), - _note( - "The original is no longer on screen. Read from the card you just wrote, so a slip of the " - "pen is caught now rather than years from now." - ), - field, - status, - comparison, - _note( - "Spaces and capitals do not matter. After a mismatch, only what you typed is shown; the " - "original stays hidden. Re-read the highlighted groups from your card and try again." - ), - ) page = _page( _counted(card.header.index.upper(), position, count), - content, + _column( + _title("Now type it back from the card", _counted(card.header.index.upper(), position, count)), + _note("Read every character from your paper card, including MS1. Pasting is turned off."), + field, + status, + comparison, + _note( + "Only highlighted groups can be changed. Use arrows, Ctrl+arrows, Tab, Home or End to move." + ), + ), actions=_actions(_button("Show the card again", view.pop), accept), ) - - def clear_page() -> None: - field.clear() - clear_comparison() - - _forget_when_gone(view, page, clear_page) + _forget_when_gone(view, page, field.clear) update() return page diff --git a/tests/test_gui_read_back.py b/tests/test_gui_read_back.py new file mode 100644 index 00000000..f463bf61 --- /dev/null +++ b/tests/test_gui_read_back.py @@ -0,0 +1,300 @@ +"""The card read-back page: Enter submits, and a mismatch opens only the wrong groups.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +gi = pytest.importorskip("gi") +try: + gi.require_version("Gtk", "4.0") + gi.require_version("Adw", "1") + gi.require_version("Gdk", "4.0") + from gi.repository import Adw, Gdk, GLib, Gtk +except (ImportError, ValueError): + pytest.skip("GTK 4 and libadwaita are unavailable", allow_module_level=True) +if not Gtk.init_check() or Gdk.Display.get_default() is None: + pytest.skip("no display for GTK", allow_module_level=True) +Adw.init() + +from data.bip93_vectors import VECTOR_2 + +from codex32 import parse_codex32 +from codex32.generation import ConfirmationResult +from codex32_gui import pages, reading +from codex32_gui.entry import GroupEntry + +SHARE = VECTOR_2["share_A"] + + +def _widgets(root: Gtk.Widget, kind: type) -> Iterator[Gtk.Widget]: + child = root.get_first_child() + while child is not None: + if isinstance(child, kind): + yield child + yield from _widgets(child, kind) + child = child.get_next_sibling() + + +def _read_back( + result: ConfirmationResult | None = None, +) -> tuple[Gtk.Widget, pages.Codex32Entry, list[str], list[bool]]: + typed: list[str] = [] + finished: list[bool] = [] + + def confirm(text: str) -> ConfirmationResult: + typed.append(text) + return result or pages._compare(SHARE, text) + + view = Adw.NavigationView() + page = pages._read_back_page(view, parse_codex32(SHARE), 1, 3, confirm, lambda: finished.append(True)) + view.push(page) + return page, next(_widgets(page, pages.Codex32Entry)), typed, finished + + +def _settle() -> None: + while GLib.MainContext.default().iteration(False): + pass + + +def _boxes(page: Gtk.Widget) -> list[Gtk.Widget]: + [grid] = list(_widgets(page, Gtk.Grid)) + return [box for box in _widgets(grid, Gtk.Widget) if isinstance(box, Gtk.Label | GroupEntry)] + + +def _submit(text: str) -> tuple[Gtk.Widget, pages.Codex32Entry, list[Gtk.Widget], list[bool]]: + page, field, _typed, finished = _read_back() + field.set_text(text) + _settle() + field.emit("activate") + return page, field, _boxes(page), finished + + +def _groups(text: str) -> list[str]: + return [text[start : start + reading.GROUP] for start in range(0, len(text), reading.GROUP)] + + +CARD = _groups(SHARE.upper()) + + +def test_readback_starts_empty_and_enter_confirms_the_whole_card() -> None: + _page, field, typed, finished = _read_back(ConfirmationResult(True)) + + assert field.get_text() == "" + field.set_text(SHARE) + field.emit("activate") + assert typed == [SHARE.upper()] and finished == [True] + + +def test_invalid_characters_are_compared_and_kept_in_their_group() -> None: + wrong = CARD.copy() + wrong[7] = "B1!O" + _page, _field, boxes, finished = _submit("".join(wrong)) + assert not finished + assert isinstance(boxes[7], GroupEntry) + assert boxes[7].get_text() == "B1!O" + assert [index for index, box in enumerate(boxes) if isinstance(box, GroupEntry)] == [7] + + +def test_a_mismatch_locks_the_right_groups_and_opens_only_the_wrong_ones() -> None: + wrong = CARD.copy() + wrong[1], wrong[11] = "QQQQ", "PPPP" + _page, field, boxes, finished = _submit("".join(wrong)) + + assert finished == [] and not field.get_visible() + assert [box.get_text() for box in boxes] == wrong + assert [i for i, box in enumerate(boxes) if isinstance(box, GroupEntry)] == [1, 11] + + +def test_a_missing_and_an_extra_character_open_only_their_own_groups() -> None: + slipped = CARD.copy() + slipped[2], slipped[6] = CARD[2][1:], CARD[6][:2] + "Q" + CARD[6][2:] + _page, _field, boxes, _finished = _submit("".join(slipped)) + + assert [i for i, box in enumerate(boxes) if isinstance(box, GroupEntry)] == [2, 6] + assert [box.get_text() for box in boxes] == slipped + + +def test_correcting_the_open_boxes_confirms_the_card() -> None: + wrong = CARD.copy() + wrong[3] = "QQQQ" + page, field, boxes, finished = _submit("".join(wrong)) + + boxes[3].set_text(CARD[3].lower()) + _settle() + boxes[3].emit("activate") + assert finished == [True] and field.get_text() == "" + assert list(_widgets(page, Gtk.Grid)) == [] and field.get_visible() + + +def test_tab_moves_only_between_the_wrong_groups() -> None: + wrong = CARD.copy() + wrong[2], wrong[9] = "QQQQ", "PPPP" + page, _field, boxes, _finished = _submit("".join(wrong)) + window = Gtk.Window(child=page.get_parent()) + window.present() + _settle() + + boxes[2].grab_focus() + controllers = boxes[2].observe_controllers() + controller = next( + controllers.get_item(index) + for index in range(controllers.get_n_items()) + if isinstance(controllers.get_item(index), Gtk.EventControllerKey) + ) + assert controller.emit("key-pressed", Gdk.KEY_Tab, 0, Gdk.ModifierType(0)) + assert window.get_focus().get_parent() is boxes[9] + window.destroy() + + +def test_the_read_back_refuses_pasted_and_dropped_text() -> None: + wrong = CARD.copy() + wrong[5] = "QQQQ" + _page, field, boxes, _finished = _submit("".join(wrong)) + field.get_clipboard().set("PPPP") + _settle() + + for entry in (field, boxes[5]): + before, text = entry.get_text(), entry.get_delegate() + text.emit("paste-clipboard") + _settle() + assert entry.get_text() == before + controllers = text.observe_controllers() + assert not any( + isinstance(controllers.get_item(i), Gtk.DropTarget) for i in range(controllers.get_n_items()) + ) + + +def test_clearing_puts_the_single_field_back() -> None: + wrong = CARD.copy() + wrong[0] = "MS1Q" + page, field, _boxes, _finished = _submit("".join(wrong)) + field.clear() + assert list(_widgets(page, Gtk.Grid)) == [] and field.get_visible() + field.set_text(SHARE[:20]) + _settle() + assert reading.normalize(field.get_text()) == SHARE[:20].upper() + + +def test_a_retry_keeps_every_box_where_it_is() -> None: + wrong = CARD.copy() + wrong[0], wrong[2] = "MS1Q", "QQQQ" + page, _field, boxes, _finished = _submit("".join(wrong)) + + boxes[0].set_text("MS12N") # One character too many, next to the locked group + boxes[0].emit("activate") + boxes = _boxes(page) + assert [i for i, box in enumerate(boxes) if isinstance(box, GroupEntry)] == [0, 2] + assert [box.get_text() for box in boxes[:2]] == ["MS12N", CARD[1]] + + +def test_a_retry_ignores_spaces_typed_into_a_box() -> None: + wrong = CARD.copy() + wrong[3], wrong[8] = "QQQQ", "PPPP" + page, _field, boxes, _finished = _submit("".join(wrong)) + + boxes[3].set_text(CARD[3][:2] + " " + CARD[3][2:]) + boxes[3].emit("activate") + assert [i for i, box in enumerate(_boxes(page)) if isinstance(box, GroupEntry)] == [8] + + +@pytest.mark.parametrize( + ("key", "modifiers", "position", "target", "cursor"), + [ + (Gdk.KEY_Right, 0, 4, 9, 0), + (Gdk.KEY_Left, 0, 0, 2, 4), + (Gdk.KEY_Right, Gdk.ModifierType.CONTROL_MASK, 1, 9, 0), + (Gdk.KEY_Left, Gdk.ModifierType.CONTROL_MASK, 1, 2, 4), + (Gdk.KEY_Home, 0, 2, 2, 0), + (Gdk.KEY_End, 0, 2, 9, 4), + (Gdk.KEY_Tab, 0, 2, 9, 0), + (Gdk.KEY_ISO_Left_Tab, Gdk.ModifierType.SHIFT_MASK, 2, 2, 4), + ], +) +def test_keyboard_navigation_skips_locked_groups( + key: int, modifiers: int, position: int, target: int, cursor: int +) -> None: + wrong = CARD.copy() + wrong[2], wrong[9] = "QQQQ", "PPPP" + page, _field, boxes, _finished = _submit("".join(wrong)) + window = Gtk.Window(child=page.get_parent()) + window.present() + _settle() + start = 9 if key in (Gdk.KEY_Left, Gdk.KEY_ISO_Left_Tab, Gdk.KEY_Home) else 2 + boxes[start].grab_focus() + boxes[start].set_position(position) + controllers = boxes[start].observe_controllers() + controller = next( + controllers.get_item(index) + for index in range(controllers.get_n_items()) + if isinstance(controllers.get_item(index), Gtk.EventControllerKey) + ) + assert controller.emit("key-pressed", key, 0, Gdk.ModifierType(modifiers)) + assert window.get_focus().get_parent() is boxes[target] + assert boxes[target].get_position() == cursor + window.destroy() + + +def test_more_than_eight_missing_characters_can_be_compared() -> None: + _page, _field, boxes, finished = _submit(SHARE[:-12]) + assert not finished + assert len(boxes) == len(CARD) + assert any(isinstance(box, GroupEntry) and box.get_text() == "" for box in boxes) + + +def test_clearing_also_empties_retained_correction_widgets() -> None: + wrong = CARD.copy() + wrong[3] = "QQQQ" + _page, field, boxes, _finished = _submit("".join(wrong)) + field.clear() + assert all(box.get_text() == "" for box in boxes) + assert field._stable == "" + _settle() + assert field.get_text() == "" + + +def test_readback_formatting_preserves_oversized_input() -> None: + page, field, typed, finished = _read_back() + field.set_text("Q" * 1025) + _settle() + assert reading.normalize_readback(field.get_text()) == "Q" * 1025 + field.emit("activate") + assert not typed and not finished + assert any("at most 1,024" in label.get_label() for label in _widgets(page, Gtk.Label)) + + +def test_selecting_a_correction_box_releases_the_primary_selection() -> None: + wrong = CARD.copy() + wrong[3] = "QQQQ" + page, _field, boxes, _finished = _submit("".join(wrong)) + window = Gtk.Window(child=page.get_parent()) + window.present() + _settle() + boxes[3].grab_focus() + boxes[3].select_region(0, -1) + _settle() + assert window.get_display().get_primary_clipboard().get_content() is None + window.destroy() + + +@pytest.mark.parametrize(("key", "cursor"), [(Gdk.KEY_Left, 0), (Gdk.KEY_Right, 4)]) +def test_arrows_do_not_wrap_when_no_other_group_is_editable(key: int, cursor: int) -> None: + wrong = CARD.copy() + wrong[3] = "QQQQ" + page, _field, boxes, _finished = _submit("".join(wrong)) + window = Gtk.Window(child=page.get_parent()) + window.present() + _settle() + box = boxes[3] + box.grab_focus() + box.set_position(cursor) + controllers = box.observe_controllers() + controller = next( + controllers.get_item(index) + for index in range(controllers.get_n_items()) + if isinstance(controllers.get_item(index), Gtk.EventControllerKey) + ) + assert not controller.emit("key-pressed", key, 0, Gdk.ModifierType(0)) + assert box.get_position() == cursor + window.destroy() diff --git a/tools/gui_walkthrough.py b/tools/gui_walkthrough.py index e4ac8cc8..456c3864 100644 --- a/tools/gui_walkthrough.py +++ b/tools/gui_walkthrough.py @@ -25,6 +25,7 @@ from codex32 import MasterSeed, parse_codex32 from codex32_gui import app, pages, reading, wallet_setup +from codex32_gui.entry import GroupEntry SHARE_A = "MS12NAMEA320ZYXWVUTSRQPNMLKJHGFEDCAXRPP870HKKQRM" SHARE_C = "MS12NAMECACDEFGHJKLMNPQRSTUVWXYZ023FTR2GDZMPY6PN" @@ -66,7 +67,11 @@ def walk(widget: Any) -> Iterator[Any]: def card(page: Any) -> str: - return "".join(item.get_label() for item in walk(page) if "card-group" in item.get_css_classes()) + return "".join( + item.get_text() if isinstance(item, GroupEntry) else item.get_label() + for item in walk(page) + if "card-group" in item.get_css_classes() + ) def button(page: Any, label: str) -> Any: @@ -703,7 +708,7 @@ def read_back(self) -> bool: settle() press(page, "Confirm card") mismatches = [ - item.get_label() + item.get_text() if isinstance(item, GroupEntry) else item.get_label() for item in walk(page) if "card-group" in item.get_css_classes() and "mismatch" in item.get_css_classes() ] @@ -717,9 +722,10 @@ def read_back(self) -> bool: card(page) == self.made[:-4] + "QQQQ", card(page), ) - field.set_text(self.made.lower()) + editable = next(item for item in walk(page) if isinstance(item, GroupEntry)) + editable.set_text(self.made[-4:].lower()) settle() - field.emit("activate") + editable.emit("activate") return True def new_card_done(self) -> bool: From b4e3910fceee08a3c0eb9e212920c762b09d200b Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 28 Oct 2026 18:18:05 +0000 Subject: [PATCH 2/2] gui: Lock read-back groups while typing Each correct group locks once the next group is typed, and the field turns red when its first group still mismatches a further group later. Enter still opens only the wrong groups. Use the library's twice-length bound instead of a 1,024-character limit. The page compares the text itself, so drop the GUI's own comparison and the unused read-back reading. Restore the base's primary-selection code. The GUI is back under its 2,250-line budget (2,249). Claude-Session: https://claude.ai/code/session_018HrKoywz6PT9c91jvKou7Q --- docs/developer/gui.md | 7 +-- docs/user/gui.md | 7 +-- src/codex32_gui/entry.py | 93 ++++++++++++++----------------------- src/codex32_gui/pages.py | 88 +++++++++++++++++++---------------- src/codex32_gui/reading.py | 10 ---- tests/test_gui_read_back.py | 84 +++++++++++++-------------------- tools/gui_walkthrough.py | 3 +- 7 files changed, 123 insertions(+), 169 deletions(-) diff --git a/docs/developer/gui.md b/docs/developer/gui.md index 674d79a6..b0bbef53 100644 --- a/docs/developer/gui.md +++ b/docs/developer/gui.md @@ -59,9 +59,10 @@ Read-back uses `codex32._confirmation._entered_groups`, shared with the CLI. After a mismatch, matching groups lock; only the entered text is shown. Arrows cross editable-group boundaries, Ctrl+arrows skip groups, Tab cycles, and Home/End reach the first/last editable group. Paste and drop are disabled. -Invalid characters and missing groups are compared directly. The former -eight-character tolerance is gone; comparison accepts up to 1,024 characters -and preserves longer input with an explicit size message. +Invalid characters and missing groups are compared directly. Input over twice +the card's length marks every group wrong without aligning it. While typing, +each correct group locks once the next group is typed, and the field turns red +when its first group still mismatches a further group later. `tests/test_gui_read_back.py` exercises these behaviors under GTK. Review `entry.py` for keyboard and clipboard handling. diff --git a/docs/user/gui.md b/docs/user/gui.md index 7be0996f..7a766334 100644 --- a/docs/user/gui.md +++ b/docs/user/gui.md @@ -63,9 +63,10 @@ The recommended layout is three cards where any two recover the wallet. One card can be lost, and one card alone reveals nothing. Copy each card to paper, hide the on-screen original, then type the paper copy -back. Read-back starts empty, including `MS1`, and does not accept paste. After -a mismatch, correct the highlighted boxes from your paper card; matching groups -are locked. Arrows, Ctrl+arrows, Tab, Home and End move between editable boxes. +back. Read-back starts empty, including `MS1`, and does not accept paste. Correct +groups lock as you type, and the box turns red when its first group is wrong. +After a mismatch, correct the highlighted boxes from your paper card; matching +groups are locked. Arrows, Ctrl+arrows, Tab, Home and End move between editable boxes. The expected text stays hidden. Press Enter to confirm. After all cards are confirmed, write the displayed master fingerprint on your diff --git a/src/codex32_gui/entry.py b/src/codex32_gui/entry.py index 44e5fbff..20ed2398 100644 --- a/src/codex32_gui/entry.py +++ b/src/codex32_gui/entry.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import replace -from typing import Literal, cast +from typing import Literal from gi.repository import Gdk, GLib, Gtk @@ -19,40 +19,12 @@ normalize, normalize_readback, read, - readback, ) EntryMode = Literal["import", "correct", "readback"] -class PrivateEntry(Gtk.Entry): - """An entry that releases its primary selection after GTK publishes it.""" - - def __init__(self, **properties: object) -> None: - super().__init__(**properties) - self._watching = 0 - self.connect("realize", lambda _entry: self._watch(True)) - self.connect("unrealize", lambda _entry: self._watch(False)) - - def _watch(self, start: bool) -> None: - primary = self.get_primary_clipboard() - if start: - self._watching = primary.connect("changed", self._unpublish) - elif self._watching: - primary.disconnect(self._watching) - self._watching = 0 - - def _unpublish(self, primary: Gdk.Clipboard) -> None: - if primary.is_local() and primary.get_content() is not None and self.get_selection_bounds(): - GLib.idle_add(self._drop, primary) - - @staticmethod - def _drop(primary: Gdk.Clipboard) -> bool: - primary.set_content(None) - return False - - -class Codex32Entry(PrivateEntry): +class Codex32Entry(Gtk.Entry): """One grouped field with explicit import, correction, and read-back behavior.""" __gtype_name__ = "Codex32Entry" @@ -67,8 +39,6 @@ def __init__( allow_header_erasures: bool = False, ) -> None: super().__init__() - if mode == "readback" and length is None: - raise ValueError("read-back entry requires the card length") self._accepted, self._length, self._mode = accepted, length, mode self._expected_header, self._allow_header_erasures, self._pending = ( expected_header, @@ -76,7 +46,7 @@ def __init__( 0, ) self._limit = (length or TEXT_LENGTHS[-1]) + SLACK - self._dropped, self._rewriting = "", False + self._dropped, self._rewriting, self._unpublishing = "", False, 0 self._last_header_fault = "" self._stable = "" if mode == "readback" else PREFIX self.set_hexpand(True) @@ -85,6 +55,8 @@ def __init__( self.set_text(self._stable) self.set_position(-1) self.connect("changed", self._schedule) + self.connect("notify::selection-bound", self._unpublish) + self.connect("notify::cursor-position", self._unpublish) if mode == "readback": refuse_paste(self) GLib.idle_add(self.set_position, -1) @@ -99,8 +71,6 @@ def prefill(self, text: str) -> None: def reading(self) -> Reading: """Return the current reading of this field.""" - if self._mode == "readback": - return readback(self.get_text(), cast(int, self._length)) 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") @@ -116,8 +86,6 @@ def clear(self) -> None: self.set_position(-1) def _header_fault(self, raw: str | None = None) -> str: - if self._mode == "readback": - return "" value = self.get_text() if raw is None else raw compact = "".join(value.split()).upper() text = compact if compact.startswith(PREFIX) else normalize(value) @@ -125,6 +93,21 @@ def _header_fault(self, raw: str | None = None) -> str: return "? cannot be used in this header. Type the character printed on the card." return header_fault(text, self._accepted, self._expected_header) + def _unpublish(self, *_arguments: object) -> None: + # Selecting text in a Gtk.Entry hands it to the primary selection, where a + # clipboard manager would copy the card into a history file on disk. The + # selection still works; only the copy that leaves the program is taken + # back, once GTK has finished publishing it. + if self.get_selection_bounds() and not self._unpublishing: + self._unpublishing = GLib.idle_add(self._drop_primary) + + def _drop_primary(self) -> bool: + self._unpublishing = 0 + display = self.get_display() + if display is not None: + display.get_primary_clipboard().set_content(None) + return False + def _schedule(self, _entry: Gtk.Entry) -> None: # One edit reaches the buffer as a deletion and then an insertion. Reformat # once the whole edit has settled, so no intermediate state is rewritten, @@ -144,9 +127,7 @@ def _reformat(self) -> bool: return False duplicated_prefix = self._mode == "import" and compact.startswith(PREFIX + PREFIX) source = compact[len(PREFIX) :] if duplicated_prefix else raw - canonical = normalizer(source) - if self._mode != "readback": - canonical = canonical[: self._limit] + canonical = normalizer(source)[: None if self._mode == "readback" else 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 "" @@ -183,10 +164,7 @@ class GroupEntry(Codex32Entry): def __init__(self, text: str) -> None: super().__init__(length=GROUP, mode="readback") - self.set_width_chars(5) - self.set_max_width_chars(5) - self.set_hexpand(False) - self.set_text(text) + self.set_properties(width_chars=5, max_width_chars=5, hexpand=False, text=text) def refuse_paste(entry: Gtk.Entry) -> None: @@ -205,25 +183,24 @@ def refuse_paste(entry: Gtk.Entry) -> None: def navigate_groups(entries: list[GroupEntry]) -> None: """Navigate editable groups while leaving character movement to GTK.""" - def key(_controller: Gtk.EventControllerKey, value: int, _code: int, state: Gdk.ModifierType) -> bool: - current = entries.index(_controller.get_widget()) - entry = entries[current] - backwards = value in (Gdk.KEY_Left, Gdk.KEY_KP_Left, Gdk.KEY_ISO_Left_Tab) - tab = value in (Gdk.KEY_Tab, Gdk.KEY_ISO_Left_Tab) - edge = entry.get_position() == (0 if backwards else len(entry.get_text())) - if value in (Gdk.KEY_Home, Gdk.KEY_End): + def key(controller: Gtk.EventControllerKey, value: int, _code: int, state: Gdk.ModifierType) -> bool: + entry = controller.get_widget() + current, back = entries.index(entry), value in (Gdk.KEY_Left, Gdk.KEY_KP_Left, Gdk.KEY_ISO_Left_Tab) + if value in (Gdk.KEY_Tab, Gdk.KEY_ISO_Left_Tab): + target = (current + (-1 if back or state & Gdk.ModifierType.SHIFT_MASK else 1)) % len(entries) + elif value in (Gdk.KEY_Home, Gdk.KEY_End): target = 0 if value == Gdk.KEY_Home else len(entries) - 1 - elif tab or value in (Gdk.KEY_Left, Gdk.KEY_Right, Gdk.KEY_KP_Left, Gdk.KEY_KP_Right): - if not tab and not edge and not state & Gdk.ModifierType.CONTROL_MASK: - return False - target = current + (-1 if backwards or tab and state & Gdk.ModifierType.SHIFT_MASK else 1) - target = target % len(entries) if tab else min(max(target, 0), len(entries) - 1) - if not tab and target == current: + elif value in (Gdk.KEY_Left, Gdk.KEY_Right, Gdk.KEY_KP_Left, Gdk.KEY_KP_Right) and ( + state & Gdk.ModifierType.CONTROL_MASK + or entry.get_position() == (0 if back else len(entry.get_text())) + ): + target = current + (-1 if back else 1) + if not 0 <= target < len(entries): return False else: return False entries[target].grab_focus() - entries[target].set_position(-1 if backwards or value == Gdk.KEY_End else 0) + entries[target].set_position(-1 if back or value == Gdk.KEY_End else 0) return True for entry in entries: diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index 8a12775a..3b3eab8a 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -26,7 +26,7 @@ generate_master_seed, recover_secret, ) -from codex32._confirmation import _compact, _entered_groups +from codex32._confirmation import _entered_groups from codex32.errors import CodexError from codex32.generation import ORDINARY_INDICES from codex32_gui import ARTWORK, reading, wallet_setup, work @@ -327,19 +327,6 @@ def _describe(artifact: Artifact) -> str: ) -def _compare(expected: str, entered: str) -> ConfirmationResult: - """Report which four-character groups do not match, by the library's own rule.""" - observed, wanted = _compact(entered), expected.lower() - span = (max(len(observed), len(wanted)) + reading.GROUP - 1) // reading.GROUP - mismatched = tuple( - group + 1 - for group in range(span) - if observed[group * reading.GROUP : (group + 1) * reading.GROUP] - != wanted[group * reading.GROUP : (group + 1) * reading.GROUP] - ) - 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() @@ -505,10 +492,10 @@ def _read_back_page( confirm: Callable[[str], ConfirmationResult], after: Callable[[], None], ) -> Adw.NavigationPage: - """Read from paper; compare with the CLI's library alignment.""" + """Read from paper with the CLI's alignment; `confirm` sees only an exact match.""" field = Codex32Entry(length=len(card.text), mode="readback") boxes: list[Gtk.Label | GroupEntry] = [] - status, comparison = _note(""), Adw.Bin() + status, comparison, wanted = _note(""), Adw.Bin(), card.text.upper() accept = _button("Confirm card", lambda: check(), style="suggested-action") def clear_boxes() -> None: @@ -519,60 +506,81 @@ def clear_boxes() -> None: for box in previous: box.clear() if isinstance(box, GroupEntry) else box.set_text("") + def place(box: Gtk.Label | GroupEntry) -> None: + if not isinstance(grid := comparison.get_child(), Gtk.Grid): + comparison.set_child(grid := Gtk.Grid(column_spacing=8, row_spacing=8, halign=Gtk.Align.CENTER)) + box.add_css_class("card-group") + grid.attach(box, len(boxes) % 4, len(boxes) // 4, 1, 1) + boxes.append(box) + def entered() -> str: - return "".join(box.get_text() for box in boxes) if boxes else field.get_text() + return "".join(box.get_text() for box in boxes) + (field.get_text() if field.get_visible() else "") def update(*_arguments: object) -> None: if field._rewriting: return - if not field.get_text(): + if not field.get_text() and not field.get_visible(): clear_boxes() text = reading.normalize_readback(entered()) - accept.set_sensitive(0 < len(text) <= 1024) - message = f"{len(text)} of {len(card.text)} characters" - _say(status, "Compare at most 1,024 characters at a time." if len(text) > 1024 else message) + accept.set_sensitive(bool(text)) + _say(status, f"{len(text)} of {len(card.text)} characters") + if field.get_visible(): + GLib.idle_add(lock) + + def lock() -> bool: + # Lock each correct group once the next is typed; flag a wrong one a group later. + text, done = reading.normalize_readback(field.get_text()), 4 * len(boxes) + taken = 0 + while len(text) >= taken + 8 and text[taken : taken + 4] == wanted[done + taken : done + taken + 4]: + place(Gtk.Label(label=text[taken : taken + 4])) + taken += 4 + if taken: + field._rewrite(reading.grouped(text[taken:]), max(field.get_position() - taken * 5 // 4, 0)) + wrong = len(text) >= taken + 12 and text[taken : taken + 4] != wanted[done + taken : done + taken + 4] + (field.add_css_class if wrong else field.remove_css_class)("error") + if wrong: + _say(status, "The first group in the box does not match your card.", "error") + return False def check() -> None: text = reading.normalize_readback(entered()) - if text == card.text.upper(): + if text == wanted: try: result = confirm(text) except CodexError as error: _failure(view, error, CARDS_SAFE) return if result.accepted: - field.clear() + clear_page() after() return - if boxes: + if field.get_visible(): + pieces, changed = _entered_groups(text, card.text) + else: pieces = [reading.normalize_readback(box.get_text()) for box in boxes] changed = { - index - for index, piece in enumerate(pieces) - if piece != card.text[index * 4 : index * 4 + 4].upper() + index for index, piece in enumerate(pieces) if piece != wanted[index * 4 : index * 4 + 4] } - else: - pieces, changed = _entered_groups(text, card.text) clear_boxes() - grid = Gtk.Grid(column_spacing=8, row_spacing=8, halign=Gtk.Align.CENTER) for index, piece in enumerate(pieces): piece = reading.normalize_readback(piece) box = GroupEntry(piece) if index in changed else Gtk.Label(label=piece) - box.add_css_class("card-group") if isinstance(box, GroupEntry): box.add_css_class("mismatch") box.connect("changed", update) box.connect("activate", lambda _box: check() if accept.get_sensitive() else None) - boxes.append(box) - grid.attach(box, index % 4, index // 4, 1, 1) + place(box) editable = [box for box in boxes if isinstance(box, GroupEntry)] navigate_groups(editable) - comparison.set_child(grid) field.set_visible(False) if editable: editable[0].grab_focus() _say(status, "Re-read the highlighted groups from your card.", "error") + def clear_page() -> None: + field.clear() + clear_boxes() + field.connect("activate", lambda _entry: check() if accept.get_sensitive() else None) field.connect("changed", update) page = _page( @@ -580,16 +588,14 @@ def check() -> None: _column( _title("Now type it back from the card", _counted(card.header.index.upper(), position, count)), _note("Read every character from your paper card, including MS1. Pasting is turned off."), + comparison, field, status, - comparison, - _note( - "Only highlighted groups can be changed. Use arrows, Ctrl+arrows, Tab, Home or End to move." - ), + _note("Correct groups lock as you type. Use arrows, Ctrl+arrows, Tab, Home or End to move."), ), actions=_actions(_button("Show the card again", view.pop), accept), ) - _forget_when_gone(view, page, field.clear) + _forget_when_gone(view, page, clear_page) update() return page @@ -711,7 +717,7 @@ def _unshared_page(view: Adw.NavigationView, core: BitcoinCore, secret: MasterSe card=secret, position=0, count=1, - confirm=lambda text: _compare(secret.text, text), + confirm=lambda _text: ConfirmationResult(True, ()), after=lambda: _identity(view, core, secret), cancel=lambda page: _abandon(view, page), ) @@ -1642,7 +1648,7 @@ def _derive(view: Adw.NavigationView, found: tuple[Artifact, ...], letter: str) card=share, position=0, count=None, - confirm=lambda text: _compare(share.text, text), + confirm=lambda _text: ConfirmationResult(True, ()), after=lambda: _replace(view, _share_done_page(view, share)), cancel=lambda _page: view.replace([home(view)]), ), diff --git a/src/codex32_gui/reading.py b/src/codex32_gui/reading.py index 6a8503c8..0aeca5e1 100644 --- a/src/codex32_gui/reading.py +++ b/src/codex32_gui/reading.py @@ -149,16 +149,6 @@ def read(text: str, *, accepted: tuple[str, ...] = (), length: int | None = None return Reading(artifact.text, artifact, "Every character checks out.", "success", expected, 0, False) -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) - - def repair(text: str, length: int | None, excluded: tuple[str, ...] = ()) -> CorrectionCandidate | str: """Ask the library for one repair, and offer nothing at all when it is unsure. diff --git a/tests/test_gui_read_back.py b/tests/test_gui_read_back.py index f463bf61..286d6b80 100644 --- a/tests/test_gui_read_back.py +++ b/tests/test_gui_read_back.py @@ -45,7 +45,7 @@ def _read_back( def confirm(text: str) -> ConfirmationResult: typed.append(text) - return result or pages._compare(SHARE, text) + return result or ConfirmationResult(text == SHARE.upper(), ()) view = Adw.NavigationView() page = pages._read_back_page(view, parse_codex32(SHARE), 1, 3, confirm, lambda: finished.append(True)) @@ -78,6 +78,13 @@ def _groups(text: str) -> list[str]: CARD = _groups(SHARE.upper()) +def _press(box: Gtk.Widget, key: int, modifiers: int = 0) -> bool: + controllers = box.observe_controllers() + found = (controllers.get_item(i) for i in range(controllers.get_n_items())) + controller = next(item for item in found if isinstance(item, Gtk.EventControllerKey)) + return bool(controller.emit("key-pressed", key, 0, Gdk.ModifierType(modifiers))) + + def test_readback_starts_empty_and_enter_confirms_the_whole_card() -> None: _page, field, typed, finished = _read_back(ConfirmationResult(True)) @@ -128,26 +135,6 @@ def test_correcting_the_open_boxes_confirms_the_card() -> None: assert list(_widgets(page, Gtk.Grid)) == [] and field.get_visible() -def test_tab_moves_only_between_the_wrong_groups() -> None: - wrong = CARD.copy() - wrong[2], wrong[9] = "QQQQ", "PPPP" - page, _field, boxes, _finished = _submit("".join(wrong)) - window = Gtk.Window(child=page.get_parent()) - window.present() - _settle() - - boxes[2].grab_focus() - controllers = boxes[2].observe_controllers() - controller = next( - controllers.get_item(index) - for index in range(controllers.get_n_items()) - if isinstance(controllers.get_item(index), Gtk.EventControllerKey) - ) - assert controller.emit("key-pressed", Gdk.KEY_Tab, 0, Gdk.ModifierType(0)) - assert window.get_focus().get_parent() is boxes[9] - window.destroy() - - def test_the_read_back_refuses_pasted_and_dropped_text() -> None: wrong = CARD.copy() wrong[5] = "QQQQ" @@ -169,12 +156,13 @@ def test_the_read_back_refuses_pasted_and_dropped_text() -> None: def test_clearing_puts_the_single_field_back() -> None: wrong = CARD.copy() wrong[0] = "MS1Q" - page, field, _boxes, _finished = _submit("".join(wrong)) + page, field, boxes, _finished = _submit("".join(wrong)) field.clear() + assert all(box.get_text() == "" for box in boxes) assert list(_widgets(page, Gtk.Grid)) == [] and field.get_visible() - field.set_text(SHARE[:20]) + field.set_text(SHARE[:7]) _settle() - assert reading.normalize(field.get_text()) == SHARE[:20].upper() + assert reading.normalize_readback(field.get_text()) == SHARE[:7].upper() def test_a_retry_keeps_every_box_where_it_is() -> None: @@ -224,13 +212,7 @@ def test_keyboard_navigation_skips_locked_groups( start = 9 if key in (Gdk.KEY_Left, Gdk.KEY_ISO_Left_Tab, Gdk.KEY_Home) else 2 boxes[start].grab_focus() boxes[start].set_position(position) - controllers = boxes[start].observe_controllers() - controller = next( - controllers.get_item(index) - for index in range(controllers.get_n_items()) - if isinstance(controllers.get_item(index), Gtk.EventControllerKey) - ) - assert controller.emit("key-pressed", key, 0, Gdk.ModifierType(modifiers)) + assert _press(boxes[start], key, modifiers) assert window.get_focus().get_parent() is boxes[target] assert boxes[target].get_position() == cursor window.destroy() @@ -243,25 +225,27 @@ def test_more_than_eight_missing_characters_can_be_compared() -> None: assert any(isinstance(box, GroupEntry) and box.get_text() == "" for box in boxes) -def test_clearing_also_empties_retained_correction_widgets() -> None: - wrong = CARD.copy() - wrong[3] = "QQQQ" - _page, field, boxes, _finished = _submit("".join(wrong)) - field.clear() - assert all(box.get_text() == "" for box in boxes) - assert field._stable == "" - _settle() - assert field.get_text() == "" +def test_input_over_twice_the_card_opens_every_group_without_aligning() -> None: + _page, _field, boxes, finished = _submit("Q" * (2 * len(SHARE) + 1)) + assert not finished and all(isinstance(box, GroupEntry) for box in boxes) -def test_readback_formatting_preserves_oversized_input() -> None: - page, field, typed, finished = _read_back() - field.set_text("Q" * 1025) +def test_correct_groups_lock_while_typing_and_a_wrong_one_turns_red() -> None: + page, field, _typed, _finished = _read_back() + field.set_text("".join(CARD[:3])) + _settle() + assert [box.get_text() for box in _boxes(page)] == CARD[:2] + assert reading.normalize_readback(field.get_text()) == CARD[2] + field.set_text(CARD[2] + "QQQQ" + "".join(CARD[4:6])) + _settle() + assert len(_boxes(page)) == 3 and field.has_css_class("error") + field.set_text(CARD[3]) + _settle() + assert not field.has_css_class("error") + field.set_text("".join(CARD[3:])) _settle() - assert reading.normalize_readback(field.get_text()) == "Q" * 1025 field.emit("activate") - assert not typed and not finished - assert any("at most 1,024" in label.get_label() for label in _widgets(page, Gtk.Label)) + assert _finished == [True] def test_selecting_a_correction_box_releases_the_primary_selection() -> None: @@ -289,12 +273,6 @@ def test_arrows_do_not_wrap_when_no_other_group_is_editable(key: int, cursor: in box = boxes[3] box.grab_focus() box.set_position(cursor) - controllers = box.observe_controllers() - controller = next( - controllers.get_item(index) - for index in range(controllers.get_n_items()) - if isinstance(controllers.get_item(index), Gtk.EventControllerKey) - ) - assert not controller.emit("key-pressed", key, 0, Gdk.ModifierType(0)) + assert not _press(box, key) assert box.get_position() == cursor window.destroy() diff --git a/tools/gui_walkthrough.py b/tools/gui_walkthrough.py index 456c3864..f4ebf6e5 100644 --- a/tools/gui_walkthrough.py +++ b/tools/gui_walkthrough.py @@ -704,7 +704,8 @@ def read_back(self) -> bool: field.select_region(0, -1) settle() check("selecting a card does not publish it", _primary_is_empty(), _primary()) - field.set_text(self.made[:-4] + "QQQQ") + check("correct groups lock as they are typed", field.get_text() == self.made[-4:], field.get_text()) + field.set_text("QQQQ") settle() press(page, "Confirm card") mismatches = [