mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
The locker was not running at all. workout-locker.service crash-looped ~1977
times in a single boot with ModuleNotFoundError while remaining `enabled`, and
has no successful start in the retained journal. A locker that cannot start is
a locker that does not lock.
Root cause: commit 9b420a9 removed the unit's WorkingDirectory and
PYTHONPATH=/opt/screen-locker on the (correct in principle) grounds that
"screen_locker is now pip-installed" -- but nothing guaranteed that install.
There was no installer, /opt/screen-locker no longer exists, and the package
was absent from every Python version's user site-packages. The unit lost its
fallback and gained a hard dependency on a manual step nobody re-ran.
install.sh fixes that as a script rather than a reminder, and *gates*: it fails
if the production interpreter cannot import screen_locker, because an installer
that reports success while the service still cannot start is precisely the
failure above. It also re-asserts an editable gatelock from ~/utils AFTER the
package install and verifies it resolves there -- pyproject pins gatelock to a
git tag, so a plain install silently replaces a local editable gatelock with a
frozen wheel, which was observed removing ScrollableSurface and taking
diet_guard down with it.
The keyboard work, all of it Tk defaults rather than local mistakes:
- Focus was invisible. Tk ships highlightcolor="#000000" -- a black ring on
#211D1B -- and relief="flat" removes the depth cue too, so a keyboard user
had no indication of where they were. Now set in the _ui_widgets factory, so
a new call site cannot omit it.
- Enter did nothing. X11 Tk binds <space> on Button and never <Return>.
- tk.Text trapped Tab (inserts a literal tab, refocuses itself) and no-opped
Shift-Tab; the only exits are the undocumented Ctrl+Tab / Ctrl+Shift+Tab.
- The lock and retry screens started with NOTHING focused. on_focus_ready was
a documented no-op arguing "buttons only, nothing to focus" -- exactly
backwards: buttons are what needs focusing when there is no other window on
screen and a global grab is held.
- The heat-skip modal grabbed input fullscreen with no focus, no ring, no
Return and no Escape. It now focuses the *declining* option, so a blind
Return keeps the workout rather than silently skipping it.
The manual-workout form's scroll viewport was pointer-only in three ways at
once: tk.Canvas has no class key bindings, so ::tk::FocusOK rejected it as a
focus stop; nothing bound wheel, PageUp/PageDown or arrows, leaving only a
draggable thumb; and canvas clipping does not unmap children, so below-the-fold
fields stayed in the tab ring while Tk never scrolled to follow focus -- Tab led
to fields the user could neither see nor reveal. Its height was also a magic
0.7*toplevel above ~230-290px of fixed chrome, i.e. satisfied at 1080p and
violated at 768p, where SUBMIT/BACK were pushed off the bottom.
All of that is gone by deletion: the surface container is now a
gatelock.ScrollableSurface, so the inner viewport is redundant. build_surface
and _ensure_container both use it, which fixes the same symmetric-clipping
problem for EVERY screen rather than one at a time; center_when_fits keeps
short screens looking exactly as they did.
Re-arming after a repaint hooks into clear_container(), which all ~19 repaint
sites already call. "Remember to call settle after painting" is a rule that
would eventually be missed, and on a lock a missed re-arm means unreachable
content.
The aux windows hardcoded geometry("600x400") and ("700x450") while displaying
more than that -- the relaxed-day prompt's own button row requests 862px inside
a 700px window, sheared at both edges. That pre-dates this work by a wide
margin (Tk's default focus ring accounts for 4px of the 162px overflow). They
now size to content, floored at the old values and clamped to the display.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
345 lines
15 KiB
Python
345 lines
15 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_DESCRIPTION_MIN_CHARS,
|
|
MANUAL_WORKOUT_REFLECTION_MIN_CHARS,
|
|
)
|
|
from screen_locker._manual_workout_widgets import ManualWorkoutFormWidgetsMixin
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
_SPORT_LABEL_TO_CODE = {
|
|
label: code for code, label in _manual_workout.SPORT_LABELS.items()
|
|
}
|
|
|
|
|
|
class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin):
|
|
"""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()
|
|
self._label("Log Manual Workout", color=self._colors.accent, pady=10)
|
|
if _manual_workout.is_budget_exhausted(self.log_file):
|
|
self._text(
|
|
"Manual-workout budget exhausted for this window.",
|
|
color=self._colors.danger,
|
|
)
|
|
self._text(
|
|
_manual_workout.budget_summary(self.log_file),
|
|
color=self._colors.muted,
|
|
)
|
|
row = self._button_row()
|
|
self._button(
|
|
row,
|
|
"BACK",
|
|
bg=self._colors.field_bg,
|
|
command=self._on_manual_workout_cancelled,
|
|
width=12,
|
|
).pack(side="left", padx=10)
|
|
return
|
|
self._text(
|
|
_manual_workout.budget_summary(self.log_file), color=self._colors.accent
|
|
)
|
|
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)
|
|
form.pack(fill="both", expand=True, pady=10)
|
|
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.
|
|
|
|
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] = {}
|
|
|
|
self._mw_section(form, "Basics")
|
|
self._mw_sport_row(form)
|
|
self._mw_vars["start_time"] = self._mw_entry(
|
|
form, "Start time (HH:MM):", focus=True
|
|
)
|
|
self._mw_vars["end_time"] = self._mw_entry(form, "End time (HH:MM):")
|
|
|
|
self._mw_section(form, "Location & logistics")
|
|
self._mw_vars["location_name"] = self._mw_entry(form, "Location name:")
|
|
self._mw_vars["transport_method"] = self._mw_entry(
|
|
form, "How did you get there?"
|
|
)
|
|
self._mw_vars["cost"] = self._mw_entry(form, "Cost (e.g. 40 PLN):")
|
|
self._mw_vars["reservation_phone"] = self._mw_entry(
|
|
form, "Reservation phone number (if booked by phone, optional):"
|
|
)
|
|
|
|
self._mw_section(form, "Activity details")
|
|
# 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.bg)
|
|
self._mw_other_frame = tk.Frame(form, bg=self._colors.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=12, pady=6
|
|
)
|
|
self._build_table_tennis_fields(self._mw_tt_frame)
|
|
self._build_other_sport_fields(self._mw_other_frame)
|
|
self._mw_rpe_row(form)
|
|
self._mw_vars["techniques_practiced"] = self._mw_entry(
|
|
form, "Techniques/focus areas practiced (optional):"
|
|
)
|
|
self._mw_vars["warm_up_minutes"] = self._mw_entry(
|
|
form, "Warm-up duration (optional):"
|
|
)
|
|
self._mw_vars["pain_or_injury"] = self._mw_entry(
|
|
form, "Pain or injury notes (optional, default none):"
|
|
)
|
|
self._on_mw_sport_changed(
|
|
_manual_workout.SPORT_LABELS[_manual_workout.SPORT_TABLE_TENNIS]
|
|
)
|
|
|
|
self._mw_section(form, "Reflection")
|
|
self._mw_text_widgets["went_well"] = self._mw_textbox(
|
|
form,
|
|
f"What went well (min {MANUAL_WORKOUT_REFLECTION_MIN_CHARS} chars):",
|
|
)
|
|
self._mw_text_widgets["to_improve"] = self._mw_textbox(
|
|
form,
|
|
f"What to improve (min {MANUAL_WORKOUT_REFLECTION_MIN_CHARS} chars):",
|
|
)
|
|
self._mw_text_widgets["overall_feeling"] = self._mw_textbox(
|
|
form,
|
|
"Overall feeling about the session "
|
|
f"(min {MANUAL_WORKOUT_REFLECTION_MIN_CHARS} chars):",
|
|
)
|
|
|
|
self._mw_error_label = self._text("", color=self._colors.danger, pady=8)
|
|
button_row = self._button_row()
|
|
self._button(
|
|
button_row,
|
|
"SUBMIT",
|
|
bg=self._colors.accent,
|
|
command=self._submit_manual_workout_form,
|
|
width=12,
|
|
).pack(side="left", padx=10)
|
|
self._button(
|
|
button_row,
|
|
"BACK",
|
|
bg=self._colors.field_bg,
|
|
command=self._on_manual_workout_cancelled,
|
|
width=12,
|
|
).pack(side="left", padx=10)
|
|
|
|
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_family, 16),
|
|
fg=self._colors.fg,
|
|
bg=self._colors.bg,
|
|
).pack(side="left", padx=5)
|
|
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_family, 16),
|
|
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=5)
|
|
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:
|
|
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:
|
|
self._mw_error_label.config(text=error)
|
|
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)
|