mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 16:23:04 +02:00
1335 lines
52 KiB
Python
1335 lines
52 KiB
Python
|
|
"""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("<Return>", self._on_return)
|
||
|
|
for entry in self._macro_entries():
|
||
|
|
entry.bind("<Return>", self._on_return)
|
||
|
|
entry.bind("<KeyRelease>", 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("<KeyRelease>", self._on_desc_keyrelease)
|
||
|
|
text.bind("<Return>", 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("<<ListboxSelect>>", 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("<KeyRelease>", 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("<KeyRelease>", 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()
|