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
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 20:08:03 +02:00
parent 7cb09a5b85
commit e8f0ecaae1
24 changed files with 1631 additions and 1124 deletions

View File

@ -28,7 +28,7 @@ repos:
args: [--pytest-test-first]
# Shared fakes and fixtures, not test modules. Split out of conftest.py
# and test_sync.py only because those files passed the 250-line cap.
exclude: ^diet_guard/tests/_(tk_fakes|gate_layout_probe|gate_keyboard_probe|gate_fixtures|sync_fakes)\.py$
exclude: ^diet_guard/tests/_(tk_fakes|gate_layout_probe|gate_focus_probe|gate_measurement|gate_keyboard_probe|gate_fixtures|sync_fakes)\.py$
- id: check-ast
- id: mixed-line-ending
args: [--fix=lf]

View File

@ -2,7 +2,7 @@ name: diet_guard_app
description: "Companion phone app for diet_guard: log meals on the go."
publish_to: 'none'
version: 1.0.18+1
version: 1.0.19+1
environment:
sdk: ^3.12.2

View File

@ -28,7 +28,7 @@ prominent, with macros and the protein target beneath. The unlock condition is
offline, so a dead OFF endpoint can never trap you behind the lock.
Building ``MealGate`` spans several sibling modules to keep each under the
repo's 500-line limit: :mod:`._gatelock_core` provides the shared leaf
repo's 250-line limit: :mod:`._gatelock_core` provides the shared leaf
widget/field helpers and state (``_GateCore``, ``_GateState``);
:mod:`._gatelock_nutrition` provides the reference->total nutrition maths and
food lookup (``_GateNutrition``); :mod:`._gatelock_mealflow` provides the
@ -42,11 +42,8 @@ construction, layout, and event binding.
from __future__ import annotations
import contextlib
import fcntl
import sys
import tkinter as tk
from typing import TYPE_CHECKING
from gatelock import (
RANK_DIET_GUARD,
@ -57,11 +54,11 @@ from gatelock import (
SurfaceInfo,
)
from diet_guard._constants import GATE_LOCK_FILE
from diet_guard._gate import due_slots
from diet_guard._gatelock_calendar import _GateCalendar, make_calendar_vars
from diet_guard._gatelock_core import _GateState
from diet_guard._gatelock_groups import GateWidgetsGroup
from diet_guard._gatelock_lockfile import acquire_gate_lock, release_gate_lock
from diet_guard._gatelock_ui import (
GateCallbacks,
make_vars,
@ -69,8 +66,11 @@ from diet_guard._gatelock_ui import (
from diet_guard._slots import current_slot, day_slots
from diet_guard._state import now_local
if TYPE_CHECKING:
from typing import TextIO
__all__ = [
"MealGate",
"acquire_gate_lock",
"release_gate_lock",
]
def _assert_not_under_pytest() -> None:
@ -85,31 +85,6 @@ def _assert_not_under_pytest() -> None:
raise RuntimeError(msg)
def acquire_gate_lock() -> TextIO | None:
"""Acquire the gate's single-instance ``flock``.
Returns:
An open file handle that must be kept alive for the gate's lifetime
(closing it releases the lock), or None if another gate already holds
it -- in which case the caller must not open a second window.
"""
GATE_LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
handle = GATE_LOCK_FILE.open("w", encoding="utf-8")
try:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
handle.close()
return None
return handle
def release_gate_lock(handle: TextIO) -> None:
"""Release the single-instance lock and close its handle."""
with contextlib.suppress(OSError):
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
handle.close()
def _pending_slots(*, demo_mode: bool) -> list[int]:
"""Return the slots the window must collect before it can unlock.

View File

@ -0,0 +1,117 @@
"""Budget editing on the gate's History tab.
Split out of :mod:`._gatelock_calendar` to keep every gate module under the
repo's 250-line limit. ``_GateBudgetEdit`` sits between
:class:`~diet_guard._gatelock_mealflow._GateMealFlow` and
:class:`~diet_guard._gatelock_calendar._GateCalendar` in the mixin chain and
owns the one place either device edits the daily budget.
The budget is a plain, freely-editable synced file -- no seal, no signing.
That is deliberate (it replaced a ``chattr +i`` mechanism so the value is
editable from either device); do not reintroduce the seal here.
"""
from __future__ import annotations
import abc
import contextlib
import tkinter as tk
from typing import TYPE_CHECKING
from diet_guard._budget import BudgetError, budget_weight, write_budget
from diet_guard._gatelock_mealflow import _GateMealFlow
from diet_guard._gatelock_ui import ERR, FG
if TYPE_CHECKING:
from diet_guard._gatelock_calendar_types import CalendarVars, CalendarWidgets
__all__ = ["_GateBudgetEdit"]
class _GateBudgetEdit(_GateMealFlow):
"""The History tab's budget row: display, edit toggle, validate, persist.
The calendar state below is *declared* here and **created** by
:class:`~diet_guard._gatelock_calendar._GateCalendar`, further down the
mixin chain: this half only reads and writes the budget row, while the
other owns the tab's construction and refresh. Declaring the attributes
keeps that split checkable rather than implicit.
"""
_cal_vars: CalendarVars
_cal_surfaces: list[CalendarWidgets]
_cal_editing_budget: bool
@abc.abstractmethod
def _refresh_calendar(self) -> None:
"""Repaint the History tab; implemented by ``_GateCalendar``."""
def _set_budget_entry_state(self, state: str) -> None:
"""Lock or unlock the budget entry on every monitor."""
for surface in self._cal_surfaces:
with contextlib.suppress(tk.TclError):
surface.budget_entry.config(state=state)
def _set_budget_button_text(self, text: str) -> None:
"""Relabel the budget edit/save button on every monitor."""
for surface in self._cal_surfaces:
with contextlib.suppress(tk.TclError):
surface.budget_edit_button.config(text=text)
def _set_budget_status(self, text: str, *, error: bool) -> None:
"""Update the budget-edit status line, red for errors."""
self._cal_vars.budget_status.set(text)
colour = ERR if error else FG
for surface in self._cal_surfaces:
with contextlib.suppress(tk.TclError):
surface.budget_status_label.config(fg=colour)
def _on_edit_or_save_budget(self) -> None:
"""Toggle the budget row between read-only display and editing.
First click: unlock the entry for typing and relabel the button
"Save" -- nothing is persisted yet. Second click: validate and
persist; on success, lock the entry back to read-only and relabel
the button "Edit". A validation failure leaves editing open so the
user can correct the value instead of silently discarding it.
"""
if not self._cal_editing_budget:
self._cal_editing_budget = True
self._set_budget_entry_state("normal")
self._set_budget_button_text("Save")
self._set_budget_status("", error=False)
return
if not self._save_budget_entry():
return
self._cal_editing_budget = False
self._set_budget_button_text("Edit")
self._refresh_calendar()
self._refresh_dashboard()
self._refresh_projection()
def _save_budget_entry(self) -> bool:
"""Validate and persist the entry's current text.
Preserves any body weight already stored alongside the budget (used
to derive the protein target) -- a bare ``write_budget(value)`` would
silently drop it, since the file holds one record, not a diff.
Returns:
Whether the value was valid and persisted.
"""
raw = self._cal_vars.budget.get().strip()
try:
value = int(raw)
except ValueError:
self._set_budget_status("Enter a whole number of kcal.", error=True)
return False
if value <= 0:
self._set_budget_status("Budget must be a positive number.", error=True)
return False
try:
weight = budget_weight()
except BudgetError:
weight = None
write_budget(value, weight_kg=weight)
self._set_budget_status("Saved.", error=False)
return True

View File

@ -1,12 +1,12 @@
"""History tab: budget-adherence calendar, streaks, YTD tally, budget edit.
"""History tab: budget-adherence calendar, streaks, YTD tally.
Split out of :mod:`._gatelock` (and kept out of :mod:`._gatelock_ui`) to keep
every gate module under the repo's 500-line limit. ``_GateCalendar`` extends
:class:`~diet_guard._gatelock_mealflow._GateMealFlow` with a second
every gate module under the repo's 250-line limit. ``_GateCalendar`` extends
:class:`~diet_guard._gatelock_budgetedit._GateBudgetEdit` with a second
``ttk.Notebook`` tab: the calendar/streak/tally view built from
:mod:`diet_guard._calendar_view`'s pure grid math, plus a budget-edit field
writing through :func:`diet_guard._budget.write_budget` -- the one place
either device edits the now freely-editable, synced daily budget.
:mod:`diet_guard._calendar_view`'s pure grid math. The budget-edit half of
the tab -- the one place either device edits the freely-editable, synced daily
budget -- lives in :mod:`._gatelock_budgetedit`.
The month-grid math itself lives in :mod:`diet_guard._calendar_view` (no Tk
dependency), so the boundary cases (varying month length/first weekday,
@ -20,15 +20,13 @@ import calendar
import contextlib
import tkinter as tk
from tkinter import ttk
from typing import TYPE_CHECKING
from diet_guard._budget import (
BudgetError,
BudgetFileCorruptError,
BudgetNotInitializedError,
budget_weight,
current_schedule,
daily_budget,
write_budget,
)
from diet_guard._calendar_view import (
CalendarCell,
@ -39,6 +37,7 @@ from diet_guard._calendar_view import (
ytd_text,
)
from diet_guard._daystatus import DayStatus, status_map
from diet_guard._gatelock_budgetedit import _GateBudgetEdit
from diet_guard._gatelock_calendar_ui import (
_DECEMBER,
_DEFAULT_BUDGET_KCAL,
@ -51,16 +50,15 @@ from diet_guard._gatelock_calendar_ui import (
build_calendar_frame,
make_calendar_vars,
)
from diet_guard._gatelock_mealflow import _GateMealFlow
from diet_guard._gatelock_ui import (
ERR,
FG,
GateCallbacks,
GateWidgets,
build_layout,
)
from diet_guard._gatelock_layout import build_layout
from diet_guard._state import load_log, now_local
if TYPE_CHECKING:
from diet_guard._gatelock_ui import (
GateCallbacks,
GateWidgets,
)
__all__ = [
"CalendarCallbacks",
"CalendarVars",
@ -71,7 +69,7 @@ __all__ = [
]
class _GateCalendar(_GateMealFlow):
class _GateCalendar(_GateBudgetEdit):
"""History tab: calendar, streaks, YTD tally, and budget editing."""
_cal_vars: CalendarVars
@ -212,75 +210,3 @@ class _GateCalendar(_GateMealFlow):
self._cal_month = _JANUARY
self._cal_year += 1
self._refresh_calendar()
# -- budget editing -----------------------------------------------------
def _set_budget_entry_state(self, state: str) -> None:
"""Lock or unlock the budget entry on every monitor."""
for surface in self._cal_surfaces:
with contextlib.suppress(tk.TclError):
surface.budget_entry.config(state=state)
def _set_budget_button_text(self, text: str) -> None:
"""Relabel the budget edit/save button on every monitor."""
for surface in self._cal_surfaces:
with contextlib.suppress(tk.TclError):
surface.budget_edit_button.config(text=text)
def _set_budget_status(self, text: str, *, error: bool) -> None:
"""Update the budget-edit status line, red for errors."""
self._cal_vars.budget_status.set(text)
colour = ERR if error else FG
for surface in self._cal_surfaces:
with contextlib.suppress(tk.TclError):
surface.budget_status_label.config(fg=colour)
def _on_edit_or_save_budget(self) -> None:
"""Toggle the budget row between read-only display and editing.
First click: unlock the entry for typing and relabel the button
"Save" -- nothing is persisted yet. Second click: validate and
persist; on success, lock the entry back to read-only and relabel
the button "Edit". A validation failure leaves editing open so the
user can correct the value instead of silently discarding it.
"""
if not self._cal_editing_budget:
self._cal_editing_budget = True
self._set_budget_entry_state("normal")
self._set_budget_button_text("Save")
self._set_budget_status("", error=False)
return
if not self._save_budget_entry():
return
self._cal_editing_budget = False
self._set_budget_button_text("Edit")
self._refresh_calendar()
self._refresh_dashboard()
self._refresh_projection()
def _save_budget_entry(self) -> bool:
"""Validate and persist the entry's current text.
Preserves any body weight already stored alongside the budget (used
to derive the protein target) -- a bare ``write_budget(value)`` would
silently drop it, since the file holds one record, not a diff.
Returns:
Whether the value was valid and persisted.
"""
raw = self._cal_vars.budget.get().strip()
try:
value = int(raw)
except ValueError:
self._set_budget_status("Enter a whole number of kcal.", error=True)
return False
if value <= 0:
self._set_budget_status("Budget must be a positive number.", error=True)
return False
try:
weight = budget_weight()
except BudgetError:
weight = None
write_budget(value, weight_kg=weight)
self._set_budget_status("Saved.", error=False)
return True

View File

@ -0,0 +1,94 @@
"""Data bundles, palette and constants for the gate's History tab.
Split out of :mod:`._gatelock_calendar_ui` to keep every gate module under the
repo's 250-line limit. This module is the tab's *shared foundation*: the
three dataclasses that cross the module boundary, the calendar constants, and
the palette read from the shared ``LockConfig``. It is deliberately free of
widget construction, so the builders in
:mod:`._gatelock_calendar_widgets` and the controller in
:mod:`._gatelock_calendar` can both import from it without a cycle.
"""
from __future__ import annotations
from dataclasses import dataclass
import tkinter as tk
from typing import TYPE_CHECKING
from gatelock import LockConfig
if TYPE_CHECKING:
from collections.abc import Callable
# BG/FG/ERR come from _gatelock_ui's public exports (themselves sourced from
# LockConfig -- see DESIGN_AUDIT_TODO.md). The rest of the calendar's palette
# used to be its own hardcoded copy of _gatelock_ui's private constants; now
# it reads the same shared LockConfig() directly instead.
_COLORS = LockConfig()
_MUTED = _COLORS.muted
_FIELD_BG = _COLORS.field_bg
_ACCENT = _COLORS.accent
# calendar.monthcalendar never returns more than 6 weeks for any month.
_MONTH_ROWS = 6
_WEEKDAY_LABELS = ("Mo", "Tu", "We", "Th", "Fr", "Sa", "Su")
_JANUARY = 1
_DECEMBER = 12
_MONTH_AFTER_DECEMBER = 13
# Shown (and classified against) before any budget has ever been set --
# mirrors the phone app's AppSettingsService default, so a fresh install
# shows a meaningful calendar on both platforms with no setup ritual.
_DEFAULT_BUDGET_KCAL = 2200
__all__ = [
"CalendarCallbacks",
"CalendarVars",
"CalendarWidgets",
"make_calendar_vars",
]
@dataclass
class CalendarVars:
"""Tk string variables bound to the History tab's live fields."""
month_label: tk.StringVar
streaks: tk.StringVar
ytd: tk.StringVar
averages: tk.StringVar
budget_status: tk.StringVar
budget: tk.StringVar
"""Backs the budget entry so its per-monitor copies cannot disagree."""
@dataclass
class CalendarWidgets:
"""Interactive widgets of the History tab."""
frame: tk.Frame
day_cells: list[tk.Label]
budget_entry: tk.Entry
budget_edit_button: tk.Button
budget_status_label: tk.Label
@dataclass
class CalendarCallbacks:
"""Construction-time commands the History tab's widgets fire."""
on_prev_month: Callable[[], None]
on_next_month: Callable[[], None]
on_edit_or_save_budget: Callable[[], None]
def make_calendar_vars(root: tk.Misc) -> CalendarVars:
"""Create the History tab's string variables, all mastered to ``root``."""
return CalendarVars(
month_label=tk.StringVar(master=root, value=""),
streaks=tk.StringVar(master=root, value=""),
ytd=tk.StringVar(master=root, value=""),
averages=tk.StringVar(master=root, value=""),
budget_status=tk.StringVar(master=root, value=""),
budget=tk.StringVar(master=root, value=""),
)

View File

@ -1,270 +1,54 @@
"""Widget construction for the gate's History tab.
"""``ttk`` theming for the gate's History tab, and its public surface.
Split out of ``_gatelock_calendar`` (and re-exported from it) so neither
module exceeds the repo's 500-line limit. This half builds the tab; the
other half is the controller mixin that drives it, once per live output
since the gatelock v0.2.0 migration.
Split across three modules to keep each under the repo's 250-line limit:
:mod:`._gatelock_calendar_types` holds the data bundles, constants and
palette, :mod:`._gatelock_calendar_widgets` builds the widgets, and this
module owns the ``ttk.Notebook`` theming.
Everything the tab's controller (:mod:`._gatelock_calendar`) needs is
re-exported here, because that module already imports all seven names from
this one. The ``ttk`` import is also load-bearing beyond theming: the test
suite patches ``_gatelock_calendar_ui.ttk`` with a fake, so this module must
keep binding ``ttk`` at module level.
"""
from __future__ import annotations
from dataclasses import dataclass
import tkinter as tk
from tkinter import ttk
from typing import TYPE_CHECKING
from gatelock import ButtonStyle, LockConfig, make_button
from diet_guard._gatelock_spacing import MD, SM, XS
from diet_guard._gatelock_typography import BODY, CAPTION, LABEL, TITLE
from diet_guard._gatelock_ui import (
BG,
FG,
from diet_guard._gatelock_calendar_types import (
_ACCENT,
_COLORS,
_DECEMBER,
_DEFAULT_BUDGET_KCAL,
_FIELD_BG,
_JANUARY,
_MONTH_AFTER_DECEMBER,
CalendarCallbacks,
CalendarVars,
CalendarWidgets,
make_calendar_vars,
)
from diet_guard._gatelock_calendar_widgets import build_calendar_frame
from diet_guard._gatelock_spacing import MD, SM
from diet_guard._gatelock_ui import BG, FG
if TYPE_CHECKING:
from collections.abc import Callable
import tkinter as tk
# BG/FG/ERR come from _gatelock_ui's public exports (themselves sourced from
# LockConfig -- see DESIGN_AUDIT_TODO.md). The rest of the calendar's palette
# used to be its own hardcoded copy of _gatelock_ui's private constants; now
# it reads the same shared LockConfig() directly instead.
_COLORS = LockConfig()
_MUTED = _COLORS.muted
_FIELD_BG = _COLORS.field_bg
_ACCENT = _COLORS.accent
# calendar.monthcalendar never returns more than 6 weeks for any month.
_MONTH_ROWS = 6
_WEEKDAY_LABELS = ("Mo", "Tu", "We", "Th", "Fr", "Sa", "Su")
_JANUARY = 1
_DECEMBER = 12
_MONTH_AFTER_DECEMBER = 13
# Shown (and classified against) before any budget has ever been set --
# mirrors the phone app's AppSettingsService default, so a fresh install
# shows a meaningful calendar on both platforms with no setup ritual.
_DEFAULT_BUDGET_KCAL = 2200
@dataclass
class CalendarVars:
"""Tk string variables bound to the History tab's live fields."""
month_label: tk.StringVar
streaks: tk.StringVar
ytd: tk.StringVar
averages: tk.StringVar
budget_status: tk.StringVar
budget: tk.StringVar
"""Backs the budget entry so its per-monitor copies cannot disagree."""
@dataclass
class CalendarWidgets:
"""Interactive widgets of the History tab."""
frame: tk.Frame
day_cells: list[tk.Label]
budget_entry: tk.Entry
budget_edit_button: tk.Button
budget_status_label: tk.Label
@dataclass
class CalendarCallbacks:
"""Construction-time commands the History tab's widgets fire."""
on_prev_month: Callable[[], None]
on_next_month: Callable[[], None]
on_edit_or_save_budget: Callable[[], None]
def make_calendar_vars(root: tk.Misc) -> CalendarVars:
"""Create the History tab's string variables, all mastered to ``root``."""
return CalendarVars(
month_label=tk.StringVar(master=root, value=""),
streaks=tk.StringVar(master=root, value=""),
ytd=tk.StringVar(master=root, value=""),
averages=tk.StringVar(master=root, value=""),
budget_status=tk.StringVar(master=root, value=""),
budget=tk.StringVar(master=root, value=""),
)
def _build_budget_row(
parent: tk.Frame,
vars_: CalendarVars,
on_edit_or_save_budget: Callable[[], None],
) -> tuple[tk.Entry, tk.Button, tk.Label]:
"""Build the budget row; return the entry, its edit button, and status label.
The entry starts read-only (``state="readonly"``): the budget is
displayed but not directly typeable. The button on the right starts
labelled "Edit"; clicking it makes the entry editable and relabels
itself "Save" -- a second click validates and persists, then reverts
both back to their read-only defaults.
"""
row = tk.Frame(parent, bg=BG)
row.pack(pady=(SM, XS))
tk.Label(
row,
text="Daily budget (kcal):",
font=(_COLORS.font_family, LABEL),
bg=BG,
fg=FG,
).pack(side="left")
entry = tk.Entry(
row,
textvariable=vars_.budget,
font=(_COLORS.font_family, BODY),
width=8,
bg=_FIELD_BG,
fg=FG,
insertbackground=FG,
justify="center",
state="readonly",
readonlybackground=_FIELD_BG,
)
entry.pack(side="left", padx=(SM, SM), ipady=XS)
edit_button = make_button(
row,
_COLORS,
"Edit",
on_edit_or_save_budget,
)
edit_button.pack(side="left")
status_label = tk.Label(
parent,
textvariable=vars_.budget_status,
font=(_COLORS.font_family, LABEL),
bg=BG,
fg=FG,
)
status_label.pack(pady=(0, XS))
return entry, edit_button, status_label
def _build_month_nav(
parent: tk.Frame,
vars_: CalendarVars,
callbacks: CalendarCallbacks,
) -> None:
"""Build the prev/month-label/next header row."""
row = tk.Frame(parent, bg=BG)
row.pack(pady=(XS, XS))
make_button(
row,
_COLORS,
"◀",
callbacks.on_prev_month,
ButtonStyle(variant="secondary"),
).pack(side="left", padx=XS)
tk.Label(
row,
textvariable=vars_.month_label,
font=(_COLORS.font_family, LABEL, "bold"),
bg=BG,
fg=FG,
width=16,
justify="center",
).pack(side="left")
make_button(
row,
_COLORS,
"▶",
callbacks.on_next_month,
ButtonStyle(variant="secondary"),
).pack(side="left", padx=XS)
def _build_grid(parent: tk.Frame) -> list[tk.Label]:
"""Build the fixed 6x7 day-cell grid and return the flat cell list."""
weekday_row = tk.Frame(parent, bg=BG)
weekday_row.pack()
for col, label in enumerate(_WEEKDAY_LABELS):
tk.Label(
weekday_row,
text=label,
font=(_COLORS.font_family, CAPTION, "bold"),
bg=BG,
fg=_MUTED,
width=4,
).grid(row=0, column=col, padx=XS)
grid_frame = tk.Frame(parent, bg=BG)
grid_frame.pack(pady=(XS, XS))
day_cells: list[tk.Label] = []
for row in range(_MONTH_ROWS):
for col in range(7):
cell = tk.Label(
grid_frame,
text="",
font=(_COLORS.font_family, CAPTION),
width=4,
height=2,
bg=BG,
fg=FG,
highlightthickness=2,
highlightbackground=BG,
)
cell.grid(row=row, column=col, padx=XS, pady=XS)
day_cells.append(cell)
return day_cells
def build_calendar_frame(
root: tk.Misc,
vars_: CalendarVars,
callbacks: CalendarCallbacks,
) -> CalendarWidgets:
"""Lay out the History tab and return the widgets the controller drives."""
frame = tk.Frame(root, bg=BG)
tk.Label(
frame,
text="📅 History",
font=(_COLORS.font_family, TITLE, "bold"),
bg=BG,
fg=_ACCENT,
).pack(pady=(SM, 0))
budget_entry, budget_edit_button, budget_status_label = _build_budget_row(
frame,
vars_,
callbacks.on_edit_or_save_budget,
)
_build_month_nav(frame, vars_, callbacks)
day_cells = _build_grid(frame)
tk.Label(
frame,
textvariable=vars_.streaks,
font=(_COLORS.font_family, LABEL, "bold"),
bg=BG,
fg=FG,
).pack(pady=(SM, 0))
tk.Label(
frame,
textvariable=vars_.ytd,
font=(_COLORS.font_family, CAPTION),
bg=BG,
fg=_MUTED,
).pack(pady=(XS, 0))
tk.Label(
frame,
textvariable=vars_.averages,
font=(_COLORS.font_family, CAPTION),
bg=BG,
fg=_MUTED,
).pack(pady=(XS, SM))
return CalendarWidgets(
frame=frame,
day_cells=day_cells,
budget_entry=budget_entry,
budget_edit_button=budget_edit_button,
budget_status_label=budget_status_label,
)
__all__ = [
"_DECEMBER",
"_DEFAULT_BUDGET_KCAL",
"_JANUARY",
"_MONTH_AFTER_DECEMBER",
"CalendarCallbacks",
"CalendarVars",
"CalendarWidgets",
"_style_notebook",
"build_calendar_frame",
"make_calendar_vars",
]
def _style_notebook(root: tk.Misc) -> None:

View File

@ -0,0 +1,216 @@
"""Widget builders for the gate's History tab.
Split out of :mod:`._gatelock_calendar_ui` to keep every gate module under the
repo's 250-line limit. That module keeps the tab's data bundles, its string
variables, and the ``ttk`` theming; this one builds the actual widgets --
the budget row, the month navigation header, the day grid, and the frame that
holds them.
.. note::
This module imports ``tkinter`` and builds widgets, so it **must** appear in
``diet_guard.tests._gate_fixtures._GATE_TK_MODULES``. A module left out of
that set builds real Tk widgets under pytest against fake parents.
``test_gate_tk_modules_complete`` enforces this.
"""
from __future__ import annotations
import tkinter as tk
from typing import TYPE_CHECKING
from gatelock import ButtonStyle, make_button
from diet_guard._gatelock_calendar_types import (
_ACCENT,
_COLORS,
_FIELD_BG,
_MONTH_ROWS,
_MUTED,
_WEEKDAY_LABELS,
CalendarCallbacks,
CalendarVars,
CalendarWidgets,
)
from diet_guard._gatelock_spacing import SM, XS
from diet_guard._gatelock_typography import BODY, CAPTION, LABEL, TITLE
from diet_guard._gatelock_ui import BG, FG
if TYPE_CHECKING:
from collections.abc import Callable
__all__ = ["build_calendar_frame"]
def _build_budget_row(
parent: tk.Frame,
vars_: CalendarVars,
on_edit_or_save_budget: Callable[[], None],
) -> tuple[tk.Entry, tk.Button, tk.Label]:
"""Build the budget row; return the entry, its edit button, and status label.
The entry starts read-only (``state="readonly"``): the budget is
displayed but not directly typeable. The button on the right starts
labelled "Edit"; clicking it makes the entry editable and relabels
itself "Save" -- a second click validates and persists, then reverts
both back to their read-only defaults.
"""
row = tk.Frame(parent, bg=BG)
row.pack(pady=(SM, XS))
tk.Label(
row,
text="Daily budget (kcal):",
font=(_COLORS.font_family, LABEL),
bg=BG,
fg=FG,
).pack(side="left")
entry = tk.Entry(
row,
textvariable=vars_.budget,
font=(_COLORS.font_family, BODY),
width=8,
bg=_FIELD_BG,
fg=FG,
insertbackground=FG,
justify="center",
state="readonly",
readonlybackground=_FIELD_BG,
)
entry.pack(side="left", padx=(SM, SM), ipady=XS)
edit_button = make_button(
row,
_COLORS,
"Edit",
on_edit_or_save_budget,
)
edit_button.pack(side="left")
status_label = tk.Label(
parent,
textvariable=vars_.budget_status,
font=(_COLORS.font_family, LABEL),
bg=BG,
fg=FG,
)
status_label.pack(pady=(0, XS))
return entry, edit_button, status_label
def _build_month_nav(
parent: tk.Frame,
vars_: CalendarVars,
callbacks: CalendarCallbacks,
) -> None:
"""Build the prev/month-label/next header row."""
row = tk.Frame(parent, bg=BG)
row.pack(pady=(XS, XS))
make_button(
row,
_COLORS,
"◀",
callbacks.on_prev_month,
ButtonStyle(variant="secondary"),
).pack(side="left", padx=XS)
tk.Label(
row,
textvariable=vars_.month_label,
font=(_COLORS.font_family, LABEL, "bold"),
bg=BG,
fg=FG,
width=16,
justify="center",
).pack(side="left")
make_button(
row,
_COLORS,
"▶",
callbacks.on_next_month,
ButtonStyle(variant="secondary"),
).pack(side="left", padx=XS)
def _build_grid(parent: tk.Frame) -> list[tk.Label]:
"""Build the fixed 6x7 day-cell grid and return the flat cell list."""
weekday_row = tk.Frame(parent, bg=BG)
weekday_row.pack()
for col, label in enumerate(_WEEKDAY_LABELS):
tk.Label(
weekday_row,
text=label,
font=(_COLORS.font_family, CAPTION, "bold"),
bg=BG,
fg=_MUTED,
width=4,
).grid(row=0, column=col, padx=XS)
grid_frame = tk.Frame(parent, bg=BG)
grid_frame.pack(pady=(XS, XS))
day_cells: list[tk.Label] = []
for row in range(_MONTH_ROWS):
for col in range(7):
cell = tk.Label(
grid_frame,
text="",
font=(_COLORS.font_family, CAPTION),
width=4,
height=2,
bg=BG,
fg=FG,
highlightthickness=2,
highlightbackground=BG,
)
cell.grid(row=row, column=col, padx=XS, pady=XS)
day_cells.append(cell)
return day_cells
def build_calendar_frame(
root: tk.Misc,
vars_: CalendarVars,
callbacks: CalendarCallbacks,
) -> CalendarWidgets:
"""Lay out the History tab and return the widgets the controller drives."""
frame = tk.Frame(root, bg=BG)
tk.Label(
frame,
text="📅 History",
font=(_COLORS.font_family, TITLE, "bold"),
bg=BG,
fg=_ACCENT,
).pack(pady=(SM, 0))
budget_entry, budget_edit_button, budget_status_label = _build_budget_row(
frame,
vars_,
callbacks.on_edit_or_save_budget,
)
_build_month_nav(frame, vars_, callbacks)
day_cells = _build_grid(frame)
tk.Label(
frame,
textvariable=vars_.streaks,
font=(_COLORS.font_family, LABEL, "bold"),
bg=BG,
fg=FG,
).pack(pady=(SM, 0))
tk.Label(
frame,
textvariable=vars_.ytd,
font=(_COLORS.font_family, CAPTION),
bg=BG,
fg=_MUTED,
).pack(pady=(XS, 0))
tk.Label(
frame,
textvariable=vars_.averages,
font=(_COLORS.font_family, CAPTION),
bg=BG,
fg=_MUTED,
).pack(pady=(XS, SM))
return CalendarWidgets(
frame=frame,
day_cells=day_cells,
budget_entry=budget_entry,
budget_edit_button=budget_edit_button,
budget_status_label=budget_status_label,
)

View File

@ -0,0 +1,72 @@
"""The gate's running "how am I doing today" dashboard.
Split out of :mod:`._gatelock_mealflow` to keep every gate module under the
repo's 250-line limit. ``_GateDashboard`` sits between
:class:`~diet_guard._gatelock_nutrition._GateNutrition` and
:class:`~diet_guard._gatelock_mealflow._GateMealFlow` in the mixin chain and
owns only the *read* side: the prominent calorie headline and the detail panel
beneath it. Nothing here writes state, so it can be recomputed at any point
in the meal flow.
"""
from __future__ import annotations
from diet_guard._budget import BudgetError, daily_budget, protein_target_g
from diet_guard._gatelock_nutrition import _GateNutrition
from diet_guard._state import (
entry_kcal,
today_entries,
today_total_kcal,
today_total_macros,
)
# How many recent meals the dashboard lists.
_DASHBOARD_ROWS = 5
# ISO timestamp "YYYY-MM-DDTHH:MM:SS": HH:MM is characters 11..16.
_TIME_SLICE = slice(11, 16)
# Width a meal description is truncated to in the dashboard.
_DASH_DESC_WIDTH = 22
__all__ = ["_GateDashboard"]
class _GateDashboard(_GateNutrition):
"""The calorie headline and detail panel for the day so far."""
def _refresh_dashboard(self) -> None:
"""Recompute the prominent calorie headline and the detail panel."""
self._vars.cal_headline.set(self._cal_headline_text())
self._vars.dashboard.set(self._dashboard_text())
def _cal_headline_text(self) -> str:
"""Return the big calories-today line: consumed, target, and remaining."""
consumed = today_total_kcal()
try:
budget = daily_budget()
except (BudgetError, OSError):
return f"{consumed:g} kcal today"
return (
f"{consumed:g} / {budget:g} kcal · {round(budget - consumed, 1):g} left"
)
def _dashboard_text(self) -> str:
"""Build the detail panel: recent meals, then macros and protein."""
lines = ["── Today ───────────────────────────────"]
entries = today_entries()
if entries:
for entry in entries[-_DASHBOARD_ROWS:]:
clock = str(entry.get("time", ""))[_TIME_SLICE]
desc = str(entry.get("desc", "?"))[:_DASH_DESC_WIDTH]
lines.append(
f" {clock:>5} {desc:<{_DASH_DESC_WIDTH}} "
f"{entry_kcal(entry):>5.0f} kcal",
)
else:
lines.append(" (nothing logged yet today)")
protein, carbs, fat = today_total_macros()
lines.append(f" macros so far: P{protein:g} C{carbs:g} F{fat:g} g")
target = protein_target_g()
if target is not None:
left = round(target - protein, 1)
lines.append(f" protein {protein:g} / {target:g} g ({left:g} g left)")
return "\n".join(lines)

View File

@ -0,0 +1,222 @@
"""Field widgets of the gate's "Log Meal" tab.
Split out of :mod:`._gatelock_ui` to keep every gate module under the repo's
250-line limit. This half builds the *input* widgets -- the description box,
the suggestion picker, the amount/unit row, and the macro section --
:mod:`._gatelock_layout` assembles them into the tab.
The palette and the leaf helpers stay in :mod:`._gatelock_ui`, which is the
single ``LockConfig()`` all gate modules read; deliberately not re-derived
here, since a separate copy is exactly what drifted before (see
``DESIGN_AUDIT_TODO.md``).
.. note::
This module imports ``tkinter`` and builds widgets, so it **must** appear in
``diet_guard.tests._gate_fixtures._GATE_TK_MODULES``. A module left out of
that set builds real Tk widgets under pytest against fake parents.
``test_gate_tk_modules_complete`` enforces this.
"""
from __future__ import annotations
import tkinter as tk
from typing import TYPE_CHECKING
from diet_guard import _gatelock_ui
from diet_guard._gatelock_spacing import SM, XS
from diet_guard._gatelock_typography import BODY, LABEL
from diet_guard._gatelock_ui import (
_ACCENT,
_COLORS,
_FIELD_BG,
_FONT,
BASIS_PREFIX_GRAMS,
BG,
FG,
SUGGESTION_ROWS,
UNIT_GRAMS,
UNIT_ITEMS,
_macro_cell,
_numeric_entry,
)
from diet_guard._gatelock_ui_types import _MacroEntries
if TYPE_CHECKING:
from collections.abc import Callable
from diet_guard._gatelock_ui_types import GateVars
__all__ = [
"build_amount_row",
"build_desc",
"build_macro_section",
"build_suggestion_box",
]
def build_desc(parent: tk.Frame) -> tk.Text:
"""Build and return the multi-line "what did you eat?" description box.
A multi-line ``Text`` (not an ``Entry``) so a long restaurant description
wraps onto a second line and stays fully visible, instead of scrolling off
the right edge where the end can no longer be read.
"""
tk.Label(
parent,
text="What did you eat?",
font=(_FONT, LABEL),
bg=BG,
fg=FG,
).pack()
text = tk.Text(
parent,
font=(_FONT, BODY),
width=64,
height=2,
wrap="word",
bg=_FIELD_BG,
fg=FG,
insertbackground=FG,
# focus_kwargs() sets highlightcolor (the *focused* ring). This
# previously set only highlightbackground, which is the UNFOCUSED ring
# -- so the box outlined while unfocused and went black (invisible on
# bg) the moment it took focus, exactly inverting the affordance.
**_COLORS.focus_kwargs(),
)
text.pack(pady=(XS, XS))
# Through the module, not a by-value import: the keyboard test patches
# ``_gatelock_ui._escape_text_tab_trap``, and ``escape_tab_trap`` resolves
# it at call time so the patch still bites. Importing the name directly
# would bind a copy the patch can never reach -- a fail-open that disables
# the Tab-trap escape with a green suite.
_gatelock_ui.escape_tab_trap(text)
return text
def build_suggestion_box(parent: tk.Frame) -> tk.Listbox:
"""Build the food-bank / staple / OFF picker list and return it."""
box = tk.Listbox(
parent,
font=(_FONT, BODY),
width=52,
height=SUGGESTION_ROWS,
bg=_FIELD_BG,
fg=FG,
selectbackground=_ACCENT,
selectforeground=_COLORS.on_fill,
activestyle="none",
# Was highlightthickness=0, which removed the focus ring entirely. The
# list *is* arrow-key navigable, so it was keyboard-usable with no
# visual indication of where focus was.
**_COLORS.focus_kwargs(),
)
box.pack(pady=(0, XS))
return box
def build_amount_row(
root: tk.Misc,
parent: tk.Frame,
vars_: GateVars,
on_unit_change: Callable[[str], None],
) -> tk.Entry:
"""Build the "how much did you eat?" amount + unit row; return the entry."""
tk.Label(
parent,
text="How much did you eat?",
font=(_FONT, LABEL),
bg=BG,
fg=FG,
).pack()
row = tk.Frame(parent, bg=BG)
row.pack(pady=(XS, XS))
amount_entry = _numeric_entry(root, row, width=10, variable=vars_.entries.amount)
amount_entry.pack(side="left", ipady=XS)
_build_unit_selector(row, vars_, on_unit_change)
return amount_entry
def _build_unit_selector(
row: tk.Frame,
vars_: GateVars,
on_unit_change: Callable[[str], None],
) -> None:
"""Build the grams/items selector as focusable radio buttons.
Deliberately **not** a ``tk.OptionMenu``, which this used to be, for two
independent reasons:
1. **It was unreachable by keyboard.** ``OptionMenu``'s underlying
``Menubutton`` defaults to ``takefocus=0``, so ``::tk::FocusOK`` rejects
it and it never appears in the tab ring (verified: the ring ran
Entry -> Text -> Listbox -> Button -> TNotebook and skipped it). Since
the unit rescales the entire entry, a pointerless user could not switch
between "250 grams" and "3 items" inside a lock they cannot leave
without logging a meal.
2. **A posted menu is unsafe on a lock surface.** A dropped-down Tk menu is
a separate override-redirect toplevel that steals the Tk grab, which
gatelock's recovery tick then kills within a second. That is the
documented 2026-07-26 failure where a frozen sport selector logged a walk
as table tennis, and it is why screen-locker statically bans
``OptionMenu`` on lock surfaces.
``tk.Radiobutton`` avoids both: it is in the tab ring by default and accepts
**both** ``Space`` and ``Return`` (unlike ``tk.Button``, which on X11 binds
only ``Space``), and it posts nothing.
"""
for unit in (UNIT_GRAMS, UNIT_ITEMS):
tk.Radiobutton(
row,
text=unit,
value=unit,
variable=vars_.unit,
command=lambda u=unit: on_unit_change(u),
font=(_FONT, LABEL),
bg=BG,
fg=FG,
selectcolor=_FIELD_BG,
activebackground=BG,
activeforeground=_ACCENT,
**_COLORS.focus_kwargs(),
).pack(side="left", padx=(SM, 0))
def build_macro_section(
root: tk.Misc,
parent: tk.Frame,
vars_: GateVars,
) -> tuple[tk.Label, tk.Entry, _MacroEntries]:
"""Build the per-basis field and macro row.
Returns the basis-prefix label, the "per" entry, and the four macro entries,
for the caller to store in the widget bundle.
"""
basis = tk.Frame(parent, bg=BG)
basis.pack()
basis_prefix = tk.Label(
basis,
text=BASIS_PREFIX_GRAMS,
font=(_FONT, LABEL),
bg=BG,
fg=FG,
)
basis_prefix.pack(side="left")
per_entry = _numeric_entry(root, basis, width=5, variable=vars_.entries.per)
per_entry.pack(side="left", padx=XS, ipady=XS)
tk.Label(
basis,
text="g (leave calories blank to look it up):",
font=(_FONT, LABEL),
bg=BG,
fg=FG,
).pack(side="left")
row = tk.Frame(parent, bg=BG)
row.pack(pady=(XS, XS))
macros = _MacroEntries(
kcal=_macro_cell(root, row, "kcal", vars_.entries.kcal),
protein=_macro_cell(root, row, "P", vars_.entries.protein),
carbs=_macro_cell(root, row, "C", vars_.entries.carbs),
fat=_macro_cell(root, row, "F", vars_.entries.fat),
)
return basis_prefix, per_entry, macros

View File

@ -2,208 +2,37 @@
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.
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
import contextlib
import tkinter as tk
from typing import TYPE_CHECKING, TypeVar
from typing import TYPE_CHECKING
from gatelock import WidgetGroup as SharedWidgetGroup
from diet_guard._gatelock_widgetgroups import (
EntryGroup,
ListboxGroup,
TextGroup,
WidgetGroup,
)
if TYPE_CHECKING:
from collections.abc import Callable
import tkinter as tk
from diet_guard._gatelock_ui_types import GateVars, GateWidgets
_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())
__all__ = [
"EntryGroup",
"GateWidgetsGroup",
"ListboxGroup",
"MacroGroup",
"TextGroup",
"WidgetGroup",
]
class MacroGroup:

View File

@ -0,0 +1,207 @@
"""Assembly of the gate's "Log Meal" tab.
Split out of :mod:`._gatelock_ui` to keep every gate module under the repo's
250-line limit. :mod:`._gatelock_fields` builds the individual input widgets;
this half packs them, the dashboard, and the buttons into one scrollable
surface and returns the bundle the controller drives.
.. note::
This module imports ``tkinter`` and builds widgets, so it **must** appear in
``diet_guard.tests._gate_fixtures._GATE_TK_MODULES``. A module left out of
that set builds real Tk widgets under pytest against fake parents.
``test_gate_tk_modules_complete`` enforces this.
"""
from __future__ import annotations
import tkinter as tk
from gatelock import ButtonStyle, ScrollableSurface, make_button
from diet_guard._gatelock_fields import (
build_amount_row,
build_desc,
build_macro_section,
build_suggestion_box,
)
from diet_guard._gatelock_spacing import SM, XS
from diet_guard._gatelock_typography import BODY, CAPTION, LABEL, SUBTITLE, TITLE
from diet_guard._gatelock_ui import (
_ACCENT,
_COLORS,
_FONT,
_MUTED,
_WRAP_PX,
BG,
FG,
)
from diet_guard._gatelock_ui_types import GateCallbacks, GateVars, GateWidgets
__all__ = ["build_layout"]
def _build_dashboard(parent: tk.Frame, vars_: GateVars) -> None:
"""Build the running "how am I doing today" panel.
The calorie line is large and prominent (the number the user steers by); the
meal list and macros sit beneath it in a smaller monospace block.
"""
tk.Label(
parent,
textvariable=vars_.cal_headline,
font=(_FONT, TITLE, "bold"),
bg=BG,
fg=_ACCENT,
).pack(pady=(SM, 0))
tk.Label(
parent,
textvariable=vars_.dashboard,
font=("Courier", CAPTION),
bg=BG,
fg=_MUTED,
justify="left",
anchor="w",
wraplength=_WRAP_PX,
).pack(pady=(XS, 0))
def _build_header(body: tk.Frame, vars_: GateVars) -> None:
"""Pack the title and the current-slot header."""
tk.Label(
body,
text="🍽 Diet Gate",
font=(_FONT, TITLE, "bold"),
bg=BG,
fg=_ACCENT,
).pack(pady=(0, XS))
tk.Label(
body,
textvariable=vars_.slot_header,
font=(_FONT, SUBTITLE, "bold"),
bg=BG,
fg=FG,
wraplength=_WRAP_PX,
justify="center",
).pack(pady=(0, SM))
def _build_readouts(body: tk.Frame, vars_: GateVars) -> None:
"""Pack the projection and preview lines beneath the input fields."""
tk.Label(
body,
textvariable=vars_.projection,
font=(_FONT, LABEL, "bold"),
bg=BG,
fg=FG,
wraplength=_WRAP_PX,
justify="center",
).pack(pady=(XS, XS))
tk.Label(
body,
textvariable=vars_.preview,
font=(_FONT, BODY, "bold"),
bg=BG,
fg=_ACCENT,
wraplength=_WRAP_PX,
justify="center",
).pack(pady=(XS, XS))
def _build_actions(body: tk.Frame, callbacks: GateCallbacks) -> None:
"""Pack the submit button and the manual sync-fetch button."""
make_button(
body,
_COLORS,
"Log & Continue",
callbacks.on_submit,
).pack(pady=(XS, XS))
# Manual pull for a meal already logged on another device (the phone) but
# not yet propagated to this machine -- saves re-typing it to unlock.
make_button(
body,
_COLORS,
"⟳ Fetch from sync",
callbacks.on_fetch_sync,
ButtonStyle(variant="secondary", bold=False),
).pack(pady=(0, XS))
def build_layout(
root: tk.Misc,
vars_: GateVars,
callbacks: GateCallbacks,
*,
demo_mode: bool,
) -> GateWidgets:
"""Lay out the whole gate UI and return the widgets the controller drives.
The controller calls this once (after configuring the window) and is then
responsible for binding per-keystroke events to the returned widgets.
The content lives inside a :class:`~gatelock.ScrollableSurface` rather than
a ``place``-centered frame. It has to: this tab requires ~700px even when
empty and grows a line per logged meal, so on a 1366x768 screen it exceeded
the notebook's content pane and a centered frame clipped it *symmetrically*
-- losing the "Diet Gate" title and the slot header off the top while the
calorie headline and dashboard went off the bottom, with no scrollbar to
recover either. The viewport also makes the overflow keyboard-reachable,
which matters because this is a hard lock the user cannot leave without
logging a meal. See ``tests/measure_gate_layout.py``.
"""
surface = ScrollableSurface(root, _COLORS)
frame = surface.container
body = surface.content
_build_header(body, vars_)
desc_text = build_desc(body)
suggestion_box = build_suggestion_box(body)
amount_entry = build_amount_row(
root,
body,
vars_,
callbacks.on_unit_change,
)
basis_prefix, per_entry, macros = build_macro_section(root, body, vars_)
_build_readouts(body, vars_)
_build_actions(body, callbacks)
status_label = tk.Label(
body,
textvariable=vars_.status,
font=(_FONT, LABEL),
bg=BG,
fg=FG,
wraplength=_WRAP_PX,
justify="center",
)
status_label.pack()
_build_dashboard(body, vars_)
# Wire focus-following and reset the view now that the content exists.
# Without this, Tab walks onto fields scrolled out of sight -- clipping does
# not remove a widget from the tab chain.
surface.finalize()
if demo_mode:
make_button(
root,
_COLORS,
"✕ Close Demo",
callbacks.on_close,
ButtonStyle(variant="danger", bold=False),
).place(x=10, y=10)
return GateWidgets(
frame=frame,
desc_text=desc_text,
amount_entry=amount_entry,
per_entry=per_entry,
basis_prefix=basis_prefix,
macros=macros,
suggestion_box=suggestion_box,
status_label=status_label,
)

View File

@ -0,0 +1,49 @@
"""Single-instance ``flock`` for the meal gate.
Split out of :mod:`._gatelock` to keep every gate module under the repo's
250-line limit. Deliberately the *stdlib-only* half of the gate: no ``tkinter``
import, so this module never needs to appear in the test suite's
``_GATE_TK_MODULES`` fake-tk patch set.
"""
from __future__ import annotations
import contextlib
import fcntl
from typing import TYPE_CHECKING
from diet_guard._constants import GATE_LOCK_FILE
if TYPE_CHECKING:
from typing import TextIO
__all__ = [
"GATE_LOCK_FILE",
"acquire_gate_lock",
"release_gate_lock",
]
def acquire_gate_lock() -> TextIO | None:
"""Acquire the gate's single-instance ``flock``.
Returns:
An open file handle that must be kept alive for the gate's lifetime
(closing it releases the lock), or None if another gate already holds
it -- in which case the caller must not open a second window.
"""
GATE_LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
handle = GATE_LOCK_FILE.open("w", encoding="utf-8")
try:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
handle.close()
return None
return handle
def release_gate_lock(handle: TextIO) -> None:
"""Release the single-instance lock and close its handle."""
with contextlib.suppress(OSError):
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
handle.close()

View File

@ -1,10 +1,11 @@
"""Submit/record flow and dashboard for the MealGate gate.
"""Submit/record flow for the MealGate gate.
Split out of :mod:`._gatelock` to keep that module under the repo's 500-line
Split out of :mod:`._gatelock` to keep that module under the repo's 250-line
limit. ``_GateMealFlow`` extends
:class:`~diet_guard._gatelock_nutrition._GateNutrition` with the
submit/lookup/log flow, the per-slot input reset, and the running
calorie/macro dashboard.
:class:`~diet_guard._gatelock_dashboard._GateDashboard` (itself over
:class:`~diet_guard._gatelock_nutrition._GateNutrition`) with the
submit/lookup/log flow and the per-slot input reset. The read-only
calorie/macro dashboard it calls into lives in :mod:`._gatelock_dashboard`.
"""
from __future__ import annotations
@ -13,20 +14,13 @@ import contextlib
import tkinter as tk
from typing import TYPE_CHECKING
from diet_guard._budget import BudgetError, daily_budget, protein_target_g
from diet_guard._foodbank import remember_food
from diet_guard._gate import due_slots
from diet_guard._gatelock_nutrition import _GateNutrition
from diet_guard._gatelock_dashboard import _GateDashboard
from diet_guard._gatelock_ui import ERR, FG, UNIT_GRAMS
from diet_guard._resolve import lookup_candidates
from diet_guard._slots import slot_label
from diet_guard._state import (
entry_kcal,
log_meal,
today_entries,
today_total_kcal,
today_total_macros,
)
from diet_guard._state import log_meal
from diet_guard._sync import pull_shared_log
if TYPE_CHECKING:
@ -34,15 +28,9 @@ if TYPE_CHECKING:
# How long the "unlocking..." confirmation lingers before the window tears down.
_UNLOCK_DELAY_MS = 1200
# How many recent meals the dashboard lists.
_DASHBOARD_ROWS = 5
# ISO timestamp "YYYY-MM-DDTHH:MM:SS": HH:MM is characters 11..16.
_TIME_SLICE = slice(11, 16)
# Width a meal description is truncated to in the dashboard.
_DASH_DESC_WIDTH = 22
class _GateMealFlow(_GateNutrition):
class _GateMealFlow(_GateDashboard):
"""Submit/lookup/log flow for a logged food."""
# -- slot walk ------------------------------------------------------------
@ -209,46 +197,6 @@ class _GateMealFlow(_GateNutrition):
self._set_status(f"Pulled {count} {meal_word} — next meal, please.")
self._widgets.desc_text.focus_set()
# -- dashboard --------------------------------------------------------------
def _refresh_dashboard(self) -> None:
"""Recompute the prominent calorie headline and the detail panel."""
self._vars.cal_headline.set(self._cal_headline_text())
self._vars.dashboard.set(self._dashboard_text())
def _cal_headline_text(self) -> str:
"""Return the big calories-today line: consumed, target, and remaining."""
consumed = today_total_kcal()
try:
budget = daily_budget()
except (BudgetError, OSError):
return f"{consumed:g} kcal today"
return (
f"{consumed:g} / {budget:g} kcal · {round(budget - consumed, 1):g} left"
)
def _dashboard_text(self) -> str:
"""Build the detail panel: recent meals, then macros and protein."""
lines = ["── Today ───────────────────────────────"]
entries = today_entries()
if entries:
for entry in entries[-_DASHBOARD_ROWS:]:
clock = str(entry.get("time", ""))[_TIME_SLICE]
desc = str(entry.get("desc", "?"))[:_DASH_DESC_WIDTH]
lines.append(
f" {clock:>5} {desc:<{_DASH_DESC_WIDTH}} "
f"{entry_kcal(entry):>5.0f} kcal",
)
else:
lines.append(" (nothing logged yet today)")
protein, carbs, fat = today_total_macros()
lines.append(f" macros so far: P{protein:g} C{carbs:g} F{fat:g} g")
target = protein_target_g()
if target is not None:
left = round(target - protein, 1)
lines.append(f" protein {protein:g} / {target:g} g ({left:g} g left)")
return "\n".join(lines)
def on_callback_error(self) -> None:
"""Surface an unexpected callback error without dropping the grab."""
self._set_status(

View File

@ -1,12 +1,14 @@
"""Widget construction for the diet_guard meal gate.
"""Palette and shared leaf widgets for the diet_guard meal gate.
This module owns the *view* half of the gate: the palette, the data bundles
that hold the live string variables and the interactive widgets, and the pure
functions that lay the window out. It deliberately knows nothing about slot
logic, nutrition maths, or logging -- the controller (:mod:`._gatelock`) keeps
all of that. Splitting the construction out keeps each file focused and within
a readable size; the controller imports :func:`build_layout` and wires events
to the widgets it gets back.
This module owns the *view* half's shared foundation: the palette every gate
module reads, the layout constants, and the small leaf-widget helpers. It
deliberately knows nothing about slot logic, nutrition maths, or logging --
the controller (:mod:`._gatelock`) keeps all of that.
Building the tab itself spans two siblings, so each file stays under the repo's
250-line limit: :mod:`._gatelock_fields` builds the individual input widgets,
and :mod:`._gatelock_layout` packs them into the surface, exporting
:func:`build_layout`.
The build functions take only public parameters (the root, the string-variable
bundle, and a small callbacks bundle) and return the populated widget bundle.
@ -17,23 +19,16 @@ controller internals ever cross the module boundary.
from __future__ import annotations
import tkinter as tk
from typing import TYPE_CHECKING
from gatelock import (
ButtonStyle,
LockConfig,
ScrollableSurface,
escape_text_tab_trap,
make_button,
)
from diet_guard._gatelock_spacing import SM, XS
from diet_guard._gatelock_typography import (
BODY,
CAPTION,
LABEL,
SUBTITLE,
TITLE,
)
from diet_guard._gatelock_ui_types import (
GateCallbacks,
@ -43,9 +38,6 @@ from diet_guard._gatelock_ui_types import (
_MacroEntries,
)
if TYPE_CHECKING:
from collections.abc import Callable
# Palette: sourced from the shared gatelock LockConfig (unified-design-system)
# instead of locally-invented hex literals -- see DESIGN_AUDIT_TODO.md. BG/FG/
# ERR stay as re-exports since _gatelock_mealflow and _gatelock_calendar
@ -85,7 +77,6 @@ __all__ = [
"GateVars",
"GateWidgets",
"_MacroEntries",
"build_layout",
"is_numeric_or_blank",
"make_vars",
]
@ -161,317 +152,14 @@ def _macro_cell(
_escape_text_tab_trap = escape_text_tab_trap
def _build_desc(parent: tk.Frame) -> tk.Text:
"""Build and return the multi-line "what did you eat?" description box.
def escape_tab_trap(text: tk.Text) -> None:
"""Let Tab leave ``text`` instead of inserting a tab character.
A multi-line ``Text`` (not an ``Entry``) so a long restaurant description
wraps onto a second line and stays fully visible, instead of scrolling off
the right edge where the end can no longer be read.
A thin public indirection over :data:`_escape_text_tab_trap`, resolved at
**call** time. :mod:`._gatelock_fields`, which owns the only call site,
goes through this rather than importing the private name by value: the
keyboard test patches ``_gatelock_ui._escape_text_tab_trap``, and a
by-value import elsewhere would bind a copy the patch can never reach --
silently disabling the Tab-trap escape while the suite stays green.
"""
tk.Label(
parent,
text="What did you eat?",
font=(_FONT, LABEL),
bg=BG,
fg=FG,
).pack()
text = tk.Text(
parent,
font=(_FONT, BODY),
width=64,
height=2,
wrap="word",
bg=_FIELD_BG,
fg=FG,
insertbackground=FG,
# focus_kwargs() sets highlightcolor (the *focused* ring). This
# previously set only highlightbackground, which is the UNFOCUSED ring
# -- so the box outlined while unfocused and went black (invisible on
# bg) the moment it took focus, exactly inverting the affordance.
**_COLORS.focus_kwargs(),
)
text.pack(pady=(XS, XS))
_escape_text_tab_trap(text)
return text
def _build_suggestion_box(parent: tk.Frame) -> tk.Listbox:
"""Build the food-bank / staple / OFF picker list and return it."""
box = tk.Listbox(
parent,
font=(_FONT, BODY),
width=52,
height=SUGGESTION_ROWS,
bg=_FIELD_BG,
fg=FG,
selectbackground=_ACCENT,
selectforeground=_COLORS.on_fill,
activestyle="none",
# Was highlightthickness=0, which removed the focus ring entirely. The
# list *is* arrow-key navigable, so it was keyboard-usable with no
# visual indication of where focus was.
**_COLORS.focus_kwargs(),
)
box.pack(pady=(0, XS))
return box
def _build_amount_row(
root: tk.Misc,
parent: tk.Frame,
vars_: GateVars,
on_unit_change: Callable[[str], None],
) -> tk.Entry:
"""Build the "how much did you eat?" amount + unit row; return the entry."""
tk.Label(
parent,
text="How much did you eat?",
font=(_FONT, LABEL),
bg=BG,
fg=FG,
).pack()
row = tk.Frame(parent, bg=BG)
row.pack(pady=(XS, XS))
amount_entry = _numeric_entry(root, row, width=10, variable=vars_.entries.amount)
amount_entry.pack(side="left", ipady=XS)
_build_unit_selector(row, vars_, on_unit_change)
return amount_entry
def _build_unit_selector(
row: tk.Frame,
vars_: GateVars,
on_unit_change: Callable[[str], None],
) -> None:
"""Build the grams/items selector as focusable radio buttons.
Deliberately **not** a ``tk.OptionMenu``, which this used to be, for two
independent reasons:
1. **It was unreachable by keyboard.** ``OptionMenu``'s underlying
``Menubutton`` defaults to ``takefocus=0``, so ``::tk::FocusOK`` rejects
it and it never appears in the tab ring (verified: the ring ran
Entry -> Text -> Listbox -> Button -> TNotebook and skipped it). Since
the unit rescales the entire entry, a pointerless user could not switch
between "250 grams" and "3 items" inside a lock they cannot leave
without logging a meal.
2. **A posted menu is unsafe on a lock surface.** A dropped-down Tk menu is
a separate override-redirect toplevel that steals the Tk grab, which
gatelock's recovery tick then kills within a second. That is the
documented 2026-07-26 failure where a frozen sport selector logged a walk
as table tennis, and it is why screen-locker statically bans
``OptionMenu`` on lock surfaces.
``tk.Radiobutton`` avoids both: it is in the tab ring by default and accepts
**both** ``Space`` and ``Return`` (unlike ``tk.Button``, which on X11 binds
only ``Space``), and it posts nothing.
"""
for unit in (UNIT_GRAMS, UNIT_ITEMS):
tk.Radiobutton(
row,
text=unit,
value=unit,
variable=vars_.unit,
command=lambda u=unit: on_unit_change(u),
font=(_FONT, LABEL),
bg=BG,
fg=FG,
selectcolor=_FIELD_BG,
activebackground=BG,
activeforeground=_ACCENT,
**_COLORS.focus_kwargs(),
).pack(side="left", padx=(SM, 0))
def _build_macro_section(
root: tk.Misc,
parent: tk.Frame,
vars_: GateVars,
) -> tuple[tk.Label, tk.Entry, _MacroEntries]:
"""Build the per-basis field and macro row.
Returns the basis-prefix label, the "per" entry, and the four macro entries,
for the caller to store in the widget bundle.
"""
basis = tk.Frame(parent, bg=BG)
basis.pack()
basis_prefix = tk.Label(
basis,
text=BASIS_PREFIX_GRAMS,
font=(_FONT, LABEL),
bg=BG,
fg=FG,
)
basis_prefix.pack(side="left")
per_entry = _numeric_entry(root, basis, width=5, variable=vars_.entries.per)
per_entry.pack(side="left", padx=XS, ipady=XS)
tk.Label(
basis,
text="g (leave calories blank to look it up):",
font=(_FONT, LABEL),
bg=BG,
fg=FG,
).pack(side="left")
row = tk.Frame(parent, bg=BG)
row.pack(pady=(XS, XS))
macros = _MacroEntries(
kcal=_macro_cell(root, row, "kcal", vars_.entries.kcal),
protein=_macro_cell(root, row, "P", vars_.entries.protein),
carbs=_macro_cell(root, row, "C", vars_.entries.carbs),
fat=_macro_cell(root, row, "F", vars_.entries.fat),
)
return basis_prefix, per_entry, macros
def _build_dashboard(parent: tk.Frame, vars_: GateVars) -> None:
"""Build the running "how am I doing today" panel.
The calorie line is large and prominent (the number the user steers by); the
meal list and macros sit beneath it in a smaller monospace block.
"""
tk.Label(
parent,
textvariable=vars_.cal_headline,
font=(_FONT, TITLE, "bold"),
bg=BG,
fg=_ACCENT,
).pack(pady=(SM, 0))
tk.Label(
parent,
textvariable=vars_.dashboard,
font=("Courier", CAPTION),
bg=BG,
fg=_MUTED,
justify="left",
anchor="w",
wraplength=_WRAP_PX,
).pack(pady=(XS, 0))
def build_layout(
root: tk.Misc,
vars_: GateVars,
callbacks: GateCallbacks,
*,
demo_mode: bool,
) -> GateWidgets:
"""Lay out the whole gate UI and return the widgets the controller drives.
The controller calls this once (after configuring the window) and is then
responsible for binding per-keystroke events to the returned widgets.
The content lives inside a :class:`~gatelock.ScrollableSurface` rather than
a ``place``-centered frame. It has to: this tab requires ~700px even when
empty and grows a line per logged meal, so on a 1366x768 screen it exceeded
the notebook's content pane and a centered frame clipped it *symmetrically*
-- losing the "Diet Gate" title and the slot header off the top while the
calorie headline and dashboard went off the bottom, with no scrollbar to
recover either. The viewport also makes the overflow keyboard-reachable,
which matters because this is a hard lock the user cannot leave without
logging a meal. See ``tests/measure_gate_layout.py``.
"""
surface = ScrollableSurface(root, _COLORS)
frame = surface.container
body = surface.content
tk.Label(
body,
text="🍽 Diet Gate",
font=(_FONT, TITLE, "bold"),
bg=BG,
fg=_ACCENT,
).pack(pady=(0, XS))
tk.Label(
body,
textvariable=vars_.slot_header,
font=(_FONT, SUBTITLE, "bold"),
bg=BG,
fg=FG,
wraplength=_WRAP_PX,
justify="center",
).pack(pady=(0, SM))
desc_text = _build_desc(body)
suggestion_box = _build_suggestion_box(body)
amount_entry = _build_amount_row(
root,
body,
vars_,
callbacks.on_unit_change,
)
basis_prefix, per_entry, macros = _build_macro_section(root, body, vars_)
tk.Label(
body,
textvariable=vars_.projection,
font=(_FONT, LABEL, "bold"),
bg=BG,
fg=FG,
wraplength=_WRAP_PX,
justify="center",
).pack(pady=(XS, XS))
tk.Label(
body,
textvariable=vars_.preview,
font=(_FONT, BODY, "bold"),
bg=BG,
fg=_ACCENT,
wraplength=_WRAP_PX,
justify="center",
).pack(pady=(XS, XS))
make_button(
body,
_COLORS,
"Log & Continue",
callbacks.on_submit,
).pack(pady=(XS, XS))
# Manual pull for a meal already logged on another device (the phone) but
# not yet propagated to this machine -- saves re-typing it to unlock.
make_button(
body,
_COLORS,
"⟳ Fetch from sync",
callbacks.on_fetch_sync,
ButtonStyle(variant="secondary", bold=False),
).pack(pady=(0, XS))
status_label = tk.Label(
body,
textvariable=vars_.status,
font=(_FONT, LABEL),
bg=BG,
fg=FG,
wraplength=_WRAP_PX,
justify="center",
)
status_label.pack()
_build_dashboard(body, vars_)
# Wire focus-following and reset the view now that the content exists.
# Without this, Tab walks onto fields scrolled out of sight -- clipping does
# not remove a widget from the tab chain.
surface.finalize()
if demo_mode:
make_button(
root,
_COLORS,
"✕ Close Demo",
callbacks.on_close,
ButtonStyle(variant="danger", bold=False),
).place(x=10, y=10)
return GateWidgets(
frame=frame,
desc_text=desc_text,
amount_entry=amount_entry,
per_entry=per_entry,
basis_prefix=basis_prefix,
macros=macros,
suggestion_box=suggestion_box,
status_label=status_label,
)

View File

@ -0,0 +1,209 @@
"""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())

View File

@ -23,9 +23,14 @@ import pytest
from diet_guard import (
_gatelock,
_gatelock_budgetedit,
_gatelock_calendar,
_gatelock_calendar_types,
_gatelock_calendar_ui,
_gatelock_calendar_widgets,
_gatelock_core,
_gatelock_fields,
_gatelock_layout,
_gatelock_mealflow,
_gatelock_nutrition,
_gatelock_ui,
@ -145,8 +150,19 @@ _GATE_TK_MODULES = (
# needs the fake tk too -- otherwise the gate tests open real windows.
_gatelock_widgets,
_gatelock_calendar,
_gatelock_calendar_ui,
# NOT _gatelock_calendar_ui: since the History tab's builders moved to
# _gatelock_calendar_widgets it imports only `ttk`, which is patched
# separately in fake_tk(). Listing it here fails with "does not have the
# attribute 'tk'".
_gatelock_calendar_widgets,
# Builds the History tab's StringVars (make_calendar_vars); without the
# fake those are REAL tkinter vars over a MagicMock root, so every
# `.get()` returns a MagicMock instead of the text under test.
_gatelock_calendar_types,
_gatelock_budgetedit,
_gatelock_core,
_gatelock_fields,
_gatelock_layout,
_gatelock_nutrition,
_gatelock_mealflow,
_gatelock_ui,

View File

@ -0,0 +1,116 @@
"""Focus-reachability probing for the gate layout measurement.
Split out of :mod:`._gate_layout_probe` to keep both files under the repo's
250-line limit. That module builds the layout and owns the
:class:`Measurement` result; this one interrogates a built viewport -- does it
scroll by keyboard, and does the view follow focus to the *bottom* of the
content, where the submit button and dashboard live.
Test support, not a test, for the same reason as its sibling: its
diagnostic-only branches are not held to the package's 100%-coverage bar.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from diet_guard.tests._gate_measurement import Measurement
if TYPE_CHECKING:
import tkinter as tk
# Tk widget classes that take keyboard focus by default. Checking `takefocus`
# is not enough: it is "" on most widgets, meaning "ask ::tk::FocusOK", so an
# empty value indicates a *default*-focusable widget rather than an opted-out
# one. Matching on class is what actually mirrors FocusOK's behaviour here.
_FOCUSABLE_CLASSES = frozenset(
{
"Button",
"Checkbutton",
"Entry",
"Listbox",
"Radiobutton",
"Spinbox",
"Text",
}
)
def _is_focusable(widget: tk.Misc) -> bool:
"""Whether ``widget`` would accept keyboard focus."""
if str(widget.cget("takefocus")) == "0":
return False
if str(widget.cget("takefocus")) == "1":
return True
return widget.winfo_class() in _FOCUSABLE_CLASSES
def _deepest_focusable(widget: tk.Misc) -> tk.Misc | None:
"""Return the last descendant that can take keyboard focus.
Used to prove the *bottom* of the content is reachable, which is where the
submit button and the dashboard live -- exactly what a centered, clipping
frame used to hide.
"""
found: tk.Misc | None = None
for child in widget.winfo_children():
if _is_focusable(child):
found = child
deeper = _deepest_focusable(child)
if deeper is not None:
found = deeper
return found
def _probe_viewport(
root: tk.Misc,
canvas: tk.Canvas,
inner: tk.Misc,
screen_w: int,
screen_h: int,
) -> Measurement:
"""Interrogate a built viewport: does it scroll, and does focus follow?
Split out of :func:`measure` so each function does one thing -- building the
layout, and interrogating it -- and so neither trips the local-variable cap.
"""
box = canvas.bbox("all")
span = (box[3] - box[1]) if box else 0
viewport_h = canvas.winfo_height()
canvas.yview_moveto(0.0)
root.update_idletasks()
canvas.yview_scroll(1, "pages")
root.update_idletasks()
scrolls = canvas.yview()[0] > 0.0
# Scroll back to the top, then focus the deepest widget and check it ends up
# on screen. Starting from the top is what makes this a real test: it is the
# state a user arrives in.
canvas.yview_moveto(0.0)
root.update_idletasks()
target = _deepest_focusable(inner)
last_visible = False
if target is not None:
# A real keypress first, then the focus it would have moved. Since
# 2026-08-03 gatelock only scrolls to follow focus the *user* moved:
# it used to follow programmatic focus too, and because the apps
# re-focus a widget on every repaint, the screen scrolled itself to
# mid-content and back while the user was doing nothing. A bare
# focus_set() here would therefore test a path no user can take.
target.event_generate("<Key-Tab>", when="now")
target.focus_set()
root.update()
root.update_idletasks()
top = target.winfo_rooty() - inner.winfo_rooty()
visible_from = canvas.canvasy(0)
last_visible = visible_from <= top <= visible_from + viewport_h
return Measurement(
screen_w=screen_w,
screen_h=screen_h,
content_span=span,
viewport_h=viewport_h,
scrolls_by_keyboard=scrolls,
last_widget_visible_after_focus=last_visible,
)

View File

@ -21,7 +21,8 @@ from dataclasses import dataclass, field
import tkinter as tk
from tkinter import ttk
from diet_guard._gatelock_ui import GateCallbacks, build_layout
from diet_guard._gatelock_layout import build_layout
from diet_guard._gatelock_ui import GateCallbacks
from diet_guard.tests._gate_layout_probe import _make_vars, _noop
# How far to walk before declaring the ring non-terminating.

View File

@ -31,12 +31,14 @@ Run the gate with:
from __future__ import annotations
from dataclasses import dataclass
import tkinter as tk
from tkinter import ttk
from diet_guard._gatelock_ui import GateCallbacks, GateVars, build_layout
from diet_guard._gatelock_layout import build_layout
from diet_guard._gatelock_ui import GateCallbacks, GateVars
from diet_guard._gatelock_ui_types import GateEntryVars
from diet_guard.tests._gate_focus_probe import _probe_viewport
from diet_guard.tests._gate_measurement import Measurement
# ttk.Notebook's tab strip is chrome the content pane never gets to use.
# Measured rather than assumed; see _notebook_overhead().
@ -53,62 +55,6 @@ _SAMPLE_DASHBOARD = (
)
@dataclass(frozen=True)
class Measurement:
"""One layout measurement against one screen size."""
screen_w: int
screen_h: int
content_span: int
viewport_h: int
scrolls_by_keyboard: bool
last_widget_visible_after_focus: bool
has_scroll_viewport: bool = True
@property
def needs_scroll(self) -> bool:
"""Whether the content is taller than the viewport."""
return self.content_span > self.viewport_h
@property
def reachable(self) -> bool:
"""Whether every part of the content can be reached by keyboard.
Content that fits is trivially reachable. Content that overflows must
scroll by keyboard *and* bring a focused below-the-fold widget into
view.
Deliberately not asserted: that focusing always *moves* the view. When
the overflow is small the deepest widget is already visible, and then
holding still is the correct behaviour -- the invariant is "the focused
widget is visible", not "the viewport scrolled".
"""
if not self.needs_scroll:
return True
return self.scrolls_by_keyboard and self.last_widget_visible_after_focus
def describe(self) -> str:
"""Return a one-line human-readable summary."""
fit = (
f"needs scroll (+{self.content_span - self.viewport_h}px)"
if self.needs_scroll
else "fits"
)
verdict = "REACHABLE" if self.reachable else "UNREACHABLE CONTENT"
viewport = (
"scroll viewport present"
if self.has_scroll_viewport
else "NO SCROLL VIEWPORT -- overflow is clipped, not scrolled"
)
return (
f"{self.screen_w}x{self.screen_h}: "
f"content {self.content_span} in viewport {self.viewport_h} "
f"-> {fit}; {viewport}; keys={self.scrolls_by_keyboard} "
f"last_visible={self.last_widget_visible_after_focus} "
f"-> {verdict}"
)
def _make_vars(*, populated: bool) -> GateVars:
"""Build the gate's Tk variables, optionally with realistic content.
@ -166,103 +112,6 @@ def _notebook_overhead(root: tk.Misc) -> int:
return overhead if overhead > 0 else _FALLBACK_TAB_STRIP_PX
# Tk widget classes that take keyboard focus by default. Checking `takefocus`
# is not enough: it is "" on most widgets, meaning "ask ::tk::FocusOK", so an
# empty value indicates a *default*-focusable widget rather than an opted-out
# one. Matching on class is what actually mirrors FocusOK's behaviour here.
_FOCUSABLE_CLASSES = frozenset(
{
"Button",
"Checkbutton",
"Entry",
"Listbox",
"Radiobutton",
"Spinbox",
"Text",
}
)
def _is_focusable(widget: tk.Misc) -> bool:
"""Whether ``widget`` would accept keyboard focus."""
if str(widget.cget("takefocus")) == "0":
return False
if str(widget.cget("takefocus")) == "1":
return True
return widget.winfo_class() in _FOCUSABLE_CLASSES
def _deepest_focusable(widget: tk.Misc) -> tk.Misc | None:
"""Return the last descendant that can take keyboard focus.
Used to prove the *bottom* of the content is reachable, which is where the
submit button and the dashboard live -- exactly what a centered, clipping
frame used to hide.
"""
found: tk.Misc | None = None
for child in widget.winfo_children():
if _is_focusable(child):
found = child
deeper = _deepest_focusable(child)
if deeper is not None:
found = deeper
return found
def _probe_viewport(
root: tk.Misc,
canvas: tk.Canvas,
inner: tk.Misc,
screen_w: int,
screen_h: int,
) -> Measurement:
"""Interrogate a built viewport: does it scroll, and does focus follow?
Split out of :func:`measure` so each function does one thing -- building the
layout, and interrogating it -- and so neither trips the local-variable cap.
"""
box = canvas.bbox("all")
span = (box[3] - box[1]) if box else 0
viewport_h = canvas.winfo_height()
canvas.yview_moveto(0.0)
root.update_idletasks()
canvas.yview_scroll(1, "pages")
root.update_idletasks()
scrolls = canvas.yview()[0] > 0.0
# Scroll back to the top, then focus the deepest widget and check it ends up
# on screen. Starting from the top is what makes this a real test: it is the
# state a user arrives in.
canvas.yview_moveto(0.0)
root.update_idletasks()
target = _deepest_focusable(inner)
last_visible = False
if target is not None:
# A real keypress first, then the focus it would have moved. Since
# 2026-08-03 gatelock only scrolls to follow focus the *user* moved:
# it used to follow programmatic focus too, and because the apps
# re-focus a widget on every repaint, the screen scrolled itself to
# mid-content and back while the user was doing nothing. A bare
# focus_set() here would therefore test a path no user can take.
target.event_generate("<Key-Tab>", when="now")
target.focus_set()
root.update()
root.update_idletasks()
top = target.winfo_rooty() - inner.winfo_rooty()
visible_from = canvas.canvasy(0)
last_visible = visible_from <= top <= visible_from + viewport_h
return Measurement(
screen_w=screen_w,
screen_h=screen_h,
content_span=span,
viewport_h=viewport_h,
scrolls_by_keyboard=scrolls,
last_widget_visible_after_focus=last_visible,
)
def measure(
screen_w: int = 1366,
screen_h: int = 768,

View File

@ -0,0 +1,70 @@
"""The result type shared by the gate's layout and focus probes.
Split out of :mod:`._gate_layout_probe` so that module and
:mod:`._gate_focus_probe` can both import it without a cycle, and so each
file stays under the repo's 250-line limit.
Test support, not a test: it lives under ``tests/`` rather than in the
package, so its diagnostic-only branches are not held to the package's
100%-coverage bar.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Measurement:
"""One layout measurement against one screen size."""
screen_w: int
screen_h: int
content_span: int
viewport_h: int
scrolls_by_keyboard: bool
last_widget_visible_after_focus: bool
has_scroll_viewport: bool = True
@property
def needs_scroll(self) -> bool:
"""Whether the content is taller than the viewport."""
return self.content_span > self.viewport_h
@property
def reachable(self) -> bool:
"""Whether every part of the content can be reached by keyboard.
Content that fits is trivially reachable. Content that overflows must
scroll by keyboard *and* bring a focused below-the-fold widget into
view.
Deliberately not asserted: that focusing always *moves* the view. When
the overflow is small the deepest widget is already visible, and then
holding still is the correct behaviour -- the invariant is "the focused
widget is visible", not "the viewport scrolled".
"""
if not self.needs_scroll:
return True
return self.scrolls_by_keyboard and self.last_widget_visible_after_focus
def describe(self) -> str:
"""Return a one-line human-readable summary."""
fit = (
f"needs scroll (+{self.content_span - self.viewport_h}px)"
if self.needs_scroll
else "fits"
)
verdict = "REACHABLE" if self.reachable else "UNREACHABLE CONTENT"
viewport = (
"scroll viewport present"
if self.has_scroll_viewport
else "NO SCROLL VIEWPORT -- overflow is clipped, not scrolled"
)
return (
f"{self.screen_w}x{self.screen_h}: "
f"content {self.content_span} in viewport {self.viewport_h} "
f"-> {fit}; {viewport}; keys={self.scrolls_by_keyboard} "
f"last_visible={self.last_widget_visible_after_focus} "
f"-> {verdict}"
)

View File

@ -119,7 +119,7 @@ def _isolate_state(tmp_path: Path) -> Iterator[None]:
tmp_path / "food_bank_manual.json",
),
patch(
"diet_guard._gatelock.GATE_LOCK_FILE",
"diet_guard._gatelock_lockfile.GATE_LOCK_FILE",
tmp_path / ".gate.lock",
),
patch(

View File

@ -0,0 +1,119 @@
"""Guard: every gate module that builds Tk widgets must be faked in tests.
``_gate_fixtures.fake_tk`` patches ``tk`` on a hand-listed tuple of modules.
That list fails **open**: split a widget builder into a new sibling, forget to
add it, and the gate tests build *real* Tk widgets parented to fake ones. The
symptom is a hang or an error far from the cause -- and on a machine with a
display, a real window during a test run.
Neither of the sweep's other detectors sees this. ``check_patch_targets.py``
only validates dotted paths that still resolve, and the suite's ~8s runtime
only catches network calls. So the invariant is asserted here directly:
*every* ``diet_guard`` module that binds the name ``tk`` is either in the
patch set or explicitly declared widget-free.
"""
from __future__ import annotations
import ast
import pathlib
from diet_guard.tests._gate_fixtures import _GATE_TK_MODULES
#: Modules that bind ``tk`` but build no part of the gate's widget tree, so
#: they never need the shared fake. Each is exempt for a stated reason -- this
#: set is the only sanctioned way out of the patch list.
_NO_WIDGETS = frozenset(
{
# Mirror/adapter layer over widgets built elsewhere; constructs none
# of its own, but does bind ``tk`` at runtime for its ``TclError``
# suppressions.
"diet_guard._gatelock_widgetgroups",
# Display-readiness probe. Builds a throwaway ``tk.Tk()`` to test
# whether X is reachable -- deliberately NOT part of the gate's widget
# tree, so the shared fake would defeat the very thing it probes. Its
# own tests patch ``_gatelock_support.tk`` per-test instead.
"diet_guard._gatelock_support",
},
)
_PACKAGE_DIR = pathlib.Path(__file__).resolve().parent.parent
def _is_type_checking(test: ast.expr) -> bool:
"""Whether an ``if`` test is the ``TYPE_CHECKING`` guard."""
if isinstance(test, ast.Name):
return test.id == "TYPE_CHECKING"
return isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING"
def _binds_tk(path: pathlib.Path) -> bool:
"""Whether ``path`` binds the name ``tk`` to tkinter at module level.
That name -- not merely "imports tkinter" -- is what ``fake_tk`` patches,
so it is what the patch set has to match. A module importing only ``ttk``
(``_gatelock_calendar_ui``) has no ``tk`` to patch, and listing it would
raise "does not have the attribute 'tk'".
"""
tree = ast.parse(path.read_text(encoding="utf-8"), str(path))
# A TYPE_CHECKING-only import binds nothing at runtime, so there is no
# attribute for ``patch.object`` to replace -- and listing such a module
# would raise "does not have the attribute 'tk'". Drop those blocks
# wholesale before walking, since ``ast.walk`` would otherwise still
# descend into their bodies.
tree.body = [
node
for node in tree.body
if not (isinstance(node, ast.If) and _is_type_checking(node.test))
]
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
if alias.name == "tkinter" and alias.asname == "tk":
return True
elif (
isinstance(node, ast.ImportFrom)
and node.module
and node.module.split(".")[0] == "tkinter"
) and any(alias.asname == "tk" or alias.name == "tk" for alias in node.names):
return True
return False
def test_gate_tk_modules_complete() -> None:
"""Every ``tk``-binding diet_guard module is faked or declared widget-free.
Fails closed: a new widget-building sibling that nobody added to
``_GATE_TK_MODULES`` shows up here as a named diff, rather than as a real
Tk window opening halfway through an unrelated gate test.
"""
patched = {module.__name__ for module in _GATE_TK_MODULES}
importers = {
f"diet_guard.{path.stem}"
for path in sorted(_PACKAGE_DIR.glob("*.py"))
if _binds_tk(path)
}
unaccounted = importers - patched - _NO_WIDGETS
assert not unaccounted, (
"these diet_guard modules bind `tk` but are neither in "
"_gate_fixtures._GATE_TK_MODULES nor declared widget-free in "
f"_NO_WIDGETS: {sorted(unaccounted)}"
)
def test_no_widgets_declarations_are_live() -> None:
"""``_NO_WIDGETS`` names only modules that really do bind ``tk``.
Keeps the escape hatch honest: once a listed module stops binding ``tk``
(or is renamed away), its entry is stale and must go, rather than
lingering as a licence for some future file to skip the patch set.
"""
importers = {
f"diet_guard.{path.stem}"
for path in sorted(_PACKAGE_DIR.glob("*.py"))
if _binds_tk(path)
}
assert importers >= _NO_WIDGETS, (
"stale _NO_WIDGETS entries (no longer bind `tk`): "
f"{sorted(_NO_WIDGETS - importers)}"
)

View File

@ -22,7 +22,7 @@ from typing import TYPE_CHECKING
import pytest
from diet_guard import _gatelock_typography, _gatelock_ui
from diet_guard import _gatelock_fields, _gatelock_typography, _gatelock_ui
from diet_guard._gatelock_ui import UNIT_GRAMS, UNIT_ITEMS
from diet_guard.tests import _gate_keyboard_probe
@ -57,7 +57,7 @@ class TestUnitSelectorIsKeyboardReachable:
try:
selector_row = tk.Frame(root)
vars_ = _gatelock_ui.make_vars(root)
_gatelock_ui._build_unit_selector(selector_row, vars_, lambda _u: None)
_gatelock_fields._build_unit_selector(selector_row, vars_, lambda _u: None)
classes = [child.winfo_class() for child in selector_row.winfo_children()]
assert classes == ["Radiobutton", "Radiobutton"]
assert "Menubutton" not in classes
@ -71,7 +71,7 @@ class TestUnitSelectorIsKeyboardReachable:
row = tk.Frame(root)
row.pack()
vars_ = _gatelock_ui.make_vars(root)
_gatelock_ui._build_unit_selector(row, vars_, lambda _u: None)
_gatelock_fields._build_unit_selector(row, vars_, lambda _u: None)
root.focus_force()
root.update()
root.update_idletasks()
@ -87,7 +87,7 @@ class TestUnitSelectorIsKeyboardReachable:
row = tk.Frame(root)
vars_ = _gatelock_ui.make_vars(root)
seen: list[str] = []
_gatelock_ui._build_unit_selector(row, vars_, seen.append)
_gatelock_fields._build_unit_selector(row, vars_, seen.append)
items = [
child
for child in row.winfo_children()