# Orca
#
# Copyright 2010 Joanmarie Diggs.
# Copyright 2014-2015 Igalia, S.L.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA  02110-1301 USA.

# pylint: disable=too-many-lines
# pylint: disable=too-many-instance-attributes
# pylint: disable=too-many-boolean-expressions
# pylint: disable=too-many-branches
# pylint: disable=too-many-locals
# pylint: disable=too-many-public-methods
# pylint: disable=too-many-statements
# pylint: disable=too-many-return-statements

"""Utilities for providing information about objects and events in web content."""

from __future__ import annotations

import functools
import re
import time
import urllib
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any

import gi

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

from orca import (
    ax_cache_manager,
    caret_navigator,
    debug,
    document_presenter,
    focus_manager,
    input_event_manager,
    script_utilities,
    speech_presenter,
)
from orca.ax_component import AXComponent
from orca.ax_hypertext import AXHypertext
from orca.ax_object import AXObject
from orca.ax_text import AXText
from orca.ax_utilities import AXUtilities
from orca.ax_utilities_debugging import AXUtilitiesDebugging
from orca.ax_utilities_hypertext import CaretPolicy

if TYPE_CHECKING:
    from collections.abc import Hashable, Iterator

    from orca.ax_utilities_text import CaretSetReason

    from .script import Script


class _WebUtilitiesCache:
    """Provides web-utility access to manager-backed cached values."""

    IN_DOCUMENT_CONTENT = "WebUtilities.in-document-content"
    IS_DOCUMENT = "WebUtilities.is-document"
    IS_TOP_LEVEL_DOCUMENT = "WebUtilities.is-top-level-document"
    DOCUMENT_FOR_OBJECT = "WebUtilities.document-for-object"
    TOP_LEVEL_DOCUMENT_FOR_OBJECT = "WebUtilities.top-level-document-for-object"
    IS_CONTENT_EDITABLE_WITH_EMBEDDED_OBJECTS = (
        "WebUtilities.is-content-editable-with-embedded-objects"
    )
    HAS_GRID_DESCENDANT = "WebUtilities.has-grid-descendant"
    IS_OFF_SCREEN_LABEL = "WebUtilities.is-off-screen-label"
    LINES_ARE_SINGLE_CHARS = "WebUtilities.lines-are-single-chars"
    LINES_ARE_SINGLE_WORDS = "WebUtilities.lines-are-single-words"
    IS_CLICKABLE_ELEMENT = "WebUtilities.is-clickable-element"
    IS_LINK = "WebUtilities.is-link"
    IS_USELESS_IMAGE = "WebUtilities.is-useless-image"
    IS_REDUNDANT_SVG = "WebUtilities.is-redundant-svg"
    IS_USELESS_EMPTY_ELEMENT = "WebUtilities.is-useless-empty-element"
    HAS_NAME_AND_ACTION_AND_NO_USEFUL_CHILDREN = (
        "WebUtilities.has-name-and-action-and-no-useful-children"
    )
    WRAPS_ONLY_SKIPPABLE_ELEMENTS = "WebUtilities.wraps-only-skippable-elements"
    INFERRED_LABEL = "WebUtilities.inferred-label"
    SHOULD_FILTER = "WebUtilities.should-filter"
    SHOULD_INFER_LABEL_FOR = "WebUtilities.should-infer-label-for"
    TREAT_AS_TEXT_OBJECT = "WebUtilities.treat-as-text-object"
    TREAT_AS_DIV = "WebUtilities.treat-as-div"
    OBJECT_CONTENTS = "WebUtilities.object-contents"
    SENTENCE_CONTENTS = "WebUtilities.sentence-contents"
    LINE_CONTENTS = "WebUtilities.line-contents"
    WORD_CONTENTS = "WebUtilities.word-contents"
    CHARACTER_CONTENTS = "WebUtilities.character-contents"
    CAN_HAVE_CARET_CONTEXT = "WebUtilities.can-have-caret-context"
    CARET_CONTEXTS = "WebUtilities.caret-contexts"
    PRIOR_CONTEXTS = "WebUtilities.prior-contexts"
    FIND_CONTAINER = "WebUtilities.find-container"

    OBJECT_DECISION_NAMESPACES = (
        IN_DOCUMENT_CONTENT,
        IS_DOCUMENT,
        IS_TOP_LEVEL_DOCUMENT,
        DOCUMENT_FOR_OBJECT,
        TOP_LEVEL_DOCUMENT_FOR_OBJECT,
        IS_CONTENT_EDITABLE_WITH_EMBEDDED_OBJECTS,
        HAS_GRID_DESCENDANT,
        IS_OFF_SCREEN_LABEL,
        LINES_ARE_SINGLE_CHARS,
        LINES_ARE_SINGLE_WORDS,
        IS_CLICKABLE_ELEMENT,
        IS_LINK,
        IS_USELESS_IMAGE,
        IS_REDUNDANT_SVG,
        IS_USELESS_EMPTY_ELEMENT,
        HAS_NAME_AND_ACTION_AND_NO_USEFUL_CHILDREN,
        WRAPS_ONLY_SKIPPABLE_ELEMENTS,
        INFERRED_LABEL,
        SHOULD_FILTER,
        SHOULD_INFER_LABEL_FOR,
        TREAT_AS_TEXT_OBJECT,
        TREAT_AS_DIV,
    )

    CONTENT_NAMESPACES = (
        OBJECT_CONTENTS,
        SENTENCE_CONTENTS,
        LINE_CONTENTS,
        WORD_CONTENTS,
        CHARACTER_CONTENTS,
    )

    TEMPORARY_CONTEXT_NAMESPACES = (CAN_HAVE_CARET_CONTEXT,)

    _CONTENT_KEY = "contents"
    _CONTEXTS_KEY = "contexts"
    _FIND_CONTAINER_KEY = "container"

    def __init__(self) -> None:
        self._manager = ax_cache_manager.get_manager()
        for namespace in (
            self.OBJECT_DECISION_NAMESPACES
            + self.CONTENT_NAMESPACES
            + self.TEMPORARY_CONTEXT_NAMESPACES
            + (
                self.CARET_CONTEXTS,
                self.PRIOR_CONTEXTS,
                self.FIND_CONTAINER,
            )
        ):
            self._manager.register_cache(
                self,
                namespace,
                lifetime=ax_cache_manager.Lifetime.OWNER,
                clear_on_demand=ax_cache_manager.ClearPolicy.PRESERVE,
                clear_interval_seconds=None,
            )
        self._caches = {
            namespace: self._manager.get_cache(self, namespace)
            for namespace in (
                self.OBJECT_DECISION_NAMESPACES
                + self.CONTENT_NAMESPACES
                + self.TEMPORARY_CONTEXT_NAMESPACES
                + (
                    self.CARET_CONTEXTS,
                    self.PRIOR_CONTEXTS,
                    self.FIND_CONTAINER,
                )
            )
        }

    def get_for_object(
        self, namespace: str, obj: Atspi.Accessible, default: Any = ax_cache_manager.MISSING
    ) -> Any:
        """Returns a cached value for obj, or default on a miss."""

        cache = self._caches.get(namespace)
        if cache is None:
            return default

        value = cache.get(ax_cache_manager.get_object_key(obj), default)
        if isinstance(value, list):
            return list(value)
        return value

    def set_for_object(self, namespace: str, obj: Atspi.Accessible, value: object) -> None:
        """Stores a cached value for obj."""

        cache = self._caches.get(namespace)
        if cache is not None:
            if isinstance(value, list):
                value = list(value)
            cache.put(ax_cache_manager.get_object_key(obj), value)

    def clear_object_decisions(self, reason: str = "") -> None:
        """Clears cached web object decisions."""

        for namespace in self.OBJECT_DECISION_NAMESPACES:
            if cache := self._caches.get(namespace):
                cache.invalidate(reason)

    def clear_namespace(self, namespace: str, reason: str = "") -> None:
        """Clears one cached web namespace."""

        if cache := self._caches.get(namespace):
            cache.invalidate(reason)

    def get_content(
        self,
        namespace: str,
    ) -> list[tuple[Atspi.Accessible, int, int, str]] | None:
        """Returns cached web contents for namespace."""

        cache = self._caches.get(namespace)
        if cache is None:
            return None

        contents = cache.get(self._CONTENT_KEY, ax_cache_manager.MISSING)
        if contents is ax_cache_manager.MISSING:
            return None
        return list(contents)

    def set_content(
        self,
        namespace: str,
        contents: list[tuple[Atspi.Accessible, int, int, str]],
    ) -> None:
        """Stores web contents for namespace."""

        cache = self._caches.get(namespace)
        if cache is not None:
            cache.put(self._CONTENT_KEY, list(contents))

    def clear_content(self, reason: str = "") -> None:
        """Clears cached web contents."""

        for namespace in self.CONTENT_NAMESPACES:
            if cache := self._caches.get(namespace):
                cache.invalidate(reason)

    def get_caret_context_decision(self, obj: Atspi.Accessible) -> bool | None:
        """Returns the cached caret-context decision for obj."""

        cache = self._caches.get(self.CAN_HAVE_CARET_CONTEXT)
        if cache is None:
            return None

        return cache.get(ax_cache_manager.get_object_key(obj), None)

    def set_caret_context_decision(self, obj: Atspi.Accessible, decision: bool) -> None:
        """Stores the caret-context decision for obj."""

        cache = self._caches.get(self.CAN_HAVE_CARET_CONTEXT)
        if cache is not None:
            cache.put(ax_cache_manager.get_object_key(obj), decision)

    def clear_caret_context_decisions(self, reason: str = "") -> None:
        """Clears cached caret-context decisions."""

        cache = self._caches.get(self.CAN_HAVE_CARET_CONTEXT)
        if cache is not None:
            cache.invalidate(reason)

    def _get_contexts(self, namespace: str) -> dict[Hashable, tuple[Atspi.Accessible | None, int]]:
        """Returns a copy of the cached context map."""

        cache = self._caches.get(namespace)
        if cache is None:
            return {}

        return dict(cache.get(self._CONTEXTS_KEY, {}))

    def get_context_for_parent(
        self,
        namespace: str,
        parent: Atspi.Accessible | None,
    ) -> tuple[Atspi.Accessible | None, int] | None:
        """Returns the cached context for parent."""

        return self._get_contexts(namespace).get(ax_cache_manager.get_object_key(parent))

    def set_context_for_parent(
        self,
        namespace: str,
        parent: Atspi.Accessible | None,
        context: tuple[Atspi.Accessible | None, int],
    ) -> None:
        """Stores the context for parent."""

        contexts = self._get_contexts(namespace)
        contexts[ax_cache_manager.get_object_key(parent)] = context
        cache = self._caches.get(namespace)
        if cache is not None:
            cache.put(self._CONTEXTS_KEY, contexts)

    def discard_context_for_parent(
        self,
        namespace: str,
        parent: Atspi.Accessible | None,
    ) -> None:
        """Removes the cached context for parent."""

        contexts = self._get_contexts(namespace)
        contexts.pop(ax_cache_manager.get_object_key(parent), None)
        cache = self._caches.get(namespace)
        if cache is not None:
            cache.put(self._CONTEXTS_KEY, contexts)

    def get_caret_contexts(self) -> dict[Hashable, tuple[Atspi.Accessible | None, int]]:
        """Returns cached caret contexts."""

        return self._get_contexts(self.CARET_CONTEXTS)

    def replace_caret_contexts(
        self,
        contexts: dict[Hashable, tuple[Atspi.Accessible | None, int]],
    ) -> None:
        """Replaces cached caret contexts."""

        cache = self._caches.get(self.CARET_CONTEXTS)
        if cache is not None:
            cache.put(self._CONTEXTS_KEY, contexts)

    def clear_prior_contexts(self, reason: str = "") -> None:
        """Clears cached prior caret contexts."""

        cache = self._caches.get(self.PRIOR_CONTEXTS)
        if cache is not None:
            cache.invalidate(reason)

    def get_find_container(self) -> Atspi.Accessible | None:
        """Returns the cached find-in-page container."""

        cache = self._caches.get(self.FIND_CONTAINER)
        if cache is None:
            return None

        return cache.get(self._FIND_CONTAINER_KEY, None)

    def set_find_container(self, container: Atspi.Accessible) -> None:
        """Stores the find-in-page container."""

        cache = self._caches.get(self.FIND_CONTAINER)
        if cache is not None:
            cache.put(self._FIND_CONTAINER_KEY, container)


