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

279 lines
10 KiB
Python

"""Sick-day justification + commitment dialog mixin for the screen locker."""
from __future__ import annotations
import logging
import tkinter as tk
from typing import TYPE_CHECKING
from gatelock import escape_text_tab_trap
from screen_locker import _sick_tracker
from screen_locker._constants import (
COMMITMENT_PROMPT_TIMEOUT_SECONDS,
SICK_COMMITMENT_FORCED_READ_SECONDS,
SICK_JUSTIFICATION_MIN_CHARS,
)
from screen_locker._surface_group import FrameGroup, TextGroup
from screen_locker._ui_widgets import disable_paste as _disable_paste
if TYPE_CHECKING:
from collections.abc import Callable
from screen_locker._sick_tracker import SickHistory
_logger = logging.getLogger(__name__)
class SickDialogMixin:
"""Renders the sick-day justification screen and commitment prompts."""
# ------------------------------------------------------------------
# Sick-day justification dialog
# ------------------------------------------------------------------
def _show_sick_justification(self) -> None:
"""Render the structured sick-day justification screen."""
history = _sick_tracker.load_history()
self._sick_history_cache: SickHistory = history
self.clear_container()
self._label("Sick Day Request", color=self._colors.warning, pady=10)
self._text(_sick_tracker.budget_summary(history), color=self._colors.warning)
recent = _sick_tracker.format_recent_justifications(history)
if recent:
self._text(
"Recent sick days:", font_size=14, color=self._colors.muted, pady=8
)
self._text(recent, font_size=14, color=self._colors.muted, pady=8)
had_commitment = _sick_tracker.had_commitment_for_today(history)
if had_commitment:
self._text(
"⚠ Yesterday you committed to working out today.",
font_size=18,
color=self._colors.danger,
)
self._text(
"Breaking the commitment costs 2 sick-budget days.",
font_size=14,
color=self._colors.danger,
)
self._build_justification_form(had_commitment=had_commitment)
def _build_justification_form(self, *, had_commitment: bool) -> None:
"""Add justification form fields and submit button to the container."""
form = self.container.child_frame(bg=self._colors.bg)
form.pack(pady=10)
self._sick_symptom_var = tk.StringVar()
self._sick_onset_var = tk.StringVar()
self._sick_severity_var = tk.IntVar(value=5)
self._sick_text_widget = self._add_form_widgets(form)
self._sick_error_label = self._text("", color=self._colors.danger, pady=8)
button_row = self._button_row()
# Starts disabled during the forced-read delay -- field_bg (our
# neutral/secondary token) signals "not yet actionable" better than
# the accent used for an immediately-clickable submit.
self._sick_submit_button = self._button(
button_row,
"SUBMIT",
bg=self._colors.field_bg,
command=self._submit_sick_justification,
width=12,
)
self._sick_submit_button.pack(side="left", padx=10)
self._button(
button_row,
"BACK",
bg=self._colors.field_bg,
command=self._start_phone_check,
width=12,
).pack(side="left", padx=10)
if had_commitment:
self._sick_submit_button.config(state="disabled")
self._commitment_forced_remaining = SICK_COMMITMENT_FORCED_READ_SECONDS
self._update_commitment_forced_delay()
def _add_form_widgets(self, parent: FrameGroup) -> TextGroup:
"""Create symptom/onset/severity/text widgets. Returns the text widget."""
self._add_label_entry(
parent,
label="Symptom (e.g. fever, nausea):",
variable=self._sick_symptom_var,
focus=True,
)
self._add_label_entry(
parent,
label="When did it start? (e.g. last night):",
variable=self._sick_onset_var,
)
sev_row = parent.child_frame(bg=self._colors.bg)
sev_row.pack(pady=8)
sev_row.child_widgets(
tk.Label,
text="Severity (1-10):",
font=(self._colors.font_family, 14),
fg=self._colors.fg,
bg=self._colors.bg,
).pack(side="left", padx=5)
sev_row.child_widgets(
tk.Spinbox,
from_=1,
to=10,
textvariable=self._sick_severity_var,
width=4,
font=(self._colors.font_family, 14),
).pack(side="left", padx=5)
parent.child_widgets(
tk.Label,
text=(f"Describe how you feel (min {SICK_JUSTIFICATION_MIN_CHARS} chars):"),
font=(self._colors.font_family, 14),
fg=self._colors.fg,
bg=self._colors.bg,
).pack(pady=8)
text_widgets = TextGroup(
list(
parent.child_widgets(
tk.Text,
width=60,
height=6,
font=(self._colors.font_family, 14),
bg=self._colors.field_bg,
fg=self._colors.fg,
insertbackground=self._colors.fg,
**self._colors.focus_kwargs(),
)
)
)
text_widgets.pack(pady=8)
for text_widget in text_widgets:
_disable_paste(text_widget)
# Tk traps <Tab> inside a Text (inserts a tab, refocuses itself) and
# no-ops <Shift-Tab>, so without this a keyboard-only user who tabs
# into the justification box can never reach SUBMIT -- inside a lock
# whose only other exit is waiting out the countdown.
escape_text_tab_trap(text_widget)
return text_widgets
def _update_commitment_forced_delay(self) -> None:
"""Tick down the forced-read delay then enable the submit button."""
if self._commitment_forced_remaining > 0:
self._sick_submit_button.config(
text=f"WAIT {self._commitment_forced_remaining}s",
)
self._commitment_forced_remaining -= 1
self.root.after(1000, self._update_commitment_forced_delay)
else:
self._sick_submit_button.config(text="SUBMIT", state="normal")
def _submit_sick_justification(self) -> None:
"""Validate the form and either show an error or proceed to countdown."""
symptom = self._sick_symptom_var.get()
onset = self._sick_onset_var.get()
try:
severity = int(self._sick_severity_var.get())
except (tk.TclError, ValueError) as exc:
_logger.warning(
"Sick-day severity is not an integer (%s) — submitting it as 0, "
"which the justification validator will reject",
exc,
)
severity = 0
text = self._sick_text_widget.get("1.0", "end").strip()
draft = _sick_tracker.JustificationDraft(
symptom=symptom,
onset=onset,
severity=severity,
text=text,
)
error = _sick_tracker.validate_justification(draft)
if error is not None:
self._sick_error_label.config(text=error)
return
history = self._sick_history_cache
_sick_tracker.add_justification(history, draft)
if not _sick_tracker.save_history(history):
self._sick_error_label.config(
text="Could not persist sick history — try again",
)
return
self._proceed_to_sick_countdown()
# ------------------------------------------------------------------
# Commitment prompt (after a verified workout)
# ------------------------------------------------------------------
def _show_commitment_prompt(self, *, on_done: Callable[[], None]) -> None:
"""Ask the user to commit to working out tomorrow.
Calls ``on_done()`` once the user answers or the timeout elapses.
"""
self.clear_container()
self._label(
"Commit to working out tomorrow?",
font_size=32,
color=self._colors.warning,
pady=20,
)
# ~80 chars at font_size 16 would render as one unbroken line past the
# ~70-char readable range (rule 21) -- wrap it explicitly.
prompt = self._text(
"If you say YES and skip via 'I'm sick' tomorrow, "
"the sick day costs 2x normal.",
font_size=16,
)
prompt.config(wraplength=560, justify="center")
self._commitment_done_fn = on_done
self._commitment_remaining = COMMITMENT_PROMPT_TIMEOUT_SECONDS
self._commitment_timer_label = self._text(
f"Auto-skipping in {COMMITMENT_PROMPT_TIMEOUT_SECONDS}s",
color=self._colors.muted,
)
row = self._button_row()
self._button(
row,
"YES",
bg=self._colors.success,
command=lambda: self._answer_commitment(commit=True),
width=12,
).pack(side="left", padx=10)
self._button(
row,
"NO",
bg=self._colors.field_bg,
command=lambda: self._answer_commitment(commit=False),
width=12,
).pack(side="left", padx=10)
self._tick_commitment_timeout()
def _tick_commitment_timeout(self) -> None:
"""Advance commitment auto-skip timer; default to NO when it expires."""
if self._commitment_remaining <= 0:
self._answer_commitment(commit=False)
return
self._commitment_timer_label.config(
text=f"Auto-skipping in {self._commitment_remaining}s",
)
self._commitment_remaining -= 1
self.root.after(1000, self._tick_commitment_timeout)
def _answer_commitment(self, *, commit: bool) -> None:
"""Persist the commitment answer and call the completion callback."""
# Disable timer re-entry by zeroing remaining.
self._commitment_remaining = -1
if commit:
history = _sick_tracker.load_history()
_sick_tracker.record_commitment_for_tomorrow(history)
_sick_tracker.save_history(history)
done = getattr(self, "_commitment_done_fn", None)
if done is not None:
self._commitment_done_fn = None
done()