mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 13:47:38 +01:00
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:
parent
6dd52a11ae
commit
2d468bf5f8
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)],
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user