class Utilities(script_utilities.Utilities):
    """Utilities for providing information about objects and events in web content."""

    def __init__(self, script: Script) -> None:
        super().__init__(script)
        self._cache = _WebUtilitiesCache()
        self.caret_policy = CaretPolicy(
            can_have_caret_context=self._can_have_caret_context,
            treat_as_text_object=self.treat_as_text_object,
            treat_as_whole=self._treat_object_as_whole,
            in_document_content=self.in_document_content,
            is_boundary=self.is_top_level_document,
            is_text_block_element=self.is_text_block_element,
        )
        self._selection_anchor_and_focus: tuple[
            Atspi.Accessible | None, Atspi.Accessible | None
        ] = (
            None,
            None,
        )
        self._valid_child_roles: dict[Atspi.Role, list[Atspi.Role]] = {
            Atspi.Role.LIST: [Atspi.Role.LIST_ITEM],
        }

    def _cleanup_contexts(self) -> None:
        contexts = self._cache.get_caret_contexts()
        contexts = {
            key: context for key, context in contexts.items() if AXObject.is_valid(context[0])
        }
        self._cache.replace_caret_contexts(contexts)

    def dump_cache(
        self,
        document: Atspi.Accessible | None = None,
        preserve_context: bool = False,
    ) -> None:
        """Dumps all cached information about objects, and by default the caret context."""

        if not AXObject.is_valid(document):
            document = self.active_document()

        document_parent = AXObject.get_parent(document)
        context = self._cache.get_context_for_parent(
            self._cache.CARET_CONTEXTS,
            document_parent,
        )
        tokens = [
            "WEB: Clearing all cached info for",
            document,
            "Preserving context:",
            preserve_context,
            "Context:",
            context,
        ]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        self.clear_caret_context(document)
        self.clear_cached_objects()

        if preserve_context and context:
            tokens = ["WEB: Preserving context of", context[0], ",", context[1]]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            self._cache.set_context_for_parent(
                self._cache.CARET_CONTEXTS,
                document_parent,
                context,
            )

    def clear_cached_objects(self) -> None:
        """Clears cached object details."""

        # TODO - JD: Should callers instead call dump_cache with preserve_context=True?

        debug.print_message(debug.LEVEL_INFO, "WEB: cleaning up cached objects", True)
        self._cache.clear_object_decisions("web clear cached objects")
        self._cache.clear_caret_context_decisions("web clear cached objects")
        self._cleanup_contexts()
        self._cache.clear_prior_contexts("web clear cached objects")

    def clear_content_cache(self) -> None:
        """Clears the cached line, word, object, character contents."""

        self._cache.clear_content("web clear content cache")

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

        namespace = self._cache.IS_DOCUMENT
        cached = self._cache.get_for_object(namespace, obj)
        if cached is not ax_cache_manager.MISSING:
            return cached

        role = AXObject.get_role(obj)
        rv = AXUtilities.is_document_web(obj, role) or AXUtilities.is_embedded(obj, role)
        self._cache.set_for_object(namespace, obj, rv)
        return rv

    def in_document_content(self, obj: Atspi.Accessible | None = None) -> bool:
        if not obj:
            obj = focus_manager.get_manager().get_locus_of_focus()

        if self.is_document(obj):
            return True

        namespace = self._cache.IN_DOCUMENT_CONTENT
        cached = self._cache.get_for_object(namespace, obj)
        if cached is not ax_cache_manager.MISSING:
            return cached

        document = self.get_document_for_object(obj)
        rv = document is not None
        self._cache.set_for_object(namespace, obj, rv)
        return rv

    def get_document_for_object(self, obj: Atspi.Accessible) -> Atspi.Accessible | None:
        """Returns the nearest document ancestor of obj, or obj if it is a document."""

        namespace = self._cache.DOCUMENT_FOR_OBJECT
        cached = self._cache.get_for_object(namespace, obj)
        if cached is not ax_cache_manager.MISSING:
            return cached

        rv = super().get_document_for_object(obj)
        self._cache.set_for_object(namespace, obj, rv)
        return rv

    def is_top_level_document(self, obj: Atspi.Accessible) -> bool:
        """Returns true if obj is a top-level document."""

        namespace = self._cache.IS_TOP_LEVEL_DOCUMENT
        cached = self._cache.get_for_object(namespace, obj)
        if cached is not ax_cache_manager.MISSING:
            return cached

        rv = super().is_top_level_document(obj)
        self._cache.set_for_object(namespace, obj, rv)
        return rv

    def get_top_level_document_for_object(
        self,
        obj: Atspi.Accessible,
    ) -> Atspi.Accessible | None:
        """Returns the top-level document containing obj."""

        namespace = self._cache.TOP_LEVEL_DOCUMENT_FOR_OBJECT
        cached = self._cache.get_for_object(namespace, obj)
        if cached is not ax_cache_manager.MISSING:
            return cached

        rv = super().get_top_level_document_for_object(obj)
        self._cache.set_for_object(namespace, obj, rv)
        return rv

    def grab_focus_when_setting_caret(self, obj: Atspi.Accessible) -> bool:
        """Returns true if we should grab focus when setting the caret."""

        # To avoid triggering popup lists.
        if AXUtilities.is_entry(obj):
            return False

        if AXUtilities.is_image(obj):
            return AXUtilities.find_ancestor(obj, AXUtilities.is_link) is not None

        if AXUtilities.is_heading(obj) and AXObject.get_child_count(obj) == 1:
            return self.is_link(AXObject.get_child(obj, 0))

        return AXUtilities.is_focusable(obj)

    def set_caret_position(
        self,
        obj: Atspi.Accessible,
        offset: int,
        document: Atspi.Accessible | None = None,
        *,
        reason: CaretSetReason,
    ) -> None:
        grab_focus = self.grab_focus_when_setting_caret(obj)

        obj, offset = self.first_context(obj, offset)
        self.set_caret_context(obj, offset, document)

        old_focus = focus_manager.get_manager().get_locus_of_focus()
        AXUtilities.clear_all_selected_text(old_focus)
        focus_manager.get_manager().set_locus_of_focus(None, obj, notify_script=False)
        if grab_focus:
            AXObject.grab_focus(obj)

        AXUtilities.set_caret_offset_with_reason(obj, offset, reason)

        # If we return earlier than here, braille cursor routing fails in sticky focus mode.
        presenter = document_presenter.get_presenter()
        if presenter.focus_mode_is_sticky(self._script.app):
            return

        if presenter.use_focus_mode(obj, old_focus) != presenter.in_focus_mode(self._script.app):
            presenter.toggle_presentation_mode(self._script)

    def in_find_container(self, obj: Atspi.Accessible | None = None) -> bool:
        """Returns True if obj is in a find-in-page container."""

        if not obj:
            obj = focus_manager.get_manager().get_locus_of_focus()

        if not obj or self.in_document_content(obj):
            return False

        if not (AXUtilities.is_entry(obj) or AXUtilities.is_push_button(obj)):
            return False

        # Gecko: tag=findbar, Chromium: class=FindBarView
        def is_find_bar(x: Atspi.Accessible) -> bool:
            return (
                AXObject.get_attribute(x, "tag") == "findbar"
                or AXObject.get_attribute(x, "class") == "FindBarView"
            )

        container = AXUtilities.find_ancestor(obj, is_find_bar)
        if container is not None and container == self._cache.get_find_container():
            return True

        if container:
            tokens = ["WEB:", obj, "believed to be find-in-page widget"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            self._cache.set_find_container(container)
            return True

        return False

    def get_find_results_count(self, root: Atspi.Accessible | None = None) -> str:
        """Returns a string description of the number of find-in-page results in root."""

        root = root or self._cache.get_find_container()
        if not root:
            return ""

        def has_result_count(x: Atspi.Accessible) -> bool:
            return len(re.findall(r"\d+", AXObject.get_name(x))) == 2

        # Gecko uses a label, Chromium uses a status bar.
        matches = AXUtilities.find_all_with_role(
            root, [Atspi.Role.LABEL, Atspi.Role.STATUS_BAR], has_result_count
        )
        if len(matches) != 1:
            return ""

        match = matches[0]
        AXObject.clear_cache(match, False, "Ensuring we have correct name for find results.")
        return AXObject.get_name(match)

    def set_caret_offset(
        self, obj: Atspi.Accessible, offset: int, *, reason: CaretSetReason
    ) -> None:
        """Sets the caret offset via AtspiText."""

        # TODO - JD: Audit callers and see if this can be merged into the default logic.

        self.set_caret_position(obj, offset, reason=reason)
        self._script.update_braille(obj)

    def next_context(
        self,
        obj: Atspi.Accessible | None = None,
        offset: int = -1,
        skip_space: bool = False,
        restrict_to: Atspi.Accessible | None = None,  # pylint: disable=unused-argument
    ) -> tuple[Atspi.Accessible | None, int]:
        """Returns the next viable/valid caret context given obj and offset."""

        if obj is None:
            obj, offset = self.get_caret_context()

        next_obj, next_offset = AXUtilities.find_next_context(obj, offset, self.caret_policy)
        if skip_space:
            seen = {(next_obj, next_offset)}
            while (
                self.treat_as_text_object(next_obj)
                and AXText.get_character_at_offset(next_obj, next_offset)[0].isspace()
            ):
                next_obj, next_offset = AXUtilities.find_next_context(
                    next_obj, next_offset, self.caret_policy
                )
                if (next_obj, next_offset) in seen:
                    msg = "WEB: Cycle detected in next_context skip_space. Breaking."
                    debug.print_message(debug.LEVEL_INFO, msg, True)
                    break
                seen.add((next_obj, next_offset))

        return next_obj, next_offset

    def previous_context(
        self,
        obj: Atspi.Accessible | None = None,
        offset: int = -1,
        skip_space: bool = False,
        restrict_to: Atspi.Accessible | None = None,  # pylint: disable=unused-argument
    ) -> tuple[Atspi.Accessible | None, int]:
        """Returns the previous viable/valid caret context given obj and offset."""

        if obj is None:
            obj, offset = self.get_caret_context()

        prev_obj, prev_offset = AXUtilities.find_previous_context(obj, offset, self.caret_policy)
        if skip_space:
            seen = {(prev_obj, prev_offset)}
            while (
                self.treat_as_text_object(prev_obj)
                and AXText.get_character_at_offset(prev_obj, prev_offset)[0].isspace()
            ):
                prev_obj, prev_offset = AXUtilities.find_previous_context(
                    prev_obj, prev_offset, self.caret_policy
                )
                if (prev_obj, prev_offset) in seen:
                    msg = "WEB: Cycle detected in previous_context skip_space. Breaking."
                    debug.print_message(debug.LEVEL_INFO, msg, True)
                    break
                seen.add((prev_obj, prev_offset))

        return prev_obj, prev_offset

    def last_context(self, root: Atspi.Accessible) -> tuple[Atspi.Accessible, int]:
        """Returns the last viable/valid caret context in root."""

        offset = 0
        if self.treat_as_text_object(root):
            offset = AXText.get_character_count(root) - 1

        def _is_in_root(o: Atspi.Accessible) -> bool:
            return o == root or AXUtilities.find_ancestor(o, lambda x: x == root) is not None

        obj = root
        while obj:
            last_obj, last_offset = self.next_context(obj, offset)
            if not (last_obj and _is_in_root(last_obj)):
                break
            obj, offset = last_obj, last_offset

        return obj, offset

    def _get_extents(
        self,
        obj: Atspi.Accessible | None,
        start_offset: int,
        end_offset: int,
    ) -> Atspi.Rect:
        """Returns the extents for the text range, or the component rect as fallback."""

        if not obj:
            return Atspi.Rect()

        if self.treat_as_text_object(obj) and 0 <= start_offset < end_offset:
            rect = AXText.get_range_rect(obj, start_offset, end_offset)
            if not (
                rect.x
                and rect.y
                and rect.width == 0
                and rect.height == 0
                and AXText.get_substring(obj, start_offset, end_offset).strip()
            ):
                return rect

            tokens = [
                "WEB: Suspected bogus range extents for",
                obj,
                "(chars:",
                start_offset,
                ",",
                end_offset,
                "):",
                rect,
            ]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        parent = AXObject.get_parent(obj)
        if (AXUtilities.is_menu(obj) or AXUtilities.is_list_item(obj)) and (
            AXUtilities.is_combo_box(parent) or AXUtilities.is_list_box(parent)
        ):
            return AXComponent.get_rect(parent)

        # A non-text embedded object reports an absolute Component rect, but Chromium's
        # text-range extents are web-area-relative; use its parent-relative embedded-character
        # rect so both sides of a same-line comparison share one coordinate space. Firefox can
        # briefly collapse that height for an image mid-relayout, so use the image's own box height
        # when it is taller.
        offset = AXHypertext.get_character_offset_in_parent(obj)
        if offset >= 0:
            rect = AXText.get_range_rect(parent, offset, offset + 1)
            if rect.width and rect.height:
                if AXUtilities.is_image_or_canvas(obj):
                    component = AXComponent.get_rect(obj)
                    rect.height = max(rect.height, component.height)
                return rect

        return AXComponent.get_rect(obj)

    def expand_eocs(
        self,
        obj: Atspi.Accessible,
        start_offset: int = 0,
        end_offset: int = -1,
    ) -> str:
        """Expands the current object replacing embedded object characters with their text."""

        if not self.in_document_content(obj):
            return super().expand_eocs(obj, start_offset, end_offset)

        if self._has_grid_descendant(obj):
            tokens = ["WEB: not expanding EOCs:", obj, "has grid descendant"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return ""

        return self._expand_eocs_for_child(obj, start_offset, end_offset)

    def _expand_eocs_for_child(
        self,
        obj: Atspi.Accessible,
        start_offset: int = 0,
        end_offset: int = -1,
    ) -> str:
        """Expands obj, whose document and grid-descendant status the caller has established."""

        if not self.treat_as_text_object(obj):
            return ""

        if AXUtilities.is_math(obj) and AXObject.get_child_count(obj):
            return speech_presenter.get_presenter().generate_speech_string(self._script, obj)

        return super().expand_eocs(obj, start_offset, end_offset)

    def _adjust_contents_for_language(
        self,
        contents: list[tuple[Atspi.Accessible, int, int, str]],
    ) -> list[tuple[Atspi.Accessible, int, int, str]]:
        rv = []
        for content in contents:
            split = self.split_substring_by_language(*content[0:3])
            for start, end, string, _language, _dialect in split:
                rv.append((content[0], start, end, string))

        return rv

    def get_language_and_dialect_from_text_attributes(
        self,
        obj: Atspi.Accessible | None,
        start_offset: int = 0,
        end_offset: int = -1,
    ) -> list[tuple[int, int, str, str]]:
        rv = super().get_language_and_dialect_from_text_attributes(obj, start_offset, end_offset)
        if rv or obj is None:
            return rv

        # Embedded objects such as images and certain widgets won't implement the text interface
        # and thus won't expose text attributes. Therefore try to get the info from the parent.
        parent = AXObject.get_parent(obj)
        if parent is None or not self.in_document_content(parent):
            return rv

        start = AXHypertext.get_link_start_offset(obj)
        end = AXHypertext.get_link_end_offset(obj)
        language, dialect = self.get_language_and_dialect_for_substring(parent, start, end)
        rv.append((0, 1, language, dialect))

        return rv

    def find_object_in_contents(
        self,
        obj: Atspi.Accessible | None,
        offset: int,
        contents: list[tuple[Atspi.Accessible, int, int, str]],
        use_cache: bool = False,
    ) -> int:
        """Returns the index of obj + offset in contents."""

        if not obj or not contents:
            return -1

        offset = max(0, offset)
        matches = [x for x in contents if x[0] == obj]
        match = [x for x in matches if x[1] <= offset < x[2]]
        if match and match[0] and match[0] in contents:
            return contents.index(match[0])
        if not use_cache:
            match = [x for x in matches if offset == x[2]]
            if match and match[0] and match[0] in contents:
                return contents.index(match[0])

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

        child = AXUtilities.find_child_at_offset(obj, offset)
        if child and not self.is_text_block_element(child):
            matches = [x for x in contents if x[0] == child]
            if len(matches) == 1:
                return contents.index(matches[0])

        return -1

    def treat_as_text_object(self, obj: Atspi.Accessible | None) -> bool:
        """Returns True if obj should be treated as a text object."""

        if not obj or AXObject.is_dead(obj):
            return False

        namespace = self._cache.TREAT_AS_TEXT_OBJECT
        cached = self._cache.get_for_object(namespace, obj)
        if cached is not ax_cache_manager.MISSING:
            return cached

        if not AXObject.supports_text(obj):
            return False

        if not self.in_document_content(
            obj,
        ) or document_presenter.get_presenter().browse_mode_is_sticky(self._script.app):
            return True

        if AXUtilities.is_live_region(obj):
            return True

        rv = AXText.get_character_count(obj) > 0 or AXUtilities.is_editable(obj)
        if (
            rv
            and self._treat_object_as_whole(obj, -1)
            and AXObject.get_name(obj)
            and not self._is_cell_with_name_from_header(obj)
        ):
            tokens = ["WEB: Treating", obj, "as non-text: named object treated as whole."]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            rv = False

        elif rv:
            not_text_roles = [
                Atspi.Role.LIST_BOX,
                Atspi.Role.TABLE,
                Atspi.Role.TABLE_ROW,
                Atspi.Role.MATH,
            ]
            role = AXObject.get_role(obj)
            if rv and role in not_text_roles:
                tokens = ["WEB: Treating", obj, "as non-text due to role."]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                rv = False
            if rv and self._is_off_screen_label(obj):
                tokens = ["WEB: Treating", obj, "as non-text: is off-screen label."]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                rv = False
            if rv and self._is_non_navigable_embedded_document(obj):
                tokens = ["WEB: Treating", obj, "as non-text: is non-navigable embedded document."]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                rv = False
            if rv and self._is_fake_placeholder_for_entry(obj):
                tokens = ["WEB: Treating", obj, "as non-text: is fake placeholder for entry."]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                rv = False

        self._cache.set_for_object(namespace, obj, rv)
        return rv

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

        namespace = self._cache.HAS_NAME_AND_ACTION_AND_NO_USEFUL_CHILDREN
        cached = self._cache.get_for_object(namespace, obj)
        if cached is not ax_cache_manager.MISSING:
            return cached

        rv = False
        if AXUtilities.has_explicit_name(obj) and AXObject.supports_action(obj):
            for child in AXObject.iter_children(obj):
                if not (self._is_useless_empty_element(child) or self._is_useless_image(child)):
                    break
            else:
                rv = True

        if rv:
            tokens = ["WEB:", obj, "has name and action and no useful children"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        self._cache.set_for_object(namespace, obj, rv)
        return rv

    def _wraps_only_skippable_elements(self, obj: Atspi.Accessible) -> bool:
        """Returns True if obj contains only named sections and otherwise useless elements."""

        namespace = self._cache.WRAPS_ONLY_SKIPPABLE_ELEMENTS
        cached = self._cache.get_for_object(namespace, obj)
        if cached is not ax_cache_manager.MISSING:
            return cached

        rv = False
        for child in AXObject.iter_children(obj):
            if self._is_useless_empty_element(child) or self._is_useless_image(child):
                continue
            if AXUtilities.is_section(child) and self.has_name_and_action_and_no_useful_children(
                child
            ):
                rv = True
                continue
            rv = False
            break

        self._cache.set_for_object(namespace, obj, rv)
        return rv

    def _treat_object_as_whole(self, obj: Atspi.Accessible, offset: int | None = None) -> bool:
        always = [
            Atspi.Role.BUTTON,
            Atspi.Role.CHECK_BOX,
            Atspi.Role.CHECK_MENU_ITEM,
            Atspi.Role.LIST_BOX,
            Atspi.Role.MATH,
            Atspi.Role.MENU_ITEM,
            Atspi.Role.PAGE_TAB,
            Atspi.Role.RADIO_MENU_ITEM,
            Atspi.Role.RADIO_BUTTON,
            Atspi.Role.TOGGLE_BUTTON,
        ]

        descendable = [
            Atspi.Role.MENU,
            Atspi.Role.MENU_BAR,
            Atspi.Role.TOOL_BAR,
            Atspi.Role.TREE_ITEM,
        ]

        role = AXObject.get_role(obj)
        if role in always:
            return True

        if role in descendable:
            if document_presenter.get_presenter().in_focus_mode(self._script.app):
                return True

            # This should cause us to initially stop at the large containers before
            # allowing the user to drill down into them in browse mode.
            return offset == -1

        if role == Atspi.Role.ENTRY:
            return AXObject.get_child_count(obj) == 1 and self._is_fake_placeholder_for_entry(
                AXObject.get_child(obj, 0),
            )

        if AXUtilities.is_editable(obj):
            return False

        if role == Atspi.Role.TABLE_CELL:
            presenter = document_presenter.get_presenter()
            if presenter.is_focus_mode_widget(self._script, obj):
                return not presenter.browse_mode_is_sticky(self._script.app)
            if self.has_name_and_action_and_no_useful_children(obj):
                return True

        if role in [
            Atspi.Role.COLUMN_HEADER,
            Atspi.Role.ROW_HEADER,
        ] and AXUtilities.has_explicit_name(obj):
            return True

        if role == Atspi.Role.COMBO_BOX:
            return True

        if role in [Atspi.Role.EMBEDDED, Atspi.Role.TREE, Atspi.Role.TREE_TABLE]:
            return not document_presenter.get_presenter().browse_mode_is_sticky(self._script.app)

        if role == Atspi.Role.LINK:
            return (
                AXUtilities.has_explicit_name(obj)
                or self.has_useless_canvas_descendant(obj)
                or self._wraps_only_skippable_elements(obj)
            )

        if self._is_non_navigable_embedded_document(obj):
            return True

        if self._is_fake_placeholder_for_entry(obj):
            return True

        if AXUtilities.is_custom_image(obj):
            return True

        # Example: Some StackExchange instances have a focusable "note"/comment role
        # with a name (e.g. "Accepted"), and a single child div which is empty.
        if (
            AXUtilities.is_text_block(obj, role)
            and AXUtilities.is_focusable(obj)
            and AXUtilities.has_explicit_name(obj)
        ):
            for child in AXObject.iter_children(obj):
                if not self._is_useless_empty_element(child):
                    return False
            return True

        return False

    def _get_text_at_offset(
        self,
        obj: Atspi.Accessible,
        offset: int,
        granularity: Atspi.TextGranularity,
    ) -> tuple[str, int, int]:
        def string_for_debug(x):
            return x.replace("\ufffc", "[OBJ]").replace("\n", "\\n")

        if not obj:
            tokens = [
                "WEB:",
                granularity,
                f"at offset {offset} for",
                obj,
                ":",
                "'', Start: 0, End: 0. (obj is None)",
            ]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return "", 0, 0

        if not self.treat_as_text_object(obj):
            tokens = [
                "WEB:",
                granularity,
                f"at offset {offset} for",
                obj,
                ":",
                "'', Start: 0, End: 1. (treat_as_text_object() returned False)",
            ]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return "", 0, 1

        if granularity is None:
            all_text = AXText.get_all_text(obj)
            string, start, end = all_text, 0, len(all_text)
            s = string_for_debug(string)
            tokens = [
                "WEB:",
                granularity,
                f"at offset {offset} for",
                obj,
                ":",
                f"'{s}', Start: {start}, End: {end}.",
            ]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return string, start, end

        if granularity == Atspi.TextGranularity.SENTENCE and not AXUtilities.is_editable(obj):
            all_text = AXText.get_all_text(obj)
            if AXObject.get_role(obj) in [Atspi.Role.LIST_ITEM, Atspi.Role.HEADING] or not (
                re.search(r"\w", all_text) and self.is_text_block_element(obj)
            ):
                string, start, end = all_text, 0, len(all_text)
                s = string_for_debug(string)
                tokens = [
                    "WEB:",
                    granularity,
                    f"at offset {offset} for",
                    obj,
                    ":",
                    f"'{s}', Start: {start}, End: {end}.",
                ]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                return string, start, end

        if granularity == Atspi.TextGranularity.LINE and self.treat_as_end_of_line(obj, offset):
            offset -= 1
            tokens = [
                "WEB: Line sought for",
                obj,
                "at end of text. Adjusting offset to",
                offset,
                ".",
            ]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        offset = max(0, offset)
        if granularity == Atspi.TextGranularity.LINE:
            string, start, end = AXText.get_line_at_offset(obj, offset)
        elif granularity == Atspi.TextGranularity.SENTENCE:
            string, start, end = AXText.get_sentence_at_offset(obj, offset)
        elif granularity == Atspi.TextGranularity.WORD:
            string, start, end = AXText.get_word_at_offset(
                obj, offset, ensure_whole_characters=True
            )
        elif granularity == Atspi.TextGranularity.CHAR:
            string, start, end = AXText.get_character_at_offset(
                obj, offset, ensure_whole_characters=True
            )
        else:
            string, start, end = AXText.get_line_at_offset(obj, offset)

        s = string_for_debug(string)
        tokens = [
            "WEB:",
            granularity,
            f"at offset {offset} for",
            obj,
            ":",
            f"'{s}', Start: {start}, End: {end}.",
        ]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)
        return string, start, end

    def _get_contents_for_obj(
        self,
        obj: Atspi.Accessible,
        offset: int,
        granularity: Atspi.TextGranularity,
    ) -> list[tuple[Atspi.Accessible, int, int, str]]:
        tokens = ["WEB: Attempting to get contents for", obj, granularity]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)
        if not obj:
            return []

        if granularity == Atspi.TextGranularity.SENTENCE and AXUtilities.is_time(obj):
            string = AXText.get_all_text(obj)
            if string:
                return [(obj, 0, len(string), string)]

        if granularity == Atspi.TextGranularity.LINE:
            treat_as_text = self.treat_as_text_object(obj)
            if self._element_lines_are_single_chars(obj):
                if AXObject.get_name(obj) and treat_as_text:
                    tokens = ["WEB: Returning name as contents for", obj, "(single-char lines)"]
                    debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                    return [(obj, 0, AXText.get_character_count(obj), AXObject.get_name(obj))]

                tokens = ["WEB: Returning all text as contents for", obj, "(single-char lines)"]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                granularity = None

            if self._element_lines_are_single_words(obj):
                if AXObject.get_name(obj) and treat_as_text:
                    tokens = ["WEB: Returning name as contents for", obj, "(single-word lines)"]
                    debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                    return [(obj, 0, AXText.get_character_count(obj), AXObject.get_name(obj))]

                tokens = ["WEB: Returning all text as contents for", obj, "(single-word lines)"]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                granularity = None

        if AXUtilities.is_internal_frame(obj) and AXObject.get_child_count(obj) == 1:
            return self._get_contents_for_obj(AXObject.get_child(obj, 0), 0, granularity)

        string, start, end = self._get_text_at_offset(obj, offset, granularity)
        if not string:
            return [(obj, start, end, string)]

        string_offset = max(0, offset - start)
        try:
            char = string[string_offset]
        except IndexError:
            msg = f"WEB: Could not get char {string_offset} for '{string}'"
            debug.print_message(debug.LEVEL_INFO, msg, True)
        else:
            if char == "\ufffc":
                if child := AXUtilities.find_child_at_offset(obj, offset):
                    return self._get_contents_for_obj(child, 0, granularity)

        ranges = [m.span() for m in re.finditer("[^\ufffc]+", string)]
        strings = list(filter(lambda x: x[0] <= string_offset <= x[1], ranges))
        if len(strings) == 1:
            range_start, range_end = strings[0]
            start += range_start
            string = string[range_start:range_end]
            end = start + len(string)

        if granularity in [Atspi.TextGranularity.WORD, Atspi.TextGranularity.CHAR]:
            return [(obj, start, end, string)]

        return self._adjust_contents_for_language([(obj, start, end, string)])

    def get_sentence_contents_at_offset(
        self,
        obj: Atspi.Accessible,
        offset: int,
        use_cache: bool = True,
    ) -> list[tuple[Atspi.Accessible, int, int, str]]:
        """Returns the sentence contents for the specified offset."""

        self._cache.clear_caret_context_decisions("web sentence contents")
        rv = self._get_sentence_contents_at_offset_internal(obj, offset, use_cache)
        self._cache.clear_caret_context_decisions("web sentence contents")
        return rv

    def _get_sentence_contents_at_offset_internal(
        self,
        obj: Atspi.Accessible,
        offset: int,
        use_cache: bool = True,
    ) -> list[tuple[Atspi.Accessible, int, int, str]]:
        if not obj:
            return []

        offset = max(0, offset)

        if use_cache:
            cached = self._cache.get_content(self._cache.SENTENCE_CONTENTS)
            if cached and self.find_object_in_contents(obj, offset, cached, use_cache=True) != -1:
                return cached

        granularity = Atspi.TextGranularity.SENTENCE
        objects = self._get_contents_for_obj(obj, offset, granularity)
        if AXUtilities.is_editable(obj):
            if AXUtilities.is_focused(obj):
                return objects
            if self.is_content_editable_with_embedded_objects(obj):
                return objects

        def _treat_as_sentence_end(x):
            x_obj, x_start, x_end, x_string = x
            if not self.is_text_block_element(x_obj):
                return False

            if self.treat_as_text_object(x_obj) and 0 < AXText.get_character_count(x_obj) <= x_end:
                return True

            if 0 <= x_start <= 5:
                x_string = " ".join(x_string.split()[1:])

            return AXUtilities.has_sentence_ending(x_string)

        # Check for things in the same sentence before this object.
        first_obj, first_start, _first_end, first_string = objects[0]
        while first_obj and first_string:
            if self.is_text_block_element(first_obj):
                if first_start == 0:
                    break
            elif self.is_text_block_element(AXObject.get_parent(first_obj)):
                if AXHypertext.get_character_offset_in_parent(first_obj) == 0:
                    break

            prev_object, prev_offset = AXUtilities.find_previous_context(
                first_obj, first_start, self.caret_policy
            )
            on_left = self._get_contents_for_obj(prev_object, prev_offset, granularity)
            on_left = list(filter(lambda x: x not in objects, on_left))
            ends_on_left = list(filter(_treat_as_sentence_end, on_left))
            if ends_on_left:
                i = on_left.index(ends_on_left[-1])
                on_left = on_left[i + 1 :]

            if not on_left:
                break

            objects[0:0] = on_left
            first_obj, first_start, _first_end, first_string = objects[0]

        # Check for things in the same sentence after this object.
        while not _treat_as_sentence_end(objects[-1]):
            last_obj, _last_start, last_end, _last_string = objects[-1]
            next_obj, next_offset = AXUtilities.find_next_context(
                last_obj, last_end - 1, self.caret_policy
            )
            on_right = self._get_contents_for_obj(next_obj, next_offset, granularity)
            on_right = list(filter(lambda x: x not in objects, on_right))
            if not on_right:
                break

            objects.extend(on_right)

        if use_cache:
            self._cache.set_content(self._cache.SENTENCE_CONTENTS, objects)

        return objects

    def get_character_contents_at_offset(
        self,
        obj: Atspi.Accessible,
        offset: int,
        use_cache: bool = True,
    ) -> list[tuple[Atspi.Accessible, int, int, str]]:
        """Returns the character contents for obj at the specified offset."""

        self._cache.clear_caret_context_decisions("web character contents")
        rv = self._get_character_contents_at_offset_internal(obj, offset, use_cache)
        self._cache.clear_caret_context_decisions("web character contents")
        return rv

    def _get_character_contents_at_offset_internal(
        self,
        obj: Atspi.Accessible,
        offset: int,
        use_cache: bool = True,
    ) -> list[tuple[Atspi.Accessible, int, int, str]]:
        if not obj:
            return []

        offset = max(0, offset)

        if use_cache:
            cached = self._cache.get_content(self._cache.CHARACTER_CONTENTS)
            if cached and self.find_object_in_contents(obj, offset, cached, use_cache=True) != -1:
                return cached

        granularity = Atspi.TextGranularity.CHAR
        objects = self._get_contents_for_obj(obj, offset, granularity)
        if use_cache:
            self._cache.set_content(self._cache.CHARACTER_CONTENTS, objects)

        return objects

    def get_word_contents_at_offset(
        self,
        obj: Atspi.Accessible,
        offset: int = 0,
        use_cache: bool = True,
    ) -> list[tuple[Atspi.Accessible, int, int, str]]:
        """Returns a list of (obj, start, end, string) tuples for the word at offset."""

        self._cache.clear_caret_context_decisions("web word contents")
        rv = self._get_word_contents_at_offset(obj, offset, use_cache)
        self._cache.clear_caret_context_decisions("web word contents")
        return rv

    def _get_word_contents_at_offset(
        self,
        obj: Atspi.Accessible,
        offset: int,
        use_cache: bool = True,
    ) -> list[tuple[Atspi.Accessible, int, int, str]]:
        if not obj:
            return []

        offset = max(0, offset)

        if use_cache:
            cached = self._cache.get_content(self._cache.WORD_CONTENTS)
            if cached and self.find_object_in_contents(obj, offset, cached, use_cache=True) != -1:
                self._debug_contents_info(obj, offset, cached, "Word (cached)")
                return cached

        granularity = Atspi.TextGranularity.WORD
        objects = self._get_contents_for_obj(obj, offset, granularity)
        rect = self._get_extents(obj, offset, offset + 1)

        if AXUtilities.is_table_cell_or_header(obj):
            obj_cell = obj
        else:
            obj_cell = AXUtilities.find_ancestor(obj, AXUtilities.is_table_cell_or_header)

        def _include(x):
            if x in objects:
                return False

            if AXUtilities.is_text_input(obj):
                return False

            x_obj, x_start, x_end, x_string = x
            if x_start == x_end or not x_string:
                return False

            if obj != x_obj:
                if AXUtilities.is_list_item(obj) and AXUtilities.is_list_item(x_obj):
                    return False
                if obj_cell is not None and not AXUtilities.is_ancestor(x_obj, obj_cell, True):
                    return False

            x_rect = self._get_extents(x_obj, x_start, x_start + 1)
            return AXUtilities.rects_are_on_same_line(rect, x_rect)

        # Check for things in the same word to the left of this object.
        first_obj, first_start, _first_end, first_string = objects[0]
        prev_obj, prev_offset = AXUtilities.find_previous_context(
            first_obj, first_start, self.caret_policy
        )
        while prev_obj and first_string and prev_obj != first_obj:
            char = AXText.get_character_at_offset(prev_obj, prev_offset)[0]
            if not char or char.isspace():
                break

            on_left = self._get_contents_for_obj(prev_obj, prev_offset, granularity)
            on_left = list(filter(_include, on_left))
            if not on_left:
                break

            objects[0:0] = on_left
            first_obj, first_start, _first_end, first_string = objects[0]
            prev_obj, prev_offset = AXUtilities.find_previous_context(
                first_obj, first_start, self.caret_policy
            )

        # Check for things in the same word to the right of this object.
        last_obj, _last_start, last_end, last_string = objects[-1]
        while last_obj and last_string and not last_string[-1].isspace():
            next_obj, next_offset = AXUtilities.find_next_context(
                last_obj, last_end - 1, self.caret_policy
            )
            if next_obj == last_obj:
                break

            on_right = self._get_contents_for_obj(next_obj, next_offset, granularity)
            on_right = list(filter(_include, on_right))
            if not on_right:
                break

            objects.extend(on_right)
            last_obj, _last_start, last_end, last_string = objects[-1]

        # We want to treat the list item marker as its own word.
        first_obj, first_start, _first_end, first_string = objects[0]
        if first_start == 0 and AXUtilities.is_list_item(first_obj):
            objects = [objects[0]]

        if use_cache:
            self._cache.set_content(self._cache.WORD_CONTENTS, objects)

        self._debug_contents_info(obj, offset, objects, "Word (not cached)")
        return objects

    def get_object_contents_at_offset(
        self,
        obj: Atspi.Accessible,
        offset: int = 0,
        use_cache: bool = True,
    ) -> list[tuple[Atspi.Accessible, int, int, str]]:
        """Returns a list of (obj, start, end, string) tuples for the object at offset."""

        self._cache.clear_caret_context_decisions("web object contents")
        rv = self._get_object_contents_at_offset(obj, offset, use_cache)
        self._cache.clear_caret_context_decisions("web object contents")
        return rv

    def _get_object_contents_at_offset(
        self,
        obj: Atspi.Accessible,
        offset: int = 0,
        use_cache: bool = True,
    ) -> list[tuple[Atspi.Accessible, int, int, str]]:
        if obj is None:
            return []

        if AXObject.is_dead(obj):
            msg = "ERROR: Cannot get object contents at offset for dead object."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return []

        offset = max(0, offset)

        if use_cache:
            cached = self._cache.get_content(self._cache.OBJECT_CONTENTS)
            if cached and self.find_object_in_contents(obj, offset, cached, use_cache=True) != -1:
                self._debug_contents_info(obj, offset, cached, "Object (cached)")
                return cached

        obj_is_landmark = AXUtilities.is_landmark(obj)
        obj_list = AXUtilities.find_ancestor(obj, AXUtilities.is_list)

        def _is_in_object(x):
            if not x:
                return False
            if x == obj:
                return True
            return _is_in_object(AXObject.get_parent(x))

        def _include(x):
            if x in objects:
                return False

            x_obj, x_start, x_end, _x_string = x
            if x_start == x_end:
                return False

            if obj_is_landmark and AXUtilities.is_landmark(x_obj) and obj != x_obj:
                return False

            if obj_list is not None:
                x_list = AXUtilities.find_ancestor(x_obj, AXUtilities.is_list)
                if x_list is not None and x_list != obj_list:
                    return False

            return _is_in_object(x_obj)

        objects = self._get_contents_for_obj(obj, offset, None)
        if not objects:
            tokens = ["ERROR: Cannot get object contents for", obj, f"at offset {offset}"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return []

        last_obj, _last_start, last_end, _last_string = objects[-1]
        next_obj, next_offset = AXUtilities.find_next_context(
            last_obj, last_end - 1, self.caret_policy
        )
        while next_obj:
            on_right = self._get_contents_for_obj(next_obj, next_offset, None)
            on_right = list(filter(_include, on_right))
            if not on_right:
                break

            objects.extend(on_right)
            last_obj, last_end = objects[-1][0], objects[-1][2]
            next_obj, next_offset = AXUtilities.find_next_context(
                last_obj, last_end - 1, self.caret_policy
            )

        if use_cache:
            self._cache.set_content(self._cache.OBJECT_CONTENTS, objects)

        self._debug_contents_info(obj, offset, objects, "Object (not cached)")
        return objects

    def _debug_contents_info(
        self,
        obj: Atspi.Accessible,
        offset: int,
        contents: list[tuple[Atspi.Accessible, int, int, str]],
        contents_msg: str = "",
    ) -> None:
        if debug.debugLevel > debug.LEVEL_INFO:
            return

        tokens = ["WEB: ", contents_msg, "for", obj, "at offset", offset, ":"]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        indent = " " * 8
        for i, (acc, start, end, string) in enumerate(contents):
            rect = self._get_extents(acc, start, end)
            msg = (
                f"     {i}. chars: {start}-{end}: '{string}' "
                f"extents=({rect.x}, {rect.y}, {rect.width}, {rect.height})\n"
            )
            msg += AXUtilitiesDebugging.object_details_as_string(acc, indent, False)
            debug.print_message(debug.LEVEL_INFO, msg, True)

    def treat_as_end_of_line(self, obj: Atspi.Accessible, offset: int) -> bool:
        """Returns true if the offset in obj should be treated as the end of the line."""

        if not self.is_content_editable_with_embedded_objects(obj):
            return False

        if not AXObject.supports_text(obj):
            return False

        if self.is_document(obj):
            return False

        if offset == AXText.get_character_count(obj):
            tokens = ["WEB: ", obj, "offset", offset, "is end of line: offset is characterCount"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return True

        # Do not treat a literal newline char as the end of line. When there is an
        # actual newline character present, user agents should give us the right value
        # for the line at that offset. Here we are trying to figure out where asking
        # for the line at offset will give us the next line rather than the line where
        # the cursor is physically blinking.
        char = AXText.get_character_at_offset(obj, offset)[0]
        if char == "\ufffc":
            prev_rect = self._get_extents(obj, offset - 1, offset)
            this_rect = self._get_extents(obj, offset, offset + 1)
            same_line = AXUtilities.rects_are_on_same_line(prev_rect, this_rect)
            tokens = [
                "WEB: ",
                obj,
                "offset",
                offset,
                "is [obj]. Same line: ",
                same_line,
                "Is end of line: ",
                not same_line,
            ]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return not same_line

        return False

    @staticmethod
    @contextmanager
    def _hypertext_content_building_scope() -> Iterator[None]:
        """Caches the AT-SPI reads made while assembling line contents, until the scope ends."""

        with ax_cache_manager.stable_tree_scope():
            yield

    def get_line_contents_at_offset(
        self,
        obj: Atspi.Accessible,
        offset: int,
        layout_mode: bool | None = None,
        use_cache: bool = True,
    ) -> list[tuple[Atspi.Accessible, int, int, str]]:
        self._cache.clear_caret_context_decisions("web line contents")
        with self._hypertext_content_building_scope():
            rv = self._get_line_contents_at_offset(obj, offset, layout_mode, use_cache)
        self._cache.clear_caret_context_decisions("web line contents")
        return rv

    def _get_line_contents_at_offset(
        self,
        obj: Atspi.Accessible,
        offset: int,
        layout_mode: bool | None = None,
        use_cache: bool = True,
    ) -> list[tuple[Atspi.Accessible, int, int, str]]:
        tokens = ["WEB: _get_line_contents_at_offset for", obj, "at offset", offset]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        if not obj:
            return []

        if AXObject.is_dead(obj):
            msg = "ERROR: Cannot get line contents at offset for dead object."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return []

        offset = max(0, offset)
        if (
            AXUtilities.is_tool_bar(obj) or AXUtilities.is_menu_bar(obj)
        ) and not self._treat_object_as_whole(obj):
            child = AXUtilities.find_child_at_offset(obj, offset)
            if child:
                obj = child
                offset = 0

        if use_cache:
            cached = self._cache.get_content(self._cache.LINE_CONTENTS)
            if cached and self.find_object_in_contents(obj, offset, cached, use_cache=True) != -1:
                self._debug_contents_info(obj, offset, cached, "Line (cached)")
                return cached

        if layout_mode is None:
            layout_mode = (
                caret_navigator.get_navigator().get_layout_mode()
                or document_presenter.get_presenter().in_focus_mode(self._script.app)
            )

        objects: list[tuple[Atspi.Accessible, int, int, str]] = []
        if offset > 0 and (
            self.treat_as_end_of_line(obj, offset)
            or AXUtilities.is_whitespace_at_end_of_line(obj, offset)
        ):
            rect = self._get_extents(obj, offset - 1, offset)
            # The preceding character can be an embedded object spanning multiple
            # lines; when it is, the character at offset identifies this line.
            end_rect = self._get_extents(obj, offset, offset + 1)
            if 0 < end_rect.height * 2 <= rect.height:
                rect = end_rect
        else:
            rect = self._get_extents(obj, offset, offset + 1)

        def _include(x):
            if x in seen:
                return False

            x_obj, x_start, x_end, _x_string = x

            # A lone newline at obj's end offset is obj's end-of-line; include it by text order.
            if (
                _x_string == "\n"
                and x_start == AXHypertext.get_link_end_offset(obj)
                and AXUtilities.is_ancestor(obj, x_obj)
            ):
                return True

            # Trailing whitespace at a line break can report extents which extend into the
            # next line, so measure the candidate's range without it.
            stripped = _x_string.rstrip()
            if stripped and len(stripped) < len(_x_string):
                x_rect = self._get_extents(x_obj, x_start, x_start + len(stripped))
            else:
                x_rect = self._get_extents(x_obj, x_start, x_end)

            if x_obj == obj:
                # Contiguous ranges from the same text object are different AT-SPI lines;
                # character extents at wrap boundaries can be unreliable.
                if AXObject.supports_text(obj):
                    for existing_obj, e_start, e_end, _e_string in objects:
                        if existing_obj == x_obj and (x_start == e_end or x_end == e_start):
                            return False

                return AXUtilities.rects_are_on_same_line(rect, x_rect)

            x_obj_block = AXUtilities.get_nearest_block_ancestor(x_obj)
            if obj_block == x_obj_block:
                if abs(rect.x - x_rect.x) <= 1 and abs(rect.y - x_rect.y) <= 1:
                    # Coinciding position is stacked (skip links) unless one contains the other.
                    return AXUtilities.get_common_ancestor(obj, x_obj) in (obj, x_obj)
                if not AXUtilities.rects_are_on_same_line(rect, x_rect, inline_flow=True):
                    return False
                # A tall image can vertically overlap text on the line below it; a text run joins
                # this line only if it also shares the line with text already on it, not merely
                # with the image (e.g. an icon beside a name must not pull in the description).
                if _x_string.strip():
                    for e_obj, e_start, e_end, e_string in objects:
                        if e_string.strip():
                            e_rect = self._get_extents(e_obj, e_start, e_end)
                            return AXUtilities.rects_are_on_same_line(
                                e_rect, x_rect, inline_flow=True
                            )
                return True

            # A focusable tab-order control that is hidden but still holds its place (an
            # off-screen or clipped dropdown toggle) is an inline sibling; keep it on the line
            # when its parent shares obj's block. Non-focusable hidden content (e.g. off-screen
            # labels) is left out.
            x_obj_parent = AXObject.get_parent(x_obj)
            if (
                x_obj_parent is not None
                and AXUtilities.is_focusable(x_obj)
                and AXUtilities.get_nearest_block_ancestor(x_obj_parent) == obj_block
                and (
                    AXUtilities.object_is_outside_parent(x_obj)
                    or (AXUtilities.is_visible(x_obj) and not AXUtilities.is_showing(x_obj))
                )
            ):
                return True

            reason = None
            if obj_row is None:
                reason = "different block ancestor"
            elif obj_row != AXUtilities.get_nearest_table_row(x_obj_block):
                reason = "different table row"

            if reason:
                tokens = ["WEB: Excluding", x_obj, "from line contents:", reason]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                return False

            # A clipped element is not laid out where its text appears, so its position cannot
            # be used to split the row it belongs to.
            if AXUtilities.clips_its_own_text(obj) or AXUtilities.clips_its_own_text(x_obj):
                return True

            return AXUtilities.rects_are_on_same_line(rect, x_rect, inline_flow=False)

        granularity = Atspi.TextGranularity.LINE
        objects = self._get_contents_for_obj(obj, offset, granularity)
        if not layout_mode:
            if use_cache:
                self._cache.set_content(self._cache.LINE_CONTENTS, objects)

            self._debug_contents_info(obj, offset, objects, "Line (not layout mode)")
            return objects

        if not (objects and objects[0]):
            tokens = ["WEB: Error. No objects found for", obj]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return []

        obj_block = AXUtilities.get_nearest_block_ancestor(obj)
        obj_row = AXUtilities.get_nearest_table_row(obj_block)
        seen = set(objects)

        first_obj, first_start, first_end, _first_string = objects[0]
        if rect.width == 0 and rect.height == 0:
            rect = self._get_extents(first_obj, first_start, first_end)

        last_obj, last_start, last_end, _last_string = objects[-1]
        if AXUtilities.is_math(last_obj):
            last_obj, last_end = self.last_context(last_obj)
            last_end += 1

        document = self.get_document_for_object(obj)
        prev_obj, prev_offset = AXUtilities.find_previous_context(
            first_obj, first_start, self.caret_policy
        )
        next_obj, next_offset = AXUtilities.find_next_context(
            last_obj, max(last_end - 1, last_start), self.caret_policy
        )

        # Check for things on the same line to the left of this object.
        while prev_obj and self.get_document_for_object(prev_obj) == document:
            on_left = self._get_contents_for_obj(prev_obj, prev_offset, granularity)
            on_left = list(filter(_include, on_left))
            if not on_left:
                break

            objects[0:0] = on_left
            seen.update(on_left)
            first_obj, first_start = objects[0][0], objects[0][1]
            prev_obj, prev_offset = AXUtilities.find_previous_context(
                first_obj, first_start, self.caret_policy
            )

        # Check for things on the same line to the right of this object.
        while next_obj and self.get_document_for_object(next_obj) == document:
            on_right = self._get_contents_for_obj(next_obj, next_offset, granularity)
            on_right = list(filter(_include, on_right))
            if not on_right:
                break

            objects.extend(on_right)
            seen.update(on_right)
            last_obj, last_start, last_end = objects[-1][0], objects[-1][1], objects[-1][2]
            if AXUtilities.is_math(last_obj):
                last_obj, last_end = self.last_context(last_obj)
                last_end += 1

            next_obj, next_offset = AXUtilities.find_next_context(
                last_obj, max(last_end - 1, last_start), self.caret_policy
            )

        if use_cache:
            self._cache.set_content(self._cache.LINE_CONTENTS, objects)

        self._debug_contents_info(obj, offset, objects, "Line (layout mode)")
        return objects

    def get_previous_line_contents(
        self,
        obj: Atspi.Accessible | None = None,
        offset: int = -1,
        layout_mode: bool | None = None,
        use_cache: bool = True,
    ) -> list[tuple[Atspi.Accessible, int, int, str]]:
        """Returns a list of (obj, start, end, string) tuples for the previous line."""

        with self._hypertext_content_building_scope():
            return self._get_previous_line_contents(obj, offset, layout_mode, use_cache)

    def _get_previous_line_contents(
        self,
        obj: Atspi.Accessible | None = None,
        offset: int = -1,
        layout_mode: bool | None = None,
        use_cache: bool = True,
    ) -> list[tuple[Atspi.Accessible, int, int, str]]:
        """Returns a list of (obj, start, end, string) tuples for the previous line."""

        if obj is None:
            obj, offset = self.get_caret_context()

        tokens = ["WEB: Current context is: ", obj, ", ", offset]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        if not AXObject.is_valid(obj):
            tokens = ["WEB: Current context obj", obj, "is not valid. Clearing cache."]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            self.clear_cached_objects()

            obj, offset = self.get_caret_context()
            tokens = ["WEB: Now Current context is: ", obj, ", ", offset]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        line = self.get_line_contents_at_offset(obj, offset, layout_mode, use_cache)
        if not (line and line[0]):
            return []

        first_obj, first_offset = line[0][0], line[0][1]
        tokens = ["WEB: First context on line is: ", first_obj, ", ", first_offset]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        skip_space = not speech_presenter.get_presenter().get_speak_blank_lines()
        obj, offset = self.previous_context(first_obj, first_offset, skip_space)
        if not obj and first_obj:
            self.clear_cached_objects()
            obj, offset = self.previous_context(first_obj, first_offset, skip_space)

        tokens = ["WEB: Previous context is: ", obj, ", ", offset]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        contents = self.get_line_contents_at_offset(obj, offset, layout_mode, use_cache)
        if not contents:
            tokens = ["WEB: Could not get line contents for ", obj, ", ", offset]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return []

        if line == contents:
            obj, offset = self.previous_context(obj, offset, True)
            tokens = ["WEB: Got same line. Trying again with ", obj, ", ", offset]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            contents = self.get_line_contents_at_offset(obj, offset, layout_mode, use_cache)

        if line == contents:
            start = AXHypertext.get_link_start_offset(obj)
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            if start >= 0:
                parent = AXObject.get_parent(obj)
                obj, offset = self.previous_context(parent, start, True)
                tokens = ["WEB: Trying again with", obj, ", ", offset]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                contents = self.get_line_contents_at_offset(obj, offset, layout_mode, use_cache)

        return contents

    def get_next_line_contents(
        self,
        obj: Atspi.Accessible | None = None,
        offset: int = -1,
        layout_mode: bool | None = None,
        use_cache: bool = True,
    ) -> list[tuple[Atspi.Accessible, int, int, str]]:
        """Returns a list of (obj, start, end, string) tuples for the next line."""

        with self._hypertext_content_building_scope():
            return self._get_next_line_contents(obj, offset, layout_mode, use_cache)

    def _get_next_line_contents(
        self,
        obj: Atspi.Accessible | None = None,
        offset: int = -1,
        layout_mode: bool | None = None,
        use_cache: bool = True,
    ) -> list[tuple[Atspi.Accessible, int, int, str]]:
        """Returns a list of (obj, start, end, string) tuples for the next line."""

        if obj is None:
            obj, offset = self.get_caret_context()

        tokens = ["WEB: Current context is: ", obj, ", ", offset]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        if not AXObject.is_valid(obj):
            tokens = ["WEB: Current context obj", obj, "is not valid. Clearing cache."]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            self.clear_cached_objects()

            obj, offset = self.get_caret_context()
            tokens = ["WEB: Now Current context is: ", obj, ", ", offset]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        line = self.get_line_contents_at_offset(obj, offset, layout_mode, use_cache)
        if not (line and line[0]):
            return []

        last_obj, last_offset = line[-1][0], line[-1][2] - 1
        math = AXUtilities.find_ancestor_inclusive(last_obj, AXUtilities.is_math)
        if math:
            last_obj, last_offset = self.last_context(math)

        tokens = ["WEB: Last context on line is: ", last_obj, ", ", last_offset]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        skip_space = not speech_presenter.get_presenter().get_speak_blank_lines()
        obj, offset = self.next_context(last_obj, last_offset, skip_space)
        if not obj and last_obj:
            self.clear_cached_objects()
            obj, offset = self.next_context(last_obj, last_offset, skip_space)

        tokens = ["WEB: Next context is: ", obj, ", ", offset]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        contents = self.get_line_contents_at_offset(obj, offset, layout_mode, use_cache)
        if line == contents:
            obj, offset = self.next_context(obj, offset, True)
            tokens = ["WEB: Got same line. Trying again with ", obj, ", ", offset]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            contents = self.get_line_contents_at_offset(obj, offset, layout_mode, use_cache)

        if line == contents:
            end = AXHypertext.get_link_end_offset(obj)
            if end >= 0:
                parent = AXObject.get_parent(obj)
                obj, offset = self.next_context(parent, end, True)
                tokens = ["WEB: Trying again with", obj, ", ", offset]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                contents = self.get_line_contents_at_offset(obj, offset, layout_mode, use_cache)

        if not contents:
            tokens = ["WEB: Could not get line contents for ", obj, ", ", offset]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return []

        return contents

    def _find_selection_boundary_object(
        self,
        root: Atspi.Accessible,
        find_start: bool = True,
    ) -> Atspi.Accessible | None:
        string = AXUtilities.get_selected_text(root)[0]
        if not string:
            return None

        if find_start and not string.startswith("\ufffc"):
            return root

        if not find_start and not string.endswith("\ufffc"):
            return root

        indices = list(range(AXObject.get_child_count(root)))
        if not find_start:
            indices.reverse()

        for i in indices:
            result = self._find_selection_boundary_object(AXObject.get_child(root, i), find_start)
            if result:
                return result

        return None

    def _get_selection_anchor_and_focus(
        self,
        root: Atspi.Accessible,
    ) -> tuple[Atspi.Accessible | None, Atspi.Accessible | None]:
        obj1 = self._find_selection_boundary_object(root, True)
        obj2 = self._find_selection_boundary_object(root, False)
        return obj1, obj2

    def _get_subtree(
        self,
        start_obj: Atspi.Accessible,
        end_obj: Atspi.Accessible,
    ) -> list[Atspi.Accessible]:
        if not (start_obj and end_obj):
            return []

        if AXObject.is_dead(start_obj):
            msg = "INFO: Cannot get subtree: Start object is dead."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return []

        def _include(x):
            return x is not None

        def _exclude(x):
            return not AXUtilities.is_web_element(x)

        subtree = []
        start_obj_parent = AXObject.get_parent(start_obj)
        for i in range(
            AXObject.get_index_in_parent(start_obj),
            AXObject.get_child_count(start_obj_parent),
        ):
            child = AXObject.get_child(start_obj_parent, i)
            if not AXUtilities.is_web_element(child):
                continue
            subtree.append(child)
            subtree.extend(self._find_all_descendants(child, _include, _exclude))
            if end_obj in subtree:
                break

        if end_obj == start_obj:
            return subtree

        if end_obj not in subtree:
            subtree.append(end_obj)
            subtree.extend(self._find_all_descendants(end_obj, _include, _exclude))

        end_obj_parent = AXObject.get_parent(end_obj)
        end_obj_index = AXObject.get_index_in_parent(end_obj)
        last_obj = AXObject.get_child(end_obj_parent, end_obj_index + 1) or end_obj

        try:
            end_index = subtree.index(last_obj)
        except ValueError:
            pass
        else:
            if last_obj == end_obj:
                end_index += 1
            subtree = subtree[:end_index]

        return subtree

    def handle_text_selection_change(
        self,
        obj: Atspi.Accessible,
        speak_message: bool = True,
    ) -> bool:
        """Handles a change in the selected text."""

        if not self.in_document_content(obj) or document_presenter.get_presenter().in_focus_mode(
            self._script.app,
        ):
            return super().handle_text_selection_change(obj)

        old_start, old_end = self._selection_anchor_and_focus
        start, end = self._get_selection_anchor_and_focus(obj)
        self._selection_anchor_and_focus = (start, end)

        def _cmp(obj1, obj2):
            return self.path_comparison(AXObject.get_path(obj1), AXObject.get_path(obj2))

        old_subtree = self._get_subtree(old_start, old_end)
        if start == old_start and end == old_end:
            descendants = old_subtree
        else:
            new_subtree = self._get_subtree(start, end)
            descendants = sorted(
                set(old_subtree).union(new_subtree),
                key=functools.cmp_to_key(_cmp),
            )

        if not descendants:
            return False

        descendants_set = set(descendants)
        for descendant in descendants:
            if descendant not in (old_start, old_end, start, end) and AXUtilities.find_ancestor(
                descendant,
                lambda x: x in descendants_set,
            ):
                AXUtilities.update_cached_selected_text(descendant)
            else:
                super().handle_text_selection_change(descendant, speak_message)

        return True

    def unrelated_labels(
        self,
        root: Atspi.Accessible | None = None,
        only_showing: bool = True,
        minimum_words: int = 3,
    ) -> list[Atspi.Accessible]:
        """Returns a list of labels in root that lack a relationship."""

        if not (root and self.in_document_content(root)):
            return super().unrelated_labels(root, only_showing, minimum_words)
        return []

    def _advance_caret_in_empty_object(self, obj: Atspi.Accessible) -> bool:
        if AXUtilities.is_table_cell(obj) and not self.treat_as_text_object(obj):
            return not caret_navigator.get_navigator().last_input_event_was_navigation_command()

        return True

    def treat_as_div(self, obj: Atspi.Accessible, offset: int | None = None) -> bool:
        """Returns True if the object should be treated as a div."""

        if not (obj and self.in_document_content(obj)):
            return False

        if AXUtilities.is_description_list(obj):
            return False

        if AXUtilities.is_list(obj) and offset is not None:
            string = AXText.get_substring(obj, offset, offset + 1)
            if string and string != "\ufffc":
                return True

        namespace = self._cache.TREAT_AS_DIV
        cached = self._cache.get_for_object(namespace, obj)
        if cached is not ax_cache_manager.MISSING:
            return cached

        child_count = AXObject.get_child_count(obj)
        if AXUtilities.is_panel(obj) and not child_count:
            return True

        rv = False

        valid_roles = self._valid_child_roles.get(AXObject.get_role(obj))
        if valid_roles:
            if not child_count:
                rv = True
            else:

                def pred1(x):
                    return x is not None and AXObject.get_role(x) not in valid_roles

                rv = bool(list(AXObject.iter_children(obj, pred1)))

        if not rv:
            parent = AXObject.get_parent(obj)
            valid_roles = self._valid_child_roles.get(parent)
            if valid_roles:

                def pred2(x):
                    return x is not None and AXObject.get_role(x) not in valid_roles

                rv = bool(list(AXObject.iter_children(parent, pred2)))

        self._cache.set_for_object(namespace, obj, rv)
        return rv

    def filter_contents_for_presentation(
        self,
        contents: list[tuple[Atspi.Accessible, int, int, str]],
        infer_labels: bool = False,
    ) -> list[tuple[Atspi.Accessible, int, int, str]]:
        """Filters contents for presentation, removing objects that should not be included."""

        # TODO - JD: Is this still needed?

        def _include(x):
            obj, _start, _end, string = x
            if not obj or AXObject.is_dead(obj):
                return False

            namespace = self._cache.SHOULD_FILTER
            cached = self._cache.get_for_object(namespace, obj)
            if cached is not ax_cache_manager.MISSING:
                return cached

            text = string or AXObject.get_name(obj)
            rv = True
            # TODO - JD: Audit this to see if they are now redundant.
            reason = None
            if (self.is_text_block_element(obj) or self.is_link(obj)) and not text:
                reason = "empty text block or link"
            elif self.is_content_editable_with_embedded_objects(obj) and not string.strip():
                reason = "empty editable host"
            elif self._is_empty_anchor(obj):
                reason = "empty anchor"
            elif AXUtilities.has_no_size(obj) and not text:
                reason = "sizeless and nameless"
            elif self._is_off_screen_label(obj):
                reason = "off-screen label"
            elif self._is_useless_image(obj):
                reason = "useless image"
            elif self.is_link_ancestor_of_image_in_contents(obj, contents):
                reason = "link ancestor of image in contents"
            elif self.is_error_for_contents(obj, contents):
                reason = "error for contents"
            elif self._is_labelling_contents(obj, contents):
                reason = "labels other contents"
            elif AXUtilities.is_table_row(obj):
                rv = AXUtilities.has_explicit_name(obj)
            else:
                widget = self.is_inferred_label_for_contents(x, contents)
                always_filter = [Atspi.Role.RADIO_BUTTON, Atspi.Role.CHECK_BOX]
                if widget and (infer_labels or AXObject.get_role(widget) in always_filter):
                    rv = False

            if reason:
                tokens = ["WEB: Excluding", obj, "from presentation:", reason]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                rv = False

            self._cache.set_for_object(namespace, obj, rv)
            return rv

        if len(contents) == 1:
            return contents

        rv = list(filter(_include, contents))
        self._cache.clear_namespace(self._cache.SHOULD_FILTER, "web filter complete")
        return rv

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

        namespace = self._cache.HAS_GRID_DESCENDANT
        cached = self._cache.get_for_object(namespace, obj)
        if cached is not ax_cache_manager.MISSING:
            return cached

        document = self.active_document()
        if obj != document and not self._has_grid_descendant(document):
            rv = False
        else:
            rv = bool(AXUtilities.find_all_grids(obj))

        self._cache.set_for_object(namespace, obj, rv)
        return rv

    def _is_cell_with_name_from_header(self, obj: Atspi.Accessible) -> bool:
        # TODO - JD: Move into one of the AX* classes.
        if not AXUtilities.is_table_cell(obj):
            return False

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

        headers = AXUtilities.get_column_headers(obj)
        if any(AXObject.get_name(header) == name for header in headers):
            return True

        headers = AXUtilities.get_row_headers(obj)
        return any(AXObject.get_name(header) == name for header in headers)

    def should_read_full_row(
        self,
        obj: Atspi.Accessible,
        previous_object: Atspi.Accessible | None = None,
    ) -> bool:
        if not (obj and self.in_document_content(obj)):
            return super().should_read_full_row(obj, previous_object)

        if not super().should_read_full_row(obj, previous_object):
            return False

        if AXUtilities.is_grid_descendant(obj):
            return not document_presenter.get_presenter().in_focus_mode(self._script.app)

        if input_event_manager.get_manager().last_event_was_line_navigation():
            return False

        return not input_event_manager.get_manager().last_event_was_mouse_button()

    def _check_element_line_pattern(self, obj: Atspi.Accessible) -> None:
        """Checks if element lines are single words or single chars, caching both results."""

        if not (obj and self.in_document_content(obj)) or self.is_document(obj):
            self._cache.set_for_object(self._cache.LINES_ARE_SINGLE_WORDS, obj, False)
            self._cache.set_for_object(self._cache.LINES_ARE_SINGLE_CHARS, obj, False)
            return

        if AXUtilities.has_non_inline_children(obj):
            self._cache.set_for_object(self._cache.LINES_ARE_SINGLE_WORDS, obj, False)
            self._cache.set_for_object(self._cache.LINES_ARE_SINGLE_CHARS, obj, False)
            return

        n_chars = AXText.get_character_count(obj)
        if not n_chars or not self.treat_as_text_object(obj):
            self._cache.set_for_object(self._cache.LINES_ARE_SINGLE_WORDS, obj, False)
            self._cache.set_for_object(self._cache.LINES_ARE_SINGLE_CHARS, obj, False)
            return

        # If we have a series of embedded object characters, there's a reasonable chance
        # they'll look like the one-word/char-per-line CSSified text we're trying to detect.
        # We don't want that false positive. By the same token, the one-word/char-per-line
        # CSSified text we're trying to detect can have embedded object characters. So
        # if we have more than 30% EOCs, don't use this workaround. (The 30% is based on
        # testing with problematic text.)
        string = AXText.get_all_text(obj, n_chars)
        if string.count("\ufffc") / n_chars > 0.3:
            self._cache.set_for_object(self._cache.LINES_ARE_SINGLE_WORDS, obj, False)
            self._cache.set_for_object(self._cache.LINES_ARE_SINGLE_CHARS, obj, False)
            return

        # An actual newline is a genuine hard line break (e.g. from a br element) whose
        # per-line structure must be honored. The CSSed-into-brokenness text we are trying
        # to detect is given to us a word/char at a time without any real line breaks.
        if "\n" in string:
            self._cache.set_for_object(self._cache.LINES_ARE_SINGLE_WORDS, obj, False)
            self._cache.set_for_object(self._cache.LINES_ARE_SINGLE_CHARS, obj, False)
            return

        # Note: We cannot check for the editable-text interface, because Gecko
        # seems to be exposing that for non-editable things. Thanks Gecko.
        is_editable = AXUtilities.is_editable(obj) or AXUtilities.is_text_input(obj)
        if is_editable:
            self._cache.set_for_object(self._cache.LINES_ARE_SINGLE_WORDS, obj, False)
            self._cache.set_for_object(self._cache.LINES_ARE_SINGLE_CHARS, obj, False)
            return

        # Check for single-char lines.
        is_single_chars = True
        for i, char in enumerate(string):
            if char.isspace() or char in ["\ufffc", "\ufffd"]:
                continue
            line_string = AXText.get_line_at_offset(obj, i)[0]
            if len(line_string.strip()) > 1:
                is_single_chars = False
                break

        # Check for single-word lines (also requires not being code).
        is_single_words = False
        if not AXUtilities.is_code(obj):
            tokens = list(filter(None, re.split(r"[\s\ufffc]", string)))
            if len(tokens) > 1:
                is_single_words = True
                i = 0
                while i < n_chars:
                    line_string, _start, end = AXText.get_line_at_offset(obj, i)
                    if len(line_string.split()) != 1:
                        is_single_words = False
                        break
                    i = max(i + 1, end)

        # A tall, narrow column is CSSed brokenness; a wider-than-tall element merely wrapped.
        # An element shorter than its own text is clipping it, so its shape proves nothing.
        if is_single_chars or is_single_words:
            rect = AXComponent.get_rect(obj)
            if rect.width > rect.height and not AXUtilities.clips_its_own_text(obj):
                is_single_chars = False
                is_single_words = False

        self._cache.set_for_object(self._cache.LINES_ARE_SINGLE_CHARS, obj, is_single_chars)
        self._cache.set_for_object(self._cache.LINES_ARE_SINGLE_WORDS, obj, is_single_words)

    def _element_lines_are_single_words(self, obj: Atspi.Accessible) -> bool:
        """Returns True if each line of obj's text contains a single word."""

        namespace = self._cache.LINES_ARE_SINGLE_WORDS
        cached = self._cache.get_for_object(namespace, obj)
        if cached is not ax_cache_manager.MISSING:
            return cached

        self._check_element_line_pattern(obj)
        return self._cache.get_for_object(namespace, obj, False)

    def _element_lines_are_single_chars(self, obj: Atspi.Accessible) -> bool:
        """Returns True if each line of obj's text contains a single character."""

        namespace = self._cache.LINES_ARE_SINGLE_CHARS
        cached = self._cache.get_for_object(namespace, obj)
        if cached is not ax_cache_manager.MISSING:
            return cached

        self._check_element_line_pattern(obj)
        return self._cache.get_for_object(namespace, obj, False)

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

        namespace = self._cache.IS_OFF_SCREEN_LABEL
        cached = self._cache.get_for_object(namespace, obj)
        if cached is not ax_cache_manager.MISSING:
            return cached

        labelled_objects = AXUtilities.get_is_label_for(obj)
        if AXUtilities.label_is_ancestor_of_labelled(obj, labelled_objects):
            return False

        rv = False
        if labelled_objects:
            end = max(1, AXText.get_character_count(obj))
            rect = AXText.get_range_rect(obj, 0, end)
            box = AXComponent.get_rect(obj)
            box_is_collapsed = box.width <= 1 and box.height <= 1
            if rect.x < 0 or rect.y < 0 or box_is_collapsed:
                rv = True

        self._cache.set_for_object(namespace, obj, rv)
        return rv

    def is_link_ancestor_of_image_in_contents(
        self,
        link: Atspi.Accessible,
        contents: list[tuple[Atspi.Accessible, int, int, str]],
    ) -> bool:
        """Returns true if link is an ancestor of an image in contents."""

        if not self.is_link(link):
            return False

        for obj, _start, _end, _string in contents:
            if not AXUtilities.is_image(obj):
                continue
            if AXUtilities.find_ancestor(obj, lambda x: x == link):
                return True

        return False

    def is_inferred_label_for_contents(
        self,
        content: tuple[Atspi.Accessible, int, int, str],
        contents: list[tuple[Atspi.Accessible, int, int, str]],
    ) -> bool:
        """Returns true if content is an inferred label for contents."""

        obj, _start, _end, string = content
        objs = list(filter(self._should_infer_label_for, [x[0] for x in contents]))
        if not objs:
            return False

        for o in objs:
            label, sources = self.infer_label_for(o)
            if obj in sources and label and label.strip() == string.strip():
                return True

        return False

    def _is_labelling_contents(
        self,
        obj: Atspi.Accessible,
        contents: list[tuple[Atspi.Accessible, int, int, str]] | None = None,
    ) -> bool:
        if AXUtilities.is_widget(obj):
            return False

        if AXUtilities.is_heading(obj) or AXUtilities.is_caption(obj):
            return False

        targets = AXUtilities.get_is_label_for(obj)
        if not contents:
            if targets:
                return True
            return AXUtilities.is_label_or_caption_descendant(obj)

        for acc, _start, _end, _string in contents:
            if acc in targets:
                return True

        if not self.is_text_block_element(obj):
            return False

        if not AXUtilities.is_label_or_caption_descendant(obj):
            return False

        for acc, _start, _end, _string in contents:
            if not AXUtilities.is_label_or_caption_descendant(acc):
                continue
            if self.is_text_block_element(acc):
                continue

            if AXUtilities.is_label_or_caption(AXUtilities.get_common_ancestor(acc, obj)):
                return True

        return False

    def _is_empty_anchor(self, obj: Atspi.Accessible) -> bool:
        return AXUtilities.is_anchor(obj) and not self.treat_as_text_object(obj)

    def is_browser_ui_alert(self, obj: Atspi.Accessible) -> bool:
        """Returns true if obj is an alert outside of document content."""

        if not AXUtilities.is_alert(obj):
            return False

        return not self.in_document_content(obj)

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

        namespace = self._cache.IS_CLICKABLE_ELEMENT
        cached = self._cache.get_for_object(namespace, obj)
        if cached is not ax_cache_manager.MISSING:
            return cached

        if AXUtilities.label_is_ancestor_of_labelled(obj, AXUtilities.get_is_label_for(obj)):
            return False

        if self._has_grid_descendant(obj):
            tokens = ["WEB:", obj, "is not clickable: has grid descendant"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return False

        rv = False
        if not document_presenter.get_presenter().is_focus_mode_widget(self._script, obj):
            if not AXUtilities.is_focusable(obj):
                rv = AXUtilities.has_action(obj, "click")
            else:
                rv = AXUtilities.has_action(obj, "click-ancestor")

        if rv and not AXObject.get_name(obj) and AXObject.supports_text(obj):
            text = AXText.get_all_text(obj)
            if not text.replace("\ufffc", ""):
                tokens = ["WEB:", obj, "is not clickable: its text is just EOCs"]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                rv = False
            elif not text.strip():
                rv = not (AXUtilities.is_static(obj) or AXUtilities.is_link(obj))

        self._cache.set_for_object(namespace, obj, rv)
        return rv

    def is_item_for_editable_combo_box(
        self,
        item: Atspi.Accessible,
        combobox: Atspi.Accessible,
    ) -> bool:
        """Returns true if item is an item for editable combobox combobox."""

        # TODO - JD: Move into AXUtilities.
        if not (AXUtilities.is_list_item(item) or AXUtilities.is_menu_item(item)):
            return False
        if not AXUtilities.is_editable_combo_box(combobox):
            return False
        if AXUtilities.is_ancestor(item, combobox):
            return True

        container = AXUtilities.find_ancestor(
            item,
            lambda x: AXUtilities.is_list_box(x) or AXUtilities.is_combo_box(x),
        )
        targets = AXUtilities.get_is_controlled_by(container)
        return combobox in targets

    def _is_fake_placeholder_for_entry(self, obj: Atspi.Accessible) -> bool:
        if not (obj and self.in_document_content(obj) and AXObject.get_parent(obj)):
            return False

        if AXUtilities.is_editable(obj):
            return False

        if not AXUtilities.is_entry_descendant(obj):
            return False

        entry_name = AXObject.get_name(AXUtilities.find_ancestor(obj, AXUtilities.is_entry))
        if not entry_name:
            return False

        def _is_match(x):
            string = AXText.get_all_text(x).strip()
            if entry_name != string:
                return False
            return AXUtilities.is_section(x) or AXUtilities.is_static(x)

        if _is_match(obj):
            return True

        return AXUtilities.find_descendant(obj, _is_match) is not None

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

        namespace = self._cache.IS_LINK
        cached = self._cache.get_for_object(namespace, obj)
        if cached is not ax_cache_manager.MISSING:
            return cached

        rv = bool(
            (AXUtilities.is_link(obj) and not AXUtilities.is_anchor(obj))
            or (
                AXUtilities.is_static(obj)
                and AXUtilities.is_link(AXObject.get_parent(obj))
                and AXObject.has_same_non_empty_name(obj, AXObject.get_parent(obj))
            ),
        )

        self._cache.set_for_object(namespace, obj, rv)
        return rv

    def has_useless_canvas_descendant(self, obj: Atspi.Accessible) -> bool:
        """Returns true if obj has a canvas descendant which lacks fallback content."""

        return len(AXUtilities.find_all_canvases(obj, self._is_useless_image)) > 0

    def _is_non_navigable_embedded_document(self, obj: Atspi.Accessible) -> bool:
        if self.is_document(obj) and self.get_document_for_object(obj):
            return "doubleclick" in AXObject.get_name(obj)
        return False

    def _is_redundant_svg(self, obj: Atspi.Accessible) -> bool:
        if not AXUtilities.is_svg(obj) or AXObject.get_child_count(AXObject.get_parent(obj)) == 1:
            return False

        namespace = self._cache.IS_REDUNDANT_SVG
        cached = self._cache.get_for_object(namespace, obj)
        if cached is not ax_cache_manager.MISSING:
            return cached

        rv = False
        parent = AXObject.get_parent(obj)
        children = list(AXObject.iter_children(parent, AXUtilities.is_svg))
        if len(children) == AXObject.get_child_count(parent):
            sorted_children = AXUtilities.sort_objects_by_size(children)
            if obj != sorted_children[-1]:
                obj_extents = AXComponent.get_rect(obj)
                largest_extents = AXComponent.get_rect(sorted_children[-1])
                intersection = AXUtilities.get_rect_intersection(obj_extents, largest_extents)
                rv = intersection == obj_extents

        rv = bool(rv)
        self._cache.set_for_object(namespace, obj, rv)
        return rv

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

        namespace = self._cache.IS_USELESS_IMAGE
        cached = self._cache.get_for_object(namespace, obj)
        if cached is not ax_cache_manager.MISSING:
            return cached

        rv = True
        has_explicit_name = AXUtilities.has_explicit_name(obj)
        if not (AXUtilities.is_image_or_canvas(obj) or AXUtilities.is_svg(obj)):
            rv = False
        if rv and (
            AXObject.get_name(obj) or AXObject.get_description(obj) or self.has_long_desc(obj)
        ):
            rv = False
        if rv and self.is_clickable_element(obj) and not has_explicit_name:
            rv = False
        if rv and AXUtilities.is_focusable(obj):
            rv = False
        parent = AXObject.get_parent(obj)
        if rv and AXUtilities.is_link(parent) and not has_explicit_name:
            uri = AXHypertext.get_link_uri(parent)
            if uri and not uri.startswith("javascript") and not AXObject.get_name(parent):
                rv = False
        if rv and AXObject.supports_image(obj):
            if AXObject.get_image_description(obj):
                rv = False
            elif not has_explicit_name and not self._is_redundant_svg(obj):
                width, height = AXObject.get_image_size(obj)
                if width > 25 and height > 25:
                    rv = False
        if rv and AXObject.supports_text(obj):
            rv = not self.treat_as_text_object(obj)
        if rv and AXObject.get_child_count(obj):
            for i in range(min(AXObject.get_child_count(obj), 50)):
                if not self._is_useless_image(AXObject.get_child(obj, i)):
                    rv = False
                    break

        self._cache.set_for_object(namespace, obj, rv)
        return rv

    def has_valid_name(self, obj: Atspi.Accessible) -> bool:
        """Returns true if obj has a valid name."""

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

        if len(name.split()) > 1:
            return True

        parsed = urllib.parse.parse_qs(name)
        if len(parsed) > 2:
            tokens = ["WEB: name of", obj, "is suspected query string"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return False

        if len(name) == 1 and ord(name) in range(0xE000, 0xF8FF):
            tokens = ["WEB: name of", obj, "is in unicode private use area"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return False

        return True

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

        namespace = self._cache.IS_USELESS_EMPTY_ELEMENT
        cached = self._cache.get_for_object(namespace, obj)
        if cached is not ax_cache_manager.MISSING:
            return cached

        roles = [Atspi.Role.PARAGRAPH, Atspi.Role.SECTION, Atspi.Role.STATIC, Atspi.Role.TABLE_ROW]
        role = AXObject.get_role(obj)
        reason = None
        if role not in roles and not AXUtilities.is_aria_alert(obj):
            reason = "ineligible role"
        elif AXUtilities.is_focusable(obj):
            reason = "focusable"
        elif AXUtilities.is_editable(obj):
            reason = "editable"
        elif self.has_valid_name(obj):
            reason = "has valid name"
        elif AXObject.get_description(obj):
            reason = "has description"
        elif AXObject.get_child_count(obj):
            reason = "has children"
        elif (char_count := AXText.get_character_count(obj)) and (
            char_count > len(name := AXObject.get_name(obj))
            or AXText.get_all_text(obj, char_count) != name
        ):
            reason = "has text content"

        if reason:
            tokens = ["WEB:", obj, "is not a useless empty element:", reason]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            rv = False
        elif AXObject.supports_action(obj):
            names = AXUtilities.get_action_names(obj)
            ignore = ["click-ancestor", "show-context-menu", "do-default"]
            names = list(filter(lambda x: x not in ignore, names))
            rv = not names
        else:
            rv = True

        self._cache.set_for_object(namespace, obj, rv)
        return rv

    def has_long_desc(self, obj: Atspi.Accessible) -> bool:
        """Returns true if obj has a long description."""

        if not (obj and self.in_document_content(obj)):
            return False
        return AXUtilities.has_action(obj, "showlongdesc")

    def infer_label_for(self, obj: Atspi.Accessible) -> tuple[str | None, list[Atspi.Accessible]]:
        """Attempts to infer the text serving as the functional label for obj."""

        if not self._should_infer_label_for(obj):
            return None, []

        namespace = self._cache.INFERRED_LABEL
        cached = self._cache.get_for_object(namespace, obj)
        if cached is not ax_cache_manager.MISSING:
            label, sources = cached
            return label, list(sources)

        label, sources = self._script.label_inference.infer(obj, False)
        self._cache.set_for_object(namespace, obj, (label, list(sources)))
        return label, sources

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

        namespace = self._cache.SHOULD_INFER_LABEL_FOR
        cached = self._cache.get_for_object(namespace, obj)
        rv = None if cached is ax_cache_manager.MISSING else cached
        if rv and not caret_navigator.get_navigator().last_input_event_was_navigation_command():
            return not focus_manager.get_manager().in_say_all()
        if rv is False:
            return rv

        if AXUtilities.is_embedded_descendant(obj):
            self._cache.set_for_object(namespace, obj, False)
            return False

        role = AXObject.get_role(obj)
        if AXObject.get_name(obj) or AXUtilities.has_role_from_aria(obj):
            rv = False
        elif not rv:
            roles = [
                Atspi.Role.CHECK_BOX,
                Atspi.Role.COMBO_BOX,
                Atspi.Role.ENTRY,
                Atspi.Role.LIST_BOX,
                Atspi.Role.PASSWORD_TEXT,
                Atspi.Role.RADIO_BUTTON,
            ]
            rv = role in roles and not AXUtilities.get_displayed_label(obj)

        self._cache.set_for_object(namespace, obj, rv)
        if (
            caret_navigator.get_navigator().last_input_event_was_navigation_command()
            and role not in [Atspi.Role.RADIO_BUTTON, Atspi.Role.CHECK_BOX]
        ):
            return False

        return rv

    def _is_spinner_entry(self, obj: Atspi.Accessible) -> bool:
        # TODO - JD: This should be in AXUtilities.
        if not self.in_document_content(obj):
            return False

        if not AXUtilities.is_editable(obj):
            return False

        return AXUtilities.is_spin_button(obj) or AXUtilities.is_spin_button(
            AXObject.get_parent(obj),
        )

    def event_is_spinner_noise_deprecated(self, event: Atspi.Event) -> bool:
        """Returns true if event is believed to be spinner noise."""

        # TODO - JD: This should be in AXEventUtilities.
        if not self._is_spinner_entry(event.source):
            return False

        return (
            event.type.startswith("object:text-selection-changed")
            and input_event_manager.get_manager().last_event_was_up_or_down()
        )

    def treat_event_as_spinner_value_change_deprecated(self, event: Atspi.Event) -> bool:
        """Returns true if event should be treated as a spinner value change."""
        # TODO - JD: This should be in AXEventUtilities.

        if event.type.startswith("object:text-caret-moved") and self._is_spinner_entry(
            event.source,
        ):
            if input_event_manager.get_manager().last_event_was_up_or_down():
                obj = self.get_caret_context()[0]
                return event.source == obj

        return False

    def event_is_browser_ui_noise_deprecated(self, event: Atspi.Event) -> bool:
        """Returns true if event is believed to be browser UI noise."""

        # TODO - JD: This is pretty generic and belongs in AXEventUtilities.
        if self.in_document_content(event.source):
            return False

        if event.type.endswith("accessible-name"):
            return (
                AXUtilities.is_status_bar(event.source)
                or AXUtilities.is_label(event.source)
                or AXUtilities.is_frame(event.source)
            )
        return event.type.startswith("object:children-changed")

    def event_is_autocomplete_noise_deprecated(self, event, document=None):
        """Returns true if event is believed to be autocomplete noise."""

        # TODO - JD: This should be in AXEventUtilities. And also fixed.

        in_content = document is not None or self.in_document_content(event.source)
        if not in_content:
            return False

        def is_list_box_item(x):
            return AXUtilities.is_list_box(AXObject.get_parent(x))

        def is_menu_item(x):
            return AXUtilities.is_menu(AXObject.get_parent(x))

        def is_combo_box_item(x):
            return AXUtilities.is_combo_box(AXObject.get_parent(x))

        if AXUtilities.is_editable(event.source) and event.type.startswith("object:text-"):
            obj, _offset = self.get_caret_context(document)
            if is_list_box_item(obj) or is_menu_item(obj):
                return True

            if (
                obj == event.source
                and is_combo_box_item(obj)
                and input_event_manager.get_manager().last_event_was_up_or_down()
            ):
                return True

        return False

    def event_is_browser_ui_autocomplete_noise_deprecated(self, event: Atspi.Event) -> bool:
        """Returns true if event is browser ui autocomplete noise."""

        # TODO - JD: This should be in AXEventUtilities.
        if self.in_document_content(event.source):
            return False

        if self._event_is_browser_ui_autocomplete_text_noise(event):
            return True

        return self._event_is_browser_ui_autocomplete_selection_noise(event)

    def _event_is_browser_ui_autocomplete_selection_noise(self, event):
        # TODO - JD: This should be in AXEventUtilities.
        selection = ["object:selection-changed", "object:state-changed:selected"]
        if event.type not in selection:
            return False

        if not AXUtilities.is_menu_related(event.source):
            return False

        focus = focus_manager.get_manager().get_locus_of_focus()
        if AXUtilities.is_entry(focus) and AXUtilities.is_focused(focus):
            if not input_event_manager.get_manager().last_event_was_up_or_down():
                return True

        return False

    def _event_is_browser_ui_autocomplete_text_noise(self, event: Atspi.Event) -> bool:
        # TODO - JD: This should be in AXEventUtilities.

        if not event.type.startswith(
            "object:text-",
        ) or not AXUtilities.is_single_line_autocomplete_entry(event.source):
            return False

        focus = focus_manager.get_manager().get_locus_of_focus()
        if not AXUtilities.is_selectable(focus):
            return False

        if AXUtilities.is_menu_item_of_any_kind(focus) or AXUtilities.is_list_item(focus):
            return input_event_manager.get_manager().last_event_was_up_or_down()

        return False

    def event_is_browser_ui_page_switch(self, event: Atspi.Event) -> bool:
        """Returns true if event is a browser UI page switch event."""

        # TODO - JD: Move into AXUtilities.

        selection = ["object:selection-changed", "object:state-changed:selected"]
        if event.type not in selection:
            return False

        if not AXUtilities.is_page_tab_list_related(event.source):
            return False

        if self.in_document_content(event.source):
            return False

        return self.in_document_content(focus_manager.get_manager().get_locus_of_focus())

    def event_is_from_locus_of_focus_document(self, event: Atspi.Event) -> bool:
        """Returns true if event comes from the document of the locus of focus."""

        if focus_manager.get_manager().focus_is_active_window():
            focus = self.active_document()
            source = self.get_top_level_document_for_object(event.source)
        else:
            focus = self.get_document_for_object(focus_manager.get_manager().get_locus_of_focus())
            source = self.get_document_for_object(event.source)

        tokens = ["WEB: Event doc:", source, ". Focus doc:", focus, "."]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        if not (source and focus):
            return False

        if source == focus:
            return True

        if not AXObject.is_valid(focus) and AXObject.is_valid(source):
            if self.active_document() == source:
                msg = "WEB: Treating active doc as locusOfFocus doc"
                debug.print_message(debug.LEVEL_INFO, msg, True)
                return True

        return False

    def event_is_irrelevant_selection_changed_event(self, event: Atspi.Event) -> bool:
        """Returns true if event is an irrelevant selection changed event."""

        # TODO - JD: This should be in AXEventUtilities.
        if event.type != "object:selection-changed":
            return False

        focus = focus_manager.get_manager().get_locus_of_focus()
        if not focus:
            msg = "WEB: Selection changed event is relevant (no locusOfFocus)"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return False
        if event.source == focus:
            msg = "WEB: Selection changed event is relevant (is locusOfFocus)"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return False
        if AXUtilities.find_ancestor(focus, lambda x: x == event.source):
            msg = "WEB: Selection changed event is relevant (ancestor of locusOfFocus)"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return False

        # There may be other roles where we need to do this. For now, solve the known one.
        if AXUtilities.is_page_tab_list(event.source):
            tokens = [
                "WEB: Selection changed event is irrelevant (unrelated",
                AXObject.get_role_name(event.source),
                ")",
            ]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return True

        msg = "WEB: Selection changed event is relevant (no reason found to ignore it)"
        debug.print_message(debug.LEVEL_INFO, msg, True)
        return False

    def event_is_for_non_navigable_text_object(self, event):
        """Returns true if event is for an object we're treating as a whole."""

        if not event.type.startswith("object:text-"):
            return False
        return self._treat_object_as_whole(event.source)

    def caret_moved_outside_active_grid(self, event, old_focus=None):
        """Returns true if caret moved outside the active grid."""

        # TODO - JD: This is one of those "noise" functions.
        if not (event and event.type.startswith("object:text-caret-moved")):
            return False

        old_focus = old_focus or focus_manager.get_manager().get_locus_of_focus()
        if not AXUtilities.is_grid_descendant(old_focus):
            return False

        return not AXUtilities.is_grid_descendant(event.source)

    def caret_moved_to_same_page_fragment(self, event, old_focus=None):
        """Returns true if the caret moved to a same-page fragment."""

        if not (event and event.type.startswith("object:text-caret-moved")):
            return False

        if AXUtilities.is_editable(event.source):
            return False

        document = self.active_document()
        fragment = AXUtilities.get_document_uri_fragment(document)
        if not fragment:
            return False

        source_id = AXObject.get_attribute(event.source, "id")
        if source_id and fragment == source_id:
            return True

        old_focus = old_focus or focus_manager.get_manager().get_locus_of_focus()
        if self.is_link(old_focus):
            link = old_focus
        else:
            link = AXUtilities.find_ancestor(old_focus, self.is_link)

        return link and AXHypertext.get_link_uri(link) == AXUtilities.get_uri(document)

    def is_child_of_current_fragment(self, obj: Atspi.Accessible) -> bool:
        """Returns true if obj is a child of the current document fragment."""

        fragment = AXUtilities.get_document_uri_fragment(self.active_document())
        if not fragment:
            return False

        def is_same_fragment(x):
            return AXObject.get_attribute(x, "id") == fragment

        return AXUtilities.find_ancestor(obj, is_same_fragment) is not None

    def is_content_editable_with_embedded_objects(self, obj: Atspi.Accessible) -> bool:
        """Returns true if obj is content editable with embedded objects."""

        # TODO - JD: Revisit all the cases this function is used and white editablility is not
        # enough of a check.
        if not (obj and self.in_document_content(obj)):
            return False

        namespace = self._cache.IS_CONTENT_EDITABLE_WITH_EMBEDDED_OBJECTS
        cached = self._cache.get_for_object(namespace, obj)
        if cached is not ax_cache_manager.MISSING:
            return cached

        rv = False

        def has_text_block_role(x):
            return (
                AXUtilities.is_text_block(x)
                and not self._is_fake_placeholder_for_entry(x)
                and AXUtilities.is_web_element(x)
            )

        if AXUtilities.is_text_input(obj):
            rv = False
        elif AXUtilities.is_multi_line_entry(obj):
            rv = AXUtilities.find_descendant(obj, has_text_block_role) is not None
        elif AXUtilities.is_editable(obj):
            rv = has_text_block_role(obj) or self.is_link(obj)
        elif not self.is_document(obj):
            document = self.get_document_for_object(obj)
            if document:
                rv = self.is_content_editable_with_embedded_objects(document)

        self._cache.set_for_object(namespace, obj, rv)
        return rv

    def _can_have_caret_context(self, obj: Atspi.Accessible) -> bool:
        rv = self._cache.get_caret_context_decision(obj)
        if rv is not None:
            return rv

        if obj is None:
            return False
        if AXObject.is_dead(obj):
            msg = "WEB: Dead object cannot have caret context"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return False
        if not AXObject.is_valid(obj):
            tokens = ["WEB: Invalid object cannot have caret context", obj]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return False
        if not AXUtilities.is_web_element(obj):
            tokens = ["WEB: Non-element cannot have caret context", obj]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return False

        start_time = time.time()
        rv = None
        if AXUtilities.is_focusable(obj):
            tokens = ["WEB: Focusable object can have caret context", obj]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            rv = True
        elif AXUtilities.is_editable(obj):
            tokens = ["WEB: Editable object can have caret context", obj]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            rv = True
        elif AXUtilities.is_landmark(obj):
            tokens = ["WEB: Landmark can have caret context", obj]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            rv = True
        elif AXUtilities.is_table_related(obj, True):
            tokens = ["WEB: Table-related object can have caret context", obj]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            rv = True
        elif AXUtilities.is_tool_tip(obj):
            tokens = ["WEB: Non-focusable tooltip cannot have caret context", obj]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            rv = False
        elif self._is_useless_empty_element(obj):
            tokens = ["WEB: Useless empty element cannot have caret context", obj]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            rv = False
        elif self._is_off_screen_label(obj):
            tokens = ["WEB: Off-screen label cannot have caret context", obj]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            rv = False
        elif self._is_useless_image(obj):
            tokens = ["WEB: Useless image cannot have caret context", obj]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            rv = False
        elif self._is_empty_anchor(obj):
            tokens = ["WEB: Empty anchor cannot have caret context", obj]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            rv = False
        elif self._is_fake_placeholder_for_entry(obj):
            tokens = ["WEB: Fake placeholder for entry cannot have caret context", obj]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            rv = False
        elif AXUtilities.is_presentational_child(obj):
            tokens = ["WEB: Presentational child cannot have caret context", obj]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            rv = False
        elif AXUtilities.has_no_size(obj):
            tokens = ["WEB: Allowing sizeless object to have caret context", obj]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            rv = True
        else:
            tokens = ["WEB: ", obj, "can have caret context."]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            rv = True

        self._cache.set_caret_context_decision(obj, rv)
        msg = f"INFO: _can_have_caret_context took {time.time() - start_time:.4f}s"
        debug.print_message(debug.LEVEL_INFO, msg, True)
        return rv

    def get_caret_context(
        self,
        document: Atspi.Accessible | None = None,
        search_if_needed: bool = True,
    ) -> tuple[Atspi.Accessible, int]:
        """Returns an (obj, offset) tuple representing the current location."""

        tokens = ["WEB: Getting caret context for", document]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        if not AXObject.is_valid(document):
            document = self.active_document()
            tokens = ["WEB: Now getting caret context for", document]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        if not document:
            if not search_if_needed:
                msg = "WEB: Returning None, -1: No document and no search requested."
                debug.print_message(debug.LEVEL_INFO, msg, True)
                return None, -1

            obj, offset = focus_manager.get_manager().get_locus_of_focus(), 0
            if AXObject.supports_text(obj):
                offset = AXText.get_caret_offset(obj)

            tokens = ["WEB: Returning", obj, ", ", offset, "(from locusOfFocus)"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return obj, offset

        context = self._cache.get_context_for_parent(
            self._cache.CARET_CONTEXTS,
            AXObject.get_parent(document),
        )
        if context is not None:
            tokens = ["WEB: Cached context of", document, "is", context[0], ", ", context[1]]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
        else:
            tokens = ["WEB: No cached context for", document, "."]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            obj, offset = None, -1

        if not context or not self.is_top_level_document(document):
            if not search_if_needed:
                msg = (
                    "WEB: Returning None, -1: No top-level document with context "
                    "and no search requested."
                )
                debug.print_message(debug.LEVEL_INFO, msg, True)
                return None, -1
            obj, offset = AXUtilities.search_for_caret_context(
                document, self.caret_policy, self.is_document
            )
        else:
            obj, offset = context

        tokens = ["WEB: Result context of", document, "is", obj, ", ", offset, "."]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)
        self.set_caret_context(obj, offset, document)
        return obj, offset

    def clear_caret_context(self, document: Atspi.Accessible | None = None) -> None:
        """Clears the caret context."""

        # TODO - JD: All the clearing stuff should be unified.
        self.clear_content_cache()
        document = document or self.active_document()
        if not document:
            return

        parent = AXObject.get_parent(document)
        self._cache.discard_context_for_parent(self._cache.CARET_CONTEXTS, parent)
        self._cache.discard_context_for_parent(self._cache.PRIOR_CONTEXTS, parent)

    def handle_event_for_removed_child(self, event):
        """Attempts to recover when the current object has been removed from the document."""

        focus = focus_manager.get_manager().get_locus_of_focus()
        if event.any_data == focus:
            msg = "WEB: Removed child is locus of focus."
            debug.print_message(debug.LEVEL_INFO, msg, True)
        elif AXUtilities.find_ancestor(focus, lambda x: x == event.any_data):
            msg = "WEB: Removed child is ancestor of locus of focus."
            debug.print_message(debug.LEVEL_INFO, msg, True)
        else:
            msg = "WEB: Removed child is not locus of focus nor ancestor of locus of focus."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return False

        if event.detail1 == -1:
            msg = "WEB: Event detail1 is useless."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return False

        obj, offset = None, -1
        notify = True
        child_count = AXObject.get_child_count(event.source)
        if input_event_manager.get_manager().last_event_was_up():
            if event.detail1 >= child_count:
                msg = "WEB: Last child removed. Getting new location from end of parent."
                debug.print_message(debug.LEVEL_INFO, msg, True)
                obj, offset = self.previous_context(event.source, -1)
            elif 0 <= event.detail1 - 1 < child_count:
                child = AXObject.get_child(event.source, event.detail1 - 1)
                tokens = ["WEB: Getting new location from end of previous child", child, "."]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                obj, offset = self.previous_context(child, -1)
            else:
                prev_obj = self.find_previous_object(event.source)
                tokens = [
                    "WEB: Getting new location from end of source's previous object",
                    prev_obj,
                    ".",
                ]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                obj, offset = self.previous_context(prev_obj, -1)

        elif input_event_manager.get_manager().last_event_was_down():
            if event.detail1 == 0:
                msg = "WEB: First child removed. Getting new location from start of parent."
                debug.print_message(debug.LEVEL_INFO, msg, True)
                obj, offset = self.next_context(event.source, -1)
            elif 0 < event.detail1 < child_count:
                child = AXObject.get_child(event.source, event.detail1)
                tokens = [
                    "WEB: Getting new location from start of child",
                    event.detail1,
                    child,
                    ".",
                ]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                obj, offset = self.next_context(child, -1)
            else:
                next_obj = self.find_next_object(event.source)
                tokens = [
                    "WEB: Getting new location from start of source's next object",
                    next_obj,
                    ".",
                ]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                obj, offset = self.next_context(next_obj, -1)

        else:
            notify = False
            # TODO - JD: Can we remove this? Even if it is needed, we now also clear the
            # cache in _handleEventForRemovedSelectableChild. Also, if it is needed, should
            # it be recursive?
            AXObject.clear_cache(event.source, False, "Handling event for removed child.")
            obj, offset = AXUtilities.search_for_caret_context(
                event.source, self.caret_policy, self.is_document
            )
            if obj is None:
                obj = AXUtilities.get_focused_object(event.source)

            if obj and focus_manager.get_manager().focus_is_dead() and AXUtilities.is_focused(obj):
                notify = True

        if obj:
            msg = f"WEB: Setting locusOfFocus and context to: {obj}, {offset}"
            focus_manager.get_manager().set_locus_of_focus(event, obj, notify)
            self.set_caret_context(obj, offset)
            return True

        tokens = ["WEB: Unable to find context for child removed from", event.source]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)
        return False

    def get_prior_context(
        self,
        document: Atspi.Accessible | None = None,
    ) -> tuple[Atspi.Accessible, int] | None:
        """Returns the previously-stored caret context for the given document."""

        if not AXObject.is_valid(document):
            document = self.active_document()

        if document:
            context = self._cache.get_context_for_parent(
                self._cache.PRIOR_CONTEXTS,
                AXObject.get_parent(document),
            )
            if context:
                return context

        return None, -1

    def set_caret_context(
        self,
        obj: Atspi.Accessible | None = None,
        offset: int = -1,
        document: Atspi.Accessible | None = None,
    ) -> None:
        """Sets the caret context in document to (obj, offset)."""

        document = document or self.active_document()
        if not document:
            return

        parent = AXObject.get_parent(document)
        old_obj, old_offset = self._cache.get_context_for_parent(
            self._cache.CARET_CONTEXTS,
            parent,
        ) or (obj, offset)
        self._cache.set_context_for_parent(
            self._cache.PRIOR_CONTEXTS,
            parent,
            (old_obj, old_offset),
        )
        self._cache.set_context_for_parent(
            self._cache.CARET_CONTEXTS,
            parent,
            (obj, offset),
        )

    def first_context(self, obj: Atspi.Accessible, offset: int) -> tuple[Atspi.Accessible, int]:
        """Returns the first viable/valid caret context given obj and offset."""

        self._cache.clear_caret_context_decisions("web first context")
        rv = self._first_context(obj, offset)
        self._cache.clear_caret_context_decisions("web first context")
        return rv

    def _first_context(self, obj: Atspi.Accessible, offset: int) -> tuple[Atspi.Accessible, int]:
        """Returns the first viable caret context at or after obj, offset."""

        tokens = ["WEB: Looking for first caret context for", obj, ", ", offset]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        role = AXObject.get_role(obj)
        look_in_child = [
            Atspi.Role.LIST,
            Atspi.Role.INTERNAL_FRAME,
            Atspi.Role.TABLE,
            Atspi.Role.TABLE_ROW,
        ]
        if (
            role in look_in_child
            and AXObject.get_child_count(obj)
            and not self.treat_as_div(obj, offset)
        ):
            first_child = AXObject.get_child(obj, 0)
            tokens = ["WEB: Will look in child", first_child, "for first caret context"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return self._first_context(first_child, 0)

        treat_as_text = self.treat_as_text_object(obj)

        length = AXText.get_character_count(obj)
        if treat_as_text and offset >= length:
            return self._first_context_at_text_end(obj, offset, length)

        offset = max(0, offset)
        if AXUtilities.is_valid_position(obj, offset, self.caret_policy):
            if not treat_as_text:
                offset = 0
            tokens = ["WEB: First caret context is", obj, ", ", offset]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return obj, offset

        if treat_as_text and self._element_lines_are_single_chars(obj):
            msg = "WEB: EOC in single-char-lines element. Returning context unchanged."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return obj, offset

        return self._first_context_in_child(obj, offset)

    def _first_context_at_text_end(
        self, obj: Atspi.Accessible, offset: int, length: int
    ) -> tuple[Atspi.Accessible, int]:
        """Returns the first caret context when offset is at or past the end of obj's text."""

        if (
            self.is_content_editable_with_embedded_objects(obj)
            and input_event_manager.get_manager().last_event_was_character_navigation()
        ):
            next_obj, next_offset = self.next_context(obj, length)
            if not next_obj:
                tokens = ["WEB: No next object found at end of contenteditable", obj]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            elif not self.is_content_editable_with_embedded_objects(next_obj):
                tokens = [
                    "WEB: Next object",
                    next_obj,
                    "found at end of contenteditable",
                    obj,
                    "is not editable",
                ]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            else:
                tokens = [
                    "WEB: First caret context at end of contenteditable",
                    obj,
                    "is next context",
                    next_obj,
                    ", ",
                    next_offset,
                ]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                return next_obj, next_offset

        tokens = [
            "WEB: First caret context at end of",
            obj,
            ", ",
            offset,
            "is",
            obj,
            ", ",
            length,
        ]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)
        return obj, length

    def _first_context_in_child(
        self, obj: Atspi.Accessible, offset: int
    ) -> tuple[Atspi.Accessible, int]:
        """Returns the first caret context found by descending into the child at offset."""

        child = AXUtilities.find_child_at_offset(obj, offset)
        if not child:
            msg = "WEB: Child at offset is null. Returning context unchanged."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return obj, offset

        if self.is_document(obj):
            while self._is_useless_empty_element(child):
                tokens = ["WEB: Child", child, "of", obj, "at offset", offset, "cannot be context."]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                offset += 1
                child = AXUtilities.find_child_at_offset(obj, offset)

        if self._is_empty_anchor(child):
            next_obj, next_offset = self.next_context(obj, offset)
            if next_obj:
                tokens = [
                    "WEB: First caret context at end of empty anchor",
                    obj,
                    "is next context",
                    next_obj,
                    ", ",
                    next_offset,
                ]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                return next_obj, next_offset

        if not self._can_have_caret_context(child):
            tokens = ["WEB: Child", child, "cannot be context. Returning", obj, ", ", offset]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return obj, offset

        tokens = ["WEB: Looking in child", child, "for first caret context for", obj, ", ", offset]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)
        return self._first_context(child, 0)
