diet-guard/diet_guard/_gatelock_layout.py
Krzysztof kuhy Rudnicki e8f0ecaae1 Split the six Python gate files; guard the fake-tk patch set
Sixth batch of the 250-line sweep. All six gate sources named for this batch
are now under cap, plus _gate_layout_probe.py, which this batch's import
rewiring pulled into the changeset (over-cap count 38 -> 31).

  _gatelock_ui.py       477 -> 174  (palette/leaf-helper hub)
  _gate_layout_probe.py 346 -> 196
  _gatelock_groups.py   308 -> 137
  _gatelock_calendar_ui 295 ->  77
  _gatelock_calendar.py 286 -> 210
  _gatelock.py          263 -> 238
  _gatelock_mealflow.py 259 -> 207

New siblings: _gatelock_fields (input widgets), _gatelock_layout (assembly),
_gatelock_widgetgroups (per-widget mirroring), _gatelock_calendar_types
(bundles/palette), _gatelock_calendar_widgets (History builders),
_gatelock_dashboard and _gatelock_budgetedit (mixins), _gatelock_lockfile
(the stdlib-only flock), _gate_focus_probe and _gate_measurement.

The load-bearing part is the test seam, not the line counts.
`_gate_fixtures._GATE_TK_MODULES` patches `tk` on a hand-listed tuple, and
that list fails OPEN: a widget builder split into a new sibling that nobody
adds to it builds *real* Tk widgets against fake parents. Neither existing
detector sees that -- check_patch_targets.py only validates paths that still
resolve, and the ~8s runtime only catches network calls. It bit twice during
this split (real StringVars from make_calendar_vars returned MagicMocks
instead of the asserted text), so test_gate_tk_modules.py now asserts the
invariant directly: every module binding `tk` is in the patch set or named in
_NO_WIDGETS with a reason. It caught a pre-existing gap too --
_gatelock_support was never in the set.

Same fail-open shape drove two other choices: `escape_tab_trap` is a call-time
indirection so the keyboard test's patch of `_escape_text_tab_trap` still
bites, and the flock pair moved with its conftest patch target updated in the
same edit (that one fails closed). build_layout is imported from
_gatelock_layout directly rather than re-exported lazily, which avoids both a
cycle and an import-outside-toplevel suppression.

Verified: 703 Python tests / 100% branch coverage, the 5 layout-fits tests
under xvfb, 555 Flutter tests (unchanged), flutter analyze clean,
check_patch_targets 0, `sync` 0 (403 entries), pre-commit clean, and
`gate --demo` renders both tabs with live dashboard data.
~/.local/share/diet_guard byte-identical across the batch (gate timer paused,
restored after).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
2026-08-15 20:08:03 +02:00

208 lines
6.0 KiB
Python

