From e8f0ecaae1b1c262cf8bec39e72bfb063bd71b30 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 20:08:03 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC --- .pre-commit-config.yaml | 2 +- app/pubspec.yaml | 2 +- diet_guard/_gatelock.py | 39 +-- diet_guard/_gatelock_budgetedit.py | 117 +++++++ diet_guard/_gatelock_calendar.py | 106 +------ diet_guard/_gatelock_calendar_types.py | 94 ++++++ diet_guard/_gatelock_calendar_ui.py | 294 +++-------------- diet_guard/_gatelock_calendar_widgets.py | 216 +++++++++++++ diet_guard/_gatelock_dashboard.py | 72 +++++ diet_guard/_gatelock_fields.py | 222 +++++++++++++ diet_guard/_gatelock_groups.py | 213 ++----------- diet_guard/_gatelock_layout.py | 207 ++++++++++++ diet_guard/_gatelock_lockfile.py | 49 +++ diet_guard/_gatelock_mealflow.py | 70 +---- diet_guard/_gatelock_ui.py | 348 ++------------------- diet_guard/_gatelock_widgetgroups.py | 209 +++++++++++++ diet_guard/tests/_gate_fixtures.py | 18 +- diet_guard/tests/_gate_focus_probe.py | 116 +++++++ diet_guard/tests/_gate_keyboard_probe.py | 3 +- diet_guard/tests/_gate_layout_probe.py | 159 +--------- diet_guard/tests/_gate_measurement.py | 70 +++++ diet_guard/tests/conftest.py | 2 +- diet_guard/tests/test_gate_tk_modules.py | 119 +++++++ diet_guard/tests/test_gatelock_keyboard.py | 8 +- 24 files changed, 1631 insertions(+), 1124 deletions(-) create mode 100644 diet_guard/_gatelock_budgetedit.py create mode 100644 diet_guard/_gatelock_calendar_types.py create mode 100644 diet_guard/_gatelock_calendar_widgets.py create mode 100644 diet_guard/_gatelock_dashboard.py create mode 100644 diet_guard/_gatelock_fields.py create mode 100644 diet_guard/_gatelock_layout.py create mode 100644 diet_guard/_gatelock_lockfile.py create mode 100644 diet_guard/_gatelock_widgetgroups.py create mode 100644 diet_guard/tests/_gate_focus_probe.py create mode 100644 diet_guard/tests/_gate_measurement.py create mode 100644 diet_guard/tests/test_gate_tk_modules.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 265a493..69dc7a2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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] diff --git a/app/pubspec.yaml b/app/pubspec.yaml index ebfce0e..c37a41a 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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 diff --git a/diet_guard/_gatelock.py b/diet_guard/_gatelock.py index 567e75b..b6b7c5e 100644 --- a/diet_guard/_gatelock.py +++ b/diet_guard/_gatelock.py @@ -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. diff --git a/diet_guard/_gatelock_budgetedit.py b/diet_guard/_gatelock_budgetedit.py new file mode 100644 index 0000000..e856c9c --- /dev/null +++ b/diet_guard/_gatelock_budgetedit.py @@ -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 diff --git a/diet_guard/_gatelock_calendar.py b/diet_guard/_gatelock_calendar.py index e0aaf7d..3fdff51 100644 --- a/diet_guard/_gatelock_calendar.py +++ b/diet_guard/_gatelock_calendar.py @@ -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 diff --git a/diet_guard/_gatelock_calendar_types.py b/diet_guard/_gatelock_calendar_types.py new file mode 100644 index 0000000..6ce6b7c --- /dev/null +++ b/diet_guard/_gatelock_calendar_types.py @@ -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=""), + ) diff --git a/diet_guard/_gatelock_calendar_ui.py b/diet_guard/_gatelock_calendar_ui.py index 1109df1..4e23d47 100644 --- a/diet_guard/_gatelock_calendar_ui.py +++ b/diet_guard/_gatelock_calendar_ui.py @@ -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: diff --git a/diet_guard/_gatelock_calendar_widgets.py b/diet_guard/_gatelock_calendar_widgets.py new file mode 100644 index 0000000..1ffb133 --- /dev/null +++ b/diet_guard/_gatelock_calendar_widgets.py @@ -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, + ) diff --git a/diet_guard/_gatelock_dashboard.py b/diet_guard/_gatelock_dashboard.py new file mode 100644 index 0000000..cdd618a --- /dev/null +++ b/diet_guard/_gatelock_dashboard.py @@ -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) diff --git a/diet_guard/_gatelock_fields.py b/diet_guard/_gatelock_fields.py new file mode 100644 index 0000000..4af175b --- /dev/null +++ b/diet_guard/_gatelock_fields.py @@ -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 diff --git a/diet_guard/_gatelock_groups.py b/diet_guard/_gatelock_groups.py index 2e20f5e..a4ae20b 100644 --- a/diet_guard/_gatelock_groups.py +++ b/diet_guard/_gatelock_groups.py @@ -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 ```` 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: diff --git a/diet_guard/_gatelock_layout.py b/diet_guard/_gatelock_layout.py new file mode 100644 index 0000000..f3737da --- /dev/null +++ b/diet_guard/_gatelock_layout.py @@ -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, + ) diff --git a/diet_guard/_gatelock_lockfile.py b/diet_guard/_gatelock_lockfile.py new file mode 100644 index 0000000..312e933 --- /dev/null +++ b/diet_guard/_gatelock_lockfile.py @@ -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() diff --git a/diet_guard/_gatelock_mealflow.py b/diet_guard/_gatelock_mealflow.py index a367bc6..e8088b4 100644 --- a/diet_guard/_gatelock_mealflow.py +++ b/diet_guard/_gatelock_mealflow.py @@ -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( diff --git a/diet_guard/_gatelock_ui.py b/diet_guard/_gatelock_ui.py index 9062697..fe013de 100644 --- a/diet_guard/_gatelock_ui.py +++ b/diet_guard/_gatelock_ui.py @@ -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, - ) diff --git a/diet_guard/_gatelock_widgetgroups.py b/diet_guard/_gatelock_widgetgroups.py new file mode 100644 index 0000000..666ebb2 --- /dev/null +++ b/diet_guard/_gatelock_widgetgroups.py @@ -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 ```` 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()) diff --git a/diet_guard/tests/_gate_fixtures.py b/diet_guard/tests/_gate_fixtures.py index a4c0a07..b78428b 100644 --- a/diet_guard/tests/_gate_fixtures.py +++ b/diet_guard/tests/_gate_fixtures.py @@ -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, diff --git a/diet_guard/tests/_gate_focus_probe.py b/diet_guard/tests/_gate_focus_probe.py new file mode 100644 index 0000000..851bb65 --- /dev/null +++ b/diet_guard/tests/_gate_focus_probe.py @@ -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("", 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, + ) diff --git a/diet_guard/tests/_gate_keyboard_probe.py b/diet_guard/tests/_gate_keyboard_probe.py index d6e7aa3..02df314 100644 --- a/diet_guard/tests/_gate_keyboard_probe.py +++ b/diet_guard/tests/_gate_keyboard_probe.py @@ -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. diff --git a/diet_guard/tests/_gate_layout_probe.py b/diet_guard/tests/_gate_layout_probe.py index 22e0bbe..fac4923 100644 --- a/diet_guard/tests/_gate_layout_probe.py +++ b/diet_guard/tests/_gate_layout_probe.py @@ -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("", 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, diff --git a/diet_guard/tests/_gate_measurement.py b/diet_guard/tests/_gate_measurement.py new file mode 100644 index 0000000..5e2a6f7 --- /dev/null +++ b/diet_guard/tests/_gate_measurement.py @@ -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}" + ) diff --git a/diet_guard/tests/conftest.py b/diet_guard/tests/conftest.py index 606502b..ae3fd3b 100644 --- a/diet_guard/tests/conftest.py +++ b/diet_guard/tests/conftest.py @@ -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( diff --git a/diet_guard/tests/test_gate_tk_modules.py b/diet_guard/tests/test_gate_tk_modules.py new file mode 100644 index 0000000..699acfb --- /dev/null +++ b/diet_guard/tests/test_gate_tk_modules.py @@ -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)}" + ) diff --git a/diet_guard/tests/test_gatelock_keyboard.py b/diet_guard/tests/test_gatelock_keyboard.py index 1e7d1dc..45ec549 100644 --- a/diet_guard/tests/test_gatelock_keyboard.py +++ b/diet_guard/tests/test_gatelock_keyboard.py @@ -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()