screen-locker/screen_locker/_surface_group.py
Krzysztof kuhy Rudnicki f617be72b4
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Type the surface groups properly and exempt the test helpers by name
Two things the CI mirror caught that the local pre-commit run did not.

The groups were annotated with `Any`, which ANN401 rejects. They are generic
over the widget they mirror now (`WidgetGroup[tk.Frame]`, `[tk.Text]`, ...),
so each subclass keeps its own methods typed instead of erasing them --
better than the `Any` it replaces, not just quieter.

`name-tests-test` requires every tracked file under tests/ to be named
test_*. The two helper modules the conftest split produced are not tests, so
they join the exemption `_status_view_helpers.py` already had; the pattern is
generalised to private (leading-underscore) helper modules rather than
listing files one at a time.
2026-07-25 16:56:59 +02:00

174 lines
6.8 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, Generic, TypeVar
if TYPE_CHECKING:
from collections.abc import Callable, Iterator
_W = TypeVar("_W", bound=tk.Misc)
"""The widget each group mirrors, so a subclass keeps its own methods typed."""
class WidgetGroup(Generic[_W]):
"""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 TextGroup(WidgetGroup[tk.Text]):
"""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[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[_W]) -> 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())