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