mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 22:27:12 +01:00
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
149 lines
5.6 KiB
Python
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())
|