diff --git a/screen_locker/_manual_workout.py b/screen_locker/_manual_workout.py index 2b89886..63f470c 100644 --- a/screen_locker/_manual_workout.py +++ b/screen_locker/_manual_workout.py @@ -15,14 +15,10 @@ see :class:`ManualWorkoutDraft` and :data:`SPORT_CHOICES`. from __future__ import annotations -from dataclasses import dataclass -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone import logging -from typing import TYPE_CHECKING from screen_locker._constants import ( - MANUAL_WORKOUT_BUDGET_PER_7_DAYS, - MANUAL_WORKOUT_BUDGET_PER_30_DAYS, MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS, MANUAL_WORKOUT_REFLECTION_MIN_CHARS, MANUAL_WORKOUT_RPE_MAX, @@ -30,137 +26,36 @@ from screen_locker._constants import ( MIN_WORKOUT_DURATION_MINUTES, WORKOUT_DURATION_ACCEPT_MINUTES, ) -from screen_locker._log_io import load_workout_log +from screen_locker._manual_workout_types import ( + MANUAL_WORKOUT_TYPE, + SPORT_CHOICES, + SPORT_LABELS, + SPORT_OTHER, + SPORT_TABLE_TENNIS, + ManualWorkoutDraft, + budget_summary, + count_in_window, + is_budget_exhausted, +) -if TYPE_CHECKING: - from pathlib import Path +__all__ = [ + "MANUAL_WORKOUT_TYPE", + "SPORT_CHOICES", + "SPORT_LABELS", + "SPORT_OTHER", + "SPORT_TABLE_TENNIS", + "ManualWorkoutDraft", + "budget_summary", + "build_entry", + "build_sync_payload", + "count_in_window", + "is_budget_exhausted", + "manual_sync_record_id", + "validate_manual_workout", +] _logger = logging.getLogger(__name__) -MANUAL_WORKOUT_TYPE = "manual_workout" - -SPORT_TABLE_TENNIS = "table_tennis" -SPORT_OTHER = "other" -SPORT_CHOICES: tuple[str, ...] = (SPORT_TABLE_TENNIS, SPORT_OTHER) -SPORT_LABELS: dict[str, str] = { - SPORT_TABLE_TENNIS: "Table tennis", - SPORT_OTHER: "Other", -} - - -def _today_iso() -> str: - """Return today's date as ``YYYY-MM-DD`` (UTC).""" - return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") - - -def _parse_iso(date_str: str) -> datetime | None: - """Parse ``YYYY-MM-DD`` into a UTC datetime, or return None.""" - try: - return datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc) - except ValueError as exc: - _logger.warning( - "Workout log has an unparsable date key %r (%s) — that day is " - "IGNORED when counting the manual-workout budget", - date_str, - exc, - ) - return None - - -def count_in_window( - log_file: Path, - days: int, - *, - today: str | None = None, -) -> int: - """Return how many ``manual_workout`` ENTRIES fall in the trailing window. - - Counted per entry, not per day: each logged manual workout consumes its - own budget slot, matching how each one also earns its own weekly-count - and shutdown credit (see ``screen_locker._weekly_check.COUNTED_WORKOUT_TYPES`` - and ``screen_locker._workout_credit``) — the budget is the sole limiter on - how many you can log, not a once-per-day collapse. - """ - today_str = today or _today_iso() - today_dt = _parse_iso(today_str) - if today_dt is None: - return 0 - cutoff = today_dt - timedelta(days=days) - count = 0 - for date_str, entries in load_workout_log(log_file).items(): - parsed = _parse_iso(date_str) - if parsed is None or not (cutoff < parsed <= today_dt): - continue - count += sum( - 1 - for entry in entries - if entry.get("workout_data", {}).get("type") == MANUAL_WORKOUT_TYPE - ) - return count - - -def is_budget_exhausted( - log_file: Path, - *, - today: str | None = None, -) -> bool: - """Return True if either rolling window has reached its manual-workout budget.""" - return ( - count_in_window(log_file, 7, today=today) >= MANUAL_WORKOUT_BUDGET_PER_7_DAYS - or count_in_window(log_file, 30, today=today) - >= MANUAL_WORKOUT_BUDGET_PER_30_DAYS - ) - - -def budget_summary( - log_file: Path, - *, - today: str | None = None, -) -> str: - """One-line UI summary string for the manual-workout budget.""" - week = count_in_window(log_file, 7, today=today) - month = count_in_window(log_file, 30, today=today) - return ( - f"Manual: {week}/{MANUAL_WORKOUT_BUDGET_PER_7_DAYS}w · " - f"{month}/{MANUAL_WORKOUT_BUDGET_PER_30_DAYS}m" - ) - - -@dataclass -class ManualWorkoutDraft: - """User-supplied evidence fields for a manual (unverified) workout. - - Fields shared by every sport come first (required unless noted). - Sport-specific fields follow, grouped by which ``sport`` they apply to — - only the group matching ``sport`` is validated/persisted. - """ - - sport: str - start_time: str # "HH:MM" - end_time: str # "HH:MM" - location_name: str - transport_method: str - cost: str - rpe: int - went_well: str - to_improve: str - overall_feeling: str - reservation_phone: str = "" - techniques_practiced: str = "" - warm_up_minutes: str = "" - pain_or_injury: str = "none" - # SPORT_TABLE_TENNIS fields - matches_won: int = 0 - matches_lost: int = 0 - sets_won: int = 0 - sets_lost: int = 0 - racket: str = "" - balls: str = "" - # SPORT_OTHER fields - activity_type_other: str = "" - activity_details: str = "" - equipment: str = "" - def _parse_hhmm(value: str) -> datetime | None: """Parse an ``HH:MM`` string into a datetime on an arbitrary fixed date.""" diff --git a/screen_locker/_manual_workout_dialog.py b/screen_locker/_manual_workout_dialog.py index bd8921c..fcecb5e 100644 --- a/screen_locker/_manual_workout_dialog.py +++ b/screen_locker/_manual_workout_dialog.py @@ -21,9 +21,9 @@ import tkinter as tk from screen_locker import _manual_workout from screen_locker._constants import ( - MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS, MANUAL_WORKOUT_REFLECTION_MIN_CHARS, ) +from screen_locker._manual_workout_sport_fields import ManualWorkoutSportFieldsMixin from screen_locker._manual_workout_widgets import ManualWorkoutFormWidgetsMixin _logger = logging.getLogger(__name__) @@ -33,7 +33,10 @@ _SPORT_LABEL_TO_CODE = { } -class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin): +class ManualWorkoutDialogMixin( + ManualWorkoutFormWidgetsMixin, + ManualWorkoutSportFieldsMixin, +): """Renders the manual-workout evidence form and handles submission.""" def _show_manual_workout_form(self) -> None: @@ -66,40 +69,6 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin): ) self._build_manual_workout_form() - def _mw_scrollable_form(self) -> tk.Frame: - """Create the two-column form frame inside the surface's viewport. - - This used to build its *own* ``Canvas`` + ``Scrollbar`` viewport, which - had three problems that are all gone by deletion now that the surface - container is itself a :class:`~gatelock.ScrollableSurface`: - - 1. **Scrolling was pointer-only.** ``tk.Canvas`` has no class-level key - bindings, so ``::tk::FocusOK`` rejected it as a focus stop, and the - canvas bound no ````, ````/```` or arrows. - The scrollbar thumb had to be *dragged* -- inside a lock that cannot - be dismissed without submitting this form. - 2. **Focus walked off-screen.** Canvas clipping does not unmap a child, - so every below-the-fold field stayed ``winfo viewable`` and stayed in - the tab ring while Tk never scrolled to follow focus. Tab led to - fields the user could neither see nor bring into view. - 3. **The height was a magic fraction.** ``height=0.7 * toplevel`` sat - above ~230-290px of fixed chrome (title, budget line, error label, - and a 24pt button row), so the constraint was ``0.3 * H >= chrome``: - satisfied at 1080p, violated at 768p, where SUBMIT/BACK were pushed - off the bottom of a centred, unscrollable container. - - Still built on the primary surface only -- an independently scrolled - copy per monitor would show two different parts of one form. - """ - form = tk.Frame(self.container.first, bg=self._colors.bg) - # No outer gap: the budget line above and the first section heading - # below already separate the grid, and on a 1024x600 panel this form - # fits by single-digit pixels. - form.pack(fill="both", expand=True) - form.grid_columnconfigure(0, weight=1, uniform="mw") - form.grid_columnconfigure(1, weight=1, uniform="mw") - return form - def _build_manual_workout_form(self) -> None: """Build the two-column evidence form and submit/back buttons. @@ -200,114 +169,6 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin): width=12, ).pack(side="left", padx=self._colors.space("sm")) - def _mw_sport_row(self, parent: tk.Widget) -> None: - """Add the sport-selector radio buttons; swaps the activity section. - - CANONICAL ACCOUNT OF THE 2026-07-26 BUG -- other files point here. - - This was a ``tk.OptionMenu``. A posted Tk menu is a separate - override-redirect toplevel that takes the Tk grab for itself, and - gatelock's 1 s recovery tick killed it within a second of opening: - ``_surfaces.enforce()`` lifts the lock surface over the menu, and - ``_reassert_grab()`` saw the grab sitting on the menu rather than the - root and yanked it back. The selector stayed stuck on "Table tennis" - while locked, and a walk got logged in the table-tennis form. - - Radio buttons are ordinary children of the surface: they lift with it - and live inside the root's grab tree, so neither mechanism can reach - them. Nothing on a lock surface may open a popup window -- - ``tests/test_no_popup_widgets.py`` enforces that statically and - ``scripts/verify_lock_popup_safety.py`` behaviourally. - """ - self._mw_sport_var = tk.StringVar( - value=_manual_workout.SPORT_LABELS[_manual_workout.SPORT_TABLE_TENNIS] - ) - row = tk.Frame(parent, bg=self._colors.bg) - tk.Label( - row, - text="Sport:", - font=self._colors.font("body"), - fg=self._colors.fg, - bg=self._colors.bg, - ).pack(side="left", padx=self._colors.space("xs")) - for label in _manual_workout.SPORT_LABELS.values(): - tk.Radiobutton( - row, - text=label, - value=label, - variable=self._mw_sport_var, - # Radiobutton's command takes no argument -- bind the label. - command=lambda chosen=label: self._on_mw_sport_changed(chosen), - font=self._colors.font("body"), - fg=self._colors.fg, - bg=self._colors.bg, - activeforeground=self._colors.fg, - activebackground=self._colors.bg, - # Without selectcolor the indicator is a white blob on the dark - # overlay; the OptionMenu was the one unstyled widget in this - # form, which likely added to it reading as dead. - selectcolor=self._colors.field_bg, - highlightthickness=0, - ).pack(side="left", padx=self._colors.space("xs")) - self._mw_grid(parent, row, full=True) - - def _on_mw_sport_changed(self, selected_label: str) -> None: - """Show the fields for the newly-selected sport, hide the other's. - - Both frames share one grid slot; ``grid_remove`` hides a frame while - remembering its cell options so ``grid`` restores it in place. - """ - sport = _SPORT_LABEL_TO_CODE.get( - selected_label, _manual_workout.SPORT_TABLE_TENNIS - ) - if sport == _manual_workout.SPORT_TABLE_TENNIS: - self._mw_other_frame.grid_remove() - self._mw_tt_frame.grid() - else: - self._mw_tt_frame.grid_remove() - self._mw_other_frame.grid() - - def _build_table_tennis_fields(self, parent: tk.Widget) -> None: - """Build the table-tennis-specific score/equipment fields.""" - self._mw_int_vars["matches_won"] = self._mw_int_field(parent, "Matches won:") - self._mw_int_vars["matches_lost"] = self._mw_int_field(parent, "Matches lost:") - self._mw_int_vars["sets_won"] = self._mw_int_field(parent, "Sets won:") - self._mw_int_vars["sets_lost"] = self._mw_int_field(parent, "Sets lost:") - self._mw_vars["racket"] = self._mw_entry(parent, "Racket used:") - self._mw_vars["balls"] = self._mw_entry(parent, "Balls used:") - - def _build_other_sport_fields(self, parent: tk.Widget) -> None: - """Build the generic "other sport" fields.""" - self._mw_vars["activity_type_other"] = self._mw_entry( - parent, "What sport/activity:" - ) - self._mw_text_widgets["activity_details"] = self._mw_textbox( - parent, - f"What was done (min {MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS} chars):", - ) - self._mw_vars["equipment"] = self._mw_entry( - parent, "Equipment used (optional):" - ) - - def _current_mw_sport(self) -> str: - """Return the internal sport code for the currently-selected label.""" - return _SPORT_LABEL_TO_CODE.get( - self._mw_sport_var.get(), _manual_workout.SPORT_TABLE_TENNIS - ) - - def _mw_int_value(self, key: str) -> int: - """Read an int Spinbox var, defaulting to 0 on an invalid value.""" - try: - return int(self._mw_int_vars[key].get()) - except (tk.TclError, ValueError) as exc: - _logger.warning( - "Manual workout field %r holds a non-integer value (%s) — " - "recording it as 0", - key, - exc, - ) - return 0 - def _submit_manual_workout_form(self) -> None: """Validate the form and either show an error or save + notify host.""" try: diff --git a/screen_locker/_manual_workout_sport_fields.py b/screen_locker/_manual_workout_sport_fields.py new file mode 100644 index 0000000..ced1c29 --- /dev/null +++ b/screen_locker/_manual_workout_sport_fields.py @@ -0,0 +1,135 @@ +"""Sport-specific rows of the manual-workout evidence form. + +Split out of :mod:`screen_locker._manual_workout_dialog` to keep every file +under the 250-line cap. Composed back into ``ManualWorkoutDialogMixin`` there, +so both host classes (``ScreenLocker`` and ``StatusWindow``) are unchanged. + +Choosing a sport swaps in that sport's fields rather than showing one generic +free-text blob -- see ``_manual_workout.SPORT_CHOICES``. +""" + +from __future__ import annotations + +import logging +import tkinter as tk + +from screen_locker import _manual_workout +from screen_locker._constants import MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS + +_logger = logging.getLogger(__name__) + +_SPORT_LABEL_TO_CODE = { + label: code for code, label in _manual_workout.SPORT_LABELS.items() +} + + +class ManualWorkoutSportFieldsMixin: + """Builds the sport picker and the per-sport detail rows.""" + + def _mw_sport_row(self, parent: tk.Widget) -> None: + """Add the sport-selector radio buttons; swaps the activity section. + + CANONICAL ACCOUNT OF THE 2026-07-26 BUG -- other files point here. + + This was a ``tk.OptionMenu``. A posted Tk menu is a separate + override-redirect toplevel that takes the Tk grab for itself, and + gatelock's 1 s recovery tick killed it within a second of opening: + ``_surfaces.enforce()`` lifts the lock surface over the menu, and + ``_reassert_grab()`` saw the grab sitting on the menu rather than the + root and yanked it back. The selector stayed stuck on "Table tennis" + while locked, and a walk got logged in the table-tennis form. + + Radio buttons are ordinary children of the surface: they lift with it + and live inside the root's grab tree, so neither mechanism can reach + them. Nothing on a lock surface may open a popup window -- + ``tests/test_no_popup_widgets.py`` enforces that statically and + ``scripts/verify_lock_popup_safety.py`` behaviourally. + """ + self._mw_sport_var = tk.StringVar( + value=_manual_workout.SPORT_LABELS[_manual_workout.SPORT_TABLE_TENNIS] + ) + row = tk.Frame(parent, bg=self._colors.bg) + tk.Label( + row, + text="Sport:", + font=self._colors.font("body"), + fg=self._colors.fg, + bg=self._colors.bg, + ).pack(side="left", padx=self._colors.space("xs")) + for label in _manual_workout.SPORT_LABELS.values(): + tk.Radiobutton( + row, + text=label, + value=label, + variable=self._mw_sport_var, + # Radiobutton's command takes no argument -- bind the label. + command=lambda chosen=label: self._on_mw_sport_changed(chosen), + font=self._colors.font("body"), + fg=self._colors.fg, + bg=self._colors.bg, + activeforeground=self._colors.fg, + activebackground=self._colors.bg, + # Without selectcolor the indicator is a white blob on the dark + # overlay; the OptionMenu was the one unstyled widget in this + # form, which likely added to it reading as dead. + selectcolor=self._colors.field_bg, + highlightthickness=0, + ).pack(side="left", padx=self._colors.space("xs")) + self._mw_grid(parent, row, full=True) + + def _on_mw_sport_changed(self, selected_label: str) -> None: + """Show the fields for the newly-selected sport, hide the other's. + + Both frames share one grid slot; ``grid_remove`` hides a frame while + remembering its cell options so ``grid`` restores it in place. + """ + sport = _SPORT_LABEL_TO_CODE.get( + selected_label, _manual_workout.SPORT_TABLE_TENNIS + ) + if sport == _manual_workout.SPORT_TABLE_TENNIS: + self._mw_other_frame.grid_remove() + self._mw_tt_frame.grid() + else: + self._mw_tt_frame.grid_remove() + self._mw_other_frame.grid() + + def _build_table_tennis_fields(self, parent: tk.Widget) -> None: + """Build the table-tennis-specific score/equipment fields.""" + self._mw_int_vars["matches_won"] = self._mw_int_field(parent, "Matches won:") + self._mw_int_vars["matches_lost"] = self._mw_int_field(parent, "Matches lost:") + self._mw_int_vars["sets_won"] = self._mw_int_field(parent, "Sets won:") + self._mw_int_vars["sets_lost"] = self._mw_int_field(parent, "Sets lost:") + self._mw_vars["racket"] = self._mw_entry(parent, "Racket used:") + self._mw_vars["balls"] = self._mw_entry(parent, "Balls used:") + + def _build_other_sport_fields(self, parent: tk.Widget) -> None: + """Build the generic "other sport" fields.""" + self._mw_vars["activity_type_other"] = self._mw_entry( + parent, "What sport/activity:" + ) + self._mw_text_widgets["activity_details"] = self._mw_textbox( + parent, + f"What was done (min {MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS} chars):", + ) + self._mw_vars["equipment"] = self._mw_entry( + parent, "Equipment used (optional):" + ) + + def _current_mw_sport(self) -> str: + """Return the internal sport code for the currently-selected label.""" + return _SPORT_LABEL_TO_CODE.get( + self._mw_sport_var.get(), _manual_workout.SPORT_TABLE_TENNIS + ) + + def _mw_int_value(self, key: str) -> int: + """Read an int Spinbox var, defaulting to 0 on an invalid value.""" + try: + return int(self._mw_int_vars[key].get()) + except (tk.TclError, ValueError) as exc: + _logger.warning( + "Manual workout field %r holds a non-integer value (%s) — " + "recording it as 0", + key, + exc, + ) + return 0 diff --git a/screen_locker/_manual_workout_types.py b/screen_locker/_manual_workout_types.py new file mode 100644 index 0000000..34956af --- /dev/null +++ b/screen_locker/_manual_workout_types.py @@ -0,0 +1,151 @@ +"""Manual-workout sports, the draft record, and rolling budget accounting. + +Split out of :mod:`screen_locker._manual_workout` to keep every file under the +250-line cap. Everything here is re-exported from there, so callers and their +patch targets are unchanged. + +The budget is what stops a manual entry from being an unlimited escape hatch: +manual workouts are self-reported, so only a few count in any rolling window. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +import logging +from typing import TYPE_CHECKING + +from screen_locker._constants import ( + MANUAL_WORKOUT_BUDGET_PER_7_DAYS, + MANUAL_WORKOUT_BUDGET_PER_30_DAYS, +) +from screen_locker._log_io import load_workout_log + +if TYPE_CHECKING: + from pathlib import Path + +_logger = logging.getLogger(__name__) + +MANUAL_WORKOUT_TYPE = "manual_workout" + +SPORT_TABLE_TENNIS = "table_tennis" +SPORT_OTHER = "other" +SPORT_CHOICES: tuple[str, ...] = (SPORT_TABLE_TENNIS, SPORT_OTHER) +SPORT_LABELS: dict[str, str] = { + SPORT_TABLE_TENNIS: "Table tennis", + SPORT_OTHER: "Other", +} + + +def _today_iso() -> str: + """Return today's date as ``YYYY-MM-DD`` (UTC).""" + return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + + +def _parse_iso(date_str: str) -> datetime | None: + """Parse ``YYYY-MM-DD`` into a UTC datetime, or return None.""" + try: + return datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc) + except ValueError as exc: + _logger.warning( + "Workout log has an unparsable date key %r (%s) — that day is " + "IGNORED when counting the manual-workout budget", + date_str, + exc, + ) + return None + + +def count_in_window( + log_file: Path, + days: int, + *, + today: str | None = None, +) -> int: + """Return how many ``manual_workout`` ENTRIES fall in the trailing window. + + Counted per entry, not per day: each logged manual workout consumes its + own budget slot, matching how each one also earns its own weekly-count + and shutdown credit (see ``screen_locker._weekly_check.COUNTED_WORKOUT_TYPES`` + and ``screen_locker._workout_credit``) — the budget is the sole limiter on + how many you can log, not a once-per-day collapse. + """ + today_str = today or _today_iso() + today_dt = _parse_iso(today_str) + if today_dt is None: + return 0 + cutoff = today_dt - timedelta(days=days) + count = 0 + for date_str, entries in load_workout_log(log_file).items(): + parsed = _parse_iso(date_str) + if parsed is None or not (cutoff < parsed <= today_dt): + continue + count += sum( + 1 + for entry in entries + if entry.get("workout_data", {}).get("type") == MANUAL_WORKOUT_TYPE + ) + return count + + +def is_budget_exhausted( + log_file: Path, + *, + today: str | None = None, +) -> bool: + """Return True if either rolling window has reached its manual-workout budget.""" + return ( + count_in_window(log_file, 7, today=today) >= MANUAL_WORKOUT_BUDGET_PER_7_DAYS + or count_in_window(log_file, 30, today=today) + >= MANUAL_WORKOUT_BUDGET_PER_30_DAYS + ) + + +def budget_summary( + log_file: Path, + *, + today: str | None = None, +) -> str: + """One-line UI summary string for the manual-workout budget.""" + week = count_in_window(log_file, 7, today=today) + month = count_in_window(log_file, 30, today=today) + return ( + f"Manual: {week}/{MANUAL_WORKOUT_BUDGET_PER_7_DAYS}w · " + f"{month}/{MANUAL_WORKOUT_BUDGET_PER_30_DAYS}m" + ) + + +@dataclass +class ManualWorkoutDraft: + """User-supplied evidence fields for a manual (unverified) workout. + + Fields shared by every sport come first (required unless noted). + Sport-specific fields follow, grouped by which ``sport`` they apply to — + only the group matching ``sport`` is validated/persisted. + """ + + sport: str + start_time: str # "HH:MM" + end_time: str # "HH:MM" + location_name: str + transport_method: str + cost: str + rpe: int + went_well: str + to_improve: str + overall_feeling: str + reservation_phone: str = "" + techniques_practiced: str = "" + warm_up_minutes: str = "" + pain_or_injury: str = "none" + # SPORT_TABLE_TENNIS fields + matches_won: int = 0 + matches_lost: int = 0 + sets_won: int = 0 + sets_lost: int = 0 + racket: str = "" + balls: str = "" + # SPORT_OTHER fields + activity_type_other: str = "" + activity_details: str = "" + equipment: str = "" diff --git a/screen_locker/_manual_workout_widgets.py b/screen_locker/_manual_workout_widgets.py index 01e85b4..f2ecffe 100644 --- a/screen_locker/_manual_workout_widgets.py +++ b/screen_locker/_manual_workout_widgets.py @@ -208,3 +208,37 @@ class ManualWorkoutFormWidgetsMixin: **self._colors.focus_kwargs(), ).pack(side="left", padx=self._colors.space("xs")) self._mw_grid(parent, row) + + def _mw_scrollable_form(self) -> tk.Frame: + """Create the two-column form frame inside the surface's viewport. + + This used to build its *own* ``Canvas`` + ``Scrollbar`` viewport, which + had three problems that are all gone by deletion now that the surface + container is itself a :class:`~gatelock.ScrollableSurface`: + + 1. **Scrolling was pointer-only.** ``tk.Canvas`` has no class-level key + bindings, so ``::tk::FocusOK`` rejected it as a focus stop, and the + canvas bound no ````, ````/```` or arrows. + The scrollbar thumb had to be *dragged* -- inside a lock that cannot + be dismissed without submitting this form. + 2. **Focus walked off-screen.** Canvas clipping does not unmap a child, + so every below-the-fold field stayed ``winfo viewable`` and stayed in + the tab ring while Tk never scrolled to follow focus. Tab led to + fields the user could neither see nor bring into view. + 3. **The height was a magic fraction.** ``height=0.7 * toplevel`` sat + above ~230-290px of fixed chrome (title, budget line, error label, + and a 24pt button row), so the constraint was ``0.3 * H >= chrome``: + satisfied at 1080p, violated at 768p, where SUBMIT/BACK were pushed + off the bottom of a centred, unscrollable container. + + Still built on the primary surface only -- an independently scrolled + copy per monitor would show two different parts of one form. + """ + form = tk.Frame(self.container.first, bg=self._colors.bg) + # No outer gap: the budget line above and the first section heading + # below already separate the grid, and on a 1024x600 panel this form + # fits by single-digit pixels. + form.pack(fill="both", expand=True) + form.grid_columnconfigure(0, weight=1, uniform="mw") + form.grid_columnconfigure(1, weight=1, uniform="mw") + return form diff --git a/screen_locker/tests/conftest.py b/screen_locker/tests/conftest.py index 99f6241..efecb19 100644 --- a/screen_locker/tests/conftest.py +++ b/screen_locker/tests/conftest.py @@ -44,6 +44,7 @@ _TK_MODULES = ( "screen_locker.screen_lock", "screen_locker._sick_dialog", "screen_locker._manual_workout_dialog", + "screen_locker._manual_workout_sport_fields", "screen_locker._manual_workout_widgets", "screen_locker._ui_widgets", "screen_locker._window_setup",