diet-guard/diet_guard/_gatelock_widgetgroups.py
Krzysztof kuhy Rudnicki e8f0ecaae1 Split the six Python gate files; guard the fake-tk patch set
Sixth batch of the 250-line sweep. All six gate sources named for this batch
are now under cap, plus _gate_layout_probe.py, which this batch's import
rewiring pulled into the changeset (over-cap count 38 -> 31).

  _gatelock_ui.py       477 -> 174  (palette/leaf-helper hub)
  _gate_layout_probe.py 346 -> 196
  _gatelock_groups.py   308 -> 137
  _gatelock_calendar_ui 295 ->  77
  _gatelock_calendar.py 286 -> 210
  _gatelock.py          263 -> 238
  _gatelock_mealflow.py 259 -> 207

New siblings: _gatelock_fields (input widgets), _gatelock_layout (assembly),
_gatelock_widgetgroups (per-widget mirroring), _gatelock_calendar_types
(bundles/palette), _gatelock_calendar_widgets (History builders),
_gatelock_dashboard and _gatelock_budgetedit (mixins), _gatelock_lockfile
(the stdlib-only flock), _gate_focus_probe and _gate_measurement.

The load-bearing part is the test seam, not the line counts.
`_gate_fixtures._GATE_TK_MODULES` patches `tk` on a hand-listed tuple, and
that list fails OPEN: a widget builder split into a new sibling that nobody
adds to it builds *real* Tk widgets against fake parents. Neither existing
detector sees that -- check_patch_targets.py only validates paths that still
resolve, and the ~8s runtime only catches network calls. It bit twice during
this split (real StringVars from make_calendar_vars returned MagicMocks
instead of the asserted text), so test_gate_tk_modules.py now asserts the
invariant directly: every module binding `tk` is in the patch set or named in
_NO_WIDGETS with a reason. It caught a pre-existing gap too --
_gatelock_support was never in the set.

Same fail-open shape drove two other choices: `escape_tab_trap` is a call-time
indirection so the keyboard test's patch of `_escape_text_tab_trap` still
bites, and the flock pair moved with its conftest patch target updated in the
same edit (that one fails closed). build_layout is imported from
_gatelock_layout directly rather than re-exported lazily, which avoids both a
cycle and an import-outside-toplevel suppression.

Verified: 703 Python tests / 100% branch coverage, the 5 layout-fits tests
under xvfb, 555 Flutter tests (unchanged), flutter analyze clean,
check_patch_targets 0, `sync` 0 (403 entries), pre-commit clean, and
`gate --demo` renders both tabs with live dashboard data.
~/.local/share/diet_guard byte-identical across the batch (gate timer paused,
restored after).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
2026-08-15 20:08:03 +02:00

210 lines
8.2 KiB
Python

