#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA  02110-1301 USA.
#
# Forked from Orca screen reader.
# Cthulhu project: https://git.stormux.org/storm/cthulhu

__id__        = "$Id$"
__version__   = "$Revision$"
__date__      = "$Date$"
__copyright__ = "Copyright (c) 2005-2009 Sun Microsystems Inc." \
                "Copyright (c) 2010 Cthulhu Team." \
                "Copyright (c) 2014-2015 Igalia, S.L."
__license__   = "LGPL"

import time

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

from cthulhu import caret_navigation
from cthulhu import cmdnames
from cthulhu import keybindings
from cthulhu import debug
from cthulhu import guilabels
from cthulhu import input_event
from cthulhu import input_event_manager
from cthulhu import liveregions
from cthulhu import messages
from cthulhu import cthulhu
from cthulhu import cthulhu_state
from cthulhu import settings
from cthulhu import settings_manager
from cthulhu import speech
from cthulhu import speechserver
from cthulhu import structural_navigation
from cthulhu import sound_theme_manager
from cthulhu.acss import ACSS
from cthulhu.scripts import default
from cthulhu.ax_object import AXObject
from cthulhu.ax_text import AXText
from cthulhu.ax_utilities import AXUtilities

from .bookmarks import Bookmarks
from .braille_generator import BrailleGenerator
from .sound_generator import SoundGenerator
from .speech_generator import SpeechGenerator
from .tutorial_generator import TutorialGenerator
from .script_utilities import Utilities

_settingsManager = None  # Removed - use cthulhu.cthulhuApp.settingsManager

def _log(message, reason=None, timestamp=True, stack=False):
    debug.print_log(debug.LEVEL_INFO, "WEB", message, reason, timestamp, stack)

def _log_tokens(tokens, reason=None, timestamp=True, stack=False):
    debug.print_log_tokens(debug.LEVEL_INFO, "WEB", tokens, reason, timestamp, stack)


