screen-locker/screen_locker/_manual_workout_sport_fields.py
Krzysztof kuhy Rudnicki e5bfdd9a9a Make the Tk locker screens text-free
Every locker screen, the status window and the credit toast now show
glyphs (screen_locker/_glyphs.py) and numbers, coloured by state.
Sentences that carried a reason (verifier message, sync diagnosis,
sick refusal, form rejection) go to the log at warning instead.
New pre-commit hook text-free runs scripts/verify_text_free.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 01:20:23 +01:00

132 lines
5.9 KiB
Python

"""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.palette.bg)
# textgate: the "Sport:" caption is gone; the radio row is the cue.
for code, label in _manual_workout.SPORT_LABELS.items():
tk.Radiobutton(
row,
text=_manual_workout.SPORT_GLYPHS[code],
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.palette.fg,
bg=self._colors.palette.bg,
activeforeground=self._colors.palette.fg,
activebackground=self._colors.palette.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.palette.field_bg,
highlightthickness=0,
).pack(side="left", padx=self._colors.space("xs"))
# The workout-date field rides in this row, at no extra height.
self._mw_date_in_row(row)
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."""
# textgate: was "Matches won/lost:", "Sets won/lost:", "Racket used:",
# "Balls used:". Match / set glyph + win / loss mark.
self._mw_int_vars["matches_won"] = self._mw_int_field(parent, "◍✓")
self._mw_int_vars["matches_lost"] = self._mw_int_field(parent, "◍✗")
self._mw_int_vars["sets_won"] = self._mw_int_field(parent, "▤✓")
self._mw_int_vars["sets_lost"] = self._mw_int_field(parent, "▤✗")
self._mw_vars["racket"] = self._mw_entry(parent, "◖")
self._mw_vars["balls"] = self._mw_entry(parent, "•")
def _build_other_sport_fields(self, parent: tk.Widget) -> None:
"""Build the generic "other sport" fields."""
# Was "What sport/activity:" / "What was done (min N chars):" /
# "Equipment used (optional):"; optional fields are in parentheses.
self._mw_vars["activity_type_other"] = self._mw_entry(parent, "✱")
self._mw_text_widgets["activity_details"] = self._mw_textbox(
parent,
f"✱≡ ≥{MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS}",
)
self._mw_vars["equipment"] = self._mw_entry(parent, "(⊡)")
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