gate: HH:MM spinboxes instead of comboboxes for the meal schedule

An inert dropdown arrow looked broken. ttk.Spinbox posts nothing; every
spin path (arrow press, Up/Down, wheel) generates <<Increment>>/
<<Decrement>>, bound here to step_time from the typed value (07:23 -> up
07:30, down 07:15) with "break" so ttk's values/wrap logic never runs.
First clamps to 00:00-23:45, last to 00:15-23:45. Popdown-blocking code
removed; FakeSpinbox replaces FakeCombobox.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
This commit is contained in:
Krzysztof Rudnicki 2026-10-10 20:13:03 +02:00
parent 6dd52a11ae
commit 2d468bf5f8
12 changed files with 122 additions and 137 deletions

View File

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

View File

@ -5,19 +5,20 @@ Split out of :mod:`._gatelock_calendar_widgets` (216 lines against the repo's
lives in :mod:`._gatelock_scheduleedit`, mirroring how the budget row's
widgets and its edit/save logic are split.
The first and last meal are editable ``ttk.Combobox`` fields: the dropdown
offers every quarter hour, and the user may also type any ``HH:MM``. The
``ttk`` import is load-bearing for the tests, which patch this module's
``ttk`` with a fake -- keep binding it at module level.
The first and last meal are editable ``ttk.Spinbox`` fields: the user may type
any ``HH:MM``, and the arrow buttons, Up/Down and the mouse wheel step to the
neighbouring quarter hour (:func:`step_time`). The ``ttk`` import is
load-bearing for the tests, which patch this module's ``ttk`` with a fake --
keep binding it at module level.
**The dropdown list is never posted.** Observed on Xvfb (2026-10-10, demo
gate): a posted ``ComboboxPopdown`` maps *beneath* the lock surface, which
gatelock keeps raised, yet still holds ttk's grab -- so the user sees nothing,
the next click anywhere is swallowed to unpost it, and keystrokes meant for
another field land in the combobox. On the real lock the grab watch also
fights the popdown for the grab. So both ways of posting are intercepted:
a click on the arrow element (:data:`_ARROW_ELEMENT`) and Down. Typing any
``HH:MM`` works, and Up/Down step by 15 minutes (:func:`step_time`).
**No popup of any kind.** These used to be ``ttk.Combobox`` fields, and on
Xvfb (2026-10-10, demo gate) a posted ``ComboboxPopdown`` mapped *beneath*
the lock surface, which gatelock keeps raised, while still holding ttk's grab:
invisible, it swallowed the next click and stole keystrokes. A spinbox posts
nothing. Every way ttk spins one -- arrow press, Up/Down, the wheel --
generates ``<<Increment>>``/``<<Decrement>>`` (``ttk/spinbox.tcl``), and those
are bound here and answered with ``"break"``, so ttk's own ``values``/``wrap``
logic never runs: stepping is always :func:`step_time` from the typed value.
"""
from __future__ import annotations
@ -41,32 +42,32 @@ if TYPE_CHECKING:
from diet_guard._gatelock_calendar_types import CalendarVars
__all__ = ["TIME_CHOICES", "build_schedule_row", "step_time"]
__all__ = ["FIRST_RANGE", "LAST_RANGE", "build_schedule_row", "step_time"]
_ENTRY_WIDTH = 4
# "HH:MM" plus room for the caret, so a typed time is never clipped.
_TIME_WIDTH = 6
#: What ``Combobox.identify`` names the arrow under the gate's ``clam`` theme
#: (verified on Xvfb: ``Combobox.field`` | ``textarea`` | ``downarrow``).
_ARROW_ELEMENT = "downarrow"
#: Every quarter hour of the day, the grid interior slots are rounded onto.
TIME_CHOICES = tuple(
slot_label(minute) for minute in range(0, MINUTES_PER_DAY, SLOT_GRID_MINUTES)
)
_LAST_MARK = MINUTES_PER_DAY - SLOT_GRID_MINUTES # 23:45, the day's last grid mark
#: Where stepping clamps each field, as ``(low, high)`` minutes. The last meal
#: must sit at least one grid step after the first, so it can never be 00:00.
FIRST_RANGE = (0, _LAST_MARK)
LAST_RANGE = (SLOT_GRID_MINUTES, _LAST_MARK)
def step_time(text: str, direction: int) -> str:
def step_time(text: str, direction: int, bounds: tuple[int, int] = FIRST_RANGE) -> str:
"""Return ``text`` moved to the next quarter hour in ``direction``.
An off-grid time (07:23) steps to the neighbouring grid mark (07:30 up,
07:15 down) rather than by a flat 15, so stepping always lands on a value
the dropdown offers. Clamped to the day; unparsable text is returned
unchanged so a half-typed value is not clobbered.
07:15 down) rather than by a flat 15, so stepping always lands on the
grid. Clamped to ``bounds``; unparsable text is returned unchanged so a
half-typed value is not clobbered.
Args:
text: The field's current value.
direction: ``+1`` for later, ``-1`` for earlier.
bounds: The ``(low, high)`` minutes the result is clamped to.
Returns:
The stepped ``HH:MM`` label, or ``text`` if it does not parse.
@ -79,53 +80,47 @@ def step_time(text: str, direction: int) -> str:
stepped = (minute // SLOT_GRID_MINUTES + 1) * SLOT_GRID_MINUTES
else:
stepped = (minute - 1) // SLOT_GRID_MINUTES * SLOT_GRID_MINUTES
last_mark = MINUTES_PER_DAY - SLOT_GRID_MINUTES
return slot_label(max(0, min(last_mark, stepped)))
low, high = bounds
return slot_label(max(low, min(high, stepped)))
def _bind_steps(combo: ttk.Combobox, variable: tk.StringVar) -> None:
"""Make Up/Down step the time, and keep the dropdown list from posting."""
def _bind_steps(
spin: ttk.Spinbox, variable: tk.StringVar, bounds: tuple[int, int]
) -> None:
"""Answer the spinbox's step events with :func:`step_time`."""
def _step(direction: int) -> str:
if str(combo.cget("state")) == "normal":
variable.set(step_time(variable.get(), direction))
# "break" stops ttk's own binding, which would post the popdown.
if str(spin.cget("state")) == "normal":
variable.set(step_time(variable.get(), direction, bounds))
# "break" keeps ttk's own Spin (values/from/to/wrap) from running.
return "break"
def _press(event: tk.Event[ttk.Combobox]) -> str | None:
# A click on the arrow would post the popdown -- see the module
# docstring for why that is unsafe here. Clicks on the text still
# fall through to ttk, so the caret lands where the user clicked.
if _ARROW_ELEMENT in str(combo.identify(event.x, event.y)):
return "break"
return None
combo.bind("<Up>", lambda _event: _step(1))
combo.bind("<Down>", lambda _event: _step(-1))
combo.bind("<ButtonPress-1>", _press)
spin.bind("<<Increment>>", lambda _event: _step(1))
spin.bind("<<Decrement>>", lambda _event: _step(-1))
def _time_combo(row: tk.Frame, variable: tk.StringVar) -> ttk.Combobox:
"""Return one locked-by-default ``HH:MM`` combobox in the schedule row.
def _time_spin(
row: tk.Frame, variable: tk.StringVar, bounds: tuple[int, int]
) -> ttk.Spinbox:
"""Return one locked-by-default ``HH:MM`` spinbox in the schedule row.
Locked means ``disabled``, not ``readonly``: a read-only ttk combobox still
lets the user pick from the list, which would edit a "locked" row. Its
colours come from the ``TCombobox`` style (see
Locked means ``disabled``, not ``readonly``: a read-only ttk spinbox still
spins, which would edit a "locked" row. Its colours come from the
``TSpinbox`` style (see
:func:`diet_guard._gatelock_calendar_ui._style_notebook`) -- ttk widgets
reject ``bg``/``fg``.
"""
combo = ttk.Combobox(
spin = ttk.Spinbox(
row,
textvariable=variable,
values=TIME_CHOICES,
font=(_COLORS.typography.font_family, BODY),
width=_TIME_WIDTH,
justify="center",
state="disabled",
)
_bind_steps(combo, variable)
combo.pack(side="left", padx=(XS, SM), ipady=XS)
return combo
_bind_steps(spin, variable, bounds)
spin.pack(side="left", padx=(XS, SM), ipady=XS)
return spin
def _spin_entry(row: tk.Frame, variable: tk.StringVar) -> tk.Entry:
@ -161,22 +156,22 @@ def build_schedule_row(
parent: tk.Frame,
vars_: CalendarVars,
on_edit_or_save_schedule: Callable[[], None],
) -> tuple[ttk.Combobox, ttk.Combobox, tk.Entry, tk.Button, tk.Label]:
) -> tuple[ttk.Spinbox, ttk.Spinbox, tk.Entry, tk.Button, tk.Label]:
"""Build the meal-schedule row.
Returns the two time comboboxes, the count entry, the edit button, and the
Returns the two time spinboxes, the count entry, the edit button, and the
status label, in that order. Like the budget row the fields start locked:
the schedule is displayed but not directly editable until "Edit".
Returns:
``(first_combo, last_combo, count_entry, edit_button, status_label)``.
``(first_spin, last_spin, count_entry, edit_button, status_label)``.
"""
row = tk.Frame(parent, bg=BG)
row.pack(pady=(SM, XS))
_caption(row, "Meals:")
first_combo = _time_combo(row, vars_.schedule.first)
first_spin = _time_spin(row, vars_.schedule.first, FIRST_RANGE)
_caption(row, "to")
last_combo = _time_combo(row, vars_.schedule.last)
last_spin = _time_spin(row, vars_.schedule.last, LAST_RANGE)
_caption(row, "x")
count_entry = _spin_entry(row, vars_.schedule.count)
edit_button = make_button(row, _COLORS, "Edit", on_edit_or_save_schedule)
@ -199,4 +194,4 @@ def build_schedule_row(
fg=FG,
)
status_label.pack(pady=(0, XS))
return first_combo, last_combo, count_entry, edit_button, status_label
return first_spin, last_spin, count_entry, edit_button, status_label

View File

@ -92,8 +92,8 @@ class CalendarWidgets:
budget_entry: tk.Entry
budget_edit_button: tk.Button
budget_status_label: tk.Label
schedule_first_entry: ttk.Combobox
schedule_last_entry: ttk.Combobox
schedule_first_entry: ttk.Spinbox
schedule_last_entry: ttk.Spinbox
schedule_count_entry: tk.Entry
schedule_edit_button: tk.Button
schedule_status_label: tk.Label

View File

@ -55,7 +55,7 @@ def _style_notebook(root: tk.Misc) -> None:
"""Theme the History tab's ttk widgets to match the gate's dark palette.
Covers the ``ttk.Notebook`` tab strip and the schedule row's
``ttk.Combobox`` fields.
``ttk.Spinbox`` fields.
``ttk`` widgets ignore plain ``bg=``/``fg=`` -- unlike every other widget
here, they only take color from a named ``ttk.Style``. Without this the
@ -80,12 +80,12 @@ def _style_notebook(root: tk.Misc) -> None:
background=[("selected", _ACCENT)],
foreground=[("selected", _COLORS.palette.on_fill)],
)
# The meal-schedule row's HH:MM comboboxes. Locked they are ``disabled``,
# The meal-schedule row's HH:MM spinboxes. Locked they are ``disabled``,
# which clam greys out; mapping the disabled state back to the field
# colours keeps the stored schedule legible, matching the read-only
# ``tk.Entry`` beside them.
style.configure(
"TCombobox",
"TSpinbox",
fieldbackground=_FIELD_BG,
background=_FIELD_BG,
foreground=FG,
@ -93,7 +93,7 @@ def _style_notebook(root: tk.Misc) -> None:
insertcolor=FG,
)
style.map(
"TCombobox",
"TSpinbox",
fieldbackground=[("disabled", _FIELD_BG), ("readonly", _FIELD_BG)],
foreground=[("disabled", FG)],
arrowcolor=[("disabled", _COLORS.palette.muted)],

View File

@ -37,9 +37,9 @@ from diet_guard._slots import day_slots, slot_label
__all__ = ["_GateScheduleEdit", "schedule_problem", "schedule_summary"]
# ttk.Combobox and tk.Entry spell "locked" differently: a ``readonly``
# combobox still lets the user pick from its list, so locked is ``disabled``.
_COMBO_STATE = {"normal": "normal", "readonly": "disabled"}
# ttk.Spinbox and tk.Entry spell "locked" differently: a ``readonly`` ttk
# spinbox still spins, which would edit a locked row, so locked is ``disabled``.
_SPIN_STATE = {"normal": "normal", "readonly": "disabled"}
def schedule_summary(schedule: MealSchedule) -> str:
@ -90,9 +90,9 @@ class _GateScheduleEdit(_GateBudgetEdit):
stop the remaining fields from changing state.
"""
for surface in self._cal_surfaces:
for combo in (surface.schedule_first_entry, surface.schedule_last_entry):
for spin in (surface.schedule_first_entry, surface.schedule_last_entry):
with contextlib.suppress(tk.TclError):
combo.config(state=_COMBO_STATE[state])
spin.configure(state=_SPIN_STATE[state])
with contextlib.suppress(tk.TclError):
surface.schedule_count_entry.config(state=state)

View File

@ -199,7 +199,7 @@ def fake_tk() -> Iterator[None]:
stack.enter_context(patch.object(module, "tk", _FAKE_TK))
stack.enter_context(patch.object(_gatelock_calendar, "ttk", _FAKE_TTK))
stack.enter_context(patch.object(_gatelock_calendar_ui, "ttk", _FAKE_TTK))
# The schedule row's HH:MM comboboxes are ttk widgets; left real, they
# The schedule row's HH:MM spinboxes are ttk widgets; left real, they
# would be built against a fake parent.
stack.enter_context(patch.object(_gatelock_calendar_schedule, "ttk", _FAKE_TTK))
yield

View File

@ -25,9 +25,9 @@ from types import SimpleNamespace
from diet_guard.tests._tk_fakes_canvas import FakeCanvas, FakeScrollbar
from diet_guard.tests._tk_fakes_containers import (
FakeCombobox,
FakeNotebook,
FakeRadiobutton,
FakeSpinbox,
FakeStyle,
FakeWidget,
)
@ -41,12 +41,12 @@ __all__ = [
"_FAKE_TK",
"_FAKE_TTK",
"FakeCanvas",
"FakeCombobox",
"FakeEntry",
"FakeListbox",
"FakeNotebook",
"FakeRadiobutton",
"FakeScrollbar",
"FakeSpinbox",
"FakeStyle",
"FakeText",
"FakeVar",
@ -176,9 +176,7 @@ _FAKE_TK = SimpleNamespace(
Event=object,
)
_FAKE_TTK = SimpleNamespace(
Notebook=FakeNotebook, Style=FakeStyle, Combobox=FakeCombobox
)
_FAKE_TTK = SimpleNamespace(Notebook=FakeNotebook, Style=FakeStyle, Spinbox=FakeSpinbox)
# Every mixin module the gate window is built from imports ``tkinter``
# independently; all of them must see the fake so ``tk.TclError`` etc. are the

View File

@ -21,9 +21,9 @@ rather than merely asserting that a widget was constructed.
from __future__ import annotations
__all__ = [
"FakeCombobox",
"FakeNotebook",
"FakeRadiobutton",
"FakeSpinbox",
"FakeStyle",
"FakeWidget",
]
@ -184,12 +184,13 @@ class FakeStyle:
self.configured.setdefault(style_name, {}).update(kwargs)
class FakeCombobox(FakeWidget):
"""A functional stand-in for ``ttk.Combobox``.
class FakeSpinbox(FakeWidget):
"""A functional stand-in for ``ttk.Spinbox``.
Reads and writes go through its ``textvariable`` (as the real widget's
do), ``state`` is readable via ``cget``, and ``bind`` keeps each handler
so a test can fire the Up/Down step bindings the way a keypress would.
so a test can fire ``<<Increment>>``/``<<Decrement>>`` the way an arrow
press, Up/Down or the wheel would.
"""
def __init__(self, *args: object, **kwargs: object) -> None:
@ -206,10 +207,3 @@ class FakeCombobox(FakeWidget):
def bind(self, *args: object, **kwargs: object) -> None:
sequence, handler = args[0], args[1]
self.bindings[str(sequence)] = handler
#: Where the fake's arrow element starts; left of it is the text area.
ARROW_X = 50
def identify(self, x: int, y: int) -> str:
"""Name the element at ``x``, as clam does: text, then the arrow."""
return "downarrow" if x >= self.ARROW_X else "textarea"

View File

@ -1,33 +1,25 @@
"""Tests for the schedule row's ``HH:MM`` comboboxes.
"""Tests for the schedule row's ``HH:MM`` spinboxes.
Covers :mod:`._gatelock_calendar_schedule`: the quarter-hour choices, the
Up/Down step that avoids posting the dropdown over a grabbed lock, and the
per-widget state toggle in :mod:`._gatelock_scheduleedit`.
Covers :mod:`._gatelock_calendar_schedule`: the quarter-hour step and its
clamps, the ``<<Increment>>``/``<<Decrement>>`` bindings that replace ttk's own
spin logic, and the per-widget state toggle in :mod:`._gatelock_scheduleedit`.
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import TYPE_CHECKING, cast
from unittest.mock import patch
import pytest
from diet_guard._gatelock_calendar_schedule import TIME_CHOICES, step_time
from diet_guard._gatelock_calendar_schedule import LAST_RANGE, step_time
from diet_guard.tests._tk_fakes import _FakeTclError
if TYPE_CHECKING:
from collections.abc import Callable
from diet_guard._gatelock import MealGate
from diet_guard.tests._tk_fakes import FakeCombobox
class TestChoices:
def test_every_quarter_hour_of_the_day(self) -> None:
assert len(TIME_CHOICES) == 96
assert TIME_CHOICES[:3] == ("00:00", "00:15", "00:30")
assert TIME_CHOICES[-1] == "23:45"
from diet_guard.tests._tk_fakes import FakeSpinbox
class TestStepTime:
@ -40,7 +32,7 @@ class TestStepTime:
("07:23", 1, "07:30"),
("07:23", -1, "07:15"),
("7", 1, "07:15"),
# Clamped to the day's grid.
# Clamped to the first meal's range: 00:00..23:45.
("00:00", -1, "00:00"),
("23:45", 1, "23:45"),
("23:50", 1, "23:45"),
@ -51,54 +43,57 @@ class TestStepTime:
) -> None:
assert step_time(text, direction) == expected
def test_the_last_meal_never_steps_below_00_15(self) -> None:
"""It must sit one grid step after the earliest possible first meal."""
assert step_time("00:15", -1, LAST_RANGE) == "00:15"
assert step_time("00:20", -1, LAST_RANGE) == "00:15"
assert step_time("23:45", 1, LAST_RANGE) == "23:45"
def test_half_typed_text_is_left_alone(self) -> None:
assert step_time("07:", 1) == "07:"
class TestComboBindings:
def _fire(self, gate: MealGate, key: str) -> object:
combo = cast("FakeCombobox", gate._cal_widgets.schedule_first_entry)
handler = cast("Callable[[object], object]", combo.bindings[key])
return handler(None)
def _spin(gate: MealGate, which: str, event: str) -> object:
"""Fire one of a spinbox's step events, as ttk would on press/key/wheel."""
widget = getattr(gate._cal_widgets, f"schedule_{which}_entry")
spin = cast("FakeSpinbox", widget)
handler = cast("Callable[[object], object]", spin.bindings[event])
return handler(None)
def test_up_down_step_only_while_editing(self, gate: MealGate) -> None:
class TestSpinEvents:
def test_steps_only_while_editing(self, gate: MealGate) -> None:
gate._cal_vars.schedule.first.set("08:00")
# Locked: the key is swallowed and the value is unchanged.
assert self._fire(gate, "<Up>") == "break"
# Locked: the event is swallowed and the value is unchanged.
assert _spin(gate, "first", "<<Increment>>") == "break"
assert gate._cal_vars.schedule.first.get() == "08:00"
gate._on_edit_or_save_schedule()
assert self._fire(gate, "<Up>") == "break"
assert _spin(gate, "first", "<<Increment>>") == "break"
assert gate._cal_vars.schedule.first.get() == "08:15"
self._fire(gate, "<Down>")
self._fire(gate, "<Down>")
_spin(gate, "first", "<<Decrement>>")
_spin(gate, "first", "<<Decrement>>")
assert gate._cal_vars.schedule.first.get() == "07:45"
class TestArrowNeverPosts:
"""The popdown would map beneath the lock yet hold the grab."""
def _press(self, gate: MealGate, x: int) -> object:
combo = cast("FakeCombobox", gate._cal_widgets.schedule_last_entry)
handler = cast("Callable[[object], object]", combo.bindings["<ButtonPress-1>"])
return handler(SimpleNamespace(x=x, y=5))
def test_a_click_on_the_arrow_is_swallowed(self, gate: MealGate) -> None:
def test_an_off_grid_value_steps_to_its_neighbour(self, gate: MealGate) -> None:
gate._on_edit_or_save_schedule()
assert self._press(gate, 60) == "break"
gate._cal_vars.schedule.last.set("19:23")
_spin(gate, "last", "<<Increment>>")
assert gate._cal_vars.schedule.last.get() == "19:30"
def test_a_click_on_the_text_reaches_ttk(self, gate: MealGate) -> None:
"""So the caret still lands where the user clicked."""
def test_the_last_field_uses_its_own_clamp(self, gate: MealGate) -> None:
gate._on_edit_or_save_schedule()
assert self._press(gate, 10) is None
gate._cal_vars.schedule.last.set("00:15")
_spin(gate, "last", "<<Decrement>>")
assert gate._cal_vars.schedule.last.get() == "00:15"
class TestStateToggle:
def test_one_dead_widget_does_not_skip_the_rest(self, gate: MealGate) -> None:
"""A vanished monitor's combobox must not leave the others locked."""
"""A vanished monitor's spinbox must not leave the others locked."""
widgets = gate._cal_widgets
dead = _FakeTclError("dead")
with patch.object(widgets.schedule_first_entry, "config", side_effect=dead):
with patch.object(widgets.schedule_first_entry, "configure", side_effect=dead):
gate._set_schedule_entry_state("normal")
assert widgets.schedule_last_entry.configured["state"] == "normal"
assert widgets.schedule_count_entry.configured["state"] == "normal"

View File

@ -28,7 +28,7 @@ def dual_gate(dual_output: None) -> Iterator[MealGate]:
del dual_output
# The shared patch set, never a hand-picked copy: a copy silently misses
# the next module that starts building widgets (the schedule row's ttk
# comboboxes were exactly that).
# time fields were exactly that).
with fake_tk():
yield MealGate(demo_mode=True)

View File

@ -84,8 +84,8 @@ class TestEditToggle:
assert gate._cal_editing_schedule
assert current_schedule() == DEFAULT_SCHEDULE
def test_the_comboboxes_lock_as_disabled_not_readonly(self, gate: MealGate) -> None:
"""A ``readonly`` ttk combobox still lets the list edit a locked row."""
def test_the_spinboxes_lock_as_disabled_not_readonly(self, gate: MealGate) -> None:
"""A ``readonly`` ttk spinbox still spins, editing a locked row."""
widgets = gate._cal_widgets
gate._on_edit_or_save_schedule()
assert widgets.schedule_first_entry.configured["state"] == "normal"

View File

@ -84,11 +84,14 @@ them untouched.
Editable on both surfaces: the app's Settings screen
(`settings_meal_schedule.dart`) and the gate's History tab
(`_gatelock_scheduleedit.py`, widgets in `_gatelock_calendar_schedule.py`).
The gate's first/last fields are `ttk.Combobox`es offering every quarter hour
and accepting any typed `HH:MM`; Up/Down step 15 minutes. The dropdown list is
**never posted** (arrow clicks and Down are intercepted): a posted popdown maps
*beneath* the lock surface, which gatelock keeps raised, while still holding
ttk's grab — invisible, it swallows the next click and steals keystrokes.
The gate's first/last fields are `ttk.Spinbox`es: any typed `HH:MM`, and the
arrow buttons, Up/Down and the wheel step to the neighbouring quarter hour
from the typed value (07:23 → 07:30 / 07:15), clamped to 00:00–23:45 (the last
meal to 00:15–23:45). `<<Increment>>`/`<<Decrement>>` are bound and answered
with `"break"`, so ttk's own values/wrap logic never runs. **No popups on the
lock:** a `ttk.Combobox` was tried first, and its popdown mapped *beneath* the
lock surface (gatelock keeps it raised) while still holding ttk's grab —
invisible, it swallowed the next click and stole keystrokes.
`diet-guard-gate.timer` fires every 15 minutes (`*:00/15:00`), so it lands on
every grid slot exactly; an off-grid first/last meal is caught by the next