screen-locker/screen_locker/_surface_group.py
Krzysztof kuhy Rudnicki 7d8daca111 sick days: one shared sickdays pool, 20:00 shutdown floor, one-tap lock button
A sick day is now a date in the shared sickdays pool (utils sickdays 0.1.0)
and nothing else. Removed: the 7/30/90-day budget, the escalating lockout
countdown, the justification form, the commitment prompt and its double
debt, the workout-debt counter, the -60 min shutdown cut and its
sick_day_state.json, the post-sick-day --verify-workout mode, the sick
auto-upgrade, scripts/revoke_sick_day.py and their tests.

- Lock screen: "I'm sick" on the retry, no-phone and both Tue-Thu screens;
  one tap -> sickdays.add_sick_day(today) -> sick floor -> best-effort
  background sickdays.sync_quietly -> unlock. Refusals (free day,
  unreadable pool) are shown and the lock stays.
- Shutdown: the reset resolves with earned_time.resolve(..., sick=True)
  (max(20:00, ladder), capped 23:00) and saves the sick lift with the grace
  lift, so later credits absorb it instead of stacking on 20:00. A day
  marked after the reset is lifted by the new _sick_floor live pass (every
  5-min tick, --sync-only, --apply-bonuses, and the tap itself).
- Workout lock and grace floor stand down on a sick day; workouts that day
  credit normally through the sync paths.
- wake-alarm workday_penalty.json is void when its issued_date or
  penalty_date is a sick day.
- Status window, i3 line, CLI --status, MCP flags and web reasons read
  sickdays.
- scripts/migrate_sick_history.py imports sick_history.json's dates via
  sickdays.import_legacy, verifies and removes the file; idempotent, run by
  install.sh.
- Pins: earned-time 0.11.0, freedays 0.4.0, sickdays 0.1.0 (tags pending);
  pre-commit 4.7.0, ruff 0.17.1 (freshness gate).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
2026-10-16 13:30:39 +02:00

149 lines
5.6 KiB
Python

"""Presenting N per-monitor surfaces as if they were one container.
Since gatelock v0.2.0 the lock builds one window per live output, so every
widget the locker paints has to exist once per monitor. The rest of this
package is written against a single ``self.container``, and building the
screen is not a pure function here -- ``_ui_flows`` records workouts, credits
weeks and branches on state while it paints -- so re-running a flow once per
surface would double those side effects.
The fan-out therefore happens at the widget factory instead. A flow body runs
exactly once; each widget it asks for is created N times and handed back as a
proxy that mirrors later ``configure`` calls to every copy. Nesting works the
same way: a frame group can be the parent of another group, so
``_button_row()`` still reads like it returns one frame.
The degenerate case matters as much as the general one. The status window
and the relaxed-day prompt are ordinary single windows with no lock and no
surfaces; they build a group of exactly one frame and every call site below
behaves as it always did.
"""
from __future__ import annotations
import contextlib
import tkinter as tk
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable, Iterator
class WidgetGroup[W: tk.Misc]:
"""One logical widget, mirrored onto every monitor.
Only the operations the locker actually performs after creation are
exposed. Anything reading a value back (``winfo_*``, ``get``) answers
from the first copy, since all copies are built identically.
"""
def __init__(self, widgets: list[W]) -> None:
"""Wrap the per-monitor copies of one widget."""
self._widgets = widgets
def __iter__(self) -> Iterator[W]:
"""Iterate the per-monitor copies, for callers that need each one."""
return iter(self._widgets)
@property
def first(self) -> W:
"""The primary monitor's copy, for reads that cannot fan out."""
return self._widgets[0]
def configure(self, **kwargs: object) -> None:
"""Apply the same configuration to every copy."""
for widget in self._widgets:
with contextlib.suppress(tk.TclError):
widget.configure(**kwargs)
# Tk's own alias; several call sites here use the short spelling.
config = configure
def pack(self, **kwargs: object) -> None:
"""Pack every copy with the same options."""
for widget in self._widgets:
with contextlib.suppress(tk.TclError):
widget.pack(**kwargs)
def pack_forget(self) -> None:
"""Unpack every copy."""
for widget in self._widgets:
with contextlib.suppress(tk.TclError):
widget.pack_forget()
def destroy(self) -> None:
"""Destroy every copy."""
for widget in self._widgets:
with contextlib.suppress(tk.TclError):
widget.destroy()
class FrameGroup(WidgetGroup[tk.Frame]):
"""A container, mirrored onto every monitor, that can parent more groups.
This is what makes the incremental widget factory in ``UIWidgetsMixin``
work unchanged: ``_button_row()`` returns a ``FrameGroup``, and passing
it to ``_button()`` creates one button inside each of its frames.
"""
def __init__(self, widgets: list[tk.Frame]) -> None:
"""Wrap the per-monitor frames, tracking which output each came from."""
super().__init__(widgets)
self._outputs: list[str] = []
@classmethod
def single(cls, parent: tk.Misc, **kwargs: object) -> FrameGroup:
"""Build a group of exactly one centred frame, for non-lock windows."""
frame = tk.Frame(parent, **kwargs)
frame.place(relx=0.5, rely=0.5, anchor="center")
return cls([frame])
@property
def surfaces(self) -> list[tk.Frame]:
"""Every per-monitor frame; empty until the lock builds them."""
return list(self._widgets)
def add(self, frame: tk.Frame, output_name: str) -> None:
"""Register the frame built for one newly-live output."""
self._widgets.append(frame)
self._outputs.append(output_name)
def discard(self, output_name: str) -> None:
"""Forget the frame for an output that went dark."""
kept = [
(frame, name)
for frame, name in zip(self._widgets, self._outputs, strict=True)
if name != output_name
]
self._widgets = [frame for frame, _ in kept]
self._outputs = [name for _, name in kept]
def child_frame(self, **kwargs: object) -> FrameGroup:
"""Create one child frame inside every copy."""
return FrameGroup([tk.Frame(frame, **kwargs) for frame in self._widgets])
def child_widgets(
self, factory: Callable[..., tk.Misc], **kwargs: object
) -> WidgetGroup[tk.Misc]:
"""Create one ``factory(parent, **kwargs)`` widget inside every copy."""
return WidgetGroup([factory(frame, **kwargs) for frame in self._widgets])
def clear(self) -> None:
"""Destroy every child of every copy."""
for frame in self._widgets:
with contextlib.suppress(tk.TclError):
for child in frame.winfo_children():
child.destroy()
def winfo_toplevel(self) -> tk.Misc:
"""The primary monitor's toplevel.
A dialog that needs *a* window to sit relative to gets the primary
one; there is no meaningful "the" toplevel across N monitors.
"""
return self._widgets[0].winfo_toplevel()
def winfo_children(self) -> list[tk.Misc]:
"""Children of the primary copy, for tests and geometry queries."""
return list(self._widgets[0].winfo_children())