feat: suppress stale AT-SPI churn from compositor state

This commit is contained in:
2026-04-09 09:27:45 -04:00
parent 80d53ebcc3
commit 309822a0ca
2 changed files with 256 additions and 0 deletions

View File

@@ -49,6 +49,16 @@ from . import script_manager
from . import settings
from .ax_object import AXObject
from .ax_utilities import AXUtilities
from .compositor_state_types import (
CompositorStateEvent,
DESKTOP_FOCUS_CONTEXT_CHANGED,
DESKTOP_TRANSITION_FINISHED,
DESKTOP_TRANSITION_STARTED,
FLUSH_STALE_ATSPI_EVENTS,
PAUSE_ATSPI_CHURN,
PRIORITIZE_FOCUS,
RESUME_ATSPI_CHURN,
)
if TYPE_CHECKING:
from .cthulhu import Cthulhu
@@ -187,7 +197,56 @@ class EventManager:
def set_compositor_state_adapter(self, adapter: Any) -> None:
"""Stores the compositor state adapter used for startup wiring."""
if self._compositorStateAdapter is adapter:
return
if self._compositorStateAdapter is not None and hasattr(self._compositorStateAdapter, "remove_listener"):
self._compositorStateAdapter.remove_listener(self._handle_compositor_signal)
self._compositorStateAdapter = adapter
if adapter is not None and hasattr(adapter, "add_listener"):
adapter.add_listener(self._handle_compositor_signal)
def _handle_compositor_signal(self, signal: CompositorStateEvent) -> None:
"""Updates churn suppression state from compositor normalization signals."""
snapshot = signal.snapshot
signalType = signal.type
contextToken = getattr(signal, "context_token", None)
if contextToken is None and signal.payload:
contextToken = signal.payload.get("context_token")
if contextToken is None and snapshot is not None:
contextToken = snapshot.active_window_token or snapshot.locus_of_focus_token or None
if snapshot is not None:
snapshotToken = snapshot.active_window_token or snapshot.locus_of_focus_token or ""
if not contextToken:
contextToken = snapshotToken or None
if signalType in (DESKTOP_FOCUS_CONTEXT_CHANGED, PRIORITIZE_FOCUS):
self._prioritizedContextToken = contextToken
cthulhu_state.prioritizedDesktopContextToken = contextToken
return
if signalType in (PAUSE_ATSPI_CHURN, DESKTOP_TRANSITION_STARTED):
self._churnSuppressed = True
cthulhu_state.pauseAtspiChurn = True
if contextToken:
self._prioritizedContextToken = contextToken
cthulhu_state.prioritizedDesktopContextToken = contextToken
return
if signalType in (RESUME_ATSPI_CHURN, DESKTOP_TRANSITION_FINISHED):
self._churnSuppressed = False
cthulhu_state.pauseAtspiChurn = False
if contextToken:
self._prioritizedContextToken = contextToken
cthulhu_state.prioritizedDesktopContextToken = contextToken
if signalType == DESKTOP_TRANSITION_FINISHED:
self._flush_stale_atspi_events()
return
if signalType == FLUSH_STALE_ATSPI_EVENTS:
self._flush_stale_atspi_events()
def ignoreEventTypes(self, eventTypeList: List[str]) -> None:
for eventType in eventTypeList:
@@ -450,6 +509,12 @@ class EventManager:
if event.type.startswith('mouse:button'):
return _allow_with_reason("mouse-event", "event type is never ignored")
if self._is_obsolete_by_context(event):
return _ignore_with_reason("compositor-stale-context", "event is from a stale compositor context")
if self._churnSuppressed and not self._should_preserve_during_suppression(event):
return _ignore_with_reason("compositor-churn-paused", "event is suppressed during compositor churn")
if self._isDuplicateEvent(event):
return _ignore_with_reason("duplicate", "duplicate event")
@@ -1343,6 +1408,89 @@ class EventManager:
msg = f"EVENT MANAGER: {oldSize - newSize} events pruned. New size: {newSize}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
def _context_token_for_accessible(self, accessible: Any) -> Optional[str]:
if accessible is None:
return None
application = AXObject.get_application(accessible)
if application is None:
return None
pid = AXObject.get_process_id(application)
name = (AXObject.get_name(accessible) or "").strip()
if not name:
name = AXObject.get_role_name(accessible) or AXObject.get_accessible_id(accessible) or "unknown"
if pid < 0 and not name:
return None
return f"{max(pid, 0)}:{name}"
def _context_token_for_event(self, event: Any) -> Optional[str]:
return self._context_token_for_accessible(getattr(event, "source", None))
def _event_is_from_stale_context(self, event: Any) -> bool:
prioritizedContextToken = self._prioritizedContextToken or cthulhu_state.prioritizedDesktopContextToken
if not prioritizedContextToken:
return False
if getattr(event, "type", "").startswith("window:"):
return False
eventToken = self._context_token_for_event(event)
if eventToken is None:
return False
return eventToken != prioritizedContextToken
def _is_obsolete_by_context(self, event: Any) -> bool:
if not (self._churnSuppressed or cthulhu_state.pauseAtspiChurn):
return False
return self._event_is_from_stale_context(event)
def _should_preserve_during_suppression(self, event: Any) -> bool:
eventType = getattr(event, "type", "")
if eventType.startswith("window:"):
return True
if eventType.startswith("object:state-changed:focused") and getattr(event, "detail1", 0):
return True
if eventType.startswith("object:state-changed:active"):
return AXUtilities.is_frame(event.source) or AXUtilities.is_window(event.source)
if eventType.startswith("object:text-selection-changed"):
return True
if eventType.startswith("object:selection-changed"):
return self._context_token_for_event(event) == (self._prioritizedContextToken or cthulhu_state.prioritizedDesktopContextToken)
return False
def _flush_stale_atspi_events(self) -> None:
"""Drops queued events that no longer match the compositor context."""
self._gidleLock.acquire()
try:
originalQueue = self._eventQueue
newQueue: queue.Queue[Any] = queue.Queue(0)
while not originalQueue.empty():
try:
event = originalQueue.get_nowait()
except queue.Empty:
break
if self._event_is_from_stale_context(event) and not self._should_preserve_during_suppression(event):
continue
newQueue.put(event)
self._eventQueue = newQueue
if self._asyncMode and not self._eventQueue.empty() and not self._gidleId:
self._gidleId = GLib.idle_add(self._dequeue)
finally:
self._gidleLock.release()
def _inFlood(self) -> bool:
size = self._eventQueue.qsize()
if size > 50: