screen-locker/screen_locker/_surface_group.py
Krzysztof kuhy Rudnicki 600d0fd084 Migrate screen_locker to gatelock v0.2.0 per-output locking
The lock now paints one container per live output instead of one window on
the primary, and claims the screen through an Arbiter at RANK_SCREEN_LOCKER
so the wake alarm can outrank it.

The fan-out happens at the widget factory, not the render loop. Painting a
screen here is not a pure function -- the ~20 clear_container() flows record
workouts, credit weeks and branch on state while they paint -- so re-running
a flow per surface would double those side effects. Instead `_label`,
`_button_row` and friends build N copies and return a group that mirrors
later configure calls to all of them, and a flow body still runs once. Frame
groups nest, so `_button(button_row, ...)` reads exactly as it did.

The degenerate case carries as much weight as the general one: the status
window, the post-sick-day verify window and the relaxed-day prompt have no
lock and no surfaces, and share this same factory. They get a group of
exactly one frame and behave as they always did.

Text boxes are the one thing that cannot be shared. Entries fan out over a
single StringVar, but tk.Text has no such variable, so the sick-day
description genuinely diverges per monitor; TextGroup.get() returns whichever
copy was actually typed into, preserving "answer on any monitor".

Tests: the suite hung rather than failed on first run, which is the trap
gatelock's own conftest documents -- screen_locker patches its own `tk`, but
LockWindow builds Toplevels through gatelock's, and real tkinter over a
MagicMock root recurses without bound. `_gatelock_fixtures` closes that and
redirects the runtime dir so no test can stand a production locker down.
Every other test runs single-output, where fan-out is unobservable, so
test_surface_fanout runs on two outputs; each assertion was checked by
cropping the group loops to the primary and confirming it goes red.

874 tests, 100% branch coverage, pre-commit clean, no lint suppressions.
2026-07-25 16:26:39 +02:00

169 lines
6.5 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,
the post-sick-day verify 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, Any
if TYPE_CHECKING:
from collections.abc import Iterator
class WidgetGroup:
"""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[Any]) -> None:
"""Wrap the per-monitor copies of one widget."""
self._widgets = widgets
def __iter__(self) -> Iterator[Any]:
"""Iterate the per-monitor copies, for callers that need each one."""
return iter(self._widgets)
@property
def first(self) -> Any:
"""The primary monitor's copy, for reads that cannot fan out."""
return self._widgets[0]
def configure(self, **kwargs: Any) -> 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: Any) -> 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 TextGroup(WidgetGroup):
"""A multi-line text box, mirrored onto every monitor.
Entries can share a ``StringVar`` and so are typed once for all screens.
``tk.Text`` has no such variable, so the copies genuinely diverge: the
user types into exactly one of them, whichever screen they are looking
at. Reading therefore returns the first copy with anything in it, which
keeps the "answer on any monitor" property the per-output lock exists
for. Falls back to the primary's (empty) content so callers still get a
string to validate and reject.
"""
def get(self, start: str, end: str) -> str:
"""Return the content of whichever copy was actually typed into."""
for widget in self._widgets:
with contextlib.suppress(tk.TclError):
content = widget.get(start, end)
if content.strip():
return str(content)
return str(self._widgets[0].get(start, end))
class FrameGroup(WidgetGroup):
"""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[Any]) -> 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: Any) -> 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[Any]:
"""Every per-monitor frame; empty until the lock builds them."""
return list(self._widgets)
def add(self, frame: Any, 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: Any) -> FrameGroup:
"""Create one child frame inside every copy."""
return FrameGroup([tk.Frame(frame, **kwargs) for frame in self._widgets])
def child_widgets(self, factory: Any, **kwargs: Any) -> WidgetGroup:
"""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) -> Any:
"""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[Any]:
"""Children of the primary copy, for tests and geometry queries."""
return list(self._widgets[0].winfo_children())