diet-guard/diet_guard/_gatelock_scheduleedit.py
Krzysztof kuhy Rudnicki 2d468bf5f8 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
2026-10-10 20:13:03 +02:00

174 lines
7.2 KiB
Python

"""Meal-schedule editing on the gate's History tab.
Sibling of :mod:`._gatelock_budgetedit`, with the same edit/save toggle shape:
the row displays the schedule read-only, "Edit" unlocks it, "Save" validates
and persists. ``_GateScheduleEdit`` sits between
:class:`~diet_guard._gatelock_budgetedit._GateBudgetEdit` and
:class:`~diet_guard._gatelock_calendar._GateCalendar` in the mixin chain.
Editing here changes what the gate itself asks for, so the value is validated
before it is written rather than relying on
:meth:`~diet_guard._meal_schedule.MealSchedule.normalized` to clamp it. The
clamp is the defence against a peer's corrupt sync data; a typo at the
keyboard should say so instead of silently becoming a different schedule.
The first and last meal are ``HH:MM`` times (any minute, not just the
quarter hours the dropdown offers); the rules mirror ``normalized()`` exactly
so that a schedule accepted here is stored unchanged.
"""
from __future__ import annotations
import abc
import contextlib
import tkinter as tk
from diet_guard._gatelock_budgetedit import _GateBudgetEdit
from diet_guard._gatelock_ui import ERR, FG
from diet_guard._meal_schedule import (
MAX_MEAL_COUNT,
MIN_MEAL_COUNT,
SLOT_GRID_MINUTES,
MealSchedule,
)
from diet_guard._meal_schedule_store import current_schedule, record_schedule_change
from diet_guard._slot_wire import parse_hhmm
from diet_guard._slots import day_slots, slot_label
__all__ = ["_GateScheduleEdit", "schedule_problem", "schedule_summary"]
# 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:
"""Return the derived checkpoint times, e.g. ``"08:00 11:00 ..."``."""
return " ".join(slot_label(slot) for slot in day_slots(schedule))
def schedule_problem(schedule: MealSchedule) -> str | None:
"""Return why ``schedule`` cannot be saved as typed, or None if it can.
Each rule is one ``normalized()`` would otherwise apply silently: the
15-minute minimum window, the meal-count range, and the capacity of the
window -- one checkpoint per grid step, because two meals closer than that
would snap onto the same quarter hour and a duplicate slot silently drops
a checkpoint.
"""
first, last = schedule.first_minute, schedule.last_minute
if last < first + SLOT_GRID_MINUTES:
return (
f"The last meal must be at least {SLOT_GRID_MINUTES} min after the first."
)
if not MIN_MEAL_COUNT <= schedule.count <= MAX_MEAL_COUNT:
return f"Meals per day must be {MIN_MEAL_COUNT}-{MAX_MEAL_COUNT}."
if schedule.count > (last - first) // SLOT_GRID_MINUTES + 1:
return "Too many meals for that window -- widen it or eat less often."
return None
class _GateScheduleEdit(_GateBudgetEdit):
"""The History tab's meal-schedule row: display, edit, validate, persist.
Like :class:`~diet_guard._gatelock_budgetedit._GateBudgetEdit`, this half
only reads and writes its own row; the tab's construction and refresh are
owned by :class:`~diet_guard._gatelock_calendar._GateCalendar`, further
down the mixin chain.
"""
_cal_editing_schedule: bool
@abc.abstractmethod
def _refresh_calendar(self) -> None:
"""Repaint the History tab; implemented by ``_GateCalendar``."""
def _set_schedule_entry_state(self, state: str) -> None:
"""Lock (``"readonly"``) or unlock (``"normal"``) the row's fields.
One suppress per widget: a monitor that vanished mid-update must not
stop the remaining fields from changing state.
"""
for surface in self._cal_surfaces:
for spin in (surface.schedule_first_entry, surface.schedule_last_entry):
with contextlib.suppress(tk.TclError):
spin.configure(state=_SPIN_STATE[state])
with contextlib.suppress(tk.TclError):
surface.schedule_count_entry.config(state=state)
def _set_schedule_button_text(self, text: str) -> None:
"""Relabel the schedule edit/save button on every monitor."""
for surface in self._cal_surfaces:
with contextlib.suppress(tk.TclError):
surface.schedule_edit_button.config(text=text)
def _set_schedule_status(self, text: str, *, error: bool) -> None:
"""Update the schedule-edit status line, red for errors."""
self._cal_vars.schedule.status.set(text)
colour = ERR if error else FG
for surface in self._cal_surfaces:
with contextlib.suppress(tk.TclError):
surface.schedule_status_label.config(fg=colour)
def _show_schedule(self, schedule: MealSchedule) -> None:
"""Populate the row's fields and derived-times label."""
self._cal_vars.schedule.first.set(slot_label(schedule.first_minute))
self._cal_vars.schedule.last.set(slot_label(schedule.last_minute))
self._cal_vars.schedule.count.set(str(schedule.count))
self._cal_vars.schedule.times.set(schedule_summary(schedule))
def _on_edit_or_save_schedule(self) -> None:
"""Toggle the schedule row between read-only display and editing.
Mirrors :meth:`_GateBudgetEdit._on_edit_or_save_budget`: a failed
validation leaves editing open so the value can be corrected rather
than silently discarded.
"""
if not self._cal_editing_schedule:
self._cal_editing_schedule = True
self._set_schedule_entry_state("normal")
self._set_schedule_button_text("Save")
self._set_schedule_status("", error=False)
return
if not self._save_schedule_entry():
return
self._cal_editing_schedule = False
self._set_schedule_entry_state("readonly")
self._set_schedule_button_text("Edit")
self._refresh_calendar()
def _read_schedule_fields(self) -> MealSchedule | None:
"""Parse the three fields, or None (with a status set) if unusable."""
try:
first = parse_hhmm(self._cal_vars.schedule.first.get())
last = parse_hhmm(self._cal_vars.schedule.last.get())
except ValueError:
self._set_schedule_status(
"Enter meal times as HH:MM, e.g. 07:15.", error=True
)
return None
try:
count = int(self._cal_vars.schedule.count.get().strip())
except ValueError:
self._set_schedule_status("Enter meals per day as a number.", error=True)
return None
return MealSchedule(first, last, count)
def _save_schedule_entry(self) -> bool:
"""Validate and persist the row's current values.
Returns:
Whether the schedule was valid and persisted.
"""
schedule = self._read_schedule_fields()
if schedule is None:
return False
problem = schedule_problem(schedule)
if problem is not None:
self._set_schedule_status(problem, error=True)
return False
record_schedule_change(schedule)
self._show_schedule(current_schedule())
self._set_schedule_status("Saved.", error=False)
return True