From 84bd5503316568914ed93b9451c397ff517a09d4 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 25 Jul 2026 16:51:31 +0200 Subject: [PATCH] Migrate diet_guard to gatelock v0.2.0 per-output locking The gate is laid out once per live output instead of once on the primary, and claims the screen through an Arbiter at RANK_DIET_GUARD so the wake alarm and the workout locker can both outrank it. Three kinds of state needed three different answers. The display fields already fanned out for free -- GateVars holds root-mastered StringVars bound to labels. The typed-in fields did not: amount, per, meal name and the four macro cells were bare tk.Entry widgets, so N copies would have been N independent forms. They are variable-backed now, which is what makes logging work from whichever screen the user is actually looking at. The description box and the suggestion list cannot be shared at all -- tk.Text and tk.Listbox have no variable -- so those genuinely diverge and reads answer from the copy the user touched. The History tab is per-surface too: its grid, budget field and edit button are driven across every monitor, with the budget likewise variable-backed so the value cannot differ between screens mid-edit. Laying the gate out on the primary monitor only was considered and rejected: not having to hunt for the screen the form landed on is the entire point of per-output locking. Tests: the suite hung rather than failed on first run -- diet_guard patches its own `tk`, but LockWindow builds Toplevels through gatelock's, and real tkinter over a MagicMock root recurses without bound. `_hermetic_gatelock` closes that and redirects the runtime dir so no test can stand a production locker down. Every other test runs single-output, where fan-out is unobservable, so test_gatelock_fanout runs on two; each assertion was checked by cropping the fan-out loops to the first surface and confirming it goes red. _gatelock_ui and _gatelock_calendar were each split in two to stay under the 500-line limit, and GateVars' typed-in fields moved into a nested GateEntryVars rather than raising pylint's attribute cap. 470 tests, 100% branch coverage, pre-commit clean, no lint suppressions. --- .pre-commit-config.yaml | 2 +- diet_guard/_calendar_view.py | 20 +- diet_guard/_gatelock.py | 81 ++++- diet_guard/_gatelock_buttons.py | 79 +++++ diet_guard/_gatelock_calendar.py | 357 +++++---------------- diet_guard/_gatelock_calendar_ui.py | 285 ++++++++++++++++ diet_guard/_gatelock_groups.py | 314 ++++++++++++++++++ diet_guard/_gatelock_spacing.py | 16 + diet_guard/_gatelock_typography.py | 18 ++ diet_guard/_gatelock_ui.py | 262 +++++++-------- diet_guard/_gatelock_ui_types.py | 90 ++++++ diet_guard/tests/conftest.py | 76 ++++- diet_guard/tests/test_calendar_view.py | 9 +- diet_guard/tests/test_gatelock.py | 24 +- diet_guard/tests/test_gatelock_calendar.py | 18 +- diet_guard/tests/test_gatelock_fanout.py | 302 +++++++++++++++++ pyproject.toml | 2 +- requirements.txt | 2 +- 18 files changed, 1503 insertions(+), 454 deletions(-) create mode 100644 diet_guard/_gatelock_buttons.py create mode 100644 diet_guard/_gatelock_calendar_ui.py create mode 100644 diet_guard/_gatelock_groups.py create mode 100644 diet_guard/_gatelock_spacing.py create mode 100644 diet_guard/_gatelock_typography.py create mode 100644 diet_guard/_gatelock_ui_types.py create mode 100644 diet_guard/tests/test_gatelock_fanout.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e7c2f41..f3d993c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -98,7 +98,7 @@ repos: additional_dependencies: - pytest - requests - - gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.1.1#subdirectory=gatelock + - gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.2.0#subdirectory=gatelock - crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.1.0#subdirectory=crdt-sync - mcp - pydantic diff --git a/diet_guard/_calendar_view.py b/diet_guard/_calendar_view.py index 064c1f9..7eb12e9 100644 --- a/diet_guard/_calendar_view.py +++ b/diet_guard/_calendar_view.py @@ -13,6 +13,8 @@ import calendar from dataclasses import dataclass from datetime import date +from gatelock import LockConfig + from diet_guard._daystatus import ( DayStatus, adherence_streak, @@ -22,15 +24,19 @@ from diet_guard._daystatus import ( from diet_guard._gatelock_ui import BG, FG from diet_guard._state import now_local -_NOT_LOGGED_FILL = "#000000" -_NOT_LOGGED_OUTLINE = "#e0e0e0" +_COLORS = LockConfig() +# A "not logged" cell renders hollow -- background-colored fill, near-white +# outline -- rather than a pure-black square (safe-design-rules rule 1), +# distinguishing "no data" from "logged and over/under budget" (colored fill). +_NOT_LOGGED_FILL = BG +_NOT_LOGGED_OUTLINE = FG _STATUS_COLORS = { - DayStatus.GREEN: "#33cc66", - DayStatus.YELLOW: "#e0c33c", - DayStatus.RED: "#ff5555", + DayStatus.GREEN: _COLORS.success, + DayStatus.YELLOW: _COLORS.warning, + DayStatus.RED: _COLORS.danger, } -_STATUS_TEXT_COLOR = "#003322" -_MUTED = "#9a9a9a" +_STATUS_TEXT_COLOR = _COLORS.on_fill +_MUTED = _COLORS.muted @dataclass(frozen=True) diff --git a/diet_guard/_gatelock.py b/diet_guard/_gatelock.py index 32b5853..71c54e8 100644 --- a/diet_guard/_gatelock.py +++ b/diet_guard/_gatelock.py @@ -48,14 +48,21 @@ import sys import tkinter as tk from typing import TYPE_CHECKING -from gatelock import GateRoot, LockConfig, LockWindow +from gatelock import ( + RANK_DIET_GUARD, + Arbiter, + GateRoot, + LockConfig, + LockWindow, + SurfaceInfo, +) from diet_guard._constants import GATE_LOCK_FILE from diet_guard._gate import due_slots -from diet_guard._gatelock_calendar import _GateCalendar +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_ui import ( - BG, GateCallbacks, make_vars, ) @@ -144,22 +151,62 @@ class MealGate(_GateCalendar): self.root = GateRoot() self.root.on_callback_error = self.on_callback_error self.root.title("Diet Gate" + (" [DEMO]" if demo_mode else "")) - config = LockConfig(mode="soft" if demo_mode else "hard", bg=BG) - self._lock = LockWindow(self.root, config, hooks=self) + # No bg=/fg=/... overrides here: LockConfig's own defaults already + # are the unified-design-system palette, and _gatelock_ui reads the + # same LockConfig() rather than a separate hardcoded copy -- see + # DESIGN_AUDIT_TODO.md for why an override here previously drifted. + config = LockConfig( + mode="soft" if demo_mode else "hard", + app_name="diet_guard", + rank=RANK_DIET_GUARD, + ) + # Published here, then owned by LockWindow, which releases it on + # close -- so deliberately not kept as an attribute. + arbiter = Arbiter( + "diet_guard", + RANK_DIET_GUARD, + grab=config.resolved_grab(), + disable_vt=config.resolved_disable_vt(), + ) + arbiter.publish() + arbiter.acquire_holder() + self._lock = LockWindow(self.root, config, hooks=self, arbiter=arbiter) self._vars = make_vars(self.root) - self._build() - - def _build(self) -> None: - """Lay out the UI, wire events, seed the first prompt, and grab input.""" - self._lock.setup() - callbacks = GateCallbacks( + # Built before setup(), which calls build_surface per live output. + self._cal_vars = make_calendar_vars(self.root) + self._cal_surfaces = [] + self._widgets = GateWidgetsGroup(self._vars) + self._callbacks = GateCallbacks( on_unit_change=self._on_unit_change, on_submit=self._on_submit, on_close=self.close, on_add_item=self._on_add_item, on_fetch_sync=self._on_fetch_sync, ) - self._widgets = self._build_tabs(callbacks) + self._build() + + def build_surface(self, parent: tk.Misc, surface: SurfaceInfo) -> None: + """Lay the whole gate out on one monitor. + + Called once per live output, and again whenever an output comes back. + Every copy shares one set of GateVars, so a value typed on any screen + is the same value on all of them and logging works from whichever + monitor the user is actually looking at. + """ + widgets = self._build_tabs(parent, self._callbacks) + self._widgets.add(widgets, surface.output_name) + # An output coming back mid-lock gets its bindings too; tk's bind + # replaces rather than stacks, so re-wiring the others is a no-op. + self._wire_events() + + def teardown_surface(self, surface: SurfaceInfo) -> None: + """Forget the copy for an output that went dark.""" + self._widgets.discard(surface.output_name) + self._cal_surfaces = self._cal_surfaces[: len(self._widgets.bundles)] + + def _build(self) -> None: + """Lay out the UI, wire events, seed the first prompt, and grab input.""" + self._lock.setup() self._wire_events() self._relabel_basis() self._refresh_slot_header() @@ -169,8 +216,14 @@ class MealGate(_GateCalendar): self._lock.grab_input() self._widgets.desc_text.focus_set() - def on_focus_ready(self) -> None: - """Put keyboard focus on the description entry once it is mapped.""" + def on_focus_ready(self, surface: SurfaceInfo | None) -> None: + """Put keyboard focus on the description entry once it is mapped. + + ``surface`` is None when no output is live -- the gate is held with + nothing to show, so there is nothing to focus. + """ + if surface is None: + return self._widgets.desc_text.focus_force() def on_close(self) -> None: diff --git a/diet_guard/_gatelock_buttons.py b/diet_guard/_gatelock_buttons.py new file mode 100644 index 0000000..87c405e --- /dev/null +++ b/diet_guard/_gatelock_buttons.py @@ -0,0 +1,79 @@ +"""Shared primary/secondary/danger button builder for the diet_guard gate. + +Split out of :mod:`._gatelock_ui` to keep every gate module under the repo's +500-line limit. Centralizes what used to be 7 independent ``tk.Button(...)`` +calls (across :mod:`._gatelock_ui` and :mod:`._gatelock_calendar`) each +picking their own bg/fg/activebackground -- see ``DESIGN_AUDIT_TODO.md``. +""" + +from __future__ import annotations + +import tkinter as tk +from typing import TYPE_CHECKING, Literal + +from gatelock import LockConfig + +from diet_guard._gatelock_typography import BODY, LABEL + +if TYPE_CHECKING: + from collections.abc import Callable + +_COLORS = LockConfig() + + +def _lighten(hex_color: str, amount: float = 0.12) -> str: + """Blend ``hex_color`` toward white by ``amount``, for a button hover state.""" + red, green, blue = (int(hex_color[i : i + 2], 16) for i in (1, 3, 5)) + return "#" + "".join( + f"{round(channel + (255 - channel) * amount):02x}" + for channel in (red, green, blue) + ) + + +# Fill/text pairs per button role, read from the shared LockConfig palette +# instead of each call site inventing its own hex pair. "primary" is the +# one high-emphasis action per screen (rule 3); "secondary" and "danger" +# stay low-contrast/alarm respectively. All three use `on_fill` (never +# `fg`) for text-on-a-filled-surface, per tokens.md. +_BUTTON_FILLS: dict[str, tuple[str, str]] = { + "primary": (_COLORS.accent, _COLORS.on_fill), + "secondary": (_COLORS.field_bg, _COLORS.fg), + "danger": (_COLORS.danger, _COLORS.on_fill), +} +# The one high-emphasis button per screen reads larger than everything else +# (rule 3: size is another axis of prominence, alongside color) -- every +# other variant shares the same, smaller chrome size (tokens.md's type scale). +_BUTTON_SIZE: dict[str, int] = { + "primary": BODY, + "secondary": LABEL, + "danger": LABEL, +} +# Horizontal = 2x vertical (tokens.md "Buttons"; both on the 4px spacing scale). +_BUTTON_PADY = 12 +_BUTTON_PADX = 24 + + +def make_button( + parent: tk.Misc, + *, + text: str, + variant: Literal["primary", "secondary", "danger"], + command: Callable[[], None], + bold: bool = True, +) -> tk.Button: + """Build a button from the shared primary/secondary/danger palette.""" + fill, text_color = _BUTTON_FILLS[variant] + size = _BUTTON_SIZE[variant] + font = (_COLORS.font_family, size, "bold") if bold else (_COLORS.font_family, size) + return tk.Button( + parent, + text=text, + font=font, + bg=fill, + fg=text_color, + activebackground=_lighten(fill), + cursor="hand2", + padx=_BUTTON_PADX, + pady=_BUTTON_PADY, + command=command, + ) diff --git a/diet_guard/_gatelock_calendar.py b/diet_guard/_gatelock_calendar.py index d7fad88..dbca1bc 100644 --- a/diet_guard/_gatelock_calendar.py +++ b/diet_guard/_gatelock_calendar.py @@ -17,10 +17,9 @@ are unit-tested directly without a fake Tk in the loop. from __future__ import annotations import calendar -from dataclasses import dataclass +import contextlib import tkinter as tk from tkinter import ttk -from typing import TYPE_CHECKING from diet_guard._budget import ( BudgetError, @@ -38,9 +37,20 @@ from diet_guard._calendar_view import ( ytd_text, ) from diet_guard._daystatus import DayStatus, status_map +from diet_guard._gatelock_calendar_ui import ( + _DECEMBER, + _DEFAULT_BUDGET_KCAL, + _JANUARY, + _MONTH_AFTER_DECEMBER, + CalendarCallbacks, + CalendarVars, + CalendarWidgets, + _style_notebook, + build_calendar_frame, + make_calendar_vars, +) from diet_guard._gatelock_mealflow import _GateMealFlow from diet_guard._gatelock_ui import ( - BG, ERR, FG, GateCallbacks, @@ -49,242 +59,14 @@ from diet_guard._gatelock_ui import ( ) from diet_guard._state import load_log, now_local -if TYPE_CHECKING: - from collections.abc import Callable - -# Palette additions specific to the calendar (BG/FG/ERR come from -# _gatelock_ui's public exports; the rest are private to that module, so -# equivalents are defined locally rather than reaching across the boundary). -_MUTED = "#9a9a9a" -_FIELD_BG = "#2a2a2a" -_ACCENT = "#00ff88" - -# 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 - budget_status: tk.StringVar - - -@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=""), - budget_status=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=(8, 4)) - tk.Label( - row, - text="Daily budget (kcal):", - font=("Arial", 12), - bg=BG, - fg=FG, - ).pack(side="left") - entry = tk.Entry( - row, - font=("Arial", 13), - width=8, - bg=_FIELD_BG, - fg=FG, - insertbackground=FG, - justify="center", - state="readonly", - readonlybackground=_FIELD_BG, - ) - entry.pack(side="left", padx=(6, 6), ipady=2) - edit_button = tk.Button( - row, - text="Edit", - font=("Arial", 12, "bold"), - bg=_ACCENT, - fg="#003322", - activebackground="#00cc66", - cursor="hand2", - command=on_edit_or_save_budget, - ) - edit_button.pack(side="left") - status_label = tk.Label( - parent, - textvariable=vars_.budget_status, - font=("Arial", 11), - bg=BG, - fg=FG, - ) - status_label.pack(pady=(0, 4)) - 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=(4, 2)) - tk.Button( - row, - text="◀", - font=("Arial", 12, "bold"), - bg=_FIELD_BG, - fg=FG, - command=callbacks.on_prev_month, - cursor="hand2", - ).pack(side="left", padx=4) - tk.Label( - row, - textvariable=vars_.month_label, - font=("Arial", 14, "bold"), - bg=BG, - fg=FG, - width=16, - justify="center", - ).pack(side="left") - tk.Button( - row, - text="▶", - font=("Arial", 12, "bold"), - bg=_FIELD_BG, - fg=FG, - command=callbacks.on_next_month, - cursor="hand2", - ).pack(side="left", padx=4) - - -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=("Arial", 10, "bold"), - bg=BG, - fg=_MUTED, - width=4, - ).grid(row=0, column=col, padx=1) - - grid_frame = tk.Frame(parent, bg=BG) - grid_frame.pack(pady=(2, 4)) - day_cells: list[tk.Label] = [] - for row in range(_MONTH_ROWS): - for col in range(7): - cell = tk.Label( - grid_frame, - text="", - font=("Arial", 11), - width=4, - height=2, - bg=BG, - fg=FG, - highlightthickness=2, - highlightbackground=BG, - ) - cell.grid(row=row, column=col, padx=1, pady=1) - 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=("Arial", 22, "bold"), - bg=BG, - fg=_ACCENT, - ).pack(pady=(10, 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=("Arial", 13, "bold"), - bg=BG, - fg=FG, - ).pack(pady=(6, 0)) - tk.Label( - frame, - textvariable=vars_.ytd, - font=("Arial", 12), - bg=BG, - fg=_MUTED, - ).pack(pady=(2, 8)) - - 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__ = [ + "CalendarCallbacks", + "CalendarVars", + "CalendarWidgets", + "_GateCalendar", + "build_calendar_frame", + "make_calendar_vars", +] class _GateCalendar(_GateMealFlow): @@ -292,40 +74,46 @@ class _GateCalendar(_GateMealFlow): _cal_vars: CalendarVars _cal_widgets: CalendarWidgets + _cal_surfaces: list[CalendarWidgets] _cal_year: int _cal_month: int _cal_editing_budget: bool _notebook: ttk.Notebook - def _build_tabs(self, callbacks: GateCallbacks) -> GateWidgets: - """Build the ttk.Notebook, wire both tabs, and return the meal widgets. + def _build_tabs(self, parent: tk.Misc, callbacks: GateCallbacks) -> GateWidgets: + """Build one monitor's notebook, wire both tabs, return its meal widgets. - The controller stores the returned bundle as ``self._widgets`` exactly - as it did with the bare ``build_layout`` result before this tab - existed; the calendar's own vars/widgets are stashed on self. + Called once per live output since gatelock v0.2.0. Every copy shares + one set of ``GateVars``, so the meal form stays in step across + monitors; the calendar tab is read-only apart from the budget field, + which is likewise variable-backed. + + The History tab's own widgets are stashed per surface so the refresh + pass can repaint every monitor's grid. """ today = now_local().date() self._cal_year = today.year self._cal_month = today.month self._cal_editing_budget = False - self._notebook = ttk.Notebook(self.root) - self._notebook.place(relx=0, rely=0, relwidth=1, relheight=1) + _style_notebook(parent) + notebook = ttk.Notebook(parent) + notebook.place(relx=0, rely=0, relwidth=1, relheight=1) + self._notebook = notebook widgets = build_layout( - self._notebook, self._vars, callbacks, demo_mode=self.demo_mode + notebook, self._vars, callbacks, demo_mode=self.demo_mode ) - self._notebook.add(widgets.frame, text="Log Meal") + notebook.add(widgets.frame, text="Log Meal") - self._cal_vars = make_calendar_vars(self.root) cal_callbacks = CalendarCallbacks( on_prev_month=self._on_prev_month, on_next_month=self._on_next_month, on_edit_or_save_budget=self._on_edit_or_save_budget, ) - self._cal_widgets = build_calendar_frame( - self._notebook, self._cal_vars, cal_callbacks - ) - self._notebook.add(self._cal_widgets.frame, text="History") + cal_widgets = build_calendar_frame(notebook, self._cal_vars, cal_callbacks) + notebook.add(cal_widgets.frame, text="History") + self._cal_surfaces.append(cal_widgets) + self._cal_widgets = cal_widgets return widgets # -- refresh -------------------------------------------------------------- @@ -364,31 +152,29 @@ class _GateCalendar(_GateMealFlow): self._cal_vars.month_label.set( f"{calendar.month_name[self._cal_month]} {self._cal_year}", ) - cells = self._cal_widgets.day_cells - for index, cell_widget in enumerate(cells): - row, col = divmod(index, 7) - spec = weeks[row][col] if row < len(weeks) else CalendarCell(None, None) - bg, fg, outline = cell_style(spec.status) - cell_widget.config( - text=str(spec.day) if spec.day else "", - bg=bg, - fg=fg, - highlightbackground=outline, - ) + for surface in self._cal_surfaces: + for index, cell_widget in enumerate(surface.day_cells): + row, col = divmod(index, 7) + spec = weeks[row][col] if row < len(weeks) else CalendarCell(None, None) + bg, fg, outline = cell_style(spec.status) + with contextlib.suppress(tk.TclError): + cell_widget.config( + text=str(spec.day) if spec.day else "", + bg=bg, + fg=fg, + highlightbackground=outline, + ) def _refresh_budget_field(self, budget: int | None) -> None: """Show ``budget`` in the (read-only) entry, or leave it blank if unset. - Temporarily switches to the "normal" Tk state to mutate the text -- - a real ``readonly`` Entry rejects ``.insert``/``.delete`` the same - as direct typing -- then restores read-only. + The text goes through the shared variable rather than the widget, + which sidesteps the old dance of flipping to "normal" to mutate it: + a ``readonly`` Entry rejects ``.insert``/``.delete`` but still + follows its ``textvariable``. """ - entry = self._cal_widgets.budget_entry - entry.config(state="normal") - entry.delete(0, tk.END) - if budget is not None: - entry.insert(0, str(budget)) - entry.config(state="readonly") + self._cal_vars.budget.set("" if budget is None else str(budget)) + self._set_budget_entry_state("readonly") # -- month navigation ------------------------------------------------------- @@ -410,10 +196,25 @@ class _GateCalendar(_GateMealFlow): # -- 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) - self._cal_widgets.budget_status_label.config(fg=ERR if error else FG) + 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. @@ -426,14 +227,14 @@ class _GateCalendar(_GateMealFlow): """ if not self._cal_editing_budget: self._cal_editing_budget = True - self._cal_widgets.budget_entry.config(state="normal") - self._cal_widgets.budget_edit_button.config(text="Save") + 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._cal_widgets.budget_edit_button.config(text="Edit") + self._set_budget_button_text("Edit") self._refresh_calendar() self._refresh_dashboard() self._refresh_projection() @@ -448,7 +249,7 @@ class _GateCalendar(_GateMealFlow): Returns: Whether the value was valid and persisted. """ - raw = self._cal_widgets.budget_entry.get().strip() + raw = self._cal_vars.budget.get().strip() try: value = int(raw) except ValueError: diff --git a/diet_guard/_gatelock_calendar_ui.py b/diet_guard/_gatelock_calendar_ui.py new file mode 100644 index 0000000..7b926b2 --- /dev/null +++ b/diet_guard/_gatelock_calendar_ui.py @@ -0,0 +1,285 @@ +"""Widget construction for the gate's History tab. + +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. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import tkinter as tk +from tkinter import ttk +from typing import TYPE_CHECKING + +from gatelock import LockConfig + +from diet_guard._gatelock_buttons import 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, +) + +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 + + +@dataclass +class CalendarVars: + """Tk string variables bound to the History tab's live fields.""" + + month_label: tk.StringVar + streaks: tk.StringVar + ytd: 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=""), + 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, + text="Edit", + variant="primary", + command=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, + text="◀", + variant="secondary", + command=callbacks.on_prev_month, + ).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, + text="▶", + variant="secondary", + command=callbacks.on_next_month, + ).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, 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, + ) + + +def _style_notebook(root: tk.Misc) -> None: + """Theme the ``ttk.Notebook`` tab strip to match the gate's dark palette. + + ``ttk`` widgets ignore plain ``bg=``/``fg=`` -- unlike every other widget + here, they only take color from a named ``ttk.Style``. Without this the + tab strip renders in the OS's default (light) ttk theme regardless of how + dark the rest of the gate is. ``clam`` is used because it is the ttk + theme that reliably honors ``.configure()`` overrides cross-platform; the + built-in ``default``/``alt`` themes on some platforms ignore custom tab + colors. + """ + style = ttk.Style(root) + style.theme_use("clam") + style.configure("TNotebook", background=BG, borderwidth=0) + style.configure( + "TNotebook.Tab", + background=_FIELD_BG, + foreground=FG, + padding=(MD, SM), + borderwidth=0, + ) + style.map( + "TNotebook.Tab", + background=[("selected", _ACCENT)], + foreground=[("selected", _COLORS.on_fill)], + ) diff --git a/diet_guard/_gatelock_groups.py b/diet_guard/_gatelock_groups.py new file mode 100644 index 0000000..594e7eb --- /dev/null +++ b/diet_guard/_gatelock_groups.py @@ -0,0 +1,314 @@ +"""Presenting the gate's N per-monitor copies as if they were one form. + +Since gatelock v0.2.0 the lock builds one window per live output, so the gate +is laid out once per monitor and the controller must drive all of them at +once. 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. +""" + +from __future__ import annotations + +import contextlib +import tkinter as tk +from typing import TYPE_CHECKING, Generic, TypeVar + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator + + 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(Generic[_W]): + """One logical widget, mirrored onto every monitor.""" + + def __init__(self, widgets: list[_W]) -> None: + """Wrap the per-monitor copies of one widget.""" + self._widgets = widgets + + def __iter__(self) -> Iterator[_W]: + """Iterate the per-monitor copies.""" + return iter(self._widgets) + + @property + def first(self) -> _W: + """The primary monitor's copy, for reads that cannot fan out.""" + return self._widgets[0] + + def config(self, **kwargs: str) -> None: + """Apply the same configuration to every copy.""" + for widget in self._widgets: + with contextlib.suppress(tk.TclError): + widget.configure(**kwargs) + + configure = config + + def bind( + self, sequence: str, callback: Callable[[tk.Event[tk.Misc]], object] + ) -> None: + """Bind the same handler on every copy. + + Tk's ``bind`` replaces an existing binding for the same sequence, so + re-wiring after an output comes back is idempotent rather than + stacking duplicate handlers. + """ + for widget in self._widgets: + with contextlib.suppress(tk.TclError): + widget.bind(sequence, callback) + + def focus_set(self) -> None: + """Focus the first copy that still exists. + + Focus is singular by nature: there is one keyboard, so this picks the + first live copy rather than fanning out. + """ + for widget in self._widgets: + with contextlib.suppress(tk.TclError): + widget.focus_set() + return + + def focus_force(self) -> None: + """Force focus onto the first copy that still exists.""" + for widget in self._widgets: + with contextlib.suppress(tk.TclError): + widget.focus_force() + return + + +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._widgets: + with contextlib.suppress(tk.TclError): + content = widget.get(start, end) + if content.strip(): + return str(content) + return str(self._widgets[0].get(start, end)) + + def delete(self, start: str, end: str) -> None: + """Clear every copy.""" + for widget in self._widgets: + 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._widgets: + 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._widgets: + 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._widgets: + 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._widgets: + 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._widgets: + 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._widgets: + 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._widgets[0].size()) + + +class MacroGroup: + """The four macro entries, each mirrored onto every monitor.""" + + def __init__( + self, + kcal: EntryGroup, + protein: EntryGroup, + carbs: EntryGroup, + fat: EntryGroup, + ) -> None: + """Bundle the four macro entry groups.""" + self.kcal = kcal + self.protein = protein + self.carbs = carbs + self.fat = fat + + +class GateWidgetsGroup: + """Every per-monitor copy of the gate, driven as if it were one form. + + ``_build_tabs`` produces one ``GateWidgets`` per live output and they are + added here; the controller then drives this exactly as it drove a single + bundle before per-output locking existed. + """ + + def __init__(self, vars_: GateVars) -> None: + """Start empty; surfaces are added as outputs come up.""" + self._vars = vars_ + self._bundles: list[GateWidgets] = [] + self._outputs: list[str] = [] + + def add(self, widgets: GateWidgets, output_name: str) -> None: + """Register the bundle built for one newly-live output.""" + self._bundles.append(widgets) + self._outputs.append(output_name) + + def discard(self, output_name: str) -> None: + """Forget the bundle for an output that went dark.""" + kept = [ + (bundle, name) + for bundle, name in zip(self._bundles, self._outputs, strict=True) + if name != output_name + ] + self._bundles = [bundle for bundle, _ in kept] + self._outputs = [name for _, name in kept] + + @property + def bundles(self) -> list[GateWidgets]: + """Every per-monitor bundle; empty until the lock builds them.""" + return list(self._bundles) + + def _each(self, name: str) -> list[tk.Misc]: + """Collect one named widget from every monitor's bundle.""" + return [getattr(bundle, name) for bundle in self._bundles] + + @property + def frame(self) -> WidgetGroup[tk.Misc]: + """The gate's outer frame on every monitor.""" + return WidgetGroup(self._each("frame")) + + @property + def desc_text(self) -> TextGroup: + """The description box on every monitor.""" + return TextGroup(self._each("desc_text")) + + @property + def suggestion_box(self) -> ListboxGroup: + """The suggestion picker on every monitor.""" + return ListboxGroup(self._each("suggestion_box")) + + @property + def status_label(self) -> WidgetGroup[tk.Misc]: + """The status line on every monitor.""" + return WidgetGroup(self._each("status_label")) + + @property + def basis_prefix(self) -> WidgetGroup[tk.Misc]: + """The per-basis prefix label on every monitor.""" + return WidgetGroup(self._each("basis_prefix")) + + @property + def amount_entry(self) -> EntryGroup: + """The amount entry, over its shared variable.""" + return EntryGroup(self._each("amount_entry"), self._vars.entries.amount) + + @property + def per_entry(self) -> EntryGroup: + """The per-basis entry, over its shared variable.""" + return EntryGroup(self._each("per_entry"), self._vars.entries.per) + + @property + def meal_name_entry(self) -> EntryGroup: + """The meal-name entry, over its shared variable.""" + return EntryGroup(self._each("meal_name_entry"), self._vars.entries.meal_name) + + @property + def macros(self) -> MacroGroup: + """The four macro entries, each over its shared variable.""" + cells = [bundle.macros for bundle in self._bundles] + return MacroGroup( + kcal=EntryGroup([c.kcal for c in cells], self._vars.entries.kcal), + protein=EntryGroup([c.protein for c in cells], self._vars.entries.protein), + carbs=EntryGroup([c.carbs for c in cells], self._vars.entries.carbs), + fat=EntryGroup([c.fat for c in cells], self._vars.entries.fat), + ) diff --git a/diet_guard/_gatelock_spacing.py b/diet_guard/_gatelock_spacing.py new file mode 100644 index 0000000..f12a426 --- /dev/null +++ b/diet_guard/_gatelock_spacing.py @@ -0,0 +1,16 @@ +"""Shared spacing scale for the diet_guard gate (tokens.md's 4px scale). + +Split out so :mod:`._gatelock_ui` and :mod:`._gatelock_calendar` pick +``pady``/``padx``/``ipady`` values from one deliberate scale instead of +each row inventing its own gap -- see ``DESIGN_AUDIT_TODO.md``. Values are +the px steps from ``~/utils/unified-design-system/tokens.md``'s spacing +scale, used directly as Tk pixel counts. +""" + +from __future__ import annotations + +XS = 4 +SM = 8 +MD = 16 +LG = 24 +XXL = 48 diff --git a/diet_guard/_gatelock_typography.py b/diet_guard/_gatelock_typography.py new file mode 100644 index 0000000..3337206 --- /dev/null +++ b/diet_guard/_gatelock_typography.py @@ -0,0 +1,18 @@ +"""Shared type scale for the diet_guard gate (tokens.md's typography scale). + +Split out so :mod:`._gatelock_ui`, :mod:`._gatelock_calendar`, and +:mod:`._gatelock_buttons` read the same sizes instead of each picking its +own point value per label -- see ``DESIGN_AUDIT_TODO.md``. Values are the +px sizes from ``~/utils/unified-design-system/tokens.md``'s typography +scale, used directly as Tk point sizes (Tk has no separate px/pt distinction +in this codebase's existing font tuples). +""" + +from __future__ import annotations + +DISPLAY = 32 +TITLE = 24 +SUBTITLE = 20 +BODY = 16 +LABEL = 14 +CAPTION = 12 diff --git a/diet_guard/_gatelock_ui.py b/diet_guard/_gatelock_ui.py index 2048e2a..6dd0db5 100644 --- a/diet_guard/_gatelock_ui.py +++ b/diet_guard/_gatelock_ui.py @@ -16,20 +16,45 @@ controller internals ever cross the module boundary. from __future__ import annotations -from dataclasses import dataclass import tkinter as tk from typing import TYPE_CHECKING +from gatelock import LockConfig + +from diet_guard._gatelock_buttons import make_button +from diet_guard._gatelock_spacing import MD, SM, XS +from diet_guard._gatelock_typography import ( + BODY, + CAPTION, + DISPLAY, + LABEL, + SUBTITLE, + TITLE, +) +from diet_guard._gatelock_ui_types import ( + GateCallbacks, + GateEntryVars, + GateVars, + GateWidgets, + _MacroEntries, +) + if TYPE_CHECKING: from collections.abc import Callable -# Palette (mirrors the screen locker's dark, high-contrast lock aesthetic). -BG = "#1a1a1a" -FG = "#e0e0e0" -_ACCENT = "#00ff88" -ERR = "#ff6666" -_FIELD_BG = "#2a2a2a" -_MUTED = "#9a9a9a" +# 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 +# already import them by name; they now track LockConfig instead of a +# separate, driftable copy. +_COLORS = LockConfig() +BG = _COLORS.bg +FG = _COLORS.fg +ERR = _COLORS.danger +_ACCENT = _COLORS.accent +_FIELD_BG = _COLORS.field_bg +_MUTED = _COLORS.muted +_FONT = _COLORS.font_family # Number of food-bank / staple / OFF suggestions shown in the picker list. SUGGESTION_ROWS = 5 # Grams a label's macros are assumed to describe when the "per" field is blank. @@ -42,59 +67,18 @@ BASIS_PREFIX_GRAMS = "Nutrition as on the label — per" BASIS_PREFIX_ITEMS = "Nutrition per 1 item ≈" -@dataclass -class _MacroEntries: - """The four macro entry widgets, in (kcal, protein, carbs, fat) order.""" - - kcal: tk.Entry - protein: tk.Entry - carbs: tk.Entry - fat: tk.Entry - - -@dataclass -class GateVars: - """Tk string variables bound to the gate's live, auto-updating fields.""" - - status: tk.StringVar - slot_header: tk.StringVar - preview: tk.StringVar - projection: tk.StringVar - cal_headline: tk.StringVar - dashboard: tk.StringVar - meal_summary: tk.StringVar - unit: tk.StringVar - - -@dataclass -class GateWidgets: - """Interactive widgets the controller reads back after the UI is built.""" - - frame: tk.Frame - desc_text: tk.Text - amount_entry: tk.Entry - per_entry: tk.Entry - basis_prefix: tk.Label - macros: _MacroEntries - suggestion_box: tk.Listbox - meal_name_entry: tk.Entry - status_label: tk.Label - - -@dataclass -class GateCallbacks: - """Construction-time commands the widgets fire (not key/event bindings). - - These are the callbacks that must be supplied when a widget is created -- - option-menu and button commands. Per-keystroke event bindings are wired by - the controller after the layout is built, so they are not carried here. - """ - - on_unit_change: Callable[[str], None] - on_submit: Callable[[], None] - on_close: Callable[[], None] - on_add_item: Callable[[], None] - on_fetch_sync: Callable[[], None] +__all__ = [ + "DEFAULT_PER_GRAMS", + "UNIT_GRAMS", + "GateCallbacks", + "GateEntryVars", + "GateVars", + "GateWidgets", + "_MacroEntries", + "build_layout", + "is_numeric_or_blank", + "make_vars", +] def make_vars(root: tk.Misc) -> GateVars: @@ -108,6 +92,15 @@ def make_vars(root: tk.Misc) -> GateVars: dashboard=tk.StringVar(master=root, value=""), meal_summary=tk.StringVar(master=root, value=""), unit=tk.StringVar(master=root, value=UNIT_GRAMS), + entries=GateEntryVars( + amount=tk.StringVar(master=root, value=""), + per=tk.StringVar(master=root, value=f"{DEFAULT_PER_GRAMS:g}"), + meal_name=tk.StringVar(master=root, value=""), + kcal=tk.StringVar(master=root, value=""), + protein=tk.StringVar(master=root, value=""), + carbs=tk.StringVar(master=root, value=""), + fat=tk.StringVar(master=root, value=""), + ), ) @@ -122,12 +115,15 @@ def is_numeric_or_blank(proposed: str) -> bool: return True -def _numeric_entry(root: tk.Misc, parent: tk.Frame, *, width: int) -> tk.Entry: +def _numeric_entry( + root: tk.Misc, parent: tk.Frame, *, width: int, variable: tk.StringVar +) -> tk.Entry: """Return an entry that only accepts a number or a blank string.""" vcmd = (root.register(is_numeric_or_blank), "%P") return tk.Entry( parent, - font=("Arial", 15), + textvariable=variable, + font=(_FONT, BODY), width=width, bg=_FIELD_BG, fg=FG, @@ -138,13 +134,15 @@ def _numeric_entry(root: tk.Misc, parent: tk.Frame, *, width: int) -> tk.Entry: ) -def _macro_cell(root: tk.Misc, row: tk.Frame, label: str) -> tk.Entry: +def _macro_cell( + root: tk.Misc, row: tk.Frame, label: str, variable: tk.StringVar +) -> tk.Entry: """Pack one small labelled numeric entry into the macro row.""" cell = tk.Frame(row, bg=BG) - cell.pack(side="left", padx=6) - tk.Label(cell, text=label, font=("Arial", 11), bg=BG, fg=FG).pack() - entry = _numeric_entry(root, cell, width=7) - entry.pack(ipady=3) + cell.pack(side="left", padx=SM) + tk.Label(cell, text=label, font=(_FONT, CAPTION), bg=BG, fg=FG).pack() + entry = _numeric_entry(root, cell, width=7, variable=variable) + entry.pack(ipady=XS) return entry @@ -158,13 +156,13 @@ def _build_desc(parent: tk.Frame) -> tk.Text: tk.Label( parent, text="What did you eat?", - font=("Arial", 12), + font=(_FONT, LABEL), bg=BG, fg=FG, ).pack() text = tk.Text( parent, - font=("Arial", 15), + font=(_FONT, BODY), width=64, height=2, wrap="word", @@ -174,7 +172,7 @@ def _build_desc(parent: tk.Frame) -> tk.Text: highlightthickness=1, highlightbackground=_MUTED, ) - text.pack(pady=(2, 6)) + text.pack(pady=(XS, SM)) return text @@ -182,59 +180,60 @@ 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=("Arial", 12), + font=(_FONT, BODY), width=52, height=SUGGESTION_ROWS, bg=_FIELD_BG, fg=FG, selectbackground=_ACCENT, - selectforeground="#003322", + selectforeground=_COLORS.on_fill, activestyle="none", highlightthickness=0, ) - box.pack(pady=(0, 8)) + box.pack(pady=(0, SM)) return box def _build_amount_row( root: tk.Misc, parent: tk.Frame, - unit_var: tk.StringVar, + 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=("Arial", 12), + font=(_FONT, LABEL), bg=BG, fg=FG, ).pack() row = tk.Frame(parent, bg=BG) - row.pack(pady=(2, 6)) - amount_entry = _numeric_entry(root, row, width=10) - amount_entry.pack(side="left", ipady=3) + row.pack(pady=(XS, SM)) + amount_entry = _numeric_entry(root, row, width=10, variable=vars_.entries.amount) + amount_entry.pack(side="left", ipady=XS) unit_menu = tk.OptionMenu( row, - unit_var, + vars_.unit, UNIT_GRAMS, UNIT_ITEMS, command=on_unit_change, ) unit_menu.configure( - font=("Arial", 12), + font=(_FONT, LABEL), bg=_FIELD_BG, fg=FG, activebackground=_ACCENT, highlightthickness=0, ) - unit_menu.pack(side="left", padx=(8, 0)) + unit_menu.pack(side="left", padx=(SM, 0)) return amount_entry 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. @@ -246,29 +245,28 @@ def _build_macro_section( basis_prefix = tk.Label( basis, text=BASIS_PREFIX_GRAMS, - font=("Arial", 12), + font=(_FONT, LABEL), bg=BG, fg=FG, ) basis_prefix.pack(side="left") - per_entry = _numeric_entry(root, basis, width=5) - per_entry.insert(0, f"{DEFAULT_PER_GRAMS:g}") - per_entry.pack(side="left", padx=4, ipady=2) + 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=("Arial", 12), + font=(_FONT, LABEL), bg=BG, fg=FG, ).pack(side="left") row = tk.Frame(parent, bg=BG) - row.pack(pady=(2, 6)) + row.pack(pady=(XS, SM)) macros = _MacroEntries( - kcal=_macro_cell(root, row, "kcal"), - protein=_macro_cell(root, row, "P"), - carbs=_macro_cell(root, row, "C"), - fat=_macro_cell(root, row, "F"), + 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 @@ -282,20 +280,20 @@ def _build_dashboard(parent: tk.Frame, vars_: GateVars) -> None: tk.Label( parent, textvariable=vars_.cal_headline, - font=("Arial", 22, "bold"), + font=(_FONT, TITLE, "bold"), bg=BG, fg=_ACCENT, - ).pack(pady=(12, 0)) + ).pack(pady=(MD, 0)) tk.Label( parent, textvariable=vars_.dashboard, - font=("Courier", 11), + font=("Courier", CAPTION), bg=BG, fg=_MUTED, justify="left", anchor="w", wraplength=900, - ).pack(pady=(2, 0)) + ).pack(pady=(XS, 0)) def _build_meal_controls( @@ -311,42 +309,39 @@ def _build_meal_controls( form for the next one; "Log & Continue" then logs the summed meal. """ row = tk.Frame(parent, bg=BG) - row.pack(pady=(2, 2)) + row.pack(pady=(XS, XS)) tk.Label( row, text="Meal name (optional):", - font=("Arial", 11), + font=(_FONT, LABEL), bg=BG, fg=FG, ).pack(side="left") meal_name_entry = tk.Entry( row, - font=("Arial", 13), + textvariable=vars_.entries.meal_name, + font=(_FONT, BODY), width=18, bg=_FIELD_BG, fg=FG, insertbackground=FG, ) - meal_name_entry.pack(side="left", padx=(6, 8), ipady=2) - tk.Button( + meal_name_entry.pack(side="left", padx=(SM, SM), ipady=XS) + make_button( row, text="+ Add item", - font=("Arial", 12, "bold"), - bg=_FIELD_BG, - fg=_ACCENT, - activebackground="#333333", - cursor="hand2", + variant="secondary", command=on_add_item, ).pack(side="left") tk.Label( parent, textvariable=vars_.meal_summary, - font=("Arial", 11), + font=(_FONT, LABEL), bg=BG, fg=_MUTED, wraplength=900, justify="center", - ).pack(pady=(0, 2)) + ).pack(pady=(0, XS)) return meal_name_entry @@ -368,79 +363,72 @@ def build_layout( tk.Label( frame, text="🍽 Diet Gate", - font=("Arial", 30, "bold"), + font=(_FONT, DISPLAY, "bold"), bg=BG, fg=_ACCENT, - ).pack(pady=(0, 4)) + ).pack(pady=(0, XS)) tk.Label( frame, textvariable=vars_.slot_header, - font=("Arial", 16, "bold"), + font=(_FONT, SUBTITLE, "bold"), bg=BG, fg=FG, wraplength=900, justify="center", - ).pack(pady=(0, 10)) + ).pack(pady=(0, MD)) desc_text = _build_desc(frame) suggestion_box = _build_suggestion_box(frame) amount_entry = _build_amount_row( root, frame, - vars_.unit, + vars_, callbacks.on_unit_change, ) - basis_prefix, per_entry, macros = _build_macro_section(root, frame) + basis_prefix, per_entry, macros = _build_macro_section(root, frame, vars_) tk.Label( frame, textvariable=vars_.projection, - font=("Arial", 13, "bold"), + font=(_FONT, LABEL, "bold"), bg=BG, fg=FG, wraplength=900, justify="center", - ).pack(pady=(2, 2)) + ).pack(pady=(XS, XS)) tk.Label( frame, textvariable=vars_.preview, - font=("Arial", 14, "bold"), + font=(_FONT, BODY, "bold"), bg=BG, fg=_ACCENT, wraplength=900, justify="center", - ).pack(pady=(2, 6)) + ).pack(pady=(XS, SM)) meal_name_entry = _build_meal_controls(frame, vars_, callbacks.on_add_item) - tk.Button( + make_button( frame, text="Log & Continue", - font=("Arial", 15, "bold"), - bg=_ACCENT, - fg="#003322", - activebackground="#00cc66", - cursor="hand2", + variant="primary", command=callbacks.on_submit, - ).pack(pady=(4, 6)) + ).pack(pady=(XS, SM)) # 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. - tk.Button( + make_button( frame, text="⟳ Fetch from sync", - font=("Arial", 12), - bg="#334455", - fg="white", - activebackground="#445566", - cursor="hand2", + variant="secondary", command=callbacks.on_fetch_sync, - ).pack(pady=(0, 6)) + bold=False, + ).pack(pady=(0, SM)) status_label = tk.Label( frame, textvariable=vars_.status, - font=("Arial", 12), + font=(_FONT, LABEL), bg=BG, fg=FG, wraplength=900, @@ -451,14 +439,12 @@ def build_layout( _build_dashboard(frame, vars_) if demo_mode: - tk.Button( + make_button( root, text="✕ Close Demo", - font=("Arial", 12), - bg="#ff4444", - fg="white", + variant="danger", command=callbacks.on_close, - cursor="hand2", + bold=False, ).place(x=10, y=10) return GateWidgets( diff --git a/diet_guard/_gatelock_ui_types.py b/diet_guard/_gatelock_ui_types.py new file mode 100644 index 0000000..f5d0502 --- /dev/null +++ b/diet_guard/_gatelock_ui_types.py @@ -0,0 +1,90 @@ +"""The gate's data bundles: its variables, widgets, and callbacks. + +Split out of ``_gatelock_ui`` (and re-exported from it) so neither module +exceeds the repo's 500-line limit. Nothing here builds a widget; these are +the shapes the layout returns and the controller drives. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + import tkinter as tk + + +@dataclass +class _MacroEntries: + """The four macro entry widgets, in (kcal, protein, carbs, fat) order.""" + + kcal: tk.Entry + protein: tk.Entry + carbs: tk.Entry + fat: tk.Entry + + +@dataclass +class GateEntryVars: + """The variables behind the gate's typed-in fields. + + Since gatelock v0.2.0 the gate is laid out once per live output, so every + entry is bound to one of these: typing an amount on whichever screen the + user is looking at is the same value on all of them, and reading it back + needs no idea which monitor that was. + """ + + amount: tk.StringVar + per: tk.StringVar + meal_name: tk.StringVar + kcal: tk.StringVar + protein: tk.StringVar + carbs: tk.StringVar + fat: tk.StringVar + + +@dataclass +class GateVars: + """Tk string variables bound to the gate's live, auto-updating fields.""" + + status: tk.StringVar + slot_header: tk.StringVar + preview: tk.StringVar + projection: tk.StringVar + cal_headline: tk.StringVar + dashboard: tk.StringVar + meal_summary: tk.StringVar + unit: tk.StringVar + entries: GateEntryVars + + +@dataclass +class GateWidgets: + """Interactive widgets the controller reads back after the UI is built.""" + + frame: tk.Frame + desc_text: tk.Text + amount_entry: tk.Entry + per_entry: tk.Entry + basis_prefix: tk.Label + macros: _MacroEntries + suggestion_box: tk.Listbox + meal_name_entry: tk.Entry + status_label: tk.Label + + +@dataclass +class GateCallbacks: + """Construction-time commands the widgets fire (not key/event bindings). + + These are the callbacks that must be supplied when a widget is created -- + option-menu and button commands. Per-keystroke event bindings are wired by + the controller after the layout is built, so they are not carried here. + """ + + on_unit_change: Callable[[str], None] + on_submit: Callable[[], None] + on_close: Callable[[], None] + on_add_item: Callable[[], None] + on_fetch_sync: Callable[[], None] diff --git a/diet_guard/tests/conftest.py b/diet_guard/tests/conftest.py index aa0c917..6aadf42 100644 --- a/diet_guard/tests/conftest.py +++ b/diet_guard/tests/conftest.py @@ -25,11 +25,14 @@ from types import SimpleNamespace from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch +from gatelock import Output, OutputRect import pytest from diet_guard import ( _gatelock, + _gatelock_buttons, _gatelock_calendar, + _gatelock_calendar_ui, _gatelock_core, _gatelock_mealflow, _gatelock_nutrition, @@ -81,6 +84,57 @@ def _block_real_tk() -> Iterator[None]: yield +FAKE_OUTPUTS = ( + Output("DP-0", connected=True, rect=OutputRect(0, 0, 1920, 1080), primary=True), +) +"""One live output by default, so the Tk-screen fallback (which would call +int() on a MagicMock) is never reached.""" + +TWO_OUTPUTS = ( + Output("DP-0", connected=True, rect=OutputRect(0, 0, 3840, 2160), primary=True), + Output("HDMI-0", connected=True, rect=OutputRect(3840, 0, 2560, 1440)), +) +"""The real desk. Opt in with ``dual_output`` for anything asserting that the +gate is really built on every monitor.""" + + +def _make_toplevel(_parent: object = None, **_kwargs: object) -> MagicMock: + """A Toplevel stand-in for gatelock's per-output surfaces.""" + window = MagicMock() + window.winfo_children.return_value = [] + return window + + +@pytest.fixture(autouse=True) +def _hermetic_gatelock( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> Iterator[None]: + """Keep gatelock v0.2.0's per-output machinery off the real machine. + + ``_block_real_tk`` patches ``tk`` inside *diet_guard*, which is no longer + enough: ``LockWindow.setup()`` builds one real ``tk.Toplevel`` per live + output through *gatelock's* own ``tk``. Over a MagicMock root that sends + real tkinter into unbounded mock recursion, so the suite **hangs** rather + than failing. The runtime dir is redirected too, so no test can stand a + production locker down through the arbiter. + """ + monkeypatch.setenv("GATELOCK_RUNTIME_DIR", str(tmp_path / "gatelock-runtime")) + with ( + patch("gatelock._surfaces.tk.Toplevel", side_effect=_make_toplevel), + patch("gatelock._outputs.RandrBackend.create", return_value=None), + patch("gatelock._outputs.scan_xrandr", return_value=FAKE_OUTPUTS), + patch("gatelock._detect._RandrEventSource.start", return_value=False), + ): + yield + + +@pytest.fixture +def dual_output(_hermetic_gatelock: None) -> Iterator[None]: + """Re-scan as a two-monitor desk, layered over the single-output default.""" + with patch("gatelock._outputs.scan_xrandr", return_value=TWO_OUTPUTS): + yield + + @pytest.fixture(autouse=True) def _block_real_vt() -> Iterator[None]: """Make gatelock's VT-switch disable a no-op for every test. @@ -268,6 +322,23 @@ class FakeNotebook(FakeWidget): return None +class FakeStyle: + """A no-op stand-in for ``ttk.Style`` -- records calls, applies nothing.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + self.theme: str | None = None + self.configured: dict[str, dict[str, object]] = {} + + def theme_use(self, theme_name: str) -> None: + self.theme = theme_name + + def configure(self, style_name: str, **kwargs: object) -> None: + self.configured.setdefault(style_name, {}).update(kwargs) + + def map(self, style_name: str, **kwargs: object) -> None: + self.configured.setdefault(style_name, {}).update(kwargs) + + _FAKE_TK = SimpleNamespace( END="end", TclError=_FakeTclError, @@ -282,14 +353,16 @@ _FAKE_TK = SimpleNamespace( Event=object, ) -_FAKE_TTK = SimpleNamespace(Notebook=FakeNotebook) +_FAKE_TTK = SimpleNamespace(Notebook=FakeNotebook, Style=FakeStyle) # Every mixin module the gate window is built from imports ``tkinter`` # independently; all of them must see the fake so ``tk.TclError`` etc. are the # catchable ``_FakeTclError`` everywhere a test raises it. _GATE_TK_MODULES = ( _gatelock, + _gatelock_buttons, _gatelock_calendar, + _gatelock_calendar_ui, _gatelock_core, _gatelock_nutrition, _gatelock_mealflow, @@ -304,6 +377,7 @@ def gate() -> Iterator[MealGate]: for module in _GATE_TK_MODULES: stack.enter_context(patch.object(module, "tk", _FAKE_TK)) stack.enter_context(patch.object(_gatelock_calendar, "ttk", _FAKE_TTK)) + stack.enter_context(patch.object(_gatelock_calendar_ui, "ttk", _FAKE_TTK)) yield MealGate(demo_mode=True) diff --git a/diet_guard/tests/test_calendar_view.py b/diet_guard/tests/test_calendar_view.py index 6f1151b..b1ee906 100644 --- a/diet_guard/tests/test_calendar_view.py +++ b/diet_guard/tests/test_calendar_view.py @@ -12,6 +12,7 @@ from diet_guard._calendar_view import ( ytd_text, ) from diet_guard._daystatus import DayStatus +from diet_guard._gatelock_ui import BG, FG class TestBuildMonthCells: @@ -84,9 +85,13 @@ class TestCellStyle: bg, _fg, outline = cell_style(None) assert bg == outline - def test_not_logged_is_black_with_a_visible_outline(self) -> None: + def test_not_logged_is_hollow_with_a_visible_outline(self) -> None: + """A "not logged" cell blends into the background but keeps a + near-white outline, distinguishing "no data" from a black square + (safe-design-rules rule 1: no pure black).""" bg, _fg, outline = cell_style(DayStatus.NOT_LOGGED) - assert bg == "#000000" + assert bg == BG + assert outline == FG assert outline != bg def test_green_yellow_red_each_style_distinctly(self) -> None: diff --git a/diet_guard/tests/test_gatelock.py b/diet_guard/tests/test_gatelock.py index b4f1ad8..b73887d 100644 --- a/diet_guard/tests/test_gatelock.py +++ b/diet_guard/tests/test_gatelock.py @@ -235,10 +235,26 @@ class TestLockDelegation: """MealGate's gatelock wiring: hooks delegate, run()/close() delegate.""" def test_on_focus_ready_focuses_desc_text(self, gate: MealGate) -> None: - """on_focus_ready puts keyboard focus on the description box.""" - gate._widgets.desc_text.focus_force = MagicMock() - gate.on_focus_ready() - gate._widgets.desc_text.focus_force.assert_called_once() + """on_focus_ready puts keyboard focus on the description box. + + ``desc_text`` is a group built fresh on each access now, so the + assertion has to bind to the widget underneath it. + """ + widget = gate._widgets.bundles[0].desc_text + widget.focus_force = MagicMock() + + gate.on_focus_ready(MagicMock()) + + widget.focus_force.assert_called_once() + + def test_on_focus_ready_with_no_live_output(self, gate: MealGate) -> None: + """No surface at all: the gate is held with nothing to focus.""" + widget = gate._widgets.bundles[0].desc_text + widget.focus_force = MagicMock() + + gate.on_focus_ready(None) + + widget.focus_force.assert_not_called() def test_on_close_is_a_noop(self, gate: MealGate) -> None: """on_close has no hardware/state to release; must not raise.""" diff --git a/diet_guard/tests/test_gatelock_calendar.py b/diet_guard/tests/test_gatelock_calendar.py index d3b4a0e..5720a83 100644 --- a/diet_guard/tests/test_gatelock_calendar.py +++ b/diet_guard/tests/test_gatelock_calendar.py @@ -49,7 +49,7 @@ class TestNoBypassRegression: pending_before = list(gate._pending) with patch.object(gate, "close") as mock_close: gate._notebook.select(gate._cal_widgets.frame) - gate._notebook.select(gate._widgets.frame) + gate._notebook.select(gate._widgets.bundles[0].frame) assert gate._notebook.select() == 0 assert gate._pending == pending_before mock_close.assert_not_called() @@ -68,7 +68,7 @@ class TestBudgetDefaultAndDegradation: def test_no_budget_set_shows_2200_in_the_entry(self, gate: MealGate) -> None: gate._refresh_calendar() - assert gate._cal_widgets.budget_entry.get() == "2200" + assert gate._cal_vars.budget.get() == "2200" def test_entry_starts_read_only(self, gate: MealGate) -> None: assert gate._cal_widgets.budget_entry.configured.get("state") == "readonly" @@ -88,7 +88,7 @@ class TestBudgetDefaultAndDegradation: write_budget(1800) log_meal("oatmeal", _nutrition(kcal=300), None) gate._refresh_calendar() - assert gate._cal_widgets.budget_entry.get() == "1800" + assert gate._cal_vars.budget.get() == "1800" assert "Logging streak" in gate._cal_vars.streaks.get() def test_a_corrupt_budget_file_degrades_with_an_error_message( @@ -129,9 +129,13 @@ class TestBudgetEditToggle: """Edit/Save toggle: first click unlocks, second click validates+saves.""" def _type(self, gate: MealGate, value: str) -> None: - """Replace the budget entry's text (as if the user retyped it).""" - gate._cal_widgets.budget_entry.delete(0, "end") - gate._cal_widgets.budget_entry.insert(0, value) + """Replace the budget entry's text (as if the user retyped it). + + The entry is variable-backed since the per-output migration, so + typing is modelled by setting the shared variable -- which is also + what makes the field agree across monitors. + """ + gate._cal_vars.budget.set(value) def test_first_click_unlocks_editing_without_persisting( self, @@ -205,4 +209,4 @@ class TestBudgetEditToggle: gate._on_edit_or_save_budget() self._type(gate, "1234") gate._on_prev_month() - assert gate._cal_widgets.budget_entry.get() == "1234" + assert gate._cal_vars.budget.get() == "1234" diff --git a/diet_guard/tests/test_gatelock_fanout.py b/diet_guard/tests/test_gatelock_fanout.py new file mode 100644 index 0000000..381ce3e --- /dev/null +++ b/diet_guard/tests/test_gatelock_fanout.py @@ -0,0 +1,302 @@ +"""Per-output fan-out: the point of the gatelock v0.2.0 migration. + +Every other test here runs against the single-output default, where "the gate +is on every monitor" and "the gate is on the primary and nowhere else" are +indistinguishable. These run on a two-monitor desk, so a loop that only ever +touched the first surface fails here and nowhere else. +""" + +from __future__ import annotations + +from contextlib import ExitStack +import tkinter as tk +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from diet_guard import _gatelock_calendar +from diet_guard._gatelock import MealGate +from diet_guard._gatelock_groups import ( + EntryGroup, + ListboxGroup, + TextGroup, + WidgetGroup, +) +from diet_guard.tests.conftest import ( + _FAKE_TK, + _FAKE_TTK, + _GATE_TK_MODULES, + TWO_OUTPUTS, +) + +if TYPE_CHECKING: + from collections.abc import Iterator + + +@pytest.fixture +def dual_gate(dual_output: None) -> Iterator[MealGate]: + """A demo gate built across two live outputs.""" + del dual_output + with ExitStack() as stack: + for module in _GATE_TK_MODULES: + stack.enter_context(patch.object(module, "tk", _FAKE_TK)) + stack.enter_context(patch.object(_gatelock_calendar, "ttk", _FAKE_TTK)) + yield MealGate(demo_mode=True) + + +class TestGateFansOut: + """The gate as it is actually built on a two-monitor desk.""" + + def test_one_gate_per_output(self, dual_gate: MealGate) -> None: + """The premise the rest of this class rests on.""" + assert len(dual_gate._widgets.bundles) == len(TWO_OUTPUTS) + + def test_every_output_gets_its_own_widgets(self, dual_gate: MealGate) -> None: + """Two distinct forms, not two references to one.""" + boxes = [bundle.desc_text for bundle in dual_gate._widgets.bundles] + assert len({id(box) for box in boxes}) == len(TWO_OUTPUTS) + + def test_typing_an_amount_is_shared_across_monitors( + self, dual_gate: MealGate + ) -> None: + """Entries are variable-backed, so both screens agree without syncing.""" + dual_gate._widgets.amount_entry.insert(0, "250") + + assert dual_gate._vars.entries.amount.get() == "250" + assert dual_gate._widgets.amount_entry.get() == "250" + + def test_description_written_to_every_monitor(self, dual_gate: MealGate) -> None: + """The description box has no variable, so writes must reach each copy.""" + dual_gate._set_desc("shoarma") + + for bundle in dual_gate._widgets.bundles: + assert "shoarma" in bundle.desc_text.get("1.0", "end-1c") + + def test_description_read_from_the_monitor_typed_on( + self, dual_gate: MealGate + ) -> None: + """Typing on the second screen is still what gets logged.""" + first, second = dual_gate._widgets.bundles + first.desc_text.delete("1.0", tk.END) + second.desc_text.delete("1.0", tk.END) + second.desc_text.insert("1.0", "late dinner") + + assert dual_gate._get_desc() == "late dinner" + + def test_suggestions_listed_on_every_monitor(self, dual_gate: MealGate) -> None: + """The picker is filled on both screens, not just the primary.""" + dual_gate._widgets.suggestion_box.delete(0, tk.END) + dual_gate._widgets.suggestion_box.insert(tk.END, "apple") + + for bundle in dual_gate._widgets.bundles: + assert bundle.suggestion_box._items == ["apple"] + + def test_calendar_grid_repainted_on_every_monitor( + self, dual_gate: MealGate + ) -> None: + """The History tab exists per surface and every copy is redrawn.""" + assert len(dual_gate._cal_surfaces) == len(TWO_OUTPUTS) + for surface in dual_gate._cal_surfaces: + for cell in surface.day_cells: + cell.configured.clear() + + dual_gate._refresh_calendar() + + for surface in dual_gate._cal_surfaces: + assert any(cell.configured for cell in surface.day_cells) + + def test_budget_controls_toggle_on_every_monitor(self, dual_gate: MealGate) -> None: + """Unlocking the budget field unlocks it on both screens.""" + dual_gate._on_edit_or_save_budget() + + for surface in dual_gate._cal_surfaces: + assert surface.budget_entry.configured.get("state") == "normal" + assert surface.budget_edit_button.configured.get("text") == "Save" + + def test_frame_group_covers_every_monitor(self, dual_gate: MealGate) -> None: + """The outer frame is exposed per surface, for tab selection.""" + frames = list(dual_gate._widgets.frame) + + assert len(frames) == len(TWO_OUTPUTS) + assert frames[0] is dual_gate._widgets.bundles[0].frame + + def test_a_dark_output_drops_only_its_own_copy(self, dual_gate: MealGate) -> None: + """Teardown is by output name, so the surviving monitor keeps working.""" + survivor = dual_gate._widgets.bundles[1] + + dual_gate.teardown_surface(MagicMock(output_name=TWO_OUTPUTS[0].name)) + + assert dual_gate._widgets.bundles == [survivor] + + +class TestGroupsInIsolation: + """The group primitives, without a gate around them.""" + + def test_config_reaches_a_dead_copy_without_raising(self) -> None: + """A monitor that vanished mid-update must not break the others.""" + dead, alive = MagicMock(), MagicMock() + dead.configure.side_effect = tk.TclError("bad window path name") + + WidgetGroup([dead, alive]).config(fg="#ff0000") + + alive.configure.assert_called_once_with(fg="#ff0000") + + def test_focus_skips_a_dead_copy(self) -> None: + """Focus is singular: the first live copy takes it.""" + dead, alive = MagicMock(), MagicMock() + dead.focus_set.side_effect = tk.TclError("gone") + dead.focus_force.side_effect = tk.TclError("gone") + group = WidgetGroup([dead, alive]) + + group.focus_set() + group.focus_force() + + alive.focus_set.assert_called_once_with() + alive.focus_force.assert_called_once_with() + + def test_bind_reaches_every_copy_and_survives_a_dead_one(self) -> None: + """Re-wiring after an output returns must not raise on a dead copy.""" + dead, alive = MagicMock(), MagicMock() + dead.bind.side_effect = tk.TclError("gone") + handler = MagicMock() + + WidgetGroup([dead, alive]).bind("", handler) + + alive.bind.assert_called_once_with("", handler) + + def test_focus_when_every_copy_is_gone(self) -> None: + """All surfaces destroyed: nothing to focus, and no exception.""" + dead = MagicMock() + dead.focus_set.side_effect = tk.TclError("gone") + dead.focus_force.side_effect = tk.TclError("gone") + group = WidgetGroup([dead]) + + group.focus_set() # must not raise + group.focus_force() + + assert dead.focus_set.called + + def test_entry_group_reads_and_writes_through_the_variable(self) -> None: + """The widgets are never consulted, so copies cannot disagree.""" + var = MagicMock() + var.get.return_value = "42" + group = EntryGroup([MagicMock(), MagicMock()], var) + + group.insert(0, "42") + value = group.get() + group.delete(0, "end") + + assert value == "42" + var.set.assert_any_call("42") + var.set.assert_called_with("") + + def test_text_group_falls_back_to_the_primary_when_all_blank(self) -> None: + """An untouched form still yields a string for validation to reject.""" + first, second = MagicMock(), MagicMock() + first.get.return_value = "" + second.get.return_value = " " + + assert TextGroup([first, second]).get("1.0", "end") == "" + + def test_text_group_skips_a_dead_copy(self) -> None: + """A destroyed monitor's box is stepped over, not fatal.""" + dead, typed = MagicMock(), MagicMock() + dead.get.side_effect = tk.TclError("gone") + typed.get.return_value = "porridge" + + assert TextGroup([dead, typed]).get("1.0", "end") == "porridge" + + def test_text_group_writes_survive_a_dead_copy(self) -> None: + """delete/insert reach the live copy even if another has gone.""" + dead, alive = MagicMock(), MagicMock() + dead.delete.side_effect = tk.TclError("gone") + dead.insert.side_effect = tk.TclError("gone") + group = TextGroup([dead, alive]) + + group.delete("1.0", "end") + group.insert("1.0", "eggs") + + alive.delete.assert_called_once_with("1.0", "end") + alive.insert.assert_called_once_with("1.0", "eggs") + + def test_listbox_selection_comes_from_the_copy_clicked(self) -> None: + """A mouse pick lands on one monitor; that is the one that counts.""" + unclicked, clicked = MagicMock(), MagicMock() + unclicked.curselection.return_value = () + clicked.curselection.return_value = (3,) + + assert ListboxGroup([unclicked, clicked]).curselection() == (3,) + + def test_listbox_selection_empty_everywhere(self) -> None: + """Nothing picked anywhere reads back as no selection.""" + first, second = MagicMock(), MagicMock() + first.curselection.return_value = () + second.curselection.return_value = () + + assert ListboxGroup([first, second]).curselection() == () + + def test_listbox_selection_skips_a_dead_copy(self) -> None: + """A destroyed copy does not block reading the live one.""" + dead, clicked = MagicMock(), MagicMock() + dead.curselection.side_effect = tk.TclError("gone") + clicked.curselection.return_value = (1,) + + assert ListboxGroup([dead, clicked]).curselection() == (1,) + + def test_listbox_writes_and_size_fan_out(self) -> None: + """Rows are written to every copy; the count reads from the primary.""" + first, second = MagicMock(), MagicMock() + first.size.return_value = 2 + group = ListboxGroup([first, second]) + + group.delete(0, "end") + group.insert("end", "a", "b") + group.selection_set(1) + group.selection_clear(0, "end") + + assert group.size() == 2 + second.insert.assert_called_once_with("end", "a", "b") + second.selection_set.assert_called_once_with(1) + second.selection_clear.assert_called_once_with(0, "end") + + def test_listbox_selection_helpers_survive_a_dead_copy(self) -> None: + """A dead copy is stepped over by both selection helpers.""" + dead, alive = MagicMock(), MagicMock() + dead.selection_set.side_effect = tk.TclError("gone") + dead.selection_clear.side_effect = tk.TclError("gone") + dead.delete.side_effect = tk.TclError("gone") + dead.insert.side_effect = tk.TclError("gone") + group = ListboxGroup([dead, alive]) + + group.selection_set(0, 2) + group.selection_clear(0) + group.delete(0) + group.insert(0, "x") + + alive.selection_set.assert_called_once_with(0, 2) + alive.selection_clear.assert_called_once_with(0) + + def test_first_exposes_the_primary_copy(self) -> None: + """Reads that cannot fan out use the primary monitor's widget.""" + first, second = MagicMock(), MagicMock() + + assert WidgetGroup([first, second]).first is first + + def test_iterating_yields_every_copy(self) -> None: + """Callers that genuinely need each widget can have them.""" + first, second = MagicMock(), MagicMock() + + assert list(WidgetGroup([first, second])) == [first, second] + + def test_discarding_an_unknown_output_changes_nothing(self) -> None: + """Tearing down an output that was never built is a no-op.""" + from diet_guard._gatelock_groups import GateWidgetsGroup + + group = GateWidgetsGroup(MagicMock()) + bundle = MagicMock() + group.add(bundle, "DP-0") + + group.discard("HDMI-9") + + assert group.bundles == [bundle] diff --git a/pyproject.toml b/pyproject.toml index 1305bb6..7866542 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "1.0.0" description = "Tkinter/systemd log-to-unlock meal gate with calorie/macro budget tracking" requires-python = ">=3.10" dependencies = [ - "gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.1.1#subdirectory=gatelock", + "gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.2.0#subdirectory=gatelock", "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.1.0#subdirectory=crdt-sync", "requests>=2.31.0", ] diff --git a/requirements.txt b/requirements.txt index 006e2d5..e28a043 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ bandit>=1.7.0 codespell>=2.2.0 coverage>=7.4.0 crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.1.0#subdirectory=crdt-sync -gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.1.1#subdirectory=gatelock +gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.2.0#subdirectory=gatelock mcp>=1.9 mypy>=1.8.0 pre-commit>=3.6.0