mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 21:07:15 +01:00
gatelock 0.4.1: re-focus a surface that comes back after a blip
LockWindow.grab_input() only ever focuses the entry once, on first grab acquisition (_notify_focus_ready is one-shot). A surface torn down and rebuilt later -- a monitor going dark and recovering, which flaky DDC/CI power control does routinely -- gets a brand new widget that nothing ever focuses. The grab stays held, so no other window can take focus either: the entry renders correctly but is permanently keyboard-dead until the process restarts. RecoveryLoop.tick() now re-runs the same focus_surface(preferred_focus_ index()) call whenever a tick's surface delta reports a newly created output, mirroring what LockWindow does on first acquisition. Bundled the loop's five collaborators into a RecoveryCollaborators dataclass rather than growing its constructor past ruff's five-argument limit. Diagnosed against a week of wake-alarm's real journal history: the dismiss-code box rendered normally every alarm morning but accepted no keystrokes, tracking exactly with HDMI-0's recorded dark/recover blips near 07:00 and 07:55 each day. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGAEbavLSCAotBqhVfFhPc
This commit is contained in:
parent
5f64a8959b
commit
92f55cee5a
@ -47,7 +47,7 @@ if TYPE_CHECKING:
|
||||
from gatelock._detect import OutputChangeDetector
|
||||
from gatelock._outputs import OutputEnumerator
|
||||
from gatelock._surfaces import SurfaceDelta, SurfaceSet
|
||||
from gatelock._window import LockConfig
|
||||
from gatelock._window import LockConfig, LockWindowHooks
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@ -69,23 +69,32 @@ class RecoveryReport:
|
||||
blind: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecoveryCollaborators:
|
||||
"""The pieces one tick re-asserts over.
|
||||
|
||||
Bundled to keep the loop's constructor at one object instead of a
|
||||
growing, unbounded arg list.
|
||||
"""
|
||||
|
||||
config: LockConfig
|
||||
surfaces: SurfaceSet
|
||||
enumerator: OutputEnumerator
|
||||
detector: OutputChangeDetector
|
||||
hooks: LockWindowHooks
|
||||
|
||||
|
||||
class RecoveryLoop:
|
||||
"""Re-asserts the lock's coverage on a timer and on change events."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: tk.Misc,
|
||||
config: LockConfig,
|
||||
surfaces: SurfaceSet,
|
||||
enumerator: OutputEnumerator,
|
||||
detector: OutputChangeDetector,
|
||||
) -> None:
|
||||
def __init__(self, root: tk.Misc, collaborators: RecoveryCollaborators) -> None:
|
||||
"""Wire the loop to the pieces it re-asserts over."""
|
||||
self._root = root
|
||||
self._config = config
|
||||
self._surfaces = surfaces
|
||||
self._enumerator = enumerator
|
||||
self._detector = detector
|
||||
self._config = collaborators.config
|
||||
self._surfaces = collaborators.surfaces
|
||||
self._enumerator = collaborators.enumerator
|
||||
self._detector = collaborators.detector
|
||||
self._hooks = collaborators.hooks
|
||||
self._running = False
|
||||
self._ticks = 0
|
||||
self._drain_job: str | None = None
|
||||
@ -176,6 +185,8 @@ class RecoveryLoop:
|
||||
corrected = self._surfaces.enforce()
|
||||
grab_reasserted = self._reassert_grab()
|
||||
vt_reasserted = self._reassert_vt()
|
||||
if delta.created:
|
||||
self._reassert_focus()
|
||||
|
||||
if delta.unverified:
|
||||
_logger.error(
|
||||
@ -265,6 +276,24 @@ class RecoveryLoop:
|
||||
self._last_grab_warning = None
|
||||
return held
|
||||
|
||||
def _reassert_focus(self) -> None:
|
||||
"""Re-focus the preferred surface after any output came back live.
|
||||
|
||||
``LockWindow.grab_input()`` only focuses the entry once, on first
|
||||
acquisition (:meth:`LockWindow._notify_focus_ready` is one-shot). A
|
||||
surface that is rebuilt later -- an output going dark and returning,
|
||||
which a flaky monitor does routinely -- gets a brand new widget that
|
||||
nothing ever focuses. The lock keeps its grab, so no other window can
|
||||
take focus either: the entry renders correctly but is permanently
|
||||
keyboard-dead until the process restarts. Re-running the same
|
||||
focus_surface(preferred_focus_index()) call LockWindow makes on
|
||||
startup, here, whenever a surface was just (re)created, is what keeps
|
||||
that widget the actual Tk focus target.
|
||||
"""
|
||||
with contextlib.suppress(tk.TclError):
|
||||
index = self._surfaces.preferred_focus_index()
|
||||
self._hooks.on_focus_ready(self._surfaces.focus_surface(index))
|
||||
|
||||
def _warn_once(self, reason: str) -> None:
|
||||
"""Warn about a lost grab, but only when the reason changes.
|
||||
|
||||
|
||||
@ -36,7 +36,7 @@ from gatelock import _density
|
||||
from gatelock._arbiter import RANK_SCREEN_LOCKER
|
||||
from gatelock._detect import OutputChangeDetector
|
||||
from gatelock._outputs import OutputEnumerator
|
||||
from gatelock._recovery import RecoveryLoop
|
||||
from gatelock._recovery import RecoveryCollaborators, RecoveryLoop
|
||||
from gatelock._surfaces import SurfaceSet, needs_backdrop_root
|
||||
from gatelock._vt import disable_vt_switching, restore_vt_switching
|
||||
|
||||
@ -314,7 +314,14 @@ class LockWindow:
|
||||
self._enumerator = OutputEnumerator(root)
|
||||
self._detector = OutputChangeDetector(root)
|
||||
self._recovery = RecoveryLoop(
|
||||
root, config, self._surfaces, self._enumerator, self._detector
|
||||
root,
|
||||
RecoveryCollaborators(
|
||||
config=config,
|
||||
surfaces=self._surfaces,
|
||||
enumerator=self._enumerator,
|
||||
detector=self._detector,
|
||||
hooks=hooks,
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
@ -16,7 +16,7 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from gatelock._outputs import Output, OutputRect, OutputScan
|
||||
from gatelock._recovery import RecoveryLoop
|
||||
from gatelock._recovery import RecoveryCollaborators, RecoveryLoop
|
||||
from gatelock._surfaces import SurfaceSet
|
||||
from gatelock._window import LockConfig
|
||||
|
||||
@ -48,11 +48,21 @@ def symbols(module: str) -> set[str]:
|
||||
def loop(mock_root: MagicMock) -> tuple[RecoveryLoop, MagicMock, SurfaceSet]:
|
||||
"""A recovery loop over mocked surroundings."""
|
||||
config = LockConfig(mode="hard")
|
||||
surfaces = SurfaceSet(mock_root, config, MagicMock())
|
||||
hooks = MagicMock()
|
||||
surfaces = SurfaceSet(mock_root, config, hooks)
|
||||
enumerator = MagicMock()
|
||||
detector = MagicMock()
|
||||
return (
|
||||
RecoveryLoop(mock_root, config, surfaces, enumerator, detector),
|
||||
RecoveryLoop(
|
||||
mock_root,
|
||||
RecoveryCollaborators(
|
||||
config=config,
|
||||
surfaces=surfaces,
|
||||
enumerator=enumerator,
|
||||
detector=detector,
|
||||
hooks=hooks,
|
||||
),
|
||||
),
|
||||
enumerator,
|
||||
surfaces,
|
||||
)
|
||||
@ -267,10 +277,68 @@ class TestGrabReassertion:
|
||||
surfaces = SurfaceSet(mock_root, config, MagicMock())
|
||||
enumerator = MagicMock()
|
||||
enumerator.scan.return_value = BOTH
|
||||
recovery = RecoveryLoop(mock_root, config, surfaces, enumerator, MagicMock())
|
||||
recovery = RecoveryLoop(
|
||||
mock_root,
|
||||
RecoveryCollaborators(
|
||||
config=config,
|
||||
surfaces=surfaces,
|
||||
enumerator=enumerator,
|
||||
detector=MagicMock(),
|
||||
hooks=MagicMock(),
|
||||
),
|
||||
)
|
||||
assert recovery.tick().grab_reasserted is False
|
||||
|
||||
|
||||
class TestFocusReassertion:
|
||||
"""Re-focusing a surface that was (re)built after the first tick.
|
||||
|
||||
Regression coverage for the 2026-08 bug: a monitor blip tears down and
|
||||
rebuilds a surface's widgets mid-lock, but ``LockWindow`` only ever
|
||||
focuses the entry once, on first grab acquisition. Without this, the new
|
||||
widget renders correctly but is never the Tk focus target, and the held
|
||||
global grab means nothing else can take focus either -- a silent,
|
||||
permanent input dead end that looks identical to a working lock.
|
||||
"""
|
||||
|
||||
def test_refocuses_when_a_surface_is_recreated(
|
||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet]
|
||||
) -> None:
|
||||
"""A surface coming back live re-triggers on_focus_ready."""
|
||||
recovery, enumerator, surfaces = loop
|
||||
hooks = surfaces._builder
|
||||
enumerator.scan.return_value = BOTH
|
||||
recovery.tick()
|
||||
hooks.on_focus_ready.reset_mock()
|
||||
enumerator.scan.return_value = DEAD_PRIMARY
|
||||
recovery.tick()
|
||||
enumerator.scan.return_value = BOTH
|
||||
recovery.tick()
|
||||
hooks.on_focus_ready.assert_called_once()
|
||||
|
||||
def test_no_refocus_when_nothing_was_created(
|
||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet]
|
||||
) -> None:
|
||||
"""A tick with no new surfaces must not fight the user mid-typing."""
|
||||
recovery, enumerator, surfaces = loop
|
||||
hooks = surfaces._builder
|
||||
enumerator.scan.return_value = BOTH
|
||||
recovery.tick()
|
||||
hooks.on_focus_ready.reset_mock()
|
||||
recovery.tick()
|
||||
hooks.on_focus_ready.assert_not_called()
|
||||
|
||||
def test_tclerror_during_refocus_is_swallowed(
|
||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
|
||||
) -> None:
|
||||
"""A focus call on a half-torn-down widget must not crash the tick."""
|
||||
recovery, enumerator, surfaces = loop
|
||||
hooks = surfaces._builder
|
||||
hooks.on_focus_ready.side_effect = tk.TclError("gone")
|
||||
enumerator.scan.return_value = BOTH
|
||||
recovery.tick()
|
||||
|
||||
|
||||
class TestVtReassertion:
|
||||
"""Periodically re-disabling VT switching, never re-enabling it."""
|
||||
|
||||
@ -291,7 +359,16 @@ class TestVtReassertion:
|
||||
surfaces = SurfaceSet(mock_root, config, MagicMock())
|
||||
enumerator = MagicMock()
|
||||
enumerator.scan.return_value = BOTH
|
||||
recovery = RecoveryLoop(mock_root, config, surfaces, enumerator, MagicMock())
|
||||
recovery = RecoveryLoop(
|
||||
mock_root,
|
||||
RecoveryCollaborators(
|
||||
config=config,
|
||||
surfaces=surfaces,
|
||||
enumerator=enumerator,
|
||||
detector=MagicMock(),
|
||||
hooks=MagicMock(),
|
||||
),
|
||||
)
|
||||
assert all(not recovery.tick().vt_reasserted for _ in range(31))
|
||||
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "gatelock"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
description = "Shared per-output lock-window + HMAC log-integrity backend for blocking-overlay apps"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [] # pure stdlib — tkinter is bundled with Python
|
||||
|
||||
Loading…
Reference in New Issue
Block a user