diff --git a/gatelock/gatelock/__init__.py b/gatelock/gatelock/__init__.py index ebd1307..0488a62 100644 --- a/gatelock/gatelock/__init__.py +++ b/gatelock/gatelock/__init__.py @@ -55,13 +55,26 @@ from gatelock._window import ( SpaceStep, TypeRole, ) +from gatelock.widget_group import WidgetGroup +from gatelock.widgets import ( + DEFAULT_WRAP, + ButtonStyle, + ButtonVariant, + RowStyle, + heading, + make_button, + row, +) __all__ = [ + "DEFAULT_WRAP", "RANK_DIET_GUARD", "RANK_SCREEN_LOCKER", "RANK_WAKE_ALARM", "Arbiter", "ArbiterVerdict", + "ButtonStyle", + "ButtonVariant", "Claim", "EscapeDraft", "EscapeHistory", @@ -80,6 +93,7 @@ __all__ = [ "RandrBackend", "RecoveryLoop", "RecoveryReport", + "RowStyle", "ScrollableSurface", "SpaceStep", "SurfaceBuilder", @@ -87,6 +101,7 @@ __all__ = [ "SurfaceInfo", "SurfaceSet", "TypeRole", + "WidgetGroup", "assert_not_under_pytest", "bind_activate", "bind_cancel", @@ -95,11 +110,14 @@ __all__ = [ "enumerate_outputs", "escape_text_tab_trap", "grab_strength", + "heading", + "make_button", "measure_fit", "mirror_text_widgets", "needs_backdrop_root", "parse_xrandr_query", "report_fit", "restore_vt_switching", + "row", "wait_for_x_server", ] diff --git a/gatelock/gatelock/tests/test_widget_group.py b/gatelock/gatelock/tests/test_widget_group.py new file mode 100644 index 0000000..8e7e3be --- /dev/null +++ b/gatelock/gatelock/tests/test_widget_group.py @@ -0,0 +1,172 @@ +"""A group holds every per-output copy, and survives losing one. + +Membership and lifecycle: what a group contains, what ``discard``/``clear``/ +``destroy`` each mean, and the guarantee that one destroyed surface cannot +abort a repaint on the others. Focus, reads and bindings live in +``test_widget_group_ops.py``. + +Run against real Tk because the central guarantee is about ``TclError`` from +genuinely destroyed widgets. A ``MagicMock`` raises whatever it was told to +raise, so it can only confirm the test author's belief about when Tk fails; +destroying a real widget and then configuring it is the actual condition +these guards exist for. +""" + +from __future__ import annotations + +import os +import tkinter as tk +from typing import TYPE_CHECKING + +import pytest + +from gatelock.widget_group import WidgetGroup + +if TYPE_CHECKING: + from collections.abc import Iterator + +pytestmark = pytest.mark.skipif( + not os.environ.get("DISPLAY"), + reason="needs a real X display; run under xvfb-run in a headless checkout", +) + + +@pytest.fixture +def root() -> Iterator[tk.Tk]: + """A toplevel to parent the per-output copies.""" + made = tk.Tk() + made.geometry("400x300+0+0") + made.focus_force() + made.update_idletasks() + yield made + made.destroy() + + +@pytest.fixture +def group(root: tk.Tk) -> WidgetGroup[tk.Label]: + """A label fanned out across three outputs.""" + made: WidgetGroup[tk.Label] = WidgetGroup() + for name in ("DP-0", "HDMI-0", "DP-2"): + made.add(name, tk.Label(root, text="start")) + return made + + +def test_an_empty_group_has_no_first_copy() -> None: + """Zero outputs is a state the apps reach, not an IndexError.""" + empty: WidgetGroup[tk.Label] = WidgetGroup() + + assert empty.first is None + assert len(empty) == 0 + assert list(empty) == [] + assert empty.outputs == () + + +def test_operations_on_an_empty_group_are_no_ops() -> None: + """A gate with no connected output must not crash on a repaint.""" + empty: WidgetGroup[tk.Label] = WidgetGroup() + + empty.configure(text="x") + empty.pack() + empty.pack_forget() + empty.focus_set() + empty.focus_force() + empty.destroy() + + assert empty.first is None + + +def test_configure_reaches_every_copy(group: WidgetGroup[tk.Label]) -> None: + """One logical update lands on all three monitors.""" + group.configure(text="12:30") + + assert [widget.cget("text") for widget in group] == ["12:30"] * 3 + + +def test_config_is_an_alias_for_configure(group: WidgetGroup[tk.Label]) -> None: + """Tk spells it both ways and the donors were split; both work.""" + group.config(text="aliased") + + assert [widget.cget("text") for widget in group] == ["aliased"] * 3 + + +def test_a_dead_copy_does_not_stop_the_live_ones( + group: WidgetGroup[tk.Label], +) -> None: + """The whole point: a destroyed surface must not abort the repaint.""" + doomed = group.first + assert doomed is not None + doomed.destroy() + + group.configure(text="after") + + survivors = [widget.cget("text") for widget in group if widget.winfo_exists()] + assert survivors == ["after", "after"] + + +def test_discard_drops_only_the_named_output(group: WidgetGroup[tk.Label]) -> None: + """Tearing down one surface leaves the rest of the group intact.""" + group.discard("HDMI-0") + + assert group.outputs == ("DP-0", "DP-2") + assert len(group) == 2 + + +def test_discard_does_not_destroy_the_widget( + root: tk.Tk, group: WidgetGroup[tk.Label] +) -> None: + """Surface teardown already destroys its tree; twice is a TclError storm.""" + copies = list(group) + group.discard("HDMI-0") + root.update_idletasks() + + assert all(widget.winfo_exists() for widget in copies) + + +def test_discarding_an_unknown_output_is_harmless( + group: WidgetGroup[tk.Label], +) -> None: + """An output that was never added can still be torn down.""" + group.discard("VGA-9") + + assert len(group) == 3 + + +def test_clear_empties_without_destroying( + root: tk.Tk, group: WidgetGroup[tk.Label] +) -> None: + """Forgetting the copies is distinct from destroying them.""" + copies = list(group) + group.clear() + root.update_idletasks() + + assert len(group) == 0 + assert all(widget.winfo_exists() for widget in copies) + + +def test_destroy_empties_the_group(root: tk.Tk, group: WidgetGroup[tk.Label]) -> None: + """Destroying really does destroy, and leaves nothing behind.""" + copies = list(group) + group.destroy() + root.update_idletasks() + + assert len(group) == 0 + assert not any(widget.winfo_exists() for widget in copies) + + +def test_iteration_preserves_insertion_order(group: WidgetGroup[tk.Label]) -> None: + """Order is the order surfaces were built, so reads are deterministic.""" + assert group.outputs == ("DP-0", "HDMI-0", "DP-2") + + +def test_a_seeded_group_keeps_its_pairs(root: tk.Tk) -> None: + """Constructing from pairs cannot desynchronise names from widgets. + + The donors kept two parallel lists and one of them could be built with + widgets but no names, which made its strict zip raise ``ValueError``. + Pairing makes that unrepresentable. + """ + label = tk.Label(root) + seeded = WidgetGroup([("DP-0", label)]) + + assert seeded.outputs == ("DP-0",) + assert seeded.first is label diff --git a/gatelock/gatelock/tests/test_widget_group_ops.py b/gatelock/gatelock/tests/test_widget_group_ops.py new file mode 100644 index 0000000..68832f7 --- /dev/null +++ b/gatelock/gatelock/tests/test_widget_group_ops.py @@ -0,0 +1,191 @@ +"""Reading a group, focusing it, and binding across it. + +Focus is the one operation that deliberately does *not* fan out, and +``first_where`` is how a caller reads back a copy that diverged on purpose. +Membership and lifecycle live in ``test_widget_group.py``. + +Run against real Tk: focus delivery and event dispatch are Tk's own +behaviour, which a mock cannot reproduce. +""" + +from __future__ import annotations + +import os +import tkinter as tk +from typing import TYPE_CHECKING + +import pytest + +from gatelock.widget_group import WidgetGroup + +if TYPE_CHECKING: + from collections.abc import Iterator + +pytestmark = pytest.mark.skipif( + not os.environ.get("DISPLAY"), + reason="needs a real X display; run under xvfb-run in a headless checkout", +) + + +@pytest.fixture +def root() -> Iterator[tk.Tk]: + """A toplevel to parent the per-output copies.""" + made = tk.Tk() + made.geometry("400x300+0+0") + made.focus_force() + made.update_idletasks() + yield made + made.destroy() + + +@pytest.fixture +def group(root: tk.Tk) -> WidgetGroup[tk.Label]: + """A label fanned out across three outputs.""" + made: WidgetGroup[tk.Label] = WidgetGroup() + for name in ("DP-0", "HDMI-0", "DP-2"): + made.add(name, tk.Label(root, text="start")) + return made + + +def test_first_where_finds_the_copy_the_user_typed_on(root: tk.Tk) -> None: + """Text copies diverge on purpose; this reads back the one in use.""" + entries: WidgetGroup[tk.Entry] = WidgetGroup() + for name in ("DP-0", "HDMI-0"): + entries.add(name, tk.Entry(root)) + typed = list(entries)[1] + typed.insert(0, "chicken") + + found = entries.first_where(lambda widget: bool(widget.get())) + + assert found is typed + + +def test_first_where_returns_none_when_nothing_matches(root: tk.Tk) -> None: + """A blank answer everywhere is None, not the first blank copy.""" + entries: WidgetGroup[tk.Entry] = WidgetGroup() + entries.add("DP-0", tk.Entry(root)) + + assert entries.first_where(lambda widget: bool(widget.get())) is None + + +def test_first_where_skips_a_dead_copy(root: tk.Tk) -> None: + """A destroyed copy is 'not a match', never an exception.""" + entries: WidgetGroup[tk.Entry] = WidgetGroup() + for name in ("DP-0", "HDMI-0"): + entries.add(name, tk.Entry(root)) + live = list(entries)[1] + live.insert(0, "rice") + next(iter(entries)).destroy() + + assert entries.first_where(lambda widget: bool(widget.get())) is live + + +def test_focus_is_singular_not_fanned_out(root: tk.Tk) -> None: + """X input focus is singular; focusing every copy means the last wins.""" + entries: WidgetGroup[tk.Entry] = WidgetGroup() + for name in ("DP-0", "HDMI-0", "DP-2"): + entry = tk.Entry(root) + entry.pack() + entries.add(name, entry) + root.update() + + entries.focus_set() + root.update() + + assert root.focus_get() is entries.first + + +def test_focus_skips_a_dead_copy(root: tk.Tk) -> None: + """Focus lands on the first copy that actually accepts it.""" + entries: WidgetGroup[tk.Entry] = WidgetGroup() + for name in ("DP-0", "HDMI-0"): + entry = tk.Entry(root) + entry.pack() + entries.add(name, entry) + root.update() + survivor = list(entries)[1] + next(iter(entries)).destroy() + + entries.focus_force() + root.update() + + assert root.focus_get() is survivor + + +def test_bind_reaches_every_copy(root: tk.Tk, group: WidgetGroup[tk.Label]) -> None: + """A binding installed once applies on whichever monitor is clicked.""" + seen: list[str] = [] + group.pack() + root.update() + group.bind("", lambda _event: seen.append("hit")) + + for widget in group: + widget.event_generate("") + root.update() + + assert seen == ["hit"] * 3 + + +def test_bind_adds_rather_than_replacing( + root: tk.Tk, group: WidgetGroup[tk.Label] +) -> None: + """Rebinding on repaint must not silently drop another handler.""" + seen: list[str] = [] + group.pack() + root.update() + group.bind("", lambda _event: seen.append("first")) + group.bind("", lambda _event: seen.append("second")) + + first = group.first + assert first is not None + first.event_generate("") + root.update() + + assert seen == ["first", "second"] + + +def test_bind_can_replace_when_asked(root: tk.Tk, group: WidgetGroup[tk.Label]) -> None: + """The additive default is overridable for a caller that means it.""" + seen: list[str] = [] + group.pack() + root.update() + group.bind("", lambda _event: seen.append("first")) + group.bind("", lambda _event: seen.append("only"), add=False) + + first = group.first + assert first is not None + first.event_generate("") + root.update() + + assert seen == ["only"] + + +def test_pack_and_pack_forget_reach_every_copy( + root: tk.Tk, group: WidgetGroup[tk.Label] +) -> None: + """Showing and hiding a logical widget covers every monitor.""" + group.pack() + root.update_idletasks() + assert all(widget.winfo_manager() == "pack" for widget in group) + + group.pack_forget() + root.update_idletasks() + assert all(widget.winfo_manager() == "" for widget in group) + + +def test_configure_carries_a_callable(root: tk.Tk) -> None: + """kwargs is typed `object`, so `command=` survives the fan-out. + + Two donors typed it ``str`` and had to reach past the group to wire a + button up, which is how a group stops being the single point of update. + """ + pressed: list[str] = [] + buttons: WidgetGroup[tk.Button] = WidgetGroup() + for name in ("DP-0", "HDMI-0"): + buttons.add(name, tk.Button(root, text="go")) + + buttons.configure(command=lambda: pressed.append("hit")) + for widget in buttons: + widget.invoke() + + assert pressed == ["hit", "hit"] diff --git a/gatelock/gatelock/tests/test_widgets.py b/gatelock/gatelock/tests/test_widgets.py new file mode 100644 index 0000000..ac5e345 --- /dev/null +++ b/gatelock/gatelock/tests/test_widgets.py @@ -0,0 +1,225 @@ +"""A button the keyboard can actually press, in colours that always contrast. + +Run against real Tk: the two defects these widgets exist to prevent are Tk's +own defaults -- ```` not being a ``tk.Button`` binding on X11, and a +1px black focus ring that is invisible on the lock palette. A mock reports +whatever it was told, so it cannot fail either check. +""" + +from __future__ import annotations + +import os +import tkinter as tk +from tkinter import font as tkfont +from typing import TYPE_CHECKING + +import pytest + +from gatelock._window import LockConfig +from gatelock.widgets import ( + DEFAULT_WRAP, + ButtonStyle, + ButtonVariant, + RowStyle, + _lighten, + heading, + make_button, + row, +) + +if TYPE_CHECKING: + from collections.abc import Iterator + +pytestmark = pytest.mark.skipif( + not os.environ.get("DISPLAY"), + reason="needs a real X display; run under xvfb-run in a headless checkout", +) + +_VARIANTS: tuple[ButtonVariant, ...] = ("primary", "secondary", "danger") + + +def _font_px(widget: tk.Misc) -> int: + """Return a widget's rendered type size in px. + + ``cget("font")`` gives back Tk's *string* spelling ("Arial -16 bold"), + not the tuple that was passed in, so the size is read through the font + object. Tk encodes pixels as a negative size; this returns the magnitude + so callers can compare sizes the way a reader would. + """ + return abs(int(tkfont.Font(font=widget.cget("font")).actual("size"))) + + +@pytest.fixture +def root() -> Iterator[tk.Tk]: + """A focused toplevel; Xvfb has no WM to assign X focus for us.""" + made = tk.Tk() + made.geometry("600x400+0+0") + made.focus_force() + made.update_idletasks() + yield made + made.destroy() + + +@pytest.fixture +def config() -> LockConfig: + """The default token set.""" + return LockConfig() + + +def test_return_activates_a_button(root: tk.Tk, config: LockConfig) -> None: + """Enter invokes the command -- Tk binds only by itself.""" + pressed: list[str] = [] + button = make_button( + root, + config, + "Log", + lambda: pressed.append("hit"), + ) + button.pack() + button.focus_force() + root.update() + + button.event_generate("") + root.update() + + assert pressed == ["hit"] + + +def test_keypad_enter_activates_a_button(root: tk.Tk, config: LockConfig) -> None: + """The numeric keypad's Enter is a different keysym, and is bound too.""" + pressed: list[str] = [] + button = make_button(root, config, "Log", lambda: pressed.append("x")) + button.pack() + button.focus_force() + root.update() + + button.event_generate("") + root.update() + + assert pressed == ["x"] + + +def test_space_still_activates_a_button(root: tk.Tk, config: LockConfig) -> None: + """Adding must not displace Tk's own class binding. + + Asserted structurally rather than by ``event_generate``: a synthetic + ```` does not drive Tk's *class* bindings under Xvfb -- verified + against a bare ``tk.Button`` with no gatelock involvement, which ignores + it too. So a behavioural assertion here would fail for a reason that has + nothing to do with this widget. ```` is bound *instance*-level, + which is why it can be, and is, exercised for real above. + """ + button = make_button(root, config, "Log", lambda: None) + + assert "" in button.bind_class("Button") + assert button.bind_class("Button", "") + + +@pytest.mark.parametrize("variant", _VARIANTS) +def test_every_variant_draws_text_on_its_own_fill( + root: tk.Tk, config: LockConfig, variant: ButtonVariant +) -> None: + """The text colour comes from the fill, so the pair can never mismatch.""" + button = make_button(root, config, "Go", lambda: None, ButtonStyle(variant=variant)) + + fill = button.cget("bg") + text_color = button.cget("fg") + expected = config.fg if variant == "secondary" else config.on_fill + assert text_color == expected + assert fill != text_color + + +@pytest.mark.parametrize("variant", _VARIANTS) +def test_every_variant_has_a_visible_focus_ring( + root: tk.Tk, config: LockConfig, variant: ButtonVariant +) -> None: + """Tk's default ring is 1px black -- invisible on this palette.""" + button = make_button(root, config, "Go", lambda: None, ButtonStyle(variant=variant)) + + assert int(button.cget("highlightthickness")) == config.focus_thickness + assert str(button.cget("highlightcolor")) == config.focus_ring + + +def test_primary_reads_larger_than_the_other_variants( + root: tk.Tk, config: LockConfig +) -> None: + """Size is the second axis of prominence, alongside colour.""" + primary = make_button(root, config, "Go", lambda: None) + secondary = make_button( + root, config, "Go", lambda: None, ButtonStyle(variant="secondary") + ) + + # Tk font tuples carry a negative (pixel) size; larger text is more + # negative, so compare magnitudes. + assert _font_px(primary) > _font_px(secondary) + + +def test_bold_is_optional(root: tk.Tk, config: LockConfig) -> None: + """A caller can opt out of the bold weight.""" + plain = make_button(root, config, "Go", lambda: None, ButtonStyle(bold=False)) + + assert "bold" not in str(plain.cget("font")) + + +def test_the_active_fill_is_lighter_than_the_resting_fill( + root: tk.Tk, config: LockConfig +) -> None: + """Hover feedback is a lighter fill, not a glow shadow.""" + button = make_button(root, config, "Go", lambda: None) + + assert button.cget("activebackground") == _lighten(config.accent) + assert button.cget("activebackground") != button.cget("bg") + + +def test_lighten_moves_every_channel_toward_white() -> None: + """Blending is per-channel and clamps at white.""" + assert _lighten("#000000", 0.5) == "#808080" + assert _lighten("#ffffff", 0.5) == "#ffffff" + + +def test_heading_uses_the_accent_and_returns_the_label( + root: tk.Tk, config: LockConfig +) -> None: + """A section title is accent-coloured and packed.""" + label = heading(root, config, "Right now") + + assert label.cget("text") == "Right now" + assert str(label.cget("fg")) == config.accent + assert label.winfo_manager() == "pack" + + +def test_row_defaults_to_the_body_foreground(root: tk.Tk, config: LockConfig) -> None: + """An uncoloured row is ordinary text, not a status.""" + label = row(root, config, "Earned all-time: 3") + + assert str(label.cget("fg")) == config.fg + assert int(label.cget("wraplength")) == DEFAULT_WRAP + + +def test_row_honours_an_explicit_status_colour(root: tk.Tk, config: LockConfig) -> None: + """Status colours are load-bearing, so a caller can set one.""" + label = row(root, config, "LOCKED", RowStyle(color=config.danger)) + + assert str(label.cget("fg")) == config.danger + + +def test_row_wraps_at_the_width_it_is_given(root: tk.Tk, config: LockConfig) -> None: + """Wrap width is a parameter, not module state shared between windows.""" + label = row(root, config, "a long explanation", RowStyle(wrap=500)) + + assert int(label.cget("wraplength")) == 500 + + +def test_row_refuses_an_unusably_narrow_wrap(root: tk.Tk, config: LockConfig) -> None: + """Below the floor a "wrapped" line would be one word per line.""" + label = row(root, config, "text", RowStyle(wrap=10)) + + assert int(label.cget("wraplength")) == 320 + + +def test_row_takes_a_type_scale_role(root: tk.Tk, config: LockConfig) -> None: + """Captions are smaller than the default label role.""" + caption = row(root, config, "note", RowStyle(role="caption")) + label = row(root, config, "note") + + assert _font_px(caption) < _font_px(label) diff --git a/gatelock/gatelock/widget_group.py b/gatelock/gatelock/widget_group.py new file mode 100644 index 0000000..ad26b25 --- /dev/null +++ b/gatelock/gatelock/widget_group.py @@ -0,0 +1,183 @@ +"""One logical widget, fanned out across every output's surface. + +A gate covers every monitor the user can see, so each surface needs its own +copy of the same logical widget: one countdown label becomes three real +labels. Every consuming app grew its own version of this (~916 lines across +four repos) and each arrived at a slightly different set of guarantees. +This is the union of what they all actually needed, with the sharp edges +that only some of them had fixed. + +Four behaviours are load-bearing, each of them a bug in at least one donor: + +1. **A dead copy must not raise.** Surfaces are destroyed and rebuilt when + outputs change, and a fan-out that touches a destroyed widget raises + ``TclError`` in the middle of a repaint -- leaving the remaining copies + untouched. Every operation here suppresses it per copy and carries on, so + one dead monitor cannot stop the others updating. +2. **Zero outputs is a real state, not an error.** A group can legitimately + hold nothing (no connected output yet, or every surface torn down), so + :attr:`first` returns ``None`` rather than raising ``IndexError``. +3. **Focus is singular.** ``focus_set``/``focus_force`` deliberately do *not* + fan out: focusing every copy would hand X input focus to whichever surface + happened to be last. They move focus to the first copy that accepts it. +4. **Copies of a text field diverge on purpose.** Entries share a + ``StringVar`` and converge, but a group makes no attempt to mirror + ``tk.Text`` or ``tk.Listbox`` content -- the user answers on whichever + monitor they are looking at, and :meth:`WidgetGroup.first_where` is how a + caller reads back the copy that was actually used. + +Storage is a single list of ``(output_name, widget)`` pairs rather than two +parallel lists. The parallel form let a group be built with widgets but no +output names, so :meth:`discard` -- which zips them strictly -- would raise +``ValueError`` on any group built that way. Pairing them makes that +unrepresentable. +""" + +from __future__ import annotations + +import contextlib +import tkinter as tk +from typing import TYPE_CHECKING, Generic, TypeVar + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator + +_W = TypeVar("_W", bound=tk.Misc) + + +class WidgetGroup(Generic[_W]): + """Every per-output copy of one logical widget. + + Build one per logical widget, :meth:`add` a copy as each surface is + built, and :meth:`discard` an output's copy when its surface is torn + down. Operations fan out across the live copies; reads come from the + first copy that answers. + """ + + def __init__(self, pairs: list[tuple[str, _W]] | None = None) -> None: + """Create a group, optionally seeded with ``(output_name, widget)``.""" + self._pairs: list[tuple[str, _W]] = list(pairs) if pairs else [] + + def add(self, output_name: str, widget: _W) -> None: + """Record ``widget`` as this group's copy on ``output_name``.""" + self._pairs.append((output_name, widget)) + + def discard(self, output_name: str) -> None: + """Forget every copy belonging to ``output_name``. + + Does not destroy the widgets: the surface teardown that prompted this + already destroys its own widget tree, and destroying a second time is + what turns a routine output change into a ``TclError`` storm. + """ + self._pairs = [pair for pair in self._pairs if pair[0] != output_name] + + def clear(self) -> None: + """Forget every copy, without destroying anything.""" + self._pairs = [] + + def __iter__(self) -> Iterator[_W]: + """Iterate the live copies, in the order they were added.""" + return (widget for _name, widget in self._pairs) + + def __len__(self) -> int: + """Return how many copies this group holds.""" + return len(self._pairs) + + @property + def outputs(self) -> tuple[str, ...]: + """Return the output names this group has a copy on.""" + return tuple(name for name, _widget in self._pairs) + + @property + def first(self) -> _W | None: + """Return the first copy, or ``None`` when the group is empty. + + ``None`` rather than an ``IndexError``: a gate with no connected + output is a state the apps reach, not a programming mistake. + """ + return self._pairs[0][1] if self._pairs else None + + def first_where(self, predicate: Callable[[_W], bool]) -> _W | None: + """Return the first copy satisfying ``predicate``, else ``None``. + + This is how a caller reads back a deliberately-diverged widget -- + typically "the first copy whose text is not blank", i.e. the monitor + the user actually typed on. A copy that raises ``TclError`` while + being examined is treated as not matching, so a destroyed surface + cannot break the read. + """ + + def matches(widget: _W) -> bool: + try: + return predicate(widget) + except tk.TclError: + return False + + return next((widget for widget in self if matches(widget)), None) + + def _each(self, action: Callable[[_W], object]) -> None: + """Apply ``action`` to every copy, skipping ones that have died.""" + for widget in self: + with contextlib.suppress(tk.TclError): + action(widget) + + def configure(self, **kwargs: object) -> None: + """Configure every copy. + + ``kwargs`` is typed ``object`` rather than ``str`` so a group can + carry ``command=``; two donors typed it ``str`` and had to + reach past the group to wire a button up. + """ + self._each(lambda widget: widget.configure(**kwargs)) + + # Tk spells it both ways, and the donors were split; keep both so a + # migrated call site does not have to change spelling. + config = configure + + def bind( + self, sequence: str, func: Callable[[tk.Event], object], *, add: bool = True + ) -> None: + """Bind ``sequence`` on every copy. + + ``add`` defaults to True: a group is rebound on repaint, and + replacing bindings by default silently drops handlers another part of + the app installed on the same widget. + """ + self._each(lambda widget: widget.bind(sequence, func, add="+" if add else "")) + + def pack(self, **kwargs: object) -> None: + """Pack every copy with the same options.""" + self._each(lambda widget: widget.pack(**kwargs)) + + def pack_forget(self) -> None: + """Un-pack every copy, leaving the widgets alive.""" + self._each(lambda widget: widget.pack_forget()) + + def destroy(self) -> None: + """Destroy every copy and empty the group.""" + self._each(lambda widget: widget.destroy()) + self.clear() + + def focus_set(self) -> None: + """Focus the first copy that accepts it. + + Deliberately not a fan-out: X input focus is singular, so focusing + every copy just means the last surface wins, unpredictably. + """ + self._focus_one(lambda widget: widget.focus_set()) + + def focus_force(self) -> None: + """Force focus onto the first copy that accepts it.""" + self._focus_one(lambda widget: widget.focus_force()) + + def _focus_one(self, action: Callable[[_W], object]) -> None: + """Apply ``action`` to the first copy that does not raise.""" + + def accepted(widget: _W) -> bool: + try: + action(widget) + except tk.TclError: + return False + return True + + next((widget for widget in self if accepted(widget)), None) diff --git a/gatelock/gatelock/widgets.py b/gatelock/gatelock/widgets.py new file mode 100644 index 0000000..783b6af --- /dev/null +++ b/gatelock/gatelock/widgets.py @@ -0,0 +1,250 @@ +"""Composite widgets every gate app was rebuilding from the same tokens. + +``LockConfig`` already owns the palette and the type scale, but a palette is +not a component: four apps each turned the same tokens into the same button, +the same section heading and the same body row, and drifted apart doing it. +This module is the shared result, extracted only where two or more repos +already had a structurally similar implementation. + +The two traps below are Tk defaults rather than oversights in any one app, +and both copies of the button had to solve them independently: + +1. **```` does nothing on a ``tk.Button``.** Tk's X11 class bindings + give the widget ```` and nothing else, so a focused button is + Space-only and Enter -- the key a user actually reaches for -- silently + does nothing. :func:`make_button` binds it. +2. **The default focus ring is 1px black**, which against ``#211D1B`` reads + as no ring at all, so keyboard users cannot see where focus is. Every + widget here takes its ring from ``LockConfig.focus_kwargs()``. + +See :class:`ButtonStyle` for why emphasis is a ``variant`` and not a colour. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import tkinter as tk +from typing import TYPE_CHECKING, Literal + +if TYPE_CHECKING: + from collections.abc import Callable + + from gatelock._window import LockConfig, TypeRole + +ButtonVariant = Literal["primary", "secondary", "danger"] +"""Emphasis role for :func:`make_button`, not a colour.""" + + +@dataclass(frozen=True, slots=True) +class ButtonStyle: + """How a button reads: its emphasis role, and whether the label is bold. + + ``variant`` lives here rather than as a loose colour pair because it is + the whole point of the API: it picks the text colour *from* the fill, so + a call site structurally cannot pair a foreground with a background it + does not contrast against. + + Attributes: + variant: Emphasis role. ``primary`` is the one high-emphasis action + per screen; ``secondary`` stays low-contrast and ``danger`` reads + as an alarm. + bold: Whether the label is bold. Gate buttons are, except for a few + low-emphasis ones ("Fetch from sync", "Close Demo") that would + otherwise shout as loudly as the action they sit beside. + """ + + variant: ButtonVariant = "primary" + bold: bool = True + + +DEFAULT_WRAP = 880 +"""Fallback wrap width for :func:`row`, matching a default window geometry. + +Passed in rather than hardcoded because the first version of the status panel +wrapped at a fixed 900px inside a 720px window, so every long explanation ran +off the right edge -- in the one panel whose entire job is explaining things. +""" + + +@dataclass(frozen=True, slots=True) +class RowStyle: + """How one :func:`row` reads: its colour, size and wrap width. + + Grouped because they are a single decision made together, and because + the overwhelmingly common row -- ordinary body text -- passes none of + them. + + Attributes: + color: Overrides ``LockConfig.fg``. Status colours are load-bearing + in a gate: red means the gate is holding you, amber means + something needs attention, green means clear. Nothing should be + coloured merely to look lively. + role: Type-scale role. + wrap: Wrap width in px, clamped to a usable minimum when applied. + """ + + color: str | None = None + role: TypeRole = "label" + wrap: int = DEFAULT_WRAP + + +# Horizontal padding is 2x vertical (tokens.md "Buttons"; both on the 4px +# spacing scale). +_BUTTON_PADY = 12 +_BUTTON_PADX = 24 +# Blend fraction toward white for a button's hover/active fill. +_HOVER_LIGHTEN = 0.12 +_HEX_CHANNEL_OFFSETS = (1, 3, 5) +_RGB_MAX = 255 +# Narrowest wrap worth honouring; below this a "wrapped" line is one word. +_MIN_WRAP = 320 + + +def _lighten(hex_color: str, amount: float = _HOVER_LIGHTEN) -> str: + """Blend ``hex_color`` toward white by ``amount``, for a hover state.""" + channels = ( + int(hex_color[offset : offset + 2], 16) for offset in _HEX_CHANNEL_OFFSETS + ) + return "#" + "".join( + f"{round(channel + (_RGB_MAX - channel) * amount):02x}" for channel in channels + ) + + +def _button_fills(config: LockConfig) -> dict[ButtonVariant, tuple[str, str]]: + """Return the fill/text pair for each variant, read from the palette. + + All three pairs use ``on_fill`` (never ``fg``) for text drawn on a filled + surface, per tokens.md -- which is the whole point of keying off a + variant instead of letting each call site invent its own hex pair. + """ + return { + "primary": (config.accent, config.on_fill), + "secondary": (config.field_bg, config.fg), + "danger": (config.danger, config.on_fill), + } + + +# The one high-emphasis button per screen reads larger than everything else +# (rule 3: size is another axis of prominence, alongside colour); every other +# variant shares the smaller chrome size. +_BUTTON_ROLES: dict[ButtonVariant, TypeRole] = { + "primary": "body", + "secondary": "label", + "danger": "label", +} + + +def make_button( + parent: tk.Misc, + config: LockConfig, + text: str, + command: Callable[[], None], + style: ButtonStyle | None = None, +) -> tk.Button: + """Build a button from the shared primary/secondary/danger palette. + + Binds ```` and ```` as well as relying on ``command``, + because Tk gives ``tk.Button`` only ```` on X11 -- without this + every button in a gate is Space-only and Enter silently does nothing. + + Args: + parent: Widget to build into. + config: Token source for colours, type scale and the focus ring. + text: The button's label. + command: Invoked on click, Space, Return and KP_Enter alike. + style: Emphasis role and label weight. Defaults to a bold primary, + which is what most gate buttons are. + + Returns: + The configured button. Not packed -- the caller places it. + """ + style = style if style is not None else ButtonStyle() + variant = style.variant + fill, text_color = _button_fills(config)[variant] + button = tk.Button( + parent, + text=text, + font=config.font(_BUTTON_ROLES[variant], bold=style.bold), + bg=fill, + fg=text_color, + activebackground=_lighten(fill), + activeforeground=text_color, + cursor="hand2", + padx=_BUTTON_PADX, + pady=_BUTTON_PADY, + command=command, + **config.focus_kwargs(), + ) + + def _activate(_event: tk.Event) -> str: + button.invoke() + return "break" + + button.bind("", _activate, add="+") + button.bind("", _activate, add="+") + return button + + +def heading(parent: tk.Misc, config: LockConfig, text: str) -> tk.Label: + """Build and pack a section title. + + Returns: + The packed label, so a caller can restyle or destroy it. + """ + label = tk.Label( + parent, + text=text, + font=config.font("body", bold=True), + fg=config.accent, + bg=config.bg, + anchor="w", + ) + label.pack( + fill="x", + padx=config.space("lg"), + pady=(config.space("md"), config.space("xs")), + ) + return label + + +def row( + parent: tk.Misc, + config: LockConfig, + text: str, + style: RowStyle | None = None, +) -> tk.Label: + """Build and pack one line of body text, wrapped to the style's width. + + The three styling options travel together in :class:`RowStyle` rather + than as loose keywords, because they are one decision ("how does this + line read?") rather than three, and because a row is written far more + often than it is restyled -- the common call passes no style at all. + + ``wrap`` lives on the style rather than in module state: the donor kept + the current width in a module-level dict, which made every row's + wrapping depend on whichever window rendered most recently. + + Args: + parent: Widget to build into. + config: Token source for colours and the type scale. + text: The line to show. + style: Colour, type-scale role and wrap width. Defaults to ordinary + body text at :data:`DEFAULT_WRAP`. + + Returns: + The packed label. + """ + style = style if style is not None else RowStyle() + color, role, wrap = style.color, style.role, style.wrap + label = tk.Label( + parent, + text=text, + font=config.font(role), + fg=color if color is not None else config.fg, + bg=config.bg, + anchor="w", + justify="left", + wraplength=max(_MIN_WRAP, wrap), + ) + label.pack(fill="x", padx=config.space("xl"), pady=1) + return label