feat: suppress stale AT-SPI churn from compositor state
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user