wake-alarm/wake_alarm/_escape_dialog.py
Krzysztof kuhy Rudnicki 7f24ed4fa6 Split _escape_flow.py: the dialog moves out, the policy stays
317 -> 143. EscapeDialog and _FormFields move to _escape_dialog.py; the
policy (whether an escape may be offered, and the budget it spends) stays.

ESCAPE_HISTORY_FILE deliberately stays imported in _escape_flow: conftest
patches `wake_alarm._escape_flow.ESCAPE_HISTORY_FILE` to keep the suite off
the production budget, and moving that binding would silently reconnect the
tests to the real file. EscapeDialog is re-imported for the same reason,
guarded by __all__.

Two things the split broke loudly, both worth noting:
- The test patched `wake_alarm._escape_flow.tk`, which after the move
  intercepted nothing; repointed at _escape_dialog.
- The gatelock imports the dialog needs (escape_text_tab_trap and friends)
  did not come across, and ruff then stripped TYPE_CHECKING before my
  annotation block could use it — so the import and its first usage had to be
  added in the same edit, exactly as the standing rule says.

465 passed at 100% coverage; both documented CLIs still print real output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
2026-08-27 23:34:38 +02:00

202 lines
6.5 KiB
Python

"""The escape-hatch dialog — the Tk form the user fills in to bail out.
Split out of ``_escape_flow.py`` for the 250-line cap. That module keeps the
policy (whether an escape may be offered at all, and the budget it spends);
this one is only the window.
"""
from __future__ import annotations
from dataclasses import dataclass
import logging
import tkinter as tk
from typing import TYPE_CHECKING
from gatelock import (
EscapeDraft,
bind_activate,
bind_cancel,
escape_text_tab_trap,
)
if TYPE_CHECKING:
from gatelock import EscapeTracker, LockConfig
from wake_alarm._escape_flow import EscapeCallbacks
_logger = logging.getLogger(__name__)
@dataclass
class _FormFields:
"""The four inputs the justification is read back out of."""
reason: tk.Entry
onset: tk.Entry
severity: tk.Entry
description: tk.Text
class EscapeDialog:
"""The justification form and its escalating wait."""
def __init__(
self,
parent: tk.Misc,
config: LockConfig,
tracker: EscapeTracker,
callbacks: EscapeCallbacks,
) -> None:
"""Build the form inside ``parent``."""
self._tracker = tracker
self._callbacks = callbacks
self._remaining = 0
self.frame = tk.Frame(parent, bg=config.bg)
self._status = tk.Label(
self.frame,
text=tracker.budget_summary(),
font=config.font("body"),
fg=config.muted,
bg=config.bg,
)
self._status.pack(pady=config.space("xs"))
tk.Label(
self.frame,
text="Why can you not do this right now?",
font=config.font("subtitle", bold=True),
fg=config.fg,
bg=config.bg,
).pack(pady=config.space("sm"))
tk.Label(
self.frame,
text=tracker.format_recent(),
font=config.font("caption", family="Courier"),
fg=config.muted,
bg=config.bg,
justify="left",
).pack(pady=config.space("xs"))
description = tk.Text(
self.frame,
height=4,
width=60,
wrap="word",
bg=config.field_bg,
fg=config.fg,
insertbackground=config.fg,
**config.focus_kwargs(),
)
# Tk traps Tab inside a Text (inserts a literal tab and refocuses the
# widget) and binds Shift-Tab to nothing, so the only exits are the
# undocumented Ctrl+Tab / Ctrl+Shift+Tab. Unlike diet_guard's equivalent
# box, nothing here bound Return either -- so a keyboard-only user who
# tabbed into this justification field at 6am could not reach Submit,
# inside an alarm whose whole point is that it resists dismissal.
escape_text_tab_trap(description)
self._fields = _FormFields(
reason=self._field("What is wrong", config),
onset=self._field("When did it start", config),
severity=self._field("Severity 1-10", config),
description=description,
)
description.pack(pady=config.space("xs"))
self._complaint = tk.Label(
self.frame,
text="",
font=config.font("label"),
fg=config.danger,
bg=config.bg,
)
self._complaint.pack(pady=config.space("xs"))
self._submit = tk.Button(
self.frame,
text="Submit",
font=config.font("body", bold=True),
bg=config.warning,
fg=config.on_fill,
command=self.submit,
**config.focus_kwargs(),
)
self._submit.pack(pady=config.space("xs"))
back = tk.Button(
self.frame,
text="Back to the alarm",
font=config.font("label"),
bg=config.field_bg,
fg=config.fg,
command=callbacks.on_cancel,
**config.focus_kwargs(),
)
back.pack(pady=config.space("xs"))
# Tk binds only <space> on Button on X11, never <Return>, so Enter --
# the key a user reaches for to submit a form -- did nothing at all.
for button in (self._submit, back):
bind_activate(button)
# Escape returns to the alarm. This cancels the *escape-hatch form*, not
# the alarm: the alarm still has to be dismissed by solving a challenge
# or by spending the budgeted hatch. Making the sanctioned way back
# keyboard-reachable does not weaken that gating.
bind_cancel(self.frame, callbacks.on_cancel)
def _field(self, label: str, config: LockConfig) -> tk.Entry:
"""Add a labelled single-line field and return its entry."""
tk.Label(
self.frame,
text=label,
font=config.font("caption"),
fg=config.muted,
bg=config.bg,
).pack()
entry = tk.Entry(self.frame, width=40, **config.focus_kwargs())
entry.pack(pady=config.space("xs"))
return entry
def _draft(self) -> EscapeDraft:
"""Read the form into a draft."""
try:
severity = int(self._fields.severity.get().strip() or "0")
except ValueError:
severity = 0
return EscapeDraft(
reason=self._fields.reason.get(),
onset=self._fields.onset.get(),
severity=severity,
description=self._fields.description.get("1.0", "end-1c"),
)
def submit(self) -> None:
"""Validate, then start the escalating wait."""
draft = self._draft()
complaint = self._tracker.validate(draft)
if complaint is not None:
self._complaint.configure(text=complaint)
return
if self._tracker.is_budget_exhausted():
# Re-checked at submit time: the budget could have been spent by
# another surface, or the day could have rolled over mid-form.
self._complaint.configure(text="No budget left. Solve the challenge.")
return
self._tracker.record(draft)
self._remaining = self._tracker.compute_lockout_seconds()
self._submit.configure(state="disabled")
self._tick()
def _tick(self) -> None:
"""Count the wait down, then grant."""
if self._remaining <= 0:
_logger.warning(
"alarm escape hatch used -- %s", self._tracker.budget_summary()
)
self._callbacks.on_granted()
return
self._complaint.configure(text=f"Wait {self._remaining}s...")
self._remaining -= 1
self.frame.after(1000, self._tick)