utils/gatelock/gatelock/_arming.py
Krzysztof kuhy Rudnicki 940bf19d54 gatelock: split _window.py 571 -> 243 lines across four modules
_window.py was the repo's largest file and could not be read in one piece.
Split along the seams the file already marked out, keeping the public API
and every existing import path intact:

  _config.py  (215) — LockConfig, the design tokens and preset resolution
  _arming.py  (178) — the way up: VT-disable, backdrop, surfaces, input grab
  _preempt.py (118) — naming a blocked grab's holder, standing a weaker one down
  _hooks.py   (51)  — the LockWindowHooks Protocol, pure interface
  _window.py  (243) — construction, focus, and teardown

_arming takes an explicit ArmingCollaborators bundle rather than the window
itself, mirroring the existing RecoveryCollaborators; setup() returns whether
VT was disabled instead of mutating the window. That leaves no reach-back into
private members (ruff SLF001 clean) and makes the arming sequence testable
without a window.

LockConfig is re-exported from _window, so `from gatelock._window import
LockConfig` — which five sibling modules and the tests already do — still
resolves. Tests that patched moved symbols were retargeted to their new home;
the dead _maybe_preempt delegate, whose only callers were tests, is gone.

389 passed, 100% branch coverage — identical to before the split.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 14:20:11 +02:00

179 lines
6.4 KiB
Python

"""Bringing the lock up: VT-disable, backdrop, surfaces, and the input grab.
Split out of :mod:`gatelock._window`, which keeps the object's construction
and its teardown. Everything here runs once, in order, on the way *up* --
:func:`setup` makes the lock visible and :func:`grab_input` makes it
exclusive.
Arming order matters and is deliberate: **arm first, render second.** The grab
and the VT-disable happen regardless of how many outputs are live, and the
surfaces are then built for whatever the user can actually see -- possibly
nothing. Zero live outputs means "lock without showing", never "decline to
lock". The inverse of that rule is what left the machine unlocked on
2026-07-25.
These are free functions over an explicit :class:`ArmingCollaborators` bundle
rather than methods, mirroring :class:`gatelock._recovery.RecoveryCollaborators`.
Nothing here reaches back into the window, so the arming sequence can be read
-- and tested -- without one.
"""
from __future__ import annotations
import contextlib
from dataclasses import dataclass
import logging
import tkinter as tk
from typing import TYPE_CHECKING
from gatelock._surfaces import needs_backdrop_root
from gatelock._vt import disable_vt_switching
if TYPE_CHECKING:
from collections.abc import Callable
from gatelock._config import LockConfig
from gatelock._detect import OutputChangeDetector
from gatelock._outputs import OutputEnumerator
from gatelock._recovery import RecoveryLoop
from gatelock._surfaces import SurfaceSet
_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
# Default retry interval for a "global" grab that initially fails (e.g. a
# fullscreen game holds it). Used whenever grab_retry_ms is left unset.
_DEFAULT_GRAB_RETRY_MS = 200
@dataclass(frozen=True)
class ArmingCollaborators:
"""Everything the arming sequence needs from its owning window.
Attributes:
config: Declarative lock behavior for this instance.
surfaces: The per-output surface set to build into.
enumerator: Source of the live-output scan.
detector: Output-change watcher, started once the grab is taken.
recovery: Re-assertion loop, started alongside the detector.
notify_focus_ready: Called once the lock is mapped and grabbed.
log_grab_blocked: Called with the attempt count when a global grab is
still blocked, to name the holder and stand a weaker one down.
"""
config: LockConfig
surfaces: SurfaceSet
enumerator: OutputEnumerator
detector: OutputChangeDetector
recovery: RecoveryLoop
notify_focus_ready: Callable[[], None]
log_grab_blocked: Callable[[int], None]
def setup(root: tk.Tk, collab: ArmingCollaborators) -> bool:
"""Configure the backdrop and build a surface on every live output.
Disables VT switching first: strengthening the lock must not wait on
being able to draw anything. If no output is live, no surface is built
and the lock stays armed and invisible until one appears.
Args:
root: The Tk root acting as backdrop and grab holder.
collab: The window's arming collaborators.
Returns:
Whether VT switching was disabled, for the caller to remember so the
matching restore happens exactly once on the way back down.
"""
vt_disabled = False
if collab.config.resolved_disable_vt():
vt_disabled = disable_vt_switching()
if needs_backdrop_root(collab.config):
# The root is a plain black backdrop spanning the whole X screen,
# including any region a modeless output left behind. It holds the
# grab (X will not grab for a window that is not viewable) and
# never carries widgets.
root.overrideredirect(boolean=collab.config.resolved_overrideredirect())
collab.surfaces.update_backdrop()
else:
root.attributes("-topmost", _TOPMOST_ON)
scan = collab.enumerator.scan()
delta = collab.surfaces.apply(scan)
if not scan.live:
_logger.warning(
"no live outputs at startup (source=%s); arming the lock with "
"nothing displayed -- it will appear as soon as a monitor "
"comes back",
scan.source,
)
else:
_logger.info(
"lock armed on %d output(s): %s",
len(scan.live),
", ".join(output.name for output in scan.live),
)
if delta.unverified:
_logger.error(
"outputs live but not covered at startup: %s",
", ".join(delta.unverified),
)
return vt_disabled
def grab_input(root: tk.Tk, collab: ArmingCollaborators) -> None:
"""Take the configured grab, then start watching for output changes.
Args:
root: The Tk root that holds the grab.
collab: The window's arming collaborators.
"""
root.update_idletasks()
root.focus_force()
grab = collab.config.resolved_grab()
if grab == "global":
acquire_global_grab(root, collab, attempt=1)
elif grab == "local":
with contextlib.suppress(tk.TclError):
root.grab_set()
collab.detector.start()
collab.recovery.start()
root.after(100, collab.notify_focus_ready)
def acquire_global_grab(
root: tk.Tk, collab: ArmingCollaborators, *, attempt: int
) -> None:
"""Acquire the global input grab, retrying per ``grab_retry_ms``.
Args:
root: The Tk root that holds the grab.
collab: The window's arming collaborators.
attempt: 1-based attempt counter, used only to throttle the log.
"""
retry_ms = collab.config.grab_retry_ms
try:
root.grab_set_global()
except tk.TclError:
if retry_ms == 0:
_logger.warning("Global grab failed, falling back to local grab")
with contextlib.suppress(tk.TclError):
root.grab_set()
return
effective_retry_ms = retry_ms or _DEFAULT_GRAB_RETRY_MS
if not attempt % collab.config.grab_log_every:
collab.log_grab_blocked(attempt)
root.after(
effective_retry_ms,
lambda: acquire_global_grab(root, collab, attempt=attempt + 1),
)
return
with contextlib.suppress(tk.TclError):
root.focus_force()
collab.notify_focus_ready()