#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# 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.
#
# Forked from Orca screen reader.
# Cthulhu project: https://git.stormux.org/storm/cthulhu

"""Commonly-required utility methods needed by -- and potentially
   customized by -- application and toolkit scripts. They have
   been pulled out from the scripts because certain scripts had
   gotten way too large as a result of including these methods."""

from __future__ import annotations

__id__ = "$Id$"
__version__   = "$Revision$"
__date__      = "$Date$"
__copyright__ = "Copyright (c) 2010 Joanmarie Diggs."
__license__   = "LGPL"

import functools
import gi
import locale
import math
import re
import time
from difflib import SequenceMatcher
from typing import Any, Callable, Generator, Optional, TYPE_CHECKING

gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
from gi.repository import Gdk
from gi.repository import Gtk

from . import chnames
from . import colornames
from . import debug
from . import keynames
from . import keybindings
from . import input_event
from . import input_event_manager
from . import mathsymbols
from . import messages
from . import cthulhu
from . import cthulhu_state
from . import object_properties
from . import pronunciation_dict
from . import settings
from . import settings_manager
from . import text_attribute_names
from .ax_component import AXComponent
from .ax_document_selection import AXDocumentSelection
from .ax_object import AXObject
from .ax_selection import AXSelection
from .ax_table import AXTable
from .ax_text import AXText
from .ax_hypertext import AXHypertext
from .ax_value import AXValue
from .ax_utilities import AXUtilities
from .ax_utilities_relation import AXUtilitiesRelation

if TYPE_CHECKING:
    from .script import Script

_settingsManager: Optional[Any] = None  # Removed - use cthulhu.cthulhuApp.settingsManager

# Try to import sound system for indentation beeps
try:
    from . import sound
    from .sound_generator import Tone
    _SOUND_AVAILABLE: bool = True
except ImportError:
    _SOUND_AVAILABLE: bool = False

#############################################################################
#                                                                           #
# Utilities                                                                 #
#                                                                           #
#############################################################################

