Replace PC gate text with glyphs

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-09 15:35:34 +01:00
parent 45627f7075
commit 43e4b4070d
43 changed files with 1226 additions and 385 deletions

View File

@ -2,7 +2,7 @@ name: diet_guard_app
description: "Companion phone app for diet_guard: log meals on the go." description: "Companion phone app for diet_guard: log meals on the go."
publish_to: 'none' publish_to: 'none'
version: 1.0.146+1 version: 1.0.147+1
environment: environment:
sdk: ^3.12.2 sdk: ^3.12.2

View File

@ -16,7 +16,7 @@ from typing import TYPE_CHECKING
from gatelock import LockConfig from gatelock import LockConfig
from diet_guard._averages import PeriodAverage, band_label from diet_guard import _gatelock_glyphs as g
from diet_guard._averages_report import monthly_average, weekly_average from diet_guard._averages_report import monthly_average, weekly_average
from diet_guard._daystatus import ( from diet_guard._daystatus import (
DayStatus, DayStatus,
@ -28,6 +28,7 @@ from diet_guard._gatelock_ui import BG, FG
from diet_guard._state import now_local from diet_guard._state import now_local
if TYPE_CHECKING: if TYPE_CHECKING:
from diet_guard._averages import PeriodAverage
from diet_guard._budget_history import BudgetSchedule from diet_guard._budget_history import BudgetSchedule
from diet_guard._state import DayLog from diet_guard._state import DayLog
@ -128,11 +129,6 @@ def cell_style(status: DayStatus | None) -> tuple[str, str, str]:
return color, _STATUS_TEXT_COLOR, color return color, _STATUS_TEXT_COLOR, color
def _plural(count: int) -> str:
"""Return "day" or "days" for ``count``."""
return "day" if count == 1 else "days"
def streaks_text( def streaks_text(
status_map_: dict[str, DayStatus], status_map_: dict[str, DayStatus],
*, *,
@ -145,10 +141,8 @@ def streaks_text(
""" """
logging = logging_streak(status_map_, today=today) logging = logging_streak(status_map_, today=today)
adherence = adherence_streak(status_map_, today=today) adherence = adherence_streak(status_map_, today=today)
return ( # Logged-days streak under the pencil, within-budget streak under the check.
f"Logging streak: {logging} {_plural(logging)} · " return f"{g.FOOD} {logging} {g.OK} {adherence}"
f"Adherence streak: {adherence} {_plural(adherence)}"
)
def ytd_text(status_map_: dict[str, DayStatus], *, today: date | None = None) -> str: def ytd_text(status_map_: dict[str, DayStatus], *, today: date | None = None) -> str:
@ -159,16 +153,16 @@ def ytd_text(status_map_: dict[str, DayStatus], *, today: date | None = None) ->
""" """
tally = year_to_date_tally(status_map_, today=today) tally = year_to_date_tally(status_map_, today=today)
return ( return (
f"This year: logged {tally.logged_days}/{tally.elapsed_days} days · " f"{g.YEAR_TOTAL} {g.FOOD} {tally.logged_days}/{tally.elapsed_days} "
f"{tally.adherent_days} within budget" f"{g.OK} {tally.adherent_days}"
) )
def _average_part(period: PeriodAverage) -> str: def _average_part(period: PeriodAverage) -> str:
"""Render one period as ``"NNNN kcal (band)"``, or ``"no data"``.""" """Render one period as ``"NNNN kcal <band glyph>"``, or a dash."""
if period.avg_kcal is None: if period.avg_kcal is None or period.band is None:
return band_label(period.band) return g.NO_VALUE
return f"{period.avg_kcal:.0f} kcal ({band_label(period.band)})" return f"{period.avg_kcal:.0f} kcal {g.BAND[period.band.value]}"
def averages_text( def averages_text(
@ -190,6 +184,6 @@ def averages_text(
week = weekly_average(log, schedule=schedule, today=today) week = weekly_average(log, schedule=schedule, today=today)
month = monthly_average(log, schedule=schedule, today=today) month = monthly_average(log, schedule=schedule, today=today)
return ( return (
f"Avg/day (to yesterday): week {_average_part(week)} \u00b7 " f"{g.AVERAGE} {g.SPAN['week']} {_average_part(week)} "
f"month {_average_part(month)}" f"{g.AVERAGE} {g.SPAN['month']} {_average_part(month)}"
) )

View File

@ -131,7 +131,7 @@ class MealGate(_GateCalendar):
self._state = _GateState() self._state = _GateState()
self.root = GateRoot() self.root = GateRoot()
self.root.on_callback_error = self.on_callback_error self.root.on_callback_error = self.on_callback_error
self.root.title("Diet Gate" + (" [DEMO]" if demo_mode else "")) self.root.title("Diet Gate") # OS-level app name: the one kept noun
# No bg=/fg=/... overrides here: LockConfig's own defaults already # No bg=/fg=/... overrides here: LockConfig's own defaults already
# are the unified-design-system palette, and _gatelock_ui reads the # are the unified-design-system palette, and _gatelock_ui reads the
# same LockConfig() rather than a separate hardcoded copy -- see # same LockConfig() rather than a separate hardcoded copy -- see

View File

@ -18,18 +18,9 @@ from typing import TYPE_CHECKING
from gatelock import ScrollableSurface from gatelock import ScrollableSurface
from diet_guard import _gatelock_glyphs as g
from diet_guard._body_fat_store import MAX_BODY_FAT, MIN_BODY_FAT, set_body_fat from diet_guard._body_fat_store import MAX_BODY_FAT, MIN_BODY_FAT, set_body_fat
from diet_guard._body_prefs import saved_goal, set_goal from diet_guard._body_prefs import saved_goal, set_goal
from diet_guard._body_report import (
activity_lines,
bmi_lines,
bmr_lines,
bodyfat_lines,
ideal_lines,
plan_lines,
profile_lines,
target_lines,
)
from diet_guard._body_store import ( from diet_guard._body_store import (
MAX_KG, MAX_KG,
MIN_KG, MIN_KG,
@ -39,6 +30,16 @@ from diet_guard._body_store import (
) )
from diet_guard._body_view import BodyView, budget_or_default, build_view from diet_guard._body_view import BodyView, budget_or_default, build_view
from diet_guard._daystatus import day_total_kcal from diet_guard._daystatus import day_total_kcal
from diet_guard._gatelock_body_glyphs import (
activity_glyphs,
bmi_glyphs,
bmr_glyphs,
bodyfat_glyphs,
ideal_glyphs,
plan_glyphs,
profile_glyphs,
target_glyphs,
)
from diet_guard._gatelock_body_graph import GraphBox, draw_weight_graph, in_range from diet_guard._gatelock_body_graph import GraphBox, draw_weight_graph, in_range
from diet_guard._gatelock_body_input import ( from diet_guard._gatelock_body_input import (
HEIGHT_CM, HEIGHT_CM,
@ -63,7 +64,9 @@ if TYPE_CHECKING:
__all__ = ["BodyTab"] __all__ = ["BodyTab"]
_DAY_HINT = "Type the YYYY-MM-DD day to delete." # The day field's expected shape, shown when it does not parse.
_DAY_SHAPE = "0000-00-00"
_DAY_HINT = f"{g.ERROR} {g.DELETE} {_DAY_SHAPE}"
class BodyTab: class BodyTab:
@ -119,17 +122,16 @@ class BodyTab:
view = build_view(today, self._goal) view = build_view(today, self._goal)
target = None if view.numbers is None else view.numbers.target target = None if view.numbers is None else view.numbers.target
kcal = None if target is None else target.kcal kcal = None if target is None else target.kcal
self.vars.goal.answer.set("" if kcal is None else f"eat {kcal} kcal") self.vars.goal.answer.set("" if kcal is None else f"→ {kcal} kcal")
lines = target_lines(view)
texts = { texts = {
"summary": profile_lines(view, today), "summary": profile_glyphs(view, today),
"table": lines[1:] if kcal is not None else lines, "table": target_glyphs(view),
"bodyfat": bodyfat_lines(view), "bodyfat": bodyfat_glyphs(view),
"bmi": bmi_lines(view), "bmi": bmi_glyphs(view),
"ideal": ideal_lines(view), "ideal": ideal_glyphs(view),
"bmr": bmr_lines(view), "bmr": bmr_glyphs(view),
"workouts": activity_lines(view), "workouts": activity_glyphs(view),
"plan": plan_lines(self._plan(today)), "plan": plan_glyphs(self._plan(today)),
} }
for name, lines in texts.items(): for name, lines in texts.items():
self.vars.blocks[name].set("\n".join(lines)) self.vars.blocks[name].set("\n".join(lines))
@ -166,8 +168,9 @@ class BodyTab:
def _saved(self, message: str) -> None: def _saved(self, message: str) -> None:
self.vars.status.set(message) self.vars.status.set(message)
# Saved here, but the publish failed: the reason goes to the log only.
publish_after_log_detached( publish_after_log_detached(
lambda why: self.vars.status.set(f"Saved; not yet synced ({why}).") lambda _why: self.vars.status.set(f"{g.OK} {g.ERROR} {g.SYNC}")
) )
self.refresh() self.refresh()
@ -176,7 +179,7 @@ class BodyTab:
self._goal = goal_choice(self.vars.goal, self._goal) self._goal = goal_choice(self.vars.goal, self._goal)
load_goal(self.vars.goal, self._goal) load_goal(self.vars.goal, self._goal)
set_goal(self._goal) set_goal(self._goal)
self._saved("Goal saved.") self._saved(g.OK)
def save_profile(self) -> None: def save_profile(self) -> None:
"""Validate and store birth date, height and sex.""" """Validate and store birth date, height and sex."""
@ -189,20 +192,22 @@ class BodyTab:
or not HEIGHT_CM[0] <= height <= HEIGHT_CM[1] or not HEIGHT_CM[0] <= height <= HEIGHT_CM[1]
or sex not in {"m", "f"} or sex not in {"m", "f"}
): ):
self.vars.status.set("Profile needs a YYYY-MM-DD birth date, cm and sex.") self.vars.status.set(f"{g.ERROR} {g.BIRTH} cm {g.MALE}{g.FEMALE}")
return return
set_profile(birth=birth, height_cm=height, sex=sex) set_profile(birth=birth, height_cm=height, sex=sex)
self._saved("Profile saved -- it moves to the bottom next time.") self._saved(g.OK)
def save_weight(self) -> None: def save_weight(self) -> None:
"""Validate and store the typed weigh-in.""" """Validate and store the typed weigh-in."""
kg = parse_float(self.vars.logs.weight.get()) kg = parse_float(self.vars.logs.weight.get())
day = parse_day(self.vars.logs.weight_day.get()) day = parse_day(self.vars.logs.weight_day.get())
if kg is None or day is None or not MIN_KG <= kg <= MAX_KG: if kg is None or day is None or not MIN_KG <= kg <= MAX_KG:
self.vars.status.set(f"Weight needs {MIN_KG:g}-{MAX_KG:g} kg and a day.") self.vars.status.set(
f"{g.ERROR} {MIN_KG:g}{g.DASH}{MAX_KG:g} kg {_DAY_SHAPE}"
)
return return
self._store_weight(day, kg) self._store_weight(day, kg)
self._saved(f"Saved {kg:g} kg for {day}.") self._saved(f"{g.OK} {kg:g} kg {day}")
def delete_weight(self) -> None: def delete_weight(self) -> None:
"""Delete the weigh-in for the typed day.""" """Delete the weigh-in for the typed day."""
@ -211,7 +216,7 @@ class BodyTab:
self.vars.status.set(_DAY_HINT) self.vars.status.set(_DAY_HINT)
return return
self._store_weight(day, None) self._store_weight(day, None)
self._saved(f"Deleted the weigh-in for {day}.") self._saved(f"{g.DELETE} {day}")
@staticmethod @staticmethod
def _store_weight(day: str, kg: float | None) -> None: def _store_weight(day: str, kg: float | None) -> None:
@ -225,11 +230,11 @@ class BodyTab:
pct = parse_float(self.vars.logs.body_fat.get()) pct = parse_float(self.vars.logs.body_fat.get())
day = parse_day(self.vars.logs.fat_day.get()) day = parse_day(self.vars.logs.fat_day.get())
if pct is None or day is None or not MIN_BODY_FAT <= pct <= MAX_BODY_FAT: if pct is None or day is None or not MIN_BODY_FAT <= pct <= MAX_BODY_FAT:
limits = f"{MIN_BODY_FAT:g}-{MAX_BODY_FAT:g}%" limits = f"{MIN_BODY_FAT:g}{g.DASH}{MAX_BODY_FAT:g}%"
self.vars.status.set(f"Body fat needs {limits} and a day.") self.vars.status.set(f"{g.ERROR} {limits} {_DAY_SHAPE}")
return return
set_body_fat(day, pct) set_body_fat(day, pct)
self._saved(f"Saved {pct:g}% body fat for {day}.") self._saved(f"{g.OK} {pct:g}% {day}")
def delete_body_fat(self) -> None: def delete_body_fat(self) -> None:
"""Delete the body-fat reading for the typed day.""" """Delete the body-fat reading for the typed day."""
@ -238,4 +243,4 @@ class BodyTab:
self.vars.status.set(_DAY_HINT) self.vars.status.set(_DAY_HINT)
return return
set_body_fat(day, None) set_body_fat(day, None)
self._saved(f"Deleted the body-fat reading for {day}.") self._saved(f"{g.DELETE} {day}")

View File

@ -0,0 +1,185 @@
"""The Body tab's report blocks as glyphs and numbers -- the gate's half.
:mod:`diet_guard._body_report` renders the same :class:`BodyView` as English
sentences for the terminal (``diet_guard body``), and terminal output keeps
its text. The gate is a window kuhy sees, so it gets this text-free twin:
one glyph per concept from :mod:`._gatelock_glyphs`, numbers through the
same ``half_up`` so both surfaces round identically. Method names that only
explained a number (formula names, BMI Prime, Trefethen, the per-formula
ideal weights) are dropped here; the CLI still prints them.
"""
from __future__ import annotations
from datetime import date
from typing import TYPE_CHECKING
from diet_guard import _gatelock_glyphs as g
from diet_guard._activity_kcal import FORMULAS, session_kcal
from diet_guard._body_calc import half_up
from diet_guard._body_energy import MEASURED_PREFIX, MIN_TARGET_KCAL
from diet_guard._body_store import age_on
if TYPE_CHECKING:
from diet_guard._body_view import BodyView
from diet_guard._week_plan import WeekPlan
__all__ = [
"ACTIVITY",
"activity_glyphs",
"bmi_glyphs",
"bmr_glyphs",
"bodyfat_glyphs",
"ideal_glyphs",
"plan_glyphs",
"profile_glyphs",
"target_glyphs",
]
_SESSIONS_SHOWN = 10
# Measured-exercise methods: MET keeps its acronym (a DOUBT row in keep.md),
# ACSM's speed equations are a slope, Per-km is its own unit.
METHOD: dict[str, str] = {"MET": "MET", "ACSM": "↗", "Per-km": "/km"}
# The activity picker: a rising bar per level, then the measured methods.
ACTIVITY: dict[str, str] = {
"sedentary": "▁",
"light": "▂",
"moderate": "▄",
"very": "▆",
"extra": "█",
**{f"{MEASURED_PREFIX}{n}": f"{g.MEASURED} {METHOD[n]}" for n in FORMULAS},
}
# Week-plan day sources: logged, typed, still planned, missed.
_SOURCE = {"logged": g.OK, "typed": g.EDIT, "plan": "○", "unlogged": "?"}
def _num(value: float, digits: int = 0) -> str:
"""``value`` rounded half-up to ``digits`` decimals."""
return f"{half_up(value, digits):.{digits}f}"
def _band(category: str) -> str:
"""The WHO band as a shape: thin, normal, over, obese; dash under 20."""
for word, glyph in (
("thinness", "▽"),
("normal", g.OK),
("overweight", "△"),
("obese", "▲"),
):
if word in category:
return glyph
return g.NO_VALUE
def profile_glyphs(view: BodyView, today: date) -> list[str]:
"""Birth date and age, height, sex; then the newest weight."""
prof = view.profile
birth = g.NO_VALUE
if prof.birth is not None:
age = age_on(date.fromisoformat(prof.birth), today)
birth = f"{prof.birth} ({age})"
height = f"{_num(prof.height_cm)} cm" if prof.height_cm else f"{g.NO_VALUE} cm"
sex = {"m": g.MALE, "f": g.FEMALE}.get(prof.sex or "", g.NO_VALUE)
newest = max(view.weights) if view.weights else None
weight = (
f"{_num(view.weights[newest], 1)} kg {newest} {g.TIMES}{len(view.weights)}"
if newest
else f"{g.NO_VALUE} kg"
)
return [f"{g.BIRTH} {birth} {height} {sex}", f"{g.AMOUNT} {weight}"]
def bodyfat_glyphs(view: BodyView) -> list[str]:
"""The newest body-fat reading, or a dash."""
if not view.body_fats:
return [f"% {g.NO_VALUE}"]
day = max(view.body_fats)
return [f"{_num(view.body_fats[day], 1)}% {day} {g.TIMES}{len(view.body_fats)}"]
def bmi_glyphs(view: BodyView) -> list[str]:
"""BMI with its band shape, and the healthy weight range."""
if view.numbers is None:
return [f"{g.TAB_BODY} ?"]
bmi = view.numbers.bmi
low, high = view.numbers.healthy
return [
f"{g.TAB_BODY} {_num(bmi.bmi, 1)} {_band(bmi.category)}",
f"{g.OK} {_num(low, 1)}{g.DASH}{_num(high, 1)} kg",
]
def ideal_glyphs(view: BodyView) -> list[str]:
"""The ideal-weight average and range over every formula."""
summary = None if view.numbers is None else view.numbers.ideal_summary
if summary is None:
return [f"{g.IDEAL} ?"]
avg, low, high = summary
return [f"{g.IDEAL} {_num(avg, 1)} kg ({_num(low, 1)}{g.DASH}{_num(high, 1)})"]
def bmr_glyphs(view: BodyView) -> list[str]:
"""Resting burn: one number per formula that has its inputs."""
if view.numbers is None:
return [f"{g.RESTING} ?"]
values = [_num(f.value) for f in view.numbers.bmr if f.value is not None]
return [f"{g.RESTING} {' '.join(values)} kcal"]
def target_glyphs(view: BodyView) -> list[str]:
"""Maintenance burn, and a warning when the target is under the floor."""
target = None if view.numbers is None else view.numbers.target
if target is None or target.kcal is None or target.tdee is None:
return [f"{g.MEASURED} {g.EMPTY}"] if target is not None else ["?"]
lines = [f"= {_num(target.tdee)} kcal"]
if target.below_floor:
lines.append(f"{g.WARN} < {MIN_TARGET_KCAL} kcal")
return lines
def activity_glyphs(view: BodyView) -> list[str]:
"""Measured means per method, steps, and the newest published workouts."""
if view.activity_stamp is None and not view.steps:
return [f"{g.MEASURED} {g.EMPTY}"]
stamp = (view.activity_stamp or "")[:16].replace("T", " ") or g.NO_VALUE
lines = [f"{g.SYNC} {stamp}"]
exercise = view.exercise
if exercise is not None:
means = " ".join(
f"{METHOD.get(name, name)} {_num(kcal)}"
for name, kcal in exercise.means.items()
)
lines.append(f"{g.AVERAGE} {exercise.days} {means} kcal")
if exercise is not None and view.steps:
newest = max(view.steps)
extra = _num(exercise.steps_kcal)
lines.append(f"{g.STEPS} {view.steps[newest]} {newest} +{extra} kcal")
weight = view.weight
for session in view.sessions[-_SESSIONS_SHOWN:][::-1]:
km = f" {_num(session.km, 1)} km" if session.km else ""
kcal = ""
if weight is not None:
result = session_kcal(session, weight)
kcal = " " + " ".join(
f"{METHOD[name]} {_num(result.kcal[name])}" for name in FORMULAS
)
# session.kind is the workout's own name, published by the PC: content.
lines.append(
f" {session.day} {session.kind:<8}{_num(session.minutes):>4} min{km}{kcal}"
)
return lines
def plan_glyphs(plan: WeekPlan) -> list[str]:
"""The week plan: day of month, kcal, and where the number came from."""
lines = [f"{g.AVERAGE} {plan.target_avg} kcal"]
lines.extend(
f" {day.day[-2:]} {day.kcal:>5} {_SOURCE.get(day.source, '?')}"
for day in plan.days
)
if plan.per_remaining is None:
lines.append(f"{g.OK} {g.AVERAGE} {_num(plan.week_avg)} kcal")
else:
warn = f" {g.WARN}" if plan.below_floor else ""
lines.append(f"→ {plan.per_remaining} kcal{warn}")
return lines

View File

@ -18,6 +18,7 @@ from datetime import date, timedelta
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from diet_guard._gatelock_calendar_types import _ACCENT, _COLORS, _MUTED from diet_guard._gatelock_calendar_types import _ACCENT, _COLORS, _MUTED
from diet_guard._gatelock_glyphs import EMPTY, INFINITE, OK
from diet_guard._gatelock_typography import CAPTION from diet_guard._gatelock_typography import CAPTION
from diet_guard._gatelock_ui import BG, FG from diet_guard._gatelock_ui import BG, FG
@ -34,15 +35,17 @@ __all__ = [
# (label, days back; None = everything). # (label, days back; None = everything).
GRAPH_RANGES: tuple[tuple[str, int | None], ...] = ( GRAPH_RANGES: tuple[tuple[str, int | None], ...] = (
("30 days", 30), ("30", 30),
("90 days", 90), ("90", 90),
("1 year", 365), ("365", 365),
("All", None), (INFINITE, None),
) )
_HEALTHY_FILL = "#1f3a2a" _HEALTHY_FILL = "#1f3a2a"
_GRID = "#33363d" _GRID = "#33363d"
_DOT_R = 3 _DOT_R = 3
_TICKS = 4 _TICKS = 4
# Size of the empty-range mark, in points.
_EMPTY_SIZE = 48
# Keep a flat line off the frame: pad the value range by this much (kg). # Keep a flat line off the frame: pad the value range by this much (kg).
_MIN_SPAN_KG = 2.0 _MIN_SPAN_KG = 2.0
# Include a healthy-band edge on the axis only when it is this close (kg). # Include a healthy-band edge on the axis only when it is this close (kg).
@ -140,8 +143,9 @@ def _draw_band(
box.x(0), top, box.x(1), bottom, fill=_HEALTHY_FILL, outline="" box.x(0), top, box.x(1), bottom, fill=_HEALTHY_FILL, outline=""
) )
font = (_COLORS.typography.font_family, CAPTION) font = (_COLORS.typography.font_family, CAPTION)
# The green band is the healthy-BMI weight range; a check marks it.
canvas.create_text( canvas.create_text(
box.x(1) - 4, top + 2, text="healthy BMI", anchor="ne", fill=_MUTED, font=font box.x(1) - 4, top + 2, text=OK, anchor="ne", fill=_MUTED, font=font
) )
@ -151,16 +155,16 @@ def draw_weight_graph(
box: GraphBox, box: GraphBox,
healthy: tuple[float, float] | None, healthy: tuple[float, float] | None,
) -> None: ) -> None:
"""Redraw the whole graph; an empty log shows a hint instead.""" """Redraw the whole graph; an empty range shows a large empty-set mark."""
canvas.delete("all") canvas.delete("all")
font = (_COLORS.typography.font_family, CAPTION) font = (_COLORS.typography.font_family, CAPTION)
if not weights: if not weights:
canvas.create_text( canvas.create_text(
box.width / 2, box.width / 2,
box.height / 2, box.height / 2,
text="Nothing logged in this range yet.", text=EMPTY,
fill=_MUTED, fill=_MUTED,
font=font, font=(_COLORS.typography.font_family, _EMPTY_SIZE),
) )
return return
span = value_span(list(weights.values()), healthy) span = value_span(list(weights.values()), healthy)

View File

@ -9,8 +9,9 @@ from __future__ import annotations
from datetime import date from datetime import date
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from diet_guard._body_energy import ACTIVITY_CHOICES, activity_label
from diet_guard._body_prefs import goal_from_json from diet_guard._body_prefs import goal_from_json
from diet_guard._gatelock_body_glyphs import ACTIVITY
from diet_guard._gatelock_glyphs import DIRECTION, SPAN
if TYPE_CHECKING: if TYPE_CHECKING:
from diet_guard._body_energy import Goal from diet_guard._body_energy import Goal
@ -38,25 +39,29 @@ def parse_day(text: str) -> str | None:
return None return None
def _back(glyphs: dict[str, str], shown: str, fallback: str) -> str:
"""The stored key whose glyph a picker shows, or ``fallback``."""
return next((key for key, glyph in glyphs.items() if glyph == shown), fallback)
def load_goal(goal_vars: GoalVars, goal: Goal) -> None: def load_goal(goal_vars: GoalVars, goal: Goal) -> None:
"""Put ``goal`` into the sentence's pickers.""" """Put ``goal`` into the goal row's pickers, as glyphs (units stay)."""
goal_vars.direction.set(goal.direction) goal_vars.direction.set(DIRECTION.get(goal.direction, goal.direction))
goal_vars.amount.set(f"{goal.amount:g}") goal_vars.amount.set(f"{goal.amount:g}")
goal_vars.weight_unit.set(goal.weight_unit) goal_vars.weight_unit.set(goal.weight_unit)
goal_vars.time_unit.set(goal.time_unit) goal_vars.time_unit.set(SPAN.get(goal.time_unit, goal.time_unit))
goal_vars.activity.set(activity_label(goal.activity)) goal_vars.activity.set(ACTIVITY.get(goal.activity, goal.activity))
def goal_choice(goal_vars: GoalVars, fallback: Goal) -> Goal: def goal_choice(goal_vars: GoalVars, fallback: Goal) -> Goal:
"""The goal the pickers show; an unreadable amount keeps ``fallback``'s.""" """The goal the pickers show; an unreadable amount keeps ``fallback``'s."""
labels = {activity_label(key): key for key in ACTIVITY_CHOICES}
amount = parse_float(goal_vars.amount.get()) amount = parse_float(goal_vars.amount.get())
return goal_from_json( return goal_from_json(
{ {
"dir": goal_vars.direction.get(), "dir": _back(DIRECTION, goal_vars.direction.get(), fallback.direction),
"amt": fallback.amount if amount is None or amount < 0 else amount, "amt": fallback.amount if amount is None or amount < 0 else amount,
"wu": goal_vars.weight_unit.get(), "wu": goal_vars.weight_unit.get(),
"tu": goal_vars.time_unit.get(), "tu": _back(SPAN, goal_vars.time_unit.get(), fallback.time_unit),
"act": labels.get(goal_vars.activity.get(), fallback.activity), "act": _back(ACTIVITY, goal_vars.activity.get(), fallback.activity),
} }
) )

View File

@ -23,21 +23,21 @@ from typing import TYPE_CHECKING
from gatelock import ButtonStyle, make_button from gatelock import ButtonStyle, make_button
from diet_guard import _gatelock_glyphs as g
from diet_guard._body_energy import ( from diet_guard._body_energy import (
ACTIVITY_CHOICES, ACTIVITY_CHOICES,
DIRECTIONS, DIRECTIONS,
PRESET_AMOUNTS, PRESET_AMOUNTS,
TIME_UNITS, TIME_UNITS,
WEIGHT_UNITS, WEIGHT_UNITS,
activity_label,
) )
from diet_guard._gatelock_body_glyphs import ACTIVITY
from diet_guard._gatelock_body_graph import GRAPH_RANGES from diet_guard._gatelock_body_graph import GRAPH_RANGES
from diet_guard._gatelock_body_vars import BodyWidgets from diet_guard._gatelock_body_vars import BodyWidgets
from diet_guard._gatelock_calendar_types import _ACCENT, _COLORS, _FIELD_BG, _MUTED from diet_guard._gatelock_calendar_types import _ACCENT, _COLORS, _FIELD_BG, _MUTED
from diet_guard._gatelock_spacing import MD, SM, XS from diet_guard._gatelock_spacing import MD, SM, XS
from diet_guard._gatelock_typography import BODY, LABEL, SUBTITLE, TITLE from diet_guard._gatelock_typography import BODY, LABEL, SUBTITLE, TITLE
from diet_guard._gatelock_ui import BG, FG from diet_guard._gatelock_ui import BG, FG
from diet_guard._week_plan import WEEKDAYS
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
@ -103,37 +103,35 @@ def _menu(
def _build_goal(parent: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> None: def _build_goal(parent: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> None:
"""``To [lose] [0.5][v] [kg] / [week] on [Moderate] eat 2019 kcal``.""" """``[↓] [0.5][v] [kg] / [◑] [▄] → 2019 kcal``: every choice a glyph."""
goal = vars_.goal goal = vars_.goal
row = _row(parent) row = _row(parent)
_label(row, "To", SUBTITLE) _menu(row, goal.direction, [g.DIRECTION[d] for d in DIRECTIONS], cb)
_menu(row, goal.direction, list(DIRECTIONS), cb)
_entry(row, goal.amount, 5, SUBTITLE) _entry(row, goal.amount, 5, SUBTITLE)
_menu(row, goal.amount, [f"{a:g}" for a in PRESET_AMOUNTS], cb) _menu(row, goal.amount, [f"{a:g}" for a in PRESET_AMOUNTS], cb)
_menu(row, goal.weight_unit, list(WEIGHT_UNITS), cb) _menu(row, goal.weight_unit, list(WEIGHT_UNITS), cb)
_label(row, "/", SUBTITLE) _label(row, "/", SUBTITLE)
_menu(row, goal.time_unit, list(TIME_UNITS), cb) _menu(row, goal.time_unit, [g.SPAN[t] for t in TIME_UNITS], cb)
_label(row, "on", SUBTITLE) _menu(row, goal.activity, [ACTIVITY[k] for k in ACTIVITY_CHOICES], cb)
_menu(row, goal.activity, [activity_label(k) for k in ACTIVITY_CHOICES], cb)
tk.Label( tk.Label(
row, textvariable=goal.answer, font=(*_font(TITLE), "bold"), bg=BG, fg=_ACCENT row, textvariable=goal.answer, font=(*_font(TITLE), "bold"), bg=BG, fg=_ACCENT
).pack(side="left", padx=(MD, 0)) ).pack(side="left", padx=(MD, 0))
_button(row, "Apply typed amount", cb.goal_changed) _button(row, g.OK, cb.goal_changed)
_block(parent, vars_.blocks["table"]) _block(parent, vars_.blocks["table"])
def _build_profile(parent: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> None: def _build_profile(parent: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> None:
row = _row(parent) row = _row(parent)
_label(row, "Profile -- born (YYYY-MM-DD)") _label(row, g.BIRTH)
_entry(row, vars_.profile.birth, 11) _entry(row, vars_.profile.birth, 11)
_label(row, "Height cm") _label(row, "cm")
_entry(row, vars_.profile.height, 6) _entry(row, vars_.profile.height, 6)
sex = vars_.profile.sex sex = vars_.profile.sex
for value, text in (("m", "male"), ("f", "female")): for value, text in (("m", g.MALE), ("f", g.FEMALE)):
tk.Radiobutton(row, text=text, value=value, variable=sex, **_TOGGLE).pack( tk.Radiobutton(row, text=text, value=value, variable=sex, **_TOGGLE).pack(
side="left" side="left"
) )
_button(row, "Save profile", cb.save_profile) _button(row, g.SAVE, cb.save_profile)
_block(parent, vars_.blocks["summary"]) _block(parent, vars_.blocks["summary"])
@ -146,18 +144,18 @@ def _dated_row(
row = _row(parent) row = _row(parent)
_label(row, label) _label(row, label)
_entry(row, vars_pair[0], 6) _entry(row, vars_pair[0], 6)
_label(row, "on") _label(row, g.CLOCK)
_entry(row, vars_pair[1], 11) _entry(row, vars_pair[1], 11)
_button(row, "Save", actions[0]) _button(row, g.SAVE, actions[0])
_button(row, "Delete that day", actions[1]) _button(row, g.DELETE, actions[1])
def _build_left(col: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> tk.Canvas: def _build_left(col: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> tk.Canvas:
logs = vars_.logs logs = vars_.logs
pair = (logs.weight, logs.weight_day) pair = (logs.weight, logs.weight_day)
_dated_row(col, "Weight kg", pair, (cb.save_weight, cb.delete_weight)) _dated_row(col, f"{g.AMOUNT} kg", pair, (cb.save_weight, cb.delete_weight))
row = _row(col) row = _row(col)
for value, text in (("weight", "Weight"), ("fat", "Body fat")): for value, text in (("weight", g.AMOUNT), ("fat", "%")):
tk.Radiobutton( tk.Radiobutton(
row, row,
text=text, text=text,
@ -181,12 +179,13 @@ def _build_right(col: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> None:
for name in ("bmi", "ideal", "bmr"): for name in ("bmi", "ideal", "bmr"):
_block(col, vars_.blocks[name]) _block(col, vars_.blocks[name])
row = _row(col) row = _row(col)
_label(row, "Week average target kcal") _label(row, f"{g.AVERAGE} kcal")
_entry(row, vars_.plan.target, 6) _entry(row, vars_.plan.target, 6)
for name, var in zip(WEEKDAYS, vars_.plan.days, strict=True): # Seven day boxes under dots, the weekend pair larger (no day names).
_label(row, name) for dot, var in zip(g.WEEKDAY_DOTS, vars_.plan.days, strict=True):
_label(row, dot)
_entry(row, var, 5) _entry(row, var, 5)
_button(row, "Plan week", cb.recalc) _button(row, g.OK, cb.recalc)
_block(col, vars_.blocks["plan"]) _block(col, vars_.blocks["plan"])
@ -195,9 +194,6 @@ def build_body_frame(
) -> BodyWidgets: ) -> BodyWidgets:
"""Build the whole tab into ``parent``; return what the controller needs.""" """Build the whole tab into ``parent``; return what the controller needs."""
frame = tk.Frame(parent, bg=BG) frame = tk.Frame(parent, bg=BG)
tk.Label(frame, text="Body", font=(*_font(TITLE), "bold"), bg=BG, fg=FG).pack(
pady=(MD, SM)
)
inner = tk.Frame(frame, bg=BG) inner = tk.Frame(frame, bg=BG)
inner.pack(fill="both", expand=True, padx=MD) inner.pack(fill="both", expand=True, padx=MD)
if profile_first: if profile_first:
@ -215,6 +211,6 @@ def build_body_frame(
_build_profile(inner, vars_, cb) _build_profile(inner, vars_, cb)
logs = vars_.logs logs = vars_.logs
pair = (logs.body_fat, logs.fat_day) pair = (logs.body_fat, logs.fat_day)
_dated_row(inner, "Body fat %", pair, (cb.save_body_fat, cb.delete_body_fat)) _dated_row(inner, "%", pair, (cb.save_body_fat, cb.delete_body_fat))
_block(inner, vars_.blocks["bodyfat"]) _block(inner, vars_.blocks["bodyfat"])
return BodyWidgets(frame=frame, canvas=canvas) return BodyWidgets(frame=frame, canvas=canvas)

View File

@ -18,6 +18,7 @@ import contextlib
import tkinter as tk import tkinter as tk
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from diet_guard import _gatelock_glyphs as g
from diet_guard._budget import BudgetError, write_budget from diet_guard._budget import BudgetError, write_budget
from diet_guard._budget_derived import budget_weight from diet_guard._budget_derived import budget_weight
from diet_guard._gatelock_mealflow import _GateMealFlow from diet_guard._gatelock_mealflow import _GateMealFlow
@ -79,13 +80,13 @@ class _GateBudgetEdit(_GateMealFlow):
if not self._cal_editing_budget: if not self._cal_editing_budget:
self._cal_editing_budget = True self._cal_editing_budget = True
self._set_budget_entry_state("normal") self._set_budget_entry_state("normal")
self._set_budget_button_text("Save") self._set_budget_button_text(g.SAVE)
self._set_budget_status("", error=False) self._set_budget_status("", error=False)
return return
if not self._save_budget_entry(): if not self._save_budget_entry():
return return
self._cal_editing_budget = False self._cal_editing_budget = False
self._set_budget_button_text("Edit") self._set_budget_button_text(g.EDIT)
self._refresh_calendar() self._refresh_calendar()
self._refresh_dashboard() self._refresh_dashboard()
self._refresh_projection() self._refresh_projection()
@ -104,15 +105,15 @@ class _GateBudgetEdit(_GateMealFlow):
try: try:
value = int(raw) value = int(raw)
except ValueError: except ValueError:
self._set_budget_status("Enter a whole number of kcal.", error=True) self._set_budget_status(f"{g.ERROR} kcal", error=True)
return False return False
if value <= 0: if value <= 0:
self._set_budget_status("Budget must be a positive number.", error=True) self._set_budget_status(f"{g.ERROR} kcal > 0", error=True)
return False return False
try: try:
weight = budget_weight() weight = budget_weight()
except BudgetError: except BudgetError:
weight = None weight = None
write_budget(value, weight_kg=weight) write_budget(value, weight_kg=weight)
self._set_budget_status("Saved.", error=False) self._set_budget_status(g.OK, error=False)
return True return True

View File

@ -16,12 +16,12 @@ are unit-tested directly without a fake Tk in the loop.
from __future__ import annotations from __future__ import annotations
import calendar
import contextlib import contextlib
import tkinter as tk import tkinter as tk
from tkinter import ttk from tkinter import ttk
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from diet_guard import _gatelock_glyphs as glyphs
from diet_guard._budget import ( from diet_guard._budget import (
BudgetFileCorruptError, BudgetFileCorruptError,
BudgetNotInitializedError, BudgetNotInitializedError,
@ -84,6 +84,7 @@ class _GateCalendar(_GateScheduleEdit):
_cal_editing_budget: bool _cal_editing_budget: bool
_cal_editing_schedule: bool _cal_editing_schedule: bool
_notebook: ttk.Notebook _notebook: ttk.Notebook
_body_tab: BodyTab
def _build_tabs(self, parent: tk.Misc, callbacks: GateCallbacks) -> GateWidgets: def _build_tabs(self, parent: tk.Misc, callbacks: GateCallbacks) -> GateWidgets:
"""Build one monitor's notebook, wire both tabs, return its meal widgets. """Build one monitor's notebook, wire both tabs, return its meal widgets.
@ -109,7 +110,7 @@ class _GateCalendar(_GateScheduleEdit):
widgets = build_layout( widgets = build_layout(
notebook, self._vars, callbacks, demo_mode=self.demo_mode notebook, self._vars, callbacks, demo_mode=self.demo_mode
) )
notebook.add(widgets.frame, text="Log Meal") notebook.add(widgets.frame, text=glyphs.TAB_LOG)
cal_callbacks = CalendarCallbacks( cal_callbacks = CalendarCallbacks(
on_prev_month=self._on_prev_month, on_prev_month=self._on_prev_month,
@ -118,8 +119,9 @@ class _GateCalendar(_GateScheduleEdit):
on_edit_or_save_schedule=self._on_edit_or_save_schedule, on_edit_or_save_schedule=self._on_edit_or_save_schedule,
) )
cal_widgets = build_calendar_frame(notebook, self._cal_vars, cal_callbacks) cal_widgets = build_calendar_frame(notebook, self._cal_vars, cal_callbacks)
notebook.add(cal_widgets.frame, text="History") notebook.add(cal_widgets.frame, text=glyphs.TAB_HISTORY)
notebook.add(BodyTab(notebook).container, text="Body") self._body_tab = BodyTab(notebook)
notebook.add(self._body_tab.container, text=glyphs.TAB_BODY)
# Ctrl+Tab / Ctrl+PageUp / Ctrl+PageDown and Alt+mnemonic tab switching # Ctrl+Tab / Ctrl+PageUp / Ctrl+PageDown and Alt+mnemonic tab switching
# all live in toplevel bindings that ttk only installs on request. The # all live in toplevel bindings that ttk only installs on request. The
# notebook is in the focus ring and Left/Right work once it holds focus, # notebook is in the focus ring and Left/Right work once it holds focus,
@ -159,7 +161,8 @@ class _GateCalendar(_GateScheduleEdit):
schedule = current_schedule(default=budget) if budget is not None else None schedule = current_schedule(default=budget) if budget is not None else None
if schedule is None: if schedule is None:
self._render_month(None) self._render_month(None)
self._cal_vars.streaks.set("Budget file is corrupt -- fix it below.") # Unreadable budget: warn at the budget flag, which is the fix.
self._cal_vars.streaks.set(f"{glyphs.WARN} {glyphs.BUDGET}")
self._cal_vars.ytd.set("") self._cal_vars.ytd.set("")
self._cal_vars.averages.set("") self._cal_vars.averages.set("")
else: else:
@ -176,9 +179,8 @@ class _GateCalendar(_GateScheduleEdit):
def _render_month(self, status_map_: dict[str, DayStatus] | None) -> None: def _render_month(self, status_map_: dict[str, DayStatus] | None) -> None:
"""Redraw the day-cell grid and month label for the displayed month.""" """Redraw the day-cell grid and month label for the displayed month."""
weeks = build_month_cells(self._cal_year, self._cal_month, status_map_) weeks = build_month_cells(self._cal_year, self._cal_month, status_map_)
self._cal_vars.month_label.set( # Numerals, not a month name: "11 · 2026".
f"{calendar.month_name[self._cal_month]} {self._cal_year}", self._cal_vars.month_label.set(f"{self._cal_month:02d} · {self._cal_year}")
)
for surface in self._cal_surfaces: for surface in self._cal_surfaces:
for index, cell_widget in enumerate(surface.day_cells): for index, cell_widget in enumerate(surface.day_cells):
row, col = divmod(index, 7) row, col = divmod(index, 7)

View File

@ -30,6 +30,7 @@ from typing import TYPE_CHECKING
from gatelock import make_button from gatelock import make_button
from diet_guard._gatelock_calendar_types import _COLORS, _FIELD_BG from diet_guard._gatelock_calendar_types import _COLORS, _FIELD_BG
from diet_guard._gatelock_glyphs import CLOCK, DASH, EDIT, TIMES
from diet_guard._gatelock_spacing import SM, XS from diet_guard._gatelock_spacing import SM, XS
from diet_guard._gatelock_typography import BODY, LABEL from diet_guard._gatelock_typography import BODY, LABEL
from diet_guard._gatelock_ui import BG, FG from diet_guard._gatelock_ui import BG, FG
@ -168,13 +169,13 @@ def build_schedule_row(
""" """
row = tk.Frame(parent, bg=BG) row = tk.Frame(parent, bg=BG)
row.pack(pady=(SM, XS)) row.pack(pady=(SM, XS))
_caption(row, "Meals:") _caption(row, CLOCK)
first_spin = _time_spin(row, vars_.schedule.first, FIRST_RANGE) first_spin = _time_spin(row, vars_.schedule.first, FIRST_RANGE)
_caption(row, "to") _caption(row, DASH)
last_spin = _time_spin(row, vars_.schedule.last, LAST_RANGE) last_spin = _time_spin(row, vars_.schedule.last, LAST_RANGE)
_caption(row, "x") _caption(row, TIMES)
count_entry = _spin_entry(row, vars_.schedule.count) count_entry = _spin_entry(row, vars_.schedule.count)
edit_button = make_button(row, _COLORS, "Edit", on_edit_or_save_schedule) edit_button = make_button(row, _COLORS, EDIT, on_edit_or_save_schedule)
edit_button.pack(side="left") edit_button.pack(side="left")
# The derived checkpoint times, so the effect of a change is visible # The derived checkpoint times, so the effect of a change is visible

View File

@ -17,6 +17,8 @@ from typing import TYPE_CHECKING
from gatelock import LockConfig from gatelock import LockConfig
from diet_guard._gatelock_glyphs import WEEKDAY, WEEKEND
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
from tkinter import ttk from tkinter import ttk
@ -32,7 +34,7 @@ _ACCENT = _COLORS.palette.accent
# calendar.monthcalendar never returns more than 6 weeks for any month. # calendar.monthcalendar never returns more than 6 weeks for any month.
_MONTH_ROWS = 6 _MONTH_ROWS = 6
_WEEKDAY_LABELS = ("Mo", "Tu", "We", "Th", "Fr", "Sa", "Su") _WEEKDAY_LABELS = (WEEKDAY,) * 5 + (WEEKEND,) * 2
_JANUARY = 1 _JANUARY = 1
_DECEMBER = 12 _DECEMBER = 12
_MONTH_AFTER_DECEMBER = 13 _MONTH_AFTER_DECEMBER = 13

View File

@ -20,6 +20,7 @@ from typing import TYPE_CHECKING
from gatelock import ButtonStyle, make_button from gatelock import ButtonStyle, make_button
from diet_guard import _gatelock_glyphs as glyphs
from diet_guard._gatelock_calendar_schedule import build_schedule_row from diet_guard._gatelock_calendar_schedule import build_schedule_row
from diet_guard._gatelock_calendar_types import ( from diet_guard._gatelock_calendar_types import (
_ACCENT, _ACCENT,
@ -33,7 +34,7 @@ from diet_guard._gatelock_calendar_types import (
CalendarWidgets, CalendarWidgets,
) )
from diet_guard._gatelock_spacing import SM, XS from diet_guard._gatelock_spacing import SM, XS
from diet_guard._gatelock_typography import BODY, CAPTION, LABEL, TITLE from diet_guard._gatelock_typography import BODY, CAPTION, LABEL
from diet_guard._gatelock_ui import BG, FG from diet_guard._gatelock_ui import BG, FG
if TYPE_CHECKING: if TYPE_CHECKING:
@ -59,7 +60,7 @@ def _build_budget_row(
row.pack(pady=(SM, XS)) row.pack(pady=(SM, XS))
tk.Label( tk.Label(
row, row,
text="Daily budget (kcal):", text=f"{glyphs.BUDGET} kcal",
font=(_COLORS.typography.font_family, LABEL), font=(_COLORS.typography.font_family, LABEL),
bg=BG, bg=BG,
fg=FG, fg=FG,
@ -80,7 +81,7 @@ def _build_budget_row(
edit_button = make_button( edit_button = make_button(
row, row,
_COLORS, _COLORS,
"Edit", glyphs.EDIT,
on_edit_or_save_budget, on_edit_or_save_budget,
) )
edit_button.pack(side="left") edit_button.pack(side="left")
@ -132,13 +133,15 @@ def _build_grid(parent: tk.Frame) -> list[tk.Label]:
"""Build the fixed 6x7 day-cell grid and return the flat cell list.""" """Build the fixed 6x7 day-cell grid and return the flat cell list."""
weekday_row = tk.Frame(parent, bg=BG) weekday_row = tk.Frame(parent, bg=BG)
weekday_row.pack() weekday_row.pack()
# Seven dots, no day names: the weekend pair is larger and accented.
for col, label in enumerate(_WEEKDAY_LABELS): for col, label in enumerate(_WEEKDAY_LABELS):
weekend = label == glyphs.WEEKEND
tk.Label( tk.Label(
weekday_row, weekday_row,
text=label, text=label,
font=(_COLORS.typography.font_family, CAPTION, "bold"), font=(_COLORS.typography.font_family, CAPTION, "bold"),
bg=BG, bg=BG,
fg=_MUTED, fg=_ACCENT if weekend else _MUTED,
width=4, width=4,
).grid(row=0, column=col, padx=XS) ).grid(row=0, column=col, padx=XS)
@ -170,14 +173,6 @@ def build_calendar_frame(
) -> CalendarWidgets: ) -> CalendarWidgets:
"""Lay out the History tab and return the widgets the controller drives.""" """Lay out the History tab and return the widgets the controller drives."""
frame = tk.Frame(root, bg=BG) frame = tk.Frame(root, bg=BG)
tk.Label(
frame,
text="📅 History",
font=(_COLORS.typography.font_family, TITLE, "bold"),
bg=BG,
fg=_ACCENT,
).pack(pady=(SM, 0))
budget_entry, budget_edit_button, budget_status_label = _build_budget_row( budget_entry, budget_edit_button, budget_status_label = _build_budget_row(
frame, frame,
vars_, vars_,

View File

@ -15,6 +15,7 @@ import logging
import tkinter as tk import tkinter as tk
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from diet_guard._gatelock_glyphs import CLOCK, OK
from diet_guard._gatelock_ui import ( from diet_guard._gatelock_ui import (
BASIS_PREFIX_GRAMS, BASIS_PREFIX_GRAMS,
BASIS_PREFIX_ITEMS, BASIS_PREFIX_ITEMS,
@ -108,11 +109,11 @@ class _GateCore:
"""Update the header to prompt for the slot now being collected.""" """Update the header to prompt for the slot now being collected."""
total = len(self._pending) total = len(self._pending)
if total == 0: if total == 0:
self._vars.slot_header.set("All meals logged.") self._vars.slot_header.set(OK)
return return
slot = self._pending[0] slot = self._pending[0]
position = "" if total == 1 else f" (1 of {total} remaining)" position = "" if total == 1 else f" 1/{total}"
self._vars.slot_header.set(f"Log your {slot_label(slot)} meal{position}") self._vars.slot_header.set(f"{CLOCK} {slot_label(slot)}{position}")
def _reset_per_default(self) -> None: def _reset_per_default(self) -> None:
"""Set the "per" field to the basis default for the current unit.""" """Set the "per" field to the basis default for the current unit."""

View File

@ -21,6 +21,7 @@ import queue
import sys import sys
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from diet_guard import _gatelock_glyphs as g
from diet_guard._gate import due_slots from diet_guard._gate import due_slots
from diet_guard._gatelock_fetch import FETCH_POLL_MS, start_fetch from diet_guard._gatelock_fetch import FETCH_POLL_MS, start_fetch
from diet_guard._gatelock_kuchnia import start_delivery_fetch from diet_guard._gatelock_kuchnia import start_delivery_fetch
@ -43,7 +44,7 @@ if TYPE_CHECKING: # pragma: no cover - typing only
_LAZY_ATTRS = ("refresh_delivery", "refresh_delivery_once") _LAZY_ATTRS = ("refresh_delivery", "refresh_delivery_once")
#: ``(unlock reason, nothing-new status)`` for a sync pull; "Fill all" has its own. #: ``(unlock reason, nothing-new status)`` for a sync pull; "Fill all" has its own.
_SYNC_LABELS = ("Synced from another device", "No new meals found in sync.") _SYNC_LABELS = (f"{g.OK} {g.SYNC}", f"{g.EMPTY} {g.SYNC}")
def __getattr__(name: str) -> object: def __getattr__(name: str) -> object:
@ -104,12 +105,12 @@ class _PullFlows(_GateNutrition):
second click while a fetch is in flight is ignored. second click while a fetch is in flight is ignored.
""" """
if self.demo_mode: if self.demo_mode:
self._set_status("Loading a delivery is only available on the real lock.") self._set_status(f"{g.DEMO_ONLY} {g.DELIVERY}")
return return
if self._delivery_result is not None: if self._delivery_result is not None:
return return
self._delivery_asked = True self._delivery_asked = True
self._set_status("Loading today's delivery…") self._set_status(f"{g.BUSY} {g.DELIVERY}")
# Through the module object so the lazy hook resolves it, and so a # Through the module object so the lazy hook resolves it, and so a
# test's ``patch.object`` still wins. # test's ``patch.object`` still wins.
self._delivery_result = start_delivery_fetch( self._delivery_result = start_delivery_fetch(
@ -131,11 +132,11 @@ class _PullFlows(_GateNutrition):
self._delivery_result = None self._delivery_result = None
if outcome.reason is not None: if outcome.reason is not None:
if self._delivery_asked: if self._delivery_asked:
self._set_status(f"{outcome.reason} — still locked.", error=True) self._set_status(f"{g.ERROR} {g.DELIVERY}", error=True)
return return
if not outcome.dishes: if not outcome.dishes:
if self._delivery_asked: if self._delivery_asked:
self._set_status("No catering delivery today.") self._set_status(f"{g.EMPTY} {g.DELIVERY}")
return return
self._delivery_pending = dishes_in_slot_order(outcome.dishes) self._delivery_pending = dishes_in_slot_order(outcome.dishes)
self._prefill_next_dish() self._prefill_next_dish()
@ -160,11 +161,9 @@ class _PullFlows(_GateNutrition):
# dish at 3.5x its calories while the fields still read correctly. # dish at 3.5x its calories while the fields still read correctly.
self._apply_reference(dish_nutrition(dish), name=dish.name) self._apply_reference(dish_nutrition(dish), name=dish.name)
remaining = len(self._delivery_pending) remaining = len(self._delivery_pending)
suffix = f" ({remaining} more to go)" if remaining else "" suffix = f" +{remaining}" if remaining else ""
prefix = f"{logged} — " if logged else "" prefix = f"{logged} " if logged else ""
self._set_status( self._set_status(f"{prefix}{g.DELIVERY} {dish.name}{suffix}")
f"{prefix}Loaded: {dish.name}{suffix} — check, then Log & Continue."
)
def _autoload_delivery(self) -> None: def _autoload_delivery(self) -> None:
"""Start a guarded catering fetch as the lock opens. """Start a guarded catering fetch as the lock opens.
@ -203,13 +202,13 @@ class _PullFlows(_GateNutrition):
write-race fix in one. write-race fix in one.
""" """
if self.demo_mode: if self.demo_mode:
self._set_status("Fetch from sync is only available on the real lock.") self._set_status(f"{g.DEMO_ONLY} {g.SYNC}")
return return
if self._fetch_result is not None: if self._fetch_result is not None:
# Already fetching. Without this a double-click starts a second # Already fetching. Without this a double-click starts a second
# worker, orphans the first result and races two log writes. # worker, orphans the first result and races two log writes.
return return
self._set_status("Fetching from sync…") self._set_status(f"{g.BUSY} {g.SYNC}")
self._fetch_result = start_fetch(pull_peer_logs) self._fetch_result = start_fetch(pull_peer_logs)
self.root.after(FETCH_POLL_MS, self._poll_fetch) self.root.after(FETCH_POLL_MS, self._poll_fetch)
@ -225,7 +224,7 @@ class _PullFlows(_GateNutrition):
return return
self._fetch_result = None self._fetch_result = None
if reason is not None: if reason is not None:
self._set_status(f"{reason} — still locked.", error=True) self._set_status(f"{g.ERROR} {g.SYNC}", error=True)
return return
self._reconcile_after_fetch() self._reconcile_after_fetch()
@ -244,7 +243,5 @@ class _PullFlows(_GateNutrition):
return return
self._clear_inputs() self._clear_inputs()
self._refresh_slot_header() self._refresh_slot_header()
count = len(satisfied_slots) self._set_status(f"{g.OK} {g.SYNC} {len(satisfied_slots)}")
meal_word = "meal" if count == 1 else "meals"
self._set_status(f"Pulled {count} {meal_word} — next meal, please.")
self._widgets.desc_text.focus_set() self._widgets.desc_text.focus_set()

View File

@ -22,9 +22,10 @@ from __future__ import annotations
import tkinter as tk import tkinter as tk
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from diet_guard import _gatelock_glyphs as glyphs
from diet_guard import _gatelock_ui from diet_guard import _gatelock_ui
from diet_guard._gatelock_spacing import SM, XS from diet_guard._gatelock_spacing import SM, XS
from diet_guard._gatelock_typography import BODY, LABEL from diet_guard._gatelock_typography import BODY, LABEL, SUBTITLE
from diet_guard._gatelock_ui import ( from diet_guard._gatelock_ui import (
_ACCENT, _ACCENT,
_COLORS, _COLORS,
@ -61,13 +62,7 @@ def build_desc(parent: tk.Frame) -> tk.Text:
wraps onto a second line and stays fully visible, instead of scrolling off wraps onto a second line and stays fully visible, instead of scrolling off
the right edge where the end can no longer be read. the right edge where the end can no longer be read.
""" """
tk.Label( tk.Label(parent, text=glyphs.FOOD, font=(_FONT, SUBTITLE), bg=BG, fg=FG).pack()
parent,
text="What did you eat?",
font=(_FONT, LABEL),
bg=BG,
fg=FG,
).pack()
text = tk.Text( text = tk.Text(
parent, parent,
font=(_FONT, BODY), font=(_FONT, BODY),
@ -121,13 +116,7 @@ def build_amount_row(
on_unit_change: Callable[[str], None], on_unit_change: Callable[[str], None],
) -> tk.Entry: ) -> tk.Entry:
"""Build the "how much did you eat?" amount + unit row; return the entry.""" """Build the "how much did you eat?" amount + unit row; return the entry."""
tk.Label( tk.Label(parent, text=glyphs.AMOUNT, font=(_FONT, SUBTITLE), bg=BG, fg=FG).pack()
parent,
text="How much did you eat?",
font=(_FONT, LABEL),
bg=BG,
fg=FG,
).pack()
row = tk.Frame(parent, bg=BG) row = tk.Frame(parent, bg=BG)
row.pack(pady=(XS, XS)) row.pack(pady=(XS, XS))
amount_entry = _numeric_entry(root, row, width=10, variable=vars_.entries.amount) amount_entry = _numeric_entry(root, row, width=10, variable=vars_.entries.amount)
@ -167,7 +156,7 @@ def _build_unit_selector(
for unit in (UNIT_GRAMS, UNIT_ITEMS): for unit in (UNIT_GRAMS, UNIT_ITEMS):
tk.Radiobutton( tk.Radiobutton(
row, row,
text=unit, text=glyphs.ITEMS if unit == UNIT_ITEMS else glyphs.GRAMS,
value=unit, value=unit,
variable=vars_.unit, variable=vars_.unit,
command=lambda u=unit: on_unit_change(u), command=lambda u=unit: on_unit_change(u),
@ -203,20 +192,21 @@ def build_macro_section(
basis_prefix.pack(side="left") basis_prefix.pack(side="left")
per_entry = _numeric_entry(root, basis, width=5, variable=vars_.entries.per) per_entry = _numeric_entry(root, basis, width=5, variable=vars_.entries.per)
per_entry.pack(side="left", padx=XS, ipady=XS) per_entry.pack(side="left", padx=XS, ipady=XS)
tk.Label( # Blank kcal still means "look it up"; the busy cue shows it happening.
basis, tk.Label(basis, text="g", font=(_FONT, LABEL), bg=BG, fg=FG).pack(side="left")
text="g (leave calories blank to look it up):",
font=(_FONT, LABEL),
bg=BG,
fg=FG,
).pack(side="left")
row = tk.Frame(parent, bg=BG) row = tk.Frame(parent, bg=BG)
row.pack(pady=(XS, XS)) row.pack(pady=(XS, XS))
# Shape + colour per macro, the phone's MacroKind vocabulary.
colors = glyphs.MACRO_COLORS
macros = _MacroEntries( macros = _MacroEntries(
kcal=_macro_cell(root, row, "kcal", vars_.entries.kcal), kcal=_macro_cell(root, row, "kcal", vars_.entries.kcal),
protein=_macro_cell(root, row, "P", vars_.entries.protein), protein=_macro_cell(
carbs=_macro_cell(root, row, "C", vars_.entries.carbs), root, row, glyphs.PROTEIN, vars_.entries.protein, colors["protein"]
fat=_macro_cell(root, row, "F", vars_.entries.fat), ),
carbs=_macro_cell(
root, row, glyphs.CARBS, vars_.entries.carbs, colors["carbs"]
),
fat=_macro_cell(root, row, glyphs.FAT, vars_.entries.fat, colors["fat"]),
) )
return basis_prefix, per_entry, macros return basis_prefix, per_entry, macros

View File

@ -23,6 +23,7 @@ from importlib import import_module
import queue import queue
from typing import TYPE_CHECKING, NamedTuple from typing import TYPE_CHECKING, NamedTuple
from diet_guard import _gatelock_glyphs as g
from diet_guard._gatelock_fetch import FETCH_POLL_MS from diet_guard._gatelock_fetch import FETCH_POLL_MS
from diet_guard._gatelock_kuchnia import start_delivery_fetch from diet_guard._gatelock_kuchnia import start_delivery_fetch
from diet_guard._kuchnia_log import fill_plan, log_dishes from diet_guard._kuchnia_log import fill_plan, log_dishes
@ -41,15 +42,19 @@ if TYPE_CHECKING: # pragma: no cover - typing only
from diet_guard._kuchnia_spread import SlottedDish from diet_guard._kuchnia_spread import SlottedDish
#: The button's resting label; also what it returns to after each attempt. #: The button's resting label; also what it returns to after each attempt.
FILL_LABEL = "🍱 Fill all" FILL_LABEL = f"{g.FILL_ALL} {g.DELIVERY}"
#: ``(unlock reason, nothing-new status)`` for the gate's post-write reconcile: #: ``(unlock reason, nothing-new status)`` for the gate's post-write reconcile:
#: a fill that covered only later slots must not report "found in sync". #: a fill that covered only later slots must not report "found in sync" --
#: catering logged, and the food field is what the current slot still needs.
RECONCILE_LABELS = ( RECONCILE_LABELS = (
"Filled from catering", f"{g.OK} {g.DELIVERY}",
"Catering logged; this slot still needs a meal.", f"{g.OK} {g.DELIVERY} {g.FOOD}",
) )
#: "Every slot already has a meal": nothing left for the fill to take.
_NOTHING_TO_FILL = f"{g.OK} {g.CLOCK}"
def _refresh_delivery(day: date) -> tuple[Sequence[Dish], str | None]: def _refresh_delivery(day: date) -> tuple[Sequence[Dish], str | None]:
"""Fetch ``day``'s dishes, importing the HTTP stack only when clicked. """Fetch ``day``'s dishes, importing the HTTP stack only when clicked.
@ -103,7 +108,7 @@ class FillAllFlow:
def click(self) -> None: def click(self) -> None:
"""First click fetches and proposes; a second click confirms.""" """First click fetches and proposes; a second click confirms."""
if self._demo_mode: if self._demo_mode:
self._set_status("Fill all is only available on the real lock.") self._set_status(f"{g.DEMO_ONLY} {g.FILL_ALL}")
return return
if self._result is not None: if self._result is not None:
return # a fetch is in flight; a double-click must not start two return # a fetch is in flight; a double-click must not start two
@ -116,7 +121,7 @@ class FillAllFlow:
# at midnight can never confirm yesterday's menu into today. # at midnight can never confirm yesterday's menu into today.
self._reset() self._reset()
self._plan_day = today self._plan_day = today
self._set_status("Loading today's delivery…") self._set_status(f"{g.BUSY} {g.DELIVERY}")
self._result = start_delivery_fetch(_refresh_delivery, today) self._result = start_delivery_fetch(_refresh_delivery, today)
self._root.after(FETCH_POLL_MS, self._poll) self._root.after(FETCH_POLL_MS, self._poll)
@ -132,24 +137,23 @@ class FillAllFlow:
return return
self._result = None self._result = None
if outcome.reason is not None: if outcome.reason is not None:
self._set_status(f"{outcome.reason} — still locked.", error=True) self._set_status(f"{g.ERROR} {g.DELIVERY}", error=True)
return return
if not outcome.dishes: if not outcome.dishes:
self._set_status("No catering delivery today.") self._set_status(f"{g.EMPTY} {g.DELIVERY}")
return return
plan = fill_plan(assign_slots(outcome.dishes, day_slots(current_schedule()))) plan = fill_plan(assign_slots(outcome.dishes, day_slots(current_schedule())))
if not plan: if not plan:
self._set_status("Every slot already has a meal — nothing to fill.") self._set_status(_NOTHING_TO_FILL)
return return
self._plan = plan self._plan = plan
self._label.set(f"✓ Confirm ({len(plan)})") self._label.set(f"{g.OK} {len(plan)}")
total = sum(item.dish.kcal for item in plan) total = sum(item.dish.kcal for item in plan)
listing = ", ".join( # The proposal: slot time and dish name (catering content) per row.
listing = " ".join(
f"{slot_label(item.slot)} {item.dish.name}" for item in plan f"{slot_label(item.slot)} {item.dish.name}" for item in plan
) )
self._set_status( self._set_status(f"{g.DELIVERY} {len(plan)} {total:g} kcal {listing}")
f"Will log {len(plan)} ({total:g} kcal): {listing} — click Confirm."
)
def _confirm(self) -> None: def _confirm(self) -> None:
"""Write the proposed dishes into the slots still empty right now.""" """Write the proposed dishes into the slots still empty right now."""
@ -158,10 +162,10 @@ class FillAllFlow:
plan = fill_plan(self._plan) plan = fill_plan(self._plan)
self._reset() self._reset()
if not plan: if not plan:
self._set_status("Every slot already has a meal — nothing to fill.") self._set_status(_NOTHING_TO_FILL)
return return
written = log_dishes(plan) written = log_dishes(plan)
self._set_status(f"Logged {len(written)} catering dish(es).") self._set_status(f"{g.OK} {g.DELIVERY} {len(written)}")
self._on_logged() self._on_logged()
def _reset(self) -> None: def _reset(self) -> None:

View File

@ -0,0 +1,88 @@
"""The PC gate's glyph vocabulary: one shape per concept, no words.
kuhy's textgate rule (2026-11-08): nothing the gate paints carries a word.
Every Tk surface draws from this one table so a concept looks the same on the
Log Meal, History and Body tabs. Plain geometric BMP characters only, all in
DejaVu Sans: colour emoji can crash Tk 8.6 and the gate is the one window
that must never fail to draw. ``test_gate_no_words`` is the guard.
The macro shapes match the phone app's ``MacroKind`` (protein circle, carbs
triangle, fat square), so one vocabulary reads the same on both devices.
"""
from __future__ import annotations
from typing import Final
# Status cues: the status line shows one of these instead of a sentence.
OK: Final = "✓"
ERROR: Final = "✗"
BUSY: Final = "⋯"
EMPTY: Final = "∅"
DEMO_ONLY: Final = "⊘"
WARN: Final = "⚠"
# Fields and concepts.
FOOD: Final = "✎"
AMOUNT: Final = "⚖"
PROTEIN: Final = "●"
CARBS: Final = "▲"
FAT: Final = "■"
MACRO_COLORS: Final = {"protein": "#6abf69", "carbs": "#e0a33a", "fat": "#6b9bef"}
BUDGET: Final = "⚑"
CLOCK: Final = "◷"
AVERAGE: Final = "⌀"
BIRTH: Final = "✱"
RESTING: Final = "☾"
IDEAL: Final = "◎"
STEPS: Final = "⇶"
MEASURED: Final = "◉"
TIMES: Final = "\N{MULTIPLICATION SIGN}"
ITEMS: Final = TIMES
GRAMS: Final = "g"
# Actions.
SUBMIT: Final = "✓"
EDIT: Final = "✎"
SAVE: Final = "✓"
DELETE: Final = "⌫"
CLOSE: Final = "✕"
SYNC: Final = "⟳"
DELIVERY: Final = "▣"
FILL_ALL: Final = "⇊"
INFINITE: Final = "∞"
UNLOCKING: Final = "⇥"
# Notebook tabs.
TAB_LOG: Final = "✚"
TAB_HISTORY: Final = "▦"
TAB_BODY: Final = "♙"
# Profile sex, goal direction and goal span.
MALE: Final = "♂"
FEMALE: Final = "♀"
DIRECTION: Final = {"lose": "↓", "maintain": "=", "gain": "↑"}
SPAN: Final = {"day": "◔", "week": "◑", "month": "◕", "year": "●"}
# Summaries: the year's tally, an empty value, and how an average compared
# with its budget (``AverageBand`` values; shape and fill grow with the excess).
YEAR_TOTAL: Final = "Σ"
DASH: Final = "\N{EN DASH}"
NO_VALUE: Final = DASH
BAND: Final = {"under": "✓", "slightly_over": "△", "very_over": "▲"}
# Weekday header: weekdays small, the weekend larger (no day names).
WEEKDAY: Final = "•"
WEEKEND: Final = "●"
WEEKDAY_DOTS: Final = (WEEKDAY,) * 5 + (WEEKEND,) * 2
def source_glyph(source: str) -> str:
"""Where a nutrition came from: food bank, staple, catering, typed, looked up."""
head = source.split(":", 1)[0].strip()
return {
"food bank": "▤",
"staple": "★",
"catering": DELIVERY,
"manual": FOOD,
}.get(head, "≈")

View File

@ -18,6 +18,7 @@ import tkinter as tk
from gatelock import ButtonStyle, ScrollableSurface, make_button from gatelock import ButtonStyle, ScrollableSurface, make_button
from diet_guard import _gatelock_glyphs as glyphs
from diet_guard._gatelock_fields import ( from diet_guard._gatelock_fields import (
build_amount_row, build_amount_row,
build_desc, build_desc,
@ -66,14 +67,7 @@ def _build_dashboard(parent: tk.Frame, vars_: GateVars) -> None:
def _build_header(body: tk.Frame, vars_: GateVars) -> None: def _build_header(body: tk.Frame, vars_: GateVars) -> None:
"""Pack the title and the current-slot header.""" """Pack the current-slot header (no title: the tab glyph names the tab)."""
tk.Label(
body,
text="🍽 Diet Gate",
font=(_FONT, TITLE, "bold"),
bg=BG,
fg=_ACCENT,
).pack(pady=(0, XS))
tk.Label( tk.Label(
body, body,
textvariable=vars_.slot_header, textvariable=vars_.slot_header,
@ -114,7 +108,7 @@ def _build_actions(
make_button( make_button(
body, body,
_COLORS, _COLORS,
"Log & Continue", glyphs.SUBMIT,
callbacks.on_submit, callbacks.on_submit,
).pack(pady=(XS, XS)) ).pack(pady=(XS, XS))
@ -129,7 +123,7 @@ def _build_actions(
make_button( make_button(
pulls, pulls,
_COLORS, _COLORS,
"⟳ Fetch from sync", glyphs.SYNC,
callbacks.on_fetch_sync, callbacks.on_fetch_sync,
ButtonStyle(variant="secondary", bold=False), ButtonStyle(variant="secondary", bold=False),
).pack(side="left", padx=(0, XS)) ).pack(side="left", padx=(0, XS))
@ -140,7 +134,7 @@ def _build_actions(
make_button( make_button(
pulls, pulls,
_COLORS, _COLORS,
"🍱 Today's delivery", glyphs.DELIVERY,
callbacks.on_load_delivery, callbacks.on_load_delivery,
ButtonStyle(variant="secondary", bold=False), ButtonStyle(variant="secondary", bold=False),
).pack(side="left", padx=(0, XS)) ).pack(side="left", padx=(0, XS))
@ -222,10 +216,12 @@ def build_layout(
make_button( make_button(
root, root,
_COLORS, _COLORS,
"✕ Close Demo", glyphs.CLOSE,
callbacks.on_close, callbacks.on_close,
ButtonStyle(variant="danger", bold=False), ButtonStyle(variant="danger", bold=False),
).place(x=10, y=10) # Top-right: the glyph-only notebook tabs are narrow and sit top-left,
# where a button at the corner would cover them.
).place(relx=1.0, x=-10, y=10, anchor="ne")
return GateWidgets( return GateWidgets(
frame=frame, frame=frame,

View File

@ -15,6 +15,7 @@ from functools import partial
import tkinter as tk import tkinter as tk
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from diet_guard import _gatelock_glyphs as g
from diet_guard._budget import BudgetError, daily_budget from diet_guard._budget import BudgetError, daily_budget
from diet_guard._budget_derived import protein_target_g from diet_guard._budget_derived import protein_target_g
from diet_guard._foodbank import remember_food from diet_guard._foodbank import remember_food
@ -92,13 +93,13 @@ class _GateMealFlow(_PullFlows):
"""Validate, then look the food up, or log it.""" """Validate, then look the food up, or log it."""
description = self._get_desc() description = self._get_desc()
if not description: if not description:
self._set_status("Type what you ate first.", error=True) self._set_status(f"{g.ERROR} {g.FOOD}", error=True)
self._widgets.desc_text.focus_set() self._widgets.desc_text.focus_set()
return return
values = self._macro_values() values = self._macro_values()
if values is None: if values is None:
self._set_status("Macros must be numbers.", error=True) self._set_status(f"{g.ERROR} {g.PROTEIN}{g.CARBS}{g.FAT}", error=True)
self._widgets.macros.kcal.focus_set() self._widgets.macros.kcal.focus_set()
return return
@ -107,7 +108,7 @@ class _GateMealFlow(_PullFlows):
return return
nutrition = self._current_nutrition() nutrition = self._current_nutrition()
if nutrition is None: if nutrition is None:
self._set_status("Enter the calories, then submit.", error=True) self._set_status(f"{g.ERROR} kcal", error=True)
self._widgets.macros.kcal.focus_set() self._widgets.macros.kcal.focus_set()
return return
self._record(description, nutrition) self._record(description, nutrition)
@ -120,31 +121,28 @@ class _GateMealFlow(_PullFlows):
up at its natural basis (per 100 g / serving); the amount eaten scales up at its natural basis (per 100 g / serving); the amount eaten scales
it, so the lookup never bakes in a portion. it, so the lookup never bakes in a portion.
""" """
self._set_status("looking up…") self._set_status(g.BUSY)
self.root.update_idletasks() self.root.update_idletasks()
candidates = lookup_candidates(description) candidates = lookup_candidates(description)
if not candidates: if not candidates:
self._set_status( # Not found: the kcal field takes focus, the cue names it.
"Couldn't look that up. Enter the calories yourself, then submit.", self._set_status(f"{g.ERROR} {g.source_glyph('')} kcal", error=True)
error=True,
)
self._widgets.macros.kcal.focus_set() self._widgets.macros.kcal.focus_set()
return return
self._show_candidates(candidates) self._show_candidates(candidates)
self._apply_reference(candidates[0][1]) self._apply_reference(candidates[0][1])
source = candidates[0][1].source # Filled, not logged: the source glyph, and the alternatives' count
tail = ( # when the picker below offers more than one.
"Review, or pick another below, then submit to log." source = g.source_glyph(candidates[0][1].source)
if len(candidates) > 1 more = f" {g.TIMES}{len(candidates)}" if len(candidates) > 1 else ""
else "Review the values, then submit to log." self._set_status(f"{g.OK} {source}{more}")
)
self._set_status(f"Filled from {source}. {tail}")
def _record(self, description: str, nutrition: Nutrition) -> None: def _record(self, description: str, nutrition: Nutrition) -> None:
"""Log and bank a single food for the current slot, then advance.""" """Log and bank a single food for the current slot, then advance."""
log_meal(description, nutrition, self._slot_for_log()) log_meal(description, nutrition, self._slot_for_log())
remember_food(description, nutrition) remember_food(description, nutrition)
self._finish_slot(f"{nutrition.kcal:g} kcal ({nutrition.source})") source = g.source_glyph(nutrition.source)
self._finish_slot(f"{nutrition.kcal:g} kcal {source}")
def _slot_for_log(self) -> int | None: def _slot_for_log(self) -> int | None:
"""Return the slot to tag a log with -- None in demo (satisfies no slot). """Return the slot to tag a log with -- None in demo (satisfies no slot).
@ -165,7 +163,7 @@ class _GateMealFlow(_PullFlows):
slot = self._pending[0] slot = self._pending[0]
self._pending.pop(0) self._pending.pop(0)
self._refresh_dashboard() self._refresh_dashboard()
logged = f"Logged {slot_label(slot)}: {summary}" logged = f"{g.OK} {slot_label(slot)} {summary}"
if not self._pending: if not self._pending:
self._unlock(logged) self._unlock(logged)
return return
@ -179,7 +177,7 @@ class _GateMealFlow(_PullFlows):
if self._delivery_pending: if self._delivery_pending:
self._prefill_next_dish(logged) self._prefill_next_dish(logged)
return return
self._set_status(f"{logged} — next meal, please.") self._set_status(logged)
self._widgets.desc_text.focus_set() self._widgets.desc_text.focus_set()
def _unlock(self, logged: str) -> None: def _unlock(self, logged: str) -> None:
@ -196,9 +194,8 @@ class _GateMealFlow(_PullFlows):
d.name.strip().casefold() not in eaten for d in self._delivery_pending d.name.strip().casefold() not in eaten for d in self._delivery_pending
) )
if left: if left:
noun = "dish" if left == 1 else "dishes" logged = f"{logged} {g.DELIVERY} +{left}"
logged = f"{logged} ({left} more {noun} delivered; log with 'ate')" self._set_status(f"{logged} {g.UNLOCKING}")
self._set_status(f"{logged} — all meals logged, unlocking…")
self.root.after(_UNLOCK_DELAY_MS, self.close) self.root.after(_UNLOCK_DELAY_MS, self.close)
def _refresh_dashboard(self) -> None: def _refresh_dashboard(self) -> None:
@ -212,14 +209,13 @@ class _GateMealFlow(_PullFlows):
try: try:
budget = daily_budget() budget = daily_budget()
except BudgetError, OSError: except BudgetError, OSError:
return f"{consumed:g} kcal today" return f"{consumed:g} kcal"
return ( left = round(budget - consumed, 1)
f"{consumed:g} / {budget:g} kcal · {round(budget - consumed, 1):g} left" return f"{consumed:g} / {budget:g} kcal {g.BUDGET} {left:g}"
)
def _dashboard_text(self) -> str: def _dashboard_text(self) -> str:
"""Build the detail panel: recent meals, then macros and protein.""" """Build the detail panel: recent meals, then macros and protein."""
lines = ["── Today ───────────────────────────────"] lines = ["────────────────────────────────────────"]
entries = today_entries() entries = today_entries()
if entries: if entries:
for entry in entries[-_DASHBOARD_ROWS:]: for entry in entries[-_DASHBOARD_ROWS:]:
@ -230,20 +226,21 @@ class _GateMealFlow(_PullFlows):
f"{entry_kcal(entry):>5.0f} kcal", f"{entry_kcal(entry):>5.0f} kcal",
) )
else: else:
lines.append(" (nothing logged yet today)") lines.append(f" {g.EMPTY}")
protein, carbs, fat = today_total_macros() protein, carbs, fat = today_total_macros()
lines.append(f" macros so far: P{protein:g} C{carbs:g} F{fat:g} g") lines.append(
f" {g.PROTEIN} {protein:g} {g.CARBS} {carbs:g} {g.FAT} {fat:g} g"
)
target = protein_target_g() target = protein_target_g()
if target is not None: if target is not None:
left = round(target - protein, 1) left = round(target - protein, 1)
lines.append(f" protein {protein:g} / {target:g} g ({left:g} g left)") lines.append(
f" {g.PROTEIN} {protein:g} / {target:g} g {g.BUDGET} {left:g} g"
)
return "\n".join(lines) return "\n".join(lines)
def on_callback_error(self) -> None: def on_callback_error(self) -> None:
"""Surface an unexpected callback error without dropping the grab.""" """Surface an unexpected callback error without dropping the grab."""
self._set_status( self._set_status(f"{g.WARN} kcal", error=True)
"Something went wrong. Enter the calories, then submit again.",
error=True,
)
with contextlib.suppress(tk.TclError): with contextlib.suppress(tk.TclError):
self._widgets.macros.kcal.focus_set() self._widgets.macros.kcal.focus_set()

View File

@ -14,6 +14,7 @@ from __future__ import annotations
import tkinter as tk import tkinter as tk
from diet_guard import _gatelock_glyphs as g
from diet_guard._budget import BudgetError, daily_budget from diet_guard._budget import BudgetError, daily_budget
from diet_guard._estimator import Nutrition, scale_nutrition from diet_guard._estimator import Nutrition, scale_nutrition
from diet_guard._gatelock_core import _GateCore from diet_guard._gatelock_core import _GateCore
@ -32,10 +33,13 @@ from diet_guard._state_today import (
def _format_preview(nutrition: Nutrition) -> str: def _format_preview(nutrition: Nutrition) -> str:
"""Render the one-line "this is what will be logged" preview.""" """Render the one-line "this is what will be logged" preview."""
portion = f" · {nutrition.grams:g}g" if nutrition.grams else "" portion = f" · {nutrition.grams:g}g" if nutrition.grams else ""
# "staple: apple" -> the staple glyph plus the food name (content).
named = nutrition.source.partition(":")[2].strip()
source = f"{g.source_glyph(nutrition.source)} {named}".rstrip()
return ( return (
f"→ {nutrition.kcal:g} kcal · " f"→ {nutrition.kcal:g} kcal · {g.PROTEIN} {nutrition.protein_g:g} "
f"P{nutrition.protein_g:g} C{nutrition.carbs_g:g} F{nutrition.fat_g:g}" f"{g.CARBS} {nutrition.carbs_g:g} {g.FAT} {nutrition.fat_g:g}"
f"{portion} · {nutrition.source}" f"{portion} · {source}"
) )
@ -98,14 +102,14 @@ class _GateNutrition(_GateCore):
try: try:
budget = daily_budget() budget = daily_budget()
except BudgetError, OSError: except BudgetError, OSError:
tail = f" · this item {this_kcal:g} kcal" if this_kcal else "" tail = f" + {this_kcal:g}" if this_kcal else ""
self._vars.projection.set(f"Consumed {consumed:g} kcal today{tail}") self._vars.projection.set(f"{consumed:g} kcal{tail}")
return return
left = round(budget - consumed, 1) left = round(budget - consumed, 1)
base = f"Consumed {consumed:g} / {budget:g} kcal · {left:g} left" base = f"{consumed:g} / {budget:g} kcal {g.BUDGET} {left:g}"
if this_kcal: if this_kcal:
after = round(budget - consumed - this_kcal, 1) after = round(budget - consumed - this_kcal, 1)
self._vars.projection.set(f"{base} → after this item: {after:g} left") self._vars.projection.set(f"{base} → {g.BUDGET} {after:g}")
else: else:
self._vars.projection.set(base) self._vars.projection.set(base)

View File

@ -23,6 +23,7 @@ import abc
import contextlib import contextlib
import tkinter as tk import tkinter as tk
from diet_guard import _gatelock_glyphs as g
from diet_guard._gatelock_budgetedit import _GateBudgetEdit from diet_guard._gatelock_budgetedit import _GateBudgetEdit
from diet_guard._gatelock_ui import ERR, FG from diet_guard._gatelock_ui import ERR, FG
from diet_guard._meal_schedule import ( from diet_guard._meal_schedule import (
@ -57,14 +58,14 @@ def schedule_problem(schedule: MealSchedule) -> str | None:
a checkpoint. a checkpoint.
""" """
first, last = schedule.first_minute, schedule.last_minute first, last = schedule.first_minute, schedule.last_minute
# Glyph cues, not sentences: the rule's numbers next to the field glyph.
if last < first + SLOT_GRID_MINUTES: if last < first + SLOT_GRID_MINUTES:
return ( return f"{g.ERROR} {g.CLOCK}{g.DASH}{g.CLOCK} ≥ {SLOT_GRID_MINUTES} min"
f"The last meal must be at least {SLOT_GRID_MINUTES} min after the first."
)
if not MIN_MEAL_COUNT <= schedule.count <= MAX_MEAL_COUNT: if not MIN_MEAL_COUNT <= schedule.count <= MAX_MEAL_COUNT:
return f"Meals per day must be {MIN_MEAL_COUNT}-{MAX_MEAL_COUNT}." return f"{g.ERROR} {g.TIMES} {MIN_MEAL_COUNT}{g.DASH}{MAX_MEAL_COUNT}"
if schedule.count > (last - first) // SLOT_GRID_MINUTES + 1: capacity = (last - first) // SLOT_GRID_MINUTES + 1
return "Too many meals for that window -- widen it or eat less often." if schedule.count > capacity:
return f"{g.ERROR} {g.TIMES} ≤ {capacity}"
return None return None
@ -127,14 +128,14 @@ class _GateScheduleEdit(_GateBudgetEdit):
if not self._cal_editing_schedule: if not self._cal_editing_schedule:
self._cal_editing_schedule = True self._cal_editing_schedule = True
self._set_schedule_entry_state("normal") self._set_schedule_entry_state("normal")
self._set_schedule_button_text("Save") self._set_schedule_button_text(g.SAVE)
self._set_schedule_status("", error=False) self._set_schedule_status("", error=False)
return return
if not self._save_schedule_entry(): if not self._save_schedule_entry():
return return
self._cal_editing_schedule = False self._cal_editing_schedule = False
self._set_schedule_entry_state("readonly") self._set_schedule_entry_state("readonly")
self._set_schedule_button_text("Edit") self._set_schedule_button_text(g.EDIT)
self._refresh_calendar() self._refresh_calendar()
def _read_schedule_fields(self) -> MealSchedule | None: def _read_schedule_fields(self) -> MealSchedule | None:
@ -143,14 +144,12 @@ class _GateScheduleEdit(_GateBudgetEdit):
first = parse_hhmm(self._cal_vars.schedule.first.get()) first = parse_hhmm(self._cal_vars.schedule.first.get())
last = parse_hhmm(self._cal_vars.schedule.last.get()) last = parse_hhmm(self._cal_vars.schedule.last.get())
except ValueError: except ValueError:
self._set_schedule_status( self._set_schedule_status(f"{g.ERROR} {g.CLOCK}", error=True)
"Enter meal times as HH:MM, e.g. 07:15.", error=True
)
return None return None
try: try:
count = int(self._cal_vars.schedule.count.get().strip()) count = int(self._cal_vars.schedule.count.get().strip())
except ValueError: except ValueError:
self._set_schedule_status("Enter meals per day as a number.", error=True) self._set_schedule_status(f"{g.ERROR} {g.TIMES}", error=True)
return None return None
return MealSchedule(first, last, count) return MealSchedule(first, last, count)
@ -169,5 +168,5 @@ class _GateScheduleEdit(_GateBudgetEdit):
return False return False
record_schedule_change(schedule) record_schedule_change(schedule)
self._show_schedule(current_schedule()) self._show_schedule(current_schedule())
self._set_schedule_status("Saved.", error=False) self._set_schedule_status(g.OK, error=False)
return True return True

View File

@ -26,6 +26,7 @@ from gatelock import (
) )
from diet_guard._gatelock_fillall import FILL_LABEL from diet_guard._gatelock_fillall import FILL_LABEL
from diet_guard._gatelock_glyphs import TIMES
from diet_guard._gatelock_spacing import SM, XS from diet_guard._gatelock_spacing import SM, XS
from diet_guard._gatelock_typography import ( from diet_guard._gatelock_typography import (
BODY, BODY,
@ -60,8 +61,8 @@ DEFAULT_PER_GRAMS = 100.0
UNIT_GRAMS = "grams" UNIT_GRAMS = "grams"
UNIT_ITEMS = "items" UNIT_ITEMS = "items"
# Per-basis label prefixes for the two measuring modes. # Per-basis label prefixes for the two measuring modes.
BASIS_PREFIX_GRAMS = "Nutrition as on the label — per" BASIS_PREFIX_GRAMS = "/"
BASIS_PREFIX_ITEMS = "Nutrition per 1 item ≈" BASIS_PREFIX_ITEMS = f"{TIMES}1 ≈"
# Wrap width for the gate's prose labels, in px. 640 is tokens.md's line-length # Wrap width for the gate's prose labels, in px. 640 is tokens.md's line-length
# cap (rule 21, 40rem / ~65-70 characters). This was 900 at five call sites, # cap (rule 21, 40rem / ~65-70 characters). This was 900 at five call sites,
# which both exceeded the cap and -- more practically -- forced a 900px-wide # which both exceeded the cap and -- more practically -- forced a 900px-wide
@ -137,12 +138,16 @@ def _numeric_entry(
def _macro_cell( def _macro_cell(
root: tk.Misc, row: tk.Frame, label: str, variable: tk.StringVar root: tk.Misc,
row: tk.Frame,
label: str,
variable: tk.StringVar,
color: str = FG,
) -> tk.Entry: ) -> tk.Entry:
"""Pack one small labelled numeric entry into the macro row.""" """Pack one numeric entry under its glyph (or unit) into the macro row."""
cell = tk.Frame(row, bg=BG) cell = tk.Frame(row, bg=BG)
cell.pack(side="left", padx=SM) cell.pack(side="left", padx=SM)
tk.Label(cell, text=label, font=(_FONT, CAPTION), bg=BG, fg=FG).pack() tk.Label(cell, text=label, font=(_FONT, CAPTION), bg=BG, fg=color).pack()
entry = _numeric_entry(root, cell, width=7, variable=variable) entry = _numeric_entry(root, cell, width=7, variable=variable)
entry.pack(ipady=XS) entry.pack(ipady=XS)
return entry return entry

View File

@ -4,6 +4,7 @@ from __future__ import annotations
from datetime import date from datetime import date
from diet_guard import _gatelock_glyphs as g
from diet_guard._budget_history import EPOCH_DAY, BudgetEntry, BudgetSchedule from diet_guard._budget_history import EPOCH_DAY, BudgetEntry, BudgetSchedule
from diet_guard._calendar_view import averages_text from diet_guard._calendar_view import averages_text
@ -15,40 +16,40 @@ def _flat(budget: int) -> BudgetSchedule:
) )
_WEEK = f"{g.AVERAGE} {g.SPAN['week']} "
_MONTH = f"{g.AVERAGE} {g.SPAN['month']} "
class TestAveragesText: class TestAveragesText:
def test_names_both_periods(self) -> None: def test_names_both_periods(self) -> None:
text = averages_text({}, schedule=_flat(2000), today=date(2026, 1, 14)) text = averages_text({}, schedule=_flat(2000), today=date(2026, 1, 14))
assert "week" in text assert _WEEK in text
assert "month" in text assert _MONTH in text
def test_says_it_stops_at_yesterday(self) -> None: def test_empty_log_reads_no_value_not_zero(self) -> None:
text = averages_text({}, schedule=_flat(2000), today=date(2026, 1, 14)) text = averages_text({}, schedule=_flat(2000), today=date(2026, 1, 14))
assert "to yesterday" in text assert text == f"{_WEEK}{g.NO_VALUE} {_MONTH}{g.NO_VALUE}"
def test_empty_log_reads_no_data_not_zero(self) -> None:
text = averages_text({}, schedule=_flat(2000), today=date(2026, 1, 14))
assert "no data" in text
assert "0 kcal" not in text assert "0 kcal" not in text
def test_renders_the_average_and_its_band(self) -> None: def test_renders_the_average_and_its_band(self) -> None:
# Mon-Tue of the week containing Wed 2026-01-14, at 3000 kcal each # Mon-Tue of the week containing Wed 2026-01-14, at 3000 kcal each
# against a 2000 budget: 150% of budget, comfortably "very over". # against a 2000 budget: 150% of budget, comfortably very over.
log = { log = {
"2026-01-12": [{"kcal": 3000.0}], "2026-01-12": [{"kcal": 3000.0}],
"2026-01-13": [{"kcal": 3000.0}], "2026-01-13": [{"kcal": 3000.0}],
} }
text = averages_text(log, schedule=_flat(2000), today=date(2026, 1, 14)) text = averages_text(log, schedule=_flat(2000), today=date(2026, 1, 14))
assert "week 3000 kcal (very over)" in text assert f"{_WEEK}3000 kcal {g.BAND['very_over']}" in text
def test_under_budget_reads_under(self) -> None: def test_under_budget_band(self) -> None:
log = {"2026-01-12": [{"kcal": 1500.0}]} log = {"2026-01-12": [{"kcal": 1500.0}]}
text = averages_text(log, schedule=_flat(2000), today=date(2026, 1, 14)) text = averages_text(log, schedule=_flat(2000), today=date(2026, 1, 14))
assert "week 1500 kcal (under)" in text assert f"{_WEEK}1500 kcal {g.BAND['under']}" in text
def test_slightly_over_reads_slightly_over(self) -> None: def test_slightly_over_band(self) -> None:
log = {"2026-01-12": [{"kcal": 2200.0}]} log = {"2026-01-12": [{"kcal": 2200.0}]}
text = averages_text(log, schedule=_flat(2000), today=date(2026, 1, 14)) text = averages_text(log, schedule=_flat(2000), today=date(2026, 1, 14))
assert "week 2200 kcal (slightly over)" in text assert f"{_WEEK}2200 kcal {g.BAND['slightly_over']}" in text
def test_today_is_not_averaged_in(self) -> None: def test_today_is_not_averaged_in(self) -> None:
log = {"2026-01-14": [{"kcal": 9000.0}]} log = {"2026-01-14": [{"kcal": 9000.0}]}
@ -56,4 +57,4 @@ class TestAveragesText:
assert "9000" not in text assert "9000" not in text
def test_defaults_to_the_real_today(self) -> None: def test_defaults_to_the_real_today(self) -> None:
assert "week" in averages_text({}, schedule=_flat(2000)) assert _WEEK in averages_text({}, schedule=_flat(2000))

View File

@ -4,6 +4,7 @@ from __future__ import annotations
from datetime import date from datetime import date
from diet_guard import _gatelock_glyphs as g
from diet_guard._calendar_view import ( from diet_guard._calendar_view import (
CalendarCell, CalendarCell,
build_month_cells, build_month_cells,
@ -103,23 +104,22 @@ class TestCellStyle:
class TestStreaksText: class TestStreaksText:
""":func:`streaks_text` boundary cases (singular/plural, zero).""" """:func:`streaks_text` boundary cases (zero, one)."""
def test_zero_streaks_render_as_zero_days(self) -> None: def test_zero_streaks_render_as_zeros(self) -> None:
text = streaks_text({}, today=date(2026, 7, 13)) text = streaks_text({}, today=date(2026, 7, 13))
assert "0 days" in text assert text == f"{g.FOOD} 0 {g.OK} 0"
def test_a_one_day_streak_uses_the_singular(self) -> None: def test_a_one_day_streak(self) -> None:
text = streaks_text( text = streaks_text(
{"2026-07-13": DayStatus.GREEN}, {"2026-07-13": DayStatus.GREEN},
today=date(2026, 7, 13), today=date(2026, 7, 13),
) )
assert "Logging streak: 1 day " in text assert text == f"{g.FOOD} 1 {g.OK} 1"
assert "Adherence streak: 1 day" in text
class TestYtdText: class TestYtdText:
""":func:`ytd_text` renders the tally's three counts.""" """:func:`ytd_text` renders the tally's counts."""
def test_reports_logged_elapsed_and_adherent_counts(self) -> None: def test_reports_logged_elapsed_and_adherent_counts(self) -> None:
status_map_ = { status_map_ = {
@ -127,5 +127,4 @@ class TestYtdText:
"2026-01-02": DayStatus.RED, "2026-01-02": DayStatus.RED,
} }
text = ytd_text(status_map_, today=date(2026, 1, 10)) text = ytd_text(status_map_, today=date(2026, 1, 10))
assert "logged 2/10 days" in text assert text == f"{g.YEAR_TOTAL} {g.FOOD} 2/10 {g.OK} 1"
assert "1 within budget" in text

View File

@ -0,0 +1,191 @@
"""Guard: nothing the PC gate paints carries a word (kuhy's textgate rule).
Every Tk surface of the gate -- Log Meal, History and Body tabs, their status
lines, buttons, notebook tabs and canvas captions -- must show glyphs, numbers
and unit symbols only. Text the user wrote (typed entries, logged food names)
is content and stays; the fake ``Entry``/``Text``/``Listbox`` widgets that hold
it are not recorded at all.
The recorder hooks the fake widgets themselves, so it sees what the gate
*built* and what it *later set*: ``text=`` at construction, ``config(text=)``
afterwards, every value written into a variable some widget displays, the
values of an ``OptionMenu``, notebook tab titles and canvas ``create_text``.
It fails closed: a run that recorded too little (a module escaped the fake tk)
is red, and a canary proves one injected word turns the check red.
"""
from __future__ import annotations
from contextlib import contextmanager
import re
from typing import TYPE_CHECKING
from unittest.mock import patch
from diet_guard._budget import write_budget
from diet_guard._gatelock import MealGate
from diet_guard._state import log_meal
from diet_guard.tests._gate_fixtures import fake_tk
from diet_guard.tests._tk_fakes import FakeCanvas, FakeNotebook, FakeVar, FakeWidget
from diet_guard.tests.conftest import _nutrition
if TYPE_CHECKING:
from collections.abc import Iterator
# Unit symbols next to a number, and the one method acronym kuhy kept (DOUBT
# rows in the rework's keep.md). Anything else of 2+ letters is a word.
UNITS = frozenset({"kcal", "kg", "lb", "st", "cm", "km", "min", "MET"})
# Food names logged by the scenario below: user content, shown on purpose.
CONTENT = frozenset({"apple"})
WORD = re.compile(r"[^\W\d_]{2,}")
def words_in(text: object) -> list[str]:
"""Every 2+ letter word in ``text`` that is neither a unit nor content."""
return [w for w in WORD.findall(str(text)) if w not in UNITS | CONTENT]
class Recorder:
"""Collects every string the fake widgets would paint."""
def __init__(self) -> None:
self.widgets: list[FakeWidget] = []
self.shown_vars: list[FakeVar] = []
self.var_history: dict[int, list[str]] = {}
self.tabs: list[str] = []
self.canvas_text: list[str] = []
self.menu_values: list[str] = []
def texts(self) -> list[str]:
"""All painted strings, history included (a status shown once counts)."""
out = [str(w.configured.get("text", "")) for w in self.widgets]
for var in self.shown_vars:
out.extend(self.var_history.get(id(var), [var.get()]))
return [*out, *self.tabs, *self.canvas_text, *self.menu_values]
def offenders(self) -> list[str]:
"""The painted strings that hold a word."""
return sorted({t for t in self.texts() if words_in(t)})
@contextmanager
def recording() -> Iterator[Recorder]:
"""Hook the fake widgets so every painted string lands in a recorder."""
rec = Recorder()
widget_init, var_init, var_set = (
FakeWidget.__init__,
FakeVar.__init__,
FakeVar.set,
)
tab_add, canvas_text = FakeNotebook.add, FakeCanvas.create_text
def init(self: FakeWidget, *args: object, **kwargs: object) -> None:
widget_init(self, *args, **kwargs)
rec.widgets.append(self)
shown = kwargs.get("textvariable")
if isinstance(shown, FakeVar):
rec.shown_vars.append(shown)
if len(args) > 1 and isinstance(args[1], FakeVar): # tk.OptionMenu
rec.shown_vars.append(args[1])
rec.menu_values.extend(str(v) for v in args[2:])
def vinit(self: FakeVar, master: object = None, value: str = "") -> None:
var_init(self, master, value)
rec.var_history[id(self)] = [value]
def vset(self: FakeVar, value: str) -> None:
var_set(self, value)
rec.var_history.setdefault(id(self), []).append(str(value))
def add(self: FakeNotebook, child: object, *, text: str = "") -> None:
tab_add(self, child, text=text)
rec.tabs.append(text)
def ctext(self: FakeCanvas, *args: object, **kwargs: object) -> int:
rec.canvas_text.append(str(kwargs.get("text", "")))
return canvas_text(self, *args, **kwargs)
with (
patch.object(FakeWidget, "__init__", init),
patch.object(FakeVar, "__init__", vinit),
patch.object(FakeVar, "set", vset),
patch.object(FakeNotebook, "add", add),
patch.object(FakeCanvas, "create_text", ctext),
):
yield rec
def _drive(gate: MealGate) -> None:
"""Walk every user-reachable state that writes a status or caption."""
gate._on_submit() # nothing typed
gate._set_desc("apple")
gate._set_entry(gate._widgets.macros.protein, "x")
gate._on_submit() # macros not numbers
gate._set_entry(gate._widgets.macros.protein, "")
gate._on_submit() # kcal missing
for click in (gate._on_fill_all, gate._on_fetch_sync, gate._on_load_delivery):
click() # demo refusals
write_budget(2000, weight_kg=80)
log_meal("apple", _nutrition(95, 100), 8)
gate._pending = [480, 720]
gate._refresh_slot_header()
gate._pending = []
gate._refresh_slot_header()
gate._refresh_dashboard()
gate._refresh_projection()
gate._refresh_calendar()
gate._on_prev_month()
gate._on_next_month()
for value in ("x", "0", "1900"):
gate._on_edit_or_save_budget()
gate._cal_vars.budget.set(value)
gate._on_edit_or_save_budget()
gate._on_edit_or_save_schedule()
gate._cal_vars.schedule.count.set("x")
gate._on_edit_or_save_schedule()
gate._cal_vars.schedule.count.set("4")
gate._on_edit_or_save_schedule()
def _drive_body(gate: MealGate) -> None:
"""The Body tab's rejects, graph ranges and a saved reading."""
tab = gate._body_tab
tab.save_profile()
tab.vars.logs.weight.set("5")
tab.save_weight()
tab.vars.logs.weight_day.set("nope")
tab.delete_weight()
tab.vars.logs.body_fat.set("90")
tab.save_body_fat()
tab.delete_body_fat()
tab.set_range(30)
tab.vars.profile.birth.set("2000-05-22")
tab.vars.profile.height.set("169")
tab.vars.profile.sex.set("m")
tab.save_profile()
tab.vars.logs.weight.set("72.5")
tab.vars.logs.weight_day.set("2026-09-10")
tab.save_weight()
tab.set_range(None)
tab.goal_changed()
def test_the_gate_paints_no_words() -> None:
"""Build and drive every tab; no painted string may hold a word."""
with recording() as rec, fake_tk():
built = MealGate(demo_mode=True)
_drive(built)
_drive_body(built)
assert len(rec.tabs) >= 3, "the notebook tabs were not recorded"
assert len(rec.widgets) > 40, "too few widgets recorded: a module escaped the fake"
assert rec.canvas_text, "the Body graph drew nothing"
offenders = rec.offenders()
assert not offenders, "\n" + "\n".join(map(repr, offenders))
def test_the_guard_catches_an_injected_word() -> None:
"""Canary: the checker is red on a single word, and quiet on units/content."""
with recording() as rec:
FakeWidget(None, text="Save")
FakeWidget(None, text="✓ 1900 kcal apple")
assert rec.offenders() == ["Save"]
assert words_in("08:00 · 2/4 72.5 kg") == []

View File

@ -0,0 +1,74 @@
"""Static twin of ``test_gate_no_words``: no gate source line can paint a word.
The fake-tk guard only sees strings on the paths a scenario drives. This one
parses every ``_gatelock*.py`` module and fails on any string literal handed
to a painted keyword (``text=``, ``notebook.add(..., text=)``) or written into
a status variable that holds a 2+ letter word, so a new sentence on an
unexercised branch cannot slip in. Runs in CI (no display, no installed gate).
"""
from __future__ import annotations
import ast
from pathlib import Path
import re
from diet_guard.tests.test_gate_no_words import UNITS
PACKAGE = Path(__file__).resolve().parent.parent
WORD = re.compile(r"[^\W\d_]{2,}")
# The window title is the OS-level app name: the one kept proper noun.
APP_NAME = "Diet Gate"
# Variable-backed text sinks: StringVar writes and status setters.
SINKS = {"set", "_set_status", "_set_budget_status", "_set_schedule_status"}
def _literals(node: ast.AST) -> list[str]:
"""Every constant string inside ``node`` (f-string parts included)."""
return [
n.value
for n in ast.walk(node)
if isinstance(n, ast.Constant) and isinstance(n.value, str)
]
def _offences(path: Path) -> list[str]:
tree = ast.parse(path.read_text(encoding="utf-8"))
found: list[str] = []
for call in (n for n in ast.walk(tree) if isinstance(n, ast.Call)):
painted: list[ast.AST] = [k.value for k in call.keywords if k.arg == "text"]
name = getattr(call.func, "attr", getattr(call.func, "id", ""))
if name in SINKS:
painted.extend(call.args[:1])
if name == "title":
continue # the window title: APP_NAME, checked below
for node in painted:
found.extend(
f"{path.name}:{call.lineno} {lit!r}"
for lit in _literals(node)
if set(WORD.findall(lit)) - UNITS
)
return found
def test_no_gate_source_paints_a_word() -> None:
paths = sorted(PACKAGE.glob("_gatelock*.py"))
assert len(paths) > 20, "the gate modules were not found"
offences = [o for path in paths for o in _offences(path)]
assert not offences, "\n" + "\n".join(offences)
def test_the_checker_flags_a_sentence(tmp_path: Path) -> None:
"""Canary: a status sentence and a ``text=`` word are both caught."""
bad = tmp_path / "_gatelock_x.py"
bad.write_text(
'def f(v, w):\n v.set("Saved.")\n w.Label(text="Edit")\n'
' v.set("OK")\n',
encoding="utf-8",
)
assert len(_offences(bad)) == 3 # "OK" is two letters: a word too
def test_the_window_title_is_the_app_name() -> None:
source = (PACKAGE / "_gatelock.py").read_text(encoding="utf-8")
assert f'self.root.title("{APP_NAME}")' in source

View File

@ -19,6 +19,7 @@ from diet_guard import (
_gatelock, _gatelock,
_gatelock_support, _gatelock_support,
) )
from diet_guard import _gatelock_glyphs as g
from diet_guard._gatelock import ( from diet_guard._gatelock import (
MealGate, MealGate,
_pending_slots, _pending_slots,
@ -162,7 +163,7 @@ class TestLockDelegation:
def test_callback_error_status(self, gate: MealGate) -> None: def test_callback_error_status(self, gate: MealGate) -> None:
"""An unexpected callback error surfaces a recoverable message.""" """An unexpected callback error surfaces a recoverable message."""
gate.on_callback_error() gate.on_callback_error()
assert "went wrong" in gate._vars.status.get() assert gate._vars.status.get() == f"{g.WARN} kcal"
def test_run_delegates_to_lock(self, gate: MealGate) -> None: def test_run_delegates_to_lock(self, gate: MealGate) -> None:
"""run() hands off to the owned LockWindow.""" """run() hands off to the owned LockWindow."""

View File

@ -12,6 +12,7 @@ from unittest.mock import patch
import pytest import pytest
from diet_guard import _gatelock_body from diet_guard import _gatelock_body
from diet_guard import _gatelock_glyphs as g
from diet_guard._body_fat_store import body_fats from diet_guard._body_fat_store import body_fats
from diet_guard._body_prefs import saved_goal from diet_guard._body_prefs import saved_goal
from diet_guard._body_store import profile, set_profile, set_weight, weights from diet_guard._body_store import profile, set_profile, set_weight, weights
@ -60,20 +61,20 @@ class TestProfile:
def test_rejects_then_saves(self, tab: BodyTab) -> None: def test_rejects_then_saves(self, tab: BodyTab) -> None:
tab.vars.profile.height.set("9") tab.vars.profile.height.set("9")
tab.save_profile() tab.save_profile()
assert tab.vars.status.get().startswith("Profile needs") assert tab.vars.status.get().startswith(g.ERROR)
tab.vars.profile.birth.set("2000-05-22") tab.vars.profile.birth.set("2000-05-22")
tab.vars.profile.height.set("169") tab.vars.profile.height.set("169")
tab.vars.profile.sex.set("m") tab.vars.profile.sex.set("m")
tab.save_profile() tab.save_profile()
assert profile().complete assert profile().complete
assert tab.vars.status.get().startswith("Profile saved") assert tab.vars.status.get() == g.OK
def test_complete_profile_loads_into_the_inputs(self) -> None: def test_complete_profile_loads_into_the_inputs(self) -> None:
_person() _person()
with fake_tk(): with fake_tk():
body = BodyTab(FakeNotebook()) body = BodyTab(FakeNotebook())
assert body.vars.profile.height.get() == "169" assert body.vars.profile.height.get() == "169"
assert body.vars.goal.answer.get().startswith("eat ") assert body.vars.goal.answer.get().startswith("→ ")
class TestWeights: class TestWeights:
@ -81,7 +82,7 @@ class TestWeights:
write_budget(2000, weight_kg=90.0) write_budget(2000, weight_kg=90.0)
tab.vars.logs.weight.set("5") tab.vars.logs.weight.set("5")
tab.save_weight() tab.save_weight()
assert tab.vars.status.get().startswith("Weight needs") assert tab.vars.status.get().startswith(g.ERROR)
tab.vars.logs.weight.set("80") tab.vars.logs.weight.set("80")
tab.vars.logs.weight_day.set("2026-09-10") tab.vars.logs.weight_day.set("2026-09-10")
tab.save_weight() tab.save_weight()
@ -89,7 +90,7 @@ class TestWeights:
assert (read_raw_record() or {})["w"] == 80.0 assert (read_raw_record() or {})["w"] == 80.0
tab.vars.logs.weight_day.set("nope") tab.vars.logs.weight_day.set("nope")
tab.delete_weight() tab.delete_weight()
assert tab.vars.status.get().startswith("Type the") assert tab.vars.status.get() == g.ERROR + " " + g.DELETE + " 0000-00-00"
tab.vars.logs.weight_day.set("2026-09-10") tab.vars.logs.weight_day.set("2026-09-10")
tab.delete_weight() tab.delete_weight()
assert weights() == {} assert weights() == {}
@ -97,13 +98,13 @@ class TestWeights:
def test_body_fat_save_reject_delete(self, tab: BodyTab) -> None: def test_body_fat_save_reject_delete(self, tab: BodyTab) -> None:
tab.vars.logs.body_fat.set("90") tab.vars.logs.body_fat.set("90")
tab.save_body_fat() tab.save_body_fat()
assert tab.vars.status.get().startswith("Body fat needs") assert tab.vars.status.get().startswith(g.ERROR)
tab.vars.logs.body_fat.set("17") tab.vars.logs.body_fat.set("17")
tab.save_body_fat() tab.save_body_fat()
assert body_fats() == {tab.vars.logs.fat_day.get(): 17.0} assert body_fats() == {tab.vars.logs.fat_day.get(): 17.0}
tab.vars.logs.fat_day.set("x") tab.vars.logs.fat_day.set("x")
tab.delete_body_fat() tab.delete_body_fat()
assert tab.vars.status.get().startswith("Type the") assert tab.vars.status.get() == g.ERROR + " " + g.DELETE + " 0000-00-00"
tab.vars.logs.fat_day.set(next(iter(body_fats()))) tab.vars.logs.fat_day.set(next(iter(body_fats())))
tab.delete_body_fat() tab.delete_body_fat()
assert body_fats() == {} assert body_fats() == {}
@ -118,7 +119,7 @@ class TestGoalAndGraph:
assert saved_goal().weight_unit == "lb" assert saved_goal().weight_unit == "lb"
assert saved_goal().amount == 0.5 # unreadable amount kept assert saved_goal().amount == 0.5 # unreadable amount kept
assert tab.vars.goal.amount.get() == "0.5" assert tab.vars.goal.amount.get() == "0.5"
assert tab.vars.goal.answer.get().startswith("eat ") assert tab.vars.goal.answer.get().startswith("→ ")
def test_range_and_body_fat_graph(self, tab: BodyTab) -> None: def test_range_and_body_fat_graph(self, tab: BodyTab) -> None:
_person() _person()
@ -127,7 +128,7 @@ class TestGoalAndGraph:
tab.set_range(None) tab.set_range(None)
tab.vars.logs.graph.set("fat") tab.vars.logs.graph.set("fat")
tab.refresh() tab.refresh()
assert "Mon" in tab.vars.blocks["plan"].get() assert f"2500 {g.EDIT}" in tab.vars.blocks["plan"].get()
class TestGraph: class TestGraph:
@ -150,6 +151,7 @@ class TestGraph:
box = GraphBox(700, 300) box = GraphBox(700, 300)
draw_weight_graph(canvas, {}, box, None) draw_weight_graph(canvas, {}, box, None)
assert canvas.drawn[0][0] == "text" assert canvas.drawn[0][0] == "text"
assert canvas.drawn[0][2]["text"] == g.EMPTY
log = {"2026-09-20": 74.0, "2026-09-27": 72.0} log = {"2026-09-20": 74.0, "2026-09-27": 72.0}
draw_weight_graph(canvas, log, box, (52.8, 71.1)) draw_weight_graph(canvas, log, box, (52.8, 71.1))
kinds = [kind for kind, _, _ in canvas.drawn] kinds = [kind for kind, _, _ in canvas.drawn]

View File

@ -0,0 +1,166 @@
"""The gate's glyph twins of the Body report blocks, and the glyph table."""
from __future__ import annotations
from dataclasses import replace
from datetime import date
import pytest
from diet_guard import _gatelock_glyphs as g
from diet_guard._activity_kcal import Session
from diet_guard._body_activity_store import set_activity
from diet_guard._body_fat_store import set_body_fat
from diet_guard._body_prefs import goal_from_json
from diet_guard._body_store import read_body, set_profile, set_weight, write_body
from diet_guard._body_view import build_view
from diet_guard._gatelock_body_glyphs import (
ACTIVITY,
_band,
activity_glyphs,
bmi_glyphs,
bmr_glyphs,
bodyfat_glyphs,
ideal_glyphs,
plan_glyphs,
profile_glyphs,
target_glyphs,
)
from diet_guard._week_plan import plan_week
_TODAY = date(2026, 9, 28)
def _person() -> None:
set_profile(birth="2000-05-22", height_cm=169.0, sex="m")
set_weight("2026-09-27", 72.6)
class TestEmptyState:
def test_every_block_has_a_placeholder_not_a_sentence(self) -> None:
view = build_view(_TODAY)
assert profile_glyphs(view, _TODAY) == [
f"{g.BIRTH} {g.NO_VALUE} {g.NO_VALUE} cm {g.NO_VALUE}",
f"{g.AMOUNT} {g.NO_VALUE} kg",
]
assert bodyfat_glyphs(view) == [f"% {g.NO_VALUE}"]
assert bmi_glyphs(view) == [f"{g.TAB_BODY} ?"]
assert ideal_glyphs(view) == [f"{g.IDEAL} ?"]
assert bmr_glyphs(view) == [f"{g.RESTING} ?"]
assert target_glyphs(view) == ["?"]
assert activity_glyphs(view) == [f"{g.MEASURED} {g.EMPTY}"]
class TestWithData:
def test_profile_bmi_ideal_and_bmr(self) -> None:
_person()
set_body_fat("2026-09-27", 18.5)
view = build_view(_TODAY)
assert profile_glyphs(view, _TODAY)[0].startswith(f"{g.BIRTH} 2000-05-22 (26)")
assert profile_glyphs(view, _TODAY)[1].startswith(f"{g.AMOUNT} 72.6 kg")
assert bodyfat_glyphs(view)[0].startswith("18.5%")
assert bmi_glyphs(view)[0].startswith(f"{g.TAB_BODY} 25.4 △")
assert ideal_glyphs(view)[0].startswith(f"{g.IDEAL} ")
assert bmr_glyphs(view)[0].startswith(f"{g.RESTING} ")
def test_target_and_the_floor_warning(self) -> None:
_person()
view = build_view(_TODAY)
assert view.numbers is not None
assert target_glyphs(view)[0].startswith("= ")
low = replace(
view,
numbers=replace(
view.numbers,
target=replace(view.numbers.target, kcal=900),
),
)
assert target_glyphs(low)[-1].startswith(g.WARN)
def test_an_unanswered_measured_goal_shows_the_empty_mark(self) -> None:
_person()
goal = goal_from_json({"act": "measured:MET"})
view = build_view(_TODAY, goal)
assert target_glyphs(view) == [f"{g.MEASURED} {g.EMPTY}"]
def test_activity_means_steps_and_sessions(self) -> None:
_person()
set_activity(
(
Session("2026-09-22", "run", 60.0, 6.0, "Run", "runnerup"),
Session("2026-09-25", "strength", 80.0, None, "SL", "screen-locker"),
)
)
doc = read_body()
doc["steps"] = {"2026-09-26": {"n": 9000, "t": "2026-09-26T22:00:00+02:00"}}
write_body(doc)
lines = activity_glyphs(build_view(_TODAY))
assert lines[0].startswith(g.SYNC)
assert lines[1].startswith(f"{g.AVERAGE} 6")
assert lines[2].startswith(f"{g.STEPS} 9000")
assert any(" run " in line and "6.0 km" in line for line in lines)
assert any("MET" in line and "↗" in line and "/km" in line for line in lines)
def test_sessions_without_a_weight_carry_no_kcal(self) -> None:
set_activity((Session("2026-09-22", "run", 60.0, None, "Run", "runnerup"),))
assert activity_glyphs(build_view(_TODAY))[1].endswith("min")
def test_steps_alone_have_a_dash_for_the_stamp(self) -> None:
_person()
doc = read_body()
doc["steps"] = {"2026-09-27": {"n": 3000, "t": "2026-09-27T22:00:00+02:00"}}
write_body(doc)
assert activity_glyphs(build_view(_TODAY))[0] == f"{g.SYNC} {g.NO_VALUE}"
class TestBand:
@pytest.mark.parametrize(
("category", "glyph"),
[
("severe thinness", "▽"),
("normal", g.OK),
("overweight", "△"),
("obese class II", "▲"),
("adult categories do not apply under 20", g.NO_VALUE),
],
)
def test_each_who_band_has_its_own_shape(self, category: str, glyph: str) -> None:
assert _band(category) == glyph
class TestPlan:
def test_sources_and_the_remaining_line(self) -> None:
low = plan_week(date(2026, 10, 1), 1500, {"2026-09-28": 5000}, {})
lines = plan_glyphs(low)
assert lines[0] == f"{g.AVERAGE} 1500 kcal"
assert g.EDIT not in lines[1]
assert "?" in lines[2]
assert lines[-1].endswith(g.WARN)
def test_a_fully_known_week_reports_its_average(self) -> None:
full = plan_week(date(2026, 10, 4), 1800, {}, dict.fromkeys(range(7), 1900))
assert plan_glyphs(full)[-1] == f"{g.OK} {g.AVERAGE} 1900 kcal"
def test_the_remaining_line_without_a_warning(self) -> None:
plan = plan_week(date(2026, 10, 1), 2200, {}, {})
assert plan_glyphs(plan)[-1].startswith("→")
assert g.WARN not in plan_glyphs(plan)[-1]
class TestGlyphTable:
def test_activity_picker_covers_every_choice(self) -> None:
assert ACTIVITY["sedentary"] == "▁"
assert ACTIVITY["measured:ACSM"] == f"{g.MEASURED} ↗"
@pytest.mark.parametrize(
("source", "glyph"),
[
("food bank", "▤"),
("staple: apple", "★"),
("catering", g.DELIVERY),
("manual", g.FOOD),
("openfoodfacts", "≈"),
],
)
def test_every_nutrition_source_has_a_glyph(self, source: str, glyph: str) -> None:
assert g.source_glyph(source) == glyph

View File

@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
from unittest.mock import patch from unittest.mock import patch
from diet_guard import _budget from diet_guard import _budget
from diet_guard import _gatelock_glyphs as g
from diet_guard._budget import daily_budget, write_budget from diet_guard._budget import daily_budget, write_budget
from diet_guard._budget_derived import budget_weight from diet_guard._budget_derived import budget_weight
from diet_guard._state import log_meal from diet_guard._state import log_meal
@ -21,7 +22,7 @@ class TestNotebookWiring:
def test_builds_a_notebook_with_all_three_tabs(self, gate: MealGate) -> None: def test_builds_a_notebook_with_all_three_tabs(self, gate: MealGate) -> None:
labels = [text for _child, text in gate._notebook.tabs] labels = [text for _child, text in gate._notebook.tabs]
assert labels == ["Log Meal", "History", "Body"] assert labels == [g.TAB_LOG, g.TAB_HISTORY, g.TAB_BODY]
def test_calendar_state_starts_on_the_current_month(self, gate: MealGate) -> None: def test_calendar_state_starts_on_the_current_month(self, gate: MealGate) -> None:
assert 1 <= gate._cal_month <= 12 assert 1 <= gate._cal_month <= 12
@ -64,8 +65,8 @@ class TestBudgetDefaultAndDegradation:
gate: MealGate, gate: MealGate,
) -> None: ) -> None:
gate._refresh_calendar() gate._refresh_calendar()
assert "Logging streak" in gate._cal_vars.streaks.get() assert gate._cal_vars.streaks.get().startswith(g.FOOD)
assert "This year" in gate._cal_vars.ytd.get() assert gate._cal_vars.ytd.get().startswith(g.YEAR_TOTAL)
def test_no_budget_set_shows_2200_in_the_entry(self, gate: MealGate) -> None: def test_no_budget_set_shows_2200_in_the_entry(self, gate: MealGate) -> None:
gate._refresh_calendar() gate._refresh_calendar()
@ -75,7 +76,7 @@ class TestBudgetDefaultAndDegradation:
assert gate._cal_widgets.budget_entry.configured.get("state") == "readonly" assert gate._cal_widgets.budget_entry.configured.get("state") == "readonly"
def test_edit_button_starts_labelled_edit(self, gate: MealGate) -> None: def test_edit_button_starts_labelled_edit(self, gate: MealGate) -> None:
assert gate._cal_widgets.budget_edit_button.configured.get("text") == "Edit" assert gate._cal_widgets.budget_edit_button.configured.get("text") == g.EDIT
def test_no_budget_set_still_renders_every_grid_cell( def test_no_budget_set_still_renders_every_grid_cell(
self, self,
@ -90,7 +91,7 @@ class TestBudgetDefaultAndDegradation:
log_meal("oatmeal", _nutrition(kcal=300), None) log_meal("oatmeal", _nutrition(kcal=300), None)
gate._refresh_calendar() gate._refresh_calendar()
assert gate._cal_vars.budget.get() == "1800" assert gate._cal_vars.budget.get() == "1800"
assert "Logging streak" in gate._cal_vars.streaks.get() assert gate._cal_vars.streaks.get().startswith(g.FOOD)
def test_a_corrupt_budget_file_degrades_with_an_error_message( def test_a_corrupt_budget_file_degrades_with_an_error_message(
self, self,
@ -98,7 +99,7 @@ class TestBudgetDefaultAndDegradation:
) -> None: ) -> None:
_budget.BUDGET_FILE.write_text("not json") _budget.BUDGET_FILE.write_text("not json")
gate._refresh_calendar() gate._refresh_calendar()
assert "corrupt" in gate._cal_vars.streaks.get() assert gate._cal_vars.streaks.get() == f"{g.WARN} {g.BUDGET}"
assert gate._cal_vars.ytd.get() == "" assert gate._cal_vars.ytd.get() == ""
@ -144,7 +145,7 @@ class TestBudgetEditToggle:
) -> None: ) -> None:
gate._on_edit_or_save_budget() gate._on_edit_or_save_budget()
assert gate._cal_editing_budget is True assert gate._cal_editing_budget is True
assert gate._cal_widgets.budget_edit_button.configured.get("text") == "Save" assert gate._cal_widgets.budget_edit_button.configured.get("text") == g.SAVE
assert gate._cal_widgets.budget_entry.configured.get("state") == "normal" assert gate._cal_widgets.budget_entry.configured.get("state") == "normal"
def test_second_click_with_non_numeric_input_stays_in_edit_mode( def test_second_click_with_non_numeric_input_stays_in_edit_mode(
@ -155,8 +156,8 @@ class TestBudgetEditToggle:
self._type(gate, "not a number") self._type(gate, "not a number")
gate._on_edit_or_save_budget() gate._on_edit_or_save_budget()
assert gate._cal_editing_budget is True assert gate._cal_editing_budget is True
assert "whole number" in gate._cal_vars.budget_status.get() assert gate._cal_vars.budget_status.get() == f"{g.ERROR} kcal"
assert gate._cal_widgets.budget_edit_button.configured.get("text") == "Save" assert gate._cal_widgets.budget_edit_button.configured.get("text") == g.SAVE
def test_second_click_with_zero_is_rejected_and_stays_in_edit_mode( def test_second_click_with_zero_is_rejected_and_stays_in_edit_mode(
self, self,
@ -166,13 +167,13 @@ class TestBudgetEditToggle:
self._type(gate, "0") self._type(gate, "0")
gate._on_edit_or_save_budget() gate._on_edit_or_save_budget()
assert gate._cal_editing_budget is True assert gate._cal_editing_budget is True
assert "positive" in gate._cal_vars.budget_status.get() assert gate._cal_vars.budget_status.get() == f"{g.ERROR} kcal > 0"
def test_second_click_with_negative_is_rejected(self, gate: MealGate) -> None: def test_second_click_with_negative_is_rejected(self, gate: MealGate) -> None:
gate._on_edit_or_save_budget() gate._on_edit_or_save_budget()
self._type(gate, "-500") self._type(gate, "-500")
gate._on_edit_or_save_budget() gate._on_edit_or_save_budget()
assert "positive" in gate._cal_vars.budget_status.get() assert gate._cal_vars.budget_status.get() == f"{g.ERROR} kcal > 0"
def test_second_click_with_a_valid_value_saves_and_exits_edit_mode( def test_second_click_with_a_valid_value_saves_and_exits_edit_mode(
self, self,
@ -182,8 +183,8 @@ class TestBudgetEditToggle:
self._type(gate, "1900") self._type(gate, "1900")
gate._on_edit_or_save_budget() gate._on_edit_or_save_budget()
assert gate._cal_editing_budget is False assert gate._cal_editing_budget is False
assert gate._cal_vars.budget_status.get() == "Saved." assert gate._cal_vars.budget_status.get() == g.OK
assert gate._cal_widgets.budget_edit_button.configured.get("text") == "Edit" assert gate._cal_widgets.budget_edit_button.configured.get("text") == g.EDIT
assert gate._cal_widgets.budget_entry.configured.get("state") == "readonly" assert gate._cal_widgets.budget_entry.configured.get("state") == "readonly"
assert daily_budget() == 1900 assert daily_budget() == 1900

View File

@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, cast
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from diet_guard import _gatelock_delivery from diet_guard import _gatelock_delivery
from diet_guard import _gatelock_glyphs as g
from diet_guard._budget import write_budget from diet_guard._budget import write_budget
from diet_guard._state import log_meal from diet_guard._state import log_meal
from diet_guard.tests.conftest import _nutrition from diet_guard.tests.conftest import _nutrition
@ -28,12 +29,12 @@ class TestDashboard:
"""A sealed budget shows consumed/target/remaining.""" """A sealed budget shows consumed/target/remaining."""
write_budget(2000) write_budget(2000)
gate._refresh_dashboard() gate._refresh_dashboard()
assert "left" in gate._vars.cal_headline.get() assert gate._vars.cal_headline.get() == f"0 / 2000 kcal {g.BUDGET} 2000"
def test_headline_without_budget(self, gate: MealGate) -> None: def test_headline_without_budget(self, gate: MealGate) -> None:
"""With no budget, only today's total is shown.""" """With no budget, only today's total is shown."""
gate._refresh_dashboard() gate._refresh_dashboard()
assert "kcal today" in gate._vars.cal_headline.get() assert gate._vars.cal_headline.get() == "0 kcal"
def test_dashboard_lists_entries(self, gate: MealGate) -> None: def test_dashboard_lists_entries(self, gate: MealGate) -> None:
"""Logged entries appear in the detail panel.""" """Logged entries appear in the detail panel."""
@ -42,31 +43,31 @@ class TestDashboard:
gate._refresh_dashboard() gate._refresh_dashboard()
text = gate._vars.dashboard.get() text = gate._vars.dashboard.get()
assert "apple" in text assert "apple" in text
assert "protein" in text assert f"{g.PROTEIN} 10 /" in text
def test_dashboard_empty(self, gate: MealGate) -> None: def test_dashboard_empty(self, gate: MealGate) -> None:
"""With nothing logged, the panel says so.""" """With nothing logged, the panel says so."""
gate._refresh_dashboard() gate._refresh_dashboard()
assert "nothing logged yet" in gate._vars.dashboard.get() assert g.EMPTY in gate._vars.dashboard.get()
def test_slot_header_variants(self, gate: MealGate) -> None: def test_slot_header_variants(self, gate: MealGate) -> None:
"""The header covers none / one / several pending slots.""" """The header covers none / one / several pending slots."""
gate._pending = [] gate._pending = []
gate._refresh_slot_header() gate._refresh_slot_header()
assert "All meals logged" in gate._vars.slot_header.get() assert gate._vars.slot_header.get() == g.OK
gate._pending = [480] gate._pending = [480]
gate._refresh_slot_header() gate._refresh_slot_header()
assert "Log your" in gate._vars.slot_header.get() assert gate._vars.slot_header.get() == f"{g.CLOCK} 08:00"
gate._pending = [480, 720] gate._pending = [480, 720]
gate._refresh_slot_header() gate._refresh_slot_header()
assert "remaining" in gate._vars.slot_header.get() assert gate._vars.slot_header.get() == f"{g.CLOCK} 08:00 1/2"
def test_projection_with_budget(self, gate: MealGate) -> None: def test_projection_with_budget(self, gate: MealGate) -> None:
"""The projection shows the after-this-item remaining when priced.""" """The projection shows the after-this-item remaining when priced."""
write_budget(2000) write_budget(2000)
gate._widgets.macros.kcal.insert(0, "300") gate._widgets.macros.kcal.insert(0, "300")
gate._refresh_projection() gate._refresh_projection()
assert "after this item" in gate._vars.projection.get() assert gate._vars.projection.get().endswith(f"→ {g.BUDGET} 1700")
class TestSlotWalk: class TestSlotWalk:
@ -121,7 +122,7 @@ class TestFetchFromSync:
with patch.object(_gatelock_delivery, "pull_peer_logs") as pull: with patch.object(_gatelock_delivery, "pull_peer_logs") as pull:
gate._on_fetch_sync() gate._on_fetch_sync()
pull.assert_not_called() pull.assert_not_called()
assert "only available on the real lock" in gate._vars.status.get() assert gate._vars.status.get() == f"{g.DEMO_ONLY} {g.SYNC}"
def test_pull_failure_keeps_lock(self, gate: MealGate) -> None: def test_pull_failure_keeps_lock(self, gate: MealGate) -> None:
"""A failed pull shows the reason and leaves pending slots intact.""" """A failed pull shows the reason and leaves pending slots intact."""
@ -135,7 +136,7 @@ class TestFetchFromSync:
gate._on_fetch_sync() gate._on_fetch_sync()
_drive_fetch(gate) _drive_fetch(gate)
assert gate._pending == [480, 720] assert gate._pending == [480, 720]
assert "still locked" in gate._vars.status.get() assert gate._vars.status.get() == f"{g.ERROR} {g.SYNC}"
def test_no_new_meals_keeps_all_slots(self, gate: MealGate) -> None: def test_no_new_meals_keeps_all_slots(self, gate: MealGate) -> None:
"""A clean pull that satisfies nothing reports so and keeps the slots.""" """A clean pull that satisfies nothing reports so and keeps the slots."""
@ -148,7 +149,7 @@ class TestFetchFromSync:
gate._on_fetch_sync() gate._on_fetch_sync()
_drive_fetch(gate) _drive_fetch(gate)
assert gate._pending == [480, 720] assert gate._pending == [480, 720]
assert "No new meals" in gate._vars.status.get() assert gate._vars.status.get() == f"{g.EMPTY} {g.SYNC}"
def test_partial_advances_to_next_slot(self, gate: MealGate) -> None: def test_partial_advances_to_next_slot(self, gate: MealGate) -> None:
"""One slot pulled in leaves the rest; the window advances (singular).""" """One slot pulled in leaves the rest; the window advances (singular)."""
@ -161,10 +162,10 @@ class TestFetchFromSync:
gate._on_fetch_sync() gate._on_fetch_sync()
_drive_fetch(gate) _drive_fetch(gate)
assert gate._pending == [720] assert gate._pending == [720]
assert "Pulled 1 meal " in gate._vars.status.get() assert gate._vars.status.get() == f"{g.OK} {g.SYNC} 1"
def test_partial_plural_wording(self, gate: MealGate) -> None: def test_partial_shows_the_count(self, gate: MealGate) -> None:
"""Two slots pulled in uses the plural 'meals'.""" """Two slots pulled in shows the count 2."""
gate.demo_mode = False gate.demo_mode = False
gate._pending = [480, 720, 960] gate._pending = [480, 720, 960]
with ( with (
@ -174,7 +175,7 @@ class TestFetchFromSync:
gate._on_fetch_sync() gate._on_fetch_sync()
_drive_fetch(gate) _drive_fetch(gate)
assert gate._pending == [960] assert gate._pending == [960]
assert "Pulled 2 meals" in gate._vars.status.get() assert gate._vars.status.get() == f"{g.OK} {g.SYNC} 2"
def test_all_satisfied_unlocks(self, gate: MealGate) -> None: def test_all_satisfied_unlocks(self, gate: MealGate) -> None:
"""When the pull satisfies every pending slot, the gate unlocks.""" """When the pull satisfies every pending slot, the gate unlocks."""
@ -187,7 +188,7 @@ class TestFetchFromSync:
gate._on_fetch_sync() gate._on_fetch_sync()
_drive_fetch(gate) _drive_fetch(gate)
assert gate._pending == [] assert gate._pending == []
assert "unlocking" in gate._vars.status.get() assert g.UNLOCKING in gate._vars.status.get()
def test_poll_without_a_fetch_is_inert(self, gate: MealGate) -> None: def test_poll_without_a_fetch_is_inert(self, gate: MealGate) -> None:
"""A stray poll after completion must stop, not block on an empty queue.""" """A stray poll after completion must stop, not block on an empty queue."""

View File

@ -14,6 +14,7 @@ from unittest.mock import MagicMock
import pytest import pytest
from diet_guard import _gatelock_glyphs as g
from diet_guard._gatelock import MealGate from diet_guard._gatelock import MealGate
from diet_guard.tests._gate_fixtures import fake_tk from diet_guard.tests._gate_fixtures import fake_tk
from diet_guard.tests.conftest import TWO_OUTPUTS from diet_guard.tests.conftest import TWO_OUTPUTS
@ -100,7 +101,7 @@ class TestGateFansOut:
for surface in dual_gate._cal_surfaces: for surface in dual_gate._cal_surfaces:
assert surface.budget_entry.configured.get("state") == "normal" assert surface.budget_entry.configured.get("state") == "normal"
assert surface.budget_edit_button.configured.get("text") == "Save" assert surface.budget_edit_button.configured.get("text") == g.SAVE
def test_frame_group_covers_every_monitor(self, dual_gate: MealGate) -> None: def test_frame_group_covers_every_monitor(self, dual_gate: MealGate) -> None:
"""The outer frame is exposed per surface, for tab selection.""" """The outer frame is exposed per surface, for tab selection."""

View File

@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, cast
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from diet_guard import _gatelock_fillall from diet_guard import _gatelock_fillall
from diet_guard import _gatelock_glyphs as g
from diet_guard._gatelock_fetch import FETCH_POLL_MS from diet_guard._gatelock_fetch import FETCH_POLL_MS
from diet_guard._gatelock_fillall import FILL_LABEL, FillAllFlow, FillHooks from diet_guard._gatelock_fillall import FILL_LABEL, FillAllFlow, FillHooks
from diet_guard._gatelock_kuchnia import DeliveryResult from diet_guard._gatelock_kuchnia import DeliveryResult
@ -115,7 +116,7 @@ class TestFirstClick:
with fetching() as start: with fetching() as start:
h.flow.click() h.flow.click()
start.assert_not_called() start.assert_not_called()
assert "only available on the real lock" in h.status assert h.status == f"{g.DEMO_ONLY} {g.FILL_ALL}"
assert h.label.get() == FILL_LABEL assert h.label.get() == FILL_LABEL
def test_a_click_while_fetching_is_ignored(self) -> None: def test_a_click_while_fetching_is_ignored(self) -> None:
@ -127,7 +128,7 @@ class TestFirstClick:
start.assert_called_once_with( start.assert_called_once_with(
_gatelock_fillall._refresh_delivery, now_local().date() _gatelock_fillall._refresh_delivery, now_local().date()
) )
assert h.statuses == [("Loading today's delivery…", False)] assert h.statuses == [(f"{g.BUSY} {g.DELIVERY}", False)]
h.root.after.assert_called_once_with(FETCH_POLL_MS, h.flow._poll) h.root.after.assert_called_once_with(FETCH_POLL_MS, h.flow._poll)
def test_the_poll_rearms_while_the_worker_runs(self) -> None: def test_the_poll_rearms_while_the_worker_runs(self) -> None:
@ -149,7 +150,7 @@ class TestFirstClick:
h = Harness() h = Harness()
with fetching(DeliveryResult(dishes=(), reason="panel down")): with fetching(DeliveryResult(dishes=(), reason="panel down")):
propose(h) propose(h)
assert h.statuses[-1] == ("panel down — still locked.", True) assert h.statuses[-1] == (f"{g.ERROR} {g.DELIVERY}", True)
assert h.label.get() == FILL_LABEL assert h.label.get() == FILL_LABEL
# Fetch done: the next click may fetch again rather than being ignored. # Fetch done: the next click may fetch again rather than being ignored.
assert h.flow._result is None assert h.flow._result is None
@ -158,7 +159,7 @@ class TestFirstClick:
h = Harness() h = Harness()
with fetching(delivered()): with fetching(delivered()):
propose(h) propose(h)
assert h.status == "No catering delivery today." assert h.status == f"{g.EMPTY} {g.DELIVERY}"
assert h.label.get() == FILL_LABEL assert h.label.get() == FILL_LABEL
def test_every_slot_already_logged(self) -> None: def test_every_slot_already_logged(self) -> None:
@ -167,7 +168,7 @@ class TestFirstClick:
h = Harness() h = Harness()
with fetching(delivered(*FOUR)): with fetching(delivered(*FOUR)):
propose(h) propose(h)
assert h.status == "Every slot already has a meal — nothing to fill." assert h.status == f"{g.OK} {g.CLOCK}"
assert h.label.get() == FILL_LABEL assert h.label.get() == FILL_LABEL
def test_the_proposal_writes_nothing(self) -> None: def test_the_proposal_writes_nothing(self) -> None:
@ -175,10 +176,9 @@ class TestFirstClick:
h = Harness() h = Harness()
with fetching(delivered(*FOUR)): with fetching(delivered(*FOUR)):
propose(h) propose(h)
assert h.label.get() == "✓ Confirm (3)" assert h.label.get() == f"{g.OK} 3"
assert h.status == ( assert h.status == (
"Will log 3 (1144 kcal): 08:00 Owsianka, 16:00 Obiad, " f"{g.DELIVERY} 3 1144 kcal 08:00 Owsianka 16:00 Obiad 20:00 Kolacja"
"20:00 Kolacja — click Confirm."
) )
assert logged() == {720: ["my sandwich"]} assert logged() == {720: ["my sandwich"]}
h.on_logged.assert_not_called() h.on_logged.assert_not_called()
@ -198,7 +198,7 @@ class TestConfirm:
960: ["Obiad"], 960: ["Obiad"],
1200: ["Kolacja"], 1200: ["Kolacja"],
} }
assert h.status == "Logged 3 catering dish(es)." assert h.status == f"{g.OK} {g.DELIVERY} 3"
h.on_logged.assert_called_once_with() h.on_logged.assert_called_once_with()
assert h.label.get() == FILL_LABEL assert h.label.get() == FILL_LABEL
@ -210,17 +210,17 @@ class TestConfirm:
propose(h) propose(h)
h.flow.click() h.flow.click()
assert logged()[480] == ["Owsianka", "Wrap"] assert logged()[480] == ["Owsianka", "Wrap"]
assert h.status == "Logged 5 catering dish(es)." assert h.status == f"{g.OK} {g.DELIVERY} 5"
def test_a_meal_typed_between_the_clicks_keeps_its_slot(self) -> None: def test_a_meal_typed_between_the_clicks_keeps_its_slot(self) -> None:
h = Harness() h = Harness()
with fetching(delivered(*FOUR)): with fetching(delivered(*FOUR)):
propose(h) propose(h)
assert h.label.get() == "✓ Confirm (4)" assert h.label.get() == f"{g.OK} 4"
log_meal("toast", _nutrition(), 480) log_meal("toast", _nutrition(), 480)
h.flow.click() h.flow.click()
assert logged()[480] == ["toast"] assert logged()[480] == ["toast"]
assert h.status == "Logged 3 catering dish(es)." assert h.status == f"{g.OK} {g.DELIVERY} 3"
def test_every_slot_taken_between_the_clicks(self) -> None: def test_every_slot_taken_between_the_clicks(self) -> None:
h = Harness() h = Harness()
@ -229,7 +229,7 @@ class TestConfirm:
for slot in (480, 720, 960, 1200): for slot in (480, 720, 960, 1200):
log_meal(f"mine {slot}", _nutrition(), slot) log_meal(f"mine {slot}", _nutrition(), slot)
h.flow.click() h.flow.click()
assert h.status == "Every slot already has a meal — nothing to fill." assert h.status == f"{g.OK} {g.CLOCK}"
h.on_logged.assert_not_called() h.on_logged.assert_not_called()
assert h.label.get() == FILL_LABEL assert h.label.get() == FILL_LABEL
assert all(descs == [f"mine {s}"] for s, descs in logged().items()) assert all(descs == [f"mine {s}"] for s, descs in logged().items())
@ -247,4 +247,4 @@ class TestConfirm:
assert logged() == {} assert logged() == {}
h.on_logged.assert_not_called() h.on_logged.assert_not_called()
assert h.label.get() == FILL_LABEL assert h.label.get() == FILL_LABEL
assert h.status == "Loading today's delivery…" assert h.status == f"{g.BUSY} {g.DELIVERY}"

View File

@ -19,6 +19,7 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from diet_guard import _gatelock_delivery, _gatelock_fillall, _gatelock_layout from diet_guard import _gatelock_delivery, _gatelock_fillall, _gatelock_layout
from diet_guard import _gatelock_glyphs as g
from diet_guard._gatelock import MealGate from diet_guard._gatelock import MealGate
from diet_guard._gatelock_fillall import FILL_LABEL, _refresh_delivery from diet_guard._gatelock_fillall import FILL_LABEL, _refresh_delivery
from diet_guard._gatelock_layout import build_layout from diet_guard._gatelock_layout import build_layout
@ -82,7 +83,7 @@ class TestTheGateDrivesTheFlow:
with patch.object(_gatelock_fillall, "start_delivery_fetch") as start: with patch.object(_gatelock_fillall, "start_delivery_fetch") as start:
gate._on_fill_all() gate._on_fill_all()
start.assert_not_called() start.assert_not_called()
assert "only available on the real lock" in gate._vars.status.get() assert gate._vars.status.get() == f"{g.DEMO_ONLY} {g.FILL_ALL}"
def test_fill_and_confirm_unlocks_from_catering(self, gate: MealGate) -> None: def test_fill_and_confirm_unlocks_from_catering(self, gate: MealGate) -> None:
gate.demo_mode = False # read once, when the flow is first built gate.demo_mode = False # read once, when the flow is first built
@ -94,7 +95,7 @@ class TestTheGateDrivesTheFlow:
with patch.object(_gatelock_delivery, "due_slots", side_effect=due): with patch.object(_gatelock_delivery, "due_slots", side_effect=due):
_fill(gate, delivered(*FOUR)) _fill(gate, delivered(*FOUR))
status = gate._vars.status.get() status = gate._vars.status.get()
assert status == "Filled from catering — all meals logged, unlocking…" assert status == f"{g.OK} {g.DELIVERY} {g.UNLOCKING}"
assert gate._pending == [] assert gate._pending == []
gate.root.after.assert_called_with(_UNLOCK_DELAY_MS, gate.close) gate.root.after.assert_called_with(_UNLOCK_DELAY_MS, gate.close)
assert sorted(logged()) == [480, 720, 960, 1200] assert sorted(logged()) == [480, 720, 960, 1200]
@ -109,8 +110,8 @@ class TestTheGateDrivesTheFlow:
with patch.object(_gatelock_delivery, "due_slots", side_effect=due): with patch.object(_gatelock_delivery, "due_slots", side_effect=due):
_fill(gate, delivered(*two)) _fill(gate, delivered(*two))
assert gate._pending == [720] assert gate._pending == [720]
assert "Pulled 1 meal " in gate._vars.status.get() assert g.UNLOCKING not in gate._vars.status.get()
assert "unlocking" not in gate._vars.status.get() assert gate._vars.status.get().startswith(g.OK)
class TestLeftoverDishesAtUnlock: class TestLeftoverDishesAtUnlock:
@ -120,22 +121,21 @@ class TestLeftoverDishesAtUnlock:
# Case and padding differ from the dish name on purpose. # Case and padding differ from the dish name on purpose.
log_meal(" KOLACJA ", _nutrition(), 20) log_meal(" KOLACJA ", _nutrition(), 20)
gate._delivery_pending = (dish("Kolacja", 4), dish("Obiad", 3)) gate._delivery_pending = (dish("Kolacja", 4), dish("Obiad", 3))
gate._unlock("Logged 20:00") gate._unlock(f"{g.OK} 20:00")
assert gate._vars.status.get() == ( assert gate._vars.status.get() == (
"Logged 20:00 (1 more dish delivered; log with 'ate') — " f"{g.OK} 20:00 {g.DELIVERY} +1 {g.UNLOCKING}"
"all meals logged, unlocking…"
) )
def test_unlogged_dishes_are_all_counted(self, gate: MealGate) -> None: def test_unlogged_dishes_are_all_counted(self, gate: MealGate) -> None:
gate._delivery_pending = (dish("Kolacja", 4), dish("Obiad", 3)) gate._delivery_pending = (dish("Kolacja", 4), dish("Obiad", 3))
gate._unlock("Logged 20:00") gate._unlock(f"{g.OK} 20:00")
assert "(2 more dishes delivered;" in gate._vars.status.get() assert f"{g.DELIVERY} +2" in gate._vars.status.get()
def test_every_queued_dish_logged_means_no_suffix(self, gate: MealGate) -> None: def test_every_queued_dish_logged_means_no_suffix(self, gate: MealGate) -> None:
log_meal("Kolacja", _nutrition(), 20) log_meal("Kolacja", _nutrition(), 20)
gate._delivery_pending = (dish("Kolacja", 4),) gate._delivery_pending = (dish("Kolacja", 4),)
gate._unlock("Logged 20:00") gate._unlock(f"{g.OK} 20:00")
assert "more dish" not in gate._vars.status.get() assert g.DELIVERY not in gate._vars.status.get()
class TestTheButton: class TestTheButton:
@ -188,8 +188,8 @@ class TestTheButton:
widgets = build_layout(notebook, vars_, callbacks, demo_mode=False) widgets = build_layout(notebook, vars_, callbacks, demo_mode=False)
button = _find_fill_button(widgets.frame, str(vars_.fill_label)) button = _find_fill_button(widgets.frame, str(vars_.fill_label))
assert button.cget("text") == FILL_LABEL assert button.cget("text") == FILL_LABEL
vars_.fill_label.set("✓ Confirm (3)") vars_.fill_label.set(f"{g.OK} 3")
assert button.cget("text") == "✓ Confirm (3)" assert button.cget("text") == f"{g.OK} 3"
button.invoke() button.invoke()
on_fill_all.assert_called_once_with() on_fill_all.assert_called_once_with()
finally: finally:

View File

@ -13,6 +13,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest.mock import patch from unittest.mock import patch
from diet_guard import _gatelock_glyphs as g
from diet_guard import ( from diet_guard import (
_gatelock_ui, _gatelock_ui,
) )
@ -107,8 +108,8 @@ class TestBasisAndAmount:
write_budget(2000) write_budget(2000)
gate._refresh_projection() gate._refresh_projection()
text = gate._vars.projection.get() text = gate._vars.projection.get()
assert "left" in text assert f"{g.BUDGET} 2000" in text
assert "after this item" not in text assert "→" not in text
def test_keyrelease_grams_mode(self, gate: MealGate) -> None: def test_keyrelease_grams_mode(self, gate: MealGate) -> None:
"""In grams mode the per-item weight is not touched on keyrelease.""" """In grams mode the per-item weight is not touched on keyrelease."""

View File

@ -12,6 +12,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest.mock import patch from unittest.mock import patch
from diet_guard import _gatelock_glyphs as g
from diet_guard import _gatelock_mealflow from diet_guard import _gatelock_mealflow
from diet_guard.tests.conftest import _nutrition from diet_guard.tests.conftest import _nutrition
@ -116,14 +117,14 @@ class TestSubmit:
def test_empty_description(self, gate: MealGate) -> None: def test_empty_description(self, gate: MealGate) -> None:
"""Submitting with no description prompts for one.""" """Submitting with no description prompts for one."""
gate._on_submit() gate._on_submit()
assert "Type what you ate" in gate._vars.status.get() assert gate._vars.status.get() == f"{g.ERROR} {g.FOOD}"
def test_non_numeric_macros(self, gate: MealGate) -> None: def test_non_numeric_macros(self, gate: MealGate) -> None:
"""Non-numeric macros are rejected before logging.""" """Non-numeric macros are rejected before logging."""
gate._set_desc("apple") gate._set_desc("apple")
gate._widgets.macros.kcal.insert(0, "abc") gate._widgets.macros.kcal.insert(0, "abc")
gate._on_submit() gate._on_submit()
assert "must be numbers" in gate._vars.status.get() assert gate._vars.status.get() == f"{g.ERROR} {g.PROTEIN}{g.CARBS}{g.FAT}"
def test_blank_calories_triggers_lookup(self, gate: MealGate) -> None: def test_blank_calories_triggers_lookup(self, gate: MealGate) -> None:
"""A blank calorie field looks the food up rather than logging.""" """A blank calorie field looks the food up rather than logging."""
@ -138,7 +139,7 @@ class TestSubmit:
gate._widgets.macros.kcal.insert(0, "200") gate._widgets.macros.kcal.insert(0, "200")
with patch.object(gate, "_current_nutrition", return_value=None): with patch.object(gate, "_current_nutrition", return_value=None):
gate._on_submit() gate._on_submit()
assert "Enter the calories" in gate._vars.status.get() assert gate._vars.status.get() == f"{g.ERROR} kcal"
def test_valid_submit_records(self, gate: MealGate) -> None: def test_valid_submit_records(self, gate: MealGate) -> None:
"""A described, priced meal is recorded.""" """A described, priced meal is recorded."""
@ -163,7 +164,7 @@ class TestLookup:
gate._set_desc("nonsense") gate._set_desc("nonsense")
with patch.object(_gatelock_mealflow, "lookup_candidates", return_value=[]): with patch.object(_gatelock_mealflow, "lookup_candidates", return_value=[]):
gate._begin_lookup("nonsense") gate._begin_lookup("nonsense")
assert "Couldn't look that up" in gate._vars.status.get() assert gate._vars.status.get().startswith(g.ERROR)
def test_single_candidate(self, gate: MealGate) -> None: def test_single_candidate(self, gate: MealGate) -> None:
"""A single match fills the fields and invites review.""" """A single match fills the fields and invites review."""
@ -173,7 +174,8 @@ class TestLookup:
return_value=[("apple", _nutrition(95, 100))], return_value=[("apple", _nutrition(95, 100))],
): ):
gate._begin_lookup("apple") gate._begin_lookup("apple")
assert "Review the values" in gate._vars.status.get() assert gate._vars.status.get().startswith(g.OK)
assert g.TIMES not in gate._vars.status.get()
def test_multiple_candidates(self, gate: MealGate) -> None: def test_multiple_candidates(self, gate: MealGate) -> None:
"""Several matches invite picking another.""" """Several matches invite picking another."""
@ -186,7 +188,7 @@ class TestLookup:
], ],
): ):
gate._begin_lookup("apple") gate._begin_lookup("apple")
assert "pick another" in gate._vars.status.get() assert gate._vars.status.get().endswith(f"{g.TIMES}2")
class TestRecord: class TestRecord:
@ -219,9 +221,9 @@ class TestRecord:
): ):
gate._record("apple", _nutrition(95, 100)) gate._record("apple", _nutrition(95, 100))
assert gate._pending == [720] assert gate._pending == [720]
assert "next meal" in gate._vars.status.get() assert gate._vars.status.get().startswith(g.OK)
def test_unlock_schedules_close(self, gate: MealGate) -> None: def test_unlock_schedules_close(self, gate: MealGate) -> None:
"""Unlock sets the closing status and schedules teardown.""" """Unlock sets the closing status and schedules teardown."""
gate._unlock("logged X") gate._unlock("logged X")
assert "unlocking" in gate._vars.status.get() assert g.UNLOCKING in gate._vars.status.get()

View File

@ -12,6 +12,7 @@ from typing import TYPE_CHECKING
import pytest import pytest
from diet_guard import _gatelock_glyphs as g
from diet_guard._gatelock_scheduleedit import schedule_problem, schedule_summary from diet_guard._gatelock_scheduleedit import schedule_problem, schedule_summary
from diet_guard._meal_schedule import DEFAULT_SCHEDULE, MealSchedule from diet_guard._meal_schedule import DEFAULT_SCHEDULE, MealSchedule
from diet_guard._meal_schedule_store import current_schedule, record_schedule_change from diet_guard._meal_schedule_store import current_schedule, record_schedule_change
@ -64,7 +65,7 @@ class TestEditToggle:
assert not gate._cal_editing_schedule assert not gate._cal_editing_schedule
assert current_schedule() == MealSchedule(435, 1140, 5) assert current_schedule() == MealSchedule(435, 1140, 5)
assert _status(gate) == "Saved." assert _status(gate) == g.OK
assert gate._cal_vars.schedule.times.get().startswith("07:15 10:15") assert gate._cal_vars.schedule.times.get().startswith("07:15 10:15")
def test_a_bare_hour_is_still_accepted(self, gate: MealGate) -> None: def test_a_bare_hour_is_still_accepted(self, gate: MealGate) -> None:
@ -106,22 +107,22 @@ class TestValidation:
_type(gate, first, "20:00", "4") _type(gate, first, "20:00", "4")
assert not gate._save_schedule_entry() assert not gate._save_schedule_entry()
assert "HH:MM" in _status(gate) assert _status(gate) == f"{g.ERROR} {g.CLOCK}"
def test_rejects_a_non_numeric_count(self, gate: MealGate) -> None: def test_rejects_a_non_numeric_count(self, gate: MealGate) -> None:
_type(gate, "08:00", "20:00", "five") _type(gate, "08:00", "20:00", "five")
assert not gate._save_schedule_entry() assert not gate._save_schedule_entry()
assert "meals per day as a number" in _status(gate) assert _status(gate) == f"{g.ERROR} {g.TIMES}"
@pytest.mark.parametrize( @pytest.mark.parametrize(
("first", "last", "count", "reason"), ("first", "last", "count", "reason"),
[ [
("20:00", "08:00", "4", "at least 15 min after"), ("20:00", "08:00", "4", f"{g.ERROR} {g.CLOCK}{g.DASH}{g.CLOCK} ≥ 15 min"),
("08:00", "08:14", "2", "at least 15 min after"), ("08:00", "08:14", "2", f"{g.ERROR} {g.CLOCK}{g.DASH}{g.CLOCK} ≥ 15 min"),
("08:00", "20:00", "1", "Meals per day must be 2-6."), ("08:00", "20:00", "1", f"{g.ERROR} {g.TIMES} 2{g.DASH}6"),
("08:00", "20:00", "9", "Meals per day must be 2-6."), ("08:00", "20:00", "9", f"{g.ERROR} {g.TIMES} 2{g.DASH}6"),
("08:00", "08:45", "5", "Too many meals"), ("08:00", "08:45", "5", f"{g.ERROR} {g.TIMES} ≤ 4"),
], ],
) )
def test_rejects_what_normalize_would_silently_change( def test_rejects_what_normalize_would_silently_change(
@ -130,7 +131,7 @@ class TestValidation:
_type(gate, first, last, count) _type(gate, first, last, count)
assert not gate._save_schedule_entry() assert not gate._save_schedule_entry()
assert reason in _status(gate) assert reason == _status(gate)
assert current_schedule() == DEFAULT_SCHEDULE assert current_schedule() == DEFAULT_SCHEDULE
def test_the_narrowest_legal_window_is_accepted(self, gate: MealGate) -> None: def test_the_narrowest_legal_window_is_accepted(self, gate: MealGate) -> None:

View File

@ -14,6 +14,7 @@ import queue
from unittest.mock import patch from unittest.mock import patch
from diet_guard import _gatelock_delivery from diet_guard import _gatelock_delivery
from diet_guard import _gatelock_glyphs as g
from diet_guard._gatelock_kuchnia import DeliveryResult from diet_guard._gatelock_kuchnia import DeliveryResult
from diet_guard.tests.test_kuchnia_gate import _deliver, _dish, _Gate from diet_guard.tests.test_kuchnia_gate import _deliver, _dish, _Gate
@ -65,4 +66,4 @@ class TestAutoload:
def test_the_button_still_reports_both(self) -> None: def test_the_button_still_reports_both(self) -> None:
gate = _Gate() gate = _Gate()
_deliver(gate, DeliveryResult(dishes=(), reason="panel down"), asked=True) _deliver(gate, DeliveryResult(dishes=(), reason="panel down"), asked=True)
assert "panel down" in gate.last_status assert gate.last_status == f"{g.ERROR} {g.DELIVERY}"

View File

@ -24,6 +24,7 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from diet_guard import _gatelock_delivery from diet_guard import _gatelock_delivery
from diet_guard import _gatelock_glyphs as g
from diet_guard._gatelock_delivery import _PullFlows from diet_guard._gatelock_delivery import _PullFlows
from diet_guard._gatelock_kuchnia import ( from diet_guard._gatelock_kuchnia import (
UNEXPECTED, UNEXPECTED,
@ -131,7 +132,7 @@ class TestLoadButton:
gate = _Gate() gate = _Gate()
gate.demo_mode = True gate.demo_mode = True
gate._on_load_delivery() gate._on_load_delivery()
assert "only available on the real lock" in gate.last_status assert gate.last_status == f"{g.DEMO_ONLY} {g.DELIVERY}"
assert gate._delivery_result is None assert gate._delivery_result is None
def test_a_second_click_while_in_flight_is_ignored(self) -> None: def test_a_second_click_while_in_flight_is_ignored(self) -> None:
@ -146,15 +147,14 @@ class TestLoadButton:
gate = _Gate() gate = _Gate()
_deliver(gate, DeliveryResult(dishes=(), reason="panel down")) _deliver(gate, DeliveryResult(dishes=(), reason="panel down"))
text, error = gate.statuses[-1] text, error = gate.statuses[-1]
assert "panel down" in text assert text == f"{g.ERROR} {g.DELIVERY}"
assert "still locked" in text
assert error is True assert error is True
assert gate._descs == [] assert gate._descs == []
def test_no_delivery_today_says_so(self) -> None: def test_no_delivery_today_shows_the_empty_mark(self) -> None:
gate = _Gate() gate = _Gate()
_deliver(gate, DeliveryResult(dishes=(), reason=None)) _deliver(gate, DeliveryResult(dishes=(), reason=None))
assert "No catering delivery today" in gate.last_status assert gate.last_status == f"{g.EMPTY} {g.DELIVERY}"
def test_a_dish_is_offered_but_never_logged(self) -> None: def test_a_dish_is_offered_but_never_logged(self) -> None:
gate = _Gate() gate = _Gate()
@ -165,21 +165,21 @@ class TestLoadButton:
assert gate._references == [dish_nutrition(_dish())] assert gate._references == [dish_nutrition(_dish())]
assert gate._references[0].grams == 318.0 assert gate._references[0].grams == 318.0
# ...and that is all. Submitting stays the user's explicit action. # ...and that is all. Submitting stays the user's explicit action.
assert "Log & Continue" in gate.last_status assert gate.last_status == f"{g.DELIVERY} Kaszotto"
def test_dishes_are_offered_one_at_a_time_in_meal_order(self) -> None: def test_dishes_are_offered_one_at_a_time_in_meal_order(self) -> None:
gate = _Gate() gate = _Gate()
dishes = (_dish("Kolacja", priority=2), _dish("Sniadanie", priority=1)) dishes = (_dish("Kolacja", priority=2), _dish("Sniadanie", priority=1))
_deliver(gate, DeliveryResult(dishes=dishes, reason=None)) _deliver(gate, DeliveryResult(dishes=dishes, reason=None))
assert gate._descs == ["Sniadanie"] assert gate._descs == ["Sniadanie"]
assert "1 more to go" in gate.last_status assert gate.last_status.endswith("+1")
gate._prefill_next_dish() gate._prefill_next_dish()
assert gate._descs == ["Sniadanie", "Kolacja"] assert gate._descs == ["Sniadanie", "Kolacja"]
def test_the_last_dish_has_no_more_to_go_suffix(self) -> None: def test_the_last_dish_has_no_count_suffix(self) -> None:
gate = _Gate() gate = _Gate()
_deliver(gate, DeliveryResult(dishes=(_dish(),), reason=None)) _deliver(gate, DeliveryResult(dishes=(_dish(),), reason=None))
assert "more to go" not in gate.last_status assert "+" not in gate.last_status
def test_prefilling_past_the_end_is_a_no_op(self) -> None: def test_prefilling_past_the_end_is_a_no_op(self) -> None:
gate = _Gate() gate = _Gate()

View File

@ -22,6 +22,7 @@ import queue
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest.mock import patch from unittest.mock import patch
from diet_guard import _gatelock_glyphs as g
from diet_guard import _gatelock_mealflow from diet_guard import _gatelock_mealflow
from diet_guard._gatelock_kuchnia import DeliveryResult from diet_guard._gatelock_kuchnia import DeliveryResult
from diet_guard._kuchnia_log import dish_nutrition from diet_guard._kuchnia_log import dish_nutrition
@ -60,16 +61,17 @@ class TestTheQueueSurvivesASubmit:
reason=None, reason=None,
), ),
) )
gate._prefill_next_dish("Logged 08:00: 391 kcal") logged = f"{g.OK} 08:00 391 kcal"
assert "Logged 08:00: 391 kcal" in gate.last_status gate._prefill_next_dish(logged)
assert "Loaded: Second" in gate.last_status assert logged in gate.last_status
assert f"{g.DELIVERY} Second" in gate.last_status
def test_prefilling_without_a_log_line_stays_unprefixed(self) -> None: def test_prefilling_without_a_log_line_stays_unprefixed(self) -> None:
# The button and the autoload pass no ``logged``, so the status must # The button and the autoload pass no ``logged``, so the status must
# not grow a stray leading separator. # not grow a stray leading separator.
gate = _Gate() gate = _Gate()
_deliver(gate, DeliveryResult(dishes=(_dish("Only"),), reason=None)) _deliver(gate, DeliveryResult(dishes=(_dish("Only"),), reason=None))
assert gate.last_status.startswith("Loaded: Only") assert gate.last_status.startswith(f"{g.DELIVERY} Only")
def test_an_exhausted_queue_prefills_nothing(self) -> None: def test_an_exhausted_queue_prefills_nothing(self) -> None:
# The guard that lets ``_finish_slot`` call this unconditionally. # The guard that lets ``_finish_slot`` call this unconditionally.
@ -143,8 +145,8 @@ class TestTheGateAdvancesTheQueue:
): ):
gate._record("apple", _nutrition(95, 100)) gate._record("apple", _nutrition(95, 100))
assert not gate._delivery_pending assert not gate._delivery_pending
assert "Loaded: Second course" in gate._vars.status.get() assert f"{g.DELIVERY} Second course" in gate._vars.status.get()
assert "Logged 08:00" in gate._vars.status.get() assert f"{g.OK} 08:00" in gate._vars.status.get()
def test_a_prefilled_dish_logs_exactly_its_own_values(self, gate: MealGate) -> None: def test_a_prefilled_dish_logs_exactly_its_own_values(self, gate: MealGate) -> None:
"""What the submit logs is the dish, not the dish scaled by grams/100. """What the submit logs is the dish, not the dish scaled by grams/100.
@ -176,5 +178,5 @@ class TestTheGateAdvancesTheQueue:
was never offered. was never offered.
""" """
gate._delivery_pending = (_dish("Kolacja"),) gate._delivery_pending = (_dish("Kolacja"),)
gate._unlock("Logged 20:00: 391 kcal") gate._unlock(f"{g.OK} 20:00 391 kcal")
assert "1 more dish delivered" in gate._vars.status.get() assert f"{g.DELIVERY} +1" in gate._vars.status.get()

View File

@ -0,0 +1,70 @@
"""Guard: ``textgate scan`` lists nothing that ``docs/DOCS-textgate-keep.md`` omits.
The per-surface guards (fake tk, widget tree, wrapper) catch what a scenario
drives; this one catches what the line-based scanner sees anywhere in the repo,
so the count cannot drift back. Every hit must be documented as a kept row in
the keep doc (proper nouns, Claude-facing MCP titles, doubts) or live under the
exempt ``docs/explanations/`` pages. The scanner is a per-machine tool, so the
test skips where it is absent (CI); the same rows are what kuhy reviews.
"""
from __future__ import annotations
from pathlib import Path
import re
import shutil
import subprocess
import pytest
ROOT = Path(__file__).resolve().parents[2]
KEEP = ROOT / "docs" / "DOCS-textgate-keep.md"
EXEMPT_PREFIX = "docs/explanations/"
HIT = re.compile(r"^hardcoded\s+(\S+?):(\d+)\s+(.*)$")
def parse_hits(output: str) -> list[tuple[str, str]]:
"""``(path, quoted text)`` for each scan line."""
hits = []
for line in output.splitlines():
match = HIT.match(line)
if match:
hits.append((match.group(1), match.group(3)))
return hits
def undocumented(hits: list[tuple[str, str]], keep: str) -> list[str]:
"""Hits outside the exempt docs whose ``path`` and text the keep doc omits."""
return [
f"{path} {text}"
for path, text in hits
if not path.startswith(EXEMPT_PREFIX) and f"`{path}`" not in keep
]
def test_parse_and_filter_are_strict() -> None:
"""Canary: a new file's hit is reported, a documented or exempt one is not."""
out = (
'hardcoded app/lib/new.dart:3 "Hello"\n'
'hardcoded docs/explanations/a.html:9 "Page"\n'
'hardcoded app/lib/main.dart:65 "Diet Guard"\n'
"240 to rework\n"
)
hits = parse_hits(out)
assert len(hits) == 3
assert undocumented(hits, "`app/lib/main.dart`") == ['app/lib/new.dart "Hello"']
@pytest.mark.skipif(shutil.which("textgate") is None, reason="textgate not installed")
def test_scan_lists_only_documented_rows() -> None:
result = subprocess.run(
[shutil.which("textgate") or "textgate", "scan"],
cwd=ROOT,
capture_output=True,
text=True,
check=False,
timeout=50,
)
hits = parse_hits(result.stdout)
assert hits, "the scan returned nothing: the parser or the scanner broke"
assert not undocumented(hits, KEEP.read_text(encoding="utf-8"))

View File

@ -0,0 +1,56 @@
# Text kept on purpose (textgate keep list)
kuhy's rule: nothing he sees carries text unless he asked. `textgate scan`
still lists the rows below; each is documented here, and
`diet_guard/tests/test_textgate_scan_clean.py` fails on any hit this file
does not name by path. The per-surface guards are `test_gate_no_words.py`
and `test_gate_no_words_static.py` (Tk gate), `app/test/no_words_*_test.dart`
(phone and web screens) and `app/test/desktop/desktop_wrapper_no_text_test.dart`
(pages served into the Chrome window).
## Exempt
- `docs/explanations/*.html`: documentation pages, read in a browser on
purpose. Not app UI. The scan lists them; the guard ignores that prefix.
## Proper nouns (OS-level app name)
| Where | Text | Why |
|---|---|---|
| `app/lib/main.dart` | `Diet Guard` | `MaterialApp.title`: task switcher and tab name |
| `diet_guard/_gatelock.py` | `Diet Gate` | window title, an identifier under override-redirect |
The launcher label and notification channel (`DietGuard`) live in
`AndroidManifest.xml` and `notification_backend_io.dart`; a due-slot
notification shows only the slot clock time and has no body.
## Spoken only (never painted)
| Where | Text | Evidence |
|---|---|---|
| `app/lib/screens/log_meal_fill_mixin.dart` | fill failed / cancelled sentences | handed to `StatusCue` as the semantic label; the cue paints a glyph and a count |
| `app/lib/services/kuchnia_fill_flow.dart` | delivery and logged sentences | `FillOutcome.message`, same route; `no_words_kuchnia_test.dart` proves the snackbar paints a glyph and a number |
## Read by Claude, not by kuhy
MCP tool titles in `diet_guard/_mcp.py` and `diet_guard/_mcp_read.py`, and the
commit text in `diet_guard/_prune_peers.py`: tool metadata and a CLI commit
message.
## Terminal output (settled: keeps its text)
All `print`/stdout/stderr and `--help` of the Python CLI, `app/bin/` and the
stderr lines of `app/lib/desktop/wrapper_server.dart`. Pages served into the
Chrome window answer errors with a status code and an empty body.
## DOUBT rows (kuhy to confirm or file a task)
- `MET` next to kcal figures (activity rows), and unit symbols `kcal kg lb st cm km min`: bare identifiers beside a number.
- Formula proper names painted outside the activity sheet: `app/lib/widgets/body/body_stats_cards.dart`, `goal_card.dart` (BMI Prime, Trefethen, ideal-weight and resting-burn formula names). The Tk gate dropped them.
- Workout names (`session.label`, `session.kind`) are PC-published content.
## Framework pickers (not reworked; follow-up task filed)
`showDatePicker`, `showDateRangePicker`, `showTimePicker` paint Select date,
Cancel, OK, Save: `log_meal_future_mixin.dart`, `history_filter_sheet.dart`,
`dated_value_card.dart`, `meal_time_dropdown.dart`, `profile_card.dart`.