utils/gatelock/gatelock/widget_group.py
Krzysztof kuhy Rudnicki 6154dc09bf Pin pre-commit's ruff to 0.16.7 and satisfy the CPY001 it brings
The three Python packages pinned ruff twice at different versions:
requirements.txt at 0.16.7, .pre-commit-config.yaml at v0.15.2. Only the
older one ever ran, so `select = ["ALL"]` was enforced two minor versions
behind. Measured before aligning: v0.15.2 reports 0 findings, every 0.16.x
reports CPY001 on each file -- 70 crdt-sync, 23 freedays, 71 gatelock, and
no other rule.

Resolved by adding the notice rather than ignoring the rule, so the gate
keeps its teeth.

Two gatelock modules sat at exactly the 250-line cap, so a one-line header
tipped them to 251. Both are split along a real seam rather than shaved:

  _widget_fills.py    colour arithmetic over LockConfig, no tkinter import
  _surfaces_focus.py  selects among existing surfaces, never mutates them

The focus split keeps _surfaces.py's documented containment (it stays the
only module that creates, moves or destroys windows), and the static guard
in test_recovery_invariants.py globs _surfaces*.py, so the new sibling is
covered with no list to extend. widgets.py re-exports the fills helpers,
which keeps gatelock.widgets the single public surface its tests import.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kMvSMtsVMB3d2uoZ56pbp
2026-09-12 18:11:24 +02:00

185 lines
7.4 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki
"""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, TypeVar
if TYPE_CHECKING:
from collections.abc import Callable, Iterator
_W = TypeVar("_W", bound=tk.Misc)
class WidgetGroup[W: tk.Misc]:
"""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)