# Orca # # 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 # 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=too-many-public-methods # pylint: disable=too-many-lines """Utilities for accessibility inspection.""" from __future__ import annotations import functools import inspect import queue import threading import time from typing import TYPE_CHECKING 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_table import AXTable from .ax_text import AXText from .ax_utilities_action import AXUtilitiesAction from .ax_utilities_application import AXUtilitiesApplication from .ax_utilities_collection import AXUtilitiesCollection from .ax_utilities_component import AXUtilitiesComponent from .ax_utilities_event import AXUtilitiesEvent from .ax_utilities_object import AXUtilitiesObject from .ax_utilities_relation import AXUtilitiesRelation from .ax_utilities_role import AXUtilitiesRole from .ax_utilities_selection import AXUtilitiesSelection from .ax_utilities_state import AXUtilitiesState from .ax_utilities_table import AXUtilitiesTable from .ax_utilities_text import AXUtilitiesText if TYPE_CHECKING: from typing import ClassVar class AXUtilities: """Utilities for accessibility inspection.""" COMPARE_COLLECTION_PERFORMANCE = False # Things we cache. SET_MEMBERS: ClassVar[dict[int, list[Atspi.Accessible]]] = {} IS_LAYOUT_ONLY: ClassVar[dict[int, tuple[bool, str]]] = {} _lock = threading.Lock() @staticmethod def start_cache_clearing_thread() -> None: """Starts thread to periodically clear cached details.""" thread = threading.Thread(target=AXUtilities._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) 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) AXUtilitiesSelection.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 AXObject.clear_cache(window, False, "Checking if window can be the active window") app = AXUtilitiesApplication.get_application(window) tokens = ["AXUtilities:", window, "from", app] can_be_active = True if not AXUtilitiesState.is_active(window): tokens.append("lacks active state") can_be_active = False elif not AXUtilitiesState.is_showing(window): tokens.append("lacks showing state") can_be_active = False elif AXUtilitiesState.is_iconified(window): tokens.append("is iconified") can_be_active = False elif AXObject.get_name(app) == "mutter-x11-frames": tokens.append("is from app that cannot have the real active window") can_be_active = False elif 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): can_be_active = False if can_be_active: tokens.append("can be active window") debug.print_tokens(debug.LEVEL_INFO, tokens, True) return can_be_active @staticmethod def find_active_window() -> Atspi.Accessible | None: """Tries to locate the active window; may or may not succeed.""" 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 if len(candidates) == 1: tokens = ["AXUtilities: Active window is", candidates[0]] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return candidates[0] 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 is_unfocused_alert_or_dialog(obj: Atspi.Accessible) -> bool: """Returns True if obj is an unfocused alert or dialog with presentable items.""" 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_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 = AXUtilitiesObject.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: result = AXUtilitiesCollection.find_all_with_role(obj, roles) else: states = [Atspi.StateType.SHOWING, Atspi.StateType.VISIBLE] result = AXUtilitiesCollection.find_all_with_role_and_all_states(obj, roles, states) if not AXUtilities.COMPARE_COLLECTION_PERFORMANCE: return result def is_match(acc): if AXObject.get_role(acc) not in roles: return False if must_be_showing_and_visible: return AXUtilitiesState.is_showing(acc) and AXUtilitiesState.is_visible(acc) return True return AXUtilitiesObject.find_all_descendants(obj, is_match) @staticmethod def get_default_button(obj: Atspi.Accessible) -> Atspi.Accessible | None: """Returns the default button descendant of obj""" result = None if AXObject.supports_collection(obj): result = AXUtilitiesCollection.find_default_button(obj) if not AXUtilities.COMPARE_COLLECTION_PERFORMANCE: return result return AXUtilitiesObject.find_descendant(obj, AXUtilitiesRole.is_default_button) @staticmethod def get_details_content(obj: Atspi.Accessible) -> list[str]: """Returns a list of non-empty text strings from the details of obj.""" details = AXUtilitiesRelation.get_details(obj) if ( not details and AXUtilitiesRole.is_toggle_button(obj) and AXUtilitiesState.is_expanded(obj) ): details = list(AXObject.iter_children(obj)) result: list[str] = [] for detail in details: texts = [ AXText.get_all_text(d).strip() for d in AXUtilities.get_text_descendants(detail) ] result.extend(t for t in texts if t) return result @staticmethod def get_description_list_terms(obj: Atspi.Accessible) -> list[Atspi.Accessible]: """Returns all description list terms in obj, excluding nested description lists.""" if not AXUtilitiesRole.is_description_list(obj): return [] if AXObject.supports_collection(obj): matches = AXUtilitiesCollection.find_all_description_terms(obj) return [ m for m in matches if AXUtilitiesObject.find_ancestor(m, AXUtilitiesRole.is_description_list) == obj ] return AXUtilitiesObject.find_all_descendants( obj, AXUtilitiesRole.is_description_term, AXUtilitiesRole.is_description_list ) @staticmethod def get_text_descendants(obj: Atspi.Accessible) -> list[Atspi.Accessible]: """Returns all descendants of obj that implement the Text interface.""" if AXObject.supports_collection(obj): return AXUtilitiesCollection.find_all_with_interfaces(obj, ["Text"]) return AXUtilitiesObject.find_all_descendants(obj, AXObject.supports_text) @staticmethod def get_focused_object(obj: Atspi.Accessible) -> Atspi.Accessible | None: """Returns the focused descendant of obj""" result = None if AXObject.supports_collection(obj): result = AXUtilitiesCollection.find_focused_object(obj) if not AXUtilities.COMPARE_COLLECTION_PERFORMANCE: return result return AXUtilitiesObject.find_descendant(obj, AXUtilitiesState.is_focused) @staticmethod 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 AXUtilitiesObject.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 if AXObject.supports_collection(obj): result = AXUtilitiesCollection.find_status_bar(obj) if not AXUtilities.COMPARE_COLLECTION_PERFORMANCE: return result return AXUtilitiesObject.find_descendant(obj, AXUtilitiesRole.is_status_bar) @staticmethod def get_label(obj: Atspi.Accessible) -> Atspi.Accessible | None: """Returns the label descendant of obj""" result = None if AXObject.supports_collection(obj): result = AXUtilitiesCollection.find_first_with_role(obj, [Atspi.Role.LABEL]) if not AXUtilities.COMPARE_COLLECTION_PERFORMANCE: return result return AXUtilitiesObject.find_descendant(obj, AXUtilitiesRole.is_label) @staticmethod def get_list_item(obj: Atspi.Accessible) -> Atspi.Accessible | None: """Returns the list item descendant of obj""" result = None if AXObject.supports_collection(obj): result = AXUtilitiesCollection.find_first_with_role(obj, [Atspi.Role.LIST_ITEM]) if not AXUtilities.COMPARE_COLLECTION_PERFORMANCE: return result return AXUtilitiesObject.find_descendant(obj, AXUtilitiesRole.is_list_item) @staticmethod def get_menu(obj: Atspi.Accessible) -> Atspi.Accessible | None: """Returns the menu descendant of obj""" result = None if AXObject.supports_collection(obj): result = AXUtilitiesCollection.find_first_with_role(obj, [Atspi.Role.MENU]) if not AXUtilities.COMPARE_COLLECTION_PERFORMANCE: return result return AXUtilitiesObject.find_descendant(obj, AXUtilitiesRole.is_menu) @staticmethod def get_menu_or_list_box(obj: Atspi.Accessible) -> Atspi.Accessible | None: """Returns the menu or list box descendant of obj""" result = None if AXObject.supports_collection(obj): result = AXUtilitiesCollection.find_first_with_role( obj, [Atspi.Role.MENU, Atspi.Role.LIST_BOX] ) if not AXUtilities.COMPARE_COLLECTION_PERFORMANCE: return result return AXUtilitiesObject.find_descendant( obj, lambda x: AXUtilitiesRole.is_menu(x) or AXUtilitiesRole.is_list_box(x) ) @staticmethod def get_progress_bar(obj: Atspi.Accessible) -> Atspi.Accessible | None: """Returns the progress bar descendant of obj""" result = None if AXObject.supports_collection(obj): result = AXUtilitiesCollection.find_first_with_role(obj, [Atspi.Role.PROGRESS_BAR]) if not AXUtilities.COMPARE_COLLECTION_PERFORMANCE: return result return AXUtilitiesObject.find_descendant(obj, AXUtilitiesRole.is_progress_bar) @staticmethod def get_slider(obj: Atspi.Accessible) -> Atspi.Accessible | None: """Returns the slider descendant of obj""" result = None if AXObject.supports_collection(obj): result = AXUtilitiesCollection.find_first_with_role(obj, [Atspi.Role.SLIDER]) if not AXUtilities.COMPARE_COLLECTION_PERFORMANCE: return result return AXUtilitiesObject.find_descendant(obj, AXUtilitiesRole.is_slider) @staticmethod def get_table_cell(obj: Atspi.Accessible) -> Atspi.Accessible | None: """Returns the table cell descendant of obj""" result = None if AXObject.supports_collection(obj): result = AXUtilitiesCollection.find_first_with_role(obj, [Atspi.Role.TABLE_CELL]) if not AXUtilities.COMPARE_COLLECTION_PERFORMANCE: return result return AXUtilitiesObject.find_descendant(obj, AXUtilitiesRole.is_table_cell) @staticmethod def get_table_cell_or_header(obj: Atspi.Accessible) -> Atspi.Accessible | None: """Returns the table cell or header descendant of obj""" result = None if AXObject.supports_collection(obj): result = AXUtilitiesCollection.find_first_with_role( obj, AXUtilitiesRole.get_table_cell_roles() ) if not AXUtilities.COMPARE_COLLECTION_PERFORMANCE: return result return AXUtilitiesObject.find_descendant(obj, AXUtilitiesRole.is_table_cell_or_header) @staticmethod def get_descendant_supporting_text(obj: Atspi.Accessible) -> Atspi.Accessible | None: """Returns the first descendant of obj implementing the text interface""" result = None if AXObject.supports_collection(obj): result = AXUtilitiesCollection.find_first_with_interfaces(obj, ["Text"]) if not AXUtilities.COMPARE_COLLECTION_PERFORMANCE: return result return AXUtilitiesObject.find_descendant(obj, AXObject.supports_text) @staticmethod def get_text_input(obj: Atspi.Accessible) -> Atspi.Accessible | None: """Returns the text input descendant of obj""" result = None if AXObject.supports_collection(obj): result = AXUtilitiesCollection.find_first_with_role( obj, [Atspi.Role.ENTRY, Atspi.Role.PASSWORD_TEXT, Atspi.Role.SPIN_BUTTON] ) if result is None: result = AXUtilitiesCollection.find_first_with_role_and_all_states( obj, [Atspi.Role.TEXT], [Atspi.StateType.EDITABLE, Atspi.StateType.SINGLE_LINE] ) if not AXUtilities.COMPARE_COLLECTION_PERFORMANCE: return result return AXUtilitiesObject.find_descendant(obj, AXUtilitiesRole.is_text_input) @staticmethod def _is_layout_only_layered_pane(obj: Atspi.Accessible) -> tuple[bool, str]: """Returns True with reason if this layered pane is layout-only.""" if AXUtilitiesObject.find_ancestor(obj, AXUtilitiesRole.is_desktop_frame) is not None: return True, "is inside desktop frame" return False, "" @staticmethod def _is_layout_only_menu_or_list(obj: Atspi.Accessible) -> tuple[bool, str]: """Returns True with reason if this menu or list in a combo box is layout-only.""" if AXUtilitiesObject.find_ancestor(obj, AXUtilitiesRole.is_combo_box) is not None: return True, "is inside combo box" return False, "" @staticmethod def _is_layout_only_group(obj: Atspi.Accessible) -> tuple[bool, str]: """Returns True with reason if this group is layout-only.""" if not AXUtilities.has_explicit_name(obj): return True, "lacks explicit name" return False, "" @staticmethod def _is_layout_only_panel(obj: Atspi.Accessible) -> tuple[bool, str]: """Returns True with reason if this panel or grouping is layout-only.""" 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 = AXUtilitiesObject.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, "" @staticmethod def _is_layout_only_section(obj: Atspi.Accessible) -> tuple[bool, str]: """Returns True with reason if this section or document is layout-only.""" if AXUtilitiesState.is_focusable(obj): return False, "is focusable" if AXUtilitiesAction.has_action(obj, "click"): return False, "has click action" return True, "is not interactive" @staticmethod def _is_layout_only_toolbar(obj: Atspi.Accessible) -> tuple[bool, str]: """Returns True with reason if this toolbar is layout-only.""" if AXUtilitiesRole.is_page_tab_list(AXObject.get_child(obj, 0)): return True, "is parent of page tab list" return False, "" @staticmethod def _is_layout_only_table(obj: Atspi.Accessible) -> tuple[bool, str]: """Returns True with reason if this table is layout-only.""" if AXUtilitiesTable.is_layout_table(obj): return True, "is layout table" return False, "" @staticmethod def _is_layout_only_table_row(obj: Atspi.Accessible) -> tuple[bool, str]: """Returns True with reason if this table row is layout-only.""" 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" @staticmethod def _is_layout_only_table_cell(obj: Atspi.Accessible) -> tuple[bool, str]: """Returns True with reason if this table cell is layout-only.""" if AXUtilitiesRole.is_table_cell(AXObject.get_child(obj, 0)): return True, "child of this cell is table cell" table = AXUtilitiesTable.get_table(obj) if AXUtilitiesRole.is_table(table) and AXUtilitiesTable.is_layout_table(table): return True, "is in layout table" return False, "" @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.""" role = AXObject.get_role(obj) if role in AXUtilitiesRole.get_layout_only_roles(): return True, "has layout-only role" result, reason = False, "" if AXUtilitiesRole.is_layered_pane(obj, role): result, reason = AXUtilities._is_layout_only_layered_pane(obj) elif AXUtilitiesRole.is_menu(obj, role) or AXUtilitiesRole.is_list(obj, role): result, reason = AXUtilities._is_layout_only_menu_or_list(obj) elif AXUtilitiesRole.is_group(obj, role): result, reason = AXUtilities._is_layout_only_group(obj) elif AXUtilitiesRole.is_panel(obj, role) or AXUtilitiesRole.is_grouping(obj, role): result, reason = AXUtilities._is_layout_only_panel(obj) elif AXUtilitiesRole.is_section(obj, role) or AXUtilitiesRole.is_document(obj, role): result, reason = AXUtilities._is_layout_only_section(obj) elif AXUtilitiesRole.is_tool_bar(obj): result, reason = AXUtilities._is_layout_only_toolbar(obj) elif AXUtilitiesRole.is_table(obj, role): result, reason = AXUtilities._is_layout_only_table(obj) elif AXUtilitiesRole.is_table_row(obj): result, reason = AXUtilities._is_layout_only_table_row(obj) elif AXUtilitiesRole.is_table_cell(obj, role): result, reason = AXUtilities._is_layout_only_table_cell(obj) return result, 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 tokens = ["AXUtilities:", obj] result = True if AXUtilitiesCollection.has_scroll_pane(obj): tokens.append("is not a message dialog: has scroll pane") result = False elif AXUtilitiesCollection.has_split_pane(obj): tokens.append("is not a message dialog: has split pane") result = False elif AXUtilitiesCollection.has_tree_or_tree_table(obj): tokens.append("is not a message dialog: has tree or tree table") result = False elif AXUtilitiesCollection.has_combo_box_or_list_box(obj): tokens.append("is not a message dialog: has combo box or list box") result = False elif AXUtilitiesCollection.has_editable_object(obj): tokens.append("is not a message dialog: has editable object") result = False else: tokens.append("is believed to be a message dialog") debug.print_tokens(debug.LEVEL_INFO, tokens, True) return result @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 = AXUtilitiesComponent.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. In a listbox, items scrolled out of view lose the showing state but # are still valid members. def is_combo_box_or_list_box(x): return AXUtilitiesRole.is_combo_box(x) or AXUtilitiesRole.is_list_box(x) must_be_showing = not AXUtilitiesObject.find_ancestor(obj, is_combo_box_or_list_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(AXUtilitiesTable.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(AXUtilitiesTable.get_table(obj)) if AXUtilitiesRole.is_combo_box(obj): selected_children = AXUtilitiesSelection.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 = AXUtilities.get_list_item(obj) 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_table_row_position(obj: Atspi.Accessible) -> int: """Returns the position of a table row from ARIA attributes, or -1.""" result = AXObject.get_attribute(obj, "rowindex", False) if not (isinstance(result, str) and result.isnumeric()) and AXObject.get_child_count(obj): cell = AXUtilities.get_table_cell_or_header(obj) result = AXObject.get_attribute(cell, "rowindex", False) if isinstance(result, str) and result.isnumeric(): return int(result) - 1 return -1 @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(): return int(result) - 1 if AXUtilitiesRole.is_table_row(obj): position = AXUtilities._get_table_row_position(obj) if position >= 0: return position 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 = AXUtilitiesSelection.get_selected_children(obj) if not selected_children: return -1 if len(selected_children) == 1: obj = selected_children[0] if AXObject.get_child_count(AXObject.get_parent(obj)) > 500: return AXObject.get_index_in_parent(obj) members = AXUtilities.get_set_members(obj) return members.index(obj) if obj in members else -1 @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 has_non_inline_children(obj: Atspi.Accessible) -> bool: """Returns True if obj has children with block, flex, or table display.""" for child in AXObject.iter_children(obj): display = AXObject.get_attribute(child, "display") if display in ("block", "flex", "table"): 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 = AXUtilitiesObject.find_ancestor(obj, pred) while ancestor: ancestors.append(ancestor) ancestor = AXUtilitiesObject.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 AXUtilitiesComponent.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 AXUtilitiesComponent.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 AXUtilitiesComponent.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. return not AXUtilitiesRole.is_page_tab(obj) 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(AXUtilitiesTable.iter_visible_cells(root)) objects = [] root_name = AXObject.get_name(root) if ( root_name or AXObject.get_description(root) or AXUtilitiesText.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) is_interactive = AXUtilitiesState.is_focusable(root) or AXUtilitiesAction.has_action( root, "click" ) if not objects and is_interactive: objects = [root] return objects @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 @staticmethod def active_descendant(obj: Atspi.Accessible) -> Atspi.Accessible | None: """Legacy table-cell code originally for managed descendants.""" # TODO - JD: Determine what actually needs this support and why. if AXObject.is_dead(obj): return None if not AXUtilitiesRole.is_table_cell(obj): return obj if AXObject.get_name(obj): return obj def pred(x: Atspi.Accessible) -> bool: return bool(AXObject.get_name(x) or AXText.get_all_text(x)) child = AXUtilitiesObject.find_descendant(obj, pred) if child is not None: return child return obj @staticmethod def find_previous_object( obj: Atspi.Accessible, restrict_to: Atspi.Accessible | None = None, ) -> Atspi.Accessible | None: """Finds the object before this one, optionally restricted to a subtree.""" result = AXUtilities._get_previous_object(obj) tokens = ["AXUtilities: Previous object for", obj, "is", result] debug.print_tokens(debug.LEVEL_INFO, tokens, True) if restrict_to is not None and not AXUtilitiesObject.is_ancestor(result, restrict_to, True): tokens = ["AXUtilities:", result, "is not a descendant of", restrict_to] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return None return result @staticmethod def find_next_object( obj: Atspi.Accessible, restrict_to: Atspi.Accessible | None = None, ) -> Atspi.Accessible | None: """Finds the object after this one, optionally restricted to a subtree.""" result = AXUtilities._get_next_object(obj) tokens = ["AXUtilities: Next object for", obj, "is", result] debug.print_tokens(debug.LEVEL_INFO, tokens, True) if restrict_to is not None and not AXUtilitiesObject.is_ancestor(result, restrict_to, True): tokens = ["AXUtilities:", result, "is not a descendant of", restrict_to] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return None return result for method_name, method in inspect.getmembers(AXUtilitiesAction, predicate=inspect.isfunction): setattr(AXUtilities, method_name, method) 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(AXUtilitiesSelection, 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) for method_name, method in inspect.getmembers(AXUtilitiesObject, predicate=inspect.isfunction): setattr(AXUtilities, method_name, method) for method_name, method in inspect.getmembers(AXUtilitiesComponent, predicate=inspect.isfunction): setattr(AXUtilities, method_name, method) for method_name, method in inspect.getmembers(AXUtilitiesTable, predicate=inspect.isfunction): setattr(AXUtilities, method_name, method) for method_name, method in inspect.getmembers(AXUtilitiesText, predicate=inspect.isfunction): setattr(AXUtilities, method_name, method) AXUtilities.start_cache_clearing_thread()