"""Per-monitor mirroring of the gate's individual widgets.
Since gatelock v0.2.0 the lock builds one window per live output, so the gate
is laid out once per monitor and the controller must drive all of them at
once. Three different kinds of state show up, and each needs a different
answer:
* **Display fields** already fan out for free. ``GateVars`` holds
root-mastered ``StringVar``s bound to labels, so one ``set`` repaints every
monitor with no help from this module.
* **Entries** are made variable-backed here for the same reason: typing an
amount on whichever screen the user is looking at is then the same text
everywhere, and reading it back needs no idea which monitor that was.
* **The description box and the suggestion list** cannot share a variable --
``tk.Text`` and ``tk.Listbox`` have none. Their copies genuinely diverge, so
reads answer from whichever copy the user actually touched.
The alternative -- laying the gate out on the primary monitor only -- was
rejected: the whole point of per-output locking is that the user does not
have to hunt for the screen the form landed on.
This module holds the per-widget groups; :mod:`._gatelock_groups` assembles
them into the whole-form bundle, so each file stays under the repo's 250-line
limit.
"""
from __future__ import annotations
import contextlib
import tkinter as tk
from typing import TYPE_CHECKING, TypeVar
from gatelock import WidgetGroup as SharedWidgetGroup
if TYPE_CHECKING:
from collections.abc import Callable
_W = TypeVar("_W", bound=tk.Misc)
"""The widget each group mirrors, so a subclass keeps its own methods typed."""
class WidgetGroup(SharedWidgetGroup[_W]):
"""One logical widget, mirrored onto every monitor.
The fan-out itself -- iteration, ``configure``, ``bind``, singular focus,
and skipping copies whose surface has been destroyed -- now lives in
:class:`gatelock.WidgetGroup`, which four apps had each reimplemented.
This subclass exists only to keep the positional ``list[_W]``
construction the gate's own builders and specialized groups use; gatelock
stores ``(output_name, widget)`` pairs, because the parallel-list form the
donors shared could be built with widgets but no names.
``first`` is re-narrowed to ``_W``: the gate always builds at least one
copy before anything reads one, and the specialized groups below index
into it without a None check.
"""
def __init__(self, widgets: list[_W]) -> None:
"""Wrap the per-monitor copies of one widget."""
super().__init__([("", widget) for widget in widgets])
@property
def first(self) -> _W:
"""The primary monitor's copy, for reads that cannot fan out."""
widget = super().first
if widget is None:
message = "widget group is empty"
raise IndexError(message)
return widget
def bind(
self,
sequence: str,
func: Callable[[tk.Event], object],
*,
add: bool = False,
) -> None:
"""Bind the same handler on every copy, *replacing* any previous one.
Only the default differs from :meth:`gatelock.WidgetGroup.bind`, and
it is inverted deliberately. ``MealGate._wire_events`` runs once per
``build_surface`` **and** again on rebuild, so with an additive bind
every output change would stack a second handler on every surviving
widget -- and ``<Return>`` would submit the meal twice. Replacing
makes re-wiring idempotent, which is what this gate needs; gatelock
defaults the other way because its callers bind once and must not
clobber a handler someone else set.
"""
# Spelled out rather than delegated: an inherited signature with a
# flipped default is invisible at the call site, and this one is
# load-bearing enough to be worth reading in place.
self._each(lambda widget: widget.bind(sequence, func, add="+" if add else ""))
class EntryGroup(WidgetGroup[tk.Entry]):
"""A single-line entry, mirrored onto every monitor over one variable.
Because every copy is bound to the same ``StringVar``, reads and writes go
through the variable rather than the widgets: the copies cannot disagree.
"""
def __init__(self, widgets: list[tk.Entry], var: tk.StringVar) -> None:
"""Wrap the per-monitor entries and the variable behind them."""
super().__init__(widgets)
self._var = var
def get(self) -> str:
"""Return the shared text, whichever monitor it was typed on."""
return self._var.get()
def delete(self, _first: int | str, _last: int | str | None = None) -> None:
"""Clear the shared text on every monitor."""
self._var.set("")
def insert(self, _index: int | str, value: str) -> None:
"""Set the shared text on every monitor.
Only ever used here to fill an empty field, so "insert" and "set" are
the same operation; taking the index keeps the tk-shaped call sites
unchanged.
"""
self._var.set(value)
class TextGroup(WidgetGroup[tk.Text]):
"""The description box, mirrored onto every monitor.
``tk.Text`` has no ``textvariable``, so the copies really do diverge. The
user types into exactly one of them; reads return that one, which keeps
"log the meal on whichever screen you are looking at" true.
"""
def get(self, start: str, end: str) -> str:
"""Return the content of whichever copy was actually typed into."""
for widget in self:
with contextlib.suppress(tk.TclError):
content = widget.get(start, end)
if content.strip():
return str(content)
return str(self.first.get(start, end))
def delete(self, start: str, end: str) -> None:
"""Clear every copy."""
for widget in self:
with contextlib.suppress(tk.TclError):
widget.delete(start, end)
def insert(self, index: str, value: str) -> None:
"""Write the same content into every copy."""
for widget in self:
with contextlib.suppress(tk.TclError):
widget.insert(index, value)
class ListboxGroup(WidgetGroup[tk.Listbox]):
"""The suggestion picker, mirrored onto every monitor.
A ``Listbox`` selection is per-widget, so the selected index is read from
whichever copy has one -- the monitor the user clicked on.
"""
def delete(self, first: int | str, last: int | str | None = None) -> None:
"""Clear every copy."""
for widget in self:
with contextlib.suppress(tk.TclError):
widget.delete(first, last)
def insert(self, index: int | str, *values: str) -> None:
"""Append the same entries to every copy."""
for widget in self:
with contextlib.suppress(tk.TclError):
widget.insert(index, *values)
def selection_set(self, first: int, last: int | None = None) -> None:
"""Select the same row on every copy.
Programmatic selection (a keyboard pick, or a test) has to reach all
of them; a *mouse* pick lands on one copy, which is what
``curselection`` reads back.
"""
for widget in self:
with contextlib.suppress(tk.TclError):
if last is None:
widget.selection_set(first)
else:
widget.selection_set(first, last)
def selection_clear(self, first: int, last: int | None = None) -> None:
"""Clear the selection on every copy."""
for widget in self:
with contextlib.suppress(tk.TclError):
if last is None:
widget.selection_clear(first)
else:
widget.selection_clear(first, last)
def curselection(self) -> tuple[int, ...]:
"""Return the selection from whichever copy the user clicked."""
for widget in self:
with contextlib.suppress(tk.TclError):
selection = widget.curselection()
if selection:
return tuple(selection)
return ()
def size(self) -> int:
"""Number of rows; identical on every copy, so read the primary."""
return int(self.first.size())