# Orca # # Copyright 2010 Joanmarie Diggs. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # 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-locals # pylint: disable=too-many-branches # pylint: disable=too-many-return-statements """Utilities for providing app/toolkit-specific information about objects and events.""" __id__ = "$Id$" __version__ = "$Revision$" __date__ = "$Date$" __copyright__ = "Copyright (c) 2010 Joanmarie Diggs." __license__ = "LGPL" from typing import Callable import gi gi.require_version("Atspi", "2.0") from gi.repository import Atspi from . import debug from . import focus_manager from . import input_event_manager from . import messages from . import object_properties from . import script_manager from . import settings from . import settings_manager from .ax_component import AXComponent from .ax_hypertext import AXHypertext from .ax_object import AXObject from .ax_selection import AXSelection from .ax_table import AXTable from .ax_text import AXText from .ax_utilities import AXUtilities from .ax_value import AXValue class Utilities: """Utilities for providing app/toolkit-specific information about objects and events.""" ZERO_WIDTH_NO_BREAK_SPACE = '\ufeff' def __init__(self, script): self._script = script # TODO - JD: We actually need to keep track of a root object and its descendant # caret context, just like we do in the web script. And it all should move into # the focus manager. In the meantime, just get things working for the active app. self._caret_context = (None, -1) def node_level(self, obj: Atspi.Accessible) -> int: """Returns the node level of the specified tree item.""" if not AXObject.find_ancestor(obj, AXUtilities.is_tree_or_tree_table): return -1 attrs = AXObject.get_attributes_dict(obj) if "level" in attrs: # ARIA levels are 1-based. return int(attrs.get("level", 0)) - 1 nodes = [] node = obj while node and (targets := AXUtilities.get_is_node_child_of(node)): node = targets[0] nodes.append(node) return len(nodes) - 1 def child_nodes(self, obj: Atspi.Accessible) -> list[Atspi.Accessible]: """Gets all of the objects that have RELATION_NODE_CHILD_OF pointing to this cell.""" if not AXUtilities.is_expanded(obj): return [] table = AXTable.get_table(obj) if table is None: return [] nodes = AXUtilities.get_is_node_parent_of(obj) tokens = ["SCRIPT UTILITIES:", len(nodes), "child nodes for", obj, "via node-parent-of"] debug.print_tokens(debug.LEVEL_INFO, tokens, True) if nodes: return nodes # Candidates will be in the rows beneath the current row. Only check in the current column # and stop checking as soon as the node level of a candidate is equal or less than our # current level. row, col = AXTable.get_cell_coordinates(obj, prefer_attribute=False) level = self.node_level(obj) for i in range(row + 1, AXTable.get_row_count(table, prefer_attribute=False)): cell = AXTable.get_cell_at(table, i, col) targets = AXUtilities.get_is_node_child_of(cell) if not targets: continue node_of = targets[0] if obj == node_of: nodes.append(cell) elif self.node_level(node_of) <= level: break tokens = ["SCRIPT UTILITIES:", len(nodes), "child nodes for", obj, "via node-child-of"] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return nodes def details_content_for_object(self, obj: Atspi.Accessible) -> list[str]: """Returns a list of strings containing the details for obj.""" details = self._details_for_object(obj) return list(map(AXText.get_all_text, details)) def _details_for_object(self, obj: Atspi.Accessible) -> list[Atspi.Accessible]: """Return a list of objects containing details for obj.""" details = AXUtilities.get_details(obj) if not details and AXUtilities.is_toggle_button(obj) and AXUtilities.is_expanded(obj): details = list(AXObject.iter_children(obj)) text_objects = [] for detail in details: text_objects.extend(self._find_all_descendants( detail, lambda x: not AXText.is_whitespace_or_empty(x))) return text_objects def frame_and_dialog( self, obj: Atspi.Accessible | None = None ) -> list[Atspi.Accessible | None]: """Returns the frame and (possibly) the dialog containing obj.""" results = [None, None] obj = obj or focus_manager.get_manager().get_locus_of_focus() if not obj: msg = "SCRIPT UTILITIES: frame_and_dialog() called without valid object" debug.print_message(debug.LEVEL_INFO, msg, True) return results top_level = self.top_level_object(obj) if top_level is None: tokens = ["SCRIPT UTILITIES: could not find top-level object for", obj] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return results dialog_roles = [Atspi.Role.DIALOG, Atspi.Role.FILE_CHOOSER, Atspi.Role.ALERT] role = AXObject.get_role(top_level) if role in dialog_roles: results[1] = top_level else: if role in [Atspi.Role.FRAME, Atspi.Role.WINDOW]: results[0] = top_level def is_dialog(x): return AXObject.get_role(x) in dialog_roles results[1] = AXObject.find_ancestor_inclusive(obj, is_dialog) tokens = ["SCRIPT UTILITIES:", obj, "is in frame", results[0], "and dialog", results[1]] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return results def grab_focus_when_setting_caret(self, obj: Atspi.Accessible) -> bool: """Returns true if we should grab focus when setting the caret.""" return AXUtilities.is_focusable(obj) def grab_focus_before_routing(self, obj: Atspi.Accessible) -> bool: """Returns true if we should grab focus before routing the cursor.""" return AXUtilities.is_combo_box(obj) \ and obj != focus_manager.get_manager().get_locus_of_focus() def in_find_container(self, obj: Atspi.Accessible | None = None) -> bool: """Returns True if obj is in a find-in-page container.""" if obj is None: obj = focus_manager.get_manager().get_locus_of_focus() if not AXUtilities.is_entry(obj): return False return AXObject.find_ancestor(obj, AXUtilities.is_tool_bar) is not None def get_find_results_count(self, _root: Atspi.Accessible | None = None) -> str: """Returns a string description of the number of find-in-page results in root.""" return "" def is_anchor(self, obj: Atspi.Accessible) -> bool: """Returns true if obj is an anchor.""" # TODO - JD: Move this into the AXUtilities. return AXUtilities.is_link(obj) and not AXUtilities.is_focusable(obj) \ and not AXObject.has_action(obj, "jump") and not AXUtilities.has_role_from_aria(obj) def is_progress_bar_update(self, obj: Atspi.Accessible) -> tuple[bool, str]: """Returns a (is-update, reason) tuple if updates to obj should be presented.""" # TODO - JD: Move this into the AXUtilities. if not settings_manager.get_manager().get_setting("speakProgressBarUpdates") \ and not settings_manager.get_manager().get_setting("brailleProgressBarUpdates") \ and not settings_manager.get_manager().get_setting("beepProgressBarUpdates"): return False, "Updates not enabled" if not AXUtilities.is_progress_bar(obj): return False, "Is not progress bar" if not AXValue.get_value_as_percent(obj): return False, "Could not obtain value" if AXComponent.has_no_size(obj): return False, "Has no size" if settings_manager.get_manager().get_setting('ignoreStatusBarProgressBars'): if AXObject.find_ancestor(obj, AXUtilities.is_status_bar): return False, "Is status bar descendant" verbosity = settings_manager.get_manager().get_setting('progressBarVerbosity') if verbosity == settings.PROGRESS_BAR_ALL: return True, "Verbosity is all" if verbosity == settings.PROGRESS_BAR_WINDOW: if self.top_level_object(obj) == focus_manager.get_manager().get_active_window(): return True, "Verbosity is window" return False, "Top-level object is not active window" if verbosity == settings.PROGRESS_BAR_APPLICATION: app = AXUtilities.get_application(obj) active_app = script_manager.get_manager().get_active_script_app() if app == active_app: return True, "Verbosity is app" return False, "App is not active app" return True, "Not handled by any other case" def description_list_terms(self, obj: Atspi.Accessible) -> list[Atspi.Accessible]: """Returns a list of all the accessible description list terms in obj.""" # TODO - JD: The only caller is the web speech generator. Consider moving this there. if not AXUtilities.is_description_list(obj): return [] _include = AXUtilities.is_description_term _exclude = AXUtilities.is_description_list return self._find_all_descendants(obj, _include, _exclude) def is_document_list(self, obj: Atspi.Accessible) -> bool: """Returns true if obj is a list inside a document.""" # TODO - JD: Move this into AXUtilities. if not (AXUtilities.is_list(obj) or AXUtilities.is_description_list(obj)): return False return AXObject.find_ancestor(obj, AXUtilities.is_document) is not None def is_document_panel(self, obj: Atspi.Accessible) -> bool: """Returns true if obj is a panel inside a document.""" # TODO - JD: Move this into AXUtilities. if not AXUtilities.is_panel(obj): return False return AXObject.find_ancestor(obj, AXUtilities.is_document) is not None def is_document(self, obj: Atspi.Accessible, _exclude_document_frame=False) -> bool: """Returns True if obj is a document.""" # TODO - JD: See if the web script logic can be included here and then it all moved # into AXUtilities. return AXUtilities.is_document(obj) def in_document_content(self, obj: Atspi.Accessible | None = None) -> bool: """Returns True if obj (or the locus of focus) is in document content.""" obj = obj or focus_manager.get_manager().get_locus_of_focus() return self.get_document_for_object(obj) is not None def active_document(self) -> Atspi.Accessible | None: """Returns the active document.""" window = focus_manager.get_manager().get_active_window() documents = list(filter(self.is_document, AXUtilities.get_embeds(window))) documents = list(filter(AXUtilities.is_showing, documents)) if len(documents) == 1: tokens = ["SCRIPT UTILITIES: Active document (via embeds):", documents[0]] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return documents[0] document = self.get_top_level_document_for_object( focus_manager.get_manager().get_locus_of_focus()) tokens = ["SCRIPT UTILITIES: Active document (via locus of focus):", document] return document def is_top_level_document(self, obj: Atspi.Accessible) -> bool: """Returns true if obj is a top-level document.""" return self.is_document(obj) and not AXObject.find_ancestor(obj, self.is_document) def get_top_level_document_for_object(self, obj: Atspi.Accessible) -> Atspi.Accessible | None: """Returns the top-level document containing obj.""" return AXObject.find_ancestor_inclusive(obj, self.is_top_level_document) def get_document_for_object(self, obj: Atspi.Accessible) -> Atspi.Accessible | None: """Returns the nearest document ancestor of obj, or obj if it is a document.""" # TODO - JD: Replace callers of this function with the logic below. return AXObject.find_ancestor_inclusive(obj, self.is_document) def convert_column_to_string(self, column: int) -> str: """Converts a column number to a string representation.""" return str(column) def is_text_document_table(self, obj: Atspi.Accessible) -> bool: """Returns True if obj is a text document table (i.e. not a spreadsheet or GUI table)""" # TODO - JD: Move this into AXUtilities. if not AXUtilities.is_table(obj): return False doc = self.get_document_for_object(obj) return doc is not None and not AXUtilities.is_document_spreadsheet(doc) def is_gui_table(self, obj: Atspi.Accessible) -> bool: """Returns True if obj is a GUI table.""" # TODO - JD: Move this into AXUtilities. return AXUtilities.is_table(obj) and self.get_document_for_object(obj) is None def is_spreadsheet_table(self, obj: Atspi.Accessible) -> bool: """Returns True if obj is a spreadsheet table.""" # TODO - JD: Move this into AXUtilities. if not (AXUtilities.is_table(obj) and AXObject.supports_table(obj)): return False doc = self.get_document_for_object(obj) if doc is None: return False if AXUtilities.is_document_spreadsheet(doc): return True return AXTable.get_row_count(obj) > 65536 def is_text_document_cell(self, obj: Atspi.Accessible) -> bool: """Returns true if obj is a cell in a text document table.""" # TODO - JD: Move this into AXUtilities. if not AXUtilities.is_table_cell_or_header(obj): return False return AXObject.find_ancestor(obj, self.is_text_document_table) is not None def is_gui_cell(self, obj: Atspi.Accessible) -> bool: """Returns true if obj is a cell in a GUI table.""" # TODO - JD: Move this into AXUtilities. if not AXUtilities.is_table_cell_or_header(obj): return False return AXObject.find_ancestor(obj, self.is_gui_table) is not None def is_spreadsheet_cell(self, obj: Atspi.Accessible) -> bool: """Returns true if obj is a cell in a spreadsheet table.""" # TODO - JD: Move this into AXUtilities. if not AXUtilities.is_table_cell_or_header(obj): return False return AXObject.find_ancestor(obj, self.is_spreadsheet_table) is not None def cell_column_changed( self, cell: Atspi.Accessible, prior_cell: Atspi.Accessible | None = None ) -> bool: """Returns True if the column of cell has changed since prior_cell.""" column = AXTable.get_cell_coordinates(cell)[1] if column == -1: return False if prior_cell is None: _last_row, last_column = focus_manager.get_manager().get_last_cell_coordinates() else: last_column = AXTable.get_cell_coordinates(prior_cell)[1] return column != last_column def cell_row_changed( self, cell: Atspi.Accessible, prior_cell: Atspi.Accessible | None = None ) -> bool: """Returns True if the row of cell has changed since prior_cell.""" row = AXTable.get_cell_coordinates(cell)[0] if row == -1: return False if prior_cell is None: last_row, _last_column = focus_manager.get_manager().get_last_cell_coordinates() else: last_row = AXTable.get_cell_coordinates(prior_cell)[0] return row != last_row def should_read_full_row( self, obj: Atspi.Accessible, previous_object: Atspi.Accessible | None = None ) -> bool: """Returns True if we should present the full row in speech.""" if focus_manager.get_manager().in_say_all(): return False if self._script.get_table_navigator().last_input_event_was_navigation_command(): return False if not self.cell_row_changed(obj, previous_object): return False table = AXTable.get_table(obj) if table is None: return False if not self.get_document_for_object(table): return settings_manager.get_manager().get_setting("readFullRowInGUITable") if self.is_spreadsheet_table(table): return settings_manager.get_manager().get_setting("readFullRowInSpreadSheet") return settings_manager.get_manager().get_setting("readFullRowInDocumentTable") def get_notification_content(self, obj: Atspi.Accessible) -> str: """Returns a string containing the content of the notification obj.""" if not AXUtilities.is_notification(obj): return "" tokens = [] name = AXObject.get_name(obj) if name: tokens.append(name) text = self.expand_eocs(obj) if text and text not in tokens: tokens.append(text) else: labels = " ".join(map(lambda x: AXText.get_all_text(x) or AXObject.get_name(x), self.unrelated_labels(obj, False, 1))) if labels and labels not in tokens: tokens.append(labels) description = AXObject.get_description(obj) if description and description not in tokens: tokens.append(description) return " ".join(tokens) def is_link(self, obj: Atspi.Accessible) -> bool: """Returns True if obj is a link.""" return AXUtilities.is_link(obj) def _get_object_from_path(self, path): # TODO - JD: This broad exception is swallowing a pyatspism meaning the one caller # (recovery code for web brokeness) is not recovering. Which suggests that code can # be removed. start = self._script.app rv = None for p in path: if p == -1: continue try: start = start[p] except (IndexError, TypeError, AttributeError): break else: rv = start return rv def active_descendant(self, 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 AXUtilities.is_table_cell(obj): return obj if AXObject.get_name(obj): return obj def pred(x): return AXObject.get_name(x) or AXText.get_all_text(x) child = AXObject.find_descendant(obj, pred) if child is not None: return child return obj def _top_level_roles(self) -> list[Atspi.Role]: # TODO - JD: Move this into AXUtilities. roles = [Atspi.Role.DIALOG, Atspi.Role.FILE_CHOOSER, Atspi.Role.FRAME, Atspi.Role.WINDOW, Atspi.Role.ALERT] return roles def _find_window_witih_descendant(self, child: Atspi.Accessible) -> Atspi.Accessible | None: """A terrible, non-performant workaround for broken ancestry.""" app = AXUtilities.get_application(child) if app is None: return None for i in range(AXObject.get_child_count(app)): window = AXObject.get_child(app, i) if AXObject.find_descendant(window, lambda x: x == child) is not None: tokens = ["SCRIPT UTILITIES:", window, "contains", child] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return window tokens = ["SCRIPT UTILITIES:", window, "does not contain", child] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return None def _is_top_level_object(self, obj: Atspi.Accessible) -> bool: return AXObject.get_role(obj) in self._top_level_roles() \ and AXObject.get_role(AXObject.get_parent(obj)) == Atspi.Role.APPLICATION def top_level_object( self, obj: Atspi.Accessible, use_fallback_search: bool = False ) -> Atspi.Accessible | None: """Returns the top-level object (frame, dialog ...) containing obj.""" rv = AXObject.find_ancestor_inclusive(obj, self._is_top_level_object) tokens = ["SCRIPT UTILITIES:", rv, "is top-level object for:", obj] debug.print_tokens(debug.LEVEL_INFO, tokens, True) if rv is None and use_fallback_search: msg = "SCRIPT UTILITIES: Attempting to find top-level object via fallback search" debug.print_message(debug.LEVEL_INFO, msg, True) rv = self._find_window_witih_descendant(obj) return rv def top_level_object_is_active_and_current(self, obj: Atspi.Accessible | None = None) -> bool: """Returns true if the top-level object for obj/locus-of-focus is active and current.""" # TODO - JD: Candidate for the focus manager. obj = obj or focus_manager.get_manager().get_locus_of_focus() top_level = self.top_level_object(obj) if not top_level: return False AXObject.clear_cache(top_level, False, "Ensuring we have the correct state.") if not AXUtilities.is_active(top_level) or AXUtilities.is_defunct(top_level): return False return top_level == focus_manager.get_manager().get_active_window() @staticmethod def path_comparison(path1: list[int], path2: list[int]) -> int: """Returns -1, 0, or 1 to indicate if path1 is before, the same, or after path2.""" # TODO - JD: Move into AXUtilities. if path1 == path2: return 0 size = max(len(path1), len(path2)) path1 = (path1 + [-1] * size)[:size] path2 = (path2 + [-1] * size)[:size] for x in range(min(len(path1), len(path2))): if path1[x] < path2[x]: return -1 if path1[x] > path2[x]: return 1 return 0 def _find_all_descendants( self, root: Atspi.Accessible | None, include_if: Callable[[Atspi.Accessible], bool] | None = None, exclude_if: Callable[[Atspi.Accessible], bool] | None = None ) -> list[Atspi.Accessible]: # TODO - JD: Move this into AXUtilities. if root is None: return [] # Don't bother if the root is a 'pre' or 'code' element. Those often have # nothing but a TON of static text leaf nodes, which we want to ignore. if AXUtilities.is_code(root): tokens = ["SCRIPUT UTILITIES: Returning 0 descendants for pre/code", root] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return [] return AXObject.find_all_descendants(root, include_if, exclude_if) def unrelated_labels( self, root: Atspi.Accessible | None = None, only_showing: bool = True, minimum_words: int = 3 ) -> list[Atspi.Accessible]: """Returns a list of labels in root that lack a relationship.""" if self._script.spellcheck and self._script.spellcheck.is_spell_check_window(root): return [] label_roles = [Atspi.Role.LABEL, Atspi.Role.STATIC] skip_roles = [Atspi.Role.COMBO_BOX, Atspi.Role.DOCUMENT_EMAIL, Atspi.Role.DOCUMENT_FRAME, Atspi.Role.DOCUMENT_PRESENTATION, Atspi.Role.DOCUMENT_SPREADSHEET, Atspi.Role.DOCUMENT_TEXT, Atspi.Role.DOCUMENT_WEB, Atspi.Role.FRAME, Atspi.Role.LIST_BOX, Atspi.Role.LIST, Atspi.Role.LIST_ITEM, Atspi.Role.MENU, Atspi.Role.MENU_BAR, Atspi.Role.PUSH_BUTTON, Atspi.Role.SCROLL_PANE, Atspi.Role.SPLIT_PANE, Atspi.Role.TABLE, Atspi.Role.TOGGLE_BUTTON, Atspi.Role.TREE, Atspi.Role.TREE_TABLE, Atspi.Role.WINDOW] if AXObject.get_role(root) in skip_roles: return [] def _include(x): if not (x and AXObject.get_role(x) in label_roles): return False if not AXUtilities.object_is_unrelated(x): return False if only_showing and not AXUtilities.is_showing(x): return False return True def _exclude(x): if not x or AXObject.get_role(x) in skip_roles: return True if only_showing and not AXUtilities.is_showing(x): return True return False labels = self._find_all_descendants(root, _include, _exclude) root_name = AXObject.get_name(root) # Eliminate things suspected to be labels for widgets labels_filtered = [] for label in labels: name = AXObject.get_name(label) or AXText.get_all_text(label) if name and name in [root_name, AXObject.get_name(AXObject.get_parent(label))]: continue if len(name.split()) < minimum_words: continue if root_name.find(name) >= 0: continue labels_filtered.append(label) return AXComponent.sort_objects_by_position(labels_filtered) def find_previous_object( self, obj: Atspi.Accessible, restrict_to: Atspi.Accessible | None = None ) -> Atspi.Accessible | None: """Finds the object before this one.""" if restrict_to is None: restrict_to = self.get_top_level_document_for_object(obj) result = AXUtilities.get_previous_object(obj) tokens = ["SCRIPT UTILITIES: Previous object for", obj, "is", result, "."] debug.print_tokens(debug.LEVEL_INFO, tokens, True) if restrict_to is not None and not AXObject.is_ancestor(result, restrict_to, True): tokens = ["SCRIPT UTILITIES:", result, "is not a descendant of", restrict_to] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return None return result def find_next_object( self, obj: Atspi.Accessible, restrict_to: Atspi.Accessible | None = None ) -> Atspi.Accessible | None: """Finds the object after this one.""" if restrict_to is None: restrict_to = self.get_top_level_document_for_object(obj) result = AXUtilities.get_next_object(obj) tokens = ["SCRIPT UTILITIES: Next object for", obj, "is", result, "."] debug.print_tokens(debug.LEVEL_INFO, tokens, True) if restrict_to is not None and not AXObject.is_ancestor(result, restrict_to, True): tokens = ["SCRIPT UTILITIES:", result, "is not a descendant of", restrict_to] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return None return result def expand_eocs( self, obj: Atspi.Accessible, start_offset: int = 0, end_offset: int = -1 ) -> str: """Expands the current object replacing embedded object characters with their text.""" text = AXText.get_substring(obj, start_offset, end_offset) if "\ufffc" not in text: return text block_roles = [Atspi.Role.HEADING, Atspi.Role.LIST, Atspi.Role.LIST_ITEM, Atspi.Role.PARAGRAPH, Atspi.Role.SECTION, Atspi.Role.TABLE, Atspi.Role.TABLE_CELL, Atspi.Role.TABLE_ROW] to_build = list(text) for i, char in enumerate(to_build): if char == "\ufffc": child = AXHypertext.find_child_at_offset(obj, i + start_offset) result = self.expand_eocs(child) if child and AXObject.get_role(child) in block_roles: result += " " to_build[i] = result result = "".join(to_build) tokens = ["SCRIPT UTILITIES: Expanded EOCs for", obj, f"range: {start_offset}:{end_offset}: '{result}'"] debug.print_tokens(debug.LEVEL_INFO, tokens, True) if "\ufffc" in result: msg = "SCRIPT UTILITIES: Unable to expand EOCs" debug.print_message(debug.LEVEL_INFO, msg, True) return "" return result def is_error_for_contents( self, obj: Atspi.Accessible, contents: list[tuple[Atspi.Accessible, int, int, str]] | None = None ) -> bool: """Returns True of obj is an error message for the contents.""" if not contents: return False if not AXUtilities.get_is_error_for(obj): return False for acc, _start, _end, _string in contents: targets = AXUtilities.get_error_message(acc) if targets is not None and obj in targets: return True return False def deleted_text(self, event: Atspi.Event) -> str: """Tries to determine the real deleted text for the given event. Because app bugs.""" return event.any_data def inserted_text(self, event: Atspi.Event) -> str: """Tries to determine the real inserted text for the given event. Because app bugs.""" if event.any_data: return event.any_data msg = "SCRIPT UTILITIES: Broken text insertion event" debug.print_message(debug.LEVEL_INFO, msg, True) if AXUtilities.is_password_text(event.source): string = AXText.get_all_text(event.source) if string: tokens = ["HACK: Returning last char in '", string, "'"] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return string[-1] msg = "FAIL: Unable to correct broken text insertion event" debug.print_message(debug.LEVEL_INFO, msg, True) return "" def clear_caret_context( self, document: Atspi.Accessible | None = None # pylint: disable=unused-argument ) -> None: """Clears the caret context.""" # TODO - JD: This logic ultimately belongs in the focus manager. self._caret_context = (None, -1) def set_caret_context( self, obj: Atspi.Accessible | None = None, # pylint: disable=unused-argument offset: int = -1, # pylint: disable=unused-argument document: Atspi.Accessible | None = None # pylint: disable=unused-argument ) -> None: """Sets the caret context in document to (obj, offset).""" # TODO - JD: This logic ultimately belongs in the focus manager. self._caret_context = (obj, offset) def get_caret_context( self, document: Atspi.Accessible | None = None, # pylint: disable=unused-argument get_replicant: bool = False, # pylint: disable=unused-argument search_if_needed: bool = True # pylint: disable=unused-argument ) -> tuple[Atspi.Accessible, int]: """Returns an (obj, offset) tuple representing the current location.""" obj, offset = self._caret_context if obj is not None: tokens = ["SCRIPT UTILITIES: Returning cached caret context", obj, offset] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return obj, offset obj = focus_manager.get_manager().get_locus_of_focus() offset = AXText.get_caret_offset(obj) tokens = ["SCRIPT UTILITIES: Returning focus + caret offset", obj, offset] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return obj, offset def set_caret_position( self, obj: Atspi.Accessible, offset: int, document: Atspi.Accessible | None = None # pylint: disable=unused-argument ) -> None: """Sets the locus of focus to obj and sets the caret position to offset.""" manager = focus_manager.get_manager() manager.set_locus_of_focus(None, obj, False) if self.grab_focus_when_setting_caret(obj): AXObject.grab_focus(obj) # We cannot count on implementations clearing the selection for us when we set the caret # offset. Also, we should clear the selected text first. # https://bugs.documentfoundation.org/show_bug.cgi?id=167930 AXText.clear_all_selected_text(obj) self.set_caret_offset(obj, offset) # TODO - JD: The web script's set_caret_position() also sets the caret context. # Ensuring global structural navigation, caret navigation, browse mode, etc. # work means we should do the same here. Ultimately, however, we need to clearly # define and unite the whole offset + position + context + locus of focus + # last cursor position. manager.set_last_cursor_position(obj, offset) self.set_caret_context(obj, offset) scroll_to = max(0, min(offset, AXText.get_character_count(obj) - 1)) self._script.get_event_synthesizer().scroll_into_view(obj, scroll_to) def set_caret_offset( self, obj: Atspi.Accessible, offset: int ) -> None: """Sets the caret offset via AtspiText.""" # TODO - JD. Remove this function if the web override can be adjusted AXText.set_caret_offset(obj, offset) def split_substring_by_language( self, obj: Atspi.Accessible, start: int, end: int ) -> list[tuple[int, int, str, str, str]]: """Returns a list of (start, end, string, language, dialect) tuples.""" rv: list[tuple[int, int, str, str, str]] = [] all_substrings = self.get_language_and_dialect_from_text_attributes(obj, start, end) for start_offset, end_offset, language, dialect in all_substrings: if start >= end_offset: continue if end <= start_offset: break start_offset = max(start, start_offset) end_offset = min(end, end_offset) string = AXText.get_substring(obj, start_offset, end_offset) rv.append((start_offset, end_offset, string, language, dialect)) return rv def get_language_and_dialect_for_substring( self, obj: Atspi.Accessible, start: int, end: int ) -> tuple[str, str]: """Returns a (language, dialect) tuple. If multiple languages apply to the substring, language and dialect will be empty strings. Callers must do any preprocessing to avoid that condition.""" all_substrings = self.get_language_and_dialect_from_text_attributes(obj, start, end) for start_offset, end_offset, language, dialect in all_substrings: if start_offset <= start and end_offset >= end: return language, dialect return "", "" def get_language_and_dialect_from_text_attributes( self, obj: Atspi.Accessible, start_offset: int = 0, end_offset: int = -1 ) -> list[tuple[int, int, str, str]]: """Returns a list of (start, end, language, dialect) tuples for obj.""" rv: list[tuple[int, int, str, str]] = [] attribute_set = AXText.get_all_text_attributes(obj, start_offset, end_offset) last_language = last_dialect = "" for (start, end, attrs) in attribute_set: language = attrs.get("language", "") dialect = "" if "-" in language: language, dialect = language.split("-", 1) if rv and last_language == language and last_dialect == dialect: rv[-1] = rv[-1][0], end, language, dialect else: rv.append((start, end, language, dialect)) last_language, last_dialect = language, dialect return rv def get_word_contents_at_offset( self, obj: Atspi.Accessible, offset: int = 0, use_cache: bool = True # pylint: disable=unused-argument ) -> list[tuple[Atspi.Accessible, int, int, str]]: """Returns a list of (obj, start, end, string) tuples for the word at offset.""" text, start, end = AXText.get_word_at_offset(obj, offset) return [(obj, start, end, text)] def get_previous_line_contents( self, obj: Atspi.Accessible | None = None, # pylint: disable=unused-argument offset: int = -1, # pylint: disable=unused-argument layout_mode: bool | None = None, # pylint: disable=unused-argument use_cache: bool = True # pylint: disable=unused-argument ) -> list[tuple[Atspi.Accessible, int, int, str]]: """Returns a list of (obj, start, end, string) tuples for the previous line.""" if obj is None: obj, offset = self.get_caret_context() if offset == -1: offset = AXText.get_caret_offset(obj) text, start, end = AXText.get_previous_line(obj, offset) if text: return [(obj, start, end, text)] _this_line, this_start, _this_end = AXText.get_line_at_offset(obj, offset) if not this_start: prev_obj, prev_offset = self.previous_context(obj, 0) return self.get_line_contents_at_offset(prev_obj, prev_offset) return [(obj, 0, 0, "")] def get_line_contents_at_offset( self, obj: Atspi.Accessible, offset: int, layout_mode: bool | None = None, # pylint: disable=unused-argument use_cache: bool = True # pylint: disable=unused-argument ) -> list[tuple[Atspi.Accessible, int, int, str]]: """Returns a list of (obj, start, end, string) tuples for the line at offset.""" text, start, end = AXText.get_line_at_offset(obj, offset) return [(obj, start, end, text)] def get_next_line_contents( self, obj: Atspi.Accessible | None = None, offset: int = -1, layout_mode: bool | None = None, # pylint: disable=unused-argument use_cache: bool = True # pylint: disable=unused-argument ) -> list[tuple[Atspi.Accessible, int, int, str]]: """Returns a list of (obj, start, end, string) tuples for the next line.""" if obj is None: obj, offset = self.get_caret_context() if offset == -1: offset = AXText.get_caret_offset(obj) text, start, end = AXText.get_next_line(obj, offset) if text: return [(obj, start, end, text)] _this_line, _this_start, this_end = AXText.get_line_at_offset(obj, offset) if this_end == AXText.get_character_count(obj): next_obj, next_offset = self.next_context(obj, this_end) return self.get_line_contents_at_offset(next_obj, next_offset) return [(obj, 0, 0, "")] def get_object_contents_at_offset( self, obj: Atspi.Accessible, offset: int = 0, # pylint: disable=unused-argument use_cache: bool = True # pylint: disable=unused-argument ) -> list[tuple[Atspi.Accessible, int, int, str]]: """Returns a list of (obj, start, end, string) tuples for the object at offset.""" text = AXText.get_all_text(obj) return [(obj, 0, len(text), text)] def get_sentence_contents_at_offset( self, obj: Atspi.Accessible, offset: int, use_cache: bool = True # pylint: disable=unused-argument ) -> list[tuple[Atspi.Accessible, int, int, str]]: """Returns the sentence contents for the specified offset.""" text, start, end = AXText.get_sentence_at_offset(obj, offset) return [(obj, start, end, text)] def previous_context( self, obj: Atspi.Accessible | None = None, offset: int = -1, skip_space: bool = False, restrict_to: Atspi.Accessible | None = None ) -> tuple[Atspi.Accessible | None, int]: """Returns the previous viable/valid caret context given obj and offset.""" if obj is None: obj, offset = self.get_caret_context() prev_offset = offset - 1 if skip_space: char = AXText.get_character_at_offset(obj, prev_offset)[0] while char and char.isspace(): prev_offset -= 1 char = AXText.get_character_at_offset(obj, prev_offset)[0] if prev_offset >= 0: return obj, prev_offset if prev_obj := self.find_previous_object(obj, restrict_to): if prev_obj != obj and not AXText.get_character_count(prev_obj) and not skip_space: return prev_obj, 0 length = AXText.get_character_count(prev_obj) return self.previous_context(prev_obj, length, skip_space, restrict_to) return None, -1 def next_context( self, obj: Atspi.Accessible | None = None, offset: int = -1, skip_space: bool = False, restrict_to: Atspi.Accessible | None = None ) -> tuple[Atspi.Accessible | None, int]: """Returns the next viable/valid caret context given obj and offset.""" if obj is None: obj, offset = self.get_caret_context() next_offset = offset + 1 if skip_space: char = AXText.get_character_at_offset(obj, next_offset)[0] while char and char.isspace(): next_offset += 1 char = AXText.get_character_at_offset(obj, next_offset)[0] if next_offset <= AXText.get_character_count(obj): return obj, next_offset if next_obj := self.find_next_object(obj, restrict_to): if next_obj != obj and not AXText.get_character_count(next_obj) and not skip_space: return next_obj, 0 return self.next_context(next_obj, -1, skip_space, restrict_to) return None, -1 def first_context(self, obj: Atspi.Accessible, offset: int) -> tuple[Atspi.Accessible, int]: """Returns the first viable/valid caret context given obj and offset.""" if AXObject.supports_text(obj): return obj, offset descendant = AXObject.find_descendant(obj, AXObject.supports_text) if descendant is not None: return descendant, 0 return obj, offset def filter_contents_for_presentation( self, contents: list[tuple[Atspi.Accessible, int, int, str]], infer_labels: bool = False # pylint: disable=unused-argument ) -> list[tuple[Atspi.Accessible, int, int, str]]: """Filters contents for presentation, removing objects that should not be included.""" return contents def last_context(self, root: Atspi.Accessible) -> tuple[Atspi.Accessible, int]: """Returns the last viable/valid caret context in root.""" offset = max(0, AXText.get_character_count(root) - 1) return root, offset def selected_children( self, obj: Atspi.Accessible ) -> list[Atspi.Accessible]: """Returns a list of selected children in obj.""" # TODO - JD: This was originally in the LO script. See if it is still an issue when # lots of cells are selected. if self.is_spreadsheet_table(obj): return [] return AXSelection.get_selected_children(obj) def speak_selected_cell_range( self, _obj: Atspi.Accessible ) -> bool: """Speaks the selected cell range in obj.""" # TODO - JD: This doesn't belong here. return False def get_selection_container( self, obj: Atspi.Accessible ) -> Atspi.Accessible | None: """Returns the selection container for obj.""" # TODO - JD: Move this into AXUtilities. Or into the where am i presenter. if not obj: return None # LO Writer implements the selection interface on paragraphs and possibly # other things. if AXUtilities.is_paragraph(obj) or AXUtilities.is_editable(obj): return None if AXObject.supports_selection(obj): return obj rolemap = { Atspi.Role.CANVAS: [Atspi.Role.LAYERED_PANE], Atspi.Role.ICON: [Atspi.Role.LAYERED_PANE], Atspi.Role.LIST_ITEM: [Atspi.Role.LIST_BOX], Atspi.Role.TREE_ITEM: [Atspi.Role.TREE, Atspi.Role.TREE_TABLE], Atspi.Role.TABLE_CELL: [Atspi.Role.TABLE, Atspi.Role.TREE_TABLE], Atspi.Role.TABLE_ROW: [Atspi.Role.TABLE, Atspi.Role.TREE_TABLE], } matching_roles = rolemap.get(AXObject.get_role(obj)) def is_match(x): if matching_roles and AXObject.get_role(x) not in matching_roles: return False return AXObject.supports_selection(x) return AXObject.find_ancestor(obj, is_match) def selectable_child_count( self, obj: Atspi.Accessible ) -> int: """Returns the number of selectable children in obj.""" # TODO - JD: Move this into AXUtilities. if not AXObject.supports_selection(obj): return 0 if AXObject.supports_table(obj): rows = AXTable.get_row_count(obj) return max(0, rows) rolemap = { Atspi.Role.LIST_BOX: [Atspi.Role.LIST_ITEM], Atspi.Role.TREE: [Atspi.Role.TREE_ITEM], } role = AXObject.get_role(obj) if role not in rolemap: return AXObject.get_child_count(obj) def is_match(x): return AXObject.get_role(x) in rolemap.get(role) return len(self._find_all_descendants(obj, is_match)) def selected_child_count( self, obj: Atspi.Accessible ) -> int: """Returns the number of selected children in obj.""" # TODO - JD: Move this into AXUtilities. if AXObject.supports_table(obj): return AXTable.get_selected_row_count(obj) return AXSelection.get_selected_child_count(obj) def is_clickable_element( self, obj: Atspi.Accessible # pylint: disable=unused-argument ) -> bool: """Returns true if obj is a clickable element (in the web sense of that word).""" return False def has_long_desc( self, obj: Atspi.Accessible # pylint: disable=unused-argument ) -> bool: """Returns true if obj has a longdesc (deprecated web attribute).""" return False def has_meaningful_toggle_action( self, obj: Atspi.Accessible ) -> bool: """Returns true if obj has a toggle action that is meaningful. Because app bugs.""" return AXObject.has_action(obj, "toggle") \ or AXObject.has_action(obj, object_properties.ACTION_TOGGLE) def get_word_at_offset_adjusted_for_navigation( self, obj: Atspi.Accessible, offset: int | None = None ) -> tuple[str, int, int]: """Returns the word at offset, adjusted for native navigation commands.""" if offset is None: offset = AXText.get_caret_offset(obj) word, start, end = AXText.get_word_at_offset(obj, offset) prev_obj, prev_offset = focus_manager.get_manager().get_penultimate_cursor_position() if prev_obj != obj: return word, start, end manager = input_event_manager.get_manager() was_previous_word_nav = manager.last_event_was_previous_word_navigation() was_next_word_nav = manager.last_event_was_next_word_navigation() # If we're in an ongoing series of native navigation-by-word commands, just present the # newly-traversed string. prev_word, prev_start, prev_end = AXText.get_word_at_offset(prev_obj, prev_offset) if self._script.point_of_reference.get("lastTextUnitSpoken") == "word": if was_previous_word_nav: start = offset end = prev_offset elif was_next_word_nav: start = prev_offset end = offset word = AXText.get_substring(obj, start, end) debug_string = word.replace("\n", "\\n") msg = ( f"SCRIPT UTILITIES: Adjusted word at offset {offset} for ongoing word nav is " f"'{debug_string}' ({start}-{end})" ) debug.print_message(debug.LEVEL_INFO, msg, True) return word, start, end # Otherwise, attempt some smarts so that the user winds up with the same presentation # they would get were this an ongoing series of native navigation-by-word commands. if was_previous_word_nav: # If we moved left via native nav, this should be the start of a native-navigation # word boundary, regardless of what ATK/AT-SPI2 tells us. start = offset # The ATK/AT-SPI2 word typically ends in a space; if the ending 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 previous word command. if not (word[-1].isspace() or word[-1].isalnum()): end -= 1 elif was_next_word_nav: # If we moved right via native nav, this should be the end of a native-navigation # word boundary, regardless of what ATK/AT-SPI2 tells us. end = offset # This suggests we just moved to the end of the previous word. if word != prev_word and prev_start < offset <= prev_end: start = prev_start # 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. if offset > 0: last_char = AXText.get_substring(obj, offset - 1, offset) if not (last_char.isspace() or last_char.isalnum()): start = offset - 1 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. if "\n" in word and word == prev_word: if word.startswith("\n"): start += 1 elif word.endswith("\n"): end -= 1 word = AXText.get_substring(obj, start, end) debug_string = word.replace("\n", "\\n") msg = ( f"SCRIPT UTILITIES: Adjusted word at offset {offset} for new word nav is " f"'{debug_string}' ({start}-{end})" ) debug.print_message(debug.LEVEL_INFO, msg, True) return word, start, end def clear_cached_command_state_deprecated(self) -> None: """Clears the cached state for undo, redo, and paste commands.""" self._script.point_of_reference["undo"] = False self._script.point_of_reference["redo"] = False self._script.point_of_reference["paste"] = False def handle_undo_text_event( self, event: Atspi.Event ) -> bool: """Handles a text-changed event resulting from an undo/redo.""" if input_event_manager.get_manager().last_event_was_undo(): if not self._script.point_of_reference.get("undo"): self._script.present_message(messages.UNDO) self._script.point_of_reference["undo"] = True AXText.update_cached_selected_text(event.source) return True if input_event_manager.get_manager().last_event_was_redo(): if not self._script.point_of_reference.get("redo"): self._script.present_message(messages.REDO) self._script.point_of_reference["redo"] = True AXText.update_cached_selected_text(event.source) return True return False def handle_undo_locus_of_focus_change(self) -> bool: """Presents an undo/redo message when the locus of focus changes.""" if input_event_manager.get_manager().last_event_was_undo(): if not self._script.point_of_reference.get("undo"): self._script.present_message(messages.UNDO) self._script.point_of_reference["undo"] = True return True if input_event_manager.get_manager().last_event_was_redo(): if not self._script.point_of_reference.get("redo"): self._script.present_message(messages.REDO) self._script.point_of_reference["redo"] = True return True return False def handle_paste_locus_of_focus_change(self) -> bool: """Presents a paste message when the locus of focus changes.""" if input_event_manager.get_manager().last_event_was_paste(): if not self._script.point_of_reference.get("paste"): self._script.present_message( messages.CLIPBOARD_PASTED_FULL, messages.CLIPBOARD_PASTED_BRIEF) self._script.point_of_reference["paste"] = True return True return False def all_items_selected( self, obj: Atspi.Accessible ) -> bool: """Returns True if all items in obj are selected.""" # TODO - JD: Move this into AXUtilities. if not AXObject.supports_selection(obj): return False if AXUtilities.is_expandable(obj) and not AXUtilities.is_expanded(obj): return False if AXUtilities.is_combo_box(obj) or AXUtilities.is_menu(obj): return False child_count = AXObject.get_child_count(obj) if child_count == AXSelection.get_selected_child_count(obj): # The selection interface gives us access to what is selected, which might # not actually be a direct child. child = AXSelection.get_selected_child(obj, 0) if AXObject.get_parent(child) != obj: return False msg = f"SCRIPT UTILITIES: All {child_count} children believed to be selected" debug.print_message(debug.LEVEL_INFO, msg, True) return True return AXTable.all_cells_are_selected(obj) def handle_container_selection_change( self, obj: Atspi.Accessible ) -> bool: """Handles a change in a container that supports selection.""" all_already_selected = self._script.point_of_reference.get("allItemsSelected") all_currently_selected = self.all_items_selected(obj) if all_already_selected and all_currently_selected: return True self._script.point_of_reference["allItemsSelected"] = all_currently_selected if input_event_manager.get_manager().last_event_was_select_all() and all_currently_selected: self._script.present_message(messages.CONTAINER_SELECTED_ALL) focus_manager.get_manager().set_locus_of_focus(None, obj, False) return True return False def handle_text_selection_change( self, obj: Atspi.Accessible, speak_message: bool = True ) -> bool: """Handles a change in the selected text.""" # Note: This guesswork to figure out what actually changed with respect # to text selection will get eliminated once the new text-selection API # is added to ATK and implemented by the toolkits. (BGO 638378) if not AXObject.supports_text(obj): return False if input_event_manager.get_manager().last_event_was_cut(): return False old_string, old_start, old_end = AXText.get_cached_selected_text(obj) AXText.update_cached_selected_text(obj) new_string, new_start, new_end = AXText.get_cached_selected_text(obj) if input_event_manager.get_manager().last_event_was_select_all() and new_string: if new_string != old_string: self._script.speak_message(messages.DOCUMENT_SELECTED_ALL) return True # Even though we present a message, treat it as unhandled so the new location is # still presented. if not input_event_manager.get_manager().last_event_was_caret_selection() \ and old_string and not new_string: self._script.speak_message(messages.SELECTION_REMOVED) return False changes = [] old_chars = set(range(old_start, old_end)) new_chars = set(range(new_start, new_end)) if not old_chars.union(new_chars): return False if old_chars and new_chars and not old_chars.intersection(new_chars): # A simultaneous unselection and selection centered at one offset. changes.append([old_start, old_end, messages.TEXT_UNSELECTED]) changes.append([new_start, new_end, messages.TEXT_SELECTED]) else: change = sorted(old_chars.symmetric_difference(new_chars)) if not change: return False change_start, change_end = change[0], change[-1] + 1 if old_chars < new_chars: changes.append([change_start, change_end, messages.TEXT_SELECTED]) if old_string.endswith("\ufffc") and old_end == change_start: # There's a possibility that we have a link spanning multiple lines. If so, # we want to present the continuation that just became selected. child = AXHypertext.find_child_at_offset(obj, old_end - 1) self.handle_text_selection_change(child, False) else: changes.append([change_start, change_end, messages.TEXT_UNSELECTED]) if new_string.endswith("\ufffc"): # There's a possibility that we have a link spanning multiple lines. If so, # we want to present the continuation that just became unselected. child = AXHypertext.find_child_at_offset(obj, new_end - 1) self.handle_text_selection_change(child, False) speak_message = speak_message \ and not settings_manager.get_manager().get_setting('onlySpeakDisplayedText') for start, end, message in changes: string = AXText.get_substring(obj, start, end) ends_with_child = string.endswith("\ufffc") if ends_with_child: end -= 1 if len(string) > 5000 and speak_message: if message == messages.TEXT_SELECTED: self._script.speak_message(messages.selected_character_count(len(string))) else: self._script.speak_message(messages.unselected_character_count(len(string))) else: self._script.say_phrase(obj, start, end) if speak_message and not ends_with_child: self._script.speak_message(message, interrupt=False) if ends_with_child: child = AXHypertext.find_child_at_offset(obj, end) self.handle_text_selection_change(child, speak_message) return True def should_interrupt_for_locus_of_focus_change( self, old_focus: Atspi.Accessible, new_focus: Atspi.Accessible, event: Atspi.Event | None = None ) -> bool: """Returns True if speech should be interrupted to present the new focus.""" msg = "SCRIPT UTILITIES: Not interrupting for locusOfFocus change: " if event is None: msg += "event is None" debug.print_message(debug.LEVEL_INFO, msg, True) return False if old_focus == new_focus: msg += "old locusOfFocus is same as new locusOfFocus" debug.print_message(debug.LEVEL_INFO, msg, True) return False if event.type.startswith("object:active-descendant-changed"): msg += "event is active-descendant-changed" debug.print_message(debug.LEVEL_INFO, msg, True) return False if AXUtilities.is_table_cell(old_focus) and AXUtilities.is_text(new_focus) \ and AXUtilities.is_editable(new_focus): msg += "suspected editable cell" debug.print_message(debug.LEVEL_INFO, msg, True) return False if not AXUtilities.is_menu_related(new_focus) \ and (AXUtilities.is_check_menu_item(old_focus) \ or AXUtilities.is_radio_menu_item(old_focus)): msg += "suspected menuitem state change" debug.print_message(debug.LEVEL_INFO, msg, True) return False if AXObject.is_ancestor(new_focus, old_focus): if old_name := AXObject.get_name(old_focus): if old_name == AXObject.get_name(new_focus): msg += "old locusOfFocus is ancestor with same name as new locusOfFocus" debug.print_message(debug.LEVEL_INFO, msg, True) return True msg += "old locusOfFocus is ancestor of new locusOfFocus, and has a name" debug.print_message(debug.LEVEL_INFO, msg, True) return False if AXUtilities.is_dialog_or_window(old_focus): if AXUtilities.is_menu(new_focus): return True msg += "old locusOfFocus is ancestor dialog or window of the new locusOfFocus" debug.print_message(debug.LEVEL_INFO, msg, True) return False return True if AXUtilities.object_is_controlled_by(old_focus, new_focus) \ or AXUtilities.object_is_controlled_by(new_focus, old_focus): msg += "new locusOfFocus and old locusOfFocus have controls relation" debug.print_message(debug.LEVEL_INFO, msg, True) return False return True