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

# pylint: disable=too-many-public-methods
# pylint: disable=too-many-lines

"""Provides typing echo support."""

from __future__ import annotations

import string
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING

from . import (
    cmdnames,
    command_manager,
    dbus_service,
    debug,
    gsettings_registry,
    guilabels,
    input_event,
    messages,
    preferences_grid_base,
    presentation_manager,
    speech_presenter,
)
from .ax_text import AXText
from .ax_utilities import AXUtilities

if TYPE_CHECKING:
    from collections.abc import Callable, Iterable

    from gi.repository import Atspi

    from .scripts import default


class PreferenceCategory(Enum):
    """Categories of typing echo preferences for UI grouping."""

    PRIMARY = "primary"
    KEY = "key"
    TEXT = "text"


@dataclass(frozen=True)
class TypingEchoPreference:
    """Descriptor for a single typing echo preference."""

    prefs_key: str
    label: str
    category: PreferenceCategory
    getter: Callable[[], bool]
    setter: Callable[[bool], bool]


class TypingEchoPreferencesGrid(preferences_grid_base.AutoPreferencesGrid):
    """GtkGrid containing the Typing Echo preferences page."""

    _gsettings_schema = "typing-echo"

    def __init__(self, presenter: TypingEchoPresenter) -> None:
        self._enable_key_echo_control = preferences_grid_base.BooleanPreferenceControl(
            label=guilabels.ECHO_ENABLE_KEY_ECHO,
            getter=presenter.get_key_echo_enabled,
            setter=presenter.set_key_echo_enabled,
            prefs_key=TypingEchoPresenter.KEY_KEY_ECHO,
        )

        controls = [
            self._enable_key_echo_control,
            preferences_grid_base.BooleanPreferenceControl(
                label=guilabels.ECHO_ALPHABETIC_KEYS,
                getter=presenter.get_alphabetic_keys_enabled,
                setter=presenter.set_alphabetic_keys_enabled,
                prefs_key=TypingEchoPresenter.KEY_ALPHABETIC_KEYS,
                member_of=guilabels.ECHO_KEYS_TO_ECHO,
                determine_sensitivity=presenter.get_key_echo_enabled,
            ),
            preferences_grid_base.BooleanPreferenceControl(
                label=guilabels.ECHO_NUMERIC_KEYS,
                getter=presenter.get_numeric_keys_enabled,
                setter=presenter.set_numeric_keys_enabled,
                prefs_key=TypingEchoPresenter.KEY_NUMERIC_KEYS,
                member_of=guilabels.ECHO_KEYS_TO_ECHO,
                determine_sensitivity=presenter.get_key_echo_enabled,
            ),
            preferences_grid_base.BooleanPreferenceControl(
                label=guilabels.ECHO_PUNCTUATION_KEYS,
                getter=presenter.get_punctuation_keys_enabled,
                setter=presenter.set_punctuation_keys_enabled,
                prefs_key=TypingEchoPresenter.KEY_PUNCTUATION_KEYS,
                member_of=guilabels.ECHO_KEYS_TO_ECHO,
                determine_sensitivity=presenter.get_key_echo_enabled,
            ),
            preferences_grid_base.BooleanPreferenceControl(
                label=guilabels.ECHO_SPACE,
                getter=presenter.get_space_enabled,
                setter=presenter.set_space_enabled,
                prefs_key=TypingEchoPresenter.KEY_SPACE,
                member_of=guilabels.ECHO_KEYS_TO_ECHO,
                determine_sensitivity=presenter.get_key_echo_enabled,
            ),
            preferences_grid_base.BooleanPreferenceControl(
                label=guilabels.ECHO_MODIFIER_KEYS,
                getter=presenter.get_modifier_keys_enabled,
                setter=presenter.set_modifier_keys_enabled,
                prefs_key=TypingEchoPresenter.KEY_MODIFIER_KEYS,
                member_of=guilabels.ECHO_KEYS_TO_ECHO,
                determine_sensitivity=presenter.get_key_echo_enabled,
            ),
            preferences_grid_base.BooleanPreferenceControl(
                label=guilabels.ECHO_FUNCTION_KEYS,
                getter=presenter.get_function_keys_enabled,
                setter=presenter.set_function_keys_enabled,
                prefs_key=TypingEchoPresenter.KEY_FUNCTION_KEYS,
                member_of=guilabels.ECHO_KEYS_TO_ECHO,
                determine_sensitivity=presenter.get_key_echo_enabled,
            ),
            preferences_grid_base.BooleanPreferenceControl(
                label=guilabels.ECHO_ACTION_KEYS,
                getter=presenter.get_action_keys_enabled,
                setter=presenter.set_action_keys_enabled,
                prefs_key=TypingEchoPresenter.KEY_ACTION_KEYS,
                member_of=guilabels.ECHO_KEYS_TO_ECHO,
                determine_sensitivity=presenter.get_key_echo_enabled,
            ),
            preferences_grid_base.BooleanPreferenceControl(
                label=guilabels.ECHO_NAVIGATION_KEYS,
                getter=presenter.get_navigation_keys_enabled,
                setter=presenter.set_navigation_keys_enabled,
                prefs_key=TypingEchoPresenter.KEY_NAVIGATION_KEYS,
                member_of=guilabels.ECHO_KEYS_TO_ECHO,
                determine_sensitivity=presenter.get_key_echo_enabled,
            ),
            preferences_grid_base.BooleanPreferenceControl(
                label=guilabels.ECHO_DIACRITICAL_KEYS,
                getter=presenter.get_diacritical_keys_enabled,
                setter=presenter.set_diacritical_keys_enabled,
                prefs_key=TypingEchoPresenter.KEY_DIACRITICAL_KEYS,
                member_of=guilabels.ECHO_KEYS_TO_ECHO,
                determine_sensitivity=presenter.get_key_echo_enabled,
            ),
            preferences_grid_base.BooleanPreferenceControl(
                label=guilabels.ECHO_CHARACTER,
                getter=presenter.get_character_echo_enabled,
                setter=presenter.set_character_echo_enabled,
                prefs_key=TypingEchoPresenter.KEY_CHARACTER_ECHO,
                member_of=guilabels.ECHO_TYPING_ECHO,
            ),
            preferences_grid_base.BooleanPreferenceControl(
                label=guilabels.ECHO_WORD,
                getter=presenter.get_word_echo_enabled,
                setter=presenter.set_word_echo_enabled,
                prefs_key=TypingEchoPresenter.KEY_WORD_ECHO,
                member_of=guilabels.ECHO_TYPING_ECHO,
            ),
            preferences_grid_base.BooleanPreferenceControl(
                label=guilabels.ECHO_SENTENCE,
                getter=presenter.get_sentence_echo_enabled,
                setter=presenter.set_sentence_echo_enabled,
                prefs_key=TypingEchoPresenter.KEY_SENTENCE_ECHO,
                member_of=guilabels.ECHO_TYPING_ECHO,
            ),
        ]

        self._presenter = presenter
        super().__init__(guilabels.ECHO, controls, info_message=guilabels.ECHO_INFO)


