diff --git a/src/cthulhu/cthulhu.py b/src/cthulhu/cthulhu.py index baf180f..10a811c 100644 --- a/src/cthulhu/cthulhu.py +++ b/src/cthulhu/cthulhu.py @@ -51,7 +51,6 @@ if TYPE_CHECKING: from .input_event import InputEvent from .input_event_manager import InputEventManager from .event_manager import EventManager - from .compositor_state_adapter import CompositorStateAdapter from .signal_manager import SignalManager from .dynamic_api_manager import DynamicApiManager from .speech import Speech @@ -258,14 +257,16 @@ except Exception: from . import braille from . import debug +from . import ax_device_manager +from . import command_manager from . import event_manager -from . import compositor_state_adapter from . import keybindings from . import learn_mode_presenter from . import logger from . import mako_notification_monitor from . import messages from . import notification_presenter +from . import presentation_manager from . import focus_manager from . import cthulhu_modifier_manager from . import cthulhu_state @@ -408,7 +409,7 @@ def deviceChangeHandler(deviceManager: Any, device: Any) -> None: if source == Gdk.InputSource.KEYBOARD: msg: str = "CTHULHU: Keyboard change detected, re-creating the xmodmap" debug.printMessage(debug.LEVEL_INFO, msg, True) - cthulhu_modifier_manager.getManager().refreshCthulhuModifiers("Keyboard change detected.") + cthulhu_modifier_manager.get_manager().refresh_cthulhu_modifiers("Keyboard change detected.") def loadUserSettings(script: Optional[Any] = None, inputEvent: Optional[Any] = None, skipReloadMessage: bool = False) -> bool: """Loads (and reloads) the user settings module, reinitializing @@ -421,23 +422,16 @@ def loadUserSettings(script: Optional[Any] = None, inputEvent: Optional[Any] = N global _userSettings - # Shutdown the output drivers and give them a chance to die. - - player = sound.getPlayer() - player.shutdown() - speech.shutdown() - braille.shutdown() - + cthulhuApp.eventManager.pause_queuing(True, True, "Loading user settings.") cthulhuApp.scriptManager.deactivate() cthulhuApp.getSignalManager().emitSignal('load-setting-begin') - reloaded: bool = False + reloaded: bool = _userSettings is not None if _userSettings: _profile = cthulhuApp.settingsManager.getSetting('activeProfile')[1] try: _userSettings = cthulhuApp.settingsManager.getGeneralSettings(_profile) cthulhuApp.settingsManager.setProfile(_profile) - reloaded = True except ImportError: debug.printException(debug.LEVEL_INFO) except Exception: @@ -456,49 +450,22 @@ def loadUserSettings(script: Optional[Any] = None, inputEvent: Optional[Any] = N cthulhuApp.settingsManager.loadAppSettings(script) - if cthulhuApp.settingsManager.getSetting('enableSpeech'): - msg = 'CTHULHU: About to enable speech' - debug.printMessage(debug.LEVEL_INFO, msg, True) - try: - speech.init() - if reloaded and not skipReloadMessage: - script.speakMessage(messages.SETTINGS_RELOADED) - except Exception: - debug.printException(debug.LEVEL_SEVERE) - else: - msg = 'CTHULHU: Speech is not enabled in settings' - debug.printMessage(debug.LEVEL_INFO, msg, True) - - if cthulhuApp.settingsManager.getSetting('enableBraille'): - msg = 'CTHULHU: About to enable braille' - debug.printMessage(debug.LEVEL_INFO, msg, True) - try: - braille.init(_processBrailleEvent) - except Exception: - debug.printException(debug.LEVEL_WARNING) - msg = 'CTHULHU: Could not initialize connection to braille.' - debug.printMessage(debug.LEVEL_WARNING, msg, True) - else: - msg = 'CTHULHU: Braille is not enabled in settings' - debug.printMessage(debug.LEVEL_INFO, msg, True) - + command_manager.get_manager().load_keyboard_layout() + presentation_manager.get_manager().start_presenters() + if reloaded and not skipReloadMessage: + presentation_manager.get_manager().speak_message(messages.SETTINGS_RELOADED) if cthulhuApp.settingsManager.getSetting('enableMouseReview'): mouse_review.getReviewer().activate() else: mouse_review.getReviewer().deactivate() - if cthulhuApp.settingsManager.getSetting('enableSound'): - player.init() + cthulhu_modifier_manager.get_manager().refresh_cthulhu_modifiers("Loading user settings.") - cthulhu_modifier_manager.getManager().refreshCthulhuModifiers("Loading user settings.") - - # Activate core systems FIRST before loading plugins - cthulhuApp.compositorStateAdapter.activate() - cthulhuApp.scriptManager.activate() + ax_device_manager.get_manager().activate() cthulhuApp.eventManager.activate() - - cthulhuApp.getSignalManager().emitSignal('load-setting-begin') + cthulhuApp.scriptManager.activate() + cthulhuApp.eventManager.pause_queuing(False, False, "User settings loaded.") # NOW load plugins after script system is ready activePluginsSetting = cthulhuApp.settingsManager.getSetting('activePlugins') or [] @@ -771,34 +738,28 @@ def shutdown(script: Optional[Any] = None, inputEvent: Optional[Any] = None) -> signal.signal(signal.SIGALRM, settings.timeoutCallback) signal.alarm(settings.timeoutTime) - cthulhu_state.activeScript.presentationInterrupt() + presManager = presentation_manager.get_manager() + presManager.interrupt_presentation() + presManager.present_message(messages.STOP_CTHULHU) cthulhuApp.getSignalManager().emitSignal('stop-application-completed') sound_theme_manager.getManager().playStopSound(wait=True, timeoutSeconds=1) cthulhuApp.getPluginSystemManager().unloadAllPlugins(ForceAllPlugins=True) cthulhuApp.getMakoNotificationMonitor().stop() - # Deactivate the event manager first so that it clears its queue and will not - # accept new events. Then let the script manager unregister script event listeners. - cthulhuApp.compositorStateAdapter.deactivate() - cthulhuApp.eventManager.deactivate() + # Pause event queuing first so that it clears its queue and will not accept + # new events. Then let the script manager unregister script event listeners. + cthulhuApp.eventManager.pause_queuing(True, True, "Shutting down.") cthulhuApp.scriptManager.deactivate() - - # Shutdown all the other support. - # - if settings.enableSpeech: - speech.shutdown() - if settings.enableBraille: - braille.shutdown() - if settings.enableSound: - player = sound.getPlayer() - player.shutdown() + cthulhuApp.eventManager.deactivate() + presentation_manager.get_manager().shutdown_presenters() + ax_device_manager.get_manager().deactivate() if settings.timeoutCallback and (settings.timeoutTime > 0): signal.alarm(0) _initialized = False - cthulhu_modifier_manager.getManager().unsetCthulhuModifiers("Shutting down.") + cthulhu_modifier_manager.get_manager().unset_cthulhu_modifiers("Shutting down.") debug.printMessage(debug.LEVEL_INFO, 'CTHULHU: Quitting Atspi main event loop', True) Atspi.event_quit() @@ -853,7 +814,7 @@ def crashOnSignal(signum: int, frame: Optional[FrameType]) -> None: msg: str = f"CTHULHU: Shutting down and exiting due to signal={signum} {signalString}" debug.printMessage(debug.LEVEL_SEVERE, msg, True) debug.printStack(debug.LEVEL_SEVERE) - cthulhu_modifier_manager.getManager().unsetCthulhuModifiers("Crashed") + cthulhu_modifier_manager.get_manager().unset_cthulhu_modifiers("Crashed") sys.exit(1) def main() -> int: @@ -956,11 +917,8 @@ class Cthulhu(GObject.Object): # add members self.resourceManager: resource_manager.ResourceManager = resource_manager.ResourceManager(self) self.settingsManager: SettingsManager = settings_manager.SettingsManager(self) # Directly instantiate - self.eventManager: EventManager = event_manager.EventManager(self) # Directly instantiate - self.compositorStateAdapter: CompositorStateAdapter = compositor_state_adapter.CompositorStateAdapter() - self.eventManager.set_compositor_state_adapter(self.compositorStateAdapter) - self.scriptManager: ScriptManager = script_manager.ScriptManager(self) # Directly instantiate - script_manager._manager = self.scriptManager + self.eventManager: EventManager = event_manager.get_manager() + self.scriptManager: ScriptManager = script_manager.get_manager() self.logger: logger.Logger = logger.Logger() # Directly instantiate self.signalManager: SignalManager = signal_manager.SignalManager(self) self.dynamicApiManager: DynamicApiManager = dynamic_api_manager.DynamicApiManager(self) @@ -992,9 +950,6 @@ class Cthulhu(GObject.Object): def getEventManager(self) -> EventManager: return self.eventManager - def getCompositorStateAdapter(self) -> CompositorStateAdapter: - return self.compositorStateAdapter - def getSettingsManager(self) -> SettingsManager: return self.settingsManager diff --git a/src/cthulhu/event_manager.py b/src/cthulhu/event_manager.py index 4c1b636..885cb49 100644 --- a/src/cthulhu/event_manager.py +++ b/src/cthulhu/event_manager.py @@ -1,9 +1,7 @@ -#!/usr/bin/env python3 +# Cthulhu # -# Copyright (c) 2024 Stormux -# Copyright (c) 2010-2012 The Orca Team -# Copyright (c) 2012 Igalia, S.L. -# Copyright (c) 2005-2010 Sun Microsystems Inc. +# Copyright 2011-2024 Igalia, S.L. +# Author: Joanmarie Diggs # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -19,1107 +17,713 @@ # License along with this library; if not, write to the # Free Software Foundation, Inc., Franklin Street, Fifth Floor, # Boston MA 02110-1301 USA. -# -# Forked from Orca screen reader. -# Cthulhu project: https://git.stormux.org/storm/cthulhu + +# pylint: disable=too-many-instance-attributes +# pylint: disable=too-many-locals + +"""Manager for accessible object events.""" from __future__ import annotations -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2011. Cthulhu Team." -__license__ = "LGPL" - -import gi -gi.require_version('Atspi', '2.0') -from gi.repository import Atspi -from gi.repository import GLib +import itertools import queue import threading import time -from typing import TYPE_CHECKING, Optional, Dict, List, Tuple, Any, Union +from typing import TYPE_CHECKING -from . import cthulhu -from . import debug -from . import input_event -from . import input_event_manager -from . import cthulhu_state -from . import script_manager -from . import settings +import gi + +gi.require_version("Atspi", "2.0") +from gi.repository import Atspi, GLib + +from . import ( + braille_presenter, + debug, + focus_manager, + input_event, + input_event_manager, + cthulhu_modifier_manager, + script_manager, + systemd, +) 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, -) +from .ax_utilities_debugging import AXUtilitiesDebugging if TYPE_CHECKING: - from .cthulhu import Cthulhu - from .script import Script - from .input_event_manager import InputEventManager + from .scripts import default + class EventManager: + """Manager for accessible object events.""" - EMBEDDED_OBJECT_CHARACTER: str = '\ufffc' - RELEVANCE_KEEP: str = "keep" - RELEVANCE_COLLAPSE: str = "collapse" - RELEVANCE_DROP: str = "drop" + PRIORITY_IMMEDIATE = 1 + PRIORITY_IMPORTANT = 2 + PRIORITY_HIGH = 3 + PRIORITY_NORMAL = 4 + PRIORITY_LOWER = 5 + PRIORITY_LOW = 6 - def __init__(self, app: Cthulhu, asyncMode: bool = True) -> None: - debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Initializing', True) - debug.printMessage(debug.LEVEL_INFO, f'EVENT MANAGER: Async Mode is {asyncMode}', True) - self.app: Cthulhu = app - self._asyncMode: bool = asyncMode - self._scriptListenerCounts: Dict[str, int] = {} + def __init__(self) -> None: + debug.print_message(debug.LEVEL_INFO, "EVENT MANAGER: Initializing", True) + self._script_listener_counts: dict[str, int] = {} self._active: bool = False - self._enqueueCount: int = 0 - self._dequeueCount: int = 0 - self._cmdlineCache: Dict[int, str] = {} - self._eventQueue: queue.Queue[Any] = queue.Queue(0) - self._prioritizedEvent: Optional[Atspi.Event] = None - self._gidleId: int = 0 - self._prioritizedIdleId: int = 0 - self._gidleLock: threading.Lock = threading.Lock() - self._gilSleepTime: float = 0.00001 - self._synchronousToolkits: List[str] = ['VCL'] - self._eventsSuspended: bool = False - self._listener: Atspi.EventListener = Atspi.EventListener.new(self._enqueue) - - # Note: These must match what the scripts registered for, otherwise - # Atspi might segfault. - # - # Events we don't want to suspend include: - # object:text-changed:insert - marco - # object:property-change:accessible-name - gnome-shell issue #6925 - self._suspendableEvents: List[str] = ['object:children-changed:add', - 'object:children-changed:remove', - 'object:state-changed:sensitive', - 'object:state-changed:showing', - 'object:text-changed:delete'] - self._eventsTriggeringSuspension: List[Atspi.Event] = [] - self._ignoredEvents: List[str] = ['object:bounds-changed', - 'object:state-changed:defunct', - 'object:property-change:accessible-parent'] - self._parentsOfDefunctDescendants: List[Atspi.Accessible] = [] - self._compositorStateAdapter: Optional[Any] = None - self._churnSuppressed: bool = cthulhu_state.pauseAtspiChurn - self._prioritizedContextToken: Optional[str] = cthulhu_state.prioritizedDesktopContextToken - self._relevanceBurstWindow: float = 0.15 - self._relevanceBurstHistory: Dict[Tuple[str, str, str], float] = {} - - cthulhu_state.device = None - self._keyHandlingActive: bool = False - self._inputEventManager: Optional[InputEventManager] = None - - debug.printMessage(debug.LEVEL_INFO, 'Event manager initialized', True) + self._paused: bool = False + self._counter = itertools.count() + self._event_queue: queue.PriorityQueue[tuple[int, int, Atspi.Event]] = queue.PriorityQueue( + 0, + ) + self._gidle_id: int = 0 + self._gidle_lock = threading.Lock() + self._listener: Atspi.EventListener = Atspi.EventListener.new(self._enqueue_object_event) + self._event_history: dict[str, tuple[int | None, float]] = {} + debug.print_message(debug.LEVEL_INFO, "Event manager initialized", True) def activate(self) -> None: """Called when this event manager is activated.""" - debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Activating', True) - self._activateKeyHandling() + debug.print_message(debug.LEVEL_INFO, "EVENT MANAGER: Activating", True, True) + if self._active: + debug.print_message(debug.LEVEL_INFO, "EVENT MANAGER: Already activated", True) + return + + input_event_manager.get_manager().start_key_watcher() + cthulhu_modifier_manager.get_manager().add_grabs_for_cthulhu_modifiers() self._active = True - GLib.idle_add(self._sync_focus_on_startup) - debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Activated', True) - - def _sync_focus_on_startup(self) -> bool: - """Initialize active window and focus when startup missed focus events.""" - - focus = cthulhu_state.locusOfFocus - if focus and not AXObject.is_dead(focus): - return False - - window = cthulhu_state.activeWindow - if not AXUtilities.can_be_active_window(window, clear_cache=True): - window = AXUtilities.find_active_window() - if window is not None: - tokens = ["EVENT MANAGER: Setting initial active window to", window] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - cthulhu.setActiveWindow(window, alsoSetLocusOfFocus=True, notifyScript=False) - - if window is None: - return False - - focused = AXUtilities.get_focused_object(window) - if focused is not None and focused != cthulhu_state.locusOfFocus: - cthulhu.setLocusOfFocus(None, focused, notifyScript=True, force=True) - elif cthulhu_state.locusOfFocus is None: - cthulhu.setLocusOfFocus(None, window, notifyScript=True, force=True) - - if getattr(self, "_compositorStateAdapter", None) is not None: - self._compositorStateAdapter.sync_accessible_context("event-manager-startup") - - return False - - def _activateKeyHandling(self) -> None: - """Activates keyboard handling using InputEventManager with Atspi.Device.""" - - if self._keyHandlingActive: - return - - debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Activating keyboard handling', True) - self._inputEventManager = input_event_manager.get_manager() - self._inputEventManager.start_key_watcher() - cthulhu_state.device = self._inputEventManager._device - self._keyHandlingActive = True - debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Keyboard handling activated', True) - - def _deactivateKeyHandling(self) -> None: - """Deactivates keyboard handling.""" - - if not self._keyHandlingActive: - return - - debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Deactivating keyboard handling', True) - if self._inputEventManager: - self._inputEventManager.stop_key_watcher() - cthulhu_state.device = None - self._keyHandlingActive = False - debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Keyboard handling deactivated', True) + debug.print_message(debug.LEVEL_INFO, "EVENT MANAGER: Activated", True) def deactivate(self) -> None: """Called when this event manager is deactivated.""" - debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Deactivating', True) + debug.print_message(debug.LEVEL_INFO, "EVENT MANAGER: Deactivating", True, True) + if not self._active: + debug.print_message(debug.LEVEL_INFO, "EVENT MANAGER: Already deactivated", True) + return + + input_event_manager.get_manager().stop_key_watcher() self._active = False - self._eventQueue = queue.Queue(0) - self._scriptListenerCounts = {} - self._deactivateKeyHandling() - debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Deactivated', True) + self._event_queue = queue.PriorityQueue(0) + self._script_listener_counts = {} + debug.print_message(debug.LEVEL_INFO, "EVENT MANAGER: Deactivated", True) - def set_compositor_state_adapter(self, adapter: Any) -> None: - """Stores the compositor state adapter used for startup wiring.""" + def pause_queuing( + self, + pause: bool = True, + clear_queue: bool = False, + reason: str = "", + ) -> None: + """Pauses/unpauses event queuing.""" - if self._compositorStateAdapter is adapter: - return + msg = f"EVENT MANAGER: Pause queueing: {pause}. Clear queue: {clear_queue}. {reason}" + debug.print_message(debug.LEVEL_INFO, msg, True) + self._paused = pause + if clear_queue: + self._event_queue = queue.PriorityQueue(0) + input_event_manager.get_manager().pause_key_watcher(pause, reason) - if self._compositorStateAdapter is not None and hasattr(self._compositorStateAdapter, "remove_listener"): - self._compositorStateAdapter.remove_listener(self._handle_compositor_signal) + def _get_priority(self, event: Atspi.Event) -> int: + """Returns the priority associated with event.""" - 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: - if eventType not in self._ignoredEvents: - self._ignoredEvents.append(eventType) - - def unignoreEventTypes(self, eventTypeList: List[str]) -> None: - for eventType in eventTypeList: - if eventType in self._ignoredEvents: - self._ignoredEvents.remove(eventType) - - def _isDuplicateEvent(self, event: Atspi.Event) -> bool: - """Returns True if this event is already in the event queue.""" - - if self._inFlood() and self._prioritizeDuringFlood(event): - return False - - def isSame(x: Any) -> bool: - return x.type == event.type \ - and x.source == event.source \ - and x.detail1 == event.detail1 \ - and x.detail2 == event.detail2 \ - and x.any_data == event.any_data - - for e in self._eventQueue.queue: - if isSame(e): - return True - - return False - - def _getAppCmdline(self, app: Atspi.Accessible) -> str: - pid = AXObject.get_process_id(app) - if pid == -1: - return "" - - cmdline = self._cmdlineCache.get(pid) - if cmdline is None: - cmdline = debug.getCmdline(pid) - self._cmdlineCache[pid] = cmdline - return cmdline - - def _isSteamApp(self, app: Atspi.Accessible) -> bool: - name = AXObject.get_name(app) - if not name: - nameLower = "" + event_type = event.type + if event_type.startswith("window") or ( + event_type == "object:state-changed:active" + and (AXUtilities.is_frame(event.source) or AXUtilities.is_dialog_or_alert(event.source)) + ): + priority = EventManager.PRIORITY_IMPORTANT + elif event_type.startswith( + ("object:state-changed:focused", "object:active-descendant-changed"), + ): + priority = EventManager.PRIORITY_HIGH + elif event_type.startswith("object:announcement"): + if event.detail1 == Atspi.Live.ASSERTIVE: + priority = EventManager.PRIORITY_IMPORTANT + elif event.detail1 == Atspi.Live.POLITE: + priority = EventManager.PRIORITY_HIGH + else: + priority = EventManager.PRIORITY_NORMAL + elif event_type.startswith("object:state-changed:invalid-entry"): + # Setting this to lower ensures we present the state and/or text changes that triggered + # the invalid state prior to presenting the invalid state. + priority = EventManager.PRIORITY_LOWER + elif event_type.startswith("object:children-changed"): + priority = EventManager.PRIORITY_LOW else: - nameLower = name.lower() + priority = EventManager.PRIORITY_NORMAL - if "steamwebhelper" in nameLower or nameLower in ("steam", "steam web helper"): - return True + tokens = ["EVENT MANAGER:", event, f"has priority level: {priority}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return priority - cmdline = self._getAppCmdline(app) - return "steamwebhelper" in cmdline + def _is_obsoleted_by(self, event: Atspi.Event) -> Atspi.Event | None: + """Returns the event which renders this one no longer worthy of being processed.""" - def _isSteamNotificationEvent(self, event: Atspi.Event) -> bool: - return self._isLiveOrNotificationEvent(event) + def is_same(x): + return ( + x.type == event.type + and x.source == event.source + and x.detail1 == event.detail1 + and x.detail2 == event.detail2 + and x.any_data == event.any_data + ) - def _isLiveOrNotificationObject(self, obj: Any) -> bool: - if obj is None or isinstance(obj, (str, bytes, int, float, bool)): - return False + def obsoletes_if_same_type_and_object(x): + skippable = { + "document:page-changed", + "object:active-descendant-changed", + "object:children-changed", + "object:property-change", + "object:state-changed", + "object:selection-changed", + "object:text-caret-moved", + "object:text-selection-changed", + "window", + } + if not any(x.type.startswith(etype) for etype in skippable): + return False + return x.source == event.source and x.type == event.type - try: - if AXUtilities.is_notification(obj) or AXUtilities.is_alert(obj): - return True - except Exception: - pass + def obsoletes_if_same_type_in_sibling(x): + if ( + x.type != event.type + or x.detail1 != event.detail1 + or x.detail2 != event.detail2 + or x.any_data != event.any_data + ): + return False - try: - liveAttr = AXObject.get_attribute(obj, 'live') - containerLive = AXObject.get_attribute(obj, 'container-live') - except Exception: - return isinstance(obj, Atspi.Accessible) + skippable = { + "object:state-changed:focused", + } + if not any(x.type.startswith(etype) for etype in skippable): + return False + return AXObject.get_parent(x.source) == AXObject.get_parent(event.source) - if liveAttr in ('assertive', 'polite') or containerLive in ('assertive', 'polite'): - return True + def obsoletes_window_event(x): + skippable = { + "window:activate", + "window:deactivate", + } + if not any(x.type.startswith(etype) for etype in skippable): + return False + if not any(event.type.startswith(etype) for etype in skippable): + return False + return x.source == event.source - return False + with self._event_queue.mutex: + try: + events = list(reversed(self._event_queue.queue)) + except queue.Empty as error: + msg = f"EVENT MANAGER: Exception in _isObsoletedBy: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + events = [] - def _isLiveOrNotificationEvent(self, event: Atspi.Event) -> bool: - for obj in (event.any_data, event.source): - if self._isLiveOrNotificationObject(obj): - return True - - return False - - def _isPhaseOneWebRole(self, role: Atspi.Role) -> bool: - return role in ( - Atspi.Role.LINK, - Atspi.Role.PUSH_BUTTON, - Atspi.Role.CHECK_BOX, - Atspi.Role.RADIO_BUTTON, - Atspi.Role.LIST_ITEM, - Atspi.Role.LIST, - Atspi.Role.SECTION, - ) - - def _eventTouchesCurrentFocusContext(self, event: Atspi.Event) -> bool: - locusOfFocus = cthulhu_state.locusOfFocus - if locusOfFocus is None: - return False - - if event.source in (locusOfFocus, getattr(event, "any_data", None)): - return True - - focused = getattr(cthulhu_state, "pendingSelfHostedFocus", None) - if focused is not None and event.source in (focused, getattr(event, "any_data", None)): - return True - - return False - - def _shouldPreserveForRelevanceGate(self, event: Atspi.Event) -> bool: - if event.type.startswith("window"): - return True - - if event.type.startswith("mouse:button"): - return True - - if event.type.startswith("object:state-changed:focused") and event.detail1: - return True - - if self._eventTouchesCurrentFocusContext(event): - return True - - return self._isLiveOrNotificationEvent(event) - - def _eventRoleForRelevance(self, event: Atspi.Event) -> Optional[Atspi.Role]: - try: - return AXObject.get_role(event.source) - except Exception: - return None - - def _appNameForRelevance(self, app: Atspi.Accessible) -> str: - try: - appName = AXObject.get_name(app) - except Exception: - return "" - - return (appName or "").strip().lower() - - def _relevanceEventGroup(self, event: Atspi.Event, role: Atspi.Role) -> Optional[str]: - if event.type.startswith("object:children-changed:") and self._isPhaseOneWebRole(role): - return "children-changed" - - if event.type.startswith("object:property-change:accessible-name") and self._isPhaseOneWebRole(role): - return "accessible-name" + for _priority, _counter, e in events: + if e == event: + return None + if is_same(e): + tokens = ["EVENT MANAGER:", event, "obsoleted by", e, "more recent duplicate"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return e + if obsoletes_if_same_type_and_object(e): + tokens = [ + "EVENT MANAGER:", + event, + "obsoleted by", + e, + "more recent event of same type for same object", + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return e + if obsoletes_if_same_type_in_sibling(e): + tokens = [ + "EVENT MANAGER:", + event, + "obsoleted by", + e, + "more recent event of same type from sibling", + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return e + if obsoletes_window_event(e): + tokens = [ + "EVENT MANAGER:", + event, + "obsoleted by", + e, + "more recent window (de)activation event", + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return e return None - def _classifyRelevance(self, event: Atspi.Event, app: Atspi.Accessible) -> str: - if self._shouldPreserveForRelevanceGate(event): - return self.RELEVANCE_KEEP + def _ignore_by_role(self, event: Atspi.Event) -> bool | None: + """Returns True/False if the source role determines ignore, or None if inconclusive.""" - if event.type.startswith("object:state-changed:focused"): - if event.detail1 or cthulhu_state.locusOfFocus is None: - return self.RELEVANCE_KEEP - if self._eventTouchesCurrentFocusContext(event): - return self.RELEVANCE_KEEP - return self.RELEVANCE_DROP + event_type = event.type - role = self._eventRoleForRelevance(event) - eventGroup = self._relevanceEventGroup(event, role) - if eventGroup is None: - return self.RELEVANCE_KEEP + # gnome-shell fires "focused" events spuriously after the Alt+Tab switcher + # is used and something else has claimed focus. + if AXUtilities.is_window(event.source) and "focused" in event_type: + msg = f"EVENT MANAGER: Ignoring {event_type} based on type and role" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - if event.type.startswith("object:property-change:accessible-name") \ - and self._isPhaseOneWebRole(role): - try: - focused = AXUtilities.is_focused(event.source) - except Exception: - focused = False - if not focused: - return self.RELEVANCE_DROP + if AXUtilities.is_frame(event.source): + app = AXUtilities.get_application(event.source) + ignore = AXObject.get_name(app) == "mutter-x11-frames" + prefix = "Ignoring" if ignore else "Not ignoring" + reason = "application" if ignore else "role" + msg = f"EVENT MANAGER: {prefix} {event_type} based on {reason}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return ignore - if self._isBurstRelevanceEvent(event, app, eventGroup): - return self.RELEVANCE_COLLAPSE + # Events from the text role are typically something we want to handle. + # One exception is a huge text insertion. + if AXUtilities.is_text(event.source): + if event_type.startswith("object:text-changed:insert") and event.detail2 > 5000: + msg = f"EVENT_MANAGER: Ignoring {event_type} due to size of inserted text" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + if not event_type.startswith("object:text-caret-moved"): + msg = f"EVENT_MANAGER: Not ignoring {event_type} due to role" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False - return self.RELEVANCE_KEEP + if AXUtilities.is_notification(event.source) or AXUtilities.is_alert(event.source): + msg = f"EVENT_MANAGER: Not ignoring {event_type} due to role" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False - def _isBurstRelevanceEvent( + return None + + def _ignore_by_focus_state( self, event: Atspi.Event, - app: Atspi.Accessible, - eventGroup: Optional[str] = None, - ) -> bool: - if self._shouldPreserveForRelevanceGate(event): + focus: Atspi.Accessible | None, + ) -> bool | None: + """Returns False if focus/state means we should not ignore, or None if inconclusive.""" + + event_type = event.type + if focus in (event.source, event.any_data): + reason = "source" if focus == event.source else "any_data" + msg = f"EVENT_MANAGER: Not ignoring {event_type} due to {reason} being locus of focus" + debug.print_message(debug.LEVEL_INFO, msg, True) return False - if eventGroup is None: - role = self._eventRoleForRelevance(event) - eventGroup = self._relevanceEventGroup(event, role) - - if eventGroup is None: + if AXUtilities.is_selected(event.source): + msg = f"EVENT_MANAGER: Not ignoring {event_type} due to source being selected" + debug.print_message(debug.LEVEL_INFO, msg, True) return False - appName = self._appNameForRelevance(app) or "unknown-app" - try: - contextKey = self._context_token_for_event(event) - except Exception: - contextKey = None - contextKey = contextKey or f"source:{id(event.source)}" - burstKey = (appName, eventGroup, contextKey) - now = time.monotonic() - lastSeen = self._relevanceBurstHistory.get(burstKey) - self._relevanceBurstHistory[burstKey] = now - return lastSeen is not None and (now - lastSeen) < self._relevanceBurstWindow + # We see an unbelievable number of active-descendant-changed and selection changed + # from Caja when the user navigates from one giant folder to another. We need the + # spam filtering below to catch this bad behavior coming from a focused object, so + # only return early here if the focused object doesn't manage descendants, or the + # event is not a focus claim. + if AXUtilities.is_focused(event.source): + if not AXUtilities.manages_descendants(event.source) or ( + event_type.startswith("object:state-changed:focused") and event.detail1 + ): + msg = f"EVENT_MANAGER: Not ignoring {event_type} due to source being focused" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False - def _isSteamBurstChurnEvent(self, event: Atspi.Event, app: Atspi.Accessible) -> bool: - if not self._isSteamApp(app) or self._isLiveOrNotificationEvent(event): + if event_type.startswith("object:text-changed:insert") and AXUtilities.is_section( + event.source, + ): + live = AXObject.get_attribute(event.source, "live") + if live and live != "off": + msg = f"EVENT_MANAGER: Not ignoring {event_type} due to source being live region" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + return None + + def _ignore_by_spam_filter(self, event: Atspi.Event) -> bool | None: + """Returns True if the event is spam, or None if inconclusive.""" + + event_type = event.type + last_app, last_time = self._event_history.get(event_type, (None, 0)) + app = AXUtilities.get_application(event.source) + ignore = last_app == hash(app) and time.time() - last_time < 0.1 + self._event_history[event_type] = hash(app), time.time() + if ignore: + msg = f"EVENT_MANAGER: Ignoring {event_type} due to multiple instances in short time" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + if AXObject.get_name(app) == "mutter-x11-frames": + msg = f"EVENT MANAGER: Ignoring {event_type} based on application" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + return None + + @staticmethod + def _ignore_children_changed( + event: Atspi.Event, + focus: Atspi.Accessible | None, + ) -> bool | None: + """Returns True/False for children-changed events, or None if not applicable.""" + + event_type = event.type + if not event_type.startswith("object:children-changed"): + return None + + if "remove" in event_type: + if (focus and AXObject.is_dead(focus)) or event.source == AXUtilities.get_desktop(): + return False + + child = event.any_data + if child is None or AXObject.is_dead(child): + msg = f"EVENT_MANAGER: Ignoring {event_type} due to null/dead event.any_data" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + if AXUtilities.is_menu_related(child) or AXUtilities.is_image(child): + msg = f"EVENT_MANAGER: Ignoring {event_type} due to role of event.any_data" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + script = script_manager.get_manager().get_active_script() + if script is None or script.app != AXUtilities.get_application(event.source): + reason = ( + "there is no active script" if script is None else "event is not from active app" + ) + msg = f"EVENT MANAGER: Ignoring {event_type} because {reason}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + return None + + @staticmethod + def _ignore_property_change(event: Atspi.Event) -> bool | None: + """Returns True/False for property-change events, or None if not applicable.""" + + event_type = event.type + if not event_type.startswith("object:property-change"): + return None + + role = AXObject.get_role(event.source) + if "name" in event_type: + ignore_name_roles = [ + Atspi.Role.CANVAS, + Atspi.Role.CHECK_BOX, + Atspi.Role.ICON, + Atspi.Role.IMAGE, + Atspi.Role.LIST, + Atspi.Role.LIST_ITEM, + Atspi.Role.MENU, + Atspi.Role.MENU_ITEM, + Atspi.Role.PANEL, + Atspi.Role.RADIO_BUTTON, + Atspi.Role.SECTION, + Atspi.Role.TABLE_ROW, + Atspi.Role.TABLE_CELL, + Atspi.Role.TREE_ITEM, + ] + if role in ignore_name_roles: + msg = f"EVENT MANAGER: Ignoring {event_type} due to role of unfocused source" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + return False + if "value" in event_type: + if role in [Atspi.Role.SPLIT_PANE, Atspi.Role.SCROLL_BAR]: + msg = f"EVENT MANAGER: Ignoring {event_type} due to role of unfocused source" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True return False - if event.type.startswith("object:state-changed:focused"): - return not event.detail1 + return None - if event.type.startswith("object:children-changed:"): - return self._isBurstRelevanceEvent(event, app, "children-changed") + @staticmethod + def _ignore_state_changed(event: Atspi.Event) -> bool | None: + """Returns True/False for state-changed events, or None if not applicable.""" - return False + event_type = event.type + if not event_type.startswith("object:state-changed"): + return None + + role = AXObject.get_role(event.source) + if event_type.endswith("system"): + system_ignore_roles = [ + Atspi.Role.TABLE, + Atspi.Role.TABLE_CELL, + Atspi.Role.TABLE_ROW, + Atspi.Role.TREE, + Atspi.Role.TREE_ITEM, + Atspi.Role.TREE_TABLE, + ] + if role in system_ignore_roles: + msg = f"EVENT MANAGER: Ignoring {event_type} based on role" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + return EventManager._ignore_state_changed_subtype(event, event_type, role) + + @staticmethod + def _ignore_state_changed_subtype( + event: Atspi.Event, + event_type: str, + role: int, + ) -> bool | None: + """Returns True/False for state-changed subtypes, or None if not applicable.""" + + showing_roles = [ + Atspi.Role.ALERT, + Atspi.Role.ANIMATION, + Atspi.Role.DIALOG, + Atspi.Role.INFO_BAR, + Atspi.Role.MENU, + Atspi.Role.NOTIFICATION, + Atspi.Role.STATUS_BAR, + Atspi.Role.TOOL_TIP, + ] + + ignore: bool | None = None + reason = "" + if "checked" in event_type: + ignore = not AXUtilities.is_showing(event.source) + reason = "unfocused, non-showing source" + elif "selected" in event_type: + ignore = not event.detail1 and role == Atspi.Role.BUTTON + reason = "role of source and detail1" + elif "sensitive" in event_type: + ignore = role not in [Atspi.Role.TEXT, Atspi.Role.ENTRY] + reason = "role of unfocused source" + elif "showing" in event_type: + ignore = role not in showing_roles + reason = "role" + + if ignore is None: + return None + + if ignore: + msg = f"EVENT MANAGER: Ignoring {event_type} due to {reason}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return ignore + + @staticmethod + def _ignore_active_descendant_or_selection(event: Atspi.Event) -> bool | None: + """Returns True/False for active-descendant and selection events.""" + + event_type = event.type + if event_type.startswith("object:active-descendant-changed"): + child = event.any_data + if child is None or AXUtilities.is_invalid_role(child): + msg = f"EVENT_MANAGER: Ignoring {event_type} due to null/invalid event.any_data" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + return False + + if event_type.startswith("object:selection-changed"): + if AXObject.is_dead(event.source): + msg = f"EVENT MANAGER: Ignoring {event_type} from dead source" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + return False + + return None + + @staticmethod + def _ignore_text_events( + event: Atspi.Event, + focus: Atspi.Accessible | None, + ) -> bool | None: + """Returns True/False for text caret-moved and text-changed events.""" + + event_type = event.type + if event_type.startswith("object:text-caret-moved"): + if AXObject.get_role(event.source) == Atspi.Role.LABEL: + msg = f"EVENT MANAGER: Ignoring {event_type} due to role of unfocused source" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + return False + + if event_type.startswith("object:text-changed"): + if "insert" in event_type and event.detail2 > 1000: + msg = f"EVENT MANAGER: Ignoring {event_type} due to inserted text size" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + if event_type.endswith("system") and AXUtilities.is_selectable(focus): + msg = f"EVENT MANAGER: Ignoring because {event_type} is suspected spam" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + return False + + return None def _ignore(self, event: Atspi.Event) -> bool: """Returns True if this event should be ignored.""" - app = AXObject.get_application(event.source) - debug.printMessage(debug.LEVEL_INFO, '') - tokens = ["EVENT MANAGER:", event.type, "from", app] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_message(debug.LEVEL_INFO, "") + tokens = ["EVENT MANAGER:", event] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) - def _log_ignore(reason: str, message: str) -> None: - debug.print_log(debug.LEVEL_INFO, "EVENT MANAGER", - f"Ignoring - {message}", reason, True) - - def _log_allow(reason: str, message: str) -> None: - debug.print_log(debug.LEVEL_INFO, "EVENT MANAGER", - f"Not ignoring - {message}", reason, True) - - def _ignore_with_reason(reason: str, message: str) -> bool: - _log_ignore(reason, message) - return True - - def _allow_with_reason(reason: str, message: str) -> bool: - _log_allow(reason, message) + if event.type.startswith(("window", "mouse:button")): return False - if self._eventsSuspended: - tokens = ["EVENT MANAGER: Suspended events:", ', '.join(self._suspendableEvents)] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + if not self._active or self._paused: + msg = "EVENT MANAGER: Ignoring because manager is not active or queueing is paused" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - if not self._active: - return _ignore_with_reason("inactive", "event manager is not active") - - if list(filter(event.type.startswith, self._ignoredEvents)): - return _ignore_with_reason("type-ignored", "event type is ignored") - - if AXObject.get_name(app) == 'gnome-shell': - if event.type.startswith('object:children-changed:remove'): - return _ignore_with_reason("gnome-shell", "children-changed:remove") - - if event.type.startswith('window'): - return _allow_with_reason("window-event", "event type is never ignored") - - 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") - - if self._isSelfHostedFocusClearedEvent(event): - return _ignore_with_reason("self-hosted-focus-cleared", "self-hosted focused=false event") - - if self._isRedundantSelfHostedPropertyEvent(event): - return _ignore_with_reason("self-hosted-redundant-property", "self-hosted redundant property event") - - # Thunderbird spams us with these when a message list thread is expanded or collapsed. - if event.type.endswith('system') \ - and AXObject.get_name(app).lower().startswith('thunderbird'): - if AXUtilities.is_table_related(event.source) \ - or AXUtilities.is_tree_related(event.source) \ - or AXUtilities.is_section(event.source): - return _ignore_with_reason("thunderbird-system", "system event based on role") - - if self._inDeluge() and self._ignoreDuringDeluge(event): - return _ignore_with_reason("deluge", "event type during deluge") - - script = cthulhu_state.activeScript - if event.type.startswith('object:children-changed') \ - or event.type.startswith('object:state-changed:sensitive'): - if not script: - return _ignore_with_reason("no-active-script", "no active script") - if script.app != app: - # Allow Steam notifications from inactive apps. - if self._isSteamApp(app) and self._isSteamNotificationEvent(event): - _log_allow("steam-notification", "inactive app notification") - else: - return _ignore_with_reason("inactive-app", "event not from active app") - - relevance = self._classifyRelevance(event, app) - if relevance == self.RELEVANCE_DROP: - return _ignore_with_reason("event-relevance-drop", "event is background churn") - if relevance == self.RELEVANCE_COLLAPSE: - return _ignore_with_reason("event-relevance-collapse", "event was collapsed by relevance gate") - - if self._isSteamBurstChurnEvent(event, app): - return _ignore_with_reason("steam-burst-churn", "event is low-value Steam churn") - - if event.type.startswith('object:text-changed') \ - and self.EMBEDDED_OBJECT_CHARACTER in event.any_data \ - and not event.any_data.replace(self.EMBEDDED_OBJECT_CHARACTER, ""): - # We should also get children-changed events telling us the same thing. - # Getting a bunch of both can result in a flood that grinds us to a halt. - return _ignore_with_reason("embedded-only", "changed text only embedded objects") - - # TODO - JD: For now we won't ask for the name. Simply asking for the name should - # not break anything, and should be a reliable way to quickly identify defunct - # objects. But apparently the mere act of asking for the name causes Cthulhu to stop - # presenting Eclipse (and possibly other) applications. This might be an AT-SPI2 - # issue, but until we know for certain.... - #name = Atspi.Accessible.get_name(event.source) - - if AXUtilities.has_no_state(event.source): - return _ignore_with_reason("empty-state", "empty state set") - - if AXUtilities.is_defunct(event.source): - return _ignore_with_reason("defunct-source", "defunct source") - - role = AXObject.get_role(event.source) - if event.type.startswith('object:property-change:accessible-name'): - if role in [Atspi.Role.CANVAS, - Atspi.Role.ICON, - Atspi.Role.LIST_ITEM, # Web app spam - Atspi.Role.LIST, # Web app spam - Atspi.Role.PANEL, # TeamTalk5 spam - Atspi.Role.SECTION, # Web app spam - Atspi.Role.TABLE_ROW, # Thunderbird spam - Atspi.Role.TABLE_CELL, # Thunderbird spam - Atspi.Role.TREE_ITEM, # Thunderbird spam - Atspi.Role.IMAGE, # Thunderbird spam - Atspi.Role.MENU, - Atspi.Role.MENU_ITEM]: - return _ignore_with_reason("name-change-role", "role filtered") - # TeamTalk5 is notoriously spammy here, and name change events on widgets are - # typically only presented if they are focused. - if not AXUtilities.is_focused(event.source) \ - and role in [Atspi.Role.PUSH_BUTTON, - Atspi.Role.CHECK_BOX, - Atspi.Role.RADIO_BUTTON]: - return _ignore_with_reason("name-change-unfocused", "role and state") - elif event.type.startswith('object:property-change:accessible-value'): - if role == Atspi.Role.SPLIT_PANE and not AXUtilities.is_focused(event.source): - return _ignore_with_reason("value-change-unfocused", "role and state") - elif event.type.startswith('object:text-changed:insert') and event.detail2 > 1000 \ - and role in [Atspi.Role.TEXT, Atspi.Role.STATIC]: - return _ignore_with_reason("text-insert-large", "inserted text > 1000 chars") - elif event.type.startswith('object:state-changed:sensitive'): - if role in [Atspi.Role.MENU_ITEM, - Atspi.Role.MENU, - Atspi.Role.FILLER, - Atspi.Role.PANEL, - Atspi.Role.CHECK_MENU_ITEM, - Atspi.Role.RADIO_MENU_ITEM]: - return _ignore_with_reason("sensitive-role", "role filtered") - elif event.type.startswith('object:state-changed:selected'): - if not event.detail1 and role in [Atspi.Role.PUSH_BUTTON]: - return _ignore_with_reason("selected-button-false", "role and detail1") - elif event.type.startswith('object:state-changed:showing'): - if role not in [Atspi.Role.ALERT, - Atspi.Role.ANIMATION, - Atspi.Role.INFO_BAR, - Atspi.Role.MENU, - Atspi.Role.NOTIFICATION, - Atspi.Role.DIALOG, - Atspi.Role.STATUS_BAR, - Atspi.Role.TOOL_TIP]: - return _ignore_with_reason("showing-role", "role filtered") - - elif event.type.startswith('object:text-caret-moved'): - if role in [Atspi.Role.LABEL] and not AXUtilities.is_focused(event.source): - return _ignore_with_reason("caret-unfocused-label", "role and state") - - elif event.type.startswith('object:selection-changed'): - if event.source in self._parentsOfDefunctDescendants: - return _ignore_with_reason("defunct-descendant-parent", "parent of defunct descendants") - - if AXObject.is_dead(event.source): - return _ignore_with_reason("dead-source", "dead source") - - if event.type.startswith('object:children-changed') \ - or event.type.startswith('object:active-descendant-changed'): - if role in [Atspi.Role.MENU, - Atspi.Role.LAYERED_PANE, - Atspi.Role.MENU_ITEM]: - return _ignore_with_reason("children-role", "role filtered") - if event.any_data is None: - return _ignore_with_reason("missing-any-data", "no event.any_data") - if event.type.endswith('remove'): - if event.any_data == cthulhu_state.locusOfFocus: - msg = 'EVENT MANAGER: Locus of focus is being destroyed' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return False - - if AXObject.is_dead(cthulhu_state.locusOfFocus): - msg = 'EVENT MANAGER: Locus of focus is dead.' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return False - - tokens = ["EVENT MANAGER: Locus of focus:", cthulhu_state.locusOfFocus] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - - elif event.any_data == cthulhu_state.locusOfFocus: - msg = 'EVENT MANAGER: Locus of focus is being added.' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return False - - elif event.any_data == cthulhu_state.pendingSelfHostedFocus: - msg = 'EVENT MANAGER: Pending self-hosted focus is being added.' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return False - - elif self._eventsSuspended: - if event.type.endswith('add') and not self._eventTypeIsSuspended(event.type): - # Cthulhu is known to miss text insertion in Gtk3 when text insertions - # are suspended. So while we do need to suspend some of the resulting - # events, we also need to make sure the text insertion isn't suspended. - if AXUtilities.is_text(event.any_data): - return _ignore_with_reason("suspended-text", "child text is not suspended") - elif not self._eventTypeIsSuspended(event.type): - return _ignore_with_reason("suspended-event-type", "event type is not suspended") + focus = focus_manager.get_manager().get_locus_of_focus() + for check in ( + lambda: self._ignore_by_role(event), + lambda: self._ignore_by_focus_state(event, focus), + lambda: self._ignore_by_spam_filter(event), + lambda: self._ignore_active_descendant_or_selection(event), + lambda: self._ignore_children_changed(event, focus), + lambda: self._ignore_property_change(event), + lambda: self._ignore_state_changed(event), + lambda: self._ignore_text_events(event, focus), + ): + result = check() + if result is not None: + return result return False - def _addToQueue(self, event: Any, asyncMode: bool) -> None: - debugging = debug.debugEventQueue - if debugging: - debug.printMessage(debug.LEVEL_ALL, " acquiring lock...") - self._gidleLock.acquire() - - if debugging: - debug.printMessage(debug.LEVEL_ALL, " ...acquired") - debug.printMessage(debug.LEVEL_ALL, " calling queue.put...") - debug.printMessage(debug.LEVEL_ALL, " (full=%s)" \ - % self._eventQueue.full()) - - self._eventQueue.put(event) - if debugging: - debug.printMessage(debug.LEVEL_ALL, " ...put complete") - - if asyncMode and not self._gidleId: - if self._gilSleepTime: - time.sleep(self._gilSleepTime) - self._gidleId = GLib.idle_add(self._dequeue) - - if debugging: - debug.printMessage(debug.LEVEL_ALL, " releasing lock...") - self._gidleLock.release() - if debug.debugEventQueue: - debug.printMessage(debug.LEVEL_ALL, " ...released") - - def _queuePrintln(self, e: Any, isEnqueue: bool = True, isPrune: Optional[bool] = None) -> None: + def _queue_println( + self, + event: input_event.InputEvent | Atspi.Event, + is_enqueue: bool = True, + ) -> None: """Convenience method to output queue-related debugging info.""" - if debug.LEVEL_INFO < debug.debugLevel: + if debug.debugLevel > debug.LEVEL_INFO: return tokens = [] - if isinstance(e, input_event.KeyboardEvent): - tokens.extend([e.event_string, e.hw_code]) - elif isinstance(e, input_event.BrailleEvent): - tokens.append(e.event) - elif not debug.eventDebugFilter or debug.eventDebugFilter.match(e.type): - tokens.append(e) + if isinstance(event, input_event.KeyboardEvent): + tokens.extend([event.keyval_name, event.hw_code]) + elif isinstance(event, input_event.BrailleEvent): + tokens.append(event.event) else: - return + tokens.append(event) - if isPrune: - tokens[0:0] = ["EVENT MANAGER: Pruning"] - elif isPrune is not None: - tokens[0:0] = ["EVENT MANAGER: Not pruning"] - elif isEnqueue: + if is_enqueue: tokens[0:0] = ["EVENT MANAGER: Queueing"] else: - tokens[0:0] = ["EVENT MANAGER: Dequeued"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + tokens[0:0] = ["EVENT MANAGER: Dequeueing"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) - def _suspendEvents(self, triggeringEvent: Atspi.Event) -> None: - self._eventsTriggeringSuspension.append(triggeringEvent) + def _enqueue_object_event(self, e: Atspi.Event) -> None: + """Callback for Atspi object events.""" - if self._eventsSuspended: - msg = "EVENT MANAGER: Events already suspended." - debug.printMessage(debug.LEVEL_INFO, msg, True) + # If we are enqueuing events, we're not dead and should not be killed + # and restarted by systemd. + if self._event_queue.qsize() > 75 and systemd.get_manager().is_systemd_managed(): + systemd.get_manager().notify_alive("Event queue size > 75") + + if self._ignore(e): return - msg = "EVENT MANAGER: Suspending events." - debug.printMessage(debug.LEVEL_INFO, msg, True) + self._queue_println(e) + app = AXUtilities.get_application(e.source) + tokens = ["EVENT MANAGER: App for event source is", app] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) - for event in self._suspendableEvents: - self.deregisterListener(event) + script = script_manager.get_manager().get_script(app, e.source) + script.event_cache[e.type] = (e, time.time()) - self._eventsSuspended = True + with self._gidle_lock: + priority = self._get_priority(e) + counter = next(self._counter) + self._event_queue.put((priority, counter, e)) + tokens = ["EVENT MANAGER: Queued", e, f"priority: {priority}, counter: {counter}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if not self._gidle_id: + self._gidle_id = GLib.idle_add(self._dequeue_object_event) - def _unsuspendEvents(self, triggeringEvent: Atspi.Event, force: bool = False) -> None: - if triggeringEvent in self._eventsTriggeringSuspension: - self._eventsTriggeringSuspension.remove(triggeringEvent) + def _on_no_focus(self) -> bool: + if focus_manager.get_manager().focus_and_window_are_unknown(): + return False - if not self._eventsSuspended: - msg = "EVENT MANAGER: Events already unsuspended." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - if self._eventsTriggeringSuspension and not force: - msg = "EVENT MANAGER: Events are suspended for another event." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - msg = "EVENT MANAGER: Unsuspending events." - debug.printMessage(debug.LEVEL_INFO, msg, True) - - for event in self._suspendableEvents: - self.registerListener(event) - - self._eventsSuspended = False - - def _shouldSuspendEventsFor(self, event: Atspi.Event) -> bool: - if AXUtilities.is_frame(event.source) \ - or (AXUtilities.is_window(event.source) \ - and AXUtilities.get_application_toolkit_name(event.source) == "clutter"): - if event.type.startswith("window"): - msg = "EVENT MANAGER: Should suspend events for window event." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - if event.type.endswith("active"): - msg = "EVENT MANAGER: Should suspend events for active event on window." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - if AXUtilities.is_document(event.source): - if event.type.endswith("busy") and event.detail1: - msg = "EVENT MANAGER: Should suspend events for busy:true event on document." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + if script_manager.get_manager().get_active_script() is None: + default_script = script_manager.get_manager().get_default_script() + script_manager.get_manager().set_active_script(default_script, "No focus") + braille_presenter.get_presenter().disable_braille() return False - def _shouldUnsuspendEventsFor(self, event: Atspi.Event) -> bool: - if event.type.startswith("object:state-changed:focused") and event.detail1: - msg = "EVENT MANAGER: Should unsuspend events for newly-focused object." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - - if AXUtilities.is_document(event.source): - if event.type.endswith("busy") and not event.detail1: - msg = "EVENT MANAGER: Should unsuspend events for busy:false event on document." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - if event.type.startswith("document:load-complete"): - msg = "EVENT MANAGER: Should unsuspend events for load-complete event on document." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - - return False - - def _didSuspendEventsFor(self, event: Atspi.Event) -> bool: - return event in self._eventsTriggeringSuspension - - def _enqueue(self, e: Atspi.Event) -> None: - """Handles the enqueueing of all events destined for scripts. - - Arguments: - - e: an at-spi event. - """ - - if debug.debugEventQueue: - if self._enqueueCount: - msg = f"EVENT MANAGER: _enqueue entered before exiting (count={self._enqueueCount})" - debug.printMessage(debug.LEVEL_ALL, msg, True) - self._enqueueCount += 1 - - inputEvents = (input_event.KeyboardEvent, input_event.BrailleEvent) - isObjectEvent = not isinstance(e, inputEvents) - - try: - ignore = isObjectEvent and self._ignore(e) - except Exception as error: - tokens = ["EVENT MANAGER: Exception evaluating event:", e, ":", error] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - ignore = True - if ignore: - if debug.debugEventQueue: - self._enqueueCount -= 1 - return - - if isObjectEvent and self._prioritizeSelfHostedFocusedEvent(e): - if debug.debugEventQueue: - self._enqueueCount -= 1 - return - - self._queuePrintln(e) - - if self._inFlood() and self._prioritizeDuringFlood(e): - msg = 'EVENT MANAGER: Pruning event queue due to flood.' - debug.printMessage(debug.LEVEL_INFO, msg, True) - self._pruneEventsDuringFlood() - - if isObjectEvent and self._shouldSuspendEventsFor(e): - self._suspendEvents(e) - - asyncMode = self._asyncMode - if isObjectEvent: - if isinstance(e, input_event.MouseButtonEvent): - asyncMode = True - elif AXUtilities.get_application_toolkit_name(e.source) in self._synchronousToolkits: - asyncMode = False - elif e.type.startswith("object:children-changed"): - asyncMode = AXUtilities.is_table(e.source) - elif AXUtilities.is_notification(e.source): - # To decrease the likelihood that the popup will be destroyed before we - # have its contents. - asyncMode = False - script = cthulhu.cthulhuApp.scriptManager.get_script(AXObject.get_application(e.source), e.source) - script.eventCache[e.type] = (e, time.time()) - - self._addToQueue(e, asyncMode) - if not asyncMode: - self._dequeue() - - if debug.debugEventQueue: - self._enqueueCount -= 1 - - def _isSelfHostedFocusedEvent(self, event: Atspi.Event) -> bool: - """Returns True if the event is a newly-focused event from Cthulhu.""" - - if not event.type.startswith("object:state-changed:focused") or not event.detail1: - return False - - app = AXObject.get_application(event.source) - appName = (AXObject.get_name(app) or "").lower() - return appName == "cthulhu" - - def _isSelfHostedFocusClearedEvent(self, event: Atspi.Event) -> bool: - """Returns True if the event is a focus-lost event from Cthulhu.""" - - if not event.type.startswith("object:state-changed:focused") or event.detail1: - return False - - app = AXObject.get_application(event.source) - appName = (AXObject.get_name(app) or "").lower() - return appName == "cthulhu" - - def _isRedundantSelfHostedPropertyEvent(self, event: Atspi.Event) -> bool: - """Returns True if the event is redundant prefs startup noise from Cthulhu.""" - - app = AXObject.get_application(event.source) - appName = (AXObject.get_name(app) or "").lower() - if appName != "cthulhu": - return False - - if event.type.startswith("object:property-change:accessible-name"): - return AXUtilities.is_combo_box(event.source) or AXUtilities.is_table_cell(event.source) - - if event.type.startswith("object:property-change:accessible-value"): - return AXObject.get_role(event.source) == Atspi.Role.SLIDER \ - and event.source != cthulhu_state.locusOfFocus - - return False - - def _prioritizeSelfHostedFocusedEvent(self, event: Atspi.Event) -> bool: - """Schedules the latest focused-child event from Cthulhu ahead of the normal queue.""" - - if not self._isSelfHostedFocusedEvent(event): - return False - - tokens = ["EVENT MANAGER: Prioritizing self-hosted focused event for", event.source] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - - script = self._get_scriptForEvent(event) - if script is not None: - script.eventCache[event.type] = (event, time.time()) - - self._gidleLock.acquire() - try: - replaced = self._prioritizedEvent is not None - self._prioritizedEvent = event - cthulhu_state.pendingSelfHostedFocus = event.source - if not self._prioritizedIdleId: - self._prioritizedIdleId = GLib.idle_add( - self._dequeuePrioritizedEvent, - priority=GLib.PRIORITY_HIGH_IDLE, - ) - finally: - self._gidleLock.release() - - msg = f"EVENT MANAGER: Prioritized self-hosted focused event. Replaced pending event: {replaced}" - debug.printMessage(debug.LEVEL_INFO, msg, True) - - return True - def _isNoFocus(self) -> bool: - if cthulhu_state.locusOfFocus or cthulhu_state.activeWindow or cthulhu_state.activeScript: - return False - - msg = 'EVENT MANAGER: No focus' - debug.printMessage(debug.LEVEL_SEVERE, msg, True) - return True - - def _onNoFocus(self) -> bool: - if not self._isNoFocus(): - return False - - defaultScript = cthulhu.cthulhuApp.scriptManager.get_default_script() - cthulhu.cthulhuApp.scriptManager.set_active_script(defaultScript, 'focus: none') - defaultScript.idleMessage() - return False - - def _dequeuePrioritizedEvent(self) -> bool: - """Handles prioritized focused events from Cthulhu-owned windows.""" - - self._gidleLock.acquire() - try: - event = self._prioritizedEvent - self._prioritizedEvent = None - self._prioritizedIdleId = 0 - if event is not None and cthulhu_state.pendingSelfHostedFocus == event.source: - cthulhu_state.pendingSelfHostedFocus = None - finally: - self._gidleLock.release() - - if event is None: - return False - - tokens = ["EVENT MANAGER: Dequeued prioritized event", event] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - - debugging = not debug.eventDebugFilter or debug.eventDebugFilter.match(event.type) - if debugging: - startTime = time.time() - msg = ( - f"\nvvvvv PROCESS OBJECT EVENT {event.type} " - f"(prioritized) vvvvv" - ) - debug.printMessage(debug.eventDebugLevel, msg, False) - - try: - self._processObjectEvent(event) - if self._didSuspendEventsFor(event): - self._unsuspendEvents(event) - elif self._eventsSuspended and self._shouldUnsuspendEventsFor(event): - self._unsuspendEvents(event, force=True) - except Exception: - debug.printException(debug.LEVEL_SEVERE) - - if debugging: - msg = ( - f"TOTAL PROCESSING TIME: {time.time() - startTime:.4f}" - f"\n^^^^^ PROCESS OBJECT EVENT {event.type} ^^^^^\n" - ) - debug.printMessage(debug.eventDebugLevel, msg, False) - - self._gidleLock.acquire() - try: - hasMore = self._prioritizedEvent is not None - if not hasMore and self._eventQueue.qsize() and not self._gidleId: - self._gidleId = GLib.idle_add(self._dequeue) - finally: - self._gidleLock.release() - - return hasMore - - def _dequeue(self) -> bool: - """Handles all events destined for scripts. Called by the GTK - idle thread.""" + def _dequeue_object_event(self) -> bool: + """Handles all object events destined for scripts.""" rerun = True - - if debug.debugEventQueue: - msg = f"EVENT MANAGER: Dequeue {self._dequeueCount}" - debug.printMessage(debug.LEVEL_ALL, msg, True) - self._dequeueCount += 1 - try: - fromPriority = False - self._gidleLock.acquire() - try: - event = self._eventQueue.get_nowait() - finally: - self._gidleLock.release() - - self._queuePrintln(event, isEnqueue=False) - inputEvents = (input_event.KeyboardEvent, input_event.BrailleEvent) - if isinstance(event, inputEvents): - self._processInputEvent(event) - else: - debug.objEvent = event - debugging = not debug.eventDebugFilter \ - or debug.eventDebugFilter.match(event.type) - if debugging: - startTime = time.time() - msg = ( - f"\nvvvvv PROCESS OBJECT EVENT {event.type} " - f"(queue size: {self._eventQueue.qsize()}) vvvvv" - ) - debug.printMessage(debug.eventDebugLevel, msg, False) - self._processObjectEvent(event) - if self._didSuspendEventsFor(event): - self._unsuspendEvents(event) - elif self._eventsSuspended and self._shouldUnsuspendEventsFor(event): - self._unsuspendEvents(event, force=True) - - if debugging: - msg = ( - f"TOTAL PROCESSING TIME: {time.time() - startTime:.4f}" - f"\n^^^^^ PROCESS OBJECT EVENT {event.type} ^^^^^\n" - ) - debug.printMessage(debug.eventDebugLevel, msg, False) - - debug.objEvent = None - - self._gidleLock.acquire() - if self._eventQueue.empty(): - GLib.timeout_add(2500, self._onNoFocus) - self._gidleId = 0 - rerun = False # destroy and don't call again - self._gidleLock.release() + priority, counter, event = self._event_queue.get_nowait() + self._queue_println(event, is_enqueue=False) + tokens = ["EVENT MANAGER: Dequeued", event, f"priority: {priority}, counter: {counter}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + start_time = time.time() + msg = ( + f"\nvvvvv START PRIORITY-{priority} OBJECT EVENT {event.type.upper()} " + f"(queue size: {self._event_queue.qsize()}) vvvvv" + ) + debug.print_message(debug.LEVEL_INFO, msg, False) + self._process_object_event(event) + msg = ( + f"TOTAL PROCESSING TIME: {time.time() - start_time:.4f}" + f"\n^^^^^ FINISHED PRIORITY-{priority} OBJECT EVENT {event.type.upper()} ^^^^^\n" + ) + debug.print_message(debug.LEVEL_INFO, msg, False) + with self._gidle_lock: + if self._event_queue.empty(): + GLib.timeout_add(2500, self._on_no_focus) + self._gidle_id = 0 + rerun = False # destroy and don't call again except queue.Empty: - msg = 'EVENT MANAGER: Attempted dequeue, but the event queue is empty' - debug.printMessage(debug.LEVEL_SEVERE, msg, True) - self._gidleId = 0 - rerun = False # destroy and don't call again - except Exception: - debug.printException(debug.LEVEL_SEVERE) - - if debug.debugEventQueue: - self._dequeueCount -= 1 - msg = f"EVENT MANAGER: Leaving _dequeue. Count: {self._dequeueCount}" - debug.printMessage(debug.LEVEL_ALL, msg, True) + msg = "EVENT MANAGER: Attempted dequeue, but the event queue is empty" + debug.print_message(debug.LEVEL_INFO, msg, True) + self._gidle_id = 0 + rerun = False # destroy and don't call again + except Exception: # pylint: disable=broad-except + self._gidle_id = GLib.idle_add(self._dequeue_object_event) + raise return rerun - def registerListener(self, eventType: str) -> None: + def register_listener(self, event_type: str) -> None: """Tells this module to listen for the given event type. Arguments: - - eventType: the event type. + - event_type: the event type. """ - msg = f'EVENT MANAGER: registering listener for: {eventType}' - debug.printMessage(debug.LEVEL_INFO, msg, True) + msg = f"EVENT MANAGER: registering listener for: {event_type}" + debug.print_message(debug.LEVEL_INFO, msg, True) - if eventType in self._scriptListenerCounts: - self._scriptListenerCounts[eventType] += 1 + if event_type in self._script_listener_counts: + self._script_listener_counts[event_type] += 1 else: - self._listener.register(eventType) - self._scriptListenerCounts[eventType] = 1 + self._listener.register(event_type) + self._script_listener_counts[event_type] = 1 - def deregisterListener(self, eventType: str) -> None: + def deregister_listener(self, event_type: str) -> None: """Tells this module to stop listening for the given event type. Arguments: - - eventType: the event type. + - event_type: the event type. """ - msg = f'EVENT MANAGER: deregistering listener for: {eventType}' - debug.printMessage(debug.LEVEL_INFO, msg, True) + msg = f"EVENT MANAGER: deregistering listener for: {event_type}" + debug.print_message(debug.LEVEL_INFO, msg, True) - if eventType not in self._scriptListenerCounts: + if event_type not in self._script_listener_counts: return - self._scriptListenerCounts[eventType] -= 1 - if self._scriptListenerCounts[eventType] == 0: - self._listener.deregister(eventType) - del self._scriptListenerCounts[eventType] + self._script_listener_counts[event_type] -= 1 + if self._script_listener_counts[event_type] == 0: + try: + self._listener.deregister(event_type) + except GLib.GError as error: + msg = f"EVENT MANAGER: Exception deregistering listener for {event_type}: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + del self._script_listener_counts[event_type] - def registerScriptListeners(self, script: Script) -> None: + def register_script_listeners(self, script: default.Script) -> None: """Tells the event manager to start listening for all the event types of interest to the script. @@ -1128,12 +732,12 @@ class EventManager: """ tokens = ["EVENT MANAGER: Registering listeners for:", script] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) - for eventType in script.listeners.keys(): - self.registerListener(eventType) + for event_type in script.listeners: + self.register_listener(event_type) - def deregisterScriptListeners(self, script: Script) -> None: + def deregister_script_listeners(self, script: default.Script) -> None: """Tells the event manager to stop listening for all the event types of interest to the script. @@ -1142,499 +746,245 @@ class EventManager: """ tokens = ["EVENT MANAGER: De-registering listeners for:", script] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) - for eventType in script.listeners.keys(): - self.deregisterListener(eventType) - - def _processInputEvent(self, event: Any) -> None: - """Processes the given input event based on the keybinding from the - currently-active script. - - Arguments: - - event: an instance of BrailleEvent or a KeyboardEvent - """ - - if not cthulhu_state.activeScript: - return - - if not isinstance(event, input_event.BrailleEvent): - return - - data = f"'{repr(event.event)}'" - eType = str(event.type).upper() - startTime = time.time() - - msg = f"\nvvvvv PROCESS {eType} {data} vvvvv" - debug.printMessage(debug.eventDebugLevel, msg, False) - - try: - cthulhu_state.activeScript.processBrailleEvent(event) - except Exception as error: - tokens = ["EVENT MANAGER: Exception processing event:", error] - debug.printTokens(debug.LEVEL_WARNING, tokens, True) - - msg = ( - f"TOTAL PROCESSING TIME: {time.time() - startTime:.4f}" - f"^^^^^ PROCESS {eType} {data} ^^^^^\n" - ) - debug.printMessage(debug.eventDebugLevel, msg, False) + for event_type in script.listeners: + self.deregister_listener(event_type) @staticmethod - def _get_scriptForEvent(event: Any) -> Optional[Script]: + def _get_script_for_event( + event: Atspi.Event, + active_script: default.Script | None = None, + ) -> default.Script | None: """Returns the script associated with event.""" + if event.source == focus_manager.get_manager().get_locus_of_focus(): + script = active_script or script_manager.get_manager().get_active_script() + tokens = ["EVENT MANAGER: Script for event from locus of focus is", script] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return script + if event.type.startswith("mouse:"): - return cthulhu.cthulhuApp.scriptManager.get_script_for_mouse_button_event(event) + mouse_event = input_event.MouseButtonEvent(event) + script = script_manager.get_manager().get_script(mouse_event.app, mouse_event.window) + tokens = ["EVENT MANAGER: Script for event is", script] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return script script = None - app = AXObject.get_application(event.source) + app = AXUtilities.get_application(event.source) if AXUtilities.is_defunct(app): tokens = ["EVENT MANAGER:", app, "is defunct. Cannot get script for event."] - debug.printTokens(debug.LEVEL_WARNING, tokens, True) + debug.print_tokens(debug.LEVEL_WARNING, tokens, True) return None - skipCheck = { - "object:children-changed", - "object:column-reordered", - "object:row-reordered", - "object:property-change", - "object:selection-changed", - "object:state-changed:checked", - "object:state-changed:expanded", - "object:state-changed:indeterminate", - "object:state-changed:pressed", - "object:state-changed:selected", - "object:state-changed:sensitive", - "object:state-changed:showing", - "object:text-changed", - } + tokens = ["EVENT MANAGER: Getting script for event for", app, event.source] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) - check = not any(event.type.startswith(x) for x in skipCheck) - tokens = ["EVENT MANAGER: Getting script for", app, "check:", check] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - - script = cthulhu.cthulhuApp.scriptManager.get_script(app, event.source, sanity_check=check) - tokens = ["EVENT MANAGER: Script is ", script] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + script = script_manager.get_manager().get_script(app, event.source) + tokens = ["EVENT MANAGER: Script for event is", script] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return script - def _isActivatableEvent(self, event: Atspi.Event, script: Optional[Script] = None) -> Tuple[bool, str]: - """Determines if the event is one which should cause us to - change which script is currently active. - - Returns a (boolean, string) tuple indicating whether or not - this is an activatable event, and our reason (for the purpose - of debugging). - """ + def _is_activatable_event( + self, + event: Atspi.Event, + script: default.Script | None = None, + ) -> tuple[bool, str]: + """Determines if event should cause us to change the active script.""" if not event.source: return False, "event.source? What event.source??" if not script: - script = self._get_scriptForEvent(event) + script = self._get_script_for_event(event) if not script: return False, "There is no script for this event." - prioritizedContextToken = self._prioritizedContextToken or cthulhu_state.prioritizedDesktopContextToken - if self._churnSuppressed and prioritizedContextToken: - eventToken = self._context_token_for_event(event) - if eventToken not in (None, prioritizedContextToken): - return False, "Event context does not match compositor-prioritized context." + app = AXUtilities.get_application(event.source) + if app and not AXUtilities.is_application_in_desktop(app): + return False, "The application is unknown to AT-SPI2" - if script == cthulhu_state.activeScript: - return False, "The script for this event is already active." - - if not script.isActivatableEvent(event): + if not script.is_activatable_event(event): return False, "The script says not to activate for this event." - if cthulhu_state.activeScript is None: - active_window = cthulhu_state.activeWindow - if AXUtilities.is_focused(event.source): - return True, "No active script and event source is focused." - if active_window and AXObject.is_ancestor(event.source, active_window, inclusive=True): - return True, "No active script and event is in active window." - if AXUtilities.is_frame(event.source) or AXUtilities.is_window(event.source): - return True, "No active script and event source is window/frame." - - if script.forceScriptActivation(event): + if script.force_script_activation(event): return True, "The script insists it should be activated for this event." - eType = event.type + return self._is_activatable_by_event_type(event) - if eType.startswith('window:activate'): - windowActivation = True - else: - windowActivation = eType.startswith('object:state-changed:active') \ - and event.detail1 and AXUtilities.is_frame(event.source) + @staticmethod + def _is_activatable_by_event_type(event: Atspi.Event) -> tuple[bool, str]: + """Determines if event type makes this an activatable event.""" - if windowActivation: - if event.source != cthulhu_state.activeWindow: - return True, "Window activation" - else: - return False, "Window activation for already-active window" + event_type = event.type - if eType.startswith('focus') \ - or (eType.startswith('object:state-changed:focused') - and event.detail1): + window_activation = event_type.startswith("window:activate") or ( + event_type.startswith("object:state-changed:active") + and event.detail1 + and AXUtilities.is_frame(event.source) + ) + if window_activation: + is_new = event.source != focus_manager.get_manager().get_active_window() + reason = ( + "Window activation" if is_new else "Window activation for already-active window" + ) + return is_new, reason + + if event_type.startswith("object:state-changed:focused") and event.detail1: return True, "Event source claimed focus." - if eType.startswith('object:state-changed:selected') and event.detail1 \ - and AXUtilities.is_menu(event.source) and AXUtilities.is_focusable(event.source): + if ( + event_type.startswith("object:state-changed:selected") + and event.detail1 + and AXUtilities.is_menu(event.source) + and AXUtilities.is_focusable(event.source) + ): return True, "Selection change in focused menu" - # This condition appears with gnome-screensaver-dialog. - # See bug 530368. - if eType.startswith('object:state-changed:showing') \ - and AXUtilities.is_panel(event.source) and AXUtilities.is_modal(event.source): + # This condition appears with gnome-screensaver-dialog. See bug 530368. + if ( + event_type.startswith("object:state-changed:showing") + and AXUtilities.is_panel(event.source) + and AXUtilities.is_modal(event.source) + ): return True, "Modal panel is showing." return False, "No reason found to activate a different script." - def _eventSourceIsDead(self, event: Atspi.Event) -> bool: + def _event_source_is_dead(self, event: Atspi.Event) -> bool: if AXObject.is_dead(event.source): tokens = ["EVENT MANAGER: source of", event.type, "is dead"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return True return False - def _ignoreDuringDeluge(self, event: Atspi.Event) -> bool: - """Returns true if this event should be ignored during a deluge.""" + def _should_process_event( + self, + event: Atspi.Event, + event_script: default.Script, + active_script: default.Script, + ) -> bool: + """Returns True if this event should be processed.""" - if self._eventSourceIsDead(event): + if event_script == active_script: + msg = f"EVENT MANAGER: Processing {event.type}: script for event is active" + debug.print_message(debug.LEVEL_INFO, msg, True) return True - ignore = ["object:text-changed:delete", - "object:text-changed:insert", - "object:text-changed:delete:system", - "object:text-changed:insert:system", - "object:text-attributes-changed", - "object:text-caret-moved", - "object:children-changed:add", - "object:children-changed:add:system", - "object:children-changed:remove", - "object:children-changed:remove:system", - "object:property-change:accessible-name", - "object:property-change:accessible-description", - "object:selection-changed", - "object:state-changed:showing", - "object:state-changed:sensitive"] - - if event.type not in ignore: - return False - - return event.source != cthulhu_state.locusOfFocus - - def _inDeluge(self) -> bool: - size = self._eventQueue.qsize() - if size > 100: - msg = f"EVENT MANAGER: DELUGE! Queue size is {size}" - debug.printMessage(debug.LEVEL_INFO, msg, True) + if event_script.present_if_inactive: + msg = f"EVENT MANAGER: Processing {event.type}: script handles events when inactive" + debug.print_message(debug.LEVEL_INFO, msg, True) return True + if "accessible-value" in event.type and AXUtilities.is_progress_bar(event.source): + msg = f"EVENT MANAGER: Processing {event.type}: source is progress bar" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + msg = f"EVENT MANAGER: Not processing {event.type} due to lack of reason" + debug.print_message(debug.LEVEL_INFO, msg, True) return False - def _processDuringFlood(self, event: Atspi.Event) -> bool: - """Returns true if this event should be processed during a flood.""" + @staticmethod + def _handle_early_event_processing(event: Atspi.Event) -> bool: + """Handles early event processing. Returns True if the event was fully handled.""" - if self._eventSourceIsDead(event): - return False + script_mgr = script_manager.get_manager() + focus_mgr = focus_manager.get_manager() - ignore = ["object:text-changed:delete", - "object:text-changed:insert", - "object:text-changed:delete:system", - "object:text-changed:insert:system", - "object:text-attributes-changed", - "object:text-caret-moved", - "object:children-changed:add", - "object:children-changed:add:system", - "object:children-changed:remove", - "object:children-changed:remove:system", - "object:property-change:accessible-name", - "object:property-change:accessible-description", - "object:selection-changed", - "object:state-changed:showing", - "object:state-changed:sensitive"] - - if event.type not in ignore: + event_type = event.type + if ( + event_type.startswith("object:children-changed:remove") + and event.source == AXUtilities.get_desktop() + ): + script_mgr.reclaim_scripts() return True - return event.source == cthulhu_state.locusOfFocus - - def _prioritizeDuringFlood(self, event: Atspi.Event) -> bool: - """Returns true if this event should be prioritized during a flood.""" - - if event.type.startswith("object:state-changed:focused"): - return event.detail1 - - if event.type.startswith("object:state-changed:selected"): - return event.detail1 - - if event.type.startswith("object:text-selection-changed"): - return True - - if event.type.startswith("window:activate"): - return True - - if event.type.startswith("window:deactivate"): - return True - - if event.type.startswith("object:state-changed:active"): - return AXUtilities.is_frame(event.source) or AXUtilities.is_window(event.source) - - if event.type.startswith("document:load-complete"): - return True - - if event.type.startswith("object:state-changed:busy"): - return True - - return False - - def _pruneEventsDuringFlood(self) -> None: - """Gets rid of events we don't care about during a flood.""" - - oldSize = self._eventQueue.qsize() - - newQueue: queue.Queue[Any] = queue.Queue(0) - while not self._eventQueue.empty(): - try: - event = self._eventQueue.get() - except Exception: - continue - - if self._processDuringFlood(event): - newQueue.put(event) - self._queuePrintln(event, isPrune=False) - self._eventQueue.task_done() - - self._eventQueue = newQueue - newSize = self._eventQueue.qsize() - - 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: - msg = f"EVENT MANAGER: FLOOD? Queue size is {size}" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - - return False - - def _processObjectEvent(self, event: Atspi.Event) -> None: - """Handles all object events destined for scripts. - - Arguments: - - e: an at-spi event. - """ - - debug.printObjectEvent(debug.LEVEL_INFO, event, timestamp=True) - eType = event.type - - if eType.startswith("object:children-changed:remove") \ - and event.source == AXUtilities.get_desktop(): - cthulhu.cthulhuApp.scriptManager.reclaim_scripts() - return - - if eType.startswith("window:") and not eType.endswith("create"): - cthulhu.cthulhuApp.scriptManager.reclaim_scripts() - elif eType.startswith("object:state-changed:active") \ - and AXUtilities.is_frame(event.source): - cthulhu.cthulhuApp.scriptManager.reclaim_scripts() - if AXObject.is_dead(event.source) or AXUtilities.is_defunct(event.source): tokens = ["EVENT MANAGER: Ignoring defunct object:", event.source] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) - if eType.startswith("window:deactivate") or eType.startswith("window:destroy") \ - and cthulhu_state.activeWindow == event.source: - msg = 'EVENT MANAGER: Clearing active window, script, and locus of focus' - debug.printMessage(debug.LEVEL_INFO, msg, True) - cthulhu_state.locusOfFocus = None - cthulhu_state.activeWindow = None - cthulhu.cthulhuApp.scriptManager.set_active_script(None, "focus: active-window-dead") - return + if event_type.startswith("window:de") and focus_mgr.get_active_window() == event.source: + focus_mgr.clear_state("Active window is dead or defunct") + script_mgr.set_active_script(None, "Active window is dead or defunct") + return True + + if event_type.startswith("window:") and event_type.endswith("destroy"): + script_mgr.reclaim_scripts() if AXUtilities.is_iconified(event.source): tokens = ["EVENT MANAGER: Ignoring iconified object:", event.source] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return True + + return False + + @staticmethod + def _find_listener(script: default.Script, event_type: str): + """Returns the listener for event_type, or None.""" + + listener = script.listeners.get(event_type) + if listener is not None: + return listener + + # The listener can be None if the event type has a suffix such as "system". + for key, value in script.listeners.items(): + if event_type.startswith(key): + return value + + return None + + def _process_object_event(self, event: Atspi.Event) -> None: + """Handles all object events destined for scripts.""" + + if self._is_obsoleted_by(event) or self._handle_early_event_processing(event): return - if self._inFlood(): - if not self._processDuringFlood(event): - msg = 'EVENT MANAGER: Not processing this event due to flood.' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - if self._prioritizeDuringFlood(event): - msg = 'EVENT MANAGER: Pruning event queue due to flood.' - debug.printMessage(debug.LEVEL_INFO, msg, True) - self._pruneEventsDuringFlood() + if debug.debugLevel <= debug.LEVEL_INFO: + msg = AXUtilitiesDebugging.object_event_details_as_string(event) + debug.print_message(debug.LEVEL_INFO, msg, True) - if eType.startswith('object:selection-changed') \ - and event.source in self._parentsOfDefunctDescendants: - msg = 'EVENT MANAGER: Ignoring event from parent of defunct descendants' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - if not debug.eventDebugFilter or debug.eventDebugFilter.match(eType) \ - and not eType.startswith("mouse:"): - indent = " " * 32 - debug.printDetails(debug.LEVEL_INFO, indent, event.source) - if isinstance(event.any_data, Atspi.Accessible): - debug.printMessage(debug.LEVEL_INFO, f"{indent}ANY DATA:") - debug.printDetails(debug.LEVEL_INFO, indent, event.any_data, includeApp=False) - - script = self._get_scriptForEvent(event) + script_mgr = script_manager.get_manager() + active_script = script_mgr.get_active_script() + script = self._get_script_for_event(event, active_script) if not script: msg = "ERROR: Could not get script for event" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return - setNewActiveScript, reason = self._isActivatableEvent(event, script) - debug.print_log(debug.LEVEL_INFO, "EVENT MANAGER", - f"Change active script: {setNewActiveScript}", reason, True) + if script != active_script: + set_new_active_script, reason = self._is_activatable_event(event, script) + msg = f"EVENT MANAGER: Change active script: {set_new_active_script} ({reason})" + debug.print_message(debug.LEVEL_INFO, msg, True) - if setNewActiveScript: - try: - cthulhu.cthulhuApp.scriptManager.set_active_script(script, reason) - except Exception as error: - tokens = ["EVENT MANAGER: Exception setting active script for", - event.source, ":", error] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + if set_new_active_script: + script_mgr.set_active_script(script, reason) + active_script = script + + try: + assert active_script is not None + except AssertionError: + # TODO - JD: Under what conditions could this actually happen? + msg = "ERROR: Active script is None" + debug.print_message(debug.LEVEL_INFO, msg, True) + else: + if not self._should_process_event(event, script, active_script): return - try: - script.processObjectEvent(event) - except Exception as error: - msg = f"EVENT MANAGER: Exception processing {event.type}: {error}" - debug.printMessage(debug.LEVEL_INFO, msg, True) - debug.printException(debug.LEVEL_INFO) - else: - if self._compositorStateAdapter is not None: - if eType.startswith("window:activate") or ( - eType.startswith("object:state-changed:focused") and event.detail1 - ): - self._compositorStateAdapter.sync_accessible_context(event.type) + listener = self._find_listener(script, event.type) + if listener is None: + msg = f"EVENT MANAGER: No listener for event type {event.type}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return - tokens = ["EVENT MANAGER: locusOfFocus:", cthulhu_state.locusOfFocus, - "activeScript:", cthulhu_state.activeScript] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + listener(event) - if debug.LEVEL_INFO >= debug.debugLevel and cthulhu_state.activeScript: - attributes = cthulhu_state.activeScript.getTransferableAttributes() - for key, value in attributes.items(): - msg = f"EVENT MANAGER: {key}: {value}" - debug.printMessage(debug.LEVEL_INFO, msg, True) - def processBrailleEvent(self, brailleEvent: input_event.BrailleEvent) -> bool: - """Called whenever a cursor key is pressed on the Braille display. +_manager: EventManager = EventManager() - Arguments: - - brailleEvent: an instance of input_event.BrailleEvent - Returns True if the command was consumed; otherwise False - """ - - if cthulhu_state.activeScript \ - and cthulhu_state.activeScript.consumesBrailleEvent(brailleEvent): - self._enqueue(brailleEvent) - return True - else: - return False - -_manager: Optional[EventManager] = None - -def getManager() -> Optional[EventManager]: - global _manager - if _manager is None: - try: - if cthulhu.cthulhuApp: - _manager = cthulhu.cthulhuApp.eventManager - except AttributeError: - pass +def get_manager() -> EventManager: + """Returns the Event Manager singleton.""" return _manager diff --git a/tests/test_compositor_state_adapter_regressions.py b/tests/test_compositor_state_adapter_regressions.py index 3999095..e6f4b99 100644 --- a/tests/test_compositor_state_adapter_regressions.py +++ b/tests/test_compositor_state_adapter_regressions.py @@ -8,11 +8,9 @@ from unittest import mock sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) from cthulhu import cthulhu_state -from cthulhu import cthulhu from cthulhu import compositor_state_adapter from cthulhu import compositor_state_types from cthulhu import compositor_state_wayland -from cthulhu.wayland_protocols import ext_workspace_v1 class FakeWorkspaceBackend: @@ -136,6 +134,8 @@ class CompositorStateAdapterRegressionTests(unittest.TestCase): self.assertIsNone(backend._display) def test_local_ext_workspace_wrapper_supports_base_pywayland_without_distro_protocol_module(self) -> None: + from cthulhu.wayland_protocols import ext_workspace_v1 + fakeClientModule = types.ModuleType("pywayland.client") class FakeDisplay: @@ -309,29 +309,5 @@ class CompositorStateAdapterRegressionTests(unittest.TestCase): self.assertEqual(cthulhu_state.compositorSnapshot.active_workspace_ids, frozenset({"ws-2"})) self.assertFalse(cthulhu_state.pauseAtspiChurn) - def test_event_manager_startup_resyncs_adapter_after_focus_recovery(self) -> None: - adapter = mock.Mock() - adapter.sync_accessible_context = mock.Mock(return_value=None) - listener = mock.Mock() - window = object() - focusedObject = object() - - with ( - mock.patch.object(cthulhu.event_manager.Atspi.EventListener, "new", return_value=listener), - mock.patch.object(cthulhu.event_manager.AXUtilities, "can_be_active_window", return_value=False), - mock.patch.object(cthulhu.event_manager.AXUtilities, "find_active_window", return_value=window), - mock.patch.object(cthulhu.event_manager.AXUtilities, "get_focused_object", return_value=focusedObject), - mock.patch.object(cthulhu.event_manager.cthulhu, "setActiveWindow") as setActiveWindow, - mock.patch.object(cthulhu.event_manager.cthulhu, "setLocusOfFocus") as setLocusOfFocus, - ): - manager = cthulhu.event_manager.EventManager(mock.Mock()) - manager.set_compositor_state_adapter(adapter) - manager._sync_focus_on_startup() - - setActiveWindow.assert_called_once_with(window, alsoSetLocusOfFocus=True, notifyScript=False) - setLocusOfFocus.assert_called_once_with(None, focusedObject, notifyScript=True, force=True) - adapter.sync_accessible_context.assert_called_once_with("event-manager-startup") - - if __name__ == "__main__": unittest.main() diff --git a/tests/test_event_manager.py b/tests/test_event_manager.py new file mode 100644 index 0000000..4d5680b --- /dev/null +++ b/tests/test_event_manager.py @@ -0,0 +1,2001 @@ +# Unit tests for event_manager.py methods. +# +# Copyright 2025 Igalia, S.L. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +# pylint: disable=wrong-import-position +# pylint: disable=import-outside-toplevel +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-statements +# pylint: disable=protected-access +# pylint: disable=too-many-arguments +# pylint: disable=too-many-positional-arguments +# pylint: disable=too-many-locals +# pylint: disable=too-many-lines + +"""Unit tests for event_manager.py methods.""" + +from __future__ import annotations + +import itertools +import queue +from typing import TYPE_CHECKING +from unittest.mock import call + +import gi +import pytest + +gi.require_version("Atspi", "2.0") +from gi.repository import Atspi + +if TYPE_CHECKING: + from unittest.mock import MagicMock + + from .cthulhu_test_context import CthulhuTestContext + + +@pytest.mark.unit +class TestEventManager: + """Test EventManager class methods.""" + + def _setup_ignore_event_ax_utilities_mocks(self, test_context, mock_time=5000.0, is_text=True): + """Set up common AXUtilities mocks for event ignore testing scenarios.""" + + test_context.patch("time.time", return_value=mock_time) + test_context.patch("cthulhu.event_manager.AXUtilities.is_text", return_value=is_text) + test_context.patch("cthulhu.event_manager.AXUtilities.is_window", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_frame", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_notification", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_alert", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_selected", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_focused", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_section", return_value=False) + + def _setup_dependencies(self, test_context: CthulhuTestContext) -> dict[str, MagicMock]: + """Returns dependencies for event_manager module testing.""" + + additional_modules = [ + "cthulhu.input_event_manager", + "cthulhu.cthulhu_modifier_manager", + "cthulhu.ax_utilities_debugging", + "cthulhu.ax_utilities", + "cthulhu.braille_presenter", + ] + essential_modules = test_context.setup_shared_dependencies(additional_modules) + + debug_mock = essential_modules["cthulhu.debug"] + debug_mock.LEVEL_INFO = 800 + debug_mock.LEVEL_WARNING = 2 + debug_mock.LEVEL_SEVERE = 3 + debug_mock.debugLevel = 0 + + braille_presenter_mock = essential_modules["cthulhu.braille_presenter"] + braille_presenter_instance = test_context.Mock() + braille_presenter_instance.disable_braille = test_context.Mock() + braille_presenter_mock.get_presenter = test_context.Mock( + return_value=braille_presenter_instance, + ) + + focus_manager_mock = essential_modules["cthulhu.focus_manager"] + focus_mgr_instance = test_context.Mock() + focus_mgr_instance.get_locus_of_focus = test_context.Mock() + focus_mgr_instance.get_active_window = test_context.Mock() + focus_mgr_instance.focus_and_window_are_unknown = test_context.Mock(return_value=False) + focus_mgr_instance.clear_state = test_context.Mock() + focus_manager_mock.get_manager = test_context.Mock(return_value=focus_mgr_instance) + + input_event_mock = essential_modules["cthulhu.input_event"] + + class MockKeyboardEvent: + """Mock KeyboardEvent for testing.""" + + class MockBrailleEvent: + """Mock BrailleEvent for testing.""" + + class MockMouseButtonEvent: + """Mock MouseButtonEvent for testing.""" + + input_event_mock.KeyboardEvent = MockKeyboardEvent + input_event_mock.BrailleEvent = MockBrailleEvent + input_event_mock.MouseButtonEvent = MockMouseButtonEvent + + input_event_manager_mock = essential_modules["cthulhu.input_event_manager"] + input_mgr_instance = test_context.Mock() + input_mgr_instance.start_key_watcher = test_context.Mock() + input_mgr_instance.stop_key_watcher = test_context.Mock() + input_event_manager_mock.get_manager = test_context.Mock(return_value=input_mgr_instance) + + script_manager_mock = essential_modules["cthulhu.script_manager"] + script_mgr_instance = test_context.Mock() + script_instance = test_context.Mock() + script_instance.app = test_context.Mock() + script_instance.event_cache = {} + script_instance.listeners = {} + script_instance.is_activatable_event = test_context.Mock(return_value=True) + script_instance.force_script_activation = test_context.Mock(return_value=False) + script_instance.present_if_inactive = False + script_mgr_instance.get_active_script = test_context.Mock(return_value=script_instance) + script_mgr_instance.get_script = test_context.Mock(return_value=script_instance) + script_mgr_instance.set_active_script = test_context.Mock() + script_mgr_instance.get_default_script = test_context.Mock(return_value=script_instance) + script_mgr_instance.reclaim_scripts = test_context.Mock() + script_manager_mock.get_manager = test_context.Mock(return_value=script_mgr_instance) + + ax_utils_debugging_mock = essential_modules["cthulhu.ax_utilities_debugging"] + ax_utils_debugging_mock.object_event_details_as_string = test_context.Mock( + return_value="mock details", + ) + + ax_object_mock = essential_modules["cthulhu.ax_object"] + ax_object_mock.get_name = test_context.Mock() + ax_object_mock.get_parent = test_context.Mock() + ax_object_mock.get_role = test_context.Mock() + ax_object_mock.get_attribute = test_context.Mock() + ax_object_mock.is_dead = test_context.Mock(return_value=False) + ax_object_mock.has_state = test_context.Mock(return_value=False) + + ax_utilities_mock = essential_modules["cthulhu.ax_utilities"] + ax_utilities_mock.is_frame = test_context.Mock(return_value=False) + ax_utilities_mock.is_dialog_or_alert = test_context.Mock(return_value=False) + ax_utilities_mock.is_window = test_context.Mock(return_value=False) + ax_utilities_mock.is_text = test_context.Mock(return_value=False) + ax_utilities_mock.is_notification = test_context.Mock(return_value=False) + ax_utilities_mock.is_alert = test_context.Mock(return_value=False) + ax_utilities_mock.is_selected = test_context.Mock(return_value=False) + ax_utilities_mock.is_focused = test_context.Mock(return_value=False) + ax_utilities_mock.manages_descendants = test_context.Mock(return_value=False) + ax_utilities_mock.is_section = test_context.Mock(return_value=False) + ax_utilities_mock.get_application = test_context.Mock() + ax_utilities_mock.get_desktop = test_context.Mock() + ax_utilities_mock.is_invalid_role = test_context.Mock(return_value=False) + ax_utilities_mock.is_menu_related = test_context.Mock(return_value=False) + ax_utilities_mock.is_image = test_context.Mock(return_value=False) + ax_utilities_mock.is_showing = test_context.Mock(return_value=True) + ax_utilities_mock.is_selectable = test_context.Mock(return_value=False) + ax_utilities_mock.is_menu = test_context.Mock(return_value=False) + ax_utilities_mock.is_focusable = test_context.Mock(return_value=False) + ax_utilities_mock.is_panel = test_context.Mock(return_value=False) + ax_utilities_mock.is_modal = test_context.Mock(return_value=False) + ax_utilities_mock.is_progress_bar = test_context.Mock(return_value=False) + ax_utilities_mock.is_defunct = test_context.Mock(return_value=False) + ax_utilities_mock.is_application_in_desktop = test_context.Mock(return_value=True) + ax_utilities_mock.is_iconified = test_context.Mock(return_value=False) + + glib_mock = test_context.Mock() + glib_mock.idle_add = test_context.Mock(return_value=123) + glib_mock.timeout_add = test_context.Mock() + test_context.patch("gi.repository.GLib", new=glib_mock) + + essential_modules["focus_manager_instance"] = focus_mgr_instance + essential_modules["input_event_manager_instance"] = input_mgr_instance + essential_modules["script_manager_instance"] = script_mgr_instance + essential_modules["script_instance"] = script_instance + essential_modules["glib"] = glib_mock + + return essential_modules + + def test_init(self, test_context: CthulhuTestContext) -> None: + """Test EventManager.__init__.""" + + self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + assert not manager._script_listener_counts + assert manager._active is False + assert manager._paused is False + assert isinstance(manager._counter, itertools.count) + assert isinstance(manager._event_queue, queue.PriorityQueue) + assert manager._gidle_id == 0 + assert not manager._event_history + + @pytest.mark.parametrize( + "case", + [ + { + "id": "activate_manager", + "operation": "activate", + "initial_active": False, + "initial_counts": {}, + "expected_active": True, + "watcher_method": "start_key_watcher", + }, + { + "id": "deactivate_manager", + "operation": "deactivate", + "initial_active": True, + "initial_counts": {"test": 1}, + "expected_active": False, + "watcher_method": "stop_key_watcher", + }, + ], + ids=lambda case: case["id"], + ) + def test_activation_operations( + self, + test_context: CthulhuTestContext, + case: dict, + ) -> None: + """Test EventManager activate/deactivate operations.""" + self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + mock_get_input_mgr = test_context.Mock() + test_context.patch( + "cthulhu.event_manager.input_event_manager.get_manager", + new=mock_get_input_mgr, + ) + mock_input_mgr = test_context.Mock() + mock_get_input_mgr.return_value = mock_input_mgr + manager = EventManager() + manager._active = case["initial_active"] + manager._script_listener_counts = case["initial_counts"].copy() + + getattr(manager, case["operation"])() + assert manager._active is case["expected_active"] + + if case["operation"] == "deactivate": + assert not manager._script_listener_counts + + getattr(mock_input_mgr, case["watcher_method"]).assert_called_once() + + getattr(mock_input_mgr, case["watcher_method"]).reset_mock() + getattr(manager, case["operation"])() + getattr(mock_input_mgr, case["watcher_method"]).assert_not_called() + + @pytest.mark.parametrize( + "case", + [ + {"id": "pause_only", "pause": True, "clear_queue": False, "reason": ""}, + { + "id": "unpause_with_reason", + "pause": False, + "clear_queue": False, + "reason": "test reason", + }, + { + "id": "pause_and_clear", + "pause": True, + "clear_queue": True, + "reason": "clear and pause", + }, + ], + ids=lambda case: case["id"], + ) + def test_pause_queuing(self, test_context, case: dict) -> None: + """Test EventManager.pause_queuing.""" + + self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + original_queue = manager._event_queue + manager.pause_queuing(case["pause"], case["clear_queue"], case["reason"]) + assert manager._paused == case["pause"] + if case["clear_queue"]: + assert manager._event_queue is not original_queue + else: + assert manager._event_queue is original_queue + + @pytest.mark.parametrize( + "case", + [ + {"id": "window_event", "event_type": "window:activate", "expected_priority": 2}, + { + "id": "focus_changed", + "event_type": "object:state-changed:focused", + "expected_priority": 3, + }, + { + "id": "active_descendant", + "event_type": "object:active-descendant-changed", + "expected_priority": 3, + }, + { + "id": "announcement_normal", + "event_type": "object:announcement", + "expected_priority": 4, + }, + { + "id": "invalid_entry", + "event_type": "object:state-changed:invalid-entry", + "expected_priority": 5, + }, + { + "id": "children_changed", + "event_type": "object:children-changed:add", + "expected_priority": 6, + }, + { + "id": "other_event", + "event_type": "object:text-changed:insert", + "expected_priority": 4, + }, + ], + ids=lambda case: case["id"], + ) + def test_get_priority(self, test_context, case: dict) -> None: + """Test EventManager._get_priority.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = case["event_type"] + mock_event.source = test_context.Mock() + mock_event.detail1 = 0 + ax_utilities = essential_modules["cthulhu.ax_utilities"] + if case["event_type"] == "object:state-changed:active": + ax_utilities.is_frame.return_value = True + elif case["event_type"] == "object:announcement": + mock_event.detail1 = Atspi.Live.POLITE if case["expected_priority"] == 3 else 0 + priority = manager._get_priority(mock_event) + assert priority == case["expected_priority"] + + def test_get_priority_announcement_levels(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._get_priority for announcement event levels.""" + + self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = "object:announcement" + mock_event.source = test_context.Mock() + + mock_event.detail1 = Atspi.Live.ASSERTIVE + priority = manager._get_priority(mock_event) + assert priority == 2 # PRIORITY_IMPORTANT + + mock_event.detail1 = Atspi.Live.POLITE + priority = manager._get_priority(mock_event) + assert priority == 3 # PRIORITY_HIGH + + mock_event.detail1 = 999 + priority = manager._get_priority(mock_event) + assert priority == 4 # PRIORITY_NORMAL + + @pytest.mark.parametrize( + "case", + [ + { + "id": "duplicate_events", + "new_type": "object:text-changed:insert", + "new_detail1": 5, + "new_detail2": 10, + "new_data": "test", + "existing_type": "object:text-changed:insert", + "existing_detail1": 5, + "existing_detail2": 10, + "existing_data": "test", + "priority": 4, + "should_obsolete": True, + }, + { + "id": "window_events", + "new_type": "window:activate", + "new_detail1": 0, + "new_detail2": 0, + "new_data": None, + "existing_type": "window:deactivate", + "existing_detail1": 0, + "existing_detail2": 0, + "existing_data": None, + "priority": 2, + "should_obsolete": True, + }, + { + "id": "no_obsolescence", + "new_type": "object:text-changed:insert", + "new_detail1": 0, + "new_detail2": 0, + "new_data": None, + "existing_type": None, + "existing_detail1": None, + "existing_detail2": None, + "existing_data": None, + "priority": None, + "should_obsolete": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_is_obsoleted_by_scenarios( + self, + test_context: CthulhuTestContext, + case: dict, + ) -> None: + """Test EventManager._is_obsoleted_by for various obsolescence scenarios.""" + self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = case["new_type"] + mock_event.source = test_context.Mock() + mock_event.detail1 = case["new_detail1"] + mock_event.detail2 = case["new_detail2"] + mock_event.any_data = case["new_data"] + + if case["existing_type"] is not None and case["priority"] is not None: + existing_event = test_context.Mock(spec=Atspi.Event) + existing_event.type = case["existing_type"] + existing_event.source = mock_event.source + existing_event.detail1 = case["existing_detail1"] + existing_event.detail2 = case["existing_detail2"] + existing_event.any_data = case["existing_data"] + manager._event_queue.put((case["priority"], 1, existing_event)) + + result = manager._is_obsoleted_by(mock_event) + if case["should_obsolete"]: + assert result is not None + else: + assert result is None + + @pytest.mark.parametrize( + "case", + [ + {"id": "inactive_manager", "active": False, "paused": False, "expected": True}, + {"id": "paused_manager", "active": True, "paused": True, "expected": True}, + ], + ids=lambda case: case["id"], + ) + def test_ignore_inactive_or_paused_manager( + self, + test_context: CthulhuTestContext, + case: dict, + ) -> None: + """Test EventManager._ignore when manager is inactive or paused.""" + self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = "object:text-changed:insert" + + manager._active = case["active"] + manager._paused = case["paused"] + assert manager._ignore(mock_event) is case["expected"] + + @pytest.mark.parametrize( + "case", + [ + { + "id": "window_activate_event", + "event_type": "window:activate", + "event_config": {}, + "expected_result": False, + }, + { + "id": "mouse_button_event", + "event_type": "mouse:button:1p", + "event_config": {}, + "expected_result": False, + }, + { + "id": "focused_window_event", + "event_type": "object:state-changed:focused", + "event_config": {"is_window": True}, + "expected_result": True, + }, + ], + ids=lambda case: case["id"], + ) + def test_ignore_window_and_mouse_events( + self, + test_context: CthulhuTestContext, + case: dict, + ) -> None: + """Test EventManager._ignore for window, mouse, and focused window events.""" + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + manager._active = True + manager._paused = False + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = case["event_type"] + mock_event.source = test_context.Mock() + + if "is_window" in case["event_config"]: + ax_utilities = essential_modules["cthulhu.ax_utilities"] + ax_utilities.is_window.return_value = case["event_config"]["is_window"] + + assert manager._ignore(mock_event) is case["expected_result"] + + def test_ignore_frame_events(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._ignore for frame events.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + manager._active = True + manager._paused = False + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = "object:test-event" + mock_event.source = test_context.Mock() + mock_event.any_data = test_context.Mock() + mock_event.detail1 = 0 + mock_event.detail2 = 0 + ax_utilities = essential_modules["cthulhu.ax_utilities"] + focus_mgr = essential_modules["focus_manager_instance"] + + focus_mgr.get_locus_of_focus.return_value = None + ax_utilities.is_window.return_value = False + test_context.patch("time.time", return_value=1000.0) + manager._event_history = {} + + mock_app = test_context.Mock() + test_context.patch("cthulhu.event_manager.AXUtilities.is_window", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_frame", return_value=True) + test_context.patch("cthulhu.event_manager.AXUtilities.get_application", return_value=mock_app) + test_context.patch("cthulhu.event_manager.AXObject.get_name", return_value="mutter-x11-frames") + assert manager._ignore(mock_event) is True + + regular_app = test_context.Mock() + test_context.patch("cthulhu.event_manager.AXUtilities.is_window", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_frame", return_value=True) + test_context.patch( + "cthulhu.event_manager.AXUtilities.get_application", + return_value=regular_app, + ) + test_context.patch("cthulhu.event_manager.AXObject.get_name", return_value="regular-app") + assert manager._ignore(mock_event) is False + + def test_ignore_text_events(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._ignore for text-related events.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + manager._active = True + manager._paused = False + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.source = test_context.Mock() + mock_event.detail2 = 5001 # Large text insertion + mock_event.any_data = test_context.Mock() + mock_event.detail1 = 0 + ax_utilities = essential_modules["cthulhu.ax_utilities"] + focus_mgr = essential_modules["focus_manager_instance"] + focus_mgr.get_locus_of_focus.return_value = None + ax_utilities.is_window.return_value = False + ax_utilities.is_frame.return_value = False + ax_utilities.is_text.return_value = True # This triggers text logic + ax_utilities.is_notification.return_value = False + ax_utilities.is_alert.return_value = False + ax_utilities.is_selected.return_value = False + ax_utilities.is_focused.return_value = False + ax_utilities.is_section.return_value = False + + self._setup_ignore_event_ax_utilities_mocks(test_context, mock_time=5000.0, is_text=True) + manager._event_history = {} + + mock_event.type = "object:text-changed:insert" + result = manager._ignore(mock_event) + assert result is True, f"Expected True but got {result}" + + mock_event.detail2 = 100 + assert manager._ignore(mock_event) is False + + mock_event.type = "object:text-changed:delete" + assert manager._ignore(mock_event) is False + + @pytest.mark.parametrize( + "case", + [ + { + "id": "notification_event", + "utility_method": "is_notification", + "utility_value": True, + "expected_result": False, + }, + { + "id": "alert_event", + "utility_method": "is_alert", + "utility_value": True, + "expected_result": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_ignore_notification_and_alert_events( + self, + test_context: CthulhuTestContext, + case: dict, + ) -> None: + """Test EventManager._ignore for notification and alert events.""" + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + manager._active = True + manager._paused = False + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = "object:test-event" + mock_event.source = test_context.Mock() + ax_utilities = essential_modules["cthulhu.ax_utilities"] + + ax_utilities.is_notification.return_value = False + ax_utilities.is_alert.return_value = False + getattr(ax_utilities, case["utility_method"]).return_value = case["utility_value"] + + assert manager._ignore(mock_event) is case["expected_result"] + + def test_ignore_focus_and_selection_events(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._ignore for focus and selection events.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + manager._active = True + manager._paused = False + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = "object:test-event" + mock_event.source = test_context.Mock() + mock_event.any_data = test_context.Mock() + ax_utilities = essential_modules["cthulhu.ax_utilities"] + focus_mgr = essential_modules["focus_manager_instance"] + + focus_mgr.get_locus_of_focus.return_value = mock_event.source + assert manager._ignore(mock_event) is False + + focus_mgr.get_locus_of_focus.return_value = mock_event.any_data + assert manager._ignore(mock_event) is False + + focus_mgr.get_locus_of_focus.return_value = test_context.Mock() + ax_utilities.is_selected.return_value = True + assert manager._ignore(mock_event) is False + + def test_ignore_focused_source_events(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._ignore for events from focused sources.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + manager._active = True + manager._paused = False + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.source = test_context.Mock() + mock_event.detail1 = 1 + mock_event.detail2 = 0 + mock_event.any_data = test_context.Mock() + focus_mgr = essential_modules["focus_manager_instance"] + + focus_mgr.get_locus_of_focus.return_value = None + test_context.patch("time.time", return_value=3000.0) + manager._event_history = {} + + mock_event.type = "object:test-event" + test_context.patch("cthulhu.event_manager.AXUtilities.is_window", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_frame", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_text", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_notification", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_alert", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_selected", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_focused", return_value=True) + test_context.patch("cthulhu.event_manager.AXUtilities.manages_descendants", return_value=False) + assert manager._ignore(mock_event) is False + + # - should not be ignored + mock_event.type = "object:state-changed:focused" + mock_event.detail1 = 1 + test_context.patch("cthulhu.event_manager.AXUtilities.is_window", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_frame", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_text", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_notification", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_alert", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_selected", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_focused", return_value=True) + test_context.patch("cthulhu.event_manager.AXUtilities.manages_descendants", return_value=True) + assert manager._ignore(mock_event) is False + + def test_ignore_live_region_events(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._ignore for live region events.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + manager._active = True + manager._paused = False + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = "object:text-changed:insert" + mock_event.source = test_context.Mock() + mock_event.detail2 = 100 + ax_utilities = essential_modules["cthulhu.ax_utilities"] + ax_object = essential_modules["cthulhu.ax_object"] + focus_mgr = essential_modules["focus_manager_instance"] + + focus_mgr.get_locus_of_focus.return_value = test_context.Mock() + ax_utilities.is_selected.return_value = False + ax_utilities.is_focused.return_value = False + ax_utilities.is_section.return_value = True + + ax_object.get_attribute.return_value = "polite" + assert manager._ignore(mock_event) is False + + ax_object.get_attribute.return_value = "off" + mock_app = test_context.Mock() + ax_utilities.get_application.return_value = mock_app + manager._event_history = {} + assert manager._ignore(mock_event) is False + + def test_ignore_spam_filtering(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._ignore spam filtering mechanism.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + manager._active = True + manager._paused = False + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = "object:test-event" + mock_event.source = test_context.Mock() + mock_event.detail1 = 0 + mock_event.detail2 = 0 + mock_event.any_data = test_context.Mock() + mock_app = test_context.Mock() + original_hash = hash + + def mock_hash(obj): + if obj is mock_app: + return 12345 + return original_hash(obj) + + test_context.patch("builtins.hash", new=mock_hash) + focus_mgr = essential_modules["focus_manager_instance"] + focus_mgr.get_locus_of_focus.return_value = None + + manager._event_history = {} + test_context.patch("time.time", return_value=100.0) + test_context.patch("cthulhu.event_manager.AXUtilities.is_window", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_frame", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_text", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_notification", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_alert", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_selected", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_focused", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_section", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.manages_descendants", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.get_application", return_value=mock_app) + test_context.patch("cthulhu.event_manager.AXObject.get_name", return_value="regular-app") + test_context.patch("cthulhu.event_manager.AXObject.get_attribute", return_value=None) + test_context.patch("cthulhu.event_manager.focus_manager.get_manager", return_value=focus_mgr) + assert manager._ignore(mock_event) is False + + test_context.patch("time.time", return_value=100.05) + test_context.patch("cthulhu.event_manager.AXUtilities.is_window", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_frame", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_text", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_notification", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_alert", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_selected", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_focused", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_section", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.manages_descendants", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.get_application", return_value=mock_app) + test_context.patch("cthulhu.event_manager.AXObject.get_name", return_value="regular-app") + test_context.patch("cthulhu.event_manager.AXObject.get_attribute", return_value=None) + test_context.patch("cthulhu.event_manager.focus_manager.get_manager", return_value=focus_mgr) + assert manager._ignore(mock_event) is True + + test_context.patch("time.time", return_value=100.2) + test_context.patch("cthulhu.event_manager.AXUtilities.is_window", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_frame", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_text", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_notification", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_alert", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_selected", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_focused", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_section", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.manages_descendants", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.get_application", return_value=mock_app) + test_context.patch("cthulhu.event_manager.AXObject.get_name", return_value="regular-app") + test_context.patch("cthulhu.event_manager.AXObject.get_attribute", return_value=None) + test_context.patch("cthulhu.event_manager.focus_manager.get_manager", return_value=focus_mgr) + assert manager._ignore(mock_event) is False + + def test_ignore_mutter_events(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._ignore for mutter-x11-frames events.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + manager._active = True + manager._paused = False + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = "object:test-event" + mock_event.source = test_context.Mock() + mock_event.detail1 = 0 + mock_event.detail2 = 0 + mock_event.any_data = test_context.Mock() + mock_app = test_context.Mock() + original_hash = hash + + def mock_hash(obj): + if obj is mock_app: + return 12345 + return original_hash(obj) + + test_context.patch("builtins.hash", new=mock_hash) + focus_mgr = essential_modules["focus_manager_instance"] + focus_mgr.get_locus_of_focus.return_value = None + + test_context.patch("time.time", return_value=100.0) + test_context.patch("cthulhu.event_manager.AXUtilities.is_window", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_frame", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_text", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_notification", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_alert", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_selected", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_focused", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_section", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.manages_descendants", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.get_application", return_value=mock_app) + test_context.patch("cthulhu.event_manager.AXObject.get_name", return_value="mutter-x11-frames") + test_context.patch("cthulhu.event_manager.AXObject.get_attribute", return_value=None) + test_context.patch("cthulhu.event_manager.focus_manager.get_manager", return_value=focus_mgr) + manager._event_history = {} + assert manager._ignore(mock_event) is True + + @pytest.mark.parametrize( + "case", + [ + { + "id": "window_event", + "event_type": "window:activate", + "is_frame": False, + "is_dialog_or_alert": False, + "detail1": 0, + "expected_priority": 2, + }, + { + "id": "active_frame", + "event_type": "object:state-changed:active", + "is_frame": True, + "is_dialog_or_alert": False, + "detail1": 0, + "expected_priority": 2, + }, + { + "id": "active_dialog", + "event_type": "object:state-changed:active", + "is_frame": False, + "is_dialog_or_alert": True, + "detail1": 0, + "expected_priority": 2, + }, + { + "id": "focused_event", + "event_type": "object:state-changed:focused", + "is_frame": False, + "is_dialog_or_alert": False, + "detail1": 0, + "expected_priority": 3, + }, + { + "id": "active_descendant", + "event_type": "object:active-descendant-changed", + "is_frame": False, + "is_dialog_or_alert": False, + "detail1": 0, + "expected_priority": 3, + }, + { + "id": "assertive_announcement", + "event_type": "object:announcement", + "is_frame": False, + "is_dialog_or_alert": False, + "detail1": 2, + "expected_priority": 2, + }, + { + "id": "polite_announcement", + "event_type": "object:announcement", + "is_frame": False, + "is_dialog_or_alert": False, + "detail1": 1, + "expected_priority": 3, + }, + { + "id": "other_announcement", + "event_type": "object:announcement", + "is_frame": False, + "is_dialog_or_alert": False, + "detail1": 3, + "expected_priority": 4, + }, + { + "id": "invalid_entry", + "event_type": "object:state-changed:invalid-entry", + "is_frame": False, + "is_dialog_or_alert": False, + "detail1": 0, + "expected_priority": 5, + }, + { + "id": "children_changed", + "event_type": "object:children-changed:add", + "is_frame": False, + "is_dialog_or_alert": False, + "detail1": 0, + "expected_priority": 6, + }, + { + "id": "default_normal", + "event_type": "object:text-changed:insert", + "is_frame": False, + "is_dialog_or_alert": False, + "detail1": 0, + "expected_priority": 4, + }, + ], + ids=lambda case: case["id"], + ) + def test_get_priority_various_event_types( + self, + test_context: CthulhuTestContext, + case: dict, + ) -> None: + """Test EventManager._get_priority with various event types and conditions.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = case["event_type"] + mock_event.detail1 = case["detail1"] + mock_event.source = test_context.Mock() + + test_context.patch("cthulhu.event_manager.AXUtilities.is_frame", return_value=case["is_frame"]) + test_context.patch( + "cthulhu.event_manager.AXUtilities.is_dialog_or_alert", + return_value=case["is_dialog_or_alert"], + ) + + result = manager._get_priority(mock_event) + assert result == case["expected_priority"] + essential_modules["cthulhu.debug"].print_tokens.assert_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "notification_not_ignored", + "source_role": "notification", + "event_type": "object:text-changed:insert", + "source_is_focused": False, + "manages_descendants": False, + "detail1": 0, + "expected_ignore": False, + }, + { + "id": "alert_not_ignored", + "source_role": "alert", + "event_type": "object:text-changed:insert", + "source_is_focused": False, + "manages_descendants": False, + "detail1": 0, + "expected_ignore": False, + }, + { + "id": "large_text_insertion_ignored", + "source_role": "text", + "event_type": "object:text-changed:insert", + "source_is_focused": False, + "manages_descendants": False, + "detail1": 5001, + "expected_ignore": True, + }, + { + "id": "small_text_insertion_not_ignored", + "source_role": "text", + "event_type": "object:text-changed:insert", + "source_is_focused": False, + "manages_descendants": False, + "detail1": 4999, + "expected_ignore": False, + }, + { + "id": "focused_source_not_ignored", + "source_role": "push_button", + "event_type": "object:state-changed:focused", + "source_is_focused": True, + "manages_descendants": False, + "detail1": 0, + "expected_ignore": False, + }, + { + "id": "focused_managing_descendants_with_detail1", + "source_role": "push_button", + "event_type": "object:state-changed:focused", + "source_is_focused": True, + "manages_descendants": True, + "detail1": 1, + "expected_ignore": False, + }, + { + "id": "window_focused_event_ignored", + "source_role": "window", + "event_type": "object:state-changed:focused", + "source_is_focused": False, + "manages_descendants": False, + "detail1": 0, + "expected_ignore": True, + }, + ], + ids=lambda case: case["id"], + ) + def test_ignore_event_conditions( + self, + test_context: CthulhuTestContext, + case: dict, + ) -> None: + """Test EventManager._ignore with various event conditions.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + manager._active = True + manager._paused = False + manager._event_history = {} + + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = case["event_type"] + mock_event.detail1 = case["detail1"] + mock_event.detail2 = ( + 0 if case["event_type"] != "object:text-changed:insert" else case["detail1"] + ) + mock_event.source = test_context.Mock() + mock_event.any_data = test_context.Mock() + + mock_app = test_context.Mock() + focus_mgr = essential_modules["focus_manager_instance"] + focus_mgr.get_locus_of_focus.return_value = None + + test_context.patch("time.time", return_value=100.0) + test_context.patch( + "cthulhu.event_manager.AXUtilities.is_window", + side_effect=lambda obj: ( + case["source_role"] == "window" if obj == mock_event.source else False + ), + ) + test_context.patch("cthulhu.event_manager.AXUtilities.is_frame", return_value=False) + test_context.patch( + "cthulhu.event_manager.AXUtilities.is_text", + side_effect=lambda obj: ( + case["source_role"] == "text" if obj == mock_event.source else False + ), + ) + test_context.patch( + "cthulhu.event_manager.AXUtilities.is_notification", + side_effect=lambda obj: case["source_role"] == "notification" + if obj == mock_event.source + else False, + ) + test_context.patch( + "cthulhu.event_manager.AXUtilities.is_alert", + side_effect=lambda obj: ( + case["source_role"] == "alert" if obj == mock_event.source else False + ), + ) + test_context.patch("cthulhu.event_manager.AXUtilities.is_selected", return_value=False) + test_context.patch( + "cthulhu.event_manager.AXUtilities.is_focused", + side_effect=lambda obj: ( + case["source_is_focused"] if obj == mock_event.source else False + ), + ) + test_context.patch("cthulhu.event_manager.AXUtilities.is_section", return_value=False) + test_context.patch( + "cthulhu.event_manager.AXUtilities.manages_descendants", + side_effect=lambda obj: ( + case["manages_descendants"] if obj == mock_event.source else False + ), + ) + test_context.patch("cthulhu.event_manager.AXUtilities.get_application", return_value=mock_app) + test_context.patch("cthulhu.event_manager.AXObject.get_name", return_value="regular-app") + + result = manager._ignore(mock_event) + assert result is case["expected_ignore"] + + def test_ignore_live_region_text_insertion(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._ignore for live region text insertions.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + manager._active = True + manager._paused = False + manager._event_history = {} + + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = "object:text-changed:insert" + mock_event.detail1 = 0 + mock_event.detail2 = 100 + mock_event.source = test_context.Mock() + mock_event.any_data = test_context.Mock() + + mock_app = test_context.Mock() + focus_mgr = essential_modules["focus_manager_instance"] + focus_mgr.get_locus_of_focus.return_value = None + + test_context.patch("time.time", return_value=100.0) + test_context.patch("cthulhu.event_manager.AXUtilities.is_window", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_frame", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_text", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_notification", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_alert", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_selected", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_focused", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_section", return_value=True) + test_context.patch("cthulhu.event_manager.AXUtilities.manages_descendants", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.get_application", return_value=mock_app) + test_context.patch("cthulhu.event_manager.AXObject.get_name", return_value="regular-app") + test_context.patch( + "cthulhu.event_manager.AXObject.get_attribute", + side_effect=lambda obj, attr: "polite" if attr == "live" else None, + ) + + result = manager._ignore(mock_event) + assert result is False + + def test_ignore_event_history_filtering(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._ignore with event history spam filtering.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + manager._active = True + manager._paused = False + manager._event_history = {} + + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = "object:test-event" + mock_event.detail1 = 0 + mock_event.detail2 = 0 + mock_event.source = test_context.Mock() + mock_event.any_data = test_context.Mock() + + mock_app = test_context.Mock() + focus_mgr = essential_modules["focus_manager_instance"] + focus_mgr.get_locus_of_focus.return_value = None + + original_hash = hash + + def mock_hash(obj): + if obj is mock_app: + return 12345 + return original_hash(obj) + + test_context.patch("builtins.hash", new=mock_hash) + test_context.patch("time.time", return_value=100.0) + test_context.patch("cthulhu.event_manager.AXUtilities.is_window", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_frame", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_text", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_notification", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_alert", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_selected", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_focused", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_section", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.manages_descendants", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.get_application", return_value=mock_app) + test_context.patch("cthulhu.event_manager.AXObject.get_name", return_value="regular-app") + test_context.patch("cthulhu.event_manager.AXObject.get_attribute", return_value=None) + + # First call should not be ignored + result1 = manager._ignore(mock_event) + assert result1 is False + assert manager._event_history["object:test-event"] == (12345, 100.0) + + # Second call within 0.1s should be ignored + test_context.patch("time.time", return_value=100.05) + result2 = manager._ignore(mock_event) + assert result2 is True + + def test_is_obsoleted_by_identical_events(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._is_obsoleted_by with identical events in queue.""" + + self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = "object:test-event" + mock_event.source = test_context.Mock() + mock_event.detail1 = 1 + mock_event.detail2 = 2 + mock_event.any_data = "test_data" + + identical_event = test_context.Mock(spec=Atspi.Event) + identical_event.type = "object:test-event" + identical_event.source = mock_event.source + identical_event.detail1 = 1 + identical_event.detail2 = 2 + identical_event.any_data = "test_data" + + queue_data = [(4, 1, identical_event)] + with manager._event_queue.mutex: + manager._event_queue.queue = queue_data + + result = manager._is_obsoleted_by(mock_event) + assert result == identical_event + + @pytest.mark.parametrize( + "case", + [ + { + "id": "active_descendant_obsoletes", + "existing_type": "object:active-descendant-changed", + "existing_source_same": True, + "new_type": "object:active-descendant-changed", + "should_obsolete": True, + }, + { + "id": "state_changed_obsoletes", + "existing_type": "object:state-changed:focused", + "existing_source_same": True, + "new_type": "object:state-changed:focused", + "should_obsolete": True, + }, + { + "id": "caret_moved_obsoletes", + "existing_type": "object:text-caret-moved", + "existing_source_same": True, + "new_type": "object:text-caret-moved", + "should_obsolete": True, + }, + { + "id": "window_activate_obsoletes", + "existing_type": "window:activate", + "existing_source_same": True, + "new_type": "window:activate", + "should_obsolete": True, + }, + { + "id": "non_skippable_does_not_obsolete", + "existing_type": "object:other-event", + "existing_source_same": True, + "new_type": "object:other-event", + "should_obsolete": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_is_obsoleted_by_same_type_and_object( + self, + test_context: CthulhuTestContext, + case: dict, + ) -> None: + """Test EventManager._is_obsoleted_by with same type and object conditions.""" + + self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + mock_source = test_context.Mock() + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = case["new_type"] + mock_event.source = mock_source + mock_event.detail1 = 0 + mock_event.detail2 = 0 + mock_event.any_data = None + + existing_event = test_context.Mock(spec=Atspi.Event) + existing_event.type = case["existing_type"] + existing_event.source = mock_source if case["existing_source_same"] else test_context.Mock() + existing_event.detail1 = ( + 0 if case["should_obsolete"] else 1 + ) # Make it different for non-obsoleting case + existing_event.detail2 = 0 + existing_event.any_data = None + + queue_data = [(4, 1, existing_event)] + with manager._event_queue.mutex: + manager._event_queue.queue = queue_data + + result = manager._is_obsoleted_by(mock_event) + if case["should_obsolete"]: + assert result == existing_event + else: + assert result is None + + def test_is_obsoleted_by_sibling_events(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._is_obsoleted_by with sibling event conditions.""" + + self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + parent_mock = test_context.Mock() + mock_source = test_context.Mock() + sibling_source = test_context.Mock() + + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = "object:state-changed:focused" + mock_event.source = mock_source + mock_event.detail1 = 1 + mock_event.detail2 = 2 + mock_event.any_data = "test_data" + + sibling_event = test_context.Mock(spec=Atspi.Event) + sibling_event.type = "object:state-changed:focused" + sibling_event.source = sibling_source + sibling_event.detail1 = 1 + sibling_event.detail2 = 2 + sibling_event.any_data = "test_data" + + test_context.patch( + "cthulhu.event_manager.AXObject.get_parent", + side_effect=lambda obj: parent_mock + if obj in [mock_source, sibling_source] + else test_context.Mock(), + ) + + queue_data = [(4, 1, sibling_event)] + with manager._event_queue.mutex: + manager._event_queue.queue = queue_data + + result = manager._is_obsoleted_by(mock_event) + assert result == sibling_event + + def test_is_obsoleted_by_window_events(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._is_obsoleted_by with window event conditions.""" + + self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + mock_source = test_context.Mock() + + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = "window:activate" + mock_event.source = mock_source + mock_event.detail1 = 0 + mock_event.detail2 = 0 + mock_event.any_data = None + + existing_event = test_context.Mock(spec=Atspi.Event) + existing_event.type = "window:activate" + existing_event.source = mock_source + existing_event.detail1 = 0 + existing_event.detail2 = 0 + existing_event.any_data = None + + queue_data = [(4, 1, existing_event)] + with manager._event_queue.mutex: + manager._event_queue.queue = queue_data + + result = manager._is_obsoleted_by(mock_event) + assert result == existing_event + + def test_focus_conditions_in_ignore(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._ignore focus-related conditions.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + manager._active = True + manager._paused = False + manager._event_history = {} + + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = "object:test-event" + mock_event.detail1 = 0 + mock_event.detail2 = 0 + mock_event.source = test_context.Mock() + mock_event.any_data = test_context.Mock() + + focus_mgr = essential_modules["focus_manager_instance"] + + focus_mgr.get_locus_of_focus.return_value = mock_event.source + test_context.patch("cthulhu.event_manager.AXUtilities.is_window", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_frame", return_value=False) + + result = manager._ignore(mock_event) + assert result is False + + focus_mgr.get_locus_of_focus.return_value = mock_event.any_data + result = manager._ignore(mock_event) + assert result is False + + def test_ignore_selected_source_not_ignored(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._ignore does not ignore events from selected sources.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + manager._active = True + manager._paused = False + manager._event_history = {} + + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = "object:test-event" + mock_event.detail1 = 0 + mock_event.detail2 = 0 + mock_event.source = test_context.Mock() + mock_event.any_data = test_context.Mock() + + mock_app = test_context.Mock() + focus_mgr = essential_modules["focus_manager_instance"] + focus_mgr.get_locus_of_focus.return_value = None + + test_context.patch("time.time", return_value=100.0) + test_context.patch("cthulhu.event_manager.AXUtilities.is_window", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_frame", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_text", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_notification", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_alert", return_value=False) + test_context.patch( + "cthulhu.event_manager.AXUtilities.is_selected", + return_value=True, # Selected source should not be ignored + ) + test_context.patch("cthulhu.event_manager.AXUtilities.is_focused", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.is_section", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.manages_descendants", return_value=False) + test_context.patch("cthulhu.event_manager.AXUtilities.get_application", return_value=mock_app) + test_context.patch("cthulhu.event_manager.AXObject.get_name", return_value="regular-app") + test_context.patch("cthulhu.event_manager.AXObject.get_attribute", return_value=None) + + result = manager._ignore(mock_event) + assert result is False + + def test_queue_println(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._queue_println.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = "test:event" + debug_mock = essential_modules["cthulhu.debug"] + debug_mock.debugLevel = 10 + manager._queue_println(mock_event) + manager._queue_println(mock_event, is_enqueue=False) + + def test_enqueue_object_event_ignored(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._enqueue_object_event for ignored events.""" + + self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + manager._active = False + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = "object:text-changed:insert" + manager._enqueue_object_event(mock_event) + assert manager._event_queue.empty() + + def test_on_no_focus(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._on_no_focus.""" + + self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + mock_get_focus_mgr = test_context.Mock() + test_context.patch("cthulhu.event_manager.focus_manager.get_manager", new=mock_get_focus_mgr) + mock_get_script_mgr = test_context.Mock() + test_context.patch("cthulhu.event_manager.script_manager.get_manager", new=mock_get_script_mgr) + mock_braille_presenter = test_context.Mock() + mock_presenter_instance = test_context.Mock() + mock_braille_presenter.get_presenter.return_value = mock_presenter_instance + test_context.patch("cthulhu.event_manager.braille_presenter", new=mock_braille_presenter) + mock_focus_mgr = test_context.Mock() + mock_script_mgr = test_context.Mock() + mock_get_focus_mgr.return_value = mock_focus_mgr + mock_get_script_mgr.return_value = mock_script_mgr + + mock_focus_mgr.focus_and_window_are_unknown.return_value = True + result = manager._on_no_focus() + assert result is False + + mock_focus_mgr.focus_and_window_are_unknown.return_value = False + mock_script_mgr.get_active_script.return_value = None + result = manager._on_no_focus() + assert result is False + mock_script_mgr.set_active_script.assert_called_once() + mock_presenter_instance.disable_braille.assert_called_once() + + def test_dequeue_object_event_empty_queue(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._dequeue_object_event with empty queue.""" + + self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + result = manager._dequeue_object_event() + assert result is False + assert manager._gidle_id == 0 + + @pytest.mark.parametrize( + "case", + [ + { + "id": "first_registration", + "operation": "register", + "initial_count": 0, + "expected_count": 1, + "should_call_listener": True, + }, + { + "id": "duplicate_registration", + "operation": "register", + "initial_count": 1, + "expected_count": 2, + "should_call_listener": False, + }, + { + "id": "partial_deregistration", + "operation": "deregister", + "initial_count": 2, + "expected_count": 1, + "should_call_listener": False, + }, + { + "id": "final_deregistration", + "operation": "deregister", + "initial_count": 1, + "expected_count": 0, + "should_call_listener": True, + }, + ], + ids=lambda case: case["id"], + ) + def test_listener_registration( + self, + test_context: CthulhuTestContext, + case: dict, + ) -> None: + """Test EventManager register/deregister listener operations.""" + self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + manager._listener = test_context.Mock() + event_type = "object:text-changed:insert" + + if case["initial_count"] > 0: + manager._script_listener_counts[event_type] = case["initial_count"] + + if case["operation"] == "register": + manager.register_listener(event_type) + if case["should_call_listener"]: + manager._listener.register.assert_called_once_with(event_type) + else: + manager._listener.register.assert_not_called() + else: # deregister + manager.deregister_listener(event_type) + if case["should_call_listener"]: + manager._listener.deregister.assert_called_once_with(event_type) + else: + manager._listener.deregister.assert_not_called() + + if case["expected_count"] == 0: + assert event_type not in manager._script_listener_counts + else: + assert manager._script_listener_counts[event_type] == case["expected_count"] + + @pytest.mark.parametrize( + "case", + [ + {"id": "register_script_listeners", "operation": "register"}, + {"id": "deregister_script_listeners", "operation": "deregister"}, + ], + ids=lambda case: case["id"], + ) + def test_script_listeners_operations(self, test_context: CthulhuTestContext, case: dict) -> None: + """Test EventManager register/deregister script listeners operations.""" + self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + mock_listener_method = test_context.Mock() + method_name = f"{case['operation']}_listener" + test_context.patch_object(manager, method_name, new=mock_listener_method) + + mock_script = test_context.Mock() + mock_script.listeners = { + "object:text-changed:insert": test_context.Mock(), + "object:state-changed:focused": test_context.Mock(), + } + + getattr(manager, f"{case['operation']}_script_listeners")(mock_script) + + expected_calls = [ + call("object:text-changed:insert"), + call("object:state-changed:focused"), + ] + mock_listener_method.assert_has_calls(expected_calls, any_order=True) + + def test_get_script_for_event_focus(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._get_script_for_event for focused events.""" + + self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.source = test_context.Mock() + mock_event.type = "object:state-changed:focused" + active_script = test_context.Mock() + mock_get_focus_mgr = test_context.Mock() + test_context.patch("cthulhu.event_manager.focus_manager.get_manager", new=mock_get_focus_mgr) + mock_focus_mgr = test_context.Mock() + mock_get_focus_mgr.return_value = mock_focus_mgr + mock_focus_mgr.get_locus_of_focus.return_value = mock_event.source + result = manager._get_script_for_event(mock_event, active_script) + assert result == active_script + + def test_get_script_for_event_mouse(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._get_script_for_event for mouse events.""" + + self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = "mouse:button:1p" + mock_event.source = test_context.Mock() + expected_script = test_context.Mock() + mock_mouse_event = test_context.Mock() + mock_mouse_event.app = test_context.Mock() + mock_mouse_event.window = test_context.Mock() + mock_get_script_mgr = test_context.Mock() + test_context.patch("cthulhu.event_manager.script_manager.get_manager", new=mock_get_script_mgr) + test_context.patch( + "cthulhu.event_manager.input_event.MouseButtonEvent", + return_value=mock_mouse_event, + ) + mock_script_mgr = test_context.Mock() + mock_get_script_mgr.return_value = mock_script_mgr + mock_script_mgr.get_script.return_value = expected_script + result = manager._get_script_for_event(mock_event) + assert result == expected_script + + def test_get_script_for_event_defunct_app(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._get_script_for_event with defunct application.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = "object:text-changed:insert" + mock_event.source = test_context.Mock() + mock_app = test_context.Mock() + ax_utilities = essential_modules["cthulhu.ax_utilities"] + ax_utilities.get_application.return_value = mock_app + ax_utilities.is_defunct.return_value = True + result = manager._get_script_for_event(mock_event) + assert result is None + + @pytest.mark.parametrize( + "case", + [ + { + "id": "focus_claimed", + "event_type": "object:state-changed:focused", + "detail1": 1, + "expected": True, + "reason_contains": "claimed focus", + }, + { + "id": "menu_selection", + "event_type": "object:state-changed:selected", + "detail1": 1, + "expected": True, + "reason_contains": "Selection change", + }, + { + "id": "modal_panel", + "event_type": "object:state-changed:showing", + "detail1": 0, + "expected": True, + "reason_contains": "Modal panel", + }, + { + "id": "no_activation", + "event_type": "object:text-changed:insert", + "detail1": 0, + "expected": False, + "reason_contains": "No reason", + }, + ], + ids=lambda case: case["id"], + ) + def test_is_activatable_event(self, test_context: CthulhuTestContext, case: dict) -> None: + """Test EventManager._is_activatable_event.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = case["event_type"] + mock_event.detail1 = case["detail1"] + mock_event.source = test_context.Mock() + mock_script = test_context.Mock() + mock_script.is_activatable_event.return_value = True + mock_script.force_script_activation.return_value = False + ax_utilities = essential_modules["cthulhu.ax_utilities"] + if case["event_type"] == "object:state-changed:selected": + ax_utilities.is_menu.return_value = True + ax_utilities.is_focusable.return_value = True + elif case["event_type"] == "object:state-changed:showing": + ax_utilities.is_panel.return_value = True + ax_utilities.is_modal.return_value = True + else: + ax_utilities.is_frame.return_value = case["event_type"] == "object:state-changed:active" + focus_mgr = essential_modules["focus_manager_instance"] + if case["event_type"] == "window:activate": + focus_mgr.get_active_window.return_value = mock_event.source + else: + focus_mgr.get_active_window.return_value = test_context.Mock() + result, reason = manager._is_activatable_event(mock_event, mock_script) + assert result == case["expected"] + assert case["reason_contains"].lower() in reason.lower() + + def test_is_activatable_event_no_source(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._is_activatable_event with no event source.""" + + self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.source = None + result, reason = manager._is_activatable_event(mock_event) + assert result is False + assert "event.source" in reason + + def test_is_activatable_event_script_forces(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._is_activatable_event when script forces activation.""" + + self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = "object:text-changed:insert" + mock_event.source = test_context.Mock() + mock_script = test_context.Mock() + mock_script.is_activatable_event.return_value = True + mock_script.force_script_activation.return_value = True + result, reason = manager._is_activatable_event(mock_event, mock_script) + assert result is True + assert "insists" in reason + + @pytest.mark.parametrize( + "case", + [ + { + "id": "active_script", + "event_script_is_active": True, + "present_if_inactive": False, + "event_type": "test:event", + "is_progress_bar": False, + "expected": True, + }, + { + "id": "inactive_but_presents", + "event_script_is_active": False, + "present_if_inactive": True, + "event_type": "test:event", + "is_progress_bar": False, + "expected": True, + }, + { + "id": "progress_bar_value_change", + "event_script_is_active": False, + "present_if_inactive": False, + "event_type": "object:property-change:accessible-value", + "is_progress_bar": True, + "expected": True, + }, + { + "id": "progress_bar_non_value_event", + "event_script_is_active": False, + "present_if_inactive": False, + "event_type": "object:state-changed:focused", + "is_progress_bar": True, + "expected": False, + }, + { + "id": "no_reason_to_process", + "event_script_is_active": False, + "present_if_inactive": False, + "event_type": "test:event", + "is_progress_bar": False, + "expected": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_should_process_event( + self, + test_context: CthulhuTestContext, + case: dict, + ) -> None: + """Test EventManager._should_process_event.""" + + self._setup_dependencies(test_context) + from cthulhu.ax_utilities import AXUtilities + from cthulhu.event_manager import EventManager + + manager = EventManager() + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = case["event_type"] + mock_event.source = test_context.Mock() + event_script = test_context.Mock() + event_script.present_if_inactive = case["present_if_inactive"] + test_context.patch_object( + AXUtilities, + "is_progress_bar", + side_effect=lambda obj: case["is_progress_bar"], + ) + if case["event_script_is_active"]: + active_script = event_script + else: + active_script = test_context.Mock() + result = manager._should_process_event(mock_event, event_script, active_script) + assert result == case["expected"] + + def test_process_object_event_obsoleted(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._process_object_event with obsoleted event.""" + + self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + mock_event = test_context.Mock(spec=Atspi.Event) + mock_is_obsoleted = test_context.Mock(return_value=test_context.Mock()) + test_context.patch_object(manager, "_is_obsoleted_by", new=mock_is_obsoleted) + manager._process_object_event(mock_event) + + def test_process_object_event_dead_source(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._process_object_event with dead event source.""" + + self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = "window:deactivate" + mock_event.source = test_context.Mock() + mock_is_obsoleted = test_context.Mock(return_value=None) + test_context.patch_object(manager, "_is_obsoleted_by", new=mock_is_obsoleted) + test_context.patch("cthulhu.event_manager.AXObject.is_dead", return_value=True) + test_context.patch("cthulhu.event_manager.AXUtilities.is_defunct", return_value=False) + mock_get_focus_mgr = test_context.Mock() + test_context.patch("cthulhu.event_manager.focus_manager.get_manager", new=mock_get_focus_mgr) + mock_get_script_mgr = test_context.Mock() + test_context.patch("cthulhu.event_manager.script_manager.get_manager", new=mock_get_script_mgr) + mock_focus_mgr = test_context.Mock() + mock_script_mgr = test_context.Mock() + mock_get_focus_mgr.return_value = mock_focus_mgr + mock_get_script_mgr.return_value = mock_script_mgr + mock_focus_mgr.get_active_window.return_value = mock_event.source + manager._process_object_event(mock_event) + + mock_focus_mgr.clear_state.assert_called_once() + mock_script_mgr.set_active_script.assert_called_once_with( + None, + "Active window is dead or defunct", + ) + + def test_process_object_event_no_listener(self, test_context: CthulhuTestContext) -> None: + """Test EventManager._process_object_event with no matching listener.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = "object:unknown-event" + mock_event.source = test_context.Mock() + mock_is_obsoleted = test_context.Mock(return_value=None) + test_context.patch_object(manager, "_is_obsoleted_by", new=mock_is_obsoleted) + ax_object = essential_modules["cthulhu.ax_object"] + ax_utilities = essential_modules["cthulhu.ax_utilities"] + ax_object.is_dead.return_value = False + ax_utilities.is_defunct.return_value = False + ax_utilities.is_iconified.return_value = False + mock_script = test_context.Mock() + mock_script.listeners = {} + script_mgr = essential_modules["script_manager_instance"] + script_mgr.get_active_script.return_value = mock_script + mock_get_script = test_context.Mock(return_value=mock_script) + mock_is_activatable = test_context.Mock(return_value=(False, "test")) + mock_should_process = test_context.Mock(return_value=True) + test_context.patch_object(manager, "_get_script_for_event", new=mock_get_script) + test_context.patch_object(manager, "_is_activatable_event", new=mock_is_activatable) + test_context.patch_object(manager, "_should_process_event", new=mock_should_process) + manager._process_object_event(mock_event) + + def test_get_manager(self, test_context: CthulhuTestContext) -> None: + """Test event_manager.get_manager.""" + + self._setup_dependencies(test_context) + from cthulhu import event_manager + + manager1 = event_manager.get_manager() + manager2 = event_manager.get_manager() + + assert manager1 is manager2 + assert isinstance(manager1, event_manager.EventManager) + + def test_enqueue_with_comprehensive_gidle_management( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test EventManager._enqueue_object_event with comprehensive GLib idle management.""" + + self._setup_dependencies(test_context) + from cthulhu.event_manager import EventManager + + manager = EventManager() + manager._active = True + manager._paused = False + manager._gidle_id = 0 # No existing idle handler + mock_event = test_context.Mock(spec=Atspi.Event) + mock_event.type = "object:text-changed:insert" + mock_event.source = test_context.Mock() + mock_app = test_context.Mock() + mock_script = test_context.Mock() + mock_script.event_cache = {} + + mock_ignore = test_context.Mock(return_value=False) + test_context.patch_object(manager, "_ignore", new=mock_ignore) + test_context.patch("cthulhu.event_manager.AXUtilities.get_application", return_value=mock_app) + mock_get_script_mgr = test_context.Mock() + test_context.patch("cthulhu.event_manager.script_manager.get_manager", new=mock_get_script_mgr) + mock_idle_add = test_context.Mock(return_value=456) + test_context.patch("cthulhu.event_manager.GLib.idle_add", new=mock_idle_add) + test_context.patch("time.time", return_value=200.0) + mock_script_mgr = test_context.Mock() + mock_get_script_mgr.return_value = mock_script_mgr + mock_script_mgr.get_script.return_value = mock_script + manager._enqueue_object_event(mock_event) + + assert not manager._event_queue.empty() + assert mock_event.type in mock_script.event_cache + mock_idle_add.assert_called_once_with(manager._dequeue_object_event) + assert manager._gidle_id == 456 + + mock_idle_add.reset_mock() + manager._gidle_id = 456 + test_context.patch("time.time", return_value=200.1) + manager._enqueue_object_event(mock_event) + + mock_idle_add.assert_not_called() + assert manager._gidle_id == 456 diff --git a/tests/test_event_manager_compositor_context_regressions.py b/tests/test_event_manager_compositor_context_regressions.py deleted file mode 100644 index e4f32c6..0000000 --- a/tests/test_event_manager_compositor_context_regressions.py +++ /dev/null @@ -1,209 +0,0 @@ -import sys -import types -import unittest -from pathlib import Path -from unittest import mock - -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) - -from cthulhu import cthulhu_state -from cthulhu import compositor_state_types - -stubCthulhu = types.ModuleType("cthulhu.cthulhu") -stubCthulhu.cthulhuApp = mock.Mock() -sys.modules.setdefault("cthulhu.cthulhu", stubCthulhu) - -from cthulhu import event_manager - - -class FakeEvent: - def __init__(self, event_type, source="same", detail1=0, detail2=0, any_data=None): - self.type = event_type - self.source = source - self.detail1 = detail1 - self.detail2 = detail2 - self.any_data = any_data - - -class EventManagerCompositorContextRegressionTests(unittest.TestCase): - def setUp(self) -> None: - self.originalPauseAtspiChurn = cthulhu_state.pauseAtspiChurn - self.originalPrioritizedDesktopContextToken = cthulhu_state.prioritizedDesktopContextToken - self.originalCompositorSnapshot = cthulhu_state.compositorSnapshot - self.originalActiveScript = cthulhu_state.activeScript - self.addCleanup(self._restore_cthulhu_state) - - self.listener = mock.Mock() - self.listenerPatch = mock.patch.object( - event_manager.Atspi.EventListener, - "new", - return_value=self.listener, - ) - self.listenerPatch.start() - self.addCleanup(self.listenerPatch.stop) - - self.manager = event_manager.EventManager(mock.Mock(), asyncMode=False) - self.manager._active = True - self.manager._context_token_for_event = mock.Mock(side_effect=lambda event: event.source) - cthulhu_state.pauseAtspiChurn = False - cthulhu_state.prioritizedDesktopContextToken = None - cthulhu_state.compositorSnapshot = None - cthulhu_state.activeScript = None - - def _restore_cthulhu_state(self) -> None: - cthulhu_state.pauseAtspiChurn = self.originalPauseAtspiChurn - cthulhu_state.prioritizedDesktopContextToken = self.originalPrioritizedDesktopContextToken - cthulhu_state.compositorSnapshot = self.originalCompositorSnapshot - cthulhu_state.activeScript = self.originalActiveScript - - def test_set_compositor_state_adapter_registers_compositor_listener(self) -> None: - adapter = mock.Mock() - - self.manager.set_compositor_state_adapter(adapter) - - adapter.add_listener.assert_called_once_with(self.manager._handle_compositor_signal) - - def test_pause_signal_updates_churn_state_and_resume_clears_it(self) -> None: - snapshot = compositor_state_types.DesktopContextSnapshot(session_type="wayland") - - self.manager._handle_compositor_signal( - compositor_state_types.CompositorStateEvent( - compositor_state_types.PAUSE_ATSPI_CHURN, - reason="workspace-transition", - snapshot=snapshot, - payload={"context_token": "current"}, - ) - ) - - self.assertTrue(cthulhu_state.pauseAtspiChurn) - self.assertTrue(self.manager._churnSuppressed) - - self.manager._handle_compositor_signal( - compositor_state_types.CompositorStateEvent( - compositor_state_types.RESUME_ATSPI_CHURN, - reason="workspace-transition", - snapshot=snapshot, - payload={"context_token": "current"}, - ) - ) - - self.assertFalse(cthulhu_state.pauseAtspiChurn) - self.assertFalse(self.manager._churnSuppressed) - - def test_stale_context_event_is_obsolete_while_churn_is_paused(self) -> None: - self.manager._churnSuppressed = True - self.manager._prioritizedContextToken = "current" - event = FakeEvent("object:children-changed:add", source="stale") - - self.assertTrue(self.manager._is_obsolete_by_context(event)) - - def test_flush_signal_removes_stale_events_from_queue(self) -> None: - self.manager._churnSuppressed = False - self.manager._prioritizedContextToken = "current" - staleEvent = FakeEvent("object:children-changed:add", source="stale") - currentEvent = FakeEvent("object:children-changed:add", source="current") - self.manager._eventQueue.put(staleEvent) - self.manager._eventQueue.put(currentEvent) - - self.manager._handle_compositor_signal( - compositor_state_types.CompositorStateEvent( - compositor_state_types.FLUSH_STALE_ATSPI_EVENTS, - reason="resume", - snapshot=compositor_state_types.DesktopContextSnapshot(session_type="wayland"), - payload={"context_token": "current"}, - ) - ) - - self.assertEqual(list(self.manager._eventQueue.queue), [currentEvent]) - - def test_stale_background_event_does_not_activate_script_during_suppression(self) -> None: - script = mock.Mock() - script.isActivatableEvent.return_value = True - script.forceScriptActivation.return_value = False - self.manager._churnSuppressed = True - self.manager._prioritizedContextToken = "current" - - event = FakeEvent("object:state-changed:showing", source="old") - - result, reason = self.manager._isActivatableEvent(event, script) - - self.assertFalse(result) - self.assertIn("compositor-prioritized context", reason) - - def test_focus_event_syncs_accessible_context_back_into_adapter(self) -> None: - adapter = mock.Mock() - script = mock.Mock() - source = object() - event = FakeEvent("object:state-changed:focused", source=source, detail1=1) - self.manager._compositorStateAdapter = adapter - self.manager._get_scriptForEvent = mock.Mock(return_value=script) - self.manager._isActivatableEvent = mock.Mock(return_value=(False, "already active")) - self.manager._inFlood = mock.Mock(return_value=False) - - with ( - mock.patch.object(event_manager.debug, "printObjectEvent"), - mock.patch.object(event_manager.debug, "printDetails"), - mock.patch.object(event_manager.debug, "printMessage"), - mock.patch.object(event_manager.debug, "printTokens"), - mock.patch.object(event_manager.AXUtilities, "get_desktop", return_value=object()), - mock.patch.object(event_manager.AXUtilities, "is_defunct", return_value=False), - mock.patch.object(event_manager.AXUtilities, "is_iconified", return_value=False), - mock.patch.object(event_manager.AXUtilities, "is_frame", return_value=False), - mock.patch.object(event_manager.AXObject, "is_dead", return_value=False), - ): - self.manager._processObjectEvent(event) - - adapter.sync_accessible_context.assert_called_once_with("object:state-changed:focused") - - def test_steam_children_changed_burst_is_suppressed_before_flood_threshold(self) -> None: - app = object() - cthulhu_state.activeScript = mock.Mock(app=app) - firstEvent = FakeEvent("object:children-changed:add", source="steam-context", any_data=object()) - secondEvent = FakeEvent("object:children-changed:add", source="steam-context", any_data=object()) - - with ( - mock.patch.object(event_manager.time, "monotonic", side_effect=[100.0, 100.05]), - mock.patch.object(event_manager.debug, "printMessage"), - mock.patch.object(event_manager.debug, "printTokens"), - mock.patch.object(event_manager.debug, "print_log"), - mock.patch.object(event_manager.AXObject, "get_application", return_value=app), - mock.patch.object(event_manager.AXObject, "get_name", return_value="steamwebhelper"), - mock.patch.object(event_manager.AXObject, "get_role", return_value=mock.Mock()), - mock.patch.object(event_manager.AXObject, "is_dead", return_value=False), - mock.patch.object(event_manager.AXUtilities, "has_no_state", return_value=False), - mock.patch.object(event_manager.AXUtilities, "is_defunct", return_value=False), - mock.patch.object(event_manager.AXUtilities, "manages_descendants", return_value=False), - mock.patch.object(event_manager.AXUtilities, "is_image", return_value=False), - mock.patch.object(event_manager.AXUtilities, "is_menu_item", return_value=False), - ): - self.manager._isSteamApp = mock.Mock(return_value=True) - self.manager._isSteamNotificationEvent = mock.Mock(return_value=False) - - self.assertFalse(self.manager._ignore(firstEvent)) - self.assertTrue(self.manager._ignore(secondEvent)) - - def test_steam_focus_lost_burst_is_ignored_but_focus_gain_is_preserved(self) -> None: - app = object() - cthulhu_state.activeScript = mock.Mock(app=app) - focusLost = FakeEvent("object:state-changed:focused", source="steam-context", detail1=0) - focusGained = FakeEvent("object:state-changed:focused", source="steam-context", detail1=1) - - with ( - mock.patch.object(event_manager.debug, "printMessage"), - mock.patch.object(event_manager.debug, "printTokens"), - mock.patch.object(event_manager.debug, "print_log"), - mock.patch.object(event_manager.AXObject, "get_application", return_value=app), - mock.patch.object(event_manager.AXObject, "get_name", return_value="steamwebhelper"), - mock.patch.object(event_manager.AXObject, "get_role", return_value=mock.Mock()), - mock.patch.object(event_manager.AXUtilities, "has_no_state", return_value=False), - mock.patch.object(event_manager.AXUtilities, "is_defunct", return_value=False), - ): - self.manager._isSteamApp = mock.Mock(return_value=True) - self.manager._isSteamNotificationEvent = mock.Mock(return_value=False) - - self.assertTrue(self.manager._ignore(focusLost)) - self.assertFalse(self.manager._ignore(focusGained)) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_event_manager_relevance_gate_regressions.py b/tests/test_event_manager_relevance_gate_regressions.py deleted file mode 100644 index dabf965..0000000 --- a/tests/test_event_manager_relevance_gate_regressions.py +++ /dev/null @@ -1,193 +0,0 @@ -import sys -import types -import unittest -from pathlib import Path -from unittest import mock - -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) - -from cthulhu import cthulhu_state - -stubCthulhu = types.ModuleType("cthulhu.cthulhu") -stubCthulhu.cthulhuApp = mock.Mock() -sys.modules.setdefault("cthulhu.cthulhu", stubCthulhu) - -from cthulhu import event_manager - - -class FakeEvent: - def __init__(self, event_type, source="same", detail1=0, detail2=0, any_data=None): - self.type = event_type - self.source = source - self.detail1 = detail1 - self.detail2 = detail2 - self.any_data = any_data - - -class EventManagerRelevanceGateRegressionTests(unittest.TestCase): - def setUp(self) -> None: - self.listener = mock.Mock() - self.listenerPatch = mock.patch.object( - event_manager.Atspi.EventListener, - "new", - return_value=self.listener, - ) - self.listenerPatch.start() - self.addCleanup(self.listenerPatch.stop) - - self.steamAppPatch = mock.patch.object(event_manager.EventManager, "_isSteamApp", return_value=False) - self.steamNotificationPatch = mock.patch.object( - event_manager.EventManager, - "_isSteamNotificationEvent", - return_value=False, - ) - self.steamAppPatch.start() - self.steamNotificationPatch.start() - self.addCleanup(self.steamAppPatch.stop) - self.addCleanup(self.steamNotificationPatch.stop) - - self.originalActiveScript = cthulhu_state.activeScript - self.originalLocusOfFocus = cthulhu_state.locusOfFocus - self.addCleanup(self._restore_state) - - self.manager = event_manager.EventManager(mock.Mock(), asyncMode=False) - self.manager._active = True - cthulhu_state.activeScript = None - cthulhu_state.locusOfFocus = None - - def _restore_state(self) -> None: - cthulhu_state.activeScript = self.originalActiveScript - cthulhu_state.locusOfFocus = self.originalLocusOfFocus - - def test_unfocused_web_link_name_change_is_dropped_when_focus_is_stable_elsewhere(self) -> None: - app = object() - focus = object() - source = object() - event = FakeEvent( - "object:property-change:accessible-name", - source=source, - any_data="Diablo II: Resurrected – Infernal Edition $39.99", - ) - cthulhu_state.activeScript = mock.Mock(app=app) - cthulhu_state.locusOfFocus = focus - - with ( - mock.patch.object(event_manager.debug, "printMessage"), - mock.patch.object(event_manager.debug, "printTokens"), - mock.patch.object(event_manager.debug, "print_log"), - mock.patch.object(event_manager.AXObject, "get_application", return_value=app), - mock.patch.object(event_manager.AXObject, "get_name", return_value="Chromium"), - mock.patch.object(event_manager.AXObject, "get_role", return_value=event_manager.Atspi.Role.LINK), - mock.patch.object(event_manager.AXObject, "is_dead", return_value=False), - mock.patch.object(event_manager.AXUtilities, "has_no_state", return_value=False), - mock.patch.object(event_manager.AXUtilities, "is_defunct", return_value=False), - mock.patch.object(event_manager.AXUtilities, "is_focused", return_value=False), - mock.patch.object(event_manager.AXUtilities, "is_notification", return_value=False), - mock.patch.object(event_manager.AXUtilities, "is_alert", return_value=False), - ): - self.assertTrue(self.manager._ignore(event)) - - def test_focused_web_link_name_change_is_kept(self) -> None: - app = object() - source = object() - event = FakeEvent("object:property-change:accessible-name", source=source, any_data="Cookie Clicker") - cthulhu_state.activeScript = mock.Mock(app=app) - cthulhu_state.locusOfFocus = source - - with ( - mock.patch.object(event_manager.debug, "printMessage"), - mock.patch.object(event_manager.debug, "printTokens"), - mock.patch.object(event_manager.debug, "print_log"), - mock.patch.object(event_manager.AXObject, "get_application", return_value=app), - mock.patch.object(event_manager.AXObject, "get_name", return_value="Chromium"), - mock.patch.object(event_manager.AXObject, "get_role", return_value=event_manager.Atspi.Role.LINK), - mock.patch.object(event_manager.AXObject, "is_dead", return_value=False), - mock.patch.object(event_manager.AXUtilities, "has_no_state", return_value=False), - mock.patch.object(event_manager.AXUtilities, "is_defunct", return_value=False), - mock.patch.object(event_manager.AXUtilities, "is_focused", return_value=True), - mock.patch.object(event_manager.AXUtilities, "is_notification", return_value=False), - mock.patch.object(event_manager.AXUtilities, "is_alert", return_value=False), - ): - self.assertFalse(self.manager._ignore(event)) - - def test_live_region_name_change_is_kept_even_when_unfocused(self) -> None: - app = object() - focus = object() - source = object() - event = FakeEvent("object:property-change:accessible-name", source=source, any_data="sale updated") - cthulhu_state.activeScript = mock.Mock(app=app) - cthulhu_state.locusOfFocus = focus - - def get_attribute(obj, name): - if name == "live": - return "polite" - return "" - - with ( - mock.patch.object(event_manager.debug, "printMessage"), - mock.patch.object(event_manager.debug, "printTokens"), - mock.patch.object(event_manager.debug, "print_log"), - mock.patch.object(event_manager.AXObject, "get_application", return_value=app), - mock.patch.object(event_manager.AXObject, "get_name", return_value="Chromium"), - mock.patch.object(event_manager.AXObject, "get_role", return_value=event_manager.Atspi.Role.LINK), - mock.patch.object(event_manager.AXObject, "get_attribute", side_effect=get_attribute), - mock.patch.object(event_manager.AXObject, "is_dead", return_value=False), - mock.patch.object(event_manager.AXUtilities, "has_no_state", return_value=False), - mock.patch.object(event_manager.AXUtilities, "is_defunct", return_value=False), - mock.patch.object(event_manager.AXUtilities, "is_focused", return_value=False), - mock.patch.object(event_manager.AXUtilities, "is_notification", return_value=False), - mock.patch.object(event_manager.AXUtilities, "is_alert", return_value=False), - ): - self.assertFalse(self.manager._ignore(event)) - - def test_unfocused_focus_loss_is_dropped_when_focus_is_stable_elsewhere(self) -> None: - app = object() - focus = object() - source = object() - event = FakeEvent("object:state-changed:focused", source=source, detail1=0) - cthulhu_state.activeScript = mock.Mock(app=app) - cthulhu_state.locusOfFocus = focus - - with ( - mock.patch.object(event_manager.debug, "printMessage"), - mock.patch.object(event_manager.debug, "printTokens"), - mock.patch.object(event_manager.debug, "print_log"), - mock.patch.object(event_manager.AXObject, "get_application", return_value=app), - mock.patch.object(event_manager.AXObject, "get_name", return_value="Chromium"), - mock.patch.object(event_manager.AXObject, "get_role", return_value=event_manager.Atspi.Role.LINK), - mock.patch.object(event_manager.AXObject, "is_dead", return_value=False), - mock.patch.object(event_manager.AXUtilities, "has_no_state", return_value=False), - mock.patch.object(event_manager.AXUtilities, "is_defunct", return_value=False), - mock.patch.object(event_manager.AXUtilities, "is_notification", return_value=False), - mock.patch.object(event_manager.AXUtilities, "is_alert", return_value=False), - ): - self.assertTrue(self.manager._ignore(event)) - - def test_repeated_web_children_changed_burst_is_collapsed(self) -> None: - app = object() - focus = object() - source = object() - firstEvent = FakeEvent("object:children-changed:add", source=source, any_data=object()) - secondEvent = FakeEvent("object:children-changed:add", source=source, any_data=object()) - cthulhu_state.activeScript = mock.Mock(app=app) - cthulhu_state.locusOfFocus = focus - - with ( - mock.patch.object(event_manager.time, "monotonic", side_effect=[100.0, 100.05]), - mock.patch.object(event_manager.debug, "printMessage"), - mock.patch.object(event_manager.debug, "printTokens"), - mock.patch.object(event_manager.debug, "print_log"), - mock.patch.object(event_manager.AXObject, "get_application", return_value=app), - mock.patch.object(event_manager.AXObject, "get_name", return_value="Chromium"), - mock.patch.object(event_manager.AXObject, "get_role", return_value=event_manager.Atspi.Role.SECTION), - mock.patch.object(event_manager.AXObject, "is_dead", return_value=False), - mock.patch.object(event_manager.AXUtilities, "has_no_state", return_value=False), - mock.patch.object(event_manager.AXUtilities, "is_defunct", return_value=False), - mock.patch.object(event_manager.AXUtilities, "manages_descendants", return_value=False), - mock.patch.object(event_manager.AXUtilities, "is_image", return_value=False), - mock.patch.object(event_manager.AXUtilities, "is_menu_item", return_value=False), - mock.patch.object(event_manager.AXUtilities, "is_notification", return_value=False), - mock.patch.object(event_manager.AXUtilities, "is_alert", return_value=False), - ): - self.assertFalse(self.manager._ignore(firstEvent)) - self.assertTrue(self.manager._ignore(secondEvent))