mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:27:15 +01:00
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:
parent
b9e54380db
commit
c963cd88a8
@ -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",
|
||||
]
|
||||
|
||||
172
gatelock/gatelock/tests/test_widget_group.py
Normal file
172
gatelock/gatelock/tests/test_widget_group.py
Normal 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
|
||||
191
gatelock/gatelock/tests/test_widget_group_ops.py
Normal file
191
gatelock/gatelock/tests/test_widget_group_ops.py
Normal 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"]
|
||||
225
gatelock/gatelock/tests/test_widgets.py
Normal file
225
gatelock/gatelock/tests/test_widgets.py
Normal 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)
|
||||
183
gatelock/gatelock/widget_group.py
Normal file
183
gatelock/gatelock/widget_group.py
Normal 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)
|
||||
250
gatelock/gatelock/widgets.py
Normal file
250
gatelock/gatelock/widgets.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user