mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 21:14:00 +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._detect import OutputChangeDetector
|
||||||
from gatelock._outputs import OutputEnumerator
|
from gatelock._outputs import OutputEnumerator
|
||||||
from gatelock._surfaces import SurfaceDelta, SurfaceSet
|
from gatelock._surfaces import SurfaceDelta, SurfaceSet
|
||||||
from gatelock._window import LockConfig
|
from gatelock._window import LockConfig, LockWindowHooks
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -69,23 +69,32 @@ class RecoveryReport:
|
|||||||
blind: tuple[str, ...] = ()
|
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:
|
class RecoveryLoop:
|
||||||
"""Re-asserts the lock's coverage on a timer and on change events."""
|
"""Re-asserts the lock's coverage on a timer and on change events."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, root: tk.Misc, collaborators: RecoveryCollaborators) -> None:
|
||||||
self,
|
|
||||||
root: tk.Misc,
|
|
||||||
config: LockConfig,
|
|
||||||
surfaces: SurfaceSet,
|
|
||||||
enumerator: OutputEnumerator,
|
|
||||||
detector: OutputChangeDetector,
|
|
||||||
) -> None:
|
|
||||||
"""Wire the loop to the pieces it re-asserts over."""
|
"""Wire the loop to the pieces it re-asserts over."""
|
||||||
self._root = root
|
self._root = root
|
||||||
self._config = config
|
self._config = collaborators.config
|
||||||
self._surfaces = surfaces
|
self._surfaces = collaborators.surfaces
|
||||||
self._enumerator = enumerator
|
self._enumerator = collaborators.enumerator
|
||||||
self._detector = detector
|
self._detector = collaborators.detector
|
||||||
|
self._hooks = collaborators.hooks
|
||||||
self._running = False
|
self._running = False
|
||||||
self._ticks = 0
|
self._ticks = 0
|
||||||
self._drain_job: str | None = None
|
self._drain_job: str | None = None
|
||||||
@ -176,6 +185,8 @@ class RecoveryLoop:
|
|||||||
corrected = self._surfaces.enforce()
|
corrected = self._surfaces.enforce()
|
||||||
grab_reasserted = self._reassert_grab()
|
grab_reasserted = self._reassert_grab()
|
||||||
vt_reasserted = self._reassert_vt()
|
vt_reasserted = self._reassert_vt()
|
||||||
|
if delta.created:
|
||||||
|
self._reassert_focus()
|
||||||
|
|
||||||
if delta.unverified:
|
if delta.unverified:
|
||||||
_logger.error(
|
_logger.error(
|
||||||
@ -265,6 +276,24 @@ class RecoveryLoop:
|
|||||||
self._last_grab_warning = None
|
self._last_grab_warning = None
|
||||||
return held
|
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:
|
def _warn_once(self, reason: str) -> None:
|
||||||
"""Warn about a lost grab, but only when the reason changes.
|
"""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._arbiter import RANK_SCREEN_LOCKER
|
||||||
from gatelock._detect import OutputChangeDetector
|
from gatelock._detect import OutputChangeDetector
|
||||||
from gatelock._outputs import OutputEnumerator
|
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._surfaces import SurfaceSet, needs_backdrop_root
|
||||||
from gatelock._vt import disable_vt_switching, restore_vt_switching
|
from gatelock._vt import disable_vt_switching, restore_vt_switching
|
||||||
|
|
||||||
@ -314,7 +314,14 @@ class LockWindow:
|
|||||||
self._enumerator = OutputEnumerator(root)
|
self._enumerator = OutputEnumerator(root)
|
||||||
self._detector = OutputChangeDetector(root)
|
self._detector = OutputChangeDetector(root)
|
||||||
self._recovery = RecoveryLoop(
|
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
|
@property
|
||||||
|
|||||||
@ -16,7 +16,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from gatelock._outputs import Output, OutputRect, OutputScan
|
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._surfaces import SurfaceSet
|
||||||
from gatelock._window import LockConfig
|
from gatelock._window import LockConfig
|
||||||
|
|
||||||
@ -48,11 +48,21 @@ def symbols(module: str) -> set[str]:
|
|||||||
def loop(mock_root: MagicMock) -> tuple[RecoveryLoop, MagicMock, SurfaceSet]:
|
def loop(mock_root: MagicMock) -> tuple[RecoveryLoop, MagicMock, SurfaceSet]:
|
||||||
"""A recovery loop over mocked surroundings."""
|
"""A recovery loop over mocked surroundings."""
|
||||||
config = LockConfig(mode="hard")
|
config = LockConfig(mode="hard")
|
||||||
surfaces = SurfaceSet(mock_root, config, MagicMock())
|
hooks = MagicMock()
|
||||||
|
surfaces = SurfaceSet(mock_root, config, hooks)
|
||||||
enumerator = MagicMock()
|
enumerator = MagicMock()
|
||||||
detector = MagicMock()
|
detector = MagicMock()
|
||||||
return (
|
return (
|
||||||
RecoveryLoop(mock_root, config, surfaces, enumerator, detector),
|
RecoveryLoop(
|
||||||
|
mock_root,
|
||||||
|
RecoveryCollaborators(
|
||||||
|
config=config,
|
||||||
|
surfaces=surfaces,
|
||||||
|
enumerator=enumerator,
|
||||||
|
detector=detector,
|
||||||
|
hooks=hooks,
|
||||||
|
),
|
||||||
|
),
|
||||||
enumerator,
|
enumerator,
|
||||||
surfaces,
|
surfaces,
|
||||||
)
|
)
|
||||||
@ -267,10 +277,68 @@ class TestGrabReassertion:
|
|||||||
surfaces = SurfaceSet(mock_root, config, MagicMock())
|
surfaces = SurfaceSet(mock_root, config, MagicMock())
|
||||||
enumerator = MagicMock()
|
enumerator = MagicMock()
|
||||||
enumerator.scan.return_value = BOTH
|
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
|
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:
|
class TestVtReassertion:
|
||||||
"""Periodically re-disabling VT switching, never re-enabling it."""
|
"""Periodically re-disabling VT switching, never re-enabling it."""
|
||||||
|
|
||||||
@ -291,7 +359,16 @@ class TestVtReassertion:
|
|||||||
surfaces = SurfaceSet(mock_root, config, MagicMock())
|
surfaces = SurfaceSet(mock_root, config, MagicMock())
|
||||||
enumerator = MagicMock()
|
enumerator = MagicMock()
|
||||||
enumerator.scan.return_value = BOTH
|
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))
|
assert all(not recovery.tick().vt_reasserted for _ in range(31))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "gatelock"
|
name = "gatelock"
|
||||||
version = "0.4.0"
|
version = "0.4.1"
|
||||||
description = "Shared per-output lock-window + HMAC log-integrity backend for blocking-overlay apps"
|
description = "Shared per-output lock-window + HMAC log-integrity backend for blocking-overlay apps"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [] # pure stdlib — tkinter is bundled with Python
|
dependencies = [] # pure stdlib — tkinter is bundled with Python
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user