diff --git a/src/codex32_gui/app.py b/src/codex32_gui/app.py index 07835bce..0242e1e2 100644 --- a/src/codex32_gui/app.py +++ b/src/codex32_gui/app.py @@ -1,5 +1,3 @@ -"""The window: one navigation view, one stylesheet, and no command arguments.""" - from __future__ import annotations import sys @@ -15,8 +13,6 @@ class Application(Adw.Application): - """One non-unique window with a stable desktop identity and no recent list or files.""" - def __init__(self) -> None: super().__init__(application_id=APP_ID, flags=Gio.ApplicationFlags.NON_UNIQUE) @@ -30,18 +26,11 @@ 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, - ) + window = Adw.ApplicationWindow(application=self, title="codex32", default_width=880, content=view) window.present() def main(argv: Sequence[str] | None = None) -> int: - """Open the window. Arguments are refused so that no secret can be passed in one.""" arguments = list(sys.argv[1:] if argv is None else argv) if arguments == ["--version"]: print(__version__) diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index fbfdd1c8..1211587a 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -12,7 +12,7 @@ from dataclasses import dataclass from typing import Literal -from gi.repository import Adw, GLib, Gtk +from gi.repository import Adw, GLib, Gtk, Pango from codex32 import ( ConfirmationResult, @@ -41,7 +41,7 @@ _ICON_STYLE = {DONE_ICON: "success", "dialog-error-symbolic": "error"} SEED_SIZES = ((16, "128-bit seed, 48 characters"), (32, "256-bit seed, 74 characters")) PRESETS = ( - (2, 3, "Three cards, any two recover (recommended)"), + (2, 3, "Three cards, any two recover"), (3, 5, "Five cards, any three recover"), (0, 1, "One card"), ) @@ -79,7 +79,7 @@ ) CARDS_SAFE = ( "Your cards are unharmed and still recover this wallet. Nothing was written onto them and " - "nothing about them changed. When Bitcoin Core is ready, choose \u201cRestore my wallet\u201d " + "nothing about them changed. When Bitcoin Core is ready, choose “Restore my wallet” " "and enter them. Do not set up a new wallet: that would make a different backup." ) @@ -104,8 +104,10 @@ def _page( *, actions: Gtk.Widget | None = None, can_pop: bool = True, + natural_height: bool = False, ) -> Adw.NavigationPage: scroller = Gtk.ScrolledWindow(hscrollbar_policy=Gtk.PolicyType.NEVER, vexpand=True) + scroller.set_propagate_natural_height(natural_height) scroller.set_child(Adw.Clamp(maximum_size=700, child=content, margin_start=18, margin_end=18)) bars = Adw.ToolbarView() bars.add_top_bar(Adw.HeaderBar()) @@ -185,17 +187,18 @@ def _card( *, highlight_class: str = "guessed", ) -> Gtk.FlowBox: - """Show one card the way wallets.md asks: uppercase, in four-character windows.""" + """Show one card as uppercase four-character groups, wrapping only when needed.""" text = text.upper() + groups = tuple(text[start : start + reading.GROUP] for start in range(0, len(text), reading.GROUP)) flow = Gtk.FlowBox(selection_mode=Gtk.SelectionMode.NONE, column_spacing=8, row_spacing=8) flow.set_homogeneous(True) - flow.set_min_children_per_line(4) - flow.set_max_children_per_line(4) + flow.set_min_children_per_line(1) + flow.set_max_children_per_line(len(groups)) flow.set_hexpand(True) - for start in range(0, len(text), reading.GROUP): - label = Gtk.Label(label=text[start : start + reading.GROUP], halign=Gtk.Align.CENTER) + for position, group in enumerate(groups): + label = Gtk.Label(label=group, halign=Gtk.Align.CENTER) label.add_css_class("card-group") - if start // reading.GROUP in highlighted: + if position in highlighted: label.add_css_class(highlight_class) flow.append(label) return flow @@ -220,6 +223,24 @@ def _rows(title: str, values: Sequence[tuple[str, str]]) -> Adw.PreferencesGroup return group +def _compact_rows(title: str, values: Sequence[tuple[str, str]]) -> Gtk.Box: + """Show a dense two-column record without hiding any selectable values.""" + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + heading = Gtk.Label(label=title, xalign=0.0) + heading.add_css_class("heading") + box.append(heading) + grid = Gtk.Grid(column_spacing=18, row_spacing=3, hexpand=True) + for position, (label, value) in enumerate(values): + name = Gtk.Label(label=label, xalign=0.0) + name.add_css_class("dim-label") + detail = Gtk.Label(label=value, xalign=1, selectable=True, wrap=True, max_width_chars=28) + detail.set_wrap_mode(Pango.WrapMode.WORD_CHAR) + grid.attach(name, 0, position, 1, 1) + grid.attach(detail, 1, position, 1, 1) + box.append(grid) + return box + + def _forget_when_gone(view: Adw.NavigationView, page: Adw.NavigationPage, clear: Callable[[], None]) -> None: """Clear recovery text as soon as its page leaves the navigation stack.""" handlers: list[int] = [] @@ -381,7 +402,7 @@ def home(view: Adw.NavigationView) -> Adw.NavigationPage: group, _note("Everything happens on this computer. codex32 never connects to the internet."), ) - return _page("codex32", content) + return _page("codex32", content, natural_height=True) # --- Bitcoin Core preflight ------------------------------------------------ @@ -904,17 +925,18 @@ def go() -> None: dialog.present(view) content = _column( - _title("Create a wallet for these keys", "Bitcoin Core makes it; codex32 fills it in."), + _title("Create a wallet for these keys", "Choose whether to encrypt the Bitcoin Core wallet file."), group, status, _note( - "Your passphrase goes straight to Bitcoin Core on standard input and nowhere else. It is " - "never saved, never written to a file, and never shown in the list of running programs." + "Wallet encryption protects this Bitcoin Core wallet file while it is locked. It does not " + "protect your recovery cards or make a compromised computer safe." ), _note( - "Forgetting this passphrase does not lose your bitcoin: your cards still recover the seed. " - "It protects the wallet on this computer.", - "success", + "If you forget the wallet passphrase, Bitcoin Core cannot unlock this wallet file. Your " + "codex32 recovery cards can recreate the wallet's keys in a new Bitcoin Core wallet, so keep " + "the cards safe and separate.", + "warning", ), ) page = _page("New wallet", content, actions=_actions(_button("Create", go, style="suggested-action"))) @@ -970,7 +992,7 @@ def go() -> None: content = _column( _title( - f"The wallet \u201c{wallet.name}\u201d is locked", + f"The wallet “{wallet.name}” is locked", "Bitcoin Core needs its passphrase before your keys can be written into it.", ), group, @@ -1035,7 +1057,7 @@ def _finished_page(view: Adw.NavigationView, record: Record, restoring: bool = F dated = () if restoring else (("Approximate creation date", time.strftime("%Y-%m-%d")),) content = _column( _title(heading, asked, DONE_ICON), - _rows( + _compact_rows( "Wallet identity", ( ("Backup identifier", record.identifier), diff --git a/tests/test_gui_boundaries.py b/tests/test_gui_boundaries.py index b79044ff..bfad256d 100644 --- a/tests/test_gui_boundaries.py +++ b/tests/test_gui_boundaries.py @@ -58,7 +58,7 @@ def _imports(tree: ast.AST) -> set[str]: @pytest.mark.parametrize("path", _modules(), ids=lambda path: path.name) def test_the_gui_draws_no_entropy_opens_no_socket_and_touches_no_file(path: Path) -> None: - imported = _imports(ast.parse(path.read_text())) + imported = _imports(ast.parse(path.read_text(encoding="utf-8"))) assert not {name.split(".")[0] for name in imported} & FORBIDDEN, sorted(imported) @@ -66,7 +66,7 @@ def test_the_gui_draws_no_entropy_opens_no_socket_and_touches_no_file(path: Path def test_nothing_reads_or_writes_a_file(path: Path) -> None: called = { node.func.id - for node in ast.walk(ast.parse(path.read_text())) + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))) if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) } assert "open" not in called and "eval" not in called and "exec" not in called @@ -74,13 +74,13 @@ def test_nothing_reads_or_writes_a_file(path: Path) -> None: @pytest.mark.parametrize("path", _modules(), ids=lambda path: path.name) def test_only_one_module_speaks_to_bitcoin_core(path: Path) -> None: - imported = _imports(ast.parse(path.read_text())) + imported = _imports(ast.parse(path.read_text(encoding="utf-8"))) assert (CORE_ADAPTER in imported) == (path.name == "wallet_setup.py"), sorted(imported) def test_the_accessibility_bus_is_turned_off_before_gtk_starts() -> None: """GTK otherwise publishes every label and entry, seed and passphrase included.""" - source = (_package() / "__init__.py").read_text() + source = (_package() / "__init__.py").read_text(encoding="utf-8") assert 'GLib.setenv("GTK_A11Y", "none", False)' in source tree = ast.parse(source) settings = [node for node in ast.walk(tree) if isinstance(node, ast.Import | ast.ImportFrom)] @@ -90,26 +90,27 @@ def test_the_accessibility_bus_is_turned_off_before_gtk_starts() -> None: @pytest.mark.parametrize("path", _modules(), ids=lambda path: path.name) def test_nothing_but_the_version_pin_may_import_a_module_by_name(path: Path) -> None: """`importlib` would reach any of the forbidden modules without naming one.""" - imported = {name.split(".")[0] for name in _imports(ast.parse(path.read_text()))} + imported = {name.split(".")[0] for name in _imports(ast.parse(path.read_text(encoding="utf-8")))} assert "importlib" not in imported or path.name == "__init__.py", sorted(imported) def test_the_parts_that_decide_something_need_no_toolkit() -> None: for name in ("reading.py", "wallet_setup.py", "style.py"): - imported = _imports(ast.parse((_package() / name).read_text())) + imported = _imports(ast.parse((_package() / name).read_text(encoding="utf-8"))) assert not any(item == "gi" or item.startswith("gi.") for item in imported), name def test_the_library_does_not_depend_on_the_gui() -> None: library = Path(importlib.import_module("codex32").__file__ or "").parent for path in library.rglob("*.py"): - assert "codex32_gui" not in path.read_text(), path + assert "codex32_gui" not in path.read_text(encoding="utf-8"), path def test_the_gui_keeps_its_own_size_budget() -> None: counts = { path.name: sum( - bool(line.strip()) and not line.lstrip().startswith("#") for line in path.read_text().splitlines() + bool(line.strip()) and not line.lstrip().startswith("#") + for line in path.read_text(encoding="utf-8").splitlines() ) for path in _modules() } @@ -117,7 +118,7 @@ def test_the_gui_keeps_its_own_size_budget() -> None: def test_read_only_poll_threads_do_not_keep_the_process_alive() -> None: - source = (_package() / "work.py").read_text() + source = (_package() / "work.py").read_text(encoding="utf-8") tree = ast.parse(source) functions = {node.name: node for node in tree.body if isinstance(node, ast.FunctionDef)} diff --git a/tools/gui_walkthrough.py b/tools/gui_walkthrough.py index 8c68ba9c..bbcd667b 100644 --- a/tools/gui_walkthrough.py +++ b/tools/gui_walkthrough.py @@ -13,6 +13,7 @@ from __future__ import annotations from collections.abc import Callable, Iterator +from itertools import pairwise from typing import Any import gi @@ -294,38 +295,45 @@ def short_repair_candidate(self) -> bool: groups = [item for item in walk(page) if "card-group" in item.get_css_classes()] flow = next(item for item in walk(page) if isinstance(item, Gtk.FlowBox)) - def row_positions() -> list[float]: + def positions() -> list[tuple[float, float]]: children = [flow.get_child_at_index(index) for index in range(len(groups))] - return [child.compute_bounds(flow)[1].origin.y for child in children] - - def four_columns(rows_y: list[float]) -> bool: - return len(set(rows_y)) == (len(groups) + 3) // 4 and all( - rows_y[index] == rows_y[(index // 4) * 4] for index in range(len(rows_y)) + return [ + (rect.origin.x, rect.origin.y) + for child in children + for rect in (child.compute_bounds(flow)[1],) + ] + + def reading_order(points: list[tuple[float, float]]) -> bool: + return all( + before_y <= y and (before_y < y or before_x < x) + for (before_x, before_y), (x, y) in pairwise(points) ) - rows_y = row_positions() + points = positions() 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), ) check( - "card display uses four aligned groups per row", - flow.get_min_children_per_line() == 4 - and flow.get_max_children_per_line() == 4 - and four_columns(rows_y), - rows_y, + "card groups follow reading order", + flow.get_min_children_per_line() == 1 + and flow.get_max_children_per_line() == len(groups) + and reading_order(points), + points, ) window = self.get_active_window() window.set_default_size(640, 620) settle() - narrow = row_positions() + narrow = positions() window.set_default_size(1100, 620) settle() - wide = row_positions() + wide = positions() check( - "card columns stay aligned when the window is resized", - four_columns(narrow) and four_columns(wide), + "card groups reflow in reading order when resized", + reading_order(narrow) + and reading_order(wide) + and len({y for _, y in narrow}) >= len({y for _, y in wide}), ) press(page, "It does not match my card") settle()