"""Fullscreen "log your meals to unlock" gate window for diet_guard. This reuses the proven screen-locker *mechanism* -- an ``overrideredirect`` fullscreen window with a global input grab and disabled VT switching -- but hardens two latent gaps in that original so a grabbed window can never become a trap: * **VT switching is restored on every exit path**, not just the clean one: ``atexit`` covers a crash/uncaught exception, signal handlers cover SIGTERM/SIGINT, and a ``try/finally`` covers normal return. * **Every callback error is swallowed and surfaced**, via a ``report_callback_exception`` override on the Tk root, so no exception can propagate out of the grabbed event loop and leave a dead window. The window walks the user through each *missing* meal slot in turn (coming home at 17:00 backfills 08:00, then 12:00, then 16:00) and dismisses only once every elapsed slot carries a logged meal. Resolution is built around one idea: the macro fields plus the "per" field hold the food's nutrition *as a reference for some amount*, and how much you ate scales that reference into the total that is logged. Measure by **grams** and the reference is "per 100 g" off a label; measure by **items** and it is "per 1 item" (with the piece's approximate weight, which you can correct). Either way the total shown in the preview is exactly what gets recorded, and changing how much you ate never rewrites the reference fields, so the two cannot desync. As you type, the picker offers your banked foods and built-in staples, so a common food fills in one click. Leaving the calorie field blank looks the food up (food bank, then staples, then Open Food Facts), fills the fields, names the source, and offers alternatives. A running dashboard makes the day's calories prominent, with macros and the protein target beneath. The unlock condition is *logging*, never *estimating correctly*: a manual calorie value always works offline, so a dead OFF endpoint can never trap you behind the lock. """ from __future__ import annotations import atexit import contextlib import fcntl import logging import shutil import signal import subprocess import sys import time import tkinter as tk from typing import TYPE_CHECKING from python_pkg.diet_guard._budget import ( BudgetError, daily_budget, protein_target_g, ) from python_pkg.diet_guard._constants import GATE_LOCK_FILE from python_pkg.diet_guard._estimator import Nutrition, scale_nutrition from python_pkg.diet_guard._foodbank import remember_food, remember_meal from python_pkg.diet_guard._gate import due_slots from python_pkg.diet_guard._meal import MealItem, meal_total from python_pkg.diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams from python_pkg.diet_guard._resolve import lookup_candidates, suggest_foods from python_pkg.diet_guard._slots import current_slot, day_slots, slot_label from python_pkg.diet_guard._state import ( entry_kcal, log_meal, now_local, today_entries, today_total_kcal, today_total_macros, ) if TYPE_CHECKING: from collections.abc import Callable from types import FrameType, TracebackType from typing import TextIO _logger = logging.getLogger(__name__) # Palette (mirrors the screen locker's dark, high-contrast lock aesthetic). _BG = "#1a1a1a" _FG = "#e0e0e0" _ACCENT = "#00ff88" _ERR = "#ff6666" _FIELD_BG = "#2a2a2a" _MUTED = "#9a9a9a" # How long the "unlocking..." confirmation lingers before the window tears down. _UNLOCK_DELAY_MS = 1200 # Periodic no-op so the grabbed, event-starved loop keeps handing control back # to Python, letting SIGTERM/SIGINT be serviced promptly. _KEEPALIVE_MS = 250 # A global input grab fails while another X client already holds one -- most # often a FULLSCREEN GAME, which takes an exclusive keyboard/pointer grab. A # single attempt then falls back to a *local* grab, which on an override-redirect # window the WM refuses to focus means no keystroke ever reaches the field -- the # "can't type anything" lock-trap. So the grab is retried for the window's whole # life: the gate waits out the game and captures input the instant it is freed. _GRAB_RETRY_MS = 200 # How often (in attempts) to log that the grab is still blocked, so the journal # shows the gate is alive and waiting rather than hung. ~every 5 s at 200 ms. _GRAB_LOG_EVERY = 25 # 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. _DEFAULT_PER_GRAMS = 100.0 # Unit-selector choices for how a portion is measured. _UNIT_GRAMS = "grams" _UNIT_ITEMS = "items" # Per-basis label prefixes for the two measuring modes. _BASIS_PREFIX_GRAMS = "Nutrition as on the label — per" _BASIS_PREFIX_ITEMS = "Nutrition per 1 item ≈" # How many recent meals the dashboard lists. _DASHBOARD_ROWS = 5 # ISO timestamp "YYYY-MM-DDTHH:MM:SS": HH:MM is characters 11..16. _TIME_SLICE = slice(11, 16) # Width a meal description is truncated to in the dashboard. _DASH_DESC_WIDTH = 22 # Fallback name for a multi-item meal when the user leaves the name field blank. _DEFAULT_MEAL_NAME = "meal" # -- display readiness (session-start race) --------------------------------- # The gate's systemd timer fires the instant the user systemd instance starts # (Persistent=true catch-up of the slot missed while the PC was off), which on a # fresh login can BEAT the display manager writing ~/.Xauthority and the X server # becoming reachable. That race -- not the slot logic -- is what silently # dropped the session-start launch: _GateRoot() raised TclError ("couldn't # connect to display") and the oneshot service died. So before building the # window we poll the display until it is connectable; on timeout the gate exits # cleanly and the next timer tick retries, instead of crashing. _DISPLAY_WAIT_TIMEOUT_S = 60.0 _DISPLAY_POLL_INTERVAL_S = 1.0 def _display_is_ready() -> bool: """Return True if a Tk root can connect to the X display right now. Builds and immediately destroys a throwaway, unmapped root -- the cheapest way to ask "is DISPLAY reachable and authorized?" without opening a visible window. A missing display or a not-yet-written X auth cookie raises ``tk.TclError``, which is reported here as not-ready. """ try: probe = tk.Tk() except tk.TclError: return False probe.destroy() return True def wait_for_display( *, timeout_s: float = _DISPLAY_WAIT_TIMEOUT_S, interval_s: float = _DISPLAY_POLL_INTERVAL_S, sleep: Callable[[float], None] = time.sleep, monotonic: Callable[[], float] = time.monotonic, ) -> bool: """Block until the X display is connectable, or ``timeout_s`` elapses. Absorbs the session-start race in which the gate's timer fires before the display manager has finished writing the X auth cookie (see the module note). ``sleep`` and ``monotonic`` are injectable so the wait is tested without real time passing. Args: timeout_s: Total seconds to keep retrying before giving up. interval_s: Seconds to wait between connection probes. sleep: Sleep function (injected in tests). monotonic: Monotonic clock (injected in tests). Returns: True as soon as a probe connects; False if the deadline passes with the display still unreachable (the caller should defer to the next tick). """ deadline = monotonic() + timeout_s while True: if _display_is_ready(): return True if monotonic() >= deadline: _logger.warning( "X display unreachable after %.0fs (session still settling?); " "deferring the gate to the next timer tick", timeout_s, ) return False sleep(interval_s) def _assert_not_under_pytest() -> None: """Raise if a real Tk gate is being built inside a pytest run. Defence-in-depth: prevents a real fullscreen window from locking the screen when a test forgets to mock ``tk.Tk``. When ``tk`` is mocked the module name is no longer ``tkinter``, so genuine mocked tests pass straight through. """ if "pytest" in sys.modules and getattr(tk, "__name__", "") == "tkinter": msg = "SAFETY: MealGate built under pytest with real tkinter (tk.Tk unmocked)" raise RuntimeError(msg) def _safe_float(raw: str) -> float | None: """Return ``raw`` parsed as a float, or None if it is blank/non-numeric.""" if not raw: return None try: return float(raw) except ValueError: return None def _format_preview(nutrition: Nutrition) -> str: """Render the one-line "this is what will be logged" preview.""" portion = f" · {nutrition.grams:g}g" if nutrition.grams else "" return ( f"→ {nutrition.kcal:g} kcal · " f"P{nutrition.protein_g:g} C{nutrition.carbs_g:g} F{nutrition.fat_g:g}" f"{portion} · {nutrition.source}" ) def acquire_gate_lock() -> TextIO | None: """Acquire the gate's single-instance ``flock``. Returns: An open file handle that must be kept alive for the gate's lifetime (closing it releases the lock), or None if another gate already holds it -- in which case the caller must not open a second window. """ GATE_LOCK_FILE.parent.mkdir(parents=True, exist_ok=True) handle = GATE_LOCK_FILE.open("w", encoding="utf-8") try: fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: handle.close() return None return handle def release_gate_lock(handle: TextIO) -> None: """Release the single-instance lock and close its handle.""" with contextlib.suppress(OSError): fcntl.flock(handle.fileno(), fcntl.LOCK_UN) handle.close() def _pending_slots(*, demo_mode: bool) -> list[int]: """Return the slots the window must collect before it can unlock. In production this is exactly the elapsed-but-unlogged slots. In demo mode -- where there may be nothing genuinely due -- fall back to a representative slot so the UI is always demonstrable. Args: demo_mode: Whether the window is a safe sandbox. Returns: The slot hours to collect, ascending. """ pending = list(due_slots()) if pending: return pending if demo_mode: return [current_slot(now_local()) or day_slots()[0]] return [] class _GateRoot(tk.Tk): """Tk root that routes callback errors to a handler instead of crashing. Overriding ``report_callback_exception`` is the idiomatic, blind-except-free way to guarantee that no exception raised inside a Tk callback escapes the event loop -- essential while a global input grab is held. """ on_callback_error: Callable[[], None] | None = None def report_callback_exception( self, exc: type[BaseException], val: BaseException, tb: TracebackType | None, ) -> None: """Log a callback error and notify the handler; never re-raise.""" _logger.error("gate callback error", exc_info=(exc, val, tb)) if self.on_callback_error is not None: self.on_callback_error() class MealGate: """A fullscreen lock that dismisses only once every missing slot is logged.""" def __init__(self, *, demo_mode: bool = True) -> None: """Build the lock window. Args: demo_mode: When True, use a local (not global) input grab and add a close button, so the gate can be exercised without locking the real session. Production passes False. """ _assert_not_under_pytest() self.demo_mode = demo_mode self._vt_disabled = False self._pending = _pending_slots(demo_mode=demo_mode) # Provenance of the values currently in the reference fields ("manual", # "food bank", "staple: apple", ...). Label only -- it never affects the # maths, which read the fields directly -- so there is no second copy of # the numbers to desync. Set when a food is picked/looked up; reset to # "manual" the moment the user hand-edits a macro. self._source = "manual" # Suggestions currently listed, paired with their nutrition; the mode # says whether picking one should also overwrite the description (bank # entries are the user's own names) or only fill macros (OFF products). self._suggestions: list[tuple[str, Nutrition]] = [] self._suggestion_mode = "bank" # The natural-basis nutrition of the food last picked or looked up (per # 100 g for staples, per logged portion for banked foods). Kept so a # grams<->items toggle can re-express it losslessly in the new basis; # set to None the moment the user hand-edits a macro (then there is no # clean reference to convert and the fields are cleared instead). self._last_reference: Nutrition | None = None # Components accumulated for a multi-item meal (salad + chicken + rice) # before it is logged as one summed entry; empty for a single food. self._meal_items: list[MealItem] = [] self.root = _GateRoot() self.root.on_callback_error = self._handle_callback_error self.root.title("Diet Gate" + (" [DEMO]" if demo_mode else "")) self._status = tk.StringVar(master=self.root, value="") self._slot_header = tk.StringVar(master=self.root, value="") self._preview = tk.StringVar(master=self.root, value="") self._projection = tk.StringVar(master=self.root, value="") self._cal_headline = tk.StringVar(master=self.root, value="") self._dashboard = tk.StringVar(master=self.root, value="") self._meal_summary = tk.StringVar(master=self.root, value="") self._unit = tk.StringVar(master=self.root, value=_UNIT_GRAMS) self._desc_text: tk.Text self._amount_entry: tk.Entry self._per_entry: tk.Entry self._basis_prefix: tk.Label self._kcal_entry: tk.Entry self._protein_entry: tk.Entry self._carbs_entry: tk.Entry self._fat_entry: tk.Entry self._suggestion_box: tk.Listbox self._meal_name_entry: tk.Entry self._status_label: tk.Label self._build() # -- window mechanics (reused screen-locker pattern) -------------------- def _setup_window(self) -> None: """Configure the lock window. Demo mode stays WM-managed so the window manager still grants it keyboard focus -- and you can always close it -- making a usable, safe sandbox. Only the real lock uses ``overrideredirect``, where the tiling WM refuses focus and input is instead forced in by a global grab. """ screen_w = self.root.winfo_screenwidth() screen_h = self.root.winfo_screenheight() self.root.geometry(f"{screen_w}x{screen_h}+0+0") self.root.attributes(topmost=True) self.root.configure(bg=_BG, cursor="arrow") if self.demo_mode: self.root.attributes(fullscreen=True) else: self.root.overrideredirect(boolean=True) self.root.attributes(fullscreen=True) self._disable_vt_switching() def _disable_vt_switching(self) -> None: """Block Ctrl+Alt+Fn TTY switching while the lock is up (best-effort).""" setxkbmap = shutil.which("setxkbmap") if setxkbmap is None: _logger.warning("setxkbmap not found; VT switching stays enabled") return subprocess.run([setxkbmap, "-option", "srvrkeys:none"], check=False) self._vt_disabled = True def _restore_vt_switching(self) -> None: """Re-enable VT switching; idempotent and safe to call on any exit.""" if not self._vt_disabled: return setxkbmap = shutil.which("setxkbmap") if setxkbmap is not None: subprocess.run([setxkbmap, "-option", ""], check=False) self._vt_disabled = False def _grab_input(self) -> None: """Force input to the window, then focus the first field. Demo mode relies on normal WM focus (no grab), keeping the window an escapable sandbox. The real lock forces *all* input here with a global grab -- the only mechanism that reaches an overrideredirect window the tiling WM will not focus. The grab is acquired with retries because it commonly fails on the first attempt while the window is still mapping. """ self.root.update_idletasks() self.root.focus_force() if not self.demo_mode: self._acquire_global_grab(attempt=1) self.root.after(100, self._focus_first_field) def _acquire_global_grab(self, *, attempt: int) -> None: """Acquire the global input grab, retrying until it succeeds. A successful global grab is the only way keystrokes reach the override-redirect window the WM will not focus. When another client (typically a fullscreen game) holds the grab, the attempt is rescheduled indefinitely rather than conceding to an unusable local grab, so the gate waits the other application out and captures input the moment it frees the grab. On success, focus is forced onto the description field so the first keystroke lands there. Args: attempt: 1-based attempt counter, used only to throttle the log. """ try: self.root.grab_set_global() except tk.TclError: if attempt % _GRAB_LOG_EVERY == 0: _logger.warning( "global grab still blocked after %d attempts (another app -- " "e.g. a fullscreen game -- holds it); waiting for it to free", attempt, ) self.root.after( _GRAB_RETRY_MS, lambda: self._acquire_global_grab(attempt=attempt + 1), ) return with contextlib.suppress(tk.TclError): self.root.focus_force() self._focus_first_field() def _focus_first_field(self) -> None: """Put keyboard focus on the description entry once it is mapped.""" with contextlib.suppress(tk.TclError): self._desc_text.focus_force() # -- UI construction ---------------------------------------------------- def _build(self) -> None: """Lay out the lock UI, seed the first slot prompt, and grab input.""" self._setup_window() frame = tk.Frame(self.root, bg=_BG) frame.place(relx=0.5, rely=0.5, anchor="center") tk.Label( frame, text="🍽 Diet Gate", font=("Arial", 30, "bold"), bg=_BG, fg=_ACCENT, ).pack(pady=(0, 4)) tk.Label( frame, textvariable=self._slot_header, font=("Arial", 16, "bold"), bg=_BG, fg=_FG, wraplength=900, justify="center", ).pack(pady=(0, 10)) self._build_desc(frame) self._suggestion_box = self._build_suggestion_box(frame) self._build_amount_row(frame) self._build_macro_section(frame) for entry in (self._amount_entry, self._per_entry): entry.bind("", self._on_return) for entry in self._macro_entries(): entry.bind("", self._on_return) entry.bind("", self._on_macro_edit) tk.Label( frame, textvariable=self._projection, font=("Arial", 13, "bold"), bg=_BG, fg=_FG, wraplength=900, justify="center", ).pack(pady=(2, 2)) tk.Label( frame, textvariable=self._preview, font=("Arial", 14, "bold"), bg=_BG, fg=_ACCENT, wraplength=900, justify="center", ).pack(pady=(2, 6)) self._build_meal_controls(frame) tk.Button( frame, text="Log & Continue", font=("Arial", 15, "bold"), bg=_ACCENT, fg="#003322", activebackground="#00cc66", cursor="hand2", command=self._on_submit, ).pack(pady=(4, 6)) self._status_label = tk.Label( frame, textvariable=self._status, font=("Arial", 12), bg=_BG, fg=_FG, wraplength=900, justify="center", ) self._status_label.pack() self._build_dashboard(frame) if self.demo_mode: tk.Button( self.root, text="✕ Close Demo", font=("Arial", 12), bg="#ff4444", fg="white", command=self.close, cursor="hand2", ).place(x=10, y=10) self._relabel_basis() self._refresh_slot_header() self._refresh_dashboard() self._refresh_projection() self._grab_input() self._desc_text.focus_set() def _build_desc(self, parent: tk.Frame) -> None: """Build the wrapping, multi-line "what did you eat?" description box. A multi-line ``Text`` (not an ``Entry``) so a long restaurant description wraps onto a second line and stays fully visible, instead of scrolling off the right edge where the end can no longer be read. """ tk.Label( parent, text="What did you eat?", font=("Arial", 12), bg=_BG, fg=_FG, ).pack() text = tk.Text( parent, font=("Arial", 15), width=64, height=2, wrap="word", bg=_FIELD_BG, fg=_FG, insertbackground=_FG, highlightthickness=1, highlightbackground=_MUTED, ) text.pack(pady=(2, 6)) text.bind("", self._on_desc_keyrelease) text.bind("", self._on_desc_return) self._desc_text = text def _get_desc(self) -> str: """Return the description text, trimmed (a Text always trails a newline).""" return self._desc_text.get("1.0", "end-1c").strip() def _set_desc(self, value: str) -> None: """Replace the description box's contents with ``value``.""" self._desc_text.delete("1.0", tk.END) if value: self._desc_text.insert("1.0", value) def _on_desc_return(self, _event: tk.Event[tk.Misc]) -> str: """Submit on Enter in the description box, suppressing the newline.""" self._on_submit() return "break" def _numeric_entry(self, parent: tk.Frame, *, width: int) -> tk.Entry: """Return an entry that only accepts a number or a blank string.""" vcmd = (self.root.register(self._is_numeric_or_blank), "%P") return tk.Entry( parent, font=("Arial", 15), width=width, bg=_FIELD_BG, fg=_FG, insertbackground=_FG, justify="center", validate="key", validatecommand=vcmd, ) @staticmethod def _is_numeric_or_blank(proposed: str) -> bool: """Validate-on-key predicate: allow only a blank field or a number.""" if proposed == "": return True try: float(proposed) except ValueError: return False return True def _build_suggestion_box(self, parent: tk.Frame) -> tk.Listbox: """Build the food-bank / staple / OFF picker list and return it.""" box = tk.Listbox( parent, font=("Arial", 12), width=52, height=_SUGGESTION_ROWS, bg=_FIELD_BG, fg=_FG, selectbackground=_ACCENT, selectforeground="#003322", activestyle="none", highlightthickness=0, ) box.bind("<>", self._on_suggestion_select) box.pack(pady=(0, 8)) return box def _build_amount_row(self, parent: tk.Frame) -> None: """Build the centered "how much did you eat?" amount + unit row.""" tk.Label( parent, text="How much did you eat?", font=("Arial", 12), bg=_BG, fg=_FG, ).pack() row = tk.Frame(parent, bg=_BG) row.pack(pady=(2, 6)) self._amount_entry = self._numeric_entry(row, width=10) self._amount_entry.pack(side="left", ipady=3) self._amount_entry.bind("", self._on_amount_change) unit_menu = tk.OptionMenu( row, self._unit, _UNIT_GRAMS, _UNIT_ITEMS, command=self._on_unit_change, ) unit_menu.configure( font=("Arial", 12), bg=_FIELD_BG, fg=_FG, activebackground=_ACCENT, highlightthickness=0, ) unit_menu.pack(side="left", padx=(8, 0)) def _build_macro_section(self, parent: tk.Frame) -> None: """Build the per-basis field (grams or item weight) and macro row.""" basis = tk.Frame(parent, bg=_BG) basis.pack() self._basis_prefix = tk.Label( basis, text=_BASIS_PREFIX_GRAMS, font=("Arial", 12), bg=_BG, fg=_FG, ) self._basis_prefix.pack(side="left") self._per_entry = self._numeric_entry(basis, width=5) self._per_entry.insert(0, f"{_DEFAULT_PER_GRAMS:g}") self._per_entry.pack(side="left", padx=4, ipady=2) self._per_entry.bind("", self._on_amount_change) tk.Label( basis, text="g (leave calories blank to look it up):", font=("Arial", 12), bg=_BG, fg=_FG, ).pack(side="left") row = tk.Frame(parent, bg=_BG) row.pack(pady=(2, 6)) self._kcal_entry = self._macro_cell(row, "kcal") self._protein_entry = self._macro_cell(row, "P") self._carbs_entry = self._macro_cell(row, "C") self._fat_entry = self._macro_cell(row, "F") def _macro_cell(self, row: tk.Frame, label: str) -> 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 = self._numeric_entry(cell, width=7) entry.pack(ipady=3) return entry def _macro_entries(self) -> tuple[tk.Entry, ...]: """Return the four numeric entry widgets in (kcal, P, C, F) order.""" return ( self._kcal_entry, self._protein_entry, self._carbs_entry, self._fat_entry, ) def _build_dashboard(self, parent: tk.Frame) -> None: """Build the running "how am I doing today" panel. The calorie line is large and prominent (the number the user steers by); the meal list and macros sit beneath it in a smaller monospace block. """ tk.Label( parent, textvariable=self._cal_headline, font=("Arial", 22, "bold"), bg=_BG, fg=_ACCENT, ).pack(pady=(12, 0)) tk.Label( parent, textvariable=self._dashboard, font=("Courier", 11), bg=_BG, fg=_MUTED, justify="left", anchor="w", wraplength=900, ).pack(pady=(2, 0)) def _build_meal_controls(self, parent: tk.Frame) -> None: """Build the optional multi-item meal row: name, add button, running sum. Logging stays one-tap for a single food; these controls only matter when a meal has several separately-macroed parts (a dinner of salad + chicken + rice). "Add item" banks the part onto the meal-in-progress and clears the form for the next one; "Log & Continue" then logs the summed meal. """ row = tk.Frame(parent, bg=_BG) row.pack(pady=(2, 2)) tk.Label( row, text="Meal name (optional):", font=("Arial", 11), bg=_BG, fg=_FG, ).pack(side="left") self._meal_name_entry = tk.Entry( row, font=("Arial", 13), width=18, bg=_FIELD_BG, fg=_FG, insertbackground=_FG, ) self._meal_name_entry.pack(side="left", padx=(6, 8), ipady=2) tk.Button( row, text="+ Add item", font=("Arial", 12, "bold"), bg=_FIELD_BG, fg=_ACCENT, activebackground="#333333", cursor="hand2", command=self._on_add_item, ).pack(side="left") tk.Label( parent, textvariable=self._meal_summary, font=("Arial", 11), bg=_BG, fg=_MUTED, wraplength=900, justify="center", ).pack(pady=(0, 2)) # -- slot walk ---------------------------------------------------------- def _refresh_slot_header(self) -> None: """Update the header to prompt for the slot now being collected.""" total = len(self._pending) if total == 0: self._slot_header.set("All meals logged.") return slot = self._pending[0] position = "" if total == 1 else f" (1 of {total} remaining)" self._slot_header.set(f"Log your {slot_label(slot)} meal{position}") def _clear_food_inputs(self) -> None: """Empty the food fields, picker, preview, and basis (keeps any meal).""" self._set_desc("") self._amount_entry.delete(0, tk.END) self._unit.set(_UNIT_GRAMS) self._relabel_basis() self._reset_per_default() for entry in self._macro_entries(): entry.delete(0, tk.END) self._suggestion_box.delete(0, tk.END) self._suggestions = [] self._source = "manual" self._last_reference = None self._preview.set("") self._refresh_projection() def _clear_inputs(self) -> None: """Empty the food fields and discard any in-progress meal (new slot).""" self._clear_food_inputs() self._meal_items = [] self._meal_name_entry.delete(0, tk.END) self._meal_summary.set("") def _reset_per_default(self) -> None: """Set the "per" field to the basis default for the current unit.""" self._per_entry.delete(0, tk.END) if self._unit.get() == _UNIT_ITEMS: grams = estimate_unit_grams(self._get_desc()) self._per_entry.insert( 0, f"{grams if grams is not None else DEFAULT_ITEM_GRAMS:g}" ) else: self._per_entry.insert(0, f"{_DEFAULT_PER_GRAMS:g}") def _relabel_basis(self) -> None: """Point the per-basis label at grams or per-item for the current unit.""" items = self._unit.get() == _UNIT_ITEMS self._basis_prefix.config( text=_BASIS_PREFIX_ITEMS if items else _BASIS_PREFIX_GRAMS, ) # -- field helpers ------------------------------------------------------ def _basis_grams(self) -> float: """Return the grams the label macros describe (per 100 g or per item). Honours an explicit "per" value when the user has typed one; otherwise falls back to one piece's weight in items mode, or 100 g in grams mode. """ typed = _safe_float(self._per_entry.get().strip()) if typed is not None and typed > 0: return typed if self._unit.get() == _UNIT_ITEMS: grams = estimate_unit_grams(self._get_desc()) return grams if grams is not None else DEFAULT_ITEM_GRAMS return _DEFAULT_PER_GRAMS def _eaten_grams(self) -> float | None: """Return how many grams were eaten, or None if no amount is entered. In grams mode the amount *is* the grams; in items mode it is multiplied by one piece's weight (the "per" field), so "5 apples" becomes a weight. """ amount = _safe_float(self._amount_entry.get().strip()) if amount is None: return None if self._unit.get() == _UNIT_ITEMS: return amount * self._basis_grams() return amount def _macro_values(self) -> tuple[float | None, ...] | None: """Return ``(kcal, P, C, F)`` floats/None, or None if any is non-numeric.""" values: list[float | None] = [] for entry in self._macro_entries(): raw = entry.get().strip() parsed = _safe_float(raw) if raw and parsed is None: return None values.append(parsed) return tuple(values) def _set_entry(self, entry: tk.Entry, value: str) -> None: """Replace an entry's contents with ``value``.""" entry.delete(0, tk.END) entry.insert(0, value) def _fill_macro_fields(self, nutrition: Nutrition) -> None: """Write a nutrition's macros into the kcal/P/C/F fields.""" pairs = zip( self._macro_entries(), ( nutrition.kcal, nutrition.protein_g, nutrition.carbs_g, nutrition.fat_g, ), strict=True, ) for entry, value in pairs: self._set_entry(entry, f"{value:g}") # -- the reference -> total model -------------------------------------- def _reference_nutrition(self) -> Nutrition | None: """Return the label values as a Nutrition, or None if calories are blank. This is the *reference* (macros for one basis -- per 100 g, or per item), not the total: how much was eaten scales it in :meth:`_current_nutrition`. """ values = self._macro_values() if values is None or values[0] is None: return None return Nutrition( kcal=values[0], protein_g=values[1] or 0.0, carbs_g=values[2] or 0.0, fat_g=values[3] or 0.0, grams=self._basis_grams(), source=self._source, ) def _current_nutrition(self) -> Nutrition | None: """Return exactly what would be logged now, or None if not yet resolvable. The label reference scaled to the amount eaten. With no amount yet, the reference itself stands in (one basis portion), so the preview is never empty just because an amount has not been typed. """ reference = self._reference_nutrition() if reference is None: return None eaten = self._eaten_grams() return scale_nutrition(reference, eaten) if eaten is not None else reference def _refresh_preview(self) -> None: """Recompute the preview line and the live calorie projection.""" nutrition = self._current_nutrition() self._preview.set(_format_preview(nutrition) if nutrition is not None else "") self._refresh_projection() def _refresh_projection(self) -> None: """Show consumed / budget / remaining, and what is left after this item. This answers, as the calories are typed, the four numbers the user asked to see together: how much is already eaten today, the day's goal, how much is left now, and how much would be left *after* logging the food currently in the form. With no budget sealed it degrades to the running total plus this item's calories, so it is always informative. """ consumed = today_total_kcal() nutrition = self._current_nutrition() this_kcal = nutrition.kcal if nutrition is not None else 0.0 try: budget = daily_budget() except (BudgetError, OSError): tail = f" · this item {this_kcal:g} kcal" if this_kcal else "" self._projection.set(f"Consumed {consumed:g} kcal today{tail}") return left = round(budget - consumed, 1) base = f"Consumed {consumed:g} / {budget:g} kcal · {left:g} left" if this_kcal: after = round(budget - consumed - this_kcal, 1) self._projection.set(f"{base} → after this item: {after:g} left") else: self._projection.set(base) # -- autocomplete / lookup --------------------------------------------- def _on_desc_keyrelease(self, _event: tk.Event[tk.Misc]) -> None: """Refresh suggestions; in items mode, show the piece's weight.""" query = self._get_desc() self._populate_suggestions(query) # In items mode, surface a recognised piece's weight as it is typed, so # "apple" visibly becomes "≈ 182 g" rather than a hidden assumption. if self._unit.get() == _UNIT_ITEMS: grams = estimate_unit_grams(query) if grams is not None: self._set_entry(self._per_entry, f"{grams:g}") self._refresh_preview() def _populate_suggestions(self, query: str) -> None: """Fill the picker with banked foods and matching staples for ``query``.""" self._suggestion_mode = "bank" self._suggestions = suggest_foods(query, limit=_SUGGESTION_ROWS) self._suggestion_box.delete(0, tk.END) for name, nutrition in self._suggestions: self._suggestion_box.insert(tk.END, f"{name} ({nutrition.kcal:g} kcal)") def _show_candidates(self, candidates: list[tuple[str, Nutrition]]) -> None: """Fill the picker with looked-up alternatives to choose from.""" self._suggestion_mode = "candidates" self._suggestions = candidates self._suggestion_box.delete(0, tk.END) for label, nutrition in candidates: self._suggestion_box.insert( tk.END, f"{label} ({nutrition.kcal:g} kcal · {nutrition.grams:g}g)", ) def _on_suggestion_select(self, _event: tk.Event[tk.Misc]) -> None: """Fill the form from the picked suggestion.""" selection = self._suggestion_box.curselection() if not selection: return index = selection[0] if index >= len(self._suggestions): return name, nutrition = self._suggestions[index] # Banked/staple entries carry a name, so adopt it; OFF products only # supply macros and must not overwrite what the user typed. if self._suggestion_mode == "bank": self._apply_reference(nutrition, name=name) else: self._apply_reference(nutrition) def _apply_reference( self, nutrition: Nutrition, *, name: str | None = None ) -> None: """Adopt ``nutrition`` as the reference and mirror it into the fields. In grams mode the food's own weight is the "per" basis and its macros fill the fields directly. In items mode the per-100 g reference is converted to a single piece (its weight shown in "per"), so the macro fields read *per item*. The amount eaten does the scaling either way. """ self._source = nutrition.source self._last_reference = nutrition if name is not None: self._set_desc(name) if self._unit.get() == _UNIT_ITEMS: grams = estimate_unit_grams(self._get_desc()) unit = grams if grams is not None else DEFAULT_ITEM_GRAMS self._set_entry(self._per_entry, f"{unit:g}") self._fill_macro_fields(scale_nutrition(nutrition, unit)) else: basis = nutrition.grams or _DEFAULT_PER_GRAMS self._set_entry(self._per_entry, f"{basis:g}") self._fill_macro_fields(nutrition) # Default the eaten amount to one reference portion so a pick is # immediately loggable (grams mode only -- items need a count). if not self._amount_entry.get().strip() and nutrition.grams: self._set_entry(self._amount_entry, f"{nutrition.grams:g}") self._refresh_preview() # -- live recompute ----------------------------------------------------- def _on_amount_change(self, _event: tk.Event[tk.Misc]) -> None: """Recompute the preview when the amount or basis changes. Crucially this does *not* rewrite the macro fields: those hold the label reference, and only the previewed/logged total reflects the new amount. """ self._refresh_preview() def _on_unit_change(self, _value: str) -> None: """Switch grams<->items, re-expressing the picked food in the new basis. The macro fields mean different things in each mode (per 100 g / per portion vs per item). When a food was picked or looked up, its stored reference is re-applied so toggling converts the values back and forth losslessly. A hand-typed (manual) entry has no clean reference to convert, so its fields are cleared to be re-entered in the new basis rather than silently reinterpreted. """ self._relabel_basis() self._amount_entry.delete(0, tk.END) if self._last_reference is not None: self._apply_reference(self._last_reference) return for entry in self._macro_entries(): entry.delete(0, tk.END) self._reset_per_default() self._source = "manual" self._refresh_preview() def _on_macro_edit(self, _event: tk.Event[tk.Misc]) -> None: """A hand-edited macro becomes the manual reference from here on. Editing a macro by hand invalidates the picked food's stored reference: the fields no longer match it, so a later unit toggle must not snap them back to it. """ self._source = "manual" self._last_reference = None self._refresh_preview() # -- behaviour ---------------------------------------------------------- def _set_status(self, text: str, *, error: bool = False) -> None: """Update the status line, red for errors.""" self._status.set(text) self._status_label.config(fg=_ERR if error else _FG) def _on_return(self, _event: tk.Event[tk.Misc]) -> None: """Handle the Enter key in any entry field.""" self._on_submit() def _on_submit(self) -> None: """Validate, then look up, or log -- as a single food or a summed meal. With a meal in progress, an empty form finalizes the accumulated items, and a completed form adds itself as the meal's last item before logging. With no meal in progress this is the original single-food path. """ description = self._get_desc() if not description: if self._meal_items: self._log_meal() return self._set_status("Type what you ate first.", error=True) self._desc_text.focus_set() return values = self._macro_values() if values is None: self._set_status("Macros must be numbers.", error=True) self._kcal_entry.focus_set() return if values[0] is None: self._begin_lookup(description) return nutrition = self._current_nutrition() if nutrition is None: self._set_status("Enter the calories, then submit.", error=True) self._kcal_entry.focus_set() return if self._meal_items: self._meal_items.append(MealItem(description, nutrition)) self._log_meal() return self._record(description, nutrition) def _begin_lookup(self, description: str) -> None: """Step 1: look the food up, fill the label fields, offer alternatives. Nothing is logged here -- the user must see and confirm the filled values (a second submit) before they are recorded. The food is looked up at its natural basis (per 100 g / serving); the amount eaten scales it, so the lookup never bakes in a portion. """ self._set_status("looking up…") self.root.update_idletasks() candidates = lookup_candidates(description) if not candidates: self._set_status( "Couldn't look that up. Enter the calories yourself, then submit.", error=True, ) self._kcal_entry.focus_set() return self._show_candidates(candidates) self._apply_reference(candidates[0][1]) source = candidates[0][1].source tail = ( "Review, or pick another below, then submit to log." if len(candidates) > 1 else "Review the values, then submit to log." ) self._set_status(f"Filled from {source}. {tail}") def _record(self, description: str, nutrition: Nutrition) -> None: """Log and bank a single food for the current slot, then advance.""" log_meal(description, nutrition, self._slot_for_log()) remember_food(description, nutrition) self._finish_slot(f"{nutrition.kcal:g} kcal ({nutrition.source})") def _meal_name(self) -> str: """Return the trimmed meal name the user typed (empty if none).""" return self._meal_name_entry.get().strip() def _refresh_meal_summary(self) -> None: """Update the running "meal so far" line from the accumulated items.""" if not self._meal_items: self._meal_summary.set("") return total = meal_total(self._meal_items) names = ", ".join(item.name for item in self._meal_items) self._meal_summary.set( f"Meal so far ({len(self._meal_items)}): {names} → " f"{total.kcal:g} kcal · P{total.protein_g:g} " f"C{total.carbs_g:g} F{total.fat_g:g}", ) def _on_add_item(self) -> None: """Add the current form as one component of a multi-part meal. Requires a name and resolved calories (a blank calorie field triggers a lookup first, exactly like submitting). On success the item is appended to the meal-in-progress, the running total updates, and the food fields clear for the next item while the meal name is kept. """ description = self._get_desc() if not description: self._set_status("Type the item first, then add it.", error=True) self._desc_text.focus_set() return values = self._macro_values() if values is None: self._set_status("Macros must be numbers.", error=True) self._kcal_entry.focus_set() return if values[0] is None: self._begin_lookup(description) return nutrition = self._current_nutrition() if nutrition is None: self._set_status("Enter the calories, then add the item.", error=True) self._kcal_entry.focus_set() return self._meal_items.append(MealItem(description, nutrition)) self._refresh_meal_summary() self._clear_food_inputs() self._set_status(f"Added {description}. Add another, or Log & Continue.") self._desc_text.focus_set() def _slot_for_log(self) -> int | None: """Return the slot to tag a log with -- None in demo (satisfies no slot). A synthetic demo slot must never satisfy a real checkpoint, so demo logs are slot-less: they still bank the food and update the dashboard, but do not silently stop the production gate from firing. """ return None if self.demo_mode else self._pending[0] def _log_meal(self) -> None: """Log the accumulated multi-item meal for the current slot and advance. Each component and the summed composite are banked (see :func:`python_pkg.diet_guard._foodbank.remember_meal`), and the slot is satisfied by the summed total under the meal's name. """ name = self._meal_name() or _DEFAULT_MEAL_NAME count = len(self._meal_items) total = remember_meal(name, list(self._meal_items)) log_meal(name, total, self._slot_for_log()) self._meal_items = [] self._finish_slot(f"{name}: {total.kcal:g} kcal ({count} items)") def _finish_slot(self, summary: str) -> None: """Advance past the current slot after something was logged for it. Args: summary: A short description of what was logged (calories/source, or the meal name and item count), shown in the confirmation line. """ slot = self._pending[0] self._pending.pop(0) self._refresh_dashboard() logged = f"Logged {slot_label(slot)}: {summary}" if not self._pending: self._unlock(logged) return self._clear_inputs() self._refresh_slot_header() self._set_status(f"{logged} — next meal, please.") self._desc_text.focus_set() def _unlock(self, logged: str) -> None: """Confirm the final log and tear the window down. Teardown is scheduled *before* the budget is looked up, so a broken budget seal (which raises) can never re-trap the user at unlock time. """ self._set_status(f"{logged} — all meals logged, unlocking…") self.root.after(_UNLOCK_DELAY_MS, self.close) # -- dashboard ---------------------------------------------------------- def _refresh_dashboard(self) -> None: """Recompute the prominent calorie headline and the detail panel.""" self._cal_headline.set(self._cal_headline_text()) self._dashboard.set(self._dashboard_text()) def _cal_headline_text(self) -> str: """Return the big calories-today line: consumed, target, and remaining.""" consumed = today_total_kcal() try: budget = daily_budget() except (BudgetError, OSError): return f"{consumed:g} kcal today" return ( f"{consumed:g} / {budget:g} kcal · {round(budget - consumed, 1):g} left" ) def _dashboard_text(self) -> str: """Build the detail panel: recent meals, then macros and protein.""" lines = ["── Today ───────────────────────────────"] entries = today_entries() if entries: for entry in entries[-_DASHBOARD_ROWS:]: clock = str(entry.get("time", ""))[_TIME_SLICE] desc = str(entry.get("desc", "?"))[:_DASH_DESC_WIDTH] lines.append( f" {clock:>5} {desc:<{_DASH_DESC_WIDTH}} " f"{entry_kcal(entry):>5.0f} kcal", ) else: lines.append(" (nothing logged yet today)") protein, carbs, fat = today_total_macros() lines.append(f" macros so far: P{protein:g} C{carbs:g} F{fat:g} g") target = protein_target_g() if target is not None: left = round(target - protein, 1) lines.append(f" protein {protein:g} / {target:g} g ({left:g} g left)") return "\n".join(lines) def _handle_callback_error(self) -> None: """Surface an unexpected callback error without dropping the grab.""" self._set_status( "Something went wrong. Enter the calories, then submit again.", error=True, ) with contextlib.suppress(tk.TclError): self._kcal_entry.focus_set() # -- lifecycle ---------------------------------------------------------- def _install_signal_handlers(self) -> None: """Ensure VT switching is restored on crash or kill, not just close.""" atexit.register(self._restore_vt_switching) for sig in (signal.SIGTERM, signal.SIGINT): with contextlib.suppress(ValueError): signal.signal(sig, self._on_signal) def _on_signal(self, _signum: int, _frame: FrameType | None) -> None: """Restore the keyboard escape, then exit, on SIGTERM/SIGINT.""" self._restore_vt_switching() raise SystemExit(0) def _keepalive(self) -> None: """Re-arm a periodic no-op so pending signals get serviced promptly.""" self.root.after(_KEEPALIVE_MS, self._keepalive) def close(self) -> None: """Restore VT switching and destroy the window (no process exit).""" self._restore_vt_switching() with contextlib.suppress(tk.TclError): self.root.destroy() def run(self) -> None: """Run the Tk loop, restoring VT switching on every exit path.""" self._install_signal_handlers() self._keepalive() try: self.root.mainloop() finally: self._restore_vt_switching()