diff --git a/src/cthulhu/script.py b/src/cthulhu/script.py index f298fb8..d499897 100644 --- a/src/cthulhu/script.py +++ b/src/cthulhu/script.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 2004-2009 Sun Microsystems Inc. +# Copyright 2011-2026 Igalia, S.L. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -19,619 +17,343 @@ # 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 -"""Each script maintains a set of key bindings, braille bindings, and -AT-SPI event listeners. The key bindings are an instance of -KeyBindings. The braille bindings are also a dictionary where the -keys are BrlTTY command integers and the values are instances of -InputEventHandler. The listeners field is a dictionary where the keys -are AT-SPI event names and the values are function pointers. +# pylint:disable=too-many-instance-attributes +# pylint:disable=unused-argument -Instances of scripts are intended to be created solely by the -script manager. +"""The base Script class.""" -This Script class is not intended to be instantiated directly. -Instead, it is expected that subclasses of the Script class will be -created in their own module. The module defining the Script subclass -is also required to have a 'get_script(app)' method that returns an -instance of the Script subclass. See default.py for an example.""" +from __future__ import annotations -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2005-2009 Sun Microsystems Inc." -__license__ = "LGPL" +from typing import TYPE_CHECKING -import gi -gi.require_version("Atspi", "2.0") -from gi.repository import Atspi - -from . import cthulhu # Need access to cthulhuApp -from . import ax_event_synthesizer -from . import action_presenter -from . import braille_generator -from . import date_and_time_presenter -from . import debug -from . import event_manager -from . import flat_review_presenter -from . import formatting -from . import keybindings -from . import label_inference -from . import learn_mode_presenter -from . import mouse_review -from . import notification_presenter -from . import object_navigator -from . import cthulhu_state -from . import script_manager -from . import script_utilities -from . import settings -from . import settings_manager -from . import sleep_mode_manager -from . import sound_generator -from . import speech_and_verbosity_manager -from . import speech_generator -from . import structural_navigation -from . import bookmarks -from . import tutorialgenerator -from . import where_am_i_presenter +from . import ( + braille_generator, + chat_presenter, + debug, + script_utilities, + sound_generator, + speech_generator, + structural_navigator, +) from .ax_object import AXObject -# Old global variables removed - scripts now access via cthulhuApp or self._app +if TYPE_CHECKING: + from collections.abc import Callable + + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + + from . import label_inference + class Script: - """The specific focus tracking scripts for applications. - """ + """The base Script class.""" - def __init__(self, app): - """Creates a script for the given application, if necessary. - This method should not be called by anyone except the - script manager. - - Arguments: - - app: the Python Accessible application to create a script for - """ + def __init__(self, app: Atspi.Accessible) -> None: self.app = app + self.app_name: str | None = AXObject.get_name(self.app) or None + self.name = f"{self.app_name or 'default'} (module={self.__module__})" + self.present_if_inactive: bool = True + self.run_find_command_on: Atspi.Accessible | None = None + self.event_cache: dict = {} - if app: - self.name = AXObject.get_name(self.app) or "default" - else: - self.name = "default" + self.listeners = self.get_listeners() + self.utilities = self.get_utilities() - self.name += " (module=" + self.__module__ + ")" + self._braille_generator = self._create_braille_generator() + self._sound_generator = self._create_sound_generator() + self._speech_generator = self._create_speech_generator() - self.listeners = self.getListeners() + # pylint:disable=assignment-from-none + self.label_inference = self.get_label_inference() + self.chat = self.get_chat() + # pylint:enable=assignment-from-none - # By default, handle events for non-active applications. - # - self.presentIfInactive = True + self.set_up_commands() - self.utilities = self.getUtilities() - self.labelInference = self.getLabelInference() - self.structuralNavigation = self.getStructuralNavigation() - self.caretNavigation = self.getCaretNavigation() - self.bookmarks = self.getBookmarks() - self.liveRegionManager = self.getLiveRegionManager() - self.notificationPresenter = self.getNotificationPresenter() - self.flatReviewPresenter = self.getFlatReviewPresenter() - self.speechAndVerbosityManager = self.getSpeechAndVerbosityManager() - self.dateAndTimePresenter = self.getDateAndTimePresenter() - self.objectNavigator = self.get_objectNavigator() - self.whereAmIPresenter = self.getWhereAmIPresenter() - self.learnModePresenter = self.getLearnModePresenter() - self.mouseReviewer = self.getMouseReviewer() - self.eventSynthesizer = self.get_event_synthesizer() - self.actionPresenter = self.getActionPresenter() + self._default_sn_mode: structural_navigator.NavigationMode = ( + structural_navigator.NavigationMode.OFF + ) + self._default_caret_navigation_enabled: bool = False - self.chat = self.getChat() - self.inputEventHandlers = {} - self.pointOfReference = {} - self.setupInputEventHandlers() - self.keyBindings = self.getKeyBindings() - self.brailleBindings = self.getBrailleBindings() + msg = f"SCRIPT: {self.name} initialized" + debug.print_message(debug.LEVEL_INFO, msg, True) - self.formatting = self.getFormatting() - self.brailleGenerator = self.getBrailleGenerator() - self.soundGenerator = self.getSoundGenerator() - self.speechGenerator = self.getSpeechGenerator() - self.generatorCache = {} - self.eventCache = {} - self.spellcheck = self.getSpellCheck() - self.tutorialGenerator = self.getTutorialGenerator() - - self.findCommandRun = False - self._lastCommandWasStructNav = False - - msg = f'SCRIPT: {self.name} initialized' - debug.printMessage(debug.LEVEL_INFO, msg, True) - - def __str__(self): + def __str__(self) -> str: return f"{self.name}" - def getListeners(self): - """Sets up the AT-SPI event listeners for this script. + def get_listeners(self) -> dict[str, Callable]: + """Returns a dictionary of the event listeners for this script.""" - Returns a dictionary where the keys are AT-SPI event names - and the values are script methods. - """ - return {} + return { + "document:attributes-changed": self._on_document_attributes_changed, + "document:load-complete": self._on_document_load_complete, + "document:load-stopped": self._on_document_load_stopped, + "document:page-changed": self._on_document_page_changed, + "document:reload": self._on_document_reload, + "mouse:button": self._on_mouse_button, + "object:active-descendant-changed": self._on_active_descendant_changed, + "object:announcement": self._on_announcement, + "object:attributes-changed": self._on_object_attributes_changed, + "object:children-changed:add": self._on_children_added, + "object:children-changed:remove": self._on_children_removed, + "object:column-reordered": self._on_column_reordered, + "object:property-change:accessible-description": self._on_description_changed, + "object:property-change:accessible-name": self._on_name_changed, + "object:property-change:accessible-value": self._on_value_changed, + "object:row-reordered": self._on_row_reordered, + "object:selection-changed": self._on_selection_changed, + "object:state-changed:active": self._on_active_changed, + "object:state-changed:busy": self._on_busy_changed, + "object:state-changed:checked": self._on_checked_changed, + "object:state-changed:expanded": self._on_expanded_changed, + "object:state-changed:focused": self._on_focused_changed, + "object:state-changed:indeterminate": self._on_indeterminate_changed, + "object:state-changed:invalid-entry": self._on_invalid_entry_changed, + "object:state-changed:pressed": self._on_pressed_changed, + "object:state-changed:selected": self._on_selected_changed, + "object:state-changed:sensitive": self._on_sensitive_changed, + "object:state-changed:showing": self._on_showing_changed, + "object:text-attributes-changed": self._on_text_attributes_changed, + "object:text-caret-moved": self._on_caret_moved, + "object:text-changed:delete": self._on_text_deleted, + "object:text-changed:insert": self._on_text_inserted, + "object:text-selection-changed": self._on_text_selection_changed, + "object:value-changed": self._on_value_changed, + "window:activate": self._on_window_activated, + "window:create": self._on_window_created, + "window:deactivate": self._on_window_deactivated, + "window:destroy": self._on_window_destroyed, + } - def setupInputEventHandlers(self): - """Defines InputEventHandler fields for this script that can be - called by the key and braille bindings.""" - pass + def set_up_commands(self) -> None: + """Sets up commands with CommandManager.""" - def getKeyBindings(self): - """Defines the key bindings for this script. + def _create_braille_generator(self) -> braille_generator.BrailleGenerator: + """Creates and returns the braille generator for this script.""" - Returns an instance of keybindings.KeyBindings. - """ - return keybindings.KeyBindings() - - def getToolkitKeyBindings(self): - """Returns the toolkit-specific keybindings for this script.""" - - return keybindings.KeyBindings() - - def getAppKeyBindings(self): - """Returns the application-specific keybindings for this script.""" - - return keybindings.KeyBindings() - - def getPluginKeyBindings(self): - """Returns the plugin keybindings for this script.""" - - from . import debug - debug.printMessage(debug.LEVEL_INFO, "SCRIPT: getPluginKeyBindings() called", True) - - plugin_bindings = keybindings.KeyBindings() - - # Get the plugin system manager - try: - from . import plugin_system_manager - manager = plugin_system_manager.getManager() - debug.printMessage(debug.LEVEL_INFO, f"SCRIPT: Plugin manager: {manager}", True) - if manager: - # Get all active plugins - active_plugins = manager.get_active_plugins() - debug.printMessage(debug.LEVEL_INFO, f"SCRIPT: Found {len(active_plugins)} active plugins", True) - for plugin in active_plugins: - # Get bindings from each plugin - bindings = plugin.get_bindings() - debug.printMessage(debug.LEVEL_INFO, f"SCRIPT: Plugin {plugin.name} has bindings: {bindings}", True) - if bindings: - # Add each binding from the plugin to our collection - for binding in bindings.keyBindings: - plugin_bindings.add(binding) - debug.printMessage(debug.LEVEL_INFO, f"SCRIPT: Added plugin binding: {binding.asString()}", True) - except Exception as e: - debug.printMessage(debug.LEVEL_INFO, f"Could not get plugin keybindings: {e}", True) - import traceback - debug.printMessage(debug.LEVEL_INFO, traceback.format_exc(), True) - - debug.printMessage(debug.LEVEL_INFO, f"SCRIPT: Returning {len(plugin_bindings.keyBindings)} plugin bindings", True) - return plugin_bindings - - def getBrailleBindings(self): - """Defines the braille bindings for this script. - - Returns a dictionary where the keys are BrlTTY commands and the - values are InputEventHandler instances. - """ - return {} - - def getFormatting(self): - """Returns the formatting strings for this script.""" - return formatting.Formatting(self) - - def getBrailleGenerator(self): - """Returns the braille generator for this script. - """ return braille_generator.BrailleGenerator(self) - def getSoundGenerator(self): - """Returns the sound generator for this script.""" + def _create_sound_generator(self) -> sound_generator.SoundGenerator: + """Creates and returns the sound generator for this script.""" + return sound_generator.SoundGenerator(self) - def getSpeechGenerator(self): - """Returns the speech generator for this script. - """ + def _create_speech_generator(self) -> speech_generator.SpeechGenerator: + """Creates and returns the speech generator for this script.""" + return speech_generator.SpeechGenerator(self) - def getTutorialGenerator(self): - """Returns the tutorial generator for this script. - """ - return tutorialgenerator.TutorialGenerator(self) + def get_braille_generator(self) -> braille_generator.BrailleGenerator: + """Returns the braille generator for this script.""" - def getChat(self): - """Returns the 'chat' class for this script. - """ - return None + return self._braille_generator - def getSpellCheck(self): - """Returns the spellcheck support for this script.""" - return None + def get_sound_generator(self) -> sound_generator.SoundGenerator: + """Returns the sound generator for this script.""" - def getCaretNavigation(self): - """Returns the caret navigation support for this script.""" - return None + return self._sound_generator - def getUtilities(self): + def get_speech_generator(self) -> speech_generator.SpeechGenerator: + """Returns the speech generator for this script.""" + + return self._speech_generator + + def get_chat(self) -> chat_presenter.Chat: + """Returns the Chat object for this script.""" + + # In practice, self will always be an instance/subclass of default.Script. + return chat_presenter.Chat(self) # type: ignore[arg-type] + + def get_utilities(self) -> script_utilities.Utilities: """Returns the utilities for this script.""" + return script_utilities.Utilities(self) - def getLabelInference(self): + def get_label_inference(self) -> label_inference.LabelInference | None: """Returns the label inference functionality for this script.""" - return label_inference.LabelInference(self) - def getEnabledStructuralNavigationTypes(self): - """Returns a list of the structural navigation object types - enabled in this script. - """ - return [] - - def getStructuralNavigation(self): - """Returns the 'structural navigation' class for this script.""" - types = self.getEnabledStructuralNavigationTypes() - enable = cthulhu.cthulhuApp.settingsManager.getSetting('structuralNavigationEnabled') - return structural_navigation.StructuralNavigation(self, types, enable) - - def getLiveRegionManager(self): - """Returns the live region support for this script.""" return None - def getNotificationPresenter(self): - return notification_presenter.getPresenter() + def locus_of_focus_changed( + self, + event: Atspi.Event | None, + old_focus: Atspi.Accessible | None, + new_focus: Atspi.Accessible | None, + ) -> bool: + """Handles changes of focus of interest. Returns True if this script did all needed work.""" - def getFlatReviewPresenter(self): - return flat_review_presenter.getPresenter() - - def getDateAndTimePresenter(self): - return date_and_time_presenter.getPresenter() - - def get_objectNavigator(self): - return object_navigator.getNavigator() - - def getSpeechAndVerbosityManager(self): - return speech_and_verbosity_manager.getManager() - - def getWhereAmIPresenter(self): - return where_am_i_presenter.getPresenter() - - def getLearnModePresenter(self): - return learn_mode_presenter.getPresenter() - - def getActionPresenter(self): - return action_presenter.getPresenter() - - def getMouseReviewer(self): - return mouse_review.getReviewer() - - def get_event_synthesizer(self): - return ax_event_synthesizer.get_synthesizer() - - def useStructuralNavigationModel(self, debugOutput=True): - """Returns True if we should use structural navigation. Most - scripts will have no need to override this. Gecko does however - because within an HTML document there are times when we do want - to use it and times when we don't even though it is enabled, - e.g. in a form field. - """ - return self.structuralNavigation.enabled - - def getBookmarks(self): - """Returns the "bookmarks" class for this script. - """ - try: - return self.bookmarks - except AttributeError: - self.bookmarks = bookmarks.Bookmarks(self) - return self.bookmarks - - def getAppPreferencesGUI(self): - """Return a GtkGrid containing the application unique configuration - GUI items for the current application. - """ - return None - - def getPreferencesFromGUI(self): - """Returns a dictionary with the app-specific preferences.""" - - return {} - - def registerEventListeners(self): - """Tells the event manager to start listening for all the event types - of interest to the script. - - Arguments: - - script: the script. - """ - - cthulhu.cthulhuApp.eventManager.registerScriptListeners(self) - - def deregisterEventListeners(self): - """Tells the event manager to stop listening for all the event types - of interest to the script. - - Arguments: - - script: the script. - """ - - cthulhu.cthulhuApp.eventManager.deregisterScriptListeners(self) - - def processObjectEvent(self, event): - """Processes all AT-SPI object events of interest to this - script. The interest in events is specified via the - 'listeners' field that was defined during the construction of - this script. - - Note that this script may be passed events it doesn't care - about, so it needs to react accordingly. - - Arguments: - - event: the Event - """ - - role = AXObject.get_role(event.source) - if role == Atspi.Role.INVALID: - msg = 'ERROR: Not processing object event for invalid object' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - # Check to see if we really want to process this event. - # - processEvent = (cthulhu_state.activeScript == self \ - or self.presentIfInactive) - if role == Atspi.Role.PROGRESS_BAR \ - and not processEvent \ - and settings.progressBarVerbosity == settings.PROGRESS_BAR_ALL: - processEvent = True - - if not processEvent: - return - - if self.skipObjectEvent(event): - return - - # Clear the generator cache for each event. - # - self.generatorCache = {} - - # This calls the first listener it finds whose key *begins with* or is - # the same as the event.type. The reason we do this is that the event - # type in the listeners dictionary may not be as specific as the event - # type we received (e.g., the listeners dictionary might contain the - # key "object:state-changed:" and the event.type might be - # "object:state-changed:focused". [[[TODO: WDW - the order of the - # keys is *not* deterministic, and is not guaranteed to be related - # to the order in which they were added. So...we need to do something - # different here. Logged as bugzilla bug 319781.]]] - # - for key in self.listeners.keys(): - if event.type.startswith(key): - self.listeners[key](event) - - def _getQueuedEvent(self, eventType, detail1=None, detail2=None, any_data=None): - cachedEvent, eventTime = self.eventCache.get(eventType, [None, 0]) - if not cachedEvent: - tokens = ["SCRIPT: No queued event of type", eventType] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return None - - if detail1 is not None and detail1 != cachedEvent.detail1: - tokens = ["SCRIPT: Queued event's detail1 (", cachedEvent.detail1, - ") doesn't match", detail1] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return None - - if detail2 is not None and detail2 != cachedEvent.detail2: - tokens = ["SCRIPT: Queued event's detail2 (", cachedEvent.detail2, - ") doesn't match", detail2] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return None - - if any_data is not None and any_data != cachedEvent.any_data: - tokens = ["SCRIPT: Queued event's any_data (", - cachedEvent.any_data, ") doesn't match", any_data] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return None - - tokens = ["SCRIPT: Found matching queued event:", cachedEvent] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return cachedEvent - - def skipObjectEvent(self, event): - """Gives us, and scripts, the ability to decide an event isn't - worth taking the time to process under the current circumstances. - - Arguments: - - event: the Event - - Returns True if we shouldn't bother processing this object event. - """ - - cachedEvent, eventTime = self.eventCache.get(event.type, [None, 0]) - if not cachedEvent or cachedEvent == event: - return False - - focus = ["object:state-changed:focused"] - typing = ["object:text-changed:insert", "object:text-changed:delete"] - arrowing = ["object:text-caret-moved", "object:text-selection-changed", - "object:selection-changed", "object:active-descendant-changed"] - - skip = False - if (event.type in arrowing or event.type in typing) \ - and event.source == cachedEvent.source: - skip = True - reason = "more recent event of the same type in the same object" - elif event.type in focus and event.source != cachedEvent.source \ - and event.type == cachedEvent.type \ - and event.detail1 == cachedEvent.detail1: - skip = True - reason = "more recent event of the same type in a different object" - elif event.type.endswith("system") and event.source == cachedEvent.source: - skip = True - reason = "more recent system event in the same object" - elif event.type.startswith("object:state-changed") \ - and event.type == cachedEvent.type \ - and event.source == cachedEvent.source \ - and event.detail1 == cachedEvent.detail1: - skip = True - reason = "appears to be duplicate state-changed event" - - if skip: - tokens = ["SCRIPT: Skipping object event:", reason, cachedEvent] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - - return skip - - def consumesKeyboardEvent(self, keyboardEvent): - """Called when a key is pressed on the keyboard. - - Arguments: - - keyboardEvent: an instance of input_event.KeyboardEvent - - Returns True if the event is of interest. - """ - - handler = self.keyBindings.getInputHandler(keyboardEvent) - self.updateKeyboardEventState(keyboardEvent, handler) - return self.shouldConsumeKeyboardEvent(keyboardEvent, handler) - - def updateKeyboardEventState(self, keyboardEvent, handler): - """Update internal state for a keyboard event without deciding consumption.""" - pass - - def shouldConsumeKeyboardEvent(self, keyboardEvent, handler): - """Returns True if the script will consume this keyboard event.""" - consumes = False - self._lastCommandWasStructNav = False - if handler and handler.function in self.structuralNavigation.functions: - consumes = self.useStructuralNavigationModel() - if consumes: - self._lastCommandWasStructNav = True - else: - consumes = handler is not None - return consumes - - def consumesBrailleEvent(self, brailleEvent): - """Called when a key is pressed on the braille display. - - Arguments: - - brailleEvent: an instance of input_event.KeyboardEvent - - Returns True if the event is of interest. - """ - user_bindings = None - user_bindings_map = settings.brailleBindingsMap - if self.__module__ in user_bindings_map: - user_bindings = user_bindings_map[self.__module__] - elif "default" in user_bindings_map: - user_bindings = user_bindings_map["default"] - - command = brailleEvent.event["command"] - consumes = False - if user_bindings: - consumes = command in user_bindings - if not consumes: - consumes = command in self.brailleBindings - return consumes - - def processBrailleEvent(self, brailleEvent): - """Called whenever a key is pressed on the Braille display. - - This method will primarily use the brailleBindings field of - this script instance see if this script has an interest in the - event. - - NOTE: there is latent, but unsupported, logic for allowing - the user's user-settings.py file to extend and/or override - the brailleBindings for a script. - - Arguments: - - brailleEvent: an instance of input_event.BrailleEvent - """ - - # We'll annotate the event with a reference to this script. - # This will allow external scripts to muck with the script - # instance if they wish. - # - brailleEvent.script = self - - # We'll let the user bindings take precedence. First, we'll - # check to see if they have bindings specific for the particular - # application, then we'll check to see if they have any default - # bindings to use. - # - # [[[TODO: WDW - for performance, these bindings should probably - # be conflated at initialization time.]]] - # - consumed = False - user_bindings = None - command = brailleEvent.event["command"] - - user_bindings_map = settings.brailleBindingsMap - if self.name in user_bindings_map: - user_bindings = user_bindings_map[self.name] - elif "default" in user_bindings_map: - user_bindings = user_bindings_map["default"] - - if user_bindings and command in user_bindings: - handler = user_bindings[command] - consumed = handler.processInputEvent(self, brailleEvent) - - if (not consumed) and command in self.brailleBindings: - handler = self.brailleBindings[command] - consumed = handler.processInputEvent(self, brailleEvent) - - return consumed - - def locus_of_focus_changed(self, event, oldLocusOfFocus, newLocusOfFocus): - """Called when the visual object with focus changes. - - The primary purpose of this method is to present locus of focus - information to the user. - - NOTE: scripts should not call this method directly. Instead, - a script should call cthulhu.setLocusOfFocus, which will eventually - result in this method being called. - - Arguments: - - event: if not None, the Event that caused the change - - oldLocusOfFocus: Accessible that is the old locus of focus - - newLocusOfFocus: Accessible that is the new locus of focus - """ - pass - - def isActivatableEvent(self, event): - """Returns True if the given event is one that should cause this - script to become the active script. This is only a hint to - the focus tracking manager and it is not guaranteed this - request will be honored. Note that by the time the focus - tracking manager calls this method, it thinks the script - should become active. This is an opportunity for the script - to say it shouldn't. - """ return True - def forceScriptActivation(self, event): + def is_activatable_event(self, event: Atspi.Event) -> bool: + """Returns True if event should cause this script to become active.""" + + return True + + def force_script_activation(self, event: Atspi.Event) -> bool: """Allows scripts to insist that they should become active.""" return False - def activate(self): + def activate(self) -> None: """Called when this script is activated.""" - pass - def deactivate(self): + def deactivate(self) -> None: """Called when this script is deactivated.""" - pass - def getSleepModeManager(self): - """Returns the sleep mode manager for this script.""" - return sleep_mode_manager.getManager() + def _on_active_changed(self, event: Atspi.Event) -> bool: + """Callback for object:state-changed:active accessibility events.""" + return True - def getTransferableAttributes(self): - return {} + def _on_active_descendant_changed(self, event: Atspi.Event) -> bool: + """Callback for object:active-descendant-changed accessibility events.""" + return True + + def _on_announcement(self, event: Atspi.Event) -> bool: + """Callback for object:announcement events.""" + return True + + def _on_busy_changed(self, event: Atspi.Event) -> bool: + """Callback for object:state-changed:busy accessibility events.""" + return True + + def _on_caret_moved(self, event: Atspi.Event) -> bool: + """Callback for object:text-caret-moved accessibility events.""" + return True + + def _on_checked_changed(self, event: Atspi.Event) -> bool: + """Callback for object:state-changed:checked accessibility events.""" + return True + + def _on_children_added(self, event: Atspi.Event) -> bool: + """Callback for object:children-changed:add accessibility events.""" + return True + + def _on_children_removed(self, event: Atspi.Event) -> bool: + """Callback for object:children-changed:remove accessibility events.""" + return True + + def _on_column_reordered(self, event: Atspi.Event) -> bool: + """Callback for object:column-reordered accessibility events.""" + return True + + def _on_description_changed(self, event: Atspi.Event) -> bool: + """Callback for object:property-change:accessible-description events.""" + return True + + def _on_document_attributes_changed(self, event: Atspi.Event) -> bool: + """Callback for document:attributes-changed accessibility events.""" + return True + + def _on_document_load_complete(self, event: Atspi.Event) -> bool: + """Callback for document:load-complete accessibility events.""" + return True + + def _on_document_load_stopped(self, event: Atspi.Event) -> bool: + """Callback for document:load-stopped accessibility events.""" + return True + + def _on_document_page_changed(self, event: Atspi.Event) -> bool: + """Callback for document:page-changed accessibility events.""" + return True + + def _on_document_reload(self, event: Atspi.Event) -> bool: + """Callback for document:reload accessibility events.""" + return True + + def _on_expanded_changed(self, event: Atspi.Event) -> bool: + """Callback for object:state-changed:expanded accessibility events.""" + return True + + def _on_focused_changed(self, event: Atspi.Event) -> bool: + """Callback for object:state-changed:focused accessibility events.""" + return True + + def _on_indeterminate_changed(self, event: Atspi.Event) -> bool: + """Callback for object:state-changed:indeterminate accessibility events.""" + return True + + def _on_invalid_entry_changed(self, event: Atspi.Event) -> bool: + """Callback for object:state-changed:invalid-entry accessibility events.""" + return True + + def _on_mouse_button(self, event: Atspi.Event) -> bool: + """Callback for mouse:button events.""" + return True + + def _on_name_changed(self, event: Atspi.Event) -> bool: + """Callback for object:property-change:accessible-name events.""" + return True + + def _on_object_attributes_changed(self, event: Atspi.Event) -> bool: + """Callback for object:attributes-changed accessibility events.""" + return True + + def _on_pressed_changed(self, event: Atspi.Event) -> bool: + """Callback for object:state-changed:pressed accessibility events.""" + return True + + def _on_row_reordered(self, event: Atspi.Event) -> bool: + """Callback for object:row-reordered accessibility events.""" + return True + + def _on_selected_changed(self, event: Atspi.Event) -> bool: + """Callback for object:state-changed:selected accessibility events.""" + return True + + def _on_selection_changed(self, event: Atspi.Event) -> bool: + """Callback for object:selection-changed accessibility events.""" + return True + + def _on_sensitive_changed(self, event: Atspi.Event) -> bool: + """Callback for object:state-changed:sensitive accessibility events.""" + return True + + def _on_showing_changed(self, event: Atspi.Event) -> bool: + """Callback for object:state-changed:showing accessibility events.""" + return True + + def _on_text_attributes_changed(self, event: Atspi.Event) -> bool: + """Callback for object:text-attributes-changed accessibility events.""" + return True + + def _on_text_deleted(self, event: Atspi.Event) -> bool: + """Callback for object:text-changed:delete accessibility events.""" + return True + + def _on_text_inserted(self, event: Atspi.Event) -> bool: + """Callback for object:text-changed:insert accessibility events.""" + return True + + def _on_text_selection_changed(self, event: Atspi.Event) -> bool: + """Callback for object:text-selection-changed accessibility events.""" + return True + + def _on_value_changed(self, event: Atspi.Event) -> bool: + """Callback for object:property-change:accessible-value accessibility events.""" + return True + + def _on_window_activated(self, event: Atspi.Event) -> bool: + """Callback for window:activate accessibility events.""" + return True + + def _on_window_created(self, event: Atspi.Event) -> bool: + """Callback for window:create accessibility events.""" + return True + + def _on_window_deactivated(self, event: Atspi.Event) -> bool: + """Callback for window:deactivate accessibility events.""" + return True + + def _on_window_destroyed(self, event: Atspi.Event) -> bool: + """Callback for window:destroy accessibility events.""" + return True + + def present_object(self, obj: Atspi.Accessible, **args) -> None: + """Presents the current object.""" + + def update_braille(self, obj: Atspi.Accessible, **args) -> None: + """Updates the braille display to show obj.""" diff --git a/src/cthulhu/script_manager.py b/src/cthulhu/script_manager.py index da29416..072e61a 100644 --- a/src/cthulhu/script_manager.py +++ b/src/cthulhu/script_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,459 +17,374 @@ # 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 -from __future__ import annotations +# pylint: disable=too-many-instance-attributes -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2011. Cthulhu Team." -__license__ = "LGPL" +"""Manages Cthulhu's scripts.""" +import contextlib import importlib -from typing import TYPE_CHECKING, Optional, Dict, Any, List, Union -from . import debug -from . import cthulhu_state +import gi + +gi.require_version("Atspi", "2.0") +from gi.repository import Atspi + +from . import command_manager, debug, gsettings_registry, sleep_mode_manager, speech_manager from .ax_object import AXObject -from .scripts import apps, toolkits +from .ax_utilities import AXUtilities +from .scripts import apps, default, sleepmode, toolkits -if TYPE_CHECKING: - from gi.repository import Atspi - from .cthulhu import Cthulhu - from .script import Script - from .input_event import InputEvent - -# Forward references to avoid circular imports -# Script is defined in script.py -# Atspi.Accessible comes from AT-SPI - -def _get_ax_utilities() -> Any: - # Avoid circular import with ax_utilities -> ax_utilities_event -> focus_manager -> braille -> settings_manager -> script_manager. - from .ax_utilities import AXUtilities - return AXUtilities - -def _log(message: str, reason: Optional[str] = None, timestamp: bool = True, stack: bool = False) -> None: - debug.print_log(debug.LEVEL_INFO, "SCRIPT MANAGER", message, reason, timestamp, stack) - -def _log_tokens(tokens: list[Any], reason: Optional[str] = None, timestamp: bool = True, stack: bool = False) -> None: - debug.print_log_tokens(debug.LEVEL_INFO, "SCRIPT MANAGER", tokens, reason, timestamp, stack) class ScriptManager: + """Manages Cthulhu's scripts.""" - def __init__(self, app: Cthulhu) -> None: - _log("Initializing") - self.app: Cthulhu = app # Store app instance - self.appScripts: Dict[Atspi.Accessible, Script] = {} - self.toolkitScripts: Dict[Atspi.Accessible, Dict[str, Script]] = {} - self.customScripts: Dict[Atspi.Accessible, Dict[str, Script]] = {} - self._sleepModeScripts: Dict[Atspi.Accessible, Script] = {} - self._appModules: List[str] = apps.__all__ - self._toolkitModules: List[str] = toolkits.__all__ - self._defaultScript: Optional[Script] = None - self._scriptPackages: List[str] = \ - ["cthulhu-scripts", - "cthulhu.scripts", - "cthulhu.scripts.apps", - "cthulhu.scripts.toolkits"] - self._appNames: Dict[str, str] = \ - {'Icedove': 'Thunderbird', - 'Nereid': 'Banshee', - 'gnome-calculator': 'gcalctool', - 'Steam': 'steamwebhelper', - 'Steam Web Helper': 'steamwebhelper', - 'gtk-window-decorator': 'switcher', - 'marco': 'switcher', - 'xfce4-notifyd': 'notification-daemon', - 'mate-notification-daemon': 'notification-daemon', - 'metacity': 'switcher', - 'pluma': 'gedit', - } - self._toolkitNames: Dict[str, str] = \ - {'WebKitGTK': 'WebKitGtk', 'GTK': 'gtk'} - - self.set_active_script(None, "lifecycle: init") + def __init__(self) -> None: + debug.print_message(debug.LEVEL_INFO, "SCRIPT MANAGER: Initializing", True) + self.app_scripts: dict = {} + self.toolkit_scripts: dict = {} + self.custom_scripts: dict = {} + self._sleep_mode_scripts: dict = {} + self._default_script: default.Script | None = None + self._active_script: default.Script | None = None self._active: bool = False - _log("Initialized") + debug.print_message(debug.LEVEL_INFO, "SCRIPT MANAGER: Initialized", True) def activate(self) -> None: """Called when this script manager is activated.""" - _log("Activating") - self._defaultScript = self.get_script(None) - if self._defaultScript: - self._defaultScript.registerEventListeners() - self.set_active_script(self._defaultScript, "lifecycle: activate") + debug.print_message(debug.LEVEL_INFO, "SCRIPT MANAGER: Activating", True, True) + if self._active: + debug.print_message(debug.LEVEL_INFO, "SCRIPT MANAGER: Already activated", True) + return + + self._default_script = self.get_default_script(None) + self._default_script.register_event_listeners() + self.set_active_script(self._default_script, "activate") self._active = True - _log("Activated") + debug.print_message(debug.LEVEL_INFO, "SCRIPT MANAGER: Activated", True) def deactivate(self) -> None: """Called when this script manager is deactivated.""" - _log("Deactivating") - if self._defaultScript: - self._defaultScript.deregisterEventListeners() - self._defaultScript = None - self.set_active_script(None, "lifecycle: deactivate") - self.appScripts = {} - self.toolkitScripts = {} - self.customScripts = {} - self._active = False - _log("Deactivated") + debug.print_message(debug.LEVEL_INFO, "SCRIPT MANAGER: Deactivating", True, True) + if not self._active: + debug.print_message(debug.LEVEL_INFO, "SCRIPT MANAGER: Already deactivated", True) + return - def get_module_name(self, app: Optional[Atspi.Accessible]) -> Optional[str]: + if self._default_script is not None: + self._default_script.deregister_event_listeners() + self._default_script = None + self.set_active_script(None, "deactivate") + self.app_scripts = {} + self.toolkit_scripts = {} + self.custom_scripts = {} + self._active = False + debug.print_message(debug.LEVEL_INFO, "SCRIPT MANAGER: Deactivated", True) + + def get_module_name(self, app: Atspi.Accessible | None) -> str | None: """Returns the module name of the script to use for application app.""" if app is None: - _log("Cannot get module name for null app", "null-app") + msg = "SCRIPT MANAGER: Cannot get module name for null app" + debug.print_message(debug.LEVEL_INFO, msg, True) return None name = AXObject.get_name(app) if not name: - _log("Cannot get module name for nameless app", "nameless-app") + msg = "SCRIPT MANAGER: Cannot get module name for nameless app" + debug.print_message(debug.LEVEL_INFO, msg, True) return None - pid = AXObject.get_process_id(app) - if pid != -1: - cmdline = debug.getCmdline(pid) - if "steamwebhelper" in cmdline: - return "steamwebhelper" + app_names = { + "Icedove": "Thunderbird", + "Nereid": "Banshee", + "gnome-calculator": "gcalctool", + "Steam": "steamwebhelper", + "Steam Web Helper": "steamwebhelper", + "gtk-window-decorator": "switcher", + "marco": "switcher", + "mate-notification-daemon": "notification-daemon", + "metacity": "switcher", + "pluma": "gedit", + "budgie-daemon": "switcher", + "xfce4-notifyd": "notification-daemon", + } + alt_names = list(app_names.keys()) + if name.endswith((".py", ".bin")): + name = name.split(".")[0] + elif name.startswith(("org.", "com.")): + name = name.split(".")[-1] - altNames = list(self._appNames.keys()) - if name.endswith(".py") or name.endswith(".bin"): - name = name.split('.')[0] - elif name.startswith("org.") or name.startswith("com."): - name = name.split('.')[-1] - - names = [n for n in altNames if n.lower() == name.lower()] + names = [n for n in alt_names if n.lower() == name.lower()] if names: - name = self._appNames.get(names[0]) + name = app_names.get(names[0], "") else: - for nameList in (self._appModules, self._toolkitModules): - names = [n for n in nameList if n.lower() == name.lower()] + for name_list in (apps.__all__, toolkits.__all__): + names = [n for n in name_list if n.lower() == name.lower()] if names: name = names[0] break - _log_tokens(["Mapped", app, "to", name]) + tokens = ["SCRIPT MANAGER: Mapped", app, "to", name] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return name - def _toolkit_for_object(self, obj: Optional[Atspi.Accessible]) -> Optional[str]: + def _toolkit_for_object(self, obj: Atspi.Accessible) -> str | None: """Returns the name of the toolkit associated with obj.""" - if obj is None: - return None - name = AXObject.get_attribute(obj, 'toolkit') - return self._toolkitNames.get(name, name) - def _script_for_role(self, obj: Optional[Atspi.Accessible]) -> str: - if _get_ax_utilities().is_terminal(obj): - return 'terminal' + names = {"GTK": "gtk", "GAIL": "gtk", "WebKitGTK": "WebKitGtk"} + name = AXObject.get_attribute(obj, "toolkit") + return names.get(name, name) - return '' + def _script_for_role(self, obj: Atspi.Accessible) -> str: + """Returns the role-based script for obj.""" - def _new_named_script(self, app: Optional[Atspi.Accessible], name: Optional[str]) -> Optional[Script]: - """Attempts to locate and load the named module. If successful, returns - a script based on this module.""" + if AXUtilities.is_terminal(obj): + return "terminal" + + return "" + + def _new_named_script(self, app: Atspi.Accessible, name: str) -> default.Script | None: + """Returns a script based on this module if it was located and loadable.""" if not (app and name): return None + packages = ["cthulhu-scripts", "cthulhu.scripts", "cthulhu.scripts.apps", "cthulhu.scripts.toolkits"] script = None - for package in self._scriptPackages: - moduleName = '.'.join((package, name)) + for package in packages: + module_name = f"{package}.{name}" try: - module = importlib.import_module(moduleName) + module = importlib.import_module(module_name) except ImportError: continue - except OSError: - debug.examineProcesses() + except OSError as error: + tokens = ["EXCEPTION: Could not import", module_name, ":", error] + debug.print_tokens(debug.LEVEL_INFO, tokens, True, True) + continue - _log_tokens(["Found", moduleName]) + tokens = ["SCRIPT MANAGER: Found", module_name] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + with contextlib.suppress(ImportError): + module = importlib.import_module(f"{module_name}.script") try: - if hasattr(module, 'get_script'): + if hasattr(module, "get_script"): script = module.get_script(app) else: script = module.Script(app) break - except Exception as error: - _log_tokens(["Could not load", moduleName, ":", error], "load-failed") + except (AttributeError, TypeError, ImportError) as error: + tokens = ["EXCEPTION: Could not load", module_name, ":", error] + debug.print_tokens(debug.LEVEL_INFO, tokens, True, True) return script - def _create_script(self, app: Optional[Atspi.Accessible], obj: Optional[Atspi.Accessible] = None) -> Script: + def _create_script( + self, + app: Atspi.Accessible, + obj: Atspi.Accessible | None = None, + ) -> default.Script: """For the given application, create a new script instance.""" - moduleName = self.get_module_name(app) - script = self._new_named_script(app, moduleName) + module_name = self.get_module_name(app) or "" + script = self._new_named_script(app, module_name) if script: return script - objToolkit = self._toolkit_for_object(obj) - script = self._new_named_script(app, objToolkit) + obj_toolkit = self._toolkit_for_object(obj) or "" + script = self._new_named_script(app, obj_toolkit) if script: return script - toolkitName = _get_ax_utilities().get_application_toolkit_name(app) - if app and toolkitName: - script = self._new_named_script(app, toolkitName) + toolkit_name = AXUtilities.get_application_toolkit_name(app) + if app and toolkit_name: + script = self._new_named_script(app, toolkit_name) if not script: script = self.get_default_script(app) - _log_tokens(["Default script created for", app, "(obj:", obj, ")"]) + tokens = ["SCRIPT MANAGER: Default script created for", app, "(obj: ", obj, ")"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return script - def get_default_script(self, app: Optional[Atspi.Accessible] = None) -> Script: - if not app and self._defaultScript: - return self._defaultScript + def get_default_script(self, app: Atspi.Accessible | None = None) -> default.Script: + """Returns the default script.""" + + if not app and self._default_script: + return self._default_script - from .scripts import default script = default.Script(app) - if not app: - self._defaultScript = script + self._default_script = script return script - def sanity_check_script(self, script: Script) -> Script: - if not self._active: - return script + def get_or_create_sleep_mode_script(self, app: Atspi.Accessible) -> sleepmode.Script: + """Gets or crates the sleep mode script.""" - if _get_ax_utilities().is_application_in_desktop(script.app): - return script - - newScript = self._get_script_for_app_replicant(script.app) - if newScript: - return newScript - - _log_tokens(["Failed to get a replacement script for", script.app], "replacement-missing") - return script - - def get_script_for_mouse_button_event(self, event: Any) -> Script: - # Note: event type unspecified in original code, likely InputEvent or similar - isActive = _get_ax_utilities().is_active(cthulhu_state.activeWindow) - _log_tokens([cthulhu_state.activeWindow, "is active:", isActive]) - - if isActive and cthulhu_state.activeScript: - return cthulhu_state.activeScript - - script = self.get_default_script() - activeWindow = script.utilities.activeWindow() - if not activeWindow: - return script - - focusedObject = _get_ax_utilities().get_focused_object(activeWindow) - if focusedObject: - return self.get_script(AXObject.get_application(focusedObject), focusedObject) - - return self.get_script(AXObject.get_application(activeWindow), activeWindow) - - def get_active_script(self) -> Optional[Script]: - return cthulhu_state.activeScript - - def get_script(self, app: Optional[Atspi.Accessible], obj: Optional[Atspi.Accessible] = None, sanity_check: bool = False) -> Script: - """Get a script for an app (and make it if necessary). This is used - instead of a simple calls to Script's constructor. - - Arguments: - - app: the Python app - - Returns an instance of a Script. - """ - - if app: - try: - from . import sleep_mode_manager - sleepModeManager = sleep_mode_manager.getManager() - sleepModeManager.refreshAutoSleepConfig() - if sleepModeManager and sleepModeManager.isActiveForApp(app): - return self.get_or_create_sleep_mode_script(app) - except Exception as error: - _log_tokens(["Could not check sleep mode for", app, ":", error], "sleep-mode-check-failed") - - customScript = None - appScript = None - toolkitScript = None - - roleName = self._script_for_role(obj) - if roleName: - customScripts = self.customScripts.get(app, {}) # type: ignore - customScript = customScripts.get(roleName) - if not customScript: - customScript = self._new_named_script(app, roleName) - if customScript: - customScripts[roleName] = customScript - if app: - self.customScripts[app] = customScripts - - objToolkit = self._toolkit_for_object(obj) - if objToolkit: - toolkitScripts = self.toolkitScripts.get(app, {}) # type: ignore - toolkitScript = toolkitScripts.get(objToolkit) - if not toolkitScript: - toolkitScript = self._create_script(app, obj) - toolkitScripts[objToolkit] = toolkitScript - if app: - self.toolkitScripts[app] = toolkitScripts - - try: - if not app: - appScript = self.get_default_script() - elif app in self.appScripts: - appScript = self.appScripts[app] - else: - appScript = self._create_script(app, None) - self.appScripts[app] = appScript - except Exception as error: - _log_tokens(["Exception getting app script for", app, ":", error], "app-script-error") - appScript = self.get_default_script() - - if customScript: - return customScript - - # Only defer to the toolkit script for this object if the app script - # is based on a different toolkit. - if toolkitScript and not (_get_ax_utilities().is_frame(obj) or _get_ax_utilities().is_status_bar(obj)) \ - and not issubclass(appScript.__class__, toolkitScript.__class__): - return toolkitScript - - if app and sanity_check: - appScript = self.sanity_check_script(appScript) - - return appScript - - def get_or_create_sleep_mode_script(self, app: Atspi.Accessible) -> Script: - """Gets or creates the sleep mode script.""" - script = self._sleepModeScripts.get(app) + script = self._sleep_mode_scripts.get(app) if script is not None: return script - # Import sleepmode dynamically to avoid circular imports - from .scripts import sleepmode script = sleepmode.Script(app) - self._sleepModeScripts[app] = script + self._sleep_mode_scripts[app] = script return script - def set_active_script(self, newScript: Optional[Script], reason: Optional[str] = None) -> None: - """Set the new active script. + def get_script( + self, + app: Atspi.Accessible | None, + obj: Atspi.Accessible | None = None, + ) -> default.Script: + """Get a script for an app (and make it if necessary).""" - Arguments: - - newScript: the new script to be made active. - """ + tokens = ["SCRIPT MANAGER: Getting script for", app, obj] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) - if cthulhu_state.activeScript == newScript: - return + custom_script: default.Script | None = None + app_script: default.Script | None = None + toolkit_script: default.Script | None = None - if cthulhu_state.activeScript: - cthulhu_state.activeScript.deactivate() + role_name = self._script_for_role(obj) + if role_name: + custom_scripts = self.custom_scripts.get(app, {}) + custom_script = custom_scripts.get(role_name) + if not custom_script: + custom_script = self._new_named_script(app, role_name) + custom_scripts[role_name] = custom_script + self.custom_scripts[app] = custom_scripts - cthulhu_state.activeScript = newScript - if not newScript: - self._log_active_state(reason) - return + obj_toolkit = self._toolkit_for_object(obj) + if obj_toolkit: + toolkit_scripts = self.toolkit_scripts.get(app, {}) + toolkit_script = toolkit_scripts.get(obj_toolkit) + if not toolkit_script: + tokens = ["SCRIPT MANAGER: Creating toolkit script for", app, obj, obj_toolkit] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + toolkit_script = self._create_script(app, obj) + toolkit_scripts[obj_toolkit] = toolkit_script + self.toolkit_scripts[app] = toolkit_scripts - newScript.activate() + try: + if not app: + app_script = self.get_default_script() + elif app in self.app_scripts: + app_script = self.app_scripts[app] + else: + tokens = ["SCRIPT MANAGER: Creating app script for", app] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + app_script = self._create_script(app, None) + self.app_scripts[app] = app_script + except (KeyError, AttributeError, ImportError) as error: + tokens = ["EXCEPTION: Exception getting app script for", app, ":", error] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + app_script = self.get_default_script() - # Emit signal that active script has changed, so PluginSystemManager can update keybindings - from . import cthulhu - if cthulhu.cthulhuApp: - cthulhu.cthulhuApp.getSignalManager().emitSignal('active-script-changed', newScript) + assert app_script is not None + if sleep_mode_manager.get_manager().is_active_for_app(app): + tokens = ["SCRIPT MANAGER: Sleep-mode toggled on for", app_script, app] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return self.get_or_create_sleep_mode_script(app) # type: ignore[return-value] - _log_tokens(["Setting active script to", newScript], reason) - self._log_active_state(reason) + if custom_script: + tokens = ["SCRIPT MANAGER: Script is custom script", custom_script] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return custom_script - def activate_script_for_context(self, app: Optional[Atspi.Accessible], obj: Optional[Atspi.Accessible], reason: Optional[str] = None) -> Script: - script = self.get_script(app, obj) - self.set_active_script(script, reason) - return script + # Only defer to the toolkit script for this object if the app script + # is based on a different toolkit. + if ( + toolkit_script + and not (AXUtilities.is_frame(obj) or AXUtilities.is_status_bar(obj)) + and not issubclass(app_script.__class__, toolkit_script.__class__) + ): + tokens = ["SCRIPT MANAGER: Script is toolkit script", toolkit_script] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return toolkit_script - def _log_active_state(self, reason: Optional[str] = None) -> None: - _log_tokens( - ["Active state:", "window", cthulhu_state.activeWindow, - "focus", cthulhu_state.locusOfFocus, - "script", cthulhu_state.activeScript], - reason - ) + tokens = ["SCRIPT MANAGER: Script is app script", app_script] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return app_script - def _get_script_for_app_replicant(self, app: Atspi.Accessible) -> Optional[Script]: - if not self._active: + def get_active_script(self) -> default.Script | None: + """Returns the active script.""" + + tokens = ["SCRIPT MANAGER: Active script is:", self._active_script] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return self._active_script + + def get_active_script_app(self) -> Atspi.Accessible | None: + """Returns the app associated with the active script.""" + + if self._active_script is None: return None - pid = AXObject.get_process_id(app) - if pid == -1: - return None + tokens = ["SCRIPT MANAGER: Active script app is:", self._active_script.app] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return self._active_script.app - items = self.appScripts.items() - for a, script in items: - if AXObject.get_process_id(a) != pid: - continue - if a != app and _get_ax_utilities().is_application_in_desktop(a): - if script.app is None: - script.app = a - _log_tokens(["Script for app replicant:", script, script.app]) - return script + def set_active_script(self, new_script: default.Script | None, reason: str = "") -> None: + """Set the active script to new_script.""" - return None + if self._active_script == new_script: + return + + if self._active_script is not None: + tokens = ["SCRIPT MANAGER: Deactivating", self._active_script, "reason:", reason] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._active_script.deactivate() + + self._active_script = new_script + if new_script is None: + gsettings_registry.get_registry().set_active_app(None) + command_manager.get_manager().check_keyboard_settings() + return + + gsettings_registry.get_registry().set_active_app(new_script.app_name) + + tokens = ["SCRIPT MANAGER: Setting active script to", new_script, "reason:", reason] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + new_script.activate() + + speech_manager.get_manager().check_speech_setting() + command_manager.get_manager().check_keyboard_settings() def reclaim_scripts(self) -> None: """Compares the list of known scripts to the list of known apps, deleting any scripts as necessary. """ - _log("Checking and cleaning up scripts.") + msg = "SCRIPT MANAGER: Checking and cleaning up scripts." + debug.print_message(debug.LEVEL_INFO, msg, True) - appList = list(self.appScripts.keys()) - for app in appList: - if _get_ax_utilities().is_application_in_desktop(app): + app_list = list(self.app_scripts.keys()) + for app in app_list: + if AXUtilities.is_application_in_desktop(app): continue try: - appScript = self.appScripts.pop(app) + app_script = self.app_scripts.pop(app) except KeyError: - _log_tokens([app, "not found in appScripts"]) + tokens = ["SCRIPT MANAGER:", app, "not found in app_scripts"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) continue - _log_tokens(["Old script for app found:", appScript, appScript.app]) + tokens = ["SCRIPT MANAGER: Old script for app found:", app_script, app_script.app] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) - newScript = self._get_script_for_app_replicant(app) - if newScript: - _log_tokens(["Transferring attributes:", newScript, newScript.app]) - attrs = appScript.getTransferableAttributes() - for attr, value in attrs.items(): - _log_tokens(["Setting", attr, "to", value]) - setattr(newScript, attr, value) + with contextlib.suppress(KeyError): + self._sleep_mode_scripts.pop(app) - del appScript + with contextlib.suppress(KeyError): + self.toolkit_scripts.pop(app) - try: - toolkitScripts = self.toolkitScripts.pop(app) - except KeyError: - pass - else: - for toolkitScript in toolkitScripts.values(): - del toolkitScript + with contextlib.suppress(KeyError): + self.custom_scripts.pop(app) - try: - customScripts = self.customScripts.pop(app) - except KeyError: - pass - else: - for customScript in customScripts.values(): - del customScript - del app +_manager: ScriptManager = ScriptManager() -_manager: Optional[ScriptManager] = None -def get_manager() -> Optional[ScriptManager]: - """Returns the Script Manager""" - - global _manager - if _manager is None: - from . import cthulhu - if cthulhu.cthulhuApp: - _manager = cthulhu.cthulhuApp.scriptManager +def get_manager() -> ScriptManager: + """Returns the Script Manager singleton.""" return _manager diff --git a/src/cthulhu/scripts/apps/Mumble/chat.py b/src/cthulhu/scripts/apps/Mumble/chat.py index eccd2a8..01f8521 100644 --- a/src/cthulhu/scripts/apps/Mumble/chat.py +++ b/src/cthulhu/scripts/apps/Mumble/chat.py @@ -56,9 +56,6 @@ class Chat(chat.Chat): "F7", "F8", "F9", "F10", "F11", "F12", ] self.messageKeyModifier = keybindings.CTHULHU_MODIFIER_MASK - self.inputEventHandlers = {} - self.setupInputEventHandlers() - self.keyBindings = self.getKeyBindings() self.messageListLength = len(self.messageKeys) self._conversationList = chat.ConversationList(self.messageListLength) diff --git a/src/cthulhu/scripts/apps/Mumble/script.py b/src/cthulhu/scripts/apps/Mumble/script.py index 7452184..b6c3ee7 100644 --- a/src/cthulhu/scripts/apps/Mumble/script.py +++ b/src/cthulhu/scripts/apps/Mumble/script.py @@ -53,40 +53,34 @@ class Script(Qt.Script): super().__init__(app) - def getChat(self): + def get_chat(self): return Chat(self) - def getUtilities(self): + def get_utilities(self): return Utilities(self) - def setupInputEventHandlers(self): - super().setupInputEventHandlers() - self.inputEventHandlers.update(self.chat.inputEventHandlers) - - def getAppKeyBindings(self): - return self.chat.keyBindings - def getAppPreferencesGUI(self): return self.chat.getAppPreferencesGUI() def getPreferencesFromGUI(self): return self.chat.getPreferencesFromGUI() - def onTextInserted(self, event): + def _on_text_inserted(self, event): if self.chat.presentInsertedText(event): - return + return True - super().onTextInserted(event) + return super()._on_text_inserted(event) - def onFocusedChanged(self, event): + def _on_focused_changed(self, event): if self._should_ignore_connect_dialog_focus(event): - return + return True - super().onFocusedChanged(event) + return super()._on_focused_changed(event) - def onCaretMoved(self, event): - super().onCaretMoved(event) + def _on_caret_moved(self, event): + result = super()._on_caret_moved(event) self._maybe_announce_message_dialog_input(event.source) + return result def _should_ignore_connect_dialog_focus(self, event): if not event.detail1: @@ -142,5 +136,5 @@ class Script(Qt.Script): self._lastMessageDialogId = dialogHash label = "Message" - voice = self.speechGenerator.voice(string=label) - self.speakMessage(label, voice=voice) + from cthulhu import presentation_manager + presentation_manager.get_manager().speak_message(label) diff --git a/src/cthulhu/scripts/apps/SeaMonkey/script.py b/src/cthulhu/scripts/apps/SeaMonkey/script.py index 2322e0a..11f3311 100644 --- a/src/cthulhu/scripts/apps/SeaMonkey/script.py +++ b/src/cthulhu/scripts/apps/SeaMonkey/script.py @@ -31,15 +31,8 @@ __date__ = "$Date$" __copyright__ = "Copyright (c) 2016 Igalia, S.L." __license__ = "LGPL" -import gi -gi.require_version("Atspi", "2.0") -from gi.repository import Atspi - -from cthulhu import cmdnames from cthulhu import debug -from cthulhu import input_event from cthulhu import cthulhu_state -from cthulhu.ax_object import AXObject from cthulhu.scripts.toolkits import Gecko @@ -48,94 +41,18 @@ class Script(Gecko.Script): def __init__(self, app): super().__init__(app) - def setupInputEventHandlers(self): - super().setupInputEventHandlers() - - self.inputEventHandlers["togglePresentationModeHandler"] = \ - input_event.InputEventHandler( - Script.togglePresentationMode, - cmdnames.TOGGLE_PRESENTATION_MODE) - - self.inputEventHandlers["enableStickyFocusModeHandler"] = \ - input_event.InputEventHandler( - Script.enableStickyFocusMode, - cmdnames.SET_FOCUS_MODE_STICKY) - - self.inputEventHandlers["enableStickyBrowseModeHandler"] = \ - input_event.InputEventHandler( - Script.enableStickyBrowseMode, - cmdnames.SET_BROWSE_MODE_STICKY) - - def onBusyChanged(self, event): + def _on_busy_changed(self, event): """Callback for object:state-changed:busy accessibility events.""" if self.utilities.isContentEditableWithEmbeddedObjects(event.source): msg = "SEAMONKEY: Ignoring, event source is content editable" debug.printMessage(debug.LEVEL_INFO, msg, True) - return + return True table = self.utilities.getTable(cthulhu_state.locusOfFocus) if table and not self.utilities.isTextDocumentTable(table): tokens = ["SEAMONKEY: Ignoring, locusOfFocus is", cthulhu_state.locusOfFocus] debug.printTokens(debug.LEVEL_INFO, tokens, True) - return - - super().onBusyChanged(event) - - def onFocus(self, event): - """Callback for focus: accessibility events.""" - - # We should get proper state-changed events for these. - if self.utilities.inDocumentContent(event.source): - return - - focusRole = AXObject.get_role(cthulhu_state.locusOfFocus) - if focusRole != Atspi.Role.ENTRY or not self.utilities.inDocumentContent(): - super().onFocus(event) - return - - if AXObject.get_role(event.source) == Atspi.Role.MENU: - msg = "SEAMONKEY: Non-document menu claimed focus from document entry" - debug.printMessage(debug.LEVEL_INFO, msg, True) - - if self.utilities.lastInputEventWasPrintableKey(): - msg = "SEAMONKEY: Ignoring, believed to be result of printable input" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - super().onFocus(event) - - def useFocusMode(self, obj, prevObj=None): - if self.utilities.isEditableMessage(obj): - tokens = ["SEAMONKEY: Using focus mode for editable message", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) return True - tokens = ["SEAMONKEY:", obj, "is not an editable message."] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return super().useFocusMode(obj, prevObj) - - def enableStickyBrowseMode(self, inputEvent, forceMessage=False): - if self.utilities.isEditableMessage(cthulhu_state.locusOfFocus): - return - - super().enableStickyBrowseMode(inputEvent, forceMessage) - - def enableStickyFocusMode(self, inputEvent, forceMessage=False): - if self.utilities.isEditableMessage(cthulhu_state.locusOfFocus): - return - - super().enableStickyFocusMode(inputEvent, forceMessage) - - def togglePresentationMode(self, inputEvent, documentFrame=None): - if self._inFocusMode \ - and self.utilities.isEditableMessage(cthulhu_state.locusOfFocus): - return - - super().togglePresentationMode(inputEvent, documentFrame) - - def useStructuralNavigationModel(self, debugOutput=True): - if self.utilities.isEditableMessage(cthulhu_state.locusOfFocus): - return False - - return super().useStructuralNavigationModel(debugOutput) + return super()._on_busy_changed(event) diff --git a/src/cthulhu/scripts/apps/Thunderbird/script.py b/src/cthulhu/scripts/apps/Thunderbird/script.py index 1503c7f..bf73079 100644 --- a/src/cthulhu/scripts/apps/Thunderbird/script.py +++ b/src/cthulhu/scripts/apps/Thunderbird/script.py @@ -1,9 +1,6 @@ -#!/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 2004-2008 Sun Microsystems Inc. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -19,386 +16,65 @@ # 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 """Custom script for Thunderbird.""" -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2004-2008 Sun Microsystems Inc." -__license__ = "LGPL" +from __future__ import annotations -import cthulhu.cthulhu as cthulhu -import cthulhu.cmdnames as cmdnames -import cthulhu.debug as debug -import cthulhu.input_event as input_event -import cthulhu.scripts.default as default -import cthulhu.settings_manager as settings_manager -import cthulhu.cthulhu_state as cthulhu_state -import cthulhu.scripts.toolkits.Gecko as Gecko +from typing import TYPE_CHECKING + +from cthulhu import document_presenter from cthulhu.ax_object import AXObject -from cthulhu.ax_text import AXText from cthulhu.ax_utilities import AXUtilities +from cthulhu.scripts.toolkits import Gecko -from .spellcheck import SpellCheck -from .script_utilities import Utilities +if TYPE_CHECKING: + import gi -_settingsManager = settings_manager.getManager() + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi -######################################################################## -# # -# The Thunderbird script class. # -# # -######################################################################## class Script(Gecko.Script): """The script for Thunderbird.""" - def __init__(self, app): - """ Creates a new script for the given application. + def _is_in_editable_message(self, obj: Atspi.Accessible) -> bool: + """Returns True if obj is in an editable message.""" - Arguments: - - app: the application to create a script for. - """ + return AXUtilities.is_editable(obj) and bool( + AXUtilities.find_ancestor_inclusive(obj, AXUtilities.is_editable_document) + ) - # Store the last autocompleted string for the address fields - # so that we're not too 'chatty'. See bug #533042. - # - self._lastAutoComplete = "" - - if _settingsManager.getSetting('sayAllOnLoad') is None: - _settingsManager.setSetting('sayAllOnLoad', False) - if _settingsManager.getSetting('pageSummaryOnLoad') is None: - _settingsManager.setSetting('pageSummaryOnLoad', False) - - super().__init__(app) - - def setupInputEventHandlers(self): - super().setupInputEventHandlers() - - self.inputEventHandlers["togglePresentationModeHandler"] = \ - input_event.InputEventHandler( - Script.togglePresentationMode, - cmdnames.TOGGLE_PRESENTATION_MODE) - - self.inputEventHandlers["enableStickyFocusModeHandler"] = \ - input_event.InputEventHandler( - Script.enableStickyFocusMode, - cmdnames.SET_FOCUS_MODE_STICKY) - - self.inputEventHandlers["enableStickyBrowseModeHandler"] = \ - input_event.InputEventHandler( - Script.enableStickyBrowseMode, - cmdnames.SET_BROWSE_MODE_STICKY) - - def getSpellCheck(self): - """Returns the spellcheck support for this script.""" - - return SpellCheck(self) - - def getUtilities(self): - """Returns the utilities for this script.""" - - return Utilities(self) - - def getAppPreferencesGUI(self): - """Return a GtkGrid containing the application unique configuration - GUI items for the current application.""" - - grid = super().getAppPreferencesGUI() - - self._sayAllOnLoadCheckButton.set_active( - _settingsManager.getSetting('sayAllOnLoad')) - self._pageSummaryOnLoadCheckButton.set_active( - _settingsManager.getSetting('pageSummaryOnLoad')) - - spellcheck = self.spellcheck.getAppPreferencesGUI() - grid.attach(spellcheck, 0, len(grid.get_children()), 1, 1) - grid.show_all() - - return grid - - def getPreferencesFromGUI(self): - """Returns a dictionary with the app-specific preferences.""" - - prefs = super().getPreferencesFromGUI() - prefs['sayAllOnLoad'] = self._sayAllOnLoadCheckButton.get_active() - prefs['pageSummaryOnLoad'] = self._pageSummaryOnLoadCheckButton.get_active() - prefs.update(self.spellcheck.getPreferencesFromGUI()) - - return prefs - - def locus_of_focus_changed(self, event, oldFocus, newFocus): - """Handles changes of focus of interest to the script.""" - - if self.spellcheck.isSuggestionsItem(newFocus): - includeLabel = not self.spellcheck.isSuggestionsItem(oldFocus) - cthulhu.emitRegionChanged(newFocus) - self.updateBraille(newFocus) - self.spellcheck.presentSuggestionListItem(includeLabel=includeLabel) - return - - super().locus_of_focus_changed(event, oldFocus, newFocus) - - def useFocusMode(self, obj, prevObj=None): - if self.utilities.isEditableMessage(obj): - tokens = ["THUNDERBIRD: Using focus mode for editable message", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return True - - tokens = ["THUNDERBIRD:", obj, "is not an editable message."] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return super().useFocusMode(obj, prevObj) - - def enableStickyBrowseMode(self, inputEvent, forceMessage=False): - if self.utilities.isEditableMessage(cthulhu_state.locusOfFocus): - return - - super().enableStickyBrowseMode(inputEvent, forceMessage) - - def enableStickyFocusMode(self, inputEvent, forceMessage=False): - if self.utilities.isEditableMessage(cthulhu_state.locusOfFocus): - return - - super().enableStickyFocusMode(inputEvent, forceMessage) - - def togglePresentationMode(self, inputEvent, documentFrame=None): - if self._inFocusMode and self.utilities.isEditableMessage(cthulhu_state.locusOfFocus): - return - - super().togglePresentationMode(inputEvent, documentFrame) - - def useStructuralNavigationModel(self, debugOutput=True): - """Returns True if structural navigation should be enabled here.""" - - if self.utilities.isEditableMessage(cthulhu_state.locusOfFocus): - return False - - return super().useStructuralNavigationModel(debugOutput) - - def onFocusedChanged(self, event): - """Callback for object:state-changed:focused accessibility events.""" - - if not event.detail1: - return - - self._lastAutoComplete = "" - obj = event.source - if self.spellcheck.isAutoFocusEvent(event): - cthulhu.setLocusOfFocus(event, event.source, False) - self.updateBraille(cthulhu_state.locusOfFocus) - - if not self.utilities.inDocumentContent(obj): - super().onFocusedChanged(event) - return - - if self.utilities.isEditableMessage(obj): - super().onFocusedChanged(event) - return - - super().onFocusedChanged(event) - - def onBusyChanged(self, event): + def _on_busy_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:busy accessibility events.""" - if self.utilities.isEditableMessage(event.source): - return + if self._is_in_editable_message(event.source): + return True - if self.inFocusMode(): - return + if document_presenter.get_presenter().in_focus_mode(self.app): + return True - obj = event.source - if self.utilities.isDocument(obj) and not event.detail1: - if AXObject.get_name(cthulhu_state.locusOfFocus) \ - and (AXUtilities.is_frame(cthulhu_state.locusOfFocus) \ - or AXUtilities.is_page_tab(cthulhu_state.locusOfFocus)): - cthulhu.setLocusOfFocus(event, event.source, False) + return super()._on_busy_changed(event) - if self.utilities.inDocumentContent(): - self.speakMessage(AXObject.get_name(obj)) - self._presentMessage(obj) - - def onCaretMoved(self, event): + def _on_caret_moved(self, event: Atspi.Event) -> bool: """Callback for object:text-caret-moved accessibility events.""" - if self.utilities.isEditableMessage(event.source): - if event.detail1 == -1: - return - self.spellcheck.setDocumentPosition(event.source, event.detail1) - if self.spellcheck.isActive(): - return + if self._is_in_editable_message(event.source) and event.detail1 == -1: + return True - super().onCaretMoved(event) + return super()._on_caret_moved(event) - def onSelectionChanged(self, event): - """Callback for object:state-changed:showing accessibility events.""" - - # We present changes when the list has focus via focus-changed events. - if event.source == self.spellcheck.getSuggestionsList(): - return + def _on_selection_changed(self, event: Atspi.Event) -> bool: + """Callback for object:selection-changed accessibility events.""" parent = AXObject.get_parent(event.source) if AXUtilities.is_combo_box(parent) and not AXUtilities.is_focused(parent): - return + return True - super().onSelectionChanged(event) + return super()._on_selection_changed(event) - def onSensitiveChanged(self, event): - """Callback for object:state-changed:sensitive accessibility events.""" - - if event.source == self.spellcheck.getChangeToEntry() \ - and self.spellcheck.presentCompletionMessage(): - return - - super().onSensitiveChanged(event) - - def onShowingChanged(self, event): - """Callback for object:state-changed:showing accessibility events.""" - - # TODO - JD: Once there are separate scripts for the Gecko toolkit - # and the Firefox browser, this method can be deleted. It's here - # right now just to prevent the Gecko script from presenting non- - # existent browsery autocompletes for Thunderbird. - - if event.detail1 and self.utilities.isMenuWithNoSelectedChild(event.source) \ - and cthulhu_state.activeWindow == self.utilities.topLevelObject(event.source): - self.presentObject(event.source) - cthulhu.setLocusOfFocus(event, event.source, False) - return - - default.Script.onShowingChanged(self, event) - - def onTextDeleted(self, event): - """Called whenever text is from an object. - - Arguments: - - event: the Event - """ - - if AXUtilities.is_label(event.source) \ - and AXUtilities.is_status_bar(AXObject.get_parent(event.source)): - return - - super().onTextDeleted(event) - - def onTextInserted(self, event): - """Callback for object:text-changed:insert accessibility events.""" - - parent = AXObject.get_parent(event.source) - if AXUtilities.is_label(event.source) and AXUtilities.is_status_bar(parent): - return - - if len(event.any_data) > 1 and event.source == self.spellcheck.getChangeToEntry(): - return - - isSystemEvent = event.type.endswith("system") - - # Try to stop unwanted chatter when a message is being replied to. - # See bgo#618484. - if isSystemEvent and self.utilities.isEditableMessage(event.source): - return - - # Speak the autocompleted text, but only if it is different - # address so that we're not too "chatty." See bug #533042. - if AXUtilities.is_autocomplete(parent): - if len(event.any_data) == 1: - default.Script.onTextInserted(self, event) - return - - if self._lastAutoComplete and self._lastAutoComplete in event.any_data: - return - - # Mozilla cannot seem to get their ":system" suffix right - # to save their lives, so we'll add yet another sad hack. - # Intentional object-local check: autocomplete selection lives in this entry. - selections = AXText.get_selected_ranges(event.source) - hasSelection = bool(selections) - if hasSelection or isSystemEvent: - voice = self.speechGenerator.voice(obj=event.source, string=event.any_data) - self.speakMessage(event.any_data, voice=voice) - self._lastAutoComplete = event.any_data - return - - super().onTextInserted(event) - - def onTextSelectionChanged(self, event): - """Callback for object:text-selection-changed accessibility events.""" - - obj = event.source - spellCheckEntry = self.spellcheck.getChangeToEntry() - if obj == spellCheckEntry: - return - - if self.utilities.isEditableMessage(obj) and self.spellcheck.isActive(): - # Intentional object-local check: spellcheck position is tracked within this entry. - selections = AXText.get_selected_ranges(obj) - if selections: - selStart, selEnd = selections[0] - self.spellcheck.setDocumentPosition(obj, selStart) - return - - super().onTextSelectionChanged(event) - - def onNameChanged(self, event): - """Callback for object:property-change:accessible-name events.""" - - if AXObject.get_name(event.source) == self.spellcheck.getMisspelledWord(): - self.spellcheck.presentErrorDetails() - return - - if not self.utilities.lastInputEventWasDelete() \ - or not self.utilities.isDocument(event.source): - return - - super().onNameChanged(event) - - def _presentMessage(self, documentFrame): - """Presents the first line of the message, or the entire message, - depending on the user's sayAllOnLoad setting.""" - - [obj, offset] = self.utilities.findFirstCaretContext(documentFrame, 0) - self.utilities.setCaretPosition(obj, offset) - self.updateBraille(obj) - if obj and self._navSuspended and self.utilities.inDocumentContent(obj): - self._setNavigationSuspended(False, "message content loaded") - - if _settingsManager.getSetting('pageSummaryOnLoad'): - tokens = ["THUNDERBIRD: Getting page summary for obj", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - summary = self.utilities.getPageSummary(obj) - if summary: - self.presentMessage(summary) - - if not _settingsManager.getSetting('sayAllOnLoad'): - msg = "THUNDERBIRD: SayAllOnLoad is False. Presenting line." - debug.printMessage(debug.LEVEL_INFO, msg, True) - contents = self.utilities.getLineContentsAtOffset(obj, offset) - self.speakContents(contents) - return - - if _settingsManager.getSetting('enableSpeech'): - msg = "THUNDERBIRD: SayAllOnLoad is True and speech is enabled" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.sayAll(None) - - def onWindowActivated(self, event): - """Callback for window:activate accessibility events.""" - - super().onWindowActivated(event) - if not self.spellcheck.isCheckWindow(event.source): - self.spellcheck.deactivate() - return - - self.spellcheck.presentErrorDetails() - cthulhu.setLocusOfFocus(None, self.spellcheck.getChangeToEntry(), False) - self.updateBraille(cthulhu_state.locusOfFocus) - - def onWindowDeactivated(self, event): + def _on_window_deactivated(self, event: Atspi.Event) -> bool: """Callback for window:deactivate accessibility events.""" - super().onWindowDeactivated(event) - self.spellcheck.deactivate() - self.utilities.clearContentCache() + self.utilities.clear_content_cache() + return super()._on_window_deactivated(event) diff --git a/src/cthulhu/scripts/apps/evince/script.py b/src/cthulhu/scripts/apps/evince/script.py index c0bd333..9d9c027 100644 --- a/src/cthulhu/scripts/apps/evince/script.py +++ b/src/cthulhu/scripts/apps/evince/script.py @@ -31,12 +31,9 @@ __date__ = "$Date$" __copyright__ = "Copyright (c) 2013 The Cthulhu Team." __license__ = "LGPL" -import cthulhu.keybindings as keybindings import cthulhu.cthulhu as cthulhu -import cthulhu.cthulhu_state as cthulhu_state import cthulhu.scripts.toolkits.gtk as gtk from cthulhu.ax_utilities import AXUtilities -from cthulhu.structural_navigation import StructuralNavigation ######################################################################## @@ -56,68 +53,11 @@ class Script(gtk.Script): gtk.Script.__init__(self, app) - def setupInputEventHandlers(self): - """Defines InputEventHandler fields for this script that can be - called by the key and braille bindings.""" - - gtk.Script.setupInputEventHandlers(self) - self.inputEventHandlers.update( - self.structuralNavigation.inputEventHandlers) - - def getAppKeyBindings(self): - """Returns the application-specific keybindings for this script.""" - - keyBindings = keybindings.KeyBindings() - bindings = self.structuralNavigation.keyBindings - for keyBinding in bindings.keyBindings: - keyBindings.add(keyBinding) - - return keyBindings - - def getStructuralNavigation(self): - """Returns the 'structural navigation' class for this script.""" - - types = self.getEnabledStructuralNavigationTypes() - return StructuralNavigation(self, types, True) - - def getEnabledStructuralNavigationTypes(self): - """Returns a list of the structural navigation object types - enabled in this script.""" - - enabledTypes = [StructuralNavigation.BUTTON, - StructuralNavigation.CHECK_BOX, - StructuralNavigation.COMBO_BOX, - StructuralNavigation.ENTRY, - StructuralNavigation.FORM_FIELD, - StructuralNavigation.HEADING, - StructuralNavigation.LINK, - StructuralNavigation.LIST, - StructuralNavigation.LIST_ITEM, - StructuralNavigation.PARAGRAPH, - StructuralNavigation.RADIO_BUTTON, - StructuralNavigation.TABLE, - StructuralNavigation.TABLE_CELL, - StructuralNavigation.UNVISITED_LINK, - StructuralNavigation.VISITED_LINK] - - return enabledTypes - - def useStructuralNavigationModel(self, debugOutput=True): - """Returns True if we should do our own structural navigation.""" - - if not self.structuralNavigation.enabled: - return False - - if AXUtilities.is_editable(cthulhu_state.locusOfFocus): - return False - - return True - - def onCaretMoved(self, event): + def _on_caret_moved(self, event): """Callback for object:text-caret-moved accessibility events.""" obj = event.source if AXUtilities.is_focused(obj): cthulhu.setLocusOfFocus(event, event.source, False) - gtk.Script.onCaretMoved(self, event) + return gtk.Script._on_caret_moved(self, event) diff --git a/src/cthulhu/scripts/apps/evolution/script.py b/src/cthulhu/scripts/apps/evolution/script.py index d716712..3e765dc 100644 --- a/src/cthulhu/scripts/apps/evolution/script.py +++ b/src/cthulhu/scripts/apps/evolution/script.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 2005-2008 Sun Microsystems Inc. +# Copyright 2013 Igalia, S.L. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -19,172 +17,89 @@ # 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 """Custom script for Evolution.""" -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2005-2008 Sun Microsystems Inc." \ - "Copyright (c) 2013 Igalia, S.L." -__license__ = "LGPL" +from __future__ import annotations +from typing import TYPE_CHECKING -import cthulhu.debug as debug -import cthulhu.cthulhu as cthulhu -import cthulhu.cthulhu_state as cthulhu_state -import cthulhu.scripts.toolkits.gtk as gtk -import cthulhu.scripts.toolkits.WebKitGtk as WebKitGtk -import cthulhu.settings_manager as settings_manager -from cthulhu.ax_object import AXObject +from cthulhu import debug from cthulhu.ax_utilities import AXUtilities +from cthulhu.scripts.toolkits import WebKitGtk, gtk from .braille_generator import BrailleGenerator -from .speech_generator import SpeechGenerator from .script_utilities import Utilities +from .speech_generator import SpeechGenerator -_settingsManager = settings_manager.getManager() +if TYPE_CHECKING: + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi -######################################################################## -# # -# The Evolution script class. # -# # -######################################################################## class Script(WebKitGtk.Script, gtk.Script): + """Custom script for Evolution.""" - def __init__(self, app): - """Creates a new script for the given application. + # For the no-such-function when the function is only in the subclass. + utilities: Utilities - Arguments: - - app: the application to create a script for. - """ + def _create_braille_generator(self) -> BrailleGenerator: + """Creates and returns the braille generator for this script.""" - if _settingsManager.getSetting('sayAllOnLoad') is None: - _settingsManager.setSetting('sayAllOnLoad', False) - - super().__init__(app) - self.presentIfInactive = False - - def getBrailleGenerator(self): return BrailleGenerator(self) - def getSpeechGenerator(self): + def _create_speech_generator(self) -> SpeechGenerator: + """Creates and returns the speech generator for this script.""" + return SpeechGenerator(self) - def getUtilities(self): + def get_utilities(self) -> Utilities: + """Returns the utilities for this script.""" + return Utilities(self) - def isActivatableEvent(self, event): - """Returns True if the given event is one that should cause this - script to become the active script. This is only a hint to - the focus tracking manager and it is not guaranteed this - request will be honored. Note that by the time the focus - tracking manager calls this method, it thinks the script - should become active. This is an opportunity for the script - to say it shouldn't. - """ + def _on_busy_changed(self, event: Atspi.Event) -> bool: + """Callback for object:state-changed:busy accessibility events.""" - if event.type.startswith("focus:") and AXUtilities.is_menu(event.source): + if self.utilities.is_ignorable_event_from_document_preview(event): + msg = "EVOLUTION: Ignoring event from document preview" + debug.print_message(debug.LEVEL_INFO, msg, True) return True - window = self.utilities.topLevelObject(event.source) - if not AXUtilities.is_active(window): - return False + msg = "EVOLUTION: Passing event to super class for processing." + debug.print_message(debug.LEVEL_INFO, msg, True) + return super()._on_busy_changed(event) - return True + def _on_caret_moved(self, event: Atspi.Event) -> bool: + """Callback for object:text-caret-moved accessibility events.""" - def stopSpeechOnActiveDescendantChanged(self, event): - """Whether or not speech should be stopped prior to setting the - locusOfFocus in onActiveDescendantChanged. - - Arguments: - - event: the Event - - Returns True if speech should be stopped; False otherwise. - """ - - return False - - ######################################################################## - # # - # AT-SPI OBJECT EVENT HANDLERS # - # # - ######################################################################## - - def onActiveDescendantChanged(self, event): - """Callback for object:active-descendant-changed accessibility events.""" - - if not event.any_data: - msg = "EVOLUTION: Ignoring event. No any_data." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - if self.utilities.isComposeAutocomplete(event.source): - if AXUtilities.is_selected(event.any_data): - msg = "EVOLUTION: Source is compose autocomplete with selected child." - debug.printMessage(debug.LEVEL_INFO, msg, True) - cthulhu.setLocusOfFocus(event, event.any_data) - else: - msg = "EVOLUTION: Source is compose autocomplete without selected child." - debug.printMessage(debug.LEVEL_INFO, msg, True) - cthulhu.setLocusOfFocus(event, event.source) - return - - if AXUtilities.is_table_cell(cthulhu_state.locusOfFocus): - table = AXObject.find_ancestor( - cthulhu_state.locusOfFocus, AXUtilities.is_tree_or_tree_table) - if table is not None and table != event.source: - msg = "EVOLUTION: Event is from a different tree or tree table." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - child = AXObject.get_active_descendant_checked(event.source, event.any_data) - if child is not None and child != event.any_data: - tokens = ["EVOLUTION: Bogus any_data suspected. Setting focus to", child] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - cthulhu.setLocusOfFocus(event, child) - return + if self.utilities.is_ignorable_event_from_document_preview(event): + msg = "EVOLUTION: Ignoring event from document preview" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True msg = "EVOLUTION: Passing event to super class for processing." - debug.printMessage(debug.LEVEL_INFO, msg, True) - super().onActiveDescendantChanged(event) + debug.print_message(debug.LEVEL_INFO, msg, True) + return super()._on_caret_moved(event) - def onBusyChanged(self, event): - """Callback for object:state-changed:busy accessibility events.""" - pass + def _on_focused_changed(self, event: Atspi.Event) -> bool: + """Callback for object:state-changed:focused accessibility events.""" - def onFocus(self, event): - """Callback for focus: accessibility events.""" + if self.utilities.is_ignorable_event_from_document_preview(event): + msg = "EVOLUTION: Ignoring event from document preview" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - if self.utilities.isWebKitGtk(event.source): - return + # TODO - JD: Figure out what's causing this in Evolution or WebKit and file a bug. + # When the selected message changes and the preview panel is showing, a panel with the + # `iframe` tag claims focus. We don't want to update our location in response. + if AXUtilities.is_internal_frame(event.source): + tokens = ["EVOLUTION: Ignoring event from internal frame", event.source] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return True - # This is some mystery child of the 'Messages' panel which fails to show - # up in the hierarchy or emit object:state-changed:focused events. - if AXUtilities.is_layered_pane(event.source): - obj = self.utilities.realActiveDescendant(event.source) - cthulhu.setLocusOfFocus(event, obj) - return - - gtk.Script.onFocus(self, event) - - def onNameChanged(self, event): - """Callback for object:property-change:accessible-name events.""" - - if self.utilities.isWebKitGtk(event.source): - return - - gtk.Script.onNameChanged(self, event) - - def onSelectionChanged(self, event): - """Callback for object:selection-changed accessibility events.""" - - if AXUtilities.is_combo_box(event.source) \ - and not AXUtilities.is_focused(event.source): - return - - gtk.Script.onSelectionChanged(self, event) + msg = "EVOLUTION: Passing event to super class for processing." + debug.print_message(debug.LEVEL_INFO, msg, True) + return super()._on_focused_changed(event) diff --git a/src/cthulhu/scripts/apps/gajim/script.py b/src/cthulhu/scripts/apps/gajim/script.py index 4161f08..4cac895 100644 --- a/src/cthulhu/scripts/apps/gajim/script.py +++ b/src/cthulhu/scripts/apps/gajim/script.py @@ -1,9 +1,6 @@ -#!/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 2010 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,92 +16,40 @@ # 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 """Custom script for Gajim.""" -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2010 Joanmarie Diggs." -__license__ = "LGPL" +from __future__ import annotations -import gi -gi.require_version("Atspi", "2.0") -from gi.repository import Atspi +from typing import TYPE_CHECKING -import cthulhu.chat as chat -import cthulhu.scripts.default as default +from cthulhu import chat_presenter from cthulhu.ax_utilities import AXUtilities +from cthulhu.scripts import default + +if TYPE_CHECKING: + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi -######################################################################## -# # -# The Empathy script class. # -# # -######################################################################## class Script(default.Script): + """Custom script for Gajim.""" - def __init__(self, app): - """Creates a new script for the given application.""" + def _on_text_inserted(self, event: Atspi.Event) -> bool: + """Callback for object:text-changed:insert accessibility events.""" - # So we can take an educated guess at identifying the buddy list. - # - self._buddyListAncestries = [[Atspi.Role.TABLE, - Atspi.Role.SCROLL_PANE, - Atspi.Role.FILLER, - Atspi.Role.SPLIT_PANE, - Atspi.Role.FILLER, - Atspi.Role.FRAME]] + if chat_presenter.get_presenter().present_inserted_text(self, event): + return True - default.Script.__init__(self, app) + return super()._on_text_inserted(event) - def getChat(self): - """Returns the 'chat' class for this script.""" - - return chat.Chat(self, self._buddyListAncestries) - - def setupInputEventHandlers(self): - """Defines InputEventHandler fields for this script that can be - called by the key and braille bindings. Here we need to add the - handlers for chat functionality. - """ - - default.Script.setupInputEventHandlers(self) - self.inputEventHandlers.update(self.chat.inputEventHandlers) - - def getAppKeyBindings(self): - """Returns the application-specific keybindings for this script.""" - - return self.chat.keyBindings - - def getAppPreferencesGUI(self): - """Return a GtkGrid containing the application unique configuration - GUI items for the current application. The chat-related options get - created by the chat module.""" - - return self.chat.getAppPreferencesGUI() - - def getPreferencesFromGUI(self): - """Returns a dictionary with the app-specific preferences.""" - - return self.chat.getPreferencesFromGUI() - - def onTextInserted(self, event): - """Called whenever text is added to an object.""" - - if self.chat.presentInsertedText(event): - return - - default.Script.onTextInserted(self, event) - - def onWindowActivated(self, event): - """Called whenever a toplevel window is activated.""" + def _on_window_activated(self, event: Atspi.Event) -> bool: + """Callback for window:activate accessibility events.""" # Hack to "tickle" the accessible hierarchy. Otherwise, the # events we need to present text added to the chatroom are # missing. AXUtilities.find_all_page_tabs(event.source) - default.Script.onWindowActivated(self, event) + return super()._on_window_activated(event) diff --git a/src/cthulhu/scripts/apps/gnome-shell/script.py b/src/cthulhu/scripts/apps/gnome-shell/script.py index 75dd8d2..b1a9e75 100644 --- a/src/cthulhu/scripts/apps/gnome-shell/script.py +++ b/src/cthulhu/scripts/apps/gnome-shell/script.py @@ -1,9 +1,9 @@ -#!/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 (C) 2010-2013 Igalia, S.L. +# +# Author: Alejandro Pinheiro Iglesias +# 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,140 +19,123 @@ # 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 -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2010-2013 Igalia, S.L." -__license__ = "LGPL" +"""Custom script for gnome-shell.""" -import cthulhu.debug as debug -import cthulhu.cthulhu as cthulhu -import cthulhu.cthulhu_state as cthulhu_state -import cthulhu.scripts.toolkits.clutter as clutter +from __future__ import annotations + +from typing import TYPE_CHECKING + +from cthulhu import debug, focus_manager, presentation_manager from cthulhu.ax_object import AXObject -from cthulhu.ax_text import AXText from cthulhu.ax_utilities import AXUtilities +from cthulhu.scripts import default -from .formatting import Formatting from .script_utilities import Utilities -class Script(clutter.Script): +if TYPE_CHECKING: + import gi - def __init__(self, app): - clutter.Script.__init__(self, app) + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi - def getFormatting(self): - """Returns the formatting strings for this script.""" - return Formatting(self) - def getUtilities(self): +class Script(default.Script): + """Custom script for gnome-shell.""" + + def get_utilities(self) -> Utilities: + """Returns the utilities for this script.""" + return Utilities(self) - def deactivate(self): - """Called when this script is deactivated.""" + def is_activatable_event(self, event: Atspi.Event) -> bool: + """Returns True if event should cause this script to become active.""" - self.utilities.clearCachedObjects() - super().deactivate() + if event.type.startswith("object:state-changed:selected") and event.detail1: + return True - def skipObjectEvent(self, event): - """Determines whether or not this event should be skipped due to - being redundant, part of an event flood, etc.""" + return super().is_activatable_event(event) - if AXUtilities.is_window(event.source): - return self.utilities.isBogusWindowFocusClaim(event) + def locus_of_focus_changed( + self, + event: Atspi.Event | None, + old_focus: Atspi.Accessible | None, + new_focus: Atspi.Accessible | None, + ) -> bool: + """Handles changes of focus of interest. Returns True if this script did all needed work.""" - return clutter.Script.skipObjectEvent(self, event) - - def locus_of_focus_changed(self, event, oldFocus, newFocus): - if event is not None and event.type == "window:activate" \ - and newFocus is not None and not AXObject.get_name(newFocus): - queuedEvent = self._getQueuedEvent("object:state-changed:focused", True) - if queuedEvent and queuedEvent.source != event.source: + # TODO - JD: This workaround no longer works because the window has a name. + if ( + event is not None + and event.type == "window:activate" + and new_focus is not None + and not AXObject.get_name(new_focus) + ): + queued_event = self._get_queued_event("object:state-changed:focused", True) + if queued_event and queued_event.source != event.source: msg = "GNOME SHELL: Have matching focused event. Not announcing nameless window." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - super().locus_of_focus_changed(event, oldFocus, newFocus) + return super().locus_of_focus_changed(event, old_focus, new_focus) - def onNameChanged(self, event): - """Callback for object:property-change:accessible-name events.""" - - if not AXUtilities.is_label(event.source): - clutter.Script.onNameChanged(self, event) - return - - # If we're already in a dialog, and a label inside that dialog changes its name, - # present the new name. Example: the "Command not found" label in the Run dialog. - dialog = AXObject.find_ancestor(cthulhu_state.locusOfFocus, AXUtilities.is_dialog) - tokens = ["GNOME SHELL: focus", cthulhu_state.locusOfFocus, "is in dialog:", dialog] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - if dialog and AXObject.is_ancestor(event.source, dialog): - msg = "GNOME SHELL: Label changed name in current dialog. Presenting." - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.presentMessage(AXObject.get_name(event.source)) - - def onSelectedChanged(self, event): - """Callback for object:state-changed:selected accessibility events.""" - - # Some buttons, like the Wikipedia button, claim to be selected but - # lack STATE_SELECTED. The other buttons, such as in the Dash and - # event switcher, seem to have the right state. Since the ones with - # the wrong state seem to be things we don't want to present anyway - # we'll stop doing so and hope we are right. - if event.detail1: - if AXUtilities.is_panel(event.source): - AXObject.clear_cache(event.source) - if AXUtilities.is_selected(event.source): - cthulhu.setLocusOfFocus(event, event.source) - return - - clutter.Script.onSelectedChanged(self, event) - - def onFocusedChanged(self, event): + def _on_focused_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:focused accessibility events.""" if not event.detail1: - return + return True # We're getting a spurious focus claim from the gnome-shell window after # the window switcher is used. if AXUtilities.is_window(event.source): - return - - if not AXObject.get_name(event.source) and AXUtilities.is_menu_item(event.source) \ - and not self.utilities.labelsForObject(event.source): - descendant = AXObject.find_descendant(event.source, AXUtilities.is_slider) - if descendant is not None: - cthulhu.setLocusOfFocus(event, descendant) - return - - clutter.Script.onFocusedChanged(self, event) - - def echoPreviousWord(self, obj, offset=None): - if not AXObject.supports_text(obj): - return False - - if not offset: - caretOffset = AXText.get_caret_offset(obj) - if caretOffset == -1: - offset = AXText.get_character_count(obj) - 1 - else: - offset = caretOffset - 1 - - if offset == 0: - return False - - return super().echoPreviousWord(obj, offset) - - def isActivatableEvent(self, event): - if event.type.startswith('object:state-changed:selected') and event.detail1: return True - if self.utilities.isBogusWindowFocusClaim(event): - return False + focus = focus_manager.get_manager().get_locus_of_focus() + if AXUtilities.is_panel(event.source) and AXUtilities.is_ancestor(focus, event.source): + msg = "GNOME SHELL: Event ignored: Source is panel ancestor of current focus." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - return super().isActivatableEvent(event) + if ( + not AXObject.get_name(event.source) + and AXUtilities.is_menu_item(event.source) + and not AXUtilities.get_is_labelled_by(event.source) + ): + descendant = AXUtilities.get_slider(event.source) + if descendant is not None: + focus_manager.get_manager().set_locus_of_focus(event, descendant) + return True + + return super()._on_focused_changed(event) + + def _on_name_changed(self, event: Atspi.Event) -> bool: + """Callback for object:property-change:accessible-name events.""" + + if not AXUtilities.is_label(event.source): + return super()._on_name_changed(event) + + # If we're already in a dialog, and a label inside that dialog changes its name, + # present the new name. Example: the "Command not found" label in the Run dialog. + dialog = AXUtilities.find_ancestor( + focus_manager.get_manager().get_locus_of_focus(), + AXUtilities.is_dialog, + ) + tokens = ["GNOME SHELL: focus is in dialog:", dialog] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if dialog and AXUtilities.is_ancestor(event.source, dialog): + msg = "GNOME SHELL: Label changed name in current dialog. Presenting." + debug.print_message(debug.LEVEL_INFO, msg, True) + presentation_manager.get_manager().present_message(AXObject.get_name(event.source)) + + return True + + def _on_selected_changed(self, event: Atspi.Event) -> bool: + """Callback for object:state-changed:selected accessibility events.""" + + # gnome-shell fails to implement the selection interface but fires state-changed + # selected in the switcher and similar containers. + if AXUtilities.is_selected(event.source): + focus_manager.get_manager().set_locus_of_focus(event, event.source) + return True + + return super()._on_selected_changed(event) diff --git a/src/cthulhu/scripts/apps/kwin/script.py b/src/cthulhu/scripts/apps/kwin/script.py index dd05c9a..e37d43e 100644 --- a/src/cthulhu/scripts/apps/kwin/script.py +++ b/src/cthulhu/scripts/apps/kwin/script.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 2019 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,18 +17,10 @@ # 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 + """Custom script for kwin.""" -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2019 Igalia, S.L." -__license__ = "LGPL" - from cthulhu.scripts import switcher from cthulhu.scripts.toolkits import Qt @@ -38,13 +28,9 @@ from .script_utilities import Utilities class Script(switcher.Script, Qt.Script): + """Custom script for kwin.""" - def __init__(self, app): - """Creates a new script for the given application.""" - - super().__init__(app) - - def getUtilities(self): + def get_utilities(self) -> Utilities: """Returns the utilities for this script.""" return Utilities(self) diff --git a/src/cthulhu/scripts/apps/notification-daemon/script.py b/src/cthulhu/scripts/apps/notification-daemon/script.py index 6c98acf..cb684f2 100644 --- a/src/cthulhu/scripts/apps/notification-daemon/script.py +++ b/src/cthulhu/scripts/apps/notification-daemon/script.py @@ -1,9 +1,6 @@ -#!/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 2004-2008 Sun Microsystems Inc. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -19,48 +16,36 @@ # 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 -""" Custom script for The notification daemon.""" -__id__ = "" -__version__ = "" -__date__ = "" -__copyright__ = "Copyright (c) 2005-2008 Sun Microsystems Inc." -__license__ = "LGPL" +"""Custom script for The notification daemon.""" -import cthulhu.messages as messages -import cthulhu.scripts.default as default -import cthulhu.settings as settings -from cthulhu.ax_object import AXObject +from __future__ import annotations + +from typing import TYPE_CHECKING + +from cthulhu import messages, notification_presenter, presentation_manager +from cthulhu.ax_text import AXText from cthulhu.ax_utilities import AXUtilities +from cthulhu.scripts import default +if TYPE_CHECKING: + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi -######################################################################## -# # -# The notification-daemon script class. # -# # -######################################################################## class Script(default.Script): + """Custom script for The notification daemon.""" - def onWindowCreated(self, event): + def _on_window_created(self, event: Atspi.Event) -> bool: """Callback for window:create accessibility events.""" - allLabels = AXUtilities.find_all_labels(event.source) - texts = [] - for acc in allLabels: - text = self.utilities.displayedText(acc) or AXObject.get_name(acc) - if text: - texts.append(text) + texts = [AXText.get_all_text(acc) for acc in AXUtilities.find_all_labels(event.source)] + text = f"{messages.NOTIFICATION} {' '.join(texts)}" - text = messages.NOTIFICATION - if texts: - text = f"{text} {' '.join(texts)}" - - voice = self.speechGenerator.voice(obj=event.source, string=text) - self.speakMessage(text, voice=voice) - self.displayBrailleMessage(text, flashTime=settings.brailleFlashTime) - self.notificationPresenter.save_notification(text) + presenter = presentation_manager.get_manager() + presenter.speak_accessible_text(event.source, text) + presenter.present_braille_message(text) + notification_presenter.get_presenter().save_notification(text) diff --git a/src/cthulhu/scripts/apps/pidgin/script.py b/src/cthulhu/scripts/apps/pidgin/script.py index 046ce21..bfaf724 100644 --- a/src/cthulhu/scripts/apps/pidgin/script.py +++ b/src/cthulhu/scripts/apps/pidgin/script.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 2004-2008 Sun Microsystems Inc. +# Copyright 2010 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,213 +17,91 @@ # 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 + """Custom script for pidgin.""" -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2010 Joanmarie Diggs." -__license__ = "LGPL" +from __future__ import annotations -import gi -gi.require_version("Atspi", "2.0") -from gi.repository import Atspi +from typing import TYPE_CHECKING -import cthulhu.debug as debug -import cthulhu.messages as messages -import cthulhu.scripts.toolkits.GAIL as GAIL -import cthulhu.settings as settings +from cthulhu import chat_presenter, debug, messages, presentation_manager from cthulhu.ax_object import AXObject from cthulhu.ax_utilities import AXUtilities +from cthulhu.scripts.toolkits import gtk -from .chat import Chat from .script_utilities import Utilities from .speech_generator import SpeechGenerator -######################################################################## -# # -# The Pidgin script class. # -# # -######################################################################## +if TYPE_CHECKING: + import gi -class Script(GAIL.Script): + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi - def __init__(self, app): - """Creates a new script for the given application. - Arguments: - - app: the application to create a script for. - """ +class Script(gtk.Script): + """Custom script for pidgin.""" - # So we can take an educated guess at identifying the buddy list. - # - self._buddyListAncestries = [[Atspi.Role.TREE_TABLE, - Atspi.Role.SCROLL_PANE, - Atspi.Role.FILLER, - Atspi.Role.PAGE_TAB, - Atspi.Role.PAGE_TAB_LIST, - Atspi.Role.FILLER, - Atspi.Role.FRAME]] + # Override the base class type annotation + utilities: Utilities - GAIL.Script.__init__(self, app) - - def getChat(self): - """Returns the 'chat' class for this script.""" - - return Chat(self, self._buddyListAncestries) - - def getSpeechGenerator(self): - """Returns the speech generator for this script. """ + def _create_speech_generator(self) -> SpeechGenerator: + """Creates and returns the speech generator for this script.""" return SpeechGenerator(self) - def getUtilities(self): + def get_utilities(self) -> Utilities: """Returns the utilities for this script.""" return Utilities(self) - def setupInputEventHandlers(self): - """Defines InputEventHandler fields for this script that can be - called by the key and braille bindings. Here we need to add the - handlers for chat functionality. - """ - - GAIL.Script.setupInputEventHandlers(self) - self.inputEventHandlers.update(self.chat.inputEventHandlers) - - def getAppKeyBindings(self): - """Returns the application-specific keybindings for this script.""" - - return self.chat.keyBindings - - def getAppPreferencesGUI(self): - """Return a GtkGrid containing the application unique configuration - GUI items for the current application. The chat-related options get - created by the chat module.""" - - return self.chat.getAppPreferencesGUI() - - def getPreferencesFromGUI(self): - """Returns a dictionary with the app-specific preferences.""" - - return self.chat.getPreferencesFromGUI() - - def onChildrenAdded(self, event): + def _on_children_added(self, event: Atspi.Event) -> bool: """Callback for object:children-changed:add accessibility events.""" - AXObject.clear_cache_now("children-changed event.") + super()._on_children_added(event) + if not AXUtilities.is_page_tab_list(event.source): + return True - # Check to see if a new chat room tab has been created and if it - # has, then announce its name. See bug #469098 for more details. - # - if event.type.startswith("object:children-changed:add"): - rolesList = [Atspi.Role.PAGE_TAB_LIST, - Atspi.Role.FILLER, - Atspi.Role.FRAME] - if self.utilities.hasMatchingHierarchy(event.source, rolesList): - # As it's possible to get this component hierarchy in other - # places than the chat room (i.e. the Preferences dialog), - # we check to see if the name of the frame is the same as one - # of its children. If it is, then it's a chat room tab event. - # For a final check, we only announce the new chat tab if the - # last child has a name. - # - nameFound = False - frame = AXObject.find_ancestor(event.source, - lambda x: AXObject.get_role(x) == Atspi.Role.FRAME) - frameName = AXObject.get_name(frame) - if not frameName: - return - for child in AXObject.iter_children(event.source): - if frameName == AXObject.get_name(child): - nameFound = True - break - if nameFound: - child = AXObject.get_child(event.source, -1) - childName = AXObject.get_name(child) - if childName: - line = messages.CHAT_NEW_TAB % childName - voice = self.speechGenerator.voice(obj=child, string=line) - self.speakMessage(line, voice=voice) + AXObject.clear_cache(event.source, True, "to ensure tab info is current.") - def onNameChanged(self, event): - """Called whenever a property on an object changes. + if AXUtilities.is_selected(event.any_data): + msg = "PIDGIN: Not presenting addition of already-selected tab" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - Arguments: - - event: the Event - """ - - if self.chat.isInBuddyList(event.source): - return + # In the chat window, the frame name changes to reflect the active chat. + # So if we don't have a matching tab, this isn't the chat window. + frame = AXUtilities.find_ancestor(event.source, AXUtilities.is_frame) + frame_name = AXObject.get_name(frame) + for child in AXObject.iter_children(event.source): + if frame_name == AXObject.get_name(child): + break else: - GAIL.Script.onNameChanged(self, event) + tokens = ["PIDGIN:", frame, "does not seem to be a chat window"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return True - def onTextDeleted(self, event): - """Called whenever text is deleted from an object. + line = messages.CHAT_NEW_TAB % AXObject.get_name(event.any_data) + presentation_manager.get_manager().speak_accessible_text(event.any_data, line) + return True - Arguments: - - event: the Event - """ - - if self.chat.isInBuddyList(event.source): - return - else: - GAIL.Script.onTextDeleted(self, event) - - def onTextInserted(self, event): - """Called whenever text is added to an object.""" - - if self.chat.presentInsertedText(event): - return - - GAIL.Script.onTextInserted(self, event) - - def onValueChanged(self, event): - """Called whenever an object's value changes. Currently, the - value changes for non-focused objects are ignored. - - Arguments: - - event: the Event - """ - - if self.chat.isInBuddyList(event.source): - return - else: - GAIL.Script.onValueChanged(self, event) - - def onWindowActivated(self, event): - """Called whenever a toplevel window is activated.""" - - if not settings.enableSadPidginHack: - msg = "PIDGIN: Hack for missing events disabled" - debug.printMessage(debug.LEVEL_INFO, msg, True) - GAIL.Script.onWindowActivated(self, event) - return - - msg = "PIDGIN: Starting hack for missing events" - debug.printMessage(debug.LEVEL_INFO, msg, True) - - # Hack to "tickle" the accessible hierarchy. Otherwise, the - # events we need to present text added to the chatroom are - # missing. - AXUtilities.find_all_page_tabs(event.source) - - msg = "PIDGIN: Hack to work around missing events complete" - debug.printMessage(debug.LEVEL_INFO, msg, True) - GAIL.Script.onWindowActivated(self, event) - - def onExpandedChanged(self, event): + def _on_expanded_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:expanded accessibility events.""" # Overridden here because the event.source is in a hidden column. obj = event.source - if self.chat.isInBuddyList(obj): + if self.chat.is_in_buddy_list(obj): obj = AXObject.get_next_sibling(obj) - self.presentObject(obj, alreadyFocused=True) - return + self.present_object(obj, alreadyFocused=True) + return True - GAIL.Script.onExpandedChanged(self, event) + return super()._on_expanded_changed(event) + + def _on_text_inserted(self, event: Atspi.Event) -> bool: + """Callback for object:text-changed:insert accessibility events.""" + + if chat_presenter.get_presenter().present_inserted_text(self, event): + return True + + return super()._on_text_inserted(event) diff --git a/src/cthulhu/scripts/apps/smuxi-frontend-gnome/script.py b/src/cthulhu/scripts/apps/smuxi-frontend-gnome/script.py index 0118954..d7268f6 100644 --- a/src/cthulhu/scripts/apps/smuxi-frontend-gnome/script.py +++ b/src/cthulhu/scripts/apps/smuxi-frontend-gnome/script.py @@ -1,9 +1,8 @@ -#!/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 2018 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,70 +18,31 @@ # 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 + """Custom script for Smuxi.""" -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2018 Igalia, S.L." -__license__ = "LGPL" +from __future__ import annotations -import gi -gi.require_version("Atspi", "2.0") -from gi.repository import Atspi +from typing import TYPE_CHECKING -import cthulhu.scripts.toolkits.GAIL as GAIL -from .chat import Chat +from cthulhu import chat_presenter +from cthulhu.scripts.toolkits import gtk -class Script(GAIL.Script): +if TYPE_CHECKING: + import gi - def __init__(self, app): - """Creates a new script for the given application.""" + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi - # So we can take an educated guess at identifying the buddy list. - self._buddyListAncestries = [[Atspi.Role.TREE_TABLE, - Atspi.Role.SCROLL_PANE, - Atspi.Role.SPLIT_PANE, - Atspi.Role.SPLIT_PANE, - Atspi.Role.FILLER, - Atspi.Role.FRAME]] - super().__init__(app) +class Script(gtk.Script): + """Custom script for Smuxi.""" - def getChat(self): - """Returns the 'chat' class for this script.""" + def _on_text_inserted(self, event: Atspi.Event) -> bool: + """Callback for object:text-changed:insert accessibility events.""" - return Chat(self, self._buddyListAncestries) + if chat_presenter.get_presenter().present_inserted_text(self, event): + return True - def setupInputEventHandlers(self): - """Defines InputEventHandler fields for this script.""" - - super().setupInputEventHandlers() - self.inputEventHandlers.update(self.chat.inputEventHandlers) - - def getAppKeyBindings(self): - """Returns the application-specific keybindings for this script.""" - - return self.chat.keyBindings - - def getAppPreferencesGUI(self): - """Return a GtkGrid containing the application unique configuration.""" - - return self.chat.getAppPreferencesGUI() - - def getPreferencesFromGUI(self): - """Returns a dictionary with the app-specific preferences.""" - - return self.chat.getPreferencesFromGUI() - - def onTextInserted(self, event): - """Called whenever text is added to an object.""" - - if self.chat.presentInsertedText(event): - return - - super().onTextInserted(event) + return super()._on_text_inserted(event) diff --git a/src/cthulhu/scripts/apps/soffice/script.py b/src/cthulhu/scripts/apps/soffice/script.py index b6dab74..ec6eba8 100644 --- a/src/cthulhu/scripts/apps/soffice/script.py +++ b/src/cthulhu/scripts/apps/soffice/script.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 2005-2009 Sun Microsystems Inc. +# Copyright 2011-2016 Igalia, S.L. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -19,974 +17,369 @@ # 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 """Custom script for LibreOffice.""" -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2005-2009 Sun Microsystems Inc." \ - "Copyright (c) 2010-2013 The Cthulhu Team." -__license__ = "LGPL" +from __future__ import annotations -import gi -gi.require_version("Atspi", "2.0") -from gi.repository import Atspi -from gi.repository import Gtk +from typing import TYPE_CHECKING -import cthulhu.cmdnames as cmdnames -import cthulhu.debug as debug -import cthulhu.scripts.default as default -import cthulhu.guilabels as guilabels -import cthulhu.keybindings as keybindings -import cthulhu.input_event as input_event -import cthulhu.messages as messages -import cthulhu.cthulhu as cthulhu -import cthulhu.cthulhu_state as cthulhu_state -import cthulhu.settings_manager as settings_manager -import cthulhu.structural_navigation as structural_navigation +from cthulhu import ( + braille_presenter, + debug, + flat_review_presenter, + focus_manager, + input_event, + input_event_manager, + messages, + presentation_manager, + speech_presenter, + structural_navigator, + table_navigator, + typing_echo_presenter, +) from cthulhu.ax_object import AXObject +from cthulhu.ax_table import AXTable from cthulhu.ax_text import AXText from cthulhu.ax_utilities import AXUtilities +from cthulhu.scripts import default from .braille_generator import BrailleGenerator -from .formatting import Formatting from .script_utilities import Utilities -from .spellcheck import SpellCheck from .speech_generator import SpeechGenerator -_settingsManager = settings_manager.getManager() +if TYPE_CHECKING: + from gi.repository import Atspi + class Script(default.Script): + """Custom script for LibreOffice.""" - def __init__(self, app): - """Creates a new script for the given application. + # Override the base class type annotations + utilities: Utilities - Arguments: - - app: the application to create a script for. - """ + def _create_braille_generator(self) -> BrailleGenerator: + """Creates and returns the braille generator for this script.""" - default.Script.__init__(self, app) - - self.speakSpreadsheetCoordinatesCheckButton = None - self.alwaysSpeakSelectedSpreadsheetRangeCheckButton = None - self.skipBlankCellsCheckButton = None - self.speakCellCoordinatesCheckButton = None - self.speakCellHeadersCheckButton = None - self.speakCellSpanCheckButton = None - - # The spreadsheet input line. - # - self.inputLineForCell = None - - # Dictionaries for the calc and writer dynamic row and column headers. - # - self.dynamicColumnHeaders = {} - self.dynamicRowHeaders = {} - - def getBrailleGenerator(self): - """Returns the braille generator for this script. - """ return BrailleGenerator(self) - def getSpeechGenerator(self): - """Returns the speech generator for this script. - """ + def _create_speech_generator(self) -> SpeechGenerator: + """Creates and returns the speech generator for this script.""" + return SpeechGenerator(self) - def getSpellCheck(self): - """Returns the spellcheck for this script.""" - - return SpellCheck(self) - - def getFormatting(self): - """Returns the formatting strings for this script.""" - return Formatting(self) - - def getUtilities(self): + def get_utilities(self) -> Utilities: """Returns the utilities for this script.""" return Utilities(self) - def getStructuralNavigation(self): - """Returns the 'structural navigation' class for this script. - """ - types = self.getEnabledStructuralNavigationTypes() - return structural_navigation.StructuralNavigation(self, types, enabled=False) + def _pan_braille_left(self, event: input_event.InputEvent | None = None) -> bool: + """Pans the braille display to the left.""" - def getEnabledStructuralNavigationTypes(self): - """Returns a list of the structural navigation object types - enabled in this script. - """ + focus = focus_manager.get_manager().get_locus_of_focus() + if ( + flat_review_presenter.get_presenter().is_active() + or AXUtilities.is_spreadsheet_cell(focus) + or not AXUtilities.is_paragraph(focus) + ): + return super()._pan_braille_left(event) - enabledTypes = [structural_navigation.StructuralNavigation.TABLE_CELL] - - return enabledTypes - - def setupInputEventHandlers(self): - """Defines InputEventHandler fields for this script that can be - called by the key and braille bindings. In this particular case, - we just want to be able to add a handler to return the contents of - the input line. - """ - - default.Script.setupInputEventHandlers(self) - self.inputEventHandlers.update( - self.structuralNavigation.inputEventHandlers) - - self.inputEventHandlers["presentInputLineHandler"] = \ - input_event.InputEventHandler( - Script.presentInputLine, - cmdnames.PRESENT_INPUT_LINE) - - self.inputEventHandlers["setDynamicColumnHeadersHandler"] = \ - input_event.InputEventHandler( - Script.setDynamicColumnHeaders, - cmdnames.DYNAMIC_COLUMN_HEADER_SET) - - self.inputEventHandlers["clearDynamicColumnHeadersHandler"] = \ - input_event.InputEventHandler( - Script.clearDynamicColumnHeaders, - cmdnames.DYNAMIC_COLUMN_HEADER_CLEAR) - - self.inputEventHandlers["setDynamicRowHeadersHandler"] = \ - input_event.InputEventHandler( - Script.setDynamicRowHeaders, - cmdnames.DYNAMIC_ROW_HEADER_SET) - - self.inputEventHandlers["clearDynamicRowHeadersHandler"] = \ - input_event.InputEventHandler( - Script.clearDynamicRowHeaders, - cmdnames.DYNAMIC_ROW_HEADER_CLEAR) - - self.inputEventHandlers["panBrailleLeftHandler"] = \ - input_event.InputEventHandler( - Script.panBrailleLeft, - cmdnames.PAN_BRAILLE_LEFT, - False) # Do not enable learn mode for this action - - self.inputEventHandlers["panBrailleRightHandler"] = \ - input_event.InputEventHandler( - Script.panBrailleRight, - cmdnames.PAN_BRAILLE_RIGHT, - False) # Do not enable learn mode for this action - - def getAppKeyBindings(self): - """Returns the application-specific keybindings for this script.""" - - keyBindings = keybindings.KeyBindings() - - keyBindings.add( - keybindings.KeyBinding( - "a", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self.inputEventHandlers["presentInputLineHandler"])) - - keyBindings.add( - keybindings.KeyBinding( - "r", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self.inputEventHandlers["setDynamicColumnHeadersHandler"], - 1)) - - keyBindings.add( - keybindings.KeyBinding( - "r", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self.inputEventHandlers["clearDynamicColumnHeadersHandler"], - 2)) - - keyBindings.add( - keybindings.KeyBinding( - "c", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self.inputEventHandlers["setDynamicRowHeadersHandler"], - 1)) - - keyBindings.add( - keybindings.KeyBinding( - "c", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self.inputEventHandlers["clearDynamicRowHeadersHandler"], - 2)) - - bindings = self.structuralNavigation.keyBindings - for keyBinding in bindings.keyBindings: - keyBindings.add(keyBinding) - - return keyBindings - - def getAppPreferencesGUI(self): - """Return a GtkGrid containing the application unique configuration - GUI items for the current application.""" - - grid = Gtk.Grid() - grid.set_border_width(12) - - label = guilabels.SPREADSHEET_SPEAK_CELL_COORDINATES - value = _settingsManager.getSetting('speakSpreadsheetCoordinates') - self.speakSpreadsheetCoordinatesCheckButton = \ - Gtk.CheckButton.new_with_mnemonic(label) - self.speakSpreadsheetCoordinatesCheckButton.set_active(value) - grid.attach(self.speakSpreadsheetCoordinatesCheckButton, 0, 0, 1, 1) - - label = guilabels.SPREADSHEET_SPEAK_SELECTED_RANGE - value = _settingsManager.getSetting('alwaysSpeakSelectedSpreadsheetRange') - self.alwaysSpeakSelectedSpreadsheetRangeCheckButton = \ - Gtk.CheckButton.new_with_mnemonic(label) - self.alwaysSpeakSelectedSpreadsheetRangeCheckButton.set_active(value) - grid.attach(self.alwaysSpeakSelectedSpreadsheetRangeCheckButton, 0, 1, 1, 1) - - tableFrame = Gtk.Frame() - grid.attach(tableFrame, 0, 2, 1, 1) - - label = Gtk.Label(label=f"{guilabels.TABLE_NAVIGATION}") - label.set_use_markup(True) - tableFrame.set_label_widget(label) - - tableAlignment = Gtk.Alignment.new(0.5, 0.5, 1, 1) - tableAlignment.set_padding(0, 0, 12, 0) - tableFrame.add(tableAlignment) - tableGrid = Gtk.Grid() - tableAlignment.add(tableGrid) - - label = guilabels.TABLE_SPEAK_CELL_COORDINATES - value = _settingsManager.getSetting('speakCellCoordinates') - self.speakCellCoordinatesCheckButton = \ - Gtk.CheckButton.new_with_mnemonic(label) - self.speakCellCoordinatesCheckButton.set_active(value) - tableGrid.attach(self.speakCellCoordinatesCheckButton, 0, 0, 1, 1) - - label = guilabels.TABLE_SPEAK_CELL_SPANS - value = _settingsManager.getSetting('speakCellSpan') - self.speakCellSpanCheckButton = \ - Gtk.CheckButton.new_with_mnemonic(label) - self.speakCellSpanCheckButton.set_active(value) - tableGrid.attach(self.speakCellSpanCheckButton, 0, 1, 1, 1) - - label = guilabels.TABLE_ANNOUNCE_CELL_HEADER - value = _settingsManager.getSetting('speakCellHeaders') - self.speakCellHeadersCheckButton = \ - Gtk.CheckButton.new_with_mnemonic(label) - self.speakCellHeadersCheckButton.set_active(value) - tableGrid.attach(self.speakCellHeadersCheckButton, 0, 2, 1, 1) - - label = guilabels.TABLE_SKIP_BLANK_CELLS - value = _settingsManager.getSetting('skipBlankCells') - self.skipBlankCellsCheckButton = \ - Gtk.CheckButton.new_with_mnemonic(label) - self.skipBlankCellsCheckButton.set_active(value) - tableGrid.attach(self.skipBlankCellsCheckButton, 0, 3, 1, 1) - - spellcheck = self.spellcheck.getAppPreferencesGUI() - grid.attach(spellcheck, 0, len(grid.get_children()), 1, 1) - grid.show_all() - - return grid - - def getPreferencesFromGUI(self): - """Returns a dictionary with the app-specific preferences.""" - - prefs = { - 'speakCellSpan': - self.speakCellSpanCheckButton.get_active(), - 'speakCellHeaders': - self.speakCellHeadersCheckButton.get_active(), - 'skipBlankCells': - self.skipBlankCellsCheckButton.get_active(), - 'speakCellCoordinates': - self.speakCellCoordinatesCheckButton.get_active(), - 'speakSpreadsheetCoordinates': - self.speakSpreadsheetCoordinatesCheckButton.get_active(), - 'alwaysSpeakSelectedSpreadsheetRange': - self.alwaysSpeakSelectedSpreadsheetRangeCheckButton.get_active(), - } - - prefs.update(self.spellcheck.getPreferencesFromGUI()) - return prefs - - def panBrailleLeft(self, inputEvent=None, panAmount=0): - """In document content, we want to use the panning keys to browse the - entire document. - """ - - if self.flatReviewPresenter.is_active() \ - or not self.isBrailleBeginningShowing() \ - or self.utilities.isSpreadSheetCell(cthulhu_state.locusOfFocus) \ - or not self.utilities.isTextArea(cthulhu_state.locusOfFocus): - return default.Script.panBrailleLeft(self, inputEvent, panAmount) - - if not AXObject.supports_text(cthulhu_state.locusOfFocus): - return default.Script.panBrailleLeft(self, inputEvent, panAmount) - - caretOffset = AXText.get_caret_offset(cthulhu_state.locusOfFocus) - string, startOffset, endOffset = AXText.get_line_at_offset( - cthulhu_state.locusOfFocus, caretOffset) - if 0 < startOffset: - AXText.set_caret_offset(cthulhu_state.locusOfFocus, startOffset - 1) + if braille_presenter.get_presenter().pan_left(): return True - obj = self.utilities.findPreviousObject(cthulhu_state.locusOfFocus) - if AXObject.supports_text(obj): - cthulhu.setLocusOfFocus(None, obj, notifyScript=False) - AXText.set_caret_offset(obj, AXText.get_character_count(obj)) + # At edge of a paragraph. Try to move caret to previous line. + start_offset = AXText.get_line_at_offset(focus)[1] + if start_offset > 0: + AXText.set_caret_offset(focus, start_offset - 1) return True - return default.Script.panBrailleLeft(self, inputEvent, panAmount) - - def panBrailleRight(self, inputEvent=None, panAmount=0): - """In document content, we want to use the panning keys to browse the - entire document. - """ - - if self.flatReviewPresenter.is_active() \ - or not self.isBrailleEndShowing() \ - or self.utilities.isSpreadSheetCell(cthulhu_state.locusOfFocus) \ - or not self.utilities.isTextArea(cthulhu_state.locusOfFocus): - return default.Script.panBrailleRight(self, inputEvent, panAmount) - - if not AXObject.supports_text(cthulhu_state.locusOfFocus): - return default.Script.panBrailleRight(self, inputEvent, panAmount) - - caretOffset = AXText.get_caret_offset(cthulhu_state.locusOfFocus) - string, startOffset, endOffset = AXText.get_line_at_offset( - cthulhu_state.locusOfFocus, caretOffset) - if endOffset < AXText.get_character_count(cthulhu_state.locusOfFocus): - AXText.set_caret_offset(cthulhu_state.locusOfFocus, endOffset) + obj = self.utilities.find_previous_object(focus) + if obj is not None: + focus_manager.get_manager().set_locus_of_focus(None, obj, notify_script=False) + AXUtilities.set_caret_offset_to_end(obj) return True - obj = self.utilities.findNextObject(cthulhu_state.locusOfFocus) - if AXObject.supports_text(obj): - cthulhu.setLocusOfFocus(None, obj, notifyScript=False) - AXText.set_caret_offset(obj, 0) + return super()._pan_braille_left(event) + + def _pan_braille_right(self, event: input_event.InputEvent | None = None) -> bool: + """Pans the braille display to the right.""" + + focus = focus_manager.get_manager().get_locus_of_focus() + if ( + flat_review_presenter.get_presenter().is_active() + or AXUtilities.is_spreadsheet_cell(focus) + or not AXUtilities.is_paragraph(focus) + ): + return super()._pan_braille_right(event) + + if braille_presenter.get_presenter().pan_right(): return True - return default.Script.panBrailleRight(self, inputEvent, panAmount) + # At edge of a paragraph. Try to move caret to next line. + end_offset = AXText.get_line_at_offset(focus)[2] + if end_offset < AXText.get_character_count(focus): + AXText.set_caret_offset(focus, end_offset) + return True - def presentInputLine(self, inputEvent): - """Presents the contents of the spread sheet input line (assuming we - have a handle to it - generated when we first focus on a spread - sheet table cell. + obj = self.utilities.find_next_object(focus) + if obj is not None: + focus_manager.get_manager().set_locus_of_focus(None, obj, notify_script=False) + AXUtilities.set_caret_offset_to_start(obj) + return True - This will be either the contents of the table cell that has focus - or the formula associated with it. + return super()._pan_braille_right(event) - Arguments: - - inputEvent: if not None, the input event that caused this action. - """ + def locus_of_focus_changed( + self, + event: Atspi.Event | None, + old_focus: Atspi.Accessible | None, + new_focus: Atspi.Accessible | None, + ) -> bool: + """Handles changes of focus of interest. Returns True if this script did all needed work.""" - if not self.utilities.isSpreadSheetCell(cthulhu_state.locusOfFocus): - return + if self.run_find_command_on: + return super().locus_of_focus_changed(event, old_focus, new_focus) - inputLine = self.utilities.locateInputLine(cthulhu_state.locusOfFocus) - if not inputLine: - return - - text = self.utilities.displayedText(inputLine) - if not text: - text = messages.EMPTY - - self.presentMessage(text) - - def setDynamicColumnHeaders(self, inputEvent): - """Set the row for the dynamic header columns to use when speaking - calc cell entries. In order to set the row, the user should first set - focus to the row that they wish to define and then press Insert-r. - - Once the user has defined the row, it will be used to first speak - this header when moving between columns. - - Arguments: - - inputEvent: if not None, the input event that caused this action. - """ - - cell = cthulhu_state.locusOfFocus - parent = AXObject.get_parent(cell) - if AXObject.get_role(parent) == Atspi.Role.TABLE_CELL: - cell = parent - - row, column, table = self.utilities.getRowColumnAndTable(cell) - if table: - self.dynamicColumnHeaders[hash(table)] = row - self.presentMessage(messages.DYNAMIC_COLUMN_HEADER_SET % (row+1)) - - return True - - def clearDynamicColumnHeaders(self, inputEvent): - """Clear the dynamic header column. - - Arguments: - - inputEvent: if not None, the input event that caused this action. - """ - - cell = cthulhu_state.locusOfFocus - parent = AXObject.get_parent(cell) - if AXObject.get_role(parent) == Atspi.Role.TABLE_CELL: - cell = parent - - row, column, table = self.utilities.getRowColumnAndTable(cell) - try: - del self.dynamicColumnHeaders[hash(table)] - self.presentationInterrupt() - self.presentMessage(messages.DYNAMIC_COLUMN_HEADER_CLEARED) - except Exception: - pass - - return True - - def setDynamicRowHeaders(self, inputEvent): - """Set the column for the dynamic header rows to use when speaking - calc cell entries. In order to set the column, the user should first - set focus to the column that they wish to define and then press - Insert-c. - - Once the user has defined the column, it will be used to first speak - this header when moving between rows. - - Arguments: - - inputEvent: if not None, the input event that caused this action. - """ - - cell = cthulhu_state.locusOfFocus - parent = AXObject.get_parent(cell) - if AXObject.get_role(parent) == Atspi.Role.TABLE_CELL: - cell = parent - - row, column, table = self.utilities.getRowColumnAndTable(cell) - if table: - self.dynamicRowHeaders[hash(table)] = column - self.presentMessage( - messages.DYNAMIC_ROW_HEADER_SET % self.utilities.columnConvert(column+1)) - - return True - - def clearDynamicRowHeaders(self, inputEvent): - """Clear the dynamic row headers. - - Arguments: - - inputEvent: if not None, the input event that caused this action. - """ - - cell = cthulhu_state.locusOfFocus - parent = AXObject.get_parent(cell) - if AXObject.get_role(parent) == Atspi.Role.TABLE_CELL: - cell = parent - - row, column, table = self.utilities.getRowColumnAndTable(cell) - try: - del self.dynamicRowHeaders[hash(table)] - self.presentationInterrupt() - self.presentMessage(messages.DYNAMIC_ROW_HEADER_CLEARED) - except Exception: - pass - - return True - - def locus_of_focus_changed(self, event, oldLocusOfFocus, newLocusOfFocus): - """Called when the visual object with focus changes. - - Arguments: - - event: if not None, the Event that caused the change - - oldLocusOfFocus: Accessible that is the old locus of focus - - newLocusOfFocus: Accessible that is the new locus of focus - """ - - # Check to see if this is this is for the find command. See - # comment #18 of bug #354463. - # - if self.findCommandRun and \ - event.type.startswith("object:state-changed:focused"): - self.findCommandRun = False - self.find() - return - - if self.flatReviewPresenter.is_active(): - self.flatReviewPresenter.quit() - - if self.spellcheck.isSuggestionsItem(newLocusOfFocus) \ - and not self.spellcheck.isSuggestionsItem(oldLocusOfFocus): - cthulhu.emitRegionChanged(newLocusOfFocus) - self.updateBraille(newLocusOfFocus) - self.spellcheck.presentSuggestionListItem(includeLabel=True) - return - - # TODO - JD: Sad hack that wouldn't be needed if LO were fixed. - # If we are in the slide presentation scroll pane, also announce - # the current page tab. See bug #538056 for more details. - # - rolesList = [Atspi.Role.SCROLL_PANE, - Atspi.Role.PANEL, - Atspi.Role.PANEL, - Atspi.Role.ROOT_PANE, - Atspi.Role.FRAME, - Atspi.Role.APPLICATION] - if self.utilities.hasMatchingHierarchy(newLocusOfFocus, rolesList): - parent = AXObject.get_parent(newLocusOfFocus) - for child in AXObject.iter_children(parent, AXUtilities.is_page_tab_list): - for tab in AXObject.iter_children(child, AXUtilities.is_selected): - self.presentObject(tab) + if flat_review_presenter.get_presenter().is_active(): + flat_review_presenter.get_presenter().quit() # TODO - JD: This is a hack that needs to be done better. For now it # fixes the broken echo previous word on Return. - elif newLocusOfFocus and oldLocusOfFocus \ - and AXObject.get_role(newLocusOfFocus) == Atspi.Role.PARAGRAPH \ - and AXObject.get_role(oldLocusOfFocus) == Atspi.Role.PARAGRAPH \ - and newLocusOfFocus != oldLocusOfFocus: - lastKey, mods = self.utilities.lastKeyAndModifiers() - if lastKey == "Return" and _settingsManager.getSetting('enableEchoByWord'): - self.echoPreviousWord(oldLocusOfFocus) - return + if ( + new_focus != old_focus + and AXUtilities.is_paragraph(new_focus) + and AXUtilities.is_paragraph(old_focus) + ): + if input_event_manager.get_manager().last_event_was_return(): + typing_echo_presenter.get_presenter().echo_previous_word(old_focus) + return True # TODO - JD: And this hack is another one that needs to be done better. # But this will get us to speak the entire paragraph when navigation by # paragraph has occurred. - event_string, mods = self.utilities.lastKeyAndModifiers() - isControlKey = mods & keybindings.CTRL_MODIFIER_MASK - isShiftKey = mods & keybindings.SHIFT_MODIFIER_MASK - if event_string in ["Up", "Down"] and isControlKey and not isShiftKey: - string = self.utilities.displayedText(newLocusOfFocus) + if input_event_manager.get_manager().last_event_was_paragraph_navigation(): + string = AXText.get_all_text(new_focus) if string: - voice = self.speechGenerator.voice(obj=newLocusOfFocus, string=string) - self.speakMessage(string, voice=voice) - self.updateBraille(newLocusOfFocus) - if AXObject.supports_text(newLocusOfFocus): - self._saveLastCursorPosition( - newLocusOfFocus, AXText.get_caret_offset(newLocusOfFocus)) - return + presentation_manager.get_manager().speak_accessible_text(new_focus, string) + self.update_braille(new_focus) + offset = AXText.get_caret_offset(new_focus) + focus_manager.get_manager().set_last_cursor_position(new_focus, offset) + return True - # Pass the event onto the parent class to be handled in the default way. - default.Script.locus_of_focus_changed(self, event, - oldLocusOfFocus, newLocusOfFocus) - if not newLocusOfFocus: - return + return super().locus_of_focus_changed(event, old_focus, new_focus) - parent = AXObject.get_parent(newLocusOfFocus) - if parent is None: - return - - cell = None - if self.utilities.isTextDocumentCell(newLocusOfFocus): - cell = newLocusOfFocus - elif self.utilities.isTextDocumentCell(parent): - cell = parent - if cell: - row, column = self.utilities.coordinatesForCell(cell) - self.pointOfReference['lastRow'] = row - self.pointOfReference['lastColumn'] = column - - def onNameChanged(self, event): - """Called whenever a property on an object changes. - - Arguments: - - event: the Event - """ - - if self.spellcheck.isCheckWindow(event.source): - return - - # Impress slide navigation. - # - if self.utilities.isInImpress(event.source) \ - and self.utilities.isDrawingView(event.source): - title, position, count = \ - self.utilities.slideTitleAndPosition(event.source) - if title: - title += "." - - msg = messages.PRESENTATION_SLIDE_POSITION % \ - {"position" : position, "count" : count} - msg = self.utilities.appendString(title, msg) - self.presentMessage(msg) - - default.Script.onNameChanged(self, event) - - def onActiveChanged(self, event): + def _on_active_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:active accessibility events.""" if not AXObject.get_parent(event.source): msg = "SOFFICE: Event source lacks parent" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - # Prevent this events from activating the find operation. - # See comment #18 of bug #354463. - if self.findCommandRun: - return - - default.Script.onActiveChanged(self, event) + return super()._on_active_changed(event) - def onActiveDescendantChanged(self, event): - """Called when an object who manages its own descendants detects a - change in one of its children. + def _on_active_descendant_changed(self, event: Atspi.Event) -> bool: + """Callback for object:state-changed:active accessibility events.""" - Arguments: - - event: the Event - """ + manager = focus_manager.get_manager() + focus = manager.get_locus_of_focus() + if event.any_data == focus: + return True - if self.utilities.isSameObject(event.any_data, cthulhu_state.locusOfFocus): - return + if AXUtilities.is_paragraph(focus): + return super()._on_active_descendant_changed(event) - if event.source == self.spellcheck.getSuggestionsList(): - if AXUtilities.is_focused(event.source): - cthulhu.setLocusOfFocus(event, event.any_data, False) - self.updateBraille(cthulhu_state.locusOfFocus) - self.spellcheck.presentSuggestionListItem() - else: - self.spellcheck.presentErrorDetails() - return - - if self.utilities.isSpreadSheetCell(event.any_data) \ - and not AXUtilities.is_focused(event.any_data) \ - and not AXUtilities.is_focused(event.source) : + if ( + AXUtilities.is_spreadsheet_cell(event.any_data) + and not AXUtilities.is_focused(event.any_data) + and not AXUtilities.is_focused(event.source) + ): msg = "SOFFICE: Neither source nor child have focused state. Clearing cache on table." - debug.printMessage(debug.LEVEL_INFO, msg, True) - AXObject.clear_cache(event.source) + AXObject.clear_cache(event.source, False, msg) - default.Script.onActiveDescendantChanged(self, event) + if event.source != focus and not AXUtilities.find_ancestor( + focus, lambda x: x == event.source + ): + msg = "SOFFICE: Working around LO bug 161444." + debug.print_message(debug.LEVEL_INFO, msg, True) + # If we immediately set focus to the table, the lack of common ancestor will result in + # the ancestry up to the frame being spoken. + manager.set_locus_of_focus(None, AXObject.get_parent(event.source), False) + # Now setting focus to the table should cause us to present it. Then we can handle the + # presentation of the actual event we're processing without too much chattiness. + manager.set_locus_of_focus(event, event.source) - def onChildrenAdded(self, event): + return super()._on_active_descendant_changed(event) + + def _on_caret_moved(self, event: Atspi.Event) -> bool: + """Callback for object:text-caret-moved accessibility events.""" + + if event.detail1 == -1: + return True + + if AXUtilities.is_paragraph(event.source) and not AXUtilities.is_focused(event.source): + # TODO - JD: Can we remove this? + AXObject.clear_cache( + event.source, + False, + "Caret-moved event from object which lacks focused state.", + ) + if AXUtilities.is_focused(event.source): + msg = "SOFFICE: Clearing cache was needed due to missing state-changed event." + debug.print_message(debug.LEVEL_INFO, msg, True) + + if table_navigator.get_navigator().last_input_event_was_navigation_command(): + msg = "SOFFICE: Event ignored: Last input event was table navigation." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + if structural_navigator.get_navigator().last_input_event_was_navigation_command(): + msg = "SOFFICE: Event ignored: Last input event was structural navigation." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + if AXUtilities.is_spreadsheet_cell(focus_manager.get_manager().get_locus_of_focus()): + if not self.utilities.is_cell_being_edited(event.source): + msg = "SOFFICE: Event ignored: Source is not cell being edited." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + return super()._on_caret_moved(event) + + def _on_children_added(self, event: Atspi.Event) -> bool: """Callback for object:children-changed:add accessibility events.""" - AXObject.clear_cache_now("children-changed event.") + if AXUtilities.is_spreadsheet_cell(event.any_data): + focus_manager.get_manager().set_locus_of_focus(event, event.any_data) + return True - if self.utilities.isSpreadSheetCell(event.any_data): - cthulhu.setLocusOfFocus(event, event.any_data) - return + AXUtilities.clear_all_cache_now(event.source, "children-changed event.") - if self.utilities.isLastCell(event.any_data): - activeRow = self.pointOfReference.get('lastRow', -1) - activeCol = self.pointOfReference.get('lastColumn', -1) - if activeRow < 0 or activeCol < 0: - return + manager = focus_manager.get_manager() + if AXUtilities.is_last_cell(event.any_data): + active_row, active_col = AXTable.get_last_cell_coordinates() + if active_row < 0 or active_col < 0: + return True - if AXObject.is_dead(cthulhu_state.locusOfFocus): - cthulhu.setLocusOfFocus(event, event.source, False) + if manager.focus_is_dead(): + manager.set_locus_of_focus(event, event.source, False) - self.utilities.handleUndoTextEvent(event) - rowCount, colCount = self.utilities.rowAndColumnCount(event.source) - if activeRow == rowCount: + presentation_manager.get_manager().present_command_announcement() + row_count = AXTable.get_row_count(event.source) + if active_row == row_count: full = messages.TABLE_ROW_DELETED_FROM_END brief = messages.TABLE_ROW_DELETED else: full = messages.TABLE_ROW_INSERTED_AT_END brief = messages.TABLE_ROW_INSERTED - self.presentMessage(full, brief) - return + presentation_manager.get_manager().present_message(full, brief) + return True - default.Script.onChildrenAdded(self, event) + return super()._on_children_added(event) - def onFocus(self, event): - """Callback for focus: accessibility events.""" + def _handle_spreadsheet_focus(self, event: Atspi.Event, focus: Atspi.Accessible) -> bool: + """Returns True if the spreadsheet focus event was handled.""" - # NOTE: This event type is deprecated and Cthulhu should no longer use it. - # This callback remains just to handle bugs in applications and toolkits - # during the remainder of the unstable (3.11) development cycle. + if not AXUtilities.is_spreadsheet_table(event.source): + return False - if self.utilities.isSameObject(cthulhu_state.locusOfFocus, event.source): - return + if focus_manager.get_manager().focus_is_dead(): + msg = "SOFFICE: Event believed to be post-editing focus claim." + debug.print_message(debug.LEVEL_INFO, msg, True) + focus_manager.get_manager().set_locus_of_focus(event, event.source, False) + return True - if self.utilities.isFocusableLabel(event.source): - cthulhu.setLocusOfFocus(event, event.source) - return + if AXUtilities.is_paragraph(focus) or AXUtilities.is_table_cell(focus): + if AXUtilities.find_ancestor(focus, lambda x: x == event.source): + msg = "SOFFICE: Event believed to be post-editing focus claim based on role." + debug.print_message(debug.LEVEL_INFO, msg, True) + focus_manager.get_manager().set_locus_of_focus(event, event.source, False) + return True - role = AXObject.get_role(event.source) + # If we were in a cell, and a different table is claiming focus, it's likely that + # the current sheet has just changed. There will not be a common ancestor between + # the old cell and the table and we'll wind up re-announcing the frame. To prevent + # that, set the focus to the parent of the sheet before the default script causes + # the table to be presented. + focus_manager.get_manager().set_locus_of_focus( + None, + AXObject.get_parent(event.source), + False, + ) - if self.utilities.isZombie(event.source) \ - or role in [Atspi.Role.TEXT, Atspi.Role.LIST]: - comboBox = self.utilities.containingComboBox(event.source) - if comboBox: - cthulhu.setLocusOfFocus(event, comboBox, True) - return + return False - # This seems to be something we inherit from Gtk+ - if role in [Atspi.Role.TEXT, Atspi.Role.PASSWORD_TEXT]: - cthulhu.setLocusOfFocus(event, event.source) - return - - # Ditto. - if role == Atspi.Role.PUSH_BUTTON: - cthulhu.setLocusOfFocus(event, event.source) - return - - # Ditto. - if role == Atspi.Role.TOGGLE_BUTTON: - cthulhu.setLocusOfFocus(event, event.source) - return - - # Ditto. - if role == Atspi.Role.COMBO_BOX: - cthulhu.setLocusOfFocus(event, event.source) - return - - # Ditto. - if role == Atspi.Role.PANEL and AXObject.get_name(event.source): - cthulhu.setLocusOfFocus(event, event.source) - return - - def onFocusedChanged(self, event): + def _on_focused_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:focused accessibility events.""" - if self._inSayAll: - return + manager = focus_manager.get_manager() + if not event.detail1 or manager.in_say_all(): + return True - if self._lastCommandWasStructNav: - return + # LibreOffice seems to fire focus events for root panes (in documents) and panels + # in the spell-check dialog for ancestors of the actual focus -- for which we also + # get events. + focus = manager.get_locus_of_focus() + if AXUtilities.is_ancestor(focus, event.source): + msg = "SOFFICE: Event ignored: Source is ancestor of current focus." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - if not event.detail1: - return + if table_navigator.get_navigator().last_input_event_was_navigation_command(): + msg = "SOFFICE: Event ignored: Last input event was table navigation." + debug.print_message(debug.LEVEL_INFO, msg, True) - if self.utilities.isAnInputLine(event.source): - msg = "SOFFICE: Event ignored: spam from inputLine" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + if structural_navigator.get_navigator().last_input_event_was_navigation_command(): + msg = "SOFFICE: Event ignored: Last input event was structural navigation." + debug.print_message(debug.LEVEL_INFO, msg, True) - if AXObject.get_child_count(event.source) \ - and self.utilities.isAnInputLine(AXObject.get_child(event.source, 0)): - msg = "SOFFICE: Event ignored: spam from inputLine parent" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + if AXUtilities.is_text(event.source) or AXUtilities.is_list(event.source): + combobox = AXUtilities.find_ancestor(event.source, AXUtilities.is_combo_box) + if combobox: + focus_manager.get_manager().set_locus_of_focus(event, combobox, True) + return True - role = AXObject.get_role(event.source) - if role in [Atspi.Role.TEXT, Atspi.Role.LIST]: - comboBox = self.utilities.containingComboBox(event.source) - if comboBox: - cthulhu.setLocusOfFocus(event, comboBox, True) - return + if AXUtilities.is_paragraph(event.source): + input_manager = input_event_manager.get_manager() + if input_manager.last_event_was_left() or input_manager.last_event_was_right(): + focus_manager.get_manager().set_locus_of_focus(event, event.source, False) + return True - parent = AXObject.get_parent(event.source) - if parent and AXObject.get_role(parent) == Atspi.Role.TOOL_BAR: - default.Script.onFocusedChanged(self, event) - return + if self._handle_spreadsheet_focus(event, focus): + return True - # TODO - JD: Verify this is still needed - ignoreRoles = [Atspi.Role.FILLER, Atspi.Role.PANEL] - if role in ignoreRoles: - return + return super()._on_focused_changed(event) - # We will present this when the selection changes. - if role == Atspi.Role.MENU: - return - - if self.utilities._flowsFromOrToSelection(event.source): - return - - if role == Atspi.Role.PARAGRAPH: - obj, offset = self.pointOfReference.get("lastCursorPosition", (None, -1)) - start, end, string = self.utilities.getCachedTextSelection(obj) - if start != end: - return - - keyString, mods = self.utilities.lastKeyAndModifiers() - if keyString in ["Left", "Right"]: - cthulhu.setLocusOfFocus(event, event.source, False) - return - - if self.utilities.isSpreadSheetTable(event.source) and cthulhu_state.locusOfFocus: - if AXObject.is_dead(cthulhu_state.locusOfFocus): - msg = "SOFFICE: Event believed to be post-editing focus claim. Dead locusOfFocus." - debug.printMessage(debug.LEVEL_INFO, msg, True) - cthulhu.setLocusOfFocus(event, event.source, False) - return - if AXUtilities.is_paragraph(cthulhu_state.locusOfFocus) \ - or AXUtilities.is_table_cell(cthulhu_state.locusOfFocus): - msg = "SOFFICE: Event believed to be post-editing focus claim based on role." - debug.printMessage(debug.LEVEL_INFO, msg, True) - cthulhu.setLocusOfFocus(event, event.source, False) - return - - default.Script.onFocusedChanged(self, event) - - def onCaretMoved(self, event): - """Callback for object:text-caret-moved accessibility events.""" - - if event.detail1 == -1: - return - - if AXObject.get_role(event.source) == Atspi.Role.PARAGRAPH \ - and not AXUtilities.is_focused(event.source): - AXObject.clear_cache(event.source) - if AXUtilities.is_focused(event.source): - msg = "SOFFICE: Clearing cache was needed due to missing state-changed event." - debug.printMessage(debug.LEVEL_INFO, msg, True) - - if self.utilities._flowsFromOrToSelection(event.source): - return - - if self._lastCommandWasStructNav: - return - - if self.utilities.isSpreadSheetCell(cthulhu_state.locusOfFocus): - tokens = ["SOFFICE: locusOfFocus", cthulhu_state.locusOfFocus, "is spreadsheet cell"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - - if not self.utilities.isCellBeingEdited(event.source): - msg = "SOFFICE: Event ignored: Source is not cell being edited." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - super().onCaretMoved(event) - - def onCheckedChanged(self, event): - """Callback for object:state-changed:checked accessibility events.""" - - obj = event.source - role = AXObject.get_role(obj) - parentRole = AXObject.get_role(AXObject.get_parent(obj)) - if role not in [Atspi.Role.TOGGLE_BUTTON, Atspi.Role.PUSH_BUTTON] \ - or not parentRole == Atspi.Role.TOOL_BAR: - default.Script.onCheckedChanged(self, event) - return - - sourceWindow = self.utilities.topLevelObject(obj) - focusWindow = self.utilities.topLevelObject(cthulhu_state.locusOfFocus) - if sourceWindow != focusWindow: - return - - # Announce when the toolbar buttons are toggled if we just toggled - # them; not if we navigated to some text. - weToggledIt = False - if isinstance(cthulhu_state.lastInputEvent, input_event.MouseButtonEvent): - x = cthulhu_state.lastInputEvent.x - y = cthulhu_state.lastInputEvent.y - if AXObject.supports_component(obj): - weToggledIt = Atspi.Component.contains(obj, x, y, Atspi.CoordType.SCREEN) - else: - weToggledIt = False - elif AXUtilities.is_focused(obj): - weToggledIt = True - else: - keyString, mods = self.utilities.lastKeyAndModifiers() - navKeys = ["Up", "Down", "Left", "Right", "Page_Up", "Page_Down", - "Home", "End", "N"] - wasCommand = mods & keybindings.COMMAND_MODIFIER_MASK - weToggledIt = wasCommand and keyString not in navKeys - if weToggledIt: - self.presentObject(obj, alreadyFocused=True, interrupt=True) - - def onSelectedChanged(self, event): + def _on_selected_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:selected accessibility events.""" - full, brief = "", "" - if self.utilities.isSelectedTextDeletionEvent(event): - msg = "SOFFICE: Change is believed to be due to deleting selected text" - debug.printMessage(debug.LEVEL_INFO, msg, True) - full = messages.SELECTION_DELETED - elif self.utilities.isSelectedTextRestoredEvent(event): - msg = "SOFFICE: Selection is believed to be due to restoring selected text" - debug.printMessage(debug.LEVEL_INFO, msg, True) - if self.utilities.handleUndoTextEvent(event): - full = messages.SELECTION_RESTORED + # https://bugs.documentfoundation.org/show_bug.cgi?id=163801 + if AXUtilities.is_paragraph(event.source): + msg = "SOFFICE: Ignoring event on unsupported role." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - if full or brief: - self.presentMessage(full, brief) - self.utilities.updateCachedTextSelection(event.source) - return + return super()._on_selected_changed(event) - super().onSelectedChanged(event) - - def onSelectionChanged(self, event): + def _on_selection_changed(self, event: Atspi.Event) -> bool: """Callback for object:selection-changed accessibility events.""" - if self.utilities.isSpreadSheetTable(event.source): - if _settingsManager.getSetting('onlySpeakDisplayedText'): - return - if _settingsManager.getSetting('alwaysSpeakSelectedSpreadsheetRange'): - self.utilities.speakSelectedCellRange(event.source) - return - if self.utilities.handleRowAndColumnSelectionChange(event.source): - return - self.utilities.handleCellSelectionChange(event.source) - return + # https://bugs.documentfoundation.org/show_bug.cgi?id=163801 + if AXUtilities.is_paragraph(event.source): + msg = "SOFFICE: Ignoring event on unsupported role." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - if event.source == self.spellcheck.getSuggestionsList(): - if cthulhu_state.locusOfFocus == cthulhu_state.activeWindow: - msg = "SOFFICE: Not presenting because locusOfFocus is window" - debug.printMessage(debug.LEVEL_INFO, msg, True) - elif AXUtilities.is_focused(event.source): - cthulhu.setLocusOfFocus(event, event.any_data, False) - self.updateBraille(cthulhu_state.locusOfFocus) - self.spellcheck.presentSuggestionListItem() - else: - self.spellcheck.presentErrorDetails() - return + if AXUtilities.is_spreadsheet_table(event.source): + presenter = speech_presenter.get_presenter() + if presenter.get_only_speak_displayed_text(): + return True + if presenter.get_always_announce_selected_range_in_spreadsheet(): + self.utilities.speak_selected_cell_range(event.source) + return True + if self.utilities.handle_row_and_column_selection_change(event.source): + return True + self.utilities.handle_cell_selection_change(event.source) + return True - if not self.utilities.isComboBoxSelectionChange(event): - super().onSelectionChanged(event) - return - - selectedChildren = self.utilities.selectedChildren(event.source) - if len(selectedChildren) == 1 \ - and self.utilities.containingComboBox(event.source) == \ - self.utilities.containingComboBox(cthulhu_state.locusOfFocus): - cthulhu.setLocusOfFocus(event, selectedChildren[0], True) - - def onTextSelectionChanged(self, event): - """Callback for object:text-selection-changed accessibility events.""" - - if self.utilities.isComboBoxNoise(event): - msg = "SOFFICE: Event is believed to be combo box noise" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - if AXObject.is_dead(event.source): - msg = "SOFFICE: Ignoring event from dead source." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - if event.source != cthulhu_state.locusOfFocus \ - and AXUtilities.is_focused(event.source): - cthulhu.setLocusOfFocus(event, event.source, False) - - super().onTextSelectionChanged(event) - - def getTextLineAtCaret(self, obj, offset=None, startOffset=None, endOffset=None): - """To-be-removed. Returns the string, caretOffset, startOffset.""" - - if AXObject.get_role(AXObject.get_parent(obj)) == Atspi.Role.COMBO_BOX: - if not AXObject.supports_text(obj): - return ["", 0, 0] - - if AXText.get_caret_offset(obj) < 0: - lineString, startOffset, endOffset = AXText.get_line_at_offset(obj, 0) - - # Sometimes we get the trailing line-feed -- remove it - # - if lineString[-1:] == "\n": - lineString = lineString[:-1] - - return [lineString, 0, startOffset] - - textLine = super().getTextLineAtCaret(obj, offset, startOffset, endOffset) - if not AXUtilities.is_focused(obj): - textLine[0] = self.utilities.displayedText(obj) - - return textLine - - def onWindowActivated(self, event): - """Callback for window:activate accessibility events.""" - - super().onWindowActivated(event) - if not self.spellcheck.isCheckWindow(event.source): - return - - child = AXObject.get_child(event.source, 0) - if AXObject.get_role(child) == Atspi.Role.DIALOG: - cthulhu.setLocusOfFocus(event, child, False) - - self.spellcheck.presentErrorDetails() - - def onWindowDeactivated(self, event): - """Callback for window:deactivate accessibility events.""" - - self._lastCommandWasStructNav = False - - super().onWindowDeactivated(event) - self.spellcheck.deactivate() + return super()._on_selection_changed(event) diff --git a/src/cthulhu/scripts/apps/steamwebhelper/script.py b/src/cthulhu/scripts/apps/steamwebhelper/script.py index c4e5d21..dfad68f 100644 --- a/src/cthulhu/scripts/apps/steamwebhelper/script.py +++ b/src/cthulhu/scripts/apps/steamwebhelper/script.py @@ -33,10 +33,9 @@ import re from gi.repository import GLib from cthulhu import debug -from cthulhu import settings +from cthulhu import notification_presenter +from cthulhu import presentation_manager from cthulhu import cthulhu_state -from cthulhu import settings_manager -from cthulhu import structural_navigation from cthulhu.ax_object import AXObject from cthulhu.ax_utilities import AXUtilities from cthulhu.ax_utilities_relation import AXUtilitiesRelation @@ -44,28 +43,6 @@ from cthulhu.ax_utilities_role import AXUtilitiesRole from cthulhu.scripts.toolkits import Chromium from .script_utilities import Utilities -settingsManager = settings_manager.getManager() - - -class SteamStructuralNavigation(structural_navigation.StructuralNavigation): - def _headingGetter(self, document, arg=None): - def isUsefulHeading(candidate): - if not self._script.utilities.inDocumentContent(candidate): - return False - - text = self._script.utilities.displayedText(candidate) or AXObject.get_name(candidate) - if not text: - return False - - return not text.strip().isdigit() - - if arg is None: - return super()._headingGetter(document, arg) - - headings = super()._headingGetter(document, None) - headings = [heading for heading in headings if isUsefulHeading(heading)] - return [heading for heading in headings if self._script.utilities.headingLevel(heading) == arg] - class Script(Chromium.Script): """Script for Steam Web Helper with background notification support.""" @@ -80,7 +57,7 @@ class Script(Chromium.Script): # CRITICAL: Enable background event processing for notifications # This allows Steam notifications to be spoken even when Steam # is not the focused application. - self.presentIfInactive = True + self.present_if_inactive = True self._lastSteamNotification = ("", 0.0) self._steamPendingNotification = None self._steamNotificationDelayMs = 500 @@ -96,47 +73,42 @@ class Script(Chromium.Script): return self._trySteamButtonActivation(keyboardEvent) - def onShowingChanged(self, event): + def _on_showing_changed(self, event): """Callback for object:state-changed:showing accessibility events.""" # Check if this is a Steam notification/alert becoming visible if event.detail1 and self._isSteamNotification(event.source): self._presentSteamNotification(event.source) - return + return True # Fall through to Chromium/web handling - super().onShowingChanged(event) + return super()._on_showing_changed(event) - def getUtilities(self): + def get_utilities(self): return Utilities(self) - def onChildrenAdded(self, event): + def _on_children_added(self, event): """Callback for object:children-changed:add accessibility events.""" # Check if a notification element was added if self._isSteamNotification(event.any_data): self._presentSteamNotification(event.any_data) - return + return True if self._handleSteamVirtualizedListMutation(event): return True - super().onChildrenAdded(event) + return super()._on_children_added(event) - def onChildrenRemoved(self, event): + def _on_children_removed(self, event): """Callback for object:children-changed:removed accessibility events.""" if self._handleSteamVirtualizedListMutation(event): return True - return super().onChildrenRemoved(event) + return super()._on_children_removed(event) - def getStructuralNavigation(self): - types = self.getEnabledStructuralNavigationTypes() - enable = settingsManager.getSetting('structuralNavigationEnabled') - return SteamStructuralNavigation(self, types, enable) - - def onFocusedChanged(self, event): + def _on_focused_changed(self, event): """Callback for object:state-changed:focused accessibility events.""" if event.detail1 and AXUtilities.is_document_web(event.source): @@ -154,27 +126,27 @@ class Script(Chromium.Script): debug.printMessage(debug.LEVEL_INFO, msg, True) return True - return super().onFocusedChanged(event) + return super()._on_focused_changed(event) - def onTextInserted(self, event): + def _on_text_inserted(self, event): """Callback for object:text-changed:insert accessibility events.""" if self._presentSteamLiveRegionText(event): - return + return True - super().onTextInserted(event) + return super()._on_text_inserted(event) - def onSelectionChanged(self, event): + def _on_selection_changed(self, event): """Callback for object:selection-changed accessibility events.""" self._logSteamNavigationEvent("selection-changed", event) - return super().onSelectionChanged(event) + return super()._on_selection_changed(event) - def onActiveDescendantChanged(self, event): + def _on_active_descendant_changed(self, event): """Callback for object:active-descendant-changed accessibility events.""" self._logSteamNavigationEvent("active-descendant-changed", event) - return super().onActiveDescendantChanged(event) + return super()._on_active_descendant_changed(event) def _handleSteamVirtualizedListMutation(self, event): if not event or not self.utilities.isSteamVirtualizedList(event.source): @@ -418,15 +390,12 @@ class Script(Chromium.Script): notificationMsg = f"Steam: {text}" - # Speak the notification - voice = self.speechGenerator.voice(obj=obj, string=notificationMsg) - self.speakMessage(notificationMsg, voice=voice) - - # Display on braille - self.displayBrailleMessage(notificationMsg, flashTime=settings.brailleFlashTime) + presenter = presentation_manager.get_manager() + presenter.speak_accessible_text(obj, notificationMsg) + presenter.present_braille_message(notificationMsg) # Save to notification history - self.notificationPresenter.save_notification(notificationMsg) + notification_presenter.get_presenter().save_notification(notificationMsg) msg = f"STEAM: Presented notification: {text}" debug.printMessage(debug.LEVEL_INFO, msg, True) diff --git a/src/cthulhu/scripts/apps/xfwm4/script.py b/src/cthulhu/scripts/apps/xfwm4/script.py index 98ffc95..d53258f 100644 --- a/src/cthulhu/scripts/apps/xfwm4/script.py +++ b/src/cthulhu/scripts/apps/xfwm4/script.py @@ -1,9 +1,6 @@ -#!/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 The Cthulhu Team. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -19,62 +16,42 @@ # 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 """Custom script for xfwm4.""" -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2011 The Cthulhu Team." -__license__ = "LGPL" +from __future__ import annotations -import cthulhu.scripts.default as default +from typing import TYPE_CHECKING + +from cthulhu import presentation_manager from cthulhu.ax_object import AXObject from cthulhu.ax_utilities import AXUtilities +from cthulhu.scripts import default + +if TYPE_CHECKING: + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi -######################################################################## -# # -# The xfwm4 script class. # -# # -######################################################################## class Script(default.Script): + """Custom script for xfwm4.""" - def __init__(self, app): - """Creates a new script for the given application. - - Arguments: - - app: the application to create a script for. - """ - - default.Script.__init__(self, app) - - def onTextInserted(self, event): - """Called whenever text is inserted into an object. Overridden - here so that we will speak each item as the user is switching - windows. - - Arguments: - - event: the Event - """ + def _on_text_inserted(self, event: Atspi.Event) -> bool: + """Callback for object:text-changed:insert accessibility events.""" if not AXUtilities.is_label(event.source): - default.Script.onTextInserted(self, event) - return + return super()._on_text_inserted(event) - self.presentMessage(AXObject.get_name(event.source)) + presentation_manager.get_manager().present_message(AXObject.get_name(event.source)) + return True - def onTextDeleted(self, event): - """Called whenever text is deleted from an object. Overridden - here because we wish to ignore text deletion events associated - with window switching. - - Arguments: - - event: the Event - """ + def _on_text_deleted(self, event: Atspi.Event) -> bool: + """Callback for object:text-changed:delete accessibility events.""" if not AXUtilities.is_label(event.source): - default.Script.onTextDeleted(self, event) + return super()._on_text_deleted(event) + + presentation_manager.get_manager().present_message(AXObject.get_name(event.source)) + return True diff --git a/src/cthulhu/scripts/default.py b/src/cthulhu/scripts/default.py index 2f89e55..ef285a5 100644 --- a/src/cthulhu/scripts/default.py +++ b/src/cthulhu/scripts/default.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 2004-2009 Sun Microsystems Inc. +# Copyright 2011-2025 Igalia, S.L. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -19,1195 +17,460 @@ # 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 -"""The default Script for presenting information to the user using -both speech and Braille. This is based primarily on the de-facto -standard implementation of the AT-SPI, which is the GAIL support -for GTK.""" +# pylint: disable=too-many-lines +# pylint: disable=too-many-arguments +# pylint: disable=too-many-locals +# pylint: disable=too-many-positional-arguments +# pylint: disable=too-many-public-methods -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2004-2009 Sun Microsystems Inc." \ - "Copyright (c) 2010 Joanmarie Diggs" -__license__ = "LGPL" +"""The default Script for presenting information to the user.""" -import gi -gi.require_version('Atspi', '2.0') -gi.require_version('Gdk', '3.0') -from gi.repository import Atspi -from gi.repository import Gdk -from gi.repository import GLib +from __future__ import annotations import re -import time +from typing import TYPE_CHECKING -import cthulhu.acss as acss -import cthulhu.braille as braille -import cthulhu.cmdnames as cmdnames -import cthulhu.dbus_service as dbus_service -import cthulhu.debug as debug -import cthulhu.find as find -import cthulhu.flat_review as flat_review -import cthulhu.input_event as input_event -import cthulhu.input_event_manager as input_event_manager -import cthulhu.keybindings as keybindings -import cthulhu.messages as messages -from cthulhu import cthulhu -import cthulhu.cthulhu_modifier_manager as cthulhu_modifier_manager -import cthulhu.cthulhu_state as cthulhu_state -import cthulhu.phonnames as phonnames -import cthulhu.script as script -import cthulhu.script_manager as script_manager -import cthulhu.settings as settings -import cthulhu.settings_manager as settings_manager -import cthulhu.sound as sound -import cthulhu.speech as speech -import cthulhu.speechserver as speechserver -import cthulhu.wnck_support as wnck_support +from cthulhu import ( + action_presenter, + ax_event_synthesizer, + braille, + braille_presenter, + bypass_mode_manager, + caret_navigator, + chat_presenter, + clipboard, + cmdnames, + command_manager, + debug, + debugging_tools_manager, + document_presenter, + event_manager, + flat_review_finder, + flat_review_presenter, + focus_manager, + guilabels, + input_event, + input_event_manager, + keybindings, + learn_mode_presenter, + live_region_presenter, + messages, + mouse_review, + notification_presenter, + object_navigator, + cthulhu, + cthulhu_gui_prefs, + presentation_manager, + profile_manager, + say_all_presenter, + script, + script_manager, + sleep_mode_manager, + speech_manager, + speech_presenter, + spellcheck_presenter, + structural_navigator, + system_information_presenter, + table_navigator, + typing_echo_presenter, + where_am_i_presenter, +) +from cthulhu.ax_document import AXDocument from cthulhu.ax_object import AXObject -from cthulhu.ax_value import AXValue +from cthulhu.ax_selection import AXSelection from cthulhu.ax_text import AXText from cthulhu.ax_utilities import AXUtilities -from cthulhu.ax_utilities_relation import AXUtilitiesRelation +from cthulhu.ax_utilities_event import TextEventReason +from cthulhu.ax_utilities_text import TextUnit -_scriptManager = None # Removed - use cthulhu.cthulhuApp.scriptManager -_settingsManager = None # Removed - use cthulhu.cthulhuApp.settingsManager +if TYPE_CHECKING: + from collections.abc import Callable + + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi -######################################################################## -# # -# The Default script class. # -# # -######################################################################## class Script(script.Script): + """The default Script for presenting information to the user.""" - EMBEDDED_OBJECT_CHARACTER = '\ufffc' - NO_BREAK_SPACE_CHARACTER = '\u00a0' - KEYBOARD_CLIPBOARD_CHECK_DELAY_MS = 100 - KEYBOARD_CLIPBOARD_MAX_CHECKS = 10 - KEYBOARD_CLIPBOARD_RELEASE_MAX_AGE_S = 1.0 + _commands_initialized: bool = False - # generatorCache - # - DISPLAYED_LABEL = 'displayedLabel' - DISPLAYED_TEXT = 'displayedText' - KEY_BINDING = 'keyBinding' - NESTING_LEVEL = 'nestingLevel' - NODE_LEVEL = 'nodeLevel' - REAL_ACTIVE_DESCENDANT = 'realActiveDescendant' + def _get_all_extensions(self) -> list[tuple[Callable, str]]: + """Returns (extension_getter, localized_name) for each extension.""" - def __init__(self, app): - """Creates a new script for the given application. + return [ + (braille_presenter.get_presenter, guilabels.BRAILLE), + (notification_presenter.get_presenter, guilabels.KB_GROUP_NOTIFICATIONS), + (clipboard.get_presenter, guilabels.KB_GROUP_CLIPBOARD), + (command_manager.get_manager, guilabels.KB_GROUP_DEFAULT), + (say_all_presenter.get_presenter, guilabels.KB_GROUP_DEFAULT), + (typing_echo_presenter.get_presenter, guilabels.KB_GROUP_DEFAULT), + (speech_manager.get_manager, guilabels.KB_GROUP_SPEECH_VERBOSITY), + (speech_presenter.get_presenter, guilabels.KB_GROUP_SPEECH_VERBOSITY), + (bypass_mode_manager.get_manager, guilabels.KB_GROUP_DEFAULT), + (sleep_mode_manager.get_manager, guilabels.KB_GROUP_SLEEP_MODE), + (system_information_presenter.get_presenter, guilabels.KB_GROUP_SYSTEM_INFORMATION), + (object_navigator.get_navigator, guilabels.KB_GROUP_OBJECT_NAVIGATION), + (caret_navigator.get_navigator, guilabels.KB_GROUP_CARET_NAVIGATION), + (structural_navigator.get_navigator, guilabels.KB_GROUP_STRUCTURAL_NAVIGATION), + (table_navigator.get_navigator, guilabels.KB_GROUP_TABLE_NAVIGATION), + (document_presenter.get_presenter, guilabels.KB_GROUP_DOCUMENTS), + (live_region_presenter.get_presenter, guilabels.KB_GROUP_LIVE_REGIONS), + (learn_mode_presenter.get_presenter, guilabels.KB_GROUP_LEARN_MODE), + (mouse_review.get_reviewer, guilabels.KB_GROUP_MOUSE_REVIEW), + (action_presenter.get_presenter, guilabels.KB_GROUP_ACTIONS), + (flat_review_presenter.get_presenter, guilabels.KB_GROUP_FLAT_REVIEW), + (flat_review_finder.get_finder, guilabels.KB_GROUP_FIND), + (where_am_i_presenter.get_presenter, guilabels.KB_GROUP_WHERE_AM_I), + (debugging_tools_manager.get_manager, guilabels.KB_GROUP_DEBUGGING_TOOLS), + (chat_presenter.get_presenter, guilabels.KB_GROUP_CHAT), + (profile_manager.get_manager, guilabels.GENERAL_PROFILES), + ] - Arguments: - - app: the application to create a script for. - """ - script.Script.__init__(self, app) + def set_up_commands(self) -> None: + """Sets up commands with CommandManager.""" - self.targetCursorCell = None + tokens = ["DEFAULT: Setting up commands for", self.app] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) - self.justEnteredFlatReviewMode = False + if Script._commands_initialized: + msg = "DEFAULT: Commands already initialized." + debug.print_message(debug.LEVEL_INFO, msg, True) + return - self.digits = '0123456789' - self.whitespace = ' \t\n\r\v\f' + Script._commands_initialized = True - # A dictionary of non-standardly-named text attributes and their - # Atk equivalents. - # - self.attributeNamesDict = {} + manager = command_manager.get_manager() + group_label = guilabels.KB_GROUP_DEFAULT - # Keep track of the last time we issued a mouse routing command - # so that we can guess if a change resulted from our moving the - # pointer. - # - self.lastMouseRoutingTime = None + kb_kp_divide_cthulhu = keybindings.KeyBinding("KP_Divide", keybindings.CTHULHU_MODIFIER_MASK) + kb_kp_divide = keybindings.KeyBinding("KP_Divide", keybindings.NO_MODIFIER_MASK) + kb_kp_multiply = keybindings.KeyBinding("KP_Multiply", keybindings.NO_MODIFIER_MASK) + kb_9_cthulhu = keybindings.KeyBinding("9", keybindings.CTHULHU_MODIFIER_MASK) + kb_7_cthulhu = keybindings.KeyBinding("7", keybindings.CTHULHU_MODIFIER_MASK) + kb_8_cthulhu = keybindings.KeyBinding("8", keybindings.CTHULHU_MODIFIER_MASK) + kb_space_cthulhu = keybindings.KeyBinding("space", keybindings.CTHULHU_MODIFIER_MASK) + kb_space_ctrl_cthulhu = keybindings.KeyBinding( + "space", keybindings.CTHULHU_CTRL_MODIFIER_MASK + ) - # The last location of the mouse, which we might want if routing - # the pointer elsewhere. - # - self.oldMouseCoordinates = [0, 0] - - self._lastWordCheckedForSpelling = "" - - self._inSayAll = False - self._sayAllIsInterrupted = False - self._sayAllContexts = [] - self.grab_ids = [] - self._modifierGrabIds = [] - self._pendingKeyboardClipboardCommand = None - self._pendingKeyboardClipboardRelease = None - self._keyboardClipboardCommandGeneration = 0 - self._sessionType = None - - if app: - Atspi.Accessible.set_cache_mask( - app, Atspi.Cache.DEFAULT ^ Atspi.Cache.NAME ^ Atspi.Cache.DESCRIPTION) - - # Register D-Bus commands if available - try: - controller = dbus_service.get_remote_controller() - if controller: - tokens = ["DEFAULT: Registering D-Bus commands for default script"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - controller.register_decorated_module("DefaultScript", self) - except Exception as error: - tokens = ["DEFAULT: Exception registering D-Bus commands:", error] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - - def setupInputEventHandlers(self): - """Defines InputEventHandler fields for this script that can be - called by the key and braille bindings.""" - - self.inputEventHandlers["routePointerToItemHandler"] = \ - input_event.InputEventHandler( - Script.routePointerToItem, - cmdnames.ROUTE_POINTER_TO_ITEM) - - self.inputEventHandlers["leftClickReviewItemHandler"] = \ - input_event.InputEventHandler( - Script.leftClickReviewItem, - cmdnames.LEFT_CLICK_REVIEW_ITEM) - - self.inputEventHandlers["rightClickReviewItemHandler"] = \ - input_event.InputEventHandler( - Script.rightClickReviewItem, - cmdnames.RIGHT_CLICK_REVIEW_ITEM) - - self.inputEventHandlers["sayAllHandler"] = \ - input_event.InputEventHandler( - Script.sayAll, - cmdnames.SAY_ALL) - - self.inputEventHandlers["findHandler"] = \ - input_event.InputEventHandler( - cthulhu.showFindGUI, - cmdnames.SHOW_FIND_GUI) - - self.inputEventHandlers["findNextHandler"] = \ - input_event.InputEventHandler( - Script.findNext, - cmdnames.FIND_NEXT) - - self.inputEventHandlers["findPreviousHandler"] = \ - input_event.InputEventHandler( - Script.findPrevious, - cmdnames.FIND_PREVIOUS) - - self.inputEventHandlers["panBrailleLeftHandler"] = \ - input_event.InputEventHandler( - Script.panBrailleLeft, + script_commands: list[ + tuple[ + str, + Callable[..., bool], + str, + keybindings.KeyBinding | None, + keybindings.KeyBinding | None, + ] + ] = [ + ( + "routePointerToItemHandler", + self.route_pointer_to_item, + cmdnames.ROUTE_POINTER_TO_ITEM, + kb_kp_divide_cthulhu, + kb_9_cthulhu, + ), + ( + "leftClickReviewItemHandler", + self.left_click_item, + cmdnames.LEFT_CLICK_REVIEW_ITEM, + kb_kp_divide, + kb_7_cthulhu, + ), + ( + "rightClickReviewItemHandler", + self.right_click_item, + cmdnames.RIGHT_CLICK_REVIEW_ITEM, + kb_kp_multiply, + kb_8_cthulhu, + ), + ( + "panBrailleLeftHandler", + self.pan_braille_left, cmdnames.PAN_BRAILLE_LEFT, - False) # Do not enable learn mode for this action - - self.inputEventHandlers["panBrailleRightHandler"] = \ - input_event.InputEventHandler( - Script.panBrailleRight, + None, + None, + ), + ( + "panBrailleRightHandler", + self.pan_braille_right, cmdnames.PAN_BRAILLE_RIGHT, - False) # Do not enable learn mode for this action - - self.inputEventHandlers["goBrailleHomeHandler"] = \ - input_event.InputEventHandler( - Script.goBrailleHome, - cmdnames.GO_BRAILLE_HOME) - - self.inputEventHandlers["contractedBrailleHandler"] = \ - input_event.InputEventHandler( - Script.setContractedBraille, - cmdnames.SET_CONTRACTED_BRAILLE) - - self.inputEventHandlers["processRoutingKeyHandler"] = \ - input_event.InputEventHandler( - Script.processRoutingKey, - cmdnames.PROCESS_ROUTING_KEY) - - self.inputEventHandlers["processBrailleCutBeginHandler"] = \ - input_event.InputEventHandler( - Script.processBrailleCutBegin, - cmdnames.PROCESS_BRAILLE_CUT_BEGIN) - - self.inputEventHandlers["processBrailleCutLineHandler"] = \ - input_event.InputEventHandler( - Script.processBrailleCutLine, - cmdnames.PROCESS_BRAILLE_CUT_LINE) - - self.inputEventHandlers["toggleSleepModeHandler"] = \ - input_event.InputEventHandler( - Script.toggleSleepMode, - cmdnames.TOGGLE_SLEEP_MODE) - - self.inputEventHandlers["shutdownHandler"] = \ - input_event.InputEventHandler( - cthulhu.quitCthulhu, - cmdnames.QUIT_CTHULHU) - - self.inputEventHandlers["preferencesSettingsHandler"] = \ - input_event.InputEventHandler( - cthulhu.showPreferencesGUI, - cmdnames.SHOW_PREFERENCES_GUI) - - self.inputEventHandlers["appPreferencesSettingsHandler"] = \ - input_event.InputEventHandler( - cthulhu.showAppPreferencesGUI, - cmdnames.SHOW_APP_PREFERENCES_GUI) - - self.inputEventHandlers["cycleSettingsProfileHandler"] = \ - input_event.InputEventHandler( - Script.cycleSettingsProfile, - cmdnames.CYCLE_SETTINGS_PROFILE) - - self.inputEventHandlers["cycleDebugLevelHandler"] = \ - input_event.InputEventHandler( - Script.cycleDebugLevel, - cmdnames.CYCLE_DEBUG_LEVEL) - - self.inputEventHandlers["bypassNextCommandHandler"] = \ - input_event.InputEventHandler( - Script.bypassNextCommand, - cmdnames.BYPASS_NEXT_COMMAND) - - self.inputEventHandlers.update(self.notificationPresenter.get_handlers()) - self.inputEventHandlers.update(self.flatReviewPresenter.get_handlers()) - self.inputEventHandlers.update(self.speechAndVerbosityManager.get_handlers()) - self.inputEventHandlers.update(self.dateAndTimePresenter.get_handlers()) - self.inputEventHandlers.update(self.bookmarks.get_handlers()) - self.inputEventHandlers.update(self.objectNavigator.get_handlers()) - self.inputEventHandlers.update(self.whereAmIPresenter.get_handlers()) - self.inputEventHandlers.update(self.learnModePresenter.get_handlers()) - self.inputEventHandlers.update(self.mouseReviewer.get_handlers()) - self.inputEventHandlers.update(self.actionPresenter.get_handlers()) - cthulhu.getManager().getDynamicApiManager().registerAPI('inputEventHandlers',self.inputEventHandlers, overwrite=True) - cthulhu.getManager().getSignalManager().emitSignal('setup-inputeventhandlers-completed') - - def getInputEventHandlerKey(self, inputEventHandler): - """Returns the name of the key that contains an inputEventHadler - passed as argument - """ - - for keyName, handler in self.inputEventHandlers.items(): - if handler == inputEventHandler: - return keyName - - return None - - def getListeners(self): - """Sets up the AT-SPI event listeners for this script. - """ - listeners = script.Script.getListeners(self) - listeners["focus:"] = \ - self.onFocus - #listeners["keyboard:modifiers"] = \ - # self.noOp - listeners["document:reload"] = \ - self.onDocumentReload - listeners["document:load-complete"] = \ - self.onDocumentLoadComplete - listeners["document:load-stopped"] = \ - self.onDocumentLoadStopped - listeners["mouse:button"] = \ - self.onMouseButton - listeners["object:announcement"] = \ - self.onAnnouncement - listeners["object:property-change:accessible-name"] = \ - self.onNameChanged - listeners["object:property-change:accessible-description"] = \ - self.onDescriptionChanged - listeners["object:text-caret-moved"] = \ - self.onCaretMoved - listeners["object:text-changed:delete"] = \ - self.onTextDeleted - listeners["object:text-changed:insert"] = \ - self.onTextInserted - listeners["object:active-descendant-changed"] = \ - self.onActiveDescendantChanged - listeners["object:children-changed:add"] = \ - self.onChildrenAdded - listeners["object:children-changed:remove"] = \ - self.onChildrenRemoved - listeners["object:state-changed:active"] = \ - self.onActiveChanged - listeners["object:state-changed:busy"] = \ - self.onBusyChanged - listeners["object:state-changed:focused"] = \ - self.onFocusedChanged - listeners["object:state-changed:showing"] = \ - self.onShowingChanged - listeners["object:state-changed:checked"] = \ - self.onCheckedChanged - listeners["object:state-changed:pressed"] = \ - self.onPressedChanged - listeners["object:state-changed:indeterminate"] = \ - self.onIndeterminateChanged - listeners["object:state-changed:expanded"] = \ - self.onExpandedChanged - listeners["object:state-changed:selected"] = \ - self.onSelectedChanged - listeners["object:state-changed:sensitive"] = \ - self.onSensitiveChanged - listeners["object:text-attributes-changed"] = \ - self.onTextAttributesChanged - listeners["object:text-selection-changed"] = \ - self.onTextSelectionChanged - listeners["object:selection-changed"] = \ - self.onSelectionChanged - listeners["object:property-change:accessible-value"] = \ - self.onValueChanged - listeners["object:value-changed"] = \ - self.onValueChanged - listeners["object:column-reordered"] = \ - self.onColumnReordered - listeners["object:row-reordered"] = \ - self.onRowReordered - listeners["window:activate"] = \ - self.onWindowActivated - listeners["window:deactivate"] = \ - self.onWindowDeactivated - listeners["window:create"] = \ - self.onWindowCreated - listeners["window:destroy"] = \ - self.onWindowDestroyed - - return listeners - - def __getDesktopBindings(self): - """Returns an instance of keybindings.KeyBindings that use the - numeric keypad for focus tracking and flat review. - """ - - import cthulhu.desktop_keyboardmap as desktop_keyboardmap - keyBindings = keybindings.KeyBindings() - keyBindings.load(desktop_keyboardmap.keymap, self.inputEventHandlers) - return keyBindings - - def __getLaptopBindings(self): - """Returns an instance of keybindings.KeyBindings that use the - the main keyboard keys for focus tracking and flat review. - """ - - import cthulhu.laptop_keyboardmap as laptop_keyboardmap - keyBindings = keybindings.KeyBindings() - keyBindings.load(laptop_keyboardmap.keymap, self.inputEventHandlers) - return keyBindings - - def getExtensionBindings(self): - with open('/tmp/extension_bindings_debug.log', 'a') as f: - f.write(f"=== getExtensionBindings() called ===\n") - keyBindings = keybindings.KeyBindings() - - bindings = self.notificationPresenter.get_bindings() - for keyBinding in bindings.keyBindings: - keyBindings.add(keyBinding) - - layout = cthulhu.cthulhuApp.settingsManager.getSetting('keyboardLayout') - isDesktop = layout == settings.GENERAL_KEYBOARD_LAYOUT_DESKTOP - bindings = self.flatReviewPresenter.get_bindings(isDesktop) - for keyBinding in bindings.keyBindings: - keyBindings.add(keyBinding) - - bindings = self.whereAmIPresenter.get_bindings(isDesktop) - for keyBinding in bindings.keyBindings: - keyBindings.add(keyBinding) - - bindings = self.learnModePresenter.get_bindings() - for keyBinding in bindings.keyBindings: - keyBindings.add(keyBinding) - - bindings = self.speechAndVerbosityManager.get_bindings() - for keyBinding in bindings.keyBindings: - keyBindings.add(keyBinding) - - bindings = self.dateAndTimePresenter.get_bindings() - for keyBinding in bindings.keyBindings: - keyBindings.add(keyBinding) - - bindings = self.objectNavigator.get_bindings() - for keyBinding in bindings.keyBindings: - keyBindings.add(keyBinding) - - bindings = self.bookmarks.get_bindings() - for keyBinding in bindings.keyBindings: - keyBindings.add(keyBinding) - - bindings = self.mouseReviewer.get_bindings() - for keyBinding in bindings.keyBindings: - keyBindings.add(keyBinding) - - bindings = self.actionPresenter.get_bindings() - for keyBinding in bindings.keyBindings: - keyBindings.add(keyBinding) - - # Add plugin keybindings from APIHelper storage - try: - if hasattr(cthulhu, 'cthulhuApp') and cthulhu.cthulhuApp: - api_helper = cthulhu.cthulhuApp.getAPIHelper() - with open('/tmp/extension_bindings_debug.log', 'a') as f: - f.write(f"=== Checking for plugin bindings ===\n") - f.write(f"api_helper exists: {api_helper is not None}\n") - if api_helper: - f.write(f"api_helper has _gestureBindings: {hasattr(api_helper, '_gestureBindings')}\n") - if hasattr(api_helper, '_gestureBindings'): - f.write(f"_gestureBindings content: {api_helper._gestureBindings}\n") - f.write(f"Available contexts: {list(api_helper._gestureBindings.keys())}\n") - - if api_helper and hasattr(api_helper, '_gestureBindings'): - with open('/tmp/extension_bindings_debug.log', 'a') as f: - f.write(f"=== Adding plugin bindings in getExtensionBindings() ===\n") - - for context_name, context_bindings in api_helper._gestureBindings.items(): - for binding in context_bindings: - keyBindings.add(binding) - with open('/tmp/extension_bindings_debug.log', 'a') as f: - f.write(f"Added plugin binding: {binding.keysymstring} modifiers={binding.modifiers} desc={binding.handler.description}\n") - else: - with open('/tmp/extension_bindings_debug.log', 'a') as f: - f.write(f"=== No plugin bindings available ===\n") - else: - with open('/tmp/extension_bindings_debug.log', 'a') as f: - f.write(f"=== cthulhuApp not available ===\n") - except Exception as e: - import cthulhu.debug as debug - debug.printMessage(debug.LEVEL_WARNING, f"Failed to add plugin bindings: {e}", True) - with open('/tmp/extension_bindings_debug.log', 'a') as f: - f.write(f"Exception in plugin binding addition: {e}\n") - - return keyBindings - - def getKeyBindings(self): - """Defines the key bindings for this script. - - Returns an instance of keybindings.KeyBindings. - """ - - keyBindings = script.Script.getKeyBindings(self) - - bindings = self.getDefaultKeyBindings() - for keyBinding in bindings.keyBindings: - keyBindings.add(keyBinding) - - bindings = self.getToolkitKeyBindings() - for keyBinding in bindings.keyBindings: - keyBindings.add(keyBinding) - - bindings = self.getAppKeyBindings() - for keyBinding in bindings.keyBindings: - keyBindings.add(keyBinding) - - bindings = self.getExtensionBindings() - for keyBinding in bindings.keyBindings: - keyBindings.add(keyBinding) - - try: - keyBindings = cthulhu.cthulhuApp.settingsManager.overrideKeyBindings(self, keyBindings) - except Exception as error: - tokens = ["DEFAULT: Exception when overriding keybindings in", self, ":", error] - debug.printTokens(debug.LEVEL_WARNING, tokens, True) - - return keyBindings - - def getDefaultKeyBindings(self): - """Returns the default script's keybindings, i.e. without any of - the toolkit or application specific commands added.""" - - keyBindings = keybindings.KeyBindings() - - layout = cthulhu.cthulhuApp.settingsManager.getSetting('keyboardLayout') - if layout == settings.GENERAL_KEYBOARD_LAYOUT_DESKTOP: - for keyBinding in self.__getDesktopBindings().keyBindings: - keyBindings.add(keyBinding) - else: - for keyBinding in self.__getLaptopBindings().keyBindings: - keyBindings.add(keyBinding) - - import cthulhu.common_keyboardmap as common_keyboardmap - keyBindings.load(common_keyboardmap.keymap, self.inputEventHandlers) - - return keyBindings - - def getBrailleBindings(self): - """Defines the braille bindings for this script. - - Returns a dictionary where the keys are BrlTTY commands and the - values are InputEventHandler instances. - """ - - msg = 'DEFAULT: Getting braille bindings.' - debug.printMessage(debug.LEVEL_INFO, msg, True) - - brailleBindings = script.Script.getBrailleBindings(self) - try: - brailleBindings[braille.brlapi.KEY_CMD_HWINLT] = \ - self.inputEventHandlers["panBrailleLeftHandler"] - brailleBindings[braille.brlapi.KEY_CMD_FWINLT] = \ - self.inputEventHandlers["panBrailleLeftHandler"] - brailleBindings[braille.brlapi.KEY_CMD_FWINLTSKIP] = \ - self.inputEventHandlers["panBrailleLeftHandler"] - brailleBindings[braille.brlapi.KEY_CMD_HWINRT] = \ - self.inputEventHandlers["panBrailleRightHandler"] - brailleBindings[braille.brlapi.KEY_CMD_FWINRT] = \ - self.inputEventHandlers["panBrailleRightHandler"] - brailleBindings[braille.brlapi.KEY_CMD_FWINRTSKIP] = \ - self.inputEventHandlers["panBrailleRightHandler"] - brailleBindings[braille.brlapi.KEY_CMD_HOME] = \ - self.inputEventHandlers["goBrailleHomeHandler"] - brailleBindings[braille.brlapi.KEY_CMD_SIXDOTS] = \ - self.inputEventHandlers["contractedBrailleHandler"] - brailleBindings[braille.brlapi.KEY_CMD_ROUTE] = \ - self.inputEventHandlers["processRoutingKeyHandler"] - brailleBindings[braille.brlapi.KEY_CMD_CUTBEGIN] = \ - self.inputEventHandlers["processBrailleCutBeginHandler"] - brailleBindings[braille.brlapi.KEY_CMD_CUTLINE] = \ - self.inputEventHandlers["processBrailleCutLineHandler"] - brailleBindings[braille.brlapi.KEY_CMD_HOME] = \ - self.inputEventHandlers["goBrailleHomeHandler"] - except AttributeError: - tokens = ["DEFAULT: Braille bindings unavailable in", self] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - except Exception as error: - tokens = ["DEFAULT: Exception getting braille bindings in", self, ":", error] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - - reviewBindings = self.flatReviewPresenter.get_braille_bindings() - brailleBindings.update(reviewBindings) - - msg = 'DEFAULT: Finished getting braille bindings.' - debug.printMessage(debug.LEVEL_INFO, msg, True) - - return brailleBindings - - def deactivate(self): - """Called when this script is deactivated.""" - - self._inSayAll = False - self._sayAllIsInterrupted = False - self.pointOfReference = {} - - self.removeKeyGrabs() - - def getEnabledKeyBindings(self): - """ Returns the key bindings that are currently active. """ - return self.getKeyBindings().getBoundBindings() - - def addKeyGrabs(self): - """ Sets up the key grabs currently needed by this script. """ - if cthulhu_state.device is None: - return - msg = "DEFAULT: adding key grabs" - debug.printMessage(debug.LEVEL_INFO, msg, True) - bound = self.getEnabledKeyBindings() - for b in bound: - for id in cthulhu.addKeyGrab(b): - self.grab_ids.append(id) - self._addModifierGrabs() - - def removeKeyGrabs(self): - """ Removes this script's AT-SPI key grabs. """ - msg = "DEFAULT: removing key grabs" - debug.printMessage(debug.LEVEL_INFO, msg, True) - if cthulhu_state.device is None: - self.grab_ids = [] - self._modifierGrabIds = [] - return - for id in self.grab_ids: - cthulhu.removeKeyGrab(id) - self.grab_ids = [] - self._removeModifierGrabs() - - def _addModifierGrabs(self): - if cthulhu_state.device is None: - return - - if self._modifierGrabIds: - return - - manager = input_event_manager.get_manager() - for modifier in settings.cthulhuModifierKeys: - if modifier not in ["Insert", "KP_Insert"]: - continue - keyval = Gdk.keyval_from_name(modifier) - keycode = keybindings.getKeycode(modifier) - if not keyval or not keycode: - continue - grabId = manager.add_grab_for_modifier(modifier, keyval, keycode) - if grabId != -1: - self._modifierGrabIds.append((modifier, grabId)) - - def _removeModifierGrabs(self): - if not self._modifierGrabIds: - return - - manager = input_event_manager.get_manager() - for modifier, grabId in self._modifierGrabIds: - manager.remove_grab_for_modifier(modifier, grabId) - self._modifierGrabIds = [] - - def refreshKeyGrabs(self): - """ Refreshes the enabled key grabs for this script. """ - # TODO: Should probably avoid removing key grabs and re-adding them. - # Otherwise, a key could conceivably leak through while the script is - # in the process of updating the bindings. - self.removeKeyGrabs() - self.addKeyGrabs() - - def registerEventListeners(self): - super().registerEventListeners() - self.utilities.connectToClipboard() - - def deregisterEventListeners(self): - super().deregisterEventListeners() - self.utilities.disconnectFromClipboard() - - @staticmethod - def _normalizeKeyboardCommandKeyName(keyboardEvent): - for value in [ - getattr(keyboardEvent, "keyval_name", ""), - getattr(keyboardEvent, "event_string", ""), - ]: - keyName = (value or "").lower() - if not keyName: - continue - - if len(keyName) == 1: - codePoint = ord(keyName) - if 1 <= codePoint <= 26: - return chr(ord("a") + codePoint - 1) - return keyName - - if keyName.startswith("0x"): - try: - codePoint = int(keyName, 16) - except ValueError: - continue - if 1 <= codePoint <= 26: - return chr(ord("a") + codePoint - 1) - - return keyName - - return "" - - @staticmethod - def _getKeyboardClipboardCommandType(keyboardEvent): - keyName = Script._normalizeKeyboardCommandKeyName(keyboardEvent) - modifiers = getattr(keyboardEvent, "modifiers", 0) - control = bool(modifiers & 1 << Atspi.ModifierType.CONTROL) - shift = bool(modifiers & 1 << Atspi.ModifierType.SHIFT) - obj = keyboardEvent.get_object() - - if not control: + None, + None, + ), + ( + "contractedBrailleHandler", + self.set_contracted_braille, + cmdnames.SET_CONTRACTED_BRAILLE, + None, + None, + ), + ("shutdownHandler", self.quit_cthulhu, cmdnames.QUIT_CTHULHU, None, None), + ( + "preferencesSettingsHandler", + self.show_preferences_gui, + cmdnames.SHOW_PREFERENCES_GUI, + kb_space_cthulhu, + kb_space_cthulhu, + ), + ( + "appPreferencesSettingsHandler", + self.show_app_preferences_gui, + cmdnames.SHOW_APP_PREFERENCES_GUI, + kb_space_ctrl_cthulhu, + kb_space_ctrl_cthulhu, + ), + ] + + for ( + name, + function, + description, + desktop_kb, + laptop_kb, + ) in script_commands: + manager.add_command( + command_manager.KeyboardCommand( + name, + function, + group_label, + description, + desktop_keybinding=desktop_kb, + laptop_keybinding=laptop_kb, + ), + ) + + braille_bindings: dict[str, tuple[int, ...]] = {} + left_keys = tuple( + key + for key in ( + braille.BRLAPI_KEY_CMD_HWINLT, + braille.BRLAPI_KEY_CMD_FWINLT, + braille.BRLAPI_KEY_CMD_FWINLTSKIP, + ) + if key is not None + ) + right_keys = tuple( + key + for key in ( + braille.BRLAPI_KEY_CMD_HWINRT, + braille.BRLAPI_KEY_CMD_FWINRT, + braille.BRLAPI_KEY_CMD_FWINRTSKIP, + ) + if key is not None + ) + if left_keys: + braille_bindings["panBrailleLeftHandler"] = left_keys + if right_keys: + braille_bindings["panBrailleRightHandler"] = right_keys + + single_key_bindings: list[tuple[str, int | None]] = [ + ("goBrailleHomeHandler", braille.BRLAPI_KEY_CMD_HOME), + ("contractedBrailleHandler", braille.BRLAPI_KEY_CMD_SIXDOTS), + ("processRoutingKeyHandler", braille.BRLAPI_KEY_CMD_ROUTE), + ("processBrailleCutBeginHandler", braille.BRLAPI_KEY_CMD_CUTBEGIN), + ("processBrailleCutLineHandler", braille.BRLAPI_KEY_CMD_CUTLINE), + ] + for handler_name, key in single_key_bindings: + if key is not None: + braille_bindings[handler_name] = (key,) + if not braille_bindings: + msg = "DEFAULT: Braille bindings unavailable." + debug.print_message(debug.LEVEL_INFO, msg, True) + + braille_commands: list[tuple[str, Callable[..., bool], str, bool]] = [ + ("panBrailleLeftHandler", self.pan_braille_left, cmdnames.PAN_BRAILLE_LEFT, True), + ("panBrailleRightHandler", self.pan_braille_right, cmdnames.PAN_BRAILLE_RIGHT, True), + ( + "contractedBrailleHandler", + self.set_contracted_braille, + cmdnames.SET_CONTRACTED_BRAILLE, + True, + ), + ("goBrailleHomeHandler", self.go_braille_home, cmdnames.GO_BRAILLE_HOME, False), + ( + "processRoutingKeyHandler", + self.process_routing_key, + cmdnames.PROCESS_ROUTING_KEY, + False, + ), + ( + "processBrailleCutBeginHandler", + self.process_braille_cut_begin, + cmdnames.PROCESS_BRAILLE_CUT_BEGIN, + False, + ), + ( + "processBrailleCutLineHandler", + self.process_braille_cut_line, + cmdnames.PROCESS_BRAILLE_CUT_LINE, + False, + ), + ] + + for name, function, description, executes_in_learn_mode in braille_commands: + bb = braille_bindings.get(name, ()) + manager.add_command( + command_manager.BrailleCommand( + name, + function, + group_label, + description, + braille_bindings=bb, + executes_in_learn_mode=executes_in_learn_mode, + ), + ) + + for extension_getter, _localized_name in self._get_all_extensions(): + extension_getter().set_up_commands() + + all_braille_keys: set[int] = set() + for cmd in manager.get_all_braille_commands(): + all_braille_keys.update(cmd.get_braille_bindings()) + braille.setup_key_ranges(all_braille_keys) + + cmd_count = len(command_manager.get_manager().get_all_keyboard_commands()) + msg = f"DEFAULT: Commands set up: {cmd_count} keyboard commands" + debug.print_message(debug.LEVEL_INFO, msg, True) + + def register_event_listeners(self) -> None: + """Registers for listeners needed by this script.""" + + event_manager.get_manager().register_script_listeners(self) + + def deregister_event_listeners(self) -> None: + """De-registers the listeners needed by this script.""" + + event_manager.get_manager().deregister_script_listeners(self) + + def _get_queued_event( + self, + event_type: str, + detail1: int | None = None, + detail2: int | None = None, + any_data=None, + ) -> Atspi.Event | None: + cached_event = self.event_cache.get(event_type, [None, 0])[0] + if not cached_event: + tokens = ["SCRIPT: No queued event of type", event_type] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return None - if keyName == "c": - if AXUtilities.is_terminal(obj): - return "copy" if shift else None - return None if shift else "copy" + if detail1 is not None and detail1 != cached_event.detail1: + tokens = [ + "SCRIPT: Queued event's detail1 (", + str(cached_event.detail1), + ") doesn't match", + str(detail1), + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return None - if keyName == "x": - return None if shift else "cut" + if detail2 is not None and detail2 != cached_event.detail2: + tokens = [ + "SCRIPT: Queued event's detail2 (", + str(cached_event.detail2), + ") doesn't match", + str(detail2), + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return None - return None + if any_data is not None and any_data != cached_event.any_data: + tokens = [ + "SCRIPT: Queued event's any_data (", + cached_event.any_data, + ") doesn't match", + any_data, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return None - def _rememberKeyboardClipboardCommand(self, commandType, sourceObj): - self._keyboardClipboardCommandGeneration += 1 - capturedSelectionText = self._getKeyboardClipboardSelectionText(sourceObj) - expectedContents = self._getKeyboardClipboardExpectedContents(sourceObj) - self._pendingKeyboardClipboardCommand = { - "generation": self._keyboardClipboardCommandGeneration, - "command": commandType, - "source": sourceObj, - "clipboard": self.utilities.getClipboardContents(), - "capturedSelectionText": capturedSelectionText, - "expectedContents": expectedContents, - "callbackSeen": False, - "presented": False, - "checksRemaining": self.KEYBOARD_CLIPBOARD_MAX_CHECKS, - "scheduled": False, - } - tokens = ["DEFAULT: Tracking keyboard clipboard command", commandType, "from", sourceObj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - tokens = [ - "DEFAULT: Captured keyboard clipboard expectations", - self._describeClipboardContents(expectedContents), - ] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + tokens = ["SCRIPT: Found matching queued event:", cached_event] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return cached_event - def _getKeyboardClipboardExpectedContents(self, sourceObj): - expectedContents = [] + def locus_of_focus_changed(self, event, old_focus, new_focus): + """Called when the visual object with focus changes.""" - for getter in [self.utilities.allSelectedText, self.utilities.selectedText]: - try: - result = getter(sourceObj) - except Exception: - continue + presentation_manager.get_manager().present_command_announcement() - if not isinstance(result, tuple) or not result: - continue - - string = result[0] - if string and string not in expectedContents: - expectedContents.append(string) - - try: - name = AXObject.get_name(sourceObj) - except Exception: - name = "" - - if name and name not in expectedContents: - expectedContents.append(name) - - return expectedContents - - def _getKeyboardClipboardSelectionText(self, sourceObj): - for getter in [self.utilities.allSelectedText, self.utilities.selectedText]: - try: - result = getter(sourceObj) - except Exception: - continue - - if not isinstance(result, tuple) or not result: - continue - - string = result[0] - if string: - return string - - return "" - - @staticmethod - def _clipboardContentsMatchExpectedContents(currentContents, expectedContents): - if not currentContents: - return False - - for string in expectedContents or []: - if string and string in currentContents: - return True - - return False - - @staticmethod - def _describeClipboardContents(contents): - if isinstance(contents, (list, tuple)): - return [Script._describeClipboardContents(item) for item in contents] - - if contents is None: - return {"len": 0, "text": ""} - - string = str(contents) - if len(string) > 80: - string = f"{string[:77]}..." - string = string.replace("\n", "\\n") - return {"len": len(str(contents)), "text": string} - - def _clipboardMatchesPendingKeyboardCommand(self, pending, currentContents): - if self._clipboardContentsMatchExpectedContents( - currentContents, pending.get("expectedContents") - ): - tokens = ["DEFAULT: Keyboard clipboard fallback matched captured contents for", - pending.get("source")] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + if not new_focus: return True - sourceObj = pending.get("source") - if bool(currentContents) and self.utilities.objectContentsAreInClipboard(sourceObj): + if AXUtilities.is_defunct(new_focus): return True - if sourceObj and self.utilities.isInActiveApp(sourceObj) \ - and bool(currentContents) and self.utilities.objectContentsAreInClipboard(): - tokens = ["DEFAULT: Keyboard clipboard fallback matched locusOfFocus after source", - "mismatch", sourceObj, cthulhu_state.locusOfFocus] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + is_name_change = event and event.type.endswith("accessible-name") + if old_focus == new_focus and not is_name_change: + msg = "DEFAULT: old focus == new focus" + debug.print_message(debug.LEVEL_INFO, msg, True) return True - return False + if self.run_find_command_on: + if self.run_find_command_on == new_focus: + self.run_find_command_on = None + flat_review_finder.get_finder().find(self) + return True - def _schedulePendingKeyboardClipboardCheck(self): - pending = self._pendingKeyboardClipboardCommand - if not pending or pending.get("scheduled"): - return + if flat_review_presenter.get_presenter().is_active(): + flat_review_presenter.get_presenter().quit() - pending["scheduled"] = True - GLib.timeout_add( - self.KEYBOARD_CLIPBOARD_CHECK_DELAY_MS, - self._checkPendingKeyboardClipboardCommand, - pending["generation"], - ) + if learn_mode_presenter.get_presenter().is_active(): + learn_mode_presenter.get_presenter().quit() - def _scheduleImmediateKeyboardClipboardPresentation(self): - pending = self._pendingKeyboardClipboardCommand - if not pending or pending.get("immediateScheduled") or pending.get("presented"): - return + document_presenter.get_presenter().update_mode_if_needed(self, old_focus, new_focus) - pending["immediateScheduled"] = True - GLib.idle_add( - self._presentPendingKeyboardClipboardCommandIdle, - pending["generation"], - ) - - def _getSessionType(self): - sessionType = getattr(self, "_sessionType", None) - if sessionType is None: - sessionType = wnck_support.get_session_type() - self._sessionType = sessionType - return sessionType - - def _shouldUseImmediateKeyboardClipboardPolicy(self, pending): - if not pending or self._getSessionType() != "wayland": - return False - - if pending.get("command") not in ["copy", "cut"]: - return False - - if not pending.get("capturedSelectionText") and not pending.get("expectedContents"): - return False - - if pending.get("command") == "cut": - sourceObj = pending.get("source") - if AXUtilities.is_editable(sourceObj): - return False + active_window = self.utilities.top_level_object(new_focus) + focus_manager.get_manager().set_active_window(active_window) + if old_focus is None: + old_focus = active_window + manager = presentation_manager.get_manager() + manager.interrupt_if_needed_for_focus_change(old_focus, new_focus, event) + manager.present_object(self, new_focus, priorObj=old_focus) return True - def _rememberKeyboardClipboardRelease(self, commandType): - self._pendingKeyboardClipboardRelease = { - "command": commandType, - "time": time.time(), - } - - def _consumePendingKeyboardClipboardRelease(self, commandType): - pending = getattr(self, "_pendingKeyboardClipboardRelease", None) - self._pendingKeyboardClipboardRelease = None - if not pending or pending.get("command") != commandType: - return False - - return time.time() - pending.get("time", 0.0) <= self.KEYBOARD_CLIPBOARD_RELEASE_MAX_AGE_S - - def _presentPendingKeyboardClipboardCommand(self, pending, reason): - sourceObj = pending.get("source") - if pending.get("command") == "cut": - if AXUtilities.is_editable(sourceObj): - pending["scheduled"] = False - pending["immediateScheduled"] = False - self._pendingKeyboardClipboardCommand = None - return - self.presentMessage(messages.CLIPBOARD_CUT_FULL, messages.CLIPBOARD_CUT_BRIEF) - else: - self.presentMessage(messages.CLIPBOARD_COPIED_FULL, messages.CLIPBOARD_COPIED_BRIEF) - - tokens = ["DEFAULT: Presented keyboard clipboard", reason, "for", pending.get("command")] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - pending["scheduled"] = False - pending["immediateScheduled"] = False - pending["presented"] = True - - def _presentPendingKeyboardClipboardCommandIdle(self, generation): - pending = self._pendingKeyboardClipboardCommand - if not pending or pending.get("generation") != generation: - return False - - if pending.get("presented"): - pending["immediateScheduled"] = False - return False - - self._presentPendingKeyboardClipboardCommand(pending, "immediately") - return False - - def _checkPendingKeyboardClipboardCommand(self, generation): - pending = self._pendingKeyboardClipboardCommand - if not pending or pending.get("generation") != generation: - return False - - if pending.get("presented"): - pending["scheduled"] = False - self._pendingKeyboardClipboardCommand = None - return False - - pending["checksRemaining"] = max(pending.get("checksRemaining", 1) - 1, 0) - currentContents = self.utilities.getClipboardContents() - sourceObj = pending.get("source") - tokens = [ - "DEFAULT: Keyboard clipboard retry snapshot", - { - "command": pending.get("command"), - "source": sourceObj, - "clipboard": self._describeClipboardContents(currentContents), - "expected": self._describeClipboardContents(pending.get("expectedContents")), - "checksRemaining": pending.get("checksRemaining"), - }, - ] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - clipboardMatchesSource = self._clipboardMatchesPendingKeyboardCommand( - pending, currentContents - ) - selectionFallback = False - if not clipboardMatchesSource: - selectionFallback = self._shouldPresentKeyboardClipboardSelectionFallback( - pending, currentContents - ) - if selectionFallback: - msg = "DEFAULT: Keyboard clipboard fallback matched captured selection without clipboard access" - debug.printMessage(debug.LEVEL_INFO, msg, True) - clipboardMatchesSource = True - - if not clipboardMatchesSource: - if pending.get("checksRemaining", 0): - tokens = ["DEFAULT: Keyboard clipboard fallback retrying", pending.get("command"), - "for", sourceObj, "checks remaining", pending.get("checksRemaining")] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return True - - tokens = ["DEFAULT: Keyboard clipboard fallback did not match", sourceObj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - pending["scheduled"] = False - self._pendingKeyboardClipboardCommand = None - return False - - if not selectionFallback and currentContents == pending.get("clipboard"): - if pending.get("command") != "copy": - msg = "DEFAULT: Keyboard clipboard fallback found no clipboard change" - debug.printMessage(debug.LEVEL_INFO, msg, True) - pending["scheduled"] = False - self._pendingKeyboardClipboardCommand = None - return False - - msg = "DEFAULT: Keyboard clipboard fallback matched unchanged clipboard for copy" - debug.printMessage(debug.LEVEL_INFO, msg, True) - - if pending.get("command") == "cut": - if AXUtilities.is_editable(sourceObj): - pending["scheduled"] = False - self._pendingKeyboardClipboardCommand = None - return False - self._presentPendingKeyboardClipboardCommand(pending, "fallback") - return False - - @staticmethod - def _shouldPresentKeyboardClipboardSelectionFallback(pending, currentContents): - if pending.get("command") != "copy": - return False - - if currentContents: - return False - - return bool(pending.get("capturedSelectionText")) - - def updateKeyboardEventState(self, keyboardEvent, handler): - super().updateKeyboardEventState(keyboardEvent, handler) - - commandType = self._getKeyboardClipboardCommandType(keyboardEvent) - if not commandType: - return - - if keyboardEvent.is_pressed_key(): - self._rememberKeyboardClipboardCommand(commandType, keyboardEvent.get_object()) - pending = self._pendingKeyboardClipboardCommand - if self._shouldUseImmediateKeyboardClipboardPolicy(pending): - self._consumePendingKeyboardClipboardRelease(commandType) - msg = "DEFAULT: Scheduling keyboard clipboard immediate presentation on press" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self._scheduleImmediateKeyboardClipboardPresentation() - return - - if self._consumePendingKeyboardClipboardRelease(commandType): - if self._shouldUseImmediateKeyboardClipboardPolicy(pending): - msg = "DEFAULT: Scheduling keyboard clipboard immediate presentation after out-of-order release" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self._scheduleImmediateKeyboardClipboardPresentation() - else: - msg = "DEFAULT: Scheduling keyboard clipboard fallback after out-of-order release" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self._schedulePendingKeyboardClipboardCheck() - return - - pending = self._pendingKeyboardClipboardCommand - if self._getSessionType() == "wayland": - if pending is None: - return - - if pending.get("command") == commandType and pending.get("immediateScheduled"): - msg = "DEFAULT: Ignoring keyboard clipboard release while immediate presentation is pending" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - if pending.get("command") == commandType and pending.get("presented"): - msg = "DEFAULT: Ignoring keyboard clipboard release after immediate presentation" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self._pendingKeyboardClipboardCommand = None - return - - if not pending or pending.get("command") != commandType: - self._rememberKeyboardClipboardRelease(commandType) - return - - if self._shouldUseImmediateKeyboardClipboardPolicy(pending): - msg = "DEFAULT: Scheduling keyboard clipboard immediate presentation" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self._scheduleImmediateKeyboardClipboardPresentation() - return - - self._schedulePendingKeyboardClipboardCheck() - - def _saveFocusedObjectInfo(self, obj): - """Saves some basic information about obj. Note that this method is - intended to be called primarily (if not only) by locus_of_focus_changed(). - It is expected that accessible event callbacks will update the point - of reference data specific to that event. The goal here is to weed - out duplicate events.""" - - if not obj: - return - - # We want to save the name because some apps and toolkits emit name - # changes after the focus or selection has changed, even though the - # name has not. - name = AXObject.get_name(obj) - names = self.pointOfReference.get('names', {}) - names[hash(obj)] = name - if cthulhu_state.activeWindow: - names[hash(cthulhu_state.activeWindow)] = AXObject.get_name(cthulhu_state.activeWindow) - self.pointOfReference['names'] = names - - descriptions = self.pointOfReference.get('descriptions', {}) - descriptions[hash(obj)] = AXObject.get_description(obj) - self.pointOfReference['descriptions'] = descriptions - - # We want to save the offset for text objects because some apps and - # toolkits emit caret-moved events immediately after a text object - # gains focus, even though the caret has not actually moved. - if AXObject.supports_text(obj): - caretOffset = AXText.get_caret_offset(obj) - if caretOffset >= 0: - self._saveLastCursorPosition(obj, max(0, caretOffset)) - self.utilities.updateCachedTextSelection(obj) - - # We want to save the current row and column of a newly focused - # or selected table cell so that on subsequent cell focus/selection - # we only present the changed location. - row, column = self.utilities.coordinatesForCell(obj, findCellAncestor=True) - self.pointOfReference['lastColumn'] = column - self.pointOfReference['lastRow'] = row - - self.pointOfReference['checkedChange'] = hash(obj), AXUtilities.is_checked(obj) - self.pointOfReference['selectedChange'] = hash(obj), AXUtilities.is_selected(obj) - self.pointOfReference['expandedChange'] = hash(obj), AXUtilities.is_expanded(obj) - - def locus_of_focus_changed(self, event, oldLocusOfFocus, newLocusOfFocus): - """Called when the visual object with focus changes. - - Arguments: - - event: if not None, the Event that caused the change - - oldLocusOfFocus: Accessible that is the old locus of focus - - newLocusOfFocus: Accessible that is the new locus of focus - """ - - self.utilities.presentFocusChangeReason() - - if not newLocusOfFocus: - cthulhu_state.noFocusTimeStamp = time.time() - return - - if AXUtilities.is_defunct(newLocusOfFocus): - return - - if oldLocusOfFocus == newLocusOfFocus: - msg = 'DEFAULT: old focus == new focus' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - # Don't apply the is-same-object heuristic in the case of table cells. - # One scenario is email client message lists. When you delete a message - # and land on the next one, the cells likely occupy the same space, - # have the same role, and might even have the same name, same path, etc. - if not AXUtilities.is_table_cell(oldLocusOfFocus) \ - and not AXUtilities.is_table_cell(newLocusOfFocus) \ - and self.utilities.isSameObject(oldLocusOfFocus, newLocusOfFocus): - tokens = ["DEFAULT: old focus", oldLocusOfFocus, - "believed to be same as new focus", newLocusOfFocus] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return - - try: - if self.findCommandRun: - # Then the Cthulhu Find dialog has just given up focus - # to the original window. We don't want to speak - # the window title, current line, etc. - return - except Exception: - pass - - if self.flatReviewPresenter.is_active(): - self.flatReviewPresenter.quit() - - if self.learnModePresenter.is_active(): - self.learnModePresenter.quit() - - topLevel = self.utilities.topLevelObject(newLocusOfFocus) - if cthulhu_state.activeWindow != topLevel: - cthulhu.setActiveWindow(topLevel) - - self.updateBraille(newLocusOfFocus) - - utterances = self.speechGenerator.generateSpeech( - newLocusOfFocus, - priorObj=oldLocusOfFocus) - - if self.utilities.shouldInterruptForLocusOfFocusChange( - oldLocusOfFocus, newLocusOfFocus, event): - self.presentationInterrupt() - speech.speak(utterances, interrupt=False) - cthulhu.emitRegionChanged(newLocusOfFocus) - self._saveFocusedObjectInfo(newLocusOfFocus) - - def activate(self): + def activate(self) -> None: """Called when this script is activated.""" tokens = ["DEFAULT: Activating script for", self.app] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) - cthulhu.cthulhuApp.settingsManager.loadAppSettings(self) - braille.checkBrailleSetting() - braille.setupKeyRanges(self.brailleBindings.keys()) - speech.checkSpeechSetting() - self.speechAndVerbosityManager.update_punctuation_level() - self.speechAndVerbosityManager.update_capitalization_style() + if not focus_manager.get_manager().is_in_preferences_window(): + speech_manager.get_manager().update_punctuation_level() + speech_manager.get_manager().update_capitalization_style() + speech_manager.get_manager().update_synthesizer() - self.addKeyGrabs() + presenter = document_presenter.get_presenter() + if presenter.has_state_for_app(self.app): + presenter.restore_mode_for_script(self) + else: + reason = "script activation, no prior state" + presenter.suspend_navigators(self, False, reason) + structural_navigator.get_navigator().set_mode(self, self._default_sn_mode) + caret_navigator.get_navigator().set_enabled_for_script( + self, + self._default_caret_navigation_enabled, + ) - tokens = ["DEFAULT: Script for", self.app, "activated"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + command_manager.get_manager().activate_commands(f"activated {self.name}") - def updateBraille(self, obj, **args): - """Updates the braille display to show the give object. + def deactivate(self) -> None: + """Called when this script is deactivated.""" - Arguments: - - obj: the Accessible - """ + if bypass_mode_manager.get_manager().is_active(): + bypass_mode_manager.get_manager().toggle_enabled(self) - if not cthulhu.cthulhuApp.settingsManager.getSetting('enableBraille') \ - and not cthulhu.cthulhuApp.settingsManager.getSetting('enableBrailleMonitor'): - debug.printMessage(debug.LEVEL_INFO, "BRAILLE: update disabled", True) - return + def update_braille(self, obj: Atspi.Accessible, **args) -> None: + """Updates the braille display to show obj.""" if not obj: return - - result, focusedRegion = self.brailleGenerator.generateBraille(obj, **args) - if not result: - return - - self.clearBraille() - line = self.getNewBrailleLine() - braille.addLine(line) - self.addBrailleRegionsToLine(result, line) - - extraRegion = args.get('extraRegion') - if extraRegion: - self.addBrailleRegionToLine(extraRegion, line) - self.setBrailleFocus(extraRegion) - else: - self.setBrailleFocus(focusedRegion) - - self.refreshBraille(True) + braille_presenter.get_presenter().present_generated_braille(self, obj, **args) ######################################################################## # # @@ -1215,1244 +478,972 @@ class Script(script.Script): # # ######################################################################## - def toggleSleepMode(self, input_event=None): - """Toggles between sleep mode and regular mode.""" - # Sleep mode is now handled by the sleep mode manager - sleepModeManager = self.getSleepModeManager() - sleepModeManager.toggleSleepMode(self) + def show_app_preferences_gui( + self, + current_script: Script | None = None, + _event: input_event.InputEvent | None = None, + ) -> bool: + """Shows the app Preferences dialog.""" + + current_script = current_script or self + ui = cthulhu_gui_prefs.CthulhuSetupGUI(current_script) + ui.show_gui() return True + def show_preferences_gui( + self, + _script: Script | None = None, + _event: input_event.InputEvent | None = None, + ) -> bool: + """Displays the Preferences dialog.""" - def bypassNextCommand(self, inputEvent=None): - """Causes the next keyboard command to be ignored by Cthulhu - and passed along to the current application. - - Returns True to indicate the input event has been consumed. - """ - - self.presentMessage(messages.BYPASS_MODE_ENABLED) - cthulhu_state.bypassNextCommand = True - cthulhu_modifier_manager.getManager().unsetCthulhuModifiers("Bypass next command enabled") - self.removeKeyGrabs() + default_script = script_manager.get_manager().get_default_script() + script_manager.get_manager().set_active_script(default_script, "Global preferences") + ui = cthulhu_gui_prefs.CthulhuSetupGUI(default_script) + ui.show_gui() return True - def findNext(self, inputEvent): - """Searches forward for the next instance of the string - searched for via the Cthulhu Find dialog. Other than direction - and the starting point, the search options initially specified - (case sensitivity, window wrap, and full/partial match) are - preserved. - """ + def quit_cthulhu( + self, + _script: Script | None = None, + _event: input_event.InputEvent | None = None, + ) -> bool: + """Quit Cthulhu.""" - lastQuery = find.getLastQuery() - if lastQuery: - lastQuery.searchBackwards = False - lastQuery.startAtTop = False - self.find(lastQuery) - else: - cthulhu.showFindGUI() + cthulhu.shutdown() + return True - def findPrevious(self, inputEvent): - """Searches backwards for the next instance of the string - searched for via the Cthulhu Find dialog. Other than direction - and the starting point, the search options initially specified - (case sensitivity, window wrap, and full/or partial match) are - preserved. - """ + def pan_braille_left( + self, + current_script: Script | None = None, + event: input_event.InputEvent | None = None, + ) -> bool: + """Pans the braille display to the left.""" - lastQuery = find.getLastQuery() - if lastQuery: - lastQuery.searchBackwards = True - lastQuery.startAtTop = False - self.find(lastQuery) - else: - cthulhu.showFindGUI() - - def panBrailleLeft(self, inputEvent=None, panAmount=0): - """Pans the braille display to the left. If panAmount is non-zero, - the display is panned by that many cells. If it is 0, the display - is panned one full display width. In flat review mode, panning - beyond the beginning will take you to the end of the previous line. - - In focus tracking mode, the cursor stays at its logical position. - In flat review mode, the review cursor moves to character - associated with cell 0.""" - - if isinstance(inputEvent, input_event.KeyboardEvent) \ - and not cthulhu.cthulhuApp.settingsManager.getSetting('enableBraille') \ - and not cthulhu.cthulhuApp.settingsManager.getSetting('enableBrailleMonitor'): + if ( + isinstance(event, input_event.KeyboardEvent) + and not braille_presenter.get_presenter().use_braille() + ): msg = "DEFAULT: panBrailleLeft command requires braille or braille monitor" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - if self.flatReviewPresenter.is_active(): - if self.isBrailleBeginningShowing(): - self.flatReviewPresenter.go_start_of_line(self, inputEvent) - self.flatReviewPresenter.go_previous_character(self, inputEvent) - else: - self.panBrailleInDirection(panAmount, panToLeft=True) - - self._setFlatReviewContextToBeginningOfBrailleDisplay() - self.targetCursorCell = 1 - self.updateBrailleReview(self.targetCursorCell) - elif self.isBrailleBeginningShowing() and cthulhu_state.locusOfFocus \ - and self.utilities.isTextArea(cthulhu_state.locusOfFocus): - - # If we're at the beginning of a line of a multiline text - # area, then force it's caret to the end of the previous - # line. The assumption here is that we're currently - # viewing the line that has the caret -- which is a pretty - # good assumption for focus tacking mode. When we set the - # caret position, we will get a caret event, which will - # then update the braille. - # - obj = cthulhu_state.locusOfFocus - if not AXObject.supports_text(obj): - return True - caretOffset = AXText.get_caret_offset(obj) - lineString, startOffset, endOffset = AXText.get_line_at_offset(obj, caretOffset) - movedCaret = False - if startOffset > 0: - movedCaret = AXText.set_caret_offset(obj, startOffset - 1) - - # If we didn't move the caret and we're in a terminal, we - # jump into flat review to review the text. See - # http://bugzilla.gnome.org/show_bug.cgi?id=482294. - # - if not movedCaret and AXUtilities.is_terminal(cthulhu_state.locusOfFocus): - context = self.getFlatReviewContext() - context.goBegin(flat_review.Context.LINE) - self.flatReviewPresenter.go_previous_character(self, inputEvent) - else: - self.panBrailleInDirection(panAmount, panToLeft=True) - # We might be panning through a flashed message. - # - braille.resetFlashTimer() - self.refreshBraille(False, stopFlash=False) - - return True - - def panBrailleLeftOneChar(self, inputEvent=None): - """Nudges the braille display one character to the left. - - In focus tracking mode, the cursor stays at its logical position. - In flat review mode, the review cursor moves to character - associated with cell 0.""" - - self.panBrailleLeft(inputEvent, 1) - - def panBrailleRight(self, inputEvent=None, panAmount=0): - """Pans the braille display to the right. If panAmount is non-zero, - the display is panned by that many cells. If it is 0, the display - is panned one full display width. In flat review mode, panning - beyond the end will take you to the beginning of the next line. - - In focus tracking mode, the cursor stays at its logical position. - In flat review mode, the review cursor moves to character - associated with cell 0.""" - - if isinstance(inputEvent, input_event.KeyboardEvent) \ - and not cthulhu.cthulhuApp.settingsManager.getSetting('enableBraille') \ - and not cthulhu.cthulhuApp.settingsManager.getSetting('enableBrailleMonitor'): - msg = "DEFAULT: panBrailleRight command requires braille or braille monitor" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - if self.flatReviewPresenter.is_active(): - if self.isBrailleEndShowing(): - self.flatReviewPresenter.go_end_of_line(self, inputEvent) - # Reviewing the next character also updates the braille output - # and refreshes the display. - self.flatReviewPresenter.go_next_character(self, inputEvent) - return - self.panBrailleInDirection(panAmount, panToLeft=False) - self._setFlatReviewContextToBeginningOfBrailleDisplay() - self.targetCursorCell = 1 - self.updateBrailleReview(self.targetCursorCell) - elif self.isBrailleEndShowing() and cthulhu_state.locusOfFocus \ - and self.utilities.isTextArea(cthulhu_state.locusOfFocus): - # If we're at the end of a line of a multiline text area, then - # force it's caret to the beginning of the next line. The - # assumption here is that we're currently viewing the line that - # has the caret -- which is a pretty good assumption for focus - # tacking mode. When we set the caret position, we will get a - # caret event, which will then update the braille. - # - obj = cthulhu_state.locusOfFocus - if not AXObject.supports_text(obj): - return True - caretOffset = AXText.get_caret_offset(obj) - lineString, startOffset, endOffset = AXText.get_line_at_offset(obj, caretOffset) - if endOffset < AXText.get_character_count(obj): - AXText.set_caret_offset(obj, endOffset) - else: - self.panBrailleInDirection(panAmount, panToLeft=False) - # We might be panning through a flashed message. - # - braille.resetFlashTimer() - self.refreshBraille(False, stopFlash=False) - - return True - - def panBrailleRightOneChar(self, inputEvent=None): - """Nudges the braille display one character to the right. - - In focus tracking mode, the cursor stays at its logical position. - In flat review mode, the review cursor moves to character - associated with cell 0.""" - - self.panBrailleRight(inputEvent, 1) - - def goBrailleHome(self, inputEvent=None): - """Returns to the component with focus.""" - - if self.flatReviewPresenter.is_active(): - self.flatReviewPresenter.quit() + debug.print_message(debug.LEVEL_INFO, msg, True) return True - return braille.returnToRegionWithFocus(inputEvent) + target = current_script or self + if target is not self: + return target.pan_braille_left(target, event) - def setContractedBraille(self, inputEvent=None): + tokens = ["DEFAULT: pan_braille_left using", target] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return self._pan_braille_left(event) + + def _pan_braille_left(self, event: input_event.InputEvent | None = None) -> bool: + """Pans the braille display to the left.""" + + if flat_review_presenter.get_presenter().is_active(): + return flat_review_presenter.get_presenter().pan_braille_left(self, event) + + presenter = braille_presenter.get_presenter() + if presenter.pan_left(): + return True + + # Couldn't pan (at edge). For text areas, move caret to get more content. + focus = focus_manager.get_manager().get_locus_of_focus() + is_text_area = AXUtilities.is_editable(focus) or AXUtilities.is_terminal(focus) + if not is_text_area: + return True + + start_offset = AXText.get_line_at_offset(focus)[1] + moved_caret = False + if start_offset > 0: + moved_caret = AXText.set_caret_offset(focus, start_offset - 1) + + # If we didn't move the caret and we're in a terminal, we + # jump into flat review to review the text. See + # http://bugzilla.gnome.org/show_bug.cgi?id=482294. + if not moved_caret and AXUtilities.is_terminal(focus): + flat_review_presenter.get_presenter().go_start_of_line(self, event) + flat_review_presenter.get_presenter().go_previous_character(self, event) + + return True + + def pan_braille_right( + self, + current_script: Script | None = None, + event: input_event.InputEvent | None = None, + ) -> bool: + """Pans the braille display to the right.""" + + if ( + isinstance(event, input_event.KeyboardEvent) + and not braille_presenter.get_presenter().use_braille() + ): + msg = "DEFAULT: panBrailleRight command requires braille or braille monitor" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + target = current_script or self + if target is not self: + return target.pan_braille_right(target, event) + + tokens = ["DEFAULT: pan_braille_right using", target] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return self._pan_braille_right(event) + + def _pan_braille_right(self, event: input_event.InputEvent | None = None) -> bool: + """Pans the braille display to the right.""" + + if flat_review_presenter.get_presenter().is_active(): + return flat_review_presenter.get_presenter().pan_braille_right(self, event) + + presenter = braille_presenter.get_presenter() + if presenter.pan_right(): + return True + + # Couldn't pan (at edge). For text areas, move caret to get more content. + focus = focus_manager.get_manager().get_locus_of_focus() + is_text_area = AXUtilities.is_editable(focus) or AXUtilities.is_terminal(focus) + if not is_text_area: + return True + + end_offset = AXText.get_line_at_offset(focus)[2] + if end_offset < AXText.get_character_count(focus): + AXText.set_caret_offset(focus, end_offset) + + return True + + def go_braille_home( + self, + _script: Script | None = None, + event: input_event.InputEvent | None = None, + ) -> bool: + """Returns to the component with focus.""" + + if flat_review_presenter.get_presenter().is_active(): + flat_review_presenter.get_presenter().quit() + return True + + presentation_manager.get_manager().interrupt_presentation() + return braille.return_to_region_with_focus(event) + + def set_contracted_braille( + self, + _script: Script | None = None, + event: input_event.InputEvent | None = None, + ) -> bool: """Toggles contracted braille.""" - self._setContractedBraille(inputEvent) + braille.toggle_contracted_braille(event) return True - def processRoutingKey(self, inputEvent=None): + def process_routing_key( + self, + _script: Script | None = None, + event: input_event.BrailleEvent | None = None, + ) -> bool: """Processes a cursor routing key.""" - braille.processRoutingKey(inputEvent) + # Don't kill flash here because it will restore the previous contents and + # then process the routing key. If the contents accept a click action, this + # would result in clicking on the link instead of clearing the flash message. + presentation_manager.get_manager().interrupt_presentation(kill_flash=False) + if event is None: + return True + braille.process_routing_key(event) return True - def processBrailleCutBegin(self, inputEvent=None): + def process_braille_cut_begin( + self, + _script: Script | None = None, + event: input_event.BrailleEvent | None = None, + ) -> bool: """Clears the selection and moves the caret offset in the currently active text area. """ - obj, caretOffset = self.getBrailleCaretContext(inputEvent) - - if caretOffset >= 0: - self.utilities.clearTextSelection(obj) - self.utilities.setCaretOffset(obj, caretOffset) + if event is None: + return True + caret_context = braille.get_caret_context(event) + if caret_context.offset < 0: + return True + presentation_manager.get_manager().interrupt_presentation() + AXUtilities.clear_all_selected_text(caret_context.accessible) + self.utilities.set_caret_offset(caret_context.accessible, caret_context.offset) return True - def processBrailleCutLine(self, inputEvent=None): + def process_braille_cut_line( + self, + _script: Script | None = None, + event: input_event.BrailleEvent | None = None, + ) -> bool: """Extends the text selection in the currently active text area and also copies the selected text to the system clipboard.""" - obj, caretOffset = self.getBrailleCaretContext(inputEvent) + if event is None: + return True + caret_context = braille.get_caret_context(event) + if caret_context.offset < 0: + return True - if caretOffset >= 0: - self.utilities.adjustTextSelection(obj, caretOffset) - selectedText, startOffset, endOffset = self.utilities.allSelectedText(obj) - if selectedText: - self.utilities.setClipboardText(selectedText) + presentation_manager.get_manager().interrupt_presentation() + start_offset = AXUtilities.get_selection_start_offset(caret_context.accessible) + end_offset = AXUtilities.get_selection_end_offset(caret_context.accessible) + if start_offset < 0 or end_offset < 0: + caret_offset = AXText.get_caret_offset(caret_context.accessible) + start_offset = min(caret_context.offset, caret_offset) + end_offset = max(caret_context.offset, caret_offset) + AXUtilities.set_selected_text(caret_context.accessible, start_offset, end_offset) + text = AXUtilities.get_selected_text(caret_context.accessible)[0] + clipboard.get_presenter().set_text(text) return True - def routePointerToItem(self, inputEvent=None): + def route_pointer_to_item( + self, + _script: Script | None = None, + event: input_event.InputEvent | None = None, + ) -> bool: """Moves the mouse pointer to the current item.""" - # Store the original location for scripts which want to restore - # it later. - # - self.oldMouseCoordinates = self.utilities.absoluteMouseCoordinates() - self.lastMouseRoutingTime = time.time() - if self.flatReviewPresenter.is_active(): - self.flatReviewPresenter.route_pointer_to_object(self, inputEvent) + if flat_review_presenter.get_presenter().is_active(): + flat_review_presenter.get_presenter().route_pointer_to_object(self, event) return True - if self.eventSynthesizer.route_to_character(cthulhu_state.locusOfFocus) \ - or self.eventSynthesizer.route_to_object(cthulhu_state.locusOfFocus): - self.presentMessage(messages.MOUSE_MOVED_SUCCESS) + focus = focus_manager.get_manager().get_locus_of_focus() + if ax_event_synthesizer.get_synthesizer().route_to_character( + focus, + ) or ax_event_synthesizer.get_synthesizer().route_to_object(focus): + presentation_manager.get_manager().present_message(messages.MOUSE_MOVED_SUCCESS) return True full = messages.LOCATION_NOT_FOUND_FULL brief = messages.LOCATION_NOT_FOUND_BRIEF - self.presentMessage(full, brief) + presentation_manager.get_manager().present_message(full, brief) return False - def leftClickReviewItem(self, inputEvent=None): + def left_click_item( + self, + _script: Script | None = None, + event: input_event.InputEvent | None = None, + ) -> bool: """Performs a left mouse button click on the current item.""" - if self.flatReviewPresenter.is_active(): - obj = self.flatReviewPresenter.get_current_object(self, inputEvent) - if self.eventSynthesizer.try_all_clickable_actions(obj): + if flat_review_presenter.get_presenter().is_active(): + obj = flat_review_presenter.get_presenter().get_current_object(self, event) + if ax_event_synthesizer.get_synthesizer().try_all_clickable_actions(obj): return True - return self.flatReviewPresenter.left_click_on_object(self, inputEvent) + return flat_review_presenter.get_presenter().left_click_on_object(self, event) - if self.eventSynthesizer.try_all_clickable_actions(cthulhu_state.locusOfFocus): + focus = focus_manager.get_manager().get_locus_of_focus() + if ax_event_synthesizer.get_synthesizer().try_all_clickable_actions(focus): return True - if self.utilities.queryNonEmptyText(cthulhu_state.locusOfFocus): - if self.eventSynthesizer.click_character(cthulhu_state.locusOfFocus, 1): + if AXText.get_character_count(focus): + if ax_event_synthesizer.get_synthesizer().click_character(focus, None, 1): return True - if self.eventSynthesizer.click_object(cthulhu_state.locusOfFocus, 1): + if ax_event_synthesizer.get_synthesizer().click_object(focus, 1): return True full = messages.LOCATION_NOT_FOUND_FULL brief = messages.LOCATION_NOT_FOUND_BRIEF - self.presentMessage(full, brief) + presentation_manager.get_manager().present_message(full, brief) return False - def rightClickReviewItem(self, inputEvent=None): + def right_click_item( + self, + _script: Script | None = None, + event: input_event.InputEvent | None = None, + ) -> bool: """Performs a right mouse button click on the current item.""" - if self.flatReviewPresenter.is_active(): - self.flatReviewPresenter.right_click_on_object(self, inputEvent) + if flat_review_presenter.get_presenter().is_active(): + obj = flat_review_presenter.get_presenter().get_current_object(self, event) + if ax_event_synthesizer.get_synthesizer().try_all_right_click_actions(obj): + return True + flat_review_presenter.get_presenter().right_click_on_object(self, event) return True - if self.eventSynthesizer.click_character(cthulhu_state.locusOfFocus, 3): + focus = focus_manager.get_manager().get_locus_of_focus() + if ax_event_synthesizer.get_synthesizer().try_all_right_click_actions(focus): return True - if self.eventSynthesizer.click_object(cthulhu_state.locusOfFocus, 3): + if ax_event_synthesizer.get_synthesizer().click_character(focus, None, 3): + return True + + if ax_event_synthesizer.get_synthesizer().click_object(focus, 3): return True full = messages.LOCATION_NOT_FOUND_FULL brief = messages.LOCATION_NOT_FOUND_BRIEF - self.presentMessage(full, brief) + presentation_manager.get_manager().present_message(full, brief) return False - def spellCurrentItem(self, itemString): - """Spell the current flat review word or line. - - Arguments: - - itemString: the string to spell. - """ - - for character in itemString: - self.speakCharacter(character) - - @dbus_service.command - def sayAll(self, inputEvent, obj=None, offset=None, notify_user=True): - """Speaks the entire document or text, starting from the current position.""" - obj = obj or cthulhu_state.locusOfFocus - if not obj or AXObject.is_dead(obj): - self.presentMessage(messages.LOCATION_NOT_FOUND_FULL) - return True - - if not AXObject.supports_text(obj): - utterances = self.speechGenerator.generateSpeech(obj) - utterances.extend(self.tutorialGenerator.getTutorial(obj, False)) - speech.speak(utterances) - else: - if offset is None: - offset = AXText.get_caret_offset(obj) - speech.sayAll(self.textLines(obj, offset), - self.__sayAllProgressCallback) - - return True - - def cycleSettingsProfile(self, inputEvent=None): - """Cycle through the user's existing settings profiles.""" - - profiles = cthulhu.cthulhuApp.settingsManager.availableProfiles() - if not (profiles and profiles[0]): - self.presentMessage(messages.PROFILE_NOT_FOUND) - return True - - def isMatch(x): - return x is not None and x[1] == cthulhu.cthulhuApp.settingsManager.getProfile() - - current = list(filter(isMatch, profiles))[0] - try: - name, profileID = profiles[profiles.index(current) + 1] - except IndexError: - name, profileID = profiles[0] - - cthulhu.cthulhuApp.settingsManager.setProfile(profileID, updateLocale=True) - - braille.checkBrailleSetting() - - speech.shutdown() - speech.init() - - # TODO: This is another "too close to code freeze" hack to cause the - # command names to be presented in the correct language. - self.setupInputEventHandlers() - - self.presentMessage(messages.PROFILE_CHANGED % name, name) - return True - - def cycleDebugLevel(self, inputEvent=None): - levels = [debug.LEVEL_ALL, "all", - debug.LEVEL_FINEST, "finest", - debug.LEVEL_FINER, "finer", - debug.LEVEL_FINE, "fine", - debug.LEVEL_CONFIGURATION, "configuration", - debug.LEVEL_INFO, "info", - debug.LEVEL_WARNING, "warning", - debug.LEVEL_SEVERE, "severe", - debug.LEVEL_OFF, "off"] - - try: - levelIndex = levels.index(debug.debugLevel) + 2 - except Exception: - levelIndex = 0 - else: - if levelIndex >= len(levels): - levelIndex = 0 - - debug.debugLevel = levels[levelIndex] - briefMessage = levels[levelIndex + 1] - fullMessage = f"Debug level {briefMessage}." - self.presentMessage(fullMessage, briefMessage) - - return True - ######################################################################## # # # AT-SPI OBJECT EVENT HANDLERS # # # + # Event handlers return bool: # + # - True: Event was fully handled, no further processing needed # + # - False: Event wasn't handled, should be passed to parent handlers # + # Default return value is True (event handled by default script) # + # # ######################################################################## - def noOp(self, event): - """Just here to capture events. - - Arguments: - - event: the Event - """ - pass - - def onActiveChanged(self, event): + def _on_active_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:active accessibility events.""" window = event.source - if AXUtilities.is_application(AXObject.get_parent(event.source)): - window = AXObject.find_real_app_and_window_for(event.source)[1] - if AXUtilities.is_dialog_or_alert(window) or AXUtilities.is_frame(window): - if event.detail1 and not self.utilities.canBeActiveWindow(window): - return + if event.detail1 and not AXUtilities.can_be_active_window(window): + return True - sourceIsActiveWindow = self.utilities.isSameObject(window, cthulhu_state.activeWindow) - if sourceIsActiveWindow and not event.detail1: - if self.utilities.inMenu(): + source_is_active_window = window == focus_manager.get_manager().get_active_window() + if source_is_active_window and not event.detail1: + focus = focus_manager.get_manager().get_locus_of_focus() + if AXUtilities.find_ancestor_inclusive(focus, AXUtilities.is_menu): msg = "DEFAULT: Ignoring event. In menu." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - if not self.utilities.eventIsUserTriggered(event): - msg = "DEFAULT: Not clearing state. Event is not user triggered." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + debug.print_message(debug.LEVEL_INFO, msg, True) + return True msg = "DEFAULT: Event is for active window. Clearing state." - debug.printMessage(debug.LEVEL_INFO, msg, True) - cthulhu.setActiveWindow(None) - return + debug.print_message(debug.LEVEL_INFO, msg, True) + focus_manager.get_manager().set_active_window(None) + return True - if not sourceIsActiveWindow and event.detail1: + if not source_is_active_window and event.detail1: msg = "DEFAULT: Updating active window." - debug.printMessage(debug.LEVEL_INFO, msg, True) - cthulhu.setActiveWindow(window, alsoSetLocusOfFocus=True, notifyScript=True) + debug.print_message(debug.LEVEL_INFO, msg, True) + focus_manager.get_manager().set_active_window( + window, + set_window_as_focus=True, + notify_script=True, + ) - if self.findCommandRun: - self.findCommandRun = False - self.find() + return True - def onActiveDescendantChanged(self, event): + def _on_active_descendant_changed(self, event: Atspi.Event) -> bool: """Callback for object:active-descendant-changed accessibility events.""" - if not event.any_data: - msg = "DEFAULT: Ignoring event. No any_data." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + if AXUtilities.is_presentable_active_descendant_change(event): + focus_manager.get_manager().set_locus_of_focus(event, event.any_data) - if not AXUtilities.is_focused(event.source) \ - and not AXUtilities.is_focused(event.any_data): - msg = "DEFAULT: Ignoring event. Neither source nor child have focused state." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + return True - if self.stopSpeechOnActiveDescendantChanged(event): - self.presentationInterrupt() - - tokens = ["DEFAULT: Setting locus of focus to any_data", event.any_data] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - cthulhu.setLocusOfFocus(event, event.any_data) - - def onBusyChanged(self, event): - """Callback for object:state-changed:busy accessibility events.""" - pass - - def onCheckedChanged(self, event): + def _on_checked_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:checked accessibility events.""" - if not self.utilities.isSameObject(event.source, cthulhu_state.locusOfFocus): - return + if AXUtilities.is_presentable_checked_change(event): + self.present_object(event.source, alreadyFocused=True, interrupt=True) - if AXUtilities.is_expandable(event.source): - return + return True - # Radio buttons normally change their state when you arrow to them, - # so we handle the announcement of their state changes in the focus - # handling code. However, we do need to handle radio buttons where - # the user needs to press the space key to select them. - if AXObject.get_role(event.source) == Atspi.Role.RADIO_BUTTON: - eventString, mods = self.utilities.lastKeyAndModifiers() - if eventString not in [" ", "space"]: - return - - oldObj, oldState = self.pointOfReference.get('checkedChange', (None, 0)) - if hash(oldObj) == hash(event.source) and oldState == event.detail1: - return - - self.presentObject(event.source, alreadyFocused=True, interrupt=True) - self.pointOfReference['checkedChange'] = hash(event.source), event.detail1 - - def onChildrenAdded(self, event): + def _on_children_added(self, event: Atspi.Event) -> bool: """Callback for object:children-changed:add accessibility events.""" - AXObject.clear_cache_now("children-changed event.") + AXUtilities.clear_all_cache_now(event.source, "children-changed event.") + return True - def onChildrenRemoved(self, event): + def _on_children_removed(self, event: Atspi.Event) -> bool: """Callback for object:children-changed:remove accessibility events.""" - AXObject.clear_cache_now("children-changed event.") + AXUtilities.clear_all_cache_now(event.source, "children-changed event.") + return True - def onCaretMoved(self, event): + # pylint: disable-next=too-many-return-statements + def _on_caret_moved(self, event: Atspi.Event) -> bool: """Callback for object:text-caret-moved accessibility events.""" - obj, offset = self.pointOfReference.get("lastCursorPosition", (None, -1)) + reason = AXUtilities.get_text_event_reason(event) + if reason == TextEventReason.SEARCH_PRESENTABLE: + msg = "DEFAULT: Presenting line for search result change" + contents = self.utilities.get_line_contents_at_offset(event.source, event.detail1) + presentation_manager.get_manager().speak_contents(contents) + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + manager = focus_manager.get_manager() + focus = manager.get_locus_of_focus() + if focus != event.source: + if not AXUtilities.is_focused(event.source): + msg = "DEFAULT: Change is from unfocused source that is not the locus of focus" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + # TODO - JD: See if this can be removed. If it's still needed document why. + manager.set_locus_of_focus(event, event.source, False) + + obj, offset = manager.get_last_cursor_position() if offset == event.detail1 and obj == event.source: - msg = "DEFAULT: Event is for last saved cursor position" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + navigation_reasons = { + TextEventReason.NAVIGATION_BY_WORD, + TextEventReason.NAVIGATION_BY_CHARACTER, + } + if reason not in navigation_reasons: + msg = "DEFAULT: Event is for last saved cursor position" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + msg = "DEFAULT: Position matches but proceeding due to navigation reason" + debug.print_message(debug.LEVEL_INFO, msg, True) + presentation_manager.get_manager().interrupt_presentation() - if not AXUtilities.is_showing(event.source): - msg = "DEFAULT: Event source is not showing. Clearing cache." - debug.printMessage(debug.LEVEL_INFO, msg, True) - AXObject.clear_cache(obj) - if not AXUtilities.is_showing(event.source): - msg = "DEFAULT: Event source is still not showing." - debug.printMessage(debug.LEVEL_INFO, msg, True) - if not self.utilities.presentEventFromNonShowingObject(event): - return + if flat_review_presenter.get_presenter().is_active(): + flat_review_presenter.get_presenter().quit() - if event.source != cthulhu_state.locusOfFocus and AXUtilities.is_focused(event.source): - topLevelObject = self.utilities.topLevelObject(event.source) - if self.utilities.isSameObject(cthulhu_state.activeWindow, topLevelObject): - tokens = ["DEFAULT: Updating locusOfFocus from", cthulhu_state.locusOfFocus, - "to", event.source] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - cthulhu.setLocusOfFocus(event, event.source, False) - else: - tokens = ["DEFAULT: Source window (", topLevelObject, ") is not active window (", - cthulhu_state.activeWindow] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + offset = AXText.get_caret_offset(event.source) - if event.source != cthulhu_state.locusOfFocus: - tokens = ["DEFAULT: Event source (", event.source, ") is not locusOfFocus (", - cthulhu_state.locusOfFocus] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return + # TODO - JD: These need to be harmonized / unified / simplified. + manager.set_last_cursor_position(event.source, offset) + self.utilities.set_caret_context(event.source, offset) - if self.flatReviewPresenter.is_active(): - self.flatReviewPresenter.quit() + ignore = [ + TextEventReason.CUT, + TextEventReason.PASTE, + TextEventReason.REDO, + TextEventReason.UNDO, + ] + if reason in ignore: + msg = f"DEFAULT: Ignoring event due to reason ({reason})" + debug.print_message(debug.LEVEL_INFO, msg, True) + AXUtilities.update_cached_selected_text(event.source) + return True - if not AXObject.supports_text(event.source): - return - - caretOffset = AXText.get_caret_offset(event.source) - if caretOffset < 0: - tokens = ["DEFAULT: Invalid caretOffset for", event.source] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return - - self._saveLastCursorPosition(event.source, caretOffset) - if self.utilities.allTextSelections(event.source): + if AXUtilities.has_selected_text(event.source): msg = "DEFAULT: Event source has text selections" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.utilities.handleTextSelectionChange(event.source) - return - else: - start, end, string = self.utilities.getCachedTextSelection(obj) - if string and self.utilities.handleTextSelectionChange(obj): - msg = "DEFAULT: Event handled as text selection change" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + debug.print_message(debug.LEVEL_INFO, msg, True) + self.utilities.handle_text_selection_change(event.source) + return True + + text, _start, _end = AXUtilities.get_cached_selected_text(obj) + if text and self.utilities.handle_text_selection_change(obj): + msg = "DEFAULT: Event handled as text selection change" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True msg = "DEFAULT: Presenting text at new caret position" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self._presentTextAtNewCaretPosition(event) + debug.print_message(debug.LEVEL_INFO, msg, True) + self._present_caret_moved_event(event, reason=reason) + return True - def onDescriptionChanged(self, event): + def _on_description_changed(self, event: Atspi.Event) -> bool: """Callback for object:property-change:accessible-description events.""" - obj = event.source - descriptions = self.pointOfReference.get('description', {}) - oldDescription = descriptions.get(hash(obj)) - if oldDescription == event.any_data: - tokens = ["DEFAULT: Old description (", oldDescription, ") is the same as new one"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return + if AXUtilities.is_presentable_description_change(event): + presentation_manager.get_manager().present_message(event.any_data) + return True - if obj != cthulhu_state.locusOfFocus: - msg = "DEFAULT: Event is for object other than the locusOfFocus" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + def _on_document_page_changed(self, event: Atspi.Event) -> bool: + """Callback for document:page-changed accessibility events.""" - descriptions[hash(obj)] = event.any_data - self.pointOfReference['descriptions'] = descriptions - if event.any_data: - self.presentMessage(event.any_data) + if event.detail1 < 0: + return True - def onDocumentReload(self, event): - """Callback for document:reload accessibility events.""" + if not AXDocument.did_page_change(event.source): + return True - pass + presentation_manager.get_manager().present_message(messages.PAGE_NUMBER % event.detail1) + return True - def onDocumentLoadComplete(self, event): - """Callback for document:load-complete accessibility events.""" - - pass - - def onDocumentLoadStopped(self, event): - """Callback for document:load-stopped accessibility events.""" - - pass - - def onExpandedChanged(self, event): + def _on_expanded_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:expanded accessibility events.""" - if not self.utilities.isPresentableExpandedChangedEvent(event): - return + AXUtilities.clear_all_cache_now(event.source, "expanded-changed event.") + if not AXUtilities.is_presentable_expanded_change(event): + return True - obj = event.source - oldObj, oldState = self.pointOfReference.get('expandedChange', (None, 0)) - if hash(oldObj) == hash(obj) and oldState == event.detail1: - return - - self.presentObject(obj, alreadyFocused=True, interrupt=True) - self.pointOfReference['expandedChange'] = hash(obj), event.detail1 - - details = self.utilities.detailsContentForObject(obj) + self.present_object(event.source, alreadyFocused=True, interrupt=True) + details = AXUtilities.get_details_content(event.source) for detail in details: - self.speakMessage(detail, interrupt=False) + presentation_manager.get_manager().speak_message(detail) - def onIndeterminateChanged(self, event): + return True + + def _on_indeterminate_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:indeterminate accessibility events.""" - # If this state is cleared, the new state will become checked or unchecked - # and we should get object:state-changed:checked events for those cases. - # Therefore, if the state is not now indeterminate/partially checked, - # ignore this event. - if not event.detail1: - return + if AXUtilities.is_presentable_indeterminate_change(event): + self.present_object(event.source, alreadyFocused=True, interrupt=True) - obj = event.source - if not self.utilities.isSameObject(obj, cthulhu_state.locusOfFocus): - return + return True - oldObj, oldState = self.pointOfReference.get('indeterminateChange', (None, 0)) - if hash(oldObj) == hash(obj) and oldState == event.detail1: - return + def _on_invalid_entry_changed(self, event: Atspi.Event) -> bool: + """Callback for object:state-changed:invalid-entry accessibility events.""" - self.presentObject(obj, alreadyFocused=True, interrupt=True) - self.pointOfReference['indeterminateChange'] = hash(obj), event.detail1 + if not AXUtilities.is_presentable_invalid_entry_change(event): + return True - def onMouseButton(self, event): + if event.detail1: + msg = self.get_speech_generator().get_error_message(event.source) + else: + msg = messages.INVALID_ENTRY_FIXED + presentation_manager.get_manager().speak_message(msg) + self.update_braille(event.source) + return True + + def _on_mouse_button(self, event: Atspi.Event) -> bool: """Callback for mouse:button events.""" - mouseEvent = input_event.MouseButtonEvent(event) - cthulhu_state.lastInputEvent = mouseEvent - if not mouseEvent.pressed: - return + input_event_manager.get_manager().process_mouse_button_event(event) + return True - windowChanged = cthulhu_state.activeWindow != mouseEvent.window - if windowChanged: - cthulhu.setActiveWindow(mouseEvent.window, alsoSetLocusOfFocus=True) - - self.presentationInterrupt() - if AXUtilities.is_focused(mouseEvent.obj): - cthulhu.setLocusOfFocus(None, mouseEvent.obj, windowChanged) - - def onAnnouncement(self, event): + def _on_announcement(self, event: Atspi.Event) -> bool: """Callback for object:announcement events.""" if isinstance(event.any_data, str): - # AT-SPI announcements contain application content, not system messages. - # Use resetStyles=False to preserve the user's punctuation settings. - self.presentMessage(event.any_data, resetStyles=False) + presentation_manager.get_manager().present_message(event.any_data) - def onNameChanged(self, event): + return True + + def _on_name_changed(self, event: Atspi.Event) -> bool: """Callback for object:property-change:accessible-name events.""" - names = self.pointOfReference.get('names', {}) - oldName = names.get(hash(event.source)) - if oldName == event.any_data: - tokens = ["DEFAULT: Old name (", oldName, ") is the same as new name"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return + if not AXUtilities.is_presentable_name_change(event): + return True - if AXUtilities.is_combo_box(event.source) or AXUtilities.is_table_cell(event.source): - msg = "DEFAULT: Event is redundant notification for this role" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + manager = focus_manager.get_manager() + if event.source == manager.get_locus_of_focus(): + # Force the update so that braille is refreshed. + manager.set_locus_of_focus(event, event.source, True, True) + return True - if AXUtilities.is_frame(event.source): - if event.source != cthulhu_state.activeWindow: - msg = "DEFAULT: Event is for frame other than the active window" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - elif event.source != cthulhu_state.locusOfFocus: - msg = "DEFAULT: Event is for object other than the locusOfFocus" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + presentation_manager.get_manager().present_message(event.any_data) + return True - names[hash(event.source)] = event.any_data - self.pointOfReference['names'] = names - if event.any_data: - self.presentMessage(event.any_data) + def _on_object_attributes_changed(self, event: Atspi.Event) -> bool: + """Callback for object:attributes-changed accessibility events.""" - def onPressedChanged(self, event): + AXUtilities.clear_all_cache_now(event.source, "object-attributes-changed event.") + return True + + def _on_pressed_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:pressed accessibility events.""" - obj = event.source - if not self.utilities.isSameObject(obj, cthulhu_state.locusOfFocus): - return + if AXUtilities.is_presentable_pressed_change(event): + self.present_object(event.source, alreadyFocused=True, interrupt=True) - oldObj, oldState = self.pointOfReference.get('pressedChange', (None, 0)) - if hash(oldObj) == hash(obj) and oldState == event.detail1: - return + return True - self.presentObject(obj, alreadyFocused=True, interrupt=True) - self.pointOfReference['pressedChange'] = hash(obj), event.detail1 - - def onSelectedChanged(self, event): + def _on_selected_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:selected accessibility events.""" - AXObject.clear_cache(event.source) - if not AXUtilities.is_focused(event.source): - msg = "DEFAULT: Event is not toggling of currently-focused object" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + if not AXUtilities.is_presentable_selected_change(event): + return True - if not self.utilities.isSameObject(cthulhu_state.locusOfFocus, event.source): - tokens = ["DEFAULT: Event is not for locusOfFocus", cthulhu_state.locusOfFocus] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return + if speech_presenter.get_presenter().get_only_speak_displayed_text(): + return True - if cthulhu.cthulhuApp.settingsManager.getSetting('onlySpeakDisplayedText'): - return + announce_state = False + manager = input_event_manager.get_manager() + if manager.last_event_was_space(): + announce_state = True + elif ( + manager.last_event_was_up() or manager.last_event_was_down() + ) and AXUtilities.is_table_cell(event.source): + announce_state = AXUtilities.is_selected(event.source) - isSelected = AXUtilities.is_selected(event.source) - if isSelected != event.detail1: - msg = "DEFAULT: Bogus event: detail1 doesn't match state" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - oldObj, oldState = self.pointOfReference.get('selectedChange', (None, 0)) - if hash(oldObj) == hash(event.source) and oldState == event.detail1: - msg = "DEFAULT: Duplicate or spam event" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - announceState = False - keyString, mods = self.utilities.lastKeyAndModifiers() - if keyString == "space": - announceState = True - elif keyString in ["Down", "Up"] and AXUtilities.is_table_cell(event.source): - announceState = isSelected - - if not announceState: - return + if not announce_state: + return True # TODO - JD: Unlike the other state-changed callbacks, it seems unwise - # to call generateSpeech() here because that also will present the + # to call generate_speech() here because that also will present the # expandable state if appropriate for the object type. The generators # need to gain some smarts w.r.t. state changes. if event.detail1: - self.speakMessage(messages.TEXT_SELECTED, interrupt=False) + presentation_manager.get_manager().speak_message(messages.TEXT_SELECTED) else: - self.speakMessage(messages.TEXT_UNSELECTED, interrupt=False) + presentation_manager.get_manager().speak_message(messages.TEXT_UNSELECTED) - self.pointOfReference['selectedChange'] = hash(event.source), event.detail1 + return True - def onSelectionChanged(self, event): + def _on_selection_changed(self, event: Atspi.Event) -> bool: """Callback for object:selection-changed accessibility events.""" - if self.utilities.handlePasteLocusOfFocusChange(): - if self.utilities.topLevelObjectIsActiveAndCurrent(event.source): - cthulhu.setLocusOfFocus(event, event.source, False) - elif self.utilities.handleContainerSelectionChange(event.source): - return - elif AXUtilities.manages_descendants(event.source): - return + if not AXUtilities.is_presentable_selection_change(event): + return True - # If the current item's selection is toggled, we'll present that - # via the state-changed event. - keyString, mods = self.utilities.lastKeyAndModifiers() - if keyString == "space": - return + presentation_manager.get_manager().present_command_announcement() - if AXUtilities.is_combo_box(event.source) and not AXUtilities.is_expanded(event.source): - if AXUtilities.is_focused(self.utilities.getEntryForEditableComboBox(event.source)): - return - elif AXUtilities.is_page_tab_list(event.source) and self.flatReviewPresenter.is_active(): + if self.utilities.handle_container_selection_change(event.source): + return True + + if ( + AXUtilities.is_page_tab_list(event.source) + and flat_review_presenter.get_presenter().is_active() + ): # If a wizard-like notebook page being reviewed changes, we might not get # any events to update the locusOfFocus. As a result, subsequent flat # review commands will continue to present the stale content. # TODO - JD: We can potentially do some automatic reading here. - self.flatReviewPresenter.quit() + flat_review_presenter.get_presenter().quit() - mouseReviewItem = self.mouseReviewer.getCurrentItem() - selectedChildren = self.utilities.selectedChildren(event.source) - for child in selectedChildren: - if AXObject.find_ancestor(cthulhu_state.locusOfFocus, lambda x: x == child): - tokens = ["DEFAULT: Child", child, "is ancestor of locusOfFocus"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - self._saveFocusedObjectInfo(cthulhu_state.locusOfFocus) - return + focus = focus_manager.get_manager().get_locus_of_focus() + mouse_review_item = mouse_review.get_reviewer().get_current_item() + child = AXUtilities.get_selected_child_for_focus( + event.source, + focus, + lambda c: c == mouse_review_item or AXUtilities.is_layout_only(c), + ) + if child is not None: + focus_manager.get_manager().set_locus_of_focus(event, child) - if child == mouseReviewItem: - tokens = ["DEFAULT: Child", child, "is current mouse review item"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - continue + return True - if AXUtilities.is_page_tab(child) and cthulhu_state.locusOfFocus \ - and AXObject.get_name(child) == AXObject.get_name(cthulhu_state.locusOfFocus) \ - and not AXUtilities.is_focused(event.source): - tokens = ["DEFAULT:", child, "'s selection redundant to", cthulhu_state.locusOfFocus] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - break - - if not self.utilities.isLayoutOnly(child): - cthulhu.setLocusOfFocus(event, child) - break - - def onSensitiveChanged(self, event): - """Callback for object:state-changed:sensitive accessibility events.""" - pass - - def onFocus(self, event): - """Callback for focus: accessibility events.""" - - pass - - def onFocusedChanged(self, event): + def _on_focused_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:focused accessibility events.""" if not event.detail1: - return + return True if not AXUtilities.is_focused(event.source): - return + tokens = ["DEFAULT:", event.source, "lacks focused state. Clearing cache."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + AXObject.clear_cache(event.source, reason="Event detail1 does not match state.") + if not AXUtilities.is_focused(event.source): + msg = "DEFAULT: Clearing cache did not update state." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True obj = event.source - window, dialog = self.utilities.frameAndDialog(obj) - clearCache = window != cthulhu_state.activeWindow - if window and not self.utilities.canBeActiveWindow(window, clearCache) and not dialog: - return + window, dialog = self.utilities.frame_and_dialog(obj) + if window and not AXUtilities.can_be_active_window(window) and not dialog: + return True if AXObject.get_child_count(obj) and not AXUtilities.is_combo_box(obj): - selectedChildren = self.utilities.selectedChildren(obj) - if selectedChildren: - obj = selectedChildren[0] + selected_children = AXUtilities.selected_children(obj) + if selected_children: + obj = selected_children[0] - cthulhu.setLocusOfFocus(event, obj) + focus_manager.get_manager().set_locus_of_focus(event, obj) + return True - def onShowingChanged(self, event): + def _on_showing_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:showing accessibility events.""" obj = event.source - role = AXObject.get_role(obj) - if role == Atspi.Role.NOTIFICATION: + if AXUtilities.is_notification(obj): if not event.detail1: - return + return True - speech.speak(self.speechGenerator.generateSpeech(obj)) - msg = self.utilities.getNotificationContent(obj) - self.displayBrailleMessage(msg, flashTime=settings.brailleFlashTime) - self.notificationPresenter.save_notification(msg) - return + presentation_manager.get_manager().speak_message( + self.get_speech_generator().get_localized_role_name(obj), + ) + msg = self.utilities.get_notification_content(obj) + presenter = presentation_manager.get_manager() + presenter.speak_accessible_text(obj, msg) + presenter.present_braille_message(msg) + notification_presenter.get_presenter().save_notification(msg) + return True - if role == Atspi.Role.TOOL_TIP: - keyString, mods = self.utilities.lastKeyAndModifiers() - if keyString != "F1" \ - and not cthulhu.cthulhuApp.settingsManager.getSetting('presentToolTips'): - return + if AXUtilities.is_tool_tip(obj): + was_f1 = input_event_manager.get_manager().last_event_was_f1() + if not was_f1 and not mouse_review.get_reviewer().get_present_tooltips(): + return True if event.detail1: - self.presentObject(obj, interrupt=True) - return - - if cthulhu_state.locusOfFocus and keyString == "F1": - obj = cthulhu_state.locusOfFocus - self.presentObject(obj, priorObj=event.source, interrupt=True) - return + self.present_object(obj, interrupt=True) + return True - def onTextAttributesChanged(self, event): + focus = focus_manager.get_manager().get_locus_of_focus() + if focus and was_f1: + obj = focus + self.present_object(obj, priorObj=event.source, interrupt=True) + return True + + return True + + def _on_text_attributes_changed(self, event: Atspi.Event) -> bool: """Callback for object:text-attributes-changed accessibility events.""" - if not self.utilities.isPresentableTextChangedEventForLocusOfFocus(event): - return + if not AXUtilities.is_presentable_text_attributes_change(event): + return True - text = self.utilities.queryNonEmptyText(event.source) - if not text: - msg = "DEFAULT: Querying non-empty text returned None" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + word, start, _end = AXText.get_word_at_offset(event.source) + if not word.strip(): + word, start, _end = AXText.get_word_at_offset(event.source, start - 1) - if cthulhu.cthulhuApp.settingsManager.getSetting('speakMisspelledIndicator'): - offset = AXText.get_caret_offset(event.source) - if not AXText.get_substring(event.source, offset, offset + 1).isalnum(): - offset -= 1 - if self.utilities.isWordMisspelled(event.source, offset-1) \ - or self.utilities.isWordMisspelled(event.source, offset+1): - self.speakMessage(messages.MISSPELLED) + speech_pres = speech_presenter.get_presenter() + if error := speech_pres.get_error_description(event.source, start, False): + presentation_manager.get_manager().speak_message(error) - def onTextDeleted(self, event): + return True + + def _on_text_deleted(self, event: Atspi.Event) -> bool: """Callback for object:text-changed:delete accessibility events.""" - if not self.utilities.isPresentableTextChangedEventForLocusOfFocus(event): - return + if not AXUtilities.is_presentable_text_deletion(event): + return True - self.utilities.handleUndoTextEvent(event) + reason = AXUtilities.get_text_event_reason(event) + presentation_manager.get_manager().present_command_announcement() + self.update_braille(event.source) - cthulhu.setLocusOfFocus(event, event.source, False) - self.updateBraille(event.source) - - full, brief = "", "" - if self.utilities.isClipboardTextChangedEvent(event): - msg = "DEFAULT: Deletion is believed to be due to clipboard cut" - debug.printMessage(debug.LEVEL_INFO, msg, True) - full, brief = messages.CLIPBOARD_CUT_FULL, messages.CLIPBOARD_CUT_BRIEF - elif self.utilities.isSelectedTextDeletionEvent(event): + if reason == TextEventReason.SELECTED_TEXT_DELETION: msg = "DEFAULT: Deletion is believed to be due to deleting selected text" - debug.printMessage(debug.LEVEL_INFO, msg, True) - full = messages.SELECTION_DELETED + debug.print_message(debug.LEVEL_INFO, msg, True) + presentation_manager.get_manager().present_message(messages.SELECTION_DELETED) + AXUtilities.update_cached_selected_text(event.source) + return True - if full or brief: - self.presentMessage(full, brief) - self.utilities.updateCachedTextSelection(event.source) - return - - string = self.utilities.deletedText(event) - if self.utilities.isDeleteCommandTextDeletionEvent(event): + text = self.utilities.deleted_text(event) + selected_text, _start, _end = AXUtilities.get_cached_selected_text(event.source) + if reason == TextEventReason.DELETE: msg = "DEFAULT: Deletion is believed to be due to Delete command" - debug.printMessage(debug.LEVEL_INFO, msg, True) - string = self.utilities.getCharacterAtOffset(event.source) - elif self.utilities.isBackSpaceCommandTextDeletionEvent(event): + debug.print_message(debug.LEVEL_INFO, msg, True) + text = AXText.get_character_at_offset(event.source)[0] + elif reason == TextEventReason.BACKSPACE and text != selected_text: msg = "DEFAULT: Deletion is believed to be due to BackSpace command" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) else: msg = "DEFAULT: Event is not being presented due to lack of cause" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - if len(string) == 1: - self.speakCharacter(string) + if len(text) == 1: + presentation_manager.get_manager().speak_character(text) else: - voice = self.speechGenerator.voice(string=string) - string = self.utilities.adjustForRepeats(string) - self.speakMessage(string, voice) + presentation_manager.get_manager().speak_accessible_text(event.source, text) - def onTextInserted(self, event): + return True + + def _on_text_inserted(self, event: Atspi.Event) -> bool: """Callback for object:text-changed:insert accessibility events.""" - if not self.utilities.isPresentableTextChangedEventForLocusOfFocus(event): - return + if not AXUtilities.is_presentable_text_insertion(event): + return True - self.utilities.handleUndoTextEvent(event) + reason = AXUtilities.get_text_event_reason(event) + presentation_manager.get_manager().present_command_announcement() + self.update_braille(event.source) - if event.source == cthulhu_state.locusOfFocus and self.utilities.isAutoTextEvent(event): - self._saveFocusedObjectInfo(event.source) - cthulhu.setLocusOfFocus(event, event.source, False) - self.updateBraille(event.source) - - full, brief = "", "" - if self.utilities.isClipboardTextChangedEvent(event): - msg = "DEFAULT: Insertion is believed to be due to clipboard paste" - debug.printMessage(debug.LEVEL_INFO, msg, True) - full, brief = messages.CLIPBOARD_PASTED_FULL, messages.CLIPBOARD_PASTED_BRIEF - elif self.utilities.isSelectedTextRestoredEvent(event): + if reason == TextEventReason.SELECTED_TEXT_RESTORATION: msg = "DEFAULT: Insertion is believed to be due to restoring selected text" - debug.printMessage(debug.LEVEL_INFO, msg, True) - full = messages.SELECTION_RESTORED + debug.print_message(debug.LEVEL_INFO, msg, True) + presentation_manager.get_manager().present_message(messages.SELECTION_RESTORED) + AXUtilities.update_cached_selected_text(event.source) + return True - if full or brief: - self.presentMessage(full, brief) - self.utilities.updateCachedTextSelection(event.source) - return + if reason == TextEventReason.LIVE_REGION_UPDATE: + msg = "DEFAULT: Event is from live region" + debug.print_message(debug.LEVEL_INFO, msg, True) + live_region_presenter.get_presenter().handle_event(self, event) + return True - speakString = True - - # Because some implementations are broken. - string = self.utilities.insertedText(event) - - if self.utilities.lastInputEventWasPageSwitch(): - msg = "DEFAULT: Insertion is believed to be due to page switch" - debug.printMessage(debug.LEVEL_INFO, msg, True) - speakString = False - elif self.utilities.lastInputEventWasCommand(): - msg = "DEFAULT: Insertion is believed to be due to command" - debug.printMessage(debug.LEVEL_INFO, msg, True) - elif self.utilities.isMiddleMouseButtonTextInsertionEvent(event): - msg = "DEFAULT: Insertion is believed to be due to middle mouse button" - debug.printMessage(debug.LEVEL_INFO, msg, True) - elif self.utilities.isEchoableTextInsertionEvent(event): - msg = "DEFAULT: Insertion is believed to be echoable" - debug.printMessage(debug.LEVEL_INFO, msg, True) - elif self.utilities.isAutoTextEvent(event): - msg = "DEFAULT: Insertion is believed to be auto text event" - debug.printMessage(debug.LEVEL_INFO, msg, True) - elif self.utilities.isSelectedTextInsertionEvent(event): - msg = "DEFAULT: Insertion is also selected" - debug.printMessage(debug.LEVEL_INFO, msg, True) + reason_messages: dict[TextEventReason, str] = { + TextEventReason.PAGE_SWITCH: "due to page switch", + TextEventReason.PASTE: "due to paste", + TextEventReason.UNSPECIFIED_COMMAND: "due to command", + TextEventReason.MOUSE_MIDDLE_BUTTON: "due to middle mouse button", + TextEventReason.TYPING_ECHOABLE: "echoable", + TextEventReason.AUTO_INSERTION_PRESENTABLE: "presentable auto text event", + TextEventReason.SELECTED_TEXT_INSERTION: "also selected", + } + silent_reasons = {TextEventReason.PAGE_SWITCH, TextEventReason.PASTE} + description = reason_messages.get(reason) + if description is not None: + msg = f"DEFAULT: Insertion is believed to be {description}" + debug.print_message(debug.LEVEL_INFO, msg, True) + speak_string = reason not in silent_reasons else: msg = "DEFAULT: Not speaking inserted string due to lack of cause" - debug.printMessage(debug.LEVEL_INFO, msg, True) - speakString = False + debug.print_message(debug.LEVEL_INFO, msg, True) + speak_string = False - if speakString: - if len(string) == 1: - self.speakCharacter(string) + # Because some implementations are broken. + text = self.utilities.inserted_text(event) + if speak_string: + if len(text) == 1: + presentation_manager.get_manager().speak_character(text) else: - voice = self.speechGenerator.voice(obj=event.source, string=string) - string = self.utilities.adjustForRepeats(string) - self.speakMessage(string, voice) + presentation_manager.get_manager().speak_accessible_text(event.source, text) - if len(string) != 1: - return + if len(text) != 1 or reason not in [ + TextEventReason.TYPING, + TextEventReason.TYPING_ECHOABLE, + ]: + return True - if not cthulhu.cthulhuApp.settingsManager.getSetting('enableKeyEcho'): - return + if AXText.get_selected_ranges(event.source): + return True - if cthulhu.cthulhuApp.settingsManager.getSetting('enableEchoBySentence') \ - and self.echoPreviousSentence(event.source): - return + presenter = typing_echo_presenter.get_presenter() + if presenter.echo_previous_sentence(event.source): + return True - if cthulhu.cthulhuApp.settingsManager.getSetting('enableEchoByWord'): - self.echoPreviousWord(event.source) + presenter.echo_previous_word(event.source) + return True - def onTextSelectionChanged(self, event): + def _on_text_selection_changed(self, event: Atspi.Event) -> bool: """Callback for object:text-selection-changed accessibility events.""" - obj = event.source + if AXUtilities.is_focusable(event.source) and not AXUtilities.is_focused(event.source): + msg = "DEFAULT: Ignoring event from focusable but unfocused source" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True # We won't handle undo here as it can lead to double-presentation. # If there is an application for which text-changed events are # missing upon undo, handle them in an app or toolkit script. - self.utilities.handleTextSelectionChange(obj) - self.updateBraille(obj) + reason = AXUtilities.get_text_event_reason(event) + if reason == TextEventReason.UNKNOWN: + msg = "DEFAULT: Ignoring event because reason for change is unknown" + debug.print_message(debug.LEVEL_INFO, msg, True) + AXUtilities.update_cached_selected_text(event.source) + return True + if reason == TextEventReason.SEARCH_PRESENTABLE: + msg = "DEFAULT: Presenting line believed to be search match" + debug.print_message(debug.LEVEL_INFO, msg, True) + self.say_line(event.source) + AXUtilities.update_cached_selected_text(event.source) + return True + if reason == TextEventReason.SEARCH_UNPRESENTABLE: + msg = "DEFAULT: Ignoring event believed to be unpresentable search results change" + debug.print_message(debug.LEVEL_INFO, msg, True) + AXUtilities.update_cached_selected_text(event.source) + return True + if reason in [TextEventReason.CUT, TextEventReason.BACKSPACE, TextEventReason.DELETE]: + msg = "DEFAULT: Ignoring event believed to be text removal" + debug.print_message(debug.LEVEL_INFO, msg, True) + AXUtilities.update_cached_selected_text(event.source) + return True + if reason == TextEventReason.AUTO_SELECTION: + msg = "DEFAULT: Ignoring auto-selection believed to be side effect of typing" + debug.print_message(debug.LEVEL_INFO, msg, True) + AXUtilities.update_cached_selected_text(event.source) + return True + if reason == TextEventReason.AUTO_UNSELECTION: + msg = "DEFAULT: Ignoring auto-unselection believed to be side effect of typing" + debug.print_message(debug.LEVEL_INFO, msg, True) + AXUtilities.update_cached_selected_text(event.source) + return True - def onColumnReordered(self, event): + self.utilities.handle_text_selection_change(event.source) + self.update_braille(event.source) + return True + + def _on_column_reordered(self, event: Atspi.Event) -> bool: """Callback for object:column-reordered accessibility events.""" - if not self.utilities.lastInputEventWasTableSort(): - return + AXUtilities.clear_all_cache_now(event.source, "column-reordered event.") + if not input_event_manager.get_manager().last_event_was_table_sort(): + return True - if event.source != self.utilities.getTable(cthulhu_state.locusOfFocus): - return + if event.source != AXUtilities.get_table(focus_manager.get_manager().get_locus_of_focus()): + return True - self.pointOfReference['last-table-sort-time'] = time.time() - self.presentMessage(messages.TABLE_REORDERED_COLUMNS) + presentation_manager.get_manager().present_message(messages.TABLE_REORDERED_COLUMNS) + return True - def onRowReordered(self, event): + def _on_row_reordered(self, event: Atspi.Event) -> bool: """Callback for object:row-reordered accessibility events.""" - if not self.utilities.lastInputEventWasTableSort(): - return + AXUtilities.clear_all_cache_now(event.source, "row-reordered event.") + if not input_event_manager.get_manager().last_event_was_table_sort(): + return True - if event.source != self.utilities.getTable(cthulhu_state.locusOfFocus): - return + if event.source != AXUtilities.get_table(focus_manager.get_manager().get_locus_of_focus()): + return True - self.pointOfReference['last-table-sort-time'] = time.time() - self.presentMessage(messages.TABLE_REORDERED_ROWS) + presentation_manager.get_manager().present_message(messages.TABLE_REORDERED_ROWS) + return True - def onValueChanged(self, event): - """Called whenever an object's value changes. Currently, the - value changes for non-focused objects are ignored. + def _on_value_changed(self, event: Atspi.Event) -> bool: + """Callback for object:property-change:accessible-value accessibility events.""" - Arguments: - - event: the Event - """ + if not AXUtilities.is_presentable_value_change(event): + return True - obj = event.source - role = AXObject.get_role(obj) + manager = focus_manager.get_manager() + if ( + not AXUtilities.is_progress_bar(event.source) + and event.source != manager.get_locus_of_focus() + ): + msg = "DEFAULT: Source != locusOfFocus" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - if not AXObject.supports_value(obj): - tokens = ["DEFAULT:", obj, "doesn't implement AtspiValue"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return + if AXUtilities.is_spin_button(event.source): + manager.set_last_cursor_position(event.source, AXText.get_caret_offset(event.source)) - currentValue = AXValue.get_current_value(obj) + if not AXUtilities.is_progress_bar(event.source): + presentation_manager.get_manager().interrupt_presentation() - if "oldValue" in self.pointOfReference \ - and (currentValue == self.pointOfReference["oldValue"]): - return + presentation_manager.get_manager().present_object( + self, + event.source, + generate_sound=True, + alreadyFocused=True, + isProgressBarUpdate=AXUtilities.is_progress_bar(event.source), + ) + return True - isProgressBarUpdate, msg = self.utilities.isProgressBarUpdate(obj) - tokens = ["DEFAULT: Is progress bar update:", isProgressBarUpdate, ",", msg] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + def _on_window_activated(self, event: Atspi.Event) -> bool: + """Callback for window:activate accessibility events.""" - if not isProgressBarUpdate and obj != cthulhu_state.locusOfFocus: - tokens = ["DEFAULT: Source != locusOfFocus (", cthulhu_state.locusOfFocus, ")"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return + if not AXUtilities.can_be_active_window(event.source): + return True - if role == Atspi.Role.SPIN_BUTTON: - self._saveFocusedObjectInfo(event.source) - - self.pointOfReference["oldValue"] = currentValue - self.updateBraille(obj, isProgressBarUpdate=isProgressBarUpdate) - speech.speak(self.speechGenerator.generateSpeech( - obj, alreadyFocused=True, isProgressBarUpdate=isProgressBarUpdate)) - self.__play(self.soundGenerator.generateSound( - obj, alreadyFocused=True, isProgressBarUpdate=isProgressBarUpdate)) - - def onWindowActivated(self, event): - """Called whenever a toplevel window is activated. - - Arguments: - - event: the Event - """ - - window = AXObject.find_real_app_and_window_for(event.source)[1] - if not self.utilities.canBeActiveWindow(window, False): - return - - if self.utilities.isSameObject(window, cthulhu_state.activeWindow): + manager = focus_manager.get_manager() + active_window = manager.get_active_window() + if event.source == active_window: msg = "DEFAULT: Event is for active window." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + debug.print_message(debug.LEVEL_INFO, msg, True) + spellcheck_presenter.get_presenter().handle_window_event(event, self) + return True - self.pointOfReference = {} + manager.set_active_window(event.source) + if AXUtilities.is_combo_box_popup(active_window): + return True - cthulhu.setActiveWindow(window) + if AXObject.get_child_count(event.source) == 1: + child = AXObject.get_child(event.source, 0) + # Popup menus in Chromium live in a menu bar whose first child is a panel. + if AXUtilities.is_menu_bar(child): + child = AXUtilities.get_menu(child) + if AXUtilities.is_menu(child): + selected_item = AXSelection.get_selected_child(child, 0) + if AXUtilities.is_selected(selected_item): + child = selected_item + manager.set_locus_of_focus(event, child) + return True - app = AXObject.get_application(window) - appName = (AXObject.get_name(app) or "").lower() - if appName == "cthulhu": - msg = "DEFAULT: Self-hosted window activated. Waiting for focused child event." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + if not spellcheck_presenter.get_presenter().handle_window_event(event, self): + manager.set_locus_of_focus(event, event.source) + return True - if self.utilities.isKeyGrabEvent(event): - msg = "DEFAULT: Ignoring event. Likely from key grab." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + def _on_window_deactivated(self, event: Atspi.Event) -> bool: + """Callback for window:deactivate accessibility events.""" - if AXObject.get_child_count(window) == 1: - child = AXObject.get_child(window, 0) - if AXObject.get_role(child) == Atspi.Role.MENU: - cthulhu.setLocusOfFocus(event, child) - return - - cthulhu.setLocusOfFocus(event, cthulhu_state.activeWindow) - - def onWindowCreated(self, event): - """Callback for window:create accessibility events.""" - - pass - - def onWindowDestroyed(self, event): - """Callback for window:destroy accessibility events.""" - - pass - - def onWindowDeactivated(self, event): - """Called whenever a toplevel window is deactivated. - - Arguments: - - event: the Event - """ - - if self.utilities.inMenu(): + manager = focus_manager.get_manager() + focus = manager.get_locus_of_focus() + if AXUtilities.find_ancestor_inclusive(focus, AXUtilities.is_menu): msg = "DEFAULT: Ignoring event. In menu." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - if event.source != cthulhu_state.activeWindow: - tokens = ["DEFAULT: Ignoring event. Not for active window", - cthulhu_state.activeWindow, "."] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return + if event.source != manager.get_active_window(): + msg = "DEFAULT: Ignoring event. Not for active window" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - if self.utilities.isKeyGrabEvent(event): - msg = "DEFAULT: Ignoring event. Likely from key grab." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + if AXUtilities.is_combo_box_popup(AXUtilities.find_active_window()): + msg = "DEFAULT: Ignoring event. Combo box popup is the new active window." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - # Some toolkits emit transient window:deactivate/window:activate pairs - # while the same window remains active. Treat those as noise so we do - # not clear state and force a full script/settings reactivation. - AXObject.clear_cache(event.source) - if AXUtilities.is_active(event.source): - msg = "DEFAULT: Ignoring event. Source window still active." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + if AXUtilities.is_combo_box_popup(event.source): + msg = "DEFAULT: Ignoring event. Combo box popup." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - if self.flatReviewPresenter.is_active(): - self.flatReviewPresenter.quit() + if flat_review_presenter.get_presenter().is_active(): + flat_review_presenter.get_presenter().quit() - if self.learnModePresenter.is_active(): - self.learnModePresenter.quit() + if learn_mode_presenter.get_presenter().is_active(): + learn_mode_presenter.get_presenter().quit() - self.pointOfReference = {} - - if not self.utilities.eventIsUserTriggered(event): - msg = "DEFAULT: Not clearing state. Event is not user triggered." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - msg = "DEFAULT: Clearing state." - debug.printMessage(debug.LEVEL_INFO, msg, True) - - cthulhu.setLocusOfFocus(event, None) - cthulhu.setActiveWindow(None) - cthulhu.cthulhuApp.scriptManager.set_active_script(None, "focus: window-deactivated") - - def onClipboardContentsChanged(self, *args): - pending = self._pendingKeyboardClipboardCommand - currentContents = self.utilities.getClipboardContents() - tokens = [ - "DEFAULT: Clipboard callback snapshot", - self._describeClipboardContents(currentContents), - ] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - if pending is not None: - pending["callbackSeen"] = True - if pending.get("presented"): - self._pendingKeyboardClipboardCommand = None - return - - if self.flatReviewPresenter.is_active(): - return - - if pending is not None: - clipboardMatchesSource = self._clipboardMatchesPendingKeyboardCommand( - pending, currentContents - ) - else: - clipboardMatchesSource = False - - if not clipboardMatchesSource and not self.utilities.objectContentsAreInClipboard(): - return - - if not self.utilities.topLevelObjectIsActiveAndCurrent(): - return - - if self.utilities.lastInputEventWasCopy(): - self.presentMessage(messages.CLIPBOARD_COPIED_FULL, messages.CLIPBOARD_COPIED_BRIEF) - if self._pendingKeyboardClipboardCommand is pending: - self._pendingKeyboardClipboardCommand = None - return - - if not self.utilities.lastInputEventWasCut(): - return - - if AXUtilities.is_editable(cthulhu_state.locusOfFocus): - return - - self.presentMessage(messages.CLIPBOARD_CUT_FULL, messages.CLIPBOARD_CUT_BRIEF) - if self._pendingKeyboardClipboardCommand is pending: - self._pendingKeyboardClipboardCommand = None + focus_manager.get_manager().clear_state("Window deactivated") + script_manager.get_manager().set_active_script(None, "Window deactivated") + return True ######################################################################## # # @@ -2460,1460 +1451,192 @@ class Script(script.Script): # # ######################################################################## - def _presentTextAtNewCaretPosition(self, event, otherObj=None): - obj = otherObj or event.source - self.updateBrailleForNewCaretPosition(obj) - if self._inSayAll: - msg = "DEFAULT: Not presenting text because SayAll is active" - debug.printMessage(debug.LEVEL_INFO, msg, True) + def _update_braille_caret_position(self, obj: Atspi.Accessible) -> None: + """Try to reposition the cursor without having to do a full update.""" + + if not braille_presenter.get_presenter().use_braille(): return - if self.utilities.lastInputEventWasLineNav(): - msg = "DEFAULT: Presenting result of line nav" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.sayLine(obj) + if braille.try_reposition_cursor(obj): return - if self.utilities.lastInputEventWasWordNav(): - msg = "DEFAULT: Presenting result of word nav" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.sayWord(obj) - return + self.update_braille(obj) - if self.utilities.lastInputEventWasCharNav(): - msg = "DEFAULT: Presenting result of char nav" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.sayCharacter(obj) - return + def _present_caret_moved_event( + self, + event: Atspi.Event, + obj: Atspi.Accessible | None = None, + reason: TextEventReason = TextEventReason.UNKNOWN, + ) -> bool: + """Presents text at the new position, based on heuristics. Returns True if handled.""" - if self.utilities.lastInputEventWasPageNav(): - msg = "DEFAULT: Presenting result of page nav" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.sayLine(obj) - return + obj = obj or event.source + self._update_braille_caret_position(obj) - if self.utilities.lastInputEventWasLineBoundaryNav(): - msg = "DEFAULT: Presenting result of line boundary nav" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.sayCharacter(obj) - return - - if self.utilities.lastInputEventWasFileBoundaryNav(): - msg = "DEFAULT: Presenting result of file boundary nav" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.sayLine(obj) - return - - if self.utilities.lastInputEventWasPrimaryMouseClick() \ - or self.utilities.lastInputEventWasPrimaryMouseRelease(): - start, end, string = self.utilities.getCachedTextSelection(event.source) - if not string: - msg = "DEFAULT: Presenting result of primary mouse button event" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.sayLine(obj) - return - - def _rewindSayAll(self, context, minCharCount=10): - if not cthulhu.cthulhuApp.settingsManager.getSetting('rewindAndFastForwardInSayAll'): - return False - - index = self._sayAllContexts.index(context) - self._sayAllContexts = self._sayAllContexts[0:index] - while self._sayAllContexts: - context = self._sayAllContexts.pop() - if context.endOffset - context.startOffset > minCharCount: - break - - if AXObject.supports_text(context.obj): - cthulhu.setLocusOfFocus(None, context.obj, notifyScript=False) - AXText.set_caret_offset(context.obj, context.startOffset) - - self.sayAll(None, context.obj, context.startOffset) - return True - - def _fastForwardSayAll(self, context): - if not cthulhu.cthulhuApp.settingsManager.getSetting('rewindAndFastForwardInSayAll'): - return False - - if AXObject.supports_text(context.obj): - cthulhu.setLocusOfFocus(None, context.obj, notifyScript=False) - AXText.set_caret_offset(context.obj, context.endOffset) - - self.sayAll(None, context.obj, context.endOffset) - return True - - def __sayAllProgressCallback(self, context, progressType): - # [[[TODO: WDW - this needs work. Need to be able to manage - # the monitoring of progress and couple that with both updating - # the visual progress of what is being spoken as well as - # positioning the cursor when speech has stopped.]]] - # - if not AXObject.supports_text(context.obj): - return - char = AXText.get_substring(context.obj, context.currentOffset, context.currentOffset + 1) - - # Setting the caret at the offset of an embedded object results in - # focus changes. - if char == self.EMBEDDED_OBJECT_CHARACTER: - return - - if progressType == speechserver.SayAllContext.PROGRESS: - cthulhu.emitRegionChanged( - context.obj, context.currentOffset, context.currentEndOffset, cthulhu.SAY_ALL) - return - - if progressType == speechserver.SayAllContext.INTERRUPTED: - if isinstance(cthulhu_state.lastInputEvent, input_event.KeyboardEvent): - self._sayAllIsInterrupted = True - lastKey = cthulhu_state.lastInputEvent.event_string - if lastKey == "Down" and self._fastForwardSayAll(context): - return - elif lastKey == "Up" and self._rewindSayAll(context): - return - - self._inSayAll = False - self._sayAllContexts = [] - cthulhu.emitRegionChanged(context.obj, context.currentOffset) - AXText.set_caret_offset(context.obj, context.currentOffset) - elif progressType == speechserver.SayAllContext.COMPLETED: - cthulhu.setLocusOfFocus(None, context.obj, notifyScript=False) - cthulhu.emitRegionChanged(context.obj, context.currentOffset, mode=cthulhu.SAY_ALL) - AXText.set_caret_offset(context.obj, context.currentOffset) - - # If there is a selection, clear it. See bug #489504 for more details. - # - if self.utilities.allTextSelections(context.obj): - self.utilities.clearTextSelection(context.obj) - - def inSayAll(self, treatInterruptedAsIn=True): - if self._inSayAll: - msg = "DEFAULT: In SayAll" - debug.printMessage(debug.LEVEL_INFO, msg, True) + # TODO - JD: Make this a TextEventReason. Also handle structural navigation + # and table navigation here. Technically that's not been necessary because + # it won't match anything below. But it would be cleaner to cover each cause + # explicitly. + if caret_navigator.get_navigator().last_input_event_was_navigation_command(): + msg = "DEFAULT: Event ignored: Last command was caret nav" + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self._sayAllIsInterrupted: - msg = "DEFAULT: SayAll is interrupted" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return treatInterruptedAsIn + if reason == TextEventReason.SAY_ALL: + msg = "DEFAULT: Not presenting text because SayAll is active" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - msg = "DEFAULT: Not in SayAll" - debug.printMessage(debug.LEVEL_INFO, msg, True) + navigation_handlers: dict[TextEventReason, Callable[[Atspi.Accessible], None]] = { + TextEventReason.NAVIGATION_BY_LINE: self.say_line, + TextEventReason.NAVIGATION_BY_WORD: self.say_word, + TextEventReason.NAVIGATION_BY_CHARACTER: self.say_character, + TextEventReason.NAVIGATION_BY_PAGE: self.say_line, + TextEventReason.NAVIGATION_TO_LINE_BOUNDARY: self.say_character, + TextEventReason.NAVIGATION_TO_FILE_BOUNDARY: self.say_line, + } + handler = navigation_handlers.get(reason) + if handler is not None: + handler(obj) + return True + + if reason == TextEventReason.MOUSE_PRIMARY_BUTTON: + text, _start, _end = AXUtilities.get_cached_selected_text(event.source) + if not text: + self.say_line(obj) + return True return False - def echoPreviousSentence(self, obj): - """Speaks the sentence prior to the caret, as long as there is - a sentence prior to the caret and there is no intervening sentence - delimiter between the caret and the end of the sentence. + def say_character(self, obj: Atspi.Accessible) -> None: + """Speak the character at the caret.""" - The entry condition for this method is that the character - prior to the current caret position is a sentence delimiter, - and it's what caused this method to be called in the first - place. - - Arguments: - - obj: an Accessible object that implements the AccessibleText - interface. - """ - - if not AXObject.supports_text(obj): - return False - - caretOffset = AXText.get_caret_offset(obj) - offset = caretOffset - 1 - previousOffset = caretOffset - 2 - if (offset < 0 or previousOffset < 0): - return False - - currentChar, _, _ = AXText.get_character_at_offset(obj, offset) - previousChar, _, _ = AXText.get_character_at_offset(obj, previousOffset) - if not self.utilities.isSentenceDelimiter(currentChar, previousChar): - return False - - # OK - we seem to be cool so far. So...starting with what - # should be the last character in the sentence (caretOffset - 2), - # work our way to the beginning of the sentence, stopping when - # we hit another sentence delimiter. - # - sentenceEndOffset = caretOffset - 2 - sentenceStartOffset = sentenceEndOffset - - while sentenceStartOffset >= 0: - currentChar, _, _ = AXText.get_character_at_offset(obj, sentenceStartOffset) - if sentenceStartOffset - 1 >= 0: - previousChar, _, _ = AXText.get_character_at_offset(obj, sentenceStartOffset - 1) - else: - previousChar = "" - if self.utilities.isSentenceDelimiter(currentChar, previousChar): - break - else: - sentenceStartOffset -= 1 - - # If we came across a sentence delimiter before hitting any - # text, we really don't have a previous sentence. - # - # Otherwise, get the sentence. Remember we stopped when we - # hit a sentence delimiter, so the sentence really starts at - # sentenceStartOffset + 1. getText also does not include - # the character at sentenceEndOffset, so we need to adjust - # for that, too. - # - if sentenceStartOffset == sentenceEndOffset: - return False + context_obj, context_offset = self.utilities.get_caret_context(obj) + if context_obj == obj: + offset = context_offset else: - sentence = self.utilities.substring(obj, sentenceStartOffset + 1, - sentenceEndOffset + 1) + offset = AXText.get_caret_offset(obj) - sentence = self.utilities.adjustForRepeats(sentence) - if self._shouldUseCustomEchoVoice("sentence"): - speech.speakEchoText(sentence, self._getCustomEchoVoice()) - else: - voice = self.speechGenerator.voice(obj=obj, string=sentence) - self.speakMessage(sentence, voice) - return True - - def echoPreviousWord(self, obj, offset=None): - """Speaks the word prior to the caret, as long as there is - a word prior to the caret and there is no intervening word - delimiter between the caret and the end of the word. - - The entry condition for this method is that the character - prior to the current caret position is a word delimiter, - and it's what caused this method to be called in the first - place. - - Arguments: - - obj: an Accessible object that implements the AccessibleText - interface. - - offset: if not None, the offset within the text to use as the - end of the word. - """ - - if not AXObject.supports_text(obj): - return False - - if not offset: - caretOffset = AXText.get_caret_offset(obj) - if caretOffset == -1: - offset = AXText.get_character_count(obj) - else: - offset = caretOffset - 1 - - if (offset < 0): - return False - - char, _, _ = AXText.get_character_at_offset(obj, offset) - if not self.utilities.isWordDelimiter(char): - return False - - # OK - we seem to be cool so far. So...starting with what - # should be the last character in the word (caretOffset - 2), - # work our way to the beginning of the word, stopping when - # we hit another word delimiter. - # - wordEndOffset = offset - 1 - wordStartOffset = wordEndOffset - - while wordStartOffset >= 0: - char, _, _ = AXText.get_character_at_offset(obj, wordStartOffset) - if self.utilities.isWordDelimiter(char): - break - else: - wordStartOffset -= 1 - - # If we came across a word delimiter before hitting any - # text, we really don't have a previous word. - # - # Otherwise, get the word. Remember we stopped when we - # hit a word delimiter, so the word really starts at - # wordStartOffset + 1. getText also does not include - # the character at wordEndOffset, so we need to adjust - # for that, too. - # - if wordStartOffset == wordEndOffset: - return False - else: - word = self.utilities.\ - substring(obj, wordStartOffset + 1, wordEndOffset + 1) - - word = self.utilities.adjustForRepeats(word) - if self._shouldUseCustomEchoVoice("word"): - speech.speakEchoText(word, self._getCustomEchoVoice()) - else: - voice = self.speechGenerator.voice(obj=obj, string=word) - self.speakMessage(word, voice) - return True - - def sayCharacter(self, obj): - """Speak the character at the caret. - - Arguments: - - obj: an Accessible object that implements the AccessibleText - interface - """ - - if not AXObject.supports_text(obj): - return - - offset = AXText.get_caret_offset(obj) - - # If we have selected text and the last event was a move to the - # right, then speak the character to the left of where the text - # caret is (i.e. the selected character). - # - eventString, mods = self.utilities.lastKeyAndModifiers() - if (mods & keybindings.SHIFT_MODIFIER_MASK) \ - and eventString in ["Right", "Down"]: + if input_event_manager.get_manager().last_event_was_forward_caret_selection(): offset -= 1 - character, startOffset, endOffset = AXText.get_character_at_offset(obj, offset) - cthulhu.emitRegionChanged(obj, startOffset, endOffset, cthulhu.CARET_TRACKING) + character, start_offset, end_offset = AXText.get_character_at_offset(obj, offset) + focus_manager.get_manager().emit_region_changed( + obj, + start_offset, + end_offset, + focus_manager.CARET_TRACKING, + ) - if not character or character == '\r': - character = "\n" + presentation_manager.get_manager().speak_character_at_offset(obj, offset, character) + AXUtilities.set_last_text_unit_spoken(TextUnit.CHAR) - speakBlankLines = cthulhu.cthulhuApp.settingsManager.getSetting('speakBlankLines') - if character == "\n": - line, _, _ = AXText.get_line_at_offset(obj, max(0, offset)) - if not line or line == "\n": - # This is a blank line. Announce it if the user requested - # that blank lines be spoken. - if speakBlankLines: - self.speakMessage(messages.BLANK, interrupt=False) - return + def say_line(self, obj: Atspi.Accessible, offset: int | None = None) -> None: + """Speaks the line at the current or specified offset.""" - if character in ["\n", "\r\n"]: - # This is a blank line. Announce it if the user requested - # that blank lines be spoken. - if speakBlankLines: - self.speakMessage(messages.BLANK, interrupt=False) - return + if offset is None: + offset = AXText.get_caret_offset(obj) else: - self.speakMisspelledIndicator(obj, offset) - self.speakCharacter(character) + AXText.set_caret_offset(obj, offset) - self.pointOfReference["lastTextUnitSpoken"] = "char" + line, start_offset = AXText.get_line_at_offset(obj, offset)[0:2] + if line and line != "\n": + end_offset = start_offset + len(line) + focus_manager.get_manager().emit_region_changed( + obj, + start_offset, + end_offset, + focus_manager.CARET_TRACKING, + ) - def sayLine(self, obj): - """Speaks the line of an AccessibleText object that contains the - caret, unless the line is empty in which case it's ignored. + speech_presenter.get_presenter().speak_line( + self, + obj, + start_offset, + start_offset + len(line), + line, + ) - Arguments: - - obj: an Accessible object that implements the AccessibleText - interface - """ + AXUtilities.set_last_text_unit_spoken(TextUnit.LINE) - [line, caretOffset, startOffset] = self.getTextLineAtCaret(obj) - if len(line) and line != "\n": - indentationDescription, hasIndentation = \ - self.utilities.get_indentation_presentation(line, obj=obj) - if indentationDescription: - self.speakMessage(indentationDescription) - stripIndentation = hasIndentation and self.utilities.should_strip_indentation(line) + def say_phrase(self, obj: Atspi.Accessible, start_offset: int, end_offset: int) -> None: + """Speaks the substring between start and end offset.""" - endOffset = startOffset + len(line) - cthulhu.emitRegionChanged(obj, startOffset, endOffset, cthulhu.CARET_TRACKING) - - utterance = [] - split = self.utilities.splitSubstringByLanguage(obj, startOffset, endOffset) - for start, end, string, language, dialect in split: - if not string: - continue - - voice = self.speechGenerator.voice( - obj=obj, string=string, language=language, dialect=dialect) - string, linkIcons = self.utilities.getLinkIndicatorPresentation(obj, string, start) - string = self.utilities.adjustForRepeats(string) - if self.utilities.shouldVerbalizeAllPunctuation(obj): - string = self.utilities.verbalizeAllPunctuation(string) - - # Some synthesizers will verbalize the whitespace, so if we've already - # described it, prevent double-presentation by stripping it off. - if not utterance and stripIndentation: - string = string.lstrip() - - result = [string] - result.extend(voice) - result.extend(linkIcons) - utterance.append(result) - speech.speak(utterance) - else: - # Speak blank line if appropriate. - # - self.sayCharacter(obj) - - self.pointOfReference["lastTextUnitSpoken"] = "line" - - def sayPhrase(self, obj, startOffset, endOffset): - """Speaks the text of an Accessible object between the start and - end offsets, unless the phrase is empty in which case it's ignored. - - Arguments: - - obj: an Accessible object that implements the AccessibleText - interface - - startOffset: the start text offset. - - endOffset: the end text offset. - """ - - phrase = self.utilities.expandEOCs(obj, startOffset, endOffset) + phrase = self.utilities.expand_eocs(obj, start_offset, end_offset) if not phrase: return if len(phrase) > 1 or phrase.isalnum(): - indentationDescription, hasIndentation = \ - self.utilities.get_indentation_presentation(phrase, obj=obj) - if indentationDescription: - self.speakMessage(indentationDescription) + focus_manager.get_manager().emit_region_changed( + obj, + start_offset, + end_offset, + focus_manager.CARET_TRACKING, + ) - cthulhu.emitRegionChanged(obj, startOffset, endOffset, cthulhu.CARET_TRACKING) + speech_presenter.get_presenter().speak_phrase(self, obj, start_offset, end_offset, phrase) + AXUtilities.set_last_text_unit_spoken(TextUnit.PHRASE) - voice = self.speechGenerator.voice(obj=obj, string=phrase) - if hasIndentation and self.utilities.should_strip_indentation(phrase): - phrase = phrase.lstrip() - phrase = self.utilities.adjustForRepeats(phrase) - if self.utilities.shouldVerbalizeAllPunctuation(obj): - phrase = self.utilities.verbalizeAllPunctuation(phrase) - - utterance = [phrase] - utterance.extend(voice) - speech.speak(utterance) - else: - self.speakCharacter(phrase) - - self.pointOfReference["lastTextUnitSpoken"] = "phrase" - - def sayWord(self, obj): + def say_word(self, obj: Atspi.Accessible) -> None: """Speaks the word at the caret, taking into account the previous caret position.""" - if not AXObject.supports_text(obj): - self.sayCharacter(obj) - return + context_obj, context_offset = self.utilities.get_caret_context(obj) + if context_obj == obj: + offset = context_offset + else: + offset = AXText.get_caret_offset(obj) - offset = AXText.get_caret_offset(obj) + word, start_offset, end_offset = self.utilities.get_word_at_offset_adjusted_for_navigation( + obj, + offset, + ) - word, startOffset, endOffset = \ - self.utilities.getWordAtOffsetAdjustedForNavigation(obj, offset) - - # Announce when we cross a hard line boundary. + speech_pres = speech_presenter.get_presenter() if "\n" in word: - if cthulhu.cthulhuApp.settingsManager.getSetting('enableSpeechIndentation'): - self.speakCharacter("\n") + # Announce when we cross a hard line boundary, based on whether or not indentation and + # justification should be spoken. This was done to avoid yet another setting in + # response to some users saying this announcement was too chatty. The idea of using + # this setting for the decision is that if the user wants indentation and justification + # announced, they are interested in explicit whitespace information. + if speech_pres.get_speak_indentation_and_justification(): + presentation_manager.get_manager().speak_character("\n") if word.startswith("\n"): - startOffset += 1 + start_offset += 1 elif word.endswith("\n"): - endOffset -= 1 - word = AXText.get_substring(obj, startOffset, endOffset) + end_offset -= 1 + word = AXText.get_substring(obj, start_offset, end_offset) - # sayPhrase is useful because it handles punctuation verbalization, but we don't want + # say_phrase is useful because it handles punctuation verbalization, but we don't want # to trigger its whitespace presentation. matches = list(re.finditer(r"\S+", word)) if matches: - startOffset += matches[0].start() - endOffset -= len(word) - matches[-1].end() - word = AXText.get_substring(obj, startOffset, endOffset) + start_offset += matches[0].start() + end_offset -= len(word) - matches[-1].end() + word = AXText.get_substring(obj, start_offset, end_offset) - string = word.replace("\n", "\\n") - msg = ( - f"DEFAULT: Final word at offset {offset} is '{string}' " - f"({startOffset}-{endOffset})" + text = word.replace("\n", "\\n") + msg = f"DEFAULT: Final word at offset {offset} is '{text}' ({start_offset}-{end_offset})" + debug.print_message(debug.LEVEL_INFO, msg, True) + + if error := speech_presenter.get_presenter().get_error_description(obj, start_offset): + presentation_manager.get_manager().speak_message(error) + + self.say_phrase(obj, start_offset, end_offset) + AXUtilities.set_last_text_unit_spoken(TextUnit.WORD) + + def present_object(self, obj: Atspi.Accessible, **args) -> None: + """Presents the current object.""" + + tokens = ["DEFAULT: Presenting object", obj, ". Interrupt:", args.get("interrupt", False)] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + offset = args.get("offset") + if offset is not None: + AXText.set_caret_offset(obj, offset) + + speech_only = args.pop("speechonly", False) + presentation_manager.get_manager().present_object( + self, + obj, + generate_braille=not speech_only, + **args, ) - debug.printMessage(debug.LEVEL_INFO, msg, True) - - self.speakMisspelledIndicator(obj, startOffset) - self.sayPhrase(obj, startOffset, endOffset) - self.pointOfReference["lastTextUnitSpoken"] = "word" - - def presentObject(self, obj, **args): - interrupt = args.get("interrupt", False) - tokens = ["DEFAULT: Presenting object", obj, ". Interrupt:", interrupt] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - - if not args.get("speechonly", False): - self.updateBraille(obj, **args) - utterances = self.speechGenerator.generateSpeech(obj, **args) - speech.speak(utterances, interrupt=interrupt) - - def stopSpeechOnActiveDescendantChanged(self, event): - """Whether or not speech should be stopped prior to setting the - locusOfFocus in onActiveDescendantChanged. - - Arguments: - - event: the Event - - Returns True if speech should be stopped; False otherwise. - """ - - if not event.any_data: - return True - - # In an object which manages its descendants, the - # 'descendants' may really be a single object which changes - # its name. If the name-change occurs followed by the active - # descendant changing (to the same object) we won't present - # the locusOfFocus because it hasn't changed. Thus we need to - # be sure not to cut of the presentation of the name-change - # event. - - if cthulhu_state.locusOfFocus == event.any_data: - names = self.pointOfReference.get('names', {}) - oldName = names.get(hash(cthulhu_state.locusOfFocus), '') - if not oldName or AXObject.get_name(event.any_data) == oldName: - return False - - if event.source == cthulhu_state.locusOfFocus == AXObject.get_parent(event.any_data): - return False - - return True - - def getFlatReviewContext(self): - """Returns the flat review context, creating one if necessary.""" - - return self.flatReviewPresenter.get_or_create_context(self) - - def updateBrailleReview(self, targetCursorCell=0): - """Obtains the braille regions for the current flat review line - and displays them on the braille display. If the targetCursorCell - is non-0, then an attempt will be made to position the review cursor - at that cell. Otherwise, we will pan in display-sized increments - to show the review cursor.""" - - if not cthulhu.cthulhuApp.settingsManager.getSetting('enableBraille') \ - and not cthulhu.cthulhuApp.settingsManager.getSetting('enableBrailleMonitor'): - debug.printMessage(debug.LEVEL_INFO, "BRAILLE: update review disabled", True) - return - - [regions, regionWithFocus] = self.flatReviewPresenter.get_braille_regions(self) - if not regions: - regions = [] - regionWithFocus = None - - line = self.getNewBrailleLine() - self.addBrailleRegionsToLine(regions, line) - braille.setLines([line]) - self.setBrailleFocus(regionWithFocus, False) - if regionWithFocus and not targetCursorCell: - offset = regionWithFocus.brailleOffset + regionWithFocus.cursorOffset - tokens = ["DEFAULT: Update to", offset, "in", regionWithFocus] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - self.panBrailleToOffset(offset) - - if self.justEnteredFlatReviewMode: - self.refreshBraille(True, self.targetCursorCell) - self.justEnteredFlatReviewMode = False - else: - self.refreshBraille(True, targetCursorCell) - - def _setFlatReviewContextToBeginningOfBrailleDisplay(self): - """Sets the character of interest to be the first character showing - at the beginning of the braille display.""" - - # The first character on the flat review line has to be in object with text. - def isTextOrComponent(x): - return isinstance(x, (braille.ReviewText, braille.ReviewComponent)) - - regions = self.flatReviewPresenter.get_braille_regions(self)[0] - regions = list(filter(isTextOrComponent, regions)) - tokens = ["DEFAULT: Text/Component regions on line:"] - for region in regions: - tokens.extend(["\n", region]) - debug.printTokens(debug.LEVEL_INFO, tokens, True) - - # TODO - JD: The current code was stopping on the first region which met the - # following condition. Is that definitely the right thing to do? Assume so for now. - # Also: Should the default script be accessing things like the viewport directly?? - def isMatch(x): - return x is not None and x.brailleOffset + len(x.string) > braille.viewport[0] - - regions = list(filter(isMatch, regions)) - if not regions: - msg = "DEFAULT: Could not find review region to move to start of display" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - tokens = ["DEFAULT: Candidates for start of display:"] - for region in regions: - tokens.extend(["\n", region]) - debug.printTokens(debug.LEVEL_INFO, tokens, True) - - - # TODO - JD: Again, for now we're preserving the original behavior of choosing the first. - region = regions[0] - position = max(region.brailleOffset, braille.viewport[0]) - if region.contracted: - offset = region.inPos[position - region.brailleOffset] - else: - offset = position - region.brailleOffset - if isinstance(region.zone, flat_review.TextZone): - offset += region.zone.startOffset - msg = f"DEFAULT: Offset for region: {offset}" - debug.printMessage(debug.LEVEL_INFO, msg, True) - - [word, charOffset] = region.zone.getWordAtOffset(offset) - if word: - tokens = ["DEFAULT: Setting start of display to", word, ", ", charOffset] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - context = self.getFlatReviewContext() - context.setCurrent( - word.zone.line.index, - word.zone.index, - word.index, - charOffset) - else: - tokens = ["DEFAULT: Setting start of display to", region.zone] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - context = self.getFlatReviewContext() - context.setCurrent( - region.zone.line.index, - region.zone.index, - 0, # word index - 0) # character index - - def find(self, query=None): - """Searches for the specified query. If no query is specified, - it searches for the query specified in the Cthulhu Find dialog. - - Arguments: - - query: The search query to find. - """ - - if not query: - query = find.getLastQuery() - if query: - context = self.getFlatReviewContext() - location = query.findQuery(context) - if not location: - self.presentMessage(messages.STRING_NOT_FOUND) - else: - context.setCurrent(location.lineIndex, location.zoneIndex, \ - location.wordIndex, location.charIndex) - self.flatReviewPresenter.present_item(self) - self.targetCursorCell = self.getBrailleCursorCell() - - def textLines(self, obj, offset=None): - """Creates a generator that can be used to iterate over each line - of a text object, starting at the caret offset. - - Arguments: - - obj: an Accessible that has a text specialization - - Returns an iterator that produces elements of the form: - [SayAllContext, acss], where SayAllContext has the text to be - spoken and acss is an ACSS instance for speaking the text. - """ - - self._sayAllIsInterrupted = False - if not AXObject.supports_text(obj): - self._inSayAll = False - self._sayAllContexts = [] - return - - self._inSayAll = True - length = AXText.get_character_count(obj) - if offset is None: - offset = AXText.get_caret_offset(obj) - - # Determine the correct "say all by" mode to use. - # - sayAllStyle = cthulhu.cthulhuApp.settingsManager.getSetting('sayAllStyle') - if sayAllStyle == settings.SAYALL_STYLE_SENTENCE: - mode = "sentence" - elif sayAllStyle == settings.SAYALL_STYLE_LINE: - mode = "line" - else: - mode = "line" - - priorObj = obj - - # Get the next line of text to read - # - done = False - while not done: - speech.speak(self.speechGenerator.generateContext(obj, priorObj=priorObj)) - - lastEndOffset = -1 - while offset < length: - if mode == "sentence": - lineString, startOffset, endOffset = AXText.get_sentence_at_offset(obj, offset) - else: - lineString, startOffset, endOffset = AXText.get_line_at_offset(obj, offset) - - # Some applications that don't support sentence boundaries - # will provide the line boundary results instead; others - # will return nothing. - # - if not lineString: - mode = "line" - lineString, startOffset, endOffset = AXText.get_line_at_offset(obj, offset) - - if endOffset > length: - tokens = ["DEFAULT: end offset", endOffset, " > character count", - length, - "resulting from getTextAtOffset(", offset, mode, ") for", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - endOffset = length - - # [[[WDW - HACK: this is here because getTextAtOffset - # tends not to be implemented consistently across toolkits. - # Sometimes it behaves properly (i.e., giving us an endOffset - # that is the beginning of the next line), sometimes it - # doesn't (e.g., giving us an endOffset that is the end of - # the current line). So...we hack. The whole 'max' deal - # is to account for lines that might be a brazillion lines - # long.]]] - # - if endOffset == lastEndOffset: - offset = max(offset + 1, lastEndOffset + 1) - lastEndOffset = endOffset - continue - - lastEndOffset = endOffset - offset = endOffset - - voice = self.speechGenerator.voice(obj=obj, string=lineString) - if voice and isinstance(voice, list): - voice = voice[0] - - lineString = \ - self.utilities.adjustForLinks(obj, lineString, startOffset) - lineString = self.utilities.adjustForRepeats(lineString) - - context = speechserver.SayAllContext( - obj, lineString, startOffset, endOffset) - tokens = ["DEFAULT:", context] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - self._sayAllContexts.append(context) - self.eventSynthesizer.scroll_into_view(obj, startOffset, endOffset) - yield [context, voice] - - moreLines = False - flowsTo = AXUtilitiesRelation.get_flows_to(obj) - if flowsTo: - priorObj = obj - obj = flowsTo[0] - - if not AXObject.supports_text(obj): - return - - length = AXText.get_character_count(obj) - offset = 0 - moreLines = True - break - if not moreLines: - done = True - - self._inSayAll = False - self._sayAllContexts = [] - - msg = "DEFAULT: textLines complete. Verifying SayAll status" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.inSayAll() - - def getTextLineAtCaret(self, obj, offset=None, startOffset=None, endOffset=None): - """To-be-removed. Returns the string, caretOffset, startOffset.""" - - if not AXObject.supports_text(obj): - return ["", 0, 0] - - offset = AXText.get_caret_offset(obj) - characterCount = AXText.get_character_count(obj) - if characterCount == 0: - return ["", 0, 0] - - if startOffset is not None and endOffset is not None: - return [AXText.get_substring(obj, startOffset, endOffset), offset, startOffset] - - targetOffset = startOffset - if targetOffset is None: - targetOffset = max(0, offset) - - if targetOffset == characterCount: - fixedTargetOffset = max(0, targetOffset - 1) - character, _, _ = AXText.get_character_at_offset(obj, fixedTargetOffset) - else: - fixedTargetOffset = targetOffset - character = None - - if (targetOffset == characterCount) and (character == "\n"): - lineString = "" - startOffset = fixedTargetOffset - else: - if characterCount == 1: - lineString = AXText.get_substring(obj, fixedTargetOffset, fixedTargetOffset + 1) - startOffset = fixedTargetOffset - else: - if fixedTargetOffset == -1: - fixedTargetOffset = characterCount - lineString, startOffset, endOffset = AXText.get_line_at_offset(obj, fixedTargetOffset) - - if 0 <= fixedTargetOffset < startOffset: - backup_offset = fixedTargetOffset - 1 - tokens = [f"DEFAULT: Start offset {startOffset} is greater than target offset {fixedTargetOffset}. Trying with offset {backup_offset}"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - lineString, startOffset, endOffset = AXText.get_line_at_offset(obj, backup_offset) - - lineString = lineString.rstrip('\n') - lineString = lineString.rstrip('\r') - - return [lineString, offset, startOffset] - - def phoneticSpellCurrentItem(self, itemString): - """Phonetically spell the current flat review word or line. - - Arguments: - - itemString: the string to phonetically spell. - """ - - for (charIndex, character) in enumerate(itemString): - voice = self.speechGenerator.voice(string=character) - phoneticString = phonnames.getPhoneticName(character.lower()) - self.speakMessage(phoneticString, voice) - - def _saveLastCursorPosition(self, obj, caretOffset): - """Save away the current text cursor position for next time. - - Arguments: - - obj: the current accessible - - caretOffset: the cursor position within this object - """ - - prevObj, prevOffset = self.pointOfReference.get("lastCursorPosition", (None, -1)) - self.pointOfReference["penultimateCursorPosition"] = prevObj, prevOffset - self.pointOfReference["lastCursorPosition"] = obj, caretOffset - - def systemBeep(self): - """Rings the system bell. This is really a hack. Ideally, we want - a method that will present an earcon (any sound designated for the - purpose of representing an error, event etc) - """ - - print("\a") - - def speakMisspelledIndicator(self, obj, offset): - """Speaks an announcement indicating that a given word is misspelled. - - Arguments: - - obj: An accessible which implements the accessible text interface. - - offset: Offset in the accessible's text for which to retrieve the - attributes. - """ - - if cthulhu.cthulhuApp.settingsManager.getSetting('speakMisspelledIndicator'): - if not AXObject.supports_text(obj): - return - # If we're on whitespace, we cannot be on a misspelled word. - # - char, _, _ = AXText.get_character_at_offset(obj, offset) - if not char.strip() or self.utilities.isWordDelimiter(char): - self._lastWordCheckedForSpelling = char - return - - word, _, _ = AXText.get_word_at_offset(obj, offset) - if self.utilities.isWordMisspelled(obj, offset) \ - and word != self._lastWordCheckedForSpelling: - self.speakMessage(messages.MISSPELLED) - # Store this word so that we do not continue to present the - # presence of the red squiggly as the user arrows amongst - # the characters. - # - self._lastWordCheckedForSpelling = word - - ############################################################################ - # # - # Presentation methods # - # (scripts should not call methods in braille.py or speech.py directly) # - # # - ############################################################################ - - def presentationInterrupt(self): - """Convenience method to interrupt presentation of whatever is being - presented at the moment.""" - - msg = "DEFAULT: Interrupting presentation" - debug.printMessage(debug.LEVEL_INFO, msg, True) - speech.stop() - braille.killFlash() - - def presentKeyboardEvent(self, event): - """Convenience method to present the KeyboardEvent event. Returns True - if we fully present the event; False otherwise.""" - - if not event.is_pressed_key(): - self._sayAllIsInterrupted = False - self.utilities.clearCachedCommandState() - - if not event.shouldEcho or event.isCthulhuModified(): - return False - - if not cthulhu.cthulhuApp.settingsManager.getSetting('enableKeyEcho'): - return False - - role = AXObject.get_role(cthulhu_state.locusOfFocus) - if role in [Atspi.Role.DIALOG, Atspi.Role.FRAME, Atspi.Role.WINDOW]: - focusedObject = AXUtilities.get_focused_object(cthulhu_state.activeWindow) - if focusedObject: - cthulhu.setLocusOfFocus(None, focusedObject, False) - role = AXObject.get_role(focusedObject) - - if role == Atspi.Role.PASSWORD_TEXT and not event.isLockingKey(): - return False - - if not event.is_pressed_key(): - return False - - braille.displayKeyEvent(event) - cthulhuModifierPressed = event.isCthulhuModifier() and event.is_pressed_key() - if event.isCharacterEchoable() and not cthulhuModifierPressed: - return False - - msg = "DEFAULT: Presenting keyboard event" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.speakKeyEvent(event) - return True - - def presentMessage(self, fullMessage, briefMessage=None, voice=None, resetStyles=True, - force=False): - """Convenience method to speak a message and 'flash' it in braille. - - Arguments: - - fullMessage: This can be a string or a list. This will be presented - as the message for users whose flash or message verbosity level is - verbose. - - briefMessage: This can be a string or a list. This will be presented - as the message for users whose flash or message verbosity level is - brief. Note that providing no briefMessage will result in the full - message being used for either. Callers wishing to present nothing as - the briefMessage should set briefMessage to an empty string. - - voice: The voice to use when speaking this message. By default, the - "system" voice will be used. - """ - - if not fullMessage: - return - - if briefMessage is None: - briefMessage = fullMessage - - if cthulhu.cthulhuApp.settingsManager.getSetting('enableSpeech'): - if not cthulhu.cthulhuApp.settingsManager.getSetting('messagesAreDetailed'): - message = briefMessage - else: - message = fullMessage - if message: - self.speakMessage(message, voice=voice, resetStyles=resetStyles, force=force) - - if (cthulhu.cthulhuApp.settingsManager.getSetting('enableBraille') \ - or cthulhu.cthulhuApp.settingsManager.getSetting('enableBrailleMonitor')) \ - and cthulhu.cthulhuApp.settingsManager.getSetting('enableFlashMessages'): - if not cthulhu.cthulhuApp.settingsManager.getSetting('flashIsDetailed'): - message = briefMessage - else: - message = fullMessage - if not message: - return - - if isinstance(message[0], list): - message = message[0] - if isinstance(message, list): - message = [i for i in message if isinstance(i, str)] - message = " ".join(message) - - if cthulhu.cthulhuApp.settingsManager.getSetting('flashIsPersistent'): - duration = -1 - else: - duration = cthulhu.cthulhuApp.settingsManager.getSetting('brailleFlashTime') - - braille.displayMessage(message, flashTime=duration) - - def idleMessage(self): - """Convenience method to tell speech and braille engines to hand off - control to other screen readers.""" - - braille.disableBraille() - - @staticmethod - def __play(sounds, interrupt=True): - if not sounds: - return - - if not isinstance(sounds, list): - sounds = [sounds] - - _player = sound.getPlayer() - _player.play(sounds[0], interrupt) - for i in range(1, len(sounds)): - sound.play(sounds[i], interrupt=False) - - @staticmethod - def addBrailleRegionToLine(region, line): - """Adds the braille region to the line. - - Arguments: - - region: a braille.Region (e.g. what is returned by the braille - generator's generateBraille() method. - - line: a braille.Line - """ - - line.addRegion(region) - - @staticmethod - def addBrailleRegionsToLine(regions, line): - """Adds the braille region to the line. - - Arguments: - - regions: a series of braille.Region instances (a single instance - being what is returned by the braille generator's generateBraille() - method. - - line: a braille.Line - """ - - line.addRegions(regions) - - @staticmethod - def addToLineAsBrailleRegion(string, line): - """Creates a Braille Region out of string and adds it to the line. - - Arguments: - - string: the string to be displayed - - line: a braille.Line - """ - - line.addRegion(braille.Region(string)) - - @staticmethod - def brailleRegionsFromStrings(strings): - """Creates a list of braille regions from the list of strings. - - Arguments: - - strings: a list of strings from which to create the list of - braille Region instances - - Returns the list of braille Region instances - """ - - brailleRegions = [] - for string in strings: - brailleRegions.append(braille.Region(string)) - - return brailleRegions - - @staticmethod - def clearBraille(): - """Clears the logical structure, but keeps the Braille display as is - (until a refresh operation).""" - - braille.clear() - - @staticmethod - def displayBrailleMessage(message, cursor=-1, flashTime=0): - """Displays a single line, setting the cursor to the given position, - ensuring that the cursor is in view. - - Arguments: - - message: the string to display - - cursor: the 0-based cursor position, where -1 (default) means no - cursor - - flashTime: if non-0, the number of milliseconds to display the - regions before reverting back to what was there before. A 0 means - to not do any flashing. A negative number means to display the - message until some other message comes along or the user presses - a cursor routing key. - """ - - if not cthulhu.cthulhuApp.settingsManager.getSetting('enableBraille') \ - and not cthulhu.cthulhuApp.settingsManager.getSetting('enableBrailleMonitor'): - debug.printMessage(debug.LEVEL_INFO, "BRAILLE: display message disabled", True) - return - - braille.displayMessage(message, cursor, flashTime) - - @staticmethod - def displayBrailleRegions(regionInfo, flashTime=0): - """Displays a list of regions on a single line, setting focus to the - specified region. The regionInfo parameter is something that is - typically returned by a call to braille_generator.generateBraille. - - Arguments: - - regionInfo: a list where the first element is a list of regions - to display and the second element is the region with focus (must - be in the list from element 0) - - flashTime: if non-0, the number of milliseconds to display the - regions before reverting back to what was there before. A 0 means - to not do any flashing. A negative number means to display the - message until some other message comes along or the user presses - a cursor routing key. - """ - - if not cthulhu.cthulhuApp.settingsManager.getSetting('enableBraille') \ - and not cthulhu.cthulhuApp.settingsManager.getSetting('enableBrailleMonitor'): - debug.printMessage(debug.LEVEL_INFO, "BRAILLE: display regions disabled", True) - return - - braille.displayRegions(regionInfo, flashTime) - - def displayBrailleForObject(self, obj): - """Convenience method for scripts combining the call to the braille - generator for the script with the call to displayBrailleRegions. - - Arguments: - - obj: the accessible object to display in braille - """ - - regions = self.brailleGenerator.generateBraille(obj) - self.displayBrailleRegions(regions) - - @staticmethod - def getBrailleCaretContext(event): - """Gets the accesible and caret offset associated with the given - event. The event should have a BrlAPI event that contains an - argument value that corresponds to a cell on the display. - - Arguments: - - event: an instance of input_event.BrailleEvent. event.event is - the dictionary form of the expanded BrlAPI event. - """ - - return braille.getCaretContext(event) - - @staticmethod - def getBrailleCursorCell(): - """Returns the value of position of the braille cell which has the - cursor. A value of 0 means no cell has the cursor.""" - - return braille.cursorCell - - @staticmethod - def getNewBrailleLine(clearBraille=False, addLine=False): - """Creates a new braille Line. - - Arguments: - - clearBraille: Whether the display should be cleared. - - addLine: Whether the line should be added to the logical display - for painting. - - Returns the new Line. - """ - - if clearBraille: - braille.clear() - line = braille.Line() - if addLine: - braille.addLine(line) - - return line - - @staticmethod - def getNewBrailleComponent(accessible, string, cursorOffset=0, - indicator='', expandOnCursor=False): - """Creates a new braille Component. - - Arguments: - - accessible: the accessible associated with this region - - string: the string to be displayed - - cursorOffset: a 0-based index saying where to draw the cursor - for this Region if it gets focus - - Returns the new Component. - """ - - return braille.Component(accessible, string, cursorOffset, - indicator, expandOnCursor) - - @staticmethod - def getNewBrailleRegion(string, cursorOffset=0, expandOnCursor=False): - """Creates a new braille Region. - - Arguments: - - string: the string to be displayed - - cursorOffset: a 0-based index saying where to draw the cursor - for this Region if it gets focus - - Returns the new Region. - """ - - return braille.Region(string, cursorOffset, expandOnCursor) - - @staticmethod - def getNewBrailleText(accessible, label="", eol="", startOffset=None, - endOffset=None): - - """Creates a new braille Text region. - - Arguments: - - accessible: the accessible associated with this region and which - implements AtkText - - label: an optional label to display - - eol: the endOfLine indicator - - Returns the new Text region. - """ - - return braille.Text(accessible, label, eol, startOffset, endOffset) - - @staticmethod - def isBrailleBeginningShowing(): - """If True, the beginning of the line is showing on the braille - display.""" - - return braille.beginningIsShowing - - @staticmethod - def isBrailleEndShowing(): - """If True, the end of the line is showing on the braille display.""" - - return braille.endIsShowing - - @staticmethod - def panBrailleInDirection(panAmount=0, panToLeft=True): - """Pans the display to the left, limiting the pan to the beginning - of the line being displayed. - - Arguments: - - panAmount: the amount to pan. A value of 0 means the entire - width of the physical display. - - panToLeft: if True, pan to the left; otherwise to the right - - Returns True if a pan actually happened. - """ - - if panToLeft: - return braille.panLeft(panAmount) - else: - return braille.panRight(panAmount) - - @staticmethod - def panBrailleToOffset(offset): - """Automatically pan left or right to make sure the current offset - is showing.""" - - braille.panToOffset(offset) - - @staticmethod - def presentItemsInBraille(items): - """Method to braille a list of items. Scripts should call this - method rather than handling the creation and displaying of a - braille line directly. - - Arguments: - - items: a list of strings to be presented - """ - - line = braille.getShowingLine() - for item in items: - line.addRegion(braille.Region(" " + item)) - - braille.refresh() - - def updateBrailleForNewCaretPosition(self, obj): - """Try to reposition the cursor without having to do a full update.""" - - if not cthulhu.cthulhuApp.settingsManager.getSetting('enableBraille') \ - and not cthulhu.cthulhuApp.settingsManager.getSetting('enableBrailleMonitor'): - debug.printMessage(debug.LEVEL_INFO, "BRAILLE: update caret disabled", True) - return - - brailleNeedsRepainting = True - line = braille.getShowingLine() - for region in line.regions: - if isinstance(region, braille.Text) and region.accessible == obj: - if region.repositionCursor(): - self.refreshBraille(True) - brailleNeedsRepainting = False - break - - if brailleNeedsRepainting: - self.updateBraille(obj) - - @staticmethod - def refreshBraille(panToCursor=True, targetCursorCell=0, getLinkMask=True, - stopFlash=True): - """This is the method scripts should use to refresh braille rather - than calling self.refreshBraille() directly. The intent is to centralize - such calls into as few places as possible so that we can easily and - safely not perform braille-related functions for users who do not - have braille and/or the braille monitor enabled. - - Arguments: - - - panToCursor: if True, will adjust the viewport so the cursor is - showing. - - targetCursorCell: Only effective if panToCursor is True. - 0 means automatically place the cursor somewhere on the display so - as to minimize movement but show as much of the line as possible. - A positive value is a 1-based target cell from the left side of - the display and a negative value is a 1-based target cell from the - right side of the display. - - getLinkMask: Whether or not we should take the time to get the - attributeMask for links. Reasons we might not want to include - knowing that we will fail and/or it taking an unreasonable - amount of time (AKA Gecko). - - stopFlash: if True, kill any flashed message that may be showing. - """ - - braille.refresh(panToCursor, targetCursorCell, getLinkMask, stopFlash) - - @staticmethod - def setBrailleFocus(region, panToFocus=True, getLinkMask=True): - """Specififes the region with focus. This region will be positioned - at the home position if panToFocus is True. - - Arguments: - - region: the given region, which much be in a line that has been - added to the logical display - - panToFocus: whether or not to position the region at the home - position - - getLinkMask: Whether or not we should take the time to get the - attributeMask for links. Reasons we might not want to include - knowing that we will fail and/or it taking an unreasonable - amount of time (AKA Gecko). - """ - - braille.setFocus(region, panToFocus, getLinkMask) - - @staticmethod - def _setContractedBraille(event): - """Turns contracted braille on or off based upon the event. - - Arguments: - - event: an instance of input_event.BrailleEvent. event.event is - the dictionary form of the expanded BrlAPI event. - """ - - braille.setContractedBraille(event) - - ######################################################################## - # # - # Speech methods # - # (scripts should not call methods in speech.py directly) # - # # - ######################################################################## - - def _shouldUseCustomEchoVoice(self, echoType): - """Returns True if custom echo settings should be used for the given type.""" - - settingForType = { - "key": "useCustomEchoForKey", - "character": "useCustomEchoForCharacter", - "word": "useCustomEchoForWord", - "sentence": "useCustomEchoForSentence", - } - - settingName = settingForType.get(echoType) - if not settingName: - return False - - settingsManager = cthulhu.cthulhuApp.settingsManager - if not settingsManager.getSetting("enableKeyEcho"): - return False - - if not settingsManager.getSetting("useCustomEchoVoice"): - return False - - return settingsManager.getSetting(settingName) - - def _getCustomEchoVoice(self): - """Returns the effective ACSS voice for custom echo output.""" - - settingsManager = cthulhu.cthulhuApp.settingsManager - voices = settingsManager.getSetting("voices") or {} - defaultVoice = acss.ACSS(voices.get(settings.DEFAULT_VOICE, {})) - - echoVoice = settingsManager.getSetting("echoVoice") - if not echoVoice: - return defaultVoice - - try: - defaultVoice.update(acss.ACSS(echoVoice)) - except Exception: - debug.printException(debug.LEVEL_INFO) - - return defaultVoice - - def speakKeyEvent(self, event): - """Method to speak a keyboard event. Scripts should use this method - rather than calling speech.speakKeyEvent directly.""" - - string = None - if event.is_printable_key(): - string = event.event_string - - if self._shouldUseCustomEchoVoice("key"): - speech.speakEchoKeyEvent(event, self._getCustomEchoVoice()) - return - - voice = self.speechGenerator.voice(string=string) - speech.speakKeyEvent(event, voice) - - def speakCharacter(self, character): - """Method to speak a single character. Scripts should use this - method rather than calling speech.speakCharacter directly.""" - - if self._shouldUseCustomEchoVoice("character"): - speech.speakEchoCharacter(character, self._getCustomEchoVoice()) - return - - voice = self.speechGenerator.voice(string=character) - speech.speakCharacter(character, voice) - - def speakMessage(self, string, voice=None, interrupt=True, resetStyles=True, force=False): - """Method to speak a single string. Scripts should use this - method rather than calling speech.speak directly. - - - string: The string to be spoken. - - voice: The voice to use. By default, the "system" voice will - be used. - - interrupt: If True, any current speech should be interrupted - prior to speaking the new text. - """ - - if not cthulhu.cthulhuApp.settingsManager.getSetting('enableSpeech') \ - or (cthulhu.cthulhuApp.settingsManager.getSetting('onlySpeakDisplayedText') and not force): - return - - voices = cthulhu.cthulhuApp.settingsManager.getSetting('voices') - systemVoice = voices.get(settings.SYSTEM_VOICE) - - voice = voice or systemVoice - if voice == systemVoice and resetStyles: - capStyle = cthulhu.cthulhuApp.settingsManager.getSetting('capitalizationStyle') - cthulhu.cthulhuApp.settingsManager.setSetting('capitalizationStyle', settings.CAPITALIZATION_STYLE_NONE) - self.speechAndVerbosityManager.update_capitalization_style() - - punctStyle = cthulhu.cthulhuApp.settingsManager.getSetting('verbalizePunctuationStyle') - cthulhu.cthulhuApp.settingsManager.setSetting('verbalizePunctuationStyle', - settings.PUNCTUATION_STYLE_NONE) - self.speechAndVerbosityManager.update_punctuation_level() - - speech.speak(string, voice, interrupt) - - if voice == systemVoice and resetStyles: - cthulhu.cthulhuApp.settingsManager.setSetting('capitalizationStyle', capStyle) - self.speechAndVerbosityManager.update_capitalization_style() - - cthulhu.cthulhuApp.settingsManager.setSetting('verbalizePunctuationStyle', punctStyle) - self.speechAndVerbosityManager.update_punctuation_level() - - @staticmethod - def presentItemsInSpeech(items): - """Method to speak a list of items. Scripts should call this - method rather than handling the creation and speaking of - utterances directly. - - Arguments: - - items: a list of strings to be presented - """ - - utterances = [] - for item in items: - utterances.append(item) - - speech.speak(utterances) - - def speakUnicodeCharacter(self, character): - """ Speaks some information about an unicode character. - At the moment it just announces the character unicode number but - this information may be changed in the future - - Arguments: - - character: the character to speak information of - """ - speech.speak(messages.UNICODE % \ - self.utilities.unicodeValueString(character)) diff --git a/src/cthulhu/scripts/self_voicing.py b/src/cthulhu/scripts/self_voicing.py index 20f0dc7..ac13513 100644 --- a/src/cthulhu/scripts/self_voicing.py +++ b/src/cthulhu/scripts/self_voicing.py @@ -47,23 +47,20 @@ class Script(default.Script): default.Script.__init__(self, app) - def getBrailleGenerator(self): + def _create_braille_generator(self): """Returns the braille generator for this script. """ return None - def getSpeechGenerator(self): + def _create_speech_generator(self): """Returns the speech generator for this script. """ return None - def processObjectEvent(self, event): - """Does nothing. + def get_listeners(self): + """Returns no listeners for self-voicing applications.""" - Arguments: - - event: the Event - """ - pass + return {} def processBrailleEvent(self, brailleEvent): """Does nothing. diff --git a/src/cthulhu/scripts/sleepmode/script.py b/src/cthulhu/scripts/sleepmode/script.py index 3a684e5..8f824d1 100644 --- a/src/cthulhu/scripts/sleepmode/script.py +++ b/src/cthulhu/scripts/sleepmode/script.py @@ -1,7 +1,6 @@ -#!/usr/bin/env python3 +# Cthulhu # -# Copyright (c) 2024 Stormux -# Copyright (c) 2023 Igalia, S.L. +# Copyright 2023 Igalia, S.L. # Author: Joanmarie Diggs # # This library is free software; you can redistribute it and/or @@ -18,123 +17,89 @@ # 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 -"""Script for sleep mode where Cthulhu ignores events and commands. - -This script is now a minimal implementation that relies on the global -sleep mode flag in cthulhu_state.sleepMode to block speech and braille -at the system level. +""" +A script which has no commands, has no presentation, and ignores events. +The main use cases for this script are self-voicing apps and VMs which +should be usable without having to quit Cthulhu entirely. """ -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2024 Stormux" -__license__ = "LGPL" +from __future__ import annotations -import cthulhu.scripts.default as default -import cthulhu.debug as debug -import cthulhu.cthulhu_modifier_manager as cthulhu_modifier_manager -import cthulhu.sleep_mode_manager as sleep_mode_manager +from typing import TYPE_CHECKING -class Script(default.Script): - """The sleep mode script. - - This script now relies on the global sleep mode flag for blocking - speech and braille output at the system level. It only handles - keybinding management and basic event blocking. - """ +from cthulhu import ( + command_manager, + debug, + focus_manager, + messages, + cthulhu_modifier_manager, + presentation_manager, + script, + sleep_mode_manager, +) +from cthulhu.ax_object import AXObject +from cthulhu.ax_utilities import AXUtilities - def __init__(self, app): - super().__init__(app) +if TYPE_CHECKING: + import gi - def activate(self): + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + + +class Script(script.Script): + """The sleep-mode script.""" + + def activate(self) -> None: """Called when this script is activated.""" - - debug.printMessage(debug.LEVEL_INFO, "SLEEP MODE SCRIPT: Activating", True) - super().activate() - cthulhu_modifier_manager.getManager().unsetCthulhuModifiers("Entering sleep mode.") - # Get the manager and add its bindings and handlers - manager = sleep_mode_manager.getManager() - managerBindings = manager.getBindings() - if hasattr(managerBindings, 'keyBindings'): - for binding in managerBindings.keyBindings: - self.keyBindings.add(binding) - self.inputEventHandlers.update(manager.getHandlers()) - - # Remove most key grabs except sleep mode toggle - self.removeKeyGrabs() - - debug.printMessage(debug.LEVEL_INFO, "SLEEP MODE SCRIPT: Activated successfully", True) + tokens = ["SLEEP MODE: Activating script for", self.app] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + exceptions = frozenset({sleep_mode_manager.SleepModeManager.COMMAND_NAME}) + command_manager.get_manager().set_all_suspended(True, exceptions) + cthulhu_modifier_manager.get_manager().unset_cthulhu_modifiers("Entering sleep mode.") - def deactivate(self): + def deactivate(self) -> None: """Called when this script is deactivated.""" - - debug.printMessage(debug.LEVEL_INFO, "SLEEP MODE SCRIPT: Deactivating", True) - cthulhu_modifier_manager.getManager().refreshCthulhuModifiers("Exiting sleep mode.") - - super().deactivate() - def removeKeyGrabs(self): - """Remove key grabs except for sleep mode toggle.""" - - try: - # First remove all grabs inherited from default activation, - # including modifier grabs. - super().removeKeyGrabs() + tokens = ["SLEEP MODE: De-activating script for", self.app] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + command_manager.get_manager().set_all_suspended(False) + cthulhu_modifier_manager.get_manager().refresh_cthulhu_modifiers("Leaving sleep mode.") - self.grab_ids = [] - for keyBinding in self.keyBindings.keyBindings: - if hasattr(keyBinding, 'handler') and hasattr(keyBinding.handler, 'function'): - if hasattr(keyBinding.handler.function, '__name__'): - if 'toggleSleepMode' in keyBinding.handler.function.__name__: - # Keep sleep mode toggle - try: - import cthulhu - grabIds = cthulhu.addKeyGrab(keyBinding) - if grabIds: - for grabId in grabIds: - self.grab_ids.append(grabId) - debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Kept sleep toggle key grab: {grabId}", True) - except Exception as e: - debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Error keeping key grab: {e}", True) - else: - debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Skipping key grab for {keyBinding.handler.function.__name__}", True) - - except Exception as e: - debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Error in removeKeyGrabs: {e}", True) + def locus_of_focus_changed( + self, + event: Atspi.Event | None, + old_focus: Atspi.Accessible | None, + new_focus: Atspi.Accessible | None, + ) -> bool: + """Handles changes of focus of interest. Returns True if this script did all needed work.""" - # Block common event handlers as an additional layer of protection - def onCaretMoved(self, event): - """Block caret movement events.""" - debug.printMessage(debug.LEVEL_INFO, "SLEEP MODE: Blocking onCaretMoved", True) + tokens = ["SLEEP MODE: focus changed from", old_focus, "to", new_focus, "due to", event] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if old_focus is None and AXUtilities.is_application(AXObject.get_parent(new_focus)): + focus_manager.get_manager().clear_state("Sleep mode enabled for this app.") + msg = messages.SLEEP_MODE_ENABLED_FOR % AXObject.get_name(self.app) + manager = presentation_manager.get_manager() + manager.speak_message(msg) + + # Don't restore previous braille content because Cthulhu is no longer active. + manager.present_braille_message(msg, restore_previous=False) + return True + + msg = "SLEEP MODE: Ignoring event." + debug.print_message(debug.LEVEL_INFO, msg, True) return True - def onTextDeleted(self, event): - """Block text deletion events.""" - debug.printMessage(debug.LEVEL_INFO, "SLEEP MODE: Blocking onTextDeleted", True) + def _on_window_activated(self, event: Atspi.Event) -> bool: + """Callback for window:activate accessibility events.""" + + focus_manager.get_manager().clear_state("Sleep mode enabled for this app.") + msg = messages.SLEEP_MODE_ENABLED_FOR % AXObject.get_name(self.app) + manager = presentation_manager.get_manager() + manager.speak_message(msg) + + # Don't restore previous braille content because Cthulhu is no longer active. + manager.present_braille_message(msg, restore_previous=False) return True - - def onTextInserted(self, event): - """Block text insertion events.""" - debug.printMessage(debug.LEVEL_INFO, "SLEEP MODE: Blocking onTextInserted", True) - return True - - def onFocusChanged(self, event): - """Block focus change events.""" - debug.printMessage(debug.LEVEL_INFO, "SLEEP MODE: Blocking onFocusChanged", True) - return True - - def onWindowActivated(self, event): - """Block window activation events.""" - debug.printMessage(debug.LEVEL_INFO, "SLEEP MODE: Blocking onWindowActivated", True) - return True - - -def get_script(app): - """Returns the script for the given application.""" - return Script(app) diff --git a/src/cthulhu/scripts/switcher/script.py b/src/cthulhu/scripts/switcher/script.py index e3e40a7..a09083d 100644 --- a/src/cthulhu/scripts/switcher/script.py +++ b/src/cthulhu/scripts/switcher/script.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 2019 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,128 +17,146 @@ # 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 + """Custom script for basic switchers like Metacity.""" -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2019 Igalia, S.L." -__license__ = "LGPL" +from __future__ import annotations -from cthulhu import debug -from cthulhu import cthulhu +from typing import TYPE_CHECKING + +from cthulhu import debug, focus_manager, presentation_manager +from cthulhu.ax_object import AXObject +from cthulhu.ax_utilities import AXUtilities from cthulhu.scripts import default from .script_utilities import Utilities +if TYPE_CHECKING: + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + class Script(default.Script): + """Custom script for basic switchers like Metacity.""" - def __init__(self, app): - """Creates a new script for the given application.""" + utilities: Utilities - super().__init__(app) - - def getUtilities(self): + def get_utilities(self) -> Utilities: """Returns the utilities for this script.""" return Utilities(self) - def forceScriptActivation(self, event): + def force_script_activation(self, event: Atspi.Event) -> bool: """Allows scripts to insist that they should become active.""" - if self.utilities.isSwitcherSelectionChangeEventType(event): + if self.utilities.is_switcher_selection_change_event_type(event): return True - return super().forceScriptActivation(event) + return super().force_script_activation(event) - def _handleSwitcherEvent(self, event): + def _handle_switcher_event(self, event: Atspi.Event) -> bool: """Presents the currently selected item, if appropriate.""" - if not self.utilities.isSwitcherContainer(event.source): + if not self.utilities.is_switcher_container(event.source): msg = "SWITCHER: Event is not from switcher container" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return False - if not self.utilities.isSwitcherSelectionChangeEventType(event): + if not self.utilities.is_switcher_selection_change_event_type(event): msg = "SWITCHER: Not treating event as selection change." - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True msg = "SWITCHER: Treating event as selection change" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) - self.presentationInterrupt() - cthulhu.setActiveWindow(self.utilities.topLevelObject(event.source)) - cthulhu.setLocusOfFocus(event, event.source, False) - self.presentMessage(self.utilities.getSelectionName(event.source), - resetStyles=False, force=True) + presentation_manager.get_manager().interrupt_presentation() + focus_manager.get_manager().set_active_window(self.utilities.top_level_object(event.source)) + focus_manager.get_manager().set_locus_of_focus(event, event.source, False) + name = self.utilities.get_selection_name(event.source) + presenter = presentation_manager.get_manager() + presenter.speak_accessible_text(event.source, name) + presenter.present_braille_message(name) return True - def onFocusedChanged(self, event): + def locus_of_focus_changed( + self, + event: Atspi.Event | None, + old_focus: Atspi.Accessible | None, + new_focus: Atspi.Accessible | None, + ) -> bool: + """Handles changes of focus of interest. Returns True if this script did all needed work.""" + + if AXUtilities.is_window(new_focus) and not AXObject.get_name(new_focus): + msg = "SWITCHER: Not presenting newly-focused nameless window." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + return super().locus_of_focus_changed(event, old_focus, new_focus) + + def _on_focused_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:focused accessibility events.""" - if self._handleSwitcherEvent(event): - return + if self._handle_switcher_event(event): + return True - super().onFocusedChanged(event) + return super()._on_focused_changed(event) - def onNameChanged(self, event): + def _on_name_changed(self, event: Atspi.Event) -> bool: """Callback for object:property-change:accessible-name events.""" - if self._handleSwitcherEvent(event): - return + if self._handle_switcher_event(event): + return True - super().onNameChanged(event) + return super()._on_name_changed(event) - def onSelectedChanged(self, event): + def _on_selected_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:selected accessibility events.""" - if self._handleSwitcherEvent(event): - return + if self._handle_switcher_event(event): + return True - super().onSelectedChanged(event) + return super()._on_selected_changed(event) - def onSelectionChanged(self, event): + def _on_selection_changed(self, event: Atspi.Event) -> bool: """Callback for object:selection-changed accessibility events.""" - if self._handleSwitcherEvent(event): - return + if self._handle_switcher_event(event): + return True - super().onSelectionChanged(event) + return super()._on_selection_changed(event) - def onShowingChanged(self, event): + def _on_showing_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:showing accessibility events.""" - if self._handleSwitcherEvent(event): - return + if self._handle_switcher_event(event): + return True - super().onShowingChanged(event) + return super()._on_showing_changed(event) - def onCaretMoved(self, event): + def _on_caret_moved(self, event: Atspi.Event) -> bool: """Callback for object:text-caret-moved accessibility events.""" - if self._handleSwitcherEvent(event): - return + if self._handle_switcher_event(event): + return True - super().onCaretMoved(event) + return super()._on_caret_moved(event) - def onTextDeleted(self, event): + def _on_text_deleted(self, event: Atspi.Event) -> bool: """Callback for object:text-changed:delete accessibility events.""" - if self._handleSwitcherEvent(event): - return + if self._handle_switcher_event(event): + return True - super().onTextDeleted(event) + return super()._on_text_deleted(event) - def onTextInserted(self, event): + def _on_text_inserted(self, event: Atspi.Event) -> bool: """Callback for object:text-changed:insert accessibility events.""" - if self._handleSwitcherEvent(event): - return + if self._handle_switcher_event(event): + return True - super().onTextInserted(event) + return super()._on_text_inserted(event) diff --git a/src/cthulhu/scripts/terminal/script.py b/src/cthulhu/scripts/terminal/script.py index f098fcd..3d25d6f 100644 --- a/src/cthulhu/scripts/terminal/script.py +++ b/src/cthulhu/scripts/terminal/script.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 2016 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,148 +17,84 @@ # 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 -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2016 Igalia, S.L." -__license__ = "LGPL" +"""Script for terminal support.""" -from cthulhu import debug -from cthulhu import cthulhu -from cthulhu.ax_object import AXObject +from __future__ import annotations + +from typing import TYPE_CHECKING + +from cthulhu import ( + debug, + flat_review_presenter, + focus_manager, + presentation_manager, + typing_echo_presenter, +) from cthulhu.ax_text import AXText +from cthulhu.ax_utilities import AXUtilities from cthulhu.scripts import default -from .braille_generator import BrailleGenerator -from .speech_generator import SpeechGenerator from .script_utilities import Utilities +if TYPE_CHECKING: + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + class Script(default.Script): + """Script for terminal support.""" - def __init__(self, app): + utilities: Utilities + + def __init__(self, app: Atspi.Accessible) -> None: super().__init__(app) - self.presentIfInactive = False + self.present_if_inactive: bool = False - def deactivate(self): - """Called when this script is deactivated.""" - - self.utilities.clearCache() - super().deactivate() - - def getBrailleGenerator(self): - """Returns the braille generator for this script.""" - - return BrailleGenerator(self) - - def getSpeechGenerator(self): - """Returns the speech generator for this script.""" - - return SpeechGenerator(self) - - def getUtilities(self): + def get_utilities(self) -> Utilities: """Returns the utilities for this script.""" return Utilities(self) - def onFocus(self, event): - """Callback for focus: accessibility events.""" - - # https://bugzilla.gnome.org/show_bug.cgi?id=748311 - cthulhu.setLocusOfFocus(event, event.source) - - def onTextDeleted(self, event): + def _on_text_deleted(self, event: Atspi.Event) -> bool: """Callback for object:text-changed:delete accessibility events.""" - if self.utilities.treatEventAsNoise(event): + if self.utilities.treat_event_as_noise(event): msg = "TERMINAL: Deletion is believed to be noise" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - super().onTextDeleted(event) + return super()._on_text_deleted(event) - def onTextInserted(self, event): + def _on_text_inserted(self, event: Atspi.Event) -> bool: """Callback for object:text-changed:insert accessibility events.""" - if not self.utilities.treatEventAsCommand(event): + typing_echo_presenter.get_presenter().echo_delayed_terminal_press(self, event) + + if not self.utilities.treat_event_as_command(event): msg = "TERMINAL: Passing along event to default script." - debug.printMessage(debug.LEVEL_INFO, msg, True) - super().onTextInserted(event) - return + debug.print_message(debug.LEVEL_INFO, msg, True) + return super()._on_text_inserted(event) msg = "TERMINAL: Insertion is believed to be due to terminal command" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) - self.updateBraille(event.source) + self.update_braille(event.source) - newString = self.utilities.insertedText(event) - if len(newString) == 1: - self.speakCharacter(newString) + new_string = self.utilities.inserted_text(event) + if len(new_string) == 1: + presentation_manager.get_manager().speak_character(new_string) else: - voice = self.speechGenerator.voice(obj=event.source, string=newString) - self.speakMessage(newString, voice=voice) + presentation_manager.get_manager().speak_accessible_text(event.source, new_string) - if self.flatReviewPresenter.is_active(): + if flat_review_presenter.get_presenter().is_active(): msg = "TERMINAL: Flat review presenter is active. Ignoring insertion" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - if AXObject.supports_text(event.source): - self._saveLastCursorPosition(event.source, AXText.get_caret_offset(event.source)) - self.utilities.updateCachedTextSelection(event.source) - - def presentKeyboardEvent(self, event): - if not event.is_printable_key(): - return super().presentKeyboardEvent(event) - - if event.is_pressed_key(): - return False - - self._sayAllIsInterrupted = False - self.utilities.clearCachedCommandState() - if not event.shouldEcho or event.isCthulhuModified() or event.isCharacterEchoable(): - return False - - # We have no reliable way of knowing a password is being entered into - # a terminal -- other than the fact that the text typed isn't there. - if not AXObject.supports_text(event.get_object()): - return False - - offset = AXText.get_caret_offset(event.get_object()) - prevChar = AXText.get_substring(event.get_object(), offset - 1, offset) - char = AXText.get_substring(event.get_object(), offset, offset + 1) - - string = event.event_string - if string not in [prevChar, "space", char]: - return False - - tokens = ["TERMINAL: Presenting keyboard event", string] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - self.speakKeyEvent(event) - return True - - def skipObjectEvent(self, event): - if event.type == "object:text-changed:insert": - return False - - newEvent, newTime = None, 0 - if event.type == "object:text-changed:delete": - if self.utilities.isBackSpaceCommandTextDeletionEvent(event): - return False - - newEvent, newTime = self.eventCache.get("object:text-changed:insert", [None, 0]) - - if newEvent is None or newEvent.source != event.source: - return super().skipObjectEvent(event) - - if event.detail1 != newEvent.detail1: - return False - - data = "\n%s%s" % (" " * 11, str(newEvent).replace("\t", " " * 11)) - tokens = ["TERMINAL: Skipping due to more recent event at offset", data] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + offset = AXText.get_caret_offset(event.source) + focus_manager.get_manager().set_last_cursor_position(event.source, offset) + AXUtilities.update_cached_selected_text(event.source) return True diff --git a/src/cthulhu/scripts/toolkits/Chromium/script.py b/src/cthulhu/scripts/toolkits/Chromium/script.py index b98de3d..2dd1ae1 100644 --- a/src/cthulhu/scripts/toolkits/Chromium/script.py +++ b/src/cthulhu/scripts/toolkits/Chromium/script.py @@ -1,9 +1,8 @@ -#!/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 2018-2019 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,450 +18,108 @@ # 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 """Custom script for Chromium.""" -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2018-2019 Igalia, S.L." -__license__ = "LGPL" +from __future__ import annotations -from cthulhu import debug -from cthulhu import cthulhu -from cthulhu import cthulhu_state +from typing import TYPE_CHECKING + +from cthulhu import debug, focus_manager +from cthulhu.ax_document import AXDocument from cthulhu.ax_object import AXObject from cthulhu.ax_utilities import AXUtilities -from cthulhu.scripts import default from cthulhu.scripts import web -from .braille_generator import BrailleGenerator -from .script_utilities import Utilities -from .speech_generator import SpeechGenerator + +if TYPE_CHECKING: + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi -class Script(web.Script): +class Script(web.ToolkitBridge): + """Custom script for Chromium.""" - def __init__(self, app): - super().__init__(app) - - self.presentIfInactive = False - - def getBrailleGenerator(self): - """Returns the braille generator for this script.""" - - return BrailleGenerator(self) - - def getSpeechGenerator(self): - """Returns the speech generator for this script.""" - - return SpeechGenerator(self) - - def getUtilities(self): - """Returns the utilities for this script.""" - - return Utilities(self) - - def isActivatableEvent(self, event): - """Returns True if this event should activate this script.""" - - if event.type == "window:activate": - return self.utilities.canBeActiveWindow(event.source) - - return super().isActivatableEvent(event) - - def locus_of_focus_changed(self, event, oldFocus, newFocus): - """Handles changes of focus of interest to the script.""" - - if super().locus_of_focus_changed(event, oldFocus, newFocus): - return - - msg = "CHROMIUM: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.locus_of_focus_changed(self, event, oldFocus, newFocus) - - def onActiveChanged(self, event): - """Callback for object:state-changed:active accessibility events.""" - - if super().onActiveChanged(event): - return - - if event.detail1 and AXUtilities.is_frame(event.source) \ - and not self.utilities.canBeActiveWindow(event.source): - return - - msg = "CHROMIUM: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onActiveChanged(self, event) - - def onActiveDescendantChanged(self, event): - """Callback for object:active-descendant-changed accessibility events.""" - - if super().onActiveDescendantChanged(event): - return - - msg = "CHROMIUM: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onActiveDescendantChanged(self, event) - - def onBusyChanged(self, event): - """Callback for object:state-changed:busy accessibility events.""" - - if self.utilities.hasNoSize(event.source): - msg = "CHROMIUM: Ignoring event from page with no size." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - if not self.utilities.documentFrameURI(event.source): - msg = "CHROMIUM: Ignoring event from page with no URI." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - if super().onBusyChanged(event): - return - - msg = "CHROMIUM: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onBusyChanged(self, event) - - def onCaretMoved(self, event): + def _on_caret_moved(self, event: Atspi.Event) -> bool: """Callback for object:text-caret-moved accessibility events.""" - if self.utilities.isStaticTextLeaf(event.source): - msg = "CHROMIUM: Ignoring event from static-text leaf" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + if not AXUtilities.is_web_element(event.source) and AXUtilities.is_web_element( + AXObject.get_parent(event.source), + ): + msg = "CHROMIUM: Ignoring because source is not an element" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - if self.utilities.isRedundantAutocompleteEvent(event): - msg = "CHROMIUM: Ignoring redundant autocomplete event" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + return super()._on_caret_moved(event) - if super().onCaretMoved(event): - return - - msg = "CHROMIUM: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onCaretMoved(self, event) - - def onCheckedChanged(self, event): - """Callback for object:state-changed:checked accessibility events.""" - - if super().onCheckedChanged(event): - return - - msg = "CHROMIUM: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onCheckedChanged(self, event) - - def onColumnReordered(self, event): - """Callback for object:column-reordered accessibility events.""" - - if super().onColumnReordered(event): - return - - msg = "CHROMIUM: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onColumnReordered(self, event) - - def onChildrenAdded(self, event): + def _on_children_added(self, event: Atspi.Event) -> bool: """Callback for object:children-changed:add accessibility events.""" - if self.utilities.isStaticTextLeaf(event.any_data): - msg = "CHROMIUM: Ignoring because child is static text leaf" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + if AXUtilities.is_web_element(event.source) and not AXUtilities.is_web_element( + event.any_data, + ): + msg = "CHROMIUM: Ignoring because child is not an element" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - if super().onChildrenAdded(event): - return + return super()._on_children_added(event) - msg = "CHROMIUM: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onChildrenAdded(self, event) - - def onChildrenRemoved(self, event): + def _on_children_removed(self, event: Atspi.Event) -> bool: """Callback for object:children-changed:removed accessibility events.""" - if self.utilities.isStaticTextLeaf(event.any_data): - msg = "CHROMIUM: Ignoring because child is static text leaf" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + if AXUtilities.is_web_element(event.source) and not AXUtilities.is_web_element( + event.any_data, + ): + msg = "CHROMIUM: Ignoring because child is not an element" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - if super().onChildrenRemoved(event): - return + return super()._on_children_removed(event) - msg = "Chromium: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onChildrenRemoved(self, event) - - def onDocumentLoadComplete(self, event): - """Callback for document:load-complete accessibility events.""" - - if not self.utilities.documentFrameURI(event.source): - msg = "CHROMIUM: Ignoring event from page with no URI." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - if super().onDocumentLoadComplete(event): - return - - msg = "CHROMIUM: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onDocumentLoadComplete(self, event) - - def onDocumentLoadStopped(self, event): - """Callback for document:load-stopped accessibility events.""" - - if not self.utilities.documentFrameURI(event.source): - msg = "CHROMIUM: Ignoring event from page with no URI." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - if super().onDocumentLoadStopped(event): - return - - msg = "CHROMIUM: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onDocumentLoadStopped(self, event) - - def onDocumentReload(self, event): - """Callback for document:reload accessibility events.""" - - if not self.utilities.documentFrameURI(event.source): - msg = "CHROMIUM: Ignoring event from page with no URI." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - if super().onDocumentReload(event): - return - - msg = "CHROMIUM: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onDocumentReload(self, event) - - def onExpandedChanged(self, event): - """Callback for object:state-changed:expanded accessibility events.""" - - if super().onExpandedChanged(event): - return - - msg = "CHROMIUM: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onExpandedChanged(self, event) - - def onFocus(self, event): - """Callback for focus: accessibility events.""" - - # This event is deprecated. We should get object:state-changed:focused - # events instead. - - if super().onFocus(event): - return - - msg = "CHROMIUM: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onFocus(self, event) - - def onFocusedChanged(self, event): + def _on_focused_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:focused accessibility events.""" - if self.utilities.isDocument(event.source) \ - and not self.utilities.documentFrameURI(event.source): + if self.utilities.is_document(event.source) and not AXDocument.get_uri(event.source): msg = "CHROMIUM: Ignoring event from document with no URI." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - if super().onFocusedChanged(event): - return + return super()._on_focused_changed(event) - msg = "CHROMIUM: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onFocusedChanged(self, event) - - def onMouseButton(self, event): - """Callback for mouse:button accessibility events.""" - - if super().onMouseButton(event): - return - - msg = "CHROMIUM: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onMouseButton(self, event) - - def onNameChanged(self, event): - """Callback for object:property-change:accessible-name events.""" - - if super().onNameChanged(event): - return - - msg = "CHROMIUM: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onNameChanged(self, event) - - def onRowReordered(self, event): - """Callback for object:row-reordered accessibility events.""" - - if super().onRowReordered(event): - return - - msg = "CHROMIUM: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onRowReordered(self, event) - - def onSelectedChanged(self, event): + def _on_selected_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:selected accessibility events.""" - if super().onSelectedChanged(event): - return - - if event.detail1 and not self.utilities.inDocumentContent(event.source): - # The popup for an input with autocomplete on is a listbox child of a nameless frame. - # It lives outside of the document and also doesn't fire selection-changed events. - if listbox := AXObject.find_ancestor_inclusive(event.source, AXUtilities.is_list_box): + if event.detail1 and not self.utilities.in_document_content(event.source): # type: ignore + if listbox := AXUtilities.find_ancestor(event.source, AXUtilities.is_list_box): parent = AXObject.get_parent(listbox) if AXUtilities.is_frame(parent) and not AXObject.get_name(parent): msg = "CHROMIUM: Event source believed to be in autocomplete popup" - debug.printMessage(debug.LEVEL_INFO, msg, True) - cthulhu_state.locusOfFocus = event.source - return + debug.print_message(debug.LEVEL_INFO, msg, True) + focus_manager.get_manager().set_locus_of_focus(event, event.source) + return True - msg = "CHROMIUM: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onSelectedChanged(self, event) + return super()._on_selected_changed(event) - def onSelectionChanged(self, event): - """Callback for object:selection-changed accessibility events.""" - - if super().onSelectionChanged(event): - return - - msg = "CHROMIUM: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onSelectionChanged(self, event) - - def onShowingChanged(self, event): - """Callback for object:state-changed:showing accessibility events.""" - - if event.detail1 and self.utilities.isMenuWithNoSelectedChild(event.source): - topLevel = self.utilities.topLevelObject(event.source) - if self.utilities.canBeActiveWindow(topLevel): - cthulhu.setActiveWindow(topLevel) - self.presentObject(event.source) - cthulhu.setLocusOfFocus(event, event.source, False) - return - - if super().onShowingChanged(event): - return - - msg = "CHROMIUM: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onShowingChanged(self, event) - - def onTextAttributesChanged(self, event): - """Callback for object:text-attributes-changed accessibility events.""" - - if super().onTextAttributesChanged(event): - return - - msg = "CHROMIUM: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onTextAttributesChanged(self, event) - - def onTextDeleted(self, event): - """Callback for object:text-changed:delete accessibility events.""" - - if super().onTextDeleted(event): - return - - msg = "CHROMIUM: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onTextDeleted(self, event) - - def onTextInserted(self, event): - """Callback for object:text-changed:insert accessibility events.""" - - if super().onTextInserted(event): - return - - msg = "CHROMIUM: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onTextInserted(self, event) - - def onTextSelectionChanged(self, event): + def _on_text_selection_changed(self, event: Atspi.Event) -> bool: """Callback for object:text-selection-changed accessibility events.""" - if self.utilities.isStaticTextLeaf(event.source): - msg = "CHROMIUM: Ignoring event from static-text leaf" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + if not AXUtilities.is_web_element(event.source) and AXUtilities.is_web_element( + AXObject.get_parent(event.source), + ): + msg = "CHROMIUM: Ignoring because source is not an element" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - if self.utilities.isListItemMarker(event.source): - msg = "CHROMIUM: Ignoring event from list item marker" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + return super()._on_text_selection_changed(event) - if super().onTextSelectionChanged(event): - return - - msg = "CHROMIUM: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onTextSelectionChanged(self, event) - - def onWindowActivated(self, event): + def _on_window_activated(self, event: Atspi.Event) -> bool: """Callback for window:activate accessibility events.""" - if not self.utilities.canBeActiveWindow(event.source): - return + super()._on_window_activated(event) - # If this is a frame for a popup menu, we don't want to treat - # it like a proper window:activate event because it's not as - # far as the end-user experience is concerned. - menu = self.utilities.popupMenuForFrame(event.source) - if menu: - cthulhu.setActiveWindow(event.source) - - activeItem = None - selected = self.utilities.selectedChildren(menu) - if len(selected) == 1: - activeItem = selected[0] - - if activeItem: - # If this is the popup menu for the locusOfFocus, we don't want to - # present the popup menu as part of the new ancestry of activeItem. - if self.utilities.isPopupMenuForCurrentItem(menu): - cthulhu.setLocusOfFocus(event, menu, False) - - tokens = ["CHROMIUM: Setting locusOfFocus to active item", activeItem] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - cthulhu.setLocusOfFocus(event, activeItem) - return - - tokens = ["CHROMIUM: Setting locusOfFocus to popup menu", menu] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - cthulhu.setLocusOfFocus(event, menu) - - if super().onWindowActivated(event): - return - - msg = "CHROMIUM: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onWindowActivated(self, event) - - # Right now we don't get accessibility events for alerts which are - # already showing at the time of window activation. If that changes, - # we should store presented alerts so we don't double-present them. for child in AXObject.iter_children(event.source): if AXUtilities.is_alert(child): - self.presentObject(child) + self.present_object(child) - def onWindowDeactivated(self, event): - """Callback for window:deactivate accessibility events.""" - - if super().onWindowDeactivated(event): - return - - msg = "CHROMIUM: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onWindowDeactivated(self, event) + return True diff --git a/src/cthulhu/scripts/toolkits/Gecko/script.py b/src/cthulhu/scripts/toolkits/Gecko/script.py index f856d8f..6c8c9d2 100644 --- a/src/cthulhu/scripts/toolkits/Gecko/script.py +++ b/src/cthulhu/scripts/toolkits/Gecko/script.py @@ -1,9 +1,8 @@ -#!/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 2005-2009 Sun Microsystems Inc. +# Copyright 2010 Cthulhu Team. +# Copyright 2014-2015 Igalia, S.L. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -19,367 +18,56 @@ # 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 -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2005-2009 Sun Microsystems Inc." \ - "Copyright (c) 2010 Cthulhu Team." \ - "Copyright (c) 2014-2015 Igalia, S.L." -__license__ = "LGPL" +"""Custom script for Gecko.""" -import gi -gi.require_version("Atspi", "2.0") -from gi.repository import Atspi +from __future__ import annotations -from cthulhu import debug -from cthulhu import cthulhu -from cthulhu import cthulhu_state -from cthulhu.ax_object import AXObject +from typing import TYPE_CHECKING + +from cthulhu import debug, focus_manager from cthulhu.ax_utilities import AXUtilities -from cthulhu.scripts import default from cthulhu.scripts import web -from .script_utilities import Utilities + +if TYPE_CHECKING: + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi -class Script(web.Script): +class Script(web.ToolkitBridge): + """Custom script for Gecko.""" - def __init__(self, app): - super().__init__(app) - - self.presentIfInactive = False - - def getUtilities(self): - """Returns the utilities for this script.""" - - return Utilities(self) - - def isActivatableEvent(self, event): - if event.type == "window:activate": - return self.utilities.canBeActiveWindow(event.source) - - return super().isActivatableEvent(event) - - def locus_of_focus_changed(self, event, oldFocus, newFocus): - """Handles changes of focus of interest to the script.""" - - if super().locus_of_focus_changed(event, oldFocus, newFocus): - return - - msg = "GECKO: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.locus_of_focus_changed(self, event, oldFocus, newFocus) - - def onActiveChanged(self, event): - """Callback for object:state-changed:active accessibility events.""" - - if super().onActiveChanged(event): - return - - if event.detail1 and AXObject.get_role(event.source) == Atspi.Role.FRAME \ - and not self.utilities.canBeActiveWindow(event.source): - return - - msg = "GECKO: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onActiveChanged(self, event) - - def onActiveDescendantChanged(self, event): - """Callback for object:active-descendant-changed accessibility events.""" - - if super().onActiveDescendantChanged(event): - return - - msg = "GECKO: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onActiveDescendantChanged(self, event) - - def onBusyChanged(self, event): - """Callback for object:state-changed:busy accessibility events.""" - - if self.utilities.isNotRealDocument(event.source): - msg = "GECKO: Ignoring: Event source is not real document" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - if super().onBusyChanged(event): - return - - msg = "GECKO: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onBusyChanged(self, event) - - def onCaretMoved(self, event): - """Callback for object:text-caret-moved accessibility events.""" - - if super().onCaretMoved(event): - return - - msg = "GECKO: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onCaretMoved(self, event) - - def onCheckedChanged(self, event): - """Callback for object:state-changed:checked accessibility events.""" - - if super().onCheckedChanged(event): - return - - msg = "GECKO: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onCheckedChanged(self, event) - - def onColumnReordered(self, event): - """Callback for object:column-reordered accessibility events.""" - - if super().onColumnReordered(event): - return - - msg = "GECKO: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onColumnReordered(self, event) - - def onChildrenAdded(self, event): - """Callback for object:children-changed:add accessibility events.""" - - if super().onChildrenAdded(event): - return - - msg = "GECKO: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onChildrenAdded(self, event) - - def onChildrenRemoved(self, event): - """Callback for object:children-changed:removed accessibility events.""" - - if super().onChildrenRemoved(event): - return - - msg = "GECKO: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onChildrenRemoved(self, event) - - def onDocumentLoadComplete(self, event): - """Callback for document:load-complete accessibility events.""" - - if self.utilities.isNotRealDocument(event.source): - msg = "GECKO: Ignoring: Event source is not real document" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - if super().onDocumentLoadComplete(event): - return - - msg = "GECKO: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onDocumentLoadComplete(self, event) - - def onDocumentLoadStopped(self, event): - """Callback for document:load-stopped accessibility events.""" - - if super().onDocumentLoadStopped(event): - return - - msg = "GECKO: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onDocumentLoadStopped(self, event) - - def onDocumentReload(self, event): - """Callback for document:reload accessibility events.""" - - if super().onDocumentReload(event): - return - - msg = "GECKO: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onDocumentReload(self, event) - - def onExpandedChanged(self, event): - """Callback for object:state-changed:expanded accessibility events.""" - - if super().onExpandedChanged(event): - return - - msg = "GECKO: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onExpandedChanged(self, event) - - def onFocus(self, event): - """Callback for focus: accessibility events.""" - - # This event is deprecated. We should get object:state-changed:focused - # events instead. - - if super().onFocus(event): - return - - if self.utilities.isLayoutOnly(event.source): - return - - if event.source == cthulhu_state.activeWindow: - msg = "GECKO: Ignoring event for active window." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - # NOTE: This event type is deprecated and Cthulhu should no longer use it. - # This callback remains just to handle bugs in applications and toolkits - # in which object:state-changed:focused events are missing. And in the - # case of Gecko dialogs, that seems to happen a lot. - cthulhu.setLocusOfFocus(event, event.source) - - def onFocusedChanged(self, event): + def _on_focused_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:focused accessibility events.""" - if super().onFocusedChanged(event): - return - - if AXObject.get_role(event.source) == Atspi.Role.PANEL: - if cthulhu_state.locusOfFocus == cthulhu_state.activeWindow: + if AXUtilities.is_panel(event.source): + if focus_manager.get_manager().focus_is_active_window(): msg = "GECKO: Ignoring event believed to be noise." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - # We're sometimes getting a spurious focus claim from the Firefox/Thunderbird - # window after opening a file from file manager. - if AXObject.get_role(event.source) == Atspi.Role.FRAME: + if AXUtilities.is_frame(event.source): msg = "GECKO: Ignoring event believed to be noise." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - msg = "GECKO: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onFocusedChanged(self, event) + return super()._on_focused_changed(event) - def onMouseButton(self, event): - """Callback for mouse:button accessibility events.""" - - if super().onMouseButton(event): - return - - msg = "GECKO: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onMouseButton(self, event) - - def onNameChanged(self, event): - """Callback for object:property-change:accessible-name events.""" - - if super().onNameChanged(event): - return - - msg = "GECKO: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onNameChanged(self, event) - - def onRowReordered(self, event): - """Callback for object:row-reordered accessibility events.""" - - if super().onRowReordered(event): - return - - msg = "GECKO: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onRowReordered(self, event) - - def onSelectedChanged(self, event): - """Callback for object:state-changed:selected accessibility events.""" - - if super().onSelectedChanged(event): - return - - msg = "GECKO: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onSelectedChanged(self, event) - - def onSelectionChanged(self, event): - """Callback for object:selection-changed accessibility events.""" - - if super().onSelectionChanged(event): - return - - msg = "GECKO: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onSelectionChanged(self, event) - - def onShowingChanged(self, event): + def _on_showing_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:showing accessibility events.""" - if super().onShowingChanged(event): - return - - # Set focus to newly shown menus outside of document content - if event.detail1 and AXUtilities.is_menu(event.source) \ - and not self.utilities.inDocumentContent(event.source): + # TODO - JD: Is this workaround still needed? It is here because we normally get a + # window:activate event when a context menu is shown, but not in the case of Gecko. + if ( + event.detail1 + and AXUtilities.is_menu(event.source) + and not self.utilities.in_document_content(event.source) + ): msg = "GECKO: Setting locus of focus to newly shown menu." - debug.printMessage(debug.LEVEL_INFO, msg, True) - cthulhu.setLocusOfFocus(event, event.source) - return + debug.print_message(debug.LEVEL_INFO, msg, True) + focus_manager.get_manager().set_locus_of_focus(event, event.source) + return True - msg = "GECKO: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onShowingChanged(self, event) - - def onTextAttributesChanged(self, event): - """Callback for object:text-attributes-changed accessibility events.""" - - if super().onTextAttributesChanged(event): - return - - msg = "GECKO: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onTextAttributesChanged(self, event) - - def onTextDeleted(self, event): - """Callback for object:text-changed:delete accessibility events.""" - - if super().onTextDeleted(event): - return - - msg = "GECKO: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onTextDeleted(self, event) - - def onTextInserted(self, event): - """Callback for object:text-changed:insert accessibility events.""" - - if super().onTextInserted(event): - return - - msg = "GECKO: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onTextInserted(self, event) - - def onTextSelectionChanged(self, event): - """Callback for object:text-selection-changed accessibility events.""" - - if super().onTextSelectionChanged(event): - return - - msg = "GECKO: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onTextSelectionChanged(self, event) - - def onWindowActivated(self, event): - """Callback for window:activate accessibility events.""" - - if not self.utilities.canBeActiveWindow(event.source): - return - - if super().onWindowActivated(event): - return - - msg = "GECKO: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onWindowActivated(self, event) - - def onWindowDeactivated(self, event): - """Callback for window:deactivate accessibility events.""" - - if super().onWindowDeactivated(event): - return - - msg = "GECKO: Passing along event to default script" - debug.printMessage(debug.LEVEL_INFO, msg, True) - default.Script.onWindowDeactivated(self, event) + return super()._on_showing_changed(event) diff --git a/src/cthulhu/scripts/toolkits/Qt/script.py b/src/cthulhu/scripts/toolkits/Qt/script.py index e61c975..363d852 100644 --- a/src/cthulhu/scripts/toolkits/Qt/script.py +++ b/src/cthulhu/scripts/toolkits/Qt/script.py @@ -1,9 +1,8 @@ -#!/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 (C) 2013-2019 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,73 +18,74 @@ # 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 -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2013-2019 Igalia, S.L." -__license__ = "LGPL" +"""Custom script for Qt.""" -import cthulhu.debug as debug -import cthulhu.cthulhu as cthulhu -import cthulhu.scripts.default as default +from __future__ import annotations + +from typing import TYPE_CHECKING + +from cthulhu import debug, focus_manager from cthulhu.ax_object import AXObject from cthulhu.ax_utilities import AXUtilities +from cthulhu.scripts import default from .script_utilities import Utilities +if TYPE_CHECKING: + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + + class Script(default.Script): + """Custom script for Qt.""" - def __init__(self, app): - super().__init__(app) - - def getUtilities(self): + def get_utilities(self) -> Utilities: return Utilities(self) - def onCaretMoved(self, event): + def _on_caret_moved(self, event: Atspi.Event) -> bool: """Callback for object:text-caret-moved accessibility events.""" if AXUtilities.is_accelerator_label(event.source): msg = "QT: Ignoring event due to role." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - super().onCaretMoved(event) + return super()._on_caret_moved(event) - def onFocusedChanged(self, event): + def _on_focused_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:focused accessibility events.""" if not event.detail1: - return + return True if AXUtilities.is_accelerator_label(event.source): msg = "QT: Ignoring event due to role." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - frame = self.utilities.topLevelObject(event.source) + frame = self.utilities.top_level_object(event.source) if not frame: msg = "QT: Ignoring event because we couldn't find an ancestor window." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - isActive = AXUtilities.is_active(frame) - if not isActive: + is_active = AXUtilities.is_active(frame) + if not is_active: tokens = ["QT: Event came from inactive top-level object", frame] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) - AXObject.clear_cache(frame) - isActive = AXUtilities.is_active(frame) - tokens = ["QT: Cleared cache of", frame, ". Frame is now active:", isActive] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + AXObject.clear_cache(frame, False, "Ensuring we have correct active state.") + is_active = AXUtilities.is_active(frame) + tokens = ["QT: Cleared cache of", frame, ". Frame is now active:", is_active] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) if AXUtilities.is_focused(event.source): - super().onFocusedChanged(event) - return + return super()._on_focused_changed(event) msg = "QT: WARNING - source lacks focused state. Setting focus anyway." - debug.printMessage(debug.LEVEL_INFO, msg, True) - cthulhu.setLocusOfFocus(event, event.source) + debug.print_message(debug.LEVEL_INFO, msg, True) + focus_manager.get_manager().set_locus_of_focus(event, event.source) + return True diff --git a/src/cthulhu/scripts/toolkits/WebKitGtk/script.py b/src/cthulhu/scripts/toolkits/WebKitGtk/script.py index 748d8b6..3347a64 100644 --- a/src/cthulhu/scripts/toolkits/WebKitGtk/script.py +++ b/src/cthulhu/scripts/toolkits/WebKitGtk/script.py @@ -1,9 +1,8 @@ -#!/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 2024 Igalia, S.L. +# Copyright 2024 GNOME Foundation Inc. +# 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,663 +18,39 @@ # 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 -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (C) 2010-2011 The Cthulhu Team" \ - "Copyright (C) 2011-2012 Igalia, S.L." -__license__ = "LGPL" +"""Custom script for WebKitGtk.""" -import gi -gi.require_version("Atspi", "2.0") -from gi.repository import Atspi +from __future__ import annotations -import cthulhu.scripts.default as default -import cthulhu.cmdnames as cmdnames -import cthulhu.debug as debug -import cthulhu.guilabels as guilabels -import cthulhu.input_event as input_event -import cthulhu.messages as messages -import cthulhu.cthulhu as cthulhu -import cthulhu.settings as settings -import cthulhu.settings_manager as settings_manager -import cthulhu.speechserver as speechserver -import cthulhu.cthulhu_state as cthulhu_state -import cthulhu.speech as speech -import cthulhu.structural_navigation as structural_navigation -from cthulhu.ax_object import AXObject -from cthulhu.ax_hypertext import AXHypertext -from cthulhu.ax_text import AXText +from typing import TYPE_CHECKING + +from cthulhu import focus_manager from cthulhu.ax_utilities import AXUtilities +from cthulhu.scripts import web +from cthulhu.scripts.toolkits import gtk -from .braille_generator import BrailleGenerator -from .speech_generator import SpeechGenerator -from .script_utilities import Utilities +if TYPE_CHECKING: + import gi -_settingsManager = settings_manager.getManager() + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi -######################################################################## -# # -# The WebKitGtk script class. # -# # -######################################################################## -class Script(default.Script): +class Script(web.ToolkitBridge, gtk.Script): + """Custom script for WebKitGtk.""" - def __init__(self, app): - """Creates a new script for WebKitGtk applications. - - Arguments: - - app: the application to create a script for. - """ - - super().__init__(app) - self._loadingDocumentContent = False - self._lastCaretContext = None, -1 - self.sayAllOnLoadCheckButton = None - - if _settingsManager.getSetting('sayAllOnLoad') is None: - _settingsManager.setSetting('sayAllOnLoad', True) - - def setupInputEventHandlers(self): - """Defines InputEventHandler fields for this script that can be - called by the key and braille bindings.""" - - default.Script.setupInputEventHandlers(self) - self.inputEventHandlers.update( - self.structuralNavigation.inputEventHandlers) - - self.inputEventHandlers["sayAllHandler"] = \ - input_event.InputEventHandler( - Script.sayAll, - cmdnames.SAY_ALL) - - self.inputEventHandlers["panBrailleLeftHandler"] = \ - input_event.InputEventHandler( - Script.panBrailleLeft, - cmdnames.PAN_BRAILLE_LEFT, - False) # Do not enable learn mode for this action - - self.inputEventHandlers["panBrailleRightHandler"] = \ - input_event.InputEventHandler( - Script.panBrailleRight, - cmdnames.PAN_BRAILLE_RIGHT, - False) # Do not enable learn mode for this action - - def getToolkitKeyBindings(self): - """Returns the toolkit-specific keybindings for this script.""" - - return self.structuralNavigation.keyBindings - - def getAppPreferencesGUI(self): - """Return a GtkGrid containing the application unique configuration - GUI items for the current application.""" - - from gi.repository import Gtk - - grid = Gtk.Grid() - grid.set_border_width(12) - - label = guilabels.READ_PAGE_UPON_LOAD - self.sayAllOnLoadCheckButton = \ - Gtk.CheckButton.new_with_mnemonic(label) - self.sayAllOnLoadCheckButton.set_active( - _settingsManager.getSetting('sayAllOnLoad')) - grid.attach(self.sayAllOnLoadCheckButton, 0, 0, 1, 1) - - grid.show_all() - - return grid - - def getPreferencesFromGUI(self): - """Returns a dictionary with the app-specific preferences.""" - - return {'sayAllOnLoad': self.sayAllOnLoadCheckButton.get_active()} - - def getBrailleGenerator(self): - """Returns the braille generator for this script.""" - - return BrailleGenerator(self) - - def getSpeechGenerator(self): - """Returns the speech generator for this script.""" - - return SpeechGenerator(self) - - def getEnabledStructuralNavigationTypes(self): - """Returns a list of the structural navigation object types - enabled in this script.""" - - return [structural_navigation.StructuralNavigation.BLOCKQUOTE, - structural_navigation.StructuralNavigation.BUTTON, - structural_navigation.StructuralNavigation.CHECK_BOX, - structural_navigation.StructuralNavigation.CHUNK, - structural_navigation.StructuralNavigation.CLICKABLE, - structural_navigation.StructuralNavigation.COMBO_BOX, - structural_navigation.StructuralNavigation.CONTAINER, - structural_navigation.StructuralNavigation.ENTRY, - structural_navigation.StructuralNavigation.FORM_FIELD, - structural_navigation.StructuralNavigation.HEADING, - structural_navigation.StructuralNavigation.IFRAME, - structural_navigation.StructuralNavigation.IMAGE, - structural_navigation.StructuralNavigation.LANDMARK, - structural_navigation.StructuralNavigation.LINK, - structural_navigation.StructuralNavigation.LIST, - structural_navigation.StructuralNavigation.LIST_ITEM, - structural_navigation.StructuralNavigation.LIVE_REGION, - structural_navigation.StructuralNavigation.PARAGRAPH, - structural_navigation.StructuralNavigation.RADIO_BUTTON, - structural_navigation.StructuralNavigation.SEPARATOR, - structural_navigation.StructuralNavigation.TABLE, - structural_navigation.StructuralNavigation.TABLE_CELL, - structural_navigation.StructuralNavigation.UNVISITED_LINK, - structural_navigation.StructuralNavigation.VISITED_LINK] - - def getUtilities(self): - """Returns the utilities for this script.""" - - return Utilities(self) - - def onCaretMoved(self, event): + def _on_caret_moved(self, event: Atspi.Event) -> bool: """Callback for object:text-caret-moved accessibility events.""" - if self._inSayAll: - return - - if not self.utilities.isWebKitGtk(event.source): - super().onCaretMoved(event) - return - - lastKey, mods = self.utilities.lastKeyAndModifiers() - if lastKey in ['Tab', 'ISO_Left_Tab']: - return - - if lastKey == 'Down' \ - and cthulhu_state.locusOfFocus == AXObject.get_parent(event.source) \ - and AXObject.get_index_in_parent(event.source) == 0 \ - and AXUtilities.is_link(cthulhu_state.locusOfFocus): - self.updateBraille(event.source) - return - - self.utilities.setCaretContext(event.source, event.detail1) - super().onCaretMoved(event) - - def onDocumentReload(self, event): - """Callback for document:reload accessibility events.""" - - if self.utilities.treatAsBrowser(event.source): - self._loadingDocumentContent = True - - def onDocumentLoadComplete(self, event): - """Callback for document:load-complete accessibility events.""" - - if not self.utilities.treatAsBrowser(event.source): - return - - self._loadingDocumentContent = False - - # TODO: We need to see what happens in Epiphany on pages where focus - # is grabbed rather than set the caret at the start. But for simple - # content in both Yelp and Epiphany this is alright for now. - obj, offset = self.utilities.setCaretAtStart(event.source) - self.utilities.setCaretContext(obj, offset) - - self.updateBraille(obj) - if _settingsManager.getSetting('sayAllOnLoad') \ - and _settingsManager.getSetting('enableSpeech'): - self.sayAll(None) - - def onDocumentLoadStopped(self, event): - """Callback for document:load-stopped accessibility events.""" - - if self.utilities.treatAsBrowser(event.source): - self._loadingDocumentContent = False - - def onFocusedChanged(self, event): - """Callback for object:state-changed:focused accessibility events.""" - - if self._inSayAll or not event.detail1: - return - - if not self.utilities.isWebKitGtk(event.source): - super().onFocusedChanged(event) - return - - contextObj, offset = self.utilities.getCaretContext() - if event.source == contextObj: - return - - obj = event.source - role = AXObject.get_role(obj) - textRoles = [Atspi.Role.HEADING, - Atspi.Role.PANEL, - Atspi.Role.PARAGRAPH, - Atspi.Role.SECTION, - Atspi.Role.TABLE_CELL] - if role in textRoles \ - or (role == Atspi.Role.LIST_ITEM and AXObject.get_child_count(obj)): - return - - super().onFocusedChanged(event) - - def onBusyChanged(self, event): - """Callback for object:state-changed:busy accessibility events.""" - - if not self.utilities.treatAsBrowser(event.source): - return - - if event.detail1: - self.presentMessage(messages.PAGE_LOADING_START) - return - - name = AXObject.get_name(event.source) - if name: - self.presentMessage(messages.PAGE_LOADING_END_NAMED % name) - else: - self.presentMessage(messages.PAGE_LOADING_END) - - def sayCharacter(self, obj): - """Speak the character at the caret. - - Arguments: - - obj: an Accessible object that implements the AccessibleText interface - """ - - if AXUtilities.is_entry(obj): - default.Script.sayCharacter(self, obj) - return - - boundary = Atspi.TextBoundaryType.CHAR - objects = self.utilities.get_objectsFromEOCs(obj, boundary=boundary) - for (obj, start, end, string) in objects: - if string: - self.speakCharacter(string) - else: - speech.speak(self.speechGenerator.generateSpeech(obj)) - - self.pointOfReference["lastTextUnitSpoken"] = "char" - - def sayWord(self, obj): - """Speaks the word at the caret. - - Arguments: - - obj: an Accessible object that implements the AccessibleText interface - """ - - if AXUtilities.is_entry(obj): - default.Script.sayWord(self, obj) - return - - boundary = Atspi.TextBoundaryType.WORD_START - objects = self.utilities.get_objectsFromEOCs(obj, boundary=boundary) - for (obj, start, end, string) in objects: - self.sayPhrase(obj, start, end) - - self.pointOfReference["lastTextUnitSpoken"] = "word" - - def sayLine(self, obj): - """Speaks the line at the caret. - - Arguments: - - obj: an Accessible object that implements the AccessibleText interface - """ - - if AXUtilities.is_entry(obj): - default.Script.sayLine(self, obj) - return - - boundary = Atspi.TextBoundaryType.LINE_START - objects = self.utilities.get_objectsFromEOCs(obj, boundary=boundary) - for (obj, start, end, string) in objects: - self.sayPhrase(obj, start, end) - - # TODO: Move these next items into the speech generator. - if AXUtilities.is_panel(obj) and AXObject.get_index_in_parent(obj) == 0: - obj = AXObject.get_parent(obj) - - rolesToSpeak = [Atspi.Role.HEADING, Atspi.Role.LINK] - if AXObject.get_role(obj) in rolesToSpeak: - speech.speak(self.speechGenerator.getRoleName(obj)) - - self.pointOfReference["lastTextUnitSpoken"] = "line" - - def sayPhrase(self, obj, startOffset, endOffset): - """Speaks the text of an Accessible object between the given offsets. - - Arguments: - - obj: an Accessible object that implements the AccessibleText interface - - startOffset: the start text offset. - - endOffset: the end text offset. - """ - - if AXUtilities.is_entry(obj): - default.Script.sayPhrase(self, obj, startOffset, endOffset) - return - - phrase = self.utilities.substring(obj, startOffset, endOffset) - if len(phrase) and phrase != "\n": - voice = self.speechGenerator.voice(obj=obj, string=phrase) - phrase = self.utilities.adjustForRepeats(phrase) - links = [x for x in AXObject.iter_children(obj, AXUtilities.is_link)] - if links: - phrase = self.utilities.adjustForLinks(obj, phrase, startOffset) - speech.speak(phrase, voice) - else: - # Speak blank line if appropriate. - # - self.sayCharacter(obj) - - self.pointOfReference["lastTextUnitSpoken"] = "phrase" - - def skipObjectEvent(self, event): - """Gives us, and scripts, the ability to decide an event isn't - worth taking the time to process under the current circumstances. - - Arguments: - - event: the Event - - Returns True if we shouldn't bother processing this object event. - """ - - if event.type.startswith('object:state-changed:focused') and event.detail1 \ - and AXUtilities.is_link(event.source): - return False - - return default.Script.skipObjectEvent(self, event) - - def useStructuralNavigationModel(self, debugOutput=True): - """Returns True if we should do our own structural navigation. - This should return False if we're in a form field, or not in - document content. - """ - - doNotHandleRoles = [Atspi.Role.ENTRY, - Atspi.Role.TEXT, - Atspi.Role.PASSWORD_TEXT, - Atspi.Role.LIST, - Atspi.Role.LIST_ITEM, - Atspi.Role.MENU_ITEM] - - if not self.structuralNavigation.enabled: - return False - - if not self.utilities.isWebKitGtk(cthulhu_state.locusOfFocus): - return False - - if AXUtilities.is_editable(cthulhu_state.locusOfFocus): - return False - - role = AXObject.get_role(cthulhu_state.locusOfFocus) - if role in doNotHandleRoles: - if role == Atspi.Role.LIST_ITEM: - return not AXUtilities.is_selectable(cthulhu_state.locusOfFocus) - - if AXUtilities.is_focused(cthulhu_state.locusOfFocus): - return False - - return True - - def panBrailleLeft(self, inputEvent=None, panAmount=0): - """In document content, we want to use the panning keys to browse the - entire document. - """ - - if self.flatReviewPresenter.is_active() \ - or not self.isBrailleBeginningShowing() \ - or not self.utilities.isWebKitGtk(cthulhu_state.locusOfFocus): - return default.Script.panBrailleLeft(self, inputEvent, panAmount) - - obj = self.utilities.findPreviousObject(cthulhu_state.locusOfFocus) - cthulhu.setLocusOfFocus(None, obj, notifyScript=False) - self.updateBraille(obj) - - # Hack: When panning to the left in a document, we want to start at - # the right/bottom of each new object. For now, we'll pan there. - # When time permits, we'll give our braille code some smarts. - while self.panBrailleInDirection(panToLeft=False): - pass - self.refreshBraille(False) - - return True - - def panBrailleRight(self, inputEvent=None, panAmount=0): - """In document content, we want to use the panning keys to browse the - entire document. - """ - - if self.flatReviewPresenter.is_active() \ - or not self.isBrailleEndShowing() \ - or not self.utilities.isWebKitGtk(cthulhu_state.locusOfFocus): - return default.Script.panBrailleRight(self, inputEvent, panAmount) - - obj = self.utilities.findNextObject(cthulhu_state.locusOfFocus) - cthulhu.setLocusOfFocus(None, obj, notifyScript=False) - self.updateBraille(obj) - - # Hack: When panning to the right in a document, we want to start at - # the left/top of each new object. For now, we'll pan there. When time - # permits, we'll give our braille code some smarts. - while self.panBrailleInDirection(panToLeft=True): - pass - self.refreshBraille(False) - - return True - - def sayAll(self, inputEvent, obj=None, offset=None): - """Speaks the contents of the document beginning with the present - location. Overridden in this script because the sayAll could have - been started on an object without text (such as an image). - """ - - obj = obj or cthulhu_state.locusOfFocus - if not self.utilities.isWebKitGtk(obj): - return default.Script.sayAll(self, inputEvent, obj, offset) - - speech.sayAll(self.textLines(obj, offset), - self.__sayAllProgressCallback) - - return True - - def getTextSegments(self, obj, boundary, offset=0): - segments = [] - if not AXObject.supports_text(obj): - return segments - - length = AXText.get_character_count(obj) - if boundary == Atspi.TextBoundaryType.CHAR: - string, start, end = AXText.get_character_at_offset(obj, offset) - elif boundary == Atspi.TextBoundaryType.WORD_START: - string, start, end = AXText.get_word_at_offset(obj, offset) - elif boundary == Atspi.TextBoundaryType.LINE_START: - string, start, end = AXText.get_line_at_offset(obj, offset) - elif boundary == Atspi.TextBoundaryType.SENTENCE_START: - string, start, end = AXText.get_sentence_at_offset(obj, offset) - elif boundary == Atspi.TextBoundaryType.PARAGRAPH_START: - string, start, end = AXText.get_paragraph_at_offset(obj, offset) - else: - string, start, end = "", 0, 0 - while string and offset < length: - string = self.utilities.adjustForRepeats(string) - voice = self.speechGenerator.getVoiceForString(obj, string) - string = self.utilities.adjustForLinks(obj, string, start) - # Incrementing the offset should cause us to eventually reach - # the end of the text as indicated by a 0-length string and - # start and end offsets of 0. Sometimes WebKitGtk returns the - # final text segment instead. - if segments and [string, start, end, voice] == segments[-1]: - break - - segments.append([string, start, end, voice]) - offset = end + 1 - if boundary == Atspi.TextBoundaryType.CHAR: - string, start, end = AXText.get_character_at_offset(obj, offset) - elif boundary == Atspi.TextBoundaryType.WORD_START: - string, start, end = AXText.get_word_at_offset(obj, offset) - elif boundary == Atspi.TextBoundaryType.LINE_START: - string, start, end = AXText.get_line_at_offset(obj, offset) - elif boundary == Atspi.TextBoundaryType.SENTENCE_START: - string, start, end = AXText.get_sentence_at_offset(obj, offset) - elif boundary == Atspi.TextBoundaryType.PARAGRAPH_START: - string, start, end = AXText.get_paragraph_at_offset(obj, offset) - else: - string, start, end = "", 0, 0 - return segments - - def textLines(self, obj, offset=None): - """Creates a generator that can be used to iterate over each line - of a text object, starting at the caret offset. - - Arguments: - - obj: an Accessible that has a text specialization - - Returns an iterator that produces elements of the form: - [SayAllContext, acss], where SayAllContext has the text to be - spoken and acss is an ACSS instance for speaking the text. - """ - - self._sayAllIsInterrupted = False - self._inSayAll = False - if not obj: - return - - if AXObject.get_role(obj) == Atspi.Role.LINK: - obj = AXObject.get_parent(obj) - - document = self.utilities.getDocumentForObject(obj) - if not document or AXUtilities.is_busy(document): - return - - allTextObjs = self.utilities.findAllDescendants( - document, lambda x: AXObject.supports_text(x)) - allTextObjs = allTextObjs[allTextObjs.index(obj):len(allTextObjs)] - textObjs = [x for x in allTextObjs if AXObject.get_parent(x) not in allTextObjs] - if not textObjs: - return - - boundary = Atspi.TextBoundaryType.LINE_START - sayAllStyle = _settingsManager.getSetting('sayAllStyle') - if sayAllStyle == settings.SAYALL_STYLE_SENTENCE: - boundary = Atspi.TextBoundaryType.SENTENCE_START - - voices = _settingsManager.getSetting('voices') - systemVoice = voices.get(settings.SYSTEM_VOICE) - - self._inSayAll = True - offset = AXText.get_caret_offset(textObjs[0]) - for textObj in textObjs: - textSegments = self.getTextSegments(textObj, boundary, offset) - roleName = self.speechGenerator.getRoleName(textObj) - if roleName: - textSegments.append([roleName, 0, -1, systemVoice]) - - for (string, start, end, voice) in textSegments: - context = speechserver.SayAllContext(textObj, string, start, end) - self._sayAllContexts.append(context) - self.eventSynthesizer.scroll_into_view(obj, start, end) - yield [context, voice] - - offset = 0 - - self._inSayAll = False - self._sayAllContexts = [] - - def __sayAllProgressCallback(self, context, progressType): - if progressType == speechserver.SayAllContext.PROGRESS: - cthulhu.emitRegionChanged( - context.obj, context.currentOffset, context.currentEndOffset, cthulhu.SAY_ALL) - return - - obj = context.obj - cthulhu.setLocusOfFocus(None, obj, notifyScript=False) - - offset = context.currentOffset - if progressType == speechserver.SayAllContext.INTERRUPTED: - self._sayAllIsInterrupted = True - if isinstance(cthulhu_state.lastInputEvent, input_event.KeyboardEvent): - lastKey = cthulhu_state.lastInputEvent.event_string - if lastKey == "Down" and self._fastForwardSayAll(context): - return - elif lastKey == "Up" and self._rewindSayAll(context): - return - - self._inSayAll = False - self._sayAllContexts = [] - if not self._lastCommandWasStructNav: - if AXObject.supports_text(obj): - AXText.set_caret_offset(obj, offset) - cthulhu.emitRegionChanged(obj, offset) - return - - # SayAllContext.COMPLETED doesn't necessarily mean done with SayAll; - # just done with the current object. If we're still in SayAll, we do - # not want to set the caret (and hence set focus) in a link we just - # passed by. - if AXObject.supports_hypertext(obj): - links = AXHypertext.get_all_links(obj) - if [link for link in links - if AXHypertext.get_link_start_offset(link) - <= offset <= AXHypertext.get_link_end_offset(link)]: - return - - cthulhu.emitRegionChanged(obj, offset, mode=cthulhu.SAY_ALL) - if AXObject.supports_text(obj): - AXText.set_caret_offset(obj, offset) - - def getTextLineAtCaret(self, obj, offset=None, startOffset=None, endOffset=None): - """To-be-removed. Returns the string, caretOffset, startOffset.""" - - textLine = super().getTextLineAtCaret(obj, offset, startOffset, endOffset) - string = textLine[0] - if string and string.find(self.EMBEDDED_OBJECT_CHARACTER) == -1 \ - and AXUtilities.is_focused(obj): - return textLine - - textLine[0] = self.utilities.displayedText(obj) - if AXObject.supports_text(obj): - textLine[1] = min(textLine[1], AXText.get_character_count(obj)) - - return textLine - - def updateBraille(self, obj, **args): - """Updates the braille display to show the given object. - - Arguments: - - obj: the Accessible - """ - - if not _settingsManager.getSetting('enableBraille') \ - and not _settingsManager.getSetting('enableBrailleMonitor'): - debug.printMessage(debug.LEVEL_INFO, "BRAILLE: update disabled", True) - return - - if not obj: - return - - if not self.utilities.isWebKitGtk(obj) \ - or (not self.utilities.isInlineContainer(obj) \ - and not self.utilities.isTextListItem(obj)): - default.Script.updateBraille(self, obj, **args) - return - - brailleLine = self.getNewBrailleLine(clearBraille=True, addLine=True) - for child in AXObject.iter_children(obj): - if not self.utilities.onSameLine(child, AXObject.get_child(obj, 0)): - break - [regions, fRegion] = self.brailleGenerator.generateBraille(child) - self.addBrailleRegionsToLine(regions, brailleLine) - - if not brailleLine.regions: - [regions, fRegion] = self.brailleGenerator.generateBraille( - obj, role=Atspi.Role.PARAGRAPH) - self.addBrailleRegionsToLine(regions, brailleLine) - self.setBrailleFocus(fRegion) - - extraRegion = args.get('extraRegion') - if extraRegion: - self.addBrailleRegionToLine(extraRegion, brailleLine) - - self.refreshBraille() + # TODO - JD: This is likely needed for https://bugs.webkit.org/show_bug.cgi?id=268154, + # but we should verify if the default logic now takes care of this for us. + focus = focus_manager.get_manager().get_locus_of_focus() + if not self.utilities.in_document_content(focus): + document = self.utilities.get_document_for_object(event.source) + if document: + ancestor = AXUtilities.find_ancestor(document, AXUtilities.is_focused) + if self.utilities.in_document_content(ancestor): + focus_manager.get_manager().set_locus_of_focus(None, document) + + return super()._on_caret_moved(event) diff --git a/src/cthulhu/scripts/toolkits/gtk/script.py b/src/cthulhu/scripts/toolkits/gtk/script.py index 8234918..4cb201e 100644 --- a/src/cthulhu/scripts/toolkits/gtk/script.py +++ b/src/cthulhu/scripts/toolkits/gtk/script.py @@ -1,9 +1,8 @@ -#!/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 (C) 2013-2014 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,227 +18,150 @@ # 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 -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2013-2014 Igalia, S.L." -__license__ = "LGPL" +"""Custom script for GTK.""" -import cthulhu.debug as debug -import cthulhu.cthulhu as cthulhu -import cthulhu.cthulhu_state as cthulhu_state -import cthulhu.scripts.default as default +from __future__ import annotations + +from typing import TYPE_CHECKING + +from cthulhu import debug, focus_manager from cthulhu.ax_object import AXObject from cthulhu.ax_utilities import AXUtilities +from cthulhu.scripts import default + +if TYPE_CHECKING: + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi -from .script_utilities import Utilities class Script(default.Script): + """Custom script for GTK.""" - def __init__(self, app): - default.Script.__init__(self, app) + def locus_of_focus_changed( + self, + event: Atspi.Event | None, + old_focus: Atspi.Accessible | None, + new_focus: Atspi.Accessible | None, + ) -> bool: + """Handles changes of focus of interest. Returns True if this script did all needed work.""" - def getUtilities(self): - return Utilities(self) + manager = focus_manager.get_manager() + if AXUtilities.is_toggle_button(new_focus): + new_focus = AXUtilities.find_ancestor(new_focus, AXUtilities.is_combo_box) or new_focus + manager.set_locus_of_focus(event, new_focus, False) + elif AXUtilities.find_ancestor(new_focus, AXUtilities.is_menu_bar): + window = self.utilities.top_level_object(new_focus) + if window and manager.get_active_window() != window: + manager.set_active_window(window) - def deactivate(self): - """Called when this script is deactivated.""" + return super().locus_of_focus_changed(event, old_focus, new_focus) - self.utilities.clearCachedObjects() - super().deactivate() - - def locus_of_focus_changed(self, event, oldFocus, newFocus): - """Handles changes of focus of interest to the script.""" - - if self.utilities.isToggleDescendantOfComboBox(newFocus): - newFocus = AXObject.find_ancestor(newFocus, AXUtilities.is_combo_box) or newFocus - cthulhu.setLocusOfFocus(event, newFocus, False) - elif self.utilities.isInOpenMenuBarMenu(newFocus): - window = self.utilities.topLevelObject(newFocus) - windowChanged = window and cthulhu_state.activeWindow != window - if windowChanged: - cthulhu.setActiveWindow(window) - - super().locus_of_focus_changed(event, oldFocus, newFocus) - - def onActiveDescendantChanged(self, event): + def _on_active_descendant_changed(self, event: Atspi.Event) -> bool: """Callback for object:active-descendant-changed accessibility events.""" - if not self.utilities.isTypeahead(cthulhu_state.locusOfFocus): - msg = "GTK: locusOfFocus is not typeahead. Passing along to default script." - debug.printMessage(debug.LEVEL_INFO, msg, True) - super().onActiveDescendantChanged(event) - return + if AXUtilities.is_table_related(event.source): + AXObject.clear_cache(event.any_data, True, "active-descendant-changed event.") + AXUtilities.clear_all_cache_now(event.source, "active-descendant-changed event.") - msg = "GTK: locusOfFocus believed to be typeahead. Presenting change." - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.presentObject(event.any_data, interrupt=True) + focus = focus_manager.get_manager().get_locus_of_focus() + if AXUtilities.is_table_cell(focus): + table = AXUtilities.find_ancestor(focus, AXUtilities.is_tree_or_tree_table) + if table is not None and table != event.source: + msg = "GTK: Event is from a different tree or tree table." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - def onCheckedChanged(self, event): - """Callback for object:state-changed:checked accessibility events.""" + child = AXObject.get_active_descendant_checked(event.source, event.any_data) + if child is not None and child != event.any_data: + tokens = ["GTK: Bogus any_data suspected. Setting focus to", child] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + focus_manager.get_manager().set_locus_of_focus(event, child) + return True - obj = event.source - if self.utilities.isSameObject(obj, cthulhu_state.locusOfFocus): - default.Script.onCheckedChanged(self, event) - return + msg = "GTK: Passing event to super class for processing." + debug.print_message(debug.LEVEL_INFO, msg, True) + return super()._on_active_descendant_changed(event) - # Present changes of child widgets of GtkListBox items - if not AXObject.find_ancestor(obj, AXUtilities.is_list_box): - return + def _on_caret_moved(self, event: Atspi.Event) -> bool: + """Callback for object:text-caret-moved accessibility events.""" - self.presentObject(obj, alreadyFocused=True, interrupt=True) + if not AXUtilities.is_focused(event.source): + AXObject.clear_cache(event.source, False, "Work around possibly-missing focused state.") + return super()._on_caret_moved(event) - def onFocus(self, event): - """Callback for focus: accessibility events.""" - - # NOTE: This event type is deprecated and Cthulhu should no longer use it. - # This callback remains just to handle bugs in applications and toolkits - # that fail to reliably emit object:state-changed:focused events. - - if self.utilities.eventIsCanvasNoise(event): - return - - if self.utilities.isLayoutOnly(event.source): - return - - if event.source == self.mouseReviewer.getCurrentItem(): - msg = "GTK: Event source is current mouse review item" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - if self.utilities.isTypeahead(cthulhu_state.locusOfFocus) \ - and AXObject.supports_table(event.source) \ - and not AXUtilities.is_focused(event.source): - return - - ancestor = AXObject.find_ancestor(cthulhu_state.locusOfFocus, lambda x: x == event.source) - if not ancestor: - cthulhu.setLocusOfFocus(event, event.source) - return - - if AXObject.supports_table(ancestor): - return - - if AXUtilities.is_menu(ancestor): - if AXUtilities.is_selected(cthulhu_state.locusOfFocus): - msg = "GTK: Event source is ancestor of selected focus. Ignoring." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - msg = "GTK: Event source is ancestor of unselected focus. Updating focus." - debug.printMessage(debug.LEVEL_INFO, msg, True) - - cthulhu.setLocusOfFocus(event, event.source) - - def onFocusedChanged(self, event): + def _on_focused_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:focused accessibility events.""" - if self.utilities.isUselessPanel(event.source): - msg = "GTK: Event source believed to be useless panel" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + focus = focus_manager.get_manager().get_locus_of_focus() + if AXUtilities.is_ancestor(focus, event.source) and AXUtilities.is_focused(focus): + msg = "GTK: Ignoring focus change on ancestor of still-focused locusOfFocus" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True - super().onFocusedChanged(event) + return super()._on_focused_changed(event) - def onSelectedChanged(self, event): + def _on_selected_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:selected accessibility events.""" - if self.utilities.isEntryCompletionPopupItem(event.source): + # Handle changes within an entry completion popup. + if ( + AXUtilities.is_table_cell(event.source) + and AXUtilities.find_ancestor(event.source, AXUtilities.is_window) is not None + ): if event.detail1: - cthulhu.setLocusOfFocus(event, event.source) - return - if cthulhu_state.locusOfFocus == event.source: - cthulhu.setLocusOfFocus(event, None) - return + focus_manager.get_manager().set_locus_of_focus(event, event.source) + return True + if focus_manager.get_manager().get_locus_of_focus() == event.source: + focus_manager.get_manager().set_locus_of_focus(event, None) + return True - if AXUtilities.is_icon_or_canvas(event.source) \ - and self.utilities.handleContainerSelectionChange(AXObject.get_parent(event.source)): - return + if AXUtilities.is_icon_or_canvas( + event.source, + ) and self.utilities.handle_container_selection_change(AXObject.get_parent(event.source)): + return True - super().onSelectedChanged(event) + return super()._on_selected_changed(event) - def onSelectionChanged(self, event): + def _on_selection_changed(self, event: Atspi.Event) -> bool: """Callback for object:selection-changed accessibility events.""" - if self.utilities.isComboBoxWithToggleDescendant(event.source) \ - and self.utilities.isOrDescendsFrom(cthulhu_state.locusOfFocus, event.source): - super().onSelectionChanged(event) - return + focus = focus_manager.get_manager().get_locus_of_focus() + if ( + AXUtilities.is_toggle_button(focus) + and AXUtilities.is_combo_box(event.source) + and AXUtilities.is_ancestor(focus, event.source) + ): + return super()._on_selection_changed(event) - isFocused = AXUtilities.is_focused(event.source) - if AXUtilities.is_combo_box(event.source) and not isFocused: - return + is_focused = AXUtilities.is_focused(event.source) + if AXUtilities.is_combo_box(event.source) and not is_focused: + return True - if not isFocused and self.utilities.isTypeahead(cthulhu_state.locusOfFocus): - msg = "GTK: locusOfFocus believed to be typeahead. Presenting change." - debug.printMessage(debug.LEVEL_INFO, msg, True) + if ( + AXUtilities.is_layered_pane(event.source) + and AXUtilities.selected_child_count(event.source) > 1 + ): + return True - selectedChildren = self.utilities.selectedChildren(event.source) - for child in selectedChildren: - if not self.utilities.isLayoutOnly(child): - self.presentObject(child) - return + return super()._on_selection_changed(event) - if AXUtilities.is_layered_pane(event.source) \ - and self.utilities.selectedChildCount(event.source) > 1: - return - - super().onSelectionChanged(event) - - def onShowingChanged(self, event): + def _on_showing_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:showing accessibility events.""" if not event.detail1: - super().onShowingChanged(event) - return + return super()._on_showing_changed(event) - if self.utilities.isPopOver(event.source) \ - or AXUtilities.is_alert(event.source) \ - or AXUtilities.is_info_bar(event.source): + if ( + AXUtilities.get_is_popup_for(event.source) + or AXUtilities.is_alert(event.source) + or AXUtilities.is_info_bar(event.source) + ): if AXUtilities.is_application(AXObject.get_parent(event.source)): - return - self.presentObject(event.source, interrupt=True) - return + return True + self.present_object(event.source, interrupt=True) + return True - super().onShowingChanged(event) - - def onTextDeleted(self, event): - """Callback for object:text-changed:delete accessibility events.""" - - if not self.utilities.isShowingAndVisible(event.source): - tokens = ["GTK:", event.source, "is not showing and visible"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return - - super().onTextDeleted(event) - - def onTextInserted(self, event): - """Callback for object:text-changed:insert accessibility events.""" - - if not self.utilities.isShowingAndVisible(event.source): - tokens = ["GTK:", event.source, "is not showing and visible"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return - - super().onTextInserted(event) - - def onTextSelectionChanged(self, event): - """Callback for object:text-selection-changed accessibility events.""" - - obj = event.source - if not self.utilities.isSameObject(obj, cthulhu_state.locusOfFocus): - return - - default.Script.onTextSelectionChanged(self, event) - - def isActivatableEvent(self, event): - if self.utilities.eventIsCanvasNoise(event): - return False - - if self.utilities.isUselessPanel(event.source): - return False - - return super().isActivatableEvent(event) + return super()._on_showing_changed(event) diff --git a/src/cthulhu/scripts/web/script.py b/src/cthulhu/scripts/web/script.py index db71882..26825cc 100644 --- a/src/cthulhu/scripts/web/script.py +++ b/src/cthulhu/scripts/web/script.py @@ -1,9 +1,8 @@ -#!/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 2005-2009 Sun Microsystems Inc. +# Copyright 2010 Cthulhu Team. +# Copyright 2014-2025 Igalia, S.L. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -19,3078 +18,1770 @@ # 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 -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2005-2009 Sun Microsystems Inc." \ - "Copyright (c) 2010 Cthulhu Team." \ - "Copyright (c) 2014-2015 Igalia, S.L." -__license__ = "LGPL" +# pylint: disable=too-many-boolean-expressions +# pylint: disable=too-many-branches +# pylint: disable=too-many-instance-attributes +# pylint: disable=too-many-lines +# pylint: disable=too-many-locals +# pylint: disable=too-many-return-statements +# pylint: disable=too-many-statements -import time +"""Provides support for accessing user-agent-agnostic web-content.""" -import gi -gi.require_version("Atspi", "2.0") -from gi.repository import Atspi -from gi.repository import Gtk +from __future__ import annotations -from cthulhu import caret_navigation -from cthulhu import cmdnames -from cthulhu import keybindings -from cthulhu import debug -from cthulhu import guilabels -from cthulhu import input_event -from cthulhu import input_event_manager -from cthulhu import liveregions -from cthulhu import messages -from cthulhu import cthulhu -from cthulhu import cthulhu_state -from cthulhu import settings -from cthulhu import settings_manager -from cthulhu import speech -from cthulhu import speechserver -from cthulhu import structural_navigation -from cthulhu import sound_theme_manager -from cthulhu.acss import ACSS -from cthulhu.scripts import default +from typing import TYPE_CHECKING + +from cthulhu import ( + braille_presenter, + caret_navigator, + debug, + document_presenter, + flat_review_presenter, + focus_manager, + input_event, + input_event_manager, + label_inference, + live_region_presenter, + messages, + presentation_manager, + say_all_presenter, + speech_manager, + speech_presenter, + structural_navigator, + table_navigator, +) +from cthulhu.ax_document import AXDocument +from cthulhu.ax_event_synthesizer import AXEventSynthesizer from cthulhu.ax_object import AXObject from cthulhu.ax_text import AXText from cthulhu.ax_utilities import AXUtilities +from cthulhu.ax_utilities_event import TextEventReason +from cthulhu.ax_utilities_text import TextUnit +from cthulhu.scripts import default +from cthulhu.structural_navigator import NavigationMode -from .bookmarks import Bookmarks from .braille_generator import BrailleGenerator -from .sound_generator import SoundGenerator -from .speech_generator import SpeechGenerator -from .tutorial_generator import TutorialGenerator from .script_utilities import Utilities +from .speech_generator import SpeechGenerator -_settingsManager = None # Removed - use cthulhu.cthulhuApp.settingsManager - -def _log(message, reason=None, timestamp=True, stack=False): - debug.print_log(debug.LEVEL_INFO, "WEB", message, reason, timestamp, stack) - -def _log_tokens(tokens, reason=None, timestamp=True, stack=False): - debug.print_log_tokens(debug.LEVEL_INFO, "WEB", tokens, reason, timestamp, stack) +if TYPE_CHECKING: + from gi.repository import Atspi class Script(default.Script): + """Provides support for accessing user-agent-agnostic web-content.""" - def __init__(self, app): + # Type annotations to override the base class types + utilities: Utilities + caret_navigator: caret_navigator.CaretNavigator + + def __init__(self, app: Atspi.Accessible) -> None: super().__init__(app) - self._sayAllContents = [] - self._inSayAll = False - self._sayAllIsInterrupted = False - self._loadingDocumentContent = False - self._madeFindAnnouncement = False - self._lastCommandWasCaretNav = False - self._lastCommandWasStructNav = False - self._lastCommandWasMouseButton = False - self._lastMouseButtonContext = None, -1 - self._lastMouseOverObject = None - self._preMouseOverContext = None, -1 - self._inMouseOverObject = False - self._inFocusMode = False - self._focusModeIsSticky = False - self._browseModeIsSticky = False - self._navSuspended = False - self._structNavWasEnabled = None + self.present_if_inactive: bool = False + self._default_sn_mode = NavigationMode.DOCUMENT + self._default_caret_navigation_enabled: bool = True - if cthulhu.cthulhuApp.settingsManager.getSetting('caretNavigationEnabled') is None: - cthulhu.cthulhuApp.settingsManager.setSetting('caretNavigationEnabled', True) - if cthulhu.cthulhuApp.settingsManager.getSetting('sayAllOnLoad') is None: - cthulhu.cthulhuApp.settingsManager.setSetting('sayAllOnLoad', True) - if cthulhu.cthulhuApp.settingsManager.getSetting('pageSummaryOnLoad') is None: - cthulhu.cthulhuApp.settingsManager.setSetting('pageSummaryOnLoad', True) + self._loading_content = False + self._last_mouse_button_context = None, -1 - self._changedLinesOnlyCheckButton = None - self._controlCaretNavigationCheckButton = None - self._minimumFindLengthAdjustment = None - self._minimumFindLengthLabel = None - self._minimumFindLengthSpinButton = None - self._pageSummaryOnLoadCheckButton = None - self._sayAllOnLoadCheckButton = None - self._skipBlankCellsCheckButton = None - self._speakCellCoordinatesCheckButton = None - self._speakCellHeadersCheckButton = None - self._speakCellSpanCheckButton = None - self._speakResultsDuringFindCheckButton = None - self._structuralNavigationCheckButton = None - self._autoFocusModeStructNavCheckButton = None - self._autoFocusModeCaretNavCheckButton = None - self._autoFocusModeNativeNavCheckButton = None - self._layoutModeCheckButton = None - - self.attributeNamesDict["invalid"] = "text-spelling" - self.attributeNamesDict["text-align"] = "justification" - self.attributeNamesDict["text-indent"] = "indent" - - @staticmethod - def _selectionContentKey(content): - obj, start, end, string = content - return hash(obj), start, end, string - - def _clearSyntheticWebSelection(self): - pointOfReference = getattr(self, "pointOfReference", None) - if isinstance(pointOfReference, dict): - pointOfReference.pop("syntheticWebSelection", None) - - def _compareCaretContexts(self, firstObj, firstOffset, secondObj, secondOffset): - if firstObj == secondObj: - if firstOffset < secondOffset: - return -1 - if firstOffset > secondOffset: - return 1 - return 0 - - return self.utilities.pathComparison(AXObject.get_path(firstObj), AXObject.get_path(secondObj)) - - def _getContentsBetweenCaretContexts(self, startObj, startOffset, endObj, endOffset): - contents = [] - currentObj, currentOffset = startObj, startOffset - seen = set() - - while currentObj and (currentObj, currentOffset) != (endObj, endOffset): - key = (hash(currentObj), currentOffset) - if key in seen: - break - seen.add(key) - - contents.extend(self.utilities.getCharacterContentsAtOffset(currentObj, currentOffset)) - currentObj, currentOffset = self.utilities.nextContext(currentObj, currentOffset) - - return contents - - def _presentSyntheticCaretSelection( - self, - event, - document, - obj, - offset, - focusOverride=None, - ): - if not self.utilities.lastInputEventWasCaretNavWithSelection(): - return False - - if focusOverride is not None: - focusObj, focusOffset = focusOverride - else: - manager = input_event_manager.get_manager() - if manager.last_event_was_forward_caret_selection(): - focusObj, focusOffset = self.utilities.nextContext(obj, offset) - elif manager.last_event_was_backward_caret_selection(): - focusObj, focusOffset = self.utilities.previousContext(obj, offset) - else: - return False - - if not focusObj: - return False - - selectionState = self.pointOfReference.get("syntheticWebSelection", {}) - if selectionState.get("document") == document: - anchorObj = selectionState.get("anchorObj", obj) - anchorOffset = selectionState.get("anchorOffset", offset) - oldContents = selectionState.get("contents", []) - else: - anchorObj, anchorOffset = obj, offset - oldContents = [] - - if self._compareCaretContexts(anchorObj, anchorOffset, focusObj, focusOffset) <= 0: - startObj, startOffset = anchorObj, anchorOffset - endObj, endOffset = focusObj, focusOffset - else: - startObj, startOffset = focusObj, focusOffset - endObj, endOffset = anchorObj, anchorOffset - - newContents = self._getContentsBetweenCaretContexts(startObj, startOffset, endObj, endOffset) - self.utilities.setCaretContext(focusObj, focusOffset, document) - cthulhu.setLocusOfFocus(event, focusObj, False, True) - self.updateBraille(focusObj) - - if not newContents: - self._clearSyntheticWebSelection() - if oldContents: - self.speakContents(oldContents) - self.speakMessage(messages.TEXT_UNSELECTED, interrupt=False) - return True - - self.pointOfReference["syntheticWebSelection"] = { - "document": document, - "anchorObj": anchorObj, - "anchorOffset": anchorOffset, - "focusObj": focusObj, - "focusOffset": focusOffset, - "contents": newContents, - "string": "".join(item[3] for item in newContents), - } - - oldKeys = [self._selectionContentKey(item) for item in oldContents] - newKeys = [self._selectionContentKey(item) for item in newContents] - deltaContents = newContents - message = messages.TEXT_SELECTED - - if oldContents: - if len(newKeys) >= len(oldKeys) and newKeys[:len(oldKeys)] == oldKeys: - deltaContents = newContents[len(oldContents):] - elif len(newKeys) >= len(oldKeys) and newKeys[-len(oldKeys):] == oldKeys: - deltaContents = newContents[:-len(oldContents)] - elif len(oldKeys) >= len(newKeys) and oldKeys[:len(newKeys)] == newKeys: - deltaContents = oldContents[len(newContents):] - message = messages.TEXT_UNSELECTED - elif len(oldKeys) >= len(newKeys) and oldKeys[-len(newKeys):] == newKeys: - deltaContents = oldContents[:-len(newContents)] - message = messages.TEXT_UNSELECTED - - if deltaContents: - self.speakContents(deltaContents) - self.speakMessage(message, interrupt=False) - - return True - - def activate(self): - """Called when this script is activated.""" - - _log_tokens(["Activating script for", self.app]) - - focus = cthulhu_state.locusOfFocus - inApp = AXObject.get_application(focus) == self.app if focus else False - inDoc = self._focusInDocumentContent() - suspend = not (inDoc and inApp) - reason = "activation-outside-document" if suspend else "activation-in-document" - - self._setNavigationSuspended(suspend, reason) - - super().activate() - - def deactivate(self): + def deactivate(self) -> None: """Called when this script is deactivated.""" - self._sayAllContents = [] - self._inSayAll = False - self._sayAllIsInterrupted = False - self._loadingDocumentContent = False - self._madeFindAnnouncement = False - self._lastCommandWasCaretNav = False - self._lastCommandWasStructNav = False - self._lastCommandWasMouseButton = False - self._lastMouseButtonContext = None, -1 - self._lastMouseOverObject = None - self._preMouseOverContext = None, -1 - self._inMouseOverObject = False - self.utilities.clearCachedObjects() - # Ensure navigation commands are re-enabled for the next activation. - self._setNavigationSuspended(False, "script deactivation") - self.removeKeyGrabs() + self._loading_content = False + document_presenter.get_presenter().reset_find_announcement_state() + self._last_mouse_button_context = None, -1 + self.utilities.clear_cached_objects() + super().deactivate() - def _setNavigationSuspended(self, suspend, reason=""): - """Suspend or resume navigation command handling (caret & structural).""" - - if suspend == self._navSuspended: - return - - self._navSuspended = suspend - - # Structural navigation has an enabled flag we can toggle. - if suspend: - self._structNavWasEnabled = self.structuralNavigation.enabled - self.structuralNavigation.enabled = False - else: - if self._structNavWasEnabled is not None: - self.structuralNavigation.enabled = self._structNavWasEnabled - self._structNavWasEnabled = None - - _log_tokens(["Navigation suspended:", suspend], reason) - - def _focusInDocumentContent(self): - focus = cthulhu_state.locusOfFocus - if not focus or AXObject.is_dead(focus): - return False - - return self.utilities.getDocumentForObject(focus) is not None - - def getAppKeyBindings(self): - """Returns the application-specific keybindings for this script.""" - - keyBindings = keybindings.KeyBindings() - - structNavBindings = self.structuralNavigation.keyBindings - for keyBinding in structNavBindings.keyBindings: - keyBindings.add(keyBinding) - - caretNavBindings = self.caretNavigation.get_bindings() - for keyBinding in caretNavBindings.keyBindings: - keyBindings.add(keyBinding) - - liveRegionBindings = self.liveRegionManager.keyBindings - for keyBinding in liveRegionBindings.keyBindings: - keyBindings.add(keyBinding) - - keyBindings.add( - keybindings.KeyBinding( - "a", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self.inputEventHandlers.get("togglePresentationModeHandler"))) - - keyBindings.add( - keybindings.KeyBinding( - "a", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self.inputEventHandlers.get("enableStickyFocusModeHandler"), - 2)) - - keyBindings.add( - keybindings.KeyBinding( - "a", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self.inputEventHandlers.get("enableStickyBrowseModeHandler"), - 3)) - - keyBindings.add( - keybindings.KeyBinding( - "", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self.inputEventHandlers.get("toggleLayoutModeHandler"))) - - - layout = cthulhu.cthulhuApp.settingsManager.getSetting('keyboardLayout') - if layout == settings.GENERAL_KEYBOARD_LAYOUT_DESKTOP: - key = "KP_Multiply" - else: - key = "0" - - keyBindings.add( - keybindings.KeyBinding( - key, - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self.inputEventHandlers.get("moveToMouseOverHandler"))) - - return keyBindings - - def setupInputEventHandlers(self): - """Defines InputEventHandlers for this script.""" - - super().setupInputEventHandlers() - self.inputEventHandlers.update( - self.structuralNavigation.inputEventHandlers) - - self.inputEventHandlers.update( - self.caretNavigation.get_handlers()) - - self.inputEventHandlers.update( - self.liveRegionManager.inputEventHandlers) - - self.inputEventHandlers["sayAllHandler"] = \ - input_event.InputEventHandler( - Script.sayAll, - cmdnames.SAY_ALL) - - self.inputEventHandlers["panBrailleLeftHandler"] = \ - input_event.InputEventHandler( - Script.panBrailleLeft, - cmdnames.PAN_BRAILLE_LEFT, - False) # Do not enable learn mode for this action - - self.inputEventHandlers["panBrailleRightHandler"] = \ - input_event.InputEventHandler( - Script.panBrailleRight, - cmdnames.PAN_BRAILLE_RIGHT, - False) # Do not enable learn mode for this action - - self.inputEventHandlers["moveToMouseOverHandler"] = \ - input_event.InputEventHandler( - Script.moveToMouseOver, - cmdnames.MOUSE_OVER_MOVE) - - self.inputEventHandlers["togglePresentationModeHandler"] = \ - input_event.InputEventHandler( - Script.togglePresentationMode, - cmdnames.TOGGLE_PRESENTATION_MODE) - - self.inputEventHandlers["enableStickyFocusModeHandler"] = \ - input_event.InputEventHandler( - Script.enableStickyFocusMode, - cmdnames.SET_FOCUS_MODE_STICKY) - - self.inputEventHandlers["enableStickyBrowseModeHandler"] = \ - input_event.InputEventHandler( - Script.enableStickyBrowseMode, - cmdnames.SET_BROWSE_MODE_STICKY) - - self.inputEventHandlers["toggleLayoutModeHandler"] = \ - input_event.InputEventHandler( - Script.toggleLayoutMode, - cmdnames.TOGGLE_LAYOUT_MODE) - - self.inputEventHandlers["activateClickableHandler"] = \ - input_event.InputEventHandler( - Script.activateClickableElement, - "Activate clickable element") - - - def getBookmarks(self): - """Returns the "bookmarks" class for this script.""" - - try: - return self.bookmarks - except AttributeError: - self.bookmarks = Bookmarks(self) - return self.bookmarks - - def getBrailleGenerator(self): - """Returns the braille generator for this script.""" + def _create_braille_generator(self) -> BrailleGenerator: + """Creates and returns the braille generator for this script.""" return BrailleGenerator(self) - def getCaretNavigation(self): - """Returns the caret navigation support for this script.""" + def get_label_inference(self) -> label_inference.LabelInference | None: + """Returns the label inference functionality for this script.""" - return caret_navigation.CaretNavigation(self) + return label_inference.LabelInference(self) - def getEnabledStructuralNavigationTypes(self): - """Returns the structural navigation object types for this script.""" - - return [structural_navigation.StructuralNavigation.BLOCKQUOTE, - structural_navigation.StructuralNavigation.BUTTON, - structural_navigation.StructuralNavigation.CHECK_BOX, - structural_navigation.StructuralNavigation.CHUNK, - structural_navigation.StructuralNavigation.CLICKABLE, - structural_navigation.StructuralNavigation.COMBO_BOX, - structural_navigation.StructuralNavigation.CONTAINER, - structural_navigation.StructuralNavigation.ENTRY, - structural_navigation.StructuralNavigation.FORM_FIELD, - structural_navigation.StructuralNavigation.HEADING, - structural_navigation.StructuralNavigation.IFRAME, - structural_navigation.StructuralNavigation.IMAGE, - structural_navigation.StructuralNavigation.LANDMARK, - structural_navigation.StructuralNavigation.LINK, - structural_navigation.StructuralNavigation.LIST, - structural_navigation.StructuralNavigation.LIST_ITEM, - structural_navigation.StructuralNavigation.LIVE_REGION, - structural_navigation.StructuralNavigation.PARAGRAPH, - structural_navigation.StructuralNavigation.RADIO_BUTTON, - structural_navigation.StructuralNavigation.SEPARATOR, - structural_navigation.StructuralNavigation.TABLE, - structural_navigation.StructuralNavigation.TABLE_CELL, - structural_navigation.StructuralNavigation.UNVISITED_LINK, - structural_navigation.StructuralNavigation.VISITED_LINK] - - def getLiveRegionManager(self): - """Returns the live region support for this script.""" - - return liveregions.LiveRegionManager(self) - - def getSoundGenerator(self): - """Returns the sound generator for this script.""" - - return SoundGenerator(self) - - def getSpeechGenerator(self): - """Returns the speech generator for this script.""" + def _create_speech_generator(self) -> SpeechGenerator: + """Creates and returns the speech generator for this script.""" return SpeechGenerator(self) - def getTutorialGenerator(self): - """Returns the tutorial generator for this script.""" - - return TutorialGenerator(self) - - def getUtilities(self): + def get_utilities(self) -> Utilities: """Returns the utilities for this script.""" return Utilities(self) - def getAppPreferencesGUI(self): - """Return a GtkGrid containing app-unique configuration items.""" + def is_loading_content(self) -> bool: + """Returns True if we're currently loading content.""" - grid = Gtk.Grid() - grid.set_border_width(12) + return self._loading_content - generalFrame = Gtk.Frame() - grid.attach(generalFrame, 0, 0, 1, 1) - - label = Gtk.Label(label=f"{guilabels.PAGE_NAVIGATION}") - label.set_use_markup(True) - generalFrame.set_label_widget(label) - - generalAlignment = Gtk.Alignment.new(0.5, 0.5, 1, 1) - generalAlignment.set_padding(0, 0, 12, 0) - generalFrame.add(generalAlignment) - generalGrid = Gtk.Grid() - generalAlignment.add(generalGrid) - - label = guilabels.USE_CARET_NAVIGATION - value = cthulhu.cthulhuApp.settingsManager.getSetting('caretNavigationEnabled') - self._controlCaretNavigationCheckButton = \ - Gtk.CheckButton.new_with_mnemonic(label) - self._controlCaretNavigationCheckButton.set_active(value) - generalGrid.attach(self._controlCaretNavigationCheckButton, 0, 0, 1, 1) - - label = guilabels.AUTO_FOCUS_MODE_CARET_NAV - value = cthulhu.cthulhuApp.settingsManager.getSetting('caretNavTriggersFocusMode') - self._autoFocusModeCaretNavCheckButton = Gtk.CheckButton.new_with_mnemonic(label) - self._autoFocusModeCaretNavCheckButton.set_active(value) - generalGrid.attach(self._autoFocusModeCaretNavCheckButton, 0, 1, 1, 1) - - label = guilabels.USE_STRUCTURAL_NAVIGATION - value = self.structuralNavigation.enabled - self._structuralNavigationCheckButton = \ - Gtk.CheckButton.new_with_mnemonic(label) - self._structuralNavigationCheckButton.set_active(value) - generalGrid.attach(self._structuralNavigationCheckButton, 0, 2, 1, 1) - - label = guilabels.AUTO_FOCUS_MODE_STRUCT_NAV - value = cthulhu.cthulhuApp.settingsManager.getSetting('structNavTriggersFocusMode') - self._autoFocusModeStructNavCheckButton = Gtk.CheckButton.new_with_mnemonic(label) - self._autoFocusModeStructNavCheckButton.set_active(value) - generalGrid.attach(self._autoFocusModeStructNavCheckButton, 0, 3, 1, 1) - - label = guilabels.AUTO_FOCUS_MODE_NATIVE_NAV - value = cthulhu.cthulhuApp.settingsManager.getSetting('nativeNavTriggersFocusMode') - self._autoFocusModeNativeNavCheckButton = Gtk.CheckButton.new_with_mnemonic(label) - self._autoFocusModeNativeNavCheckButton.set_active(value) - generalGrid.attach(self._autoFocusModeNativeNavCheckButton, 0, 4, 1, 1) - - label = guilabels.READ_PAGE_UPON_LOAD - value = cthulhu.cthulhuApp.settingsManager.getSetting('sayAllOnLoad') - self._sayAllOnLoadCheckButton = Gtk.CheckButton.new_with_mnemonic(label) - self._sayAllOnLoadCheckButton.set_active(value) - generalGrid.attach(self._sayAllOnLoadCheckButton, 0, 5, 1, 1) - - label = guilabels.PAGE_SUMMARY_UPON_LOAD - value = cthulhu.cthulhuApp.settingsManager.getSetting('pageSummaryOnLoad') - self._pageSummaryOnLoadCheckButton = Gtk.CheckButton.new_with_mnemonic(label) - self._pageSummaryOnLoadCheckButton.set_active(value) - generalGrid.attach(self._pageSummaryOnLoadCheckButton, 0, 6, 1, 1) - - label = guilabels.CONTENT_LAYOUT_MODE - value = cthulhu.cthulhuApp.settingsManager.getSetting('layoutMode') - self._layoutModeCheckButton = Gtk.CheckButton.new_with_mnemonic(label) - self._layoutModeCheckButton.set_active(value) - generalGrid.attach(self._layoutModeCheckButton, 0, 7, 1, 1) - - tableFrame = Gtk.Frame() - grid.attach(tableFrame, 0, 1, 1, 1) - - label = Gtk.Label(label=f"{guilabels.TABLE_NAVIGATION}") - label.set_use_markup(True) - tableFrame.set_label_widget(label) - - tableAlignment = Gtk.Alignment.new(0.5, 0.5, 1, 1) - tableAlignment.set_padding(0, 0, 12, 0) - tableFrame.add(tableAlignment) - tableGrid = Gtk.Grid() - tableAlignment.add(tableGrid) - - label = guilabels.TABLE_SPEAK_CELL_COORDINATES - value = cthulhu.cthulhuApp.settingsManager.getSetting('speakCellCoordinates') - self._speakCellCoordinatesCheckButton = \ - Gtk.CheckButton.new_with_mnemonic(label) - self._speakCellCoordinatesCheckButton.set_active(value) - tableGrid.attach(self._speakCellCoordinatesCheckButton, 0, 0, 1, 1) - - label = guilabels.TABLE_SPEAK_CELL_SPANS - value = cthulhu.cthulhuApp.settingsManager.getSetting('speakCellSpan') - self._speakCellSpanCheckButton = \ - Gtk.CheckButton.new_with_mnemonic(label) - self._speakCellSpanCheckButton.set_active(value) - tableGrid.attach(self._speakCellSpanCheckButton, 0, 1, 1, 1) - - label = guilabels.TABLE_ANNOUNCE_CELL_HEADER - value = cthulhu.cthulhuApp.settingsManager.getSetting('speakCellHeaders') - self._speakCellHeadersCheckButton = \ - Gtk.CheckButton.new_with_mnemonic(label) - self._speakCellHeadersCheckButton.set_active(value) - tableGrid.attach(self._speakCellHeadersCheckButton, 0, 2, 1, 1) - - label = guilabels.TABLE_SKIP_BLANK_CELLS - value = cthulhu.cthulhuApp.settingsManager.getSetting('skipBlankCells') - self._skipBlankCellsCheckButton = \ - Gtk.CheckButton.new_with_mnemonic(label) - self._skipBlankCellsCheckButton.set_active(value) - tableGrid.attach(self._skipBlankCellsCheckButton, 0, 3, 1, 1) - - findFrame = Gtk.Frame() - grid.attach(findFrame, 0, 2, 1, 1) - - label = Gtk.Label(label=f"{guilabels.FIND_OPTIONS}") - label.set_use_markup(True) - findFrame.set_label_widget(label) - - findAlignment = Gtk.Alignment.new(0.5, 0.5, 1, 1) - findAlignment.set_padding(0, 0, 12, 0) - findFrame.add(findAlignment) - findGrid = Gtk.Grid() - findAlignment.add(findGrid) - - verbosity = cthulhu.cthulhuApp.settingsManager.getSetting('findResultsVerbosity') - - label = guilabels.FIND_SPEAK_RESULTS - value = verbosity != settings.FIND_SPEAK_NONE - self._speakResultsDuringFindCheckButton = \ - Gtk.CheckButton.new_with_mnemonic(label) - self._speakResultsDuringFindCheckButton.set_active(value) - findGrid.attach(self._speakResultsDuringFindCheckButton, 0, 0, 1, 1) - - label = guilabels.FIND_ONLY_SPEAK_CHANGED_LINES - value = verbosity == settings.FIND_SPEAK_IF_LINE_CHANGED - self._changedLinesOnlyCheckButton = \ - Gtk.CheckButton.new_with_mnemonic(label) - self._changedLinesOnlyCheckButton.set_active(value) - findGrid.attach(self._changedLinesOnlyCheckButton, 0, 1, 1, 1) - - hgrid = Gtk.Grid() - findGrid.attach(hgrid, 0, 2, 1, 1) - - self._minimumFindLengthLabel = \ - Gtk.Label(label=guilabels.FIND_MINIMUM_MATCH_LENGTH) - self._minimumFindLengthLabel.set_alignment(0, 0.5) - hgrid.attach(self._minimumFindLengthLabel, 0, 0, 1, 1) - - self._minimumFindLengthAdjustment = \ - Gtk.Adjustment(cthulhu.cthulhuApp.settingsManager.getSetting( - 'findResultsMinimumLength'), 0, 20, 1) - self._minimumFindLengthSpinButton = Gtk.SpinButton() - self._minimumFindLengthSpinButton.set_adjustment( - self._minimumFindLengthAdjustment) - hgrid.attach(self._minimumFindLengthSpinButton, 1, 0, 1, 1) - self._minimumFindLengthLabel.set_mnemonic_widget( - self._minimumFindLengthSpinButton) - - grid.show_all() - return grid - - def getPreferencesFromGUI(self): - """Returns a dictionary with the app-specific preferences.""" - - if not self._speakResultsDuringFindCheckButton.get_active(): - verbosity = settings.FIND_SPEAK_NONE - elif self._changedLinesOnlyCheckButton.get_active(): - verbosity = settings.FIND_SPEAK_IF_LINE_CHANGED - else: - verbosity = settings.FIND_SPEAK_ALL - - return { - 'findResultsVerbosity': verbosity, - 'findResultsMinimumLength': self._minimumFindLengthSpinButton.get_value(), - 'sayAllOnLoad': self._sayAllOnLoadCheckButton.get_active(), - 'pageSummaryOnLoad': self._pageSummaryOnLoadCheckButton.get_active(), - 'structuralNavigationEnabled': self._structuralNavigationCheckButton.get_active(), - 'structNavTriggersFocusMode': self._autoFocusModeStructNavCheckButton.get_active(), - 'caretNavigationEnabled': self._controlCaretNavigationCheckButton.get_active(), - 'caretNavTriggersFocusMode': self._autoFocusModeCaretNavCheckButton.get_active(), - 'nativeNavTriggersFocusMode': self._autoFocusModeNativeNavCheckButton.get_active(), - 'speakCellCoordinates': self._speakCellCoordinatesCheckButton.get_active(), - 'layoutMode': self._layoutModeCheckButton.get_active(), - 'speakCellSpan': self._speakCellSpanCheckButton.get_active(), - 'speakCellHeaders': self._speakCellHeadersCheckButton.get_active(), - 'skipBlankCells': self._skipBlankCellsCheckButton.get_active() - } - - def skipObjectEvent(self, event): - """Returns True if this object event should be skipped.""" - - if event.type.startswith('object:state-changed:focused') and event.detail1: - if AXUtilities.is_link(event.source): - return False - elif event.type.startswith('object:children-changed'): - if AXUtilities.is_dialog(event.any_data): - return False - - return super().skipObjectEvent(event) - - def presentationInterrupt(self): - super().presentationInterrupt() - msg = "WEB: Flushing live region messages" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.liveRegionManager.flushMessages() - - def updateKeyboardEventState(self, keyboardEvent, handler): - """Update internal state for a keyboard event without deciding consumption.""" - - # We need to do this here. Cthulhu caret and structural navigation - # often result in the user being repositioned without our getting - # a corresponding AT-SPI event. Without an AT-SPI event, script.py - # won't know to dump the generator cache. See bgo#618827. - self.generatorCache = {} - self._lastMouseButtonContext = None, -1 - super().updateKeyboardEventState(keyboardEvent, handler) - - def shouldConsumeKeyboardEvent(self, keyboardEvent, handler): - """Returns True if the script will consume this keyboard event.""" - - if handler and self.caretNavigation.handles_navigation(handler): - consumes = self.useCaretNavigationModel(keyboardEvent) - self._lastCommandWasCaretNav = consumes - self._lastCommandWasStructNav = False - self._lastCommandWasMouseButton = False - return consumes - - if handler and handler.function in self.structuralNavigation.functions: - consumes = self.useStructuralNavigationModel() - self._lastCommandWasCaretNav = False - self._lastCommandWasStructNav = consumes - self._lastCommandWasMouseButton = False - return consumes - - if handler and handler.function in self.liveRegionManager.functions: - # This is temporary. - consumes = self.useStructuralNavigationModel() - self._lastCommandWasCaretNav = False - self._lastCommandWasStructNav = consumes - self._lastCommandWasMouseButton = False - return consumes - - if not keyboardEvent.is_modifier_key(): - self._lastCommandWasCaretNav = False - self._lastCommandWasStructNav = False - self._lastCommandWasMouseButton = False - - # Check parent first - consumes = super().shouldConsumeKeyboardEvent(keyboardEvent, handler) - - # If parent doesn't consume Return key, try our clickable fallback - if not consumes and keyboardEvent.event_string == "Return": - return self._tryClickableActivation(keyboardEvent) - - return consumes - - def getEnabledKeyBindings(self): - all = super().getEnabledKeyBindings() - ret = [] - for b in all: - if b.handler and self.caretNavigation.handles_navigation(b.handler): - if self.useCaretNavigationModel(None, False): - ret.append(b) - elif b.handler and b.handler.function in self.structuralNavigation.functions: - if self.useStructuralNavigationModel(False): - ret.append(b) - elif b.handler and b.handler.function in self.liveRegionManager.functions: - # This is temporary. - if self.useStructuralNavigationModel(False): - ret.append(b) - else: - ret.append(b) - return ret - - def consumesBrailleEvent(self, brailleEvent): - """Returns True if the script will consume this braille event.""" - - self._lastCommandWasCaretNav = False - self._lastCommandWasStructNav = False - self._lastCommandWasMouseButton = False - return super().consumesBrailleEvent(brailleEvent) - - # TODO - JD: This needs to be moved out of the scripts. - def textLines(self, obj, offset=None): - """Creates a generator that can be used to iterate document content.""" - - if not self.utilities.inDocumentContent(): - tokens = ["WEB: textLines called for non-document content", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - super().textLines(obj, offset) - return - - self._sayAllIsInterrupted = False - - sayAllStyle = cthulhu.cthulhuApp.settingsManager.getSetting('sayAllStyle') - sayAllBySentence = sayAllStyle == settings.SAYALL_STYLE_SENTENCE - if offset is None: - obj, characterOffset = self.utilities.getCaretContext() - else: - characterOffset = offset - priorObj, priorOffset = self.utilities.getPriorContext() - - # TODO - JD: This is sad, but it's better than the old, broken - # clumpUtterances(). We really need to fix the speechservers' - # SayAll support. In the meantime, the generators should be - # providing one ACSS per string. - def _parseUtterances(utterances): - elements, voices = [], [] - for u in utterances: - if isinstance(u, list): - e, v = _parseUtterances(u) - elements.extend(e) - voices.extend(v) - elif isinstance(u, str): - elements.append(u) - elif isinstance(u, ACSS): - voices.append(u) - return elements, voices - - self._inSayAll = True - done = False - while not done: - if sayAllBySentence: - contents = self.utilities.getSentenceContentsAtOffset(obj, characterOffset) - else: - contents = self.utilities.getLineContentsAtOffset(obj, characterOffset) - self._sayAllContents = contents - for i, content in enumerate(contents): - obj, startOffset, endOffset, text = content - tokens = ["WEB SAY ALL CONTENT:", - i, ". ", obj, "'", text, "' (", startOffset, "-", endOffset, ")"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - - if self.utilities.isInferredLabelForContents(content, contents): - continue - - if startOffset == endOffset: - continue - - if self.utilities.isLabellingInteractiveElement(obj): - continue - - if self.utilities.isLinkAncestorOfImageInContents(obj, contents): - continue - - utterances = self.speechGenerator.generateContents( - [content], eliminatePauses=True, priorObj=priorObj) - priorObj = obj - - elements, voices = _parseUtterances(utterances) - if len(elements) != len(voices): - continue - - for i, element in enumerate(elements): - context = speechserver.SayAllContext( - obj, element, startOffset, endOffset) - tokens = ["WEB", context] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - self._sayAllContexts.append(context) - self.eventSynthesizer.scroll_into_view(obj, startOffset, endOffset) - yield [context, voices[i]] - - lastObj, lastOffset = contents[-1][0], contents[-1][2] - obj, characterOffset = self.utilities.findNextCaretInOrder(lastObj, lastOffset - 1) - if obj == lastObj and characterOffset <= lastOffset: - obj, characterOffset = self.utilities.findNextCaretInOrder(lastObj, lastOffset) - if obj == lastObj and characterOffset <= lastOffset: - tokens = ["WEB: Cycle within object detected in textLines. Last:", - lastObj, ", ", lastOffset, "Next:", obj, ", ", characterOffset] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - break - - done = obj is None - - self._inSayAll = False - self._sayAllContents = [] - self._sayAllContexts = [] - - msg = "WEB: textLines complete. Verifying SayAll status" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.inSayAll() - - def presentFindResults(self, obj, offset): - """Updates the context and presents the find results if appropriate.""" - - text = self.utilities.queryNonEmptyText(obj) - selections = self.utilities.allTextSelections(obj) if text else [] - if not selections: - return - - document = self.utilities.getDocumentForObject(obj) - if not document: - return - - context = self.utilities.getCaretContext(documentFrame=document) - start, end = selections[0] - offset = max(offset, start) - self.utilities.setCaretContext(obj, offset, documentFrame=document) - if end - start < cthulhu.cthulhuApp.settingsManager.getSetting('findResultsMinimumLength'): - return - - verbosity = cthulhu.cthulhuApp.settingsManager.getSetting('findResultsVerbosity') - if verbosity == settings.FIND_SPEAK_NONE: - return - - if self._madeFindAnnouncement \ - and verbosity == settings.FIND_SPEAK_IF_LINE_CHANGED \ - and self.utilities.contextsAreOnSameLine(context, (obj, offset)): - return - - contents = self.utilities.getLineContentsAtOffset(obj, offset) - self.speakContents(contents) - self.updateBraille(obj) - - resultsCount = self.utilities.getFindResultsCount() - if resultsCount: - self.presentMessage(resultsCount) - - self._madeFindAnnouncement = True - - def sayAll(self, inputEvent, obj=None, offset=None): - """Speaks the contents of the document beginning with the present - location. Overridden in this script because the sayAll could have - been started on an object without text (such as an image). - """ - - if not self.utilities.inDocumentContent(): - tokens = ["WEB: SayAll called for non-document content", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return super().sayAll(inputEvent, obj, offset) - - obj = obj or cthulhu_state.locusOfFocus - tokens = ["WEB: SayAll called for document content", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - speech.sayAll(self.textLines(obj, offset), self.__sayAllProgressCallback) - return True - - def _rewindSayAll(self, context, minCharCount=10): - if not self.utilities.inDocumentContent(): - return super()._rewindSayAll(context, minCharCount) - - if not cthulhu.cthulhuApp.settingsManager.getSetting('rewindAndFastForwardInSayAll'): - return False - - try: - obj, start, end, string = self._sayAllContents[0] - except IndexError: - return False - - cthulhu.setLocusOfFocus(None, obj, notifyScript=False) - self.utilities.setCaretContext(obj, start) - - prevObj, prevOffset = self.utilities.findPreviousCaretInOrder(obj, start) - self.sayAll(None, prevObj, prevOffset) - return True - - def _fastForwardSayAll(self, context): - if not self.utilities.inDocumentContent(): - return super()._fastForwardSayAll(context) - - if not cthulhu.cthulhuApp.settingsManager.getSetting('rewindAndFastForwardInSayAll'): - return False - - try: - obj, start, end, string = self._sayAllContents[-1] - except IndexError: - return False - - cthulhu.setLocusOfFocus(None, obj, notifyScript=False) - self.utilities.setCaretContext(obj, end) - - nextObj, nextOffset = self.utilities.findNextCaretInOrder(obj, end) - self.sayAll(None, nextObj, nextOffset) - return True - - def __sayAllProgressCallback(self, context, progressType): - if not self.utilities.inDocumentContent(): - super().__sayAllProgressCallback(context, progressType) - return - - if progressType == speechserver.SayAllContext.INTERRUPTED: - if isinstance(cthulhu_state.lastInputEvent, input_event.KeyboardEvent): - self._sayAllIsInterrupted = True - lastKey = cthulhu_state.lastInputEvent.event_string - if lastKey == "Down" and self._fastForwardSayAll(context): - return - elif lastKey == "Up" and self._rewindSayAll(context): - return - elif not self._lastCommandWasStructNav: - cthulhu.emitRegionChanged(context.obj, context.currentOffset) - self.utilities.setCaretPosition(context.obj, context.currentOffset) - self.updateBraille(context.obj) - - self._inSayAll = False - self._sayAllContents = [] - self._sayAllContexts = [] - return - - cthulhu.setLocusOfFocus(None, context.obj, notifyScript=False) - cthulhu.emitRegionChanged( - context.obj, context.currentOffset, context.currentEndOffset, cthulhu.SAY_ALL) - self.utilities.setCaretContext(context.obj, context.currentOffset) - - def inFocusMode(self): - """ Returns True if we're in focus mode.""" - - return self._inFocusMode - - def focusModeIsSticky(self): - """Returns True if we're in 'sticky' focus mode.""" - - return self._focusModeIsSticky - - def browseModeIsSticky(self): - """Returns True if we're in 'sticky' browse mode.""" - - return self._browseModeIsSticky - - def useFocusMode(self, obj, prevObj=None): - """Returns True if we should use focus mode in obj.""" - - if self._focusModeIsSticky: - _log("Using focus mode", "focus-mode-sticky") - return True - - if self._browseModeIsSticky: - _log("Not using focus mode", "browse-mode-sticky") - return False - - if self.inSayAll(): - _log("Not using focus mode", "say-all") - return False - - if not cthulhu.cthulhuApp.settingsManager.getSetting('structNavTriggersFocusMode') \ - and self._lastCommandWasStructNav: - _log("Not using focus mode", "struct-nav-settings") - return False - - if prevObj and AXObject.is_dead(prevObj): - prevObj = None - - if not cthulhu.cthulhuApp.settingsManager.getSetting('caretNavTriggersFocusMode') \ - and self._lastCommandWasCaretNav \ - and not self.utilities.isNavigableToolTipDescendant(prevObj): - _log("Not using focus mode", "caret-nav-settings") - return False - - if not cthulhu.cthulhuApp.settingsManager.getSetting('nativeNavTriggersFocusMode') \ - and not (self._lastCommandWasStructNav or self._lastCommandWasCaretNav): - _log("Not changing focus/browse mode", "native-nav-settings") - return self._inFocusMode - - if self.utilities.isFocusModeWidget(obj): - _log_tokens(["Using focus mode for", obj], "focus-mode-widget") - return True - - doNotToggle = AXUtilities.is_link(obj) or AXUtilities.is_radio_button(obj) - if self._inFocusMode and doNotToggle and self.utilities.lastInputEventWasUnmodifiedArrow(): - _log_tokens(["Staying in focus mode due to arrowing in role of", obj], "arrowing") - return True - - if self._inFocusMode and self.utilities.isWebAppDescendant(obj): - if self.utilities.forceBrowseModeForWebAppDescendant(obj): - _log_tokens(["Forcing browse mode for web app descendant", obj], "web-app-forced-browse") - return False - - _log("Staying in focus mode because we're inside a web application", "web-app-context") - return True - - _log_tokens(["Not using focus mode for", obj], "no-cause") - return False - - def speakContents(self, contents, **args): - """Speaks the specified contents.""" - - utterances = self.speechGenerator.generateContents(contents, **args) - speech.speak(utterances) - - def sayCharacter(self, obj): + def say_character(self, obj: Atspi.Accessible) -> None: """Speaks the character at the current caret position.""" - if not self._lastCommandWasCaretNav \ - and not self.utilities.isContentEditableWithEmbeddedObjects(obj): - super().sayCharacter(obj) + tokens = ["WEB: Say character for", obj] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if not self.utilities.in_document_content(obj): + msg = "WEB: Object is not in document content." + debug.print_message(debug.LEVEL_INFO, msg, True) + super().say_character(obj) return - document = self.utilities.getTopLevelDocumentForObject(obj) - obj, offset = self.utilities.getCaretContext(documentFrame=document) + document = self.utilities.get_top_level_document_for_object(obj) + obj, offset = self.utilities.get_caret_context(document=document) + tokens = ["WEB: Adjusted object and offset for say character to", obj, offset] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if not obj: return - contents = None - if self.utilities.treatAsEndOfLine(obj, offset) and AXObject.supports_text(obj): - char = AXText.get_substring(obj, offset, offset + 1) - if char == self.EMBEDDED_OBJECT_CHARACTER: + contents: list[tuple[Atspi.Accessible, int, int, str]] | None = None + if self.utilities.treat_as_end_of_line(obj, offset) and AXObject.supports_text(obj): + char = AXText.get_character_at_offset(obj, offset)[0] + if char == "\ufffc": char = "" - contents = [[obj, offset, offset + 1, char]] + contents = [(obj, offset, offset + 1, char)] else: - contents = self.utilities.getCharacterContentsAtOffset(obj, offset) + contents = self.utilities.get_character_contents_at_offset(obj, offset) if not contents: return - obj, start, end, string = contents[0] - if start > 0: - string = string or "\n" + speech_pres = speech_presenter.get_presenter() + obj, start, _end, string = contents[0] + if start > 0 and string == "\n": + if speech_pres.get_speak_blank_lines(): + presentation_manager.get_manager().speak_message(messages.BLANK) + return + presenter = presentation_manager.get_manager() if string: - self.speakMisspelledIndicator(obj, start) - self.speakCharacter(string) + if error := speech_pres.get_error_description(obj, start): + presenter.speak_message(error) + presenter.speak_character(string, obj=obj) else: - self.speakContents(contents) + presenter.speak_contents(contents) - self.pointOfReference["lastTextUnitSpoken"] = "char" + AXUtilities.set_last_text_unit_spoken(TextUnit.CHAR) - def sayWord(self, obj): + def say_word(self, obj: Atspi.Accessible) -> None: """Speaks the word at the current caret position.""" - isEditable = self.utilities.isContentEditableWithEmbeddedObjects(obj) - if not self._lastCommandWasCaretNav and not isEditable: - super().sayWord(obj) + tokens = ["WEB: Say word for", obj] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if not self.utilities.in_document_content(obj): + msg = "WEB: Object is not in document content." + debug.print_message(debug.LEVEL_INFO, msg, True) + super().say_word(obj) return - document = self.utilities.getTopLevelDocumentForObject(obj) - obj, offset = self.utilities.getCaretContext(documentFrame=document) + document = self.utilities.get_top_level_document_for_object(obj) + obj, offset = self.utilities.get_caret_context(document=document) if input_event_manager.get_manager().last_event_was_right(): offset -= 1 - wordContents = self.utilities.getWordContentsAtOffset(obj, offset, useCache=True) - textObj, startOffset, endOffset, word = wordContents[0] - self.speakMisspelledIndicator(textObj, startOffset) - self.speakContents(wordContents) - self.pointOfReference["lastTextUnitSpoken"] = "word" + tokens = ["WEB: Adjusted object and offset for say word to", obj, offset] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) - def sayLine(self, obj): + word_contents = self.utilities.get_word_contents_at_offset(obj, offset, use_cache=True) + text_obj, start_offset, _end_offset, _word = word_contents[0] + + if error := speech_presenter.get_presenter().get_error_description(text_obj, start_offset): + presentation_manager.get_manager().speak_message(error) + + speech_presenter.get_presenter().speak_word(self, obj, offset) + AXUtilities.set_last_text_unit_spoken(TextUnit.WORD) + + def say_line(self, obj: Atspi.Accessible, offset: int | None = None) -> None: """Speaks the line at the current caret position.""" - isEditable = self.utilities.isContentEditableWithEmbeddedObjects(obj) - if not (self._lastCommandWasCaretNav or self._lastCommandWasStructNav) and not isEditable: - super().sayLine(obj) + tokens = ["WEB: Say line for", obj] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if not self.utilities.in_document_content(obj): + msg = "WEB: Object is not in document content." + debug.print_message(debug.LEVEL_INFO, msg, True) + super().say_line(obj) return - document = self.utilities.getTopLevelDocumentForObject(obj) - priorObj = None - if self._lastCommandWasCaretNav or isEditable: - priorObj, priorOffset = self.utilities.getPriorContext(documentFrame=document) + document = self.utilities.get_top_level_document_for_object(obj) + if offset is None: + obj, offset = self.utilities.get_caret_context(document) + tokens = ["WEB: Adjusted object and offset for say line to", obj, offset] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) - obj, offset = self.utilities.getCaretContext(documentFrame=document) - contents = self.utilities.getLineContentsAtOffset(obj, offset, useCache=True) - self.speakContents(contents, priorObj=priorObj) - self.pointOfReference["lastTextUnitSpoken"] = "line" + contents = self.utilities.get_line_contents_at_offset(obj, offset, use_cache=True) + if ( + contents + and contents[0] + and not document_presenter.get_presenter().in_focus_mode(self.app) + ): + self.utilities.set_caret_position(contents[0][0], contents[0][1]) - def presentObject(self, obj, **args): - if not self.utilities.inDocumentContent(obj) or AXUtilities.is_document(obj): - super().presentObject(obj, **args) + line, start_offset = AXText.get_line_at_offset(obj, offset)[0:2] + speech_presenter.get_presenter().speak_line( + self, + obj, + start_offset, + start_offset + len(line), + line, + ) + + AXUtilities.set_last_text_unit_spoken(TextUnit.LINE) + + def present_object(self, obj: Atspi.Accessible, **args) -> None: + if obj is None: return - if AXUtilities.is_status_bar(obj): - super().presentObject(obj, **args) + if not self.utilities.in_document_content(obj) or AXUtilities.is_document(obj): + super().present_object(obj, **args) return - priorObj = args.get("priorObj") - if self._lastCommandWasCaretNav or args.get("includeContext") \ - or self.utilities.getTable(obj): - priorObj, priorOffset = self.utilities.getPriorContext() - args["priorObj"] = priorObj + mode, _obj = focus_manager.get_manager().get_active_mode_and_object_of_interest() + if mode in [focus_manager.OBJECT_NAVIGATOR, focus_manager.MOUSE_REVIEW]: + super().present_object(obj, **args) + return + if AXUtilities.is_status_bar(obj) or AXUtilities.is_alert(obj): + if not document_presenter.get_presenter().in_focus_mode(self.app): + self.utilities.set_caret_position(obj, 0) + super().present_object(obj, **args) + return + + prior_obj = args.get("priorObj") + if ( + caret_navigator.get_navigator().last_input_event_was_navigation_command() + or structural_navigator.get_navigator().last_input_event_was_navigation_command() + or table_navigator.get_navigator().last_input_event_was_navigation_command() + or args.get("includeContext") + or AXUtilities.get_table(obj) + ): + prior_context = self.utilities.get_prior_context() + if prior_context is not None: + prior_obj, _prior_offset = prior_context + args["priorObj"] = prior_obj + + # Objects might be destroyed as a consequence of scrolling, such as in an infinite scroll + # list. Therefore, store its name and role beforehand. Objects in the process of being + # destroyed typically lose their name even if they lack the defunct state. If the name of + # the object is different after scrolling, we'll try to find a child with the same name and + # role. + document = self.utilities.get_document_for_object(obj) + name = AXObject.get_name(obj) + role = AXObject.get_role(obj) + AXEventSynthesizer.scroll_to_center(obj, start_offset=0) + if (name and AXObject.get_name(obj) != name) or AXObject.get_index_in_parent(obj) < 0: + tokens = ["WEB:", obj, "believed to be destroyed after scroll."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + replicant = AXUtilities.find_descendant( + document, + lambda x: AXObject.get_name(x) == name and AXObject.get_role(obj) == role, + ) + if replicant: + obj = replicant + tokens = ["WEB: Replacing destroyed object with", obj] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + # Editors like VSCode use the entry role for the code editor. if AXUtilities.is_entry(obj): - super().presentObject(obj, **args) + if not document_presenter.get_presenter().in_focus_mode(self.app): + self.utilities.set_caret_position(obj, 0) + super().present_object(obj, **args) return interrupt = args.get("interrupt", False) tokens = ["WEB: Presenting object", obj, ". Interrupt:", interrupt] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) # We shouldn't use cache in this method, because if the last thing we presented # included this object and offset (e.g. a Say All or Mouse Review), we're in # danger of presented irrelevant context. - useCache = False offset = args.get("offset", 0) - contents = self.utilities.get_objectContentsAtOffset(obj, offset, useCache) - self.displayContents(contents) - self.speakContents(contents, **args) - - def updateBrailleForNewCaretPosition(self, obj): + contents = self.utilities.get_object_contents_at_offset(obj, offset, use_cache=False) + if ( + contents + and contents[0] + and not document_presenter.get_presenter().in_focus_mode(self.app) + ): + self.utilities.set_caret_position(contents[0][0], contents[0][1]) + presenter = presentation_manager.get_manager() + presenter.display_contents(contents) + presenter.speak_contents(contents, **args) + + def _update_braille_caret_position(self, obj: Atspi.Accessible) -> None: """Try to reposition the cursor without having to do a full update.""" - text = self.utilities.queryNonEmptyText(obj) - if text and self.EMBEDDED_OBJECT_CHARACTER in AXText.get_all_text(obj): - self.updateBraille(obj) + if "\ufffc" in AXText.get_all_text(obj): + self.update_braille(obj) return - super().updateBrailleForNewCaretPosition(obj) + super()._update_braille_caret_position(obj) - def updateBraille(self, obj, **args): + def update_braille(self, obj: Atspi.Accessible, **args) -> None: """Updates the braille display to show the given object.""" - if not cthulhu.cthulhuApp.settingsManager.getSetting('enableBraille') \ - and not cthulhu.cthulhuApp.settingsManager.getSetting('enableBrailleMonitor'): - debug.printMessage(debug.LEVEL_INFO, "BRAILLE: disabled", True) + tokens = ["WEB: updating braille for", obj, args] + debug.print_tokens(debug.LEVEL_INFO, tokens, True, True) + + if not braille_presenter.get_presenter().use_braille(): return - if self._inFocusMode: + if document_presenter.get_presenter().in_focus_mode( + self.app, + ) and "\ufffc" not in AXText.get_all_text(obj): tokens = ["WEB: updating braille in focus mode", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - super().updateBraille(obj, **args) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + super().update_braille(obj, **args) return - document = args.get("documentFrame", self.utilities.getTopLevelDocumentForObject(obj)) + document = args.get("documentFrame", self.utilities.get_top_level_document_for_object(obj)) if not document: tokens = ["WEB: updating braille for non-document object", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - super().updateBraille(obj, **args) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + super().update_braille(obj, **args) return - isContentEditable = self.utilities.isContentEditableWithEmbeddedObjects(obj) + is_content_editable = self.utilities.is_content_editable_with_embedded_objects(obj) - if not self._lastCommandWasCaretNav \ - and not self._lastCommandWasStructNav \ - and not isContentEditable \ - and not self.utilities.isPlainText() \ - and not self.utilities.lastInputEventWasCaretNavWithSelection(): + if ( + not caret_navigator.get_navigator().last_input_event_was_navigation_command() + and not structural_navigator.get_navigator().last_input_event_was_navigation_command() + and not table_navigator.get_navigator().last_input_event_was_navigation_command() + and not is_content_editable + and not AXDocument.is_plain_text(document) + and not input_event_manager.get_manager().last_event_was_caret_selection() + ): tokens = ["WEB: updating braille for unhandled navigation type", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - super().updateBraille(obj, **args) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + super().update_braille(obj, **args) return - obj, offset = self.utilities.getCaretContext( - documentFrame=document, getZombieReplicant=True) - if offset > 0 and isContentEditable: - text = self.utilities.queryNonEmptyText(obj) - if text: - offset = min(offset, AXText.get_character_count(obj)) + # TODO - JD: Getting the caret context can, by side effect, update it. This in turn + # can prevent us from presenting table column headers when braille is enabled because + # we think they are not "new." Commit bd877203f0 addressed that, but we need to stop + # such side effects from happening in the first place. + offset = args.get("offset") + if offset is None: + obj, offset = self.utilities.get_caret_context(document, get_replicant=True) + if offset > 0 and is_content_editable and self.utilities.treat_as_text_object(obj): + offset = min(offset, AXText.get_character_count(obj)) - contents = self.utilities.getLineContentsAtOffset(obj, offset) - self.displayContents(contents, documentFrame=document) + contents = self.utilities.get_line_contents_at_offset(obj, offset) + presentation_manager.get_manager().display_contents(contents, documentFrame=document) - def displayContents(self, contents, **args): - """Displays contents in braille.""" - - if not cthulhu.cthulhuApp.settingsManager.getSetting('enableBraille') \ - and not cthulhu.cthulhuApp.settingsManager.getSetting('enableBrailleMonitor'): - debug.printMessage(debug.LEVEL_INFO, "BRAILLE: disabled", True) - return - - line = self.getNewBrailleLine(clearBraille=True, addLine=True) - document = args.get("documentFrame") - contents = self.brailleGenerator.generateContents(contents, documentFrame=document) - if not contents: - return - - regions, focusedRegion = contents - for region in regions: - self.addBrailleRegionsToLine(region, line) - - if line.regions: - line.regions[-1].string = line.regions[-1].string.rstrip(" ") - - self.setBrailleFocus(focusedRegion, getLinkMask=False) - self.refreshBraille(panToCursor=True, getLinkMask=False) - - def panBrailleLeft(self, inputEvent=None, panAmount=0): + def _pan_braille_left(self, event: input_event.InputEvent | None = None) -> bool: """Pans braille to the left.""" - if self.flatReviewPresenter.is_active() \ - or not self.utilities.inDocumentContent() \ - or not self.isBrailleBeginningShowing(): - super().panBrailleLeft(inputEvent, panAmount) - return + if ( + flat_review_presenter.get_presenter().is_active() + or not self.utilities.in_document_content() + ): + return super()._pan_braille_left(event) - contents = self.utilities.getPreviousLineContents() + presenter = braille_presenter.get_presenter() + if presenter.pan_left(): + return True + + # At edge, get previous line from document. + contents = self.utilities.get_previous_line_contents() if not contents: - return + return False - obj, start, end, string = contents[0] - self.utilities.setCaretPosition(obj, start) - self.updateBraille(obj) - - # Hack: When panning to the left in a document, we want to start at - # the right/bottom of each new object. For now, we'll pan there. - # When time permits, we'll give our braille code some smarts. - while self.panBrailleInDirection(panToLeft=False): - pass - - self.refreshBraille(False) + obj, start, _end, _string = contents[0] + self.utilities.set_caret_position(obj, start) + self.update_braille(obj) + presenter.pan_to_end() return True - def panBrailleRight(self, inputEvent=None, panAmount=0): + def _pan_braille_right(self, event: input_event.InputEvent | None = None) -> bool: """Pans braille to the right.""" - if self.flatReviewPresenter.is_active() \ - or not self.utilities.inDocumentContent() \ - or not self.isBrailleEndShowing(): - super().panBrailleRight(inputEvent, panAmount) - return + if ( + flat_review_presenter.get_presenter().is_active() + or not self.utilities.in_document_content() + ): + return super()._pan_braille_right(event) - contents = self.utilities.getNextLineContents() + presenter = braille_presenter.get_presenter() + if presenter.pan_right(): + return True + + # At edge, get next line from document. + contents = self.utilities.get_next_line_contents() if not contents: - return + return False - obj, start, end, string = contents[0] - self.utilities.setCaretPosition(obj, start) - self.updateBraille(obj) - - # Hack: When panning to the right in a document, we want to start at - # the left/top of each new object. For now, we'll pan there. When time - # permits, we'll give our braille code some smarts. - while self.panBrailleInDirection(panToLeft=True): - pass - - self.refreshBraille(False) + obj, start, _end, _string = contents[0] + self.utilities.set_caret_position(obj, start) + self.update_braille(obj) + presenter.pan_to_beginning() return True - def useCaretNavigationModel(self, keyboardEvent, debugOutput=True): - """Returns True if caret navigation should be used.""" - - if not cthulhu.cthulhuApp.settingsManager.getSetting('caretNavigationEnabled'): - if debugOutput: - _log("Not using caret navigation", "disabled") - return False - - if self._inFocusMode: - if debugOutput: - _log("Not using caret navigation", "focus-mode") - return False - - inDoc = self._focusInDocumentContent() - - if self._navSuspended: - if inDoc: - self._setNavigationSuspended(False, "focus confirmed in document content") - else: - if debugOutput: - _log("Not using caret navigation", "suspended") - return False - - if not inDoc: - if debugOutput: - _log_tokens( - ["Not using caret navigation because locusOfFocus", cthulhu_state.locusOfFocus, - "is not in document content."], - "focus-not-document" - ) - return False - - if keyboardEvent and keyboardEvent.modifiers & keybindings.SHIFT_MODIFIER_MASK: - if debugOutput: - _log("Not using caret navigation", "shift-modifier") - return False - - if debugOutput: - _log_tokens( - ["Using caret navigation with locusOfFocus", cthulhu_state.locusOfFocus, - "in document content."], - "enabled" - ) - return True - - def useStructuralNavigationModel(self, debugOutput=True): - """Returns True if structural navigation should be used.""" - - if not self.structuralNavigation.enabled: - if debugOutput: - _log("Not using structural navigation", "disabled") - return False - - if self._inFocusMode: - if debugOutput: - _log("Not using structural navigation", "focus-mode") - return False - - inDoc = self._focusInDocumentContent() - - if self._navSuspended: - if inDoc: - self._setNavigationSuspended(False, "focus confirmed in document content") - else: - if debugOutput: - _log("Not using structural navigation", "suspended") - return False - - if not inDoc: - if debugOutput: - _log_tokens( - ["Not using structural navigation because locusOfFocus", - cthulhu_state.locusOfFocus, "is not in document content."], - "focus-not-document" - ) - return False - - if debugOutput: - _log_tokens( - ["Using structural navigation with locusOfFocus", cthulhu_state.locusOfFocus, - "in document content."], - "enabled" - ) - return True - - def getTextLineAtCaret(self, obj, offset=None, startOffset=None, endOffset=None): - """To-be-removed. Returns the string, caretOffset, startOffset.""" - - if self._inFocusMode or not self.utilities.inDocumentContent(obj) \ - or self.utilities.isFocusModeWidget(obj): - return super().getTextLineAtCaret(obj, offset, startOffset, endOffset) - - text = self.utilities.queryNonEmptyText(obj) - if offset is None: - offset = max(0, AXText.get_caret_offset(obj)) - - if text and startOffset is not None and endOffset is not None: - return AXText.get_substring(obj, startOffset, endOffset), offset, startOffset - - contextObj, contextOffset = self.utilities.getCaretContext(documentFrame=None) - if contextObj == obj: - caretOffset = contextOffset - else: - caretOffset = offset - - contents = self.utilities.getLineContentsAtOffset(obj, offset) - contents = list(filter(lambda x: x[0] == obj, contents)) - if len(contents) == 1: - index = 0 - else: - index = self.utilities.findObjectInContents(obj, offset, contents) - - if index > -1: - candidate, startOffset, endOffset, string = contents[index] - if self.EMBEDDED_OBJECT_CHARACTER not in string: - return string, caretOffset, startOffset - - return "", 0, 0 - - def moveToMouseOver(self, inputEvent): - """Moves the context to/from the mouseover which has just appeared.""" - - if not self._lastMouseOverObject: - self.presentMessage(messages.MOUSE_OVER_NOT_FOUND) - return - - if self._inMouseOverObject: - x, y = self.oldMouseCoordinates - self.eventSynthesizer.route_to_point(x, y) - self.restorePreMouseOverContext() - return - - obj = self._lastMouseOverObject - obj, offset = self.utilities.findFirstCaretContext(obj, 0) - if not obj: - return - - if AXUtilities.is_focusable(obj) and AXObject.supports_component(obj): - Atspi.Component.grab_focus(obj) - - contents = self.utilities.get_objectContentsAtOffset(obj, offset) - self.utilities.setCaretPosition(obj, offset) - self.speakContents(contents) - self.updateBraille(obj) - self._inMouseOverObject = True - - def restorePreMouseOverContext(self): - """Cleans things up after a mouse-over object has been hidden.""" - - obj, offset = self._preMouseOverContext - self.utilities.setCaretPosition(obj, offset) - self.speakContents(self.utilities.get_objectContentsAtOffset(obj, offset)) - self.updateBraille(obj) - self._inMouseOverObject = False - self._lastMouseOverObject = None - - def enableStickyBrowseMode(self, inputEvent, forceMessage=False): - if not self._browseModeIsSticky or forceMessage: - self.presentMessage(messages.MODE_BROWSE_IS_STICKY) - - self._inFocusMode = False - self._focusModeIsSticky = False - self._browseModeIsSticky = True - self.refreshKeyGrabs() - - def enableStickyFocusMode(self, inputEvent, forceMessage=False): - if not self._focusModeIsSticky or forceMessage: - self.presentMessage(messages.MODE_FOCUS_IS_STICKY) - - self._inFocusMode = True - self._focusModeIsSticky = True - self._browseModeIsSticky = False - self.refreshKeyGrabs() - - def toggleLayoutMode(self, inputEvent): - layoutMode = not cthulhu.cthulhuApp.settingsManager.getSetting('layoutMode') - if layoutMode: - self.presentMessage(messages.MODE_LAYOUT) - else: - self.presentMessage(messages.MODE_OBJECT) - cthulhu.cthulhuApp.settingsManager.setSetting('layoutMode', layoutMode) - - def togglePresentationMode(self, inputEvent, documentFrame=None): - [obj, characterOffset] = self.utilities.getCaretContext(documentFrame) - if self._inFocusMode: - parent = AXObject.get_parent(obj) - if AXUtilities.is_list_box(parent): - self.utilities.setCaretContext(parent, -1) - elif AXUtilities.is_menu(parent): - self.utilities.setCaretContext(AXObject.get_parent(parent), -1) - if not self._loadingDocumentContent: - self.presentMessage(messages.MODE_BROWSE) - if not self._shouldSuppressBrowseModeSound(obj, inputEvent): - sound_theme_manager.getManager().playBrowseModeSound() - else: - if not self.utilities.grabFocusWhenSettingCaret(obj) \ - and (self._lastCommandWasCaretNav \ - or self._lastCommandWasStructNav \ - or inputEvent): - self.utilities.grabFocus(obj) - - self.presentMessage(messages.MODE_FOCUS) - sound_theme_manager.getManager().playFocusModeSound() - self._inFocusMode = not self._inFocusMode - self._focusModeIsSticky = False - self._browseModeIsSticky = False - self.refreshKeyGrabs() - - def _shouldSuppressBrowseModeSound(self, obj, inputEvent): - if inputEvent is not None: - return False - - if cthulhu.cthulhuApp.settingsManager.getSetting('roleSoundPresentation') \ - == settings.ROLE_SOUND_PRESENTATION_SPEECH_ONLY: - return False - - if not cthulhu.cthulhuApp.settingsManager.getSetting('enableSound'): - return False - - icon = self._getControlSoundIcon(obj) - return icon is not None and icon.isValid() - - def _getControlSoundIcon(self, obj): - if not obj: - return None - - role = AXObject.get_role(obj) - if self.utilities.isSwitch(obj): - role = Atspi.Role.SWITCH - manager = sound_theme_manager.getManager() - icon = manager.getRoleSoundIcon(role) - if icon: - return icon - - stateKey = None - if AXUtilities.is_checkable(obj) or AXUtilities.is_check_menu_item(obj): - if AXUtilities.is_indeterminate(obj): - stateKey = "mixed" - elif AXUtilities.is_checked(obj): - stateKey = "checked" - else: - stateKey = "unchecked" - elif AXUtilities.is_radio_button(obj): - stateKey = "checked" if AXUtilities.is_checked(obj) else "unchecked" - elif AXUtilities.is_toggle_button(obj) or AXUtilities.is_switch(obj): - stateKey = "checked" if (AXUtilities.is_checked(obj) or AXUtilities.is_pressed(obj)) \ - else "unchecked" - - if stateKey: - return manager.getRoleStateSoundIcon(role, stateKey) - - return None - - def _getClickableActivationTarget(self): - obj = cthulhu_state.locusOfFocus - if self.inFocusMode(): - return obj - - if not self.utilities.inDocumentContent(obj): - return obj - - contextObj, _ = self.utilities.getCaretContext(searchIfNeeded=False) - if contextObj and self.utilities.inDocumentContent(contextObj): - return contextObj - - return obj - - def _performClickableAction(self, obj): - from cthulhu import ax_object - - actionNames = ["click", "click-ancestor", "press", "jump", "open", "activate"] - for actionName in actionNames: - if not ax_object.AXObject.has_action(obj, actionName): - continue - if ax_object.AXObject.do_named_action(obj, actionName): - return True - - return False - - def _tryClickableActivation(self, keyboardEvent): - """Try to activate clickable element - returns True if we should consume the event.""" - - obj = self._getClickableActivationTarget() - if not obj or not self.utilities.inDocumentContent(obj): - return False - - # Skip form controls where Return should have normal behavior - from cthulhu import ax_utilities - if (ax_utilities.AXUtilities.is_entry(obj) or - ax_utilities.AXUtilities.is_text(obj) or - ax_utilities.AXUtilities.is_password_text(obj) or - ax_utilities.AXUtilities.is_combo_box(obj) or - ax_utilities.AXUtilities.is_button(obj) or - ax_utilities.AXUtilities.is_push_button(obj) or - ax_utilities.AXUtilities.is_link(obj)): - return False - - # Try clickable activation for non-standard clickable elements - # Store focus information before clicking for potential restoration - original_focus = obj - - # First try the standard clickable detection - if self.utilities.isClickableElement(obj): - self.presentMessage("Activating...") - result = self._performClickableAction(obj) - if result: - return True - - from cthulhu import ax_event_synthesizer - result = ax_event_synthesizer.AXEventSynthesizer.click_object(obj) - if result: - self._restoreFocusAfterClick(original_focus) - return True - - # If that didn't work, try a more permissive approach for any element with click action - from cthulhu import ax_object - if ax_object.AXObject.has_action(obj, "click") \ - or ax_object.AXObject.has_action(obj, "click-ancestor"): - self.presentMessage("Activating...") - result = self._performClickableAction(obj) - if result: - return True - - from cthulhu import ax_event_synthesizer - result = ax_event_synthesizer.AXEventSynthesizer.click_object(obj) - if result: - self._restoreFocusAfterClick(original_focus) - return True - - return False - - def _restoreFocusAfterClick(self, original_focus): - """Try to restore focus after a click action that may have caused DOM changes.""" - try: - from gi.repository import GObject - from cthulhu import ax_object - - # Store the document and caret position to restore navigation context - document = None - caret_offset = -1 - try: - # Find the document root - obj = original_focus - while obj and not ax_object.AXObject.is_dead(obj): - if self.utilities.isDocument(obj): - document = obj - break - parent = ax_object.AXObject.get_parent(obj) - if parent == obj: # Avoid infinite loops - break - obj = parent - - # Get current caret position if available - if document: - try: - caret_offset = ax_object.AXObject.get_caret_offset(document) - except Exception: - pass - - except Exception: - pass - - def restore_focus(): - try: - # First try direct focus restoration if object still exists - if not ax_object.AXObject.is_dead(original_focus): - original_focus.grabFocus() - return False - - # If we have document and caret info, try to restore position - if document and not ax_object.AXObject.is_dead(document) and caret_offset >= 0: - try: - # Set caret back to where it was - ax_object.AXObject.set_caret_offset(document, caret_offset) - # Focus the document to make sure screen reader tracks properly - document.grabFocus() - return False - except Exception: - pass - - # Last resort: try to focus the document if we have it - if document and not ax_object.AXObject.is_dead(document): - document.grabFocus() - - except Exception: - pass # Focus restoration is best effort - return False # Don't repeat the timeout - - # Delay restoration to allow JavaScript and DOM changes to complete - GObject.timeout_add(150, restore_focus) # 150ms delay for DOM changes - - except Exception: - pass # Focus restoration is best effort - - def _presentDelayedMessage(self, message, delay_ms): - """Present a message after a specified delay in milliseconds.""" - try: - from gi.repository import GObject - - def present_message(): - self.presentMessage(message) - return False # Don't repeat - - GObject.timeout_add(delay_ms, present_message) - except Exception: - # If delay fails, present immediately - self.presentMessage(message) - - def activateClickableElement(self, inputEvent): - """Activates clickable element at current focus via Return key.""" - return self._tryClickableActivation(inputEvent) - - def locus_of_focus_changed(self, event, oldFocus, newFocus): - """Handles changes of focus of interest to the script.""" - - if newFocus and self.utilities.isZombie(newFocus): - tokens = ["WEB: New focus is Zombie:", newFocus] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + def locus_of_focus_changed( + self, + event: Atspi.Event | None, + old_focus: Atspi.Accessible | None, + new_focus: Atspi.Accessible | None, + ) -> bool: + """Handles changes of focus of interest. Returns True if this script did all needed work.""" + + tokens = ["WEB: Focus changing from", old_focus, "to", new_focus] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if new_focus and not AXObject.is_valid(new_focus): return True - if newFocus and AXObject.is_dead(newFocus): - msg = "WEB: New focus is dead" - debug.printMessage(debug.LEVEL_INFO, msg, True) + if new_focus and AXObject.is_dead(new_focus): return True - document = self.utilities.getTopLevelDocumentForObject(newFocus) - if not document and self.utilities.isDocument(newFocus): - document = newFocus + document = self.utilities.get_top_level_document_for_object(new_focus) + if not document and self.utilities.is_document(new_focus): + document = new_focus + + sn_navigator = structural_navigator.get_navigator() + last_command_was_struct_nav = sn_navigator.last_input_event_was_navigation_command() if not document: msg = "WEB: Locus of focus changed to non-document obj" - self._madeFindAnnouncement = False - self._inFocusMode = False - self._setNavigationSuspended(True, "focus left document content") - debug.printMessage(debug.LEVEL_INFO, msg, True) - oldDocument = self.utilities.getTopLevelDocumentForObject(oldFocus) - if not oldDocument and self.utilities.isDocument(oldFocus): - oldDocument = oldFocus + debug.print_message(debug.LEVEL_INFO, msg, True) + document_presenter.get_presenter().reset_find_announcement_state() - if oldFocus and not oldDocument: + old_document = self.utilities.get_top_level_document_for_object(old_focus) + if not document and self.utilities.is_document(old_focus): + old_document = old_focus + + if old_focus and not old_document: msg = "WEB: Not refreshing grabs because we weren't in a document before" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return False - tokens = ["WEB: Refreshing grabs because we left document", oldDocument] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - self.refreshKeyGrabs() + if last_command_was_struct_nav and sn_navigator.get_mode(self) == NavigationMode.GUI: + msg = "WEB: Not refreshing grabs: Last command was GUI structural navigation" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + reason = "locus of focus no longer in document" + document_presenter.get_presenter().suspend_navigators(self, True, reason) return False - # Focus is in document content - unsuspend navigation if it was suspended - if self._navSuspended: - self._setNavigationSuspended(False, "focus entered document content") + if flat_review_presenter.get_presenter().is_active(): + flat_review_presenter.get_presenter().quit() - if self.flatReviewPresenter.is_active(): - self.flatReviewPresenter.quit() + caret_offset = 0 + if self.utilities.in_find_container(old_focus) or ( + self.utilities.is_document(new_focus) + and old_focus == focus_manager.get_manager().get_active_window() + ): + context_obj, context_offset = self.utilities.get_caret_context(document) + if context_obj and AXObject.is_valid(context_obj): + new_focus, caret_offset = context_obj, context_offset - caretOffset = 0 - if self.utilities.inFindContainer(oldFocus) \ - or (self.utilities.isDocument(newFocus) and oldFocus == cthulhu_state.activeWindow): - contextObj, contextOffset = self.utilities.getCaretContext(documentFrame=document) - if contextObj and not self.utilities.isZombie(contextObj): - newFocus, caretOffset = contextObj, contextOffset - - if AXUtilities.is_unknown_or_redundant(newFocus): + if AXUtilities.is_unknown_or_redundant(new_focus): msg = "WEB: Event source has bogus role. Likely browser bug." - debug.printMessage(debug.LEVEL_INFO, msg, True) - newFocus, offset = self.utilities.findFirstCaretContext(newFocus, 0) + debug.print_message(debug.LEVEL_INFO, msg, True) + new_focus, _offset = self.utilities.first_context(new_focus, 0) - text = self.utilities.queryNonEmptyText(newFocus) - if text: - textOffset = AXText.get_caret_offset(newFocus) - if 0 <= textOffset <= AXText.get_character_count(newFocus): - caretOffset = textOffset + if self.utilities.treat_as_text_object(new_focus): + text_offset = AXText.get_caret_offset(new_focus) + if 0 <= text_offset <= AXText.get_character_count(new_focus): + caret_offset = text_offset - self.utilities.setCaretContext(newFocus, caretOffset, document) - self.updateBraille(newFocus, documentFrame=document) - cthulhu.emitRegionChanged(newFocus, caretOffset) + self.utilities.set_caret_context(new_focus, caret_offset, document) + self.update_braille(new_focus, documentFrame=document) contents = None args = {} - if self._lastCommandWasMouseButton and event \ - and event.type.startswith("object:text-caret-moved"): - msg = "WEB: Last input event was mouse button. Generating line." - debug.printMessage(debug.LEVEL_INFO, msg, True) - contents = self.utilities.getLineContentsAtOffset(newFocus, caretOffset) - args['priorObj'] = oldFocus - elif self.utilities.isContentEditableWithEmbeddedObjects(newFocus) \ - and (self._lastCommandWasCaretNav or self._lastCommandWasStructNav) \ - and not (AXUtilities.is_table_cell(newFocus) and AXObject.get_name(newFocus)): - # Check if we're entering the content editable from outside (e.g. down arrow - # from a message list into a message entry). In that case, generate full object - # speech (with label and role) rather than just line contents. - enteredFromOutside = oldFocus is not None \ - and oldFocus != newFocus \ - and not AXObject.find_ancestor(oldFocus, lambda x: x == newFocus) - if enteredFromOutside: - tokens = ["WEB: New focus", newFocus, - "content editable entered from outside. Generating speech."] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - args['priorObj'] = oldFocus - else: - tokens = ["WEB: New focus", newFocus, "content editable. Generating line."] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - contents = self.utilities.getLineContentsAtOffset(newFocus, caretOffset) - elif self.utilities.isAnchor(newFocus): - tokens = ["WEB: New focus", newFocus, "is anchor. Generating line."] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - contents = self.utilities.getLineContentsAtOffset(newFocus, 0) - elif self.utilities.lastInputEventWasPageNav() \ - and not self.utilities.getTable(newFocus) \ - and not self.utilities.isFeedArticle(newFocus): - tokens = ["WEB: New focus", newFocus, "was scrolled to. Generating line."] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - contents = self.utilities.getLineContentsAtOffset(newFocus, caretOffset) - elif self.utilities.isFocusedWithMathChild(newFocus): - tokens = ["WEB: New focus", newFocus, "has math child. Generating line."] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - contents = self.utilities.getLineContentsAtOffset(newFocus, caretOffset) - elif AXUtilities.is_heading(newFocus): - tokens = ["WEB: New focus", newFocus, "is heading. Generating object."] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - contents = self.utilities.get_objectContentsAtOffset(newFocus, 0) - elif self.utilities.caretMovedToSamePageFragment(event, oldFocus): - tokens = ["WEB: Source", event.source, "is same page fragment. Generating line."] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - contents = self.utilities.getLineContentsAtOffset(newFocus, 0) - elif event and event.type.startswith("object:children-changed:remove") \ - and self.utilities.isFocusModeWidget(newFocus): - tokens = ["WEB: New focus", newFocus, - "is recovery from removed child. Generating speech."] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - elif self.utilities.lastInputEventWasLineNav() and self.utilities.isZombie(oldFocus): - msg = "WEB: Last input event was line nav; oldFocus is zombie. Generating line." - debug.printMessage(debug.LEVEL_INFO, msg, True) - contents = self.utilities.getLineContentsAtOffset(newFocus, caretOffset) - elif self.utilities.lastInputEventWasLineNav() and event \ - and event.type.startswith("object:children-changed"): - msg = "WEB: Last input event was line nav and children changed. Generating line." - debug.printMessage(debug.LEVEL_INFO, msg, True) - contents = self.utilities.getLineContentsAtOffset(newFocus, caretOffset) - else: - tokens = ["WEB: New focus", newFocus, "is not a special case. Generating speech."] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - args['priorObj'] = oldFocus + last_command_was_caret_nav = ( + caret_navigator.get_navigator().last_input_event_was_navigation_command() + ) + last_command_was_struct_nav = ( + last_command_was_struct_nav + or table_navigator.get_navigator().last_input_event_was_navigation_command() + ) + manager = input_event_manager.get_manager() + last_command_was_line_nav = ( + manager.last_event_was_line_navigation() and not last_command_was_caret_nav + ) - if newFocus and AXObject.is_dead(newFocus): + args["priorObj"] = old_focus + if ( + manager.last_event_was_mouse_button() + and event + and event.type.startswith("object:text-caret-moved") + ): + msg = "WEB: Last input event was mouse button. Generating line." + debug.print_message(debug.LEVEL_INFO, msg, True) + contents = self.utilities.get_line_contents_at_offset(new_focus, caret_offset) + elif ( + self.utilities.is_content_editable_with_embedded_objects(new_focus) + and ( + last_command_was_caret_nav + or last_command_was_struct_nav + or last_command_was_line_nav + ) + and not (AXUtilities.is_table_cell(new_focus) and AXObject.get_name(new_focus)) + ): + tokens = ["WEB: New focus", new_focus, "content editable. Generating line."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + contents = self.utilities.get_line_contents_at_offset(new_focus, caret_offset) + elif AXUtilities.is_anchor(new_focus): + tokens = ["WEB: New focus", new_focus, "is anchor. Generating line."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + contents = self.utilities.get_line_contents_at_offset(new_focus, 0) + elif ( + input_event_manager.get_manager().last_event_was_page_navigation() + and not AXUtilities.get_table(new_focus) + and not AXUtilities.is_feed_article(new_focus) + ): + tokens = ["WEB: New focus", new_focus, "was scrolled to. Generating line."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + contents = self.utilities.get_line_contents_at_offset(new_focus, caret_offset) + elif self.utilities.is_focused_with_math_child(new_focus): + tokens = ["WEB: New focus", new_focus, "has math child. Generating line."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + contents = self.utilities.get_line_contents_at_offset(new_focus, caret_offset) + elif AXUtilities.is_heading(new_focus): + tokens = ["WEB: New focus", new_focus, "is heading. Generating object."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + contents = self.utilities.get_object_contents_at_offset(new_focus, 0) + elif self.utilities.caret_moved_to_same_page_fragment(event, old_focus): + assert event is not None + tokens = ["WEB: Source", event.source, "is same page fragment. Generating line."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + contents = self.utilities.get_line_contents_at_offset(new_focus, 0) + elif ( + event + and event.type.startswith("object:children-changed:remove") + and document_presenter.get_presenter().is_focus_mode_widget(self, new_focus) + ): + tokens = [ + "WEB: New focus", + new_focus, + "is recovery from removed child. Generating speech.", + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + elif last_command_was_line_nav and not AXObject.is_valid(old_focus): + msg = "WEB: Last input event was line nav; old_focus is invalid. Generating line." + debug.print_message(debug.LEVEL_INFO, msg, True) + contents = self.utilities.get_line_contents_at_offset(new_focus, caret_offset) + elif ( + last_command_was_line_nav and event and event.type.startswith("object:children-changed") + ): + msg = "WEB: Last input event was line nav and children changed. Generating line." + debug.print_message(debug.LEVEL_INFO, msg, True) + contents = self.utilities.get_line_contents_at_offset(new_focus, caret_offset) + else: + tokens = ["WEB: New focus", new_focus, "is not a special case. Generating speech."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if new_focus and AXObject.is_dead(new_focus): msg = "WEB: New focus has since died" - debug.printMessage(debug.LEVEL_INFO, msg, True) - if self._getQueuedEvent("object:state-changed:focused", True): + debug.print_message(debug.LEVEL_INFO, msg, True) + if self._get_queued_event("object:state-changed:focused", True): msg = "WEB: Have matching focused event. Not speaking contents" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True + presentation_manager.get_manager().interrupt_if_needed_for_focus_change( + old_focus, new_focus, event + ) + if contents: - self.speakContents(contents, **args) + presentation_manager.get_manager().speak_contents(contents, **args) else: - utterances = self.speechGenerator.generateSpeech(newFocus, **args) - speech.speak(utterances) - - self._saveFocusedObjectInfo(newFocus) - - if not self._focusModeIsSticky \ - and not self._browseModeIsSticky \ - and self.useFocusMode(newFocus, oldFocus) != self._inFocusMode: - self.togglePresentationMode(None, document) - - if not self.utilities.inDocumentContent(oldFocus): - self.refreshKeyGrabs() + presentation_manager.get_manager().present_object( + self, + new_focus, + generate_braille=False, + **args, # type: ignore[arg-type] + ) + document_presenter.get_presenter().update_mode_if_needed(self, old_focus, new_focus) return True - def onActiveChanged(self, event): + def _on_active_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:active accessibility events.""" - if not self.utilities.inDocumentContent(event.source): + if not self.utilities.in_document_content(event.source): msg = "WEB: Event source is not in document content" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return False if not event.detail1: msg = "WEB: Ignoring because event source is now inactive" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True if AXUtilities.is_dialog_or_alert(event.source): msg = "WEB: Event handled: Setting locusOfFocus to event source" - debug.printMessage(debug.LEVEL_INFO, msg, True) - cthulhu.setLocusOfFocus(event, event.source) + debug.print_message(debug.LEVEL_INFO, msg, True) + focus_manager.get_manager().set_locus_of_focus(event, event.source) return True return False - def onActiveDescendantChanged(self, event): - """Callback for object:active-descendant-changed accessibility events.""" - - if not self.utilities.inDocumentContent(event.source): - msg = "WEB: Event source is not in document content" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return False - - return True - - def onBusyChanged(self, event): + def _on_busy_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:busy accessibility events.""" - if event.detail1 and self._loadingDocumentContent: - msg = "WEB: Ignoring: Already loading document content" - debug.printMessage(debug.LEVEL_INFO, msg, True) + if AXUtilities.has_no_size(event.source): + msg = "WEB: Ignoring event from page with no size." + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if not self.utilities.inDocumentContent(event.source): + if not AXDocument.get_uri(event.source): + msg = "WEB: Ignoring event from page with no URI." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + AXUtilities.clear_all_cache_now(event.source, "busy-changed event.") + + if event.detail1 and self._loading_content: + msg = "WEB: Ignoring: Already loading document content" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + if not self.utilities.in_document_content(event.source): msg = "WEB: Event source is not in document content" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return False - if not AXUtilities.is_document_web(event.source) \ - and not self.utilities.isOrDescendsFrom(cthulhu_state.locusOfFocus, event.source): - msg = "WEB: Ignoring: Not document and not something we're in" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - - self.structuralNavigation.clearCache() - - if self.utilities.getDocumentForObject(AXObject.get_parent(event.source)): - msg = "WEB: Ignoring: Event source is nested document" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - - obj, offset = self.utilities.getCaretContext() - if not obj or self.utilities.isZombie(obj): - self.utilities.clearCaretContext() - - shouldPresent = True - if not (AXUtilities.is_showing(event.source) or AXUtilities.is_visible(event.source)): - shouldPresent = False - msg = "WEB: Not presenting because source is not showing or visible" - debug.printMessage(debug.LEVEL_INFO, msg, True) - elif not self.utilities.documentFrameURI(event.source): - shouldPresent = False - msg = "WEB: Not presenting because source lacks URI" - debug.printMessage(debug.LEVEL_INFO, msg, True) - elif not event.detail1 and self._inFocusMode and not self.utilities.isZombie(obj): - shouldPresent = False - tokens = ["WEB: Not presenting due to focus mode for", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - - if not cthulhu.cthulhuApp.settingsManager.getSetting('onlySpeakDisplayedText') and shouldPresent: - if event.detail1: - self.presentMessage(messages.PAGE_LOADING_START) - elif AXObject.get_name(event.source): - msg = messages.PAGE_LOADING_END_NAMED % AXObject.get_name(event.source) - self.presentMessage(msg, resetStyles=False) - else: - self.presentMessage(messages.PAGE_LOADING_END) - - activeDocument = self.utilities.activeDocument() - if activeDocument and activeDocument != event.source: + active_document = self.utilities.active_document() + if active_document and active_document != event.source: msg = "WEB: Ignoring: Event source is not active document" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - self._loadingDocumentContent = event.detail1 + focus = focus_manager.get_manager().get_locus_of_focus() + if not AXUtilities.is_document_web(event.source) and not AXUtilities.is_ancestor( + focus, + event.source, + True, + ): + msg = "WEB: Ignoring: Not document and not something we're in" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + if self.utilities.get_document_for_object(AXObject.get_parent(event.source)): + msg = "WEB: Ignoring: Event source is nested document" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + obj, offset = self.utilities.get_caret_context() + if not AXObject.is_valid(obj): + self.utilities.clear_caret_context() + + should_present = True + mgr = speech_presenter.get_presenter() + if mgr.get_only_speak_displayed_text(): + should_present = False + msg = "WEB: Not presenting due to settings" + debug.print_message(debug.LEVEL_INFO, msg, True) + elif not (AXUtilities.is_showing(event.source) or AXUtilities.is_visible(event.source)): + should_present = False + msg = "WEB: Not presenting because source is not showing or visible" + debug.print_message(debug.LEVEL_INFO, msg, True) + elif not AXDocument.get_uri(event.source): + should_present = False + msg = "WEB: Not presenting because source lacks URI" + debug.print_message(debug.LEVEL_INFO, msg, True) + elif ( + not event.detail1 + and document_presenter.get_presenter().in_focus_mode(self.app) + and AXObject.is_valid(obj) + ): + should_present = False + tokens = ["WEB: Not presenting due to focus mode for", obj] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + elif not mgr.use_verbose_speech(): + should_present = not event.detail1 + tokens = ["WEB: Brief verbosity set. Should present", obj, f": {should_present}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if should_present and AXDocument.get_uri(event.source).startswith("http"): + if event.detail1: + presentation_manager.get_manager().present_message(messages.PAGE_LOADING_START) + elif AXObject.get_name(event.source): + if not mgr.use_verbose_speech(): + msg = AXObject.get_name(event.source) + else: + msg = messages.PAGE_LOADING_END_NAMED % AXObject.get_name(event.source) + presentation_manager.get_manager().present_message(msg) + else: + presentation_manager.get_manager().present_message(messages.PAGE_LOADING_END) + + self._loading_content = event.detail1 if event.detail1: return True - self.utilities.clearCachedObjects() + self.utilities.clear_cached_objects() if AXObject.is_dead(obj): obj = None - if not AXObject.is_dead(cthulhu_state.locusOfFocus) \ - and not self.utilities.inDocumentContent(cthulhu_state.locusOfFocus) \ - and AXUtilities.is_focused(cthulhu_state.locusOfFocus): + if ( + not focus_manager.get_manager().focus_is_dead() + and not self.utilities.in_document_content(focus) + and AXUtilities.is_focused(focus) + ): msg = "WEB: Not presenting content, focus is outside of document" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if cthulhu.cthulhuApp.settingsManager.getSetting('pageSummaryOnLoad') and shouldPresent: - obj = obj or event.source - tokens = ["WEB: Getting page summary for obj", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - summary = self.utilities.getPageSummary(obj) + if document_presenter.get_presenter().get_page_summary_on_load() and should_present: + tokens = ["WEB: Getting page summary for", event.source] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + summary = AXDocument.get_document_summary(event.source) if summary: - self.presentMessage(summary) + presentation_manager.get_manager().present_message(summary) - obj, offset = self.utilities.getCaretContext() - if self.useFocusMode(obj) != self._inFocusMode: - self.togglePresentationMode(None) + obj, offset = self.utilities.get_caret_context() + if not AXUtilities.is_busy(event.source): + document_presenter.get_presenter().update_mode_if_needed(self, None, obj) if not obj: msg = "WEB: Could not get caret context" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self.utilities.isFocusModeWidget(obj): + if document_presenter.get_presenter().is_focus_mode_widget(self, obj): tokens = ["WEB: Setting locus of focus to focusModeWidget", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - cthulhu.setLocusOfFocus(event, obj) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + focus_manager.get_manager().set_locus_of_focus(event, obj) return True - if self.utilities.isLink(obj) and AXUtilities.is_focused(obj): + if self.utilities.is_link(obj) and AXUtilities.is_focused(obj): tokens = ["WEB: Setting locus of focus to focused link", obj, ". No SayAll."] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - cthulhu.setLocusOfFocus(event, obj) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + focus_manager.get_manager().set_locus_of_focus(event, obj) return True if offset > 0: tokens = ["WEB: Setting locus of focus to context obj", obj, ". No SayAll"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - cthulhu.setLocusOfFocus(event, obj) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + focus_manager.get_manager().set_locus_of_focus(event, obj) return True - if not AXUtilities.is_focused(cthulhu_state.locusOfFocus): + if not AXUtilities.is_focused(focus_manager.get_manager().get_locus_of_focus()): tokens = ["WEB: Setting locus of focus to context obj", obj, "(no notification)"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - cthulhu.setLocusOfFocus(event, obj, False) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + focus_manager.get_manager().set_locus_of_focus(event, obj, False) - self.updateBraille(obj) - if self.utilities.documentFragment(event.source): + self.update_braille(obj) + if AXDocument.get_document_uri_fragment(event.source): msg = "WEB: Not doing SayAll due to page fragment" - debug.printMessage(debug.LEVEL_INFO, msg, True) - elif not cthulhu.cthulhuApp.settingsManager.getSetting('sayAllOnLoad'): + debug.print_message(debug.LEVEL_INFO, msg, True) + elif not document_presenter.get_presenter().get_say_all_on_load(): msg = "WEB: Not doing SayAll due to sayAllOnLoad being False" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.speakContents(self.utilities.getLineContentsAtOffset(obj, offset)) - elif cthulhu.cthulhuApp.settingsManager.getSetting('enableSpeech'): + debug.print_message(debug.LEVEL_INFO, msg, True) + presentation_manager.get_manager().speak_contents( + self.utilities.get_line_contents_at_offset(obj, offset), + ) + elif speech_manager.get_manager().get_speech_is_enabled_and_not_muted(): msg = "WEB: Doing SayAll" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.sayAll(None) + debug.print_message(debug.LEVEL_INFO, msg, True) + say_all_presenter.get_presenter().say_all(self, None) else: - msg = "WEB: Not doing SayAll due to enableSpeech being False" - debug.printMessage(debug.LEVEL_INFO, msg, True) + msg = "WEB: Not doing SayAll due to speech being disabled or muted" + debug.print_message(debug.LEVEL_INFO, msg, True) return True - def onCaretMoved(self, event): + def _on_caret_moved(self, event: Atspi.Event) -> bool: """Callback for object:text-caret-moved accessibility events.""" - self.utilities.sanityCheckActiveWindow() - - if self.utilities.isZombie(event.source): - msg = "WEB: Event source is Zombie" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - - document = self.utilities.getTopLevelDocumentForObject(event.source) + reason = AXUtilities.get_text_event_reason(event) + document = self.utilities.get_top_level_document_for_object(event.source) if not document: - if self.utilities.eventIsBrowserUINoise(event): + if self.utilities.event_is_browser_ui_noise_deprecated(event): msg = "WEB: Ignoring event believed to be browser UI noise" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self.utilities.eventIsBrowserUIAutocompleteNoise(event): + if self.utilities.event_is_browser_ui_autocomplete_noise_deprecated(event): msg = "WEB: Ignoring event believed to be browser UI autocomplete noise" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + if self.utilities.in_find_container() and reason not in ( + TextEventReason.NAVIGATION_BY_CHARACTER, + TextEventReason.NAVIGATION_BY_WORD, + TextEventReason.NAVIGATION_TO_LINE_BOUNDARY, + ): + msg = "WEB: Event handled: Presenting find results" + debug.print_message(debug.LEVEL_INFO, msg, True) + document_presenter.get_presenter().present_find_results(event.source, event.detail1) return True msg = "WEB: Event source is not in document content" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return False - obj, offset = self.utilities.getCaretContext(document, False, False) - tokens = ["WEB: Context: ", obj, ", ", offset, "(focus: ", cthulhu_state.locusOfFocus, ")"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + obj, offset = self.utilities.get_caret_context(document, False, False) + tokens = ["WEB: Context: ", obj, ", ", offset] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) - if self.utilities.lastInputEventWasCaretNavWithSelection() and event.detail1 < 0: - if self._presentSyntheticCaretSelection(event, document, obj, offset): - msg = "WEB: Event handled: synthetic caret selection" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - else: - self._clearSyntheticWebSelection() - - if self._lastCommandWasCaretNav: + # TODO - JD: Make this a TextEventReason. + if caret_navigator.get_navigator().last_input_event_was_navigation_command(): msg = "WEB: Event ignored: Last command was caret nav" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self._lastCommandWasStructNav: + # TODO - JD: Make this a TextEventReason. + if structural_navigator.get_navigator().last_input_event_was_navigation_command(): msg = "WEB: Event ignored: Last command was struct nav" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self._lastCommandWasMouseButton: - msg = "WEB: Last command was mouse button" - debug.printMessage(debug.LEVEL_INFO, msg, True) + # TODO - JD: Make this a TextEventReason. + if table_navigator.get_navigator().last_input_event_was_navigation_command(): + msg = "WEB: Event ignored: Last command was table nav" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + if reason == TextEventReason.SAY_ALL: + msg = "WEB: Event handled: SayAll triggered" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + if reason == TextEventReason.MOUSE_PRIMARY_BUTTON: if (event.source, event.detail1) == (obj, offset): msg = "WEB: Event is for current caret context." - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if (event.source, event.detail1) == self._lastMouseButtonContext: + if (event.source, event.detail1) == self._last_mouse_button_context: msg = "WEB: Event is for last mouse button context." - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - self._lastMouseButtonContext = event.source, event.detail1 + self._last_mouse_button_context = event.source, event.detail1 msg = "WEB: Event handled: Last command was mouse button" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.utilities.setCaretContext(event.source, event.detail1) - notify = not self.utilities.isEntryDescendant(event.source) - cthulhu.setLocusOfFocus(event, event.source, notify, True) - if cthulhu_state.locusOfFocus == event.source: - self.updateBraille(event.source) + debug.print_message(debug.LEVEL_INFO, msg, True) + self.utilities.set_caret_context(event.source, event.detail1) + notify = AXUtilities.find_ancestor_inclusive(obj, AXUtilities.is_entry) is None + focus_manager.get_manager().set_locus_of_focus(event, event.source, notify, True) return True - if self.utilities.lastInputEventWasTab(): - msg = "WEB: Last input event was Tab." - debug.printMessage(debug.LEVEL_INFO, msg, True) - - if self.utilities.isDocument(event.source): - msg = "WEB: Event ignored: Caret moved in document due to Tab." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - - if self.utilities.inFindContainer(): - msg = "WEB: Event handled: Presenting find results" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.presentFindResults(event.source, event.detail1) - self._saveFocusedObjectInfo(cthulhu_state.locusOfFocus) + if reason == TextEventReason.FOCUS_CHANGE: + msg = "WEB: Event ignored: Caret moved due to focus change." + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if not self.utilities.eventIsFromLocusOfFocusDocument(event): + if not self.utilities.event_is_from_locus_of_focus_document(event): msg = "WEB: Event ignored: Not from locus of focus document" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self.utilities.textEventIsDueToInsertion(event): + if reason == TextEventReason.UI_UPDATE: + msg = "WEB: Event ignored: Caret moved due to UI update." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + if reason in [TextEventReason.TYPING, TextEventReason.TYPING_ECHOABLE]: msg = "WEB: Event handled: Updating position due to insertion" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self._saveLastCursorPosition(event.source, event.detail1) + debug.print_message(debug.LEVEL_INFO, msg, True) + focus_manager.get_manager().set_last_cursor_position(event.source, event.detail1) return True - if self.utilities.textEventIsDueToDeletion(event): + if reason in (TextEventReason.DELETE, TextEventReason.BACKSPACE): msg = "WEB: Event handled: Updating position due to deletion" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self._saveLastCursorPosition(event.source, event.detail1) + debug.print_message(debug.LEVEL_INFO, msg, True) + focus_manager.get_manager().set_last_cursor_position(event.source, event.detail1) return True - if self.utilities.isItemForEditableComboBox(cthulhu_state.locusOfFocus, event.source) \ - and not self.utilities.lastInputEventWasCharNav() \ - and not self.utilities.lastInputEventWasLineBoundaryNav(): + focus = focus_manager.get_manager().get_locus_of_focus() + i_e_manager = input_event_manager.get_manager() + if ( + self.utilities.is_item_for_editable_combo_box(focus, event.source) + and not i_e_manager.last_event_was_character_navigation() + and not i_e_manager.last_event_was_line_boundary_navigation() + ): msg = "WEB: Event ignored: Editable combobox noise" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self.utilities.eventIsAutocompleteNoise(event, document): + if self.utilities.event_is_autocomplete_noise_deprecated(event, document): msg = "WEB: Event ignored: Autocomplete noise" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self._inFocusMode and self.utilities.caretMovedOutsideActiveGrid(event): + if document_presenter.get_presenter().in_focus_mode( + self.app, + ) and self.utilities.caret_moved_outside_active_grid(event): msg = "WEB: Event ignored: Caret moved outside active grid during focus mode" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self.utilities.treatEventAsSpinnerValueChange(event): + if self.utilities.treat_event_as_spinner_value_change_deprecated(event): msg = "WEB: Event handled as the value-change event we wish we'd get" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.updateBraille(event.source) - self._presentTextAtNewCaretPosition(event) + debug.print_message(debug.LEVEL_INFO, msg, True) + self.update_braille(event.source) + self._present_caret_moved_event(event) return True - if not self.utilities.queryNonEmptyText(event.source) \ - and not AXUtilities.is_editable(event.source): + if not self.utilities.treat_as_text_object(event.source) and not AXUtilities.is_editable( + event.source, + ): msg = "WEB: Event ignored: Was for non-editable object we're treating as textless" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - obj, offset = self.utilities.findFirstCaretContext(event.source, event.detail1) notify = force = handled = False - AXObject.clear_cache(event.source) + AXObject.clear_cache(event.source, False, "Updating state for caret moved event.") - if self.utilities.lastInputEventWasPageNav(): + if document_presenter.get_presenter().in_focus_mode(self.app): + obj, offset = event.source, event.detail1 + else: + obj, offset = self.utilities.first_context(event.source, event.detail1) + + if reason == TextEventReason.NAVIGATION_BY_PAGE: msg = "WEB: Caret moved due to scrolling." - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) notify = force = handled = True - elif self.utilities.caretMovedToSamePageFragment(event): + elif self.utilities.caret_moved_to_same_page_fragment(event): msg = "WEB: Caret moved to fragment." - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) notify = force = handled = True - elif self.utilities.lastInputEventWasCaretNav(): + elif ( + event.source != focus + and AXUtilities.is_editable(event.source) + and (AXUtilities.is_focused(event.source) or not AXUtilities.is_focusable(event.source)) + ): + msg = "WEB: Editable object is not (yet) the locus of focus." + debug.print_message(debug.LEVEL_INFO, msg, True) + notify = force = handled = ( + i_e_manager.last_event_was_line_navigation() + or i_e_manager.last_event_was_return() + or i_e_manager.last_event_was_command() + ) + + elif i_e_manager.last_event_was_caret_navigation(): msg = "WEB: Caret moved due to native caret navigation." - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) - elif self.utilities.isTextField(event.source) \ - and AXUtilities.is_focused(event.source) \ - and event.source != cthulhu_state.locusOfFocus: - msg = "WEB: Focused text field is not (yet) the locus of focus." - debug.printMessage(debug.LEVEL_INFO, msg, True) - notify = force = handled = True - - tokens = ["WEB: Setting context and focus to: ", obj, ", ", offset] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - self.utilities.setCaretContext(obj, offset, document) - cthulhu.setLocusOfFocus(event, obj, notify, force) + tokens = ["WEB: Setting context and focus to: ", obj, ", ", offset, f", notify: {notify}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self.utilities.set_caret_context(obj, offset, document) + focus_manager.get_manager().set_locus_of_focus(event, obj, notify, force) return handled - def onCheckedChanged(self, event): + def _on_checked_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:checked accessibility events.""" - if not self.utilities.inDocumentContent(event.source): - msg = "WEB: Event source is not in document content" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return False + msg = "WEB: This event is is handled by the toolkit or default script." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False - obj, offset = self.utilities.getCaretContext() - if obj != event.source: - msg = "WEB: Event source is not context object" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - - oldObj, oldState = self.pointOfReference.get('checkedChange', (None, 0)) - if hash(oldObj) == hash(obj) and oldState == event.detail1: - msg = "WEB: Ignoring event, state hasn't changed" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - - if not (self._lastCommandWasCaretNav and AXUtilities.is_radio_button(obj)): - msg = "WEB: Event is something default can handle" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return False - - self.presentObject(obj, alreadyFocused=True, interrupt=True) - self.pointOfReference['checkedChange'] = hash(obj), event.detail1 - return True - - def onChildrenAdded(self, event): + def _on_children_added(self, event: Atspi.Event) -> bool: """Callback for object:children-changed:add accessibility events.""" - AXObject.clear_cache_now("children-changed event.") - if self.utilities.eventIsBrowserUINoise(event): + AXUtilities.clear_all_cache_now(event.source, "children-changed event.") + + if self.utilities.event_is_browser_ui_noise_deprecated(event): msg = "WEB: Ignoring event believed to be browser UI noise" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - isLiveRegion = self.utilities.isLiveRegion(event.source) - document = self.utilities.getTopLevelDocumentForObject(event.source) - if document and not isLiveRegion: - if event.source == cthulhu_state.locusOfFocus: - tokens = ["WEB: Dumping cache and context: source is focus", - cthulhu_state.locusOfFocus] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - self.utilities.dumpCache(document, preserveContext=False) - elif AXObject.is_dead(cthulhu_state.locusOfFocus): + is_live_region = AXUtilities.is_live_region(event.source) + document = self.utilities.get_top_level_document_for_object(event.source) + if document and not is_live_region: + focus = focus_manager.get_manager().get_locus_of_focus() + if event.source == focus: + msg = "WEB: Dumping cache: source is focus" + debug.print_message(debug.LEVEL_INFO, msg, True) + self.utilities.dump_cache(document, preserve_context=True) + elif focus_manager.get_manager().focus_is_dead(): msg = "WEB: Dumping cache: dead focus" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.utilities.dumpCache(document, preserveContext=True) - elif AXObject.find_ancestor(cthulhu_state.locusOfFocus, lambda x: x == event.source): - tokens = ["WEB: Dumping cache: source is ancestor of focus", - cthulhu_state.locusOfFocus] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - self.utilities.dumpCache(document, preserveContext=True) + debug.print_message(debug.LEVEL_INFO, msg, True) + self.utilities.dump_cache(document, preserve_context=True) + elif AXUtilities.find_ancestor(focus, lambda x: x == event.source): + msg = "WEB: Dumping cache: source is ancestor of focus" + debug.print_message(debug.LEVEL_INFO, msg, True) + self.utilities.dump_cache(document, preserve_context=True) else: - tokens = ["WEB: Not dumping full cache. Focus is", cthulhu_state.locusOfFocus] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - self.utilities.clearCachedObjects() - - elif isLiveRegion: - if self.utilities.handleAsLiveRegion(event): - msg = "WEB: Event to be handled as live region" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.liveRegionManager.handleEvent(event) - else: - msg = "WEB: Ignoring because live region event not to be handled." - debug.printMessage(debug.LEVEL_INFO, msg, True) + msg = "WEB: Not dumping full cache" + debug.print_message(debug.LEVEL_INFO, msg, True) + self.utilities.clear_cached_objects() + elif is_live_region: + msg = "WEB: Ignoring event from live region." + debug.print_message(debug.LEVEL_INFO, msg, True) return True else: msg = "WEB: Could not get document for event source" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return False - if self._loadingDocumentContent: + if self._loading_content: msg = "WEB: Ignoring because document content is being loaded." - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self.utilities.isZombie(document): - tokens = ["WEB: Ignoring because", document, "is zombified."] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + if not AXObject.is_valid(document): + tokens = ["WEB: Ignoring because", document, "is not valid."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return True if AXUtilities.is_busy(document): tokens = ["WEB: Ignoring because", document, "is busy."] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return True - if not event.any_data or self.utilities.isZombie(event.any_data): - msg = "WEB: Ignoring because any data is null or zombified." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - - if self.utilities.handleEventFromContextReplicant(event, event.any_data): + if self.utilities.handle_event_from_context_replicant(event, event.any_data): msg = "WEB: Event handled by updating locusOfFocus and context to child." - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True if AXUtilities.is_alert(event.any_data): - if event.any_data == self.utilities.lastQueuedLiveRegion(): - tokens = ["WEB: Ignoring", event.any_data, "(is last queued live region)"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return True - msg = "WEB: Presenting event.any_data" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.presentObject(event.any_data, interrupt=True) + debug.print_message(debug.LEVEL_INFO, msg, True) + self.present_object(event.any_data, interrupt=True) focused = AXUtilities.get_focused_object(event.any_data) if focused: - notify = self.utilities.queryNonEmptyText(focused) is None + notify = not self.utilities.treat_as_text_object(focused) tokens = ["WEB: Setting locusOfFocus and caret context to", focused] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - cthulhu.setLocusOfFocus(event, focused, notify) - self.utilities.setCaretContext(focused, 0) - return True - - if self.lastMouseRoutingTime and 0 < time.time() - self.lastMouseRoutingTime < 1: - utterances = [] - utterances.append(messages.NEW_ITEM_ADDED) - utterances.extend(self.speechGenerator.generateSpeech(event.any_data, force=True)) - speech.speak(utterances) - self._lastMouseOverObject = event.any_data - self.preMouseOverContext = self.utilities.getCaretContext() + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + focus_manager.get_manager().set_locus_of_focus(event, focused, notify) + self.utilities.set_caret_context(focused, 0) return True return False - def onChildrenRemoved(self, event): + def _on_children_removed(self, event: Atspi.Event) -> bool: """Callback for object:children-changed:removed accessibility events.""" - AXObject.clear_cache_now("children-changed event.") - if not self.utilities.inDocumentContent(event.source): + AXUtilities.clear_all_cache_now(event.source, "children-changed event.") + + if not self.utilities.in_document_content(event.source): msg = "WEB: Event source is not in document content." - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return False - if self._loadingDocumentContent: + if self._loading_content: msg = "WEB: Ignoring because document content is being loaded." - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self.utilities.isLiveRegion(event.source): - if self.utilities.handleEventForRemovedChild(event): + if AXUtilities.is_live_region(event.source): + if self.utilities.handle_event_for_removed_child(event): msg = "WEB: Event handled for removed live-region child." - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) else: msg = "WEB: Ignoring removal from live region." - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - document = self.utilities.getTopLevelDocumentForObject(event.source) + document = self.utilities.get_top_level_document_for_object(event.source) if document: - if event.source == cthulhu_state.locusOfFocus: - tokens = ["WEB: Dumping cache and context: source is focus", - cthulhu_state.locusOfFocus] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - self.utilities.dumpCache(document, preserveContext=False) - elif AXObject.is_dead(cthulhu_state.locusOfFocus): + focus = focus_manager.get_manager().get_locus_of_focus() + if event.source == focus: + msg = "WEB: Dumping cache: source is focus" + debug.print_message(debug.LEVEL_INFO, msg, True) + self.utilities.dump_cache(document, preserve_context=True) + elif focus_manager.get_manager().focus_is_dead(): msg = "WEB: Dumping cache: dead focus" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.utilities.dumpCache(document, preserveContext=True) - elif AXObject.find_ancestor(cthulhu_state.locusOfFocus, lambda x: x == event.source): - tokens = ["WEB: Dumping cache: source is ancestor of focus", - cthulhu_state.locusOfFocus] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - self.utilities.dumpCache(document, preserveContext=True) + debug.print_message(debug.LEVEL_INFO, msg, True) + self.utilities.dump_cache(document, preserve_context=True) + elif AXUtilities.find_ancestor(focus, lambda x: x == event.source): + msg = "WEB: Dumping cache: source is ancestor of focus" + debug.print_message(debug.LEVEL_INFO, msg, True) + self.utilities.dump_cache(document, preserve_context=True) else: - tokens = ["WEB: Not dumping full cache. Focus is", cthulhu_state.locusOfFocus] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - self.utilities.clearCachedObjects() + msg = "WEB: Not dumping full cache" + debug.print_message(debug.LEVEL_INFO, msg, True) + self.utilities.clear_cached_objects() - if self.utilities.handleEventForRemovedChild(event): + if self.utilities.handle_event_for_removed_child(event): msg = "WEB: Event handled for removed child." - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True return False - def onColumnReordered(self, event): + def _on_column_reordered(self, event: Atspi.Event) -> bool: """Callback for object:column-reordered accessibility events.""" - if not self.utilities.inDocumentContent(event.source): + if not self.utilities.in_document_content(event.source): msg = "WEB: Event source is not in document content" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return False - if event.source != self.utilities.getTable(cthulhu_state.locusOfFocus): - tokens = ["WEB: locusOfFocus (", cthulhu_state.locusOfFocus, ") is not in this table"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + focus = focus_manager.get_manager().get_locus_of_focus() + if event.source != AXUtilities.get_table(focus): + msg = "WEB: focus is not in this table" + debug.print_message(debug.LEVEL_INFO, msg, True) return False - self.pointOfReference['last-table-sort-time'] = time.time() - self.presentMessage(messages.TABLE_REORDERED_COLUMNS) - header = self.utilities.containingTableHeader(cthulhu_state.locusOfFocus) - if header: - self.presentMessage(self.utilities.getSortOrderDescription(header, True)) - + presentation_manager.get_manager().present_message(messages.TABLE_REORDERED_COLUMNS) + header = AXUtilities.find_ancestor_inclusive(focus, AXUtilities.is_table_header) + msg = AXUtilities.get_presentable_sort_order_from_header(header, True) + if msg: + presentation_manager.get_manager().present_message(msg) return True - def onDocumentLoadComplete(self, event): + def _on_document_load_complete(self, event: Atspi.Event) -> bool: """Callback for document:load-complete accessibility events.""" - if self.utilities.getDocumentForObject(AXObject.get_parent(event.source)): + if AXUtilities.has_no_size(event.source): + msg = "WEB: Ignoring event from page with no size." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + uri = AXDocument.get_uri(event.source) + if not uri: + msg = "WEB: Ignoring event from page with no URI." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + if uri.startswith("moz-extension"): + msg = f"WEB: Ignoring event from page with URI: {uri}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + AXUtilities.clear_all_cache_now(event.source, "load-complete event.") + if self.utilities.get_document_for_object(AXObject.get_parent(event.source)): msg = "WEB: Ignoring: Event source is nested document" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True msg = "WEB: Updating loading state and resetting live regions" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self._loadingDocumentContent = False - self.liveRegionManager.reset() + debug.print_message(debug.LEVEL_INFO, msg, True) + self._loading_content = False + live_region_presenter.get_presenter().reset() return True - def onDocumentLoadStopped(self, event): + def _on_document_load_stopped(self, event: Atspi.Event) -> bool: """Callback for document:load-stopped accessibility events.""" - if self.utilities.getDocumentForObject(AXObject.get_parent(event.source)): + if not AXDocument.get_uri(event.source): + msg = "WEB: Ignoring event from page with no URI." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + if self.utilities.get_document_for_object(AXObject.get_parent(event.source)): msg = "WEB: Ignoring: Event source is nested document" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True msg = "WEB: Updating loading state" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self._loadingDocumentContent = False + debug.print_message(debug.LEVEL_INFO, msg, True) + self._loading_content = False return True - def onDocumentReload(self, event): + def _on_document_reload(self, event: Atspi.Event) -> bool: """Callback for document:reload accessibility events.""" - if self.utilities.getDocumentForObject(AXObject.get_parent(event.source)): + if not AXDocument.get_uri(event.source): + msg = "WEB: Ignoring event from page with no URI." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + if self.utilities.get_document_for_object(AXObject.get_parent(event.source)): msg = "WEB: Ignoring: Event source is nested document" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True msg = "WEB: Updating loading state" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self._loadingDocumentContent = True + debug.print_message(debug.LEVEL_INFO, msg, True) + self._loading_content = True return True - def onExpandedChanged(self, event): + def _on_expanded_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:expanded accessibility events.""" - if self.utilities.isZombie(event.source): - msg = "WEB: Event source is Zombie" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - - if not self.utilities.inDocumentContent(event.source): + if not self.utilities.in_document_content(event.source): msg = "WEB: Event source is not in document content" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return False - obj, offset = self.utilities.getCaretContext(searchIfNeeded=False) - tokens = ["WEB: Caret context is", obj, ", ", offset, - "(focus: ", cthulhu_state.locusOfFocus, ")"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - if not obj or self.utilities.isZombie(obj) and event.source == cthulhu_state.locusOfFocus: + focus = focus_manager.get_manager().get_locus_of_focus() + obj, offset = self.utilities.get_caret_context(search_if_needed=False) + tokens = ["WEB: Caret context is", obj, ", ", offset] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if not AXObject.is_valid(obj) and event.source == focus: msg = "WEB: Setting caret context to event source" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.utilities.setCaretContext(event.source, 0) + debug.print_message(debug.LEVEL_INFO, msg, True) + self.utilities.set_caret_context(event.source, 0) return False - def onFocus(self, event): - """Callback for focus: accessibility events.""" - - # We should get proper state-changed events for these. - if self.utilities.inDocumentContent(event.source): - if self._getQueuedEvent("object:state-changed:focused", True): - msg = "WEB: Ignoring because object:state-changed-focused expected." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - - msg = "WEB: Handling focus event in document content; focused event missing." - debug.printMessage(debug.LEVEL_INFO, msg, True) - cthulhu.setLocusOfFocus(event, event.source) - return True - - return False - - def onFocusedChanged(self, event): + def _on_focused_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:focused accessibility events.""" if not event.detail1: msg = "WEB: Ignoring because event source lost focus" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self.utilities.isZombie(event.source): - msg = "WEB: Event source is Zombie" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - - document = self.utilities.getDocumentForObject(event.source) + document = self.utilities.get_top_level_document_for_object(event.source) if not document: msg = "WEB: Could not get document for event source" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return False - prevDocument = self.utilities.getDocumentForObject(cthulhu_state.locusOfFocus) - if prevDocument != document: - tokens = ["WEB: document changed from", prevDocument, "to", document] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - elif document == event.source: - msg = "WEB: Ignoring focus change to document ancestor of focus" - debug.printMessage(debug.LEVEL_INFO, msg, True) + if focus_manager.get_manager().in_say_all(): + msg = "WEB: Ignoring focus change during say all" + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self.utilities.isWebAppDescendant(event.source): - if self._browseModeIsSticky: - msg = "WEB: Web app descendant claimed focus, but browse mode is sticky" - debug.printMessage(debug.LEVEL_INFO, msg, True) - elif AXUtilities.is_tool_tip(event.source) \ - and AXObject.find_ancestor(cthulhu_state.locusOfFocus, lambda x: x == event.source): + focus = focus_manager.get_manager().get_locus_of_focus() + prev_document = self.utilities.get_top_level_document_for_object(focus) + if prev_document != document: + tokens = ["WEB: document changed from", prev_document, "to", document] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + elif document == event.source: + msg = "WEB: Ignoring focus change to document ancestor of focus" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + if AXUtilities.is_link(event.source) and AXUtilities.is_ancestor(focus, event.source): + msg = "WEB: Ignoring focus change on link ancestor of focus" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + if caret_navigator.get_navigator().last_input_event_was_navigation_command(): + msg = "WEB: Event ignored: Last command was caret nav" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + if structural_navigator.get_navigator().last_input_event_was_navigation_command(): + msg = "WEB: Event ignored: Last command was struct nav" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + if table_navigator.get_navigator().last_input_event_was_navigation_command(): + msg = "WEB: Event ignored: Last command was table nav" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + if ( + document_presenter.get_presenter().browse_mode_is_sticky(self.app) + and not input_event_manager.get_manager().last_event_was_tab_navigation() + ): + msg = "WEB: Element claimed focus, but browse mode is sticky" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + if AXUtilities.find_ancestor(event.source, AXUtilities.is_embedded): + if AXUtilities.is_tool_tip(event.source) and AXUtilities.is_ancestor( + focus, event.source + ): msg = "WEB: Event believed to be side effect of tooltip navigation." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - else: - msg = "WEB: Event handled: Setting locusOfFocus to web app descendant" - debug.printMessage(debug.LEVEL_INFO, msg, True) - cthulhu.setLocusOfFocus(event, event.source) + debug.print_message(debug.LEVEL_INFO, msg, True) return True + msg = "WEB: Event handled: Setting locusOfFocus to embedded descendant" + debug.print_message(debug.LEVEL_INFO, msg, True) + presentation_manager.get_manager().interrupt_if_needed_for_focus_change( + focus, event.source, event + ) + + focus_manager.get_manager().set_locus_of_focus(event, event.source) + return True + if AXUtilities.is_editable(event.source): msg = "WEB: Event source is editable" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return False if AXUtilities.is_dialog_or_alert(event.source): - msg = "WEB: Event handled: Setting locusOfFocus to event source" - debug.printMessage(debug.LEVEL_INFO, msg, True) - cthulhu.setLocusOfFocus(event, event.source) + if AXUtilities.is_ancestor(focus, event.source, True): + msg = "WEB: Ignoring event from ancestor of focus" + debug.print_message(debug.LEVEL_INFO, msg, True) + else: + msg = "WEB: Event handled: Setting locusOfFocus to event source" + debug.print_message(debug.LEVEL_INFO, msg, True) + focus_manager.get_manager().set_locus_of_focus(event, event.source) return True - if self.utilities.handleEventFromContextReplicant(event, event.source): + if self.utilities.handle_event_from_context_replicant(event, event.source): msg = "WEB: Event handled by updating locusOfFocus and context to source." - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - obj, offset = self.utilities.getCaretContext() - tokens = ["WEB: Caret context is", obj, ", ", offset, - "(focus: ", cthulhu_state.locusOfFocus, ")"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + obj, offset = self.utilities.get_caret_context() + tokens = ["WEB: Caret context is", obj, ", ", offset] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) - if not obj or self.utilities.isZombie(obj) or prevDocument != document: - tokens = ["WEB: Clearing context - obj", obj, "is null or zombie or document changed"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - self.utilities.clearCaretContext() + if not AXObject.is_valid(obj) or prev_document != document: + tokens = ["WEB: Clearing context - obj", obj, "is not valid or document changed"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self.utilities.clear_caret_context() - obj, offset = self.utilities.searchForCaretContext(event.source) + obj, offset = self.utilities.search_for_caret_context(event.source) if obj: - notify = self.utilities.inFindContainer(cthulhu_state.locusOfFocus) tokens = ["WEB: Updating focus and context to", obj, ", ", offset] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - cthulhu.setLocusOfFocus(event, obj, notify) - if not notify and prevDocument is None: - self.refreshKeyGrabs() - self.utilities.setCaretContext(obj, offset) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + focus_manager.get_manager().set_locus_of_focus(event, obj) + self.utilities.set_caret_context(obj, offset) else: msg = "WEB: Search for caret context failed" - debug.printMessage(debug.LEVEL_INFO, msg, True) - - if self._lastCommandWasCaretNav: - msg = "WEB: Event ignored: Last command was caret nav" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - - if self._lastCommandWasStructNav: - msg = "WEB: Event ignored: Last command was struct nav" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + debug.print_message(debug.LEVEL_INFO, msg, True) if not (AXUtilities.is_focusable(event.source) and AXUtilities.is_focused(event.source)): msg = "WEB: Event ignored: Source is not focusable or focused" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True if not AXUtilities.is_document(event.source): msg = "WEB: Deferring to other scripts for handling non-document source" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return False if not obj: - msg = "WEB: Unable to get non-null, non-zombie context object" - debug.printMessage(debug.LEVEL_INFO, msg, True) + msg = "WEB: Unable to get valid context object" + debug.print_message(debug.LEVEL_INFO, msg, True) return False - if self.utilities.lastInputEventWasPageNav(): + if input_event_manager.get_manager().last_event_was_page_navigation(): msg = "WEB: Event handled: Focus changed due to scrolling" - debug.printMessage(debug.LEVEL_INFO, msg, True) - cthulhu.setLocusOfFocus(event, obj) - self.utilities.setCaretContext(obj, offset) + debug.print_message(debug.LEVEL_INFO, msg, True) + focus_manager.get_manager().set_locus_of_focus(event, obj) + self.utilities.set_caret_context(obj, offset) return True - wasFocused = AXUtilities.is_focused(obj) - AXObject.clear_cache(obj) - isFocused = AXUtilities.is_focused(obj) - if wasFocused != isFocused: - tokens = ["WEB: Focused state of", obj, "changed to", isFocused] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + # TODO - JD: Can this logic be removed? + was_focused = AXUtilities.is_focused(obj) + AXObject.clear_cache(obj, False, "Sanity-checking focused state.") + is_focused = AXUtilities.is_focused(obj) + if was_focused != is_focused: + tokens = ["WEB: Focused state of", obj, "changed to", is_focused] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return False - if self.utilities.isAnchor(obj): + if AXUtilities.is_anchor(obj): cause = "Context is anchor" - elif not (self.utilities.isLink(obj) and not isFocused): + elif not (self.utilities.is_link(obj) and not is_focused): cause = "Context is not a non-focused link" - elif self.utilities.isChildOfCurrentFragment(obj): + elif self.utilities.is_child_of_current_fragment(obj): cause = "Context is child of current fragment" - elif document == event.source and self.utilities.documentFragment(event.source): + elif document == event.source and AXDocument.get_document_uri_fragment(event.source): cause = "Document URI is fragment" else: return False tokens = ["WEB: Event handled: Setting locusOfFocus to", obj, "(", cause, ")"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - cthulhu.setLocusOfFocus(event, obj) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + focus_manager.get_manager().set_locus_of_focus(event, obj) return True - def onMouseButton(self, event): + def _on_mouse_button(self, event: Atspi.Event) -> bool: """Callback for mouse:button accessibility events.""" - self._lastCommandWasCaretNav = False - self._lastCommandWasStructNav = False - self._lastCommandWasMouseButton = True return False - def onNameChanged(self, event): + def _on_name_changed(self, event: Atspi.Event) -> bool: """Callback for object:property-change:accessible-name events.""" - if self.utilities.eventIsBrowserUINoise(event): + if self.utilities.event_is_browser_ui_noise_deprecated(event): msg = "WEB: Ignoring event believed to be browser UI noise" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - return True + return False - def onRowReordered(self, event): + def _on_row_reordered(self, event: Atspi.Event) -> bool: """Callback for object:row-reordered accessibility events.""" - if not self.utilities.inDocumentContent(event.source): + if not self.utilities.in_document_content(event.source): msg = "WEB: Event source is not in document content" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return False - if event.source != self.utilities.getTable(cthulhu_state.locusOfFocus): - tokens = ["WEB: locusOfFocus (", cthulhu_state.locusOfFocus, ") is not in this table"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + focus = focus_manager.get_manager().get_locus_of_focus() + if event.source != AXUtilities.get_table(focus): + msg = "WEB: focus is not in this table" + debug.print_message(debug.LEVEL_INFO, msg, True) return False - self.pointOfReference['last-table-sort-time'] = time.time() - self.presentMessage(messages.TABLE_REORDERED_ROWS) - header = self.utilities.containingTableHeader(cthulhu_state.locusOfFocus) - if header: - self.presentMessage(self.utilities.getSortOrderDescription(header, True)) - + presentation_manager.get_manager().present_message(messages.TABLE_REORDERED_ROWS) + header = AXUtilities.find_ancestor_inclusive(focus, AXUtilities.is_table_header) + msg = AXUtilities.get_presentable_sort_order_from_header(header, True) + if msg: + presentation_manager.get_manager().present_message(msg) return True - def onSelectedChanged(self, event): + def _on_selected_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:selected accessibility events.""" - if self.utilities.eventIsBrowserUIAutocompleteNoise(event): + if self.utilities.event_is_browser_ui_autocomplete_noise_deprecated(event): msg = "WEB: Ignoring event believed to be browser UI autocomplete noise" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self.utilities.eventIsBrowserUIPageSwitch(event): + focus = focus_manager.get_manager().get_locus_of_focus() + if self.utilities.event_is_browser_ui_page_switch(event): msg = "WEB: Event believed to be browser UI page switch" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) if event.detail1: - # Work around stale cache when switching tabs. - AXObject.clear_cache(event.source, False, "Work around Chromium page switch.") + # https://bugzilla.mozilla.org/show_bug.cgi?id=1867044 + AXObject.clear_cache(event.source, False, "Work around Gecko bug.") AXUtilities.clear_all_cache_now(reason=msg) - self.utilities.clearCaretContext() - self.presentObject(event.source, priorObj=cthulhu_state.locusOfFocus, interrupt=True) + self.present_object(event.source, priorObj=focus, interrupt=True) return True - if not self.utilities.inDocumentContent(event.source): + if not self.utilities.in_document_content(event.source): msg = "WEB: Event source is not in document content" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return False - if cthulhu_state.locusOfFocus != event.source: + if focus != event.source: msg = "WEB: Ignoring because event source is not locusOfFocus" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True return False - def onSelectionChanged(self, event): + def _on_selection_changed(self, event: Atspi.Event) -> bool: """Callback for object:selection-changed accessibility events.""" - if self.utilities.eventIsBrowserUIAutocompleteNoise(event): + if self.utilities.event_is_browser_ui_autocomplete_noise_deprecated(event): msg = "WEB: Ignoring event believed to be browser UI autocomplete noise" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self.utilities.eventIsBrowserUIPageSwitch(event): + if self.utilities.event_is_browser_ui_page_switch(event): msg = "WEB: Ignoring event believed to be browser UI page switch" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if not self.utilities.inDocumentContent(event.source): + if not self.utilities.in_document_content(event.source): msg = "WEB: Event source is not in document content" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return False - if not self.utilities.inDocumentContent(cthulhu_state.locusOfFocus): - tokens = ["WEB: Event ignored: locusOfFocus", cthulhu_state.locusOfFocus, - "is not in document content"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + if not self.utilities.in_document_content(focus_manager.get_manager().get_locus_of_focus()): + msg = "WEB: Event ignored: locusOfFocus is not in document content" + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if not self.utilities.eventIsFromLocusOfFocusDocument(event): + if not self.utilities.event_is_from_locus_of_focus_document(event): msg = "WEB: Event ignored: Not from locus of focus document" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self.utilities.isWebAppDescendant(event.source): - if self._inFocusMode: + if AXUtilities.find_ancestor(event.source, AXUtilities.is_embedded): + if document_presenter.get_presenter().in_focus_mode(self.app): # Because we cannot count on the app firing the right state-changed events # for descendants. - AXObject.clear_cache(event.source) - msg = "WEB: Event source is web app descendant and we're in focus mode" - debug.printMessage(debug.LEVEL_INFO, msg, True) + AXObject.clear_cache( + event.source, + True, + "Workaround for missing events on descendants.", + ) + msg = "WEB: Event source is embedded descendant and we're in focus mode" + debug.print_message(debug.LEVEL_INFO, msg, True) return False - msg = "WEB: Event source is web app descendant and we're in browse mode" - debug.printMessage(debug.LEVEL_INFO, msg, True) + msg = "WEB: Event source is embedded descendant and we're in browse mode" + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self.utilities.eventIsIrrelevantSelectionChangedEvent(event): + if self.utilities.event_is_irrelevant_selection_changed_event(event): msg = "WEB: Event ignored: Irrelevant" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - obj, offset = self.utilities.getCaretContext() - ancestor = self.utilities.commonAncestor(obj, event.source) - if ancestor and self.utilities.isTextBlockElement(ancestor): + obj, _offset = self.utilities.get_caret_context() + ancestor = AXUtilities.get_common_ancestor(obj, event.source) + if ancestor and self.utilities.is_text_block_element(ancestor): msg = "WEB: Ignoring: Common ancestor of context and event source is text block" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True return False - def onShowingChanged(self, event): + def _on_showing_changed(self, event: Atspi.Event) -> bool: """Callback for object:state-changed:showing accessibility events.""" - if event.detail1 and self.utilities.isTopLevelBrowserUIAlert(event.source): + if event.detail1 and self.utilities.is_browser_ui_alert(event.source): msg = "WEB: Event handled: Presenting event source" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.presentObject(event.source, interrupt=True) + debug.print_message(debug.LEVEL_INFO, msg, True) + self.present_object(event.source, interrupt=True) return True - if not self.utilities.inDocumentContent(event.source): + if not self.utilities.in_document_content(event.source): msg = "WEB: Event source is not in document content" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return False return True - def onTextAttributesChanged(self, event): + def _on_text_attributes_changed(self, event: Atspi.Event) -> bool: """Callback for object:text-attributes-changed accessibility events.""" - msg = "WEB: Clearing cached text attributes" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self._currentTextAttrs = {} + msg = "WEB: This event is is handled by the toolkit or default script." + debug.print_message(debug.LEVEL_INFO, msg, True) return False - def onTextDeleted(self, event): + def _on_text_deleted(self, event: Atspi.Event) -> bool: """Callback for object:text-changed:delete accessibility events.""" - if self.utilities.isZombie(event.source): - msg = "WEB: Event source is Zombie" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - - if self.utilities.lastInputEventWasPageSwitch(): + reason = AXUtilities.get_text_event_reason(event) + if reason == TextEventReason.PAGE_SWITCH: msg = "WEB: Deletion is believed to be due to page switch" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self.utilities.isLiveRegion(event.source): + if reason == TextEventReason.LIVE_REGION_UPDATE: msg = "WEB: Ignoring deletion from live region" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self.utilities.eventIsBrowserUINoise(event): - msg = "WEB: Ignoring event believed to be browser UI noise" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - - if not self.utilities.inDocumentContent(event.source): + if not self.utilities.in_document_content(event.source): msg = "WEB: Event source is not in document content" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) + + if reason == TextEventReason.UI_UPDATE: + msg = "WEB: Ignoring event believed to be browser UI update" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + return False - if self.utilities.eventIsSpinnerNoise(event): - msg = "WEB: Ignoring: Event believed to be spinner noise" - debug.printMessage(debug.LEVEL_INFO, msg, True) + if reason == TextEventReason.SPIN_BUTTON_VALUE_CHANGE: + msg = "WEB: Ignoring: Event believed to be spin button value change" + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self.utilities.eventIsAutocompleteNoise(event): - msg = "WEB: Ignoring event believed to be autocomplete noise" - debug.printMessage(debug.LEVEL_INFO, msg, True) + if reason == TextEventReason.AUTO_DELETION: + msg = "WEB: Ignoring event believed to be auto deletion" + debug.print_message(debug.LEVEL_INFO, msg, True) return True msg = "WEB: Clearing content cache due to text deletion" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.utilities.clearContentCache() + debug.print_message(debug.LEVEL_INFO, msg, True) + self.utilities.clear_content_cache() - if self.utilities.textEventIsDueToDeletion(event): + if reason in (TextEventReason.DELETE, TextEventReason.BACKSPACE): msg = "WEB: Event believed to be due to editable text deletion" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return False - if self.utilities.textEventIsDueToInsertion(event): + if reason in [TextEventReason.TYPING, TextEventReason.TYPING_ECHOABLE]: msg = "WEB: Ignoring event believed to be due to text insertion" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - obj, offset = self.utilities.getCaretContext(getZombieReplicant=False) - if obj and obj != event.source \ - and not AXObject.find_ancestor(obj, lambda x: x == event.source): + obj, _offset = self.utilities.get_caret_context(get_replicant=False) + if ( + obj + and obj != event.source + and not AXUtilities.find_ancestor(obj, lambda x: x == event.source) + ): tokens = ["WEB: Ignoring event because it isn't", obj, "or its ancestor"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return True - if self.utilities.isZombie(obj): - if self.utilities.isLink(obj): + if not AXObject.is_valid(obj): + if self.utilities.is_link(obj): msg = "WEB: Focused link deleted. Taking no further action." - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - obj, offset = self.utilities.getCaretContext(getZombieReplicant=True) + obj, _offset = self.utilities.get_caret_context(get_replicant=True) if obj: - cthulhu.setLocusOfFocus(event, obj, notifyScript=False) + focus_manager.get_manager().set_locus_of_focus(event, obj, notify_script=False) - if self.utilities.isZombie(obj): - msg = "WEB: Unable to get non-null, non-zombie context object" - debug.printMessage(debug.LEVEL_INFO, msg, True) - - document = self.utilities.getDocumentForObject(event.source) - if document: - tokens = ["WEB: Clearing structural navigation cache for", document] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - self.structuralNavigation.clearCache(document) - - if not AXUtilities.is_editable(event.source) \ - and not self.utilities.isContentEditableWithEmbeddedObjects(event.source): - if self._inMouseOverObject \ - and self.utilities.isZombie(self._lastMouseOverObject): - msg = "WEB: Restoring pre-mouseover context" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.restorePreMouseOverContext() + if not AXObject.is_valid(obj): + msg = "WEB: Unable to get valid context object" + debug.print_message(debug.LEVEL_INFO, msg, True) + if not AXUtilities.is_editable( + event.source, + ) and not self.utilities.is_content_editable_with_embedded_objects(event.source): msg = "WEB: Done processing non-editable source" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True return False - def onTextInserted(self, event): + def _on_text_inserted(self, event: Atspi.Event) -> bool: """Callback for object:text-changed:insert accessibility events.""" - if self.utilities.isZombie(event.source): - msg = "WEB: Event source is Zombie" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - - if self.utilities.lastInputEventWasPageSwitch(): + reason = AXUtilities.get_text_event_reason(event) + if reason == TextEventReason.PAGE_SWITCH: msg = "WEB: Insertion is believed to be due to page switch" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self.utilities.handleAsLiveRegion(event): - msg = "WEB: Event to be handled as live region" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.liveRegionManager.handleEvent(event) + if reason == TextEventReason.LIVE_REGION_UPDATE: + msg = "WEB: Event is from live region" + debug.print_message(debug.LEVEL_INFO, msg, True) + live_region_presenter.get_presenter().handle_event(self, event) return True - if self.utilities.isLiveRegion(event.source): - msg = "WEB: Ignoring because live region event not to be handled." - debug.printMessage(debug.LEVEL_INFO, msg, True) + if reason == TextEventReason.CHILDREN_CHANGE: + msg = "WEB: Ignoring: Event believed to be due to children change" + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self.utilities.eventIsEOCAdded(event): - msg = "WEB: Ignoring: Event was for embedded object char" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - - if self.utilities.eventIsBrowserUINoise(event): - msg = "WEB: Ignoring event believed to be browser UI noise" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - - if not self.utilities.inDocumentContent(event.source): + if not self.utilities.in_document_content(event.source): msg = "WEB: Event source is not in document content" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) + + if reason == TextEventReason.UI_UPDATE: + msg = "WEB: Ignoring event believed to be browser UI update" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + return False - if self.utilities.eventIsSpinnerNoise(event): - msg = "WEB: Ignoring: Event believed to be spinner noise" - debug.printMessage(debug.LEVEL_INFO, msg, True) + if reason == TextEventReason.SPIN_BUTTON_VALUE_CHANGE: + msg = "WEB: Ignoring: Event believed to be spin button value change" + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self.utilities.eventIsAutocompleteNoise(event): - msg = "WEB: Ignoring: Event believed to be autocomplete noise" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + if reason == TextEventReason.AUTO_INSERTION_PRESENTABLE: + msg = "WEB: Ignoring: Event believed to be auto insertion" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False msg = "WEB: Clearing content cache due to text insertion" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.utilities.clearContentCache() + debug.print_message(debug.LEVEL_INFO, msg, True) + self.utilities.clear_content_cache() - document = self.utilities.getTopLevelDocumentForObject(event.source) - if AXObject.is_dead(cthulhu_state.locusOfFocus): + document = self.utilities.get_top_level_document_for_object(event.source) + if focus_manager.get_manager().focus_is_dead(): msg = "WEB: Dumping cache: dead focus" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self.utilities.dumpCache(document, preserveContext=True) + debug.print_message(debug.LEVEL_INFO, msg, True) + self.utilities.dump_cache(document, preserve_context=True) if AXUtilities.is_focused(event.source): msg = "WEB: Event handled: Setting locusOfFocus to event source" - debug.printMessage(debug.LEVEL_INFO, msg, True) - cthulhu.setLocusOfFocus(None, event.source, force=True) + debug.print_message(debug.LEVEL_INFO, msg, True) + focus_manager.get_manager().set_locus_of_focus(None, event.source, force=True) return True - else: - tokens = ["WEB: Clearing structural navigation cache for", document] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - self.structuralNavigation.clearCache(document) - - text = self.utilities.queryNonEmptyText(event.source) - if not text: + if not self.utilities.treat_as_text_object(event.source): msg = "WEB: Ignoring: Event source is not a text object" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True + source_is_focus = event.source == focus_manager.get_manager().get_locus_of_focus() if not AXUtilities.is_editable(event.source): - if event.source != cthulhu_state.locusOfFocus: + if not source_is_focus: msg = "WEB: Done processing non-editable, non-locusOfFocus source" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self.utilities.isClickableElement(event.source): + if self.utilities.is_clickable_element(event.source): msg = "WEB: Event handled: Re-setting locusOfFocus to changed clickable" - debug.printMessage(debug.LEVEL_INFO, msg, True) - cthulhu.setLocusOfFocus(None, event.source, force=True) + debug.print_message(debug.LEVEL_INFO, msg, True) + focus_manager.get_manager().set_locus_of_focus(None, event.source, force=True) return True - if AXUtilities.is_text_input(event.source) \ - and AXUtilities.is_focused(event.source) \ - and event.source != cthulhu_state.locusOfFocus: + if ( + not source_is_focus + and AXUtilities.is_text_input(event.source) + and AXUtilities.is_focused(event.source) + ): msg = "WEB: Focused entry is not the locus of focus. Waiting for focus event." - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True return False - def onTextSelectionChanged(self, event): + def _on_text_selection_changed(self, event: Atspi.Event) -> bool: """Callback for object:text-selection-changed accessibility events.""" - self._clearSyntheticWebSelection() - - if self.utilities.isZombie(event.source): - msg = "WEB: Event source is Zombie" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - - if self.utilities.eventIsBrowserUINoise(event): + _reason = AXUtilities.get_text_event_reason(event) + if self.utilities.event_is_browser_ui_noise_deprecated(event): msg = "WEB: Ignoring event believed to be browser UI noise" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if not self.utilities.inDocumentContent(event.source): + if self.utilities.in_find_container(): + msg = "WEB: Event handled: Presenting find results" + debug.print_message(debug.LEVEL_INFO, msg, True) + document_presenter.get_presenter().present_find_results(event.source, 0) + return True + + if not self.utilities.in_document_content(event.source): msg = "WEB: Event source is not in document content" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return False - if not self.utilities.inDocumentContent(cthulhu_state.locusOfFocus): - tokens = ["WEB: Event ignored: locusOfFocus", cthulhu_state.locusOfFocus, - "is not in document content"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + focus = focus_manager.get_manager().get_locus_of_focus() + if not self.utilities.in_document_content(focus): + msg = "WEB: Locus of focus is not in document content" + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self.utilities.eventIsAutocompleteNoise(event): + if self.utilities.event_is_autocomplete_noise_deprecated(event): msg = "WEB: Ignoring: Event believed to be autocomplete noise" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self.utilities.eventIsSpinnerNoise(event): + if self.utilities.event_is_spinner_noise_deprecated(event): msg = "WEB: Ignoring: Event believed to be spinner noise" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self.utilities.textEventIsForNonNavigableTextObject(event): + if self.utilities.event_is_for_non_navigable_text_object(event): msg = "WEB: Ignoring event for non-navigable text object" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - text = self.utilities.queryNonEmptyText(event.source) - if not text: + if not self.utilities.treat_as_text_object(event.source): msg = "WEB: Ignoring: Event source is not a text object" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self.utilities.lastInputEventWasCaretNavWithSelection() \ - and not AXText.get_selected_ranges(event.source): - document = self.utilities.getTopLevelDocumentForObject(event.source) - obj, offset = self.utilities.getCaretContext(document, False, False) - focusOffset = AXText.get_caret_offset(event.source) - if self._presentSyntheticCaretSelection( - event, - document, - obj, - offset, - focusOverride=(event.source, focusOffset), - ): - msg = "WEB: Event handled: synthetic selection from event source" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True - - if AXUtilities.is_text_input(event.source) \ - and AXUtilities.is_focused(event.source) \ - and event.source != cthulhu_state.locusOfFocus: + if ( + event.source != focus + and AXUtilities.is_text_input(event.source) + and AXUtilities.is_focused(event.source) + ): msg = "WEB: Focused entry is not the locus of focus. Waiting for focus event." - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True - if self.utilities.isContentEditableWithEmbeddedObjects(event.source): + if self.utilities.is_content_editable_with_embedded_objects(event.source): msg = "WEB: In content editable with embedded objects" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return False - offset = AXText.get_caret_offset(event.source) - char = AXText.get_substring(event.source, offset, offset + 1) - if char == self.EMBEDDED_OBJECT_CHARACTER \ - and not self.utilities.lastInputEventWasCaretNavWithSelection() \ - and not self.utilities.lastInputEventWasCommand(): + if structural_navigator.get_navigator().last_input_event_was_navigation_command(): + msg = "WEB: Ignoring: Last input event was structural navigation command." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + if table_navigator.get_navigator().last_input_event_was_navigation_command(): + msg = "WEB: Ignoring: Last input event was table navigation command." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + char = AXText.get_character_at_offset(event.source)[0] + manager = input_event_manager.get_manager() + if ( + char == "\ufffc" + and not manager.last_event_was_caret_selection() + and not manager.last_event_was_command() + ): msg = "WEB: Ignoring: Not selecting and event offset is at embedded object" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return True return False - def onWindowActivated(self, event): + def _on_window_activated(self, event: Atspi.Event) -> bool: """Callback for window:activate accessibility events.""" msg = "WEB: Deferring to app/toolkit script" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return False - def onWindowDeactivated(self, event): + def _on_window_deactivated(self, event: Atspi.Event) -> bool: """Callback for window:deactivate accessibility events.""" msg = "WEB: Clearing command state" - debug.printMessage(debug.LEVEL_INFO, msg, True) - self._clearSyntheticWebSelection() - self._lastCommandWasCaretNav = False - self._lastCommandWasStructNav = False - self._lastCommandWasMouseButton = False - self._lastMouseButtonContext = None, -1 + debug.print_message(debug.LEVEL_INFO, msg, True) + self._last_mouse_button_context = None, -1 return False - - def getTransferableAttributes(self): - return {"_lastCommandWasCaretNav": self._lastCommandWasCaretNav, - "_lastCommandWasStructNav": self._lastCommandWasStructNav, - "_lastCommandWasMouseButton": self._lastCommandWasMouseButton, - "_inFocusMode": self._inFocusMode, - "_focusModeIsSticky": self._focusModeIsSticky, - "_browseModeIsSticky": self._browseModeIsSticky, - } diff --git a/tests/cthulhu_test_context.py b/tests/cthulhu_test_context.py index 9050cd3..d002d8e 100644 --- a/tests/cthulhu_test_context.py +++ b/tests/cthulhu_test_context.py @@ -103,13 +103,7 @@ class CthulhuTestContext: def patch_module(self, module_name: str, mock_module: Any) -> MagicMock: """Convenience method for patching sys.modules entries.""" - patch = self.mocker.patch.dict(sys.modules, {module_name: mock_module}) - if "." in module_name: - package_name, attr_name = module_name.rsplit(".", 1) - package = sys.modules.get(package_name) - if package is not None: - self.monkeypatch.setattr(package, attr_name, mock_module, raising=False) - return patch + return self.mocker.patch.dict(sys.modules, {module_name: mock_module}) def patch_modules(self, modules: dict[str, Any]) -> MagicMock: """Convenience method for patching multiple sys.modules entries.""" diff --git a/tests/test_script_manager.py b/tests/test_script_manager.py new file mode 100644 index 0000000..c90eea6 --- /dev/null +++ b/tests/test_script_manager.py @@ -0,0 +1,974 @@ +# Unit tests for script_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-instance-attributes +# pylint: disable=too-many-lines + +"""Unit tests for script_manager.py methods.""" + +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING +from unittest.mock import Mock + +import pytest + +if TYPE_CHECKING: + from unittest.mock import MagicMock + + from .cthulhu_test_context import CthulhuTestContext + + +@pytest.mark.unit +class TestScriptManager: + """Test ScriptManager class methods.""" + + def _setup_dependencies(self, test_context: CthulhuTestContext) -> dict[str, MagicMock]: + """Returns dependencies for script_manager module testing.""" + + modules_to_clean = ["cthulhu.script_manager"] + for module_name in modules_to_clean: + if module_name in sys.modules: + del sys.modules[module_name] + + additional_modules = [ + "gi", + "gi.repository", + "gi.repository.Atspi", + "cthulhu.ax_utilities", + "cthulhu.command_manager", + "cthulhu.scripts", + "cthulhu.scripts.apps", + "cthulhu.scripts.default", + "cthulhu.scripts.sleepmode", + "cthulhu.scripts.toolkits", + "cthulhu.sleep_mode_manager", + "cthulhu.speech_manager", + ] + essential_modules = test_context.setup_shared_dependencies(additional_modules) + + if "cthulhu.script_manager" in essential_modules: + del essential_modules["cthulhu.script_manager"] + if "cthulhu.script_manager" in sys.modules: + del sys.modules["cthulhu.script_manager"] + + gi_repository_mock = essential_modules["gi.repository"] + atspi_mock = essential_modules["gi.repository.Atspi"] + + class UnionSupportingMock(Mock): + """Mock that supports union operations for Atspi.Accessible.""" + + def __or__(self, other): + return UnionSupportingMock() + + atspi_mock.Accessible = UnionSupportingMock() + gi_repository_mock.Atspi = atspi_mock + + ax_object_mock = essential_modules["cthulhu.ax_object"] + ax_object_class_mock = test_context.Mock() + ax_object_class_mock.get_name = test_context.Mock(return_value="test-app") + ax_object_class_mock.get_attribute = test_context.Mock(return_value="GTK") + ax_object_mock.AXObject = ax_object_class_mock + + ax_utilities_mock = essential_modules["cthulhu.ax_utilities"] + ax_utilities_class_mock = test_context.Mock() + ax_utilities_class_mock.is_terminal = test_context.Mock(return_value=False) + ax_utilities_class_mock.get_application_toolkit_name = test_context.Mock(return_value="gtk") + ax_utilities_class_mock.is_application_in_desktop = test_context.Mock(return_value=True) + ax_utilities_class_mock.is_frame = test_context.Mock(return_value=False) + ax_utilities_class_mock.is_status_bar = test_context.Mock(return_value=False) + ax_utilities_mock.AXUtilities = ax_utilities_class_mock + + braille_mock = essential_modules["cthulhu.braille"] + braille_mock.check_braille_setting = test_context.Mock() + braille_mock.setup_key_ranges = test_context.Mock() + + debug_mock = essential_modules["cthulhu.debug"] + debug_mock.print_message = test_context.Mock() + debug_mock.LEVEL_INFO = 800 + + speech_verbosity_mock = essential_modules["cthulhu.speech_manager"] + speech_manager_instance = test_context.Mock() + speech_manager_instance.check_speech_setting = test_context.Mock() + speech_verbosity_mock.get_manager = test_context.Mock(return_value=speech_manager_instance) + + command_manager_mock = essential_modules["cthulhu.command_manager"] + command_manager_instance = test_context.Mock() + command_manager_instance.check_keyboard_settings = test_context.Mock() + command_manager_mock.get_manager = test_context.Mock(return_value=command_manager_instance) + + scripts_mock = essential_modules["cthulhu.scripts"] + apps_mock = essential_modules["cthulhu.scripts.apps"] + toolkits_mock = essential_modules["cthulhu.scripts.toolkits"] + default_mock = essential_modules["cthulhu.scripts.default"] + sleepmode_mock = essential_modules["cthulhu.scripts.sleepmode"] + + apps_mock.__all__ = ["evolution", "gnome-shell"] + toolkits_mock.__all__ = ["gtk", "Gecko", "Qt"] + + class MockScript: + """Mock script class for testing.""" + + def __init__(self, app=None): + self.app = app + self.register_event_listeners = test_context.Mock() + self.deregister_event_listeners = test_context.Mock() + self.activate = test_context.Mock() + self.deactivate = test_context.Mock() + + def __or__(self, other): + return MockScript + + default_script_constructor = test_context.Mock(return_value=MockScript()) + default_script_constructor.__or__ = lambda self, other: MockScript() + sleepmode_script_constructor = test_context.Mock(return_value=MockScript()) + sleepmode_script_constructor.__or__ = lambda self, other: MockScript() + + default_mock.Script = default_script_constructor + sleepmode_mock.Script = sleepmode_script_constructor + + scripts_mock.apps = apps_mock + scripts_mock.default = default_mock + scripts_mock.sleepmode = sleepmode_mock + scripts_mock.toolkits = toolkits_mock + + essential_modules["speech_manager_instance"] = speech_manager_instance + + default_script = test_context.Mock() + default_script.app = test_context.Mock() + default_script.register_event_listeners = test_context.Mock() + default_script.deregister_event_listeners = test_context.Mock() + default_script.activate = test_context.Mock() + default_script.deactivate = test_context.Mock() + + sleepmode_script = test_context.Mock() + sleepmode_script.app = test_context.Mock() + sleepmode_script.register_event_listeners = test_context.Mock() + sleepmode_script.deregister_event_listeners = test_context.Mock() + sleepmode_script.activate = test_context.Mock() + sleepmode_script.deactivate = test_context.Mock() + + sleep_mode_manager_instance = test_context.Mock() + sleep_mode_manager_instance.is_active_for_app = test_context.Mock(return_value=False) + + # Set up the module-level get_manager mock + sleep_mode_manager_mock = essential_modules["cthulhu.sleep_mode_manager"] + sleep_mode_manager_mock.get_manager = test_context.Mock( + return_value=sleep_mode_manager_instance, + ) + + essential_modules["default_script"] = default_script + essential_modules["sleepmode_script"] = sleepmode_script + essential_modules["sleep_mode_manager"] = sleep_mode_manager_instance + + default_module = test_context.Mock() + default_module.Script = test_context.Mock(return_value=test_context.Mock()) + + sleepmode_module = test_context.Mock() + sleepmode_module.Script = test_context.Mock(return_value=test_context.Mock()) + + deps_speech_manager = test_context.Mock() + deps_speech_manager_instance = test_context.Mock() + deps_speech_manager_instance.check_speech_setting = test_context.Mock() + deps_speech_manager.get_manager = test_context.Mock( + return_value=deps_speech_manager_instance, + ) + + braille_module = test_context.Mock() + braille_module.check_braille_setting = test_context.Mock() + braille_module.setup_key_ranges = test_context.Mock() + + essential_modules["default_module"] = default_module + essential_modules["sleepmode_module"] = sleepmode_module + essential_modules["deps_speech_manager"] = deps_speech_manager + essential_modules["braille_module"] = braille_module + + return essential_modules + + def test_init(self, test_context: CthulhuTestContext) -> None: + """Test ScriptManager.__init__.""" + + self._setup_dependencies(test_context) + from cthulhu.script_manager import ScriptManager + + manager = ScriptManager() + assert not manager.app_scripts + assert not manager.toolkit_scripts + assert not manager.custom_scripts + assert not manager._sleep_mode_scripts + assert manager._default_script is None + assert manager._active_script is None + assert manager._active is False + + @pytest.mark.parametrize( + "case", + [ + { + "id": "inactive_to_active", + "initially_active": False, + "expects_script_creation": True, + }, + {"id": "already_active", "initially_active": True, "expects_script_creation": False}, + ], + ids=lambda case: case["id"], + ) + def test_activate(self, test_context: CthulhuTestContext, case: dict) -> None: + """Test ScriptManager.activate with different initial states.""" + + essential_modules = self._setup_dependencies(test_context) + default_script = essential_modules["default_script"] + + if case["expects_script_creation"]: + mock_default_script = test_context.Mock() + test_context.patch("cthulhu.script_manager.default.Script", new=mock_default_script) + mock_default_script.return_value = default_script + + from cthulhu.script_manager import ScriptManager + + manager = ScriptManager() + manager._active = case["initially_active"] + manager.activate() + + assert manager._active is True + + if case["expects_script_creation"]: + assert manager._default_script is not None + assert manager._active_script is not None + default_script.register_event_listeners.assert_called_once() + + @pytest.mark.parametrize( + "case", + [ + {"id": "active_to_inactive", "initially_active": True, "expects_cleanup": True}, + {"id": "already_inactive", "initially_active": False, "expects_cleanup": False}, + ], + ids=lambda case: case["id"], + ) + def test_deactivate(self, test_context: CthulhuTestContext, case: dict) -> None: + """Test ScriptManager.deactivate with different initial states.""" + + self._setup_dependencies(test_context) + from cthulhu.script_manager import ScriptManager + + manager = ScriptManager() + manager._active = case["initially_active"] + + if case["expects_cleanup"]: + mock_script = test_context.Mock() + mock_script.deregister_event_listeners = test_context.Mock() + manager._default_script = mock_script + manager.app_scripts = {"test": "script"} + manager.toolkit_scripts = {"test": "script"} + manager.custom_scripts = {"test": "script"} + + manager.deactivate() + assert manager._active is False + + if case["expects_cleanup"]: + assert manager._default_script is None + assert manager._active_script is None + assert not manager.app_scripts + assert not manager.toolkit_scripts + assert not manager.custom_scripts + mock_script.deregister_event_listeners.assert_called_once() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "app_in_apps_list", + "app_name": "evolution", + "expected_result": "evolution", + "use_null_app": False, + }, + { + "id": "toolkit_in_toolkits_list", + "app_name": "gtk", + "expected_result": "gtk", + "use_null_app": False, + }, + { + "id": "mapped_app_name", + "app_name": "mate-notification-daemon", + "expected_result": "notification-daemon", + "use_null_app": False, + }, + { + "id": "steam_alias", + "app_name": "Steam Web Helper", + "expected_result": "steamwebhelper", + "use_null_app": False, + }, + { + "id": "python_extension", + "app_name": "test-app.py", + "expected_result": "test-app", + "use_null_app": False, + }, + { + "id": "bin_extension", + "app_name": "test-app.bin", + "expected_result": "test-app", + "use_null_app": False, + }, + { + "id": "reverse_domain", + "app_name": "org.gnome.TestApp", + "expected_result": "TestApp", + "use_null_app": False, + }, + { + "id": "reverse_domain_com", + "app_name": "com.example.TestApp", + "expected_result": "TestApp", + "use_null_app": False, + }, + { + "id": "unknown_app", + "app_name": "unknown-app", + "expected_result": "unknown-app", + "use_null_app": False, + }, + {"id": "null_app", "app_name": None, "expected_result": None, "use_null_app": True}, + {"id": "nameless_app", "app_name": "", "expected_result": None, "use_null_app": False}, + ], + ids=lambda case: case["id"], + ) + def test_get_module_name_scenarios(self, test_context, case: dict) -> None: + """Test ScriptManager.get_module_name with various scenarios.""" + self._setup_dependencies(test_context) + from cthulhu.script_manager import ScriptManager + + manager = ScriptManager() + + if case["use_null_app"]: + result = manager.get_module_name(None) + else: + mock_app = test_context.Mock() + mock_ax_object = test_context.Mock() + test_context.patch("cthulhu.script_manager.AXObject", new=mock_ax_object) + mock_ax_object.get_name.return_value = case["app_name"] + result = manager.get_module_name(mock_app) + + assert result == case["expected_result"] + + @pytest.mark.parametrize( + "case", + [ + {"id": "gtk_toolkit", "toolkit_attribute": "GTK", "expected_result": "gtk"}, + {"id": "gail_mapped_to_gtk", "toolkit_attribute": "GAIL", "expected_result": "gtk"}, + { + "id": "webkitgtk_mapped_to_local_dir", + "toolkit_attribute": "WebKitGTK", + "expected_result": "WebKitGtk", + }, + {"id": "qt_toolkit", "toolkit_attribute": "Qt", "expected_result": "Qt"}, + {"id": "empty_toolkit", "toolkit_attribute": "", "expected_result": ""}, + {"id": "none_toolkit", "toolkit_attribute": None, "expected_result": None}, + ], + ids=lambda case: case["id"], + ) + def test_toolkit_for_object(self, test_context, case: dict) -> None: + """Test ScriptManager._toolkit_for_object.""" + + self._setup_dependencies(test_context) + from cthulhu.script_manager import ScriptManager + + manager = ScriptManager() + mock_obj = test_context.Mock() + + mock_ax_object = test_context.Mock() + test_context.patch("cthulhu.script_manager.AXObject", new=mock_ax_object) + mock_ax_object.get_attribute.return_value = case["toolkit_attribute"] + result = manager._toolkit_for_object(mock_obj) + assert result == case["expected_result"] + mock_ax_object.get_attribute.assert_called_once_with(mock_obj, "toolkit") + + @pytest.mark.parametrize( + "case", + [ + {"id": "terminal_role", "is_terminal": True, "expected_result": "terminal"}, + {"id": "non_terminal_role", "is_terminal": False, "expected_result": ""}, + ], + ids=lambda case: case["id"], + ) + def test_script_for_role(self, test_context, case: dict) -> None: + """Test ScriptManager._script_for_role.""" + + self._setup_dependencies(test_context) + from cthulhu.script_manager import ScriptManager + + manager = ScriptManager() + mock_obj = test_context.Mock() + + mock_ax_utilities = test_context.Mock() + test_context.patch("cthulhu.script_manager.AXUtilities", new=mock_ax_utilities) + mock_ax_utilities.is_terminal.return_value = case["is_terminal"] + result = manager._script_for_role(mock_obj) + assert result == case["expected_result"] + mock_ax_utilities.is_terminal.assert_called_once_with(mock_obj) + + @pytest.mark.parametrize( + "case", + [ + { + "id": "null_app_empty_name", + "app": None, + "name": "", + "has_module": False, + "has_get_script": False, + "has_script_class": False, + "should_succeed": False, + }, + { + "id": "empty_name", + "app": "app", + "name": "", + "has_module": False, + "has_get_script": False, + "has_script_class": False, + "should_succeed": False, + }, + { + "id": "no_module", + "app": "app", + "name": "test", + "has_module": False, + "has_get_script": False, + "has_script_class": False, + "should_succeed": False, + }, + { + "id": "has_get_script", + "app": "app", + "name": "test", + "has_module": True, + "has_get_script": True, + "has_script_class": False, + "should_succeed": True, + }, + { + "id": "has_script_class", + "app": "app", + "name": "test", + "has_module": True, + "has_get_script": False, + "has_script_class": True, + "should_succeed": True, + }, + { + "id": "no_script_creation", + "app": "app", + "name": "test", + "has_module": True, + "has_get_script": False, + "has_script_class": False, + "should_succeed": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_new_named_script(self, test_context, case: dict) -> None: + """Test ScriptManager._new_named_script.""" + + self._setup_dependencies(test_context) + from cthulhu.script_manager import ScriptManager + + manager = ScriptManager() + mock_app = test_context.Mock() if case["app"] else None + mock_script = test_context.Mock() + mock_import = test_context.Mock() + test_context.patch("importlib.import_module", side_effect=mock_import) + if case["has_module"]: + mock_module = type("MockModule", (), {})() + if case["has_get_script"]: + mock_module.get_script = test_context.Mock(return_value=mock_script) + elif case["has_script_class"]: + mock_module.Script = test_context.Mock(return_value=mock_script) + mock_import.return_value = mock_module + else: + mock_import.side_effect = ImportError("Module not found") + result = manager._new_named_script(mock_app, case["name"]) + if case["should_succeed"]: + assert result == mock_script + else: + assert result is None + + @pytest.mark.parametrize( + "case", + [ + { + "id": "os_error_returns_none", + "exception_type": "OSError", + }, + { + "id": "script_creation_error", + "exception_type": "AttributeError", + }, + ], + ids=lambda case: case["id"], + ) + def test_new_named_script_error_handling( + self, + test_context: CthulhuTestContext, + case: dict, + ) -> None: + """Test ScriptManager._new_named_script handles various errors.""" + + self._setup_dependencies(test_context) + from cthulhu.script_manager import ScriptManager + + manager = ScriptManager() + mock_app = test_context.Mock() + mock_import = test_context.Mock() + test_context.patch("importlib.import_module", side_effect=mock_import) + + if case["exception_type"] == "OSError": + mock_import.side_effect = OSError("Permission denied") + result = manager._new_named_script(mock_app, "test") + assert result is None + else: + mock_module = type("MockModule", (), {})() + mock_module.Script = test_context.Mock( + side_effect=AttributeError("Script class not found"), + ) + mock_import.return_value = mock_module + result = manager._new_named_script(mock_app, "test") + assert result is None + + @pytest.mark.parametrize( + "case", + [ + {"id": "no_module_name", "module_name": None, "expected_result_type": "default_script"}, + { + "id": "with_module_name", + "module_name": "test_script", + "expected_result_type": "named_script", + }, + ], + ids=lambda case: case["id"], + ) + def test_create_script(self, test_context: CthulhuTestContext, case: dict) -> None: + """Test ScriptManager._create_script with different module name scenarios.""" + + essential_modules = self._setup_dependencies(test_context) + default_script = essential_modules["default_script"] + from cthulhu.script_manager import ScriptManager + + manager = ScriptManager() + mock_app = test_context.Mock() + mock_obj = test_context.Mock() + mock_script = test_context.Mock() + + test_context.patch_object(manager, "get_module_name", return_value=case["module_name"]) + test_context.patch_object(manager, "_toolkit_for_object", return_value=None) + test_context.patch_object(manager, "get_default_script", return_value=default_script) + + test_context.patch( + "cthulhu.script_manager.AXUtilities.get_application_toolkit_name", + return_value=None, + ) + + if case["expected_result_type"] == "default_script": + mock_new_named_script = test_context.Mock(return_value=None) + else: + mock_new_named_script = test_context.Mock(return_value=mock_script) + test_context.patch_object(manager, "_new_named_script", side_effect=mock_new_named_script) + + result = manager._create_script(mock_app, mock_obj) + + if case["expected_result_type"] == "default_script": + assert result == default_script + else: + assert result == mock_script + mock_new_named_script.assert_called_once_with(mock_app, case["module_name"]) + + def test_get_default_script(self, test_context: CthulhuTestContext) -> None: + """Test ScriptManager.get_default_script.""" + + essential_modules = self._setup_dependencies(test_context) + default_script = essential_modules["default_script"] + from cthulhu.script_manager import ScriptManager + + mock_default_script = test_context.Mock() + test_context.patch("cthulhu.script_manager.default.Script", new=mock_default_script) + mock_default_script.return_value = default_script + manager = ScriptManager() + mock_app = test_context.Mock() + + result = manager.get_default_script(mock_app) + assert result is not None + mock_default_script.assert_called_with(mock_app) + + manager._default_script = default_script + result = manager.get_default_script(None) + assert result == default_script + + manager._default_script = None + result = manager.get_default_script(None) + assert result is not None + assert manager._default_script is not None + + def test_get_or_create_sleep_mode_script(self, test_context: CthulhuTestContext) -> None: + """Test ScriptManager.get_or_create_sleep_mode_script.""" + + essential_modules = self._setup_dependencies(test_context) + sleepmode_script = essential_modules["sleepmode_script"] + from cthulhu.script_manager import ScriptManager + + mock_sleepmode_script = test_context.Mock() + test_context.patch("cthulhu.script_manager.sleepmode.Script", new=mock_sleepmode_script) + mock_sleepmode_script.return_value = sleepmode_script + manager = ScriptManager() + mock_app = test_context.Mock() + + result = manager.get_or_create_sleep_mode_script(mock_app) + assert result is not None + assert result == sleepmode_script + assert manager._sleep_mode_scripts[mock_app] == result + + result2 = manager.get_or_create_sleep_mode_script(mock_app) + assert result2 == result + assert len(manager._sleep_mode_scripts) == 1 + + @pytest.mark.parametrize( + "case", + [ + { + "id": "null_app_and_obj", + "app": None, + "obj": None, + "sleep_mode_active": False, + "has_custom_script": False, + "has_toolkit_script": False, + "expected_script_type": "default", + }, + { + "id": "sleep_mode_active", + "app": "app", + "obj": "obj", + "sleep_mode_active": True, + "has_custom_script": False, + "has_toolkit_script": False, + "expected_script_type": "sleep", + }, + { + "id": "custom_script", + "app": "app", + "obj": "obj", + "sleep_mode_active": False, + "has_custom_script": True, + "has_toolkit_script": False, + "expected_script_type": "custom", + }, + { + "id": "toolkit_script", + "app": "app", + "obj": "obj", + "sleep_mode_active": False, + "has_custom_script": False, + "has_toolkit_script": True, + "expected_script_type": "toolkit", + }, + { + "id": "app_script", + "app": "app", + "obj": "obj", + "sleep_mode_active": False, + "has_custom_script": False, + "has_toolkit_script": False, + "expected_script_type": "app", + }, + ], + ids=lambda case: case["id"], + ) + def test_get_script(self, test_context, case: dict) -> None: + """Test ScriptManager.get_script.""" + + essential_modules = self._setup_dependencies(test_context) + sleepmode_script = essential_modules["sleepmode_script"] + sleep_mode_manager = essential_modules["sleep_mode_manager"] + from cthulhu.script_manager import ScriptManager + + manager = ScriptManager() + mock_app = test_context.Mock() if case["app"] else None + mock_obj = test_context.Mock() if case["obj"] else None + mock_sleep_script = sleepmode_script + mock_custom_script = test_context.Mock() + + class ToolkitScript: + """Mock toolkit script class.""" + + class AppScript: + """Mock app script class.""" + + mock_toolkit_script = test_context.Mock(spec=ToolkitScript) + mock_app_script = test_context.Mock(spec=AppScript) + sleep_mode_manager.is_active_for_app = test_context.Mock( + return_value=case["sleep_mode_active"], + ) + test_context.patch_object( + manager, + "get_or_create_sleep_mode_script", + return_value=mock_sleep_script, + ) + test_context.patch_object( + manager, + "_script_for_role", + return_value="terminal" if case["has_custom_script"] else "", + ) + test_context.patch_object( + manager, + "_toolkit_for_object", + return_value="gtk" if case["has_toolkit_script"] else None, + ) + + def create_script_side_effect(_app, obj): + if obj and case["has_toolkit_script"]: + return mock_toolkit_script + return mock_app_script + + test_context.patch_object(manager, "_create_script", side_effect=create_script_side_effect) + if case["has_custom_script"]: + test_context.patch_object(manager, "_new_named_script", return_value=mock_custom_script) + result = manager.get_script(mock_app, mock_obj) + if case["expected_script_type"] == "default": + assert result is not None + assert hasattr(result, "register_event_listeners") + elif case["expected_script_type"] == "sleep": + assert result == mock_sleep_script + elif case["expected_script_type"] == "custom": + assert result == mock_custom_script + elif case["expected_script_type"] == "toolkit": + assert result == mock_toolkit_script + elif case["expected_script_type"] == "app": + assert result == mock_app_script + + def test_get_script_exception_handling(self, test_context: CthulhuTestContext) -> None: + """Test ScriptManager.get_script handles exceptions.""" + + self._setup_dependencies(test_context) + from cthulhu.script_manager import ScriptManager + + manager = ScriptManager() + mock_app = test_context.Mock() + mock_obj = test_context.Mock() + test_context.patch_object(manager, "_script_for_role", return_value="") + test_context.patch_object(manager, "_toolkit_for_object", return_value=None) + + def create_script_side_effect(app, obj): + raise KeyError("Script creation failed") + + test_context.patch_object(manager, "_create_script", side_effect=create_script_side_effect) + result = manager.get_script(mock_app, mock_obj) + assert result is not None + assert hasattr(result, "register_event_listeners") + + @pytest.mark.parametrize( + "case", + [ + {"id": "no_active_script", "has_active_script": False, "test_type": "script"}, + {"id": "has_active_script", "has_active_script": True, "test_type": "script"}, + {"id": "no_active_script_app", "has_active_script": False, "test_type": "app"}, + {"id": "has_active_script_app", "has_active_script": True, "test_type": "app"}, + ], + ids=lambda case: case["id"], + ) + def test_get_active_script_and_app(self, test_context: CthulhuTestContext, case: dict) -> None: + """Test ScriptManager.get_active_script and get_active_script_app.""" + essential_modules = self._setup_dependencies(test_context) + default_script = essential_modules["default_script"] + from cthulhu.script_manager import ScriptManager + + manager = ScriptManager() + mock_app = test_context.Mock() + + if case["has_active_script"]: + default_script.app = mock_app + manager._active_script = default_script + + if case["test_type"] == "script": + result = manager.get_active_script() + expected = default_script if case["has_active_script"] else None + else: + result = manager.get_active_script_app() + expected = mock_app if case["has_active_script"] else None + + assert result == expected + + def test_set_active_script(self, test_context: CthulhuTestContext) -> None: + """Test ScriptManager.set_active_script.""" + + essential_modules = self._setup_dependencies(test_context) + default_script = essential_modules["default_script"] + from cthulhu.script_manager import ScriptManager + + mock_get_speech_manager = test_context.Mock() + test_context.patch( + "cthulhu.script_manager.speech_manager.get_manager", + new=mock_get_speech_manager, + ) + mock_speech_manager_instance = test_context.Mock() + mock_get_speech_manager.return_value = mock_speech_manager_instance + + manager = ScriptManager() + old_script = test_context.Mock() + old_script.app = test_context.Mock() + old_script.deactivate = test_context.Mock() + new_script = default_script + new_script.app = test_context.Mock() + new_script.activate = test_context.Mock() + + manager._active_script = old_script + manager.set_active_script(new_script, "test reason") + old_script.deactivate.assert_called_once() + new_script.activate.assert_called_once() + assert manager._active_script == new_script + + mock_speech_manager_instance.check_speech_setting.assert_called_once() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "same_script_no_change", + "new_script_type": "same", + "expects_deactivate": False, + "expects_activate": False, + }, + { + "id": "set_to_none", + "new_script_type": "none", + "expects_deactivate": True, + "expects_activate": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_set_active_script_special_cases( + self, + test_context: CthulhuTestContext, + case: dict, + ) -> None: + """Test ScriptManager.set_active_script special cases.""" + essential_modules = self._setup_dependencies(test_context) + default_script = essential_modules["default_script"] + from cthulhu.script_manager import ScriptManager + + manager = ScriptManager() + old_script = test_context.Mock() + old_script.deactivate = test_context.Mock() + manager._active_script = old_script + + if case["new_script_type"] == "same": + manager._active_script = default_script + manager.set_active_script(default_script, "same script") + if case["expects_deactivate"]: + default_script.deactivate.assert_called_once() + else: + default_script.deactivate.assert_not_called() + default_script.activate.assert_not_called() + else: + manager.set_active_script(None) + if case["expects_deactivate"]: + old_script.deactivate.assert_called_once() + assert manager._active_script is None + + @pytest.mark.parametrize( + "case", + [ + {"id": "mixed_apps_normal", "has_app_in_desktop": True, "has_key_error": False}, + { + "id": "no_desktop_apps_with_key_error", + "has_app_in_desktop": False, + "has_key_error": True, + }, + ], + ids=lambda case: case["id"], + ) + def test_reclaim_scripts(self, test_context: CthulhuTestContext, case: dict) -> None: + """Test ScriptManager.reclaim_scripts with various scenarios.""" + self._setup_dependencies(test_context) + from cthulhu.script_manager import ScriptManager + + desktop_patch = "cthulhu.script_manager.AXUtilities.is_application_in_desktop" + mock_is_in_desktop = test_context.Mock() + test_context.patch(desktop_patch, new=mock_is_in_desktop) + manager = ScriptManager() + app_not_in_desktop = test_context.Mock() + + if case["has_app_in_desktop"]: + app_in_desktop = test_context.Mock() + manager.app_scripts = { + app_in_desktop: test_context.Mock(), + app_not_in_desktop: test_context.Mock(), + } + manager.toolkit_scripts = {app_not_in_desktop: test_context.Mock()} + manager.custom_scripts = {app_not_in_desktop: test_context.Mock()} + manager._sleep_mode_scripts = {app_not_in_desktop: test_context.Mock()} + + def mock_is_in_desktop_func(app): + return app == app_in_desktop + + mock_is_in_desktop.side_effect = mock_is_in_desktop_func + manager.reclaim_scripts() + assert app_in_desktop in manager.app_scripts + assert app_not_in_desktop not in manager.app_scripts + assert app_not_in_desktop not in manager.toolkit_scripts + assert app_not_in_desktop not in manager.custom_scripts + assert app_not_in_desktop not in manager._sleep_mode_scripts + else: + manager.app_scripts = {app_not_in_desktop: test_context.Mock()} + if case["has_key_error"]: + manager.toolkit_scripts = {} + manager.custom_scripts = {} + manager._sleep_mode_scripts = {} + else: + manager.toolkit_scripts = {app_not_in_desktop: test_context.Mock()} + manager.custom_scripts = {app_not_in_desktop: test_context.Mock()} + manager._sleep_mode_scripts = {app_not_in_desktop: test_context.Mock()} + + mock_is_in_desktop.return_value = False + manager.reclaim_scripts() + assert app_not_in_desktop not in manager.app_scripts + if not case["has_key_error"]: + assert app_not_in_desktop not in manager.toolkit_scripts + assert app_not_in_desktop not in manager.custom_scripts + assert app_not_in_desktop not in manager._sleep_mode_scripts + + def test_get_manager(self, test_context: CthulhuTestContext) -> None: + """Test script_manager.get_manager.""" + + self._setup_dependencies(test_context) + from cthulhu import script_manager + + manager1 = script_manager.get_manager() + manager2 = script_manager.get_manager() + assert manager1 is manager2 + assert isinstance(manager1, script_manager.ScriptManager)