diet-guard/diet_guard/_gatelock_body_widgets.py
Krzysztof kuhy Rudnicki 43e4b4070d Replace PC gate text with glyphs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:35:34 +01:00

217 lines
7.4 KiB
Python

"""Widget builders for the gate's Body tab.
Split from :mod:`diet_guard._gatelock_body` (the controller) for the 250-line
cap. Everything here only builds and packs; every value lives in
:class:`~diet_guard._gatelock_body_vars.BodyVars` and every action is a
callback.
Layout, top to bottom: the goal sentence ("To lose 0.5 kg / week on
Moderate eat 2019 kcal", every choice a drop-down), then weight with the
graph beside BMI, ideal weight and resting burn, then workouts and the week
plan, then the profile (set up once -- first instead while incomplete) and
last the body-fat log.
.. note::
Builds ``tkinter`` widgets, so it **must** appear in
``diet_guard.tests._gate_fixtures._GATE_TK_MODULES``.
"""
from __future__ import annotations
import tkinter as tk
from typing import TYPE_CHECKING
from gatelock import ButtonStyle, make_button
from diet_guard import _gatelock_glyphs as g
from diet_guard._body_energy import (
ACTIVITY_CHOICES,
DIRECTIONS,
PRESET_AMOUNTS,
TIME_UNITS,
WEIGHT_UNITS,
)
from diet_guard._gatelock_body_glyphs import ACTIVITY
from diet_guard._gatelock_body_graph import GRAPH_RANGES
from diet_guard._gatelock_body_vars import BodyWidgets
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_typography import BODY, LABEL, SUBTITLE, TITLE
from diet_guard._gatelock_ui import BG, FG
if TYPE_CHECKING:
from collections.abc import Callable
from diet_guard._gatelock_body_vars import BodyCallbacks, BodyVars
__all__ = ["GRAPH_H", "GRAPH_W", "build_body_frame"]
GRAPH_W = 720
GRAPH_H = 300
_MONO = ("Courier", LABEL)
_SECONDARY = ButtonStyle(variant="secondary", bold=False)
_TOGGLE = {"bg": BG, "fg": FG, "selectcolor": _FIELD_BG, "activebackground": BG}
def _font(size: int = LABEL) -> tuple[str, int]:
return (_COLORS.typography.font_family, size)
def _label(parent: tk.Misc, text: str, size: int = LABEL) -> None:
tk.Label(parent, text=text, font=_font(size), bg=BG, fg=FG).pack(
side="left", padx=(0, XS)
)
def _entry(parent: tk.Misc, var: tk.StringVar, width: int, size: int = BODY) -> None:
tk.Entry(
parent,
textvariable=var,
width=width,
font=_font(size),
bg=_FIELD_BG,
fg=FG,
insertbackground=FG,
).pack(side="left", padx=(0, SM))
def _row(parent: tk.Misc) -> tk.Frame:
row = tk.Frame(parent, bg=BG)
row.pack(anchor="w", pady=(XS, XS))
return row
def _block(parent: tk.Misc, var: tk.StringVar) -> None:
tk.Label(
parent, textvariable=var, font=_MONO, bg=BG, fg=FG, justify="left", anchor="w"
).pack(anchor="w", pady=(SM, 0))
def _button(parent: tk.Misc, text: str, command: Callable[[], None]) -> None:
make_button(parent, _COLORS, text, command, _SECONDARY).pack(
side="left", padx=(0, SM)
)
def _menu(
parent: tk.Misc, var: tk.StringVar, values: list[str], cb: BodyCallbacks
) -> None:
"""A drop-down that re-answers the goal whenever a value is picked."""
menu = tk.OptionMenu(parent, var, *values, command=lambda _v: cb.goal_changed())
menu.config(font=_font(SUBTITLE), bg=_FIELD_BG, fg=_ACCENT, highlightthickness=0)
menu.pack(side="left", padx=(0, XS))
def _build_goal(parent: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> None:
"""``[↓] [0.5][v] [kg] / [◑] [▄] → 2019 kcal``: every choice a glyph."""
goal = vars_.goal
row = _row(parent)
_menu(row, goal.direction, [g.DIRECTION[d] for d in DIRECTIONS], cb)
_entry(row, goal.amount, 5, SUBTITLE)
_menu(row, goal.amount, [f"{a:g}" for a in PRESET_AMOUNTS], cb)
_menu(row, goal.weight_unit, list(WEIGHT_UNITS), cb)
_label(row, "/", SUBTITLE)
_menu(row, goal.time_unit, [g.SPAN[t] for t in TIME_UNITS], cb)
_menu(row, goal.activity, [ACTIVITY[k] for k in ACTIVITY_CHOICES], cb)
tk.Label(
row, textvariable=goal.answer, font=(*_font(TITLE), "bold"), bg=BG, fg=_ACCENT
).pack(side="left", padx=(MD, 0))
_button(row, g.OK, cb.goal_changed)
_block(parent, vars_.blocks["table"])
def _build_profile(parent: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> None:
row = _row(parent)
_label(row, g.BIRTH)
_entry(row, vars_.profile.birth, 11)
_label(row, "cm")
_entry(row, vars_.profile.height, 6)
sex = vars_.profile.sex
for value, text in (("m", g.MALE), ("f", g.FEMALE)):
tk.Radiobutton(row, text=text, value=value, variable=sex, **_TOGGLE).pack(
side="left"
)
_button(row, g.SAVE, cb.save_profile)
_block(parent, vars_.blocks["summary"])
def _dated_row(
parent: tk.Misc,
label: str,
vars_pair: tuple[tk.StringVar, tk.StringVar],
actions: tuple[Callable[[], None], Callable[[], None]],
) -> None:
row = _row(parent)
_label(row, label)
_entry(row, vars_pair[0], 6)
_label(row, g.CLOCK)
_entry(row, vars_pair[1], 11)
_button(row, g.SAVE, actions[0])
_button(row, g.DELETE, actions[1])
def _build_left(col: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> tk.Canvas:
logs = vars_.logs
pair = (logs.weight, logs.weight_day)
_dated_row(col, f"{g.AMOUNT} kg", pair, (cb.save_weight, cb.delete_weight))
row = _row(col)
for value, text in (("weight", g.AMOUNT), ("fat", "%")):
tk.Radiobutton(
row,
text=text,
value=value,
variable=logs.graph,
command=cb.recalc,
**_TOGGLE,
).pack(side="left")
for text, days in GRAPH_RANGES:
_button(row, text, lambda d=days: cb.set_range(d))
canvas = tk.Canvas(col, width=GRAPH_W, height=GRAPH_H, bg=BG, highlightthickness=0)
canvas.pack(anchor="w", pady=(SM, 0))
tk.Label(col, textvariable=vars_.status, font=_font(), bg=BG, fg=_MUTED).pack(
anchor="w"
)
_block(col, vars_.blocks["workouts"])
return canvas
def _build_right(col: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> None:
for name in ("bmi", "ideal", "bmr"):
_block(col, vars_.blocks[name])
row = _row(col)
_label(row, f"{g.AVERAGE} kcal")
_entry(row, vars_.plan.target, 6)
# Seven day boxes under dots, the weekend pair larger (no day names).
for dot, var in zip(g.WEEKDAY_DOTS, vars_.plan.days, strict=True):
_label(row, dot)
_entry(row, var, 5)
_button(row, g.OK, cb.recalc)
_block(col, vars_.blocks["plan"])
def build_body_frame(
parent: tk.Misc, vars_: BodyVars, cb: BodyCallbacks, *, profile_first: bool
) -> BodyWidgets:
"""Build the whole tab into ``parent``; return what the controller needs."""
frame = tk.Frame(parent, bg=BG)
inner = tk.Frame(frame, bg=BG)
inner.pack(fill="both", expand=True, padx=MD)
if profile_first:
_build_profile(inner, vars_, cb)
_build_goal(inner, vars_, cb)
columns = tk.Frame(inner, bg=BG)
columns.pack(anchor="w", pady=(MD, 0))
left = tk.Frame(columns, bg=BG)
left.pack(side="left", anchor="n", padx=(0, MD))
right = tk.Frame(columns, bg=BG)
right.pack(side="left", anchor="n")
canvas = _build_left(left, vars_, cb)
_build_right(right, vars_, cb)
if not profile_first:
_build_profile(inner, vars_, cb)
logs = vars_.logs
pair = (logs.body_fat, logs.fat_day)
_dated_row(inner, "%", pair, (cb.save_body_fat, cb.delete_body_fat))
_block(inner, vars_.blocks["bodyfat"])
return BodyWidgets(frame=frame, canvas=canvas)