Skip to content
Open
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
2 changes: 1 addition & 1 deletion docs/developer/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ documentation and enforcement update.
installed as `codex32[gui]` and started by `codex32-gui`. It is a client of the
surface above and of the private Core adapter; nothing in `src/codex32/` imports
it, and the base install keeps its property of having no third-party runtime
dependency. It carries its own budget of 1,800 logical review lines, separate
dependency. It carries its own budget of 2,050 logical review lines, separate
from the 5,000 above. Its own boundaries are documented in
[`gui.md`](gui.md) and enforced by `tests/test_gui_boundaries.py`.

Expand Down
40 changes: 23 additions & 17 deletions docs/developer/gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,34 @@ Design and screen mock-ups: `docs/planning/gui-plan.md` and

| File | Logical lines | What it holds |
|---|---:|---|
| `reading.py` | 131 | What a field of codex32 text means, and the repair policy. No toolkit. |
| `wallet_setup.py` | 243 | Every Bitcoin Core call, including the passphrase and the relock. No toolkit. |
| `work.py` | 67 | One background operation at a time. |
| `entry.py` | 73 | The `Gtk.Entry` subclass that applies `reading.py`. |
| `pages.py` | 1225 | One function per screen: widgets and wording, no decisions. |
| `app.py` | 43 | The window. |
| `reading.py` | 154 | What a field of codex32 text means, and the repair policy. No toolkit. |
| `wallet_setup.py` | 263 | Every Bitcoin Core call, including the passphrase and the relock. No toolkit. |
| `work.py` | 65 | One background operation at a time. |
| `entry.py` | 111 | The `Gtk.Entry` subclasses that apply `reading.py`, keep a selection off the primary clipboard, and refuse pasted text. |
| `pages.py` | 1344 | One function per screen: widgets and wording, no decisions. |
| `app.py` | 40 | The window. |
| `style.py` | 40 | The stylesheet, as a string. |
| `__init__.py`, `__main__.py` | 26 | Version pinning, the accessibility setting, and the entry point. |

The first three are where review effort belongs: they are the only modules that
decide anything, they total 441 lines, and none of them imports a toolkit, so
`tests/test_gui_reading.py` and `tests/test_gui_wallet_setup.py` cover them
without a display and run in ordinary CI. `tools/gui_walkthrough.py` drives the
real widgets through every task under a throwaway X server and is the cheapest
way to see the screens without a desktop.

The package carries its own budget of 2,000 logical lines, separate from the
| `__init__.py`, `__main__.py` | 28 | Version pinning, the accessibility setting, and the entry point. |

The first three are where review effort belongs: they decide what text means,
what Bitcoin Core is told, and when work runs. They total 482 lines, and none of
them imports a toolkit, so `tests/test_gui_reading.py` and
`tests/test_gui_wallet_setup.py` cover them without a display and run in
ordinary CI. `entry.py` comes next: its primary-clipboard guard keeps a
selected card off the clipboard, and its paste refusal makes the read-back come
from the paper. `tests/test_gui_entry.py` and `tests/test_gui_read_back.py`
check both under a display. `tools/gui_walkthrough.py` drives the real widgets through every task
under a throwaway X server and is the cheapest way to see the screens without a
desktop.

The package carries its own budget of 2,050 logical lines, separate from the
5,000 the installed library keeps, and `tests/test_gui_boundaries.py` enforces
it. The plan proposed 1,000 before the screens were written and the budget was
1,800 before the security review of 2026-09-19; that review's remediations are
about 250 lines, and the rest of the difference is user-facing wording in
`pages.py`, which is the first priority this program was built for.
`pages.py`, which is the first priority this program was built for. It was
2,000 until the card read-back rework of 2026-10-07, which added fixed
correction boxes, group alignment and paste refusal.
Comment on lines +41 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the review map for the expanded modules

This note records the read-back rework, but the review map immediately above still lists reading.py and entry.py at 131 and 73 logical lines; using the test's counting rule they are now 154 and 111. It also still says only the first three modules make decisions even though entry.py now owns the primary-selection and paste-refusal security behavior, so reviewers following the documented order can under-review the newly added boundary logic. Update the table, totals, and review guidance alongside the budget rationale.

