diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index 6ed80813..57fd21ad 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -107,6 +107,7 @@ def _page( content: Gtk.Widget, *, actions: Gtk.Widget | None = None, + enter: Sequence[Gtk.Widget] = (), can_pop: bool = True, ) -> Adw.NavigationPage: scroller = Gtk.ScrolledWindow(hscrollbar_policy=Gtk.PolicyType.NEVER, vexpand=True) @@ -116,11 +117,35 @@ def _page( bars.set_content(scroller) if actions is not None: bars.add_bottom_bar(actions) + _on_enter(content, enter or (actions.get_last_child(),)) page = Adw.NavigationPage(title=title, child=bars) page.set_can_pop(can_pop) return page +def _texts(widget: Gtk.Widget) -> list[Gtk.Widget]: + found, child = [widget] if isinstance(widget, Gtk.Text) else [], widget.get_first_child() + while child is not None: + found, child = found + _texts(child), child.get_next_sibling() + return found + + +def _on_enter(content: Gtk.Widget, buttons: Sequence[Gtk.Widget]) -> None: + """Enter moves on as at a CLI prompt: to the next field, and from the last to the first button that can act.""" + texts = _texts(content) + + def press(text: Gtk.Widget) -> None: + later = [item for item in texts[texts.index(text) + 1 :] if item.get_mapped()] + ready = [button for button in buttons if button.is_sensitive()] + if later: + later[0].grab_focus() + elif ready: + ready[0].emit("clicked") + + for text in texts: + text.connect("activate", press) + + def _column(*children: Gtk.Widget) -> Gtk.Box: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=14, margin_top=18, margin_bottom=18) for child in children: @@ -1310,7 +1335,7 @@ def following() -> Adw.NavigationPage: ) ) content = _column(_title(heading, body), *progress, field, status) - page = _page(title, content, actions=_actions(fix, go)) + page = _page(title, content, actions=_actions(fix, go), enter=(go, fix)) _forget_when_gone(view, page, field.clear) update() return page diff --git a/tests/test_gui_boundaries.py b/tests/test_gui_boundaries.py index 1d73c9c7..4c66465a 100644 --- a/tests/test_gui_boundaries.py +++ b/tests/test_gui_boundaries.py @@ -33,7 +33,7 @@ } ) CORE_ADAPTER = "codex32._bitcoin_core" -BUDGET = 2050 +BUDGET = 2070 def _package() -> Path: diff --git a/tools/gui_walkthrough.py b/tools/gui_walkthrough.py index b6b4a44f..705ae77b 100644 --- a/tools/gui_walkthrough.py +++ b/tools/gui_walkthrough.py @@ -20,7 +20,7 @@ gi.require_version("Adw", "1") gi.require_version("Gtk", "4.0") -from gi.repository import Gdk, GLib, Gtk +from gi.repository import Adw, Gdk, GLib, Gtk from codex32_gui import app, pages, reading, wallet_setup @@ -82,6 +82,12 @@ def press(page: Any, label: str) -> None: ) +def enter(field: Any) -> None: + """Press Enter in a text field, as its key binding does.""" + field.get_delegate().emit("activate") + settle() + + def labels(page: Any) -> list[str]: return [item.get_label() for item in walk(page) if isinstance(item, Gtk.Label) and item.get_label()] @@ -147,6 +153,7 @@ def do_activate(self) -> None: self.new_card_done, self.seed_entry, self.seed_shown, + self.form, ] GLib.timeout_add(300, self.pump) @@ -177,6 +184,8 @@ def typing(self) -> bool: item for item in walk(page) if isinstance(item, Gtk.Button) and item.get_label() == "Continue" ) check("nothing may be submitted yet", not go.get_sensitive()) + enter(field) + check("so Enter stays on the page", self.page() is page) field.set_text("ms12nameacd") settle() check("text is uppercased and grouped", field.get_text() == "MS12 NAME ACD", field.get_text()) @@ -199,7 +208,7 @@ def typing(self) -> bool: field.set_text(SHARE_C) settle() check("a valid card may be submitted", go.get_sensitive()) - go.emit("clicked") + enter(field) return True def intact(self) -> bool: @@ -229,7 +238,8 @@ def damaged(self) -> bool: if isinstance(item, Gtk.Button) and item.get_label() == "Suggest a repair" ) check("a repair is offered for an unreadable character", fix.get_sensitive()) - fix.emit("clicked") + enter(field) + check("and Enter asks for it", self.page() is not page) return True def candidate(self) -> bool: @@ -375,7 +385,7 @@ def read_back(self) -> bool: check("selecting a card does not publish it", _primary_is_empty(), _primary()) field.set_text(self.made[:-4] + "QQQQ") settle() - press(page, "Confirm card") + enter(field) check( "a mistyped group is named and refused", any("Group 12 does not match" in text for text in labels(page)), @@ -383,7 +393,7 @@ def read_back(self) -> bool: ) field.set_text(self.made.lower()) settle() - press(page, "Confirm card") + enter(field) return True def new_card_done(self) -> bool: @@ -406,14 +416,14 @@ def seed_entry(self) -> bool: page = self.page() field_of(page).set_text(text) settle() - press(page, "Continue") + enter(field_of(page)) return True def seed_shown(self) -> bool: page = self.page() if page.get_title() != "Master seed": return False - check("two cards recover the published secret", card(page) == SECRET_S, card(page)) + check("two cards recover the published secret by Enter alone", card(page) == SECRET_S, card(page)) check( "and the screen warns before anything else", any("take every coin" in text for text in labels(page)), @@ -421,6 +431,22 @@ def seed_shown(self) -> bool: ) return True + def form(self) -> bool: + pressed: list[bool] = [] + first, second, hidden = Adw.EntryRow(), Adw.PasswordEntryRow(), Adw.EntryRow(visible=False) + action = pages._button("Create", lambda: pressed.append(True)) + content = pages._column(first, hidden, second) + self.view.push(pages._page("Form", content, actions=pages._actions(action))) + settle() + enter(first) + check("Enter moves on to the next shown field", second.get_delegate().has_focus() and not pressed) + enter(second) + check("and in the last field presses the page's action", pressed == [True]) + action.set_sensitive(False) + enter(second) + check("but never an action that is switched off", pressed == [True]) + return True + def main() -> int: wallet_setup.connect = _connect # type: ignore[assignment]