diff --git a/pyatspi.md b/pyatspi.md new file mode 100644 index 0000000..930c810 --- /dev/null +++ b/pyatspi.md @@ -0,0 +1,131 @@ +# PyAtspi Removal Progress + +This file tracks progress on removing the python-atspi (pyatspi) dependency from Cthulhu. + +## Status: IN PROGRESS - Orca AX modules + focus/input event manager ported (not yet validated) + +## Problem Discovered + +The initial attempt to remove pyatspi broke keyboard navigation: +- Arrow keys in terminals didn't trigger screen reader responses +- Browse mode in web browsers didn't work +- Only Cthulhu modifier keys (like Cthulhu+Q to quit) worked + +**Root Cause**: The simplified removal approach was too aggressive. The legacy keyboard handling code may have been doing something important, or the changes affected how keybindings are registered/matched. + +## What Was Tried (and reverted) + +1. Removed `import pyatspi` from event_manager.py +2. Removed legacy keystroke listener methods +3. Simplified `setKeyHandling()` method +4. Changed `activate()` to call `activateNewKeyHandling()` directly + +## What Still Needs Investigation + +1. **Why did the simplified approach break keybindings?** + - The InputEventManager IS receiving keyboard events (confirmed in logs) + - Events ARE being processed + - But handlers aren't being found for navigation keys + - Cthulhu modifier keys still work + +2. **What does Orca's migration actually change?** + - Orca took 7 months (July 2023 - Feb 2024) to migrate + - Need to study their commits more carefully + - Key commits to review: + - `fc082b1c4` - Use Atspi.event_main/quit instead of pyatspi.Registry.start/stop + - `58826de39` - Use Atspi.generate_keyboard_event instead of pyatspi's + - `72492c05a` - Use Atspi rather than pyatspi to register for object events + +## Completed Work (still valid) + +1. **Test files updated** - 20 test files in `test/keystrokes/` now use `Atspi.Role.*` instead of `pyatspi.ROLE_*` + +2. **pyproject.toml updated** - Removed `python-atspi>=2.48` from dependencies (may need to restore) + +## Next Steps for New Session + +1. **Restore pyproject.toml** if needed (add back python-atspi dependency for now) + +2. **Study Orca's migration more carefully**: + - Look at the specific changes in each commit + - Understand what pyatspi APIs were replaced with what Atspi APIs + - Check if there are subtle differences in how events are registered + +3. **Take incremental approach**: + - Make one small change at a time + - Test after each change + - Don't remove legacy fallback until we understand why it's needed + +4. **Update call sites to Atspi APIs**: + - Replace queryText/queryComponent/queryTable/etc. with Atspi methods + - Confirm text attributes and selections behave as expected + +5. **Validate input events with new InputEventManager**: + - Ensure keybindings are found for navigation keys + - Verify click-count logic (keyboard + mouse) with new manager + - Confirm no remaining callers rely on input_event_manager.getManager() + +5. **Key files to compare between Orca and Cthulhu**: + - `event_manager.py` - Event registration and handling + - `input_event_manager.py` - Keyboard event processing + - `input_event.py` - KeyboardEvent class + - `keybindings.py` - Keybinding registration and matching + +## Current State + +- **event_manager.py**: Atspi-only event handling (no pyatspi/legacy path); now uses input_event_manager.get_manager() (unvalidated) +- **test files**: Updated to use Atspi.Role (this is fine) +- **pyproject.toml**: python-atspi removed (may need to restore) +- **compat layer**: Removed; need direct Atspi usage +- **AX modules**: Replaced/added Orca AX modules (ax_component/ax_document/ax_event_synthesizer/ax_hypertext/ax_object/ax_selection/ax_table/ax_text/ax_utilities/ax_utilities_* etc.) (imports only; unvalidated) +- **focus_manager.py**: Ported from Orca and synced to cthulhu_state (unvalidated) +- **script_manager.py / input_event_manager.py**: Added get_manager + snake_case wrappers for Orca-style callers (unvalidated) +- **input_event_manager.py**: Replaced with Orca version and adapted to Cthulhu input_event API (camelCase methods) + Cthulhu device name (unvalidated) +- **input_event.py**: InputEvent/MouseButtonEvent setClickCount now accepts optional count (used by input_event_manager) (unvalidated) +- **dbus_service.py**: Remote controller events now routed via input_event_manager.get_manager() (unvalidated) +- **meson.build**: Added missing ax_* modules and focus_manager.py to install sources (unvalidated) +- **script_manager.py**: Lazy import for AXUtilities to break circular import chain (unvalidated) +- **focus_manager.py**: Lazy import for AXUtilities to break circular import chain (unvalidated) +- **script.py**: Switched to ax_event_synthesizer.get_synthesizer() (unvalidated) +- **script.py**: Added locus_of_focus_changed() wrapper calling locusOfFocusChanged() (unvalidated) +- **ax_object.py**: Added AXObject.get_application() wrapper for Atspi.Accessible.get_application (unvalidated) +- **script_manager.py / script.py / input_event.py**: Began full snake_case migration (removed script_manager getManager/getActiveScript wrappers, renamed locus_of_focus_changed + get_event_synthesizer, converted InputEvent getters/setters to snake_case, updated call sites) (unvalidated) +- **event_manager.py**: Replaced AXObject.get_application_toolkit_name() calls with AXUtilities.get_application_toolkit_name() (unvalidated) +- **script_manager.py**: Replaced AXObject.get_application_toolkit_name() with AXUtilities.get_application_toolkit_name() (unvalidated) +- **ax_utilities_relation.py / relation call sites**: Migrated relation usage to AXUtilitiesRelation helpers (get_is_* / get_flows_* etc.) and removed AXObject relation calls (unvalidated) +- **braille.py**: Added BRLAPI priority constants + setBrlapiPriority used by focus_manager (unvalidated) +- **debug.py**: Added snake_case print_message/print_tokens aliases and lazy AXObject import to avoid cycles (unvalidated) +- **debug.py**: getAccessibleDetails now uses AXUtilitiesDebugging.object_details_as_string to avoid missing AXObject *_as_string methods (unvalidated) +- **speech_generator.py**: Replaced queryText usage in text content/selection paths with AXText (unvalidated) +- **script_utilities.py**: selectedText/substring/getCaretContext/getCharacterAtOffset now use AXText (unvalidated) +- **scripts/default.py**: getTextLineAtCaret now uses AXText (unvalidated) +- **script_utilities.py / WebKitGtk script_utilities.py**: Replaced AXObject.get_previous_object/get_next_object with AXUtilities equivalents (unvalidated) +- **scripts/default.py**: onCaretMoved now uses AXText (unvalidated) +- **scripts/terminal/script_utilities.py**: Replaced queryText usage with AXText for insertions and caret checks (unvalidated) +- **scripts/terminal/script.py**: Replaced queryText usage with AXText for caret/echo logic (unvalidated) +- **script_utilities.py**: updateCachedTextSelection now uses AXText (unvalidated) +- **scripts/default.py**: sayCharacter now uses AXText (unvalidated) +- **scripts/toolkits/Chromium/script.py**: Replaced AXObject.get_parent predicate usage with AXObject.find_ancestor_inclusive (unvalidated) +- **scripts/web/script_utilities.py**: Replaced queryDocument/queryText selection logic with AXDocument/AXText (unvalidated) +- **input_event.py / input_event_manager.py**: Defer shouldConsume initialization until script/object/window set (unvalidated) +- **script_utilities.py**: queryNonEmptyText now uses AXText (unvalidated) +- **flat_review.py**: Replaced queryText/queryComponent usage with AXText/Atspi.Component (unvalidated) +- **queryText removal sweep**: Replaced remaining queryText usage across core, web, toolkit, app, and plugin codepaths with AXText/Atspi.Text equivalents (unvalidated) + - core: `script_utilities.py` (word nav + selection + password/lastContext fixes), `default.py` (SayAll progress + misspelling checks), `speech_generator.py`, `caret_navigation.py`, `label_inference.py`, `structural_navigation.py`, `spellcheck.py`, `mouse_review.py`, `liveregions.py` + - web: `scripts/web/script_utilities.py` (text-at-offset pipeline + caret context + element line heuristics), `scripts/web/script.py`, `scripts/web/speech_generator.py` + - toolkits: `scripts/toolkits/WebKitGtk/script_utilities.py`, `scripts/toolkits/WebKitGtk/script.py`, `scripts/toolkits/gtk/script_utilities.py` + - apps: `scripts/apps/gnome-shell/script.py`, `scripts/apps/gnome-shell/script_utilities.py`, `scripts/apps/gnome-documents/script.py`, `scripts/apps/Eclipse/script.py`, `scripts/apps/Thunderbird/script.py`, `scripts/apps/soffice/script.py`, `scripts/apps/soffice/script_utilities.py`, `scripts/apps/soffice/spellcheck.py`, `scripts/apps/soffice/speech_generator.py` + - plugins: `plugins/AIAssistant/plugin.py`, `plugins/IndentationAudio/plugin.py` +- **relation helpers**: Replaced remaining AXObject.has_relation() uses with AXUtilitiesRelation helpers in web + gtk toolkits (unvalidated) +- **web flat review crash**: Fixed AXObject.has_relation call in web `isErrorMessage()` path (unvalidated) + +## Reference: Orca's Key Commits + +``` +fc082b1c4 - 2023-07-27 - Use Atspi.event_main/quit instead of pyatspi.Registry.start/stop +58826de39 - 2023-07-27 - Use Atspi.generate_keyboard_event instead of pyatspi's +6b4a9a23c - 2023-07-27 - Use Atspi's set_cache_mask instead of pyatspi's +72492c05a - 2023-07-27 - Use Atspi rather than pyatspi to register for object events +837c31e9d - 2024-01-19 - Remove obsolete pyatspi-based functions for hypertext/hyperlink +1c496c9ad - 2024-02-19 - CI: Remove pyatspi installation (final removal) +``` diff --git a/pyproject.toml b/pyproject.toml index 968e227..c0911a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,6 @@ requires-python = ">=3.10" license = { text = "LGPL-2.1-or-later" } dependencies = [ "pygobject>=3.18", - "python-atspi>=2.48", "brlapi; extra == 'braille'", "python-speechd; extra == 'speech'", "louis; extra == 'braille'" diff --git a/src/cthulhu/__init__.py b/src/cthulhu/__init__.py index a29527d..d893644 100644 --- a/src/cthulhu/__init__.py +++ b/src/cthulhu/__init__.py @@ -27,5 +27,3 @@ __copyright__ = "Copyright (c) 2005-2006 Sun Microsystems Inc." __license__ = "LGPL" - - diff --git a/src/cthulhu/action_presenter.py b/src/cthulhu/action_presenter.py index bcbab0e..63a6c8a 100644 --- a/src/cthulhu/action_presenter.py +++ b/src/cthulhu/action_presenter.py @@ -223,8 +223,8 @@ class ActionPresenter: reason = "Action Presenter list is being destroyed" app = AXObject.get_application(self._obj) - script = script_manager.getManager().getScript(app, self._obj) - script_manager.getManager().setActiveScript(script, reason) + script = script_manager.get_manager().get_script(app, self._obj) + script_manager.get_manager().set_active_script(script, reason) # Update Cthulhu state cthulhu_state.activeWindow = self._window @@ -308,7 +308,7 @@ class ActionPresenter: start_time = time.time() # Get object from input event if available, otherwise let present_with_time handle it - obj = input_event.getObject() if input_event else None + obj = input_event.get_object() if input_event else None debug.printMessage(debug.LEVEL_INFO, f"ACTION PRESENTER: Object from input_event: {obj}", True) result = self.present_with_time(obj, start_time) diff --git a/src/cthulhu/ax_collection.py b/src/cthulhu/ax_collection.py index 78851b3..e20aa50 100644 --- a/src/cthulhu/ax_collection.py +++ b/src/cthulhu/ax_collection.py @@ -1,9 +1,7 @@ -#!/usr/bin/env python3 +# Utilities for obtaining objects via the collection interface. # -# 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 2023 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,19 +17,11 @@ # 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 -""" -Utilities for obtaining objects via the collection interface. -These utilities are app-type- and toolkit-agnostic. Utilities that might have -different implementations or results depending on the type of app (e.g. terminal, -chat, web) or toolkit (e.g. Qt, Gtk) should be in script_utilities.py file(s). +# pylint: disable=wrong-import-position +# pylint: disable=too-many-positional-arguments -N.B. There are currently utilities that should never have custom implementations -that live in script_utilities.py files. These will be moved over time. -""" +"""Utilities for obtaining objects via the collection interface.""" __id__ = "$Id$" __version__ = "$Revision$" @@ -44,6 +34,7 @@ import time import gi gi.require_version("Atspi", "2.0") from gi.repository import Atspi +from gi.repository import GLib from . import debug from .ax_object import AXObject @@ -56,23 +47,33 @@ class AXCollection: # This function wraps Atspi.MatchRule.new which has all the arguments. # pylint: disable=R0913,R0914 @staticmethod - def create_match_rule(states=[], - state_match_type=Atspi.CollectionMatchType.ALL, - attributes=[], - attribute_match_type=Atspi.CollectionMatchType.ANY, - roles=[], - role_match_type=Atspi.CollectionMatchType.ANY, - interfaces=[], - interface_match_type=Atspi.CollectionMatchType.ALL, - invert=False): + def create_match_rule( + states: list[str] | None = None, + state_match_type: Atspi.CollectionMatchType = Atspi.CollectionMatchType.ALL, + attributes: list[str] | None = None, + attribute_match_type: Atspi.CollectionMatchType = Atspi.CollectionMatchType.ALL, + roles: list[str] | None = None, + role_match_type: Atspi.CollectionMatchType = Atspi.CollectionMatchType.ALL, + interfaces: list[str] | None = None, + interface_match_type: Atspi.CollectionMatchType = Atspi.CollectionMatchType.ALL, + invert: bool = False) -> Atspi.MatchRule | None: """Creates a match rule based on the supplied criteria.""" + if states is None: + states = [] + if attributes is None: + attributes = [] + if roles is None: + roles = [] + if interfaces is None: + interfaces = [] + state_set = Atspi.StateSet() if states: for state in states: state_set.add(state) - attributes_dict = {} + attributes_dict: dict[str, str] = {} if attributes: for attr in attributes: key, value = attr.split(":", 1) @@ -92,19 +93,25 @@ class AXCollection: interfaces, interface_match_type, invert) - except Exception as error: + except GLib.GError as error: tokens = ["AXCollection: Exception in create_match_rule:", error] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return None return rule # pylint: enable=R0913,R0914 @staticmethod - def get_all_matches(obj, rule, order=Atspi.CollectionSortOrder.CANONICAL): + def get_all_matches( + obj: Atspi.Accessible, + rule: Atspi.MatchRule, + order: Atspi.CollectionSortOrder = Atspi.CollectionSortOrder.CANONICAL + ) -> list[Atspi.Accessible]: """Returns a list of objects matching the specified rule.""" if not AXObject.supports_collection(obj): + tokens = ["AXCollection:", obj, "does not implement this interface."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return [] if rule is None: @@ -115,20 +122,26 @@ class AXCollection: # 0 means no limit on the number of results # The final argument, traverse, is not supported but is expected. matches = Atspi.Collection.get_matches(obj, rule, order, 0, True) - except Exception as error: + except GLib.GError as error: tokens = ["AXCollection: Exception in get_all_matches:", error] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return [] msg = f"AXCollection: {len(matches)} match(es) found in {time.time() - start:.4f}s" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return matches @staticmethod - def get_first_match(obj, rule, order=Atspi.CollectionSortOrder.CANONICAL): + def get_first_match( + obj: Atspi.Accessible, + rule: Atspi.MatchRule, + order: Atspi.CollectionSortOrder = Atspi.CollectionSortOrder.CANONICAL + ) -> Atspi.Accessible | None: """Returns the first object matching the specified rule.""" if not AXObject.supports_collection(obj): + tokens = ["AXCollection:", obj, "does not implement this interface."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return None if rule is None: @@ -139,15 +152,15 @@ class AXCollection: # 1 means limit the number of results to 1 # The final argument, traverse, is not supported but is expected. matches = Atspi.Collection.get_matches(obj, rule, order, 1, True) - except Exception as error: + except GLib.GError as error: tokens = ["AXCollection: Exception in get_first_match:", error] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return None match = None if matches: match = matches[0] - msg = f"AXCollection: found {match} in {time.time() - start:.4f}s" - debug.printMessage(debug.LEVEL_INFO, msg, True) + tokens = ["AXCollection: found", match, f"in {time.time() - start:.4f}s"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return match diff --git a/src/cthulhu/ax_component.py b/src/cthulhu/ax_component.py new file mode 100644 index 0000000..6f6065e --- /dev/null +++ b/src/cthulhu/ax_component.py @@ -0,0 +1,410 @@ +# Orca +# +# 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 +# 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 + +"""Utilities for obtaining position-related information about accessible objects.""" + +__id__ = "$Id$" +__version__ = "$Revision$" +__date__ = "$Date$" +__copyright__ = "Copyright (c) 2024 Igalia, S.L." \ + "Copyright (c) 2024 GNOME Foundation Inc." +__license__ = "LGPL" + +import functools + +import gi +gi.require_version("Atspi", "2.0") +from gi.repository import Atspi +from gi.repository import GLib + +from . import debug +from .ax_object import AXObject +from .ax_utilities_role import AXUtilitiesRole + + +class AXComponent: + """Utilities for obtaining position-related information about accessible objects.""" + + @staticmethod + def get_center_point(obj: Atspi.Accessible) -> tuple[float, float]: + """Returns the center point of obj with respect to its window.""" + + rect = AXComponent.get_rect(obj) + return rect.x + rect.width / 2, rect.y + rect.height / 2 + + @staticmethod + def get_position(obj: Atspi.Accessible) -> tuple[int, int]: + """Returns the x, y position tuple of obj with respect to its window.""" + + if not AXObject.supports_component(obj): + return -1, -1 + + try: + point = Atspi.Component.get_position(obj, Atspi.CoordType.WINDOW) + except GLib.GError as error: + msg = f"AXComponent: Exception in get_position: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return -1, -1 + + if point is None: + tokens = ["AXComponent: get_position failed for", obj] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return -1, -1 + + return point.x, point.y + + @staticmethod + def get_rect(obj: Atspi.Accessible) -> Atspi.Rect: + """Returns the Atspi rect of obj with respect to its window.""" + + if not AXObject.supports_component(obj): + return Atspi.Rect() + + try: + rect = Atspi.Component.get_extents(obj, Atspi.CoordType.WINDOW) + except GLib.GError as error: + msg = f"AXComponent: Exception in get_rect: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return Atspi.Rect() + + return rect + + @staticmethod + def get_rect_intersection(rect1: Atspi.Rect, rect2: Atspi.Rect) -> Atspi.Rect: + """Returns a rect representing the intersection of rect1 and rect2.""" + + result = Atspi.Rect() + + dest_x = max(rect1.x, rect2.x) + dest_y = max(rect1.y, rect2.y) + dest_x2 = min(rect1.x + rect1.width, rect2.x + rect2.width) + dest_y2 = min(rect1.y + rect1.height, rect2.y + rect2.height) + + if dest_x2 >= dest_x and dest_y2 >= dest_y: + result.x = dest_x + result.y = dest_y + result.width = dest_x2 - dest_x + result.height = dest_y2 - dest_y + + tokens = ["AXComponent: The intersection of", rect1, "and", rect2, "is:", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def get_size(obj: Atspi.Accessible) -> tuple[int, int]: + """Returns the width, height tuple of obj with respect to its window.""" + + if not AXObject.supports_component(obj): + return -1, -1 + + try: + point = Atspi.Component.get_size(obj, Atspi.CoordType.WINDOW) + except GLib.GError as error: + msg = f"AXComponent: Exception in get_position: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return -1, -1 + + if point is None: + tokens = ["AXComponent: get_size failed for", obj] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return -1, -1 + + # An Atspi.Point object stores width in x and height in y. + return point.x, point.y + + @staticmethod + def has_no_size(obj: Atspi.Accessible) -> bool: + """Returns True if obj has a width and height of 0.""" + + rect = AXComponent.get_rect(obj) + return not(rect.width or rect.height) + + @staticmethod + def has_no_size_or_invalid_rect(obj: Atspi.Accessible) -> bool: + """Returns True if the rect associated with obj is sizeless or invalid.""" + + rect = AXComponent.get_rect(obj) + if not (rect.width or rect.height): + return True + + if rect.x == rect.y == rect.width == rect.height == -1: + return True + + if (rect.width < -1 or rect.height < -1): + tokens = ["WARNING: ", obj, "has a broken rect:", rect] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + AXObject.clear_cache(obj) + rect = AXComponent.get_rect(obj) + if (rect.width < -1 or rect.height < -1): + msg = "AXComponent: Clearing cache did not fix the rect" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + return False + + @staticmethod + def is_empty_rect(rect: Atspi.Rect) -> bool: + """Returns True if rect's x, y, width, and height are all 0.""" + + return rect.x == 0 and rect.y == 0 and rect.width == 0 and rect.height == 0 + + @staticmethod + def is_same_rect(rect1: Atspi.Rect, rect2: Atspi.Rect) -> bool: + """Returns True if rect1 and rect2 represent the same bounding box.""" + + return rect1.x == rect2.x \ + and rect1.y == rect2.y \ + and rect1.width == rect2.width \ + and rect1.height == rect2.height + + @staticmethod + def object_contains_point(obj: Atspi.Accessible, x: int, y: int) -> bool: + """Returns True if obj's rect contains the specified point.""" + + if not AXObject.supports_component(obj): + return False + + if AXObject.is_bogus(obj): + return False + + try: + result = Atspi.Component.contains(obj, x, y, Atspi.CoordType.WINDOW) + except GLib.GError as error: + msg = f"AXComponent: Exception in object_contains_point: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + tokens = ["AXComponent: ", obj, f"contains point {x}, {y}: {result}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def object_intersects_rect(obj: Atspi.Accessible, rect: Atspi.Rect) -> bool: + """Returns True if the Atspi.Rect associated with obj intersects rect.""" + + intersection = AXComponent.get_rect_intersection(AXComponent.get_rect(obj), rect) + return not AXComponent.is_empty_rect(intersection) + + @staticmethod + def object_is_off_screen(obj: Atspi.Accessible) -> bool: + """Returns True if the rect associated with obj is off-screen""" + + rect = AXComponent.get_rect(obj) + if abs(rect.x) > 10000 or abs(rect.y) > 10000: + tokens = ["AXComponent: Treating", obj, "as offscreen due to position"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return True + + if rect.width == 0 or rect.height == 0: + if not AXObject.get_child_count(obj): + tokens = ["AXComponent: Treating", obj, "as offscreen due to size and no children"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return True + if AXUtilitiesRole.is_menu(obj): + tokens = ["AXComponent: Treating", obj, "as offscreen due to size and role"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return True + tokens = ["AXComponent: Treating sizeless", obj, "as onscreen"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + result = rect.x + rect.width < 0 and rect.y + rect.height < 0 + tokens = ["AXComponent:", obj, f"is off-screen: {result}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def objects_have_same_rect(obj1: Atspi.Accessible, obj2: Atspi.Accessible) -> bool: + """Returns True if obj1 and obj2 have the same rect.""" + + return AXComponent.is_same_rect(AXComponent.get_rect(obj1),AXComponent.get_rect(obj2)) + + @staticmethod + def objects_overlap(obj1: Atspi.Accessible, obj2: Atspi.Accessible) -> bool: + """Returns True if the rects associated with obj1 and obj2 overlap.""" + + intersection = AXComponent.get_rect_intersection( + AXComponent.get_rect(obj1), AXComponent.get_rect(obj2)) + return not AXComponent.is_empty_rect(intersection) + + @staticmethod + def on_same_line(obj1: Atspi.Accessible, obj2: Atspi.Accessible, delta: int = 0) -> bool: + """Returns True if obj1 and obj2 are on the same line.""" + + rect1 = AXComponent.get_rect(obj1) + rect2 = AXComponent.get_rect(obj2) + y1_center = rect1.y + rect1.height / 2 + y2_center = rect2.y + rect2.height / 2 + + # If the center points differ by more than delta, they are not on the same line. + if abs(y1_center - y2_center) > delta: + return False + + # If there's a significant difference in height, they are not on the same line. + min_height = min(rect1.height, rect2.height) + max_height = max(rect1.height, rect2.height) + if min_height > 0 and max_height / min_height > 2.0: + return False + + return True + + @staticmethod + def _object_bounds_includes_children(obj: Atspi.Accessible) -> bool: + """Returns True if obj's rect is expected to include the rects of its children.""" + + if AXUtilitiesRole.is_menu(obj) or AXUtilitiesRole.is_page_tab(obj): + return False + + rect = AXComponent.get_rect(obj) + return rect.width > 0 and rect.height > 0 + + @staticmethod + def _find_descendant_at_point( + obj: Atspi.Accessible, x: int, y: int + ) -> Atspi.Accessible | None: + """Checks each child to see if it has a descendant at the specified point.""" + + for child in AXObject.iter_children(obj): + if AXComponent._object_bounds_includes_children(child): + continue + for descendant in AXObject.iter_children(child): + if AXComponent.object_contains_point(descendant, x, y): + return descendant + return None + + @staticmethod + def _get_object_at_point(obj: Atspi.Accessible, x: int, y: int) -> Atspi.Accessible | None: + """Returns the child (or descendant?) of obj at the specified point.""" + + if not AXObject.supports_component(obj): + return None + + try: + result = Atspi.Component.get_accessible_at_point(obj, x, y, Atspi.CoordType.WINDOW) + except GLib.GError as error: + msg = f"AXComponent: Exception in get_child_at_point: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return None + + tokens = ["AXComponent: Child of", obj, f"at {x}, {y} is", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def _get_descendant_at_point( + obj: Atspi.Accessible, x: int, y: int + ) -> Atspi.Accessible | None: + """Returns the deepest descendant of obj at the specified point.""" + + child = AXComponent._get_object_at_point(obj, x, y) + if child is None and AXComponent.object_contains_point(obj, x, y): + descendant = AXComponent._find_descendant_at_point(obj, x, y) + if descendant is None: + return obj + child = descendant + + if child == obj or not AXObject.get_child_count(child): + return child + + result = AXComponent._get_descendant_at_point(child, x, y) + if result and not AXObject.is_dead(result): + return result + return child + + @staticmethod + def get_descendant_at_point( + obj: Atspi.Accessible, x: int, y: int + ) -> Atspi.Accessible | None: + """Returns the deepest descendant of obj at the specified point.""" + + result = AXComponent._get_descendant_at_point(obj, x, y) + tokens = ["AXComponent: Descendant of", obj, f"at {x}, {y} is", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def scroll_object_to_point(obj: Atspi.Accessible, x: int, y: int) -> bool: + """Attempts to scroll obj to the specified point.""" + + if not AXObject.supports_component(obj): + return False + + try: + result = Atspi.Component.scroll_to_point(obj, Atspi.CoordType.WINDOW, x, y) + except GLib.GError as error: + msg = f"AXComponent: Exception in scroll_object_to_point: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + tokens = ["AXComponent: Scrolled", obj, f"to {x}, {y}:", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def scroll_object_to_location(obj: Atspi.Accessible, location: Atspi.ScrollType) -> bool: + """Attempts to scroll obj to the specified Atspi.ScrollType location.""" + + if not AXObject.supports_component(obj): + return False + + try: + result = Atspi.Component.scroll_to(obj, location) + except GLib.GError as error: + msg = f"AXComponent: Exception in scroll_object_to_location: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + tokens = ["AXComponent: Scrolled", obj, "to", location, f": {result}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def sort_objects_by_size(objects: list[Atspi.Accessible]) -> list[Atspi.Accessible]: + """Returns objects sorted from smallest to largest.""" + + def _size_comparison(obj1: Atspi.Accessible, obj2: Atspi.Accessible) -> int: + rect1 = AXComponent.get_rect(obj1) + rect2 = AXComponent.get_rect(obj2) + return (rect1.width * rect1.height) - (rect2.width * rect2.height) + + return sorted(objects, key=functools.cmp_to_key(_size_comparison)) + + @staticmethod + def sort_objects_by_position(objects: list[Atspi.Accessible]) -> list[Atspi.Accessible]: + """Returns objects sorted from top-left to bottom-right.""" + + def _spatial_comparison(obj1: Atspi.Accessible, obj2: Atspi.Accessible) -> int: + rect1 = AXComponent.get_rect(obj1) + rect2 = AXComponent.get_rect(obj2) + rv = rect1.y - rect2.y or rect1.x - rect2.x + + # If the objects claim to have the same coordinates and the same parent, + # we probably have bogus coordinates from the implementation. + if not rv and AXObject.get_parent(obj1) == AXObject.get_parent(obj2): + rv = AXObject.get_index_in_parent(obj1) - AXObject.get_index_in_parent(obj2) + + rv = max(rv, -1) + rv = min(rv, 1) + return rv + + return sorted(objects, key=functools.cmp_to_key(_spatial_comparison)) diff --git a/src/cthulhu/ax_document.py b/src/cthulhu/ax_document.py new file mode 100644 index 0000000..e8fc4bd --- /dev/null +++ b/src/cthulhu/ax_document.py @@ -0,0 +1,276 @@ +# Orca +# +# 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 +# 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 + +"""Utilities for obtaining document-related information about accessible objects.""" + +__id__ = "$Id$" +__version__ = "$Revision$" +__date__ = "$Date$" +__copyright__ = "Copyright (c) 2024 Igalia, S.L." \ + "Copyright (c) 2024 GNOME Foundation Inc." +__license__ = "LGPL" + +import threading +import time +import urllib.parse + +import gi +gi.require_version("Atspi", "2.0") +from gi.repository import Atspi +from gi.repository import GLib + +from . import debug +from . import messages +from .ax_collection import AXCollection +from .ax_object import AXObject +from .ax_table import AXTable +from .ax_utilities_role import AXUtilitiesRole +from .ax_utilities_state import AXUtilitiesState + +class AXDocument: + """Utilities for obtaining document-related information about accessible objects.""" + + LAST_KNOWN_PAGE: dict[int, int] = {} + _lock = threading.Lock() + + @staticmethod + def _clear_stored_data() -> None: + """Clears any data we have cached for objects""" + + while True: + time.sleep(60) + msg = "AXDocument: Clearing local cache." + debug.print_message(debug.LEVEL_INFO, msg, True) + AXDocument.LAST_KNOWN_PAGE.clear() + + @staticmethod + def start_cache_clearing_thread() -> None: + """Starts thread to periodically clear cached details.""" + + thread = threading.Thread(target=AXDocument._clear_stored_data) + thread.daemon = True + thread.start() + + @staticmethod + def did_page_change(document: Atspi.Accessible) -> bool: + """Returns True if the current page changed.""" + + if not AXObject.supports_document(document): + return False + + old_page = AXDocument.LAST_KNOWN_PAGE.get(hash(document)) + result = old_page != AXDocument._get_current_page(document) + if result: + tokens = ["AXDocument: Previous page of", document, f"was {old_page}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + return result + + @staticmethod + def _get_current_page(document: Atspi.Accessible) -> int: + """Returns the current page of document.""" + + if not AXObject.supports_document(document): + return 0 + + try: + page = Atspi.Document.get_current_page_number(document) + except GLib.GError as error: + msg = f"AXDocument: Exception in _get_current_page: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return 0 + + tokens = ["AXDocument: Current page of", document, f"is {page}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return page + + @staticmethod + def get_current_page(document: Atspi.Accessible) -> int: + """Returns the current page of document.""" + + if not AXObject.supports_document(document): + return 0 + + page = AXDocument._get_current_page(document) + AXDocument.LAST_KNOWN_PAGE[hash(document)] = page + return page + + @staticmethod + def get_page_count(document: Atspi.Accessible) -> int: + """Returns the page count of document.""" + + if not AXObject.supports_document(document): + return 0 + + try: + count = Atspi.Document.get_page_count(document) + except GLib.GError as error: + msg = f"AXDocument: Exception in get_page_count: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return 0 + + tokens = ["AXDocument: Page count of", document, f"is {count}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return count + + @staticmethod + def get_locale(document: Atspi.Accessible) -> str: + """Returns the locale of document.""" + + if not AXObject.supports_document(document): + return "" + + try: + result = Atspi.Document.get_locale(document) + except GLib.GError as error: + msg = f"AXDocument: Exception in get_locale: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return "" + + if result is None: + tokens = ["AXDocument: get_locale failed for", document] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return "" + + tokens = ["AXDocument: Locale of", document, f"is '{result}'"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def _get_attributes_dict(document: Atspi.Accessible) -> dict[str, str]: + """Returns a dict with the document-attributes of document.""" + + if not AXObject.supports_document(document): + return {} + + try: + result = Atspi.Document.get_document_attributes(document) + except GLib.GError as error: + msg = f"AXDocument: Exception in _get_attributes_dict: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return {} + + tokens = ["AXDocument: Attributes of", document, "are:", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result or {} + + @staticmethod + def get_uri(document: Atspi.Accessible) -> str: + """Returns the uri of document.""" + + if not AXObject.supports_document(document): + return "" + + attributes = AXDocument._get_attributes_dict(document) + return attributes.get("DocURL", attributes.get("URI", "")) + + @staticmethod + def get_mime_type(document: Atspi.Accessible) -> str: + """Returns the uri of document.""" + + if not AXObject.supports_document(document): + return "" + + attributes = AXDocument._get_attributes_dict(document) + return attributes.get("MimeType", "") + + @staticmethod + def is_plain_text(document: Atspi.Accessible) -> bool: + """Returns True if document is a plain-text document.""" + + return AXDocument.get_mime_type(document) == "text/plain" + + @staticmethod + def is_pdf(document: Atspi.Accessible) -> bool: + """Returns True if document is a PDF document.""" + + mime_type = AXDocument.get_mime_type(document) + if mime_type == "application/pdf": + return True + if mime_type == "text/html": + return AXDocument.get_uri(document).endswith(".pdf") + return False + + @staticmethod + def get_document_uri_fragment(document: Atspi.Accessible) -> str: + """Returns the fragment portion of document's uri.""" + + result = urllib.parse.urlparse(AXDocument.get_uri(document)) + return result.fragment + + @staticmethod + def _get_object_counts(document: Atspi.Accessible) -> dict[str, int]: + """Returns a dictionary of object counts used in a document summary.""" + + result = {"forms": 0, + "landmarks": 0, + "headings": 0, + "tables": 0, + "unvisited_links": 0, + "visited_links": 0} + + roles = [Atspi.Role.HEADING, + Atspi.Role.LINK, + Atspi.Role.TABLE, + Atspi.Role.FORM, + Atspi.Role.LANDMARK] + + rule = AXCollection.create_match_rule( + roles=roles, role_match_type=Atspi.CollectionMatchType.ANY) + matches = AXCollection.get_all_matches(document, rule) + + for obj in matches: + if AXUtilitiesRole.is_heading(obj): + result["headings"] += 1 + elif AXUtilitiesRole.is_form(obj): + result["forms"] += 1 + elif AXUtilitiesRole.is_table(obj) and not AXTable.is_layout_table(obj): + result["tables"] += 1 + elif AXUtilitiesRole.is_link(obj): + if AXUtilitiesState.is_visited(obj): + result["visited_links"] += 1 + else: + result["unvisited_links"] += 1 + elif AXUtilitiesRole.is_landmark(obj): + result["landmarks"] += 1 + + return result + + @staticmethod + def get_document_summary(document: Atspi.Accessible, only_if_found: bool = True) -> str: + """Returns a string summarizing the document's structure and objects of interest.""" + + result = [] + counts = AXDocument._get_object_counts(document) + result.append(messages.landmark_count(counts.get("landmarks", 0), only_if_found)) + result.append(messages.heading_count(counts.get("headings", 0), only_if_found)) + result.append(messages.form_count(counts.get("forms", 0), only_if_found)) + result.append(messages.table_count(counts.get("tables", 0), only_if_found)) + result.append(messages.visited_link_count(counts.get("visited_links", 0), only_if_found)) + result.append(messages.unvisited_link_count( + counts.get("unvisited_links", 0), only_if_found)) + result = list(filter(lambda x: x, result)) + if not result: + return "" + + return messages.PAGE_SUMMARY_PREFIX % ", ".join(result) diff --git a/src/cthulhu/ax_event_synthesizer.py b/src/cthulhu/ax_event_synthesizer.py index 9d413ec..b008527 100644 --- a/src/cthulhu/ax_event_synthesizer.py +++ b/src/cthulhu/ax_event_synthesizer.py @@ -1,9 +1,7 @@ -#!/usr/bin/env python3 +# Orca # -# 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 2018-2023 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,9 +17,9 @@ # License along with this library; if not, write to the # Free Software Foundation, Inc., Franklin Street, Fifth Floor, # Boston MA 02110-1301 USA. -# -# Forked from Orca screen reader. -# Cthulhu project: https://git.stormux.org/storm/cthulhu + +# pylint: disable=wrong-import-position +# pylint: disable=too-many-public-methods """Provides support for synthesizing accessible input events.""" @@ -32,555 +30,328 @@ __copyright__ = "Copyright (c) 2005-2008 Sun Microsystems Inc." \ "Copyright (c) 2018-2023 Igalia, S.L." __license__ = "LGPL" -import time - import gi - gi.require_version("Atspi", "2.0") -from gi.repository import Atspi - gi.require_version("Gtk", "3.0") -from gi.repository import Gtk +from gi.repository import Atspi +from gi.repository import GLib from . import debug +from .ax_component import AXComponent from .ax_object import AXObject -from .ax_utilities import AXUtilities +from .ax_text import AXText +from .ax_utilities_debugging import AXUtilitiesDebugging +from .ax_utilities_role import AXUtilitiesRole class AXEventSynthesizer: """Provides support for synthesizing accessible input events.""" - _banner = None + @staticmethod + def _highest_ancestor(obj: Atspi.Accessible) -> bool: + """Returns True if the parent of obj is the application or None.""" + + parent = AXObject.get_parent(obj) + return parent is None or AXUtilitiesRole.is_application(parent) @staticmethod - def _get_mouse_coordinates(): - """Returns the current mouse coordinates.""" + def _is_scrolled_off_screen( + obj: Atspi.Accessible, + offset: int | None = None, + ancestor: Atspi.Accessible | None = None + ) -> bool: + """Returns true if obj, or the caret offset therein, is scrolled off-screen.""" - root_window = Gtk.Window().get_screen().get_root_window() - window, x_coord, y_coord, modifiers = root_window.get_pointer() - tokens = ["AXEventSynthesizer: Mouse coordinates:", x_coord, ",", y_coord] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return x_coord, y_coord + tokens = ["AXEventSynthesizer: Checking if", obj, "is scrolled offscreen"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) - @staticmethod - def _generate_mouse_event(x_coord, y_coord, event): - """Synthesize a mouse event at a specific screen coordinate.""" - - old_x, old_y = AXEventSynthesizer._get_mouse_coordinates() - tokens = ["AXEventSynthesizer: Generating", event, "mouse event at", x_coord, ",", y_coord] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - - try: - success = Atspi.generate_mouse_event(x_coord, y_coord, event) - except Exception as error: - tokens = ["AXEventSynthesizer: Exception in _generate_mouse_event:", error] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - success = False - else: - tokens = ["AXEventSynthesizer: Atspi.generate_mouse_event returned", success] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - - # There seems to be a timeout / lack of reply from this blocking call. - # But often the mouse event is successful. Pause briefly before checking. - time.sleep(1) - - new_x, new_y = AXEventSynthesizer._get_mouse_coordinates() - if old_x == new_x and old_y == new_y and (old_x, old_y) != (x_coord, y_coord): - msg = "AXEventSynthesizer: Mouse event possible failure. Pointer didn't move" - debug.println(debug.LEVEL_INFO, msg, True) + rect = AXComponent.get_rect(obj) + ancestor = ancestor or AXObject.find_ancestor(obj, AXEventSynthesizer._highest_ancestor) + if ancestor is None: + tokens = ["AXEventSynthesizer: Could not get ancestor of", obj] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return False + ancestor_rect = AXComponent.get_rect(ancestor) + intersection = AXComponent.get_rect_intersection(ancestor_rect, rect) + if AXComponent.is_empty_rect(intersection): + tokens = ["AXEventSynthesizer:", obj, "is outside of", ancestor, ancestor_rect] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return True + + if offset is None: + tokens = ["AXEventSynthesizer:", obj, "is not scrolled offscreen"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + extents = AXText.get_character_rect(obj, offset) + if AXComponent.is_empty_rect(extents): + tokens = ["AXEventSynthesizer: Could not get character rect of", obj] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + intersection = AXComponent.get_rect_intersection(extents, rect) + if AXComponent.is_empty_rect(intersection): + tokens = ["AXEventSynthesizer:", obj, "'s caret", extents, "not in obj", rect] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return True + + return False + + @staticmethod + def _generate_mouse_event( + obj: Atspi.Accessible, relative_x: int, relative_y: int, event: str + ) -> bool: + tokens = ["AXEventSynthesizer: Attempting to generate mouse event on", obj, + f"at relative coordinates {relative_x},{relative_y}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + try: + device = Atspi.Device.new() + Atspi.Device.generate_mouse_event(device, obj, relative_x, relative_y, event) + except GLib.GError as error: + message = f"AXEventSynthesizer: Exception in _generate_mouse_event_new: {error}" + debug.print_message(debug.LEVEL_INFO, message, True) + return False return True @staticmethod - def _intersection(extents1, extents2): - """Returns the bounding box containing the intersection of the two boxes.""" - - x_coord1, y_coord1, width1, height1 = extents1 - x_coord2, y_coord2, width2, height2 = extents2 - - x_points1 = range(x_coord1, x_coord1 + width1 + 1) - x_points2 = range(x_coord2, x_coord2 + width2 + 1) - x_intersection = sorted(set(x_points1).intersection(set(x_points2))) - - y_points1 = range(y_coord1, y_coord1 + height1 + 1) - y_points2 = range(y_coord2, y_coord2 + height2 + 1) - y_intersection = sorted(set(y_points1).intersection(set(y_points2))) - - if not (x_intersection and y_intersection): - return 0, 0, 0, 0 - - x_coord = x_intersection[0] - y_coord = y_intersection[0] - width = x_intersection[-1] - x_coord - height = y_intersection[-1] - y_coord - return x_coord, y_coord, width, height - - @staticmethod - def _extents_at_caret(obj): - """Returns the character extents of obj at the current caret offset.""" - - try: - text = obj.queryText() - extents = text.getCharacterExtents(text.caretOffset, Atspi.CoordType.SCREEN) - except Exception: - tokens = ["ERROR: Exception getting character extents for", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return 0, 0, 0, 0 - - return extents - - @staticmethod - def _object_extents(obj): - """Returns the bounding box associated with obj.""" - - try: - extents = obj.queryComponent().getExtents(Atspi.CoordType.SCREEN) - except Exception: - tokens = ["ERROR: Exception getting extents for", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return 0, 0, 0, 0 - - return extents - - @staticmethod - def _mouse_event_on_character(obj, event): + def _mouse_event_on_character(obj: Atspi.Accessible, offset: int | None, event: str) -> bool: """Performs the specified mouse event on the current character in obj.""" - extents = AXEventSynthesizer._extents_at_caret(obj) - if extents == (0, 0, 0, 0): + if offset is None: + offset = max(AXText.get_caret_offset(obj), 0) + + if AXEventSynthesizer._is_scrolled_off_screen(obj, offset): + AXEventSynthesizer.scroll_into_view(obj, offset) + if AXEventSynthesizer._is_scrolled_off_screen(obj, offset): + tokens = ["AXEventSynthesizer:", obj, "is still offscreen. Setting caret."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + AXText.set_caret_offset(obj, offset) + + extents = AXText.get_character_rect(obj, offset) + if AXComponent.is_empty_rect(extents): return False - obj_extents = AXEventSynthesizer._object_extents(obj) - intersection = AXEventSynthesizer._intersection(extents, obj_extents) - if intersection == (0, 0, 0, 0): - tokens = ["AXEventSynthesizer:", obj, "'s caret", extents, "not in obj", obj_extents] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + rect = AXComponent.get_rect(obj) + intersection = AXComponent.get_rect_intersection(extents, rect) + if AXComponent.is_empty_rect(intersection): + tokens = ["AXEventSynthesizer:", obj, "'s caret", extents, "not in obj", rect] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return False - x_coord = max(extents[0], extents[0] + (extents[2] / 2) - 1) - y_coord = extents[1] + extents[3] / 2 - return AXEventSynthesizer._generate_mouse_event(x_coord, y_coord, event) + relative_x = (extents.x - rect.x) + extents.width / 2 + relative_y = (extents.y - rect.y) + extents.height / 2 + return AXEventSynthesizer._generate_mouse_event(obj, relative_x, relative_y, event) @staticmethod - def _mouse_event_on_object(obj, event): + def _mouse_event_on_object(obj: Atspi.Accessible, event: str) -> bool: """Performs the specified mouse event on obj.""" - extents = AXEventSynthesizer._object_extents(obj) - if extents == (0, 0, 0, 0): - return False + if AXEventSynthesizer._is_scrolled_off_screen(obj): + AXEventSynthesizer.scroll_into_view(obj) + if AXEventSynthesizer._is_scrolled_off_screen(obj): + tokens = ["AXEventSynthesizer:", obj, "is still offscreen. Grabbing focus."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + AXObject.grab_focus(obj) - x_coord = extents.x + extents.width/2 - y_coord = extents.y + extents.height/2 - return AXEventSynthesizer._generate_mouse_event(x_coord, y_coord, event) + rect = AXComponent.get_rect(obj) + relative_x = rect.width / 2 + relative_y = rect.height / 2 + return AXEventSynthesizer._generate_mouse_event(obj, relative_x, relative_y, event) @staticmethod - def route_to_character(obj): + def route_to_character(obj: Atspi.Accessible, offset: int | None = None) -> bool: """Routes the pointer to the current character in obj.""" - tokens = ["AXEventSynthesizer: Attempting to route to character in", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return AXEventSynthesizer._mouse_event_on_character(obj, "abs") + tokens = [f"AXEventSynthesizer: Attempting to route to offset {offset} in", obj] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return AXEventSynthesizer._mouse_event_on_character(obj, offset, "abs") @staticmethod - def route_to_object(obj): + def route_to_object(obj: Atspi.Accessible) -> bool: """Moves the mouse pointer to the center of obj.""" tokens = ["AXEventSynthesizer: Attempting to route to", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return AXEventSynthesizer._mouse_event_on_object(obj, "abs") @staticmethod - def route_to_point(x_coord, y_coord): - """Routes the pointer to the specified coordinates.""" - - return AXEventSynthesizer._generate_mouse_event(x_coord, y_coord, "abs") - - @staticmethod - def click_character(obj, button=1): + def click_character( + obj: Atspi.Accessible, offset: int | None = None, button: int = 1 + ) -> bool: """Single click on the current character in obj using the specified button.""" - return AXEventSynthesizer._mouse_event_on_character(obj, f"b{button}c") + tokens = [f"AXEventSynthesizer: Attempting to click at offset {offset} in", obj] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return AXEventSynthesizer._mouse_event_on_character(obj, offset, f"b{button}c") @staticmethod - def click_object(obj, button=1): + def click_object(obj: Atspi.Accessible, button: int = 1) -> bool: """Single click on obj using the specified button.""" return AXEventSynthesizer._mouse_event_on_object(obj, f"b{button}c") @staticmethod - def click_point(x_coord, y_coord, button=1): - """Single click on the given point using the specified button.""" - - return AXEventSynthesizer._generate_mouse_event(x_coord, y_coord, f"b{button}c") - - @staticmethod - def double_click_character(obj, button=1): - """Double click on the current character in obj using the specified button.""" - - return AXEventSynthesizer._mouse_event_on_character(obj, f"b{button}d") - - @staticmethod - def double_click_object(obj, button=1): - """Double click on obj using the specified button.""" - - return AXEventSynthesizer._mouse_event_on_object(obj, f"b{button}d") - - @staticmethod - def double_click_point(x_coord, y_coord, button=1): - """Double click on the given point using the specified button.""" - - return AXEventSynthesizer._generate_mouse_event(x_coord, y_coord, f"b{button}d") - - @staticmethod - def press_at_character(obj, button=1): - """Performs a press on the current character in obj using the specified button.""" - - return AXEventSynthesizer._mouse_event_on_character(obj, f"b{button}p") - - @staticmethod - def press_at_object(obj, button=1): - """Performs a press on obj using the specified button.""" - - return AXEventSynthesizer._mouse_event_on_object(obj, f"b{button}p") - - @staticmethod - def press_at_point(x_coord, y_coord, button=1): - """Performs a press on the given point using the specified button.""" - - return AXEventSynthesizer._generate_mouse_event(x_coord, y_coord, f"b{button}p") - - @staticmethod - def release_at_character(obj, button=1): - """Performs a release on the current character in obj using the specified button.""" - - return AXEventSynthesizer._mouse_event_on_character(obj, f"b{button}r") - - @staticmethod - def release_at_object(obj, button=1): - """Performs a release on obj using the specified button.""" - - return AXEventSynthesizer._mouse_event_on_object(obj, f"b{button}r") - - @staticmethod - def release_at_point(x_coord, y_coord, button=1): - """Performs a release on the given point using the specified button.""" - - return AXEventSynthesizer._generate_mouse_event(x_coord, y_coord, f"b{button}r") - - @staticmethod - def _scroll_substring_to_location(obj, location, start_offset, end_offset): - """Attempts to scroll the given substring to the specified location.""" - - try: - text = obj.queryText() - if not text.characterCount: - return False - if start_offset is None: - start_offset = 0 - if end_offset is None: - end_offset = text.characterCount - 1 - result = text.scrollSubstringTo(start_offset, end_offset, location) - except NotImplementedError: - tokens = ["AXEventSynthesizer: Text interface not implemented for", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return False - except Exception: - msg = ( - f"AXEventSynthesizer: Exception scrolling {obj} ({start_offset}, {end_offset}) " - f"to {location.value_name}." - ) - debug.println(debug.LEVEL_INFO, msg, True) - return False - - msg = ( - f"AXEventSynthesizer: scrolled {obj} substring ({start_offset}, {end_offset}) " - f"to {location.value_name}: {result}" - ) - debug.println(debug.LEVEL_INFO, msg, True) - return result - - @staticmethod - def _scroll_object_to_location(obj, location): - """Attempts to scroll obj to the specified location.""" - - try: - result = obj.queryComponent().scrollTo(location) - except NotImplementedError: - tokens = ["AXEventSynthesizer: Component interface not implemented for", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return False - except Exception: - tokens = ["AXEventSynthesizer: Exception scrolling", - obj, "to", location.value_name, "."] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return False - - tokens = ["AXEventSynthesizer: scrolled", obj, "to", location.value_name, ":", result] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return result - - @staticmethod - def _scroll_to_location(obj, location, start_offset=None, end_offset=None): + def _scroll_to_location( + obj: Atspi.Accessible, location: Atspi.ScrollType, + start_offset: int | None = None, end_offset: int | None = None + ) -> None: """Attempts to scroll to the specified location.""" - try: - component = obj.queryComponent() - except Exception: - tokens = ["AXEventSynthesizer: Exception querying component of", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + before = AXComponent.get_position(obj) + AXText.scroll_substring_to_location(obj, location, start_offset, end_offset) + AXObject.clear_cache(obj, False, "To obtain updated location after scroll.") + after = AXComponent.get_position(obj) + tokens = ["AXEventSynthesizer: Text scroll, before:", before, "after:", after] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if before != after: return - before = component.getExtents(Atspi.CoordType.SCREEN) - - if not AXEventSynthesizer._scroll_substring_to_location( - obj, location, start_offset, end_offset): - AXEventSynthesizer._scroll_object_to_location(obj, location) - - after = component.getExtents(Atspi.CoordType.SCREEN) - msg = ( - f"AXEventSynthesizer: Before scroll: {before[0]}, {before[1]}. " - f"After scroll: {after[0]}, {after[1]}." - ) - debug.println(debug.LEVEL_INFO, msg, True) + AXComponent.scroll_object_to_location(obj, location) + AXObject.clear_cache(obj, False, "To obtain updated location after scroll.") + after = AXComponent.get_position(obj) + tokens = ["AXEventSynthesizer: Object scroll, before:", before, "after:", after] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) @staticmethod - def _scroll_substring_to_point(obj, x_coord, y_coord, start_offset, end_offset): - """Attempts to scroll the given substring to the specified location.""" - - try: - text = obj.queryText() - if not text.characterCount: - return False - if start_offset is None: - start_offset = 0 - if end_offset is None: - end_offset = text.characterCount - 1 - result = text.scrollSubstringToPoint( - start_offset, end_offset, Atspi.CoordType.SCREEN, x_coord, y_coord) - except NotImplementedError: - tokens = ["AXEventSynthesizer: Text interface not implemented for", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return False - except Exception: - msg = ( - f"AXEventSynthesizer: Exception scrolling {obj} ({start_offset}, {end_offset}) " - f"to {x_coord}, {y_coord}" - ) - debug.println(debug.LEVEL_INFO, msg, True) - return False - - msg = "AXEventSynthesizer: scrolled %s (%i, %i) to %i, %i: %s" % \ - (obj, start_offset, end_offset, x_coord, y_coord, result) - debug.println(debug.LEVEL_INFO, msg, True) - return result - - @staticmethod - def _scroll_object_to_point(obj, x_coord, y_coord): + def _scroll_to_point( + obj: Atspi.Accessible, x_coord: int, y_coord: int, + start_offset: int | None = None, end_offset: int | None = None + ) -> None: """Attempts to scroll obj to the specified point.""" - try: - result = obj.queryComponent().scrollToPoint(Atspi.CoordType.SCREEN, x_coord, y_coord) - except NotImplementedError: - tokens = ["AXEventSynthesizer: Component interface not implemented for", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return False - except Exception: - tokens = ["AXEventSynthesizer: Exception scrolling", obj, "to", x_coord, ",", y_coord] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return False - - tokens = ["AXEventSynthesizer: scrolled", obj, "to", x_coord, ",", y_coord, ":", result] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return result - - @staticmethod - def _scroll_to_point(obj, x_coord, y_coord, start_offset=None, end_offset=None): - """Attempts to scroll obj to the specified point.""" - - try: - component = obj.queryComponent() - except Exception: - tokens = ["AXEventSynthesizer: Exception querying component of", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + before = AXComponent.get_position(obj) + AXText.scroll_substring_to_point(obj, x_coord, y_coord, start_offset, end_offset) + AXObject.clear_cache(obj, False, "To obtain updated location after scroll.") + after = AXComponent.get_position(obj) + tokens = ["AXEventSynthesizer: Text scroll, before:", before, "after:", after] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if before != after: return - before = component.getExtents(Atspi.CoordType.SCREEN) - - if not AXEventSynthesizer._scroll_substring_to_point( - obj, x_coord, y_coord, start_offset, end_offset): - AXEventSynthesizer._scroll_object_to_point(obj, x_coord, y_coord) - - after = component.getExtents(Atspi.CoordType.SCREEN) - msg = ( - f"AXEventSynthesizer: Before scroll: {before[0]}, {before[1]}. " - f"After scroll: {after[0]}, {after[1]}." - ) - debug.println(debug.LEVEL_INFO, msg, True) + AXComponent.scroll_object_to_point(obj, x_coord, y_coord) + AXObject.clear_cache(obj, False, "To obtain updated location after scroll.") + after = AXComponent.get_position(obj) + tokens = ["AXEventSynthesizer: Object scroll, before:", before, "after:", after] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) @staticmethod - def scroll_into_view(obj, start_offset=None, end_offset=None): + def scroll_into_view( + obj: Atspi.Accessible, + start_offset: int | None = None, + end_offset: int | None = None + ) -> None: """Attempts to scroll obj into view.""" AXEventSynthesizer._scroll_to_location( obj, Atspi.ScrollType.ANYWHERE, start_offset, end_offset) @staticmethod - def _containing_document(obj): - """Returns the document containing obj""" + def scroll_to_center( + obj: Atspi.Accessible, + start_offset: int | None = None, + end_offset: int | None = None + ) -> None: + """Attempts to scroll obj to the center of its window.""" - document = AXObject.find_ancestor(obj, AXUtilities.is_document) - while document: - ancestor = AXObject.find_ancestor(document, AXUtilities.is_document) - if ancestor is None or ancestor == document: - break - document = ancestor - - return document - - @staticmethod - def _get_accessible_at_point(root, x_coord, y_coord): - """"Returns the accessible in root at the specified point.""" - - try: - result = root.queryComponent().getAccessibleAtPoint( - x_coord, y_coord, Atspi.CoordType.SCREEN) - except NotImplementedError: - tokens = ["AXEventSynthesizer: Component interface not implemented for", root] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return None - except Exception: - msg = ( - f"AXEventSynthesizer: Exception getting accessible at " - f"{x_coord}, {y_coord} for {root}" - ) - debug.println(debug.LEVEL_INFO, msg, True) - return None - - tokens = ["AXEventSynthesizer: Accessible at", - x_coord, ",", y_coord, "in", root, ":", result] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return result - - @staticmethod - def _get_obscuring_banner(obj): - """"Returns the banner obscuring obj from view.""" - - document = AXEventSynthesizer._containing_document(obj) - if not document: - tokens = ["AXEventSynthesizer: No obscuring banner found for", obj, ". No document."] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return None - - if not AXObject.supports_component(document): - tokens = ["AXEventSynthesizer: No obscuring banner found for", obj, ". No doc iface."] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return None - - obj_x, obj_y, obj_width, obj_height = AXEventSynthesizer._object_extents(obj) - doc_x, doc_y, doc_width, doc_height = AXEventSynthesizer._object_extents(document) - - left = AXEventSynthesizer._get_accessible_at_point(document, doc_x, obj_y) - right = AXEventSynthesizer._get_accessible_at_point(document, doc_x + doc_width, obj_y) - if not (left and right and left == right != document): - tokens = ["AXEventSynthesizer: No obscuring banner found for", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return None - - tokens = ["AXEventSynthesizer:", obj, "believed to be obscured by banner", left] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return left - - @staticmethod - def _scroll_below_banner(obj, banner, start_offset, end_offset, margin=25): - """Attempts to scroll obj below banner.""" - - obj_x, obj_y, obj_width, obj_height = AXEventSynthesizer._object_extents(obj) - banner_x, banner_y, banner_width, banner_height = AXEventSynthesizer._object_extents(banner) - msg = ( - f"AXEventSynthesizer: Extents of banner: " - f"({banner_x}, {banner_y}, {banner_width}, {banner_height})" - ) - debug.println(debug.LEVEL_INFO, msg, True) - AXEventSynthesizer._scroll_to_point( - obj, obj_x, banner_y + banner_height + margin, start_offset, end_offset) - - @staticmethod - def scroll_to_top_edge(obj, start_offset=None, end_offset=None): - """Attempts to scroll obj to the top edge.""" - - if AXEventSynthesizer._banner and not AXObject.is_dead(AXEventSynthesizer._banner): - msg = ( - f"AXEventSynthesizer: Suspected existing banner found: " - f"{AXEventSynthesizer._banner}" - ) - debug.println(debug.LEVEL_INFO, msg, True) - AXEventSynthesizer._scroll_below_banner( - obj, AXEventSynthesizer._banner, start_offset, end_offset) + ancestor = AXObject.find_ancestor(obj, AXEventSynthesizer._highest_ancestor) + if ancestor is None: + tokens = ["AXEventSynthesizer: Could not get ancestor of", obj] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return + ancestor_rect = AXComponent.get_rect(ancestor) + x_coord = ancestor_rect.x + ancestor_rect.width / 2 + y_coord = ancestor_rect.y + ancestor_rect.height / 2 + AXEventSynthesizer._scroll_to_point(obj, x_coord, y_coord, start_offset, end_offset) + + @staticmethod + def scroll_to_top_edge( + obj: Atspi.Accessible, + start_offset: int | None = None, + end_offset: int | None = None + ) -> None: + """Attempts to scroll obj to the top edge.""" + AXEventSynthesizer._scroll_to_location( obj, Atspi.ScrollType.TOP_EDGE, start_offset, end_offset) - AXEventSynthesizer._banner = AXEventSynthesizer._get_obscuring_banner(obj) - if AXEventSynthesizer._banner: - msg = f"AXEventSynthesizer: Re-scrolling {obj} due to banner" - AXEventSynthesizer._scroll_below_banner( - obj, AXEventSynthesizer._banner, start_offset, end_offset) - debug.println(debug.LEVEL_INFO, msg, True) - @staticmethod - def scroll_to_top_left(obj, start_offset=None, end_offset=None): + def scroll_to_top_left( + obj: Atspi.Accessible, + start_offset: int | None = None, + end_offset: int | None = None + ) -> None: """Attempts to scroll obj to the top left.""" AXEventSynthesizer._scroll_to_location( obj, Atspi.ScrollType.TOP_LEFT, start_offset, end_offset) @staticmethod - def scroll_to_left_edge(obj, start_offset=None, end_offset=None): + def scroll_to_left_edge( + obj: Atspi.Accessible, + start_offset: int | None = None, + end_offset: int | None = None + ) -> None: """Attempts to scroll obj to the left edge.""" AXEventSynthesizer._scroll_to_location( obj, Atspi.ScrollType.LEFT_EDGE, start_offset, end_offset) @staticmethod - def scroll_to_bottom_edge(obj, start_offset=None, end_offset=None): + def scroll_to_bottom_edge( + obj: Atspi.Accessible, + start_offset: int | None = None, + end_offset: int | None = None + ) -> None: """Attempts to scroll obj to the bottom edge.""" AXEventSynthesizer._scroll_to_location( obj, Atspi.ScrollType.BOTTOM_EDGE, start_offset, end_offset) @staticmethod - def scroll_to_bottom_right(obj, start_offset=None, end_offset=None): + def scroll_to_bottom_right( + obj: Atspi.Accessible, + start_offset: int | None = None, + end_offset: int | None = None + ) -> None: """Attempts to scroll obj to the bottom right.""" AXEventSynthesizer._scroll_to_location( obj, Atspi.ScrollType.BOTTOM_RIGHT, start_offset, end_offset) @staticmethod - def scroll_to_right_edge(obj, start_offset=None, end_offset=None): + def scroll_to_right_edge( + obj: Atspi.Accessible, + start_offset: int | None = None, + end_offset: int | None = None + ) -> None: """Attempts to scroll obj to the right edge.""" AXEventSynthesizer._scroll_to_location( obj, Atspi.ScrollType.RIGHT_EDGE, start_offset, end_offset) @staticmethod - def try_all_clickable_actions(obj): + def try_all_clickable_actions(obj: Atspi.Accessible) -> bool: """Attempts to perform a click-like action if one is available.""" - actions = ["click", "press", "jump", "open"] + actions = ["click", "press", "jump", "open", "activate"] for action in actions: if AXObject.do_named_action(obj, action): tokens = ["AXEventSynthesizer: '", action, "' on", obj, "performed successfully"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return True if debug.LEVEL_INFO < debug.debugLevel: return False - tokens = ["AXEventSynthesizer: Actions on", obj, ":", AXObject.actions_as_string(obj)] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + tokens = ["AXEventSynthesizer: Actions on", obj, ":", + AXUtilitiesDebugging.actions_as_string(obj)] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return False _synthesizer = AXEventSynthesizer() -def getSynthesizer(): +def get_synthesizer() -> AXEventSynthesizer: + """Returns the Event Synthesizer.""" + return _synthesizer diff --git a/src/cthulhu/ax_hypertext.py b/src/cthulhu/ax_hypertext.py new file mode 100644 index 0000000..47d9c00 --- /dev/null +++ b/src/cthulhu/ax_hypertext.py @@ -0,0 +1,264 @@ +# Orca +# +# Copyright 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 +# 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 + +"""Utilities for obtaining information about accessible hypertext and hyperlinks.""" + +__id__ = "$Id$" +__version__ = "$Revision$" +__date__ = "$Date$" +__copyright__ = "Copyright (c) 2024 Igalia, S.L." +__license__ = "LGPL" + +import os +import re +from urllib.parse import urlparse + +import gi +gi.require_version("Atspi", "2.0") +from gi.repository import Atspi +from gi.repository import GLib + +from . import debug +from .ax_object import AXObject + +class AXHypertext: + """Utilities for obtaining information about accessible hypertext and hyperlinks.""" + + @staticmethod + def _get_link_count(obj: Atspi.Accessible) -> int: + """Returns the number of hyperlinks in obj.""" + + if not AXObject.supports_hypertext(obj): + return 0 + + try: + count = Atspi.Hypertext.get_n_links(obj) + except GLib.GError as error: + msg = f"AXHypertext: Exception in _get_link_count: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return 0 + + tokens = ["AXHypertext:", obj, f"reports {count} hyperlinks"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return count + + @staticmethod + def _get_link_at_index(obj: Atspi.Accessible, index: int) -> Atspi.Hyperlink | None: + """Returns the hyperlink object at the specified index.""" + + if not AXObject.supports_hypertext(obj): + return None + + try: + link = Atspi.Hypertext.get_link(obj, index) + except GLib.GError as error: + msg = f"AXHypertext: Exception in _get_link_at_index: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return None + + return link + + @staticmethod + def get_all_links_in_range( + obj: Atspi.Accessible, start_offset: int, end_offset: int + ) -> list[Atspi.Hyperlink]: + """Returns all the hyperlinks in obj who started within the specified range.""" + + links = [] + for i in range(AXHypertext._get_link_count(obj)): + link = AXHypertext._get_link_at_index(obj, i) + if start_offset <= AXHypertext.get_link_start_offset(link) < end_offset \ + or start_offset < AXHypertext.get_link_end_offset(link) <= end_offset: + links.append(link) + + tokens = [f"AXHypertext: {len(links)} hyperlinks found in", obj, + f"between start: {start_offset} and end: {end_offset}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return links + + @staticmethod + def get_all_links(obj: Atspi.Accessible) -> list[Atspi.Hyperlink]: + """Returns a list of all the hyperlinks in obj.""" + + links = [] + for i in range(AXHypertext._get_link_count(obj)): + link = AXHypertext._get_link_at_index(obj, i) + if link is not None: + links.append(link) + + tokens = [f"AXHypertext: {len(links)} hyperlinks found in", obj] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return links + + @staticmethod + def get_link_uri(obj: Atspi.Accessible, index: int = 0) -> str: + """Returns the URI associated with obj at the specified index.""" + + try: + link = Atspi.Accessible.get_hyperlink(obj) + uri = Atspi.Hyperlink.get_uri(link, index) + except GLib.GError as error: + msg = f"AXHypertext: Exception in get_link_uri: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return "" + + tokens = ["AXHypertext: URI of", obj, f"at index {index} is {uri}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return uri + + @staticmethod + def get_link_start_offset(obj: Atspi.Accessible) -> int: + """Returns the start offset of obj in the associated text.""" + + if isinstance(obj, Atspi.Hyperlink): + link = obj + obj = Atspi.Hyperlink.get_object(link, 0) + else: + link = Atspi.Accessible.get_hyperlink(obj) + + if link is None: + tokens = ["AXHypertext: Couldn't get hyperlink for", obj] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return -1 + + try: + offset = Atspi.Hyperlink.get_start_index(link) + except GLib.GError as error: + msg = f"AXHypertext: Exception in get_link_start_offset: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return -1 + + tokens = ["AXHypertext: Start offset of", obj, f"is {offset}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return offset + + @staticmethod + def get_link_end_offset(obj: Atspi.Accessible) -> int: + """Returns the end offset of obj in the associated text.""" + + if isinstance(obj, Atspi.Hyperlink): + link = obj + obj = Atspi.Hyperlink.get_object(link, 0) + else: + link = Atspi.Accessible.get_hyperlink(obj) + + if link is None: + tokens = ["AXHypertext: Couldn't get hyperlink for", obj] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return -1 + + try: + offset = Atspi.Hyperlink.get_end_index(link) + except GLib.GError as error: + msg = f"AXHypertext: Exception in get_link_end_offset: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return -1 + + tokens = ["AXHypertext: End offset of", obj, f"is {offset}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return offset + + @staticmethod + def get_link_basename( + obj: Atspi.Accessible, index: int = 0, remove_extension: bool = False + ) -> str: + """Strip directory and suffix off of the URL associated with obj.""" + + uri = AXHypertext.get_link_uri(obj, index) + if not uri: + return "" + + parsed_uri = urlparse(uri) + basename = os.path.basename(parsed_uri.path) + if remove_extension: + basename = os.path.splitext(basename)[0] + basename = re.sub(r"[-_]", " ", basename) + + tokens = ["AXHypertext: Basename for link", obj, f"is '{basename}'"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return basename + + @staticmethod + def find_child_at_offset(obj: Atspi.Accessible, offset: int) -> Atspi.Accessible | None: + """Attempts to correct for off-by-one brokenness in implementations""" + + if child := AXHypertext.get_child_at_offset(obj, offset): + return child + + if child_before := AXHypertext.get_child_at_offset(obj, offset - 1): + offset_in_parent = AXHypertext.get_character_offset_in_parent(child_before) + if offset_in_parent == offset: + tokens = [f"AXHypertext: Corrected child at offset {offset} in", obj, "is", + child_before, f"at offset {offset - 1}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return child_before + + if child_after := AXHypertext.get_child_at_offset(obj, offset + 1): + offset_in_parent = AXHypertext.get_character_offset_in_parent(child_after) + if offset_in_parent == offset: + tokens = [f"AXHypertext: Corrected child at offset {offset} in", obj, "is", + child_after, f"at offset {offset + 1}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return child_after + + return None + + @staticmethod + def get_child_at_offset(obj: Atspi.Accessible, offset: int) -> Atspi.Accessible | None: + """Returns the embedded-object child of obj at the specified offset.""" + + if not AXObject.supports_hypertext(obj): + return None + + try: + index = Atspi.Hypertext.get_link_index(obj, offset) + except GLib.GError as error: + msg = f"AXHypertext: Exception in get_child_at_offset: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return None + + if index < 0: + return None + + link = AXHypertext._get_link_at_index(obj, index) + if link is None: + return None + + try: + child = Atspi.Hyperlink.get_object(link, 0) + except GLib.GError as error: + msg = f"AXHypertext: Exception in get_child_at_offset: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return None + + tokens = [f"AXHypertext: Child at offset {offset} in", obj, "is", child] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return child + + @staticmethod + def get_character_offset_in_parent(obj: Atspi.Accessible) -> int: + """Returns the offset of the embedded-object obj in the text of its parent.""" + + if not AXObject.supports_text(AXObject.get_parent(obj)): + return -1 + + return AXHypertext.get_link_start_offset(obj) diff --git a/src/cthulhu/ax_object.py b/src/cthulhu/ax_object.py index 43b742d..362ffca 100644 --- a/src/cthulhu/ax_object.py +++ b/src/cthulhu/ax_object.py @@ -1,9 +1,7 @@ -#!/usr/bin/env python3 +# Utilities for obtaining information about accessible objects. # -# 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 2023 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,19 +17,13 @@ # 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 -""" -Utilities for obtaining information about accessible objects. -These utilities are app-type- and toolkit-agnostic. Utilities that might have -different implementations or results depending on the type of app (e.g. terminal, -chat, web) or toolkit (e.g. Qt, Gtk) should be in script_utilities.py file(s). +# pylint: disable=wrong-import-position +# pylint: disable=too-many-lines +# pylint: disable=too-many-return-statements +# pylint: disable=too-many-public-methods -N.B. There are currently utilities that should never have custom implementations -that live in script_utilities.py files. These will be moved over time. -""" +"""Utilities for obtaining information about accessible objects.""" __id__ = "$Id$" __version__ = "$Revision$" @@ -42,25 +34,31 @@ __license__ = "LGPL" import re import threading import time +from typing import Callable, Generator import gi gi.require_version("Atspi", "2.0") +gi.require_version("Gtk", "3.0") from gi.repository import Atspi +from gi.repository import GLib +from gi.repository import Gtk from . import debug +from . import keynames class AXObject: """Utilities for obtaining information about accessible objects.""" - KNOWN_DEAD = {} - REAL_APP_FOR_MUTTER_FRAME = {} - REAL_FRAME_FOR_MUTTER_FRAME = {} + KNOWN_DEAD: dict[int, bool] = {} + OBJECT_ATTRIBUTES: dict[int, dict[str, str]] = {} + REAL_APP_FOR_MUTTER_FRAME: dict[int, Atspi.Accessible] = {} + REAL_FRAME_FOR_MUTTER_FRAME: dict[int, Atspi.Accessible] = {} _lock = threading.Lock() @staticmethod - def _clear_stored_data(): + def _clear_stored_data() -> None: """Clears any data we have cached for objects""" while True: @@ -68,36 +66,24 @@ class AXObject: AXObject._clear_all_dictionaries() @staticmethod - def _clear_all_dictionaries(reason=""): - msg = "AXObject: Clearing cache." + def _clear_all_dictionaries(reason: str = "") -> None: + msg = "AXObject: Clearing local cache." if reason: msg += f" Reason: {reason}" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) with AXObject._lock: - tokens = ["AXObject: Clearing known dead-or-alive state for", - len(AXObject.KNOWN_DEAD), "objects"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) AXObject.KNOWN_DEAD.clear() - - tokens = ["AXObject: Clearing", len(AXObject.REAL_APP_FOR_MUTTER_FRAME), - "real apps for mutter frames"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - AXObject.REAL_APP_FOR_MUTTER_FRAME.clear() - - tokens = ["AXObject: Clearing", len(AXObject.REAL_FRAME_FOR_MUTTER_FRAME), - "real frames for mutter frames"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - AXObject.REAL_FRAME_FOR_MUTTER_FRAME.clear() + AXObject.OBJECT_ATTRIBUTES.clear() @staticmethod - def clear_cache_now(reason=""): + def clear_cache_now(reason: str = "") -> None: """Clears all cached information immediately.""" AXObject._clear_all_dictionaries(reason) @staticmethod - def start_cache_clearing_thread(): + def start_cache_clearing_thread() -> None: """Starts thread to periodically clear cached details.""" thread = threading.Thread(target=AXObject._clear_stored_data) @@ -105,19 +91,90 @@ class AXObject: thread.start() @staticmethod - def is_valid(obj): + def get_toolkit_name(obj: Atspi.Accessible) -> str: + """Returns the toolkit name of obj as a lowercase string""" + + try: + app = Atspi.Accessible.get_application(obj) + name = Atspi.Accessible.get_toolkit_name(app) or "" + except GLib.GError as error: + tokens = ["AXObject: Exception calling _get_toolkit_name_on", app, f": {error}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return "" + + return name.lower() + + @staticmethod + def get_application(obj: Atspi.Accessible) -> Atspi.Accessible | None: + """Returns the accessible application associated with obj.""" + + if obj is None: + return None + + try: + app = Atspi.Accessible.get_application(obj) + except GLib.GError as error: + msg = f"AXObject: Exception in get_application: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return None + return app + + @staticmethod + def is_bogus(obj: Atspi.Accessible) -> bool: + """Hack to ignore certain objects. All entries must have a bug.""" + + # TODO - JD: Periodically check for fixes and remove hacks which are no + # longer needed. + + # https://bugzilla.mozilla.org/show_bug.cgi?id=1879750 + if AXObject.get_role(obj) == Atspi.Role.SECTION \ + and AXObject.get_role(AXObject.get_parent(obj)) == Atspi.Role.FRAME \ + and AXObject.get_toolkit_name(obj) == "gecko": + tokens = ["AXObject:", obj, "is bogus. See mozilla bug 1879750."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True, True) + return True + + return False + + @staticmethod + def has_broken_ancestry(obj: Atspi.Accessible) -> bool: + """Returns True if obj's ancestry is broken.""" + + if obj is None: + return False + + # https://bugreports.qt.io/browse/QTBUG-130116 + toolkit_name = AXObject.get_toolkit_name(obj) + if not toolkit_name.startswith("qt"): + return False + + reached_app = False + parent = AXObject.get_parent(obj) + while parent and not reached_app: + reached_app = AXObject.get_role(parent) == Atspi.Role.APPLICATION + parent = AXObject.get_parent(parent) + + if not reached_app: + tokens = ["AXObject:", obj, "has broken ancestry. See qt bug 130116."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return True + + return False + + @staticmethod + def is_valid(obj: Atspi.Accessible) -> bool: """Returns False if we know for certain this object is invalid""" return not (obj is None or AXObject.object_is_known_dead(obj)) @staticmethod - def object_is_known_dead(obj): + def object_is_known_dead(obj: Atspi.Accessible) -> bool: """Returns True if we know for certain this object no longer exists""" - return obj and AXObject.KNOWN_DEAD.get(hash(obj)) is True + return bool(obj and AXObject.KNOWN_DEAD.get(hash(obj))) is True @staticmethod - def _set_known_dead_status(obj, is_dead): + def _set_known_dead_status(obj: Atspi.Accessible, is_dead: bool) -> None: """Updates the known-dead status of obj""" if obj is None: @@ -130,33 +187,33 @@ class AXObject: AXObject.KNOWN_DEAD[hash(obj)] = is_dead if is_dead: msg = "AXObject: Adding to known dead objects" - debug.printMessage(debug.LEVEL_INFO, msg, True, True) + debug.print_message(debug.LEVEL_INFO, msg, True, True) return if current_status: tokens = ["AXObject: Removing", obj, "from known-dead objects"] - debug.printTokens(debug.LEVEL_INFO, msg, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) @staticmethod - def handle_error(obj, error, msg): + def handle_error(obj: Atspi.Accessible, error: Exception, msg: str) -> None: """Parses the exception and potentially updates our status for obj""" - error = str(error) - if re.search(r"accessible/\d+ does not exist", error): - msg = msg.replace(error, "object no longer exists") - debug.printMessage(debug.LEVEL_INFO, msg, True) - elif re.search(r"The application no longer exists", error): - msg = msg.replace(error, "app no longer exists") - debug.printMessage(debug.LEVEL_INFO, msg, True) + error_string = str(error) + if re.search(r"accessible/\d+ does not exist", error_string): + msg = msg.replace(error_string, "object no longer exists") + debug.print_message(debug.LEVEL_INFO, msg, True) + elif re.search(r"The application no longer exists", error_string): + msg = msg.replace(error_string, "app no longer exists") + debug.print_message(debug.LEVEL_INFO, msg, True) else: - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return if AXObject.KNOWN_DEAD.get(hash(obj)) is False: AXObject._set_known_dead_status(obj, True) @staticmethod - def supports_action(obj): + def supports_action(obj: Atspi.Accessible) -> bool: """Returns True if the action interface is supported on obj""" if not AXObject.is_valid(obj): @@ -164,7 +221,7 @@ class AXObject: try: iface = Atspi.Accessible.get_action_iface(obj) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception calling get_action_iface on {obj}: {error}" AXObject.handle_error(obj, error, msg) return False @@ -172,29 +229,58 @@ class AXObject: return iface is not None @staticmethod - def supports_collection(obj): + def _has_document_spreadsheet(obj: Atspi.Accessible) -> bool: + # To avoid circular import. pylint: disable=import-outside-toplevel + from .ax_collection import AXCollection + rule = AXCollection.create_match_rule(roles=[Atspi.Role.DOCUMENT_SPREADSHEET]) + if rule is None: + return False + + frame = AXObject.find_ancestor_inclusive( + obj, lambda x: AXObject.get_role(x) == Atspi.Role.FRAME) + if frame is None: + return False + return bool(Atspi.Collection.get_matches( + frame, rule, Atspi.CollectionSortOrder.CANONICAL, 1, True)) + + @staticmethod + def supports_collection(obj: Atspi.Accessible) -> bool: """Returns True if the collection interface is supported on obj""" if not AXObject.is_valid(obj): return False - app_name = AXObject.get_name(AXObject.get_application(obj)) - if app_name in ["soffice"]: - tokens = ["AXObject: Treating", app_name, "as not supporting collection."] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + try: + app = Atspi.Accessible.get_application(obj) + except GLib.GError as error: + msg = f"AXObject: Exception in supports_collection: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) return False try: iface = Atspi.Accessible.get_collection_iface(obj) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception calling get_collection_iface on {obj}: {error}" AXObject.handle_error(obj, error, msg) return False - return iface is not None + app_name = AXObject.get_name(app) + if app_name != "soffice": + return iface is not None + + if AXObject.find_ancestor_inclusive( + obj, lambda x: AXObject.get_role(x) == Atspi.Role.DOCUMENT_TEXT): + return True + + if AXObject._has_document_spreadsheet(obj): + msg = "AXObject: Treating soffice as not supporting collection due to spreadsheet." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + return True @staticmethod - def supports_component(obj): + def supports_component(obj: Atspi.Accessible) -> bool: """Returns True if the component interface is supported on obj""" if not AXObject.is_valid(obj): @@ -202,7 +288,7 @@ class AXObject: try: iface = Atspi.Accessible.get_component_iface(obj) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception calling get_component_iface on {obj}: {error}" AXObject.handle_error(obj, error, msg) return False @@ -211,7 +297,7 @@ class AXObject: @staticmethod - def supports_document(obj): + def supports_document(obj: Atspi.Accessible) -> bool: """Returns True if the document interface is supported on obj""" if not AXObject.is_valid(obj): @@ -219,7 +305,7 @@ class AXObject: try: iface = Atspi.Accessible.get_document_iface(obj) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception calling get_document_iface on {obj}: {error}" AXObject.handle_error(obj, error, msg) return False @@ -227,7 +313,59 @@ class AXObject: return iface is not None @staticmethod - def supports_editable_text(obj): + def find_real_app_and_window_for(obj: Atspi.Accessible, app: Atspi.Accessible | None = None): + """Work around for window events coming from mutter-x11-frames.""" + + if app is None: + try: + app = Atspi.Accessible.get_application(obj) + except Exception as error: + msg = f"AXObject: Exception getting application of {obj}: {error}" + AXObject.handle_error(obj, error, msg) + return None, None + + if AXObject.get_name(app) != "mutter-x11-frames": + return app, obj + + real_app = AXObject.REAL_APP_FOR_MUTTER_FRAME.get(hash(obj)) + real_frame = AXObject.REAL_FRAME_FOR_MUTTER_FRAME.get(hash(obj)) + if real_app is not None and real_frame is not None: + return real_app, real_frame + + tokens = ["AXObject:", app, "is not valid app for", obj] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + try: + desktop = Atspi.get_desktop(0) + except Exception as error: + tokens = ["AXObject: Exception getting desktop from Atspi:", error] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return None, None + + name = AXObject.get_name(obj) + real_app = None + real_frame = None + for desktop_app in AXObject.iter_children(desktop): + if AXObject.get_name(desktop_app) == "mutter-x11-frames": + continue + for frame in AXObject.iter_children(desktop_app): + if name == AXObject.get_name(frame): + real_app = desktop_app + real_frame = frame + + tokens = ["AXObject:", real_app, "is real app for", obj] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if real_frame != obj: + msg = "AXObject: Updated frame to frame from real app" + debug.print_message(debug.LEVEL_INFO, msg, True) + + AXObject.REAL_APP_FOR_MUTTER_FRAME[hash(obj)] = real_app + AXObject.REAL_FRAME_FOR_MUTTER_FRAME[hash(obj)] = real_frame + return real_app, real_frame + + @staticmethod + def supports_editable_text(obj: Atspi.Accessible) -> bool: """Returns True if the editable-text interface is supported on obj""" if not AXObject.is_valid(obj): @@ -235,7 +373,7 @@ class AXObject: try: iface = Atspi.Accessible.get_editable_text_iface(obj) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception calling get_editable_text_iface on {obj}: {error}" AXObject.handle_error(obj, error, msg) return False @@ -243,7 +381,7 @@ class AXObject: return iface is not None @staticmethod - def supports_hyperlink(obj): + def supports_hyperlink(obj: Atspi.Accessible) -> bool: """Returns True if the hyperlink interface is supported on obj""" if not AXObject.is_valid(obj): @@ -251,7 +389,7 @@ class AXObject: try: iface = Atspi.Accessible.get_hyperlink(obj) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception calling get_hyperlink on {obj}: {error}" AXObject.handle_error(obj, error, msg) return False @@ -259,7 +397,7 @@ class AXObject: return iface is not None @staticmethod - def supports_hypertext(obj): + def supports_hypertext(obj: Atspi.Accessible) -> bool: """Returns True if the hypertext interface is supported on obj""" if not AXObject.is_valid(obj): @@ -267,7 +405,7 @@ class AXObject: try: iface = Atspi.Accessible.get_hypertext_iface(obj) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception calling get_hypertext_iface on {obj}: {error}" AXObject.handle_error(obj, error, msg) return False @@ -275,7 +413,7 @@ class AXObject: return iface is not None @staticmethod - def supports_image(obj): + def supports_image(obj: Atspi.Accessible) -> bool: """Returns True if the image interface is supported on obj""" if not AXObject.is_valid(obj): @@ -283,7 +421,7 @@ class AXObject: try: iface = Atspi.Accessible.get_image_iface(obj) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception calling get_image_iface on {obj}: {error}" AXObject.handle_error(obj, error, msg) return False @@ -291,7 +429,7 @@ class AXObject: return iface is not None @staticmethod - def supports_selection(obj): + def supports_selection(obj: Atspi.Accessible) -> bool: """Returns True if the selection interface is supported on obj""" if not AXObject.is_valid(obj): @@ -299,7 +437,7 @@ class AXObject: try: iface = Atspi.Accessible.get_selection_iface(obj) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception calling get_selection_iface on {obj}: {error}" AXObject.handle_error(obj, error, msg) return False @@ -307,7 +445,7 @@ class AXObject: return iface is not None @staticmethod - def supports_table(obj): + def supports_table(obj: Atspi.Accessible) -> bool: """Returns True if the table interface is supported on obj""" if not AXObject.is_valid(obj): @@ -315,7 +453,7 @@ class AXObject: try: iface = Atspi.Accessible.get_table_iface(obj) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception calling get_table_iface on {obj}: {error}" AXObject.handle_error(obj, error, msg) return False @@ -323,7 +461,7 @@ class AXObject: return iface is not None @staticmethod - def supports_table_cell(obj): + def supports_table_cell(obj: Atspi.Accessible) -> bool: """Returns True if the table cell interface is supported on obj""" if not AXObject.is_valid(obj): @@ -331,7 +469,7 @@ class AXObject: try: iface = Atspi.Accessible.get_table_cell(obj) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception calling get_table_cell on {obj}: {error}" AXObject.handle_error(obj, error, msg) return False @@ -339,7 +477,7 @@ class AXObject: return iface is not None @staticmethod - def supports_text(obj): + def supports_text(obj: Atspi.Accessible) -> bool: """Returns True if the text interface is supported on obj""" if not AXObject.is_valid(obj): @@ -347,14 +485,14 @@ class AXObject: try: iface = Atspi.Accessible.get_text_iface(obj) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception calling get_text_iface on {obj}: {error}" AXObject.handle_error(obj, error, msg) return False return iface is not None @staticmethod - def supports_value(obj): + def supports_value(obj: Atspi.Accessible) -> bool: """Returns True if the value interface is supported on obj""" if not AXObject.is_valid(obj): @@ -362,7 +500,7 @@ class AXObject: try: iface = Atspi.Accessible.get_value_iface(obj) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception calling get_value_iface on {obj}: {error}" AXObject.handle_error(obj, error, msg) return False @@ -370,33 +508,7 @@ class AXObject: return iface is not None @staticmethod - def supported_interfaces_as_string(obj): - """Returns the supported interfaces of obj as a string""" - - if not AXObject.is_valid(obj): - return "" - - iface_checks = [ - (AXObject.supports_action, "Action"), - (AXObject.supports_collection, "Collection"), - (AXObject.supports_component, "Component"), - (AXObject.supports_document, "Document"), - (AXObject.supports_editable_text, "EditableText"), - (AXObject.supports_hyperlink, "Hyperlink"), - (AXObject.supports_hypertext, "Hypertext"), - (AXObject.supports_image, "Image"), - (AXObject.supports_selection, "Selection"), - (AXObject.supports_table, "Table"), - (AXObject.supports_table_cell, "TableCell"), - (AXObject.supports_text, "Text"), - (AXObject.supports_value, "Value"), - ] - - ifaces = [iface for check, iface in iface_checks if check(obj)] - return ", ".join(ifaces) - - @staticmethod - def get_path(obj): + def get_path(obj: Atspi.Accessible) -> list[int]: """Returns the path from application to obj as list of child indices""" if not AXObject.is_valid(obj): @@ -407,7 +519,7 @@ class AXObject: while acc: try: path.append(Atspi.Accessible.get_index_in_parent(acc)) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception getting index in parent for {acc}: {error}" AXObject.handle_error(acc, error, msg) return [] @@ -417,7 +529,7 @@ class AXObject: return path @staticmethod - def get_index_in_parent(obj): + def get_index_in_parent(obj: Atspi.Accessible) -> int: """Returns the child index of obj within its parent""" if not AXObject.is_valid(obj): @@ -425,7 +537,7 @@ class AXObject: try: index = Atspi.Accessible.get_index_in_parent(obj) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception in get_index_in_parent: {error}" AXObject.handle_error(obj, error, msg) return -1 @@ -433,7 +545,7 @@ class AXObject: return index @staticmethod - def get_parent(obj): + def get_parent(obj: Atspi.Accessible) -> Atspi.Accessible | None: """Returns the accessible parent of obj. See also get_parent_checked.""" if not AXObject.is_valid(obj): @@ -441,20 +553,25 @@ class AXObject: try: parent = Atspi.Accessible.get_parent(obj) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception in get_parent: {error}" AXObject.handle_error(obj, error, msg) return None if parent == obj: tokens = ["AXObject:", obj, "claims to be its own parent"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return None + if parent is None \ + and AXObject.get_role(obj) not in [Atspi.Role.INVALID, Atspi.Role.DESKTOP_FRAME]: + tokens = ["AXObject:", obj, "claims to have no parent"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return parent @staticmethod - def get_parent_checked(obj): + def get_parent_checked(obj: Atspi.Accessible) -> Atspi.Accessible | None: """Returns the parent of obj, doing checks for tree validity""" if not AXObject.is_valid(obj): @@ -479,7 +596,7 @@ class AXObject: if index < 0 or index >= n_children: tokens = ["AXObject:", obj, "has index", index, "; parent", parent, "has", n_children, "children"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return parent # This performs our check and includes any errors. We don't need the return value here. @@ -487,7 +604,62 @@ class AXObject: return parent @staticmethod - def find_ancestor(obj, pred): + def _get_ancestors(obj: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns a list of the ancestors of obj, starting with its parent.""" + + ancestors = [] + parent = AXObject.get_parent_checked(obj) + while parent: + ancestors.append(parent) + parent = AXObject.get_parent_checked(parent) + ancestors.reverse() + return ancestors + + @staticmethod + def get_common_ancestor( + obj1: Atspi.Accessible, + obj2: Atspi.Accessible + ) -> Atspi.Accessible | None: + """Returns the common ancestor of obj1 and obj2.""" + + tokens = ["AXObject: Looking for common ancestor of", obj1, "and", obj2] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if not (obj1 and obj2): + return None + + if obj1 == obj2: + return obj1 + + obj1_ancestors = AXObject._get_ancestors(obj1) + [obj1] + obj2_ancestors = AXObject._get_ancestors(obj2) + [obj2] + result = None + for a1, a2 in zip(obj1_ancestors, obj2_ancestors): + if a1 == a2: + result = a1 + else: + break + + tokens = ["AXObject: Common ancestor of", obj1, "and", obj2, "is", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def find_ancestor_inclusive( + obj: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] + ) -> Atspi.Accessible | None: + """Returns obj, or the ancestor of obj, for which the function pred is true""" + + if pred(obj): + return obj + + return AXObject.find_ancestor(obj, pred) + + @staticmethod + def find_ancestor( + obj: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] + ) -> Atspi.Accessible | None: """Returns the ancestor of obj if the function pred is true""" if not AXObject.is_valid(obj): @@ -500,7 +672,7 @@ class AXObject: if parent in objects: tokens = ["AXObject: Circular tree suspected in find_ancestor. ", parent, "already in: ", objects] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return None if pred(parent): @@ -512,8 +684,12 @@ class AXObject: return None @staticmethod - def is_ancestor(obj, ancestor): - """Returns true if ancestor is an ancestor of obj""" + def is_ancestor( + obj: Atspi.Accessible, + ancestor: Atspi.Accessible, + inclusive: bool = False + ) -> bool: + """Returns true if ancestor is an ancestor of obj or, if inclusive, obj is ancestor.""" if not AXObject.is_valid(obj): return False @@ -521,10 +697,13 @@ class AXObject: if not AXObject.is_valid(ancestor): return False + if obj == ancestor and inclusive: + return True + return AXObject.find_ancestor(obj, lambda x: x == ancestor) is not None @staticmethod - def get_child(obj, index): + def get_child(obj: Atspi.Accessible, index: int) -> Atspi.Accessible | None: """Returns the nth child of obj. See also get_child_checked.""" if not AXObject.is_valid(obj): @@ -542,20 +721,22 @@ class AXObject: try: child = Atspi.Accessible.get_child_at_index(obj, index) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception in get_child: {error}" AXObject.handle_error(obj, error, msg) return None if child == obj: tokens = ["AXObject:", obj, "claims to be its own child"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return None return child @staticmethod - def get_child_checked(obj, index): + def get_child_checked( + obj: Atspi.Accessible, index: int + ) -> Atspi.Accessible | None: """Returns the nth child of obj, doing checks for tree validity""" if not AXObject.is_valid(obj): @@ -568,12 +749,15 @@ class AXObject: parent = AXObject.get_parent(child) if obj != parent: tokens = ["AXObject:", obj, "claims", child, "as child; child's parent is", parent] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return child @staticmethod - def get_active_descendant_checked(container, reported_child): + def get_active_descendant_checked( + container: Atspi.Accessible, + reported_child: Atspi.Accessible + ) -> Atspi.Accessible | None: """Checks the reported active descendant and return the real/valid one.""" if not AXObject.has_state(container, Atspi.StateType.MANAGES_DESCENDANTS): @@ -582,20 +766,25 @@ class AXObject: index = AXObject.get_index_in_parent(reported_child) try: real_child = Atspi.Accessible.get_child_at_index(container, index) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception in get_active_descendant_checked: {error}" AXObject.handle_error(container, error, msg) return reported_child if real_child != reported_child: - tokens = ["AXObject: ", container, f"'s child at {index} is ", real_child, - "; not reported child", reported_child] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + tokens = [ + "AXObject: ", container, f"'s child at {index} is ", real_child, + "; not reported child", reported_child + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return real_child @staticmethod - def _find_descendant(obj, pred): + def _find_descendant( + obj: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] + ) -> Atspi.Accessible | None: """Returns the descendant of obj if the function pred is true""" if not AXObject.is_valid(obj): @@ -603,27 +792,31 @@ class AXObject: for i in range(AXObject.get_child_count(obj)): child = AXObject.get_child_checked(obj, i) - if child and pred(child): + if child is None: + continue + if pred(child): return child - child = AXObject._find_descendant(child, pred) - if child and pred(child): + if child: return child return None @staticmethod - def find_descendant(obj, pred): + def find_descendant( + obj: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] + ) -> Atspi.Accessible | None: """Returns the descendant of obj if the function pred is true""" start = time.time() result = AXObject._find_descendant(obj, pred) tokens = ["AXObject: find_descendant: found", result, f"in {time.time() - start:.4f}s"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return result @staticmethod - def find_deepest_descendant(obj): + def find_deepest_descendant(obj: Atspi.Accessible) -> Atspi.Accessible | None: """Returns the deepest descendant of obj""" if not AXObject.is_valid(obj): @@ -636,7 +829,12 @@ class AXObject: return AXObject.find_deepest_descendant(last_child) @staticmethod - def _find_all_descendants(obj, include_if, exclude_if, matches): + def _find_all_descendants( + obj: Atspi.Accessible, + include_if: Callable[[Atspi.Accessible], bool] | None, + exclude_if: Callable[[Atspi.Accessible], bool] | None, + matches: list[Atspi.Accessible] + ) -> None: """Returns all descendants which match the specified inclusion and exclusion""" if not AXObject.is_valid(obj): @@ -652,21 +850,25 @@ class AXObject: AXObject._find_all_descendants(child, include_if, exclude_if, matches) @staticmethod - def find_all_descendants(root, include_if=None, exclude_if=None): + def find_all_descendants( + root: Atspi.Accessible, + include_if: Callable[[Atspi.Accessible], bool] | None = None, + exclude_if: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants which match the specified inclusion and exclusion""" start = time.time() - matches = [] + matches: list[Atspi.Accessible] = [] AXObject._find_all_descendants(root, include_if, exclude_if, matches) msg = ( f"AXObject: find_all_descendants: {len(matches)} " f"matches found in {time.time() - start:.4f}s" ) - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return matches @staticmethod - def get_role(obj): + def get_role(obj: Atspi.Accessible) -> Atspi.Role: """Returns the accessible role of obj""" if not AXObject.is_valid(obj): @@ -674,7 +876,7 @@ class AXObject: try: role = Atspi.Accessible.get_role(obj) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception in get_role: {error}" AXObject.handle_error(obj, error, msg) return Atspi.Role.INVALID @@ -683,15 +885,18 @@ class AXObject: return role @staticmethod - def get_role_name(obj): + def get_role_name(obj: Atspi.Accessible, localized: bool = False) -> str: """Returns the accessible role name of obj""" if not AXObject.is_valid(obj): return "" try: - role_name = Atspi.Accessible.get_role_name(obj) - except Exception as error: + if not localized: + role_name = Atspi.Accessible.get_role_name(obj) + else: + role_name = Atspi.Accessible.get_localized_role_name(obj) + except GLib.GError as error: msg = f"AXObject: Exception in get_role_name: {error}" AXObject.handle_error(obj, error, msg) return "" @@ -699,7 +904,37 @@ class AXObject: return role_name @staticmethod - def get_name(obj): + def get_role_description(obj: Atspi.Accessible, is_braille: bool = False) -> str: + """Returns the accessible role description of obj""" + + if not AXObject.is_valid(obj): + return "" + + attrs = AXObject.get_attributes_dict(obj) + rv = attrs.get("roledescription", "") + if is_braille: + rv = attrs.get("brailleroledescription", rv) + return rv + + @staticmethod + def get_accessible_id(obj: Atspi.Accessible) -> str: + """Returns the accessible id of obj""" + + if not AXObject.is_valid(obj): + return "" + + try: + result = Atspi.Accessible.get_accessible_id(obj) + except GLib.GError as error: + msg = f"AXObject: Exception in get_accessible_id: {error}" + AXObject.handle_error(obj, error, msg) + return "" + + AXObject._set_known_dead_status(obj, False) + return result + + @staticmethod + def get_name(obj: Atspi.Accessible) -> str: """Returns the accessible name of obj""" if not AXObject.is_valid(obj): @@ -707,7 +942,7 @@ class AXObject: try: name = Atspi.Accessible.get_name(obj) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception in get_name: {error}" AXObject.handle_error(obj, error, msg) return "" @@ -716,7 +951,7 @@ class AXObject: return name @staticmethod - def has_same_non_empty_name(obj1, obj2): + def has_same_non_empty_name(obj1: Atspi.Accessible, obj2: Atspi.Accessible) -> bool: """Returns true if obj1 and obj2 share the same non-empty name""" name1 = AXObject.get_name(obj1) @@ -726,7 +961,7 @@ class AXObject: return name1 == AXObject.get_name(obj2) @staticmethod - def get_description(obj): + def get_description(obj: Atspi.Accessible) -> str: """Returns the accessible description of obj""" if not AXObject.is_valid(obj): @@ -734,7 +969,7 @@ class AXObject: try: description = Atspi.Accessible.get_description(obj) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception in get_description: {error}" AXObject.handle_error(obj, error, msg) return "" @@ -742,7 +977,56 @@ class AXObject: return description @staticmethod - def get_child_count(obj): + def get_image_description(obj: Atspi.Accessible) -> str: + """Returns the accessible image description of obj""" + + if not AXObject.supports_image(obj): + return "" + + try: + description = Atspi.Image.get_image_description(obj) + except GLib.GError as error: + msg = f"AXObject: Exception in get_image_description: {error}" + AXObject.handle_error(obj, error, msg) + return "" + + return description + + @staticmethod + def get_image_size(obj: Atspi.Accessible) -> tuple[int, int]: + """Returns a (width, height) tuple of the image in obj""" + + if not AXObject.supports_image(obj): + return 0, 0 + + try: + result = Atspi.Image.get_image_size(obj) + except GLib.GError as error: + msg = f"AXObject: Exception in get_image_size: {error}" + AXObject.handle_error(obj, error, msg) + return 0, 0 + + # The return value is an AtspiPoint, hence x and y. + return result.x, result.y + + @staticmethod + def get_help_text(obj: Atspi.Accessible) -> str: + """Returns the accessible help text of obj""" + + if not AXObject.is_valid(obj): + return "" + + try: + # Added in Atspi 2.52. + text = Atspi.Accessible.get_help_text(obj) or "" + except GLib.GError: + # This is for prototyping in the meantime. + text = AXObject.get_attribute(obj, "helptext") or "" + + return text + + @staticmethod + def get_child_count(obj: Atspi.Accessible) -> int: """Returns the child count of obj""" if not AXObject.is_valid(obj): @@ -750,7 +1034,7 @@ class AXObject: try: count = Atspi.Accessible.get_child_count(obj) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception in get_child_count: {error}" AXObject.handle_error(obj, error, msg) return 0 @@ -758,7 +1042,10 @@ class AXObject: return count @staticmethod - def iter_children(obj, pred=None): + def iter_children( + obj: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> Generator[Atspi.Accessible, None, None]: """Generator to iterate through obj's children. If the function pred is specified, children for which pred is False will be skipped.""" @@ -766,13 +1053,22 @@ class AXObject: return child_count = AXObject.get_child_count(obj) + if child_count > 500: + tokens = ["AXObject:", obj, "has more than 500 children"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True, True) + for index in range(child_count): child = AXObject.get_child(obj, index) + if child is None and not AXObject.is_valid(obj): + tokens = ["AXObject:", obj, "is no longer valid"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return + if child is not None and (pred is None or pred(child)): yield child @staticmethod - def get_previous_sibling(obj): + def get_previous_sibling(obj: Atspi.Accessible) -> Atspi.Accessible | None: """Returns the previous sibling of obj, based on child indices""" if not AXObject.is_valid(obj): @@ -789,13 +1085,13 @@ class AXObject: sibling = AXObject.get_child(parent, index - 1) if sibling == obj: tokens = ["AXObject:", obj, "claims to be its own sibling"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return None return sibling @staticmethod - def get_next_sibling(obj): + def get_next_sibling(obj: Atspi.Accessible) -> Atspi.Accessible | None: """Returns the next sibling of obj, based on child indices""" if not AXObject.is_valid(obj): @@ -812,63 +1108,29 @@ class AXObject: sibling = AXObject.get_child(parent, index + 1) if sibling == obj: tokens = ["AXObject:", obj, "claims to be its own sibling"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return None return sibling @staticmethod - def get_next_object(obj): - """Returns the next object (depth first) in the accessibility tree""" + def get_locale(obj: Atspi.Accessible) -> str: + """Returns the locale of obj""" if not AXObject.is_valid(obj): - return None + return "" - index = AXObject.get_index_in_parent(obj) + 1 - parent = AXObject.get_parent(obj) - while parent and not 0 < index < AXObject.get_child_count(parent): - obj = parent - index = AXObject.get_index_in_parent(obj) + 1 - parent = AXObject.get_parent(obj) + try: + locale = Atspi.Accessible.get_object_locale(obj) + except GLib.GError as error: + msg = f"AXObject: Exception in get_locale: {error}" + AXObject.handle_error(obj, error, msg) + return "" - if parent is None: - return None - - next_object = AXObject.get_child(parent, index) - if next_object == obj: - tokens = ["AXObject:", obj, "claims to be its own next object"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return None - - return next_object + return locale or "" @staticmethod - def get_previous_object(obj): - """Returns the previous object (depth first) in the accessibility tree""" - - if not AXObject.is_valid(obj): - return None - - index = AXObject.get_index_in_parent(obj) - 1 - parent = AXObject.get_parent(obj) - while parent and not 0 <= index < AXObject.get_child_count(parent) - 1: - obj = parent - index = AXObject.get_index_in_parent(obj) - 1 - parent = AXObject.get_parent(obj) - - if parent is None: - return None - - previous_object = AXObject.get_child(parent, index) - if previous_object == obj: - tokens = ["AXObject:", obj, "claims to be its own previous object"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return None - - return previous_object - - @staticmethod - def get_state_set(obj): + def get_state_set(obj: Atspi.Accessible) -> Atspi.StateSet: """Returns the state set associated with obj""" if not AXObject.is_valid(obj): @@ -876,16 +1138,21 @@ class AXObject: try: state_set = Atspi.Accessible.get_state_set(obj) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception in get_state_set: {error}" AXObject.handle_error(obj, error, msg) return Atspi.StateSet() + if state_set is None: + tokens = ["AXObject: get_state_set failed for", obj] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return Atspi.StateSet() + AXObject._set_known_dead_status(obj, False) return state_set @staticmethod - def has_state(obj, state): + def has_state(obj: Atspi.Accessible, state: Atspi.StateType) -> bool: """Returns true if obj has the specified state""" if not AXObject.is_valid(obj): @@ -894,264 +1161,37 @@ class AXObject: return AXObject.get_state_set(obj).contains(state) @staticmethod - def state_set_as_string(obj): - """Returns the state set associated with obj as a string""" - - if not AXObject.is_valid(obj): - return "" - - def as_string(state): - return state.value_name[12:].replace("_", "-").lower() - - return ", ".join(map(as_string, AXObject.get_state_set(obj).get_states())) - - @staticmethod - def get_relations(obj): - """Returns the list of Atspi.Relation objects associated with obj""" - - if not AXObject.is_valid(obj): - return [] - - try: - relations = Atspi.Accessible.get_relation_set(obj) - except Exception as error: - msg = f"AXObject: Exception in get_relations: {error}" - AXObject.handle_error(obj, error, msg) - return [] - - return relations - - @staticmethod - def get_relation(obj, relation_type): - """Returns the specified Atspi.Relation for obj""" - - if not AXObject.is_valid(obj): - return None - - for relation in AXObject.get_relations(obj): - if relation and relation.get_relation_type() == relation_type: - return relation - - return None - - @staticmethod - def has_relation(obj, relation_type): - """Returns true if obj has the specified relation type""" - - if not AXObject.is_valid(obj): - return False - - return AXObject.get_relation(obj, relation_type) is not None - - @staticmethod - def get_relation_targets(obj, relation_type, pred=None): - """Returns the list of targets with the specified relation type to obj. - If pred is provided, a target will only be included if pred is true.""" - - if not AXObject.is_valid(obj): - return [] - - relation = AXObject.get_relation(obj, relation_type) - if relation is None: - return [] - - targets = set() - for i in range(relation.get_n_targets()): - target = relation.get_target(i) - if pred is None or pred(target): - targets.add(target) - - # We want to avoid self-referential relationships. - type_includes_object = [Atspi.RelationType.MEMBER_OF] - if relation_type not in type_includes_object and obj in targets: - tokens = ["AXObject: ", obj, "is in its own", relation_type, "target list"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - targets.remove(obj) - - return list(targets) - - @staticmethod - def relations_as_string(obj): - """Returns the relations associated with obj as a string""" - - if not AXObject.is_valid(obj): - return "" - - def as_string(relations): - return relations.value_name[15:].replace("_", "-").lower() - - results = [] - for rel in AXObject.get_relations(obj): - type_string = as_string(rel.get_relation_type()) - targets = AXObject.get_relation_targets(obj, rel.get_relation_type()) - target_string = ",".join(map(str, targets)) - results.append(f"{type_string}: {target_string}") - - return "; ".join(results) - - - @staticmethod - def find_real_app_and_window_for(obj, app=None): - """Work around for window events coming from mutter-x11-frames.""" - - if app is None: - try: - app = Atspi.Accessible.get_application(obj) - except Exception as error: - msg = f"AXObject: Exception getting application of {obj}: {error}" - AXObject.handle_error(obj, error, msg) - return None, None - - if AXObject.get_name(app) != "mutter-x11-frames": - return app, obj - - real_app = AXObject.REAL_APP_FOR_MUTTER_FRAME.get(hash(obj)) - real_frame = AXObject.REAL_FRAME_FOR_MUTTER_FRAME.get(hash(obj)) - if real_app is not None and real_frame is not None: - return real_app, real_frame - - tokens = ["AXObject:", app, "is not valid app for", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - - try: - desktop = Atspi.get_desktop(0) - except Exception as error: - tokens = ["AXObject: Exception getting desktop from Atspi:", error] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return None, None - - name = AXObject.get_name(obj) - for desktop_app in AXObject.iter_children(desktop): - if AXObject.get_name(desktop_app) == "mutter-x11-frames": - continue - for frame in AXObject.iter_children(desktop_app): - if name == AXObject.get_name(frame): - real_app = desktop_app - real_frame = frame - - tokens = ["AXObject:", real_app, "is real app for", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - - if real_frame != obj: - msg = "AXObject: Updated frame to frame from real app" - debug.printMessage(debug.LEVEL_INFO, msg, True) - - AXObject.REAL_APP_FOR_MUTTER_FRAME[hash(obj)] = real_app - AXObject.REAL_FRAME_FOR_MUTTER_FRAME[hash(obj)] = real_frame - return real_app, real_frame - - @staticmethod - def get_application(obj): - """Returns the accessible application associated with obj""" - - if not AXObject.is_valid(obj): - return None - - app = AXObject.REAL_APP_FOR_MUTTER_FRAME.get(hash(obj)) - if app is not None: - return app - - try: - app = Atspi.Accessible.get_application(obj) - except Exception as error: - msg = f"AXObject: Exception in get_application: {error}" - AXObject.handle_error(obj, error, msg) - return None - - if AXObject.get_name(app) != "mutter-x11-frames": - return app - - real_app = AXObject.find_real_app_and_window_for(obj, app)[0] - if real_app is not None: - app = real_app - - return app - - @staticmethod - def get_application_toolkit_name(obj): - """Returns the toolkit name reported for obj's application.""" - - if not AXObject.is_valid(obj): - return "" - - app = AXObject.get_application(obj) - if app is None: - return "" - - try: - name = Atspi.Accessible.get_toolkit_name(app) - except Exception as error: - tokens = ["AXObject: Exception in get_application_toolkit_name:", error] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return "" - - return name - - @staticmethod - def get_application_toolkit_version(obj): - """Returns the toolkit version reported for obj's application.""" - - if not AXObject.is_valid(obj): - return "" - - app = AXObject.get_application(obj) - if app is None: - return "" - - try: - version = Atspi.Accessible.get_toolkit_version(app) - except Exception as error: - tokens = ["AXObject: Exception in get_application_toolkit_version:", error] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return "" - - return version - - @staticmethod - def application_as_string(obj): - """Returns the application details of obj as a string.""" - - if not AXObject.is_valid(obj): - return "" - - app = AXObject.get_application(obj) - if app is None: - return "" - - string = ( - f"{AXObject.get_name(app)} " - f"({AXObject.get_application_toolkit_name(obj)} " - f"{AXObject.get_application_toolkit_version(obj)})" - ) - return string - - @staticmethod - def clear_cache(obj, recursive=False): + def clear_cache( + obj: Atspi.Accessible, + recursive: bool = False, + reason: str = "" + ) -> None: """Clears the Atspi cached information associated with obj""" - if not AXObject.is_valid(obj): + if obj is None: return + tokens = ["AXObject: Clearing AT-SPI cache on", obj, f"Recursive: {recursive}."] + if reason: + tokens.append(f" Reason: {reason}") + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if not recursive: try: Atspi.Accessible.clear_cache_single(obj) - except Exception: - # This is new API, added in 2.49.1. So log success rather than - # (likely) failure for now. - pass - else: - msg = "AXObject: clear_cache_single succeeded." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return + except GLib.GError as error: + msg = f"AXObject: Exception in clear_cache_single: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return try: Atspi.Accessible.clear_cache(obj) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception in clear_cache: {error}" AXObject.handle_error(obj, error, msg) @staticmethod - def get_process_id(obj): + def get_process_id(obj: Atspi.Accessible) -> int: """Returns the process id associated with obj""" if not AXObject.is_valid(obj): @@ -1159,7 +1199,7 @@ class AXObject: try: pid = Atspi.Accessible.get_process_id(obj) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception in get_process_id: {error}" AXObject.handle_error(obj, error, msg) return -1 @@ -1167,7 +1207,7 @@ class AXObject: return pid @staticmethod - def is_dead(obj): + def is_dead(obj: Atspi.Accessible) -> bool: """Returns true of obj exists but is believed to be dead.""" if obj is None: @@ -1180,7 +1220,7 @@ class AXObject: # We use the Atspi function rather than the AXObject function because the # latter intentionally handles exceptions. Atspi.Accessible.get_name(obj) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Accessible is dead: {error}" AXObject.handle_error(obj, error, msg) return True @@ -1189,15 +1229,23 @@ class AXObject: return False @staticmethod - def get_attributes_dict(obj): + def get_attributes_dict( + obj: Atspi.Accessible, + use_cache: bool = True + ) -> dict[str, str]: """Returns the object attributes of obj as a dictionary.""" if not AXObject.is_valid(obj): return {} + if use_cache: + attributes = AXObject.OBJECT_ATTRIBUTES.get(hash(obj)) + if attributes: + return attributes + try: attributes = Atspi.Accessible.get_attributes(obj) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception in get_attributes_dict: {error}" AXObject.handle_error(obj, error, msg) return {} @@ -1205,32 +1253,25 @@ class AXObject: if attributes is None: return {} + AXObject.OBJECT_ATTRIBUTES[hash(obj)] = attributes return attributes @staticmethod - def get_attribute(obj, attribute_name): + def get_attribute( + obj: Atspi.Accessible, + attribute_name: str, + use_cache: bool = True + ) -> str: """Returns the value of the specified attribute as a string.""" if not AXObject.is_valid(obj): return "" - attributes = AXObject.get_attributes_dict(obj) + attributes = AXObject.get_attributes_dict(obj, use_cache) return attributes.get(attribute_name, "") @staticmethod - def attributes_as_string(obj): - """Returns the object attributes of obj as a string.""" - - if not AXObject.is_valid(obj): - return "" - - def as_string(attribute): - return f"{attribute[0]}:{attribute[1]}" - - return ", ".join(map(as_string, AXObject.get_attributes_dict(obj).items())) - - @staticmethod - def get_n_actions(obj): + def get_n_actions(obj: Atspi.Accessible) -> int: """Returns the number of actions supported on obj.""" if not AXObject.supports_action(obj): @@ -1238,7 +1279,7 @@ class AXObject: try: count = Atspi.Action.get_n_actions(obj) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception in get_n_actions: {error}" AXObject.handle_error(obj, error, msg) return 0 @@ -1246,7 +1287,7 @@ class AXObject: return count @staticmethod - def _normalize_action_name(action_name): + def _normalize_action_name(action_name: str) -> str: """Adjusts the name to account for differences in implementations.""" if not action_name: @@ -1257,7 +1298,7 @@ class AXObject: return name @staticmethod - def get_action_name(obj, i): + def get_action_name(obj: Atspi.Accessible, i: int) -> str: """Returns the name of obj's action at index i.""" if not 0 <= i < AXObject.get_n_actions(obj): @@ -1265,7 +1306,7 @@ class AXObject: try: name = Atspi.Action.get_action_name(obj, i) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception in get_action_name: {error}" AXObject.handle_error(obj, error, msg) return "" @@ -1273,7 +1314,7 @@ class AXObject: return AXObject._normalize_action_name(name) @staticmethod - def get_action_names(obj): + def get_action_names(obj: Atspi.Accessible) -> list[str]: """Returns the list of actions supported on obj.""" results = [] @@ -1284,7 +1325,7 @@ class AXObject: return results @staticmethod - def get_action_description(obj, i): + def get_action_description(obj: Atspi.Accessible, i: int) -> str: """Returns the description of obj's action at index i.""" if not 0 <= i < AXObject.get_n_actions(obj): @@ -1292,7 +1333,7 @@ class AXObject: try: description = Atspi.Action.get_action_description(obj, i) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception in get_action_description: {error}" AXObject.handle_error(obj, error, msg) return "" @@ -1300,7 +1341,23 @@ class AXObject: return description @staticmethod - def get_action_key_binding(obj, i): + def get_action_localized_name(obj: Atspi.Accessible, i: int) -> str: + """Returns the localized name of obj's action at index i.""" + + if not 0 <= i < AXObject.get_n_actions(obj): + return "" + + try: + name = Atspi.Action.get_localized_name(obj, i) + except GLib.GError as error: + msg = f"AXObject: Exception in get_action_localized_name: {error}" + AXObject.handle_error(obj, error, msg) + return "" + + return name + + @staticmethod + def get_action_key_binding(obj: Atspi.Accessible, i: int) -> str: """Returns the key binding string of obj's action at index i.""" if not 0 <= i < AXObject.get_n_actions(obj): @@ -1308,21 +1365,127 @@ class AXObject: try: keybinding = Atspi.Action.get_key_binding(obj, i) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception in get_action_key_binding: {error}" AXObject.handle_error(obj, error, msg) return "" + # GTK4 does this. + if keybinding == "": + return "" return keybinding @staticmethod - def has_action(obj, action_name): + def _get_label_for_key_sequence(sequence: str) -> str: + """Returns the human consumable label for the key sequence.""" + + if not sequence: + return "" + + # We get all sorts of variations in the keybinding string. Try to normalize it. + if len(sequence) > 1 and not sequence.startswith("<") and "," not in sequence: + tokens = sequence.split("+") + sequence = "".join(f"<{part}>" for part in tokens[:-1]) + tokens[-1] + + # We use Gtk for conversion to handle things like . + try: + key, mods = Gtk.accelerator_parse(sequence) + result = Gtk.accelerator_get_label(key, mods) + except GLib.GError as error: + msg = f"AXObject: Exception in _get_label_for_key_sequence: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + sequence = sequence.replace("<", "").replace(">", " ").strip() + else: + if result and not result.endswith("+"): + sequence = result + + return keynames.localize_key_sequence(sequence) + + @staticmethod + def get_accelerator(obj: Atspi.Accessible) -> str: + """Returns the accelerator/shortcut associated with obj.""" + + attrs = AXObject.get_attributes_dict(obj) + # The ARIA spec suggests a given shortcut's components should be separated by a "+". + # Multiple shortcuts are apparently allowed and separated by a space. + shortcuts = attrs.get("keyshortcuts", "").split(" ") + if shortcuts and shortcuts[0]: + result = " ".join(map(AXObject._get_label_for_key_sequence, shortcuts)).strip() + # Accelerators are typically modified and thus more than one character. + if len(result) > 1: + return result + + index = AXObject._find_first_action_with_keybinding(obj) + if index == -1: + return "" + + # This should be a string separated by semicolons and in the form: + # ;; (optional) + # In practice we get all sorts of variations. + + # If there's a third item, it's probably the accelerator. + strings = AXObject.get_action_key_binding(obj, index).split(";") + if len(strings) == 3: + return AXObject._get_label_for_key_sequence(strings[2]) + + # If the last thing has Ctrl in it, it's probably the accelerator. + result = AXObject._get_label_for_key_sequence(strings[-1]) + if "Ctrl" in result: + return result + + return "" + + @staticmethod + def get_mnemonic(obj: Atspi.Accessible) -> str: + """Returns the mnemonic associated with obj.""" + + attrs = AXObject.get_attributes_dict(obj) + # The ARIA spec suggests a given shortcut's components should be separated by a "+". + # Multiple shortcuts are apparently allowed and separated by a space. + shortcuts = attrs.get("keyshortcuts", "").split(" ") + if shortcuts and shortcuts[0]: + result = " ".join(map(AXObject._get_label_for_key_sequence, shortcuts)).strip() + # If it's not a single letter it's probably not the mnemonic. + if len(result) == 1: + return result + + index = AXObject._find_first_action_with_keybinding(obj) + if index == -1: + return "" + + # This should be a string separated by semicolons and in the form: + # ;; (optional) + # In practice we get all sorts of variations. + + strings = AXObject.get_action_key_binding(obj, index).split(";") + result = AXObject._get_label_for_key_sequence(strings[0]) + # If Ctrl is in the result, it's probably the accelerator rather than the mnemonic. + if "Ctrl" in result or "Control" in result: + return "" + + # Don't treat space as a mnemonic. + if result.lower() in [" ", "space", ""]: + return "" + + return result + + @staticmethod + def _find_first_action_with_keybinding(obj: Atspi.Accessible) -> int: + """Returns the index of the first action with a keybinding on obj.""" + + for i in range(AXObject.get_n_actions(obj)): + if AXObject.get_action_key_binding(obj, i): + return i + return -1 + + @staticmethod + def has_action(obj: Atspi.Accessible, action_name: str) -> bool: """Returns true if the named action is supported on obj.""" return AXObject.get_action_index(obj, action_name) >= 0 @staticmethod - def get_action_index(obj, action_name): + def get_action_index(obj: Atspi.Accessible, action_name: str) -> int: """Returns the index of the named action or -1 if unsupported.""" action_name = AXObject._normalize_action_name(action_name) @@ -1333,7 +1496,7 @@ class AXObject: return -1 @staticmethod - def do_action(obj, i): + def do_action(obj: Atspi.Accessible, i: int) -> bool: """Invokes obj's action at index i. The return value, if true, may be meaningless because most implementors return true without knowing if the action was successfully performed.""" @@ -1343,7 +1506,7 @@ class AXObject: try: result = Atspi.Action.do_action(obj, i) - except Exception as error: + except GLib.GError as error: msg = f"AXObject: Exception in do_action: {error}" AXObject.handle_error(obj, error, msg) return False @@ -1351,7 +1514,7 @@ class AXObject: return result @staticmethod - def do_named_action(obj, action_name): + def do_named_action(obj: Atspi.Accessible, action_name: str) -> bool: """Invokes the named action on obj. The return value, if true, may be meaningless because most implementors return true without knowing if the action was successfully performed.""" @@ -1359,23 +1522,32 @@ class AXObject: index = AXObject.get_action_index(obj, action_name) if index == -1: tokens = ["INFO:", action_name, "not an available action for", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return False return AXObject.do_action(obj, index) @staticmethod - def actions_as_string(obj): - """Returns information about the actions as a string.""" + def grab_focus(obj: Atspi.Accessible) -> bool: + """Attempts to grab focus on obj. Returns true if successful.""" - results = [] - for i in range(AXObject.get_n_actions(obj)): - result = AXObject.get_action_name(obj, i) - keybinding = AXObject.get_action_key_binding(obj, i) - if keybinding: - result += f" ({keybinding})" - results.append(result) + if not AXObject.supports_component(obj): + return False - return "; ".join(results) + try: + result = Atspi.Component.grab_focus(obj) + except GLib.GError as error: + msg = f"AXObject: Exception in grab_focus: {error}" + AXObject.handle_error(obj, error, msg) + return False + + if debug.LEVEL_INFO < debug.debugLevel: + return result + + if result and not AXObject.has_state(obj, Atspi.StateType.FOCUSED): + tokens = ["AXObject:", obj, "lacks focused state after focus grab"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + return result AXObject.start_cache_clearing_thread() diff --git a/src/cthulhu/ax_selection.py b/src/cthulhu/ax_selection.py index c5d84c3..903dd5e 100644 --- a/src/cthulhu/ax_selection.py +++ b/src/cthulhu/ax_selection.py @@ -1,9 +1,7 @@ -#!/usr/bin/env python3 +# Utilities for obtaining information about containers supporting selection # -# 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 2023 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,19 +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 -""" -Utilities for obtaining information about containers supporting selection. -These utilities are app-type- and toolkit-agnostic. Utilities that might have -different implementations or results depending on the type of app (e.g. terminal, -chat, web) or toolkit (e.g. Qt, Gtk) should be in script_utilities.py file(s). +# pylint: disable=wrong-import-position -N.B. There are currently utilities that should never have custom implementations -that live in script_utilities.py files. These will be moved over time. -""" +"""Utilities for obtaining information about containers supporting selection.""" __id__ = "$Id$" __version__ = "$Revision$" @@ -40,19 +29,20 @@ __copyright__ = "Copyright (c) 2023 Igalia, S.L." __license__ = "LGPL" import gi - gi.require_version("Atspi", "2.0") from gi.repository import Atspi +from gi.repository import GLib from . import debug from .ax_object import AXObject +from .ax_utilities_role import AXUtilitiesRole class AXSelection: """Utilities for obtaining information about containers supporting selection.""" @staticmethod - def get_selected_child_count(obj): + def get_selected_child_count(obj: Atspi.Accessible) -> int: """Returns the selected child count of obj""" if not AXObject.supports_selection(obj): @@ -60,17 +50,17 @@ class AXSelection: try: count = Atspi.Selection.get_n_selected_children(obj) - except Exception as error: + except GLib.GError as error: tokens = ["AXSelection: Exception in get_selected_child_count:", error] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return 0 tokens = ["AXSelection:", obj, "reports", count, "selected children"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return count @staticmethod - def get_selected_child(obj, index): + def get_selected_child(obj: Atspi.Accessible, index: int) -> Atspi.Accessible | None: """Returns the nth selected child of obj.""" n_children = AXSelection.get_selected_child_count(obj) @@ -85,32 +75,40 @@ class AXSelection: try: child = Atspi.Selection.get_selected_child(obj, index) - except Exception as error: + except GLib.GError as error: tokens = ["AXSelection: Exception in get_selected_child:", error] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return None if child == obj: tokens = ["AXSelection:", obj, "claims to be its own selected child"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return None tokens = ["AXSelection:", child, "is selected child #", index, "of", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return child @staticmethod - def get_selected_children(obj): + def get_selected_children(obj: Atspi.Accessible) -> list[Atspi.Accessible]: """Returns a list of all the selected children of obj.""" + if obj is None: + return [] + count = AXSelection.get_selected_child_count(obj) + if not count and AXUtilitiesRole.is_combo_box(obj): + container = AXObject.find_descendant( + obj, lambda x: AXUtilitiesRole.is_menu(x) or AXUtilitiesRole.is_list_box(x)) + return AXSelection.get_selected_children(container) + children = set() for i in range(count): try: child = Atspi.Selection.get_selected_child(obj, i) - except Exception as error: + except GLib.GError as error: tokens = ["AXSelection: Exception in get_selected_children:", error] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return [] if child is not None: @@ -118,12 +116,12 @@ class AXSelection: if obj in children: tokens = ["AXSelection:", obj, "claims to be its own selected child"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) children.remove(obj) result = list(children) if len(result) != count: tokens = ["AXSelection: Selected child count of", obj, f"is {count}"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return result diff --git a/src/cthulhu/ax_table.py b/src/cthulhu/ax_table.py new file mode 100644 index 0000000..7dc4414 --- /dev/null +++ b/src/cthulhu/ax_table.py @@ -0,0 +1,1373 @@ +# Utilities for obtaining information about accessible tables. +# +# Copyright 2023 Igalia, S.L. +# Copyright 2023 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 +# 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=too-many-lines +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-return-statements + +"""Utilities for obtaining information about accessible tables.""" + +__id__ = "$Id$" +__version__ = "$Revision$" +__date__ = "$Date$" +__copyright__ = "Copyright (c) 2023 Igalia, S.L." \ + "Copyright (c) 2023 GNOME Foundation Inc." +__license__ = "LGPL" + +import threading +import time +from typing import Generator + +import gi +gi.require_version("Atspi", "2.0") +from gi.repository import Atspi +from gi.repository import GLib + +from . import debug +from . import messages +from . import object_properties +from .ax_object import AXObject +from .ax_component import AXComponent +from .ax_utilities_role import AXUtilitiesRole +from .ax_utilities_state import AXUtilitiesState + + +class AXTable: + """Utilities for obtaining information about accessible tables.""" + + # Things we cache. + CAPTIONS: dict[int, Atspi.Accessible] = {} + PHYSICAL_COORDINATES_FROM_CELL: dict[int, tuple[int, int]] = {} + PHYSICAL_COORDINATES_FROM_TABLE: dict[int, tuple[int, int]] = {} + PHYSICAL_SPANS_FROM_CELL: dict[int, tuple[int, int]] = {} + PHYSICAL_SPANS_FROM_TABLE: dict[int, tuple[int, int]] = {} + PHYSICAL_COLUMN_COUNT: dict[int, int] = {} + PHYSICAL_ROW_COUNT: dict[int, int] = {} + PRESENTABLE_COORDINATES: dict[int, tuple[str | None, str | None]] = {} + PRESENTABLE_COORDINATES_LABELS: dict[int, str] = {} + PRESENTABLE_SPANS: dict[int, tuple[str | None, str | None]] = {} + PRESENTABLE_COLUMN_COUNT: dict[int, int | None] = {} + PRESENTABLE_ROW_COUNT: dict[int, int | None] = {} + COLUMN_HEADERS_FOR_CELL: dict[int, list[Atspi.Accessible]] = {} + ROW_HEADERS_FOR_CELL: dict[int, list[Atspi.Accessible]] = {} + + # Things which have to be explicitly cleared. + DYNAMIC_COLUMN_HEADERS_ROW: dict[int, int] = {} + DYNAMIC_ROW_HEADERS_COLUMN: dict[int, int] = {} + + _lock = threading.Lock() + + @staticmethod + def start_cache_clearing_thread() -> None: + """Starts thread to periodically clear cached details.""" + + thread = threading.Thread(target=AXTable._clear_stored_data) + thread.daemon = True + thread.start() + + @staticmethod + def _clear_stored_data() -> None: + """Clears any data we have cached for objects""" + + while True: + time.sleep(60) + AXTable._clear_all_dictionaries() + + @staticmethod + def _clear_all_dictionaries(reason: str = "") -> None: + msg = "AXTable: Clearing cache." + if reason: + msg += f" Reason: {reason}" + debug.print_message(debug.LEVEL_INFO, msg, True) + + with AXTable._lock: + AXTable.CAPTIONS.clear() + AXTable.PHYSICAL_COORDINATES_FROM_CELL.clear() + AXTable.PHYSICAL_COORDINATES_FROM_TABLE.clear() + AXTable.PHYSICAL_SPANS_FROM_CELL.clear() + AXTable.PHYSICAL_SPANS_FROM_TABLE.clear() + AXTable.PHYSICAL_COLUMN_COUNT.clear() + AXTable.PHYSICAL_ROW_COUNT.clear() + AXTable.PRESENTABLE_COORDINATES.clear() + AXTable.PRESENTABLE_COORDINATES_LABELS.clear() + AXTable.PRESENTABLE_COLUMN_COUNT.clear() + AXTable.PRESENTABLE_ROW_COUNT.clear() + AXTable.COLUMN_HEADERS_FOR_CELL.clear() + AXTable.ROW_HEADERS_FOR_CELL.clear() + + @staticmethod + def clear_cache_now(reason: str = "") -> None: + """Clears all cached information immediately.""" + + AXTable._clear_all_dictionaries(reason) + + @staticmethod + def get_caption(table: Atspi.Accessible) -> Atspi.Accessible | None: + """Returns the accessible object containing the caption of table.""" + + if not AXObject.supports_table(table): + return None + + if hash(table) in AXTable.CAPTIONS: + return AXTable.CAPTIONS.get(hash(table)) + + try: + caption = Atspi.Table.get_caption(table) + except GLib.GError as error: + msg = f"AXTable: Exception in get_caption: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return None + + tokens = ["AXTable: Caption for", table, "is", caption] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + AXTable.CAPTIONS[hash(table)] = caption + return caption + + @staticmethod + def get_column_count(table: Atspi.Accessible, prefer_attribute: bool = True) -> int: + """Returns the column count of table.""" + + if not AXObject.supports_table(table): + return -1 + + if prefer_attribute: + count = AXTable._get_column_count_from_attribute(table) + if count is not None: + return count + + count = AXTable.PHYSICAL_COLUMN_COUNT.get(hash(table)) + if count is not None: + return count + + try: + count = Atspi.Table.get_n_columns(table) + except GLib.GError as error: + msg = f"AXTable: Exception in get_column_count: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return -1 + + tokens = ["AXTable: Column count for", table, "is", count] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + AXTable.PHYSICAL_COLUMN_COUNT[hash(table)] = count + return count + + @staticmethod + def _get_column_count_from_attribute(table: Atspi.Accessible) -> int | None: + """Returns the value of the 'colcount' object attribute or None if not found.""" + + if hash(table) in AXTable.PRESENTABLE_COLUMN_COUNT: + return AXTable.PRESENTABLE_COLUMN_COUNT.get(hash(table)) + + attrs = AXObject.get_attributes_dict(table) + attr = attrs.get("colcount") + count = None + if attr is not None: + count = int(attr) + + tokens = ["AXTable: Column count attribute for", table, "is", count] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + AXTable.PRESENTABLE_COLUMN_COUNT[hash(table)] = count + return count + + @staticmethod + def get_row_count(table: Atspi.Accessible, prefer_attribute: bool = True) -> int: + """Returns the row count of table.""" + + if not AXObject.supports_table(table): + return -1 + + if prefer_attribute: + count = AXTable._get_row_count_from_attribute(table) + if count is not None: + return count + + count = AXTable.PHYSICAL_ROW_COUNT.get(hash(table)) + if count is not None: + return count + + try: + count = Atspi.Table.get_n_rows(table) + except GLib.GError as error: + msg = f"AXTable: Exception in get_row_count: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return -1 + + tokens = ["AXTable: Row count for", table, "is", count] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + AXTable.PHYSICAL_ROW_COUNT[hash(table)] = count + return count + + @staticmethod + def _get_row_count_from_attribute(table: Atspi.Accessible) -> int | None: + """Returns the value of the 'rowcount' object attribute or None if not found.""" + + if hash(table) in AXTable.PRESENTABLE_ROW_COUNT: + return AXTable.PRESENTABLE_ROW_COUNT.get(hash(table)) + + attrs = AXObject.get_attributes_dict(table) + attr = attrs.get("rowcount") + count = None + if attr is not None: + count = int(attr) + + tokens = ["AXTable: Row count attribute for", table, "is", count] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + AXTable.PRESENTABLE_ROW_COUNT[hash(table)] = count + return count + + @staticmethod + def is_non_uniform_table( + table: Atspi.Accessible, max_rows: int = 25, max_cols: int = 25 + ) -> bool: + """Returns True if table has at least one cell with a span > 1.""" + + for row in range(min(max_rows, AXTable.get_row_count(table, False))): + for col in range(min(max_cols, AXTable.get_column_count(table, False))): + try: + if Atspi.Table.get_row_extent_at(table, row, col) > 1: + return True + if Atspi.Table.get_column_extent_at(table, row, col) > 1: + return True + except GLib.GError as error: + msg = f"AXTable: Exception in is_non_uniform_table: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + return False + + @staticmethod + def get_selected_column_count(table: Atspi.Accessible) -> int: + """Returns the number of selected columns in table.""" + + if not AXObject.supports_table(table): + return 0 + + try: + count = Atspi.Table.get_n_selected_columns(table) + except GLib.GError as error: + msg = f"AXTable: Exception in get_selected_column_count {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return 0 + + tokens = ["AXTable: Selected column count for", table, "is", count] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return count + + @staticmethod + def get_selected_columns(table: Atspi.Accessible) -> list[int]: + """Returns a list of column indices for the selected columns in table.""" + + if not AXObject.supports_table(table): + return [] + + try: + columns = Atspi.Table.get_selected_columns(table) + except GLib.GError as error: + msg = f"AXTable: Exception in get_selected_columns: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return [] + + tokens = ["AXTable: Selected columns for", table, "are", columns] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return columns + + @staticmethod + def get_selected_row_count(table: Atspi.Accessible) -> int: + """Returns the number of selected rows in table.""" + + if not AXObject.supports_table(table): + return 0 + + try: + count = Atspi.Table.get_n_selected_rows(table) + except GLib.GError as error: + msg = f"AXTable: Exception in get_selected_row_count {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return 0 + + tokens = ["AXTable: Selected row count for", table, "is", count] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return count + + @staticmethod + def get_selected_rows(table: Atspi.Accessible) -> list[int]: + """Returns a list of row indices for the selected rows in table.""" + + if not AXObject.supports_table(table): + return [] + + try: + rows = Atspi.Table.get_selected_rows(table) + except GLib.GError as error: + msg = f"AXTable: Exception in get_selected_rows: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return [] + + tokens = ["AXTable: Selected rows for", table, "are", rows] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return rows + + @staticmethod + def all_cells_are_selected(table: Atspi.Accessible) -> bool: + """Returns True if all cells in table are selected.""" + + if not AXObject.supports_table(table): + return False + + rows = AXTable.get_row_count(table, prefer_attribute=False) + if rows <= 0: + return False + + if AXTable.get_selected_row_count(table) == rows: + return True + + cols = AXTable.get_column_count(table, prefer_attribute=False) + return AXTable.get_selected_column_count(table) == cols + + @staticmethod + def get_cell_at(table: Atspi.Accessible, row: int, column: int) -> Atspi.Accessible | None: + """Returns the cell at the 0-indexed row and column.""" + + if not AXObject.supports_table(table): + return None + + try: + cell = Atspi.Table.get_accessible_at(table, row, column) + except GLib.GError as error: + tokens = [f"AXTable: Exception getting cell at row: {row} col: {column} in", table, + ":", error] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return None + + tokens = [f"AXTable: Cell at row: {row} col: {column} in", table, "is", cell] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return cell + + @staticmethod + def _get_cell_index(cell: Atspi.Accessible) -> int: + """Returns the index of cell to be used with the table interface.""" + + index = AXObject.get_attribute(cell, "table-cell-index") + if index is not None and index != "": + return int(index) + + # We might have nested cells. So far this has only been seen in Gtk, + # where the parent of a table cell is also a table cell. We need the + # index of the parent for use with the table interface. + parent = AXObject.get_parent(cell) + if AXObject.get_role(parent) == Atspi.Role.TABLE_CELL: + cell = parent + + return AXObject.get_index_in_parent(cell) + + @staticmethod + def get_cell_spans(cell: Atspi.Accessible, prefer_attribute: bool = True) -> tuple[int, int]: + """Returns the row and column spans.""" + + if not AXUtilitiesRole.is_table_cell_or_header(cell): + return -1, -1 + + if AXObject.supports_table_cell(cell): + row_span, col_span = AXTable._get_cell_spans_from_table_cell(cell) + else: + row_span, col_span = AXTable._get_cell_spans_from_table(cell) + + if not prefer_attribute: + return row_span, col_span + + rowspan_attr, colspan_attr = AXTable._get_cell_spans_from_attribute(cell) + if rowspan_attr is not None: + row_span = int(rowspan_attr) + if colspan_attr is not None: + col_span = int(colspan_attr) + + return row_span, col_span + + @staticmethod + def _get_cell_spans_from_attribute( + cell: Atspi.Accessible + ) -> tuple[str | None, str | None]: + """Returns the row and column spans exposed via object attribute, or None, None.""" + + if hash(cell) in AXTable.PRESENTABLE_SPANS: + return AXTable.PRESENTABLE_SPANS.get(hash(cell), (None, None)) + + attrs = AXObject.get_attributes_dict(cell) + row_span = attrs.get("rowspan", None) + col_span = attrs.get("colspan", None) + + tokens = ["AXTable: Row and col span attributes for", cell, ":", row_span, ",", col_span] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + AXTable.PRESENTABLE_SPANS[hash(cell)] = row_span, col_span + return row_span, col_span + + @staticmethod + def _get_cell_spans_from_table(cell: Atspi.Accessible) -> tuple[int, int]: + """Returns the row and column spans of cell via the table interface.""" + + if hash(cell) in AXTable.PHYSICAL_SPANS_FROM_TABLE: + return AXTable.PHYSICAL_SPANS_FROM_TABLE.get(hash(cell), (-1, -1)) + + index = AXTable._get_cell_index(cell) + if index < 0: + return -1, -1 + + table = AXTable.get_table(cell) + if table is None: + return -1, -1 + + if not AXObject.supports_table(table): + return -1, -1 + + # Cells in a tree are expected to not span multiple rows or columns. + # Also this: https://bugreports.qt.io/browse/QTBUG-119167 + if AXUtilitiesRole.is_tree(table): + return 1, 1 + + try: + result = Atspi.Table.get_row_column_extents_at_index(table, index) + except GLib.GError as error: + msg = f"AXTable: Exception in _get_cell_spans_from_table: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return -1, -1 + + if result is None: + tokens = ["AXTable: get_row_column_extents_at_index failed for", cell, + f"at index {index} in", table] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return -1, -1 + + if not result[0]: + return -1, -1 + + row_span = result.row_extents + row_count = AXTable.get_row_count(table, False) + if row_span > row_count: + tokens = ["AXTable: Table iface row span for", cell, + f"{row_span} is greater than row count: {row_count}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + row_span = 1 + + col_span = result.col_extents + col_count = AXTable.get_column_count(table, False) + if col_span > col_count: + tokens = ["AXTable: Table iface col span for", cell, + f"{col_span} is greater than col count: {col_count}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + col_span = 1 + + tokens = ["AXTable: Table iface spans for", cell, + f"are rowspan: {row_span}, colspan: {col_span}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + AXTable.PHYSICAL_SPANS_FROM_TABLE[hash(cell)] = row_span, col_span + return row_span, col_span + + @staticmethod + def _get_cell_spans_from_table_cell(cell: Atspi.Accessible) -> tuple[int, int]: + """Returns the row and column spans of cell via the table cell interface.""" + + if hash(cell) in AXTable.PHYSICAL_SPANS_FROM_CELL: + return AXTable.PHYSICAL_SPANS_FROM_CELL.get(hash(cell), (-1, -1)) + + if not AXObject.supports_table_cell(cell): + return -1, -1 + + try: + # TODO - JD: We get the spans individually due to + # https://bugzilla.mozilla.org/show_bug.cgi?id=1862437 + row_span = Atspi.TableCell.get_row_span(cell) + col_span = Atspi.TableCell.get_column_span(cell) + except GLib.GError as error: + msg = f"AXTable: Exception in _get_cell_spans_from_table_cell: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return -1, -1 + + tokens = ["AXTable: TableCell iface spans for", cell, + f"are rowspan: {row_span}, colspan: {col_span}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + AXTable.PHYSICAL_SPANS_FROM_CELL[hash(cell)] = row_span, col_span + return row_span, col_span + + @staticmethod + def _get_column_headers_from_table( + table: Atspi.Accessible, column: int + ) -> list[Atspi.Accessible]: + """Returns the column headers of the indexed column via the table interface.""" + + if not AXObject.supports_table(table): + return [] + + if column < 0: + return [] + + try: + header = Atspi.Table.get_column_header(table, column) + except GLib.GError as error: + msg = f"AXTable: Exception in _get_column_headers_from_table: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return [] + + tokens = [f"AXTable: Table iface header for column {column} of", table, "is", header] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if header is not None: + return [header] + + return [] + + @staticmethod + def _get_column_headers_from_table_cell(cell: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns the column headers for cell via the table cell interface.""" + + if not AXObject.supports_table_cell(cell): + return [] + + try: + headers = Atspi.TableCell.get_column_header_cells(cell) + except GLib.GError as error: + msg = f"AXTable: Exception in _get_column_headers_from_table_cell: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return [] + + if headers is None: + tokens = ["AXTable: get_column_header_cells failed for", cell] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return [] + + tokens = ["AXTable: TableCell iface column headers for cell are:", headers] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return headers + + @staticmethod + def _get_row_headers_from_table(table: Atspi.Accessible, row: int) -> list[Atspi.Accessible]: + """Returns the row headers of the indexed row via the table interface.""" + + if not AXObject.supports_table(table): + return [] + + if row < 0: + return [] + + try: + header = Atspi.Table.get_row_header(table, row) + except GLib.GError as error: + msg = f"AXTable: Exception in _get_row_headers_from_table: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return [] + + tokens = [f"AXTable: Table iface header for row {row} of", table, "is", header] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if header is not None: + return [header] + + return [] + + @staticmethod + def _get_row_headers_from_table_cell(cell: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns the row headers for cell via the table cell interface.""" + + if not AXObject.supports_table_cell(cell): + return [] + + try: + headers = Atspi.TableCell.get_row_header_cells(cell) + except GLib.GError as error: + msg = f"AXTable: Exception in _get_row_headers_from_table_cell: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return [] + + if headers is None: + tokens = ["AXTable: get_row_header_cells failed for", cell] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return [] + + tokens = ["AXTable: TableCell iface row headers for cell are:", headers] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return headers + + @staticmethod + def get_new_row_headers( + cell: Atspi.Accessible, + old_cell: Atspi.Accessible | None + ) -> list[Atspi.Accessible]: + """Returns row headers of cell that are not also headers of old_cell. """ + + if old_cell and not AXUtilitiesRole.is_table_cell_or_header(old_cell): + old_cell = AXObject.find_ancestor(old_cell, AXUtilitiesRole.is_table_cell_or_header) + + headers = AXTable.get_row_headers(cell) + if old_cell is None: + return headers + + old_headers = AXTable.get_row_headers(old_cell) + return list(set(headers).difference(set(old_headers))) + + @staticmethod + def get_new_column_headers( + cell: Atspi.Accessible, + old_cell: Atspi.Accessible | None + ) -> list[Atspi.Accessible]: + """Returns column headers of cell that are not also headers of old_cell. """ + + if old_cell and not AXUtilitiesRole.is_table_cell_or_header(old_cell): + old_cell = AXObject.find_ancestor(old_cell, AXUtilitiesRole.is_table_cell_or_header) + + headers = AXTable.get_column_headers(cell) + if old_cell is None: + return headers + + old_headers = AXTable.get_column_headers(old_cell) + return list(set(headers).difference(set(old_headers))) + + @staticmethod + def get_row_headers(cell: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns the row headers for cell, doing extra work to ensure we have them all.""" + + if not AXUtilitiesRole.is_table_cell(cell): + return [] + + dynamic_header = AXTable.get_dynamic_row_header(cell) + if dynamic_header is not None: + return [dynamic_header] + + # Firefox has the following implementation: + # 1. Only gives us the innermost/closest header for a cell + # 2. Supports returning the header of a header + # Chromium has the following implementation: + # 1. Gives us all the headers for a cell + # 2. Does NOT support returning the header of a header + # The Firefox implementation means we can get all the headers with some work. + # The Chromium implementation means less work, but makes it hard to present + # the changed outer header when navigating among nested row/column headers. + # TODO - JD: Figure out what the rest do, and then try to get the implementations + # aligned. + + result = AXTable.ROW_HEADERS_FOR_CELL.get(hash(cell)) + if result is not None: + return result + + result = AXTable._get_row_headers(cell) + # There either are no headers, or we got all of them. + if len(result) != 1: + AXTable.ROW_HEADERS_FOR_CELL[hash(cell)] = result + return result + + others = AXTable._get_row_headers(result[0]) + while len(others) == 1 and others[0] not in result: + result.insert(0, others[0]) + others = AXTable._get_row_headers(result[0]) + + AXTable.ROW_HEADERS_FOR_CELL[hash(cell)] = result + return result + + @staticmethod + def _get_row_headers(cell: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns the row headers for cell.""" + + if AXObject.supports_table_cell(cell): + return AXTable._get_row_headers_from_table_cell(cell) + + row, column = AXTable._get_cell_coordinates_from_table(cell) + if row < 0 or column < 0: + return [] + + table = AXTable.get_table(cell) + if table is None: + return [] + + headers = [] + rowspan = AXTable._get_cell_spans_from_table(cell)[0] + for index in range(row, row + rowspan): + headers.extend(AXTable._get_row_headers_from_table(table, index)) + + return headers + + @staticmethod + def has_row_headers(table: Atspi.Accessible, stop_after: int = 10) -> bool: + """Returns True if table has any headers for rows 0-stop_after.""" + + if not AXObject.supports_table(table): + return False + + stop_after = min(stop_after + 1, AXTable.get_row_count(table)) + for i in range(stop_after): + if AXTable._get_row_headers_from_table(table, i): + return True + + return False + + @staticmethod + def get_column_headers(cell: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns the column headers for cell, doing extra work to ensure we have them all.""" + + if not AXUtilitiesRole.is_table_cell(cell): + return [] + + dynamic_header = AXTable.get_dynamic_column_header(cell) + if dynamic_header is not None: + return [dynamic_header] + + # Firefox has the following implementation: + # 1. Only gives us the innermost/closest header for a cell + # 2. Supports returning the header of a header + # Chromium has the following implementation: + # 1. Gives us all the headers for a cell + # 2. Does NOT support returning the header of a header + # The Firefox implementation means we can get all the headers with some work. + # The Chromium implementation means less work, but makes it hard to present + # the changed outer header when navigating among nested row/column headers. + # TODO - JD: Figure out what the rest do, and then try to get the implementations + # aligned. + + result = AXTable.COLUMN_HEADERS_FOR_CELL.get(hash(cell)) + if result is not None: + return result + + result = AXTable._get_column_headers(cell) + # There either are no headers, or we got all of them. + if len(result) != 1: + AXTable.COLUMN_HEADERS_FOR_CELL[hash(cell)] = result + return result + + others = AXTable._get_column_headers(result[0]) + while len(others) == 1 and others[0] not in result: + result.insert(0, others[0]) + others = AXTable._get_column_headers(result[0]) + + AXTable.COLUMN_HEADERS_FOR_CELL[hash(cell)] = result + return result + + @staticmethod + def _get_column_headers(cell: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns the column headers for cell.""" + + if AXObject.supports_table_cell(cell): + return AXTable._get_column_headers_from_table_cell(cell) + + row, column = AXTable._get_cell_coordinates_from_table(cell) + if row < 0 or column < 0: + return [] + + table = AXTable.get_table(cell) + if table is None: + return [] + + headers = [] + colspan = AXTable._get_cell_spans_from_table(cell)[1] + for index in range(column, column + colspan): + headers.extend(AXTable._get_column_headers_from_table(table, index)) + + return headers + + @staticmethod + def has_column_headers(table: Atspi.Accessible, stop_after: int = 10) -> bool: + """Returns True if table has any headers for columns 0-stop_after.""" + + if not AXObject.supports_table(table): + return False + + stop_after = min(stop_after + 1, AXTable.get_column_count(table)) + for i in range(stop_after): + if AXTable._get_column_headers_from_table(table, i): + return True + + return False + + @staticmethod + def get_cell_coordinates( + cell: Atspi.Accessible, + prefer_attribute: bool = True, + find_cell: bool = False + ) -> tuple[int, int]: + """Returns the 0-based row and column indices.""" + + if not AXUtilitiesRole.is_table_cell_or_header(cell) and find_cell: + cell = AXObject.find_ancestor(cell, AXUtilitiesRole.is_table_cell_or_header) + + if not AXUtilitiesRole.is_table_cell_or_header(cell): + return -1, -1 + + if AXObject.supports_table_cell(cell): + row, col = AXTable._get_cell_coordinates_from_table_cell(cell) + else: + row, col = AXTable._get_cell_coordinates_from_table(cell) + + if not prefer_attribute: + return row, col + + row_index, col_index = AXTable._get_cell_coordinates_from_attribute(cell) + if row_index is not None: + row = int(row_index) - 1 + if col_index is not None: + col = int(col_index) - 1 + + return row, col + + @staticmethod + def _get_cell_coordinates_from_table(cell: Atspi.Accessible) -> tuple[int, int]: + """Returns the row and column indices of cell via the table interface.""" + + if hash(cell) in AXTable.PHYSICAL_COORDINATES_FROM_TABLE: + return AXTable.PHYSICAL_COORDINATES_FROM_TABLE.get(hash(cell), (-1, -1)) + + index = AXTable._get_cell_index(cell) + if index < 0: + return -1, -1 + + table = AXTable.get_table(cell) + if table is None: + tokens = ["AXTable: Couldn't find table-implementing ancestor for", cell] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return -1, -1 + + try: + row = Atspi.Table.get_row_at_index(table, index) + column = Atspi.Table.get_column_at_index(table, index) + except GLib.GError as error: + msg = f"AXTable: Exception in _get_cell_coordinates_from_table: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return -1, -1 + + tokens = ["AXTable: Table iface coords for", cell, f"are row: {row}, col: {column}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + AXTable.PHYSICAL_COORDINATES_FROM_TABLE[hash(cell)] = row, column + return row, column + + @staticmethod + def _get_cell_coordinates_from_table_cell(cell: Atspi.Accessible) -> tuple[int, int]: + """Returns the row and column indices of cell via the table cell interface.""" + + if hash(cell) in AXTable.PHYSICAL_COORDINATES_FROM_CELL: + return AXTable.PHYSICAL_COORDINATES_FROM_CELL.get(hash(cell), (-1, -1)) + + if not AXObject.supports_table_cell(cell): + return -1, -1 + + try: + success, row, column = Atspi.TableCell.get_position(cell) + except GLib.GError as error: + msg = f"AXTable: Exception in _get_cell_coordinates_from_table_cell: {error}" + + debug.print_message(debug.LEVEL_INFO, msg, True) + return -1, -1 + + if not success: + return -1, -1 + + tokens = ["AXTable: TableCell iface coords for", cell, f"are row: {row}, col: {column}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + AXTable.PHYSICAL_COORDINATES_FROM_CELL[hash(cell)] = row, column + return row, column + + @staticmethod + def _get_cell_coordinates_from_attribute( + cell: Atspi.Accessible + ) -> tuple[str | None, str | None]: + """Returns the 1-based indices for cell exposed via object attribute, or None, None.""" + + if cell is None: + return None, None + + if hash(cell) in AXTable.PRESENTABLE_COORDINATES: + return AXTable.PRESENTABLE_COORDINATES.get(hash(cell), (None, None)) + + attrs = AXObject.get_attributes_dict(cell) + row_index = attrs.get("rowindex") + col_index = attrs.get("colindex") + + tokens = ["AXTable: Row and col index attributes for", cell, ":", row_index, ",", col_index] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + AXTable.PRESENTABLE_COORDINATES[hash(cell)] = row_index, col_index + if row_index is not None and col_index is not None: + return row_index, col_index + + row = AXObject.find_ancestor(cell, AXUtilitiesRole.is_table_row) + if row is None: + return row_index, col_index + + attrs = AXObject.get_attributes_dict(row) + row_index = attrs.get("rowindex", row_index) + col_index = attrs.get("colindex", col_index) + + tokens = ["AXTable: Updated attributes based on", row, ":", row_index, col_index] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + AXTable.PRESENTABLE_COORDINATES[hash(cell)] = row_index, col_index + return row_index, col_index + + @staticmethod + def get_presentable_sort_order_from_header( + obj: Atspi.Accessible, + include_name: bool = False + ) -> str: + """Returns the end-user-consumable row/column sort order from its header.""" + + if not AXUtilitiesRole.is_table_header(obj): + return "" + + sort_order = AXObject.get_attribute(obj, "sort", False) + if not sort_order or sort_order == "none": + return "" + + if sort_order == "ascending": + result = object_properties.SORT_ORDER_ASCENDING + elif sort_order == "descending": + result = object_properties.SORT_ORDER_DESCENDING + else: + result = object_properties.SORT_ORDER_OTHER + + if include_name: + name = AXObject.get_name(obj) + if name: + result = f"{name}. {result}" + + return result + + @staticmethod + def get_table(obj: Atspi.Accessible) -> Atspi.Accessible | None: + """Returns obj if it is a table, otherwise returns the ancestor table of obj.""" + + if obj is None: + return None + + if AXObject.supports_table_cell(obj): + try: + table = Atspi.TableCell.get_table(obj) + except GLib.GError as error: + msg = f"AXTable: Exception in get_table: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + else: + if AXObject.supports_table(table): + return table + + def is_table(x): + if AXUtilitiesRole.is_table(x) \ + or AXUtilitiesRole.is_tree_table(x) or AXUtilitiesRole.is_tree(x): + return AXObject.supports_table(x) + return False + + if is_table(obj): + return obj + + return AXObject.find_ancestor(obj, is_table) + + @staticmethod + def get_table_description_for_presentation(table: Atspi.Accessible) -> str: + """Returns an end-user-consumable string which describes the table.""" + + if not AXObject.supports_table(table): + return "" + + result = messages.table_size(AXTable.get_row_count(table), AXTable.get_column_count(table)) + if AXTable.is_non_uniform_table(table): + result = f"{messages.TABLE_NON_UNIFORM} {result}" + return result + + @staticmethod + def get_first_cell(table: Atspi.Accessible) -> Atspi.Accessible | None: + """Returns the first cell in table.""" + + row, col = 0, 0 + return AXTable.get_cell_at(table, row, col) + + @staticmethod + def get_last_cell(table: Atspi.Accessible) -> Atspi.Accessible | None: + """Returns the last cell in table.""" + + row, col = AXTable.get_row_count(table) - 1, AXTable.get_column_count(table) - 1 + return AXTable.get_cell_at(table, row, col) + + @staticmethod + def get_cell_above(cell: Atspi.Accessible) -> Atspi.Accessible | None: + """Returns the cell above cell in table.""" + + row, col = AXTable.get_cell_coordinates(cell, prefer_attribute=False) + row -= 1 + return AXTable.get_cell_at(AXTable.get_table(cell), row, col) + + @staticmethod + def get_cell_below(cell: Atspi.Accessible) -> Atspi.Accessible | None: + """Returns the cell below cell in table.""" + + row, col = AXTable.get_cell_coordinates(cell, prefer_attribute=False) + row += AXTable.get_cell_spans(cell, prefer_attribute=False)[0] + return AXTable.get_cell_at(AXTable.get_table(cell), row, col) + + @staticmethod + def get_cell_on_left(cell: Atspi.Accessible) -> Atspi.Accessible | None: + """Returns the cell to the left.""" + + row, col = AXTable.get_cell_coordinates(cell, prefer_attribute=False) + col -= 1 + return AXTable.get_cell_at(AXTable.get_table(cell), row, col) + + @staticmethod + def get_cell_on_right(cell: Atspi.Accessible) -> Atspi.Accessible | None: + """Returns the cell to the right.""" + + row, col = AXTable.get_cell_coordinates(cell, prefer_attribute=False) + col += AXTable.get_cell_spans(cell, prefer_attribute=False)[1] + return AXTable.get_cell_at(AXTable.get_table(cell), row, col) + + @staticmethod + def get_start_of_row(cell: Atspi.Accessible) -> Atspi.Accessible | None: + """Returns the cell at the start of cell's row.""" + + row = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[0] + return AXTable.get_cell_at(AXTable.get_table(cell), row, 0) + + @staticmethod + def get_end_of_row(cell: Atspi.Accessible) -> Atspi.Accessible | None: + """Returns the cell at the end of cell's row.""" + + row = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[0] + table = AXTable.get_table(cell) + col = AXTable.get_column_count(table) - 1 + return AXTable.get_cell_at(AXTable.get_table(cell), row, col) + + @staticmethod + def get_top_of_column(cell: Atspi.Accessible) -> Atspi.Accessible | None: + """Returns the cell at the top of cell's column.""" + + col = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[1] + return AXTable.get_cell_at(AXTable.get_table(cell), 0, col) + + @staticmethod + def get_bottom_of_column(cell: Atspi.Accessible) -> Atspi.Accessible | None: + """Returns the cell at the bottom of cell's column.""" + + col = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[1] + table = AXTable.get_table(cell) + row = AXTable.get_row_count(table) - 1 + return AXTable.get_cell_at(AXTable.get_table(cell), row, col) + + @staticmethod + def get_cell_formula(cell: Atspi.Accessible) -> str | None: + """Returns the formula associated with this cell.""" + + attrs = AXObject.get_attributes_dict(cell, use_cache=False) + return attrs.get("formula", attrs.get("Formula")) + + @staticmethod + def is_first_cell(cell: Atspi.Accessible) -> bool: + """Returns True if this is the first cell in its table.""" + + row, col = AXTable.get_cell_coordinates(cell, prefer_attribute=False) + return row == 0 and col == 0 + + @staticmethod + def is_last_cell(cell: Atspi.Accessible) -> bool: + """Returns True if this is the last cell in its table.""" + + row, col = AXTable.get_cell_coordinates(cell, prefer_attribute=False) + if row < 0 or col < 0: + return False + + table = AXTable.get_table(cell) + if table is None: + return False + + return row + 1 == AXTable.get_row_count(table, prefer_attribute=False) \ + and col + 1 == AXTable.get_column_count(table, prefer_attribute=False) + + @staticmethod + def is_start_of_row(cell: Atspi.Accessible) -> bool: + """Returns True if this is the first cell in its row.""" + + col = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[1] + return col == 0 + + @staticmethod + def is_end_of_row(cell: Atspi.Accessible) -> bool: + """Returns True if this is the last cell in its row.""" + + col = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[1] + if col < 0: + return False + + table = AXTable.get_table(cell) + if table is None: + return False + + return col + 1 == AXTable.get_column_count(table, prefer_attribute=False) + + @staticmethod + def is_top_of_column(cell: Atspi.Accessible) -> bool: + """Returns True if this is the first cell in its column.""" + + row = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[0] + return row == 0 + + @staticmethod + def is_bottom_of_column(cell: Atspi.Accessible) -> bool: + """Returns True if this is the last cell in its column.""" + + row = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[0] + if row < 0: + return False + + table = AXTable.get_table(cell) + if table is None: + return False + + return row + 1 == AXTable.get_row_count(table, prefer_attribute=False) + + @staticmethod + def is_layout_table(table: Atspi.Accessible) -> bool: + """Returns True if this table should be treated as layout only.""" + + result, reason = False, "Not enough information" + attrs = AXObject.get_attributes_dict(table) + if AXUtilitiesRole.is_table(table): + if attrs.get("layout-guess") == "true": + result, reason = True, "The layout-guess attribute is true." + elif not AXObject.supports_table(table): + result, reason = True, "Doesn't support table interface." + elif attrs.get("xml-roles") == "table" or attrs.get("tag") == "table": + result, reason = False, "Is a web table without layout-guess set to true." + elif AXTable.has_column_headers(table) or AXTable.has_row_headers(table): + result, reason = False, "Has headers" + elif AXObject.get_name(table) or AXObject.get_description(table): + result, reason = False, "Has name or description" + elif AXTable.get_caption(table): + result, reason = False, "Has caption" + + tokens = ["AXTable:", table, f"is layout only: {result} ({reason})"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def get_label_for_cell_coordinates(cell: Atspi.Accessible) -> str: + """Returns the text that should be used instead of the numeric indices.""" + + if hash(cell) in AXTable.PRESENTABLE_COORDINATES_LABELS: + return AXTable.PRESENTABLE_COORDINATES_LABELS.get(hash(cell), "") + + attrs = AXObject.get_attributes_dict(cell) + result = "" + + # The attribute officially has the word "index" in it for clarity. + # TODO - JD: Google Sheets needs to start using the correct attribute name. + col_label = attrs.get("colindextext", attrs.get("coltext")) + row_label = attrs.get("rowindextext", attrs.get("rowtext")) + if col_label is not None and row_label is not None: + result = f"{col_label}{row_label}" + + tokens = ["AXTable: Coordinates label for", cell, f": {result}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + AXTable.PRESENTABLE_COORDINATES_LABELS[hash(cell)] = result + if result: + return result + + row = AXObject.find_ancestor(cell, AXUtilitiesRole.is_table_row) + if row is None: + return result + + attrs = AXObject.get_attributes_dict(row) + col_label = attrs.get("colindextext", attrs.get("coltext", col_label)) + row_label = attrs.get("rowindextext", attrs.get("rowtext", row_label)) + if col_label is not None and row_label is not None: + result = f"{col_label}{row_label}" + + tokens = ["AXTable: Updated coordinates label based on", row, f": {result}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + AXTable.PRESENTABLE_COORDINATES_LABELS[hash(cell)] = result + return result + + @staticmethod + def get_dynamic_row_header(cell: Atspi.Accessible) -> Atspi.Accessible | None: + """Returns the user-set row header for cell.""" + + table = AXTable.get_table(cell) + headers_column = AXTable.DYNAMIC_ROW_HEADERS_COLUMN.get(hash(table)) + if headers_column is None: + return None + + cell_row, cell_column = AXTable.get_cell_coordinates(cell) + if cell_column == headers_column: + return None + + return AXTable.get_cell_at(table, cell_row, headers_column) + + @staticmethod + def get_dynamic_column_header(cell: Atspi.Accessible) -> Atspi.Accessible | None: + """Returns the user-set column header for cell.""" + + table = AXTable.get_table(cell) + headers_row = AXTable.DYNAMIC_COLUMN_HEADERS_ROW.get(hash(table)) + if headers_row is None: + return None + + cell_row, cell_column = AXTable.get_cell_coordinates(cell) + if cell_row == headers_row: + return None + + return AXTable.get_cell_at(table, headers_row, cell_column) + + @staticmethod + def set_dynamic_row_headers_column(table: Atspi.Accessible, column: int) -> None: + """Sets the dynamic row headers column of table to column.""" + + AXTable.DYNAMIC_ROW_HEADERS_COLUMN[hash(table)] = column + + @staticmethod + def set_dynamic_column_headers_row(table: Atspi.Accessible, row: int) -> None: + """Sets the dynamic column headers row of table to row.""" + + AXTable.DYNAMIC_COLUMN_HEADERS_ROW[hash(table)] = row + + @staticmethod + def clear_dynamic_row_headers_column(table: Atspi.Accessible) -> None: + """Clears the dynamic row headers column of table.""" + + if hash(table) not in AXTable.DYNAMIC_ROW_HEADERS_COLUMN: + return + + AXTable.DYNAMIC_ROW_HEADERS_COLUMN.pop(hash(table)) + + @staticmethod + def clear_dynamic_column_headers_row(table: Atspi.Accessible) -> None: + """Clears the dynamic column headers row of table.""" + + if hash(table) not in AXTable.DYNAMIC_COLUMN_HEADERS_ROW: + return + + AXTable.DYNAMIC_COLUMN_HEADERS_ROW.pop(hash(table)) + + @staticmethod + def _get_visible_cell_range(table: Atspi.Accessible) -> tuple[tuple[int, int], tuple[int, int]]: + """Returns the (row, col) of the first and last visible cells in table.""" + + if not AXObject.supports_table(table): + return (-1, -1), (-1, -1) + + rect = AXComponent.get_rect(table) + tokens = ["AXTable: Rect for", table, "is", rect] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + first_cell = AXComponent.get_descendant_at_point(table, rect.x + 1, rect.y + 1) + tokens = ["AXTable: First visible cell for", table, "is", first_cell] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + start = AXTable.get_cell_coordinates(first_cell, prefer_attribute=False) + tokens = ["AXTable: First visible cell is at row", start[0], "column", start[1]] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + last_cell = AXComponent.get_descendant_at_point( + table, rect.x + rect.width - 1, rect.y + rect.height - 1) + tokens = ["AXTable: Last visible cell for", table, "is", last_cell] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + end = AXTable.get_cell_coordinates(last_cell, prefer_attribute=False) + tokens = ["AXTable: Last visible cell is at row", end[0], "column", end[1]] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if end == (-1, -1): + last_cell = AXTable.get_last_cell(table) + tokens = ["AXTable: Adjusted lasat visible cell for", table, "is", last_cell] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + end = AXTable.get_cell_coordinates(last_cell, prefer_attribute=False) + tokens = ["AXTable: Adjusted last cell is at row", end[0], "column", end[1]] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if AXUtilitiesRole.is_table_cell(last_cell) \ + and not AXUtilitiesRole.is_table_cell_or_header(first_cell): + candidate = AXTable.get_cell_above(last_cell) + while candidate and AXComponent.object_intersects_rect(candidate, rect): + first_cell = candidate + candidate = AXTable.get_cell_above(first_cell) + + candidate = AXTable.get_cell_on_left(first_cell) + while candidate and AXComponent.object_intersects_rect(candidate, rect): + first_cell = candidate + candidate = AXTable.get_cell_on_left(first_cell) + + tokens = ["AXTable: Adjusted first visible cell for", table, "is", first_cell] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + start = AXTable.get_cell_coordinates(first_cell, prefer_attribute=False) + tokens = ["AXTable: Adjusted first cell is at row", start[0], "column", start[1]] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + return start, end + + @staticmethod + def iter_visible_cells(table: Atspi.Accessible) -> Generator[Atspi.Accessible, None, None]: + """Yields the visible cells in table.""" + + start, end = AXTable._get_visible_cell_range(table) + if start[0] < 0 or start[1] < 0 or end[0] < 0 or end[1] < 0: + return + + for row in range(start[0], end[0] + 1): + for col in range(start[1], end[1] + 1): + cell = AXTable.get_cell_at(table, row, col) + if cell is None: + continue + for child in AXObject.iter_children(cell, AXUtilitiesRole.is_table_cell): + if AXObject.get_name(child): + cell = child + break + yield cell + + @staticmethod + def get_showing_cells_in_same_row( + cell: Atspi.Accessible, + clip_to_window: bool = False + ) -> list[Atspi.Accessible]: + """Returns a list of all the cells in the same row as obj that are showing.""" + + row = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[0] + if row == -1: + return [] + + table = AXTable.get_table(cell) + start_index, end_index = 0, AXTable.get_column_count(table, False) + if clip_to_window: + rect = AXComponent.get_rect(table) + if (cell := AXComponent.get_descendant_at_point(table, rect.x + 1, rect.y)): + start_index = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[1] + if (cell := AXComponent.get_descendant_at_point( + table, rect.x + rect.width - 1, rect.y)): + end_index = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[1] + 1 + + if start_index == end_index: + return [] + + cells = [] + for i in range(start_index, end_index): + cell = AXTable.get_cell_at(table, row, i) + if AXUtilitiesState.is_showing(cell): + cells.append(cell) + + if not cells: + tokens = ["AXTable: No visible cells found in row with", cell] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return [] + + tokens = ["AXTable: First visible cell in row with", cell, "is", cells[0]] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + tokens = ["AXTable: Last visible cell in row with", cell, "is", cells[-1]] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return cells + + +AXTable.start_cache_clearing_thread() diff --git a/src/cthulhu/ax_text.py b/src/cthulhu/ax_text.py new file mode 100644 index 0000000..66347f7 --- /dev/null +++ b/src/cthulhu/ax_text.py @@ -0,0 +1,1606 @@ +# Orca +# +# 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 +# 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=too-many-locals +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-branches +# pylint: disable=too-many-lines + +"""Utilities for obtaining information about accessible text.""" + +# This has to be the first non-docstring line in the module to make linters happy. +from __future__ import annotations + +__id__ = "$Id$" +__version__ = "$Revision$" +__date__ = "$Date$" +__copyright__ = "Copyright (c) 2024 Igalia, S.L." \ + "Copyright (c) 2024 GNOME Foundation Inc." +__license__ = "LGPL" + +import enum +import locale +import re +from typing import Generator + +import gi +gi.require_version("Atspi", "2.0") +from gi.repository import Atspi +from gi.repository import GLib + +from . import colornames +from . import debug +from . import messages +from . import text_attribute_names +from .ax_object import AXObject +from .ax_utilities_role import AXUtilitiesRole +from .ax_utilities_state import AXUtilitiesState + +class AXTextAttribute(enum.Enum): + """Enum representing an accessible text attribute.""" + + # Note: Anything added here should also have an entry in text_attribute_names.py. + # The tuple is (non-localized name, enable by default). + BG_COLOR = ("bg-color", True) + BG_FULL_HEIGHT = ("bg-full-height", False) + BG_STIPPLE = ("bg-stipple", False) + DIRECTION = ("direction", False) + EDITABLE = ("editable", False) + FAMILY_NAME = ("family-name", True) + FG_COLOR = ("fg-color", True) + FG_STIPPLE = ("fg-stipple", False) + FONT_EFFECT = ("font-effect", True) + INDENT = ("indent", True) + INVALID = ("invalid", True) + INVISIBLE = ("invisible", False) + JUSTIFICATION = ("justification", True) + LANGUAGE = ("language", False) + LEFT_MARGIN = ("left-margin", False) + LINE_HEIGHT = ("line-height", False) + MARK = ("mark", True) + PARAGRAPH_STYLE = ("paragraph-style", True) + PIXELS_ABOVE_LINES = ("pixels-above-lines", False) + PIXELS_BELOW_LINES = ("pixels-below-lines", False) + PIXELS_INSIDE_WRAP = ("pixels-inside-wrap", False) + RIGHT_MARGIN = ("right-margin", False) + RISE = ("rise", False) + SCALE = ("scale", False) + SIZE = ("size", True) + STRETCH = ("stretch", False) + STRIKETHROUGH = ("strikethrough", True) + STYLE = ("style", True) + TEXT_DECORATION = ("text-decoration", True) + TEXT_POSITION = ("text-position", False) + TEXT_ROTATION = ("text-rotation", True) + TEXT_SHADOW = ("text-shadow", False) + UNDERLINE = ("underline", True) + VARIANT = ("variant", False) + VERTICAL_ALIGN = ("vertical-align", False) + WEIGHT = ("weight", True) + WRAP_MODE = ("wrap-mode", False) + WRITING_MODE = ("writing-mode", False) + + @classmethod + def from_string(cls, string: str) -> "AXTextAttribute" | None: + """Returns the AXTextAttribute for the specified string.""" + + for attribute in cls: + if attribute.get_attribute_name() == string: + return attribute + + return None + + @classmethod + def from_localized_string(cls, string: str) -> "AXTextAttribute" | None: + """Returns the AXTextAttribute for the specified localized string.""" + + for attribute in cls: + if attribute.get_localized_name() == string: + return attribute + + return None + + def get_attribute_name(self) -> str: + """Returns the non-localized name of the attribute.""" + + return self.value[0] + + def get_localized_name(self) -> str: + """Returns the localized name of the attribute.""" + + name = self.value[0] + return text_attribute_names.attribute_names.get(name, name) + + def get_localized_value(self, value) -> str: + """Returns the localized value of the attribute.""" + + if value is None: + return "" + + if value.endswith("px"): + value = value.split("px")[0] + if locale.localeconv()["decimal_point"] in value: + return messages.pixel_count(float(value)) + return messages.pixel_count(int(value)) + + if self in [AXTextAttribute.BG_COLOR, AXTextAttribute.FG_COLOR]: + return colornames.get_presentable_color_name(value) + + # TODO - JD: Is this still needed? + value = value.replace("-moz", "") + + # TODO - JD: Are these still needed? + if self == AXTextAttribute.JUSTIFICATION: + value = value.replace("justify", "fill") + elif self == AXTextAttribute.FAMILY_NAME: + value = value.split(",")[0].strip().strip('"') + + return text_attribute_names.attribute_values.get(value, value) + + def should_present_by_default(self) -> bool: + """Returns True if the attribute should be presented by default.""" + + return self.value[1] + + def value_is_default(self, value) -> bool: + """Returns True if value should be treated as the default value for this attribute.""" + + null_values = ["0", "0mm", "0px", "none", "false", "normal", "", None] + if value in null_values: + return True + + if self == AXTextAttribute.SCALE: + return float(value) == 1.0 + if self == AXTextAttribute.TEXT_POSITION: + return value == "baseline" + if self == AXTextAttribute.WEIGHT: + return value == "400" + if self == AXTextAttribute.LANGUAGE: + loc = locale.getlocale()[0] or "" + return value == loc[:2] + + return False + +class AXText: + """Utilities for obtaining information about accessible text.""" + + CACHED_TEXT_SELECTION: dict[int, tuple[str, int, int]] = {} + + @staticmethod + def get_character_at_offset( + obj: Atspi.Accessible, + offset: int | None = None + ) -> tuple[str, int, int]: + """Returns the (character, start, end) for the current or specified offset.""" + + length = AXText.get_character_count(obj) + if not length: + return "", 0, 0 + + if offset is None: + offset = AXText.get_caret_offset(obj) + + if not 0 <= offset <= length: + msg = f"WARNING: Offset {offset} is not valid. No character can be provided." + debug.print_message(debug.LEVEL_INFO, msg, True) + return "", 0, 0 + + try: + result = Atspi.Text.get_string_at_offset(obj, offset, Atspi.TextGranularity.CHAR) + except GLib.GError as error: + msg = f"AXText: Exception in get_character_at_offset: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return "", 0, 0 + + if result is None: + tokens = ["AXText: get_string_at_offset (char) failed for", obj, f"at {offset}."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return "", 0, 0 + + debug_string = result.content.replace("\n", "\\n") + tokens = [f"AXText: Character at offset {offset} in", obj, + f"'{debug_string}' ({result.start_offset}-{result.end_offset})"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result.content, result.start_offset, result.end_offset + + @staticmethod + def get_character_at_point(obj: Atspi.Accessible, x: int, y: int) -> tuple[str, int, int]: + """Returns the (character, start, end) at the specified point.""" + + offset = AXText.get_offset_at_point(obj, x, y) + if not 0 <= offset < AXText.get_character_count(obj): + return "", 0, 0 + + return AXText.get_character_at_offset(obj, offset) + + @staticmethod + def get_next_character( + obj: Atspi.Accessible, + offset: int | None = None + ) -> tuple[str, int, int]: + """Returns the next (character, start, end) for the current or specified offset.""" + + if offset is None: + offset = AXText.get_caret_offset(obj) + + current_character, start, end = AXText.get_character_at_offset(obj, offset) + if not current_character: + return "", 0, 0 + + length = AXText.get_character_count(obj) + next_offset = max(end, offset + 1) + + while next_offset < length: + next_character, next_start, next_end = AXText.get_character_at_offset(obj, next_offset) + if (next_character, next_start, next_end) != (current_character, start, end): + return next_character, next_start, next_end + next_offset += 1 + + return "", 0, 0 + + @staticmethod + def get_previous_character( + obj: Atspi.Accessible, + offset: int | None = None + ) -> tuple[str, int, int]: + """Returns the previous (character, start, end) for the current or specified offset.""" + + if offset is None: + offset = AXText.get_caret_offset(obj) + + current_character, start, end = AXText.get_character_at_offset(obj, offset) + if not current_character: + return "", 0, 0 + + if start <= 0: + return "", 0, 0 + + prev_offset = start - 1 + + while prev_offset >= 0: + prev_character, prev_start, prev_end = AXText.get_character_at_offset(obj, prev_offset) + if (prev_character, prev_start, prev_end) != (current_character, start, end): + return prev_character, prev_start, prev_end + prev_offset -= 1 + + return "", 0, 0 + + @staticmethod + def iter_character( + obj: Atspi.Accessible, + offset: int | None = None + ) -> Generator[tuple[str, int, int], None, None]: + """Generator to iterate by character in obj starting with the character at offset.""" + + if offset is None: + offset = AXText.get_caret_offset(obj) + + last_result = None + length = AXText.get_character_count(obj) + while offset < length: + character, start, end = AXText.get_character_at_offset(obj, offset) + if last_result is None and not character: + return + if character and (character, start, end) != last_result: + yield character, start, end + offset = max(end, offset + 1) + last_result = character, start, end + + @staticmethod + def get_word_at_offset( + obj: Atspi.Accessible, + offset: int | None = None + ) -> tuple[str, int, int]: + """Returns the (word, start, end) for the current or specified offset.""" + + length = AXText.get_character_count(obj) + if not length: + return "", 0, 0 + + if offset is None: + offset = AXText.get_caret_offset(obj) + + offset = min(max(0, offset), length - 1) + try: + result = Atspi.Text.get_string_at_offset(obj, offset, Atspi.TextGranularity.WORD) + except GLib.GError as error: + msg = f"AXText: Exception in get_word_at_offset: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return "", 0, 0 + + if result is None: + tokens = ["AXText: get_string_at_offset (word) failed for", obj, f"at {offset}."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return "", 0, 0 + + tokens = [f"AXText: Word at offset {offset} in", obj, + f"'{result.content}' ({result.start_offset}-{result.end_offset})"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result.content, result.start_offset, result.end_offset + + @staticmethod + def get_word_at_point(obj: Atspi.Accessible, x: int, y: int) -> tuple[str, int, int]: + """Returns the (word, start, end) at the specified point.""" + + offset = AXText.get_offset_at_point(obj, x, y) + if not 0 <= offset < AXText.get_character_count(obj): + return "", 0, 0 + + return AXText.get_word_at_offset(obj, offset) + + @staticmethod + def get_next_word( + obj: Atspi.Accessible, + offset: int | None = None + ) -> tuple[str, int, int]: + """Returns the next (word, start, end) for the current or specified offset.""" + + if offset is None: + offset = AXText.get_caret_offset(obj) + + current_word, start, end = AXText.get_word_at_offset(obj, offset) + if not current_word: + return "", 0, 0 + + length = AXText.get_character_count(obj) + next_offset = max(end, offset + 1) + + while next_offset < length: + next_word, next_start, next_end = AXText.get_word_at_offset(obj, next_offset) + if (next_word, next_start, next_end) != (current_word, start, end): + return next_word, next_start, next_end + next_offset += 1 + + return "", 0, 0 + + @staticmethod + def get_previous_word( + obj: Atspi.Accessible, + offset: int | None = None + ) -> tuple[str, int, int]: + """Returns the previous (word, start, end) for the current or specified offset.""" + + if offset is None: + offset = AXText.get_caret_offset(obj) + + current_word, start, end = AXText.get_word_at_offset(obj, offset) + if not current_word: + return "", 0, 0 + + if start <= 0: + return "", 0, 0 + + prev_offset = start - 1 + + while prev_offset >= 0: + prev_word, prev_start, prev_end = AXText.get_word_at_offset(obj, prev_offset) + if (prev_word, prev_start, prev_end) != (current_word, start, end): + return prev_word, prev_start, prev_end + prev_offset -= 1 + + return "", 0, 0 + + @staticmethod + def iter_word( + obj: Atspi.Accessible, + offset: int | None = None + ) -> Generator[tuple[str, int, int], None, None]: + """Generator to iterate by word in obj starting with the word at offset.""" + + if offset is None: + offset = AXText.get_caret_offset(obj) + + last_result = None + length = AXText.get_character_count(obj) + while offset < length: + word, start, end = AXText.get_word_at_offset(obj, offset) + if last_result is None and not word: + return + if word and (word, start, end) != last_result: + yield word, start, end + offset = max(end, offset + 1) + last_result = word, start, end + + @staticmethod + def get_line_at_offset( + obj: Atspi.Accessible, + offset: int | None = None + ) -> tuple[str, int, int]: + """Returns the (line, start, end) for the current or specified offset.""" + + length = AXText.get_character_count(obj) + if not length: + return "", 0, 0 + + if offset is None: + offset = AXText.get_caret_offset(obj) + + # Don't adjust the length in multiline text because we want to say "blank" at the end. + # This may or may not be sufficient. GTK3 seems to give us the correct, empty line. But + # (at least) Chromium does not. See comment below. + if not AXUtilitiesState.is_multi_line(obj) \ + and not AXUtilitiesRole.is_paragraph(obj) and not AXUtilitiesRole.is_section(obj): + offset = min(max(0, offset), length - 1) + else: + offset = max(0, offset) + + try: + result = Atspi.Text.get_string_at_offset(obj, offset, Atspi.TextGranularity.LINE) + except GLib.GError as error: + msg = f"AXText: Exception in get_line_at_offset: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return "", 0, 0 + + if result is None: + tokens = ["AXText: get_string_at_offset (line) failed for", obj, f"at {offset}."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return "", 0, 0 + + # Try again, e.g. Chromium returns "", -1, -1. + if result.start_offset == result.end_offset == -1 and offset == length: + offset -= 1 + result = Atspi.Text.get_string_at_offset(obj, offset, Atspi.TextGranularity.LINE) + + debug_string = result.content.replace("\n", "\\n") + tokens = [f"AXText: Line at offset {offset} in", obj, + f"'{debug_string}' ({result.start_offset}-{result.end_offset})"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if 0 <= offset < result.start_offset: + offset -= 1 + msg = f"ERROR: Start offset is greater than offset. Trying with offset {offset}" + debug.print_message(debug.LEVEL_INFO, msg, True) + result = Atspi.Text.get_string_at_offset(obj, offset, Atspi.TextGranularity.LINE) + + debug_string = result.content.replace("\n", "\\n") + tokens = [f"AXText: Line at offset {offset} in", obj, + f"'{debug_string}' ({result.start_offset}-{result.end_offset})"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + return result.content, result.start_offset, result.end_offset + + @staticmethod + def get_line_at_point(obj: Atspi.Accessible, x: int, y: int) -> tuple[str, int, int]: + """Returns the (line, start, end) at the specified point.""" + + offset = AXText.get_offset_at_point(obj, x, y) + if not 0 <= offset < AXText.get_character_count(obj): + return "", 0, 0 + + return AXText.get_line_at_offset(obj, offset) + + @staticmethod + def get_next_line( + obj: Atspi.Accessible, + offset: int | None = None + ) -> tuple[str, int, int]: + """Returns the next (line, start, end) for the current or specified offset.""" + + if offset is None: + offset = AXText.get_caret_offset(obj) + + current_line, start, end = AXText.get_line_at_offset(obj, offset) + if not current_line: + return "", 0, 0 + + length = AXText.get_character_count(obj) + next_offset = max(end, offset + 1) + + while next_offset < length: + next_line, next_start, next_end = AXText.get_line_at_offset(obj, next_offset) + if (next_line, next_start, next_end) != (current_line, start, end): + return next_line, next_start, next_end + next_offset += 1 + + return "", 0, 0 + + @staticmethod + def get_previous_line( + obj: Atspi.Accessible, + offset: int | None = None + ) -> tuple[str, int, int]: + """Returns the previous (line, start, end) for the current or specified offset.""" + + if offset is None: + offset = AXText.get_caret_offset(obj) + + current_line, start, end = AXText.get_line_at_offset(obj, offset) + if not current_line and offset == AXText.get_character_count(obj): + current_line, start, end = AXText.get_line_at_offset(obj, offset - 1) + if current_line.endswith("\n"): + start = offset - 1 + + if not current_line or start <= 0: + return "", 0, 0 + + prev_offset = start - 1 + + while prev_offset >= 0: + prev_line, prev_start, prev_end = AXText.get_line_at_offset(obj, prev_offset) + if (prev_line, prev_start, prev_end) != (current_line, start, end): + return prev_line, prev_start, prev_end + prev_offset -= 1 + + return "", 0, 0 + + @staticmethod + def iter_line( + obj: Atspi.Accessible, + offset: int | None = None + ) -> Generator[tuple[str, int, int], None, None]: + """Generator to iterate by line in obj starting with the line at offset.""" + + line, start, end = AXText.get_line_at_offset(obj, offset) + if not line: + return + + # If the caller provides an offset positioned at the end boundary of the + # current line (e.g. start iteration from the previous line's end), some + # implementations of Atspi return the same line again for that offset. + # To avoid yielding duplicates (e.g. in get_visible_lines()), only yield + # the current line when the offset points inside it; otherwise start with + # the next distinct line. + if offset is None or offset < end: + yield line, start, end + current_start = start + + while True: + next_line, next_start, next_end = AXText.get_next_line(obj, current_start) + if not next_line or next_start <= current_start: + break + yield next_line, next_start, next_end + current_start = next_start + + @staticmethod + def _find_sentence_boundaries(text: str) -> list[int]: + """Returns the offsets in text that should be treated as sentence beginnings.""" + + if not text: + return [] + + boundaries = [0] + pattern = r"[.!?]+(?=\s|\ufffc|$)" + for match in re.finditer(pattern, text): + end_pos = match.end() + # Skip whitespace and embedded objects to find start of next sentence. + while end_pos < len(text) and (text[end_pos].isspace() or text[end_pos] == "\ufffc"): + end_pos += 1 + # Only add boundary if we haven't reached the end and it's not a duplicate. + if end_pos < len(text) and end_pos not in boundaries: + boundaries.append(end_pos) + + if boundaries[-1] != len(text): + boundaries.append(len(text)) + + return boundaries + + @staticmethod + def has_sentence_ending(text: str) -> bool: + """Check if text contains a sentence ending.""" + + return bool(text and re.search(r"\S[.!?]+(\s|\ufffc|$)", text)) + + @staticmethod + def _get_sentence_at_offset_fallback( + obj: Atspi.Accessible, + offset: int | None = None + ) -> tuple[str, int, int]: + """Fallback sentence detection for broken implementations.""" + + if offset is None: + offset = AXText.get_caret_offset(obj) + + text = AXText.get_all_text(obj) + if not text or offset < 0 or offset >= len(text): + return "", 0, 0 + + fallback_text, fallback_start, fallback_end = text, 0, len(text) + boundaries = AXText._find_sentence_boundaries(text) + for i in range(len(boundaries) - 1): + start, end = boundaries[i], boundaries[i + 1] + if start <= offset < end: + fallback_text, fallback_start, fallback_end = text[start:end], start, end + break + + tokens = ["AXText: Fallback sentence in", obj, + f" at offset {offset}: '{fallback_text}' ({fallback_start}-{fallback_end})"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return fallback_text, fallback_start, fallback_end + + @staticmethod + def get_sentence_at_offset( + obj: Atspi.Accessible, + offset: int | None = None + ) -> tuple[str, int, int]: + """Returns the (sentence, start, end) for the current or specified offset.""" + + length = AXText.get_character_count(obj) + if not length: + return "", 0, 0 + + if offset is None: + offset = AXText.get_caret_offset(obj) + + offset = min(max(0, offset), length - 1) + try: + result = Atspi.Text.get_string_at_offset(obj, offset, Atspi.TextGranularity.SENTENCE) + except GLib.GError as error: + msg = f"AXText: Exception in get_sentence_at_offset: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return AXText._get_sentence_at_offset_fallback(obj, offset) + + if result is None: + tokens = ["AXText: get_string_at_offset (sentence) failed for", obj, f"at {offset}."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return "", 0, 0 + + if result.start_offset == result.end_offset == -1 or not result.content: + return AXText._get_sentence_at_offset_fallback(obj, offset) + + if (result.start_offset == result.end_offset and + result.start_offset in [0, -1] and not result.content): + return AXText._get_sentence_at_offset_fallback(obj, offset) + + tokens = [f"AXText: Sentence at offset {offset} in", obj, + f"'{result.content}' ({result.start_offset}-{result.end_offset})"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result.content, result.start_offset, result.end_offset + + @staticmethod + def get_sentence_at_point(obj: Atspi.Accessible, x: int, y: int) -> tuple[str, int, int]: + """Returns the (sentence, start, end) at the specified point.""" + + offset = AXText.get_offset_at_point(obj, x, y) + if not 0 <= offset < AXText.get_character_count(obj): + return "", 0, 0 + + return AXText.get_sentence_at_offset(obj, offset) + + @staticmethod + def get_next_sentence( + obj: Atspi.Accessible, + offset: int | None = None + ) -> tuple[str, int, int]: + """Returns the next (sentence, start, end) for the current or specified offset.""" + + if offset is None: + offset = AXText.get_caret_offset(obj) + + current_sentence, start, end = AXText.get_sentence_at_offset(obj, offset) + if not current_sentence: + return "", 0, 0 + + length = AXText.get_character_count(obj) + next_offset = max(end, offset + 1) + + while next_offset < length: + next_sentence, next_start, next_end = AXText.get_sentence_at_offset(obj, next_offset) + if (next_sentence, next_start, next_end) != (current_sentence, start, end): + return next_sentence, next_start, next_end + next_offset += 1 + + return "", 0, 0 + + @staticmethod + def get_previous_sentence( + obj: Atspi.Accessible, + offset: int | None = None + ) -> tuple[str, int, int]: + """Returns the previous (sentence, start, end) for the current or specified offset.""" + + if offset is None: + offset = AXText.get_caret_offset(obj) + + current_sentence, start, end = AXText.get_sentence_at_offset(obj, offset) + if not current_sentence: + return "", 0, 0 + + if start <= 0: + return "", 0, 0 + + prev_offset = start - 1 + + while prev_offset >= 0: + prev_sentence, prev_start, prev_end = AXText.get_sentence_at_offset(obj, prev_offset) + if (prev_sentence, prev_start, prev_end) != (current_sentence, start, end): + return prev_sentence, prev_start, prev_end + prev_offset -= 1 + + return "", 0, 0 + + @staticmethod + def iter_sentence( + obj: Atspi.Accessible, + offset: int | None = None + ) -> Generator[tuple[str, int, int], None, None]: + """Generator to iterate by sentence in obj starting with the sentence at offset.""" + + sentence, start, end = AXText.get_sentence_at_offset(obj, offset) + if not sentence: + return + + # Avoid yielding a duplicate when the starting offset is exactly at the + # end boundary of the current sentence. Some implementations can return + # the same (sentence, start, end) again for that offset. + if offset is None or offset < end: + yield sentence, start, end + current_start = start + + while True: + next_sentence, next_start, next_end = AXText.get_next_sentence(obj, current_start) + if not next_sentence or next_start <= current_start: + break + yield next_sentence, next_start, next_end + current_start = next_start + + @staticmethod + def get_paragraph_at_offset( + obj: Atspi.Accessible, + offset: int | None = None + ) -> tuple[str, int, int]: + """Returns the (paragraph, start, end) for the current or specified offset.""" + + length = AXText.get_character_count(obj) + if not length: + return "", 0, 0 + + if offset is None: + offset = AXText.get_caret_offset(obj) + + offset = min(max(0, offset), length - 1) + try: + result = Atspi.Text.get_string_at_offset(obj, offset, Atspi.TextGranularity.PARAGRAPH) + except GLib.GError as error: + msg = f"AXText: Exception in get_paragraph_at_offset: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return "", 0, 0 + + if result is None: + tokens = ["AXText: get_string_at_offset (paragraph) failed for", obj, f"at {offset}."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return "", 0, 0 + + tokens = [f"AXText: Paragraph at offset {offset} in", obj, + f"'{result.content}' ({result.start_offset}-{result.end_offset})"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result.content, result.start_offset, result.end_offset + + @staticmethod + def get_paragraph_at_point(obj: Atspi.Accessible, x: int, y: int) -> tuple[str, int, int]: + """Returns the (paragraph, start, end) at the specified point.""" + + offset = AXText.get_offset_at_point(obj, x, y) + if not 0 <= offset < AXText.get_character_count(obj): + return "", 0, 0 + + return AXText.get_paragraph_at_offset(obj, offset) + + @staticmethod + def get_next_paragraph( + obj: Atspi.Accessible, + offset: int | None = None + ) -> tuple[str, int, int]: + """Returns the next (paragraph, start, end) for the current or specified offset.""" + + if offset is None: + offset = AXText.get_caret_offset(obj) + + current_paragraph, start, end = AXText.get_paragraph_at_offset(obj, offset) + if not current_paragraph: + return "", 0, 0 + + length = AXText.get_character_count(obj) + next_offset = max(end, offset + 1) + + while next_offset < length: + next_paragraph, next_start, next_end = AXText.get_paragraph_at_offset(obj, next_offset) + if (next_paragraph, next_start, next_end) != (current_paragraph, start, end): + return next_paragraph, next_start, next_end + next_offset += 1 + + return "", 0, 0 + + @staticmethod + def get_previous_paragraph( + obj: Atspi.Accessible, + offset: int | None = None + ) -> tuple[str, int, int]: + """Returns the previous (paragraph, start, end) for the current or specified offset.""" + + if offset is None: + offset = AXText.get_caret_offset(obj) + + current_paragraph, start, end = AXText.get_paragraph_at_offset(obj, offset) + if not current_paragraph: + return "", 0, 0 + + if start <= 0: + return "", 0, 0 + + prev_offset = start - 1 + + while prev_offset >= 0: + prev_paragraph, prev_start, prev_end = AXText.get_paragraph_at_offset(obj, prev_offset) + if (prev_paragraph, prev_start, prev_end) != (current_paragraph, start, end): + return prev_paragraph, prev_start, prev_end + prev_offset -= 1 + + return "", 0, 0 + + @staticmethod + def iter_paragraph( + obj: Atspi.Accessible, offset: int | None = None + ) -> Generator[tuple[str, int, int], None, None]: + """Generator to iterate by paragraph in obj starting with the paragraph at offset.""" + + if offset is None: + offset = AXText.get_caret_offset(obj) + + last_result = None + length = AXText.get_character_count(obj) + while offset < length: + paragraph, start, end = AXText.get_paragraph_at_offset(obj, offset) + if last_result is None and not paragraph: + return + if paragraph and (paragraph, start, end) != last_result: + yield paragraph, start, end + offset = max(end, offset + 1) + last_result = paragraph, start, end + + @staticmethod + def supports_paragraph_iteration(obj: Atspi.Accessible) -> bool: + """Returns True if paragraph iteration is supported on obj.""" + + if not AXObject.supports_text(obj): + return False + + string, start, end = AXText.get_paragraph_at_offset(obj, 0) + result = string and 0 <= start < end + tokens = ["AXText: Paragraph iteration supported on", obj, f": {result}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return bool(result) + + @staticmethod + def get_character_count(obj: Atspi.Accessible) -> int: + """Returns the character count of obj.""" + + if not AXObject.supports_text(obj): + return 0 + + try: + count = Atspi.Text.get_character_count(obj) + except GLib.GError as error: + msg = f"AXText: Exception in get_character_count: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return 0 + + tokens = ["AXText:", obj, f"reports {count} characters."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return count + + @staticmethod + def get_caret_offset(obj: Atspi.Accessible) -> int: + """Returns the caret offset of obj.""" + + if not AXObject.supports_text(obj): + return -1 + + try: + offset = Atspi.Text.get_caret_offset(obj) + except GLib.GError as error: + msg = f"AXText: Exception in get_caret_offset: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return -1 + + tokens = ["AXText:", obj, f"reports caret offset of {offset}."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return offset + + @staticmethod + def set_caret_offset(obj: Atspi.Accessible, offset: int) -> bool: + """Returns False if we definitely failed to set the offset. True cannot be trusted.""" + + if not AXObject.supports_text(obj): + return False + + try: + result = Atspi.Text.set_caret_offset(obj, offset) + except GLib.GError as error: + msg = f"AXText: Exception in set_caret_offset: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + tokens = [f"AXText: Reported result of setting offset to {offset} in", obj, f": {result}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def set_caret_offset_to_start(obj: Atspi.Accessible) -> bool: + """Returns False if we definitely failed to set the offset. True cannot be trusted.""" + + return AXText.set_caret_offset(obj, 0) + + @staticmethod + def set_caret_offset_to_end(obj: Atspi.Accessible) -> bool: + """Returns False if we definitely failed to set the offset. True cannot be trusted.""" + + return AXText.set_caret_offset(obj, AXText.get_character_count(obj)) + + @staticmethod + def get_substring(obj: Atspi.Accessible, start_offset: int, end_offset: int) -> str: + """Returns the text of obj within the specified offsets.""" + + if not AXObject.supports_text(obj): + return "" + + if end_offset == -1: + end_offset = AXText.get_character_count(obj) + + try: + result = Atspi.Text.get_text(obj, start_offset, end_offset) + except GLib.GError as error: + msg = f"AXText: Exception in get_substring: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return "" + + debug_string = result.replace("\n", "\\n") + tokens = ["AXText: Text of", obj, f"({start_offset}-{end_offset}): '{debug_string}'"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def get_all_text(obj: Atspi.Accessible) -> str: + """Returns the text content of obj.""" + + length = AXText.get_character_count(obj) + if not length: + return "" + + try: + result = Atspi.Text.get_text(obj, 0, length) + except GLib.GError as error: + msg = f"AXText: Exception in get_all_text: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return "" + + words = result.split() + if len(words) > 20: + debug_string = f"{' '.join(words[:5])} ... {' '.join(words[-5:])}" + else: + debug_string = result + + debug_string = debug_string.replace("\n", "\\n") + tokens = ["AXText: Text of", obj, f"'{debug_string}'"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def _get_n_selections(obj: Atspi.Accessible) -> int: + """Returns the number of reported selected substrings in obj.""" + + if not AXObject.supports_text(obj): + return 0 + + try: + result = Atspi.Text.get_n_selections(obj) + except GLib.GError as error: + msg = f"AXText: Exception in _get_n_selections: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return 0 + + tokens = ["AXText:", obj, f"reports {result} selection(s)."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def _remove_selection(obj: Atspi.Accessible, selection_number: int) -> None: + """Attempts to remove the specified selection.""" + + if not AXObject.supports_text(obj): + return + + try: + Atspi.Text.remove_selection(obj, selection_number) + except GLib.GError as error: + msg = f"AXText: Exception in _remove_selection: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return + + @staticmethod + def has_selected_text(obj: Atspi.Accessible) -> bool: + """Returns True if obj has selected text.""" + + return bool(AXText.get_selected_ranges(obj)) + + @staticmethod + def is_all_text_selected(obj: Atspi.Accessible) -> bool: + """Returns True of all the text in obj is selected.""" + + length = AXText.get_character_count(obj) + if not length: + return False + + ranges = AXText.get_selected_ranges(obj) + if not ranges: + return False + + return ranges[0][0] == 0 and ranges[-1][1] == length + + @staticmethod + def clear_all_selected_text(obj: Atspi.Accessible) -> None: + """Attempts to clear the selected text.""" + + for i in range(AXText._get_n_selections(obj)): + AXText._remove_selection(obj, i) + + @staticmethod + def get_selection_start_offset(obj: Atspi.Accessible) -> int: + """Returns the leftmost offset of the selected text.""" + + ranges = AXText.get_selected_ranges(obj) + if ranges: + return ranges[0][0] + + return -1 + + @staticmethod + def get_selection_end_offset(obj: Atspi.Accessible) -> int: + """Returns the rightmost offset of the selected text.""" + + ranges = AXText.get_selected_ranges(obj) + if ranges: + return ranges[-1][1] + + return -1 + + @staticmethod + def get_selected_ranges(obj: Atspi.Accessible) -> list[tuple[int, int]]: + """Returns a list of (start_offset, end_offset) tuples reflecting the selected text.""" + + count = AXText._get_n_selections(obj) + if not count: + return [] + + selections = [] + for i in range(count): + try: + result = Atspi.Text.get_selection(obj, i) + except GLib.GError as error: + msg = f"AXText: Exception in get_selected_ranges: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + break + if 0 <= result.start_offset < result.end_offset: + selections.append((result.start_offset, result.end_offset)) + + tokens = ["AXText:", obj, f"reports selected ranges: {selections}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return selections + + @staticmethod + def get_cached_selected_text(obj: Atspi.Accessible) -> tuple[str, int, int]: + """Returns the last known selected string, start, and end for obj.""" + + string, start, end = AXText.CACHED_TEXT_SELECTION.get(hash(obj), ("", 0, 0)) + debug_string = string.replace("\n", "\\n") + tokens = ["AXText: Cached selection for", obj, f"is '{debug_string}' ({start}, {end})"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return string, start, end + + @staticmethod + def update_cached_selected_text(obj: Atspi.Accessible) -> None: + """Updates the last known selected string, start, and end for obj.""" + + AXText.CACHED_TEXT_SELECTION[hash(obj)] = AXText.get_selected_text(obj) + + @staticmethod + def get_selected_text(obj: Atspi.Accessible) -> tuple[str, int, int]: + """Returns the selected string, start, and end for obj.""" + + selections = AXText.get_selected_ranges(obj) + if not selections: + return "", 0, 0 + + strings = [] + start_offset = -1 + end_offset = -1 + for selection in sorted(set(selections)): + strings.append(AXText.get_substring(obj, *selection)) + end_offset = selection[1] + if start_offset == -1: + start_offset = selection[0] + + text = " ".join(strings) + words = text.split() + if len(words) > 20: + debug_string = f"{' '.join(words[:5])} ... {' '.join(words[-5:])}" + else: + debug_string = text + + tokens = ["AXText: Selected text of", obj, + f"'{debug_string}' ({start_offset}-{end_offset})"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return text, start_offset, end_offset + + @staticmethod + def _add_new_selection(obj: Atspi.Accessible, start_offset: int, end_offset: int) -> bool: + """Creates a new selection for the specified range in obj.""" + + if not AXObject.supports_text(obj): + return False + + try: + result = Atspi.Text.add_selection(obj, start_offset, end_offset) + except GLib.GError as error: + msg = f"AXText: Exception in _add_selection: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + return result + + @staticmethod + def _update_existing_selection( + obj: Atspi.Accessible, + start_offset: int, + end_offset: int, + selection_number: int = 0 + ) -> bool: + """Modifies specified selection in obj to the specified range.""" + + if not AXObject.supports_text(obj): + return False + + try: + result = Atspi.Text.set_selection(obj, selection_number, start_offset, end_offset) + except GLib.GError as error: + msg = f"AXText: Exception in set_selected_text: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + return result + + @staticmethod + def set_selected_text(obj: Atspi.Accessible, start_offset: int, end_offset: int) -> bool: + """Returns False if we definitely failed to set the selection. True cannot be trusted.""" + + # TODO - JD: For now we always assume and operate on the first selection. + # This preserves the original functionality prior to the refactor. But whether + # that functionality is what it should be needs investigation. + if AXText._get_n_selections(obj) > 0: + result = AXText._update_existing_selection(obj, start_offset, end_offset) + else: + result = AXText._add_new_selection(obj, start_offset, end_offset) + + if result and debug.LEVEL_INFO >= debug.debugLevel: + substring = AXText.get_substring(obj, start_offset, end_offset) + selection = AXText.get_selected_text(obj)[0] + if substring != selection: + msg = "AXText: Substring and selected text do not match." + debug.print_message(debug.LEVEL_INFO, msg, True) + + return result + + # TODO - JD: This should be converted to return AXTextAttribute values. + @staticmethod + def get_text_attributes_at_offset( + obj: Atspi.Accessible, + offset: int | None = None + ) -> tuple[dict[str, str], int, int]: + """Returns a (dict, start, end) tuple for attributes at offset in obj.""" + + if not AXObject.supports_text(obj): + return {}, 0, 0 + + if offset is None: + offset = AXText.get_caret_offset(obj) + + try: + result = Atspi.Text.get_attribute_run(obj, offset, include_defaults=True) + except GLib.GError as error: + msg = f"AXText: Exception in get_text_attributes_at_offset: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return {}, 0, AXText.get_character_count(obj) + + if result is None: + tokens = ["AXText: get_attribute_run failed for", obj, f"at offset {offset}."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return {}, 0, AXText.get_character_count(obj) + + tokens = ["AXText: Attributes for", obj, f"at offset {offset} : {result}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + # Adjust for web browsers that report indentation and justification at object attributes + # rather than text attributes. + obj_attributes = AXObject.get_attributes_dict(obj, False) + if not result[0].get("justification"): + alternative = obj_attributes.get("text-align") + if alternative: + result[0]["justification"] = alternative + if not result[0].get("indent"): + alternative = obj_attributes.get("text-indent") + if alternative: + result[0]["indent"] = alternative + + return result[0] or {}, result[1] or 0, result[2] or AXText.get_character_count(obj) + + @staticmethod + def get_all_text_attributes( + obj: Atspi.Accessible, + start_offset: int = 0, + end_offset: int = -1 + ) -> list[tuple[int, int, dict[str, str]]]: + """Returns a list of (start, end, attrs dict) tuples for obj.""" + + if not AXObject.supports_text(obj): + return [] + + if end_offset == -1: + end_offset = AXText.get_character_count(obj) + + tokens = ["AXText: Getting attributes for", obj, f"chars: {start_offset}-{end_offset}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + rv = [] + offset = start_offset + while offset < end_offset: + attrs, start, end = AXText.get_text_attributes_at_offset(obj, offset) + if start <= end: + rv.append((max(start, offset), end, attrs)) + else: + # TODO - JD: We're sometimes seeing this from WebKit, e.g. in Evo gitlab messages. + msg = f"AXText: Start offset {start} > end offset {end}" + debug.print_message(debug.LEVEL_INFO, msg, True) + offset = max(end, offset + 1) + + msg = f"AXText: {len(rv)} attribute ranges found." + debug.print_message(debug.LEVEL_INFO, msg, True) + return rv + + @staticmethod + def get_all_supported_text_attributes() -> list[AXTextAttribute]: + """Returns a set of all supported text attribute names.""" + + return list(AXTextAttribute) + + @staticmethod + def get_offset_at_point(obj: Atspi.Accessible, x: int, y: int) -> int: + """Returns the character offset in obj at the specified point.""" + + if not AXObject.supports_text(obj): + return -1 + + try: + offset = Atspi.Text.get_offset_at_point(obj, x, y, Atspi.CoordType.WINDOW) + except GLib.GError as error: + msg = f"AXText: Exception in get_offset_at_point: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return -1 + + tokens = ["AXText: Offset in", obj, f"at {x}, {y} is {offset}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return offset + + @staticmethod + def get_character_rect(obj: Atspi.Accessible, offset: int | None = None) -> Atspi.Rect: + """Returns the Atspi rect of the character at the specified offset in obj.""" + + if not AXObject.supports_text(obj): + return Atspi.Rect() + + if offset is None: + offset = AXText.get_caret_offset(obj) + + try: + rect = Atspi.Text.get_character_extents(obj, offset, Atspi.CoordType.WINDOW) + except GLib.GError as error: + msg = f"AXText: Exception in get_character_rect: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return Atspi.Rect() + + tokens = [f"AXText: Offset {offset} in", obj, "has rect", rect] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return rect + + @staticmethod + def get_range_rect(obj: Atspi.Accessible, start: int, end: int) -> Atspi.Rect: + """Returns the Atspi rect of the string at the specified range in obj.""" + + if not AXObject.supports_text(obj): + return Atspi.Rect() + + if end <= 0: + end = AXText.get_character_count(obj) + + try: + rect = Atspi.Text.get_range_extents(obj, start, end, Atspi.CoordType.WINDOW) + except GLib.GError as error: + msg = f"AXText: Exception in get_range_rect: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return Atspi.Rect() + + tokens = [f"AXText: Range {start}-{end} in", obj, "has rect", rect] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return rect + + @staticmethod + def _rect_is_fully_contained_in(rect1: Atspi.Rect, rect2: Atspi.Rect) -> bool: + """Returns true if rect1 is fully contained in rect2""" + + return rect2.x <= rect1.x and rect2.y <= rect1.y \ + and rect2.x + rect2.width >= rect1.x + rect1.width \ + and rect2.y + rect2.height >= rect1.y + rect1.height + + @staticmethod + def _line_comparison(line_rect: Atspi.Rect, clip_rect: Atspi.Rect) -> int: + """Returns -1 (line above), 1 (line below), or 0 (line inside) clip_rect.""" + + # https://gitlab.gnome.org/GNOME/gtk/-/issues/6419 + clip_rect.y = max(0, clip_rect.y) + + if line_rect.y + line_rect.height / 2 < clip_rect.y: + return -1 + + if line_rect.y + line_rect.height / 2 > clip_rect.y + clip_rect.height: + return 1 + + return 0 + + @staticmethod + def get_visible_lines( + obj: Atspi.Accessible, + clip_rect: Atspi.Rect + ) -> list[tuple[str, int, int]]: + """Returns a list of (string, start, end) for lines of obj inside clip_rect.""" + + tokens = ["AXText: Getting visible lines for", obj, "inside", clip_rect] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + line, start, end = AXText.find_first_visible_line(obj, clip_rect) + debug_string = line.replace("\n", "\\n") + tokens = ["AXText: First visible line in", obj, f"is: '{debug_string}' ({start}-{end})"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + result = [(line, start, end)] + offset = end + for line, start, end in AXText.iter_line(obj, offset): + line_rect = AXText.get_range_rect(obj, start, end) + if AXText._line_comparison(line_rect, clip_rect) > 0: + break + result.append((line, start, end)) + + line, start, end = result[-1] + debug_string = line.replace("\n", "\\n") + tokens = ["AXText: Last visible line in", obj, f"is: '{debug_string}' ({start}-{end})"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def find_first_visible_line( + obj: Atspi.Accessible, + clip_rect: Atspi.Rect + ) -> tuple[str, int, int]: + """Returns the first (string, start, end) visible line of obj inside clip_rect.""" + + result = "", 0, 0 + length = AXText.get_character_count(obj) + low, high = 0, length + while low < high: + mid = (low + high) // 2 + line, start, end = AXText.get_line_at_offset(obj, mid) + if start == 0: + return line, start, end + + if start < 0: + tokens = ["AXText: Treating invalid offset as above", clip_rect] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + low = mid + 1 + continue + + result = line, start, end + previous_line, previous_start, previous_end = AXText.get_line_at_offset(obj, start - 1) + if previous_start <= 0 and previous_end <= 0: + return result + + text_rect = AXText.get_range_rect(obj, start, end) + if AXText._line_comparison(text_rect, clip_rect) < 0: + low = mid + 1 + continue + + if AXText._line_comparison(text_rect, clip_rect) > 0: + high = mid + continue + + previous_rect = AXText.get_range_rect(obj, previous_start, previous_end) + if AXText._line_comparison(previous_rect, clip_rect) != 0: + return result + + result = previous_line, previous_start, previous_end + high = mid + + return result + + @staticmethod + def find_last_visible_line( + obj: Atspi.Accessible, + clip_rect: Atspi.Rect + ) -> tuple[str, int, int]: + """Returns the last (string, start, end) visible line of obj inside clip_rect.""" + + result = "", 0, 0 + length = AXText.get_character_count(obj) + low, high = 0, length + while low < high: + mid = (low + high) // 2 + line, start, end = AXText.get_line_at_offset(obj, mid) + if end >= length: + return line, start, end + + if end <= 0: + tokens = ["AXText: Treating invalid offset as below", clip_rect] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + high = mid + continue + + result = line, start, end + next_line, next_start, next_end = AXText.get_line_at_offset(obj, end) + if next_start <= 0 and next_end <= 0: + return result + + text_rect = AXText.get_range_rect(obj, start, end) + if AXText._line_comparison(text_rect, clip_rect) < 0: + low = mid + 1 + continue + + if AXText._line_comparison(text_rect, clip_rect) > 0: + high = mid + continue + + next_rect = AXText.get_range_rect(obj, next_start, next_end) + if AXText._line_comparison(next_rect, clip_rect) != 0: + return result + + result = next_line, next_start, next_end + low = mid + 1 + + return result + + @staticmethod + def string_has_spelling_error(obj: Atspi.Accessible, offset: int | None = None) -> bool: + """Returns True if the text attributes indicate a spelling error.""" + + attributes = AXText.get_text_attributes_at_offset(obj, offset)[0] + if attributes.get("invalid") == "spelling": + return True + if attributes.get("invalid") == "grammar": + return False + if attributes.get("text-spelling") == "misspelled": + return True + if attributes.get("underline") in ["error", "spelling"]: + return True + return False + + @staticmethod + def string_has_grammar_error(obj: Atspi.Accessible, offset: int | None = None) -> bool: + """Returns True if the text attributes indicate a grammar error.""" + + attributes = AXText.get_text_attributes_at_offset(obj, offset)[0] + if attributes.get("invalid") == "grammar": + return True + if attributes.get("underline") == "grammar": + return True + return False + + @staticmethod + def is_eoc(character: str) -> bool: + """Returns True if character is an embedded object character (\ufffc).""" + + return character == "\ufffc" + + @staticmethod + def character_at_offset_is_eoc(obj: Atspi.Accessible, offset: int) -> bool: + """Returns True if character in obj is an embedded object character (\ufffc).""" + + character, _start, _end = AXText.get_character_at_offset(obj, offset) + return AXText.is_eoc(character) + + @staticmethod + def is_whitespace_or_empty(obj: Atspi.Accessible) -> bool: + """Returns True if obj lacks text, or contains only whitespace.""" + + if not AXObject.supports_text(obj): + return True + + return not AXText.get_all_text(obj).strip() + + @staticmethod + def has_presentable_text(obj: Atspi.Accessible) -> bool: + """Returns True if obj has presentable text.""" + + if not AXObject.supports_text(obj): + return False + + text = AXText.get_all_text(obj).strip() + if not text: + return AXUtilitiesRole.is_paragraph(obj) + + return bool(re.search(r"\w+", text)) + + @staticmethod + def scroll_substring_to_point( + obj: Atspi.Accessible, + x: int, + y: int, + start_offset: int | None = None, + end_offset: int | None = None + ) -> bool: + """Attempts to scroll obj to the specified point.""" + + length = AXText.get_character_count(obj) + if not length: + return False + + if start_offset is None: + start_offset = 0 + if end_offset is None: + end_offset = length - 1 + + try: + result = Atspi.Text.scroll_substring_to_point( + obj, start_offset, end_offset, Atspi.CoordType.WINDOW, x, y) + except GLib.GError as error: + msg = f"AXText: Exception in scroll_substring_to_point: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + tokens = ["AXText: Scrolled", obj, f"substring ({start_offset}-{end_offset}) to", + f"{x}, {y}: {result}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def scroll_substring_to_location( + obj: Atspi.Accessible, + location: Atspi.ScrollType, + start_offset: int | None = None, + end_offset: int | None = None + ) -> bool: + """Attempts to scroll the substring to the specified Atspi.ScrollType location.""" + + length = AXText.get_character_count(obj) + if not length: + return False + + if start_offset is None: + start_offset = 0 + if end_offset is None: + end_offset = length - 1 + + try: + result = Atspi.Text.scroll_substring_to(obj, start_offset, end_offset, location) + except GLib.GError as error: + msg = f"AXText: Exception in scroll_substring_to_location: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + tokens = ["AXText: Scrolled", obj, f"substring ({start_offset}-{end_offset}) to", + location, f": {result}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result diff --git a/src/cthulhu/ax_utilities.py b/src/cthulhu/ax_utilities.py index ed36bb6..5963e4e 100644 --- a/src/cthulhu/ax_utilities.py +++ b/src/cthulhu/ax_utilities.py @@ -1,9 +1,7 @@ -#!/usr/bin/env python3 +# Utilities for performing tasks related to accessibility inspection. # -# 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 2023-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 @@ -19,35 +17,43 @@ # 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 -""" -Utilities for performing tasks related to accessibility inspection. -These utilities are app-type- and toolkit-agnostic. Utilities that might have -different implementations or results depending on the type of app (e.g. terminal, -chat, web) or toolkit (e.g. Qt, Gtk) should be in script_utilities.py file(s). +# pylint: disable=too-many-branches +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-return-statements +# pylint: disable=too-many-statements +# pylint: disable=wrong-import-position -N.B. There are currently utilities that should never have custom implementations -that live in script_utilities.py files. These will be moved over time. -""" +"""Utilities for performing tasks related to accessibility inspection.""" + +from __future__ import annotations __id__ = "$Id$" __version__ = "$Revision$" __date__ = "$Date$" -__copyright__ = "Copyright (c) 2023 Igalia, S.L." +__copyright__ = "Copyright (c) 2023-2025 Igalia, S.L." __license__ = "LGPL" +import functools import inspect +import queue +import threading +import time import gi gi.require_version("Atspi", "2.0") from gi.repository import Atspi from . import debug +from .ax_component import AXComponent from .ax_object import AXObject +from .ax_selection import AXSelection +from .ax_table import AXTable +from .ax_text import AXText +from .ax_utilities_application import AXUtilitiesApplication from .ax_utilities_collection import AXUtilitiesCollection +from .ax_utilities_event import AXUtilitiesEvent +from .ax_utilities_relation import AXUtilitiesRelation from .ax_utilities_role import AXUtilitiesRole from .ax_utilities_state import AXUtilitiesState @@ -57,91 +63,187 @@ class AXUtilities: COMPARE_COLLECTION_PERFORMANCE = False - @staticmethod - def get_desktop(): - """Returns the accessible desktop""" + # Things we cache. + SET_MEMBERS: dict[int, list[Atspi.Accessible]] = {} + IS_LAYOUT_ONLY: dict[int, tuple[bool, str]] = {} - try: - desktop = Atspi.get_desktop(0) - except Exception as error: - tokens = ["ERROR: Exception getting desktop from Atspi:", error] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return None - - return desktop + _lock = threading.Lock() @staticmethod - def get_all_applications(must_have_window=False): - """Returns a list of running applications known to Atspi, filtering out - those which have no child windows if must_have_window is True.""" + def start_cache_clearing_thread() -> None: + """Starts thread to periodically clear cached details.""" - desktop = AXUtilities.get_desktop() - if desktop is None: - return [] - - def pred(obj): - if must_have_window: - return AXObject.get_child_count(obj) > 0 - return True - - return list(AXObject.iter_children(desktop, pred)) + thread = threading.Thread(target=AXUtilities._clear_stored_data) + thread.daemon = True + thread.start() @staticmethod - def is_application_in_desktop(app): - """Returns true if app is known to Atspi""" + def _clear_stored_data() -> None: + """Clears any data we have cached for objects""" - desktop = AXUtilities.get_desktop() - if desktop is None: + while True: + time.sleep(60) + AXUtilities._clear_all_dictionaries() + + @staticmethod + def _clear_all_dictionaries(reason: str = "") -> None: + msg = "AXUtilities: Clearing cache." + if reason: + msg += f" Reason: {reason}" + debug.print_message(debug.LEVEL_INFO, msg, True) + + with AXUtilities._lock: + AXUtilities.SET_MEMBERS.clear() + AXUtilities.IS_LAYOUT_ONLY.clear() + + @staticmethod + def clear_all_cache_now(obj: Atspi.Accessible | None = None, reason: str = "") -> None: + """Clears all cached information immediately.""" + + AXUtilities._clear_all_dictionaries(reason) + AXObject.clear_cache_now(reason) + AXUtilitiesRelation.clear_cache_now(reason) + AXUtilitiesEvent.clear_cache_now(reason) + if AXUtilitiesRole.is_table_related(obj): + AXTable.clear_cache_now(reason) + + @staticmethod + def can_be_active_window(window: Atspi.Accessible) -> bool: + """Returns True if window can be the active window based on its state.""" + + if window is None: return False - for child in AXObject.iter_children(desktop): - if child == app: - return True + AXObject.clear_cache(window, False, "Checking if window can be the active window") + app = AXUtilitiesApplication.get_application(window) + tokens = ["AXUtilities:", window, "from", app] - tokens = ["WARNING:", app, "is not in", desktop] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return False + if not AXUtilitiesState.is_active(window): + tokens.append("lacks active state") + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + if not AXUtilitiesState.is_showing(window): + tokens.append("lacks showing state") + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + if AXUtilitiesState.is_iconified(window): + tokens.append("is iconified") + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + if AXObject.get_name(app) == "mutter-x11-frames": + tokens.append("is from app that cannot have the real active window") + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + if app and not AXUtilitiesApplication.is_application_in_desktop(app): + tokens.append("is from app unknown to AT-SPI2") + # Firefox alerts and dialogs suffer from this bug too, but if we ignore these windows + # we'll fail to fully present things like the file chooser dialog and the replace-file + # alert. https://bugzilla.mozilla.org/show_bug.cgi?id=1882794 + if not AXUtilitiesRole.is_dialog_or_alert(window): + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + tokens.append("can be active window") + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return True @staticmethod - def get_application_with_pid(pid): - """Returns the accessible application with the specified pid""" + def find_active_window() -> Atspi.Accessible | None: + """Tries to locate the active window; may or may not succeed.""" - desktop = AXUtilities.get_desktop() - if desktop is None: + candidates = [] + apps = AXUtilitiesApplication.get_all_applications(must_have_window=True) + for app in apps: + candidates.extend(list(AXObject.iter_children(app, AXUtilities.can_be_active_window))) + + if not candidates: + tokens = ["AXUtilities: Unable to find active window from", apps] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return None - for app in AXObject.iter_children(desktop): - if AXObject.get_process_id(app) == pid: - return app + if len(candidates) == 1: + tokens = ["AXUtilities: Active window is", candidates[0]] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return candidates[0] - tokens = ["WARNING: app with pid", pid, "is not in", desktop] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return None + tokens = ["AXUtilities: These windows all claim to be active:", candidates] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + # Some electron apps running in the background claim to be active even when they + # are not. These are the ones we know about. We can add others as we go. + suspect_apps = ["slack", + "discord", + "outline-client", + "whatsapp-desktop-linux"] + filtered = [] + for frame in candidates: + if AXObject.get_name(AXUtilitiesApplication.get_application(frame)) in suspect_apps: + tokens = ["AXUtilities: Suspecting", frame, "is a non-active Electron app"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + else: + filtered.append(frame) + + if len(filtered) == 1: + tokens = ["AXUtilities: Active window is believed to be", filtered[0]] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return filtered[0] + + guess: Atspi.Accessible | None = None + if filtered: + tokens = ["AXUtilities: Still have multiple active windows:", filtered] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + guess = filtered[0] + + if guess is not None: + tokens = ["AXUtilities: Returning", guess, "as active window"] + else: + tokens = ["AXUtilities: No active window found"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return guess @staticmethod - def get_all_static_text_leaf_nodes(obj): - """Returns all the descendants of obj that are static text leaf nodes""" + def is_unfocused_alert_or_dialog(obj: Atspi.Accessible) -> bool: + """Returns True if obj is an unfocused alert or dialog with presentable items.""" - roles = [Atspi.Role.STATIC, Atspi.Role.TEXT] - def is_not_element(acc): - return AXObject.get_attribute(acc, "tag") in (None, "", "br") - - result = None - if AXObject.supports_collection(obj): - result = AXUtilitiesCollection.find_all_with_role(obj, roles, is_not_element) - if not AXUtilities.COMPARE_COLLECTION_PERFORMANCE: - return result - - def is_match(acc): - return AXObject.get_role(acc) in roles and is_not_element(acc) - - return AXObject.find_all_descendants(obj, is_match) + if not AXUtilitiesRole.is_dialog_or_alert(obj): + return False + if not AXObject.get_child_count(obj): + return False + if not AXUtilitiesState.is_showing(obj): + return False + return not AXUtilities.can_be_active_window(obj) @staticmethod - def get_all_widgets(obj, must_be_showing_and_visible=True): + def get_unfocused_alerts_and_dialogs(obj: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns a list of all the unfocused alerts and dialogs in the app and window of obj.""" + + app = AXUtilitiesApplication.get_application(obj) + result = list(AXObject.iter_children(app, AXUtilities.is_unfocused_alert_or_dialog)) + + frame = AXObject.find_ancestor( + obj, lambda x: AXUtilitiesRole.is_application(AXObject.get_parent(x))) + result.extend(list(AXObject.iter_children(frame, AXUtilities.is_unfocused_alert_or_dialog))) + + tokens = ["AXUtilities: Unfocused alerts and dialogs for", obj, ":", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def get_all_widgets( + obj: Atspi.Accessible, + must_be_showing_and_visible: bool = True, + exclude_push_button: bool = False + ) -> list[Atspi.Accessible]: """Returns all the descendants of obj with a widget role""" roles = AXUtilitiesRole.get_widget_roles() + if exclude_push_button and Atspi.Role.BUTTON in roles: + roles.remove(Atspi.Role.BUTTON) + result = None if AXObject.supports_collection(obj): if not must_be_showing_and_visible: @@ -164,7 +266,7 @@ class AXUtilities: return AXObject.find_all_descendants(obj, is_match) @staticmethod - def get_default_button(obj): + def get_default_button(obj: Atspi.Accessible) -> Atspi.Accessible | None: """Returns the default button descendant of obj""" result = None @@ -176,7 +278,7 @@ class AXUtilities: return AXObject.find_descendant(obj, AXUtilitiesRole.is_default_button) @staticmethod - def get_focused_object(obj): + def get_focused_object(obj: Atspi.Accessible) -> Atspi.Accessible | None: """Returns the focused descendant of obj""" result = None @@ -188,7 +290,19 @@ class AXUtilities: return AXObject.find_descendant(obj, AXUtilitiesState.is_focused) @staticmethod - def get_status_bar(obj): + def get_info_bar(obj: Atspi.Accessible) -> Atspi.Accessible | None: + """Returns the info bar descendant of obj""" + + result = None + if AXObject.supports_collection(obj): + result = AXUtilitiesCollection.find_info_bar(obj) + if not AXUtilities.COMPARE_COLLECTION_PERFORMANCE: + return result + + return AXObject.find_descendant(obj, AXUtilitiesRole.is_info_bar) + + @staticmethod + def get_status_bar(obj: Atspi.Accessible) -> Atspi.Accessible | None: """Returns the status bar descendant of obj""" result = None @@ -199,13 +313,684 @@ class AXUtilities: return AXObject.find_descendant(obj, AXUtilitiesRole.is_status_bar) + @staticmethod + def _is_layout_only(obj: Atspi.Accessible) -> tuple[bool, str]: + """Returns True and a string reason if obj is believed to serve only for layout.""" -for name, method in inspect.getmembers(AXUtilitiesRole, predicate=inspect.isfunction): - setattr(AXUtilities, name, method) + reason = "" + role = AXObject.get_role(obj) + if role in AXUtilitiesRole.get_layout_only_roles(): + return True, "has layout-only role" -for name, method in inspect.getmembers(AXUtilitiesState, predicate=inspect.isfunction): - setattr(AXUtilities, name, method) + if AXUtilitiesRole.is_layered_pane(obj, role): + result = AXObject.find_ancestor(obj, AXUtilitiesRole.is_desktop_frame) is not None + if result: + reason = "is inside desktop frame" + return result, reason -for name, method in inspect.getmembers(AXUtilitiesCollection, predicate=inspect.isfunction): - if name.startswith("find"): - setattr(AXUtilities, name, method) + if AXUtilitiesRole.is_menu(obj, role) or AXUtilitiesRole.is_list(obj, role): + result = AXObject.find_ancestor(obj, AXUtilitiesRole.is_combo_box) is not None + if result: + reason = "is inside combo box" + return result, reason + + if AXUtilitiesRole.is_group(obj, role): + result = not AXUtilities.has_explicit_name(obj) + if result: + reason = "lacks explicit name" + return result, reason + + if AXUtilitiesRole.is_panel(obj, role) or AXUtilitiesRole.is_grouping(obj, role): + name = AXObject.get_name(obj) + description = AXObject.get_description(obj) + labelled_by = AXUtilitiesRelation.get_is_labelled_by(obj) + described_by = AXUtilitiesRelation.get_is_described_by(obj) + if not (name or description or labelled_by or described_by): + return True, "lacks name, description, and relations" + if name == AXObject.get_name(AXUtilitiesApplication.get_application(obj)): + return True, "has same name as app" + if AXObject.get_child_count(obj) == 1: + child = AXObject.get_child(obj, 0) + if name == AXObject.get_name(child): + return True, "has same name as its only child" + if not AXUtilitiesRole.is_label(child) and child in labelled_by: + return True, "is labelled by non-label only child" + set_roles = AXUtilitiesRole.get_set_container_roles() + ancestor = AXObject.find_ancestor(obj, lambda x: AXObject.get_role(x) in set_roles) + if ancestor and AXObject.get_name(ancestor) == name: + return True, "is in set container with same name" + return False, reason + + if AXUtilitiesRole.is_section(obj, role) or AXUtilitiesRole.is_document(obj, role): + if AXUtilitiesState.is_focusable(obj): + return False, "is focusable" + if AXObject.has_action(obj, "click"): + return False, "has click action" + return True, "is not interactive" + + if AXUtilitiesRole.is_tool_bar(obj): + result = AXUtilitiesRole.is_page_tab_list(AXObject.get_child(obj, 0)) + if result: + reason = "is parent of page tab list" + return result, reason + + if AXUtilitiesRole.is_table(obj, role): + result = AXTable.is_layout_table(obj) + if result: + reason = "is layout table" + return result, reason + + if AXUtilitiesRole.is_table_row(obj): + if AXUtilitiesState.is_focusable(obj): + return False, "is focusable" + if AXUtilitiesState.is_selectable(obj): + return False, "is selectable" + if AXUtilitiesState.is_expandable(obj): + return False, "is expandable" + if AXUtilities.has_explicit_name(obj): + return False, "has explicit name" + return True, "is not focusable, selectable, or expandable and lacks explicit name" + + if AXUtilitiesRole.is_table_cell(obj, role): + if AXUtilitiesRole.is_table_cell(AXObject.get_child(obj, 0)): + return True, "child of this cell is table cell" + table = AXTable.get_table(obj) + if AXUtilitiesRole.is_table(table): + result = AXTable.is_layout_table(table) + if result: + reason = "is in layout table" + return result, reason + + return False, reason + + @staticmethod + def is_layout_only(obj: Atspi.Accessible) -> bool: + """Returns True if obj is believed to serve only for layout.""" + + if hash(obj) in AXUtilities.IS_LAYOUT_ONLY: + result, reason = AXUtilities.IS_LAYOUT_ONLY.get(hash(obj), (False, "")) + else: + result, reason = AXUtilities._is_layout_only(obj) + AXUtilities.IS_LAYOUT_ONLY[hash(obj)] = result, reason + + if reason: + tokens = ["AXUtilities:", obj, f"believed to be layout only: {result}, {reason}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + return result + + @staticmethod + def is_message_dialog(obj: Atspi.Accessible) -> bool: + """Returns True if obj is a dialog that should be treated as a message dialog""" + + if not AXUtilitiesRole.is_dialog_or_alert(obj): + return False + + if not AXObject.supports_collection(obj): + widgets = AXUtilities.get_all_widgets(obj, exclude_push_button=True) + return not widgets + + if AXUtilitiesCollection.has_scroll_pane(obj): + tokens = ["AXUtilities:", obj, "is not a message dialog: has scroll pane"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + if AXUtilitiesCollection.has_split_pane(obj): + tokens = ["AXUtilities:", obj, "is not a message dialog: has split pane"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + if AXUtilitiesCollection.has_tree_or_tree_table(obj): + tokens = ["AXUtilities:", obj, "is not a message dialog: has tree or tree table"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + if AXUtilitiesCollection.has_combo_box_or_list_box(obj): + tokens = ["AXUtilities:", obj, "is not a message dialog: has combo box or list box"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + if AXUtilitiesCollection.has_editable_object(obj): + tokens = ["AXUtilities:", obj, "is not a message dialog: has editable object"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + tokens = ["AXUtilities:", obj, "is believed to be a message dialog"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return True + + @staticmethod + def is_redundant_object(obj1: Atspi.Accessible, obj2: Atspi.Accessible) -> bool: + """Returns True if obj2 is redundant to obj1.""" + + if obj1 == obj2: + return False + + if AXObject.get_name(obj1) != AXObject.get_name(obj2) \ + or AXObject.get_role(obj1) != AXObject.get_role(obj2): + return False + + tokens = ["AXUtilities:", obj2, "is redundant to", obj1] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return True + + @staticmethod + def _sort_by_child_index(object_list: list[Atspi.Accessible]) -> list[Atspi.Accessible]: + """Returns the list of objects sorted according to child index.""" + + def cmp(x, y): + return AXObject.get_index_in_parent(x) - AXObject.get_index_in_parent(y) + + if not object_list or len(object_list) == 1: + return object_list + + result = sorted(object_list, key=functools.cmp_to_key(cmp)) + + first, second = result[0:2] + if AXUtilitiesRole.is_radio_button(first) and AXObject.get_toolkit_name(first) == "gtk": + # Gtk radio buttons are often in reverse order, except for when they're not. + # See https://gitlab.gnome.org/GNOME/gtk/-/issues/7839. + sorted_first, _sorted_second = AXComponent.sort_objects_by_position([first, second]) + if sorted_first != first: + result.reverse() + + if object_list != result: + tokens = ["AXUtilities: Original list", object_list] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + tokens = ["AXUtilities: Sorted list", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + return result + + @staticmethod + def _get_set_members( + obj: Atspi.Accessible, container: Atspi.Accessible + ) -> list[Atspi.Accessible]: + """Returns the members of the container of obj""" + + if container is None: + tokens = ["AXUtilities: Members of", obj, "not obtainable: container is None"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return [] + + result = AXUtilitiesRelation.get_is_member_of(obj) + if result: + tokens = ["AXUtilities: Members of", obj, "in", container, "via member-of", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return AXUtilities._sort_by_child_index(result) + + result = AXUtilitiesRelation.get_is_node_parent_of(obj) + if result: + tokens = ["AXUtilities: Members of", obj, "in", container, "via node-parent-of", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return AXUtilities._sort_by_child_index(result) + + if AXUtilitiesRole.is_description_value(obj): + previous_sibling = AXObject.get_previous_sibling(obj) + while previous_sibling and AXUtilitiesRole.is_description_value(previous_sibling): + result.append(previous_sibling) + previous_sibling = AXObject.get_previous_sibling(previous_sibling) + result.append(obj) + next_sibling = AXObject.get_next_sibling(obj) + while next_sibling and AXUtilitiesRole.is_description_value(next_sibling): + result.append(next_sibling) + next_sibling = AXObject.get_next_sibling(next_sibling) + tokens = ["AXUtilities: Members of", obj, "in", container, "based on siblings", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + if AXUtilitiesRole.is_menu_related(obj): + result = list(AXObject.iter_children(container, AXUtilitiesRole.is_menu_related)) + tokens = ["AXUtilities: Members of", obj, "in", container, "based on menu role", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + role = AXObject.get_role(obj) + result = list(AXObject.iter_children(container, lambda x: AXObject.get_role(x) == role)) + tokens = ["AXUtilities: Members of", obj, "in", container, "based on role", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def get_set_members(obj: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns the members of the container of obj.""" + + result: list[Atspi.Accessible] = [] + container = AXObject.get_parent_checked(obj) + if hash(container) in AXUtilities.SET_MEMBERS: + result = AXUtilities.SET_MEMBERS.get(hash(container), []) + + if obj not in result: + if result: + tokens = ["AXUtilities:", obj, "not in cached members of", container, ":", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + result = AXUtilities._get_set_members(obj, container) + AXUtilities.SET_MEMBERS[hash(container)] = result + + # In a collapsed combobox, one can arrow to change the selection without showing the items. + must_be_showing = not AXObject.find_ancestor(obj, AXUtilitiesRole.is_combo_box) + if not must_be_showing: + return result + + filtered = list(filter(AXUtilitiesState.is_showing, result)) + if result != filtered: + tokens = ["AXUtilities: Filtered non-showing:", set(result).difference(set(filtered))] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + return filtered + + @staticmethod + def get_set_size(obj: Atspi.Accessible) -> int: + """Returns the total number of objects in this container.""" + + result = AXObject.get_attribute(obj, "setsize", False) + if isinstance(result, str) and result.isnumeric(): + return int(result) + + if AXUtilitiesRole.is_table_row(obj): + return AXTable.get_row_count(AXTable.get_table(obj)) + + if AXUtilitiesRole.is_table_cell_or_header(obj) \ + and not AXUtilitiesRole.is_table_row(AXObject.get_parent(obj)): + return AXTable.get_row_count(AXTable.get_table(obj)) + + if AXUtilitiesRole.is_combo_box(obj): + selected_children = AXSelection.get_selected_children(obj) + if not selected_children: + return -1 + if len(selected_children) == 1: + obj = selected_children[0] + + if AXUtilitiesRole.is_list(obj) or AXUtilitiesRole.is_list_box(obj): + obj = AXObject.find_descendant(obj, AXUtilitiesRole.is_list_item) + + child_count = AXObject.get_child_count(AXObject.get_parent(obj)) + if child_count > 500: + return child_count + + members = AXUtilities.get_set_members(obj) + return len(members) + + @staticmethod + def get_set_size_is_unknown(obj: Atspi.Accessible) -> bool: + """Returns True if the total number of objects in this container is unknown.""" + + if AXUtilitiesState.is_indeterminate(obj): + return True + + attrs = AXObject.get_attributes_dict(obj, False) + if attrs.get("setsize") == "-1": + return True + + if AXUtilitiesRole.is_table(obj): + return attrs.get("rowcount") == "-1" or attrs.get("colcount") == "-1" + + return False + + @staticmethod + def get_position_in_set(obj: Atspi.Accessible) -> int: + """Returns the position of obj with respect to the number of items in its container.""" + + result = AXObject.get_attribute(obj, "posinset", False) + if isinstance(result, str) and result.isnumeric(): + # ARIA posinset is 1-based. + return int(result) - 1 + + if AXUtilitiesRole.is_table_row(obj): + result = AXObject.get_attribute(obj, "rowindex", False) + if isinstance(result, str) and result.isnumeric(): + # ARIA posinset is 1-based. + return int(result) - 1 + + if AXObject.get_child_count(obj): + cell = AXObject.find_descendant(obj, AXUtilitiesRole.is_table_cell_or_header) + result = AXObject.get_attribute(cell, "rowindex", False) + + if isinstance(result, str) and result.isnumeric(): + # ARIA posinset is 1-based. + return int(result) - 1 + + if AXUtilitiesRole.is_table_cell_or_header(obj) \ + and not AXUtilitiesRole.is_table_row(AXObject.get_parent(obj)): + return AXTable.get_cell_coordinates(obj)[0] + + if AXUtilitiesRole.is_combo_box(obj): + selected_children = AXSelection.get_selected_children(obj) + if not selected_children: + return -1 + if len(selected_children) == 1: + obj = selected_children[0] + + child_count = AXObject.get_child_count(AXObject.get_parent(obj)) + if child_count > 500: + return AXObject.get_index_in_parent(obj) + + members = AXUtilities.get_set_members(obj) + if obj not in members: + return -1 + + return members.index(obj) + + @staticmethod + def has_explicit_name(obj: Atspi.Accessible) -> bool: + """Returns True if obj has an author/app-provided name as opposed to a calculated name.""" + + return AXObject.get_attribute(obj, "explicit-name") == "true" + + @staticmethod + def has_visible_caption(obj: Atspi.Accessible) -> bool: + """Returns True if obj has a visible caption.""" + + if not (AXUtilitiesRole.is_figure(obj) or AXObject.supports_table(obj)): + return False + + labels = AXUtilitiesRelation.get_is_labelled_by(obj) + for label in labels: + if AXUtilitiesRole.is_caption(label) \ + and AXUtilitiesState.is_showing(label) and AXUtilitiesState.is_visible(label): + return True + + return False + + @staticmethod + def get_displayed_label(obj: Atspi.Accessible) -> str: + """Returns the displayed label of obj.""" + + labels = AXUtilitiesRelation.get_is_labelled_by(obj) + strings = [AXObject.get_name(label) or AXText.get_all_text(label) for label in labels] + result = " ".join(strings) + return result + + @staticmethod + def get_displayed_description(obj: Atspi.Accessible) -> str: + """Returns the displayed description of obj.""" + + descriptions = AXUtilitiesRelation.get_is_described_by(obj) + strings = [AXObject.get_name(desc) or AXText.get_all_text(desc) for desc in descriptions] + result = " ".join(strings) + return result + + @staticmethod + def get_heading_level(obj: Atspi.Accessible) -> int: + """Returns the heading level of obj.""" + + if not AXUtilitiesRole.is_heading(obj): + return 0 + + use_cache = not AXUtilitiesState.is_editable(obj) + attrs = AXObject.get_attributes_dict(obj, use_cache) + + try: + value = int(attrs.get("level", "0")) + except ValueError: + tokens = ["AXUtilities: Exception getting value for", obj, "(", attrs, ")"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return 0 + + return value + + @staticmethod + def get_nesting_level(obj: Atspi.Accessible) -> int: + """Returns the nesting level of obj.""" + + def pred(x: Atspi.Accessible) -> bool: + if AXUtilitiesRole.is_list_item(obj): + return AXUtilitiesRole.is_list(AXObject.get_parent(x)) + return AXUtilitiesRole.have_same_role(obj, x) + + ancestors = [] + ancestor = AXObject.find_ancestor(obj, pred) + while ancestor: + ancestors.append(ancestor) + ancestor = AXObject.find_ancestor(ancestor, pred) + + return len(ancestors) + + @staticmethod + def get_next_object(obj: Atspi.Accessible) -> Atspi.Accessible | None: + """Returns the next object (depth first, unless there's a flows-to relation)""" + + if not AXObject.is_valid(obj): + return None + + targets = AXUtilitiesRelation.get_flows_to(obj) + for target in targets: + if not AXObject.is_dead(target): + return target + + index = AXObject.get_index_in_parent(obj) + 1 + parent = AXObject.get_parent(obj) + while parent and not 0 < index < AXObject.get_child_count(parent): + obj = parent + index = AXObject.get_index_in_parent(obj) + 1 + parent = AXObject.get_parent(obj) + + if parent is None: + return None + + next_object = AXObject.get_child(parent, index) + if next_object == obj: + tokens = ["AXUtilities:", obj, "claims to be its own next object"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return None + + return next_object + + @staticmethod + def get_previous_object(obj: Atspi.Accessible) -> Atspi.Accessible | None: + """Returns the previous object (depth first, unless there's a flows-from relation)""" + + if not AXObject.is_valid(obj): + return None + + targets = AXUtilitiesRelation.get_flows_from(obj) + for target in targets: + if not AXObject.is_dead(target): + return target + + index = AXObject.get_index_in_parent(obj) - 1 + parent = AXObject.get_parent(obj) + while parent and not 0 <= index < AXObject.get_child_count(parent) - 1: + obj = parent + index = AXObject.get_index_in_parent(obj) - 1 + parent = AXObject.get_parent(obj) + + if parent is None: + return None + + previous_object = AXObject.get_child(parent, index) + if previous_object == obj: + tokens = ["AXUtilities:", obj, "claims to be its own previous object"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return None + + return previous_object + + @staticmethod + def is_on_screen( + obj: Atspi.Accessible, + bounding_box: Atspi.Rect | None = None + ) -> bool: + """Returns true if obj should be treated as being on screen.""" + + AXObject.clear_cache(obj, False, "Updating to check if object is on screen.") + + tokens = ["AXUtilities: Checking if", obj, "is showing and visible...."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if not (AXUtilitiesState.is_showing(obj) and AXUtilitiesState.is_visible(obj)): + tokens = ["AXUtilities:", obj, "is not showing and visible. Treating as off screen."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + tokens = ["AXUtilities:", obj, "is showing and visible. Checking hidden..."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if AXUtilitiesState.is_hidden(obj): + tokens = ["AXUtilities:", obj, "is reports being hidden. Treating as off screen."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + tokens = ["AXUtilities:", obj, "is not hidden. Checking size and rect..."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if AXComponent.has_no_size_or_invalid_rect(obj): + tokens = ["AXUtilities: Rect of", obj, "is unhelpful. Treating as on screen."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return True + + tokens = ["AXUtilities:", obj, "has size and a valid rect. Checking if off screen..."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if AXComponent.object_is_off_screen(obj): + tokens = ["AXUtilities:", obj, "is believed to be off screen."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + tokens = ["AXUtilities:", obj, "is not off screen. Checking", + bounding_box, "intersection..."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if bounding_box is not None and not AXComponent.object_intersects_rect(obj, bounding_box): + tokens = ["AXUtilities", obj, "not in", bounding_box, ". Treating as off screen."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + tokens = ["AXUtilities:", obj, "is believed to be on screen."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return True + + @staticmethod + def treat_as_leaf_node(obj: Atspi.Accessible) -> bool: + """Returns True if obj should be treated as a leaf node.""" + + if AXUtilitiesRole.children_are_presentational(obj): + # In GTK, the contents of the page tab descends from the page tab. + if AXUtilitiesRole.is_page_tab(obj): + return False + return True + + role = AXObject.get_role(obj) + if AXUtilitiesRole.is_combo_box(obj, role): + return not AXUtilitiesState.is_expanded(obj) + + if AXUtilitiesRole.is_menu(obj, role) and not AXUtilitiesRole.has_role_from_aria(obj): + return not AXUtilitiesState.is_expanded(obj) + + if AXObject.get_name(obj): + return AXUtilitiesRole.is_link(obj, role) or AXUtilitiesRole.is_label(obj, role) + + return False + + @staticmethod + def _get_on_screen_objects( + root: Atspi.Accessible, + cancellation_event: threading.Event, + bounding_box: Atspi.Rect | None = None + ) -> list: + + tokens = ["AXUtilities: Getting on-screen objects in", root, f"({hex(id(root))})"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if cancellation_event.is_set(): + msg = "AXUtilities: Cancellation event set. Stopping search." + debug.print_message(debug.LEVEL_INFO, msg, True) + return [] + + if not AXUtilities.is_on_screen(root, bounding_box): + return [] + + if AXUtilities.treat_as_leaf_node(root): + return [root] + + if AXObject.supports_table(root) and AXObject.supports_selection(root): + return list(AXTable.iter_visible_cells(root)) + + objects = [] + root_name = AXObject.get_name(root) + if root_name or AXObject.get_description(root) or AXText.has_presentable_text(root): + objects.append(root) + + if bounding_box is None: + bounding_box = AXComponent.get_rect(root) + + for i, child in enumerate(AXObject.iter_children(root)): + tokens = [f"AXUtilities: Child {i} is", child, f"({hex(id(child))})"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if cancellation_event.is_set(): + msg = "AXUtilities: Cancellation event set. Stopping search." + debug.print_message(debug.LEVEL_INFO, msg, True) + break + + children = AXUtilities._get_on_screen_objects(child, cancellation_event, bounding_box) + objects.extend(children) + if root_name and children and root in objects and root_name == AXObject.get_name(child): + objects.remove(root) + + if objects: + return objects + + if AXUtilitiesState.is_focusable(root) or AXObject.has_action(root, "click"): + return [root] + + return [] + + @staticmethod + def get_on_screen_objects( + root: Atspi.Accessible, + bounding_box: Atspi.Rect | None = None, + timeout: float = 5.0 + ) -> list: + """Returns a list of onscreen objects in the given root.""" + + result_queue: queue.Queue[list] = queue.Queue() + cancellation_event = threading.Event() + + def _worker(): + result = AXUtilities._get_on_screen_objects(root, cancellation_event, bounding_box) + if not cancellation_event.is_set(): + result_queue.put(result) + + worker_thread = threading.Thread(target=_worker) + worker_thread.start() + + try: + result = result_queue.get(timeout=timeout) + except queue.Empty: + tokens = ["AXUtilities: get_on_screen_objects timed out.", root] + debug.print_tokens(debug.LEVEL_WARNING, tokens, True) + cancellation_event.set() + result = [] + + msg = "AXUtilities: Checking AT-SPI responsiveness...." + debug.print_message(debug.LEVEL_INFO, msg, True) + desktop = AXUtilitiesApplication.get_desktop() + tokens = ["AXUtilities: Desktop is", desktop] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + worker_thread.join() + tokens = [f"AXUtilities: {len(result)} onscreen objects found in", root] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + +for method_name, method in inspect.getmembers(AXUtilitiesApplication, predicate=inspect.isfunction): + setattr(AXUtilities, method_name, method) + +for method_name, method in inspect.getmembers(AXUtilitiesEvent, predicate=inspect.isfunction): + setattr(AXUtilities, method_name, method) + +for method_name, method in inspect.getmembers(AXUtilitiesRelation, predicate=inspect.isfunction): + setattr(AXUtilities, method_name, method) + +for method_name, method in inspect.getmembers(AXUtilitiesRole, predicate=inspect.isfunction): + setattr(AXUtilities, method_name, method) + +for method_name, method in inspect.getmembers(AXUtilitiesState, predicate=inspect.isfunction): + setattr(AXUtilities, method_name, method) + +for method_name, method in inspect.getmembers(AXUtilitiesCollection, predicate=inspect.isfunction): + if method_name.startswith("find"): + setattr(AXUtilities, method_name, method) + +AXUtilities.start_cache_clearing_thread() diff --git a/src/cthulhu/ax_utilities_application.py b/src/cthulhu/ax_utilities_application.py new file mode 100644 index 0000000..dbaca2e --- /dev/null +++ b/src/cthulhu/ax_utilities_application.py @@ -0,0 +1,208 @@ +# Utilities for obtaining information about accessible applications. +# +# Copyright 2023-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 +# 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 + +"""Utilities for obtaining information about accessible applications.""" + +__id__ = "$Id$" +__version__ = "$Revision$" +__date__ = "$Date$" +__copyright__ = "Copyright (c) 2023-2024 Igalia, S.L." \ + "Copyright (c) 2024 GNOME Foundation Inc." +__license__ = "LGPL" + +import subprocess + +import gi +gi.require_version("Atspi", "2.0") +from gi.repository import Atspi +from gi.repository import GLib + +from . import debug +from .ax_object import AXObject + +class AXUtilitiesApplication: + """Utilities for obtaining information about accessible applications.""" + + @staticmethod + def application_as_string(obj: Atspi.Accessible) -> str: + """Returns the application details of obj as a string.""" + + app = AXUtilitiesApplication.get_application(obj) + if app is None: + return "" + + string = ( + f"{AXObject.get_name(app)} " + f"({AXUtilitiesApplication.get_application_toolkit_name(obj)} " + f"{AXUtilitiesApplication.get_application_toolkit_version(obj)})" + ) + return string + + @staticmethod + def get_all_applications( + must_have_window: bool = False, + exclude_unresponsive: bool = False, + is_debug: bool = False + ) -> list[Atspi.Accessible]: + """Returns a list of running applications known to Atspi.""" + + desktop = AXUtilitiesApplication.get_desktop() + if desktop is None: + return [] + + def pred(obj: Atspi.Accessible) -> bool: + if exclude_unresponsive and AXUtilitiesApplication.is_application_unresponsive(obj): + return False + if AXObject.get_name(obj) == "mutter-x11-frames": + return is_debug + if must_have_window: + return AXObject.get_child_count(obj) > 0 + return True + + return list(AXObject.iter_children(desktop, pred)) + + @staticmethod + def get_application(obj: Atspi.Accessible) -> Atspi.Accessible | None: + """Returns the accessible application associated with obj""" + + if obj is None: + return None + + try: + app = Atspi.Accessible.get_application(obj) + except GLib.GError as error: + msg = f"AXUtilitiesApplication: Exception in get_application: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return None + return app + + @staticmethod + def get_application_toolkit_name(obj: Atspi.Accessible) -> str: + """Returns the toolkit name reported for obj's application.""" + + app = AXUtilitiesApplication.get_application(obj) + if app is None: + return "" + + try: + name = Atspi.Accessible.get_toolkit_name(app) + except GLib.GError as error: + msg = f"AXUtilitiesApplication: Exception in get_application_toolkit_name: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return "" + + return name + + @staticmethod + def get_application_toolkit_version(obj: Atspi.Accessible) -> str: + """Returns the toolkit version reported for obj's application.""" + + app = AXUtilitiesApplication.get_application(obj) + if app is None: + return "" + + try: + version = Atspi.Accessible.get_toolkit_version(app) + except GLib.GError as error: + msg = f"AXUtilitiesApplication: Exception in get_application_toolkit_version: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return "" + + return version + + @staticmethod + def get_application_with_pid(pid: int) -> Atspi.Accessible | None: + """Returns the accessible application with the specified pid""" + + applications = AXUtilitiesApplication.get_all_applications() + for child in applications: + if AXUtilitiesApplication.get_process_id(child) == pid: + return child + + tokens = ["WARNING: app with pid", pid, "is not in the accessible desktop"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return None + + @staticmethod + def get_desktop() -> Atspi.Accessible | None: + """Returns the accessible desktop""" + + try: + desktop = Atspi.get_desktop(0) + except GLib.GError as error: + tokens = ["ERROR: Exception getting desktop from Atspi:", error] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return None + + return desktop + + @staticmethod + def get_process_id(obj: Atspi.Accessible) -> int: + """Returns the process id associated with obj""" + + try: + pid = Atspi.Accessible.get_process_id(obj) + except GLib.GError as error: + msg = f"AXUtilitiesApplication: Exception in get_process_id: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return -1 + + return pid + + @staticmethod + def is_application_in_desktop(app: Atspi.Accessible) -> bool: + """Returns true if app is known to Atspi""" + + applications = AXUtilitiesApplication.get_all_applications() + for child in applications: + if child == app: + return True + + tokens = ["WARNING:", app, "is not in the accessible desktop"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + @staticmethod + def is_application_unresponsive(app: Atspi.Accessible) -> bool: + """Returns true if app's process is known to be unresponsive.""" + + pid = AXUtilitiesApplication.get_process_id(app) + try: + state = subprocess.getoutput(f"cat /proc/{pid}/status | grep State") + state = state.split()[1] + except (GLib.GError, IndexError) as error: + tokens = [f"AXUtilitiesApplication: Exception checking state of pid {pid}: {error}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + if state == "Z": + tokens = [f"AXUtilitiesApplication: pid {pid} is zombie process"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return True + + if state == "T": + tokens = [f"AXUtilitiesApplication: pid {pid} is suspended/stopped process"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return True + + return False diff --git a/src/cthulhu/ax_utilities_collection.py b/src/cthulhu/ax_utilities_collection.py index 53acfe2..9a356cf 100644 --- a/src/cthulhu/ax_utilities_collection.py +++ b/src/cthulhu/ax_utilities_collection.py @@ -1,9 +1,7 @@ -#!/usr/bin/env python3 +# Utilities for finding all objects that meet a certain criteria. # -# 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 2023 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,19 +17,12 @@ # 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 -""" -Utilities for finding all objects that meet a certain criteria. -These utilities are app-type- and toolkit-agnostic. Utilities that might have -different implementations or results depending on the type of app (e.g. terminal, -chat, web) or toolkit (e.g. Qt, Gtk) should be in script_utilities.py file(s). +# pylint: disable=wrong-import-position +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-lines -N.B. There are currently utilities that should never have custom implementations -that live in script_utilities.py files. These will be moved over time. -""" +"""Utilities for finding all objects that meet a certain criteria.""" __id__ = "$Id$" __version__ = "$Revision$" @@ -41,6 +32,7 @@ __license__ = "LGPL" import inspect import time +from typing import Callable import gi gi.require_version("Atspi", "2.0") @@ -49,6 +41,8 @@ from gi.repository import Atspi from . import debug from .ax_collection import AXCollection from .ax_object import AXObject +from .ax_utilities_debugging import AXUtilitiesDebugging +from .ax_utilities_relation import AXUtilitiesRelation from .ax_utilities_role import AXUtilitiesRole from .ax_utilities_state import AXUtilitiesState @@ -57,28 +51,36 @@ class AXUtilitiesCollection: """Utilities for finding all objects that meet a certain criteria.""" @staticmethod - def _apply_predicate(matches, pred): + def _apply_predicate( + matches: list[Atspi.Accessible], + pred: Callable[[Atspi.Accessible], bool] + ) -> list[Atspi.Accessible]: if not matches: return [] start = time.time() tokens = ["AXUtilitiesCollection: Applying predicate ", pred] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) matches = list(filter(pred, matches)) msg = f"AXUtilitiesCollection: {len(matches)} matches found in {time.time() - start:.4f}s" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return matches @staticmethod - def _find_all_with_states(root, state_list, state_match_type, pred=None): + def _find_all_with_states( + root: Atspi.Accessible, + state_list: list[Atspi.StateType], + state_match_type: Atspi.CollectionMatchType, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: if not (root and state_list): return [] state_list = list(state_list) tokens = ["AXUtilitiesCollection:", inspect.currentframe(), "Root:", root, state_match_type, "of:", state_list] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) rule = AXCollection.create_match_rule(states=state_list, state_match_type=state_match_type) matches = AXCollection.get_all_matches(root, rule) @@ -88,14 +90,19 @@ class AXUtilitiesCollection: return matches @staticmethod - def _find_all_with_role(root, role_list, role_match_type, pred=None): + def _find_all_with_role( + root: Atspi.Accessible, + role_list: list[Atspi.Role], + role_match_type: Atspi.CollectionMatchType, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: if not (root and role_list): return [] role_list = list(role_list) tokens = ["AXUtilitiesCollection:", inspect.currentframe(), "Root:", root, role_match_type, "of:", role_list] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) rule = AXCollection.create_match_rule(roles=role_list, role_match_type=role_match_type) matches = AXCollection.get_all_matches(root, rule) @@ -105,7 +112,11 @@ class AXUtilitiesCollection: return matches @staticmethod - def find_all_with_interfaces(root, interface_list, pred=None): + def find_all_with_interfaces( + root: Atspi.Accessible, + interface_list: list[str], + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root which implement all the specified interfaces""" if not (root and interface_list): @@ -114,7 +125,7 @@ class AXUtilitiesCollection: interface_list = list(interface_list) tokens = ["AXUtilitiesCollection:", inspect.currentframe(), "Root:", root, "all of:", interface_list] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) rule = AXCollection.create_match_rule(interfaces=interface_list) matches = AXCollection.get_all_matches(root, rule) @@ -124,21 +135,34 @@ class AXUtilitiesCollection: return matches @staticmethod - def find_all_with_role(root, role_list, pred=None): + def find_all_with_role( + root: Atspi.Accessible, + role_list: list[Atspi.Role], + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with any of the specified roles""" return AXUtilitiesCollection._find_all_with_role( root, role_list, Atspi.CollectionMatchType.ANY, pred) @staticmethod - def find_all_without_roles(root, role_list, pred=None): + def find_all_without_roles( + root: Atspi.Accessible, + role_list: list[Atspi.Role], + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root which have none of the specified roles""" return AXUtilitiesCollection._find_all_with_role( root, role_list, Atspi.CollectionMatchType.NONE, pred) @staticmethod - def find_all_with_role_and_all_states(root, role_list, state_list, pred=None): + def find_all_with_role_and_all_states( + root: Atspi.Accessible, + role_list: list[Atspi.Role], + state_list: list[Atspi.StateType], + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with any of the roles, and all the states""" if not (root and role_list and state_list): @@ -148,10 +172,11 @@ class AXUtilitiesCollection: state_list = list(state_list) tokens = ["AXUtilitiesCollection:", inspect.currentframe(), "Root:", root, "Roles:", role_list, "States:", state_list] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) rule = AXCollection.create_match_rule( - roles=role_list, states=state_list, state_match_type=Atspi.CollectionMatchType.ALL) + roles=role_list, role_match_type=Atspi.CollectionMatchType.ANY, + states=state_list, state_match_type=Atspi.CollectionMatchType.ALL) matches = AXCollection.get_all_matches(root, rule) if pred is not None: matches = AXUtilitiesCollection._apply_predicate(matches, pred) @@ -159,7 +184,12 @@ class AXUtilitiesCollection: return matches @staticmethod - def find_all_with_role_and_any_state(root, role_list, state_list, pred=None): + def find_all_with_role_and_any_state( + root: Atspi.Accessible, + role_list: list[Atspi.Role], + state_list: list[Atspi.StateType], + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with any of the roles, and any of the states""" if not (root and role_list and state_list): @@ -169,10 +199,11 @@ class AXUtilitiesCollection: state_list = list(state_list) tokens = ["AXUtilitiesCollection:", inspect.currentframe(), "Root:", root, "Roles:", role_list, "States:", state_list] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) rule = AXCollection.create_match_rule( - roles=role_list, states=state_list, state_match_type=Atspi.CollectionMatchType.ANY) + roles=role_list, role_match_type=Atspi.CollectionMatchType.ANY, + states=state_list, state_match_type=Atspi.CollectionMatchType.ANY) matches = AXCollection.get_all_matches(root, rule) if pred is not None: matches = AXUtilitiesCollection._apply_predicate(matches, pred) @@ -180,7 +211,12 @@ class AXUtilitiesCollection: return matches @staticmethod - def find_all_with_role_without_states(root, role_list, state_list, pred=None): + def find_all_with_role_without_states( + root: Atspi.Accessible, + role_list: list[Atspi.Role], + state_list: list[Atspi.StateType], + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with any of the roles, and none of the states""" if not (root and role_list and state_list): @@ -190,10 +226,11 @@ class AXUtilitiesCollection: state_list = list(state_list) tokens = ["AXUtilitiesCollection:", inspect.currentframe(), "Root:", root, "Roles:", role_list, "States:", state_list] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) rule = AXCollection.create_match_rule( - roles=role_list, states=state_list, state_match_type=Atspi.CollectionMatchType.NONE) + roles=role_list, role_match_type=Atspi.CollectionMatchType.ANY, + states=state_list, state_match_type=Atspi.CollectionMatchType.NONE) matches = AXCollection.get_all_matches(root, rule) if pred is not None: matches = AXUtilitiesCollection._apply_predicate(matches, pred) @@ -201,133 +238,193 @@ class AXUtilitiesCollection: return matches @staticmethod - def find_all_with_states(root, state_list, pred=None): + def find_all_with_states( + root: Atspi.Accessible, + state_list: list[Atspi.StateType], + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root which have all of the specified states""" return AXUtilitiesCollection._find_all_with_states( root, state_list, Atspi.CollectionMatchType.ALL, pred) @staticmethod - def find_all_with_any_state(root, state_list, pred=None): + def find_all_with_any_state( + root: Atspi.Accessible, + state_list: list[Atspi.StateType], + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root which have any of the specified states""" return AXUtilitiesCollection._find_all_with_states( root, state_list, Atspi.CollectionMatchType.ANY, pred) @staticmethod - def find_all_without_states(root, state_list, pred=None): + def find_all_without_states( + root: Atspi.Accessible, + state_list: list[Atspi.StateType], + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root which have none of the specified states""" return AXUtilitiesCollection._find_all_with_states( root, state_list, Atspi.CollectionMatchType.NONE, pred) @staticmethod - def find_all_accelerator_labels(root, pred=None): + def find_all_accelerator_labels( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the accelerator label role""" roles = [Atspi.Role.ACCELERATOR_LABEL] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_alerts(root, pred=None): + def find_all_alerts( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the alert role""" roles = [Atspi.Role.ALERT] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_animations(root, pred=None): + def find_all_animations( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the animation role""" roles = [Atspi.Role.ANIMATION] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_arrows(root, pred=None): + def find_all_arrows( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the arrow role""" roles = [Atspi.Role.ARROW] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_articles(root, pred=None): + def find_all_articles( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the article role""" roles = [Atspi.Role.ARTICLE] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_audios(root, pred=None): + def find_all_audios( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the audio role""" roles = [Atspi.Role.AUDIO] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_autocompletes(root, pred=None): + def find_all_autocompletes( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the autocomplete role""" roles = [Atspi.Role.AUTOCOMPLETE] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_block_quotes(root, pred=None): + def find_all_block_quotes( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the block quote role""" roles = [Atspi.Role.BLOCK_QUOTE] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_buttons(root, pred=None): + def find_all_buttons( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the push- or toggle-button role""" - roles = [Atspi.Role.PUSH_BUTTON, Atspi.Role.TOGGLE_BUTTON] + roles = [Atspi.Role.BUTTON, Atspi.Role.TOGGLE_BUTTON] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_calendars(root, pred=None): + def find_all_calendars( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the calendar role""" roles = [Atspi.Role.CALENDAR] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_canvases(root, pred=None): + def find_all_canvases( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the canvas role""" roles = [Atspi.Role.CANVAS] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_captions(root, pred=None): + def find_all_captions( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the caption role""" roles = [Atspi.Role.CAPTION] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_charts(root, pred=None): + def find_all_charts( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the chart role""" roles = [Atspi.Role.CHART] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_check_boxes(root, pred=None): + def find_all_check_boxes( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the checkbox role""" roles = [Atspi.Role.CHECK_BOX] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_check_menu_items(root, pred=None): + def find_all_check_menu_items( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the check menuitem role""" roles = [Atspi.Role.CHECK_MENU_ITEM] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_clickables(root, pred=None): + def find_all_clickables( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all non-focusable descendants of root which support the click action""" if root is None: @@ -343,13 +440,13 @@ class AXUtilitiesCollection: tokens = ["AXUtilitiesCollection:", inspect.currentframe(), "Root:", root, roles_match_type, "of:", roles, ". pred:", pred] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) def is_match(obj): result = AXObject.has_action(obj, "click") - tokens = ["AXUtilitiesCollection:", obj, AXObject.actions_as_string(obj), + tokens = ["AXUtilitiesCollection:", obj, AXUtilitiesDebugging.actions_as_string(obj), "has click Action:", result] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) if not result: return False return pred is None or pred(obj) @@ -367,182 +464,261 @@ class AXUtilitiesCollection: return matches @staticmethod - def find_all_color_choosers(root, pred=None): + def find_all_color_choosers( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the color_chooser role""" roles = [Atspi.Role.COLOR_CHOOSER] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_column_headers(root, pred=None): + def find_all_column_headers( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the column header role""" roles = [Atspi.Role.COLUMN_HEADER] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_combo_boxes(root, pred=None): + def find_all_combo_boxes( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the combobox role""" roles = [Atspi.Role.COMBO_BOX] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_comments(root, pred=None): + def find_all_comments( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the comment role""" roles = [Atspi.Role.COMMENT] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_content_deletions(root, pred=None): + def find_all_content_deletions( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the content deletion role""" roles = [Atspi.Role.CONTENT_DELETION] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_content_insertions(root, pred=None): + def find_all_content_insertions( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the content insertion role""" roles = [Atspi.Role.CONTENT_INSERTION] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_date_editors(root, pred=None): + def find_all_date_editors( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the date editor role""" roles = [Atspi.Role.DATE_EDITOR] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_definitions(root, pred=None): + def find_all_definitions( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the definition role""" roles = [Atspi.Role.DEFINITION] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_description_lists(root, pred=None): + def find_all_description_lists( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the description list role""" roles = [Atspi.Role.DESCRIPTION_LIST] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_description_terms(root, pred=None): + def find_all_description_terms( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the description term role""" roles = [Atspi.Role.DESCRIPTION_TERM] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_description_values(root, pred=None): + def find_all_description_values( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the description value role""" roles = [Atspi.Role.DESCRIPTION_VALUE] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_desktop_frames(root, pred=None): + def find_all_desktop_frames( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the desktop frame role""" roles = [Atspi.Role.DESKTOP_FRAME] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_desktop_icons(root, pred=None): + def find_all_desktop_icons( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the desktop icon role""" roles = [Atspi.Role.DESKTOP_ICON] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_dials(root, pred=None): + def find_all_dials( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the dial role""" roles = [Atspi.Role.DIAL] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_dialogs(root, pred=None): + def find_all_dialogs( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the dialog role""" roles = [Atspi.Role.DIALOG] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_dialogs_and_alerts(root, pred=None): + def find_all_dialogs_and_alerts( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root that has any dialog or alert role""" roles = AXUtilitiesRole.get_dialog_roles(True) return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_directory_panes(root, pred=None): + def find_all_directory_panes( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the directory pane role""" roles = [Atspi.Role.DIRECTORY_PANE] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_documents(root, pred=None): + def find_all_documents( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root that has any document-related role""" roles = AXUtilitiesRole.get_document_roles() return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_document_emails(root, pred=None): + def find_all_document_emails( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the document email role""" roles = [Atspi.Role.DOCUMENT_EMAIL] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_document_frames(root, pred=None): + def find_all_document_frames( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the document frame role""" roles = [Atspi.Role.DOCUMENT_FRAME] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_document_presentations(root, pred=None): + def find_all_document_presentations( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the document presentation role""" roles = [Atspi.Role.DOCUMENT_PRESENTATION] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_document_spreadsheets(root, pred=None): + def find_all_document_spreadsheets( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the document spreadsheet role""" roles = [Atspi.Role.DOCUMENT_SPREADSHEET] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_document_texts(root, pred=None): + def find_all_document_texts( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the document text role""" roles = [Atspi.Role.DOCUMENT_TEXT] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_document_webs(root, pred=None): + def find_all_document_webs( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the document web role""" roles = [Atspi.Role.DOCUMENT_WEB] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_drawing_areas(root, pred=None): + def find_all_drawing_areas( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the drawing area role""" roles = [Atspi.Role.DRAWING_AREA] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_editable_objects(root, must_be_focusable=True, pred=None): + def find_all_editable_objects( + root: Atspi.Accessible, + must_be_focusable: bool = True, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root which are editable""" states = [Atspi.StateType.EDITABLE] @@ -551,56 +727,80 @@ class AXUtilitiesCollection: return AXUtilitiesCollection.find_all_with_states(root, states, pred) @staticmethod - def find_all_editbars(root, pred=None): + def find_all_editbars( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the editbar role""" roles = [Atspi.Role.EDITBAR] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_embeddeds(root, pred=None): + def find_all_embeddeds( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the embedded role""" roles = [Atspi.Role.EMBEDDED] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_entries(root, pred=None): + def find_all_entries( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the entry role""" roles = [Atspi.Role.ENTRY] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_extendeds(root, pred=None): + def find_all_extendeds( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the extended role""" roles = [Atspi.Role.EXTENDED] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_file_choosers(root, pred=None): + def find_all_file_choosers( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the file chooser role""" roles = [Atspi.Role.FILE_CHOOSER] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_fillers(root, pred=None): + def find_all_fillers( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the filler role""" roles = [Atspi.Role.FILLER] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_focusable_objects(root, pred=None): + def find_all_focusable_objects( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root which are focusable""" states = [Atspi.StateType.FOCUSABLE] return AXUtilitiesCollection.find_all_with_states(root, states, pred) @staticmethod - def find_all_focusable_objects_with_click_ancestor(root, pred=None): + def find_all_focusable_objects_with_click_ancestor( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all focusable descendants of root which support the click-ancestor action""" if root is None: @@ -614,13 +814,13 @@ class AXUtilitiesCollection: tokens = ["AXUtilitiesCollection:", inspect.currentframe(), "Root:", root, roles_match_type, "of:", roles, ". pred:", pred] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) def is_match(obj): result = AXObject.has_action(obj, "click-ancestor") - tokens = ["AXUtilitiesCollection:", obj, AXObject.actions_as_string(obj), + tokens = ["AXUtilitiesCollection:", obj, AXUtilitiesDebugging.actions_as_string(obj), "has click-ancestor Action:", result] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) if not result: return False return pred is None or pred(obj) @@ -636,49 +836,71 @@ class AXUtilitiesCollection: return matches @staticmethod - def find_all_focused_objects(root, pred=None): + def find_all_focused_objects( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root which are focused""" states = [Atspi.StateType.FOCUSED] return AXUtilitiesCollection.find_all_with_states(root, states, pred) @staticmethod - def find_all_focus_traversables(root, pred=None): + def find_all_focus_traversables( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the focus traversable role""" roles = [Atspi.Role.FOCUS_TRAVERSABLE] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_font_choosers(root, pred=None): + def find_all_font_choosers( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the font chooser role""" roles = [Atspi.Role.FONT_CHOOSER] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_footers(root, pred=None): + def find_all_footers( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the footer role""" roles = [Atspi.Role.FOOTER] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_footnotes(root, pred=None): + def find_all_footnotes( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the footnote role""" roles = [Atspi.Role.FOOTNOTE] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_forms(root, pred=None): + def find_all_forms( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the form role""" roles = [Atspi.Role.FORM] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_form_fields(root, must_be_focusable=True, pred=None): + def find_all_form_fields( + root: Atspi.Accessible, + must_be_focusable: bool = True, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with a form-field-related role""" roles = AXUtilitiesRole.get_form_field_roles() @@ -689,21 +911,30 @@ class AXUtilitiesCollection: return AXUtilitiesCollection.find_all_with_role_and_all_states(root, roles, states, pred) @staticmethod - def find_all_frames(root, pred=None): + def find_all_frames( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the frame role""" roles = [Atspi.Role.FRAME] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_glass_panes(root, pred=None): + def find_all_glass_panes( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the glass pane role""" roles = [Atspi.Role.GLASS_PANE] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_grids(root, pred=None): + def find_all_grids( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root that are grids""" if root is None: @@ -711,7 +942,7 @@ class AXUtilitiesCollection: tokens = ["AXUtilitiesCollection:", inspect.currentframe(), "Root:", root, "pred:", pred] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) roles = [Atspi.Role.TABLE] attributes = ["xml-roles:grid"] @@ -723,7 +954,10 @@ class AXUtilitiesCollection: return grids @staticmethod - def find_all_grid_cells(root, pred=None): + def find_all_grid_cells( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root that are grid cells""" if root is None: @@ -735,7 +969,7 @@ class AXUtilitiesCollection: tokens = ["AXUtilitiesCollection:", inspect.currentframe(), "Root:", root, "pred:", pred] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) cells = [] for grid in grids: @@ -747,28 +981,41 @@ class AXUtilitiesCollection: return cells @staticmethod - def find_all_groupings(root, pred=None): + def find_all_groupings( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the grouping role""" roles = [Atspi.Role.GROUPING] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_headers(root, pred=None): + def find_all_headers( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the header role""" roles = [Atspi.Role.HEADER] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_headings(root, pred=None): + def find_all_headings( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the heading role""" roles = [Atspi.Role.HEADING] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_headings_at_level(root, level, pred=None): + def find_all_headings_at_level( + root: Atspi.Accessible, + level: int, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the heading role""" if root is None: @@ -776,7 +1023,7 @@ class AXUtilitiesCollection: tokens = ["AXUtilitiesCollection:", inspect.currentframe(), "Root:", root, "Level:", level, "pred:", pred] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) roles = [Atspi.Role.HEADING] attributes = [f"level:{level}"] @@ -787,14 +1034,20 @@ class AXUtilitiesCollection: return matches @staticmethod - def find_all_html_containers(root, pred=None): + def find_all_html_containers( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the html container role""" roles = [Atspi.Role.HTML_CONTAINER] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_horizontal_scrollbars(root, pred=None): + def find_all_horizontal_scrollbars( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root that is a horizontal scrollbar""" roles = [Atspi.Role.SCROLL_BAR] @@ -802,7 +1055,10 @@ class AXUtilitiesCollection: return AXUtilitiesCollection.find_all_with_role_and_all_states(root, roles, states, pred) @staticmethod - def find_all_horizontal_separators(root, pred=None): + def find_all_horizontal_separators( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root that is a horizontal separator""" roles = [Atspi.Role.SEPARATOR] @@ -810,7 +1066,10 @@ class AXUtilitiesCollection: return AXUtilitiesCollection.find_all_with_role_and_all_states(root, roles, states, pred) @staticmethod - def find_all_horizontal_sliders(root, pred=None): + def find_all_horizontal_sliders( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root that is a horizontal slider""" roles = [Atspi.Role.SLIDER] @@ -818,105 +1077,161 @@ class AXUtilitiesCollection: return AXUtilitiesCollection.find_all_with_role_and_all_states(root, roles, states, pred) @staticmethod - def find_all_icons(root, pred=None): + def find_all_icons( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the icon role""" roles = [Atspi.Role.ICON] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_icons_and_canvases(root, pred=None): + def find_all_icons_and_canvases( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the icon or canvas role""" roles = [Atspi.Role.ICON, Atspi.Role.CANVAS] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_images(root, pred=None): + def find_all_images( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the image role""" roles = [Atspi.Role.IMAGE] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_images_and_canvases(root, pred=None): + def find_all_images_and_canvases( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the image or canvas role""" roles = [Atspi.Role.IMAGE, Atspi.Role.CANVAS] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_images_and_image_maps(root, pred=None): + def find_all_images_and_image_maps( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the image or image map role""" roles = [Atspi.Role.IMAGE, Atspi.Role.IMAGE_MAP] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_image_maps(root, pred=None): + def find_all_image_maps( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the image map role""" roles = [Atspi.Role.IMAGE_MAP] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_info_bars(root, pred=None): + def find_all_info_bars( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the info bar role""" roles = [Atspi.Role.INFO_BAR] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_input_method_windows(root, pred=None): + def find_all_input_method_windows( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the input method window role""" roles = [Atspi.Role.INPUT_METHOD_WINDOW] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_internal_frames(root, pred=None): + def find_all_internal_frames( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the internal frame role""" roles = [Atspi.Role.INTERNAL_FRAME] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_labels(root, pred=None): + def find_all_labels( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the label role""" roles = [Atspi.Role.LABEL] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_labels_and_captions(root, pred=None): + def find_all_labels_and_captions( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the label or caption role""" roles = [Atspi.Role.LABEL, Atspi.Role.CAPTION] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_landmarks(root, pred=None): + def find_all_landmarks( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the landmark role""" roles = [Atspi.Role.LANDMARK] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_layered_panes(root, pred=None): + def find_all_large_containers( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: + """Returns all descendants of root that we consider a large container""" + + roles = AXUtilitiesRole.get_large_container_roles() + return AXUtilitiesCollection.find_all_with_role(root, roles, pred) + + @staticmethod + def find_all_layered_panes( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the layered pane role""" roles = [Atspi.Role.LAYERED_PANE] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_level_bars(root, pred=None): + def find_all_level_bars( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the level bar role""" roles = [Atspi.Role.LEVEL_BAR] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_links(root, must_be_focusable=True, pred=None): + def find_all_links( + root: Atspi.Accessible, + must_be_focusable: bool = True, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the link role""" roles = [Atspi.Role.LINK] @@ -927,7 +1242,10 @@ class AXUtilitiesCollection: return AXUtilitiesCollection.find_all_with_role_and_all_states(root, roles, states, pred) @staticmethod - def find_all_live_regions(root, pred=None): + def find_all_live_regions( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root that are live regions""" if root is None: @@ -935,14 +1253,15 @@ class AXUtilitiesCollection: tokens = ["AXUtilitiesCollection:", inspect.currentframe(), "Root:", root, "pred:", pred] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) attributes = [] levels = ["off", "polite", "assertive"] for level in levels: attributes.append('container-live:' + level) - rule = AXCollection.create_match_rule(attributes=attributes) + rule = AXCollection.create_match_rule(attributes=attributes, + attribute_match_type=Atspi.CollectionMatchType.ANY) matches = AXCollection.get_all_matches(root, rule) if pred is not None: matches = AXUtilitiesCollection._apply_predicate(matches, pred) @@ -950,105 +1269,162 @@ class AXUtilitiesCollection: return matches @staticmethod - def find_all_lists(root, pred=None): + def find_all_lists( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None, + include_description_lists: bool = False, + include_tab_lists: bool = False + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the list role""" roles = [Atspi.Role.LIST] + if include_description_lists: + roles.append(Atspi.Role.DESCRIPTION_LIST) + if include_tab_lists: + roles.append(Atspi.Role.PAGE_TAB_LIST) return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_list_boxes(root, pred=None): + def find_all_list_boxes( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the list box role""" roles = [Atspi.Role.LIST_BOX] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_list_items(root, pred=None): + def find_all_list_items( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None, + include_description_terms: bool = False, + include_tabs: bool = False + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the list item role""" roles = [Atspi.Role.LIST_ITEM] + if include_description_terms: + roles.append(Atspi.Role.DESCRIPTION_TERM) + if include_tabs: + roles.append(Atspi.Role.PAGE_TAB) return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_logs(root, pred=None): + def find_all_logs( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the log role""" roles = [Atspi.Role.LOG] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_marks(root, pred=None): + def find_all_marks( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the mark role""" roles = [Atspi.Role.MARK] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_marquees(root, pred=None): + def find_all_marquees( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the marquee role""" roles = [Atspi.Role.MARQUEE] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_maths(root, pred=None): + def find_all_maths( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the math role""" roles = [Atspi.Role.MATH] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_math_fractions(root, pred=None): + def find_all_math_fractions( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the math fraction role""" roles = [Atspi.Role.MATH_FRACTION] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_math_roots(root, pred=None): + def find_all_math_roots( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the math root role""" roles = [Atspi.Role.MATH_ROOT] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_menus(root, pred=None): + def find_all_menus( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the menu role""" roles = [Atspi.Role.MENU] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_menu_bars(root, pred=None): + def find_all_menu_bars( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the menubar role""" roles = [Atspi.Role.MENU_BAR] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_menu_items(root, pred=None): + def find_all_menu_items( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the menu item role""" roles = [Atspi.Role.MENU_ITEM] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_menu_items_of_any_kind(root, pred=None): + def find_all_menu_items_of_any_kind( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root that has any menu item role""" roles = AXUtilitiesRole.get_menu_item_roles() return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_menu_related_objects(root, pred=None): + def find_all_menu_related_objects( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root that has any menu-related role""" roles = AXUtilitiesRole.get_menu_related_roles() return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_modal_dialogs(root, pred=None): + def find_all_modal_dialogs( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the alert or dialog role and modal state""" roles = AXUtilitiesRole.get_dialog_roles(True) @@ -1056,7 +1432,10 @@ class AXUtilitiesCollection: return AXUtilitiesCollection.find_all_with_role_and_all_states(root, roles, states, pred) @staticmethod - def find_all_multi_line_entries(root, pred=None): + def find_all_multi_line_entries( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the entry role and multiline state""" roles = [Atspi.Role.ENTRY] @@ -1064,56 +1443,81 @@ class AXUtilitiesCollection: return AXUtilitiesCollection.find_all_with_role_and_all_states(root, roles, states, pred) @staticmethod - def find_all_notifications(root, pred=None): + def find_all_notifications( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the notification role""" roles = [Atspi.Role.NOTIFICATION] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_option_panes(root, pred=None): + def find_all_option_panes( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the option pane role""" roles = [Atspi.Role.OPTION_PANE] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_pages(root, pred=None): + def find_all_pages( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the page role""" roles = [Atspi.Role.PAGE] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_page_tabs(root, pred=None): + def find_all_page_tabs( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the page tab role""" roles = [Atspi.Role.PAGE_TAB] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_page_tab_lists(root, pred=None): + def find_all_page_tab_lists( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the page tab list role""" roles = [Atspi.Role.PAGE_TAB_LIST] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_page_tab_list_related_objects(root, pred=None): + def find_all_page_tab_list_related_objects( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the page tab or page tab list role""" roles = [Atspi.Role.PAGE_TAB_LIST, Atspi.Role.PAGE_TAB] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_panels(root, pred=None): + def find_all_panels( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the panel role""" roles = [Atspi.Role.PANEL] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_paragraphs(root, treat_headings_as_paragraphs=False, pred=None): + def find_all_paragraphs( + root: Atspi.Accessible, + treat_headings_as_paragraphs: bool = False, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the paragraph role""" roles = [Atspi.Role.PARAGRAPH] @@ -1122,154 +1526,220 @@ class AXUtilitiesCollection: return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_password_texts(root, pred=None): + def find_all_password_texts( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the password text role""" roles = [Atspi.Role.PASSWORD_TEXT] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_popup_menus(root, pred=None): + def find_all_popup_menus( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the popup menu role""" roles = [Atspi.Role.POPUP_MENU] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_progress_bars(root, pred=None): + def find_all_progress_bars( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the progress bar role""" roles = [Atspi.Role.PROGRESS_BAR] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_push_buttons(root, pred=None): + def find_all_push_buttons( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the push button role""" - roles = [Atspi.Role.PUSH_BUTTON] + roles = [Atspi.Role.BUTTON] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_push_button_menus(root, pred=None): + def find_all_push_button_menus( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the push button menu role""" roles = [Atspi.Role.PUSH_BUTTON_MENU] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_radio_buttons(root, pred=None): + def find_all_radio_buttons( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the radio button role""" roles = [Atspi.Role.RADIO_BUTTON] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_radio_menu_items(root, pred=None): + def find_all_radio_menu_items( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the radio menu item role""" roles = [Atspi.Role.RADIO_MENU_ITEM] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_ratings(root, pred=None): + def find_all_ratings( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the rating role""" roles = [Atspi.Role.RATING] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_root_panes(root, pred=None): + def find_all_root_panes( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the root pane role""" roles = [Atspi.Role.ROOT_PANE] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_row_headers(root, pred=None): + def find_all_row_headers( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the row header role""" roles = [Atspi.Role.ROW_HEADER] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_rulers(root, pred=None): + def find_all_rulers( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the ruler role""" roles = [Atspi.Role.RULER] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_scroll_bars(root, pred=None): + def find_all_scroll_bars( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the scrollbar role""" roles = [Atspi.Role.SCROLL_BAR] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_scroll_panes(root, pred=None): + def find_all_scroll_panes( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the scroll pane role""" roles = [Atspi.Role.SCROLL_PANE] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_sections(root, pred=None): + def find_all_sections( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the section role""" roles = [Atspi.Role.SECTION] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_selectable_objects(root, pred=None): + def find_all_selectable_objects( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root which are selectable""" states = [Atspi.StateType.SELECTABLE] return AXUtilitiesCollection.find_all_with_states(root, states, pred) @staticmethod - def find_all_selected_objects(root, pred=None): + def find_all_selected_objects( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root which are selected""" states = [Atspi.StateType.SELECTED] return AXUtilitiesCollection.find_all_with_states(root, states, pred) @staticmethod - def find_all_separators(root, pred=None): + def find_all_separators( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the separator role""" roles = [Atspi.Role.SEPARATOR] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_set_containers(root, pred=None): + def find_all_set_containers( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with a set container role""" roles = AXUtilitiesRole.get_set_container_roles() return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_showing_objects(root, pred=None): + def find_all_showing_objects( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root which are showing""" states = [Atspi.StateType.SHOWING] return AXUtilitiesCollection.find_all_with_states(root, states, pred) @staticmethod - def find_all_showing_and_visible_objects(root, pred=None): + def find_all_showing_and_visible_objects( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root which are showing and visible""" states = [Atspi.StateType.SHOWING, Atspi.StateType.VISIBLE] return AXUtilitiesCollection.find_all_with_states(root, states, pred) @staticmethod - def find_all_showing_or_visible_objects(root, pred=None): + def find_all_showing_or_visible_objects( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root which are showing or visible""" states = [Atspi.StateType.SHOWING, Atspi.StateType.VISIBLE] return AXUtilitiesCollection.find_all_with_any_state(root, states, pred) @staticmethod - def find_all_single_line_entries(root, pred=None): + def find_all_single_line_entries( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the entry role and multiline state""" roles = [Atspi.Role.ENTRY] @@ -1277,298 +1747,443 @@ class AXUtilitiesCollection: return AXUtilitiesCollection.find_all_with_role_and_all_states(root, roles, states, pred) @staticmethod - def find_all_sliders(root, pred=None): + def find_all_sliders( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the slider role""" roles = [Atspi.Role.SLIDER] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_spin_buttons(root, pred=None): + def find_all_spin_buttons( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the spin button role""" roles = [Atspi.Role.SPIN_BUTTON] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_split_panes(root, pred=None): + def find_all_split_panes( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the split pane role""" roles = [Atspi.Role.SPLIT_PANE] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_statics(root, pred=None): + def find_all_statics( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the static role""" roles = [Atspi.Role.STATIC] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_status_bars(root, pred=None): + def find_all_status_bars( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the statusbar role""" roles = [Atspi.Role.STATUS_BAR] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_subscripts(root, pred=None): + def find_all_subscripts( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the subscript role""" roles = [Atspi.Role.SUBSCRIPT] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_subscripts_and_superscripts(root, pred=None): + def find_all_subscripts_and_superscripts( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the subscript or superscript role""" roles = [Atspi.Role.SUBSCRIPT, Atspi.Role.SUPERSCRIPT] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_suggestions(root, pred=None): + def find_all_suggestions( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the suggestion role""" roles = [Atspi.Role.SUGGESTION] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_superscripts(root, pred=None): + def find_all_superscripts( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the superscript role""" roles = [Atspi.Role.SUPERSCRIPT] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_supports_action(root, pred=None): + def find_all_supports_action( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root which support the action interface""" interfaces = ["Action"] return AXUtilitiesCollection.find_all_with_interfaces(root, interfaces, pred) @staticmethod - def find_all_supports_document(root, pred=None): + def find_all_supports_document( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root which support the document interface""" interfaces = ["Document"] return AXUtilitiesCollection.find_all_with_interfaces(root, interfaces, pred) @staticmethod - def find_all_supports_editable_text(root, pred=None): + def find_all_supports_editable_text( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root which support the editable text interface""" interfaces = ["EditableText"] return AXUtilitiesCollection.find_all_with_interfaces(root, interfaces, pred) @staticmethod - def find_all_supports_hypertext(root, pred=None): + def find_all_supports_hypertext( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root which support the hypertext interface""" interfaces = ["Hypertext"] return AXUtilitiesCollection.find_all_with_interfaces(root, interfaces, pred) @staticmethod - def find_all_supports_hyperlink(root, pred=None): + def find_all_supports_hyperlink( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root which support the hyperlink interface""" interfaces = ["Hyperlink"] return AXUtilitiesCollection.find_all_with_interfaces(root, interfaces, pred) @staticmethod - def find_all_supports_selection(root, pred=None): + def find_all_supports_selection( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root which support the selection interface""" interfaces = ["Selection"] return AXUtilitiesCollection.find_all_with_interfaces(root, interfaces, pred) @staticmethod - def find_all_supports_table(root, pred=None): + def find_all_supports_table( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root which support the table interface""" interfaces = ["Table"] return AXUtilitiesCollection.find_all_with_interfaces(root, interfaces, pred) @staticmethod - def find_all_supports_table_cell(root, pred=None): + def find_all_supports_table_cell( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root which support the table cell interface""" interfaces = ["TableCell"] return AXUtilitiesCollection.find_all_with_interfaces(root, interfaces, pred) @staticmethod - def find_all_supports_text(root, pred=None): + def find_all_supports_text( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root which support the text interface""" interfaces = ["Text"] return AXUtilitiesCollection.find_all_with_interfaces(root, interfaces, pred) @staticmethod - def find_all_supports_value(root, pred=None): + def find_all_supports_value( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root which support the value interface""" interfaces = ["Value"] return AXUtilitiesCollection.find_all_with_interfaces(root, interfaces, pred) @staticmethod - def find_all_tables(root, pred=None): + def find_all_tables( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the table role""" + if root is None: + return [] + + tokens = ["AXUtilitiesCollection:", inspect.currentframe(), "Root:", root, "pred:", pred] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + roles = [Atspi.Role.TABLE] - return AXUtilitiesCollection.find_all_with_role(root, roles, pred) + attributes = ["layout-guess:true"] + attribute_match_type = Atspi.CollectionMatchType.NONE + rule = AXCollection.create_match_rule( + roles=roles, + attributes=attributes, + attribute_match_type=attribute_match_type) + + tables = AXCollection.get_all_matches(root, rule) + if pred is not None: + AXUtilitiesCollection._apply_predicate(tables, pred) + + return tables @staticmethod - def find_all_table_cells(root, pred=None): + def find_all_table_cells( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the table cell role""" roles = [Atspi.Role.TABLE_CELL] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_table_cells_and_headers(root, pred=None): + def find_all_table_cells_and_headers( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the table cell or a header-related role""" roles = AXUtilitiesRole.get_table_cell_roles() return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_table_column_headers(root, pred=None): + def find_all_table_column_headers( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the table column header role""" roles = [Atspi.Role.TABLE_COLUMN_HEADER] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_table_headers(root, pred=None): + def find_all_table_headers( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root that has a table header related role""" roles = AXUtilitiesRole.get_table_header_roles() return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_table_related_objects(root, pred=None, include_caption=False): + def find_all_table_related_objects( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None, + include_caption: bool = False + ) -> list[Atspi.Accessible]: """Returns all descendants of root that has a table related role""" roles = AXUtilitiesRole.get_table_related_roles(include_caption) return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_table_rows(root, pred=None): + def find_all_table_rows( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the table row role""" roles = [Atspi.Role.TABLE_ROW] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_table_row_headers(root, pred=None): + def find_all_table_row_headers( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the table row header role""" roles = [Atspi.Role.TABLE_ROW_HEADER] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_tearoff_menu_items(root, pred=None): + def find_all_tearoff_menu_items( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the tearoff menu item role""" roles = [Atspi.Role.TEAROFF_MENU_ITEM] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_terminals(root, pred=None): + def find_all_terminals( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the terminal role""" roles = [Atspi.Role.TERMINAL] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_texts(root, pred=None): + def find_all_texts( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the text role""" roles = [Atspi.Role.TEXT] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_text_inputs(root, pred=None): + def find_all_text_inputs( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root that has any role associated with textual input""" roles = [Atspi.Role.ENTRY, Atspi.Role.PASSWORD_TEXT, Atspi.Role.SPIN_BUTTON] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_timers(root, pred=None): + def find_all_timers( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the timer role""" roles = [Atspi.Role.TIMER] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_title_bars(root, pred=None): + def find_all_title_bars( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the titlebar role""" roles = [Atspi.Role.TITLE_BAR] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_toggle_buttons(root, pred=None): + def find_all_toggle_buttons( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the toggle button role""" roles = [Atspi.Role.TOGGLE_BUTTON] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_tool_bars(root, pred=None): + def find_all_tool_bars( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the toolbar role""" roles = [Atspi.Role.TOOL_BAR] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_tool_tips(root, pred=None): + def find_all_tool_tips( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the tooltip role""" roles = [Atspi.Role.TOOL_TIP] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_trees(root, pred=None): + def find_all_trees( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the tree role""" roles = [Atspi.Role.TREE] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_trees_and_tree_tables(root, pred=None): + def find_all_trees_and_tree_tables( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the tree or tree table role""" roles = [Atspi.Role.TREE, Atspi.Role.TREE_TABLE] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_tree_related_objects(root, pred=None): + def find_all_tree_related_objects( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root that has a tree related role""" roles = AXUtilitiesRole.get_tree_related_roles() return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_tree_items(root, pred=None): + def find_all_tree_items( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the tree item role""" roles = [Atspi.Role.TREE_ITEM] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_tree_tables(root, pred=None): + def find_all_tree_tables( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the tree table role""" roles = [Atspi.Role.TREE_TABLE] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_unrelated_labels(root, must_be_showing=True, pred=None): + def find_all_unrelated_labels( + root: Atspi.Accessible, + must_be_showing: bool = True, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all the descendants of root that have a label role, but no relations""" def _pred(obj): - if AXObject.get_relations(obj): + if not AXUtilitiesRelation.object_is_unrelated(obj): return False if pred is not None: return pred(obj) @@ -1585,7 +2200,11 @@ class AXUtilitiesCollection: return matches @staticmethod - def find_all_unvisited_links(root, must_be_focusable=True, pred=None): + def find_all_unvisited_links( + root: Atspi.Accessible, + must_be_focusable: bool = True, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the link role and without the visited state""" roles = [Atspi.Role.LINK] @@ -1596,7 +2215,10 @@ class AXUtilitiesCollection: return result @staticmethod - def find_all_vertical_scrollbars(root, pred=None): + def find_all_vertical_scrollbars( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root that is a vertical scrollbar""" roles = [Atspi.Role.SCROLL_BAR] @@ -1604,7 +2226,10 @@ class AXUtilitiesCollection: return AXUtilitiesCollection.find_all_with_role_and_all_states(root, roles, states, pred) @staticmethod - def find_all_vertical_separators(root, pred=None): + def find_all_vertical_separators( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root that is a vertical separator""" roles = [Atspi.Role.SEPARATOR] @@ -1612,7 +2237,10 @@ class AXUtilitiesCollection: return AXUtilitiesCollection.find_all_with_role_and_all_states(root, roles, states, pred) @staticmethod - def find_all_vertical_sliders(root, pred=None): + def find_all_vertical_sliders( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root that is a vertical slider""" roles = [Atspi.Role.SLIDER] @@ -1620,28 +2248,41 @@ class AXUtilitiesCollection: return AXUtilitiesCollection.find_all_with_role_and_all_states(root, roles, states, pred) @staticmethod - def find_all_visible_objects(root, pred=None): + def find_all_visible_objects( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root which are visible""" states = [Atspi.StateType.VISIBLE] return AXUtilitiesCollection.find_all_with_states(root, states, pred) @staticmethod - def find_all_videos(root, pred=None): + def find_all_videos( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the video role""" roles = [Atspi.Role.VIDEO] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_viewports(root, pred=None): + def find_all_viewports( + root: Atspi.Accessible, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the viewport role""" roles = [Atspi.Role.VIEWPORT] return AXUtilitiesCollection.find_all_with_role(root, roles, pred) @staticmethod - def find_all_visited_links(root, must_be_focusable=True, pred=None): + def find_all_visited_links( + root: Atspi.Accessible, + must_be_focusable: bool = True, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: """Returns all descendants of root with the link role and focused and visited states""" roles = [Atspi.Role.LINK] @@ -1651,16 +2292,16 @@ class AXUtilitiesCollection: return AXUtilitiesCollection.find_all_with_role_and_all_states(root, roles, states, pred) @staticmethod - def find_default_button(root): + def find_default_button(root: Atspi.Accessible) -> Atspi.Accessible | None: """Returns the default button inside root""" - roles = [Atspi.Role.PUSH_BUTTON] + roles = [Atspi.Role.BUTTON] states = [Atspi.StateType.IS_DEFAULT] rule = AXCollection.create_match_rule(roles=roles, states=states) return AXCollection.get_first_match(root, rule) @staticmethod - def find_focused_object(root): + def find_focused_object(root: Atspi.Accessible) -> Atspi.Accessible | None: """Returns the focused object inside root""" states = [Atspi.StateType.FOCUSED] @@ -1668,10 +2309,69 @@ class AXUtilitiesCollection: return AXCollection.get_first_match(root, rule) @staticmethod - def find_status_bar(root): + def find_info_bar(root: Atspi.Accessible) -> Atspi.Accessible | None: + """Returns the info bar inside root""" + + roles = [Atspi.Role.INFO_BAR] + states = [Atspi.StateType.SHOWING, Atspi.StateType.VISIBLE] + rule = AXCollection.create_match_rule(roles=roles, states=states) + return AXCollection.get_first_match(root, rule) + + @staticmethod + def find_status_bar(root: Atspi.Accessible) -> Atspi.Accessible | None: """Returns the status bar inside root""" roles = [Atspi.Role.STATUS_BAR] states = [Atspi.StateType.SHOWING, Atspi.StateType.VISIBLE] rule = AXCollection.create_match_rule(roles=roles, states=states) return AXCollection.get_first_match(root, rule) + + @staticmethod + def has_combo_box_or_list_box(root: Atspi.Accessible) -> bool: + """Returns True if there's a showing, visible combobox or listbox inside root""" + + roles = [Atspi.Role.COMBO_BOX, Atspi.Role.LIST_BOX] + states = [Atspi.StateType.SHOWING, Atspi.StateType.VISIBLE] + rule = AXCollection.create_match_rule(roles=roles, + role_match_type=Atspi.CollectionMatchType.ANY, + states=states) + return bool(AXCollection.get_first_match(root, rule)) + + @staticmethod + def has_editable_object(root: Atspi.Accessible) -> bool: + """Returns True if there's a showing, visible, editable object inside root""" + + states = [Atspi.StateType.SHOWING, Atspi.StateType.VISIBLE, Atspi.StateType.EDITABLE] + rule = AXCollection.create_match_rule(states=states) + return bool(AXCollection.get_first_match(root, rule)) + + @staticmethod + def has_scroll_pane(root: Atspi.Accessible) -> bool: + """Returns True if there's a showing, visible scroll pane inside root""" + + roles = [Atspi.Role.SCROLL_PANE] + states = [Atspi.StateType.SHOWING, Atspi.StateType.VISIBLE] + rule = AXCollection.create_match_rule(roles=roles, + role_match_type=Atspi.CollectionMatchType.ANY, + states=states) + return bool(AXCollection.get_first_match(root, rule)) + + @staticmethod + def has_split_pane(root: Atspi.Accessible) -> bool: + """Returns True if there's a showing, visible split pane inside root""" + + roles = [Atspi.Role.SPLIT_PANE] + states = [Atspi.StateType.SHOWING, Atspi.StateType.VISIBLE] + rule = AXCollection.create_match_rule(roles=roles, states=states) + return bool(AXCollection.get_first_match(root, rule)) + + @staticmethod + def has_tree_or_tree_table(root: Atspi.Accessible) -> bool: + """Returns True if there's a showing, visible tree or tree table inside root""" + + roles = [Atspi.Role.TREE, Atspi.Role.TREE_TABLE] + states = [Atspi.StateType.SHOWING, Atspi.StateType.VISIBLE] + rule = AXCollection.create_match_rule(roles=roles, + role_match_type=Atspi.CollectionMatchType.ANY, + states=states) + return bool(AXCollection.get_first_match(root, rule)) diff --git a/src/cthulhu/ax_utilities_debugging.py b/src/cthulhu/ax_utilities_debugging.py new file mode 100644 index 0000000..15d7904 --- /dev/null +++ b/src/cthulhu/ax_utilities_debugging.py @@ -0,0 +1,281 @@ +# Orca +# +# 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 +# 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=too-many-branches +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-return-statements + +"""Utilities for obtaining accessibility information for debugging.""" + +__id__ = "$Id$" +__version__ = "$Revision$" +__date__ = "$Date$" +__copyright__ = "Copyright (c) 2024 Igalia, S.L." \ + "Copyright (c) 2024 GNOME Foundation Inc." +__license__ = "LGPL" + +import inspect +import pprint +import types +from typing import Any + +import gi +gi.require_version("Atspi", "2.0") +from gi.repository import Atspi +from gi.repository import GLib + +from .ax_object import AXObject +from .ax_utilities_application import AXUtilitiesApplication +from .ax_utilities_relation import AXUtilitiesRelation + + +class AXUtilitiesDebugging: + """Utilities for obtaining accessibility information for debugging.""" + + @staticmethod + def _format_string(string: str = "") -> str: + if not string: + return "" + + string = string.replace("\n", "\\n").replace("\ufffc", "[OBJ]") + if len(string) < 100: + return string + + words = string.split() + string = f"{' '.join(words[:5])} ... {' '.join(words[-5:])} ({len(string)} chars.)" + return string + + @staticmethod + def as_string(obj: Any) -> str: + """Turns obj into a human-consumable string.""" + + if isinstance(obj, Atspi.Accessible): + result = AXObject.get_role_name(obj) + name = AXObject.get_name(obj) + if name: + result += f": '{AXUtilitiesDebugging._format_string(name)}'" + if not result: + result = "DEAD" + + return f"[{result} ({hex(id(obj))})] " + + if isinstance(obj, Atspi.Event): + any_data = AXUtilitiesDebugging._format_string( + AXUtilitiesDebugging.as_string(obj.any_data)) + return ( + f"{obj.type} for {AXUtilitiesDebugging.as_string(obj.source)} in " + f"{AXUtilitiesApplication.application_as_string(obj.source)} " + f"({obj.detail1}, {obj.detail2}, {any_data})" + ) + + if isinstance(obj, (Atspi.Role, Atspi.StateType, Atspi.CollectionMatchType, + Atspi.TextGranularity, Atspi.ScrollType)): + return obj.value_nick + + if isinstance(obj, Atspi.Rect): + return f"(x:{obj.x}, y:{obj.y}, width:{obj.width}, height:{obj.height})" + + if isinstance(obj, (list, set)): + return f"[{', '.join(map(AXUtilitiesDebugging.as_string, obj))}]" + + if isinstance(obj, dict): + stringified = {key: AXUtilitiesDebugging.as_string(value) for key, value in obj.items()} + formatter = pprint.PrettyPrinter(width=150) + return f"{formatter.pformat(stringified)}" + + if isinstance(obj, types.FunctionType): + if hasattr(obj, "__self__"): + return f"{obj.__module__}.{obj.__self__.__class__.__name__}.{obj.__name__}" + return f"{obj.__module__}.{obj.__name__}" + + if isinstance(obj, types.MethodType): + if hasattr(obj, "__self__"): + return f"{obj.__self__.__class__.__name__}.{obj.__name__}" + return f"{obj.__name__}" + + if isinstance(obj, types.FrameType): + module_name = inspect.getmodulename(obj.f_code.co_filename) + return f"{module_name}.{obj.f_code.co_name}" + + if isinstance(obj, inspect.FrameInfo): + module_name = inspect.getmodulename(obj.filename) or "" + return f"{module_name}.{obj.function}:{obj.lineno}" + + return str(obj) + + @staticmethod + def actions_as_string(obj: Atspi.Accessible) -> str: + """Returns information about the actions as a string.""" + + results = [] + for i in range(AXObject.get_n_actions(obj)): + result = AXObject.get_action_name(obj, i) + keybinding = AXObject.get_action_key_binding(obj, i) + if keybinding: + result += f" ({keybinding})" + results.append(result) + + return "; ".join(results) + + @staticmethod + def attributes_as_string(obj: Atspi.Accessible) -> str: + """Returns the object attributes of obj as a string.""" + + def as_string(attribute): + return f"{attribute[0]}:{attribute[1]}" + + return ", ".join(map(as_string, AXObject.get_attributes_dict(obj).items())) + + @staticmethod + def interfaces_as_string(obj: Atspi.Accessible) -> str: + """Returns the supported interfaces of obj as a string.""" + + if not AXObject.is_valid(obj): + return "" + + iface_checks = [ + (AXObject.supports_action, "Action"), + (AXObject.supports_collection, "Collection"), + (AXObject.supports_component, "Component"), + (AXObject.supports_document, "Document"), + (AXObject.supports_editable_text, "EditableText"), + (AXObject.supports_hyperlink, "Hyperlink"), + (AXObject.supports_hypertext, "Hypertext"), + (AXObject.supports_image, "Image"), + (AXObject.supports_selection, "Selection"), + (AXObject.supports_table, "Table"), + (AXObject.supports_table_cell, "TableCell"), + (AXObject.supports_text, "Text"), + (AXObject.supports_value, "Value"), + ] + + ifaces = [iface for check, iface in iface_checks if check(obj)] + return ", ".join(ifaces) + + @staticmethod + def relations_as_string(obj: Atspi.Accessible) -> str: + """Returns the relations associated with obj as a string.""" + + if not AXObject.is_valid(obj): + return "" + + def as_string(relations): + return relations.value_name[15:].replace("_", "-").lower() + + def obj_as_string(acc): + result = AXObject.get_role_name(acc) + name = AXObject.get_name(acc) + if name: + result += f": '{name}'" + if not result: + result = "DEAD" + return f"[{result}]" + + results = [] + for rel in AXUtilitiesRelation.get_relations(obj): + type_string = as_string(rel.get_relation_type()) + targets = AXUtilitiesRelation.get_relation_targets_for_debugging( + obj, rel.get_relation_type()) + target_string = ",".join(map(obj_as_string, targets)) + results.append(f"{type_string}: {target_string}") + + return "; ".join(results) + + @staticmethod + def state_set_as_string(obj: Atspi.Accessible) -> str: + """Returns the state set associated with obj as a string.""" + + if not AXObject.is_valid(obj): + return "" + + def as_string(state): + return state.value_name[12:].replace("_", "-").lower() + + return ", ".join(map(as_string, AXObject.get_state_set(obj).get_states())) + + @staticmethod + def text_for_debugging(obj: Atspi.Accessible) -> str: + """Returns the text content of obj for debugging.""" + + if not AXObject.supports_text(obj): + return "" + + try: + result = Atspi.Text.get_text(obj, 0, Atspi.Text.get_character_count(obj)) + except GLib.GError: + return "" + + return AXUtilitiesDebugging._format_string(result) + + @staticmethod + def object_details_as_string( + obj: Atspi.Accessible, + indent: str = "", + include_app: bool = True + ) -> str: + """Returns a string, suitable for printing, that describes details about obj.""" + + if not isinstance(obj, Atspi.Accessible): + return "" + + if AXObject.is_dead(obj): + return "(exception fetching data)" + + if include_app: + string = f"{indent}app='{AXUtilitiesApplication.application_as_string(obj)}' " + else: + string = indent + + name = AXUtilitiesDebugging._format_string(AXObject.get_name(obj)) + desc = AXUtilitiesDebugging._format_string(AXObject.get_description(obj)) + help_text = AXUtilitiesDebugging._format_string(AXObject.get_help_text(obj)) + obj_locale = AXObject.get_locale(obj) + ax_id = AXObject.get_accessible_id(obj) + string += ( + f"name='{name}' role='{AXObject.get_role_name(obj)}'" + f" axid='{ax_id}' id={hex(id(obj))}\n" + f"{indent}description='{desc}'\n" + f"{indent}help='{help_text}'\n" + f"{indent}locale='{obj_locale}'\n" + f"{indent}states='{AXUtilitiesDebugging.state_set_as_string(obj)}'\n" + f"{indent}relations='{AXUtilitiesDebugging.relations_as_string(obj)}'\n" + f"{indent}actions='{AXUtilitiesDebugging.actions_as_string(obj)}'\n" + f"{indent}interfaces='{AXUtilitiesDebugging.interfaces_as_string(obj)}'\n" + f"{indent}attributes='{AXUtilitiesDebugging.attributes_as_string(obj)}'\n" + f"{indent}text='{AXUtilitiesDebugging.text_for_debugging(obj)}'\n" + f"{indent}path={AXObject.get_path(obj)}" + ) + return string + + @staticmethod + def object_event_details_as_string(event: Atspi.Event, indent: str = "") -> str: + """Returns a string, suitable for printing, with details about event.""" + + if event.type.startswith("mouse:"): + return "" + + source = AXUtilitiesDebugging.object_details_as_string(event.source, indent, True) + any_data = AXUtilitiesDebugging.object_details_as_string(event.any_data, indent, False) + string = f"EVENT SOURCE:\n{source}\n" + if any_data: + string += f"\nEVENT ANY DATA:\n{any_data}\n" + return string diff --git a/src/cthulhu/ax_utilities_event.py b/src/cthulhu/ax_utilities_event.py new file mode 100644 index 0000000..29b0e9c --- /dev/null +++ b/src/cthulhu/ax_utilities_event.py @@ -0,0 +1,838 @@ +# Utilities for obtaining event-related information. +# +# 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 +# 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=too-many-return-statements +# pylint: disable=too-many-branches +# pylint: disable=too-many-statements + +"""Utilities for obtaining event-related information.""" + +__id__ = "$Id$" +__version__ = "$Revision$" +__date__ = "$Date$" +__copyright__ = "Copyright (c) 2024 Igalia, S.L." \ + "Copyright (c) 2024 GNOME Foundation Inc." +__license__ = "LGPL" + +import enum +import threading +import time + +import gi +gi.require_version("Atspi", "2.0") +from gi.repository import Atspi + +from . import debug +from . import focus_manager + +from .ax_object import AXObject +from .ax_text import AXText +from .ax_utilities_role import AXUtilitiesRole +from .ax_utilities_state import AXUtilitiesState + +class TextEventReason(enum.Enum): + """Enum representing the reason for an object:text- event.""" + + UNKNOWN = enum.auto() + AUTO_DELETION = enum.auto() + AUTO_INSERTION_PRESENTABLE = enum.auto() + AUTO_INSERTION_UNPRESENTABLE = enum.auto() + BACKSPACE = enum.auto() + CHILDREN_CHANGE = enum.auto() + CUT = enum.auto() + DELETE = enum.auto() + FOCUS_CHANGE = enum.auto() + LIVE_REGION_UPDATE = enum.auto() + MOUSE_MIDDLE_BUTTON = enum.auto() + MOUSE_PRIMARY_BUTTON = enum.auto() + NAVIGATION_BY_CHARACTER = enum.auto() + NAVIGATION_BY_LINE = enum.auto() + NAVIGATION_BY_PARAGRAPH = enum.auto() + NAVIGATION_BY_PAGE = enum.auto() + NAVIGATION_BY_WORD = enum.auto() + NAVIGATION_TO_FILE_BOUNDARY = enum.auto() + NAVIGATION_TO_LINE_BOUNDARY = enum.auto() + PAGE_SWITCH = enum.auto() + PASTE = enum.auto() + REDO = enum.auto() + SAY_ALL = enum.auto() + SEARCH_PRESENTABLE = enum.auto() + SEARCH_UNPRESENTABLE = enum.auto() + SELECT_ALL = enum.auto() + SELECTED_TEXT_DELETION = enum.auto() + SELECTED_TEXT_INSERTION = enum.auto() + SELECTED_TEXT_RESTORATION = enum.auto() + SELECTION_BY_CHARACTER = enum.auto() + SELECTION_BY_LINE = enum.auto() + SELECTION_BY_PARAGRAPH = enum.auto() + SELECTION_BY_PAGE = enum.auto() + SELECTION_BY_WORD = enum.auto() + SELECTION_TO_FILE_BOUNDARY = enum.auto() + SELECTION_TO_LINE_BOUNDARY = enum.auto() + SPIN_BUTTON_VALUE_CHANGE = enum.auto() + TYPING = enum.auto() + TYPING_ECHOABLE = enum.auto() + UI_UPDATE = enum.auto() + UNDO = enum.auto() + UNSPECIFIED_COMMAND = enum.auto() + UNSPECIFIED_NAVIGATION = enum.auto() + UNSPECIFIED_SELECTION = enum.auto() + + +class AXUtilitiesEvent: + """Utilities for obtaining event-related information.""" + + LAST_KNOWN_DESCRIPTION: dict[int, str] = {} + LAST_KNOWN_NAME: dict[int, str] = {} + + LAST_KNOWN_CHECKED: dict[int, bool] = {} + LAST_KNOWN_EXPANDED: dict[int, bool] = {} + LAST_KNOWN_INDETERMINATE: dict[int, bool] = {} + LAST_KNOWN_INVALID_ENTRY: dict[int, bool] = {} + LAST_KNOWN_PRESSED: dict[int, bool] = {} + LAST_KNOWN_SELECTED: dict[int, bool] = {} + + IGNORE_NAME_CHANGES_FOR: list[int] = [] + + TEXT_EVENT_REASON: dict[Atspi.Event, TextEventReason] = {} + + _lock = threading.Lock() + + @staticmethod + def _clear_stored_data() -> None: + """Clears any data we have cached for objects""" + + while True: + time.sleep(60) + AXUtilitiesEvent._clear_all_dictionaries() + + @staticmethod + def _clear_all_dictionaries(reason: str = "") -> None: + msg = "AXUtilitiesEvent: Clearing local cache." + if reason: + msg += f" Reason: {reason}" + debug.print_message(debug.LEVEL_INFO, msg, True) + AXUtilitiesEvent.LAST_KNOWN_DESCRIPTION.clear() + AXUtilitiesEvent.LAST_KNOWN_NAME.clear() + AXUtilitiesEvent.LAST_KNOWN_CHECKED.clear() + AXUtilitiesEvent.LAST_KNOWN_EXPANDED.clear() + AXUtilitiesEvent.LAST_KNOWN_INDETERMINATE.clear() + AXUtilitiesEvent.LAST_KNOWN_INVALID_ENTRY.clear() + AXUtilitiesEvent.LAST_KNOWN_PRESSED.clear() + AXUtilitiesEvent.LAST_KNOWN_SELECTED.clear() + AXUtilitiesEvent.TEXT_EVENT_REASON.clear() + AXUtilitiesEvent.IGNORE_NAME_CHANGES_FOR.clear() + + @staticmethod + def clear_cache_now(reason: str = "") -> None: + """Clears all cached information immediately.""" + + AXUtilitiesEvent._clear_all_dictionaries(reason) + + @staticmethod + def save_object_info_for_events(obj: Atspi.Accessible) -> None: + """Saves properties, states, etc. of obj for later use in event processing.""" + + if obj is None: + return + + AXUtilitiesEvent.LAST_KNOWN_DESCRIPTION[hash(obj)] = AXObject.get_description(obj) + AXUtilitiesEvent.LAST_KNOWN_NAME[hash(obj)] = AXObject.get_name(obj) + AXUtilitiesEvent.LAST_KNOWN_CHECKED[hash(obj)] = AXUtilitiesState.is_checked(obj) + AXUtilitiesEvent.LAST_KNOWN_EXPANDED[hash(obj)] = AXUtilitiesState.is_expanded(obj) + AXUtilitiesEvent.LAST_KNOWN_INDETERMINATE[hash(obj)] = \ + AXUtilitiesState.is_indeterminate(obj) + AXUtilitiesEvent.LAST_KNOWN_PRESSED[hash(obj)] = AXUtilitiesState.is_pressed(obj) + AXUtilitiesEvent.LAST_KNOWN_SELECTED[hash(obj)] = AXUtilitiesState.is_selected(obj) + + window = focus_manager.get_manager().get_active_window() + AXUtilitiesEvent.LAST_KNOWN_NAME[hash(window)] = AXObject.get_name(window) + AXUtilitiesEvent.LAST_KNOWN_DESCRIPTION[hash(window)] = AXObject.get_description(window) + + @staticmethod + def start_cache_clearing_thread() -> None: + """Starts thread to periodically clear cached details.""" + + thread = threading.Thread(target=AXUtilitiesEvent._clear_stored_data) + thread.daemon = True + thread.start() + + @staticmethod + def get_text_event_reason(event: Atspi.Event) -> TextEventReason: + """Returns the TextEventReason for the given event.""" + + reason = AXUtilitiesEvent.TEXT_EVENT_REASON.get(event) + if reason is not None: + tokens = ["AXUtilitiesEvent: Cached reason for", event, f": {reason}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return reason + + reason = TextEventReason.UNKNOWN + if event.type.startswith("object:text-changed:insert"): + reason = AXUtilitiesEvent._get_text_insertion_event_reason(event) + elif event.type.startswith("object:text-caret-moved"): + reason = AXUtilitiesEvent._get_caret_moved_event_reason(event) + elif event.type.startswith("object:text-changed:delete"): + reason = AXUtilitiesEvent._get_text_deletion_event_reason(event) + elif event.type.startswith("object:text-selection-changed"): + reason = AXUtilitiesEvent._get_text_selection_changed_event_reason(event) + else: + raise ValueError(f"Unexpected event type: {event.type}") + + AXUtilitiesEvent.TEXT_EVENT_REASON[event] = reason + tokens = ["AXUtilitiesEvent: Reason for", event, f": {reason}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return reason + + @staticmethod + def _get_caret_moved_event_reason(event: Atspi.Event) -> TextEventReason: + """Returns the TextEventReason for the given event.""" + + from . import input_event_manager # pylint: disable=import-outside-toplevel + mgr = input_event_manager.get_manager() + + reason = TextEventReason.UNKNOWN + obj = event.source + mode, focus = focus_manager.get_manager().get_active_mode_and_object_of_interest() + if mode == focus_manager.SAY_ALL: + reason = TextEventReason.SAY_ALL + elif focus != obj and AXUtilitiesRole.is_text_input_search(focus): + if mgr.last_event_was_backspace() or mgr.last_event_was_delete(): + reason = TextEventReason.SEARCH_UNPRESENTABLE + else: + reason = TextEventReason.SEARCH_PRESENTABLE + elif mgr.last_event_was_caret_selection(): + if mgr.last_event_was_line_navigation(): + reason = TextEventReason.SELECTION_BY_LINE + elif mgr.last_event_was_word_navigation(): + reason = TextEventReason.SELECTION_BY_WORD + elif mgr.last_event_was_character_navigation(): + reason = TextEventReason.SELECTION_BY_CHARACTER + elif mgr.last_event_was_page_navigation(): + reason = TextEventReason.SELECTION_BY_PAGE + elif mgr.last_event_was_line_boundary_navigation(): + reason = TextEventReason.SELECTION_TO_LINE_BOUNDARY + elif mgr.last_event_was_file_boundary_navigation(): + reason = TextEventReason.SELECTION_TO_FILE_BOUNDARY + else: + reason = TextEventReason.UNSPECIFIED_SELECTION + elif mgr.last_event_was_caret_navigation(): + if mgr.last_event_was_line_navigation(): + reason = TextEventReason.NAVIGATION_BY_LINE + elif mgr.last_event_was_word_navigation(): + reason = TextEventReason.NAVIGATION_BY_WORD + elif mgr.last_event_was_character_navigation(): + reason = TextEventReason.NAVIGATION_BY_CHARACTER + elif mgr.last_event_was_page_navigation(): + reason = TextEventReason.NAVIGATION_BY_PAGE + elif mgr.last_event_was_line_boundary_navigation(): + reason = TextEventReason.NAVIGATION_TO_LINE_BOUNDARY + elif mgr.last_event_was_file_boundary_navigation(): + reason = TextEventReason.NAVIGATION_TO_FILE_BOUNDARY + else: + reason = TextEventReason.UNSPECIFIED_NAVIGATION + elif mgr.last_event_was_select_all(): + reason = TextEventReason.SELECT_ALL + elif mgr.last_event_was_primary_click_or_release(): + reason = TextEventReason.MOUSE_PRIMARY_BUTTON + elif AXUtilitiesState.is_editable(obj) or AXUtilitiesRole.is_terminal(obj): + if mgr.last_event_was_backspace(): + reason = TextEventReason.BACKSPACE + elif mgr.last_event_was_delete(): + reason = TextEventReason.DELETE + elif mgr.last_event_was_cut(): + reason = TextEventReason.CUT + elif mgr.last_event_was_paste(): + reason = TextEventReason.PASTE + elif mgr.last_event_was_undo(): + reason = TextEventReason.UNDO + elif mgr.last_event_was_redo(): + reason = TextEventReason.REDO + elif mgr.last_event_was_page_switch(): + reason = TextEventReason.PAGE_SWITCH + elif mgr.last_event_was_command(): + reason = TextEventReason.UNSPECIFIED_COMMAND + elif mgr.last_event_was_printable_key(): + reason = TextEventReason.TYPING + elif mgr.last_event_was_tab_navigation(): + reason = TextEventReason.FOCUS_CHANGE + elif AXObject.find_ancestor(obj, AXUtilitiesRole.children_are_presentational): + reason = TextEventReason.UI_UPDATE + return reason + + @staticmethod + def _get_text_deletion_event_reason(event: Atspi.Event) -> TextEventReason: + """Returns the TextEventReason for the given event.""" + + from . import input_event_manager # pylint: disable=import-outside-toplevel + mgr = input_event_manager.get_manager() + + reason = TextEventReason.UNKNOWN + obj = event.source + if AXObject.get_role(obj) in AXUtilitiesRole.get_text_ui_roles(): + reason = TextEventReason.UI_UPDATE + elif AXUtilitiesRole.is_live_region(obj): + reason = TextEventReason.LIVE_REGION_UPDATE + elif mgr.last_event_was_page_switch(): + reason = TextEventReason.PAGE_SWITCH + elif AXUtilitiesState.is_editable(obj) or AXUtilitiesRole.is_terminal(obj): + if mgr.last_event_was_backspace(): + reason = TextEventReason.BACKSPACE + elif mgr.last_event_was_delete(): + reason = TextEventReason.DELETE + elif mgr.last_event_was_cut(): + reason = TextEventReason.CUT + elif mgr.last_event_was_paste(): + reason = TextEventReason.PASTE + elif mgr.last_event_was_undo(): + reason = TextEventReason.UNDO + elif mgr.last_event_was_redo(): + reason = TextEventReason.REDO + elif mgr.last_event_was_command(): + reason = TextEventReason.UNSPECIFIED_COMMAND + elif mgr.last_event_was_printable_key(): + reason = TextEventReason.TYPING + elif mgr.last_event_was_up_or_down() or mgr.last_event_was_page_up_or_page_down(): + if AXUtilitiesRole.is_spin_button(obj) \ + or AXObject.find_ancestor(obj, AXUtilitiesRole.is_spin_button): + reason = TextEventReason.SPIN_BUTTON_VALUE_CHANGE + else: + reason = TextEventReason.AUTO_DELETION + if reason == TextEventReason.UNKNOWN: + selected_text, _start, _end = AXText.get_cached_selected_text(obj) + if selected_text and event.any_data.strip() == selected_text.strip(): + reason = TextEventReason.SELECTED_TEXT_DELETION + elif mgr.last_event_was_command(): + reason = TextEventReason.UNSPECIFIED_COMMAND + elif "\ufffc" in event.any_data and not event.any_data.replace("\ufffc", ""): + reason = TextEventReason.CHILDREN_CHANGE + return reason + + @staticmethod + def _get_text_insertion_event_reason(event: Atspi.Event) -> TextEventReason: + """Returns the TextEventReason for the given event.""" + + from . import input_event_manager # pylint: disable=import-outside-toplevel + mgr = input_event_manager.get_manager() + + reason = TextEventReason.UNKNOWN + obj = event.source + if AXObject.get_role(obj) in AXUtilitiesRole.get_text_ui_roles(): + reason = TextEventReason.UI_UPDATE + elif AXUtilitiesRole.is_live_region(obj): + reason = TextEventReason.LIVE_REGION_UPDATE + elif mgr.last_event_was_page_switch(): + reason = TextEventReason.PAGE_SWITCH + elif AXUtilitiesState.is_editable(obj) \ + or AXUtilitiesRole.is_terminal(obj): + selected_text, _start, _end = AXText.get_selected_text(obj) + if selected_text and event.any_data == selected_text: + reason = TextEventReason.SELECTED_TEXT_INSERTION + if mgr.last_event_was_backspace(): + reason = TextEventReason.BACKSPACE + elif mgr.last_event_was_delete(): + reason = TextEventReason.DELETE + elif mgr.last_event_was_cut(): + reason = TextEventReason.CUT + elif mgr.last_event_was_paste(): + reason = TextEventReason.PASTE + elif mgr.last_event_was_undo(): + if reason == TextEventReason.SELECTED_TEXT_INSERTION: + reason = TextEventReason.SELECTED_TEXT_RESTORATION + else: + reason = TextEventReason.UNDO + elif mgr.last_event_was_redo(): + if reason == TextEventReason.SELECTED_TEXT_INSERTION: + reason = TextEventReason.SELECTED_TEXT_RESTORATION + else: + reason = TextEventReason.REDO + elif mgr.last_event_was_command(): + reason = TextEventReason.UNSPECIFIED_COMMAND + elif mgr.last_event_was_space() and not AXUtilitiesRole.is_password_text(obj): + # Gecko inserts a newline at the offset past the space in contenteditables. + if event.any_data == "\n": + reason = TextEventReason.AUTO_INSERTION_UNPRESENTABLE + else: + reason = TextEventReason.TYPING + elif mgr.last_event_was_tab() or mgr.last_event_was_return(): + if not event.any_data.strip(): + reason = TextEventReason.TYPING + elif mgr.last_event_was_printable_key() or mgr.last_event_was_space(): + if reason == TextEventReason.SELECTED_TEXT_INSERTION: + reason = TextEventReason.AUTO_INSERTION_PRESENTABLE + else: + reason = TextEventReason.TYPING + from . import typing_echo_presenter # pylint: disable=import-outside-toplevel + presenter = typing_echo_presenter.get_presenter() + if AXUtilitiesRole.is_password_text(obj): + echo = presenter.get_key_echo_enabled() + else: + echo = presenter.get_character_echo_enabled() + if echo: + reason = TextEventReason.TYPING_ECHOABLE + elif mgr.last_event_was_middle_click() or mgr.last_event_was_middle_release(): + reason = TextEventReason.MOUSE_MIDDLE_BUTTON + elif mgr.last_event_was_up_or_down() or mgr.last_event_was_page_up_or_page_down(): + if AXUtilitiesRole.is_spin_button(obj) \ + or AXObject.find_ancestor(obj, AXUtilitiesRole.is_spin_button): + reason = TextEventReason.SPIN_BUTTON_VALUE_CHANGE + else: + reason = TextEventReason.AUTO_INSERTION_PRESENTABLE + if reason == TextEventReason.UNKNOWN: + if len(event.any_data) == 1: + pass + elif mgr.last_event_was_tab() and event.any_data != "\t": + reason = TextEventReason.AUTO_INSERTION_PRESENTABLE + elif mgr.last_event_was_return() and event.any_data != "\n": + if AXUtilitiesState.is_single_line(event.source): + # Example: The browser's address bar in response to return on a link. + reason = TextEventReason.AUTO_INSERTION_UNPRESENTABLE + else: + reason = TextEventReason.AUTO_INSERTION_PRESENTABLE + elif mgr.last_event_was_command(): + reason = TextEventReason.UNSPECIFIED_COMMAND + elif "\ufffc" in event.any_data and not event.any_data.replace("\ufffc", ""): + reason = TextEventReason.CHILDREN_CHANGE + + return reason + + @staticmethod + def _get_text_selection_changed_event_reason(event: Atspi.Event) -> TextEventReason: + """Returns the TextEventReason for the given event.""" + + from . import input_event_manager # pylint: disable=import-outside-toplevel + mgr = input_event_manager.get_manager() + + reason = TextEventReason.UNKNOWN + obj = event.source + focus = focus_manager.get_manager().get_locus_of_focus() + if focus != obj and AXUtilitiesRole.is_text_input_search(focus): + if mgr.last_event_was_backspace() or mgr.last_event_was_delete(): + reason = TextEventReason.SEARCH_UNPRESENTABLE + else: + reason = TextEventReason.SEARCH_PRESENTABLE + elif mgr.last_event_was_caret_selection(): + if mgr.last_event_was_line_navigation(): + reason = TextEventReason.SELECTION_BY_LINE + elif mgr.last_event_was_word_navigation(): + reason = TextEventReason.SELECTION_BY_WORD + elif mgr.last_event_was_character_navigation(): + reason = TextEventReason.SELECTION_BY_CHARACTER + elif mgr.last_event_was_page_navigation(): + reason = TextEventReason.SELECTION_BY_PAGE + elif mgr.last_event_was_line_boundary_navigation(): + reason = TextEventReason.SELECTION_TO_LINE_BOUNDARY + elif mgr.last_event_was_file_boundary_navigation(): + reason = TextEventReason.SELECTION_TO_FILE_BOUNDARY + else: + reason = TextEventReason.UNSPECIFIED_SELECTION + elif mgr.last_event_was_caret_navigation(): + if mgr.last_event_was_line_navigation(): + reason = TextEventReason.NAVIGATION_BY_LINE + elif mgr.last_event_was_word_navigation(): + reason = TextEventReason.NAVIGATION_BY_WORD + elif mgr.last_event_was_character_navigation(): + reason = TextEventReason.NAVIGATION_BY_CHARACTER + elif mgr.last_event_was_page_navigation(): + reason = TextEventReason.NAVIGATION_BY_PAGE + elif mgr.last_event_was_line_boundary_navigation(): + reason = TextEventReason.NAVIGATION_TO_LINE_BOUNDARY + elif mgr.last_event_was_file_boundary_navigation(): + reason = TextEventReason.NAVIGATION_TO_FILE_BOUNDARY + else: + reason = TextEventReason.UNSPECIFIED_NAVIGATION + elif mgr.last_event_was_select_all(): + reason = TextEventReason.SELECT_ALL + elif mgr.last_event_was_primary_click_or_release(): + reason = TextEventReason.MOUSE_PRIMARY_BUTTON + elif AXUtilitiesState.is_editable(obj) or AXUtilitiesRole.is_terminal(obj): + if mgr.last_event_was_backspace(): + reason = TextEventReason.BACKSPACE + elif mgr.last_event_was_delete(): + reason = TextEventReason.DELETE + elif mgr.last_event_was_cut(): + reason = TextEventReason.CUT + elif mgr.last_event_was_paste(): + reason = TextEventReason.PASTE + elif mgr.last_event_was_undo(): + reason = TextEventReason.UNDO + elif mgr.last_event_was_redo(): + reason = TextEventReason.REDO + elif mgr.last_event_was_page_switch(): + reason = TextEventReason.PAGE_SWITCH + elif mgr.last_event_was_command(): + reason = TextEventReason.UNSPECIFIED_COMMAND + elif mgr.last_event_was_printable_key(): + reason = TextEventReason.TYPING + elif mgr.last_event_was_up_or_down() or mgr.last_event_was_page_up_or_page_down(): + if AXUtilitiesRole.is_spin_button(obj) \ + or AXObject.find_ancestor(obj, AXUtilitiesRole.is_spin_button): + reason = TextEventReason.SPIN_BUTTON_VALUE_CHANGE + return reason + + @staticmethod + def is_presentable_active_descendant_change(event: Atspi.Event) -> bool: + """Returns True if this event should be presented as an active-descendant change.""" + + if not event.any_data: + msg = "AXUtilitiesEvent: No any_data." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + if not (AXUtilitiesState.is_focused(event.source) \ + or AXUtilitiesState.is_focused(event.any_data)): + msg = "AXUtilitiesEvent: Neither source nor child have focused state." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + focus = focus_manager.get_manager().get_locus_of_focus() + if AXUtilitiesRole.is_table_cell(focus): + table = AXObject.find_ancestor(focus, AXUtilitiesRole.is_tree_or_tree_table) + if table is not None and table != event.source: + msg = "AXUtilitiesEvent: Event is from a different tree or tree table." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + msg = "AXUtilitiesEvent: Event is presentable." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + @staticmethod + def is_presentable_checked_change(event: Atspi.Event) -> bool: + """Returns True if this event should be presented as a checked-state change.""" + + old_state = AXUtilitiesEvent.LAST_KNOWN_CHECKED.get(hash(event.source)) + new_state = AXUtilitiesState.is_checked(event.source) + if old_state == new_state: + msg = "AXUtilitiesEvent: The new state matches the old state." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + AXUtilitiesEvent.LAST_KNOWN_CHECKED[hash(event.source)] = new_state + focus = focus_manager.get_manager().get_locus_of_focus() + if event.source != focus: + if not AXObject.is_ancestor(event.source, focus): + msg = "AXUtilitiesEvent: The source is not the locus of focus or its descendant." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + if not (AXUtilitiesRole.is_list_item(focus) or AXUtilitiesRole.is_tree_item(focus)): + msg = "AXUtilitiesEvent: The source descends from non-interactive-item focus." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + from . import input_event_manager # pylint: disable=import-outside-toplevel + mgr = input_event_manager.get_manager() + + # 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. + if AXUtilitiesRole.is_radio_button(event.source) and not mgr.last_event_was_space(): + msg = "AXUtilitiesEvent: Only presentable for this role if toggled by user." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + msg = "AXUtilitiesEvent: Event is presentable." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + @staticmethod + def is_presentable_description_change(event: Atspi.Event) -> bool: + """Returns True if this event should be presented as a description change.""" + + if not isinstance(event.any_data, str): + msg = "AXUtilitiesEvent: The any_data is not a string." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + old_description = AXUtilitiesEvent.LAST_KNOWN_DESCRIPTION.get(hash(event.source)) + new_description = event.any_data + if old_description == new_description: + msg = "AXUtilitiesEvent: The new description matches the old description." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + AXUtilitiesEvent.LAST_KNOWN_DESCRIPTION[hash(event.source)] = new_description + if not new_description: + msg = "AXUtilitiesEvent: The description is empty." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + if not AXUtilitiesState.is_showing(event.source): + msg = "AXUtilitiesEvent: The event source is not showing." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + focus = focus_manager.get_manager().get_locus_of_focus() + if event.source != focus and not AXObject.is_ancestor(focus, event.source): + msg = "AXUtilitiesEvent: The event is not from the locus of focus or ancestor." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + msg = "AXUtilitiesEvent: Event is presentable." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + @staticmethod + def is_presentable_expanded_change(event: Atspi.Event) -> bool: + """Returns True if this event should be presented as an expanded-state change.""" + + old_state = AXUtilitiesEvent.LAST_KNOWN_EXPANDED.get(hash(event.source)) + new_state = AXUtilitiesState.is_expanded(event.source) + if old_state == new_state: + msg = "AXUtilitiesEvent: The new state matches the old state." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + AXUtilitiesEvent.LAST_KNOWN_EXPANDED[hash(event.source)] = new_state + focus = focus_manager.get_manager().get_locus_of_focus() + if event.source == focus: + msg = "AXUtilitiesEvent: Event is presentable, from the locus of focus." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + if not event.detail1 and not AXObject.is_ancestor(focus, event.source): + msg = "AXUtilitiesEvent: Event is not from the locus of focus or ancestor." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + if AXUtilitiesRole.is_table_row(event.source) or AXUtilitiesRole.is_list_box(event.source): + msg = "AXUtilitiesEvent: Event is presentable based on role." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + if AXUtilitiesRole.is_combo_box(event.source) or AXUtilitiesRole.is_button(event.source): + if not AXUtilitiesState.is_focused(event.source): + msg = "AXUtilitiesEvent: Only presentable for this role if focused." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + msg = "AXUtilitiesEvent: Event is presentable." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + @staticmethod + def is_presentable_indeterminate_change(event: Atspi.Event) -> bool: + """Returns True if this event should be presented as an indeterminate-state change.""" + + old_state = AXUtilitiesEvent.LAST_KNOWN_INDETERMINATE.get(hash(event.source)) + new_state = AXUtilitiesState.is_indeterminate(event.source) + if old_state == new_state: + msg = "AXUtilitiesEvent: The new state matches the old state." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + AXUtilitiesEvent.LAST_KNOWN_INDETERMINATE[hash(event.source)] = new_state + + # 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. + if not new_state: + msg = "AXUtilitiesEvent: The new state should be presented as a checked-change." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + if event.source != focus_manager.get_manager().get_locus_of_focus(): + msg = "AXUtilitiesEvent: The event is not from the locus of focus." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + msg = "AXUtilitiesEvent: Event is presentable." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + @staticmethod + def is_presentable_invalid_entry_change(event: Atspi.Event) -> bool: + """Returns True if this event should be presented as an invalid-entry-state change.""" + + old_state = AXUtilitiesEvent.LAST_KNOWN_INVALID_ENTRY.get(hash(event.source)) + new_state = AXUtilitiesState.is_invalid_entry(event.source) + if old_state == new_state: + msg = "AXUtilitiesEvent: The new state matches the old state." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + AXUtilitiesEvent.LAST_KNOWN_INVALID_ENTRY[hash(event.source)] = new_state + if event.source != focus_manager.get_manager().get_locus_of_focus(): + msg = "AXUtilitiesEvent: The event is not from the locus of focus." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + msg = "AXUtilitiesEvent: Event is presentable." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + @staticmethod + def is_presentable_name_change(event: Atspi.Event) -> bool: + """Returns True if this event should be presented as a name change.""" + + if hash(event.source) in AXUtilitiesEvent.IGNORE_NAME_CHANGES_FOR: + msg = "AXUtilitiesEvent: Ignoring name change for this source." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + if not isinstance(event.any_data, str): + msg = "AXUtilitiesEvent: The any_data is not a string." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + old_name = AXUtilitiesEvent.LAST_KNOWN_NAME.get(hash(event.source)) + new_name = event.any_data + if old_name == new_name: + msg = "AXUtilitiesEvent: The new name matches the old name." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + AXUtilitiesEvent.LAST_KNOWN_NAME[hash(event.source)] = new_name + if not new_name: + msg = "AXUtilitiesEvent: The name is empty." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + if not AXUtilitiesState.is_showing(event.source): + msg = "AXUtilitiesEvent: The event source is not showing." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + if AXUtilitiesRole.is_frame(event.source): + if event.source != focus_manager.get_manager().get_active_window(): + msg = "AXUtilitiesEvent: Event is for frame other than the active window." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + # Example: Typing the subject in an email client causing the window name to change. + focus = focus_manager.get_manager().get_locus_of_focus() + if AXUtilitiesState.is_editable(focus) and AXText.get_character_count(focus) \ + and AXText.get_all_text(focus) in event.any_data: + msg = "AXUtilitiesEvent: Event is redundant notification for the locus of focus." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + msg = "AXUtilitiesEvent: Event is presentable." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + if event.source != focus_manager.get_manager().get_locus_of_focus(): + msg = "AXUtilitiesEvent: The event is not from the locus of focus." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + if AXUtilitiesRole.is_list_item(event.source): + # Inspired by Firefox's downloads list in which the list item's name changes to show + # download progress. It's super chatty and stomps on the progress bar announcements. + if AXObject.find_descendant(event.source, AXUtilitiesRole.is_progress_bar): + msg = "AXUtilitiesEvent: The list item contains a progress bar." + debug.print_message(debug.LEVEL_INFO, msg, True) + AXUtilitiesEvent.IGNORE_NAME_CHANGES_FOR.append(hash(event.source)) + return False + + msg = "AXUtilitiesEvent: Event is presentable." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + @staticmethod + def is_presentable_pressed_change(event: Atspi.Event) -> bool: + """Returns True if this event should be presented as a pressed-state change.""" + + old_state = AXUtilitiesEvent.LAST_KNOWN_PRESSED.get(hash(event.source)) + new_state = AXUtilitiesState.is_pressed(event.source) + if old_state == new_state: + msg = "AXUtilitiesEvent: The new state matches the old state." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + AXUtilitiesEvent.LAST_KNOWN_PRESSED[hash(event.source)] = new_state + if event.source != focus_manager.get_manager().get_locus_of_focus(): + msg = "AXUtilitiesEvent: The event is not from the locus of focus." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + msg = "AXUtilitiesEvent: Event is presentable." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + @staticmethod + def is_presentable_selected_change(event: Atspi.Event) -> bool: + """Returns True if this event should be presented as a selected-state change.""" + + old_state = AXUtilitiesEvent.LAST_KNOWN_SELECTED.get(hash(event.source)) + new_state = AXUtilitiesState.is_selected(event.source) + if old_state == new_state: + msg = "AXUtilitiesEvent: The new state matches the old state." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + AXUtilitiesEvent.LAST_KNOWN_SELECTED[hash(event.source)] = new_state + if event.source != focus_manager.get_manager().get_locus_of_focus(): + msg = "AXUtilitiesEvent: The event is not from the locus of focus." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + msg = "AXUtilitiesEvent: Event is presentable." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + @staticmethod + def _is_presentable_text_event(event: Atspi.Event) -> bool: + """Returns True if this text event should be presented.""" + + if not (AXUtilitiesState.is_editable(event.source) or \ + AXUtilitiesRole.is_terminal(event.source)): + msg = "AXUtilitiesEvent: The source is neither editable nor a terminal." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + focus = focus_manager.get_manager().get_locus_of_focus() + if focus != event.source and not AXUtilitiesState.is_focused(event.source): + msg = "AXUtilitiesEvent: The source is neither focused, nor the locus of focus" + debug.print_message(debug.LEVEL_INFO, msg, True) + + # This can happen in web content where the focus is a contenteditable element and a + # new child element is created for new or changed text. + if AXObject.is_ancestor(event.source, focus): + msg = "AXUtilitiesEvent: The locus of focus is an ancestor of the source." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + return False + + msg = "AXUtilitiesEvent: Event is presentable." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + @staticmethod + def is_presentable_text_attributes_change(event: Atspi.Event) -> bool: + """Returns True if this text-attributes-change event should be presented.""" + + return AXUtilitiesEvent._is_presentable_text_event(event) + + @staticmethod + def is_presentable_text_deletion(event: Atspi.Event) -> bool: + """Returns True if this text-deletion event should be presented.""" + + return AXUtilitiesEvent._is_presentable_text_event(event) + + @staticmethod + def is_presentable_text_insertion(event: Atspi.Event) -> bool: + """Returns True if this text-insertion event should be presented.""" + + return AXUtilitiesEvent._is_presentable_text_event(event) + + +AXUtilitiesEvent.start_cache_clearing_thread() diff --git a/src/cthulhu/ax_utilities_relation.py b/src/cthulhu/ax_utilities_relation.py new file mode 100644 index 0000000..95ddc6d --- /dev/null +++ b/src/cthulhu/ax_utilities_relation.py @@ -0,0 +1,384 @@ +# Orca +# +# 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 +# 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=too-many-public-methods + +"""Utilities for obtaining relation-related information.""" + +__id__ = "$Id$" +__version__ = "$Revision$" +__date__ = "$Date$" +__copyright__ = "Copyright (c) 2024 Igalia, S.L." \ + "Copyright (c) 2024 GNOME Foundation Inc." +__license__ = "LGPL" + +import threading +import time + +import gi +gi.require_version("Atspi", "2.0") +from gi.repository import Atspi +from gi.repository import GLib + +from . import debug +from .ax_object import AXObject + + +class AXUtilitiesRelation: + """Utilities for obtaining relation-related information.""" + + RELATIONS: dict[int, list[Atspi.Relation]] = {} + TARGETS: dict[int, dict[Atspi.RelationType, list[Atspi.Accessible]]] = {} + + _lock = threading.Lock() + + @staticmethod + def _clear_stored_data() -> None: + """Clears any data we have cached for objects""" + + while True: + time.sleep(60) + AXUtilitiesRelation._clear_all_dictionaries() + + @staticmethod + def _clear_all_dictionaries(reason: str = "") -> None: + msg = "AXUtilitiesRelation: Clearing local cache." + if reason: + msg += f" Reason: {reason}" + debug.print_message(debug.LEVEL_INFO, msg, True) + + with AXUtilitiesRelation._lock: + AXUtilitiesRelation.RELATIONS.clear() + AXUtilitiesRelation.TARGETS.clear() + + @staticmethod + def clear_cache_now(reason: str = "") -> None: + """Clears all cached information immediately.""" + + AXUtilitiesRelation._clear_all_dictionaries(reason) + + @staticmethod + def start_cache_clearing_thread() -> None: + """Starts thread to periodically clear cached details.""" + + thread = threading.Thread(target=AXUtilitiesRelation._clear_stored_data) + thread.daemon = True + thread.start() + + @staticmethod + def get_relations(obj: Atspi.Accessible) -> list[Atspi.Relation]: + """Returns the list of Atspi.Relation objects associated with obj""" + + if not AXObject.is_valid(obj): + return [] + + relations = AXUtilitiesRelation.RELATIONS.get(hash(obj)) + if relations is not None: + return relations + + try: + relations = Atspi.Accessible.get_relation_set(obj) + except GLib.GError as error: + msg = f"AXUtilitiesRelation: Exception in get_relations: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return [] + + if relations is None: + tokens = ["AXUtilitiesRelation: get_relation_set failed for", obj] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return [] + + AXUtilitiesRelation.RELATIONS[hash(obj)] = relations + return relations + + @staticmethod + def _get_relation( + obj: Atspi.Accessible, + relation_type: Atspi.RelationType + ) -> Atspi.Relation | None: + """Returns the specified Atspi.Relation for obj""" + + for relation in AXUtilitiesRelation.get_relations(obj): + if relation and relation.get_relation_type() == relation_type: + return relation + + return None + + + @staticmethod + def get_relation_targets_for_debugging( + obj: Atspi.Accessible, relation_type: Atspi.RelationType + ) -> list[Atspi.Accessible]: + """Returns the list of targets with the specified relation type to obj.""" + + return AXUtilitiesRelation._get_relation_targets(obj, relation_type) + + @staticmethod + def _get_relation_targets( + obj: Atspi.Accessible, + relation_type: Atspi.RelationType + ) -> list[Atspi.Accessible]: + """Returns the list of targets with the specified relation type to obj.""" + + cached_targets = AXUtilitiesRelation.TARGETS.get(hash(obj), {}) + cached_relation = cached_targets.get(relation_type) + if isinstance(cached_relation, list): + return cached_relation + + relation = AXUtilitiesRelation._get_relation(obj, relation_type) + if relation is None: + cached_targets[relation_type] = [] + AXUtilitiesRelation.TARGETS[hash(obj)] = cached_targets + return [] + + targets = set() + for i in range(relation.get_n_targets()): + if target := relation.get_target(i): + targets.add(target) + + # We want to avoid self-referential relationships. + type_includes_object = [Atspi.RelationType.MEMBER_OF] + if relation_type not in type_includes_object and obj in targets: + tokens = ["AXUtilitiesRelation: ", obj, "is in its own", relation_type, "target list"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + targets.remove(obj) + + result = list(targets) + cached_targets[relation_type] = result + AXUtilitiesRelation.TARGETS[hash(obj)] = cached_targets + return result + + @staticmethod + def get_is_controlled_by(obj: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns a list of accessible objects that obj is controlled by.""" + + result = AXUtilitiesRelation._get_relation_targets(obj, Atspi.RelationType.CONTROLLED_BY) + tokens = ["AXUtilitiesRelation:", obj, "is controlled by:", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def get_is_controller_for(obj: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns a list of accessible objects that obj is the controller for.""" + + result = AXUtilitiesRelation._get_relation_targets(obj, Atspi.RelationType.CONTROLLER_FOR) + tokens = ["AXUtilitiesRelation:", obj, "is controller for:", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def get_is_described_by(obj: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns a list of accessible objects that obj is described by.""" + + result = AXUtilitiesRelation._get_relation_targets(obj, Atspi.RelationType.DESCRIBED_BY) + tokens = ["AXUtilitiesRelation:", obj, "is described by:", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def get_is_description_for(obj: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns a list of accessible objects that obj is the description for.""" + + result = AXUtilitiesRelation._get_relation_targets(obj, Atspi.RelationType.DESCRIPTION_FOR) + tokens = ["AXUtilitiesRelation:", obj, "is description for:", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def get_details(obj: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns a list of accessible objects that contain details for obj.""" + + result = AXUtilitiesRelation._get_relation_targets(obj, Atspi.RelationType.DETAILS) + tokens = ["AXUtilitiesRelation:", obj, "has details in:", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def get_is_details_for(obj: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns a list of accessible objects that obj contains details for.""" + + result = AXUtilitiesRelation._get_relation_targets(obj, Atspi.RelationType.DETAILS_FOR) + tokens = ["AXUtilitiesRelation:", obj, "contains details for:", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def get_is_embedded_by(obj: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns a list of accessible objects that obj is embedded by.""" + + result = AXUtilitiesRelation._get_relation_targets(obj, Atspi.RelationType.EMBEDDED_BY) + tokens = ["AXUtilitiesRelation:", obj, "is embedded by:", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def get_embeds(obj: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns a list of accessible objects that obj embeds.""" + + result = AXUtilitiesRelation._get_relation_targets(obj, Atspi.RelationType.EMBEDS) + tokens = ["AXUtilitiesRelation:", obj, "embeds:", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def get_is_error_for(obj: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns a list of accessible objects that obj contains an error message for.""" + + result = AXUtilitiesRelation._get_relation_targets(obj, Atspi.RelationType.ERROR_FOR) + tokens = ["AXUtilitiesRelation:", obj, "is error for:", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def get_error_message(obj: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns a list of accessible objects that contain an error message for obj.""" + + result = AXUtilitiesRelation._get_relation_targets(obj, Atspi.RelationType.ERROR_MESSAGE) + tokens = ["AXUtilitiesRelation:", obj, "has error messages in:", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def get_flows_from(obj: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns a list of accessible objects that obj flows from.""" + + result = AXUtilitiesRelation._get_relation_targets(obj, Atspi.RelationType.FLOWS_FROM) + tokens = ["AXUtilitiesRelation:", obj, "flows from:", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def get_flows_to(obj: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns a list of accessible objects that obj flows to.""" + + result = AXUtilitiesRelation._get_relation_targets(obj, Atspi.RelationType.FLOWS_TO) + tokens = ["AXUtilitiesRelation:", obj, "flows to:", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def get_is_label_for(obj: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns a list of accessible objects that obj is the label for.""" + + result = AXUtilitiesRelation._get_relation_targets(obj, Atspi.RelationType.LABEL_FOR) + tokens = ["AXUtilitiesRelation:", obj, "is label for:", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def get_is_labelled_by( + obj: Atspi.Accessible, + exclude_ancestors: bool = True + ) -> list[Atspi.Accessible]: + """Returns a list of accessible objects that obj is labelled by.""" + + def is_not_ancestor(acc): + return not AXObject.is_ancestor(obj, acc) + + result = AXUtilitiesRelation._get_relation_targets(obj, Atspi.RelationType.LABELLED_BY) + if exclude_ancestors: + result = list(filter(is_not_ancestor, result)) + + tokens = ["AXUtilitiesRelation:", obj, "is labelled by:", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def get_is_member_of(obj: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns a list of accessible objects that obj is a member of.""" + + result = AXUtilitiesRelation._get_relation_targets(obj, Atspi.RelationType.MEMBER_OF) + tokens = ["AXUtilitiesRelation:", obj, "is member of:", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def get_is_node_child_of(obj: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns a list of accessible objects that obj is the node child of.""" + + result = AXUtilitiesRelation._get_relation_targets(obj, Atspi.RelationType.NODE_CHILD_OF) + tokens = ["AXUtilitiesRelation:", obj, "is node child of:", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def get_is_node_parent_of(obj: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns a list of accessible objects that obj is the node parent of.""" + + result = AXUtilitiesRelation._get_relation_targets(obj, Atspi.RelationType.NODE_PARENT_OF) + tokens = ["AXUtilitiesRelation:", obj, "is node parent of:", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def get_is_parent_window_of(obj: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns a list of accessible objects that obj is a parent window of.""" + + result = AXUtilitiesRelation._get_relation_targets(obj, Atspi.RelationType.PARENT_WINDOW_OF) + tokens = ["AXUtilitiesRelation:", obj, "is parent window of:", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def get_is_popup_for(obj: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns a list of accessible objects that obj is the popup for.""" + + result = AXUtilitiesRelation._get_relation_targets(obj, Atspi.RelationType.POPUP_FOR) + tokens = ["AXUtilitiesRelation:", obj, "is popup for:", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def get_is_subwindow_of(obj: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns a list of accessible objects that obj is a subwindow of.""" + + result = AXUtilitiesRelation._get_relation_targets(obj, Atspi.RelationType.SUBWINDOW_OF) + tokens = ["AXUtilitiesRelation:", obj, "is subwindow of:", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def get_is_tooltip_for(obj: Atspi.Accessible) -> list[Atspi.Accessible]: + """Returns a list of accessible objects that obj is the tooltip for.""" + + result = AXUtilitiesRelation._get_relation_targets(obj, Atspi.RelationType.TOOLTIP_FOR) + tokens = ["AXUtilitiesRelation:", obj, "is tooltip for:", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def object_is_controlled_by(obj1: Atspi.Accessible, obj2: Atspi.Accessible) -> bool: + """Returns True if obj1 is controlled by obj2.""" + + targets = AXUtilitiesRelation._get_relation_targets(obj1, Atspi.RelationType.CONTROLLED_BY) + result = obj2 in targets + tokens = ["AXUtilitiesRelation:", obj1, "is controlled by", obj2, f": {result}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def object_is_unrelated(obj: Atspi.Accessible) -> bool: + """Returns True if obj does not have any relations.""" + + return not AXUtilitiesRelation.get_relations(obj) + +AXUtilitiesRelation.start_cache_clearing_thread() diff --git a/src/cthulhu/ax_utilities_role.py b/src/cthulhu/ax_utilities_role.py index 6b3ec4b..e4901d3 100644 --- a/src/cthulhu/ax_utilities_role.py +++ b/src/cthulhu/ax_utilities_role.py @@ -1,9 +1,7 @@ -#!/usr/bin/env python3 +# Utilities for obtaining role-related information. # -# 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 2023 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,19 +17,15 @@ # 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 -""" -Utilities for obtaining role-related information. -These utilities are app-type- and toolkit-agnostic. Utilities that might have -different implementations or results depending on the type of app (e.g. terminal, -chat, web) or toolkit (e.g. Qt, Gtk) should be in script_utilities.py file(s). +# pylint: disable=wrong-import-position +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-lines +# pylint: disable=too-many-branches +# pylint: disable=too-many-return-statements +# pylint: disable=too-many-statements -N.B. There are currently utilities that should never have custom implementations -that live in script_utilities.py files. These will be moved over time. -""" +"""Utilities for obtaining role-related information.""" __id__ = "$Id$" __version__ = "$Revision$" @@ -43,13 +37,68 @@ import gi gi.require_version("Atspi", "2.0") from gi.repository import Atspi +from . import debug +from . import object_properties from .ax_object import AXObject +from .ax_utilities_state import AXUtilitiesState class AXUtilitiesRole: """Utilities for obtaining role-related information.""" @staticmethod - def get_dialog_roles(include_alert_as_dialog=True): + def _get_display_style(obj: Atspi.Accessible) -> str: + attrs = AXObject.get_attributes_dict(obj) + return attrs.get("display", "") + + @staticmethod + def _get_tag(obj: Atspi.Accessible) -> str | None: + attrs = AXObject.get_attributes_dict(obj) + return attrs.get("tag") + + @staticmethod + def _get_xml_roles(obj: Atspi.Accessible) -> list[str]: + attrs = AXObject.get_attributes_dict(obj) + return attrs.get("xml-roles", "").split() + + @staticmethod + def children_are_presentational( + obj: Atspi.Accessible, + role: Atspi.Role | None = None + ) -> bool: + """Returns True if the descendants of obj should be ignored. See ARIA spec.""" + + # Note: We are deliberately leaving out listbox options because they can be complex, + # both in ARIA and in GTK. + + roles = [ + Atspi.Role.BUTTON, + Atspi.Role.CHECK_BOX, + Atspi.Role.CHECK_MENU_ITEM, + Atspi.Role.IMAGE, + Atspi.Role.LEVEL_BAR, + Atspi.Role.PAGE_TAB, + Atspi.Role.PROGRESS_BAR, + Atspi.Role.RADIO_BUTTON, + Atspi.Role.RADIO_MENU_ITEM, + Atspi.Role.SCROLL_BAR, + Atspi.Role.SEPARATOR, + Atspi.Role.SLIDER, + Atspi.Role.SWITCH, + Atspi.Role.TOGGLE_BUTTON, + ] + + if role is None: + role = AXObject.get_role(obj) + + if role in roles: + tokens = ["AXUtilitiesRole:", obj, "has presentational children."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return True + + return False + + @staticmethod + def get_dialog_roles(include_alert_as_dialog: bool = True) -> list[Atspi.Role]: """Returns the list of roles we consider documents""" roles = [Atspi.Role.COLOR_CHOOSER, @@ -60,7 +109,7 @@ class AXUtilitiesRole: return roles @staticmethod - def get_document_roles(): + def get_document_roles() -> list[Atspi.Role]: """Returns the list of roles we consider documents""" roles = [Atspi.Role.DOCUMENT_EMAIL, @@ -72,10 +121,11 @@ class AXUtilitiesRole: return roles @staticmethod - def get_form_field_roles(): + def get_form_field_roles() -> list[Atspi.Role]: """Returns the list of roles we consider form fields""" - roles = [Atspi.Role.CHECK_BOX, + roles = [Atspi.Role.BUTTON, + Atspi.Role.CHECK_BOX, Atspi.Role.RADIO_BUTTON, Atspi.Role.COMBO_BOX, Atspi.Role.DOCUMENT_FRAME, # rich text editing pred recommended @@ -83,12 +133,50 @@ class AXUtilitiesRole: Atspi.Role.LIST_BOX, Atspi.Role.ENTRY, Atspi.Role.PASSWORD_TEXT, - Atspi.Role.PUSH_BUTTON, - Atspi.Role.SPIN_BUTTON] + Atspi.Role.SPIN_BUTTON, + Atspi.Role.TOGGLE_BUTTON] return roles @staticmethod - def get_menu_item_roles(): + def get_large_container_roles() -> list[Atspi.Role]: + """Returns the list of roles we consider a large container.""" + + # Note: We are deliberately leaving out sections because those are often DIVs + # which are generic and often not large. The primary consumer of this function + # is structural navigation which uses it for the jump-to-edge functionality. + roles = [Atspi.Role.ARTICLE, + Atspi.Role.BLOCK_QUOTE, + Atspi.Role.DESCRIPTION_LIST, + Atspi.Role.FORM, + Atspi.Role.FOOTER, + Atspi.Role.GROUPING, + Atspi.Role.HEADER, + Atspi.Role.HTML_CONTAINER, + Atspi.Role.LANDMARK, + Atspi.Role.LOG, + Atspi.Role.LIST, + Atspi.Role.MARQUEE, + Atspi.Role.PANEL, + Atspi.Role.TABLE, + Atspi.Role.TREE, + Atspi.Role.TREE_TABLE] + + return roles + + @staticmethod + def get_layout_only_roles() -> list[Atspi.Role]: + """Returns the list of roles we consider are for layout only""" + + roles = [Atspi.Role.AUTOCOMPLETE, + Atspi.Role.FILLER, + Atspi.Role.REDUNDANT_OBJECT, + Atspi.Role.UNKNOWN, + Atspi.Role.SCROLL_PANE, + Atspi.Role.TEAROFF_MENU_ITEM] + return roles + + @staticmethod + def get_menu_item_roles() -> list[Atspi.Role]: """Returns the list of roles we consider menu items""" roles = [Atspi.Role.MENU_ITEM, @@ -98,7 +186,7 @@ class AXUtilitiesRole: return roles @staticmethod - def get_menu_related_roles(): + def get_menu_related_roles() -> list[Atspi.Role]: """Returns the list of roles we consider menu related""" roles = [Atspi.Role.MENU, @@ -111,7 +199,7 @@ class AXUtilitiesRole: return roles @staticmethod - def get_roles_to_exclude_from_clickables_list(): + def get_roles_to_exclude_from_clickables_list() -> list[Atspi.Role]: """Returns the list of roles we want to exclude from the list of clickables""" roles = [Atspi.Role.COMBO_BOX, @@ -134,7 +222,7 @@ class AXUtilitiesRole: return roles @staticmethod - def get_set_container_roles(): + def get_set_container_roles() -> list[Atspi.Role]: """Returns the list of roles we consider a set container""" roles = [Atspi.Role.LIST, @@ -146,7 +234,7 @@ class AXUtilitiesRole: return roles @staticmethod - def get_table_cell_roles(include_headers=True): + def get_table_cell_roles(include_headers: bool = True) -> list[Atspi.Role]: """Returns the list of roles we consider table cells""" roles = [Atspi.Role.TABLE_CELL] @@ -158,7 +246,7 @@ class AXUtilitiesRole: return roles @staticmethod - def get_table_header_roles(): + def get_table_header_roles() -> list[Atspi.Role]: """Returns the list of roles we consider table headers""" roles = [Atspi.Role.TABLE_COLUMN_HEADER, @@ -168,7 +256,7 @@ class AXUtilitiesRole: return roles @staticmethod - def get_table_related_roles(include_caption=False): + def get_table_related_roles(include_caption: bool = False) -> list[Atspi.Role]: """Returns the list of roles we consider table related""" roles = [Atspi.Role.TABLE, @@ -182,7 +270,17 @@ class AXUtilitiesRole: return roles @staticmethod - def get_tree_related_roles(): + def get_text_ui_roles() -> list[Atspi.Role]: + """Returns the list of roles we consider UI that displays static text""" + + roles = [Atspi.Role.INFO_BAR, + Atspi.Role.LABEL, + Atspi.Role.PAGE_TAB, + Atspi.Role.STATUS_BAR] + return roles + + @staticmethod + def get_tree_related_roles() -> list[Atspi.Role]: """Returns the list of roles we consider tree related""" roles = [Atspi.Role.TREE, @@ -191,26 +289,173 @@ class AXUtilitiesRole: return roles @staticmethod - def get_widget_roles(): + def get_widget_roles() -> list[Atspi.Role]: """Returns the list of roles we consider widgets""" - roles = [Atspi.Role.CHECK_BOX, + roles = [Atspi.Role.BUTTON, + Atspi.Role.CHECK_BOX, Atspi.Role.COMBO_BOX, - Atspi.Role.PUSH_BUTTON, + Atspi.Role.ENTRY, + Atspi.Role.LIST_BOX, + Atspi.Role.PASSWORD_TEXT, Atspi.Role.RADIO_BUTTON, Atspi.Role.SLIDER, + Atspi.Role.SPIN_BUTTON, + Atspi.Role.SWITCH, Atspi.Role.TEXT, # predicate recommended to check it is editable Atspi.Role.TOGGLE_BUTTON] return roles @staticmethod - def have_same_role(obj1, obj2): + def get_localized_role_name(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> str: + """Returns a string representing the localized role name of obj.""" + + if role is None: + role = AXObject.get_role(obj) + + if AXObject.supports_value(obj): + if AXUtilitiesRole.is_horizontal_slider(obj, role): + return object_properties.ROLE_SLIDER_HORIZONTAL + if AXUtilitiesRole.is_vertical_slider(obj, role): + return object_properties.ROLE_SLIDER_VERTICAL + if AXUtilitiesRole.is_horizontal_scrollbar(obj, role): + return object_properties.ROLE_SCROLL_BAR_HORIZONTAL + if AXUtilitiesRole.is_vertical_scrollbar(obj, role): + return object_properties.ROLE_SCROLL_BAR_VERTICAL + if AXUtilitiesRole.is_horizontal_separator(obj, role): + return object_properties.ROLE_SPLITTER_HORIZONTAL + if AXUtilitiesRole.is_vertical_separator(obj, role): + return object_properties.ROLE_SPLITTER_VERTICAL + if AXUtilitiesRole.is_split_pane(obj, role): + # The splitter has the opposite orientation of the split pane. + if AXObject.has_state(obj, Atspi.StateType.HORIZONTAL): + return object_properties.ROLE_SPLITTER_VERTICAL + if AXObject.has_state(obj, Atspi.StateType.VERTICAL): + return object_properties.ROLE_SPLITTER_HORIZONTAL + + if AXUtilitiesRole.is_suggestion(obj, role): + return object_properties.ROLE_CONTENT_SUGGESTION + + if AXUtilitiesRole.is_feed(obj, role): + return object_properties.ROLE_FEED + + if AXUtilitiesRole.is_figure(obj, role): + return object_properties.ROLE_FIGURE + + if AXUtilitiesRole.is_switch(obj, role): + return object_properties.ROLE_SWITCH + + if AXUtilitiesRole.is_dpub(obj): + if AXUtilitiesRole.is_landmark(obj, role): + if AXUtilitiesRole.is_dpub_acknowledgments(obj, role): + return object_properties.ROLE_ACKNOWLEDGMENTS + if AXUtilitiesRole.is_dpub_afterword(obj, role): + return object_properties.ROLE_AFTERWORD + if AXUtilitiesRole.is_dpub_appendix(obj, role): + return object_properties.ROLE_APPENDIX + if AXUtilitiesRole.is_dpub_bibliography(obj, role): + return object_properties.ROLE_BIBLIOGRAPHY + if AXUtilitiesRole.is_dpub_chapter(obj, role): + return object_properties.ROLE_CHAPTER + if AXUtilitiesRole.is_dpub_conclusion(obj, role): + return object_properties.ROLE_CONCLUSION + if AXUtilitiesRole.is_dpub_credits(obj, role): + return object_properties.ROLE_CREDITS + if AXUtilitiesRole.is_dpub_endnotes(obj, role): + return object_properties.ROLE_ENDNOTES + if AXUtilitiesRole.is_dpub_epilogue(obj, role): + return object_properties.ROLE_EPILOGUE + if AXUtilitiesRole.is_dpub_errata(obj, role): + return object_properties.ROLE_ERRATA + if AXUtilitiesRole.is_dpub_foreword(obj, role): + return object_properties.ROLE_FOREWORD + if AXUtilitiesRole.is_dpub_glossary(obj, role): + return object_properties.ROLE_GLOSSARY + if AXUtilitiesRole.is_dpub_index(obj, role): + return object_properties.ROLE_INDEX + if AXUtilitiesRole.is_dpub_introduction(obj, role): + return object_properties.ROLE_INTRODUCTION + if AXUtilitiesRole.is_dpub_pagelist(obj, role): + return object_properties.ROLE_PAGELIST + if AXUtilitiesRole.is_dpub_part(obj, role): + return object_properties.ROLE_PART + if AXUtilitiesRole.is_dpub_preface(obj, role): + return object_properties.ROLE_PREFACE + if AXUtilitiesRole.is_dpub_prologue(obj, role): + return object_properties.ROLE_PROLOGUE + if AXUtilitiesRole.is_dpub_toc(obj, role): + return object_properties.ROLE_TOC + elif role == "ROLE_DPUB_SECTION": + if AXUtilitiesRole.is_dpub_abstract(obj, role): + return object_properties.ROLE_ABSTRACT + if AXUtilitiesRole.is_dpub_colophon(obj, role): + return object_properties.ROLE_COLOPHON + if AXUtilitiesRole.is_dpub_credit(obj, role): + return object_properties.ROLE_CREDIT + if AXUtilitiesRole.is_dpub_dedication(obj, role): + return object_properties.ROLE_DEDICATION + if AXUtilitiesRole.is_dpub_epigraph(obj, role): + return object_properties.ROLE_EPIGRAPH + if AXUtilitiesRole.is_dpub_example(obj, role): + return object_properties.ROLE_EXAMPLE + if AXUtilitiesRole.is_dpub_pullquote(obj, role): + return object_properties.ROLE_PULLQUOTE + if AXUtilitiesRole.is_dpub_qna(obj, role): + return object_properties.ROLE_QNA + elif AXUtilitiesRole.is_list_item(obj, role): + if AXUtilitiesRole.is_dpub_biblioref(obj, role): + return object_properties.ROLE_BIBLIOENTRY + if AXUtilitiesRole.is_dpub_endnote(obj, role): + return object_properties.ROLE_ENDNOTE + else: + if AXUtilitiesRole.is_dpub_cover(obj, role): + return object_properties.ROLE_COVER + if AXUtilitiesRole.is_dpub_pagebreak(obj, role): + return object_properties.ROLE_PAGEBREAK + if AXUtilitiesRole.is_dpub_subtitle(obj, role): + return object_properties.ROLE_SUBTITLE + + if AXUtilitiesRole.is_landmark(obj, role): + if AXUtilitiesRole.is_landmark_without_type(obj, role): + return "" + if AXUtilitiesRole.is_landmark_banner(obj, role): + return object_properties.ROLE_LANDMARK_BANNER + if AXUtilitiesRole.is_landmark_complementary(obj, role): + return object_properties.ROLE_LANDMARK_COMPLEMENTARY + if AXUtilitiesRole.is_landmark_contentinfo(obj, role): + return object_properties.ROLE_LANDMARK_CONTENTINFO + if AXUtilitiesRole.is_landmark_main(obj, role): + return object_properties.ROLE_LANDMARK_MAIN + if AXUtilitiesRole.is_landmark_navigation(obj, role): + return object_properties.ROLE_LANDMARK_NAVIGATION + if AXUtilitiesRole.is_landmark_region(obj, role): + return object_properties.ROLE_LANDMARK_REGION + if AXUtilitiesRole.is_landmark_search(obj, role): + return object_properties.ROLE_LANDMARK_SEARCH + if AXUtilitiesRole.is_landmark_form(obj, role): + role = Atspi.Role.FORM + elif AXUtilitiesRole.is_comment(obj, role): + role = Atspi.Role.COMMENT + + if not isinstance(role, Atspi.Role): + return AXObject.get_role_name(obj, True) + + return Atspi.role_get_localized_name(role) + + @staticmethod + def has_role_from_aria(obj: Atspi.Accessible) -> bool: + """Returns True if obj's role comes from ARIA""" + + return bool(AXUtilitiesRole._get_xml_roles(obj)) + + @staticmethod + def have_same_role(obj1: Atspi.Accessible, obj2: Atspi.Accessible) -> bool: """Returns True if obj1 and obj2 have the same role""" return AXObject.get_role(obj1) == AXObject.get_role(obj2) @staticmethod - def is_accelerator_label(obj, role=None): + def is_accelerator_label(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the accelerator label role""" if role is None: @@ -218,15 +463,31 @@ class AXUtilitiesRole: return role == Atspi.Role.ACCELERATOR_LABEL @staticmethod - def is_alert(obj, role=None): - """Returns True if obj has the alert role""" + def is_alert(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the alert (a type of dialog) role""" if role is None: role = AXObject.get_role(obj) return role == Atspi.Role.ALERT @staticmethod - def is_animation(obj, role=None): + def is_aria_alert(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj is an ARIA alert (should have notification role)""" + + if "alert" not in AXUtilitiesRole._get_xml_roles(obj): + return False + + if role is None: + role = AXObject.get_role(obj) + + if role != Atspi.Role.NOTIFICATION: + tokens = ["AXUtilitiesRole: Unexpected role for ARIA alert", obj] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + return True + + @staticmethod + def is_animation(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the animation role""" if role is None: @@ -234,7 +495,7 @@ class AXUtilitiesRole: return role == Atspi.Role.ANIMATION @staticmethod - def is_application(obj, role=None): + def is_application(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the application role""" if role is None: @@ -242,7 +503,7 @@ class AXUtilitiesRole: return role == Atspi.Role.APPLICATION @staticmethod - def is_arrow(obj, role=None): + def is_arrow(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the arrow role""" if role is None: @@ -250,7 +511,7 @@ class AXUtilitiesRole: return role == Atspi.Role.ARROW @staticmethod - def is_article(obj, role=None): + def is_article(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the article role""" if role is None: @@ -258,7 +519,7 @@ class AXUtilitiesRole: return role == Atspi.Role.ARTICLE @staticmethod - def is_audio(obj, role=None): + def is_audio(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the audio role""" if role is None: @@ -266,7 +527,7 @@ class AXUtilitiesRole: return role == Atspi.Role.AUDIO @staticmethod - def is_autocomplete(obj, role=None): + def is_autocomplete(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the autocomplete role""" if role is None: @@ -274,23 +535,31 @@ class AXUtilitiesRole: return role == Atspi.Role.AUTOCOMPLETE @staticmethod - def is_block_quote(obj, role=None): + def is_block_quote(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the block quote role""" if role is None: role = AXObject.get_role(obj) - return role == Atspi.Role.BLOCK_QUOTE + return role == Atspi.Role.BLOCK_QUOTE or AXUtilitiesRole._get_tag(obj) == "blockquote" @staticmethod - def is_button(obj, role=None): + def is_button(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the push- or toggle-button role""" if role is None: role = AXObject.get_role(obj) - return role in [Atspi.Role.PUSH_BUTTON, Atspi.Role.TOGGLE_BUTTON] + return role in [Atspi.Role.BUTTON, Atspi.Role.TOGGLE_BUTTON] @staticmethod - def is_calendar(obj, role=None): + def is_button_with_popup(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the push- or toggle-button role and a popup""" + + if not AXUtilitiesRole.is_button(obj, role): + return False + return AXUtilitiesState.has_popup(obj) + + @staticmethod + def is_calendar(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the calendar role""" if role is None: @@ -298,7 +567,7 @@ class AXUtilitiesRole: return role == Atspi.Role.CALENDAR @staticmethod - def is_canvas(obj, role=None): + def is_canvas(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the canvas role""" if role is None: @@ -306,7 +575,7 @@ class AXUtilitiesRole: return role == Atspi.Role.CANVAS @staticmethod - def is_caption(obj, role=None): + def is_caption(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the caption role""" if role is None: @@ -314,7 +583,7 @@ class AXUtilitiesRole: return role == Atspi.Role.CAPTION @staticmethod - def is_chart(obj, role=None): + def is_chart(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the chart role""" if role is None: @@ -322,7 +591,7 @@ class AXUtilitiesRole: return role == Atspi.Role.CHART @staticmethod - def is_check_box(obj, role=None): + def is_check_box(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the checkbox role""" if role is None: @@ -330,7 +599,7 @@ class AXUtilitiesRole: return role == Atspi.Role.CHECK_BOX @staticmethod - def is_check_menu_item(obj, role=None): + def is_check_menu_item(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the check menuitem role""" if role is None: @@ -338,7 +607,14 @@ class AXUtilitiesRole: return role == Atspi.Role.CHECK_MENU_ITEM @staticmethod - def is_color_chooser(obj, role=None): + def is_code(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the code or code-like role""" + + return "code" in AXUtilitiesRole._get_xml_roles(obj) \ + or AXUtilitiesRole._get_tag(obj) in ["code", "pre"] + + @staticmethod + def is_color_chooser(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the color_chooser role""" if role is None: @@ -346,7 +622,7 @@ class AXUtilitiesRole: return role == Atspi.Role.COLOR_CHOOSER @staticmethod - def is_column_header(obj, role=None): + def is_column_header(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the column header role""" if role is None: @@ -354,7 +630,7 @@ class AXUtilitiesRole: return role == Atspi.Role.COLUMN_HEADER @staticmethod - def is_combo_box(obj, role=None): + def is_combo_box(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the combobox role""" if role is None: @@ -362,31 +638,35 @@ class AXUtilitiesRole: return role == Atspi.Role.COMBO_BOX @staticmethod - def is_comment(obj, role=None): + def is_comment(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the comment role""" if role is None: role = AXObject.get_role(obj) - return role == Atspi.Role.COMMENT + return role == Atspi.Role.COMMENT or "comment" in AXUtilitiesRole._get_xml_roles(obj) @staticmethod - def is_content_deletion(obj, role=None): + def is_content_deletion(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the content deletion role""" if role is None: role = AXObject.get_role(obj) - return role == Atspi.Role.CONTENT_DELETION + return role == Atspi.Role.CONTENT_DELETION \ + or "deletion" in AXUtilitiesRole._get_xml_roles(obj) \ + or "del" == AXUtilitiesRole._get_tag(obj) @staticmethod - def is_content_insertion(obj, role=None): + def is_content_insertion(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the content insertion role""" if role is None: role = AXObject.get_role(obj) - return role == Atspi.Role.CONTENT_INSERTION + return role == Atspi.Role.CONTENT_INSERTION \ + or "insertion" in AXUtilitiesRole._get_xml_roles(obj) \ + or "ins" == AXUtilitiesRole._get_tag(obj) @staticmethod - def is_date_editor(obj, role=None): + def is_date_editor(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the date editor role""" if role is None: @@ -394,14 +674,14 @@ class AXUtilitiesRole: return role == Atspi.Role.DATE_EDITOR @staticmethod - def is_default_button(obj, role=None): + def is_default_button(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the push button role the is-default state""" return AXUtilitiesRole.is_push_button(obj, role) \ and AXObject.has_state(obj, Atspi.StateType.IS_DEFAULT) @staticmethod - def is_definition(obj, role=None): + def is_definition(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the definition role""" if role is None: @@ -409,39 +689,47 @@ class AXUtilitiesRole: return role == Atspi.Role.DEFINITION @staticmethod - def is_description_list(obj, role=None): + def is_description_list(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the description list role""" if role is None: role = AXObject.get_role(obj) - return role == Atspi.Role.DESCRIPTION_LIST + return role == Atspi.Role.DESCRIPTION_LIST \ + or "dl" == AXUtilitiesRole._get_tag(obj) @staticmethod - def is_description_term(obj, role=None): + def is_description_term(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the description term role""" if role is None: role = AXObject.get_role(obj) - return role == Atspi.Role.DESCRIPTION_TERM + return role == Atspi.Role.DESCRIPTION_TERM \ + or "dt" == AXUtilitiesRole._get_tag(obj) @staticmethod - def is_description_value(obj, role=None): + def is_description_value(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the description value role""" if role is None: role = AXObject.get_role(obj) - return role == Atspi.Role.DESCRIPTION_VALUE + return role == Atspi.Role.DESCRIPTION_VALUE \ + or "dd" == AXUtilitiesRole._get_tag(obj) @staticmethod - def is_desktop_frame(obj, role=None): + def is_desktop_frame(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the desktop frame role""" if role is None: role = AXObject.get_role(obj) - return role == Atspi.Role.DESKTOP_FRAME + if role == Atspi.Role.DESKTOP_FRAME: + return True + if role == Atspi.Role.FRAME: + attrs = AXObject.get_attributes_dict(obj) + return attrs.get("is-desktop") == "true" + return False @staticmethod - def is_desktop_icon(obj, role=None): + def is_desktop_icon(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the desktop icon role""" if role is None: @@ -449,7 +737,7 @@ class AXUtilitiesRole: return role == Atspi.Role.DESKTOP_ICON @staticmethod - def is_dial(obj, role=None): + def is_dial(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the dial role""" if role is None: @@ -457,7 +745,7 @@ class AXUtilitiesRole: return role == Atspi.Role.DIAL @staticmethod - def is_dialog(obj, role=None): + def is_dialog(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the dialog role""" if role is None: @@ -465,7 +753,7 @@ class AXUtilitiesRole: return role == Atspi.Role.DIALOG @staticmethod - def is_dialog_or_alert(obj, role=None): + def is_dialog_or_alert(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has any dialog or alert role""" roles = AXUtilitiesRole.get_dialog_roles(True) @@ -474,7 +762,17 @@ class AXUtilitiesRole: return role in roles @staticmethod - def is_directory_pane(obj, role=None): + def is_dialog_or_window(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj has any dialog or window-related role""" + + roles = AXUtilitiesRole.get_dialog_roles(False) + roles.extend((Atspi.Role.FRAME, Atspi.Role.WINDOW)) + if role is None: + role = AXObject.get_role(obj) + return role in roles + + @staticmethod + def is_directory_pane(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the directory pane role""" if role is None: @@ -482,7 +780,17 @@ class AXUtilitiesRole: return role == Atspi.Role.DIRECTORY_PANE @staticmethod - def is_document(obj, role=None): + def is_docked_frame(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the frame role and is docked.""" + + if not AXUtilitiesRole.is_frame(obj, role): + return False + + attrs = AXObject.get_attributes_dict(obj) + return attrs.get("window-type") == "dock" + + @staticmethod + def is_document(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has any document-related role""" roles = AXUtilitiesRole.get_document_roles() @@ -491,7 +799,7 @@ class AXUtilitiesRole: return role in roles @staticmethod - def is_document_email(obj, role=None): + def is_document_email(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the document email role""" if role is None: @@ -499,7 +807,7 @@ class AXUtilitiesRole: return role == Atspi.Role.DOCUMENT_EMAIL @staticmethod - def is_document_frame(obj, role=None): + def is_document_frame(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the document frame role""" if role is None: @@ -507,7 +815,7 @@ class AXUtilitiesRole: return role == Atspi.Role.DOCUMENT_FRAME @staticmethod - def is_document_presentation(obj, role=None): + def is_document_presentation(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the document presentation role""" if role is None: @@ -515,7 +823,7 @@ class AXUtilitiesRole: return role == Atspi.Role.DOCUMENT_PRESENTATION @staticmethod - def is_document_spreadsheet(obj, role=None): + def is_document_spreadsheet(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the document spreadsheet role""" if role is None: @@ -523,7 +831,7 @@ class AXUtilitiesRole: return role == Atspi.Role.DOCUMENT_SPREADSHEET @staticmethod - def is_document_text(obj, role=None): + def is_document_text(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the document text role""" if role is None: @@ -531,7 +839,7 @@ class AXUtilitiesRole: return role == Atspi.Role.DOCUMENT_TEXT @staticmethod - def is_document_web(obj, role=None): + def is_document_web(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the document web role""" if role is None: @@ -539,7 +847,231 @@ class AXUtilitiesRole: return role == Atspi.Role.DOCUMENT_WEB @staticmethod - def is_drawing_area(obj, role=None): + def is_dpub(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has a DPub role.""" + + roles = AXUtilitiesRole._get_xml_roles(obj) + rv = bool(list(filter(lambda x: x.startswith("doc-"), roles))) + return rv + + @staticmethod + def is_dpub_abstract(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub abstract role.""" + + return "doc-abstract" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_acknowledgments(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub acknowledgments role.""" + + return "doc-acknowledgments" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_afterword(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub afterword role.""" + + return "doc-afterword" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_appendix(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub appendix role.""" + + return "doc-appendix" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_backlink(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub backlink role.""" + + return "doc-backlink" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_biblioref(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub biblioref role.""" + + return "doc-biblioref" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_bibliography(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub bibliography role.""" + + return "doc-bibliography" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_chapter(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub chapter role.""" + + return "doc-chapter" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_colophon(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub colophon role.""" + + return "doc-colophon" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_conclusion(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub conclusion role.""" + + return "doc-conclusion" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_cover(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub cover role.""" + + return "doc-cover" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_credit(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub credit role.""" + + return "doc-credit" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_credits(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub credits role.""" + + return "doc-credits" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_dedication(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub dedication role.""" + + return "doc-dedication" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_endnote(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub endnote role.""" + + return "doc-endnote" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_endnotes(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub endnotes role.""" + + return "doc-endnotes" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_epigraph(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub epigraph role.""" + + return "doc-epigraph" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_epilogue(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub epilogue role.""" + + return "doc-epilogue" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_errata(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub errata role.""" + + return "doc-errata" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_example(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub example role.""" + + return "doc-example" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_footnote(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub footnote role.""" + + return "doc-footnote" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_foreword(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub foreword role.""" + + return "doc-foreword" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_glossary(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub glossary role.""" + + return "doc-glossary" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_glossref(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub glossref role.""" + + return "doc-glossref" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_index(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub index role.""" + + return "doc-index" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_introduction(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub introduction role.""" + + return "doc-introduction" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_noteref(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub noteref role.""" + + return "doc-noteref" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_pagelist(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub pagelist role.""" + + return "doc-pagelist" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_pagebreak(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub pagebreak role.""" + + return "doc-pagebreak" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_part(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub part role.""" + + return "doc-part" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_preface(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub preface role.""" + + return "doc-preface" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_prologue(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub prologue role.""" + + return "doc-prologue" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_pullquote(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub pullquote role.""" + + return "doc-pullquote" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_qna(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub qna role.""" + + return "doc-qna" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_subtitle(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub subtitle role.""" + + return "doc-subtitle" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_dpub_toc(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the DPub toc role.""" + + return "doc-toc" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_drawing_area(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the drawing area role""" if role is None: @@ -547,7 +1079,19 @@ class AXUtilitiesRole: return role == Atspi.Role.DRAWING_AREA @staticmethod - def is_editbar(obj, role=None): + def is_editable_combo_box(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj is an editable combobox""" + + if role is None: + role = AXObject.get_role(obj) + if role != Atspi.Role.COMBO_BOX: + return False + if AXUtilitiesState.is_editable(obj): + return True + return bool(AXObject.find_descendant(obj, AXUtilitiesRole.is_text_input)) + + @staticmethod + def is_editbar(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the editbar role""" if role is None: @@ -555,7 +1099,7 @@ class AXUtilitiesRole: return role == Atspi.Role.EDITBAR @staticmethod - def is_embedded(obj, role=None): + def is_embedded(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the embedded role""" if role is None: @@ -563,7 +1107,7 @@ class AXUtilitiesRole: return role == Atspi.Role.EMBEDDED @staticmethod - def is_entry(obj, role=None): + def is_entry(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the entry role""" if role is None: @@ -571,7 +1115,7 @@ class AXUtilitiesRole: return role == Atspi.Role.ENTRY @staticmethod - def is_extended(obj, role=None): + def is_extended(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the extended role""" if role is None: @@ -579,7 +1123,29 @@ class AXUtilitiesRole: return role == Atspi.Role.EXTENDED @staticmethod - def is_file_chooser(obj, role=None): + def is_feed(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the feed role""" + + return "feed" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_feed_article(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the article role and descends from a feed.""" + + if not AXUtilitiesRole.is_article(obj, role): + return False + + return AXObject.find_ancestor(obj, AXUtilitiesRole.is_feed) is not None + + @staticmethod + def is_figure(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the figure role or tag.""" + + return "figure" in AXUtilitiesRole._get_xml_roles(obj) \ + or AXUtilitiesRole._get_tag(obj) == "figure" + + @staticmethod + def is_file_chooser(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the file chooser role""" if role is None: @@ -587,7 +1153,7 @@ class AXUtilitiesRole: return role == Atspi.Role.FILE_CHOOSER @staticmethod - def is_filler(obj, role=None): + def is_filler(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the filler role""" if role is None: @@ -595,7 +1161,7 @@ class AXUtilitiesRole: return role == Atspi.Role.FILLER @staticmethod - def is_focus_traversable(obj, role=None): + def is_focus_traversable(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the focus traversable role""" if role is None: @@ -603,7 +1169,7 @@ class AXUtilitiesRole: return role == Atspi.Role.FOCUS_TRAVERSABLE @staticmethod - def is_font_chooser(obj, role=None): + def is_font_chooser(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the font chooser role""" if role is None: @@ -611,7 +1177,7 @@ class AXUtilitiesRole: return role == Atspi.Role.FONT_CHOOSER @staticmethod - def is_footer(obj, role=None): + def is_footer(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the footer role""" if role is None: @@ -619,7 +1185,7 @@ class AXUtilitiesRole: return role == Atspi.Role.FOOTER @staticmethod - def is_footnote(obj, role=None): + def is_footnote(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the footnote role""" if role is None: @@ -627,7 +1193,7 @@ class AXUtilitiesRole: return role == Atspi.Role.FOOTNOTE @staticmethod - def is_form(obj, role=None): + def is_form(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the form role""" if role is None: @@ -635,7 +1201,7 @@ class AXUtilitiesRole: return role == Atspi.Role.FORM @staticmethod - def is_frame(obj, role=None): + def is_frame(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the frame role""" if role is None: @@ -643,7 +1209,7 @@ class AXUtilitiesRole: return role == Atspi.Role.FRAME @staticmethod - def is_glass_pane(obj, role=None): + def is_glass_pane(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the glass pane role""" if role is None: @@ -651,7 +1217,45 @@ class AXUtilitiesRole: return role == Atspi.Role.GLASS_PANE @staticmethod - def is_grouping(obj, role=None): + def is_gui_list(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the list role but contains UI rather than static text.""" + + if not AXUtilitiesRole.is_list(obj, role): + return False + + return AXObject.get_toolkit_name(obj) == "gtk" + + @staticmethod + def is_grid(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the grid role.""" + + if not AXUtilitiesRole.is_table(obj, role): + return False + + return "grid" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_grid_cell(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the gridcell role or the cell role and is in a grid.""" + + if not AXUtilitiesRole.is_table_cell(obj, role): + return False + + roles = AXUtilitiesRole._get_xml_roles(obj) + if "gridcell" in roles: + return True + if "cell" in roles: + return AXObject.find_ancestor(obj, AXUtilitiesRole.is_grid) is not None + return False + + @staticmethod + def is_group(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj is an ARIA group.""" + + return "group" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_grouping(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the grouping role""" if role is None: @@ -659,7 +1263,7 @@ class AXUtilitiesRole: return role == Atspi.Role.GROUPING @staticmethod - def is_header(obj, role=None): + def is_header(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the header role""" if role is None: @@ -667,7 +1271,7 @@ class AXUtilitiesRole: return role == Atspi.Role.HEADER @staticmethod - def is_heading(obj, role=None): + def is_heading(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the heading role""" if role is None: @@ -675,7 +1279,7 @@ class AXUtilitiesRole: return role == Atspi.Role.HEADING @staticmethod - def is_html_container(obj, role=None): + def is_html_container(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the html container role""" if role is None: @@ -683,28 +1287,28 @@ class AXUtilitiesRole: return role == Atspi.Role.HTML_CONTAINER @staticmethod - def is_horizontal_scrollbar(obj, role=None): + def is_horizontal_scrollbar(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj is a horizontal scrollbar""" return AXUtilitiesRole.is_scroll_bar(obj, role) \ and AXObject.has_state(obj, Atspi.StateType.HORIZONTAL) @staticmethod - def is_horizontal_separator(obj, role=None): + def is_horizontal_separator(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj is a horizontal separator""" return AXUtilitiesRole.is_separator(obj, role) \ and AXObject.has_state(obj, Atspi.StateType.HORIZONTAL) @staticmethod - def is_horizontal_slider(obj, role=None): + def is_horizontal_slider(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj is a horizontal slider""" return AXUtilitiesRole.is_slider(obj, role) \ and AXObject.has_state(obj, Atspi.StateType.HORIZONTAL) @staticmethod - def is_icon(obj, role=None): + def is_icon(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the icon role""" if role is None: @@ -712,7 +1316,7 @@ class AXUtilitiesRole: return role == Atspi.Role.ICON @staticmethod - def is_icon_or_canvas(obj, role=None): + def is_icon_or_canvas(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the icon or canvas role""" if role is None: @@ -720,7 +1324,7 @@ class AXUtilitiesRole: return role in [Atspi.Role.ICON, Atspi.Role.CANVAS] @staticmethod - def is_image(obj, role=None): + def is_image(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the image role""" if role is None: @@ -728,7 +1332,7 @@ class AXUtilitiesRole: return role == Atspi.Role.IMAGE @staticmethod - def is_image_or_canvas(obj, role=None): + def is_image_or_canvas(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the image or canvas role""" if role is None: @@ -736,7 +1340,7 @@ class AXUtilitiesRole: return role in [Atspi.Role.IMAGE, Atspi.Role.CANVAS] @staticmethod - def is_image_map(obj, role=None): + def is_image_map(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the image map role""" if role is None: @@ -744,7 +1348,7 @@ class AXUtilitiesRole: return role == Atspi.Role.IMAGE_MAP @staticmethod - def is_info_bar(obj, role=None): + def is_info_bar(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the info bar role""" if role is None: @@ -752,7 +1356,34 @@ class AXUtilitiesRole: return role == Atspi.Role.INFO_BAR @staticmethod - def is_input_method_window(obj, role=None): + def is_inline_internal_frame(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the internal frame role and is inline.""" + + if not AXUtilitiesRole.is_internal_frame(obj, role): + return False + + return "inline" in AXUtilitiesRole._get_display_style(obj) + + @staticmethod + def is_inline_list_item(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the list item role and is inline.""" + + if not AXUtilitiesRole.is_list_item(obj, role): + return False + + return "inline" in AXUtilitiesRole._get_display_style(obj) + + @staticmethod + def is_inline_suggestion(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the suggestion role and is inline.""" + + if not AXUtilitiesRole.is_suggestion(obj, role): + return False + + return "inline" in AXUtilitiesRole._get_display_style(obj) + + @staticmethod + def is_input_method_window(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the input method window role""" if role is None: @@ -760,15 +1391,15 @@ class AXUtilitiesRole: return role == Atspi.Role.INPUT_METHOD_WINDOW @staticmethod - def is_internal_frame(obj, role=None): + def is_internal_frame(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the internal frame role""" if role is None: role = AXObject.get_role(obj) - return role == Atspi.Role.INTERNAL_FRAME + return role == Atspi.Role.INTERNAL_FRAME or AXUtilitiesRole._get_tag(obj) == "iframe" @staticmethod - def is_invalid_role(obj, role=None): + def is_invalid_role(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the invalid role""" if role is None: @@ -776,7 +1407,7 @@ class AXUtilitiesRole: return role == Atspi.Role.INVALID @staticmethod - def is_label(obj, role=None): + def is_label(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the label role""" if role is None: @@ -784,7 +1415,7 @@ class AXUtilitiesRole: return role == Atspi.Role.LABEL @staticmethod - def is_label_or_caption(obj, role=None): + def is_label_or_caption(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the label or caption role""" if role is None: @@ -792,7 +1423,7 @@ class AXUtilitiesRole: return role in [Atspi.Role.LABEL, Atspi.Role.CAPTION] @staticmethod - def is_landmark(obj, role=None): + def is_landmark(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the landmark role""" if role is None: @@ -800,7 +1431,76 @@ class AXUtilitiesRole: return role == Atspi.Role.LANDMARK @staticmethod - def is_layered_pane(obj, role=None): + def is_landmark_banner(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the banner landmark role""" + + return "banner" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_landmark_complementary( + obj: Atspi.Accessible, _role: Atspi.Role | None = None + ) -> bool: + """Returns True if obj has the complementary landmark role""" + + return "complementary" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_landmark_contentinfo(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the contentinfo landmark role""" + + return "contentinfo" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_landmark_form(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the form landmark role""" + + return "form" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_landmark_main(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the main landmark role""" + + return "main" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_landmark_navigation(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the navigation landmark role""" + + return "navigation" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_landmark_region(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the region landmark role""" + + return "region" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_landmark_search(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the search landmark role""" + + return "search" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_landmark_without_type(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the landmark role but no type""" + + if not AXUtilitiesRole.is_landmark(obj, role): + return False + + roles = AXUtilitiesRole._get_xml_roles(obj) + return not roles + + @staticmethod + def is_large_container(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj has a large container role""" + + if role is None: + role = AXObject.get_role(obj) + + return role in AXUtilitiesRole.get_large_container_roles() + + @staticmethod + def is_layered_pane(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the layered pane role""" if role is None: @@ -808,7 +1508,7 @@ class AXUtilitiesRole: return role == Atspi.Role.LAYERED_PANE @staticmethod - def is_level_bar(obj, role=None): + def is_level_bar(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the level bar role""" if role is None: @@ -816,7 +1516,7 @@ class AXUtilitiesRole: return role == Atspi.Role.LEVEL_BAR @staticmethod - def is_link(obj, role=None): + def is_link(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the link role""" if role is None: @@ -824,7 +1524,7 @@ class AXUtilitiesRole: return role == Atspi.Role.LINK @staticmethod - def is_list(obj, role=None): + def is_list(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the list role""" if role is None: @@ -832,7 +1532,7 @@ class AXUtilitiesRole: return role == Atspi.Role.LIST @staticmethod - def is_list_box(obj, role=None): + def is_list_box(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the list box role""" if role is None: @@ -840,7 +1540,15 @@ class AXUtilitiesRole: return role == Atspi.Role.LIST_BOX @staticmethod - def is_list_item(obj, role=None): + def is_list_box_item(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj is an item in a list box""" + + if not AXUtilitiesRole.is_list_item(obj, role): + return False + return AXObject.find_ancestor(obj, AXUtilitiesRole.is_list_box) is not None + + @staticmethod + def is_list_item(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the list item role""" if role is None: @@ -848,7 +1556,7 @@ class AXUtilitiesRole: return role == Atspi.Role.LIST_ITEM @staticmethod - def is_log(obj, role=None): + def is_log(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the log role""" if role is None: @@ -856,15 +1564,24 @@ class AXUtilitiesRole: return role == Atspi.Role.LOG @staticmethod - def is_mark(obj, role=None): + def is_live_region(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj is a live region.""" + + attrs = AXObject.get_attributes_dict(obj) + return "container-live" in attrs and attrs.get("container-live") in ["polite", "assertive"] + + @staticmethod + def is_mark(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the mark role""" if role is None: role = AXObject.get_role(obj) - return role == Atspi.Role.MARK + return role == Atspi.Role.MARK \ + or "mark" in AXUtilitiesRole._get_xml_roles(obj) \ + or "mark" == AXUtilitiesRole._get_tag(obj) @staticmethod - def is_marquee(obj, role=None): + def is_marquee(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the marquee role""" if role is None: @@ -872,7 +1589,7 @@ class AXUtilitiesRole: return role == Atspi.Role.MARQUEE @staticmethod - def is_math(obj, role=None): + def is_math(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the math role""" if role is None: @@ -880,7 +1597,19 @@ class AXUtilitiesRole: return role == Atspi.Role.MATH @staticmethod - def is_math_fraction(obj, role=None): + def is_math_enclose(obj: Atspi.Accessible) -> bool: + """Returns True if obj has the math enclose role/tag""" + + return AXUtilitiesRole._get_tag(obj) == "menclose" + + @staticmethod + def is_math_fenced(obj: Atspi.Accessible) -> bool: + """Returns True if obj has the math fenced role/tag""" + + return AXUtilitiesRole._get_tag(obj) == "mfenced" + + @staticmethod + def is_math_fraction(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the math fraction role""" if role is None: @@ -888,7 +1617,88 @@ class AXUtilitiesRole: return role == Atspi.Role.MATH_FRACTION @staticmethod - def is_math_root(obj, role=None): + def is_math_fraction_without_bar( + obj: Atspi.Accessible, role: Atspi.Role | None = None + ) -> bool: + """Returns True if obj has the math fraction role and lacks the fraction bar""" + + if not AXUtilitiesRole.is_math_fraction(obj, role): + return False + + line_thickness = AXObject.get_attribute(obj, "linethickness") + if not line_thickness: + return False + + for char in line_thickness: + if char.isnumeric() and char != "0": + return False + + return True + + @staticmethod + def is_math_layout_only(obj: Atspi.Accessible) -> bool: + """Returns True if obj has a layout-only math role""" + + return AXUtilitiesRole._get_tag(obj) \ + in ["mrow", "mstyle", "merror", "mpadded", "none", "semantics"] + + @staticmethod + def is_math_multi_script(obj: Atspi.Accessible) -> bool: + """Returns True if obj has the math multi-scripts role/tag""" + + return AXUtilitiesRole._get_tag(obj) == "mmultiscripts" + + @staticmethod + def is_math_related(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj has a math-related role""" + + if role is None: + role = AXObject.get_role(obj) + if role in [Atspi.Role.MATH, Atspi.Role.MATH_FRACTION, Atspi.Role.MATH_ROOT]: + return True + return AXUtilitiesRole._get_tag(obj) in ["math", + "maction", + "maligngroup", + "malignmark", + "menclose", + "merror", + "mfenced", + "mfrac", + "mglyph", + "mi", + "mlabeledtr", + "mlongdiv", + "mmultiscripts", + "mn", + "mo", + "mover", + "mpadded", + "mphantom", + "mprescripts", + "mroot", + "mrow", + "ms", + "mscarries", + "mscarry", + "msgroup", + "msline", + "mspace", + "msqrt", + "msrow", + "mstack", + "mstyle", + "msub", + "msup", + "msubsup", + "mtable", + "mtd", + "mtext", + "mtr", + "munder", + "munderover"] + + @staticmethod + def is_math_root(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the math root role""" if role is None: @@ -896,7 +1706,43 @@ class AXUtilitiesRole: return role == Atspi.Role.MATH_ROOT @staticmethod - def is_menu(obj, role=None): + def is_math_square_root(obj: Atspi.Accessible) -> bool: + """Returns True if obj has the math root role/tag""" + + return AXUtilitiesRole._get_tag(obj) == "msqrt" + + @staticmethod + def is_math_sub_or_super_script(obj: Atspi.Accessible) -> bool: + """Returns True if obj has the math subscript or superscript role/tag""" + + return AXUtilitiesRole._get_tag(obj) in ["msub", "msup", "msubsup"] + + @staticmethod + def is_math_table(obj: Atspi.Accessible) -> bool: + """Returns True if obj has the math table role/tag""" + + return AXUtilitiesRole._get_tag(obj) == "mtable" + + @staticmethod + def is_math_table_row(obj: Atspi.Accessible) -> bool: + """Returns True if obj has the math table row role/tag""" + + return AXUtilitiesRole._get_tag(obj) in ["mtr", "mlabeledtr"] + + @staticmethod + def is_math_token(obj: Atspi.Accessible) -> bool: + """Returns True if obj has a math token role/tag""" + + return AXUtilitiesRole._get_tag(obj) in ["mi", "mn", "mo", "mtext", "ms", "mspace"] + + @staticmethod + def is_math_under_or_over_script(obj: Atspi.Accessible) -> bool: + """Returns True if obj has the math under-script or over-script role/tag""" + + return AXUtilitiesRole._get_tag(obj) in ["mover", "munder", "munderover"] + + @staticmethod + def is_menu(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the menu role""" if role is None: @@ -904,7 +1750,7 @@ class AXUtilitiesRole: return role == Atspi.Role.MENU @staticmethod - def is_menu_bar(obj, role=None): + def is_menu_bar(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the menubar role""" if role is None: @@ -912,7 +1758,7 @@ class AXUtilitiesRole: return role == Atspi.Role.MENU_BAR @staticmethod - def is_menu_item(obj, role=None): + def is_menu_item(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the menu item role""" if role is None: @@ -920,7 +1766,7 @@ class AXUtilitiesRole: return role == Atspi.Role.MENU_ITEM @staticmethod - def is_menu_item_of_any_kind(obj, role=None): + def is_menu_item_of_any_kind(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has any menu item role""" roles = AXUtilitiesRole.get_menu_item_roles() @@ -929,7 +1775,7 @@ class AXUtilitiesRole: return role in roles @staticmethod - def is_menu_related(obj, role=None): + def is_menu_related(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has any menu-related role""" roles = AXUtilitiesRole.get_menu_related_roles() @@ -938,21 +1784,21 @@ class AXUtilitiesRole: return role in roles @staticmethod - def is_modal_dialog(obj, role=None): + def is_modal_dialog(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the alert or dialog role and modal state""" return AXUtilitiesRole.is_dialog_or_alert(obj, role) \ and AXObject.has_state(obj, Atspi.StateType.MODAL) @staticmethod - def is_multi_line_entry(obj, role=None): + def is_multi_line_entry(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the entry role and multiline state""" return AXUtilitiesRole.is_entry(obj, role) \ and AXObject.has_state(obj, Atspi.StateType.MULTI_LINE) @staticmethod - def is_notification(obj, role=None): + def is_notification(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the notification role""" if role is None: @@ -960,7 +1806,7 @@ class AXUtilitiesRole: return role == Atspi.Role.NOTIFICATION @staticmethod - def is_option_pane(obj, role=None): + def is_option_pane(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the option pane role""" if role is None: @@ -968,7 +1814,7 @@ class AXUtilitiesRole: return role == Atspi.Role.OPTION_PANE @staticmethod - def is_page(obj, role=None): + def is_page(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the page role""" if role is None: @@ -976,7 +1822,7 @@ class AXUtilitiesRole: return role == Atspi.Role.PAGE @staticmethod - def is_page_tab(obj, role=None): + def is_page_tab(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the page tab role""" if role is None: @@ -984,7 +1830,7 @@ class AXUtilitiesRole: return role == Atspi.Role.PAGE_TAB @staticmethod - def is_page_tab_list(obj, role=None): + def is_page_tab_list(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the page tab list role""" if role is None: @@ -992,7 +1838,7 @@ class AXUtilitiesRole: return role == Atspi.Role.PAGE_TAB_LIST @staticmethod - def is_page_tab_list_related(obj, role=None): + def is_page_tab_list_related(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the page tab or page tab list role""" roles = [Atspi.Role.PAGE_TAB_LIST, Atspi.Role.PAGE_TAB] @@ -1001,7 +1847,7 @@ class AXUtilitiesRole: return role in roles @staticmethod - def is_panel(obj, role=None): + def is_panel(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the panel role""" if role is None: @@ -1009,7 +1855,7 @@ class AXUtilitiesRole: return role == Atspi.Role.PANEL @staticmethod - def is_paragraph(obj, role=None): + def is_paragraph(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the paragraph role""" if role is None: @@ -1017,7 +1863,7 @@ class AXUtilitiesRole: return role == Atspi.Role.PARAGRAPH @staticmethod - def is_password_text(obj, role=None): + def is_password_text(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the password text role""" if role is None: @@ -1025,7 +1871,7 @@ class AXUtilitiesRole: return role == Atspi.Role.PASSWORD_TEXT @staticmethod - def is_popup_menu(obj, role=None): + def is_popup_menu(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the popup menu role""" if role is None: @@ -1033,7 +1879,7 @@ class AXUtilitiesRole: return role == Atspi.Role.POPUP_MENU @staticmethod - def is_progress_bar(obj, role=None): + def is_progress_bar(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the progress bar role""" if role is None: @@ -1041,15 +1887,15 @@ class AXUtilitiesRole: return role == Atspi.Role.PROGRESS_BAR @staticmethod - def is_push_button(obj, role=None): + def is_push_button(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the push button role""" if role is None: role = AXObject.get_role(obj) - return role == Atspi.Role.PUSH_BUTTON + return role == Atspi.Role.BUTTON @staticmethod - def is_push_button_menu(obj, role=None): + def is_push_button_menu(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the push button menu role""" if role is None: @@ -1057,7 +1903,7 @@ class AXUtilitiesRole: return role == Atspi.Role.PUSH_BUTTON_MENU @staticmethod - def is_radio_button(obj, role=None): + def is_radio_button(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the radio button role""" if role is None: @@ -1065,7 +1911,7 @@ class AXUtilitiesRole: return role == Atspi.Role.RADIO_BUTTON @staticmethod - def is_radio_menu_item(obj, role=None): + def is_radio_menu_item(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the radio menu item role""" if role is None: @@ -1073,7 +1919,7 @@ class AXUtilitiesRole: return role == Atspi.Role.RADIO_MENU_ITEM @staticmethod - def is_rating(obj, role=None): + def is_rating(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the rating role""" if role is None: @@ -1081,7 +1927,7 @@ class AXUtilitiesRole: return role == Atspi.Role.RATING @staticmethod - def is_redundant_object(obj, role=None): + def is_redundant_object_role(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the redundant object role""" if role is None: @@ -1089,7 +1935,7 @@ class AXUtilitiesRole: return role == Atspi.Role.REDUNDANT_OBJECT @staticmethod - def is_root_pane(obj, role=None): + def is_root_pane(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the root pane role""" if role is None: @@ -1097,7 +1943,7 @@ class AXUtilitiesRole: return role == Atspi.Role.ROOT_PANE @staticmethod - def is_row_header(obj, role=None): + def is_row_header(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the row header role""" if role is None: @@ -1105,7 +1951,7 @@ class AXUtilitiesRole: return role == Atspi.Role.ROW_HEADER @staticmethod - def is_ruler(obj, role=None): + def is_ruler(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the ruler role""" if role is None: @@ -1113,7 +1959,7 @@ class AXUtilitiesRole: return role == Atspi.Role.RULER @staticmethod - def is_scroll_bar(obj, role=None): + def is_scroll_bar(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the scrollbar role""" if role is None: @@ -1121,7 +1967,7 @@ class AXUtilitiesRole: return role == Atspi.Role.SCROLL_BAR @staticmethod - def is_scroll_pane(obj, role=None): + def is_scroll_pane(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the scroll pane role""" if role is None: @@ -1129,7 +1975,7 @@ class AXUtilitiesRole: return role == Atspi.Role.SCROLL_PANE @staticmethod - def is_section(obj, role=None): + def is_section(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the section role""" if role is None: @@ -1137,7 +1983,7 @@ class AXUtilitiesRole: return role == Atspi.Role.SECTION @staticmethod - def is_separator(obj, role=None): + def is_separator(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the separator role""" if role is None: @@ -1145,14 +1991,30 @@ class AXUtilitiesRole: return role == Atspi.Role.SEPARATOR @staticmethod - def is_single_line_entry(obj, role=None): - """Returns True if obj has the entry role and multiline state""" + def is_single_line_autocomplete_entry( + obj: Atspi.Accessible, role: Atspi.Role | None = None + ) -> bool: + """Returns True if obj has the entry role and single-line state""" - return AXUtilitiesRole.is_entry(obj, role) \ - and AXObject.has_state(obj, Atspi.StateType.SINGLE_LINE) + if not AXUtilitiesRole.is_single_line_entry(obj, role): + return False + + return AXUtilitiesState.supports_autocompletion(obj) @staticmethod - def is_slider(obj, role=None): + def is_single_line_entry(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the entry role and the single-line state""" + + if not AXUtilitiesState.is_single_line(obj): + return False + if AXUtilitiesRole.is_entry(obj, role): + return True + if AXUtilitiesRole.is_text(obj, role): + return AXUtilitiesState.is_editable(obj) + return False + + @staticmethod + def is_slider(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the slider role""" if role is None: @@ -1160,7 +2022,7 @@ class AXUtilitiesRole: return role == Atspi.Role.SLIDER @staticmethod - def is_spin_button(obj, role=None): + def is_spin_button(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the spin button role""" if role is None: @@ -1168,7 +2030,7 @@ class AXUtilitiesRole: return role == Atspi.Role.SPIN_BUTTON @staticmethod - def is_split_pane(obj, role=None): + def is_split_pane(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the split pane role""" if role is None: @@ -1176,7 +2038,7 @@ class AXUtilitiesRole: return role == Atspi.Role.SPLIT_PANE @staticmethod - def is_static(obj, role=None): + def is_static(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the static role""" if role is None: @@ -1184,7 +2046,7 @@ class AXUtilitiesRole: return role == Atspi.Role.STATIC @staticmethod - def is_status_bar(obj, role=None): + def is_status_bar(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the statusbar role""" if role is None: @@ -1192,7 +2054,7 @@ class AXUtilitiesRole: return role == Atspi.Role.STATUS_BAR @staticmethod - def is_subscript(obj, role=None): + def is_subscript(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the subscript role""" if role is None: @@ -1200,7 +2062,9 @@ class AXUtilitiesRole: return role == Atspi.Role.SUBSCRIPT @staticmethod - def is_subscript_or_superscript(obj, role=None): + def is_subscript_or_superscript( + obj: Atspi.Accessible, role: Atspi.Role | None = None + ) -> bool: """Returns True if obj has the subscript or superscript role""" if role is None: @@ -1208,15 +2072,26 @@ class AXUtilitiesRole: return role in [Atspi.Role.SUBSCRIPT, Atspi.Role.SUPERSCRIPT] @staticmethod - def is_suggestion(obj, role=None): + def is_subscript_or_superscript_text( + obj: Atspi.Accessible, role: Atspi.Role | None = None + ) -> bool: + """Returns True if obj has the subscript or superscript role and is not math-related""" + + if AXUtilitiesRole.is_math_related(obj, role): + return False + return AXUtilitiesRole.is_subscript_or_superscript(obj, role) + + @staticmethod + def is_suggestion(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the suggestion role""" if role is None: role = AXObject.get_role(obj) - return role == Atspi.Role.SUGGESTION + return role == Atspi.Role.SUGGESTION \ + or "suggestion" in AXUtilitiesRole._get_xml_roles(obj) @staticmethod - def is_superscript(obj, role=None): + def is_superscript(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the superscript role""" if role is None: @@ -1224,7 +2099,25 @@ class AXUtilitiesRole: return role == Atspi.Role.SUPERSCRIPT @staticmethod - def is_table(obj, role=None): + def is_svg(obj: Atspi.Accessible) -> bool: + """Returns True if obj is an svg.""" + + return AXUtilitiesRole._get_tag(obj) == "svg" + + @staticmethod + def is_switch(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the switch role.""" + + if role is None: + role = AXObject.get_role(obj) + + if role == Atspi.Role.SWITCH: + return True + + return "switch" in AXUtilitiesRole._get_xml_roles(obj) + + @staticmethod + def is_table(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the table role""" if role is None: @@ -1232,7 +2125,7 @@ class AXUtilitiesRole: return role == Atspi.Role.TABLE @staticmethod - def is_table_cell(obj, role=None): + def is_table_cell(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the table cell role""" if role is None: @@ -1240,7 +2133,7 @@ class AXUtilitiesRole: return role == Atspi.Role.TABLE_CELL @staticmethod - def is_table_cell_or_header(obj, role=None): + def is_table_cell_or_header(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the table cell or a header-related role""" roles = AXUtilitiesRole.get_table_cell_roles() @@ -1249,7 +2142,7 @@ class AXUtilitiesRole: return role in roles @staticmethod - def is_table_column_header(obj, role=None): + def is_table_column_header(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the table column header role""" if role is None: @@ -1257,7 +2150,7 @@ class AXUtilitiesRole: return role == Atspi.Role.TABLE_COLUMN_HEADER @staticmethod - def is_table_header(obj, role=None): + def is_table_header(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has a table header related role""" roles = AXUtilitiesRole.get_table_header_roles() @@ -1266,7 +2159,11 @@ class AXUtilitiesRole: return role in roles @staticmethod - def is_table_related(obj, role=None, include_caption=False): + def is_table_related( + obj: Atspi.Accessible, + role: Atspi.Role | None = None, + include_caption: bool = False + ) -> bool: """Returns True if obj has a table-related role""" roles = AXUtilitiesRole.get_table_related_roles(include_caption) @@ -1275,7 +2172,7 @@ class AXUtilitiesRole: return role in roles @staticmethod - def is_table_row(obj, role=None): + def is_table_row(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the table row role""" if role is None: @@ -1283,7 +2180,7 @@ class AXUtilitiesRole: return role == Atspi.Role.TABLE_ROW @staticmethod - def is_table_row_header(obj, role=None): + def is_table_row_header(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the table row header role""" if role is None: @@ -1291,7 +2188,7 @@ class AXUtilitiesRole: return role == Atspi.Role.TABLE_ROW_HEADER @staticmethod - def is_tearoff_menu_item(obj, role=None): + def is_tearoff_menu_item(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the tearoff menu item role""" if role is None: @@ -1299,14 +2196,14 @@ class AXUtilitiesRole: return role == Atspi.Role.TEAROFF_MENU_ITEM @staticmethod - def is_terminal(obj, role=None): + def is_terminal(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the terminal role""" if role is None: role = AXObject.get_role(obj) return role == Atspi.Role.TERMINAL @staticmethod - def is_text(obj, role=None): + def is_text(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the text role""" if role is None: @@ -1314,16 +2211,127 @@ class AXUtilitiesRole: return role == Atspi.Role.TEXT @staticmethod - def is_text_input(obj, role=None): + def is_text_input(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has any role associated with textual input""" roles = [Atspi.Role.ENTRY, Atspi.Role.PASSWORD_TEXT, Atspi.Role.SPIN_BUTTON] if role is None: role = AXObject.get_role(obj) - return role in roles + if role in roles: + return True + if role == Atspi.Role.TEXT: + return AXUtilitiesState.is_editable(obj) and AXUtilitiesState.is_single_line(obj) + if AXUtilitiesRole.is_editable_combo_box(obj): + return True + return False @staticmethod - def is_timer(obj, role=None): + def is_text_input_date(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj is a date text input""" + + if not AXUtilitiesRole.is_text_input(obj, role): + return False + + attrs = AXObject.get_attributes_dict(obj) + return attrs.get("text-input-type") == "date" + + @staticmethod + def is_text_input_email(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj is an email text input""" + + if not AXUtilitiesRole.is_text_input(obj, role): + return False + + attrs = AXObject.get_attributes_dict(obj) + return attrs.get("text-input-type") == "email" + + @staticmethod + def is_text_input_number(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj is a numeric text input""" + + if not AXUtilitiesRole.is_text_input(obj, role): + return False + + attrs = AXObject.get_attributes_dict(obj) + return attrs.get("text-input-type") == "number" + + @staticmethod + def is_text_input_search(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj is a telephone text input""" + + if not AXUtilitiesRole.is_text_input(obj, role): + return False + + attrs = AXObject.get_attributes_dict(obj) + if attrs.get("text-input-type") == "search": + return True + if "searchbox" in AXUtilitiesRole._get_xml_roles(obj): + return True + + ax_id = AXObject.get_accessible_id(obj) or "" + if ax_id: + return "search" in ax_id.lower() or "find" in ax_id.lower() + + child = AXObject.get_child(obj, 0) + if AXUtilitiesRole.is_icon(child) or AXUtilitiesRole.is_image(child): + child_id = AXObject.get_accessible_id(child) or "" + if "search" in child_id.lower() or "find" in child_id.lower(): + return True + # Some toolkits don't localize the symbolic icon names, so it's worth a try. + child_name = AXObject.get_name(child).lower() + return "search" in child_name or "find" in child_name + + return False + + @staticmethod + def is_text_input_telephone(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj is a telephone text input""" + + if not AXUtilitiesRole.is_text_input(obj, role): + return False + + attrs = AXObject.get_attributes_dict(obj) + return attrs.get("text-input-type") == "telephone" + + @staticmethod + def is_text_input_time(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj is a time text input""" + + if not AXUtilitiesRole.is_text_input(obj, role): + return False + + attrs = AXObject.get_attributes_dict(obj) + return attrs.get("text-input-type") == "time" + + @staticmethod + def is_text_input_url(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj is a url text input""" + + if not AXUtilitiesRole.is_text_input(obj, role): + return False + + attrs = AXObject.get_attributes_dict(obj) + return attrs.get("text-input-type") == "url" + + @staticmethod + def is_text_input_week(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj is a week text input""" + + if not AXUtilitiesRole.is_text_input(obj, role): + return False + + attrs = AXObject.get_attributes_dict(obj) + return attrs.get("text-input-type") == "week" + + @staticmethod + def is_time(obj: Atspi.Accessible, _role: Atspi.Role | None = None) -> bool: + """Returns True if obj has the time role""" + + return "time" in AXUtilitiesRole._get_xml_roles(obj) \ + or "time" == AXUtilitiesRole._get_tag(obj) + + @staticmethod + def is_timer(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the timer role""" if role is None: @@ -1331,7 +2339,7 @@ class AXUtilitiesRole: return role == Atspi.Role.TIMER @staticmethod - def is_title_bar(obj, role=None): + def is_title_bar(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the titlebar role""" if role is None: @@ -1339,7 +2347,7 @@ class AXUtilitiesRole: return role == Atspi.Role.TITLE_BAR @staticmethod - def is_toggle_button(obj, role=None): + def is_toggle_button(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the toggle button role""" if role is None: @@ -1347,7 +2355,7 @@ class AXUtilitiesRole: return role == Atspi.Role.TOGGLE_BUTTON @staticmethod - def is_tool_bar(obj, role=None): + def is_tool_bar(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the toolbar role""" if role is None: @@ -1355,7 +2363,7 @@ class AXUtilitiesRole: return role == Atspi.Role.TOOL_BAR @staticmethod - def is_tool_tip(obj, role=None): + def is_tool_tip(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the tooltip role""" if role is None: @@ -1363,7 +2371,7 @@ class AXUtilitiesRole: return role == Atspi.Role.TOOL_TIP @staticmethod - def is_tree(obj, role=None): + def is_tree(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the tree role""" if role is None: @@ -1371,7 +2379,7 @@ class AXUtilitiesRole: return role == Atspi.Role.TREE @staticmethod - def is_tree_or_tree_table(obj, role=None): + def is_tree_or_tree_table(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the tree or tree table role""" if role is None: @@ -1379,7 +2387,7 @@ class AXUtilitiesRole: return role in [Atspi.Role.TREE, Atspi.Role.TREE_TABLE] @staticmethod - def is_tree_related(obj, role=None): + def is_tree_related(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has a tree-related role""" roles = [Atspi.Role.TREE, @@ -1390,7 +2398,7 @@ class AXUtilitiesRole: return role in roles @staticmethod - def is_tree_item(obj, role=None): + def is_tree_item(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the tree item role""" if role is None: @@ -1398,7 +2406,7 @@ class AXUtilitiesRole: return role == Atspi.Role.TREE_ITEM @staticmethod - def is_tree_table(obj, role=None): + def is_tree_table(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the tree table role""" if role is None: @@ -1406,7 +2414,7 @@ class AXUtilitiesRole: return role == Atspi.Role.TREE_TABLE @staticmethod - def is_unknown(obj, role=None): + def is_unknown(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the unknown role""" if role is None: @@ -1414,7 +2422,7 @@ class AXUtilitiesRole: return role == Atspi.Role.UNKNOWN @staticmethod - def is_unknown_or_redundant(obj, role=None): + def is_unknown_or_redundant(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the unknown or redundant object role""" if role is None: @@ -1422,28 +2430,28 @@ class AXUtilitiesRole: return role in [Atspi.Role.UNKNOWN, Atspi.Role.REDUNDANT_OBJECT] @staticmethod - def is_vertical_scrollbar(obj, role=None): + def is_vertical_scrollbar(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj is a vertical scrollbar""" return AXUtilitiesRole.is_scroll_bar(obj, role) \ and AXObject.has_state(obj, Atspi.StateType.VERTICAL) @staticmethod - def is_vertical_separator(obj, role=None): + def is_vertical_separator(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj is a vertical separator""" return AXUtilitiesRole.is_separator(obj, role) \ and AXObject.has_state(obj, Atspi.StateType.VERTICAL) @staticmethod - def is_vertical_slider(obj, role=None): + def is_vertical_slider(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj is a vertical slider""" return AXUtilitiesRole.is_slider(obj, role) \ and AXObject.has_state(obj, Atspi.StateType.VERTICAL) @staticmethod - def is_video(obj, role=None): + def is_video(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the video role""" if role is None: @@ -1451,7 +2459,7 @@ class AXUtilitiesRole: return role == Atspi.Role.VIDEO @staticmethod - def is_viewport(obj, role=None): + def is_viewport(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the viewport role""" if role is None: @@ -1459,7 +2467,63 @@ class AXUtilitiesRole: return role == Atspi.Role.VIEWPORT @staticmethod - def is_window(obj, role=None): + def is_web_element(obj: Atspi.Accessible, exclude_pseudo_elements: bool = True) -> bool: + """Returns True if obj is a web element""" + + tag = AXUtilitiesRole._get_tag(obj) + if not tag: + return False + if not exclude_pseudo_elements: + return True + exclude = ["::before", "::after", "::marker"] + return tag not in exclude + + @staticmethod + def is_web_element_custom(obj: Atspi.Accessible) -> bool: + """Returns True if obj is a custom web element""" + + tag = AXUtilitiesRole._get_tag(obj) + return tag is not None and "-" in tag + + @staticmethod + def is_widget(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: + """Returns True if obj has a widget role""" + + if role is None: + role = AXObject.get_role(obj) + return role in AXUtilitiesRole.get_widget_roles() + + @staticmethod + def is_widget_controlled_by_line_navigation( + obj: Atspi.Accessible, role: Atspi.Role | None = None + ) -> bool: + """Returns True if obj is a widget controlled by line navigation""" + + if role is None: + role = AXObject.get_role(obj) + + roles = [Atspi.Role.COMBO_BOX, + Atspi.Role.LIST_BOX, + Atspi.Role.MENU, + Atspi.Role.SPIN_BUTTON, + Atspi.Role.TREE, + Atspi.Role.TREE_TABLE] + if role in roles: + return True + + if AXUtilitiesState.is_editable(obj) or AXUtilitiesState.is_selectable(obj): + return AXObject.find_ancestor(obj, lambda x: AXObject.get_role(x) in roles) is not None + + if not AXUtilitiesState.is_vertical(obj): + return False + + return role in [Atspi.Role.SCROLL_BAR, + Atspi.Role.SEPARATOR, + Atspi.Role.SLIDER, + Atspi.Role.SPLIT_PANE] + + @staticmethod + def is_window(obj: Atspi.Accessible, role: Atspi.Role | None = None) -> bool: """Returns True if obj has the window role""" if role is None: diff --git a/src/cthulhu/ax_utilities_state.py b/src/cthulhu/ax_utilities_state.py index b0f46d3..b146261 100644 --- a/src/cthulhu/ax_utilities_state.py +++ b/src/cthulhu/ax_utilities_state.py @@ -1,9 +1,7 @@ -#!/usr/bin/env python3 +# Utilities for obtaining state-related information. # -# 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 2023 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,19 +17,12 @@ # 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 -""" -Utilities for obtaining state-related information. -These utilities are app-type- and toolkit-agnostic. Utilities that might have -different implementations or results depending on the type of app (e.g. terminal, -chat, web) or toolkit (e.g. Qt, Gtk) should be in script_utilities.py file(s). +# pylint: disable=wrong-import-position +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-return-statements -N.B. There are currently utilities that should never have custom implementations -that live in script_utilities.py files. These will be moved over time. -""" +"""Utilities for obtaining state-related information.""" __id__ = "$Id$" __version__ = "$Revision$" @@ -44,6 +35,7 @@ gi.require_version("Atspi", "2.0") from gi.repository import Atspi from . import debug +from . import messages from .ax_object import AXObject @@ -51,49 +43,71 @@ class AXUtilitiesState: """Utilities for obtaining state-related information.""" @staticmethod - def has_no_state(obj): + def get_current_item_status_string(obj: Atspi.Accessible) -> str: + """Returns the current item status string of obj.""" + + if not AXUtilitiesState.is_active(obj): + return "" + + result = AXObject.get_attribute(obj, "current") + if not result: + return "" + if result == "date": + return messages.CURRENT_DATE + if result == "time": + return messages.CURRENT_TIME + if result == "location": + return messages.CURRENT_LOCATION + if result == "page": + return messages.CURRENT_PAGE + if result == "step": + return messages.CURRENT_STEP + return messages.CURRENT_ITEM + + @staticmethod + def has_no_state(obj: Atspi.Accessible) -> bool: """Returns true if obj has an empty state set""" return AXObject.get_state_set(obj).is_empty() @staticmethod - def has_popup(obj): + def has_popup(obj: Atspi.Accessible) -> bool: """Returns true if obj has the has-popup state""" return AXObject.has_state(obj, Atspi.StateType.HAS_POPUP) @staticmethod - def has_tooltip(obj): + def has_tooltip(obj: Atspi.Accessible) -> bool: """Returns true if obj has the has-tooltip state""" return AXObject.has_state(obj, Atspi.StateType.HAS_TOOLTIP) @staticmethod - def is_active(obj): + def is_active(obj: Atspi.Accessible) -> bool: """Returns true if obj has the active state""" return AXObject.has_state(obj, Atspi.StateType.ACTIVE) @staticmethod - def is_animated(obj): + def is_animated(obj: Atspi.Accessible) -> bool: """Returns true if obj has the animated state""" return AXObject.has_state(obj, Atspi.StateType.ANIMATED) @staticmethod - def is_armed(obj): + def is_armed(obj: Atspi.Accessible) -> bool: """Returns true if obj has the armed state""" return AXObject.has_state(obj, Atspi.StateType.ARMED) @staticmethod - def is_busy(obj): + def is_busy(obj: Atspi.Accessible) -> bool: """Returns true if obj has the busy state""" return AXObject.has_state(obj, Atspi.StateType.BUSY) @staticmethod - def is_checkable(obj): + def is_checkable(obj: Atspi.Accessible) -> bool: """Returns true if obj has the checkable state""" if AXObject.has_state(obj, Atspi.StateType.CHECKABLE): @@ -101,13 +115,13 @@ class AXUtilitiesState: if AXObject.has_state(obj, Atspi.StateType.CHECKED): tokens = ["AXUtilitiesState:", obj, "is checked but lacks state checkable"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return True return False @staticmethod - def is_checked(obj): + def is_checked(obj: Atspi.Accessible) -> bool: """Returns true if obj has the checked state""" if not AXObject.has_state(obj, Atspi.StateType.CHECKED): @@ -115,42 +129,42 @@ class AXUtilitiesState: if not AXObject.has_state(obj, Atspi.StateType.CHECKABLE): tokens = ["AXUtilitiesState:", obj, "is checked but lacks state checkable"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return True @staticmethod - def is_collapsed(obj): + def is_collapsed(obj: Atspi.Accessible) -> bool: """Returns true if obj has the collapsed state""" return AXObject.has_state(obj, Atspi.StateType.COLLAPSED) @staticmethod - def is_default(obj): + def is_default(obj: Atspi.Accessible) -> bool: """Returns true if obj has the is-default state""" return AXObject.has_state(obj, Atspi.StateType.IS_DEFAULT) @staticmethod - def is_defunct(obj): + def is_defunct(obj: Atspi.Accessible) -> bool: """Returns true if obj has the defunct state""" return AXObject.has_state(obj, Atspi.StateType.DEFUNCT) @staticmethod - def is_editable(obj): + def is_editable(obj: Atspi.Accessible) -> bool: """Returns true if obj has the editable state""" return AXObject.has_state(obj, Atspi.StateType.EDITABLE) @staticmethod - def is_enabled(obj): + def is_enabled(obj: Atspi.Accessible) -> bool: """Returns true if obj has the enabled state""" return AXObject.has_state(obj, Atspi.StateType.ENABLED) @staticmethod - def is_expandable(obj): + def is_expandable(obj: Atspi.Accessible) -> bool: """Returns true if obj has the expandable state""" if AXObject.has_state(obj, Atspi.StateType.EXPANDABLE): @@ -158,13 +172,13 @@ class AXUtilitiesState: if AXObject.has_state(obj, Atspi.StateType.EXPANDED): tokens = ["AXUtilitiesState:", obj, "is expanded but lacks state expandable"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return True return False @staticmethod - def is_expanded(obj): + def is_expanded(obj: Atspi.Accessible) -> bool: """Returns true if obj has the expanded state""" if not AXObject.has_state(obj, Atspi.StateType.EXPANDED): @@ -172,12 +186,12 @@ class AXUtilitiesState: if not AXObject.has_state(obj, Atspi.StateType.EXPANDABLE): tokens = ["AXUtilitiesState:", obj, "is expanded but lacks state expandable"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return True @staticmethod - def is_focusable(obj): + def is_focusable(obj: Atspi.Accessible) -> bool: """Returns true if obj has the focusable state""" if AXObject.has_state(obj, Atspi.StateType.FOCUSABLE): @@ -185,13 +199,13 @@ class AXUtilitiesState: if AXObject.has_state(obj, Atspi.StateType.FOCUSED): tokens = ["AXUtilitiesState:", obj, "is focused but lacks state focusable"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return True return False @staticmethod - def is_focused(obj): + def is_focused(obj: Atspi.Accessible) -> bool: """Returns true if obj has the focused state""" if not AXObject.has_state(obj, Atspi.StateType.FOCUSED): @@ -199,168 +213,180 @@ class AXUtilitiesState: if not AXObject.has_state(obj, Atspi.StateType.FOCUSABLE): tokens = ["AXUtilitiesState:", obj, "is focused but lacks state focusable"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return True @staticmethod - def is_horizontal(obj): + def is_hidden(obj: Atspi.Accessible) -> bool: + """Returns true if obj reports being hidden""" + + return AXObject.get_attribute(obj, "hidden", False) == "true" + + @staticmethod + def is_horizontal(obj: Atspi.Accessible) -> bool: """Returns true if obj has the horizontal state""" return AXObject.has_state(obj, Atspi.StateType.HORIZONTAL) @staticmethod - def is_iconified(obj): + def is_iconified(obj: Atspi.Accessible) -> bool: """Returns true if obj has the iconified state""" return AXObject.has_state(obj, Atspi.StateType.ICONIFIED) @staticmethod - def is_indeterminate(obj): + def is_indeterminate(obj: Atspi.Accessible) -> bool: """Returns true if obj has the indeterminate state""" return AXObject.has_state(obj, Atspi.StateType.INDETERMINATE) @staticmethod - def is_invalid_state(obj): + def is_invalid_state(obj: Atspi.Accessible) -> bool: """Returns true if obj has the invalid_state state""" return AXObject.has_state(obj, Atspi.StateType.INVALID) @staticmethod - def is_invalid_entry(obj): + def is_invalid_entry(obj: Atspi.Accessible) -> bool: """Returns true if obj has the invalid_entry state""" return AXObject.has_state(obj, Atspi.StateType.INVALID_ENTRY) @staticmethod - def is_modal(obj): + def is_modal(obj: Atspi.Accessible) -> bool: """Returns true if obj has the modal state""" return AXObject.has_state(obj, Atspi.StateType.MODAL) @staticmethod - def is_multi_line(obj): + def is_multi_line(obj: Atspi.Accessible) -> bool: """Returns true if obj has the multi_line state""" return AXObject.has_state(obj, Atspi.StateType.MULTI_LINE) @staticmethod - def is_multiselectable(obj): + def is_multiselectable(obj: Atspi.Accessible) -> bool: """Returns true if obj has the multiselectable state""" return AXObject.has_state(obj, Atspi.StateType.MULTISELECTABLE) @staticmethod - def is_opaque(obj): + def is_opaque(obj: Atspi.Accessible) -> bool: """Returns true if obj has the opaque state""" return AXObject.has_state(obj, Atspi.StateType.OPAQUE) @staticmethod - def is_pressed(obj): + def is_pressed(obj: Atspi.Accessible) -> bool: """Returns true if obj has the pressed state""" return AXObject.has_state(obj, Atspi.StateType.PRESSED) @staticmethod - def is_read_only(obj): + def is_read_only(obj: Atspi.Accessible) -> bool: """Returns true if obj has the read-only state""" - return AXObject.has_state(obj, Atspi.StateType.READ_ONLY) + if AXObject.has_state(obj, Atspi.StateType.READ_ONLY): + return True + if AXUtilitiesState.is_editable(obj): + return False + + # We cannot count on GTK to set the read-only state on text objects. + return AXObject.get_role(obj) == Atspi.Role.TEXT @staticmethod - def is_required(obj): + def is_required(obj: Atspi.Accessible) -> bool: """Returns true if obj has the required state""" return AXObject.has_state(obj, Atspi.StateType.REQUIRED) @staticmethod - def is_resizable(obj): + def is_resizable(obj: Atspi.Accessible) -> bool: """Returns true if obj has the resizable state""" return AXObject.has_state(obj, Atspi.StateType.RESIZABLE) @staticmethod - def is_selectable(obj): + def is_selectable(obj: Atspi.Accessible) -> bool: """Returns true if obj has the selectable state""" return AXObject.has_state(obj, Atspi.StateType.SELECTABLE) @staticmethod - def is_selectable_text(obj): + def is_selectable_text(obj: Atspi.Accessible) -> bool: """Returns true if obj has the selectable-text state""" return AXObject.has_state(obj, Atspi.StateType.SELECTABLE_TEXT) @staticmethod - def is_selected(obj): + def is_selected(obj: Atspi.Accessible) -> bool: """Returns true if obj has the selected state""" return AXObject.has_state(obj, Atspi.StateType.SELECTED) @staticmethod - def is_sensitive(obj): + def is_sensitive(obj: Atspi.Accessible) -> bool: """Returns true if obj has the sensitive state""" return AXObject.has_state(obj, Atspi.StateType.SENSITIVE) @staticmethod - def is_showing(obj): + def is_showing(obj: Atspi.Accessible) -> bool: """Returns true if obj has the showing state""" return AXObject.has_state(obj, Atspi.StateType.SHOWING) @staticmethod - def is_single_line(obj): + def is_single_line(obj: Atspi.Accessible) -> bool: """Returns true if obj has the single-line state""" return AXObject.has_state(obj, Atspi.StateType.SINGLE_LINE) @staticmethod - def is_stale(obj): + def is_stale(obj: Atspi.Accessible) -> bool: """Returns true if obj has the stale state""" return AXObject.has_state(obj, Atspi.StateType.STALE) @staticmethod - def is_transient(obj): + def is_transient(obj: Atspi.Accessible) -> bool: """Returns true if obj has the transient state""" return AXObject.has_state(obj, Atspi.StateType.TRANSIENT) @staticmethod - def is_truncated(obj): + def is_truncated(obj: Atspi.Accessible) -> bool: """Returns true if obj has the truncated state""" return AXObject.has_state(obj, Atspi.StateType.TRUNCATED) @staticmethod - def is_vertical(obj): + def is_vertical(obj: Atspi.Accessible) -> bool: """Returns true if obj has the vertical state""" return AXObject.has_state(obj, Atspi.StateType.VERTICAL) @staticmethod - def is_visible(obj): + def is_visible(obj: Atspi.Accessible) -> bool: """Returns true if obj has the visible state""" return AXObject.has_state(obj, Atspi.StateType.VISIBLE) @staticmethod - def is_visited(obj): + def is_visited(obj: Atspi.Accessible) -> bool: """Returns true if obj has the visited state""" return AXObject.has_state(obj, Atspi.StateType.VISITED) @staticmethod - def manages_descendants(obj): + def manages_descendants(obj: Atspi.Accessible) -> bool: """Returns true if obj has the manages-descendants state""" return AXObject.has_state(obj, Atspi.StateType.MANAGES_DESCENDANTS) @staticmethod - def supports_autocompletion(obj): + def supports_autocompletion(obj: Atspi.Accessible) -> bool: """Returns true if obj has the supports-autocompletion state""" return AXObject.has_state(obj, Atspi.StateType.SUPPORTS_AUTOCOMPLETION) diff --git a/src/cthulhu/ax_value.py b/src/cthulhu/ax_value.py new file mode 100644 index 0000000..effeccc --- /dev/null +++ b/src/cthulhu/ax_value.py @@ -0,0 +1,206 @@ +# Orca +# +# 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 +# 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 + +"""Utilities for obtaining value-related information about accessible objects.""" + +__id__ = "$Id$" +__version__ = "$Revision$" +__date__ = "$Date$" +__copyright__ = "Copyright (c) 2024 Igalia, S.L." \ + "Copyright (c) 2024 GNOME Foundation Inc." +__license__ = "LGPL" + +import threading +import time + +import gi +gi.require_version("Atspi", "2.0") +from gi.repository import Atspi +from gi.repository import GLib + +from . import debug +from .ax_object import AXObject +from .ax_utilities import AXUtilities + +class AXValue: + """Utilities for obtaining value-related information about accessible objects.""" + + LAST_KNOWN_VALUE: dict[int, float] = {} + _lock = threading.Lock() + + @staticmethod + def _clear_stored_data() -> None: + """Clears any data we have cached for objects""" + + while True: + time.sleep(60) + msg = "AXValue: Clearing local cache." + debug.print_message(debug.LEVEL_INFO, msg, True) + AXValue.LAST_KNOWN_VALUE.clear() + + @staticmethod + def start_cache_clearing_thread() -> None: + """Starts thread to periodically clear cached details.""" + + thread = threading.Thread(target=AXValue._clear_stored_data) + thread.daemon = True + thread.start() + + @staticmethod + def did_value_change(obj: Atspi.Accessible) -> bool: + """Returns True if the current value changed.""" + + if not AXObject.supports_value(obj): + return False + + old_value = AXValue.LAST_KNOWN_VALUE.get(hash(obj)) + result = old_value != AXValue._get_current_value(obj) + if result: + tokens = ["AXValue: Previous value of", obj, f"was {old_value}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + return result + + @staticmethod + def _get_current_value(obj: Atspi.Accessible) -> float: + """Returns the current value of obj.""" + + if not AXObject.supports_value(obj): + return 0.0 + + try: + value = Atspi.Value.get_current_value(obj) + except GLib.GError as error: + msg = f"AXValue: Exception in _get_current_value: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return 0.0 + + tokens = ["AXValue: Current value of", obj, f"is {value}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return value + + @staticmethod + def get_current_value(obj: Atspi.Accessible) -> float: + """Returns the current value of obj.""" + + if not AXObject.supports_value(obj): + return 0.0 + + value = AXValue._get_current_value(obj) + AXValue.LAST_KNOWN_VALUE[hash(obj)] = value + return value + + @staticmethod + def get_current_value_text(obj: Atspi.Accessible) -> str: + """Returns the app-provided text-alternative for the current value of obj.""" + + text = AXObject.get_attribute(obj, "valuetext", False) or "" + if text: + tokens = ["AXValue: valuetext attribute for", obj, f"is '{text}'"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return text + + if not AXObject.supports_value(obj): + return "" + + try: + value = Atspi.Value.get_text(obj) + except GLib.GError as error: + msg = f"AXValue: Exception in get_current_value_text: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + value = "" + + tokens = ["AXValue: Value text of", obj, f"is '{value}'"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if value: + return value + + current = AXValue.get_current_value(obj) + if abs(current) < 1 and current != 0: + str_current = str(current) + decimal_places = len(str_current.split('.')[1]) + else: + decimal_places = 0 + + return f"{current:.{decimal_places}f}" + + @staticmethod + def get_value_as_percent(obj: Atspi.Accessible) -> int | None: + """Returns the current value as a percent, or None if that is not applicable.""" + + if not AXObject.supports_value(obj): + return None + + value = AXValue.get_current_value(obj) + if AXUtilities.is_indeterminate(obj) and value <= 0: + tokens = ["AXValue:", obj, "has state indeterminate"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return None + + minimum = AXValue.get_minimum_value(obj) + maximum = AXValue.get_maximum_value(obj) + if minimum == maximum: + return None + + result = int((value / (maximum - minimum)) * 100) + tokens = ["AXValue: Current value of", obj, f"as percent is is {result}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + @staticmethod + def get_minimum_value(obj: Atspi.Accessible) -> float: + """Returns the minimum value of obj.""" + + if not AXObject.supports_value(obj): + return 0.0 + + try: + value = Atspi.Value.get_minimum_value(obj) + except GLib.GError as error: + msg = f"AXValue: Exception in get_minimum_value: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return 0.0 + + tokens = ["AXValue: Minimum value of", obj, f"is {value}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return value + + @staticmethod + def get_maximum_value(obj: Atspi.Accessible) -> float: + """Returns the maximum value of obj.""" + + if not AXObject.supports_value(obj): + return 0.0 + + try: + value = Atspi.Value.get_maximum_value(obj) + except GLib.GError as error: + msg = f"AXValue: Exception in get_maximum_value: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return 0.0 + + tokens = ["AXValue: Maximum value of", obj, f"is {value}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return value + +AXValue.start_cache_clearing_thread() diff --git a/src/cthulhu/braille.py b/src/cthulhu/braille.py index bd01e67..c9a32cf 100644 --- a/src/cthulhu/braille.py +++ b/src/cthulhu/braille.py @@ -79,6 +79,11 @@ else: tokens = ["BRAILLE: brlapi imported", brlapi] debug.printTokens(debug.LEVEL_INFO, tokens, True) +BRLAPI_PRIORITY_IDLE = 0 +BRLAPI_PRIORITY_DEFAULT = 50 +BRLAPI_PRIORITY_HIGH = 70 +brlapi_priority = BRLAPI_PRIORITY_DEFAULT + try: msg = "BRAILLE: About to import louis." debug.printMessage(debug.LEVEL_INFO, msg, True) @@ -723,9 +728,7 @@ class Text(Region): unreasonable amount of time (AKA Gecko). """ - try: - self.accessible.queryText() - except NotImplementedError: + if not AXObject.supports_text(self.accessible): return '' # Start with an empty mask. @@ -1157,7 +1160,7 @@ def _idleBraille(): try: msg = "BRAILLE: Attempting to idle braille." debug.printMessage(debug.LEVEL_INFO, msg, True) - _brlAPI.setParameter(brlapi.PARAM_CLIENT_PRIORITY, 0, False, 0) + _brlAPI.setParameter(brlapi.PARAM_CLIENT_PRIORITY, 0, False, BRLAPI_PRIORITY_IDLE) idle = True except Exception: msg = "BRAILLE: Idling braille failled. This requires BrlAPI >= 0.8." @@ -1206,7 +1209,7 @@ def _enableBraille(): # Restore default priority msg = "BRAILLE: Attempting to de-idle braille." debug.printMessage(debug.LEVEL_INFO, msg, True) - _brlAPI.setParameter(brlapi.PARAM_CLIENT_PRIORITY, 0, False, 50) + _brlAPI.setParameter(brlapi.PARAM_CLIENT_PRIORITY, 0, False, BRLAPI_PRIORITY_DEFAULT) idle = False except Exception: msg = "BRAILLE: could not restore priority" @@ -1874,6 +1877,36 @@ def setupKeyRanges(keys): msg = "BRAILLE: Key ranges set up." debug.printMessage(debug.LEVEL_INFO, msg, True) +def setBrlapiPriority(level=BRLAPI_PRIORITY_DEFAULT): + """Set BRLAPI priority. + + Arguments: + - level: the priority level to apply. + """ + + global idle, brlapi_priority + + if not _brlAPIAvailable or not _brlAPIRunning or not settings.enableBraille: + return + + if idle: + msg = "BRAILLE: Braille is idle, don't change BRLAPI priority." + debug.printMessage(debug.LEVEL_INFO, msg, True) + brlapi_priority = level + return + + try: + tokens = ["BRAILLE: Setting priority to:", level] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + _brlAPI.setParameter(brlapi.PARAM_CLIENT_PRIORITY, 0, False, level) + except Exception as error: + msg = f"BRAILLE: Cannot set priority: {error}" + debug.printMessage(debug.LEVEL_WARNING, msg, True) + else: + msg = "BRAILLE: Priority set." + debug.printMessage(debug.LEVEL_INFO, msg, True) + brlapi_priority = level + def init(callback=None): """Initializes the braille module, connecting to the BrlTTY driver. diff --git a/src/cthulhu/braille_generator.py b/src/cthulhu/braille_generator.py index b0cca06..47d4177 100644 --- a/src/cthulhu/braille_generator.py +++ b/src/cthulhu/braille_generator.py @@ -44,7 +44,9 @@ from . import cthulhu_state from . import settings from . import settings_manager from .ax_object import AXObject +from .ax_text import AXText from .ax_utilities import AXUtilities +from .ax_utilities_relation import AXUtilitiesRelation from .braille_rolenames import shortRoleNames _settingsManager = settings_manager.getManager() @@ -471,22 +473,16 @@ class BrailleGenerator(generator.Generator): include = _settingsManager.getSetting('enableBrailleContext') if not include: return include - try: - text = obj.queryText() - except NotImplementedError: - text = None - if text and (self._script.utilities.isTextArea(obj) or AXUtilities.is_label(obj)): - try: - [lineString, startOffset, endOffset] = text.getTextAtOffset( - text.caretOffset, Atspi.TextBoundaryType.LINE_START) - except Exception: - return include + if AXObject.supports_text(obj) \ + and (self._script.utilities.isTextArea(obj) or AXUtilities.is_label(obj)): + caretOffset = AXText.get_caret_offset(obj) + lineString, startOffset, endOffset = AXText.get_line_at_offset(obj, caretOffset) include = startOffset == 0 if include: - relation = AXObject.get_relation(obj, Atspi.RelationType.FLOWS_FROM) - if relation: - include = not self._script.utilities.isTextArea(relation.get_target(0)) + flowsFrom = AXUtilitiesRelation.get_flows_from(obj) + if flowsFrom: + include = not self._script.utilities.isTextArea(flowsFrom[0]) return include ##################################################################### diff --git a/src/cthulhu/caret_navigation.py b/src/cthulhu/caret_navigation.py index f25b869..e84533a 100644 --- a/src/cthulhu/caret_navigation.py +++ b/src/cthulhu/caret_navigation.py @@ -37,6 +37,7 @@ from . import input_event from . import keybindings from . import messages from . import settings_manager +from .ax_text import AXText class CaretNavigation: @@ -443,7 +444,7 @@ class CaretNavigation: offset = 0 text = script.utilities.queryNonEmptyText(obj) if text: - offset = text.characterCount - 1 + offset = AXText.get_character_count(obj) - 1 while obj: lastobj, lastoffset = script.utilities.nextContext(obj, offset) diff --git a/src/cthulhu/cthulhu.py b/src/cthulhu/cthulhu.py index 77cb4d4..e923900 100644 --- a/src/cthulhu/cthulhu.py +++ b/src/cthulhu/cthulhu.py @@ -211,6 +211,7 @@ from . import learn_mode_presenter from . import logger from . import messages from . import notification_presenter +from . import focus_manager from . import cthulhu_state from . import cthulhu_platform from . import script_manager @@ -250,7 +251,7 @@ def _ensureManagers(): if _eventManager is None: _eventManager = event_manager.getManager() if _scriptManager is None: - _scriptManager = script_manager.getManager() + _scriptManager = script_manager.get_manager() if _settingsManager is None: _settingsManager = settings_manager.getManager() if _logger is None: @@ -300,67 +301,19 @@ OBJECT_NAVIGATOR = "object-navigator" SAY_ALL = "say-all" def getActiveModeAndObjectOfInterest(): - tokens = ["CTHULHU: Active mode:", cthulhu_state.activeMode, - "Object of interest:", cthulhu_state.objOfInterest] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return cthulhu_state.activeMode, cthulhu_state.objOfInterest + return focus_manager.get_manager().get_active_mode_and_object_of_interest() def emitRegionChanged(obj, startOffset=None, endOffset=None, mode=None): """Notifies interested clients that the current region of interest has changed.""" - - if startOffset is None: - startOffset = 0 - if endOffset is None: - endOffset = startOffset - if mode is None: - mode = FOCUS_TRACKING - - try: - obj.emit("mode-changed::" + mode, 1, "") - except Exception: - msg = "CTHULHU: Exception emitting mode-changed notification" - debug.printMessage(debug.LEVEL_INFO, msg, True) - - if mode != cthulhu_state.activeMode: - tokens = ["CTHULHU: Switching active mode from", cthulhu_state.activeMode, "to", mode] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - cthulhu_state.activeMode = mode - - try: - tokens = ["CTHULHU: Region of interest:", obj, "(", startOffset, ")", endOffset] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - obj.emit("region-changed", startOffset, endOffset) - except Exception: - msg = "CTHULHU: Exception emitting region-changed notification" - debug.printMessage(debug.LEVEL_INFO, msg, True) - - if obj != cthulhu_state.objOfInterest: - tokens = ["CTHULHU: Switching object of interest from", cthulhu_state.objOfInterest, "to", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - cthulhu_state.objOfInterest = obj + focus_manager.get_manager().emit_region_changed(obj, startOffset, endOffset, mode) def setActiveWindow(frame, app=None, alsoSetLocusOfFocus=False, notifyScript=False): - tokens = ["CTHULHU: Request to set active window to", frame] - if app is not None: - tokens.extend(["in", app]) - debug.printTokens(debug.LEVEL_INFO, tokens, True) - - if frame == cthulhu_state.activeWindow: - msg = "CTHULHU: Setting activeWindow to existing activeWindow" - debug.printMessage(debug.LEVEL_INFO, msg, True) - elif frame is None: - cthulhu_state.activeWindow = None - else: + real_app = app + real_frame = frame + if frame is not None and hasattr(AXObject, "find_real_app_and_window_for"): real_app, real_frame = AXObject.find_real_app_and_window_for(frame, app) - if real_frame != frame: - tokens = ["CTHULHU: Correcting active window to", real_frame, "in", real_app] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - cthulhu_state.activeWindow = real_frame - else: - cthulhu_state.activeWindow = frame - - if alsoSetLocusOfFocus: - setLocusOfFocus(None, cthulhu_state.activeWindow, notifyScript=notifyScript) + focus_manager.get_manager().set_active_window( + real_frame, real_app, set_window_as_focus=alsoSetLocusOfFocus, notify_script=notifyScript) def setLocusOfFocus(event, obj, notifyScript=True, force=False): """Sets the locus of focus (i.e., the object with visual focus) and @@ -375,51 +328,8 @@ def setLocusOfFocus(event, obj, notifyScript=True, force=False): current locusOfFocus """ - if not force and obj == cthulhu_state.locusOfFocus: - msg = "CTHULHU: Setting locusOfFocus to existing locusOfFocus" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - if event and (cthulhu_state.activeScript and not cthulhu_state.activeScript.app): - app = AXObject.get_application(event.source) - script = _scriptManager.getScript(app, event.source) - _scriptManager.setActiveScript(script, "Setting locusOfFocus") - - oldFocus = cthulhu_state.locusOfFocus - if AXObject.is_dead(oldFocus): - oldFocus = None - - if obj is None: - msg = "CTHULHU: New locusOfFocus is null (being cleared)" - debug.printMessage(debug.LEVEL_INFO, msg, True) - cthulhu_state.locusOfFocus = None - return - - if cthulhu_state.activeScript: - tokens = ["CTHULHU: Active script is:", cthulhu_state.activeScript] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - if cthulhu_state.activeScript.utilities.isZombie(obj): - tokens = ["ERROR: New locusOfFocus (", obj, ") is zombie. Not updating."] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return - if AXObject.is_dead(obj): - tokens = ["ERROR: New locusOfFocus (", obj, ") is dead. Not updating."] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return - - tokens = ["CTHULHU: Changing locusOfFocus from", oldFocus, "to", obj, ". Notify:", notifyScript] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - cthulhu_state.locusOfFocus = obj - - if not notifyScript: - return - - if not cthulhu_state.activeScript: - msg = "CTHULHU: Cannot notify active script because there isn't one" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - cthulhu_state.activeScript.locusOfFocusChanged(event, oldFocus, cthulhu_state.locusOfFocus) + focus_manager.get_manager().set_locus_of_focus( + event, obj, notify_script=notifyScript, force=force) ######################################################################## # # @@ -477,7 +387,7 @@ def updateKeyMap(keyboardEvent): """Unsupported convenience method to call sad hacks which should go away.""" global _restoreCthulhuKeys - if keyboardEvent.isPressedKey(): + if keyboardEvent.is_pressed_key(): return if keyboardEvent.event_string in settings.cthulhuModifierKeys \ @@ -638,7 +548,7 @@ def loadUserSettings(script=None, inputEvent=None, skipReloadMessage=False): debug.printException(debug.LEVEL_SEVERE) if not script: - script = _scriptManager.getDefaultScript() + script = _scriptManager.get_default_script() _settingsManager.loadAppSettings(script) @@ -754,7 +664,7 @@ def showPreferencesGUI(script=None, inputEvent=None): _ensureManagers() # Initialize managers if not already done prefs = _settingsManager.getGeneralSettings(_settingsManager.profile) - script = _scriptManager.getDefaultScript() + script = _scriptManager.get_default_script() _showPreferencesUI(script, prefs) return True @@ -1094,17 +1004,17 @@ def main(): # setActiveWindow does some corrective work needed thanks to # mutter-x11-frames. So retrieve the window just in case. window = cthulhu_state.activeWindow - script = _scriptManager.getScript(app, window) - _scriptManager.setActiveScript(script, "Launching.") + script = _scriptManager.get_script(app, window) + _scriptManager.set_active_script(script, "Launching.") focusedObject = AXUtilities.get_focused_object(window) tokens = ["CTHULHU: Focused object is:", focusedObject] debug.printTokens(debug.LEVEL_INFO, tokens, True) if focusedObject: setLocusOfFocus(None, focusedObject) - script = _scriptManager.getScript( + script = _scriptManager.get_script( AXObject.get_application(focusedObject), focusedObject) - _scriptManager.setActiveScript(script, "Found focused object.") + _scriptManager.set_active_script(script, "Found focused object.") try: msg = "CTHULHU: Starting ATSPI registry." @@ -1155,7 +1065,7 @@ class Cthulhu(GObject.Object): return self.eventManager def getSettingsManager(self): return self.settingsManager - def getScriptManager(self): + def get_scriptManager(self): return self.scriptManager def getDebugManager(self): return self.debugManager diff --git a/src/cthulhu/cthulhu_gui_prefs.py b/src/cthulhu/cthulhu_gui_prefs.py index 39ecd14..8073622 100644 --- a/src/cthulhu/cthulhu_gui_prefs.py +++ b/src/cthulhu/cthulhu_gui_prefs.py @@ -2361,7 +2361,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): svKeyBindings = self.script.getSpeechAndVerbosityManager().get_bindings() dtKeyBindings = self.script.getDateAndTimePresenter().get_bindings() bmKeyBindings = self.script.getBookmarks().get_bindings() - onKeyBindings = self.script.getObjectNavigator().get_bindings() + onKeyBindings = self.script.get_objectNavigator().get_bindings() lmKeyBindings = self.script.getLearnModePresenter().get_bindings() mrKeyBindings = self.script.getMouseReviewer().get_bindings() acKeyBindings = self.script.getActionPresenter().get_bindings() diff --git a/src/cthulhu/dbus_service.py b/src/cthulhu/dbus_service.py index 9af6f6a..daf51b3 100644 --- a/src/cthulhu/dbus_service.py +++ b/src/cthulhu/dbus_service.py @@ -455,8 +455,8 @@ if _dasbus_available: msg = "DBUS SERVICE: ShowPreferences called." debug.printMessage(debug.LEVEL_INFO, msg, True) - manager = script_manager.getManager() - script = cthulhu_state.activeScript or manager.getDefaultScript() + manager = script_manager.get_manager() + script = cthulhu_state.activeScript or manager.get_default_script() if script is None: msg = "DBUS SERVICE: No script available" debug.printMessage(debug.LEVEL_WARNING, msg, True) @@ -471,8 +471,8 @@ if _dasbus_available: msg = f"DBUS SERVICE: PresentMessage called with: '{message}'" debug.printMessage(debug.LEVEL_INFO, msg, True) - manager = script_manager.getManager() - script = cthulhu_state.activeScript or script_manager.getManager().getDefaultScript() + manager = script_manager.get_manager() + script = cthulhu_state.activeScript or script_manager.get_manager().get_default_script() if script is None: msg = "DBUS SERVICE: No script available" debug.printMessage(debug.LEVEL_WARNING, msg, True) @@ -675,10 +675,10 @@ class CthulhuRemoteController: event = _get_input_event().RemoteControllerEvent() script = cthulhu_state.activeScript if script is None: - manager = script_manager.getManager() - script = manager.getDefaultScript() + manager = script_manager.get_manager() + script = manager.get_default_script() rv = method(script=script, event=event, notify_user=notify_user) - _get_input_event_manager().getManager().process_remote_controller_event(event) + _get_input_event_manager().get_manager().process_remote_controller_event(event) return rv return _wrapper handler_info = _HandlerInfo( @@ -699,10 +699,10 @@ class CthulhuRemoteController: event = _get_input_event().RemoteControllerEvent() script = cthulhu_state.activeScript if script is None: - manager = script_manager.getManager() - script = manager.getDefaultScript() + manager = script_manager.get_manager() + script = manager.get_default_script() rv = method(script=script, event=event, **kwargs) - _get_input_event_manager().getManager().process_remote_controller_event(event) + _get_input_event_manager().get_manager().process_remote_controller_event(event) return rv return _wrapper handler_info = _HandlerInfo( @@ -870,4 +870,4 @@ _remote_controller: CthulhuRemoteController = CthulhuRemoteController() def get_remote_controller() -> CthulhuRemoteController: """Returns the CthulhuRemoteController singleton.""" - return _remote_controller \ No newline at end of file + return _remote_controller diff --git a/src/cthulhu/debug.py b/src/cthulhu/debug.py index febb9c3..e58d789 100644 --- a/src/cthulhu/debug.py +++ b/src/cthulhu/debug.py @@ -48,8 +48,14 @@ import gi gi.require_version("Atspi", "2.0") from gi.repository import Atspi -from .ax_object import AXObject -from .ax_utilities import AXUtilities +AXObject = None + +def _get_ax_object(): + global AXObject + if AXObject is None: + from .ax_object import AXObject as ax_object + AXObject = ax_object + return AXObject # Used to turn off all debugging. # @@ -210,6 +216,7 @@ def printStack(level): println(level) def _asString(obj): + AXObject = _get_ax_object() if isinstance(obj, Atspi.Accessible): result = AXObject.get_role_name(obj) name = AXObject.get_name(obj) @@ -257,12 +264,18 @@ def printTokens(level, tokens, timestamp=False, stack=False): text = re.sub(r" (?=[,.:)])(?![\n])", "", text) println(level, text, timestamp, stack) +def print_tokens(level, tokens, timestamp=False, stack=False): + return printTokens(level, tokens, timestamp, stack) + def printMessage(level, text, timestamp=False, stack=False): if level < debugLevel: return println(level, text, timestamp, stack) +def print_message(level, text, timestamp=False, stack=False): + return printMessage(level, text, timestamp, stack) + def _stackAsString(max_frames=4): callers = [] current_module = inspect.getmodule(inspect.currentframe()) @@ -412,30 +425,8 @@ def getAccessibleDetails(level, acc, indent="", includeApp=True): if level < debugLevel: return "" - if includeApp: - string = indent + f"app='{AXObject.application_as_string(acc)}' " - else: - string = indent - - if AXObject.is_dead(acc): - string += "(exception fetching data)" - return string - - name_string = "name='%s'".replace("\n", "\\n") % AXObject.get_name(acc) - desc_string = "%sdescription='%s'".replace("\n", "\\n") % \ - (indent, AXObject.get_description(acc)) - role_string = f"role='{AXObject.get_role_name(acc)}'" - path_string = f"{indent}path={AXObject.get_path(acc)}" - state_string = f"{indent}states='{AXObject.state_set_as_string(acc)}'" - rel_string = f"{indent}relations='{AXObject.relations_as_string(acc)}'" - actions_string = f"{indent}actions='{AXObject.actions_as_string(acc)}'" - iface_string = f"{indent}interfaces='{AXObject.supported_interfaces_as_string(acc)}'" - attr_string = f"{indent}attributes='{AXObject.attributes_as_string(acc)}'" - - string += "%s %s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n" \ - % (name_string, role_string, desc_string, state_string, rel_string, - actions_string, iface_string, attr_string, path_string) - return string + from .ax_utilities_debugging import AXUtilitiesDebugging + return AXUtilitiesDebugging.object_details_as_string(acc, indent, includeApp) # The following code originated from the following URL: # @@ -458,6 +449,7 @@ def _getFileAndModule(frame): return filename, module def _shouldTraceIt(): + AXObject = _get_ax_object() if not objEvent: return not TRACE_ONLY_PROCESSING_EVENTS @@ -555,6 +547,8 @@ def pidOf(procName): return [int(p) for p in pids.split()] def examineProcesses(force=False): + AXObject = _get_ax_object() + from .ax_utilities import AXUtilities if force: level = LEVEL_OFF else: diff --git a/src/cthulhu/event_manager.py b/src/cthulhu/event_manager.py index 6c35637..35fc16a 100644 --- a/src/cthulhu/event_manager.py +++ b/src/cthulhu/event_manager.py @@ -30,10 +30,9 @@ __copyright__ = "Copyright (c) 2011. Cthulhu Team." __license__ = "LGPL" import gi -gi.require_version('Atspi', '2.0') +gi.require_version('Atspi', '2.0') from gi.repository import Atspi from gi.repository import GLib -import pyatspi import queue import threading import time @@ -47,7 +46,7 @@ from . import settings from .ax_object import AXObject from .ax_utilities import AXUtilities -_scriptManager = script_manager.getManager() +_scriptManager = script_manager.get_manager() class EventManager: @@ -87,9 +86,8 @@ class EventManager: self._parentsOfDefunctDescendants = [] cthulhu_state.device = None - self.newKeyHandlingActive = False - self.legacyKeyHandlingActive = False - self.forceLegacyKeyHandling = False + self._keyHandlingActive = False + self._inputEventManager = None debug.printMessage(debug.LEVEL_INFO, 'Event manager initialized', True) @@ -97,49 +95,46 @@ class EventManager: """Called when this event manager is activated.""" debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Activating', True) - self.setKeyHandling(True) # Enable new InputEventManager for global keyboard capture - + self._activateKeyHandling() self._active = True debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Activated', True) - def activateNewKeyHandling(self): - if not self.newKeyHandlingActive: - try: - debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Attempting to activate new keyboard handling', True) - # Use the new InputEventManager instead of direct Atspi.Device - self._inputEventManager = input_event_manager.getManager() - debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: InputEventManager obtained', True) - self._inputEventManager.start_key_watcher() - debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Key watcher started', True) - cthulhu_state.device = self._inputEventManager._device # For compatibility - except Exception as e: - debug.printMessage(debug.LEVEL_INFO, f'EVENT MANAGER: New keyboard handling failed: {e}', True) - self.forceLegacyKeyHandling = True - self.activateLegacyKeyHandling() - return - - self.newKeyHandlingActive = True - debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: New keyboard handling activated with InputEventManager', True) - - # Notify plugin system that device is now available for keybinding registration - from . import cthulhu - if hasattr(cthulhu, 'cthulhuApp') and cthulhu.cthulhuApp: - plugin_manager = cthulhu.cthulhuApp.getPluginSystemManager() - if plugin_manager: - pass # plugin_manager.register_plugin_keybindings_with_active_script() + def _activateKeyHandling(self): + """Activates keyboard handling using InputEventManager with Atspi.Device.""" - def activateLegacyKeyHandling(self): - if not self.legacyKeyHandlingActive: - self.registerKeystrokeListener(self._processKeyboardEvent) - self.legacyKeyHandlingActive = True + if self._keyHandlingActive: + return - def setKeyHandling(self, new): - if new and not self.forceLegacyKeyHandling: - self.deactivateLegacyKeyHandling() - self.activateNewKeyHandling() + debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Activating keyboard handling', True) + self._inputEventManager = input_event_manager.get_manager() + self._inputEventManager.start_key_watcher() + cthulhu_state.device = self._inputEventManager._device + self._keyHandlingActive = True + debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Keyboard handling activated', True) + + def _deactivateKeyHandling(self): + """Deactivates keyboard handling.""" + + if not self._keyHandlingActive: + return + + debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Deactivating keyboard handling', True) + if self._inputEventManager: + self._inputEventManager.stop_key_watcher() + cthulhu_state.device = None + self._keyHandlingActive = False + debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Keyboard handling deactivated', True) + + def setKeyHandling(self, enable): + """Enables or disables keyboard handling. + + Arguments: + - enable: if True, activate keyboard handling; if False, deactivate. + """ + if enable: + self._activateKeyHandling() else: - self.deactivateNewKeyHandling() - self.activateLegacyKeyHandling() + self._deactivateKeyHandling() def deactivate(self): """Called when this event manager is deactivated.""" @@ -148,22 +143,9 @@ class EventManager: self._active = False self._eventQueue = queue.Queue(0) self._scriptListenerCounts = {} - self.deactivateLegacyKeyHandling() + self._deactivateKeyHandling() debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Deactivated', True) - def deactivateNewKeyHandling(self): - if self.newKeyHandlingActive: - if hasattr(self, '_inputEventManager'): - self._inputEventManager.stop_key_watcher() - self._inputEventManager = None - cthulhu_state.device = None - self.newKeyHandlingActive = False - - def deactivateLegacyKeyHandling(self): - if self.legacyKeyHandlingActive: - self.deregisterKeystrokeListener(self._processKeyboardEvent) - self.legacyKeyHandlingActive = False - def ignoreEventTypes(self, eventTypeList): for eventType in eventTypeList: if eventType not in self._ignoredEvents: @@ -524,7 +506,7 @@ class EventManager: def _shouldSuspendEventsFor(self, event): if AXUtilities.is_frame(event.source) \ or (AXUtilities.is_window(event.source) \ - and AXObject.get_application_toolkit_name(event.source) == "clutter"): + and AXUtilities.get_application_toolkit_name(event.source) == "clutter"): if event.type.startswith("window"): msg = "EVENT MANAGER: Should suspend events for window event." debug.printMessage(debug.LEVEL_INFO, msg, True) @@ -603,7 +585,7 @@ class EventManager: if isObjectEvent: if isinstance(e, input_event.MouseButtonEvent): asyncMode = True - elif AXObject.get_application_toolkit_name(e.source) in self._synchronousToolkits: + elif AXUtilities.get_application_toolkit_name(e.source) in self._synchronousToolkits: asyncMode = False elif e.type.startswith("object:children-changed"): asyncMode = AXUtilities.is_table(e.source) @@ -611,7 +593,7 @@ class EventManager: # To decrease the likelihood that the popup will be destroyed before we # have its contents. asyncMode = False - script = _scriptManager.getScript(AXObject.get_application(e.source), e.source) + script = _scriptManager.get_script(AXObject.get_application(e.source), e.source) script.eventCache[e.type] = (e, time.time()) self._addToQueue(e, asyncMode) @@ -633,8 +615,8 @@ class EventManager: if not self._isNoFocus(): return False - defaultScript = _scriptManager.getDefaultScript() - _scriptManager.setActiveScript(defaultScript, 'No focus') + defaultScript = _scriptManager.get_default_script() + _scriptManager.set_active_script(defaultScript, 'No focus') defaultScript.idleMessage() return False @@ -764,34 +746,6 @@ class EventManager: for eventType in script.listeners.keys(): self.deregisterListener(eventType) - def registerKeystrokeListener(self, function, mask=None, kind=None): - """Register the keystroke listener on behalf of the caller.""" - - tokens = ["EVENT MANAGER: Registering keystroke listener function:", function] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - - if mask is None: - mask = list(range(256)) - - if kind is None: - kind = (Atspi.EventType.KEY_PRESSED_EVENT, Atspi.EventType.KEY_RELEASED_EVENT) - - pyatspi.Registry.registerKeystrokeListener(function, mask=mask, kind=kind) - - def deregisterKeystrokeListener(self, function, mask=None, kind=None): - """Deregister the keystroke listener on behalf of the caller.""" - - tokens = ["EVENT MANAGER: De-registering keystroke listener function:", function] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - - if mask is None: - mask = list(range(256)) - - if kind is None: - kind = (Atspi.EventType.KEY_PRESSED_EVENT, Atspi.EventType.KEY_RELEASED_EVENT) - - pyatspi.Registry.deregisterKeystrokeListener(function, mask=mask, kind=kind) - def _processInputEvent(self, event): """Processes the given input event based on the keybinding from the currently-active script. @@ -826,11 +780,11 @@ class EventManager: debug.printMessage(debug.eventDebugLevel, msg, False) @staticmethod - def _getScriptForEvent(event): + def _get_scriptForEvent(event): """Returns the script associated with event.""" if event.type.startswith("mouse:"): - return _scriptManager.getScriptForMouseButtonEvent(event) + return _scriptManager.get_script_for_mouse_button_event(event) script = None app = AXObject.get_application(event.source) @@ -859,7 +813,7 @@ class EventManager: tokens = ["EVENT MANAGER: Getting script for", app, "check:", check] debug.printTokens(debug.LEVEL_INFO, tokens, True) - script = _scriptManager.getScript(app, event.source, sanityCheck=check) + script = _scriptManager.get_script(app, event.source, sanity_check=check) tokens = ["EVENT MANAGER: Script is ", script] debug.printTokens(debug.LEVEL_INFO, tokens, True) return script @@ -877,7 +831,7 @@ class EventManager: return False, "event.source? What event.source??" if not script: - script = self._getScriptForEvent(event) + script = self._get_scriptForEvent(event) if not script: return False, "There is no script for this event." @@ -1065,14 +1019,14 @@ class EventManager: if eType.startswith("object:children-changed:remove") \ and event.source == AXUtilities.get_desktop(): - _scriptManager.reclaimScripts() + _scriptManager.reclaim_scripts() return if eType.startswith("window:") and not eType.endswith("create"): - _scriptManager.reclaimScripts() + _scriptManager.reclaim_scripts() elif eType.startswith("object:state-changed:active") \ and AXUtilities.is_frame(event.source): - _scriptManager.reclaimScripts() + _scriptManager.reclaim_scripts() if AXObject.is_dead(event.source) or AXUtilities.is_defunct(event.source): tokens = ["EVENT MANAGER: Ignoring defunct object:", event.source] @@ -1084,7 +1038,7 @@ class EventManager: debug.printMessage(debug.LEVEL_INFO, msg, True) cthulhu_state.locusOfFocus = None cthulhu_state.activeWindow = None - _scriptManager.setActiveScript(None, "Active window is dead or defunct") + _scriptManager.set_active_script(None, "Active window is dead or defunct") return if AXUtilities.is_iconified(event.source): @@ -1116,7 +1070,7 @@ class EventManager: debug.printMessage(debug.LEVEL_INFO, f"{indent}ANY DATA:") debug.printDetails(debug.LEVEL_INFO, indent, event.any_data, includeApp=False) - script = self._getScriptForEvent(event) + script = self._get_scriptForEvent(event) if not script: msg = "ERROR: Could not get script for event" debug.printMessage(debug.LEVEL_INFO, msg, True) @@ -1128,7 +1082,7 @@ class EventManager: if setNewActiveScript: try: - _scriptManager.setActiveScript(script, reason) + _scriptManager.set_active_script(script, reason) except Exception as error: tokens = ["EVENT MANAGER: Exception setting active script for", event.source, ":", error] @@ -1152,63 +1106,6 @@ class EventManager: msg = f"EVENT MANAGER: {key}: {value}" debug.printMessage(debug.LEVEL_INFO, msg, True) - def _processNewKeyboardEvent(self, device, pressed, keycode, keysym, state, text): - """Process keyboard event using new direct KeyboardEvent creation.""" - - if not pressed and text == "Num_Lock" and "KP_Insert" in settings.cthulhuModifierKeys \ - and cthulhu_state.activeScript is not None: - cthulhu_state.activeScript.refreshKeyGrabs() - - if pressed: - cthulhu_state.openingDialog = (text == "space" \ - and (state & ~(1 << Atspi.ModifierType.NUMLOCK))) - - # Create KeyboardEvent directly with new constructor - keyboardEvent = input_event.KeyboardEvent(pressed, keycode, keysym, state, text or "") - - # Set context information - keyboardEvent.setWindow(cthulhu_state.activeWindow) - keyboardEvent.setObject(cthulhu_state.locusOfFocus) - keyboardEvent.setScript(cthulhu_state.activeScript) - - # Finalize initialization now that context is set - keyboardEvent._finalize_initialization() - - if not keyboardEvent.is_duplicate: - debug.printMessage(debug.LEVEL_INFO, f"\n{keyboardEvent}") - - rv = keyboardEvent.process() - - # Do any needed xmodmap crap. Hopefully this can die soon. - from cthulhu import cthulhu - cthulhu.updateKeyMap(keyboardEvent) - - return rv - - def _processKeyboardEvent(self, event): - # Convert AT-SPI event to new KeyboardEvent format - pressed = event.type == Atspi.EventType.KEY_PRESSED_EVENT - keyboardEvent = input_event.KeyboardEvent(pressed, event.hw_code, event.id, event.modifiers, event.event_string or "") - - # Set context information - keyboardEvent.setWindow(cthulhu_state.activeWindow) - keyboardEvent.setObject(cthulhu_state.locusOfFocus) - keyboardEvent.setScript(cthulhu_state.activeScript) - - # Finalize initialization - keyboardEvent._finalize_initialization() - - if not keyboardEvent.is_duplicate: - debug.printMessage(debug.LEVEL_INFO, f"\n{keyboardEvent}") - - rv = keyboardEvent.process() - - # Do any needed xmodmap crap. Hopefully this can die soon. - from cthulhu import cthulhu - cthulhu.updateKeyMap(keyboardEvent) - - return rv - def processBrailleEvent(self, brailleEvent): """Called whenever a cursor key is pressed on the Braille display. diff --git a/src/cthulhu/flat_review.py b/src/cthulhu/flat_review.py index 7bab06d..2540787 100644 --- a/src/cthulhu/flat_review.py +++ b/src/cthulhu/flat_review.py @@ -43,7 +43,9 @@ from . import cthulhu from . import cthulhu_state from . import settings from .ax_event_synthesizer import AXEventSynthesizer +from .ax_component import AXComponent from .ax_object import AXObject +from .ax_text import AXText from .ax_utilities import AXUtilities EMBEDDED_OBJECT_CHARACTER = '\ufffc' @@ -113,17 +115,14 @@ class Word: # The main goal is to improve reviewability. extents = self.x, self.y, self.width, self.height - try: - text = self.zone.accessible.queryText() - except Exception: - text = None - chars = [] for i, char in enumerate(self.string): start = i + self.startOffset - if text: + if AXObject.supports_text(self.zone.accessible): try: - extents = text.getRangeExtents(start, start+1, Atspi.CoordType.SCREEN) + rect = Atspi.Text.get_range_extents( + self.zone.accessible, start, start + 1, Atspi.CoordType.SCREEN) + extents = rect.x, rect.y, rect.width, rect.height except Exception as error: tokens = ["FLAT REVIEW: Exception in getRangeExtents:", error] debug.printTokens(debug.LEVEL_INFO, tokens, True) @@ -281,7 +280,7 @@ class TextZone(Zone): self.startOffset = startOffset self.endOffset = self.startOffset + len(string) - self._itext = self.accessible.queryText() + self._itext = None def __getattribute__(self, attr): """To ensure we update the content.""" @@ -289,11 +288,15 @@ class TextZone(Zone): if attr not in ["words", "string"]: return super().__getattribute__(attr) - string = self._itext.getText(self.startOffset, self.endOffset) + string = AXText.get_substring(self.accessible, self.startOffset, self.endOffset) words = [] for i, word in enumerate(re.finditer(self.WORDS_RE, string)): start, end = map(lambda x: x + self.startOffset, word.span()) - extents = self._itext.getRangeExtents(start, end, Atspi.CoordType.SCREEN) + try: + rect = Atspi.Text.get_range_extents(self.accessible, start, end, Atspi.CoordType.SCREEN) + extents = rect.x, rect.y, rect.width, rect.height + except Exception: + extents = (self.x, self.y, self.width, self.height) words.append(Word(self, i, start, word.group(), *extents)) self._string = string @@ -303,11 +306,11 @@ class TextZone(Zone): def hasCaret(self): """Returns True if this Zone contains the caret.""" - offset = self._itext.caretOffset + offset = AXText.get_caret_offset(self.accessible) if self.startOffset <= offset < self.endOffset: return True - return self.endOffset == self._itext.characterCount + return self.endOffset == AXText.get_character_count(self.accessible) def wordWithCaret(self): """Returns the Word and relative offset with the caret.""" @@ -315,7 +318,7 @@ class TextZone(Zone): if not self.hasCaret(): return None, -1 - return self.getWordAtOffset(self._itext.caretOffset) + return self.getWordAtOffset(AXText.get_caret_offset(self.accessible)) class StateZone(Zone): @@ -510,8 +513,9 @@ class Context: debug.printTokens(debug.LEVEL_INFO, tokens, True) try: - component = self.topLevel.queryComponent() - self.bounds = component.getExtents(Atspi.CoordType.SCREEN) + if AXObject.supports_component(self.topLevel): + rect = Atspi.Component.get_extents(self.topLevel, Atspi.CoordType.SCREEN) + self.bounds = rect.x, rect.y, rect.width, rect.height except Exception: tokens = ["ERROR: Exception getting extents of", self.topLevel] debug.printTokens(debug.LEVEL_INFO, tokens, True) @@ -567,7 +571,11 @@ class Context: substrings = [(*m.span(), m.group(0)) for m in re.finditer(r"[^\ufffc]+", string)] substrings = list(map(lambda x: (x[0] + startOffset, x[1] + startOffset, x[2]), substrings)) for (start, end, substring) in substrings: - extents = accessible.queryText().getRangeExtents(start, end, Atspi.CoordType.SCREEN) + try: + rect = Atspi.Text.get_range_extents(accessible, start, end, Atspi.CoordType.SCREEN) + extents = rect.x, rect.y, rect.width, rect.height + except Exception: + extents = (0, 0, 0, 0) if self.script.utilities.containsRegion(extents, cliprect): clipping = self.script.utilities.intersection(extents, cliprect) zones.append(TextZone(accessible, start, substring, *clipping)) @@ -577,18 +585,17 @@ class Context: def _getLines(self, accessible, startOffset, endOffset): # TODO - JD: Move this into the script utilities so we can better handle # app and toolkit quirks and also reuse this (e.g. for SayAll). - try: - text = accessible.queryText() - except NotImplementedError: + if not AXObject.supports_text(accessible): return [] lines = [] offset = startOffset - while offset < min(endOffset, text.characterCount): - result = text.getTextAtOffset(offset, Atspi.TextBoundaryType.LINE_START) - if result[0] and result not in lines: - lines.append(result) - offset = max(result[2], offset + 1) + maxOffset = min(endOffset, AXText.get_character_count(accessible)) + while offset < maxOffset: + line, start, end = AXText.get_line_at_offset(accessible, offset) + if line and (line, start, end) not in lines: + lines.append((line, start, end)) + offset = max(end, offset + 1) return lines @@ -607,16 +614,16 @@ class Context: return [] zones = [] - text = accessible.queryText() # TODO - JD: This is here temporarily whilst I sort out the rest # of the text-related mess. if AXObject.supports_editable_text(accessible) \ and AXUtilities.is_single_line(accessible): - extents = accessible.queryComponent().getExtents(0) - return [TextZone(accessible, 0, text.getText(0, -1), *extents)] + rect = AXComponent.get_rect(accessible) + return [TextZone(accessible, 0, AXText.get_substring(accessible, 0, -1), + rect.x, rect.y, rect.width, rect.height)] - upperMax = lowerMax = text.characterCount + upperMax = lowerMax = AXText.get_character_count(accessible) upperMid = lowerMid = int(upperMax / 2) upperMin = lowerMin = 0 oldMid = 0 @@ -624,9 +631,9 @@ class Context: # performing binary search to locate first line inside clipped area while oldMid != upperMid: oldMid = upperMid - [x, y, width, height] = text.getRangeExtents(upperMid, - upperMid+1, - 0) + rect = Atspi.Text.get_range_extents(accessible, upperMid, upperMid + 1, + Atspi.CoordType.SCREEN) + x, y, width, height = rect.x, rect.y, rect.width, rect.height if y > cliprect.y: upperMax = upperMid else: @@ -638,9 +645,9 @@ class Context: limit = cliprect.y+cliprect.height while oldMid != lowerMid: oldMid = lowerMid - [x, y, width, height] = text.getRangeExtents(lowerMid, - lowerMid+1, - 0) + rect = Atspi.Text.get_range_extents(accessible, lowerMid, lowerMid + 1, + Atspi.CoordType.SCREEN) + x, y, width, height = rect.x, rect.y, rect.width, rect.height if y > limit: lowerMax = lowerMid else: @@ -710,8 +717,11 @@ class Context: """Returns a list of Zones for the given accessible.""" try: - component = accessible.queryComponent() - extents = component.getExtents(Atspi.CoordType.SCREEN) + if AXObject.supports_component(accessible): + rect = Atspi.Component.get_extents(accessible, Atspi.CoordType.SCREEN) + extents = rect.x, rect.y, rect.width, rect.height + else: + return [] except Exception: return [] diff --git a/src/cthulhu/focus_manager.py b/src/cthulhu/focus_manager.py new file mode 100644 index 0000000..ce02725 --- /dev/null +++ b/src/cthulhu/focus_manager.py @@ -0,0 +1,465 @@ +# Orca +# +# Copyright 2005-2008 Sun Microsystems Inc. +# Copyright 2016-2023 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=too-many-instance-attributes +# pylint: disable=too-many-public-methods + +"""Module to manage the focused object, window, etc.""" + +# This has to be the first non-docstring line in the module to make linters happy. +from __future__ import annotations + +__id__ = "$Id$" +__version__ = "$Revision$" +__date__ = "$Date$" +__copyright__ = "Copyright (c) 2005-2008 Sun Microsystems Inc." \ + "Copyright (c) 2016-2023 Igalia, S.L." +__license__ = "LGPL" + +from typing import TYPE_CHECKING + +import gi +gi.require_version("Atspi", "2.0") +from gi.repository import Atspi + +from . import braille +from . import cthulhu_state +from . import dbus_service +from . import debug +from . import script_manager +from .ax_object import AXObject +from .ax_table import AXTable +from .ax_text import AXText +def _get_ax_utilities(): + # Avoid circular import with ax_utilities -> ax_utilities_event -> focus_manager. + from .ax_utilities import AXUtilities + return AXUtilities + +if TYPE_CHECKING: + from .input_event import InputEvent + from .scripts import default + +CARET_TRACKING = "caret-tracking" +FOCUS_TRACKING = "focus-tracking" +FLAT_REVIEW = "flat-review" +MOUSE_REVIEW = "mouse-review" +OBJECT_NAVIGATOR = "object-navigator" +SAY_ALL = "say-all" + + +class FocusManager: + """Manages the focused object, window, etc.""" + + def __init__(self) -> None: + self._window: Atspi.Accessible | None = cthulhu_state.activeWindow + self._focus: Atspi.Accessible | None = cthulhu_state.locusOfFocus + self._object_of_interest: Atspi.Accessible | None = cthulhu_state.objOfInterest + self._active_mode: str | None = cthulhu_state.activeMode + self._last_cell_coordinates: tuple[int, int] = (-1, -1) + self._last_cursor_position: tuple[Atspi.Accessible | None, int] = (None, -1) + self._penultimate_cursor_position: tuple[Atspi.Accessible | None, int] = (None, -1) + + msg = "FOCUS MANAGER: Registering D-Bus commands." + debug.print_message(debug.LEVEL_INFO, msg, True) + controller = dbus_service.get_remote_controller() + controller.register_decorated_module("FocusManager", self) + + def clear_state(self, reason: str = "") -> None: + """Clears everything we're tracking.""" + + msg = "FOCUS MANAGER: Clearing all state" + if reason: + msg += f": {reason}" + debug.print_message(debug.LEVEL_INFO, msg, True) + self._focus = None + self._window = None + self._object_of_interest = None + self._active_mode = None + cthulhu_state.locusOfFocus = None + cthulhu_state.activeWindow = None + cthulhu_state.objOfInterest = None + cthulhu_state.activeMode = None + + def find_focused_object(self) -> Atspi.Accessible | None: + """Returns the focused object in the active window.""" + + result = _get_ax_utilities().get_focused_object(self._window) + tokens = ["FOCUS MANAGER: Focused object in", self._window, "is", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + def focus_and_window_are_unknown(self) -> bool: + """Returns True if we have no knowledge about what is focused.""" + + result = self._focus is None and self._window is None + if result: + msg = "FOCUS MANAGER: Focus and window are unknown" + debug.print_message(debug.LEVEL_INFO, msg, True) + + return result + + def focus_is_dead(self) -> bool: + """Returns True if the locus of focus is dead.""" + + if not AXObject.is_dead(self._focus): + return False + + msg = "FOCUS MANAGER: Focus is dead" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + def focus_is_active_window(self) -> bool: + """Returns True if the locus of focus is the active window.""" + + if self._focus is None: + return False + + return self._focus == self._window + + def focus_is_in_active_window(self) -> bool: + """Returns True if the locus of focus is inside the current window.""" + + return self._focus is not None and AXObject.is_ancestor(self._focus, self._window) + + def emit_region_changed( + self, obj: Atspi.Accessible, + start_offset: int | None = None, + end_offset: int | None = None, + mode: str | None = None + ) -> None: + """Notifies interested clients that the current region of interest has changed.""" + + if start_offset is None: + start_offset = 0 + if end_offset is None: + end_offset = start_offset + if mode is None: + mode = FOCUS_TRACKING + + if obj is not None: + obj.emit("mode-changed::" + mode, 1, "") + + if mode != self._active_mode: + tokens = ["FOCUS MANAGER: Switching mode from", self._active_mode, "to", mode] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._active_mode = mode + cthulhu_state.activeMode = mode + if mode == FLAT_REVIEW: + braille.setBrlapiPriority(braille.BRLAPI_PRIORITY_HIGH) + else: + braille.setBrlapiPriority() + + tokens = ["FOCUS MANAGER: Region of interest:", obj, f"({start_offset}, {end_offset})"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if obj is not None: + obj.emit("region-changed", start_offset, end_offset) + + if obj != self._object_of_interest: + tokens = ["FOCUS MANAGER: Switching object of interest from", + self._object_of_interest, "to", obj] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._object_of_interest = obj + cthulhu_state.objOfInterest = obj + + def in_say_all(self) -> bool: + """Returns True if we are in say-all mode.""" + + return self._active_mode == SAY_ALL + + def get_active_mode_and_object_of_interest( + self + ) -> tuple[str | None, Atspi.Accessible | None]: + """Returns the current mode and associated object of interest""" + + tokens = ["FOCUS MANAGER: Active mode:", self._active_mode, + "Object of interest:", self._object_of_interest] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return self._active_mode, self._object_of_interest + + def get_penultimate_cursor_position(self) -> tuple[Atspi.Accessible | None, int]: + """Returns the penultimate cursor position as a tuple of (object, offset).""" + + obj, offset = self._penultimate_cursor_position + tokens = ["FOCUS MANAGER: Penultimate cursor position:", obj, offset] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return obj, offset + + def get_last_cursor_position(self) -> tuple[Atspi.Accessible | None, int]: + """Returns the last cursor position as a tuple of (object, offset).""" + + obj, offset = self._last_cursor_position + tokens = ["FOCUS MANAGER: Last cursor position:", obj, offset] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return obj, offset + + def set_last_cursor_position(self, obj: Atspi.Accessible | None, offset: int) -> None: + """Sets the last cursor position as a tuple of (object, offset).""" + + tokens = ["FOCUS MANAGER: Setting last cursor position to", obj, offset] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._penultimate_cursor_position = self._last_cursor_position + self._last_cursor_position = obj, offset + + def get_last_cell_coordinates(self) -> tuple[int, int]: + """Returns the last known cell coordinates as a tuple of (row, column).""" + + row, column = self._last_cell_coordinates + msg = f"FOCUS MANAGER: Last known cell coordinates: row={row}, column={column}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return row, column + + def set_last_cell_coordinates(self, row: int, column: int) -> None: + """Sets the last known cell coordinates as a tuple of (row, column).""" + + msg = f"FOCUS MANAGER: Setting last cell coordinates to row={row}, column={column}" + debug.print_message(debug.LEVEL_INFO, msg, True) + self._last_cell_coordinates = row, column + + def get_locus_of_focus(self) -> Atspi.Accessible | None: + """Returns the current locus of focus (i.e. the object with visual focus).""" + + tokens = ["FOCUS MANAGER: Locus of focus is", self._focus] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return self._focus + + def set_locus_of_focus( + self, + event: Atspi.Event | None, + obj: Atspi.Accessible | None, + notify_script: bool = True, + force: bool = False + ) -> None: + """Sets the locus of focus (i.e., the object with visual focus).""" + + tokens = ["FOCUS MANAGER: Request to set locus of focus to", obj] + debug.print_tokens(debug.LEVEL_INFO, tokens, True, True) + + # We clear the cache on the locus of focus because too many apps and toolkits fail + # to emit the correct accessibility events. We do so recursively on table cells + # to handle bugs like https://gitlab.gnome.org/GNOME/nautilus/-/issues/3253. + recursive = _get_ax_utilities().is_table_cell(obj) + AXObject.clear_cache(obj, recursive, "Setting locus of focus.") + if not force and obj == self._focus: + msg = "FOCUS MANAGER: Setting locus of focus to existing locus of focus" + debug.print_message(debug.LEVEL_INFO, msg, True) + return + + # We 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 = AXTable.get_cell_coordinates(obj, find_cell=True) + self.set_last_cell_coordinates(row, column) + + # We 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. + # TODO - JD: We should consider making this part of `save_object_info_for_events()` for the + # motivation described above. However, we need to audit callers that set/get the position + # before doing so. + self.set_last_cursor_position(obj, AXText.get_caret_offset(obj)) + AXText.update_cached_selected_text(obj) + + # We save additional information about the object for events that were received at the same + # time as the prioritized focus-change event so we don't double-present aspects about obj. + _get_ax_utilities().save_object_info_for_events(obj) + + # TODO - JD: Consider always updating the active script here. + script = script_manager.get_manager().get_active_script() + if event and (script and not script.app): + app = _get_ax_utilities().get_application(event.source) + script = script_manager.get_manager().get_script(app, event.source) + script_manager.get_manager().set_active_script(script, "Setting locus of focus") + + old_focus = self._focus + if AXObject.is_dead(old_focus): + old_focus = None + + if obj is None: + msg = "FOCUS MANAGER: New locus of focus is null (being cleared)" + debug.print_message(debug.LEVEL_INFO, msg, True) + self._focus = None + cthulhu_state.locusOfFocus = None + return + + if AXObject.is_dead(obj): + tokens = ["FOCUS MANAGER: New locus of focus (", obj, ") is dead. Not updating."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return + + if script is not None: + if not AXObject.is_valid(obj): + tokens = ["FOCUS MANAGER: New locus of focus (", obj, ") is invalid. Not updating."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return + + tokens = ["FOCUS MANAGER: Changing locus of focus from", old_focus, + "to", obj, ". Notify:", notify_script] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._focus = obj + cthulhu_state.locusOfFocus = obj + + if not notify_script: + return + + if script is None: + msg = "FOCUS MANAGER: Cannot notify active script because there isn't one" + debug.print_message(debug.LEVEL_INFO, msg, True) + return + + self.emit_region_changed(obj, mode=FOCUS_TRACKING) + script.locus_of_focus_changed(event, old_focus, self._focus) + + def active_window_is_active(self) -> bool: + """Returns True if the window we think is currently active is actually active.""" + + AXObject.clear_cache(self._window, False, "Ensuring the active window is really active.") + is_active = _get_ax_utilities().is_active(self._window) + tokens = ["FOCUS MANAGER:", self._window, "is active:", is_active] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return is_active + + def get_active_window(self) -> Atspi.Accessible | None: + """Returns the currently-active window (i.e. without searching or verifying).""" + + tokens = ["FOCUS MANAGER: Active window is", self._window] + debug.print_tokens(debug.LEVEL_INFO, tokens, True, True) + return self._window + + def set_active_window( + self, + frame: Atspi.Accessible | None, + app: Atspi.Accessible | None = None, + set_window_as_focus: bool = False, + notify_script: bool = False + ) -> None: + """Sets the active window.""" + + tokens = ["FOCUS MANAGER: Request to set active window to", frame] + if app is not None: + tokens.extend(["in", app]) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if frame == self._window: + msg = "FOCUS MANAGER: Setting active window to existing active window" + debug.print_message(debug.LEVEL_INFO, msg, True) + elif frame is None: + self._window = None + cthulhu_state.activeWindow = None + else: + self._window = frame + cthulhu_state.activeWindow = frame + + if set_window_as_focus: + self.set_locus_of_focus(None, self._window, notify_script) + elif not (self.focus_is_active_window() or self.focus_is_in_active_window()): + tokens = ["FOCUS MANAGER: Focus", self._focus, "is not in", self._window] + debug.print_tokens(debug.LEVEL_INFO, tokens, True, True) + + # Don't update the focus to the active window if we can't get to the active window + # from the focused object. https://bugreports.qt.io/browse/QTBUG-130116 + if not AXObject.has_broken_ancestry(self._focus): + self.set_locus_of_focus(None, self._window, notify_script=True) + + app = _get_ax_utilities().get_application(self._focus) + script = script_manager.get_manager().get_script(app, self._focus) + script_manager.get_manager().set_active_script(script, "Setting active window") + + @dbus_service.command + def toggle_presentation_mode( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True + ) -> bool: + """Switches between browse mode and focus mode (web content only).""" + + return script.toggle_presentation_mode(event, document=None, notify_user=notify_user) + + @dbus_service.command + def toggle_layout_mode( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True + ) -> bool: + """Switches between object mode and layout mode for line presentation (web content only).""" + + return script.toggle_layout_mode(event, notify_user=notify_user) + + @dbus_service.command + def enable_sticky_browse_mode( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True + ) -> bool: + """Enables sticky browse mode (web content only).""" + + return script.enable_sticky_browse_mode(event, force_message=notify_user) + + @dbus_service.command + def enable_sticky_focus_mode( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True + ) -> bool: + """Enables sticky focus mode (web content only).""" + + return script.enable_sticky_focus_mode(event, force_message=notify_user) + + @dbus_service.getter + def get_in_layout_mode(self) -> bool: + """Returns True if layout mode (as opposed to object mode) is active (web content only).""" + + if script := script_manager.get_manager().get_active_script(): + return script.in_layout_mode() + return False + + @dbus_service.getter + def get_in_focus_mode(self) -> bool: + """Returns True if focus mode is active (web content only).""" + + if script := script_manager.get_manager().get_active_script(): + return script.in_focus_mode() + return False + + @dbus_service.getter + def get_focus_mode_is_sticky(self) -> bool: + """Returns True if focus mode is active and 'sticky' (web content only).""" + + if script := script_manager.get_manager().get_active_script(): + return script.focus_mode_is_sticky() + return False + + @dbus_service.getter + def get_browse_mode_is_sticky(self) -> bool: + """Returns True if browse mode is active and 'sticky' (web content only).""" + + if script := script_manager.get_manager().get_active_script(): + return script.browse_mode_is_sticky() + return False + +_manager: FocusManager = FocusManager() + +def get_manager() -> FocusManager: + """Returns the focus manager singleton.""" + return _manager diff --git a/src/cthulhu/generator.py b/src/cthulhu/generator.py index 7cf4cfa..cbf16ba 100644 --- a/src/cthulhu/generator.py +++ b/src/cthulhu/generator.py @@ -52,6 +52,7 @@ from . import settings from . import settings_manager from .ax_object import AXObject from .ax_utilities import AXUtilities +from .ax_utilities_relation import AXUtilitiesRelation # Python 3.10 compatibility: try: @@ -1166,9 +1167,9 @@ class Generator: return [] radioGroupLabel = None - relation = AXObject.get_relation(obj, Atspi.RelationType.LABELLED_BY) - if relation: - radioGroupLabel = relation.get_target(0) + labelledBy = AXUtilitiesRelation.get_is_labelled_by(obj) + if labelledBy: + radioGroupLabel = labelledBy[0] if radioGroupLabel: return [self._script.utilities.displayedText(radioGroupLabel)] diff --git a/src/cthulhu/input_event.py b/src/cthulhu/input_event.py index a5d1add..276a429 100644 --- a/src/cthulhu/input_event.py +++ b/src/cthulhu/input_event.py @@ -67,15 +67,18 @@ class InputEvent: self.time = time.time() self._clickCount = 0 - def getClickCount(self): + def get_click_count(self): """Return the count of the number of clicks a user has made.""" return self._clickCount - def setClickCount(self): + def set_click_count(self, count=None): """Updates the count of the number of clicks a user has made.""" - pass + if count is None: + return + + self._clickCount = count def _getXkbStickyKeysState(): from subprocess import check_output @@ -292,8 +295,8 @@ class KeyboardEvent(InputEvent): self.keyType = None self.shouldEcho = False - # Initialize key type - will be refined later in _finalize_initialization - self._finalize_initialization() + # InputEventManager will call _finalize_initialization after setting + # script/object/window to ensure shouldConsume uses correct context. def _finalize_initialization(self): """Finalize initialization after object creation. @@ -331,7 +334,7 @@ class KeyboardEvent(InputEvent): elif self.isActionKey(): self.keyType = KeyboardEvent.TYPE_ACTION self.shouldEcho = _mayEcho and settings.enableActionKeys - elif self.isModifierKey(): + elif self.is_modifier_key(): self.keyType = KeyboardEvent.TYPE_MODIFIER self.shouldEcho = _mayEcho and settings.enableModifierKeys if self.isCthulhuModifier() and not self.is_duplicate: @@ -393,8 +396,8 @@ class KeyboardEvent(InputEvent): if not self.isLockingKey(): self.shouldEcho = self.shouldEcho and settings.enableKeyEcho - if not self.isModifierKey(): - self.setClickCount() + if not self.is_modifier_key(): + self.set_click_count() if cthulhu_state.bypassNextCommand and _isPressed: KeyboardEvent.cthulhuModifierPressed = False @@ -405,7 +408,7 @@ class KeyboardEvent(InputEvent): if KeyboardEvent.stickyKeys: # apply all recorded sticky modifiers self.modifiers |= KeyboardEvent.cthulhuStickyModifiers - if self.isModifierKey(): + if self.is_modifier_key(): # add this modifier to the sticky ones KeyboardEvent.cthulhuStickyModifiers |= self.modifiers else: @@ -425,7 +428,7 @@ class KeyboardEvent(InputEvent): return lastEvent return None - def setClickCount(self, count=None): + def set_click_count(self, count=None): """Updates the count of the number of clicks a user has made. If count is provided, sets the click count to that value. @@ -440,7 +443,7 @@ class KeyboardEvent(InputEvent): self._clickCount = 1 return - self._clickCount = doubleEvent.getClickCount() + self._clickCount = doubleEvent.get_click_count() if self.is_duplicate: return @@ -489,7 +492,7 @@ class KeyboardEvent(InputEvent): if not AXUtilities.is_password_text(self._obj): return False - if not self.isPrintableKey(): + if not self.is_printable_key(): return False if self.modifiers & keybindings.CTRL_MODIFIER_MASK \ @@ -504,7 +507,7 @@ class KeyboardEvent(InputEvent): if not last: return False - if not last.isPressedKey() or self.isPressedKey(): + if not last.is_pressed_key() or self.is_pressed_key(): return False if self.id == last.id and self.hw_code == last.hw_code: @@ -518,7 +521,7 @@ class KeyboardEvent(InputEvent): if not other: return False - if not other.isPressedKey() or self.isPressedKey(): + if not other.is_pressed_key() or self.is_pressed_key(): return False return self.id == other.id \ @@ -592,7 +595,7 @@ class KeyboardEvent(InputEvent): return True - def isModifierKey(self): + def is_modifier_key(self): """Return True if this is a modifier key.""" if self.keyType: @@ -646,7 +649,7 @@ class KeyboardEvent(InputEvent): return self._is_kp_with_numlock - def isPrintableKey(self): + def is_printable_key(self): """Return True if this is a printable key.""" if self.event_string in ["space", " "]: @@ -657,7 +660,7 @@ class KeyboardEvent(InputEvent): return self.event_string.isprintable() - def isPressedKey(self): + def is_pressed_key(self): """Returns True if the key is pressed""" return self.type == Atspi.EventType.KEY_PRESSED_EVENT @@ -694,7 +697,7 @@ class KeyboardEvent(InputEvent): character echo. We do this to not double-echo a given printable character.""" - if not self.isPrintableKey(): + if not self.is_printable_key(): return False script = cthulhu_state.activeScript @@ -737,47 +740,47 @@ class KeyboardEvent(InputEvent): return keynames.getKeyName(self.event_string) - def getObject(self): + def get_object(self): """Returns the object believed to be associated with this key event.""" return self._obj - def setObject(self, obj): + def set_object(self, obj): """Sets the object believed to be associated with this key event.""" self._obj = obj - def getWindow(self): + def get_window(self): """Returns the window associated with this key event.""" return self._window - def setWindow(self, window): + def set_window(self, window): """Sets the window associated with this key event.""" self._window = window - def getScript(self): + def get_script(self): """Returns the script associated with this key event.""" return self._script - def setScript(self, script): + def set_script(self, script): """Sets the script associated with this key event.""" self._script = script if script: self._app = script.app - def getClickCount(self): + def get_click_count(self): """Returns the click count for this event.""" return self._clickCount - def asSingleLineString(self): + def as_single_line_string(self): """Returns a single-line string representation of this event.""" - return f"KeyboardEvent({self.keyval_name}, pressed={self.isPressedKey()}, modifiers={self.modifiers})" + return f"KeyboardEvent({self.keyval_name}, pressed={self.is_pressed_key()}, modifiers={self.modifiers})" def getHandler(self): """Returns the handler associated with this key event.""" @@ -806,12 +809,19 @@ class KeyboardEvent(InputEvent): def shouldConsume(self): """Returns True if this event should be consumed.""" + # Debug logging to understand handler matching + debugMsg = f"shouldConsume: key='{self.event_string}' hw_code={self.hw_code} modifiers={self.modifiers}" + debug.printMessage(debug.LEVEL_INFO, debugMsg, True) + if not self.timestamp: return False, 'No timestamp' if not self._script: + debug.printMessage(debug.LEVEL_INFO, "shouldConsume: No active script", True) return False, 'No active script when received' + debug.printMessage(debug.LEVEL_INFO, f"shouldConsume: Active script={self._script.__class__.__name__}", True) + if self.is_duplicate: return False, 'Is duplicate' @@ -824,10 +834,16 @@ class KeyboardEvent(InputEvent): self._handler = self._getUserHandler() \ or self._script.keyBindings.getInputHandler(self) + if self._handler: + debug.printMessage(debug.LEVEL_INFO, f"shouldConsume: Handler found: {self._handler.description}", True) + else: + debug.printMessage(debug.LEVEL_INFO, "shouldConsume: No handler found", True) + # TODO - JD: Right now we need to always call consumesKeyboardEvent() # because that method is updating state, even in instances where there # is no handler. scriptConsumes = self._script.consumesKeyboardEvent(self) + debug.printMessage(debug.LEVEL_INFO, f"shouldConsume: scriptConsumes={scriptConsumes}", True) if self._isReleaseForLastNonModifierKeyEvent(): return scriptConsumes, 'Is release for last non-modifier keyevent' @@ -836,7 +852,7 @@ class KeyboardEvent(InputEvent): self._consumer = self._script.learnModePresenter.handle_event return True, 'In Learn Mode' - if self.isModifierKey(): + if self.is_modifier_key(): if not self.isCthulhuModifier(): return False, 'Non-Cthulhu modifier not in Learn Mode' return True, 'Cthulhu modifier' @@ -861,7 +877,7 @@ class KeyboardEvent(InputEvent): return method.__func__ == self._handler.function def _present(self, inputEvent=None): - if self.isPressedKey(): + if self.is_pressed_key(): self._script.presentationInterrupt() if self._script.learnModePresenter.is_active(): @@ -929,7 +945,7 @@ class KeyboardEvent(InputEvent): return False, 'Bypassed cthulhu modifier' cthulhu_state.lastInputEvent = self - if not self.isModifierKey(): + if not self.is_modifier_key(): cthulhu_state.lastNonModifierKeyEvent = self if not self._script: @@ -940,7 +956,7 @@ class KeyboardEvent(InputEvent): self._present() - if not self.isPressedKey(): + if not self.is_pressed_key(): return self._should_consume, 'Consumed based on handler' if cthulhu_state.capturingKeys: @@ -950,7 +966,7 @@ class KeyboardEvent(InputEvent): return True, 'Cthulhu modifier' if cthulhu_state.bypassNextCommand: - if not self.isModifierKey(): + if not self.is_modifier_key(): cthulhu_state.bypassNextCommand = False self._script.addKeyGrabs() return False, 'Bypass next command' @@ -1083,9 +1099,13 @@ class MouseButtonEvent(InputEvent): debug.printMessage(debug.LEVEL_INFO, msg, True) self.x, self.y = x, y - def setClickCount(self): + def set_click_count(self, count=None): """Updates the count of the number of clicks a user has made.""" + if count is not None: + self._clickCount = count + return + if not self.pressed: return diff --git a/src/cthulhu/input_event_manager.py b/src/cthulhu/input_event_manager.py index c79f1c8..96bc37b 100644 --- a/src/cthulhu/input_event_manager.py +++ b/src/cthulhu/input_event_manager.py @@ -1,8 +1,8 @@ -#!/usr/bin/env python3 +# Orca # -# Copyright (c) 2024 Stormux -# Copyright (c) 2024 Igalia, S.L. -# Copyright (c) 2024 GNOME Foundation 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,15 +19,19 @@ # Free Software Foundation, Inc., Franklin Street, Fifth Floor, # Boston MA 02110-1301 USA. +# pylint: disable=wrong-import-position +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-lines + """Provides utilities for managing input events.""" +# This has to be the first non-docstring line in the module to make linters happy. from __future__ import annotations __id__ = "$Id$" __version__ = "$Revision$" __date__ = "$Date$" -__copyright__ = "Copyright (c) 2024 Stormux" \ - "Copyright (c) 2024 Igalia, S.L." \ +__copyright__ = "Copyright (c) 2024 Igalia, S.L." \ "Copyright (c) 2024 GNOME Foundation Inc." __license__ = "LGPL" @@ -35,15 +39,13 @@ from typing import TYPE_CHECKING 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 . import debug +from . import focus_manager from . import input_event from . import script_manager from . import settings -from . import cthulhu_state from .ax_object import AXObject from .ax_utilities import AXUtilities @@ -66,47 +68,32 @@ class InputEventManager: """Starts the watcher for keyboard input events.""" msg = "INPUT EVENT MANAGER: Starting key watcher." - debug.printMessage(debug.LEVEL_INFO, msg, True) - try: - atspi_version = Atspi.get_version() - debug.printMessage(debug.LEVEL_INFO, f"INPUT EVENT MANAGER: AT-SPI version: {atspi_version}", True) - if atspi_version >= (2, 55, 90): - debug.printMessage(debug.LEVEL_INFO, "INPUT EVENT MANAGER: Using Device.new_full", True) - self._device = Atspi.Device.new_full("org.stormux.Cthulhu") - else: - debug.printMessage(debug.LEVEL_INFO, "INPUT EVENT MANAGER: Using Device.new", True) - self._device = Atspi.Device.new() - debug.printMessage(debug.LEVEL_INFO, f"INPUT EVENT MANAGER: Device created: {self._device}", True) - debug.printMessage(debug.LEVEL_INFO, f"INPUT EVENT MANAGER: About to add key watcher callback", True) - result = self._device.add_key_watcher(self.process_keyboard_event) - debug.printMessage(debug.LEVEL_INFO, f"INPUT EVENT MANAGER: add_key_watcher result: {result}", True) - debug.printMessage(debug.LEVEL_INFO, "INPUT EVENT MANAGER: Key watcher added successfully", True) - except Exception as e: - debug.printMessage(debug.LEVEL_INFO, f"INPUT EVENT MANAGER: Error in start_key_watcher: {e}", True) - raise + debug.print_message(debug.LEVEL_INFO, msg, True) + self._device = Atspi.Device.new_full("org.stormux.Cthulhu") + self._device.add_key_watcher(self.process_keyboard_event) def stop_key_watcher(self) -> None: - """Stops the watcher for keyboard input events.""" + """Starts the watcher for keyboard input events.""" msg = "INPUT EVENT MANAGER: Stopping key watcher." - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) self._device = None def pause_key_watcher(self, pause: bool = True, reason: str = "") -> None: """Pauses processing of keyboard input events.""" - msg = f"INPUT EVENT MANAGER: Pause processing: {pause}. {reason}" - debug.printMessage(debug.LEVEL_INFO, msg, True) + msg = f"INPUT EVENT MANAGER: Pause queueing: {pause}. {reason}" + debug.print_message(debug.LEVEL_INFO, msg, True) self._paused = pause def check_grabbed_bindings(self) -> None: """Checks the grabbed key bindings.""" msg = f"INPUT EVENT MANAGER: {len(self._grabbed_bindings)} grabbed key bindings." - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) for grab_id, binding in self._grabbed_bindings.items(): msg = f"INPUT EVENT MANAGER: {grab_id} for: {binding}" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) def add_grabs_for_keybinding(self, binding: keybindings.KeyBinding) -> list[int]: """Adds grabs for binding if it is enabled, returns grab IDs.""" @@ -116,17 +103,23 @@ class InputEventManager: if binding.has_grabs(): tokens = ["INPUT EVENT MANAGER:", binding, "already has grabs."] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return [] if self._device is None: tokens = ["INPUT EVENT MANAGER: No device to add grab for", binding] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return [] grab_ids = [] for kd in binding.key_definitions(): grab_id = self._device.add_key_grab(kd, None) + # When we have double/triple-click bindings, the single-click binding will be + # registered first, and subsequent attempts to register what is externally the + # same grab will fail. If we only have a double/triple-click, it succeeds. + # A grab id of 0 indicates failure. + if grab_id == 0: + continue grab_ids.append(grab_id) self._grabbed_bindings[grab_id] = binding @@ -137,13 +130,13 @@ class InputEventManager: if self._device is None: tokens = ["INPUT EVENT MANAGER: No device to remove grab from", binding] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return grab_ids = binding.get_grab_ids() if not grab_ids: tokens = ["INPUT EVENT MANAGER:", binding, "doesn't have grabs to remove."] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return for grab_id in grab_ids: @@ -151,25 +144,14 @@ class InputEventManager: removed = self._grabbed_bindings.pop(grab_id, None) if removed is None: msg = f"INPUT EVENT MANAGER: No key binding for grab id {grab_id}" - debug.printMessage(debug.LEVEL_INFO, msg, True) - - def map_keycode_to_modifier(self, keycode: int) -> int: - """Maps keycode as a modifier, returns the newly-mapped modifier.""" - - if self._device is None: - msg = f"INPUT EVENT MANAGER: No device to map keycode {keycode} to modifier" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return 0 - - self._mapped_keycodes.append(keycode) - return self._device.map_modifier(keycode) + debug.print_message(debug.LEVEL_INFO, msg, True) def map_keysym_to_modifier(self, keysym: int) -> int: """Maps keysym as a modifier, returns the newly-mapped modifier.""" if self._device is None: msg = f"INPUT EVENT MANAGER: No device to map keysym {keysym} to modifier" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return 0 self._mapped_keysyms.append(keysym) @@ -180,7 +162,7 @@ class InputEventManager: if self._device is None: msg = "INPUT EVENT MANAGER: No device to unmap all modifiers from" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return for keycode in self._mapped_keycodes: @@ -197,7 +179,7 @@ class InputEventManager: if self._device is None: msg = f"INPUT EVENT MANAGER: No device to add grab for {modifier}" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return -1 kd = Atspi.KeyDefinition() @@ -207,7 +189,7 @@ class InputEventManager: grab_id = self._device.add_key_grab(kd) msg = f"INPUT EVENT MANAGER: Grab id for {modifier}: {grab_id}" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return grab_id def remove_grab_for_modifier(self, modifier: str, grab_id: int) -> None: @@ -215,12 +197,12 @@ class InputEventManager: if self._device is None: msg = f"INPUT EVENT MANAGER: No device to remove grab from {modifier}" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return self._device.remove_key_grab(grab_id) msg = f"INPUT EVENT MANAGER: Grab id removed for {modifier}: {grab_id}" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) def grab_keyboard(self, reason: str = "") -> None: """Grabs the keyboard, e.g. when entering learn mode.""" @@ -228,7 +210,7 @@ class InputEventManager: msg = "INPUT EVENT MANAGER: Grabbing keyboard" if reason: msg += f" Reason: {reason}" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) Atspi.Device.grab_keyboard(self._device) def ungrab_keyboard(self, reason: str = "") -> None: @@ -237,7 +219,7 @@ class InputEventManager: msg = "INPUT EVENT MANAGER: Ungrabbing keyboard" if reason: msg += f" Reason: {reason}" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) Atspi.Device.ungrab_keyboard(self._device) def process_braille_event(self, event: Atspi.Event) -> bool: @@ -253,7 +235,7 @@ class InputEventManager: """Processes this Mouse event.""" mouse_event = input_event.MouseButtonEvent(event) - mouse_event.setClickCount(self._determine_mouse_event_click_count(mouse_event)) + mouse_event.set_click_count(self._determine_mouse_event_click_count(mouse_event)) self._last_input_event = mouse_event def process_remote_controller_event(self, event: input_event.RemoteControllerEvent) -> None: @@ -264,83 +246,73 @@ class InputEventManager: self._last_input_event = event self._last_non_modifier_key_event = None - def process_keyboard_event(self, _device, pressed, keycode, keysym, modifiers, text): + # pylint: disable=too-many-arguments + # pylint: disable=too-many-positional-arguments + def process_keyboard_event( + self, + _device: Atspi.Device, + pressed: bool, + keycode: int, + keysym: int, + modifiers: int, + text: str + ) -> bool: """Processes this Atspi keyboard event.""" - debug.printMessage(debug.LEVEL_INFO, f"INPUT EVENT MANAGER: Received keyboard event: pressed={pressed}, keycode={keycode}, keysym={keysym}, text='{text}'", True) - if self._paused: msg = "INPUT EVENT MANAGER: Keyboard event processing is paused." - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return False - # Handle Cthulhu-specific logic before creating event - if not pressed and text == "Num_Lock" and "KP_Insert" in settings.cthulhuModifierKeys \ - and cthulhu_state.activeScript is not None: - cthulhu_state.activeScript.refreshKeyGrabs() - - if pressed: - cthulhu_state.openingDialog = (text == "space" \ - and (modifiers & ~(1 << Atspi.ModifierType.NUMLOCK))) - - event = input_event.KeyboardEvent(pressed, keycode, keysym, modifiers, text or "") + event = input_event.KeyboardEvent(pressed, keycode, keysym, modifiers, text) if event in [self._last_input_event, self._last_non_modifier_key_event]: msg = "INPUT EVENT MANAGER: Received duplicate event." - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return False + manager = focus_manager.get_manager() if pressed: - # Get active window and focus - window = cthulhu_state.activeWindow - # Use cthulhu_state.activeScript instead of ScriptManager API - active_script = cthulhu_state.activeScript - if active_script and hasattr(active_script, 'utilities') and hasattr(active_script.utilities, 'canBeActiveWindow'): - if not active_script.utilities.canBeActiveWindow(window): - new_window = active_script.utilities.activeWindow() - if new_window is not None: - window = new_window - tokens = ["INPUT EVENT MANAGER: Updating window and active window to", window] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - cthulhu_state.activeWindow = window - else: - tokens = ["WARNING:", window, "cannot be active window. No alternative found."] - debug.printTokens(debug.LEVEL_WARNING, tokens, True) - - event.setWindow(window) - event.setObject(cthulhu_state.locusOfFocus) - event.setScript(active_script) + window = manager.get_active_window() + if not AXUtilities.can_be_active_window(window): + new_window = AXUtilities.find_active_window() + if new_window is not None: + window = new_window + tokens = ["INPUT EVENT MANAGER: Updating window and active window to", window] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + manager.set_active_window(window) + else: + # One example: Brave's popup menus live in frames which lack the active state. + tokens = ["WARNING:", window, "cannot be active window. No alternative found."] + debug.print_tokens(debug.LEVEL_WARNING, tokens, True) + event.set_window(window) + event.set_object(manager.get_locus_of_focus()) + event.set_script(script_manager.get_manager().get_active_script()) elif self.last_event_was_keyboard(): assert isinstance(self._last_input_event, input_event.KeyboardEvent) - event.setWindow(self._last_input_event.getWindow()) - event.setObject(self._last_input_event.getObject()) - event.setScript(self._last_input_event.getScript()) + event.set_window(self._last_input_event.get_window()) + event.set_object(self._last_input_event.get_object()) + event.set_script(self._last_input_event.get_script()) else: - event.setWindow(cthulhu_state.activeWindow) - event.setObject(cthulhu_state.locusOfFocus) - event.setScript(cthulhu_state.activeScript) + event.set_window(manager.get_active_window()) + event.set_object(manager.get_locus_of_focus()) + event.set_script(script_manager.get_manager().get_active_script()) - # Finalize initialization now that context is set event._finalize_initialization() - - if not event.is_duplicate: - debug.printMessage(debug.LEVEL_INFO, f"\n{event}") + event.set_click_count(self._determine_keyboard_event_click_count(event)) + event.process() - event.setClickCount(self._determine_keyboard_event_click_count(event)) - rv = event.process() - - # Do any needed xmodmap handling - from . import cthulhu - cthulhu.updateKeyMap(event) - - if event.isModifierKey(): + if event.is_modifier_key(): if self.is_release_for(event, self._last_input_event): msg = "INPUT EVENT MANAGER: Clearing last non modifier key event" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) self._last_non_modifier_key_event = None else: self._last_non_modifier_key_event = event self._last_input_event = event - return rv + return True + + # pylint: enable=too-many-arguments + # pylint: enable=too-many-positional-arguments def _determine_keyboard_event_click_count(self, event: input_event.KeyboardEvent) -> int: """Determines the click count of event.""" @@ -348,7 +320,7 @@ class InputEventManager: if not self.last_event_was_keyboard(): return 1 - if event.isModifierKey(): + if event.is_modifier_key(): last_event = self._last_input_event else: last_event = self._last_non_modifier_key_event or self._last_input_event @@ -356,15 +328,15 @@ class InputEventManager: assert isinstance(last_event, input_event.KeyboardEvent) if (event.time - last_event.time > settings.doubleClickTimeout) or \ (event.keyval_name != last_event.keyval_name) or \ - (event.getObject() != last_event.getObject()): + (event.get_object() != last_event.get_object()): return 1 - last_count = last_event.getClickCount() - if not event.isPressedKey(): + last_count = last_event.get_click_count() + if not event.is_pressed_key(): return last_count - if last_event.isPressedKey(): + if last_event.is_pressed_key(): return last_count - if (event.isModifierKey() and last_count == 2) or last_count == 3: + if (event.is_modifier_key() and last_count == 2) or last_count == 3: return 1 return last_count + 1 @@ -376,13 +348,13 @@ class InputEventManager: assert isinstance(self._last_input_event, input_event.MouseButtonEvent) if not event.pressed: - return self._last_input_event.getClickCount() + return self._last_input_event.get_click_count() if self._last_input_event.button != event.button: return 1 if event.time - self._last_input_event.time > settings.doubleClickTimeout: return 1 - return self._last_input_event.getClickCount() + 1 + return self._last_input_event.get_click_count() + 1 def last_event_was_keyboard(self) -> bool: """Returns True if the last event is a keyboard event.""" @@ -404,25 +376,31 @@ class InputEventManager: or not isinstance(event2, input_event.KeyboardEvent): return False - if event1.isPressedKey() or not event2.isPressedKey(): + if event1.is_pressed_key() or not event2.is_pressed_key(): return False result = event1.id == event2.id \ and event1.hw_code == event2.hw_code \ and event1.keyval_name == event2.keyval_name - if result and not event1.isModifierKey(): + if result and not event1.is_modifier_key(): result = event1.modifiers == event2.modifiers msg = ( - f"INPUT EVENT MANAGER: {event1.asSingleLineString()} " - f"is release for {event2.asSingleLineString()}: {result}" + f"INPUT EVENT MANAGER: {event1.as_single_line_string()} " + f"is release for {event2.as_single_line_string()}: {result}" ) - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return result def last_event_equals_or_is_release_for_event(self, event): - """Returns True if the last non-modifier event equals, or is the release for, event.""" + """Returns True if the last event equals the provided event, or is the release for it.""" + + if self._last_input_event is event: + return True + + if not self.last_event_was_keyboard(): + return False if self._last_non_modifier_key_event is None: return False @@ -432,8 +410,600 @@ class InputEventManager: return self.is_release_for(self._last_non_modifier_key_event, event) + def _last_key_and_modifiers(self): + """Returns the last keyval name and modifiers""" + + if self._last_non_modifier_key_event is None: + return "", 0 + + if not self.last_event_was_keyboard(): + return "", 0 + + return self._last_non_modifier_key_event.keyval_name, self._last_input_event.modifiers + + def last_event_was_command(self): + """Returns True if the last event is believed to be a command.""" + + if bool(self._last_key_and_modifiers()[1] & 1 << Atspi.ModifierType.CONTROL): + msg = "INPUT EVENT MANAGER: Last event was command." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + return False + + def last_event_was_shortcut_for(self, obj): + """Returns True if the last event is believed to be a shortcut key for obj.""" + + string = self._last_key_and_modifiers()[0] + if not string: + return False + + rv = False + keys = AXObject.get_action_key_binding(obj, 0).split(";") + for key in keys: + if key.endswith(string.upper()): + rv = True + break + + if rv: + tokens = ["INPUT EVENT MANAGER: Last event was shortcut for", obj] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return rv + + def last_event_was_printable_key(self): + """Returns True if the last event is believed to be a printable key.""" + + if not self.last_event_was_keyboard(): + return False + + if self._last_input_event.is_printable_key(): + msg = "INPUT EVENT MANAGER: Last event was printable key" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + return False + + def last_event_was_caret_navigation(self): + """Returns True if the last event is believed to be caret navigation.""" + + return self.last_event_was_character_navigation() \ + or self.last_event_was_word_navigation() \ + or self.last_event_was_line_navigation() \ + or self.last_event_was_line_boundary_navigation() \ + or self.last_event_was_file_boundary_navigation() \ + or self.last_event_was_page_navigation() + + def last_event_was_caret_selection(self): + """Returns True if the last event is believed to be caret selection.""" + + string, mods = self._last_key_and_modifiers() + if string not in ["Home", "End", "Up", "Down", "Left", "Right"]: + rv = False + else: + rv = bool(mods & 1 << Atspi.ModifierType.SHIFT) + + if rv: + msg = "INPUT EVENT MANAGER: Last event was caret selection" + debug.print_message(debug.LEVEL_INFO, msg, True) + return rv + + def last_event_was_backward_caret_navigation(self): + """Returns True if the last event is believed to be backward caret navigation.""" + + string, mods = self._last_key_and_modifiers() + if string not in ["Up", "Left"]: + rv = False + else: + rv = not mods & 1 << Atspi.ModifierType.SHIFT + + if rv: + msg = "INPUT EVENT MANAGER: Last event was backward caret navigation" + debug.print_message(debug.LEVEL_INFO, msg, True) + return rv + + def last_event_was_forward_caret_navigation(self): + """Returns True if the last event is believed to be forward caret navigation.""" + + string, mods = self._last_key_and_modifiers() + if string not in ["Down", "Right"]: + rv = False + else: + rv = not mods & 1 << Atspi.ModifierType.SHIFT + + if rv: + msg = "INPUT EVENT MANAGER: Last event was forward caret navigation" + debug.print_message(debug.LEVEL_INFO, msg, True) + return rv + + def last_event_was_forward_caret_selection(self): + """Returns True if the last event is believed to be forward caret selection.""" + + string, mods = self._last_key_and_modifiers() + if string not in ["Down", "Right"]: + rv = False + else: + rv = bool(mods & 1 << Atspi.ModifierType.SHIFT) + + if rv: + msg = "INPUT EVENT MANAGER: Last event was forward caret selection" + debug.print_message(debug.LEVEL_INFO, msg, True) + return rv + + def last_event_was_character_navigation(self): + """Returns True if the last event is believed to be character navigation.""" + + string, mods = self._last_key_and_modifiers() + if string not in ["Left", "Right"]: + rv = False + elif mods & 1 << Atspi.ModifierType.CONTROL or mods & 1 << Atspi.ModifierType.ALT: + rv = False + else: + rv = True + + if rv: + msg = "INPUT EVENT MANAGER: Last event was character navigation" + debug.print_message(debug.LEVEL_INFO, msg, True) + return rv + + def last_event_was_word_navigation(self): + """Returns True if the last event is believed to be word navigation.""" + + string, mods = self._last_key_and_modifiers() + if string not in ["Left", "Right"]: + rv = False + else: + rv = bool(mods & 1 << Atspi.ModifierType.CONTROL) + + if rv: + msg = "INPUT EVENT MANAGER: Last event was word navigation" + debug.print_message(debug.LEVEL_INFO, msg, True) + return rv + + def last_event_was_previous_word_navigation(self): + """Returns True if the last event is believed to be previous-word navigation.""" + + string, mods = self._last_key_and_modifiers() + if string != "Left": + rv = False + else: + rv = bool(mods & 1 << Atspi.ModifierType.CONTROL) + + if rv: + msg = "INPUT EVENT MANAGER: Last event was previous-word navigation" + debug.print_message(debug.LEVEL_INFO, msg, True) + return rv + + def last_event_was_next_word_navigation(self): + """Returns True if the last event is believed to be next-word navigation.""" + + string, mods = self._last_key_and_modifiers() + if string != "Right": + rv = False + else: + rv = bool(mods & 1 << Atspi.ModifierType.CONTROL) + + if rv: + msg = "INPUT EVENT MANAGER: Last event was next-word navigation" + debug.print_message(debug.LEVEL_INFO, msg, True) + return rv + + def last_event_was_line_navigation(self): + """Returns True if the last event is believed to be line navigation.""" + + string, mods = self._last_key_and_modifiers() + if string not in ["Up", "Down"]: + rv = False + elif mods & 1 << Atspi.ModifierType.CONTROL: + rv = False + else: + focus = focus_manager.get_manager().get_locus_of_focus() + if AXUtilities.is_single_line(focus): + rv = False + else: + rv = not AXUtilities.is_widget_controlled_by_line_navigation(focus) + + if rv: + msg = "INPUT EVENT MANAGER: Last event was line navigation" + debug.print_message(debug.LEVEL_INFO, msg, True) + return rv + + def last_event_was_paragraph_navigation(self): + """Returns True if the last event is believed to be paragraph navigation.""" + + string, mods = self._last_key_and_modifiers() + if not (string in ["Up", "Down"] and mods & 1 << Atspi.ModifierType.CONTROL): + rv = False + else: + rv = not mods & 1 << Atspi.ModifierType.SHIFT + + if rv: + msg = "INPUT EVENT MANAGER: Last event was paragraph navigation" + debug.print_message(debug.LEVEL_INFO, msg, True) + return rv + + def last_event_was_line_boundary_navigation(self): + """Returns True if the last event is believed to be navigation to start/end of line.""" + + string, mods = self._last_key_and_modifiers() + if string not in ["Home", "End"]: + rv = False + else: + rv = not mods & 1 << Atspi.ModifierType.CONTROL + + if rv: + msg = "INPUT EVENT MANAGER: Last event was line boundary navigation" + debug.print_message(debug.LEVEL_INFO, msg, True) + return rv + + def last_event_was_file_boundary_navigation(self): + """Returns True if the last event is believed to be navigation to top/bottom of file.""" + + string, mods = self._last_key_and_modifiers() + if string not in ["Home", "End"]: + rv = False + else: + rv = bool(mods & 1 << Atspi.ModifierType.CONTROL) + + if rv: + msg = "INPUT EVENT MANAGER: Last event was file boundary navigation" + debug.print_message(debug.LEVEL_INFO, msg, True) + return rv + + def last_event_was_page_navigation(self): + """Returns True if the last event is believed to be page navigation.""" + + string, mods = self._last_key_and_modifiers() + if string not in ["Page_Up", "Page_Down"]: + rv = False + elif mods & 1 << Atspi.ModifierType.CONTROL: + rv = False + else: + focus = focus_manager.get_manager().get_locus_of_focus() + if AXUtilities.is_single_line(focus): + rv = False + else: + rv = not AXUtilities.is_widget_controlled_by_line_navigation(focus) + + if rv: + msg = "INPUT EVENT MANAGER: Last event was page navigation" + debug.print_message(debug.LEVEL_INFO, msg, True) + return rv + + def last_event_was_page_switch(self): + """Returns True if the last event is believed to be a page switch.""" + + string, mods = self._last_key_and_modifiers() + if string.isnumeric(): + rv = bool(mods & 1 << Atspi.ModifierType.ALT) + elif string in ["Page_Up", "Page_Down"]: + rv = bool(mods & 1 << Atspi.ModifierType.CONTROL) + else: + rv = False + + if rv: + msg = "INPUT EVENT MANAGER: Last event was page switch" + debug.print_message(debug.LEVEL_INFO, msg, True) + return rv + + def last_event_was_tab_navigation(self): + """Returns True if the last event is believed to be Tab navigation.""" + + string, mods = self._last_key_and_modifiers() + if string not in ["Tab", "ISO_Left_Tab"]: + rv = False + elif mods & 1 << Atspi.ModifierType.CONTROL or mods & 1 << Atspi.ModifierType.ALT: + rv = False + else: + rv = True + + if rv: + msg = "INPUT EVENT MANAGER: Last event was Tab navigation" + debug.print_message(debug.LEVEL_INFO, msg, True) + return rv + + def last_event_was_table_sort(self): + """Returns True if the last event is believed to be a table sort.""" + + focus = focus_manager.get_manager().get_locus_of_focus() + if not AXUtilities.is_table_header(focus): + rv = False + elif self.last_event_was_mouse_button(): + rv = self.last_event_was_primary_click() + elif self.last_event_was_keyboard(): + rv = self.last_event_was_return_or_space() + else: + rv = False + + if rv: + msg = "INPUT EVENT MANAGER: Last event was table sort" + debug.print_message(debug.LEVEL_INFO, msg, True) + return rv + + def last_event_was_unmodified_arrow(self): + """Returns True if the last event is an unmodified arrow.""" + + string, mods = self._last_key_and_modifiers() + if string not in ["Left", "Right", "Up", "Down"]: + return False + + if mods & 1 << Atspi.ModifierType.CONTROL \ + or mods & 1 << Atspi.ModifierType.SHIFT \ + or mods & 1 << Atspi.ModifierType.ALT: + return False + + # TODO: JD - 8 is the value of keybindings.MODIFIER_ORCA, but we need to + # avoid a circular import. + if mods & 1 << 8: + return False + + return True + + def last_event_was_alt_modified(self): + """Returns True if the last event was alt-modified.""" + + mods = self._last_key_and_modifiers()[-1] + return mods & 1 << Atspi.ModifierType.ALT + + def last_event_was_backspace(self): + """Returns True if the last event is BackSpace.""" + + return self._last_key_and_modifiers()[0] == "BackSpace" + + def last_event_was_down(self): + """Returns True if the last event is Down.""" + + return self._last_key_and_modifiers()[0] == "Down" + + def last_event_was_f1(self): + """Returns True if the last event is F1.""" + + return self._last_key_and_modifiers()[0] == "F1" + + def last_event_was_left(self): + """Returns True if the last event is Left.""" + + return self._last_key_and_modifiers()[0] == "Left" + + def last_event_was_left_or_right(self): + """Returns True if the last event is Left or Right.""" + + return self._last_key_and_modifiers()[0] in ["Left", "Right"] + + def last_event_was_page_up_or_page_down(self): + """Returns True if the last event is Page_Up or Page_Down.""" + + return self._last_key_and_modifiers()[0] in ["Page_Up", "Page_Down"] + + def last_event_was_right(self): + """Returns True if the last event is Right.""" + + return self._last_key_and_modifiers()[0] == "Right" + + def last_event_was_return(self): + """Returns True if the last event is Return.""" + + return self._last_key_and_modifiers()[0] == "Return" + + def last_event_was_return_or_space(self): + """Returns True if the last event is Return or space.""" + + return self._last_key_and_modifiers()[0] in ["Return", "space", " "] + + def last_event_was_return_tab_or_space(self): + """Returns True if the last event is Return, Tab, or space.""" + + return self._last_key_and_modifiers()[0] in ["Return", "Tab", "space", " "] + + def last_event_was_space(self): + """Returns True if the last event is space.""" + + return self._last_key_and_modifiers()[0] in [" ", "space"] + + def last_event_was_tab(self): + """Returns True if the last event is Tab.""" + + return self._last_key_and_modifiers()[0] == "Tab" + + def last_event_was_up(self): + """Returns True if the last event is Up.""" + + return self._last_key_and_modifiers()[0] == "Up" + + def last_event_was_up_or_down(self): + """Returns True if the last event is Up or Down.""" + + return self._last_key_and_modifiers()[0] in ["Up", "Down"] + + def last_event_was_delete(self): + """Returns True if the last event is believed to be delete.""" + + string, mods = self._last_key_and_modifiers() + if string in ["Delete", "KP_Delete"]: + rv = True + elif string.lower() == "d": + rv = bool(mods & 1 << Atspi.ModifierType.CONTROL) + else: + rv = False + + if rv: + msg = "INPUT EVENT MANAGER: Last event was delete" + debug.print_message(debug.LEVEL_INFO, msg, True) + return rv + + def last_event_was_cut(self): + """Returns True if the last event is believed to be the cut command.""" + + string, mods = self._last_key_and_modifiers() + if string.lower() != "x": + return False + + if mods & 1 << Atspi.ModifierType.CONTROL and not mods & 1 << Atspi.ModifierType.SHIFT: + msg = "INPUT EVENT MANAGER: Last event was cut" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + return False + + def last_event_was_copy(self): + """Returns True if the last event is believed to be the copy command.""" + + string, mods = self._last_key_and_modifiers() + if string.lower() != "c" or not mods & 1 << Atspi.ModifierType.CONTROL: + rv = False + elif AXUtilities.is_terminal(self._last_input_event.get_object()): + rv = mods & 1 << Atspi.ModifierType.SHIFT + else: + rv = not mods & 1 << Atspi.ModifierType.SHIFT + + if rv: + msg = "INPUT EVENT MANAGER: Last event was copy" + debug.print_message(debug.LEVEL_INFO, msg, True) + return rv + + def last_event_was_paste(self): + """Returns True if the last event is believed to be the paste command.""" + + string, mods = self._last_key_and_modifiers() + if string.lower() != "v" or not mods & 1 << Atspi.ModifierType.CONTROL: + rv = False + elif AXUtilities.is_terminal(self._last_input_event.get_object()): + rv = mods & 1 << Atspi.ModifierType.SHIFT + else: + rv = not mods & 1 << Atspi.ModifierType.SHIFT + + if rv: + msg = "INPUT EVENT MANAGER: Last event was paste" + debug.print_message(debug.LEVEL_INFO, msg, True) + return rv + + def last_event_was_undo(self): + """Returns True if the last event is believed to be the undo command.""" + + string, mods = self._last_key_and_modifiers() + if string.lower() != "z": + return False + if mods & 1 << Atspi.ModifierType.CONTROL and not mods & 1 << Atspi.ModifierType.SHIFT: + msg = "INPUT EVENT MANAGER: Last event was undo" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + return False + + def last_event_was_redo(self): + """Returns True if the last event is believed to be the redo command.""" + + string, mods = self._last_key_and_modifiers() + if string.lower() == "z": + rv = mods & 1 << Atspi.ModifierType.CONTROL and mods & 1 << Atspi.ModifierType.SHIFT + elif string.lower() == "y": + # LibreOffice + rv = mods & 1 << Atspi.ModifierType.CONTROL \ + and not mods & 1 << Atspi.ModifierType.SHIFT + else: + rv = False + + if rv: + msg = "INPUT EVENT MANAGER: Last event was redo" + debug.print_message(debug.LEVEL_INFO, msg, True) + return rv + + def last_event_was_select_all(self): + """Returns True if the last event is believed to be the select all command.""" + + string, mods = self._last_key_and_modifiers() + if string.lower() != "a": + return False + + if (mods & 1 << Atspi.ModifierType.CONTROL and not mods & 1 << Atspi.ModifierType.SHIFT): + msg = "INPUT EVENT MANAGER: Last event was select all" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + return False + + def last_event_was_primary_click(self): + """Returns True if the last event is a primary mouse click.""" + + if not self.last_event_was_mouse_button(): + return False + + if self._last_input_event.button == "1" and self._last_input_event.pressed: + msg = "INPUT EVENT MANAGER: Last event was primary click" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + return False + + def last_event_was_primary_release(self): + """Returns True if the last event is a primary mouse release.""" + + if not self.last_event_was_mouse_button(): + return False + + if self._last_input_event.button == "1" and not self._last_input_event.pressed: + msg = "INPUT EVENT MANAGER: Last event was primary release" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + return False + + def last_event_was_primary_click_or_release(self): + """Returns True if the last event is a primary mouse click or release.""" + + if not self.last_event_was_mouse_button(): + return False + + if self._last_input_event.button == "1": + msg = "INPUT EVENT MANAGER: Last event was primary click or release" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + return False + + def last_event_was_middle_click(self): + """Returns True if the last event is a middle mouse click.""" + + if not self.last_event_was_mouse_button(): + return False + + if self._last_input_event.button == "2" and self._last_input_event.pressed: + msg = "INPUT EVENT MANAGER: Last event was middle click" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + return False + + def last_event_was_middle_release(self): + """Returns True if the last event is a middle mouse release.""" + + if not self.last_event_was_mouse_button(): + return False + + if self._last_input_event.button == "2" and not self._last_input_event.pressed: + msg = "INPUT EVENT MANAGER: Last event was middle release" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + return False + + def last_event_was_secondary_click(self): + """Returns True if the last event is a secondary mouse click.""" + + if not self.last_event_was_mouse_button(): + return False + + if self._last_input_event.button == "3" and self._last_input_event.pressed: + msg = "INPUT EVENT MANAGER: Last event was secondary click" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + return False + + def last_event_was_secondary_release(self): + """Returns True if the last event is a secondary mouse release.""" + + if not self.last_event_was_mouse_button(): + return False + + if self._last_input_event.button == "3" and not self._last_input_event.pressed: + msg = "INPUT EVENT MANAGER: Last event was secondary release" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + return False + _manager = InputEventManager() -def getManager(): +def get_manager(): """Returns the Input Event Manager singleton.""" - return _manager \ No newline at end of file + return _manager diff --git a/src/cthulhu/keybindings.py b/src/cthulhu/keybindings.py index 6ec9f49..dcc0367 100644 --- a/src/cthulhu/keybindings.py +++ b/src/cthulhu/keybindings.py @@ -193,7 +193,7 @@ def getModifierNames(mods): text += _("Shift") + "+" return text -def getClickCountString(count): +def get_click_countString(count): """Returns a human-consumable string representing the number of clicks, such as 'double click' and 'triple click'.""" @@ -286,7 +286,7 @@ class KeyBinding: """Returns a more human-consumable string representing this binding.""" mods = getModifierNames(self.modifiers) - clickCount = getClickCountString(self.click_count) + clickCount = get_click_countString(self.click_count) keysym = self.keysymstring string = f'{mods}{keysym} {clickCount}' @@ -515,7 +515,7 @@ class KeyBindings: matches = [] candidates = [] - clickCount = keyboardEvent.getClickCount() + clickCount = keyboardEvent.get_click_count() for keyBinding in self.keyBindings: if keyBinding.matches(keyboardEvent.hw_code, keyboardEvent.modifiers): if event_str.lower() == 'v': diff --git a/src/cthulhu/label_inference.py b/src/cthulhu/label_inference.py index 75d1afa..097c776 100644 --- a/src/cthulhu/label_inference.py +++ b/src/cthulhu/label_inference.py @@ -37,6 +37,7 @@ from gi.repository import Atspi from . import debug from .ax_object import AXObject +from .ax_text import AXText from .ax_utilities import AXUtilities class LabelInference: @@ -159,12 +160,10 @@ class LabelInference: if len(children) > 1: return False - try: - text = obj.queryText() - except NotImplementedError: + if not AXObject.supports_text(obj): return True - string = text.getText(0, -1).strip() + string = AXText.get_all_text(obj).strip() if string.count(self._script.EMBEDDED_OBJECT_CHARACTER) > 1: return False @@ -224,17 +223,17 @@ class LabelInference: extents = 0, 0, 0, 0 text = self._script.utilities.queryNonEmptyText(obj) - if text: - if not AXUtilities.is_text_input(obj): - if endOffset == -1: - try: - endOffset = text.characterCount - except Exception: - tokens = ["LABEL INFERENCE: Exception getting character count for", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return extents + if text and not AXUtilities.is_text_input(obj): + if endOffset == -1: + endOffset = AXText.get_character_count(obj) - extents = text.getRangeExtents(startOffset, endOffset, 0) + try: + extents = Atspi.Text.get_range_extents( + obj, startOffset, endOffset, Atspi.CoordType.SCREEN) + except Exception: + tokens = ["LABEL INFERENCE: Exception getting character count for", obj] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + return extents if not (extents[2] and extents[3]): try: @@ -260,7 +259,7 @@ class LabelInference: if self._cannotLabel(obj): return None, [] - contents = self._script.utilities.getObjectContentsAtOffset(obj, useCache=False) + contents = self._script.utilities.get_objectContentsAtOffset(obj, useCache=False) objects = [content[0] for content in contents] if list(filter(self._isWidget, objects)): return None, [] diff --git a/src/cthulhu/learn_mode_presenter.py b/src/cthulhu/learn_mode_presenter.py index 5556750..e64c067 100644 --- a/src/cthulhu/learn_mode_presenter.py +++ b/src/cthulhu/learn_mode_presenter.py @@ -164,7 +164,7 @@ class LearnModePresenter: return False cthulhu_state.activeScript.speakKeyEvent(event) - if event.isPrintableKey() and event.getClickCount() == 2 \ + if event.is_printable_key() and event.get_click_count() == 2 \ and event.getHandler() is None: cthulhu_state.activeScript.phoneticSpellCurrentItem(event.event_string) @@ -238,7 +238,7 @@ class LearnModePresenter: bindings[guilabels.KB_GROUP_FLAT_REVIEW] = bound items += len(bound) - bound = script.getObjectNavigator().get_bindings().getBoundBindings() + bound = script.get_objectNavigator().get_bindings().getBoundBindings() bindings[guilabels.KB_GROUP_OBJECT_NAVIGATION] = bound items += len(bound) diff --git a/src/cthulhu/liveregions.py b/src/cthulhu/liveregions.py index e51ed85..6caa161 100644 --- a/src/cthulhu/liveregions.py +++ b/src/cthulhu/liveregions.py @@ -42,6 +42,8 @@ from . import cthulhu_state from . import settings_manager from .ax_collection import AXCollection from .ax_object import AXObject +from .ax_text import AXText +from .ax_utilities_relation import AXUtilitiesRelation _settingsManager = settings_manager.getManager() @@ -313,7 +315,7 @@ class LiveRegionManager: return obj = cthulhu_state.locusOfFocus - objectid = self._getObjectId(obj) + objectid = self._get_objectId(obj) uri = self._script.bookmarks.getURIKey() try: @@ -344,7 +346,7 @@ class LiveRegionManager: contents of that object""" if self.lastliveobj: self._script.utilities.setCaretPosition(self.lastliveobj, 0) - self._script.speakContents(self._script.utilities.getObjectContentsAtOffset( + self._script.speakContents(self._script.utilities.get_objectContentsAtOffset( self.lastliveobj, 0)) def reviewLiveAnnouncement(self, script, inputEvent): @@ -391,7 +393,7 @@ class LiveRegionManager: # markup. matches = self.getAllLiveRegions(docframe) for match in matches: - objectid = self._getObjectId(match) + objectid = self._get_objectId(match) self._politenessOverrides[(uri, objectid)] = LIVE_OFF # Toggle our flag @@ -421,26 +423,24 @@ class LiveRegionManager: def generateLiveRegionDescription(self, obj, **args): """Used in conjunction with whereAmI to output description and politeness of the given live region object""" - objectid = self._getObjectId(obj) + objectid = self._get_objectId(obj) uri = self._script.bookmarks.getURIKey() results = [] # get the description if there is one. - relation = AXObject.get_relation(obj, Atspi.RelationType.DESCRIBED_BY) - if relation: - targetobj = relation.getTarget(0) - try: + describedBy = AXUtilitiesRelation.get_is_described_by(obj) + if describedBy: + targetobj = describedBy[0] + if AXObject.supports_text(targetobj): # We will add on descriptions if they don't duplicate # what's already in the object's description. # See http://bugzilla.gnome.org/show_bug.cgi?id=568467 # for more information. # - description = targetobj.queryText().getText(0, -1) + description = AXText.get_all_text(targetobj) if description.strip() != AXObject.get_description(obj).strip(): results.append(description) - except NotImplementedError: - pass # get the politeness level as a string try: @@ -526,7 +526,7 @@ class LiveRegionManager: def _getLiveType(self, obj): """Returns the live politeness setting for a given object. Also, registers LIVE_NONE objects in politeness overrides when monitoring.""" - objectid = self._getObjectId(obj) + objectid = self._get_objectId(obj) uri = self._script.bookmarks.getURIKey() if (uri, objectid) in self._politenessOverrides: # look to see if there is a user politeness override @@ -540,7 +540,7 @@ class LiveRegionManager: self._politenessOverrides[(uri, objectid)] = livetype return livetype - def _getObjectId(self, obj): + def _get_objectId(self, obj): """Returns the HTML 'id' or a path to the object is an HTML id is unavailable""" attrs = self._getAttrDictionary(obj) diff --git a/src/cthulhu/meson.build b/src/cthulhu/meson.build index 52296f4..94400f8 100644 --- a/src/cthulhu/meson.build +++ b/src/cthulhu/meson.build @@ -4,13 +4,23 @@ cthulhu_python_sources = files([ 'acss.py', 'action_presenter.py', 'ax_collection.py', + 'ax_component.py', + 'ax_document.py', 'ax_event_synthesizer.py', + 'ax_hypertext.py', 'ax_object.py', 'ax_selection.py', + 'ax_table.py', + 'ax_text.py', + 'ax_utilities_application.py', 'ax_utilities.py', 'ax_utilities_collection.py', + 'ax_utilities_debugging.py', + 'ax_utilities_event.py', + 'ax_utilities_relation.py', 'ax_utilities_role.py', 'ax_utilities_state.py', + 'ax_value.py', 'bookmarks.py', 'braille.py', 'braille_generator.py', @@ -35,6 +45,7 @@ cthulhu_python_sources = files([ 'flat_review.py', 'flat_review_presenter.py', 'formatting.py', + 'focus_manager.py', 'generator.py', 'guilabels.py', 'highlighter.py', diff --git a/src/cthulhu/mouse_review.py b/src/cthulhu/mouse_review.py index fbe08e2..d761309 100644 --- a/src/cthulhu/mouse_review.py +++ b/src/cthulhu/mouse_review.py @@ -57,9 +57,10 @@ from . import cthulhu_state from . import script_manager from . import settings_manager from .ax_object import AXObject +from .ax_text import AXText from .ax_utilities import AXUtilities -_scriptManager = script_manager.getManager() +_scriptManager = script_manager.get_manager() _settingsManager = settings_manager.getManager() class _StringContext: @@ -222,7 +223,7 @@ class _ItemContext: if not AXObject.supports_text(self._obj): return True - if not self._obj.queryText().characterCount: + if not AXText.get_character_count(self._obj): return True return False @@ -258,7 +259,7 @@ class _ItemContext: return self._string.isSubstringOf(other._string) - def getObject(self): + def get_object(self): """Returns the accessible object associated with this context.""" return self._obj @@ -431,7 +432,7 @@ class MouseReviewer: script = None frame = None if obj: - script = _scriptManager.getScript(AXObject.get_application(obj), obj) + script = _scriptManager.get_script(AXObject.get_application(obj), obj) if script: frame = script.utilities.topLevelObject(obj) self._currentMouseOver = _ItemContext(obj=obj, frame=frame, script=script) @@ -480,7 +481,7 @@ class MouseReviewer: if not self._active: return None - obj = self._currentMouseOver.getObject() + obj = self._currentMouseOver.get_object() if time.time() - self._currentMouseOver.getTime() > 0.1: tokens = ["MOUSE REVIEW: Treating", obj, "as stale"] @@ -614,7 +615,7 @@ class MouseReviewer: if not window: return - script = _scriptManager.getScript(AXObject.get_application(window)) + script = _scriptManager.get_script(AXObject.get_application(window)) if not script: return @@ -636,7 +637,7 @@ class MouseReviewer: tokens = [f"MOUSE REVIEW: Object at ({pX}, {pY}) is", obj] debug.printTokens(debug.LEVEL_INFO, tokens, True) - script = _scriptManager.getScript(AXObject.get_application(window), obj) + script = _scriptManager.get_script(AXObject.get_application(window), obj) if menu and obj and not AXObject.find_ancestor(obj, AXUtilities.is_menu): if script.utilities.intersectingRegion(obj, menu) != (0, 0, 0, 0): tokens = ["MOUSE REVIEW:", obj, "believed to be under", menu] @@ -661,7 +662,7 @@ class MouseReviewer: x, y, width, height = self._currentMouseOver.getBoundingBox() if y <= pY <= y + height and self._currentMouseOver.getString(): boundary = Atspi.TextBoundaryType.WORD_START - elif obj == self._currentMouseOver.getObject(): + elif obj == self._currentMouseOver.get_object(): boundary = Atspi.TextBoundaryType.LINE_START elif AXUtilities.is_selectable(obj): boundary = Atspi.TextBoundaryType.LINE_START diff --git a/src/cthulhu/plugins/AIAssistant/plugin.py b/src/cthulhu/plugins/AIAssistant/plugin.py index b2f3a1c..d08318b 100644 --- a/src/cthulhu/plugins/AIAssistant/plugin.py +++ b/src/cthulhu/plugins/AIAssistant/plugin.py @@ -27,6 +27,7 @@ from cthulhu import settings from cthulhu import settings_manager from cthulhu import cthulhu_state from cthulhu import ax_object +from cthulhu.ax_text import AXText from cthulhu import ax_utilities from cthulhu.ax_utilities_state import AXUtilitiesState from cthulhu.plugins.AIAssistant.ai_providers import create_provider @@ -742,16 +743,11 @@ class AIAssistant(Plugin): pass # Fallback: try direct AT-SPI text interface - try: - if ax_object.AXObject.supports_text(obj): - text_iface = obj.queryText() - if text_iface: - text = text_iface.getText(0, -1) - if text: - return text.strip() - except: - pass - + if ax_object.AXObject.supports_text(obj): + text = AXText.get_all_text(obj) + if text: + return text.strip() + return "" except Exception as e: diff --git a/src/cthulhu/plugins/IndentationAudio/plugin.py b/src/cthulhu/plugins/IndentationAudio/plugin.py index 84318ad..04252ba 100644 --- a/src/cthulhu/plugins/IndentationAudio/plugin.py +++ b/src/cthulhu/plugins/IndentationAudio/plugin.py @@ -15,6 +15,8 @@ from gi.repository import GLib from cthulhu.plugin import Plugin, cthulhu_hookimpl from cthulhu import debug +from cthulhu.ax_object import AXObject +from cthulhu.ax_text import AXText # Import Cthulhu's sound system try: @@ -200,22 +202,19 @@ class IndentationAudio(Plugin): # Fallback to direct AT-SPI access try: obj = event.source - if obj and hasattr(obj, 'queryText'): - text_iface = obj.queryText() - if text_iface: - # Get all text and find current line - full_text = text_iface.getText(0, -1) - caret_pos = text_iface.caretOffset - - if full_text: - lines = full_text.split('\n') - char_count = 0 - for line in lines: - if char_count <= caret_pos <= char_count + len(line): - debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Fallback line: '{line}'", True) - self.check_indentation_change(obj, line) - break - char_count += len(line) + 1 # +1 for newline + if AXObject.supports_text(obj): + # Get all text and find current line + full_text = AXText.get_all_text(obj) + caret_pos = AXText.get_caret_offset(obj) + if full_text: + lines = full_text.split('\n') + char_count = 0 + for line in lines: + if char_count <= caret_pos <= char_count + len(line): + debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Fallback line: '{line}'", True) + self.check_indentation_change(obj, line) + break + char_count += len(line) + 1 # +1 for newline except Exception as fallback_e: debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Fallback also failed: {fallback_e}", True) @@ -475,4 +474,4 @@ class IndentationAudio(Plugin): logger.info("IndentationAudio: Handled script change") except Exception as e: - logger.error(f"IndentationAudio: Error handling script change: {e}") \ No newline at end of file + logger.error(f"IndentationAudio: Error handling script change: {e}") diff --git a/src/cthulhu/plugins/self_voice/plugin.py b/src/cthulhu/plugins/self_voice/plugin.py index 5a52d55..e2893ff 100644 --- a/src/cthulhu/plugins/self_voice/plugin.py +++ b/src/cthulhu/plugins/self_voice/plugin.py @@ -110,8 +110,8 @@ class SelfVoice(Plugin): else: # Use the script manager for standard messages script_manager = self.app.getDynamicApiManager().getAPI('ScriptManager') - scriptManager = script_manager.getManager() - scriptManager.getDefaultScript().presentMessage(message, resetStyles=False) + scriptManager = script_manager.get_manager() + scriptManager.get_default_script().presentMessage(message, resetStyles=False) def voiceWorker(self): """Worker thread that listens on a socket for messages to speak.""" diff --git a/src/cthulhu/script.py b/src/cthulhu/script.py index 87fcb97..29812ed 100644 --- a/src/cthulhu/script.py +++ b/src/cthulhu/script.py @@ -36,7 +36,7 @@ script manager. 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 'getScript(app)' method that returns an +is also required to have a 'get_script(app)' method that returns an instance of the Script subclass. See default.py for an example.""" __id__ = "$Id$" @@ -79,7 +79,7 @@ from . import where_am_i_presenter from .ax_object import AXObject _eventManager = event_manager.getManager() -_scriptManager = script_manager.getManager() +_scriptManager = script_manager.get_manager() _settingsManager = settings_manager.getManager() class Script: @@ -119,11 +119,11 @@ class Script: self.flatReviewPresenter = self.getFlatReviewPresenter() self.speechAndVerbosityManager = self.getSpeechAndVerbosityManager() self.dateAndTimePresenter = self.getDateAndTimePresenter() - self.objectNavigator = self.getObjectNavigator() + self.objectNavigator = self.get_objectNavigator() self.whereAmIPresenter = self.getWhereAmIPresenter() self.learnModePresenter = self.getLearnModePresenter() self.mouseReviewer = self.getMouseReviewer() - self.eventSynthesizer = self.getEventSynthesizer() + self.eventSynthesizer = self.get_event_synthesizer() self.actionPresenter = self.getActionPresenter() self.chat = self.getChat() @@ -292,7 +292,7 @@ class Script: def getDateAndTimePresenter(self): return date_and_time_presenter.getPresenter() - def getObjectNavigator(self): + def get_objectNavigator(self): return object_navigator.getNavigator() def getSpeechAndVerbosityManager(self): @@ -310,8 +310,8 @@ class Script: def getMouseReviewer(self): return mouse_review.getReviewer() - def getEventSynthesizer(self): - return ax_event_synthesizer.getSynthesizer() + 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 @@ -598,7 +598,7 @@ class Script: return consumed - def locusOfFocusChanged(self, event, oldLocusOfFocus, newLocusOfFocus): + 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 diff --git a/src/cthulhu/script_manager.py b/src/cthulhu/script_manager.py index 3969643..f21abd3 100644 --- a/src/cthulhu/script_manager.py +++ b/src/cthulhu/script_manager.py @@ -34,9 +34,13 @@ import importlib from . import debug from . import cthulhu_state from .ax_object import AXObject -from .ax_utilities import AXUtilities from .scripts import apps, toolkits +def _get_ax_utilities(): + # Avoid circular import with ax_utilities -> ax_utilities_event -> focus_manager -> braille -> settings_manager -> script_manager. + from .ax_utilities import AXUtilities + return AXUtilities + class ScriptManager: def __init__(self): @@ -67,7 +71,7 @@ class ScriptManager: self._toolkitNames = \ {'WebKitGTK': 'WebKitGtk', 'GTK': 'gtk'} - self.setActiveScript(None, "__init__") + self.set_active_script(None, "__init__") self._active = False debug.printMessage(debug.LEVEL_INFO, "SCRIPT MANAGER: Initialized", True) @@ -75,9 +79,9 @@ class ScriptManager: """Called when this script manager is activated.""" debug.printMessage(debug.LEVEL_INFO, "SCRIPT MANAGER: Activating", True) - self._defaultScript = self.getScript(None) + self._defaultScript = self.get_script(None) self._defaultScript.registerEventListeners() - self.setActiveScript(self._defaultScript, "activate") + self.set_active_script(self._defaultScript, "activate") self._active = True debug.printMessage(debug.LEVEL_INFO, "SCRIPT MANAGER: Activated", True) @@ -88,14 +92,14 @@ class ScriptManager: if self._defaultScript: self._defaultScript.deregisterEventListeners() self._defaultScript = None - self.setActiveScript(None, "deactivate") + self.set_active_script(None, "deactivate") self.appScripts = {} self.toolkitScripts = {} self.customScripts = {} self._active = False debug.printMessage(debug.LEVEL_INFO, "SCRIPT MANAGER: Deactivated", True) - def getModuleName(self, app): + def get_module_name(self, app): """Returns the module name of the script to use for application app.""" if app is None: @@ -129,19 +133,19 @@ class ScriptManager: debug.printTokens(debug.LEVEL_INFO, tokens, True) return name - def _toolkitForObject(self, obj): + def _toolkit_for_object(self, obj): """Returns the name of the toolkit associated with obj.""" name = AXObject.get_attribute(obj, 'toolkit') return self._toolkitNames.get(name, name) - def _scriptForRole(self, obj): - if AXUtilities.is_terminal(obj): + def _script_for_role(self, obj): + if _get_ax_utilities().is_terminal(obj): return 'terminal' return '' - def _newNamedScript(self, app, name): + def _new_named_script(self, app, name): """Attempts to locate and load the named module. If successful, returns a script based on this module.""" @@ -161,8 +165,8 @@ class ScriptManager: tokens = ["SCRIPT MANAGER: Found", moduleName] debug.printTokens(debug.LEVEL_INFO, tokens, True) try: - if hasattr(module, 'getScript'): - script = module.getScript(app) + if hasattr(module, 'get_script'): + script = module.get_script(app) else: script = module.Script(app) break @@ -172,31 +176,31 @@ class ScriptManager: return script - def _createScript(self, app, obj=None): + def _create_script(self, app, obj=None): """For the given application, create a new script instance.""" - moduleName = self.getModuleName(app) - script = self._newNamedScript(app, moduleName) + moduleName = self.get_module_name(app) + script = self._new_named_script(app, moduleName) if script: return script - objToolkit = self._toolkitForObject(obj) - script = self._newNamedScript(app, objToolkit) + objToolkit = self._toolkit_for_object(obj) + script = self._new_named_script(app, objToolkit) if script: return script - toolkitName = AXObject.get_application_toolkit_name(app) + toolkitName = _get_ax_utilities().get_application_toolkit_name(app) if app and toolkitName: - script = self._newNamedScript(app, toolkitName) + script = self._new_named_script(app, toolkitName) if not script: - script = self.getDefaultScript(app) + script = self.get_default_script(app) tokens = ["SCRIPT MANAGER: Default script created for", app, "(obj: ", obj, ")"] debug.printTokens(debug.LEVEL_INFO, tokens, True) return script - def getDefaultScript(self, app=None): + def get_default_script(self, app=None): if not app and self._defaultScript: return self._defaultScript @@ -208,14 +212,14 @@ class ScriptManager: return script - def sanityCheckScript(self, script): + def sanity_check_script(self, script): if not self._active: return script - if AXUtilities.is_application_in_desktop(script.app): + if _get_ax_utilities().is_application_in_desktop(script.app): return script - newScript = self._getScriptForAppReplicant(script.app) + newScript = self._get_script_for_app_replicant(script.app) if newScript: return newScript @@ -223,26 +227,29 @@ class ScriptManager: debug.printTokens(debug.LEVEL_INFO, tokens, True) return script - def getScriptForMouseButtonEvent(self, event): - isActive = AXUtilities.is_active(cthulhu_state.activeWindow) + def get_script_for_mouse_button_event(self, event): + isActive = _get_ax_utilities().is_active(cthulhu_state.activeWindow) tokens = ["SCRIPT MANAGER:", cthulhu_state.activeWindow, "is active:", isActive] debug.printTokens(debug.LEVEL_INFO, tokens, True) if isActive and cthulhu_state.activeScript: return cthulhu_state.activeScript - script = self.getDefaultScript() + script = self.get_default_script() activeWindow = script.utilities.activeWindow() if not activeWindow: return script - focusedObject = AXUtilities.get_focused_object(activeWindow) + focusedObject = _get_ax_utilities().get_focused_object(activeWindow) if focusedObject: - return self.getScript(AXObject.get_application(focusedObject), focusedObject) + return self.get_script(AXObject.get_application(focusedObject), focusedObject) - return self.getScript(AXObject.get_application(activeWindow), activeWindow) + return self.get_script(AXObject.get_application(activeWindow), activeWindow) - def getScript(self, app, obj=None, sanityCheck=False): + def get_active_script(self): + return cthulhu_state.activeScript + + def get_script(self, app, obj=None, sanity_check=False): """Get a script for an app (and make it if necessary). This is used instead of a simple calls to Script's constructor. @@ -256,52 +263,52 @@ class ScriptManager: appScript = None toolkitScript = None - roleName = self._scriptForRole(obj) + roleName = self._script_for_role(obj) if roleName: customScripts = self.customScripts.get(app, {}) customScript = customScripts.get(roleName) if not customScript: - customScript = self._newNamedScript(app, roleName) + customScript = self._new_named_script(app, roleName) customScripts[roleName] = customScript self.customScripts[app] = customScripts - objToolkit = self._toolkitForObject(obj) + objToolkit = self._toolkit_for_object(obj) if objToolkit: toolkitScripts = self.toolkitScripts.get(app, {}) toolkitScript = toolkitScripts.get(objToolkit) if not toolkitScript: - toolkitScript = self._createScript(app, obj) + toolkitScript = self._create_script(app, obj) toolkitScripts[objToolkit] = toolkitScript self.toolkitScripts[app] = toolkitScripts try: if not app: - appScript = self.getDefaultScript() + appScript = self.get_default_script() elif app in self.appScripts: appScript = self.appScripts[app] else: - appScript = self._createScript(app, None) + appScript = self._create_script(app, None) self.appScripts[app] = appScript except Exception as error: tokens = ["SCRIPT MANAGER: Exception getting app script for", app, ":", error] debug.printTokens(debug.LEVEL_INFO, tokens, True) - appScript = self.getDefaultScript() + 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 (AXUtilities.is_frame(obj) or AXUtilities.is_status_bar(obj)) \ + 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 sanityCheck: - appScript = self.sanityCheckScript(appScript) + if app and sanity_check: + appScript = self.sanity_check_script(appScript) return appScript - def getOrCreateSleepModeScript(self, app): + def get_or_create_sleep_mode_script(self, app): """Gets or creates the sleep mode script.""" script = self._sleepModeScripts.get(app) if script is not None: @@ -313,7 +320,7 @@ class ScriptManager: self._sleepModeScripts[app] = script return script - def setActiveScript(self, newScript, reason=None): + def set_active_script(self, newScript, reason=None): """Set the new active script. Arguments: @@ -341,7 +348,7 @@ class ScriptManager: tokens = ["SCRIPT MANAGER: Setting active script to", newScript, "reason:", reason] debug.printTokens(debug.LEVEL_INFO, tokens, True) - def _getScriptForAppReplicant(self, app): + def _get_script_for_app_replicant(self, app): if not self._active: return None @@ -353,7 +360,7 @@ class ScriptManager: for a, script in items: if AXObject.get_process_id(a) != pid: continue - if a != app and AXUtilities.is_application_in_desktop(a): + if a != app and _get_ax_utilities().is_application_in_desktop(a): if script.app is None: script.app = a tokens = ["SCRIPT MANAGER: Script for app replicant:", script, script.app] @@ -362,7 +369,7 @@ class ScriptManager: return None - def reclaimScripts(self): + def reclaim_scripts(self): """Compares the list of known scripts to the list of known apps, deleting any scripts as necessary. """ @@ -372,7 +379,7 @@ class ScriptManager: appList = list(self.appScripts.keys()) for app in appList: - if AXUtilities.is_application_in_desktop(app): + if _get_ax_utilities().is_application_in_desktop(app): continue try: @@ -385,7 +392,7 @@ class ScriptManager: tokens = ["SCRIPT MANAGER: Old script for app found:", appScript, appScript.app] debug.printTokens(debug.LEVEL_INFO, tokens, True) - newScript = self._getScriptForAppReplicant(app) + newScript = self._get_script_for_app_replicant(app) if newScript: tokens = ["SCRIPT MANAGER: Transferring attributes:", newScript, newScript.app] debug.printTokens(debug.LEVEL_INFO, tokens, True) @@ -417,5 +424,5 @@ class ScriptManager: _manager = ScriptManager() -def getManager(): +def get_manager(): return _manager diff --git a/src/cthulhu/script_utilities.py b/src/cthulhu/script_utilities.py index e4b290a..3453a64 100644 --- a/src/cthulhu/script_utilities.py +++ b/src/cthulhu/script_utilities.py @@ -65,7 +65,9 @@ from . import settings_manager from . import text_attribute_names from .ax_object import AXObject from .ax_selection import AXSelection +from .ax_text import AXText from .ax_utilities import AXUtilities +from .ax_utilities_relation import AXUtilitiesRelation _settingsManager = settings_manager.getManager() @@ -288,7 +290,7 @@ class Utilities: def pred(x): return AXObject.get_index_in_parent(x) >= 0 - nodes = AXObject.get_relation_targets(obj, Atspi.RelationType.NODE_PARENT_OF, pred) + nodes = [x for x in AXUtilitiesRelation.get_is_node_parent_of(obj) if pred(x)] tokens = ["SCRIPT UTILITIES:", len(nodes), "child nodes for", obj, "via node-parent-of"] debug.printTokens(debug.LEVEL_INFO, tokens, True) if nodes: @@ -303,11 +305,10 @@ class Utilities: nodeLevel = self.nodeLevel(obj) for i in range(row+1, table.nRows): cell = table.getAccessibleAt(i, col) - relation = AXObject.get_relation(cell, Atspi.RelationType.NODE_CHILD_OF) - if not relation: + nodeOf = AXUtilitiesRelation.get_is_node_child_of(cell) + if not nodeOf: continue - - nodeOf = relation.get_target(0) + nodeOf = nodeOf[0] if self.isSameObject(obj, nodeOf): nodes.append(cell) elif self.nodeLevel(nodeOf) <= nodeLevel: @@ -397,11 +398,11 @@ class Utilities: def descriptionsForObject(self, obj): """Return a list of objects describing obj.""" - descriptions = AXObject.get_relation_targets(obj, Atspi.RelationType.DESCRIBED_BY) + descriptions = AXUtilitiesRelation.get_is_described_by(obj) if not descriptions: return [] - labels = AXObject.get_relation_targets(obj, Atspi.RelationType.LABELLED_BY) + labels = AXUtilitiesRelation.get_is_labelled_by(obj) if descriptions == labels: tokens = ["SCRIPT UTILITIES:", obj, "'s described-by targets are the same as labelled-by targets"] @@ -417,7 +418,7 @@ class Utilities: def detailsForObject(self, obj, textOnly=True): """Return a list of objects containing details for obj.""" - details = AXObject.get_relation_targets(obj, Atspi.RelationType.DETAILS) + details = AXUtilitiesRelation.get_details(obj) if not details and AXUtilities.is_toggle_button(obj) \ and AXUtilities.is_expanded(obj): details = [child for child in AXObject.iter_children(obj)] @@ -467,9 +468,7 @@ class Utilities: return name if AXObject.supports_text(obj): - # We should be able to use -1 for the final offset, but that crashes Nautilus. - text = obj.queryText() - displayedText = text.getText(0, text.characterCount) + displayedText = AXText.get_all_text(obj) if self.EMBEDDED_OBJECT_CHARACTER in displayedText: displayedText = None @@ -1429,7 +1428,7 @@ class Utilities: def isSwitch(self, obj): return False - def getObjectFromPath(self, path): + def get_objectFromPath(self, path): start = self._script.app rv = None for p in path: @@ -1548,7 +1547,7 @@ class Utilities: def isNotAncestor(acc): return not AXObject.find_ancestor(obj, lambda x: x == acc) - result = AXObject.get_relation_targets(obj, Atspi.RelationType.LABELLED_BY) + result = AXUtilitiesRelation.get_is_labelled_by(obj) return list(filter(isNotAncestor, result)) def linkBasenameToName(self, obj): @@ -1639,9 +1638,7 @@ class Utilities: if not obj: return -1 - try: - obj.queryText() - except NotImplementedError: + if not AXObject.supports_text(obj): return -1 try: @@ -1712,10 +1709,8 @@ class Utilities: node = obj done = False while not done: - relation = AXObject.get_relation(node, Atspi.RelationType.NODE_CHILD_OF) - node = None - if relation: - node = relation.get_target(0) + nodeOf = AXUtilitiesRelation.get_is_node_child_of(node) + node = nodeOf[0] if nodeOf else None # We want to avoid situations where something gives us an # infinite cycle of nodes. Bon Echo has been seen to do @@ -1843,7 +1838,7 @@ class Utilities: if not AXObject.supports_text(obj): return False - return bool(re.search(r"\w+", obj.queryText().getText(0, -1))) + return bool(re.search(r"\w+", AXText.get_all_text(obj))) def getOnScreenObjects(self, root, extents=None): if not self.isOnScreen(root, extents): @@ -2183,8 +2178,11 @@ class Utilities: return rv def getTextBoundingBox(self, obj, start, end): + if not AXObject.supports_text(obj): + return -1, -1, 0, 0 + try: - extents = obj.queryText().getRangeExtents(start, end, Atspi.CoordType.SCREEN) + extents = Atspi.Text.get_range_extents(obj, start, end, Atspi.CoordType.SCREEN) except Exception: tokens = ["SCRIPT UTILITIES: Exception getting range extents of", obj] debug.printTokens(debug.LEVEL_INFO, tokens, True) @@ -2251,7 +2249,7 @@ class Utilities: def _include(x): if not (x and AXObject.get_role(x) in labelRoles): return False - if AXObject.get_relations(x): + if AXUtilitiesRelation.get_relations(x): return False if onlyShowing and not AXUtilities.is_showing(x): return False @@ -2347,23 +2345,22 @@ class Utilities: depending on the direction of selection """ - try: - text = obj.queryText() - except Exception: + if not AXObject.supports_text(obj): return - if text.getNSelections() <= 0: - caretOffset = text.caretOffset + selections = AXText.get_selected_ranges(obj) + if not selections: + caretOffset = AXText.get_caret_offset(obj) startOffset = min(offset, caretOffset) endOffset = max(offset, caretOffset) - text.addSelection(startOffset, endOffset) else: - startOffset, endOffset = text.getSelection(0) + startOffset, endOffset = selections[0] if offset < startOffset: startOffset = offset else: endOffset = offset - text.setSelection(0, startOffset, endOffset) + + AXText.set_selected_text(obj, startOffset, endOffset) def findPreviousObject(self, obj): """Finds the object before this one.""" @@ -2371,11 +2368,11 @@ class Utilities: if not obj or self.isZombie(obj): return None - relation = AXObject.get_relation(obj, Atspi.RelationType.FLOWS_FROM) - if relation: - return relation.get_target(0) + flowsFrom = AXUtilitiesRelation.get_flows_from(obj) + if flowsFrom: + return flowsFrom[0] - return AXObject.get_previous_object(obj) + return AXUtilities.get_previous_object(obj) def findNextObject(self, obj): """Finds the object after this one.""" @@ -2383,11 +2380,11 @@ class Utilities: if not obj or self.isZombie(obj): return None - relation = AXObject.get_relation(obj, Atspi.RelationType.FLOWS_TO) - if relation: - return relation.get_target(0) + flowsTo = AXUtilitiesRelation.get_flows_to(obj) + if flowsTo: + return flowsTo[0] - return AXObject.get_next_object(obj) + return AXUtilities.get_next_object(obj) def allSelectedText(self, obj): """Get all the text applicable text selections for the given object. @@ -2441,20 +2438,7 @@ class Utilities: the text interface. """ - try: - text = obj.queryText() - except Exception: - return [] - - rv = [] - try: - nSelections = text.getNSelections() - except Exception: - nSelections = 0 - for i in range(nSelections): - rv.append(text.getSelection(i)) - - return rv + return AXText.get_selected_ranges(obj) def getChildAtOffset(self, obj, offset): try: @@ -2478,7 +2462,7 @@ class Utilities: debug.printTokens(debug.LEVEL_INFO, tokens, True) return None - child = hyperlink.getObject(0) + child = hyperlink.get_object(0) tokens = ["SCRIPT UTILITIES: Hyperlink object at index", index, "for", obj, "is", child] debug.printTokens(debug.LEVEL_INFO, tokens, True) if offset != hyperlink.startIndex: @@ -2547,16 +2531,14 @@ class Utilities: # some accessible text (as opposed to an imagemap link). # parent = AXObject.get_parent(obj) - try: - parent.queryText() + if AXObject.supports_text(parent): offset = hyperlink.startIndex - except Exception: + else: tokens = ["SCRIPT UTILITIES: Exception getting startIndex for", obj, "in parent", parent] debug.printTokens(debug.LEVEL_INFO, tokens, True) - else: - tokens = ["SCRIPT UTILITIES: startIndex of", obj, f"is {offset}"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + tokens = ["SCRIPT UTILITIES: startIndex of", obj, f"is {offset}"] + debug.printTokens(debug.LEVEL_INFO, tokens, True) return offset @@ -2567,20 +2549,14 @@ class Utilities: - obj: the Accessible object. """ - try: - text = obj.queryText() - except Exception: + if not AXObject.supports_text(obj): return - for i in range(text.getNSelections()): - text.removeSelection(i) + for i in range(AXText._get_n_selections(obj)): + AXText._remove_selection(obj, i) def containsOnlyEOCs(self, obj): - try: - string = obj.queryText().getText(0, -1) - except Exception: - return False - + string = AXText.get_all_text(obj) return string and not re.search(r"[^\ufffc]", string) def expandEOCs(self, obj, startOffset=0, endOffset=-1): @@ -2657,11 +2633,10 @@ class Utilities: return False def getCharacterAtOffset(self, obj, offset=None): - text = self.queryNonEmptyText(obj) - if text: + if AXObject.supports_text(obj): if offset is None: - offset = text.caretOffset - return text.getText(offset, offset + 1) + offset = AXText.get_caret_offset(obj) + return AXText.get_substring(obj, offset, offset + 1) return "" @@ -2673,17 +2648,12 @@ class Utilities: - obj: an accessible object """ - try: - text = obj.queryText() - charCount = text.characterCount - except NotImplementedError: - pass - except Exception: - tokens = ["SCRIPT UTILITIES: Exception getting character count of", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - else: - if charCount: - return text + if not AXObject.supports_text(obj): + return None + + charCount = AXText.get_character_count(obj) + if charCount: + return obj return None @@ -2700,7 +2670,7 @@ class Utilities: if AXUtilities.is_password_text(event.source): text = self.queryNonEmptyText(event.source) if text: - string = text.getText(0, -1) + string = AXText.get_all_text(event.source) if string: tokens = ["HACK: Returning last char in '", string, "'"] debug.printTokens(debug.LEVEL_INFO, tokens, True) @@ -2722,31 +2692,24 @@ class Utilities: textContents = "" startOffset = endOffset = 0 - try: - textObj = obj.queryText() - except Exception: - nSelections = 0 - else: - nSelections = textObj.getNSelections() - - for i in range(0, nSelections): - [startOffset, endOffset] = textObj.getSelection(i) - if startOffset == endOffset: + for start, end in AXText.get_selected_ranges(obj): + if start == end: continue - selectedText = self.expandEOCs(obj, startOffset, endOffset) - if i > 0: + selectedText = self.expandEOCs(obj, start, end) + if textContents: textContents += " " textContents += selectedText + if startOffset == endOffset == 0: + startOffset = start + endOffset = end return [textContents, startOffset, endOffset] def getCaretContext(self): obj = cthulhu_state.locusOfFocus - try: - offset = obj.queryText().caretOffset - except NotImplementedError: - offset = 0 - except Exception: + if AXObject.supports_text(obj): + offset = AXText.get_caret_offset(obj) + else: offset = -1 return obj, offset @@ -2766,12 +2729,7 @@ class Utilities: - obj: Given accessible object. - offset: Offset to hich to set the caret. """ - try: - texti = obj.queryText() - except Exception: - return None - - texti.setCaretOffset(offset) + AXText.set_caret_offset(obj, offset) def substring(self, obj, startOffset, endOffset): """Returns the substring of the given object's text specialization. @@ -2783,12 +2741,7 @@ class Utilities: of -1 means the last character """ - try: - text = obj.queryText() - except Exception: - return "" - - return text.getText(startOffset, endOffset) + return AXText.get_substring(obj, startOffset, endOffset) def getAppNameForAttribute(self, attribName): """Converts the given Atk attribute name into the application's @@ -2824,13 +2777,11 @@ class Utilities: def getAllTextAttributesForObject(self, obj, startOffset=0, endOffset=-1): """Returns a list of (start, end, attrsDict) tuples for obj.""" - try: - text = obj.queryText() - except Exception: + if not AXObject.supports_text(obj): return [] if endOffset == -1: - endOffset = text.characterCount + endOffset = AXText.get_character_count(obj) tokens = ["SCRIPT UTILITIES: Getting text attributes for", obj, f"chars: {startOffset}-{endOffset}"] @@ -2840,17 +2791,10 @@ class Utilities: rv = [] offset = startOffset while offset < endOffset: - try: - attrList, start, end = text.getAttributeRun(offset) - tokens = [f"SCRIPT UTILITIES: At {offset}:", attrList, f"({start}, {end})"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - except Exception as error: - msg = f"SCRIPT UTILITIES: Exception getting attributes at {offset}: {error}" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return rv - - attrDict = dict([attr.split(':', 1) for attr in attrList]) - rv.append((max(start, offset), end, attrDict)) + attrs, start, end = AXText.get_text_attributes_at_offset(obj, offset) + tokens = [f"SCRIPT UTILITIES: At {offset}:", attrs, f"({start}, {end})"] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + rv.append((max(start, offset), end, attrs)) offset = max(end, offset + 1) endTime = time.time() @@ -2873,27 +2817,14 @@ class Utilities: supprt the text attribute. """ - rv = {} - try: - text = acc.queryText() - except Exception: - return rv, 0, 0 - - if get_defaults: - stringAndDict = self.stringToKeysAndDict(text.getDefaultAttributes()) - rv.update(stringAndDict[1]) + if not AXObject.supports_text(acc): + return {}, 0, 0 if offset is None: - offset = text.caretOffset + offset = AXText.get_caret_offset(acc) - attrString, start, end = text.getAttributes(offset) - stringAndDict = self.stringToKeysAndDict(attrString) - rv.update(stringAndDict[1]) - - start = min(start, offset) - end = max(end, offset + 1) - - return rv, start, end + attrs, start, end = AXText.get_text_attributes_at_offset(acc, offset) + return attrs, min(start, offset), max(end, offset + 1) def localizeTextAttribute(self, key, value): if key == "weight" and (value == "bold" or int(value) > 400): @@ -3310,7 +3241,7 @@ class Utilities: if not self.lastInputEventWasPrintableKey(): return False - string = event.source.queryText().getText(0, -1) + string = AXText.get_all_text(event.source) if string.endswith(event.any_data): selection, start, end = self.selectedText(event.source) if selection == event.any_data: @@ -3607,7 +3538,7 @@ class Utilities: def getLineContentsAtOffset(self, obj, offset, layoutMode=True, useCache=True): return [] - def getObjectContentsAtOffset(self, obj, offset=0, useCache=True): + def get_objectContentsAtOffset(self, obj, offset=0, useCache=True): return [] def previousContext(self, obj=None, offset=-1, skipSpace=False): @@ -3626,7 +3557,7 @@ class Utilities: offset = 0 text = self.queryNonEmptyText(root) if text: - offset = text.characterCount - 1 + offset = AXText.get_character_count(root) - 1 return root, offset @@ -4218,7 +4149,7 @@ class Utilities: if not self.containsPoint(child, x, y, coordType): continue if self.queryNonEmptyText(child): - string = child.queryText().getText(0, -1) + string = AXText.get_all_text(child) if re.search(r"[^\ufffc\s]", string): candidates.append(child) if AXUtilities.is_showing(child): @@ -4242,19 +4173,17 @@ class Utilities: if not AXObject.supports_text(obj): return False - text = obj.queryText() - string = text.getText(0, -1) + string = AXText.get_all_text(obj) chunks = list(filter(lambda x: x.strip(), string.split("\n\n"))) return len(chunks) > 1 def getWordAtOffsetAdjustedForNavigation(self, obj, offset=None): - try: - text = obj.queryText() - if offset is None: - offset = text.caretOffset - except Exception: + if not AXObject.supports_text(obj): return "", 0, 0 + if offset is None: + offset = AXText.get_caret_offset(obj) + word, start, end = self.getWordAtOffset(obj, offset) prevObj, prevOffset = self._script.pointOfReference.get( "penultimateCursorPosition", (None, -1)) @@ -4272,7 +4201,7 @@ class Utilities: start = prevOffset end = offset - word = text.getText(start, end) + word = AXText.get_substring(obj, start, end) debugString = word.replace("\n", "\\n") msg = ( f"SCRIPT UTILITIES: Adjusted word at offset {offset} for ongoing word nav is " @@ -4306,11 +4235,11 @@ class Utilities: # If the character to the left of our present position is neither a space, nor # an alphanumeric character, then suspect that character is a navigation boundary # where we would have landed before via the native next word command. - lastChar = text.getText(offset - 1, offset) + lastChar = AXText.get_substring(obj, offset - 1, offset) if not (lastChar.isspace() or lastChar.isalnum()): start = offset - 1 - word = text.getText(start, end) + word = AXText.get_substring(obj, start, end) # We only want to present the newline character when we cross a boundary moving from one # word to another. If we're in the same word, strip it out. @@ -4319,9 +4248,9 @@ class Utilities: start += 1 elif word.endswith("\n"): end -= 1 - word = text.getText(start, end) + word = AXText.get_substring(obj, start, end) - word = text.getText(start, end) + word = AXText.get_substring(obj, start, end) debugString = word.replace("\n", "\\n") msg = ( f"SCRIPT UTILITIES: Adjusted word at offset {offset} for new word nav is " @@ -4331,14 +4260,13 @@ class Utilities: return word, start, end def getWordAtOffset(self, obj, offset=None): - try: - text = obj.queryText() - if offset is None: - offset = text.caretOffset - except Exception: + if not AXObject.supports_text(obj): return "", 0, 0 - word, start, end = text.getTextAtOffset(offset, Atspi.TextBoundaryType.WORD_START) + if offset is None: + offset = AXText.get_caret_offset(obj) + + word, start, end = AXText.get_word_at_offset(obj, offset) debugString = word.replace("\n", "\\n") msg = ( f"SCRIPT UTILITIES: Word at offset {offset} is " @@ -4359,18 +4287,46 @@ class Utilities: boundary = Atspi.TextBoundaryType.LINE_START x, y = self._adjustPointForObj(obj, x, y, coordType) - offset = text.getOffsetAtPoint(x, y, coordType) - if not 0 <= offset < text.characterCount: + try: + offset = Atspi.Text.get_offset_at_point(obj, x, y, coordType) + except Exception: return "", 0, 0 - string, start, end = text.getTextAtOffset(offset, boundary) + if not 0 <= offset < AXText.get_character_count(obj): + return "", 0, 0 + + 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: + try: + result = Atspi.Text.get_string_at_offset(obj, offset, boundary) + except Exception: + return "", 0, 0 + if result is None: + return "", 0, 0 + string = result.content + start = result.start_offset + end = result.end_offset + if not string: return "", start, end if boundary == Atspi.TextBoundaryType.WORD_START and not string.strip(): return "", 0, 0 - extents = text.getRangeExtents(start, end, coordType) + try: + extents = Atspi.Text.get_range_extents(obj, start, end, coordType) + except Exception: + return "", 0, 0 + if not self.containsRegion(extents, (x, y, 1, 1)) and string != "\n": return "", 0, 0 @@ -4636,13 +4592,13 @@ class Utilities: return replicant def getFunctionalChildCount(self, obj): - relation = AXObject.get_relation(obj, Atspi.RelationType.NODE_PARENT_OF) - if relation: - return relation.get_n_targets() + nodeParents = AXUtilitiesRelation.get_is_node_parent_of(obj) + if nodeParents: + return len(nodeParents) return AXObject.get_child_count(obj) def getFunctionalChildren(self, obj, sibling=None): - result = AXObject.get_relation_targets(obj, Atspi.RelationType.NODE_PARENT_OF) + result = AXUtilitiesRelation.get_is_node_parent_of(obj) if result: return result if self.isDescriptionListTerm(sibling): @@ -4652,9 +4608,9 @@ class Utilities: return [x for x in AXObject.iter_children(obj)] def getFunctionalParent(self, obj): - relation = AXObject.get_relation(obj, Atspi.RelationType.NODE_CHILD_OF) - if relation: - return relation.get_target(0) + nodeChildren = AXUtilitiesRelation.get_is_node_child_of(obj) + if nodeChildren: + return nodeChildren[0] return AXObject.get_parent(obj) def getPositionAndSetSize(self, obj, **args): @@ -4741,16 +4697,12 @@ class Utilities: return start, end, string def updateCachedTextSelection(self, obj): - try: - text = obj.queryText() - except NotImplementedError: + if not AXObject.supports_text(obj): tokens = ["SCRIPT UTILITIES:", obj, "doesn't implement AtspiText"] debug.printTokens(debug.LEVEL_INFO, tokens, True) text = None - except Exception: - tokens = ["SCRIPT UTILITIES: Exception querying text interface for", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - text = None + else: + text = obj if self._script.pointOfReference.get('entireDocumentSelected'): selectedText, selectedStart, selectedEnd = self.allSelectedText(obj) @@ -4771,14 +4723,9 @@ class Utilities: # selections in a single accessible object. start, end, string = 0, 0, '' if text: - try: - start, end = text.getSelection(0) - except Exception: - tokens = ["SCRIPT UTILITIES: Exception getting selected text for", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - start = end = 0 - if start != end: - string = text.getText(start, end) + string, start, end = AXText.get_selected_text(obj) + if string: + string = self.expandEOCs(obj, start, end) tokens = ["SCRIPT UTILITIES: New selection for", obj, f"is '{string}' ({start}, {end})"] debug.printTokens(debug.LEVEL_INFO, tokens, True) @@ -4843,7 +4790,7 @@ class Utilities: if not isinstance(event, input_event.KeyboardEvent): return False - return event.isPrintableKey() + return event.is_printable_key() def lastInputEventWasCommand(self): keyString, mods = self.lastKeyAndModifiers() @@ -5484,9 +5431,8 @@ class Utilities: self.handleTextSelectionChange(child, False) speakMessage = speakMessage and not _settingsManager.getSetting('onlySpeakDisplayedText') - text = obj.queryText() for start, end, message in changes: - string = text.getText(start, end) + string = AXText.get_substring(obj, start, end) endsWithChild = string.endswith(self.EMBEDDED_OBJECT_CHARACTER) if endsWithChild: end -= 1 @@ -5600,19 +5546,11 @@ class Utilities: debug.printMessage(debug.LEVEL_INFO, msg, True) return False - def isOld(target): - return target == oldLocusOfFocus - - def isNew(target): - return target == newLocusOfFocus - - if AXObject.get_relation_targets(newLocusOfFocus, - Atspi.RelationType.CONTROLLER_FOR, isOld): + if oldLocusOfFocus in AXUtilitiesRelation.get_is_controller_for(newLocusOfFocus): msg += "new locusOfFocus controls old locusOfFocus" debug.printMessage(debug.LEVEL_INFO, msg, True) return False - if AXObject.get_relation_targets(oldLocusOfFocus, - Atspi.RelationType.CONTROLLER_FOR, isNew): + if newLocusOfFocus in AXUtilitiesRelation.get_is_controller_for(oldLocusOfFocus): msg += "old locusOfFocus controls new locusOfFocus" debug.printMessage(debug.LEVEL_INFO, msg, True) return False diff --git a/src/cthulhu/scripts/apps/Eclipse/script.py b/src/cthulhu/scripts/apps/Eclipse/script.py index 426a43d..1060ace 100644 --- a/src/cthulhu/scripts/apps/Eclipse/script.py +++ b/src/cthulhu/scripts/apps/Eclipse/script.py @@ -34,6 +34,7 @@ __license__ = "LGPL" import cthulhu.debug as debug import cthulhu.cthulhu as cthulhu import cthulhu.scripts.toolkits.GAIL as GAIL +from cthulhu.ax_text import AXText from cthulhu.ax_utilities import AXUtilities ######################################################################## @@ -111,7 +112,7 @@ class Script(GAIL.Script): """ if self.utilities.isTextArea(event.source): - length = event.source.queryText().characterCount + length = AXText.get_character_count(event.source) if event.detail1 == 0 and event.detail2 == length: # seems to be generated by a reformat (ctrl+shift+f) # or by commenting some block (ctrl+/). @@ -137,7 +138,7 @@ class Script(GAIL.Script): def _saveLastTextPosition(self, obj): if self.utilities.isTextArea(obj): - self._saveLastCursorPosition(obj, obj.queryText().caretOffset) + self._saveLastCursorPosition(obj, AXText.get_caret_offset(obj)) def onSelectionChanged(self, event): """Callback for object:selection-changed accessibility events.""" @@ -148,4 +149,3 @@ class Script(GAIL.Script): return GAIL.Script.onSelectionChanged(self, event) - diff --git a/src/cthulhu/scripts/apps/Thunderbird/script.py b/src/cthulhu/scripts/apps/Thunderbird/script.py index 89c185c..a93a092 100644 --- a/src/cthulhu/scripts/apps/Thunderbird/script.py +++ b/src/cthulhu/scripts/apps/Thunderbird/script.py @@ -40,6 +40,7 @@ import cthulhu.settings_manager as settings_manager import cthulhu.cthulhu_state as cthulhu_state import cthulhu.scripts.toolkits.Gecko as Gecko from cthulhu.ax_object import AXObject +from cthulhu.ax_text import AXText from cthulhu.ax_utilities import AXUtilities from .spellcheck import SpellCheck @@ -130,7 +131,7 @@ class Script(Gecko.Script): return prefs - def locusOfFocusChanged(self, event, oldFocus, newFocus): + def locus_of_focus_changed(self, event, oldFocus, newFocus): """Handles changes of focus of interest to the script.""" if self.spellcheck.isSuggestionsItem(newFocus): @@ -140,7 +141,7 @@ class Script(Gecko.Script): self.spellcheck.presentSuggestionListItem(includeLabel=includeLabel) return - super().locusOfFocusChanged(event, oldFocus, newFocus) + super().locus_of_focus_changed(event, oldFocus, newFocus) def useFocusMode(self, obj, prevObj=None): if self.utilities.isEditableMessage(obj): @@ -312,12 +313,8 @@ class Script(Gecko.Script): # Mozilla cannot seem to get their ":system" suffix right # to save their lives, so we'll add yet another sad hack. - try: - text = event.source.queryText() - except Exception: - hasSelection = False - else: - hasSelection = text.getNSelections() > 0 + 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) @@ -335,9 +332,10 @@ class Script(Gecko.Script): return if self.utilities.isEditableMessage(obj) and self.spellcheck.isActive(): - text = obj.queryText() - selStart, selEnd = text.getSelection(0) - self.spellcheck.setDocumentPosition(obj, selStart) + selections = AXText.get_selected_ranges(obj) + if selections: + selStart, selEnd = selections[0] + self.spellcheck.setDocumentPosition(obj, selStart) return super().onTextSelectionChanged(event) diff --git a/src/cthulhu/scripts/apps/Thunderbird/spellcheck.py b/src/cthulhu/scripts/apps/Thunderbird/spellcheck.py index ced90b5..5e1f615 100644 --- a/src/cthulhu/scripts/apps/Thunderbird/spellcheck.py +++ b/src/cthulhu/scripts/apps/Thunderbird/spellcheck.py @@ -35,6 +35,7 @@ import cthulhu.cthulhu_state as cthulhu_state import cthulhu.spellcheck as spellcheck from cthulhu.ax_object import AXObject from cthulhu.ax_utilities import AXUtilities +from cthulhu.ax_utilities_relation import AXUtilitiesRelation class SpellCheck(spellcheck.SpellCheck): @@ -79,7 +80,7 @@ class SpellCheck(spellcheck.SpellCheck): def isError(x): return AXUtilities.is_label(x) \ and ":" not in AXObject.get_name(x) \ - and not AXObject.get_relations(x) + and not AXUtilitiesRelation.get_relations(x) return AXObject.find_descendant(root, isError) diff --git a/src/cthulhu/scripts/apps/gcalctool/script.py b/src/cthulhu/scripts/apps/gcalctool/script.py index 099b941..c09b460 100644 --- a/src/cthulhu/scripts/apps/gcalctool/script.py +++ b/src/cthulhu/scripts/apps/gcalctool/script.py @@ -50,7 +50,7 @@ class Script(gtk.Script): def __init__(self, app): """Creates a new script for the given application. Callers - should use the getScript factory method instead of calling + should use the get_script factory method instead of calling this constructor directly. Arguments: diff --git a/src/cthulhu/scripts/apps/gedit/script.py b/src/cthulhu/scripts/apps/gedit/script.py index ebc3b54..371eaec 100644 --- a/src/cthulhu/scripts/apps/gedit/script.py +++ b/src/cthulhu/scripts/apps/gedit/script.py @@ -68,7 +68,7 @@ class Script(gtk.Script): return self.spellcheck.getPreferencesFromGUI() - def locusOfFocusChanged(self, event, oldFocus, newFocus): + def locus_of_focus_changed(self, event, oldFocus, newFocus): """Handles changes of focus of interest to the script.""" if self.spellcheck.isSuggestionsItem(newFocus): @@ -78,7 +78,7 @@ class Script(gtk.Script): self.spellcheck.presentSuggestionListItem(includeLabel=includeLabel) return - super().locusOfFocusChanged(event, oldFocus, newFocus) + super().locus_of_focus_changed(event, oldFocus, newFocus) def onActiveDescendantChanged(self, event): """Callback for object:active-descendant-changed accessibility events.""" diff --git a/src/cthulhu/scripts/apps/gedit/spellcheck.py b/src/cthulhu/scripts/apps/gedit/spellcheck.py index ff4dc0f..6e81441 100644 --- a/src/cthulhu/scripts/apps/gedit/spellcheck.py +++ b/src/cthulhu/scripts/apps/gedit/spellcheck.py @@ -38,6 +38,7 @@ from gi.repository import Atspi import cthulhu.spellcheck as spellcheck from cthulhu.ax_object import AXObject from cthulhu.ax_utilities import AXUtilities +from cthulhu.ax_utilities_relation import AXUtilitiesRelation class SpellCheck(spellcheck.SpellCheck): @@ -73,7 +74,7 @@ class SpellCheck(spellcheck.SpellCheck): def isError(x): return AXUtilities.is_label(x) \ - and ":" not in AXObject.get_name(x) and not AXObject.get_relations(x) + and ":" not in AXObject.get_name(x) and not AXUtilitiesRelation.get_relations(x) return AXObject.find_descendant(panel, isError) diff --git a/src/cthulhu/scripts/apps/gnome-documents/script.py b/src/cthulhu/scripts/apps/gnome-documents/script.py index f8f926a..8a0bba5 100644 --- a/src/cthulhu/scripts/apps/gnome-documents/script.py +++ b/src/cthulhu/scripts/apps/gnome-documents/script.py @@ -33,6 +33,8 @@ __license__ = "LGPL" import cthulhu.scripts.toolkits.gtk as gtk import cthulhu.cthulhu_state as cthulhu_state +from cthulhu.ax_object import AXObject +from cthulhu.ax_text import AXText from cthulhu.ax_utilities import AXUtilities from .speech_generator import SpeechGenerator @@ -69,12 +71,8 @@ class Script(gtk.Script): # HACK: Reposition the caret offset from the last character to the # first so that SayAll will say all. - try: - text = cthulhu_state.locusOfFocus.queryText() - except NotImplementedError: - pass - else: - text.setCaretOffset(0) + if AXObject.supports_text(cthulhu_state.locusOfFocus): + AXText.set_caret_offset(cthulhu_state.locusOfFocus, 0) return self.sayAll(None) gtk.Script.onNameChanged(self, event) diff --git a/src/cthulhu/scripts/apps/gnome-shell/script.py b/src/cthulhu/scripts/apps/gnome-shell/script.py index 4789f1d..75dd8d2 100644 --- a/src/cthulhu/scripts/apps/gnome-shell/script.py +++ b/src/cthulhu/scripts/apps/gnome-shell/script.py @@ -34,6 +34,7 @@ import cthulhu.cthulhu as cthulhu import cthulhu.cthulhu_state as cthulhu_state import cthulhu.scripts.toolkits.clutter as clutter from cthulhu.ax_object import AXObject +from cthulhu.ax_text import AXText from cthulhu.ax_utilities import AXUtilities from .formatting import Formatting @@ -66,7 +67,7 @@ class Script(clutter.Script): return clutter.Script.skipObjectEvent(self, event) - def locusOfFocusChanged(self, event, oldFocus, newFocus): + 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) @@ -75,7 +76,7 @@ class Script(clutter.Script): debug.printMessage(debug.LEVEL_INFO, msg, True) return - super().locusOfFocusChanged(event, oldFocus, newFocus) + super().locus_of_focus_changed(event, oldFocus, newFocus) def onNameChanged(self, event): """Callback for object:property-change:accessible-name events.""" @@ -132,16 +133,15 @@ class Script(clutter.Script): clutter.Script.onFocusedChanged(self, event) def echoPreviousWord(self, obj, offset=None): - try: - text = obj.queryText() - except NotImplementedError: + if not AXObject.supports_text(obj): return False if not offset: - if text.caretOffset == -1: - offset = text.characterCount - 1 + caretOffset = AXText.get_caret_offset(obj) + if caretOffset == -1: + offset = AXText.get_character_count(obj) - 1 else: - offset = text.caretOffset - 1 + offset = caretOffset - 1 if offset == 0: return False diff --git a/src/cthulhu/scripts/apps/gnome-shell/script_utilities.py b/src/cthulhu/scripts/apps/gnome-shell/script_utilities.py index 4c6c4bc..d5958d7 100644 --- a/src/cthulhu/scripts/apps/gnome-shell/script_utilities.py +++ b/src/cthulhu/scripts/apps/gnome-shell/script_utilities.py @@ -32,6 +32,7 @@ __license__ = "LGPL" import cthulhu.debug as debug import cthulhu.script_utilities as script_utilities from cthulhu.ax_object import AXObject +from cthulhu.ax_text import AXText from cthulhu.ax_selection import AXSelection from cthulhu.ax_utilities import AXUtilities @@ -65,7 +66,7 @@ class Utilities(script_utilities.Utilities): text = self.queryNonEmptyText(event.source) if text: - string = text.getText(0, -1) + string = AXText.get_all_text(event.source) if string: msg = f"GNOME SHELL: Returning last char in '{string}'" debug.printMessage(debug.LEVEL_INFO, msg, True) @@ -85,8 +86,8 @@ class Utilities(script_utilities.Utilities): debug.printTokens(debug.LEVEL_INFO, tokens, True) text = self.queryNonEmptyText(obj) - if text.getNSelections() > 0: - string = text.getText(0, -1) + if AXText.get_selected_ranges(obj): + string = AXText.get_all_text(obj) start, end = 0, len(string) tokens = [f"GNOME SHELL: Returning '{string}' ({start}, {end}) for", obj] diff --git a/src/cthulhu/scripts/apps/pidgin/script_utilities.py b/src/cthulhu/scripts/apps/pidgin/script_utilities.py index 9784012..a16e090 100644 --- a/src/cthulhu/scripts/apps/pidgin/script_utilities.py +++ b/src/cthulhu/scripts/apps/pidgin/script_utilities.py @@ -42,6 +42,7 @@ import cthulhu.debug as debug import cthulhu.script_utilities as script_utilities from cthulhu.ax_object import AXObject from cthulhu.ax_utilities import AXUtilities +from cthulhu.ax_utilities_relation import AXUtilitiesRelation ############################################################################# # # @@ -104,11 +105,11 @@ class Utilities(script_utilities.Utilities): for i in range(row+1, table.nRows): cell = table.getAccessibleAt(i, col) nodeCell = AXObject.get_previous_sibling(cell) - relation = AXObject.get_relation(nodeCell, Atspi.RelationType.NODE_CHILD_OF) - if not relation: + nodeOf = AXUtilitiesRelation.get_is_node_child_of(nodeCell) + if not nodeOf: continue - nodeOf = relation.getTarget(0) + nodeOf = nodeOf[0] if self.isSameObject(obj, nodeOf): nodes.append(cell) elif self.nodeLevel(nodeOf) <= nodeLevel: @@ -143,10 +144,8 @@ class Utilities(script_utilities.Utilities): node = obj done = False while not done: - relation = AXObject.get_relation(node, Atspi.RelationType.NODE_CHILD_OF) - node = None - if relation: - node = relation.get_target(0) + nodeOf = AXUtilitiesRelation.get_is_node_child_of(node) + node = nodeOf[0] if nodeOf else None # We want to avoid situations where something gives us an # infinite cycle of nodes. Bon Echo has been seen to do diff --git a/src/cthulhu/scripts/apps/soffice/script.py b/src/cthulhu/scripts/apps/soffice/script.py index 70f0d71..dd3e9bd 100644 --- a/src/cthulhu/scripts/apps/soffice/script.py +++ b/src/cthulhu/scripts/apps/soffice/script.py @@ -49,6 +49,7 @@ import cthulhu.cthulhu_state as cthulhu_state import cthulhu.settings_manager as settings_manager import cthulhu.structural_navigation as structural_navigation from cthulhu.ax_object import AXObject +from cthulhu.ax_text import AXText from cthulhu.ax_utilities import AXUtilities from .braille_generator import BrailleGenerator @@ -323,21 +324,20 @@ class Script(default.Script): or not self.utilities.isTextArea(cthulhu_state.locusOfFocus): return default.Script.panBrailleLeft(self, inputEvent, panAmount) - text = cthulhu_state.locusOfFocus.queryText() - string, startOffset, endOffset = text.getTextAtOffset( - text.caretOffset, Atspi.TextBoundaryType.LINE_START) + 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: - text.setCaretOffset(startOffset-1) + AXText.set_caret_offset(cthulhu_state.locusOfFocus, startOffset - 1) return True obj = self.utilities.findPreviousObject(cthulhu_state.locusOfFocus) - try: - text = obj.queryText() - except Exception: - pass - else: + if AXObject.supports_text(obj): cthulhu.setLocusOfFocus(None, obj, notifyScript=False) - text.setCaretOffset(text.characterCount) + AXText.set_caret_offset(obj, AXText.get_character_count(obj)) return True return default.Script.panBrailleLeft(self, inputEvent, panAmount) @@ -353,21 +353,20 @@ class Script(default.Script): or not self.utilities.isTextArea(cthulhu_state.locusOfFocus): return default.Script.panBrailleRight(self, inputEvent, panAmount) - text = cthulhu_state.locusOfFocus.queryText() - string, startOffset, endOffset = text.getTextAtOffset( - text.caretOffset, Atspi.TextBoundaryType.LINE_START) - if endOffset < text.characterCount: - text.setCaretOffset(endOffset) + 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) return True obj = self.utilities.findNextObject(cthulhu_state.locusOfFocus) - try: - text = obj.queryText() - except Exception: - pass - else: + if AXObject.supports_text(obj): cthulhu.setLocusOfFocus(None, obj, notifyScript=False) - text.setCaretOffset(0) + AXText.set_caret_offset(obj, 0) return True return default.Script.panBrailleRight(self, inputEvent, panAmount) @@ -491,7 +490,7 @@ class Script(default.Script): return True - def locusOfFocusChanged(self, event, oldLocusOfFocus, newLocusOfFocus): + def locus_of_focus_changed(self, event, oldLocusOfFocus, newLocusOfFocus): """Called when the visual object with focus changes. Arguments: @@ -558,16 +557,13 @@ class Script(default.Script): voice = self.speechGenerator.voice(obj=newLocusOfFocus, string=string) self.speakMessage(string, voice=voice) self.updateBraille(newLocusOfFocus) - try: - text = newLocusOfFocus.queryText() - except Exception: - pass - else: - self._saveLastCursorPosition(newLocusOfFocus, text.caretOffset) + if AXObject.supports_text(newLocusOfFocus): + self._saveLastCursorPosition( + newLocusOfFocus, AXText.get_caret_offset(newLocusOfFocus)) return # Pass the event onto the parent class to be handled in the default way. - default.Script.locusOfFocusChanged(self, event, + default.Script.locus_of_focus_changed(self, event, oldLocusOfFocus, newLocusOfFocus) if not newLocusOfFocus: return @@ -952,14 +948,11 @@ class Script(default.Script): """To-be-removed. Returns the string, caretOffset, startOffset.""" if AXObject.get_role(AXObject.get_parent(obj)) == Atspi.Role.COMBO_BOX: - try: - text = obj.queryText() - except NotImplementedError: + if not AXObject.supports_text(obj): return ["", 0, 0] - if text.caretOffset < 0: - [lineString, startOffset, endOffset] = text.getTextAtOffset( - 0, Atspi.TextBoundaryType.LINE_START) + 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 # diff --git a/src/cthulhu/scripts/apps/soffice/script_utilities.py b/src/cthulhu/scripts/apps/soffice/script_utilities.py index dc7447a..1e667be 100644 --- a/src/cthulhu/scripts/apps/soffice/script_utilities.py +++ b/src/cthulhu/scripts/apps/soffice/script_utilities.py @@ -45,7 +45,9 @@ import cthulhu.cthulhu_state as cthulhu_state import cthulhu.script_utilities as script_utilities from cthulhu.ax_object import AXObject 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 ############################################################################# @@ -330,16 +332,12 @@ class Utilities(script_utilities.Utilities): @staticmethod def _flowsFromOrToSelection(obj): - relationSet = AXObject.get_relations(obj) + relationSet = AXUtilitiesRelation.get_relations(obj) flows = [Atspi.RelationType.FLOWS_FROM, Atspi.RelationType.FLOWS_TO] relations = filter(lambda r: r.getRelationType() in flows, relationSet) targets = [r.getTarget(0) for r in relations] for target in targets: - try: - nSelections = target.queryText().getNSelections() - except Exception: - return False - if nSelections: + if AXText.get_selected_ranges(target): return True return False @@ -494,11 +492,7 @@ class Utilities(script_utilities.Utilities): if event.type.startswith("focus:"): if lastKey == "Return": - try: - charCount = event.source.queryText().characterCount - except Exception: - charCount = 0 - return charCount > 0 + return AXText.get_character_count(event.source) > 0 return False @@ -598,9 +592,7 @@ class Utilities(script_utilities.Utilities): return AXSelection.get_selected_children(obj) def getFirstCaretPosition(self, obj): - try: - obj.queryText() - except Exception: + if not AXObject.supports_text(obj): if AXObject.get_child_count(obj): return self.getFirstCaretPosition(AXObject.get_child(obj, 0)) diff --git a/src/cthulhu/scripts/apps/soffice/speech_generator.py b/src/cthulhu/scripts/apps/soffice/speech_generator.py index c193108..2970d42 100644 --- a/src/cthulhu/scripts/apps/soffice/speech_generator.py +++ b/src/cthulhu/scripts/apps/soffice/speech_generator.py @@ -39,6 +39,7 @@ import cthulhu.messages as messages import cthulhu.settings_manager as settings_manager import cthulhu.speech_generator as speech_generator from cthulhu.ax_object import AXObject +from cthulhu.ax_text import AXText from cthulhu.ax_utilities import AXUtilities _settingsManager = settings_manager.getManager() @@ -342,24 +343,27 @@ class SpeechGenerator(speech_generator.SpeechGenerator): return [] result = [] - try: - text = obj.queryText() - objectText = \ - self._script.utilities.substring(obj, 0, -1) - extents = obj.queryComponent().getExtents(Atspi.CoordType.SCREEN) - except NotImplementedError: - pass - else: - tooLongCount = 0 - for i in range(0, len(objectText)): - [x, y, width, height] = text.getRangeExtents(i, i + 1, 0) - if x < extents.x: - tooLongCount += 1 - elif (x + width) > extents.x + extents.width: - tooLongCount += len(objectText) - i - break - if tooLongCount > 0: - result = [messages.charactersTooLong(tooLongCount)] + if AXObject.supports_text(obj): + objectText = self._script.utilities.substring(obj, 0, -1) + try: + extents = obj.queryComponent().getExtents(Atspi.CoordType.SCREEN) + except Exception: + extents = None + + if extents is not None: + tooLongCount = 0 + for i in range(0, len(objectText)): + rect = Atspi.Text.get_range_extents( + obj, i, i + 1, Atspi.CoordType.SCREEN) + x = rect.x + width = rect.width + if x < extents.x: + tooLongCount += 1 + elif (x + width) > extents.x + extents.width: + tooLongCount += len(objectText) - i + break + if tooLongCount > 0: + result = [messages.charactersTooLong(tooLongCount)] if result: result.extend(self.voice(speech_generator.SYSTEM, obj=obj, **args)) return result diff --git a/src/cthulhu/scripts/apps/soffice/spellcheck.py b/src/cthulhu/scripts/apps/soffice/spellcheck.py index 395e817..00ca72c 100644 --- a/src/cthulhu/scripts/apps/soffice/spellcheck.py +++ b/src/cthulhu/scripts/apps/soffice/spellcheck.py @@ -35,6 +35,7 @@ from cthulhu import debug from cthulhu import messages from cthulhu import spellcheck from cthulhu.ax_object import AXObject +from cthulhu.ax_text import AXText from cthulhu.ax_utilities import AXUtilities class SpellCheck(spellcheck.SpellCheck): @@ -115,17 +116,15 @@ class SpellCheck(spellcheck.SpellCheck): return index + 1, total def getMisspelledWord(self): - try: - text = self._errorWidget.queryText() - except Exception: + if not AXObject.supports_text(self._errorWidget): return "" offset, string = 0, "" - while 0 <= offset < text.characterCount: - attributes, start, end = text.getAttributeRun(offset, False) - attrs = dict([attr.split(":", 1) for attr in attributes]) + char_count = AXText.get_character_count(self._errorWidget) + while 0 <= offset < char_count: + attrs, start, end = AXText.get_text_attributes_at_offset(self._errorWidget, offset) if attrs.get("fg-color", "").replace(" ", "") == "255,0,0": - return text.getText(start, end) + return AXText.get_substring(self._errorWidget, start, end) offset = max(end, offset + 1) return string @@ -134,12 +133,10 @@ class SpellCheck(spellcheck.SpellCheck): if not self.isActive(): return False - try: - text = self._errorWidget.queryText() - except Exception: + if not AXObject.supports_text(self._errorWidget): return False - string = text.getText(0, -1) + string = AXText.get_all_text(self._errorWidget) if not string: return False diff --git a/src/cthulhu/scripts/default.py b/src/cthulhu/scripts/default.py index c5d4b85..b01e290 100644 --- a/src/cthulhu/scripts/default.py +++ b/src/cthulhu/scripts/default.py @@ -62,9 +62,11 @@ import cthulhu.sound as sound import cthulhu.speech as speech import cthulhu.speechserver as speechserver from cthulhu.ax_object import AXObject +from cthulhu.ax_text import AXText from cthulhu.ax_utilities import AXUtilities +from cthulhu.ax_utilities_relation import AXUtilitiesRelation -_scriptManager = script_manager.getManager() +_scriptManager = script_manager.get_manager() _settingsManager = settings_manager.getManager() ######################################################################## @@ -610,7 +612,7 @@ class Script(script.Script): def _saveFocusedObjectInfo(self, obj): """Saves some basic information about obj. Note that this method is - intended to be called primarily (if not only) by locusOfFocusChanged(). + 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.""" @@ -635,14 +637,11 @@ class Script(script.Script): # 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. - try: - text = obj.queryText() - caretOffset = text.caretOffset - except Exception: - pass - else: - self._saveLastCursorPosition(obj, max(0, caretOffset)) - self.utilities.updateCachedTextSelection(obj) + 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 @@ -655,7 +654,7 @@ class Script(script.Script): self.pointOfReference['selectedChange'] = hash(obj), AXUtilities.is_selected(obj) self.pointOfReference['expandedChange'] = hash(obj), AXUtilities.is_expanded(obj) - def locusOfFocusChanged(self, event, oldLocusOfFocus, newLocusOfFocus): + def locus_of_focus_changed(self, event, oldLocusOfFocus, newLocusOfFocus): """Called when the visual object with focus changes. Arguments: @@ -872,13 +871,14 @@ class Script(script.Script): # caret position, we will get a caret event, which will # then update the braille. # - text = cthulhu_state.locusOfFocus.queryText() - [lineString, startOffset, endOffset] = text.getTextAtOffset( - text.caretOffset, - Atspi.TextBoundaryType.LINE_START) + 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 = text.setCaretOffset(startOffset - 1) + 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 @@ -943,12 +943,13 @@ class Script(script.Script): # tacking mode. When we set the caret position, we will get a # caret event, which will then update the braille. # - text = cthulhu_state.locusOfFocus.queryText() - [lineString, startOffset, endOffset] = text.getTextAtOffset( - text.caretOffset, - Atspi.TextBoundaryType.LINE_START) - if endOffset < text.characterCount: - text.setCaretOffset(endOffset) + 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. @@ -1009,9 +1010,10 @@ class Script(script.Script): if caretOffset >= 0: self.utilities.adjustTextSelection(obj, caretOffset) - texti = obj.queryText() - startOffset, endOffset = texti.getSelection(0) - self.utilities.setClipboardText(texti.getText(startOffset, endOffset)) + selections = AXText.get_selected_ranges(obj) + if selections: + startOffset, endOffset = selections[0] + self.utilities.setClipboardText(AXText.get_substring(obj, startOffset, endOffset)) return True @@ -1097,17 +1099,13 @@ class Script(script.Script): self.presentMessage(messages.LOCATION_NOT_FOUND_FULL) return True - try: - text = obj.queryText() - except NotImplementedError: + if not AXObject.supports_text(obj): utterances = self.speechGenerator.generateSpeech(obj) utterances.extend(self.tutorialGenerator.getTutorial(obj, False)) speech.speak(utterances) - except AttributeError: - pass else: if offset is None: - offset = text.caretOffset + offset = AXText.get_caret_offset(obj) speech.sayAll(self.textLines(obj, offset), self.__sayAllProgressCallback) @@ -1321,16 +1319,17 @@ class Script(script.Script): if self.flatReviewPresenter.is_active(): self.flatReviewPresenter.quit() - text = event.source.queryText() - try: - text.caretOffset - except Exception as error: - tokens = ["DEFAULT: Exception getting caretOffset for", event.source, ":", error] + 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, text.caretOffset) - if text.getNSelections() > 0: + self._saveLastCursorPosition(event.source, caretOffset) + if AXText.get_selected_ranges(event.source): msg = "DEFAULT: Event source has text selections" debug.printMessage(debug.LEVEL_INFO, msg, True) self.utilities.handleTextSelectionChange(event.source) @@ -1663,8 +1662,8 @@ class Script(script.Script): return if _settingsManager.getSetting('speakMisspelledIndicator'): - offset = text.caretOffset - if not text.getText(offset, offset+1).isalnum(): + 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): @@ -1956,7 +1955,7 @@ class Script(script.Script): cthulhu.setLocusOfFocus(event, None) cthulhu.setActiveWindow(None) - _scriptManager.setActiveScript(None, "Window deactivated") + _scriptManager.set_active_script(None, "Window deactivated") def onClipboardContentsChanged(self, *args): if self.flatReviewPresenter.is_active(): @@ -2050,13 +2049,9 @@ class Script(script.Script): if context.endOffset - context.startOffset > minCharCount: break - try: - text = context.obj.queryText() - except Exception: - pass - else: + if AXObject.supports_text(context.obj): cthulhu.setLocusOfFocus(None, context.obj, notifyScript=False) - text.setCaretOffset(context.startOffset) + AXText.set_caret_offset(context.obj, context.startOffset) self.sayAll(None, context.obj, context.startOffset) return True @@ -2065,13 +2060,9 @@ class Script(script.Script): if not _settingsManager.getSetting('rewindAndFastForwardInSayAll'): return False - try: - text = context.obj.queryText() - except Exception: - pass - else: + if AXObject.supports_text(context.obj): cthulhu.setLocusOfFocus(None, context.obj, notifyScript=False) - text.setCaretOffset(context.endOffset) + AXText.set_caret_offset(context.obj, context.endOffset) self.sayAll(None, context.obj, context.endOffset) return True @@ -2082,11 +2073,9 @@ class Script(script.Script): # the visual progress of what is being spoken as well as # positioning the cursor when speech has stopped.]]] # - try: - text = context.obj.queryText() - char = text.getText(context.currentOffset, context.currentOffset+1) - except Exception: + 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. @@ -2110,16 +2099,17 @@ class Script(script.Script): self._inSayAll = False self._sayAllContexts = [] cthulhu.emitRegionChanged(context.obj, context.currentOffset) - text.setCaretOffset(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) - text.setCaretOffset(context.currentOffset) + AXText.set_caret_offset(context.obj, context.currentOffset) # If there is a selection, clear it. See bug #489504 for more details. # - if text.getNSelections() > 0: - text.setSelection(0, context.currentOffset, context.currentOffset) + if AXText.get_selected_ranges(context.obj): + AXText.set_selected_text( + context.obj, context.currentOffset, context.currentOffset) def inSayAll(self, treatInterruptedAsIn=True): if self._inSayAll: @@ -2151,20 +2141,17 @@ class Script(script.Script): interface. """ - try: - text = obj.queryText() - except NotImplementedError: + if not AXObject.supports_text(obj): return False - offset = text.caretOffset - 1 - previousOffset = text.caretOffset - 2 + caretOffset = AXText.get_caret_offset(obj) + offset = caretOffset - 1 + previousOffset = caretOffset - 2 if (offset < 0 or previousOffset < 0): return False - [currentChar, startOffset, endOffset] = \ - text.getTextAtOffset(offset, Atspi.TextBoundaryType.CHAR) - [previousChar, startOffset, endOffset] = \ - text.getTextAtOffset(previousOffset, Atspi.TextBoundaryType.CHAR) + 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 @@ -2173,16 +2160,15 @@ class Script(script.Script): # work our way to the beginning of the sentence, stopping when # we hit another sentence delimiter. # - sentenceEndOffset = text.caretOffset - 2 + sentenceEndOffset = caretOffset - 2 sentenceStartOffset = sentenceEndOffset while sentenceStartOffset >= 0: - [currentChar, startOffset, endOffset] = \ - text.getTextAtOffset(sentenceStartOffset, - Atspi.TextBoundaryType.CHAR) - [previousChar, startOffset, endOffset] = \ - text.getTextAtOffset(sentenceStartOffset-1, - Atspi.TextBoundaryType.CHAR) + 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: @@ -2225,24 +2211,20 @@ class Script(script.Script): end of the word. """ - try: - text = obj.queryText() - except NotImplementedError: + if not AXObject.supports_text(obj): return False if not offset: - if text.caretOffset == -1: - offset = text.characterCount + caretOffset = AXText.get_caret_offset(obj) + if caretOffset == -1: + offset = AXText.get_character_count(obj) else: - offset = text.caretOffset - 1 + offset = caretOffset - 1 if (offset < 0): return False - [char, startOffset, endOffset] = \ - text.getTextAtOffset( \ - offset, - Atspi.TextBoundaryType.CHAR) + char, _, _ = AXText.get_character_at_offset(obj, offset) if not self.utilities.isWordDelimiter(char): return False @@ -2255,10 +2237,7 @@ class Script(script.Script): wordStartOffset = wordEndOffset while wordStartOffset >= 0: - [char, startOffset, endOffset] = \ - text.getTextAtOffset( \ - wordStartOffset, - Atspi.TextBoundaryType.CHAR) + char, _, _ = AXText.get_character_at_offset(obj, wordStartOffset) if self.utilities.isWordDelimiter(char): break else: @@ -2292,8 +2271,10 @@ class Script(script.Script): interface """ - text = obj.queryText() - offset = text.caretOffset + 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 @@ -2304,8 +2285,7 @@ class Script(script.Script): and eventString in ["Right", "Down"]: offset -= 1 - character, startOffset, endOffset = text.getTextAtOffset( - offset, Atspi.TextBoundaryType.CHAR) + character, startOffset, endOffset = AXText.get_character_at_offset(obj, offset) cthulhu.emitRegionChanged(obj, startOffset, endOffset, cthulhu.CARET_TRACKING) if not character or character == '\r': @@ -2313,9 +2293,8 @@ class Script(script.Script): speakBlankLines = _settingsManager.getSetting('speakBlankLines') if character == "\n": - line = text.getTextAtOffset(max(0, offset), - Atspi.TextBoundaryType.LINE_START) - if not line[0] or line[0] == "\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: @@ -2419,13 +2398,12 @@ class Script(script.Script): def sayWord(self, obj): """Speaks the word at the caret, taking into account the previous caret position.""" - try: - text = obj.queryText() - offset = text.caretOffset - except Exception: + if not AXObject.supports_text(obj): self.sayCharacter(obj) return + offset = AXText.get_caret_offset(obj) + word, startOffset, endOffset = \ self.utilities.getWordAtOffsetAdjustedForNavigation(obj, offset) @@ -2437,7 +2415,7 @@ class Script(script.Script): startOffset += 1 elif word.endswith("\n"): endOffset -= 1 - word = text.getText(startOffset, endOffset) + word = AXText.get_substring(obj, startOffset, endOffset) # sayPhrase is useful because it handles punctuation verbalization, but we don't want # to trigger its whitespace presentation. @@ -2445,7 +2423,7 @@ class Script(script.Script): if matches: startOffset += matches[0].start() endOffset -= len(word) - matches[-1].end() - word = text.getText(startOffset, endOffset) + word = AXText.get_substring(obj, startOffset, endOffset) string = word.replace("\n", "\\n") msg = ( @@ -2637,27 +2615,25 @@ class Script(script.Script): """ self._sayAllIsInterrupted = False - try: - text = obj.queryText() - except Exception: + if not AXObject.supports_text(obj): self._inSayAll = False self._sayAllContexts = [] return self._inSayAll = True - length = text.characterCount + length = AXText.get_character_count(obj) if offset is None: - offset = text.caretOffset + offset = AXText.get_caret_offset(obj) # Determine the correct "say all by" mode to use. # sayAllStyle = _settingsManager.getSetting('sayAllStyle') if sayAllStyle == settings.SAYALL_STYLE_SENTENCE: - mode = Atspi.TextBoundaryType.SENTENCE_START + mode = "sentence" elif sayAllStyle == settings.SAYALL_STYLE_LINE: - mode = Atspi.TextBoundaryType.LINE_START + mode = "line" else: - mode = Atspi.TextBoundaryType.LINE_START + mode = "line" priorObj = obj @@ -2669,24 +2645,25 @@ class Script(script.Script): lastEndOffset = -1 while offset < length: - [lineString, startOffset, endOffset] = text.getTextAtOffset( - offset, mode) + 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 = Atspi.TextBoundaryType.LINE_START - [lineString, startOffset, endOffset] = \ - text.getTextAtOffset(offset, mode) + mode = "line" + lineString, startOffset, endOffset = AXText.get_line_at_offset(obj, offset) - if endOffset > text.characterCount: + if endOffset > length: tokens = ["DEFAULT: end offset", endOffset, " > character count", - text.characterCount, - "resulting from text.getTextAtOffset(", offset, mode, ") for", obj] + length, + "resulting from getTextAtOffset(", offset, mode, ") for", obj] debug.printTokens(debug.LEVEL_INFO, tokens, True) - endOffset = text.characterCount + endOffset = length # [[[WDW - HACK: this is here because getTextAtOffset # tends not to be implemented consistently across toolkits. @@ -2722,17 +2699,15 @@ class Script(script.Script): yield [context, voice] moreLines = False - relation = AXObject.get_relation(obj, Atspi.RelationType.FLOWS_TO) - if relation: + flowsTo = AXUtilitiesRelation.get_flows_to(obj) + if flowsTo: priorObj = obj - obj = relation.getTarget(0) + obj = flowsTo[0] - try: - text = obj.queryText() - except NotImplementedError: + if not AXObject.supports_text(obj): return - length = text.characterCount + length = AXText.get_character_count(obj) offset = 0 moreLines = True break @@ -2749,89 +2724,50 @@ class Script(script.Script): def getTextLineAtCaret(self, obj, offset=None, startOffset=None, endOffset=None): """To-be-removed. Returns the string, caretOffset, startOffset.""" - try: - text = obj.queryText() - offset = text.caretOffset - characterCount = text.characterCount - except NotImplementedError: - return ["", 0, 0] - except Exception: - tokens = ["DEFAULT: Exception getting offset and length for", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + 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) - # The offset might be positioned at the very end of the text area. - # In these cases, calling text.getTextAtOffset on an offset that's - # not positioned to a character can yield unexpected results. In - # particular, we'll see the Gecko toolkit return a start and end - # offset of (0, 0), and we'll see other implementations, such as - # gedit, return reasonable results (i.e., gedit will give us the - # last line). - # - # In order to accommodate the differing behavior of different - # AT-SPI implementations, we'll make sure we give getTextAtOffset - # the offset of an actual character. Then, we'll do a little check - # to see if that character is a newline - if it is, we'll treat it - # as the line. - # if targetOffset == characterCount: fixedTargetOffset = max(0, targetOffset - 1) - character = text.getText(fixedTargetOffset, fixedTargetOffset + 1) + character, _, _ = AXText.get_character_at_offset(obj, fixedTargetOffset) else: fixedTargetOffset = targetOffset character = None - if (targetOffset == characterCount) \ - and (character == "\n"): + if (targetOffset == characterCount) and (character == "\n"): lineString = "" startOffset = fixedTargetOffset else: - # Get the line containing the caret. [[[TODO: HACK WDW - If - # there's only 1 character in the string, well, we get it. We - # do this because Gecko's implementation of getTextAtOffset - # is broken if there is just one character in the string.]]] - # - if (characterCount == 1): - lineString = text.getText(fixedTargetOffset, fixedTargetOffset + 1) + if characterCount == 1: + lineString = AXText.get_substring(obj, fixedTargetOffset, fixedTargetOffset + 1) startOffset = fixedTargetOffset else: if fixedTargetOffset == -1: fixedTargetOffset = characterCount - try: - [lineString, startOffset, endOffset] = text.getTextAtOffset( - fixedTargetOffset, Atspi.TextBoundaryType.LINE_START) - - # Chrome fix: Handle case where get_line_at_offset returns the line - # after the offset, which seems to happen when the character at offset - # is an embedded object at a line boundary. - 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] = text.getTextAtOffset( - backup_offset, Atspi.TextBoundaryType.LINE_START) - - except Exception: - return ["", 0, 0] + 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) - # Sometimes we get the trailing line-feed-- remove it - # It is important that these are in order. - # In some circumstances we might get: - # word word\r\n - # so remove \n, and then remove \r. - # See bgo#619332. - # lineString = lineString.rstrip('\n') lineString = lineString.rstrip('\r') - return [lineString, text.caretOffset, startOffset] + return [lineString, offset, startOffset] def phoneticSpellCurrentItem(self, itemString): """Phonetically spell the current flat review word or line. @@ -2875,29 +2811,24 @@ class Script(script.Script): """ if _settingsManager.getSetting('speakMisspelledIndicator'): - try: - text = obj.queryText() - except Exception: + if not AXObject.supports_text(obj): return # If we're on whitespace, we cannot be on a misspelled word. # - charAndOffsets = \ - text.getTextAtOffset(offset, Atspi.TextBoundaryType.CHAR) - if not charAndOffsets[0].strip() \ - or self.utilities.isWordDelimiter(charAndOffsets[0]): - self._lastWordCheckedForSpelling = charAndOffsets[0] + char, _, _ = AXText.get_character_at_offset(obj, offset) + if not char.strip() or self.utilities.isWordDelimiter(char): + self._lastWordCheckedForSpelling = char return - wordAndOffsets = \ - text.getTextAtOffset(offset, Atspi.TextBoundaryType.WORD_START) + word, _, _ = AXText.get_word_at_offset(obj, offset) if self.utilities.isWordMisspelled(obj, offset) \ - and wordAndOffsets[0] != self._lastWordCheckedForSpelling: + 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 = wordAndOffsets[0] + self._lastWordCheckedForSpelling = word ############################################################################ # # @@ -2919,7 +2850,7 @@ class Script(script.Script): """Convenience method to present the KeyboardEvent event. Returns True if we fully present the event; False otherwise.""" - if not event.isPressedKey(): + if not event.is_pressed_key(): self._sayAllIsInterrupted = False self.utilities.clearCachedCommandState() @@ -2936,11 +2867,11 @@ class Script(script.Script): if role == Atspi.Role.PASSWORD_TEXT and not event.isLockingKey(): return False - if not event.isPressedKey(): + if not event.is_pressed_key(): return False braille.displayKeyEvent(event) - cthulhuModifierPressed = event.isCthulhuModifier() and event.isPressedKey() + cthulhuModifierPressed = event.isCthulhuModifier() and event.is_pressed_key() if event.isCharacterEchoable() and not cthulhuModifierPressed: return False @@ -3371,7 +3302,7 @@ class Script(script.Script): rather than calling speech.speakKeyEvent directly.""" string = None - if event.isPrintableKey(): + if event.is_printable_key(): string = event.event_string voice = self.speechGenerator.voice(string=string) diff --git a/src/cthulhu/scripts/sleepmode/__init__.py b/src/cthulhu/scripts/sleepmode/__init__.py index 185d4a1..bb5107e 100644 --- a/src/cthulhu/scripts/sleepmode/__init__.py +++ b/src/cthulhu/scripts/sleepmode/__init__.py @@ -25,7 +25,7 @@ """Sleep mode script for Cthulhu.""" -from .script import Script, getScript +from .script import Script, get_script -# Ensure getScript is available at module level -__all__ = ['Script', 'getScript'] \ No newline at end of file +# Ensure get_script is available at module level +__all__ = ['Script', 'get_script'] \ No newline at end of file diff --git a/src/cthulhu/scripts/sleepmode/script.py b/src/cthulhu/scripts/sleepmode/script.py index 2042856..dfb2271 100644 --- a/src/cthulhu/scripts/sleepmode/script.py +++ b/src/cthulhu/scripts/sleepmode/script.py @@ -151,6 +151,6 @@ class Script(default.Script): return True -def getScript(app): +def get_script(app): """Returns the script for the given application.""" return Script(app) \ No newline at end of file diff --git a/src/cthulhu/scripts/terminal/script.py b/src/cthulhu/scripts/terminal/script.py index ae3c41f..f098fcd 100644 --- a/src/cthulhu/scripts/terminal/script.py +++ b/src/cthulhu/scripts/terminal/script.py @@ -31,6 +31,8 @@ __license__ = "LGPL" from cthulhu import debug from cthulhu import cthulhu +from cthulhu.ax_object import AXObject +from cthulhu.ax_text import AXText from cthulhu.scripts import default from .braille_generator import BrailleGenerator @@ -107,19 +109,15 @@ class Script(default.Script): debug.printMessage(debug.LEVEL_INFO, msg, True) return - try: - text = event.source.queryText() - except Exception: - pass - else: - self._saveLastCursorPosition(event.source, text.caretOffset) + 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.isPrintableKey(): + if not event.is_printable_key(): return super().presentKeyboardEvent(event) - if event.isPressedKey(): + if event.is_pressed_key(): return False self._sayAllIsInterrupted = False @@ -129,14 +127,13 @@ class Script(default.Script): # 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. - try: - text = event.getObject().queryText() - offset = text.caretOffset - prevChar = text.getText(offset - 1, offset) - char = text.getText(offset, offset + 1) - except Exception: + 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 diff --git a/src/cthulhu/scripts/terminal/script_utilities.py b/src/cthulhu/scripts/terminal/script_utilities.py index 81a9c10..1596c83 100644 --- a/src/cthulhu/scripts/terminal/script_utilities.py +++ b/src/cthulhu/scripts/terminal/script_utilities.py @@ -40,6 +40,8 @@ from cthulhu import keybindings from cthulhu import cthulhu_state from cthulhu import script_utilities from cthulhu import settings_manager +from cthulhu.ax_object import AXObject +from cthulhu.ax_text import AXText from cthulhu.ax_utilities import AXUtilities _settingsManager = settings_manager.getManager() @@ -73,21 +75,15 @@ class Utilities(script_utilities.Utilities): if self.isClipboardTextChangedEvent(event): return event.any_data - try: - text = event.source.queryText() - except Exception: - tokens = ["ERROR: Exception querying text for", event.source] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + if not AXObject.supports_text(event.source): return event.any_data start, end = event.detail1, event.detail1 + len(event.any_data) - boundary = Atspi.TextBoundaryType.LINE_START - - firstLine = text.getTextAtOffset(start, boundary) + firstLine = AXText.get_line_at_offset(event.source, start) tokens = ["TERMINAL: First line of insertion:", firstLine] debug.printTokens(debug.LEVEL_INFO, tokens, True) - lastLine = text.getTextAtOffset(end - 1, boundary) + lastLine = AXText.get_line_at_offset(event.source, end - 1) tokens = ["TERMINAL: Last line of insertion:", firstLine] debug.printTokens(debug.LEVEL_INFO, tokens, True) @@ -96,7 +92,7 @@ class Utilities(script_utilities.Utilities): debug.printMessage(debug.LEVEL_INFO, msg, True) return event.any_data - currentLine = text.getTextAtOffset(text.caretOffset, boundary) + currentLine = AXText.get_line_at_offset(event.source, AXText.get_caret_offset(event.source)) tokens = ["TERMINAL: Current line:", firstLine] debug.printTokens(debug.LEVEL_INFO, tokens, True) @@ -111,7 +107,7 @@ class Utilities(script_utilities.Utilities): if lastLine[0].endswith("\n"): end -= 1 - adjusted = text.getText(start, end) + adjusted = AXText.get_substring(event.source, start, end) if adjusted: tokens = ["TERMINAL: Adjusted insertion: '", adjusted, "'"] debug.printTokens(debug.LEVEL_INFO, tokens, True) @@ -123,14 +119,10 @@ class Utilities(script_utilities.Utilities): return adjusted def insertionEndsAtCaret(self, event): - try: - text = event.source.queryText() - except Exception: - tokens = ["ERROR: Exception querying text for", event.source] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + if not AXObject.supports_text(event.source): return False - return text.caretOffset == event.detail1 + event.detail2 + return AXText.get_caret_offset(event.source) == event.detail1 + event.detail2 def isEditableTextArea(self, obj): if AXUtilities.is_terminal(obj): diff --git a/src/cthulhu/scripts/toolkits/Chromium/script.py b/src/cthulhu/scripts/toolkits/Chromium/script.py index a9086a3..b98de3d 100644 --- a/src/cthulhu/scripts/toolkits/Chromium/script.py +++ b/src/cthulhu/scripts/toolkits/Chromium/script.py @@ -73,15 +73,15 @@ class Script(web.Script): return super().isActivatableEvent(event) - def locusOfFocusChanged(self, event, oldFocus, newFocus): + def locus_of_focus_changed(self, event, oldFocus, newFocus): """Handles changes of focus of interest to the script.""" - if super().locusOfFocusChanged(event, oldFocus, newFocus): + 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.locusOfFocusChanged(self, event, oldFocus, newFocus) + default.Script.locus_of_focus_changed(self, event, oldFocus, newFocus) def onActiveChanged(self, event): """Callback for object:state-changed:active accessibility events.""" @@ -320,7 +320,7 @@ class Script(web.Script): 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.get_parent(event.source, AXUtilities.is_list_box): + if listbox := AXObject.find_ancestor_inclusive(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" diff --git a/src/cthulhu/scripts/toolkits/Chromium/script_utilities.py b/src/cthulhu/scripts/toolkits/Chromium/script_utilities.py index 537ed0a..a3110be 100644 --- a/src/cthulhu/scripts/toolkits/Chromium/script_utilities.py +++ b/src/cthulhu/scripts/toolkits/Chromium/script_utilities.py @@ -41,6 +41,7 @@ from cthulhu import cthulhu_state from cthulhu.scripts import web from cthulhu.ax_object import AXObject from cthulhu.ax_utilities import AXUtilities +from cthulhu.ax_utilities_relation import AXUtilitiesRelation class Utilities(web.Utilities): @@ -220,11 +221,11 @@ class Utilities(web.Utilities): return result def autocompleteForPopup(self, obj): - relation = AXObject.get_relation(obj, Atspi.RelationType.POPUP_FOR) - if not relation: + targets = AXUtilitiesRelation.get_is_popup_for(obj) + if not targets: return None - target = relation.get_target(0) + target = targets[0] if AXUtilities.is_autocomplete(target): return target diff --git a/src/cthulhu/scripts/toolkits/GAIL/script.py b/src/cthulhu/scripts/toolkits/GAIL/script.py index e2bfbbd..30b9dcd 100644 --- a/src/cthulhu/scripts/toolkits/GAIL/script.py +++ b/src/cthulhu/scripts/toolkits/GAIL/script.py @@ -46,7 +46,7 @@ class Script(default.Script): def getUtilities(self): return Utilities(self) - def locusOfFocusChanged(self, event, oldFocus, newFocus): + def locus_of_focus_changed(self, event, oldFocus, newFocus): """Handles changes of focus of interest to the script.""" if self.utilities.isInOpenMenuBarMenu(newFocus): @@ -55,7 +55,7 @@ class Script(default.Script): if windowChanged: cthulhu.setActiveWindow(window) - super().locusOfFocusChanged(event, oldFocus, newFocus) + super().locus_of_focus_changed(event, oldFocus, newFocus) def onActiveDescendantChanged(self, event): """Callback for object:active-descendant-changed accessibility events.""" diff --git a/src/cthulhu/scripts/toolkits/Gecko/script.py b/src/cthulhu/scripts/toolkits/Gecko/script.py index 54d14ff..c840234 100644 --- a/src/cthulhu/scripts/toolkits/Gecko/script.py +++ b/src/cthulhu/scripts/toolkits/Gecko/script.py @@ -62,15 +62,15 @@ class Script(web.Script): return super().isActivatableEvent(event) - def locusOfFocusChanged(self, event, oldFocus, newFocus): + def locus_of_focus_changed(self, event, oldFocus, newFocus): """Handles changes of focus of interest to the script.""" - if super().locusOfFocusChanged(event, oldFocus, newFocus): + 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.locusOfFocusChanged(self, event, oldFocus, newFocus) + default.Script.locus_of_focus_changed(self, event, oldFocus, newFocus) def onActiveChanged(self, event): """Callback for object:state-changed:active accessibility events.""" diff --git a/src/cthulhu/scripts/toolkits/WebKitGtk/script.py b/src/cthulhu/scripts/toolkits/WebKitGtk/script.py index 22c4c9f..883bc6f 100644 --- a/src/cthulhu/scripts/toolkits/WebKitGtk/script.py +++ b/src/cthulhu/scripts/toolkits/WebKitGtk/script.py @@ -48,6 +48,7 @@ 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_text import AXText from cthulhu.ax_utilities import AXUtilities from .braille_generator import BrailleGenerator @@ -288,7 +289,7 @@ class Script(default.Script): return boundary = Atspi.TextBoundaryType.CHAR - objects = self.utilities.getObjectsFromEOCs(obj, boundary=boundary) + objects = self.utilities.get_objectsFromEOCs(obj, boundary=boundary) for (obj, start, end, string) in objects: if string: self.speakCharacter(string) @@ -309,7 +310,7 @@ class Script(default.Script): return boundary = Atspi.TextBoundaryType.WORD_START - objects = self.utilities.getObjectsFromEOCs(obj, boundary=boundary) + objects = self.utilities.get_objectsFromEOCs(obj, boundary=boundary) for (obj, start, end, string) in objects: self.sayPhrase(obj, start, end) @@ -327,7 +328,7 @@ class Script(default.Script): return boundary = Atspi.TextBoundaryType.LINE_START - objects = self.utilities.getObjectsFromEOCs(obj, boundary=boundary) + objects = self.utilities.get_objectsFromEOCs(obj, boundary=boundary) for (obj, start, end, string) in objects: self.sayPhrase(obj, start, end) @@ -480,9 +481,22 @@ class Script(default.Script): def getTextSegments(self, obj, boundary, offset=0): segments = [] - text = obj.queryText() - length = text.characterCount - string, start, end = text.getTextAtOffset(offset, boundary) + 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) @@ -496,7 +510,18 @@ class Script(default.Script): segments.append([string, start, end, voice]) offset = end + 1 - string, start, end = text.getTextAtOffset(offset, boundary) + 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): @@ -539,7 +564,7 @@ class Script(default.Script): systemVoice = voices.get(settings.SYSTEM_VOICE) self._inSayAll = True - offset = textObjs[0].queryText().caretOffset + offset = AXText.get_caret_offset(textObjs[0]) for textObj in textObjs: textSegments = self.getTextSegments(textObj, boundary, offset) roleName = self.speechGenerator.getRoleName(textObj) @@ -567,8 +592,6 @@ class Script(default.Script): cthulhu.setLocusOfFocus(None, obj, notifyScript=False) offset = context.currentOffset - text = obj.queryText() - if progressType == speechserver.SayAllContext.INTERRUPTED: self._sayAllIsInterrupted = True if isinstance(cthulhu_state.lastInputEvent, input_event.KeyboardEvent): @@ -581,7 +604,8 @@ class Script(default.Script): self._inSayAll = False self._sayAllContexts = [] if not self._lastCommandWasStructNav: - text.setCaretOffset(offset) + if AXObject.supports_text(obj): + AXText.set_caret_offset(obj, offset) cthulhu.emitRegionChanged(obj, offset) return @@ -600,7 +624,8 @@ class Script(default.Script): return cthulhu.emitRegionChanged(obj, offset, mode=cthulhu.SAY_ALL) - text.setCaretOffset(offset) + 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.""" @@ -612,12 +637,8 @@ class Script(default.Script): return textLine textLine[0] = self.utilities.displayedText(obj) - try: - text = obj.queryText() - except Exception: - pass - else: - textLine[1] = min(textLine[1], text.characterCount) + if AXObject.supports_text(obj): + textLine[1] = min(textLine[1], AXText.get_character_count(obj)) return textLine diff --git a/src/cthulhu/scripts/toolkits/WebKitGtk/script_utilities.py b/src/cthulhu/scripts/toolkits/WebKitGtk/script_utilities.py index 73019a2..094b711 100644 --- a/src/cthulhu/scripts/toolkits/WebKitGtk/script_utilities.py +++ b/src/cthulhu/scripts/toolkits/WebKitGtk/script_utilities.py @@ -40,6 +40,7 @@ import cthulhu.keybindings as keybindings import cthulhu.cthulhu as cthulhu import cthulhu.cthulhu_state as cthulhu_state from cthulhu.ax_object import AXObject +from cthulhu.ax_text import AXText from cthulhu.ax_utilities import AXUtilities ############################################################################# @@ -120,13 +121,13 @@ class Utilities(script_utilities.Utilities): return text def getLineContentsAtOffset(self, obj, offset, layoutMode=True, useCache=True): - return self.getObjectsFromEOCs( + return self.get_objectsFromEOCs( obj, offset, Atspi.TextBoundaryType.LINE_START) - def getObjectContentsAtOffset(self, obj, offset=0, useCache=True): - return self.getObjectsFromEOCs(obj, offset) + def get_objectContentsAtOffset(self, obj, offset=0, useCache=True): + return self.get_objectsFromEOCs(obj, offset) - def getObjectsFromEOCs(self, obj, offset=None, boundary=None): + def get_objectsFromEOCs(self, obj, offset=None, boundary=None): """Breaks the string containing a mixture of text and embedded object characters into a list of (obj, startOffset, endOffset, string) tuples. @@ -138,27 +139,40 @@ class Utilities(script_utilities.Utilities): Returns a list of (obj, startOffset, endOffset, string) tuples. """ + if not AXObject.supports_text(obj): + return [(obj, 0, 1, '')] + try: - text = obj.queryText() htext = obj.queryHypertext() except (AttributeError, NotImplementedError): return [(obj, 0, 1, '')] - string = text.getText(0, -1) + string = AXText.get_all_text(obj) if not string: return [(obj, 0, 1, '')] if offset is None: - offset = text.caretOffset + offset = AXText.get_caret_offset(obj) if boundary is None: start = 0 - end = text.characterCount + end = AXText.get_character_count(obj) else: if boundary == Atspi.TextBoundaryType.CHAR: key, mods = self.lastKeyAndModifiers() if (mods & keybindings.SHIFT_MODIFIER_MASK) and key == 'Right': offset -= 1 - segment, start, end = text.getTextAtOffset(offset, boundary) + if boundary == Atspi.TextBoundaryType.CHAR: + segment, start, end = AXText.get_character_at_offset(obj, offset) + elif boundary == Atspi.TextBoundaryType.WORD_START: + segment, start, end = AXText.get_word_at_offset(obj, offset) + elif boundary == Atspi.TextBoundaryType.LINE_START: + segment, start, end = AXText.get_line_at_offset(obj, offset) + elif boundary == Atspi.TextBoundaryType.SENTENCE_START: + segment, start, end = AXText.get_sentence_at_offset(obj, offset) + elif boundary == Atspi.TextBoundaryType.PARAGRAPH_START: + segment, start, end = AXText.get_paragraph_at_offset(obj, offset) + else: + segment, start, end = "", 0, 0 pattern = re.compile(self.EMBEDDED_OBJECT_CHARACTER) offsets = [m.start(0) for m in re.finditer(pattern, string)] @@ -188,7 +202,7 @@ class Utilities(script_utilities.Utilities): if AXUtilities.is_link(obj): obj = AXObject.get_parent(obj) - prevObj = AXObject.get_previous_object(obj) + prevObj = AXUtilities.get_previous_object(obj) if AXUtilities.is_list(prevObj) and AXObject.get_child_count(prevObj): child = AXObject.get_child(prevObj, -1) if self.isTextListItem(child): @@ -205,7 +219,7 @@ class Utilities(script_utilities.Utilities): if AXUtilities.is_link(obj): obj = AXObject.get_parent(obj) - nextObj = AXObject.get_next_object(obj) + nextObj = AXUtilities.get_next_object(obj) if AXUtilities.is_list(nextObj) and AXObject.get_child_count(nextObj): child = AXObject.get_child(nextObj, 0) if self.isTextListItem(child): @@ -273,9 +287,9 @@ class Utilities(script_utilities.Utilities): return None, -1 index = -1 - text = child.queryText() - for i in range(text.characterCount): - if text.setCaretOffset(i): + char_count = AXText.get_character_count(child) + for i in range(char_count): + if AXText.set_caret_offset(child, i): index = i break diff --git a/src/cthulhu/scripts/toolkits/gtk/script.py b/src/cthulhu/scripts/toolkits/gtk/script.py index 902ec31..8234918 100644 --- a/src/cthulhu/scripts/toolkits/gtk/script.py +++ b/src/cthulhu/scripts/toolkits/gtk/script.py @@ -52,7 +52,7 @@ class Script(default.Script): self.utilities.clearCachedObjects() super().deactivate() - def locusOfFocusChanged(self, event, oldFocus, newFocus): + def locus_of_focus_changed(self, event, oldFocus, newFocus): """Handles changes of focus of interest to the script.""" if self.utilities.isToggleDescendantOfComboBox(newFocus): @@ -64,7 +64,7 @@ class Script(default.Script): if windowChanged: cthulhu.setActiveWindow(window) - super().locusOfFocusChanged(event, oldFocus, newFocus) + super().locus_of_focus_changed(event, oldFocus, newFocus) def onActiveDescendantChanged(self, event): """Callback for object:active-descendant-changed accessibility events.""" diff --git a/src/cthulhu/scripts/toolkits/gtk/script_utilities.py b/src/cthulhu/scripts/toolkits/gtk/script_utilities.py index 3892bf9..62c31e7 100644 --- a/src/cthulhu/scripts/toolkits/gtk/script_utilities.py +++ b/src/cthulhu/scripts/toolkits/gtk/script_utilities.py @@ -38,7 +38,9 @@ import cthulhu.debug as debug import cthulhu.script_utilities as script_utilities import cthulhu.cthulhu_state as cthulhu_state from cthulhu.ax_object import AXObject +from cthulhu.ax_text import AXText from cthulhu.ax_utilities import AXUtilities +from cthulhu.ax_utilities_relation import AXUtilitiesRelation class Utilities(script_utilities.Utilities): @@ -148,7 +150,7 @@ class Utilities(script_utilities.Utilities): and AXObject.find_ancestor(obj, AXUtilities.is_window) is not None def isPopOver(self, obj): - return AXObject.has_relation(obj, Atspi.RelationType.POPUP_FOR) + return bool(AXUtilitiesRelation.get_is_popup_for(obj)) def isSameObject(self, obj1, obj2, comparePaths=False, ignoreNames=False, ignoreDescriptions=True): @@ -211,7 +213,8 @@ class Utilities(script_utilities.Utilities): return x, y objBox = obj.queryComponent().getExtents(coordType) - stringBox = text.getRangeExtents(0, text.characterCount, coordType) + stringBox = Atspi.Text.get_range_extents( + obj, 0, AXText.get_character_count(obj), coordType) if self.intersection(objBox, stringBox) != (0, 0, 0, 0): return x, y @@ -224,7 +227,7 @@ class Utilities(script_utilities.Utilities): # Window Coordinates should be relative to the window; not the widget. # But broken interface is broken, and this appears to be what is being # exposed. And we need this information to get the widget's x and y. - charExtents = text.getCharacterExtents(0, Atspi.CoordType.WINDOW) + charExtents = Atspi.Text.get_character_extents(obj, 0, Atspi.CoordType.WINDOW) if 0 < charExtents[0] < charExtents[2]: boxX -= charExtents[0] if 0 < charExtents[1] < charExtents[3]: diff --git a/src/cthulhu/scripts/web/bookmarks.py b/src/cthulhu/scripts/web/bookmarks.py index 8b87d47..9fb790f 100644 --- a/src/cthulhu/scripts/web/bookmarks.py +++ b/src/cthulhu/scripts/web/bookmarks.py @@ -64,7 +64,7 @@ class Bookmarks(bookmarks.Bookmarks): return self._script.utilities.setCaretPosition(obj, offset) - contents = self._script.utilities.getObjectContentsAtOffset(obj, offset) + contents = self._script.utilities.get_objectContentsAtOffset(obj, offset) self._script.speakContents(contents) self._script.displayContents(contents) self._currentbookmarkindex[index[1]] = index[0] diff --git a/src/cthulhu/scripts/web/script.py b/src/cthulhu/scripts/web/script.py index 7116285..5a309e6 100644 --- a/src/cthulhu/scripts/web/script.py +++ b/src/cthulhu/scripts/web/script.py @@ -52,6 +52,7 @@ from cthulhu import structural_navigation from cthulhu.acss import ACSS from cthulhu.scripts import default from cthulhu.ax_object import AXObject +from cthulhu.ax_text import AXText from cthulhu.ax_utilities import AXUtilities from .bookmarks import Bookmarks @@ -607,7 +608,7 @@ class Script(default.Script): self._lastCommandWasMouseButton = False return consumes - if not keyboardEvent.isModifierKey(): + if not keyboardEvent.is_modifier_key(): self._lastCommandWasCaretNav = False self._lastCommandWasStructNav = False self._lastCommandWasMouseButton = False @@ -751,7 +752,8 @@ class Script(default.Script): """Updates the context and presents the find results if appropriate.""" text = self.utilities.queryNonEmptyText(obj) - if not (text and text.getNSelections() > 0): + selections = AXText.get_selected_ranges(obj) if text else [] + if not selections: return document = self.utilities.getDocumentForObject(obj) @@ -759,7 +761,7 @@ class Script(default.Script): return context = self.utilities.getCaretContext(documentFrame=document) - start, end = text.getSelection(0) + start, end = selections[0] offset = max(offset, start) self.utilities.setCaretContext(obj, offset, documentFrame=document) if end - start < _settingsManager.getSetting('findResultsMinimumLength'): @@ -968,7 +970,7 @@ class Script(default.Script): contents = None if self.utilities.treatAsEndOfLine(obj, offset) and AXObject.supports_text(obj): - char = obj.queryText().getText(offset, offset + 1) + char = AXText.get_substring(obj, offset, offset + 1) if char == self.EMBEDDED_OBJECT_CHARACTER: char = "" contents = [[obj, offset, offset + 1, char]] @@ -1056,7 +1058,7 @@ class Script(default.Script): # danger of presented irrelevant context. useCache = False offset = args.get("offset", 0) - contents = self.utilities.getObjectContentsAtOffset(obj, offset, useCache) + contents = self.utilities.get_objectContentsAtOffset(obj, offset, useCache) self.displayContents(contents) self.speakContents(contents, **args) @@ -1064,7 +1066,7 @@ class Script(default.Script): """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 text.getText(0, -1): + if text and self.EMBEDDED_OBJECT_CHARACTER in AXText.get_all_text(obj): self.updateBraille(obj) return @@ -1108,7 +1110,7 @@ class Script(default.Script): if offset > 0 and isContentEditable: text = self.utilities.queryNonEmptyText(obj) if text: - offset = min(offset, text.characterCount) + offset = min(offset, AXText.get_character_count(obj)) contents = self.utilities.getLineContentsAtOffset(obj, offset) self.displayContents(contents, documentFrame=document) @@ -1272,13 +1274,10 @@ class Script(default.Script): text = self.utilities.queryNonEmptyText(obj) if offset is None: - try: - offset = max(0, text.caretOffset) - except Exception: - offset = 0 + offset = max(0, AXText.get_caret_offset(obj)) if text and startOffset is not None and endOffset is not None: - return text.getText(startOffset, endOffset), offset, startOffset + return AXText.get_substring(obj, startOffset, endOffset), offset, startOffset contextObj, contextOffset = self.utilities.getCaretContext(documentFrame=None) if contextObj == obj: @@ -1321,7 +1320,7 @@ class Script(default.Script): if AXUtilities.is_focusable(obj): obj.queryComponent().grabFocus() - contents = self.utilities.getObjectContentsAtOffset(obj, offset) + contents = self.utilities.get_objectContentsAtOffset(obj, offset) self.utilities.setCaretPosition(obj, offset) self.speakContents(contents) self.updateBraille(obj) @@ -1332,7 +1331,7 @@ class Script(default.Script): obj, offset = self._preMouseOverContext self.utilities.setCaretPosition(obj, offset) - self.speakContents(self.utilities.getObjectContentsAtOffset(obj, offset)) + self.speakContents(self.utilities.get_objectContentsAtOffset(obj, offset)) self.updateBraille(obj) self._inMouseOverObject = False self._lastMouseOverObject = None @@ -1518,7 +1517,7 @@ class Script(default.Script): """Activates clickable element at current focus via Return key.""" return self._tryClickableActivation(inputEvent) - def locusOfFocusChanged(self, event, oldFocus, newFocus): + 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): @@ -1564,8 +1563,10 @@ class Script(default.Script): newFocus, offset = self.utilities.findFirstCaretContext(newFocus, 0) text = self.utilities.queryNonEmptyText(newFocus) - if text and (0 <= text.caretOffset <= text.characterCount): - caretOffset = text.caretOffset + if text: + textOffset = AXText.get_caret_offset(newFocus) + if 0 <= textOffset <= AXText.get_character_count(newFocus): + caretOffset = textOffset self.utilities.setCaretContext(newFocus, caretOffset, document) self.updateBraille(newFocus, documentFrame=document) @@ -1602,7 +1603,7 @@ class Script(default.Script): elif AXUtilities.is_heading(newFocus): tokens = ["WEB: New focus", newFocus, "is heading. Generating object."] debug.printTokens(debug.LEVEL_INFO, tokens, True) - contents = self.utilities.getObjectContentsAtOffset(newFocus, 0) + 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) @@ -2790,8 +2791,8 @@ class Script(default.Script): debug.printMessage(debug.LEVEL_INFO, msg, True) return False - offset = text.caretOffset - char = text.getText(offset, offset+1) + 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(): diff --git a/src/cthulhu/scripts/web/script_utilities.py b/src/cthulhu/scripts/web/script_utilities.py index c5f82f9..d1d3c31 100644 --- a/src/cthulhu/scripts/web/script_utilities.py +++ b/src/cthulhu/scripts/web/script_utilities.py @@ -48,10 +48,13 @@ from cthulhu import script_utilities from cthulhu import script_manager from cthulhu import settings_manager from cthulhu.ax_collection import AXCollection +from cthulhu.ax_document import AXDocument from cthulhu.ax_object import AXObject +from cthulhu.ax_text import AXText from cthulhu.ax_utilities import AXUtilities +from cthulhu.ax_utilities_relation import AXUtilitiesRelation -_scriptManager = script_manager.getManager() +_scriptManager = script_manager.get_manager() _settingsManager = settings_manager.getManager() @@ -265,7 +268,7 @@ class Utilities(script_utilities.Utilities): return rv def _getDocumentsEmbeddedBy(self, frame): - return AXObject.get_relation_targets(frame, Atspi.RelationType.EMBEDS, self.isDocument) + return [x for x in AXUtilitiesRelation.get_embeds(frame) if self.isDocument(x)] def sanityCheckActiveWindow(self): app = self._script.app @@ -277,7 +280,7 @@ class Utilities(script_utilities.Utilities): # TODO - JD: Is this exception handling still needed? try: - script = _scriptManager.getScript(app, cthulhu_state.activeWindow) + script = _scriptManager.get_script(app, cthulhu_state.activeWindow) tokens = ["WEB: Script for active Window is", script] debug.printTokens(debug.LEVEL_INFO, tokens, True) except Exception: @@ -317,16 +320,9 @@ class Utilities(script_utilities.Utilities): def documentFrameURI(self, documentFrame=None): documentFrame = documentFrame or self.documentFrame() if documentFrame: - try: - document = documentFrame.queryDocument() - except NotImplementedError: - tokens = ["WEB:", documentFrame, "does not implement document interface"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - except Exception: - tokens = ["ERROR: Exception querying document interface of", documentFrame] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - else: - return document.getAttributeValue('DocURL') or document.getAttributeValue('URI') + uri = AXDocument.get_uri(documentFrame) + if uri: + return uri return "" @@ -339,20 +335,10 @@ class Utilities(script_utilities.Utilities): if rv is not None: return rv - try: - document = documentFrame.queryDocument() - attrs = dict([attr.split(":", 1) for attr in document.getAttributes()]) - except NotImplementedError: - tokens = ["WEB:", documentFrame, "does not implement document interface"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - except Exception: - tokens = ["ERROR: Exception getting document attributes of", documentFrame] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - else: - rv = attrs.get("MimeType") - tokens = ["WEB: MimeType of", documentFrame, "is '", rv, "'"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - self._mimeType[hash(documentFrame)] = rv + rv = AXDocument.get_mime_type(documentFrame) + tokens = ["WEB: MimeType of", documentFrame, "is '", rv, "'"] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + self._mimeType[hash(documentFrame)] = rv return rv @@ -397,13 +383,11 @@ class Utilities(script_utilities.Utilities): # Don't use queryNonEmptyText() because we need to try to force-update focus. if AXObject.supports_text(obj): - try: - obj.queryText().setCaretOffset(offset) - except Exception as error: - tokens = ["WEB: Exception setting caret to", offset, "in", obj, ":", error] + if AXText.set_caret_offset(obj, offset): + tokens = ["WEB: Caret set to", offset, "in", obj] debug.printTokens(debug.LEVEL_INFO, tokens, True) else: - tokens = ["WEB: Caret set to", offset, "in", obj] + tokens = ["WEB: Exception setting caret to", offset, "in", obj] debug.printTokens(debug.LEVEL_INFO, tokens, True) if self._script.useFocusMode(obj, oldFocus) != self._script.inFocusMode(): @@ -419,9 +403,9 @@ class Utilities(script_utilities.Utilities): if not obj: return None - relation = AXObject.get_relation(obj, Atspi.RelationType.FLOWS_TO) - if relation: - return relation.get_target(0) + flowsTo = AXUtilitiesRelation.get_flows_to(obj) + if flowsTo: + return flowsTo[0] if obj == documentFrame: obj, offset = self.getCaretContext(documentFrame) @@ -628,7 +612,7 @@ class Utilities(script_utilities.Utilities): nextobj, nextoffset = self.findNextCaretInOrder(obj, offset) if skipSpace: text = self.queryNonEmptyText(nextobj) - while text and text.getText(nextoffset, nextoffset + 1) in [" ", "\xa0"]: + while text and AXText.get_substring(nextobj, nextoffset, nextoffset + 1) in [" ", "\xa0"]: nextobj, nextoffset = self.findNextCaretInOrder(nextobj, nextoffset) text = self.queryNonEmptyText(nextobj) @@ -641,7 +625,7 @@ class Utilities(script_utilities.Utilities): prevobj, prevoffset = self.findPreviousCaretInOrder(obj, offset) if skipSpace: text = self.queryNonEmptyText(prevobj) - while text and text.getText(prevoffset, prevoffset + 1) in [" ", "\xa0"]: + while text and AXText.get_substring(prevobj, prevoffset, prevoffset + 1) in [" ", "\xa0"]: prevobj, prevoffset = self.findPreviousCaretInOrder(prevobj, prevoffset) text = self.queryNonEmptyText(prevobj) @@ -651,7 +635,7 @@ class Utilities(script_utilities.Utilities): offset = 0 text = self.queryNonEmptyText(root) if text: - offset = text.characterCount - 1 + offset = AXText.get_character_count(root) - 1 def _isInRoot(o): return o == root or AXObject.find_ancestor(o, lambda x: x == root) @@ -706,24 +690,23 @@ class Utilities(script_utilities.Utilities): return [0, 0, 0, 0] result = [0, 0, 0, 0] - try: - text = obj.queryText() - if text.characterCount and 0 <= startOffset < endOffset: - result = list(text.getRangeExtents(startOffset, endOffset, 0)) - except NotImplementedError: - pass - except Exception: - tokens = ["WEB: Exception getting range extents for", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return [0, 0, 0, 0] - else: - if result[0] and result[1] and result[2] == 0 and result[3] == 0 \ - and text.getText(startOffset, endOffset).strip(): - tokens = ["WEB: Suspected bogus range extents for", - obj, "(chars:", startOffset, ",", endOffset, "):", result] + if AXObject.supports_text(obj): + try: + char_count = AXText.get_character_count(obj) + if char_count and 0 <= startOffset < endOffset: + result = list(Atspi.Text.get_range_extents( + obj, startOffset, endOffset, Atspi.CoordType.SCREEN)) + except Exception as error: + tokens = ["WEB: Exception getting range extents for", obj, ":", error] debug.printTokens(debug.LEVEL_INFO, tokens, True) - elif text.characterCount: - return result + else: + if result[0] and result[1] and result[2] == 0 and result[3] == 0 \ + and AXText.get_substring(obj, startOffset, endOffset).strip(): + tokens = ["WEB: Suspected bogus range extents for", + obj, "(chars:", startOffset, ",", endOffset, "):", result] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + elif char_count: + return result parent = AXObject.get_parent(obj) if (AXUtilities.is_menu(obj) or AXUtilities.is_list_item(obj)) \ @@ -802,7 +785,7 @@ class Utilities(script_utilities.Utilities): text = self.queryNonEmptyText(obj) if text: - return text.getText(startOffset, endOffset) + return AXText.get_substring(obj, startOffset, endOffset) return "" @@ -1112,11 +1095,11 @@ class Utilities(script_utilities.Utilities): return False - def __findRange(self, text, offset, start, end, boundary): + def __findRange(self, obj, offset, start, end, boundary): # We should not have to do any of this. Seriously. This is why # We can't have nice things. - allText = text.getText(0, -1) + allText = AXText.get_all_text(obj) if boundary == Atspi.TextBoundaryType.CHAR: try: string = allText[offset] @@ -1125,15 +1108,18 @@ class Utilities(script_utilities.Utilities): return string, offset, offset + 1 - extents = list(text.getRangeExtents(offset, offset + 1, 0)) + extents = list(Atspi.Text.get_range_extents( + obj, offset, offset + 1, Atspi.CoordType.SCREEN)) def _inThisSpan(span): return span[0] <= offset <= span[1] def _onThisLine(span): start, end = span - startExtents = list(text.getRangeExtents(start, start + 1, 0)) - endExtents = list(text.getRangeExtents(end - 1, end, 0)) + startExtents = list(Atspi.Text.get_range_extents( + obj, start, start + 1, Atspi.CoordType.SCREEN)) + endExtents = list(Atspi.Text.get_range_extents( + obj, end - 1, end, Atspi.CoordType.SCREEN)) delta = max(startExtents[3], endExtents[3]) if not self.extentsAreOnSameLine(startExtents, endExtents, delta): tokens = ["FAIL: Start", startExtents, "and end", endExtents, @@ -1144,7 +1130,7 @@ class Utilities(script_utilities.Utilities): return self.extentsAreOnSameLine(extents, startExtents) spans = [] - charCount = text.characterCount + charCount = AXText.get_character_count(obj) if boundary == Atspi.TextBoundaryType.SENTENCE_START: spans = [m.span() for m in re.finditer( r"\S*[^\.\?\!]+((? 0.3: return False AXObject.clear_cache(obj) - tokens = list(filter(lambda x: x, re.split(r"[\s\ufffc]", text.getText(0, -1)))) + tokens = list(filter(lambda x: x, re.split(r"[\s\ufffc]", AXText.get_all_text(obj)))) # Note: We cannot check for the editable-text interface, because Gecko # seems to be exposing that for non-editable things. Thanks Gecko. @@ -3235,7 +3225,7 @@ class Utilities(script_utilities.Utilities): boundary = Atspi.TextBoundaryType.LINE_START i = 0 while i < nChars: - string, start, end = text.getTextAtOffset(i, boundary) + string, start, end = AXText.get_line_at_offset(obj, i) if len(string.split()) != 1: rv = False break @@ -3256,10 +3246,7 @@ class Utilities(script_utilities.Utilities): if not text: return False - try: - nChars = text.characterCount - except Exception: - return False + nChars = AXText.get_character_count(obj) if not nChars: return False @@ -3270,7 +3257,7 @@ class Utilities(script_utilities.Utilities): # CSSified text we're trying to detect can have embedded object characters. So # if we have more than 30% EOCs, don't use this workaround. (The 30% is based on # testing with problematic text.) - eocs = re.findall(self.EMBEDDED_OBJECT_CHARACTER, text.getText(0, -1)) + eocs = re.findall(self.EMBEDDED_OBJECT_CHARACTER, AXText.get_all_text(obj)) if len(eocs)/nChars > 0.3: return False @@ -3280,13 +3267,12 @@ class Utilities(script_utilities.Utilities): # seems to be exposing that for non-editable things. Thanks Gecko. rv = not AXUtilities.is_editable(obj) if rv: - boundary = Atspi.TextBoundaryType.LINE_START for i in range(nChars): - char = text.getText(i, i + 1) + char = AXText.get_substring(obj, i, i + 1) if char.isspace() or char in ["\ufffc", "\ufffd"]: continue - string, start, end = text.getTextAtOffset(i, boundary) + string, start, end = AXText.get_line_at_offset(obj, i) if len(string.strip()) > 1: rv = False break @@ -3325,11 +3311,7 @@ class Utilities(script_utilities.Utilities): rv = False targets = self.labelTargets(obj) if targets: - try: - text = obj.queryText() - end = text.characterCount - except Exception: - end = 1 + end = AXText.get_character_count(obj) if AXObject.supports_text(obj) else 1 x, y, width, height = self.getExtents(obj, 0, end) if x < 0 or y < 0: rv = True @@ -3364,9 +3346,13 @@ class Utilities(script_utilities.Utilities): if not AXUtilities.is_link(obj) or not AXObject.supports_text(obj): return False - text = obj.queryText() - start = list(text.getRangeExtents(0, 1, 0)) - end = list(text.getRangeExtents(text.characterCount - 1, text.characterCount, 0)) + try: + char_count = AXText.get_character_count(obj) + start = list(Atspi.Text.get_range_extents(obj, 0, 1, Atspi.CoordType.SCREEN)) + end = list(Atspi.Text.get_range_extents( + obj, max(0, char_count - 1), char_count, Atspi.CoordType.SCREEN)) + except Exception: + return False if self.extentsAreOnSameLine(start, end): return False @@ -3378,7 +3364,7 @@ class Utilities(script_utilities.Utilities): return True def targetsForLabel(self, obj): - return AXObject.get_relation_targets(obj, Atspi.RelationType.LABEL_FOR) + return AXUtilitiesRelation.get_is_label_for(obj) def labelTargets(self, obj): if not (obj and self.inDocumentContent(obj)): @@ -3525,7 +3511,7 @@ class Utilities(script_utilities.Utilities): rv = AXObject.has_action(obj, "click-ancestor") if rv and not AXObject.get_name(obj) and AXObject.supports_text(obj): - string = obj.queryText().getText(0, -1) + string = AXText.get_all_text(obj) if not string.strip(): rv = not (AXUtilities.is_static(obj) or AXUtilities.is_link(obj)) @@ -3625,9 +3611,8 @@ class Utilities(script_utilities.Utilities): if listbox is None: return None - targets = AXObject.get_relation_targets(listbox, - Atspi.RelationType.CONTROLLED_BY, - self.isEditableComboBox) + targets = [x for x in AXUtilitiesRelation.get_is_controlled_by(listbox) + if self.isEditableComboBox(x)] if len(targets) == 1: return targets[0] @@ -3768,7 +3753,7 @@ class Utilities(script_utilities.Utilities): if rv is not None: return rv - rv = AXObject.has_relation(obj, Atspi.RelationType.ERROR_FOR) + rv = bool(AXUtilitiesRelation.get_is_error_for(obj)) self._isErrorMessage[hash(obj)] = rv return rv @@ -3784,10 +3769,9 @@ class Utilities(script_utilities.Utilities): return False def _isMatch(x): - try: - string = x.queryText().getText(0, -1).strip() - except Exception: + if not AXObject.supports_text(x): return False + string = AXText.get_all_text(x).strip() if entryName != string: return False return AXUtilities.is_section(x) or AXUtilities.is_static(x) @@ -4030,7 +4014,7 @@ class Utilities(script_utilities.Utilities): if self.isCustomElement(obj) and self.hasExplicitName(obj) \ and AXUtilities.is_section(obj) \ and AXObject.supports_text(obj) \ - and not re.search(r'[^\s\ufffc]', obj.queryText().getText(0, -1)): + and not re.search(r'[^\s\ufffc]', AXText.get_all_text(obj)): for child in AXObject.iter_children(obj): if not (AXUtilities.is_image_or_canvas(child) or self.isSVG(child)): break @@ -4125,8 +4109,8 @@ class Utilities(script_utilities.Utilities): elif self.hasValidName(obj) \ or AXObject.get_description(obj) or AXObject.get_child_count(obj): rv = False - elif AXObject.supports_text(obj) and obj.queryText().characterCount \ - and obj.queryText().getText(0, -1) != AXObject.get_name(obj): + elif AXObject.supports_text(obj) and AXText.get_character_count(obj) \ + and AXText.get_all_text(obj) != AXObject.get_name(obj): rv = False elif AXObject.supports_action(obj): names = AXObject.get_action_names(obj) @@ -4205,8 +4189,7 @@ class Utilities(script_utilities.Utilities): if rv is not None: return rv - relation = AXObject.get_relation(obj, Atspi.RelationType.DETAILS) - rv = relation and relation.get_n_targets() > 0 + rv = bool(AXUtilitiesRelation.get_details(obj)) self._hasDetails[hash(obj)] = rv return rv @@ -4214,7 +4197,7 @@ class Utilities(script_utilities.Utilities): if not self.hasDetails(obj): return [] - return AXObject.get_relation_targets(obj, Atspi.RelationType.DETAILS) + return AXUtilitiesRelation.get_details(obj) def isDetails(self, obj): if not (obj and self.inDocumentContent(obj)): @@ -4224,8 +4207,7 @@ class Utilities(script_utilities.Utilities): if rv is not None: return rv - relation = AXObject.get_relation(obj, Atspi.RelationType.DETAILS_FOR) - rv = relation and relation.get_n_targets() > 0 + rv = bool(AXUtilitiesRelation.get_is_details_for(obj)) self._isDetails[hash(obj)] = rv return rv @@ -4233,7 +4215,7 @@ class Utilities(script_utilities.Utilities): if not self.isDetails(obj): return [] - return AXObject.get_relation_targets(obj, Atspi.RelationType.DETAILS_FOR) + return AXUtilitiesRelation.get_is_details_for(obj) def popupType(self, obj): if not (obj and self.inDocumentContent(obj)): @@ -4530,7 +4512,7 @@ class Utilities(script_utilities.Utilities): if isinstance(cthulhu_state.lastInputEvent, input_event.KeyboardEvent): inputEvent = cthulhu_state.lastNonModifierKeyEvent - return inputEvent and inputEvent.isPrintableKey() and not inputEvent.modifiers + return inputEvent and inputEvent.is_printable_key() and not inputEvent.modifiers return False @@ -4637,7 +4619,7 @@ class Utilities(script_utilities.Utilities): return -1, -1, 0 start, end = self.getHyperlinkRange(obj) - return start, end, text.characterCount + return start, end, AXText.get_character_count(AXObject.get_parent(obj)) def getError(self, obj): if not (obj and self.inDocumentContent(obj)): @@ -4667,9 +4649,9 @@ class Utilities(script_utilities.Utilities): if not self.getError(obj): return None - relation = AXObject.get_relation(obj, Atspi.RelationType.ERROR_MESSAGE) - if relation: - return relation.get_target(0) + errorMessages = AXUtilitiesRelation.get_error_message(obj) + if errorMessages: + return errorMessages[0] return None @@ -4803,19 +4785,25 @@ class Utilities(script_utilities.Utilities): container = obj contextObj, contextOffset = None, -1 while obj: - try: - offset = obj.queryText().caretOffset - except Exception: + if not AXObject.supports_text(obj): tokens = ["WEB: Exception getting caret offset of", obj] debug.printTokens(debug.LEVEL_INFO, tokens, True) obj = None + continue + + offset = AXText.get_caret_offset(obj) + if offset < 0: + tokens = ["WEB: Exception getting caret offset of", obj] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + obj = None + continue + + contextObj, contextOffset = obj, offset + child = self.findChildAtOffset(obj, offset) + if child: + obj = child else: - contextObj, contextOffset = obj, offset - child = self.findChildAtOffset(obj, offset) - if child: - obj = child - else: - break + break if contextObj and not self.isHidden(contextObj): return self.findNextCaretInOrder(contextObj, max(-1, contextOffset - 1)) @@ -4832,12 +4820,13 @@ class Utilities(script_utilities.Utilities): if not self.inDocumentContent(obj): return None, -1 - try: - offset = obj.queryText().caretOffset - except NotImplementedError: + if AXObject.supports_text(obj): + offset = AXText.get_caret_offset(obj) + else: + offset = 0 + + if offset < 0: offset = 0 - except Exception: - offset = -1 return obj, offset @@ -5071,7 +5060,7 @@ class Utilities(script_utilities.Utilities): def findContextReplicant(self, documentFrame=None, matchRole=True, matchName=True): path, oldRole, oldName = self.getCaretContextPathRoleAndName(documentFrame) - obj = self.getObjectFromPath(path) + obj = self.get_objectFromPath(path) if obj and matchRole: if AXObject.get_role(obj) != oldRole: obj = None @@ -5149,9 +5138,14 @@ class Utilities(script_utilities.Utilities): debug.printTokens(debug.LEVEL_INFO, tokens, True) return obj, 0 - if text and offset >= text.characterCount: + if text: + char_count = AXText.get_character_count(obj) + else: + char_count = 0 + + if text and offset >= char_count: if self.isContentEditableWithEmbeddedObjects(obj) and self.lastInputEventWasCharNav(): - nextObj, nextOffset = self.nextContext(obj, text.characterCount) + nextObj, nextOffset = self.nextContext(obj, char_count) if not nextObj: tokens = ["WEB: No next object found at end of contenteditable", obj] debug.printTokens(debug.LEVEL_INFO, tokens, True) @@ -5166,13 +5160,13 @@ class Utilities(script_utilities.Utilities): return nextObj, nextOffset tokens = ["WEB: First caret context at end of", obj, ", ", offset, "is", - obj, ", ", text.characterCount] + obj, ", ", char_count] debug.printTokens(debug.LEVEL_INFO, tokens, True) - return obj, text.characterCount + return obj, char_count offset = max(0, offset) if text: - allText = text.getText(0, -1) + allText = AXText.get_all_text(obj) if allText[offset] != self.EMBEDDED_OBJECT_CHARACTER or role == Atspi.Role.ENTRY: msg = "WEB: First caret context is unchanged" debug.printMessage(debug.LEVEL_INFO, msg, True) @@ -5238,7 +5232,7 @@ class Utilities(script_utilities.Utilities): if self._canHaveCaretContext(obj): text = self.queryNonEmptyText(obj) if text: - allText = text.getText(0, -1) + allText = AXText.get_all_text(obj) for i in range(offset + 1, len(allText)): child = self.findChildAtOffset(obj, i) if child and allText[i] != self.EMBEDDED_OBJECT_CHARACTER: @@ -5312,7 +5306,7 @@ class Utilities(script_utilities.Utilities): if self._canHaveCaretContext(obj): text = self.queryNonEmptyText(obj) if text: - allText = text.getText(0, -1) + allText = AXText.get_all_text(obj) if offset == -1 or offset > len(allText): offset = len(allText) for i in range(offset - 1, -1, -1): diff --git a/src/cthulhu/scripts/web/speech_generator.py b/src/cthulhu/scripts/web/speech_generator.py index 4d07b76..2cf417e 100644 --- a/src/cthulhu/scripts/web/speech_generator.py +++ b/src/cthulhu/scripts/web/speech_generator.py @@ -44,6 +44,7 @@ from cthulhu import settings from cthulhu import settings_manager from cthulhu import speech_generator from cthulhu.ax_object import AXObject +from cthulhu.ax_text import AXText from cthulhu.ax_utilities import AXUtilities _settingsManager = settings_manager.getManager() @@ -306,7 +307,7 @@ class SpeechGenerator(speech_generator.SpeechGenerator): return [] if lastKey in ['Up', 'Left']: text = self._script.utilities.queryNonEmptyText(obj) - if text and args.get('endOffset') not in [None, text.characterCount]: + if text and args.get('endOffset') not in [None, AXText.get_character_count(obj)]: return [] result = [] @@ -601,7 +602,7 @@ class SpeechGenerator(speech_generator.SpeechGenerator): return [] if lastKey in ["Up", "Left"] and not mods: text = self._script.utilities.queryNonEmptyText(obj) - if text and end not in [None, text.characterCount]: + if text and end not in [None, AXText.get_character_count(obj)]: return [] if role not in doNotSpeak: result.append(self.getLocalizedRoleName(obj, **args)) diff --git a/src/cthulhu/settings_manager.py b/src/cthulhu/settings_manager.py index 6d1cba6..b2e3eb9 100644 --- a/src/cthulhu/settings_manager.py +++ b/src/cthulhu/settings_manager.py @@ -57,7 +57,7 @@ try: except Exception: _proxy = None -_scriptManager = script_manager.getManager() +_scriptManager = script_manager.get_manager() class SettingsManager(object): """Settings backend manager. This class manages cthulhu user's settings diff --git a/src/cthulhu/sleep_mode_manager.py b/src/cthulhu/sleep_mode_manager.py index 6601d82..6e3e268 100644 --- a/src/cthulhu/sleep_mode_manager.py +++ b/src/cthulhu/sleep_mode_manager.py @@ -133,16 +133,16 @@ class SleepModeManager: return True from . import cthulhu_state - scriptManager = script_manager.getManager() + scriptManager = script_manager.get_manager() if self.isActiveForApp(script.app): # Turning OFF sleep mode self._apps.remove(hash(script.app)) - newScript = scriptManager.getScript(script.app) + newScript = scriptManager.get_script(script.app) if notifyUser: newScript.presentMessage( messages.SLEEP_MODE_DISABLED_FOR % AXObject.get_name(script.app)) - scriptManager.setActiveScript(newScript, "Sleep mode toggled off") + scriptManager.set_active_script(newScript, "Sleep mode toggled off") debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Disabled for {AXObject.get_name(script.app)}", True) # Reset debounce timer after successful toggle self._lastToggleTime = 0 @@ -169,11 +169,11 @@ class SleepModeManager: try: debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Getting sleep script for {AXObject.get_name(script.app)}", True) - sleepScript = scriptManager.getOrCreateSleepModeScript(script.app) + sleepScript = scriptManager.get_or_create_sleep_mode_script(script.app) debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Got sleep script: {sleepScript}", True) debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Setting active script", True) - scriptManager.setActiveScript(sleepScript, "Sleep mode toggled on") + scriptManager.set_active_script(sleepScript, "Sleep mode toggled on") debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Active script set successfully", True) debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Adding app to sleep list", True) @@ -193,4 +193,4 @@ _manager = SleepModeManager() def getManager(): """Returns the Sleep Mode Manager singleton.""" - return _manager \ No newline at end of file + return _manager diff --git a/src/cthulhu/speech_generator.py b/src/cthulhu/speech_generator.py index 4daef6f..74c62cc 100644 --- a/src/cthulhu/speech_generator.py +++ b/src/cthulhu/speech_generator.py @@ -52,7 +52,9 @@ from . import settings_manager from . import speech from . import text_attribute_names from .ax_object import AXObject +from .ax_text import AXText from .ax_utilities import AXUtilities +from .ax_utilities_relation import AXUtilitiesRelation class Pause: """A dummy class to indicate we want to insert a pause into an @@ -391,8 +393,8 @@ class SpeechGenerator(generator.Generator): endOffset = args.get('endOffset') if endOffset is not None: - text = self._script.utilities.queryNonEmptyText(obj) - if text and text.characterCount != endOffset: + if self._script.utilities.queryNonEmptyText(obj) \ + and AXText.get_character_count(obj) != endOffset: return [] result = [messages.CONTENT_DELETION_END] @@ -434,8 +436,8 @@ class SpeechGenerator(generator.Generator): endOffset = args.get('endOffset') if endOffset is not None: - text = self._script.utilities.queryNonEmptyText(obj) - if text and text.characterCount != endOffset: + if self._script.utilities.queryNonEmptyText(obj) \ + and AXText.get_character_count(obj) != endOffset: return [] result = [messages.CONTENT_INSERTION_END] @@ -478,8 +480,8 @@ class SpeechGenerator(generator.Generator): endOffset = args.get('endOffset') if endOffset is not None: - text = self._script.utilities.queryNonEmptyText(obj) - if text and text.characterCount != endOffset: + if self._script.utilities.queryNonEmptyText(obj) \ + and AXText.get_character_count(obj) != endOffset: return [] result = [messages.CONTENT_MARK_END] @@ -1330,16 +1332,7 @@ class SpeechGenerator(generator.Generator): """ attribStr = "" - defaultAttributes = text.getDefaultAttributes() - keyList, attributesDictionary = \ - self._script.utilities.stringToKeysAndDict(defaultAttributes) - - charAttributes = text.getAttributes(textOffset) - if charAttributes[0]: - keyList, charDict = \ - self._script.utilities.stringToKeysAndDict(charAttributes[0]) - for key in keyList: - attributesDictionary[key] = charDict[key] + attributesDictionary, _, _ = AXText.get_text_attributes_at_offset(obj, textOffset) if attributesDictionary: for key in keys: @@ -1391,8 +1384,11 @@ class SpeechGenerator(generator.Generator): except Exception: pass - textObj = obj.queryText() - caretOffset = textObj.caretOffset + if not AXObject.supports_text(obj): + self._script.generatorCache['textInformation'] = ["", 0, 0, False] + return self._script.generatorCache['textInformation'] + + caretOffset = AXText.get_caret_offset(obj) textContents, startOffset, endOffset = self._script.utilities.allSelectedText(obj) selected = textContents != "" @@ -1400,17 +1396,14 @@ class SpeechGenerator(generator.Generator): if not selected: # Get the line containing the caret # - [line, startOffset, endOffset] = textObj.getTextAtOffset( - textObj.caretOffset, - Atspi.TextBoundaryType.LINE_START) + [line, startOffset, endOffset] = AXText.get_line_at_offset(obj, caretOffset) if len(line): line = self._script.utilities.adjustForRepeats(line) textContents = line else: - char = textObj.getTextAtOffset(caretOffset, - Atspi.TextBoundaryType.CHAR) - if char[0] == "\n" and startOffset == caretOffset: - textContents = char[0] + char, charStart, charEnd = AXText.get_character_at_offset(obj, caretOffset) + if char == "\n" and startOffset == caretOffset: + textContents = char if self._script.utilities.shouldVerbalizeAllPunctuation(obj): textContents = self._script.utilities.verbalizeAllPunctuation(textContents) @@ -1430,9 +1423,7 @@ class SpeechGenerator(generator.Generator): if result: return result - try: - obj.queryText() - except NotImplementedError: + if not AXObject.supports_text(obj): return [] result = [] @@ -1455,9 +1446,7 @@ class SpeechGenerator(generator.Generator): called prior to this method. """ - try: - text = obj.queryText() - except NotImplementedError: + if not AXObject.supports_text(obj): return [] [line, startOffset, endOffset, selected] = self._getTextInformation(obj) @@ -1466,7 +1455,7 @@ class SpeechGenerator(generator.Generator): lastAttribs = None textOffset = startOffset for i in range(0, len(line)): - attribs = self._getCharacterAttributes(obj, text, textOffset, i) + attribs = self._getCharacterAttributes(obj, None, textOffset, i) if attribs and attribs != lastAttribs: if newLine: newLine += " ; " @@ -1477,7 +1466,7 @@ class SpeechGenerator(generator.Generator): textOffset += 1 attribs = self._getCharacterAttributes(obj, - text, + None, startOffset, 0, ["paragraph-style"]) @@ -1500,9 +1489,7 @@ class SpeechGenerator(generator.Generator): if _settingsManager.getSetting('onlySpeakDisplayedText'): return [] - try: - obj.queryText() - except NotImplementedError: + if not AXObject.supports_text(obj): return [] result = [] @@ -1523,16 +1510,11 @@ class SpeechGenerator(generator.Generator): return [] result = [] - try: - textObj = obj.queryText() - except Exception: - pass - else: - noOfSelections = textObj.getNSelections() - if noOfSelections == 1: - [string, startOffset, endOffset] = \ - textObj.getTextAtOffset(0, Atspi.TextBoundaryType.LINE_START) - if startOffset == 0 and endOffset == len(string): + if AXObject.supports_text(obj): + selections = AXText.get_selected_ranges(obj) + if len(selections) == 1: + startOffset, endOffset = selections[0] + if startOffset == 0 and endOffset >= AXText.get_character_count(obj): result = [messages.TEXT_SELECTED] result.extend(self.voice(SYSTEM, obj=obj, **args)) return result @@ -1666,7 +1648,7 @@ class SpeechGenerator(generator.Generator): # TODO - JD: We need other ways to determine group membership. Not all # implementations expose the member-of relation. Gtk3 does. Others are TBD. - members = AXObject.get_relation_targets(obj, Atspi.RelationType.MEMBER_OF) + members = AXUtilitiesRelation.get_is_member_of(obj) if priorObj not in members: return result @@ -2290,8 +2272,7 @@ class SpeechGenerator(generator.Generator): # TODO - JD: We need other ways to determine group membership. Not all # implementations expose the member-of relation. Gtk3 does. Others are TBD. - members = AXObject.get_relation_targets( - obj, Atspi.RelationType.MEMBER_OF, AXUtilities.is_showing) + members = [m for m in AXUtilitiesRelation.get_is_member_of(obj) if AXUtilities.is_showing(m)] if obj not in members: return [] diff --git a/src/cthulhu/spellcheck.py b/src/cthulhu/spellcheck.py index 9e53b12..22bccc5 100644 --- a/src/cthulhu/spellcheck.py +++ b/src/cthulhu/spellcheck.py @@ -44,6 +44,7 @@ from cthulhu import object_properties from cthulhu import cthulhu_state from cthulhu import settings_manager from cthulhu.ax_object import AXObject +from cthulhu.ax_text import AXText from cthulhu.ax_utilities import AXUtilities _settingsManager = settings_manager.getManager() @@ -156,18 +157,14 @@ class SpellCheck: if not (obj and offset >= 0): return False - try: - text = obj.queryText() - except Exception: + # This should work, but some toolkits are broken. + if not AXObject.supports_text(obj): return False - # This should work, but some toolkits are broken. - boundary = Atspi.TextBoundaryType.SENTENCE_START - string, start, end = text.getTextAtOffset(offset, boundary) + string, start, end = AXText.get_sentence_at_offset(obj, offset) if not string: - boundary = Atspi.TextBoundaryType.LINE_START - string, start, end = text.getTextAtOffset(offset, boundary) + string, start, end = AXText.get_line_at_offset(obj, offset) sentences = re.split(r'(?:\.|\!|\?)', string) word = self.getMisspelledWord() if string.count(word) == 1: diff --git a/src/cthulhu/structural_navigation.py b/src/cthulhu/structural_navigation.py index a621f52..a385b4f 100644 --- a/src/cthulhu/structural_navigation.py +++ b/src/cthulhu/structural_navigation.py @@ -51,6 +51,7 @@ from . import settings_manager from .ax_collection import AXCollection from .ax_event_synthesizer import AXEventSynthesizer from .ax_object import AXObject +from .ax_text import AXText from .ax_selection import AXSelection from .ax_utilities import AXUtilities @@ -975,12 +976,10 @@ class StructuralNavigation: """ caption = obj.queryTable().caption - try: - caption.queryText() - except Exception: + if not AXObject.supports_text(caption): return None - else: - return self._script.utilities.displayedText(caption) + + return self._script.utilities.displayedText(caption) def _getTableDescription(self, obj): """Returns a string which describes the table.""" @@ -1056,12 +1055,8 @@ class StructuralNavigation: if obj and (AXObject.get_name(obj) or AXObject.get_child_count(obj)): return False - try: - text = obj.queryText() - except Exception: - pass - else: - if text.getText(0, -1).strip(): + if AXObject.supports_text(obj): + if AXText.get_all_text(obj).strip(): return False return True @@ -1174,7 +1169,7 @@ class StructuralNavigation: tokens = ["STRUCTURAL NAVIGATION:", obj, "became defunct after setting caret position"] debug.printTokens(debug.LEVEL_INFO, tokens, True) - replicant = self._script.utilities.getObjectFromPath(objPath) + replicant = self._script.utilities.get_objectFromPath(objPath) if replicant and AXObject.get_role(replicant) == objRole: tokens = ["STRUCTURAL NAVIGATION: Updating obj to replicant", replicant] debug.printTokens(debug.LEVEL_INFO, tokens, True) @@ -1491,12 +1486,16 @@ class StructuralNavigation: return True text = self._script.utilities.queryNonEmptyText(obj) - if not (text and text.characterCount > settings.largeObjectTextLength): + if not text: return False - string = text.getText(0, -1) + char_count = AXText.get_character_count(obj) + if char_count <= settings.largeObjectTextLength: + return False + + string = AXText.get_all_text(obj) eocs = string.count(self._script.EMBEDDED_OBJECT_CHARACTER) - if eocs/text.characterCount < 0.05: + if char_count and eocs/char_count < 0.05: return True return False @@ -1991,16 +1990,15 @@ class StructuralNavigation: if AXUtilities.is_heading(obj): return True - try: - text = obj.queryText() - # We're choosing 3 characters as the minimum because some - # paragraphs contain a single image or link and a text - # of length 2: An embedded object character and a space. - # We want to skip these. - return text.characterCount > 2 - except Exception: + if not AXObject.supports_text(obj): return False + # We're choosing 3 characters as the minimum because some + # paragraphs contain a single image or link and a text + # of length 2: An embedded object character and a space. + # We want to skip these. + return AXText.get_character_count(obj) > 2 + return AXUtilities.find_all_paragraphs(document, True, has_at_least_three_characters) def _paragraphPresentation(self, obj, arg=None): diff --git a/test/keystrokes/gnome-appearance-properties/font-preferences.py b/test/keystrokes/gnome-appearance-properties/font-preferences.py index a993852..7eab85c 100644 --- a/test/keystrokes/gnome-appearance-properties/font-preferences.py +++ b/test/keystrokes/gnome-appearance-properties/font-preferences.py @@ -25,6 +25,9 @@ """Testing of font preferences in the gnome-appearance properties dialog.""" +import gi +gi.require_version('Atspi', '2.0') +from gi.repository import Atspi from macaroon.playback import * import utils @@ -35,14 +38,14 @@ sequence = MacroSequence() # then navigate to the Fonts tab # sequence.append(WaitForWindowActivate("Appearance Preferences")) -sequence.append(WaitForFocus("Theme", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("Theme", acc_role=Atspi.Role.PAGE_TAB)) sequence.append(KeyComboAction("Right")) -sequence.append(WaitForFocus("Background", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("Background", acc_role=Atspi.Role.PAGE_TAB)) sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Right")) -sequence.append(WaitForFocus("Fonts", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("Fonts", acc_role=Atspi.Role.PAGE_TAB)) sequence.append(utils.AssertPresentationAction( "Fonts tab", ["BRAILLE LINE: 'gnome-appearance-properties Application Appearance Preferences Dialog Fonts'", @@ -54,12 +57,12 @@ sequence.append(utils.AssertPresentationAction( # Open the 'Pick a Font' dialog # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.PUSH_BUTTON)) sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("a")) #sequence.append(WaitForWindowActivate("Pick a Font")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TABLE)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TABLE)) sequence.append(utils.AssertPresentationAction( "Pick a Font dialog", ["BRAILLE LINE: 'gnome-appearance-properties Application Pick a Font FontChooser'", @@ -80,13 +83,13 @@ sequence.append(KeyComboAction("Down")) sequence.append(WaitAction("object:state-changed:selected", None, None, - pyatspi.ROLE_TABLE_CELL, + Atspi.Role.TABLE_CELL, 5000)) sequence.append(KeyComboAction("Up")) sequence.append(WaitAction("object:state-changed:selected", None, None, - pyatspi.ROLE_TABLE_CELL, + Atspi.Role.TABLE_CELL, 5000)) sequence.append(utils.AssertPresentationAction( "Examine Family", @@ -104,7 +107,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TABLE)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TABLE)) sequence.append(utils.AssertPresentationAction( "Style table", ["BRAILLE LINE: 'gnome-appearance-properties Application Pick a Font FontChooser ScrollPane Style: Table Face ColumnHeader Regular'", @@ -118,7 +121,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TEXT)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TEXT)) sequence.append(utils.AssertPresentationAction( "Size table", ["BRAILLE LINE: 'gnome-appearance-properties Application Pick a Font FontChooser Size: 10 $l'", @@ -134,10 +137,10 @@ sequence.append(KeyComboAction("Return")) sequence.append(WaitAction("object:selection-changed", None, None, - pyatspi.ROLE_TABLE, + Atspi.Role.TABLE, 5000)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TABLE)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TABLE)) sequence.append(utils.AssertPresentationAction( "Change size", ["BRAILLE LINE: 'gnome-appearance-properties Application Pick a Font FontChooser Size: 10 $l'", @@ -162,7 +165,7 @@ sequence.append(utils.AssertPresentationAction( # Accept the change and dismiss the 'Pick a Font' dialog. # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TEXT)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TEXT)) # check the font attributes sequence.append(utils.StartRecordingAction()) @@ -175,41 +178,41 @@ sequence.append(utils.AssertPresentationAction( "SPEECH OUTPUT: 'family-name Sans'"])) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Cancel", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("Cancel", acc_role=Atspi.Role.PUSH_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("OK", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("OK", acc_role=Atspi.Role.PUSH_BUTTON)) sequence.append(KeyComboAction("o")) ######################################################################## # Bring the 'Pick a Font' dialog back up # #sequence.append(WaitForWindowActivate("Appearance Preferences")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.PUSH_BUTTON)) sequence.append(KeyComboAction("Return")) #sequence.append(WaitForWindowActivate("Pick a Font")) -sequence.append(WaitForFocus("OK", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("OK", acc_role=Atspi.Role.PUSH_BUTTON)) ######################################################################## # Go to the 'Size' areas and change it to 10 from 18 # sequence.append(KeyComboAction("z")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TEXT)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TEXT)) sequence.append(TypeAction("10")) sequence.append(KeyComboAction("Return")) sequence.append(WaitAction("object:selection-changed", None, None, - pyatspi.ROLE_TABLE, + Atspi.Role.TABLE, 5000)) ######################################################################## # Accept the change and dismiss the 'Pick a Font' dialog. # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TABLE)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TABLE)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TEXT)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TEXT)) # check the font attributes sequence.append(utils.StartRecordingAction()) @@ -222,23 +225,23 @@ sequence.append(utils.AssertPresentationAction( "SPEECH OUTPUT: 'family-name Sans'"])) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Cancel", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("Cancel", acc_role=Atspi.Role.PUSH_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("OK", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("OK", acc_role=Atspi.Role.PUSH_BUTTON)) sequence.append(KeyComboAction("o")) ######################################################################## # Revert application to original status # #sequence.append(WaitForWindowActivate("Appearance Preferences")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.PUSH_BUTTON)) sequence.append(KeyComboAction("ISO_Left_Tab")) -sequence.append(WaitForFocus("Fonts", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("Fonts", acc_role=Atspi.Role.PAGE_TAB)) sequence.append(KeyComboAction("Left")) -sequence.append(WaitForFocus("Background", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("Background", acc_role=Atspi.Role.PAGE_TAB)) sequence.append(KeyComboAction("Left")) -sequence.append(WaitForFocus("Theme", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("Theme", acc_role=Atspi.Role.PAGE_TAB)) # Just a little extra wait to let some events get through. # diff --git a/test/keystrokes/gnome-calculator/gcalctool01.py b/test/keystrokes/gnome-calculator/gcalctool01.py index 6d81f0e..65b5ddc 100644 --- a/test/keystrokes/gnome-calculator/gcalctool01.py +++ b/test/keystrokes/gnome-calculator/gcalctool01.py @@ -25,6 +25,9 @@ '''TEST the Ability to find out the square root of a selected number''' +import gi +gi.require_version('Atspi', '2.0') +from gi.repository import Atspi from macaroon.playback import * import utils sequence = MacroSequence() @@ -34,7 +37,7 @@ sequence = MacroSequence() # sequence.append(WaitForWindowActivate("Calculator", None)) sequence.append(KeyComboAction("a")) -sequence.append(WaitForFocus("Change Mode", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("Change Mode", acc_role=Atspi.Role.PUSH_BUTTON)) sequence.append(KeyComboAction("Return")) ############################################################################### diff --git a/test/keystrokes/java/role_accel_label.py b/test/keystrokes/java/role_accel_label.py index 85424b6..fcd0291 100644 --- a/test/keystrokes/java/role_accel_label.py +++ b/test/keystrokes/java/role_accel_label.py @@ -25,6 +25,9 @@ """Test of accelerator labels in Java's SwingSet2.""" +import gi +gi.require_version('Atspi', '2.0') +from gi.repository import Atspi from macaroon.playback import * import utils @@ -34,13 +37,13 @@ sequence = MacroSequence() # We wait for the demo to come up and for focus to be on the toggle button # #sequence.append(WaitForWindowActivate("SwingSet2",None)) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(PauseAction(5000)) sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("F10")) -sequence.append(WaitForFocus("File", acc_role=pyatspi.ROLE_MENU)) +sequence.append(WaitForFocus("File", acc_role=Atspi.Role.MENU)) sequence.append(utils.AssertPresentationAction( "1. F10 for File menu", ["KNOWN ISSUE - Sometimes more of the hierarchy is included in the braille; other times it is not. This applies to all tests here.", @@ -56,7 +59,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) -sequence.append(WaitForFocus("About", acc_role=pyatspi.ROLE_MENU_ITEM)) +sequence.append(WaitForFocus("About", acc_role=Atspi.Role.MENU_ITEM)) sequence.append(utils.AssertPresentationAction( "2. Arrow Down", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Swing demo menu bar MenuBar About'", @@ -82,7 +85,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) -sequence.append(WaitForFocus("Exit", acc_role=pyatspi.ROLE_MENU_ITEM)) +sequence.append(WaitForFocus("Exit", acc_role=Atspi.Role.MENU_ITEM)) sequence.append(utils.AssertPresentationAction( "4. Arrow Down", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Swing demo menu bar MenuBar Exit'", diff --git a/test/keystrokes/java/role_check_box.py b/test/keystrokes/java/role_check_box.py index 246fd65..9a3a12c 100644 --- a/test/keystrokes/java/role_check_box.py +++ b/test/keystrokes/java/role_check_box.py @@ -26,6 +26,9 @@ """Test of check boxes in Java's SwingSet2. """ +import gi +gi.require_version('Atspi', '2.0') +from gi.repository import Atspi from macaroon.playback import * import utils @@ -35,7 +38,7 @@ sequence = MacroSequence() # We wait for the demo to come up and for focus to be on the toggle button # #sequence.append(WaitForWindowActivate("SwingSet2",None)) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) # Wait for entire window to get populated. sequence.append(PauseAction(10000)) @@ -44,52 +47,52 @@ sequence.append(PauseAction(10000)) # Tab over to the button demo, and activate it. # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(TypeAction(" ")) ########################################################################## # Tab all the way down to the button page tab. # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Button Demo", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("Button Demo", acc_role=Atspi.Role.PAGE_TAB)) sequence.append(KeyComboAction("Tab")) ########################################################################## # Select Check Boxes tab # -sequence.append(WaitForFocus("Buttons", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("Buttons", acc_role=Atspi.Role.PAGE_TAB)) sequence.append(KeyComboAction("Right")) -sequence.append(WaitForFocus("Radio Buttons", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("Radio Buttons", acc_role=Atspi.Role.PAGE_TAB)) sequence.append(KeyComboAction("Right")) -sequence.append(WaitForFocus("Check Boxes", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("Check Boxes", acc_role=Atspi.Role.PAGE_TAB)) sequence.append(PauseAction(5000)) ########################################################################## @@ -97,7 +100,7 @@ sequence.append(PauseAction(5000)) # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("One ", acc_role=pyatspi.ROLE_CHECK_BOX)) +sequence.append(WaitForFocus("One ", acc_role=Atspi.Role.CHECK_BOX)) sequence.append(utils.AssertPresentationAction( "One checkbox unchecked", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Button Demo TabList Button Demo Page Check Boxes TabList Check Boxes Page Text CheckBoxes Panel < > One CheckBox'", @@ -122,7 +125,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(TypeAction(' ')) sequence.append(WaitAction("object:state-changed:checked", None, - None, pyatspi.ROLE_CHECK_BOX, 5000)) + None, Atspi.Role.CHECK_BOX, 5000)) sequence.append(utils.AssertPresentationAction( "One checkbox checked", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Button Demo TabList Button Demo Page Check Boxes TabList Check Boxes Page Text CheckBoxes Panel One CheckBox'", @@ -148,7 +151,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(TypeAction(' ')) sequence.append(WaitAction("object:state-changed:checked", None, - None, pyatspi.ROLE_CHECK_BOX, 5000)) + None, Atspi.Role.CHECK_BOX, 5000)) sequence.append(utils.AssertPresentationAction( "One checkbox unchecked", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Button Demo TabList Button Demo Page Check Boxes TabList Check Boxes Page Text CheckBoxes Panel < > One CheckBox'", @@ -156,16 +159,16 @@ sequence.append(utils.AssertPresentationAction( "SPEECH OUTPUT: 'not checked'"])) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Two", acc_role=pyatspi.ROLE_CHECK_BOX)) +sequence.append(WaitForFocus("Two", acc_role=Atspi.Role.CHECK_BOX)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Three", acc_role=pyatspi.ROLE_CHECK_BOX)) +sequence.append(WaitForFocus("Three", acc_role=Atspi.Role.CHECK_BOX)) ######################################################################## # Tab to the One lightbulb # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("One ", acc_role=pyatspi.ROLE_CHECK_BOX)) +sequence.append(WaitForFocus("One ", acc_role=Atspi.Role.CHECK_BOX)) sequence.append(utils.AssertPresentationAction( "One lightbulb checkbox unchecked", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Button Demo TabList Button Demo Page Check Boxes TabList Check Boxes Page Image CheckBoxes Panel < > One CheckBox'", @@ -191,7 +194,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(TypeAction(' ')) sequence.append(WaitAction("object:state-changed:checked", None, - None, pyatspi.ROLE_CHECK_BOX, 5000)) + None, Atspi.Role.CHECK_BOX, 5000)) sequence.append(utils.AssertPresentationAction( "One lightbulb checkbox checked", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Button Demo TabList Button Demo Page Check Boxes TabList Check Boxes Page Image CheckBoxes Panel One CheckBox'", @@ -201,7 +204,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(TypeAction(' ')) sequence.append(WaitAction("object:state-changed:checked", None, - None, pyatspi.ROLE_CHECK_BOX, 5000)) + None, Atspi.Role.CHECK_BOX, 5000)) sequence.append(utils.AssertPresentationAction( "One lightbulb unchecked checkbox", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Button Demo TabList Button Demo Page Check Boxes TabList Check Boxes Page Image CheckBoxes Panel < > One CheckBox'", @@ -209,25 +212,25 @@ sequence.append(utils.AssertPresentationAction( "SPEECH OUTPUT: 'not checked'"])) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Two", acc_role=pyatspi.ROLE_CHECK_BOX)) +sequence.append(WaitForFocus("Two", acc_role=Atspi.Role.CHECK_BOX)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Three", acc_role=pyatspi.ROLE_CHECK_BOX)) +sequence.append(WaitForFocus("Three", acc_role=Atspi.Role.CHECK_BOX)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Paint Border", acc_role=pyatspi.ROLE_CHECK_BOX)) +sequence.append(WaitForFocus("Paint Border", acc_role=Atspi.Role.CHECK_BOX)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Paint Focus", acc_role=pyatspi.ROLE_CHECK_BOX)) +sequence.append(WaitForFocus("Paint Focus", acc_role=Atspi.Role.CHECK_BOX)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Enabled", acc_role=pyatspi.ROLE_CHECK_BOX)) +sequence.append(WaitForFocus("Enabled", acc_role=Atspi.Role.CHECK_BOX)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Content Filled", acc_role=pyatspi.ROLE_CHECK_BOX)) +sequence.append(WaitForFocus("Content Filled", acc_role=Atspi.Role.CHECK_BOX)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Default", acc_role=pyatspi.ROLE_RADIO_BUTTON)) +sequence.append(WaitForFocus("Default", acc_role=Atspi.Role.RADIO_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("0", acc_role=pyatspi.ROLE_RADIO_BUTTON)) +sequence.append(WaitForFocus("0", acc_role=Atspi.Role.RADIO_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("10", acc_role=pyatspi.ROLE_RADIO_BUTTON)) +sequence.append(WaitForFocus("10", acc_role=Atspi.Role.RADIO_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TEXT)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TEXT)) sequence.append(KeyComboAction("Tab")) # Toggle the top left button, to return to normal state. diff --git a/test/keystrokes/java/role_check_menu_item.py b/test/keystrokes/java/role_check_menu_item.py index 6053324..2cec15c 100644 --- a/test/keystrokes/java/role_check_menu_item.py +++ b/test/keystrokes/java/role_check_menu_item.py @@ -26,6 +26,9 @@ """Test of check menu items in Java's SwingSet2. """ +import gi +gi.require_version('Atspi', '2.0') +from gi.repository import Atspi from macaroon.playback import * import utils @@ -35,18 +38,18 @@ sequence = MacroSequence() # We wait for the demo to come up and for focus to be on the toggle button # #sequence.append(WaitForWindowActivate("SwingSet2",None)) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(PauseAction(5000)) sequence.append(KeyComboAction("F10")) -sequence.append(WaitForFocus("File", acc_role=pyatspi.ROLE_MENU)) +sequence.append(WaitForFocus("File", acc_role=Atspi.Role.MENU)) sequence.append(KeyComboAction("Right")) -sequence.append(WaitForFocus("Look & Feel", acc_role=pyatspi.ROLE_MENU)) +sequence.append(WaitForFocus("Look & Feel", acc_role=Atspi.Role.MENU)) sequence.append(KeyComboAction("Right")) -sequence.append(WaitForFocus("Themes", acc_role=pyatspi.ROLE_MENU)) +sequence.append(WaitForFocus("Themes", acc_role=Atspi.Role.MENU)) sequence.append(KeyComboAction("Right")) -sequence.append(WaitForFocus("Options", acc_role=pyatspi.ROLE_MENU)) +sequence.append(WaitForFocus("Options", acc_role=Atspi.Role.MENU)) ######################################################################## # Go Down to the Enable Tool Tips menu item @@ -54,7 +57,7 @@ sequence.append(WaitForFocus("Options", acc_role=pyatspi.ROLE_MENU)) sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) sequence.append(WaitForFocus("Enable Tool Tips", - acc_role=pyatspi.ROLE_CHECK_BOX)) + acc_role=Atspi.Role.CHECK_BOX)) sequence.append(utils.AssertPresentationAction( "Enable Tool Tips checked check menu item", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Swing demo menu bar MenuBar Enable Tool Tips CheckBox'", @@ -67,7 +70,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) sequence.append(WaitForFocus("Enable Drag Support", - acc_role=pyatspi.ROLE_CHECK_BOX)) + acc_role=Atspi.Role.CHECK_BOX)) sequence.append(utils.AssertPresentationAction( "Enable Drag Support unchecked menu item", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Swing demo menu bar MenuBar < > Enable Drag Support CheckBox'", @@ -93,18 +96,18 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(TypeAction(" ")) sequence.append(WaitAction("object:state-changed:checked", None, - None, pyatspi.ROLE_CHECK_BOX, 5000)) + None, Atspi.Role.CHECK_BOX, 5000)) ######################################################################## # Go directly back to the checked menu item. # sequence.append(KeyComboAction("p")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) sequence.append(WaitForFocus("Enable Drag Support", - acc_role=pyatspi.ROLE_CHECK_BOX)) + acc_role=Atspi.Role.CHECK_BOX)) sequence.append(utils.AssertPresentationAction( "Enable Drag Support checked menu item", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Swing demo menu bar MenuBar Enable Drag Support CheckBox'", diff --git a/test/keystrokes/java/role_combo_box.py b/test/keystrokes/java/role_combo_box.py index 9810326..92ab04a 100644 --- a/test/keystrokes/java/role_combo_box.py +++ b/test/keystrokes/java/role_combo_box.py @@ -25,6 +25,9 @@ """Test of combo boxes in Java's SwingSet2.""" +import gi +gi.require_version('Atspi', '2.0') +from gi.repository import Atspi from macaroon.playback import * import utils @@ -34,7 +37,7 @@ sequence = MacroSequence() # We wait for the demo to come up and for focus to be on the toggle button # #sequence.append(WaitForWindowActivate("SwingSet2",None)) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) # Wait for entire window to get populated. sequence.append(PauseAction(5000)) @@ -43,42 +46,42 @@ sequence.append(PauseAction(5000)) # Tab over to the combo box demo, and activate it. # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(TypeAction(" ")) ########################################################################## # Tab all the way down to the button page tab. # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("ComboBox Demo", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("ComboBox Demo", acc_role=Atspi.Role.PAGE_TAB)) sequence.append(PauseAction(5000)) @@ -88,7 +91,7 @@ sequence.append(PauseAction(5000)) sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Tab")) sequence.append(WaitForFocus("Philip, Howard, Jeff", - acc_role=pyatspi.ROLE_COMBO_BOX)) + acc_role=Atspi.Role.COMBO_BOX)) sequence.append(utils.AssertPresentationAction( "1. focusing over first combo box", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane ComboBox Demo TabList ComboBox Demo Page Presets: Philip, Howard, Jeff Combo'", @@ -246,7 +249,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Philip", acc_role=pyatspi.ROLE_COMBO_BOX)) +sequence.append(WaitForFocus("Philip", acc_role=Atspi.Role.COMBO_BOX)) sequence.append(utils.AssertPresentationAction( "15. Tab to next combo box", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane ComboBox Demo TabList ComboBox Demo Page Hair: Philip Combo'", @@ -258,7 +261,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Howard", acc_role=pyatspi.ROLE_COMBO_BOX)) +sequence.append(WaitForFocus("Howard", acc_role=Atspi.Role.COMBO_BOX)) sequence.append(utils.AssertPresentationAction( "16. Tab to next combo box", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane ComboBox Demo TabList ComboBox Demo Page Eyes & Nose: Howard Combo'", @@ -270,7 +273,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Jeff", acc_role=pyatspi.ROLE_COMBO_BOX)) +sequence.append(WaitForFocus("Jeff", acc_role=Atspi.Role.COMBO_BOX)) sequence.append(utils.AssertPresentationAction( "17. Tab to next combo box", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane ComboBox Demo TabList ComboBox Demo Page Mouth: Jeff Combo'", @@ -281,10 +284,10 @@ sequence.append(utils.AssertPresentationAction( # Tab back up to starting state # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TEXT)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TEXT)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) # Toggle the top left button, to return to normal state. sequence.append(TypeAction (" ")) diff --git a/test/keystrokes/java/role_dialog.py b/test/keystrokes/java/role_dialog.py index 2a238c6..8e1a576 100644 --- a/test/keystrokes/java/role_dialog.py +++ b/test/keystrokes/java/role_dialog.py @@ -25,6 +25,9 @@ """Test of dialogs in Java's SwingSet2.""" +import gi +gi.require_version('Atspi', '2.0') +from gi.repository import Atspi from macaroon.playback import * import utils @@ -34,7 +37,7 @@ sequence = MacroSequence() # We wait for the demo to come up and for focus to be on the toggle button # #sequence.append(WaitForWindowActivate("SwingSet2",None)) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) # Wait for entire window to get populated. sequence.append(PauseAction(5000)) @@ -43,52 +46,52 @@ sequence.append(PauseAction(5000)) # Tab over to the JOptionPane demo, and activate it. # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(TypeAction(" ")) ########################################################################## # Tab down to the dialog activation button in the demo. # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Option Pane Demo", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("Option Pane Demo", acc_role=Atspi.Role.PAGE_TAB)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Show Input Dialog", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("Show Input Dialog", acc_role=Atspi.Role.PUSH_BUTTON)) ######################################################################## # Dialog is activated # sequence.append(utils.StartRecordingAction()) sequence.append(TypeAction(" ")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TEXT)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TEXT)) sequence.append(utils.AssertPresentationAction( "1. Dialog is activated", ["BRAILLE LINE: 'SwingSet2 Application Input Dialog'", @@ -110,7 +113,7 @@ sequence.append(KeyComboAction("Return")) # Expected output when "OK" button gets focus. # sequence.append(utils.StartRecordingAction()) -sequence.append(WaitForFocus("OK", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("OK", acc_role=Atspi.Role.PUSH_BUTTON)) sequence.append(utils.AssertPresentationAction( "2. OK button gains focus", ["BUG? - We don't always present anything here. Need to investigate.", @@ -140,17 +143,17 @@ sequence.append(KeyComboAction("Return")) ######################################################################## # Wait for main application to gain focus and return to starting state. # sequence.append(WaitForWindowActivate("SwingSet2",None)) -sequence.append(WaitForFocus("Show Input Dialog", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("Show Input Dialog", acc_role=Atspi.Role.PUSH_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Show Warning Dialog", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("Show Warning Dialog", acc_role=Atspi.Role.PUSH_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Show Message Dialog", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("Show Message Dialog", acc_role=Atspi.Role.PUSH_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Show Component Dialog", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("Show Component Dialog", acc_role=Atspi.Role.PUSH_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Show Confirmation Dialog", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("Show Confirmation Dialog", acc_role=Atspi.Role.PUSH_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TEXT)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TEXT)) sequence.append(KeyComboAction("Tab")) diff --git a/test/keystrokes/java/role_menu.py b/test/keystrokes/java/role_menu.py index b523b7b..32e6bc7 100644 --- a/test/keystrokes/java/role_menu.py +++ b/test/keystrokes/java/role_menu.py @@ -25,6 +25,9 @@ """Test of menus in Java's SwingSet2.""" +import gi +gi.require_version('Atspi', '2.0') +from gi.repository import Atspi from macaroon.playback import * import utils @@ -34,7 +37,7 @@ sequence = MacroSequence() # We wait for the demo to come up and for focus to be on the toggle button # #sequence.append(WaitForWindowActivate("SwingSet2",None)) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) # Wait for entire window to get populated. sequence.append(PauseAction(5000)) @@ -42,14 +45,14 @@ sequence.append(PauseAction(5000)) # Hack to deal with a timing issue which seems to interfere with our # setting the locusOfFocus reliably. sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) ########################################################################## # Open File menu # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("f")) -sequence.append(WaitForFocus("File", acc_role=pyatspi.ROLE_MENU)) +sequence.append(WaitForFocus("File", acc_role=Atspi.Role.MENU)) sequence.append(utils.AssertPresentationAction( "1. Open File menu", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane File Menu'", @@ -80,7 +83,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Right")) -sequence.append(WaitForFocus("Look & Feel", acc_role=pyatspi.ROLE_MENU)) +sequence.append(WaitForFocus("Look & Feel", acc_role=Atspi.Role.MENU)) sequence.append(utils.AssertPresentationAction( "3. Move to Look & Feel menu", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Look & Feel Menu'", @@ -108,7 +111,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Right")) -sequence.append(WaitForFocus("Themes", acc_role=pyatspi.ROLE_MENU)) +sequence.append(WaitForFocus("Themes", acc_role=Atspi.Role.MENU)) sequence.append(utils.AssertPresentationAction( "5. Move to Themes menu", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Look & Feel Menu'", @@ -138,7 +141,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) -sequence.append(WaitForFocus("Audio", acc_role=pyatspi.ROLE_MENU)) +sequence.append(WaitForFocus("Audio", acc_role=Atspi.Role.MENU)) sequence.append(utils.AssertPresentationAction( "7. Move to Audio menu", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Swing demo menu bar MenuBar Audio Menu'", diff --git a/test/keystrokes/java/role_page_tab.py b/test/keystrokes/java/role_page_tab.py index 95fb05a..b9a5b04 100644 --- a/test/keystrokes/java/role_page_tab.py +++ b/test/keystrokes/java/role_page_tab.py @@ -25,6 +25,9 @@ """Test of page tabs in Java's SwingSet2.""" +import gi +gi.require_version('Atspi', '2.0') +from gi.repository import Atspi from macaroon.playback import * import utils @@ -34,7 +37,7 @@ sequence = MacroSequence() # We wait for the demo to come up and for focus to be on the toggle button # #sequence.append(WaitForWindowActivate("SwingSet2",None)) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) # Wait for entire window to get populated. sequence.append(PauseAction(5000)) @@ -43,57 +46,57 @@ sequence.append(PauseAction(5000)) # Tab over to the JTabbedPane demo, and activate it. # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(TypeAction(" ")) ########################################################################## # Tab all the way down to the demo. # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("TabbedPane Demo", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("TabbedPane Demo", acc_role=Atspi.Role.PAGE_TAB)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Top", acc_role=pyatspi.ROLE_RADIO_BUTTON)) +sequence.append(WaitForFocus("Top", acc_role=Atspi.Role.RADIO_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Left", acc_role=pyatspi.ROLE_RADIO_BUTTON)) +sequence.append(WaitForFocus("Left", acc_role=Atspi.Role.RADIO_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Bottom", acc_role=pyatspi.ROLE_RADIO_BUTTON)) +sequence.append(WaitForFocus("Bottom", acc_role=Atspi.Role.RADIO_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Right", acc_role=pyatspi.ROLE_RADIO_BUTTON)) +sequence.append(WaitForFocus("Right", acc_role=Atspi.Role.RADIO_BUTTON)) ######################################################################## # Expected output when "Laine" tab gets focus. # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Laine", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("Laine", acc_role=Atspi.Role.PAGE_TAB)) sequence.append(utils.AssertPresentationAction( "1. Move to Laine tab", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane TabbedPane Demo TabList TabbedPane Demo Page Laine Page'", @@ -105,7 +108,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Right")) -sequence.append(WaitForFocus("Ewan", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("Ewan", acc_role=Atspi.Role.PAGE_TAB)) sequence.append(utils.AssertPresentationAction( "2. Move to Ewan tab", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane TabbedPane Demo TabList TabbedPane Demo Page Ewan Page'", @@ -117,7 +120,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Right")) -sequence.append(WaitForFocus("Hania", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("Hania", acc_role=Atspi.Role.PAGE_TAB)) sequence.append(utils.AssertPresentationAction( "3. Move to Hania tab", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane TabbedPane Demo TabList TabbedPane Demo Page Hania Page'", @@ -129,7 +132,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Right")) -sequence.append(WaitForFocus("
Bouncing Babies!
", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("
Bouncing Babies!
", acc_role=Atspi.Role.PAGE_TAB)) sequence.append(utils.AssertPresentationAction( "4. Move to Bouncing Babies! tab", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane TabbedPane Demo TabList TabbedPane Demo Page
Bouncing Babies!
Page'", @@ -150,7 +153,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Left")) -sequence.append(WaitForFocus("Hania", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("Hania", acc_role=Atspi.Role.PAGE_TAB)) sequence.append(utils.AssertPresentationAction( "6. Back to Hania tab", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane TabbedPane Demo TabList TabbedPane Demo Page Hania Page'", @@ -171,7 +174,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Left")) -sequence.append(WaitForFocus("Ewan", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("Ewan", acc_role=Atspi.Role.PAGE_TAB)) sequence.append(utils.AssertPresentationAction( "8. Back to Ewan tab", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane TabbedPane Demo TabList TabbedPane Demo Page Ewan Page'", @@ -192,7 +195,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Left")) -sequence.append(WaitForFocus("Laine", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("Laine", acc_role=Atspi.Role.PAGE_TAB)) sequence.append(utils.AssertPresentationAction( "10. Back to Laine tab", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane TabbedPane Demo TabList TabbedPane Demo Page Laine Page'", @@ -212,7 +215,7 @@ sequence.append(utils.AssertPresentationAction( "SPEECH OUTPUT: 'tab list Laine page 1 of 4'"])) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TEXT)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TEXT)) sequence.append(KeyComboAction("Tab")) # Toggle the top left button, to return to normal state. diff --git a/test/keystrokes/java/role_push_button.py b/test/keystrokes/java/role_push_button.py index 67a4db7..afd4fee 100644 --- a/test/keystrokes/java/role_push_button.py +++ b/test/keystrokes/java/role_push_button.py @@ -25,6 +25,9 @@ """Test of push buttons in Java's SwingSet2.""" +import gi +gi.require_version('Atspi', '2.0') +from gi.repository import Atspi from macaroon.playback import * import utils @@ -34,7 +37,7 @@ sequence = MacroSequence() # We wait for the demo to come up and for focus to be on the toggle button # #sequence.append(WaitForWindowActivate("SwingSet2",None)) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) # Wait for entire window to get populated. sequence.append(PauseAction(5000)) @@ -43,44 +46,44 @@ sequence.append(PauseAction(5000)) # Tab over to the button demo, and activate it. # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(TypeAction(" ")) ########################################################################## # Tab all the way down to the button page tab. # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Button Demo", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("Button Demo", acc_role=Atspi.Role.PAGE_TAB)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Buttons", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("Buttons", acc_role=Atspi.Role.PAGE_TAB)) sequence.append(PauseAction(5000)) ########################################################################## @@ -88,7 +91,7 @@ sequence.append(PauseAction(5000)) # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("One ", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("One ", acc_role=Atspi.Role.PUSH_BUTTON)) sequence.append(utils.AssertPresentationAction( "1. Move to One button", ["BRAILLE LINE: 'SwingSet2 Application (SwingSet2 Frame RootPane LayeredPane Button Demo TabList Button Demo Page |)Buttons TabList Buttons Page Text Buttons Panel One Button'", @@ -112,7 +115,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Two", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("Two", acc_role=Atspi.Role.PUSH_BUTTON)) sequence.append(utils.AssertPresentationAction( "3. Move to Two button", ["BRAILLE LINE: 'SwingSet2 Application (SwingSet2 Frame RootPane LayeredPane Button Demo TabList Button Demo Page |)Buttons TabList Buttons Page Text Buttons Panel Two Button'", @@ -124,7 +127,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Three!", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("Three!", acc_role=Atspi.Role.PUSH_BUTTON)) sequence.append(utils.AssertPresentationAction( "4. Move to Three button", ["BUG? - What's up with the extra whitespace in the speech?", @@ -140,7 +143,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.PUSH_BUTTON)) sequence.append(utils.AssertPresentationAction( "5. Move to first image button", ["BRAILLE LINE: 'SwingSet2 Application (SwingSet2 Frame RootPane LayeredPane Button Demo TabList Button Demo Page |)Buttons TabList Buttons Page Image Buttons Panel Button'", @@ -152,7 +155,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.PUSH_BUTTON)) sequence.append(utils.AssertPresentationAction( "6. Move to second image button", ["BRAILLE LINE: 'SwingSet2 Application (SwingSet2 Frame RootPane LayeredPane Button Demo TabList Button Demo Page |)Buttons TabList Buttons Page Image Buttons Panel Button'", @@ -164,7 +167,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.PUSH_BUTTON)) sequence.append(utils.AssertPresentationAction( "7. Move to third image button", ["BRAILLE LINE: 'SwingSet2 Application (SwingSet2 Frame RootPane LayeredPane Button Demo TabList Button Demo Page |)Buttons TabList Buttons Page Image Buttons Panel Button'", @@ -174,21 +177,21 @@ sequence.append(utils.AssertPresentationAction( ########################################################################## # Wrap around tabbing to top left toggle button. sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Paint Border", acc_role=pyatspi.ROLE_CHECK_BOX)) +sequence.append(WaitForFocus("Paint Border", acc_role=Atspi.Role.CHECK_BOX)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Paint Focus", acc_role=pyatspi.ROLE_CHECK_BOX)) +sequence.append(WaitForFocus("Paint Focus", acc_role=Atspi.Role.CHECK_BOX)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Enabled", acc_role=pyatspi.ROLE_CHECK_BOX)) +sequence.append(WaitForFocus("Enabled", acc_role=Atspi.Role.CHECK_BOX)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Content Filled", acc_role=pyatspi.ROLE_CHECK_BOX)) +sequence.append(WaitForFocus("Content Filled", acc_role=Atspi.Role.CHECK_BOX)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Default", acc_role=pyatspi.ROLE_RADIO_BUTTON)) +sequence.append(WaitForFocus("Default", acc_role=Atspi.Role.RADIO_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("0", acc_role=pyatspi.ROLE_RADIO_BUTTON)) +sequence.append(WaitForFocus("0", acc_role=Atspi.Role.RADIO_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("10", acc_role=pyatspi.ROLE_RADIO_BUTTON)) +sequence.append(WaitForFocus("10", acc_role=Atspi.Role.RADIO_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TEXT)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TEXT)) sequence.append(KeyComboAction("Tab")) # Toggle the top left button, to return to normal state. diff --git a/test/keystrokes/java/role_radio_button.py b/test/keystrokes/java/role_radio_button.py index 71db801..a9f39eb 100644 --- a/test/keystrokes/java/role_radio_button.py +++ b/test/keystrokes/java/role_radio_button.py @@ -25,6 +25,9 @@ """Test of radio buttons in Java's SwingSet2.""" +import gi +gi.require_version('Atspi', '2.0') +from gi.repository import Atspi from macaroon.playback import * import utils @@ -34,7 +37,7 @@ sequence = MacroSequence() # We wait for the demo to come up and for focus to be on the toggle button # #sequence.append(WaitForWindowActivate("SwingSet2",None)) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) # Wait for entire window to get populated. sequence.append(PauseAction(5000)) @@ -43,50 +46,50 @@ sequence.append(PauseAction(5000)) # Tab over to the button demo, and activate it. # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(TypeAction(" ")) ########################################################################## # Tab all the way down to the button page tab. # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Button Demo", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("Button Demo", acc_role=Atspi.Role.PAGE_TAB)) sequence.append(KeyComboAction("Tab")) ########################################################################## # Select Check Boxes tab # -sequence.append(WaitForFocus("Buttons", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("Buttons", acc_role=Atspi.Role.PAGE_TAB)) sequence.append(KeyComboAction("Right")) -sequence.append(WaitForFocus("Radio Buttons", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("Radio Buttons", acc_role=Atspi.Role.PAGE_TAB)) sequence.append(PauseAction(5000)) ########################################################################## @@ -94,7 +97,7 @@ sequence.append(PauseAction(5000)) # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Radio One ", acc_role=pyatspi.ROLE_RADIO_BUTTON)) +sequence.append(WaitForFocus("Radio One ", acc_role=Atspi.Role.RADIO_BUTTON)) sequence.append(utils.AssertPresentationAction( "1. Move to Radio One radio button", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Button Demo TabList Button Demo Page Radio Buttons TabList Radio Buttons Page Text Radio Buttons Panel Text Radio Buttons & y Radio One RadioButton'", @@ -111,7 +114,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(TypeAction(" ")) sequence.append(WaitAction("object:property-change:accessible-value", None, - None, pyatspi.ROLE_RADIO_BUTTON, 5000)) + None, Atspi.Role.RADIO_BUTTON, 5000)) sequence.append(utils.AssertPresentationAction( "2. Select the focused radio button", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Button Demo TabList Button Demo Page Radio Buttons TabList Radio Buttons Page Text Radio Buttons Panel Text Radio Buttons &=y Radio One RadioButton'", @@ -123,7 +126,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Radio Two", acc_role=pyatspi.ROLE_RADIO_BUTTON)) +sequence.append(WaitForFocus("Radio Two", acc_role=Atspi.Role.RADIO_BUTTON)) sequence.append(utils.AssertPresentationAction( "3. Move to Radio Two radio button", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Button Demo TabList Button Demo Page Radio Buttons TabList Radio Buttons Page Text Radio Buttons Panel Text Radio Buttons & y Radio Two RadioButton'", @@ -136,7 +139,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(TypeAction(" ")) sequence.append(WaitAction("object:property-change:accessible-value", None, - None, pyatspi.ROLE_RADIO_BUTTON, 5000)) + None, Atspi.Role.RADIO_BUTTON, 5000)) sequence.append(utils.AssertPresentationAction( "4. Select the focused radio button", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Button Demo TabList Button Demo Page Radio Buttons TabList Radio Buttons Page Text Radio Buttons Panel Text Radio Buttons &=y Radio Two RadioButton'", @@ -148,7 +151,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Radio Three", acc_role=pyatspi.ROLE_RADIO_BUTTON)) +sequence.append(WaitForFocus("Radio Three", acc_role=Atspi.Role.RADIO_BUTTON)) sequence.append(utils.AssertPresentationAction( "5. Move to Radio Three radio button", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Button Demo TabList Button Demo Page Radio Buttons TabList Radio Buttons Page Text Radio Buttons Panel Text Radio Buttons & y Radio Three RadioButton'", @@ -161,7 +164,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(TypeAction(" ")) sequence.append(WaitAction("object:property-change:accessible-value", None, - None, pyatspi.ROLE_RADIO_BUTTON, 5000)) + None, Atspi.Role.RADIO_BUTTON, 5000)) sequence.append(utils.AssertPresentationAction( "6. Select the focused radio button", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Button Demo TabList Button Demo Page Radio Buttons TabList Radio Buttons Page Text Radio Buttons Panel Text Radio Buttons &=y Radio Three RadioButton'", @@ -173,7 +176,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Radio One ", acc_role=pyatspi.ROLE_RADIO_BUTTON)) +sequence.append(WaitForFocus("Radio One ", acc_role=Atspi.Role.RADIO_BUTTON)) sequence.append(utils.AssertPresentationAction( "7. Move to Radio One radio button", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Button Demo TabList Button Demo Page Radio Buttons TabList Radio Buttons Page Image Radio Buttons Panel Image Radio Buttons & y Radio One RadioButton'", @@ -198,7 +201,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(TypeAction(" ")) sequence.append(WaitAction("object:property-change:accessible-value", None, - None, pyatspi.ROLE_RADIO_BUTTON, 5000)) + None, Atspi.Role.RADIO_BUTTON, 5000)) sequence.append(utils.AssertPresentationAction( "9. Select the focused radio button", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Button Demo TabList Button Demo Page Radio Buttons TabList Radio Buttons Page Image Radio Buttons Panel Image Radio Buttons &=y Radio One RadioButton'", @@ -222,7 +225,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Radio Two", acc_role=pyatspi.ROLE_RADIO_BUTTON)) +sequence.append(WaitForFocus("Radio Two", acc_role=Atspi.Role.RADIO_BUTTON)) sequence.append(utils.AssertPresentationAction( "11. Move to Radio Two radio button", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Button Demo TabList Button Demo Page Radio Buttons TabList Radio Buttons Page Image Radio Buttons Panel Image Radio Buttons & y Radio Two RadioButton'", @@ -247,7 +250,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(TypeAction(" ")) sequence.append(WaitAction("object:property-change:accessible-value", None, - None, pyatspi.ROLE_RADIO_BUTTON, 5000)) + None, Atspi.Role.RADIO_BUTTON, 5000)) sequence.append(utils.AssertPresentationAction( "13. Select the focused radio button", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Button Demo TabList Button Demo Page Radio Buttons TabList Radio Buttons Page Image Radio Buttons Panel Image Radio Buttons &=y Radio Two RadioButton'", @@ -271,7 +274,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Radio Three", acc_role=pyatspi.ROLE_RADIO_BUTTON)) +sequence.append(WaitForFocus("Radio Three", acc_role=Atspi.Role.RADIO_BUTTON)) sequence.append(utils.AssertPresentationAction( "15. Move to Radio Three radio button", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Button Demo TabList Button Demo Page Radio Buttons TabList Radio Buttons Page Image Radio Buttons Panel Image Radio Buttons & y Radio Three RadioButton'", @@ -284,7 +287,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(TypeAction(" ")) sequence.append(WaitAction("object:property-change:accessible-value", None, - None, pyatspi.ROLE_RADIO_BUTTON, 5000)) + None, Atspi.Role.RADIO_BUTTON, 5000)) sequence.append(utils.AssertPresentationAction( "16. Select the focused radio button", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Button Demo TabList Button Demo Page Radio Buttons TabList Radio Buttons Page Image Radio Buttons Panel Image Radio Buttons &=y Radio Three RadioButton'", @@ -293,21 +296,21 @@ sequence.append(utils.AssertPresentationAction( # Tab back up to beginning sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Paint Border", acc_role=pyatspi.ROLE_CHECK_BOX)) +sequence.append(WaitForFocus("Paint Border", acc_role=Atspi.Role.CHECK_BOX)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Paint Focus", acc_role=pyatspi.ROLE_CHECK_BOX)) +sequence.append(WaitForFocus("Paint Focus", acc_role=Atspi.Role.CHECK_BOX)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Enabled", acc_role=pyatspi.ROLE_CHECK_BOX)) +sequence.append(WaitForFocus("Enabled", acc_role=Atspi.Role.CHECK_BOX)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Content Filled", acc_role=pyatspi.ROLE_CHECK_BOX)) +sequence.append(WaitForFocus("Content Filled", acc_role=Atspi.Role.CHECK_BOX)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Default", acc_role=pyatspi.ROLE_RADIO_BUTTON)) +sequence.append(WaitForFocus("Default", acc_role=Atspi.Role.RADIO_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("0", acc_role=pyatspi.ROLE_RADIO_BUTTON)) +sequence.append(WaitForFocus("0", acc_role=Atspi.Role.RADIO_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("10", acc_role=pyatspi.ROLE_RADIO_BUTTON)) +sequence.append(WaitForFocus("10", acc_role=Atspi.Role.RADIO_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TEXT)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TEXT)) sequence.append(KeyComboAction("Tab")) # Toggle the top left button, to return to normal state. diff --git a/test/keystrokes/java/role_radio_menu_item.py b/test/keystrokes/java/role_radio_menu_item.py index 830d376..55edac1 100644 --- a/test/keystrokes/java/role_radio_menu_item.py +++ b/test/keystrokes/java/role_radio_menu_item.py @@ -25,6 +25,9 @@ """Test of radio menu items in Java's SwingSet2.""" +import gi +gi.require_version('Atspi', '2.0') +from gi.repository import Atspi from macaroon.playback import * import utils @@ -34,16 +37,16 @@ sequence = MacroSequence() # We wait for the demo to come up and for focus to be on the toggle button # #sequence.append(WaitForWindowActivate("SwingSet2",None)) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(PauseAction(5000)) ########################################################################## # Invoke Themes menu sequence.append(KeyComboAction("t")) -sequence.append(WaitForFocus("Audio", acc_role=pyatspi.ROLE_MENU)) +sequence.append(WaitForFocus("Audio", acc_role=Atspi.Role.MENU)) sequence.append(KeyComboAction("Down")) -sequence.append(WaitForFocus("Fonts", acc_role=pyatspi.ROLE_MENU)) +sequence.append(WaitForFocus("Fonts", acc_role=Atspi.Role.MENU)) sequence.append(PauseAction(5000)) ########################################################################## @@ -51,7 +54,7 @@ sequence.append(PauseAction(5000)) # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) -sequence.append(WaitForFocus("Ocean", acc_role=pyatspi.ROLE_RADIO_MENU_ITEM)) +sequence.append(WaitForFocus("Ocean", acc_role=Atspi.Role.RADIO_MENU_ITEM)) sequence.append(utils.AssertPresentationAction( "1. Down Arrow to Ocean", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Swing demo menu bar MenuBar Fonts Menu'", @@ -79,7 +82,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) -sequence.append(WaitForFocus("Steel", acc_role=pyatspi.ROLE_RADIO_MENU_ITEM)) +sequence.append(WaitForFocus("Steel", acc_role=Atspi.Role.RADIO_MENU_ITEM)) sequence.append(utils.AssertPresentationAction( "3. Down Arrow to Steel", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Swing demo menu bar MenuBar & y Steel RadioItem'", @@ -105,7 +108,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) -sequence.append(WaitForFocus("Aqua", acc_role=pyatspi.ROLE_RADIO_MENU_ITEM)) +sequence.append(WaitForFocus("Aqua", acc_role=Atspi.Role.RADIO_MENU_ITEM)) sequence.append(utils.AssertPresentationAction( "5. Down Arrow to Aqua", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Swing demo menu bar MenuBar & y Aqua RadioItem'", @@ -117,7 +120,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) -sequence.append(WaitForFocus("Charcoal", acc_role=pyatspi.ROLE_RADIO_MENU_ITEM)) +sequence.append(WaitForFocus("Charcoal", acc_role=Atspi.Role.RADIO_MENU_ITEM)) sequence.append(utils.AssertPresentationAction( "6. Down Arrow to Charcoal", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Swing demo menu bar MenuBar & y Charcoal RadioItem'", @@ -129,7 +132,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) -sequence.append(WaitForFocus("High Contrast", acc_role=pyatspi.ROLE_RADIO_MENU_ITEM)) +sequence.append(WaitForFocus("High Contrast", acc_role=Atspi.Role.RADIO_MENU_ITEM)) sequence.append(utils.AssertPresentationAction( "7. Down Arrow to High Contrast", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Swing demo menu bar MenuBar & y High Contrast RadioItem'", @@ -141,7 +144,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) -sequence.append(WaitForFocus("Emerald", acc_role=pyatspi.ROLE_RADIO_MENU_ITEM)) +sequence.append(WaitForFocus("Emerald", acc_role=Atspi.Role.RADIO_MENU_ITEM)) sequence.append(utils.AssertPresentationAction( "8. Down Arrow to Emerald", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Swing demo menu bar MenuBar & y Emerald RadioItem'", @@ -153,7 +156,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) -sequence.append(WaitForFocus("Ruby", acc_role=pyatspi.ROLE_RADIO_MENU_ITEM)) +sequence.append(WaitForFocus("Ruby", acc_role=Atspi.Role.RADIO_MENU_ITEM)) sequence.append(utils.AssertPresentationAction( "9. Down Arrow to Ruby", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Swing demo menu bar MenuBar & y Ruby RadioItem'", @@ -165,7 +168,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Up")) -sequence.append(WaitForFocus("Emerald", acc_role=pyatspi.ROLE_RADIO_MENU_ITEM)) +sequence.append(WaitForFocus("Emerald", acc_role=Atspi.Role.RADIO_MENU_ITEM)) sequence.append(utils.AssertPresentationAction( "10. Up Arrow to Emerald", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Swing demo menu bar MenuBar & y Emerald RadioItem'", @@ -177,7 +180,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Up")) -sequence.append(WaitForFocus("High Contrast", acc_role=pyatspi.ROLE_RADIO_MENU_ITEM)) +sequence.append(WaitForFocus("High Contrast", acc_role=Atspi.Role.RADIO_MENU_ITEM)) sequence.append(utils.AssertPresentationAction( "11. Up Arrow to High Contrast", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Swing demo menu bar MenuBar & y High Contrast RadioItem'", @@ -189,7 +192,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Up")) -sequence.append(WaitForFocus("Charcoal", acc_role=pyatspi.ROLE_RADIO_MENU_ITEM)) +sequence.append(WaitForFocus("Charcoal", acc_role=Atspi.Role.RADIO_MENU_ITEM)) sequence.append(utils.AssertPresentationAction( "12. Up Arrow to Charcoal", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Swing demo menu bar MenuBar & y Charcoal RadioItem'", @@ -201,7 +204,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Up")) -sequence.append(WaitForFocus("Aqua", acc_role=pyatspi.ROLE_RADIO_MENU_ITEM)) +sequence.append(WaitForFocus("Aqua", acc_role=Atspi.Role.RADIO_MENU_ITEM)) sequence.append(utils.AssertPresentationAction( "13. Up Arrow to Aqua", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Swing demo menu bar MenuBar & y Aqua RadioItem'", @@ -213,7 +216,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Up")) -sequence.append(WaitForFocus("Steel", acc_role=pyatspi.ROLE_RADIO_MENU_ITEM)) +sequence.append(WaitForFocus("Steel", acc_role=Atspi.Role.RADIO_MENU_ITEM)) sequence.append(utils.AssertPresentationAction( "14. Up Arrow to Steel", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Swing demo menu bar MenuBar & y Steel RadioItem'", @@ -226,7 +229,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(TypeAction(" ")) sequence.append(WaitAction("object:property-change:accessible-value", None, - None, pyatspi.ROLE_RADIO_MENU_ITEM, 5000)) + None, Atspi.Role.RADIO_MENU_ITEM, 5000)) sequence.append(utils.AssertPresentationAction( "15. Select the radio menu item", ["BUG? - Why are we speaking JInternalFrame demo? Also, some times the state of the toggle button is wrong. Need to investigate.", @@ -238,16 +241,16 @@ sequence.append(utils.AssertPresentationAction( "SPEECH OUTPUT: 'JInternalFrame demo toggle button pressed'"])) sequence.append(KeyComboAction("t")) -sequence.append(WaitForFocus("Audio", acc_role=pyatspi.ROLE_MENU)) +sequence.append(WaitForFocus("Audio", acc_role=Atspi.Role.MENU)) sequence.append(KeyComboAction("Down")) -sequence.append(WaitForFocus("Fonts", acc_role=pyatspi.ROLE_MENU)) +sequence.append(WaitForFocus("Fonts", acc_role=Atspi.Role.MENU)) ########################################################################## # Expected output when radio menu item gets focused. # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) -sequence.append(WaitForFocus("Ocean", acc_role=pyatspi.ROLE_RADIO_MENU_ITEM)) +sequence.append(WaitForFocus("Ocean", acc_role=Atspi.Role.RADIO_MENU_ITEM)) sequence.append(utils.AssertPresentationAction( "16. Down Arrow to Ocean", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Swing demo menu bar MenuBar Fonts Menu'", @@ -261,7 +264,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) -sequence.append(WaitForFocus("Steel", acc_role=pyatspi.ROLE_RADIO_MENU_ITEM)) +sequence.append(WaitForFocus("Steel", acc_role=Atspi.Role.RADIO_MENU_ITEM)) sequence.append(utils.AssertPresentationAction( "17. Down Arrow to Steel", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Swing demo menu bar MenuBar &=y Steel RadioItem'", @@ -273,7 +276,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) -sequence.append(WaitForFocus("Aqua", acc_role=pyatspi.ROLE_RADIO_MENU_ITEM)) +sequence.append(WaitForFocus("Aqua", acc_role=Atspi.Role.RADIO_MENU_ITEM)) sequence.append(utils.AssertPresentationAction( "18. Down Arrow to Aqua", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Swing demo menu bar MenuBar & y Aqua RadioItem'", @@ -285,7 +288,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Up")) -sequence.append(WaitForFocus("Steel", acc_role=pyatspi.ROLE_RADIO_MENU_ITEM)) +sequence.append(WaitForFocus("Steel", acc_role=Atspi.Role.RADIO_MENU_ITEM)) sequence.append(utils.AssertPresentationAction( "19. Up Arrow to Steel", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Swing demo menu bar MenuBar &=y Steel RadioItem'", @@ -297,7 +300,7 @@ sequence.append(utils.AssertPresentationAction( # sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Up")) -sequence.append(WaitForFocus("Ocean", acc_role=pyatspi.ROLE_RADIO_MENU_ITEM)) +sequence.append(WaitForFocus("Ocean", acc_role=Atspi.Role.RADIO_MENU_ITEM)) sequence.append(utils.AssertPresentationAction( "20. Up Arrow to Ocean", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Swing demo menu bar MenuBar & y Ocean RadioItem'", @@ -306,7 +309,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(TypeAction(" ")) sequence.append(WaitAction("object:property-change:accessible-value", None, - None, pyatspi.ROLE_RADIO_MENU_ITEM, 5000)) + None, Atspi.Role.RADIO_MENU_ITEM, 5000)) # Just a little extra wait to let some events get through. # diff --git a/test/keystrokes/java/role_table.py b/test/keystrokes/java/role_table.py index 1ff2916..715103d 100644 --- a/test/keystrokes/java/role_table.py +++ b/test/keystrokes/java/role_table.py @@ -25,6 +25,9 @@ """Test of push buttons in Java's SwingSet2.""" +import gi +gi.require_version('Atspi', '2.0') +from gi.repository import Atspi from macaroon.playback import * import utils @@ -34,7 +37,7 @@ sequence = MacroSequence() # We wait for the demo to come up and for focus to be on the toggle button # #sequence.append(WaitForWindowActivate("SwingSet2",None)) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) # Wait for entire window to get populated. sequence.append(PauseAction(5000)) @@ -43,70 +46,70 @@ sequence.append(PauseAction(5000)) # Tab over to the button demo, and activate it. # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(TypeAction(" ")) ########################################################################## # Tab all the way down to the table. # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Table Demo", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("Table Demo", acc_role=Atspi.Role.PAGE_TAB)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Reordering allowed", acc_role=pyatspi.ROLE_CHECK_BOX)) +sequence.append(WaitForFocus("Reordering allowed", acc_role=Atspi.Role.CHECK_BOX)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Row selection", acc_role=pyatspi.ROLE_CHECK_BOX)) +sequence.append(WaitForFocus("Row selection", acc_role=Atspi.Role.CHECK_BOX)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Horiz. Lines", acc_role=pyatspi.ROLE_CHECK_BOX)) +sequence.append(WaitForFocus("Horiz. Lines", acc_role=Atspi.Role.CHECK_BOX)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Column selection", acc_role=pyatspi.ROLE_CHECK_BOX)) +sequence.append(WaitForFocus("Column selection", acc_role=Atspi.Role.CHECK_BOX)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Vert. Lines", acc_role=pyatspi.ROLE_CHECK_BOX)) +sequence.append(WaitForFocus("Vert. Lines", acc_role=Atspi.Role.CHECK_BOX)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Inter-cell spacing", acc_role=pyatspi.ROLE_SLIDER)) +sequence.append(WaitForFocus("Inter-cell spacing", acc_role=Atspi.Role.SLIDER)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Row height", acc_role=pyatspi.ROLE_SLIDER)) +sequence.append(WaitForFocus("Row height", acc_role=Atspi.Role.SLIDER)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Multiple ranges", acc_role=pyatspi.ROLE_COMBO_BOX)) +sequence.append(WaitForFocus("Multiple ranges", acc_role=Atspi.Role.COMBO_BOX)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Subsequent columns", acc_role=pyatspi.ROLE_COMBO_BOX)) +sequence.append(WaitForFocus("Subsequent columns", acc_role=Atspi.Role.COMBO_BOX)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TEXT)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TEXT)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TEXT)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TEXT)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Fit Width", acc_role=pyatspi.ROLE_CHECK_BOX)) +sequence.append(WaitForFocus("Fit Width", acc_role=Atspi.Role.CHECK_BOX)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Print", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("Print", acc_role=Atspi.Role.PUSH_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TABLE)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TABLE)) ########################################################################## # Expected output when focus is on "Mike" cell: @@ -114,7 +117,7 @@ sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TABLE)) sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Right")) sequence.append(WaitAction("object:selection-changed", None, None, - pyatspi.ROLE_TABLE, 5000)) + Atspi.Role.TABLE, 5000)) sequence.append(utils.AssertPresentationAction( "1. Control Right Arrow into the cell", ["BUG? - No output when navigating JTable with cursor. See bug 483214."])) @@ -125,7 +128,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Right")) sequence.append(WaitAction("object:selection-changed", None, None, - pyatspi.ROLE_TABLE, 5000)) + Atspi.Role.TABLE, 5000)) sequence.append(utils.AssertPresentationAction( "2. Control Right Arrow into the cell", ["BUG? - No output when navigating JTable with cursor. See bug 483214."])) @@ -154,7 +157,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Right")) sequence.append(WaitAction("object:selection-changed", None, None, - pyatspi.ROLE_TABLE, 5000)) + Atspi.Role.TABLE, 5000)) sequence.append(utils.AssertPresentationAction( "4 Control Right Arrow into the cell", ["BUG? - No output when navigating JTable with cursor. See bug 483214."])) @@ -165,7 +168,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Right")) sequence.append(WaitAction("object:selection-changed", None, None, - pyatspi.ROLE_TABLE, 5000)) + Atspi.Role.TABLE, 5000)) sequence.append(utils.AssertPresentationAction( "5. Control Right Arrow into the cell", ["BUG? - No output when navigating JTable with cursor. See bug 483214."])) @@ -176,7 +179,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Right")) sequence.append(WaitAction("object:selection-changed", None, None, - pyatspi.ROLE_TABLE, 5000)) + Atspi.Role.TABLE, 5000)) sequence.append(utils.AssertPresentationAction( "6. Control Right Arrow into the cell", ["BUG? - No output when navigating JTable with cursor. See bug 483214."])) @@ -187,7 +190,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Right")) sequence.append(WaitAction("object:selection-changed", None, None, - pyatspi.ROLE_TABLE, 5000)) + Atspi.Role.TABLE, 5000)) sequence.append(utils.AssertPresentationAction( "7. Control Right Arrow into the cell", ["BUG? - No output when navigating JTable with cursor. See bug 483214."])) @@ -198,7 +201,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) sequence.append(WaitAction("object:selection-changed", None, None, - pyatspi.ROLE_TABLE, 5000)) + Atspi.Role.TABLE, 5000)) sequence.append(utils.AssertPresentationAction( "8. Control Down Arrow into the cell", ["BUG? - No output when navigating JTable with cursor. See bug 483214."])) @@ -209,7 +212,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Left")) sequence.append(WaitAction("object:selection-changed", None, None, - pyatspi.ROLE_TABLE, 5000)) + Atspi.Role.TABLE, 5000)) sequence.append(utils.AssertPresentationAction( "9. Control Left into the cell", ["BUG? - No output when navigating JTable with cursor. See bug 483214."])) @@ -220,7 +223,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Left")) sequence.append(WaitAction("object:selection-changed", None, None, - pyatspi.ROLE_TABLE, 5000)) + Atspi.Role.TABLE, 5000)) sequence.append(utils.AssertPresentationAction( "10. Control Left into the cell", ["BUG? - No output when navigating JTable with cursor. See bug 483214."])) @@ -231,7 +234,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Left")) sequence.append(WaitAction("object:selection-changed", None, None, - pyatspi.ROLE_TABLE, 5000)) + Atspi.Role.TABLE, 5000)) sequence.append(utils.AssertPresentationAction( "11. Control Left into the cell", ["BUG? - No output when navigating JTable with cursor. See bug 483214."])) @@ -242,7 +245,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Left")) sequence.append(WaitAction("object:selection-changed", None, None, - pyatspi.ROLE_TABLE, 5000)) + Atspi.Role.TABLE, 5000)) sequence.append(utils.AssertPresentationAction( "12. Control Left into the cell", ["BUG? - No output when navigating JTable with cursor. See bug 483214."])) @@ -253,7 +256,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(TypeAction(" ")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TABLE, 5000)) + Atspi.Role.TABLE, 5000)) sequence.append(utils.AssertPresentationAction( "13. Space Bar on the cell", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Table Demo TabList Table Demo Page ScrollPane Viewport Table Last Name ColumnHeader Andrews'", @@ -286,7 +289,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Return")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TABLE, 5000)) + Atspi.Role.TABLE, 5000)) sequence.append(utils.AssertPresentationAction( "16. Press Return", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Table Demo TabList Table Demo Page ScrollPane Viewport Table Last Name ColumnHeader Beck'", @@ -299,7 +302,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Left")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TABLE, 5000)) + Atspi.Role.TABLE, 5000)) sequence.append(utils.AssertPresentationAction( "17. Press Left Arrow", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Table Demo TabList Table Demo Page ScrollPane Viewport Table First Name ColumnHeader Brian'", @@ -312,7 +315,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Up")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TABLE, 5000)) + Atspi.Role.TABLE, 5000)) sequence.append(utils.AssertPresentationAction( "18. Press Up Arrow", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Table Demo TabList Table Demo Page ScrollPane Viewport Table First Name ColumnHeader Mark'", @@ -325,7 +328,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Right")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TABLE, 5000)) + Atspi.Role.TABLE, 5000)) sequence.append(utils.AssertPresentationAction( "19. Press Right Arrow", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Table Demo TabList Table Demo Page ScrollPane Viewport Table Last Name ColumnHeader Andy'", @@ -357,7 +360,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Return")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TABLE, 5000)) + Atspi.Role.TABLE, 5000)) sequence.append(utils.AssertPresentationAction( "22. Press Return", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Table Demo TabList Table Demo Page ScrollPane Viewport Table Last Name ColumnHeader Beck'", @@ -370,7 +373,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Left")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TABLE, 5000)) + Atspi.Role.TABLE, 5000)) sequence.append(utils.AssertPresentationAction( "23. Press Left Arrow", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Table Demo TabList Table Demo Page ScrollPane Viewport Table First Name ColumnHeader Brian'", @@ -383,7 +386,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Up")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TABLE, 5000)) + Atspi.Role.TABLE, 5000)) sequence.append(utils.AssertPresentationAction( "24. Shift Up Arrow to select the row", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Table Demo TabList Table Demo Page ScrollPane Viewport Table First Name ColumnHeader Mark'", @@ -396,7 +399,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Up")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TABLE, 5000)) + Atspi.Role.TABLE, 5000)) sequence.append(utils.AssertPresentationAction( "25. Shift Up Arrow to select the row", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Table Demo TabList Table Demo Page ScrollPane Viewport Table First Name ColumnHeader Mike'", @@ -421,16 +424,16 @@ sequence.append(utils.AssertPresentationAction( sequence.append(KeyComboAction("Right")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TABLE, 5000)) + Atspi.Role.TABLE, 5000)) sequence.append(KeyComboAction("Left")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TABLE, 5000)) + Atspi.Role.TABLE, 5000)) ########################################################################## # Leave table. sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TEXT)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TEXT)) sequence.append(KeyComboAction("Tab")) # Toggle the top left button, to return to normal state. diff --git a/test/keystrokes/java/role_tree.py b/test/keystrokes/java/role_tree.py index fdabd1f..bb8bdd3 100644 --- a/test/keystrokes/java/role_tree.py +++ b/test/keystrokes/java/role_tree.py @@ -25,6 +25,9 @@ """Test of push buttons in Java's SwingSet2.""" +import gi +gi.require_version('Atspi', '2.0') +from gi.repository import Atspi from macaroon.playback import * import utils @@ -34,7 +37,7 @@ sequence = MacroSequence() # We wait for the demo to come up and for focus to be on the toggle button # #sequence.append(WaitForWindowActivate("SwingSet2",None)) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) # Wait for entire window to get populated. sequence.append(PauseAction(5000)) @@ -43,43 +46,43 @@ sequence.append(PauseAction(5000)) # Tab over to the button demo, and activate it. # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TOGGLE_BUTTON)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TOGGLE_BUTTON)) sequence.append(TypeAction(" ")) ########################################################################## # Tab all the way down to the tree. # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Tree Demo", acc_role=pyatspi.ROLE_PAGE_TAB)) +sequence.append(WaitForFocus("Tree Demo", acc_role=Atspi.Role.PAGE_TAB)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TREE)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TREE)) ########################################################################## # Expected output when node is selected: @@ -87,7 +90,7 @@ sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TREE)) sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TREE, 5000)) + Atspi.Role.TREE, 5000)) sequence.append(utils.AssertPresentationAction( "1. Down Arrow in the tree", ["BRAILLE LINE: 'SwingSet2 Application SwingSet2 Frame RootPane LayeredPane Tree Demo TabList Tree Demo Page ScrollPane Viewport Tree Music expanded TREE LEVEL 1'", @@ -100,7 +103,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TREE, 5000)) + Atspi.Role.TREE, 5000)) sequence.append(utils.AssertPresentationAction( "2. Down Arrow in the tree", ["BRAILLE LINE: 'SwingSet2 Application Classical collapsed TREE LEVEL 2'", @@ -113,7 +116,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TREE, 5000)) + Atspi.Role.TREE, 5000)) sequence.append(utils.AssertPresentationAction( "3. Down Arrow in the tree", ["BRAILLE LINE: 'SwingSet2 Application Jazz collapsed TREE LEVEL 2'", @@ -126,7 +129,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Right")) sequence.append(WaitAction("object:state-changed:expanded", None, None, - pyatspi.ROLE_LABEL, 5000)) + Atspi.Role.LABEL, 5000)) sequence.append(utils.AssertPresentationAction( "4. Right Arrow in the tree", ["BRAILLE LINE: 'SwingSet2 Application Jazz expanded TREE LEVEL 2'", @@ -139,7 +142,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TREE, 5000)) + Atspi.Role.TREE, 5000)) sequence.append(utils.AssertPresentationAction( "5. Down Arrow in the tree", ["BRAILLE LINE: 'SwingSet2 Application Albert Ayler collapsed TREE LEVEL 3'", @@ -152,7 +155,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TREE, 5000)) + Atspi.Role.TREE, 5000)) sequence.append(utils.AssertPresentationAction( "6. Down Arrow in the tree", ["BRAILLE LINE: 'SwingSet2 Application Chet Baker collapsed TREE LEVEL 3'", @@ -165,7 +168,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Right")) sequence.append(WaitAction("object:state-changed:expanded", None, None, - pyatspi.ROLE_LABEL, 5000)) + Atspi.Role.LABEL, 5000)) sequence.append(utils.AssertPresentationAction( "7. Right Arrow in the tree", ["BRAILLE LINE: 'SwingSet2 Application Chet Baker expanded TREE LEVEL 3'", @@ -178,7 +181,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TREE, 5000)) + Atspi.Role.TREE, 5000)) sequence.append(utils.AssertPresentationAction( "8. Down Arrow in the tree", ["BRAILLE LINE: 'SwingSet2 Application Sings and Plays collapsed TREE LEVEL 4'", @@ -191,7 +194,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TREE, 5000)) + Atspi.Role.TREE, 5000)) sequence.append(utils.AssertPresentationAction( "9. Down Arrow in the tree", ["BRAILLE LINE: 'SwingSet2 Application My Funny Valentine collapsed TREE LEVEL 4'", @@ -204,7 +207,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TREE, 5000)) + Atspi.Role.TREE, 5000)) sequence.append(utils.AssertPresentationAction( "10. Down Arrow in the tree", ["BRAILLE LINE: 'SwingSet2 Application Grey December collapsed TREE LEVEL 4'", @@ -229,7 +232,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Right")) sequence.append(WaitAction("object:state-changed:expanded", None, None, - pyatspi.ROLE_LABEL, 5000)) + Atspi.Role.LABEL, 5000)) sequence.append(utils.AssertPresentationAction( "12. Right Arrow in the tree", ["BRAILLE LINE: 'SwingSet2 Application Grey December expanded TREE LEVEL 4'", @@ -254,7 +257,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TREE, 5000)) + Atspi.Role.TREE, 5000)) sequence.append(utils.AssertPresentationAction( "14. Down Arrow in the tree", ["BRAILLE LINE: 'SwingSet2 Application Grey December TREE LEVEL 5'", @@ -267,7 +270,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TREE, 5000)) + Atspi.Role.TREE, 5000)) sequence.append(utils.AssertPresentationAction( "15. Down Arrow in the tree", ["BRAILLE LINE: 'SwingSet2 Application I Wish I Knew TREE LEVEL 5'", @@ -280,7 +283,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Down")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TREE, 5000)) + Atspi.Role.TREE, 5000)) sequence.append(utils.AssertPresentationAction( "16. Down Arrow in the tree", ["BRAILLE LINE: 'SwingSet2 Application Someone To Watch Over Me TREE LEVEL 5'", @@ -305,7 +308,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Up")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TREE, 5000)) + Atspi.Role.TREE, 5000)) sequence.append(utils.AssertPresentationAction( "18. Up Arrow in the tree", ["BRAILLE LINE: 'SwingSet2 Application I Wish I Knew TREE LEVEL 5'", @@ -318,7 +321,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Up")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TREE, 5000)) + Atspi.Role.TREE, 5000)) sequence.append(utils.AssertPresentationAction( "19. Up Arrow in the tree", ["BRAILLE LINE: 'SwingSet2 Application Grey December TREE LEVEL 5'", @@ -331,7 +334,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Up")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TREE, 5000)) + Atspi.Role.TREE, 5000)) sequence.append(utils.AssertPresentationAction( "20. Up Arrow in the tree", ["BRAILLE LINE: 'SwingSet2 Application Grey December expanded TREE LEVEL 4'", @@ -344,7 +347,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Left")) sequence.append(WaitAction("object:state-changed:expanded", None, None, - pyatspi.ROLE_LABEL, 5000)) + Atspi.Role.LABEL, 5000)) sequence.append(utils.AssertPresentationAction( "21. Left Arrow in the tree", ["BRAILLE LINE: 'SwingSet2 Application Grey December collapsed TREE LEVEL 4'", @@ -357,7 +360,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Up")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TREE, 5000)) + Atspi.Role.TREE, 5000)) sequence.append(utils.AssertPresentationAction( "22. Up Arrow in the tree", ["BRAILLE LINE: 'SwingSet2 Application My Funny Valentine collapsed TREE LEVEL 4'", @@ -370,7 +373,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Up")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TREE, 5000)) + Atspi.Role.TREE, 5000)) sequence.append(utils.AssertPresentationAction( "23. Up Arrow in the tree", ["BRAILLE LINE: 'SwingSet2 Application Sings and Plays collapsed TREE LEVEL 4'", @@ -383,7 +386,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Up")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TREE, 5000)) + Atspi.Role.TREE, 5000)) sequence.append(utils.AssertPresentationAction( "24. Up Arrow in the tree", ["BRAILLE LINE: 'SwingSet2 Application Chet Baker expanded TREE LEVEL 3'", @@ -396,7 +399,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Left")) sequence.append(WaitAction("object:state-changed:expanded", None, None, - pyatspi.ROLE_LABEL, 5000)) + Atspi.Role.LABEL, 5000)) sequence.append(utils.AssertPresentationAction( "25. Left Arrow in the tree", ["BRAILLE LINE: 'SwingSet2 Application Chet Baker collapsed TREE LEVEL 3'", @@ -409,7 +412,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Up")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TREE, 5000)) + Atspi.Role.TREE, 5000)) sequence.append(utils.AssertPresentationAction( "26. Up Arrow in the tree", ["BRAILLE LINE: 'SwingSet2 Application Albert Ayler collapsed TREE LEVEL 3'", @@ -422,7 +425,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Up")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TREE, 5000)) + Atspi.Role.TREE, 5000)) sequence.append(utils.AssertPresentationAction( "27. Up Arrow in the tree", ["BRAILLE LINE: 'SwingSet2 Application Jazz expanded TREE LEVEL 2'", @@ -435,7 +438,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Left")) sequence.append(WaitAction("object:state-changed:expanded", None, None, - pyatspi.ROLE_LABEL, 5000)) + Atspi.Role.LABEL, 5000)) sequence.append(utils.AssertPresentationAction( "28. Left Arrow in the tree", ["BRAILLE LINE: 'SwingSet2 Application Jazz collapsed TREE LEVEL 2'", @@ -448,7 +451,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Up")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TREE, 5000)) + Atspi.Role.TREE, 5000)) sequence.append(utils.AssertPresentationAction( "29. Up Arrow in the tree", ["BRAILLE LINE: 'SwingSet2 Application Classical collapsed TREE LEVEL 2'", @@ -461,7 +464,7 @@ sequence.append(utils.AssertPresentationAction( sequence.append(utils.StartRecordingAction()) sequence.append(KeyComboAction("Up")) sequence.append(WaitAction("object:active-descendant-changed", None, None, - pyatspi.ROLE_TREE, 5000)) + Atspi.Role.TREE, 5000)) sequence.append(utils.AssertPresentationAction( "30. Up Arrow in the tree", ["BUG? - Seems a bit chatty", @@ -473,7 +476,7 @@ sequence.append(utils.AssertPresentationAction( # Leave tree # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TEXT)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TEXT)) sequence.append(KeyComboAction("Tab")) diff --git a/test/keystrokes/oobase/bug_463172.py b/test/keystrokes/oobase/bug_463172.py index 5abd8e8..35586df 100644 --- a/test/keystrokes/oobase/bug_463172.py +++ b/test/keystrokes/oobase/bug_463172.py @@ -27,6 +27,9 @@ OOo sbase application crashes when entering a database record. """ +import gi +gi.require_version('Atspi', '2.0') +from gi.repository import Atspi from macaroon.playback import * sequence = MacroSequence() @@ -36,7 +39,7 @@ sequence = MacroSequence() # appear. # sequence.append(WaitForWindowActivate("Database Wizard", None)) -sequence.append(WaitForFocus("Select database", acc_role=pyatspi.ROLE_LABEL)) +sequence.append(WaitForFocus("Select database", acc_role=Atspi.Role.LABEL)) ###################################################################### # 2. Press Return to get to the second screen of the startup wizard. @@ -46,7 +49,7 @@ sequence.append(WaitForFocus("Select database", acc_role=pyatspi.ROLE_LABEL)) # SPEECH OUTPUT: 'Save and proceed label' # sequence.append(KeyComboAction("Return")) -sequence.append(WaitForFocus("Save and proceed", acc_role=pyatspi.ROLE_LABEL)) +sequence.append(WaitForFocus("Save and proceed", acc_role=Atspi.Role.LABEL)) ###################################################################### # 3. Press Tab to get to the database registration radio buttons. @@ -56,7 +59,7 @@ sequence.append(WaitForFocus("Save and proceed", acc_role=pyatspi.ROLE_LABEL)) # SPEECH OUTPUT: 'Yes, register the database for me selected radio button' # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Yes, register the database for me", acc_role=pyatspi.ROLE_RADIO_BUTTON)) +sequence.append(WaitForFocus("Yes, register the database for me", acc_role=Atspi.Role.RADIO_BUTTON)) ###################################################################### # 4. Press down arrow to not register this database. @@ -66,7 +69,7 @@ sequence.append(WaitForFocus("Yes, register the database for me", acc_role=pyats # SPEECH OUTPUT: 'No, do not register the database selected radio button' # sequence.append(KeyComboAction("Down")) -sequence.append(WaitForFocus("No, do not register the database", acc_role=pyatspi.ROLE_RADIO_BUTTON)) +sequence.append(WaitForFocus("No, do not register the database", acc_role=Atspi.Role.RADIO_BUTTON)) sequence.append(PauseAction(3000)) ###################################################################### @@ -78,7 +81,7 @@ sequence.append(PauseAction(3000)) # SPEECH OUTPUT: 'Files table' # sequence.append(KeyComboAction("Return")) -sequence.append(WaitForFocus("Files", acc_role=pyatspi.ROLE_TABLE)) +sequence.append(WaitForFocus("Files", acc_role=Atspi.Role.TABLE)) sequence.append(PauseAction(3000)) ###################################################################### @@ -97,19 +100,19 @@ sequence.append(PauseAction(3000)) # sequence.append(KeyComboAction("Return")) sequence.append(WaitForWindowActivate("New Database - OpenOffice.org Base", None)) -sequence.append(WaitForFocus("IconChoiceControl", acc_role=pyatspi.ROLE_TREE)) +sequence.append(WaitForFocus("IconChoiceControl", acc_role=Atspi.Role.TREE)) ###################################################################### # 7. Enter Alt-f, Alt-c to close the database window. # sequence.append(KeyComboAction("f")) -sequence.append(WaitForFocus("New", acc_role=pyatspi.ROLE_MENU)) +sequence.append(WaitForFocus("New", acc_role=Atspi.Role.MENU)) sequence.append(KeyComboAction("c")) sequence.append(WaitAction("object:property-change:accessible-name", None, None, - pyatspi.ROLE_ROOT_PANE, + Atspi.Role.ROOT_PANE, 30000)) ###################################################################### diff --git a/test/keystrokes/oobase/bug_465109.py b/test/keystrokes/oobase/bug_465109.py index a86e4e3..7b43df4 100644 --- a/test/keystrokes/oobase/bug_465109.py +++ b/test/keystrokes/oobase/bug_465109.py @@ -27,6 +27,9 @@ OOo sbase application crashes when entering a database record. """ +import gi +gi.require_version('Atspi', '2.0') +from gi.repository import Atspi from macaroon.playback import * sequence = MacroSequence() @@ -49,10 +52,10 @@ sequence.append(WaitForWindowActivate("bug_465109 - OpenOffice.org Base", None)) # SPEECH OUTPUT: 'Tables label' # sequence.append(KeyComboAction("v")) -sequence.append(WaitForFocus("Database Objects", acc_role=pyatspi.ROLE_MENU)) +sequence.append(WaitForFocus("Database Objects", acc_role=Atspi.Role.MENU)) sequence.append(KeyComboAction("Right")) -sequence.append(WaitForFocus("Tables", acc_role=pyatspi.ROLE_MENU_ITEM)) +sequence.append(WaitForFocus("Tables", acc_role=Atspi.Role.MENU_ITEM)) sequence.append(KeyComboAction("Return")) @@ -61,13 +64,13 @@ sequence.append(KeyComboAction("Return")) # Tables pane. # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TREE)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TREE)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("None", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("None", acc_role=Atspi.Role.PUSH_BUTTON)) sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TREE)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TREE)) ###################################################################### # 4. Enter down arrow and Return to bring up a separate window showing @@ -82,7 +85,7 @@ sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TREE)) sequence.append(KeyComboAction("Down")) sequence.append(KeyComboAction("Return")) sequence.append(WaitForWindowActivate("bug_465109: NameAddrPhone", None)) -sequence.append(WaitForFocus("Data source table view", acc_role=pyatspi.ROLE_PANEL)) +sequence.append(WaitForFocus("Data source table view", acc_role=Atspi.Role.PANEL)) ###################################################################### # 5. Press Tab to get focus into the LastName field and enter "smith". @@ -93,7 +96,7 @@ sequence.append(WaitForFocus("Data source table view", acc_role=pyatspi.ROLE_PAN # SPEECH OUTPUT: 'text ' # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TEXT)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TEXT)) sequence.append(TypeAction("smith")) @@ -101,7 +104,7 @@ sequence.append(TypeAction("smith")) # 6. Press Tab to get focus into the City field and enter "san francisco". # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TEXT)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TEXT)) sequence.append(TypeAction("san francisco")) @@ -110,7 +113,7 @@ sequence.append(TypeAction("san francisco")) # "california". # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TEXT)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TEXT)) sequence.append(TypeAction("california")) @@ -119,7 +122,7 @@ sequence.append(TypeAction("california")) # "415-555-1212". # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TEXT)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TEXT)) sequence.append(TypeAction("415-555-1212")) @@ -127,33 +130,33 @@ sequence.append(TypeAction("415-555-1212")) # 9. Enter Alt-f, up arrow and Return to select Exit from the File menu. # sequence.append(KeyComboAction("f")) -sequence.append(WaitForFocus("New", acc_role=pyatspi.ROLE_MENU)) +sequence.append(WaitForFocus("New", acc_role=Atspi.Role.MENU)) sequence.append(KeyComboAction("Up")) -sequence.append(WaitForFocus("Exit", acc_role=pyatspi.ROLE_MENU_ITEM)) +sequence.append(WaitForFocus("Exit", acc_role=Atspi.Role.MENU_ITEM)) sequence.append(KeyComboAction("Return")) -sequence.append(WaitForFocus("Yes", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("Yes", acc_role=Atspi.Role.PUSH_BUTTON)) ###################################################################### # 10. Press Tab and Return to not save the current changes. # This dismisses the NameAddrPhone table window. # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("No", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("No", acc_role=Atspi.Role.PUSH_BUTTON)) sequence.append(KeyComboAction("Return")) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_TREE)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.TREE)) ###################################################################### # 9. Enter Alt-f, up arrow and Return to select Exit from the File menu. # of the main oobase window. # sequence.append(KeyComboAction("f")) -sequence.append(WaitForFocus("New", acc_role=pyatspi.ROLE_MENU)) +sequence.append(WaitForFocus("New", acc_role=Atspi.Role.MENU)) sequence.append(KeyComboAction("Up")) -sequence.append(WaitForFocus("Exit", acc_role=pyatspi.ROLE_MENU_ITEM)) +sequence.append(WaitForFocus("Exit", acc_role=Atspi.Role.MENU_ITEM)) sequence.append(KeyComboAction("Return")) diff --git a/test/keystrokes/ooimpress/bug_462239.py b/test/keystrokes/ooimpress/bug_462239.py index be6ea46..8d3b8d4 100644 --- a/test/keystrokes/ooimpress/bug_462239.py +++ b/test/keystrokes/ooimpress/bug_462239.py @@ -28,6 +28,9 @@ to open an existing presentation. """ +import gi +gi.require_version('Atspi', '2.0') +from gi.repository import Atspi from macaroon.playback import * sequence = MacroSequence() @@ -48,13 +51,13 @@ sequence.append(WaitForWindowActivate("subtlewaves - OpenOffice.org Impress", No # 2. Enter Alt-f, Alt-c to close the presentation window. # sequence.append(KeyComboAction("f")) -sequence.append(WaitForFocus("New", acc_role=pyatspi.ROLE_MENU)) +sequence.append(WaitForFocus("New", acc_role=Atspi.Role.MENU)) sequence.append(KeyComboAction("c")) sequence.append(WaitAction("object:property-change:accessible-name", None, None, - pyatspi.ROLE_ROOT_PANE, + Atspi.Role.ROOT_PANE, 30000)) ###################################################################### diff --git a/test/keystrokes/ooimpress/bug_462256.py b/test/keystrokes/ooimpress/bug_462256.py index 699d843..b0fae26 100644 --- a/test/keystrokes/ooimpress/bug_462256.py +++ b/test/keystrokes/ooimpress/bug_462256.py @@ -28,6 +28,9 @@ the OOo Presentation startup wizard. """ +import gi +gi.require_version('Atspi', '2.0') +from gi.repository import Atspi from macaroon.playback import * sequence = MacroSequence() @@ -42,7 +45,7 @@ sequence = MacroSequence() # SPEECH OUTPUT: 'Next >> button' # sequence.append(WaitForWindowActivate("Presentation Wizard", None)) -sequence.append(WaitForFocus("Next", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("Next", acc_role=Atspi.Role.PUSH_BUTTON)) ###################################################################### # 2. Press Return to get to the second screen of the startup wizard. @@ -62,7 +65,7 @@ sequence.append(KeyComboAction("Return")) # SPEECH OUTPUT: 'Create button' # sequence.append(KeyComboAction("Return")) -sequence.append(WaitForFocus("Create", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("Create", acc_role=Atspi.Role.PUSH_BUTTON)) ###################################################################### # 4. Press Return to start up ooimpress with an empty presentation. @@ -75,22 +78,22 @@ sequence.append(WaitForFocus("Create", acc_role=pyatspi.ROLE_PUSH_BUTTON)) # sequence.append(KeyComboAction("Return")) sequence.append(WaitForWindowActivate("Untitled1 - OpenOffice.org Impress", None)) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_SCROLL_PANE)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.SCROLL_PANE)) ###################################################################### # 5. Enter Alt-f, Alt-c to close the presentation window. # sequence.append(KeyComboAction("f")) -sequence.append(WaitForFocus("New", acc_role=pyatspi.ROLE_MENU)) +sequence.append(WaitForFocus("New", acc_role=Atspi.Role.MENU)) sequence.append(KeyComboAction("c")) -sequence.append(WaitForFocus("Save", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("Save", acc_role=Atspi.Role.PUSH_BUTTON)) ###################################################################### # 6. Enter Tab and Return to discard the current changes. # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Discard", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("Discard", acc_role=Atspi.Role.PUSH_BUTTON)) sequence.append(KeyComboAction("Return")) diff --git a/test/keystrokes/ooimpress/bug_462547.py b/test/keystrokes/ooimpress/bug_462547.py index d1b219e..88b8ca7 100644 --- a/test/keystrokes/ooimpress/bug_462547.py +++ b/test/keystrokes/ooimpress/bug_462547.py @@ -27,6 +27,9 @@ OOo-dev 2.3.0 simpress application startup wizard hangs the desktop. """ +import gi +gi.require_version('Atspi', '2.0') +from gi.repository import Atspi from macaroon.playback import * sequence = MacroSequence() @@ -41,7 +44,7 @@ sequence = MacroSequence() # SPEECH OUTPUT: 'Next >> button' # sequence.append(WaitForWindowActivate("Presentation Wizard", None)) -sequence.append(WaitForFocus("Next", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("Next", acc_role=Atspi.Role.PUSH_BUTTON)) ###################################################################### # 2. Press Space to get to the second screen of the startup wizard. @@ -61,7 +64,7 @@ sequence.append(KeyComboAction("space")) # SPEECH OUTPUT: 'Create button' # sequence.append(KeyComboAction("space")) -sequence.append(WaitForFocus("Create", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("Create", acc_role=Atspi.Role.PUSH_BUTTON)) ###################################################################### # 4. Press Space to start up ooimpress with an empty presentation. @@ -74,22 +77,22 @@ sequence.append(WaitForFocus("Create", acc_role=pyatspi.ROLE_PUSH_BUTTON)) # sequence.append(KeyComboAction("space")) sequence.append(WaitForWindowActivate("Untitled1 - OpenOffice.org Impress", None)) -sequence.append(WaitForFocus("", acc_role=pyatspi.ROLE_SCROLL_PANE)) +sequence.append(WaitForFocus("", acc_role=Atspi.Role.SCROLL_PANE)) ###################################################################### # 5. Enter Alt-f, Alt-c to close the presentation window. # sequence.append(KeyComboAction("f")) -sequence.append(WaitForFocus("New", acc_role=pyatspi.ROLE_MENU)) +sequence.append(WaitForFocus("New", acc_role=Atspi.Role.MENU)) sequence.append(KeyComboAction("c")) -sequence.append(WaitForFocus("Save", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("Save", acc_role=Atspi.Role.PUSH_BUTTON)) ###################################################################### # 6. Enter Tab and Return to discard the current changes. # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Discard", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("Discard", acc_role=Atspi.Role.PUSH_BUTTON)) sequence.append(KeyComboAction("Return")) diff --git a/test/keystrokes/ooimpress/bug_465449.py b/test/keystrokes/ooimpress/bug_465449.py index dbfb280..f72f209 100644 --- a/test/keystrokes/ooimpress/bug_465449.py +++ b/test/keystrokes/ooimpress/bug_465449.py @@ -27,6 +27,9 @@ OOo simpress crashes when trying to change view modes. """ +import gi +gi.require_version('Atspi', '2.0') +from gi.repository import Atspi from macaroon.playback import * sequence = MacroSequence() @@ -52,28 +55,28 @@ sequence.append(WaitForWindowActivate("subtlewaves - OpenOffice.org Impress", No # SPEECH OUTPUT: 'scroll pane' # sequence.append(KeyComboAction("v")) -sequence.append(WaitForFocus("Normal", acc_role=pyatspi.ROLE_MENU_ITEM)) +sequence.append(WaitForFocus("Normal", acc_role=Atspi.Role.MENU_ITEM)) sequence.append(KeyComboAction("Down")) -sequence.append(WaitForFocus("Outline", acc_role=pyatspi.ROLE_MENU_ITEM)) +sequence.append(WaitForFocus("Outline", acc_role=Atspi.Role.MENU_ITEM)) sequence.append(KeyComboAction("Return")) -sequence.append(WaitForFocus("Paragraph 0", acc_role=pyatspi.ROLE_PARAGRAPH)) +sequence.append(WaitForFocus("Paragraph 0", acc_role=Atspi.Role.PARAGRAPH)) ###################################################################### # 3. Enter Alt-f, Alt-c to close the presentation window. # sequence.append(KeyComboAction("f")) -sequence.append(WaitForFocus("New", acc_role=pyatspi.ROLE_MENU)) +sequence.append(WaitForFocus("New", acc_role=Atspi.Role.MENU)) sequence.append(KeyComboAction("c")) -sequence.append(WaitForFocus("Save", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("Save", acc_role=Atspi.Role.PUSH_BUTTON)) ###################################################################### # 4. Enter Tab and Return to discard any changes. # sequence.append(KeyComboAction("Tab")) -sequence.append(WaitForFocus("Discard", acc_role=pyatspi.ROLE_PUSH_BUTTON)) +sequence.append(WaitForFocus("Discard", acc_role=Atspi.Role.PUSH_BUTTON)) sequence.append(KeyComboAction("Return")) ######################################################################