mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 12:07:44 +01:00
The manual-workout sport selector was a `tk.OptionMenu`, and while the screen
was locked it could not be changed. A posted Tk menu is a separate
override-redirect toplevel that takes the Tk grab for itself, and gatelock's
1s recovery tick attacks it from both sides: `_surfaces.enforce()` lifts the
lock surface over the menu, and `_reassert_grab()` read the grab sitting on
the menu as "the grab was lost" and yanked it back. The menu opened on click
and died within a second. On 2026-07-26 that forced an hour-long walk to be
logged through the table-tennis form, with the reason typed into `racket`.
Radio buttons instead. They are ordinary children of the surface, so they
lift with it and live inside the root's grab tree -- neither mechanism can
reach them. That is a structural guarantee, not a tuned workaround; the
alternative, teaching gatelock to skip its lift while a popup is posted,
would open a window where something could cover the lock. They are also the
first version of this control styled from the shared palette, where the
OptionMenu was default grey on the dark overlay.
Nothing on a lock surface may open a popup, so two halves enforce it. The
static half walks each module's AST for popup-creating names -- AST rather
than a source regex because the modules explain this bug in prose that names
the very widgets being banned. Its module list is derived ("imports tkinter",
minus a short reasoned exemption list) rather than hardcoded, so a new lock
screen is in scope the day it is written; a hardcoded list drafted today
would already have missed `_sick_dialog`. The behavioural half is
`scripts/verify_lock_popup_safety.py`: it builds the real form with real Tk,
replays both hostile behaviours for five ticks, and re-runs the original
failure so the mechanism stays demonstrated rather than described. It
re-execs itself under Xvfb -- a global grab on the developer's own session
would freeze it -- and a CI job runs it, because the pytest suite replaces
the whole `tk` module and so cannot host it.
Separately, no field ever took keyboard focus. Under a global grab that means
typing goes nowhere with no other window on screen to reveal why. The
manual-workout and sick-justification forms now each name their own first
field via a `focus=` flag; `on_focus_ready` genuinely has nothing to focus
(it fires before those screens exist) and its docstring now says that instead
of claiming the lock window has no typed input at all.
`scripts/fix_2026_07_26_sport.py` re-files that walk as sport "other",
rebuilt through the form's own draft/validate/build path so it carries
exactly the writer's key set, and re-signed. `start_time` and the date are
untouched so `workout_id` stays stable and the phone converges instead of
gaining a duplicate. Kept as the audit trail for the edit; the walk's details
come from the entry and `--details`, so nothing personal lands in a tracked
file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
271 lines
10 KiB
Python
271 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 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,
|
|
)
|
|
)
|
|
)
|
|
text_widgets.pack(pady=8)
|
|
for text_widget in text_widgets:
|
|
_disable_paste(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()
|