screen-locker/screen_locker/_manual_workout_dialog.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

224 lines
9.7 KiB
Python

"""Manual (unverified) workout evidence-form dialog mixin.
Composed onto both ``ScreenLocker`` (locked-out entry point) and
``StatusWindow`` (voluntary, anytime entry point). Host classes must
implement two hooks:
- ``_on_manual_workout_saved(entry: dict) -> None`` — called with the built
``workout_data`` dict once the form validates; the host decides how to
persist it (and, for ``ScreenLocker``, unlock afterward).
- ``_on_manual_workout_cancelled() -> None`` — called when BACK is pressed.
The form's activity-details section is sport-specific (see
``_manual_workout.SPORT_CHOICES``): choosing a sport swaps in that sport's
fields via ``_on_mw_sport_changed`` rather than one generic free-text blob.
"""
from __future__ import annotations
import logging
import tkinter as tk
from screen_locker import _manual_workout
from screen_locker._constants import (
MANUAL_WORKOUT_REFLECTION_MIN_CHARS,
)
from screen_locker._manual_workout_date import (
BACK_GLYPH,
REJECTED_GLYPH,
ManualWorkoutDateMixin,
)
from screen_locker._manual_workout_sport_fields import ManualWorkoutSportFieldsMixin
from screen_locker._manual_workout_widgets import ManualWorkoutFormWidgetsMixin
_logger = logging.getLogger(__name__)
# textgate glyphs (plain Unicode, no colour emoji: Tk 8.6 can crash on them).
FORM_GLYPH = "≡"
"""Form heading (accent); was "Log Manual Workout"."""
EXHAUSTED_GLYPH = "✕"
"""Budget used up (danger); was the "budget exhausted" sentence."""
SUBMIT_GLYPH = "✓"
"""Submit button (accent fill); was "SUBMIT"."""
_SPORT_LABEL_TO_CODE = {
label: code for code, label in _manual_workout.SPORT_LABELS.items()
}
class ManualWorkoutDialogMixin(
ManualWorkoutDateMixin,
ManualWorkoutFormWidgetsMixin,
ManualWorkoutSportFieldsMixin,
):
"""Renders the manual-workout evidence form and handles submission."""
def _show_manual_workout_form(self) -> None:
"""Render the manual-workout evidence form, or a budget-exhausted note."""
self.clear_container()
# textgate: glyphs, colours second. Was "Log Manual Workout" heading /
# "Manual-workout budget exhausted for this window." / "BACK".
self._label(FORM_GLYPH, color=self._colors.palette.accent, pad="xs")
if self._mw_budget_closed():
self._label(EXHAUSTED_GLYPH, color=self._colors.palette.danger, pad="xs")
self._text(
_manual_workout.budget_summary(self.log_file),
color=self._colors.palette.muted,
)
row = self._button_row()
self._button(
row,
BACK_GLYPH,
bg=self._colors.palette.field_bg,
command=self._on_manual_workout_cancelled,
width=12,
).pack(side="left", padx=self._colors.space("sm"))
return
self._text(
_manual_workout.budget_summary(self.log_file),
role="label",
color=self._colors.palette.accent,
pad="xs",
)
self._build_manual_workout_form()
def _build_manual_workout_form(self) -> None:
"""Build the two-column evidence form and submit/back buttons.
Large labels/inputs and a screen-filling grid (see
``_mw_scrollable_form``) so the form is usable with a keyboard while
locked out, rather than a cramped narrow column.
"""
form = self._mw_scrollable_form()
# Independent 2-column grid cursor per master (form + sport sub-frames).
self._mw_grid_counters: dict[int, int] = {}
self._mw_vars: dict[str, tk.StringVar] = {}
self._mw_int_vars: dict[str, tk.IntVar] = {}
self._mw_rpe_var = tk.IntVar(value=5)
self._mw_text_widgets: dict[str, tk.Text] = {}
# textgate: every prompt is a glyph; optional fields are in
# parentheses; section headings are plain rules. The old prompts were
# the Basics heading, start and end time, the location heading and
# name, how you got there, the cost in PLN, the optional reservation
# phone number, and the activity-details heading.
self._mw_section(form)
self._mw_sport_row(form)
self._mw_vars["start_time"] = self._mw_entry(form, "◷▶", focus=True)
self._mw_vars["end_time"] = self._mw_entry(form, "◷■")
self._mw_section(form)
self._mw_vars["location_name"] = self._mw_entry(form, "⌖")
self._mw_vars["transport_method"] = self._mw_entry(form, "⇄")
self._mw_vars["cost"] = self._mw_entry(form, "¤")
self._mw_vars["reservation_phone"] = self._mw_entry(form, "(▯#)")
self._mw_section(form)
# Both sport sub-frames occupy the same full-width grid slot; only the
# selected one is shown (see _on_mw_sport_changed).
act_row = self._mw_next_full_row(form)
self._mw_tt_frame = tk.Frame(form, bg=self._colors.palette.bg)
self._mw_other_frame = tk.Frame(form, bg=self._colors.palette.bg)
for sport_frame in (self._mw_tt_frame, self._mw_other_frame):
sport_frame.grid_columnconfigure(0, weight=1, uniform="mwact")
sport_frame.grid_columnconfigure(1, weight=1, uniform="mwact")
sport_frame.grid(
row=act_row,
column=0,
columnspan=2,
sticky="ew",
padx=self._colors.space("sm"),
pady=self._colors.space("xs"),
)
self._build_table_tennis_fields(self._mw_tt_frame)
self._build_other_sport_fields(self._mw_other_frame)
self._mw_rpe_row(form)
# Was "Techniques/focus areas practiced (optional):", "Warm-up
# duration (optional):", "Pain or injury notes (optional, ...)".
self._mw_vars["techniques_practiced"] = self._mw_entry(form, "(◎)")
self._mw_vars["warm_up_minutes"] = self._mw_entry(form, "(∿)")
self._mw_vars["pain_or_injury"] = self._mw_entry(form, "(↯)")
self._on_mw_sport_changed(
_manual_workout.SPORT_LABELS[_manual_workout.SPORT_TABLE_TENNIS]
)
# Was "Reflection", "What went well / What to improve / Overall
# feeling about the session (min N chars):".
self._mw_section(form)
least = f"≥{MANUAL_WORKOUT_REFLECTION_MIN_CHARS}"
self._mw_text_widgets["went_well"] = self._mw_textbox(form, f"+ {least}")
self._mw_text_widgets["to_improve"] = self._mw_textbox(form, f"↑ {least}")
self._mw_text_widgets["overall_feeling"] = self._mw_textbox(form, f"~ {least}")
self._mw_error_label = self._text(
"", role="label", color=self._colors.palette.danger, pad="xs"
)
button_row = self._button_row()
self._button(
button_row,
SUBMIT_GLYPH,
bg=self._colors.palette.accent,
command=self._submit_manual_workout_form,
width=12,
).pack(side="left", padx=self._colors.space("sm"))
self._button(
button_row,
BACK_GLYPH,
bg=self._colors.palette.field_bg,
command=self._on_manual_workout_cancelled,
width=12,
).pack(side="left", padx=self._colors.space("sm"))
def _submit_manual_workout_form(self) -> None:
"""Validate the form and either show an error or save + notify host."""
try:
rpe = int(self._mw_rpe_var.get())
except (tk.TclError, ValueError) as exc:
_logger.warning(
"Manual workout RPE is not an integer (%s) — submitting it as "
"0, which the form validator will reject",
exc,
)
rpe = 0
sport = self._current_mw_sport()
draft = _manual_workout.ManualWorkoutDraft(
sport=sport,
start_time=self._mw_vars["start_time"].get(),
end_time=self._mw_vars["end_time"].get(),
location_name=self._mw_vars["location_name"].get(),
transport_method=self._mw_vars["transport_method"].get(),
cost=self._mw_vars["cost"].get(),
rpe=rpe,
went_well=self._mw_text_widgets["went_well"].get("1.0", "end"),
to_improve=self._mw_text_widgets["to_improve"].get("1.0", "end"),
overall_feeling=self._mw_text_widgets["overall_feeling"].get("1.0", "end"),
reservation_phone=self._mw_vars["reservation_phone"].get(),
techniques_practiced=self._mw_vars["techniques_practiced"].get(),
warm_up_minutes=self._mw_vars["warm_up_minutes"].get(),
pain_or_injury=self._mw_vars["pain_or_injury"].get() or "none",
matches_won=self._mw_int_value("matches_won"),
matches_lost=self._mw_int_value("matches_lost"),
sets_won=self._mw_int_value("sets_won"),
sets_lost=self._mw_int_value("sets_lost"),
racket=self._mw_vars["racket"].get(),
balls=self._mw_vars["balls"].get(),
activity_type_other=self._mw_vars["activity_type_other"].get(),
activity_details=self._mw_text_widgets["activity_details"].get(
"1.0", "end"
),
equipment=self._mw_vars["equipment"].get(),
)
error = _manual_workout.validate_manual_workout(draft)
if error is not None:
# textgate: the reason is logged; the form shows the fail mark.
_logger.warning("Manual workout form rejected: %s", error)
self._mw_error_label.config(text=REJECTED_GLYPH)
return
if self._mw_handled_by_date(draft):
return
entry = _manual_workout.build_entry(draft)
# Record the manual for cross-device sync (published on the next lock
# startup). Local + best-effort — never blocks saving/unlocking.
self._on_manual_workout_saved(entry)