class Script(default.Script):

    def __init__(self, app):
        super().__init__(app)

        self._sayAllContents = []
        self._inSayAll = False
        self._sayAllIsInterrupted = False
        self._loadingDocumentContent = False
        self._madeFindAnnouncement = False
        self._lastCommandWasCaretNav = False
        self._lastCommandWasStructNav = False
        self._lastCommandWasMouseButton = False
        self._lastMouseButtonContext = None, -1
        self._lastMouseOverObject = None
        self._preMouseOverContext = None, -1
        self._inMouseOverObject = False
        self._inFocusMode = False
        self._focusModeIsSticky = False
        self._browseModeIsSticky = False
        self._navSuspended = False
        self._structNavWasEnabled = None

        if cthulhu.cthulhuApp.settingsManager.getSetting('caretNavigationEnabled') is None:
            cthulhu.cthulhuApp.settingsManager.setSetting('caretNavigationEnabled', True)
        if cthulhu.cthulhuApp.settingsManager.getSetting('sayAllOnLoad') is None:
            cthulhu.cthulhuApp.settingsManager.setSetting('sayAllOnLoad', True)
        if cthulhu.cthulhuApp.settingsManager.getSetting('pageSummaryOnLoad') is None:
            cthulhu.cthulhuApp.settingsManager.setSetting('pageSummaryOnLoad', True)

        self._changedLinesOnlyCheckButton = None
        self._controlCaretNavigationCheckButton = None
        self._minimumFindLengthAdjustment = None
        self._minimumFindLengthLabel = None
        self._minimumFindLengthSpinButton = None
        self._pageSummaryOnLoadCheckButton = None
        self._sayAllOnLoadCheckButton = None
        self._skipBlankCellsCheckButton = None
        self._speakCellCoordinatesCheckButton = None
        self._speakCellHeadersCheckButton = None
        self._speakCellSpanCheckButton = None
        self._speakResultsDuringFindCheckButton = None
        self._structuralNavigationCheckButton = None
        self._autoFocusModeStructNavCheckButton = None
        self._autoFocusModeCaretNavCheckButton = None
        self._autoFocusModeNativeNavCheckButton = None
        self._layoutModeCheckButton = None

        self.attributeNamesDict["invalid"] = "text-spelling"
        self.attributeNamesDict["text-align"] = "justification"
        self.attributeNamesDict["text-indent"] = "indent"

    @staticmethod
    def _selectionContentKey(content):
        obj, start, end, string = content
        return hash(obj), start, end, string

    def _clearSyntheticWebSelection(self):
        pointOfReference = getattr(self, "pointOfReference", None)
        if isinstance(pointOfReference, dict):
            pointOfReference.pop("syntheticWebSelection", None)

    def _compareCaretContexts(self, firstObj, firstOffset, secondObj, secondOffset):
        if firstObj == secondObj:
            if firstOffset < secondOffset:
                return -1
            if firstOffset > secondOffset:
                return 1
            return 0

        return self.utilities.pathComparison(AXObject.get_path(firstObj), AXObject.get_path(secondObj))

    def _getContentsBetweenCaretContexts(self, startObj, startOffset, endObj, endOffset):
        contents = []
        currentObj, currentOffset = startObj, startOffset
        seen = set()

        while currentObj and (currentObj, currentOffset) != (endObj, endOffset):
            key = (hash(currentObj), currentOffset)
            if key in seen:
                break
            seen.add(key)

            contents.extend(self.utilities.getCharacterContentsAtOffset(currentObj, currentOffset))
            currentObj, currentOffset = self.utilities.nextContext(currentObj, currentOffset)

        return contents

    def _presentSyntheticCaretSelection(
        self,
        event,
        document,
        obj,
        offset,
        focusOverride=None,
    ):
        if not self.utilities.lastInputEventWasCaretNavWithSelection():
            return False

        if focusOverride is not None:
            focusObj, focusOffset = focusOverride
        else:
            manager = input_event_manager.get_manager()
            if manager.last_event_was_forward_caret_selection():
                focusObj, focusOffset = self.utilities.nextContext(obj, offset)
            elif manager.last_event_was_backward_caret_selection():
                focusObj, focusOffset = self.utilities.previousContext(obj, offset)
            else:
                return False

        if not focusObj:
            return False

        selectionState = self.pointOfReference.get("syntheticWebSelection", {})
        if selectionState.get("document") == document:
            anchorObj = selectionState.get("anchorObj", obj)
            anchorOffset = selectionState.get("anchorOffset", offset)
            oldContents = selectionState.get("contents", [])
        else:
            anchorObj, anchorOffset = obj, offset
            oldContents = []

        if self._compareCaretContexts(anchorObj, anchorOffset, focusObj, focusOffset) <= 0:
            startObj, startOffset = anchorObj, anchorOffset
            endObj, endOffset = focusObj, focusOffset
        else:
            startObj, startOffset = focusObj, focusOffset
            endObj, endOffset = anchorObj, anchorOffset

        newContents = self._getContentsBetweenCaretContexts(startObj, startOffset, endObj, endOffset)
        self.utilities.setCaretContext(focusObj, focusOffset, document)
        cthulhu.setLocusOfFocus(event, focusObj, False, True)
        self.updateBraille(focusObj)

        if not newContents:
            self._clearSyntheticWebSelection()
            if oldContents:
                self.speakContents(oldContents)
                self.speakMessage(messages.TEXT_UNSELECTED)
            return True

        self.pointOfReference["syntheticWebSelection"] = {
            "document": document,
            "anchorObj": anchorObj,
            "anchorOffset": anchorOffset,
            "focusObj": focusObj,
            "focusOffset": focusOffset,
            "contents": newContents,
            "string": "".join(item[3] for item in newContents),
        }

        oldKeys = [self._selectionContentKey(item) for item in oldContents]
        newKeys = [self._selectionContentKey(item) for item in newContents]
        deltaContents = newContents
        message = messages.TEXT_SELECTED

        if oldContents:
            if len(newKeys) >= len(oldKeys) and newKeys[:len(oldKeys)] == oldKeys:
                deltaContents = newContents[len(oldContents):]
            elif len(newKeys) >= len(oldKeys) and newKeys[-len(oldKeys):] == oldKeys:
                deltaContents = newContents[:-len(oldContents)]
            elif len(oldKeys) >= len(newKeys) and oldKeys[:len(newKeys)] == newKeys:
                deltaContents = oldContents[len(newContents):]
                message = messages.TEXT_UNSELECTED
            elif len(oldKeys) >= len(newKeys) and oldKeys[-len(newKeys):] == newKeys:
                deltaContents = oldContents[:-len(newContents)]
                message = messages.TEXT_UNSELECTED

        if deltaContents:
            self.speakContents(deltaContents)
            self.speakMessage(message)

        return True

    def activate(self):
        """Called when this script is activated."""

        _log_tokens(["Activating script for", self.app])

        focus = cthulhu_state.locusOfFocus
        inApp = AXObject.get_application(focus) == self.app if focus else False
        inDoc = self._focusInDocumentContent()
        suspend = not (inDoc and inApp)
        reason = "activation-outside-document" if suspend else "activation-in-document"

        self._setNavigationSuspended(suspend, reason)

        super().activate()

    def deactivate(self):
        """Called when this script is deactivated."""

        self._sayAllContents = []
        self._inSayAll = False
        self._sayAllIsInterrupted = False
        self._loadingDocumentContent = False
        self._madeFindAnnouncement = False
        self._lastCommandWasCaretNav = False
        self._lastCommandWasStructNav = False
        self._lastCommandWasMouseButton = False
        self._lastMouseButtonContext = None, -1
        self._lastMouseOverObject = None
        self._preMouseOverContext = None, -1
        self._inMouseOverObject = False
        self.utilities.clearCachedObjects()
        # Ensure navigation commands are re-enabled for the next activation.
        self._setNavigationSuspended(False, "script deactivation")
        self.removeKeyGrabs()

    def _setNavigationSuspended(self, suspend, reason=""):
        """Suspend or resume navigation command handling (caret & structural)."""

        if suspend == self._navSuspended:
            return

        self._navSuspended = suspend

        # Structural navigation has an enabled flag we can toggle.
        if suspend:
            self._structNavWasEnabled = self.structuralNavigation.enabled
            self.structuralNavigation.enabled = False
        else:
            if self._structNavWasEnabled is not None:
                self.structuralNavigation.enabled = self._structNavWasEnabled
            self._structNavWasEnabled = None

        _log_tokens(["Navigation suspended:", suspend], reason)

    def _focusInDocumentContent(self):
        focus = cthulhu_state.locusOfFocus
        if not focus or AXObject.is_dead(focus):
            return False

        return self.utilities.getDocumentForObject(focus) is not None

    def getAppKeyBindings(self):
        """Returns the application-specific keybindings for this script."""

        keyBindings = keybindings.KeyBindings()

        structNavBindings = self.structuralNavigation.keyBindings
        for keyBinding in structNavBindings.keyBindings:
            keyBindings.add(keyBinding)

        caretNavBindings = self.caretNavigation.get_bindings()
        for keyBinding in caretNavBindings.keyBindings:
            keyBindings.add(keyBinding)

        liveRegionBindings = self.liveRegionManager.keyBindings
        for keyBinding in liveRegionBindings.keyBindings:
            keyBindings.add(keyBinding)

        keyBindings.add(
            keybindings.KeyBinding(
                "a",
                keybindings.defaultModifierMask,
                keybindings.CTHULHU_MODIFIER_MASK,
                self.inputEventHandlers.get("togglePresentationModeHandler")))

        keyBindings.add(
            keybindings.KeyBinding(
                "a",
                keybindings.defaultModifierMask,
                keybindings.CTHULHU_MODIFIER_MASK,
                self.inputEventHandlers.get("enableStickyFocusModeHandler"),
                2))

        keyBindings.add(
            keybindings.KeyBinding(
                "a",
                keybindings.defaultModifierMask,
                keybindings.CTHULHU_MODIFIER_MASK,
                self.inputEventHandlers.get("enableStickyBrowseModeHandler"),
                3))

        keyBindings.add(
            keybindings.KeyBinding(
                "",
                keybindings.defaultModifierMask,
                keybindings.NO_MODIFIER_MASK,
                self.inputEventHandlers.get("toggleLayoutModeHandler")))


        layout = cthulhu.cthulhuApp.settingsManager.getSetting('keyboardLayout')
        if layout == settings.GENERAL_KEYBOARD_LAYOUT_DESKTOP:
            key = "KP_Multiply"
        else:
            key = "0"

        keyBindings.add(
            keybindings.KeyBinding(
                key,
                keybindings.defaultModifierMask,
                keybindings.CTHULHU_MODIFIER_MASK,
                self.inputEventHandlers.get("moveToMouseOverHandler")))

        return keyBindings

    def setupInputEventHandlers(self):
        """Defines InputEventHandlers for this script."""

        super().setupInputEventHandlers()
        self.inputEventHandlers.update(
            self.structuralNavigation.inputEventHandlers)

        self.inputEventHandlers.update(
            self.caretNavigation.get_handlers())

        self.inputEventHandlers.update(
            self.liveRegionManager.inputEventHandlers)

        self.inputEventHandlers["sayAllHandler"] = \
            input_event.InputEventHandler(
                Script.sayAll,
                cmdnames.SAY_ALL)

        self.inputEventHandlers["panBrailleLeftHandler"] = \
            input_event.InputEventHandler(
                Script.panBrailleLeft,
                cmdnames.PAN_BRAILLE_LEFT,
                False) # Do not enable learn mode for this action

        self.inputEventHandlers["panBrailleRightHandler"] = \
            input_event.InputEventHandler(
                Script.panBrailleRight,
                cmdnames.PAN_BRAILLE_RIGHT,
                False) # Do not enable learn mode for this action

        self.inputEventHandlers["moveToMouseOverHandler"] = \
            input_event.InputEventHandler(
                Script.moveToMouseOver,
                cmdnames.MOUSE_OVER_MOVE)

        self.inputEventHandlers["togglePresentationModeHandler"] = \
            input_event.InputEventHandler(
                Script.togglePresentationMode,
                cmdnames.TOGGLE_PRESENTATION_MODE)

        self.inputEventHandlers["enableStickyFocusModeHandler"] = \
            input_event.InputEventHandler(
                Script.enableStickyFocusMode,
                cmdnames.SET_FOCUS_MODE_STICKY)

        self.inputEventHandlers["enableStickyBrowseModeHandler"] = \
            input_event.InputEventHandler(
                Script.enableStickyBrowseMode,
                cmdnames.SET_BROWSE_MODE_STICKY)

        self.inputEventHandlers["toggleLayoutModeHandler"] = \
            input_event.InputEventHandler(
                Script.toggleLayoutMode,
                cmdnames.TOGGLE_LAYOUT_MODE)

        self.inputEventHandlers["activateClickableHandler"] = \
            input_event.InputEventHandler(
                Script.activateClickableElement,
                "Activate clickable element")


    def getBookmarks(self):
        """Returns the "bookmarks" class for this script."""

        try:
            return self.bookmarks
        except AttributeError:
            self.bookmarks = Bookmarks(self)
            return self.bookmarks

    def getBrailleGenerator(self):
        """Returns the braille generator for this script."""

        return BrailleGenerator(self)

    def getCaretNavigation(self):
        """Returns the caret navigation support for this script."""

        return caret_navigation.CaretNavigation(self)

    def getEnabledStructuralNavigationTypes(self):
        """Returns the structural navigation object types for this script."""

        return [structural_navigation.StructuralNavigation.BLOCKQUOTE,
                structural_navigation.StructuralNavigation.BUTTON,
                structural_navigation.StructuralNavigation.CHECK_BOX,
                structural_navigation.StructuralNavigation.CHUNK,
                structural_navigation.StructuralNavigation.CLICKABLE,
                structural_navigation.StructuralNavigation.COMBO_BOX,
                structural_navigation.StructuralNavigation.CONTAINER,
                structural_navigation.StructuralNavigation.ENTRY,
                structural_navigation.StructuralNavigation.FORM_FIELD,
                structural_navigation.StructuralNavigation.HEADING,
                structural_navigation.StructuralNavigation.IFRAME,
                structural_navigation.StructuralNavigation.IMAGE,
                structural_navigation.StructuralNavigation.LANDMARK,
                structural_navigation.StructuralNavigation.LINK,
                structural_navigation.StructuralNavigation.LIST,
                structural_navigation.StructuralNavigation.LIST_ITEM,
                structural_navigation.StructuralNavigation.LIVE_REGION,
                structural_navigation.StructuralNavigation.PARAGRAPH,
                structural_navigation.StructuralNavigation.RADIO_BUTTON,
                structural_navigation.StructuralNavigation.SEPARATOR,
                structural_navigation.StructuralNavigation.TABLE,
                structural_navigation.StructuralNavigation.TABLE_CELL,
                structural_navigation.StructuralNavigation.UNVISITED_LINK,
                structural_navigation.StructuralNavigation.VISITED_LINK]

    def getLiveRegionManager(self):
        """Returns the live region support for this script."""

        return liveregions.LiveRegionManager(self)

    def getSoundGenerator(self):
        """Returns the sound generator for this script."""

        return SoundGenerator(self)

    def getSpeechGenerator(self):
        """Returns the speech generator for this script."""

        return SpeechGenerator(self)

    def getTutorialGenerator(self):
        """Returns the tutorial generator for this script."""

        return TutorialGenerator(self)

    def getUtilities(self):
        """Returns the utilities for this script."""

        return Utilities(self)

    def getAppPreferencesGUI(self):
        """Return a GtkGrid containing app-unique configuration items."""

        grid = Gtk.Grid()
        grid.set_border_width(12)

        generalFrame = Gtk.Frame()
        grid.attach(generalFrame, 0, 0, 1, 1)

        label = Gtk.Label(label=f"<b>{guilabels.PAGE_NAVIGATION}</b>")
        label.set_use_markup(True)
        generalFrame.set_label_widget(label)

        generalAlignment = Gtk.Alignment.new(0.5, 0.5, 1, 1)
        generalAlignment.set_padding(0, 0, 12, 0)
        generalFrame.add(generalAlignment)
        generalGrid = Gtk.Grid()
        generalAlignment.add(generalGrid)

        label = guilabels.USE_CARET_NAVIGATION
        value = cthulhu.cthulhuApp.settingsManager.getSetting('caretNavigationEnabled')
        self._controlCaretNavigationCheckButton = \
            Gtk.CheckButton.new_with_mnemonic(label)
        self._controlCaretNavigationCheckButton.set_active(value)
        generalGrid.attach(self._controlCaretNavigationCheckButton, 0, 0, 1, 1)

        label = guilabels.AUTO_FOCUS_MODE_CARET_NAV
        value = cthulhu.cthulhuApp.settingsManager.getSetting('caretNavTriggersFocusMode')
        self._autoFocusModeCaretNavCheckButton = Gtk.CheckButton.new_with_mnemonic(label)
        self._autoFocusModeCaretNavCheckButton.set_active(value)
        generalGrid.attach(self._autoFocusModeCaretNavCheckButton, 0, 1, 1, 1)

        label = guilabels.USE_STRUCTURAL_NAVIGATION
        value = self.structuralNavigation.enabled
        self._structuralNavigationCheckButton = \
            Gtk.CheckButton.new_with_mnemonic(label)
        self._structuralNavigationCheckButton.set_active(value)
        generalGrid.attach(self._structuralNavigationCheckButton, 0, 2, 1, 1)

        label = guilabels.AUTO_FOCUS_MODE_STRUCT_NAV
        value = cthulhu.cthulhuApp.settingsManager.getSetting('structNavTriggersFocusMode')
        self._autoFocusModeStructNavCheckButton = Gtk.CheckButton.new_with_mnemonic(label)
        self._autoFocusModeStructNavCheckButton.set_active(value)
        generalGrid.attach(self._autoFocusModeStructNavCheckButton, 0, 3, 1, 1)

        label = guilabels.AUTO_FOCUS_MODE_NATIVE_NAV
        value = cthulhu.cthulhuApp.settingsManager.getSetting('nativeNavTriggersFocusMode')
        self._autoFocusModeNativeNavCheckButton = Gtk.CheckButton.new_with_mnemonic(label)
        self._autoFocusModeNativeNavCheckButton.set_active(value)
        generalGrid.attach(self._autoFocusModeNativeNavCheckButton, 0, 4, 1, 1)

        label = guilabels.READ_PAGE_UPON_LOAD
        value = cthulhu.cthulhuApp.settingsManager.getSetting('sayAllOnLoad')
        self._sayAllOnLoadCheckButton = Gtk.CheckButton.new_with_mnemonic(label)
        self._sayAllOnLoadCheckButton.set_active(value)
        generalGrid.attach(self._sayAllOnLoadCheckButton, 0, 5, 1, 1)

        label = guilabels.PAGE_SUMMARY_UPON_LOAD
        value = cthulhu.cthulhuApp.settingsManager.getSetting('pageSummaryOnLoad')
        self._pageSummaryOnLoadCheckButton = Gtk.CheckButton.new_with_mnemonic(label)
        self._pageSummaryOnLoadCheckButton.set_active(value)
        generalGrid.attach(self._pageSummaryOnLoadCheckButton, 0, 6, 1, 1)

        label = guilabels.CONTENT_LAYOUT_MODE
        value = cthulhu.cthulhuApp.settingsManager.getSetting('layoutMode')
        self._layoutModeCheckButton = Gtk.CheckButton.new_with_mnemonic(label)
        self._layoutModeCheckButton.set_active(value)
        generalGrid.attach(self._layoutModeCheckButton, 0, 7, 1, 1)

        tableFrame = Gtk.Frame()
        grid.attach(tableFrame, 0, 1, 1, 1)

        label = Gtk.Label(label=f"<b>{guilabels.TABLE_NAVIGATION}</b>")
        label.set_use_markup(True)
        tableFrame.set_label_widget(label)

        tableAlignment = Gtk.Alignment.new(0.5, 0.5, 1, 1)
        tableAlignment.set_padding(0, 0, 12, 0)
        tableFrame.add(tableAlignment)
        tableGrid = Gtk.Grid()
        tableAlignment.add(tableGrid)

        label = guilabels.TABLE_SPEAK_CELL_COORDINATES
        value = cthulhu.cthulhuApp.settingsManager.getSetting('speakCellCoordinates')
        self._speakCellCoordinatesCheckButton = \
            Gtk.CheckButton.new_with_mnemonic(label)
        self._speakCellCoordinatesCheckButton.set_active(value)
        tableGrid.attach(self._speakCellCoordinatesCheckButton, 0, 0, 1, 1)

        label = guilabels.TABLE_SPEAK_CELL_SPANS
        value = cthulhu.cthulhuApp.settingsManager.getSetting('speakCellSpan')
        self._speakCellSpanCheckButton = \
            Gtk.CheckButton.new_with_mnemonic(label)
        self._speakCellSpanCheckButton.set_active(value)
        tableGrid.attach(self._speakCellSpanCheckButton, 0, 1, 1, 1)

        label = guilabels.TABLE_ANNOUNCE_CELL_HEADER
        value = cthulhu.cthulhuApp.settingsManager.getSetting('speakCellHeaders')
        self._speakCellHeadersCheckButton = \
            Gtk.CheckButton.new_with_mnemonic(label)
        self._speakCellHeadersCheckButton.set_active(value)
        tableGrid.attach(self._speakCellHeadersCheckButton, 0, 2, 1, 1)

        label = guilabels.TABLE_SKIP_BLANK_CELLS
        value = cthulhu.cthulhuApp.settingsManager.getSetting('skipBlankCells')
        self._skipBlankCellsCheckButton = \
            Gtk.CheckButton.new_with_mnemonic(label)
        self._skipBlankCellsCheckButton.set_active(value)
        tableGrid.attach(self._skipBlankCellsCheckButton, 0, 3, 1, 1)

        findFrame = Gtk.Frame()
        grid.attach(findFrame, 0, 2, 1, 1)

        label = Gtk.Label(label=f"<b>{guilabels.FIND_OPTIONS}</b>")
        label.set_use_markup(True)
        findFrame.set_label_widget(label)

        findAlignment = Gtk.Alignment.new(0.5, 0.5, 1, 1)
        findAlignment.set_padding(0, 0, 12, 0)
        findFrame.add(findAlignment)
        findGrid = Gtk.Grid()
        findAlignment.add(findGrid)

        verbosity = cthulhu.cthulhuApp.settingsManager.getSetting('findResultsVerbosity')

        label = guilabels.FIND_SPEAK_RESULTS
        value = verbosity != settings.FIND_SPEAK_NONE
        self._speakResultsDuringFindCheckButton = \
            Gtk.CheckButton.new_with_mnemonic(label)
        self._speakResultsDuringFindCheckButton.set_active(value)
        findGrid.attach(self._speakResultsDuringFindCheckButton, 0, 0, 1, 1)

        label = guilabels.FIND_ONLY_SPEAK_CHANGED_LINES
        value = verbosity == settings.FIND_SPEAK_IF_LINE_CHANGED
        self._changedLinesOnlyCheckButton = \
            Gtk.CheckButton.new_with_mnemonic(label)
        self._changedLinesOnlyCheckButton.set_active(value)
        findGrid.attach(self._changedLinesOnlyCheckButton, 0, 1, 1, 1)

        hgrid = Gtk.Grid()
        findGrid.attach(hgrid, 0, 2, 1, 1)

        self._minimumFindLengthLabel = \
              Gtk.Label(label=guilabels.FIND_MINIMUM_MATCH_LENGTH)
        self._minimumFindLengthLabel.set_alignment(0, 0.5)
        hgrid.attach(self._minimumFindLengthLabel, 0, 0, 1, 1)

        self._minimumFindLengthAdjustment = \
            Gtk.Adjustment(cthulhu.cthulhuApp.settingsManager.getSetting(
                'findResultsMinimumLength'), 0, 20, 1)
        self._minimumFindLengthSpinButton = Gtk.SpinButton()
        self._minimumFindLengthSpinButton.set_adjustment(
            self._minimumFindLengthAdjustment)
        hgrid.attach(self._minimumFindLengthSpinButton, 1, 0, 1, 1)
        self._minimumFindLengthLabel.set_mnemonic_widget(
            self._minimumFindLengthSpinButton)

        grid.show_all()
        return grid

    def getPreferencesFromGUI(self):
        """Returns a dictionary with the app-specific preferences."""

        if not self._speakResultsDuringFindCheckButton.get_active():
            verbosity = settings.FIND_SPEAK_NONE
        elif self._changedLinesOnlyCheckButton.get_active():
            verbosity = settings.FIND_SPEAK_IF_LINE_CHANGED
        else:
            verbosity = settings.FIND_SPEAK_ALL

        return {
            'findResultsVerbosity': verbosity,
            'findResultsMinimumLength': self._minimumFindLengthSpinButton.get_value(),
            'sayAllOnLoad': self._sayAllOnLoadCheckButton.get_active(),
            'pageSummaryOnLoad': self._pageSummaryOnLoadCheckButton.get_active(),
            'structuralNavigationEnabled': self._structuralNavigationCheckButton.get_active(),
            'structNavTriggersFocusMode': self._autoFocusModeStructNavCheckButton.get_active(),
            'caretNavigationEnabled': self._controlCaretNavigationCheckButton.get_active(),
            'caretNavTriggersFocusMode': self._autoFocusModeCaretNavCheckButton.get_active(),
            'nativeNavTriggersFocusMode': self._autoFocusModeNativeNavCheckButton.get_active(),
            'speakCellCoordinates': self._speakCellCoordinatesCheckButton.get_active(),
            'layoutMode': self._layoutModeCheckButton.get_active(),
            'speakCellSpan': self._speakCellSpanCheckButton.get_active(),
            'speakCellHeaders': self._speakCellHeadersCheckButton.get_active(),
            'skipBlankCells': self._skipBlankCellsCheckButton.get_active()
        }

    def skipObjectEvent(self, event):
        """Returns True if this object event should be skipped."""

        if event.type.startswith('object:state-changed:focused') and event.detail1:
            if AXUtilities.is_link(event.source):
                return False
        elif event.type.startswith('object:children-changed'):
            if AXUtilities.is_dialog(event.any_data):
                return False

        return super().skipObjectEvent(event)

    def presentationInterrupt(self):
        super().presentationInterrupt()
        msg = "WEB: Flushing live region messages"
        debug.printMessage(debug.LEVEL_INFO, msg, True)
        self.liveRegionManager.flushMessages()

    def updateKeyboardEventState(self, keyboardEvent, handler):
        """Update internal state for a keyboard event without deciding consumption."""

        # We need to do this here. Cthulhu caret and structural navigation
        # often result in the user being repositioned without our getting
        # a corresponding AT-SPI event. Without an AT-SPI event, script.py
        # won't know to dump the generator cache. See bgo#618827.
        self.generatorCache = {}
        self._lastMouseButtonContext = None, -1
        super().updateKeyboardEventState(keyboardEvent, handler)

    def shouldConsumeKeyboardEvent(self, keyboardEvent, handler):
        """Returns True if the script will consume this keyboard event."""

        if handler and self.caretNavigation.handles_navigation(handler):
            consumes = self.useCaretNavigationModel(keyboardEvent)
            self._lastCommandWasCaretNav = consumes
            self._lastCommandWasStructNav = False
            self._lastCommandWasMouseButton = False
            return consumes

        if handler and handler.function in self.structuralNavigation.functions:
            consumes = self.useStructuralNavigationModel()
            self._lastCommandWasCaretNav = False
            self._lastCommandWasStructNav = consumes
            self._lastCommandWasMouseButton = False
            return consumes

        if handler and handler.function in self.liveRegionManager.functions:
            # This is temporary.
            consumes = self.useStructuralNavigationModel()
            self._lastCommandWasCaretNav = False
            self._lastCommandWasStructNav = consumes
            self._lastCommandWasMouseButton = False
            return consumes

        if not keyboardEvent.is_modifier_key():
            self._lastCommandWasCaretNav = False
            self._lastCommandWasStructNav = False
            self._lastCommandWasMouseButton = False

        # Check parent first
        consumes = super().shouldConsumeKeyboardEvent(keyboardEvent, handler)
        
        # If parent doesn't consume Return key, try our clickable fallback
        if not consumes and keyboardEvent.event_string == "Return":
            return self._tryClickableActivation(keyboardEvent)

        return consumes

    def getEnabledKeyBindings(self):
        all = super().getEnabledKeyBindings()
        ret = []
        for b in all:
            if b.handler and self.caretNavigation.handles_navigation(b.handler):
                if self.useCaretNavigationModel(None, False):
                    ret.append(b)
            elif b.handler and b.handler.function in self.structuralNavigation.functions:
                if self.useStructuralNavigationModel(False):
                    ret.append(b)
            elif b.handler and b.handler.function in self.liveRegionManager.functions:
                # This is temporary.
                if self.useStructuralNavigationModel(False):
                    ret.append(b)
            else:
                ret.append(b)
        return ret

    def consumesBrailleEvent(self, brailleEvent):
        """Returns True if the script will consume this braille event."""

        self._lastCommandWasCaretNav = False
        self._lastCommandWasStructNav = False
        self._lastCommandWasMouseButton = False
        return super().consumesBrailleEvent(brailleEvent)

    # TODO - JD: This needs to be moved out of the scripts.
    def textLines(self, obj, offset=None):
        """Creates a generator that can be used to iterate document content."""

        if not self.utilities.inDocumentContent():
            tokens = ["WEB: textLines called for non-document content", obj]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            super().textLines(obj, offset)
            return

        self._sayAllIsInterrupted = False

        sayAllStyle = cthulhu.cthulhuApp.settingsManager.getSetting('sayAllStyle')
        sayAllBySentence = sayAllStyle == settings.SAYALL_STYLE_SENTENCE
        if offset is None:
            obj, characterOffset = self.utilities.getCaretContext()
        else:
            characterOffset = offset
        priorObj, priorOffset = self.utilities.getPriorContext()

        # TODO - JD: This is sad, but it's better than the old, broken
        # clumpUtterances(). We really need to fix the speechservers'
        # SayAll support. In the meantime, the generators should be
        # providing one ACSS per string.
        def _parseUtterances(utterances):
            elements, voices = [], []
            for u in utterances:
                if isinstance(u, list):
                    e, v = _parseUtterances(u)
                    elements.extend(e)
                    voices.extend(v)
                elif isinstance(u, str):
                    elements.append(u)
                elif isinstance(u, ACSS):
                    voices.append(u)
            return elements, voices

        self._inSayAll = True
        done = False
        while not done:
            if sayAllBySentence:
                contents = self.utilities.getSentenceContentsAtOffset(obj, characterOffset)
            else:
                contents = self.utilities.getLineContentsAtOffset(obj, characterOffset)
            self._sayAllContents = contents
            for i, content in enumerate(contents):
                obj, startOffset, endOffset, text = content
                tokens = ["WEB SAY ALL CONTENT:",
                          i, ". ", obj, "'", text, "' (", startOffset, "-", endOffset, ")"]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)

                if self.utilities.isInferredLabelForContents(content, contents):
                    continue

                if startOffset == endOffset:
                    continue

                if self.utilities.isLabellingInteractiveElement(obj):
                    continue

                if self.utilities.isLinkAncestorOfImageInContents(obj, contents):
                    continue

                utterances = self.speechGenerator.generateContents(
                    [content], eliminatePauses=True, priorObj=priorObj)
                priorObj = obj

                elements, voices = _parseUtterances(utterances)
                if len(elements) != len(voices):
                    continue

                for i, element in enumerate(elements):
                    context = speechserver.SayAllContext(
                        obj, element, startOffset, endOffset)
                    tokens = ["WEB", context]
                    debug.printTokens(debug.LEVEL_INFO, tokens, True)
                    self._sayAllContexts.append(context)
                    self.eventSynthesizer.scroll_into_view(obj, startOffset, endOffset)
                    yield [context, voices[i]]

            lastObj, lastOffset = contents[-1][0], contents[-1][2]
            obj, characterOffset = self.utilities.findNextCaretInOrder(lastObj, lastOffset - 1)
            if obj == lastObj and characterOffset <= lastOffset:
                obj, characterOffset = self.utilities.findNextCaretInOrder(lastObj, lastOffset)
            if obj == lastObj and characterOffset <= lastOffset:
                tokens = ["WEB: Cycle within object detected in textLines. Last:",
                          lastObj, ", ", lastOffset, "Next:", obj, ", ", characterOffset]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
                break

            done = obj is None

        self._inSayAll = False
        self._sayAllContents = []
        self._sayAllContexts = []

        msg = "WEB: textLines complete. Verifying SayAll status"
        debug.printMessage(debug.LEVEL_INFO, msg, True)
        self.inSayAll()

    def presentFindResults(self, obj, offset):
        """Updates the context and presents the find results if appropriate."""

        text = self.utilities.queryNonEmptyText(obj)
        selections = self.utilities.allTextSelections(obj) if text else []
        if not selections:
            return

        document = self.utilities.getDocumentForObject(obj)
        if not document:
            return

        context = self.utilities.getCaretContext(documentFrame=document)
        start, end = selections[0]
        offset = max(offset, start)
        self.utilities.setCaretContext(obj, offset, documentFrame=document)
        if end - start < cthulhu.cthulhuApp.settingsManager.getSetting('findResultsMinimumLength'):
            return

        verbosity = cthulhu.cthulhuApp.settingsManager.getSetting('findResultsVerbosity')
        if verbosity == settings.FIND_SPEAK_NONE:
            return

        if self._madeFindAnnouncement \
           and verbosity == settings.FIND_SPEAK_IF_LINE_CHANGED \
           and self.utilities.contextsAreOnSameLine(context, (obj, offset)):
            return

        contents = self.utilities.getLineContentsAtOffset(obj, offset)
        self.speakContents(contents)
        self.updateBraille(obj)

        resultsCount = self.utilities.getFindResultsCount()
        if resultsCount:
            self.presentMessage(resultsCount)

        self._madeFindAnnouncement = True

    def sayAll(self, inputEvent, obj=None, offset=None):
        """Speaks the contents of the document beginning with the present
        location.  Overridden in this script because the sayAll could have
        been started on an object without text (such as an image).
        """

        if not self.utilities.inDocumentContent():
            tokens = ["WEB: SayAll called for non-document content", obj]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return super().sayAll(inputEvent, obj, offset)

        obj = obj or cthulhu_state.locusOfFocus
        tokens = ["WEB: SayAll called for document content", obj]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)
        speech.sayAll(self.textLines(obj, offset), self.__sayAllProgressCallback)
        return True

    def _rewindSayAll(self, context, minCharCount=10):
        if not self.utilities.inDocumentContent():
            return super()._rewindSayAll(context, minCharCount)

        if not cthulhu.cthulhuApp.settingsManager.getSetting('rewindAndFastForwardInSayAll'):
            return False

        try:
            obj, start, end, string = self._sayAllContents[0]
        except IndexError:
            return False

        cthulhu.setLocusOfFocus(None, obj, notifyScript=False)
        self.utilities.setCaretContext(obj, start)

        prevObj, prevOffset = self.utilities.findPreviousCaretInOrder(obj, start)
        self.sayAll(None, prevObj, prevOffset)
        return True

    def _fastForwardSayAll(self, context):
        if not self.utilities.inDocumentContent():
            return super()._fastForwardSayAll(context)

        if not cthulhu.cthulhuApp.settingsManager.getSetting('rewindAndFastForwardInSayAll'):
            return False

        try:
            obj, start, end, string = self._sayAllContents[-1]
        except IndexError:
            return False

        cthulhu.setLocusOfFocus(None, obj, notifyScript=False)
        self.utilities.setCaretContext(obj, end)

        nextObj, nextOffset = self.utilities.findNextCaretInOrder(obj, end)
        self.sayAll(None, nextObj, nextOffset)
        return True

    def __sayAllProgressCallback(self, context, progressType):
        if not self.utilities.inDocumentContent():
            super().__sayAllProgressCallback(context, progressType)
            return

        if progressType == speechserver.SayAllContext.INTERRUPTED:
            if isinstance(cthulhu_state.lastInputEvent, input_event.KeyboardEvent):
                self._sayAllIsInterrupted = True
                lastKey = cthulhu_state.lastInputEvent.event_string
                if lastKey == "Down" and self._fastForwardSayAll(context):
                    return
                elif lastKey == "Up" and self._rewindSayAll(context):
                    return
                elif not self._lastCommandWasStructNav:
                    cthulhu.emitRegionChanged(context.obj, context.currentOffset)
                    self.utilities.setCaretPosition(context.obj, context.currentOffset)
                    self.updateBraille(context.obj)

            self._inSayAll = False
            self._sayAllContents = []
            self._sayAllContexts = []
            return

        cthulhu.setLocusOfFocus(None, context.obj, notifyScript=False)
        cthulhu.emitRegionChanged(
            context.obj, context.currentOffset, context.currentEndOffset, cthulhu.SAY_ALL)
        self.utilities.setCaretContext(context.obj, context.currentOffset)

    def inFocusMode(self):
        """ Returns True if we're in focus mode."""

        return self._inFocusMode

    def focusModeIsSticky(self):
        """Returns True if we're in 'sticky' focus mode."""

        return self._focusModeIsSticky

    def browseModeIsSticky(self):
        """Returns True if we're in 'sticky' browse mode."""

        return self._browseModeIsSticky

    def useFocusMode(self, obj, prevObj=None):
        """Returns True if we should use focus mode in obj."""

        if self._focusModeIsSticky:
            _log("Using focus mode", "focus-mode-sticky")
            return True

        if self._browseModeIsSticky:
            _log("Not using focus mode", "browse-mode-sticky")
            return False

        if self.inSayAll():
            _log("Not using focus mode", "say-all")
            return False

        if not cthulhu.cthulhuApp.settingsManager.getSetting('structNavTriggersFocusMode') \
           and self._lastCommandWasStructNav:
            _log("Not using focus mode", "struct-nav-settings")
            return False

        if prevObj and AXObject.is_dead(prevObj):
            prevObj = None

        if not cthulhu.cthulhuApp.settingsManager.getSetting('caretNavTriggersFocusMode') \
           and self._lastCommandWasCaretNav \
           and not self.utilities.isNavigableToolTipDescendant(prevObj):
            _log("Not using focus mode", "caret-nav-settings")
            return False

        if not cthulhu.cthulhuApp.settingsManager.getSetting('nativeNavTriggersFocusMode') \
           and not (self._lastCommandWasStructNav or self._lastCommandWasCaretNav):
            _log("Not changing focus/browse mode", "native-nav-settings")
            return self._inFocusMode

        if self.utilities.isFocusModeWidget(obj):
            _log_tokens(["Using focus mode for", obj], "focus-mode-widget")
            return True

        doNotToggle = AXUtilities.is_link(obj) or AXUtilities.is_radio_button(obj)
        if self._inFocusMode and doNotToggle and self.utilities.lastInputEventWasUnmodifiedArrow():
            _log_tokens(["Staying in focus mode due to arrowing in role of", obj], "arrowing")
            return True

        if self._inFocusMode and self.utilities.isWebAppDescendant(obj):
            if self.utilities.forceBrowseModeForWebAppDescendant(obj):
                _log_tokens(["Forcing browse mode for web app descendant", obj], "web-app-forced-browse")
                return False

            _log("Staying in focus mode because we're inside a web application", "web-app-context")
            return True

        _log_tokens(["Not using focus mode for", obj], "no-cause")
        return False

    def speakContents(self, contents, **args):
        """Speaks the specified contents."""

        utterances = self.speechGenerator.generateContents(contents, **args)
        speech.speak(utterances, interrupt=args.get("interrupt", False))

    def sayCharacter(self, obj):
        """Speaks the character at the current caret position."""

        if not self._lastCommandWasCaretNav \
           and not self.utilities.isContentEditableWithEmbeddedObjects(obj):
            super().sayCharacter(obj)
            return

        document = self.utilities.getTopLevelDocumentForObject(obj)
        obj, offset = self.utilities.getCaretContext(documentFrame=document)
        if not obj:
            return

        contents = None
        if self.utilities.treatAsEndOfLine(obj, offset) and AXObject.supports_text(obj):
            char = AXText.get_substring(obj, offset, offset + 1)
            if char == self.EMBEDDED_OBJECT_CHARACTER:
                char = ""
            contents = [[obj, offset, offset + 1, char]]
        else:
            contents = self.utilities.getCharacterContentsAtOffset(obj, offset)

        if not contents:
            return

        obj, start, end, string = contents[0]
        if start > 0:
            string = string or "\n"

        if string:
            self.speakMisspelledIndicator(obj, start)
            self.speakCharacter(string)
        else:
            self.speakContents(contents)

        self.pointOfReference["lastTextUnitSpoken"] = "char"

    def sayWord(self, obj):
        """Speaks the word at the current caret position."""

        isEditable = self.utilities.isContentEditableWithEmbeddedObjects(obj)
        if not self._lastCommandWasCaretNav and not isEditable:
            super().sayWord(obj)
            return

        document = self.utilities.getTopLevelDocumentForObject(obj)
        obj, offset = self.utilities.getCaretContext(documentFrame=document)
        if input_event_manager.get_manager().last_event_was_right():
            offset -= 1

        wordContents = self.utilities.getWordContentsAtOffset(obj, offset, useCache=True)
        textObj, startOffset, endOffset, word = wordContents[0]
        self.speakMisspelledIndicator(textObj, startOffset)
        self.speakContents(wordContents)
        self.pointOfReference["lastTextUnitSpoken"] = "word"

    def sayLine(self, obj):
        """Speaks the line at the current caret position."""

        isEditable = self.utilities.isContentEditableWithEmbeddedObjects(obj)
        if not (self._lastCommandWasCaretNav or self._lastCommandWasStructNav) and not isEditable:
            super().sayLine(obj)
            return

        document = self.utilities.getTopLevelDocumentForObject(obj)
        priorObj = None
        if self._lastCommandWasCaretNav or isEditable:
            priorObj, priorOffset = self.utilities.getPriorContext(documentFrame=document)

        obj, offset = self.utilities.getCaretContext(documentFrame=document)
        contents = self.utilities.getLineContentsAtOffset(obj, offset, useCache=True)
        self.speakContents(contents, priorObj=priorObj)
        self.pointOfReference["lastTextUnitSpoken"] = "line"

    def presentObject(self, obj, **args):
        if not self.utilities.inDocumentContent(obj) or AXUtilities.is_document(obj):
            super().presentObject(obj, **args)
            return

        if AXUtilities.is_status_bar(obj):
            super().presentObject(obj, **args)
            return

        priorObj = args.get("priorObj")
        if self._lastCommandWasCaretNav or args.get("includeContext") \
           or self.utilities.getTable(obj):
            priorObj, priorOffset = self.utilities.getPriorContext()
            args["priorObj"] = priorObj

        if AXUtilities.is_entry(obj):
            super().presentObject(obj, **args)
            return

        interrupt = args.get("interrupt", False)
        tokens = ["WEB: Presenting object", obj, ". Interrupt:", interrupt]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)

        # We shouldn't use cache in this method, because if the last thing we presented
        # included this object and offset (e.g. a Say All or Mouse Review), we're in
        # danger of presented irrelevant context.
        useCache = False
        offset = args.get("offset", 0)
        contents = self.utilities.get_objectContentsAtOffset(obj, offset, useCache)
        self.displayContents(contents)
        self.speakContents(contents, **args)
 
    def updateBrailleForNewCaretPosition(self, obj):
        """Try to reposition the cursor without having to do a full update."""

        text = self.utilities.queryNonEmptyText(obj)
        if text and self.EMBEDDED_OBJECT_CHARACTER in AXText.get_all_text(obj):
            self.updateBraille(obj)
            return

        super().updateBrailleForNewCaretPosition(obj)

    def updateBraille(self, obj, **args):
        """Updates the braille display to show the given object."""

        if not cthulhu.cthulhuApp.settingsManager.getSetting('enableBraille') \
           and not cthulhu.cthulhuApp.settingsManager.getSetting('enableBrailleMonitor'):
            debug.printMessage(debug.LEVEL_INFO, "BRAILLE: disabled", True)
            return

        if self._inFocusMode:
            tokens = ["WEB: updating braille in focus mode", obj]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            super().updateBraille(obj, **args)
            return

        document = args.get("documentFrame", self.utilities.getTopLevelDocumentForObject(obj))
        if not document:
            tokens = ["WEB: updating braille for non-document object", obj]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            super().updateBraille(obj, **args)
            return

        isContentEditable = self.utilities.isContentEditableWithEmbeddedObjects(obj)

        if not self._lastCommandWasCaretNav \
           and not self._lastCommandWasStructNav \
           and not isContentEditable \
           and not self.utilities.isPlainText() \
           and not self.utilities.lastInputEventWasCaretNavWithSelection():
            tokens = ["WEB: updating braille for unhandled navigation type", obj]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            super().updateBraille(obj, **args)
            return

        obj, offset = self.utilities.getCaretContext(
            documentFrame=document, getZombieReplicant=True)
        if offset > 0 and isContentEditable:
            text = self.utilities.queryNonEmptyText(obj)
            if text:
                offset = min(offset, AXText.get_character_count(obj))

        contents = self.utilities.getLineContentsAtOffset(obj, offset)
        self.displayContents(contents, documentFrame=document)

    def displayContents(self, contents, **args):
        """Displays contents in braille."""

        if not cthulhu.cthulhuApp.settingsManager.getSetting('enableBraille') \
           and not cthulhu.cthulhuApp.settingsManager.getSetting('enableBrailleMonitor'):
            debug.printMessage(debug.LEVEL_INFO, "BRAILLE: disabled", True)
            return

        line = self.getNewBrailleLine(clearBraille=True, addLine=True)
        document = args.get("documentFrame")
        contents = self.brailleGenerator.generateContents(contents, documentFrame=document)
        if not contents:
            return

        regions, focusedRegion = contents
        for region in regions:
            self.addBrailleRegionsToLine(region, line)

        if line.regions:
            line.regions[-1].string = line.regions[-1].string.rstrip(" ")

        self.setBrailleFocus(focusedRegion, getLinkMask=False)
        self.refreshBraille(panToCursor=True, getLinkMask=False)

    def panBrailleLeft(self, inputEvent=None, panAmount=0):
        """Pans braille to the left."""

        if self.flatReviewPresenter.is_active() \
           or not self.utilities.inDocumentContent() \
           or not self.isBrailleBeginningShowing():
            super().panBrailleLeft(inputEvent, panAmount)
            return

        contents = self.utilities.getPreviousLineContents()
        if not contents:
            return

        obj, start, end, string = contents[0]
        self.utilities.setCaretPosition(obj, start)
        self.updateBraille(obj)

        # Hack: When panning to the left in a document, we want to start at
        # the right/bottom of each new object. For now, we'll pan there.
        # When time permits, we'll give our braille code some smarts.
        while self.panBrailleInDirection(panToLeft=False):
            pass

        self.refreshBraille(False)
        return True

    def panBrailleRight(self, inputEvent=None, panAmount=0):
        """Pans braille to the right."""

        if self.flatReviewPresenter.is_active() \
           or not self.utilities.inDocumentContent() \
           or not self.isBrailleEndShowing():
            super().panBrailleRight(inputEvent, panAmount)
            return

        contents = self.utilities.getNextLineContents()
        if not contents:
            return

        obj, start, end, string = contents[0]
        self.utilities.setCaretPosition(obj, start)
        self.updateBraille(obj)

        # Hack: When panning to the right in a document, we want to start at
        # the left/top of each new object. For now, we'll pan there. When time
        # permits, we'll give our braille code some smarts.
        while self.panBrailleInDirection(panToLeft=True):
            pass

        self.refreshBraille(False)
        return True

    def useCaretNavigationModel(self, keyboardEvent, debugOutput=True):
        """Returns True if caret navigation should be used."""

        if not cthulhu.cthulhuApp.settingsManager.getSetting('caretNavigationEnabled'):
            if debugOutput:
                _log("Not using caret navigation", "disabled")
            return False

        if self._inFocusMode:
            if debugOutput:
                _log("Not using caret navigation", "focus-mode")
            return False

        inDoc = self._focusInDocumentContent()

        if self._navSuspended:
            if inDoc:
                self._setNavigationSuspended(False, "focus confirmed in document content")
            else:
                if debugOutput:
                    _log("Not using caret navigation", "suspended")
                return False

        if not inDoc:
            if debugOutput:
                _log_tokens(
                    ["Not using caret navigation because locusOfFocus", cthulhu_state.locusOfFocus,
                     "is not in document content."],
                    "focus-not-document"
                )
            return False

        if keyboardEvent and keyboardEvent.modifiers & keybindings.SHIFT_MODIFIER_MASK:
            if debugOutput:
                _log("Not using caret navigation", "shift-modifier")
            return False

        if debugOutput:
            _log_tokens(
                ["Using caret navigation with locusOfFocus", cthulhu_state.locusOfFocus,
                 "in document content."],
                "enabled"
            )
        return True

    def useStructuralNavigationModel(self, debugOutput=True):
        """Returns True if structural navigation should be used."""

        if not self.structuralNavigation.enabled:
            if debugOutput:
                _log("Not using structural navigation", "disabled")
            return False

        if self._inFocusMode:
            if debugOutput:
                _log("Not using structural navigation", "focus-mode")
            return False

        inDoc = self._focusInDocumentContent()

        if self._navSuspended:
            if inDoc:
                self._setNavigationSuspended(False, "focus confirmed in document content")
            else:
                if debugOutput:
                    _log("Not using structural navigation", "suspended")
                return False

        if not inDoc:
            if debugOutput:
                _log_tokens(
                    ["Not using structural navigation because locusOfFocus",
                     cthulhu_state.locusOfFocus, "is not in document content."],
                    "focus-not-document"
                )
            return False

        if debugOutput:
            _log_tokens(
                ["Using structural navigation with locusOfFocus", cthulhu_state.locusOfFocus,
                 "in document content."],
                "enabled"
            )
        return True

    def getTextLineAtCaret(self, obj, offset=None, startOffset=None, endOffset=None):
        """To-be-removed. Returns the string, caretOffset, startOffset."""

        if self._inFocusMode or not self.utilities.inDocumentContent(obj) \
           or self.utilities.isFocusModeWidget(obj):
            return super().getTextLineAtCaret(obj, offset, startOffset, endOffset)

        text = self.utilities.queryNonEmptyText(obj)
        if offset is None:
            offset = max(0, AXText.get_caret_offset(obj))

        if text and startOffset is not None and endOffset is not None:
            return AXText.get_substring(obj, startOffset, endOffset), offset, startOffset

        contextObj, contextOffset = self.utilities.getCaretContext(documentFrame=None)
        if contextObj == obj:
            caretOffset = contextOffset
        else:
            caretOffset = offset

        contents = self.utilities.getLineContentsAtOffset(obj, offset)
        contents = list(filter(lambda x: x[0] == obj, contents))
        if len(contents) == 1:
            index = 0
        else:
            index = self.utilities.findObjectInContents(obj, offset, contents)

        if index > -1:
            candidate, startOffset, endOffset, string = contents[index]
            if self.EMBEDDED_OBJECT_CHARACTER not in string:
                return string, caretOffset, startOffset

        return "", 0, 0

    def moveToMouseOver(self, inputEvent):
        """Moves the context to/from the mouseover which has just appeared."""

        if not self._lastMouseOverObject:
            self.presentMessage(messages.MOUSE_OVER_NOT_FOUND)
            return

        if self._inMouseOverObject:
            x, y = self.oldMouseCoordinates
            self.eventSynthesizer.route_to_point(x, y)
            self.restorePreMouseOverContext()
            return

        obj = self._lastMouseOverObject
        obj, offset = self.utilities.findFirstCaretContext(obj, 0)
        if not obj:
            return

        if AXUtilities.is_focusable(obj) and AXObject.supports_component(obj):
            Atspi.Component.grab_focus(obj)

        contents = self.utilities.get_objectContentsAtOffset(obj, offset)
        self.utilities.setCaretPosition(obj, offset)
        self.speakContents(contents)
        self.updateBraille(obj)
        self._inMouseOverObject = True

    def restorePreMouseOverContext(self):
        """Cleans things up after a mouse-over object has been hidden."""

        obj, offset = self._preMouseOverContext
        self.utilities.setCaretPosition(obj, offset)
        self.speakContents(self.utilities.get_objectContentsAtOffset(obj, offset))
        self.updateBraille(obj)
        self._inMouseOverObject = False
        self._lastMouseOverObject = None

    def enableStickyBrowseMode(self, inputEvent, forceMessage=False):
        if not self._browseModeIsSticky or forceMessage:
            self.presentMessage(messages.MODE_BROWSE_IS_STICKY)

        self._inFocusMode = False
        self._focusModeIsSticky = False
        self._browseModeIsSticky = True
        self.refreshKeyGrabs()

    def enableStickyFocusMode(self, inputEvent, forceMessage=False):
        if not self._focusModeIsSticky or forceMessage:
            self.presentMessage(messages.MODE_FOCUS_IS_STICKY)

        self._inFocusMode = True
        self._focusModeIsSticky = True
        self._browseModeIsSticky = False
        self.refreshKeyGrabs()

    def toggleLayoutMode(self, inputEvent):
        layoutMode = not cthulhu.cthulhuApp.settingsManager.getSetting('layoutMode')
        if layoutMode:
            self.presentMessage(messages.MODE_LAYOUT)
        else:
            self.presentMessage(messages.MODE_OBJECT)
        cthulhu.cthulhuApp.settingsManager.setSetting('layoutMode', layoutMode)

    def togglePresentationMode(self, inputEvent, documentFrame=None):
        [obj, characterOffset] = self.utilities.getCaretContext(documentFrame)
        if self._inFocusMode:
            parent = AXObject.get_parent(obj)
            if AXUtilities.is_list_box(parent):
                self.utilities.setCaretContext(parent, -1)
            elif AXUtilities.is_menu(parent):
                self.utilities.setCaretContext(AXObject.get_parent(parent), -1)
            if not self._loadingDocumentContent:
                if inputEvent is not None:
                    self.presentMessage(messages.MODE_BROWSE, interrupt=True)
                else:
                    self.presentMessage(messages.MODE_BROWSE)
                if not self._shouldSuppressBrowseModeSound(obj, inputEvent):
                    sound_theme_manager.getManager().playBrowseModeSound()
        else:
            if not self.utilities.grabFocusWhenSettingCaret(obj) \
               and (self._lastCommandWasCaretNav \
                    or self._lastCommandWasStructNav \
                    or inputEvent):
                self.utilities.grabFocus(obj)

            if inputEvent is not None:
                self.presentMessage(messages.MODE_FOCUS, interrupt=True)
            else:
                self.presentMessage(messages.MODE_FOCUS)
            sound_theme_manager.getManager().playFocusModeSound()
        self._inFocusMode = not self._inFocusMode
        self._focusModeIsSticky = False
        self._browseModeIsSticky = False
        self.refreshKeyGrabs()

    def _shouldSuppressBrowseModeSound(self, obj, inputEvent):
        if inputEvent is not None:
            return False

        if cthulhu.cthulhuApp.settingsManager.getSetting('roleSoundPresentation') \
                == settings.ROLE_SOUND_PRESENTATION_SPEECH_ONLY:
            return False

        if not cthulhu.cthulhuApp.settingsManager.getSetting('enableSound'):
            return False

        icon = self._getControlSoundIcon(obj)
        return icon is not None and icon.isValid()

    def _getControlSoundIcon(self, obj):
        if not obj:
            return None

        role = AXObject.get_role(obj)
        if self.utilities.isSwitch(obj):
            role = Atspi.Role.SWITCH
        manager = sound_theme_manager.getManager()
        icon = manager.getRoleSoundIcon(role)
        if icon:
            return icon

        stateKey = None
        if AXUtilities.is_checkable(obj) or AXUtilities.is_check_menu_item(obj):
            if AXUtilities.is_indeterminate(obj):
                stateKey = "mixed"
            elif AXUtilities.is_checked(obj):
                stateKey = "checked"
            else:
                stateKey = "unchecked"
        elif AXUtilities.is_radio_button(obj):
            stateKey = "checked" if AXUtilities.is_checked(obj) else "unchecked"
        elif AXUtilities.is_toggle_button(obj) or AXUtilities.is_switch(obj):
            stateKey = "checked" if (AXUtilities.is_checked(obj) or AXUtilities.is_pressed(obj)) \
                else "unchecked"

        if stateKey:
            return manager.getRoleStateSoundIcon(role, stateKey)

        return None

    def _getClickableActivationTarget(self):
        obj = cthulhu_state.locusOfFocus
        if self.inFocusMode():
            return obj

        if not self.utilities.inDocumentContent(obj):
            return obj

        contextObj, _ = self.utilities.getCaretContext(searchIfNeeded=False)
        if contextObj and self.utilities.inDocumentContent(contextObj):
            return contextObj

        return obj

    def _performClickableAction(self, obj):
        from cthulhu import ax_object

        actionNames = ["click", "click-ancestor", "press", "jump", "open", "activate"]
        for actionName in actionNames:
            if not ax_object.AXObject.has_action(obj, actionName):
                continue
            if ax_object.AXObject.do_named_action(obj, actionName):
                return True

        return False

    def _tryClickableActivation(self, keyboardEvent):
        """Try to activate clickable element - returns True if we should consume the event."""

        obj = self._getClickableActivationTarget()
        if not obj or not self.utilities.inDocumentContent(obj):
            return False

        # Skip form controls where Return should have normal behavior
        from cthulhu import ax_utilities
        if (ax_utilities.AXUtilities.is_entry(obj) or 
            ax_utilities.AXUtilities.is_text(obj) or 
            ax_utilities.AXUtilities.is_password_text(obj) or
            ax_utilities.AXUtilities.is_combo_box(obj) or
            ax_utilities.AXUtilities.is_button(obj) or
            ax_utilities.AXUtilities.is_push_button(obj) or
            ax_utilities.AXUtilities.is_link(obj)):
            return False
        
        # Try clickable activation for non-standard clickable elements
        # Store focus information before clicking for potential restoration
        original_focus = obj
        
        # First try the standard clickable detection
        if self.utilities.isClickableElement(obj):
            self.presentMessage("Activating...")
            result = self._performClickableAction(obj)
            if result:
                return True

            from cthulhu import ax_event_synthesizer
            result = ax_event_synthesizer.AXEventSynthesizer.click_object(obj)
            if result:
                self._restoreFocusAfterClick(original_focus)
                return True

        # If that didn't work, try a more permissive approach for any element with click action
        from cthulhu import ax_object
        if ax_object.AXObject.has_action(obj, "click") \
           or ax_object.AXObject.has_action(obj, "click-ancestor"):
            self.presentMessage("Activating...")
            result = self._performClickableAction(obj)
            if result:
                return True

            from cthulhu import ax_event_synthesizer
            result = ax_event_synthesizer.AXEventSynthesizer.click_object(obj)
            if result:
                self._restoreFocusAfterClick(original_focus)
                return True

        return False

    def _restoreFocusAfterClick(self, original_focus):
        """Try to restore focus after a click action that may have caused DOM changes."""
        try:
            from gi.repository import GObject
            from cthulhu import ax_object
            
            # Store the document and caret position to restore navigation context
            document = None
            caret_offset = -1
            try:
                # Find the document root
                obj = original_focus
                while obj and not ax_object.AXObject.is_dead(obj):
                    if self.utilities.isDocument(obj):
                        document = obj
                        break
                    parent = ax_object.AXObject.get_parent(obj)
                    if parent == obj:  # Avoid infinite loops
                        break
                    obj = parent
                
                # Get current caret position if available
                if document:
                    try:
                        caret_offset = ax_object.AXObject.get_caret_offset(document)
                    except Exception:
                        pass
                        
            except Exception:
                pass
            
            def restore_focus():
                try:
                    # First try direct focus restoration if object still exists
                    if not ax_object.AXObject.is_dead(original_focus):
                        original_focus.grabFocus()
                        return False
                    
                    # If we have document and caret info, try to restore position
                    if document and not ax_object.AXObject.is_dead(document) and caret_offset >= 0:
                        try:
                            # Set caret back to where it was
                            ax_object.AXObject.set_caret_offset(document, caret_offset)
                            # Focus the document to make sure screen reader tracks properly
                            document.grabFocus()
                            return False
                        except Exception:
                            pass
                    
                    # Last resort: try to focus the document if we have it
                    if document and not ax_object.AXObject.is_dead(document):
                        document.grabFocus()
                    
                except Exception:
                    pass  # Focus restoration is best effort
                return False  # Don't repeat the timeout
            
            # Delay restoration to allow JavaScript and DOM changes to complete
            GObject.timeout_add(150, restore_focus)  # 150ms delay for DOM changes
            
        except Exception:
            pass  # Focus restoration is best effort

    def _presentDelayedMessage(self, message, delay_ms):
        """Present a message after a specified delay in milliseconds."""
        try:
            from gi.repository import GObject
            
            def present_message():
                self.presentMessage(message)
                return False  # Don't repeat
                
            GObject.timeout_add(delay_ms, present_message)
        except Exception:
            # If delay fails, present immediately
            self.presentMessage(message)

    def activateClickableElement(self, inputEvent):
        """Activates clickable element at current focus via Return key."""
        return self._tryClickableActivation(inputEvent)

    def locus_of_focus_changed(self, event, oldFocus, newFocus):
        """Handles changes of focus of interest to the script."""

        if newFocus and self.utilities.isZombie(newFocus):
            tokens = ["WEB: New focus is Zombie:", newFocus]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return True

        if newFocus and AXObject.is_dead(newFocus):
            msg = "WEB: New focus is dead"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        document = self.utilities.getTopLevelDocumentForObject(newFocus)
        if not document and self.utilities.isDocument(newFocus):
            document = newFocus

        if not document:
            msg = "WEB: Locus of focus changed to non-document obj"
            self._madeFindAnnouncement = False
            self._inFocusMode = False
            self._setNavigationSuspended(True, "focus left document content")
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            oldDocument = self.utilities.getTopLevelDocumentForObject(oldFocus)
            if not oldDocument and self.utilities.isDocument(oldFocus):
                oldDocument = oldFocus

            if oldFocus and not oldDocument:
                msg = "WEB: Not refreshing grabs because we weren't in a document before"
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                return False

            tokens = ["WEB: Refreshing grabs because we left document", oldDocument]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            self.refreshKeyGrabs()
            return False

        # Focus is in document content - unsuspend navigation if it was suspended
        if self._navSuspended:
            self._setNavigationSuspended(False, "focus entered document content")

        if self.flatReviewPresenter.is_active():
            self.flatReviewPresenter.quit()

        caretOffset = 0
        if self.utilities.inFindContainer(oldFocus) \
           or (self.utilities.isDocument(newFocus) and oldFocus == cthulhu_state.activeWindow):
            contextObj, contextOffset = self.utilities.getCaretContext(documentFrame=document)
            if contextObj and not self.utilities.isZombie(contextObj):
                newFocus, caretOffset = contextObj, contextOffset

        if AXUtilities.is_unknown_or_redundant(newFocus):
            msg = "WEB: Event source has bogus role. Likely browser bug."
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            newFocus, offset = self.utilities.findFirstCaretContext(newFocus, 0)

        text = self.utilities.queryNonEmptyText(newFocus)
        if text:
            textOffset = AXText.get_caret_offset(newFocus)
            if 0 <= textOffset <= AXText.get_character_count(newFocus):
                caretOffset = textOffset

        self.utilities.setCaretContext(newFocus, caretOffset, document)
        self.updateBraille(newFocus, documentFrame=document)
        cthulhu.emitRegionChanged(newFocus, caretOffset)

        contents = None
        args = {}
        if self._lastCommandWasMouseButton and event \
             and event.type.startswith("object:text-caret-moved"):
            msg = "WEB: Last input event was mouse button. Generating line."
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            contents = self.utilities.getLineContentsAtOffset(newFocus, caretOffset)
            args['priorObj'] = oldFocus
        elif self.utilities.isContentEditableWithEmbeddedObjects(newFocus) \
           and (self._lastCommandWasCaretNav or self._lastCommandWasStructNav) \
           and not (AXUtilities.is_table_cell(newFocus) and AXObject.get_name(newFocus)):
            # Check if we're entering the content editable from outside (e.g. down arrow
            # from a message list into a message entry). In that case, generate full object
            # speech (with label and role) rather than just line contents.
            enteredFromOutside = oldFocus is not None \
                and oldFocus != newFocus \
                and not AXObject.find_ancestor(oldFocus, lambda x: x == newFocus)
            if enteredFromOutside:
                tokens = ["WEB: New focus", newFocus,
                          "content editable entered from outside. Generating speech."]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
                args['priorObj'] = oldFocus
            else:
                tokens = ["WEB: New focus", newFocus, "content editable. Generating line."]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
                contents = self.utilities.getLineContentsAtOffset(newFocus, caretOffset)
        elif self.utilities.isAnchor(newFocus):
            tokens = ["WEB: New focus", newFocus, "is anchor. Generating line."]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            contents = self.utilities.getLineContentsAtOffset(newFocus, 0)
        elif self.utilities.lastInputEventWasPageNav() \
             and not self.utilities.getTable(newFocus) \
             and not self.utilities.isFeedArticle(newFocus):
            tokens = ["WEB: New focus", newFocus, "was scrolled to. Generating line."]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            contents = self.utilities.getLineContentsAtOffset(newFocus, caretOffset)
        elif self.utilities.isFocusedWithMathChild(newFocus):
            tokens = ["WEB: New focus", newFocus, "has math child. Generating line."]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            contents = self.utilities.getLineContentsAtOffset(newFocus, caretOffset)
        elif AXUtilities.is_heading(newFocus):
            tokens = ["WEB: New focus", newFocus, "is heading. Generating object."]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            contents = self.utilities.get_objectContentsAtOffset(newFocus, 0)
        elif self.utilities.caretMovedToSamePageFragment(event, oldFocus):
            tokens = ["WEB: Source", event.source, "is same page fragment. Generating line."]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            contents = self.utilities.getLineContentsAtOffset(newFocus, 0)
        elif event and event.type.startswith("object:children-changed:remove") \
             and self.utilities.isFocusModeWidget(newFocus):
            tokens = ["WEB: New focus", newFocus,
                      "is recovery from removed child. Generating speech."]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
        elif self.utilities.lastInputEventWasLineNav() and self.utilities.isZombie(oldFocus):
            msg = "WEB: Last input event was line nav; oldFocus is zombie. Generating line."
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            contents = self.utilities.getLineContentsAtOffset(newFocus, caretOffset)
        elif self.utilities.lastInputEventWasLineNav() and event \
             and event.type.startswith("object:children-changed"):
            msg = "WEB: Last input event was line nav and children changed. Generating line."
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            contents = self.utilities.getLineContentsAtOffset(newFocus, caretOffset)
        else:
            tokens = ["WEB: New focus", newFocus, "is not a special case. Generating speech."]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            args['priorObj'] = oldFocus

        if newFocus and AXObject.is_dead(newFocus):
            msg = "WEB: New focus has since died"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            if self._getQueuedEvent("object:state-changed:focused", True):
                msg = "WEB: Have matching focused event. Not speaking contents"
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                return True

        if self.utilities.shouldInterruptForLocusOfFocusChange(oldFocus, newFocus, event):
            self.presentationInterrupt()

        args["interrupt"] = False
        if contents:
            self.speakContents(contents, **args)
        else:
            utterances = self.speechGenerator.generateSpeech(newFocus, **args)
            speech.speak(utterances, interrupt=False)

        self._saveFocusedObjectInfo(newFocus)

        if not self._focusModeIsSticky \
           and not self._browseModeIsSticky \
           and self.useFocusMode(newFocus, oldFocus) != self._inFocusMode:
            self.togglePresentationMode(None, document)

        if not self.utilities.inDocumentContent(oldFocus):
            self.refreshKeyGrabs()

        return True

    def onActiveChanged(self, event):
        """Callback for object:state-changed:active accessibility events."""

        if not self.utilities.inDocumentContent(event.source):
            msg = "WEB: Event source is not in document content"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        if not event.detail1:
            msg = "WEB: Ignoring because event source is now inactive"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if AXUtilities.is_dialog_or_alert(event.source):
            msg = "WEB: Event handled: Setting locusOfFocus to event source"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            cthulhu.setLocusOfFocus(event, event.source)
            return True

        return False

    def onActiveDescendantChanged(self, event):
        """Callback for object:active-descendant-changed accessibility events."""

        if not self.utilities.inDocumentContent(event.source):
            msg = "WEB: Event source is not in document content"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        return True

    def onBusyChanged(self, event):
        """Callback for object:state-changed:busy accessibility events."""

        if event.detail1 and self._loadingDocumentContent:
            msg = "WEB: Ignoring: Already loading document content"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if not self.utilities.inDocumentContent(event.source):
            msg = "WEB: Event source is not in document content"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        if not AXUtilities.is_document_web(event.source) \
           and not self.utilities.isOrDescendsFrom(cthulhu_state.locusOfFocus, event.source):
            msg = "WEB: Ignoring: Not document and not something we're in"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        self.structuralNavigation.clearCache()

        if self.utilities.getDocumentForObject(AXObject.get_parent(event.source)):
            msg = "WEB: Ignoring: Event source is nested document"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        obj, offset = self.utilities.getCaretContext()
        if not obj or self.utilities.isZombie(obj):
            self.utilities.clearCaretContext()

        shouldPresent = True
        if not (AXUtilities.is_showing(event.source) or AXUtilities.is_visible(event.source)):
            shouldPresent = False
            msg = "WEB: Not presenting because source is not showing or visible"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
        elif not self.utilities.documentFrameURI(event.source):
            shouldPresent = False
            msg = "WEB: Not presenting because source lacks URI"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
        elif not event.detail1 and self._inFocusMode and not self.utilities.isZombie(obj):
            shouldPresent = False
            tokens = ["WEB: Not presenting due to focus mode for", obj]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)

        if not cthulhu.cthulhuApp.settingsManager.getSetting('onlySpeakDisplayedText') and shouldPresent:
            if event.detail1:
                self.presentMessage(messages.PAGE_LOADING_START)
            elif AXObject.get_name(event.source):
                msg = messages.PAGE_LOADING_END_NAMED % AXObject.get_name(event.source)
                self.presentMessage(msg, resetStyles=False)
            else:
                self.presentMessage(messages.PAGE_LOADING_END)

        activeDocument = self.utilities.activeDocument()
        if activeDocument and activeDocument != event.source:
            msg = "WEB: Ignoring: Event source is not active document"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        self._loadingDocumentContent = event.detail1
        if event.detail1:
            return True

        self.utilities.clearCachedObjects()
        if AXObject.is_dead(obj):
            obj = None

        if not AXObject.is_dead(cthulhu_state.locusOfFocus) \
           and not self.utilities.inDocumentContent(cthulhu_state.locusOfFocus) \
           and AXUtilities.is_focused(cthulhu_state.locusOfFocus):
            msg = "WEB: Not presenting content, focus is outside of document"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if cthulhu.cthulhuApp.settingsManager.getSetting('pageSummaryOnLoad') and shouldPresent:
            obj = obj or event.source
            tokens = ["WEB: Getting page summary for obj", obj]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            summary = self.utilities.getPageSummary(obj)
            if summary:
                self.presentMessage(summary)

        obj, offset = self.utilities.getCaretContext()
        if self.useFocusMode(obj) != self._inFocusMode:
            self.togglePresentationMode(None)

        if not obj:
            msg = "WEB: Could not get caret context"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.isFocusModeWidget(obj):
            tokens = ["WEB: Setting locus of focus to focusModeWidget", obj]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            cthulhu.setLocusOfFocus(event, obj)
            return True

        if self.utilities.isLink(obj) and AXUtilities.is_focused(obj):
            tokens = ["WEB: Setting locus of focus to focused link", obj, ". No SayAll."]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            cthulhu.setLocusOfFocus(event, obj)
            return True

        if offset > 0:
            tokens = ["WEB: Setting locus of focus to context obj", obj, ". No SayAll"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            cthulhu.setLocusOfFocus(event, obj)
            return True

        if not AXUtilities.is_focused(cthulhu_state.locusOfFocus):
            tokens = ["WEB: Setting locus of focus to context obj", obj, "(no notification)"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            cthulhu.setLocusOfFocus(event, obj, False)

        self.updateBraille(obj)
        if self.utilities.documentFragment(event.source):
            msg = "WEB: Not doing SayAll due to page fragment"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
        elif not cthulhu.cthulhuApp.settingsManager.getSetting('sayAllOnLoad'):
            msg = "WEB: Not doing SayAll due to sayAllOnLoad being False"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            self.speakContents(self.utilities.getLineContentsAtOffset(obj, offset))
        elif cthulhu.cthulhuApp.settingsManager.getSetting('enableSpeech'):
            msg = "WEB: Doing SayAll"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            self.sayAll(None)
        else:
            msg = "WEB: Not doing SayAll due to enableSpeech being False"
            debug.printMessage(debug.LEVEL_INFO, msg, True)

        return True

    def onCaretMoved(self, event):
        """Callback for object:text-caret-moved accessibility events."""

        self.utilities.sanityCheckActiveWindow()

        if self.utilities.isZombie(event.source):
            msg = "WEB: Event source is Zombie"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        document = self.utilities.getTopLevelDocumentForObject(event.source)
        if not document:
            if self.utilities.eventIsBrowserUINoise(event):
                msg = "WEB: Ignoring event believed to be browser UI noise"
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                return True

            if self.utilities.eventIsBrowserUIAutocompleteNoise(event):
                msg = "WEB: Ignoring event believed to be browser UI autocomplete noise"
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                return True

            msg = "WEB: Event source is not in document content"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        obj, offset = self.utilities.getCaretContext(document, False, False)
        tokens = ["WEB: Context: ", obj, ", ", offset, "(focus: ", cthulhu_state.locusOfFocus, ")"]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)

        if self.utilities.lastInputEventWasCaretNavWithSelection() and event.detail1 < 0:
            if self._presentSyntheticCaretSelection(event, document, obj, offset):
                msg = "WEB: Event handled: synthetic caret selection"
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                return True
        else:
            self._clearSyntheticWebSelection()

        if self._lastCommandWasCaretNav:
            msg = "WEB: Event ignored: Last command was caret nav"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self._lastCommandWasStructNav:
            msg = "WEB: Event ignored: Last command was struct nav"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self._lastCommandWasMouseButton:
            msg = "WEB: Last command was mouse button"
            debug.printMessage(debug.LEVEL_INFO, msg, True)

            if (event.source, event.detail1) == (obj, offset):
                msg = "WEB: Event is for current caret context."
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                return True

            if (event.source, event.detail1) == self._lastMouseButtonContext:
                msg = "WEB: Event is for last mouse button context."
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                return True

            self._lastMouseButtonContext = event.source, event.detail1

            msg = "WEB: Event handled: Last command was mouse button"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            self.utilities.setCaretContext(event.source, event.detail1)
            notify = not self.utilities.isEntryDescendant(event.source)
            cthulhu.setLocusOfFocus(event, event.source, notify, True)
            if cthulhu_state.locusOfFocus == event.source:
                self.updateBraille(event.source)
            return True

        if self.utilities.lastInputEventWasTab():
            msg = "WEB: Last input event was Tab."
            debug.printMessage(debug.LEVEL_INFO, msg, True)

            if self.utilities.isDocument(event.source):
                msg = "WEB: Event ignored: Caret moved in document due to Tab."
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                return True

        if self.utilities.inFindContainer():
            msg = "WEB: Event handled: Presenting find results"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            self.presentFindResults(event.source, event.detail1)
            self._saveFocusedObjectInfo(cthulhu_state.locusOfFocus)
            return True

        if not self.utilities.eventIsFromLocusOfFocusDocument(event):
            msg = "WEB: Event ignored: Not from locus of focus document"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.textEventIsDueToInsertion(event):
            msg = "WEB: Event handled: Updating position due to insertion"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            self._saveLastCursorPosition(event.source, event.detail1)
            return True

        if self.utilities.textEventIsDueToDeletion(event):
            msg = "WEB: Event handled: Updating position due to deletion"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            self._saveLastCursorPosition(event.source, event.detail1)
            return True

        if self.utilities.isItemForEditableComboBox(cthulhu_state.locusOfFocus, event.source) \
           and not self.utilities.lastInputEventWasCharNav() \
           and not self.utilities.lastInputEventWasLineBoundaryNav():
            msg = "WEB: Event ignored: Editable combobox noise"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.eventIsAutocompleteNoise(event, document):
            msg = "WEB: Event ignored: Autocomplete noise"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self._inFocusMode and self.utilities.caretMovedOutsideActiveGrid(event):
            msg = "WEB: Event ignored: Caret moved outside active grid during focus mode"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.treatEventAsSpinnerValueChange(event):
            msg = "WEB: Event handled as the value-change event we wish we'd get"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            self.updateBraille(event.source)
            self._presentTextAtNewCaretPosition(event)
            return True

        if not self.utilities.queryNonEmptyText(event.source) \
           and not AXUtilities.is_editable(event.source):
            msg = "WEB: Event ignored: Was for non-editable object we're treating as textless"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        obj, offset = self.utilities.findFirstCaretContext(event.source, event.detail1)
        notify = force = handled = False
        AXObject.clear_cache(event.source)

        if self.utilities.lastInputEventWasPageNav():
            msg = "WEB: Caret moved due to scrolling."
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            notify = force = handled = True

        elif self.utilities.caretMovedToSamePageFragment(event):
            msg = "WEB: Caret moved to fragment."
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            notify = force = handled = True

        elif self.utilities.lastInputEventWasCaretNav():
            msg = "WEB: Caret moved due to native caret navigation."
            debug.printMessage(debug.LEVEL_INFO, msg, True)

        elif self.utilities.isTextField(event.source) \
           and AXUtilities.is_focused(event.source) \
           and event.source != cthulhu_state.locusOfFocus:
            msg = "WEB: Focused text field is not (yet) the locus of focus."
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            notify = force = handled = True

        tokens = ["WEB: Setting context and focus to: ", obj, ", ", offset]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)
        self.utilities.setCaretContext(obj, offset, document)
        cthulhu.setLocusOfFocus(event, obj, notify, force)
        return handled

    def onCheckedChanged(self, event):
        """Callback for object:state-changed:checked accessibility events."""

        if not self.utilities.inDocumentContent(event.source):
            msg = "WEB: Event source is not in document content"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        obj, offset = self.utilities.getCaretContext()
        if obj != event.source:
            msg = "WEB: Event source is not context object"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        oldObj, oldState = self.pointOfReference.get('checkedChange', (None, 0))
        if hash(oldObj) == hash(obj) and oldState == event.detail1:
            msg = "WEB: Ignoring event, state hasn't changed"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if not (self._lastCommandWasCaretNav and AXUtilities.is_radio_button(obj)):
            msg = "WEB: Event is something default can handle"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        self.presentObject(obj, alreadyFocused=True, interrupt=True)
        self.pointOfReference['checkedChange'] = hash(obj), event.detail1
        return True

    def onChildrenAdded(self, event):
        """Callback for object:children-changed:add accessibility events."""

        AXObject.clear_cache_now("children-changed event.")
        if self.utilities.eventIsBrowserUINoise(event):
            msg = "WEB: Ignoring event believed to be browser UI noise"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        isLiveRegion = self.utilities.isLiveRegion(event.source)
        document = self.utilities.getTopLevelDocumentForObject(event.source)
        if document and not isLiveRegion:
            if event.source == cthulhu_state.locusOfFocus:
                tokens = ["WEB: Dumping cache and context: source is focus",
                          cthulhu_state.locusOfFocus]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
                self.utilities.dumpCache(
                    document,
                    preserveContext=not AXObject.is_dead(event.source),
                )
            elif AXObject.is_dead(cthulhu_state.locusOfFocus):
                msg = "WEB: Dumping cache: dead focus"
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                self.utilities.dumpCache(document, preserveContext=True)
            elif AXObject.find_ancestor(cthulhu_state.locusOfFocus, lambda x: x == event.source):
                tokens = ["WEB: Dumping cache: source is ancestor of focus",
                          cthulhu_state.locusOfFocus]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
                self.utilities.dumpCache(document, preserveContext=True)
            else:
                tokens = ["WEB: Not dumping full cache. Focus is", cthulhu_state.locusOfFocus]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
                self.utilities.clearCachedObjects()

        elif isLiveRegion:
            if self.utilities.handleAsLiveRegion(event):
                msg = "WEB: Event to be handled as live region"
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                self.liveRegionManager.handleEvent(event)
            else:
                msg = "WEB: Ignoring because live region event not to be handled."
                debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True
        else:
            msg = "WEB: Could not get document for event source"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        if self._loadingDocumentContent:
            msg = "WEB: Ignoring because document content is being loaded."
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.isZombie(document):
            tokens = ["WEB: Ignoring because", document, "is zombified."]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return True

        if AXUtilities.is_busy(document):
            tokens = ["WEB: Ignoring because", document, "is busy."]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return True

        if not event.any_data or self.utilities.isZombie(event.any_data):
            msg = "WEB: Ignoring because any data is null or zombified."
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.handleEventFromContextReplicant(event, event.any_data):
            msg = "WEB: Event handled by updating locusOfFocus and context to child."
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if AXUtilities.is_alert(event.any_data):
            if event.any_data == self.utilities.lastQueuedLiveRegion():
                tokens = ["WEB: Ignoring", event.any_data, "(is last queued live region)"]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
                return True

            msg = "WEB: Presenting event.any_data"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            self.presentObject(event.any_data, interrupt=True)

            focused = AXUtilities.get_focused_object(event.any_data)
            if focused:
                notify = self.utilities.queryNonEmptyText(focused) is None
                tokens = ["WEB: Setting locusOfFocus and caret context to", focused]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
                cthulhu.setLocusOfFocus(event, focused, notify)
                self.utilities.setCaretContext(focused, 0)
            return True

        if self.lastMouseRoutingTime and 0 < time.time() - self.lastMouseRoutingTime < 1:
            utterances = []
            utterances.append(messages.NEW_ITEM_ADDED)
            utterances.extend(self.speechGenerator.generateSpeech(event.any_data, force=True))
            speech.speak(utterances)
            self._lastMouseOverObject = event.any_data
            self.preMouseOverContext = self.utilities.getCaretContext()
            return True

        return False

    def onChildrenRemoved(self, event):
        """Callback for object:children-changed:removed accessibility events."""

        AXObject.clear_cache_now("children-changed event.")
        if not self.utilities.inDocumentContent(event.source):
            msg = "WEB: Event source is not in document content."
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        if self._loadingDocumentContent:
            msg = "WEB: Ignoring because document content is being loaded."
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.isLiveRegion(event.source):
            if self.utilities.handleEventForRemovedChild(event):
                msg = "WEB: Event handled for removed live-region child."
                debug.printMessage(debug.LEVEL_INFO, msg, True)
            else:
                msg = "WEB: Ignoring removal from live region."
                debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        document = self.utilities.getTopLevelDocumentForObject(event.source)
        if document:
            if event.source == cthulhu_state.locusOfFocus:
                tokens = ["WEB: Dumping cache and context: source is focus",
                          cthulhu_state.locusOfFocus]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
                self.utilities.dumpCache(
                    document,
                    preserveContext=not AXObject.is_dead(event.source),
                )
            elif AXObject.is_dead(cthulhu_state.locusOfFocus):
                msg = "WEB: Dumping cache: dead focus"
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                self.utilities.dumpCache(document, preserveContext=True)
            elif AXObject.find_ancestor(cthulhu_state.locusOfFocus, lambda x: x == event.source):
                tokens = ["WEB: Dumping cache: source is ancestor of focus",
                          cthulhu_state.locusOfFocus]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
                self.utilities.dumpCache(document, preserveContext=True)
            else:
                tokens = ["WEB: Not dumping full cache. Focus is", cthulhu_state.locusOfFocus]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
                self.utilities.clearCachedObjects()

        if self.utilities.handleEventForRemovedChild(event):
            msg = "WEB: Event handled for removed child."
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        return False

    def onColumnReordered(self, event):
        """Callback for object:column-reordered accessibility events."""

        if not self.utilities.inDocumentContent(event.source):
            msg = "WEB: Event source is not in document content"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        if event.source != self.utilities.getTable(cthulhu_state.locusOfFocus):
            tokens = ["WEB: locusOfFocus (", cthulhu_state.locusOfFocus, ") is not in this table"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return False

        self.pointOfReference['last-table-sort-time'] = time.time()
        self.presentMessage(messages.TABLE_REORDERED_COLUMNS)
        header = self.utilities.containingTableHeader(cthulhu_state.locusOfFocus)
        if header:
            self.presentMessage(self.utilities.getSortOrderDescription(header, True))

        return True

    def onDocumentLoadComplete(self, event):
        """Callback for document:load-complete accessibility events."""

        if self.utilities.getDocumentForObject(AXObject.get_parent(event.source)):
            msg = "WEB: Ignoring: Event source is nested document"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        msg = "WEB: Updating loading state and resetting live regions"
        debug.printMessage(debug.LEVEL_INFO, msg, True)
        self._loadingDocumentContent = False
        self.liveRegionManager.reset()
        return True

    def onDocumentLoadStopped(self, event):
        """Callback for document:load-stopped accessibility events."""

        if self.utilities.getDocumentForObject(AXObject.get_parent(event.source)):
            msg = "WEB: Ignoring: Event source is nested document"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        msg = "WEB: Updating loading state"
        debug.printMessage(debug.LEVEL_INFO, msg, True)
        self._loadingDocumentContent = False
        return True

    def onDocumentReload(self, event):
        """Callback for document:reload accessibility events."""

        if self.utilities.getDocumentForObject(AXObject.get_parent(event.source)):
            msg = "WEB: Ignoring: Event source is nested document"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        msg = "WEB: Updating loading state"
        debug.printMessage(debug.LEVEL_INFO, msg, True)
        self._loadingDocumentContent = True
        return True

    def onExpandedChanged(self, event):
        """Callback for object:state-changed:expanded accessibility events."""

        if self.utilities.isZombie(event.source):
            msg = "WEB: Event source is Zombie"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if not self.utilities.inDocumentContent(event.source):
            msg = "WEB: Event source is not in document content"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        obj, offset = self.utilities.getCaretContext(searchIfNeeded=False)
        tokens = ["WEB: Caret context is", obj, ", ", offset,
                  "(focus: ", cthulhu_state.locusOfFocus, ")"]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)
        if not obj or self.utilities.isZombie(obj) and event.source == cthulhu_state.locusOfFocus:
            msg = "WEB: Setting caret context to event source"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            self.utilities.setCaretContext(event.source, 0)

        return False

    def onFocus(self, event):
        """Callback for focus: accessibility events."""

        # We should get proper state-changed events for these.
        if self.utilities.inDocumentContent(event.source):
            if self._getQueuedEvent("object:state-changed:focused", True):
                msg = "WEB: Ignoring because object:state-changed-focused expected."
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                return True

            msg = "WEB: Handling focus event in document content; focused event missing."
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            cthulhu.setLocusOfFocus(event, event.source)
            return True

        return False

    def onFocusedChanged(self, event):
        """Callback for object:state-changed:focused accessibility events."""

        if not event.detail1:
            msg = "WEB: Ignoring because event source lost focus"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.isZombie(event.source):
            msg = "WEB: Event source is Zombie"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        document = self.utilities.getDocumentForObject(event.source)
        if not document:
            msg = "WEB: Could not get document for event source"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        prevDocument = self.utilities.getDocumentForObject(cthulhu_state.locusOfFocus)
        if prevDocument != document:
            tokens = ["WEB: document changed from", prevDocument, "to", document]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
        elif document == event.source:
            msg = "WEB: Ignoring focus change to document ancestor of focus"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.isWebAppDescendant(event.source):
            if self._browseModeIsSticky:
                msg = "WEB: Web app descendant claimed focus, but browse mode is sticky"
                debug.printMessage(debug.LEVEL_INFO, msg, True)
            elif AXUtilities.is_tool_tip(event.source) \
              and AXObject.find_ancestor(cthulhu_state.locusOfFocus, lambda x: x == event.source):
                msg = "WEB: Event believed to be side effect of tooltip navigation."
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                return True
            else:
                msg = "WEB: Event handled: Setting locusOfFocus to web app descendant"
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                cthulhu.setLocusOfFocus(event, event.source)
                return True

        if AXUtilities.is_editable(event.source):
            msg = "WEB: Event source is editable"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        if AXUtilities.is_dialog_or_alert(event.source):
            msg = "WEB: Event handled: Setting locusOfFocus to event source"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            cthulhu.setLocusOfFocus(event, event.source)
            return True

        if self.utilities.handleEventFromContextReplicant(event, event.source):
            msg = "WEB: Event handled by updating locusOfFocus and context to source."
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        obj, offset = self.utilities.getCaretContext()
        tokens = ["WEB: Caret context is", obj, ", ", offset,
                  "(focus: ", cthulhu_state.locusOfFocus, ")"]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)

        if not obj or self.utilities.isZombie(obj) or prevDocument != document:
            tokens = ["WEB: Clearing context - obj", obj, "is null or zombie or document changed"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            self.utilities.clearCaretContext()

            obj, offset = self.utilities.searchForCaretContext(event.source)
            if obj:
                notify = self.utilities.inFindContainer(cthulhu_state.locusOfFocus)
                tokens = ["WEB: Updating focus and context to", obj, ", ", offset]
                debug.printTokens(debug.LEVEL_INFO, tokens, True)
                cthulhu.setLocusOfFocus(event, obj, notify)
                if not notify and prevDocument is None:
                    self.refreshKeyGrabs()
                self.utilities.setCaretContext(obj, offset)
            else:
                msg = "WEB: Search for caret context failed"
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                if AXUtilities.is_focusable(event.source) \
                   and AXUtilities.is_focused(event.source) \
                   and self.utilities.inDocumentContent(event.source):
                    msg = "WEB: Falling back to focused event source as caret context"
                    debug.printMessage(debug.LEVEL_INFO, msg, True)
                    cthulhu.setLocusOfFocus(event, event.source, False)
                    self.utilities.setCaretContext(event.source, 0)
                    obj, offset = event.source, 0

        if self._lastCommandWasCaretNav:
            msg = "WEB: Event ignored: Last command was caret nav"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self._lastCommandWasStructNav:
            msg = "WEB: Event ignored: Last command was struct nav"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if not (AXUtilities.is_focusable(event.source) and AXUtilities.is_focused(event.source)):
            msg = "WEB: Event ignored: Source is not focusable or focused"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if not AXUtilities.is_document(event.source):
            msg = "WEB: Deferring to other scripts for handling non-document source"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        if not obj:
            msg = "WEB: Unable to get non-null, non-zombie context object"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        if self.utilities.lastInputEventWasPageNav():
            msg = "WEB: Event handled: Focus changed due to scrolling"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            cthulhu.setLocusOfFocus(event, obj)
            self.utilities.setCaretContext(obj, offset)
            return True

        wasFocused = AXUtilities.is_focused(obj)
        AXObject.clear_cache(obj)
        isFocused = AXUtilities.is_focused(obj)
        if wasFocused != isFocused:
            tokens = ["WEB: Focused state of", obj, "changed to", isFocused]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return False

        if self.utilities.isAnchor(obj):
            cause = "Context is anchor"
        elif not (self.utilities.isLink(obj) and not isFocused):
            cause = "Context is not a non-focused link"
        elif self.utilities.isChildOfCurrentFragment(obj):
            cause = "Context is child of current fragment"
        elif document == event.source and self.utilities.documentFragment(event.source):
            cause = "Document URI is fragment"
        else:
            return False

        tokens = ["WEB: Event handled: Setting locusOfFocus to", obj, "(", cause, ")"]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)
        cthulhu.setLocusOfFocus(event, obj)
        return True

    def onMouseButton(self, event):
        """Callback for mouse:button accessibility events."""

        self._lastCommandWasCaretNav = False
        self._lastCommandWasStructNav = False
        self._lastCommandWasMouseButton = True
        return False

    def onDescriptionChanged(self, event):
        """Callback for object:property-change:accessible-description events."""

        if self.utilities.eventIsBrowserUINoise(event):
            msg = "WEB: Ignoring event believed to be browser UI noise"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        obj = event.source
        if not self.utilities.inDocumentContent(obj):
            return False

        descriptions = self.pointOfReference.get('descriptions', {})
        oldDescription = descriptions.get(hash(obj))
        if oldDescription == event.any_data:
            tokens = ["WEB: Old description (", oldDescription, ") is the same as new one"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return True

        descriptions[hash(obj)] = event.any_data
        self.pointOfReference['descriptions'] = descriptions
        if obj != cthulhu_state.locusOfFocus:
            msg = "WEB: Description change is for object other than locusOfFocus"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if not event.any_data:
            return True

        name = AXObject.get_name(obj)
        if self.utilities.stringsAreRedundant(name, event.any_data):
            tokens = ["WEB: Description change is redundant with name for", obj]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return True

        self.presentMessage(event.any_data)
        return True

    def onNameChanged(self, event):
        """Callback for object:property-change:accessible-name events."""

        if self.utilities.eventIsBrowserUINoise(event):
            msg = "WEB: Ignoring event believed to be browser UI noise"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        return True

    def onRowReordered(self, event):
        """Callback for object:row-reordered accessibility events."""

        if not self.utilities.inDocumentContent(event.source):
            msg = "WEB: Event source is not in document content"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        if event.source != self.utilities.getTable(cthulhu_state.locusOfFocus):
            tokens = ["WEB: locusOfFocus (", cthulhu_state.locusOfFocus, ") is not in this table"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return False

        self.pointOfReference['last-table-sort-time'] = time.time()
        self.presentMessage(messages.TABLE_REORDERED_ROWS)
        header = self.utilities.containingTableHeader(cthulhu_state.locusOfFocus)
        if header:
            self.presentMessage(self.utilities.getSortOrderDescription(header, True))

        return True

    def onSelectedChanged(self, event):
        """Callback for object:state-changed:selected accessibility events."""

        if self.utilities.eventIsBrowserUIAutocompleteNoise(event):
            msg = "WEB: Ignoring event believed to be browser UI autocomplete noise"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.eventIsBrowserUIPageSwitch(event):
            msg = "WEB: Event believed to be browser UI page switch"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            if event.detail1:
                # Work around stale cache when switching tabs.
                AXObject.clear_cache(event.source, False, "Work around Chromium page switch.")
                AXUtilities.clear_all_cache_now(reason=msg)
                self.utilities.clearCaretContext()
                self.presentObject(event.source, priorObj=cthulhu_state.locusOfFocus, interrupt=True)
            return True

        if not self.utilities.inDocumentContent(event.source):
            msg = "WEB: Event source is not in document content"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        if cthulhu_state.locusOfFocus != event.source:
            msg = "WEB: Ignoring because event source is not locusOfFocus"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        return False

    def onSelectionChanged(self, event):
        """Callback for object:selection-changed accessibility events."""

        if self.utilities.eventIsBrowserUIAutocompleteNoise(event):
            msg = "WEB: Ignoring event believed to be browser UI autocomplete noise"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.eventIsBrowserUIPageSwitch(event):
            msg = "WEB: Ignoring event believed to be browser UI page switch"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if not self.utilities.inDocumentContent(event.source):
            msg = "WEB: Event source is not in document content"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        if not self.utilities.inDocumentContent(cthulhu_state.locusOfFocus):
            tokens = ["WEB: Event ignored: locusOfFocus", cthulhu_state.locusOfFocus,
                      "is not in document content"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return True

        if not self.utilities.eventIsFromLocusOfFocusDocument(event):
            msg = "WEB: Event ignored: Not from locus of focus document"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.isWebAppDescendant(event.source):
            if self._inFocusMode:
                # Because we cannot count on the app firing the right state-changed events
                # for descendants.
                AXObject.clear_cache(event.source)
                msg = "WEB: Event source is web app descendant and we're in focus mode"
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                return False

            msg = "WEB: Event source is web app descendant and we're in browse mode"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.eventIsIrrelevantSelectionChangedEvent(event):
            msg = "WEB: Event ignored: Irrelevant"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        obj, offset = self.utilities.getCaretContext()
        ancestor = self.utilities.commonAncestor(obj, event.source)
        if ancestor and self.utilities.isTextBlockElement(ancestor):
            msg = "WEB: Ignoring: Common ancestor of context and event source is text block"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        return False

    def onShowingChanged(self, event):
        """Callback for object:state-changed:showing accessibility events."""

        if event.detail1 and self.utilities.isTopLevelBrowserUIAlert(event.source):
            msg = "WEB: Event handled: Presenting event source"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            self.presentObject(event.source, interrupt=True)
            return True

        if not self.utilities.inDocumentContent(event.source):
            msg = "WEB: Event source is not in document content"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        return True

    def onTextAttributesChanged(self, event):
        """Callback for object:text-attributes-changed accessibility events."""

        msg = "WEB: Clearing cached text attributes"
        debug.printMessage(debug.LEVEL_INFO, msg, True)
        self._currentTextAttrs = {}
        return False

    def onTextDeleted(self, event):
        """Callback for object:text-changed:delete accessibility events."""

        if self.utilities.isZombie(event.source):
            msg = "WEB: Event source is Zombie"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.lastInputEventWasPageSwitch():
            msg = "WEB: Deletion is believed to be due to page switch"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.isLiveRegion(event.source):
            msg = "WEB: Ignoring deletion from live region"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.eventIsBrowserUINoise(event):
            msg = "WEB: Ignoring event believed to be browser UI noise"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if not self.utilities.inDocumentContent(event.source):
            msg = "WEB: Event source is not in document content"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        if self.utilities.eventIsSpinnerNoise(event):
            msg = "WEB: Ignoring: Event believed to be spinner noise"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.eventIsAutocompleteNoise(event):
            msg = "WEB: Ignoring event believed to be autocomplete noise"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        msg = "WEB: Clearing content cache due to text deletion"
        debug.printMessage(debug.LEVEL_INFO, msg, True)
        self.utilities.clearContentCache()

        if self.utilities.textEventIsDueToDeletion(event):
            msg = "WEB: Event believed to be due to editable text deletion"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        if self.utilities.textEventIsDueToInsertion(event):
            msg = "WEB: Ignoring event believed to be due to text insertion"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        obj, offset = self.utilities.getCaretContext(getZombieReplicant=False)
        if obj and obj != event.source \
           and not AXObject.find_ancestor(obj, lambda x: x == event.source):
            tokens = ["WEB: Ignoring event because it isn't", obj, "or its ancestor"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return True

        if self.utilities.isZombie(obj):
            if self.utilities.isLink(obj):
                msg = "WEB: Focused link deleted. Taking no further action."
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                return True

            obj, offset = self.utilities.getCaretContext(getZombieReplicant=True)
            if obj:
                cthulhu.setLocusOfFocus(event, obj, notifyScript=False)

        if self.utilities.isZombie(obj):
            msg = "WEB: Unable to get non-null, non-zombie context object"
            debug.printMessage(debug.LEVEL_INFO, msg, True)

        document = self.utilities.getDocumentForObject(event.source)
        if document:
            tokens = ["WEB: Clearing structural navigation cache for", document]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            self.structuralNavigation.clearCache(document)

        if not AXUtilities.is_editable(event.source) \
           and not self.utilities.isContentEditableWithEmbeddedObjects(event.source):
            if self._inMouseOverObject \
               and self.utilities.isZombie(self._lastMouseOverObject):
                msg = "WEB: Restoring pre-mouseover context"
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                self.restorePreMouseOverContext()

            msg = "WEB: Done processing non-editable source"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        return False

    def onTextInserted(self, event):
        """Callback for object:text-changed:insert accessibility events."""

        if self.utilities.isZombie(event.source):
            msg = "WEB: Event source is Zombie"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.lastInputEventWasPageSwitch():
            msg = "WEB: Insertion is believed to be due to page switch"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.handleAsLiveRegion(event):
            msg = "WEB: Event to be handled as live region"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            self.liveRegionManager.handleEvent(event)
            return True

        if self.utilities.isLiveRegion(event.source):
            msg = "WEB: Ignoring because live region event not to be handled."
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.eventIsEOCAdded(event):
            msg = "WEB: Ignoring: Event was for embedded object char"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.eventIsBrowserUINoise(event):
            msg = "WEB: Ignoring event believed to be browser UI noise"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if not self.utilities.inDocumentContent(event.source):
            msg = "WEB: Event source is not in document content"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        if self.utilities.eventIsSpinnerNoise(event):
            msg = "WEB: Ignoring: Event believed to be spinner noise"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.eventIsAutocompleteNoise(event):
            msg = "WEB: Ignoring: Event believed to be autocomplete noise"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        msg = "WEB: Clearing content cache due to text insertion"
        debug.printMessage(debug.LEVEL_INFO, msg, True)
        self.utilities.clearContentCache()

        document = self.utilities.getTopLevelDocumentForObject(event.source)
        if AXObject.is_dead(cthulhu_state.locusOfFocus):
            msg = "WEB: Dumping cache: dead focus"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            self.utilities.dumpCache(document, preserveContext=True)

            if AXUtilities.is_focused(event.source):
                msg = "WEB: Event handled: Setting locusOfFocus to event source"
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                cthulhu.setLocusOfFocus(None, event.source, force=True)
                return True

        else:
            tokens = ["WEB: Clearing structural navigation cache for", document]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            self.structuralNavigation.clearCache(document)

        text = self.utilities.queryNonEmptyText(event.source)
        if not text:
            msg = "WEB: Ignoring: Event source is not a text object"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if not AXUtilities.is_editable(event.source):
            if event.source != cthulhu_state.locusOfFocus:
                msg = "WEB: Done processing non-editable, non-locusOfFocus source"
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                return True

            if self.utilities.isClickableElement(event.source):
                msg = "WEB: Event handled: Re-setting locusOfFocus to changed clickable"
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                cthulhu.setLocusOfFocus(None, event.source, force=True)
                return True

        if AXUtilities.is_text_input(event.source) \
           and AXUtilities.is_focused(event.source) \
           and event.source != cthulhu_state.locusOfFocus:
            msg = "WEB: Focused entry is not the locus of focus. Waiting for focus event."
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        return False

    def onTextSelectionChanged(self, event):
        """Callback for object:text-selection-changed accessibility events."""

        self._clearSyntheticWebSelection()

        if self.utilities.isZombie(event.source):
            msg = "WEB: Event source is Zombie"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.eventIsBrowserUINoise(event):
            msg = "WEB: Ignoring event believed to be browser UI noise"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if not self.utilities.inDocumentContent(event.source):
            msg = "WEB: Event source is not in document content"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        if not self.utilities.inDocumentContent(cthulhu_state.locusOfFocus):
            tokens = ["WEB: Event ignored: locusOfFocus", cthulhu_state.locusOfFocus,
                      "is not in document content"]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return True

        if self.utilities.eventIsAutocompleteNoise(event):
            msg = "WEB: Ignoring: Event believed to be autocomplete noise"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.eventIsSpinnerNoise(event):
            msg = "WEB: Ignoring: Event believed to be spinner noise"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.textEventIsForNonNavigableTextObject(event):
            msg = "WEB: Ignoring event for non-navigable text object"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        text = self.utilities.queryNonEmptyText(event.source)
        if not text:
            msg = "WEB: Ignoring: Event source is not a text object"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.lastInputEventWasCaretNavWithSelection() \
           and not AXText.get_selected_ranges(event.source):
            document = self.utilities.getTopLevelDocumentForObject(event.source)
            obj, offset = self.utilities.getCaretContext(document, False, False)
            focusOffset = AXText.get_caret_offset(event.source)
            if self._presentSyntheticCaretSelection(
                event,
                document,
                obj,
                offset,
                focusOverride=(event.source, focusOffset),
            ):
                msg = "WEB: Event handled: synthetic selection from event source"
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                return True

        if AXUtilities.is_text_input(event.source) \
           and AXUtilities.is_focused(event.source) \
           and event.source != cthulhu_state.locusOfFocus:
            msg = "WEB: Focused entry is not the locus of focus. Waiting for focus event."
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        if self.utilities.isContentEditableWithEmbeddedObjects(event.source):
            msg = "WEB: In content editable with embedded objects"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return False

        offset = AXText.get_caret_offset(event.source)
        char = AXText.get_substring(event.source, offset, offset + 1)
        if char == self.EMBEDDED_OBJECT_CHARACTER \
           and not self.utilities.lastInputEventWasCaretNavWithSelection() \
           and not self.utilities.lastInputEventWasCommand():
            msg = "WEB: Ignoring: Not selecting and event offset is at embedded object"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        return False

    def onWindowActivated(self, event):
        """Callback for window:activate accessibility events."""

        msg = "WEB: Deferring to app/toolkit script"
        debug.printMessage(debug.LEVEL_INFO, msg, True)
        return False

    def onWindowDeactivated(self, event):
        """Callback for window:deactivate accessibility events."""

        msg = "WEB: Clearing command state"
        debug.printMessage(debug.LEVEL_INFO, msg, True)
        self._clearSyntheticWebSelection()
        self._lastCommandWasCaretNav = False
        self._lastCommandWasStructNav = False
        self._lastCommandWasMouseButton = False
        self._lastMouseButtonContext = None, -1
        return False

    def getTransferableAttributes(self):
        return {"_lastCommandWasCaretNav": self._lastCommandWasCaretNav,
                "_lastCommandWasStructNav": self._lastCommandWasStructNav,
                "_lastCommandWasMouseButton": self._lastCommandWasMouseButton,
                "_inFocusMode": self._inFocusMode,
                "_focusModeIsSticky": self._focusModeIsSticky,
                "_browseModeIsSticky": self._browseModeIsSticky,
        }
