Migrate diet_guard to gatelock v0.2.0 per-output locking
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run

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.
This commit is contained in:
Krzysztof Rudnicki 2026-07-25 16:51:31 +02:00
parent a106970b4f
commit 84bd550331
18 changed files with 1503 additions and 454 deletions

View File

@ -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

View File

@ -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)

View File

@ -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:

View File

@ -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,
)

View File

@ -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:

View File

@ -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)],
)

View File

@ -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),
)

View File

@ -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

View File

@ -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

View File

@ -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(

View File

@ -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]

View File

@ -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)

View File

@ -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:

View File

@ -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."""

View File

@ -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"

View File

@ -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("<Return>", handler)
alive.bind.assert_called_once_with("<Return>", 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]

View File

@ -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",
]

View File

@ -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