From 2d468bf5f837ce5b244e5aa7ee4b5b6126c27cb4 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 10 Oct 2026 20:13:03 +0200 Subject: [PATCH] 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 <>/ <>, 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) Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn --- app/pubspec.yaml | 2 +- diet_guard/_gatelock_calendar_schedule.py | 111 +++++++++--------- diet_guard/_gatelock_calendar_types.py | 4 +- diet_guard/_gatelock_calendar_ui.py | 8 +- diet_guard/_gatelock_scheduleedit.py | 10 +- diet_guard/tests/_gate_fixtures.py | 2 +- diet_guard/tests/_tk_fakes.py | 8 +- diet_guard/tests/_tk_fakes_containers.py | 16 +-- .../tests/test_gatelock_calendar_schedule.py | 79 ++++++------- diet_guard/tests/test_gatelock_fanout.py | 2 +- .../tests/test_gatelock_scheduleedit.py | 4 +- docs/DOCS-meal-schedule.md | 13 +- 12 files changed, 122 insertions(+), 137 deletions(-) diff --git a/app/pubspec.yaml b/app/pubspec.yaml index d522a96..8f744de 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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 diff --git a/diet_guard/_gatelock_calendar_schedule.py b/diet_guard/_gatelock_calendar_schedule.py index 15cd112..03712db 100644 --- a/diet_guard/_gatelock_calendar_schedule.py +++ b/diet_guard/_gatelock_calendar_schedule.py @@ -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 ``<>``/``<>`` (``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("", lambda _event: _step(1)) - combo.bind("", lambda _event: _step(-1)) - combo.bind("", _press) + spin.bind("<>", lambda _event: _step(1)) + spin.bind("<>", 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 diff --git a/diet_guard/_gatelock_calendar_types.py b/diet_guard/_gatelock_calendar_types.py index e459878..511189b 100644 --- a/diet_guard/_gatelock_calendar_types.py +++ b/diet_guard/_gatelock_calendar_types.py @@ -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 diff --git a/diet_guard/_gatelock_calendar_ui.py b/diet_guard/_gatelock_calendar_ui.py index ad8599c..d7ec889 100644 --- a/diet_guard/_gatelock_calendar_ui.py +++ b/diet_guard/_gatelock_calendar_ui.py @@ -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)], diff --git a/diet_guard/_gatelock_scheduleedit.py b/diet_guard/_gatelock_scheduleedit.py index 2550e88..cf60120 100644 --- a/diet_guard/_gatelock_scheduleedit.py +++ b/diet_guard/_gatelock_scheduleedit.py @@ -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) diff --git a/diet_guard/tests/_gate_fixtures.py b/diet_guard/tests/_gate_fixtures.py index fb88db6..f09a406 100644 --- a/diet_guard/tests/_gate_fixtures.py +++ b/diet_guard/tests/_gate_fixtures.py @@ -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 diff --git a/diet_guard/tests/_tk_fakes.py b/diet_guard/tests/_tk_fakes.py index 69e98e5..0465380 100644 --- a/diet_guard/tests/_tk_fakes.py +++ b/diet_guard/tests/_tk_fakes.py @@ -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 diff --git a/diet_guard/tests/_tk_fakes_containers.py b/diet_guard/tests/_tk_fakes_containers.py index b517ac4..140ed74 100644 --- a/diet_guard/tests/_tk_fakes_containers.py +++ b/diet_guard/tests/_tk_fakes_containers.py @@ -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 ``<>``/``<>`` 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" diff --git a/diet_guard/tests/test_gatelock_calendar_schedule.py b/diet_guard/tests/test_gatelock_calendar_schedule.py index 0054308..0d6b18d 100644 --- a/diet_guard/tests/test_gatelock_calendar_schedule.py +++ b/diet_guard/tests/test_gatelock_calendar_schedule.py @@ -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 ``<>``/``<>`` 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, "") == "break" + # Locked: the event is swallowed and the value is unchanged. + assert _spin(gate, "first", "<>") == "break" assert gate._cal_vars.schedule.first.get() == "08:00" gate._on_edit_or_save_schedule() - assert self._fire(gate, "") == "break" + assert _spin(gate, "first", "<>") == "break" assert gate._cal_vars.schedule.first.get() == "08:15" - self._fire(gate, "") - self._fire(gate, "") + _spin(gate, "first", "<>") + _spin(gate, "first", "<>") 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[""]) - 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", "<>") + 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", "<>") + 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" diff --git a/diet_guard/tests/test_gatelock_fanout.py b/diet_guard/tests/test_gatelock_fanout.py index 9eec3d4..a16ab79 100644 --- a/diet_guard/tests/test_gatelock_fanout.py +++ b/diet_guard/tests/test_gatelock_fanout.py @@ -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) diff --git a/diet_guard/tests/test_gatelock_scheduleedit.py b/diet_guard/tests/test_gatelock_scheduleedit.py index e2206b0..8e8914b 100644 --- a/diet_guard/tests/test_gatelock_scheduleedit.py +++ b/diet_guard/tests/test_gatelock_scheduleedit.py @@ -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" diff --git a/docs/DOCS-meal-schedule.md b/docs/DOCS-meal-schedule.md index 43eb41e..e591e3b 100644 --- a/docs/DOCS-meal-schedule.md +++ b/docs/DOCS-meal-schedule.md @@ -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). `<>`/`<>` 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