Skip to content

Commit 5311cd6

Browse files
committed
gui: Lock the groups that matched when a card is read back
After a mismatch, only the highlighted groups can still be edited. An edit that reaches a group that matched is undone. An edit inside an open group is cut, or padded with "?", back to that group's size, so the card keeps its length and no later group shifts. Clearing the field unlocks it. Claude-Session: https://claude.ai/code/session_018HrKoywz6PT9c91jvKou7Q
1 parent b24d2a6 commit 5311cd6

3 files changed

Lines changed: 86 additions & 8 deletions

File tree

‎src/codex32_gui/entry.py‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ def __init__(self, *, accepted: tuple[str, ...] = (), length: int | None = None)
3131
self._accepted, self._length, self._pending = accepted, length, 0
3232
self._limit = (length or TEXT_LENGTHS[-1]) + SLACK
3333
self._dropped, self._rewriting, self._unpublishing = "", False, 0
34+
self._locked, self._shown = frozenset[int](), PREFIX
3435
self.set_hexpand(True)
3536
self.add_css_class("card-entry")
3637
self.set_input_hints(Gtk.InputHints.NO_SPELLCHECK | Gtk.InputHints.NO_EMOJI)
@@ -53,9 +54,14 @@ def reading(self) -> Reading:
5354

5455
def clear(self) -> None:
5556
"""Drop the entered recovery text."""
56-
self._dropped = ""
57+
self._dropped, self._locked = "", frozenset()
5758
self.set_text(PREFIX)
5859

