diet-guard/diet_guard/_gatelock_nutrition.py

231 lines
10 KiB
Python
Raw Normal View History

"""Reference-to-total nutrition model and food lookup for the MealGate gate.
Split out of :mod:`._gatelock` to keep that module under the repo's 500-line
limit. ``_GateNutrition`` extends
:class:`~python_pkg.diet_guard._gatelock_core._GateCore` with the
"reference -> total" nutrition maths -- the label macros describe one basis
(per 100 g or per item), and how much was eaten scales that reference into
what gets logged -- plus the live preview/projection and the
autocomplete/lookup flow that fills the reference fields from banked foods,
staples, or Open Food Facts.
"""
from __future__ import annotations
import tkinter as tk
from python_pkg.diet_guard._budget import BudgetError, daily_budget
from python_pkg.diet_guard._estimator import Nutrition, scale_nutrition
from python_pkg.diet_guard._gatelock_core import _GateCore
from python_pkg.diet_guard._gatelock_ui import (
DEFAULT_PER_GRAMS,
SUGGESTION_ROWS,
UNIT_ITEMS,
)
from python_pkg.diet_guard._portions import DEFAULT_ITEM_GRAMS, estimate_unit_grams
from python_pkg.diet_guard._resolve import suggest_foods
from python_pkg.diet_guard._state import today_total_kcal
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}"
)
class _GateNutrition(_GateCore):
"""Reference->total nutrition maths, live preview, and food lookup."""
# -- 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._state.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._vars.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._vars.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._vars.projection.set(f"{base} → after this item: {after:g} left")
else:
self._vars.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._vars.unit.get() == UNIT_ITEMS:
grams = estimate_unit_grams(query)
if grams is not None:
self._set_entry(self._widgets.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._state.suggestion_mode = "bank"
self._state.suggestions = suggest_foods(query, limit=SUGGESTION_ROWS)
self._widgets.suggestion_box.delete(0, tk.END)
for name, nutrition in self._state.suggestions:
self._widgets.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._state.suggestion_mode = "candidates"
self._state.suggestions = candidates
self._widgets.suggestion_box.delete(0, tk.END)
for label, nutrition in candidates:
self._widgets.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._widgets.suggestion_box.curselection()
if not selection:
return
index = selection[0]
if index >= len(self._state.suggestions):
return
name, nutrition = self._state.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._state.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._state.source = nutrition.source
self._state.last_reference = nutrition
if name is not None:
self._set_desc(name)
if self._vars.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._widgets.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._widgets.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._widgets.amount_entry.get().strip() and nutrition.grams:
self._set_entry(self._widgets.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._widgets.amount_entry.delete(0, tk.END)
if self._state.last_reference is not None:
self._apply_reference(self._state.last_reference)
return
for entry in self._macro_entries():
entry.delete(0, tk.END)
self._reset_per_default()
self._state.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._state.source = "manual"
self._state.last_reference = None
self._refresh_preview()