gatelock: add shared button, heading/row and WidgetGroup

Phase 2 of the shared-component work. gatelock owned the tokens but stopped
short of composite widgets, so four gate apps each built the same ones and
drifted apart doing it.

widgets.py:
- make_button(), from diet-guard's _gatelock_buttons. Emphasis is a variant,
  not a colour pair, so the text colour is picked *from* the fill and a call
  site structurally cannot produce the fg-vs-on_fill contrast bug. Binds
  <Return>/<KP_Enter>, which Tk does not give tk.Button on X11, and takes its
  focus ring from LockConfig (Tk's default 1px black is invisible on #211D1B).
- heading() and row(), from leetcode-guard's _status_sections. row()'s wrap
  width is a parameter rather than the donor's module-level dict, which made
  every row's wrapping depend on whichever window rendered most recently.

widget_group.py:
- WidgetGroup, replacing ~916 lines of per-output fan-out across four repos.
  Takes the union of what they needed and the fixes only some of them had:
  first -> W|None (three donors could IndexError on zero outputs), TclError
  suppressed per copy (one donor did not guard at all), focus deliberately
  singular rather than fanned out, and kwargs typed `object` so command= is
  expressible. Stores (output_name, widget) pairs, which designs out a latent
  strict-zip ValueError in the donor that kept two parallel lists.

Additive only: nothing is removed from the consumers in this commit, so all
four gates keep working on their current pin. 100% branch coverage, real-Tk
tests under xvfb.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1qGVUMjmtppci6Fjxs9xS
This commit is contained in:
Krzysztof Rudnicki 2026-08-14 23:54:52 +02:00
parent b9e54380db
commit c963cd88a8
6 changed files with 1039 additions and 0 deletions

View File

@ -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",
]

View File

@ -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

View File

@ -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("<Button-1>", lambda _event: seen.append("hit"))
for widget in group:
widget.event_generate("<Button-1>")
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("<Button-1>", lambda _event: seen.append("first"))
group.bind("<Button-1>", lambda _event: seen.append("second"))
first = group.first
assert first is not None
first.event_generate("<Button-1>")
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("<Button-1>", lambda _event: seen.append("first"))
group.bind("<Button-1>", lambda _event: seen.append("only"), add=False)
first = group.first
assert first is not None
first.event_generate("<Button-1>")
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"]

View File

@ -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 -- ``<Return>`` 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 <space> 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("<Return>")
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("<KP_Enter>")
root.update()
assert pressed == ["x"]
def test_space_still_activates_a_button(root: tk.Tk, config: LockConfig) -> None:
"""Adding <Return> must not displace Tk's own <space> class binding.
Asserted structurally rather than by ``event_generate``: a synthetic
``<space>`` 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. ``<Return>`` 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 "<Key-space>" in button.bind_class("Button")
assert button.bind_class("Button", "<Key-space>")
@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)

View File

@ -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=<callable>``; 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)

View File

@ -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. **``<Return>`` does nothing on a ``tk.Button``.** Tk's X11 class bindings
give the widget ``<space>`` 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 ``<Return>`` and ``<KP_Enter>`` as well as relying on ``command``,
because Tk gives ``tk.Button`` only ``<space>`` 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("<Return>", _activate, add="+")
button.bind("<KP_Enter>", _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