mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:27:02 +01:00
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
144 lines
4.9 KiB
Python
144 lines
4.9 KiB
Python
"""The alarm's sanctioned way out when the challenge genuinely isn't happening.
|
|
|
|
Until now the alarm had exactly one exit: four correct challenge rounds against
|
|
a 30-second refresh. That is a lot to ask of someone who has just woken up, and
|
|
it is *nothing* to fall back on if the alarm misfires at the wrong hour. Every
|
|
other locker here has a bounded escape -- screen-locker's sick mode,
|
|
diet-guard's log-a-meal -- so the alarm now has the same, built on the shared
|
|
:mod:`gatelock._escape` core rather than a private copy.
|
|
|
|
The shape, deliberately identical to sick mode:
|
|
|
|
* the button only appears after the alarm has been up a while, so it is never
|
|
the *easy* first move;
|
|
* it disappears entirely once any rolling budget is spent;
|
|
* it costs a written justification, with the last ten shown back to you;
|
|
* it costs a wait that doubles with each recent use;
|
|
* every entry is HMAC-signed, so editing the history is detectable.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
from dataclasses import dataclass
|
|
import logging
|
|
import tkinter as tk
|
|
from typing import TYPE_CHECKING
|
|
|
|
from gatelock import (
|
|
EscapePolicy,
|
|
EscapeTracker,
|
|
)
|
|
|
|
from wake_alarm._constants import ESCAPE_HISTORY_FILE, ESCAPE_OFFER_AFTER_SECONDS
|
|
from wake_alarm._escape_dialog import EscapeDialog, _FormFields
|
|
|
|
# Imported under their original names: the suite patches
|
|
# `wake_alarm._escape_flow.EscapeDialog`, and a moved symbol makes patch()
|
|
# stop intercepting silently. __all__ stops ruff deleting them as unused.
|
|
__all__ = ["EscapeDialog", "EscapeHatch", "_FormFields"]
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
from wake_alarm._alarm_view import _ViewGroup
|
|
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
ALARM_ESCAPE_POLICY = EscapePolicy(
|
|
name="cant-wake",
|
|
label="Can't-wake",
|
|
)
|
|
"""Budgets left at the shared defaults, so this is exactly as hard as sick mode."""
|
|
|
|
|
|
def build_tracker() -> EscapeTracker:
|
|
"""Build the alarm's escape tracker over its own history file."""
|
|
tracker = EscapeTracker(ALARM_ESCAPE_POLICY, ESCAPE_HISTORY_FILE)
|
|
tracker.load()
|
|
return tracker
|
|
|
|
|
|
def is_offerable(tracker: EscapeTracker, elapsed_seconds: float) -> bool:
|
|
"""Whether the hatch should be shown at all.
|
|
|
|
Two independent gates: the alarm must have been up long enough that this
|
|
is not the first thing you reach for, and the budget must not be spent.
|
|
"""
|
|
if elapsed_seconds < ESCAPE_OFFER_AFTER_SECONDS:
|
|
return False
|
|
return not tracker.is_budget_exhausted()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EscapeCallbacks:
|
|
"""Who to tell how the form ended."""
|
|
|
|
on_granted: Callable[[], None]
|
|
on_cancel: Callable[[], None]
|
|
|
|
|
|
class EscapeHatch:
|
|
"""The alarm's way out, from the hidden button to putting the screen back.
|
|
|
|
The alarm delegates the whole flow here so that opening and closing the
|
|
form stay symmetric: every ``open`` that hides a challenge and builds a
|
|
dialog has a ``close`` that destroys the dialog and packs the challenge
|
|
back. Splitting those halves across two objects is what previously left
|
|
"Back to the alarm" showing a blank screen.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
tracker: EscapeTracker,
|
|
views: _ViewGroup,
|
|
on_granted: Callable[[], None],
|
|
) -> None:
|
|
"""Bind the hatch to the surfaces it swaps and the alarm it releases."""
|
|
self._tracker = tracker
|
|
self._views = views
|
|
self._on_granted = on_granted
|
|
self._dialogs: list[EscapeDialog] = []
|
|
self.is_open = False
|
|
|
|
def maybe_offer(self, elapsed_seconds: float) -> None:
|
|
"""Reveal the button on every monitor once it is earned, and only then."""
|
|
if self.is_open:
|
|
return
|
|
if not is_offerable(self._tracker, elapsed_seconds):
|
|
return
|
|
for button in self._views.escape_buttons:
|
|
with contextlib.suppress(tk.TclError):
|
|
button.pack(pady=self._views.config.space("sm"))
|
|
|
|
def open(self) -> None:
|
|
"""Swap every surface over to the justification form."""
|
|
self.is_open = True
|
|
callbacks = EscapeCallbacks(
|
|
on_granted=self._on_granted,
|
|
on_cancel=self.close,
|
|
)
|
|
for view in self._views.surfaces:
|
|
with contextlib.suppress(tk.TclError):
|
|
view.challenge_frame.pack_forget()
|
|
dialog = EscapeDialog(
|
|
view.container,
|
|
self._views.config,
|
|
self._tracker,
|
|
callbacks,
|
|
)
|
|
dialog.frame.pack()
|
|
self._dialogs.append(dialog)
|
|
|
|
def close(self) -> None:
|
|
"""Back out of the form and put the challenge back on every monitor."""
|
|
for dialog in self._dialogs:
|
|
with contextlib.suppress(tk.TclError):
|
|
dialog.frame.destroy()
|
|
self._dialogs.clear()
|
|
for view in self._views.surfaces:
|
|
with contextlib.suppress(tk.TclError):
|
|
view.challenge_frame.pack()
|
|
self.is_open = False
|