mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:07:15 +01:00
Split the six Python gate files; guard the fake-tk patch set
Sixth batch of the 250-line sweep. All six gate sources named for this batch are now under cap, plus _gate_layout_probe.py, which this batch's import rewiring pulled into the changeset (over-cap count 38 -> 31). _gatelock_ui.py 477 -> 174 (palette/leaf-helper hub) _gate_layout_probe.py 346 -> 196 _gatelock_groups.py 308 -> 137 _gatelock_calendar_ui 295 -> 77 _gatelock_calendar.py 286 -> 210 _gatelock.py 263 -> 238 _gatelock_mealflow.py 259 -> 207 New siblings: _gatelock_fields (input widgets), _gatelock_layout (assembly), _gatelock_widgetgroups (per-widget mirroring), _gatelock_calendar_types (bundles/palette), _gatelock_calendar_widgets (History builders), _gatelock_dashboard and _gatelock_budgetedit (mixins), _gatelock_lockfile (the stdlib-only flock), _gate_focus_probe and _gate_measurement. The load-bearing part is the test seam, not the line counts. `_gate_fixtures._GATE_TK_MODULES` patches `tk` on a hand-listed tuple, and that list fails OPEN: a widget builder split into a new sibling that nobody adds to it builds *real* Tk widgets against fake parents. Neither existing detector sees that -- check_patch_targets.py only validates paths that still resolve, and the ~8s runtime only catches network calls. It bit twice during this split (real StringVars from make_calendar_vars returned MagicMocks instead of the asserted text), so test_gate_tk_modules.py now asserts the invariant directly: every module binding `tk` is in the patch set or named in _NO_WIDGETS with a reason. It caught a pre-existing gap too -- _gatelock_support was never in the set. Same fail-open shape drove two other choices: `escape_tab_trap` is a call-time indirection so the keyboard test's patch of `_escape_text_tab_trap` still bites, and the flock pair moved with its conftest patch target updated in the same edit (that one fails closed). build_layout is imported from _gatelock_layout directly rather than re-exported lazily, which avoids both a cycle and an import-outside-toplevel suppression. Verified: 703 Python tests / 100% branch coverage, the 5 layout-fits tests under xvfb, 555 Flutter tests (unchanged), flutter analyze clean, check_patch_targets 0, `sync` 0 (403 entries), pre-commit clean, and `gate --demo` renders both tabs with live dashboard data. ~/.local/share/diet_guard byte-identical across the batch (gate timer paused, restored after). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
This commit is contained in:
parent
7cb09a5b85
commit
e8f0ecaae1
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
117
diet_guard/_gatelock_budgetedit.py
Normal file
117
diet_guard/_gatelock_budgetedit.py
Normal file
@ -0,0 +1,117 @@
|
||||
"""Budget editing on the gate's History tab.
|
||||
|
||||
Split out of :mod:`._gatelock_calendar` to keep every gate module under the
|
||||
repo's 250-line limit. ``_GateBudgetEdit`` sits between
|
||||
:class:`~diet_guard._gatelock_mealflow._GateMealFlow` and
|
||||
:class:`~diet_guard._gatelock_calendar._GateCalendar` in the mixin chain and
|
||||
owns the one place either device edits the daily budget.
|
||||
|
||||
The budget is a plain, freely-editable synced file -- no seal, no signing.
|
||||
That is deliberate (it replaced a ``chattr +i`` mechanism so the value is
|
||||
editable from either device); do not reintroduce the seal here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import contextlib
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from diet_guard._budget import BudgetError, budget_weight, write_budget
|
||||
from diet_guard._gatelock_mealflow import _GateMealFlow
|
||||
from diet_guard._gatelock_ui import ERR, FG
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from diet_guard._gatelock_calendar_types import CalendarVars, CalendarWidgets
|
||||
|
||||
__all__ = ["_GateBudgetEdit"]
|
||||
|
||||
|
||||
class _GateBudgetEdit(_GateMealFlow):
|
||||
"""The History tab's budget row: display, edit toggle, validate, persist.
|
||||
|
||||
The calendar state below is *declared* here and **created** by
|
||||
:class:`~diet_guard._gatelock_calendar._GateCalendar`, further down the
|
||||
mixin chain: this half only reads and writes the budget row, while the
|
||||
other owns the tab's construction and refresh. Declaring the attributes
|
||||
keeps that split checkable rather than implicit.
|
||||
"""
|
||||
|
||||
_cal_vars: CalendarVars
|
||||
_cal_surfaces: list[CalendarWidgets]
|
||||
_cal_editing_budget: bool
|
||||
|
||||
@abc.abstractmethod
|
||||
def _refresh_calendar(self) -> None:
|
||||
"""Repaint the History tab; implemented by ``_GateCalendar``."""
|
||||
|
||||
def _set_budget_entry_state(self, state: str) -> None:
|
||||
"""Lock or unlock the budget entry on every monitor."""
|
||||
for surface in self._cal_surfaces:
|
||||
with contextlib.suppress(tk.TclError):
|
||||
surface.budget_entry.config(state=state)
|
||||
|
||||
def _set_budget_button_text(self, text: str) -> None:
|
||||
"""Relabel the budget edit/save button on every monitor."""
|
||||
for surface in self._cal_surfaces:
|
||||
with contextlib.suppress(tk.TclError):
|
||||
surface.budget_edit_button.config(text=text)
|
||||
|
||||
def _set_budget_status(self, text: str, *, error: bool) -> None:
|
||||
"""Update the budget-edit status line, red for errors."""
|
||||
self._cal_vars.budget_status.set(text)
|
||||
colour = ERR if error else FG
|
||||
for surface in self._cal_surfaces:
|
||||
with contextlib.suppress(tk.TclError):
|
||||
surface.budget_status_label.config(fg=colour)
|
||||
|
||||
def _on_edit_or_save_budget(self) -> None:
|
||||
"""Toggle the budget row between read-only display and editing.
|
||||
|
||||
First click: unlock the entry for typing and relabel the button
|
||||
"Save" -- nothing is persisted yet. Second click: validate and
|
||||
persist; on success, lock the entry back to read-only and relabel
|
||||
the button "Edit". A validation failure leaves editing open so the
|
||||
user can correct the value instead of silently discarding it.
|
||||
"""
|
||||
if not self._cal_editing_budget:
|
||||
self._cal_editing_budget = True
|
||||
self._set_budget_entry_state("normal")
|
||||
self._set_budget_button_text("Save")
|
||||
self._set_budget_status("", error=False)
|
||||
return
|
||||
if not self._save_budget_entry():
|
||||
return
|
||||
self._cal_editing_budget = False
|
||||
self._set_budget_button_text("Edit")
|
||||
self._refresh_calendar()
|
||||
self._refresh_dashboard()
|
||||
self._refresh_projection()
|
||||
|
||||
def _save_budget_entry(self) -> bool:
|
||||
"""Validate and persist the entry's current text.
|
||||
|
||||
Preserves any body weight already stored alongside the budget (used
|
||||
to derive the protein target) -- a bare ``write_budget(value)`` would
|
||||
silently drop it, since the file holds one record, not a diff.
|
||||
|
||||
Returns:
|
||||
Whether the value was valid and persisted.
|
||||
"""
|
||||
raw = self._cal_vars.budget.get().strip()
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
self._set_budget_status("Enter a whole number of kcal.", error=True)
|
||||
return False
|
||||
if value <= 0:
|
||||
self._set_budget_status("Budget must be a positive number.", error=True)
|
||||
return False
|
||||
try:
|
||||
weight = budget_weight()
|
||||
except BudgetError:
|
||||
weight = None
|
||||
write_budget(value, weight_kg=weight)
|
||||
self._set_budget_status("Saved.", error=False)
|
||||
return True
|
||||
@ -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
|
||||
|
||||
94
diet_guard/_gatelock_calendar_types.py
Normal file
94
diet_guard/_gatelock_calendar_types.py
Normal file
@ -0,0 +1,94 @@
|
||||
"""Data bundles, palette and constants for the gate's History tab.
|
||||
|
||||
Split out of :mod:`._gatelock_calendar_ui` to keep every gate module under the
|
||||
repo's 250-line limit. This module is the tab's *shared foundation*: the
|
||||
three dataclasses that cross the module boundary, the calendar constants, and
|
||||
the palette read from the shared ``LockConfig``. It is deliberately free of
|
||||
widget construction, so the builders in
|
||||
:mod:`._gatelock_calendar_widgets` and the controller in
|
||||
:mod:`._gatelock_calendar` can both import from it without a cycle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from gatelock import LockConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
# BG/FG/ERR come from _gatelock_ui's public exports (themselves sourced from
|
||||
# LockConfig -- see DESIGN_AUDIT_TODO.md). The rest of the calendar's palette
|
||||
# used to be its own hardcoded copy of _gatelock_ui's private constants; now
|
||||
# it reads the same shared LockConfig() directly instead.
|
||||
_COLORS = LockConfig()
|
||||
_MUTED = _COLORS.muted
|
||||
_FIELD_BG = _COLORS.field_bg
|
||||
_ACCENT = _COLORS.accent
|
||||
|
||||
# calendar.monthcalendar never returns more than 6 weeks for any month.
|
||||
_MONTH_ROWS = 6
|
||||
_WEEKDAY_LABELS = ("Mo", "Tu", "We", "Th", "Fr", "Sa", "Su")
|
||||
_JANUARY = 1
|
||||
_DECEMBER = 12
|
||||
_MONTH_AFTER_DECEMBER = 13
|
||||
|
||||
# Shown (and classified against) before any budget has ever been set --
|
||||
# mirrors the phone app's AppSettingsService default, so a fresh install
|
||||
# shows a meaningful calendar on both platforms with no setup ritual.
|
||||
_DEFAULT_BUDGET_KCAL = 2200
|
||||
|
||||
__all__ = [
|
||||
"CalendarCallbacks",
|
||||
"CalendarVars",
|
||||
"CalendarWidgets",
|
||||
"make_calendar_vars",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalendarVars:
|
||||
"""Tk string variables bound to the History tab's live fields."""
|
||||
|
||||
month_label: tk.StringVar
|
||||
streaks: tk.StringVar
|
||||
ytd: tk.StringVar
|
||||
averages: tk.StringVar
|
||||
budget_status: tk.StringVar
|
||||
budget: tk.StringVar
|
||||
"""Backs the budget entry so its per-monitor copies cannot disagree."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalendarWidgets:
|
||||
"""Interactive widgets of the History tab."""
|
||||
|
||||
frame: tk.Frame
|
||||
day_cells: list[tk.Label]
|
||||
budget_entry: tk.Entry
|
||||
budget_edit_button: tk.Button
|
||||
budget_status_label: tk.Label
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalendarCallbacks:
|
||||
"""Construction-time commands the History tab's widgets fire."""
|
||||
|
||||
on_prev_month: Callable[[], None]
|
||||
on_next_month: Callable[[], None]
|
||||
on_edit_or_save_budget: Callable[[], None]
|
||||
|
||||
|
||||
def make_calendar_vars(root: tk.Misc) -> CalendarVars:
|
||||
"""Create the History tab's string variables, all mastered to ``root``."""
|
||||
return CalendarVars(
|
||||
month_label=tk.StringVar(master=root, value=""),
|
||||
streaks=tk.StringVar(master=root, value=""),
|
||||
ytd=tk.StringVar(master=root, value=""),
|
||||
averages=tk.StringVar(master=root, value=""),
|
||||
budget_status=tk.StringVar(master=root, value=""),
|
||||
budget=tk.StringVar(master=root, value=""),
|
||||
)
|
||||
@ -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:
|
||||
|
||||
216
diet_guard/_gatelock_calendar_widgets.py
Normal file
216
diet_guard/_gatelock_calendar_widgets.py
Normal file
@ -0,0 +1,216 @@
|
||||
"""Widget builders for the gate's History tab.
|
||||
|
||||
Split out of :mod:`._gatelock_calendar_ui` to keep every gate module under the
|
||||
repo's 250-line limit. That module keeps the tab's data bundles, its string
|
||||
variables, and the ``ttk`` theming; this one builds the actual widgets --
|
||||
the budget row, the month navigation header, the day grid, and the frame that
|
||||
holds them.
|
||||
|
||||
.. note::
|
||||
This module imports ``tkinter`` and builds widgets, so it **must** appear in
|
||||
``diet_guard.tests._gate_fixtures._GATE_TK_MODULES``. A module left out of
|
||||
that set builds real Tk widgets under pytest against fake parents.
|
||||
``test_gate_tk_modules_complete`` enforces this.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from gatelock import ButtonStyle, make_button
|
||||
|
||||
from diet_guard._gatelock_calendar_types import (
|
||||
_ACCENT,
|
||||
_COLORS,
|
||||
_FIELD_BG,
|
||||
_MONTH_ROWS,
|
||||
_MUTED,
|
||||
_WEEKDAY_LABELS,
|
||||
CalendarCallbacks,
|
||||
CalendarVars,
|
||||
CalendarWidgets,
|
||||
)
|
||||
from diet_guard._gatelock_spacing import SM, XS
|
||||
from diet_guard._gatelock_typography import BODY, CAPTION, LABEL, TITLE
|
||||
from diet_guard._gatelock_ui import BG, FG
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
__all__ = ["build_calendar_frame"]
|
||||
|
||||
|
||||
def _build_budget_row(
|
||||
parent: tk.Frame,
|
||||
vars_: CalendarVars,
|
||||
on_edit_or_save_budget: Callable[[], None],
|
||||
) -> tuple[tk.Entry, tk.Button, tk.Label]:
|
||||
"""Build the budget row; return the entry, its edit button, and status label.
|
||||
|
||||
The entry starts read-only (``state="readonly"``): the budget is
|
||||
displayed but not directly typeable. The button on the right starts
|
||||
labelled "Edit"; clicking it makes the entry editable and relabels
|
||||
itself "Save" -- a second click validates and persists, then reverts
|
||||
both back to their read-only defaults.
|
||||
"""
|
||||
row = tk.Frame(parent, bg=BG)
|
||||
row.pack(pady=(SM, XS))
|
||||
tk.Label(
|
||||
row,
|
||||
text="Daily budget (kcal):",
|
||||
font=(_COLORS.font_family, LABEL),
|
||||
bg=BG,
|
||||
fg=FG,
|
||||
).pack(side="left")
|
||||
entry = tk.Entry(
|
||||
row,
|
||||
textvariable=vars_.budget,
|
||||
font=(_COLORS.font_family, BODY),
|
||||
width=8,
|
||||
bg=_FIELD_BG,
|
||||
fg=FG,
|
||||
insertbackground=FG,
|
||||
justify="center",
|
||||
state="readonly",
|
||||
readonlybackground=_FIELD_BG,
|
||||
)
|
||||
entry.pack(side="left", padx=(SM, SM), ipady=XS)
|
||||
edit_button = make_button(
|
||||
row,
|
||||
_COLORS,
|
||||
"Edit",
|
||||
on_edit_or_save_budget,
|
||||
)
|
||||
edit_button.pack(side="left")
|
||||
status_label = tk.Label(
|
||||
parent,
|
||||
textvariable=vars_.budget_status,
|
||||
font=(_COLORS.font_family, LABEL),
|
||||
bg=BG,
|
||||
fg=FG,
|
||||
)
|
||||
status_label.pack(pady=(0, XS))
|
||||
return entry, edit_button, status_label
|
||||
|
||||
|
||||
def _build_month_nav(
|
||||
parent: tk.Frame,
|
||||
vars_: CalendarVars,
|
||||
callbacks: CalendarCallbacks,
|
||||
) -> None:
|
||||
"""Build the prev/month-label/next header row."""
|
||||
row = tk.Frame(parent, bg=BG)
|
||||
row.pack(pady=(XS, XS))
|
||||
make_button(
|
||||
row,
|
||||
_COLORS,
|
||||
"◀",
|
||||
callbacks.on_prev_month,
|
||||
ButtonStyle(variant="secondary"),
|
||||
).pack(side="left", padx=XS)
|
||||
tk.Label(
|
||||
row,
|
||||
textvariable=vars_.month_label,
|
||||
font=(_COLORS.font_family, LABEL, "bold"),
|
||||
bg=BG,
|
||||
fg=FG,
|
||||
width=16,
|
||||
justify="center",
|
||||
).pack(side="left")
|
||||
make_button(
|
||||
row,
|
||||
_COLORS,
|
||||
"▶",
|
||||
callbacks.on_next_month,
|
||||
ButtonStyle(variant="secondary"),
|
||||
).pack(side="left", padx=XS)
|
||||
|
||||
|
||||
def _build_grid(parent: tk.Frame) -> list[tk.Label]:
|
||||
"""Build the fixed 6x7 day-cell grid and return the flat cell list."""
|
||||
weekday_row = tk.Frame(parent, bg=BG)
|
||||
weekday_row.pack()
|
||||
for col, label in enumerate(_WEEKDAY_LABELS):
|
||||
tk.Label(
|
||||
weekday_row,
|
||||
text=label,
|
||||
font=(_COLORS.font_family, CAPTION, "bold"),
|
||||
bg=BG,
|
||||
fg=_MUTED,
|
||||
width=4,
|
||||
).grid(row=0, column=col, padx=XS)
|
||||
|
||||
grid_frame = tk.Frame(parent, bg=BG)
|
||||
grid_frame.pack(pady=(XS, XS))
|
||||
day_cells: list[tk.Label] = []
|
||||
for row in range(_MONTH_ROWS):
|
||||
for col in range(7):
|
||||
cell = tk.Label(
|
||||
grid_frame,
|
||||
text="",
|
||||
font=(_COLORS.font_family, CAPTION),
|
||||
width=4,
|
||||
height=2,
|
||||
bg=BG,
|
||||
fg=FG,
|
||||
highlightthickness=2,
|
||||
highlightbackground=BG,
|
||||
)
|
||||
cell.grid(row=row, column=col, padx=XS, pady=XS)
|
||||
day_cells.append(cell)
|
||||
return day_cells
|
||||
|
||||
|
||||
def build_calendar_frame(
|
||||
root: tk.Misc,
|
||||
vars_: CalendarVars,
|
||||
callbacks: CalendarCallbacks,
|
||||
) -> CalendarWidgets:
|
||||
"""Lay out the History tab and return the widgets the controller drives."""
|
||||
frame = tk.Frame(root, bg=BG)
|
||||
tk.Label(
|
||||
frame,
|
||||
text="📅 History",
|
||||
font=(_COLORS.font_family, TITLE, "bold"),
|
||||
bg=BG,
|
||||
fg=_ACCENT,
|
||||
).pack(pady=(SM, 0))
|
||||
|
||||
budget_entry, budget_edit_button, budget_status_label = _build_budget_row(
|
||||
frame,
|
||||
vars_,
|
||||
callbacks.on_edit_or_save_budget,
|
||||
)
|
||||
_build_month_nav(frame, vars_, callbacks)
|
||||
day_cells = _build_grid(frame)
|
||||
|
||||
tk.Label(
|
||||
frame,
|
||||
textvariable=vars_.streaks,
|
||||
font=(_COLORS.font_family, LABEL, "bold"),
|
||||
bg=BG,
|
||||
fg=FG,
|
||||
).pack(pady=(SM, 0))
|
||||
tk.Label(
|
||||
frame,
|
||||
textvariable=vars_.ytd,
|
||||
font=(_COLORS.font_family, CAPTION),
|
||||
bg=BG,
|
||||
fg=_MUTED,
|
||||
).pack(pady=(XS, 0))
|
||||
tk.Label(
|
||||
frame,
|
||||
textvariable=vars_.averages,
|
||||
font=(_COLORS.font_family, CAPTION),
|
||||
bg=BG,
|
||||
fg=_MUTED,
|
||||
).pack(pady=(XS, SM))
|
||||
|
||||
return CalendarWidgets(
|
||||
frame=frame,
|
||||
day_cells=day_cells,
|
||||
budget_entry=budget_entry,
|
||||
budget_edit_button=budget_edit_button,
|
||||
budget_status_label=budget_status_label,
|
||||
)
|
||||
72
diet_guard/_gatelock_dashboard.py
Normal file
72
diet_guard/_gatelock_dashboard.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""The gate's running "how am I doing today" dashboard.
|
||||
|
||||
Split out of :mod:`._gatelock_mealflow` to keep every gate module under the
|
||||
repo's 250-line limit. ``_GateDashboard`` sits between
|
||||
:class:`~diet_guard._gatelock_nutrition._GateNutrition` and
|
||||
:class:`~diet_guard._gatelock_mealflow._GateMealFlow` in the mixin chain and
|
||||
owns only the *read* side: the prominent calorie headline and the detail panel
|
||||
beneath it. Nothing here writes state, so it can be recomputed at any point
|
||||
in the meal flow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from diet_guard._budget import BudgetError, daily_budget, protein_target_g
|
||||
from diet_guard._gatelock_nutrition import _GateNutrition
|
||||
from diet_guard._state import (
|
||||
entry_kcal,
|
||||
today_entries,
|
||||
today_total_kcal,
|
||||
today_total_macros,
|
||||
)
|
||||
|
||||
# How many recent meals the dashboard lists.
|
||||
_DASHBOARD_ROWS = 5
|
||||
# ISO timestamp "YYYY-MM-DDTHH:MM:SS": HH:MM is characters 11..16.
|
||||
_TIME_SLICE = slice(11, 16)
|
||||
# Width a meal description is truncated to in the dashboard.
|
||||
_DASH_DESC_WIDTH = 22
|
||||
|
||||
__all__ = ["_GateDashboard"]
|
||||
|
||||
|
||||
class _GateDashboard(_GateNutrition):
|
||||
"""The calorie headline and detail panel for the day so far."""
|
||||
|
||||
def _refresh_dashboard(self) -> None:
|
||||
"""Recompute the prominent calorie headline and the detail panel."""
|
||||
self._vars.cal_headline.set(self._cal_headline_text())
|
||||
self._vars.dashboard.set(self._dashboard_text())
|
||||
|
||||
def _cal_headline_text(self) -> str:
|
||||
"""Return the big calories-today line: consumed, target, and remaining."""
|
||||
consumed = today_total_kcal()
|
||||
try:
|
||||
budget = daily_budget()
|
||||
except (BudgetError, OSError):
|
||||
return f"{consumed:g} kcal today"
|
||||
return (
|
||||
f"{consumed:g} / {budget:g} kcal · {round(budget - consumed, 1):g} left"
|
||||
)
|
||||
|
||||
def _dashboard_text(self) -> str:
|
||||
"""Build the detail panel: recent meals, then macros and protein."""
|
||||
lines = ["── Today ───────────────────────────────"]
|
||||
entries = today_entries()
|
||||
if entries:
|
||||
for entry in entries[-_DASHBOARD_ROWS:]:
|
||||
clock = str(entry.get("time", ""))[_TIME_SLICE]
|
||||
desc = str(entry.get("desc", "?"))[:_DASH_DESC_WIDTH]
|
||||
lines.append(
|
||||
f" {clock:>5} {desc:<{_DASH_DESC_WIDTH}} "
|
||||
f"{entry_kcal(entry):>5.0f} kcal",
|
||||
)
|
||||
else:
|
||||
lines.append(" (nothing logged yet today)")
|
||||
protein, carbs, fat = today_total_macros()
|
||||
lines.append(f" macros so far: P{protein:g} C{carbs:g} F{fat:g} g")
|
||||
target = protein_target_g()
|
||||
if target is not None:
|
||||
left = round(target - protein, 1)
|
||||
lines.append(f" protein {protein:g} / {target:g} g ({left:g} g left)")
|
||||
return "\n".join(lines)
|
||||
222
diet_guard/_gatelock_fields.py
Normal file
222
diet_guard/_gatelock_fields.py
Normal file
@ -0,0 +1,222 @@
|
||||
"""Field widgets of the gate's "Log Meal" tab.
|
||||
|
||||
Split out of :mod:`._gatelock_ui` to keep every gate module under the repo's
|
||||
250-line limit. This half builds the *input* widgets -- the description box,
|
||||
the suggestion picker, the amount/unit row, and the macro section --
|
||||
:mod:`._gatelock_layout` assembles them into the tab.
|
||||
|
||||
The palette and the leaf helpers stay in :mod:`._gatelock_ui`, which is the
|
||||
single ``LockConfig()`` all gate modules read; deliberately not re-derived
|
||||
here, since a separate copy is exactly what drifted before (see
|
||||
``DESIGN_AUDIT_TODO.md``).
|
||||
|
||||
.. note::
|
||||
This module imports ``tkinter`` and builds widgets, so it **must** appear in
|
||||
``diet_guard.tests._gate_fixtures._GATE_TK_MODULES``. A module left out of
|
||||
that set builds real Tk widgets under pytest against fake parents.
|
||||
``test_gate_tk_modules_complete`` enforces this.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from diet_guard import _gatelock_ui
|
||||
from diet_guard._gatelock_spacing import SM, XS
|
||||
from diet_guard._gatelock_typography import BODY, LABEL
|
||||
from diet_guard._gatelock_ui import (
|
||||
_ACCENT,
|
||||
_COLORS,
|
||||
_FIELD_BG,
|
||||
_FONT,
|
||||
BASIS_PREFIX_GRAMS,
|
||||
BG,
|
||||
FG,
|
||||
SUGGESTION_ROWS,
|
||||
UNIT_GRAMS,
|
||||
UNIT_ITEMS,
|
||||
_macro_cell,
|
||||
_numeric_entry,
|
||||
)
|
||||
from diet_guard._gatelock_ui_types import _MacroEntries
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from diet_guard._gatelock_ui_types import GateVars
|
||||
|
||||
__all__ = [
|
||||
"build_amount_row",
|
||||
"build_desc",
|
||||
"build_macro_section",
|
||||
"build_suggestion_box",
|
||||
]
|
||||
|
||||
|
||||
def build_desc(parent: tk.Frame) -> tk.Text:
|
||||
"""Build and return the multi-line "what did you eat?" description box.
|
||||
|
||||
A multi-line ``Text`` (not an ``Entry``) so a long restaurant description
|
||||
wraps onto a second line and stays fully visible, instead of scrolling off
|
||||
the right edge where the end can no longer be read.
|
||||
"""
|
||||
tk.Label(
|
||||
parent,
|
||||
text="What did you eat?",
|
||||
font=(_FONT, LABEL),
|
||||
bg=BG,
|
||||
fg=FG,
|
||||
).pack()
|
||||
text = tk.Text(
|
||||
parent,
|
||||
font=(_FONT, BODY),
|
||||
width=64,
|
||||
height=2,
|
||||
wrap="word",
|
||||
bg=_FIELD_BG,
|
||||
fg=FG,
|
||||
insertbackground=FG,
|
||||
# focus_kwargs() sets highlightcolor (the *focused* ring). This
|
||||
# previously set only highlightbackground, which is the UNFOCUSED ring
|
||||
# -- so the box outlined while unfocused and went black (invisible on
|
||||
# bg) the moment it took focus, exactly inverting the affordance.
|
||||
**_COLORS.focus_kwargs(),
|
||||
)
|
||||
text.pack(pady=(XS, XS))
|
||||
# Through the module, not a by-value import: the keyboard test patches
|
||||
# ``_gatelock_ui._escape_text_tab_trap``, and ``escape_tab_trap`` resolves
|
||||
# it at call time so the patch still bites. Importing the name directly
|
||||
# would bind a copy the patch can never reach -- a fail-open that disables
|
||||
# the Tab-trap escape with a green suite.
|
||||
_gatelock_ui.escape_tab_trap(text)
|
||||
return text
|
||||
|
||||
|
||||
def build_suggestion_box(parent: tk.Frame) -> tk.Listbox:
|
||||
"""Build the food-bank / staple / OFF picker list and return it."""
|
||||
box = tk.Listbox(
|
||||
parent,
|
||||
font=(_FONT, BODY),
|
||||
width=52,
|
||||
height=SUGGESTION_ROWS,
|
||||
bg=_FIELD_BG,
|
||||
fg=FG,
|
||||
selectbackground=_ACCENT,
|
||||
selectforeground=_COLORS.on_fill,
|
||||
activestyle="none",
|
||||
# Was highlightthickness=0, which removed the focus ring entirely. The
|
||||
# list *is* arrow-key navigable, so it was keyboard-usable with no
|
||||
# visual indication of where focus was.
|
||||
**_COLORS.focus_kwargs(),
|
||||
)
|
||||
box.pack(pady=(0, XS))
|
||||
return box
|
||||
|
||||
|
||||
def build_amount_row(
|
||||
root: tk.Misc,
|
||||
parent: tk.Frame,
|
||||
vars_: GateVars,
|
||||
on_unit_change: Callable[[str], None],
|
||||
) -> tk.Entry:
|
||||
"""Build the "how much did you eat?" amount + unit row; return the entry."""
|
||||
tk.Label(
|
||||
parent,
|
||||
text="How much did you eat?",
|
||||
font=(_FONT, LABEL),
|
||||
bg=BG,
|
||||
fg=FG,
|
||||
).pack()
|
||||
row = tk.Frame(parent, bg=BG)
|
||||
row.pack(pady=(XS, XS))
|
||||
amount_entry = _numeric_entry(root, row, width=10, variable=vars_.entries.amount)
|
||||
amount_entry.pack(side="left", ipady=XS)
|
||||
_build_unit_selector(row, vars_, on_unit_change)
|
||||
return amount_entry
|
||||
|
||||
|
||||
def _build_unit_selector(
|
||||
row: tk.Frame,
|
||||
vars_: GateVars,
|
||||
on_unit_change: Callable[[str], None],
|
||||
) -> None:
|
||||
"""Build the grams/items selector as focusable radio buttons.
|
||||
|
||||
Deliberately **not** a ``tk.OptionMenu``, which this used to be, for two
|
||||
independent reasons:
|
||||
|
||||
1. **It was unreachable by keyboard.** ``OptionMenu``'s underlying
|
||||
``Menubutton`` defaults to ``takefocus=0``, so ``::tk::FocusOK`` rejects
|
||||
it and it never appears in the tab ring (verified: the ring ran
|
||||
Entry -> Text -> Listbox -> Button -> TNotebook and skipped it). Since
|
||||
the unit rescales the entire entry, a pointerless user could not switch
|
||||
between "250 grams" and "3 items" inside a lock they cannot leave
|
||||
without logging a meal.
|
||||
2. **A posted menu is unsafe on a lock surface.** A dropped-down Tk menu is
|
||||
a separate override-redirect toplevel that steals the Tk grab, which
|
||||
gatelock's recovery tick then kills within a second. That is the
|
||||
documented 2026-07-26 failure where a frozen sport selector logged a walk
|
||||
as table tennis, and it is why screen-locker statically bans
|
||||
``OptionMenu`` on lock surfaces.
|
||||
|
||||
``tk.Radiobutton`` avoids both: it is in the tab ring by default and accepts
|
||||
**both** ``Space`` and ``Return`` (unlike ``tk.Button``, which on X11 binds
|
||||
only ``Space``), and it posts nothing.
|
||||
"""
|
||||
for unit in (UNIT_GRAMS, UNIT_ITEMS):
|
||||
tk.Radiobutton(
|
||||
row,
|
||||
text=unit,
|
||||
value=unit,
|
||||
variable=vars_.unit,
|
||||
command=lambda u=unit: on_unit_change(u),
|
||||
font=(_FONT, LABEL),
|
||||
bg=BG,
|
||||
fg=FG,
|
||||
selectcolor=_FIELD_BG,
|
||||
activebackground=BG,
|
||||
activeforeground=_ACCENT,
|
||||
**_COLORS.focus_kwargs(),
|
||||
).pack(side="left", padx=(SM, 0))
|
||||
|
||||
|
||||
def build_macro_section(
|
||||
root: tk.Misc,
|
||||
parent: tk.Frame,
|
||||
vars_: GateVars,
|
||||
) -> tuple[tk.Label, tk.Entry, _MacroEntries]:
|
||||
"""Build the per-basis field and macro row.
|
||||
|
||||
Returns the basis-prefix label, the "per" entry, and the four macro entries,
|
||||
for the caller to store in the widget bundle.
|
||||
"""
|
||||
basis = tk.Frame(parent, bg=BG)
|
||||
basis.pack()
|
||||
basis_prefix = tk.Label(
|
||||
basis,
|
||||
text=BASIS_PREFIX_GRAMS,
|
||||
font=(_FONT, LABEL),
|
||||
bg=BG,
|
||||
fg=FG,
|
||||
)
|
||||
basis_prefix.pack(side="left")
|
||||
per_entry = _numeric_entry(root, basis, width=5, variable=vars_.entries.per)
|
||||
per_entry.pack(side="left", padx=XS, ipady=XS)
|
||||
tk.Label(
|
||||
basis,
|
||||
text="g (leave calories blank to look it up):",
|
||||
font=(_FONT, LABEL),
|
||||
bg=BG,
|
||||
fg=FG,
|
||||
).pack(side="left")
|
||||
|
||||
row = tk.Frame(parent, bg=BG)
|
||||
row.pack(pady=(XS, XS))
|
||||
macros = _MacroEntries(
|
||||
kcal=_macro_cell(root, row, "kcal", vars_.entries.kcal),
|
||||
protein=_macro_cell(root, row, "P", vars_.entries.protein),
|
||||
carbs=_macro_cell(root, row, "C", vars_.entries.carbs),
|
||||
fat=_macro_cell(root, row, "F", vars_.entries.fat),
|
||||
)
|
||||
return basis_prefix, per_entry, macros
|
||||
@ -2,208 +2,37 @@
|
||||
|
||||
Since gatelock v0.2.0 the lock builds one window per live output, so the gate
|
||||
is laid out once per monitor and the controller must drive all of them at
|
||||
once. Three different kinds of state show up, and each needs a different
|
||||
answer:
|
||||
|
||||
* **Display fields** already fan out for free. ``GateVars`` holds
|
||||
root-mastered ``StringVar``s bound to labels, so one ``set`` repaints every
|
||||
monitor with no help from this module.
|
||||
* **Entries** are made variable-backed here for the same reason: typing an
|
||||
amount on whichever screen the user is looking at is then the same text
|
||||
everywhere, and reading it back needs no idea which monitor that was.
|
||||
* **The description box and the suggestion list** cannot share a variable --
|
||||
``tk.Text`` and ``tk.Listbox`` have none. Their copies genuinely diverge, so
|
||||
reads answer from whichever copy the user actually touched.
|
||||
|
||||
The alternative -- laying the gate out on the primary monitor only -- was
|
||||
rejected: the whole point of per-output locking is that the user does not
|
||||
have to hunt for the screen the form landed on.
|
||||
once. The individual per-widget groups -- and the rationale for which kinds of
|
||||
state can share a Tk variable and which genuinely diverge -- live in
|
||||
:mod:`._gatelock_widgetgroups`; this half assembles them into the single
|
||||
``GateWidgetsGroup`` the controller drives, so each file stays under the
|
||||
repo's 250-line limit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING, TypeVar
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from gatelock import WidgetGroup as SharedWidgetGroup
|
||||
from diet_guard._gatelock_widgetgroups import (
|
||||
EntryGroup,
|
||||
ListboxGroup,
|
||||
TextGroup,
|
||||
WidgetGroup,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
import tkinter as tk
|
||||
|
||||
from diet_guard._gatelock_ui_types import GateVars, GateWidgets
|
||||
|
||||
_W = TypeVar("_W", bound=tk.Misc)
|
||||
"""The widget each group mirrors, so a subclass keeps its own methods typed."""
|
||||
|
||||
|
||||
class WidgetGroup(SharedWidgetGroup[_W]):
|
||||
"""One logical widget, mirrored onto every monitor.
|
||||
|
||||
The fan-out itself -- iteration, ``configure``, ``bind``, singular focus,
|
||||
and skipping copies whose surface has been destroyed -- now lives in
|
||||
:class:`gatelock.WidgetGroup`, which four apps had each reimplemented.
|
||||
This subclass exists only to keep the positional ``list[_W]``
|
||||
construction the gate's own builders and specialized groups use; gatelock
|
||||
stores ``(output_name, widget)`` pairs, because the parallel-list form the
|
||||
donors shared could be built with widgets but no names.
|
||||
|
||||
``first`` is re-narrowed to ``_W``: the gate always builds at least one
|
||||
copy before anything reads one, and the specialized groups below index
|
||||
into it without a None check.
|
||||
"""
|
||||
|
||||
def __init__(self, widgets: list[_W]) -> None:
|
||||
"""Wrap the per-monitor copies of one widget."""
|
||||
super().__init__([("", widget) for widget in widgets])
|
||||
|
||||
@property
|
||||
def first(self) -> _W:
|
||||
"""The primary monitor's copy, for reads that cannot fan out."""
|
||||
widget = super().first
|
||||
if widget is None:
|
||||
message = "widget group is empty"
|
||||
raise IndexError(message)
|
||||
return widget
|
||||
|
||||
def bind(
|
||||
self,
|
||||
sequence: str,
|
||||
func: Callable[[tk.Event], object],
|
||||
*,
|
||||
add: bool = False,
|
||||
) -> None:
|
||||
"""Bind the same handler on every copy, *replacing* any previous one.
|
||||
|
||||
Only the default differs from :meth:`gatelock.WidgetGroup.bind`, and
|
||||
it is inverted deliberately. ``MealGate._wire_events`` runs once per
|
||||
``build_surface`` **and** again on rebuild, so with an additive bind
|
||||
every output change would stack a second handler on every surviving
|
||||
widget -- and ``<Return>`` would submit the meal twice. Replacing
|
||||
makes re-wiring idempotent, which is what this gate needs; gatelock
|
||||
defaults the other way because its callers bind once and must not
|
||||
clobber a handler someone else set.
|
||||
"""
|
||||
# Spelled out rather than delegated: an inherited signature with a
|
||||
# flipped default is invisible at the call site, and this one is
|
||||
# load-bearing enough to be worth reading in place.
|
||||
self._each(lambda widget: widget.bind(sequence, func, add="+" if add else ""))
|
||||
|
||||
|
||||
class EntryGroup(WidgetGroup[tk.Entry]):
|
||||
"""A single-line entry, mirrored onto every monitor over one variable.
|
||||
|
||||
Because every copy is bound to the same ``StringVar``, reads and writes go
|
||||
through the variable rather than the widgets: the copies cannot disagree.
|
||||
"""
|
||||
|
||||
def __init__(self, widgets: list[tk.Entry], var: tk.StringVar) -> None:
|
||||
"""Wrap the per-monitor entries and the variable behind them."""
|
||||
super().__init__(widgets)
|
||||
self._var = var
|
||||
|
||||
def get(self) -> str:
|
||||
"""Return the shared text, whichever monitor it was typed on."""
|
||||
return self._var.get()
|
||||
|
||||
def delete(self, _first: int | str, _last: int | str | None = None) -> None:
|
||||
"""Clear the shared text on every monitor."""
|
||||
self._var.set("")
|
||||
|
||||
def insert(self, _index: int | str, value: str) -> None:
|
||||
"""Set the shared text on every monitor.
|
||||
|
||||
Only ever used here to fill an empty field, so "insert" and "set" are
|
||||
the same operation; taking the index keeps the tk-shaped call sites
|
||||
unchanged.
|
||||
"""
|
||||
self._var.set(value)
|
||||
|
||||
|
||||
class TextGroup(WidgetGroup[tk.Text]):
|
||||
"""The description box, mirrored onto every monitor.
|
||||
|
||||
``tk.Text`` has no ``textvariable``, so the copies really do diverge. The
|
||||
user types into exactly one of them; reads return that one, which keeps
|
||||
"log the meal on whichever screen you are looking at" true.
|
||||
"""
|
||||
|
||||
def get(self, start: str, end: str) -> str:
|
||||
"""Return the content of whichever copy was actually typed into."""
|
||||
for widget in self:
|
||||
with contextlib.suppress(tk.TclError):
|
||||
content = widget.get(start, end)
|
||||
if content.strip():
|
||||
return str(content)
|
||||
return str(self.first.get(start, end))
|
||||
|
||||
def delete(self, start: str, end: str) -> None:
|
||||
"""Clear every copy."""
|
||||
for widget in self:
|
||||
with contextlib.suppress(tk.TclError):
|
||||
widget.delete(start, end)
|
||||
|
||||
def insert(self, index: str, value: str) -> None:
|
||||
"""Write the same content into every copy."""
|
||||
for widget in self:
|
||||
with contextlib.suppress(tk.TclError):
|
||||
widget.insert(index, value)
|
||||
|
||||
|
||||
class ListboxGroup(WidgetGroup[tk.Listbox]):
|
||||
"""The suggestion picker, mirrored onto every monitor.
|
||||
|
||||
A ``Listbox`` selection is per-widget, so the selected index is read from
|
||||
whichever copy has one -- the monitor the user clicked on.
|
||||
"""
|
||||
|
||||
def delete(self, first: int | str, last: int | str | None = None) -> None:
|
||||
"""Clear every copy."""
|
||||
for widget in self:
|
||||
with contextlib.suppress(tk.TclError):
|
||||
widget.delete(first, last)
|
||||
|
||||
def insert(self, index: int | str, *values: str) -> None:
|
||||
"""Append the same entries to every copy."""
|
||||
for widget in self:
|
||||
with contextlib.suppress(tk.TclError):
|
||||
widget.insert(index, *values)
|
||||
|
||||
def selection_set(self, first: int, last: int | None = None) -> None:
|
||||
"""Select the same row on every copy.
|
||||
|
||||
Programmatic selection (a keyboard pick, or a test) has to reach all
|
||||
of them; a *mouse* pick lands on one copy, which is what
|
||||
``curselection`` reads back.
|
||||
"""
|
||||
for widget in self:
|
||||
with contextlib.suppress(tk.TclError):
|
||||
if last is None:
|
||||
widget.selection_set(first)
|
||||
else:
|
||||
widget.selection_set(first, last)
|
||||
|
||||
def selection_clear(self, first: int, last: int | None = None) -> None:
|
||||
"""Clear the selection on every copy."""
|
||||
for widget in self:
|
||||
with contextlib.suppress(tk.TclError):
|
||||
if last is None:
|
||||
widget.selection_clear(first)
|
||||
else:
|
||||
widget.selection_clear(first, last)
|
||||
|
||||
def curselection(self) -> tuple[int, ...]:
|
||||
"""Return the selection from whichever copy the user clicked."""
|
||||
for widget in self:
|
||||
with contextlib.suppress(tk.TclError):
|
||||
selection = widget.curselection()
|
||||
if selection:
|
||||
return tuple(selection)
|
||||
return ()
|
||||
|
||||
def size(self) -> int:
|
||||
"""Number of rows; identical on every copy, so read the primary."""
|
||||
return int(self.first.size())
|
||||
__all__ = [
|
||||
"EntryGroup",
|
||||
"GateWidgetsGroup",
|
||||
"ListboxGroup",
|
||||
"MacroGroup",
|
||||
"TextGroup",
|
||||
"WidgetGroup",
|
||||
]
|
||||
|
||||
|
||||
class MacroGroup:
|
||||
|
||||
207
diet_guard/_gatelock_layout.py
Normal file
207
diet_guard/_gatelock_layout.py
Normal file
@ -0,0 +1,207 @@
|
||||
"""Assembly of the gate's "Log Meal" tab.
|
||||
|
||||
Split out of :mod:`._gatelock_ui` to keep every gate module under the repo's
|
||||
250-line limit. :mod:`._gatelock_fields` builds the individual input widgets;
|
||||
this half packs them, the dashboard, and the buttons into one scrollable
|
||||
surface and returns the bundle the controller drives.
|
||||
|
||||
.. note::
|
||||
This module imports ``tkinter`` and builds widgets, so it **must** appear in
|
||||
``diet_guard.tests._gate_fixtures._GATE_TK_MODULES``. A module left out of
|
||||
that set builds real Tk widgets under pytest against fake parents.
|
||||
``test_gate_tk_modules_complete`` enforces this.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
|
||||
from gatelock import ButtonStyle, ScrollableSurface, make_button
|
||||
|
||||
from diet_guard._gatelock_fields import (
|
||||
build_amount_row,
|
||||
build_desc,
|
||||
build_macro_section,
|
||||
build_suggestion_box,
|
||||
)
|
||||
from diet_guard._gatelock_spacing import SM, XS
|
||||
from diet_guard._gatelock_typography import BODY, CAPTION, LABEL, SUBTITLE, TITLE
|
||||
from diet_guard._gatelock_ui import (
|
||||
_ACCENT,
|
||||
_COLORS,
|
||||
_FONT,
|
||||
_MUTED,
|
||||
_WRAP_PX,
|
||||
BG,
|
||||
FG,
|
||||
)
|
||||
from diet_guard._gatelock_ui_types import GateCallbacks, GateVars, GateWidgets
|
||||
|
||||
__all__ = ["build_layout"]
|
||||
|
||||
|
||||
def _build_dashboard(parent: tk.Frame, vars_: GateVars) -> None:
|
||||
"""Build the running "how am I doing today" panel.
|
||||
|
||||
The calorie line is large and prominent (the number the user steers by); the
|
||||
meal list and macros sit beneath it in a smaller monospace block.
|
||||
"""
|
||||
tk.Label(
|
||||
parent,
|
||||
textvariable=vars_.cal_headline,
|
||||
font=(_FONT, TITLE, "bold"),
|
||||
bg=BG,
|
||||
fg=_ACCENT,
|
||||
).pack(pady=(SM, 0))
|
||||
tk.Label(
|
||||
parent,
|
||||
textvariable=vars_.dashboard,
|
||||
font=("Courier", CAPTION),
|
||||
bg=BG,
|
||||
fg=_MUTED,
|
||||
justify="left",
|
||||
anchor="w",
|
||||
wraplength=_WRAP_PX,
|
||||
).pack(pady=(XS, 0))
|
||||
|
||||
|
||||
def _build_header(body: tk.Frame, vars_: GateVars) -> None:
|
||||
"""Pack the title and the current-slot header."""
|
||||
tk.Label(
|
||||
body,
|
||||
text="🍽 Diet Gate",
|
||||
font=(_FONT, TITLE, "bold"),
|
||||
bg=BG,
|
||||
fg=_ACCENT,
|
||||
).pack(pady=(0, XS))
|
||||
tk.Label(
|
||||
body,
|
||||
textvariable=vars_.slot_header,
|
||||
font=(_FONT, SUBTITLE, "bold"),
|
||||
bg=BG,
|
||||
fg=FG,
|
||||
wraplength=_WRAP_PX,
|
||||
justify="center",
|
||||
).pack(pady=(0, SM))
|
||||
|
||||
|
||||
def _build_readouts(body: tk.Frame, vars_: GateVars) -> None:
|
||||
"""Pack the projection and preview lines beneath the input fields."""
|
||||
tk.Label(
|
||||
body,
|
||||
textvariable=vars_.projection,
|
||||
font=(_FONT, LABEL, "bold"),
|
||||
bg=BG,
|
||||
fg=FG,
|
||||
wraplength=_WRAP_PX,
|
||||
justify="center",
|
||||
).pack(pady=(XS, XS))
|
||||
tk.Label(
|
||||
body,
|
||||
textvariable=vars_.preview,
|
||||
font=(_FONT, BODY, "bold"),
|
||||
bg=BG,
|
||||
fg=_ACCENT,
|
||||
wraplength=_WRAP_PX,
|
||||
justify="center",
|
||||
).pack(pady=(XS, XS))
|
||||
|
||||
|
||||
def _build_actions(body: tk.Frame, callbacks: GateCallbacks) -> None:
|
||||
"""Pack the submit button and the manual sync-fetch button."""
|
||||
make_button(
|
||||
body,
|
||||
_COLORS,
|
||||
"Log & Continue",
|
||||
callbacks.on_submit,
|
||||
).pack(pady=(XS, XS))
|
||||
|
||||
# Manual pull for a meal already logged on another device (the phone) but
|
||||
# not yet propagated to this machine -- saves re-typing it to unlock.
|
||||
make_button(
|
||||
body,
|
||||
_COLORS,
|
||||
"⟳ Fetch from sync",
|
||||
callbacks.on_fetch_sync,
|
||||
ButtonStyle(variant="secondary", bold=False),
|
||||
).pack(pady=(0, XS))
|
||||
|
||||
|
||||
def build_layout(
|
||||
root: tk.Misc,
|
||||
vars_: GateVars,
|
||||
callbacks: GateCallbacks,
|
||||
*,
|
||||
demo_mode: bool,
|
||||
) -> GateWidgets:
|
||||
"""Lay out the whole gate UI and return the widgets the controller drives.
|
||||
|
||||
The controller calls this once (after configuring the window) and is then
|
||||
responsible for binding per-keystroke events to the returned widgets.
|
||||
|
||||
The content lives inside a :class:`~gatelock.ScrollableSurface` rather than
|
||||
a ``place``-centered frame. It has to: this tab requires ~700px even when
|
||||
empty and grows a line per logged meal, so on a 1366x768 screen it exceeded
|
||||
the notebook's content pane and a centered frame clipped it *symmetrically*
|
||||
-- losing the "Diet Gate" title and the slot header off the top while the
|
||||
calorie headline and dashboard went off the bottom, with no scrollbar to
|
||||
recover either. The viewport also makes the overflow keyboard-reachable,
|
||||
which matters because this is a hard lock the user cannot leave without
|
||||
logging a meal. See ``tests/measure_gate_layout.py``.
|
||||
"""
|
||||
surface = ScrollableSurface(root, _COLORS)
|
||||
frame = surface.container
|
||||
body = surface.content
|
||||
|
||||
_build_header(body, vars_)
|
||||
|
||||
desc_text = build_desc(body)
|
||||
suggestion_box = build_suggestion_box(body)
|
||||
amount_entry = build_amount_row(
|
||||
root,
|
||||
body,
|
||||
vars_,
|
||||
callbacks.on_unit_change,
|
||||
)
|
||||
basis_prefix, per_entry, macros = build_macro_section(root, body, vars_)
|
||||
|
||||
_build_readouts(body, vars_)
|
||||
_build_actions(body, callbacks)
|
||||
|
||||
status_label = tk.Label(
|
||||
body,
|
||||
textvariable=vars_.status,
|
||||
font=(_FONT, LABEL),
|
||||
bg=BG,
|
||||
fg=FG,
|
||||
wraplength=_WRAP_PX,
|
||||
justify="center",
|
||||
)
|
||||
status_label.pack()
|
||||
|
||||
_build_dashboard(body, vars_)
|
||||
|
||||
# Wire focus-following and reset the view now that the content exists.
|
||||
# Without this, Tab walks onto fields scrolled out of sight -- clipping does
|
||||
# not remove a widget from the tab chain.
|
||||
surface.finalize()
|
||||
|
||||
if demo_mode:
|
||||
make_button(
|
||||
root,
|
||||
_COLORS,
|
||||
"✕ Close Demo",
|
||||
callbacks.on_close,
|
||||
ButtonStyle(variant="danger", bold=False),
|
||||
).place(x=10, y=10)
|
||||
|
||||
return GateWidgets(
|
||||
frame=frame,
|
||||
desc_text=desc_text,
|
||||
amount_entry=amount_entry,
|
||||
per_entry=per_entry,
|
||||
basis_prefix=basis_prefix,
|
||||
macros=macros,
|
||||
suggestion_box=suggestion_box,
|
||||
status_label=status_label,
|
||||
)
|
||||
49
diet_guard/_gatelock_lockfile.py
Normal file
49
diet_guard/_gatelock_lockfile.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""Single-instance ``flock`` for the meal gate.
|
||||
|
||||
Split out of :mod:`._gatelock` to keep every gate module under the repo's
|
||||
250-line limit. Deliberately the *stdlib-only* half of the gate: no ``tkinter``
|
||||
import, so this module never needs to appear in the test suite's
|
||||
``_GATE_TK_MODULES`` fake-tk patch set.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import fcntl
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from diet_guard._constants import GATE_LOCK_FILE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import TextIO
|
||||
|
||||
__all__ = [
|
||||
"GATE_LOCK_FILE",
|
||||
"acquire_gate_lock",
|
||||
"release_gate_lock",
|
||||
]
|
||||
|
||||
|
||||
def acquire_gate_lock() -> TextIO | None:
|
||||
"""Acquire the gate's single-instance ``flock``.
|
||||
|
||||
Returns:
|
||||
An open file handle that must be kept alive for the gate's lifetime
|
||||
(closing it releases the lock), or None if another gate already holds
|
||||
it -- in which case the caller must not open a second window.
|
||||
"""
|
||||
GATE_LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
handle = GATE_LOCK_FILE.open("w", encoding="utf-8")
|
||||
try:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
handle.close()
|
||||
return None
|
||||
return handle
|
||||
|
||||
|
||||
def release_gate_lock(handle: TextIO) -> None:
|
||||
"""Release the single-instance lock and close its handle."""
|
||||
with contextlib.suppress(OSError):
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
handle.close()
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
209
diet_guard/_gatelock_widgetgroups.py
Normal file
209
diet_guard/_gatelock_widgetgroups.py
Normal file
@ -0,0 +1,209 @@
|
||||
"""Per-monitor mirroring of the gate's individual widgets.
|
||||
|
||||
Since gatelock v0.2.0 the lock builds one window per live output, so the gate
|
||||
is laid out once per monitor and the controller must drive all of them at
|
||||
once. Three different kinds of state show up, and each needs a different
|
||||
answer:
|
||||
|
||||
* **Display fields** already fan out for free. ``GateVars`` holds
|
||||
root-mastered ``StringVar``s bound to labels, so one ``set`` repaints every
|
||||
monitor with no help from this module.
|
||||
* **Entries** are made variable-backed here for the same reason: typing an
|
||||
amount on whichever screen the user is looking at is then the same text
|
||||
everywhere, and reading it back needs no idea which monitor that was.
|
||||
* **The description box and the suggestion list** cannot share a variable --
|
||||
``tk.Text`` and ``tk.Listbox`` have none. Their copies genuinely diverge, so
|
||||
reads answer from whichever copy the user actually touched.
|
||||
|
||||
The alternative -- laying the gate out on the primary monitor only -- was
|
||||
rejected: the whole point of per-output locking is that the user does not
|
||||
have to hunt for the screen the form landed on.
|
||||
|
||||
This module holds the per-widget groups; :mod:`._gatelock_groups` assembles
|
||||
them into the whole-form bundle, so each file stays under the repo's 250-line
|
||||
limit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING, TypeVar
|
||||
|
||||
from gatelock import WidgetGroup as SharedWidgetGroup
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
_W = TypeVar("_W", bound=tk.Misc)
|
||||
"""The widget each group mirrors, so a subclass keeps its own methods typed."""
|
||||
|
||||
|
||||
class WidgetGroup(SharedWidgetGroup[_W]):
|
||||
"""One logical widget, mirrored onto every monitor.
|
||||
|
||||
The fan-out itself -- iteration, ``configure``, ``bind``, singular focus,
|
||||
and skipping copies whose surface has been destroyed -- now lives in
|
||||
:class:`gatelock.WidgetGroup`, which four apps had each reimplemented.
|
||||
This subclass exists only to keep the positional ``list[_W]``
|
||||
construction the gate's own builders and specialized groups use; gatelock
|
||||
stores ``(output_name, widget)`` pairs, because the parallel-list form the
|
||||
donors shared could be built with widgets but no names.
|
||||
|
||||
``first`` is re-narrowed to ``_W``: the gate always builds at least one
|
||||
copy before anything reads one, and the specialized groups below index
|
||||
into it without a None check.
|
||||
"""
|
||||
|
||||
def __init__(self, widgets: list[_W]) -> None:
|
||||
"""Wrap the per-monitor copies of one widget."""
|
||||
super().__init__([("", widget) for widget in widgets])
|
||||
|
||||
@property
|
||||
def first(self) -> _W:
|
||||
"""The primary monitor's copy, for reads that cannot fan out."""
|
||||
widget = super().first
|
||||
if widget is None:
|
||||
message = "widget group is empty"
|
||||
raise IndexError(message)
|
||||
return widget
|
||||
|
||||
def bind(
|
||||
self,
|
||||
sequence: str,
|
||||
func: Callable[[tk.Event], object],
|
||||
*,
|
||||
add: bool = False,
|
||||
) -> None:
|
||||
"""Bind the same handler on every copy, *replacing* any previous one.
|
||||
|
||||
Only the default differs from :meth:`gatelock.WidgetGroup.bind`, and
|
||||
it is inverted deliberately. ``MealGate._wire_events`` runs once per
|
||||
``build_surface`` **and** again on rebuild, so with an additive bind
|
||||
every output change would stack a second handler on every surviving
|
||||
widget -- and ``<Return>`` would submit the meal twice. Replacing
|
||||
makes re-wiring idempotent, which is what this gate needs; gatelock
|
||||
defaults the other way because its callers bind once and must not
|
||||
clobber a handler someone else set.
|
||||
"""
|
||||
# Spelled out rather than delegated: an inherited signature with a
|
||||
# flipped default is invisible at the call site, and this one is
|
||||
# load-bearing enough to be worth reading in place.
|
||||
self._each(lambda widget: widget.bind(sequence, func, add="+" if add else ""))
|
||||
|
||||
|
||||
class EntryGroup(WidgetGroup[tk.Entry]):
|
||||
"""A single-line entry, mirrored onto every monitor over one variable.
|
||||
|
||||
Because every copy is bound to the same ``StringVar``, reads and writes go
|
||||
through the variable rather than the widgets: the copies cannot disagree.
|
||||
"""
|
||||
|
||||
def __init__(self, widgets: list[tk.Entry], var: tk.StringVar) -> None:
|
||||
"""Wrap the per-monitor entries and the variable behind them."""
|
||||
super().__init__(widgets)
|
||||
self._var = var
|
||||
|
||||
def get(self) -> str:
|
||||
"""Return the shared text, whichever monitor it was typed on."""
|
||||
return self._var.get()
|
||||
|
||||
def delete(self, _first: int | str, _last: int | str | None = None) -> None:
|
||||
"""Clear the shared text on every monitor."""
|
||||
self._var.set("")
|
||||
|
||||
def insert(self, _index: int | str, value: str) -> None:
|
||||
"""Set the shared text on every monitor.
|
||||
|
||||
Only ever used here to fill an empty field, so "insert" and "set" are
|
||||
the same operation; taking the index keeps the tk-shaped call sites
|
||||
unchanged.
|
||||
"""
|
||||
self._var.set(value)
|
||||
|
||||
|
||||
class TextGroup(WidgetGroup[tk.Text]):
|
||||
"""The description box, mirrored onto every monitor.
|
||||
|
||||
``tk.Text`` has no ``textvariable``, so the copies really do diverge. The
|
||||
user types into exactly one of them; reads return that one, which keeps
|
||||
"log the meal on whichever screen you are looking at" true.
|
||||
"""
|
||||
|
||||
def get(self, start: str, end: str) -> str:
|
||||
"""Return the content of whichever copy was actually typed into."""
|
||||
for widget in self:
|
||||
with contextlib.suppress(tk.TclError):
|
||||
content = widget.get(start, end)
|
||||
if content.strip():
|
||||
return str(content)
|
||||
return str(self.first.get(start, end))
|
||||
|
||||
def delete(self, start: str, end: str) -> None:
|
||||
"""Clear every copy."""
|
||||
for widget in self:
|
||||
with contextlib.suppress(tk.TclError):
|
||||
widget.delete(start, end)
|
||||
|
||||
def insert(self, index: str, value: str) -> None:
|
||||
"""Write the same content into every copy."""
|
||||
for widget in self:
|
||||
with contextlib.suppress(tk.TclError):
|
||||
widget.insert(index, value)
|
||||
|
||||
|
||||
class ListboxGroup(WidgetGroup[tk.Listbox]):
|
||||
"""The suggestion picker, mirrored onto every monitor.
|
||||
|
||||
A ``Listbox`` selection is per-widget, so the selected index is read from
|
||||
whichever copy has one -- the monitor the user clicked on.
|
||||
"""
|
||||
|
||||
def delete(self, first: int | str, last: int | str | None = None) -> None:
|
||||
"""Clear every copy."""
|
||||
for widget in self:
|
||||
with contextlib.suppress(tk.TclError):
|
||||
widget.delete(first, last)
|
||||
|
||||
def insert(self, index: int | str, *values: str) -> None:
|
||||
"""Append the same entries to every copy."""
|
||||
for widget in self:
|
||||
with contextlib.suppress(tk.TclError):
|
||||
widget.insert(index, *values)
|
||||
|
||||
def selection_set(self, first: int, last: int | None = None) -> None:
|
||||
"""Select the same row on every copy.
|
||||
|
||||
Programmatic selection (a keyboard pick, or a test) has to reach all
|
||||
of them; a *mouse* pick lands on one copy, which is what
|
||||
``curselection`` reads back.
|
||||
"""
|
||||
for widget in self:
|
||||
with contextlib.suppress(tk.TclError):
|
||||
if last is None:
|
||||
widget.selection_set(first)
|
||||
else:
|
||||
widget.selection_set(first, last)
|
||||
|
||||
def selection_clear(self, first: int, last: int | None = None) -> None:
|
||||
"""Clear the selection on every copy."""
|
||||
for widget in self:
|
||||
with contextlib.suppress(tk.TclError):
|
||||
if last is None:
|
||||
widget.selection_clear(first)
|
||||
else:
|
||||
widget.selection_clear(first, last)
|
||||
|
||||
def curselection(self) -> tuple[int, ...]:
|
||||
"""Return the selection from whichever copy the user clicked."""
|
||||
for widget in self:
|
||||
with contextlib.suppress(tk.TclError):
|
||||
selection = widget.curselection()
|
||||
if selection:
|
||||
return tuple(selection)
|
||||
return ()
|
||||
|
||||
def size(self) -> int:
|
||||
"""Number of rows; identical on every copy, so read the primary."""
|
||||
return int(self.first.size())
|
||||
@ -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,
|
||||
|
||||
116
diet_guard/tests/_gate_focus_probe.py
Normal file
116
diet_guard/tests/_gate_focus_probe.py
Normal file
@ -0,0 +1,116 @@
|
||||
"""Focus-reachability probing for the gate layout measurement.
|
||||
|
||||
Split out of :mod:`._gate_layout_probe` to keep both files under the repo's
|
||||
250-line limit. That module builds the layout and owns the
|
||||
:class:`Measurement` result; this one interrogates a built viewport -- does it
|
||||
scroll by keyboard, and does the view follow focus to the *bottom* of the
|
||||
content, where the submit button and dashboard live.
|
||||
|
||||
Test support, not a test, for the same reason as its sibling: its
|
||||
diagnostic-only branches are not held to the package's 100%-coverage bar.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from diet_guard.tests._gate_measurement import Measurement
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import tkinter as tk
|
||||
|
||||
# Tk widget classes that take keyboard focus by default. Checking `takefocus`
|
||||
# is not enough: it is "" on most widgets, meaning "ask ::tk::FocusOK", so an
|
||||
# empty value indicates a *default*-focusable widget rather than an opted-out
|
||||
# one. Matching on class is what actually mirrors FocusOK's behaviour here.
|
||||
_FOCUSABLE_CLASSES = frozenset(
|
||||
{
|
||||
"Button",
|
||||
"Checkbutton",
|
||||
"Entry",
|
||||
"Listbox",
|
||||
"Radiobutton",
|
||||
"Spinbox",
|
||||
"Text",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_focusable(widget: tk.Misc) -> bool:
|
||||
"""Whether ``widget`` would accept keyboard focus."""
|
||||
if str(widget.cget("takefocus")) == "0":
|
||||
return False
|
||||
if str(widget.cget("takefocus")) == "1":
|
||||
return True
|
||||
return widget.winfo_class() in _FOCUSABLE_CLASSES
|
||||
|
||||
|
||||
def _deepest_focusable(widget: tk.Misc) -> tk.Misc | None:
|
||||
"""Return the last descendant that can take keyboard focus.
|
||||
|
||||
Used to prove the *bottom* of the content is reachable, which is where the
|
||||
submit button and the dashboard live -- exactly what a centered, clipping
|
||||
frame used to hide.
|
||||
"""
|
||||
found: tk.Misc | None = None
|
||||
for child in widget.winfo_children():
|
||||
if _is_focusable(child):
|
||||
found = child
|
||||
deeper = _deepest_focusable(child)
|
||||
if deeper is not None:
|
||||
found = deeper
|
||||
return found
|
||||
|
||||
|
||||
def _probe_viewport(
|
||||
root: tk.Misc,
|
||||
canvas: tk.Canvas,
|
||||
inner: tk.Misc,
|
||||
screen_w: int,
|
||||
screen_h: int,
|
||||
) -> Measurement:
|
||||
"""Interrogate a built viewport: does it scroll, and does focus follow?
|
||||
|
||||
Split out of :func:`measure` so each function does one thing -- building the
|
||||
layout, and interrogating it -- and so neither trips the local-variable cap.
|
||||
"""
|
||||
box = canvas.bbox("all")
|
||||
span = (box[3] - box[1]) if box else 0
|
||||
viewport_h = canvas.winfo_height()
|
||||
|
||||
canvas.yview_moveto(0.0)
|
||||
root.update_idletasks()
|
||||
canvas.yview_scroll(1, "pages")
|
||||
root.update_idletasks()
|
||||
scrolls = canvas.yview()[0] > 0.0
|
||||
|
||||
# Scroll back to the top, then focus the deepest widget and check it ends up
|
||||
# on screen. Starting from the top is what makes this a real test: it is the
|
||||
# state a user arrives in.
|
||||
canvas.yview_moveto(0.0)
|
||||
root.update_idletasks()
|
||||
target = _deepest_focusable(inner)
|
||||
last_visible = False
|
||||
if target is not None:
|
||||
# A real keypress first, then the focus it would have moved. Since
|
||||
# 2026-08-03 gatelock only scrolls to follow focus the *user* moved:
|
||||
# it used to follow programmatic focus too, and because the apps
|
||||
# re-focus a widget on every repaint, the screen scrolled itself to
|
||||
# mid-content and back while the user was doing nothing. A bare
|
||||
# focus_set() here would therefore test a path no user can take.
|
||||
target.event_generate("<Key-Tab>", when="now")
|
||||
target.focus_set()
|
||||
root.update()
|
||||
root.update_idletasks()
|
||||
top = target.winfo_rooty() - inner.winfo_rooty()
|
||||
visible_from = canvas.canvasy(0)
|
||||
last_visible = visible_from <= top <= visible_from + viewport_h
|
||||
|
||||
return Measurement(
|
||||
screen_w=screen_w,
|
||||
screen_h=screen_h,
|
||||
content_span=span,
|
||||
viewport_h=viewport_h,
|
||||
scrolls_by_keyboard=scrolls,
|
||||
last_widget_visible_after_focus=last_visible,
|
||||
)
|
||||
@ -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.
|
||||
|
||||
@ -31,12 +31,14 @@ Run the gate with:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
from diet_guard._gatelock_ui import GateCallbacks, GateVars, build_layout
|
||||
from diet_guard._gatelock_layout import build_layout
|
||||
from diet_guard._gatelock_ui import GateCallbacks, GateVars
|
||||
from diet_guard._gatelock_ui_types import GateEntryVars
|
||||
from diet_guard.tests._gate_focus_probe import _probe_viewport
|
||||
from diet_guard.tests._gate_measurement import Measurement
|
||||
|
||||
# ttk.Notebook's tab strip is chrome the content pane never gets to use.
|
||||
# Measured rather than assumed; see _notebook_overhead().
|
||||
@ -53,62 +55,6 @@ _SAMPLE_DASHBOARD = (
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Measurement:
|
||||
"""One layout measurement against one screen size."""
|
||||
|
||||
screen_w: int
|
||||
screen_h: int
|
||||
content_span: int
|
||||
viewport_h: int
|
||||
scrolls_by_keyboard: bool
|
||||
last_widget_visible_after_focus: bool
|
||||
has_scroll_viewport: bool = True
|
||||
|
||||
@property
|
||||
def needs_scroll(self) -> bool:
|
||||
"""Whether the content is taller than the viewport."""
|
||||
return self.content_span > self.viewport_h
|
||||
|
||||
@property
|
||||
def reachable(self) -> bool:
|
||||
"""Whether every part of the content can be reached by keyboard.
|
||||
|
||||
Content that fits is trivially reachable. Content that overflows must
|
||||
scroll by keyboard *and* bring a focused below-the-fold widget into
|
||||
view.
|
||||
|
||||
Deliberately not asserted: that focusing always *moves* the view. When
|
||||
the overflow is small the deepest widget is already visible, and then
|
||||
holding still is the correct behaviour -- the invariant is "the focused
|
||||
widget is visible", not "the viewport scrolled".
|
||||
"""
|
||||
if not self.needs_scroll:
|
||||
return True
|
||||
return self.scrolls_by_keyboard and self.last_widget_visible_after_focus
|
||||
|
||||
def describe(self) -> str:
|
||||
"""Return a one-line human-readable summary."""
|
||||
fit = (
|
||||
f"needs scroll (+{self.content_span - self.viewport_h}px)"
|
||||
if self.needs_scroll
|
||||
else "fits"
|
||||
)
|
||||
verdict = "REACHABLE" if self.reachable else "UNREACHABLE CONTENT"
|
||||
viewport = (
|
||||
"scroll viewport present"
|
||||
if self.has_scroll_viewport
|
||||
else "NO SCROLL VIEWPORT -- overflow is clipped, not scrolled"
|
||||
)
|
||||
return (
|
||||
f"{self.screen_w}x{self.screen_h}: "
|
||||
f"content {self.content_span} in viewport {self.viewport_h} "
|
||||
f"-> {fit}; {viewport}; keys={self.scrolls_by_keyboard} "
|
||||
f"last_visible={self.last_widget_visible_after_focus} "
|
||||
f"-> {verdict}"
|
||||
)
|
||||
|
||||
|
||||
def _make_vars(*, populated: bool) -> GateVars:
|
||||
"""Build the gate's Tk variables, optionally with realistic content.
|
||||
|
||||
@ -166,103 +112,6 @@ def _notebook_overhead(root: tk.Misc) -> int:
|
||||
return overhead if overhead > 0 else _FALLBACK_TAB_STRIP_PX
|
||||
|
||||
|
||||
# Tk widget classes that take keyboard focus by default. Checking `takefocus`
|
||||
# is not enough: it is "" on most widgets, meaning "ask ::tk::FocusOK", so an
|
||||
# empty value indicates a *default*-focusable widget rather than an opted-out
|
||||
# one. Matching on class is what actually mirrors FocusOK's behaviour here.
|
||||
_FOCUSABLE_CLASSES = frozenset(
|
||||
{
|
||||
"Button",
|
||||
"Checkbutton",
|
||||
"Entry",
|
||||
"Listbox",
|
||||
"Radiobutton",
|
||||
"Spinbox",
|
||||
"Text",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_focusable(widget: tk.Misc) -> bool:
|
||||
"""Whether ``widget`` would accept keyboard focus."""
|
||||
if str(widget.cget("takefocus")) == "0":
|
||||
return False
|
||||
if str(widget.cget("takefocus")) == "1":
|
||||
return True
|
||||
return widget.winfo_class() in _FOCUSABLE_CLASSES
|
||||
|
||||
|
||||
def _deepest_focusable(widget: tk.Misc) -> tk.Misc | None:
|
||||
"""Return the last descendant that can take keyboard focus.
|
||||
|
||||
Used to prove the *bottom* of the content is reachable, which is where the
|
||||
submit button and the dashboard live -- exactly what a centered, clipping
|
||||
frame used to hide.
|
||||
"""
|
||||
found: tk.Misc | None = None
|
||||
for child in widget.winfo_children():
|
||||
if _is_focusable(child):
|
||||
found = child
|
||||
deeper = _deepest_focusable(child)
|
||||
if deeper is not None:
|
||||
found = deeper
|
||||
return found
|
||||
|
||||
|
||||
def _probe_viewport(
|
||||
root: tk.Misc,
|
||||
canvas: tk.Canvas,
|
||||
inner: tk.Misc,
|
||||
screen_w: int,
|
||||
screen_h: int,
|
||||
) -> Measurement:
|
||||
"""Interrogate a built viewport: does it scroll, and does focus follow?
|
||||
|
||||
Split out of :func:`measure` so each function does one thing -- building the
|
||||
layout, and interrogating it -- and so neither trips the local-variable cap.
|
||||
"""
|
||||
box = canvas.bbox("all")
|
||||
span = (box[3] - box[1]) if box else 0
|
||||
viewport_h = canvas.winfo_height()
|
||||
|
||||
canvas.yview_moveto(0.0)
|
||||
root.update_idletasks()
|
||||
canvas.yview_scroll(1, "pages")
|
||||
root.update_idletasks()
|
||||
scrolls = canvas.yview()[0] > 0.0
|
||||
|
||||
# Scroll back to the top, then focus the deepest widget and check it ends up
|
||||
# on screen. Starting from the top is what makes this a real test: it is the
|
||||
# state a user arrives in.
|
||||
canvas.yview_moveto(0.0)
|
||||
root.update_idletasks()
|
||||
target = _deepest_focusable(inner)
|
||||
last_visible = False
|
||||
if target is not None:
|
||||
# A real keypress first, then the focus it would have moved. Since
|
||||
# 2026-08-03 gatelock only scrolls to follow focus the *user* moved:
|
||||
# it used to follow programmatic focus too, and because the apps
|
||||
# re-focus a widget on every repaint, the screen scrolled itself to
|
||||
# mid-content and back while the user was doing nothing. A bare
|
||||
# focus_set() here would therefore test a path no user can take.
|
||||
target.event_generate("<Key-Tab>", when="now")
|
||||
target.focus_set()
|
||||
root.update()
|
||||
root.update_idletasks()
|
||||
top = target.winfo_rooty() - inner.winfo_rooty()
|
||||
visible_from = canvas.canvasy(0)
|
||||
last_visible = visible_from <= top <= visible_from + viewport_h
|
||||
|
||||
return Measurement(
|
||||
screen_w=screen_w,
|
||||
screen_h=screen_h,
|
||||
content_span=span,
|
||||
viewport_h=viewport_h,
|
||||
scrolls_by_keyboard=scrolls,
|
||||
last_widget_visible_after_focus=last_visible,
|
||||
)
|
||||
|
||||
|
||||
def measure(
|
||||
screen_w: int = 1366,
|
||||
screen_h: int = 768,
|
||||
|
||||
70
diet_guard/tests/_gate_measurement.py
Normal file
70
diet_guard/tests/_gate_measurement.py
Normal file
@ -0,0 +1,70 @@
|
||||
"""The result type shared by the gate's layout and focus probes.
|
||||
|
||||
Split out of :mod:`._gate_layout_probe` so that module and
|
||||
:mod:`._gate_focus_probe` can both import it without a cycle, and so each
|
||||
file stays under the repo's 250-line limit.
|
||||
|
||||
Test support, not a test: it lives under ``tests/`` rather than in the
|
||||
package, so its diagnostic-only branches are not held to the package's
|
||||
100%-coverage bar.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Measurement:
|
||||
"""One layout measurement against one screen size."""
|
||||
|
||||
screen_w: int
|
||||
screen_h: int
|
||||
content_span: int
|
||||
viewport_h: int
|
||||
scrolls_by_keyboard: bool
|
||||
last_widget_visible_after_focus: bool
|
||||
has_scroll_viewport: bool = True
|
||||
|
||||
@property
|
||||
def needs_scroll(self) -> bool:
|
||||
"""Whether the content is taller than the viewport."""
|
||||
return self.content_span > self.viewport_h
|
||||
|
||||
@property
|
||||
def reachable(self) -> bool:
|
||||
"""Whether every part of the content can be reached by keyboard.
|
||||
|
||||
Content that fits is trivially reachable. Content that overflows must
|
||||
scroll by keyboard *and* bring a focused below-the-fold widget into
|
||||
view.
|
||||
|
||||
Deliberately not asserted: that focusing always *moves* the view. When
|
||||
the overflow is small the deepest widget is already visible, and then
|
||||
holding still is the correct behaviour -- the invariant is "the focused
|
||||
widget is visible", not "the viewport scrolled".
|
||||
"""
|
||||
if not self.needs_scroll:
|
||||
return True
|
||||
return self.scrolls_by_keyboard and self.last_widget_visible_after_focus
|
||||
|
||||
def describe(self) -> str:
|
||||
"""Return a one-line human-readable summary."""
|
||||
fit = (
|
||||
f"needs scroll (+{self.content_span - self.viewport_h}px)"
|
||||
if self.needs_scroll
|
||||
else "fits"
|
||||
)
|
||||
verdict = "REACHABLE" if self.reachable else "UNREACHABLE CONTENT"
|
||||
viewport = (
|
||||
"scroll viewport present"
|
||||
if self.has_scroll_viewport
|
||||
else "NO SCROLL VIEWPORT -- overflow is clipped, not scrolled"
|
||||
)
|
||||
return (
|
||||
f"{self.screen_w}x{self.screen_h}: "
|
||||
f"content {self.content_span} in viewport {self.viewport_h} "
|
||||
f"-> {fit}; {viewport}; keys={self.scrolls_by_keyboard} "
|
||||
f"last_visible={self.last_widget_visible_after_focus} "
|
||||
f"-> {verdict}"
|
||||
)
|
||||
@ -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(
|
||||
|
||||
119
diet_guard/tests/test_gate_tk_modules.py
Normal file
119
diet_guard/tests/test_gate_tk_modules.py
Normal file
@ -0,0 +1,119 @@
|
||||
"""Guard: every gate module that builds Tk widgets must be faked in tests.
|
||||
|
||||
``_gate_fixtures.fake_tk`` patches ``tk`` on a hand-listed tuple of modules.
|
||||
That list fails **open**: split a widget builder into a new sibling, forget to
|
||||
add it, and the gate tests build *real* Tk widgets parented to fake ones. The
|
||||
symptom is a hang or an error far from the cause -- and on a machine with a
|
||||
display, a real window during a test run.
|
||||
|
||||
Neither of the sweep's other detectors sees this. ``check_patch_targets.py``
|
||||
only validates dotted paths that still resolve, and the suite's ~8s runtime
|
||||
only catches network calls. So the invariant is asserted here directly:
|
||||
*every* ``diet_guard`` module that binds the name ``tk`` is either in the
|
||||
patch set or explicitly declared widget-free.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import pathlib
|
||||
|
||||
from diet_guard.tests._gate_fixtures import _GATE_TK_MODULES
|
||||
|
||||
#: Modules that bind ``tk`` but build no part of the gate's widget tree, so
|
||||
#: they never need the shared fake. Each is exempt for a stated reason -- this
|
||||
#: set is the only sanctioned way out of the patch list.
|
||||
_NO_WIDGETS = frozenset(
|
||||
{
|
||||
# Mirror/adapter layer over widgets built elsewhere; constructs none
|
||||
# of its own, but does bind ``tk`` at runtime for its ``TclError``
|
||||
# suppressions.
|
||||
"diet_guard._gatelock_widgetgroups",
|
||||
# Display-readiness probe. Builds a throwaway ``tk.Tk()`` to test
|
||||
# whether X is reachable -- deliberately NOT part of the gate's widget
|
||||
# tree, so the shared fake would defeat the very thing it probes. Its
|
||||
# own tests patch ``_gatelock_support.tk`` per-test instead.
|
||||
"diet_guard._gatelock_support",
|
||||
},
|
||||
)
|
||||
|
||||
_PACKAGE_DIR = pathlib.Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _is_type_checking(test: ast.expr) -> bool:
|
||||
"""Whether an ``if`` test is the ``TYPE_CHECKING`` guard."""
|
||||
if isinstance(test, ast.Name):
|
||||
return test.id == "TYPE_CHECKING"
|
||||
return isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING"
|
||||
|
||||
|
||||
def _binds_tk(path: pathlib.Path) -> bool:
|
||||
"""Whether ``path`` binds the name ``tk`` to tkinter at module level.
|
||||
|
||||
That name -- not merely "imports tkinter" -- is what ``fake_tk`` patches,
|
||||
so it is what the patch set has to match. A module importing only ``ttk``
|
||||
(``_gatelock_calendar_ui``) has no ``tk`` to patch, and listing it would
|
||||
raise "does not have the attribute 'tk'".
|
||||
"""
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), str(path))
|
||||
# A TYPE_CHECKING-only import binds nothing at runtime, so there is no
|
||||
# attribute for ``patch.object`` to replace -- and listing such a module
|
||||
# would raise "does not have the attribute 'tk'". Drop those blocks
|
||||
# wholesale before walking, since ``ast.walk`` would otherwise still
|
||||
# descend into their bodies.
|
||||
tree.body = [
|
||||
node
|
||||
for node in tree.body
|
||||
if not (isinstance(node, ast.If) and _is_type_checking(node.test))
|
||||
]
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name == "tkinter" and alias.asname == "tk":
|
||||
return True
|
||||
elif (
|
||||
isinstance(node, ast.ImportFrom)
|
||||
and node.module
|
||||
and node.module.split(".")[0] == "tkinter"
|
||||
) and any(alias.asname == "tk" or alias.name == "tk" for alias in node.names):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def test_gate_tk_modules_complete() -> None:
|
||||
"""Every ``tk``-binding diet_guard module is faked or declared widget-free.
|
||||
|
||||
Fails closed: a new widget-building sibling that nobody added to
|
||||
``_GATE_TK_MODULES`` shows up here as a named diff, rather than as a real
|
||||
Tk window opening halfway through an unrelated gate test.
|
||||
"""
|
||||
patched = {module.__name__ for module in _GATE_TK_MODULES}
|
||||
importers = {
|
||||
f"diet_guard.{path.stem}"
|
||||
for path in sorted(_PACKAGE_DIR.glob("*.py"))
|
||||
if _binds_tk(path)
|
||||
}
|
||||
unaccounted = importers - patched - _NO_WIDGETS
|
||||
assert not unaccounted, (
|
||||
"these diet_guard modules bind `tk` but are neither in "
|
||||
"_gate_fixtures._GATE_TK_MODULES nor declared widget-free in "
|
||||
f"_NO_WIDGETS: {sorted(unaccounted)}"
|
||||
)
|
||||
|
||||
|
||||
def test_no_widgets_declarations_are_live() -> None:
|
||||
"""``_NO_WIDGETS`` names only modules that really do bind ``tk``.
|
||||
|
||||
Keeps the escape hatch honest: once a listed module stops binding ``tk``
|
||||
(or is renamed away), its entry is stale and must go, rather than
|
||||
lingering as a licence for some future file to skip the patch set.
|
||||
"""
|
||||
importers = {
|
||||
f"diet_guard.{path.stem}"
|
||||
for path in sorted(_PACKAGE_DIR.glob("*.py"))
|
||||
if _binds_tk(path)
|
||||
}
|
||||
assert importers >= _NO_WIDGETS, (
|
||||
"stale _NO_WIDGETS entries (no longer bind `tk`): "
|
||||
f"{sorted(_NO_WIDGETS - importers)}"
|
||||
)
|
||||
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user