class Utilities:

    _last_clipboard_update: float = time.time()

    EMBEDDED_OBJECT_CHARACTER: str = '\ufffc'
    ZERO_WIDTH_NO_BREAK_SPACE: str = '\ufeff'
    SUPERSCRIPT_DIGITS: list[str] = \
        ['\u2070', '\u00b9', '\u00b2', '\u00b3', '\u2074',
         '\u2075', '\u2076', '\u2077', '\u2078', '\u2079']
    SUBSCRIPT_DIGITS: list[str] = \
        ['\u2080', '\u2081', '\u2082', '\u2083', '\u2084',
         '\u2085', '\u2086', '\u2087', '\u2088', '\u2089']
    MENU_ROLES_IN_OPEN_MENU: set[Atspi.Role] = {
        Atspi.Role.MENU,
        Atspi.Role.MENU_ITEM,
        Atspi.Role.CHECK_MENU_ITEM,
        Atspi.Role.RADIO_MENU_ITEM,
        Atspi.Role.SEPARATOR,
    }
    ZOMBIE_TOP_LEVEL_ROLES: set[Atspi.Role] = {
        Atspi.Role.APPLICATION,
        Atspi.Role.ALERT,
        Atspi.Role.DIALOG,
        Atspi.Role.LABEL, # For Unity Panel Service bug
        Atspi.Role.PAGE, # For Evince bug
        Atspi.Role.WINDOW,
        Atspi.Role.FRAME,
    }
    DISPLAYED_TEXT_DIRECT_NAME_ROLES: set[Atspi.Role] = {
        Atspi.Role.PUSH_BUTTON,
        Atspi.Role.LABEL,
    }
    DISPLAYED_TEXT_SKIP_NAME_ROLES: set[Atspi.Role] = {
        Atspi.Role.COMBO_BOX,
        Atspi.Role.SPIN_BUTTON,
    }
    DISPLAYED_TEXT_LABEL_FALLBACK_ROLES: set[Atspi.Role] = {
        Atspi.Role.PUSH_BUTTON,
        Atspi.Role.LIST_ITEM,
    }

    flags: re.RegexFlag = re.UNICODE
    WORDS_RE: re.Pattern[str] = re.compile(r"(\W+)", flags)
    SUPERSCRIPTS_RE: re.Pattern[str] = re.compile(f"[{''.join(SUPERSCRIPT_DIGITS)}]+", flags)
    SUBSCRIPTS_RE: re.Pattern[str] = re.compile(f"[{''.join(SUBSCRIPT_DIGITS)}]+", flags)
    PUNCTUATION: re.Pattern[str] = re.compile(r"[^\w\s]", flags)

    # generatorCache
    #
    DISPLAYED_DESCRIPTION: str = 'displayedDescription'
    DISPLAYED_LABEL: str = 'displayedLabel'
    DISPLAYED_TEXT: str = 'displayedText'
    KEY_BINDING: str = 'keyBinding'
    NESTING_LEVEL: str = 'nestingLevel'
    NODE_LEVEL: str = 'nodeLevel'

    def __init__(self, script: Script) -> None:
        """Creates an instance of the Utilities class.

        Arguments:
        - script: the script with which this instance is associated.
        """

        self._script: Script = script
        self._clipboardHandlerId: Optional[int] = None
        self._lastIndentationData: dict[Any, Any] = {}
        self._selectedMenuBarMenu: dict[Any, Any] = {}
        self._documentSelectionAdapter = AXDocumentSelection(self)

    #########################################################################
    #                                                                       #
    # Utilities for finding, identifying, and comparing accessibles         #
    #                                                                       #
    #########################################################################

    def _isActiveAndShowingAndNotIconified(self, obj: Atspi.Accessible) -> bool:
        if not AXUtilities.is_active(obj):
            tokens = ["SCRIPT UTILITIES:", obj, "lacks state active"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return False

        if AXUtilities.is_iconified(obj):
            tokens = ["SCRIPT UTILITIES:", obj, "has state iconified"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return False

        if not AXUtilities.is_showing(obj):
            tokens = ["SCRIPT UTILITIES:", obj, "lacks state showing"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return False

        return True

    @staticmethod
    def _getAppCommandLine(app: Optional[Atspi.Accessible]) -> str:
        if not app:
            return ""

        try:
            pid = app.get_process_id()
        except Exception:
            tokens = ["SCRIPT UTILITIES: Exception getting process id of", app, ". May be defunct."]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return ""

        try:
            with open(f"/proc/{pid}/cmdline", "rb") as cmdline_file:
                cmdline = cmdline_file.read().decode("utf-8", errors="replace")
        except Exception:
            return ""

        return cmdline.replace("\x00", " ")

    def canBeActiveWindow(self, window: Optional[Atspi.Accessible], clearCache: bool = False) -> bool:
        if not window:
            return False

        app = AXObject.get_application(window)
        tokens = ["SCRIPT UTILITIES: Looking at", window, "from", app]
        if debug.debugLevel <= debug.LEVEL_INFO:
            tokens.append(self._getAppCommandLine(app))
        debug.printTokens(debug.LEVEL_INFO, tokens, True)

        if clearCache:
            AXObject.clear_cache(window)

        if not self._isActiveAndShowingAndNotIconified(window):
            tokens = ["SCRIPT UTILITIES:", window, "is not active and showing, or is iconified"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return False

        tokens = ["SCRIPT UTILITIES:", window, "can be active window"]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)
        return True

    def activeWindow(self, *apps: Atspi.Accessible) -> Optional[Atspi.Accessible]:
        """Tries to locate the active window; may or may not succeed."""

        candidates = []
        apps = apps or AXUtilities.get_all_applications(must_have_window=True)
        for app in apps:
            candidates.extend([c for c in AXObject.iter_children(app, self.canBeActiveWindow)])

        if not candidates:
            tokens = ["SCRIPT UTILITIES: Unable to find active window from", apps]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return None

        if len(candidates) == 1:
            tokens = ["SCRIPT UTILITIES: Active window is", candidates[0]]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return candidates[0]

        tokens = ["SCRIPT UTILITIES: These windows all claim to be active:", candidates]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)

        filtered = []
        for candidate in candidates:
            if self.isDesktop(candidate):
                tokens = ["SCRIPT UTILITIES: Rejecting", candidate, ": it's the desktop frame"]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
            elif AXObject.get_name(AXObject.get_application(candidate)) == "mutter-x11-frames":
                tokens = ["SCRIPT UTILITIES: Rejecting", candidate, ": app is mutter-x11-frames"]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
            else:
                filtered.append(candidate)

        if len(filtered) == 1:
            tokens = ["SCRIPT UTILITIES: Active window is believed to be", filtered[0]]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return filtered[0]

        # Some electron apps running in the background claim to be active even when they
        # are not. Slack is one such example. We can add others as we go.
        suspect_app_names = ["slack",
                             "discord",
                             "outline-client",
                             "whatsapp-desktop-linux"]
        refiltered = []
        for frame in filtered:
            if AXObject.get_name(AXObject.get_application(frame)) in suspect_app_names:
                tokens = ["SCRIPT UTILITIES: Suspecting", frame, "is a non-active Electron app"]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
            else:
                refiltered.append(frame)

        if len(refiltered) == 1:
            tokens = ["SCRIPT UTILITIES: Active window is believed to be", refiltered[0]]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return refiltered[0]

        guess = None
        if refiltered:
            tokens = ["SCRIPT UTILITIES: Still have multiple active windows:", refiltered]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            guess = refiltered[0]

        tokens = ["SCRIPT UTILITIES: Active window is:", guess]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)
        return guess

    def objectAttributes(self, obj: Atspi.Accessible, useCache: bool = True) -> dict[str, str]:
        return AXObject.get_attributes_dict(obj)

    def cellIndex(self, obj: Atspi.Accessible) -> int:
        """Returns the index of the cell which should be used with the
        table interface.  This is necessary because in some apps we
        cannot count on the index in parent being the index we need.

        Arguments:
        -obj: the table cell whose index we need.
        """

        attrs = self.objectAttributes(obj)
        index = attrs.get('table-cell-index')
        if index:
            return int(index)

        obj = AXObject.find_ancestor(obj, AXUtilities.is_table_cell_or_header) or obj
        return AXObject.get_index_in_parent(obj)

    def childNodes(self, obj: Atspi.Accessible) -> list[Atspi.Accessible]:
        """Gets all of the children that have RELATION_NODE_CHILD_OF pointing
        to this expanded table cell.

        Arguments:
        -obj: the Accessible Object

        Returns: a list of all the child nodes
        """

        parent = AXObject.get_parent(obj)
        table = AXTable.get_table(parent)
        if table is None:
            return []

        if not AXUtilities.is_expanded(obj):
            return []

        # First see if this accessible implements RELATION_NODE_PARENT_OF.
        # If it does, the full target list are the nodes. If it doesn't
        # we'll do an old-school, row-by-row search for child nodes.
        def pred(x: Atspi.Accessible) -> bool:
            return AXObject.get_index_in_parent(x) >= 0

        nodes = [x for x in AXUtilitiesRelation.get_is_node_parent_of(obj) if pred(x)]
        tokens = ["SCRIPT UTILITIES:", len(nodes), "child nodes for", obj, "via node-parent-of"]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)
        if nodes:
            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 = self.coordinatesForCell(obj)
        nodeLevel = self.nodeLevel(obj)
        for i in range(row + 1, AXTable.get_row_count(table, prefer_attribute=False)):
            cell = AXTable.get_cell_at(table, i, col)
            if not cell:
                continue
            nodeOf = AXUtilitiesRelation.get_is_node_child_of(cell)
            if not nodeOf:
                continue
            nodeOf = nodeOf[0]
            if self.isSameObject(obj, nodeOf):
                nodes.append(cell)
            elif self.nodeLevel(nodeOf) <= nodeLevel:
                break

        tokens = ["SCRIPT UTILITIES:", len(nodes), "child nodes for", obj, "via node-child-of"]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)
        return nodes

    def commonAncestor(self, a: Optional[Atspi.Accessible], b: Optional[Atspi.Accessible]) -> Optional[Atspi.Accessible]:
        """Finds the common ancestor between Accessible a and Accessible b.

        Arguments:
        - a: Accessible
        - b: Accessible
        """

        tokens = ["SCRIPT UTILITIES: Looking for common ancestor of", a, "and", b]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)

        # Don't do any Zombie checks here, as tempting and logical as it
        # may seem as it can lead to chattiness.
        if not (a and b):
            return None

        if a == b:
            return a

        aParents = [a]
        parent = AXObject.get_parent_checked(a)
        while parent:
            aParents.append(parent)
            parent = AXObject.get_parent_checked(parent)
        aParents.reverse()

        bParents = [b]
        parent = AXObject.get_parent_checked(b)
        while parent:
            bParents.append(parent)
            parent = AXObject.get_parent_checked(parent)
        bParents.reverse()

        commonAncestor = None
        maxSearch = min(len(aParents), len(bParents))
        i = 0
        while i < maxSearch:
            if self.isSameObject(aParents[i], bParents[i]):
                commonAncestor = aParents[i]
                i += 1
            else:
                break

        tokens = ["SCRIPT UTILITIES: Common ancestor of", a, "and", b, "is", commonAncestor]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)
        return commonAncestor

    def displayedLabel(self, obj: Atspi.Accessible) -> Optional[str]:
        """If there is an object labelling the given object, return the
        text being displayed for the object labelling this object.
        Otherwise, return None.

        Argument:
        - obj: the object in question

        Returns the string of the object labelling this object, or None
        if there is nothing of interest here.
        """

        try:
            return self._script.generatorCache[self.DISPLAYED_LABEL][obj]
        except Exception:
            if self.DISPLAYED_LABEL not in self._script.generatorCache:
                self._script.generatorCache[self.DISPLAYED_LABEL] = {}
            labelString = None

        labels = self.labelsForObject(obj)
        for label in labels:
            labelString = \
                self.appendString(labelString, self.displayedText(label))

        self._script.generatorCache[self.DISPLAYED_LABEL][obj] = labelString
        return self._script.generatorCache[self.DISPLAYED_LABEL][obj]

    def preferDescriptionOverName(self, obj: Atspi.Accessible) -> bool:
        return False

    def descriptionsForObject(self, obj: Atspi.Accessible) -> list[Atspi.Accessible]:
        """Return a list of objects describing obj."""

        descriptions = AXUtilitiesRelation.get_is_described_by(obj)
        if not descriptions:
            return []

        labels = AXUtilitiesRelation.get_is_labelled_by(obj)
        if descriptions == labels:
            tokens = ["SCRIPT UTILITIES:", obj,
                      "'s described-by targets are the same as labelled-by targets"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return []

        return descriptions

    def detailsContentForObject(self, obj: Atspi.Accessible) -> list[str]:
        details = self.detailsForObject(obj)
        return list(map(self.displayedText, details))

    def detailsForObject(self, obj: Atspi.Accessible, textOnly: bool = True) -> list[Atspi.Accessible]:
        """Return a list of objects containing details for obj."""

        details = AXUtilitiesRelation.get_details(obj)
        if not details and AXUtilities.is_toggle_button(obj) \
            and AXUtilities.is_expanded(obj):
            details = [child for child in AXObject.iter_children(obj)]

        if not textOnly:
            return details

        textObjects = []
        for detail in details:
            textObjects.extend(self.findAllDescendants(detail, self.queryNonEmptyText))

        return textObjects

    def displayedDescription(self, obj: Atspi.Accessible) -> str:
        """Returns the text being displayed for the object describing obj."""

        try:
            return self._script.generatorCache[self.DISPLAYED_DESCRIPTION][obj]
        except Exception:
            if self.DISPLAYED_DESCRIPTION not in self._script.generatorCache:
                self._script.generatorCache[self.DISPLAYED_DESCRIPTION] = {}

        string = " ".join(map(self.displayedText, self.descriptionsForObject(obj)))
        self._script.generatorCache[self.DISPLAYED_DESCRIPTION][obj] = string
        return self._script.generatorCache[self.DISPLAYED_DESCRIPTION][obj]

    def displayedText(self, obj: Atspi.Accessible) -> Optional[str]:
        """Returns the text being displayed for an object.

        Arguments:
        - obj: the object

        Returns the text being displayed for an object or None if there isn't
        any text being shown.
        """

        # NOTE: Legacy behavior; removal requires thorough testing.

        try:
            return self._script.generatorCache[self.DISPLAYED_TEXT][obj]
        except Exception:
            displayedText = None

        name = AXObject.get_name(obj)
        role = AXObject.get_role(obj)

        directText = self._getDisplayedTextDirectName(role, name)
        if directText is not None:
            return directText

        displayedText = self._getDisplayedTextFromText(obj)
        if not displayedText:
            displayedText = self._getDisplayedTextFallbackName(role, name)
        if not displayedText:
            displayedText = self._getDisplayedTextFromLabels(obj, role)

        if self.DISPLAYED_TEXT not in self._script.generatorCache:
            self._script.generatorCache[self.DISPLAYED_TEXT] = {}

        self._script.generatorCache[self.DISPLAYED_TEXT][obj] = displayedText
        return self._script.generatorCache[self.DISPLAYED_TEXT][obj]

    def _getDisplayedTextDirectName(self, role: Atspi.Role, name: str) -> Optional[str]:
        if role in self.DISPLAYED_TEXT_DIRECT_NAME_ROLES and name:
            return name
        return None

    def _getDisplayedTextFromText(self, obj: Atspi.Accessible) -> Optional[str]:
        if not AXObject.supports_text(obj):
            return None

        displayedText = AXText.get_all_text(obj)
        if self.EMBEDDED_OBJECT_CHARACTER in displayedText:
            return None

        return displayedText

    def _getDisplayedTextFallbackName(self, role: Atspi.Role, name: str) -> Optional[str]:
        # NOTE: Legacy fallback; removal requires thorough testing.
        if name and role not in self.DISPLAYED_TEXT_SKIP_NAME_ROLES:
            return name

        return None

    def _getDisplayedTextFromLabels(self, obj: Atspi.Accessible, role: Atspi.Role) -> Optional[str]:
        if role not in self.DISPLAYED_TEXT_LABEL_FALLBACK_ROLES:
            return None

        labels = self.unrelatedLabels(obj, minimumWords=1)
        if not labels:
            labels = self.unrelatedLabels(obj, onlyShowing=False, minimumWords=1)
        return " ".join(map(self.displayedText, labels))

    def documentFrame(self, obj: Optional[Atspi.Accessible] = None) -> Optional[Atspi.Accessible]:
        """Returns the document frame which is displaying the content.
        Note that this is intended primarily for web content."""

        if not obj:
            obj, offset = self.getCaretContext()

        document = AXObject.find_ancestor(obj, AXUtilities.is_document)
        if not document and AXUtilities.is_document(cthulhu_state.locusOfFocus):
            return cthulhu_state.locusOfFocus

        return document

    def documentFrameURI(self, documentFrame: Optional[Atspi.Accessible] = None) -> Optional[str]:
        """Returns the URI of the document frame that is active."""

        return None

    def frameAndDialog(self, obj: Optional[Atspi.Accessible]) -> list[Optional[Atspi.Accessible]]:
        """Returns the frame and (possibly) the dialog containing obj."""

        results = [None, None]

        obj = obj or cthulhu_state.locusOfFocus
        if not obj:
            msg = "SCRIPT UTILITIES: frameAndDialog() called without valid object"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return results

        topLevel = self.topLevelObject(obj)
        if topLevel is None:
            tokens = ["SCRIPT UTILITIES: could not find top-level object for", obj]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return results

        dialog_roles = [Atspi.Role.DIALOG, Atspi.Role.FILE_CHOOSER]
        if self._treatAlertsAsDialogs():
            dialog_roles.append(Atspi.Role.ALERT)

        role = AXObject.get_role(topLevel)
        if role in dialog_roles:
            results[1] = topLevel
        else:
            if role in [Atspi.Role.FRAME, Atspi.Role.WINDOW]:
                results[0] = topLevel

            def isDialog(x: Atspi.Accessible) -> bool:
                return AXObject.get_role(x) in dialog_roles

            if isDialog(obj):
                results[1] = obj
            else:
                results[1] = AXObject.find_ancestor(obj, isDialog)

        tokens = ["SCRIPT UTILITIES:", obj, "is in frame", results[0], "and dialog", results[1]]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)
        return results

    def presentEventFromNonShowingObject(self, event: Any) -> bool:
        if event.source == cthulhu_state.locusOfFocus:
            return True

        return False

    def grabFocusBeforeRouting(self, obj: Atspi.Accessible) -> bool:
        """Whether or not we should perform a grabFocus before routing
        the cursor via the braille cursor routing keys.

        Arguments:
        - obj: the accessible object where the cursor should be routed

        Returns True if we should do an explicit grabFocus on obj prior
        to routing the cursor.
        """

        return AXUtilities.is_combo_box(obj) \
            and not self.isSameObject(obj, cthulhu_state.locusOfFocus)

    def hasMatchingHierarchy(self, obj: Atspi.Accessible, rolesList: list[Atspi.Role]) -> bool:
        """Called to determine if the given object and it's hierarchy of
        parent objects, each have the desired roles. Please note: You
        should strongly consider an alternative means for determining
        that a given object is the desired item. Failing that, you should
        include only enough of the hierarchy to make the determination.
        If the developer of the application you are providing access to
        does so much as add an Adjustment to reposition a widget, this
        method can fail. You have been warned.

        Arguments:
        - obj: the accessible object to check.
        - rolesList: the list of desired roles for the components and the
          hierarchy of its parents.

        Returns True if all roles match.
        """

        current = obj
        for role in rolesList:
            if current is None:
                return False

            if not isinstance(role, list):
                role = [role]

            if isinstance(role[0], str):
                current_role = AXObject.get_role_name(current)
            else:
                current_role = AXObject.get_role(current)

            if current_role not in role:
                return False

            current = AXObject.get_parent_checked(current)

        return True

    def inFindContainer(self, obj: Optional[Atspi.Accessible] = None) -> bool:
        if obj is None:
            obj = cthulhu_state.locusOfFocus

        if not AXUtilities.is_entry(obj):
            return False

        return AXObject.find_ancestor(obj, AXUtilities.is_tool_bar) is not None

    def getFindResultsCount(self, root: Optional[Atspi.Accessible] = None) -> str:
        return ""

    def isAnchor(self, obj: Atspi.Accessible) -> bool:
        return False

    def isCode(self, obj: Atspi.Accessible) -> bool:
        return False

    def isCodeDescendant(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDockedFrame(self, obj: Atspi.Accessible) -> bool:
        if not AXUtilities.is_frame(obj):
            return False

        attrs = self.objectAttributes(obj)
        return attrs.get('window-type') == 'dock'

    def isDesktop(self, obj: Atspi.Accessible) -> bool:
        if not AXUtilities.is_frame(obj):
            return False

        attrs = self.objectAttributes(obj)
        return attrs.get('is-desktop') == 'true'

    def isComboBoxWithToggleDescendant(self, obj: Atspi.Accessible) -> bool:
        return False

    def isToggleDescendantOfComboBox(self, obj: Atspi.Accessible) -> bool:
        return False

    def isTypeahead(self, obj: Atspi.Accessible) -> bool:
        return False

    def isOrDescendsFrom(self, obj: Atspi.Accessible, ancestor: Atspi.Accessible) -> bool:
        if obj == ancestor:
            return True

        return AXObject.find_ancestor(obj, lambda x: x and x == ancestor)

    def isFunctionalDialog(self, obj: Atspi.Accessible) -> bool:
        """Returns True if the window is a functioning as a dialog.
        This method should be subclassed by application scripts as
        needed.
        """

        return False

    def isComment(self, obj: Atspi.Accessible) -> bool:
        return False

    def isContentDeletion(self, obj: Atspi.Accessible) -> bool:
        return False

    def isContentError(self, obj: Atspi.Accessible) -> bool:
        return False

    def isContentInsertion(self, obj: Atspi.Accessible) -> bool:
        return False

    def isContentMarked(self, obj: Atspi.Accessible) -> bool:
        return False

    def isContentSuggestion(self, obj: Atspi.Accessible) -> bool:
        return False

    def isInlineSuggestion(self, obj: Atspi.Accessible) -> bool:
        return False

    def isFirstItemInInlineContentSuggestion(self, obj: Atspi.Accessible) -> bool:
        return False

    def isLastItemInInlineContentSuggestion(self, obj: Atspi.Accessible) -> bool:
        return False

    def isEmpty(self, obj: Atspi.Accessible) -> bool:
        return False

    def isHidden(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPub(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubAbstract(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubAcknowledgments(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubAfterword(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubAppendix(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubBibliography(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubBacklink(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubBiblioref(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubChapter(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubColophon(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubConclusion(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubCover(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubCredit(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubCredits(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubDedication(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubEndnote(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubEndnotes(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubEpigraph(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubEpilogue(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubErrata(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubExample(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubFootnote(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubForeword(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubGlossary(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubGlossref(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubIndex(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubIntroduction(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubPagelist(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubPagebreak(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubPart(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubPreface(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubPrologue(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubPullquote(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubQna(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubSubtitle(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubToc(self, obj: Atspi.Accessible) -> bool:
        return False

    def isFeed(self, obj: Atspi.Accessible) -> bool:
        return False

    def isFeedArticle(self, obj: Atspi.Accessible) -> bool:
        return False

    def isFigure(self, obj: Atspi.Accessible) -> bool:
        return False

    def isGrid(self, obj: Atspi.Accessible) -> bool:
        return False

    def isGridCell(self, obj: Atspi.Accessible) -> bool:
        return False

    def isLandmark(self, obj: Atspi.Accessible) -> bool:
        return False

    def isLandmarkWithoutType(self, obj: Atspi.Accessible) -> bool:
        return False

    def isLandmarkBanner(self, obj: Atspi.Accessible) -> bool:
        return False

    def isLandmarkComplementary(self, obj: Atspi.Accessible) -> bool:
        return False

    def isLandmarkContentInfo(self, obj: Atspi.Accessible) -> bool:
        return False

    def isLandmarkForm(self, obj: Atspi.Accessible) -> bool:
        return False

    def isLandmarkMain(self, obj: Atspi.Accessible) -> bool:
        return False

    def isLandmarkNavigation(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDPubNoteref(self, obj: Atspi.Accessible) -> bool:
        return False

    def isLandmarkRegion(self, obj: Atspi.Accessible) -> bool:
        return False

    def isLandmarkSearch(self, obj: Atspi.Accessible) -> bool:
        return False

    def isSVG(self, obj: Atspi.Accessible) -> bool:
        return False

    def speakMathSymbolNames(self, obj: Optional[Atspi.Accessible] = None) -> bool:
        return False

    def isInMath(self) -> bool:
        return False

    def isMath(self, obj: Atspi.Accessible) -> bool:
        return False

    def isMathLayoutOnly(self, obj: Atspi.Accessible) -> bool:
        return False

    def isMathMultiline(self, obj: Atspi.Accessible) -> bool:
        return False

    def isMathEnclosed(self, obj: Atspi.Accessible) -> bool:
        return False

    def isMathFenced(self, obj: Atspi.Accessible) -> bool:
        return False

    def isMathFractionWithoutBar(self, obj: Atspi.Accessible) -> bool:
        return False

    def isMathPhantom(self, obj: Atspi.Accessible) -> bool:
        return False

    def isMathMultiScript(self, obj: Atspi.Accessible) -> bool:
        return False

    def isMathSubOrSuperScript(self, obj: Atspi.Accessible) -> bool:
        return False

    def isMathUnderOrOverScript(self, obj: Atspi.Accessible) -> bool:
        return False

    def isMathSquareRoot(self, obj: Atspi.Accessible) -> bool:
        return False

    def isMathTable(self, obj: Atspi.Accessible) -> bool:
        return False

    def isMathTableRow(self, obj: Atspi.Accessible) -> bool:
        return False

    def isMathTableCell(self, obj: Atspi.Accessible) -> bool:
        return False

    def isMathToken(self, obj: Atspi.Accessible) -> bool:
        return False

    def isMathTopLevel(self, obj: Atspi.Accessible) -> bool:
        return False

    def getMathDenominator(self, obj: Atspi.Accessible) -> Optional[Atspi.Accessible]:
        return None

    def getMathNumerator(self, obj: Atspi.Accessible) -> Optional[Atspi.Accessible]:
        return None

    def getMathRootBase(self, obj: Atspi.Accessible) -> Optional[Atspi.Accessible]:
        return None

    def getMathRootIndex(self, obj: Atspi.Accessible) -> Optional[Atspi.Accessible]:
        return None

    def getMathScriptBase(self, obj: Atspi.Accessible) -> Optional[Atspi.Accessible]:
        return None

    def getMathScriptSubscript(self, obj: Atspi.Accessible) -> Optional[Atspi.Accessible]:
        return None

    def getMathScriptSuperscript(self, obj: Atspi.Accessible) -> Optional[Atspi.Accessible]:
        return None

    def getMathScriptUnderscript(self, obj: Atspi.Accessible) -> Optional[Atspi.Accessible]:
        return None

    def getMathScriptOverscript(self, obj: Atspi.Accessible) -> Optional[Atspi.Accessible]:
        return None

    def getMathPrescripts(self, obj: Atspi.Accessible) -> list[Atspi.Accessible]:
        return []

    def getMathPostscripts(self, obj: Atspi.Accessible) -> list[Atspi.Accessible]:
        return []

    def getMathEnclosures(self, obj: Atspi.Accessible) -> list[str]:
        return []

    def getMathFencedSeparators(self, obj: Atspi.Accessible) -> list[str]:
        return ['']

    def getMathFences(self, obj: Atspi.Accessible) -> list[str]:
        return ['', '']

    def getMathNestingLevel(self, obj: Atspi.Accessible, test: Optional[Callable[[Atspi.Accessible], bool]] = None) -> int:
        return 0

    def getLandmarkTypes(self) -> list[str]:
        return ["banner",
                "complementary",
                "contentinfo",
                "doc-acknowledgments",
                "doc-afterword",
                "doc-appendix",
                "doc-bibliography",
                "doc-chapter",
                "doc-conclusion",
                "doc-credits",
                "doc-endnotes",
                "doc-epilogue",
                "doc-errata",
                "doc-foreword",
                "doc-glossary",
                "doc-index",
                "doc-introduction",
                "doc-pagelist",
                "doc-part",
                "doc-preface",
                "doc-prologue",
                "doc-toc",
                "form",
                "main",
                "navigation",
                "region",
                "search"]

    def isProgressBar(self, obj: Atspi.Accessible) -> bool:
        if not AXUtilities.is_progress_bar(obj):
            return False

        if not AXObject.supports_value(obj):
            tokens = ["SCRIPT UTILITIES:", obj, "doesn't implement AtspiValue"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return False

        min_value = AXValue.get_minimum_value(obj)
        max_value = AXValue.get_maximum_value(obj)
        if max_value == min_value:
            tokens = ["SCRIPT UTILITIES:", obj, "is busy indicator"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return False

        return True

    def isProgressBarUpdate(self, obj: Atspi.Accessible) -> bool:
        if not cthulhu.cthulhuApp.settingsManager.getSetting('speakProgressBarUpdates') \
           and not cthulhu.cthulhuApp.settingsManager.getSetting('brailleProgressBarUpdates') \
           and not cthulhu.cthulhuApp.settingsManager.getSetting('beepProgressBarUpdates'):
            return False, "Updates not enabled"

        if not self.isProgressBar(obj):
            return False, "Is not progress bar"

        if self.hasNoSize(obj):
            return False, "Has no size"

        if cthulhu.cthulhuApp.settingsManager.getSetting('ignoreStatusBarProgressBars'):
            if AXObject.find_ancestor(obj, AXUtilities.is_status_bar):
                return False, "Is status bar descendant"

        verbosity = cthulhu.cthulhuApp.settingsManager.getSetting('progressBarVerbosity')
        if verbosity == settings.PROGRESS_BAR_ALL:
            return True, "Verbosity is all"

        if verbosity == settings.PROGRESS_BAR_WINDOW:
            topLevel = self.topLevelObject(obj)
            if topLevel == cthulhu_state.activeWindow:
                return True, "Verbosity is window"
            return False, f"Window {topLevel} is not {cthulhu_state.activeWindow}"

        if verbosity == settings.PROGRESS_BAR_APPLICATION:
            app = AXObject.get_application(obj)
            if app == cthulhu_state.activeScript.app:
                return True, "Verbosity is app"
            return False, f"App {app} is not {cthulhu_state.activeScript.app}"

        return True, "Not handled by any other case"

    def getValueAsPercent(self, obj: Atspi.Accessible) -> Optional[Any]:
        if not AXObject.supports_value(obj):
            tokens = ["SCRIPT UTILITIES:", obj, "doesn't implement AtspiValue"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return None

        val = AXValue.get_current_value(obj)
        minval = AXValue.get_minimum_value(obj)
        maxval = AXValue.get_maximum_value(obj)

        if AXUtilities.is_indeterminate(obj):
            tokens = ["SCRIPT UTILITIES:", obj, "has state indeterminate and value of", val]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            if val <= 0:
                return None

        if maxval == minval == val:
            if 1 <= val <= 100:
                return int(val)
            return None

        return int((val / (maxval - minval)) * 100)

    def isBlockquote(self, obj: Atspi.Accessible) -> bool:
        return AXUtilities.is_block_quote(obj)

    def isDescriptionList(self, obj: Atspi.Accessible) -> bool:
        return AXUtilities.is_description_list(obj)

    def isDescriptionListTerm(self, obj: Atspi.Accessible) -> bool:
        return AXUtilities.is_description_term(obj)

    def isDescriptionListDescription(self, obj: Atspi.Accessible) -> bool:
        return AXUtilities.is_description_value(obj)

    def descriptionListTerms(self, obj: Atspi.Accessible) -> list[Any]:
        if not self.isDescriptionList(obj):
            return []

        _include = self.isDescriptionListTerm
        _exclude = self.isDescriptionList
        return self.findAllDescendants(obj, _include, _exclude)

    def isDocumentList(self, obj: Atspi.Accessible) -> bool:
        if AXObject.get_role(obj) not in [Atspi.Role.LIST, Atspi.Role.DESCRIPTION_LIST]:
            return False
        return AXObject.find_ancestor(obj, AXUtilities.is_document) is not None

    def isDocumentPanel(self, obj: Atspi.Accessible) -> bool:
        if not AXUtilities.is_panel(obj):
            return False
        return AXObject.find_ancestor(obj, AXUtilities.is_document) is not None

    def isDocument(self, obj: Atspi.Accessible) -> bool:
        return AXUtilities.is_document(obj)

    def inDocumentContent(self, obj: Optional[Atspi.Accessible] = None) -> Optional[Atspi.Accessible]:
        obj = obj or cthulhu_state.locusOfFocus
        return self.getDocumentForObject(obj) is not None

    def activeDocument(self, window: Optional[Atspi.Accessible] = None) -> Optional[Atspi.Accessible]:
        return self.getTopLevelDocumentForObject(cthulhu_state.locusOfFocus)

    def isTopLevelDocument(self, obj: Atspi.Accessible) -> bool:
        return self.isDocument(obj) and not AXObject.find_ancestor(obj, self.isDocument)

    def getTopLevelDocumentForObject(self, obj: Atspi.Accessible) -> Any:
        if self.isTopLevelDocument(obj):
            return obj

        return AXObject.find_ancestor(obj, self.isTopLevelDocument)

    def getDocumentForObject(self, obj: Atspi.Accessible) -> Optional[Any]:
        if not obj:
            return None

        if self.isDocument(obj):
            return obj

        return AXObject.find_ancestor(obj, self.isDocument)

    def getModalDialog(self, obj: Atspi.Accessible) -> bool:
        if not obj:
            return False

        if AXUtilities.is_modal_dialog(obj):
            return obj

        return AXObject.find_ancestor(obj, AXUtilities.is_modal_dialog)

    def isModalDialogDescendant(self, obj: Atspi.Accessible) -> bool:
        if not obj:
            return False

        return self.getModalDialog(obj) is not None

    def getTable(self, obj: Atspi.Accessible) -> Optional[Any]:
        if not obj:
            return None

        def isTable(x: Atspi.Accessible) -> bool:
            if AXUtilities.is_table(x) or AXUtilities.is_tree_table(x) or AXUtilities.is_tree(x):
                return AXObject.supports_table(x)
            return False

        if isTable(obj):
            return obj

        return AXObject.find_ancestor(obj, isTable)

    def isTextDocumentTable(self, obj: Atspi.Accessible) -> bool:
        if not AXUtilities.is_table(obj):
            return False

        doc = self.getDocumentForObject(obj)
        return doc is not None and not AXUtilities.is_document_spreadsheet(doc)

    def isGUITable(self, obj: Atspi.Accessible) -> bool:
        return AXUtilities.is_table(obj) and self.getDocumentForObject(obj) is None

    def isSpreadSheetTable(self, obj: Atspi.Accessible) -> bool:
        if not (AXUtilities.is_table(obj) and AXObject.supports_table(obj)):
            return False

        doc = self.getDocumentForObject(obj)
        if doc is None:
            return False
        if AXUtilities.is_document_spreadsheet(doc):
            return True

        return AXTable.get_row_count(obj, prefer_attribute=False) > 65536

    def isTextDocumentCell(self, obj: Atspi.Accessible) -> bool:
        if not AXUtilities.is_table_cell_or_header(obj):
            return False
        return AXObject.find_ancestor(obj, self.isTextDocumentTable)

    def isSpreadSheetCell(self, obj: Atspi.Accessible) -> bool:
        if not AXUtilities.is_table_cell_or_header(obj):
            return False
        return AXObject.find_ancestor(obj, self.isSpreadSheetTable)

    def cellColumnChanged(self, cell: Any) -> bool:
        row, column = self.coordinatesForCell(cell)
        if column == -1:
            return False

        lastColumn = self._script.pointOfReference.get("lastColumn")
        return column != lastColumn

    def cellRowChanged(self, cell: Any) -> bool:
        row, column = self.coordinatesForCell(cell)
        if row == -1:
            return False

        lastRow = self._script.pointOfReference.get("lastRow")
        return row != lastRow

    def shouldReadFullRow(self, obj: Atspi.Accessible) -> bool:
        if self._script.inSayAll():
            return False

        if not self.cellRowChanged(obj):
            return False

        table = self.getTable(obj)
        if not table:
            return False

        if not self.getDocumentForObject(table):
            return cthulhu.cthulhuApp.settingsManager.getSetting('readFullRowInGUITable')

        if self.isSpreadSheetTable(table):
            return cthulhu.cthulhuApp.settingsManager.getSetting('readFullRowInSpreadSheet')

        return cthulhu.cthulhuApp.settingsManager.getSetting('readFullRowInDocumentTable')

    def isSorted(self, obj: Atspi.Accessible) -> bool:
        return False

    def isAscending(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDescending(self, obj: Atspi.Accessible) -> bool:
        return False

    def getSortOrderDescription(self, obj: Atspi.Accessible, includeName: Any = False) -> str:
        if not (obj and self.isSorted(obj)):
            return ""

        if self.isAscending(obj):
            result = object_properties.SORT_ORDER_ASCENDING
        elif self.isDescending(obj):
            result = object_properties.SORT_ORDER_DESCENDING
        else:
            result = object_properties.SORT_ORDER_OTHER

        if includeName and AXObject.get_name(obj):
            result = f"{AXObject.get_name(obj)}. {result}"

        return result

    def isFocusableLabel(self, obj: Atspi.Accessible) -> bool:
        return AXUtilities.is_label(obj) and AXUtilities.is_focusable(obj)

    def isNonFocusableList(self, obj: Atspi.Accessible) -> bool:
        return AXUtilities.is_list(obj) and not AXUtilities.is_focusable(obj)

    def isStatusBarNotification(self, obj: Atspi.Accessible) -> bool:
        if not AXUtilities.is_notification(obj):
            return False
        return AXObject.find_ancestor(obj, AXUtilities.is_status_bar) is not None

    def getNotificationContent(self, obj: Atspi.Accessible) -> str:
        if not AXUtilities.is_notification(obj):
            return ""

        tokens = []
        name = AXObject.get_name(obj)
        if name:
            tokens.append(name)
        text = self.expandEOCs(obj)
        if text and text not in tokens:
            tokens.append(text)
        else:
            labels = " ".join(map(self.displayedText, self.unrelatedLabels(obj, False, 1)))
            if labels and labels not in tokens:
                tokens.append(labels)

        if not tokens:
            description = AXObject.get_description(obj)
            if description:
                tokens.append(description)

        return " ".join(tokens)

    def isTreeDescendant(self, obj: Atspi.Accessible) -> bool:
        if obj is None:
            return False

        if AXUtilities.is_tree_item(obj):
            return True

        return AXObject.find_ancestor(obj, AXUtilities.is_tree_or_tree_table) is not None

    def isLayoutOnly(self, obj: Atspi.Accessible) -> bool:
        """Returns True if the given object is a container which has
        no presentable information (label, name, displayed text, etc.)."""

        layoutOnly = False

        if AXObject.is_dead(obj) or self.isZombie(obj):
            return True

        attrs = self.objectAttributes(obj)
        role = AXObject.get_role(obj)
        parentRole = AXObject.get_role(AXObject.get_parent(obj))
        firstChild = AXObject.get_child(obj, 0)

        topLevelRoles = self._topLevelRoles()
        ignorePanelParent = [Atspi.Role.MENU,
                             Atspi.Role.MENU_ITEM,
                             Atspi.Role.LIST_ITEM,
                             Atspi.Role.TREE_ITEM]

        if role == Atspi.Role.TABLE and attrs.get('layout-guess') != 'true':
            if not AXObject.supports_table(obj):
                tokens = ["SCRIPT UTILITIES: Table", obj, "does not implement table interface"]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
                layoutOnly = True
            else:
                rows = AXTable.get_row_count(obj, prefer_attribute=False)
                cols = AXTable.get_column_count(obj, prefer_attribute=False)
                if not (rows and cols):
                    layoutOnly = not AXUtilities.is_focused(obj)
                elif attrs.get('xml-roles') == 'table' or attrs.get('tag') == 'table':
                    layoutOnly = False
                elif not (AXObject.get_name(obj) or self.displayedLabel(obj)):
                    layoutOnly = not (AXTable.has_column_headers(obj) or AXTable.has_row_headers(obj))
        elif role == Atspi.Role.TABLE_CELL and AXObject.get_child_count(obj):
            if parentRole == Atspi.Role.TREE_TABLE:
                layoutOnly = not AXObject.get_name(obj)
            elif AXUtilities.is_table_cell(firstChild):
                layoutOnly = True
            elif parentRole == Atspi.Role.TABLE:
                layoutOnly = self.isLayoutOnly(AXObject.get_parent(obj))
        elif role == Atspi.Role.SECTION:
            layoutOnly = not self.isBlockquote(obj)
        elif role == Atspi.Role.BLOCK_QUOTE:
            layoutOnly = False
        elif role == Atspi.Role.FILLER:
            layoutOnly = True
        elif role == Atspi.Role.SCROLL_PANE:
            layoutOnly = True
        elif role == Atspi.Role.LAYERED_PANE:
            layoutOnly = self.isDesktop(self.topLevelObject(obj))
        elif role == Atspi.Role.AUTOCOMPLETE:
            layoutOnly = True
        elif role in [Atspi.Role.TEAROFF_MENU_ITEM, Atspi.Role.SEPARATOR]:
            layoutOnly = True
        elif role in [Atspi.Role.LIST_BOX, Atspi.Role.TREE_TABLE]:
            layoutOnly = False
        elif role in topLevelRoles:
            layoutOnly = False
        elif role == Atspi.Role.MENU:
            layoutOnly = parentRole == Atspi.Role.COMBO_BOX
        elif role == Atspi.Role.COMBO_BOX:
            layoutOnly = False
        elif role == Atspi.Role.LIST:
            layoutOnly = False
        elif role == Atspi.Role.FORM:
            layoutOnly = False
        elif role in [Atspi.Role.PUSH_BUTTON, Atspi.Role.TOGGLE_BUTTON]:
            layoutOnly = False
        elif role in [Atspi.Role.TEXT, Atspi.Role.PASSWORD_TEXT, Atspi.Role.ENTRY]:
            layoutOnly = False
        elif role == Atspi.Role.LIST_ITEM and parentRole == Atspi.Role.LIST_BOX:
            layoutOnly = False
        elif role in [Atspi.Role.REDUNDANT_OBJECT, Atspi.Role.UNKNOWN]:
            layoutOnly = True
        elif self.isTableRow(obj):
            layoutOnly = not (AXUtilities.is_focusable(obj) or AXUtilities.is_selectable(obj))
        elif role == Atspi.Role.PANEL and AXObject.get_role(firstChild) in ignorePanelParent:
            layoutOnly = True
        elif role == Atspi.Role.PANEL \
                and AXObject.has_same_non_empty_name(obj, AXObject.get_application(obj)):
            layoutOnly = True
        elif AXObject.get_child_count(obj) == 1 \
                and AXObject.has_same_non_empty_name(obj, firstChild):
            layoutOnly = True
        elif self.isHidden(obj):
            layoutOnly = True
        else:
            if not (self.displayedText(obj) or self.displayedLabel(obj)):
                layoutOnly = True

        if layoutOnly:
            tokens = ["SCRIPT UTILITIES:", obj, "is deemed to be layout only"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)

        return layoutOnly

    def isInActiveApp(self, obj: Atspi.Accessible) -> bool:
        """Returns True if the given object is from the same application that
        currently has keyboard focus.

        Arguments:
        - obj: an Accessible object
        """

        if not obj or not cthulhu_state.locusOfFocus:
            return False

        return AXObject.get_application(cthulhu_state.locusOfFocus) == AXObject.get_application(obj)

    def isLink(self, obj: Atspi.Accessible) -> bool:
        """Returns True if obj is a link."""

        return AXUtilities.is_link(obj)

    def isReadOnlyTextArea(self, obj: Atspi.Accessible) -> bool:
        """Returns True if obj is a text entry area that is read only."""

        if not self.isTextArea(obj):
            return False

        if AXUtilities.is_read_only(obj):
            return True

        return AXUtilities.is_focusable(obj) and not AXUtilities.is_editable(obj)

    def isSwitch(self, obj: Atspi.Accessible) -> bool:
        return False

    def get_objectFromPath(self, path: int) -> Any:
        start = self._script.app
        rv = None
        for p in path:
            if p == -1:
                continue
            try:
                start = start[p]
            except Exception:
                break
        else:
            rv = start

        return rv

    def _hasSamePath(self, obj1: Any, obj2: Any) -> bool:
        path1 = AXObject.get_path(obj1)
        path2 = AXObject.get_path(obj2)
        if len(path1) != len(path2):
            return False

        if not (path1 and path2):
            return False

        # The first item in all paths, even valid ones, is -1.
        path1 = path1[1:]
        path2 = path2[1:]

        # If the object is being destroyed and the replacement is too, which
        # sadly can happen in at least Firefox, both will have an index of -1.
        # If the rest of the paths are valid and match, it's probably ok.
        if path1[-1] == -1 and path2[-1] == -1:
            path1 = path1[:-1]
            path2 = path2[:-1]

        # If both have invalid child indices, all bets are off.
        if path1.count(-1) and path2.count(-1):
            return False

        try:
            index = path1.index(-1)
        except ValueError:
            try:
                index = path2.index(-1)
            except ValueError:
                index = len(path2)

        return path1[0:index] == path2[0:index]

    def isSameObject(self, obj1: Optional[Atspi.Accessible], obj2: Optional[Atspi.Accessible],
                     comparePaths: bool = False, ignoreNames: bool = False,
                     ignoreDescriptions: bool = True) -> bool:
        if obj1 == obj2:
            return True

        if obj1 is None or obj2 is None:
            return False

        if not AXUtilities.have_same_role(obj1, obj2):
            return False

        if not ignoreNames and AXObject.get_name(obj1) != AXObject.get_name(obj2):
            return False

        if not ignoreDescriptions \
           and AXObject.get_description(obj1) != AXObject.get_description(obj2):
            return False

        if comparePaths and self._hasSamePath(obj1, obj2):
            return True

        try:
            # Comparing the extents of objects which claim to be different
            # addresses both managed descendants and implementations which
            # recreate accessibles for the same widget.
            if not AXObject.supports_component(obj1) or not AXObject.supports_component(obj2):
                return False
            extents1 = Atspi.Component.get_extents(obj1, Atspi.CoordType.WINDOW)
            extents2 = Atspi.Component.get_extents(obj2, Atspi.CoordType.WINDOW)

            # Objects which claim to be different and which are in different
            # locations are almost certainly not recreated objects.
            if extents1 != extents2:
                return False

            # Objects which claim to have the same role, the same name, and
            # the same size and position are highly likely to be the same
            # functional object -- if they have valid, on-screen extents.
            if extents1.x >= 0 and extents1.y >= 0 and extents1.width > 0 \
                and extents1.height > 0:
                return True
        except Exception as error:
            tokens = ["SCRIPT UTILITIES: Exception in isSameObject (",
                      obj1, "vs", obj2, "):", error]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)

        return False

    def isTextArea(self, obj: Atspi.Accessible) -> bool:
        """Returns True if obj is a GUI component that is for entering text.

        Arguments:
        - obj: an accessible
        """

        if self.isLink(obj):
            return False

        return self._isTextAreaByType(obj)

    def _isTextAreaByType(self, obj: Atspi.Accessible) -> Any:
        # NOTE: This is legacy and may need more checks now.
        return AXUtilities.is_text_input(obj) \
            or AXUtilities.is_text(obj) \
            or AXUtilities.is_paragraph(obj)

    def labelsForObject(self, obj: Atspi.Accessible) -> Any:
        """Return a list of the labels for this object."""

        def isNotAncestor(acc: Atspi.Accessible) -> bool:
            return not AXObject.find_ancestor(obj, lambda x: x == acc)

        result = AXUtilitiesRelation.get_is_labelled_by(obj)
        return list(filter(isNotAncestor, result))

    def linkBasenameToName(self, obj: Atspi.Accessible) -> str:
        basename = self.linkBasename(obj)
        if not basename:
            return ""

        basename = re.sub(r"[-_]", " ", basename)
        tokens = basename.split()
        for token in tokens:
            if not token.isalpha():
                return ""

        return basename

    def linkBasename(self, obj: Atspi.Accessible) -> Any:
        """Returns the relevant information from the URI.  The idea is
        to attempt to strip off all prefix and suffix, much like the
        basename command in a shell."""

        basename = None
        uri = AXHypertext.get_link_uri(obj)
        if uri and len(uri):
            # Sometimes the URI is an expression that includes a URL.
            # Currently that can be found at the bottom of safeway.com.
            # It can also be seen in the backwards.html test file.
            #
            expression = uri.split(',')
            if len(expression) > 1:
                for item in expression:
                    if item.find('://') >=0:
                        if not item[0].isalnum():
                            item = item[1:-1]
                        if not item[-1].isalnum():
                            item = item[0:-2]
                        uri = item
                        break

            # We're assuming that there IS a base name to be had.
            # What if there's not? See backwards.html.
            #
            uri = uri.split('://')[-1]
            if not uri:
                return basename

            # Get the last thing after all the /'s, unless it ends
            # in a /.  If it ends in a /, we'll look to the stuff
            # before the ending /.
            #
            if uri[-1] == "/":
                basename = uri[0:-1]
                basename = basename.split('/')[-1]
            elif not uri.count("/"):
                basename = uri
            else:
                basename = uri.split('/')[-1]
                if basename.startswith("index"):
                    basename = uri.split('/')[-2]

                # Now, try to strip off the suffixes.
                #
                basename = basename.split('.')[0]
                basename = basename.split('?')[0]
                basename = basename.split('#')[0]
                basename = basename.split('%')[0]

        return basename

    def linkIndex(self, obj: Atspi.Accessible, characterIndex: int) -> int:
        """A brute force method to see if an offset is a link.  This
        is provided because not all Accessible Hypertext implementations
        properly support the getLinkIndex method.  Returns an index of
        0 or greater of the characterIndex is on a hyperlink.

        Arguments:
        -obj: the object with the Accessible Hypertext specialization
        -characterIndex: the text position to check
        """

        if not obj:
            return -1

        if not AXObject.supports_text(obj):
            return -1

        if not AXObject.supports_hypertext(obj):
            return -1

        for i, link in enumerate(AXHypertext.get_all_links(obj)):
            start = AXHypertext.get_link_start_offset(link)
            end = AXHypertext.get_link_end_offset(link)
            if (characterIndex >= start) and (characterIndex <= end):
                return i

        return -1

    def nestingLevel(self, obj: Atspi.Accessible) -> int:
        """Determines the nesting level of this object.

        Arguments:
        -obj: the Accessible object
        """

        if obj is None:
            return 0

        try:
            return self._script.generatorCache[self.NESTING_LEVEL][obj]
        except Exception:
            if self.NESTING_LEVEL not in self._script.generatorCache:
                self._script.generatorCache[self.NESTING_LEVEL] = {}

        def pred(x: Atspi.Accessible) -> bool:
            if self.isBlockquote(obj):
                return self.isBlockquote(x)
            if AXUtilities.is_list_item(obj):
                return AXUtilities.is_list(AXObject.get_parent(x))
            return AXUtilities.have_same_role(obj, x)

        ancestors = []
        ancestor = AXObject.find_ancestor(obj, pred)
        while ancestor:
            ancestors.append(ancestor)
            ancestor = AXObject.find_ancestor(ancestor, pred)

        nestingLevel = len(ancestors)
        self._script.generatorCache[self.NESTING_LEVEL][obj] = nestingLevel
        return self._script.generatorCache[self.NESTING_LEVEL][obj]

    def nodeLevel(self, obj: Atspi.Accessible) -> int:
        """Determines the node level of this object if it is in a tree
        relation, with 0 being the top level node.  If this object is
        not in a tree relation, then -1 will be returned.

        Arguments:
        -obj: the Accessible object
        """

        if not self.isTreeDescendant(obj):
            return -1

        try:
            return self._script.generatorCache[self.NODE_LEVEL][obj]
        except Exception:
            if self.NODE_LEVEL not in self._script.generatorCache:
                self._script.generatorCache[self.NODE_LEVEL] = {}

        nodes = []
        node = obj
        done = False
        while not done:
            nodeOf = AXUtilitiesRelation.get_is_node_child_of(node)
            node = nodeOf[0] if nodeOf else None

            # We want to avoid situations where something gives us an
            # infinite cycle of nodes.  Bon Echo has been seen to do
            # this (see bug 351847).
            if nodes.count(node):
                tokens = ["SCRIPT UTILITIES:", node, "is already in the list of nodes for", obj]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
                done = True
            if len(nodes) > 100:
                tokens = ["SCRIPT UTILITIES: More than 100 nodes found for", obj]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
                done = True
            elif node:
                nodes.append(node)
            else:
                done = True

        self._script.generatorCache[self.NODE_LEVEL][obj] = len(nodes) - 1
        return self._script.generatorCache[self.NODE_LEVEL][obj]

    def isOnScreen(self, obj: Atspi.Accessible, boundingbox: Any = None) -> bool:
        if AXObject.is_dead(obj):
            return False

        if self.isHidden(obj):
            return False

        if not self.isShowingAndVisible(obj):
            tokens = ["SCRIPT UTILITIES:", obj, "is not showing and visible"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return False

        if not AXObject.supports_component(obj):
            tokens = ["SCRIPT UTILITIES:", obj, "does not support component interface"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return False

        try:
            box = Atspi.Component.get_extents(obj, Atspi.CoordType.SCREEN)
        except Exception:
            tokens = ["SCRIPT UTILITIES: Exception getting extents for", obj]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return False

        tokens = ["SCRIPT UTILITIES: Extents for", obj, "are:", box]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)

        boxTuple = self._extentsToTuple(box)
        x, y, width, height = boxTuple

        if x > 10000 or y > 10000:
            tokens = ["SCRIPT UTILITIES:", obj, "seems to have bogus coordinates"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return False

        if x < 0 and y < 0 and boxTuple != (-1, -1, -1, -1):
            tokens = ["SCRIPT UTILITIES:", obj, "has negative coordinates"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return False

        if not (width or height):
            if not AXObject.get_child_count(obj):
                tokens = ["SCRIPT UTILITIES:", obj, "has no size and no children"]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
                return False
            if AXUtilities.is_menu(obj):
                tokens = ["SCRIPT UTILITIES:", obj, "has no size"]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
                return False

            return True

        if boundingbox is None or not self._boundsIncludeChildren(AXObject.get_parent(obj)):
            return True

        boundingbox = self._extentsToTuple(boundingbox)
        if not self.containsRegion(boxTuple, boundingbox) and boxTuple != (-1, -1, -1, -1):
            tokens = ["SCRIPT UTILITIES:", obj, box, "not in", boundingbox]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return False

        return True

    def selectedMenuBarMenu(self, menubar: Any) -> Optional[Any]:
        if not AXUtilities.is_menu_bar(menubar):
            return None

        if AXObject.supports_selection(menubar):
            selected = self.selectedChildren(menubar)
            if selected:
                return selected[0]
            return None

        for menu in AXObject.iter_children(menubar):
            AXObject.clear_cache(menu)
            if AXUtilities.is_expanded(menu) or AXUtilities.is_selected(menu):
                return menu

        return 

    def isInOpenMenuBarMenu(self, obj: Atspi.Accessible) -> bool:
        if obj is None:
            return False

        menubar = AXObject.find_ancestor(obj, AXUtilities.is_menu_bar)
        if menubar is None:
            return False

        selectedMenu = self._selectedMenuBarMenu.get(hash(menubar))
        if selectedMenu is None:
            selectedMenu = self.selectedMenuBarMenu(menubar)

        if selectedMenu is None:
            return False

        def inSelectedMenu(x: Atspi.Accessible) -> bool:
            return x == selectedMenu

        if inSelectedMenu(obj):
            return True

        return AXObject.find_ancestor(obj, inSelectedMenu) is not None

    def isStaticTextLeaf(self, obj: Atspi.Accessible) -> bool:
        return False

    def isListItemMarker(self, obj: Atspi.Accessible) -> bool:
        return False

    def hasPresentableText(self, obj: Atspi.Accessible) -> bool:
        if self.isStaticTextLeaf(obj):
            return False

        if not AXObject.supports_text(obj):
            return False

        return bool(re.search(r"\w+", AXText.get_all_text(obj)))

    def getOnScreenObjects(self, root: Any, extents: Any = None) -> list[Any]:
        if not self.isOnScreen(root, extents):
            return []

        if AXObject.get_role(root) == Atspi.Role.INVALID:
            return []

        if AXUtilities.is_button(root) or AXUtilities.is_combo_box(root):
            return [root]

        if AXUtilities.is_menu_bar(root):
            self._selectedMenuBarMenu[hash(root)] = self.selectedMenuBarMenu(root)

        if AXUtilities.is_menu_bar(AXObject.get_parent(root)) \
           and not self.isInOpenMenuBarMenu(root):
            return [root]

        if AXUtilities.is_filler(root) and not AXObject.get_child_count(root):
            tokens = ["SCRIPT UTILITIES:", root, "is empty filler. Clearing cache."]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            AXObject.clear_cache(root, recursive=True)
            tokens = ["SCRIPT UTILITIES:", root, "now reports",
                      AXObject.get_child_count(root), "children"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)

        if extents is None:
            if AXObject.supports_component(root):
                try:
                    extents = Atspi.Component.get_extents(root, Atspi.CoordType.SCREEN)
                except Exception:
                    tokens = ["SCRIPT UTILITIES: Exception getting extents of", root]
                    debug.printTokens(debug.LEVEL_INFO, tokens, True)
                    extents = Atspi.Rect()
            else:
                extents = Atspi.Rect()

        if AXObject.supports_table(root) and AXObject.supports_selection(root):
            visibleCells = self.getVisibleTableCells(root)
            if visibleCells:
                return visibleCells

        objects = []
        hasNameOrDesc = AXObject.get_name(root) or AXObject.get_description(root)
        if hasNameOrDesc and (AXUtilities.is_page_tab(root) or AXUtilities.is_image(root)):
            objects.append(root)
        elif self.hasPresentableText(root):
            objects.append(root)

        def pred(x: Atspi.Accessible) -> bool:
            return x is not None and not self.isStaticTextLeaf(x)

        for child in AXObject.iter_children(root, pred):
            objects.extend(self.getOnScreenObjects(child, extents))

        if AXUtilities.is_menu_bar(root):
            self._selectedMenuBarMenu[hash(root)] = None

        if objects:
            return objects

        if AXUtilities.is_label(root) and not hasNameOrDesc and not self.queryNonEmptyText(root):
            return []

        containers = [Atspi.Role.CANVAS,
                      Atspi.Role.FILLER,
                      Atspi.Role.IMAGE,
                      Atspi.Role.LINK,
                      Atspi.Role.LIST_BOX,
                      Atspi.Role.PANEL,
                      Atspi.Role.SECTION,
                      Atspi.Role.SCROLL_PANE,
                      Atspi.Role.VIEWPORT]
        if AXObject.get_role(root) in containers and not hasNameOrDesc:
            return []

        return [root]

    def isTableRow(self, obj: Atspi.Accessible) -> bool:
        """Determines if obj is a table row -- real or functionally."""

        childCount = AXObject.get_child_count(obj)
        if not childCount:
            return False

        if AXObject.get_parent(obj) is None:
            return False

        if AXUtilities.is_table_row(obj):
            return True

        if AXUtilities.is_table_cell_or_header(obj):
            return False

        if not AXUtilities.is_table(AXObject.get_parent(obj)):
            return False

        cells = [x for x in AXObject.iter_children(obj, AXUtilities.is_table_cell_or_header)]
        if len(cells) == childCount:
            return True

        return False

    def realActiveAncestor(self, obj: Atspi.Accessible) -> Any:
        if AXUtilities.is_focused(obj):
            return obj

        def pred(x: Atspi.Accessible) -> bool:
            return AXUtilities.is_table_cell_or_header(x) or AXUtilities.is_list_item(x)

        ancestor = AXObject.find_ancestor(obj, pred)
        if ancestor is not None \
           and not self._script.utilities.isLayoutOnly(AXObject.get_parent(ancestor)):
            obj = ancestor

        return obj

    def realActiveDescendant(self, obj: Atspi.Accessible) -> Optional[Any]:
        """Given an object that should be a child of an object that
        manages its descendants, return the child that is the real
        active descendant carrying useful information.

        Arguments:
        - obj: an object that should be a child of an object that
        manages its descendants.
        """

        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: Atspi.Accessible) -> bool:
            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 isStatusBarDescendant(self, obj: Atspi.Accessible) -> bool:
        if obj is None:
            return False

        return AXObject.find_ancestor(obj, AXUtilities.is_status_bar) is not None

    def statusBarItems(self, obj: Atspi.Accessible) -> list[Any]:
        if not AXUtilities.is_status_bar(obj):
            return []

        start = time.time()
        items = self._script.pointOfReference.get('statusBarItems')
        if not items:

            def include(x: Atspi.Accessible) -> bool:
                return not AXUtilities.is_status_bar(x)

            items = list(filter(include, self.getOnScreenObjects(obj)))
            self._script.pointOfReference['statusBarItems'] = items

        end = time.time()
        msg = f"SCRIPT UTILITIES: Time getting status bar items: {end - start:.4f}"
        debug.printMessage(debug.LEVEL_INFO, msg, True)

        return items

    def infoBar(self, root: Any) -> Optional[Any]:
        return None

    def _topLevelRoles(self) -> Any:
        roles = [Atspi.Role.DIALOG,
                 Atspi.Role.FILE_CHOOSER,
                 Atspi.Role.FRAME,
                 Atspi.Role.WINDOW]
        if self._treatAlertsAsDialogs():
            roles.append(Atspi.Role.ALERT)
        return roles

    def _locusOfFocusIsTopLevelObject(self) -> bool:
        if not cthulhu_state.locusOfFocus:
            return False

        rv = cthulhu_state.locusOfFocus == self.topLevelObject(cthulhu_state.locusOfFocus)
        tokens = ["SCRIPT UTILITIES:", cthulhu_state.locusOfFocus, "is top-level object:", rv]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)
        return rv

    def _findWindowWithDescendant(self, child: Any) -> Optional[Any]:
        """Searches each frame/window/dialog of an application to find the one
        which contains child. This is extremely non-performant and should only
        be used to work around broken accessibility trees where topLevelObject
        fails."""

        app = AXObject.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.printTokens(debug.LEVEL_INFO, tokens, True)
                return window

            tokens = ["SCRIPT UTILITIES:", window, "does not contain", child]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)

        return None

    def _isTopLevelObject(self, obj: Atspi.Accessible) -> Any:
        return AXObject.get_role(obj) in self._topLevelRoles() \
            and AXObject.get_role(AXObject.get_parent(obj)) == Atspi.Role.APPLICATION

    def topLevelObject(self, obj: Atspi.Accessible, useFallbackSearch: bool = False) -> Any:
        """Returns the top-level object (frame, dialog ...) containing obj,
        or None if obj is not inside a top-level object.

        Arguments:
        - obj: the Accessible object
        """

        if self._isTopLevelObject(obj):
            rv = obj
        else:
            rv = AXObject.find_ancestor(obj, self._isTopLevelObject)

        tokens = ["SCRIPT UTILITIES:", rv, "is top-level object for:", obj]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)

        if rv is None and useFallbackSearch:
            msg = "SCRIPT UTILITIES: Attempting to find top-level object via fallback search"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            rv = self._findWindowWithDescendant(obj)

        return rv

    def topLevelObjectIsActiveAndCurrent(self, obj: Optional[Atspi.Accessible] = None) -> bool:
        obj = obj or cthulhu_state.locusOfFocus
        topLevel = self.topLevelObject(obj)
        if not topLevel:
            return False

        AXObject.clear_cache(topLevel)
        if not AXUtilities.is_active(topLevel) or AXUtilities.is_defunct(topLevel):
            return False

        if not self.isSameObject(topLevel, cthulhu_state.activeWindow):
            return False

        return True

    def onSameLine(self, obj1: Atspi.Accessible, obj2: Atspi.Accessible, delta: int = 0) -> bool:
        """Determines if obj1 and obj2 are on the same line."""

        if not AXObject.supports_component(obj1) or not AXObject.supports_component(obj2):
            return False

        try:
            bbox1 = Atspi.Component.get_extents(obj1, Atspi.CoordType.SCREEN)
            bbox2 = Atspi.Component.get_extents(obj2, Atspi.CoordType.SCREEN)
        except Exception:
            return False

        center1 = bbox1.y + bbox1.height / 2
        center2 = bbox2.y + bbox2.height / 2

        return abs(center1 - center2) <= delta

    def pathComparison(self, path1: int, path2: int) -> int:
        """Compares the two paths and returns -1, 0, or 1 to indicate if path1
        is before, the same, or after path2."""

        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 sizeComparison(self, obj1: Atspi.Accessible, obj2: Atspi.Accessible) -> Any:
        width1, height1 = 0, 0
        width2, height2 = 0, 0

        if AXObject.supports_component(obj1):
            try:
                bbox = Atspi.Component.get_extents(obj1, Atspi.CoordType.SCREEN)
                width1, height1 = bbox.width, bbox.height
            except Exception:
                pass

        if AXObject.supports_component(obj2):
            try:
                bbox = Atspi.Component.get_extents(obj2, Atspi.CoordType.SCREEN)
                width2, height2 = bbox.width, bbox.height
            except Exception:
                pass

        return (width1 * height1) - (width2 * height2)

    def spatialComparison(self, obj1: Atspi.Accessible, obj2: Atspi.Accessible) -> Any:
        """Compares the physical locations of obj1 and obj2 and returns -1,
        0, or 1 to indicate if obj1 physically is before, is in the same
        place as, or is after obj2."""

        x1, y1 = 0, 0
        x2, y2 = 0, 0

        if AXObject.supports_component(obj1):
            try:
                bbox = Atspi.Component.get_extents(obj1, Atspi.CoordType.SCREEN)
                x1, y1 = bbox.x, bbox.y
            except Exception:
                pass

        if AXObject.supports_component(obj2):
            try:
                bbox = Atspi.Component.get_extents(obj2, Atspi.CoordType.SCREEN)
                x2, y2 = bbox.x, bbox.y
            except Exception:
                pass

        rv = y1 - y2 or x1 - x2

        # If the objects claim to have the same coordinates, there is either
        # a horrible design crime or we've been given bogus extents. Fall back
        # on the index in the parent. This is seen with GtkListBox items which
        # had been scrolled off-screen.
        if not rv and AXObject.get_parent(obj1) == AXObject.get_parent(obj2):
            rv = AXObject.get_index_in_parent(obj1) - AXObject.get_index_in_parent(obj2)

        rv = max(rv, -1)
        rv = min(rv, 1)

        return rv

    def getTextBoundingBox(self, obj: Atspi.Accessible, start: int, end: int) -> int:
        if not AXObject.supports_text(obj):
            return -1, -1, 0, 0

        try:
            extents = Atspi.Text.get_range_extents(obj, start, end, Atspi.CoordType.SCREEN)
        except Exception:
            tokens = ["SCRIPT UTILITIES: Exception getting range extents of", obj]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return -1, -1, 0, 0

        return extents

    def getBoundingBox(self, obj: Atspi.Accessible) -> int:
        if not AXObject.supports_component(obj):
            return -1, -1, 0, 0

        try:
            extents = Atspi.Component.get_extents(obj, Atspi.CoordType.SCREEN)
        except Exception:
            tokens = ["SCRIPT UTILITIES: Exception getting extents of", obj]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return -1, -1, 0, 0

        return extents.x, extents.y, extents.width, extents.height

    def hasNoSize(self, obj: Atspi.Accessible) -> bool:
        if not obj:
            return False

        if AXUtilities.is_application(obj):
            return False

        if not AXObject.supports_component(obj):
            return True

        try:
            extents = Atspi.Component.get_extents(obj, Atspi.CoordType.SCREEN)
        except Exception:
            tokens = ["SCRIPT UTILITIES: Exception getting extents for", obj]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return True

        return not (extents.width and extents.height)

    def findAllDescendants(self, root: Any, includeIf: Any = None, excludeIf: Any = None) -> Any:
        return AXObject.find_all_descendants(root, includeIf, excludeIf)

    def unrelatedLabels(self, root: Any, onlyShowing: Any = True, minimumWords: Any = 3) -> list[Any]:
        """Returns a list containing all the unrelated (i.e., have no
        relations to anything and are not a fundamental element of a
        more atomic component like a combo box) labels under the given
        root.  Note that the labels must also be showing on the display.

        Arguments:
        - root: the Accessible object to traverse
        - onlyShowing: if True, only return labels with STATE_SHOWING

        Returns a list of unrelated labels under the given root.
        """

        if self._script.spellcheck and self._script.spellcheck.isCheckWindow(root):
            return []

        labelRoles = [Atspi.Role.LABEL, Atspi.Role.STATIC]
        skipRoles = [Atspi.Role.COMBO_BOX,
                     Atspi.Role.LIST_BOX,
                     Atspi.Role.MENU,
                     Atspi.Role.MENU_BAR,
                     Atspi.Role.SCROLL_PANE,
                     Atspi.Role.SPLIT_PANE,
                     Atspi.Role.TABLE,
                     Atspi.Role.TREE,
                     Atspi.Role.TREE_TABLE]

        def _include(x: Atspi.Accessible) -> bool:
            if not (x and AXObject.get_role(x) in labelRoles):
                return False
            if AXUtilitiesRelation.get_relations(x):
                return False
            if onlyShowing and not AXUtilities.is_showing(x):
                return False
            return True

        def _exclude(x: Atspi.Accessible) -> bool:
            if not x or AXObject.get_role(x) in skipRoles:
                return True
            if onlyShowing and not AXUtilities.is_showing(x):
                return True
            return False

        labels = self.findAllDescendants(root, _include, _exclude)

        rootName = AXObject.get_name(root)

        # Eliminate duplicates and things suspected to be labels for widgets
        d = {}
        for label in labels:
            name = AXObject.get_name(label) or self.displayedText(label)
            if name and name in [rootName, AXObject.get_name(AXObject.get_parent(label))]:
                continue
            if len(name.split()) < minimumWords:
                continue
            if rootName.find(name) >= 0:
                continue
            d[name] = label
        labels = list(d.values())

        return sorted(labels, key=functools.cmp_to_key(self.spatialComparison))

    def _treatAlertsAsDialogs(self) -> bool:
        return True

    def unfocusedAlertAndDialogCount(self, obj: Atspi.Accessible) -> Any:
        """If the current application has one or more alert or dialog
        windows and the currently focused window is not an alert or a dialog,
        return a count of the number of alert and dialog windows, otherwise
        return a count of zero.

        Arguments:
        - obj: the Accessible object

        Returns the alert and dialog count.
        """

        roles = [Atspi.Role.DIALOG]
        if self._treatAlertsAsDialogs():
            roles.append(Atspi.Role.ALERT)

        def isDialog(x: Atspi.Accessible) -> bool:
            return AXObject.get_role(x) in roles or self.isFunctionalDialog(x)

        dialogs = [x for x in AXObject.iter_children(AXObject.get_application(obj), isDialog)]
        dialogs.extend([x for x in AXObject.iter_children(self.topLevelObject(obj), isDialog)])

        def isPresentable(x: Atspi.Accessible) -> bool:
            return self.isShowingAndVisible(x) \
                and (AXObject.get_name(x) or AXObject.get_child_count(x))

        def cannotBeActiveWindow(x: Atspi.Accessible) -> bool:
            return not self.canBeActiveWindow(x)

        presentable = list(filter(isPresentable, set(dialogs)))
        unfocused = list(filter(cannotBeActiveWindow, presentable))
        return len(unfocused)

    def uri(self, obj: Atspi.Accessible) -> Any:
        """Return the URI for a given link object.

        Arguments:
        - obj: the Accessible object.
        """

        uri = AXHypertext.get_link_uri(obj)
        return uri or None

    #########################################################################
    #                                                                       #
    # Utilities for working with the accessible text interface              #
    #                                                                       #
    #########################################################################

    def _getSelectionAdapter(self) -> AXDocumentSelection:
        adapter = getattr(self, "_documentSelectionAdapter", None)
        if adapter is None:
            adapter = AXDocumentSelection(self)
            self._documentSelectionAdapter = adapter
        return adapter

    def _getSelectionState(self) -> dict[Any, Any]:
        state = self._script.pointOfReference.get('selectionState')
        if state is None:
            state = {'textSelections': {}}
            self._script.pointOfReference['selectionState'] = state
        state.setdefault('textSelections', {})
        return state

    def _getCachedSelectionData(self, obj: Atspi.Accessible) -> dict[str, Any]:
        if not obj:
            return {'ranges': [], 'start': 0, 'end': 0, 'string': ''}

        cached = self._getSelectionState().get('textSelections', {}).get(hash(obj), {})
        return {
            'ranges': list(cached.get('ranges', [])),
            'start': cached.get('start', 0),
            'end': cached.get('end', 0),
            'string': cached.get('string', ''),
        }

    def _cacheSelectionData(self, obj: Atspi.Accessible, data: dict[str, Any]) -> None:
        textSelections = self._getSelectionState().setdefault('textSelections', {})

        if hash(obj) in textSelections:
            value = textSelections.pop(hash(obj))
            for x in [k for k in textSelections.keys() if textSelections.get(k) == value]:
                textSelections.pop(x)

        textSelections[hash(obj)] = {
            'ranges': list(data.get('ranges', [])),
            'start': data.get('start', 0),
            'end': data.get('end', 0),
            'string': data.get('string', ''),
        }

    def _getSelectionTextFragment(self, obj: Atspi.Accessible, start: int, end: int) -> str:
        text = self.expandEOCs(obj, start, end)
        if text:
            return text

        fallback = AXText.get_substring(obj, start, end)
        if fallback:
            tokens = [
                "SCRIPT UTILITIES: Falling back to raw substring for selection text in",
                obj,
                f"({start}, {end})",
            ]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return fallback

        return ""

    def _getLocalSelectionData(self, obj: Atspi.Accessible) -> dict[str, Any]:
        if not AXObject.supports_text(obj):
            return {'ranges': [], 'start': 0, 'end': 0, 'string': ''}

        textContents = ""
        startOffset = endOffset = 0
        ranges = AXText.get_selected_ranges(obj)
        for start, end in ranges:
            if start == end:
                continue
            selectedText = self._getSelectionTextFragment(obj, start, end)
            if textContents:
                textContents += " "
            textContents += selectedText
            if startOffset == endOffset == 0:
                startOffset = start
            endOffset = end

        return {
            'ranges': ranges,
            'start': startOffset,
            'end': endOffset,
            'string': textContents,
        }

    def _getSelectionData(
        self,
        obj: Atspi.Accessible,
        snapshot: Any = None,
    ) -> dict[str, Any]:
        if not AXObject.supports_text(obj):
            return {'ranges': [], 'start': 0, 'end': 0, 'string': ''}

        snapshot = snapshot if snapshot is not None else self._getSelectionAdapter().refresh_snapshot(obj)
        if snapshot.is_authoritative:
            projected = self._getSelectionAdapter().get_projected_selection(obj, snapshot)
            return {
                'ranges': list(projected.local_ranges),
                'start': projected.start_offset,
                'end': projected.end_offset,
                'string': projected.text,
            }

        return self._getLocalSelectionData(obj)

    @staticmethod
    def _rangesToCharSet(ranges: list[tuple[int, int]]) -> set[int]:
        chars = set()
        for start, end in ranges:
            chars.update(range(start, end))
        return chars

    @staticmethod
    def _charSetToRanges(chars: set[int]) -> list[tuple[int, int]]:
        if not chars:
            return []

        change = sorted(chars)
        ranges = []
        startOffset = change[0]
        endOffset = change[0] + 1
        for offset in change[1:]:
            if offset == endOffset:
                endOffset += 1
                continue
            ranges.append((startOffset, endOffset))
            startOffset = offset
            endOffset = offset + 1

        ranges.append((startOffset, endOffset))
        return ranges

    def adjustTextSelection(self, obj: Atspi.Accessible, offset: int) -> None:
        """Adjusts the end point of a text selection

        Arguments:
        - obj: the Accessible object.
        - offset: the new end point - can be to the left or to the right
          depending on the direction of selection
        """

        if not AXObject.supports_text(obj):
            return

        if self._getSelectionAdapter().adjust_selection(obj, offset):
            return

        selections = AXText.get_selected_ranges(obj)
        if not selections:
            caretOffset = AXText.get_caret_offset(obj)
            startOffset = min(offset, caretOffset)
            endOffset = max(offset, caretOffset)
        else:
            startOffset, endOffset = selections[0]
            if offset < startOffset:
                startOffset = offset
            else:
                endOffset = offset

        AXText.set_selected_text(obj, startOffset, endOffset)

    def findPreviousObject(self, obj: Atspi.Accessible) -> Optional[Any]:
        """Finds the object before this one."""

        if not obj or self.isZombie(obj):
            return None

        flowsFrom = AXUtilitiesRelation.get_flows_from(obj)
        if flowsFrom:
            return flowsFrom[0]

        return AXUtilities.get_previous_object(obj)

    def findNextObject(self, obj: Atspi.Accessible) -> Optional[Any]:
        """Finds the object after this one."""

        if not obj or self.isZombie(obj):
            return None

        flowsTo = AXUtilitiesRelation.get_flows_to(obj)
        if flowsTo:
            return flowsTo[0]

        return AXUtilities.get_next_object(obj)

    def allSelectedText(self, obj: Atspi.Accessible) -> Any:
        """Get all the text applicable text selections for the given object.
        including any previous or next text objects that also have
        selected text and add in their text contents.

        Arguments:
        - obj: the text object to start extracting the selected text from.

        Returns: all the selected text contents plus the start and end
        offsets within the text for the given object.
        """

        snapshot = self._getSelectionAdapter().refresh_snapshot(obj)
        if snapshot.is_authoritative:
            return self._getSelectionAdapter().get_all_selected_text(obj, snapshot)

        textContents, startOffset, endOffset = self.selectedText(obj)
        if textContents and self._script.pointOfReference.get('entireDocumentSelected'):
            return textContents, startOffset, endOffset

        if self.isSpreadSheetCell(obj):
            return textContents, startOffset, endOffset

        prevObj = self.findPreviousObject(obj)
        while prevObj:
            if self.queryNonEmptyText(prevObj):
                selection, start, end = self.selectedText(prevObj)
                if not selection:
                    break
                textContents = f"{selection} {textContents}"
            prevObj = self.findPreviousObject(prevObj)

        nextObj = self.findNextObject(obj)
        while nextObj:
            if self.queryNonEmptyText(nextObj):
                selection, start, end = self.selectedText(nextObj)
                if not selection:
                    break
                textContents = f"{textContents} {selection}"
            nextObj = self.findNextObject(nextObj)

        return textContents, startOffset, endOffset

    def allTextSelections(self, obj: Atspi.Accessible) -> Any:
        """Get a list of text selections in the given accessible object,
        equivalent to getNSelections()*texti.getSelection()

        Arguments:
        - obj: An accessible.

        Returns list of start and end offsets for multiple selections, or an
        empty list if nothing is selected or if the accessible does not support
        the text interface.
        """

        snapshot = self._getSelectionAdapter().refresh_snapshot(obj)
        if snapshot.is_authoritative:
            return self._getSelectionAdapter().get_projected_selection(obj, snapshot).local_ranges

        return AXText.get_selected_ranges(obj)

    def getChildAtOffset(self, obj: Atspi.Accessible, offset: Any) -> Optional[Any]:
        child = AXHypertext.get_child_at_offset(obj, offset)
        if not child:
            return None

        tokens = ["SCRIPT UTILITIES: Hyperlink object at offset", offset, "for", obj, "is", child]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)
        return child

    def findChildAtOffset(self, obj: Atspi.Accessible, offset: int) -> Any:
        """Attempts to correct for off-by-one brokenness in hypertext implementations.
        
        We're seeing off-by-one errors in (at least) Chromium where the text
        at a given offset is an embedded object character, but retrieving the
        child at that offset returns None.
        
        This method handles this condition by checking for the child at the 
        previous and next offsets. If there's a child, and its offset in parent 
        matches the desired offset, return that child.
        """
        
        if child := self.getChildAtOffset(obj, offset):
            return child

        if child_before := self.getChildAtOffset(obj, offset - 1):
            offset_in_parent = self.characterOffsetInParent(child_before)
            if offset_in_parent == offset:
                tokens = [f"SCRIPT UTILITIES: Corrected child at offset {offset} in", obj, "is",
                          child_before, f"at offset {offset - 1}"]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
                return child_before

        if child_after := self.getChildAtOffset(obj, offset + 1):
            offset_in_parent = self.characterOffsetInParent(child_after)
            if offset_in_parent == offset:
                tokens = [f"SCRIPT UTILITIES: Corrected child at offset {offset} in", obj, "is",
                          child_after, f"at offset {offset + 1}"]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
                return child_after

        return None

    def characterOffsetInParent(self, obj: Atspi.Accessible) -> Any:
        """Returns the character offset of the embedded object
        character for this object in its parent's accessible text.

        Arguments:
        - obj: an Accessible that should implement the accessible
          hyperlink specialization.

        Returns an integer representing the character offset of the
        embedded object character for this hyperlink in its parent's
        accessible text, or -1 something was amuck.
        """

        offset = -1
        # We need to make sure that this is an embedded object in
        # some accessible text (as opposed to an imagemap link).
        #
        parent = AXObject.get_parent(obj)
        if AXObject.supports_text(parent):
            offset = AXHypertext.get_link_start_offset(obj)
        else:
            tokens = ["SCRIPT UTILITIES: Exception getting startIndex for",
                      obj, "in parent", parent]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)

        tokens = ["SCRIPT UTILITIES: startIndex of", obj, f"is {offset}"]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)
        return offset

    def clearTextSelection(self, obj: Atspi.Accessible) -> None:
        """Clears the text selection if the object supports it.

        Arguments:
        - obj: the Accessible object.
        """

        if not AXObject.supports_text(obj):
            return

        if self._getSelectionAdapter().clear_selection(obj):
            self._script.pointOfReference['entireDocumentSelected'] = False
            self._getSelectionState()['textSelections'] = {}
            return

        for i in range(AXText._get_n_selections(obj)):
            AXText._remove_selection(obj, i)

    def containsOnlyEOCs(self, obj: Atspi.Accessible) -> Any:
        string = AXText.get_all_text(obj)
        return string and not re.search(r"[^\ufffc]", string)

    def expandEOCs(self, obj: Atspi.Accessible, startOffset: Any = 0, endOffset: Any = -1) -> str:
        """Expands the current object replacing EMBEDDED_OBJECT_CHARACTERS
        with their text.

        Arguments
        - obj: the object whose text should be expanded
        - startOffset: the offset of the first character to be included
        - endOffset: the offset of the last character to be included

        Returns the fully expanded text for the object.
        """

        try:
            string = self.substring(obj, startOffset, endOffset)
        except Exception:
            return ""

        if self.EMBEDDED_OBJECT_CHARACTER not in string:
            return string

        blockRoles = [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]

        toBuild = list(string)
        for i, char in enumerate(toBuild):
            if char == self.EMBEDDED_OBJECT_CHARACTER:
                child = self.findChildAtOffset(obj, i + startOffset)
                result = self.expandEOCs(child)
                if child and AXObject.get_role(child) in blockRoles:
                    result += " "
                toBuild[i] = result

        return "".join(toBuild)

    def isWordMisspelled(self, obj: Atspi.Accessible, offset: Any) -> bool:
        """Identifies if the current word is flagged as misspelled by the
        application. Different applications and toolkits flag misspelled
        words differently. Thus each script will likely need to implement
        its own version of this method.

        Arguments:
        - obj: An accessible which implements the accessible text interface.
        - offset: Offset in the accessible's text for which to retrieve the
          attributes.

        Returns True if the word is flagged as misspelled.
        """

        attributes, start, end  = self.textAttributes(obj, offset, True)
        if attributes.get("invalid") == "spelling":
            return True
        if attributes.get("text-spelling") == "misspelled":
            return True
        if attributes.get("underline") in ["error", "spelling"]:
            return True

        return False

    def getError(self, obj: Atspi.Accessible) -> Any:
        return AXUtilities.is_invalid_entry(obj)

    def getErrorMessage(self, obj: Atspi.Accessible) -> str:
        return ""

    def isErrorMessage(self, obj: Atspi.Accessible) -> bool:
        return False

    def getCharacterAtOffset(self, obj: Atspi.Accessible, offset: int = None) -> Any:
        if AXObject.supports_text(obj):
            if offset is None:
                offset = AXText.get_caret_offset(obj)
            return AXText.get_substring(obj, offset, offset + 1)

        return ""

    def queryNonEmptyText(self, obj: Atspi.Accessible) -> Optional[Any]:
        """Get the text interface associated with an object, if it is
        non-empty.

        Arguments:
        - obj: an accessible object
        """

        if not AXObject.supports_text(obj):
            return None

        charCount = AXText.get_character_count(obj)
        if charCount:
            return obj

        return None

    def deletedText(self, event: Any) -> Any:
        return event.any_data

    def insertedText(self, event: Any) -> Any:
        if event.any_data:
            return event.any_data

        msg = "SCRIPT UTILITIES: Broken text insertion event"
        debug.printMessage(debug.LEVEL_INFO, msg, True)

        fallbackText = self._getFallbackInsertedText(event)
        if fallbackText:
            return fallbackText

        msg = "FAIL: Unable to correct broken text insertion event"
        debug.printMessage(debug.LEVEL_INFO, msg, True)
        return ""

    def _getFallbackInsertedText(self, event: Any) -> str:
        if not AXUtilities.is_password_text(event.source):
            return ""

        text = self.queryNonEmptyText(event.source)
        if not text:
            return ""

        string = AXText.get_all_text(event.source)
        if not string:
            return ""

        tokens = ["HACK: Returning last char in '", string, "'"]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)
        return string[-1]

    def selectedText(self, obj: Atspi.Accessible) -> Any:
        """Get the text selection for the given object.

        Arguments:
        - obj: the text object to extract the selected text from.

        Returns: the selected text contents plus the start and end
        offsets within the text.
        """

        selectionData = self._getSelectionData(obj)
        return [selectionData['string'], selectionData['start'], selectionData['end']]

    def getCaretContext(self) -> Any:
        obj = cthulhu_state.locusOfFocus
        if AXObject.supports_text(obj):
            offset = AXText.get_caret_offset(obj)
        else:
            offset = -1

        return obj, offset

    def getFirstCaretPosition(self, obj: Atspi.Accessible) -> Any:
        return obj, 0

    def setCaretPosition(self, obj: Atspi.Accessible, offset: int, documentFrame: Any = None) -> Any:
        cthulhu.setLocusOfFocus(None, obj, False)
        self.setCaretOffset(obj, offset)

    def setCaretOffset(self, obj: Atspi.Accessible, offset: int) -> Any:
        """Set the caret offset on a given accessible. Similar to
        Accessible.setCaretOffset()

        Arguments:
        - obj: Given accessible object.
        - offset: Offset to hich to set the caret.
        """
        AXText.set_caret_offset(obj, offset)

    def substring(self, obj: Atspi.Accessible, startOffset: Any, endOffset: Any) -> Any:
        """Returns the substring of the given object's text specialization.

        Arguments:
        - obj: an accessible supporting the accessible text specialization
        - startOffset: the starting character position
        - endOffset: the ending character position. Note that an end offset
          of -1 means the last character
        """

        return AXText.get_substring(obj, startOffset, endOffset)

    def getAppNameForAttribute(self, attribName: Any) -> Any:
        """Converts the given Atk attribute name into the application's
        equivalent. This is necessary because an application or toolkit
        (e.g. Gecko) might invent entirely new names for the same text
        attributes.

        Arguments:
        - attribName: The name of the text attribute

        Returns the application's equivalent name if found or attribName
        otherwise.
        """

        for key, value in self._script.attributeNamesDict.items():
            if value == attribName:
                return key

        return attribName

    def getAtkNameForAttribute(self, attribName: Any) -> Any:
        """Converts the given attribute name into the Atk equivalent. This
        is necessary because an application or toolkit (e.g. Gecko) might
        invent entirely new names for the same attributes.

        Arguments:
        - attribName: The name of the text attribute

        Returns the Atk equivalent name if found or attribName otherwise.
        """

        return self._script.attributeNamesDict.get(attribName, attribName)

    def getAllTextAttributesForObject(self, obj: Atspi.Accessible, startOffset: Any = 0, endOffset: Any = -1) -> list[Any]:
        """Returns a list of (start, end, attrsDict) tuples for obj."""
        if not AXObject.supports_text(obj):
            return []

        if endOffset == -1:
            endOffset = AXText.get_character_count(obj)

        tokens = ["SCRIPT UTILITIES: Getting text attributes for", obj,
                  f"chars: {startOffset}-{endOffset}"]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)
        startTime = time.time()

        rv = []
        offset = startOffset
        while offset < endOffset:
            attrs, start, end = AXText.get_text_attributes_at_offset(obj, offset)
            tokens = [f"SCRIPT UTILITIES: At {offset}:", attrs, f"({start}, {end})"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            rv.append((max(start, offset), end, attrs))
            offset = max(end, offset + 1)

        endTime = time.time()
        msg = f"SCRIPT UTILITIES: {len(rv)} attribute ranges found in {endTime - startTime:.4f}s"
        debug.printMessage(debug.LEVEL_INFO, msg, True)
        return rv

    def textAttributes(self, acc: Any, offset: Any = None, get_defaults: Any = False) -> dict[Any, Any]:
        """Get the text attributes run for a given offset in a given accessible

        Arguments:
        - acc: An accessible.
        - offset: Offset in the accessible's text for which to retrieve the
        attributes.
        - get_defaults: Get the default attributes as well as the unique ones.
        Default is True

        Returns a dictionary of attributes, a start offset where the attributes
        begin, and an end offset. Returns ({}, 0, 0) if the accessible does not
        supprt the text attribute.
        """

        if not AXObject.supports_text(acc):
            return {}, 0, 0

        if offset is None:
            offset = AXText.get_caret_offset(acc)

        attrs, start, end = AXText.get_text_attributes_at_offset(acc, offset)
        return attrs, min(start, offset), max(end, offset + 1)

    def localizeTextAttribute(self, key: Any, value: Any) -> Any:
        if key == "weight" and (value == "bold" or int(value) > 400):
            return messages.BOLD

        if key.endswith("spelling") or value == "spelling":
            return messages.MISSPELLED

        localizedKey = text_attribute_names.getTextAttributeName(key, self._script)

        if key == "family-name":
            localizedValue = value.split(",")[0].strip().strip('"')
        elif value and value.endswith("px"):
            value = value.split("px")[0]
            if locale.localeconv()["decimal_point"] in value:
                localizedValue = messages.pixelCount(float(value))
            else:
                localizedValue = messages.pixelCount(int(value))
        elif key.endswith("color"):
            r, g, b = self.rgbFromString(value)
            if settings.useColorNames:
                localizedValue = colornames.rgbToName(r, g, b)
            else:
                localizedValue = "%i %i %i" % (r, g, b)
        else:
            localizedValue = text_attribute_names.getTextAttributeName(value, self._script)

        return f"{localizedKey}: {localizedValue}"

    def splitSubstringByLanguage(self, obj: Atspi.Accessible, start: int, end: int) -> Any:
        """Returns a list of (start, end, string, language, dialect) tuples."""

        rv = []
        allSubstrings = self.getLanguageAndDialectFromTextAttributes(obj, start, end)
        for startOffset, endOffset, language, dialect in allSubstrings:
            if start >= endOffset:
                continue
            if end <= startOffset:
                break
            startOffset = max(start, startOffset)
            endOffset = min(end, endOffset)
            string = self.substring(obj, startOffset, endOffset)
            rv.append([startOffset, endOffset, string, language, dialect])

        return rv

    def getLanguageAndDialectForSubstring(self, obj: Atspi.Accessible, start: int, end: int) -> Any:
        """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."""

        allSubstrings = self.getLanguageAndDialectFromTextAttributes(obj, start, end)
        for startOffset, endOffset, language, dialect in allSubstrings:
            if startOffset <= start and endOffset >= end:
                return language, dialect

        return "", ""

    def getLanguageAndDialectFromTextAttributes(self, obj: Atspi.Accessible, startOffset: Any = 0, endOffset: Any = -1) -> Any:
        """Returns a list of (start, end, language, dialect) tuples for obj
        based on what is exposed via text attributes."""

        rv = []
        attributeSet = self.getAllTextAttributesForObject(obj, startOffset, endOffset)
        lastLanguage = lastDialect = ""
        for (start, end, attrs) in attributeSet:
            language = attrs.get("language", "")
            dialect = ""
            if "-" in language:
                language, dialect = language.split("-", 1)
            if rv and lastLanguage == language and lastDialect == dialect:
                rv[-1] = rv[-1][0], end, language, dialect
            else:
                rv.append((start, end, language, dialect))
            lastLanguage, lastDialect = language, dialect

        return rv

    def willEchoCharacter(self, event: Any) -> bool:
        """Given a keyboard event containing an alphanumeric key,
        determine if the script is likely to echo it as a character.
        """

        if not cthulhu_state.locusOfFocus or not settings.enableEchoByCharacter:
            return False

        if len(event.event_string) != 1 \
           or event.modifiers & keybindings.CTHULHU_CTRL_MODIFIER_MASK:
            return False

        if AXUtilities.is_password_text(cthulhu_state.locusOfFocus):
            return False

        if AXUtilities.is_editable(cthulhu_state.locusOfFocus):
            return True

        return False

    #########################################################################
    #                                                                       #
    # Miscellaneous Utilities                                               #
    #                                                                       #
    #########################################################################

    def _addRepeatSegment(self, segment: Any, line: Any, respectPunctuation: Any = True) -> Any:
        """Add in the latest line segment, adjusting for repeat characters
        and punctuation.

        Arguments:
        - segment: the segment of repeated characters.
        - line: the current built-up line to characters to speak.
        - respectPunctuation: if False, ignore punctuation level.

        Returns: the current built-up line plus the new segment, after
        adjusting for repeat character counts and punctuation.
        """

        from . import punctuation_settings

        style = settings.verbalizePunctuationStyle
        isPunctChar = True
        try:
            level, action = punctuation_settings.getPunctuationInfo(segment[0])
        except Exception:
            isPunctChar = False
        count = len(segment)
        if (count >= settings.repeatCharacterLimit) \
           and (segment[0] not in self._script.whitespace):
            if (not respectPunctuation) \
               or (isPunctChar and (style <= level)):
                repeatChar = chnames.getCharacterName(segment[0])
                repeatSegment = messages.repeatedCharCount(repeatChar, count)
                line = f"{line} {repeatSegment}"
            else:
                line += segment
        else:
            line += segment

        return line

    def shouldVerbalizeAllPunctuation(self, obj: Atspi.Accessible) -> bool:
        if not (self.isCode(obj) or self.isCodeDescendant(obj)):
            return False

        # If the user has set their punctuation level to All, then the synthesizer will
        # do the work for us. If the user has set their punctuation level to None, then
        # they really don't want punctuation and we mustn't override that.
        style = cthulhu.cthulhuApp.settingsManager.getSetting("verbalizePunctuationStyle")
        if style in [settings.PUNCTUATION_STYLE_ALL, settings.PUNCTUATION_STYLE_NONE]:
            return False

        return True

    def verbalizeAllPunctuation(self, string: Any) -> Any:
        result = string
        for symbol in set(re.findall(self.PUNCTUATION, result)):
            charName = f" {chnames.getCharacterName(symbol)} "
            result = re.sub(r"\%s" % symbol, charName, result)

        return result

    def adjustForLinks(self, obj: Atspi.Accessible, line: Any, startOffset: Any) -> Any:
        """Adjust line to include the word "link" after any hypertext links.

        Arguments:
        - obj: the accessible object that this line came from.
        - line: the string to adjust for links.
        - startOffset: the caret offset at the start of the line.

        Returns: a new line adjusted to add the speaking of "link" after
        text which is also a link.
        """

        from . import punctuation_settings

        endOffset = startOffset + len(line)
        links = AXHypertext.get_all_links(obj) if AXObject.supports_hypertext(obj) else []

        adjustedLine = list(line)
        for link in reversed(links):
            start_index = AXHypertext.get_link_start_offset(link)
            end_index = AXHypertext.get_link_end_offset(link)
            if start_index < 0 or end_index < 0:
                continue

            # We only care about links in the string, line:
            #
            if startOffset < end_index <= endOffset:
                index = end_index - startOffset
            elif startOffset <= start_index < endOffset:
                index = len(line)
                if end_index < endOffset:
                    index -= 1
            else:
                continue

            linkString = " " + messages.LINK

            # If the link was not followed by a whitespace or punctuation
            # character, then add in a space to make it more presentable.
            #
            nextChar = ""
            if index < len(line):
                nextChar = adjustedLine[index]
            if not (nextChar in self._script.whitespace \
                    or punctuation_settings.getPunctuationInfo(nextChar)):
                linkString += " "
            adjustedLine[index:index] = linkString

        return "".join(adjustedLine)

    def getLinkIndicatorPresentation(self, obj: Atspi.Accessible, line: Any, startOffset: Any):
        """Return the spoken text and any link icons for a line fragment."""

        spoken = self.adjustForLinks(obj, line, startOffset)
        roleSoundPresentation = cthulhu.cthulhuApp.settingsManager.getSetting('roleSoundPresentation')
        if roleSoundPresentation == settings.ROLE_SOUND_PRESENTATION_SPEECH_ONLY:
            return spoken, []

        if not cthulhu.cthulhuApp.settingsManager.getSetting('enableSound'):
            return spoken, []

        if not AXObject.supports_hypertext(obj):
            return spoken, []

        from . import sound_theme_manager

        manager = sound_theme_manager.getManager()
        if manager is None:
            return spoken, []

        endOffset = startOffset + len(line)
        icons = []
        missingIcon = False
        for link in AXHypertext.get_all_links(obj):
            start_index = AXHypertext.get_link_start_offset(link)
            end_index = AXHypertext.get_link_end_offset(link)
            if start_index < 0 or end_index < 0:
                continue

            if startOffset < end_index <= endOffset:
                pass
            elif startOffset <= start_index < endOffset:
                pass
            else:
                continue

            icon = manager.getLinkSoundIcon(visited=AXUtilities.is_visited(link))
            if icon:
                rect = None
                if cthulhu.cthulhuApp.settingsManager.getSetting('spatializeObjectSounds'):
                    rect = AXText.get_range_rect(obj, start_index, end_index)
                self.spatializeSoundIcon(icon, link, rect)
                icons.append(icon)
            else:
                missingIcon = True

        if not icons:
            return spoken, []

        if roleSoundPresentation == settings.ROLE_SOUND_PRESENTATION_SOUND_ONLY and not missingIcon:
            return line, icons

        return spoken, icons

    def spatializeSoundIcon(
        self,
        icon: Any,
        obj: Optional[Atspi.Accessible],
        rect: Optional[Any] = None,
    ) -> Any:
        """Attach an optional stereo pan value to an icon based on object position."""

        if not cthulhu.cthulhuApp.settingsManager.getSetting('spatializeObjectSounds'):
            return icon

        pan = self.getSoundPanForObject(obj, rect)
        if pan is None:
            return icon

        try:
            icon.pan = pan
        except Exception:
            pass
        return icon

    def getSoundPanForObject(
        self,
        obj: Optional[Atspi.Accessible],
        rect: Optional[Any] = None,
    ) -> Optional[float]:
        """Return a conservative left/right pan value for obj, or None if unknown."""

        if obj is None and rect is None:
            return None

        rect = rect or AXComponent.get_rect(obj)
        width = getattr(rect, "width", 0)
        if width <= 0:
            return None

        frame, dialog = self.frameAndDialog(obj)
        reference = dialog or frame or self.activeWindow()
        if reference is None:
            return None

        referenceRect = AXComponent.get_rect(reference)
        referenceWidth = getattr(referenceRect, "width", 0)
        if referenceWidth <= 0:
            return None

        centerX = getattr(rect, "x", 0) + width / 2
        left = getattr(referenceRect, "x", 0)
        position = (centerX - left) / referenceWidth
        position = max(0.0, min(1.0, position))

        edge = 0.05
        if position <= edge:
            return -1.0
        if position >= 1.0 - edge:
            return 1.0

        return ((position - edge) / (1.0 - (2 * edge)) * 2.0) - 1.0

    def _processMultiCaseString(self, string: Any) -> Any:
        return re.sub(r'(?<=[a-z])(?=[A-Z])', ' ', string)

    def _convertWordToDigits(self, word: Any) -> Any:
        if not word.isnumeric():
            return word

        return ' '.join(list(word))

    def adjustForPronunciation(self, line: Any) -> Any:
        """Adjust the line to replace words in the pronunciation dictionary,
        with what those words actually sound like.

        Arguments:
        - line: the string to adjust for words in the pronunciation dictionary.

        Returns: a new line adjusted for words found in the pronunciation
        dictionary.
        """

        if settings.speakMultiCaseStringsAsWords:
            line = self._processMultiCaseString(line)

        if self.speakMathSymbolNames():
            line = mathsymbols.adjustForSpeech(line)

        if settings.speakNumbersAsDigits:
            words = self.WORDS_RE.split(line)
            line = ''.join(map(self._convertWordToDigits, words))

        if len(line) == 1 and not self._script.inSayAll():
            charname = chnames.getCharacterName(line)
            if charname != line:
                return charname

        if not settings.usePronunciationDictionary:
            return line

        newLine = ""
        words = self.WORDS_RE.split(line)
        newLine = ''.join(map(pronunciation_dict.getPronunciation, words))

        if settings.speakMultiCaseStringsAsWords:
            newLine = self._processMultiCaseString(newLine)

        return newLine

    def adjustForRepeats(self, line: Any) -> Any:
        """Adjust line to include repeat character counts. As some people
        will want this and others might not, there is a setting in
        settings.py that determines whether this functionality is enabled.

        repeatCharacterLimit = <n>

        If <n> is 0, then there would be no repeat characters.
        Otherwise <n> would be the number of same characters (or more)
        in a row that cause the repeat character count output.
        If the value is set to 1, 2 or 3 then it's treated as if it was
        zero. In other words, no repeat character count is given.

        Arguments:
        - line: the string to adjust for repeat character counts.

        Returns: a new line adjusted for repeat character counts (if enabled).
        """

        if (len(line) < 4) or (settings.repeatCharacterLimit < 4):
            return line

        newLine = ''
        segment = lastChar = line[0]

        multipleChars = False
        for i in range(1, len(line)):
            if line[i] == lastChar:
                segment += line[i]
            else:
                multipleChars = True
                newLine = self._addRepeatSegment(segment, newLine)
                segment = line[i]

            lastChar = line[i]

        return self._addRepeatSegment(segment, newLine, multipleChars)

    def adjustForDigits(self, string: Any) -> Any:
        """Adjusts the string to convert digit-like text, such as subscript
        and superscript numbers, into actual digits.

        Arguments:
        - string: the string to be adjusted

        Returns: a new string which contains actual digits.
        """

        subscripted = set(re.findall(self.SUBSCRIPTS_RE, string))
        superscripted = set(re.findall(self.SUPERSCRIPTS_RE, string))

        for number in superscripted:
            new = [str(self.SUPERSCRIPT_DIGITS.index(d)) for d in number]
            newString = messages.DIGITS_SUPERSCRIPT % "".join(new)
            string = re.sub(number, newString, string)

        for number in subscripted:
            new = [str(self.SUBSCRIPT_DIGITS.index(d)) for d in number]
            newString = messages.DIGITS_SUBSCRIPT % "".join(new)
            string = re.sub(number, newString, string)

        return string

    # Hardcoded indentation audio parameters
    _INDENTATION_TAB_WIDTH = 4
    _INDENTATION_BASE_FREQUENCY = 200
    _INDENTATION_STEP_FREQUENCY = 30
    _INDENTATION_MAX_FREQUENCY = 2700
    _INDENTATION_DURATION = 0.15
    _INDENTATION_VOLUME = 0.7

    def _get_indentation_key(self, obj: Atspi.Accessible) -> Any:
        if obj is None:
            return "global"

        try:
            return id(obj)
        except Exception:
            return str(obj)

    def _extract_indentation(self, line: Any) -> str:
        if not line:
            return ""

        line = line.replace("\u00a0", " ")
        end = re.search("[^ \t]", line)
        if end:
            return line[:end.start()]

        return line

    def _get_indentation_columns(self, indentation: Any, tabWidth: Any) -> Any:
        columns = 0
        tabWidth = max(1, tabWidth)
        for char in indentation:
            if char == "\t":
                columns += tabWidth - (columns % tabWidth)
            else:
                columns += 1

        return columns

    def _get_indentation_data(self, line: Any) -> Any:
        indentation = self._extract_indentation(line)
        columns = self._get_indentation_columns(indentation, self._INDENTATION_TAB_WIDTH)
        return {
            "indentation": indentation,
            "columns": columns,
        }

    def _remember_indentation(self, obj: Atspi.Accessible, data: Any) -> bool:
        key = self._get_indentation_key(obj)
        self._lastIndentationData[key] = {
            "signature": data["indentation"],
            "columns": data["columns"],
        }

    def _indentation_has_changed(self, obj: Atspi.Accessible, data: Any) -> bool:
        key = self._get_indentation_key(obj)
        previous = self._lastIndentationData.get(key)
        self._remember_indentation(obj, data)
        if previous is None:
            return True, 0

        changed = previous.get("signature") != data["indentation"]
        previousColumns = previous.get("columns", 0)
        return changed, previousColumns

    def _indentation_enabled(self) -> bool:
        if cthulhu.cthulhuApp.settingsManager.getSetting('onlySpeakDisplayedText'):
            return False
        return cthulhu.cthulhuApp.settingsManager.getSetting('enableIndentation')

    def _indentation_speech_enabled(self) -> bool:
        if not self._indentation_enabled():
            return False

        presentationMode = cthulhu.cthulhuApp.settingsManager.getSetting('indentationPresentationMode') \
            or settings.indentationPresentationMode
        return presentationMode in (
            settings.INDENTATION_PRESENTATION_SPEECH,
            settings.INDENTATION_PRESENTATION_SPEECH_AND_BEEPS,
        )

    def _indentation_beeps_enabled(self) -> bool:
        if not self._indentation_enabled():
            return False

        presentationMode = cthulhu.cthulhuApp.settingsManager.getSetting('indentationPresentationMode') \
            or settings.indentationPresentationMode
        return presentationMode in (
            settings.INDENTATION_PRESENTATION_BEEPS,
            settings.INDENTATION_PRESENTATION_SPEECH_AND_BEEPS,
        )

    def _play_indentation_tone(self, columns: Any, previousColumns: Any) -> None:
        """Play an audio tone indicating indentation level."""
        if not _SOUND_AVAILABLE:
            return

        try:
            player = sound.getPlayer()
            if not player:
                return

            frequency = min(
                self._INDENTATION_BASE_FREQUENCY + (columns * self._INDENTATION_STEP_FREQUENCY),
                self._INDENTATION_MAX_FREQUENCY
            )

            # Add directional cue
            if columns > previousColumns:
                frequency += 50
            elif columns < previousColumns:
                frequency = max(frequency - 50, 100)

            tone = Tone(
                duration=self._INDENTATION_DURATION,
                frequency=frequency,
                volumeMultiplier=self._INDENTATION_VOLUME,
                wave=Tone.SINE_WAVE
            )
            player.play(tone, interrupt=False)
            debug.printMessage(debug.LEVEL_INFO,
                f"INDENTATION: played tone freq={frequency}Hz columns={columns}", True)
        except Exception as e:
            debug.printMessage(debug.LEVEL_INFO,
                f"INDENTATION: error playing tone: {e}", True)

    def get_indentation_presentation(self, line: Any, obj: Optional[Atspi.Accessible] = None) -> str:
        data = self._get_indentation_data(line)
        hasIndentation = bool(data["indentation"])
        presentationMode = cthulhu.cthulhuApp.settingsManager.getSetting('indentationPresentationMode') \
            or settings.indentationPresentationMode
        indentDebug = data["indentation"].replace("\t", "\\t").replace(" ", ".")

        if not self._indentation_enabled():
            self._remember_indentation(obj, data)
            msg = (
                f"INDENTATION: disabled mode={presentationMode} "
                f"columns={data['columns']} indent='{indentDebug}'"
            )
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return "", hasIndentation

        changed, previousColumns = self._indentation_has_changed(obj, data)

        if not changed:
            msg = (
                f"INDENTATION: unchanged mode={presentationMode} "
                f"columns={data['columns']} indent='{indentDebug}'"
            )
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return "", hasIndentation

        # Play beep if beeps are enabled
        if self._indentation_beeps_enabled():
            self._play_indentation_tone(data["columns"], previousColumns)

        # Return speech description if speech is enabled
        if self._indentation_speech_enabled():
            description = messages.indentationColumnCount(data["columns"])
            msg = (
                f"INDENTATION: speaking '{description}' mode={presentationMode} "
                f"columns={data['columns']} indent='{indentDebug}'"
            )
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return description, hasIndentation

        return "", hasIndentation

    def should_strip_indentation(self, line: Any) -> bool:
        if not self._indentation_enabled():
            return False

        data = self._get_indentation_data(line)
        return bool(data["indentation"])

    def indentationDescription(self, line: Any, obj: Optional[Atspi.Accessible] = None) -> Any:
        description, _hasIndentation = self.get_indentation_presentation(line, obj=obj)
        return description

    def absoluteMouseCoordinates(self) -> Any:
        """Gets the absolute position of the mouse pointer."""

        from gi.repository import Gtk
        rootWindow = Gtk.Window().get_screen().get_root_window()
        window, x, y, modifiers = rootWindow.get_pointer()

        return x, y

    def appendString(self, text: Any, newText: Any, delimiter: Any = " ") -> Any:
        """Appends the newText to the given text with the delimiter in between
        and returns the new string.  Edge cases, such as no initial text or
        no newText, are handled gracefully."""

        if not newText:
            return text
        if not text:
            return newText

        return text + delimiter + newText

    def treatAsDuplicateEvent(self, event1: Any, event2: Any) -> bool:
        if not (event1 and event2):
            return False

        # The goal is to find event spam so we can ignore the event.
        if event1 == event2:
            return False

        return event1.source == event2.source \
            and event1.type == event2.type \
            and event1.detail1 == event2.detail1 \
            and event1.detail2 == event2.detail2 \
            and event1.any_data == event2.any_data

    def isAutoTextEvent(self, event: Any) -> bool:
        """Returns True if event is associated with text being autocompleted
        or autoinserted or autocorrected or autosomethingelsed.

        Arguments:
        - event: the accessible event being examined
        """

        if event.type.startswith("object:text-changed:insert"):
            if not event.any_data or not event.source:
                return False

            if not AXUtilities.is_editable(event.source):
                return False
            if not AXUtilities.is_showing(event.source):
                return False
            if AXUtilities.is_focusable(event.source):
                AXObject.clear_cache(event.source)
                if not AXUtilities.is_focused(event.source):
                    return False

            lastKey, mods = self.lastKeyAndModifiers()
            if lastKey == "Tab" and event.any_data != "\t":
                return True
            if lastKey == "Return" and event.any_data != "\n":
                return True
            if lastKey in ["Up", "Down", "Page_Up", "Page_Down"]:
                return self.isEditableDescendantOfComboBox(event.source)
            if not self.lastInputEventWasPrintableKey():
                return False

            string = AXText.get_all_text(event.source)
            if string.endswith(event.any_data):
                selection, start, end = self.selectedText(event.source)
                if selection == event.any_data:
                    return True
                if string == event.any_data and string.endswith(selection):
                    beginning = string[:string.find(selection)]
                    return beginning.lower().endswith(lastKey.lower())

        return False

    def isSentenceDelimiter(self, currentChar: Any, previousChar: Any) -> bool:
        """Returns True if we are positioned at the end of a sentence.
        This is determined by checking if the current character is a
        white space character and the previous character is one of the
        normal end-of-sentence punctuation characters.

        Arguments:
        - currentChar:  the current character
        - previousChar: the previous character

        Returns True if the given character is a sentence delimiter.
        """

        if currentChar == '\r' or currentChar == '\n':
            return True

        return currentChar in self._script.whitespace \
               and previousChar in '!.?:;'

    def isWordDelimiter(self, character: Any) -> Any:
        """Returns True if the given character is a word delimiter.

        Arguments:
        - character: the character in question

        Returns True if the given character is a word delimiter.
        """

        return character in self._script.whitespace \
               or character in r'!*+,-./:;<=>?@[\]^_{|}' \
               or character == self._script.NO_BREAK_SPACE_CHARACTER

    def intersectingRegion(self, obj1: Any, obj2: Any, coordType: Any = None) -> int:
        """Returns the extents of the intersection of obj1 and obj2."""

        if coordType is None:
            coordType = Atspi.CoordType.SCREEN

        if not AXObject.supports_component(obj1) or not AXObject.supports_component(obj2):
            return 0, 0, 0, 0

        try:
            extents1 = Atspi.Component.get_extents(obj1, coordType)
            extents2 = Atspi.Component.get_extents(obj2, coordType)
        except Exception:
            return 0, 0, 0, 0

        return self.intersection(extents1, extents2)

    def intersection(self, extents1: Any, extents2: Any) -> int:
        def _toTuple(extents: Any) -> tuple[int, int, int, int]:
            if extents is None:
                return 0, 0, 0, 0
            if hasattr(extents, "x") and hasattr(extents, "y") \
               and hasattr(extents, "width") and hasattr(extents, "height"):
                return extents.x, extents.y, extents.width, extents.height
            return extents

        extents1 = _toTuple(extents1)
        extents2 = _toTuple(extents2)
        x1, y1, width1, height1 = extents1
        x2, y2, width2, height2 = extents2

        xPoints1 = range(x1, x1 + width1 + 1)
        xPoints2 = range(x2, x2 + width2 + 1)
        xIntersection = sorted(set(xPoints1).intersection(set(xPoints2)))

        yPoints1 = range(y1, y1 + height1 + 1)
        yPoints2 = range(y2, y2 + height2 + 1)
        yIntersection = sorted(set(yPoints1).intersection(set(yPoints2)))

        if not (xIntersection and yIntersection):
            return 0, 0, 0, 0

        x = xIntersection[0]
        y = yIntersection[0]
        width = xIntersection[-1] - x
        height = yIntersection[-1] - y

        return x, y, width, height

    def containsRegion(self, extents1: Any, extents2: Any) -> Any:
        return self.intersection(extents1, extents2) != (0, 0, 0, 0)

    def _extentsToTuple(self, extents: Any) -> int:
        if extents is None:
            return 0, 0, 0, 0
        if hasattr(extents, "x") and hasattr(extents, "y") \
           and hasattr(extents, "width") and hasattr(extents, "height"):
            return extents.x, extents.y, extents.width, extents.height
        return tuple(extents)

    def _allNamesForKeyCode(self, keycode: Any) -> Any:
        keymap = Gdk.Keymap.get_default()
        entries = keymap.get_entries_for_keycode(keycode)[-1]
        return list(map(Gdk.keyval_name, set(entries)))

    def _lastKeyCodeAndModifiers(self) -> int:
        if not isinstance(cthulhu_state.lastInputEvent, input_event.KeyboardEvent):
            return 0, 0

        event = cthulhu_state.lastNonModifierKeyEvent
        if event:
            return event.hw_code, event.modifiers

        return 0, 0

    def lastKeyAndModifiers(self) -> Any:
        """Convenience method which returns a tuple containing the event
        string and modifiers of the last non-modifier key event or ("", 0)
        if there is no such event."""

        if isinstance(cthulhu_state.lastInputEvent, input_event.KeyboardEvent) \
           and cthulhu_state.lastNonModifierKeyEvent:
            event = cthulhu_state.lastNonModifierKeyEvent
            if event.keyval_name in ["BackSpace", "Delete"]:
                eventStr = event.keyval_name
            else:
                eventStr = event.event_string
            mods = cthulhu_state.lastInputEvent.modifiers
        else:
            eventStr = ""
            mods = 0

        return (eventStr, mods)

    def labelFromKeySequence(self, sequence: Any) -> Any:
        """Turns a key sequence into a user-presentable label."""

        try:
            from gi.repository import Gtk
            key, mods = Gtk.accelerator_parse(sequence)
            newSequence = Gtk.accelerator_get_label(key, mods)
            if newSequence and \
               (not newSequence.endswith('+') or newSequence.endswith('++')):
                sequence = newSequence
        except Exception:
            if sequence.endswith(" "):
                sequence += chnames.getCharacterName(" ")
            sequence = sequence.replace("<", "")
            sequence = sequence.replace(">", " ").strip()

        return keynames.localizeKeySequence(sequence)

    def mnemonicShortcutAccelerator(self, obj: Atspi.Accessible) -> Any:
        """Gets the mnemonic, accelerator string and possibly shortcut
        for the given object.  These are based upon the first accessible
        action for the object.

        Arguments:
        - obj: the Accessible object

        Returns: list containing strings: [mnemonic, shortcut, accelerator]
        """

        try:
            return self._script.generatorCache[self.KEY_BINDING][obj]
        except Exception:
            if self.KEY_BINDING not in self._script.generatorCache:
                self._script.generatorCache[self.KEY_BINDING] = {}

        keybinding = AXObject.get_action_key_binding(obj, 0)
        if not keybinding:
            self._script.generatorCache[self.KEY_BINDING][obj] = ["", "", ""]
            return self._script.generatorCache[self.KEY_BINDING][obj]

        # Action is a string in the format, where the mnemonic and/or
        # accelerator can be missing.
        #
        # <mnemonic>;<full-path>;<accelerator>
        #
        # The keybindings in <full-path> should be separated by ":"
        #

        bindingStrings = keybinding.split(';')
        if len(bindingStrings) == 3:
            mnemonic       = bindingStrings[0]
            fullShortcut   = bindingStrings[1]
            accelerator    = bindingStrings[2]
        elif len(bindingStrings) > 0:
            mnemonic       = ""
            fullShortcut   = bindingStrings[0]
            try:
                accelerator = bindingStrings[1]
            except Exception:
                accelerator = ""
        else:
            mnemonic       = ""
            fullShortcut   = ""
            accelerator    = ""

        fullShortcut = fullShortcut.replace(":", " ").strip()
        fullShortcut = self.labelFromKeySequence(fullShortcut)
        mnemonic = self.labelFromKeySequence(mnemonic)
        accelerator = self.labelFromKeySequence(accelerator)

        if self.KEY_BINDING not in self._script.generatorCache:
            self._script.generatorCache[self.KEY_BINDING] = {}

        self._script.generatorCache[self.KEY_BINDING][obj] = \
            [mnemonic, fullShortcut, accelerator]
        return self._script.generatorCache[self.KEY_BINDING][obj]

    def stringToKeysAndDict(self, string: Any) -> list[Any]:
        """Converts a string made up of a series of <key>:<value>; pairs
        into a dictionary of keys and values. Text before the colon is the
        key and text afterwards is the value. The final semi-colon, if
        found, is ignored.

        Arguments:
        - string: the string of tokens containing <key>:<value>; pairs.

        Returns a list containing two items:
        A list of the keys in the order they were extracted from the
        string and a dictionary of key/value items.
        """

        try:
            items = [s.strip() for s in string.split(";")]
            items = [item for item in items if len(item.split(':')) == 2]
            keys = [item.split(':')[0].strip() for item in items]
            dictionary = dict([item.split(':') for item in items])
        except Exception:
            return [], {}

        return [keys, dictionary]

    def textForValue(self, obj: Atspi.Accessible) -> Any:
        """Returns the text to be displayed for the object's current value.

        Arguments:
        - obj: the Accessible object that may or may not have a value.

        Returns a string representing the value.
        """

        attrs = self.objectAttributes(obj, False)
        valuetext = attrs.get("valuetext")
        if valuetext:
            return valuetext

        if not AXObject.supports_value(obj):
            return ""

        currentValue = AXValue.get_current_value(obj)

        # "The reports of my implementation are greatly exaggerated."
        maxValue = AXValue.get_maximum_value(obj)
        minValue = AXValue.get_minimum_value(obj)
        try:
            minIncrement = Atspi.Value.get_minimum_increment(obj)
        except Exception as error:
            minIncrement = (maxValue - minValue) / 100.0
            tokens = ["SCRIPT UTILITIES: Could not get minimum increment for", obj, ":", error]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
        if minIncrement != 0.0:
            try:
                decimalPlaces = math.ceil(max(0, -math.log10(minIncrement)))
            except ValueError as error:
                tokens = ["SCRIPT UTILITIES: Could not calculate decimal places for",
                          obj, ":", error]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
                return ""
        elif abs(currentValue) < 1:
            decimalPlaces = 1
        else:
            decimalPlaces = 0

        formatter = "%%.%df" % decimalPlaces
        return formatter % currentValue

    def unicodeValueString(self, character: Any) -> Any:
        """ Returns a four hex digit representation of the given character

        Arguments:
        - The character to return representation

        Returns a string representaition of the given character unicode vlue
        """

        try:
            return f"{ord(character):04x}"
        except Exception:
            debug.printException(debug.LEVEL_WARNING)
            return ""

    def getLinesForRange(self, obj: Atspi.Accessible, startOffset: Any, endOffset: Any) -> list[Any]:
        if not AXObject.supports_text(obj):
            return []

        lines = []
        offset = startOffset
        maxOffset = min(endOffset, AXText.get_character_count(obj))
        while offset < maxOffset:
            line, start, end = AXText.get_line_at_offset(obj, offset)
            if line and (line, start, end) not in lines:
                lines.append((line, start, end))
            offset = max(end, offset + 1)

        return lines

    def getLineContentsAtOffset(self, obj: Atspi.Accessible, offset: Any, layoutMode: Any = True, useCache: Any = True) -> list[Any]:
        return []

    def get_objectContentsAtOffset(self, obj: Atspi.Accessible, offset: Any = 0, useCache: Any = True) -> list[Any]:
        return []

    def previousContext(self, obj: Optional[Atspi.Accessible] = None, offset: int = -1, skipSpace: Any = False) -> Any:
        if not obj:
            obj, offset = self.getCaretContext()

        return obj, offset - 1

    def nextContext(self, obj: Optional[Atspi.Accessible] = None, offset: int = -1, skipSpace: Any = False) -> Any:
        if not obj:
            obj, offset = self.getCaretContext()

        return obj, offset + 1

    def lastContext(self, root: Any) -> Any:
        offset = 0
        text = self.queryNonEmptyText(root)
        if text:
            offset = AXText.get_character_count(root) - 1

        return root, offset

    def getHyperlinkRange(self, obj: Atspi.Accessible) -> int:
        """Returns the text range in parent associated with obj."""

        start = AXHypertext.get_link_start_offset(obj)
        end = AXHypertext.get_link_end_offset(obj)
        if start < 0 or end < 0:
            tokens = ["SCRIPT UTILITIES: Exception getting hyperlink indices for", obj]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return -1, -1

        return start, end

    def selectedChildren(self, obj: Atspi.Accessible) -> Any:
        children = AXSelection.get_selected_children(obj)
        if children:
            return children

        msg = "SCRIPT UTILITIES: Selected children not retrieved via selection interface."
        debug.printMessage(debug.LEVEL_INFO, msg, True)

        role = AXObject.get_role(obj)
        if role == Atspi.Role.MENU and not children:
            children = self.findAllDescendants(obj, AXUtilities.is_selected)

        if role == Atspi.Role.COMBO_BOX \
           and children and AXObject.get_role(children[0]) == Atspi.Role.MENU:
            children = self.selectedChildren(children[0])
            name = AXObject.get_name(obj)
            if not children and name:
                def pred(x: Atspi.Accessible) -> bool:
                    return AXObject.get_name(x) == name

                children = self.findAllDescendants(obj, pred)

        return children

    def speakSelectedCellRange(self, obj: Atspi.Accessible) -> bool:
        return False

    def getSelectionContainer(self, obj: Atspi.Accessible) -> Optional[Any]:
        if not 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],
        }

        matchingRoles = rolemap.get(AXObject.get_role(obj))
        def isMatch(x: Atspi.Accessible) -> bool:
            if matchingRoles and AXObject.get_role(x) not in matchingRoles:
                return False
            return AXObject.supports_selection(x)

        return AXObject.find_ancestor(obj, isMatch)

    def selectableChildCount(self, obj: Atspi.Accessible) -> int:
        if not AXObject.supports_selection(obj):
            return 0

        if AXObject.supports_table(obj):
            rows, cols = self.rowAndColumnCount(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 isMatch(x: Atspi.Accessible) -> bool:
            return AXObject.get_role(x) in rolemap.get(role)

        return len(self.findAllDescendants(obj, isMatch))

    def selectedChildCount(self, obj: Atspi.Accessible) -> Any:
        if AXObject.supports_table(obj):
            count = AXTable.get_selected_row_count(obj)
            if count:
                return count

        return AXSelection.get_selected_child_count(obj)

    def popupMenuFor(self, obj: Atspi.Accessible) -> Optional[Any]:
        if obj is None:
            return None

        menus = [child for child in AXObject.iter_children(obj, AXUtilities.is_menu)]
        for menu in menus:
            if AXUtilities.is_enabled(menu):
                return menu

        return None

    def isButtonWithPopup(self, obj: Atspi.Accessible) -> bool:
        return AXUtilities.is_button(obj) and AXUtilities.has_popup(obj)

    def isPopupMenuForCurrentItem(self, obj: Atspi.Accessible) -> bool:
        if obj == cthulhu_state.locusOfFocus:
            return False

        if not AXUtilities.is_menu(obj):
            return False

        name = AXObject.get_name(obj)
        if not name:
            return False

        return name == AXObject.get_name(cthulhu_state.locusOfFocus)

    def isMenuWithNoSelectedChild(self, obj: Atspi.Accessible) -> bool:
        return AXUtilities.is_menu(obj) and not self.selectedChildCount(obj)

    def isMenuButton(self, obj: Atspi.Accessible) -> bool:
        return AXUtilities.is_button(obj) and self.popupMenuFor(obj) is not None

    def inMenu(self, obj: Optional[Atspi.Accessible] = None) -> bool:
        obj = obj or cthulhu_state.locusOfFocus
        if obj is None:
            return False

        if AXUtilities.is_menu_item_of_any_kind(obj) or AXUtilities.is_menu(obj):
            return True

        if AXUtilities.is_panel(obj) or AXUtilities.is_separator(obj):
            return AXObject.find_ancestor(obj, AXUtilities.is_menu) is not None

        return False

    def inContextMenu(self, obj: Optional[Atspi.Accessible] = None) -> bool:
        obj = obj or cthulhu_state.locusOfFocus
        if not self.inMenu(obj):
            return False

        return AXObject.find_ancestor(obj, self.isContextMenu) is not None

    def _contextMenuParentRoles(self) -> Any:
        return Atspi.Role.FRAME, Atspi.Role.WINDOW

    def isContextMenu(self, obj: Atspi.Accessible) -> bool:
        if not AXUtilities.is_menu(obj):
            return False

        return AXObject.get_role(AXObject.get_parent(obj)) in self._contextMenuParentRoles()

    def isTopLevelMenu(self, obj: Atspi.Accessible) -> bool:
        if not AXUtilities.is_menu(obj):
            return False
        return AXObject.get_parent(obj) == self.topLevelObject(obj)

    def isSingleLineAutocompleteEntry(self, obj: Atspi.Accessible) -> bool:
        if not AXUtilities.is_entry(obj):
            return False
        if not AXUtilities.supports_autocompletion(obj):
            return False
        return AXUtilities.is_single_line(obj)

    def isEntryCompletionPopupItem(self, obj: Atspi.Accessible) -> bool:
        return False

    def getEntryForEditableComboBox(self, obj: Atspi.Accessible) -> Optional[Any]:
        if not AXUtilities.is_combo_box(obj):
            return None

        children = [x for x in AXObject.iter_children(obj, self.isEditableTextArea)]
        if len(children) == 1:
            return children[0]

        return None

    def isEditableComboBox(self, obj: Atspi.Accessible) -> bool:
        return self.getEntryForEditableComboBox(obj) is not None

    def isEditableDescendantOfComboBox(self, obj: Atspi.Accessible) -> bool:
        if not AXUtilities.is_editable(obj):
            return False

        return AXObject.find_ancestor(obj, AXUtilities.is_combo_box) is not None

    def getComboBoxValue(self, obj: Atspi.Accessible) -> Any:
        if not AXObject.get_child_count(obj):
            return self.displayedText(obj)

        entry = self.getEntryForEditableComboBox(obj)
        if entry:
            return self.displayedText(entry)

        selected = self._script.utilities.selectedChildren(obj)
        selected = selected or self._script.utilities.selectedChildren(AXObject.get_child(obj, 0))
        if len(selected) == 1:
            return selected[0].name or self.displayedText(selected[0])

        return self.displayedText(obj)

    def isPopOver(self, obj: Atspi.Accessible) -> bool:
        return False

    def isNonModalPopOver(self, obj: Atspi.Accessible) -> bool:
        if not self.isPopOver(obj):
            return False
        return not AXUtilities.is_modal(obj)

    def isUselessPanel(self, obj: Atspi.Accessible) -> bool:
        return False

    def rgbFromString(self, attributeValue: Any) -> Any:
        regex = re.compile(r"rgb|[^\w,]", re.IGNORECASE)
        string = re.sub(regex, "", attributeValue)
        red, green, blue = string.split(",")

        return int(red), int(green), int(blue)

    def isClickableElement(self, obj: Atspi.Accessible) -> bool:
        return False

    def hasLongDesc(self, obj: Atspi.Accessible) -> bool:
        return False

    def hasDetails(self, obj: Atspi.Accessible) -> bool:
        return False

    def isDetails(self, obj: Atspi.Accessible) -> bool:
        return False

    def detailsFor(self, obj: Atspi.Accessible) -> list[Any]:
        return []

    def hasVisibleCaption(self, obj: Atspi.Accessible) -> bool:
        return False

    def popupType(self, obj: Atspi.Accessible) -> str:
        return ''

    def headingLevel(self, obj: Atspi.Accessible) -> int:
        if not AXUtilities.is_heading(obj):
            return 0

        attrs = self.objectAttributes(obj)

        try:
            value = int(attrs.get('level', '0'))
        except ValueError:
            tokens = ["SCRIPT UTILITIES: Exception getting value for", obj, "(", attrs, ")"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return 0

        return value

    def hasMeaningfulToggleAction(self, obj: Atspi.Accessible) -> Any:
        return AXObject.has_action(obj, "toggle") \
            or AXObject.has_action(obj, object_properties.ACTION_TOGGLE)

    def containingTableHeader(self, obj: Atspi.Accessible) -> Any:
        if AXUtilities.is_table_header(obj):
            return obj

        return AXObject.find_ancestor(obj, AXUtilities.is_table_header)

    def columnHeadersForCell(self, obj: Atspi.Accessible) -> Any:
        result = self._columnHeadersForCell(obj)
        # There either are no headers, or we got all of them.
        if len(result) != 1:
            return result

        others = self._columnHeadersForCell(result[0])
        while len(others) == 1 and others[0] not in result:
            result.insert(0, others[0])
            others = self._columnHeadersForCell(result[0])

        return result

    def _columnHeadersForCell(self, obj: Atspi.Accessible) -> list[Any]:
        if not obj:
            msg = "SCRIPT UTILITIES: Attempted to get column headers for null cell"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return []

        return AXTable.get_column_headers(obj)

    def rowHeadersForCell(self, obj: Atspi.Accessible) -> Any:
        result = self._rowHeadersForCell(obj)
        # There either are no headers, or we got all of them.
        if len(result) != 1:
            return result

        others = self._rowHeadersForCell(result[0])
        while len(others) == 1 and others[0] not in result:
            result.insert(0, others[0])
            others = self._rowHeadersForCell(result[0])

        return result

    def _rowHeadersForCell(self, obj: Atspi.Accessible) -> list[Any]:
        if not obj:
            msg = "SCRIPT UTILITIES: Attempted to get row headers for null cell"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return []

        return AXTable.get_row_headers(obj)

    def columnHeaderForCell(self, obj: Atspi.Accessible) -> Any:
        headers = self.columnHeadersForCell(obj)
        if headers:
            return headers[0]

        return None

    def rowHeaderForCell(self, obj: Atspi.Accessible) -> Any:
        headers = self.rowHeadersForCell(obj)
        if headers:
            return headers[0]

        return None

    def _shouldUseTableCellInterfaceForCoordinates(self) -> bool:
        return True

    def coordinatesForCell(self, obj: Atspi.Accessible, preferAttribute: Any = True, findCellAncestor: Any = False) -> Any:
        return AXTable.get_cell_coordinates(
            obj, prefer_attribute=preferAttribute, find_cell=findCellAncestor)

    def rowAndColumnSpan(self, obj: Atspi.Accessible) -> int:
        if not AXUtilities.is_table_cell_or_header(obj):
            return -1, -1
        return AXTable.get_cell_spans(obj, prefer_attribute=True)

    def setSizeUnknown(self, obj: Atspi.Accessible) -> Any:
        return AXUtilities.is_indeterminate(obj)

    def rowOrColumnCountUnknown(self, obj: Atspi.Accessible) -> Any:
        return AXUtilities.is_indeterminate(obj)

    def rowAndColumnCount(self, obj: Atspi.Accessible, preferAttribute: Any = True) -> int:
        if not AXObject.supports_table(obj):
            return -1, -1

        rows = AXTable.get_row_count(obj, prefer_attribute=preferAttribute)
        cols = AXTable.get_column_count(obj, prefer_attribute=preferAttribute)
        return rows, cols

    def _objectBoundsMightBeBogus(self, obj: Atspi.Accessible) -> bool:
        return False

    def _objectMightBeBogus(self, obj: Atspi.Accessible) -> bool:
        return False

    def containsPoint(self, obj: Atspi.Accessible, x: Any, y: Any, coordType: Any, margin: Any = 2) -> bool:
        if self._objectBoundsMightBeBogus(obj) \
           and self.textAtPoint(obj, x, y, coordType) == ("", 0, 0):
            return False

        if self._objectMightBeBogus(obj):
            return False

        if not AXObject.supports_component(obj):
            return False

        if coordType is None:
            coordType = Atspi.CoordType.SCREEN

        try:
            if Atspi.Component.contains(obj, x, y, coordType):
                return True

            x1, y1 = x + margin, y + margin
            if Atspi.Component.contains(obj, x1, y1, coordType):
                tokens = ["SCRIPT UTILITIES: ", obj, f"contains ({x1},{y1}); not ({x},{y}"]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
                return True
        except Exception:
            return False

        return False

    def _boundsIncludeChildren(self, obj: Atspi.Accessible) -> bool:
        if obj is None:
            return False

        if self.hasNoSize(obj):
            return False

        return not (AXUtilities.is_menu(obj) or AXUtilities.is_page_tab(obj))

    def treatAsEntry(self, obj: Atspi.Accessible) -> bool:
        return False

    def _treatAsLeafNode(self, obj: Atspi.Accessible) -> bool:
        if obj is None or AXObject.is_dead(obj):
            return False

        if not AXObject.get_child_count(obj):
            return True

        if AXUtilities.is_autocomplete(obj) or AXUtilities.is_table_row(obj):
            return False

        if AXUtilities.is_combo_box(obj):
            return AXObject.find_descendant(obj, AXUtilities.is_entry) is None

        if AXUtilities.is_link(obj) and AXObject.get_name(obj):
            return True

        if AXUtilities.is_expandable(obj):
            return not AXUtilities.is_expanded(obj)

        if AXUtilities.is_button(obj):
            return True

        return False

    def accessibleAtPoint(self, root: Any, x: Any, y: Any, coordType: Any = None) -> Optional[Any]:
        if self.isHidden(root):
            return None

        if not AXObject.supports_component(root):
            tokens = ["SCRIPT UTILITIES:", root, "does not support component interface"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return None

        if coordType is None:
            coordType = Atspi.CoordType.SCREEN

        try:
            result = Atspi.Component.get_accessible_at_point(root, x, y, coordType)
        except Exception:
            tokens = ["SCRIPT UTILITIES: Exception getting accessible at point for", root]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return None

        tokens = ["SCRIPT UTILITIES: ", result, "is descendant of", root, f"at ({x}, {y})"]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)
        return result

    def descendantAtPoint(self, root: Any, x: Any, y: Any, coordType: Any = None) -> Optional[Any]:
        if not root:
            return None

        if not self.isShowingAndVisible(root):
            return None

        if coordType is None:
            coordType = Atspi.CoordType.SCREEN

        if self.containsPoint(root, x, y, coordType):
            if self._treatAsLeafNode(root) or not self._boundsIncludeChildren(root):
                return root
        elif self._treatAsLeafNode(root) or self._boundsIncludeChildren(root):
            return None

        if AXObject.supports_table(root):
            child = self.accessibleAtPoint(root, x, y, coordType)
            if child and child != root:
                cell = self.descendantAtPoint(child, x, y, coordType)
                if cell:
                    return cell
                return child

        candidates_showing = []
        candidates = []
        for child in AXObject.iter_children(root):
            obj = self.descendantAtPoint(child, x, y, coordType)
            if obj:
                return obj
            if not self.containsPoint(child, x, y, coordType):
                continue
            if self.queryNonEmptyText(child):
                string = AXText.get_all_text(child)
                if re.search(r"[^\ufffc\s]", string):
                    candidates.append(child)
                    if AXUtilities.is_showing(child):
                        candidates_showing.append(child)

        if len(candidates_showing) == 1:
            return candidates_showing[0]
        if len(candidates) == 1:
            # It should have had state "showing" actually
            return candidates[0]

        return None

    def _adjustPointForObj(self, obj: Atspi.Accessible, x: Any, y: Any, coordType: Any) -> Any:
        return x, y

    def isMultiParagraphObject(self, obj: Atspi.Accessible) -> bool:
        if not obj:
            return False

        if not AXObject.supports_text(obj):
            return False

        string = AXText.get_all_text(obj)
        chunks = list(filter(lambda x: x.strip(), string.split("\n\n")))
        return len(chunks) > 1

    def getWordAtOffsetAdjustedForNavigation(self, obj: Atspi.Accessible, offset: Any = None) -> str:
        if not AXObject.supports_text(obj):
            return "", 0, 0

        if offset is None:
            offset = AXText.get_caret_offset(obj)

        word, start, end = self.getWordAtOffset(obj, offset)
        prevObj, prevOffset = self._script.pointOfReference.get(
            "penultimateCursorPosition", (None, -1))
        if prevObj != obj:
            return word, start, end

        # If we're in an ongoing series of native navigation-by-word commands, just present the
        # newly-traversed string.
        prevWord, prevStart, prevEnd = self.getWordAtOffset(prevObj, prevOffset)
        if self._script.pointOfReference.get("lastTextUnitSpoken") == "word":
            if self.lastInputEventWasPrevWordNav():
                start = offset
                end = prevOffset
            elif self.lastInputEventWasNextWordNav():
                start = prevOffset
                end = offset

            word = AXText.get_substring(obj, start, end)
            debugString = word.replace("\n", "\\n")
            msg = (
                f"SCRIPT UTILITIES: Adjusted word at offset {offset} for ongoing word nav is "
                f"'{debugString}' ({start}-{end})"
            )
            debug.printMessage(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 self.lastInputEventWasPrevWordNav():
            # 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 self.lastInputEventWasNextWordNav():
            # 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 != prevWord and prevStart < offset <= prevEnd:
                start = prevStart

            # If the character to the left of our present position is neither a space, nor
            # an alphanumeric character, then suspect that character is a navigation boundary
            # where we would have landed before via the native next word command.
            lastChar = AXText.get_substring(obj, offset - 1, offset)
            if not (lastChar.isspace() or lastChar.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 == prevWord:
            if word.startswith("\n"):
                start += 1
            elif word.endswith("\n"):
                end -= 1
            word = AXText.get_substring(obj, start, end)

        word = AXText.get_substring(obj, start, end)
        debugString = word.replace("\n", "\\n")
        msg = (
            f"SCRIPT UTILITIES: Adjusted word at offset {offset} for new word nav is "
            f"'{debugString}' ({start}-{end})"
        )
        debug.printMessage(debug.LEVEL_INFO, msg, True)
        return word, start, end

    def getWordAtOffset(self, obj: Atspi.Accessible, offset: Any = None) -> str:
        if not AXObject.supports_text(obj):
            return "", 0, 0

        if offset is None:
            offset = AXText.get_caret_offset(obj)

        word, start, end = AXText.get_word_at_offset(obj, offset)
        debugString = word.replace("\n", "\\n")
        msg = (
            f"SCRIPT UTILITIES: Word at offset {offset} is "
            f"'{debugString}' ({start}-{end})"
        )
        debug.printMessage(debug.LEVEL_INFO, msg, True)
        return word, start, end

    def textAtPoint(self, obj: Atspi.Accessible, x: Any, y: Any, coordType: Any = None, boundary: Any = None) -> str:
        text = self.queryNonEmptyText(obj)
        if not text:
            return "", 0, 0

        if coordType is None:
            coordType = Atspi.CoordType.SCREEN

        if boundary is None:
            boundary = Atspi.TextBoundaryType.LINE_START

        x, y = self._adjustPointForObj(obj, x, y, coordType)
        try:
            offset = Atspi.Text.get_offset_at_point(obj, x, y, coordType)
        except Exception:
            return "", 0, 0

        if not 0 <= offset < AXText.get_character_count(obj):
            return "", 0, 0

        if boundary == Atspi.TextBoundaryType.CHAR:
            string, start, end = AXText.get_character_at_offset(obj, offset)
        elif boundary == Atspi.TextBoundaryType.WORD_START:
            string, start, end = AXText.get_word_at_offset(obj, offset)
        elif boundary == Atspi.TextBoundaryType.LINE_START:
            string, start, end = AXText.get_line_at_offset(obj, offset)
        elif boundary == Atspi.TextBoundaryType.SENTENCE_START:
            string, start, end = AXText.get_sentence_at_offset(obj, offset)
        elif boundary == Atspi.TextBoundaryType.PARAGRAPH_START:
            string, start, end = AXText.get_paragraph_at_offset(obj, offset)
        else:
            try:
                result = Atspi.Text.get_string_at_offset(obj, offset, boundary)
            except Exception:
                return "", 0, 0
            if result is None:
                return "", 0, 0
            string = result.content
            start = result.start_offset
            end = result.end_offset

        if not string:
            return "", start, end

        if boundary == Atspi.TextBoundaryType.WORD_START and not string.strip():
            return "", 0, 0

        try:
            extents = Atspi.Text.get_range_extents(obj, start, end, coordType)
        except Exception:
            return "", 0, 0

        if not self.containsRegion(extents, (x, y, 1, 1)) and string != "\n":
            return "", 0, 0

        if not string.endswith("\n") or string == "\n":
            return string, start, end

        if boundary == Atspi.TextBoundaryType.CHAR:
            return string, start, end

        char = self.textAtPoint(obj, x, y, coordType, Atspi.TextBoundaryType.CHAR)
        if char[0] == "\n" and char[2] - char[1] == 1:
            return char

        return string, start, end

    def visibleRows(self, obj: Atspi.Accessible, boundingbox: Any) -> list[Any]:
        if not AXObject.supports_table(obj):
            return []

        nRows = AXTable.get_row_count(obj, prefer_attribute=False)
        if nRows < 0:
            return []

        tokens = ["SCRIPT UTILITIES: ", obj, f"has {nRows} rows"]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)

        x, y, width, height = boundingbox.x, boundingbox.y, boundingbox.width, boundingbox.height
        cell = self.descendantAtPoint(obj, x, y + 1)
        row, col = self.coordinatesForCell(cell)
        startIndex = max(0, row)
        tokens = ["SCRIPT UTILITIES: First cell:", cell, f"(row: {row}"]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)

        # Just in case the row above is a static header row in a scrollable table.
        if cell and AXObject.supports_component(cell):
            try:
                extents = Atspi.Component.get_extents(cell, Atspi.CoordType.SCREEN)
                cell = self.descendantAtPoint(obj, x, y + extents.height + 1)
                row, col = self.coordinatesForCell(cell)
                nextIndex = max(startIndex, row)
                tokens = ["SCRIPT UTILITIES: Next cell:", cell, f"(row: {row}"]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
            except Exception:
                nextIndex = startIndex
        else:
            nextIndex = startIndex

        cell = self.descendantAtPoint(obj, x, y + height - 1)
        row, col = self.coordinatesForCell(cell)
        tokens = ["SCRIPT UTILITIES: Last cell:", cell, f"(row: {row}"]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)

        if row == -1:
            row = nRows
        endIndex = row

        rows = list(range(nextIndex, endIndex))
        if startIndex not in rows:
            rows.insert(0, startIndex)

        return rows

    def getVisibleTableCells(self, obj: Atspi.Accessible) -> list[Any]:
        if not AXObject.supports_table(obj):
            return []

        if not AXObject.supports_component(obj):
            return []

        try:
            extents = Atspi.Component.get_extents(obj, Atspi.CoordType.SCREEN)
        except Exception:
            tokens = ["SCRIPT UTILITIES: Exception getting extents of", obj]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return []

        rows = self.visibleRows(obj, extents)
        if not rows:
            return []

        colStartIndex, colEndIndex = self._getTableRowRange(obj)
        if colStartIndex == colEndIndex:
            return []

        cells = []
        for col in range(colStartIndex, colEndIndex):
            try:
                colHeader = Atspi.Table.get_column_header(obj, col)
                if colHeader:
                    cells.append(colHeader)
            except Exception:
                pass
            for row in rows:
                try:
                    cell = Atspi.Table.get_accessible_at(obj, row, col)
                except Exception:
                    continue
                if cell and self.isOnScreen(cell):
                    cells.append(cell)

        return cells

    def _getTableRowRange(self, obj: Atspi.Accessible) -> Any:
        rowCount, columnCount = self.rowAndColumnCount(obj)
        startIndex, endIndex = 0, columnCount
        if not self.isSpreadSheetCell(obj):
            return startIndex, endIndex

        parent = self.getTable(obj)
        if not parent or not AXObject.supports_component(parent):
            tokens = ["SCRIPT UTILITIES:", parent, "does not support component interface"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return startIndex, endIndex

        try:
            extents = Atspi.Component.get_extents(parent, Atspi.CoordType.SCREEN)
            x, y, width, height = extents.x, extents.y, extents.width, extents.height
        except Exception:
            tokens = ["SCRIPT UTILITIES: Exception getting extents of", parent]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return startIndex, endIndex

        try:
            cell = Atspi.Component.get_accessible_at_point(parent, x+1, y, Atspi.CoordType.SCREEN)
            if cell:
                row, column = self.coordinatesForCell(cell)
                startIndex = column
        except Exception:
            pass

        try:
            cell = Atspi.Component.get_accessible_at_point(parent, x+width-1, y, Atspi.CoordType.SCREEN)
            if cell:
                row, column = self.coordinatesForCell(cell)
                endIndex = column + 1
        except Exception:
            pass

        return startIndex, endIndex

    def getShowingCellsInSameRow(self, obj: Atspi.Accessible, forceFullRow: Any = False) -> list[Any]:
        parent = self.getTable(obj)
        if not parent or not AXObject.supports_table(parent):
            tokens = ["SCRIPT UTILITIES: Exception querying table interface of", parent]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return []

        row, column = self.coordinatesForCell(obj, False)
        if row == -1:
            return []

        if forceFullRow:
            startIndex, endIndex = 0, AXTable.get_column_count(parent, prefer_attribute=False)
        else:
            startIndex, endIndex = self._getTableRowRange(obj)
        if startIndex == endIndex:
            return []

        cells = []
        for i in range(startIndex, endIndex):
            cell = AXTable.get_cell_at(parent, row, i)
            if cell and AXUtilities.is_showing(cell):
                cells.append(cell)

        return cells

    def cellForCoordinates(self, obj: Atspi.Accessible, row: Any, column: Any, showingOnly: Any = False) -> Optional[Any]:
        if not AXObject.supports_table(obj):
            return None

        cell = AXTable.get_cell_at(obj, row, column)
        if not showingOnly:
            return cell

        if AXUtilities.is_showing(cell):
            return cell

        return None

    def isLastCell(self, obj: Atspi.Accessible) -> bool:
        if not AXUtilities.is_table_cell(obj):
            return False

        table = AXTable.get_table(obj)
        if table is None:
            return False

        row, col = self.coordinatesForCell(obj)
        rows = AXTable.get_row_count(table, prefer_attribute=False)
        cols = AXTable.get_column_count(table, prefer_attribute=False)
        return row + 1 == rows and col + 1 == cols

    def isNonUniformTable(self, obj: Atspi.Accessible, maxRows: Any = 25, maxCols: Any = 25) -> bool:
        if not AXObject.supports_table(obj):
            return False

        return AXTable.is_non_uniform_table(obj, maxRows, maxCols)

    def isShowingAndVisible(self, obj: Atspi.Accessible) -> bool:
        if AXUtilities.is_showing(obj) and AXUtilities.is_visible(obj):
            return True

        # NOTE: This likely belongs in toolkit scripts, but it's shared across
        # toolkits (Gtk, Firefox, Chrome, LO, Eclipse) and might be an AT-SPI2
        # issue, so we keep it here for now.
        if self._is_open_menu_bar_menu_role(obj):
            tokens = ["HACK: Treating", obj, "as showing and visible"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return True

        return False

    def _is_open_menu_bar_menu_role(self, obj: Atspi.Accessible) -> bool:
        if AXObject.get_role(obj) not in self.MENU_ROLES_IN_OPEN_MENU:
            return False
        return self.isInOpenMenuBarMenu(obj)

    def isZombie(self, obj: Atspi.Accessible) -> bool:
        index = AXObject.get_index_in_parent(obj)
        role = AXObject.get_role(obj)
        tokens = ["SCRIPT UTILITIES: ", obj, "is zombie:"]
        if index == -1 and role not in self.ZOMBIE_TOP_LEVEL_ROLES:
            tokens.append("index is -1")
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return True
        if AXUtilities.is_defunct(obj):
            tokens.append("is defunct")
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return True
        if AXUtilities.is_invalid_state(obj):
            tokens.append("has invalid state")
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return True
        if AXUtilities.is_invalid_role(obj):
            tokens.append("has invalid role")
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return True

        return False

    def findReplicant(self, root: Any, obj: Atspi.Accessible) -> Optional[Any]:
        tokens = ["SCRIPT UTILITIES: Searching for replicant for", obj, "in", root]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)
        if not (root and obj):
            return None

        if AXUtilities.is_table(root) or AXUtilities.is_embedded(root):
            return None

        def isSame(x: Atspi.Accessible) -> bool:
            return self.isSameObject(x, obj, comparePaths=True, ignoreNames=True)

        if isSame(root):
            replicant = root
        else:
            replicant = AXObject.find_descendant(root, isSame)

        tokens = ["HACK: Returning", replicant, "as replicant for Zombie", obj]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)
        return replicant

    def getFunctionalChildCount(self, obj: Atspi.Accessible) -> Any:
        nodeParents = AXUtilitiesRelation.get_is_node_parent_of(obj)
        if nodeParents:
            return len(nodeParents)
        return AXObject.get_child_count(obj)

    def getFunctionalChildren(self, obj: Atspi.Accessible, sibling: Any = None) -> Any:
        result = AXUtilitiesRelation.get_is_node_parent_of(obj)
        if result:
            return result
        if self.isDescriptionListTerm(sibling):
            return self.descriptionListTerms(obj)
        if self.isDescriptionListDescription(sibling):
            return self.valuesForTerm(self.termForValue(sibling))
        return [x for x in AXObject.iter_children(obj)]

    def getFunctionalParent(self, obj: Atspi.Accessible) -> Any:
        nodeChildren = AXUtilitiesRelation.get_is_node_child_of(obj)
        if nodeChildren:
            return nodeChildren[0]
        return AXObject.get_parent(obj)

    def getPositionAndSetSize(self, obj: Atspi.Accessible, **args: Any) -> int:
        if obj is None:
            return -1, -1

        if AXUtilities.is_table_cell(obj) and args.get("readingRow"):
            row, col = self.coordinatesForCell(obj)
            rowcount, colcount = self.rowAndColumnCount(self.getTable(obj))
            return row, rowcount

        if AXUtilities.is_combo_box(obj):
            selected = self.selectedChildren(obj)
            if selected:
                obj = selected[0]
            else:
                def isMenu(x: Atspi.Accessible) -> bool:
                    return AXUtilities.is_menu(x) or AXUtilities.is_list_box(x)

                selected = self.selectedChildren(AXObject.find_descendant(obj, isMenu))
                if selected:
                    obj = selected[0]
                else:
                    return -1, -1

        parent = self.getFunctionalParent(obj)
        childCount = self.getFunctionalChildCount(parent)
        if childCount > 100 and parent == AXObject.get_parent(obj):
            return AXObject.get_index_in_parent(obj), childCount

        siblings = self.getFunctionalChildren(parent, obj)
        if len(siblings) < 100 and not AXObject.find_ancestor(obj, AXUtilities.is_combo_box):
            layoutRoles = [Atspi.Role.SEPARATOR, Atspi.Role.TEAROFF_MENU_ITEM]

            def isNotLayoutOnly(x: Atspi.Accessible) -> bool:
                return not (self.isZombie(x) or AXObject.get_role(x) in layoutRoles)

            siblings = list(filter(isNotLayoutOnly, siblings))

        if not (siblings and obj in siblings):
            return -1, -1

        if self.isFocusableLabel(obj):
            siblings = list(filter(self.isFocusableLabel, siblings))
            if len(siblings) == 1:
                return -1, -1

        position = siblings.index(obj)
        setSize = len(siblings)
        return position, setSize

    def termForValue(self, obj: Atspi.Accessible) -> Optional[Any]:
        if not self.isDescriptionListDescription(obj):
            return None

        while obj and not self.isDescriptionListTerm(obj):
            obj = AXObject.get_previous_sibling(obj)

        return obj

    def valuesForTerm(self, obj: Atspi.Accessible) -> list[Any]:
        if not self.isDescriptionListTerm(obj):
            return []

        values = []
        obj = AXObject.get_next_sibling(obj)
        while obj and self.isDescriptionListDescription(obj):
            values.append(obj)
            obj = AXObject.get_next_sibling(obj)

        return values

    def getValueCountForTerm(self, obj: Atspi.Accessible) -> Any:
        return len(self.valuesForTerm(obj))

    def getRoleDescription(self, obj: Atspi.Accessible, isBraille: Any = False) -> str:
        return ""

    def getCachedTextSelection(self, obj: Atspi.Accessible) -> Any:
        cached = self._getCachedSelectionData(obj)
        start, end, string = cached['start'], cached['end'], cached['string']
        tokens = ["SCRIPT UTILITIES: Cached selection for", obj, f"is '{string}' ({start}, {end})"]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)
        return start, end, string

    def updateCachedTextSelection(self, obj: Atspi.Accessible) -> Any:
        if not AXObject.supports_text(obj):
            tokens = ["SCRIPT UTILITIES:", obj, "doesn't implement AtspiText"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            self._cacheSelectionData(obj, {'ranges': [], 'start': 0, 'end': 0, 'string': ''})
            return

        if self._script.pointOfReference.get('entireDocumentSelected'):
            selectedText, selectedStart, selectedEnd = self.allSelectedText(obj)
            if not selectedText:
                self._script.pointOfReference['entireDocumentSelected'] = False
                self._getSelectionState()['textSelections'] = {}

        snapshot = self._getSelectionAdapter().refresh_snapshot(obj)
        selectionData = self._getSelectionData(obj, snapshot)
        if snapshot.is_authoritative and not snapshot.ranges:
            self._script.pointOfReference['entireDocumentSelected'] = False
            self._getSelectionState()['textSelections'] = {}

        tokens = [
            "SCRIPT UTILITIES: New selection for",
            obj,
            f"is '{selectionData['string']}' ({selectionData['start']}, {selectionData['end']})",
        ]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)
        self._cacheSelectionData(obj, selectionData)

    def _getSingleSelectionText(self, obj: Atspi.Accessible) -> Any:
        # NOTE: Does not handle multiple non-contiguous selections.
        string, start, end = AXText.get_selected_text(obj)
        if string:
            string = self._getSelectionTextFragment(obj, start, end)
        return string, start, end

    def onClipboardContentsChanged(self, *args: Any) -> None:
        script = cthulhu_state.activeScript
        if not script:
            msg = "SCRIPT UTILITIES: Clipboard contents changed with no active script"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return

        if time.time() - Utilities._last_clipboard_update < 0.05:
            msg = "SCRIPT UTILITIES: Clipboard contents change believed to be duplicate"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return

        Utilities._last_clipboard_update = time.time()
        tokens = ["SCRIPT UTILITIES: Clipboard contents changed for", script]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)
        script.onClipboardContentsChanged(*args)

    def connectToClipboard(self) -> None:
        if self._clipboardHandlerId is not None:
            return

        clipboard = Gtk.Clipboard.get(Gdk.Atom.intern("CLIPBOARD", False))
        self._clipboardHandlerId = clipboard.connect(
            'owner-change', self.onClipboardContentsChanged)

    def disconnectFromClipboard(self) -> None:
        if self._clipboardHandlerId is None:
            return

        clipboard = Gtk.Clipboard.get(Gdk.Atom.intern("CLIPBOARD", False))
        clipboard.disconnect(self._clipboardHandlerId)

    def getClipboardContents(self) -> Any:
        clipboard = Gtk.Clipboard.get(Gdk.Atom.intern("CLIPBOARD", False))
        return clipboard.wait_for_text()

    def setClipboardText(self, text: Any) -> bool:
        clipboard = Gtk.Clipboard.get(Gdk.Atom.intern("CLIPBOARD", False))
        clipboard.set_text(text, -1)

    def appendTextToClipboard(self, text: Any) -> bool:
        clipboard = Gtk.Clipboard.get(Gdk.Atom.intern("CLIPBOARD", False))
        clipboard.request_text(self._appendTextToClipboardCallback, text)

    def _appendTextToClipboardCallback(self, clipboard: Any, text: Any, newText: Any, separator: Any = "\n") -> bool:
        text = text.rstrip("\n")
        text = f"{text}{separator}{newText}"
        clipboard.set_text(text, -1)

    def lastInputEventCameFromThisApp(self) -> bool:
        if not isinstance(cthulhu_state.lastInputEvent, input_event.KeyboardEvent):
            return False

        event = cthulhu_state.lastNonModifierKeyEvent
        return event and event.isFromApplication(self._script.app)

    def lastInputEventWasPrintableKey(self) -> Any:
        return input_event_manager.get_manager().last_event_was_printable_key()

    def lastInputEventWasCommand(self) -> Any:
        return input_event_manager.get_manager().last_event_was_command()

    def lastInputEventWasPageSwitch(self) -> Any:
        return input_event_manager.get_manager().last_event_was_page_switch()

    def lastInputEventWasUnmodifiedArrow(self) -> Any:
        return input_event_manager.get_manager().last_event_was_unmodified_arrow()

    def lastInputEventWasCaretNav(self) -> Any:
        return input_event_manager.get_manager().last_event_was_caret_navigation()

    def lastInputEventWasCharNav(self) -> Any:
        return input_event_manager.get_manager().last_event_was_character_navigation()

    def lastInputEventWasWordNav(self) -> Any:
        return input_event_manager.get_manager().last_event_was_word_navigation()

    def lastInputEventWasPrevWordNav(self) -> Any:
        return input_event_manager.get_manager().last_event_was_previous_word_navigation()

    def lastInputEventWasNextWordNav(self) -> Any:
        return input_event_manager.get_manager().last_event_was_next_word_navigation()

    def lastInputEventWasLineNav(self) -> bool:
        if not input_event_manager.get_manager().last_event_was_line_navigation():
            return False

        if self.isEditableDescendantOfComboBox(cthulhu_state.locusOfFocus):
            return False

        return True

    def lastInputEventWasLineBoundaryNav(self) -> Any:
        return input_event_manager.get_manager().last_event_was_line_boundary_navigation()

    def lastInputEventWasPageNav(self) -> bool:
        if not input_event_manager.get_manager().last_event_was_page_navigation():
            return False

        if self.isEditableDescendantOfComboBox(cthulhu_state.locusOfFocus):
            return False

        return True

    def lastInputEventWasFileBoundaryNav(self) -> Any:
        return input_event_manager.get_manager().last_event_was_file_boundary_navigation()

    def lastInputEventWasCaretNavWithSelection(self) -> Any:
        return input_event_manager.get_manager().last_event_was_caret_selection()

    def lastInputEventWasUndo(self) -> Any:
        return input_event_manager.get_manager().last_event_was_undo()

    def lastInputEventWasRedo(self) -> Any:
        return input_event_manager.get_manager().last_event_was_redo()

    def lastInputEventWasCut(self) -> Any:
        return input_event_manager.get_manager().last_event_was_cut()

    def lastInputEventWasCopy(self) -> Any:
        return input_event_manager.get_manager().last_event_was_copy()

    def lastInputEventWasPaste(self) -> Any:
        return input_event_manager.get_manager().last_event_was_paste()

    def lastInputEventWasSelectAll(self) -> Any:
        return input_event_manager.get_manager().last_event_was_select_all()

    def lastInputEventWasDelete(self) -> Any:
        return input_event_manager.get_manager().last_event_was_delete()

    def lastInputEventWasTab(self) -> Any:
        return input_event_manager.get_manager().last_event_was_tab()

    def lastInputEventWasMouseButton(self) -> Any:
        return input_event_manager.get_manager().last_event_was_mouse_button()

    def lastInputEventWasPrimaryMouseClick(self) -> Any:
        return input_event_manager.get_manager().last_event_was_primary_click()

    def lastInputEventWasMiddleMouseClick(self) -> Any:
        return input_event_manager.get_manager().last_event_was_middle_click()

    def lastInputEventWasSecondaryMouseClick(self) -> Any:
        return input_event_manager.get_manager().last_event_was_secondary_click()

    def lastInputEventWasPrimaryMouseRelease(self) -> Any:
        return input_event_manager.get_manager().last_event_was_primary_release()

    def lastInputEventWasMiddleMouseRelease(self) -> Any:
        return input_event_manager.get_manager().last_event_was_middle_release()

    def lastInputEventWasSecondaryMouseRelease(self) -> Any:
        return input_event_manager.get_manager().last_event_was_secondary_release()

    def lastInputEventWasTableSort(self, delta: Any = 0.5) -> bool:
        if not input_event_manager.get_manager().last_event_was_table_sort():
            return False

        event = cthulhu_state.lastInputEvent
        if not event:
            return False

        now = time.time()
        if now - event.time > delta:
            return False

        lastSortTime = self._script.pointOfReference.get('last-table-sort-time', 0.0)
        if now - lastSortTime < delta:
            return False

        if isinstance(event, input_event.MouseButtonEvent):
            if not self.lastInputEventWasPrimaryMouseRelease():
                return False
        elif isinstance(event, input_event.KeyboardEvent):
            if not event.isHandledBy(self._script.leftClickReviewItem):
                keyString, mods = self.lastKeyAndModifiers()
                if keyString not in ["Return", "space", " "]:
                    return False

        return AXUtilities.is_table_header(cthulhu_state.locusOfFocus)

    def isPresentableExpandedChangedEvent(self, event: Any) -> bool:
        if self.isSameObject(event.source, cthulhu_state.locusOfFocus):
            return True

        if AXUtilities.is_table_row(event.source) or AXUtilities.is_list_box(event.source):
            return True

        if AXUtilities.is_combo_box(event.source) or AXUtilities.is_button(event.source):
            return AXUtilities.is_focused(event.source)

        return False

    def isPresentableTextChangedEventForLocusOfFocus(self, event: Any) -> bool:
        if not event.type.startswith("object:text-changed:") \
           and not event.type.startswith("object:text-attributes-changed"):
            return False

        if AXUtilities.is_menu_related(event.source) \
           or AXUtilities.is_slider(event.source) \
           or AXUtilities.is_spin_button(event.source) \
           or AXUtilities.is_label(event.source):
            msg = "SCRIPT UTILITIES: Event is not being presented due to role"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        if AXUtilities.is_focused(event.source):
            if self.isTypeahead(event.source):
                return True
            if AXUtilities.is_password_text(event.source):
                return True
            if AXObject.is_dead(cthulhu_state.locusOfFocus):
                return True
        elif AXUtilities.is_table_cell(event.source) and not AXUtilities.is_selected(event.source):
            msg = "SCRIPT UTILITIES: Event is not being presented due to role and states"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        if cthulhu_state.locusOfFocus in [event.source, AXObject.get_parent(event.source)]:
            return True

        msg = "SCRIPT UTILITIES: Event is not being presented due to lack of cause"
        debug.printMessage(debug.LEVEL_INFO, msg, True)
        return False

    def isBackSpaceCommandTextDeletionEvent(self, event: Any) -> bool:
        if not event.type.startswith("object:text-changed:delete"):
            return False

        if self.isHidden(event.source):
            return False

        keyString, mods = self.lastKeyAndModifiers()
        if keyString == "BackSpace":
            return True

        return False

    def isDeleteCommandTextDeletionEvent(self, event: Any) -> bool:
        if not event.type.startswith("object:text-changed:delete"):
            return False

        return self.lastInputEventWasDelete()

    def isUndoCommandTextDeletionEvent(self, event: Any) -> bool:
        if not event.type.startswith("object:text-changed:delete"):
            return False

        if not self.lastInputEventWasUndo():
            return False

        start, end, string = self.getCachedTextSelection(event.source)
        return not string

    def isSelectedTextDeletionEvent(self, event: Any) -> bool:
        if not event.type.startswith("object:text-changed:delete"):
            return False

        if self.lastInputEventWasPaste():
            return False

        start, end, string = self.getCachedTextSelection(event.source)
        return string and string.strip() == event.any_data.strip()

    def isSelectedTextInsertionEvent(self, event: Any) -> bool:
        if not event.type.startswith("object:text-changed:insert"):
            return False

        self.updateCachedTextSelection(event.source)
        start, end, string = self.getCachedTextSelection(event.source)
        return string and string == event.any_data and start == event.detail1

    def isSelectedTextRestoredEvent(self, event: Any) -> bool:
        if not self.lastInputEventWasUndo():
            return False

        if self.isSelectedTextInsertionEvent(event):
            return True

        return False

    def isMiddleMouseButtonTextInsertionEvent(self, event: Any) -> bool:
        if not event.type.startswith("object:text-changed:insert"):
            return False

        return self.lastInputEventWasMiddleMouseClick()

    def isEchoableTextInsertionEvent(self, event: Any) -> bool:
        if not event.type.startswith("object:text-changed:insert"):
            return False

        if not cthulhu.cthulhuApp.settingsManager.getSetting("enableKeyEcho"):
            return False

        if AXUtilities.is_focusable(event.source) \
           and not AXUtilities.is_focused(event.source) \
           and event.source != cthulhu_state.locusOfFocus:
            msg = "SCRIPT UTILITIES: Not echoable text insertion event: " \
                 "focusable source is not focused"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        if AXUtilities.is_password_text(event.source):
            return cthulhu.cthulhuApp.settingsManager.getSetting("enableKeyEcho")

        if len(event.any_data.strip()) == 1:
            return cthulhu.cthulhuApp.settingsManager.getSetting("enableEchoByCharacter")

        return False

    def isEditableTextArea(self, obj: Atspi.Accessible) -> bool:
        if not self.isTextArea(obj):
            return False
        return AXUtilities.is_editable(obj)

    def isClipboardTextChangedEvent(self, event: Any) -> bool:
        if not event.type.startswith("object:text-changed"):
            return False

        if not self.lastInputEventWasCommand() or self.lastInputEventWasUndo():
            return False

        if self.isBackSpaceCommandTextDeletionEvent(event):
            return False

        if "delete" in event.type and self.lastInputEventWasPaste():
            return False

        if not self.isEditableTextArea(event.source):
            return False

        contents = self.getClipboardContents()
        if not contents:
            return False
        if event.any_data == contents:
            return True
        if bool(re.search(r"\w", event.any_data)) != bool(re.search(r"\w", contents)):
            return False

        if self._isParagraphClipboardEvent(event, contents):
            return True

        return False

    def _isParagraphClipboardEvent(self, event: Any, contents: Any) -> bool:
        # NOTE: Paragraph-per-object toolkits can emit per-paragraph events.
        if "\n" in contents and event.any_data.rstrip() in contents:
            return True

        return False

    def objectContentsAreInClipboard(self, obj: Optional[Atspi.Accessible] = None) -> bool:
        obj = obj or cthulhu_state.locusOfFocus
        if not obj or AXObject.is_dead(obj):
            return False

        contents = self.getClipboardContents()
        if not contents:
            return False

        string, _, _ = self.allSelectedText(obj)
        if string and string in contents:
            return True

        string, start, end = self.selectedText(obj)
        if string and string in contents:
            return True

        obj = self.realActiveDescendant(obj) or obj
        if AXObject.is_dead(obj):
            return False

        name = AXObject.get_name(obj)
        if obj and name in contents:
            return True

        return False

    def clearCachedCommandState(self) -> bool:
        self._script.pointOfReference['undo'] = False
        self._script.pointOfReference['redo'] = False
        self._script.pointOfReference['paste'] = False
        self._script.pointOfReference['last-selection-message'] = ''

    def handleUndoTextEvent(self, event: Any) -> bool:
        if self.lastInputEventWasUndo():
            if not self._script.pointOfReference.get('undo'):
                self._script.presentMessage(messages.UNDO)
                self._script.pointOfReference['undo'] = True
            self.updateCachedTextSelection(event.source)
            return True

        if self.lastInputEventWasRedo():
            if not self._script.pointOfReference.get('redo'):
                self._script.presentMessage(messages.REDO)
                self._script.pointOfReference['redo'] = True
            self.updateCachedTextSelection(event.source)
            return True

        return False

    def handleUndoLocusOfFocusChange(self) -> bool:
        if self._locusOfFocusIsTopLevelObject():
            return False

        if self.lastInputEventWasUndo():
            if not self._script.pointOfReference.get('undo'):
                self._script.presentMessage(messages.UNDO)
                self._script.pointOfReference['undo'] = True
            return True

        if self.lastInputEventWasRedo():
            if not self._script.pointOfReference.get('redo'):
                self._script.presentMessage(messages.REDO)
                self._script.pointOfReference['redo'] = True
            return True

        return False

    def handlePasteLocusOfFocusChange(self) -> bool:
        if self._locusOfFocusIsTopLevelObject():
            return False

        if self.lastInputEventWasPaste():
            if not self._script.pointOfReference.get('paste'):
                self._script.presentMessage(
                    messages.CLIPBOARD_PASTED_FULL, messages.CLIPBOARD_PASTED_BRIEF)
                self._script.pointOfReference['paste'] = True
            return True

        return False

    def eventIsUserTriggered(self, event: Any) -> bool:
        if not cthulhu_state.lastInputEvent:
            msg = "SCRIPT UTILITIES: Not user triggered: No last input event."
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        delta = time.time() - cthulhu_state.lastInputEvent.time
        if delta > 1:
            msg = f"SCRIPT UTILITIES: Not user triggered: Last input event {delta:.2f}s ago."
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        if self.isKeyGrabEvent(event):
            msg = "INFO: Last key was consumed. Probably a bogus event from a key grab"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        return True

    def isKeyGrabEvent(self, event: Any) -> bool:
        """ Returns True if this event appears to be a side-effect of an
        X11 key grab. """
        if not isinstance(cthulhu_state.lastInputEvent, input_event.KeyboardEvent):
            return False
        return cthulhu_state.lastInputEvent.didConsume() and not cthulhu_state.openingDialog

    def presentFocusChangeReason(self) -> bool:
        if self.handleUndoLocusOfFocusChange():
            return True
        if self.handlePasteLocusOfFocusChange():
            return True
        return False

    def allItemsSelected(self, obj: Atspi.Accessible) -> bool:
        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

        childCount = AXObject.get_child_count(obj)
        if childCount == 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 {childCount} children believed to be selected"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if not AXObject.supports_table(obj):
            return False

        rows = AXTable.get_row_count(obj, prefer_attribute=False)
        cols = AXTable.get_column_count(obj, prefer_attribute=False)
        selected_rows = AXTable.get_selected_row_count(obj)
        selected_cols = AXTable.get_selected_column_count(obj)

        if selected_rows == rows:
            msg = f"SCRIPT UTILITIES: All {rows} rows believed to be selected"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if selected_cols == cols:
            msg = f"SCRIPT UTILITIES: All {cols} columns believed to be selected"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        return False

    def handleContainerSelectionChange(self, obj: Atspi.Accessible) -> bool:
        allAlreadySelected = self._script.pointOfReference.get('allItemsSelected')
        allCurrentlySelected = self.allItemsSelected(obj)
        if allAlreadySelected and allCurrentlySelected:
            return True

        self._script.pointOfReference['allItemsSelected'] = allCurrentlySelected
        if self.lastInputEventWasSelectAll() and allCurrentlySelected:
            self._script.presentMessage(messages.CONTAINER_SELECTED_ALL)
            cthulhu.setLocusOfFocus(None, obj, False)
            return True

        return False

    def handleTextSelectionChange(self, obj: Atspi.Accessible, speakMessage: Any = True) -> bool:
        # 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

        oldSelection = self._getCachedSelectionData(obj)
        oldStart = oldSelection['start']
        oldEnd = oldSelection['end']
        oldString = oldSelection['string']
        oldRanges = oldSelection['ranges']

        snapshot = self._getSelectionAdapter().refresh_snapshot(obj)
        newSelection = self._getSelectionData(obj, snapshot)
        self._cacheSelectionData(obj, newSelection)
        newStart = newSelection['start']
        newEnd = newSelection['end']
        newString = newSelection['string']
        newRanges = newSelection['ranges']

        if self._speakTextSelectionState(len(newString)):
            return True

        # Even though we present a message, treat it as unhandled so the new location is
        # still presented.
        if not self.lastInputEventWasCaretNavWithSelection() and oldString and not newString:
            self._script.speakMessage(messages.SELECTION_REMOVED)
            return False

        changes = []
        oldChars = self._rangesToCharSet(oldRanges)
        newChars = self._rangesToCharSet(newRanges)
        if not oldChars.union(newChars):
            return False

        for start, end in self._charSetToRanges(oldChars - newChars):
            changes.append([start, end, messages.TEXT_UNSELECTED])

        for start, end in self._charSetToRanges(newChars - oldChars):
            changes.append([start, end, messages.TEXT_SELECTED])

        if not changes:
            return False

        speakMessage = speakMessage and not cthulhu.cthulhuApp.settingsManager.getSetting('onlySpeakDisplayedText')
        maxFragments = 3
        for start, end, message in changes[:maxFragments]:
            string = AXText.get_substring(obj, start, end)
            endsWithChild = string.endswith(self.EMBEDDED_OBJECT_CHARACTER)
            if endsWithChild:
                end -= 1

            self._script.sayPhrase(obj, start, end)
            if speakMessage and not endsWithChild:
                self._script.speakMessage(message, interrupt=False)

            if endsWithChild:
                child = self.findChildAtOffset(obj, end)
                self.handleTextSelectionChange(child, speakMessage)

        remaining = changes[maxFragments:]
        if speakMessage and remaining:
            selectedCount = sum(1 for x in remaining if x[2] == messages.TEXT_SELECTED)
            unselectedCount = sum(1 for x in remaining if x[2] == messages.TEXT_UNSELECTED)
            if selectedCount and not unselectedCount:
                self._script.speakMessage(f"{selectedCount} more ranges selected", interrupt=False)
            elif unselectedCount and not selectedCount:
                self._script.speakMessage(f"{unselectedCount} more ranges unselected", interrupt=False)
            else:
                self._script.speakMessage(f"{len(remaining)} more selection changes", interrupt=False)

        return True

    def _getCtrlShiftSelectionsStrings(self) -> Any:
        """Hacky and to-be-obsoleted method."""
        return [messages.PARAGRAPH_SELECTED_DOWN,
                messages.PARAGRAPH_UNSELECTED_DOWN,
                messages.PARAGRAPH_SELECTED_UP,
                messages.PARAGRAPH_UNSELECTED_UP]

    def _speakTextSelectionState(self, nSelections: Any) -> bool:
        """Hacky and to-be-obsoleted method."""

        if cthulhu.cthulhuApp.settingsManager.getSetting('onlySpeakDisplayedText'):
            return False

        eventStr, mods = self.lastKeyAndModifiers()
        isControlKey = mods & keybindings.CTRL_MODIFIER_MASK
        isShiftKey = mods & keybindings.SHIFT_MODIFIER_MASK
        selectedText = nSelections > 0

        line = None
        if (eventStr == "Page_Down") and isShiftKey and isControlKey:
            line = messages.LINE_SELECTED_RIGHT
        elif (eventStr == "Page_Up") and isShiftKey and isControlKey:
            line = messages.LINE_SELECTED_LEFT
        elif (eventStr == "Page_Down") and isShiftKey and not isControlKey:
            if selectedText:
                line = messages.PAGE_SELECTED_DOWN
            else:
                line = messages.PAGE_UNSELECTED_DOWN
        elif (eventStr == "Page_Up") and isShiftKey and not isControlKey:
            if selectedText:
                line = messages.PAGE_SELECTED_UP
            else:
                line = messages.PAGE_UNSELECTED_UP
        elif (eventStr == "Down") and isShiftKey and isControlKey:
            strings = self._getCtrlShiftSelectionsStrings()
            if selectedText:
                line = strings[0]
            else:
                line = strings[1]
        elif (eventStr == "Up") and isShiftKey and isControlKey:
            strings = self._getCtrlShiftSelectionsStrings()
            if selectedText:
                line = strings[2]
            else:
                line = strings[3]
        elif (eventStr == "Home") and isShiftKey and isControlKey:
            if selectedText:
                line = messages.DOCUMENT_SELECTED_UP
            else:
                line = messages.DOCUMENT_UNSELECTED_UP
        elif (eventStr == "End") and isShiftKey and isControlKey:
            if selectedText:
                line = messages.DOCUMENT_SELECTED_DOWN
            else:
                line = messages.DOCUMENT_SELECTED_UP
        elif self.lastInputEventWasSelectAll() and selectedText:
            if not self._script.pointOfReference.get('entireDocumentSelected'):
                self._script.pointOfReference['entireDocumentSelected'] = True
                line = messages.DOCUMENT_SELECTED_ALL
            else:
                return True

        if not line:
            return False

        if line != self._script.pointOfReference.get('last-selection-message'):
            self._script.pointOfReference['last-selection-message'] = line
            self._script.speakMessage(line)

        return True

    def shouldInterruptForLocusOfFocusChange(self, oldLocusOfFocus: Any, newLocusOfFocus: Any, event: Any = None) -> bool:
        msg = "SCRIPT UTILITIES: Not interrupting for locusOfFocus change: "
        if event is None:
            msg += "event is None"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        if event is not None and event.type.startswith("object:active-descendant-changed"):
            return self._script.stopSpeechOnActiveDescendantChanged(event)

        if AXUtilities.is_table_cell(oldLocusOfFocus) and AXUtilities.is_text(newLocusOfFocus) \
           and AXUtilities.is_editable(newLocusOfFocus):
            msg += "suspected editable cell"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        if not AXUtilities.is_menu_related(newLocusOfFocus) \
           and (AXUtilities.is_check_menu_item(oldLocusOfFocus) \
                or AXUtilities.is_radio_menu_item(oldLocusOfFocus)):
            msg += "suspected menuitem state change"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        if AXObject.is_ancestor(newLocusOfFocus, oldLocusOfFocus):
            msg += "old locusOfFocus is ancestor of new locusOfFocus"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        if oldLocusOfFocus in AXUtilitiesRelation.get_is_controller_for(newLocusOfFocus):
            msg += "new locusOfFocus controls old locusOfFocus"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False
        if newLocusOfFocus in AXUtilitiesRelation.get_is_controller_for(oldLocusOfFocus):
            msg += "old locusOfFocus controls new locusOfFocus"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False
        return True

    def stringsAreRedundant(self, str1: Any, str2: Any, threshold: Any = 0.5) -> bool:
        if not (str1 and str2):
            return False

        similarity = round(SequenceMatcher(None, str1.lower(), str2.lower()).ratio(), 2)
        msg = (
            f"SCRIPT UTILITIES: Similarity between '{str1}', '{str2}': {similarity} "
            f"(threshold: {threshold})"
        )
        debug.printMessage(debug.LEVEL_INFO, msg, True)
        return similarity >= threshold
