mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 22:07:12 +01:00
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.
This commit is contained in:
parent
a106970b4f
commit
84bd550331
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
79
diet_guard/_gatelock_buttons.py
Normal file
79
diet_guard/_gatelock_buttons.py
Normal 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,
|
||||
)
|
||||
@ -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:
|
||||
|
||||
285
diet_guard/_gatelock_calendar_ui.py
Normal file
285
diet_guard/_gatelock_calendar_ui.py
Normal 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)],
|
||||
)
|
||||
314
diet_guard/_gatelock_groups.py
Normal file
314
diet_guard/_gatelock_groups.py
Normal 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),
|
||||
)
|
||||
16
diet_guard/_gatelock_spacing.py
Normal file
16
diet_guard/_gatelock_spacing.py
Normal 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
|
||||
18
diet_guard/_gatelock_typography.py
Normal file
18
diet_guard/_gatelock_typography.py
Normal 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
|
||||
@ -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(
|
||||
|
||||
90
diet_guard/_gatelock_ui_types.py
Normal file
90
diet_guard/_gatelock_ui_types.py
Normal 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]
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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"
|
||||
|
||||
302
diet_guard/tests/test_gatelock_fanout.py
Normal file
302
diet_guard/tests/test_gatelock_fanout.py
Normal 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]
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user