utils/gatelock/gatelock/_surfaces.py
Krzysztof kuhy Rudnicki 0cb575d15f
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
gatelock 0.8.1: public recovery/detector handles; declarative types import from _config
leetcode-guard's study mode has to stop the re-assertion loop and the
output-change watcher before it releases the grab, and could only reach
them as LockWindow._recovery / _detector -- attributes 0.8.0 folded into
the arming bundle, which broke that documented private dependency.
They are public read-only properties now, next to `surfaces`.

_window no longer re-exports the LockConfig family; it was the one
thing keeping it over the 250-line cap, and every importer now takes
those names from gatelock._config (or the package) directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
2026-09-12 19:41:13 +02:00

243 lines
9.0 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki
"""Own every lock window: one per live output, plus the backdrop.
This is the **only** module allowed to create, move or destroy windows. That
containment is what lets :mod:`gatelock._recovery` be provably monotonic --
recovery delegates all structural change here and therefore cannot itself
contain a teardown path. A test asserts that separation by parsing both
modules, so routing destruction back into recovery fails CI.
The window shape, and why:
.. code-block:: text
GateRoot (tk.Tk) <- always the grab holder, always mapped
|- overrideredirect, geometry = the whole X screen, no widgets
|- Toplevel -> "DP-0" 3840x2160+0+0
+- Toplevel -> "HDMI-0" 2560x1440+3840+0
Three probe results against a live X server dictated this and are worth
recording, because each rules out an approach that looks reasonable:
1. **A Tk grab must live on the root**, not on a Toplevel. With the grab on a
*sibling* Toplevel, clicks on another surface are swallowed. Grabbing
surface 0 would leave surface 1 visible but dead -- the 2026-07-25 incident
rebuilt on purpose.
2. **``overrideredirect`` must be set before the window is mapped.** Set it
afterwards and the placement is ignored.
3. **A window-manager-managed toplevel cannot be placed per-output** -- i3
rewrites the geometry wholesale. Per-output placement *requires*
override-redirect, so ``attributes(fullscreen=True)`` is gone from the lock
path entirely: it is an EWMH request that an override-redirect window is
invisible to, and where it does apply it targets one monitor or the whole
bounding box. Neither is what per-output locking needs.
"""
from __future__ import annotations
import logging
import tkinter as tk
from typing import TYPE_CHECKING
from gatelock._placement import (
_Surface,
blind_outputs,
enforce_all,
needs_backdrop_root,
set_geometry,
)
from gatelock._surface_types import (
SurfaceBuilder,
SurfaceDelta,
SurfaceInfo,
)
from gatelock._surfaces_focus import focus_surface, preferred_focus_index
from gatelock._textmirror import TextMirror, mirror_text_widgets
# Names this module re-exports after the 250-line split. Listed explicitly so
# the autofixer cannot prune an import that exists *for* its re-export -- it
# stripped mirror_text_widgets once, and `gatelock` itself stopped importing.
__all__ = [
"SurfaceBuilder",
"SurfaceDelta",
"SurfaceInfo",
"SurfaceSet",
"TextMirror",
"mirror_text_widgets",
"needs_backdrop_root",
]
if TYPE_CHECKING:
from gatelock._config import LockConfig
from gatelock._outputs import OutputScan
_logger = logging.getLogger(__name__)
# Tk's attributes() takes the value positionally, so a bare `True` here
# trips the boolean-positional lint. Naming it satisfies both that rule
# and the type stubs, which have no keyword overload.
_TOPMOST_ON = True
class SurfaceSet:
"""The full set of lock windows, kept in step with the live outputs."""
def __init__(
self,
root: tk.Tk,
config: LockConfig,
builder: SurfaceBuilder,
) -> None:
"""Prepare a surface set over ``root``; creates no windows yet."""
self._root = root
self._config = config
self._builder = builder
self._surfaces: dict[str, _Surface] = {}
def infos(self) -> tuple[SurfaceInfo, ...]:
"""Every current surface, in output order."""
return tuple(
surface.info
for surface in sorted(self._surfaces.values(), key=lambda s: s.info.index)
)
def names(self) -> frozenset[str]:
"""The output names currently carrying a surface."""
return frozenset(self._surfaces)
def update_backdrop(self) -> None:
"""Size the root to the whole X screen and keep it black.
The backdrop covers regions no live output claims -- including the dead
column a modeless monitor leaves behind -- so nothing of the desktop
shows through the gaps.
"""
if not needs_backdrop_root(self._config):
return
width = self._root.winfo_screenwidth()
height = self._root.winfo_screenheight()
self._root.geometry(f"{width}x{height}+0+0")
self._root.configure(bg=self._config.palette.bg, cursor="arrow")
def apply(self, scan: OutputScan) -> SurfaceDelta:
"""Bring the surface set in line with ``scan``.
A failed scan must never reach this method: callers check ``scan.ok``
first, because "we could not enumerate" is not evidence that anything
should change.
"""
live = scan.live
wanted = {output.name: output for output in live}
removed = tuple(name for name in sorted(self._surfaces) if name not in wanted)
for name in removed:
self._destroy(name)
created: list[str] = []
moved: list[str] = []
for index, output in enumerate(live):
rect = output.rect
if rect is None: # pragma: no cover - `live` guarantees a rect
continue
existing = self._surfaces.get(output.name)
info = SurfaceInfo(
output_name=output.name,
rect=rect,
index=index,
is_primary=output.primary,
)
if existing is None:
self._create(info)
created.append(output.name)
elif existing.info != info:
self._reposition(existing, info)
moved.append(output.name)
return SurfaceDelta(
created=tuple(created),
moved=tuple(moved),
removed=removed,
unverified=self.verify(live_names=frozenset(wanted)),
)
def verify(self, *, live_names: frozenset[str] | None = None) -> tuple[str, ...]:
"""Return live outputs that are NOT currently showing a lock.
This is the alarm for the original defect: an output the user can see,
with no lock on it, means the escape UI is somewhere they cannot reach.
"""
names = live_names if live_names is not None else self.names()
return blind_outputs(self._surfaces, names)
def enforce(self) -> tuple[str, ...]:
"""Re-assert every surface's placement and stacking.
Only ever *adds* visibility: remaps what became unmapped, corrects
drifted geometry, and raises. Nothing here can hide a surface.
"""
return enforce_all(
self._surfaces,
overrideredirect=self._config.resolved_overrideredirect(),
)
def raise_all(self) -> None:
"""Lift every surface above whatever else is on screen."""
for surface in self._surfaces.values():
surface.window.lift()
def focus_surface(self, index: int) -> SurfaceInfo | None:
"""Move keyboard focus to one surface, returning what got it."""
return focus_surface(self._surfaces, index)
def preferred_focus_index(self) -> int:
"""Index of the surface that should take initial focus."""
return preferred_focus_index(self._surfaces)
def _create(self, info: SurfaceInfo) -> None:
"""Create one surface window and let the app build its UI inside."""
window = tk.Toplevel(self._root)
# Order matters and is not negotiable: a window must be withdrawn while
# override-redirect is set, or the geometry below is ignored.
window.withdraw()
if self._config.resolved_overrideredirect():
window.overrideredirect(boolean=True)
window.configure(bg=self._config.palette.bg, cursor="arrow")
set_geometry(window, info.rect)
if not self._config.resolved_overrideredirect():
window.attributes("-topmost", _TOPMOST_ON)
window.deiconify()
window.lift()
surface = _Surface(info=info, window=window)
self._surfaces[info.output_name] = surface
self._builder.build_surface(window, info)
surface.built = True
_logger.info(
"lock surface up on %s at %s", info.output_name, info.rect.geometry()
)
def _reposition(self, surface: _Surface, info: SurfaceInfo) -> None:
"""Move an existing surface to a new rectangle.
Deliberately a move, never a destroy-and-recreate: a layout change must
not discard half-typed input.
"""
surface.info = info
set_geometry(surface.window, info.rect)
surface.window.lift()
_logger.info(
"lock surface on %s moved to %s", info.output_name, info.rect.geometry()
)
def _destroy(self, name: str) -> None:
"""Tear down the surface for an output that is no longer live."""
surface = self._surfaces.pop(name)
self._builder.teardown_surface(surface.info)
surface.window.destroy()
_logger.info("lock surface on %s removed; that output went dark", name)
def destroy_all(self) -> None:
"""Tear down every surface. Used only when the whole lock is closing."""
for name in list(self._surfaces):
self._destroy(name)