testsAndMisc/python_pkg/diet_guard/_gatelock_window.py
Krzysztof kuhy Rudnicki 038e08d2be feat: split oversized modules for 500-line limit, fix kasa coverage gap
Split diet_guard/_gatelock.py, wake_alarm/_alarm.py, and the
usage_report.py/_usage_report_parsing.py pair into focused
sub-modules so every Python file is <= 500 lines, satisfying
test_file_length.py. Install python-kasa into .venv (declared in
requirements but missing after the 3.13->3.14 venv upgrade),
fixing 8 failing smart_plug tests and restoring 100% coverage.

Also includes prior in-progress work from the working tree: the
wake_alarm Progress/View/Hardware field-grouping refactor,
brother_printer query module + tests, diet_guard foodbank/state/cli
updates, new shared coerce/logging_setup helpers, morning_routine
orchestrator tweaks, dwm window-manager config, gaming scripts, and
misc maintenance/digital-wellbeing script updates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 07:19:37 +02:00

172 lines
7.0 KiB
Python

"""Window mechanics and process lifecycle for the MealGate gate.
Split out of :mod:`._gatelock` to keep that module under the repo's 500-line
limit. ``_GateWindow`` extends
:class:`~python_pkg.diet_guard._gatelock_core._GateCore` with the
screen-locker-style window setup (fullscreen, VT-switch disable, global input
grab with retry) and the signal/atexit lifecycle that guarantees VT switching
is restored on every exit path.
"""
from __future__ import annotations
import atexit
import contextlib
import logging
import shutil
import signal
import subprocess
import tkinter as tk
from typing import TYPE_CHECKING
from python_pkg.diet_guard._gatelock_core import _GateCore
from python_pkg.diet_guard._gatelock_ui import BG
if TYPE_CHECKING:
from types import FrameType
_logger = logging.getLogger(__name__)
# Periodic no-op so the grabbed, event-starved loop keeps handing control back
# to Python, letting SIGTERM/SIGINT be serviced promptly.
_KEEPALIVE_MS = 250
# A global input grab fails while another X client already holds one -- most
# often a FULLSCREEN GAME, which takes an exclusive keyboard/pointer grab. A
# single attempt then falls back to a *local* grab, which on an override-redirect
# window the WM refuses to focus means no keystroke ever reaches the field -- the
# "can't type anything" lock-trap. So the grab is retried for the window's whole
# life: the gate waits out the game and captures input the instant it is freed.
_GRAB_RETRY_MS = 200
# How often (in attempts) to log that the grab is still blocked, so the journal
# shows the gate is alive and waiting rather than hung. ~every 5 s at 200 ms.
_GRAB_LOG_EVERY = 25
class _GateWindow(_GateCore):
"""Fullscreen window setup, input grab, and exit-path lifecycle."""
# -- window mechanics (reused screen-locker pattern) --------------------
def _setup_window(self) -> None:
"""Configure the lock window.
Demo mode stays WM-managed so the window manager still grants it
keyboard focus -- and you can always close it -- making a usable, safe
sandbox. Only the real lock uses ``overrideredirect``, where the tiling
WM refuses focus and input is instead forced in by a global grab.
"""
screen_w = self.root.winfo_screenwidth()
screen_h = self.root.winfo_screenheight()
self.root.geometry(f"{screen_w}x{screen_h}+0+0")
self.root.attributes(topmost=True)
self.root.configure(bg=BG, cursor="arrow")
if self.demo_mode:
self.root.attributes(fullscreen=True)
else:
self.root.overrideredirect(boolean=True)
self.root.attributes(fullscreen=True)
self._disable_vt_switching()
def _disable_vt_switching(self) -> None:
"""Block Ctrl+Alt+Fn TTY switching while the lock is up (best-effort)."""
setxkbmap = shutil.which("setxkbmap")
if setxkbmap is None:
_logger.warning("setxkbmap not found; VT switching stays enabled")
return
subprocess.run([setxkbmap, "-option", "srvrkeys:none"], check=False)
self._vt_disabled = True
def _restore_vt_switching(self) -> None:
"""Re-enable VT switching; idempotent and safe to call on any exit."""
if not self._vt_disabled:
return
setxkbmap = shutil.which("setxkbmap")
if setxkbmap is not None:
subprocess.run([setxkbmap, "-option", ""], check=False)
self._vt_disabled = False
def _grab_input(self) -> None:
"""Force input to the window, then focus the first field.
Demo mode relies on normal WM focus (no grab), keeping the window an
escapable sandbox. The real lock forces *all* input here with a global
grab -- the only mechanism that reaches an overrideredirect window the
tiling WM will not focus. The grab is acquired with retries because it
commonly fails on the first attempt while the window is still mapping.
"""
self.root.update_idletasks()
self.root.focus_force()
if not self.demo_mode:
self._acquire_global_grab(attempt=1)
self.root.after(100, self._focus_first_field)
def _acquire_global_grab(self, *, attempt: int) -> None:
"""Acquire the global input grab, retrying until it succeeds.
A successful global grab is the only way keystrokes reach the
override-redirect window the WM will not focus. When another client
(typically a fullscreen game) holds the grab, the attempt is rescheduled
indefinitely rather than conceding to an unusable local grab, so the gate
waits the other application out and captures input the moment it frees
the grab. On success, focus is forced onto the description field so the
first keystroke lands there.
Args:
attempt: 1-based attempt counter, used only to throttle the log.
"""
try:
self.root.grab_set_global()
except tk.TclError:
if attempt % _GRAB_LOG_EVERY == 0:
_logger.warning(
"global grab still blocked after %d attempts (another app -- "
"e.g. a fullscreen game -- holds it); waiting for it to free",
attempt,
)
self.root.after(
_GRAB_RETRY_MS,
lambda: self._acquire_global_grab(attempt=attempt + 1),
)
return
with contextlib.suppress(tk.TclError):
self.root.focus_force()
self._focus_first_field()
def _focus_first_field(self) -> None:
"""Put keyboard focus on the description entry once it is mapped."""
with contextlib.suppress(tk.TclError):
self._widgets.desc_text.focus_force()
# -- lifecycle ------------------------------------------------------------
def _install_signal_handlers(self) -> None:
"""Ensure VT switching is restored on crash or kill, not just close."""
atexit.register(self._restore_vt_switching)
for sig in (signal.SIGTERM, signal.SIGINT):
with contextlib.suppress(ValueError):
signal.signal(sig, self._on_signal)
def _on_signal(self, _signum: int, _frame: FrameType | None) -> None:
"""Restore the keyboard escape, then exit, on SIGTERM/SIGINT."""
self._restore_vt_switching()
raise SystemExit(0)
def _keepalive(self) -> None:
"""Re-arm a periodic no-op so pending signals get serviced promptly."""
self.root.after(_KEEPALIVE_MS, self._keepalive)
def close(self) -> None:
"""Restore VT switching and destroy the window (no process exit)."""
self._restore_vt_switching()
with contextlib.suppress(tk.TclError):
self.root.destroy()
def run(self) -> None:
"""Run the Tk loop, restoring VT switching on every exit path."""
self._install_signal_handlers()
self._keepalive()
try:
self.root.mainloop()
finally:
self._restore_vt_switching()