Useful? React with 👍 / 👎.


## Claims, and how to check each one

Expand Down
6 changes: 1 addition & 5 deletions src/codex32_gui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,7 @@ def do_activate(self) -> None:
view = Adw.NavigationView()
view.push(pages.home(view))
window = Adw.ApplicationWindow(
application=self,
title="codex32",
default_width=880,
default_height=620,
content=view,
application=self, title="codex32", default_width=880, default_height=620, content=view
)
window.present()

Expand Down
92 changes: 71 additions & 21 deletions src/codex32_gui/entry.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""The one field that interprets codex32 keystrokes."""
"""The fields that interpret codex32 keystrokes."""

from __future__ import annotations

from dataclasses import replace

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 (
Expand All @@ -21,7 +21,39 @@
)


class Codex32Entry(Gtk.Entry):
class PrivateEntry(Gtk.Entry):
"""An entry whose selected text does not stay on the primary clipboard."""

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:
# Selecting text hands it to the primary selection, where a clipboard
# manager would copy the card into a history file on disk. GTK publishes
# it again on focus and on any selection, not only when the selection
# moves, so the clipboard itself is watched while this entry is on screen
# and the copy is taken back once GTK has finished. The selection still works.
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):
"""A Gtk.Entry that can only ever hold grouped uppercase codex32 text."""

__gtype_name__ = "Codex32Entry"
Expand All @@ -30,14 +62,12 @@ def __init__(self, *, accepted: tuple[str, ...] = (), length: int | None = None)
super().__init__()
self._accepted, self._length, self._pending = accepted, length, 0
self._limit = (length or TEXT_LENGTHS[-1]) + SLACK
self._dropped, self._rewriting, self._unpublishing = "", False, 0
self._dropped, self._rewriting = "", False
self.set_hexpand(True)
self.add_css_class("card-entry")
self.set_input_hints(Gtk.InputHints.NO_SPELLCHECK | Gtk.InputHints.NO_EMOJI)
self.set_text(PREFIX)
self.connect("changed", self._schedule)
self.connect("notify::selection-bound", self._unpublish)
self.connect("notify::cursor-position", self._unpublish)

def prefill(self, text: str) -> None:
"""Offer a known header that the operator may still overtype."""
Expand All @@ -56,21 +86,6 @@ def clear(self) -> None:
self._dropped = ""
self.set_text(PREFIX)

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,
Expand All @@ -94,3 +109,38 @@ def _reformat(self) -> bool:
self._rewriting = False
self.set_position(kept + max(kept - 1, 0) // GROUP)
return False


class GroupEntry(PrivateEntry):
"""One group of a card being corrected in place, shown in capitals."""

__gtype_name__ = "GroupEntry"

def __init__(self, text: str) -> None:
super().__init__(
text=text, width_chars=GROUP + 1, max_width_chars=GROUP + 1, max_length=GROUP + SLACK
)
self.add_css_class("card-entry")
self.set_input_hints(Gtk.InputHints.NO_SPELLCHECK | Gtk.InputHints.NO_EMOJI)
refuse_paste(self)
self.connect("changed", lambda _entry: GLib.idle_add(self._capitals))
Comment on lines +123 to +126

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear primary selections from correction boxes

When the operator selects text in a highlighted mismatch box—for example, before replacing the group—GroupEntry lacks the notify::selection-bound and notify::cursor-position hooks used by Codex32Entry to clear GTK's primary selection. The selected recovery fragment therefore remains published long enough for a clipboard manager to persist it, rather than being reclaimed on the next main-loop turn as promised by the GUI security model; reuse the same unpublishing behavior for these new entries.

AGENTS.md reference: AGENTS.md:L78-L82

Useful? React with 👍 / 👎.


def _capitals(self) -> bool:
position, text = self.get_position(), self.get_text()
if text != text.upper():
self.set_text(text.upper())
self.set_position(position)
return False


def refuse_paste(entry: Gtk.Entry) -> None:
"""Take only keystrokes, so a card is read back from the paper and not pasted from elsewhere."""
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) # Primary paste
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): # Dragged-in text
text.remove_controller(controller)
Loading
Loading