@gsettings_registry.get_registry().gsettings_schema("org.gnome.Orca.TypingEcho", name="typing-echo")
class TypingEchoPresenter:
    """Provides typing echo support."""

    _SCHEMA = "typing-echo"
    KEY_KEY_ECHO = "key-echo"
    KEY_CHARACTER_ECHO = "character-echo"
    KEY_WORD_ECHO = "word-echo"
    KEY_SENTENCE_ECHO = "sentence-echo"
    KEY_ALPHABETIC_KEYS = "alphabetic-keys"
    KEY_NUMERIC_KEYS = "numeric-keys"
    KEY_PUNCTUATION_KEYS = "punctuation-keys"
    KEY_SPACE = "space"
    KEY_MODIFIER_KEYS = "modifier-keys"
    KEY_FUNCTION_KEYS = "function-keys"
    KEY_ACTION_KEYS = "action-keys"
    KEY_NAVIGATION_KEYS = "navigation-keys"
    KEY_DIACRITICAL_KEYS = "diacritical-keys"

    def _get_setting(self, key: str, default: bool) -> bool:
        """Returns the dconf value for key, or default if not in dconf."""

        return gsettings_registry.get_registry().layered_lookup(
            self._SCHEMA,
            key,
            "b",
            default=default,
        )

    def __init__(self) -> None:
        self._delayed_terminal_press: input_event.KeyboardEvent | None = None
        self._initialized: bool = False
        self._present_locking_keys: bool | None = None
        msg = "TYPING ECHO PRESENTER: Registering D-Bus commands."
        debug.print_message(debug.LEVEL_INFO, msg, True)
        controller = dbus_service.get_remote_controller()
        controller.register_decorated_module("TypingEchoPresenter", self)

    def set_up_commands(self) -> None:
        """Sets up commands with CommandManager."""

        if self._initialized:
            return
        self._initialized = True

        manager = command_manager.get_manager()
        group_label = guilabels.KB_GROUP_DEFAULT

        manager.add_command(
            command_manager.KeyboardCommand(
                "cycleKeyEchoHandler",
                self.cycle_key_echo,
                group_label,
                cmdnames.CYCLE_KEY_ECHO,
                desktop_keybinding=None,
                laptop_keybinding=None,
            ),
        )

        msg = "TYPING ECHO PRESENTER: Commands set up."
        debug.print_message(debug.LEVEL_INFO, msg, True)

    def create_preferences_grid(self) -> TypingEchoPreferencesGrid:
        """Returns the GtkGrid containing the Typing Echo preferences UI."""

        return TypingEchoPreferencesGrid(self)

    def get_typing_echo_preferences(self) -> tuple[TypingEchoPreference, ...]:
        """Return descriptors for all typing echo preferences."""

        return (
            TypingEchoPreference(
                "enableKeyEcho",
                guilabels.ECHO_ENABLE_KEY_ECHO,
                PreferenceCategory.PRIMARY,
                self.get_key_echo_enabled,
                self.set_key_echo_enabled,
            ),
            TypingEchoPreference(
                "enableAlphabeticKeys",
                guilabels.ECHO_ALPHABETIC_KEYS,
                PreferenceCategory.KEY,
                self.get_alphabetic_keys_enabled,
                self.set_alphabetic_keys_enabled,
            ),
            TypingEchoPreference(
                "enableNumericKeys",
                guilabels.ECHO_NUMERIC_KEYS,
                PreferenceCategory.KEY,
                self.get_numeric_keys_enabled,
                self.set_numeric_keys_enabled,
            ),
            TypingEchoPreference(
                "enablePunctuationKeys",
                guilabels.ECHO_PUNCTUATION_KEYS,
                PreferenceCategory.KEY,
                self.get_punctuation_keys_enabled,
                self.set_punctuation_keys_enabled,
            ),
            TypingEchoPreference(
                "enableSpace",
                guilabels.ECHO_SPACE,
                PreferenceCategory.KEY,
                self.get_space_enabled,
                self.set_space_enabled,
            ),
            TypingEchoPreference(
                "enableModifierKeys",
                guilabels.ECHO_MODIFIER_KEYS,
                PreferenceCategory.KEY,
                self.get_modifier_keys_enabled,
                self.set_modifier_keys_enabled,
            ),
            TypingEchoPreference(
                "enableFunctionKeys",
                guilabels.ECHO_FUNCTION_KEYS,
                PreferenceCategory.KEY,
                self.get_function_keys_enabled,
                self.set_function_keys_enabled,
            ),
            TypingEchoPreference(
                "enableActionKeys",
                guilabels.ECHO_ACTION_KEYS,
                PreferenceCategory.KEY,
                self.get_action_keys_enabled,
                self.set_action_keys_enabled,
            ),
            TypingEchoPreference(
                "enableNavigationKeys",
                guilabels.ECHO_NAVIGATION_KEYS,
                PreferenceCategory.KEY,
                self.get_navigation_keys_enabled,
                self.set_navigation_keys_enabled,
            ),
            TypingEchoPreference(
                "enableDiacriticalKeys",
                guilabels.ECHO_DIACRITICAL_KEYS,
                PreferenceCategory.KEY,
                self.get_diacritical_keys_enabled,
                self.set_diacritical_keys_enabled,
            ),
            TypingEchoPreference(
                "enableEchoByCharacter",
                guilabels.ECHO_CHARACTER,
                PreferenceCategory.TEXT,
                self.get_character_echo_enabled,
                self.set_character_echo_enabled,
            ),
            TypingEchoPreference(
                "enableEchoByWord",
                guilabels.ECHO_WORD,
                PreferenceCategory.TEXT,
                self.get_word_echo_enabled,
                self.set_word_echo_enabled,
            ),
            TypingEchoPreference(
                "enableEchoBySentence",
                guilabels.ECHO_SENTENCE,
                PreferenceCategory.TEXT,
                self.get_sentence_echo_enabled,
                self.set_sentence_echo_enabled,
            ),
        )

    def apply_typing_echo_preferences(
        self,
        updates: Iterable[tuple[TypingEchoPreference, bool]],
    ) -> dict[str, bool]:
        """Apply the provided preference values and return the saved mapping."""

        result: dict[str, bool] = {}
        for descriptor, value in updates:
            descriptor.setter(value)
            result[descriptor.prefs_key] = value
        return result

    @dbus_service.command
    def cycle_key_echo(
        self,
        script: default.Script | None = None,
        event: input_event.InputEvent | None = None,
        notify_user: bool = True,
    ) -> bool:
        """Cycle through the key echo levels."""

        tokens = [
            "TYPING ECHO PRESENTER: cycle_key_echo. Script:",
            script,
            "Event:",
            event,
            "notify_user:",
            notify_user,
        ]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        (new_key, new_word, new_sentence) = (False, False, False)
        key = self.get_key_echo_enabled()
        word = self.get_word_echo_enabled()
        sentence = self.get_sentence_echo_enabled()

        if (key, word, sentence) == (False, False, False):
            (new_key, new_word, new_sentence) = (True, False, False)
            full = messages.KEY_ECHO_KEY_FULL
            brief = messages.KEY_ECHO_KEY_BRIEF
        elif (key, word, sentence) == (True, False, False):
            (new_key, new_word, new_sentence) = (False, True, False)
            full = messages.KEY_ECHO_WORD_FULL
            brief = messages.KEY_ECHO_WORD_BRIEF
        elif (key, word, sentence) == (False, True, False):
            (new_key, new_word, new_sentence) = (False, False, True)
            full = messages.KEY_ECHO_SENTENCE_FULL
            brief = messages.KEY_ECHO_SENTENCE_BRIEF
        elif (key, word, sentence) == (False, False, True):
            (new_key, new_word, new_sentence) = (True, True, False)
            full = messages.KEY_ECHO_KEY_AND_WORD_FULL
            brief = messages.KEY_ECHO_KEY_AND_WORD_BRIEF
        elif (key, word, sentence) == (True, True, False):
            (new_key, new_word, new_sentence) = (False, True, True)
            full = messages.KEY_ECHO_WORD_AND_SENTENCE_FULL
            brief = messages.KEY_ECHO_WORD_AND_SENTENCE_BRIEF
        else:
            (new_key, new_word, new_sentence) = (False, False, False)
            full = messages.KEY_ECHO_NONE_FULL
            brief = messages.KEY_ECHO_NONE_BRIEF

        self.set_key_echo_enabled(new_key)
        self.set_word_echo_enabled(new_word)
        self.set_sentence_echo_enabled(new_sentence)
        if script is not None and notify_user:
            presentation_manager.get_manager().present_message(full, brief)
        return True

    @gsettings_registry.get_registry().gsetting(
        key=KEY_KEY_ECHO,
        schema="typing-echo",
        gtype="b",
        default=True,
        summary="Enable key echo",
        migration_key="enableKeyEcho",
    )
    @dbus_service.getter
    def get_key_echo_enabled(self) -> bool:
        """Returns whether echo of key presses is enabled. See also get_character_echo_enabled."""

        return self._get_setting(self.KEY_KEY_ECHO, True)

    @dbus_service.setter
    def set_key_echo_enabled(self, value: bool) -> bool:
        """Sets whether echo of key presses is enabled. See also set_character_echo_enabled."""

        msg = f"TYPING ECHO PRESENTER: Setting enable key echo to {value}."
        debug.print_message(debug.LEVEL_INFO, msg, True)
        gsettings_registry.get_registry().set_runtime_value(self._SCHEMA, self.KEY_KEY_ECHO, value)
        return True

    @gsettings_registry.get_registry().gsetting(
        key=KEY_CHARACTER_ECHO,
        schema="typing-echo",
        gtype="b",
        default=False,
        summary="Echo inserted characters",
        migration_key="enableEchoByCharacter",
    )
    @dbus_service.getter
    def get_character_echo_enabled(self) -> bool:
        """Returns whether echo of inserted characters is enabled."""

        return self._get_setting(self.KEY_CHARACTER_ECHO, False)

    @dbus_service.setter
    def set_character_echo_enabled(self, value: bool) -> bool:
        """Sets whether echo of inserted characters is enabled."""

        msg = f"TYPING ECHO PRESENTER: Setting enable character echo to {value}."
        debug.print_message(debug.LEVEL_INFO, msg, True)
        gsettings_registry.get_registry().set_runtime_value(
            self._SCHEMA, self.KEY_CHARACTER_ECHO, value
        )
        return True

    @gsettings_registry.get_registry().gsetting(
        key=KEY_WORD_ECHO,
        schema="typing-echo",
        gtype="b",
        default=False,
        summary="Echo completed words",
        migration_key="enableEchoByWord",
    )
    @dbus_service.getter
    def get_word_echo_enabled(self) -> bool:
        """Returns whether word echo is enabled."""

        return self._get_setting(self.KEY_WORD_ECHO, False)

    @dbus_service.setter
    def set_word_echo_enabled(self, value: bool) -> bool:
        """Sets whether word echo is enabled."""

        msg = f"TYPING ECHO PRESENTER: Setting enable word echo to {value}."
        debug.print_message(debug.LEVEL_INFO, msg, True)
        gsettings_registry.get_registry().set_runtime_value(self._SCHEMA, self.KEY_WORD_ECHO, value)
        return True

    @gsettings_registry.get_registry().gsetting(
        key=KEY_SENTENCE_ECHO,
        schema="typing-echo",
        gtype="b",
        default=False,
        summary="Echo completed sentences",
        migration_key="enableEchoBySentence",
    )
    @dbus_service.getter
    def get_sentence_echo_enabled(self) -> bool:
        """Returns whether sentence echo is enabled."""

        return self._get_setting(self.KEY_SENTENCE_ECHO, False)

    @dbus_service.setter
    def set_sentence_echo_enabled(self, value: bool) -> bool:
        """Sets whether sentence echo is enabled."""

        msg = f"TYPING ECHO PRESENTER: Setting enable sentence echo to {value}."
        debug.print_message(debug.LEVEL_INFO, msg, True)
        gsettings_registry.get_registry().set_runtime_value(
            self._SCHEMA, self.KEY_SENTENCE_ECHO, value
        )
        return True

    @gsettings_registry.get_registry().gsetting(
        key=KEY_ALPHABETIC_KEYS,
        schema="typing-echo",
        gtype="b",
        default=True,
        summary="Echo alphabetic keys",
        migration_key="enableAlphabeticKeys",
    )
    @dbus_service.getter
    def get_alphabetic_keys_enabled(self) -> bool:
        """Returns whether alphabetic keys will be echoed when key echo is enabled."""

        return self._get_setting(self.KEY_ALPHABETIC_KEYS, True)

    @dbus_service.setter
    def set_alphabetic_keys_enabled(self, value: bool) -> bool:
        """Sets whether alphabetic keys will be echoed when key echo is enabled."""

        msg = f"TYPING ECHO PRESENTER: Setting enable alphabetic keys to {value}."
        debug.print_message(debug.LEVEL_INFO, msg, True)
        gsettings_registry.get_registry().set_runtime_value(
            self._SCHEMA, self.KEY_ALPHABETIC_KEYS, value
        )
        return True

    @gsettings_registry.get_registry().gsetting(
        key=KEY_NUMERIC_KEYS,
        schema="typing-echo",
        gtype="b",
        default=True,
        summary="Echo numeric keys",
        migration_key="enableNumericKeys",
    )
    @dbus_service.getter
    def get_numeric_keys_enabled(self) -> bool:
        """Returns whether numeric keys will be echoed when key echo is enabled."""

        return self._get_setting(self.KEY_NUMERIC_KEYS, True)

    @dbus_service.setter
    def set_numeric_keys_enabled(self, value: bool) -> bool:
        """Sets whether numeric keys will be echoed when key echo is enabled."""

        msg = f"TYPING ECHO PRESENTER: Setting enable numeric keys to {value}."
        debug.print_message(debug.LEVEL_INFO, msg, True)
        gsettings_registry.get_registry().set_runtime_value(
            self._SCHEMA, self.KEY_NUMERIC_KEYS, value
        )
        return True

    @gsettings_registry.get_registry().gsetting(
        key=KEY_PUNCTUATION_KEYS,
        schema="typing-echo",
        gtype="b",
        default=True,
        summary="Echo punctuation keys",
        migration_key="enablePunctuationKeys",
    )
    @dbus_service.getter
    def get_punctuation_keys_enabled(self) -> bool:
        """Returns whether punctuation keys will be echoed when key echo is enabled."""

        return self._get_setting(self.KEY_PUNCTUATION_KEYS, True)

    @dbus_service.setter
    def set_punctuation_keys_enabled(self, value: bool) -> bool:
        """Sets whether punctuation keys will be echoed when key echo is enabled."""

        msg = f"TYPING ECHO PRESENTER: Setting enable punctuation keys to {value}."
        debug.print_message(debug.LEVEL_INFO, msg, True)
        gsettings_registry.get_registry().set_runtime_value(
            self._SCHEMA, self.KEY_PUNCTUATION_KEYS, value
        )
        return True

    @gsettings_registry.get_registry().gsetting(
        key=KEY_SPACE,
        schema="typing-echo",
        gtype="b",
        default=True,
        summary="Echo space key",
        migration_key="enableSpace",
    )
    @dbus_service.getter
    def get_space_enabled(self) -> bool:
        """Returns whether space key will be echoed when key echo is enabled."""

        return self._get_setting(self.KEY_SPACE, True)

    @dbus_service.setter
    def set_space_enabled(self, value: bool) -> bool:
        """Sets whether space key will be echoed when key echo is enabled."""

        msg = f"TYPING ECHO PRESENTER: Setting enable space to {value}."
        debug.print_message(debug.LEVEL_INFO, msg, True)
        gsettings_registry.get_registry().set_runtime_value(self._SCHEMA, self.KEY_SPACE, value)
        return True

    @gsettings_registry.get_registry().gsetting(
        key=KEY_MODIFIER_KEYS,
        schema="typing-echo",
        gtype="b",
        default=True,
        summary="Echo modifier keys",
        migration_key="enableModifierKeys",
    )
    @dbus_service.getter
    def get_modifier_keys_enabled(self) -> bool:
        """Returns whether modifier keys will be echoed when key echo is enabled."""

        return self._get_setting(self.KEY_MODIFIER_KEYS, True)

    @dbus_service.setter
    def set_modifier_keys_enabled(self, value: bool) -> bool:
        """Sets whether modifier keys will be echoed when key echo is enabled."""

        msg = f"TYPING ECHO PRESENTER: Setting enable modifier keys to {value}."
        debug.print_message(debug.LEVEL_INFO, msg, True)
        gsettings_registry.get_registry().set_runtime_value(
            self._SCHEMA, self.KEY_MODIFIER_KEYS, value
        )
        return True

    @gsettings_registry.get_registry().gsetting(
        key=KEY_FUNCTION_KEYS,
        schema="typing-echo",
        gtype="b",
        default=True,
        summary="Echo function keys",
        migration_key="enableFunctionKeys",
    )
    @dbus_service.getter
    def get_function_keys_enabled(self) -> bool:
        """Returns whether function keys will be echoed when key echo is enabled."""

        return self._get_setting(self.KEY_FUNCTION_KEYS, True)

    @dbus_service.setter
    def set_function_keys_enabled(self, value: bool) -> bool:
        """Sets whether function keys will be echoed when key echo is enabled."""

        msg = f"TYPING ECHO PRESENTER: Setting enable function keys to {value}."
        debug.print_message(debug.LEVEL_INFO, msg, True)
        gsettings_registry.get_registry().set_runtime_value(
            self._SCHEMA, self.KEY_FUNCTION_KEYS, value
        )
        return True

    @gsettings_registry.get_registry().gsetting(
        key=KEY_ACTION_KEYS,
        schema="typing-echo",
        gtype="b",
        default=True,
        summary="Echo action keys",
        migration_key="enableActionKeys",
    )
    @dbus_service.getter
    def get_action_keys_enabled(self) -> bool:
        """Returns whether action keys will be echoed when key echo is enabled."""

        return self._get_setting(self.KEY_ACTION_KEYS, True)

    @dbus_service.setter
    def set_action_keys_enabled(self, value: bool) -> bool:
        """Sets whether action keys will be echoed when key echo is enabled."""

        msg = f"TYPING ECHO PRESENTER: Setting enable action keys to {value}."
        debug.print_message(debug.LEVEL_INFO, msg, True)
        gsettings_registry.get_registry().set_runtime_value(
            self._SCHEMA, self.KEY_ACTION_KEYS, value
        )
        return True

    @gsettings_registry.get_registry().gsetting(
        key=KEY_NAVIGATION_KEYS,
        schema="typing-echo",
        gtype="b",
        default=False,
        summary="Echo navigation keys",
        migration_key="enableNavigationKeys",
    )
    @dbus_service.getter
    def get_navigation_keys_enabled(self) -> bool:
        """Returns whether navigation keys will be echoed when key echo is enabled."""

        return self._get_setting(self.KEY_NAVIGATION_KEYS, False)

    @dbus_service.setter
    def set_navigation_keys_enabled(self, value: bool) -> bool:
        """Sets whether navigation keys will be echoed when key echo is enabled."""

        msg = f"TYPING ECHO PRESENTER: Setting enable navigation keys to {value}."
        debug.print_message(debug.LEVEL_INFO, msg, True)
        gsettings_registry.get_registry().set_runtime_value(
            self._SCHEMA, self.KEY_NAVIGATION_KEYS, value
        )
        return True

    @gsettings_registry.get_registry().gsetting(
        key=KEY_DIACRITICAL_KEYS,
        schema="typing-echo",
        gtype="b",
        default=False,
        summary="Echo diacritical keys",
        migration_key="enableDiacriticalKeys",
    )
    @dbus_service.getter
    def get_diacritical_keys_enabled(self) -> bool:
        """Returns whether diacritical keys will be echoed when key echo is enabled."""

        return self._get_setting(self.KEY_DIACRITICAL_KEYS, False)

    @dbus_service.setter
    def set_diacritical_keys_enabled(self, value: bool) -> bool:
        """Sets whether diacritical keys will be echoed when key echo is enabled."""

        msg = f"TYPING ECHO PRESENTER: Setting enable diacritical keys to {value}."
        debug.print_message(debug.LEVEL_INFO, msg, True)
        gsettings_registry.get_registry().set_runtime_value(
            self._SCHEMA, self.KEY_DIACRITICAL_KEYS, value
        )
        return True

    @dbus_service.getter
    def get_locking_keys_presented(self) -> bool:
        """Returns whether locking keys are presented."""

        # TODO - JD: It turns out there's no UI for this setting, so it defaults to None.

        if self._present_locking_keys is not None:
            return self._present_locking_keys

        return not speech_presenter.get_presenter().get_only_speak_displayed_text()

    @dbus_service.setter
    def set_locking_keys_presented(self, value: bool | None) -> bool:
        """Sets whether locking keys are presented."""

        msg = f"TYPING ECHO PRESENTER: Setting present locking keys to {value}."
        debug.print_message(debug.LEVEL_INFO, msg, True)
        self._present_locking_keys = value
        return True

    def echo_previous_sentence(self, obj: Atspi.Accessible) -> bool:
        """Speaks the sentence prior to the caret if at a sentence boundary."""

        if not self.get_sentence_echo_enabled():
            return False

        offset = AXText.get_caret_offset(obj)
        char, start = AXText.get_character_at_offset(obj, offset - 1)[0:-1]
        previous_char, previous_start = AXText.get_character_at_offset(obj, start - 1)[0:-1]
        if not (char in string.whitespace + "\u00a0" and previous_char in "!.?:;"):
            return False

        sentence = AXText.get_sentence_at_offset(obj, previous_start)[0]
        if not sentence:
            msg = "TYPING ECHO PRESENTER: At a sentence boundary, but no sentence found."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return False

        presentation_manager.get_manager().speak_accessible_text(obj, sentence)
        return True

    def echo_previous_word(self, obj: Atspi.Accessible) -> bool:
        """Speaks the word prior to the caret if at a word boundary."""

        if not self.get_word_echo_enabled():
            return False

        offset = AXText.get_caret_offset(obj)
        if offset == -1:
            offset = AXText.get_character_count(obj)

        if offset <= 0:
            return False

        # If the previous character is not a word delimiter, there's nothing to echo.
        prev_char, prev_start = AXText.get_character_at_offset(obj, offset - 1)[0:-1]
        if prev_char not in string.punctuation + string.whitespace + "\u00a0":
            return False

        # Two back-to-back delimiters should not result in a re-echo.
        prev_char, prev_start = AXText.get_character_at_offset(obj, prev_start - 1)[0:-1]
        if prev_char in string.punctuation + string.whitespace + "\u00a0":
            return False

        word = AXText.get_word_at_offset(obj, prev_start)[0]
        if not word:
            return False

        presentation_manager.get_manager().speak_accessible_text(obj, word)
        return True

    def _should_echo_orca_modifier(self, event: input_event.KeyboardEvent) -> bool:
        """Returns whether an Orca modifier event should be echoed."""

        click_count = event.get_click_count()
        if click_count == 2:
            msg = "TYPING ECHO PRESENTER: Echoing Orca modifier double-click."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return True

        result = (
            click_count == 1 and self.get_key_echo_enabled() and self.get_modifier_keys_enabled()
        )
        msg = f"TYPING ECHO PRESENTER: Echoing modifier Orca modifier event: {result}."
        debug.print_message(debug.LEVEL_INFO, msg, True)
        return result

    def _get_echo_for_key_type(self, event: input_event.KeyboardEvent) -> tuple[bool, str] | None:
        """Returns (result, key_type) for the event's key type, or None if unrecognized."""

        checks = [
            (event.is_navigation_key, self.get_navigation_keys_enabled, "navigation"),
            (event.is_action_key, self.get_action_keys_enabled, "action"),
            (event.is_modifier_key, self.get_modifier_keys_enabled, "modifier"),
            (event.is_function_key, self.get_function_keys_enabled, "function"),
        ]
        for type_check, enabled_check, label in checks:
            if type_check():
                return enabled_check(), label

        if AXUtilities.is_password_text(event.get_object()) and event.should_obscure():
            return False, "password text"

        character_checks = [
            (event.is_diacritical_key, self.get_diacritical_keys_enabled, "diacritical"),
            (event.is_alphabetic_key, self.get_alphabetic_keys_enabled, "alphabetic"),
            (event.is_numeric_key, self.get_numeric_keys_enabled, "numeric"),
            (event.is_punctuation_key, self.get_punctuation_keys_enabled, "punctuation"),
        ]
        for type_check, enabled_check, label in character_checks:
            if type_check():
                return enabled_check(), label

        if event.is_space():
            return self.get_space_enabled() or self.get_character_echo_enabled(), "space"

        return None

    def should_echo_keyboard_event(self, event: input_event.KeyboardEvent) -> bool:
        """Returns whether the given keyboard event should be echoed."""

        should_obscure = event.should_obscure()
        name = event.get_key_name() if not should_obscure else "(obscured)"
        msg = f"TYPING ECHO PRESENTER: should_echo_keyboard_event: '{name}'?"
        debug.print_message(debug.LEVEL_INFO, msg, True)

        if not event.is_pressed_key():
            msg = "TYPING ECHO PRESENTER: Not echoing keyboard event: key is not pressed."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return False

        if event.is_orca_modifier():
            return self._should_echo_orca_modifier(event)

        # Treat all command modifiers the same and suppress echo.
        if event.is_alt_control_or_orca_modified() or self.is_character_echoable(event):
            reason = "modifier" if event.is_alt_control_or_orca_modified() else "character echoable"
            msg = f"TYPING ECHO PRESENTER: Not echoing keyboard event: {reason}."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return False

        if event.is_locking_key():
            result = self.get_locking_keys_presented()
            msg = f"TYPING ECHO PRESENTER: Echoing locking keyboard event: {result}."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return result

        if not self.get_key_echo_enabled():
            msg = "TYPING ECHO PRESENTER: Not echoing keyboard event: key echo is not enabled."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return False

        key_type_result = self._get_echo_for_key_type(event)
        result, label = key_type_result if key_type_result is not None else (False, "unknown")
        msg = f"TYPING ECHO PRESENTER: Echoing {label} keyboard event: {result}."
        debug.print_message(debug.LEVEL_INFO, msg, True)
        return result

    def is_character_echoable(self, event: input_event.KeyboardEvent) -> bool:
        """Returns True if the script will echo this event as part of character echo."""

        if not self.get_character_echo_enabled():
            return False

        if event.is_alt_control_or_orca_modified():
            msg = "TYPING ECHO PRESENTER: Not character echoable due to modifier."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return False

        if not event.is_printable_key():
            msg = "TYPING ECHO PRESENTER: Not character echoable, is not printable key."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return False

        obj = event.get_object()
        if AXUtilities.is_password_text(obj):
            msg = "TYPING ECHO PRESENTER: Not character echoable, is password text."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return False

        if AXUtilities.is_editable(obj) or AXUtilities.is_terminal(obj):
            msg = "TYPING ECHO PRESENTER: Character echoable, is editable or terminal."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return True

        msg = "TYPING ECHO PRESENTER: Not character echoable, no reason to echo."
        debug.print_message(debug.LEVEL_INFO, msg, True)
        return False

    def echo_delayed_terminal_press(self, _script: default.Script, event: Atspi.Event) -> None:
        """Echoes a previously delayed terminal key press if it matches the inserted text."""

        if self._delayed_terminal_press is None:
            msg = "TYPING ECHO PRESENTER: No rejected terminal press to echo."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return

        if self._delayed_terminal_press.get_object() != event.source:
            msg = "TYPING ECHO PRESENTER: Delayed terminal press does not match event source."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return

        character = self._delayed_terminal_press.get_key_name().lower()
        if event.any_data.lower() == character:
            msg = "TYPING ECHO PRESENTER: Echoing delayed terminal press."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            presentation_manager.get_manager().present_key_event(self._delayed_terminal_press)
            self._delayed_terminal_press = None

    def echo_keyboard_event(self, event: input_event.KeyboardEvent) -> None:
        """Presents the KeyboardEvent event."""

        if not event.is_pressed_key():
            presentation_manager.get_manager().clear_command_announcement()
            return

        self._delayed_terminal_press = None
        if not self.should_echo_keyboard_event(event):
            return

        obj = event.get_object()
        if AXUtilities.is_terminal(obj) and event.is_printable_key():
            # We have no reliable way of knowing a password is being entered into a terminal --
            # other than the fact that the text typed isn't there. Before we waited for the
            # release event and echoed that. But that is laggy. So delay presentation until we
            # see the text appear. If it doesn't appear, we never echo it.
            msg = "TYPING ECHO PRESENTER: Delaying terminal key press echo."
            debug.print_message(debug.LEVEL_INFO, msg, True)
            self._delayed_terminal_press = event
            return

        if locking_state_string := event.get_locking_state_string():
            keyname = event.get_key_name()
            msg = f"{keyname} {locking_state_string}"
            presentation_manager.get_manager().present_braille_message(msg)

        presentation_manager.get_manager().present_key_event(event)


_presenter: TypingEchoPresenter = TypingEchoPresenter()


def get_presenter() -> TypingEchoPresenter:
    """Returns the Typing Echo Presenter"""

    return _presenter
