diet-guard/diet_guard/_gatelock_groups.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

138 lines
4.5 KiB
Python

"""Presenting the gate's N per-monitor copies as if they were one form.
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. The individual per-widget groups -- and the rationale for which kinds of
state can share a Tk variable and which genuinely diverge -- live in
:mod:`._gatelock_widgetgroups`; this half assembles them into the single
``GateWidgetsGroup`` the controller drives, so each file stays under the
repo's 250-line limit.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from diet_guard._gatelock_widgetgroups import (
EntryGroup,
ListboxGroup,
TextGroup,
WidgetGroup,
)
if TYPE_CHECKING:
import tkinter as tk
from diet_guard._gatelock_ui_types import GateVars, GateWidgets
__all__ = [
"EntryGroup",
"GateWidgetsGroup",
"ListboxGroup",
"MacroGroup",
"TextGroup",
"WidgetGroup",
]
class MacroGroup:
"""The four macro entries, each mirrored onto every monitor."""
def __init__(
self,
kcal: EntryGroup,
protein: EntryGroup,
carbs: EntryGroup,
fat: EntryGroup,
) -> None:
"""Bundle the four macro entry groups."""
self.kcal = kcal
self.protein = protein
self.carbs = carbs
self.fat = fat
class GateWidgetsGroup:
"""Every per-monitor copy of the gate, driven as if it were one form.
``_build_tabs`` produces one ``GateWidgets`` per live output and they are
added here; the controller then drives this exactly as it drove a single
bundle before per-output locking existed.
"""
def __init__(self, vars_: GateVars) -> None:
"""Start empty; surfaces are added as outputs come up."""
self._vars = vars_
self._bundles: list[GateWidgets] = []
self._outputs: list[str] = []
def add(self, widgets: GateWidgets, output_name: str) -> None:
"""Register the bundle built for one newly-live output."""
self._bundles.append(widgets)
self._outputs.append(output_name)
def discard(self, output_name: str) -> None:
"""Forget the bundle for an output that went dark."""
kept = [
(bundle, name)
for bundle, name in zip(self._bundles, self._outputs, strict=True)
if name != output_name
]
self._bundles = [bundle for bundle, _ in kept]
self._outputs = [name for _, name in kept]
@property
def bundles(self) -> list[GateWidgets]:
"""Every per-monitor bundle; empty until the lock builds them."""
return list(self._bundles)
def _each(self, name: str) -> list[tk.Misc]:
"""Collect one named widget from every monitor's bundle."""
return [getattr(bundle, name) for bundle in self._bundles]
@property
def frame(self) -> WidgetGroup[tk.Misc]:
"""The gate's outer frame on every monitor."""
return WidgetGroup(self._each("frame"))
@property
def desc_text(self) -> TextGroup:
"""The description box on every monitor."""
return TextGroup(self._each("desc_text"))
@property
def suggestion_box(self) -> ListboxGroup:
"""The suggestion picker on every monitor."""
return ListboxGroup(self._each("suggestion_box"))
@property
def status_label(self) -> WidgetGroup[tk.Misc]:
"""The status line on every monitor."""
return WidgetGroup(self._each("status_label"))
@property
def basis_prefix(self) -> WidgetGroup[tk.Misc]:
"""The per-basis prefix label on every monitor."""
return WidgetGroup(self._each("basis_prefix"))
@property
def amount_entry(self) -> EntryGroup:
"""The amount entry, over its shared variable."""
return EntryGroup(self._each("amount_entry"), self._vars.entries.amount)
@property
def per_entry(self) -> EntryGroup:
"""The per-basis entry, over its shared variable."""
return EntryGroup(self._each("per_entry"), self._vars.entries.per)
@property
def macros(self) -> MacroGroup:
"""The four macro entries, each over its shared variable."""
cells = [bundle.macros for bundle in self._bundles]
return MacroGroup(
kcal=EntryGroup([c.kcal for c in cells], self._vars.entries.kcal),
protein=EntryGroup([c.protein for c in cells], self._vars.entries.protein),
carbs=EntryGroup([c.carbs for c in cells], self._vars.entries.carbs),
fat=EntryGroup([c.fat for c in cells], self._vars.entries.fat),
)