Split _manual_workout_dialog.py and _manual_workout.py

_manual_workout_dialog.py (357) -> _manual_workout_sport_fields.py
                                     ManualWorkoutSportFieldsMixin
                                     (+ _mw_scrollable_form moved into the
                                      existing widgets mixin, where the
                                      generic scaffold belongs)
  _manual_workout.py (355)        -> _manual_workout_types.py: the sport
                                     constants, ManualWorkoutDraft, and the
                                     rolling budget accounting

A painting mixin cannot simply move: conftest's mock_tk fixture patches `tk`
per-module through the _TK_MODULES list, so the new module had to be
registered there or its tkinter calls reach a real display. That is what the
two dialog tests caught.

Test count 976 -> 977: _manual_workout_sport_fields is a new lock-surface
module, so test_no_popup_widgets now safety-checks it too. Coverage stays
100%, pylint holds at 8.70/10, `screen_lock --status` unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 20:16:33 +02:00
parent bdc405c8c9
commit f5c6c2fb9d
6 changed files with 353 additions and 276 deletions

View File

@ -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."""

View File

@ -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 ``<MouseWheel>``, ``<Prior>``/``<Next>`` 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:

View File

@ -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

View File

@ -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 = ""

View File

@ -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 ``<MouseWheel>``, ``<Prior>``/``<Next>`` 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

View File

@ -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",