screen-locker/screen_locker/_manual_workout_widgets.py
Krzysztof Rudnicki 3e77f4e232 Make every lock screen keyboard-operable, and add the missing installer
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>
2026-07-30 16:25:45 +02:00

199 lines
7.0 KiB
Python

"""Low-level widget/grid builders for the manual-workout evidence form.
Split out of ``_manual_workout_dialog`` so the dialog module stays under the
repo's file-length cap. These helpers build individual form cells and place
them into a two-column grid; the orchestration (which fields, in what order,
and submission) lives in ``ManualWorkoutDialogMixin``, which inherits this.
The host is expected to have initialised ``self._mw_grid_counters`` (per-master
grid cursors) and, for ``_mw_rpe_row``, ``self._mw_rpe_var``.
"""
from __future__ import annotations
import tkinter as tk
from gatelock import escape_text_tab_trap
from screen_locker._constants import (
MANUAL_WORKOUT_RPE_MAX,
MANUAL_WORKOUT_RPE_MIN,
)
from screen_locker._ui_widgets import disable_paste
class ManualWorkoutFormWidgetsMixin:
"""Two-column grid placement + individual field-cell builders."""
def _mw_grid(
self, parent: tk.Widget, widget: tk.Widget, *, full: bool = False
) -> None:
"""Place ``widget`` into ``parent``'s two-column grid cursor.
A half-width item (``full=False``) fills the next cell (left, then
right); a ``full`` item starts a fresh row and spans both columns. Each
master keeps its own cursor (keyed by ``id(parent)``) so nested sport
frames grid independently of the main form.
"""
counters = self._mw_grid_counters
idx = counters.get(id(parent), 0)
if full and idx % 2 == 1:
idx += 1 # a full-width item can't share a row — bump to a new one
row, col = divmod(idx, 2)
widget.grid(
row=row,
column=col,
columnspan=2 if full else 1,
sticky="ew",
padx=12,
pady=8,
)
counters[id(parent)] = idx + (2 if full else 1)
def _mw_next_full_row(self, parent: tk.Widget) -> int:
"""Reserve a fresh full-width row for a manually-gridded widget.
Used for the twin sport frames, which share one slot and are gridded
directly (not through ``_mw_grid``); this advances the cursor past that
row so following fields resume cleanly.
"""
counters = self._mw_grid_counters
idx = counters.get(id(parent), 0)
if idx % 2 == 1:
idx += 1
counters[id(parent)] = idx + 2
return idx // 2
def _mw_section(self, parent: tk.Widget, title: str) -> None:
"""Add a full-width section heading inside the form."""
label = tk.Label(
parent,
text=title,
font=(self._colors.font_family, 20, "bold"),
fg=self._colors.accent,
bg=self._colors.bg,
anchor="w",
)
self._mw_grid(parent, label, full=True)
def _mw_entry(
self, parent: tk.Widget, label: str, *, focus: bool = False
) -> tk.StringVar:
"""Add a half-width label+entry cell and return its backing StringVar.
Builds its own cell inline (rather than reusing ``_add_label_entry``,
which is pack-based and shared with the sick dialog) so it slots into
the two-column grid. Paste stays disabled via ``disable_paste``.
Args:
parent: The grid master to place the cell in.
label: Text shown above the entry.
focus: Give this entry the keyboard focus; see
``_ui_widgets.UIWidgetsMixin._add_label_entry`` for why exactly
one field per form should ask.
"""
var = tk.StringVar()
cell = tk.Frame(parent, bg=self._colors.bg)
tk.Label(
cell,
text=label,
font=(self._colors.font_family, 16),
fg=self._colors.fg,
bg=self._colors.bg,
anchor="w",
).pack(fill="x")
entry = tk.Entry(
cell,
textvariable=var,
font=(self._colors.font_family, 18),
bg=self._colors.field_bg,
fg=self._colors.fg,
insertbackground=self._colors.fg,
**self._colors.focus_kwargs(),
)
entry.pack(fill="x", pady=4)
disable_paste(entry)
if focus:
entry.focus_set()
self._mw_grid(parent, cell)
return var
def _mw_int_field(
self, parent: tk.Widget, label: str, *, frm: int = 0, to: int = 99
) -> tk.IntVar:
"""Add a half-width label + numeric Spinbox and return its IntVar."""
var = tk.IntVar(value=0)
row = tk.Frame(parent, bg=self._colors.bg)
tk.Label(
row,
text=label,
font=(self._colors.font_family, 16),
fg=self._colors.fg,
bg=self._colors.bg,
).pack(side="left", padx=5)
tk.Spinbox(
row,
from_=frm,
to=to,
textvariable=var,
width=4,
font=(self._colors.font_family, 16),
**self._colors.focus_kwargs(),
).pack(side="left", padx=5)
self._mw_grid(parent, row)
return var
def _mw_textbox(self, parent: tk.Widget, label: str) -> tk.Text:
"""Add a full-width label + multi-line Text cell and return the widget."""
cell = tk.Frame(parent, bg=self._colors.bg)
tk.Label(
cell,
text=label,
font=(self._colors.font_family, 16),
fg=self._colors.fg,
bg=self._colors.bg,
anchor="w",
).pack(fill="x", pady=(8, 0))
text_widget = tk.Text(
cell,
height=3,
font=(self._colors.font_family, 14),
bg=self._colors.field_bg,
fg=self._colors.fg,
insertbackground=self._colors.fg,
**self._colors.focus_kwargs(),
)
text_widget.pack(pady=4, fill="x")
disable_paste(text_widget)
# Tk makes <Tab> insert a literal tab and refocus the widget, and binds
# <Shift-Tab> to nothing, so an untreated Text is a keyboard dead end --
# and the only exits (Ctrl+Tab / Ctrl+Shift+Tab) are advertised nowhere.
# On this form that means never reaching SUBMIT.
escape_text_tab_trap(text_widget)
self._mw_grid(parent, cell, full=True)
return text_widget
def _mw_rpe_row(self, parent: tk.Widget) -> None:
"""Add the RPE (rate of perceived exertion) spinbox row."""
row = tk.Frame(parent, bg=self._colors.bg)
tk.Label(
row,
text=(
f"RPE — perceived exertion "
f"({MANUAL_WORKOUT_RPE_MIN}-{MANUAL_WORKOUT_RPE_MAX}):"
),
font=(self._colors.font_family, 16),
fg=self._colors.fg,
bg=self._colors.bg,
).pack(side="left", padx=5)
tk.Spinbox(
row,
from_=MANUAL_WORKOUT_RPE_MIN,
to=MANUAL_WORKOUT_RPE_MAX,
textvariable=self._mw_rpe_var,
width=4,
font=(self._colors.font_family, 16),
**self._colors.focus_kwargs(),
).pack(side="left", padx=5)
self._mw_grid(parent, row)