"""Assembly of the gate's "Log Meal" tab.
Split out of :mod:`._gatelock_ui` to keep every gate module under the repo's
250-line limit. :mod:`._gatelock_fields` builds the individual input widgets;
this half packs them, the dashboard, and the buttons into one scrollable
surface and returns the bundle the controller drives.
.. note::
This module imports ``tkinter`` and builds widgets, so it **must** appear in
``diet_guard.tests._gate_fixtures._GATE_TK_MODULES``. A module left out of
that set builds real Tk widgets under pytest against fake parents.
``test_gate_tk_modules_complete`` enforces this.
"""
from __future__ import annotations
import tkinter as tk
from gatelock import ButtonStyle, ScrollableSurface, make_button
from diet_guard._gatelock_fields import (
build_amount_row,
build_desc,
build_macro_section,
build_suggestion_box,
)
from diet_guard._gatelock_spacing import SM, XS
from diet_guard._gatelock_typography import BODY, CAPTION, LABEL, SUBTITLE, TITLE
from diet_guard._gatelock_ui import (
_ACCENT,
_COLORS,
_FONT,
_MUTED,
_WRAP_PX,
BG,
FG,
)
from diet_guard._gatelock_ui_types import GateCallbacks, GateVars, GateWidgets
__all__ = ["build_layout"]
def _build_dashboard(parent: tk.Frame, vars_: GateVars) -> None:
"""Build the running "how am I doing today" panel.
The calorie line is large and prominent (the number the user steers by); the
meal list and macros sit beneath it in a smaller monospace block.
"""
tk.Label(
parent,
textvariable=vars_.cal_headline,
font=(_FONT, TITLE, "bold"),
bg=BG,
fg=_ACCENT,
).pack(pady=(SM, 0))
tk.Label(
parent,
textvariable=vars_.dashboard,
font=("Courier", CAPTION),
bg=BG,
fg=_MUTED,
justify="left",
anchor="w",
wraplength=_WRAP_PX,
).pack(pady=(XS, 0))
def _build_header(body: tk.Frame, vars_: GateVars) -> None:
"""Pack the title and the current-slot header."""
tk.Label(
body,
text="🍽 Diet Gate",
font=(_FONT, TITLE, "bold"),
bg=BG,
fg=_ACCENT,
).pack(pady=(0, XS))
tk.Label(
body,
textvariable=vars_.slot_header,
font=(_FONT, SUBTITLE, "bold"),
bg=BG,
fg=FG,
wraplength=_WRAP_PX,
justify="center",
).pack(pady=(0, SM))
def _build_readouts(body: tk.Frame, vars_: GateVars) -> None:
"""Pack the projection and preview lines beneath the input fields."""
tk.Label(
body,
textvariable=vars_.projection,
font=(_FONT, LABEL, "bold"),
bg=BG,
fg=FG,
wraplength=_WRAP_PX,
justify="center",
).pack(pady=(XS, XS))
tk.Label(
body,
textvariable=vars_.preview,
font=(_FONT, BODY, "bold"),
bg=BG,
fg=_ACCENT,
wraplength=_WRAP_PX,
justify="center",
).pack(pady=(XS, XS))
def _build_actions(body: tk.Frame, callbacks: GateCallbacks) -> None:
"""Pack the submit button and the manual sync-fetch button."""
make_button(
body,
_COLORS,
"Log & Continue",
callbacks.on_submit,
).pack(pady=(XS, XS))
# Manual pull for a meal already logged on another device (the phone) but
# not yet propagated to this machine -- saves re-typing it to unlock.
make_button(
body,
_COLORS,
"⟳ Fetch from sync",
callbacks.on_fetch_sync,
ButtonStyle(variant="secondary", bold=False),
).pack(pady=(0, XS))
def build_layout(
root: tk.Misc,
vars_: GateVars,
callbacks: GateCallbacks,
*,
demo_mode: bool,
) -> GateWidgets:
"""Lay out the whole gate UI and return the widgets the controller drives.
The controller calls this once (after configuring the window) and is then
responsible for binding per-keystroke events to the returned widgets.
The content lives inside a :class:`~gatelock.ScrollableSurface` rather than
a ``place``-centered frame. It has to: this tab requires ~700px even when
empty and grows a line per logged meal, so on a 1366x768 screen it exceeded
the notebook's content pane and a centered frame clipped it *symmetrically*
-- losing the "Diet Gate" title and the slot header off the top while the
calorie headline and dashboard went off the bottom, with no scrollbar to
recover either. The viewport also makes the overflow keyboard-reachable,
which matters because this is a hard lock the user cannot leave without
logging a meal. See ``tests/measure_gate_layout.py``.
"""
surface = ScrollableSurface(root, _COLORS)
frame = surface.container
body = surface.content
_build_header(body, vars_)
desc_text = build_desc(body)
suggestion_box = build_suggestion_box(body)
amount_entry = build_amount_row(
root,
body,
vars_,
callbacks.on_unit_change,
)
basis_prefix, per_entry, macros = build_macro_section(root, body, vars_)
_build_readouts(body, vars_)
_build_actions(body, callbacks)
status_label = tk.Label(
body,
textvariable=vars_.status,
font=(_FONT, LABEL),
bg=BG,
fg=FG,
wraplength=_WRAP_PX,
justify="center",
)
status_label.pack()
_build_dashboard(body, vars_)
# Wire focus-following and reset the view now that the content exists.
# Without this, Tab walks onto fields scrolled out of sight -- clipping does
# not remove a widget from the tab chain.
surface.finalize()
if demo_mode:
make_button(
root,
_COLORS,
"✕ Close Demo",
callbacks.on_close,
ButtonStyle(variant="danger", bold=False),
).place(x=10, y=10)
return GateWidgets(
frame=frame,
desc_text=desc_text,
amount_entry=amount_entry,
per_entry=per_entry,
basis_prefix=basis_prefix,
macros=macros,
suggestion_box=suggestion_box,
status_label=status_label,
)