60+
def lock(self, open_groups: frozenset[int] | None) -> None:
61+
"""Freeze every four-character group except `open_groups`, which stay editable in place."""
62+
self._shown, every = self.get_text(), range(-(-len(normalize(self.get_text())) // GROUP))
63+
self._locked = frozenset(every) - open_groups if open_groups else frozenset()
64+
5965
def _unpublish(self, *_arguments: object) -> None:
6066
# Selecting text in a Gtk.Entry hands it to the primary selection, where a
6167
# clipboard manager would copy the card into a history file on disk. The
@@ -85,12 +91,28 @@ def _reformat(self) -> bool:
8591
canonical = normalize(raw)[: self._limit]
8692
if header_fault(canonical, self._accepted):
8793
canonical = canonical[: len(PREFIX) + HEADER_LENGTH]
88-
shown = grouped(canonical)
94+
kept = min(len(normalize(raw[:position])), len(canonical))
95+
if self._locked and canonical != normalize(self._shown):
96+
canonical, kept = self._within_open_group(normalize(self._shown), canonical)
97+
shown = self._shown = grouped(canonical)
8998
self._dropped = lookalike_fault(raw)
9099
if shown != raw:
91-
kept = min(len(normalize(raw[:position])), len(canonical))
92100
self._rewriting = True
93101
self.set_text(shown)
94102
self._rewriting = False
95103
self.set_position(kept + max(kept - 1, 0) // GROUP)
96104
return False
105+
106+
def _within_open_group(self, old: str, new: str) -> tuple[str, int]:
107+
# Once groups are locked the card keeps its length and they keep their
108+
# text: an edit inside one open group is cut or padded with "?" back to
109+
# that group's size, and an edit reaching any other group is undone.
110+
same = min(len(old), len(new))
111+
start = next((i for i in range(same) if old[i] != new[i]), same)
112+
tail = next((i for i in range(same - start) if old[-1 - i] != new[-1 - i]), same - start)
113+
first, end = start - start % GROUP, min(start - start % GROUP + GROUP, len(old))
114+
if first // GROUP in self._locked or len(old) - tail > end:
115+
return old, start
116+
typed = new[start : len(new) - tail]
117+
edited = (old[first:start] + typed + old[len(old) - tail : end] + "?" * GROUP)[: end - first]
118+
return old[:first] + edited + old[end:], min(start + len(typed), end)

‎src/codex32_gui/pages.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,8 @@ def _read_back_page(
492492
accept = _button("Confirm card", lambda: None, style="suggested-action")
493493

494494
def update(*_arguments: object) -> None:
495-
comparison.set_child(None)
495+
if field.get_text() == reading.PREFIX: # Cleared: the redrawn copy goes too.
496+
comparison.set_child(None)
496497
state = field.reading()
497498
accept.set_sensitive(state.complete)
498499
# A character a card can never carry is named, never quietly deleted: this
@@ -511,6 +512,7 @@ def check() -> None:
511512
if not result.accepted:
512513
groups = frozenset(group - 1 for group in result.mismatched_groups)
513514
comparison.set_child(_card(typed, groups))
515+
field.lock(groups or None) # Only the highlighted groups can still be edited.
514516
_say(status, "The highlighted groups do not match. Re-read them from the card.", "error")
515517
return
516518
field.clear()
@@ -529,8 +531,8 @@ def check() -> None:
529531
status,
530532
comparison,
531533
_note(
532-
"Spaces and capitals do not matter, and you may try as many times as you like. Correct the "
533-
"highlighted groups; the rest stays as you typed it."
534+
"Spaces and capitals do not matter, and you may try as many times as you like. Only the "
535+
"highlighted groups can be changed; the rest is locked as you typed it."
534536
),
535537
)
536538
page = _page(

‎tests/test_gui_read_back.py‎

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
gi.require_version("Gtk", "4.0")
1212
gi.require_version("Adw", "1")
1313
gi.require_version("Gdk", "4.0")
14-
from gi.repository import Adw, Gdk, Gtk
14+
from gi.repository import Adw, Gdk, GLib, Gtk
1515
except (ImportError, ValueError):
1616
pytest.skip("GTK 4 and libadwaita are unavailable", allow_module_level=True)
1717
if not Gtk.init_check() or Gdk.Display.get_default() is None:
@@ -22,7 +22,7 @@
2222

2323
from codex32 import parse_codex32
2424
from codex32.generation import ConfirmationResult
25-
from codex32_gui import pages
25+
from codex32_gui import pages, reading
2626

2727
SHARE = VECTOR_2["share_A"]
2828

@@ -77,3 +77,57 @@ def test_a_mismatch_highlights_every_wrong_group_of_the_typed_text() -> None:
7777

7878
field.clear()
7979
assert list(_widgets(page, Gtk.FlowBox)) == []
80+
81+
82+
def _settle() -> None:
83+
while GLib.MainContext.default().iteration(False):
84+
pass
85+
86+
87+
def _groups(text: str) -> list[str]:
88+
return text.split(" ")
89+
90+
91+
def test_a_mismatch_locks_every_group_that_matched() -> None:
92+
_page, field, _typed, _finished = _read_back(ConfirmationResult(False, (2, 12)))
93+
field.prefill(SHARE)
94+
field.emit("activate")
95+
before = _groups(field.get_text())
96+
97+
edited = before.copy()
98+
edited[0], edited[2] = "XXXX", "YYYY"
99+
field.set_text(" ".join(edited))
100+
_settle()
101+
assert field.get_text() == " ".join(before)
102+
103+
edited = before.copy()
104+
edited[1] = "QQQQ"
105+
field.set_text(" ".join(edited))
106+
_settle()
107+
assert field.get_text() == " ".join(edited)
108+
109+
110+
def test_an_open_group_keeps_its_size_and_the_card_its_length() -> None:
111+
_page, field, _typed, _finished = _read_back(ConfirmationResult(False, (12,)))
112+
field.prefill(SHARE)
113+
field.emit("activate")
114+
before = _groups(field.get_text())
115+
116+
field.delete_text(56, 57) # the second character of group 12
117+
_settle()
118+
after = _groups(field.get_text())
119+
assert after[:11] == before[:11] and after[11] == before[11][0] + before[11][2:] + "?"
120+
121+
field.insert_text("WXYZ", 55)
122+
_settle()
123+
assert _groups(field.get_text())[11] == "WXYZ" and len(field.get_text()) == len(" ".join(before))
124+
125+
126+
def test_clearing_unlocks_the_card() -> None:
127+
_page, field, _typed, _finished = _read_back(ConfirmationResult(False, (2,)))
128+
field.prefill(SHARE)
129+
field.emit("activate")
130+
field.clear()
131+
field.prefill(SHARE[:20])
132+
_settle()
133+
assert reading.normalize(field.get_text()) == SHARE[:20].upper()

0 commit comments

Comments
 (0)