From 5abf57447f46afc97e2535d698d4ae7978b59580 Mon Sep 17 00:00:00 2001 From: Hunter Jozwiak Date: Sat, 11 Apr 2026 02:49:04 -0400 Subject: [PATCH] Port Orca 50 presentation and preferences layer --- src/cthulhu/action_presenter.py | 452 +- src/cthulhu/ax_utilities.py | 75 + src/cthulhu/braille_generator.py | 21 + src/cthulhu/braille_presenter.py | 2008 ++++++ src/cthulhu/caret_navigator.py | 1016 +++ src/cthulhu/chat_presenter.py | 947 +++ src/cthulhu/cthulhu_gui_prefs.py | 6295 +++-------------- src/cthulhu/document_presenter.py | 1452 ++++ src/cthulhu/flat_review_presenter.py | 2078 +++--- src/cthulhu/gsettings_registry.py | 166 +- src/cthulhu/guilabels.py | 1687 ++++- src/cthulhu/live_region_presenter.py | 729 ++ src/cthulhu/meson.build | 18 + src/cthulhu/notification_presenter.py | 847 +-- src/cthulhu/object_navigator.py | 447 +- src/cthulhu/plugins/OCR/plugin.py | 177 + src/cthulhu/presentation_manager.py | 450 ++ src/cthulhu/profile_manager.py | 882 +++ .../pronunciation_dictionary_manager.py | 579 ++ src/cthulhu/say_all_presenter.py | 993 +++ src/cthulhu/sound.py | 89 +- src/cthulhu/sound_generator.py | 40 +- src/cthulhu/sound_presenter.py | 490 ++ src/cthulhu/speech.py | 39 + src/cthulhu/speech_generator.py | 34 + src/cthulhu/speech_manager.py | 2882 ++++++++ src/cthulhu/speech_monitor.py | 259 + src/cthulhu/speech_presenter.py | 2788 ++++++++ src/cthulhu/speechserver.py | 115 +- src/cthulhu/spellcheck_presenter.py | 967 +++ src/cthulhu/structural_navigator.py | 3856 ++++++++++ src/cthulhu/system_information_presenter.py | 466 ++ src/cthulhu/table_navigator.py | 984 +++ src/cthulhu/text_attribute_manager.py | 516 ++ src/cthulhu/typing_echo_presenter.py | 1004 ++- src/cthulhu/where_am_i_presenter.py | 847 ++- tests/cthulhu_test_context.py | 21 +- tests/test_braille_presenter.py | 1224 ++++ tests/test_caret_navigator.py | 847 +++ tests/test_document_presenter.py | 2331 ++++++ tests/test_live_region_presenter.py | 1020 +++ tests/test_ocr_preferences_grid.py | 17 + tests/test_preferences_grid_base.py | 982 +++ tests/test_presentation_manager.py | 580 ++ tests/test_profile_manager.py | 501 ++ .../test_pronunciation_dictionary_manager.py | 278 + tests/test_say_all_presenter.py | 909 +++ tests/test_sound_presenter.py | 235 + tests/test_speech_presenter.py | 1095 +++ tests/test_spellcheck_presenter.py | 239 + tests/test_structural_navigator.py | 1616 +++++ tests/test_table_navigator.py | 2241 ++++++ tests/test_text_attribute_manager.py | 177 + tests/test_typing_echo_presenter.py | 936 +++ 54 files changed, 43818 insertions(+), 8126 deletions(-) create mode 100644 src/cthulhu/braille_presenter.py create mode 100644 src/cthulhu/caret_navigator.py create mode 100644 src/cthulhu/chat_presenter.py create mode 100644 src/cthulhu/document_presenter.py create mode 100644 src/cthulhu/live_region_presenter.py create mode 100644 src/cthulhu/presentation_manager.py create mode 100644 src/cthulhu/profile_manager.py create mode 100644 src/cthulhu/pronunciation_dictionary_manager.py create mode 100644 src/cthulhu/say_all_presenter.py create mode 100644 src/cthulhu/sound_presenter.py create mode 100644 src/cthulhu/speech_manager.py create mode 100644 src/cthulhu/speech_monitor.py create mode 100644 src/cthulhu/speech_presenter.py create mode 100644 src/cthulhu/spellcheck_presenter.py create mode 100644 src/cthulhu/structural_navigator.py create mode 100644 src/cthulhu/system_information_presenter.py create mode 100644 src/cthulhu/table_navigator.py create mode 100644 src/cthulhu/text_attribute_manager.py create mode 100644 tests/test_braille_presenter.py create mode 100644 tests/test_caret_navigator.py create mode 100644 tests/test_document_presenter.py create mode 100644 tests/test_live_region_presenter.py create mode 100644 tests/test_ocr_preferences_grid.py create mode 100644 tests/test_preferences_grid_base.py create mode 100644 tests/test_presentation_manager.py create mode 100644 tests/test_profile_manager.py create mode 100644 tests/test_pronunciation_dictionary_manager.py create mode 100644 tests/test_say_all_presenter.py create mode 100644 tests/test_sound_presenter.py create mode 100644 tests/test_speech_presenter.py create mode 100644 tests/test_spellcheck_presenter.py create mode 100644 tests/test_structural_navigator.py create mode 100644 tests/test_table_navigator.py create mode 100644 tests/test_text_attribute_manager.py create mode 100644 tests/test_typing_echo_presenter.py diff --git a/src/cthulhu/action_presenter.py b/src/cthulhu/action_presenter.py index 092e385..36726e7 100644 --- a/src/cthulhu/action_presenter.py +++ b/src/cthulhu/action_presenter.py @@ -1,9 +1,7 @@ -#!/usr/bin/env python3 +# Cthulhu # -# Copyright (c) 2024 Stormux -# Copyright (c) 2010-2012 The Orca Team -# Copyright (c) 2012 Igalia, S.L. -# Copyright (c) 2005-2010 Sun Microsystems Inc. +# Copyright 2023 Igalia, S.L. +# Author: Joanmarie Diggs # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -19,22 +17,13 @@ # 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 """Module for performing accessible actions via a list""" from __future__ import annotations -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2023 Igalia, S.L." -__license__ = "LGPL" - import time -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING, Any import gi @@ -42,183 +31,79 @@ gi.require_version("Gdk", "3.0") gi.require_version("Gtk", "3.0") from gi.repository import Gdk, GLib, Gtk -from . import cmdnames -from . import dbus_service -from . import debug -from . import focus_manager -from . import guilabels -from . import input_event -from . import keybindings -from . import messages -from . import cthulhu_state -from . import script_manager +from . import ( + cmdnames, + command_manager, + dbus_service, + debug, + focus_manager, + guilabels, + input_event, + keybindings, + messages, + presentation_manager, + script_manager, +) +from .ax_action import AXAction from .ax_object import AXObject from .ax_utilities import AXUtilities if TYPE_CHECKING: - from . import script + from collections.abc import Callable + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi -class ActionList(Gtk.Window): - """Window containing a list of accessible actions.""" - - def __init__(self, presenter: ActionPresenter): - super().__init__(window_position=Gtk.WindowPosition.MOUSE, transient_for=None) - self._presenter = presenter - self._setup_gui() - - def _setup_gui(self) -> None: - """Sets up the GUI for the actions list.""" - - self.set_title(guilabels.KB_GROUP_ACTIONS) - self.set_decorated(False) - - self._listbox = Gtk.ListBox() - self._listbox.set_selection_mode(Gtk.SelectionMode.SINGLE) - self._listbox.connect("row-activated", self._on_row_activated) - self._listbox.set_margin_top(5) - self._listbox.set_margin_bottom(5) - self.add(self._listbox) - - self.connect("key-press-event", self._on_key_press) - self.connect("destroy", self._on_destroy) - - def _on_key_press(self, widget, event) -> bool: - """Handles key press events.""" - - if event.keyval == Gdk.KEY_Escape: - self.destroy() - return True - return False - - def _on_row_activated(self, listbox, row) -> None: - """Handles row activation (Enter or double-click).""" - - action_name = getattr(row, "_action_name", None) - if action_name: - self._presenter._perform_action(action_name) - - def _on_destroy(self, widget) -> None: - """Handles window destruction.""" - - GLib.idle_add(self._presenter._clear_gui_and_restore_focus) - - def populate_actions(self, actions: Union[dict[str, str], list[str]]) -> None: - """Populates the list with accessible actions.""" - - if isinstance(actions, dict): - items = list(actions.items()) - else: - items = [(action, action) for action in actions] - - # Clear existing items - for child in self._listbox.get_children(): - self._listbox.remove(child) - - # Add actions to list - for action_name, label_text in items: - row = Gtk.ListBoxRow() - label = Gtk.Label(label=label_text, xalign=0) - label.set_margin_start(10) - label.set_margin_end(10) - row.add(label) - setattr(row, "_action_name", action_name) - self._listbox.add(row) - - # Select first item - if actions: - first_row = self._listbox.get_row_at_index(0) - if first_row: - self._listbox.select_row(first_row) - first_row.grab_focus() - - def show_gui(self) -> None: - """Shows the window.""" - - self.show_all() - self.present_with_time(time.time()) - self._listbox.grab_focus() + from .scripts import default class ActionPresenter: """Provides a list for performing accessible actions on an object.""" - def __init__(self): - self._handlers = None - self._bindings = None - self._gui = None - self._obj = None - self._window = None - - # Initialize handlers - self._handlers = self.get_handlers(True) - # _bindings will be initialized lazily in get_bindings() + def __init__(self) -> None: + self._gui: ActionList | None = None + self._obj: Atspi.Accessible | None = None + self._window: Atspi.Accessible | None = None + self._initialized: bool = False msg = "ACTION PRESENTER: Registering D-Bus commands." - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) controller = dbus_service.get_remote_controller() controller.register_decorated_module("ActionPresenter", self) - def get_handlers(self, refresh: bool = False) -> dict: - """Returns a dictionary of input event handlers.""" + def set_up_commands(self) -> None: + """Sets up commands with CommandManager.""" - if self._handlers is None or refresh: - msg = "ACTION PRESENTER: Getting input event handlers." - debug.printMessage(debug.LEVEL_INFO, msg, True) - self._setup_handlers() + if self._initialized: + return + self._initialized = True - return self._handlers + manager = command_manager.get_manager() + group_label = guilabels.KB_GROUP_ACTIONS + kb = keybindings.KeyBinding("a", keybindings.CTHULHU_SHIFT_MODIFIER_MASK) - def get_bindings(self, refresh: bool = False, is_desktop: bool = True) -> keybindings.KeyBindings: - """Returns keybindings for this presenter.""" - - if self._bindings is None or refresh: - msg = "ACTION PRESENTER: Getting key bindings." - debug.printMessage(debug.LEVEL_INFO, msg, True) - self._setup_bindings() - - return self._bindings - - def _setup_handlers(self) -> None: - """Sets up input event handlers for this presenter.""" - - msg = "ACTION PRESENTER: Setting up handlers." - debug.printMessage(debug.LEVEL_INFO, msg, True) - - self._handlers = {} - - self._handlers["show_actions_list"] = \ - input_event.InputEventHandler( + manager.add_command( + command_manager.KeyboardCommand( + "show_actions_list", self.show_actions_list, - cmdnames.SHOW_ACTIONS_LIST) + group_label, + cmdnames.SHOW_ACTIONS_LIST, + desktop_keybinding=kb, + laptop_keybinding=kb, + ), + ) - msg = "ACTION PRESENTER: Handlers set up." - debug.printMessage(debug.LEVEL_INFO, msg, True) - - def _setup_bindings(self) -> None: - """Sets up key bindings for this presenter.""" - - msg = "ACTION PRESENTER: Setting up bindings." - debug.printMessage(debug.LEVEL_INFO, msg, True) - - self._bindings = keybindings.KeyBindings() - self._bindings.add(keybindings.KeyBinding( - "a", - keybindings.defaultModifierMask, - keybindings.CTHULHU_SHIFT_MODIFIER_MASK, - self._handlers["show_actions_list"])) - - msg = "ACTION PRESENTER: Bindings set up." - debug.printMessage(debug.LEVEL_INFO, msg, True) + msg = "ACTION PRESENTER: Commands set up." + debug.print_message(debug.LEVEL_INFO, msg, True) def _restore_focus(self) -> None: """Restores focus to the object associated with the actions list.""" tokens = ["ACTION PRESENTER: Restoring focus to", self._obj, "in", self._window] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) reason = "Action Presenter list is being destroyed" - app = AXObject.get_application(self._obj) + app = AXUtilities.get_application(self._obj) script = script_manager.get_manager().get_script(app, self._obj) script_manager.get_manager().set_active_script(script, reason) @@ -227,158 +112,149 @@ class ActionPresenter: manager.set_active_window(self._window) manager.set_locus_of_focus(None, self._obj) - def _present_message(self, script, full_message, brief_message=None, notify_user=True) -> None: - """Presents a message using the provided script or the active script.""" - - if not notify_user: - return - - if script is not None: - script.presentMessage(full_message, brief_message) - return - - active_script = cthulhu_state.activeScript - if active_script is not None: - active_script.presentMessage(full_message, brief_message) - return - - msg = "ACTION PRESENTER: Unable to present message (no script)." - debug.printMessage(debug.LEVEL_INFO, msg, True) - def _clear_gui_and_restore_focus(self) -> None: """Clears the GUI reference and then restores focus.""" self._gui = None - GLib.timeout_add(150, self._maybe_restore_focus) - - def _maybe_restore_focus(self) -> bool: - """Restores focus unless it already moved to another object in the target app.""" - - if not AXObject.is_valid(self._obj): - return False - - manager = focus_manager.get_manager() - current_focus = manager.get_locus_of_focus() - if current_focus and AXObject.is_valid(current_focus): - target_app = AXObject.get_application(self._obj) - focus_app = AXObject.get_application(current_focus) - if target_app and focus_app == target_app and current_focus != self._obj: - tokens = ["ACTION PRESENTER: Skipping focus restore; focus now on", current_focus] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return False - self._restore_focus() - return False def _perform_action(self, action: str) -> None: """Attempts to perform the named action.""" if self._gui is None: msg = "ACTION PRESENTER: _perform_action called when self._gui is None." - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return self._gui.hide() - result = AXObject.do_named_action(self._obj, action) + result = AXUtilities.do_named_action(self._obj, action) tokens = ["ACTION PRESENTER: Performing", action, "on", self._obj, "succeeded:", result] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - - # Use idle_add for asynchronous destruction to allow action to complete + debug.print_tokens(debug.LEVEL_INFO, tokens, True) GLib.idle_add(self._gui.destroy) - def present_with_time(self, obj, start_time: float, script=None, notify_user=True) -> bool: - """Presents accessible actions for the given object with timing.""" - - try: - debug.printMessage(debug.LEVEL_INFO, f"ACTION PRESENTER: present_with_time called with obj: {obj}", True) - - if self._gui is not None: - msg = "ACTION PRESENTER: GUI already exists." - debug.printMessage(debug.LEVEL_INFO, msg, True) - return False - - if obj is None: - msg = "ACTION PRESENTER: No object found, presenting LOCATION_NOT_FOUND" - debug.printMessage(debug.LEVEL_INFO, msg, True) - full_message = messages.LOCATION_NOT_FOUND_FULL - brief_message = messages.LOCATION_NOT_FOUND_BRIEF - self._present_message(script, full_message, brief_message, notify_user) - return True - - debug.printMessage(debug.LEVEL_INFO, f"ACTION PRESENTER: Getting actions for object: {obj}", True) - actions = {} - for i in range(AXObject.get_n_actions(obj)): - name = AXObject.get_action_name(obj, i) - if not name: - continue - localized_name = AXObject.get_action_localized_name(obj, i) - description = AXObject.get_action_description(obj, i) - actions[name] = localized_name or description or name - - debug.printMessage(debug.LEVEL_INFO, f"ACTION PRESENTER: Found {len(actions)} actions: {actions}", True) - - if not actions: - msg = "ACTION PRESENTER: No actions found, presenting NO_ACTIONS_FOUND_ON" - debug.printMessage(debug.LEVEL_INFO, msg, True) - name = AXObject.get_name(obj) or AXUtilities.get_localized_role_name(obj) - full_message = messages.NO_ACTIONS_FOUND_ON % name - self._present_message(script, full_message, notify_user=notify_user) - return True - - debug.printMessage(debug.LEVEL_INFO, "ACTION PRESENTER: Creating GUI", True) - self._obj = obj - self._window = focus_manager.get_manager().get_active_window() - - self._gui = ActionList(self) - self._gui.populate_actions(actions) - self._gui.show_gui() - - debug.printMessage(debug.LEVEL_INFO, "ACTION PRESENTER: GUI created successfully", True) - return True - except Exception as e: - debug.printMessage(debug.LEVEL_INFO, f"ACTION PRESENTER: ERROR in present_with_time: {e}", True) - import traceback - debug.printMessage(debug.LEVEL_INFO, f"ACTION PRESENTER: Traceback: {traceback.format_exc()}", True) - return False - @dbus_service.command def show_actions_list( self, - script: script.Script = None, - input_event: input_event.InputEvent = None, - notify_user: bool = True + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, ) -> bool: - """Shows the accessible actions list.""" + """Shows a list of all the accessible actions exposed by the focused object.""" - try: - msg = f"ACTION PRESENTER: show_actions_list called! input_event: {input_event}" - debug.printMessage(debug.LEVEL_INFO, msg, True) + tokens = [ + "ACTION PRESENTER: show_actions_list. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) - start_time = time.time() + manager = focus_manager.get_manager() + obj = manager.get_active_mode_and_object_of_interest()[1] or manager.get_locus_of_focus() + if obj is None: + full = messages.LOCATION_NOT_FOUND_FULL + brief = messages.LOCATION_NOT_FOUND_BRIEF + presentation_manager.get_manager().present_message(full, brief) + return True - obj = None - if input_event is not None: - obj = input_event.get_object() - debug.printMessage(debug.LEVEL_INFO, f"ACTION PRESENTER: Object from input_event: {obj}", True) + actions = {} + for i in range(AXAction.get_n_actions(obj)): + name = AXAction.get_action_name(obj, i) + localized_name = AXAction.get_action_localized_name(obj, i) + description = AXAction.get_action_description(obj, i) + tokens = [ + f"ACTION PRESENTER: Action {i} on", + obj, + f": '{name}' localized name: '{localized_name}' ", + f"localized description: '{description}'", + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + actions[name] = localized_name or description or name - if obj is None: - manager = focus_manager.get_manager() - _mode, obj = manager.get_active_mode_and_object_of_interest() - if obj is None: - obj = manager.get_locus_of_focus() + if not actions.items(): + name = AXObject.get_name(obj) or AXUtilities.get_localized_role_name(obj) + presentation_manager.get_manager().present_message(messages.NO_ACTIONS_FOUND_ON % name) + return True - result = self.present_with_time(obj, start_time, script, notify_user) - debug.printMessage(debug.LEVEL_INFO, f"ACTION PRESENTER: show_actions_list returning: {result}", True) - return result - except Exception as e: - debug.printMessage(debug.LEVEL_INFO, f"ACTION PRESENTER: ERROR in show_actions_list: {e}", True) - import traceback - debug.printMessage(debug.LEVEL_INFO, f"ACTION PRESENTER: Traceback: {traceback.format_exc()}", True) - return False + self._obj = obj + self._window = manager.get_active_window() + self._gui = ActionList(actions, self._perform_action, self._clear_gui_and_restore_focus) + self._gui.show_gui() + return True + + +class ActionList(Gtk.Window): + """A Gtk.Window containing a Gtk.ListBox of accessible actions.""" + + def __init__( + self, + actions: dict[str, str], + action_handler: Callable[[str], None], + cleanup_handler: Callable[[], None], + ) -> None: + super().__init__(window_position=Gtk.WindowPosition.MOUSE, transient_for=None) + self.set_title(guilabels.ACTIONS_LIST) + self.set_decorated(False) + + self.connect("destroy", self._on_hidden) + self.on_option_selected = action_handler + self.on_list_hidden = cleanup_handler + + self._list_box = Gtk.ListBox() + self._list_box.set_selection_mode(Gtk.SelectionMode.SINGLE) + self._list_box.connect("row-activated", self._on_row_activated) + self._list_box.set_margin_top(5) + self._list_box.set_margin_bottom(5) + + for name, description in actions.items(): + row = Gtk.ListBoxRow() + label = Gtk.Label(label=description, xalign=0) + label.set_margin_start(10) + label.set_margin_end(10) + row.add(label) # pylint: disable=no-member + row._action_name = name + self._list_box.add(row) # pylint: disable=no-member + + self.add(self._list_box) # pylint: disable=no-member + + self.connect("key-press-event", self._on_key_press) + + def _on_key_press(self, _widget: Gtk.Widget, event: Gdk.EventKey) -> bool: + """Handles key presses for the window, e.g. Escape to close.""" + + if event.keyval == Gdk.KEY_Escape: + self.destroy() + return True + return False + + def _on_row_activated(self, _list_box: Gtk.ListBox, row: Gtk.ListBoxRow) -> None: + """Handler for the 'row-activated' signal of the Gtk.ListBox""" + + action_name = getattr(row, "_action_name", None) + if action_name: + self.on_option_selected(action_name) + + def _on_hidden(self, *_args: tuple[Any, ...]) -> None: + """Handler for the 'destroy' window signal""" + + msg = "ACTION PRESENTER: ActionList destroyed" + debug.print_message(debug.LEVEL_INFO, msg, True) + self.on_list_hidden() + + def show_gui(self) -> None: + """Shows the window""" + + self.show_all() # pylint: disable=no-member + self.present_with_time(time.time()) + self._list_box.grab_focus() _presenter = ActionPresenter() -def getPresenter() -> ActionPresenter: - """Returns the Action Presenter singleton.""" + +def get_presenter() -> ActionPresenter: + """Returns the action presenter.""" + return _presenter diff --git a/src/cthulhu/ax_utilities.py b/src/cthulhu/ax_utilities.py index 77498e4..52dde8f 100644 --- a/src/cthulhu/ax_utilities.py +++ b/src/cthulhu/ax_utilities.py @@ -51,12 +51,18 @@ from .ax_object import AXObject from .ax_selection import AXSelection from .ax_table import AXTable from .ax_text import AXText +from .ax_utilities_action import AXUtilitiesAction from .ax_utilities_application import AXUtilitiesApplication from .ax_utilities_collection import AXUtilitiesCollection +from .ax_utilities_component import AXUtilitiesComponent from .ax_utilities_event import AXUtilitiesEvent +from .ax_utilities_object import AXUtilitiesObject from .ax_utilities_relation import AXUtilitiesRelation from .ax_utilities_role import AXUtilitiesRole +from .ax_utilities_selection import AXUtilitiesSelection from .ax_utilities_state import AXUtilitiesState +from .ax_utilities_table import AXUtilitiesTable +from .ax_utilities_text import AXUtilitiesText class AXUtilities: @@ -105,6 +111,7 @@ class AXUtilities: AXObject.clear_cache_now(reason) AXUtilitiesRelation.clear_cache_now(reason) AXUtilitiesEvent.clear_cache_now(reason) + AXUtilitiesSelection.clear_cache_now(reason) if AXUtilitiesRole.is_table_related(obj): AXTable.clear_cache_now(reason) @@ -312,6 +319,56 @@ class AXUtilities: return AXObject.find_descendant(obj, AXUtilitiesRole.is_status_bar) + @staticmethod + def get_menu(obj: Atspi.Accessible) -> Optional[Atspi.Accessible]: + """Returns the menu descendant of obj""" + + result = None + if AXObject.supports_collection(obj): + result = AXUtilitiesCollection.find_first_with_role(obj, [Atspi.Role.MENU]) + if not AXUtilities.COMPARE_COLLECTION_PERFORMANCE: + return result + + return AXUtilitiesObject.find_descendant(obj, AXUtilitiesRole.is_menu) + + @staticmethod + def get_table_cell(obj: Atspi.Accessible) -> Optional[Atspi.Accessible]: + """Returns the table cell descendant of obj""" + + result = None + if AXObject.supports_collection(obj): + result = AXUtilitiesCollection.find_first_with_role(obj, [Atspi.Role.TABLE_CELL]) + if not AXUtilities.COMPARE_COLLECTION_PERFORMANCE: + return result + + return AXUtilitiesObject.find_descendant(obj, AXUtilitiesRole.is_table_cell) + + @staticmethod + def get_table_cell_or_header(obj: Atspi.Accessible) -> Optional[Atspi.Accessible]: + """Returns the table cell or header descendant of obj""" + + result = None + if AXObject.supports_collection(obj): + result = AXUtilitiesCollection.find_first_with_role( + obj, AXUtilitiesRole.get_table_cell_roles() + ) + if not AXUtilities.COMPARE_COLLECTION_PERFORMANCE: + return result + + return AXUtilitiesObject.find_descendant(obj, AXUtilitiesRole.is_table_cell_or_header) + + @staticmethod + def get_descendant_supporting_text(obj: Atspi.Accessible) -> Optional[Atspi.Accessible]: + """Returns the first descendant of obj implementing the text interface""" + + result = None + if AXObject.supports_collection(obj): + result = AXUtilitiesCollection.find_first_with_interfaces(obj, ["Text"]) + if not AXUtilities.COMPARE_COLLECTION_PERFORMANCE: + return result + + return AXUtilitiesObject.find_descendant(obj, AXObject.supports_text) + @staticmethod def _is_layout_only(obj: Atspi.Accessible) -> tuple[bool, str]: """Returns True and a string reason if obj is believed to serve only for layout.""" @@ -977,6 +1034,9 @@ class AXUtilities: # Dynamically expose helper methods for compatibility with older callers. # Keep side effects explicit in the underlying helpers (e.g., clear_cache flags). +for method_name, method in inspect.getmembers(AXUtilitiesAction, predicate=inspect.isfunction): + setattr(AXUtilities, method_name, method) + for method_name, method in inspect.getmembers(AXUtilitiesApplication, predicate=inspect.isfunction): setattr(AXUtilities, method_name, method) @@ -992,8 +1052,23 @@ for method_name, method in inspect.getmembers(AXUtilitiesRole, predicate=inspect for method_name, method in inspect.getmembers(AXUtilitiesState, predicate=inspect.isfunction): setattr(AXUtilities, method_name, method) +for method_name, method in inspect.getmembers(AXUtilitiesSelection, predicate=inspect.isfunction): + setattr(AXUtilities, method_name, method) + for method_name, method in inspect.getmembers(AXUtilitiesCollection, predicate=inspect.isfunction): if method_name.startswith("find"): setattr(AXUtilities, method_name, method) +for method_name, method in inspect.getmembers(AXUtilitiesObject, predicate=inspect.isfunction): + setattr(AXUtilities, method_name, method) + +for method_name, method in inspect.getmembers(AXUtilitiesComponent, predicate=inspect.isfunction): + setattr(AXUtilities, method_name, method) + +for method_name, method in inspect.getmembers(AXUtilitiesTable, predicate=inspect.isfunction): + setattr(AXUtilities, method_name, method) + +for method_name, method in inspect.getmembers(AXUtilitiesText, predicate=inspect.isfunction): + setattr(AXUtilities, method_name, method) + AXUtilities.start_cache_clearing_thread() diff --git a/src/cthulhu/braille_generator.py b/src/cthulhu/braille_generator.py index 75a2aaf..f59a806 100644 --- a/src/cthulhu/braille_generator.py +++ b/src/cthulhu/braille_generator.py @@ -31,6 +31,9 @@ __date__ = "$Date$" __copyright__ = "Copyright (c) 2005-2009 Sun Microsystems Inc." __license__ = "LGPL" +from dataclasses import dataclass +from typing import Any + import gi gi.require_version("Atspi", "2.0") from gi.repository import Atspi @@ -53,6 +56,24 @@ from .braille_rolenames import shortRoleNames _settingsManager = None # Removed - use cthulhu.cthulhuApp.settingsManager + +@dataclass(frozen=True) +class BrailleGeneratorContext: + """Settings context for Orca 50 braille generators.""" + + enabled: bool + verbose: bool + focus: Any + in_say_all: bool + in_focus_mode: bool + active_mode: str | None + where_am_i_type: Any + full_rolenames: bool + display_ancestors: bool + end_of_line_indicator: bool + present_mnemonics: bool + + class Space: """A dummy class to indicate we want to insert a space into an utterance, but only if there is text prior to the space.""" diff --git a/src/cthulhu/braille_presenter.py b/src/cthulhu/braille_presenter.py new file mode 100644 index 0000000..a841caa --- /dev/null +++ b/src/cthulhu/braille_presenter.py @@ -0,0 +1,2008 @@ +# Cthulhu +# +# Copyright 2005-2008 Sun Microsystems Inc. +# Copyright 2011-2026 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 braille presentation support.""" + +from __future__ import annotations + +import os +import time +from enum import Enum +from typing import TYPE_CHECKING, Any + +from . import ( + braille, + braille_monitor, + brltablenames, + cmdnames, + command_manager, + dbus_service, + debug, + document_presenter, + focus_manager, + gsettings_registry, + guilabels, + input_event, + input_event_manager, + messages, + preferences_grid_base, +) +from .braille_generator import BrailleGeneratorContext +from .cthulhu_platform import tablesdir # pylint: disable=import-error + +if TYPE_CHECKING: + import gi + + from .generator import WhereAmI + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + + from .scripts import default + + +@gsettings_registry.get_registry().gsettings_enum( + "org.stormux.Cthulhu.VerbosityLevel", + values={"brief": 0, "verbose": 1}, +) +class VerbosityLevel(Enum): + """Verbosity level enumeration.""" + + BRIEF = 0 + VERBOSE = 1 + + @property + def string_name(self) -> str: + """Returns the lowercase string name for this enum value.""" + + return self.name.lower() + + +@gsettings_registry.get_registry().gsettings_enum( + "org.stormux.Cthulhu.BrailleIndicator", + values={"none": 0, "dot7": 64, "dot8": 128, "dots78": 192}, +) +class BrailleIndicator(Enum): + """Braille indicator enumeration.""" + + NONE = 0x00 + DOT7 = 0x40 + DOT8 = 0x80 + DOTS78 = 0xC0 + + @property + def string_name(self) -> str: + """Returns the lowercase string name for this enum value.""" + + return self.name.lower() + + +@gsettings_registry.get_registry().gsettings_enum( + "org.stormux.Cthulhu.ProgressBarVerbosity", + values={"all": 0, "application": 1, "window": 2}, +) +class ProgressBarVerbosity(Enum): + """Progress bar verbosity level enumeration.""" + + ALL = 0 + APPLICATION = 1 + WINDOW = 2 + + +class BrailleVerbosityPreferencesGrid(preferences_grid_base.AutoPreferencesGrid): + """GtkGrid containing the Braille Verbosity preferences page.""" + + def __init__(self, presenter: BraillePresenter) -> None: + self._presenter = presenter + controls = [ + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.OBJECT_PRESENTATION_IS_DETAILED, + getter=presenter._get_verbosity_is_detailed, + setter=presenter._set_verbosity_is_detailed, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.BRAILLE_SHOW_CONTEXT, + getter=presenter.get_display_ancestors, + setter=presenter.set_display_ancestors, + prefs_key=BraillePresenter.KEY_DISPLAY_ANCESTORS, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.BRAILLE_ABBREVIATED_ROLE_NAMES, + getter=presenter._get_use_abbreviated_rolenames, + setter=presenter._set_use_abbreviated_rolenames, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.PRESENT_OBJECT_MNEMONICS, + getter=presenter.get_present_mnemonics, + setter=presenter.set_present_mnemonics, + prefs_key=BraillePresenter.KEY_PRESENT_MNEMONICS, + ), + ] + + super().__init__(guilabels.VERBOSITY, controls) + + def save_settings(self, profile: str = "", app_name: str = "") -> dict[str, Any]: + """Save settings, writing enum values for verbosity and rolename style.""" + + result = super().save_settings(profile, app_name) + result[BraillePresenter.KEY_VERBOSITY_LEVEL] = self._presenter.get_verbosity_level() + result[BraillePresenter.KEY_ROLENAME_STYLE] = VerbosityLevel[ + self._presenter.get_rolename_style().upper() + ].value + return result + + +class BrailleDisplaySettingsPreferencesGrid(preferences_grid_base.AutoPreferencesGrid): + """GtkGrid containing the Braille Display Settings preferences page.""" + + def __init__(self, presenter: BraillePresenter) -> None: + table_dict = presenter.get_contraction_tables_dict() + table_names = sorted(table_dict.keys()) if table_dict else [] + table_paths = [table_dict[name] for name in table_names] if table_dict else [] + + self._enable_contracted_control = preferences_grid_base.BooleanPreferenceControl( + label=guilabels.BRAILLE_ENABLE_CONTRACTED_BRAILLE, + getter=presenter.get_contracted_braille_is_enabled, + setter=presenter.set_contracted_braille_is_enabled, + prefs_key=BraillePresenter.KEY_CONTRACTED_BRAILLE, + ) + + controls: list[ + preferences_grid_base.BooleanPreferenceControl + | preferences_grid_base.EnumPreferenceControl + ] = [ + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.BRAILLE_ENABLE_END_OF_LINE_SYMBOL, + getter=presenter.get_end_of_line_indicator_is_enabled, + setter=presenter.set_end_of_line_indicator_is_enabled, + prefs_key=BraillePresenter.KEY_END_OF_LINE_INDICATOR, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.BRAILLE_ENABLE_WORD_WRAP, + getter=presenter.get_word_wrap_is_enabled, + setter=presenter.set_word_wrap_is_enabled, + prefs_key=BraillePresenter.KEY_WORD_WRAP, + ), + self._enable_contracted_control, + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.BRAILLE_COMPUTER_BRAILLE_AT_CURSOR, + getter=presenter.get_computer_braille_at_cursor_is_enabled, + setter=presenter.set_computer_braille_at_cursor_is_enabled, + prefs_key=BraillePresenter.KEY_COMPUTER_BRAILLE_AT_CURSOR, + determine_sensitivity=self._contracted_enabled, + ), + preferences_grid_base.EnumPreferenceControl( + label=guilabels.BRAILLE_CONTRACTION_TABLE, + options=table_names, + values=table_paths, + getter=presenter.get_contraction_table_path, + setter=presenter.set_contraction_table_from_path, + prefs_key=BraillePresenter.KEY_CONTRACTION_TABLE, + determine_sensitivity=self._contracted_enabled, + ), + preferences_grid_base.EnumPreferenceControl( + label=guilabels.BRAILLE_HYPERLINK_INDICATOR, + options=[ + guilabels.BRAILLE_DOT_NONE, + guilabels.BRAILLE_DOT_7, + guilabels.BRAILLE_DOT_8, + guilabels.BRAILLE_DOT_7_8, + ], + values=[ + BrailleIndicator.NONE.value, + BrailleIndicator.DOT7.value, + BrailleIndicator.DOT8.value, + BrailleIndicator.DOTS78.value, + ], + getter=presenter._get_link_indicator_as_int, + setter=presenter.set_link_indicator_from_int, + prefs_key=BraillePresenter.KEY_LINK_INDICATOR, + member_of=guilabels.BRAILLE_INDICATORS, + ), + preferences_grid_base.EnumPreferenceControl( + label=guilabels.BRAILLE_SELECTION_INDICATOR, + options=[ + guilabels.BRAILLE_DOT_NONE, + guilabels.BRAILLE_DOT_7, + guilabels.BRAILLE_DOT_8, + guilabels.BRAILLE_DOT_7_8, + ], + values=[ + BrailleIndicator.NONE.value, + BrailleIndicator.DOT7.value, + BrailleIndicator.DOT8.value, + BrailleIndicator.DOTS78.value, + ], + getter=presenter._get_selector_indicator_as_int, + setter=presenter.set_selector_indicator_from_int, + prefs_key=BraillePresenter.KEY_SELECTOR_INDICATOR, + member_of=guilabels.BRAILLE_INDICATORS, + ), + preferences_grid_base.EnumPreferenceControl( + label=guilabels.BRAILLE_TEXT_ATTRIBUTES_INDICATOR, + options=[ + guilabels.BRAILLE_DOT_NONE, + guilabels.BRAILLE_DOT_7, + guilabels.BRAILLE_DOT_8, + guilabels.BRAILLE_DOT_7_8, + ], + values=[ + BrailleIndicator.NONE.value, + BrailleIndicator.DOT7.value, + BrailleIndicator.DOT8.value, + BrailleIndicator.DOTS78.value, + ], + getter=presenter._get_text_attributes_indicator_as_int, + setter=presenter.set_text_attributes_indicator_from_int, + prefs_key=BraillePresenter.KEY_TEXT_ATTRIBUTES_INDICATOR, + member_of=guilabels.BRAILLE_INDICATORS, + ), + ] + + super().__init__(guilabels.BRAILLE_DISPLAY_SETTINGS, controls) + + def _contracted_enabled(self) -> bool: + """Check if contracted braille is enabled in the UI.""" + + widget = self.get_widget_for_control(self._enable_contracted_control) + return widget.get_active() if widget else True + + +class BrailleFlashMessagesPreferencesGrid(preferences_grid_base.AutoPreferencesGrid): + """GtkGrid containing the Braille Flash Messages preferences page.""" + + def __init__(self, presenter: BraillePresenter) -> None: + self._presenter = presenter + self._flash_persistent_control = preferences_grid_base.BooleanPreferenceControl( + label=guilabels.BRAILLE_MESSAGES_ARE_PERSISTENT, + getter=presenter.get_flash_messages_are_persistent, + setter=presenter.set_flash_messages_are_persistent, + prefs_key=BraillePresenter.KEY_FLASH_MESSAGES_PERSISTENT, + ) + + controls: list[ + preferences_grid_base.BooleanPreferenceControl + | preferences_grid_base.IntRangePreferenceControl + ] = [ + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.BRAILLE_ENABLE_FLASH_MESSAGES, + getter=presenter.get_flash_messages_are_enabled, + setter=presenter.set_flash_messages_are_enabled, + prefs_key=BraillePresenter.KEY_FLASH_MESSAGES, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.BRAILLE_MESSAGES_ARE_DETAILED, + getter=presenter.get_flash_messages_are_detailed, + setter=presenter.set_flash_messages_are_detailed, + prefs_key=BraillePresenter.KEY_FLASH_MESSAGES_DETAILED, + ), + self._flash_persistent_control, + preferences_grid_base.IntRangePreferenceControl( + label=guilabels.BRAILLE_DURATION_SECS, + minimum=1, + maximum=100, + getter=presenter._get_flash_duration_seconds, + setter=presenter._set_flash_duration_seconds, + determine_sensitivity=self._flash_not_persistent, + ), + ] + + super().__init__(guilabels.BRAILLE_FLASH_MESSAGES, controls) + + def _flash_not_persistent(self) -> bool: + """Check if flash messages are not persistent in the UI.""" + + widget = self.get_widget_for_control(self._flash_persistent_control) + return not widget.get_active() if widget else True + + def save_settings(self, profile: str = "", app_name: str = "") -> dict: + """Persist staged values, including flash-message-duration in milliseconds.""" + + result = super().save_settings(profile, app_name) + result[BraillePresenter.KEY_FLASH_MESSAGE_DURATION] = ( + self._presenter.get_flash_message_duration() + ) + return result + + +class BrailleProgressBarsPreferencesGrid(preferences_grid_base.AutoPreferencesGrid): + """GtkGrid containing the Braille Progress Bars preferences page.""" + + def __init__(self, presenter: BraillePresenter) -> None: + controls: list[preferences_grid_base.ControlType] = [ + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.GENERAL_BRAILLE_UPDATES, + getter=presenter.get_braille_progress_bar_updates, + setter=presenter.set_braille_progress_bar_updates, + prefs_key=BraillePresenter.KEY_BRAILLE_PROGRESS_BAR_UPDATES, + ), + preferences_grid_base.IntRangePreferenceControl( + label=guilabels.GENERAL_FREQUENCY_SECS, + getter=presenter.get_progress_bar_braille_interval, + setter=presenter.set_progress_bar_braille_interval, + prefs_key=BraillePresenter.KEY_PROGRESS_BAR_BRAILLE_INTERVAL, + minimum=0, + maximum=100, + ), + preferences_grid_base.EnumPreferenceControl( + label=guilabels.GENERAL_APPLIES_TO, + getter=presenter.get_progress_bar_braille_verbosity, + setter=presenter.set_progress_bar_braille_verbosity, + prefs_key=BraillePresenter.KEY_PROGRESS_BAR_BRAILLE_VERBOSITY, + options=[ + guilabels.PROGRESS_BAR_ALL, + guilabels.PROGRESS_BAR_APPLICATION, + guilabels.PROGRESS_BAR_WINDOW, + ], + values=[ + ProgressBarVerbosity.ALL.value, + ProgressBarVerbosity.APPLICATION.value, + ProgressBarVerbosity.WINDOW.value, + ], + ), + ] + + super().__init__(guilabels.PROGRESS_BARS, controls) + + +class BrailleOSDPreferencesGrid(preferences_grid_base.AutoPreferencesGrid): + """GtkGrid containing the braille on-screen display preferences page.""" + + def __init__(self, presenter: BraillePresenter) -> None: + controls: list[preferences_grid_base.ControlType] = [ + preferences_grid_base.IntRangePreferenceControl( + label=guilabels.BRAILLE_MONITOR_CELL_COUNT, + getter=presenter.get_monitor_cell_count, + setter=presenter.set_monitor_cell_count, + prefs_key=BraillePresenter.KEY_MONITOR_CELL_COUNT, + minimum=1, + maximum=80, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.BRAILLE_MONITOR_SHOW_DOTS, + getter=presenter.get_monitor_show_dots, + setter=presenter.set_monitor_show_dots, + prefs_key=BraillePresenter.KEY_MONITOR_SHOW_DOTS, + ), + preferences_grid_base.ColorPreferenceControl( + label=guilabels.BRAILLE_MONITOR_FOREGROUND, + getter=presenter.get_monitor_foreground, + setter=presenter.set_monitor_foreground, + prefs_key=BraillePresenter.KEY_MONITOR_FOREGROUND, + ), + preferences_grid_base.ColorPreferenceControl( + label=guilabels.BRAILLE_MONITOR_BACKGROUND, + getter=presenter.get_monitor_background, + setter=presenter.set_monitor_background, + prefs_key=BraillePresenter.KEY_MONITOR_BACKGROUND, + ), + ] + + super().__init__( + guilabels.ON_SCREEN_DISPLAY, + controls, + info_message=guilabels.BRAILLE_MONITOR_INFO, + ) + + +# pylint: disable-next=too-many-instance-attributes +class BraillePreferencesGrid(preferences_grid_base.PreferencesGridBase): + """GtkGrid containing the Braille preferences page with nested stack navigation.""" + + def __init__( + self, + presenter: BraillePresenter, + title_change_callback: preferences_grid_base.Callable[[str], None] | None = None, + ) -> None: + super().__init__(guilabels.BRAILLE) + self._presenter = presenter + self._initializing = True + self._title_change_callback = title_change_callback + + self._verbosity_grid = BrailleVerbosityPreferencesGrid(presenter) + self._display_settings_grid = BrailleDisplaySettingsPreferencesGrid(presenter) + self._flash_messages_grid = BrailleFlashMessagesPreferencesGrid(presenter) + self._progress_bars_grid = BrailleProgressBarsPreferencesGrid(presenter) + self._osd_grid = BrailleOSDPreferencesGrid(presenter) + + self._build() + self._initializing = False + + def _build(self) -> None: + """Build the nested stack UI.""" + + row = 0 + + categories = [ + (guilabels.VERBOSITY, "verbosity", self._verbosity_grid), + (guilabels.BRAILLE_DISPLAY_SETTINGS, "display_settings", self._display_settings_grid), + (guilabels.BRAILLE_FLASH_MESSAGES, "flash_messages", self._flash_messages_grid), + (guilabels.PROGRESS_BARS, "progress-bars", self._progress_bars_grid), + (guilabels.ON_SCREEN_DISPLAY, "osd", self._osd_grid), + ] + + enable_listbox, stack, _categories_listbox = self._create_multi_page_stack( + enable_label=guilabels.BRAILLE_ENABLE_BRAILLE_SUPPORT, + enable_getter=self._presenter.get_braille_is_enabled, + enable_setter=self._presenter.set_braille_is_enabled, + categories=categories, + title_change_callback=self._title_change_callback, + main_title=guilabels.BRAILLE, + ) + + self.attach(enable_listbox, 0, row, 1, 1) + row += 1 + self.attach(stack, 0, row, 1, 1) + + def on_becoming_visible(self) -> None: + """Reset to the categories view when this grid becomes visible.""" + + self.multipage_on_becoming_visible() + + def reload(self) -> None: + """Fetch fresh values and update UI.""" + + self._initializing = True + self._has_unsaved_changes = False + self._verbosity_grid.reload() + self._display_settings_grid.reload() + self._flash_messages_grid.reload() + self._progress_bars_grid.reload() + self._osd_grid.reload() + self._initializing = False + + def save_settings(self, profile: str = "", app_name: str = "") -> dict: + """Persist staged values.""" + + assert self._multipage_enable_switch is not None + result: dict[str, Any] = {} + result[BraillePresenter.KEY_ENABLED] = self._multipage_enable_switch.get_active() + result.update(self._verbosity_grid.save_settings()) + result.update(self._display_settings_grid.save_settings()) + result.update(self._flash_messages_grid.save_settings()) + result.update(self._progress_bars_grid.save_settings()) + result.update(self._osd_grid.save_settings()) + + if profile: + skip = not app_name and profile == "default" + gsettings_registry.get_registry().save_schema( + "braille", + result, + profile, + app_name, + skip, + ) + + return result + + def refresh(self) -> None: + """Update widgets from staged values.""" + + self._initializing = True + self._verbosity_grid.refresh() + self._display_settings_grid.refresh() + self._flash_messages_grid.refresh() + self._progress_bars_grid.refresh() + self._osd_grid.refresh() + self._initializing = False + + def has_changes(self) -> bool: + """Return True if there are unsaved changes.""" + + return ( + self._has_unsaved_changes + or self._verbosity_grid.has_changes() + or self._display_settings_grid.has_changes() + or self._flash_messages_grid.has_changes() + or self._progress_bars_grid.has_changes() + or self._osd_grid.has_changes() + ) + + +@gsettings_registry.get_registry().gsettings_schema("org.stormux.Cthulhu.Braille", name="braille") +class BraillePresenter: + """Provides braille presentation support.""" + + _SCHEMA = "braille" + + KEY_ENABLED = "enabled" + KEY_VERBOSITY_LEVEL = "verbosity-level" + KEY_ROLENAME_STYLE = "rolename-style" + KEY_PRESENT_MNEMONICS = "present-mnemonics" + KEY_DISPLAY_ANCESTORS = "display-ancestors" + KEY_BRAILLE_PROGRESS_BAR_UPDATES = "braille-progress-bar-updates" + KEY_PROGRESS_BAR_BRAILLE_INTERVAL = "progress-bar-braille-interval" + KEY_PROGRESS_BAR_BRAILLE_VERBOSITY = "progress-bar-braille-verbosity" + KEY_CONTRACTED_BRAILLE = "contracted-braille" + KEY_COMPUTER_BRAILLE_AT_CURSOR = "computer-braille-at-cursor" + KEY_CONTRACTION_TABLE = "contraction-table" + KEY_END_OF_LINE_INDICATOR = "end-of-line-indicator" + KEY_WORD_WRAP = "word-wrap" + KEY_FLASH_MESSAGES = "flash-messages" + KEY_FLASH_MESSAGE_DURATION = "flash-message-duration" + KEY_FLASH_MESSAGES_PERSISTENT = "flash-messages-persistent" + KEY_FLASH_MESSAGES_DETAILED = "flash-messages-detailed" + KEY_SELECTOR_INDICATOR = "selector-indicator" + KEY_LINK_INDICATOR = "link-indicator" + KEY_TEXT_ATTRIBUTES_INDICATOR = "text-attributes-indicator" + KEY_MONITOR_CELL_COUNT = "monitor-cell-count" + KEY_MONITOR_SHOW_DOTS = "monitor-show-dots" + KEY_MONITOR_FOREGROUND = "monitor-foreground" + KEY_MONITOR_BACKGROUND = "monitor-background" + + def _get_setting(self, key: str, gtype: str, default: Any) -> Any: + """Returns the dconf value for key, or default if not in dconf.""" + + return gsettings_registry.get_registry().layered_lookup( + self._SCHEMA, + key, + gtype, + default=default, + ) + + def __init__(self) -> None: + msg = "BRAILLE PRESENTER: Registering D-Bus commands." + debug.print_message(debug.LEVEL_INFO, msg, True) + controller = dbus_service.get_remote_controller() + controller.register_decorated_module("BraillePresenter", self) + self._command_names: dict[int, str] | None = None + self._table_names: dict[str, str] | None = None + self._monitor: braille_monitor.BrailleMonitor | None = None + self._monitor_enabled_override: bool | None = None + self._initialized = False + self._progress_bar_cache: dict = {} + + def set_up_commands(self) -> None: + """Sets up commands with CommandManager.""" + + if self._initialized: + return + self._initialized = True + + manager = command_manager.get_manager() + manager.add_command( + command_manager.KeyboardCommand( + "toggle_braille_monitor", + self.toggle_monitor, + guilabels.BRAILLE, + cmdnames.TOGGLE_BRAILLE_MONITOR, + ), + ) + + msg = "BRAILLE PRESENTER: Commands set up." + debug.print_message(debug.LEVEL_INFO, msg, True) + + @dbus_service.command + def toggle_monitor( + self, + script: default.Script | None = None, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Toggles the braille monitor on and off.""" + + tokens = [ + "BRAILLE PRESENTER: toggle_monitor. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + from . import presentation_manager # pylint: disable=import-outside-toplevel + + if self.get_monitor_is_enabled(): + self.set_monitor_is_enabled(False) + if script is not None and notify_user: + presentation_manager.get_manager().present_message( + messages.BRAILLE_MONITOR_DISABLED, + ) + else: + self.set_monitor_is_enabled(True) + if script is not None and notify_user: + presentation_manager.get_manager().present_message(messages.BRAILLE_MONITOR_ENABLED) + return True + + @staticmethod + def _build_table_names() -> dict[str, str]: + """Returns display names for braille translation tables.""" + + return { + "Cz-Cz-g1": brltablenames.CZ_CZ_G1, + "Es-Es-g1": brltablenames.ES_ES_G1, + "Fr-Ca-g2": brltablenames.FR_CA_G2, + "Fr-Fr-g2": brltablenames.FR_FR_G2, + "Lv-Lv-g1": brltablenames.LV_LV_G1, + "Nl-Nl-g1": brltablenames.NL_NL_G1, + "No-No-g0": brltablenames.NO_NO_G0, + "No-No-g1": brltablenames.NO_NO_G1, + "No-No-g2": brltablenames.NO_NO_G2, + "No-No-g3": brltablenames.NO_NO_G3, + "Pl-Pl-g1": brltablenames.PL_PL_G1, + "Pt-Pt-g1": brltablenames.PT_PT_G1, + "Se-Se-g1": brltablenames.SE_SE_G1, + "ar-ar-g1": brltablenames.AR_AR_G1, + "cy-cy-g1": brltablenames.CY_CY_G1, + "cy-cy-g2": brltablenames.CY_CY_G2, + "de-de-g0": brltablenames.DE_DE_G0, + "de-de-g1": brltablenames.DE_DE_G1, + "de-de-g2": brltablenames.DE_DE_G2, + "en-GB-g2": brltablenames.EN_GB_G2, + "en-gb-g1": brltablenames.EN_GB_G1, + "en-us-g1": brltablenames.EN_US_G1, + "en-us-g2": brltablenames.EN_US_G2, + "fr-ca-g1": brltablenames.FR_CA_G1, + "fr-fr-g1": brltablenames.FR_FR_G1, + "gr-gr-g1": brltablenames.GR_GR_G1, + "hi-in-g1": brltablenames.HI_IN_G1, + "hu-hu-comp8": brltablenames.HU_HU_8DOT, + "hu-hu-g1": brltablenames.HU_HU_G1, + "hu-hu-g2": brltablenames.HU_HU_G2, + "it-it-g1": brltablenames.IT_IT_G1, + "nl-be-g1": brltablenames.NL_BE_G1, + } + + def get_table_names(self) -> dict[str, str]: + """Returns table aliases mapped to localized display names.""" + + if self._table_names is None: + self._table_names = self._build_table_names() + return dict(self._table_names) + + @staticmethod + def _build_command_names() -> dict[int, str]: + """Return BrlTTY command names for presentation in the UI.""" + + command_names: dict[int, str] = {} + + def add_command(command_id: int | None, label: str) -> None: + if command_id is not None: + command_names[command_id] = label + + add_command(braille.BRLAPI_KEY_CMD_HWINLT, cmdnames.BRAILLE_LINE_LEFT) + add_command(braille.BRLAPI_KEY_CMD_FWINLT, cmdnames.BRAILLE_LINE_LEFT) + add_command(braille.BRLAPI_KEY_CMD_FWINLTSKIP, cmdnames.BRAILLE_LINE_LEFT) + add_command(braille.BRLAPI_KEY_CMD_HWINRT, cmdnames.BRAILLE_LINE_RIGHT) + add_command(braille.BRLAPI_KEY_CMD_FWINRT, cmdnames.BRAILLE_LINE_RIGHT) + add_command(braille.BRLAPI_KEY_CMD_FWINRTSKIP, cmdnames.BRAILLE_LINE_RIGHT) + add_command(braille.BRLAPI_KEY_CMD_LNUP, cmdnames.BRAILLE_LINE_UP) + add_command(braille.BRLAPI_KEY_CMD_LNDN, cmdnames.BRAILLE_LINE_DOWN) + add_command(braille.BRLAPI_KEY_CMD_FREEZE, cmdnames.BRAILLE_FREEZE) + add_command(braille.BRLAPI_KEY_CMD_TOP_LEFT, cmdnames.BRAILLE_TOP_LEFT) + add_command(braille.BRLAPI_KEY_CMD_BOT_LEFT, cmdnames.BRAILLE_BOTTOM_LEFT) + add_command(braille.BRLAPI_KEY_CMD_HOME, cmdnames.BRAILLE_HOME) + add_command(braille.BRLAPI_KEY_CMD_SIXDOTS, cmdnames.BRAILLE_SIX_DOTS) + add_command(braille.BRLAPI_KEY_CMD_ROUTE, cmdnames.BRAILLE_ROUTE_CURSOR) + add_command(braille.BRLAPI_KEY_CMD_CUTBEGIN, cmdnames.BRAILLE_CUT_BEGIN) + add_command(braille.BRLAPI_KEY_CMD_CUTLINE, cmdnames.BRAILLE_CUT_LINE) + + return command_names + + def get_command_names(self) -> dict[int, str]: + """Returns a mapping of BrlTTY command IDs to user-visible labels.""" + + if self._command_names is None: + self._command_names = self._build_command_names() + return dict(self._command_names) + + def create_preferences_grid( + self, + title_change_callback: preferences_grid_base.Callable[[str], None] | None = None, + ) -> BraillePreferencesGrid: + """Returns the GtkGrid containing the preferences UI.""" + + return BraillePreferencesGrid(self, title_change_callback) + + def _get_verbosity_is_detailed(self) -> bool: + """Returns whether braille verbosity is set to detailed/verbose.""" + + return self.get_verbosity_level() == "verbose" + + def _set_verbosity_is_detailed(self, value: bool) -> bool: + """Sets braille verbosity to detailed/verbose if True, brief if False.""" + + return self.set_verbosity_level("verbose" if value else "brief") + + def _get_use_abbreviated_rolenames(self) -> bool: + """Returns whether abbreviated role names are used.""" + + return self.get_rolename_style() == "brief" + + def _set_use_abbreviated_rolenames(self, value: bool) -> bool: + """Sets whether abbreviated role names are used.""" + + return self.set_rolename_style("brief" if value else "verbose") + + def _get_flash_duration_seconds(self) -> int: + """Returns flash duration in seconds (converted from milliseconds).""" + + duration_ms = self.get_flash_message_duration() + return max(1, duration_ms // 1000) + + def _set_flash_duration_seconds(self, value: int) -> None: + """Sets flash duration in seconds (converts to milliseconds).""" + + duration_ms = value * 1000 + self.set_flash_message_duration(duration_ms) + + def use_braille(self) -> bool: + """Returns whether braille is to be used.""" + + result = self.get_braille_is_enabled() or self.get_monitor_is_enabled() + if not result: + msg = "BRAILLE PRESENTER: Braille is disabled." + debug.print_message(debug.LEVEL_INFO, msg, True) + return result + + @dbus_service.getter + def get_monitor_is_enabled(self) -> bool: + """Returns whether the braille monitor is enabled.""" + + return self._monitor_enabled_override or False + + @dbus_service.setter + def set_monitor_is_enabled(self, value: bool) -> bool: + """Sets whether the braille monitor is enabled.""" + + msg = f"BRAILLE PRESENTER: Setting enable braille monitor to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + self._monitor_enabled_override = value + if not value: + self.destroy_monitor() + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_MONITOR_CELL_COUNT, + schema="braille", + gtype="i", + default=32, + summary="Braille monitor cell count", + migration_key="brailleMonitorCellCount", + ) + @dbus_service.getter + def get_monitor_cell_count(self) -> int: + """Returns the configured braille monitor cell count.""" + + return self._get_setting(self.KEY_MONITOR_CELL_COUNT, "i", 32) + + @dbus_service.setter + def set_monitor_cell_count(self, value: int) -> bool: + """Sets the braille monitor cell count.""" + + msg = f"BRAILLE PRESENTER: Setting braille monitor cell count to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_MONITOR_CELL_COUNT, + value, + ) + self.destroy_monitor() + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_MONITOR_SHOW_DOTS, + schema="braille", + gtype="b", + default=False, + summary="Show Unicode braille dots in braille monitor", + migration_key="brailleMonitorShowDots", + ) + @dbus_service.getter + def get_monitor_show_dots(self) -> bool: + """Returns whether the braille monitor shows Unicode braille dots.""" + + return self._get_setting(self.KEY_MONITOR_SHOW_DOTS, "b", False) + + @dbus_service.setter + def set_monitor_show_dots(self, value: bool) -> bool: + """Sets whether the braille monitor shows Unicode braille dots.""" + + msg = f"BRAILLE PRESENTER: Setting braille monitor show dots to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_MONITOR_SHOW_DOTS, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_MONITOR_FOREGROUND, + schema="braille", + gtype="s", + default="#000000", + summary="Braille monitor foreground color", + migration_key="brailleMonitorForeground", + ) + @dbus_service.getter + def get_monitor_foreground(self) -> str: + """Returns the braille monitor foreground color.""" + + return self._get_setting(self.KEY_MONITOR_FOREGROUND, "s", "#000000") + + @dbus_service.setter + def set_monitor_foreground(self, value: str) -> bool: + """Sets the braille monitor foreground color.""" + + msg = f"BRAILLE PRESENTER: Setting braille monitor foreground to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_MONITOR_FOREGROUND, + value, + ) + if self._monitor is not None: + self._monitor.reapply_css(foreground=value) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_MONITOR_BACKGROUND, + schema="braille", + gtype="s", + default="#ffffff", + summary="Braille monitor background color", + migration_key="brailleMonitorBackground", + ) + @dbus_service.getter + def get_monitor_background(self) -> str: + """Returns the braille monitor background color.""" + + return self._get_setting(self.KEY_MONITOR_BACKGROUND, "s", "#ffffff") + + @dbus_service.setter + def set_monitor_background(self, value: str) -> bool: + """Sets the braille monitor background color.""" + + msg = f"BRAILLE PRESENTER: Setting braille monitor background to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_MONITOR_BACKGROUND, + value, + ) + if self._monitor is not None: + self._monitor.reapply_css(background=value) + return True + + # pylint: disable-next=too-many-arguments + def present_regions( + self, + regions: list[braille.Region], + focused_region: braille.Region | None, + extra_region: braille.Region | None = None, + *, + pan_to_cursor: bool = True, + indicate_links: bool = True, + stop_flash: bool = True, + ) -> None: + """Build a line from regions and present it as a single braille line.""" + + if extra_region is not None: + regions = list(regions) + regions.append(extra_region) + focused_region = extra_region + + line = braille.Line() + line.add_regions(regions) + braille.display_line( + line, + focused_region, + pan_to_cursor=pan_to_cursor, + indicate_links=indicate_links, + stop_flash=stop_flash, + ) + + def _build_generator_context( + self, + where_am_i_type: WhereAmI | None = None, + ) -> BrailleGeneratorContext: + """Builds the settings context for braille generators.""" + + mgr = focus_manager.get_manager() + active_mode, _obj = mgr.get_active_mode_and_object_of_interest() + + return BrailleGeneratorContext( + enabled=self.use_braille(), + verbose=self.use_verbose_braille(), + focus=mgr.get_locus_of_focus(), + in_say_all=mgr.in_say_all(), + in_focus_mode=document_presenter.get_presenter().get_in_focus_mode(), + active_mode=active_mode, + where_am_i_type=where_am_i_type, + full_rolenames=self.use_full_rolenames(), + display_ancestors=self.get_display_ancestors(), + end_of_line_indicator=self.get_end_of_line_indicator_is_enabled(), + present_mnemonics=self.get_present_mnemonics(), + ) + + def display_generated_contents( + self, + script: default.Script, + contents: list[tuple[Atspi.Accessible, int, int, str]], + **args: Any, + ) -> None: + """Generates braille for contents and displays the flattened regions.""" + + if not self.use_braille(): + return + + context = self._build_generator_context() + regions_list, focused_region = script.get_braille_generator().generate_contents( + contents, + context, + **args, + ) + if not regions_list: + return + + flattened_regions: list = [] + for regions in regions_list: + flattened_regions.extend(regions) + if flattened_regions: + flattened_regions[-1].string = flattened_regions[-1].string.rstrip(" ") + self.present_regions(flattened_regions, focused_region, indicate_links=False) + + def present_generated_braille( + self, + script: default.Script, + obj: Atspi.Accessible, + **args: Any, + ) -> None: + """Generates braille for obj using the script's braille generator and displays it.""" + + if not self.use_braille(): + return + + where_am_i_type = args.pop("where_am_i_type", None) + context = self._build_generator_context(where_am_i_type) + generator = script.get_braille_generator() + result, focused_region = generator.generate_braille(obj, context, **args) + if result: + self.present_regions( + list(result), + focused_region, + extra_region=args.get("extraRegion"), + ) + + @gsettings_registry.get_registry().gsetting( + key=KEY_ENABLED, + schema="braille", + gtype="b", + default=True, + summary="Enable braille output", + migration_key="enableBraille", + ) + @dbus_service.getter + def get_braille_is_enabled(self) -> bool: + """Returns whether braille is enabled.""" + + return self._get_setting(self.KEY_ENABLED, "b", True) + + @dbus_service.setter + def set_braille_is_enabled(self, value: bool) -> bool: + """Sets whether braille is enabled.""" + + if value == self.get_braille_is_enabled(): + return True + + msg = f"BRAILLE PRESENTER: Setting enable braille to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value(self._SCHEMA, self.KEY_ENABLED, value) + braille.set_enable_braille(value) + + if value: + braille.init(input_event_manager.get_manager().process_braille_event) + else: + braille.shutdown() + + return True + + def use_verbose_braille(self) -> bool: + """Returns whether the braille verbosity level is set to verbose.""" + + return self.get_verbosity_level() == "verbose" + + @gsettings_registry.get_registry().gsetting( + key=KEY_VERBOSITY_LEVEL, + schema="braille", + genum="org.stormux.Cthulhu.VerbosityLevel", + default="verbose", + summary="Braille verbosity level (brief, verbose)", + migration_key="brailleVerbosityLevel", + ) + @dbus_service.getter + def get_verbosity_level(self) -> str: + """Returns the current braille verbosity level for object presentation.""" + + return gsettings_registry.get_registry().layered_lookup( + self._SCHEMA, + self.KEY_VERBOSITY_LEVEL, + "", + genum="org.stormux.Cthulhu.VerbosityLevel", + default="verbose", + ) + + @dbus_service.setter + def set_verbosity_level(self, value: str) -> bool: + """Sets the braille verbosity level for object presentation.""" + + try: + level = VerbosityLevel[value.upper()] + except KeyError: + msg = f"BRAILLE PRESENTER: Invalid verbosity level: {value}" + debug.print_message(debug.LEVEL_WARNING, msg, True) + return False + + msg = f"BRAILLE PRESENTER: Setting verbosity level to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_VERBOSITY_LEVEL, + level.string_name, + ) + return True + + def use_full_rolenames(self) -> bool: + """Returns whether full rolenames should be used.""" + + return self.get_rolename_style() == "verbose" + + @gsettings_registry.get_registry().gsetting( + key=KEY_ROLENAME_STYLE, + schema="braille", + genum="org.stormux.Cthulhu.VerbosityLevel", + default="verbose", + summary="Braille rolename style (brief, verbose)", + migration_key="brailleRolenameStyle", + ) + @dbus_service.getter + def get_rolename_style(self) -> str: + """Returns the current rolename style for object presentation.""" + + return gsettings_registry.get_registry().layered_lookup( + self._SCHEMA, + self.KEY_ROLENAME_STYLE, + "", + genum="org.stormux.Cthulhu.VerbosityLevel", + default="verbose", + ) + + @dbus_service.setter + def set_rolename_style(self, value: str) -> bool: + """Sets the current rolename style for object presentation.""" + + try: + level = VerbosityLevel[value.upper()] + except KeyError: + msg = f"BRAILLE PRESENTER: Invalid rolename style: {value}" + debug.print_message(debug.LEVEL_WARNING, msg, True) + return False + + msg = f"BRAILLE PRESENTER: Setting rolename style to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_ROLENAME_STYLE, + level.string_name, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_PRESENT_MNEMONICS, + schema="braille", + gtype="b", + default=True, + summary="Present mnemonics on braille display", + migration_key="displayObjectMnemonic", + ) + @dbus_service.getter + def get_present_mnemonics(self) -> bool: + """Returns whether mnemonics are presented on the braille display.""" + + return self._get_setting(self.KEY_PRESENT_MNEMONICS, "b", True) + + @dbus_service.setter + def set_present_mnemonics(self, value: bool) -> bool: + """Sets whether mnemonics are presented on the braille display.""" + + msg = f"BRAILLE PRESENTER: Setting present mnemonics to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_PRESENT_MNEMONICS, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_DISPLAY_ANCESTORS, + schema="braille", + gtype="b", + default=True, + summary="Display ancestors of current object", + migration_key="enableBrailleContext", + ) + @dbus_service.getter + def get_display_ancestors(self) -> bool: + """Returns whether ancestors of the current object will be displayed.""" + + return self._get_setting(self.KEY_DISPLAY_ANCESTORS, "b", True) + + @dbus_service.setter + def set_display_ancestors(self, value: bool) -> bool: + """Sets whether ancestors of the current object will be displayed.""" + + msg = f"BRAILLE PRESENTER: Setting enable braille context to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_DISPLAY_ANCESTORS, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_BRAILLE_PROGRESS_BAR_UPDATES, + schema="braille", + gtype="b", + default=False, + summary="Show progress bar updates in braille", + migration_key="brailleProgressBarUpdates", + ) + @dbus_service.getter + def get_braille_progress_bar_updates(self) -> bool: + """Returns whether braille progress bar updates are enabled.""" + + return self._get_setting(self.KEY_BRAILLE_PROGRESS_BAR_UPDATES, "b", False) + + @dbus_service.setter + def set_braille_progress_bar_updates(self, value: bool) -> bool: + """Sets whether braille progress bar updates are enabled.""" + + msg = f"BRAILLE PRESENTER: Setting braille progress bar updates to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_BRAILLE_PROGRESS_BAR_UPDATES, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_PROGRESS_BAR_BRAILLE_INTERVAL, + schema="braille", + gtype="i", + default=10, + summary="Progress bar braille update interval in seconds", + migration_key="progressBarBrailleInterval", + ) + @dbus_service.getter + def get_progress_bar_braille_interval(self) -> int: + """Returns the braille progress bar update interval in seconds.""" + + return self._get_setting(self.KEY_PROGRESS_BAR_BRAILLE_INTERVAL, "i", 10) + + @dbus_service.setter + def set_progress_bar_braille_interval(self, value: int) -> bool: + """Sets the braille progress bar update interval in seconds.""" + + msg = f"BRAILLE PRESENTER: Setting progress bar braille interval to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_PROGRESS_BAR_BRAILLE_INTERVAL, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_PROGRESS_BAR_BRAILLE_VERBOSITY, + schema="braille", + genum="org.stormux.Cthulhu.ProgressBarVerbosity", + default="application", + summary="Progress bar braille verbosity (all, application, window)", + migration_key="progressBarBrailleVerbosity", + ) + @dbus_service.getter + def get_progress_bar_braille_verbosity(self) -> int: + """Returns the braille progress bar verbosity level.""" + + nick = gsettings_registry.get_registry().layered_lookup( + self._SCHEMA, + self.KEY_PROGRESS_BAR_BRAILLE_VERBOSITY, + "", + genum="org.stormux.Cthulhu.ProgressBarVerbosity", + default="application", + ) + return ProgressBarVerbosity[nick.upper()].value + + @dbus_service.setter + def set_progress_bar_braille_verbosity(self, value: int) -> bool: + """Sets the braille progress bar verbosity level.""" + + msg = f"BRAILLE PRESENTER: Setting progress bar braille verbosity to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + level = ProgressBarVerbosity(value) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_PROGRESS_BAR_BRAILLE_VERBOSITY, + level.name.lower(), + ) + return True + + def should_present_progress_bar_update( + self, + obj: Atspi.Accessible, + percent: int | None, + is_same_app: bool, + is_same_window: bool, + ) -> bool: + """Returns True if the progress bar update should be brailled.""" + + if not self.get_braille_progress_bar_updates(): + return False + + last_time, last_value = self._progress_bar_cache.get(id(obj), (0.0, None)) + if percent == last_value: + return False + + if percent != 100: + interval = int(time.time() - last_time) + if interval < self.get_progress_bar_braille_interval(): + return False + + verbosity = self.get_progress_bar_braille_verbosity() + if verbosity == ProgressBarVerbosity.ALL.value: + present = True + elif verbosity == ProgressBarVerbosity.APPLICATION.value: + present = is_same_app + elif verbosity == ProgressBarVerbosity.WINDOW.value: + present = is_same_window + else: + present = True + + if present: + self._progress_bar_cache[id(obj)] = (time.time(), percent) + + return present + + @gsettings_registry.get_registry().gsetting( + key=KEY_CONTRACTED_BRAILLE, + schema="braille", + gtype="b", + default=False, + summary="Enable contracted braille", + migration_key="enableContractedBraille", + ) + @dbus_service.getter + def get_contracted_braille_is_enabled(self) -> bool: + """Returns whether contracted braille is enabled.""" + + return self._get_setting(self.KEY_CONTRACTED_BRAILLE, "b", False) + + @dbus_service.setter + def set_contracted_braille_is_enabled(self, value: bool) -> bool: + """Sets whether contracted braille is enabled.""" + + msg = f"BRAILLE PRESENTER: Setting enable contracted braille to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_CONTRACTED_BRAILLE, + value, + ) + braille.set_enable_contracted_braille(value) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_COMPUTER_BRAILLE_AT_CURSOR, + schema="braille", + gtype="b", + default=True, + summary="Use computer braille at cursor position", + migration_key="enableComputerBrailleAtCursor", + ) + @dbus_service.getter + def get_computer_braille_at_cursor_is_enabled(self) -> bool: + """Returns whether computer braille is used at the cursor position.""" + + return self._get_setting(self.KEY_COMPUTER_BRAILLE_AT_CURSOR, "b", True) + + @dbus_service.setter + def set_computer_braille_at_cursor_is_enabled(self, value: bool) -> bool: + """Sets whether computer braille is used at the cursor position.""" + + msg = f"BRAILLE PRESENTER: Setting enable computer braille at cursor to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_COMPUTER_BRAILLE_AT_CURSOR, + value, + ) + braille.set_enable_computer_braille_at_cursor(value) + return True + + def get_contraction_table_path(self) -> str: + """Returns the current braille contraction table file path.""" + + value = gsettings_registry.get_registry().layered_lookup( + self._SCHEMA, + self.KEY_CONTRACTION_TABLE, + "s", + default="", + ) + return value or braille.get_default_contraction_table() + + @gsettings_registry.get_registry().gsetting( + key=KEY_CONTRACTION_TABLE, + schema="braille", + gtype="s", + default="", + summary="Braille contraction table name", + migration_key="brailleContractionTable", + ) + @dbus_service.getter + def get_contraction_table(self) -> str: + """Returns the current braille contraction table name.""" + + full_path = gsettings_registry.get_registry().layered_lookup( + self._SCHEMA, + self.KEY_CONTRACTION_TABLE, + "s", + default="", + ) + if not full_path: + return "" + return os.path.splitext(os.path.basename(full_path))[0] + + @dbus_service.getter + def get_available_contraction_tables(self) -> list[str]: + """Returns a list of available contraction table names.""" + + table_files = self._get_table_files() + return [os.path.splitext(filename)[0] for filename in table_files] + + def get_contraction_tables_dict(self) -> dict[str, str]: + """Returns a dictionary mapping display names to table file paths.""" + + names = self.get_table_names() + tables = {} + for fname in self._get_table_files(): + alias = fname[:-4] + tables[names.get(alias, alias)] = os.path.join(tablesdir, fname) + return tables + + @dbus_service.setter + def set_contraction_table(self, value: str) -> bool: + """Sets the current braille contraction table.""" + + table_files = self._get_table_files() + base_name = os.path.splitext(value)[0] + filename = None + for table_file in table_files: + if table_file.startswith(base_name + "."): + filename = table_file + break + + if not filename: + msg = f"BRAILLE PRESENTER: Invalid contraction table: {value}" + debug.print_message(debug.LEVEL_WARNING, msg, True) + return False + + full_path = os.path.join(tablesdir, filename) + msg = f"BRAILLE PRESENTER: Setting contraction table to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_CONTRACTION_TABLE, + full_path, + ) + return True + + @staticmethod + def _get_table_files() -> list[str]: + """Returns a list of braille table filenames in the tables directory.""" + + try: + return [fname for fname in os.listdir(tablesdir) if fname[-4:] in (".utb", ".ctb")] + except OSError: + return [] + + def set_contraction_table_from_path(self, file_path: str) -> bool: + """Sets the current braille contraction table from a file path.""" + + msg = f"BRAILLE PRESENTER: Setting contraction table to {file_path}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_CONTRACTION_TABLE, + file_path, + ) + braille.set_contraction_table(file_path) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_END_OF_LINE_INDICATOR, + schema="braille", + gtype="b", + default=True, + summary="Show end-of-line indicator", + migration_key="enableBrailleEOL", + ) + @dbus_service.getter + def get_end_of_line_indicator_is_enabled(self) -> bool: + """Returns whether the end-of-line indicator is enabled.""" + + return self._get_setting(self.KEY_END_OF_LINE_INDICATOR, "b", True) + + @dbus_service.setter + def set_end_of_line_indicator_is_enabled(self, value: bool) -> bool: + """Sets whether the end-of-line indicator is enabled.""" + + msg = f"BRAILLE PRESENTER: Setting enable-eol to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_END_OF_LINE_INDICATOR, + value, + ) + braille.set_enable_eol(value) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_WORD_WRAP, + schema="braille", + gtype="b", + default=False, + summary="Enable braille word wrap", + migration_key="enableBrailleWordWrap", + ) + @dbus_service.getter + def get_word_wrap_is_enabled(self) -> bool: + """Returns whether braille word wrap is enabled.""" + + return self._get_setting(self.KEY_WORD_WRAP, "b", False) + + @dbus_service.setter + def set_word_wrap_is_enabled(self, value: bool) -> bool: + """Sets whether braille word wrap is enabled.""" + + msg = f"BRAILLE PRESENTER: Setting enable word wrap to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value(self._SCHEMA, self.KEY_WORD_WRAP, value) + braille.set_enable_word_wrap(value) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_FLASH_MESSAGES, + schema="braille", + gtype="b", + default=True, + summary="Enable braille flash messages", + migration_key="enableFlashMessages", + ) + @dbus_service.getter + def get_flash_messages_are_enabled(self) -> bool: + """Returns whether 'flash' messages (i.e. announcements) are enabled.""" + + return self._get_setting(self.KEY_FLASH_MESSAGES, "b", True) + + @dbus_service.setter + def set_flash_messages_are_enabled(self, value: bool) -> bool: + """Sets whether 'flash' messages (i.e. announcements) are enabled.""" + + msg = f"BRAILLE PRESENTER: Setting enable flash messages to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, self.KEY_FLASH_MESSAGES, value + ) + return True + + def get_flashtime_from_settings(self) -> int: + """Returns flash message duration in milliseconds based on user settings.""" + + if self.get_flash_messages_are_persistent(): + return -1 + return self.get_flash_message_duration() + + @gsettings_registry.get_registry().gsetting( + key=KEY_FLASH_MESSAGE_DURATION, + schema="braille", + gtype="i", + default=5000, + summary="Flash message duration in milliseconds", + migration_key="brailleFlashTime", + ) + @dbus_service.getter + def get_flash_message_duration(self) -> int: + """Returns flash message duration in milliseconds.""" + + return self._get_setting(self.KEY_FLASH_MESSAGE_DURATION, "i", 5000) + + @dbus_service.setter + def set_flash_message_duration(self, value: int) -> bool: + """Sets flash message duration in milliseconds.""" + + msg = f"BRAILLE PRESENTER: Setting braille flash time to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_FLASH_MESSAGE_DURATION, + value, + ) + return True + + def set_selector_indicator_from_int(self, value: int) -> bool: + """Sets the braille selector indicator from an int value.""" + + msg = f"BRAILLE PRESENTER: Setting selector indicator to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + indicator = BrailleIndicator(value) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_SELECTOR_INDICATOR, + indicator.string_name, + ) + braille.set_selector_indicator(value) + return True + + def set_link_indicator_from_int(self, value: int) -> bool: + """Sets the braille link indicator from an int value.""" + + msg = f"BRAILLE PRESENTER: Setting link indicator to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + indicator = BrailleIndicator(value) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_LINK_INDICATOR, + indicator.string_name, + ) + braille.set_link_indicator(value) + return True + + def set_text_attributes_indicator_from_int(self, value: int) -> bool: + """Sets the braille text attributes indicator from an int value.""" + + msg = f"BRAILLE PRESENTER: Setting text attributes indicator to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + indicator = BrailleIndicator(value) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_TEXT_ATTRIBUTES_INDICATOR, + indicator.string_name, + ) + braille.set_text_attributes_indicator(value) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_FLASH_MESSAGES_PERSISTENT, + schema="braille", + gtype="b", + default=False, + summary="Make flash messages persistent", + migration_key="flashIsPersistent", + ) + @dbus_service.getter + def get_flash_messages_are_persistent(self) -> bool: + """Returns whether 'flash' messages are persistent (as opposed to temporary).""" + + return self._get_setting(self.KEY_FLASH_MESSAGES_PERSISTENT, "b", False) + + @dbus_service.setter + def set_flash_messages_are_persistent(self, value: bool) -> bool: + """Sets whether 'flash' messages are persistent (as opposed to temporary).""" + + msg = f"BRAILLE PRESENTER: Setting flash messages are persistent to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_FLASH_MESSAGES_PERSISTENT, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_FLASH_MESSAGES_DETAILED, + schema="braille", + gtype="b", + default=True, + summary="Use detailed flash messages", + migration_key="flashIsDetailed", + ) + @dbus_service.getter + def get_flash_messages_are_detailed(self) -> bool: + """Returns whether 'flash' messages are detailed (as opposed to brief).""" + + return self._get_setting(self.KEY_FLASH_MESSAGES_DETAILED, "b", True) + + @dbus_service.setter + def set_flash_messages_are_detailed(self, value: bool) -> bool: + """Sets whether 'flash' messages are detailed (as opposed to brief).""" + + msg = f"BRAILLE PRESENTER: Setting flash messages are detailed to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_FLASH_MESSAGES_DETAILED, + value, + ) + return True + + def _get_selector_indicator_as_int(self) -> int: + """Returns the braille selector indicator as an int.""" + + nick = gsettings_registry.get_registry().layered_lookup( + self._SCHEMA, + self.KEY_SELECTOR_INDICATOR, + "", + genum="org.stormux.Cthulhu.BrailleIndicator", + default="dots78", + ) + value = BrailleIndicator[nick.upper()].value + msg = f"BRAILLE PRESENTER: Getting selector indicator: {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + return value + + @gsettings_registry.get_registry().gsetting( + key=KEY_SELECTOR_INDICATOR, + schema="braille", + genum="org.stormux.Cthulhu.BrailleIndicator", + default="dots78", + summary="Braille selector indicator style (none, dot7, dot8, dots78)", + migration_key="brailleSelectorIndicator", + ) + @dbus_service.getter + def get_selector_indicator(self) -> str: + """Returns the braille selector indicator style.""" + + return gsettings_registry.get_registry().layered_lookup( + self._SCHEMA, + self.KEY_SELECTOR_INDICATOR, + "", + genum="org.stormux.Cthulhu.BrailleIndicator", + default="dots78", + ) + + @dbus_service.setter + def set_selector_indicator(self, value: str) -> bool: + """Sets the braille selector indicator style.""" + + try: + indicator = BrailleIndicator[value.upper()] + except KeyError: + msg = f"BRAILLE PRESENTER: Invalid selector indicator: {value}" + debug.print_message(debug.LEVEL_WARNING, msg, True) + return False + + msg = f"BRAILLE PRESENTER: Setting selector indicator to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_SELECTOR_INDICATOR, + indicator.string_name, + ) + braille.set_selector_indicator(indicator.value) + return True + + def _get_link_indicator_as_int(self) -> int: + """Returns the braille link indicator as an int.""" + + nick = gsettings_registry.get_registry().layered_lookup( + self._SCHEMA, + self.KEY_LINK_INDICATOR, + "", + genum="org.stormux.Cthulhu.BrailleIndicator", + default="dots78", + ) + value = BrailleIndicator[nick.upper()].value + msg = f"BRAILLE PRESENTER: Getting link indicator: {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + return value + + @gsettings_registry.get_registry().gsetting( + key=KEY_LINK_INDICATOR, + schema="braille", + genum="org.stormux.Cthulhu.BrailleIndicator", + default="dots78", + summary="Braille link indicator style (none, dot7, dot8, dots78)", + migration_key="brailleLinkIndicator", + ) + @dbus_service.getter + def get_link_indicator(self) -> str: + """Returns the braille link indicator style.""" + + return gsettings_registry.get_registry().layered_lookup( + self._SCHEMA, + self.KEY_LINK_INDICATOR, + "", + genum="org.stormux.Cthulhu.BrailleIndicator", + default="dots78", + ) + + @dbus_service.setter + def set_link_indicator(self, value: str) -> bool: + """Sets the braille link indicator style.""" + + try: + indicator = BrailleIndicator[value.upper()] + except KeyError: + msg = f"BRAILLE PRESENTER: Invalid link indicator: {value}" + debug.print_message(debug.LEVEL_WARNING, msg, True) + return False + + msg = f"BRAILLE PRESENTER: Setting link indicator to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_LINK_INDICATOR, + indicator.string_name, + ) + braille.set_link_indicator(indicator.value) + return True + + def _get_text_attributes_indicator_as_int(self) -> int: + """Returns the braille text attributes indicator as an int.""" + + nick = gsettings_registry.get_registry().layered_lookup( + self._SCHEMA, + self.KEY_TEXT_ATTRIBUTES_INDICATOR, + "", + genum="org.stormux.Cthulhu.BrailleIndicator", + default="none", + ) + value = BrailleIndicator[nick.upper()].value + msg = f"BRAILLE PRESENTER: Getting text attributes indicator: {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + return value + + @gsettings_registry.get_registry().gsetting( + key=KEY_TEXT_ATTRIBUTES_INDICATOR, + schema="braille", + genum="org.stormux.Cthulhu.BrailleIndicator", + default="none", + summary="Braille text attributes indicator style (none, dot7, dot8, dots78)", + migration_key="textAttributesBrailleIndicator", + ) + @dbus_service.getter + def get_text_attributes_indicator(self) -> str: + """Returns the braille text attributes indicator style.""" + + return gsettings_registry.get_registry().layered_lookup( + self._SCHEMA, + self.KEY_TEXT_ATTRIBUTES_INDICATOR, + "", + genum="org.stormux.Cthulhu.BrailleIndicator", + default="none", + ) + + @dbus_service.setter + def set_text_attributes_indicator(self, value: str) -> bool: + """Sets the braille text attributes indicator style.""" + + try: + indicator = BrailleIndicator[value.upper()] + except KeyError: + msg = f"BRAILLE PRESENTER: Invalid text attributes indicator: {value}" + debug.print_message(debug.LEVEL_WARNING, msg, True) + return False + + msg = f"BRAILLE PRESENTER: Setting text attributes indicator to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_TEXT_ATTRIBUTES_INDICATOR, + indicator.string_name, + ) + braille.set_text_attributes_indicator(indicator.value) + return True + + def is_flash_active(self) -> bool: + """Returns True if a flash message is currently being displayed.""" + + return braille.is_flash_active() + + def kill_flash(self, restore_saved: bool = True) -> None: + """Kills any flashed message currently being displayed.""" + + braille.kill_flash(restore_saved) + + def present_message(self, message: str, restore_previous: bool = True) -> None: + """Displays a single line message in braille.""" + + if not self.use_braille(): + return + + flash_time = self.get_flashtime_from_settings() + + if not restore_previous and flash_time: + braille.kill_flash(restore_saved=False) + + braille.display_message(message, flash_time=flash_time) + + def pan_left(self) -> bool: + """Pans the braille display left, returning True if the display moved.""" + + if not self.use_braille(): + return False + + moved = braille.pan_left() + if moved: + braille.refresh(pan_to_cursor=False, stop_flash=False) + return moved + + def pan_right(self) -> bool: + """Pans the braille display right, returning True if the display moved.""" + + if not self.use_braille(): + return False + + moved = braille.pan_right() + if moved: + braille.refresh(pan_to_cursor=False, stop_flash=False) + return moved + + def refresh_braille(self, pan_to_cursor: bool = True) -> None: + """Refreshes the braille display without rebuilding the line.""" + + if not self.use_braille(): + return + + braille.refresh(pan_to_cursor=pan_to_cursor) + + def pan_to_beginning(self) -> None: + """Pans the braille display all the way to the beginning of the line.""" + + if not self.use_braille(): + return + + while braille.pan_left(): + pass + braille.refresh(pan_to_cursor=False, stop_flash=True) + + def pan_to_end(self) -> None: + """Pans the braille display all the way to the end of the line.""" + + if not self.use_braille(): + return + + while braille.pan_right(): + pass + braille.refresh(pan_to_cursor=False, stop_flash=True) + + def check_braille_setting(self) -> None: + """Checks the braille setting and disables braille if necessary.""" + + braille.check_braille_setting() + + def disable_braille(self) -> None: + """Idles or shuts down braille output if enabled.""" + + braille.disable_braille() + + def set_brlapi_priority(self, high: bool = False) -> None: + """Sets the BrlAPI priority level. + + Args: + high: If True, use high priority (for flat review). Otherwise use default. + """ + + if high: + braille.set_brlapi_priority(braille.BRLAPI_PRIORITY_HIGH) + else: + braille.set_brlapi_priority() + + def update_monitor( + self, + cursor_cell: int, + substring: str, + mask: str | None, + display_size: int, + ) -> None: + """Updates the braille monitor display, creating it on demand if enabled.""" + + if not self.get_monitor_is_enabled(): + return + + cell_count = self.get_monitor_cell_count() or display_size + if self._monitor is None: + self._monitor = braille_monitor.BrailleMonitor( + cell_count, + on_close=lambda: self.set_monitor_is_enabled(False), + foreground=self.get_monitor_foreground(), + background=self.get_monitor_background(), + ) + self._monitor.show_all() # pylint: disable=no-member + + if self.get_monitor_show_dots(): + substring = self._to_unicode_braille(substring) + + self._monitor.write_text(cursor_cell, substring, mask) + + def _to_unicode_braille(self, text: str) -> str: + """Convert text to Unicode braille dot pattern characters. + + Uses louis.charToDots() to map each character to its braille dot pattern, + then converts to Unicode braille characters (U+2800 block). + """ + + try: + import louis # pylint: disable=import-outside-toplevel + + table = ( + self.get_contraction_table_path() + if self.get_contracted_braille_is_enabled() + else "" + ) + if not table: + table = "en-us-comp8.ctb" + dots_str = louis.charToDots([table], text) + return "".join(chr(0x2800 | (ord(c) & 0xFF)) for c in dots_str) + except Exception: # pylint: disable=broad-except + return text + + def destroy_monitor(self) -> None: + """Destroys the braille monitor widget if it exists.""" + + if self._monitor is not None: + self._monitor.destroy() + self._monitor = None + + def init_braille(self) -> None: + """Initializes braille if enabled.""" + + braille.set_monitor_callback(self.update_monitor) + if not self.get_braille_is_enabled(): + return + + braille.init(input_event_manager.get_manager().process_braille_event) + self._sync_state_to_braille() + + def _sync_state_to_braille(self) -> None: + """Pushes persisted dconf settings into the braille module's runtime state.""" + + braille.set_enable_contracted_braille(self.get_contracted_braille_is_enabled()) + braille.set_contraction_table(self.get_contraction_table_path()) + braille.set_enable_computer_braille_at_cursor( + self.get_computer_braille_at_cursor_is_enabled(), + ) + braille.set_enable_eol(self.get_end_of_line_indicator_is_enabled()) + braille.set_enable_word_wrap(self.get_word_wrap_is_enabled()) + braille.set_selector_indicator(self._get_selector_indicator_as_int()) + braille.set_link_indicator(self._get_link_indicator_as_int()) + braille.set_text_attributes_indicator(self._get_text_attributes_indicator_as_int()) + + def shutdown_braille(self) -> None: + """Shuts down braille.""" + + braille.shutdown() + + +_presenter: BraillePresenter = BraillePresenter() + + +def get_presenter() -> BraillePresenter: + """Returns the Braille Presenter""" + + return _presenter diff --git a/src/cthulhu/caret_navigator.py b/src/cthulhu/caret_navigator.py new file mode 100644 index 0000000..628e1ec --- /dev/null +++ b/src/cthulhu/caret_navigator.py @@ -0,0 +1,1016 @@ +# Cthulhu +# +# Copyright 2013-2025 Igalia, S.L. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-locals +# pylint: disable=too-many-lines + +"""Provides an Cthulhu-controlled caret for text content.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from . import ( + cmdnames, + command_manager, + dbus_service, + debug, + focus_manager, + gsettings_registry, + guilabels, + input_event, + input_event_manager, + keybindings, + messages, + presentation_manager, + say_all_presenter, + script_manager, +) +from .ax_object import AXObject +from .ax_text import AXText +from .ax_utilities import AXUtilities + +if TYPE_CHECKING: + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + + from .scripts import default + + +@gsettings_registry.get_registry().gsettings_schema( + "org.stormux.Cthulhu.CaretNavigation", + name="caret-navigation", +) +class CaretNavigator: + """Implements the caret navigation support available to scripts.""" + + _SCHEMA = "caret-navigation" + KEY_ENABLED = "enabled" + KEY_TRIGGERS_FOCUS_MODE = "triggers-focus-mode" + KEY_LAYOUT_MODE = "layout-mode" + + 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: + # To make it possible for focus mode to suspend this navigation without + # changing the user's preferred setting. + self._suspended: bool = False + self._last_input_event: input_event.InputEvent | None = None + self._enabled_for_script: dict[default.Script, bool] = {} + self._initialized: bool = False + + msg = "CARET NAVIGATOR: Registering D-Bus commands." + debug.print_message(debug.LEVEL_INFO, msg, True) + controller = dbus_service.get_remote_controller() + controller.register_decorated_module("CaretNavigator", self) + + def set_up_commands(self) -> None: + """Sets up the caret-navigation commands with CommandManager.""" + + if self._initialized: + return + self._initialized = True + + manager = command_manager.get_manager() + group_label = guilabels.KB_GROUP_CARET_NAVIGATION + + # Keybindings (same for desktop and laptop) + kb_f12 = keybindings.KeyBinding("F12", keybindings.CTHULHU_MODIFIER_MASK) + kb_right = keybindings.KeyBinding("Right", keybindings.NO_MODIFIER_MASK) + kb_left = keybindings.KeyBinding("Left", keybindings.NO_MODIFIER_MASK) + kb_right_ctrl = keybindings.KeyBinding("Right", keybindings.CTRL_MODIFIER_MASK) + kb_left_ctrl = keybindings.KeyBinding("Left", keybindings.CTRL_MODIFIER_MASK) + kb_down = keybindings.KeyBinding("Down", keybindings.NO_MODIFIER_MASK) + kb_up = keybindings.KeyBinding("Up", keybindings.NO_MODIFIER_MASK) + kb_end = keybindings.KeyBinding("End", keybindings.NO_MODIFIER_MASK) + kb_home = keybindings.KeyBinding("Home", keybindings.NO_MODIFIER_MASK) + kb_end_ctrl = keybindings.KeyBinding("End", keybindings.CTRL_MODIFIER_MASK) + kb_home_ctrl = keybindings.KeyBinding("Home", keybindings.CTRL_MODIFIER_MASK) + + manager.add_command( + command_manager.KeyboardCommand( + "toggle_enabled", + self.toggle_enabled, + group_label, + cmdnames.CARET_NAVIGATION_TOGGLE, + desktop_keybinding=kb_f12, + laptop_keybinding=kb_f12, + enabled=not self._suspended, + is_group_toggle=True, + ), + ) + + enabled = self.get_is_enabled() and not self._suspended + + # (name, function, description, keybinding) + commands_data = [ + ("next_character", self.next_character, cmdnames.CARET_NAVIGATION_NEXT_CHAR, kb_right), + ( + "previous_character", + self.previous_character, + cmdnames.CARET_NAVIGATION_PREV_CHAR, + kb_left, + ), + ("next_word", self.next_word, cmdnames.CARET_NAVIGATION_NEXT_WORD, kb_right_ctrl), + ( + "previous_word", + self.previous_word, + cmdnames.CARET_NAVIGATION_PREV_WORD, + kb_left_ctrl, + ), + ("next_line", self.next_line, cmdnames.CARET_NAVIGATION_NEXT_LINE, kb_down), + ("previous_line", self.previous_line, cmdnames.CARET_NAVIGATION_PREV_LINE, kb_up), + ( + "start_of_file", + self.start_of_file, + cmdnames.CARET_NAVIGATION_FILE_START, + kb_home_ctrl, + ), + ("end_of_file", self.end_of_file, cmdnames.CARET_NAVIGATION_FILE_END, kb_end_ctrl), + ("start_of_line", self.start_of_line, cmdnames.CARET_NAVIGATION_LINE_START, kb_home), + ("end_of_line", self.end_of_line, cmdnames.CARET_NAVIGATION_LINE_END, kb_end), + ] + + for name, function, description, kb in commands_data: + manager.add_command( + command_manager.KeyboardCommand( + name, + function, + group_label, + description, + desktop_keybinding=kb, + laptop_keybinding=kb, + enabled=enabled, + ), + ) + + manager.add_command( + command_manager.KeyboardCommand( + "toggle_layout_mode", + self.toggle_layout_mode, + group_label, + cmdnames.TOGGLE_LAYOUT_MODE, + enabled=enabled, + ), + ) + + msg = f"CARET NAVIGATOR: Commands set up. Suspended: {self._suspended}" + debug.print_message(debug.LEVEL_INFO, msg, True) + + def _is_active_script(self, script): + active_script = script_manager.get_manager().get_active_script() + if active_script == script: + return True + + tokens = ["CARET NAVIGATOR:", script, "is not the active script", active_script] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + @gsettings_registry.get_registry().gsetting( + key=KEY_ENABLED, + schema="caret-navigation", + gtype="b", + default=True, + summary="Enable caret navigation", + migration_key="caretNavigationEnabled", + ) + @dbus_service.getter + def get_is_enabled(self) -> bool: + """Returns whether caret navigation is enabled.""" + + return self._get_setting(self.KEY_ENABLED, True) + + @dbus_service.setter + def set_is_enabled(self, value: bool) -> bool: + """Sets whether caret navigation is enabled.""" + + if self.get_is_enabled() == value: + msg = f"CARET NAVIGATOR: Enabled already {value}. Refreshing command group." + debug.print_message(debug.LEVEL_INFO, msg, True) + command_manager.get_manager().set_group_enabled( + guilabels.KB_GROUP_CARET_NAVIGATION, + value, + ) + return True + + msg = f"CARET NAVIGATOR: Setting enabled to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value(self._SCHEMA, self.KEY_ENABLED, value) + + self._last_input_event = None + command_manager.get_manager().set_group_enabled(guilabels.KB_GROUP_CARET_NAVIGATION, value) + + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_TRIGGERS_FOCUS_MODE, + schema="caret-navigation", + gtype="b", + default=False, + summary="Caret navigation triggers focus mode", + migration_key="caretNavTriggersFocusMode", + ) + @dbus_service.getter + def get_triggers_focus_mode(self) -> bool: + """Returns whether caret navigation triggers focus mode.""" + + return self._get_setting(self.KEY_TRIGGERS_FOCUS_MODE, False) + + @dbus_service.setter + def set_triggers_focus_mode(self, value: bool) -> bool: + """Sets whether caret navigation triggers focus mode.""" + + if self.get_triggers_focus_mode() == value: + return True + + msg = f"CARET NAVIGATOR: Setting triggers focus mode to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_TRIGGERS_FOCUS_MODE, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_LAYOUT_MODE, + schema="caret-navigation", + gtype="b", + default=True, + summary="Use document layout mode", + migration_key="layoutMode", + ) + @dbus_service.getter + def get_layout_mode(self) -> bool: + """Returns whether layout mode is enabled.""" + + return self._get_setting(self.KEY_LAYOUT_MODE, True) + + @dbus_service.setter + def set_layout_mode(self, value: bool) -> bool: + """Sets whether layout mode is enabled.""" + + if self.get_layout_mode() == value: + return True + + msg = f"CARET NAVIGATOR: Setting layout mode to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, self.KEY_LAYOUT_MODE, value + ) + return True + + @dbus_service.command + def toggle_layout_mode( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Switches between object mode and layout mode for line presentation.""" + + tokens = [ + "CARET NAVIGATOR: toggle_layout_mode. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + layout_mode = not self.get_layout_mode() + if notify_user: + if layout_mode: + presentation_manager.get_manager().present_message(messages.MODE_LAYOUT) + else: + presentation_manager.get_manager().present_message(messages.MODE_OBJECT) + self.set_layout_mode(layout_mode) + return True + + def get_enabled_for_script(self, script: default.Script) -> bool: + """Returns the current caret-navigator enabled state associated with script.""" + + enabled = self._enabled_for_script.get(script, False) + tokens = ["CARET NAVIGATOR: Enabled state for", script, f"is {enabled}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return enabled + + def set_enabled_for_script(self, script: default.Script, enabled: bool) -> None: + """Sets the current caret-navigator enabled state associated with script.""" + + tokens = ["CARET NAVIGATOR: Setting enabled state for", script, f"to {enabled}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._enabled_for_script[script] = enabled + + if not (script and self._is_active_script(script)): + return + + # Use the per-script state combined with the user's preference to determine + # whether commands should be active, without overwriting the preference. + effective = enabled and self.get_is_enabled() + command_manager.get_manager().set_group_enabled( + guilabels.KB_GROUP_CARET_NAVIGATION, + effective, + ) + + def last_input_event_was_navigation_command(self) -> bool: + """Returns true if the last input event was a navigation command.""" + + if self._last_input_event is None: + return False + + manager = input_event_manager.get_manager() + result = manager.last_event_equals_or_is_release_for_event(self._last_input_event) + if self._last_input_event is not None: + string = self._last_input_event.as_single_line_string() + else: + string = "None" + + msg = f"CARET NAVIGATOR: Last navigation event ({string}) is last input event: {result}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return result + + def last_command_prevents_focus_mode(self) -> bool: + """Returns True if the last command was navigation but the setting disallows focus mode.""" + + if not self.last_input_event_was_navigation_command(): + return False + + return not self.get_triggers_focus_mode() + + @dbus_service.command + def toggle_enabled( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Toggles caret navigation.""" + + tokens = [ + "CARET NAVIGATOR: toggle_enabled. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + enabled = not command_manager.get_manager().is_group_enabled( + guilabels.KB_GROUP_CARET_NAVIGATION, + ) + if enabled: + string = messages.CARET_CONTROL_CTHULHU + else: + string = messages.CARET_CONTROL_APP + script.utilities.clear_caret_context() + + if notify_user: + presentation_manager.get_manager().present_message(string) + + self.set_is_enabled(enabled) + return True + + def suspend_commands(self, script: default.Script, suspended: bool, reason: str = "") -> None: + """Suspends caret navigation independent of the enabled setting.""" + + if not (script and self._is_active_script(script)): + return + + msg = f"CARET NAVIGATOR: Commands suspended: {suspended}" + if reason: + msg += f": {reason}" + debug.print_message(debug.LEVEL_INFO, msg, True) + + self._suspended = suspended + command_manager.get_manager().set_group_suspended( + guilabels.KB_GROUP_CARET_NAVIGATION, + suspended, + ) + + def _get_root_object( + self, + script: default.Script, + obj: Atspi.Accessible | None = None, + ) -> Atspi.Accessible | None: + """Returns the object which should be treated as the root/container for navigation.""" + + root = script.utilities.active_document() + if root is None: + if obj is None: + obj, _offset = script.utilities.get_caret_context() + if AXObject.supports_text(obj): + root = obj + + tokens = ["CARET NAVIGATOR: Root is", root] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return root + + def _is_navigable_object( + self, + script: default.Script, + obj: Atspi.Accessible, + root: Atspi.Accessible | None = None, + ) -> bool: + """Returns True if obj is a valid location for navigation.""" + + # There's a small, theoretical possibility that we can creep out of the logical container, + # but until that happens, this check is the most performant. + if AXObject.supports_text(obj): + return True + + if root is None: + root = self._get_root_object(script) + + if root is None: + return False + + return AXUtilities.is_ancestor(obj, root, True) + + def _line_contains_context( + self, + line: list[tuple[Atspi.Accessible, int, int, str]], + context: tuple[Atspi.Accessible, int], + ) -> bool: + """Returns True if line contains the (obj, offset) context.""" + + for entry in line: + line_obj, start, end = entry[0], entry[1], entry[2] + if line_obj == context[0] and start <= context[1] <= end: + return True + + return False + + def _get_start_of_file(self, script: default.Script) -> tuple[Atspi.Accessible | None, int]: + """Returns the start of the file as (obj, offset).""" + + root = self._get_root_object(script) + obj, offset = script.utilities.first_context(root, 0) + if obj is None: + return None, -1 + + while obj: + prev_obj, prev_offset = script.utilities.previous_context(obj, offset, restrict_to=root) + if prev_obj is None or (prev_obj, prev_offset) == (obj, offset): + break + obj, offset = prev_obj, prev_offset + + return obj, offset + + def _get_end_of_file(self, script: default.Script) -> tuple[Atspi.Accessible | None, int]: + """Returns the end of the file as (obj, offset).""" + + root = self._get_root_object(script) + obj = AXUtilities.find_deepest_descendant(root) + if obj is None: + return None, -1 + + offset = max(0, AXText.get_character_count(obj) - 1) + while obj: + next_obj, next_offset = script.utilities.next_context(obj, offset, restrict_to=root) + if next_obj is None or (next_obj, next_offset) == (obj, offset): + break + obj, offset = next_obj, next_offset + + return obj, offset + + @dbus_service.command + def next_character( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Moves to the next character.""" + + tokens = [ + "CARET NAVIGATOR: next_character. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + obj, offset = script.utilities.next_context() + if not self._is_navigable_object(script, obj): + return False + + self._last_input_event = event + presentation_manager.get_manager().interrupt_presentation() + script.utilities.set_caret_position(obj, offset) + focus_manager.get_manager().emit_region_changed( + obj, + start_offset=offset, + mode=focus_manager.CARET_NAVIGATOR, + ) + if not notify_user: + return True + + script.update_braille(obj, offset=offset) + script.say_character(obj) + return True + + @dbus_service.command + def previous_character( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Moves to the previous character.""" + + tokens = [ + "CARET NAVIGATOR: previous_character. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + obj, offset = script.utilities.previous_context() + if not self._is_navigable_object(script, obj): + return False + + self._last_input_event = event + presentation_manager.get_manager().interrupt_presentation() + script.utilities.set_caret_position(obj, offset) + focus_manager.get_manager().emit_region_changed( + obj, + start_offset=offset, + mode=focus_manager.CARET_NAVIGATOR, + ) + if not notify_user: + return True + + script.update_braille(obj, offset=offset) + script.say_character(obj) + return True + + @dbus_service.command + def next_word( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Moves to the next word.""" + + tokens = [ + "CARET NAVIGATOR: next_word. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + obj, offset = script.utilities.next_context(skip_space=True) + if obj is None: + return False + + contents = script.utilities.get_word_contents_at_offset(obj, offset) + if not contents: + return False + + # If the "word" to the right consists of the content of the last word in an embedded + # object followed by the space of the parent object, the normal space-adjustment we + # do will cause us to set the caret to the offset with the embedded child and then + # present the first word in that child. + if len(contents) > 1 and contents[-1][3].isspace(): + msg = "CARET NAVIGATOR: Adjusting next word contents to eliminate trailing space." + debug.print_message(debug.LEVEL_INFO, msg, True) + contents = contents[:-1] + + obj, start, end, string = contents[-1] + if not self._is_navigable_object(script, obj): + return False + + if string and string[-1].isspace(): + end -= 1 + + self._last_input_event = event + presentation_manager.get_manager().interrupt_presentation() + script.utilities.set_caret_position(obj, end) + focus_manager.get_manager().emit_region_changed( + obj, + start, + end, + focus_manager.CARET_NAVIGATOR, + ) + if not notify_user: + return True + + script.update_braille(obj, offset=end) + script.say_word(obj) + return True + + @dbus_service.command + def previous_word( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Moves to the previous word.""" + + tokens = [ + "CARET NAVIGATOR: previous_word. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + obj, offset = script.utilities.previous_context(skip_space=True) + if obj is None: + return False + + contents = script.utilities.get_word_contents_at_offset(obj, offset) + if not contents: + return False + + obj, start, end, _string = contents[0] + if not self._is_navigable_object(script, obj): + return False + + self._last_input_event = event + presentation_manager.get_manager().interrupt_presentation() + script.utilities.set_caret_position(obj, start) + focus_manager.get_manager().emit_region_changed( + obj, + start, + end, + focus_manager.CARET_NAVIGATOR, + ) + + if not notify_user: + return True + + script.update_braille(obj, offset=start) + script.say_word(obj) + return True + + @dbus_service.command + def next_line( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Moves to the next line.""" + + tokens = [ + "CARET NAVIGATOR: next_line. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if ( + focus_manager.get_manager().in_say_all() + and say_all_presenter.get_presenter().get_rewind_and_fast_forward_enabled() + ): + msg = "CARET NAVIGATOR: In say all and rewind/fast-forward is enabled" + debug.print_message(debug.LEVEL_INFO, msg) + return True + + obj, offset = script.utilities.get_caret_context() + if obj is None: + return False + + line = script.utilities.get_line_contents_at_offset(obj, offset) + if not (line and line[0]): + return False + + contents = script.utilities.get_next_line_contents() + if not contents: + last_obj, last_offset = self._get_end_of_file(script) + if self._line_contains_context(line, (last_obj, last_offset)): + msg = "CARET NAVIGATOR: At end of document; cannot move to next line." + debug.print_message(debug.LEVEL_INFO, msg) + contents = line + + if not contents: + return False + + if not self._is_navigable_object(script, obj): + return False + + self._last_input_event = event + presentation_manager.get_manager().interrupt_presentation() + + if line != contents: + obj, offset, end, _string = contents[0] + else: + obj, offset, end, _string = contents[-1] + + script.utilities.set_caret_position(obj, offset) + focus_manager.get_manager().emit_region_changed( + obj, + offset, + end, + focus_manager.CARET_NAVIGATOR, + ) + + if notify_user: + # Setting the last object on the current line as priorObj + # prevents re-announcing context. + presenter = presentation_manager.get_manager() + presenter.speak_contents(contents, priorObj=line[-1][0]) + presenter.display_contents(contents) + return True + + @dbus_service.command + def previous_line( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Moves to the previous line.""" + + tokens = [ + "CARET NAVIGATOR: previous_line. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if ( + focus_manager.get_manager().in_say_all() + and say_all_presenter.get_presenter().get_rewind_and_fast_forward_enabled() + ): + msg = "CARET NAVIGATOR: In say all and rewind/fast-forward is enabled" + debug.print_message(debug.LEVEL_INFO, msg) + return True + + obj, offset = script.utilities.get_caret_context() + if obj is None: + return False + + line = script.utilities.get_line_contents_at_offset(obj, offset) + if not (line and line[0]): + return False + + contents = script.utilities.get_previous_line_contents(obj, offset) + if not contents: + first_obj, first_offset = self._get_start_of_file(script) + if self._line_contains_context(line, (first_obj, first_offset)): + msg = "CARET NAVIGATOR: At start of document; cannot move to previous line." + debug.print_message(debug.LEVEL_INFO, msg) + contents = line + + if not contents: + return False + + obj, start, end, _string = contents[0] + if not self._is_navigable_object(script, obj): + return False + + self._last_input_event = event + presentation_manager.get_manager().interrupt_presentation() + script.utilities.set_caret_position(obj, start) + focus_manager.get_manager().emit_region_changed( + obj, + start, + end, + focus_manager.CARET_NAVIGATOR, + ) + + if notify_user: + # Setting the first object on the current line as priorObj + # prevents re-announcing context. + presenter = presentation_manager.get_manager() + presenter.speak_contents(contents, priorObj=line[0][0]) + presenter.display_contents(contents) + return True + + @dbus_service.command + def start_of_line( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Moves to the start of the line.""" + + tokens = [ + "CARET NAVIGATOR: start_of_line. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + obj, offset = script.utilities.get_caret_context() + line = script.utilities.get_line_contents_at_offset(obj, offset) + if not (line and line[0]): + return False + + self._last_input_event = event + obj, start, end, _string = line[0] + presentation_manager.get_manager().interrupt_presentation() + script.utilities.set_caret_position(obj, start) + focus_manager.get_manager().emit_region_changed( + obj, + start, + end, + focus_manager.CARET_NAVIGATOR, + ) + + if not notify_user: + return True + + script.say_character(obj) + presentation_manager.get_manager().display_contents(line) + return True + + @dbus_service.command + def end_of_line( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Moves to the end of the line.""" + + tokens = [ + "CARET NAVIGATOR: end_of_line. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + obj, offset = script.utilities.get_caret_context() + line = script.utilities.get_line_contents_at_offset(obj, offset) + if not (line and line[0]): + return False + + obj, start, end, string = line[-1] + if string.strip() and string[-1].isspace(): + end -= 1 + + self._last_input_event = event + presentation_manager.get_manager().interrupt_presentation() + script.utilities.set_caret_position(obj, end) + focus_manager.get_manager().emit_region_changed( + obj, + start, + end, + focus_manager.CARET_NAVIGATOR, + ) + + if not notify_user: + return True + + script.say_character(obj) + presentation_manager.get_manager().display_contents(line) + return True + + @dbus_service.command + def start_of_file( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Moves to the start of the file.""" + + tokens = [ + "CARET NAVIGATOR: start_of_file. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + obj, start = self._get_start_of_file(script) + if obj is None: + return False + + contents = script.utilities.get_line_contents_at_offset(obj, start) + if not contents: + return False + + self._last_input_event = event + obj, start, end, _string = contents[0] + presentation_manager.get_manager().interrupt_presentation() + script.utilities.set_caret_position(obj, start) + focus_manager.get_manager().emit_region_changed( + obj, + start, + end, + focus_manager.CARET_NAVIGATOR, + ) + + if not notify_user: + return True + + presenter = presentation_manager.get_manager() + presenter.speak_contents(contents) + presenter.display_contents(contents) + return True + + @dbus_service.command + def end_of_file( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Moves to the end of the file.""" + + tokens = [ + "CARET NAVIGATOR: end_of_file. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + obj, end = self._get_end_of_file(script) + if obj is None: + return False + + contents = script.utilities.get_line_contents_at_offset(obj, end) + if not contents: + return False + + self._last_input_event = event + obj, start, end, _string = contents[-1] + presentation_manager.get_manager().interrupt_presentation() + script.utilities.set_caret_position(obj, end) + focus_manager.get_manager().emit_region_changed( + obj, + start, + end, + focus_manager.CARET_NAVIGATOR, + ) + if not notify_user: + return True + + presenter = presentation_manager.get_manager() + presenter.speak_contents(contents) + presenter.display_contents(contents) + return True + + +_navigator = CaretNavigator() + + +def get_navigator() -> CaretNavigator: + """Returns the Caret Navigator.""" + + return _navigator diff --git a/src/cthulhu/chat_presenter.py b/src/cthulhu/chat_presenter.py new file mode 100644 index 0000000..a3aa53a --- /dev/null +++ b/src/cthulhu/chat_presenter.py @@ -0,0 +1,947 @@ +# Cthulhu +# +# 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. + +"""Implements generic chat support.""" + +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass +from enum import Enum +from typing import TYPE_CHECKING + +from . import ( + cmdnames, + command_manager, + dbus_service, + debug, + focus_manager, + gsettings_registry, + guilabels, + input_event, + input_event_manager, + messages, + preferences_grid_base, + presentation_manager, + script_manager, +) +from .ax_object import AXObject +from .ax_selection import AXSelection +from .ax_text import AXText +from .ax_utilities import AXUtilities + +if TYPE_CHECKING: + from collections.abc import Iterator + + from gi.repository import Atspi + + from .scripts import default + + +@gsettings_registry.get_registry().gsettings_enum( + "org.stormux.Cthulhu.ChatMessageVerbosity", + values={"all": 0, "all-if-focused": 1, "focused-channel": 2, "active-channel": 3}, +) +class ChatMessageVerbosity(Enum): + """Chat message verbosity level enumeration.""" + + ALL_ANY_APP = 0 + ALL_ACTIVE_APP = 1 + CURRENT_ACTIVE_APP = 2 + CURRENT_ANY_APP = 3 + + @property + def string_name(self) -> str: + """Returns the lowercase string name for this enum value.""" + + return self.name.lower().replace("_", "-") + + +class Conversation: + """Represents a conversation or chat room.""" + + HISTORY_SIZE = 9 + + def __init__(self, name: str, log: Atspi.Accessible) -> None: + self._name = name + self._log = log + self._messages: deque[str] = deque(maxlen=Conversation.HISTORY_SIZE) + self._typing_status = "" + + def get_name(self) -> str: + """Returns the conversation name.""" + + return self._name + + def get_log(self) -> Atspi.Accessible: + """Returns the conversation log accessible.""" + + return self._log + + def is_log(self, obj: Atspi.Accessible) -> bool: + """Returns true if obj is the conversation log.""" + + return self._log == obj + + def add_message(self, message: str) -> None: + """Adds the current message to the message history.""" + + self._messages.append(message) + + def get_message(self, index: int) -> str: + """Returns the indexed message from the message history.""" + + return self._messages[index] + + def has_messages(self) -> bool: + """Returns True if there are any messages in the history.""" + + return len(self._messages) > 0 + + def get_message_count(self) -> int: + """Returns the number of messages in the history.""" + + return len(self._messages) + + def get_typing_status(self) -> str: + """Returns the typing status of the buddy in this conversation.""" + + return self._typing_status + + def set_typing_status(self, status: str) -> None: + """Sets the typing status of the buddy in this conversation.""" + + self._typing_status = status + + +@dataclass +class Message: + """Represents a chat message with its associated conversation.""" + + text: str + conversation: Conversation | None + + +class ConversationList: + """Represents a list of Conversations.""" + + def __init__(self) -> None: + self._conversations: list[Conversation] = [] + self._messages: deque[Message] = deque(maxlen=Conversation.HISTORY_SIZE) + + def __iter__(self) -> Iterator[Conversation]: + """Allows iteration over conversations.""" + + return iter(self._conversations) + + def add_message(self, message: str, conversation: Conversation | None) -> None: + """Adds the current message to the message history.""" + + if conversation and conversation not in self._conversations: + self._conversations.append(conversation) + + msg = Message(text=message, conversation=conversation) + self._messages.append(msg) + + def get_message_and_name(self, index: int) -> tuple[str, str, Atspi.Accessible | None]: + """Returns the indexed message, room-name, and log from the message history.""" + + msg = self._messages[index] + if msg.conversation: + return msg.text, msg.conversation.get_name(), msg.conversation.get_log() + return msg.text, "", None + + def has_messages(self) -> bool: + """Returns True if there are any messages in the history.""" + + return len(self._messages) > 0 + + def get_message_count(self) -> int: + """Returns the number of messages in the history.""" + + return len(self._messages) + + +class Chat: + """Provides chat state and detection helpers for chat apps.""" + + def __init__(self, script: default.Script) -> None: + self._script = script + self._conversation_list = ConversationList() + self._current_index = Conversation.HISTORY_SIZE # Sentinel for "not navigating" + + def get_current_index(self) -> int: + """Returns the current message navigation index.""" + + return self._current_index + + def set_current_index(self, value: int) -> None: + """Sets the current message navigation index.""" + + self._current_index = value + + def get_message_count(self) -> int: + """Returns the message count based on current history setting.""" + + if get_presenter().get_room_histories(): + conversation = self.get_conversation_for_object( + focus_manager.get_manager().get_locus_of_focus(), + ) + if conversation: + return conversation.get_message_count() + return 0 + return self._conversation_list.get_message_count() + + def get_message_and_name(self, index: int) -> tuple[str, str, Atspi.Accessible | None]: + """Returns the indexed message, room name, and log from the conversation list.""" + + return self._conversation_list.get_message_and_name(index) + + def add_message(self, message: str, conversation: Conversation) -> None: + """Adds a message to the conversation list.""" + + self._conversation_list.add_message(message, conversation) + + def _is_scrollable_list(self, obj: Atspi.Accessible) -> bool: + """Returns True if obj is a list-like scrollable widget.""" + + scroll_pane = AXUtilities.find_ancestor(obj, AXUtilities.is_scroll_pane) + if not scroll_pane: + return False + + return ( + AXUtilities.is_tree_or_tree_table(obj) + or AXUtilities.is_list_box(obj) + or AXUtilities.is_list(obj) + ) + + def is_buddy_list(self, obj: Atspi.Accessible) -> bool: + """Returns True if obj is believed to be the buddy list.""" + + # Note: This is a very simple heuristic based on existing chat apps. + # Subclasses can override this function. + + if not self._is_scrollable_list(obj): + return False + + if AXUtilities.find_ancestor(obj, AXUtilities.is_frame) is None: + return False + + tokens = ["CHAT:", obj, "believed to be buddy list."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return True + + def is_in_buddy_list(self, obj: Atspi.Accessible) -> bool: + """Returns True if obj is, or is inside of, the buddy list.""" + + if self.is_buddy_list(obj): + return True + + buddy_list = AXUtilities.find_ancestor(obj, self._is_scrollable_list) + if buddy_list is None: + return False + + return self.is_buddy_list(buddy_list) + + def get_conversation_for_object(self, obj: Atspi.Accessible) -> Conversation | None: + """Attempts to locate the conversation associated with obj.""" + + if obj is None: + return None + + name = self.get_chat_room_name(obj) + for conversation in self._conversation_list: + if name: + if name == conversation.get_name(): + return conversation + elif conversation.is_log(obj): + return conversation + + return None + + def is_chat_room_message(self, obj: Atspi.Accessible) -> bool: + """Returns True if obj holds a chat room conversation.""" + + if AXUtilities.is_text(obj) and AXUtilities.is_scroll_pane(AXObject.get_parent(obj)): + return not AXUtilities.is_editable(obj) and AXUtilities.is_multi_line(obj) + return False + + def is_current_channel_in_active_app(self, obj: Atspi.Accessible) -> bool: + """Returns True if obj is from the active chat room in the focused window.""" + + if not self.is_active_channel(obj): + return False + return self._script.utilities.top_level_object_is_active_and_current(obj) + + def is_active_channel(self, obj: Atspi.Accessible) -> bool: + """Returns True if obj is in the active/selected channel.""" + + if page_tab := AXUtilities.find_ancestor(obj, AXUtilities.is_page_tab): + tab_list = AXObject.get_parent(page_tab) + selected = AXSelection.get_selected_child(tab_list, 0) + result = selected == page_tab + tokens = [ + "CHAT:", + obj, + "tab:", + page_tab, + "selected tab:", + selected, + "is active channel:", + result, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + if AXUtilities.is_showing(obj): + tokens = ["CHAT:", obj, "is in active channel (showing)"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return True + + tokens = ["CHAT:", obj, "is not in active channel (not showing, no tab)"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + def get_chat_room_name(self, obj: Atspi.Accessible) -> str: + """Attempts to find the name of the current chat room.""" + + def pred(x: Atspi.Accessible) -> bool: + if not (AXUtilities.is_page_tab(x) or AXUtilities.is_frame(x)): + return False + return bool(AXObject.get_name(x)) + + ancestor = AXUtilities.find_ancestor(obj, pred) + if ancestor: + return AXObject.get_name(ancestor) + return "" + + def is_auto_completed_text_event(self, event: Atspi.Event) -> bool: + """Returns True if event is associated with text being autocompleted.""" + + if not AXUtilities.is_text(event.source): + return False + + return ( + input_event_manager.get_manager().last_event_was_tab() + and event.any_data + and event.any_data != "\t" + ) + + def is_typing_status_changed_event(self, event: Atspi.Event) -> bool: + """Returns True if event is associated with a change in typing status.""" + + if not event.type.startswith("object:text-changed:insert"): + return False + + # TODO - JD: This is from 15 years ago. Who knows if it still works? + # Bit of a hack. Pidgin inserts text into the chat history when the + # user is typing. We seem able to (more or less) reliably distinguish + # this text via its attributes because these attributes are absent + # from user inserted text -- no matter how that text is formatted. + attr = AXText.get_text_attributes_at_offset(event.source, event.detail1)[0] + return float(attr.get("scale", "1")) < 1 or int(attr.get("weight", "400")) < 400 + + +class ChatPreferencesGrid(preferences_grid_base.AutoPreferencesGrid): + """Preferences grid for Chat settings.""" + + _gsettings_schema = "chat" + + def __init__(self, presenter: ChatPresenter) -> None: + options = [ + guilabels.CHAT_SPEAK_MESSAGES_ALL, + guilabels.CHAT_SPEAK_MESSAGES_ACTIVE_CHANNEL, + guilabels.CHAT_SPEAK_MESSAGES_ALL_IF_FOCUSED, + guilabels.CHAT_SPEAK_MESSAGES_ACTIVE, + ] + values = [ + ChatMessageVerbosity.ALL_ANY_APP.value, + ChatMessageVerbosity.CURRENT_ANY_APP.value, + ChatMessageVerbosity.ALL_ACTIVE_APP.value, + ChatMessageVerbosity.CURRENT_ACTIVE_APP.value, + ] + + controls: list[ + preferences_grid_base.BooleanPreferenceControl + | preferences_grid_base.SelectionPreferenceControl + ] = [ + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.CHAT_SPEAK_ROOM_NAME, + getter=presenter.get_speak_room_name, + setter=presenter.set_speak_room_name, + prefs_key=ChatPresenter.KEY_SPEAK_ROOM_NAME, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.CHAT_SPEAK_ROOM_NAME_LAST, + getter=presenter.get_speak_room_name_last, + setter=presenter.set_speak_room_name_last, + prefs_key=ChatPresenter.KEY_SPEAK_ROOM_NAME_LAST, + determine_sensitivity=presenter.get_speak_room_name, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.CHAT_ANNOUNCE_BUDDY_TYPING, + getter=presenter.get_announce_buddy_typing, + setter=presenter.set_announce_buddy_typing, + prefs_key=ChatPresenter.KEY_ANNOUNCE_BUDDY_TYPING, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.CHAT_SEPARATE_MESSAGE_HISTORIES, + getter=presenter.get_room_histories, + setter=presenter.set_room_histories, + prefs_key=ChatPresenter.KEY_ROOM_HISTORIES, + ), + preferences_grid_base.SelectionPreferenceControl( + label=guilabels.CHAT_SPEAK_MESSAGES_FROM, + options=options, + values=values, + getter=presenter.get_message_verbosity, + setter=presenter.set_message_verbosity, + prefs_key=ChatPresenter.KEY_MESSAGE_VERBOSITY, + ), + ] + + super().__init__(guilabels.KB_GROUP_CHAT, controls) + + +@gsettings_registry.get_registry().gsettings_schema("org.stormux.Cthulhu.Chat", name="chat") +class ChatPresenter: + """Presenter for chat preferences and commands.""" + + _SCHEMA = "chat" + KEY_SPEAK_ROOM_NAME = "speak-room-name" + KEY_SPEAK_ROOM_NAME_LAST = "speak-room-name-last" + KEY_ANNOUNCE_BUDDY_TYPING = "announce-buddy-typing" + KEY_ROOM_HISTORIES = "room-histories" + KEY_MESSAGE_VERBOSITY = "message-verbosity" + + 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._initialized: bool = False + msg = "CHAT PRESENTER: Registering D-Bus commands." + debug.print_message(debug.LEVEL_INFO, msg, True) + controller = dbus_service.get_remote_controller() + controller.register_decorated_module("ChatPresenter", 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_CHAT + + commands_data = [ + ( + "chat_toggle_room_name_prefix", + self.toggle_prefix, + cmdnames.CHAT_TOGGLE_ROOM_NAME_PREFIX, + ), + ( + "chat_toggle_buddy_typing", + self.toggle_buddy_typing, + cmdnames.CHAT_TOGGLE_BUDDY_TYPING, + ), + ( + "chat_toggle_message_histories", + self.toggle_message_histories, + cmdnames.CHAT_TOGGLE_MESSAGE_HISTORIES, + ), + ( + "chat_previous_message", + self.present_previous_message, + cmdnames.CHAT_PREVIOUS_MESSAGE, + ), + ("chat_next_message", self.present_next_message, cmdnames.CHAT_NEXT_MESSAGE), + ] + + for name, function, description in commands_data: + manager.add_command( + command_manager.KeyboardCommand( + name, + function, + group_label, + description, + desktop_keybinding=None, + laptop_keybinding=None, + ), + ) + + msg = "CHAT PRESENTER: Commands set up." + debug.print_message(debug.LEVEL_INFO, msg, True) + + def create_preferences_grid(self) -> ChatPreferencesGrid: + """Create and return the chat preferences grid.""" + + return ChatPreferencesGrid(self) + + def utter_message( # pylint: disable=too-many-arguments,too-many-positional-arguments + self, + script: default.Script, + obj: Atspi.Accessible, + room_name: str, + message: str, + focused: bool = True, + active_channel: bool = True, + ) -> None: + """Speak/braille a chat room message, taking user settings into account.""" + + verbosity = self.get_message_verbosity(script.app) + active_script = script_manager.get_manager().get_active_script() + if ( + active_script is not None + and active_script.name != script.name + and verbosity == ChatMessageVerbosity.ALL_ACTIVE_APP.value + ): + return + if not focused and verbosity == ChatMessageVerbosity.CURRENT_ACTIVE_APP.value: + return + if not active_channel and verbosity == ChatMessageVerbosity.CURRENT_ANY_APP.value: + return + + text = "" + if room_name and self.get_speak_room_name(): + text = messages.CHAT_MESSAGE_FROM_ROOM % room_name + + if not self.get_speak_room_name_last(): + text = f"{text} {message}" + else: + text = f"{message} {text}" + + if text.strip(): + presentation_manager.get_manager().speak_accessible_text(obj, text) + presentation_manager.get_manager().present_braille_message(text) + + def present_message_at_index(self, script: default.Script, index: int) -> None: + """Presents the chat message at the specified index.""" + + chat = script.chat + if self.get_room_histories(): + conversation = chat.get_conversation_for_object( + focus_manager.get_manager().get_locus_of_focus(), + ) + if not conversation: + return + message = conversation.get_message(index) + chat_room_name = conversation.get_name() + log: Atspi.Accessible | None = conversation.get_log() + else: + message, chat_room_name, log = chat.get_message_and_name(index) + + if message and chat_room_name and log: + self.utter_message(script, log, chat_room_name, message, True) + + def present_inserted_text(self, script: default.Script, event: Atspi.Event) -> bool: + """Presents text inserted into a chat application.""" + + chat = script.chat + if not event.any_data or AXUtilities.is_text_input(event.source): + return False + + if chat.is_in_buddy_list(event.source): + return True + + if chat.is_typing_status_changed_event(event): + self._present_typing_status_change(script, event, event.any_data) + return True + + if chat.is_chat_room_message(event.source): + conversation = chat.get_conversation_for_object(event.source) + if conversation is None: + name = chat.get_chat_room_name(event.source) + conversation = Conversation(name, event.source) + name = conversation.get_name() + message = event.any_data.strip("\n") + if message: + conversation.add_message(message) + chat.add_message(message, conversation) + + focused = chat.is_current_channel_in_active_app(event.source) + active_channel = chat.is_active_channel(event.source) + if focused: + name = "" + if message: + self.utter_message(script, event.source, name, message, focused, active_channel) + return True + + if chat.is_auto_completed_text_event(event): + text = event.any_data + presentation_manager.get_manager().speak_accessible_text(event.source, text) + return True + + return False + + def _present_typing_status_change( + self, + script: default.Script, + event: Atspi.Event, + status: str, + ) -> bool: + """Presents a change in typing status for the current conversation.""" + + if not self.get_announce_buddy_typing(): + return False + + chat = script.chat + conversation = chat.get_conversation_for_object(event.source) + if conversation and status != conversation.get_typing_status(): + presentation_manager.get_manager().speak_accessible_text(event.source, status) + conversation.set_typing_status(status) + return True + + return False + + @dbus_service.command + def present_previous_message( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Navigate to and present the previous chat message in the history.""" + + tokens = [ + "CHAT PRESENTER: present_previous_message. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + chat = script.chat + message_count = chat.get_message_count() + if message_count == 0: + if notify_user: + presentation_manager.get_manager().present_message(messages.CHAT_NO_MESSAGES) + return True + + oldest_index = -message_count + current_index = chat.get_current_index() + + if current_index < oldest_index: + current_index = oldest_index + chat.set_current_index(current_index) + + if current_index == oldest_index: + if notify_user: + presentation_manager.get_manager().present_message(messages.CHAT_LIST_TOP) + self.present_message_at_index(script, oldest_index) + return True + + if current_index >= 0: + chat.set_current_index(-1) + else: + chat.set_current_index(current_index - 1) + + self.present_message_at_index(script, chat.get_current_index()) + return True + + @dbus_service.command + def present_next_message( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Navigate to and present the next chat message in the history.""" + + tokens = [ + "CHAT PRESENTER: present_next_message. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + chat = script.chat + message_count = chat.get_message_count() + if message_count == 0: + if notify_user: + presentation_manager.get_manager().present_message(messages.CHAT_NO_MESSAGES) + return True + + oldest_index = -message_count + current_index = chat.get_current_index() + + if current_index < oldest_index: + current_index = oldest_index + chat.set_current_index(current_index) + + if current_index == -1: + if notify_user: + presentation_manager.get_manager().present_message(messages.CHAT_LIST_BOTTOM) + self.present_message_at_index(script, -1) + return True + + if current_index >= 0: + chat.set_current_index(-1) + else: + chat.set_current_index(current_index + 1) + + self.present_message_at_index(script, chat.get_current_index()) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_SPEAK_ROOM_NAME, + schema="chat", + gtype="b", + default=False, + summary="Speak chat room name", + migration_key="chatSpeakRoomName", + ) + @dbus_service.getter + def get_speak_room_name(self, app: Atspi.Accessible | None = None) -> bool: + """Returns whether to speak the chat room name.""" + + app_name = AXObject.get_name(app) if app else None + return gsettings_registry.get_registry().layered_lookup( + self._SCHEMA, + self.KEY_SPEAK_ROOM_NAME, + "b", + app_name=app_name, + default=False, + ) + + @dbus_service.setter + def set_speak_room_name(self, value: bool) -> bool: + """Sets whether to speak the chat room name.""" + + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, self.KEY_SPEAK_ROOM_NAME, value + ) + return value + + @gsettings_registry.get_registry().gsetting( + key=KEY_ANNOUNCE_BUDDY_TYPING, + schema="chat", + gtype="b", + default=False, + summary="Announce when buddies are typing", + migration_key="chatAnnounceBuddyTyping", + ) + @dbus_service.getter + def get_announce_buddy_typing(self) -> bool: + """Returns whether to announce when buddies are typing.""" + + return self._get_setting(self.KEY_ANNOUNCE_BUDDY_TYPING, False) + + @dbus_service.setter + def set_announce_buddy_typing(self, value: bool) -> bool: + """Sets whether to announce when buddies are typing.""" + + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_ANNOUNCE_BUDDY_TYPING, + value, + ) + return value + + @gsettings_registry.get_registry().gsetting( + key=KEY_ROOM_HISTORIES, + schema="chat", + gtype="b", + default=False, + summary="Provide chat room specific message histories", + migration_key="chatRoomHistories", + ) + @dbus_service.getter + def get_room_histories(self) -> bool: + """Returns whether to provide chat room specific message histories.""" + + return self._get_setting(self.KEY_ROOM_HISTORIES, False) + + @dbus_service.setter + def set_room_histories(self, value: bool) -> bool: + """Sets whether to provide chat room specific message histories.""" + + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, self.KEY_ROOM_HISTORIES, value + ) + return value + + @gsettings_registry.get_registry().gsetting( + key=KEY_MESSAGE_VERBOSITY, + schema="chat", + genum="org.stormux.Cthulhu.ChatMessageVerbosity", + default="all", + summary="Chat message verbosity (all, all-if-focused, focused-channel, active-channel)", + migration_key="chatMessageVerbosity", + ) + @dbus_service.getter + def get_message_verbosity(self, app: Atspi.Accessible | None = None) -> int: + """Returns the chat message verbosity setting.""" + + app_name = AXObject.get_name(app) if app else None + nick = gsettings_registry.get_registry().layered_lookup( + self._SCHEMA, + self.KEY_MESSAGE_VERBOSITY, + "", + genum="org.stormux.Cthulhu.ChatMessageVerbosity", + app_name=app_name, + default="all", + ) + enum_values = gsettings_registry.get_registry().get_enum_values( + "org.stormux.Cthulhu.ChatMessageVerbosity", + ) + if enum_values and nick in enum_values: + return enum_values[nick] + return 0 + + @dbus_service.setter + def set_message_verbosity(self, value: int) -> int: + """Sets the chat message verbosity setting.""" + + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_MESSAGE_VERBOSITY, + value, + ) + return value + + @gsettings_registry.get_registry().gsetting( + key=KEY_SPEAK_ROOM_NAME_LAST, + schema="chat", + gtype="b", + default=False, + summary="Speak chat room name after message", + migration_key="presentChatRoomLast", + ) + @dbus_service.getter + def get_speak_room_name_last(self) -> bool: + """Returns whether to speak the chat room name after the message.""" + + return self._get_setting(self.KEY_SPEAK_ROOM_NAME_LAST, False) + + @dbus_service.setter + def set_speak_room_name_last(self, value: bool) -> bool: + """Sets whether to speak the chat room name after the message.""" + + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_SPEAK_ROOM_NAME_LAST, + value, + ) + return value + + @dbus_service.command + def toggle_prefix( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Toggles whether we prefix chat room messages with the name of the chat room.""" + + tokens = [ + "CHAT PRESENTER: toggle_prefix. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + line = messages.CHAT_ROOM_NAME_PREFIX_ON + speak_room_name = self.get_speak_room_name() + self.set_speak_room_name(not speak_room_name) + if speak_room_name: + line = messages.CHAT_ROOM_NAME_PREFIX_OFF + if notify_user: + presentation_manager.get_manager().present_message(line) + return True + + @dbus_service.command + def toggle_buddy_typing( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Toggles whether we announce when our buddies are typing a message.""" + + tokens = [ + "CHAT PRESENTER: toggle_buddy_typing. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + line = messages.CHAT_BUDDY_TYPING_ON + announce_typing = self.get_announce_buddy_typing() + self.set_announce_buddy_typing(not announce_typing) + if announce_typing: + line = messages.CHAT_BUDDY_TYPING_OFF + if notify_user: + presentation_manager.get_manager().present_message(line) + return True + + @dbus_service.command + def toggle_message_histories( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Toggles whether we provide chat room specific message histories.""" + + tokens = [ + "CHAT PRESENTER: toggle_message_histories. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + line = messages.CHAT_SEPARATE_HISTORIES_ON + room_histories = self.get_room_histories() + self.set_room_histories(not room_histories) + if room_histories: + line = messages.CHAT_SEPARATE_HISTORIES_OFF + if notify_user: + presentation_manager.get_manager().present_message(line) + return True + + +_presenter: ChatPresenter = ChatPresenter() + + +def get_presenter() -> ChatPresenter: + """Returns the Chat Presenter.""" + + return _presenter diff --git a/src/cthulhu/cthulhu_gui_prefs.py b/src/cthulhu/cthulhu_gui_prefs.py index 6ecfa54..1c39d03 100644 --- a/src/cthulhu/cthulhu_gui_prefs.py +++ b/src/cthulhu/cthulhu_gui_prefs.py @@ -1,9 +1,7 @@ -#!/usr/bin/env python3 +# Cthulhu # -# Copyright (c) 2024 Stormux -# Copyright (c) 2010-2012 The Orca Team -# Copyright (c) 2012 Igalia, S.L. -# Copyright (c) 2005-2010 Sun Microsystems Inc. +# Copyright 2005-2009 Sun Microsystems Inc. +# Copyright 2011-2026 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 @@ -19,5483 +17,1000 @@ # 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 + +# pylint: disable=no-member +# pylint: disable=too-many-locals +# pylint: disable=too-many-statements +# pylint: disable=too-many-lines """Displays a GUI for the user to set Cthulhu preferences.""" -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2005-2009 Sun Microsystems Inc." -__license__ = "LGPL" - -import gi -gi.require_version("Atspi", "2.0") -gi.require_version("Gdk", "3.0") -gi.require_version("Gtk", "3.0") -from gi.repository import Atspi +from __future__ import annotations import os -import json -import subprocess -import sys -import threading -from gi.repository import Gdk -from gi.repository import GLib -from gi.repository import Gtk -from gi.repository import GObject -from gi.repository import Pango import time +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any -from . import acss -from . import debug -from . import event_manager -from . import guilabels -from . import messages -from . import cthulhu -from . import cthulhu_gtkbuilder -from . import cthulhu_gui_profile -from . import cthulhu_state -from . import settings -from . import settings_manager -from . import sound_sink -from . import input_event -from . import input_event_manager -from . import keybindings -from . import pronunciation_dict -from . import braille -from . import speech -from . import speechserver -from . import text_attribute_names -from . import sound_theme_manager -from . import script_manager +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import ( + Gdk, # pylint: disable=no-name-in-module + Gio, + GLib, + GObject, + Gtk, # pylint: disable=no-name-in-module +) + +from . import ( + braille_presenter, + chat_presenter, + command_manager, + debug, + document_presenter, + event_manager, + focus_manager, + gsettings_registry, + guilabels, + learn_mode_presenter, + messages, + mouse_review, + cthulhu, + preferences_grid_base, + presentation_manager, + profile_manager, + pronunciation_dictionary_manager, + say_all_presenter, + sound_presenter, + speech_presenter, + spellcheck_presenter, + system_information_presenter, + text_attribute_manager, + typing_echo_presenter, +) from .ax_object import AXObject -from .ax_utilities import AXUtilities -_settingsManager = None # Removed - use cthulhu.cthulhuApp.settingsManager +if TYPE_CHECKING: + from .scripts import default -try: - import louis -except ImportError: - louis = None -from .cthulhu_platform import tablesdir -if louis and not tablesdir: - louis = None -(HANDLER, DESCRIP, MOD_MASK1, MOD_USED1, KEY1, CLICK_COUNT1, OLDTEXT1, \ - TEXT1, MODIF, EDITABLE) = list(range(10)) +@dataclass +class _AppearanceProviders: + """CSS providers for conditional appearance settings.""" -(NAME, IS_SPOKEN, IS_BRAILLED, VALUE) = list(range(4)) + hc: Gtk.CssProvider + dark: Gtk.CssProvider + shapes: Gtk.CssProvider | None = None -(ACTUAL, REPLACEMENT) = list(range(2)) -# Must match the order of voice types in the GtkBuilder file. -# -(DEFAULT, UPPERCASE, HYPERLINK, SYSTEM) = list(range(4)) +class NavigationRow(Gtk.ListBoxRow): + """ListBoxRow with a panel_id attribute for navigation.""" -# Must match the order that the timeFormatCombo is populated. -# -(TIME_FORMAT_LOCALE, TIME_FORMAT_12_HM, TIME_FORMAT_12_HMS, TIME_FORMAT_24_HMS, - TIME_FORMAT_24_HMS_WITH_WORDS, TIME_FORMAT_24_HM, - TIME_FORMAT_24_HM_WITH_WORDS) = list(range(7)) + def __init__(self, panel_id: str | None = None) -> None: + super().__init__() + self.panel_id = panel_id -# Must match the order that the dateFormatCombo is populated. -# -(DATE_FORMAT_LOCALE, DATE_FORMAT_NUMBERS_DM, DATE_FORMAT_NUMBERS_MD, - DATE_FORMAT_NUMBERS_DMY, DATE_FORMAT_NUMBERS_MDY, DATE_FORMAT_NUMBERS_YMD, - DATE_FORMAT_FULL_DM, DATE_FORMAT_FULL_MD, DATE_FORMAT_FULL_DMY, - DATE_FORMAT_FULL_MDY, DATE_FORMAT_FULL_YMD, DATE_FORMAT_ABBREVIATED_DM, - DATE_FORMAT_ABBREVIATED_MD, DATE_FORMAT_ABBREVIATED_DMY, - DATE_FORMAT_ABBREVIATED_MDY, DATE_FORMAT_ABBREVIATED_YMD) = list(range(16)) -class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): - PLUGIN_COL_ENABLED = 0 - PLUGIN_COL_DISPLAY = 1 - PLUGIN_COL_CAN_TOGGLE = 2 - PLUGIN_COL_NAME = 3 +class CthulhuSetupGUI(Gtk.ApplicationWindow): # pylint: disable=too-many-instance-attributes + """Preferences window for configuring Cthulhu screen reader settings.""" - def __init__(self, fileName, windowName, prefsDict): - """Initialize the Cthulhu configuration GUI. + WINDOW: CthulhuSetupGUI | None = None - Arguments: - - fileName: name of the GtkBuilder file. - - windowName: name of the component to get from the GtkBuilder file. - - prefsDict: dictionary of preferences to use during initialization - """ + _EVENTS_TO_SUSPEND: tuple[str, ...] = ( + "object:state-changed:checked", + "object:state-changed:sensitive", + "object:state-changed:showing", + "object:children-changed:add", + "object:children-changed:remove", + "object:selection-changed", + "object:property-change:accessible-name", + "object:property-change:accessible-description", + ) - cthulhu_gtkbuilder.GtkBuilderWrapper.__init__(self, fileName, windowName) - self.prefsDict = prefsDict + def __init__(self, script: default.Script) -> None: + if CthulhuSetupGUI.WINDOW is not None: + return - self._defaultProfile = ['Default', 'default'] - self.bbindings = None - self.cellRendererText = None - self.defaultVoice = None - self.disableKeyGrabPref = None - self.getTextAttributesView = None - self.hyperlinkVoice = None - self.initializingSpeech = None - self.kbindings = None - self.keyBindingsModel = None - self.keyBindView = None - self.newBinding = None - self.pendingKeyBindings = None - self.planeCellRendererText = None - self.pronunciationModel = None - self.pronunciationView = None - self._plugin_treeview = None - self._plugin_model = None - self._plugin_iters = {} - self._plugin_enabled_iter = None - self._plugin_disabled_iter = None - self._plugin_sources = [] - self._plugin_sources_entry = None - self._plugin_sources_listbox = None - self._plugin_sources_original = [] - self._available_plugins = set() - self._plugin_canonical_map = {} - self._plugin_group_map = {} - self._plugin_update_button = None - self._plugin_update_progress = None - self._plugin_update_status = None - self._plugin_update_in_progress = False - self._plugin_tabs = {} - self._plugin_tabs_cached = False - self._dynamic_plugin_tabs = {} - self.screenHeight = None - self.screenWidth = None - self.speechFamiliesChoice = None - self.speechFamiliesChoices = None - self.speechFamiliesModel = None - self.speechLanguagesChoice = None - self.speechLanguagesChoices = None - self.speechLanguagesModel = None - self.speechFamilies = [] - self.speechServersChoice = None - self.speechServersChoices = None - self.speechServersModel = None - self.speechSystemsChoice = None - self.speechSystemsChoices = None - self.speechSystemsModel = None - self.echoVoice = None - self.echoSpeechFamiliesChoice = None - self.echoSpeechFamiliesChoices = None - self.echoSpeechFamiliesModel = None - self.echoSpeechServersChoice = None - self.echoSpeechServersChoices = None - self.echoSpeechServersModel = None - self.initializingEchoSpeech = False - self._echoVoiceFetchToken = 0 - self._updatingEchoSpeechFamilies = False - self.systemVoice = None - self.uppercaseVoice = None - self.window = None - self.workingFactories = None - self.savedGain = None - self.savedPitch = None - self.savedRate = None - self.soundThemeCombo = None - self.soundSinkCombo = None - self.roleSoundPresentationCombo = None - self.soundVolumeScale = None - self.progressBarBeepIntervalSpinButton = None - self._isInitialSetup = False - self._updatingSpeechFamilies = False - self.selectedFamilyChoices = {} - self.selectedLanguageChoices = {} - self.profilesCombo = None - self.profilesComboModel = None - self.startingProfileCombo = None - self._initialFocusSyncAttempts = 0 - self._capturedKey = [] - self.script = None + msg = "PREFERENCES: Initializing UI" + debug.print_message(debug.LEVEL_ALL, msg, True) - def init(self, script): - """Initialize the Cthulhu configuration GUI. Read the users current - set of preferences and set the GUI state to match. Setup speech - support and populate the combo box lists on the Speech Tab pane - accordingly. - """ + appearance_refs = self._sync_appearance() + super().__init__(title=guilabels.DIALOG_SCREEN_READER_PREFERENCES) + self._appearance_refs = appearance_refs + self.connect("destroy", self.window_destroyed) + self.connect("delete-event", self.window_closed) + + self._profile_name: str = profile_manager.get_manager().get_active_profile() self.script = script + self._app_name: str | None = None + if script.app is not None: + self._app_name = AXObject.get_name(script.app) or None + self._current_page_title: str = "" + self._current_panel_id: str | None = None + self._original_profile: str = profile_manager.get_manager().get_active_profile() - # Restore the default rate/pitch/gain, - # in case the user played with the sliders. - # - try: - voices = cthulhu.cthulhuApp.settingsManager.getSetting('voices') - defaultVoice = voices[settings.DEFAULT_VOICE] - except KeyError: - defaultVoice = {} - try: - self.savedGain = defaultVoice[acss.ACSS.GAIN] - except KeyError: - self.savedGain = 10.0 - try: - self.savedPitch = defaultVoice[acss.ACSS.AVERAGE_PITCH] - except KeyError: - self.savedPitch = 5.0 - try: - self.savedRate = defaultVoice[acss.ACSS.RATE] - except KeyError: - self.savedRate = 50.0 + titlebar_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) - # ***** Key Bindings treeview initialization ***** + self.left_headerbar = Gtk.HeaderBar() + self.left_headerbar.set_show_close_button(True) + self.left_headerbar.set_title(guilabels.DIALOG_SCREEN_READER_PREFERENCES) + self.left_headerbar.get_style_context().add_class("cthulhu-left-headerbar") - self.keyBindView = self.get_widget("keyBindingsTreeview") + self._app_icon = Gtk.Image.new_from_icon_name("cthulhu", Gtk.IconSize.MENU) + self._app_icon.set_margin_start(2) - if self.keyBindView.get_columns(): - for column in self.keyBindView.get_columns(): - self.keyBindView.remove_column(column) + self.menu_button = Gtk.MenuButton() + menu_image = Gtk.Image.new_from_icon_name("open-menu-symbolic", Gtk.IconSize.BUTTON) + self.menu_button.set_image(menu_image) + self.menu_button.get_accessible().set_name(guilabels.MENU_BUTTON_OPTIONS) - self.keyBindingsModel = Gtk.TreeStore( - GObject.TYPE_STRING, # Handler name - GObject.TYPE_STRING, # Human Readable Description - GObject.TYPE_STRING, # Modifier mask 1 - GObject.TYPE_STRING, # Used Modifiers 1 - GObject.TYPE_STRING, # Modifier key name 1 - GObject.TYPE_STRING, # Click count 1 - GObject.TYPE_STRING, # Original Text of the Key Binding Shown 1 - GObject.TYPE_STRING, # Text of the Key Binding Shown 1 - GObject.TYPE_BOOLEAN, # Key Modified by User - GObject.TYPE_BOOLEAN) # Row with fields editable or not + popover = Gtk.Popover() + menu_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) - self.planeCellRendererText = Gtk.CellRendererText() + help_item = Gtk.ModelButton() + help_item.set_property("text", guilabels.DIALOG_HELP) + help_item.connect("clicked", lambda _: self.help_button_clicked(None)) + menu_box.pack_start(help_item, False, False, 0) - self.cellRendererText = Gtk.CellRendererText() - self.cellRendererText.set_property("ellipsize", Pango.EllipsizeMode.END) + self.apply_item = Gtk.ModelButton() + self.apply_item.set_property("text", guilabels.DIALOG_APPLY) + self.apply_item.connect("clicked", lambda _: self.apply_button_clicked(None)) + menu_box.pack_start(self.apply_item, False, False, 0) - # HANDLER - invisble column - # - column = Gtk.TreeViewColumn("Handler", - self.planeCellRendererText, - text=HANDLER) - column.set_resizable(True) - column.set_visible(False) - column.set_sort_column_id(HANDLER) - self.keyBindView.append_column(column) + self.save_item = Gtk.ModelButton() + self.save_item.set_property("text", guilabels.BTN_SAVE) + self.save_item.connect("clicked", lambda _: self.ok_button_clicked(None)) + menu_box.pack_start(self.save_item, False, False, 0) - # DESCRIP - # - column = Gtk.TreeViewColumn(guilabels.KB_HEADER_FUNCTION, - self.cellRendererText, - text=DESCRIP) - column.set_resizable(True) - column.set_min_width(380) - column.set_sort_column_id(DESCRIP) - self.keyBindView.append_column(column) + # Save Profile As is only for global preferences (profiles are global) + if not self._app_name: + save_as_item = Gtk.ModelButton() + save_as_item.set_property("text", guilabels.PROFILE_SAVE_AS_TITLE) + save_as_item.connect("clicked", lambda _: self._on_save_profile_as()) + menu_box.pack_start(save_as_item, False, False, 0) - # MOD_MASK1 - invisble column - # - column = Gtk.TreeViewColumn("Mod.Mask 1", - self.planeCellRendererText, - text=MOD_MASK1) - column.set_visible(False) - column.set_resizable(True) - column.set_sort_column_id(MOD_MASK1) - self.keyBindView.append_column(column) + cancel_item = Gtk.ModelButton() + cancel_item.set_property("text", guilabels.DIALOG_CANCEL) + cancel_item.connect("clicked", lambda _: self.cancel_button_clicked(None)) + menu_box.pack_start(cancel_item, False, False, 0) - # MOD_USED1 - invisble column - # - column = Gtk.TreeViewColumn("Use Mod.1", - self.planeCellRendererText, - text=MOD_USED1) - column.set_visible(False) - column.set_resizable(True) - column.set_sort_column_id(MOD_USED1) - self.keyBindView.append_column(column) + menu_box.show_all() + popover.add(menu_box) + self.menu_button.set_popover(popover) - # KEY1 - invisble column - # - column = Gtk.TreeViewColumn("Key1", - self.planeCellRendererText, - text=KEY1) - column.set_resizable(True) - column.set_visible(False) - column.set_sort_column_id(KEY1) - self.keyBindView.append_column(column) + self.left_headerbar.pack_end(self.menu_button) - # CLICK_COUNT1 - invisble column - # - column = Gtk.TreeViewColumn("ClickCount1", - self.planeCellRendererText, - text=CLICK_COUNT1) - column.set_resizable(True) - column.set_visible(False) - column.set_sort_column_id(CLICK_COUNT1) - self.keyBindView.append_column(column) + titlebar_box.pack_start(self.left_headerbar, False, False, 0) - # OLDTEXT1 - invisble column which will store a copy of the - # original keybinding in TEXT1 prior to the Apply or OK - # buttons being pressed. This will prevent automatic - # resorting each time a cell is edited. - # - column = Gtk.TreeViewColumn("OldText1", - self.planeCellRendererText, - text=OLDTEXT1) - column.set_resizable(True) - column.set_visible(False) - column.set_sort_column_id(OLDTEXT1) - self.keyBindView.append_column(column) + titlebar_separator = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL) + titlebar_box.pack_start(titlebar_separator, False, False, 0) - # TEXT1 - # - rendererText = Gtk.CellRendererText() - rendererText.connect("editing-started", - self.editingKey, - self.keyBindingsModel) - rendererText.connect("editing-canceled", - self.editingCanceledKey) - rendererText.connect('edited', - self.editedKey, - self.keyBindingsModel, - MOD_MASK1, MOD_USED1, KEY1, CLICK_COUNT1, TEXT1) + self.panel_headerbar = Gtk.HeaderBar() + self.panel_headerbar.set_show_close_button(True) + self.panel_headerbar.set_title("") + self.panel_headerbar.get_style_context().add_class("cthulhu-panel-headerbar") - column = Gtk.TreeViewColumn(guilabels.KB_HEADER_KEY_BINDING, - rendererText, - text=TEXT1, - editable=EDITABLE) + titlebar_box.pack_start(self.panel_headerbar, True, True, 0) - column.set_resizable(True) - column.set_sort_column_id(OLDTEXT1) - self.keyBindView.append_column(column) - - # MODIF - # - rendererToggle = Gtk.CellRendererToggle() - rendererToggle.connect('toggled', - self.keyModifiedToggle, - self.keyBindingsModel, - MODIF) - column = Gtk.TreeViewColumn(guilabels.KB_MODIFIED, - rendererToggle, - active=MODIF, - activatable=EDITABLE) - #column.set_visible(False) - column.set_resizable(True) - column.set_sort_column_id(MODIF) - self.keyBindView.append_column(column) - - # EDITABLE - invisble column - # - rendererToggle = Gtk.CellRendererToggle() - rendererToggle.set_property('activatable', False) - column = Gtk.TreeViewColumn("Modified", - rendererToggle, - active=EDITABLE) - column.set_visible(False) - column.set_resizable(True) - column.set_sort_column_id(EDITABLE) - self.keyBindView.append_column(column) - - # Populates the treeview with all the keybindings: - # - self._populateKeyBindings() - - self.window = self.get_widget("cthulhuSetupWindow") - - self._setKeyEchoItems() - - self.speechSystemsModel = \ - self._initComboBox(self.get_widget("speechSystems")) - self.speechServersModel = \ - self._initComboBox(self.get_widget("speechServers")) - self.speechLanguagesModel = \ - self._initComboBox(self.get_widget("speechLanguages")) - self.speechFamiliesModel = \ - self._initComboBox(self.get_widget("speechFamilies")) - self.echoSpeechServersModel = \ - self._initComboBox(self.get_widget("echoSpeechServers")) - self.echoSpeechFamiliesModel = \ - self._initComboBox(self.get_widget("echoSpeechFamilies")) - self._initSpeechState() - self._initEchoSpeechState() - - # TODO - JD: Will this ever be the case?? - self._isInitialSetup = \ - not os.path.exists(cthulhu.cthulhuApp.settingsManager.getPrefsDir()) - - try: - self._initPluginsPage() - except Exception as e: - debug.printMessage(debug.LEVEL_WARNING, f"PREFERENCES DIALOG: Plugin page init failed: {e}", True) - - appPage = self.script.getAppPreferencesGUI() - if appPage: - label = Gtk.Label(label=AXObject.get_name(self.script.app)) - self.get_widget("notebook").append_page(appPage, label) - - self._cache_plugin_tabs() - self._update_plugin_tabs() - self._refresh_dynamic_plugin_tabs() - self._initGUIState() - self._initSoundThemeState() - - def _initPluginsPage(self): - self._plugin_sources = list(self.prefsDict.get("pluginSources", settings.pluginSources) or []) - self._plugin_sources_original = list(self._plugin_sources) - - pluginsPage = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) - pluginsPage.set_border_width(12) - - infoLabel = Gtk.Label(label="Enable or disable plugins and manage plugin sources.") - infoLabel.set_line_wrap(True) - infoLabel.set_halign(Gtk.Align.START) - pluginsPage.pack_start(infoLabel, False, False, 0) - - pluginsFrame = Gtk.Frame(label="Plugins") - try: - pluginsFrame.set_label_align(0.0, 0.5) - except Exception: - try: - pluginsFrame.set_label_xalign(0.0) - except Exception: - pass - pluginsFrame.set_shadow_type(Gtk.ShadowType.NONE) - pluginsFrameBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - pluginsFrameBox.set_border_width(6) - pluginsFrame.add(pluginsFrameBox) - - pluginsScrolled = Gtk.ScrolledWindow() - pluginsScrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - pluginsScrolled.set_size_request(-1, 200) - self._plugin_model = Gtk.TreeStore( - GObject.TYPE_BOOLEAN, # enabled - GObject.TYPE_STRING, # display text - GObject.TYPE_BOOLEAN, # can toggle - GObject.TYPE_STRING, # plugin name - ) - self._plugin_treeview = Gtk.TreeView(model=self._plugin_model) - self._plugin_treeview.set_headers_visible(True) - self._plugin_treeview.set_enable_search(False) - self._plugin_treeview.connect("key-press-event", self._on_plugin_tree_key_press) - self._plugin_treeview.connect("row-activated", self._on_plugin_tree_row_activated) - - toggle_renderer = Gtk.CellRendererToggle() - toggle_renderer.set_activatable(True) - toggle_renderer.connect("toggled", self._on_plugin_tree_toggled) - toggle_column = Gtk.TreeViewColumn( - "Enabled", - toggle_renderer, - active=self.PLUGIN_COL_ENABLED, - activatable=self.PLUGIN_COL_CAN_TOGGLE - ) - toggle_column.add_attribute(toggle_renderer, "visible", self.PLUGIN_COL_CAN_TOGGLE) - self._plugin_treeview.append_column(toggle_column) - - text_renderer = Gtk.CellRendererText() - text_renderer.set_property("ellipsize", Pango.EllipsizeMode.END) - text_column = Gtk.TreeViewColumn( - "Plugin", - text_renderer, - text=self.PLUGIN_COL_DISPLAY - ) - text_column.set_expand(True) - self._plugin_treeview.append_column(text_column) - - pluginsScrolled.add(self._plugin_treeview) - pluginsFrameBox.pack_start(pluginsScrolled, True, True, 0) - - pluginsPage.pack_start(pluginsFrame, True, True, 0) - - sourcesFrame = Gtk.Frame(label="Plugin Sources") - try: - sourcesFrame.set_label_align(0.0, 0.5) - except Exception: - try: - sourcesFrame.set_label_xalign(0.0) - except Exception: - pass - sourcesFrame.set_shadow_type(Gtk.ShadowType.NONE) - sourcesFrameBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - sourcesFrameBox.set_border_width(6) - sourcesFrame.add(sourcesFrameBox) - - sourcesGrid = Gtk.Grid(row_spacing=6, column_spacing=6) - sourcesLabel = Gtk.Label(label="_Source URL:") - sourcesLabel.set_use_underline(True) - sourcesLabel.set_halign(Gtk.Align.START) - self._plugin_sources_entry = Gtk.Entry() - self._plugin_sources_entry.set_placeholder_text("https://example.com/repo.git") - sourcesLabel.set_mnemonic_widget(self._plugin_sources_entry) - addButton = Gtk.Button(label="Add Source") - addButton.connect("clicked", self._on_add_plugin_source) - sourcesGrid.attach(sourcesLabel, 0, 0, 1, 1) - sourcesGrid.attach(self._plugin_sources_entry, 1, 0, 1, 1) - sourcesGrid.attach(addButton, 2, 0, 1, 1) - sourcesFrameBox.pack_start(sourcesGrid, False, False, 0) - - updateRow = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) - self._plugin_update_button = Gtk.Button(label="Update Plugins") - self._plugin_update_button.connect("clicked", self._on_update_plugins_clicked) - self._plugin_update_progress = Gtk.ProgressBar() - self._plugin_update_progress.set_show_text(True) - self._plugin_update_progress.set_text("Idle") - updateRow.pack_start(self._plugin_update_button, False, False, 0) - updateRow.pack_start(self._plugin_update_progress, True, True, 0) - sourcesFrameBox.pack_start(updateRow, False, False, 0) - - sourcesScrolled = Gtk.ScrolledWindow() - sourcesScrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - sourcesScrolled.set_size_request(-1, 120) - self._plugin_sources_listbox = Gtk.ListBox() - self._plugin_sources_listbox.set_selection_mode(Gtk.SelectionMode.NONE) - sourcesScrolled.add(self._plugin_sources_listbox) - sourcesFrameBox.pack_start(sourcesScrolled, True, True, 0) - - sourcesNote = Gtk.Label(label="Use Update Plugins to install or update sources, then Apply or OK to save settings.") - sourcesNote.set_line_wrap(True) - sourcesNote.set_halign(Gtk.Align.START) - sourcesFrameBox.pack_start(sourcesNote, False, False, 0) - - self._plugin_update_status = Gtk.Label(label="") - self._plugin_update_status.set_line_wrap(True) - self._plugin_update_status.set_halign(Gtk.Align.START) - sourcesFrameBox.pack_start(self._plugin_update_status, False, False, 0) - - pluginsPage.pack_start(sourcesFrame, True, True, 0) - - notebook = self.get_widget("notebook") - notebook.append_page(pluginsPage, Gtk.Label(label="Plugins")) - - self._populate_plugin_list() - self._populate_plugin_sources_list() - pluginsPage.show_all() - - def _clear_listbox(self, listbox): - for child in listbox.get_children(): - listbox.remove(child) - - def _populate_plugin_list(self): - if not self._plugin_treeview: - return - - try: - self._plugin_model.clear() - self._plugin_iters = {} - self._available_plugins = set() - self._plugin_canonical_map = {} - self._plugin_group_map = {} - - manager = cthulhu.cthulhuApp.getPluginSystemManager() - if manager: - try: - manager.rescanPlugins() - except Exception as e: - debug.printMessage(debug.LEVEL_WARNING, f"PREFERENCES DIALOG: Plugin rescan failed: {e}", True) - manager = None - - active_plugins = list(self.prefsDict.get("activePlugins", settings.activePlugins) or []) - active_plugins_lower = {name.lower() for name in active_plugins} - - plugin_infos = manager.plugins if manager else [] - self._available_plugins = {info.get_module_name() for info in plugin_infos} - canonical_counts = {} - canonical_builtins = {} - for info in plugin_infos: - canonical = info.get_canonical_name() - canonical_counts[canonical] = canonical_counts.get(canonical, 0) + 1 - if info.builtin: - canonical_builtins[canonical] = True - - self._plugin_enabled_iter = self._plugin_model.append( - None, - [False, "Enabled plugins", False, ""] - ) - self._plugin_disabled_iter = self._plugin_model.append( - None, - [False, "Disabled plugins", False, ""] + self._apply_decoration_layout() + gtk_settings = Gtk.Settings.get_default() # pylint: disable=no-value-for-parameter + if gtk_settings is not None: + gtk_settings.connect( + "notify::gtk-decoration-layout", + lambda *_: self._apply_decoration_layout(), ) - for plugin_info in sorted(plugin_infos, key=lambda item: (item.get_name() or item.get_module_name()).lower()): - plugin_name = plugin_info.get_module_name() - canonical_name = plugin_info.get_canonical_name() - if plugin_info.hidden or canonical_name == "PluginManager": - continue + self.set_titlebar(titlebar_box) - self._plugin_canonical_map[plugin_name] = canonical_name + main_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) - is_active = ( - plugin_name in active_plugins - or plugin_name.lower() in active_plugins_lower - or (plugin_info.preferred_alias and (canonical_name in active_plugins or canonical_name.lower() in active_plugins_lower)) - or plugin_info.builtin - ) + self.hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) - can_toggle = True - if canonical_builtins.get(canonical_name) and not plugin_info.builtin: - is_active = False - can_toggle = False + self.sidebar_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + self.sidebar_vbox.get_style_context().add_class("cthulhu-sidebar") - if plugin_info.builtin: - can_toggle = False + self.sidebar_scrolled = Gtk.ScrolledWindow() + self.sidebar_scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self.listbox = Gtk.ListBox() + self.listbox.set_selection_mode(Gtk.SelectionMode.SINGLE) + self.listbox.get_accessible().set_name(guilabels.PREFERENCES_CATEGORIES) + self.listbox.connect("row-selected", self._on_row_selected) + self.sidebar_scrolled.add(self.listbox) + self.sidebar_vbox.pack_start(self.sidebar_scrolled, True, True, 0) - display_name = plugin_info.get_name() or plugin_name - info_text = display_name - description = plugin_info.get_description() - if description: - info_text += f" - {description}" - version = plugin_info.get_version() - if version: - info_text += f" (v{version})" - if canonical_counts.get(canonical_name, 0) > 1: - info_text += f" - Source: {plugin_info.get_source_label()}" - if canonical_builtins.get(canonical_name) and not plugin_info.builtin: - info_text += " - Disabled because a builtin plugin uses this name." + self.listbox.connect("key-press-event", self._on_listbox_key_press) - parent_iter = self._plugin_enabled_iter if is_active else self._plugin_disabled_iter - tree_iter = self._plugin_model.append( - parent_iter, - [is_active, info_text, can_toggle, plugin_name] - ) - self._plugin_iters[plugin_name] = tree_iter - self._plugin_group_map.setdefault(canonical_name, []).append(plugin_name) + self.hbox.pack_start(self.sidebar_vbox, False, False, 0) - self._plugin_treeview.collapse_all() - self._plugin_treeview.show_all() - except Exception as e: - debug.printMessage(debug.LEVEL_WARNING, f"PREFERENCES DIALOG: Plugin list build failed: {e}", True) + separator = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL) + self.hbox.pack_start(separator, False, False, 0) - def _populate_plugin_sources_list(self): - if not self._plugin_sources_listbox: - return + self.content_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) - self._clear_listbox(self._plugin_sources_listbox) + self.stack = Gtk.Stack() + self.stack.set_hexpand(True) + self.stack.set_vhomogeneous(False) + stack_scrolled = Gtk.ScrolledWindow() + stack_scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + stack_scrolled.add(self.stack) + self.content_vbox.pack_start(stack_scrolled, True, True, 0) - for source in self._plugin_sources: - row = Gtk.ListBoxRow() - row.set_activatable(False) - hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - hbox.set_border_width(5) + self.hbox.pack_start(self.content_vbox, True, True, 0) - label = Gtk.Label(label=source) - label.set_halign(Gtk.Align.START) - label.set_line_wrap(True) + main_vbox.pack_start(self.hbox, True, True, 0) - remove_button = Gtk.Button(label="Remove") - remove_button.connect("clicked", self._on_remove_plugin_source, source, row) + self.add(main_vbox) - hbox.pack_start(label, True, True, 0) - hbox.pack_start(remove_button, False, False, 0) - row.add(hbox) - self._plugin_sources_listbox.add(row) + # Ignore runtime overrides while creating preference grids so that + # on-the-fly changes (e.g. speech rate) don't influence the values shown. + registry = gsettings_registry.get_registry() + registry.set_ignore_runtime(True) - self._plugin_sources_listbox.show_all() - - def _on_add_plugin_source(self, widget): - if not self._plugin_sources_entry: - return - source = self._plugin_sources_entry.get_text().strip() - if not source: - return - if source in self._plugin_sources: - return - self._plugin_sources.append(source) - self._plugin_sources_entry.set_text("") - self._populate_plugin_sources_list() - - def _on_remove_plugin_source(self, widget, source, row): - if source in self._plugin_sources: - self._plugin_sources.remove(source) - if self._plugin_sources_listbox and row: - self._plugin_sources_listbox.remove(row) - - def _on_update_plugins_clicked(self, widget): - if self._plugin_update_in_progress: - return - - sources = list(self._plugin_sources) - if not sources: - self._set_plugin_update_status("No sources to update.", done=True) - return - - self._plugin_update_in_progress = True - if self._plugin_update_button: - self._plugin_update_button.set_sensitive(False) - self._set_plugin_update_progress(0, len(sources), "Starting updates...") - - def _run_updates(): - manager = cthulhu.cthulhuApp.getPluginSystemManager() - if not manager: - GLib.idle_add(self._finish_plugin_updates, "Plugin manager unavailable.") - return - - def _progress_callback(index, total, message): - GLib.idle_add(self._set_plugin_update_progress, index, total, message) - - manager.syncPluginSources(sources, progress_callback=_progress_callback) - try: - manager.rescanPlugins() - except Exception as e: - GLib.idle_add(self._finish_plugin_updates, f"Update finished with errors: {e}") - return - - GLib.idle_add(self._finish_plugin_updates, "Update complete.") - - thread = threading.Thread(target=_run_updates, daemon=True) - thread.start() - - def _set_plugin_update_progress(self, index, total, message): - if not self._plugin_update_progress: - return - fraction = 0.0 if total <= 0 else min(1.0, float(index) / float(total)) - self._plugin_update_progress.set_fraction(fraction) - self._plugin_update_progress.set_text(message) - self._set_plugin_update_status(message) - - def _set_plugin_update_status(self, message, done=False): - if self._plugin_update_status: - self._plugin_update_status.set_text(message) - if done and self._plugin_update_progress: - self._plugin_update_progress.set_fraction(0.0) - self._plugin_update_progress.set_text("Idle") - - def _finish_plugin_updates(self, message): - self._plugin_update_in_progress = False - if self._plugin_update_button: - self._plugin_update_button.set_sensitive(True) - if message: - self._set_plugin_update_status(message) - self._populate_plugin_list() - - def _on_plugin_tree_toggled(self, renderer, path): - if not self._plugin_model: - return - - tree_iter = self._plugin_model.get_iter(path) - if not tree_iter: - return - - can_toggle = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_CAN_TOGGLE) - if not can_toggle: - return - - plugin_name = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_NAME) - if not plugin_name: - return - - current_active = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_ENABLED) - new_active = not current_active - - if new_active: - canonical_name = self._plugin_canonical_map.get(plugin_name) - for other_name in self._plugin_group_map.get(canonical_name, []): - if other_name == plugin_name: - continue - self._set_plugin_row_active(other_name, False) - - self._set_plugin_row_active(plugin_name, new_active) - - def _on_plugin_tree_key_press(self, widget, event): - if event.keyval != Gdk.KEY_space: - return False - - selection = self._plugin_treeview.get_selection() - model, tree_iter = selection.get_selected() - if not tree_iter: - return False - - path = model.get_path(tree_iter) - self._on_plugin_tree_toggled(None, path) - return True - - def _on_plugin_tree_row_activated(self, treeview, path, column): - self._on_plugin_tree_toggled(None, path) - - def _set_plugin_row_active(self, plugin_name, is_active): - tree_iter = self._plugin_iters.get(plugin_name) - if not tree_iter: - return - - current_active = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_ENABLED) - if current_active == is_active: - return - - can_toggle = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_CAN_TOGGLE) - display_text = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_DISPLAY) - - selection = self._plugin_treeview.get_selection() - model, selected_iter = selection.get_selected() - was_selected = selected_iter == tree_iter - - self._plugin_model.remove(tree_iter) - - parent_iter = self._plugin_enabled_iter if is_active else self._plugin_disabled_iter - new_iter = self._plugin_model.append( - parent_iter, - [is_active, display_text, can_toggle, plugin_name] + # Create all preference grids + # Profiles grid controls are insensitive for app-specific prefs + prof_manager = profile_manager.get_manager() + self.profiles_grid = prof_manager.create_preferences_grid( + profile_loaded_callback=self._on_profile_loaded, + is_app_specific=bool(self._app_name), + labels_update_callback=self.update_menu_labels, + unsaved_changes_checker=self._has_unsaved_changes, ) - self._plugin_iters[plugin_name] = new_iter + self.stack.add_named(self.profiles_grid, "profiles") + self._add_navigation_row("profiles", self.profiles_grid.get_label().get_text()) + self.update_menu_labels() - if was_selected: - path = self._plugin_model.get_path(new_iter) - self._plugin_treeview.expand_to_path(path) - selection.select_path(path) - self._update_plugin_tabs() + presenter = speech_presenter.get_presenter() - def _get_active_plugins_from_ui(self): - existing_plugins = list(self.prefsDict.get("activePlugins", settings.activePlugins) or []) - preserved_plugins = [ - name for name in existing_plugins - if name not in self._plugin_iters and name in self._available_plugins - ] - selected_plugins = [] - for canonical_name, plugin_names in self._plugin_group_map.items(): - active_in_group = [ - name for name in plugin_names - if self._plugin_iters.get(name) - and self._plugin_model.get_value(self._plugin_iters[name], self.PLUGIN_COL_ENABLED) - ] - if active_in_group: - selected_plugins.append(active_in_group[-1]) - return preserved_plugins + selected_plugins + def update_title(title: str) -> None: + """Update the panel header title and window accessible name.""" - def _apply_plugin_changes(self): - active_plugins = self._get_active_plugins_from_ui() - plugin_sources = list(self._plugin_sources) + self._set_page_title(title) - self.prefsDict["activePlugins"] = active_plugins - self.prefsDict["pluginSources"] = plugin_sources + self.speech_grid = presenter.create_speech_preferences_grid( + title_change_callback=update_title, + app_name=self._app_name or "", + ) + self.stack.add_named(self.speech_grid, "speech") + self._add_navigation_row("speech", self.speech_grid.get_label().get_text()) - removed_sources = [source for source in self._plugin_sources_original if source not in plugin_sources] - manager = cthulhu.cthulhuApp.getPluginSystemManager() - if manager: - try: - manager.removePluginSources(removed_sources) - manager.rescanPlugins() - except Exception as e: - debug.printMessage(debug.LEVEL_WARNING, f"PREFERENCES DIALOG: Plugin sync failed: {e}", True) + braille_pres = braille_presenter.get_presenter() + self.braille_grid = braille_pres.create_preferences_grid(title_change_callback=update_title) + self.stack.add_named(self.braille_grid, "braille") + self._add_navigation_row("braille", self.braille_grid.get_label().get_text()) - self._plugin_sources_original = list(plugin_sources) - self._populate_plugin_list() - self._update_plugin_tabs(active_plugins) + sound_pres = sound_presenter.get_presenter() + self.sound_grid = sound_pres.create_preferences_grid(title_change_callback=update_title) + self.stack.add_named(self.sound_grid, "sound") + self._add_navigation_row("sound", self.sound_grid.get_label().get_text()) - def _get_active_plugin_infos(self): - manager = cthulhu.cthulhuApp.getPluginSystemManager() - if not manager: - return [] - return [info for info in manager.plugins if info.loaded and info.instance] + cmd_manager = command_manager.get_manager() + self.keybindings_grid = cmd_manager.create_preferences_grid( + self.script, + title_change_callback=update_title, + ) + self.stack.add_named(self.keybindings_grid, "keybindings") + self._add_navigation_row("keybindings", self.keybindings_grid.get_label().get_text()) - def _get_plugin_preferences_page(self, plugin_info): - plugin_instance = plugin_info.instance - if not plugin_instance: - return None + typing_pres = typing_echo_presenter.get_presenter() + self.typing_echo_grid = typing_pres.create_preferences_grid() + self.stack.add_named(self.typing_echo_grid, "typing_echo") + self._add_navigation_row("typing_echo", self.typing_echo_grid.get_label().get_text()) - if hasattr(plugin_instance, "getPreferencesGUI"): - page = plugin_instance.getPreferencesGUI() - elif hasattr(plugin_instance, "get_preferences_gui"): - page = plugin_instance.get_preferences_gui() + mouse_reviewer = mouse_review.get_reviewer() + self.mouse_grid = mouse_reviewer.create_preferences_grid() + self.stack.add_named(self.mouse_grid, "mouse") + self._add_navigation_row("mouse", self.mouse_grid.get_label().get_text()) + + doc_presenter = document_presenter.get_presenter() + self.document_grid = doc_presenter.create_preferences_grid(update_title) + self.stack.add_named(self.document_grid, "documents") + self._add_navigation_row("documents", self.document_grid.get_label().get_text()) + + say_all_pres = say_all_presenter.get_presenter() + self.say_all_grid = say_all_pres.create_preferences_grid() + self.stack.add_named(self.say_all_grid, "say_all") + self._add_navigation_row("say_all", self.say_all_grid.get_label().get_text()) + + pronunciation_manager = pronunciation_dictionary_manager.get_manager() + self.pronunciation_grid = pronunciation_manager.create_preferences_grid(self.script) + self.stack.add_named(self.pronunciation_grid, "pronunciation") + self._add_navigation_row("pronunciation", self.pronunciation_grid.get_label().get_text()) + + spellcheck_pres = spellcheck_presenter.get_presenter() + self.spellcheck_grid = spellcheck_pres.create_preferences_grid() + self.stack.add_named(self.spellcheck_grid, "spellcheck") + self._add_navigation_row("spellcheck", self.spellcheck_grid.get_label().get_text()) + + chat_pres = chat_presenter.get_presenter() + self.chat_grid = chat_pres.create_preferences_grid() + self.stack.add_named(self.chat_grid, "chat") + self._add_navigation_row("chat", self.chat_grid.get_label().get_text()) + + text_attr_mgr = text_attribute_manager.get_manager() + self.text_attributes_grid = text_attr_mgr.create_preferences_grid() + self.stack.add_named(self.text_attributes_grid, "text_attributes") + self._add_navigation_row( + "text_attributes", + self.text_attributes_grid.get_label().get_text(), + ) + + system_info_presenter = system_information_presenter.get_presenter() + self.time_and_date_grid = system_info_presenter.create_time_and_date_preferences_grid() + self.stack.add_named(self.time_and_date_grid, "time_and_date") + self._add_navigation_row("time_and_date", self.time_and_date_grid.get_label().get_text()) + + # Cthulhu-only pages. + from .plugins.OCR import plugin as ocr_plugin # pylint: disable=import-outside-toplevel + + self.ocr_grid = ocr_plugin.OCRPreferencesGrid(title_change_callback=update_title) + self.stack.add_named(self.ocr_grid, "ocr") + self._add_navigation_row("ocr", self.ocr_grid.get_label().get_text()) + + registry.set_ignore_runtime(False) + + self._page_to_grid = { + "speech": self.speech_grid, + "braille": self.braille_grid, + "keybindings": self.keybindings_grid, + "typing_echo": self.typing_echo_grid, + "say_all": self.say_all_grid, + "spellcheck": self.spellcheck_grid, + "chat": self.chat_grid, + "mouse": self.mouse_grid, + "documents": self.document_grid, + "pronunciation": self.pronunciation_grid, + "sound": self.sound_grid, + "time_and_date": self.time_and_date_grid, + "text_attributes": self.text_attributes_grid, + "ocr": self.ocr_grid, + "profiles": self.profiles_grid, + } + + for grid in self._page_to_grid.values(): + grid.set_focus_sidebar_callback(self._focus_sidebar) + + first_row = self.listbox.get_row_at_index(0) + if first_row: + self.listbox.select_row(first_row) + + msg = "PREFERENCES: Initializing UI complete" + debug.print_message(debug.LEVEL_ALL, msg, True) + + def _apply_decoration_layout(self) -> None: + """Syncs headerbar button placement and app icon with the system layout.""" + + gtk_settings = Gtk.Settings.get_default() # pylint: disable=no-value-for-parameter + if gtk_settings is None: + return + + layout = gtk_settings.get_property("gtk-decoration-layout") or ":minimize,maximize,close" + left_layout, _, right_layout = layout.partition(":") + + self.left_headerbar.set_decoration_layout(f"{left_layout}:") + self.panel_headerbar.set_decoration_layout(f":{right_layout}") + + parent = self._app_icon.get_parent() + if parent: + parent.remove(self._app_icon) + + if "close" in left_layout: + self.panel_headerbar.pack_end(self._app_icon) else: - return None + self.left_headerbar.pack_start(self._app_icon) + self._app_icon.show() - if not page: - return None + if self.get_visible(): + GLib.idle_add(self._sync_headerbar_widths) - label = plugin_info.get_name() or plugin_info.get_module_name() - if isinstance(page, (list, tuple)) and len(page) == 2: - page, label = page + def _sync_headerbar_widths(self) -> None: + headerbar_width = self.left_headerbar.get_allocated_width() + sidebar_width = self.sidebar_vbox.get_allocated_width() + max_width = max(headerbar_width, sidebar_width) - if isinstance(label, str): - label_widget = Gtk.Label(label=label) + self.left_headerbar.set_size_request(max_width, -1) + self.sidebar_vbox.set_size_request(max_width, -1) + + def _set_height_from_sidebar(self) -> None: + """Set window height to fit the sidebar items, capped at 800px.""" + + sidebar_height = self.listbox.get_preferred_height()[1] + headerbar_height = self.left_headerbar.get_allocated_height() + height = min(sidebar_height + headerbar_height, 800) + self.resize(self.get_allocated_width(), height) + + def _on_listbox_key_press(self, _widget: Gtk.Widget, event: Gdk.EventKey) -> bool: + if event.keyval == Gdk.KEY_Tab: + self.stack.child_focus(Gtk.DirectionType.TAB_FORWARD) + return True + if event.keyval == Gdk.KEY_ISO_Left_Tab: + # Shift+Tab from listbox should go back to menu button + self.menu_button.grab_focus() + return True + return False + + def _focus_sidebar(self) -> None: + """Move focus to the sidebar navigation listbox.""" + + selected_row = self.listbox.get_selected_row() + if selected_row: + selected_row.grab_focus() else: - label_widget = label + self.listbox.grab_focus() - return page, label_widget + def _add_navigation_row(self, panel_id: str, label_text: str) -> NavigationRow: + """Add a navigation row to the sidebar listbox and return it.""" - def _refresh_dynamic_plugin_tabs(self): - notebook = self.get_widget("notebook") - if not notebook: + row = NavigationRow(panel_id=panel_id) + + label = Gtk.Label(label=label_text, xalign=0) + label.set_margin_start(12) + label.set_margin_end(12) + label.set_margin_top(6) + label.set_margin_bottom(6) + row.add(label) + + self.listbox.add(row) + return row + + def _on_row_selected(self, _listbox: Gtk.ListBox, row: Gtk.ListBoxRow | None) -> None: + """Handle listbox row selection and switch to the selected panel.""" + + if row is None or not isinstance(row, NavigationRow): return - for tab in list(self._dynamic_plugin_tabs.values()): - page = tab.get("page") - if page: - page_num = notebook.page_num(page) - if page_num != -1: - notebook.remove_page(page_num) - - self._dynamic_plugin_tabs = {} - - plugin_infos = self._get_active_plugin_infos() - for plugin_info in sorted(plugin_infos, key=lambda item: (item.get_name() or item.get_module_name()).lower()): - result = self._get_plugin_preferences_page(plugin_info) - if not result: - continue - - page, label = result - if not page or not label: - continue - - notebook.append_page(page, label) - page.show_all() - label.show() - self._dynamic_plugin_tabs[plugin_info.get_module_name()] = { - "page": page, - "label": label, - } - - for plugin_name in self._dynamic_plugin_tabs: - tab = self._plugin_tabs.get(plugin_name) - if not tab: - continue - page = tab.get("page") - if not page: - continue - page_num = notebook.page_num(page) - if page_num != -1: - notebook.remove_page(page_num) - - def _cache_plugin_tabs(self): - if self._plugin_tabs_cached: + # Skip if this is a non-selectable header row + if row.panel_id is None: return - notebook = self.get_widget("notebook") - if not notebook: + panel_id = row.panel_id + + # Update the panel title in headerbar and accessible name + if panel_id == "application": + title = AXObject.get_name(self.script.app) + else: + child = self.stack.get_child_by_name(panel_id) + title = child.get_label().get_text() + + self._set_page_title(title) + + self.stack.set_visible_child_name(panel_id) + + # Only reset multi-page grids when switching to a different panel. + # This preserves sub-category state when focus returns to the + # sidebar via Shift-Tab without changing the selected row. + child = self.stack.get_child_by_name(panel_id) + if isinstance(child, preferences_grid_base.PreferencesGridBase): + if panel_id != self._current_panel_id: + child.on_becoming_visible() + + self._current_panel_id = panel_id + + def _init_gui_state(self, include_profiles: bool = False) -> None: + """Adjust the settings of the various widgets based on user settings.""" + + self._reload_all_grids(include_profiles=include_profiles) + + def show_gui(self) -> None: + """Show the Cthulhu configuration GUI window.""" + + def enable_configuring_mode(): + focus_manager.get_manager().set_in_preferences_window(True) + return False + + if CthulhuSetupGUI.WINDOW is not None: + CthulhuSetupGUI.WINDOW.present() return - tab_specs = [ - ("AIAssistant", "aiPage", "aiTabLabel"), - ("OCR", "ocrGrid", "ocrTabLabel"), - ] + CthulhuSetupGUI.WINDOW = self - for plugin_name, page_id, label_id in tab_specs: - page = self.get_widget(page_id) - label = self.get_widget(label_id) - if not page or not label: + accel_group = Gtk.AccelGroup() + CthulhuSetupGUI.WINDOW.add_accel_group(accel_group) + + # Select first row and set visible child before showing dialog + first_row = self.listbox.get_row_at_index(0) + if first_row and isinstance(first_row, NavigationRow) and first_row.panel_id: + self.listbox.select_row(first_row) + panel_id = first_row.panel_id + # Set initial panel title in headerbar + if panel_id == "application": + title = AXObject.get_name(self.script.app) + else: + child = self.stack.get_child_by_name(panel_id) + title = child.get_label().get_text() + self._set_page_title(title) + self.stack.set_visible_child_name(panel_id) + + self.suspend_events("Window being shown.") + CthulhuSetupGUI.WINDOW.show_all() + self._sync_headerbar_widths() + self._set_height_from_sidebar() + self.stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE) + self.stack.set_transition_duration(150) + CthulhuSetupGUI.WINDOW.present_with_time(time.time()) + GLib.timeout_add(500, self.resume_events) + + # Set accessible name after window is realized + def set_accessible_name() -> bool: + self.update_menu_labels() + return False + + GLib.idle_add(set_accessible_name) + + # Enable configuring mode after a brief delay to allow initial setup to complete + GLib.timeout_add(100, enable_configuring_mode) + + def help_button_clicked(self, _widget: Gtk.Button) -> None: + """Handle Help button click to show preferences help.""" + + learn_mode_presenter.get_presenter().show_help(page="preferences") + + def _reload_all_grids(self, include_profiles: bool = False) -> None: + """Reload all preference grids from settings.""" + + registry = gsettings_registry.get_registry() + registry.set_ignore_runtime(True) + for grid in self._page_to_grid.values(): + if grid is self.profiles_grid and not include_profiles: continue - position = notebook.page_num(page) - self._plugin_tabs[plugin_name] = { - "page": page, - "label": label, - "position": position, - } + grid.reload() + registry.set_ignore_runtime(False) - self._plugin_tabs_cached = True + def apply_button_clicked(self, _widget: Gtk.Button) -> None: + """Handle Apply button click to save and apply preferences.""" - def _get_active_plugins_for_tabs(self): - if self._plugin_iters: - return self._get_active_plugins_from_ui() - return list(self.prefsDict.get("activePlugins", settings.activePlugins) or []) + msg = "PREFERENCES: Apply button clicked" + debug.print_message(debug.LEVEL_ALL, msg, True) + + save_app = self._app_name or "" + + # Save profiles first so any renames happen before saving other settings + # (profiles are global, not saved for app-specific preferences) + if not self._app_name: + self.profiles_grid.save_settings() + + for grid in self._page_to_grid.values(): + if grid is not self.profiles_grid: + grid.save_settings(self._profile_name, save_app) + + cthulhu.load_user_settings(self.script, skip_reload_message=True) + + # Speak the settings reloaded message after a delay to ensure speech has fully started. + def speak_settings_reloaded() -> bool: + presentation_manager.get_manager().speak_message(messages.SETTINGS_RELOADED) + return False + + GLib.timeout_add(100, speak_settings_reloaded) + + self._reload_all_grids() + + # Re-apply user keybinding overrides and refresh grabs so changes work immediately. + command_manager.get_manager().activate_commands("Applied preferences") + + msg = "PREFERENCES: Handling Apply button click complete" + debug.print_message(debug.LEVEL_ALL, msg, True) + + def cancel_button_clicked(self, _widget: Gtk.Button) -> None: + """Handle Cancel button click to close window without saving.""" + + msg = "PREFERENCES: Cancel button clicked" + debug.print_message(debug.LEVEL_ALL, msg, True) + + for grid in self._page_to_grid.values(): + grid.revert_changes() + self.destroy() + + msg = "PREFERENCES: Handling Cancel button click complete" + debug.print_message(debug.LEVEL_ALL, msg, True) + + def ok_button_clicked(self, widget: Gtk.Button | None = None) -> None: + """Handle OK button click to save preferences and close window.""" + + msg = "PREFERENCES: OK button clicked" + debug.print_message(debug.LEVEL_ALL, msg, True) + + self.apply_button_clicked(widget) # type: ignore[arg-type] + self.destroy() + + msg = "PREFERENCES: Handling OK button click complete" + debug.print_message(debug.LEVEL_ALL, msg, True) + + def _on_save_profile_as(self) -> None: + """Handle Save Profile As menu item.""" + + new_profile = self.profiles_grid.get_new_profile_name() + if new_profile is None: + return + + self._profile_name = new_profile[1] + self.apply_button_clicked(None) + self._on_profile_loaded(new_profile) + + def window_closed(self, _widget: Gtk.Widget, _event: Any) -> bool: + """Handle window close signal by suspending events temporarily.""" + + msg = "PREFERENCES: Window is being closed" + debug.print_message(debug.LEVEL_ALL, msg, True) + + has_unsaved_changes = self._has_unsaved_changes( + include_profiles=not self._app_name, + ) + + # Check if profile was switched during this session + current_profile = profile_manager.get_manager().get_active_profile() + profile_was_switched = current_profile != self._original_profile + + if has_unsaved_changes: + dialog = Gtk.MessageDialog( + transient_for=self, + modal=True, + message_type=Gtk.MessageType.QUESTION, + buttons=Gtk.ButtonsType.NONE, + text=guilabels.PREFERENCES_CLOSE_WITHOUT_SAVE, + ) + dialog.format_secondary_text(guilabels.PREFERENCES_CHANGES_WILL_BE_LOST) + + profile_label = self._get_current_profile_label() + save_button = dialog.add_button( + guilabels.MENU_SAVE_PROFILE % profile_label, + Gtk.ResponseType.YES, + ) + save_button.get_style_context().add_class("suggested-action") + + # Only show "New Profile" for global preferences (profiles are global) + if not self._app_name: + dialog.add_button(guilabels.PROFILE_CREATE_NEW, Gtk.ResponseType.APPLY) + + dialog.add_button(guilabels.BTN_CLOSE_WITHOUT_SAVING, Gtk.ResponseType.NO) + dialog.add_button(guilabels.DIALOG_CANCEL, Gtk.ResponseType.CANCEL) + + dialog.show_all() + dialog.present() + save_button.grab_focus() + response = dialog.run() + dialog.destroy() + + if response == Gtk.ResponseType.YES: + self.apply_button_clicked(None) + elif response == Gtk.ResponseType.APPLY: + self._on_save_profile_as() + elif response in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT): + return True + else: + for grid in self._page_to_grid.values(): + grid.revert_changes() + + elif profile_was_switched: + # Profile was switched but no other changes - show simple dialog + current_label = self._get_current_profile_label() + original_label = self.profiles_grid.get_profile_label(self._original_profile) + dialog = Gtk.MessageDialog( + transient_for=self, + modal=True, + message_type=Gtk.MessageType.QUESTION, + buttons=Gtk.ButtonsType.NONE, + text=guilabels.PREFERENCES_PROFILE_SWITCHED, + ) + + use_button = dialog.add_button( + guilabels.PROFILE_USE % current_label, + Gtk.ResponseType.YES, + ) + use_button.get_style_context().add_class("suggested-action") + dialog.add_button( + guilabels.PROFILE_SWITCH_BACK_TO % original_label, + Gtk.ResponseType.NO, + ) + + dialog.show_all() + dialog.present() + use_button.grab_focus() + response = dialog.run() + dialog.destroy() + + if response == Gtk.ResponseType.NO: + profile_manager.get_manager().load_profile(self._original_profile) + + self.suspend_events("Window being closed.") + GObject.timeout_add(1000, self.resume_events) + + msg = "PREFERENCES: Window closure complete" + debug.print_message(debug.LEVEL_ALL, msg, True) + + return False + + _BASE_CSS = b""" + decoration { + border-radius: 15px; + } + @define-color cthulhu_sidebar_bg shade(@theme_bg_color, 0.98); + list.frame { + border-color: alpha(@theme_fg_color, 0.15); + } + .cthulhu-left-headerbar { + background-color: @cthulhu_sidebar_bg; + background-image: none; + border-bottom-width: 0; + } + .cthulhu-panel-headerbar { + background-color: @theme_bg_color; + background-image: none; + border-bottom-width: 0; + } + .cthulhu-sidebar { + background-color: @cthulhu_sidebar_bg; + } + .cthulhu-sidebar scrolledwindow, + .cthulhu-sidebar list { + background-color: transparent; + } + .cthulhu-sidebar list { + padding: 6px 0; + } + .cthulhu-sidebar list row { + border-radius: 9px; + min-height: 36px; + padding: 0 8px; + margin: 0 6px 2px; + } + .cthulhu-sidebar list row:selected { + background-color: alpha(@theme_fg_color, 0.10); + } + .cthulhu-sidebar list row:selected, + .cthulhu-sidebar list row:selected label { + color: @theme_fg_color; + } + .cthulhu-sidebar list row:hover { + background-color: alpha(@theme_fg_color, 0.07); + } + .cthulhu-sidebar list row:selected:hover { + background-color: alpha(@theme_fg_color, 0.13); + } + .cthulhu-sidebar list row:active, + .cthulhu-sidebar list row:selected:active { + background-color: alpha(@theme_fg_color, 0.19); + } + list.frame row:focus { + box-shadow: inset 0 0 0 2px alpha(@theme_selected_bg_color, 0.5); + } + """ + + _HIGH_CONTRAST_CSS = b""" + list.frame { + border-color: alpha(@theme_fg_color, 0.4); + } + list separator { + background-color: alpha(@theme_fg_color, 0.4); + } + .dim-label { + opacity: 1.0; + } + """ + + _DARK_MODE_CSS = b""" + @define-color cthulhu_sidebar_bg @theme_bg_color; + window.background { + background-color: @theme_base_color; + } + .cthulhu-panel-headerbar { + background-color: @theme_base_color; + } + switch slider { + background-image: image(white); + } + """ + + _GNOME_DARK_CSS = b""" + @define-color theme_bg_color #303030; + @define-color theme_base_color #242424; + @define-color cthulhu_sidebar_bg #2e2e32; + """ + + _STATUS_SHAPES_CSS = b""" + switch image { + color: @theme_fg_color; + } + switch:checked image { + color: white; + } + """ @staticmethod - def _plugin_active(active_plugins, names): - active_lower = {name.lower() for name in active_plugins} - return any(name.lower() in active_lower for name in names) + def _sync_appearance() -> tuple | None: + """Bridges GNOME's color-scheme and high-contrast gsettings to GTK3.""" - def _update_plugin_tabs(self, active_plugins=None): - if not self._plugin_tabs: - return - - notebook = self.get_widget("notebook") - if not notebook: - return - - if active_plugins is None: - active_plugins = self._get_active_plugins_for_tabs() - - for plugin_name, tab in self._plugin_tabs.items(): - if plugin_name in self._dynamic_plugin_tabs: - continue - page = tab["page"] - label = tab["label"] - position = tab["position"] - page_num = notebook.page_num(page) - is_active = self._plugin_active(active_plugins, [plugin_name]) - - if is_active and page_num == -1: - insert_pos = min(max(position, 0), notebook.get_n_pages()) - notebook.insert_page(page, label, insert_pos) - page.show_all() - label.show() - elif not is_active and page_num != -1: - notebook.remove_page(page_num) - - def _getACSSForVoiceType(self, voiceType): - """Return the ACSS value for the given voice type. - - Arguments: - - voiceType: one of DEFAULT, UPPERCASE, HYPERLINK, SYSTEM - - Returns the voice dictionary for the given voice type. - """ - - if voiceType == DEFAULT: - voiceACSS = self.defaultVoice - elif voiceType == UPPERCASE: - voiceACSS = self.uppercaseVoice - elif voiceType == HYPERLINK: - voiceACSS = self.hyperlinkVoice - elif voiceType == SYSTEM: - voiceACSS = self.systemVoice - else: - voiceACSS = self.defaultVoice - - return voiceACSS - - def writeUserPreferences(self): - """Write out the user's generic Cthulhu preferences. - """ - pronunciationDict = self.getModelDict(self.pronunciationModel) - keyBindingsDict = self.getKeyBindingsModelDict(self.keyBindingsModel) - self.prefsDict.update(self.script.getPreferencesFromGUI()) - self.prefsDict.update(self._get_plugin_preferences_from_gui()) - cthulhu.cthulhuApp.settingsManager.saveSettings(self.script, - self.prefsDict, - pronunciationDict, - keyBindingsDict) - - def _get_plugin_preferences_from_gui(self): - preferences = {} - for plugin_info in self._get_active_plugin_infos(): - plugin_instance = plugin_info.instance - if not plugin_instance: - continue - - if hasattr(plugin_instance, "getPreferencesFromGUI"): - plugin_prefs = plugin_instance.getPreferencesFromGUI() - elif hasattr(plugin_instance, "get_preferences_from_gui"): - plugin_prefs = plugin_instance.get_preferences_from_gui() - else: - continue - - if isinstance(plugin_prefs, dict): - preferences.update(plugin_prefs) - - return preferences - - def _getKeyValueForVoiceType(self, voiceType, key, useDefault=True): - """Look for the value of the given key in the voice dictionary - for the given voice type. - - Arguments: - - voiceType: one of DEFAULT, UPPERCASE, HYPERLINK, SYSTEM - - key: the key to look for in the voice dictionary. - - useDefault: if True, and the key isn't found for the given voice - type, the look for it in the default voice dictionary - as well. - - Returns the value of the given key, or None if it's not set. - """ - - if voiceType == DEFAULT: - voice = self.defaultVoice - elif voiceType == UPPERCASE: - voice = self.uppercaseVoice - if key not in voice: - if not useDefault: - return None - voice = self.defaultVoice - elif voiceType == HYPERLINK: - voice = self.hyperlinkVoice - if key not in voice: - if not useDefault: - return None - voice = self.defaultVoice - elif voiceType == SYSTEM: - voice = self.systemVoice - if key not in voice: - if not useDefault: - return None - voice = self.defaultVoice - else: - voice = self.defaultVoice - - if key in voice: - return voice[key] - else: + gtk_settings = Gtk.Settings.get_default() # pylint: disable=no-value-for-parameter + screen = Gdk.Screen.get_default() # pylint: disable=no-value-for-parameter + if gtk_settings is None or screen is None: return None - def _getFamilyNameForVoiceType(self, voiceType): - """Gets the name of the voice family for the given voice type. - - Arguments: - - voiceType: one of DEFAULT, UPPERCASE, HYPERLINK, SYSTEM - - Returns the name of the voice family for the given voice type, - or None if not set. - """ - - familyName = None - family = self._getKeyValueForVoiceType(voiceType, acss.ACSS.FAMILY) - - if family and speechserver.VoiceFamily.NAME in family: - familyName = family[speechserver.VoiceFamily.NAME] - - return familyName - - def _setFamilyNameForVoiceType(self, voiceType, name, language, dialect, variant): - """Sets the name of the voice family for the given voice type. - - Arguments: - - voiceType: one of DEFAULT, UPPERCASE, HYPERLINK, SYSTEM - - name: the name of the voice family to set. - - language: the locale of the voice family to set. - - dialect: the dialect of the voice family to set. - """ - - family = self._getKeyValueForVoiceType(voiceType, - acss.ACSS.FAMILY, - False) - - voiceACSS = self._getACSSForVoiceType(voiceType) - if family: - family[speechserver.VoiceFamily.NAME] = name - family[speechserver.VoiceFamily.LANG] = language - family[speechserver.VoiceFamily.DIALECT] = dialect - family[speechserver.VoiceFamily.VARIANT] = variant - else: - voiceACSS[acss.ACSS.FAMILY] = {} - voiceACSS[acss.ACSS.FAMILY][speechserver.VoiceFamily.NAME] = name - voiceACSS[acss.ACSS.FAMILY][speechserver.VoiceFamily.LANG] = language - voiceACSS[acss.ACSS.FAMILY][speechserver.VoiceFamily.DIALECT] = dialect - voiceACSS[acss.ACSS.FAMILY][speechserver.VoiceFamily.VARIANT] = variant - voiceACSS['established'] = True - - #settings.voices[voiceType] = voiceACSS - - def _getRateForVoiceType(self, voiceType): - """Gets the speaking rate value for the given voice type. - - Arguments: - - voiceType: one of DEFAULT, UPPERCASE, HYPERLINK, SYSTEM - - Returns the rate value for the given voice type, or None if - not set. - """ - - return self._getKeyValueForVoiceType(voiceType, acss.ACSS.RATE) - - def _setRateForVoiceType(self, voiceType, value): - """Sets the speaking rate value for the given voice type. - - Arguments: - - voiceType: one of DEFAULT, UPPERCASE, HYPERLINK, SYSTEM - - value: the rate value to set. - """ - - voiceACSS = self._getACSSForVoiceType(voiceType) - voiceACSS[acss.ACSS.RATE] = value - voiceACSS['established'] = True - #settings.voices[voiceType] = voiceACSS - - def _getPitchForVoiceType(self, voiceType): - """Gets the pitch value for the given voice type. - - Arguments: - - voiceType: one of DEFAULT, UPPERCASE, HYPERLINK, SYSTEM - - Returns the pitch value for the given voice type, or None if - not set. - """ - - return self._getKeyValueForVoiceType(voiceType, - acss.ACSS.AVERAGE_PITCH) - - def _setPitchForVoiceType(self, voiceType, value): - """Sets the pitch value for the given voice type. - - Arguments: - - voiceType: one of DEFAULT, UPPERCASE, HYPERLINK, SYSTEM - - value: the pitch value to set. - """ - - voiceACSS = self._getACSSForVoiceType(voiceType) - voiceACSS[acss.ACSS.AVERAGE_PITCH] = value - voiceACSS['established'] = True - #settings.voices[voiceType] = voiceACSS - - def _getVolumeForVoiceType(self, voiceType): - """Gets the volume (gain) value for the given voice type. - - Arguments: - - voiceType: one of DEFAULT, UPPERCASE, HYPERLINK, SYSTEM - - Returns the volume (gain) value for the given voice type, or - None if not set. - """ - - return self._getKeyValueForVoiceType(voiceType, acss.ACSS.GAIN) - - def _setVolumeForVoiceType(self, voiceType, value): - """Sets the volume (gain) value for the given voice type. - - Arguments: - - voiceType: one of DEFAULT, UPPERCASE, HYPERLINK, SYSTEM - - value: the volume (gain) value to set. - """ - - voiceACSS = self._getACSSForVoiceType(voiceType) - voiceACSS[acss.ACSS.GAIN] = value - voiceACSS['established'] = True - #settings.voices[voiceType] = voiceACSS - - def _setVoiceSettingsForVoiceType(self, voiceType): - """Sets the family, rate, pitch and volume GUI components based - on the given voice type. - - Arguments: - - voiceType: one of DEFAULT, UPPERCASE, HYPERLINK, SYSTEM - """ - - familyName = self._getFamilyNameForVoiceType(voiceType) - self._setSpeechFamiliesChoice(familyName) - - rate = self._getRateForVoiceType(voiceType) - if rate is not None: - self.get_widget("rateScale").set_value(rate) - else: - self.get_widget("rateScale").set_value(50.0) - - pitch = self._getPitchForVoiceType(voiceType) - if pitch is not None: - self.get_widget("pitchScale").set_value(pitch) - else: - self.get_widget("pitchScale").set_value(5.0) - - volume = self._getVolumeForVoiceType(voiceType) - if volume is not None: - self.get_widget("volumeScale").set_value(volume) - else: - self.get_widget("volumeScale").set_value(10.0) - - def _setSpeechFamiliesChoice(self, familyName): - """Sets the active item in the families ("Person:") combo box - to the given family name. - - Arguments: - - familyName: the family name to use to set the active combo box item. - """ - - if len(self.speechFamilies) == 0: - return - - languageSet = False - familySet = False - for family in self.speechFamilies: - name = family[speechserver.VoiceFamily.NAME] - if name == familyName: - lang = family[speechserver.VoiceFamily.LANG] - dialect = family[speechserver.VoiceFamily.DIALECT] - - if dialect: - language = lang + '-' + dialect - else: - language = lang - - i = 0 - for languageChoice in self.speechLanguagesChoices: - if languageChoice == language: - self.get_widget("speechLanguages").set_active(i) - self.speechLanguagesChoice = self.speechLanguagesChoices[i] - languageSet = True - - self._setupFamilies() - - i = 0 - for familyChoice in self.speechFamiliesChoices: - name = familyChoice[speechserver.VoiceFamily.NAME] - if name == familyName: - self._setSpeechFamiliesActive(i) - self.speechFamiliesChoice = self.speechFamiliesChoices[i] - familySet = True - break - i += 1 - - break - - i += 1 - - break - - if not languageSet: - tokens = ["PREFERENCES DIALOG: Could not find speech language match for", familyName] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - self.get_widget("speechLanguages").set_active(0) - self.speechLanguagesChoice = self.speechLanguagesChoices[0] - - if languageSet: - self.selectedLanguageChoices[self.speechServersChoice] = i - - if not familySet: - tokens = ["PREFERENCES DIALOG: Could not find speech family match for", familyName] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - self._setSpeechFamiliesActive(0) - self.speechFamiliesChoice = self.speechFamiliesChoices[0] - - if familySet: - self.selectedFamilyChoices[self.speechServersChoice, - self.speechLanguagesChoice] = i - - def _setSpeechFamiliesActive(self, index): - """Sets speechFamilies active index while suppressing preview chatter.""" - - self._updatingSpeechFamilies = True - try: - self.get_widget("speechFamilies").set_active(index) - finally: - self._updatingSpeechFamilies = False - - def _setupFamilies(self): - """Gets the list of voice variants for the current speech server and - current language. - If there are variants, get the information associated with - each voice variant and add an entry for it to the variants - GtkComboBox list. - """ - - combobox = self.get_widget("speechFamilies") - combobox.set_model(None) - self.speechFamiliesModel.clear() - - currentLanguage = self.speechLanguagesChoice - - i = 0 - self.speechFamiliesChoices = [] - for family in self.speechFamilies: - lang = family[speechserver.VoiceFamily.LANG] - dialect = family[speechserver.VoiceFamily.DIALECT] - - if dialect: - language = lang + '-' + dialect - else: - language = lang - - if language != currentLanguage: - continue - - name = family[speechserver.VoiceFamily.NAME] - self.speechFamiliesChoices.append(family) - self.speechFamiliesModel.append((i, name)) - i += 1 - - combobox.set_model(self.speechFamiliesModel) - if i == 0: - tokens = ["No speech family was available for", str(currentLanguage), "."] - debug.printTokens(debug.LEVEL_SEVERE, tokens, True) - debug.printStack(debug.LEVEL_FINEST) - self.speechFamiliesChoice = None - return - - # If user manually selected a family for the current speech server - # this choice it's restored. In other case the first family - # (usually the default one) is selected - # - selectedIndex = 0 - if (self.speechServersChoice, self.speechLanguagesChoice) \ - in self.selectedFamilyChoices: - selectedIndex = self.selectedFamilyChoices[self.speechServersChoice, - self.speechLanguagesChoice] - - self._setSpeechFamiliesActive(selectedIndex) - - def _setSpeechLanguagesChoice(self, languageName): - """Sets the active item in the languages ("Language:") combo box - to the given language name. - - Arguments: - - languageName: the language name to use to set the active combo box item. - """ - - if len(self.speechLanguagesChoices) == 0: - return - - valueSet = False - i = 0 - for language in self.speechLanguagesChoices: - if language == languageName: - self.get_widget("speechLanguages").set_active(i) - self.speechLanguagesChoice = self.speechLanguagesChoices[i] - valueSet = True - break - i += 1 - - if not valueSet: - tokens = ["PREFERENCES DIALOG: Could not find speech language match for", languageName] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - self.get_widget("speechLanguages").set_active(0) - self.speechLanguagesChoice = self.speechLanguagesChoices[0] - - if valueSet: - self.selectedLanguageChoices[self.speechServersChoice] = i - - self._setupFamilies() - - def _setupVoices(self): - """Gets the list of voices for the current speech server. - If there are families, get the information associated with - each voice family and add an entry for it to the families - GtkComboBox list. - """ - - combobox = self.get_widget("speechLanguages") - combobox.set_model(None) - self.speechLanguagesModel.clear() - self.speechFamilies = self.speechServersChoice.getVoiceFamilies() - self.speechLanguagesChoices = [] - - if len(self.speechFamilies) == 0: - debug.printMessage(debug.LEVEL_SEVERE, "No speech voice was available.") - debug.printStack(debug.LEVEL_FINEST) - self.speechLanguagesChoice = None - return - - done = {} - i = 0 - for family in self.speechFamilies: - lang = family[speechserver.VoiceFamily.LANG] - dialect = family[speechserver.VoiceFamily.DIALECT] - if (lang,dialect) in done: - continue - done[lang,dialect] = True - - if dialect: - language = lang + '-' + dialect - else: - language = lang - - # TODO: get translated language name from CLDR or such - msg = language - if msg == "": - # Unsupported locale - msg = "default language" - - self.speechLanguagesChoices.append(language) - self.speechLanguagesModel.append((i, msg)) - i += 1 - - # If user manually selected a language for the current speech server - # this choice it's restored. In other case the first language - # (usually the default one) is selected - # - selectedIndex = 0 - if self.speechServersChoice in self.selectedLanguageChoices: - selectedIndex = self.selectedLanguageChoices[self.speechServersChoice] - - combobox.set_model(self.speechLanguagesModel) - - self.get_widget("speechLanguages").set_active(selectedIndex) - if self.initializingSpeech: - self.speechLanguagesChoice = self.speechLanguagesChoices[selectedIndex] - - self._setupFamilies() - - # The family name will be selected as part of selecting the - # voice type. Whenever the families change, we'll reset the - # voice type selection to the first one ("Default"). - # - comboBox = self.get_widget("voiceTypesCombo") - types = [guilabels.SPEECH_VOICE_TYPE_DEFAULT, - guilabels.SPEECH_VOICE_TYPE_UPPERCASE, - guilabels.SPEECH_VOICE_TYPE_HYPERLINK, - guilabels.SPEECH_VOICE_TYPE_SYSTEM] - self.populateComboBox(comboBox, types) - comboBox.set_active(DEFAULT) - voiceType = comboBox.get_active() - self._setVoiceSettingsForVoiceType(voiceType) - - def _setSpeechServersChoice(self, serverInfo): - """Sets the active item in the speech servers combo box to the - given server. - - Arguments: - - serversChoices: the list of available speech servers. - - serverInfo: the speech server to use to set the active combo - box item. - """ - - if len(self.speechServersChoices) == 0: - return - - # We'll fallback to whatever we happen to be using in the event - # that this preference has never been set. - # - if not serverInfo: - serverInfo = speech.getInfo() - if serverInfo and len(serverInfo) >= 2 and serverInfo[1] == 'default': - defaultFamily = None - voices = self.prefsDict.get("voices", {}) - defaultVoice = voices.get(settings.DEFAULT_VOICE) if voices else None - if defaultVoice: - defaultFamily = acss.ACSS(defaultVoice).get(acss.ACSS.FAMILY) - - resolved = self._resolveSpeechDispatcherServerForFamily(defaultFamily) - if resolved is not None: - serverInfo = resolved.getInfo() - - valueSet = False - i = 0 - for server in self.speechServersChoices: - if serverInfo == server.getInfo(): - self.get_widget("speechServers").set_active(i) - self.speechServersChoice = server - valueSet = True - break - i += 1 - - if not valueSet: - tokens = ["PREFERENCES DIALOG: Could not find speech server match for", serverInfo] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - self.get_widget("speechServers").set_active(0) - self.speechServersChoice = self.speechServersChoices[0] - - self._setupVoices() - - def _setupSpeechServers(self): - """Gets the list of speech servers for the current speech factory. - If there are servers, get the information associated with each - speech server and add an entry for it to the speechServers - GtkComboBox list. Set the current choice to be the first item. - """ - - combobox = self.get_widget("speechServers") - combobox.set_model(None) - self.speechServersModel.clear() - self.speechServersChoices = \ - self.speechSystemsChoice.SpeechServer.getSpeechServers() - if len(self.speechServersChoices) == 0: - debug.printMessage(debug.LEVEL_SEVERE, "Speech not available.") - debug.printStack(debug.LEVEL_FINEST) - self.speechServersChoice = None - self.speechLanguagesChoices = [] - self.speechLanguagesChoice = None - self.speechFamiliesChoices = [] - self.speechFamiliesChoice = None - combobox.set_model(self.speechServersModel) - return - - i = 0 - for server in self.speechServersChoices: - name = server.getInfo()[0] - self.speechServersModel.append((i, name)) - i += 1 - - combobox.set_model(self.speechServersModel) - self._setSpeechServersChoice(self.prefsDict["speechServerInfo"]) - - def _setSpeechSystemsChoice(self, systemName): - """Set the active item in the speech systems combo box to the - given system name. - - Arguments: - - factoryChoices: the list of available speech factories (systems). - - systemName: the speech system name to use to set the active combo - box item. - """ - - systemName = systemName.strip("'") - - if len(self.speechSystemsChoices) == 0: - self.speechSystemsChoice = None - return - - valueSet = False - i = 0 - for speechSystem in self.speechSystemsChoices: - name = speechSystem.__name__ - if name.endswith(systemName): - self.get_widget("speechSystems").set_active(i) - self.speechSystemsChoice = self.speechSystemsChoices[i] - valueSet = True - break - i += 1 - - if not valueSet: - tokens = ["PREFERENCES DIALOG: Could not find speech system match for", systemName] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - self.get_widget("speechSystems").set_active(0) - self.speechSystemsChoice = self.speechSystemsChoices[0] - - self._setupSpeechServers() - - def _setupSpeechSystems(self, factories): - """Sets up the speech systems combo box and sets the selection - to the preferred speech system. - - Arguments: - -factories: the list of known speech factories (working or not) - """ - - combobox = self.get_widget("speechSystems") - combobox.set_model(None) - self.speechSystemsModel.clear() - self.workingFactories = [] - for factory in factories: - try: - servers = factory.SpeechServer.getSpeechServers() - if len(servers): - self.workingFactories.append(factory) - except Exception: - debug.printException(debug.LEVEL_FINEST) - - self.speechSystemsChoices = [] - if len(self.workingFactories) == 0: - debug.printMessage(debug.LEVEL_SEVERE, "Speech not available.") - debug.printStack(debug.LEVEL_FINEST) - self.speechSystemsChoice = None - self.speechServersChoices = [] - self.speechServersChoice = None - self.speechLanguagesChoices = [] - self.speechLanguagesChoice = None - self.speechFamiliesChoices = [] - self.speechFamiliesChoice = None - combobox.set_model(self.speechSystemsModel) - return - - i = 0 - for workingFactory in self.workingFactories: - self.speechSystemsChoices.append(workingFactory) - name = workingFactory.SpeechServer.getFactoryName() - self.speechSystemsModel.append((i, name)) - i += 1 - - combobox.set_model(self.speechSystemsModel) - if self.prefsDict["speechServerFactory"]: - self._setSpeechSystemsChoice(self.prefsDict["speechServerFactory"]) - else: - self.speechSystemsChoice = None - - def _initSpeechState(self): - """Initialize the various speech components. - """ - - voices = self.prefsDict["voices"] - self.defaultVoice = acss.ACSS(voices.get(settings.DEFAULT_VOICE)) - self.uppercaseVoice = acss.ACSS(voices.get(settings.UPPERCASE_VOICE)) - self.hyperlinkVoice = acss.ACSS(voices.get(settings.HYPERLINK_VOICE)) - self.systemVoice = acss.ACSS(voices.get(settings.SYSTEM_VOICE)) - - # Just a note on general naming pattern: - # - # * = The name of the combobox - # *Model = the name of the comobox model - # *Choices = the Cthulhu/speech python objects - # *Choice = a value from *Choices - # - # Where * = speechSystems, speechServers, speechLanguages, speechFamilies - # - factories = cthulhu.cthulhuApp.settingsManager.getSpeechServerFactories() - if len(factories) == 0 or not self.prefsDict.get('enableSpeech', True): - self.workingFactories = [] - self.speechSystemsChoice = None - self.speechServersChoices = [] - self.speechServersChoice = None - self.speechLanguagesChoices = [] - self.speechLanguagesChoice = None - self.speechFamiliesChoices = [] - self.speechFamiliesChoice = None - return + base_provider = Gtk.CssProvider() + base_provider.load_from_data(CthulhuSetupGUI._BASE_CSS) + Gtk.StyleContext.add_provider_for_screen( + screen, + base_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + + providers = _AppearanceProviders( + hc=Gtk.CssProvider(), + dark=Gtk.CssProvider(), + ) + providers.hc.load_from_data(CthulhuSetupGUI._HIGH_CONTRAST_CSS) + dark_css = CthulhuSetupGUI._DARK_MODE_CSS + if "GNOME" in os.environ.get("XDG_CURRENT_DESKTOP", ""): + dark_css += CthulhuSetupGUI._GNOME_DARK_CSS + providers.dark.load_from_data(dark_css) try: - speech.init() - except Exception: - self.workingFactories = [] - self.speechSystemsChoice = None - self.speechServersChoices = [] - self.speechServersChoice = None - self.speechLanguagesChoices = [] - self.speechLanguagesChoice = None - self.speechFamiliesChoices = [] - self.speechFamiliesChoice = None - return - - # This cascades into systems->servers->voice_type->families... - # - self.initializingSpeech = True - self._setupSpeechSystems(factories) - self.initializingSpeech = False - - def _getSpeechDispatcherFactory(self): - """Returns the Speech Dispatcher factory if available.""" - - factories = cthulhu.cthulhuApp.settingsManager.getSpeechServerFactories() - for factory in factories: - try: - if factory.SpeechServer.getFactoryName() == guilabels.SPEECH_DISPATCHER: - return factory - except Exception: - debug.printException(debug.LEVEL_FINEST) - - return None - - def _setEchoVoiceFamily(self, family): - """Sets echo voice family from the selected server family object.""" - - if not family: - return - - name = family.get(speechserver.VoiceFamily.NAME) - language = family.get(speechserver.VoiceFamily.LANG) - dialect = family.get(speechserver.VoiceFamily.DIALECT) - variant = family.get(speechserver.VoiceFamily.VARIANT) - self.echoVoice[acss.ACSS.FAMILY] = { - speechserver.VoiceFamily.NAME: name, - speechserver.VoiceFamily.LANG: language, - speechserver.VoiceFamily.DIALECT: dialect, - speechserver.VoiceFamily.VARIANT: variant, - } - self.echoVoice['established'] = True - - def _resolveSpeechDispatcherServerForFamily(self, family): - """Returns a concrete Speech Dispatcher server matching the voice family.""" - - if not family or not self.speechServersChoices: - return None - - name = family.get(speechserver.VoiceFamily.NAME) - language = family.get(speechserver.VoiceFamily.LANG) - dialect = family.get(speechserver.VoiceFamily.DIALECT) - variant = family.get(speechserver.VoiceFamily.VARIANT) - if not name: - return None - - for server in self.speechServersChoices: - info = server.getInfo() - if not info or len(info) < 2 or info[1] == 'default': - continue - - try: - families = server.getVoiceFamilies() - except Exception: - debug.printException(debug.LEVEL_FINEST) - continue - - for candidate in families: - if candidate.get(speechserver.VoiceFamily.NAME) != name: - continue - if candidate.get(speechserver.VoiceFamily.LANG) != language: - continue - if candidate.get(speechserver.VoiceFamily.DIALECT) != dialect: - continue - if candidate.get(speechserver.VoiceFamily.VARIANT) != variant: - continue - - tokens = [ - "PREFERENCES DIALOG: Resolved voice family", - name, - "to speech server", - info, - ] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return server - - tokens = [ - "PREFERENCES DIALOG: Could not resolve speech server for family", - family, - ] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return None - - def _getSpeechServerChoiceForSave(self): - """Returns the server choice that should be persisted for speech settings.""" - - server = self.speechServersChoice - if not server or self.speechSystemsChoice != self._getSpeechDispatcherFactory(): - return server - - info = server.getInfo() - if not info or len(info) < 2 or info[1] != 'default': - return server - - defaultFamily = None - if self.defaultVoice is not None: - defaultFamily = self.defaultVoice.get(acss.ACSS.FAMILY) - - resolved = self._resolveSpeechDispatcherServerForFamily(defaultFamily) - return resolved or server - - def _getEchoSpeechServerFamilyForSave(self): - """Returns the most specific echo family to use for server resolution.""" - - family = None - if self.echoVoice is not None: - family = self.echoVoice.get(acss.ACSS.FAMILY) - - if family: - return family - - if self.defaultVoice is not None: - return self.defaultVoice.get(acss.ACSS.FAMILY) - - return None - - def _getEchoSpeechServerChoiceForSave(self): - """Returns the echo speech server choice that should be persisted.""" - - server = self.echoSpeechServersChoice - if server is None: - return None - - info = server.getInfo() - if not info or len(info) < 2 or info[1] != 'default': - return server - - family = self._getEchoSpeechServerFamilyForSave() - resolved = self._resolveSpeechDispatcherServerForFamily(family) - if resolved is not None: - return resolved - - speechServer = self._getSpeechServerChoiceForSave() - if speechServer is not None: - speechInfo = speechServer.getInfo() - if speechInfo and len(speechInfo) >= 2 and speechInfo[1] != 'default': - tokens = [ - "PREFERENCES DIALOG: Falling back to main speech server for echo", - speechInfo, - ] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return speechServer - - return server - - def _populateEchoSpeechFamilies(self, families): - """Populate the echo family combobox from the provided families list.""" - - combobox = self.get_widget("echoSpeechFamilies") - combobox.set_model(None) - self.echoSpeechFamiliesModel.clear() - self.echoSpeechFamiliesChoices = list(families or []) - self.echoSpeechFamiliesChoice = None - - selectedIndex = 0 - selectedMatchFound = False - selectedFamily = self.echoVoice.get(acss.ACSS.FAMILY) - selectedName = selectedLanguage = selectedDialect = selectedVariant = None - if selectedFamily: - selectedName = selectedFamily.get(speechserver.VoiceFamily.NAME) - selectedLanguage = selectedFamily.get(speechserver.VoiceFamily.LANG) - selectedDialect = selectedFamily.get(speechserver.VoiceFamily.DIALECT) - selectedVariant = selectedFamily.get(speechserver.VoiceFamily.VARIANT) - - for i, family in enumerate(self.echoSpeechFamiliesChoices): - name = family.get(speechserver.VoiceFamily.NAME) or "" - language = family.get(speechserver.VoiceFamily.LANG) or "" - dialect = family.get(speechserver.VoiceFamily.DIALECT) or "" - variant = family.get(speechserver.VoiceFamily.VARIANT) - display = name - locale = language - if dialect: - locale = f"{language}-{dialect}" if language else dialect - if locale: - display = f"{name} ({locale})" - self.echoSpeechFamiliesModel.append((i, display)) - if selectedName == name and selectedLanguage == language \ - and selectedDialect == dialect and selectedVariant == variant: - selectedIndex = i - selectedMatchFound = True - - combobox.set_model(self.echoSpeechFamiliesModel) - if self.echoSpeechFamiliesChoices: - self._updatingEchoSpeechFamilies = True - try: - combobox.set_active(selectedIndex) - finally: - self._updatingEchoSpeechFamilies = False - self.echoSpeechFamiliesChoice = self.echoSpeechFamiliesChoices[selectedIndex] - - def _fetchEchoSpeechFamiliesWorker(self, serverInfo, requestToken): - """Fetch module-specific voices in a subprocess and apply asynchronously.""" - - queryScript = """ -import json -import sys -from cthulhu import speechdispatcherfactory -from cthulhu import speechserver - -serverInfo = json.loads(sys.argv[1]) -if isinstance(serverInfo, list): - serverInfo = tuple(serverInfo) - -server = speechdispatcherfactory.SpeechServer.getSpeechServer(serverInfo) -families = server.getVoiceFamilies() if server else [] - -result = [] -for family in families: - result.append({ - "name": family.get(speechserver.VoiceFamily.NAME), - "lang": family.get(speechserver.VoiceFamily.LANG), - "dialect": family.get(speechserver.VoiceFamily.DIALECT), - "variant": family.get(speechserver.VoiceFamily.VARIANT), - }) - -print(json.dumps(result)) -""" - - familyDefs = None - try: - completed = subprocess.run( - [sys.executable, "-c", queryScript, json.dumps(serverInfo)], - capture_output=True, - text=True, - timeout=4.0, - check=False, - ) - if completed.returncode == 0 and completed.stdout.strip(): - familyDefs = json.loads(completed.stdout.strip()) - else: - tokens = [ - "PREFERENCES DIALOG: Echo voice fetch failed for", - serverInfo, - "rc=", - completed.returncode, - ] - debug.printTokens(debug.LEVEL_WARNING, tokens, True) - except subprocess.TimeoutExpired: - tokens = ["PREFERENCES DIALOG: Echo voice fetch timed out for", serverInfo] - debug.printTokens(debug.LEVEL_WARNING, tokens, True) - except Exception: - debug.printException(debug.LEVEL_WARNING) - - GLib.idle_add(self._applyFetchedEchoSpeechFamilies, requestToken, familyDefs) - - def _applyFetchedEchoSpeechFamilies(self, requestToken, familyDefs): - """Apply fetched echo families if this is still the active request.""" - - if requestToken != self._echoVoiceFetchToken: - return False - - if not isinstance(familyDefs, list): - return False - - families = [] - for familyDef in familyDefs: - if not isinstance(familyDef, dict): - continue - families.append( - speechserver.VoiceFamily({ - speechserver.VoiceFamily.NAME: familyDef.get("name"), - speechserver.VoiceFamily.LANG: familyDef.get("lang"), - speechserver.VoiceFamily.DIALECT: familyDef.get("dialect"), - speechserver.VoiceFamily.VARIANT: familyDef.get("variant"), - }) + interface_settings = Gio.Settings(schema_id="org.gnome.desktop.interface") + a11y_settings = Gio.Settings(schema_id="org.gnome.desktop.a11y.interface") + if "show-status-shapes" in a11y_settings.list_keys(): + shapes_provider = Gtk.CssProvider() + shapes_provider.load_from_data(CthulhuSetupGUI._STATUS_SHAPES_CSS) + providers.shapes = shapes_provider + CthulhuSetupGUI._apply_appearance( + interface_settings, + a11y_settings, + gtk_settings, + screen, + providers, ) - if not families: - return False - - self._populateEchoSpeechFamilies(families) - return False - - def _setupEchoSpeechFamilies(self): - """Populate echo voice family list for the current echo server.""" - self._echoVoiceFetchToken += 1 - requestToken = self._echoVoiceFetchToken - useCustomModule = self.get_widget("useCustomEchoSpeechServerCheckButton").get_active() - fallbackFamilies = list(self.speechFamiliesChoices or []) - self._populateEchoSpeechFamilies(fallbackFamilies) - - if useCustomModule and self.echoSpeechServersChoice: - serverInfo = self.echoSpeechServersChoice.getInfo() - thread = threading.Thread( - target=self._fetchEchoSpeechFamiliesWorker, - args=(serverInfo, requestToken), - daemon=True, - ) - thread.start() - - def _setEchoSpeechServersChoice(self, serverInfo): - """Set the active echo module based on stored server info.""" - - if not self.echoSpeechServersChoices: - self.echoSpeechServersChoice = None - self._setupEchoSpeechFamilies() - return - - if serverInfo and len(serverInfo) >= 2 and serverInfo[1] == 'default': - family = self._getEchoSpeechServerFamilyForSave() - resolved = self._resolveSpeechDispatcherServerForFamily(family) - if resolved is None: - speechServer = self._getSpeechServerChoiceForSave() - if speechServer is not None: - speechInfo = speechServer.getInfo() - if speechInfo and len(speechInfo) >= 2 and speechInfo[1] != 'default': - resolved = speechServer - tokens = [ - "PREFERENCES DIALOG: Reusing main speech server for echo", - speechInfo, - ] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - if resolved is not None: - serverInfo = resolved.getInfo() - - valueSet = False - for i, server in enumerate(self.echoSpeechServersChoices): - info = server.getInfo() - if serverInfo and info == serverInfo: - self.get_widget("echoSpeechServers").set_active(i) - self.echoSpeechServersChoice = server - valueSet = True - break - - if not valueSet: - self.get_widget("echoSpeechServers").set_active(0) - self.echoSpeechServersChoice = self.echoSpeechServersChoices[0] - - self._setupEchoSpeechFamilies() - - def _setupEchoSpeechServers(self): - """Populate available speech-dispatcher modules for echo.""" - - combobox = self.get_widget("echoSpeechServers") - combobox.set_model(None) - self.echoSpeechServersModel.clear() - self.echoSpeechServersChoices = [] - self.echoSpeechServersChoice = None - - if not self.prefsDict.get('enableSpeech', True): - combobox.set_model(self.echoSpeechServersModel) - self._setupEchoSpeechFamilies() - return - - factory = self._getSpeechDispatcherFactory() - if factory is None: - combobox.set_model(self.echoSpeechServersModel) - self._setupEchoSpeechFamilies() - return - - try: - self.echoSpeechServersChoices = factory.SpeechServer.getSpeechServers() - except Exception: - debug.printException(debug.LEVEL_WARNING) - self.echoSpeechServersChoices = [] - - for i, server in enumerate(self.echoSpeechServersChoices): - name = server.getInfo()[0] - self.echoSpeechServersModel.append((i, name)) - - combobox.set_model(self.echoSpeechServersModel) - self._setEchoSpeechServersChoice(self.prefsDict.get("echoSpeechServerInfo")) - - def _initEchoSpeechState(self): - """Initialize echo voice controls and choices.""" - - self.echoVoice = acss.ACSS( - self.prefsDict.get("echoVoice", settings.echoVoice) or {}) - - baseVoice = self.defaultVoice or acss.ACSS( - self.prefsDict.get("voices", {}).get(settings.DEFAULT_VOICE, {})) - - rate = self.echoVoice.get(acss.ACSS.RATE, baseVoice.get(acss.ACSS.RATE, 50.0)) - pitch = self.echoVoice.get(acss.ACSS.AVERAGE_PITCH, baseVoice.get(acss.ACSS.AVERAGE_PITCH, 5.0)) - volume = self.echoVoice.get(acss.ACSS.GAIN, baseVoice.get(acss.ACSS.GAIN, 10.0)) - - self.get_widget("echoRateScale").set_value(rate) - self.get_widget("echoPitchScale").set_value(pitch) - self.get_widget("echoVolumeScale").set_value(volume) - - self.initializingEchoSpeech = True - self._setupEchoSpeechServers() - self.initializingEchoSpeech = False - - self._setEchoVoiceItems() - - def _setSpokenTextAttributes(self, view, setAttributes, - state, moveToTop=False): - """Given a set of spoken text attributes, update the model used by the - text attribute tree view. - - Arguments: - - view: the text attribute tree view. - - setAttributes: the list of spoken text attributes to update. - - state: the state (True or False) that they all should be set to. - - moveToTop: if True, move these attributes to the top of the list. - """ - - model = view.get_model() - view.set_model(None) - - [attrList, attrDict] = \ - self.script.utilities.stringToKeysAndDict(setAttributes) - [allAttrList, allAttrDict] = self.script.utilities.stringToKeysAndDict( - cthulhu.cthulhuApp.settingsManager.getSetting('allTextAttributes')) - - for i in range(0, len(attrList)): - for path in range(0, len(allAttrList)): - localizedKey = text_attribute_names.getTextAttributeName( - attrList[i], self.script) - localizedValue = text_attribute_names.getTextAttributeName( - attrDict[attrList[i]], self.script) - if localizedKey == model[path][NAME]: - thisIter = model.get_iter(path) - model.set_value(thisIter, NAME, localizedKey) - model.set_value(thisIter, IS_SPOKEN, state) - model.set_value(thisIter, VALUE, localizedValue) - if moveToTop: - thisIter = model.get_iter(path) - otherIter = model.get_iter(i) - model.move_before(thisIter, otherIter) - break - - view.set_model(model) - - def _setBrailledTextAttributes(self, view, setAttributes, state): - """Given a set of brailled text attributes, update the model used - by the text attribute tree view. - - Arguments: - - view: the text attribute tree view. - - setAttributes: the list of brailled text attributes to update. - - state: the state (True or False) that they all should be set to. - """ - - model = view.get_model() - view.set_model(None) - - [attrList, attrDict] = \ - self.script.utilities.stringToKeysAndDict(setAttributes) - [allAttrList, allAttrDict] = self.script.utilities.stringToKeysAndDict( - cthulhu.cthulhuApp.settingsManager.getSetting('allTextAttributes')) - - for i in range(0, len(attrList)): - for path in range(0, len(allAttrList)): - localizedKey = text_attribute_names.getTextAttributeName( - attrList[i], self.script) - if localizedKey == model[path][NAME]: - thisIter = model.get_iter(path) - model.set_value(thisIter, IS_BRAILLED, state) - break - - view.set_model(model) - - def _getAppNameForAttribute(self, attributeName): - """Converts the given Atk attribute name into the application's - equivalent. This is necessary because an application or toolkit - (e.g. Gecko) might invent entirely new names for the same text - attributes. - - Arguments: - - attribName: The name of the text attribute - - Returns the application's equivalent name if found or attribName - otherwise. - """ - - return self.script.utilities.getAppNameForAttribute(attributeName) - - def _updateTextDictEntry(self): - """The user has updated the text attribute list in some way. Update - the "enabledSpokenTextAttributes" and "enabledBrailledTextAttributes" - preference strings to reflect the current state of the corresponding - text attribute lists. - """ - - model = self.getTextAttributesView.get_model() - spokenAttrStr = "" - brailledAttrStr = "" - noRows = model.iter_n_children(None) - for path in range(0, noRows): - localizedKey = model[path][NAME] - key = text_attribute_names.getTextAttributeKey(localizedKey) - - # Convert the normalized, Atk attribute name back into what - # the app/toolkit uses. - # - key = self._getAppNameForAttribute(key) - - localizedValue = model[path][VALUE] - value = text_attribute_names.getTextAttributeKey(localizedValue) - - if model[path][IS_SPOKEN]: - spokenAttrStr += key + ":" + value + "; " - if model[path][IS_BRAILLED]: - brailledAttrStr += key + ":" + value + "; " - - self.prefsDict["enabledSpokenTextAttributes"] = spokenAttrStr - self.prefsDict["enabledBrailledTextAttributes"] = brailledAttrStr - - def contractedBrailleToggled(self, checkbox): - grid = self.get_widget('contractionTableGrid') - grid.set_sensitive(checkbox.get_active()) - self.prefsDict["enableContractedBraille"] = checkbox.get_active() - - def contractionTableComboChanged(self, combobox): - model = combobox.get_model() - myIter = combobox.get_active_iter() - self.prefsDict["brailleContractionTable"] = model[myIter][1] - - def flashPersistenceToggled(self, checkbox): - grid = self.get_widget('flashMessageDurationGrid') - grid.set_sensitive(not checkbox.get_active()) - self.prefsDict["flashIsPersistent"] = checkbox.get_active() - - def textAttributeSpokenToggled(self, cell, path, model): - """The user has toggled the state of one of the text attribute - checkboxes to be spoken. Update our model to reflect this, then - update the "enabledSpokenTextAttributes" preference string. - - Arguments: - - cell: the cell that changed. - - path: the path of that cell. - - model: the model that the cell is part of. - """ - - thisIter = model.get_iter(path) - model.set(thisIter, IS_SPOKEN, not model[path][IS_SPOKEN]) - self._updateTextDictEntry() - - def textAttributeBrailledToggled(self, cell, path, model): - """The user has toggled the state of one of the text attribute - checkboxes to be brailled. Update our model to reflect this, - then update the "enabledBrailledTextAttributes" preference string. - - Arguments: - - cell: the cell that changed. - - path: the path of that cell. - - model: the model that the cell is part of. - """ - - thisIter = model.get_iter(path) - model.set(thisIter, IS_BRAILLED, not model[path][IS_BRAILLED]) - self._updateTextDictEntry() - - def textAttrValueEdited(self, cell, path, new_text, model): - """The user has edited the value of one of the text attributes. - Update our model to reflect this, then update the - "enabledSpokenTextAttributes" and "enabledBrailledTextAttributes" - preference strings. - - Arguments: - - cell: the cell that changed. - - path: the path of that cell. - - new_text: the new text attribute value string. - - model: the model that the cell is part of. - """ - - thisIter = model.get_iter(path) - model.set(thisIter, VALUE, new_text) - self._updateTextDictEntry() - - def textAttrCursorChanged(self, widget): - """Set the search column in the text attribute tree view - depending upon which column the user currently has the cursor in. - """ - - [path, focusColumn] = self.getTextAttributesView.get_cursor() - if focusColumn: - noColumns = len(self.getTextAttributesView.get_columns()) - for i in range(0, noColumns): - col = self.getTextAttributesView.get_column(i) - if focusColumn == col: - self.getTextAttributesView.set_search_column(i) - break - - def _createTextAttributesTreeView(self): - """Create the text attributes tree view. The view is the - textAttributesTreeView GtkTreeView widget. The view will consist - of a list containing three columns: - IS_SPOKEN - a checkbox whose state indicates whether this text - attribute will be spoken or not. - NAME - the text attribute name. - VALUE - if set, (and this attributes is enabled for speaking), - then this attribute will be spoken unless it equals - this value. - """ - - self.getTextAttributesView = self.get_widget("textAttributesTreeView") - - if self.getTextAttributesView.get_columns(): - for column in self.getTextAttributesView.get_columns(): - self.getTextAttributesView.remove_column(column) - - model = Gtk.ListStore(GObject.TYPE_STRING, - GObject.TYPE_BOOLEAN, - GObject.TYPE_BOOLEAN, - GObject.TYPE_STRING) - - # Initially setup the list store model based on the values of all - # the known text attributes. - # - [allAttrList, allAttrDict] = self.script.utilities.stringToKeysAndDict( - cthulhu.cthulhuApp.settingsManager.getSetting('allTextAttributes')) - for i in range(0, len(allAttrList)): - thisIter = model.append() - localizedKey = text_attribute_names.getTextAttributeName( - allAttrList[i], self.script) - localizedValue = text_attribute_names.getTextAttributeName( - allAttrDict[allAttrList[i]], self.script) - model.set_value(thisIter, NAME, localizedKey) - model.set_value(thisIter, IS_SPOKEN, False) - model.set_value(thisIter, IS_BRAILLED, False) - model.set_value(thisIter, VALUE, localizedValue) - - self.getTextAttributesView.set_model(model) - - # Attribute Name column (NAME). - column = Gtk.TreeViewColumn(guilabels.TEXT_ATTRIBUTE_NAME) - column.set_min_width(250) - column.set_resizable(True) - renderer = Gtk.CellRendererText() - column.pack_end(renderer, True) - column.add_attribute(renderer, 'text', NAME) - self.getTextAttributesView.insert_column(column, 0) - - # Attribute Speak column (IS_SPOKEN). - speakAttrColumnLabel = guilabels.PRESENTATION_SPEAK - column = Gtk.TreeViewColumn(speakAttrColumnLabel) - renderer = Gtk.CellRendererToggle() - column.pack_start(renderer, False) - column.add_attribute(renderer, 'active', IS_SPOKEN) - renderer.connect("toggled", - self.textAttributeSpokenToggled, - model) - self.getTextAttributesView.insert_column(column, 1) - column.clicked() - - # Attribute Mark in Braille column (IS_BRAILLED). - markAttrColumnLabel = guilabels.PRESENTATION_MARK_IN_BRAILLE - column = Gtk.TreeViewColumn(markAttrColumnLabel) - renderer = Gtk.CellRendererToggle() - column.pack_start(renderer, False) - column.add_attribute(renderer, 'active', IS_BRAILLED) - renderer.connect("toggled", - self.textAttributeBrailledToggled, - model) - self.getTextAttributesView.insert_column(column, 2) - column.clicked() - - # Attribute Value column (VALUE) - column = Gtk.TreeViewColumn(guilabels.PRESENTATION_PRESENT_UNLESS) - renderer = Gtk.CellRendererText() - renderer.set_property('editable', True) - column.pack_end(renderer, True) - column.add_attribute(renderer, 'text', VALUE) - renderer.connect("edited", self.textAttrValueEdited, model) - - self.getTextAttributesView.insert_column(column, 4) - - # Check all the enabled (spoken) text attributes. - # - self._setSpokenTextAttributes( - self.getTextAttributesView, - cthulhu.cthulhuApp.settingsManager.getSetting('enabledSpokenTextAttributes'), - True, True) - - # Check all the enabled (brailled) text attributes. - # - self._setBrailledTextAttributes( - self.getTextAttributesView, - cthulhu.cthulhuApp.settingsManager.getSetting('enabledBrailledTextAttributes'), - True) - - # Connect a handler for when the user changes columns within the - # view, so that we can adjust the search column for item lookups. - # - self.getTextAttributesView.connect("cursor_changed", - self.textAttrCursorChanged) - - def pronActualValueEdited(self, cell, path, new_text, model): - """The user has edited the value of one of the actual strings in - the pronunciation dictionary. Update our model to reflect this. - - Arguments: - - cell: the cell that changed. - - path: the path of that cell. - - new_text: the new pronunciation dictionary actual string. - - model: the model that the cell is part of. - """ - - thisIter = model.get_iter(path) - model.set(thisIter, ACTUAL, new_text) - - def pronReplacementValueEdited(self, cell, path, new_text, model): - """The user has edited the value of one of the replacement strings - in the pronunciation dictionary. Update our model to reflect this. - - Arguments: - - cell: the cell that changed. - - path: the path of that cell. - - new_text: the new pronunciation dictionary replacement string. - - model: the model that the cell is part of. - """ - - thisIter = model.get_iter(path) - model.set(thisIter, REPLACEMENT, new_text) - - def pronunciationFocusChange(self, widget, event, isFocused): - """Callback for the pronunciation tree's focus-{in,out}-event signal.""" - - cthulhu.cthulhuApp.settingsManager.setSetting('usePronunciationDictionary', not isFocused) - - def pronunciationCursorChanged(self, widget): - """Set the search column in the pronunciation dictionary tree view - depending upon which column the user currently has the cursor in. - """ - - [path, focusColumn] = self.pronunciationView.get_cursor() - if focusColumn: - noColumns = len(self.pronunciationView.get_columns()) - for i in range(0, noColumns): - col = self.pronunciationView.get_column(i) - if focusColumn == col: - self.pronunciationView.set_search_column(i) - break - - def _createPronunciationTreeView(self): - """Create the pronunciation dictionary tree view. The view is the - pronunciationTreeView GtkTreeView widget. The view will consist - of a list containing two columns: - ACTUAL - the actual text string (word). - REPLACEMENT - the string that is used to pronounce that word. - """ - - self.pronunciationView = self.get_widget("pronunciationTreeView") - - if self.pronunciationView.get_columns(): - for column in self.pronunciationView.get_columns(): - self.pronunciationView.remove_column(column) - - model = Gtk.ListStore(GObject.TYPE_STRING, - GObject.TYPE_STRING) - - # Initially setup the list store model based on the values of all - # existing entries in the pronunciation dictionary -- unless it's - # the default script. - # - if not self.script.app: - _profile = self.prefsDict.get('activeProfile')[1] - pronDict = cthulhu.cthulhuApp.settingsManager.getPronunciations(_profile) - else: - pronDict = pronunciation_dict.pronunciation_dict - for pronKey in sorted(pronDict.keys()): - thisIter = model.append() - try: - actual, replacement = pronDict[pronKey] - except Exception: - # Try to do something sensible for the previous format of - # pronunciation dictionary entries. See bug #464754 for - # more details. - # - actual = pronKey - replacement = pronDict[pronKey] - model.set(thisIter, - ACTUAL, actual, - REPLACEMENT, replacement) - - self.pronunciationView.set_model(model) - - # Pronunciation Dictionary actual string (word) column (ACTUAL). - column = Gtk.TreeViewColumn(guilabels.DICTIONARY_ACTUAL_STRING) - column.set_min_width(250) - column.set_resizable(True) - renderer = Gtk.CellRendererText() - renderer.set_property('editable', True) - column.pack_end(renderer, True) - column.add_attribute(renderer, 'text', ACTUAL) - renderer.connect("edited", self.pronActualValueEdited, model) - self.pronunciationView.insert_column(column, 0) - - # Pronunciation Dictionary replacement string column (REPLACEMENT) - column = Gtk.TreeViewColumn(guilabels.DICTIONARY_REPLACEMENT_STRING) - renderer = Gtk.CellRendererText() - renderer.set_property('editable', True) - column.pack_end(renderer, True) - column.add_attribute(renderer, 'text', REPLACEMENT) - renderer.connect("edited", self.pronReplacementValueEdited, model) - self.pronunciationView.insert_column(column, 1) - - self.pronunciationModel = model - - # Connect a handler for when the user changes columns within the - # view, so that we can adjust the search column for item lookups. - # - self.pronunciationView.connect("cursor_changed", - self.pronunciationCursorChanged) - - self.pronunciationView.connect( - "focus_in_event", self.pronunciationFocusChange, True) - self.pronunciationView.connect( - "focus_out_event", self.pronunciationFocusChange, False) - - def _initGUIState(self): - """Adjust the settings of the various components on the - configuration GUI depending upon the users preferences. - """ - - prefs = self.prefsDict - - # Speech pane. - # - enable = prefs["enableSpeech"] - self.get_widget("speechSupportCheckButton").set_active(enable) - self.get_widget("speechOptionsGrid").set_sensitive(enable) - - enable = prefs["onlySpeakDisplayedText"] - self.get_widget("onlySpeakDisplayedTextCheckButton").set_active(enable) - self.get_widget("contextOptionsGrid").set_sensitive(not enable) - - if prefs["verbalizePunctuationStyle"] == \ - settings.PUNCTUATION_STYLE_NONE: - self.get_widget("noneButton").set_active(True) - elif prefs["verbalizePunctuationStyle"] == \ - settings.PUNCTUATION_STYLE_SOME: - self.get_widget("someButton").set_active(True) - elif prefs["verbalizePunctuationStyle"] == \ - settings.PUNCTUATION_STYLE_MOST: - self.get_widget("mostButton").set_active(True) - else: - self.get_widget("allButton").set_active(True) - - if prefs["speechVerbosityLevel"] == settings.VERBOSITY_LEVEL_BRIEF: - self.get_widget("speechBriefButton").set_active(True) - else: - self.get_widget("speechVerboseButton").set_active(True) - - self.get_widget("onlySpeakDisplayedTextCheckButton").set_active( - prefs["onlySpeakDisplayedText"]) - - - self.get_widget("speakBlankLinesCheckButton").set_active(\ - prefs["speakBlankLines"]) - self.get_widget("speakMultiCaseStringsAsWordsCheckButton").set_active(\ - prefs["speakMultiCaseStringsAsWords"]) - self.get_widget("speakNumbersAsDigitsCheckButton").set_active( - prefs.get("speakNumbersAsDigits", settings.speakNumbersAsDigits)) - self.get_widget("enableTutorialMessagesCheckButton").set_active(\ - prefs["enableTutorialMessages"]) - self.get_widget("enablePauseBreaksCheckButton").set_active(\ - prefs["enablePauseBreaks"]) - self.get_widget("enablePositionSpeakingCheckButton").set_active(\ - prefs["enablePositionSpeaking"]) - self.get_widget("enableMnemonicSpeakingCheckButton").set_active(\ - prefs["enableMnemonicSpeaking"]) - self.get_widget("speakMisspelledIndicatorCheckButton").set_active( - prefs.get("speakMisspelledIndicator", settings.speakMisspelledIndicator)) - self.get_widget("speakDescriptionCheckButton").set_active( - prefs.get("speakDescription", settings.speakDescription)) - self.get_widget("speakContextBlockquoteCheckButton").set_active( - prefs.get("speakContextBlockquote", settings.speakContextList)) - self.get_widget("speakContextLandmarkCheckButton").set_active( - prefs.get("speakContextLandmark", settings.speakContextLandmark)) - self.get_widget("speakContextNonLandmarkFormCheckButton").set_active( - prefs.get("speakContextNonLandmarkForm", settings.speakContextNonLandmarkForm)) - self.get_widget("speakContextListCheckButton").set_active( - prefs.get("speakContextList", settings.speakContextList)) - self.get_widget("speakContextPanelCheckButton").set_active( - prefs.get("speakContextPanel", settings.speakContextPanel)) - self.get_widget("speakContextTableCheckButton").set_active( - prefs.get("speakContextTable", settings.speakContextTable)) - - enable = prefs.get("messagesAreDetailed", settings.messagesAreDetailed) - self.get_widget("messagesAreDetailedCheckButton").set_active(enable) - - enable = prefs.get("useColorNames", settings.useColorNames) - self.get_widget("useColorNamesCheckButton").set_active(enable) - - enable = prefs.get("readFullRowInGUITable", settings.readFullRowInGUITable) - self.get_widget("readFullRowInGUITableCheckButton").set_active(enable) - - enable = prefs.get("readFullRowInDocumentTable", settings.readFullRowInDocumentTable) - self.get_widget("readFullRowInDocumentTableCheckButton").set_active(enable) - - enable = prefs.get("readFullRowInSpreadSheet", settings.readFullRowInSpreadSheet) - self.get_widget("readFullRowInSpreadSheetCheckButton").set_active(enable) - - style = prefs.get("capitalizationStyle", settings.capitalizationStyle) - combobox = self.get_widget("capitalizationStyle") - options = [guilabels.CAPITALIZATION_STYLE_NONE, - guilabels.CAPITALIZATION_STYLE_ICON, - guilabels.CAPITALIZATION_STYLE_SPELL] - self.populateComboBox(combobox, options) - if style == settings.CAPITALIZATION_STYLE_ICON: - value = guilabels.CAPITALIZATION_STYLE_ICON - elif style == settings.CAPITALIZATION_STYLE_SPELL: - value = guilabels.CAPITALIZATION_STYLE_SPELL - else: - value = guilabels.CAPITALIZATION_STYLE_NONE - combobox.set_active(options.index(value)) - - combobox2 = self.get_widget("dateFormatCombo") - sdtime = time.strftime - ltime = time.localtime - self.populateComboBox(combobox2, - [sdtime(messages.DATE_FORMAT_LOCALE, ltime()), - sdtime(messages.DATE_FORMAT_NUMBERS_DM, ltime()), - sdtime(messages.DATE_FORMAT_NUMBERS_MD, ltime()), - sdtime(messages.DATE_FORMAT_NUMBERS_DMY, ltime()), - sdtime(messages.DATE_FORMAT_NUMBERS_MDY, ltime()), - sdtime(messages.DATE_FORMAT_NUMBERS_YMD, ltime()), - sdtime(messages.DATE_FORMAT_FULL_DM, ltime()), - sdtime(messages.DATE_FORMAT_FULL_MD, ltime()), - sdtime(messages.DATE_FORMAT_FULL_DMY, ltime()), - sdtime(messages.DATE_FORMAT_FULL_MDY, ltime()), - sdtime(messages.DATE_FORMAT_FULL_YMD, ltime()), - sdtime(messages.DATE_FORMAT_ABBREVIATED_DM, ltime()), - sdtime(messages.DATE_FORMAT_ABBREVIATED_MD, ltime()), - sdtime(messages.DATE_FORMAT_ABBREVIATED_DMY, ltime()), - sdtime(messages.DATE_FORMAT_ABBREVIATED_MDY, ltime()), - sdtime(messages.DATE_FORMAT_ABBREVIATED_YMD, ltime()) - ]) - - indexdate = DATE_FORMAT_LOCALE - dateFormat = self.prefsDict["presentDateFormat"] - if dateFormat == messages.DATE_FORMAT_LOCALE: - indexdate = DATE_FORMAT_LOCALE - elif dateFormat == messages.DATE_FORMAT_NUMBERS_DM: - indexdate = DATE_FORMAT_NUMBERS_DM - elif dateFormat == messages.DATE_FORMAT_NUMBERS_MD: - indexdate = DATE_FORMAT_NUMBERS_MD - elif dateFormat == messages.DATE_FORMAT_NUMBERS_DMY: - indexdate = DATE_FORMAT_NUMBERS_DMY - elif dateFormat == messages.DATE_FORMAT_NUMBERS_MDY: - indexdate = DATE_FORMAT_NUMBERS_MDY - elif dateFormat == messages.DATE_FORMAT_NUMBERS_YMD: - indexdate = DATE_FORMAT_NUMBERS_YMD - elif dateFormat == messages.DATE_FORMAT_FULL_DM: - indexdate = DATE_FORMAT_FULL_DM - elif dateFormat == messages.DATE_FORMAT_FULL_MD: - indexdate = DATE_FORMAT_FULL_MD - elif dateFormat == messages.DATE_FORMAT_FULL_DMY: - indexdate = DATE_FORMAT_FULL_DMY - elif dateFormat == messages.DATE_FORMAT_FULL_MDY: - indexdate = DATE_FORMAT_FULL_MDY - elif dateFormat == messages.DATE_FORMAT_FULL_YMD: - indexdate = DATE_FORMAT_FULL_YMD - elif dateFormat == messages.DATE_FORMAT_ABBREVIATED_DM: - indexdate = DATE_FORMAT_ABBREVIATED_DM - elif dateFormat == messages.DATE_FORMAT_ABBREVIATED_MD: - indexdate = DATE_FORMAT_ABBREVIATED_MD - elif dateFormat == messages.DATE_FORMAT_ABBREVIATED_DMY: - indexdate = DATE_FORMAT_ABBREVIATED_DMY - elif dateFormat == messages.DATE_FORMAT_ABBREVIATED_MDY: - indexdate = DATE_FORMAT_ABBREVIATED_MDY - elif dateFormat == messages.DATE_FORMAT_ABBREVIATED_YMD: - indexdate = DATE_FORMAT_ABBREVIATED_YMD - combobox2.set_active (indexdate) - - combobox3 = self.get_widget("timeFormatCombo") - self.populateComboBox(combobox3, - [sdtime(messages.TIME_FORMAT_LOCALE, ltime()), - sdtime(messages.TIME_FORMAT_12_HM, ltime()), - sdtime(messages.TIME_FORMAT_12_HMS, ltime()), - sdtime(messages.TIME_FORMAT_24_HMS, ltime()), - sdtime(messages.TIME_FORMAT_24_HMS_WITH_WORDS, ltime()), - sdtime(messages.TIME_FORMAT_24_HM, ltime()), - sdtime(messages.TIME_FORMAT_24_HM_WITH_WORDS, ltime())]) - indextime = TIME_FORMAT_LOCALE - timeFormat = self.prefsDict["presentTimeFormat"] - if timeFormat == messages.TIME_FORMAT_LOCALE: - indextime = TIME_FORMAT_LOCALE - elif timeFormat == messages.TIME_FORMAT_12_HM: - indextime = TIME_FORMAT_12_HM - elif timeFormat == messages.TIME_FORMAT_12_HMS: - indextime = TIME_FORMAT_12_HMS - elif timeFormat == messages.TIME_FORMAT_24_HMS: - indextime = TIME_FORMAT_24_HMS - elif timeFormat == messages.TIME_FORMAT_24_HMS_WITH_WORDS: - indextime = TIME_FORMAT_24_HMS_WITH_WORDS - elif timeFormat == messages.TIME_FORMAT_24_HM: - indextime = TIME_FORMAT_24_HM - elif timeFormat == messages.TIME_FORMAT_24_HM_WITH_WORDS: - indextime = TIME_FORMAT_24_HM_WITH_WORDS - combobox3.set_active (indextime) - - self.get_widget("speakProgressBarUpdatesCheckButton").set_active( - prefs.get("speakProgressBarUpdates", settings.speakProgressBarUpdates)) - self.get_widget("brailleProgressBarUpdatesCheckButton").set_active( - prefs.get("brailleProgressBarUpdates", settings.brailleProgressBarUpdates)) - self.get_widget("beepProgressBarUpdatesCheckButton").set_active( - prefs.get("beepProgressBarUpdates", settings.beepProgressBarUpdates)) - - interval = prefs["progressBarUpdateInterval"] - self.get_widget("progressBarUpdateIntervalSpinButton").set_value(interval) - - comboBox = self.get_widget("progressBarVerbosity") - levels = [guilabels.PROGRESS_BAR_ALL, - guilabels.PROGRESS_BAR_APPLICATION, - guilabels.PROGRESS_BAR_WINDOW] - self.populateComboBox(comboBox, levels) - comboBox.set_active(prefs["progressBarVerbosity"]) - - enable = prefs["enableMouseReview"] - self.get_widget("enableMouseReviewCheckButton").set_active(enable) - - # Braille pane. - # - self.get_widget("enableBrailleCheckButton").set_active( \ - prefs["enableBraille"]) - state = prefs["brailleRolenameStyle"] == \ - settings.BRAILLE_ROLENAME_STYLE_SHORT - self.get_widget("abbrevRolenames").set_active(state) - - self.get_widget("disableBrailleEOLCheckButton").set_active( - prefs["disableBrailleEOL"]) - - if louis is None: - self.get_widget( \ - "contractedBrailleCheckButton").set_sensitive(False) - else: - self.get_widget("contractedBrailleCheckButton").set_active( \ - prefs["enableContractedBraille"]) - # Set up contraction table combo box and set it to the - # currently used one. - # - tablesCombo = self.get_widget("contractionTableCombo") - tableDict = braille.listTables() - selectedTableIter = None - selectedTable = prefs["brailleContractionTable"] or \ - braille.getDefaultTable() - if tableDict: - tablesModel = Gtk.ListStore(str, str) - names = sorted(tableDict.keys()) - for name in names: - fname = tableDict[name] - it = tablesModel.append([name, fname]) - if os.path.join(braille.tablesdir, fname) == \ - selectedTable: - selectedTableIter = it - cell = self.planeCellRendererText - tablesCombo.clear() - tablesCombo.pack_start(cell, True) - tablesCombo.add_attribute(cell, 'text', 0) - tablesCombo.set_model(tablesModel) - if selectedTableIter: - tablesCombo.set_active_iter(selectedTableIter) - else: - tablesCombo.set_active(0) - else: - tablesCombo.set_sensitive(False) - if prefs["brailleVerbosityLevel"] == settings.VERBOSITY_LEVEL_BRIEF: - self.get_widget("brailleBriefButton").set_active(True) - else: - self.get_widget("brailleVerboseButton").set_active(True) - - self.get_widget("enableBrailleWordWrapCheckButton").set_active( - prefs.get("enableBrailleWordWrap", settings.enableBrailleWordWrap)) - - selectionIndicator = prefs["brailleSelectorIndicator"] - if selectionIndicator == settings.BRAILLE_UNDERLINE_7: - self.get_widget("brailleSelection7Button").set_active(True) - elif selectionIndicator == settings.BRAILLE_UNDERLINE_8: - self.get_widget("brailleSelection8Button").set_active(True) - elif selectionIndicator == settings.BRAILLE_UNDERLINE_BOTH: - self.get_widget("brailleSelectionBothButton").set_active(True) - else: - self.get_widget("brailleSelectionNoneButton").set_active(True) - - linkIndicator = prefs["brailleLinkIndicator"] - if linkIndicator == settings.BRAILLE_UNDERLINE_7: - self.get_widget("brailleLink7Button").set_active(True) - elif linkIndicator == settings.BRAILLE_UNDERLINE_8: - self.get_widget("brailleLink8Button").set_active(True) - elif linkIndicator == settings.BRAILLE_UNDERLINE_BOTH: - self.get_widget("brailleLinkBothButton").set_active(True) - else: - self.get_widget("brailleLinkNoneButton").set_active(True) - - enable = prefs.get("enableFlashMessages", settings.enableFlashMessages) - self.get_widget("enableFlashMessagesCheckButton").set_active(enable) - - enable = prefs.get("flashIsPersistent", settings.flashIsPersistent) - self.get_widget("flashIsPersistentCheckButton").set_active(enable) - - enable = prefs.get("flashIsDetailed", settings.flashIsDetailed) - self.get_widget("flashIsDetailedCheckButton").set_active(enable) - - duration = prefs["brailleFlashTime"] - self.get_widget("brailleFlashTimeSpinButton").set_value(duration / 1000) - - # Key Echo pane. - # - self.get_widget("keyEchoCheckButton").set_active( \ - prefs["enableKeyEcho"]) - self.get_widget("enableAlphabeticKeysCheckButton").set_active( - prefs.get("enableAlphabeticKeys", settings.enableAlphabeticKeys)) - self.get_widget("enableNumericKeysCheckButton").set_active( - prefs.get("enableNumericKeys", settings.enableNumericKeys)) - self.get_widget("enablePunctuationKeysCheckButton").set_active( - prefs.get("enablePunctuationKeys", settings.enablePunctuationKeys)) - self.get_widget("enableSpaceCheckButton").set_active( - prefs.get("enableSpace", settings.enableSpace)) - self.get_widget("enableModifierKeysCheckButton").set_active( \ - prefs["enableModifierKeys"]) - self.get_widget("enableFunctionKeysCheckButton").set_active( \ - prefs["enableFunctionKeys"]) - self.get_widget("enableActionKeysCheckButton").set_active( \ - prefs["enableActionKeys"]) - self.get_widget("enableNavigationKeysCheckButton").set_active( \ - prefs["enableNavigationKeys"]) - self.get_widget("enableDiacriticalKeysCheckButton").set_active( \ - prefs["enableDiacriticalKeys"]) - self.get_widget("enableEchoByCharacterCheckButton").set_active( \ - prefs["enableEchoByCharacter"]) - self.get_widget("enableEchoByWordCheckButton").set_active( \ - prefs["enableEchoByWord"]) - self.get_widget("enableEchoBySentenceCheckButton").set_active( \ - prefs["enableEchoBySentence"]) - self.get_widget("useCustomEchoVoiceCheckButton").set_active( - prefs.get("useCustomEchoVoice", settings.useCustomEchoVoice)) - self.get_widget("useCustomEchoSpeechServerCheckButton").set_active( - prefs.get("useCustomEchoSpeechServer", settings.useCustomEchoSpeechServer)) - self.get_widget("useCustomEchoForKeyCheckButton").set_active( - prefs.get("useCustomEchoForKey", settings.useCustomEchoForKey)) - self.get_widget("useCustomEchoForCharacterCheckButton").set_active( - prefs.get("useCustomEchoForCharacter", settings.useCustomEchoForCharacter)) - self.get_widget("useCustomEchoForWordCheckButton").set_active( - prefs.get("useCustomEchoForWord", settings.useCustomEchoForWord)) - self.get_widget("useCustomEchoForSentenceCheckButton").set_active( - prefs.get("useCustomEchoForSentence", settings.useCustomEchoForSentence)) - self._setEchoVoiceItems() - - # Text attributes pane. - # - self._createTextAttributesTreeView() - - brailleIndicator = prefs["textAttributesBrailleIndicator"] - if brailleIndicator == settings.BRAILLE_UNDERLINE_7: - self.get_widget("textBraille7Button").set_active(True) - elif brailleIndicator == settings.BRAILLE_UNDERLINE_8: - self.get_widget("textBraille8Button").set_active(True) - elif brailleIndicator == settings.BRAILLE_UNDERLINE_BOTH: - self.get_widget("textBrailleBothButton").set_active(True) - else: - self.get_widget("textBrailleNoneButton").set_active(True) - - # Pronunciation dictionary pane. - # - self._createPronunciationTreeView() - - # General pane. - # - self.get_widget("presentToolTipsCheckButton").set_active( - prefs["presentToolTips"]) - - if prefs["keyboardLayout"] == settings.GENERAL_KEYBOARD_LAYOUT_DESKTOP: - self.get_widget("generalDesktopButton").set_active(True) - else: - self.get_widget("generalLaptopButton").set_active(True) - - combobox = self.get_widget("sayAllStyle") - self.populateComboBox(combobox, [guilabels.SAY_ALL_STYLE_LINE, - guilabels.SAY_ALL_STYLE_SENTENCE]) - combobox.set_active(prefs["sayAllStyle"]) - self.get_widget("rewindAndFastForwardInSayAllCheckButton").set_active( - prefs.get("rewindAndFastForwardInSayAll", settings.rewindAndFastForwardInSayAll)) - self.get_widget("structNavInSayAllCheckButton").set_active( - prefs.get("structNavInSayAll", settings.structNavInSayAll)) - self.get_widget("sayAllContextBlockquoteCheckButton").set_active( - prefs.get("sayAllContextBlockquote", settings.sayAllContextBlockquote)) - self.get_widget("sayAllContextLandmarkCheckButton").set_active( - prefs.get("sayAllContextLandmark", settings.sayAllContextLandmark)) - self.get_widget("sayAllContextNonLandmarkFormCheckButton").set_active( - prefs.get("sayAllContextNonLandmarkForm", settings.sayAllContextNonLandmarkForm)) - self.get_widget("sayAllContextListCheckButton").set_active( - prefs.get("sayAllContextList", settings.sayAllContextList)) - self.get_widget("sayAllContextPanelCheckButton").set_active( - prefs.get("sayAllContextPanel", settings.sayAllContextPanel)) - self.get_widget("sayAllContextTableCheckButton").set_active( - prefs.get("sayAllContextTable", settings.sayAllContextTable)) - - # Cthulhu User Profiles - # - self.profilesCombo = self.get_widget('availableProfilesComboBox1') - self.startingProfileCombo = self.get_widget('availableProfilesComboBox2') - self.profilesComboModel = self.get_widget('model9') - self.__initProfileCombo() - if self.script.app: - self.get_widget('profilesFrame').set_sensitive(False) - - active_plugins = self._get_active_plugins_for_tabs() - self._update_plugin_tabs(active_plugins) - - # AI Assistant settings - # - if self._plugin_active(active_plugins, ["AIAssistant"]): - self._initAIState() - - # Indentation settings - # - self._initIndentationState() - - # OCR Plugin settings - # - if self._plugin_active(active_plugins, ["OCR"]): - self._initOCRState() - - def __initProfileCombo(self): - """Adding available profiles and setting active as the active one""" - - availableProfiles = self.__getAvailableProfiles() - self.profilesComboModel.clear() - - if not len(availableProfiles): - self.profilesComboModel.append(self._defaultProfile) - else: - for profile in availableProfiles: - self.profilesComboModel.append(profile) - - activeProfile = self.prefsDict.get('activeProfile') or self._defaultProfile - startingProfile = self.prefsDict.get('startingProfile') or self._defaultProfile - - activeProfileIter = self.getComboBoxIndex(self.profilesCombo, - activeProfile[0]) - startingProfileIter = self.getComboBoxIndex(self.startingProfileCombo, - startingProfile[0]) - self.profilesCombo.set_active(activeProfileIter) - self.startingProfileCombo.set_active(startingProfileIter) - - def __getAvailableProfiles(self): - """Get available user profiles.""" - return cthulhu.cthulhuApp.settingsManager.availableProfiles() - - def _initAIState(self): - """Initialize AI Assistant tab widgets with current settings.""" - prefs = self.prefsDict - - # Store widget references - self.enableAICheckButton = self.get_widget("enableAICheckButton") - self.aiProviderCombo = self.get_widget("aiProviderCombo") - self.aiApiKeyEntry = self.get_widget("aiApiKeyEntry") - self.aiOllamaModelEntry = self.get_widget("aiOllamaModelEntry") - self.aiOllamaEndpointEntry = self.get_widget("aiOllamaEndpointEntry") - self.aiConfirmationCheckButton = self.get_widget("aiConfirmationCheckButton") - self.aiScreenshotQualityCombo = self.get_widget("aiScreenshotQualityCombo") - - # Set enable AI checkbox - enabled = prefs.get("aiAssistantEnabled", settings.aiAssistantEnabled) - self.enableAICheckButton.set_active(enabled) - - # Set provider combo - provider = prefs.get("aiProvider", settings.aiProvider) - providerIndex = 0 # Default to Claude Code - if provider == settings.AI_PROVIDER_CODEX: - providerIndex = 1 - elif provider == settings.AI_PROVIDER_GEMINI: - providerIndex = 2 - elif provider == settings.AI_PROVIDER_OLLAMA: - providerIndex = 3 - self.aiProviderCombo.set_active(providerIndex) - - # Set API key file - apiKeyFile = prefs.get("aiApiKeyFile", settings.aiApiKeyFile) - self.aiApiKeyEntry.set_text(apiKeyFile) - - # Set Ollama model - ollamaModel = prefs.get("aiOllamaModel", settings.aiOllamaModel) - self.aiOllamaModelEntry.set_text(ollamaModel) - - # Set Ollama endpoint - ollamaEndpoint = prefs.get("aiOllamaEndpoint", settings.aiOllamaEndpoint) - self.aiOllamaEndpointEntry.set_text(ollamaEndpoint) - - # Set confirmation checkbox - confirmationRequired = prefs.get("aiConfirmationRequired", settings.aiConfirmationRequired) - self.aiConfirmationCheckButton.set_active(confirmationRequired) - - # Set screenshot quality combo - quality = prefs.get("aiScreenshotQuality", settings.aiScreenshotQuality) - qualityIndex = 1 # Default to medium - if quality == settings.AI_SCREENSHOT_QUALITY_LOW: - qualityIndex = 0 - elif quality == settings.AI_SCREENSHOT_QUALITY_HIGH: - qualityIndex = 2 - self.aiScreenshotQualityCombo.set_active(qualityIndex) - - # Enable/disable controls based on AI enabled state - self._updateAIControlsState(enabled) - - def _updateAIControlsState(self, enabled): - """Refresh AI controls while keeping configuration fields editable.""" - _ = enabled # kept for signal/call compatibility - # Keep settings editable even when AI assistant is disabled so users - # can configure providers/keys before enabling it. - self.aiProviderCombo.set_sensitive(True) - self.aiConfirmationCheckButton.set_sensitive(True) - self.aiScreenshotQualityCombo.set_sensitive(True) - try: - self.get_widget("aiGetClaudeKeyButton").set_sensitive(True) - except: - pass # Button might not exist in older UI files - - current_provider = self.prefsDict.get("aiProvider", settings.aiProvider) - self._updateProviderControls(current_provider) - - def _initIndentationState(self): - """Initialize Indentation widgets with current settings.""" - prefs = self.prefsDict - - self.enableIndentationCheckButton = self.get_widget("enableIndentationCheckButton") - self.indentationSpeechButton = self.get_widget("indentationSpeechButton") - self.indentationBeepsButton = self.get_widget("indentationBeepsButton") - self.indentationSpeechAndBeepsButton = self.get_widget("indentationSpeechAndBeepsButton") - self.indentationModeGrid = self.get_widget("indentationModeGrid") - - enabled = prefs.get("enableIndentation", settings.enableIndentation) - self.enableIndentationCheckButton.set_active(enabled) - - mode = prefs.get("indentationPresentationMode", settings.indentationPresentationMode) - if mode == settings.INDENTATION_PRESENTATION_BEEPS: - self.indentationBeepsButton.set_active(True) - elif mode == settings.INDENTATION_PRESENTATION_SPEECH_AND_BEEPS: - self.indentationSpeechAndBeepsButton.set_active(True) - else: - self.indentationSpeechButton.set_active(True) - - self._updateIndentationControlsState(enabled) - - def _updateIndentationControlsState(self, enabled): - """Enable or disable indentation mode controls based on checkbox state.""" - self.indentationModeGrid.set_sensitive(enabled) - - def _updateProviderControls(self, provider): - """Update visibility/sensitivity of provider-specific controls.""" - # API key controls (only needed for Gemini - not for Claude Code or Ollama) - api_key_needed = provider in [settings.AI_PROVIDER_GEMINI] - self.aiApiKeyEntry.set_sensitive(api_key_needed) - - # Get Claude API Key button (only for Claude Code) - try: - claude_button = self.get_widget("aiGetClaudeKeyButton") - claude_button.set_visible(provider == settings.AI_PROVIDER_CLAUDE_CODE) - except: - pass # Button might not exist - - # Ollama model and endpoint entries (only for Ollama) - ollama_enabled = provider == settings.AI_PROVIDER_OLLAMA - self.aiOllamaModelEntry.set_sensitive(ollama_enabled) - self.aiOllamaEndpointEntry.set_sensitive(ollama_enabled) - - # Update labels based on provider - if provider == settings.AI_PROVIDER_CLAUDE_CODE: - self.aiApiKeyEntry.set_placeholder_text("No API key needed - uses Claude Code CLI") - elif provider == settings.AI_PROVIDER_CODEX: - self.aiApiKeyEntry.set_placeholder_text("No API key needed - uses Codex CLI") - elif provider == settings.AI_PROVIDER_OLLAMA: - self.aiApiKeyEntry.set_placeholder_text("No API key needed - uses local Ollama") - else: - self.aiApiKeyEntry.set_placeholder_text("Path to API key file") - - def _initOCRState(self): - """Initialize OCR Plugin tab widgets with current settings.""" - prefs = self.prefsDict - - # Store widget references - self.ocrLanguageEntry = self.get_widget("ocrLanguageEntry") - self.ocrScaleSpinButton = self.get_widget("ocrScaleSpinButton") - self.ocrGrayscaleCheckButton = self.get_widget("ocrGrayscaleCheckButton") - self.ocrInvertCheckButton = self.get_widget("ocrInvertCheckButton") - self.ocrBlackWhiteCheckButton = self.get_widget("ocrBlackWhiteCheckButton") - self.ocrBlackWhiteValueSpinButton = self.get_widget("ocrBlackWhiteValueSpinButton") - self.ocrColorCalculationCheckButton = self.get_widget("ocrColorCalculationCheckButton") - self.ocrCopyToClipboardCheckButton = self.get_widget("ocrCopyToClipboardCheckButton") - - # Set language code - languageCode = prefs.get("ocrLanguageCode", settings.ocrLanguageCode) - self.ocrLanguageEntry.set_text(languageCode) - - # Set scale factor - scaleFactor = prefs.get("ocrScaleFactor", settings.ocrScaleFactor) - self.ocrScaleSpinButton.set_value(scaleFactor) - - # Set checkboxes - grayscale = prefs.get("ocrGrayscaleImg", settings.ocrGrayscaleImg) - self.ocrGrayscaleCheckButton.set_active(grayscale) - - invert = prefs.get("ocrInvertImg", settings.ocrInvertImg) - self.ocrInvertCheckButton.set_active(invert) - - blackWhite = prefs.get("ocrBlackWhiteImg", settings.ocrBlackWhiteImg) - self.ocrBlackWhiteCheckButton.set_active(blackWhite) - - blackWhiteValue = prefs.get("ocrBlackWhiteImgValue", settings.ocrBlackWhiteImgValue) - self.ocrBlackWhiteValueSpinButton.set_value(blackWhiteValue) - - colorCalculation = prefs.get("ocrColorCalculation", settings.ocrColorCalculation) - self.ocrColorCalculationCheckButton.set_active(colorCalculation) - - copyToClipboard = prefs.get("ocrCopyToClipboard", settings.ocrCopyToClipboard) - self.ocrCopyToClipboardCheckButton.set_active(copyToClipboard) - - def _initSoundThemeState(self): - """Initialize sound widgets with current settings.""" - prefs = self.prefsDict - - # Get widget references - self.soundSinkCombo = self.get_widget("soundSinkCombo") - self.soundThemeCombo = self.get_widget("soundThemeCombo") - self.roleSoundPresentationCombo = self.get_widget("roleSoundPresentationCombo") - self.soundVolumeScale = self.get_widget("soundVolumeScale") - self.progressBarBeepIntervalSpinButton = self.get_widget("progressBarBeepIntervalSpinButton") - self.soundSinkCombo.set_can_focus(False) - self.soundThemeCombo.set_can_focus(False) - self.roleSoundPresentationCombo.set_can_focus(False) - self.get_widget("enableSoundCheckButton").set_active( - prefs.get("enableSound", settings.enableSound) - ) - self.soundVolumeScale.set_value( - float(prefs.get("soundVolume", settings.soundVolume)) - ) - self.get_widget("playSoundForRoleCheckButton").set_active( - prefs.get("playSoundForRole", settings.playSoundForRole) - ) - self.get_widget("playSoundForStateCheckButton").set_active( - prefs.get("playSoundForState", settings.playSoundForState) - ) - self.get_widget("playSoundForPositionInSetCheckButton").set_active( - prefs.get("playSoundForPositionInSet", settings.playSoundForPositionInSet) - ) - self.get_widget("playSoundForValueCheckButton").set_active( - prefs.get("playSoundForValue", settings.playSoundForValue) - ) - self.get_widget("beepProgressBarUpdatesCheckButton").set_active( - prefs.get("beepProgressBarUpdates", settings.beepProgressBarUpdates) - ) - self.progressBarBeepIntervalSpinButton.set_value( - int(prefs.get("progressBarBeepInterval", settings.progressBarBeepInterval)) - ) - - self._soundSinkChoices = [ - (settings.SOUND_SINK_AUTO, guilabels.SOUND_BACKEND_AUTO), - (settings.SOUND_SINK_PIPEWIRE, guilabels.SOUND_BACKEND_PIPEWIRE), - (settings.SOUND_SINK_PULSE, guilabels.SOUND_BACKEND_PULSE), - (settings.SOUND_SINK_ALSA, guilabels.SOUND_BACKEND_ALSA), - ] - self.soundSinkCombo.remove_all() - for _, label in self._soundSinkChoices: - self.soundSinkCombo.append_text(label) - runtimeSoundSink = cthulhu.cthulhuApp.settingsManager.getSetting("soundSink") - currentSink = sound_sink.normalize_sound_sink_choice( - prefs.get("soundSink", runtimeSoundSink if runtimeSoundSink is not None else settings.soundSink) - ) - sinkIndex = 0 - for index, (value, _) in enumerate(self._soundSinkChoices): - if value == currentSink: - sinkIndex = index - break - self.soundSinkCombo.set_active(sinkIndex) - - # Populate sound theme combo box - themeManager = sound_theme_manager.getManager() - availableThemes = themeManager.getAvailableThemes() - - # Clear and populate combo - add "None" as first option - self.soundThemeCombo.remove_all() - self.soundThemeCombo.append_text(sound_theme_manager.THEME_NONE) - for theme in availableThemes: - self.soundThemeCombo.append_text(theme) - - # Build the full list for index lookup - allThemes = [sound_theme_manager.THEME_NONE] + availableThemes - - # Set active theme - currentTheme = prefs.get("soundTheme", settings.soundTheme) - if currentTheme in allThemes: - self.soundThemeCombo.set_active(allThemes.index(currentTheme)) - elif len(allThemes) > 1: - # Default to first actual theme (skip "none") - self.soundThemeCombo.set_active(1) - else: - self.soundThemeCombo.set_active(0) - - self._roleSoundPresentationChoices = [ - (settings.ROLE_SOUND_PRESENTATION_SOUND_AND_SPEECH, "Sound and speech"), - (settings.ROLE_SOUND_PRESENTATION_SPEECH_ONLY, "Speech only"), - (settings.ROLE_SOUND_PRESENTATION_SOUND_ONLY, "Sound only"), - ] - self.roleSoundPresentationCombo.remove_all() - for _, label in self._roleSoundPresentationChoices: - self.roleSoundPresentationCombo.append_text(label) - currentPresentation = prefs.get( - "roleSoundPresentation", - settings.roleSoundPresentation - ) - presentationIndex = 0 - for index, (value, _) in enumerate(self._roleSoundPresentationChoices): - if value == currentPresentation: - presentationIndex = index - break - self.roleSoundPresentationCombo.set_active(presentationIndex) - - def soundThemeComboChanged(self, widget): - """Signal handler for the sound theme combo box.""" - activeText = widget.get_active_text() - if activeText: - self.prefsDict["soundTheme"] = activeText - - def soundSinkComboChanged(self, widget): - """Signal handler for the sound backend combo box.""" - activeIndex = widget.get_active() - if activeIndex < 0: - return - value = self._soundSinkChoices[activeIndex][0] - self.prefsDict["soundSink"] = value - - def roleSoundPresentationComboChanged(self, widget): - """Signal handler for the role sound presentation combo box.""" - activeIndex = widget.get_active() - if activeIndex < 0: - return - value = self._roleSoundPresentationChoices[activeIndex][0] - self.prefsDict["roleSoundPresentation"] = value - - def soundVolumeValueChanged(self, widget): - """Signal handler for the sound volume scale.""" - self.prefsDict["soundVolume"] = widget.get_value() - - def progressBarBeepIntervalValueChanged(self, widget): - """Signal handler for the progress bar beep interval spin button.""" - self.prefsDict["progressBarBeepInterval"] = widget.get_value_as_int() - - - def _updateCthulhuModifier(self): - combobox = self.get_widget("cthulhuModifierComboBox") - keystring = ", ".join(self.prefsDict["cthulhuModifierKeys"]) - combobox.set_active(self.getComboBoxIndex(combobox, keystring)) - - def populateComboBox(self, combobox, items): - """Populates the combobox with the items provided. - - Arguments: - - combobox: the GtkComboBox to populate - - items: the list of strings with which to populate it - """ - - model = Gtk.ListStore(str) - for item in items: - model.append([item]) - combobox.set_model(model) - - def getComboBoxIndex(self, combobox, searchStr, col=0): - """ For each of the entries in the given combo box, look for searchStr. - Return the index of the entry if searchStr is found. - - Arguments: - - combobox: the GtkComboBox to search. - - searchStr: the string to search for. - - Returns the index of the first entry in combobox with searchStr, or - 0 if not found. - """ - - model = combobox.get_model() - myiter = model.get_iter_first() - for i in range(0, len(model)): - name = model.get_value(myiter, col) - if name == searchStr: - return i - myiter = model.iter_next(myiter) - - return 0 - - def getComboBoxList(self, combobox): - """Get the list of values from the active combox - """ - active = combobox.get_active() - model = combobox.get_model() - activeIter = model.get_iter(active) - activeLabel = model.get_value(activeIter, 0) - activeName = model.get_value(activeIter, 1) - return [activeLabel, activeName] - - def getKeyBindingsModelDict(self, model, modifiedOnly=True): - modelDict = {} - node = model.get_iter_first() - while node: - child = model.iter_children(node) - while child: - key, modified = model.get(child, HANDLER, MODIF) - if modified or not modifiedOnly: - value = [] - value.append(list(model.get( - child, KEY1, MOD_MASK1, MOD_USED1, CLICK_COUNT1))) - modelDict[key] = value - child = model.iter_next(child) - node = model.iter_next(node) - - return modelDict - - def getModelDict(self, model): - """Get the list of values from a list[str,str] model - """ - pronunciation_dict.pronunciation_dict = {} - currentIter = model.get_iter_first() - while currentIter is not None: - key, value = model.get(currentIter, ACTUAL, REPLACEMENT) - if key and value: - pronunciation_dict.setPronunciation(key, value) - currentIter = model.iter_next(currentIter) - modelDict = pronunciation_dict.pronunciation_dict - return modelDict - - def showGUI(self): - """Show the Cthulhu configuration GUI window. This assumes that - the GUI has already been created. - """ - - cthulhuSetupWindow = self.get_widget("cthulhuSetupWindow") - - accelGroup = Gtk.AccelGroup() - cthulhuSetupWindow.add_accel_group(accelGroup) - helpButton = self.get_widget("helpButton") - (keyVal, modifierMask) = Gtk.accelerator_parse("F1") - helpButton.add_accelerator("clicked", - accelGroup, - keyVal, - modifierMask, - 0) - - try: - ts = cthulhu_state.lastInputEvent.timestamp - # Ensure timestamp fits in 32-bit range for GTK - if ts > 4294967295: # 2^32 - 1 - ts = ts % 4294967296 # Wrap to 32-bit range - except Exception: - ts = 0 - if ts == 0: - ts = Gtk.get_current_event_time() - cthulhuSetupWindow.present_with_time(int(ts)) - - # We always want to re-order the text attributes page so that enabled - # items are consistently at the top. - # - self._setSpokenTextAttributes( - self.getTextAttributesView, - cthulhu.cthulhuApp.settingsManager.getSetting('enabledSpokenTextAttributes'), - True, True) - - if self.script.app: - title = guilabels.PREFERENCES_APPLICATION_TITLE % AXObject.get_name(self.script.app) - cthulhuSetupWindow.set_title(title) - - cthulhuSetupWindow.show() - self._initialFocusSyncAttempts = 0 - GLib.idle_add(self._set_initial_window_state) - GLib.idle_add(self._set_initial_gtk_focus) - GLib.timeout_add(50, self._set_initial_window_state) - - def _set_initial_gtk_focus(self): - """Give GTK focus to a real preferences control as soon as the dialog appears.""" - - candidateIds = [ - "generalDesktopButton", - "generalLaptopButton", - "availableProfilesComboBox1", - "speechSupportCheckButton", - "notebook", - ] - cthulhuSetupWindow = self.get_widget("cthulhuSetupWindow") - for widgetId in candidateIds: - widget = self.get_widget(widgetId) - if not widget.get_visible() or not widget.get_sensitive(): - continue - if not widget.get_can_focus(): - continue - - debug.printMessage( - debug.LEVEL_INFO, - f"PREFERENCES DIALOG: Setting initial GTK focus to {widgetId}", - True, - ) - cthulhuSetupWindow.set_focus(widget) - widget.grab_focus() - return False - - debug.printMessage( - debug.LEVEL_INFO, - "PREFERENCES DIALOG: No focusable initial GTK widget found", - True, - ) - return False - - def _set_initial_window_state(self): - """Sync Cthulhu's active window to preferences without forcing dialog focus.""" - - self._initialFocusSyncAttempts += 1 - activeWindow = AXUtilities.find_active_window() - if activeWindow is None: - return self._initialFocusSyncAttempts < 5 - - app = AXObject.get_application(activeWindow) - appName = (AXObject.get_name(app) or "").lower() - if appName != "cthulhu": - return self._initialFocusSyncAttempts < 5 - - debug.printTokens( - debug.LEVEL_INFO, - ["PREFERENCES DIALOG: Syncing active window to", activeWindow], - True, - ) - cthulhu.setActiveWindow(activeWindow, notifyScript=False) - - tokens = ["PREFERENCES DIALOG: Synced initial window state to", activeWindow] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return False - - def _initComboBox(self, combobox): - """Initialize the given combo box to take a list of int/str pairs. - - Arguments: - - combobox: the GtkComboBox to initialize. - """ - - cell = Gtk.CellRendererText() - combobox.pack_start(cell, True) - # We only want to display one column; not two. - # - try: - columnToDisplay = combobox.get_cells()[0] - combobox.add_attribute(columnToDisplay, 'text', 1) - except Exception: - combobox.add_attribute(cell, 'text', 1) - model = Gtk.ListStore(int, str) - combobox.set_model(model) - - # Force the display comboboxes to be left aligned. - # - if isinstance(combobox, Gtk.ComboBoxText): - size = combobox.size_request() - cell.set_fixed_size(size[0] - 29, -1) - - return model - - def _setKeyEchoItems(self): - """[In]sensitize the checkboxes for the various types of key echo, - depending upon whether the value of the key echo check button is set. - """ - - enable = self.get_widget("keyEchoCheckButton").get_active() - self.get_widget("enableAlphabeticKeysCheckButton").set_sensitive(enable) - self.get_widget("enableNumericKeysCheckButton").set_sensitive(enable) - self.get_widget("enablePunctuationKeysCheckButton").set_sensitive(enable) - self.get_widget("enableSpaceCheckButton").set_sensitive(enable) - self.get_widget("enableModifierKeysCheckButton").set_sensitive(enable) - self.get_widget("enableFunctionKeysCheckButton").set_sensitive(enable) - self.get_widget("enableActionKeysCheckButton").set_sensitive(enable) - self.get_widget("enableNavigationKeysCheckButton").set_sensitive(enable) - self.get_widget("enableDiacriticalKeysCheckButton").set_sensitive( \ - enable) - self._setEchoVoiceItems() - - def _setEchoVoiceItems(self): - """[In]sensitize echo voice controls based on current state.""" - - useCustomVoice = self.get_widget("useCustomEchoVoiceCheckButton").get_active() - useCustomModule = self.get_widget("useCustomEchoSpeechServerCheckButton").get_active() - keyEchoEnabled = self.get_widget("keyEchoCheckButton").get_active() - charEchoEnabled = self.get_widget("enableEchoByCharacterCheckButton").get_active() - wordEchoEnabled = self.get_widget("enableEchoByWordCheckButton").get_active() - sentenceEchoEnabled = self.get_widget("enableEchoBySentenceCheckButton").get_active() - speechEnabled = self.get_widget("speechSupportCheckButton").get_active() - - speechSystemIsDispatcher = False - if self.speechSystemsChoice: - try: - speechSystemIsDispatcher = \ - self.speechSystemsChoice.SpeechServer.getFactoryName() == guilabels.SPEECH_DISPATCHER - except Exception: - speechSystemIsDispatcher = False - - voiceControlsEnabled = useCustomVoice and speechEnabled - moduleOverrideAvailable = voiceControlsEnabled and speechSystemIsDispatcher - moduleControlsEnabled = moduleOverrideAvailable and useCustomModule - - self.get_widget("useCustomEchoSpeechServerCheckButton").set_sensitive(moduleOverrideAvailable) - self.get_widget("echoSpeechServers").set_sensitive(moduleControlsEnabled) - self.get_widget("echoSpeechFamilies").set_sensitive(voiceControlsEnabled) - - self.get_widget("echoRateScale").set_sensitive(useCustomVoice) - self.get_widget("echoPitchScale").set_sensitive(useCustomVoice) - self.get_widget("echoVolumeScale").set_sensitive(useCustomVoice) - - self.get_widget("useCustomEchoForKeyCheckButton").set_sensitive(useCustomVoice and keyEchoEnabled) - self.get_widget("useCustomEchoForCharacterCheckButton").set_sensitive( - useCustomVoice and charEchoEnabled) - self.get_widget("useCustomEchoForWordCheckButton").set_sensitive(useCustomVoice and wordEchoEnabled) - self.get_widget("useCustomEchoForSentenceCheckButton").set_sensitive( - useCustomVoice and sentenceEchoEnabled) - - def _presentMessage(self, text, interrupt=False, voice=None): - """If the text field is not None, presents the given text, optionally - interrupting anything currently being spoken. - - Arguments: - - text: the text to present - - interrupt: if True, interrupt any speech currently being spoken - - voice: the voice to use; if None, use the default system voice - """ - - self.script.speakMessage(text, voice=voice, interrupt=interrupt) - try: - self.script.displayBrailleMessage(text, flashTime=-1) - except Exception: - pass - - def _previewVoiceSelection(self, voiceType, name): - """Present a short preview in the currently selected voice.""" - - if not name: - return - - voice = self._getACSSForVoiceType(voiceType) - if not voice: - return - - previewVoice = acss.ACSS(voice) - if self.speechSystemsChoice \ - and self.speechSystemsChoice.SpeechServer.getFactoryName() == guilabels.SPEECH_DISPATCHER: - previewMessage = messages.SPEECH_VOICE_VALUE % name - else: - previewMessage = messages.SPEECH_VOICE_VALUE_GENERIC % name - - # Speak through the currently-selected preferences speech server so - # preview uses the chosen module/voice, not the globally-active one. - server = self.speechServersChoice - if server and hasattr(server, "speak"): - try: - server.speak(previewMessage, previewVoice, interrupt=True) - return - except Exception: - debug.printException(debug.LEVEL_WARNING) - - self._presentMessage(previewMessage, interrupt=True, voice=previewVoice) - - def _createNode(self, appName): - """Create a new root node in the TreeStore model with the name of the - application. - - Arguments: - - appName: the name of the TreeStore Node (the same of the application) - """ - - model = self.keyBindingsModel - - myiter = model.append(None) - model.set_value(myiter, DESCRIP, appName) - model.set_value(myiter, MODIF, False) - - return myiter - - def _getIterOf(self, appName): - """Returns the Gtk.TreeIter of the TreeStore model - that matches the application name passed as argument - - Arguments: - - appName: a string with the name of the application of the node wanted - it's the same that the field DESCRIP of the model treeStore - """ - - model = self.keyBindingsModel - - for row in model: - if ((model.iter_depth(row.iter) == 0) \ - and (row[DESCRIP] == appName)): - return row.iter - - return None - - def _clickCountToString(self, clickCount): - """Given a numeric clickCount, returns a string for inclusion - in the list of keybindings. - - Argument: - - clickCount: the number of clicks associated with the keybinding. - """ - - clickCountString = "" - if clickCount == 2: - clickCountString = f" ({guilabels.CLICK_COUNT_DOUBLE})" - elif clickCount == 3: - clickCountString = f" ({guilabels.CLICK_COUNT_TRIPLE})" - - return clickCountString - - def _insertRow(self, handl, kb, parent=None, modif=False): - """Appends a new row with the new keybinding data to the treeview - - Arguments: - - handl: the name of the handler associated to the keyBinding - - kb: the new keybinding. - - parent: the parent node of the treeview, where to append the kb - - modif: whether to check the modified field or not. - - Returns a Gtk.TreeIter pointing at the new row. - """ - - model = self.keyBindingsModel - - if parent is None: - parent = self._getIterOf(guilabels.KB_GROUP_DEFAULT) - - if parent is not None: - myiter = model.append(parent) - if not kb.keysymstring: - text = None - else: - clickCount = self._clickCountToString(kb.click_count) - keysymstring = kb.keysymstring - text = keybindings.getModifierNames(kb.modifiers) \ - + keysymstring \ - + clickCount - - model.set_value(myiter, HANDLER, handl) - model.set_value(myiter, DESCRIP, kb.handler.description) - model.set_value(myiter, MOD_MASK1, str(kb.modifier_mask)) - model.set_value(myiter, MOD_USED1, str(kb.modifiers)) - model.set_value(myiter, KEY1, kb.keysymstring) - model.set_value(myiter, CLICK_COUNT1, str(kb.click_count)) - if text is not None: - model.set_value(myiter, OLDTEXT1, text) - model.set_value(myiter, TEXT1, text) - model.set_value(myiter, MODIF, modif) - model.set_value(myiter, EDITABLE, True) - - return myiter - else: - return None - - def _insertRowBraille(self, handl, com, inputEvHand, - parent=None, modif=False): - """Appends a new row with the new braille binding data to the treeview - - Arguments: - - handl: the name of the handler associated to the brailleBinding - - com: the BrlTTY command - - inputEvHand: the inputEventHandler with the new brailleBinding - - parent: the parent node of the treeview, where to append the kb - - modif: whether to check the modified field or not. - - Returns a Gtk.TreeIter pointing at the new row. - """ - - model = self.keyBindingsModel - - if parent is None: - parent = self._getIterOf(guilabels.KB_GROUP_BRAILLE) - - if parent is not None: - myiter = model.append(parent) - model.set_value(myiter, HANDLER, handl) - model.set_value(myiter, DESCRIP, inputEvHand.description) - model.set_value(myiter, KEY1, str(com)) - model.set_value(myiter, TEXT1, braille.command_name[com]) - model.set_value(myiter, MODIF, modif) - model.set_value(myiter, EDITABLE, False) - return myiter - else: - return None - - def _markModified(self): - """ Mark as modified the user custom key bindings: - """ - - try: - self.script.setupInputEventHandlers() - keyBinds = keybindings.KeyBindings() - keyBinds = cthulhu.cthulhuApp.settingsManager.overrideKeyBindings(self.script, keyBinds) - keyBind = keybindings.KeyBinding(None, None, None, None) - treeModel = self.keyBindingsModel - - myiter = treeModel.get_iter_first() - while myiter is not None: - iterChild = treeModel.iter_children(myiter) - while iterChild is not None: - descrip = treeModel.get_value(iterChild, DESCRIP) - keyBind.handler = \ - input_event.InputEventHandler(None, descrip) - if keyBinds.hasKeyBinding(keyBind, - typeOfSearch="description"): - treeModel.set_value(iterChild, MODIF, True) - iterChild = treeModel.iter_next(iterChild) - myiter = treeModel.iter_next(myiter) - except Exception: - debug.printException(debug.LEVEL_SEVERE) - - def _populateKeyBindings(self, clearModel=True): - """Fills the TreeView with the list of Cthulhu keybindings - - Arguments: - - clearModel: if True, initially clear out the key bindings model. - """ - - self.keyBindView.set_model(None) - self.keyBindView.set_headers_visible(False) - self.keyBindView.hide() - if clearModel: - self.keyBindingsModel.clear() - self.kbindings = None - - appName = AXObject.get_name(self.script.app) - iterApp = self._createNode(appName) - iterCthulhu = self._createNode(guilabels.KB_GROUP_DEFAULT) - iterUnbound = self._createNode(guilabels.KB_GROUP_UNBOUND) - iterNotificationPresenter = self._createNode(guilabels.KB_GROUP_NOTIFICATIONS) - iterFlatReviewPresenter = self._createNode(guilabels.KB_GROUP_FLAT_REVIEW) - iterSpeechAndVerbosity = self._createNode(guilabels.KB_GROUP_SPEECH_VERBOSITY) - iterDateAndTime = self._createNode(guilabels.KB_GROUP_DATE_AND_TIME) - iterBookmarks = self._createNode(guilabels.KB_GROUP_BOOKMARKS) - iterObjectNav = self._createNode(guilabels.KB_GROUP_OBJECT_NAVIGATION) - iterWhereAmIPresenter = self._createNode(guilabels.KB_GROUP_WHERE_AM_I) - iterLearnMode = self._createNode(guilabels.KB_GROUP_LEARN_MODE) - iterMouseReviewer = self._createNode(guilabels.KB_GROUP_MOUSE_REVIEW) - iterActionPresenter = self._createNode(guilabels.KB_GROUP_ACTIONS) - - if not self.kbindings: - self.kbindings = keybindings.KeyBindings() - self.script.setupInputEventHandlers() - allKeyBindings = self.script.getKeyBindings() - defKeyBindings = self.script.getDefaultKeyBindings() - npKeyBindings = self.script.getNotificationPresenter().get_bindings() - svKeyBindings = self.script.getSpeechAndVerbosityManager().get_bindings() - dtKeyBindings = self.script.getDateAndTimePresenter().get_bindings() - bmKeyBindings = self.script.getBookmarks().get_bindings() - onKeyBindings = self.script.get_objectNavigator().get_bindings() - lmKeyBindings = self.script.getLearnModePresenter().get_bindings() - mrKeyBindings = self.script.getMouseReviewer().get_bindings() - acKeyBindings = self.script.getActionPresenter().get_bindings() - - layout = cthulhu.cthulhuApp.settingsManager.getSetting('keyboardLayout') - isDesktop = layout == settings.GENERAL_KEYBOARD_LAYOUT_DESKTOP - frKeyBindings = self.script.getFlatReviewPresenter().get_bindings(isDesktop) - waiKeyBindings = self.script.getWhereAmIPresenter().get_bindings(isDesktop) - - for kb in allKeyBindings.keyBindings: - if not self.kbindings.hasKeyBinding(kb, "strict"): - handl = self.script.getInputEventHandlerKey(kb.handler) - if npKeyBindings.hasKeyBinding(kb, "description"): - self._insertRow(handl, kb, iterNotificationPresenter) - elif onKeyBindings.hasKeyBinding(kb, "description"): - self._insertRow(handl, kb, iterObjectNav) - elif frKeyBindings.hasKeyBinding(kb, "description"): - self._insertRow(handl, kb, iterFlatReviewPresenter) - elif waiKeyBindings.hasKeyBinding(kb, "description"): - self._insertRow(handl, kb, iterWhereAmIPresenter) - elif svKeyBindings.hasKeyBinding(kb, "description"): - self._insertRow(handl, kb, iterSpeechAndVerbosity) - elif dtKeyBindings.hasKeyBinding(kb, "description"): - self._insertRow(handl, kb, iterDateAndTime) - elif bmKeyBindings.hasKeyBinding(kb, "description"): - self._insertRow(handl, kb, iterBookmarks) - elif lmKeyBindings.hasKeyBinding(kb, "description"): - self._insertRow(handl, kb, iterLearnMode) - elif acKeyBindings.hasKeyBinding(kb, "description"): - self._insertRow(handl, kb, iterActionPresenter) - elif mrKeyBindings.hasKeyBinding(kb, "description"): - self._insertRow(handl, kb, iterMouseReviewer) - elif not defKeyBindings.hasKeyBinding(kb, "description"): - self._insertRow(handl, kb, iterApp) - elif kb.keysymstring: - self._insertRow(handl, kb, iterCthulhu) - else: - self._insertRow(handl, kb, iterUnbound) - self.kbindings.add(kb) - - if not self.keyBindingsModel.iter_has_child(iterApp): - self.keyBindingsModel.remove(iterApp) - - if not self.keyBindingsModel.iter_has_child(iterUnbound): - self.keyBindingsModel.remove(iterUnbound) - - self._updateCthulhuModifier() - self._markModified() - iterBB = self._createNode(guilabels.KB_GROUP_BRAILLE) - self.bbindings = self.script.getBrailleBindings() - for com, inputEvHand in self.bbindings.items(): - handl = self.script.getInputEventHandlerKey(inputEvHand) - self._insertRowBraille(handl, com, inputEvHand, iterBB) - - self.keyBindView.set_model(self.keyBindingsModel) - self.keyBindView.set_headers_visible(True) - self.keyBindingsModel.set_sort_column_id(OLDTEXT1, Gtk.SortType.ASCENDING) - self.keyBindView.show() - - # Keep track of new/unbound keybindings that have yet to be applied. - # - self.pendingKeyBindings = {} - - def _cleanupSpeechServers(self): - """Remove unwanted factories and drivers for the current active - factory, when the user dismisses the Cthulhu Preferences dialog.""" - - for workingFactory in self.workingFactories: - if not (workingFactory == self.speechSystemsChoice): - workingFactory.SpeechServer.shutdownActiveServers() - else: - servers = workingFactory.SpeechServer.getSpeechServers() - for server in servers: - if not (server == self.speechServersChoice): - server.shutdown() - - def speechSupportChecked(self, widget): - """Signal handler for the "toggled" signal for the - speechSupportCheckButton GtkCheckButton widget. The user has - [un]checked the 'Enable Speech' checkbox. Set the 'enableSpeech' - preference to the new value. Set the rest of the speech pane items - [in]sensensitive depending upon whether this checkbox is checked. - - Arguments: - - widget: the component that generated the signal. - """ - - enable = widget.get_active() - self.prefsDict["enableSpeech"] = enable - self.get_widget("speechOptionsGrid").set_sensitive(enable) - self._setupEchoSpeechServers() - self._setEchoVoiceItems() - - def onlySpeakDisplayedTextToggled(self, widget): - """Signal handler for the "toggled" signal for the GtkCheckButton - onlySpeakDisplayedText. In addition to updating the preferences, - set the sensitivity of the contextOptionsGrid. - - Arguments: - - widget: the component that generated the signal. - """ - - enable = widget.get_active() - self.prefsDict["onlySpeakDisplayedText"] = enable - self.get_widget("contextOptionsGrid").set_sensitive(not enable) - - def speechSystemsChanged(self, widget): - """Signal handler for the "changed" signal for the speechSystems - GtkComboBox widget. The user has selected a different speech - system. Clear the existing list of speech servers, and setup - a new list of speech servers based on the new choice. Setup a - new list of voices for the first speech server in the list. - - Arguments: - - widget: the component that generated the signal. - """ - - if self.initializingSpeech: - return - - selectedIndex = widget.get_active() - self.speechSystemsChoice = self.speechSystemsChoices[selectedIndex] - self._setupSpeechServers() - self._setupEchoSpeechServers() - self._setEchoVoiceItems() - - def speechServersChanged(self, widget): - """Signal handler for the "changed" signal for the speechServers - GtkComboBox widget. The user has selected a different speech - server. Clear the existing list of voices, and setup a new - list of voices based on the new choice. - - Arguments: - - widget: the component that generated the signal. - """ - - if self.initializingSpeech: - return - - selectedIndex = widget.get_active() - self.speechServersChoice = self.speechServersChoices[selectedIndex] - - # Whenever the speech servers change, we need to make sure we - # clear whatever family was in use by the current voice types. - # Otherwise, we can end up with family names from one server - # bleeding over (e.g., "Paul" from Fonix ends up getting in - # the "Default" voice type after we switch to eSpeak). - # - try: - del self.defaultVoice[acss.ACSS.FAMILY] - del self.uppercaseVoice[acss.ACSS.FAMILY] - del self.hyperlinkVoice[acss.ACSS.FAMILY] - del self.systemVoice[acss.ACSS.FAMILY] - except Exception: - pass - - self._setupVoices() - self._setEchoVoiceItems() - - def speechLanguagesChanged(self, widget): - """Signal handler for the "value_changed" signal for the languages - GtkComboBox widget. The user has selected a different voice - language. Save the new voice language name based on the new choice. - - Arguments: - - widget: the component that generated the signal. - """ - - if self.initializingSpeech: - return - - selectedIndex = widget.get_active() - try: - self.speechLanguagesChoice = self.speechLanguagesChoices[selectedIndex] - if (self.speechServersChoice, self.speechLanguagesChoice) in \ - self.selectedFamilyChoices: - i = self.selectedFamilyChoices[self.speechServersChoice, \ - self.speechLanguagesChoice] - family = self.speechFamiliesChoices[i] - name = family[speechserver.VoiceFamily.NAME] - language = family[speechserver.VoiceFamily.LANG] - dialect = family[speechserver.VoiceFamily.DIALECT] - variant = family[speechserver.VoiceFamily.VARIANT] - voiceType = self.get_widget("voiceTypesCombo").get_active() - self._setFamilyNameForVoiceType(voiceType, name, language, dialect, variant) - except Exception: - debug.printException(debug.LEVEL_SEVERE) - - # Remember the last family manually selected by the user for the - # current speech server. - # - if not selectedIndex == -1: - self.selectedLanguageChoices[self.speechServersChoice] = selectedIndex - - self._setupFamilies() - - def speechFamiliesChanged(self, widget): - """Signal handler for the "value_changed" signal for the families - GtkComboBox widget. The user has selected a different voice - family. Save the new voice family name based on the new choice. - - Arguments: - - widget: the component that generated the signal. - """ - - if self.initializingSpeech: - return - - selectedIndex = widget.get_active() - try: - family = self.speechFamiliesChoices[selectedIndex] - name = family[speechserver.VoiceFamily.NAME] - language = family[speechserver.VoiceFamily.LANG] - dialect = family[speechserver.VoiceFamily.DIALECT] - variant = family[speechserver.VoiceFamily.VARIANT] - voiceType = self.get_widget("voiceTypesCombo").get_active() - self._setFamilyNameForVoiceType(voiceType, name, language, dialect, variant) - if not self._updatingSpeechFamilies: - self._previewVoiceSelection(voiceType, name) - except Exception: - debug.printException(debug.LEVEL_SEVERE) - - # Remember the last family manually selected by the user for the - # current speech server. - # - if not selectedIndex == -1: - self.selectedFamilyChoices[self.speechServersChoice, \ - self.speechLanguagesChoice] = selectedIndex - - def voiceTypesChanged(self, widget): - """Signal handler for the "changed" signal for the voiceTypes - GtkComboBox widget. The user has selected a different voice - type. Setup the new family, rate, pitch and volume component - values based on the new choice. - - Arguments: - - widget: the component that generated the signal. - """ - - if self.initializingSpeech: - return - - voiceType = widget.get_active() - self._setVoiceSettingsForVoiceType(voiceType) - - def rateValueChanged(self, widget): - """Signal handler for the "value_changed" signal for the rateScale - GtkScale widget. The user has changed the current rate value. - Save the new rate value based on the currently selected voice - type. - - Arguments: - - widget: the component that generated the signal. - """ - - rate = widget.get_value() - voiceType = self.get_widget("voiceTypesCombo").get_active() - self._setRateForVoiceType(voiceType, rate) - voices = cthulhu.cthulhuApp.settingsManager.getSetting('voices') - voices.get(settings.DEFAULT_VOICE, {})[acss.ACSS.RATE] = rate - cthulhu.cthulhuApp.settingsManager.setSetting('voices', voices) - - def pitchValueChanged(self, widget): - """Signal handler for the "value_changed" signal for the pitchScale - GtkScale widget. The user has changed the current pitch value. - Save the new pitch value based on the currently selected voice - type. - - Arguments: - - widget: the component that generated the signal. - """ - - pitch = widget.get_value() - voiceType = self.get_widget("voiceTypesCombo").get_active() - self._setPitchForVoiceType(voiceType, pitch) - voices = cthulhu.cthulhuApp.settingsManager.getSetting('voices') - voices.get(settings.DEFAULT_VOICE, {})[acss.ACSS.AVERAGE_PITCH] = pitch - cthulhu.cthulhuApp.settingsManager.setSetting('voices', voices) - - def volumeValueChanged(self, widget): - """Signal handler for the "value_changed" signal for the voiceScale - GtkScale widget. The user has changed the current volume value. - Save the new volume value based on the currently selected voice - type. - - Arguments: - - widget: the component that generated the signal. - """ - - volume = widget.get_value() - voiceType = self.get_widget("voiceTypesCombo").get_active() - self._setVolumeForVoiceType(voiceType, volume) - voices = cthulhu.cthulhuApp.settingsManager.getSetting('voices') - voices.get(settings.DEFAULT_VOICE, {})[acss.ACSS.GAIN] = volume - cthulhu.cthulhuApp.settingsManager.setSetting('voices', voices) - - def useCustomEchoVoiceToggled(self, widget): - """Signal handler for enabling/disabling custom echo voice settings.""" - - self.prefsDict["useCustomEchoVoice"] = widget.get_active() - self._setEchoVoiceItems() - self._setupEchoSpeechFamilies() - - def useCustomEchoSpeechServerToggled(self, widget): - """Signal handler for enabling/disabling custom echo module override.""" - - self.prefsDict["useCustomEchoSpeechServer"] = widget.get_active() - self._setEchoVoiceItems() - self._setupEchoSpeechFamilies() - - def echoSpeechServersChanged(self, widget): - """Signal handler for selecting the echo speech-dispatcher module.""" - - if self.initializingEchoSpeech: - return - - selectedIndex = widget.get_active() - if selectedIndex < 0: - self.echoSpeechServersChoice = None - self.prefsDict["echoSpeechServerInfo"] = None - self._setupEchoSpeechFamilies() - return - - self.echoSpeechServersChoice = self.echoSpeechServersChoices[selectedIndex] - self.prefsDict["echoSpeechServerInfo"] = self.echoSpeechServersChoice.getInfo() - self._setupEchoSpeechFamilies() - - def echoSpeechFamiliesChanged(self, widget): - """Signal handler for selecting the echo voice family.""" - - if self.initializingEchoSpeech or self._updatingEchoSpeechFamilies: - return - - selectedIndex = widget.get_active() - if selectedIndex < 0: - self.echoSpeechFamiliesChoice = None - return - - self.echoSpeechFamiliesChoice = self.echoSpeechFamiliesChoices[selectedIndex] - self._setEchoVoiceFamily(self.echoSpeechFamiliesChoice) - - def echoRateValueChanged(self, widget): - """Signal handler for changing custom echo rate.""" - - if self.echoVoice is None: - self.echoVoice = acss.ACSS({}) - self.echoVoice[acss.ACSS.RATE] = widget.get_value() - self.echoVoice['established'] = True - - def echoPitchValueChanged(self, widget): - """Signal handler for changing custom echo pitch.""" - - if self.echoVoice is None: - self.echoVoice = acss.ACSS({}) - self.echoVoice[acss.ACSS.AVERAGE_PITCH] = widget.get_value() - self.echoVoice['established'] = True - - def echoVolumeValueChanged(self, widget): - """Signal handler for changing custom echo volume.""" - - if self.echoVoice is None: - self.echoVoice = acss.ACSS({}) - self.echoVoice[acss.ACSS.GAIN] = widget.get_value() - self.echoVoice['established'] = True - - def checkButtonToggled(self, widget): - """Signal handler for "toggled" signal for basic GtkCheckButton - widgets. The user has altered the state of the checkbox. - Set the preference to the new value. - - Arguments: - - widget: the component that generated the signal. - """ - - # To use this default handler please make sure: - # The name of the setting that will be changed is: settingName - # The id of the widget in the ui should be: settingNameCheckButton - # - settingName = Gtk.Buildable.get_name(widget) - # strip "CheckButton" from the end. - settingName = settingName[:-11] - self.prefsDict[settingName] = widget.get_active() - if settingName in [ - "enableEchoByCharacter", - "enableEchoByWord", - "enableEchoBySentence", - "useCustomEchoForKey", - "useCustomEchoForCharacter", - "useCustomEchoForWord", - "useCustomEchoForSentence"]: - self._setEchoVoiceItems() - - def keyEchoChecked(self, widget): - """Signal handler for the "toggled" signal for the - keyEchoCheckbutton GtkCheckButton widget. The user has - [un]checked the 'Enable Key Echo' checkbox. Set the - 'enableKeyEcho' preference to the new value. [In]sensitize - the checkboxes for the various types of key echo, depending - upon whether this value is checked or unchecked. - - Arguments: - - widget: the component that generated the signal. - """ - - self.prefsDict["enableKeyEcho"] = widget.get_active() - self._setKeyEchoItems() - - def brailleSelectionChanged(self, widget): - """Signal handler for the "toggled" signal for the - brailleSelectionNoneButton, brailleSelection7Button, - brailleSelection8Button or brailleSelectionBothButton - GtkRadioButton widgets. The user has toggled the braille - selection indicator value. If this signal was generated - as the result of a radio button getting selected (as - opposed to a radio button losing the selection), set the - 'brailleSelectorIndicator' preference to the new value. - - Arguments: - - widget: the component that generated the signal. - """ - - if widget.get_active(): - if widget.get_label() == guilabels.BRAILLE_DOT_7: - self.prefsDict["brailleSelectorIndicator"] = \ - settings.BRAILLE_UNDERLINE_7 - elif widget.get_label() == guilabels.BRAILLE_DOT_8: - self.prefsDict["brailleSelectorIndicator"] = \ - settings.BRAILLE_UNDERLINE_8 - elif widget.get_label() == guilabels.BRAILLE_DOT_7_8: - self.prefsDict["brailleSelectorIndicator"] = \ - settings.BRAILLE_UNDERLINE_BOTH - else: - self.prefsDict["brailleSelectorIndicator"] = \ - settings.BRAILLE_UNDERLINE_NONE - - def brailleLinkChanged(self, widget): - """Signal handler for the "toggled" signal for the - brailleLinkNoneButton, brailleLink7Button, - brailleLink8Button or brailleLinkBothButton - GtkRadioButton widgets. The user has toggled the braille - link indicator value. If this signal was generated - as the result of a radio button getting selected (as - opposed to a radio button losing the selection), set the - 'brailleLinkIndicator' preference to the new value. - - Arguments: - - widget: the component that generated the signal. - """ - - if widget.get_active(): - if widget.get_label() == guilabels.BRAILLE_DOT_7: - self.prefsDict["brailleLinkIndicator"] = \ - settings.BRAILLE_UNDERLINE_7 - elif widget.get_label() == guilabels.BRAILLE_DOT_8: - self.prefsDict["brailleLinkIndicator"] = \ - settings.BRAILLE_UNDERLINE_8 - elif widget.get_label() == guilabels.BRAILLE_DOT_7_8: - self.prefsDict["brailleLinkIndicator"] = \ - settings.BRAILLE_UNDERLINE_BOTH - else: - self.prefsDict["brailleLinkIndicator"] = \ - settings.BRAILLE_UNDERLINE_NONE - - def brailleIndicatorChanged(self, widget): - """Signal handler for the "toggled" signal for the - textBrailleNoneButton, textBraille7Button, textBraille8Button - or textBrailleBothButton GtkRadioButton widgets. The user has - toggled the text attributes braille indicator value. If this signal - was generated as the result of a radio button getting selected - (as opposed to a radio button losing the selection), set the - 'textAttributesBrailleIndicator' preference to the new value. - - Arguments: - - widget: the component that generated the signal. - """ - - if widget.get_active(): - if widget.get_label() == guilabels.BRAILLE_DOT_7: - self.prefsDict["textAttributesBrailleIndicator"] = \ - settings.BRAILLE_UNDERLINE_7 - elif widget.get_label() == guilabels.BRAILLE_DOT_8: - self.prefsDict["textAttributesBrailleIndicator"] = \ - settings.BRAILLE_UNDERLINE_8 - elif widget.get_label() == guilabels.BRAILLE_DOT_7_8: - self.prefsDict["textAttributesBrailleIndicator"] = \ - settings.BRAILLE_UNDERLINE_BOTH - else: - self.prefsDict["textAttributesBrailleIndicator"] = \ - settings.BRAILLE_UNDERLINE_NONE - - def punctuationLevelChanged(self, widget): - """Signal handler for the "toggled" signal for the noneButton, - someButton or allButton GtkRadioButton widgets. The user has - toggled the speech punctuation level value. If this signal - was generated as the result of a radio button getting selected - (as opposed to a radio button losing the selection), set the - 'verbalizePunctuationStyle' preference to the new value. - - Arguments: - - widget: the component that generated the signal. - """ - - if widget.get_active(): - if widget.get_label() == guilabels.PUNCTUATION_STYLE_NONE: - self.prefsDict["verbalizePunctuationStyle"] = \ - settings.PUNCTUATION_STYLE_NONE - elif widget.get_label() == guilabels.PUNCTUATION_STYLE_SOME: - self.prefsDict["verbalizePunctuationStyle"] = \ - settings.PUNCTUATION_STYLE_SOME - elif widget.get_label() == guilabels.PUNCTUATION_STYLE_MOST: - self.prefsDict["verbalizePunctuationStyle"] = \ - settings.PUNCTUATION_STYLE_MOST - else: - self.prefsDict["verbalizePunctuationStyle"] = \ - settings.PUNCTUATION_STYLE_ALL - - def cthulhuModifierChanged(self, widget): - """Signal handler for the changed signal for the cthulhuModifierComboBox - Set the 'cthulhuModifierKeys' preference to the new value. - - Arguments: - - widget: the component that generated the signal. - """ - - model = widget.get_model() - myIter = widget.get_active_iter() - cthulhuModifier = model[myIter][0] - self.prefsDict["cthulhuModifierKeys"] = cthulhuModifier.split(', ') - - def progressBarVerbosityChanged(self, widget): - """Signal handler for the changed signal for the progressBarVerbosity - GtkComboBox widget. Set the 'progressBarVerbosity' preference to - the new value. - - Arguments: - - widget: the component that generated the signal. - """ - - model = widget.get_model() - myIter = widget.get_active_iter() - progressBarVerbosity = model[myIter][0] - if progressBarVerbosity == guilabels.PROGRESS_BAR_ALL: - self.prefsDict["progressBarVerbosity"] = \ - settings.PROGRESS_BAR_ALL - elif progressBarVerbosity == guilabels.PROGRESS_BAR_WINDOW: - self.prefsDict["progressBarVerbosity"] = \ - settings.PROGRESS_BAR_WINDOW - else: - self.prefsDict["progressBarVerbosity"] = \ - settings.PROGRESS_BAR_APPLICATION - - def capitalizationStyleChanged(self, widget): - model = widget.get_model() - myIter = widget.get_active_iter() - capitalizationStyle = model[myIter][0] - if capitalizationStyle == guilabels.CAPITALIZATION_STYLE_ICON: - self.prefsDict["capitalizationStyle"] = settings.CAPITALIZATION_STYLE_ICON - elif capitalizationStyle == guilabels.CAPITALIZATION_STYLE_SPELL: - self.prefsDict["capitalizationStyle"] = settings.CAPITALIZATION_STYLE_SPELL - else: - self.prefsDict["capitalizationStyle"] = settings.CAPITALIZATION_STYLE_NONE - self.script.speechAndVerbosityManager.update_capitalization_style() - - def sayAllStyleChanged(self, widget): - """Signal handler for the "changed" signal for the sayAllStyle - GtkComboBox widget. Set the 'sayAllStyle' preference to the - new value. - - Arguments: - - widget: the component that generated the signal. - """ - - model = widget.get_model() - myIter = widget.get_active_iter() - sayAllStyle = model[myIter][0] - if sayAllStyle == guilabels.SAY_ALL_STYLE_LINE: - self.prefsDict["sayAllStyle"] = settings.SAYALL_STYLE_LINE - elif sayAllStyle == guilabels.SAY_ALL_STYLE_SENTENCE: - self.prefsDict["sayAllStyle"] = settings.SAYALL_STYLE_SENTENCE - - def dateFormatChanged(self, widget): - """Signal handler for the "changed" signal for the dateFormat - GtkComboBox widget. Set the 'dateFormat' preference to the - new value. - - Arguments: - - widget: the component that generated the signal. - """ - - dateFormatCombo = widget.get_active() - if dateFormatCombo == DATE_FORMAT_LOCALE: - newFormat = messages.DATE_FORMAT_LOCALE - elif dateFormatCombo == DATE_FORMAT_NUMBERS_DM: - newFormat = messages.DATE_FORMAT_NUMBERS_DM - elif dateFormatCombo == DATE_FORMAT_NUMBERS_MD: - newFormat = messages.DATE_FORMAT_NUMBERS_MD - elif dateFormatCombo == DATE_FORMAT_NUMBERS_DMY: - newFormat = messages.DATE_FORMAT_NUMBERS_DMY - elif dateFormatCombo == DATE_FORMAT_NUMBERS_MDY: - newFormat = messages.DATE_FORMAT_NUMBERS_MDY - elif dateFormatCombo == DATE_FORMAT_NUMBERS_YMD: - newFormat = messages.DATE_FORMAT_NUMBERS_YMD - elif dateFormatCombo == DATE_FORMAT_FULL_DM: - newFormat = messages.DATE_FORMAT_FULL_DM - elif dateFormatCombo == DATE_FORMAT_FULL_MD: - newFormat = messages.DATE_FORMAT_FULL_MD - elif dateFormatCombo == DATE_FORMAT_FULL_DMY: - newFormat = messages.DATE_FORMAT_FULL_DMY - elif dateFormatCombo == DATE_FORMAT_FULL_MDY: - newFormat = messages.DATE_FORMAT_FULL_MDY - elif dateFormatCombo == DATE_FORMAT_FULL_YMD: - newFormat = messages.DATE_FORMAT_FULL_YMD - elif dateFormatCombo == DATE_FORMAT_ABBREVIATED_DM: - newFormat = messages.DATE_FORMAT_ABBREVIATED_DM - elif dateFormatCombo == DATE_FORMAT_ABBREVIATED_MD: - newFormat = messages.DATE_FORMAT_ABBREVIATED_MD - elif dateFormatCombo == DATE_FORMAT_ABBREVIATED_DMY: - newFormat = messages.DATE_FORMAT_ABBREVIATED_DMY - elif dateFormatCombo == DATE_FORMAT_ABBREVIATED_MDY: - newFormat = messages.DATE_FORMAT_ABBREVIATED_MDY - elif dateFormatCombo == DATE_FORMAT_ABBREVIATED_YMD: - newFormat = messages.DATE_FORMAT_ABBREVIATED_YMD - self.prefsDict["presentDateFormat"] = newFormat - - def timeFormatChanged(self, widget): - """Signal handler for the "changed" signal for the timeFormat - GtkComboBox widget. Set the 'timeFormat' preference to the - new value. - - Arguments: - - widget: the component that generated the signal. - """ - - timeFormatCombo = widget.get_active() - if timeFormatCombo == TIME_FORMAT_LOCALE: - newFormat = messages.TIME_FORMAT_LOCALE - elif timeFormatCombo == TIME_FORMAT_12_HM: - newFormat = messages.TIME_FORMAT_12_HM - elif timeFormatCombo == TIME_FORMAT_12_HMS: - newFormat = messages.TIME_FORMAT_12_HMS - elif timeFormatCombo == TIME_FORMAT_24_HMS: - newFormat = messages.TIME_FORMAT_24_HMS - elif timeFormatCombo == TIME_FORMAT_24_HMS_WITH_WORDS: - newFormat = messages.TIME_FORMAT_24_HMS_WITH_WORDS - elif timeFormatCombo == TIME_FORMAT_24_HM: - newFormat = messages.TIME_FORMAT_24_HM - elif timeFormatCombo == TIME_FORMAT_24_HM_WITH_WORDS: - newFormat = messages.TIME_FORMAT_24_HM_WITH_WORDS - self.prefsDict["presentTimeFormat"] = newFormat - - def speechVerbosityChanged(self, widget): - """Signal handler for the "toggled" signal for the speechBriefButton, - or speechVerboseButton GtkRadioButton widgets. The user has - toggled the speech verbosity level value. If this signal was - generated as the result of a radio button getting selected - (as opposed to a radio button losing the selection), set the - 'speechVerbosityLevel' preference to the new value. - - Arguments: - - widget: the component that generated the signal. - """ - - if widget.get_active(): - if widget.get_label() == guilabels.VERBOSITY_LEVEL_BRIEF: - self.prefsDict["speechVerbosityLevel"] = \ - settings.VERBOSITY_LEVEL_BRIEF - else: - self.prefsDict["speechVerbosityLevel"] = \ - settings.VERBOSITY_LEVEL_VERBOSE - - def progressBarUpdateIntervalValueChanged(self, widget): - """Signal handler for the "value_changed" signal for the - progressBarUpdateIntervalSpinButton GtkSpinButton widget. - - Arguments: - - widget: the component that generated the signal. - """ - - self.prefsDict["progressBarUpdateInterval"] = widget.get_value_as_int() - - def brailleFlashTimeValueChanged(self, widget): - self.prefsDict["brailleFlashTime"] = widget.get_value_as_int() * 1000 - - def abbrevRolenamesChecked(self, widget): - """Signal handler for the "toggled" signal for the abbrevRolenames - GtkCheckButton widget. The user has [un]checked the 'Abbreviated - Rolenames' checkbox. Set the 'brailleRolenameStyle' preference - to the new value. - - Arguments: - - widget: the component that generated the signal. - """ - - if widget.get_active(): - self.prefsDict["brailleRolenameStyle"] = \ - settings.BRAILLE_ROLENAME_STYLE_SHORT - else: - self.prefsDict["brailleRolenameStyle"] = \ - settings.BRAILLE_ROLENAME_STYLE_LONG - - def brailleVerbosityChanged(self, widget): - """Signal handler for the "toggled" signal for the brailleBriefButton, - or brailleVerboseButton GtkRadioButton widgets. The user has - toggled the braille verbosity level value. If this signal was - generated as the result of a radio button getting selected - (as opposed to a radio button losing the selection), set the - 'brailleVerbosityLevel' preference to the new value. - - Arguments: - - widget: the component that generated the signal. - """ - - if widget.get_active(): - if widget.get_label() == guilabels.VERBOSITY_LEVEL_BRIEF: - self.prefsDict["brailleVerbosityLevel"] = \ - settings.VERBOSITY_LEVEL_BRIEF - else: - self.prefsDict["brailleVerbosityLevel"] = \ - settings.VERBOSITY_LEVEL_VERBOSE - - def keyModifiedToggle(self, cell, path, model, col): - """When the user changes a checkbox field (boolean field)""" - - model[path][col] = not model[path][col] - return - - def editingKey(self, cell, editable, path, treeModel): - """Starts user input of a Key for a selected key binding""" - - self._presentMessage(messages.KB_ENTER_NEW_KEY) - cthulhu_state.capturingKeys = True - try: - script_manager.get_manager().get_active_script().removeKeyGrabs() - except Exception: - pass - try: - input_event_manager.get_manager().unmap_all_modifiers() - except Exception: - pass - self._unmapCthulhuModifiersForCapture() - editable.connect('key-press-event', self.kbKeyPressed) - return - - def _unmapCthulhuModifiersForCapture(self): - """Unmap Cthulhu modifier keys so they can be captured as bindings.""" - device = cthulhu_state.device - if device is None: - return - - for modifierKey in settings.cthulhuModifierKeys: - keycode = keybindings.getKeycode(modifierKey) - if keycode == 0 and modifierKey == "Shift_Lock": - keycode = keybindings.getKeycode("Caps_Lock") - if keycode: - try: - device.unmap_modifier(keycode) - except Exception: - pass - - def editingCanceledKey(self, editable): - """Stops user input of a Key for a selected key binding""" - - cthulhu_state.capturingKeys = False - self._capturedKey = [] - try: - script_manager.get_manager().get_active_script().refreshKeyGrabs() - except Exception: - pass - return - - def _processKeyCaptured(self, keyPressedEvent): - """Called when a new key event arrives and we are capturing keys. - (used for key bindings redefinition) - """ - - # We want the keyname rather than the printable character. - # If it's not on the keypad, get the name of the unshifted - # character. (i.e. "1" instead of "!") - # - keycode = keyPressedEvent.hardware_keycode - keymap = Gdk.Keymap.get_default() - entries_for_keycode = keymap.get_entries_for_keycode(keycode) - entries = entries_for_keycode[-1] - eventString = Gdk.keyval_name(entries[0]) - eventState = keyPressedEvent.state - eventKeyvalName = Gdk.keyval_name(keyPressedEvent.keyval) - - cthulhuMods = settings.cthulhuModifierKeys - if eventKeyvalName in cthulhuMods: - eventString = eventKeyvalName - self._capturedKey = ['', keybindings.CTHULHU_MODIFIER_MASK, 0] - return False - if eventKeyvalName == "KP_0" \ - and "KP_Insert" in cthulhuMods \ - and eventState & Gdk.ModifierType.SHIFT_MASK: - eventString = "KP_Insert" - self._capturedKey = ['', keybindings.CTHULHU_MODIFIER_MASK, 0] - return False - - modifierKeys = ['Alt_L', 'Alt_R', 'Control_L', 'Control_R', - 'Shift_L', 'Shift_R', 'Meta_L', 'Meta_R', - 'Num_Lock', 'Caps_Lock', 'Shift_Lock'] - if eventString in modifierKeys: - return False - - eventState = eventState & Gtk.accelerator_get_default_mod_mask() - if not self._capturedKey \ - or eventString in ['Return', 'Escape']: - self._capturedKey = [eventString, eventState, 1] - return True - - string, modifiers, clickCount = self._capturedKey - isCthulhuModifier = modifiers & keybindings.CTHULHU_MODIFIER_MASK - if isCthulhuModifier: - eventState |= keybindings.CTHULHU_MODIFIER_MASK - self._capturedKey = [eventString, eventState, clickCount + 1] - - return True - - def kbKeyPressed(self, editable, event): - """Special handler for the key_pressed events when editing the - keybindings. This lets us control what gets inserted into the - entry. - """ - - keyProcessed = self._processKeyCaptured(event) - if not keyProcessed: - return True - - if not self._capturedKey: - return False - - keyName, modifiers, clickCount = self._capturedKey - if not keyName or keyName in ["Return", "Escape"]: - return False - - isCthulhuModifier = modifiers & keybindings.CTHULHU_MODIFIER_MASK - if keyName in ["Delete", "BackSpace"] and not isCthulhuModifier: - editable.set_text("") - self._presentMessage(messages.KB_DELETED) - self._capturedKey = [] - self.newBinding = None - return True - - self.newBinding = keybindings.KeyBinding(keyName, - keybindings.defaultModifierMask, - modifiers, - None, - clickCount) - modifierNames = keybindings.getModifierNames(modifiers) - clickCountString = self._clickCountToString(clickCount) - newString = modifierNames + keyName + clickCountString - description = self.pendingKeyBindings.get(newString) - - if description is None: - - def match(x): - return x.keysymstring == keyName and x.modifiers == modifiers \ - and x.click_count == clickCount and x.handler - - matches = list(filter(match, self.kbindings.keyBindings)) - if matches: - description = matches[0].handler.description - - if description: - msg = messages.KB_ALREADY_BOUND % description - delay = int(1000 * settings.doubleClickTimeout) - GLib.timeout_add(delay, self._presentMessage, msg) - else: - msg = messages.KB_CAPTURED % newString - editable.set_text(newString) - self._presentMessage(msg) - - return True - - def editedKey(self, cell, path, new_text, treeModel, - modMask, modUsed, key, click_count, text): - """The user changed the key for a Keybinding: update the model of - the treeview. - """ - - cthulhu_state.capturingKeys = False - self._capturedKey = [] - try: - script_manager.get_manager().get_active_script().refreshKeyGrabs() - except Exception: - pass - myiter = treeModel.get_iter_from_string(path) - try: - originalBinding = treeModel.get_value(myiter, text) - except Exception: - originalBinding = '' - modified = (originalBinding != new_text) - - try: - string = self.newBinding.keysymstring - mods = self.newBinding.modifiers - clickCount = self.newBinding.click_count - except Exception: - string = '' - mods = 0 - clickCount = 1 - - mods = mods & Gdk.ModifierType.MODIFIER_MASK - if mods & (1 << Atspi.ModifierType.SHIFTLOCK) \ - and mods & keybindings.CTHULHU_MODIFIER_MASK: - mods ^= (1 << Atspi.ModifierType.SHIFTLOCK) - - treeModel.set(myiter, - modMask, str(keybindings.defaultModifierMask), - modUsed, str(int(mods)), - key, string, - text, new_text, - click_count, str(clickCount), - MODIF, modified) - speech.stop() - if new_text: - message = messages.KB_CAPTURED_CONFIRMATION % new_text - description = treeModel.get_value(myiter, DESCRIP) - self.pendingKeyBindings[new_text] = description - else: - message = messages.KB_DELETED_CONFIRMATION - - if modified: - self._presentMessage(message) - self.pendingKeyBindings[originalBinding] = "" - - return - - def presentToolTipsChecked(self, widget): - """Signal handler for the "toggled" signal for the - presentToolTipsCheckButton GtkCheckButton widget. - The user has [un]checked the 'Present ToolTips' - checkbox. Set the 'presentToolTips' - preference to the new value if the user can present tooltips. - - Arguments: - - widget: the component that generated the signal. - """ - - self.prefsDict["presentToolTips"] = widget.get_active() - - def keyboardLayoutChanged(self, widget): - """Signal handler for the "toggled" signal for the generalDesktopButton, - or generalLaptopButton GtkRadioButton widgets. The user has - toggled the keyboard layout value. If this signal was - generated as the result of a radio button getting selected - (as opposed to a radio button losing the selection), set the - 'keyboardLayout' preference to the new value. Also set the - matching list of Cthulhu modifier keys - - Arguments: - - widget: the component that generated the signal. - """ - - if widget.get_active(): - if widget.get_label() == guilabels.KEYBOARD_LAYOUT_DESKTOP: - self.prefsDict["keyboardLayout"] = \ - settings.GENERAL_KEYBOARD_LAYOUT_DESKTOP - self.prefsDict["cthulhuModifierKeys"] = \ - settings.DESKTOP_MODIFIER_KEYS - else: - self.prefsDict["keyboardLayout"] = \ - settings.GENERAL_KEYBOARD_LAYOUT_LAPTOP - self.prefsDict["cthulhuModifierKeys"] = \ - settings.LAPTOP_MODIFIER_KEYS - - def pronunciationAddButtonClicked(self, widget): - """Signal handler for the "clicked" signal for the - pronunciationAddButton GtkButton widget. The user has clicked - the Add button on the Pronunciation pane. A new row will be - added to the end of the pronunciation dictionary list. Both the - actual and replacement strings will initially be set to an empty - string. Focus will be moved to that row. - - Arguments: - - widget: the component that generated the signal. - """ - - model = self.pronunciationView.get_model() - thisIter = model.append() - model.set(thisIter, ACTUAL, "", REPLACEMENT, "") - path = model.get_path(thisIter) - col = self.pronunciationView.get_column(0) - self.pronunciationView.grab_focus() - self.pronunciationView.set_cursor(path, col, True) - - def pronunciationDeleteButtonClicked(self, widget): - """Signal handler for the "clicked" signal for the - pronunciationDeleteButton GtkButton widget. The user has clicked - the Delete button on the Pronunciation pane. The row in the - pronunciation dictionary list with focus will be deleted. - - Arguments: - - widget: the component that generated the signal. - """ - - model, oldIter = self.pronunciationView.get_selection().get_selected() - model.remove(oldIter) - - def textSelectAllButtonClicked(self, widget): - """Signal handler for the "clicked" signal for the - textSelectAllButton GtkButton widget. The user has clicked - the Speak all button. Check all the text attributes and - then update the "enabledSpokenTextAttributes" and - "enabledBrailledTextAttributes" preference strings. - - Arguments: - - widget: the component that generated the signal. - """ - - attributes = cthulhu.cthulhuApp.settingsManager.getSetting('allTextAttributes') - self._setSpokenTextAttributes( - self.getTextAttributesView, attributes, True) - self._setBrailledTextAttributes( - self.getTextAttributesView, attributes, True) - self._updateTextDictEntry() - - def textUnselectAllButtonClicked(self, widget): - """Signal handler for the "clicked" signal for the - textUnselectAllButton GtkButton widget. The user has clicked - the Speak none button. Uncheck all the text attributes and - then update the "enabledSpokenTextAttributes" and - "enabledBrailledTextAttributes" preference strings. - - Arguments: - - widget: the component that generated the signal. - """ - - attributes = cthulhu.cthulhuApp.settingsManager.getSetting('allTextAttributes') - self._setSpokenTextAttributes( - self.getTextAttributesView, attributes, False) - self._setBrailledTextAttributes( - self.getTextAttributesView, attributes, False) - self._updateTextDictEntry() - - def textResetButtonClicked(self, widget): - """Signal handler for the "clicked" signal for the - textResetButton GtkButton widget. The user has clicked - the Reset button. Reset all the text attributes to their - initial state and then update the "enabledSpokenTextAttributes" - and "enabledBrailledTextAttributes" preference strings. - - Arguments: - - widget: the component that generated the signal. - """ - - attributes = cthulhu.cthulhuApp.settingsManager.getSetting('allTextAttributes') - self._setSpokenTextAttributes( - self.getTextAttributesView, attributes, False) - self._setBrailledTextAttributes( - self.getTextAttributesView, attributes, False) - - attributes = cthulhu.cthulhuApp.settingsManager.getSetting('enabledSpokenTextAttributes') - self._setSpokenTextAttributes( - self.getTextAttributesView, attributes, True) - - attributes = \ - cthulhu.cthulhuApp.settingsManager.getSetting('enabledBrailledTextAttributes') - self._setBrailledTextAttributes( - self.getTextAttributesView, attributes, True) - - self._updateTextDictEntry() - - def textMoveToTopButtonClicked(self, widget): - """Signal handler for the "clicked" signal for the - textMoveToTopButton GtkButton widget. The user has clicked - the Move to top button. Move the selected rows in the text - attribute view to the very top of the list and then update - the "enabledSpokenTextAttributes" and "enabledBrailledTextAttributes" - preference strings. - - Arguments: - - widget: the component that generated the signal. - """ - - textSelection = self.getTextAttributesView.get_selection() - [model, paths] = textSelection.get_selected_rows() - for path in paths: - thisIter = model.get_iter(path) - model.move_after(thisIter, None) - self._updateTextDictEntry() - - def textMoveUpOneButtonClicked(self, widget): - """Signal handler for the "clicked" signal for the - textMoveUpOneButton GtkButton widget. The user has clicked - the Move up one button. Move the selected rows in the text - attribute view up one row in the list and then update the - "enabledSpokenTextAttributes" and "enabledBrailledTextAttributes" - preference strings. - - Arguments: - - widget: the component that generated the signal. - """ - - textSelection = self.getTextAttributesView.get_selection() - [model, paths] = textSelection.get_selected_rows() - for path in paths: - thisIter = model.get_iter(path) - indices = path.get_indices() - if indices[0]: - otherIter = model.iter_nth_child(None, indices[0]-1) - model.swap(thisIter, otherIter) - self._updateTextDictEntry() - - def textMoveDownOneButtonClicked(self, widget): - """Signal handler for the "clicked" signal for the - textMoveDownOneButton GtkButton widget. The user has clicked - the Move down one button. Move the selected rows in the text - attribute view down one row in the list and then update the - "enabledSpokenTextAttributes" and "enabledBrailledTextAttributes" - preference strings. - - Arguments: - - widget: the component that generated the signal. - """ - - textSelection = self.getTextAttributesView.get_selection() - [model, paths] = textSelection.get_selected_rows() - noRows = model.iter_n_children(None) - for path in paths: - thisIter = model.get_iter(path) - indices = path.get_indices() - if indices[0] < noRows-1: - otherIter = model.iter_next(thisIter) - model.swap(thisIter, otherIter) - self._updateTextDictEntry() - - def textMoveToBottomButtonClicked(self, widget): - """Signal handler for the "clicked" signal for the - textMoveToBottomButton GtkButton widget. The user has clicked - the Move to bottom button. Move the selected rows in the text - attribute view to the bottom of the list and then update the - "enabledSpokenTextAttributes" and "enabledBrailledTextAttributes" - preference strings. - - Arguments: - - widget: the component that generated the signal. - """ - - textSelection = self.getTextAttributesView.get_selection() - [model, paths] = textSelection.get_selected_rows() - for path in paths: - thisIter = model.get_iter(path) - model.move_before(thisIter, None) - self._updateTextDictEntry() - - def helpButtonClicked(self, widget): - """Signal handler for the "clicked" signal for the helpButton - GtkButton widget. The user has clicked the Help button. - - Arguments: - - widget: the component that generated the signal. - """ - - self.script.learnModePresenter.show_help(page="preferences") - - def restoreSettings(self): - """Restore the settings we saved away when opening the preferences - dialog.""" - # Restore the default rate/pitch/gain, - # in case the user played with the sliders. - # - voices = cthulhu.cthulhuApp.settingsManager.getSetting('voices') - defaultVoice = voices.get(settings.DEFAULT_VOICE) - if defaultVoice is not None: - defaultVoice[acss.ACSS.GAIN] = self.savedGain - defaultVoice[acss.ACSS.AVERAGE_PITCH] = self.savedPitch - defaultVoice[acss.ACSS.RATE] = self.savedRate - - def saveBasicSettings(self): - if not self._isInitialSetup: - self.restoreSettings() - - if self.soundSinkCombo is not None: - activeIndex = self.soundSinkCombo.get_active() - if activeIndex >= 0: - self.prefsDict["soundSink"] = self._soundSinkChoices[activeIndex][0] - - enable = self.get_widget("speechSupportCheckButton").get_active() - self.prefsDict["enableSpeech"] = enable - - if self.speechSystemsChoice: - self.prefsDict["speechServerFactory"] = \ - self.speechSystemsChoice.__name__ - - speechServerChoice = self._getSpeechServerChoiceForSave() - if speechServerChoice: - self.prefsDict["speechServerInfo"] = \ - speechServerChoice.getInfo() - else: - activeSpeechInfo = speech.getInfo() - if activeSpeechInfo: - self.prefsDict["speechServerInfo"] = activeSpeechInfo - - runtimeSpeechServerInfo = cthulhu.cthulhuApp.settingsManager.getSetting( - "speechServerInfo" - ) - if runtimeSpeechServerInfo and not self.prefsDict.get("speechServerInfo"): - self.prefsDict["speechServerInfo"] = runtimeSpeechServerInfo - - existingSpeechServerInfo = self.prefsDict.get("speechServerInfo") - if existingSpeechServerInfo: - self.prefsDict["speechServerInfo"] = existingSpeechServerInfo - - if self.defaultVoice is not None: - self.prefsDict["voices"] = { - settings.DEFAULT_VOICE: acss.ACSS(self.defaultVoice), - settings.UPPERCASE_VOICE: acss.ACSS(self.uppercaseVoice), - settings.HYPERLINK_VOICE: acss.ACSS(self.hyperlinkVoice), - settings.SYSTEM_VOICE: acss.ACSS(self.systemVoice), - } - - self.prefsDict["useCustomEchoVoice"] = \ - self.get_widget("useCustomEchoVoiceCheckButton").get_active() - self.prefsDict["useCustomEchoSpeechServer"] = \ - self.get_widget("useCustomEchoSpeechServerCheckButton").get_active() - self.prefsDict["useCustomEchoForKey"] = \ - self.get_widget("useCustomEchoForKeyCheckButton").get_active() - self.prefsDict["useCustomEchoForCharacter"] = \ - self.get_widget("useCustomEchoForCharacterCheckButton").get_active() - self.prefsDict["useCustomEchoForWord"] = \ - self.get_widget("useCustomEchoForWordCheckButton").get_active() - self.prefsDict["useCustomEchoForSentence"] = \ - self.get_widget("useCustomEchoForSentenceCheckButton").get_active() - - if self.echoVoice is None: - self.echoVoice = acss.ACSS({}) - - # Persist slider values directly so saving does not depend on - # value-changed signal timing. - self.echoVoice[acss.ACSS.RATE] = self.get_widget("echoRateScale").get_value() - self.echoVoice[acss.ACSS.AVERAGE_PITCH] = self.get_widget("echoPitchScale").get_value() - self.echoVoice[acss.ACSS.GAIN] = self.get_widget("echoVolumeScale").get_value() - self.echoVoice['established'] = True - self.prefsDict["echoVoice"] = acss.ACSS(self.echoVoice) - - echoSpeechServerChoice = self._getEchoSpeechServerChoiceForSave() - if echoSpeechServerChoice: - self.prefsDict["echoSpeechServerInfo"] = echoSpeechServerChoice.getInfo() - else: - self.prefsDict["echoSpeechServerInfo"] = None - - def applyButtonClicked(self, widget): - """Signal handler for the "clicked" signal for the applyButton - GtkButton widget. The user has clicked the Apply button. - Write out the users preferences. If GNOME accessibility hadn't - previously been enabled, warn the user that they will need to - log out. Shut down any active speech servers that were started. - Reload the users preferences to get the new speech, braille and - key echo value to take effect. Do not dismiss the configuration - window. - - Arguments: - - widget: the component that generated the signal. - """ - - msg = "PREFERENCES DIALOG: Apply button clicked" - debug.printMessage(debug.LEVEL_ALL, msg, True) - - self.saveBasicSettings() - activeProfile = self.getComboBoxList(self.profilesCombo) - startingProfile = self.getComboBoxList(self.startingProfileCombo) - - self.prefsDict['profile'] = activeProfile - self.prefsDict['activeProfile'] = activeProfile - self.prefsDict['startingProfile'] = startingProfile - cthulhu.cthulhuApp.settingsManager.setStartingProfile(startingProfile) - - self._apply_plugin_changes() - self.writeUserPreferences() - cthulhu.loadUserSettings(self.script) - self._initSoundThemeState() - self._refresh_dynamic_plugin_tabs() - braille.checkBrailleSetting() - self._initSpeechState() - self._initEchoSpeechState() - self._populateKeyBindings() - self.__initProfileCombo() - - msg = "PREFERENCES DIALOG: Handling Apply button click complete" - debug.printMessage(debug.LEVEL_ALL, msg, True) - - def cancelButtonClicked(self, widget): - """Signal handler for the "clicked" signal for the cancelButton - GtkButton widget. The user has clicked the Cancel button. - Don't write out the preferences. Destroy the configuration window. - - Arguments: - - widget: the component that generated the signal. - """ - - msg = "PREFERENCES DIALOG: Cancel button clicked" - debug.printMessage(debug.LEVEL_ALL, msg, True) - - self.windowClosed(widget) - self.get_widget("cthulhuSetupWindow").destroy() - - msg = "PREFERENCES DIALOG: Handling Cancel button click complete" - debug.printMessage(debug.LEVEL_ALL, msg, True) - - def okButtonClicked(self, widget=None): - """Signal handler for the "clicked" signal for the okButton - GtkButton widget. The user has clicked the OK button. - Write out the users preferences. If GNOME accessibility hadn't - previously been enabled, warn the user that they will need to - log out. Shut down any active speech servers that were started. - Reload the users preferences to get the new speech, braille and - key echo value to take effect. Hide the configuration window. - - Arguments: - - widget: the component that generated the signal. - """ - - msg = "PREFERENCES DIALOG: OK button clicked" - debug.printMessage(debug.LEVEL_ALL, msg, True) - - self.applyButtonClicked(widget) - self._cleanupSpeechServers() - self.get_widget("cthulhuSetupWindow").destroy() - - msg = "PREFERENCES DIALOG: Handling OK button click complete" - debug.printMessage(debug.LEVEL_ALL, msg, True) - - def windowClosed(self, widget): - """Signal handler for the "closed" signal for the cthulhuSetupWindow - GtkWindow widget. This is effectively the same as pressing the - cancel button, except the window is destroyed for us. - - Arguments: - - widget: the component that generated the signal. - """ - - msg = "PREFERENCES DIALOG: Window is being closed" - debug.printMessage(debug.LEVEL_ALL, msg, True) - - self.suspendEvents() - - factory = cthulhu.cthulhuApp.settingsManager.getSetting('speechServerFactory') - if factory: - self._setSpeechSystemsChoice(factory) - - server = cthulhu.cthulhuApp.settingsManager.getSetting('speechServerInfo') - if server: - self._setSpeechServersChoice(server) - - self._cleanupSpeechServers() - self.restoreSettings() - - GObject.timeout_add(1000, self.resumeEvents) - - msg = "PREFERENCES DIALOG: Window closure complete" - debug.printMessage(debug.LEVEL_ALL, msg, True) - - def windowDestroyed(self, widget): - """Signal handler for the "destroyed" signal for the cthulhuSetupWindow - GtkWindow widget. Reset cthulhu_state.cthulhuOS to None, so that the - GUI can be rebuilt from the GtkBuilder file the next time the user - wants to display the configuration GUI. - - Arguments: - - widget: the component that generated the signal. - """ - - msg = "PREFERENCES DIALOG: Window is being destroyed" - debug.printMessage(debug.LEVEL_ALL, msg, True) - - self.keyBindView.set_model(None) - self.getTextAttributesView.set_model(None) - self.pronunciationView.set_model(None) - self.keyBindView.set_headers_visible(False) - self.getTextAttributesView.set_headers_visible(False) - self.pronunciationView.set_headers_visible(False) - self.keyBindView.hide() - self.getTextAttributesView.hide() - self.pronunciationView.hide() - cthulhu_state.cthulhuOS = None - - msg = "PREFERENCES DIALOG: Window destruction complete" - debug.printMessage(debug.LEVEL_ALL, msg, True) - - def resumeEvents(self): - msg = "PREFERENCES DIALOG: Re-registering floody events." - debug.printMessage(debug.LEVEL_ALL, msg, True) - - manager = event_manager.getManager() - manager.registerListener("object:state-changed:showing") - manager.registerListener("object:children-changed:remove") - manager.registerListener("object:selection-changed") - manager.registerListener("object:property-change:accessible-name") - - def suspendEvents(self): - msg = "PREFERENCES DIALOG: Deregistering floody events." - debug.printMessage(debug.LEVEL_ALL, msg, True) - - manager = event_manager.getManager() - manager.deregisterListener("object:state-changed:showing") - manager.deregisterListener("object:children-changed:remove") - manager.deregisterListener("object:selection-changed") - manager.deregisterListener("object:property-change:accessible-name") - - def showProfileGUI(self, widget): - """Show profile Dialog to add a new one""" - - cthulhu_gui_profile.showProfileUI(self) - - def saveProfile(self, profileToSaveLabel): - """Creates a new profile based on the name profileToSaveLabel and - updates the Preferences dialog combo boxes accordingly.""" - - if not profileToSaveLabel: - return - profileToSave = profileToSaveLabel.replace(' ', '_').lower() - profile = [profileToSaveLabel, profileToSave] - - def saveActiveProfile(newProfile = True): - if newProfile: - activeProfileIter = self.profilesComboModel.append(profile) - self.profilesCombo.set_active_iter(activeProfileIter) - - self.prefsDict['profile'] = profile - self.prefsDict['activeProfile'] = profile - self.saveBasicSettings() - self.writeUserPreferences() - - availableProfiles = [p[1] for p in self.__getAvailableProfiles()] - if isinstance(profileToSave, str) \ - and profileToSave != '' \ - and profileToSave not in availableProfiles \ - and profileToSave != self._defaultProfile[1]: - saveActiveProfile() - else: - if profileToSave is not None: - message = guilabels.PROFILE_CONFLICT_MESSAGE % \ - f"{GLib.markup_escape_text(profileToSaveLabel)}" - dialog = Gtk.MessageDialog(None, - Gtk.DialogFlags.MODAL, - type=Gtk.MessageType.INFO, - buttons=Gtk.ButtonsType.YES_NO) - dialog.set_markup(f"{guilabels.PROFILE_CONFLICT_LABEL}") - dialog.format_secondary_markup(message) - dialog.set_title(guilabels.PROFILE_CONFLICT_TITLE) - response = dialog.run() - if response == Gtk.ResponseType.YES: - dialog.destroy() - saveActiveProfile(False) - else: - dialog.destroy() - - - def removeProfileButtonClicked(self, widget): - """Remove profile button clicked handler - - If we removed the last profile, a default one will automatically get - added back by the settings manager. - """ - - oldProfile = self.getComboBoxList(self.profilesCombo) - - message = guilabels.PROFILE_REMOVE_MESSAGE % \ - f"{GLib.markup_escape_text(oldProfile[0])}" - dialog = Gtk.MessageDialog(self.window, Gtk.DialogFlags.MODAL, - type=Gtk.MessageType.INFO, - buttons=Gtk.ButtonsType.YES_NO) - dialog.set_markup(f"{guilabels.PROFILE_REMOVE_LABEL}") - dialog.format_secondary_markup(message) - if dialog.run() == Gtk.ResponseType.YES: - # If we remove the currently used starting profile, fallback on - # the first listed profile, or the default one if there's - # nothing better - newStartingProfile = self.prefsDict.get('startingProfile') - if not newStartingProfile or newStartingProfile == oldProfile: - newStartingProfile = self._defaultProfile - for row in self.profilesComboModel: - rowProfile = row[:] - if rowProfile != oldProfile: - newStartingProfile = rowProfile - break - # Update the current profile to the active profile unless we're - # removing that one, in which case we use the new starting - # profile - newProfile = self.prefsDict.get('activeProfile') - if not newProfile or newProfile == oldProfile: - newProfile = newStartingProfile - - cthulhu.cthulhuApp.settingsManager.removeProfile(oldProfile[1]) - self.loadProfile(newProfile) - - # Make sure nothing is referencing the removed profile anymore - startingProfile = self.prefsDict.get('startingProfile') - if not startingProfile or startingProfile == oldProfile: - self.prefsDict['startingProfile'] = newStartingProfile - cthulhu.cthulhuApp.settingsManager.setStartingProfile(newStartingProfile) - self.writeUserPreferences() - - dialog.destroy() - - def loadProfileButtonClicked(self, widget): - """Load profile button clicked handler""" - - if self._isInitialSetup: - return - - dialog = Gtk.MessageDialog(None, - Gtk.DialogFlags.MODAL, - type=Gtk.MessageType.INFO, - buttons=Gtk.ButtonsType.YES_NO) - - dialog.set_markup(f"{guilabels.PROFILE_LOAD_LABEL}") - dialog.format_secondary_markup(guilabels.PROFILE_LOAD_MESSAGE) - response = dialog.run() - if response == Gtk.ResponseType.YES: - dialog.destroy() - self.loadSelectedProfile() - else: - dialog.destroy() - - def loadSelectedProfile(self): - """Load selected profile""" - - activeProfile = self.getComboBoxList(self.profilesCombo) - self.loadProfile(activeProfile) - - def loadProfile(self, profile): - """Load profile""" - - self.saveBasicSettings() - - self.prefsDict['activeProfile'] = profile - cthulhu.cthulhuApp.settingsManager.setProfile(profile[1]) - self.prefsDict = cthulhu.cthulhuApp.settingsManager.getGeneralSettings(profile[1]) - - cthulhu.loadUserSettings(skipReloadMessage=True) - - self._initGUIState() - self._initSoundThemeState() - - braille.checkBrailleSetting() - - self._initSpeechState() - self._initEchoSpeechState() - - self._populateKeyBindings() - - self.__initProfileCombo() - - # Indentation signal handlers - - def enableIndentationToggled(self, widget): - """Enable indentation checkbox toggled handler.""" - enabled = widget.get_active() - self.prefsDict["enableIndentation"] = enabled - self._updateIndentationControlsState(enabled) - - def indentationModeToggled(self, widget): - """Indentation mode radio toggled handler.""" - if not widget.get_active(): - return - - mapping = { - "indentationSpeechButton": settings.INDENTATION_PRESENTATION_SPEECH, - "indentationBeepsButton": settings.INDENTATION_PRESENTATION_BEEPS, - "indentationSpeechAndBeepsButton": settings.INDENTATION_PRESENTATION_SPEECH_AND_BEEPS, - } - widgetName = Gtk.Buildable.get_name(widget) - mode = mapping.get(widgetName) - if mode is not None: - self.prefsDict["indentationPresentationMode"] = mode - - # AI Assistant signal handlers - - def enableAIToggled(self, widget): - """Enable AI Assistant checkbox toggled handler""" - enabled = widget.get_active() - self.prefsDict["aiAssistantEnabled"] = enabled - self._updateAIControlsState(enabled) - - def aiProviderChanged(self, widget): - """AI Provider combo box changed handler""" - providers = [ - settings.AI_PROVIDER_CLAUDE_CODE, - settings.AI_PROVIDER_CODEX, - settings.AI_PROVIDER_GEMINI, - settings.AI_PROVIDER_OLLAMA - ] - activeIndex = widget.get_active() - if 0 <= activeIndex < len(providers): - provider = providers[activeIndex] - self.prefsDict["aiProvider"] = provider - self._updateProviderControls(provider) - - def aiApiKeyChanged(self, widget): - """AI API key file entry changed handler""" - self.prefsDict["aiApiKeyFile"] = widget.get_text() - - def aiOllamaModelChanged(self, widget): - """AI Ollama model entry changed handler""" - self.prefsDict["aiOllamaModel"] = widget.get_text() - - def aiOllamaEndpointChanged(self, widget): - """AI Ollama endpoint entry changed handler""" - self.prefsDict["aiOllamaEndpoint"] = widget.get_text() - - def aiGetClaudeKeyClicked(self, widget): - """Get Claude API Key button clicked handler""" - import subprocess - import os - - try: - # Open browser to Claude API key page - subprocess.run(["xdg-open", "https://console.anthropic.com/"], check=True) - - # Show dialog with instructions - dialog = Gtk.MessageDialog( - parent=self, - flags=Gtk.DialogFlags.MODAL, - type=Gtk.MessageType.INFO, - buttons=Gtk.ButtonsType.OK, - message_format="Claude API Key Setup" - ) - dialog.format_secondary_text( - "Browser opened to get your Claude API key.\n\n" - "💡 TIP: Claude offers $5 free credit, then ~$20/month for Pro.\n" - "Ollama is also available as a free alternative.\n\n" - "Steps:\n" - "1. Sign up or log in to your Anthropic account\n" - "2. Go to 'API Keys' in Account Settings\n" - "3. Click 'Create Key' and copy the API key\n" - "4. Click OK below when you have your key ready\n" - "5. Paste the API key when prompted" - ) - dialog.run() - dialog.destroy() - - # Show API key input dialog - key_dialog = Gtk.MessageDialog( - parent=self, - flags=Gtk.DialogFlags.MODAL, - type=Gtk.MessageType.QUESTION, - buttons=Gtk.ButtonsType.OK_CANCEL, - message_format="Enter Claude API Key" - ) - key_dialog.format_secondary_text("Paste your Claude API key (starts with 'sk-ant-'):") - - # Add text entry to dialog - entry = Gtk.Entry() - entry.set_placeholder_text("sk-ant-your-api-key-here...") - entry.set_visibility(False) # Hide key for security - entry.set_width_chars(50) - key_dialog.get_content_area().pack_start(entry, False, False, 0) - entry.show() - - response = key_dialog.run() - api_key = entry.get_text().strip() - key_dialog.destroy() - - if response == Gtk.ResponseType.OK and api_key: - # Save API key to file - config_dir = os.path.expanduser("~/.local/share/cthulhu") - os.makedirs(config_dir, exist_ok=True) - api_key_file = os.path.join(config_dir, "claude-api-key") - - with open(api_key_file, 'w') as f: - f.write(api_key) - os.chmod(api_key_file, 0o600) # Secure file permissions - - # Update GUI - self.get_widget("aiApiKeyEntry").set_text(api_key_file) - self.prefsDict["aiApiKeyFile"] = api_key_file - - # Success message - success_dialog = Gtk.MessageDialog( - parent=self, - flags=Gtk.DialogFlags.MODAL, - type=Gtk.MessageType.INFO, - buttons=Gtk.ButtonsType.OK, - message_format="API Key Saved Successfully" + def on_setting_changed(*_args): + CthulhuSetupGUI._apply_appearance( + interface_settings, + a11y_settings, + gtk_settings, + screen, + providers, ) - success_dialog.format_secondary_text(f"Claude API key saved to:\n{api_key_file}") - success_dialog.run() - success_dialog.destroy() - - except Exception as e: - # Error dialog - error_dialog = Gtk.MessageDialog( - parent=self, - flags=Gtk.DialogFlags.MODAL, - type=Gtk.MessageType.ERROR, - buttons=Gtk.ButtonsType.OK, - message_format="Error Setting Up API Key" + + interface_settings.connect("changed::color-scheme", on_setting_changed) + a11y_settings.connect("changed::high-contrast", on_setting_changed) + if providers.shapes is not None: + a11y_settings.connect("changed::show-status-shapes", on_setting_changed) + gtk_settings.connect("notify::gtk-theme-name", on_setting_changed) + return interface_settings, a11y_settings, base_provider, providers + except GLib.Error as error: + msg = f"PREFERENCES WINDOW: Exception syncing appearance: {error}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return None + + @staticmethod + def _apply_appearance( + interface_settings: Gio.Settings, + a11y_settings: Gio.Settings, + gtk_settings: Gtk.Settings, + screen: Gdk.Screen, + providers: _AppearanceProviders, + ) -> None: + """Applies color-scheme, high-contrast, and status-shapes settings.""" + + prefer_dark = interface_settings.get_string("color-scheme") == "prefer-dark" + gtk_settings.set_property("gtk-application-prefer-dark-theme", prefer_dark) + + theme = gtk_settings.get_property("gtk-theme-name") + if prefer_dark and theme == "HighContrast": + gtk_settings.set_property("gtk-theme-name", "HighContrastInverse") + elif not prefer_dark and theme == "HighContrastInverse": + gtk_settings.set_property("gtk-theme-name", "HighContrast") + + priority = Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + 1 + conditional_providers: list[tuple[Gtk.CssProvider, bool]] = [ + (providers.hc, a11y_settings.get_boolean("high-contrast")), + (providers.dark, prefer_dark), + ] + if providers.shapes is not None: + conditional_providers.append( + (providers.shapes, a11y_settings.get_boolean("show-status-shapes")), ) - error_dialog.format_secondary_text(f"Failed to open browser or save API key:\n{str(e)}") - error_dialog.run() - error_dialog.destroy() - - def aiApiKeyBrowseClicked(self, widget): - """AI API key browse button clicked handler""" - dialog = Gtk.FileChooserDialog( - title="Select API Key File", - parent=self, - action=Gtk.FileChooserAction.OPEN - ) - dialog.add_buttons( - Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, - Gtk.STOCK_OPEN, Gtk.ResponseType.OK - ) - - response = dialog.run() - if response == Gtk.ResponseType.OK: - filename = dialog.get_filename() - self.aiApiKeyEntry.set_text(filename) - self.prefsDict["aiApiKeyFile"] = filename - - dialog.destroy() - - def aiConfirmationToggled(self, widget): - """AI confirmation required checkbox toggled handler""" - self.prefsDict["aiConfirmationRequired"] = widget.get_active() - - def aiScreenshotQualityChanged(self, widget): - """AI screenshot quality combo box changed handler""" - qualities = [settings.AI_SCREENSHOT_QUALITY_LOW, - settings.AI_SCREENSHOT_QUALITY_MEDIUM, - settings.AI_SCREENSHOT_QUALITY_HIGH] - activeIndex = widget.get_active() - if 0 <= activeIndex < len(qualities): - self.prefsDict["aiScreenshotQuality"] = qualities[activeIndex] - - # OCR Plugin Settings Handlers - def ocrLanguageChanged(self, widget): - """OCR language code entry changed handler""" - self.prefsDict["ocrLanguageCode"] = widget.get_text() - - def ocrScaleChanged(self, widget): - """OCR scale factor spin button changed handler""" - self.prefsDict["ocrScaleFactor"] = int(widget.get_value()) - - def ocrGrayscaleToggled(self, widget): - """OCR grayscale image checkbox toggled handler""" - self.prefsDict["ocrGrayscaleImg"] = widget.get_active() - - def ocrInvertToggled(self, widget): - """OCR invert image checkbox toggled handler""" - self.prefsDict["ocrInvertImg"] = widget.get_active() - - def ocrBlackWhiteToggled(self, widget): - """OCR black and white image checkbox toggled handler""" - self.prefsDict["ocrBlackWhiteImg"] = widget.get_active() - - def ocrBlackWhiteValueChanged(self, widget): - """OCR black/white threshold spin button changed handler""" - self.prefsDict["ocrBlackWhiteImgValue"] = int(widget.get_value()) - - def ocrColorCalculationToggled(self, widget): - """OCR color calculation checkbox toggled handler""" - self.prefsDict["ocrColorCalculation"] = widget.get_active() - - def ocrCopyToClipboardToggled(self, widget): - """OCR copy to clipboard checkbox toggled handler""" - self.prefsDict["ocrCopyToClipboard"] = widget.get_active() - + for provider, enabled in conditional_providers: + if enabled: + Gtk.StyleContext.add_provider_for_screen(screen, provider, priority) + else: + Gtk.StyleContext.remove_provider_for_screen(screen, provider) + + def window_destroyed(self, _widget: Gtk.Widget) -> None: + """Handle window destroyed signal by clearing window reference.""" + + msg = "PREFERENCES WINDOW: Window is being destroyed" + debug.print_message(debug.LEVEL_ALL, msg, True) + + CthulhuSetupGUI.WINDOW = None + + msg = "PREFERENCES WINDOW: Window destruction complete" + debug.print_message(debug.LEVEL_ALL, msg, True) + + focus_manager.get_manager().set_in_preferences_window(False) + + def _has_unsaved_changes(self, include_profiles: bool = False) -> bool: + """Returns True if any preference grid has unsaved changes.""" + + for name, grid in self._page_to_grid.items(): + if grid is self.profiles_grid and not include_profiles: + continue + if grid.has_changes(): + msg = f"PREFERENCES: Grid '{name}' has unsaved changes" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + return False + + def resume_events(self, reason: str = "") -> bool: + """Re-register event listeners suspended during UI creation and teardown.""" + + msg = f"PREFERENCES: Re-registering events. {reason}" + debug.print_message(debug.LEVEL_ALL, msg, True) + + manager = event_manager.get_manager() + for event in self._EVENTS_TO_SUSPEND: + manager.register_listener(event) + return False + + def suspend_events(self, reason: str = "") -> None: + """Deregister event listeners that flood Cthulhu during UI creation and teardown.""" + + msg = f"PREFERENCES: Suspending events. {reason}" + debug.print_message(debug.LEVEL_ALL, msg, True) + + manager = event_manager.get_manager() + for event in self._EVENTS_TO_SUSPEND: + manager.deregister_listener(event) + + def _get_current_profile_label(self) -> str: + """Get the display label for the current profile, including pending renames.""" + + return self.profiles_grid.get_current_profile_label() + + def _get_panel_title(self, page_title: str) -> str: + """Build the panel header title with profile in parentheses.""" + + profile_label = self._get_current_profile_label() + return f"{page_title} ({profile_label})" + + def _set_page_title(self, title: str) -> None: + """Set the current page title, updating panel header and accessible name.""" + + self._current_page_title = title + self.panel_headerbar.set_title(self._get_panel_title(title)) + self.get_accessible().set_name(self._get_accessible_name(title)) + + def _get_accessible_name(self, page_title: str = "") -> str: + """Build the accessible name for the window.""" + + if self._app_name: + base_title = guilabels.PREFERENCES_APPLICATION_TITLE % self._app_name + else: + base_title = guilabels.DIALOG_SCREEN_READER_PREFERENCES_ACCESSIBLE + + profile_label = self._get_current_profile_label() + + if page_title: + return f"{base_title}, {page_title}, {profile_label}" + return f"{base_title}, {profile_label}" + + def update_menu_labels(self) -> None: + """Update Apply and Save menu items and panel title to show the current profile.""" + + profile_label = self._get_current_profile_label() + self.apply_item.set_property("text", guilabels.MENU_APPLY_PROFILE % profile_label) + self.save_item.set_property("text", guilabels.MENU_SAVE_PROFILE % profile_label) + + if self._app_name: + self.left_headerbar.set_subtitle(self._app_name) + else: + self.left_headerbar.set_subtitle(None) + + if self._current_page_title: + self._set_page_title(self._current_page_title) + + def _on_profile_loaded(self, profile: list[str]) -> None: + """Handle profile loaded callback and reload all preference grids.""" + + if not self.get_realized(): + return + + self._profile_name = profile[1] + self._init_gui_state(include_profiles=False) + self.update_menu_labels() diff --git a/src/cthulhu/document_presenter.py b/src/cthulhu/document_presenter.py new file mode 100644 index 0000000..99980ef --- /dev/null +++ b/src/cthulhu/document_presenter.py @@ -0,0 +1,1452 @@ +# Cthulhu +# +# Copyright 2026 Igalia, S.L. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +# pylint: disable=too-many-lines +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-instance-attributes +# pylint: disable=too-many-arguments, too-many-positional-arguments + +"""Module for document-related presentation and navigation settings.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import TYPE_CHECKING, Any + +import gi + +gi.require_version("Atspi", "2.0") +gi.require_version("Gtk", "3.0") +from gi.repository import Atspi, Gtk + +from . import ( + caret_navigator, + cmdnames, + command_manager, + dbus_service, + debug, + focus_manager, + gsettings_registry, + guilabels, + input_event, + input_event_manager, + keybindings, + messages, + preferences_grid_base, + presentation_manager, + script_manager, + structural_navigator, + table_navigator, +) +from .ax_document import AXDocument +from .ax_object import AXObject +from .ax_text import AXText +from .ax_utilities import AXUtilities + +if TYPE_CHECKING: + from collections.abc import Callable + + from .scripts import default + + +@gsettings_registry.get_registry().gsettings_enum( + "org.stormux.Cthulhu.FindResultsVerbosity", + values={"none": 0, "if-line-changed": 1, "all": 2}, +) +class FindResultsVerbosity(Enum): + """Find results verbosity level enumeration.""" + + NONE = 0 + IF_LINE_CHANGED = 1 + ALL = 2 + + @property + def string_name(self) -> str: + """Returns the lowercase string name for this enum value.""" + + return self.name.lower().replace("_", "-") + + +@dataclass +class _AppModeState: + """Tracks focus/browse mode state for a specific application.""" + + in_focus_mode: bool = True + focus_mode_is_sticky: bool = False + browse_mode_is_sticky: bool = False + user_has_toggled: bool = False + + +class CaretNavigationPreferencesGrid(preferences_grid_base.AutoPreferencesGrid): + """Sub-grid for caret navigation settings within the Documents page.""" + + _gsettings_schema = "caret-navigation" + + def __init__(self) -> None: + nav = caret_navigator.get_navigator() + + # Child controls need to check the enabled switch's UI state (not runtime state) + # because the enabled switch has apply_immediately=False. + self._enabled_switch: Gtk.Switch | None = None + + def is_enabled() -> bool: + if self._enabled_switch is not None: + return self._enabled_switch.get_active() + return nav.get_is_enabled() + + controls = [ + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.KB_GROUP_CARET_NAVIGATION, + getter=nav.get_is_enabled, + setter=nav.set_is_enabled, + prefs_key=caret_navigator.CaretNavigator.KEY_ENABLED, + apply_immediately=False, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.AUTOMATIC_FOCUS_MODE, + getter=nav.get_triggers_focus_mode, + setter=nav.set_triggers_focus_mode, + prefs_key=caret_navigator.CaretNavigator.KEY_TRIGGERS_FOCUS_MODE, + determine_sensitivity=is_enabled, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.CONTENT_LAYOUT_MODE, + getter=nav.get_layout_mode, + setter=nav.set_layout_mode, + prefs_key=caret_navigator.CaretNavigator.KEY_LAYOUT_MODE, + determine_sensitivity=is_enabled, + ), + ] + info = ( + f"{guilabels.CARET_NAVIGATION_INFO}\n\n{guilabels.AUTOMATIC_FOCUS_MODE_INFO}" + f"\n\n{guilabels.LAYOUT_MODE_INFO}" + ) + super().__init__(guilabels.KB_GROUP_CARET_NAVIGATION, controls, info_message=info) + + self._enabled_switch = self._widgets[0] + + +class StructuralNavigationPreferencesGrid(preferences_grid_base.AutoPreferencesGrid): + """Sub-grid for structural navigation settings within the Documents page.""" + + _gsettings_schema = "structural-navigation" + + def __init__(self) -> None: + nav = structural_navigator.get_navigator() + + # Child controls need to check the enabled switch's UI state (not runtime state) + # because the enabled switch has apply_immediately=False. + self._enabled_switch: Gtk.Switch | None = None + + def is_enabled() -> bool: + if self._enabled_switch is not None: + return self._enabled_switch.get_active() + return nav.get_is_enabled() + + controls: list[preferences_grid_base.ControlType] = [ + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.KB_GROUP_STRUCTURAL_NAVIGATION, + getter=nav.get_is_enabled, + setter=nav.set_is_enabled, + prefs_key=structural_navigator.StructuralNavigator.KEY_ENABLED, + apply_immediately=False, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.AUTOMATIC_FOCUS_MODE, + getter=nav.get_triggers_focus_mode, + setter=nav.set_triggers_focus_mode, + prefs_key=structural_navigator.StructuralNavigator.KEY_TRIGGERS_FOCUS_MODE, + determine_sensitivity=is_enabled, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.STRUCTURAL_NAVIGATION_WRAP_AROUND, + getter=nav.get_navigation_wraps, + setter=nav.set_navigation_wraps, + prefs_key=structural_navigator.StructuralNavigator.KEY_WRAPS, + determine_sensitivity=is_enabled, + ), + preferences_grid_base.IntRangePreferenceControl( + label=guilabels.STRUCTURAL_NAVIGATION_LARGE_OBJECT_LENGTH, + minimum=1, + maximum=500, + getter=nav.get_large_object_text_length, + setter=nav.set_large_object_text_length, + prefs_key=structural_navigator.StructuralNavigator.KEY_LARGE_OBJECT_TEXT_LENGTH, + determine_sensitivity=is_enabled, + ), + ] + info = ( + f"{guilabels.STRUCTURAL_NAVIGATION_INFO}\n\n{guilabels.AUTOMATIC_FOCUS_MODE_INFO}" + f"\n\n{guilabels.LARGE_OBJECT_INFO}" + ) + super().__init__(guilabels.KB_GROUP_STRUCTURAL_NAVIGATION, controls, info_message=info) + + self._enabled_switch = self._widgets[0] + + +class TableNavigationPreferencesGrid(preferences_grid_base.AutoPreferencesGrid): + """Sub-grid for table navigation settings within the Documents page.""" + + _gsettings_schema = "table-navigation" + + def __init__(self) -> None: + nav = table_navigator.get_navigator() + + # Child controls need to check the enabled switch's UI state (not runtime state) + # because the enabled switch has apply_immediately=False. + self._enabled_switch: Gtk.Switch | None = None + + def is_enabled() -> bool: + if self._enabled_switch is not None: + return self._enabled_switch.get_active() + return nav.get_is_enabled() + + controls = [ + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.KB_GROUP_TABLE_NAVIGATION, + getter=nav.get_is_enabled, + setter=nav.set_is_enabled, + prefs_key=table_navigator.TableNavigator.KEY_ENABLED, + apply_immediately=False, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.TABLE_SKIP_BLANK_CELLS, + getter=nav.get_skip_blank_cells, + setter=nav.set_skip_blank_cells, + prefs_key=table_navigator.TableNavigator.KEY_SKIP_BLANK_CELLS, + determine_sensitivity=is_enabled, + ), + ] + super().__init__(guilabels.KB_GROUP_TABLE_NAVIGATION, controls) + + self._enabled_switch = self._widgets[0] + + +class NativeNavigationPreferencesGrid(preferences_grid_base.AutoPreferencesGrid): + """Sub-grid for native navigation settings within the Documents page.""" + + _gsettings_schema = "document" + + def __init__(self, presenter: DocumentPresenter) -> None: + self._presenter = presenter + controls: list[preferences_grid_base.ControlType] = [ + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.AUTOMATIC_FOCUS_MODE, + getter=presenter.get_native_nav_triggers_focus_mode, + setter=presenter.set_native_nav_triggers_focus_mode, + prefs_key=DocumentPresenter.KEY_NATIVE_NAV_TRIGGERS_FOCUS_MODE, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.AUTO_STICKY_FOCUS_MODE, + getter=presenter.get_auto_sticky_focus_mode_for_web_apps, + setter=presenter.set_auto_sticky_focus_mode_for_web_apps, + prefs_key=DocumentPresenter.KEY_AUTO_STICKY_FOCUS_MODE, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.FIND_SPEAK_RESULTS, + getter=presenter.get_speak_find_results, + setter=presenter.set_speak_find_results, + member_of=guilabels.FIND_OPTIONS, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.FIND_ONLY_SPEAK_CHANGED_LINES, + getter=presenter.get_only_speak_changed_lines, + setter=presenter.set_only_speak_changed_lines, + determine_sensitivity=presenter.get_speak_find_results, + member_of=guilabels.FIND_OPTIONS, + ), + preferences_grid_base.IntRangePreferenceControl( + label=guilabels.FIND_MINIMUM_MATCH_LENGTH, + minimum=0, + maximum=20, + getter=presenter.get_find_results_minimum_length, + setter=presenter.set_find_results_minimum_length, + prefs_key=DocumentPresenter.KEY_FIND_RESULTS_MINIMUM_LENGTH, + member_of=guilabels.FIND_OPTIONS, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.READ_PAGE_UPON_LOAD, + getter=presenter.get_say_all_on_load, + setter=presenter.set_say_all_on_load, + prefs_key=DocumentPresenter.KEY_SAY_ALL_ON_LOAD, + member_of=guilabels.PAGE_LOAD, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.PAGE_SUMMARY_UPON_LOAD, + getter=presenter.get_page_summary_on_load, + setter=presenter.set_page_summary_on_load, + prefs_key=DocumentPresenter.KEY_PAGE_SUMMARY_ON_LOAD, + member_of=guilabels.PAGE_LOAD, + ), + ] + info = ( + f"{guilabels.NATIVE_NAVIGATION_INFO}\n\n" + f"{guilabels.AUTOMATIC_FOCUS_MODE_INFO}\n\n" + f"{guilabels.AUTO_STICKY_FOCUS_MODE_INFO}" + ) + super().__init__(guilabels.NATIVE_NAVIGATION, controls, info_message=info) + + def save_settings(self, profile: str = "", app_name: str = "") -> dict[str, Any]: + """Save settings, writing the find-results enum from the presenter.""" + + result = super().save_settings(profile, app_name) + verbosity = self._presenter.get_find_results_verbosity_name() + result[DocumentPresenter.KEY_FIND_RESULTS_VERBOSITY] = verbosity + self._write_gsettings( + {DocumentPresenter.KEY_FIND_RESULTS_VERBOSITY: verbosity}, profile, app_name + ) + return result + + +class DocumentPreferencesGrid(preferences_grid_base.PreferencesGridBase): + """Main document preferences grid with categorized navigation settings.""" + + def __init__( + self, + presenter: DocumentPresenter, + title_change_callback: Callable[[str], None] | None = None, + ) -> None: + super().__init__(guilabels.DOCUMENTS) + self._presenter = presenter + self._initializing = True + self._title_change_callback = title_change_callback + + self._caret_grid = CaretNavigationPreferencesGrid() + self._structural_grid = StructuralNavigationPreferencesGrid() + self._table_grid = TableNavigationPreferencesGrid() + self._native_grid = NativeNavigationPreferencesGrid(presenter) + + self._build() + self._initializing = False + + def _build(self) -> None: + categories = [ + (guilabels.KB_GROUP_CARET_NAVIGATION, "caret", self._caret_grid), + (guilabels.KB_GROUP_STRUCTURAL_NAVIGATION, "structural", self._structural_grid), + (guilabels.KB_GROUP_TABLE_NAVIGATION, "table", self._table_grid), + (guilabels.NATIVE_NAVIGATION, "native", self._native_grid), + ] + + _enable_listbox, stack, _categories_listbox = self._create_multi_page_stack( + enable_label=None, + enable_getter=None, + enable_setter=None, + categories=categories, + title_change_callback=self._title_change_callback, + main_title=guilabels.DOCUMENTS, + ) + + self.attach(stack, 0, 0, 1, 1) + + def on_becoming_visible(self) -> None: + """Reset to the categories view when this grid becomes visible.""" + + self.multipage_on_becoming_visible() + + def reload(self) -> None: + """Reload all child grids.""" + + self._initializing = True + self._has_unsaved_changes = False + self._caret_grid.reload() + self._structural_grid.reload() + self._table_grid.reload() + self._native_grid.reload() + self._initializing = False + + def save_settings(self, profile: str = "", app_name: str = "") -> dict: + """Save all settings from child grids.""" + + result = {} + result.update(self._caret_grid.save_settings(profile, app_name)) + result.update(self._structural_grid.save_settings(profile, app_name)) + result.update(self._table_grid.save_settings(profile, app_name)) + result.update(self._native_grid.save_settings(profile, app_name)) + + return result + + def has_changes(self) -> bool: + """Check if any child grid has changes.""" + + return ( + self._has_unsaved_changes + or self._caret_grid.has_changes() + or self._structural_grid.has_changes() + or self._table_grid.has_changes() + or self._native_grid.has_changes() + ) + + def refresh(self) -> None: + """Refresh all child grids.""" + + self._initializing = True + self._caret_grid.refresh() + self._structural_grid.refresh() + self._table_grid.refresh() + self._native_grid.refresh() + self._initializing = False + + +@gsettings_registry.get_registry().gsettings_schema("org.stormux.Cthulhu.Document", name="document") +class DocumentPresenter: + """Manages document-related presentation and navigation settings.""" + + _SCHEMA = "document" + KEY_NATIVE_NAV_TRIGGERS_FOCUS_MODE = "native-nav-triggers-focus-mode" + KEY_AUTO_STICKY_FOCUS_MODE = "auto-sticky-focus-mode" + KEY_SAY_ALL_ON_LOAD = "say-all-on-load" + KEY_PAGE_SUMMARY_ON_LOAD = "page-summary-on-load" + KEY_FIND_RESULTS_VERBOSITY = "find-results-verbosity" + KEY_FIND_RESULTS_MINIMUM_LENGTH = "find-results-minimum-length" + + def _get_setting(self, key: str, gtype: str, default: Any) -> Any: + """Returns the dconf value for key, or default if not in dconf.""" + + return gsettings_registry.get_registry().layered_lookup( + self._SCHEMA, + key, + gtype, + default=default, + ) + + def __init__(self) -> None: + self._made_find_announcement = False + self._app_states: dict[int, _AppModeState] = {} + self._initialized: bool = False + + msg = "DOCUMENT PRESENTER: Registering D-Bus commands." + debug.print_message(debug.LEVEL_INFO, msg, True) + controller = dbus_service.get_remote_controller() + controller.register_decorated_module("DocumentPresenter", 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_DOCUMENTS + + # Keybindings (same for desktop and laptop) + kb_a = keybindings.KeyBinding("a", keybindings.CTHULHU_MODIFIER_MASK) + kb_a_2 = keybindings.KeyBinding("a", keybindings.CTHULHU_MODIFIER_MASK, click_count=2) + kb_a_3 = keybindings.KeyBinding("a", keybindings.CTHULHU_MODIFIER_MASK, click_count=3) + + # (name, function, description, keybinding) + commands_data = [ + ( + "toggle_presentation_mode", + self.toggle_presentation_mode, + cmdnames.TOGGLE_PRESENTATION_MODE, + kb_a, + ), + ( + "enable_sticky_focus_mode", + self.enable_sticky_focus_mode, + cmdnames.SET_FOCUS_MODE_STICKY, + kb_a_2, + ), + ( + "enable_sticky_browse_mode", + self.enable_sticky_browse_mode, + cmdnames.SET_BROWSE_MODE_STICKY, + kb_a_3, + ), + ] + + for name, function, description, kb in commands_data: + manager.add_command( + command_manager.KeyboardCommand( + name, + function, + group_label, + description, + desktop_keybinding=kb, + laptop_keybinding=kb, + ), + ) + + msg = "DOCUMENT PRESENTER: Commands set up." + debug.print_message(debug.LEVEL_INFO, msg, True) + + def _get_state_for_app(self, app: Atspi.Accessible | None) -> _AppModeState: + """Returns the mode state for the given app, creating if needed.""" + + if app is None: + return _AppModeState() + + app_hash = hash(app) + if app_hash not in self._app_states: + self._app_states[app_hash] = _AppModeState() + return self._app_states[app_hash] + + def _get_current_app(self) -> Atspi.Accessible | None: + """Returns the current application from the active script.""" + + script = script_manager.get_manager().get_active_script() + if script is None: + return None + return script.app + + def _is_likely_electron_app(self, app: Atspi.Accessible | None) -> bool: + """Returns True if app is likely an Electron app (Chromium-based, not a browser).""" + + if app is None: + return False + + toolkit = AXObject.get_toolkit_name(app).lower() + if toolkit != "chromium": + return False + + app_name = AXObject.get_name(app).lower() + known_browsers = ("brave", "chromium", "edge", "chrome", "opera", "vivaldi") + result = not any(browser in app_name for browser in known_browsers) + tokens = ["DOCUMENT PRESENTER:", app, "is likely Electron app:", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + def _is_top_level_web_app(self, script: default.Script, obj: Atspi.Accessible | None) -> bool: + """Returns True if obj is in a top-level web application (e.g., Google Docs).""" + + if obj is None: + return False + + document = script.utilities.active_document() + if document is None: + return False + + if not AXUtilities.is_embedded(document): + return False + + uri = AXDocument.get_uri(document) + result = bool(uri and uri.startswith("http")) + tokens = ["DOCUMENT PRESENTER:", document, f"is top-level web app: {result}. URI: {uri}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + def in_focus_mode(self, app: Atspi.Accessible | None = None) -> bool: + """Returns True if focus mode is active for the given app.""" + + if app is None: + app = self._get_current_app() + if app is None: + return False + app_hash = hash(app) + if app_hash not in self._app_states: + return False + return self._app_states[app_hash].in_focus_mode + + def focus_mode_is_sticky(self, app: Atspi.Accessible | None = None) -> bool: + """Returns True if focus mode is sticky for the given app.""" + + if app is None: + app = self._get_current_app() + if app is None: + return False + app_hash = hash(app) + if app_hash not in self._app_states: + return False + return self._app_states[app_hash].focus_mode_is_sticky + + def browse_mode_is_sticky(self, app: Atspi.Accessible | None = None) -> bool: + """Returns True if browse mode is sticky for the given app.""" + + if app is None: + app = self._get_current_app() + if app is None: + return False + app_hash = hash(app) + if app_hash not in self._app_states: + return False + return self._app_states[app_hash].browse_mode_is_sticky + + def _set_presentation_mode( + self, + script: default.Script, + use_focus_mode: bool, + obj: Atspi.Accessible | None = None, + document: Atspi.Accessible | None = None, + notify_user: bool = True, + ) -> bool: + """Sets the presentation mode to focus or browse mode.""" + + tokens = [ + f"DOCUMENT PRESENTER: set_presentation_mode. Use focus mode: {use_focus_mode},", + obj, + "in", + document, + f"notify user: {notify_user}", + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if obj is not None and AXObject.is_dead(obj): + obj = None + + if not script.utilities.in_document_content(obj): + if notify_user: + presentation_manager.get_manager().present_message(messages.DOCUMENT_NOT_IN_A) + return False + + has_state = self.has_state_for_app(script.app) + in_focus_mode = self.in_focus_mode(script.app) + if has_state and in_focus_mode == use_focus_mode: + msg = "DOCUMENT PRESENTER: Presentation mode already set." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + obj, _offset = script.utilities.get_caret_context(document) + + if in_focus_mode and not use_focus_mode: + parent = AXObject.get_parent(obj) + if AXUtilities.is_list_box(parent): + script.utilities.set_caret_context(parent, -1) + elif AXUtilities.is_menu(parent): + script.utilities.set_caret_context(AXObject.get_parent(parent), -1) + + if not in_focus_mode and use_focus_mode: + if ( + caret_navigator.get_navigator().last_input_event_was_navigation_command() + or structural_navigator.get_navigator().last_input_event_was_navigation_command() + or table_navigator.get_navigator().last_input_event_was_navigation_command() + ): + AXObject.grab_focus(obj) + + if notify_user: + msg = messages.MODE_FOCUS if use_focus_mode else messages.MODE_BROWSE + presentation_manager.get_manager().present_message(msg) + + state = self._get_state_for_app(script.app) + state.in_focus_mode = use_focus_mode + state.focus_mode_is_sticky = False + state.browse_mode_is_sticky = False + + reason = "setting presentation mode" + if not use_focus_mode: + self._enable_document_navigators(script, reason) + + self.suspend_navigators(script, use_focus_mode, reason) + return True + + def suspend_navigators(self, script: default.Script, suspended: bool, reason: str) -> bool: + """Suspends or unsuspends navigation commands. Returns True if state changed.""" + + caret_navigator.get_navigator().suspend_commands(script, suspended, reason) + structural_navigator.get_navigator().suspend_commands(script, suspended, reason) + return True + + def _enable_document_navigators(self, script: default.Script, reason: str) -> None: + """Enables document navigators for the given script.""" + + msg = f"DOCUMENT PRESENTER: _enable_document_navigators. Reason: {reason}" + debug.print_message(debug.LEVEL_INFO, msg, True) + + structural_navigator.get_navigator().set_mode( + script, + structural_navigator.NavigationMode.DOCUMENT, + ) + caret_navigator.get_navigator().set_enabled_for_script(script, True) + + def _is_focus_mode_widget_by_state(self, obj: Atspi.Accessible) -> tuple[bool, str]: + """Returns (True, reason) if obj's state makes it a focus mode widget.""" + + if AXUtilities.is_editable(obj): + return True, "it's editable" + + if ( + AXUtilities.is_expandable(obj) + and AXUtilities.is_focusable(obj) + and not AXUtilities.is_link(obj) + ): + return True, "it's expandable and focusable" + + return False, "" + + def _is_focus_mode_widget_by_role( + self, + script: default.Script, + obj: Atspi.Accessible, + ) -> tuple[bool | None, str]: + """Returns (True/False, reason) if role determines focus mode, or (None, '') if unclear.""" + + always_focus_mode_roles = [ + Atspi.Role.COMBO_BOX, + Atspi.Role.ENTRY, + Atspi.Role.LIST_BOX, + Atspi.Role.MENU, + Atspi.Role.MENU_ITEM, + Atspi.Role.CHECK_MENU_ITEM, + Atspi.Role.RADIO_MENU_ITEM, + Atspi.Role.PAGE_TAB, + Atspi.Role.PASSWORD_TEXT, + Atspi.Role.PROGRESS_BAR, + Atspi.Role.SLIDER, + Atspi.Role.SPIN_BUTTON, + Atspi.Role.TOOL_BAR, + Atspi.Role.TREE_ITEM, + Atspi.Role.TREE_TABLE, + Atspi.Role.TREE, + ] + + role = AXObject.get_role(obj) + if role in always_focus_mode_roles: + return True, "due to its role" + + if role in [Atspi.Role.TABLE_CELL, Atspi.Role.TABLE] and AXUtilities.is_layout_table( + AXUtilities.get_table(obj), + ): + return False, "it's layout only" + + if AXUtilities.is_list_box_item(obj, role): + return True, "it's a listbox item" + + if AXUtilities.is_button_with_popup(obj, role): + return True, "it's a button with popup" + + focus_mode_roles = [Atspi.Role.EMBEDDED, Atspi.Role.TABLE_CELL, Atspi.Role.TABLE] + if ( + role in focus_mode_roles + and not script.utilities.is_text_block_element(obj) + and not script.utilities.has_name_and_action_and_no_useful_children(obj) + and not AXDocument.is_pdf(script.utilities.get_document_for_object(obj)) + ): + return True, "based on presumed functionality" + + return None, "" + + def _is_focus_mode_widget_by_ancestry( + self, + script: default.Script, + obj: Atspi.Accessible, + ) -> tuple[bool, str]: + """Returns (True, reason) if obj's ancestry makes it a focus mode widget.""" + + ancestor_checks: list[tuple[Callable[[Atspi.Accessible], bool], str]] = [ + (AXUtilities.is_grid, "it's a grid descendant"), + (AXUtilities.is_menu, "it's a menu descendant"), + (AXUtilities.is_tool_bar, "it's a toolbar descendant"), + ] + for predicate, reason in ancestor_checks: + if AXUtilities.find_ancestor(obj, predicate) is not None: + return True, reason + + if script.utilities.is_content_editable_with_embedded_objects(obj): + return True, "it's content editable" + + return False, "" + + def is_focus_mode_widget(self, script: default.Script, obj: Atspi.Accessible) -> bool: + """Returns True if obj should be interacted with in focus mode.""" + + result, reason = self._is_focus_mode_widget_by_state(obj) + if not result: + role_result, reason = self._is_focus_mode_widget_by_role(script, obj) + if role_result is None: + result, reason = self._is_focus_mode_widget_by_ancestry(script, obj) + else: + result = role_result + + prefix = "is" if result else "is not" + if reason: + tokens = ["DOCUMENT PRESENTER:", obj, f"{prefix} focus mode widget:", reason] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + return result + + @dbus_service.command + def enable_sticky_browse_mode( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Enables sticky browse mode.""" + + msg = f"DOCUMENT PRESENTER: enable_sticky_browse_mode({event}, {notify_user})" + debug.print_message(debug.LEVEL_INFO, msg, True) + + if not script.utilities.in_document_content(): + if notify_user: + presentation_manager.get_manager().present_message(messages.DOCUMENT_NOT_IN_A) + return True + + state = self._get_state_for_app(script.app) + + if not state.browse_mode_is_sticky or notify_user: + presentation_manager.get_manager().present_message(messages.MODE_BROWSE_IS_STICKY) + + state.in_focus_mode = False + state.focus_mode_is_sticky = False + state.browse_mode_is_sticky = True + state.user_has_toggled = True + + reason = "enable sticky browse mode" + self.suspend_navigators(script, state.in_focus_mode, reason) + return True + + @dbus_service.command + def enable_sticky_focus_mode( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Enables sticky focus mode.""" + + msg = f"DOCUMENT PRESENTER: enable_sticky_focus_mode({event}, {notify_user})" + debug.print_message(debug.LEVEL_INFO, msg, True) + + if not script.utilities.in_document_content(): + if notify_user: + presentation_manager.get_manager().present_message(messages.DOCUMENT_NOT_IN_A) + return True + + state = self._get_state_for_app(script.app) + + if not state.focus_mode_is_sticky or notify_user: + presentation_manager.get_manager().present_message(messages.MODE_FOCUS_IS_STICKY) + + state.in_focus_mode = True + state.focus_mode_is_sticky = True + state.browse_mode_is_sticky = False + state.user_has_toggled = True + + reason = "enable sticky focus mode" + self.suspend_navigators(script, state.in_focus_mode, reason) + return True + + @dbus_service.command + def toggle_presentation_mode( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + document: Atspi.Accessible | None = None, + notify_user: bool = True, + ) -> bool: + """Switches between browse mode and focus mode (user-initiated).""" + + if not script.utilities.in_document_content(): + if notify_user: + presentation_manager.get_manager().present_message(messages.DOCUMENT_NOT_IN_A) + return True + + use_focus = not self.in_focus_mode(script.app) + obj, _offset = script.utilities.get_caret_context(document) + if event is not None and use_focus: + AXObject.grab_focus(obj) + + self._set_presentation_mode( + script, + use_focus, + obj=obj, + document=document, + notify_user=notify_user, + ) + self._get_state_for_app(script.app).user_has_toggled = True + return True + + @dbus_service.getter + def get_in_focus_mode(self) -> bool: + """Returns True if focus mode is active (web content only).""" + + if script := script_manager.get_manager().get_active_script(): + return self.in_focus_mode(script.app) + return False + + @dbus_service.getter + def get_focus_mode_is_sticky(self) -> bool: + """Returns True if focus mode is active and 'sticky' (web content only).""" + + if script := script_manager.get_manager().get_active_script(): + return self.focus_mode_is_sticky(script.app) + return False + + @dbus_service.getter + def get_browse_mode_is_sticky(self) -> bool: + """Returns True if browse mode is active and 'sticky' (web content only).""" + + if script := script_manager.get_manager().get_active_script(): + return self.browse_mode_is_sticky(script.app) + return False + + def restore_mode_for_script(self, script: default.Script) -> None: + """Restores navigator suspension state when a script is activated.""" + + if script.app is None: + return + + app_hash = hash(script.app) + if app_hash not in self._app_states: + return + + state = self._app_states[app_hash] + + # When restoring browse mode, also re-enable the navigators. + # This is needed because another script (e.g. file browser) may have + # disabled them via set_mode(OFF) while it was active. + reason = "restoring mode state for activated script" + if not state.in_focus_mode: + self._enable_document_navigators(script, reason) + + self.suspend_navigators(script, state.in_focus_mode, reason) + + tokens = [ + "DOCUMENT PRESENTER: Restored mode for", + script.app, + ". Focus mode:", + state.in_focus_mode, + "Focus sticky:", + state.focus_mode_is_sticky, + "Browse sticky:", + state.browse_mode_is_sticky, + "User toggled:", + state.user_has_toggled, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + def has_state_for_app(self, app: Atspi.Accessible | None) -> bool: + """Returns True if mode state exists for the given app.""" + + if app is None: + return False + return hash(app) in self._app_states + + def clear_state_for_app(self, app: Atspi.Accessible | None) -> None: + """Clears mode state when an app is closed.""" + + if app is None: + return + app_hash = hash(app) + if app_hash in self._app_states: + del self._app_states[app_hash] + tokens = ["DOCUMENT PRESENTER: Cleared state for", app] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + def _handle_entering_document( + self, + script: default.Script, + new_focus: Atspi.Accessible, + old_focus: Atspi.Accessible | None, + ) -> bool: + """Handles mode/navigator setup when entering a document from outside.""" + + if self.focus_mode_is_sticky(script.app): + presentation_manager.get_manager().present_message(messages.MODE_FOCUS_IS_STICKY) + reason = "locus of focus now in document, focus mode is sticky" + self.suspend_navigators(script, True, reason) + return True + + if self.browse_mode_is_sticky(script.app): + presentation_manager.get_manager().present_message(messages.MODE_BROWSE_IS_STICKY) + reason = "locus of focus now in document, browse mode is sticky" + self._enable_document_navigators(script, reason) + self.suspend_navigators(script, False, reason) + return True + + # Only do app-type detection if setting is enabled and user hasn't explicitly + # toggled mode. This allows the user to escape auto-enabled sticky focus mode. + if ( + self.get_auto_sticky_focus_mode_for_web_apps() + and not self._get_state_for_app(script.app).user_has_toggled + ): + if self._is_likely_electron_app(script.app): + msg = "DOCUMENT PRESENTER: Electron app detected, enabling sticky focus mode" + debug.print_message(debug.LEVEL_INFO, msg, True) + self.enable_sticky_focus_mode(script, notify_user=True) + return True + + if self._is_top_level_web_app(script, new_focus): + msg = "DOCUMENT PRESENTER: Top-level web app detected, enabling sticky focus mode" + debug.print_message(debug.LEVEL_INFO, msg, True) + self.enable_sticky_focus_mode(script, notify_user=True) + return True + + use_focus = self.use_focus_mode(new_focus, old_focus) + reason = "entering document" + if not use_focus: + self._enable_document_navigators(script, reason) + + state = self._get_state_for_app(script.app) + state.in_focus_mode = use_focus + self.suspend_navigators(script, use_focus, reason) + + if use_focus: + presentation_manager.get_manager().present_message(messages.MODE_FOCUS) + else: + presentation_manager.get_manager().present_message(messages.MODE_BROWSE) + + return True + + def update_mode_if_needed( + self, + script: default.Script, + old_focus: Atspi.Accessible | None, + new_focus: Atspi.Accessible | None, + ) -> bool: + """Updates focus/browse mode based on a focus change. Returns True if handled.""" + + old_doc = script.utilities.get_top_level_document_for_object(old_focus) + new_doc = script.utilities.get_top_level_document_for_object(new_focus) + + tokens = [ + "DOCUMENT PRESENTER: Updating mode for focus change.", + "Old focus:", + old_focus, + "old doc:", + old_doc, + "New focus:", + new_focus, + "new doc:", + new_doc, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if new_doc is None: + self.reset_find_announcement_state() + reason = "locus of focus no longer in document" + self.suspend_navigators(script, False, reason) + structural_navigator.get_navigator().set_mode( + script, + structural_navigator.NavigationMode.OFF, + ) + caret_navigator.get_navigator().set_enabled_for_script(script, False) + return True + + if old_doc is None and not focus_manager.get_manager().old_focus_was_dead(): + return self._handle_entering_document(script, new_focus, old_focus) + + # Focus change within document + if self.focus_mode_is_sticky(script.app) or self.browse_mode_is_sticky(script.app): + return False + + use_focus = self.use_focus_mode(new_focus, old_focus) + self._set_presentation_mode(script, use_focus, obj=new_focus, document=new_doc) + return True + + def reset_find_announcement_state(self) -> None: + """Resets the find announcement state.""" + + self._made_find_announcement = False + + @gsettings_registry.get_registry().gsetting( + key=KEY_NATIVE_NAV_TRIGGERS_FOCUS_MODE, + schema="document", + gtype="b", + default=True, + summary="Native navigation triggers focus mode", + migration_key="nativeNavTriggersFocusMode", + ) + @dbus_service.getter + def get_native_nav_triggers_focus_mode(self) -> bool: + """Returns whether native navigation triggers focus mode.""" + + return self._get_setting(self.KEY_NATIVE_NAV_TRIGGERS_FOCUS_MODE, "b", True) + + @dbus_service.setter + def set_native_nav_triggers_focus_mode(self, value: bool) -> bool: + """Sets whether native navigation triggers focus mode.""" + + if self.get_native_nav_triggers_focus_mode() == value: + return True + + msg = f"DOCUMENT PRESENTER: Setting native nav triggers focus mode to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_NATIVE_NAV_TRIGGERS_FOCUS_MODE, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_AUTO_STICKY_FOCUS_MODE, + schema="document", + gtype="b", + default=True, + summary="Auto-detect sticky focus mode for web apps", + migration_key="autoStickyFocusModeForWebApps", + ) + @dbus_service.getter + def get_auto_sticky_focus_mode_for_web_apps(self) -> bool: + """Returns whether to auto-detect web apps and enable sticky focus mode.""" + + return self._get_setting(self.KEY_AUTO_STICKY_FOCUS_MODE, "b", True) + + @dbus_service.setter + def set_auto_sticky_focus_mode_for_web_apps(self, value: bool) -> bool: + """Sets whether to auto-detect web apps and enable sticky focus mode.""" + + if self.get_auto_sticky_focus_mode_for_web_apps() == value: + return True + + msg = f"DOCUMENT PRESENTER: Setting auto sticky focus mode for web apps to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_AUTO_STICKY_FOCUS_MODE, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_SAY_ALL_ON_LOAD, + schema="document", + gtype="b", + default=True, + summary="Perform say all when document loads", + migration_key="sayAllOnLoad", + ) + @dbus_service.getter + def get_say_all_on_load(self) -> bool: + """Returns whether to perform say all when a document loads.""" + + return self._get_setting(self.KEY_SAY_ALL_ON_LOAD, "b", True) + + @dbus_service.setter + def set_say_all_on_load(self, value: bool) -> bool: + """Sets whether to perform say all when a document loads.""" + + if self.get_say_all_on_load() == value: + return True + + msg = f"DOCUMENT PRESENTER: Setting say all on load to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, self.KEY_SAY_ALL_ON_LOAD, value + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_PAGE_SUMMARY_ON_LOAD, + schema="document", + gtype="b", + default=True, + summary="Present page summary when document loads", + migration_key="pageSummaryOnLoad", + ) + @dbus_service.getter + def get_page_summary_on_load(self) -> bool: + """Returns whether to present a page summary when a document loads.""" + + return self._get_setting(self.KEY_PAGE_SUMMARY_ON_LOAD, "b", True) + + @dbus_service.setter + def set_page_summary_on_load(self, value: bool) -> bool: + """Sets whether to present a page summary when a document loads.""" + + if self.get_page_summary_on_load() == value: + return True + + msg = f"DOCUMENT PRESENTER: Setting page summary on load to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_PAGE_SUMMARY_ON_LOAD, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_FIND_RESULTS_VERBOSITY, + schema="document", + genum="org.stormux.Cthulhu.FindResultsVerbosity", + default="all", + summary="Find results verbosity (none, if-line-changed, all)", + migration_key="findResultsVerbosity", + ) + def get_find_results_verbosity_name(self) -> str: + """Returns the find results verbosity level as a string name.""" + + return gsettings_registry.get_registry().layered_lookup( + self._SCHEMA, + self.KEY_FIND_RESULTS_VERBOSITY, + "", + genum="org.stormux.Cthulhu.FindResultsVerbosity", + default="all", + ) + + @dbus_service.getter + def get_speak_find_results(self) -> bool: + """Returns whether to speak find results.""" + + return self.get_find_results_verbosity_name() != "none" + + @dbus_service.setter + def set_speak_find_results(self, value: bool) -> bool: + """Sets whether to speak find results.""" + + if self.get_speak_find_results() == value: + return True + + msg = f"DOCUMENT PRESENTER: Setting speak find results to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + name = "all" if value else "none" + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_FIND_RESULTS_VERBOSITY, + name, + ) + return True + + @dbus_service.getter + def get_only_speak_changed_lines(self) -> bool: + """Returns whether to only speak changed lines during find.""" + + return self.get_find_results_verbosity_name() == "if-line-changed" + + @dbus_service.setter + def set_only_speak_changed_lines(self, value: bool) -> bool: + """Sets whether to only speak changed lines during find.""" + + if self.get_only_speak_changed_lines() == value: + return True + + msg = f"DOCUMENT PRESENTER: Setting only speak changed lines to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + name = "if-line-changed" if value else "all" + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_FIND_RESULTS_VERBOSITY, + name, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_FIND_RESULTS_MINIMUM_LENGTH, + schema="document", + gtype="i", + default=4, + summary="Minimum length for find results to be spoken", + migration_key="findResultsMinimumLength", + ) + @dbus_service.getter + def get_find_results_minimum_length(self) -> int: + """Returns the minimum length for find results to be spoken.""" + + return self._get_setting(self.KEY_FIND_RESULTS_MINIMUM_LENGTH, "i", 4) + + @dbus_service.setter + def set_find_results_minimum_length(self, value: int) -> bool: + """Sets the minimum length for find results to be spoken.""" + + if self.get_find_results_minimum_length() == value: + return True + + msg = f"DOCUMENT PRESENTER: Setting find results minimum length to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_FIND_RESULTS_MINIMUM_LENGTH, + value, + ) + return True + + # pylint: disable-next=too-many-locals + def present_find_results(self, obj: Atspi.Accessible, offset: int) -> bool: + """Presents find results if appropriate based on settings. Returns True if presented.""" + + script = script_manager.get_manager().get_active_script() + if script is None: + msg = "DOCUMENT PRESENTER: No active script for find results presentation." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + document = script.utilities.get_document_for_object(obj) + if document is None: + msg = "DOCUMENT PRESENTER: No document for find results presentation." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + start = AXUtilities.get_selection_start_offset(obj) + if start < 0: + msg = "DOCUMENT PRESENTER: Invalid selection start for find results presentation." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + tokens = [ + "DOCUMENT PRESENTER: Find results", + obj, + f"offset: {offset}, selection start offset: {start}", + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + offset = max(offset, start) + context = script.utilities.get_caret_context(document) + script.utilities.set_caret_context(obj, offset, document=document) + + end = AXUtilities.get_selection_end_offset(obj) + if ( + end - start < self.get_find_results_minimum_length() + or not self.get_speak_find_results() + ): + return False + + if self._made_find_announcement and self.get_only_speak_changed_lines(): + context_obj, context_offset = context + context_rect = AXText.get_range_rect(context_obj, context_offset, context_offset + 1) + current_rect = AXText.get_range_rect(obj, offset, offset + 1) + if AXUtilities.rects_are_on_same_line(context_rect, current_rect): + return False + + contents = script.utilities.get_line_contents_at_offset(obj, offset) + presentation_manager.get_manager().speak_contents(contents) + script.update_braille(obj) + + results_count = script.utilities.get_find_results_count() + if results_count: + presentation_manager.get_manager().present_message(results_count) + + self._made_find_announcement = True + return True + + def _force_browse_mode_for_web_app_descendant( + self, + script: default.Script, + obj: Atspi.Accessible, + ) -> bool: + """Returns True if we should force browse mode for web-app descendant obj.""" + + if not AXUtilities.find_ancestor(obj, AXUtilities.is_embedded): + return False + + if AXUtilities.is_tool_tip(obj): + return AXUtilities.is_focused(obj) + + if AXUtilities.is_document_web(obj): + return not self.is_focus_mode_widget(script, obj) + + return False + + def _navigation_prevents_focus_mode( + self, + script: default.Script, + obj: Atspi.Accessible, + prev_obj: Atspi.Accessible | None, + ) -> tuple[bool | None, str]: + """Returns (True/False, reason) if navigation state determines focus mode. + + Returns (None, '') if navigation doesn't determine the result. + """ + + if self.focus_mode_is_sticky(script.app): + return True, "focus mode is sticky" + if self.browse_mode_is_sticky(script.app): + return False, "browse mode is sticky" + if focus_manager.get_manager().in_say_all(): + return self.in_focus_mode(script.app), "SayAll preserves current mode" + if table_navigator.get_navigator().last_input_event_was_navigation_command(): + return self.in_focus_mode(script.app), "table nav preserves current mode" + + _structural_navigator = structural_navigator.get_navigator() + _caret_navigator = caret_navigator.get_navigator() + caret_prevents = ( + _caret_navigator.last_command_prevents_focus_mode() + and AXUtilities.find_ancestor_inclusive(prev_obj, AXUtilities.is_tool_tip) is None + ) + if _structural_navigator.last_command_prevents_focus_mode() or caret_prevents: + struct_prevents = _structural_navigator.last_command_prevents_focus_mode() + nav_type = "structural" if struct_prevents else "caret" + return False, f"prevented by {nav_type} nav settings" + + old_doc = script.utilities.get_top_level_document_for_object(prev_obj) + new_doc = script.utilities.get_top_level_document_for_object(obj) + if old_doc == new_doc and not self.get_native_nav_triggers_focus_mode(): + was_struct_nav = _structural_navigator.last_input_event_was_navigation_command() + was_caret_nav = _caret_navigator.last_input_event_was_navigation_command() + if not (was_struct_nav or was_caret_nav): + result = self.in_focus_mode(script.app) + return result, "prevented by native nav settings" + + return None, "" + + def use_focus_mode( + self, + obj: Atspi.Accessible, + prev_obj: Atspi.Accessible | None = None, + ) -> bool: + """Returns True if we should use focus mode in obj.""" + + script = script_manager.get_manager().get_active_script() + if script is None: + msg = "DOCUMENT PRESENTER: No active script for focus mode decision." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + if prev_obj and AXObject.is_dead(prev_obj): + prev_obj = None + + nav_result, reason = self._navigation_prevents_focus_mode(script, obj, prev_obj) + if nav_result is not None: + prefix = "Using" if nav_result else "Not using" + msg = f"DOCUMENT PRESENTER: {prefix} focus mode: {reason}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return nav_result + + do_not_toggle = AXUtilities.is_link(obj) or AXUtilities.is_radio_button(obj) + stay_in_focus = ( + self.in_focus_mode(script.app) + and do_not_toggle + and input_event_manager.get_manager().last_event_was_unmodified_arrow() + ) + if self.is_focus_mode_widget(script, obj) or stay_in_focus: + reason = "is a focus mode widget" if not stay_in_focus else "arrowing in link/radio" + tokens = ["DOCUMENT PRESENTER: Using focus mode:", obj, reason] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return True + + was_in_app = AXUtilities.find_ancestor(prev_obj, AXUtilities.is_embedded) + is_in_app = AXUtilities.find_ancestor(obj, AXUtilities.is_embedded) + if is_in_app: + if not was_in_app: + msg = "DOCUMENT PRESENTER: Using focus mode: just entered a web application" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + if self.in_focus_mode(script.app): + force_browse = self._force_browse_mode_for_web_app_descendant(script, obj) + if force_browse: + tokens = ["DOCUMENT PRESENTER: Forcing browse mode for web app descendant", obj] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + else: + msg = "DOCUMENT PRESENTER: Staying in focus mode: inside a web application" + debug.print_message(debug.LEVEL_INFO, msg, True) + return not force_browse + + tokens = ["DOCUMENT PRESENTER: Not using focus mode for", obj, "due to lack of cause"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + def create_preferences_grid( + self, + title_change_callback: Callable[[str], None] | None = None, + ) -> DocumentPreferencesGrid: + """Returns the preferences grid for document settings.""" + + return DocumentPreferencesGrid(self, title_change_callback) + + +_presenter = DocumentPresenter() + + +def get_presenter() -> DocumentPresenter: + """Returns the Document Presenter.""" + + return _presenter diff --git a/src/cthulhu/flat_review_presenter.py b/src/cthulhu/flat_review_presenter.py index bb0cc51..18ba5f9 100644 --- a/src/cthulhu/flat_review_presenter.py +++ b/src/cthulhu/flat_review_presenter.py @@ -1,9 +1,7 @@ -#!/usr/bin/env python3 +# Cthulhu # -# Copyright (c) 2024 Stormux -# Copyright (c) 2010-2012 The Orca Team -# Copyright (c) 2012 Igalia, S.L. -# Copyright (c) 2005-2010 Sun Microsystems Inc. +# Copyright 2005-2008 Sun Microsystems Inc. +# Copyright 2016-2023 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 @@ -19,747 +17,441 @@ # 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 + +# pylint: disable=too-many-lines +# pylint: disable=too-many-public-methods """Module for flat-review commands""" -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2005-2008 Sun Microsystems Inc." \ - "Copyright (c) 2016-2023 Igalia, S.L." -__license__ = "LGPL" +from __future__ import annotations + +import time +from typing import TYPE_CHECKING import gi -from typing import Optional, Dict, Callable, Any gi.require_version("Gtk", "3.0") from gi.repository import Gtk -from . import braille -from . import cmdnames -from . import debug -from . import flat_review -from . import guilabels -from . import input_event -from . import keybindings -from . import messages -from . import cthulhu -from . import cthulhu_state -from . import settings_manager -from . import settings +from . import ( + braille, + braille_presenter, + clipboard, + cmdnames, + command_manager, + dbus_service, + debug, + flat_review, + focus_manager, + gsettings_registry, + guilabels, + input_event, + keybindings, + messages, + presentation_manager, + script_manager, + speech_presenter, +) +from .ax_event_synthesizer import AXEventSynthesizer +from .ax_object import AXObject +from .ax_text import AXText -_settingsManager = None # Removed - use cthulhu.cthulhuApp.settingsManager +if TYPE_CHECKING: + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + from .scripts import default + + +@gsettings_registry.get_registry().gsettings_schema("org.stormux.Cthulhu.FlatReview", name="flat-review") class FlatReviewPresenter: """Provides access to on-screen objects via flat-review.""" + _SCHEMA = "flat-review" + KEY_RESTRICTED = "restricted" + + 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._context: Optional[flat_review.Context] = None + self._context: flat_review.Context | None = None self._current_contents: str = "" - self._restrict: bool = cthulhu.cthulhuApp.settingsManager.getSetting("flatReviewIsRestricted") - self._handlers: Dict[str, Callable] = self._setup_handlers() - self._desktop_bindings: keybindings.KeyBindings = self._setup_desktop_bindings() - self._laptop_bindings: keybindings.KeyBindings = self._setup_laptop_bindings() - self._gui: Optional[Any] = None # Optional[Gtk.Window] + self._restrict: bool = self.get_is_restricted() + self._gui: FlatReviewContextGUI | None = None + self._initialized: bool = False + + msg = "FLAT REVIEW PRESENTER: Registering D-Bus commands." + debug.print_message(debug.LEVEL_INFO, msg, True) + controller = dbus_service.get_remote_controller() + controller.register_decorated_module("FlatReviewPresenter", self) + + # pylint: disable-next=too-many-locals + 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_FLAT_REVIEW + + # Build keybinding mapping: cmd_name -> (desktop_kb, laptop_kb) + def kb(keysym: str, mod: int, clicks: int = 1) -> keybindings.KeyBinding: + return keybindings.KeyBinding(keysym, mod, click_count=clicks) + + cmd_bindings: dict[ + str, + tuple[keybindings.KeyBinding | None, keybindings.KeyBinding | None], + ] = { + "toggleFlatReviewModeHandler": ( + kb("KP_Subtract", keybindings.NO_MODIFIER_MASK), + kb("p", keybindings.CTHULHU_MODIFIER_MASK), + ), + "flatReviewSayAllHandler": ( + kb("KP_Add", keybindings.NO_MODIFIER_MASK, 2), + kb("semicolon", keybindings.CTHULHU_MODIFIER_MASK, 2), + ), + "reviewHomeHandler": ( + kb("KP_Home", keybindings.CTHULHU_MODIFIER_MASK), + kb("u", keybindings.CTHULHU_CTRL_MODIFIER_MASK), + ), + "reviewPreviousLineHandler": ( + kb("KP_Home", keybindings.NO_MODIFIER_MASK), + kb("u", keybindings.CTHULHU_MODIFIER_MASK), + ), + "reviewCurrentLineHandler": ( + kb("KP_Up", keybindings.NO_MODIFIER_MASK), + kb("i", keybindings.CTHULHU_MODIFIER_MASK), + ), + "reviewSpellCurrentLineHandler": ( + kb("KP_Up", keybindings.NO_MODIFIER_MASK, 2), + kb("i", keybindings.CTHULHU_MODIFIER_MASK, 2), + ), + "reviewPhoneticCurrentLineHandler": ( + kb("KP_Up", keybindings.NO_MODIFIER_MASK, 3), + kb("i", keybindings.CTHULHU_MODIFIER_MASK, 3), + ), + "reviewNextLineHandler": ( + kb("KP_Page_Up", keybindings.NO_MODIFIER_MASK), + kb("o", keybindings.CTHULHU_MODIFIER_MASK), + ), + "reviewEndHandler": ( + kb("KP_Page_Up", keybindings.CTHULHU_MODIFIER_MASK), + kb("o", keybindings.CTHULHU_CTRL_MODIFIER_MASK), + ), + "reviewPreviousItemHandler": ( + kb("KP_Left", keybindings.NO_MODIFIER_MASK), + kb("j", keybindings.CTHULHU_MODIFIER_MASK), + ), + "reviewAboveHandler": ( + kb("KP_Left", keybindings.CTHULHU_MODIFIER_MASK), + kb("j", keybindings.CTHULHU_CTRL_MODIFIER_MASK), + ), + "reviewCurrentItemHandler": ( + kb("KP_Begin", keybindings.NO_MODIFIER_MASK), + kb("k", keybindings.CTHULHU_MODIFIER_MASK), + ), + "reviewSpellCurrentItemHandler": ( + kb("KP_Begin", keybindings.NO_MODIFIER_MASK, 2), + kb("k", keybindings.CTHULHU_MODIFIER_MASK, 2), + ), + "reviewPhoneticCurrentItemHandler": ( + kb("KP_Begin", keybindings.NO_MODIFIER_MASK, 3), + kb("k", keybindings.CTHULHU_MODIFIER_MASK, 3), + ), + "reviewCurrentAccessibleHandler": ( + kb("KP_Begin", keybindings.CTHULHU_MODIFIER_MASK), + kb("k", keybindings.CTHULHU_CTRL_MODIFIER_MASK), + ), + "reviewNextItemHandler": ( + kb("KP_Right", keybindings.NO_MODIFIER_MASK), + kb("l", keybindings.CTHULHU_MODIFIER_MASK), + ), + "reviewBelowHandler": ( + kb("KP_Right", keybindings.CTHULHU_MODIFIER_MASK), + kb("l", keybindings.CTHULHU_CTRL_MODIFIER_MASK), + ), + "reviewPreviousCharacterHandler": ( + kb("KP_End", keybindings.NO_MODIFIER_MASK), + kb("m", keybindings.CTHULHU_MODIFIER_MASK), + ), + "reviewEndOfLineHandler": ( + kb("KP_End", keybindings.CTHULHU_MODIFIER_MASK), + kb("m", keybindings.CTHULHU_CTRL_MODIFIER_MASK), + ), + "reviewCurrentCharacterHandler": ( + kb("KP_Down", keybindings.NO_MODIFIER_MASK), + kb("comma", keybindings.CTHULHU_MODIFIER_MASK), + ), + "reviewSpellCurrentCharacterHandler": ( + kb("KP_Down", keybindings.NO_MODIFIER_MASK, 2), + kb("comma", keybindings.CTHULHU_MODIFIER_MASK, 2), + ), + "reviewUnicodeCurrentCharacterHandler": ( + kb("KP_Down", keybindings.NO_MODIFIER_MASK, 3), + kb("comma", keybindings.CTHULHU_MODIFIER_MASK, 3), + ), + "reviewNextCharacterHandler": ( + kb("KP_Page_Down", keybindings.NO_MODIFIER_MASK), + kb("period", keybindings.CTHULHU_MODIFIER_MASK), + ), + # Commands with no keybinding + "reviewBottomLeftHandler": (None, None), + "showContentsHandler": (None, None), + "flatReviewCopyHandler": (None, None), + "flatReviewAppendHandler": (None, None), + "flatReviewToggleRestrictHandler": (None, None), + } + + commands_data = [ + ( + "toggleFlatReviewModeHandler", + self.toggle_flat_review_mode, + cmdnames.TOGGLE_FLAT_REVIEW, + ), + ("reviewHomeHandler", self.go_home, cmdnames.REVIEW_HOME), + ("reviewEndHandler", self.go_end, cmdnames.REVIEW_END), + ("reviewBottomLeftHandler", self.go_bottom_left, cmdnames.REVIEW_BOTTOM_LEFT), + ("reviewPreviousLineHandler", self.go_previous_line, cmdnames.REVIEW_PREVIOUS_LINE), + ("reviewCurrentLineHandler", self.present_line, cmdnames.REVIEW_CURRENT_LINE), + ("reviewNextLineHandler", self.go_next_line, cmdnames.REVIEW_NEXT_LINE), + ("reviewSpellCurrentLineHandler", self.spell_line, cmdnames.REVIEW_SPELL_CURRENT_LINE), + ( + "reviewPhoneticCurrentLineHandler", + self.phonetic_line, + cmdnames.REVIEW_PHONETIC_CURRENT_LINE, + ), + ("reviewEndOfLineHandler", self.go_end_of_line, cmdnames.REVIEW_END_OF_LINE), + ("reviewPreviousItemHandler", self.go_previous_item, cmdnames.REVIEW_PREVIOUS_ITEM), + ("reviewCurrentItemHandler", self.present_item, cmdnames.REVIEW_CURRENT_ITEM), + ("reviewNextItemHandler", self.go_next_item, cmdnames.REVIEW_NEXT_ITEM), + ("reviewSpellCurrentItemHandler", self.spell_item, cmdnames.REVIEW_SPELL_CURRENT_ITEM), + ( + "reviewPhoneticCurrentItemHandler", + self.phonetic_item, + cmdnames.REVIEW_PHONETIC_CURRENT_ITEM, + ), + ( + "reviewPreviousCharacterHandler", + self.go_previous_character, + cmdnames.REVIEW_PREVIOUS_CHARACTER, + ), + ( + "reviewCurrentCharacterHandler", + self.present_character, + cmdnames.REVIEW_CURRENT_CHARACTER, + ), + ( + "reviewSpellCurrentCharacterHandler", + self.spell_character, + cmdnames.REVIEW_SPELL_CURRENT_CHARACTER, + ), + ( + "reviewUnicodeCurrentCharacterHandler", + self.unicode_current_character, + cmdnames.REVIEW_UNICODE_CURRENT_CHARACTER, + ), + ("reviewNextCharacterHandler", self.go_next_character, cmdnames.REVIEW_NEXT_CHARACTER), + ( + "reviewCurrentAccessibleHandler", + self.present_object, + cmdnames.REVIEW_CURRENT_ACCESSIBLE, + ), + ("reviewAboveHandler", self.go_above, cmdnames.REVIEW_ABOVE), + ("reviewBelowHandler", self.go_below, cmdnames.REVIEW_BELOW), + ("showContentsHandler", self.show_contents, cmdnames.FLAT_REVIEW_SHOW_CONTENTS), + ("flatReviewCopyHandler", self.copy_to_clipboard, cmdnames.FLAT_REVIEW_COPY), + ("flatReviewAppendHandler", self.append_to_clipboard, cmdnames.FLAT_REVIEW_APPEND), + ("flatReviewSayAllHandler", self.say_all, cmdnames.SAY_ALL_FLAT_REVIEW), + ( + "flatReviewToggleRestrictHandler", + self.toggle_restrict, + cmdnames.TOGGLE_RESTRICT_FLAT_REVIEW, + ), + ] + + braille_bindings: dict[str, tuple[int, ...]] = {} + bindings = [ + ("reviewAboveHandler", braille.BRLAPI_KEY_CMD_LNUP), + ("reviewBelowHandler", braille.BRLAPI_KEY_CMD_LNDN), + ("toggleFlatReviewModeHandler", braille.BRLAPI_KEY_CMD_FREEZE), + ("reviewHomeHandler", braille.BRLAPI_KEY_CMD_TOP_LEFT), + ("reviewBottomLeftHandler", braille.BRLAPI_KEY_CMD_BOT_LEFT), + ] + for name, key in bindings: + if key is not None: + braille_bindings[name] = (key,) + if not braille_bindings: + msg = "FLAT REVIEW PRESENTER: Braille bindings unavailable." + debug.print_message(debug.LEVEL_INFO, msg, True) + + for name, function, description in commands_data: + desktop_kb, laptop_kb = cmd_bindings.get(name, (None, None)) + manager.add_command( + command_manager.KeyboardCommand( + name, + function, + group_label, + description, + desktop_keybinding=desktop_kb, + laptop_keybinding=laptop_kb, + ), + ) + if name in braille_bindings: + bb = braille_bindings[name] + manager.add_command( + command_manager.BrailleCommand( + name, + function, + group_label, + description, + braille_bindings=bb, + ), + ) + + msg = "FLAT REVIEW PRESENTER: Commands set up." + debug.print_message(debug.LEVEL_INFO, msg, True) def is_active(self) -> bool: """Returns True if the flat review presenter is active.""" return self._context is not None - def get_or_create_context(self, script: Optional[Any] = None) -> Optional[flat_review.Context]: + def get_or_create_context(self, script: default.Script | None = None) -> flat_review.Context: """Returns the flat review context, creating one if necessary.""" - # TODO - JD: Scripts should not be able to interact with the - # context directly. get_or_create_context is public temporarily - # to prevent breakage. - if not self._context: msg = f"FLAT REVIEW PRESENTER: Creating new context. Restrict: {self._restrict}" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) if self._restrict: - mode, obj = cthulhu.getActiveModeAndObjectOfInterest() + mode, obj = focus_manager.get_manager().get_active_mode_and_object_of_interest() self._context = flat_review.Context(script, root=obj) else: self._context = flat_review.Context(script) - cthulhu.emitRegionChanged(self._context.getCurrentAccessible(), mode=cthulhu.FLAT_REVIEW) - if script is not None: - script.justEnteredFlatReviewMode = True - script.targetCursorCell = script.getBrailleCursorCell() + focus_manager.get_manager().emit_region_changed( + self._context.get_current_object(), + mode=focus_manager.FLAT_REVIEW, + ) + return self._context msg = f"FLAT REVIEW PRESENTER: Using existing context. Restrict: {self._restrict}" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) # If we are in unrestricted mode, update the context as below. # If the context already exists, but the active mode is not flat review, update # the flat review location to that of the object of interest -- if the object of # interest is in the flat review context (which means it's on screen). In some # cases the object of interest will not be in the flat review context because it - # is represented by descendant text objects. setCurrentToZoneWithObject checks + # is represented by descendant text objects. set_current_to_zone_with_object checks # for this condition and if it can find a zone whose ancestor is the object of # interest, it will set the current zone to the descendant, causing Cthulhu to # present the text at the location of the object of interest. - mode, obj = cthulhu.getActiveModeAndObjectOfInterest() - obj = obj or cthulhu_state.locusOfFocus - if mode != cthulhu.FLAT_REVIEW and obj != self._context.getCurrentAccessible() \ - and not self._restrict: - tokens = ["FLAT REVIEW PRESENTER: Attempting to update location from", - self._context.getCurrentAccessible(), "to", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - self._context.setCurrentToZoneWithObject(obj) + mode, obj = focus_manager.get_manager().get_active_mode_and_object_of_interest() + obj = obj or focus_manager.get_manager().get_locus_of_focus() + if ( + mode != focus_manager.FLAT_REVIEW + and obj != self._context.get_current_object() + and not self._restrict + ): + tokens = [ + "FLAT REVIEW PRESENTER: Attempting to update location from", + self._context.get_current_object(), + "to", + obj, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._context.set_current_to_zone_with_object(obj) # If we are restricting, and the current mode is not flat review, calculate a new context - if self._restrict and mode != cthulhu.FLAT_REVIEW: + if self._restrict and mode != focus_manager.FLAT_REVIEW: msg = "FLAT REVIEW PRESENTER: Creating new restricted context." - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) self._context = flat_review.Context(script, obj) return self._context - def get_bindings(self, is_desktop): - """Returns the flat-review-presenter keybindings.""" - - if is_desktop: - return self._desktop_bindings - return self._laptop_bindings - - def get_braille_bindings(self): - """Returns the flat-review-presenter braille bindings.""" - - bindings = {} - try: - bindings[braille.brlapi.KEY_CMD_LNUP] = \ - self._handlers.get("reviewAboveHandler") - bindings[braille.brlapi.KEY_CMD_LNDN] = \ - self._handlers.get("reviewBelowHandler") - bindings[braille.brlapi.KEY_CMD_FREEZE] = \ - self._handlers.get("toggleFlatReviewModeHandler") - bindings[braille.brlapi.KEY_CMD_TOP_LEFT] = \ - self._handlers.get("reviewHomeHandler") - bindings[braille.brlapi.KEY_CMD_BOT_LEFT] = \ - self._handlers.get("reviewBottomLeftHandler") - except Exception as error: - tokens = ["FLAT REVIEW PRESENTER: Exception getting braille bindings:", error] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return {} - return bindings - - def get_handlers(self): - """Returns the flat-review-presenter handlers.""" - - return self._handlers - - def _setup_handlers(self): - """Sets up and returns the flat-review-presenter input event handlers.""" - - handlers = {} - - handlers["toggleFlatReviewModeHandler"] = \ - input_event.InputEventHandler( - self.toggle_flat_review_mode, - cmdnames.TOGGLE_FLAT_REVIEW) - - handlers["reviewHomeHandler"] = \ - input_event.InputEventHandler( - self.go_home, - cmdnames.REVIEW_HOME) - - handlers["reviewEndHandler"] = \ - input_event.InputEventHandler( - self.go_end, - cmdnames.REVIEW_END) - - handlers["reviewBottomLeftHandler"] = \ - input_event.InputEventHandler( - self.go_bottom_left, - cmdnames.REVIEW_BOTTOM_LEFT) - - handlers["reviewPreviousLineHandler"] = \ - input_event.InputEventHandler( - self.go_previous_line, - cmdnames.REVIEW_PREVIOUS_LINE) - - handlers["reviewCurrentLineHandler"] = \ - input_event.InputEventHandler( - self.present_line, - cmdnames.REVIEW_CURRENT_LINE) - - handlers["reviewNextLineHandler"] = \ - input_event.InputEventHandler( - self.go_next_line, - cmdnames.REVIEW_NEXT_LINE) - - handlers["reviewSpellCurrentLineHandler"] = \ - input_event.InputEventHandler( - self.spell_line, - cmdnames.REVIEW_SPELL_CURRENT_LINE) - - handlers["reviewPhoneticCurrentLineHandler"] = \ - input_event.InputEventHandler( - self.phonetic_line, - cmdnames.REVIEW_PHONETIC_CURRENT_LINE) - - handlers["reviewEndOfLineHandler"] = \ - input_event.InputEventHandler( - self.go_end_of_line, - cmdnames.REVIEW_END_OF_LINE) - - handlers["reviewPreviousItemHandler"] = \ - input_event.InputEventHandler( - self.go_previous_item, - cmdnames.REVIEW_PREVIOUS_ITEM) - - handlers["reviewCurrentItemHandler"] = \ - input_event.InputEventHandler( - self.present_item, - cmdnames.REVIEW_CURRENT_ITEM) - - handlers["reviewNextItemHandler"] = \ - input_event.InputEventHandler( - self.go_next_item, - cmdnames.REVIEW_NEXT_ITEM) - - handlers["reviewSpellCurrentItemHandler"] = \ - input_event.InputEventHandler( - self.spell_item, - cmdnames.REVIEW_SPELL_CURRENT_ITEM) - - handlers["reviewPhoneticCurrentItemHandler"] = \ - input_event.InputEventHandler( - self.phonetic_item, - cmdnames.REVIEW_PHONETIC_CURRENT_ITEM) - - handlers["reviewPreviousCharacterHandler"] = \ - input_event.InputEventHandler( - self.go_previous_character, - cmdnames.REVIEW_PREVIOUS_CHARACTER) - - handlers["reviewCurrentCharacterHandler"] = \ - input_event.InputEventHandler( - self.present_character, - cmdnames.REVIEW_CURRENT_CHARACTER) - - handlers["reviewSpellCurrentCharacterHandler"] = \ - input_event.InputEventHandler( - self.spell_character, - cmdnames.REVIEW_SPELL_CURRENT_CHARACTER) - - handlers["reviewUnicodeCurrentCharacterHandler"] = \ - input_event.InputEventHandler( - self.unicode_current_character, - cmdnames.REVIEW_UNICODE_CURRENT_CHARACTER) - - handlers["reviewNextCharacterHandler"] = \ - input_event.InputEventHandler( - self.go_next_character, - cmdnames.REVIEW_NEXT_CHARACTER) - - handlers["reviewCurrentAccessibleHandler"] = \ - input_event.InputEventHandler( - self.present_object, - cmdnames.REVIEW_CURRENT_ACCESSIBLE) - - handlers["reviewAboveHandler"] = \ - input_event.InputEventHandler( - self.go_above, - cmdnames.REVIEW_ABOVE) - - handlers["reviewBelowHandler"] = \ - input_event.InputEventHandler( - self.go_below, - cmdnames.REVIEW_BELOW) - - handlers["showContentsHandler"] = \ - input_event.InputEventHandler( - self.show_contents, - cmdnames.FLAT_REVIEW_SHOW_CONTENTS) - - handlers["flatReviewCopyHandler"] = \ - input_event.InputEventHandler( - self.copy_to_clipboard, - cmdnames.FLAT_REVIEW_COPY) - - handlers["flatReviewAppendHandler"] = \ - input_event.InputEventHandler( - self.append_to_clipboard, - cmdnames.FLAT_REVIEW_APPEND) - - handlers["flatReviewSayAllHandler"] = \ - input_event.InputEventHandler( - self.say_all, - cmdnames.SAY_ALL_FLAT_REVIEW) - - handlers["flatReviewToggleRestrictHandler"] = \ - input_event.InputEventHandler( - self.toggle_restrict, - cmdnames.TOGGLE_RESTRICT_FLAT_REVIEW) - - return handlers - - def _setup_desktop_bindings(self): - """Sets up and returns the flat-review-presenter desktop key bindings.""" - - bindings = keybindings.KeyBindings() - - bindings.add( - keybindings.KeyBinding( - "KP_Subtract", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("toggleFlatReviewModeHandler"))) - - bindings.add( - keybindings.KeyBinding( - "KP_Add", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("flatReviewSayAllHandler"), - 2)) - - bindings.add( - keybindings.KeyBinding( - "KP_Home", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("reviewHomeHandler"))) - - bindings.add( - keybindings.KeyBinding( - "KP_Home", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("reviewPreviousLineHandler"))) - - bindings.add( - keybindings.KeyBinding( - "KP_Up", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("reviewCurrentLineHandler"), - 1)) - - bindings.add( - keybindings.KeyBinding( - "KP_Up", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("reviewSpellCurrentLineHandler"), - 2)) - - bindings.add( - keybindings.KeyBinding( - "KP_Up", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("reviewPhoneticCurrentLineHandler"), - 3)) - - bindings.add( - keybindings.KeyBinding( - "KP_Page_Up", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("reviewNextLineHandler"))) - - bindings.add( - keybindings.KeyBinding( - "KP_Page_Up", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("reviewEndHandler"))) - - bindings.add( - keybindings.KeyBinding( - "KP_Left", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("reviewPreviousItemHandler"))) - - bindings.add( - keybindings.KeyBinding( - "KP_Left", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("reviewAboveHandler"))) - - bindings.add( - keybindings.KeyBinding( - "KP_Begin", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("reviewCurrentItemHandler"), - 1)) - - bindings.add( - keybindings.KeyBinding( - "KP_Begin", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("reviewSpellCurrentItemHandler"), - 2)) - - bindings.add( - keybindings.KeyBinding( - "KP_Begin", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("reviewPhoneticCurrentItemHandler"), - 3)) - - bindings.add( - keybindings.KeyBinding( - "KP_Begin", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("reviewCurrentAccessibleHandler"))) - - bindings.add( - keybindings.KeyBinding( - "KP_Right", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("reviewNextItemHandler"))) - - bindings.add( - keybindings.KeyBinding( - "KP_Right", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("reviewBelowHandler"))) - - bindings.add( - keybindings.KeyBinding( - "KP_End", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("reviewPreviousCharacterHandler"))) - - bindings.add( - keybindings.KeyBinding( - "KP_End", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("reviewEndOfLineHandler"))) - - bindings.add( - keybindings.KeyBinding( - "KP_Down", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("reviewCurrentCharacterHandler"), - 1)) - - bindings.add( - keybindings.KeyBinding( - "KP_Down", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("reviewSpellCurrentCharacterHandler"), - 2)) - - bindings.add( - keybindings.KeyBinding( - "KP_Down", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("reviewUnicodeCurrentCharacterHandler"), - 3)) - - bindings.add( - keybindings.KeyBinding( - "KP_Page_Down", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("reviewNextCharacterHandler"))) - - bindings.add( - keybindings.KeyBinding( - "f", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK | keybindings.SHIFT_MODIFIER_MASK, - self._handlers.get("showContentsHandler"))) - - bindings.add( - keybindings.KeyBinding( - "c", - keybindings.defaultModifierMask, - keybindings.CTHULHU_CTRL_MODIFIER_MASK, - self._handlers.get("flatReviewCopyHandler"))) - - bindings.add( - keybindings.KeyBinding( - "c", - keybindings.defaultModifierMask, - keybindings.CTHULHU_CTRL_MODIFIER_MASK | keybindings.SHIFT_MODIFIER_MASK, - self._handlers.get("flatReviewAppendHandler"))) - - bindings.add( - keybindings.KeyBinding( - "", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("flatReviewToggleRestrictHandler"))) - - return bindings - - def _setup_laptop_bindings(self): - """Sets up and returns the flat-review-presenter laptop key bindings.""" - - bindings = keybindings.KeyBindings() - - bindings.add( - keybindings.KeyBinding( - "p", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("toggleFlatReviewModeHandler"))) - - bindings.add( - keybindings.KeyBinding( - "semicolon", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("flatReviewSayAllHandler"), - 2)) - - bindings.add( - keybindings.KeyBinding( - "u", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("reviewPreviousLineHandler"))) - - bindings.add( - keybindings.KeyBinding( - "u", - keybindings.defaultModifierMask, - keybindings.CTHULHU_CTRL_MODIFIER_MASK, - self._handlers.get("reviewHomeHandler"))) - - bindings.add( - keybindings.KeyBinding( - "i", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("reviewCurrentLineHandler"), - 1)) - - bindings.add( - keybindings.KeyBinding( - "i", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("reviewSpellCurrentLineHandler"), - 2)) - - bindings.add( - keybindings.KeyBinding( - "i", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("reviewPhoneticCurrentLineHandler"), - 3)) - - bindings.add( - keybindings.KeyBinding( - "o", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("reviewNextLineHandler"))) - - bindings.add( - keybindings.KeyBinding( - "o", - keybindings.defaultModifierMask, - keybindings.CTHULHU_CTRL_MODIFIER_MASK, - self._handlers.get("reviewEndHandler"))) - - bindings.add( - keybindings.KeyBinding( - "j", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("reviewPreviousItemHandler"))) - - bindings.add( - keybindings.KeyBinding( - "j", - keybindings.defaultModifierMask, - keybindings.CTHULHU_CTRL_MODIFIER_MASK, - self._handlers.get("reviewAboveHandler"))) - - bindings.add( - keybindings.KeyBinding( - "k", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("reviewCurrentItemHandler"), - 1)) - - bindings.add( - keybindings.KeyBinding( - "k", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("reviewSpellCurrentItemHandler"), - 2)) - - bindings.add( - keybindings.KeyBinding( - "k", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("reviewPhoneticCurrentItemHandler"), - 3)) - - bindings.add( - keybindings.KeyBinding( - "k", - keybindings.defaultModifierMask, - keybindings.CTHULHU_CTRL_MODIFIER_MASK, - self._handlers.get("reviewCurrentAccessibleHandler"))) - - bindings.add( - keybindings.KeyBinding( - "l", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("reviewNextItemHandler"))) - - bindings.add( - keybindings.KeyBinding( - "l", - keybindings.defaultModifierMask, - keybindings.CTHULHU_CTRL_MODIFIER_MASK, - self._handlers.get("reviewBelowHandler"))) - - bindings.add( - keybindings.KeyBinding( - "m", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("reviewPreviousCharacterHandler"))) - - bindings.add( - keybindings.KeyBinding( - "m", - keybindings.defaultModifierMask, - keybindings.CTHULHU_CTRL_MODIFIER_MASK, - self._handlers.get("reviewEndOfLineHandler"))) - - bindings.add( - keybindings.KeyBinding( - "comma", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("reviewCurrentCharacterHandler"), - 1)) - - bindings.add( - keybindings.KeyBinding( - "comma", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("reviewSpellCurrentCharacterHandler"), - 2)) - - bindings.add( - keybindings.KeyBinding( - "comma", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("reviewUnicodeCurrentCharacterHandler"), - 3)) - - bindings.add( - keybindings.KeyBinding( - "period", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("reviewNextCharacterHandler"))) - - bindings.add( - keybindings.KeyBinding( - "f", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK | keybindings.SHIFT_MODIFIER_MASK, - self._handlers.get("showContentsHandler"))) - - bindings.add( - keybindings.KeyBinding( - "c", - keybindings.defaultModifierMask, - keybindings.CTHULHU_CTRL_MODIFIER_MASK, - self._handlers.get("flatReviewCopyHandler"))) - - bindings.add( - keybindings.KeyBinding( - "c", - keybindings.defaultModifierMask, - keybindings.CTHULHU_CTRL_MODIFIER_MASK | keybindings.SHIFT_MODIFIER_MASK, - self._handlers.get("flatReviewAppendHandler"))) - - bindings.add( - keybindings.KeyBinding( - "", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("flatReviewToggleRestrictHandler"))) - - return bindings - - def start(self, script=None, event=None): + def start( + self, + script: default.Script | None = None, + event: input_event.InputEvent | None = None, + ) -> None: """Starts flat review.""" if self._context: msg = "FLAT REVIEW PRESENTER: Already in flat review" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return msg = "FLAT REVIEW PRESENTER: Starting flat review" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) if script is None: - script = cthulhu_state.activeScript + script = script_manager.get_manager().get_active_script() + assert script is not None self.get_or_create_context(script) if event is None: return - if cthulhu.cthulhuApp.settingsManager.getSetting('speechVerbosityLevel') != settings.VERBOSITY_LEVEL_BRIEF: - script.presentMessage(messages.FLAT_REVIEW_START) - self._item_presentation(script, event, script.targetCursorCell) + if speech_presenter.get_presenter().use_verbose_speech(): + presentation_manager.get_manager().present_message(messages.FLAT_REVIEW_START) + self._item_presentation(script, event) - def quit(self, script=None, event=None): + def quit( + self, + script: default.Script | None = None, + event: input_event.InputEvent | None = None, + ) -> None: """Quits flat review.""" if self._context is None: msg = "FLAT REVIEW PRESENTER: Not in flat review" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) return msg = "FLAT REVIEW PRESENTER: Quitting flat review" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) self._context = None - cthulhu.emitRegionChanged(cthulhu_state.locusOfFocus, mode=cthulhu.FOCUS_TRACKING) - + focus = focus_manager.get_manager().get_locus_of_focus() + focus_manager.get_manager().emit_region_changed(focus, mode=focus_manager.FOCUS_TRACKING) if event is None or script is None: return - if cthulhu.cthulhuApp.settingsManager.getSetting('speechVerbosityLevel') != settings.VERBOSITY_LEVEL_BRIEF: - script.presentMessage(messages.FLAT_REVIEW_STOP) - script.updateBraille(cthulhu_state.locusOfFocus) + if speech_presenter.get_presenter().use_verbose_speech(): + presentation_manager.get_manager().present_message(messages.FLAT_REVIEW_STOP) + script.update_braille(focus) - def toggle_flat_review_mode(self, script, event=None): + @dbus_service.command + def toggle_flat_review_mode( + self, + script: default.Script | None = None, + event: input_event.InputEvent | None = None, + notify_user: bool = False, + ) -> bool: """Toggles between flat review mode and focus tracking mode.""" + tokens = [ + "FLAT REVIEW PRESENTER: toggle_flat_review_mode. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if self.is_active(): self.quit(script, event) return True @@ -767,285 +459,924 @@ class FlatReviewPresenter: self.start(script, event) return True - def go_home(self, script, event=None): + @dbus_service.command + def go_home( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Moves to the top left of the current window.""" + tokens = [ + "FLAT REVIEW PRESENTER: go_home. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._context = self.get_or_create_context(script) - self._context.goBegin(flat_review.Context.WINDOW) + self._context.go_to_start_of(flat_review.Context.WINDOW) self.present_line(script, event) - script.targetCursorCell = script.getBrailleCursorCell() return True - def go_end(self, script, event=None): + @dbus_service.command + def go_end( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Moves to the bottom right of the current window.""" + tokens = [ + "FLAT REVIEW PRESENTER: go_end. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._context = self.get_or_create_context(script) - self._context.goEnd(flat_review.Context.WINDOW) + self._context.go_to_end_of(flat_review.Context.WINDOW) self.present_line(script, event) - script.targetCursorCell = script.getBrailleCursorCell() return True - def go_bottom_left(self, script, event=None): + @dbus_service.command + def go_bottom_left( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Moves to the bottom left of the current window.""" + tokens = [ + "FLAT REVIEW PRESENTER: go_bottom_left. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._context = self.get_or_create_context(script) - self._context.goEnd(flat_review.Context.WINDOW) - self._context.goBegin(flat_review.Context.LINE) + self._context.go_to_end_of(flat_review.Context.WINDOW) + self._context.go_to_start_of(flat_review.Context.LINE) self.present_line(script, event) - script.targetCursorCell = script.getBrailleCursorCell() return True - def go_previous_line(self, script, event=None): + @dbus_service.command + def go_previous_line( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Moves to the previous line.""" + tokens = [ + "FLAT REVIEW PRESENTER: go_previous_line. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._context = self.get_or_create_context(script) - if self._context.goPrevious(flat_review.Context.LINE, flat_review.Context.WRAP_LINE): + if self._context.go_previous_line(): self.present_line(script, event) - script.targetCursorCell = script.getBrailleCursorCell() return True - def present_line(self, script, event=None): + @dbus_service.command + def present_line( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Presents the current line.""" + tokens = [ + "FLAT REVIEW PRESENTER: present_line. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._line_presentation(script, event, 1) return True - def go_next_line(self, script, event=None): + @dbus_service.command + def go_next_line( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Moves to the next line.""" + tokens = [ + "FLAT REVIEW PRESENTER: go_next_line. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._context = self.get_or_create_context(script) - if self._context.goNext(flat_review.Context.LINE, flat_review.Context.WRAP_LINE): + if self._context.go_next_line(): self.present_line(script, event) - script.targetCursorCell = script.getBrailleCursorCell() return True - def spell_line(self, script, event=None): + @dbus_service.command + def spell_line( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Presents the current line letter by letter.""" + tokens = [ + "FLAT REVIEW PRESENTER: spell_line. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._line_presentation(script, event, 2) return True - def phonetic_line(self, script, event=None): + @dbus_service.command + def phonetic_line( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Presents the current line letter by letter phonetically.""" + tokens = [ + "FLAT REVIEW PRESENTER: phonetic_line. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._line_presentation(script, event, 3) return True - def go_start_of_line(self, script, event=None): + @dbus_service.command + def go_start_of_line( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Moves to the beginning of the current line.""" + tokens = [ + "FLAT REVIEW PRESENTER: go_start_of_line. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._context = self.get_or_create_context(script) - self._context.goEnd(flat_review.Context.LINE) + self._context.go_to_start_of(flat_review.Context.LINE) self.present_character(script, event) - script.targetCursorCell = script.getBrailleCursorCell() return True - def go_end_of_line(self, script, event=None): + @dbus_service.command + def go_end_of_line( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Moves to the end of the line.""" + tokens = [ + "FLAT REVIEW PRESENTER: go_end_of_line. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._context = self.get_or_create_context(script) - self._context.goEnd(flat_review.Context.LINE) + self._context.go_to_end_of(flat_review.Context.LINE) self.present_character(script, event) - script.targetCursorCell = script.getBrailleCursorCell() return True - def go_previous_item(self, script, event=None): + @dbus_service.command + def go_previous_item( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Moves to the previous item or word.""" + tokens = [ + "FLAT REVIEW PRESENTER: go_previous_item. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._context = self.get_or_create_context(script) - if self._context.goPrevious(flat_review.Context.WORD, flat_review.Context.WRAP_LINE): + if self._context.go_previous_word(): self.present_item(script, event) - script.targetCursorCell = script.getBrailleCursorCell() return True - def present_item(self, script, event=None, target_cursor_cell=0): + @dbus_service.command + def present_item( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Presents the current item/word.""" - self._item_presentation(script, event, target_cursor_cell, 1) + tokens = [ + "FLAT REVIEW PRESENTER: present_item. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._item_presentation(script, event, 1) return True - def go_next_item(self, script, event=None): + @dbus_service.command + def go_next_item( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Moves to the next item or word.""" - self._context = self.get_or_create_context(script) - if self._context.goNext(flat_review.Context.WORD, flat_review.Context.WRAP_LINE): - self.present_item(script, event) - script.targetCursorCell = script.getBrailleCursorCell() + tokens = [ + "FLAT REVIEW PRESENTER: go_next_item. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._context = self.get_or_create_context(script) + if self._context.go_next_word(): + self.present_item(script, event) return True - def spell_item(self, script, event=None): + @dbus_service.command + def spell_item( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Presents the current item/word letter by letter.""" - self._item_presentation(script, event, script.targetCursorCell, 2) + tokens = [ + "FLAT REVIEW PRESENTER: spell_item. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._item_presentation(script, event, 2) return True - def phonetic_item(self, script, event=None): + @dbus_service.command + def phonetic_item( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Presents the current word letter by letter phonetically.""" - self._item_presentation(script, event, script.targetCursorCell, 3) + tokens = [ + "FLAT REVIEW PRESENTER: phonetic_item. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._item_presentation(script, event, 3) return True - def go_previous_character(self, script, event=None): + @dbus_service.command + def go_previous_character( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Moves to the previous character.""" + tokens = [ + "FLAT REVIEW PRESENTER: go_previous_character. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._context = self.get_or_create_context(script) - if self._context.goPrevious(flat_review.Context.CHAR, flat_review.Context.WRAP_LINE): + if self._context.go_previous_character(): self.present_character(script, event) - script.targetCursorCell = script.getBrailleCursorCell() return True - def present_character(self, script, event=None): + @dbus_service.command + def present_character( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Presents the current character.""" + tokens = [ + "FLAT REVIEW PRESENTER: present_character. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._character_presentation(script, event, 1) return True - def go_next_character(self, script, event=None): + @dbus_service.command + def go_next_character( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Moves to the next character.""" + tokens = [ + "FLAT REVIEW PRESENTER: go_next_character. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._context = self.get_or_create_context(script) - if self._context.goNext(flat_review.Context.CHAR, flat_review.Context.WRAP_LINE): + if self._context.go_next_character(): self.present_character(script, event) - script.targetCursorCell = script.getBrailleCursorCell() return True - def spell_character(self, script, event=None): + @dbus_service.command + def spell_character( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Presents the current character phonetically.""" + tokens = [ + "FLAT REVIEW PRESENTER: spell_character. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._character_presentation(script, event, 2) return True - def unicode_current_character(self, script, event=None): + @dbus_service.command + def unicode_current_character( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Presents the current character's unicode value.""" + tokens = [ + "FLAT REVIEW PRESENTER: unicode_current_character. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._character_presentation(script, event, 3) return True - def go_above(self, script, event=None): + @dbus_service.command + def go_above( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Moves to the character above.""" + tokens = [ + "FLAT REVIEW PRESENTER: go_above. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._context = self.get_or_create_context(script) - if self._context.goAbove(flat_review.Context.CHAR, flat_review.Context.WRAP_LINE): - self.present_item(script, event, script.targetCursorCell) + if self._context.go_up(): + self.present_item(script, event) return True - def go_below(self, script, event=None): + @dbus_service.command + def go_below( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Moves to the character below.""" + tokens = [ + "FLAT REVIEW PRESENTER: go_below. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._context = self.get_or_create_context(script) - if self._context.goBelow(flat_review.Context.CHAR, flat_review.Context.WRAP_LINE): - self.present_item(script, event, script.targetCursorCell) + if self._context.go_down(): + self.present_item(script, event) return True - def get_current_object(self, script, event=None): + @dbus_service.command + def get_current_object( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> Atspi.Accessible: """Returns the current accessible object.""" - self._context = self.get_or_create_context(script) - return self._context.getCurrentAccessible() + tokens = [ + "FLAT REVIEW PRESENTER: get_current_object. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) - def present_object(self, script, event=None): + self._context = self.get_or_create_context(script) + return self._context.get_current_object() + + @dbus_service.command + def present_object( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Presents the current accessible object.""" + tokens = [ + "FLAT REVIEW PRESENTER: present_object. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._context = self.get_or_create_context(script) if not isinstance(event, input_event.BrailleEvent): - script.presentObject(self._context.getCurrentAccessible(), speechonly=True) + script.present_object(self._context.get_current_object(), speechonly=True) - cthulhu.emitRegionChanged(self._context.getCurrentAccessible(), mode=cthulhu.FLAT_REVIEW) + focus_manager.get_manager().emit_region_changed( + self._context.get_current_object(), + mode=focus_manager.FLAT_REVIEW, + ) return True - def left_click_on_object(self, script, event=None): + @dbus_service.command + def left_click_on_object( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Attempts to synthesize a left click on the current accessible.""" - self._context = self.get_or_create_context(script) - return self._context.clickCurrent(1) - - def right_click_on_object(self, script, event=None): - """Attempts to synthesize a left click on the current accessible.""" + tokens = [ + "FLAT REVIEW PRESENTER: left_click_on_object. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) self._context = self.get_or_create_context(script) - return self._context.clickCurrent(3) + obj = self._context.get_current_object() + offset = self._context.get_current_text_offset() + if offset >= 0 and AXEventSynthesizer.click_character(obj, offset, 1): + return True + return AXEventSynthesizer.click_object(obj, 1) - def route_pointer_to_object(self, script, event=None): + @dbus_service.command + def right_click_on_object( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Attempts to synthesize a right click on the current accessible.""" + + tokens = [ + "FLAT REVIEW PRESENTER: right_click_on_object. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._context = self.get_or_create_context(script) + obj = self._context.get_current_object() + offset = self._context.get_current_text_offset() + if offset >= 0 and AXEventSynthesizer.click_character(obj, offset, 3): + return True + return AXEventSynthesizer.click_object(obj, 3) + + @dbus_service.command + def route_pointer_to_object( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Routes the mouse pointer to the current accessible.""" - self._context = self.get_or_create_context(script) - return self._context.routeToCurrent() - - def get_braille_regions(self, script, event=None): - """Returns the braille regions and region with focus being reviewed.""" + tokens = [ + "FLAT REVIEW PRESENTER: route_pointer_to_object. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) self._context = self.get_or_create_context(script) - regions, focused_region = self._context.getCurrentBrailleRegions() - return [regions, focused_region] + obj = self._context.get_current_object() + offset = self._context.get_current_text_offset() + if offset >= 0 and AXEventSynthesizer.route_to_character(obj, offset): + return True + return AXEventSynthesizer.route_to_object(obj) - def _get_all_lines(self, script, event=None): - """Returns a list of textual lines representing the contents.""" + def _update_braille(self, script: default.Script) -> None: + """Obtains the braille regions for the current flat review line and displays them.""" + + if not braille_presenter.get_presenter().use_braille(): + return - lines = [] self._context = self.get_or_create_context(script) - self._context.goBegin(flat_review.Context.WINDOW) - string = self._context.getCurrent(flat_review.Context.LINE)[0] - while string is not None: - lines.append(string.rstrip("\n")) - if not self._context.goNext(flat_review.Context.LINE, flat_review.Context.WRAP_LINE): + regions, focused_region = self._context.get_current_braille_regions() + if not regions: + braille_presenter.get_presenter().refresh_braille() + return + + braille_presenter.get_presenter().present_regions( + list(regions), + focused_region, + ) + + def pan_braille_left( + self, + script: default.Script, + _event: input_event.InputEvent | None = None, + ) -> bool: + """Pans the braille display left.""" + + self._context = self.get_or_create_context(script) + + # Try to pan left. If we couldn't (at edge), move to previous line. + if not braille_presenter.get_presenter().pan_left(): + self._context.go_to_start_of(flat_review.Context.LINE) + self._context.go_previous_character() + self._update_braille(script) + return True + + # After panning, find a region that has a zone attribute to sync flat review context. + # We do cell-by-cell panning here because not all braille regions have zones (e.g., + # buttons don't have zones, only text regions do). This finds the first region with + # a zone so we can update the flat review position accordingly. + region_info = braille.get_region_at_cell(1) + braille_region = region_info.region + offset_in_zone = region_info.offset_in_region + while braille_region and not hasattr(braille_region, "zone"): + # Cell-by-cell panning (amount=1) to find the first region with a zone attribute. + if not braille.pan_left(1): break - string = self._context.getCurrent(flat_review.Context.LINE)[0] - return lines + region_info = braille.get_region_at_cell(1) + braille_region = region_info.region + offset_in_zone = region_info.offset_in_region - def say_all(self, script, event=None): - """Speaks the contents of the entire window.""" - - for string in self._get_all_lines(script, event): - if not string.isspace(): - script.speakMessage(string, script.speechGenerator.voice(string=string)) + if braille_region and not hasattr(braille_region, "zone"): + self._context.go_to_start_of(flat_review.Context.LINE) + self._context.go_previous_character() + self._update_braille(script) + elif braille_region and hasattr(braille_region, "zone"): + self._context.set_current_zone(braille_region.zone, offset_in_zone) + braille_presenter.get_presenter().refresh_braille(pan_to_cursor=False) return True - def show_contents(self, script, event=None): + def pan_braille_right( + self, + script: default.Script, + _event: input_event.InputEvent | None = None, + ) -> bool: + """Pans the braille display right.""" + + self._context = self.get_or_create_context(script) + + # Try to pan right. If we couldn't (at edge), move to next line. + if not braille_presenter.get_presenter().pan_right(): + self._context.go_next_line() + self._update_braille(script) + return True + + # After panning, find a region that has a zone attribute to sync flat review context. + # We do cell-by-cell panning here because not all braille regions have zones (e.g., + # buttons don't have zones, only text regions do). This finds the first region with + # a zone so we can update the flat review position accordingly. + region_info = braille.get_region_at_cell(1) + braille_region = region_info.region + offset_in_zone = region_info.offset_in_region + while braille_region and not hasattr(braille_region, "zone"): + # Cell-by-cell panning (amount=1) to find the first region with a zone attribute. + if not braille.pan_right(1): + break + region_info = braille.get_region_at_cell(1) + braille_region = region_info.region + offset_in_zone = region_info.offset_in_region + + if braille_region and not hasattr(braille_region, "zone"): + self._context.go_next_line() + self._update_braille(script) + elif braille_region and hasattr(braille_region, "zone"): + self._context.set_current_zone(braille_region.zone, offset_in_zone) + braille_presenter.get_presenter().refresh_braille(pan_to_cursor=False) + + return True + + def _get_all_lines( + self, + script: default.Script, + _event: input_event.InputEvent | None = None, + ) -> tuple[list[tuple[str, Atspi.Accessible | None]], tuple[int, int, int, int]]: + """Returns a (list of (line_string, obj) tuples, current location) tuple.""" + + lines: list[tuple[str, Atspi.Accessible | None]] = [] + self._context = self.get_or_create_context(script) + location = self._context.get_current_location() + self._context.go_to_start_of(flat_review.Context.WINDOW) + string = self._context.get_current_line_string() + while string is not None: + obj = self._context.get_current_object() + lines.append((string.rstrip("\n"), obj)) + if not self._context.go_next_line(): + break + string = self._context.get_current_line_string() + return lines, location + + @dbus_service.command + def say_all( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Speaks the contents of the entire window.""" + + tokens = [ + "FLAT REVIEW PRESENTER: say_all. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + for string, obj in self._get_all_lines(script, event)[0]: + if not string.isspace(): + presentation_manager.get_manager().speak_accessible_text(obj, string) + + return True + + @dbus_service.command + def show_contents( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Displays the entire flat review contents in a text view.""" - msg = "FLAT REVIEW PRESENTER: Showing contents." - debug.printMessage(debug.LEVEL_INFO, msg, True) + tokens = [ + "FLAT REVIEW PRESENTER: show_contents. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) - text = "\n".join(self._get_all_lines(script, event)) + line_tuples, location = self._get_all_lines(script, event) + text = "\n".join(string for string, _obj in line_tuples) title = guilabels.FLAT_REVIEW_CONTENTS - self._gui = FlatReviewContextGUI(script, title, text) + self._gui = FlatReviewContextGUI(script, title, text, location) self._gui.show_gui() return True - def copy_to_clipboard(self, script, event=None): + @dbus_service.command + def copy_to_clipboard( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Copies the string just presented to the clipboard.""" + tokens = [ + "FLAT REVIEW PRESENTER: copy_to_clipboard. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if not self.is_active(): - script.presentMessage(messages.FLAT_REVIEW_NOT_IN) + if notify_user: + presentation_manager.get_manager().present_message(messages.FLAT_REVIEW_NOT_IN) return True - script.utilities.setClipboardText(self._current_contents.rstrip("\n")) - script.presentMessage(messages.FLAT_REVIEW_COPIED) + clipboard.get_presenter().set_text(self._current_contents.rstrip("\n")) + if notify_user: + presentation_manager.get_manager().present_message(messages.FLAT_REVIEW_COPIED) return True - def append_to_clipboard(self, script, event=None): + @dbus_service.command + def append_to_clipboard( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Appends the string just presented to the clipboard.""" + tokens = [ + "FLAT REVIEW PRESENTER: append_to_clipboard. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if not self.is_active(): - script.presentMessage(messages.FLAT_REVIEW_NOT_IN) + if notify_user: + presentation_manager.get_manager().present_message(messages.FLAT_REVIEW_NOT_IN) return True - script.utilities.appendTextToClipboard(self._current_contents.rstrip("\n")) - script.presentMessage(messages.FLAT_REVIEW_APPENDED) + clipboard.get_presenter().append_text(self._current_contents.rstrip("\n")) + if notify_user: + presentation_manager.get_manager().present_message(messages.FLAT_REVIEW_APPENDED) return True - def toggle_restrict(self, script, event=None): - """ Toggles the restricting of flat review to the current object. """ + @gsettings_registry.get_registry().gsetting( + key=KEY_RESTRICTED, + schema="flat-review", + gtype="b", + default=False, + summary="Restrict flat review to current object", + migration_key="flatReviewIsRestricted", + ) + @dbus_service.getter + def get_is_restricted(self) -> bool: + """Returns whether flat review is restricted to the current object.""" + + return self._get_setting(self.KEY_RESTRICTED, False) + + @dbus_service.setter + def set_is_restricted(self, value: bool) -> bool: + """Sets whether flat review is restricted to the current object.""" + + msg = f"FLAT REVIEW PRESENTER: Setting is-restricted to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, self.KEY_RESTRICTED, value + ) + return True + + @dbus_service.command + def toggle_restrict( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Toggles the restricting of flat review to the current object.""" + + tokens = [ + "FLAT REVIEW PRESENTER: toggle_restrict. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) self._restrict = not self._restrict - cthulhu.cthulhuApp.settingsManager.setSetting("flatReviewIsRestricted", self._restrict) + self.set_is_restricted(self._restrict) if self._restrict: - script.presentMessage(messages.FLAT_REVIEW_RESTRICTED) - else: - script.presentMessage(messages.FLAT_REVIEW_UNRESTRICTED) + if notify_user: + presentation_manager.get_manager().present_message(messages.FLAT_REVIEW_RESTRICTED) + elif notify_user: + presentation_manager.get_manager().present_message(messages.FLAT_REVIEW_UNRESTRICTED) if self.is_active(): # Reset the context self._context = None @@ -1053,142 +1384,195 @@ class FlatReviewPresenter: return True - def _line_presentation(self, script, event, speech_type=1): + def _line_presentation( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + speech_type: int = 1, + ) -> bool: """Presents the current line.""" self._context = self.get_or_create_context(script) - line_string = self._context.getCurrent(flat_review.Context.LINE)[0] or "" - voice = script.speechGenerator.voice(string=line_string) + line_string = self._context.get_current_line_string() if not isinstance(event, input_event.BrailleEvent): + presenter = presentation_manager.get_manager() if not line_string or line_string == "\n": - script.speakMessage(messages.BLANK) + presenter.speak_message(messages.BLANK) elif line_string.isspace(): - script.speakMessage(messages.WHITE_SPACE) + presenter.speak_message(messages.WHITE_SPACE) elif line_string.isupper() and (speech_type < 2 or speech_type > 3): - script.speakMessage(line_string, voice) + presenter.speak_accessible_text(self._context.get_current_object(), line_string) elif speech_type == 2: - script.spellCurrentItem(line_string) + presenter.spell_item(line_string) elif speech_type == 3: - script.phoneticSpellCurrentItem(line_string) + presenter.spell_phonetically(line_string) else: - line_string = script.utilities.adjustForRepeats(line_string) - script.speakMessage(line_string, voice) + presenter.speak_accessible_text(self._context.get_current_object(), line_string) - cthulhu.emitRegionChanged(self._context.getCurrentAccessible(), mode=cthulhu.FLAT_REVIEW) - script.updateBrailleReview() + focus_manager.get_manager().emit_region_changed( + self._context.get_current_object(), + mode=focus_manager.FLAT_REVIEW, + ) + self._update_braille(script) self._current_contents = line_string return True - def _item_presentation(self, script, event, target_cursor_cell=0, speech_type=1): + def _item_presentation( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + speech_type: int = 1, + ) -> bool: """Presents the current item/word.""" self._context = self.get_or_create_context(script) - word_string = self._context.getCurrent(flat_review.Context.WORD)[0] or "" - voice = script.speechGenerator.voice(string=word_string) + word_string = self._context.get_current_word_string() if not isinstance(event, input_event.BrailleEvent): + presenter = presentation_manager.get_manager() if not word_string or word_string == "\n": - script.speakMessage(messages.BLANK) + presenter.speak_message(messages.BLANK) else: - line_string = self._context.getCurrent(flat_review.Context.LINE)[0] or "" + line_string = self._context.get_current_line_string() if line_string == "\n": - script.speakMessage(messages.BLANK) + presenter.speak_message(messages.BLANK) elif word_string.isspace(): - script.speakMessage(messages.WHITE_SPACE) + presenter.speak_message(messages.WHITE_SPACE) elif word_string.isupper() and speech_type == 1: - script.speakMessage(word_string, voice) + presenter.speak_accessible_text(self._context.get_current_object(), word_string) elif speech_type == 2: - script.spellCurrentItem(word_string) + presenter.spell_item(word_string) elif speech_type == 3: - script.phoneticSpellCurrentItem(word_string) + presenter.spell_phonetically(word_string) elif speech_type == 1: - word_string = script.utilities.adjustForRepeats(word_string) - script.speakMessage(word_string, voice) + presenter.speak_accessible_text(self._context.get_current_object(), word_string) - cthulhu.emitRegionChanged(self._context.getCurrentAccessible(), mode=cthulhu.FLAT_REVIEW) - script.updateBrailleReview(target_cursor_cell) + focus_manager.get_manager().emit_region_changed( + self._context.get_current_object(), + mode=focus_manager.FLAT_REVIEW, + ) + self._update_braille(script) self._current_contents = word_string return True - def _character_presentation(self, script, event, speech_type=1): + def _character_presentation( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + speech_type: int = 1, + ) -> bool: """Presents the current character.""" - self._context = self.get_or_create_context(script) - char_string = self._context.getCurrent(flat_review.Context.CHAR)[0] or "" + focus = focus_manager.get_manager().get_locus_of_focus() + if not self._context and AXObject.supports_text(focus): + char_string = AXText.get_character_at_offset(focus)[0] + else: + self._context = self.get_or_create_context(script) + char_string = self._context.get_current_character_string() if not isinstance(event, input_event.BrailleEvent): - if not char_string: - script.speakMessage(messages.BLANK) + presenter = presentation_manager.get_manager() + if not char_string or (char_string == "\n" and speech_type != 3): + presenter.speak_message(messages.BLANK) + elif speech_type == 3: + presenter.speak_message(messages.UNICODE % f"{ord(char_string):04x}") + elif speech_type == 2: + presenter.spell_phonetically(char_string) else: - line_string = self._context.getCurrent(flat_review.Context.LINE)[0] or "" - if line_string == "\n" and speech_type != 3: - script.speakMessage(messages.BLANK) - elif speech_type == 3: - script.speakUnicodeCharacter(char_string) - elif speech_type == 2: - script.phoneticSpellCurrentItem(char_string) - else: - script.speakCharacter(char_string) + presenter.speak_character(char_string) - cthulhu.emitRegionChanged(self._context.getCurrentAccessible(), mode=cthulhu.FLAT_REVIEW) - script.updateBrailleReview() + if not self._context: + return True + + focus_manager.get_manager().emit_region_changed( + self._context.get_current_object(), + mode=focus_manager.FLAT_REVIEW, + ) + self._update_braille(script) self._current_contents = char_string return True + class FlatReviewContextGUI: """Presents the entire flat review context in a text view""" - def __init__(self, script, title, text): - self._script = script - self._gui = self._create_dialog(title, text) + def __init__( + self, + script: default.Script, + title: str, + text: str, + location: tuple[int, int, int, int], + ) -> None: + self._script: default.Script = script + self._gui: Gtk.Dialog = self._create_dialog(title, text, location) - def _create_dialog(self, title, text): + def _create_dialog( + self, + title: str, + text: str, + location: tuple[int, int, int, int], + ) -> Gtk.Dialog: """Creates the dialog.""" - dialog = Gtk.Dialog(title, - None, - Gtk.DialogFlags.MODAL, - (Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE)) + dialog = Gtk.Dialog( + title, + None, + Gtk.DialogFlags.MODAL, + (Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE), + ) dialog.set_default_size(800, 600) scrolled_window = Gtk.ScrolledWindow() scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) - - textbuffer = Gtk.TextBuffer() - textbuffer.set_text(text) - textbuffer.place_cursor(textbuffer.get_start_iter()) - textview = Gtk.TextView(buffer=textbuffer) - textview.set_wrap_mode(Gtk.WrapMode.WORD) - - scrolled_window.add(textview) scrolled_window.set_hexpand(True) scrolled_window.set_vexpand(True) + textbuffer = Gtk.TextBuffer() + textbuffer.set_text(text) + + line_index, _zone_index, word_index, char_index = location + start_iter = textbuffer.get_start_iter() + start_iter.forward_lines(line_index) + for _ in range(word_index): + start_iter.forward_word_end() + # This is needed to get past any punctuation. Note that we cannot check starts_word + # here. Example: The letter after an apostrophe is reported as a word start. + while not start_iter.get_char().isspace(): + if not start_iter.forward_char(): + break + + # The text iter word can start with one or more spaces. Move to the beginning of the flat + # review word before advancing by character. + while not start_iter.starts_word(): + if not start_iter.forward_char(): + break + + start_iter.forward_chars(char_index) + textbuffer.place_cursor(start_iter) + + textview = Gtk.TextView(buffer=textbuffer) + textview.set_wrap_mode(Gtk.WrapMode.WORD) + scrolled_window.add(textview) # pylint: disable=no-member dialog.get_content_area().pack_start(scrolled_window, True, True, 0) dialog.connect("response", self.on_response) - return dialog - def on_response(self, dialog, response): + def on_response(self, _dialog: Gtk.Dialog, response: Gtk.ResponseType) -> None: """Handler for the 'response' signal of the dialog.""" if response == Gtk.ResponseType.CLOSE: self._gui.destroy() - def show_gui(self): + def show_gui(self) -> None: """Shows the dialog.""" - self._gui.show_all() - time_stamp = cthulhu_state.lastInputEvent.timestamp - if time_stamp == 0: - time_stamp = Gtk.get_current_event_time() - self._gui.present_with_time(time_stamp) + self._gui.show_all() # pylint: disable=no-member + self._gui.present_with_time(time.time()) -_presenter = None -def getPresenter(): +_presenter: FlatReviewPresenter = FlatReviewPresenter() + + +def get_presenter() -> FlatReviewPresenter: """Returns the Flat Review Presenter""" - - global _presenter - if _presenter is None: - _presenter = FlatReviewPresenter() + return _presenter diff --git a/src/cthulhu/gsettings_registry.py b/src/cthulhu/gsettings_registry.py index c7e9e56..8ac9b95 100644 --- a/src/cthulhu/gsettings_registry.py +++ b/src/cthulhu/gsettings_registry.py @@ -22,6 +22,9 @@ from tomlkit import dumps, document, parse from . import debug, gsettings_migrator +GSETTINGS_PATH_PREFIX = "/org/gnome/cthulhu/" + + CTHULHU_LEGACY_SCHEMA_ALIASES: dict[str, tuple[str, str]] = { "aiAssistantEnabled": ("ai-assistant", "enabled"), "aiProvider": ("ai-assistant", "provider"), @@ -88,6 +91,7 @@ class GSettingsRegistry: self._mappings: dict[str, list[SettingsMapping]] = {} self._enums: dict[str, dict[str, int]] = {} self._schemas: dict[str, str] = {} + self._handles: dict[str, GSettingsSchemaHandle] = {} self._runtime_values: dict[tuple[str, str, str | None], Any] = {} self._ignore_runtime: bool = False @@ -96,6 +100,20 @@ class GSettingsRegistry: self._ignore_runtime = ignore + def _get_handle(self, schema_name: str) -> GSettingsSchemaHandle | None: + """Returns a cached schema handle for compatibility callers.""" + + if schema_name in self._handles: + return self._handles[schema_name] + + schema_id = self._schemas.get(schema_name) + if schema_id is None: + return None + + handle = GSettingsSchemaHandle(schema_id, schema_name) + self._handles[schema_name] = handle + return handle + @staticmethod def sanitize_gsettings_path(name: str) -> str: """Sanitize a name for schema/path compatibility.""" @@ -209,12 +227,32 @@ class GSettingsRegistry: ) -> Any | None: """Returns a setting value via app, profile, default-profile, or default layers.""" - del gtype, genum if not self._ignore_runtime: found, value = self.get_runtime_value(schema, key, voice_type) if found: return value + handle = self._get_handle(schema) + sub_path = "" + if handle is not None and schema == "voice": + vt = voice_type or "default" + sub_path = f"voices/{self.sanitize_gsettings_path(vt)}" + + if handle is not None: + accessors: dict[str, Callable[..., Any | None]] = { + "b": handle.get_boolean, + "s": handle.get_string, + "i": handle.get_int, + "d": handle.get_double, + "as": handle.get_strv, + "a{ss}": handle.get_dict, + "a{saas}": handle.get_dict, + } + accessor = accessors.get("s" if genum else gtype) + result = accessor(key, sub_path, app_name) if accessor is not None else None + if result is not None: + return result + profile = self.get_active_profile() effective_app = self.get_active_app() if app_name is None else app_name if effective_app: @@ -314,6 +352,53 @@ class GSettingsRegistry: return self._profile + def _rename_profile_in_document( + self, + prefs_doc, + old_profile: str, + new_profile: str, + new_label: str, + new_internal_name: str, + ) -> None: + profiles = self._table(prefs_doc, "profiles") + if old_profile == new_profile: + profile_table = self._ensure_profile(prefs_doc, new_profile, new_label) + else: + profile_table = profiles.pop(old_profile, None) + if not _is_table(profile_table): + profile_table = {} + profiles[new_profile] = profile_table + + metadata = self._table(profile_table, "metadata") + metadata["display-name"] = new_label + metadata["internal-name"] = new_internal_name + + def rename_profile(self, old_name: str, new_label: str, new_internal_name: str) -> None: + """Renames a profile in TOML-backed user and app settings.""" + + old_profile = self.sanitize_gsettings_path(old_name) + new_profile = self.sanitize_gsettings_path(new_internal_name) + + paths = [self._settings_path()] + if self._prefs_dir is not None: + app_settings_dir = self._prefs_dir / "app-settings" + if app_settings_dir.exists(): + paths.extend(sorted(app_settings_dir.glob("*.toml"))) + + for path in paths: + prefs_doc = self._read_document(path) + self._rename_profile_in_document( + prefs_doc, + old_profile, + new_profile, + new_label, + new_internal_name, + ) + self._write_document(path, prefs_doc) + + if self._profile == old_profile: + self._profile = new_profile + def gsetting( self, key: str, @@ -537,6 +622,85 @@ class GSettingsRegistry: return True +class GSettingsSchemaHandle: + """Compatibility handle for Orca 50 layered GSettings lookups.""" + + def __init__(self, schema_id: str, path_suffix: str) -> None: + self._schema_id = schema_id + self._path_suffix = path_suffix + + def get_schema_id(self) -> str: + """Returns the schema ID for this handle.""" + + return self._schema_id + + def get_boolean( + self, + key: str, + sub_path: str = "", + app_name: str | None = None, + ) -> bool | None: + """Returns a boolean value, or None when no GSettings backend is active.""" + + del key, sub_path, app_name + return None + + def get_string( + self, + key: str, + sub_path: str = "", + app_name: str | None = None, + ) -> str | None: + """Returns a string value, or None when no GSettings backend is active.""" + + del key, sub_path, app_name + return None + + def get_int( + self, + key: str, + sub_path: str = "", + app_name: str | None = None, + ) -> int | None: + """Returns an integer value, or None when no GSettings backend is active.""" + + del key, sub_path, app_name + return None + + def get_double( + self, + key: str, + sub_path: str = "", + app_name: str | None = None, + ) -> float | None: + """Returns a float value, or None when no GSettings backend is active.""" + + del key, sub_path, app_name + return None + + def get_strv( + self, + key: str, + sub_path: str = "", + app_name: str | None = None, + ) -> list[str] | None: + """Returns a string array, or None when no GSettings backend is active.""" + + del key, sub_path, app_name + return None + + def get_dict( + self, + key: str, + sub_path: str = "", + app_name: str | None = None, + ) -> dict | None: + """Returns a dictionary, or None when no GSettings backend is active.""" + + del key, sub_path, app_name + return None + + _registry: GSettingsRegistry = GSettingsRegistry() diff --git a/src/cthulhu/guilabels.py b/src/cthulhu/guilabels.py index 5946025..e909a0f 100644 --- a/src/cthulhu/guilabels.py +++ b/src/cthulhu/guilabels.py @@ -1,9 +1,7 @@ -#!/usr/bin/env python3 +# Cthulhu # -# Copyright (c) 2024 Stormux -# Copyright (c) 2010-2012 The Orca Team -# Copyright (c) 2012 Igalia, S.L. -# Copyright (c) 2005-2010 Sun Microsystems Inc. +# Copyright 2004-2009 Sun Microsystems Inc. +# Copyright 2010-2013 The Cthulhu Team # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -19,22 +17,25 @@ # 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 + +# pylint: disable=too-many-lines """Labels for Cthulhu's GUIs. These have been put in their own module so that we can present them in the correct language when users change the language on the fly without having to reload a bunch of modules.""" -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2004-2009 Sun Microsystems Inc." \ - "Copyright (c) 2010-2013 The Cthulhu Team" -__license__ = "LGPL" +from .cthulhu_i18n import C_, _, ngettext # pylint: disable=import-error -from .cthulhu_i18n import _, C_, ngettext +OCR = _("OCR") +OCR_LANGUAGE_CODE = _("OCR _language code:") +OCR_SCALE_FACTOR = _("Image _scale factor:") +OCR_GRAYSCALE_IMAGE = _("_Grayscale image") +OCR_INVERT_IMAGE = _("_Invert image") +OCR_BLACK_WHITE_IMAGE = _("_Black and white image") +OCR_BLACK_WHITE_THRESHOLD = _("Black and white _threshold:") +OCR_COLOR_CALCULATION = _("Use color _calculation") +OCR_COLOR_CALCULATION_MAX = _("Maximum _colors to report:") +OCR_COPY_TO_CLIPBOARD = _("_Copy recognized text to clipboard") # Translators: This string appears on a button in a dialog. "Activating" the # selected item will perform the action that one would expect to occur if the @@ -44,74 +45,103 @@ from .cthulhu_i18n import _, C_, ngettext # it to show all of its contents. And so on. ACTIVATE = _("_Activate") -# Translators: Cthulhu has a number of commands that override the default behavior -# within an application. For instance, on a web page Cthulhu's Structural Navigation -# command "h" moves you to the next heading. What should happen when you press -# "h" in an entry on a web page depends: If you want to resume reading content, -# "h" should move to the next heading; if you want to enter text, "h" should not -# move you to the next heading. Because Cthulhu doesn't know what you want to do, -# it has two modes: In browse mode, Cthulhu treats key presses as commands to read -# the content; in focus mode, Cthulhu treats key presses as something that should be -# handled by the focused widget. Cthulhu optionally can attempt to detect which mode -# is appropriate for the current situation and switch automatically. This string -# is a label for a GUI option to enable such automatic switching when structural -# navigation commands are used. As an example, if this setting were enabled, -# pressing "e" to move to the next entry would move focus there and also turn -# focus mode on so that the next press of "e" would type an "e" into the entry. -# If this setting is not enabled, the second press of "e" would continue to be -# a navigation command to move amongst entries. -AUTO_FOCUS_MODE_STRUCT_NAV = _("Automatic focus mode during structural navigation") +# Translators: Some users want to hear additional information when entering +# different types of content. If this checkbox is checked, Cthulhu will announce +# that a blockquote has been entered before speaking the text. At the end of +# the text, Cthulhu will announce that the blockquote is being exited. +ANNOUNCE_BLOCKQUOTES = C_("Context", "Blockquotes") -# Translators: Cthulhu has a number of commands that override the default behavior -# within an application. For instance, if you are at the bottom of an entry and -# press Down arrow, should you leave the entry? It depends on if you want to -# resume reading content or if you are editing the text in the entry. Because -# Cthulhu doesn't know what you want to do, it has two modes: In browse mode, Cthulhu -# treats key presses as commands to read the content; in focus mode, Cthulhu treats -# key presses as something that should be handled by the focused widget. Cthulhu -# optionally can attempt to detect which mode is appropriate for the current -# situation and switch automatically. This string is a label for a GUI option to -# enable such automatic switching when caret navigation commands are used. As an -# example, if this setting were enabled, pressing Down Arrow would allow you to -# move into an entry but once you had done so, Cthulhu would switch to Focus mode -# and subsequent presses of Down Arrow would be controlled by the web browser -# and not by Cthulhu. If this setting is not enabled, Cthulhu would continue to control -# what happens when you press an arrow key, thus making it possible to arrow out -# of the entry. -AUTO_FOCUS_MODE_CARET_NAV = _("Automatic focus mode during caret navigation") +# Translators: Some users want to hear additional information when entering +# different types of content. If this checkbox is checked, Cthulhu will announce +# that a form has been entered before speaking the contents of that form. At +# the end of the form, Cthulhu will announce that the form is being exited. +ANNOUNCE_FORMS = C_("Context", "Forms") -# Translators: Cthulhu has a number of commands that override the default behavior -# within an application. For instance, if you are at the bottom of an entry and -# press Down arrow, should you leave the entry? It depends on if you want to -# resume reading content or if you are editing the text in the entry. Because -# Cthulhu doesn't know what you want to do, it has two modes: In browse mode, Cthulhu -# treats key presses as commands to read the content; in focus mode, Cthulhu treats -# key presses as something that should be handled by the focused widget. Cthulhu -# optionally can attempt to detect which mode is appropriate for the current -# situation and switch automatically. This string is a label for a GUI option to -# enable such automatic switching when native navigation commands are used. -# Here "native" means "not Cthulhu"; it could be a browser navigation command such -# as the Tab key, or it might be a web page behavior, such as the search field -# automatically gaining focus when the page loads. -AUTO_FOCUS_MODE_NATIVE_NAV = _("Automatic focus mode during native navigation") +# Translators: Some users want to hear additional information when entering +# different types of content. If this checkbox is checked, Cthulhu will announce +# when an ARIA landmark has been entered or exited. ARIA landmarks are the W3C +# defined HTML tag attribute 'role' used to identify important part of webpage +# like banners, main context, search, etc. +ANNOUNCE_LANDMARKS = C_("Context", "Landmarks") + +# Translators: Some users want to hear additional information when entering +# different types of content. If this checkbox is checked, Cthulhu will announce +# that a list with x items has been entered before speaking the content of that +# list. At the end of the list content, Cthulhu will announce that the list is +# being exited. +ANNOUNCE_LISTS = C_("Context", "Lists") + +# Translators: Some users want to hear additional information when entering +# different types of content. If this checkbox is checked, Cthulhu will announce +# that a panel has been entered before speaking the new location. At the end of +# the panel contents, Cthulhu will announce that the panel is being exited. A +# panel is a generic container of objects, such as a group of related form +# fields. +ANNOUNCE_PANELS = C_("Context", "Panels") + +# Translators: Some users want to hear additional information when entering +# different types of content. If this checkbox is checked, Cthulhu will announce +# that a table with x rows and y columns has been entered before speaking the +# content of that table. At the end of the table content, Cthulhu will announce +# that the table is being exited. +ANNOUNCE_TABLES = C_("Context", "Tables") + +# Translators: This is a shorter version of "Automatic focus mode during caret/structural +# navigation" used on sub-pages where the navigation type context is already clear. +AUTOMATIC_FOCUS_MODE = _("Automatic focus mode") + +# Translators: This explains what the "Automatic focus mode" setting does. It appears +# in info boxes on the Caret Navigation and Structural Navigation preferences pages. +# Please translate "Automatic focus mode" consistently with the AUTOMATIC_FOCUS_MODE +# string above. +AUTOMATIC_FOCUS_MODE_INFO = _( + "Automatic focus mode causes Cthulhu to switch to focus mode when you navigate " + "into a form field or other interactive widget.", +) + +# Translators: This is an informational message on the Native Navigation +# preferences page explaining what native navigation means. +NATIVE_NAVIGATION_INFO = _( + "Native navigation refers to keyboard commands handled by the application, " + "such as Tab, Page Up, Page Down, and Enter.", +) + +# Translators: This setting controls whether Cthulhu automatically enables sticky +# focus mode for web applications (such as Facebook Messenger) and Electron apps +# (such as Visual Studio Code). When enabled, Cthulhu will detect these applications +# and use focus mode by default. Please translate this string using language +# consistent with `MODE_FOCUS_IS_STICKY = _("Focus mode is sticky.")` in +# messages.py. +AUTO_STICKY_FOCUS_MODE = _("Automatic sticky focus mode for web applications") + +# Translators: This explains what the "Automatic sticky focus mode for web +# applications" setting does. +AUTO_STICKY_FOCUS_MODE_INFO = _( + "Automatic sticky focus mode causes Cthulhu to detect web applications and " + "Electron apps and stay in focus mode until toggled off by command.", +) # Translators: A single braille cell on a refreshable braille display consists -# of 8 dots. Dot 7 is the dot in the bottom left corner. If the user selects +# of 8 dots. Dot 7 is the dot in the bottom left corner. If the user selects # this option, Dot 7 will be used to 'underline' text of interest, e.g. when # "marking"/indicating that a given word is bold. -BRAILLE_DOT_7 = _("Dot _7") +BRAILLE_DOT_7 = _("Dot 7") # Translators: A single braille cell on a refreshable braille display consists # of 8 dots. Dot 8 is the dot in the bottom right corner. If the user selects # this option, Dot 8 will be used to 'underline' text of interest, e.g. when # "marking"/indicating that a given word is bold. -BRAILLE_DOT_8 = _("Dot _8") +BRAILLE_DOT_8 = _("Dot 8") # Translators: A single braille cell on a refreshable braille display consists # of 8 dots. Dots 7-8 are the dots at the bottom. If the user selects this # option, Dots 7-8 will be used to 'underline' text of interest, e.g. when # "marking"/indicating that a given word is bold. -BRAILLE_DOT_7_8 = _("Dots 7 an_d 8") +BRAILLE_DOT_7_8 = _("Dots 7 and 8") + +# Translators: This option refers to the dot or dots in braille which will be +# used to underline certain characters. +BRAILLE_DOT_NONE = C_("braille dots", "None") # Translators: This is the label for a button in a dialog. BTN_CANCEL = _("_Cancel") @@ -122,6 +152,61 @@ BTN_JUMP_TO = _("_Jump to") # Translators: This is the label for a button in a dialog. BTN_OK = _("_OK") +# Translators: This is the label for a button in a dialog. +BTN_SAVE = _("Save") + +# Translators: This is the label for a button in a dialog. +BTN_CLOSE_WITHOUT_SAVING = _("Close without saving") + +# Translators: This is the format for a menu item that applies settings to the +# specified profile. The %s will be replaced with the profile name. +MENU_APPLY_PROFILE = _("Apply to %s") + +# Translators: This is the format for a menu item that saves settings to the +# specified profile and closes the dialog. The %s will be replaced with the +# profile name. +MENU_SAVE_PROFILE = _("Save to %s and close") + +# Translators: This is the label for a menu item to remove a profile. +MENU_REMOVE_PROFILE = _("Remove Profile...") + +# Translators: This is the label for a menu item to rename a profile. +MENU_RENAME = _("Rename...") + +# Translators: This is the accessible name for the main menu button in the +# preferences dialog. The menu contains actions like Help, Apply, Save, and Cancel. +MENU_BUTTON_OPTIONS = _("Options") + +# Translators: This is the primary message in a dialog asking the user to confirm +# closing the preferences window without saving changes. +PREFERENCES_CLOSE_WITHOUT_SAVE = _("Save Changes?") + +# Translators: This is the secondary message in a dialog asking the user to confirm +# closing the preferences window without saving changes. +PREFERENCES_CHANGES_WILL_BE_LOST = _("Your changes will be lost if you don't save them.") + +# Translators: Profiles in Cthulhu make it possible for users to quickly switch +# amongst a group of pre-defined settings (e.g. an 'English' profile for reading +# text written in English using an English-language speech synthesizer and +# braille rules, and a similar 'Spanish' profile for reading Spanish text. +# This message appears in a dialog when the user closes the preferences +# after switching to a different profile. +PREFERENCES_PROFILE_SWITCHED = _("You switched profiles.") + +# Translators: Profiles in Cthulhu make it possible for users to quickly switch +# amongst a group of pre-defined settings (e.g. an 'English' profile for reading +# text written in English using an English-language speech synthesizer and +# braille rules, and a similar 'Spanish' profile for reading Spanish text. +# This is a button label in the profile switch dialog. %s is the profile name. +PROFILE_USE = C_("profile", "Use %s") + +# Translators: Profiles in Cthulhu make it possible for users to quickly switch +# amongst a group of pre-defined settings (e.g. an 'English' profile for reading +# text written in English using an English-language speech synthesizer and +# braille rules, and a similar 'Spanish' profile for reading Spanish text. +# This is a button label in the profile switch dialog. %s is the profile name. +PROFILE_SWITCH_BACK_TO = _("Switch back to %s") + # Translators: Cthulhu uses Speech Dispatcher to present content to users via # text-to-speech. Speech Dispatcher has a feature to control how capital # letters are presented: Do nothing at all, say the word 'capital' prior to @@ -146,11 +231,11 @@ CAPITALIZATION_STYLE_NONE = C_("capitalization style", "None") # string to be translated appears as a combo box item in Cthulhu's Preferences. CAPITALIZATION_STYLE_SPELL = C_("capitalization style", "Spell") -# Translators: If this checkbox is checked, then Cthulhu will tell you when one of +# Translators: If this widget is enabled, then Cthulhu will tell you when one of # your buddies is typing a message. CHAT_ANNOUNCE_BUDDY_TYPING = _("Announce when your _buddies are typing") -# Translators: If this checkbox is checked, then Cthulhu will provide the user with +# Translators: If this widget is enabled, then Cthulhu will provide the user with # chat room specific message histories rather than just a single history which # contains the latest messages from all the chat rooms that they are in. CHAT_SEPARATE_MESSAGE_HISTORIES = _("Provide chat room specific _message histories") @@ -158,28 +243,41 @@ CHAT_SEPARATE_MESSAGE_HISTORIES = _("Provide chat room specific _message histori # Translators: This is the label of a panel holding options for how messages in # this application's chat rooms should be spoken. The options are: Speak messages # from all channels (i.e. even if the chat application doesn't have focus); speak -# messages from a channel only if it is the active channel; speak messages from -# any channel, but only if the chat application has focus. +# messages from the active channel regardless of application focus; speak messages +# from all channels but only if the chat application has focus; speak messages from +# the active channel only if the chat application has focus. CHAT_SPEAK_MESSAGES_FROM = _("Speak messages from") -# Translators: This is the label of a radio button. If it is selected, Cthulhu will -# speak all new chat messages as they appear irrespective of whether or not the -# chat application currently has focus. This is the default behaviour. -CHAT_SPEAK_MESSAGES_ALL = _("All cha_nnels") +# Translators: This is the label of a widget in the preferences dialog. If it is +# selected, Cthulhu will speak all new chat messages as they appear irrespective of +# whether or not the chat application currently has focus. This is the default +# behavior. +CHAT_SPEAK_MESSAGES_ALL = _("All channels when using any application") -# Translators: This is the label of a radio button. If it is selected, Cthulhu will -# speak all new chat messages as they appear if and only if the chat application -# has focus. The string substitution is for the application name (e.g Pidgin). -CHAT_SPEAK_MESSAGES_ALL_IF_FOCUSED = _("All channels when an_y %s window is active") +# Translators: This is the label of a widget in the preferences dialog. If it is +# selected, Cthulhu will only speak new chat messages for the currently active channel +# (the selected tab or focused chat window), regardless of whether or not the chat +# application currently has focus. +CHAT_SPEAK_MESSAGES_ACTIVE_CHANNEL = _("The active channel when using any application") -# Translators: This is the label of a radio button. If it is selected, Cthulhu will -# only speak new chat messages for the currently active channel, irrespective of -# whether the chat application has focus. -CHAT_SPEAK_MESSAGES_ACTIVE = _("A channel only if its _window is active") +# Translators: This is the label of a widget in the preferences dialog. If it is +# selected, Cthulhu will speak all new chat messages as they appear if and only if the +# chat application has focus. +CHAT_SPEAK_MESSAGES_ALL_IF_FOCUSED = _("All channels when using the application") -# Translators: If this checkbox is checked, then Cthulhu will speak the name of the +# Translators: This is the label of a widget in the preferences dialog. If it is +# selected, Cthulhu will only speak new chat messages for the currently active channel +# (the selected tab or focused chat window) when the chat application has focus. +CHAT_SPEAK_MESSAGES_ACTIVE = _("The active channel when using the application") + +# Translators: If this widget is enabled, then Cthulhu will speak the name of the # chat room prior to presenting an incoming message. -CHAT_SPEAK_ROOM_NAME = _("_Speak Chat Room name") +CHAT_SPEAK_ROOM_NAME = _("_Speak room name") + +# Translators: This setting controls when the chat room name is spoken relative +# to the message content. When enabled, the room name is spoken after the message. +# When disabled, the room name is spoken before the message. +CHAT_SPEAK_ROOM_NAME_LAST = _("Speak room name last") # Translators: When presenting the content of a line on a web page, Cthulhu by # default presents the full line, including any links or form fields on that @@ -191,17 +289,18 @@ CHAT_SPEAK_ROOM_NAME = _("_Speak Chat Room name") # is enabled, Cthulhu will present the full line as it appears on the screen; if # it is disabled, Cthulhu will treat each object as if it were on a separate line, # both for presentation and navigation. -CONTENT_LAYOUT_MODE = _("Enable layout mode for content") +CONTENT_LAYOUT_MODE = _("Layout mode") -# Translators: Cthulhu's keybindings support double and triple "clicks" or key -# presses, similar to using a mouse. This string appears in Cthulhu's preferences -# dialog after a keybinding which requires a double click. -CLICK_COUNT_DOUBLE = _("double click") +# Translators: This is an informational message on the Caret Navigation preferences +# page explaining what layout mode is. When translating "layout mode", please use +# terminology consistent with that of `CONTENT_LAYOUT_MODE = _("Layout mode")` +# above. -# Translators: Cthulhu's keybindings support double and triple "clicks" or key -# presses, similar to using a mouse. This string appears in Cthulhu's preferences -# dialog after a keybinding which requires a triple click. -CLICK_COUNT_TRIPLE = _("triple click") +LAYOUT_MODE_INFO = _( + "If layout mode is enabled, Cthulhu will present the full line as it " + "appears on the screen. Otherwise, Cthulhu will treat each object as if " + "it were on a separate line.", +) # Translators: This is a label which will appear in the list of available speech # engines as a special item. It refers to the default engine configured within @@ -209,6 +308,20 @@ CLICK_COUNT_TRIPLE = _("triple click") # select a particular speech engine by its real name (Festival, IBMTTS, etc.) DEFAULT_SYNTHESIZER = _("Default Synthesizer") +# Translators: The pronunciation dictionary allows the user to correct words +# which the speech synthesizer mispronounces (e.g. a person's name, a technical +# word) or doesn't pronounce as the user desires (e.g. an acronym) by providing +# an alternative string. For instance "idk" can be sent to the speech server +# as "I don't know" or "I D K" or "eye dee kay" or whatever causes the user's +# speech synthesizer to say what the user finds most helpful. +PRONUNCIATION_DICTIONARY = _("Pronunciation Dictionary") + +# Translators: This is an informational message on the Pronunciation Dictionary +# preferences page explaining what the dictionary does. +PRONUNCIATION_DICTIONARY_INFO = _( + "Customize how words are spoken. Add a word and its replacement below.", +) + # Translators: This is a label for a column header in Cthulhu's pronunciation # dictionary. The pronunciation dictionary allows the user to correct words # which the speech synthesizer mispronounces (e.g. a person's name, a technical @@ -226,23 +339,99 @@ DICTIONARY_ACTUAL_STRING = _("Actual String") # Example: "L O L" or "Laughing Out Loud" (for Actual String "LOL"). DICTIONARY_REPLACEMENT_STRING = _("Replacement String") -# Translators: Cthulhu has an "echo" feature to present text as it is being written -# by the user. While Cthulhu's "key echo" options present the actual keyboard keys -# being pressed, "character echo" presents the character/string of length 1 that -# is inserted as a result of the keypress. -ECHO_CHARACTER = _("Enable echo by cha_racter") +# Translators: The pronunciation dictionary allows the user to correct words +# which the speech synthesizer mispronounces (e.g. a person's name, a technical +# word) or doesn't pronounce as the user desires (e.g. an acronym) by providing +# an alternative string. For instance "idk" can be sent to the speech server +# as "I don't know" or "I D K" or "eye dee kay" or whatever causes the user's +# speech synthesizer to say what the user finds most helpful. This string is +# the label for the widget to add a new entry. +DICTIONARY_NEW_ENTRY = _("New entry") -# Translators: Cthulhu has an "echo" feature to present text as it is being written -# by the user. This string refers to a "key echo" option. When this option is -# enabled, dead keys will be announced when pressed. -ECHO_DIACRITICAL = _("Enable non-spacing _diacritical keys") +# Translators: The pronunciation dictionary allows the user to correct words +# which the speech synthesizer mispronounces (e.g. a person's name, a technical +# word) or doesn't pronounce as the user desires (e.g. an acronym) by providing +# an alternative string. For instance "idk" can be sent to the speech server +# as "I don't know" or "I D K" or "eye dee kay" or whatever causes the user's +# speech synthesizer to say what the user finds most helpful. This string is +# the label for the widget to delete the selected entry. +DICTIONARY_DELETE = _("Delete") + +# Translators: The pronunciation dictionary allows the user to correct words +# which the speech synthesizer mispronounces (e.g. a person's name, a technical +# word) or doesn't pronounce as the user desires (e.g. an acronym) by providing +# an alternative string. This message is displayed when the pronunciation +# dictionary is empty. +DICTIONARY_EMPTY = _("No pronunciation entries") + +# Translators: Title for the dialog to add a new pronunciation entry. +ADD_NEW_PRONUNCIATION = _("Add New Pronunciation") + +# Translators: Title for the dialog to edit an existing pronunciation entry. +EDIT_PRONUNCIATION = _("Edit Pronunciation") + +# Translators: Cthulhu has a "find" feature which allows the user to search the +# active application for on screen text and widgets. This string is the title +# of the dialog box. +FIND_DIALOG_TITLE = _("Find") +KB_GROUP_FIND = FIND_DIALOG_TITLE # Translators: Cthulhu has a "find" feature which allows the user to search the # active application for on screen text and widgets. This label is associated -# with the setting to begin the search from the current location rather than -# from the top of the screen. +# with the text entry where the user types the term to search for. +FIND_SEARCH_FOR = _("_Search for:") + +# Translators: Cthulhu has a "find" feature which allows the user to search the +# active application for on screen text and widgets. This label is associated +# with a group of options related to where the search should begin. The options +# are to begin the search from the current location or from the top of the window. +FIND_START_FROM = _("Start from:") + +# Translators: Cthulhu has a "find" feature which allows the user to search the +# active application for on screen text and widgets. This label is associated +# with the radio button to begin the search from the current location rather +# than from the top of the window. FIND_START_AT_CURRENT_LOCATION = _("C_urrent location") +# Translators: Cthulhu has a "find" feature which allows the user to search the +# active application for on screen text and widgets. This label is associated +# with the radio button to begin the search from the top of the window rather +# than the current location. +FIND_START_AT_TOP_OF_WINDOW = _("_Top of window") + +# Translators: Cthulhu has a "find" feature which allows the user to search the +# active application for on screen text and widgets. This label is associated +# with a group of options related to the direction of the search. The options +# are to search backwards and to wrap. +FIND_SEARCH_DIRECTION = _("Search direction:") + +# Translators: Cthulhu has a "find" feature which allows the user to search the +# active application for on screen text and widgets. This label is associated +# with the widget to perform the search in the reverse direction. +FIND_SEARCH_BACKWARDS = _("Search _backwards") + +# Translators: Cthulhu has a "find" feature which allows the user to search the +# active application for on screen text and widgets. This label is associated +# with the widget to wrap around when the top/bottom of the window has been +# reached. +FIND_WRAP_AROUND = _("_Wrap around") + +# Translators: Cthulhu has a "find" feature which allows the user to search the +# active application for on screen text and widgets. This label is associated +# with a group of options related to what constitutes a match. The options are +# to match case and to match the entire word only. +FIND_MATCH_OPTIONS = _("Match options:") + +# Translators: Cthulhu has a "find" feature which allows the user to search the +# active application for on screen text and widgets. This label is associated +# with the widget to make the search case-sensitive. +FIND_MATCH_CASE = _("_Match case") + +# Translators: Cthulhu has a "find" feature which allows the user to search the +# active application for on screen text and widgets. This label is associated +# with the widget to only match if the full word consists of the search term. +FIND_MATCH_ENTIRE_WORD = _("Match _entire word only") + # Translators: This is the label for a spinbutton. This option allows the user # to specify the number of matched characters that must be present before Cthulhu # speaks the line that contains the results from an application's Find toolbar. @@ -252,13 +441,13 @@ FIND_MINIMUM_MATCH_LENGTH = _("Minimum length of matched text:") # presents when the user is in the Find toolbar of an application, e.g. Firefox. FIND_OPTIONS = _("Find Options") -# Translators: This is the label for a checkbox. This option controls whether +# Translators: This is the label for a widget. This option controls whether # the line that contains the match from an application's Find toolbar should # always be spoken, or only spoken if it is a different line than the line # which contained the last match. FIND_ONLY_SPEAK_CHANGED_LINES = _("Onl_y speak changed lines during find") -# Translators: This is the label for a checkbox. This option controls whether or +# Translators: This is the label for a widget. This option controls whether or # not Cthulhu will automatically speak the line that contains the match while the # user is performing a search from the Find toolbar of an application, e.g. # Firefox. @@ -279,6 +468,10 @@ KB_HEADER_KEY_BINDING = _("Key Binding") # to, for instance, web browsing. KB_GROUP_DEFAULT = C_("keybindings", "Default") +# Translators: This string is a label for the group of Cthulhu commands which +# are related to debugging. +KB_GROUP_DEBUGGING_TOOLS = C_("keybindings", "Debugging Tools") + # Translators: This string is a label for the group of Cthulhu commands which # are related to its "learn mode". Please use the same translation as done # in cmdnames.py @@ -288,20 +481,50 @@ KB_GROUP_LEARN_MODE = C_("keybindings", "Learn mode") # are related to presenting and performing the accessible actions associated # with the current object. KB_GROUP_ACTIONS = _("Actions") - -# Translators: An external braille device has buttons on it that permit the -# user to create input gestures from the braille device. The braille bindings -# are what determine the actions Cthulhu will take when the user presses these -# buttons. -KB_GROUP_BRAILLE = _("Braille Bindings") +ACTIONS_LIST = KB_GROUP_ACTIONS # Translators: This string is a label for the group of Cthulhu commands which -# are related to saving and jumping among objects via "bookmarks". -KB_GROUP_BOOKMARKS = _("Bookmarks") +# are related to documents. +KB_GROUP_DOCUMENTS = _("Documents") # Translators: This string is a label for the group of Cthulhu commands which -# are related to presenting the date and time. -KB_GROUP_DATE_AND_TIME = _("Date and time") +# are related to caret navigation, such as moving by character, word, and line. +# These commands are enabled by default for web content and can be optionally +# toggled on in other applications. +KB_GROUP_CARET_NAVIGATION = _("Caret navigation") + +# Translators: This is an informational message on the Caret Navigation +# preferences page explaining what caret navigation does. +CARET_NAVIGATION_INFO = _( + "When enabled, Cthulhu handles the arrow keys, Home, and End to move through " + "content by character, word, or line.", +) + +# Translators: This string is a label for the group of Cthulhu commands which +# are related to chat applications such as instant messaging. +KB_GROUP_CHAT = _("Chat") + +# Translators: This string is a label for the group of Cthulhu commands which +# are related to the clipboard. +KB_GROUP_CLIPBOARD = _("Clipboard") + +# Translators: A live region is an area of a web page that is periodically +# updated, e.g. stock ticker. https://w3c.github.io/aria/#dfn-live-region +KB_GROUP_LIVE_REGIONS = _("Live regions") + +# Translators: Cthulhu has a sleep mode which causes Cthulhu to essentially behave as +# if it were not running for a given application. Some use cases include self- +# voicing apps with associated commands (e.g. ChromeVox) and VMs. In the former +# case, the self-voicing app is expected to provide all needed commands as well +# as speech and braille. In the latter case, we want to ensure that Cthulhu's +# commands and speech/braille do not interfere with that of the VM and any +# screen reader being used in that VM. Thus when an application is being used +# in sleep mode, nearly all Cthulhu commands become unbound/free, and nothing is +# spoken or brailled. But if the user toggles sleep mode off or switches to +# another application window, Cthulhu commands, speech, and braille immediately +# resume working. This string is a label for the group of Cthulhu commands which +# are related to sleep mode. +KB_GROUP_SLEEP_MODE = _("Sleep mode") # Translators: This string is a label for the group of Cthulhu commands which # are related to presenting the object under the mouse pointer in speech @@ -313,6 +536,49 @@ KB_GROUP_MOUSE_REVIEW = _("Mouse review") # are related to object navigation. KB_GROUP_OBJECT_NAVIGATION = _("Object navigation") +# Translators: This string is a label for a group of Cthulhu commands which are +# related to presenting information about the system, such as date, time, +# battery status, CPU status, etc. +KB_GROUP_SYSTEM_INFORMATION = _("System information") + +# Translators: This string is a label for the group of Cthulhu commands which +# are related to structural navigation, such as moving to the next heading, +# paragraph, form field, etc. in a given direction. +KB_GROUP_STRUCTURAL_NAVIGATION = _("Structural navigation") + +# Translators: This is an informational message on the Structural Navigation +# preferences page explaining what structural navigation does. +STRUCTURAL_NAVIGATION_INFO = _( + "When enabled, alphanumeric keys can be used to quickly jump between " + "elements. For example, H moves to the next heading, K to the next link. " + "Add Shift to move backwards.", +) + +# Translators: This is a label for a switch that controls whether structural +# navigation wraps around when reaching the top or bottom of a document. +STRUCTURAL_NAVIGATION_WRAP_AROUND = _("Wrap around") + +# Translators: This is a label for a spin button that sets the minimum number +# of characters for an object to be considered "large" during structural +# navigation to large objects. +STRUCTURAL_NAVIGATION_LARGE_OBJECT_LENGTH = _("Large object length") + +# Translators: This is an informational message on the Structural Navigation +# preferences page explaining what the "Large object length" setting does. +# When translating "large object", please use terminology consistent with that of +# `STRUCTURAL_NAVIGATION_LARGE_OBJECT_LENGTH = _("Large object length")` above. +# See also cmdnames.py's `LARGE_OBJECT_NEXT = _("Go to next large object")` +# and `LARGE_OBJECT_PREVIOUS = _("Go to previous large object")`. +LARGE_OBJECT_INFO = _( + "Large object length defines the minimum number of characters an element " + "must have to be included when using the large object navigation shortcuts.", +) + +# Translators: This string is a label for the group of Cthulhu commands which +# are related to table navigation, such as moving to the next cell in a +# given direction. +KB_GROUP_TABLE_NAVIGATION = _("Table navigation") + # Translators: This string is a label for the group of Cthulhu commands which # are related to presenting information about the current location, such as # the title, status bar, and default button of the current window; the @@ -320,10 +586,6 @@ KB_GROUP_OBJECT_NAVIGATION = _("Object navigation") # text in the currently-focused object; etc. KB_GROUP_WHERE_AM_I = _("Object details") -# Translators: This string is a label for the group of Cthulhu commands which -# do not currently have an associated key binding. -KB_GROUP_UNBOUND = _("Unbound") - # Translators: This string is a label for the group of Cthulhu commands which # are related to Cthulhu's "flat review" feature. This feature allows the blind # user to explore the text in a window in a 2D fashion. That is, Cthulhu treats @@ -351,14 +613,21 @@ KB_GROUP_SPEECH_VERBOSITY = _("Speech and verbosity") # and copy the text. This string is the title of the window with the text view. FLAT_REVIEW_CONTENTS = _("Flat review contents") -# Translators: Modified is a table column header in Cthulhu's preferences dialog. -# This column contains a checkbox which indicates whether a key binding -# for an Cthulhu command has been changed by the user to something other than its -# default value. -KB_MODIFIED = C_("keybindings", "Modified") +# Translators: This is a label for the setting which determines if Cthulhu will +# use the "desktop" or "laptop" keyboard layout. The desktop layout is +# intended for use with a full-size keyboard, while the laptop layout is +# intended for use with a laptop-style keyboard. +KEYBOARD_LAYOUT = _("Keyboard Layout") # Translators: This label refers to the keyboard layout (desktop or laptop). -KEYBOARD_LAYOUT_DESKTOP = _("_Desktop") +# The desktop layout is intended for use with a full-size keyboard, while the +# laptop layout is intended for use with a laptop-style keyboard. +KEYBOARD_LAYOUT_DESKTOP = _("Desktop") + +# Translators: This label refers to the keyboard layout (desktop or laptop). +# The desktop layout is intended for use with a full-size keyboard, while the +# laptop layout is intended for use with a laptop-style keyboard. +KEYBOARD_LAYOUT_LAPTOP = _("Laptop") # Translators: Cthulhu has a feature to list all of the notification messages # received, similar to the functionality gnome-shell provides when you press @@ -376,22 +645,6 @@ NOTIFICATIONS_COLUMN_HEADER = C_("notification presenter", "Notifications") # for the time, which will be relative (e.g. "10 minutes ago") or absolute. NOTIFICATIONS_RECEIVED_TIME = C_("notification presenter", "Received") -# Translators: This is the label for a button which dismisses the selected live -# notification from mako's queue. -NOTIFICATIONS_DISMISS_BUTTON = C_("notification presenter", "Dismiss Notification") - -# Translators: This is the label for a button which opens the list of actions -# for the selected live notification. -NOTIFICATIONS_ACTIONS_BUTTON = C_("notification presenter", "Notification Actions") - -# Translators: This is the title for the dialog listing the actions associated -# with the selected live notification. -NOTIFICATIONS_ACTIONS_TITLE = C_("notification presenter", "Notification Actions") - -# Translators: This is the label for a button which invokes the selected action -# from the notification actions dialog. -NOTIFICATIONS_INVOKE_ACTION_BUTTON = C_("notification presenter", "Invoke Action") - # Translators: This string is a label for the group of Cthulhu commands which # are associated with presenting notifications. KB_GROUP_NOTIFICATIONS = _("Notification presenter") @@ -402,85 +655,68 @@ KB_GROUP_NOTIFICATIONS = _("Notification presenter") # title of Cthulhu's application-specific preferences dialog for an application. # The string substituted in is the accessible name of the application (e.g. # "Gedit", "Firefox", etc. -PREFERENCES_APPLICATION_TITLE = _("Cthulhu Preferences for %s") +PREFERENCES_APPLICATION_TITLE = _("Screen Reader Preferences for %s") + +# Translators: This is the accessible name for the list of settings categories +# on the left side of the preferences dialog (e.g. "General", "Speech", "Braille"). +PREFERENCES_CATEGORIES = _("Categories") # Translators: This is a table column header. This column consists of a single -# checkbox. If the checkbox is checked, Cthulhu will indicate the associated item +# widget. If the widget is enabled, Cthulhu will indicate the associated item # or attribute by "marking" it in braille. "Marking" is not the same as writing # out the word; instead marking refers to adding some other indicator, e.g. # "underlining" with braille dots 7-8 a word that is bold. PRESENTATION_MARK_IN_BRAILLE = _("Mark in braille") -# Translators: "Present Unless" is a column header of the text attributes panel -# of the Cthulhu preferences dialog. On this panel, the user can select a set of -# text attributes that they would like spoken and/or indicated in braille. -# Because the list of attributes could get quite lengthy, we provide the option -# to always speak/braille a text attribute *unless* its value is equal to the -# value given by the user in this column of the list. For example, given the -# text attribute "underline" and a present unless value of "none", the user is -# stating that he/she would like to have underlined text announced for all cases -# (single, double, low, etc.) except when the value of underline is none (i.e. -# when it's not underlined). "Present" here is being used as a verb. -PRESENTATION_PRESENT_UNLESS = _("Present Unless") - # Translators: This is a table column header. The "Speak" column consists of a -# single checkbox. If the checkbox is checked, Cthulhu will speak the associated +# single widget. If the widget is enabled, Cthulhu will speak the associated # item or attribute (e.g. saying "Bold" as part of the information presented # when the user gives the Cthulhu command to obtain the format and font details of # the current text). PRESENTATION_SPEAK = _("Speak") -# Translators: This is the title of a message dialog informing the user that -# he/she attempted to save a new user profile under a name which already exists. -# A "user profile" is a collection of settings which apply to a given task, such -# as a "Spanish" profile which would use Spanish text-to-speech and Spanish -# braille and selected when reading Spanish content. -PROFILE_CONFLICT_TITLE = _("Save Profile As Conflict") +# Translators: This is an option in a combo box for how a text attribute should +# be presented. If selected, the attribute will be both spoken and marked in braille. +PRESENTATION_SPEAK_AND_MARK = _("Speak and mark") -# Translators: This is the label of a message dialog informing the user that -# he/she attempted to save a new user profile under a name which already exists. -# A "user profile" is a collection of settings which apply to a given task, such -# as a "Spanish" profile which would use Spanish text-to-speech and Spanish -# braille and selected when reading Spanish content. -PROFILE_CONFLICT_LABEL = _("User Profile Conflict!") +# Translators: This is an option in a combo box for how a text attribute should +# be presented. If selected, the attribute will not be presented. +TEXT_ATTRIBUTES_PRESENTATION_NONE = C_("text attributes", "None") # Translators: This is the message in a dialog informing the user that he/she # attempted to save a new user profile under a name which already exists. # A "user profile" is a collection of settings which apply to a given task, such # as a "Spanish" profile which would use Spanish text-to-speech and Spanish # braille and selected when reading Spanish content. -PROFILE_CONFLICT_MESSAGE = _("Profile %s already exists.\n" \ - "Continue updating the existing profile with " \ - "these new changes?") - -# Translators: This text is displayed in a message dialog when a user indicates -# he/she wants to switch to a new user profile which will cause him/her to lose -# settings which have been altered but not yet saved. A "user profile" is a -# collection of settings which apply to a given task such as a "Spanish" profile -# which would use Spanish text-to-speech and Spanish braille and selected when -# reading Spanish content. -PROFILE_LOAD_LABEL = _("Load user profile") - -# Translators: This text is displayed in a message dialog when a user indicates -# he/she wants to switch to a new user profile which will cause him/her to lose -# settings which have been altered but not yet saved. A "user profile" is a -# collection of settings which apply to a given task such as a "Spanish" profile -# which would use Spanish text-to-speech and Spanish braille and selected when -# reading Spanish content. -PROFILE_LOAD_MESSAGE = \ - _("You are about to change the active profile. If you\n" \ - "have just made changes in your preferences, they will\n" \ - "be dropped at profile load.\n\n" \ - "Continue loading profile discarding previous changes?") +PROFILE_CONFLICT_MESSAGE = _( + "Profile %s already exists.\nContinue updating the existing profile with these new changes?", +) # Translators: Profiles in Cthulhu make it possible for users to quickly switch # amongst a group of pre-defined settings (e.g. an 'English' profile for reading -# text written in English using an English-language speech synthesizer and +# text written in English using an English-language speech synthesizer and +# braille rules, and a similar 'Spanish' profile for reading Spanish text. +# The following string is the name of the default profile which is created +# when Cthulhu is installed. +PROFILE_DEFAULT = C_("Profile", "Default") + +# Translators: This is an error message displayed when the user tries to remove +# the default profile. +PROFILE_CANNOT_REMOVE_DEFAULT = _("The default profile cannot be removed.") + +# Translators: Profiles in Cthulhu make it possible for users to quickly switch +# amongst a group of pre-defined settings (e.g. an 'English' profile for reading +# text written in English using an English-language speech synthesizer and # braille rules, and a similar 'Spanish' profile for reading Spanish text. The # following string is the title of a dialog in which users can save a newly- # defined profile. PROFILE_SAVE_AS_TITLE = _("Save Profile As") +# Translators: This is the label for a button that allows users to create a new +# profile. A "profile" is a named collection of Cthulhu settings that users can +# switch between. Creating a new profile copies the current settings. +PROFILE_CREATE_NEW = _("New Profile") + # Translators: Profiles in Cthulhu make it possible for users to quickly switch # amongst a group of pre-defined settings (e.g. an 'English' profile for reading # text written in English using an English-language speech synthesizer and @@ -503,10 +739,21 @@ PROFILE_REMOVE_LABEL = _("Remove user profile") # braille rules, and a similar 'Spanish' profile for reading Spanish text. # The following is a message in a dialog informing the user that he/she # is about to remove a user profile, an action that cannot be undone. -PROFILE_REMOVE_MESSAGE = _("You are about to remove profile %s. " \ - "All unsaved settings and settings saved in this " \ - "profile will be lost. Do you want to continue " \ - "and remove this profile and all related settings?") +PROFILE_REMOVE_MESSAGE = _( + "You are about to remove profile %s. " + "All unsaved settings and settings saved in this " + "profile will be lost. Do you want to continue " + "and remove this profile and all related settings?", +) + +# Translators: This is a message in a dialog shown when the user tries to create +# a new profile while there are unsaved changes in the preferences dialog. +# Creating a new profile copies the saved settings, not the unsaved changes. +PROFILE_CREATE_UNSAVED_WARNING = _( + "You have unsaved changes. Creating a new profile will copy your " + "current saved settings, not your unsaved changes. Your unsaved " + "changes will be lost.\n\nDo you want to continue?", +) # Translators: Cthulhu has a setting which determines which progress bar updates # should be announced. Choosing "All" means that Cthulhu will present progress bar @@ -524,18 +771,26 @@ PROGRESS_BAR_APPLICATION = C_("ProgressBar", "Application") # bar updates as long as the progress bar is in the active window. PROGRESS_BAR_WINDOW = C_("ProgressBar", "Window") +# Translators: Cthulhu has a setting which determines how much punctuation should +# be spoken as a user reads a document. The choices are None, Some, Most, and All. +PUNCTUATION_STYLE = _("Punctuation Level") + # Translators: If this setting is chosen, no punctuation symbols will be spoken # as a user reads a document. -PUNCTUATION_STYLE_NONE = C_("punctuation level", "_None") +PUNCTUATION_STYLE_NONE = C_("punctuation level", "None") # Translators: If this setting is chosen, common punctuation symbols (like # comma, period, question mark) will not be spoken as a user reads a document, # but less common symbols (such as #, @, $) will. -PUNCTUATION_STYLE_SOME = _("So_me") +PUNCTUATION_STYLE_SOME = C_("punctuation level", "Some") # Translators: If this setting is chosen, the majority of punctuation symbols # will be spoken as a user reads a document. -PUNCTUATION_STYLE_MOST = _("M_ost") +PUNCTUATION_STYLE_MOST = C_("punctuation level", "Most") + +# Translators: If this setting is chosen, all punctuation symbols will be spoken +# as a user reads a document. +PUNCTUATION_STYLE_ALL = C_("punctuation level", "All") # Translators: If this setting is chosen and the user is reading over an entire # document, Cthulhu will pause at the end of each line. @@ -545,6 +800,25 @@ SAY_ALL_STYLE_LINE = _("Line") # document, Cthulhu will pause at the end of each sentence. SAY_ALL_STYLE_SENTENCE = _("Sentence") +# Translators: This is the label for a group of settings in the Say All +# preferences page. It describes different methods for rewinding and fast +# forwarding during Say All (e.g., using arrow keys or structural navigation). +SAY_ALL_REWIND_AND_FAST_FORWARD_BY = _("Rewind and Fast Forward By") + +# Translators: Cthulhu has a Say All feature which speaks the entire document. +# Normally, pressing any key will interrupt Say All presentation. However, if +# this option is enabled, Up Arrow and Down Arrow can be used to quickly move +# within the document to re-hear something which was just read or skip past +# something of no interest. +SAY_ALL_UP_AND_DOWN_ARROW = _("Up and down arrow") + +# Translators: This is a label for a setting on the Say All preferences page. Cthulhu +# provides a Say All command that reads content from the current location to the +# end. If this setting is enabled, users can use Cthulhu's structural navigation +# commands (like H/Shift+H to jump to headings, P/Shift+P to jump to paragraphs) +# to jump forward or backward during Say All. +SAY_ALL_STRUCTURAL_NAVIGATION = _("Structural navigation") + # Translators: Cthulhu has a command that presents a list of structural navigation # objects in a dialog box so that users can navigate more quickly than they # could with native keyboard navigation. This is the title for a column which @@ -669,12 +943,6 @@ SN_HEADER_RADIO_BUTTON = C_("structural navigation", "Radio Button") # "table", "combo box", etc. SN_HEADER_ROLE = C_("structural navigation", "Role") -# Translators: Cthulhu has a command that presents a list of structural navigation -# objects in a dialog box so that users can navigate more quickly than they -# could with native keyboard navigation. This is the title for a column which -# contains the selected item of a form field. -SN_HEADER_SELECTED_ITEM = C_("structural navigation", "Selected Item") - # Translators: Cthulhu has a command that presents a list of structural navigation # objects in a dialog box so that users can navigate more quickly than they # could with native keyboard navigation. This is the title for a column which @@ -682,12 +950,6 @@ SN_HEADER_SELECTED_ITEM = C_("structural navigation", "Selected Item") # "selected"/"not selected", "visited/not visited", etc. SN_HEADER_STATE = C_("structural navigation", "State") -# Translators: Cthulhu has a command that presents a list of structural navigation -# objects in a dialog box so that users can navigate more quickly than they -# could with native keyboard navigation. This is the title for a column which -# contains the text of an entry. -SN_HEADER_TEXT = C_("structural navigation", "Text") - # Translators: Cthulhu has a command that presents a list of structural navigation # objects in a dialog box so that users can navigate more quickly than they # could with native keyboard navigation. This is the title for a column which @@ -811,16 +1073,10 @@ SN_TITLE_UNVISITED_LINK = C_("structural navigation", "Unvisited Links") # could with native keyboard navigation. This is the title of such a dialog box. SN_TITLE_VISITED_LINK = C_("structural navigation", "Visited Links") -# Translators: This is the title of a panel holding options for how to navigate -# HTML content (e.g., Cthulhu caret navigation, positioning of caret, structural -# navigation, etc.). -PAGE_NAVIGATION = _("Page Navigation") - # Translators: When the user loads a new web page, they can optionally have Cthulhu # automatically start reading the page from beginning to end. This is the label -# of a checkbox in which users can indicate their preference. -READ_PAGE_UPON_LOAD = \ - _("Automatically start speaking a page when it is first _loaded") +# of a widget in which users can indicate their preference. +READ_PAGE_UPON_LOAD = _("Automatically start speaking a page when it is first _loaded") # Translators: When the user loads a new web page, they can optionally have Cthulhu # automatically summarize details about the page, such as the number of elements @@ -833,7 +1089,7 @@ PAGE_SUMMARY_UPON_LOAD = _("_Present summary of a page when it is first loaded") # system immediately when a pause directive is encountered or if it should be # queued up and sent to the speech synthesis system once the entire set of # utterances has been calculated. -SPEECH_BREAK_INTO_CHUNKS = _("Break speech into ch_unks between pauses") +SPEECH_BREAK_INTO_CHUNKS = _("Insert pauses to break up speech") # Translators: This string will appear in the list of available voices for the # current speech engine. "%s" will be replaced by the name of the current speech @@ -855,7 +1111,7 @@ SPEECH_VOICE_TYPE_HYPERLINK = C_("VoiceType", "Hyperlink") # which is not displayed on the screen as text, but is still being communicated # by the system in some visual fashion. For instance, Cthulhu says "misspelled" to # indicate the presence of the red squiggly line found under a spelling error; -# Cthulhu might say "3 of 6" when a user Tabs into a list of six items and the +# Cthulhu might say "3 of 6" when a user Tabs into a list of six items and the # third item is selected. And so on. SPEECH_VOICE_TYPE_SYSTEM = C_("VoiceType", "System") @@ -867,28 +1123,37 @@ SPEECH_VOICE_TYPE_UPPERCASE = C_("VoiceType", "Uppercase") # system. (http://devel.freebsoft.org/speechd) SPEECH_DISPATCHER = _("Speech Dispatcher") -# Translators: This label refers to the Piper neural text-to-speech system. -# (https://github.com/rhasspy/piper) -PIPER_TTS = _("Piper Neural TTS") +# Translators this label refers to the name of particular speech synthesis +# system. (https://github.com/eeejay/spiel) +SPIEL = _("Spiel") # Translators: This is a label for a group of options related to Cthulhu's behavior # when presenting an application's spell check dialog. SPELL_CHECK = C_("OptionGroup", "Spell Check") -# Translators: This is a label for a checkbox associated with an Cthulhu setting. +# Translators: This is a description that appears at the top of the Spell Check +# preferences page. It explains that these settings only work when the screen +# reader can identify the components of an application's spell checker (e.g., the +# misspelled word, suggestions list, etc.). +SPELL_CHECK_DESCRIPTION = _( + "These settings apply when the screen reader can identify the " + "elements of the application's spell check dialog.", +) + +# Translators: This is a label for a widget associated with an Cthulhu setting. # When this option is enabled, Cthulhu will spell out the current error in addition # to speaking it. For example, if the misspelled word is "foo," enabling this # setting would cause Cthulhu to speak "f o o" after speaking "foo". SPELL_CHECK_SPELL_ERROR = _("Spell _error") -# Translators: This is a label for a checkbox associated with an Cthulhu setting. +# Translators: This is a label for a widget associated with an Cthulhu setting. # When this option is enabled, Cthulhu will spell out the current suggestion in # addition to speaking it. For example, if the misspelled word is "foo," and # the first suggestion is "for" enabling this setting would cause Cthulhu to speak # "f o r" after speaking "for". SPELL_CHECK_SPELL_SUGGESTION = _("Spell _suggestion") -# Translators: This is a label for a checkbox associated with an Cthulhu setting. +# Translators: This is a label for a widget associated with an Cthulhu setting. # When this option is enabled, Cthulhu will present the context (surrounding text, # typically the sentence or line) in which the mistake occurred. SPELL_CHECK_PRESENT_CONTEXT = _("Present _context of error") @@ -909,21 +1174,22 @@ SPREADSHEET_SPEAK_SELECTED_RANGE = _("Always speak selected spreadsheet range") # Translators: This is a label for an option for whether or not to speak the # header of a table cell in document content. -TABLE_ANNOUNCE_CELL_HEADER = _("Announce cell _header") +TABLE_SPEAK_CELL_HEADER = _("Speak cell header") -# Translators: This is the title of a panel containing options for specifying -# how to navigate tables in document content. -TABLE_NAVIGATION = _("Table Navigation") +# Translators: This is a label for a group of settings in the Tables tab of the +# Cthulhu Preferences dialog. These settings control whether Cthulhu speaks the entire +# row when the user moves from row to row in different types of tables. +TABLE_ROW_NAVIGATION = _("Row Navigation") + +# Translators: This is a label for a group of settings in the Tables tab of the +# Cthulhu Preferences dialog. These settings control what information about table +# cells Cthulhu announces, such as cell coordinates, headers, and spans. +TABLE_CELL_NAVIGATION = _("Cell Navigation") # Translators: This is a label for an option to tell Cthulhu to skip over empty/ # blank cells when navigating tables in document content. TABLE_SKIP_BLANK_CELLS = _("Skip _blank cells") -# Translators: When users are navigating a table, they sometimes want the entire -# row of a table read; other times they want just the current cell presented to -# them. This label is associated with the default presentation to be used. -TABLE_SPEAK_CELL = _("Speak _cell") - # Translators: This is a label for an option to tell Cthulhu whether or not it # should speak table cell coordinates in document content. TABLE_SPEAK_CELL_COORDINATES = _("Speak _cell coordinates") @@ -933,51 +1199,930 @@ TABLE_SPEAK_CELL_COORDINATES = _("Speak _cell coordinates") # a particular table cell spans in a table). TABLE_SPEAK_CELL_SPANS = _("Speak _multiple cell spans") -# Translators: This is a table column header. "Attribute" here refers to text -# attributes such as bold, underline, family-name, etc. -TEXT_ATTRIBUTE_NAME = _("Attribute Name") +# Translators: This string is associated with a combo box which allows the user +# to select the set of symbols to be used when Cthulhu presents print strings on a +# refreshable braille display. Braille symbols vary from language to language +# due in part to what print letters exist for that language. The other reason +# braille symbols vary is due to which braille contractions get used. +# Contractions are shorter forms of commonly-used letter combinations and +# words. For instance in English there is a single braille symbol for ing (dots +# 3-4-6), and the letter e (dots 1-5) all by itself represents the word every. +# The list of rules which dictate what contractions should be used and whether +# or not they can be used in a particular context are stored in tables provided +# by liblouis. +BRAILLE_CONTRACTION_TABLE = _("Contraction _Table:") -# Translators: Gecko native caret navigation is where Firefox itself controls -# how the arrow keys move the caret around HTML content. It's often broken, so -# Cthulhu needs to provide its own support. As such, Cthulhu offers the user the -# ability to switch between the Firefox mode and the Cthulhu mode. This is the -# label of a checkbox in which users can indicate their default preference. -USE_CARET_NAVIGATION = _("Control caret navigation") +# Translators: Braille flash messages are similar in nature to notifications or +# announcements. They are most commonly used for Cthulhu to communicate +# Cthulhu-specific information to the user via braille, such as confirming the +# toggling of an Cthulhu setting via command. The reason they are called flash +# messages by screen readers is that they are shown on the refreshable braille +# display for only a brief time, after which the original contents of the +# display are restored. This label is for the spin button through which a user +# can customize how long (in seconds) these temporary messages should be +# displayed. +BRAILLE_DURATION_SECS = _("D_uration (secs):") -# Translators: Cthulhu provides keystrokes to navigate HTML content in a structural -# manner: go to previous/next header, list item, table, etc. This is the label -# of a checkbox in which users can indicate their default preference. -USE_STRUCTURAL_NAVIGATION = _("Enable _structural navigation") +# Translators: This is the label for a setting which controls whether Cthulhu +# displays the braille indicator symbol at the end of each line of text. +BRAILLE_ENABLE_END_OF_LINE_SYMBOL = _("_End of line symbol") -# Translators: This is the label for a combo box in the preferences dialog -# where users can select a sound theme. A sound theme is a collection of -# audio files that Cthulhu plays for various events. -SOUND_THEME = _("Sound _theme:") +# Translators: This is a label for a group of settings related to a refreshable +# braille display. +BRAILLE_DISPLAY_SETTINGS = _("Display Settings") -# Translators: This is the label for a combo box in the preferences dialog -# where users can choose which audio backend Cthulhu should use. -SOUND_BACKEND = _("Audio _backend:") +# Translators: This is the label for a setting in the preferences dialog which +# turns braille support on or off. +BRAILLE_ENABLE_BRAILLE_SUPPORT = _("Braille support") -# Translators: This is a sound backend option which lets Cthulhu choose a -# backend automatically. -SOUND_BACKEND_AUTO = _("Automatic") +# Translators: If this option is enabled, Cthulhu will adjust the text shown on +# the braille display so that only full words are shown. If it is not enabled, +# Cthulhu uses all of the cells on the display, but some words might not be fully +# shown requiring the user to scroll to see the remainder. +BRAILLE_ENABLE_WORD_WRAP = _("Word wrap") -# Translators: This is a sound backend option which forces PipeWire. -SOUND_BACKEND_PIPEWIRE = _("PipeWire") +# Translators: Braille flash messages are similar in nature to notifications or +# announcements in that they are temporarily shown on the refreshable braille +# display. Upon removal of the message, the original contents of the braille +# display are restored. This widget allows the user to toggle this feature. +BRAILLE_ENABLE_FLASH_MESSAGES = _("Flash messages") -# Translators: This is a sound backend option which forces PulseAudio. -SOUND_BACKEND_PULSE = _("PulseAudio") +# Translators: Braille flash messages are similar in nature to notifications or +# announcements. They are most commonly used for Cthulhu to communicate +# Cthulhu-specific information to the user via braille, such as confirming the +# toggling of an Cthulhu setting via command. The reason they are called flash +# messages by screen readers is that they are shown on the refreshable braille +# display for only a brief time, after which the original contents of the +# display are restored. +BRAILLE_FLASH_MESSAGE_SETTINGS = _("Flash Message Settings") -# Translators: This is a sound backend option which forces ALSA. -SOUND_BACKEND_ALSA = _("ALSA") +# Translators: This label is for a frame containing braille indicator settings. +# Within this frame, users can configure which dots (7, 8, or both) are used to +# indicate selections, hyperlinks, and text attributes. +BRAILLE_INDICATORS = _("Indicators") -# Translators: This is the title of a frame in the preferences dialog -# containing sound options. -SOUND_THEME_TITLE = _("Sound") +# Translators: This label for a widget from which the user can select which +# braille dot or dots should be used to indicate that character(s) being +# shown on the braille display are part of a link. The "indicator" can be chosen +# from among: None, Dot 7, Dot 8, Dots 7 and 8. +BRAILLE_HYPERLINK_INDICATOR = C_("Braille indicator", "Hyperlink:") + +# Translators: This label for a widget from which the user can select which +# braille dot or dots should be used to indicate that character(s) being +# shown on the braille display are selected. The "indicator" can be chosen +# from among: None, Dot 7, Dot 8, Dots 7 and 8. +BRAILLE_SELECTION_INDICATOR = C_("Braille indicator", "Selection:") + +# Translators: This label for a widget from which the user can select which +# braille dot or dots should be used to indicate that character(s) being +# shown on the braille display have a particular attribute (e.g. bold). The +# "indicator" can be chosen from among: None, Dot 7, Dot 8, Dots 7 and 8. +BRAILLE_TEXT_ATTRIBUTES_INDICATOR = C_("Braille indicator", "Text Attributes:") + +# Translators: Braille flash messages are similar in nature to notifications or +# announcements. They are most commonly used for Cthulhu to communicate +# Cthulhu-specific information to the user via braille, such as confirming the +# toggling of an Cthulhu setting via command. The reason they are called flash +# messages by screen readers is that they are shown on the refreshable braille +# display for only a brief time, after which the original contents of the +# display are restored. In instances where the message to be displayed is +# long/detailed, Cthulhu provides a brief alternative. Users who prefer the brief +# alternative can uncheck this widget. +BRAILLE_MESSAGES_ARE_DETAILED = _("Messages are _detailed") + +# Translators: Braille flash messages are similar in nature to notifications or +# announcements. They are most commonly used for Cthulhu to communicate +# Cthulhu-specific information to the user via braille, such as confirming the +# toggling of an Cthulhu setting via command. The reason they are called flash +# messages by screen readers is that they are shown on the refreshable braille +# display for only a brief time, after which the original contents of the +# display are restored. Some users, however, would prefer to have the message +# remain displayed until they explicitly dismiss it. This can be accomplished +# by making flash messages persistent by checking this widget. +BRAILLE_MESSAGES_ARE_PERSISTENT = _("Messages are _persistent") + +# Translators: This is the title of a section in the braille settings where the user +# can configure how Cthulhu presents flash messages on a refreshable braille display. +BRAILLE_FLASH_MESSAGES = _("Flash Messages") + +# Translators: This is the label for a setting for whether or not Cthulhu should +# use abbreviated role names when presenting the role of an object on a +# refreshable braille display. For instance, if this option is enabled, Cthulhu +# would present "btn" instead of "button". +BRAILLE_ABBREVIATED_ROLE_NAMES = _("_Abbreviated role names") + +# Translators: This is the label for a setting for whether or not Cthulhu should +# present contextual information about an object (e.g. the panel it is inside of) +# on a refreshable braille display. +BRAILLE_SHOW_CONTEXT = _("Show context (ancestors)") + +# Translators: This is the label for a setting which turns on contracted braille. +# Contractions are shorter forms of commonly-used letter combinations and words. +# For instance in English there is a single braille symbol for "ing" in English +# braille. +BRAILLE_ENABLE_CONTRACTED_BRAILLE = _("Contracted Braille") + +# Translators: This is the label for a setting which controls whether computer +# braille (uncontracted) is used for the word at the cursor location when +# contracted braille is enabled. If disabled, the word at the cursor remains +# contracted like the rest of the text. +BRAILLE_COMPUTER_BRAILLE_AT_CURSOR = _("Expand word at cursor") + +# Translators: This is the title of the Cthulhu Preferences dialog box. +DIALOG_SCREEN_READER_PREFERENCES = _("Screen Reader") + +# Translators: This is the accessible name for the Cthulhu Preferences dialog box. +DIALOG_SCREEN_READER_PREFERENCES_ACCESSIBLE = _("Screen Reader Preferences") + +# Translators: This is the label for a widget in the preferences dialog. +DIALOG_ADD = _("Add") + +# Translators: This is the label for a widget in the preferences dialog. +DIALOG_APPLY = _("Apply") + +# Translators: This is the label for a widget in the preferences dialog. +DIALOG_CANCEL = _("Cancel") + +# Translators: This is the label for a widget in the preferences dialog. +DIALOG_EDIT = _("Edit") + +# Translators: This is the label for a widget in the preferences dialog. +DIALOG_HELP = _("Help") + +# Translators: This is the label for a widget in the preferences dialog. +DIALOG_SAVE_AS = _("Save _As") + +# Translators: Cthulhu has a feature to "echo" keys as they are pressed. This is +# the label for the setting which determines whether or not key echo is enabled. +# If it is enabled, the user can then choose which types of keys they want to +# hear. See the strings which follow this one. +ECHO_ENABLE_KEY_ECHO = _("Key echo") + +# Translators: Cthulhu has a feature to "echo" keys as they are pressed. This is +# the label for the setting which controls whether or not alphabetic keys will +# be spoken when pressed. +ECHO_ALPHABETIC_KEYS = _("Alphabetic keys") + +# Translators: Cthulhu has a feature to "echo" keys as they are pressed. This is +# the label for the setting which controls whether or not numeric keys will +# be spoken when pressed. +ECHO_NUMERIC_KEYS = _("Numeric keys") + +# Translators: Cthulhu has a feature to "echo" keys as they are pressed. This is +# the label for the setting which controls whether or not punctuation keys will +# be spoken when pressed. +ECHO_PUNCTUATION_KEYS = _("Punctuation keys") + +# Translators: Cthulhu has a feature to "echo" keys as they are pressed. This is +# the label for the setting which controls whether or not function keys (F1, F2, +# etc.) will be spoken when pressed. +ECHO_FUNCTION_KEYS = _("Function keys") + +# Translators: Cthulhu has a feature to "echo" keys as they are pressed. This is +# the label for the setting which controls whether or not diacritical keys will +# be spoken when pressed. +ECHO_DIACRITICAL_KEYS = _("Diacritical keys") + +# Translators: Cthulhu has a feature to "echo" keys as they are pressed. This is +# the label for the setting which controls whether or not modifier keys (Shift, +# Control, Alt, etc.) will be spoken when pressed. +ECHO_MODIFIER_KEYS = _("Modifier keys") + +# Translators: Cthulhu has a feature to "echo" keys as they are pressed. This is +# the label for the setting which controls whether or not navigation keys (Arrows, +# Home, End, etc.) will be spoken when pressed. +ECHO_NAVIGATION_KEYS = _("Navigation keys") + +# Translators: Cthulhu has a feature to "echo" keys as they are pressed. This is +# the label for the setting which controls whether or not the space bar will +# be spoken when pressed. +ECHO_SPACE = _("Space") + +# Translators: Cthulhu has a feature to "echo" keys as they are pressed. This is +# the label for the setting which controls whether or not action keys such as +# Enter, Escape, Tab, Backspace, etc. will be spoken when pressed. +ECHO_ACTION_KEYS = _("Action keys") + +# Translators: This is the label for a group of settings on the Echo preferences +# page. It groups the types of keys that will be spoken when pressed. +ECHO_KEYS_TO_ECHO = _("Keys to Echo") + +# Translators: This is the label for a group of settings on the Echo preferences +# page. It groups the text echo options (character, word, sentence). +ECHO_TYPING_ECHO = _("Typing Echo") + +# Translators: Cthulhu has an "echo" feature to present text as it is being written +# by the user. While Cthulhu's "key echo" options present the actual keyboard keys +# being pressed, "character echo" presents the character/string of length 1 that +# is inserted as a result of the keypress. +ECHO_CHARACTER = C_("Typing echo", "Character") + +# Translators: Cthulhu has an "echo" feature to present text as it is being written +# by the user. While Cthulhu's "key echo" options present the actual keyboard keys +# being pressed, "sentence echo" presents the sentence that was just completed. +ECHO_SENTENCE = C_("Typing echo", "Sentence") + +# Translators: Cthulhu has an "echo" feature to present text as it is being written +# by the user. While Cthulhu's "key echo" options present the actual keyboard keys +# being pressed, "word echo" presents the word that was just completed. +ECHO_WORD = C_("Typing echo", "Word") + +# Translators: This is a label for a widget that allows the user to select which +# settings profile should be active. A profile is a collection of settings which +# can be saved and later loaded. +GENERAL_ACTIVE_PROFILE = _("Active _Profile:") + +# Translators: Cthulhu has a Say All feature which speaks the entire document. +# Some users want to hear additional information about what is being spoken. If +# this widget is enabled, Cthulhu will announce that a form has been entered +# before speaking the contents of that form. At the end of the form, Cthulhu will +# announce that the form is being exited. +GENERAL_ANNOUNCE_FORMS_IN_SAY_ALL = _("Announce _forms in Say All") + +# Translators: Cthulhu has a Say All feature which speaks the entire document. +# Some users want to hear additional information about what is being spoken. If +# this widget is enabled, Cthulhu will announce that a panel has been entered +# before speaking the new location. At the end of the panel contents, Cthulhu will +# announce that the panel is being exited. A panel is a generic container of +# objects, such as a group of related form fields. +GENERAL_ANNOUNCE_PANELS_IN_SAY_ALL = _("Announce _panels in Say All") + +# Translators: Cthulhu has a Say All feature which speaks the entire document. +# Some users want to hear additional information about what is being spoken. If +# this widget is enabled, Cthulhu will announce that a table with x rows and y +# columns has been entered before speaking the content of that table. At the +# end of the table content, Cthulhu will announce that the table is being exited. +GENERAL_ANNOUNCE_TABLES_IN_SAY_ALL = _("Announce _tables in Say All") + +# Translators: Cthulhu has a Say All feature which speaks the entire document. +# Some users want to hear additional information about what is being spoken. If +# this widget is enabled, Cthulhu will announce that a blockquote has been +# entered before speaking the text. At the end of the text, Cthulhu will announce +# that the blockquote is being exited. +GENERAL_ANNOUNCE_BLOCKQUOTES_IN_SAY_ALL = _("Announce block_quotes in Say All") + +# Translators: Cthulhu has a Say All feature which speaks the entire document. +# Some users want to hear additional information about what is being spoken. If +# this widget is enabled, Cthulhu will announce when an ARIA landmark has been +# entered or exited. ARIA landmarks are the W3C defined HTML tag attribute +# 'role' used to identify important part of webpage like banners, main context, +# search, etc. +GENERAL_ANNOUNCE_LANDMARKS_IN_SAY_ALL = _("Announce land_marks in Say All") + +# Translators: Cthulhu has a Say All feature which speaks the entire document. +# Some users want to hear additional information about what is being spoken. If +# this widget is enabled, Cthulhu will announce that a list with x items has +# been entered before speaking the content of that list. At the end of the list +# content, Cthulhu will announce that the list is being exited. +GENERAL_ANNOUNCE_LISTS_IN_SAY_ALL = _("Announce li_sts in Say All") + +# Translators: Cthulhu has a setting which determines which progress bar updates +# should be announced. The options are all progress bars, only progress bars in +# the active application, or only progress bars in the current window. +GENERAL_APPLIES_TO = _("Applies to:") + +# Translators: Cthulhu has a setting which determines which progress bar updates +# should be announced. The options are all progress bars, only progress bars in +# the active application, or only progress bars in the current window. This +# label is for the second option. +GENERAL_APPLICATION = _("Application") + +# Translators: Cthulhu has a setting which determines which progress bar updates +# should be announced. The options are all progress bars, only progress bars in +# the active application, or only progress bars in the current window. This +# label is for the third option. +GENERAL_WINDOW = _("Window") + +# Translators: This is an option in the Preferences dialog box related to the +# presentation of progress bar updates. If this widget is enabled, Cthulhu will +# periodically emit beeps which increase in pitch as the value of the progress +# bar increases. +GENERAL_BEEP_UPDATES = _("Bee_p updates") + +# Translators: This is a label for the setting which controls how Cthulhu will +# present the date. +GENERAL_DATE_FORMAT = _("Dat_e format:") + +# Translators: This is a label for the setting which controls how Cthulhu will +# present the time. +GENERAL_TIME_FORMAT = _("_Time format:") + +# Translators: Profiles in Cthulhu make it possible for users to quickly switch +# amongst a group of pre-defined settings (e.g. an 'English' profile for reading +# text written in English using an English-language speech synthesizer and +# braille rules, and a similar 'Spanish' profile for reading Spanish text. The +# following string is the label for the profiles sidebar item. +GENERAL_PROFILES = _("Profiles") + +# Translators: This is the heading shown at the top of the profiles preferences +# page, indicating which profile is currently selected and being edited. +CURRENT_PROFILE = _("Current Profile") + +# Translators: This is an informational message shown at the top of the Profiles +# preferences page. It explains to the user what profiles are and how they work. +# The quoted profile name is `PROFILE_DEFAULT = C_("Profile", "Default")` defined +# earlier in this file. +PROFILES_INFO = ( + _( + 'Profiles are collections of settings. The "%s" profile is ' + "loaded at startup. To edit an existing profile, select it below and then " + "adjust settings on the other pages. To rename or delete the selected profile, " + "use the Tab key to navigate to its associated buttons.", + ) + % PROFILE_DEFAULT +) + +# Translators: This is a label in the Preferences dialog box. It applies to +# several options related to which progress bars Cthulhu should speak and how +# often Cthulhu should speak them. +PROGRESS_BARS = _("Progress Bars") + +# Translators: Cthulhu has a Say All feature which speaks the entire document. +# This is the label for the group of settings related to Say All. +GENERAL_SAY_ALL = _("Say All") + +# Translators: Say all by refers to the way that Cthulhu will say (speak) an +# amount of text -- in particular, where Cthulhu where insert pauses. There are +# currently two choices (supplied by a combo box to the right of this label): +# say all by sentence and say all by line. If Cthulhu were speaking a work of +# fiction, it would probably be best to do say all by sentence so it sounds +# more natural. If Cthulhu were speaking something like a page of computer +# commands, doing a say all by line would work better. +SAY_ALL_BY = _("Say All By") + +# Translators: This is an informational message on the Say All preferences page +# explaining what Say All does. Please translate this message consistent with the +# strings in `GENERAL_SAY_ALL = _("Say All")` and SAY_ALL_BY = _("Say All By")` +SAY_ALL_INFO = _( + "Say All reads from the current location to the end of the document, " + 'pausing after each sentence or line based on the "Say All By" value.', +) + +# Translators: This is an informational message on the Say All preferences page +# explaining the options found under the section with the following label: +# `SAY_ALL_REWIND_AND_FAST_FORWARD_BY = _("Rewind and Fast Forward By")` +# Please translate "structural navigation" using terminology consistent with +# `KB_GROUP_STRUCTURAL_NAVIGATION = _("Structural navigation")` +SAY_ALL_NAVIGATION_INFO = _( + "If enabled, you can use the arrow keys and/or structural navigation commands " + "to move within the document while Say All is active.", +) + +# Translators: This is an informational message on the Say All preferences page +# explaining the options found under the section with the following label: +# ANNOUNCEMENTS = _("Container Announcements") +SAY_ALL_CONTAINER_INFO = _( + 'The "Container Announcements" settings determine what additional details ' + "will be announced as Say All moves through the document.", +) + +GENERAL_SPEAK_OBJECT_UNDER_MOUSE = _("Speak object under mo_use") + +# Translators: Profiles in Cthulhu make it possible for users to quickly switch +# amongst a group of pre-defined settings (e.g. an 'English' profile for reading +# text written in English using an English-language speech synthesizer and +# braille rules, and a similar 'Spanish' profile for reading Spanish text. The +# following string is the label for the widget from which the user can select +# which profile should be loaded when Cthulhu starts up. +GENERAL_START_UP_PROFILE = _("Start-up Profile:") + +# Translators: This is an option in the Preferences dialog box related to the +# presentation of progress bar updates. If this widget is enabled, Cthulhu will +# periodically display the current percentage in braille. +GENERAL_BRAILLE_UPDATES = _("_Braille updates") + + +# This button will load the selected settings profile in the application. +GENERAL_LOAD = _("_Load") + +GENERAL_PRESENT_TOOLTIPS = _("_Present tooltips") + +# Translators: This is the label for a button in a dialog. +GENERAL_REMOVE = _("_Remove") + +# Translators: This is an option in the Preferences dialog box related to the +# presentation of progress bar updates. If this widget is enabled, Cthulhu will +# periodically speak the current percentage. +GENERAL_SPEAK_UPDATES = _("_Speak updates") + +# Translators: Here this is a label for a spin button through which a user can +# customize the frequency in seconds an announcement should be made regarding +# the current value of a progress bar. +GENERAL_FREQUENCY_SECS = C_("ProgressBar", "Frequency (secs):") + +# Translators: The "Cthulhu modifier" is a key that Cthulhu uses for its own commands. +# The default Cthulhu modifier is KP_Insert for the "desktop" keyboard layout and +# Caps Lock for the "laptop" keyboard layout. This string is a label for choosing +# which key(s) should be used as the Cthulhu modifier. +KEY_BINDINGS_SCREEN_READER_MODIFIER_KEY_S = _("Screen Reader _Modifier Key(s):") + +# Translators: Cthulhu can optionally speak additional details as the user +# navigates (e.g. via the arrow keys) within document content. If this widget +# is enabled, Cthulhu will announce that a form has been entered as the user +# arrows into it and before speaking the new location. Upon navigating out of +# the form, Cthulhu will announce that the form has been exited prior to speaking +# the new location. +SPEECH_ANNOUNCE_FORMS_DURING_NAVIGATION = _("Announce _forms during navigation") + +# Translators: Cthulhu can optionally speak additional details as the user +# navigates (e.g. via the arrow keys) within document content. If this +# widget is enabled, Cthulhu will announce that a list with x items has been +# entered as the user arrows into it and before speaking the list content. Upon +# navigating out of the list, Cthulhu will announce that the list has been exited +# prior to speaking the new location. +SPEECH_ANNOUNCE_LISTS_DURING_NAVIGATION = _("Announce _lists during navigation") + +# Translators: Cthulhu can optionally speak additional details as the user +# navigates (e.g. via the arrow keys) within document content. If this +# widget is enabled, Cthulhu will announce that a panel has been entered as the +# user arrows into it and before speaking the new location. Upon navigating out +# of the panel, Cthulhu will announce that the panel has been exited prior to +# speaking the new location. A panel is a generic container of objects, such as +# a group of related form fields. +SPEECH_ANNOUNCE_PANELS_DURING_NAVIGATION = _("Announce _panels during navigation") + +# Translators: Cthulhu can optionally speak additional details as the user +# navigates (e.g. via the arrow keys) within document content. If this +# widget is enabled, Cthulhu will announce that a table with x rows and y +# columns has been entered as the user arrows into it and before speaking the +# table content. Upon navigating out of the table, Cthulhu will announce that the +# table has been exited prior to speaking the new location. +SPEECH_ANNOUNCE_TABLES_DURING_NAVIGATION = _("Announce _tables during navigation") + +# Translators: Cthulhu can optionally speak additional details as the user +# navigates (e.g. via the arrow keys) within document content. If this +# widget is enabled, Cthulhu will announce that a blockquote has been entered as +# the user arrows into it and before speaking the text. Upon navigating out of +# the blockquote, Cthulhu will announce that the blockquote has been exited prior +# to speaking the new location. +SPEECH_ANNOUNCE_BLOCKQUOTES_DURING_NAVIGATION = _("Announce block_quotes during navigation") + +# Translators: Cthulhu can optionally speak additional details as the user +# navigates (e.g. via the arrow keys) within document content. If this +# widget is enabled, Cthulhu will announce the ARIA landmark that has been +# entered as the user arrows into it and before speaking the text. Upon +# navigating out of the landmark, Cthulhu will announce that the landmark has been +# exited prior to speaking the new location. ARIA landmarks are the W3C defined +# HTML tag attribute 'role' used to identify important part of webpage like +# banners, main context, search, etc. +SPEECH_ANNOUNCE_LANDMARKS_DURING_NAVIGATION = _("Announce land_marks during navigation") + +# Translators: If this setting is enabled, Cthulhu will only speak text which is +# actually displayed on the screen. It will NOT speak things like the role of +# an item (e.g. widget) or its state (e.g. not checked) or say misspelled to +# indicate the presence of red squiggly spelling error lines -- things which +# Cthulhu normally speaks. This setting is primarily intended for low vision users +# and sighted users with a learning disability. +SPEECH_ONLY_SPEAK_DISPLAYED_TEXT = _("Only speak displayed text") + +# Translators: Cthulhu has a command to present font and formatting information, +# including foreground and background color. The setting associated with this +# widget determines how Cthulhu will speak colors: As rgb values or as names +# (e.g. light blue). +SPEECH_SPEAK_COLORS_AS_NAMES = _("S_peak colors as names") + +# Translators: This is a label for a widget associated with whether Cthulhu will +# present the mnemonic (underlined character) of an object, such as a button or +# menu item, when it becomes focused/selected. +PRESENT_OBJECT_MNEMONICS = _("Mnemonics") + +# Translators: If this widget is enabled, Cthulhu will speak the accessible +# description of an object. Whereas the accessible name of an object tends to +# be short and typically corresponds to what is displayed on screen, the +# contents of the accessible description tend to be longer, e.g. matching the +# text of the tooltip, and are sometimes redundant to the accessible name. +# Therefore, we allow the user to opt out of this additional information. +SPEECH_SPEAK_DESCRIPTION = _("Description") + +# Translators: This is a label for a widget associated with whether Cthulhu will +# speak indentation and justification information for text content. +SPEECH_SPEAK_INDENTATION_AND_JUSTIFICATION = _("Indentation and justification") + +# Translators: This is the label for a widget on the Speech preferences page. +# When checked, indentation and justification will only be announced when they +# have changed from the previous line. +SPEECH_INDENTATION_ONLY_IF_CHANGED = _("Only speak indentation if changed") + +# Translators: The misspelled-word indicator is the red squiggly line that +# appears underneath misspelled words in editable text fields. If this setting +# is enabled, when a user first moves into a word with this indicator, or types +# a misspelled word causing this indicator to appear, Cthulhu will announce that +# the word is misspelled. +SPEECH_SPEAK_MISSPELLED_WORD_INDICATOR = _("Misspelled-word indicator") + +# Translators: This is a label for a widget associated with whether Cthulhu will +# speak blank lines when navigating in document content. +SPEECH_SPEAK_BLANK_LINES = _("Blank lines") + +# Translators: This widget toggles whether or not Cthulhu says the child +# position (e.g., item 6 of 7). +SPEECH_SPEAK_CHILD_POSITION = _("Position in set") + +# Translators: This widget is associated with the setting that determines +# what happens if a user presses Up or Down arrow to move row by row in a GUI +# table, such as a GtkTreeView. Document tables, such as those found in Writer +# and web content, and spreadsheet tables such as those found in Calc are not +# considered GUI tables. If this setting is enabled, Cthulhu will speak the entire +# row; if it is disabled, Cthulhu will only speak the cell with focus. +SPEECH_SPEAK_FULL_ROW_IN_GUI_TABLES = _("Speak full row in _GUI tables") + +# Translators: This widget is associated with the setting that determines +# what happens if a user presses Up or Down arrow to move row by row in a +# document table. In this context, document tables include tables such as those +# found in Writer documents as well as HTML table elements, but exclude +# spreadsheet tables such as found in Calc. If this setting is enabled, Cthulhu +# will speak the entire row; if it is disabled, Cthulhu will only speak the cell +# with focus. +SPEECH_SPEAK_FULL_ROW_IN_DOCUMENT_TABLES = _("Speak full row in _document tables") + +# Translators: This widget is associated with the setting that determines +# what happens if a user presses Up or Down arrow to move row by row in a +# spreadsheet. If this setting is enabled, Cthulhu will speak the entire row; if +# it is disabled, Cthulhu will only speak the cell with focus. +SPEECH_SPEAK_FULL_ROW_IN_SPREADSHEETS = _("Speak full row in sp_readsheets") + +# Translators: This is a label for a widget associated with whether Cthulhu speaks +# tutorial messages / "help text". +SPEECH_SPEAK_TUTORIAL_MESSAGES = _("Tutorial messages") + +# Translators: This is the label for a group of settings on the Speech +# preferences page. It groups settings related to how objects are presented +# via speech (descriptions, mnemonics, child position). +SPEECH_OBJECT_DETAILS = _("Spoken Object Details") + +# Translators: This is a setting in the Speech and Braille preferences pages. +# When enabled, Cthulhu will provide detailed/verbose object descriptions. +OBJECT_PRESENTATION_IS_DETAILED = _("Object presentation is detailed") + +# Translators: This is the label for a checkbox on the Speech preferences page. +# When checked, Cthulhu will apply the user's pronunciation dictionary when speaking. +# The pronunciation dictionary allows users to customize how specific words or +# character sequences are spoken by the speech synthesizer. +SPEECH_USE_PRONUNCIATION_DICTIONARY = _("Use pronunciation dictionary") + +# Translators: This is a label for a group of widgets from which the user can +# chose what is and is not spoken by Cthulhu during navigation or Say All. For +# instance, when the user first navigates into a table, should Cthulhu speak the +# table details or just the newly focused/selected cell? +SPEECH_SPOKEN_CONTEXT = _("Spoken Context") + +# Translators: This is the label for a widget in the preferences dialog. +# If selected speech support will be enabled. +SPEECH_ENABLE_SPEECH = _("Speech support") + +# Translators: Cthulhu has system messages which are similar in nature to +# notifications or announcements. They are most commonly used for Cthulhu to +# communicate Cthulhu-specific information to the user via speech, such as +# confirming the toggling of an Cthulhu setting via command. In instances where +# the message to be displayed is long/detailed, Cthulhu provides a brief +# alternative. Users who prefer that brief alternative can uncheck this +# widget. +SPEECH_SYSTEM_MESSAGES_ARE_DETAILED = _("_System messages are detailed") + +# Translators: The on-screen braille display is a graphical window that displays a +# visual representation of what would be shown on a connected braille display, +# including the braille dots and the text characters they correspond to. It is +# primarily used by developers and testers who may not have a physical braille +# display. +BRAILLE_MONITOR = _("On-screen braille") + +# Translators: The on-screen speech display is a graphical window that displays a +# scrolling log of all text spoken by the screen reader. It is primarily used by +# developers and testers to verify what is being spoken without having to listen +# to the audio. +SPEECH_MONITOR = _("On-screen speech") + +# Translators: This is the title of the on-screen display settings category in the +# Cthulhu Preferences dialog. These settings control the appearance of the on-screen +# braille and speech monitor windows. +ON_SCREEN_DISPLAY = _("On-Screen Display") + +# Translators: This is the label of a spin button that controls the number of braille +# cells displayed in the on-screen braille monitor. +BRAILLE_MONITOR_CELL_COUNT = _("Cell count") + +# Translators: This is the label of a checkbox that controls whether the on-screen +# braille monitor shows Unicode braille dot patterns instead of text characters. +BRAILLE_MONITOR_SHOW_DOTS = _("Show braille dot patterns") + +# Translators: This is the label of a color button that controls the text color +# in the on-screen braille monitor. +BRAILLE_MONITOR_FOREGROUND = _("Text color") + +# Translators: This is the label of a color button that controls the background color +# in the on-screen braille monitor. +BRAILLE_MONITOR_BACKGROUND = _("Background color") + +# Translators: This is the label of a spin button that controls the font size of +# text in the on-screen speech monitor. +SPEECH_MONITOR_FONT_SIZE = _("Font size") + +# Translators: This is the label of a color button that controls the text color +# in the on-screen speech monitor. +SPEECH_MONITOR_FOREGROUND = _("Text color") + +# Translators: This is the label of a color button that controls the background color +# in the on-screen speech monitor. +SPEECH_MONITOR_BACKGROUND = _("Background color") + +# Translators: This is an informational message displayed at the top of the on-screen +# speech display preferences page, briefly explaining the feature and how to toggle it. +SPEECH_MONITOR_INFO = _( + "The on-screen speech display is a window that shows what the screen reader " + "is speaking. The default keybinding to show or hide it is Cthulhu+Shift+d. " + "This can be changed in Commands.", +) + +# Translators: This is an informational message displayed at the top of the on-screen +# braille display preferences page, briefly explaining the feature and how to toggle it. +BRAILLE_MONITOR_INFO = _( + "The on-screen braille display is a window intended for developers that shows " + "what would be presented on a braille display. To show or hide it, assign a " + "keybinding in Commands.", +) + +# Translators: This is the label of the Braille page in the Cthulhu Preferences dialog. +BRAILLE = _("Braille") + +# Translators: This is the label of the Documents page in the Cthulhu Preferences +# dialog. On that page there are settings related to navigating and reading documents, +# including web pages, word processor documents, and PDFs. +DOCUMENTS = _("Documents") + +# Translators: This string is a label for a preferences page containing settings +# that apply when the user is navigating using the application's native navigation +# (e.g., arrow keys handled by the app) rather than the screen reader's browse mode. +NATIVE_NAVIGATION = _("Native navigation") + +# Translators: This string is a label for a group of settings related to what +# happens when a document or web page finishes loading. +PAGE_LOAD = _("Page load") + +# Translators: This is the label for a group of settings related to what Cthulhu +# announces when entering/exiting different types of containers/ancestors. +ANNOUNCEMENTS = _("Container Announcements") + +# Translators: This is a heading for a group of toggleable widgets. Each one controls +# whether Cthulhu announces entering a type of container (e.g. blockquotes, forms, +# landmarks, lists, panels, tables). The full meaning is "Announce when entering +# [container type]" where the container types are listed below this heading. +ANNOUNCE_WHEN_ENTERING = _("Announce when entering") + +# Translators: This is the label of the Echo page in the Cthulhu Preferences dialog. +# On that page there are a variety of settings related to what Cthulhu will echo +# as the user types on the keyboard. +ECHO = _("Echo") + +# Translators: This text appears at the top of the Echo preferences page. It explains +# the difference between key echo (speaking the key pressed) and typing echo (speaking +# what was inserted as a result of typing). +ECHO_INFO = _( + "Key echo speaks the key you pressed. Typing echo speaks what was inserted " + "into the document as a result of your typing.", +) + +# Translators: This is the label of the General page in the Cthulhu Preferences +# dialog. On that page there are a variety of general Cthulhu settings which do +# not fit well into any of the other pages. +GENERAL = _("General") + +# Translators: This is the label of the Key Bindings page in the Cthulhu Preferences +# dialog. On that page there is a list of all Cthulhu commands and the keystrokes +# associated with them. The user can customize the keystrokes for any command. +KEY_BINDINGS = _("Key Bindings") + +# Translators: This is the label of the Commands page in the Cthulhu Preferences +# dialog. On that page there is a list of all Cthulhu commands and the keystrokes +# associated with them. The user can customize the keystrokes for any command. +COMMANDS = _("Commands") + +# Translators: This is the label of the Mouse page in the Cthulhu Preferences dialog. +# On that page there are settings related to what information Cthulhu presents when +# the mouse pointer moves. +MOUSE = _("Mouse") + +# Translators: This is an informational message displayed on Cthulhu's Mouse +# preferences page. Mouse review features may not work properly on +# Wayland because Wayland restricts applications from monitoring the +# mouse pointer. +MOUSE_WAYLAND_WARNING = _("These settings may not work on Wayland.") + +# Translators: This is the label of the Pronunciation page in the Cthulhu Preferences +# dialog. On that page there is UI for customizing how a given word will be sent +# to the speech synthesizer. For instance "idk" can be sent to the speech server +# as "I don't know" or "I D K" or "eye dee kay" or whatever causes the user's +# speech synthesizer to say what the user finds most helpful. +PRONUNCIATION = _("Pronunciation") + +# Translators: This is the label of the Sound page in the Cthulhu Preferences dialog. +# On that page there are settings related to sound output, including volume +# and progress bar beep notifications. +SOUND = _("Sound") + +# Translators: This is the label for a widget in the preferences dialog which +# turns sound support on or off. +SOUND_ENABLE_SOUND_SUPPORT = _("Sound support") + +# Translators: This is the label for the volume control in the Sound preferences. +SOUND_VOLUME = _("Volume") + +# Translators: This is the label of the Tables page in the Cthulhu Preferences +# dialog. On that page there are settings related to table navigation, such as +# whether to speak table cell coordinates, row and column headers, and whether +# to read tables cell by cell or by full rows. +TABLES = _("Tables") + +# Translators: This is the label of the Time and Date page in the Cthulhu Preferences +# dialog. +TIME_AND_DATE = _("Time and Date") + +# Translators: This is the label of the Speech page in the Cthulhu Preferences dialog. +SPEECH = _("Speech") + +# Translators: This is the label of the Text Attributes page in the Cthulhu Preferences +# dialog. +TEXT_ATTRIBUTES = _("Text Attributes") + +# Translators: This text appears at the top of the Text Attributes preferences +# page. It explains that users can configure which text formatting attributes +# (such as bold, italic, underline) should be announced via speech and/or braille, +# and that the order of the attributes controls the order in which they are presented. +TEXT_ATTRIBUTES_INFO = _( + "Configure which text attributes are spoken and/or marked in braille, " + "and the order in which they are presented.", +) + +# Translators: This is the label of the Voice page in the Cthulhu Preferences +# dialog. +VOICE = _("Voice") + +# Translators: This is the label for a group of settings on the Voice preferences +# page. It contains widgets that control whether speech is enabled and how +# certain content (numbers, colors) is spoken. +VOICE_SPEECH_SETTINGS = _("Speech Settings") + +# Translators: This is the accessible label for a button in the Voice preferences +# that opens a dialog to configure settings for a specific voice type (such as +# default, hyperlink, uppercase, or system). The button displays only an icon +# (a cog/gear), so this label is for screen reader users. The %s is replaced +# with the voice type name (e.g., "Default", "Uppercase", "Hyperlink", "System"). +VOICE_TYPE_SETTINGS = C_("VoiceType", "%s Settings") + +# Translators: This is a label for a setting that controls whether Cthulhu will +# automatically switch the speech synthesizer's language based on the language +# of the text or UI element being spoken. +AUTO_LANGUAGE_SWITCHING = _("Automatic language switching") + +# Translators: This label is for a group of buttons on the Text Attributes +# pane of the Cthulhu Preferences dialog. On that pane there is a long list of +# possible text attributes. The user can select one and then, by using the +# Move buttons in this group, adjust when that attribute is spoken by Cthulhu. +TEXT_ATTRIBUTES_ADJUST_SELECTED_ATTRIBUTE = _("Adjust selected attribute") + +# Translators: This label for a widget from which the user can select which +# braille dot or dots should be used to indicate that character(s) being +# shown on the braille display have a particular attribute (e.g. bold). The +# "indicator" can be chosen from among: None, Dot 7, Dot 8, Dots 7 and 8. +TEXT_ATTRIBUTES_BRAILLE_INDICATOR = _("Braille Indicator") + +# Translators: This label is on a button on the Text Attributes pane of the +# Cthulhu Preferences dialog. On that pane there is a long list of possible text +# attributes. The user can select one and then, by using the Move _down one +# button, move that attribute down one line in the list. The ordering in the +# list is important as Cthulhu will speak the selected text attributes in the +# given order. +TEXT_ATTRIBUTES_MOVE_DOWN_ONE = _("Move down one") + +# Translators: This label is on a button on the Text Attributes pane of the +# Cthulhu Preferences dialog. On that pane there is a long list of possible text +# attributes. The user can select one and then, by using the Move _up one +# button, move that attribute up one line in the list. The ordering in the list +# is important as Cthulhu will speak the selected text attributes in the given +# order. +TEXT_ATTRIBUTES_MOVE_UP_ONE = _("Move up one") + +# Translators: This label is on a button on the Text Attributes pane of the +# Cthulhu Preferences dialog. On that pane there is a long list of possible text +# attributes. The user can select one and then, by using the Move to _bottom +# button, move that attribute to the bottom of the list. The ordering in the +# list is important as Cthulhu will speak the selected text attributes in the +# given order. +TEXT_ATTRIBUTES_MOVE_TO_BOTTOM = _("Move to bottom") + +# Translators: This label is on a button on the Text Attributes pane of the +# Cthulhu Preferences dialog. On that pane there is a long list of possible text +# attributes. The user can select one and then, by using the Move to _top +# button, move that attribute to the top of the list. The ordering in the list +# is important as Cthulhu will speak the selected text attributes in the given +# order. +TEXT_ATTRIBUTES_MOVE_TO_TOP = _("Move to top") + +# Translators: This is a label for a group of widgets associated with how Cthulhu +# will present text attributes such as bold, underline, italic, font size, +TEXT_ATTRIBUTES_TEXT_ATTRIBUTES = _("Text attributes") + +# Translators: This is the accessible name for a widget in the preferences dialog +# that allows the user to reorder text attributes in the list. +TEXT_ATTRIBUTES_REORDER = _("Reorder") + +# Translators: This refers to the amount of information Cthulhu provides about a +# particular object that receives focus. The choices are Brief and Verbose. +VERBOSITY = _("Verbosity") # Translators: This refers to the amount of information Cthulhu provides about a # particular object that receives focus. -VERBOSITY_LEVEL_BRIEF = _("Brie_f") +VERBOSITY_LEVEL_VERBOSE = _("Ver_bose") + +# Translators: This is a label for a group of widgets associated with how Cthulhu +# will speak regardless of which voice (Default, Hyperlink, System, Uppercase) +# is being used. For instance, Cthulhu can speak numbers as individual digits +# (e.g. 123 as 1 2 3) or as a whole (e.g. 123 as one hundred and twenty three). +VOICE_GLOBAL_VOICE_SETTINGS = _("Global Voice Settings") + +# Translators: If this setting is enabled, 123 will be spoken as the individual +# digits 1 2 3; otherwise, it will be sent to the synthesizer and (likely) +# spoken as one hundred and twenty three. +VOICE_SPEAK_NUMBERS_AS_DIGITS = _("Speak _numbers as digits") + +# Translators: Having multiple voice types in Cthulhu makes it possible for the +# user to more quickly identify properties of text non-visually, such as the +# fact that text is written in capital letters or is a link; or that text is +# actually visible on the screen as opposed to an Cthulhu-specific message. The +# available voice types in Cthulhu include: default, uppercase, hyperlink, and +# system -- each of which can be configured by the user to sound the way he/she +# finds most helpful. This string is displayed in the label for the group of +# all of the controls associated with configuring a particular voice type. +VOICE_VOICE_TYPE_SETTINGS = _("Voice Type Settings") + +# Translators: This is the label for a widget from which the user can select +# which speech synthesis system Cthulhu should use. Examples of speech synthesis +# systems include Speech Dispatcher and Spiel. +VOICE_SPEECH_SYSTEM = _("Speech _system:") + +# Translators: This is the label for a widget from which the user can select +# which speech synthesizer Cthulhu should use. Examples of speech synthesizers +# include eSpeak, Festival, and Pico. +VOICE_SPEECH_SYNTHESIZER = _("Speech synthesi_zer:") + +# Translators: Cthulhu uses Speech Dispatcher to present content to users via +# text-to-speech. Speech Dispatcher has a feature to control how capital +# letters are presented: Do nothing at all; say the word 'capital' prior to +# presenting a capital letter (which Speech Dispatcher refers to as 'spell'), +# or play a tone (which Speech Dispatcher refers to as a sound 'icon'). Cthulhu +# refers to these things as 'capitalization style'. This string is the text of +# the label through which users can choose which of style they would prefer. +VOICE_CAPITALIZATION_STYLE = _("Capitalization style") + +# Translators: This is the label for a widget from which the user can select +# the language of the speech synthesizer. +VOICE_LANGUAGE = _("_Language:") + +# Translators: This is the label for a widget from which the user can select +# which voice from the current speech synthesizer should be used. Often voices +# have human names such as "Allison". Hence the use of the term person. +VOICE_PERSON = _("_Person:") + +# Translators: This is the label for a widget from which the user can set the +# speaking rate of the current voice. +VOICE_RATE = _("_Rate:") + +# Translators: This is the label for a widget from which the user can set the +# pitch of the current voice. +VOICE_PITCH = _("Pi_tch:") + +# Translators: This is the label for a widget from which the user can set the +# volume of the current voice. +VOICE_VOLUME = _("Vo_lume:") + +# Translators: Having multiple voice types in Cthulhu makes it possible for the +# user to more quickly identify properties of text non-visually, such as the +# fact that text is written in capital letters or is a link; or that text is +# actually visible on the screen as opposed to an Cthulhu-specific message. The +# available voice types in Cthulhu include: default, uppercase, hyperlink, and +# system -- each of which can be configured by the user to sound the way he/she +# finds most helpful. This string is displayed in the label for the combo box +# in which the user selects a voice type to configure. +VOICE_VOICE_TYPE = _("_Voice type:") + +# Translators: This refers to the voice used by Cthulhu by default. +VOICE_TYPE_DEFAULT = _("Default") + +# Translators: This refers to the voice used by Cthulhu when presenting one or more +# characters which are part of a hyperlink. +VOICE_TYPE_HYPERLINK = _("Hyperlink") + +# Translators: This refers to the voice used by Cthulhu when presenting information +# which is not displayed on the screen as text, but is still being communicated +# by the system in some visual fashion. For instance, Cthulhu says "misspelled" to +# indicate the presence of the red squiggly line found under a spelling error; +# Cthulhu might say "3 of 6" when a user Tabs into a list of six items and the +# third item is selected. And so on. +VOICE_TYPE_SYSTEM = _("System") + +# Translators: This refers to the voice used by Cthulhu when presenting one or more +# characters which is written in uppercase. +VOICE_TYPE_UPPERCASE = _("Uppercase") + def notifications_count(count): """Returns the gui label representing the notifications count.""" diff --git a/src/cthulhu/live_region_presenter.py b/src/cthulhu/live_region_presenter.py new file mode 100644 index 0000000..d91487c --- /dev/null +++ b/src/cthulhu/live_region_presenter.py @@ -0,0 +1,729 @@ +# Cthulhu +# +# Copyright 2004-2009 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. + +# pylint: disable=too-many-instance-attributes + +"""Provides live region support.""" + +from __future__ import annotations + +import copy +import enum +import heapq +import time +from typing import TYPE_CHECKING + +from gi.repository import GLib + +from . import ( + cmdnames, + command_manager, + dbus_service, + debug, + focus_manager, + gsettings_registry, + guilabels, + input_event, + keybindings, + messages, + presentation_manager, + script_manager, +) +from .ax_object import AXObject +from .ax_utilities import AXUtilities + +if TYPE_CHECKING: + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + + from .scripts import default + + +class LivePoliteness(enum.Enum): + """Live region politeness levels.""" + + ASSERTIVE = (0, "assertive") + POLITE = (1, "polite") + OFF = (2, "off") + + def __init__(self, priority: int, name_str: str) -> None: + self.priority = priority + self.name_str = name_str + + def from_string(self, value: str | None) -> LivePoliteness: + """Convert string to LivePoliteness enum.""" + + for member in LivePoliteness: + if member.name_str == value: + return member + return LivePoliteness.OFF + + def to_string(self) -> str: + """Convert LivePoliteness enum to string.""" + + return self.name_str + + +class LiveRegionMessage: + """Represents a live region message in the queue.""" + + def __init__( + self, + text: str, + politeness: LivePoliteness, + obj: Atspi.Accessible, + timestamp: float | None = None, + ) -> None: + self.text = text + self.politeness = politeness + self.obj = obj + self.timestamp = timestamp if timestamp is not None else time.time() + + def __lt__(self, other: LiveRegionMessage) -> bool: + """Compare messages for priority queue ordering.""" + + # ASSERTIVE=0 before POLITE=1 + if self.politeness.priority != other.politeness.priority: + return self.politeness.priority < other.politeness.priority + + # Older before newer + return self.timestamp < other.timestamp + + def is_duplicate_of(self, other: LiveRegionMessage | None) -> bool: + """Check if this message is a duplicate of another based on text and time.""" + + if other is None: + return False + if self.text != other.text: + return False + time_delta = self.timestamp - other.timestamp + return time_delta <= 0.25 + + +class LiveRegionMessageQueue: + """Holds the prioritized queue of live region messages.""" + + # Seconds a message is held in the queue before it is discarded. + MSG_KEEPALIVE_TIME = 45 + + def __init__(self, max_size: int) -> None: + self._heap: list[LiveRegionMessage] = [] + self._max_size = max_size + + def enqueue(self, message: LiveRegionMessage) -> None: + """Add a new element to the queue according to priority and timestamp.""" + + heapq.heappush(self._heap, message) + + if len(self._heap) > self._max_size: + self._heap.sort() + self._heap.pop() + heapq.heapify(self._heap) + + def dequeue(self) -> LiveRegionMessage | None: + """Get the highest priority element from the queue.""" + + if not self._heap: + return None + + return heapq.heappop(self._heap) + + def clear(self) -> None: + """Clear the queue.""" + + self._heap.clear() + + def purge_by_keep_alive(self) -> None: + """Purge items from the queue that are older than the keepalive time.""" + + current_time = time.time() + + self._heap = [ + msg for msg in self._heap if msg.timestamp + self.MSG_KEEPALIVE_TIME > current_time + ] + heapq.heapify(self._heap) + + def purge_by_priority(self, priority: LivePoliteness) -> None: + """Purge items from the queue that have a lower than or equal priority to priority.""" + + self._heap = [msg for msg in self._heap if msg.politeness.priority < priority.priority] + heapq.heapify(self._heap) + + def __len__(self) -> int: + """Return the length of the queue.""" + return len(self._heap) + + +@gsettings_registry.get_registry().gsettings_schema( + "org.stormux.Cthulhu.LiveRegions", + name="live-regions", +) +class LiveRegionPresenter: + """Presents live region announcements.""" + + _SCHEMA = "live-regions" + KEY_ENABLED = "enabled" + KEY_PRESENT_FROM_INACTIVE_TAB = "present-from-inactive-tab" + + 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, + ) + + # Maximum size for message queue and cache + QUEUE_SIZE = 9 + + def __init__(self) -> None: + self.msg_queue = LiveRegionMessageQueue(max_size=self.QUEUE_SIZE) + + self.msg_cache: list[str] = [] + self._politeness_overrides: dict[int, LivePoliteness] = {} + self._restore_overrides: dict[int, LivePoliteness] = {} + + self._last_presented_message: LiveRegionMessage | None = None + self._monitoring: bool = True + # Use QUEUE_SIZE as sentinel to indicate "not yet navigating" + self._current_index: int = self.QUEUE_SIZE + self._initialized: bool = False + + msg = "LIVE REGION PRESENTER: Registering D-Bus commands." + debug.print_message(debug.LEVEL_INFO, msg, True) + controller = dbus_service.get_remote_controller() + controller.register_decorated_module("LiveRegionPresenter", 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_LIVE_REGIONS + + # Keybinding (same for desktop and laptop) + kb_backslash = keybindings.KeyBinding("backslash", keybindings.CTHULHU_MODIFIER_MASK) + + manager.add_command( + command_manager.KeyboardCommand( + "toggle_live_region_support", + self.toggle_monitoring, + group_label, + cmdnames.LIVE_REGIONS_MONITOR, + desktop_keybinding=kb_backslash, + laptop_keybinding=kb_backslash, + is_group_toggle=True, + ), + ) + + manager.add_command( + command_manager.KeyboardCommand( + "present_previous_live_region_message", + self.present_previous_live_region_message, + group_label, + cmdnames.LIVE_REGIONS_PREVIOUS, + desktop_keybinding=None, + laptop_keybinding=None, + ), + ) + + manager.add_command( + command_manager.KeyboardCommand( + "advance_live_politeness", + self._advance_politeness_level, + group_label, + cmdnames.LIVE_REGIONS_ADVANCE_POLITENESS, + desktop_keybinding=None, + laptop_keybinding=None, + ), + ) + + manager.add_command( + command_manager.KeyboardCommand( + "toggle_live_region_presentation", + self.toggle_live_region_presentation, + group_label, + cmdnames.LIVE_REGIONS_ARE_ANNOUNCED, + desktop_keybinding=None, + laptop_keybinding=None, + is_group_toggle=True, + ), + ) + + manager.add_command( + command_manager.KeyboardCommand( + "present_next_live_region_message", + self.present_next_live_region_message, + group_label, + cmdnames.LIVE_REGIONS_NEXT, + desktop_keybinding=None, + laptop_keybinding=None, + ), + ) + + msg = "LIVE REGION PRESENTER: Commands set up." + debug.print_message(debug.LEVEL_INFO, msg, True) + + def reset(self) -> None: + """Reset the live region presenter.""" + + self._politeness_overrides = {} + + def handle_event(self, script: default.Script, event: Atspi.Event) -> None: + """Handles a live region event.""" + + if not self.is_presentable_live_region_event(script, event): + return + + politeness = self._get_live_event_type(event.source) + if politeness == LivePoliteness.OFF: + return + if politeness == LivePoliteness.ASSERTIVE: + self.msg_queue.purge_by_priority(LivePoliteness.POLITE) + + text = self._get_message(event) + if not text: + return + + message = LiveRegionMessage(text=text, politeness=politeness, obj=event.source) + + # Check for duplicate and update tracking. + if message.is_duplicate_of(self._last_presented_message): + msg = f"LIVE REGION PRESENTER: Ignoring duplicate message: {text}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return + + self._last_presented_message = message + + if len(self.msg_queue) == 0: + GLib.timeout_add(100, self._pump_messages) + + self.msg_queue.enqueue(message) + + def is_presentable_live_region_event(self, script: default.Script, event: Atspi.Event) -> bool: + """Returns whether the given event is a presentable live region event.""" + + # Live regions were invented to work with web content. At the time the ARIA working group + # invented them, they failed to ask for the creation of ATK/AT-SPI API that would make it + # possible to distinguish live region events from DOM mutations. This, combined with what + # is stated in the Core-AAM, means that user agents are firing both children changed and + # text changed events which can cause us to double-present live region messages. Based on + # testing of various user agents, we sometimes do not receive children changed events, but + # do receive text changed events. Therefore we only pay attention to the latter here. + # TODO - JD: Now that we have the "notification" event in AT-SPI, handle that here is well. + if not event.type.startswith("object:text-changed:insert"): + msg = f"LIVE REGION PRESENTER: Ignoring event of type {event.type}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + if not self.get_is_enabled(): + msg = "LIVE REGION PRESENTER: Live region presenter is not enabled." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + if not AXUtilities.is_live_region(event.source): + msg = "LIVE REGION PRESENTER: Event is not from a live region." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + if not self.get_present_live_region_from_inactive_tab(): + this_doc = script.utilities.get_top_level_document_for_object(event.source) + active_doc = script.utilities.active_document() + if this_doc and active_doc and this_doc != active_doc: + tokens = [ + "LIVE REGION PRESENTER: Event from", + this_doc, + "but active document is", + active_doc, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + alert = AXUtilities.find_ancestor(event.source, AXUtilities.is_aria_alert) + if alert and AXUtilities.get_focused_object(alert) == event.source: + msg = "LIVE REGION PRESENTER: Focused source will be presented as part of alert" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + return True + + def _pump_messages(self) -> bool: + """Presents queued messages.""" + + if len(self.msg_queue) > 0: + debug.print_message(debug.LEVEL_INFO, "\nvvvvv PRESENT LIVE REGION MESSAGE vvvvv") + self.msg_queue.purge_by_keep_alive() + message = self.msg_queue.dequeue() + if message is None: + return False + + if self._monitoring: + presentation_manager.get_manager().present_message(message.text) + else: + msg = "INFO: Not presenting message because monitoring is off" + debug.print_message(debug.LEVEL_INFO, msg, True) + + self._cache_message(message.text) + + # We still want to maintain our queue if we are not monitoring. + if not self._monitoring: + self.msg_queue.purge_by_keep_alive() + + msg = f"LIVE REGIONS: messages in queue: {len(self.msg_queue)}" + debug.print_message(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, "^^^^^ PRESENT LIVE REGION MESSAGE ^^^^^\n") + return len(self.msg_queue) > 0 + + def _advance_politeness_level( + self, + _script: default.Script, + _event: input_event.InputEvent, + ) -> bool: + """Advance the politeness level of the given object""" + + if not self.get_is_enabled(): + presentation_manager.get_manager().present_message( + messages.LIVE_REGIONS_SUPPORT_DISABLED, + ) + return False + + obj = focus_manager.get_manager().get_locus_of_focus() + object_id = self._get_object_id(obj) + try: + # The current priority is either a previous override or the live property. If an + # exception is thrown, an override for this object has never occurred or the object + # does not have live markup. In either case, set the override to LivePoliteness.OFF. + cur_priority = self._politeness_overrides[object_id] + except KeyError: + attrs = AXObject.get_attributes_dict(obj, False) + cur_priority = LivePoliteness.OFF.from_string(attrs.get("container-live")) + + if cur_priority == LivePoliteness.OFF: + self._politeness_overrides[object_id] = LivePoliteness.POLITE + presentation_manager.get_manager().present_message(messages.LIVE_REGIONS_LEVEL_POLITE) + elif cur_priority == LivePoliteness.POLITE: + self._politeness_overrides[object_id] = LivePoliteness.ASSERTIVE + presentation_manager.get_manager().present_message( + messages.LIVE_REGIONS_LEVEL_ASSERTIVE, + ) + elif cur_priority == LivePoliteness.ASSERTIVE: + self._politeness_overrides[object_id] = LivePoliteness.OFF + presentation_manager.get_manager().present_message(messages.LIVE_REGIONS_LEVEL_OFF) + return True + + def go_last_live_region( + self, + script: default.Script, + _event: input_event.InputEvent | None, + ) -> bool: + """Move to the last announced live region and speak the contents of that object.""" + + if self._last_presented_message is None: + msg = "LIVE REGION PRESENTER: No last presented live region message." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + if not self.get_is_enabled(): + presentation_manager.get_manager().present_message( + messages.LIVE_REGIONS_SUPPORT_DISABLED, + ) + return False + + obj = self._last_presented_message.obj + script.utilities.set_caret_position(obj, 0) + presentation_manager.get_manager().speak_contents( + script.utilities.get_object_contents_at_offset(obj, 0), + ) + return True + + def present_previous_live_region_message( + self, + script: default.Script, + _event: input_event.InputEvent | None, + ) -> bool: + """Presents the previous live region message.""" + + if not self.get_is_enabled(): + presentation_manager.get_manager().present_message( + messages.LIVE_REGIONS_SUPPORT_DISABLED, + ) + return False + + tokens = [ + "LIVE REGION PRESENTER: present_previous_live_region_message. Script:", + script, + "Current index:", + self._current_index, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if not self.msg_cache: + presentation_manager.get_manager().present_message(messages.LIVE_REGIONS_NO_MESSAGES) + return True + + oldest_index = -len(self.msg_cache) + if self._current_index == oldest_index: + presentation_manager.get_manager().present_message(messages.LIVE_REGIONS_LIST_TOP) + message = self.msg_cache[oldest_index] + presentation_manager.get_manager().present_message(message) + return True + + if self._current_index >= 0: + self._current_index = -1 + else: + self._current_index -= 1 + + message = self.msg_cache[self._current_index] + presentation_manager.get_manager().present_message(message) + return True + + def present_next_live_region_message( + self, + script: default.Script, + _event: input_event.InputEvent | None, + ) -> bool: + """Presents the next live region message.""" + + if not self.get_is_enabled(): + presentation_manager.get_manager().present_message( + messages.LIVE_REGIONS_SUPPORT_DISABLED, + ) + return False + + tokens = [ + "LIVE REGION PRESENTER: present_next_live_region_message. Script:", + script, + "Current index:", + self._current_index, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if not self.msg_cache: + presentation_manager.get_manager().present_message(messages.LIVE_REGIONS_NO_MESSAGES) + return True + + oldest_index = -len(self.msg_cache) + if self._current_index == -1: + presentation_manager.get_manager().present_message(messages.LIVE_REGIONS_LIST_BOTTOM) + message = self.msg_cache[-1] + presentation_manager.get_manager().present_message(message) + return True + + if self._current_index >= 0: + self._current_index = -1 + elif self._current_index < oldest_index: + self._current_index = oldest_index + else: + self._current_index += 1 + + message = self.msg_cache[self._current_index] + presentation_manager.get_manager().present_message(message) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_ENABLED, + schema="live-regions", + gtype="b", + default=True, + summary="Enable live region support", + migration_key="enableLiveRegions", + ) + def get_is_enabled(self) -> bool: + """Returns whether live region support is enabled.""" + + return self._get_setting(self.KEY_ENABLED, True) + + def set_is_enabled(self, value: bool) -> bool: + """Sets whether live region support is enabled.""" + + if self.get_is_enabled() == value: + return True + + msg = f"LIVE REGION PRESENTER: Setting enabled to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value(self._SCHEMA, self.KEY_ENABLED, value) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_PRESENT_FROM_INACTIVE_TAB, + schema="live-regions", + gtype="b", + default=False, + summary="Present live regions from inactive tabs", + migration_key="presentLiveRegionFromInactiveTab", + ) + def get_present_live_region_from_inactive_tab(self) -> bool: + """Returns whether live region messages are presented from inactive tabs.""" + + return self._get_setting(self.KEY_PRESENT_FROM_INACTIVE_TAB, False) + + def set_present_live_region_from_inactive_tab(self, value: bool) -> bool: + """Sets whether live region messages are presented from inactive tabs.""" + + if self.get_present_live_region_from_inactive_tab() == value: + return True + + msg = f"LIVE REGION PRESENTER: Setting presentLiveRegionFromInactiveTab to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_PRESENT_FROM_INACTIVE_TAB, + value, + ) + return True + + def toggle_monitoring(self, _script: default.Script, _event: input_event.InputEvent) -> bool: + """Toggles live region monitoring on and off.""" + + if not self.get_is_enabled(): + self.set_is_enabled(True) + presentation_manager.get_manager().present_message(messages.LIVE_REGIONS_ENABLED) + return True + + self.set_is_enabled(False) + self.flush_messages() + self._current_index = self.QUEUE_SIZE + presentation_manager.get_manager().present_message(messages.LIVE_REGIONS_DISABLED) + return True + + def toggle_live_region_presentation( + self, + script: default.Script, + _event: input_event.InputEvent, + ) -> bool: + """Toggles between presenting live regions and not presenting them.""" + + if not self.get_is_enabled(): + presentation_manager.get_manager().present_message( + messages.LIVE_REGIONS_SUPPORT_DISABLED, + ) + return False + + document = script.utilities.active_document() + + # The user is currently monitoring live regions but now wants to + # change all live region politeness on page to LivePoliteness.OFF. + if self._monitoring: + presentation_manager.get_manager().present_message(messages.LIVE_REGIONS_ALL_OFF) + self.msg_queue.clear() + + self._restore_overrides = copy.copy(self._politeness_overrides) + for override in self._politeness_overrides: + self._politeness_overrides[override] = LivePoliteness.OFF + + matches = AXUtilities.find_all_live_regions(document) + for match in matches: + objectid = self._get_object_id(match) + self._politeness_overrides[objectid] = LivePoliteness.OFF + + self._monitoring = False + return True + + # The user wants to restore politeness levels, + for key, value in self._restore_overrides.items(): + self._politeness_overrides[key] = value + presentation_manager.get_manager().present_message(messages.LIVE_REGIONS_ALL_RESTORED) + self._monitoring = True + return True + + def _find_container(self, obj: Atspi.Accessible) -> Atspi.Accessible | None: + def is_container(x: Atspi.Accessible) -> bool: + attrs = AXObject.get_attributes_dict(x, False) + return bool(attrs.get("atomic")) + + container = AXUtilities.find_ancestor_inclusive(obj, is_container) + tokens = ["LIVE REGION PRESENTER: Container for", obj, "is", container] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return container + + def _get_message(self, event: Atspi.Event) -> str | None: + """Gets the message associated with a given live event.""" + + attrs = AXObject.get_attributes_dict(event.source, False) + content = "" + + script = script_manager.get_manager().get_active_script() + if script is None: + return None + + if attrs.get("container-atomic") != "true": + if "\ufffc" not in event.any_data: + content = event.any_data + else: + content = script.utilities.expand_eocs( + event.source, + event.detail1, + event.detail1 + event.detail2, + ) + else: + container = self._find_container(event.source) + content = script.utilities.expand_eocs(container) + + content = content.strip() + if not content: + return None + + name = AXObject.get_name(event.source).strip() + if name and name != content: + content = f"{name}. {content}" + return content + + def flush_messages(self) -> None: + """Flushes the message queue.""" + + self.msg_queue.clear() + + def _cache_message(self, utts: str) -> None: + """Cache a message in our cache list of length QUEUE_SIZE""" + + self.msg_cache.append(utts) + if len(self.msg_cache) > self.QUEUE_SIZE: + self.msg_cache.pop(0) + + def _get_live_event_type(self, obj: Atspi.Accessible) -> LivePoliteness: + """Returns the live politeness setting for a given object.""" + + object_id = self._get_object_id(obj) + if object_id in self._politeness_overrides: + return self._politeness_overrides[object_id] + + attrs = AXObject.get_attributes_dict(obj, False) + return LivePoliteness.OFF.from_string(attrs.get("container-live")) + + def _get_object_id(self, obj: Atspi.Accessible) -> int: + """Returns the HTML 'id' or a path to the object is an HTML id is unavailable.""" + + attrs = AXObject.get_attributes_dict(obj, False) + return hash(attrs.get("id") or obj) + + +_presenter: LiveRegionPresenter = LiveRegionPresenter() + + +def get_presenter() -> LiveRegionPresenter: + """Returns the Live Region Presenter singleton.""" + + return _presenter diff --git a/src/cthulhu/meson.build b/src/cthulhu/meson.build index 377ea22..aa5cb60 100644 --- a/src/cthulhu/meson.build +++ b/src/cthulhu/meson.build @@ -34,12 +34,15 @@ cthulhu_python_sources = files([ 'braille.py', 'braille_generator.py', 'braille_monitor.py', + 'braille_presenter.py', 'braille_rolenames.py', 'brlmon.py', 'brltablenames.py', 'bypass_mode_manager.py', + 'caret_navigator.py', 'caret_navigation.py', 'chat.py', + 'chat_presenter.py', 'chnames.py', 'clipboard.py', 'cmdnames.py', @@ -57,6 +60,7 @@ cthulhu_python_sources = files([ 'debug.py', 'debugging_tools_manager.py', 'desktop_keyboardmap.py', + 'document_presenter.py', 'dynamic_api_manager.py', 'event_manager.py', 'find.py', @@ -79,6 +83,7 @@ cthulhu_python_sources = files([ 'laptop_keyboardmap.py', 'learn_mode_presenter.py', 'liveregions.py', + 'live_region_presenter.py', 'logger.py', 'mako_notification_monitor.py', 'mathsymbols.py', @@ -95,9 +100,12 @@ cthulhu_python_sources = files([ 'cthulhu_gui_profile.py', 'phonnames.py', 'preferences_grid_base.py', + 'presentation_manager.py', 'plugin.py', 'plugin_system_manager.py', + 'profile_manager.py', 'pronunciation_dict.py', + 'pronunciation_dictionary_manager.py', 'punctuation_settings.py', 'resource_manager.py', 'role_keys.py', @@ -108,21 +116,30 @@ cthulhu_python_sources = files([ 'settings_manager.py', 'signal_manager.py', 'sleep_mode_manager.py', + 'say_all_presenter.py', 'sound.py', 'sound_helper.py', 'sound_sink.py', 'sound_generator.py', + 'sound_presenter.py', 'sound_theme_manager.py', 'speech_and_verbosity_manager.py', 'speech_history.py', 'speech.py', 'spellcheck.py', + 'spellcheck_presenter.py', 'speechdispatcherfactory.py', 'speech_generator.py', + 'speech_manager.py', + 'speech_monitor.py', + 'speech_presenter.py', 'speechserver.py', 'spiel.py', 'ssml.py', + 'structural_navigator.py', 'systemd.py', + 'system_information_presenter.py', + 'table_navigator.py', 'piperfactory.py', 'piper_voice_manager.py', 'piper_audio_player.py', @@ -132,6 +149,7 @@ cthulhu_python_sources = files([ 'translation_manager.py', 'tutorialgenerator.py', 'typing_echo_presenter.py', + 'text_attribute_manager.py', 'wnck_support.py', 'where_am_i_presenter.py', ]) diff --git a/src/cthulhu/notification_presenter.py b/src/cthulhu/notification_presenter.py index 7eaf451..3aac39e 100644 --- a/src/cthulhu/notification_presenter.py +++ b/src/cthulhu/notification_presenter.py @@ -1,9 +1,10 @@ -#!/usr/bin/env python3 +# Cthulhu # -# Copyright (c) 2024 Stormux -# Copyright (c) 2010-2012 The Orca Team -# Copyright (c) 2012 Igalia, S.L. -# Copyright (c) 2005-2010 Sun Microsystems Inc. +# Copyright 2023 Igalia, S.L. +# Author: Joanmarie Diggs +# Based on the feature created by: +# Author: Jose Vilmar +# Copyright 2010 Informal Informatica LTDA. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -19,428 +20,322 @@ # 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 -"""Module for notification messages.""" +# pylint: disable=too-many-arguments +# pylint: disable=too-many-positional-arguments +# pylint: disable=too-many-locals + +"""Module for notification messages""" + +from __future__ import annotations -from dataclasses import dataclass, field import time -from typing import Optional, Dict, List, Any, Callable +from typing import TYPE_CHECKING import gi gi.require_version("Gtk", "3.0") -from gi.repository import GObject -from gi.repository import Gtk -from gi.repository import Gdk +from gi.repository import GObject, Gtk -from . import cmdnames -from . import debug -from . import guilabels -from . import input_event -from . import keybindings -from . import messages +from . import ( + cmdnames, + command_manager, + dbus_service, + debug, + guilabels, + input_event, + messages, + presentation_manager, +) +if TYPE_CHECKING: + from collections.abc import Callable -@dataclass -class NotificationEntry: - """Represents a notification saved in Cthulhu's history.""" - - message: str - timestamp: float - source: str = "generic" - source_generation: int = 0 - notification_id: Optional[int] = None - live: bool = False - actions: Dict[str, str] = field(default_factory=dict) - app_name: str = "" - summary: str = "" - body: str = "" - urgency: int = -1 - desktop_entry: str = "" + from .scripts import default class NotificationPresenter: """Provides access to the notification history.""" def __init__(self) -> None: - self._gui: Optional[Any] = None - self._mako_monitor: Optional[Any] = None - self._handlers: Dict[str, Callable] = self._setup_handlers() - self._bindings: keybindings.KeyBindings = self._setup_bindings() + self._gui: NotificationListGUI | None = None self._max_size: int = 55 # The list is arranged with the most recent message being at the end of # the list. The current index is relative to, and used directly, with the # python list, i.e. self._notifications[-3] would return the third-to-last # notification message. - self._notifications: List[NotificationEntry] = [] + self._notifications: list[tuple[str, float]] = [] self._current_index: int = -1 + self._initialized: bool = False - def get_bindings(self) -> keybindings.KeyBindings: - """Returns the notification-presenter keybindings.""" + msg = "NOTIFICATION PRESENTER: Registering D-Bus commands." + debug.print_message(debug.LEVEL_INFO, msg, True) + controller = dbus_service.get_remote_controller() + controller.register_decorated_module("NotificationPresenter", self) - return self._bindings + def set_up_commands(self) -> None: + """Sets up commands with CommandManager.""" - def get_handlers(self) -> Dict[str, Callable]: - """Returns the notification-presenter handlers.""" + if self._initialized: + return + self._initialized = True - return self._handlers + manager = command_manager.get_manager() + group_label = guilabels.KB_GROUP_NOTIFICATIONS - def set_mako_monitor(self, monitor: Any) -> None: - """Associates the mako D-Bus monitor with this presenter.""" + commands_data = [ + ( + "present_last_notification", + self.present_last_notification, + cmdnames.NOTIFICATION_MESSAGES_LAST, + ), + ( + "present_next_notification", + self.present_next_notification, + cmdnames.NOTIFICATION_MESSAGES_NEXT, + ), + ( + "present_previous_notification", + self.present_previous_notification, + cmdnames.NOTIFICATION_MESSAGES_PREVIOUS, + ), + ( + "show_notification_list", + self.show_notification_list, + cmdnames.NOTIFICATION_MESSAGES_LIST, + ), + ] - self._mako_monitor = monitor + for name, function, description in commands_data: + manager.add_command( + command_manager.KeyboardCommand( + name, + function, + group_label, + description, + desktop_keybinding=None, + laptop_keybinding=None, + ), + ) - def save_notification( - self, - message: str, - source: str = "generic", - source_generation: int = 0, - notification_id: Optional[int] = None, - live: bool = False, - actions: Optional[Dict[str, str]] = None, - app_name: str = "", - summary: str = "", - body: str = "", - urgency: int = -1, - desktop_entry: str = "", - ) -> NotificationEntry: + msg = "NOTIFICATION PRESENTER: Commands set up." + debug.print_message(debug.LEVEL_INFO, msg, True) + + def save_notification(self, message: str) -> None: """Adds message to the list of notification messages.""" tokens = ["NOTIFICATION PRESENTER: Adding '", message, "'."] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) to_remove = max(len(self._notifications) - self._max_size + 1, 0) self._notifications = self._notifications[to_remove:] - entry = NotificationEntry( - message=message, - timestamp=time.time(), - source=source, - source_generation=source_generation, - notification_id=notification_id, - live=live, - actions=dict(actions or {}), - app_name=app_name, - summary=summary, - body=body, - urgency=urgency, - desktop_entry=desktop_entry, - ) - self._notifications.append(entry) - return entry + self._notifications.append((message, time.time())) def clear_list(self) -> None: """Clears the notifications list.""" msg = "NOTIFICATION PRESENTER: Clearing list." - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) self._notifications = [] self._current_index = -1 - def remove_entry(self, entry: Optional[NotificationEntry]) -> bool: - """Removes entry from the notification history.""" - - if entry is None: - return False - - try: - index = self._notifications.index(entry) - except ValueError: - return False - - del self._notifications[index] - if not self._notifications: - self._current_index = -1 - return True - - if self._current_index == -1: - return True - - if index < self._current_index: - self._current_index -= 1 - elif index == self._current_index and self._current_index >= len(self._notifications): - self._current_index = -1 - - return True - - def refresh_live_notifications(self) -> bool: - """Refreshes live mako state without announcing new notifications.""" - - if self._mako_monitor is None: - return False - - return self._mako_monitor.refresh(announce_new=False) - - def sync_live_notifications( - self, - source: str, - live_notifications: Dict[int, Dict[str, Any]], - source_generation: int, - ) -> None: - """Synchronizes current live notification metadata with history.""" - - live_ids = set(live_notifications) - for entry in self._notifications: - if entry.source != source or entry.source_generation != source_generation: - continue - - if entry.notification_id in live_ids: - metadata = live_notifications[entry.notification_id] - entry.live = True - entry.message = metadata.get("message", entry.message) - entry.actions = dict(metadata.get("actions") or {}) - entry.app_name = metadata.get("app_name", entry.app_name) - entry.summary = metadata.get("summary", entry.summary) - entry.body = metadata.get("body", entry.body) - entry.urgency = metadata.get("urgency", entry.urgency) - entry.desktop_entry = metadata.get("desktop_entry", entry.desktop_entry) - elif entry.live: - entry.live = False - - def mark_source_unavailable(self, source: str) -> None: - """Marks all live notifications from source as no longer live.""" - - for entry in self._notifications: - if entry.source == source and entry.live: - entry.live = False - - def can_control_entry(self, entry: Optional[NotificationEntry]) -> bool: - """Returns True if entry can still be controlled through mako.""" - - if entry is None or not entry.live or entry.notification_id is None: - return False - - if self._mako_monitor is None: - return False - - return self._mako_monitor.is_current_entry(entry) - - def get_actions_for_entry(self, entry: Optional[NotificationEntry]) -> Dict[str, str]: - """Returns the live action map for entry, if it is controllable.""" - - if not self.can_control_entry(entry): - return {} - - return dict(entry.actions) - - def dismiss_entry(self, script: Any, entry: Optional[NotificationEntry]) -> bool: - """Dismisses the live notification represented by entry.""" - - if not self.can_control_entry(entry) or self._mako_monitor is None: - return False - - result = self._mako_monitor.dismiss_notification(entry.notification_id) - if result: - entry.live = False - self.remove_entry(entry) - script.presentMessage(messages.NOTIFICATION_DISMISSED) - return result - - def invoke_action_for_entry( - self, - script: Any, - entry: Optional[NotificationEntry], - action_key: str, - ) -> bool: - """Invokes action_key for the live notification represented by entry.""" - - if not self.can_control_entry(entry) or self._mako_monitor is None: - return False - - result = self._mako_monitor.invoke_action(entry.notification_id, action_key) - if result: - script.presentMessage(messages.NOTIFICATION_ACTION_INVOKED) - return result - - def _setup_handlers(self) -> Dict[str, Callable]: - """Sets up and returns the notification-presenter input event handlers.""" - - handlers = {} - - handlers["present_last_notification"] = input_event.InputEventHandler( - self._present_last_notification, - cmdnames.NOTIFICATION_MESSAGES_LAST, - ) - - handlers["present_next_notification"] = input_event.InputEventHandler( - self._present_next_notification, - cmdnames.NOTIFICATION_MESSAGES_NEXT, - ) - - handlers["present_previous_notification"] = input_event.InputEventHandler( - self._present_previous_notification, - cmdnames.NOTIFICATION_MESSAGES_PREVIOUS, - ) - - handlers["show_notification_list"] = input_event.InputEventHandler( - self._show_notification_list, - cmdnames.NOTIFICATION_MESSAGES_LIST, - ) - - return handlers - - def _setup_bindings(self): - """Sets up and returns the notification-presenter key bindings.""" - - bindings = keybindings.KeyBindings() - - bindings.add( - keybindings.KeyBinding( - "n", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("present_last_notification"), - ) - ) - - bindings.add( - keybindings.KeyBinding( - "", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("present_next_notification"), - ) - ) - - bindings.add( - keybindings.KeyBinding( - "", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("present_previous_notification"), - ) - ) - - bindings.add( - keybindings.KeyBinding( - "n", - keybindings.defaultModifierMask, - keybindings.CTHULHU_CTRL_MODIFIER_MASK, - self._handlers.get("show_notification_list"), - ) - ) - - return bindings - - def _timestamp_to_string(self, timestamp): + def _timestamp_to_string(self, timestamp: float) -> str: diff = time.time() - timestamp if diff < 60: - return messages.secondsAgo(diff) + return messages.seconds_ago(diff) if diff < 3600: minutes = round(diff / 60) - return messages.minutesAgo(minutes) + return messages.minutes_ago(minutes) if diff < 86400: hours = round(diff / 3600) - return messages.hoursAgo(hours) + return messages.hours_ago(hours) days = round(diff / 86400) - return messages.daysAgo(days) + return messages.days_ago(days) - def _entry_to_string(self, entry: NotificationEntry) -> str: - return f"{entry.message} {self._timestamp_to_string(entry.timestamp)}" - - def _present_last_notification(self, script, event=None): + @dbus_service.command + def present_last_notification( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Presents the last notification.""" + tokens = [ + "NOTIFICATION PRESENTER: present_last_notification. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if not self._notifications: - script.presentMessage(messages.NOTIFICATION_NO_MESSAGES) + if notify_user: + presentation_manager.get_manager().present_message( + messages.NOTIFICATION_NO_MESSAGES, + ) return True - msg = "NOTIFICATION PRESENTER: Presenting last notification." - debug.printMessage(debug.LEVEL_INFO, msg, True) - - entry = self._notifications[-1] - script.presentMessage(self._entry_to_string(entry)) + message, timestamp = self._notifications[-1] + string = f"{message} {self._timestamp_to_string(timestamp)}" + presentation_manager.get_manager().present_message(string) self._current_index = -1 return True - def _present_previous_notification(self, script, event=None): + @dbus_service.command + def present_previous_notification( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Presents the previous notification.""" + tokens = [ + "NOTIFICATION PRESENTER: present_previous_notification. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + "Current index:", + self._current_index, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if not self._notifications: - script.presentMessage(messages.NOTIFICATION_NO_MESSAGES) + if notify_user: + presentation_manager.get_manager().present_message( + messages.NOTIFICATION_NO_MESSAGES, + ) return True - msg = ( - f"NOTIFICATION PRESENTER: Presenting previous notification. " - f"Current index: {self._current_index}" - ) - debug.printMessage(debug.LEVEL_INFO, msg, True) - + # This is the first (oldest) message in the list. if self._current_index == 0: - script.presentMessage(messages.NOTIFICATION_LIST_TOP) - entry = self._notifications[self._current_index] + presentation_manager.get_manager().present_message(messages.NOTIFICATION_LIST_TOP) + message, timestamp = self._notifications[self._current_index] else: try: index = self._current_index - 1 - entry = self._notifications[index] + message, timestamp = self._notifications[index] self._current_index -= 1 except IndexError: msg = "NOTIFICATION PRESENTER: Handling IndexError exception." - debug.printMessage(debug.LEVEL_INFO, msg, True) - script.presentMessage(messages.NOTIFICATION_LIST_TOP) - entry = self._notifications[self._current_index] + debug.print_message(debug.LEVEL_INFO, msg, True) + presentation_manager.get_manager().present_message(messages.NOTIFICATION_LIST_TOP) + message, timestamp = self._notifications[self._current_index] - script.presentMessage(self._entry_to_string(entry)) + string = f"{message} {self._timestamp_to_string(timestamp)}" + presentation_manager.get_manager().present_message(string) return True - def _present_next_notification(self, script, event=None): + @dbus_service.command + def present_next_notification( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Presents the next notification.""" + tokens = [ + "NOTIFICATION PRESENTER: present_next_notification. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + "Current index:", + self._current_index, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if not self._notifications: - script.presentMessage(messages.NOTIFICATION_NO_MESSAGES) + if notify_user: + presentation_manager.get_manager().present_message( + messages.NOTIFICATION_NO_MESSAGES, + ) return True - msg = ( - f"NOTIFICATION PRESENTER: Presenting next notification. " - f"Current index: {self._current_index}" - ) - debug.printMessage(debug.LEVEL_INFO, msg, True) - + # This is the last (newest) message in the list. if self._current_index == -1: - script.presentMessage(messages.NOTIFICATION_LIST_BOTTOM) - entry = self._notifications[self._current_index] + presentation_manager.get_manager().present_message(messages.NOTIFICATION_LIST_BOTTOM) + message, timestamp = self._notifications[self._current_index] else: try: index = self._current_index + 1 - entry = self._notifications[index] + message, timestamp = self._notifications[index] self._current_index += 1 except IndexError: msg = "NOTIFICATION PRESENTER: Handling IndexError exception." - debug.printMessage(debug.LEVEL_INFO, msg, True) - script.presentMessage(messages.NOTIFICATION_LIST_BOTTOM) - entry = self._notifications[self._current_index] + debug.print_message(debug.LEVEL_INFO, msg, True) + presentation_manager.get_manager().present_message( + messages.NOTIFICATION_LIST_BOTTOM, + ) + message, timestamp = self._notifications[self._current_index] - script.presentMessage(self._entry_to_string(entry)) + string = f"{message} {self._timestamp_to_string(timestamp)}" + presentation_manager.get_manager().present_message(string) return True - def _show_notification_list(self, script, event=None): + @dbus_service.command + def show_notification_list( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Opens a dialog with a list of the notifications.""" - self.refresh_live_notifications() + tokens = [ + "NOTIFICATION PRESENTER: show_notification_list. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if not self._notifications: - script.presentMessage(messages.NOTIFICATION_NO_MESSAGES) + if notify_user: + presentation_manager.get_manager().present_message( + messages.NOTIFICATION_NO_MESSAGES, + ) return True - msg = "NOTIFICATION PRESENTER: Showing notification list." - debug.printMessage(debug.LEVEL_INFO, msg, True) + if self._gui: + msg = "NOTIFICATION PRESENTER: Notification list already exists. Showing." + debug.print_message(debug.LEVEL_INFO, msg, True) + self._gui.show_gui() + return True - entries = list(reversed(self._notifications)) + rows = [ + (message, self._timestamp_to_string(timestamp)) + for message, timestamp in reversed(self._notifications) + ] title = guilabels.notifications_count(len(self._notifications)) column_headers = [ guilabels.NOTIFICATIONS_COLUMN_HEADER, guilabels.NOTIFICATIONS_RECEIVED_TIME, ] - self._gui = NotificationListGUI(self, script, title, column_headers, entries) + self._gui = NotificationListGUI( + script, + title, + column_headers, + rows, + self.on_dialog_destroyed, + ) self._gui.show_gui() return True - def on_dialog_destroyed(self): - """Handler for the dialog being destroyed.""" + def on_dialog_destroyed(self, _dialog: Gtk.Dialog) -> None: + """Handler for the 'destroyed' signal of the dialog.""" self._gui = None @@ -448,51 +343,46 @@ class NotificationPresenter: class NotificationListGUI: """The dialog containing the notifications list.""" - RESPONSE_COPY = 1 - RESPONSE_DISMISS = 2 + def __init__( + self, + script: default.Script, + title: str, + column_headers: list[str], + rows: list[tuple[str, str]], + destroyed_callback: Callable[[Gtk.Dialog], None], + ): + self._script: default.Script = script + self._model: Gtk.ListStore | None = None + self._gui: Gtk.Dialog = self._create_dialog(title, column_headers, rows) + self._gui.connect("destroy", destroyed_callback) - def __init__(self, presenter, script, title, column_headers, entries): - self._presenter = presenter - self._script = script - self._model = None - self._tree = None - self._selection = None - self._dismiss_button = None - self._actions_box = None - self._actions_status_label = None - self._gui = self._create_dialog(title, column_headers, entries) - - def _create_dialog(self, title, column_headers, entries): - dialog = Gtk.Dialog(title, None, Gtk.DialogFlags.MODAL) - dialog.set_default_size(600, 400) - dialog.add_button(Gtk.STOCK_COPY, self.RESPONSE_COPY) - self._dismiss_button = dialog.add_button( - guilabels.NOTIFICATIONS_DISMISS_BUTTON, - self.RESPONSE_DISMISS, + def _create_dialog( + self, + title: str, + column_headers: list[str], + rows: list[tuple[str, str]], + ) -> Gtk.Dialog: + dialog = Gtk.Dialog( + title, + None, + Gtk.DialogFlags.MODAL, + (Gtk.STOCK_CLEAR, Gtk.ResponseType.APPLY, Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE), ) - dialog.add_button(Gtk.STOCK_CLEAR, Gtk.ResponseType.APPLY) - dialog.add_button(Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE) - dialog.set_default_response(Gtk.ResponseType.CLOSE) + dialog.set_default_size(600, 400) grid = Gtk.Grid() - grid.set_row_spacing(12) content_area = dialog.get_content_area() content_area.add(grid) scrolled_window = Gtk.ScrolledWindow() - scrolled_window.set_hexpand(True) - scrolled_window.set_vexpand(True) - grid.add(scrolled_window) + grid.add(scrolled_window) # pylint: disable=no-member tree = Gtk.TreeView() tree.set_hexpand(True) tree.set_vexpand(True) - tree_accessible = tree.get_accessible() - if tree_accessible: - tree_accessible.set_name(title) - scrolled_window.add(tree) + scrolled_window.add(tree) # pylint: disable=no-member - cols = (GObject.TYPE_STRING, GObject.TYPE_STRING, GObject.TYPE_PYOBJECT) + cols = len(column_headers) * [GObject.TYPE_STRING] for i, header in enumerate(column_headers): cell = Gtk.CellRendererText() column = Gtk.TreeViewColumn(header, cell, text=i) @@ -501,267 +391,40 @@ class NotificationListGUI: column.set_sort_column_id(i) self._model = Gtk.ListStore(*cols) - for entry in entries: + for row in rows: row_iter = self._model.append(None) - self._model.set_value(row_iter, 0, entry.message) - self._model.set_value( - row_iter, - 1, - self._presenter._timestamp_to_string(entry.timestamp), - ) - self._model.set_value(row_iter, 2, entry) + for i, cell in enumerate(row): + self._model.set_value(row_iter, i, cell) tree.set_model(self._model) - selection = tree.get_selection() - selection.set_mode(Gtk.SelectionMode.SINGLE) - selection.connect("changed", self._on_selection_changed) - if self._model.iter_n_children(None) > 0: - selection.select_path(0) - self._selection = selection - self._tree = tree - - actions_frame = Gtk.Frame(label=guilabels.NOTIFICATIONS_ACTIONS_TITLE) - actions_frame.set_label_align(0.0, 0.5) - actions_accessible = actions_frame.get_accessible() - if actions_accessible: - actions_accessible.set_name(guilabels.NOTIFICATIONS_ACTIONS_TITLE) - grid.attach_next_to(actions_frame, scrolled_window, Gtk.PositionType.BOTTOM, 1, 1) - - actions_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - actions_content.set_margin_top(6) - actions_content.set_margin_bottom(6) - actions_content.set_margin_start(6) - actions_content.set_margin_end(6) - actions_frame.add(actions_content) - - self._actions_status_label = Gtk.Label(xalign=0) - self._actions_status_label.set_line_wrap(True) - self._actions_status_label.set_no_show_all(True) - actions_content.pack_start(self._actions_status_label, False, False, 0) - - self._actions_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - actions_content.pack_start(self._actions_box, False, False, 0) - dialog.connect("response", self.on_response) - dialog.connect("destroy", self._on_destroy) - self._update_action_buttons() return dialog - def on_response(self, dialog, response): + def on_response(self, _dialog: Gtk.Dialog, response: int) -> None: """The handler for the 'response' signal.""" if response == Gtk.ResponseType.CLOSE: self._gui.destroy() return - if response == self.RESPONSE_COPY: - self._copy_selected_notification() - return - - if response == self.RESPONSE_DISMISS: - self._dismiss_selected_notification() - return - if response == Gtk.ResponseType.APPLY and self._model is not None: self._model.clear() - self._presenter.clear_list() - self._script.presentMessage(messages.NOTIFICATION_NO_MESSAGES) + get_presenter().clear_list() + presentation_manager.get_manager().present_message(messages.NOTIFICATION_NO_MESSAGES) time.sleep(1) self._gui.destroy() - def show_gui(self): + def show_gui(self) -> None: """Shows the notifications list dialog.""" - self._gui.show_all() - time_stamp = Gtk.get_current_event_time() - if not time_stamp or time_stamp > 0xFFFFFFFF: - time_stamp = Gdk.CURRENT_TIME - self._gui.present_with_time(int(time_stamp)) - - def _on_destroy(self, widget): - self._presenter.on_dialog_destroyed() - - def _on_selection_changed(self, selection): - self._presenter.refresh_live_notifications() - self._update_action_buttons() - - def _get_selected_entry(self) -> Optional[NotificationEntry]: - if self._selection is None or self._model is None: - return None - - model, paths = self._selection.get_selected_rows() - if not paths and self._model.iter_n_children(None) > 0: - self._selection.select_path(0) - model, paths = self._selection.get_selected_rows() - - if not paths: - return None - - iter_for_path = model.get_iter(paths[0]) - if iter_for_path is None: - return None - - return model.get_value(iter_for_path, 2) - - def _update_action_buttons(self) -> None: - entry = self._get_selected_entry() - can_control = self._presenter.can_control_entry(entry) - actions = self._presenter.get_actions_for_entry(entry) - - if self._dismiss_button is not None: - self._dismiss_button.set_sensitive(can_control) - - self._clear_inline_action_buttons() - - if not can_control: - self._set_inline_action_status(messages.NOTIFICATION_UNAVAILABLE) - return - - if not actions: - self._set_inline_action_status(messages.NOTIFICATION_NO_ACTIONS) - return - - self._set_inline_action_status("") - for action_key, label_text in actions.items(): - self._add_inline_action_button(entry, action_key, label_text) - - def _clear_inline_action_buttons(self) -> None: - if self._actions_box is None: - return - - for child in self._actions_box.get_children(): - self._actions_box.remove(child) - - def _set_inline_action_status(self, text: str) -> None: - if self._actions_status_label is None: - return - - self._actions_status_label.set_text(text) - self._actions_status_label.set_visible(bool(text)) - - def _add_inline_action_button( - self, - entry: Optional[NotificationEntry], - action_key: str, - label_text: str, - ) -> None: - if self._actions_box is None: - return - - button = Gtk.Button(label=label_text or action_key) - button.connect("clicked", self._on_inline_action_clicked, entry, action_key) - self._actions_box.pack_start(button, False, False, 0) - self._actions_box.show_all() - - def _on_inline_action_clicked( - self, - button: Gtk.Button, - entry: Optional[NotificationEntry], - action_key: str, - ) -> None: - del button - self._presenter.refresh_live_notifications() - if not self._presenter.can_control_entry(entry): - self._script.presentMessage(messages.NOTIFICATION_UNAVAILABLE) - self._update_action_buttons() - return - - if not self._presenter.invoke_action_for_entry(self._script, entry, action_key): - self._script.presentMessage(messages.NOTIFICATION_UNAVAILABLE) - - self._update_action_buttons() - - def _copy_selected_notification(self): - entry = self._get_selected_entry() - if entry is None: - return - - timestamp = self._presenter._timestamp_to_string(entry.timestamp) - if timestamp: - text = f"{entry.message}\t{timestamp}" - else: - text = f"{entry.message}" - - clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) - clipboard.set_text(text, -1) - clipboard.store() - self._script.presentMessage(messages.CLIPBOARD_COPIED_FULL) - - def _dismiss_selected_notification(self) -> None: - self._presenter.refresh_live_notifications() - entry = self._get_selected_entry() - if not self._presenter.can_control_entry(entry): - self._script.presentMessage(messages.NOTIFICATION_UNAVAILABLE) - self._update_action_buttons() - return - - if not self._presenter.dismiss_entry(self._script, entry): - self._script.presentMessage(messages.NOTIFICATION_UNAVAILABLE) - else: - self._remove_selected_row() - - self._update_action_buttons() - - def _remove_selected_row(self) -> None: - if self._selection is None or self._model is None: - return - - model, paths = self._selection.get_selected_rows() - if not paths: - return - - row_iter = model.get_iter(paths[0]) - if row_iter is None: - return - - model.remove(row_iter) - if self._model.iter_n_children(None) == 0: - return - - row_index = self._path_to_index(paths[0]) - if row_index is None: - self._selection.select_path(0) - return - - next_index = min(row_index, self._model.iter_n_children(None) - 1) - self._selection.select_path(next_index) - - def _path_to_index(self, path: Any) -> Optional[int]: - if isinstance(path, int): - return path - - get_indices = getattr(path, "get_indices", None) - if callable(get_indices): - indices = get_indices() - if indices: - return indices[0] - - try: - return path[0] - except (TypeError, IndexError, KeyError): - return None + self._gui.show_all() # pylint: disable=no-member + self._gui.present_with_time(time.time()) -_presenter = None +_presenter: NotificationPresenter = NotificationPresenter() -def getPresenter(): - """Returns the Notification Presenter.""" - - global _presenter - if _presenter is None: - _presenter = NotificationPresenter() - - if _presenter._mako_monitor is None: - try: - from . import cthulhu - - app = getattr(cthulhu, "cthulhuApp", None) - if app is not None: - monitor = getattr(app, "makoNotificationMonitor", None) - if monitor is not None: - _presenter.set_mako_monitor(monitor) - except Exception: - pass +def get_presenter() -> NotificationPresenter: + """Returns the Notification Presenter""" return _presenter diff --git a/src/cthulhu/object_navigator.py b/src/cthulhu/object_navigator.py index 6459160..e6e9dbe 100644 --- a/src/cthulhu/object_navigator.py +++ b/src/cthulhu/object_navigator.py @@ -1,9 +1,7 @@ -#!/usr/bin/env python3 +# Cthulhu # -# Copyright (c) 2024 Stormux -# Copyright (c) 2010-2012 The Orca Team -# Copyright (c) 2012 Igalia, S.L. -# Copyright (c) 2005-2010 Sun Microsystems Inc. +# Copyright 2023 The Cthulhu Team +# Author: Rynhardt Kruger # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -19,167 +17,160 @@ # 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 """Provides ability to navigate objects hierarchically.""" -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2023 The Cthulhu Team" -__license__ = "LGPL" +from __future__ import annotations -from . import cmdnames -from . import debug -from . import input_event -from . import keybindings -from . import messages -from . import cthulhu -from . import cthulhu_state +from typing import TYPE_CHECKING + +from . import ( + cmdnames, + command_manager, + dbus_service, + debug, + focus_manager, + guilabels, + input_event, + keybindings, + messages, + presentation_manager, +) from .ax_event_synthesizer import AXEventSynthesizer from .ax_object import AXObject from .ax_utilities import AXUtilities +if TYPE_CHECKING: + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + + from .scripts import default + class ObjectNavigator: """Provides ability to navigate objects hierarchically.""" - def __init__(self): - self._navigator_focus = None - self._last_navigator_focus = None - self._last_locus_of_focus = None - self._simplify = True - self._handlers = self._setup_handlers() - self._bindings = self._setup_bindings() + def __init__(self) -> None: + self._navigator_focus: Atspi.Accessible | None = None + self._last_navigator_focus: Atspi.Accessible | None = None + self._last_locus_of_focus: Atspi.Accessible | None = None + self._simplify: bool = True + self._initialized: bool = False - def get_bindings(self): - """Returns the object-navigator keybindings.""" + msg = "OBJECT NAVIGATOR: Registering D-Bus commands." + debug.print_message(debug.LEVEL_INFO, msg, True) + controller = dbus_service.get_remote_controller() + controller.register_decorated_module("ObjectNavigator", self) - return self._bindings + def set_up_commands(self) -> None: + """Sets up commands with CommandManager.""" - def get_handlers(self): - """Returns the object-navigator handlers.""" + if self._initialized: + return + self._initialized = True - return self._handlers + manager = command_manager.get_manager() + group_label = guilabels.KB_GROUP_OBJECT_NAVIGATION - def _setup_bindings(self): - """Sets up and returns the object-navigator key bindings.""" - - bindings = keybindings.KeyBindings() - - bindings.add( - keybindings.KeyBinding( + # (name, function, description, keysymstring, modifiers) + # Same bindings on desktop and laptop + commands_data = [ + ( + "object_navigator_up", + self.move_to_parent, + cmdnames.NAVIGATOR_UP, "Up", - keybindings.defaultModifierMask, keybindings.CTHULHU_CTRL_MODIFIER_MASK, - self._handlers.get("object_navigator_up"))) - - bindings.add( - keybindings.KeyBinding( + ), + ( + "object_navigator_down", + self.move_to_first_child, + cmdnames.NAVIGATOR_DOWN, "Down", - keybindings.defaultModifierMask, keybindings.CTHULHU_CTRL_MODIFIER_MASK, - self._handlers.get("object_navigator_down"))) - - bindings.add( - keybindings.KeyBinding( + ), + ( + "object_navigator_next", + self.move_to_next_sibling, + cmdnames.NAVIGATOR_NEXT, "Right", - keybindings.defaultModifierMask, keybindings.CTHULHU_CTRL_MODIFIER_MASK, - self._handlers.get("object_navigator_next"))) - - bindings.add( - keybindings.KeyBinding( + ), + ( + "object_navigator_previous", + self.move_to_previous_sibling, + cmdnames.NAVIGATOR_PREVIOUS, "Left", - keybindings.defaultModifierMask, keybindings.CTHULHU_CTRL_MODIFIER_MASK, - self._handlers.get("object_navigator_previous"))) - - bindings.add( - keybindings.KeyBinding( - "Return", - keybindings.defaultModifierMask, - keybindings.CTHULHU_CTRL_MODIFIER_MASK, - self._handlers.get("object_navigator_perform_action"))) - - bindings.add( - keybindings.KeyBinding( - "s", - keybindings.defaultModifierMask, - keybindings.CTHULHU_CTRL_MODIFIER_MASK, - self._handlers.get("object_navigator_toggle_simplify"))) - - return bindings - - def _setup_handlers(self): - """Sets up and returns the object-navigator input event handlers.""" - - handlers = {} - - handlers["object_navigator_up"] = \ - input_event.InputEventHandler( - self.up, - cmdnames.NAVIGATOR_UP) - - handlers["object_navigator_down"] = \ - input_event.InputEventHandler( - self.down, - cmdnames.NAVIGATOR_DOWN) - - handlers["object_navigator_next"] = \ - input_event.InputEventHandler( - self.next, - cmdnames.NAVIGATOR_NEXT) - - handlers["object_navigator_previous"] = \ - input_event.InputEventHandler( - self.previous, - cmdnames.NAVIGATOR_PREVIOUS) - - handlers["object_navigator_perform_action"] = \ - input_event.InputEventHandler( + ), + ( + "object_navigator_perform_action", self.perform_action, - cmdnames.NAVIGATOR_PERFORM_ACTION) - - handlers["object_navigator_toggle_simplify"] = \ - input_event.InputEventHandler( + cmdnames.NAVIGATOR_PERFORM_ACTION, + "Return", + keybindings.CTHULHU_CTRL_MODIFIER_MASK, + ), + ( + "object_navigator_toggle_simplify", self.toggle_simplify, - cmdnames.NAVIGATOR_TOGGLE_SIMPLIFIED) + cmdnames.NAVIGATOR_TOGGLE_SIMPLIFIED, + "s", + keybindings.CTHULHU_CTRL_MODIFIER_MASK, + ), + ] - return handlers + for name, function, description, keysym, modifiers in commands_data: + kb = keybindings.KeyBinding(keysym, modifiers) + manager.add_command( + command_manager.KeyboardCommand( + name, + function, + group_label, + description, + desktop_keybinding=kb, + laptop_keybinding=kb, + ), + ) - def _include_in_simple_navigation(self, obj): + msg = "OBJECT NAVIGATOR: Commands set up." + debug.print_message(debug.LEVEL_INFO, msg, True) + + def _include_in_simple_navigation(self, obj: Atspi.Accessible) -> bool: """Returns True if obj should be included in simple navigation.""" return AXUtilities.is_paragraph(obj) - def _exclude_from_simple_navigation(self, script, obj): + def _exclude_from_simple_navigation( + self, + _script: default.Script, + obj: Atspi.Accessible, + ) -> bool: """Returns True if obj should be excluded from simple navigation.""" if self._include_in_simple_navigation(obj): tokens = ["OBJECT NAVIGATOR: Not excluding", obj, ": explicit inclusion"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return False - # isLayoutOnly should catch things that really should be skipped. + # is_layout_only should catch things that really should be skipped. # # You do not want to exclude all sections because they may be focusable, e.g. #
foo
should not be excluded, despite the poor authoring. # # You do not want to exclude table cells and headers because it will make the # selectable items in tables non-navigable (e.g. the mail folders in Evolution) - if script.utilities.isLayoutOnly(obj): + if AXUtilities.is_layout_only(obj): tokens = ["OBJECT NAVIGATOR: Excluding", obj, ": is layout only"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return True tokens = ["OBJECT NAVIGATOR: Not excluding", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return False - def _children(self, script, obj): + def _children(self, script: default.Script, obj: Atspi.Accessible) -> list: """Returns a list of children for obj, taking simple navigation into account.""" if not AXObject.get_child_count(obj): @@ -199,7 +190,7 @@ class ObjectNavigator: return functional_children - def _parent(self, script, obj): + def _parent(self, script: default.Script, obj: Atspi.Accessible) -> Atspi.Accessible | None: """Returns the parent for obj, taking simple navigation into account.""" parent = AXObject.get_parent(obj) @@ -212,108 +203,227 @@ class ObjectNavigator: return parent - def _set_navigator_focus(self, obj): + def _set_navigator_focus(self, obj: Atspi.Accessible) -> None: """Changes the navigator focus, storing the previous focus.""" self._last_navigator_focus = self._navigator_focus self._navigator_focus = obj - def update(self): + def _update(self) -> None: """Updates the navigator focus to Cthulhu's object of interest.""" - mode, region = cthulhu.getActiveModeAndObjectOfInterest() - obj = region or cthulhu_state.locusOfFocus - if self._last_locus_of_focus == obj or (region is None and mode == cthulhu.FLAT_REVIEW): + mode, region = focus_manager.get_manager().get_active_mode_and_object_of_interest() + obj = region or focus_manager.get_manager().get_locus_of_focus() + if self._last_locus_of_focus == obj or ( + region is None and mode == focus_manager.FLAT_REVIEW + ): return self._navigator_focus = obj self._last_locus_of_focus = obj - def present(self, script): + def _present(self, script: default.Script, notify_user: bool = True) -> None: """Presents the current navigator focus to the user.""" tokens = ["OBJECT NAVIGATOR: Presenting", self._navigator_focus] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - cthulhu.emitRegionChanged(self._navigator_focus, mode=cthulhu.OBJECT_NAVIGATOR) - script.presentObject(self._navigator_focus, priorObj=self._last_navigator_focus) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + focus_manager.get_manager().emit_region_changed( + self._navigator_focus, + mode=focus_manager.OBJECT_NAVIGATOR, + ) + if not notify_user: + msg = "OBJECT NAVIGATOR: _present called with notify_user=False" + debug.print_message(debug.LEVEL_INFO, msg, True) + return - def up(self, script, event=None): + script.present_object(self._navigator_focus, priorObj=self._last_navigator_focus) + + @dbus_service.command + def move_to_parent( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Moves the navigator focus to the parent of the current focus.""" - self.update() + tokens = [ + "OBJECT NAVIGATOR: move_to_parent. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._update() parent = self._parent(script, self._navigator_focus) if parent is not None: self._set_navigator_focus(parent) - self.present(script) - else: - script.presentMessage(messages.NAVIGATOR_NO_PARENT) + self._present(script, notify_user) + elif notify_user: + presentation_manager.get_manager().present_message(messages.NAVIGATOR_NO_PARENT) + return True - def down(self, script, event=None): + @dbus_service.command + def move_to_first_child( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Moves the navigator focus to the first child of the current focus.""" - self.update() + tokens = [ + "OBJECT NAVIGATOR: move_to_first_child. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._update() children = self._children(script, self._navigator_focus) if not children: - script.presentMessage(messages.NAVIGATOR_NO_CHILDREN) - return + if notify_user: + presentation_manager.get_manager().present_message(messages.NAVIGATOR_NO_CHILDREN) + return True self._set_navigator_focus(children[0]) - self.present(script) + self._present(script, notify_user) + return True - def next(self, script, event=None): + @dbus_service.command + def move_to_next_sibling( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Moves the navigator focus to the next sibling of the current focus.""" - self.update() + tokens = [ + "OBJECT NAVIGATOR: move_to_next_sibling. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._update() parent = self._parent(script, self._navigator_focus) if parent is None: - script.presentMessage(messages.NAVIGATOR_NO_NEXT) - return + if notify_user: + presentation_manager.get_manager().present_message(messages.NAVIGATOR_NO_NEXT) + return True siblings = self._children(script, parent) if self._navigator_focus in siblings: index = siblings.index(self._navigator_focus) if index < len(siblings) - 1: - self._set_navigator_focus(siblings[index+1]) - self.present(script) - else: - script.presentMessage(messages.NAVIGATOR_NO_NEXT) + self._set_navigator_focus(siblings[index + 1]) + self._present(script, notify_user) + elif notify_user: + presentation_manager.get_manager().present_message(messages.NAVIGATOR_NO_NEXT) else: self._set_navigator_focus(parent) - self.present(script) + self._present(script, notify_user) + return True - def previous(self, script, event=None): + @dbus_service.command + def move_to_previous_sibling( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Moves the navigator focus to the previous sibling of the current focus.""" - self.update() + tokens = [ + "OBJECT NAVIGATOR: move_to_previous_sibling. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._update() parent = self._parent(script, self._navigator_focus) if parent is None: - script.presentMessage(messages.NAVIGATOR_NO_PREVIOUS) - return + if notify_user: + presentation_manager.get_manager().present_message(messages.NAVIGATOR_NO_PREVIOUS) + return True siblings = self._children(script, parent) if self._navigator_focus in siblings: index = siblings.index(self._navigator_focus) if index > 0: - self._set_navigator_focus(siblings[index-1]) - self.present(script) - else: - script.presentMessage(messages.NAVIGATOR_NO_PREVIOUS) + self._set_navigator_focus(siblings[index - 1]) + self._present(script, notify_user) + elif notify_user: + presentation_manager.get_manager().present_message(messages.NAVIGATOR_NO_PREVIOUS) else: self._set_navigator_focus(parent) - self.present(script) - - def toggle_simplify(self, script, event=None): - """Toggles simplified navigation.""" - - self._simplify = not self._simplify - if self._simplify: - script.presentMessage(messages.NAVIGATOR_SIMPLIFIED_ENABLED) - else: - script.presentMessage(messages.NAVIGATOR_SIMPLIFIED_DISABLED) + self._present(script, notify_user) return True - def perform_action(self, script, event=None): + @dbus_service.command + def toggle_simplify( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Toggles simplified navigation.""" + + tokens = [ + "OBJECT NAVIGATOR: toggle_simplify. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._simplify = not self._simplify + if notify_user: + if self._simplify: + presentation_manager.get_manager().present_message( + messages.NAVIGATOR_SIMPLIFIED_ENABLED, + ) + else: + presentation_manager.get_manager().present_message( + messages.NAVIGATOR_SIMPLIFIED_DISABLED, + ) + return True + + @dbus_service.command + def perform_action( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Attempts to click on the current focus.""" + + tokens = [ + "OBJECT NAVIGATOR: perform_action. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if AXEventSynthesizer.try_all_clickable_actions(self._navigator_focus): return True @@ -321,11 +431,10 @@ class ObjectNavigator: return True -_navigator = None -def getNavigator(): +_navigator: ObjectNavigator = ObjectNavigator() + + +def get_navigator() -> ObjectNavigator: """Returns the Object Navigator""" - - global _navigator - if _navigator is None: - _navigator = ObjectNavigator() + return _navigator diff --git a/src/cthulhu/plugins/OCR/plugin.py b/src/cthulhu/plugins/OCR/plugin.py index 996e6dd..16b3b07 100644 --- a/src/cthulhu/plugins/OCR/plugin.py +++ b/src/cthulhu/plugins/OCR/plugin.py @@ -26,6 +26,8 @@ from gi.repository import Atspi from cthulhu.plugin import Plugin, cthulhu_hookimpl from cthulhu import debug +from cthulhu import guilabels +from cthulhu import preferences_grid_base from cthulhu import settings_manager from cthulhu.wnck_support import load_wnck @@ -83,6 +85,181 @@ WNCK_AVAILABLE = Wnck is not None logger = logging.getLogger(__name__) + +KEY_LANGUAGE_CODE = "language-code" +KEY_SCALE_FACTOR = "scale-factor" +KEY_GRAYSCALE_IMAGE = "grayscale-image" +KEY_INVERT_IMAGE = "invert-image" +KEY_BLACK_WHITE_IMAGE = "black-white-image" +KEY_BLACK_WHITE_THRESHOLD = "black-white-threshold" +KEY_COLOR_CALCULATION = "color-calculation" +KEY_COLOR_CALCULATION_MAX = "color-calculation-max" +KEY_COPY_TO_CLIPBOARD = "copy-to-clipboard" + + +class OCRPreferencesGrid(preferences_grid_base.AutoPreferencesGrid): + """Preferences grid for Cthulhu OCR settings.""" + + _gsettings_schema = "ocr" + + def __init__(self, title_change_callback=None): + self._language_code = "eng" + self._scale_factor = 3 + self._grayscale_image = False + self._invert_image = False + self._black_white_image = False + self._black_white_threshold = 200 + self._color_calculation = False + self._color_calculation_max = 3 + self._copy_to_clipboard = False + if title_change_callback is not None: + title_change_callback(guilabels.OCR) + + controls = [ + preferences_grid_base.EnumPreferenceControl( + label=guilabels.OCR_LANGUAGE_CODE, + options=["eng"], + values=["eng"], + getter=self.get_language_code, + setter=self.set_language_code, + prefs_key=KEY_LANGUAGE_CODE, + ), + preferences_grid_base.IntRangePreferenceControl( + label=guilabels.OCR_SCALE_FACTOR, + minimum=1, + maximum=5, + getter=self.get_scale_factor, + setter=self.set_scale_factor, + prefs_key=KEY_SCALE_FACTOR, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.OCR_GRAYSCALE_IMAGE, + getter=self.get_grayscale_image, + setter=self.set_grayscale_image, + prefs_key=KEY_GRAYSCALE_IMAGE, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.OCR_INVERT_IMAGE, + getter=self.get_invert_image, + setter=self.set_invert_image, + prefs_key=KEY_INVERT_IMAGE, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.OCR_BLACK_WHITE_IMAGE, + getter=self.get_black_white_image, + setter=self.set_black_white_image, + prefs_key=KEY_BLACK_WHITE_IMAGE, + ), + preferences_grid_base.IntRangePreferenceControl( + label=guilabels.OCR_BLACK_WHITE_THRESHOLD, + minimum=0, + maximum=255, + getter=self.get_black_white_threshold, + setter=self.set_black_white_threshold, + prefs_key=KEY_BLACK_WHITE_THRESHOLD, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.OCR_COLOR_CALCULATION, + getter=self.get_color_calculation, + setter=self.set_color_calculation, + prefs_key=KEY_COLOR_CALCULATION, + ), + preferences_grid_base.IntRangePreferenceControl( + label=guilabels.OCR_COLOR_CALCULATION_MAX, + minimum=1, + maximum=10, + getter=self.get_color_calculation_max, + setter=self.set_color_calculation_max, + prefs_key=KEY_COLOR_CALCULATION_MAX, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.OCR_COPY_TO_CLIPBOARD, + getter=self.get_copy_to_clipboard, + setter=self.set_copy_to_clipboard, + prefs_key=KEY_COPY_TO_CLIPBOARD, + ), + ] + super().__init__(guilabels.OCR, controls) + + def _refresh_widget_for_key(self, prefs_key): + if not hasattr(self, "_controls"): + return + + for control, widget in zip(self._controls, self._widgets, strict=True): + if control.prefs_key != prefs_key: + continue + + self._initializing = True + try: + self._refresh_widget(control, widget) + finally: + self._initializing = False + return + + def get_language_code(self): + return self._language_code + + def set_language_code(self, value): + self._language_code = value + self._refresh_widget_for_key(KEY_LANGUAGE_CODE) + + def get_scale_factor(self): + return self._scale_factor + + def set_scale_factor(self, value): + self._scale_factor = value + self._refresh_widget_for_key(KEY_SCALE_FACTOR) + + def get_grayscale_image(self): + return self._grayscale_image + + def set_grayscale_image(self, value): + self._grayscale_image = value + self._refresh_widget_for_key(KEY_GRAYSCALE_IMAGE) + + def get_invert_image(self): + return self._invert_image + + def set_invert_image(self, value): + self._invert_image = value + self._refresh_widget_for_key(KEY_INVERT_IMAGE) + + def get_black_white_image(self): + return self._black_white_image + + def set_black_white_image(self, value): + self._black_white_image = value + self._refresh_widget_for_key(KEY_BLACK_WHITE_IMAGE) + + def get_black_white_threshold(self): + return self._black_white_threshold + + def set_black_white_threshold(self, value): + self._black_white_threshold = value + self._refresh_widget_for_key(KEY_BLACK_WHITE_THRESHOLD) + + def get_color_calculation(self): + return self._color_calculation + + def set_color_calculation(self, value): + self._color_calculation = value + self._refresh_widget_for_key(KEY_COLOR_CALCULATION) + + def get_color_calculation_max(self): + return self._color_calculation_max + + def set_color_calculation_max(self, value): + self._color_calculation_max = value + self._refresh_widget_for_key(KEY_COLOR_CALCULATION_MAX) + + def get_copy_to_clipboard(self): + return self._copy_to_clipboard + + def set_copy_to_clipboard(self, value): + self._copy_to_clipboard = value + self._refresh_widget_for_key(KEY_COPY_TO_CLIPBOARD) + + class OCRDesktop(Plugin): """OCR Desktop accessibility plugin for reading inaccessible windows.""" diff --git a/src/cthulhu/presentation_manager.py b/src/cthulhu/presentation_manager.py new file mode 100644 index 0000000..f746803 --- /dev/null +++ b/src/cthulhu/presentation_manager.py @@ -0,0 +1,450 @@ +# Cthulhu +# +# Copyright 2026 Igalia, S.L. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-return-statements + +"""Module for managing presentation of information to the user via speech, braille, and sound.""" + +from __future__ import annotations + +import enum +from typing import TYPE_CHECKING, Any + +from . import ( + braille_presenter, + debug, + focus_manager, + input_event_manager, + live_region_presenter, + messages, + script_manager, + sound_presenter, + speech_manager, + speech_presenter, + speechserver, + typing_echo_presenter, +) +from .ax_object import AXObject +from .ax_utilities import AXUtilities +from .ax_value import AXValue + +if TYPE_CHECKING: + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + + from .input_event import KeyboardEvent + from .scripts import default + from .sound import Icon, Tone + + +class _Command(enum.Enum): + """Commands whose announcement should be deduplicated.""" + + UNDO = enum.auto() + REDO = enum.auto() + PASTE = enum.auto() + + +class PresentationManager: + """Manages presentation of information to the user via speech, braille, and sound.""" + + def _get_active_script(self) -> default.Script | None: + """Returns the active script.""" + + return script_manager.get_manager().get_active_script() + + def is_flash_message_displayed(self) -> bool: + """Returns True if a flash message is currently being displayed on braille.""" + + return braille_presenter.get_presenter().is_flash_active() + + def interrupt_presentation(self, kill_flash: bool = True) -> None: + """Convenience method to interrupt whatever is being presented at the moment.""" + + msg = "PRESENTATION MANAGER: Interrupting presentation" + debug.print_message(debug.LEVEL_INFO, msg, True) + speech_manager.get_manager().interrupt_speech() + if kill_flash: + braille_presenter.get_presenter().kill_flash() + live_region_presenter.get_presenter().flush_messages() + + def interrupt_if_needed_for_focus_change( + self, + old_focus: Atspi.Accessible, + new_focus: Atspi.Accessible, + event: Atspi.Event | None = None, + ) -> None: + """Interrupts presentation if the focus change warrants it.""" + + if self._should_interrupt_for_focus_change(old_focus, new_focus, event): + self.interrupt_presentation() + + @staticmethod + def _should_interrupt_for_focus_change( + old_focus: Atspi.Accessible, + new_focus: Atspi.Accessible, + event: Atspi.Event | None = None, + ) -> bool: + """Returns True if speech should be interrupted to present the new focus.""" + + msg = "PRESENTATION MANAGER: Not interrupting for locusOfFocus change: " + if ( + event is None + or old_focus == new_focus + or event.type.startswith("object:active-descendant-changed") + ): + if event is None: + msg += "event is None" + elif old_focus == new_focus: + msg += "old locusOfFocus is same as new locusOfFocus" + else: + msg += "event is active-descendant-changed" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + if ( + AXUtilities.is_table_cell(old_focus) + and AXUtilities.is_text(new_focus) + and AXUtilities.is_editable(new_focus) + ): + msg += "suspected editable cell" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + if not AXUtilities.is_menu_related(new_focus) and ( + AXUtilities.is_check_menu_item(old_focus) or AXUtilities.is_radio_menu_item(old_focus) + ): + msg += "suspected menuitem state change" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + if AXUtilities.is_ancestor(new_focus, old_focus): + if old_name := AXObject.get_name(old_focus): + if old_name == AXObject.get_name(new_focus): + return True + msg += "old locusOfFocus is ancestor of new locusOfFocus, and has a name" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + if AXUtilities.is_dialog_or_window(old_focus): + if AXUtilities.is_menu(new_focus): + return True + msg += "old locusOfFocus is ancestor dialog or window of the new locusOfFocus" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + return True + + if AXUtilities.object_is_controlled_by( + old_focus, + new_focus, + ) or AXUtilities.object_is_controlled_by(new_focus, old_focus): + msg += "new locusOfFocus and old locusOfFocus have controls relation" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + return True + + _announced_command: _Command | None = None + + def present_command_announcement(self) -> None: + """Presents undo/redo/paste announcement once per command.""" + + manager = input_event_manager.get_manager() + if manager.last_event_was_undo(): + if self._announced_command != _Command.UNDO: + self.present_message(messages.UNDO) + self._announced_command = _Command.UNDO + elif manager.last_event_was_redo(): + if self._announced_command != _Command.REDO: + self.present_message(messages.REDO) + self._announced_command = _Command.REDO + elif manager.last_event_was_paste(): + if self._announced_command != _Command.PASTE: + self.present_message( + messages.CLIPBOARD_PASTED_FULL, messages.CLIPBOARD_PASTED_BRIEF + ) + self._announced_command = _Command.PASTE + + def clear_command_announcement(self) -> None: + """Clears the announced-command state.""" + + self._announced_command = None + + def refresh_presenters(self) -> None: + """Refreshes braille and speech settings after profile/settings change.""" + + # Braille settings apply dynamically; just ensure enabled/disabled state is correct. + braille_presenter.get_presenter().check_braille_setting() + # Speech needs full restart because synthesizer/server might have changed. + speech_manager.get_manager().refresh_speech() + + def shutdown_presenters(self) -> None: + """Shuts down braille, speech, and sound.""" + + msg = "PRESENTATION MANAGER: Shutting down presenters" + debug.print_message(debug.LEVEL_INFO, msg, True) + sound_presenter.get_presenter().shutdown_sound() + speech_presenter.get_presenter().destroy_monitor() + speech_manager.get_manager().shutdown_speech() + braille_presenter.get_presenter().shutdown_braille() + + def start_presenters(self) -> None: + """Starts braille, speech, and sound if each is enabled.""" + + msg = "PRESENTATION MANAGER: Starting presenters" + debug.print_message(debug.LEVEL_INFO, msg, True) + speech_manager.get_manager().start_speech() + braille_presenter.get_presenter().init_braille() + sound_presenter.get_presenter().init_sound() + speech_presenter.get_presenter().init_monitor() + + def present_keyboard_event(self, event: KeyboardEvent) -> None: + """Presents the KeyboardEvent event.""" + + typing_echo_presenter.get_presenter().echo_keyboard_event(event) + + def present_key_event(self, event: KeyboardEvent) -> None: + """Presents a key event via speech (and potentially braille/sound in the future).""" + + key_name = event.get_key_name() + if len(key_name) == 1: + self.speak_character(key_name) + return + speech_presenter.get_presenter().present_key_event(event) + + # pylint: disable-next=too-many-arguments, too-many-positional-arguments + def present_message( + self, + full: str, + brief: str | None = None, + ) -> None: + """Convenience method to speak a message and 'flash' it in braille.""" + + if not full: + return + + if brief is None: + brief = full + + if speech_manager.get_manager().get_speech_is_enabled_and_not_muted(): + speech_pres = speech_presenter.get_presenter() + message = full if speech_pres.get_messages_are_detailed() else brief + if message: + speech_pres.speak_message(message) + + braille_pres = braille_presenter.get_presenter() + if not (braille_pres.use_braille() and braille_pres.get_flash_messages_are_enabled()): + return + + message = full if braille_pres.get_flash_messages_are_detailed() else brief + if not message: + return + + if isinstance(message[0], list): + message = message[0] + if isinstance(message, list): + message = [i for i in message if isinstance(i, str)] + message = " ".join(message) + + braille_pres.present_message(message) + + @staticmethod + def play_sound(sounds: list[Icon | Tone] | Icon | Tone, interrupt: bool = True) -> None: + """Plays the specified sound(s).""" + + sound_presenter.get_presenter().play(sounds, interrupt) + + @staticmethod + def present_braille_message(message: str, restore_previous: bool = True) -> None: + """Displays a single line in braille.""" + + braille_presenter.get_presenter().present_message( + message, + restore_previous=restore_previous, + ) + + def spell_item(self, text: str) -> None: + """Speak the characters in the string one by one.""" + + speech_presenter.get_presenter().spell_item(text) + + def spell_phonetically(self, item_string: str) -> None: + """Phonetically spell item_string.""" + + speech_presenter.get_presenter().spell_phonetically(item_string) + + @staticmethod + def _get_cap_style(character: str) -> speechserver.CapitalizationStyle | None: + """Returns the capitalization style if character is uppercase alpha.""" + + if character.isupper() and character.strip().isalpha(): + style_str = speech_manager.get_manager().get_capitalization_style() + return speechserver.CapitalizationStyle(style_str) + return None + + def speak_character( + self, + character: str, + obj: Atspi.Accessible | None = None, + ) -> None: + """Speaks a single character.""" + + speech_presenter.get_presenter().speak_character( + character, + voice_from=character, + cap_style=self._get_cap_style(character), + obj=obj, + ) + + def speak_character_at_offset( + self, + obj: Atspi.Accessible, + offset: int, + character: str, + ) -> None: + """Speaks a character at the given offset, handling capitalization style.""" + + cap_style = self._get_cap_style(character) + speech_presenter.get_presenter().speak_character_at_offset( + obj, + offset, + character, + cap_style=cap_style, + ) + + def speak_accessible_text(self, obj: Atspi.Accessible | None, text: str) -> None: + """Speaks text from an accessible object.""" + + if speech_manager.get_manager().get_speech_is_muted(): + return + speech_presenter.get_presenter().speak_accessible_text(obj, text) + + def speak_message(self, text: str) -> None: + """Speaks a single string.""" + + if speech_manager.get_manager().get_speech_is_muted(): + return + speech_presenter.get_presenter().speak_message(text) + + # pylint: disable-next=too-many-arguments + def present_object( + self, + script: default.Script, + obj: Atspi.Accessible, + *, + generate_speech: bool = True, + generate_braille: bool = True, + generate_sound: bool = False, + **args: Any, + ) -> None: + """Generates and presents an object via speech, braille, and sound.""" + + if obj is None: + return + + if args.get("isProgressBarUpdate"): + percent = AXValue.get_value_as_percent(obj) + is_same_app = ( + AXUtilities.get_application(obj) + == script_manager.get_manager().get_active_script_app() + ) + is_same_window = ( + script.utilities.top_level_object(obj) + == focus_manager.get_manager().get_active_window() + ) + if generate_speech: + generate_speech = ( + speech_presenter.get_presenter().should_present_progress_bar_update( + obj, + percent, + is_same_app, + is_same_window, + ) + ) + if generate_braille: + generate_braille = ( + braille_presenter.get_presenter().should_present_progress_bar_update( + obj, + percent, + is_same_app, + is_same_window, + ) + ) + if generate_sound: + generate_sound = sound_presenter.get_presenter().should_present_progress_bar_update( + obj, + percent, + is_same_app, + is_same_window, + ) + + if generate_speech: + speech_presenter.get_presenter().present_generated_speech(script, obj, **args) + + if generate_braille: + braille_presenter.get_presenter().present_generated_braille(script, obj, **args) + + if generate_sound: + sounds = script.get_sound_generator().generate_sound(obj, **args) + sound_presenter.get_presenter().play(sounds) + + def speak_contents( + self, + contents: list[tuple[Atspi.Accessible, int, int, str]], + **args: Any, + ) -> None: + """Speaks the specified contents.""" + + speech_presenter.get_presenter().speak_contents(contents, **args) + + def display_contents( + self, + contents: list[tuple[Atspi.Accessible, int, int, str]], + **args: Any, + ) -> None: + """Displays contents in braille.""" + + tokens = ["PRESENTATION MANAGER: Displaying", contents, args] + debug.print_tokens(debug.LEVEL_INFO, tokens, True, True) + + if not (active_script := self._get_active_script()): + return + + braille_presenter.get_presenter().display_generated_contents( + active_script, + contents, + **args, + ) + + def present_window_title(self, script: default.Script, obj: Atspi.Accessible) -> None: + """Generates and presents the window title.""" + + for string in speech_presenter.get_presenter().generate_window_title_strings(script, obj): + self.present_message(string) + + +_manager: PresentationManager = PresentationManager() + + +def get_manager() -> PresentationManager: + """Returns the Presentation Manager singleton.""" + return _manager diff --git a/src/cthulhu/profile_manager.py b/src/cthulhu/profile_manager.py new file mode 100644 index 0000000..1175c76 --- /dev/null +++ b/src/cthulhu/profile_manager.py @@ -0,0 +1,882 @@ +# Cthulhu +# +# Copyright 2005-2008 Sun Microsystems Inc. +# Copyright 2011-2026 Igalia, S.L. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +# pylint: disable=too-many-instance-attributes +# pylint: disable=too-many-arguments +# pylint: disable=too-many-positional-arguments + +"""Manager for Cthulhu profile creation, loading, and management.""" + +from __future__ import annotations + +import subprocess +import time +import unicodedata +from typing import TYPE_CHECKING + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import GLib, Gtk # pylint: disable=no-name-in-module + +from . import ( + cmdnames, + command_manager, + dbus_service, + debug, + gsettings_registry, + guilabels, + input_event, + messages, + cthulhu, + cthulhu_modifier_manager, + preferences_grid_base, + presentation_manager, +) + +if TYPE_CHECKING: + from collections.abc import Callable + + from .scripts import default + + +class ProfilePreferencesGrid(preferences_grid_base.PreferencesGridBase): + """GtkGrid containing the Profile management preferences page.""" + + # pylint: disable=no-member + + def __init__( + self, + manager: ProfileManager, + profile_loaded_callback: Callable[[list[str]], None], + is_app_specific: bool = False, + labels_update_callback: Callable[[], None] | None = None, + unsaved_changes_checker: Callable[[], bool] | None = None, + ) -> None: + super().__init__(guilabels.GENERAL_PROFILES) + self.set_margin_start(0) + self.set_margin_end(0) + self.set_margin_top(0) + self.set_margin_bottom(0) + self.set_border_width(0) + + self._manager: ProfileManager = manager + self._profile_loaded_callback = profile_loaded_callback + self._is_app_specific: bool = is_app_specific + self._labels_update_callback = labels_update_callback + self._unsaved_changes_checker = unsaved_changes_checker + self._initializing: bool = True + self._default_profile: list[str] = [guilabels.PROFILE_DEFAULT, "default"] + self._auto_grid: preferences_grid_base.AutoPreferencesGrid | None = None + self._pending_renames: dict[str, list[str]] = {} + + self._build() + self.refresh() + self._initializing = False + + def _build(self) -> None: + available_profiles = self._get_available_profiles() + profile_labels = [p[0] for p in available_profiles] + profile_values = [p[1] for p in available_profiles] + + controls = [ + preferences_grid_base.SelectionPreferenceControl( + label=guilabels.CURRENT_PROFILE, + options=profile_labels, + getter=self._get_active_profile, + setter=self._set_active_profile, + values=profile_values, + prefs_key="activeProfile", + member_of=guilabels.CURRENT_PROFILE, + get_actions_for_option=None if self._is_app_specific else self._get_profile_actions, + tracks_changes=False, + ), + ] + + self._auto_grid = preferences_grid_base.AutoPreferencesGrid( + tab_label="", + controls=controls, + info_message=guilabels.PROFILES_INFO, + ) + self.attach(self._auto_grid, 0, 0, 1, 1) + + if not self._is_app_specific: + self._auto_grid.add_button_to_group_header( + guilabels.CURRENT_PROFILE, + "list-add-symbolic", + self._on_new_profile_clicked, + guilabels.PROFILE_CREATE_NEW.replace("_", ""), + ) + + def _on_new_profile_clicked(self, _button: Gtk.Button) -> None: + """Handle New Profile button click.""" + + if self._unsaved_changes_checker and self._unsaved_changes_checker(): + dialog = Gtk.MessageDialog( + transient_for=self.get_toplevel(), + modal=True, + message_type=Gtk.MessageType.WARNING, + buttons=Gtk.ButtonsType.YES_NO, + text=guilabels.PROFILE_CREATE_UNSAVED_WARNING, + ) + response = dialog.run() + dialog.destroy() + if response != Gtk.ResponseType.YES: + return + + new_profile = self.get_new_profile_name() + if new_profile is None: + return + + if not self._manager.create_profile(new_profile): + self._show_error_dialog(guilabels.PROFILE_CONFLICT_MESSAGE % new_profile[0]) + return + + self._manager.load_profile(new_profile[1]) + self._rebuild_ui() + self._profile_loaded_callback(new_profile) + + def _get_available_profiles(self) -> list[list[str]]: + """Get list of available profiles, including any pending renames.""" + + profiles = self._manager.get_available_profiles() + if not profiles or len(profiles) == 0: + return [self._default_profile] + + result = [] + for profile in profiles: + if profile is None: + continue + internal_name = profile[1] + if internal_name in self._pending_renames: + result.append(self._pending_renames[internal_name]) + elif internal_name == "default": + result.append(self._default_profile) + else: + result.append(profile) + return result or [self._default_profile] + + def _get_active_profile(self) -> str: + return self._manager.get_active_profile() + + def get_profile_label(self, internal_name: str) -> str: + """Get the display label for a profile by its internal name.""" + + if internal_name in self._pending_renames: + return self._pending_renames[internal_name][0] + + if internal_name == "default": + return self._default_profile[0] + + for profile in self._manager.get_available_profiles(): + if profile is not None and profile[1] == internal_name: + return profile[0] + return internal_name + + def get_current_profile_label(self) -> str: + """Get the display label for the current profile, including pending renames.""" + + return self.get_profile_label(self._manager.get_active_profile()) + + def _set_active_profile(self, internal_name: str) -> None: + if self._initializing: + return + + profile = None + for p in self._get_available_profiles(): + if p[1] == internal_name: + profile = p + break + + if profile is None: + return + + def do_load_profile(): + try: + self._manager.load_profile(internal_name) + self._profile_loaded_callback(profile) + except (KeyError, FileNotFoundError) as error: + tokens = ["PROFILE MANAGER: Failed to load profile", internal_name, error] + debug.print_tokens(debug.LEVEL_SEVERE, tokens, True) + self.reload() + return False + + GLib.idle_add(do_load_profile) + + def _get_profile_actions(self, internal_name: str) -> list[tuple[str, str, Callable[[], None]]]: + """Get the list of actions (label, icon_name, callback) for a profile.""" + + if internal_name == "default": + return [] + + return [ + ( + guilabels.MENU_RENAME, + "document-edit-symbolic", + lambda: self._on_rename_profile(internal_name), + ), + ( + guilabels.MENU_REMOVE_PROFILE, + "user-trash-symbolic", + lambda: self._on_remove_profile(internal_name), + ), + ] + + @staticmethod + def _sanitize_profile_label(name: str) -> str: + """Strip control characters and surrounding whitespace from a profile label.""" + + return "".join(c for c in name if unicodedata.category(c)[0] != "C").strip() + + def _validate_profile_name( + self, + name: str, + exclude_internal_name: str | None = None, + ) -> tuple[bool, str]: + """Validate a profile name and return (is_valid, error_message).""" + + internal_name = name.replace(" ", "_").lower() + + saved_profiles = self._manager.get_available_profiles() + + for profile in saved_profiles: + if exclude_internal_name and profile[1] == exclude_internal_name: + continue + if profile[1].lower() == internal_name: + return (False, guilabels.PROFILE_CONFLICT_MESSAGE % name) + + for old_name, new_profile in self._pending_renames.items(): + if exclude_internal_name and old_name == exclude_internal_name: + continue + if new_profile[1].lower() == internal_name: + return (False, guilabels.PROFILE_CONFLICT_MESSAGE % name) + + return (True, "") + + def _show_error_dialog(self, message: str) -> None: + """Show an error dialog to the user.""" + + parent = self.get_toplevel() + dialog = Gtk.MessageDialog( + transient_for=parent if parent.is_toplevel() else None, + modal=True, + message_type=Gtk.MessageType.ERROR, + buttons=Gtk.ButtonsType.OK, + text=message, + ) + dialog.present_with_time(time.time()) + dialog.run() + dialog.destroy() + + def _show_confirmation_dialog(self, title: str, message: str) -> bool: + """Show a confirmation dialog and return True if user clicked Yes.""" + + parent = self.get_toplevel() + dialog = Gtk.MessageDialog( + transient_for=parent if parent.is_toplevel() else None, + modal=True, + message_type=Gtk.MessageType.WARNING, + buttons=Gtk.ButtonsType.YES_NO, + text=title, + ) + dialog.format_secondary_text(message) + dialog.present_with_time(time.time()) + response = dialog.run() + dialog.destroy() + return response == Gtk.ResponseType.YES + + def _on_remove_profile(self, internal_name: str) -> None: + """Handle Remove Profile action from three-dot menu.""" + + profile = None + for p in self._get_available_profiles(): + if p[1] == internal_name: + profile = p + break + + if profile is None: + return + + if not self._show_confirmation_dialog( + guilabels.PROFILE_REMOVE_LABEL, + guilabels.PROFILE_REMOVE_MESSAGE % profile[0], + ): + return + + self._manager.remove_profile(internal_name) + + active_profile_name = self._manager.get_active_profile() + if active_profile_name == internal_name: + self._manager.set_active_profile(self._default_profile[1]) + self._profile_loaded_callback(self._default_profile) + + self._rebuild_ui() + + def _on_rename_profile(self, internal_name: str) -> None: + profile = None + for p in self._get_available_profiles(): + if p[1] == internal_name: + profile = p + break + + if profile is None: + return + + dialog, ok_button = self._create_header_bar_dialog( + guilabels.MENU_RENAME, + guilabels.DIALOG_CANCEL, + guilabels.DIALOG_APPLY, + width=400, + ) + + content_area = dialog.get_content_area() + content_area.set_spacing(12) + + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + hbox.set_margin_start(12) + hbox.set_margin_end(12) + hbox.set_margin_top(12) + hbox.set_margin_bottom(12) + + label = Gtk.Label(label=guilabels.PROFILE_NAME_LABEL, xalign=0) + label.set_use_underline(True) + hbox.pack_start(label, False, False, 0) + + entry = Gtk.Entry() + entry.set_text(profile[0]) + entry.set_hexpand(True) + entry.set_activates_default(True) + entry.connect( + "changed", + lambda e: ok_button.set_sensitive(bool(self._sanitize_profile_label(e.get_text()))), + ) + label.set_mnemonic_widget(entry) + hbox.pack_start(entry, True, True, 0) + + content_area.pack_start(hbox, False, False, 0) + + dialog.show_all() + ok_button.grab_default() + entry.set_position(-1) + entry.grab_focus() + + response = dialog.run() + new_name = self._sanitize_profile_label(entry.get_text()) + dialog.destroy() + + if response != Gtk.ResponseType.OK or not new_name: + return + + is_valid, error_msg = self._validate_profile_name( + new_name, + exclude_internal_name=internal_name, + ) + if not is_valid: + self._show_error_dialog(error_msg) + return + + # Stage the rename - don't write to disk yet + new_profile = [new_name, internal_name] + self._pending_renames[internal_name] = new_profile + self._has_unsaved_changes = True + + self._rebuild_ui() + + if self._labels_update_callback: + self._labels_update_callback() + + def get_new_profile_name(self) -> list[str] | None: + """Show dialog to get a new profile name. Returns [label, name] or None if cancelled.""" + + dialog, ok_button = self._create_header_bar_dialog( + guilabels.PROFILE_SAVE_AS_TITLE, + guilabels.DIALOG_CANCEL, + guilabels.DIALOG_ADD, + width=400, + ) + + content_area = dialog.get_content_area() + content_area.set_spacing(12) + + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + hbox.set_margin_start(12) + hbox.set_margin_end(12) + hbox.set_margin_top(12) + hbox.set_margin_bottom(12) + + label = Gtk.Label(label=guilabels.PROFILE_NAME_LABEL, xalign=0) + label.set_use_underline(True) + hbox.pack_start(label, False, False, 0) + + entry = Gtk.Entry() + entry.set_hexpand(True) + entry.set_activates_default(True) + entry.connect( + "changed", + lambda e: ok_button.set_sensitive(bool(self._sanitize_profile_label(e.get_text()))), + ) + label.set_mnemonic_widget(entry) + hbox.pack_start(entry, True, True, 0) + + content_area.pack_start(hbox, False, False, 0) + + ok_button.set_sensitive(False) + dialog.show_all() + ok_button.grab_default() + entry.grab_focus() + + response = dialog.run() + new_name = self._sanitize_profile_label(entry.get_text()) + dialog.destroy() + + if response != Gtk.ResponseType.OK or not new_name: + return None + + is_valid, error_msg = self._validate_profile_name(new_name) + if not is_valid: + self._show_error_dialog(error_msg) + return None + + internal_name = new_name.replace(" ", "_").lower() + return [new_name, internal_name] + + def _rebuild_ui(self) -> None: + """Rebuild the UI without discarding pending changes.""" + + if self._auto_grid: + self.remove(self._auto_grid) + self._auto_grid.destroy() + self._auto_grid = None + + self._build() + self.show_all() + + def reload(self) -> None: + """Reload settings from the settings_manager and refresh the UI.""" + + self._pending_renames.clear() + self._rebuild_ui() + + def save_settings(self, _profile: str = "", _app_name: str = "") -> dict: + """Save settings and return a dictionary of the current values for those settings.""" + + result = {} + + if self._pending_renames: + result.update(self._apply_pending_renames()) + + if self._auto_grid: + result.update(self._auto_grid.save_settings()) + + return result + + def has_changes(self) -> bool: + """Return True if the user has made changes that haven't been written to file.""" + + if self._pending_renames: + return True + + if self._auto_grid: + return self._auto_grid.has_changes() + return False + + def set_focus_sidebar_callback(self, callback: Callable[[], None]) -> None: + """Set the callback to focus the sidebar navigation list.""" + + super().set_focus_sidebar_callback(callback) + if self._auto_grid: + self._auto_grid.set_focus_sidebar_callback(callback) + + def _apply_pending_renames(self) -> dict: + """Apply all pending profile renames and return updated settings.""" + + result = {} + active_profile_name = self._manager.get_active_profile() + + for old_internal_name, pending_profile in self._pending_renames.items(): + new_name = pending_profile[0] + new_internal_name = new_name.replace(" ", "_").lower() + new_profile = [new_name, new_internal_name] + + self._manager.rename_profile(old_internal_name, new_profile) + + if active_profile_name == old_internal_name: + self._manager.set_active_profile(new_internal_name) + result["activeProfile"] = new_profile + active_profile_name = new_internal_name + + self._pending_renames.clear() + + return result + + def refresh(self) -> None: + """Update UI to reflect current profiles and settings.""" + + self._initializing = True + if self._auto_grid: + self._auto_grid.refresh() + self._initializing = False + + +@gsettings_registry.get_registry().gsettings_schema( + "org.stormux.Cthulhu.ProfileMetadata", + name="metadata", +) +class ProfileManager: + """Manager for Cthulhu profiles.""" + + @gsettings_registry.get_registry().gsetting( + key="display-name", + schema="metadata", + gtype="s", + default="", + summary="Original display name (label) of the profile or app", + ) + def get_display_name(self) -> str: + """Returns the display name for the active profile.""" + + return gsettings_registry.get_registry().get_active_profile() + + @gsettings_registry.get_registry().gsetting( + key="internal-name", + schema="metadata", + gtype="s", + default="", + summary="Original internal name (JSON dict key) of the profile", + ) + def get_internal_name(self) -> str: + """Returns the internal name for the active profile.""" + + return gsettings_registry.get_registry().get_active_profile() + + def __init__(self) -> None: + self._initialized: bool = False + + msg = "PROFILE MANAGER: Registering D-Bus commands." + debug.print_message(debug.LEVEL_INFO, msg, True) + controller = dbus_service.get_remote_controller() + controller.register_decorated_module("ProfileManager", 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.GENERAL_PROFILES + + # (name, function, description, desktop_binding, laptop_binding) + commands_data = [ + ( + "cycleSettingsProfileHandler", + self.cycle_settings_profile, + cmdnames.CYCLE_SETTINGS_PROFILE, + None, + None, + ), + ( + "presentCurrentProfileHandler", + self.present_current_profile, + cmdnames.PRESENT_CURRENT_PROFILE, + None, + None, + ), + ] + + for name, function, description, desktop_kb, laptop_kb in commands_data: + manager.add_command( + command_manager.KeyboardCommand( + name, + function, + group_label, + description, + desktop_keybinding=desktop_kb, + laptop_keybinding=laptop_kb, + ), + ) + + msg = "PROFILE MANAGER: Commands set up." + debug.print_message(debug.LEVEL_INFO, msg, True) + + @dbus_service.getter + def get_available_profiles(self) -> list[list[str]]: + """Returns list of available profiles as [display_name, internal_name] pairs.""" + + profiles = self._get_stored_profiles(gsettings_registry.get_registry()) + for profile in profiles: + if profile[1] == "default": + profile[0] = guilabels.PROFILE_DEFAULT + break + return profiles + + @staticmethod + def _get_stored_profiles( + registry: gsettings_registry.GSettingsRegistry, + ) -> list[list[str]]: + """Returns available profiles by enumerating stored metadata.""" + + try: + result = subprocess.run( + ["dconf", "list", gsettings_registry.GSETTINGS_PATH_PREFIX], + capture_output=True, + text=True, + check=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + return [["Default", "default"]] + + default_profile: list[str] | None = None + profiles: list[list[str]] = [] + for entry in result.stdout.strip().split("\n"): + if not entry.endswith("/"): + continue + sanitized_name = entry.rstrip("/") + gs = registry.get_settings("metadata", sanitized_name) + if gs is None: + continue + display_variant = gs.get_user_value("display-name") + internal_variant = gs.get_user_value("internal-name") + if display_variant is None or internal_variant is None: + continue + display_name = display_variant.get_string() + internal_name = internal_variant.get_string() + profile = [display_name, internal_name] + if internal_name == "default": + default_profile = profile + else: + profiles.append(profile) + + if default_profile is not None: + profiles.insert(0, default_profile) + elif not profiles: + profiles.append(["Default", "default"]) + return profiles + + @dbus_service.getter + def get_active_profile(self) -> str: + """Returns the internal name of the currently active profile.""" + + return gsettings_registry.get_registry().get_active_profile() + + @dbus_service.setter + def set_active_profile(self, internal_name: str) -> bool: + """Sets the active profile by internal name.""" + + registry = gsettings_registry.get_registry() + registry.clear_runtime_values() + registry.set_active_profile(internal_name) + return True + + def load_profile(self, internal_name: str) -> None: + """Loads a profile by setting it active and reloading user settings.""" + + msg = f"PROFILE MANAGER: Loading profile '{internal_name}'." + debug.print_message(debug.LEVEL_INFO, msg, True) + + self.set_active_profile(internal_name) + cthulhu.load_user_settings(skip_reload_message=True) + + def create_profile(self, new_profile: list[str]) -> bool: + """Create a new profile by copying the current active profile to dconf.""" + + current_profile = self.get_active_profile() + msg = f"PROFILE MANAGER: Creating profile '{new_profile[1]}' from '{current_profile}'." + debug.print_message(debug.LEVEL_INFO, msg, True) + + registry = gsettings_registry.get_registry() + old_profile = registry.sanitize_gsettings_path(current_profile) + new_name = registry.sanitize_gsettings_path(new_profile[1]) + + for schema_name in registry.get_schema_names(): + if schema_name == "voice": + from . import gsettings_migrator # pylint: disable=import-outside-toplevel + + for voice_type in gsettings_migrator.VOICE_TYPES: + vt = gsettings_migrator.sanitize_gsettings_path(voice_type) + old_gs = registry.get_settings("voice", old_profile, f"voices/{vt}") + new_gs = registry.get_settings("voice", new_name, f"voices/{vt}") + registry.copy_user_keys(old_gs, new_gs) + continue + old_gs = registry.get_settings(schema_name, old_profile) + new_gs = registry.get_settings(schema_name, new_name) + registry.copy_user_keys(old_gs, new_gs) + + metadata_gs = registry.get_settings("metadata", new_name) + if metadata_gs is not None: + metadata_gs.set_string("display-name", new_profile[0]) + metadata_gs.set_string("internal-name", new_profile[1]) + + return True + + @dbus_service.getter + def get_starting_profile(self) -> list[str]: + """Returns the starting profile (always Default).""" + + return ["Default", "default"] + + @dbus_service.setter + def set_starting_profile(self, _profile: list[str]) -> bool: + """No-op for backwards compatibility. Starting profile is always Default.""" + + return True + + def remove_profile(self, internal_name: str) -> None: + """Removes a profile by internal name.""" + + registry = gsettings_registry.get_registry() + sanitized_name = registry.sanitize_gsettings_path(internal_name) + path = f"{gsettings_registry.GSETTINGS_PATH_PREFIX}{sanitized_name}/" + try: + subprocess.run(["dconf", "reset", "-f", path], check=True) + msg = f"PROFILE MANAGER: Cleared GSettings for profile: {internal_name}" + debug.print_message(debug.LEVEL_INFO, msg, True) + except (subprocess.CalledProcessError, FileNotFoundError) as e: + msg = f"PROFILE MANAGER: Failed to clear GSettings for profile: {internal_name}: {e}" + debug.print_message(debug.LEVEL_INFO, msg, True) + + def rename_profile(self, old_internal_name: str, new_profile: list[str]) -> None: + """Renames a profile.""" + + gsettings_registry.get_registry().rename_profile( + old_internal_name, + new_profile[0], + new_profile[1], + ) + + @dbus_service.command + def cycle_settings_profile( + self, + script: default.Script | None = None, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Cycle through the user's existing settings profiles.""" + + tokens = [ + "PROFILE MANAGER: cycle_settings_profile. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + profile_names = self.get_available_profiles() + if not profile_names: + if script is not None and notify_user: + presentation_manager.get_manager().present_message(messages.PROFILE_NOT_FOUND) + return True + + profiles = [(profile[0], profile[1]) for profile in profile_names] + current_profile = self.get_active_profile() + + current_index = 0 + for i, (_, internal_name) in enumerate(profiles): + if internal_name == current_profile: + current_index = i + break + + try: + name, profile_id = profiles[current_index + 1] + except IndexError: + name, profile_id = profiles[0] + + self.set_active_profile(profile_id) + + cthulhu_modifier_manager.get_manager().unset_cthulhu_modifiers("Profile changing.") + command_manager.get_manager().load_keyboard_layout() + cthulhu_modifier_manager.get_manager().refresh_cthulhu_modifiers("Profile changed.") + presentation_manager.get_manager().refresh_presenters() + + if script is not None: + script.set_up_commands() + if notify_user: + presentation_manager.get_manager().present_message( + messages.PROFILE_CHANGED % name, + name, + ) + + return True + + @dbus_service.command + def present_current_profile( + self, + script: default.Script | None = None, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Present the name of the currently active profile.""" + + tokens = [ + "PROFILE MANAGER: present_current_profile. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + profile_names = self.get_available_profiles() + current_profile_id = self.get_active_profile() + + name = current_profile_id + for display_name, internal_name in profile_names: + if internal_name == current_profile_id: + name = display_name + break + + if script is not None and notify_user: + presentation_manager.get_manager().present_message( + messages.PROFILE_CURRENT % name, + name, + ) + + return True + + def create_preferences_grid( + self, + profile_loaded_callback: Callable[[list[str]], None], + is_app_specific: bool = False, + labels_update_callback: Callable[[], None] | None = None, + unsaved_changes_checker: Callable[[], bool] | None = None, + ) -> ProfilePreferencesGrid: + """Returns the GtkGrid containing the profile management UI.""" + + return ProfilePreferencesGrid( + self, + profile_loaded_callback, + is_app_specific, + labels_update_callback, + unsaved_changes_checker, + ) + + +_manager = ProfileManager() + + +def get_manager() -> ProfileManager: + """Returns the profile-manager singleton.""" + + return _manager diff --git a/src/cthulhu/pronunciation_dictionary_manager.py b/src/cthulhu/pronunciation_dictionary_manager.py new file mode 100644 index 0000000..44a6b41 --- /dev/null +++ b/src/cthulhu/pronunciation_dictionary_manager.py @@ -0,0 +1,579 @@ +# Cthulhu +# +# Copyright 2006-2008 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. + +# pylint: disable=too-many-branches + +"""Manager for user's pronunciation dictionary that maps words to what they sound like.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk # pylint: disable=no-name-in-module + +from . import ( + gsettings_migrator, + gsettings_registry, + guilabels, + messages, + preferences_grid_base, + presentation_manager, + script_manager, +) + +if TYPE_CHECKING: + from collections.abc import Callable + + from .scripts import default + + +class PronunciationDictionaryPreferencesGrid( # pylint: disable=too-many-instance-attributes + preferences_grid_base.PreferencesGridBase, +): + """GtkGrid containing the Pronunciation Dictionary preferences page.""" + + # pylint: disable=no-member + + def __init__(self, manager: PronunciationDictionaryManager, script: default.Script) -> None: + super().__init__(guilabels.PRONUNCIATION) + self._manager: PronunciationDictionaryManager = manager + self._script: default.Script = script + self._initializing: bool = True + + self._pronunciations: list[tuple[str, str]] = [] + self._inherited_keys: set[str] = set() + self._deleted_inherited_keys: set[str] = set() + self._listbox: Gtk.ListBox | None = None + self._has_unsaved_changes: bool = False + self._loaded_from_settings: bool = False + + # Size group to ensure all left labels (phrases) have the same width + self._left_label_size_group: Gtk.SizeGroup = Gtk.SizeGroup( + mode=Gtk.SizeGroupMode.HORIZONTAL, + ) + + self._build() + self.refresh() + + def _build(self) -> None: + """Create the Gtk widgets composing the grid.""" + + row = 0 + + # Info box + info_listbox = self._create_info_listbox(guilabels.PRONUNCIATION_DICTIONARY_INFO) + info_listbox.set_margin_bottom(12) + self.attach(info_listbox, 0, row, 1, 1) + row += 1 + + # Header box with title and + button + header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + header_box.set_margin_bottom(6) + + title_label = Gtk.Label(label=guilabels.PRONUNCIATION_DICTIONARY) + title_label.set_halign(Gtk.Align.START) + title_label.get_style_context().add_class("heading") + header_box.pack_start(title_label, True, True, 0) + + add_button = Gtk.Button.new_from_icon_name("list-add-symbolic", Gtk.IconSize.BUTTON) + add_button.get_accessible().set_name(guilabels.DICTIONARY_NEW_ENTRY) + add_button.connect("clicked", self._on_add_clicked) + header_box.pack_end(add_button, False, False, 0) + + self.attach(header_box, 0, row, 1, 1) + row += 1 + + # ListBox for pronunciation entries + self._listbox = Gtk.ListBox() + self._listbox.set_selection_mode(Gtk.SelectionMode.SINGLE) + self._listbox.get_style_context().add_class("frame") + + # Suppress pronunciation substitution while editing entries + self._listbox.connect("realize", self._on_listbox_realize) + self._listbox.connect("unrealize", self._on_listbox_unrealize) + + scrolled_window = self._create_scrolled_window(self._listbox) + self.attach(scrolled_window, 0, row, 1, 1) + + def _create_two_label_row( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals + self, + left_label_text: str, + right_label_text: str, + edit_handler: Callable[[Gtk.Button], None] | None = None, + delete_handler: Callable[[Gtk.Button], None] | None = None, + include_top_separator: bool = True, + left_label_size_group: Gtk.SizeGroup | None = None, + ) -> Gtk.ListBoxRow: + """Create a single listbox row with two labels and optional edit/delete buttons.""" + + row = Gtk.ListBoxRow() + row.set_activatable(False) + + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + + if include_top_separator: + separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) + vbox.pack_start(separator, False, False, 0) + + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + hbox.set_margin_start(12) + hbox.set_margin_end(12) + hbox.set_margin_top(12) + hbox.set_margin_bottom(12) + + left_label = Gtk.Label(label=left_label_text, xalign=0) + + if left_label_size_group: + left_label_size_group.add_widget(left_label) + + hbox.pack_start(left_label, False, False, 0) + + right_label = Gtk.Label(label=right_label_text, xalign=0) + right_label.set_hexpand(True) + hbox.pack_start(right_label, True, True, 0) + + button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + + delete_button = None + if delete_handler: + delete_button = Gtk.Button.new_from_icon_name("user-trash-symbolic", Gtk.IconSize.DND) + delete_button.set_relief(Gtk.ReliefStyle.NONE) + delete_button.get_accessible().set_name(guilabels.DICTIONARY_DELETE) + delete_button.connect("clicked", delete_handler) + button_box.pack_start(delete_button, False, False, 0) + + edit_button = None + if edit_handler: + edit_button = Gtk.Button.new_from_icon_name("document-edit-symbolic", Gtk.IconSize.DND) + edit_button.set_relief(Gtk.ReliefStyle.NONE) + edit_button.get_accessible().set_name(guilabels.DIALOG_EDIT) + edit_button.connect("clicked", edit_handler) + button_box.pack_start(edit_button, False, False, 0) + + hbox.pack_end(button_box, False, False, 0) + + vbox.pack_start(hbox, False, False, 0) + row.add(vbox) + + row.delete_button = delete_button + row.edit_button = edit_button + + return row + + def _create_row( + self, + phrase: str, + substitution: str, + row_index: int, + include_top_separator: bool = True, + ) -> Gtk.ListBoxRow: + """Create a ListBoxRow for a pronunciation entry.""" + + row = self._create_two_label_row( + phrase, + substitution, + edit_handler=self._on_edit_clicked, + delete_handler=self._on_delete_clicked, + include_top_separator=include_top_separator, + left_label_size_group=self._left_label_size_group, + ) + + row.pronunciation_row_index = row_index + + if row.edit_button: + row.edit_button.pronunciation_row_index = row_index + + if row.delete_button: + row.delete_button.pronunciation_row_index = row_index + + return row + + def _on_edit_clicked(self, button: Gtk.Button) -> None: + """Handle edit button click.""" + + row_index = button.pronunciation_row_index + phrase, substitution = self._pronunciations[row_index] + self._show_edit_dialog(phrase, substitution, row_index) + + def _on_delete_clicked(self, button: Gtk.Button) -> None: + """Handle delete button click.""" + + row_index = button.pronunciation_row_index + phrase, _ = self._pronunciations[row_index] + key = phrase.lower() + if key in self._inherited_keys: + self._deleted_inherited_keys.add(key) + self._inherited_keys.discard(key) + del self._pronunciations[row_index] + self._has_unsaved_changes = True + self.refresh() + + script = script_manager.get_manager().get_active_script() + if script: + presentation_manager.get_manager().present_message( + messages.PRONUNCIATION_DELETED % phrase, + ) + + def _on_listbox_realize(self, _widget: Gtk.Widget) -> None: + """Suppress pronunciation substitution while editing entries.""" + + self._manager.suppress() + + def _on_listbox_unrealize(self, _widget: Gtk.Widget) -> None: + """Restore pronunciation substitution when done editing entries.""" + + self._manager.unsuppress() + + def _on_add_clicked(self, _button: Gtk.Button) -> None: + """Handle Add button click to open add dialog.""" + + self._show_add_dialog() + + def _show_add_dialog(self) -> None: + """Show dialog to add a new pronunciation.""" + + dialog, add_button = self._create_header_bar_dialog( + guilabels.ADD_NEW_PRONUNCIATION, + guilabels.DIALOG_CANCEL, + guilabels.DIALOG_ADD, + ) + + content_area = dialog.get_content_area() + + listbox = preferences_grid_base.FocusManagedListBox() + + # Size group to ensure labels have same width and entries align + label_size_group = Gtk.SizeGroup(mode=Gtk.SizeGroupMode.HORIZONTAL) + + # Actual string row + phrase_entry = Gtk.Entry() + phrase_entry.set_size_request(-1, 40) + phrase_row = self._create_labeled_entry_row( + guilabels.DICTIONARY_ACTUAL_STRING, + phrase_entry, + include_top_separator=False, + label_size_group=label_size_group, + ) + listbox.add_row_with_widget(phrase_row, phrase_entry) + + # Replacement string row + substitution_entry = Gtk.Entry() + substitution_entry.set_size_request(-1, 40) + substitution_row = self._create_labeled_entry_row( + guilabels.DICTIONARY_REPLACEMENT_STRING, + substitution_entry, + label_size_group=label_size_group, + ) + listbox.add_row_with_widget(substitution_row, substitution_entry) + + def on_entry_activate(_entry): + """Only activate dialog if both fields are filled.""" + if phrase_entry.get_text().strip() and substitution_entry.get_text().strip(): + dialog.response(Gtk.ResponseType.OK) + + phrase_entry.connect("activate", on_entry_activate) + substitution_entry.connect("activate", on_entry_activate) + + content_area.pack_start(listbox, True, True, 0) + + def on_response(dlg, response_id): + if response_id == Gtk.ResponseType.OK: + phrase = phrase_entry.get_text().strip() + substitution = substitution_entry.get_text().strip() + if phrase and substitution: + self._pronunciations.append((phrase, substitution)) + self._has_unsaved_changes = True + self.refresh() + dlg.destroy() + + dialog.connect("response", on_response) + dialog.show_all() + add_button.grab_default() + phrase_entry.grab_focus() + + def _show_edit_dialog( # pylint: disable=too-many-locals + self, + phrase: str, + substitution: str, + row_index: int, + ) -> None: + """Show dialog to edit an existing pronunciation.""" + + dialog, edit_button = self._create_header_bar_dialog( + guilabels.EDIT_PRONUNCIATION, + guilabels.DIALOG_CANCEL, + guilabels.DIALOG_EDIT, + ) + + content_area = dialog.get_content_area() + + listbox = preferences_grid_base.FocusManagedListBox() + + # Size group to ensure labels have same width and entries align + label_size_group = Gtk.SizeGroup(mode=Gtk.SizeGroupMode.HORIZONTAL) + + # Actual string row + phrase_entry = Gtk.Entry() + phrase_entry.set_text(phrase) + phrase_entry.set_size_request(-1, 40) + phrase_row = self._create_labeled_entry_row( + guilabels.DICTIONARY_ACTUAL_STRING, + phrase_entry, + include_top_separator=False, + label_size_group=label_size_group, + ) + listbox.add_row_with_widget(phrase_row, phrase_entry) + + # Replacement string row + substitution_entry = Gtk.Entry() + substitution_entry.set_text(substitution) + substitution_entry.set_size_request(-1, 40) + substitution_row = self._create_labeled_entry_row( + guilabels.DICTIONARY_REPLACEMENT_STRING, + substitution_entry, + label_size_group=label_size_group, + ) + listbox.add_row_with_widget(substitution_row, substitution_entry) + + def on_entry_activate(_entry): + """Only activate dialog if both fields are filled.""" + if phrase_entry.get_text().strip() and substitution_entry.get_text().strip(): + dialog.response(Gtk.ResponseType.OK) + + phrase_entry.connect("activate", on_entry_activate) + substitution_entry.connect("activate", on_entry_activate) + + content_area.pack_start(listbox, True, True, 0) + + def on_response(dlg, response_id): + if response_id == Gtk.ResponseType.OK: + new_phrase = phrase_entry.get_text().strip() + new_substitution = substitution_entry.get_text().strip() + if new_phrase and new_substitution: + self._inherited_keys.discard(phrase.lower()) + self._pronunciations[row_index] = (new_phrase, new_substitution) + self._has_unsaved_changes = True + self.refresh() + dlg.destroy() + + dialog.connect("response", on_response) + dialog.show_all() + edit_button.grab_default() + phrase_entry.grab_focus() + + def reload(self) -> None: + """Reload settings from the manager and refresh the UI.""" + + self._pronunciations = [] + self._inherited_keys = set() + self._deleted_inherited_keys = set() + self._loaded_from_settings = False + self._has_unsaved_changes = False + self.refresh() + + def save_settings(self, profile: str = "", app_name: str = "") -> dict[str, list[str]]: + """Save settings and return a dictionary of the current values for those settings.""" + + self._manager.set_dictionary({}) + result: dict[str, list[str]] = {} + + for phrase, substitution in self._pronunciations: + if phrase and substitution: + self._manager.set_pronunciation(phrase, substitution) + result[phrase.lower()] = [phrase, substitution] + + self._has_unsaved_changes = False + + if profile: + registry = gsettings_registry.get_registry() + parent_pronunciations: dict[str, str] = {} + if profile != "default" or app_name: + if profile != "default": + parent_pronunciations |= registry.get_pronunciations("default", "") + if app_name: + parent_pronunciations |= registry.get_pronunciations(profile, "") + + diff: dict[str, str] = {} + for key, value in result.items(): + replacement = value[1] + if parent_pronunciations.get(key) != replacement: + diff[key] = replacement + for key in self._deleted_inherited_keys: + if key in parent_pronunciations: + diff[key] = "" + + pron_gs = registry.get_settings("pronunciations", profile, "pronunciations", app_name) + if pron_gs is not None: + if diff: + gsettings_migrator.import_pronunciations(pron_gs, diff) + elif pron_gs.get_user_value("entries") is not None: + pron_gs.reset("entries") + + return result + + def refresh(self) -> None: + """Update listbox to reflect current pronunciation list.""" + + if self._listbox is None: + return + + self._initializing = True + + for child in self._listbox.get_children(): + self._listbox.remove(child) + + if not self._loaded_from_settings: + self._loaded_from_settings = True + registry = gsettings_registry.get_registry() + profile = registry.get_active_profile() + app_name = "" + if self._script and self._script.app: + from .ax_object import AXObject # pylint: disable=import-outside-toplevel + + app_name = AXObject.get_name(self._script.app) + + pronunciation_dict = registry.layered_lookup( + "pronunciations", + "entries", + "a{ss}", + app_name=app_name or None, + default={}, + ) + local_dict = registry.get_pronunciations(profile, app_name) + self._inherited_keys = set(pronunciation_dict.keys()) - set(local_dict.keys()) + + for key in sorted(pronunciation_dict.keys()): + if pronunciation_dict[key]: + self._pronunciations.append((key, pronunciation_dict[key])) + + if self._pronunciations: + for index, (phrase, substitution) in enumerate(self._pronunciations): + row = self._create_row( + phrase, + substitution, + index, + include_top_separator=index > 0, + ) + self._listbox.add(row) + else: + empty_row = self._create_info_row(guilabels.DICTIONARY_EMPTY) + self._listbox.add(empty_row) + + self._listbox.show_all() + self._initializing = False + + +@gsettings_registry.get_registry().gsettings_schema( + "org.stormux.Cthulhu.Pronunciations", + name="pronunciations", +) +class PronunciationDictionaryManager: + """Manager for the pronunciation dictionary.""" + + def __init__(self) -> None: + self._dictionary: dict[str, str] | None = None + self._cached_app: str | None = None + self._cached_profile: str = "default" + self._suppressed: bool = False + + def create_preferences_grid( + self, + script: default.Script, + ) -> PronunciationDictionaryPreferencesGrid: + """Returns the GtkGrid containing the pronunciation dictionary UI.""" + + return PronunciationDictionaryPreferencesGrid(self, script) + + def suppress(self) -> None: + """Suppresses pronunciation substitution without changing the user's preference.""" + + self._suppressed = True + + def unsuppress(self) -> None: + """Restores pronunciation substitution after a prior suppress call.""" + + self._suppressed = False + + def get_pronunciation(self, word: str) -> str: + """Returns the pronunciation for word, or word if not found.""" + + if self._suppressed: + return word + + registry = gsettings_registry.get_registry() + current_app = registry.get_active_app() + current_profile = registry.get_active_profile() + if self._cached_app != current_app or self._cached_profile != current_profile: + self._dictionary = None + self._cached_app = current_app + self._cached_profile = current_profile + + if self._dictionary is None: + self._dictionary = registry.layered_lookup( + "pronunciations", + "entries", + "a{ss}", + default={}, + ) + + return self._dictionary.get(word.lower(), word) or word + + def set_pronunciation(self, word: str, replacement: str) -> None: + """Adds word/replacement pair.""" + + # TODO - JD: Storing the words as lowercase is what we've done historically. + # However, this means that on occasions where case sensitivity matters, there + # will be a false positive (e.g., "US" vs "us"). Consider adding a checkbox + # to the UI to allow users to choose case sensitivity for individual entries. + + key = word.lower() + if self._dictionary is None: + self._dictionary = {} + self._dictionary[key] = replacement + + @gsettings_registry.get_registry().gsetting( + key="entries", + schema="pronunciations", + gtype="a{ss}", + default={}, + summary="Pronunciation dictionary entries", + ) + def get_dictionary(self) -> dict[str, str]: + """Returns the pronunciation dictionary.""" + + if self._dictionary is None: + return {} + return self._dictionary + + def set_dictionary(self, value: dict[str, str]) -> None: + """Sets the pronunciation dictionary, or invalidates the cache if empty.""" + + self._dictionary = value or None + + +_manager = PronunciationDictionaryManager() + + +def get_manager() -> PronunciationDictionaryManager: + """Returns the pronunciation-dictionary-manager singleton.""" + + return _manager diff --git a/src/cthulhu/say_all_presenter.py b/src/cthulhu/say_all_presenter.py new file mode 100644 index 0000000..64bd8fe --- /dev/null +++ b/src/cthulhu/say_all_presenter.py @@ -0,0 +1,993 @@ +# Cthulhu +# +# Copyright 2005-2009 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-locals +# pylint: disable=too-many-arguments +# pylint: disable=too-many-positional-arguments +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-instance-attributes + +"""Module for commands related to the current accessible object.""" + +from __future__ import annotations + +import unicodedata +from enum import Enum +from typing import TYPE_CHECKING + +from . import ( + ax_event_synthesizer, + cmdnames, + command_manager, + dbus_service, + debug, + focus_manager, + gsettings_registry, + guilabels, + input_event, + input_event_manager, + keybindings, + messages, + preferences_grid_base, + presentation_manager, + speech_presenter, + speechserver, + structural_navigator, +) +from .acss import ACSS +from .ax_object import AXObject +from .ax_text import AXText +from .ax_utilities import AXUtilities + +if TYPE_CHECKING: + from collections.abc import Generator + + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + + from .scripts import default + + +@gsettings_registry.get_registry().gsettings_enum( + "org.stormux.Cthulhu.SayAllStyle", + values={"line": 0, "sentence": 1}, +) +class SayAllStyle(Enum): + """Style enumeration with int values from settings.""" + + SENTENCE = 1 + LINE = 0 + + @property + def string_name(self) -> str: + """Returns the lowercase string name for this enum value.""" + + return self.name.lower() + + +class SayAllPreferencesGrid(preferences_grid_base.AutoPreferencesGrid): + """GtkGrid containing the Say All preferences page.""" + + _gsettings_schema = "say-all" + + def __init__(self, presenter: SayAllPresenter) -> None: + self._only_speak_displayed_control = preferences_grid_base.BooleanPreferenceControl( + label=guilabels.SPEECH_ONLY_SPEAK_DISPLAYED_TEXT, + getter=presenter.get_only_speak_displayed_text, + setter=presenter.set_only_speak_displayed_text, + prefs_key=SayAllPresenter.KEY_ONLY_SPEAK_DISPLAYED_TEXT, + ) + + controls: list[preferences_grid_base.ControlType] = [ + preferences_grid_base.EnumPreferenceControl( + label=guilabels.SAY_ALL_BY, + options=[guilabels.SAY_ALL_STYLE_SENTENCE, guilabels.SAY_ALL_STYLE_LINE], + values=[SayAllStyle.SENTENCE.value, SayAllStyle.LINE.value], + getter=presenter.get_style_as_int, + setter=presenter.set_style_from_int, + prefs_key=SayAllPresenter.KEY_STYLE, + ), + self._only_speak_displayed_control, + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.SAY_ALL_UP_AND_DOWN_ARROW, + getter=presenter.get_rewind_and_fast_forward_enabled, + setter=presenter.set_rewind_and_fast_forward_enabled, + prefs_key=SayAllPresenter.KEY_REWIND_AND_FAST_FORWARD, + member_of=guilabels.SAY_ALL_REWIND_AND_FAST_FORWARD_BY, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.SAY_ALL_STRUCTURAL_NAVIGATION, + getter=presenter.get_structural_navigation_enabled, + setter=presenter.set_structural_navigation_enabled, + prefs_key=SayAllPresenter.KEY_STRUCTURAL_NAVIGATION, + member_of=guilabels.SAY_ALL_REWIND_AND_FAST_FORWARD_BY, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.ANNOUNCE_BLOCKQUOTES, + getter=presenter.get_announce_blockquote, + setter=presenter.set_announce_blockquote, + prefs_key=SayAllPresenter.KEY_ANNOUNCE_BLOCKQUOTE, + member_of=guilabels.ANNOUNCEMENTS, + determine_sensitivity=self._only_speak_displayed_text_is_off, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.ANNOUNCE_FORMS, + getter=presenter.get_announce_form, + setter=presenter.set_announce_form, + prefs_key=SayAllPresenter.KEY_ANNOUNCE_FORM, + member_of=guilabels.ANNOUNCEMENTS, + determine_sensitivity=self._only_speak_displayed_text_is_off, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.ANNOUNCE_LANDMARKS, + getter=presenter.get_announce_landmark, + setter=presenter.set_announce_landmark, + prefs_key=SayAllPresenter.KEY_ANNOUNCE_LANDMARK, + member_of=guilabels.ANNOUNCEMENTS, + determine_sensitivity=self._only_speak_displayed_text_is_off, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.ANNOUNCE_LISTS, + getter=presenter.get_announce_list, + setter=presenter.set_announce_list, + prefs_key=SayAllPresenter.KEY_ANNOUNCE_LIST, + member_of=guilabels.ANNOUNCEMENTS, + determine_sensitivity=self._only_speak_displayed_text_is_off, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.ANNOUNCE_PANELS, + getter=presenter.get_announce_grouping, + setter=presenter.set_announce_grouping, + prefs_key=SayAllPresenter.KEY_ANNOUNCE_GROUPING, + member_of=guilabels.ANNOUNCEMENTS, + determine_sensitivity=self._only_speak_displayed_text_is_off, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.ANNOUNCE_TABLES, + getter=presenter.get_announce_table, + setter=presenter.set_announce_table, + prefs_key=SayAllPresenter.KEY_ANNOUNCE_TABLE, + member_of=guilabels.ANNOUNCEMENTS, + determine_sensitivity=self._only_speak_displayed_text_is_off, + ), + ] + + info = ( + f"{guilabels.SAY_ALL_INFO}\n\n{guilabels.SAY_ALL_NAVIGATION_INFO}" + f"\n\n{guilabels.SAY_ALL_CONTAINER_INFO}" + ) + super().__init__(guilabels.GENERAL_SAY_ALL, controls, info_message=info) + + def _only_speak_displayed_text_is_off(self) -> bool: + """Returns True if only-speak-displayed-text is off in the UI.""" + + widget = self.get_widget_for_control(self._only_speak_displayed_control) + if widget: + return not widget.get_active() + return True + + +@gsettings_registry.get_registry().gsettings_schema("org.stormux.Cthulhu.SayAll", name="say-all") +class SayAllPresenter: + """Module for commands related to the current accessible object.""" + + _SCHEMA = "say-all" + KEY_ANNOUNCE_BLOCKQUOTE = "announce-blockquote" + KEY_ANNOUNCE_FORM = "announce-form" + KEY_ANNOUNCE_GROUPING = "announce-grouping" + KEY_ANNOUNCE_LANDMARK = "announce-landmark" + KEY_ANNOUNCE_LIST = "announce-list" + KEY_ANNOUNCE_TABLE = "announce-table" + KEY_ONLY_SPEAK_DISPLAYED_TEXT = "only-speak-displayed-text" + KEY_REWIND_AND_FAST_FORWARD = "rewind-and-fast-forward" + KEY_STRUCTURAL_NAVIGATION = "structural-navigation" + KEY_STYLE = "style" + + 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._script: default.Script | None = None + self._contents: list[tuple[Atspi.Accessible, int, int, str]] = [] + self._contexts: list[speechserver.SayAllContext] = [] + self._current_context: speechserver.SayAllContext | None = None + self._say_all_is_running: bool = False + self._initialized: bool = False + + msg = "SAY ALL PRESENTER: Registering D-Bus commands." + debug.print_message(debug.LEVEL_INFO, msg, True) + controller = dbus_service.get_remote_controller() + controller.register_decorated_module("SayAllPresenter", 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 + + # Layout-specific keybindings + kb_desktop = keybindings.KeyBinding("KP_Add", keybindings.NO_MODIFIER_MASK) + kb_laptop = keybindings.KeyBinding("semicolon", keybindings.CTHULHU_MODIFIER_MASK) + + manager.add_command( + command_manager.KeyboardCommand( + "sayAllHandler", + self.say_all, + group_label, + cmdnames.SAY_ALL, + desktop_keybinding=kb_desktop, + laptop_keybinding=kb_laptop, + ), + ) + + msg = "SAY ALL PRESENTER: Commands set up." + debug.print_message(debug.LEVEL_INFO, msg, True) + + def create_preferences_grid(self) -> SayAllPreferencesGrid: + """Returns the GtkGrid containing the Say All preferences UI.""" + + return SayAllPreferencesGrid(self) + + def get_style_as_int(self) -> int: + """Returns the current Say All style as an integer value.""" + + style_name = self.get_style() + return SayAllStyle[style_name.upper()].value + + def set_style_from_int(self, value: int) -> bool: + """Sets the Say All style from an integer value.""" + + msg = f"SAY ALL PRESENTER: Setting style to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + style_name = SayAllStyle(value).string_name + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, self.KEY_STYLE, style_name + ) + return True + + @dbus_service.command + def say_all( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + obj: Atspi.Accessible | None = None, + offset: int | None = None, + ) -> bool: + """Speaks the entire document or text, starting from the current position.""" + + self._contexts = [] + self._contents = [] + self._current_context = None + self._say_all_is_running = False + + tokens = [ + "SAY ALL PRESENTER: say_all. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._script = script + presentation_manager.get_manager().interrupt_presentation() + obj = obj or focus_manager.get_manager().get_locus_of_focus() + if not obj or AXObject.is_dead(obj): + presentation_manager.get_manager().present_message(messages.LOCATION_NOT_FOUND_FULL) + return True + + speech_presenter.get_presenter().say_all( + self._say_all_iter(obj, offset), + self._progress_callback, + ) + return True + + @dbus_service.command + def rewind( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Jumps back in the current Say All.""" + + tokens = [ + "SAY ALL PRESENTER: rewind. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return self._rewind(None, True) + + @dbus_service.command + def fast_forward( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Jumps forward in the current Say All.""" + + tokens = [ + "SAY ALL PRESENTER: fast_forward. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return self._fast_forward(None, True) + + def stop(self) -> None: + """Stops the current Say All.""" + + self._contexts = [] + self._contents = [] + self._current_context = None + self._say_all_is_running = False + focus_manager.get_manager().reset_active_mode("SAY ALL PRESENTER: Stopped Say All.") + + def _parse_utterances( + self, + utterances: list[str | ACSS | list], + ) -> tuple[list[str], list[ACSS]]: + """Parse utterances into elements and voices lists.""" + + # TODO - JD: This is a workaround from the web script. Ideally, the speech servers' say-all + # would be able to handle more complex utterances. + elements, voices = [], [] + for u in utterances: + if isinstance(u, list): + e, v = self._parse_utterances(u) + elements.extend(e) + voices.extend(v) + elif isinstance(u, str): + elements.append(u) + elif isinstance(u, ACSS): + voices.append(u) + return elements, voices + + def _say_all_should_skip_content( + self, + content: tuple[Atspi.Accessible, int, int, str], + contents: list[tuple[Atspi.Accessible, int, int, str]], # pylint: disable=unused-argument + ) -> tuple[bool, str]: + """Returns True if content should be skipped during say-all iteration.""" + + obj, start_offset, end_offset, _text = content + if start_offset == end_offset: + return True, "start_offset equals end_offset" + if AXUtilities.get_is_label_for(obj) and not AXUtilities.is_focusable(obj): + return True, "is non-focusable label for other object" + stripped = _text.strip() + if stripped and all(unicodedata.category(c).startswith("P") for c in stripped): + return True, "is punctuation only" + return False, "" + + def _advance_to_next( + self, + obj: Atspi.Accessible, + _offset: int, + contents: list, + restrict_to: Atspi.Accessible | None, + ) -> tuple[Atspi.Accessible | None, int]: + """Advances to the next content position during say-all iteration.""" + + assert self._script is not None + if contents: + last_obj, last_offset = contents[-1][0], contents[-1][2] + # last_offset is the start of the next text unit (per AT-SPI2 semantics). + # next_context() looks for the position after the provided offset. In the case of + # text, we will wind up with the same text unit for last_offset and last_offset - 1. + # However, if the character at last_offset is an embedded object, we'll skip over + # its contents if we pass last_offset directly. Only decrement in that case so that + # next_context() can still cross object boundaries at end of text. + if AXUtilities.character_at_offset_is_eoc(last_obj, last_offset): + last_offset = max(0, last_offset - 1) + next_obj, next_offset = self._script.utilities.next_context( + last_obj, + last_offset, + restrict_to=restrict_to, + ) + else: + next_obj = self._script.utilities.find_next_object(obj, restrict_to) + next_offset = 0 + + if next_obj is not None: + tokens = ["SAY ALL PRESENTER: Updating focus to", next_obj] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + focus_manager.get_manager().set_locus_of_focus(None, next_obj, notify_script=False) + + return next_obj, next_offset + + def _build_displayed_text_context( + self, + contents: list[tuple[Atspi.Accessible, int, int, str]], + ) -> tuple[speechserver.SayAllContext, ACSS] | None: + """Returns a single SayAllContext from displayed text, or None if empty.""" + + assert self._script is not None + + parts: list[str] = [] + first_obj, first_start, last_end = None, 0, 0 + for content in contents: + content_obj, start, end, _text = content + skip, _reason = self._say_all_should_skip_content(content, contents) + if skip: + continue + expanded = self._script.utilities.expand_eocs(content_obj, start, end) + if not expanded.strip(): + continue + if first_obj is None: + first_obj, first_start = content_obj, start + last_end = end + parts.append(expanded) + + if first_obj is None or not parts: + return None + + combined = " ".join(parts) + context = speechserver.SayAllContext(first_obj, combined, first_start, last_end) + self._contexts.append(context) + tokens = [ + "SAY ALL PRESENTER: Speaking (displayed-text):", + first_obj, + f"'{combined}' ({first_start}-{last_end})", + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._script.utilities.set_caret_offset(first_obj, first_start) + ax_event_synthesizer.get_synthesizer().scroll_into_view( + context.obj, + context.start_offset, + context.end_offset, + ) + return context, ACSS() + + def _generate_speech_contexts( + self, + contents: list[tuple[Atspi.Accessible, int, int, str]], + prior_obj: Atspi.Accessible, + ) -> Generator[list[speechserver.SayAllContext | ACSS], None, None]: + """Yields [SayAllContext, ACSS] pairs for each content item.""" + + assert self._script is not None + + for i, content in enumerate(contents): + content_obj, start, end, text = content + tokens = [ + f"SAY ALL PRESENTER: CONTENT: {i}.", + content_obj, + f"'{text}' ({start}-{end})", + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + skip, reason = self._say_all_should_skip_content(content, contents) + if skip: + msg = f"SAY ALL PRESENTER: Skipping content - {reason}." + debug.print_message(debug.LEVEL_INFO, msg, True) + continue + + utterances = speech_presenter.get_presenter().generate_speech_contents( + self._script, + [content], + eliminatePauses=True, + priorObj=prior_obj, + index=i, + total=len(contents), + ) + prior_obj = content_obj + elements, voices = self._parse_utterances(utterances) + if len(elements) != len(voices): + tokens = [ + "SAY ALL PRESENTER: Skipping content - elements/voices mismatch:", + content_obj, + f"'{text}', elements: {len(elements)}, voices: {len(voices)}", + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + continue + + for element, voice in zip(elements, voices, strict=True): + if not element or (isinstance(element, str) and not element.strip()): + continue + + context = speechserver.SayAllContext(content_obj, element, start, end) + self._contexts.append(context) + tokens = [ + "SAY ALL PRESENTER: Speaking (contents):", + content_obj, + f"'{element}' ({start}-{end})", + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._script.utilities.set_caret_offset(content_obj, start) + ax_event_synthesizer.get_synthesizer().scroll_into_view( + context.obj, + context.start_offset, + context.end_offset, + ) + yield [context, voice] + + def _say_all_iter( + self, + obj: Atspi.Accessible, + offset: int | None = None, + ) -> Generator[list[speechserver.SayAllContext | ACSS], None, None]: + """A generator used by Say All.""" + + assert self._script is not None, "Script must be set before calling _say_all_iter." + + prior_obj = obj + say_all_by_sentence = self.get_style() == "sentence" + + if offset is None: + offset = self._script.utilities.get_caret_context()[-1] or 0 + + restrict_to = None + if AXUtilities.is_text(obj) or AXUtilities.is_terminal(obj): + restrict_to = obj + + prev_obj, prev_offset = None, None + while obj: + if obj == prev_obj and offset == prev_offset: + obj, offset = self._script.utilities.next_context(obj, offset) + tokens = [ + "SAY ALL PRESENTER: Stuck at", + prev_obj, + f"offset {prev_offset}.", + "Moving to", + obj, + f"offset {offset}.", + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + continue + prev_obj, prev_offset = obj, offset + + if say_all_by_sentence: + contents = self._script.utilities.get_sentence_contents_at_offset(obj, offset) + else: + contents = self._script.utilities.get_line_contents_at_offset( + obj, + offset, + layout_mode=True, + use_cache=False, + ) + + contents = self._script.utilities.filter_contents_for_presentation(contents) + self._contents.extend(contents) + + if self.get_only_speak_displayed_text(): + if (result := self._build_displayed_text_context(contents)) is not None: + yield list(result) + else: + yield from self._generate_speech_contexts(contents, prior_obj) + + obj, offset = self._advance_to_next(obj, offset, contents, restrict_to) + + self.stop() + + def _rewind( + self, + context: speechserver.SayAllContext | None, + override_setting: bool = False, + ) -> bool: + if not (override_setting or self.get_rewind_and_fast_forward_enabled()): + return False + + if context is None: + context = self._current_context + + obj = None + try: + obj, start, _end, _string = self._contents[0] + except IndexError: + if context is not None: + obj, start = context.obj, context.start_offset + + if obj is None: + return False + + focus_manager.get_manager().set_locus_of_focus(None, obj, notify_script=False) + assert self._script is not None, "Script must be set before calling _rewind." + self._script.utilities.set_caret_context(obj, start) + + prev_obj, prev_offset = self._script.utilities.previous_context(obj, start, True) + self.say_all(self._script, obj=prev_obj, offset=prev_offset) + return True + + def _fast_forward( + self, + context: speechserver.SayAllContext | None, + override_setting: bool = False, + ) -> bool: + if not (override_setting or self.get_rewind_and_fast_forward_enabled()): + return False + + if context is None: + context = self._current_context + + obj = None + try: + obj, _start, end, _string = self._contents[-1] + except IndexError: + if context is not None: + obj, end = context.obj, context.end_offset + + if obj is None: + return False + + focus_manager.get_manager().set_locus_of_focus(None, obj, notify_script=False) + assert self._script is not None, "Script must be set before calling _fast_forward." + self._script.utilities.set_caret_context(obj, end) + + next_obj, next_offset = self._script.utilities.next_context(obj, end, True) + self.say_all(self._script, obj=next_obj, offset=next_offset) + return True + + def _progress_callback(self, context: speechserver.SayAllContext, progress_type: int) -> None: + self._current_context = context + self._say_all_is_running = True + + if progress_type == speechserver.SayAllContext.PROGRESS: + if AXUtilities.character_at_offset_is_eoc(context.obj, context.current_offset): + return + focus_manager.get_manager().emit_region_changed( + context.obj, + context.current_offset, + context.current_end_offset, + focus_manager.SAY_ALL, + ) + return + + assert self._script is not None, "Script must be set before calling _progress_callback." + + if progress_type == speechserver.SayAllContext.INTERRUPTED: + tokens = ["SAY ALL PROGRESS CALLBACK: Interrupted", context] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + manager = input_event_manager.get_manager() + if manager.last_event_was_keyboard(): + if manager.last_event_was_down() and self._fast_forward(context): + return + if manager.last_event_was_up() and self._rewind(context): + return + navigator = structural_navigator.get_navigator() + if ( + self.get_structural_navigation_enabled() + and navigator.last_input_event_was_navigation_command() + ): + return + presentation_manager.get_manager().interrupt_presentation() + AXText.set_caret_offset(context.obj, context.current_offset) + self._say_all_is_running = False + else: + tokens = ["SAY ALL PROGRESS CALLBACK: Completed", context] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + focus_manager.get_manager().set_locus_of_focus(None, context.obj, notify_script=False) + mode = focus_manager.SAY_ALL if self._say_all_is_running else focus_manager.FOCUS_TRACKING + focus_manager.get_manager().emit_region_changed( + context.obj, + context.current_offset, + None, + mode, + ) + self._script.utilities.set_caret_context(context.obj, context.current_offset) + + @gsettings_registry.get_registry().gsetting( + key=KEY_ANNOUNCE_BLOCKQUOTE, + schema="say-all", + gtype="b", + default=True, + summary="Announce blockquotes", + migration_key="sayAllContextBlockquote", + ) + @dbus_service.getter + def get_announce_blockquote(self) -> bool: + """Returns whether blockquotes are announced when entered.""" + + return self._get_setting(self.KEY_ANNOUNCE_BLOCKQUOTE, True) + + @dbus_service.setter + def set_announce_blockquote(self, value: bool) -> bool: + """Sets whether blockquotes are announced when entered.""" + + msg = f"SAY ALL PRESENTER: Setting announce blockquotes to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_ANNOUNCE_BLOCKQUOTE, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_ANNOUNCE_FORM, + schema="say-all", + gtype="b", + default=True, + summary="Announce non-landmark forms", + migration_key="sayAllContextNonLandmarkForm", + ) + @dbus_service.getter + def get_announce_form(self) -> bool: + """Returns whether non-landmark forms are announced when entered.""" + + return self._get_setting(self.KEY_ANNOUNCE_FORM, True) + + @dbus_service.setter + def set_announce_form(self, value: bool) -> bool: + """Sets whether non-landmark forms are announced when entered.""" + + msg = f"SAY ALL PRESENTER: Setting announce forms to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, self.KEY_ANNOUNCE_FORM, value + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_ANNOUNCE_GROUPING, + schema="say-all", + gtype="b", + default=True, + summary="Announce groupings", + migration_key="sayAllContextPanel", + ) + @dbus_service.getter + def get_announce_grouping(self) -> bool: + """Returns whether groupings are announced when entered.""" + + return self._get_setting(self.KEY_ANNOUNCE_GROUPING, True) + + @dbus_service.setter + def set_announce_grouping(self, value: bool) -> bool: + """Sets whether groupings are announced when entered.""" + + msg = f"SAY ALL PRESENTER: Setting announce groupings to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_ANNOUNCE_GROUPING, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_ANNOUNCE_LANDMARK, + schema="say-all", + gtype="b", + default=True, + summary="Announce landmarks", + migration_key="sayAllContextLandmark", + ) + @dbus_service.getter + def get_announce_landmark(self) -> bool: + """Returns whether landmarks are announced when entered.""" + + return self._get_setting(self.KEY_ANNOUNCE_LANDMARK, True) + + @dbus_service.setter + def set_announce_landmark(self, value: bool) -> bool: + """Sets whether landmarks are announced when entered.""" + + msg = f"SAY ALL PRESENTER: Setting announce landmarks to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_ANNOUNCE_LANDMARK, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_ANNOUNCE_LIST, + schema="say-all", + gtype="b", + default=True, + summary="Announce lists", + migration_key="sayAllContextList", + ) + @dbus_service.getter + def get_announce_list(self) -> bool: + """Returns whether lists are announced when entered.""" + + return self._get_setting(self.KEY_ANNOUNCE_LIST, True) + + @dbus_service.setter + def set_announce_list(self, value: bool) -> bool: + """Sets whether lists are announced when entered.""" + + msg = f"SAY ALL PRESENTER: Setting announce lists to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, self.KEY_ANNOUNCE_LIST, value + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_ANNOUNCE_TABLE, + schema="say-all", + gtype="b", + default=True, + summary="Announce tables", + migration_key="sayAllContextTable", + ) + @dbus_service.getter + def get_announce_table(self) -> bool: + """Returns whether tables are announced when entered.""" + + return self._get_setting(self.KEY_ANNOUNCE_TABLE, True) + + @dbus_service.setter + def set_announce_table(self, value: bool) -> bool: + """Sets whether tables are announced when entered.""" + + msg = f"SAY ALL PRESENTER: Setting announce tables to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, self.KEY_ANNOUNCE_TABLE, value + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_ONLY_SPEAK_DISPLAYED_TEXT, + schema="say-all", + gtype="b", + default=False, + summary="Only speak displayed text", + ) + @dbus_service.getter + def get_only_speak_displayed_text(self) -> bool: + """Returns whether Say All only speaks displayed text.""" + + return self._get_setting(self.KEY_ONLY_SPEAK_DISPLAYED_TEXT, False) + + @dbus_service.setter + def set_only_speak_displayed_text(self, value: bool) -> bool: + """Sets whether Say All only speaks displayed text.""" + + msg = f"SAY ALL PRESENTER: Setting only speak displayed text to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_ONLY_SPEAK_DISPLAYED_TEXT, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_STYLE, + schema="say-all", + genum="org.stormux.Cthulhu.SayAllStyle", + default="sentence", + summary="Say All style (line, sentence)", + migration_key="sayAllStyle", + ) + @dbus_service.getter + def get_style(self) -> str: + """Returns the current Say All style.""" + + return gsettings_registry.get_registry().layered_lookup( + self._SCHEMA, + self.KEY_STYLE, + "", + genum="org.stormux.Cthulhu.SayAllStyle", + default="sentence", + ) + + @dbus_service.setter + def set_style(self, value: str) -> bool: + """Sets the current Say All style.""" + + try: + style = SayAllStyle[value.upper()] + except KeyError: + msg = f"SAY ALL PRESENTER: Invalid style: {value}" + debug.print_message(debug.LEVEL_WARNING, msg, True) + return False + + msg = f"SAY ALL PRESENTER: Setting style to {value} ({style.value})." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_STYLE, + style.string_name, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_STRUCTURAL_NAVIGATION, + schema="say-all", + gtype="b", + default=False, + summary="Enable structural navigation in Say All", + migration_key="structNavInSayAll", + ) + @dbus_service.getter + def get_structural_navigation_enabled(self) -> bool: + """Returns whether structural navigation keys can be used in Say All.""" + + return self._get_setting(self.KEY_STRUCTURAL_NAVIGATION, False) + + @dbus_service.setter + def set_structural_navigation_enabled(self, value: bool) -> bool: + """Sets whether structural navigation keys can be used in Say All.""" + + msg = f"SAY ALL PRESENTER: Setting enable structural navigation to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_STRUCTURAL_NAVIGATION, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_REWIND_AND_FAST_FORWARD, + schema="say-all", + gtype="b", + default=False, + summary="Enable rewind and fast forward in Say All", + migration_key="rewindAndFastForwardInSayAll", + ) + @dbus_service.getter + def get_rewind_and_fast_forward_enabled(self) -> bool: + """Returns whether Up and Down can be used in Say All.""" + + return self._get_setting(self.KEY_REWIND_AND_FAST_FORWARD, False) + + @dbus_service.setter + def set_rewind_and_fast_forward_enabled(self, value: bool) -> bool: + """Returns whether Up and Down can be used in Say All.""" + + msg = f"SAY ALL PRESENTER: Setting enable rewind and fast forward to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_REWIND_AND_FAST_FORWARD, + value, + ) + return True + + +_presenter: SayAllPresenter = SayAllPresenter() + + +def get_presenter() -> SayAllPresenter: + """Returns the Say All Presenter""" + + return _presenter diff --git a/src/cthulhu/sound.py b/src/cthulhu/sound.py index ed3bb6b..edc517f 100644 --- a/src/cthulhu/sound.py +++ b/src/cthulhu/sound.py @@ -43,7 +43,6 @@ from . import debug from . import gstreamer_support from . import settings from . import sound_sink -from .sound_generator import Icon, Tone try: gi.require_version('Gst', '1.0') @@ -56,6 +55,78 @@ else: _soundSystemFailureReason: Optional[str] = None +def _get_configured_volume() -> float: + """Returns the configured sound volume with a safe fallback.""" + + try: + from . import cthulhu + if cthulhu.cthulhuApp is not None: + volume = cthulhu.cthulhuApp.settingsManager.getSetting('soundVolume') + return max(0.0, float(volume)) + except Exception: + pass + + return max(0.0, float(settings.soundVolume)) + + +class Icon: + """Sound file representing a particular aspect of an object.""" + + def __init__(self, location: str, filename: str) -> None: + self.path = os.path.join(location, filename) + + def __str__(self) -> str: + return f'Icon(path: {self.path}, isValid: {self.isValid()})' + + def isValid(self) -> bool: # pylint: disable=invalid-name + """Returns True if the path associated with this icon is valid.""" + + return os.path.isfile(self.path) + + def is_valid(self) -> bool: + """Returns True if the path associated with this icon is valid.""" + + return self.isValid() + + +class Tone: + """Tone representing a particular aspect of an object.""" + + SINE_WAVE = 0 + SQUARE_WAVE = 1 + SAW_WAVE = 2 + TRIANGLE_WAVE = 3 + SILENCE = 4 + WHITE_UNIFORM_NOISE = 5 + PINK_NOISE = 6 + SINE_WAVE_USING_TABLE = 7 + PERIODIC_TICKS = 8 + WHITE_GAUSSIAN_NOISE = 9 + RED_NOISE = 10 + INVERTED_PINK_NOISE = 11 + INVERTED_RED_NOISE = 12 + + def __init__( + self, + duration: float, + frequency: int, + volumeMultiplier: float = 1, + wave: int = SINE_WAVE, + ) -> None: + self.duration = duration + self.frequency = min(max(0, frequency), 20000) + self.volume = _get_configured_volume() * volumeMultiplier + self.wave = wave + + def __str__(self) -> str: + return ( + f'Tone(duration: {self.duration}, ' + f'frequency: {self.frequency}, ' + f'volume: {self.volume}, ' + f'wave: {self.wave})' + ) + + class _PendingResponse: def __init__(self) -> None: self.event = threading.Event() @@ -84,15 +155,7 @@ class Player: def _get_configured_volume() -> float: """Returns the configured sound volume with a safe fallback.""" - try: - from . import cthulhu - if cthulhu.cthulhuApp is not None: - volume = cthulhu.cthulhuApp.settingsManager.getSetting('soundVolume') - return max(0.0, float(volume)) - except Exception: - pass - - return max(0.0, float(settings.soundVolume)) + return _get_configured_volume() def init(self) -> None: """(Re)Initializes the persistent worker.""" @@ -564,6 +627,12 @@ def getPlayer() -> Player: return _player +def get_player() -> Player: + """Returns the Player singleton.""" + + return getPlayer() + + def play(item: Any, interrupt: bool = True) -> None: _player.play(item, interrupt) diff --git a/src/cthulhu/sound_generator.py b/src/cthulhu/sound_generator.py index 8bcd115..892b1e6 100644 --- a/src/cthulhu/sound_generator.py +++ b/src/cthulhu/sound_generator.py @@ -43,51 +43,13 @@ from . import role_keys from . import settings_manager from .ax_object import AXObject from .ax_utilities import AXUtilities +from .sound import Icon, Tone _settingsManager = None # Removed - use cthulhu.cthulhuApp.settingsManager METHOD_PREFIX = "_generate" -class Icon: - """Sound file representing a particular aspect of an object.""" - - def __init__(self, location, filename): - self.path = os.path.join(location, filename) - - def __str__(self): - return f'Icon(path: {self.path}, isValid: {self.isValid()})' - - def isValid(self): - return os.path.isfile(self.path) - -class Tone: - """Tone representing a particular aspect of an object.""" - - SINE_WAVE = 0 - SQUARE_WAVE = 1 - SAW_WAVE = 2 - TRIANGLE_WAVE = 3 - SILENCE = 4 - WHITE_UNIFORM_NOISE = 5 - PINK_NOISE = 6 - SINE_WAVE_USING_TABLE = 7 - PERIODIC_TICKS = 8 - WHITE_GAUSSIAN_NOISE = 9 - RED_NOISE = 10 - INVERTED_PINK_NOISE = 11 - INVERTED_RED_NOISE = 12 - - def __init__(self, duration, frequency, volumeMultiplier=1, wave=SINE_WAVE): - self.duration = duration - self.frequency = min(max(0, frequency), 20000) - self.volume = cthulhu.cthulhuApp.settingsManager.getSetting('soundVolume') * volumeMultiplier - self.wave = wave - - def __str__(self): - return 'Tone(duration: %s, frequency: %s, volume: %s, wave: %s)' \ - % (self.duration, self.frequency, self.volume, self.wave) - class SoundGenerator(generator.Generator): """Takes accessible objects and produces the sound(s) to be played.""" diff --git a/src/cthulhu/sound_presenter.py b/src/cthulhu/sound_presenter.py new file mode 100644 index 0000000..2d1ba15 --- /dev/null +++ b/src/cthulhu/sound_presenter.py @@ -0,0 +1,490 @@ +# Cthulhu +# +# 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 + +"""Provides sound presentation support.""" + +from __future__ import annotations + +import time +from enum import Enum +from typing import TYPE_CHECKING, Any + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +from . import dbus_service, debug, gsettings_registry, guilabels, preferences_grid_base, sound + +if TYPE_CHECKING: + from .sound import Icon, Tone + + +@gsettings_registry.get_registry().gsettings_enum( + "org.stormux.Cthulhu.ProgressBarVerbosity", + values={"all": 0, "application": 1, "window": 2}, +) +class ProgressBarVerbosity(Enum): + """Progress bar verbosity level enumeration.""" + + ALL = 0 + APPLICATION = 1 + WINDOW = 2 + + @property + def string_name(self) -> str: + """Returns the lowercase string name for this enum value.""" + + return self.name.lower() + + +class SoundProgressBarsPreferencesGrid(preferences_grid_base.AutoPreferencesGrid): + """GtkGrid containing the Sound Progress Bars preferences page.""" + + def __init__(self, presenter: SoundPresenter) -> None: + controls: list[preferences_grid_base.ControlType] = [ + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.GENERAL_BEEP_UPDATES, + getter=presenter.get_beep_progress_bar_updates, + setter=presenter.set_beep_progress_bar_updates, + prefs_key=SoundPresenter.KEY_BEEP_PROGRESS_BAR_UPDATES, + ), + preferences_grid_base.IntRangePreferenceControl( + label=guilabels.GENERAL_FREQUENCY_SECS, + getter=presenter.get_progress_bar_beep_interval, + setter=presenter.set_progress_bar_beep_interval, + prefs_key=SoundPresenter.KEY_PROGRESS_BAR_BEEP_INTERVAL, + minimum=0, + maximum=100, + ), + preferences_grid_base.EnumPreferenceControl( + label=guilabels.GENERAL_APPLIES_TO, + getter=presenter.get_progress_bar_beep_verbosity, + setter=presenter.set_progress_bar_beep_verbosity, + prefs_key=SoundPresenter.KEY_PROGRESS_BAR_BEEP_VERBOSITY, + options=[ + guilabels.PROGRESS_BAR_ALL, + guilabels.PROGRESS_BAR_APPLICATION, + guilabels.PROGRESS_BAR_WINDOW, + ], + values=[ + ProgressBarVerbosity.ALL.value, + ProgressBarVerbosity.APPLICATION.value, + ProgressBarVerbosity.WINDOW.value, + ], + ), + ] + + super().__init__(guilabels.PROGRESS_BARS, controls) + + +class SoundPreferencesGrid(preferences_grid_base.PreferencesGridBase): + """GtkGrid containing the Sound preferences page with nested stack navigation.""" + + def __init__( + self, + presenter: SoundPresenter, + title_change_callback: preferences_grid_base.Callable[[str], None] | None = None, + ) -> None: + super().__init__(guilabels.SOUND) + self._presenter = presenter + self._initializing = True + self._title_change_callback = title_change_callback + + self._progress_bars_grid = SoundProgressBarsPreferencesGrid(presenter) + self._volume_scale: Gtk.Scale | None = None + self._volume_listbox: preferences_grid_base.FocusManagedListBox | None = None + + self._build() + self._initializing = False + + def _build(self) -> None: + """Build the nested stack UI.""" + + row = 0 + + categories = [ + (guilabels.PROGRESS_BARS, "progress-bars", self._progress_bars_grid), + ] + + enable_listbox, stack, _categories_listbox = self._create_multi_page_stack( + enable_label=guilabels.SOUND_ENABLE_SOUND_SUPPORT, + enable_getter=self._presenter.get_sound_is_enabled, + enable_setter=self._presenter.set_sound_is_enabled, + categories=categories, + title_change_callback=self._title_change_callback, + main_title=guilabels.SOUND, + ) + + self.attach(enable_listbox, 0, row, 1, 1) + row += 1 + + # Volume slider on the main page + self._volume_listbox = preferences_grid_base.FocusManagedListBox() + + volume_adj = Gtk.Adjustment( + value=self._presenter.get_sound_volume(), + lower=0.0, + upper=1.0, + step_increment=0.1, + page_increment=0.1, + ) + volume_row, self._volume_scale, _volume_label = self._create_slider_row( + guilabels.SOUND_VOLUME, + volume_adj, + changed_handler=self._on_volume_changed, + include_top_separator=False, + digits=1, + ) + self._volume_listbox.add_row_with_widget(volume_row, self._volume_scale) + self._volume_listbox.set_sensitive(self._presenter.get_sound_is_enabled()) + + self.attach(self._volume_listbox, 0, row, 1, 1) + row += 1 + + self.attach(stack, 0, row, 1, 1) + + def _on_volume_changed(self, scale: Gtk.Scale) -> None: + """Handle volume slider change.""" + + if self._initializing: + return + value = scale.get_value() + self._presenter.set_sound_volume(value) + self._has_unsaved_changes = True + + def _on_multipage_enable_toggled( + self, + switch: Gtk.Switch, + setter: preferences_grid_base.Callable[[bool], preferences_grid_base.Any], + ) -> None: + """Handle enable switch toggle - also controls volume slider sensitivity.""" + + super()._on_multipage_enable_toggled(switch, setter) + if self._volume_listbox is not None: + self._volume_listbox.set_sensitive(switch.get_active()) + + def _on_multipage_category_activated(self, listbox: Gtk.ListBox, row: Gtk.ListBoxRow) -> None: + """Handle category activation - also hide volume slider.""" + + super()._on_multipage_category_activated(listbox, row) + if self._volume_listbox is not None: + self._volume_listbox.hide() + + def multipage_show_categories(self) -> None: + """Switch back to categories view - also show volume slider.""" + + super().multipage_show_categories() + if self._volume_listbox is not None: + self._volume_listbox.show() + + def on_becoming_visible(self) -> None: + """Reset to the categories view when this grid becomes visible.""" + + self.multipage_on_becoming_visible() + + def reload(self) -> None: + """Fetch fresh values and update UI.""" + + self._initializing = True + self._has_unsaved_changes = False + + enabled = self._presenter.get_sound_is_enabled() + if self._volume_listbox is not None: + self._volume_listbox.set_sensitive(enabled) + if self._volume_scale is not None: + self._volume_scale.set_value(self._presenter.get_sound_volume()) + self._progress_bars_grid.reload() + + self._initializing = False + + def save_settings(self, profile: str = "", app_name: str = "") -> dict: + """Persist staged values.""" + + result: dict[str, Any] = {} + result["enabled"] = self._presenter.get_sound_is_enabled() + result["volume"] = self._presenter.get_sound_volume() + result.update(self._progress_bars_grid.save_settings()) + + if profile: + skip = not app_name and profile == "default" + gsettings_registry.get_registry().save_schema("sound", result, profile, app_name, skip) + + return result + + def refresh(self) -> None: + """Update widgets from staged values.""" + + self._initializing = True + if self._volume_scale is not None: + self._volume_scale.set_value(self._presenter.get_sound_volume()) + self._progress_bars_grid.refresh() + self._initializing = False + + def has_changes(self) -> bool: + """Return True if any child grid has unsaved changes.""" + + return self._progress_bars_grid.has_changes() or self._has_unsaved_changes + + +@gsettings_registry.get_registry().gsettings_schema("org.stormux.Cthulhu.Sound", name="sound") +class SoundPresenter: + """Provides sound presentation support.""" + + _SCHEMA = "sound" + KEY_ENABLED = "enabled" + KEY_VOLUME = "volume" + KEY_BEEP_PROGRESS_BAR_UPDATES = "beep-progress-bar-updates" + KEY_PROGRESS_BAR_BEEP_INTERVAL = "progress-bar-beep-interval" + KEY_PROGRESS_BAR_BEEP_VERBOSITY = "progress-bar-beep-verbosity" + + def _get_setting(self, key: str, gtype: str, default: Any) -> Any: + """Returns the dconf value for key, or default if not in dconf.""" + + return gsettings_registry.get_registry().layered_lookup( + self._SCHEMA, + key, + gtype, + default=default, + ) + + def __init__(self) -> None: + msg = "SOUND PRESENTER: Registering D-Bus commands." + debug.print_message(debug.LEVEL_INFO, msg, True) + controller = dbus_service.get_remote_controller() + controller.register_decorated_module("SoundPresenter", self) + self._progress_bar_cache: dict = {} + + def create_preferences_grid( + self, + title_change_callback: preferences_grid_base.Callable[[str], None] | None = None, + ) -> SoundPreferencesGrid: + """Returns the GtkGrid containing the preferences UI.""" + + return SoundPreferencesGrid(self, title_change_callback) + + @gsettings_registry.get_registry().gsetting( + key=KEY_ENABLED, + schema="sound", + gtype="b", + default=True, + summary="Enable sound output", + migration_key="enableSound", + ) + @dbus_service.getter + def get_sound_is_enabled(self) -> bool: + """Returns whether sound is enabled.""" + + return self._get_setting(self.KEY_ENABLED, "b", True) + + @dbus_service.setter + def set_sound_is_enabled(self, value: bool) -> bool: + """Sets whether sound is enabled.""" + + msg = f"SOUND PRESENTER: Setting enable sound to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value(self._SCHEMA, self.KEY_ENABLED, value) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_VOLUME, + schema="sound", + gtype="d", + default=0.5, + summary="Sound volume (0.0-1.0)", + migration_key="soundVolume", + ) + @dbus_service.getter + def get_sound_volume(self) -> float: + """Returns the sound volume (0.0 to 1.0).""" + + return self._get_setting(self.KEY_VOLUME, "d", 0.5) + + @dbus_service.setter + def set_sound_volume(self, value: float) -> bool: + """Sets the sound volume (0.0 to 1.0).""" + + msg = f"SOUND PRESENTER: Setting sound volume to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value(self._SCHEMA, self.KEY_VOLUME, value) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_BEEP_PROGRESS_BAR_UPDATES, + schema="sound", + gtype="b", + default=False, + summary="Beep progress bar updates", + migration_key="beepProgressBarUpdates", + ) + @dbus_service.getter + def get_beep_progress_bar_updates(self) -> bool: + """Returns whether beep progress bar updates are enabled.""" + + return self._get_setting(self.KEY_BEEP_PROGRESS_BAR_UPDATES, "b", False) + + @dbus_service.setter + def set_beep_progress_bar_updates(self, value: bool) -> bool: + """Sets whether beep progress bar updates are enabled.""" + + msg = f"SOUND PRESENTER: Setting beep progress bar updates to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_BEEP_PROGRESS_BAR_UPDATES, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_PROGRESS_BAR_BEEP_INTERVAL, + schema="sound", + gtype="i", + default=0, + summary="Progress bar beep interval in seconds", + migration_key="progressBarBeepInterval", + ) + @dbus_service.getter + def get_progress_bar_beep_interval(self) -> int: + """Returns the beep progress bar update interval in seconds.""" + + return self._get_setting(self.KEY_PROGRESS_BAR_BEEP_INTERVAL, "i", 0) + + @dbus_service.setter + def set_progress_bar_beep_interval(self, value: int) -> bool: + """Sets the beep progress bar update interval in seconds.""" + + msg = f"SOUND PRESENTER: Setting progress bar beep interval to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_PROGRESS_BAR_BEEP_INTERVAL, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_PROGRESS_BAR_BEEP_VERBOSITY, + schema="sound", + genum="org.stormux.Cthulhu.ProgressBarVerbosity", + default="application", + summary="Progress bar beep verbosity (all, application, window)", + migration_key="progressBarBeepVerbosity", + ) + @dbus_service.getter + def get_progress_bar_beep_verbosity(self) -> int: + """Returns the beep progress bar verbosity level.""" + + nick = gsettings_registry.get_registry().layered_lookup( + self._SCHEMA, + self.KEY_PROGRESS_BAR_BEEP_VERBOSITY, + "", + genum="org.stormux.Cthulhu.ProgressBarVerbosity", + default="application", + ) + return ProgressBarVerbosity[nick.upper()].value + + @dbus_service.setter + def set_progress_bar_beep_verbosity(self, value: int) -> bool: + """Sets the beep progress bar verbosity level.""" + + msg = f"SOUND PRESENTER: Setting progress bar beep verbosity to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + level = ProgressBarVerbosity(value) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_PROGRESS_BAR_BEEP_VERBOSITY, + level.name.lower(), + ) + return True + + def should_present_progress_bar_update( + self, + obj: object, + percent: int | None, + is_same_app: bool, + is_same_window: bool, + ) -> bool: + """Returns True if the progress bar update should be beeped.""" + + if not self.get_beep_progress_bar_updates(): + return False + + last_time, last_value = self._progress_bar_cache.get(id(obj), (0.0, None)) + if percent == last_value: + return False + + if percent != 100: + interval = int(time.time() - last_time) + if interval < self.get_progress_bar_beep_interval(): + return False + + verbosity = self.get_progress_bar_beep_verbosity() + if verbosity == ProgressBarVerbosity.ALL.value: + present = True + elif verbosity == ProgressBarVerbosity.APPLICATION.value: + present = is_same_app + elif verbosity == ProgressBarVerbosity.WINDOW.value: + present = is_same_window + else: + present = True + + if present: + self._progress_bar_cache[id(obj)] = (time.time(), percent) + + return present + + def play(self, sounds: list[Icon | Tone] | Icon | Tone, interrupt: bool = True) -> None: + """Plays the specified sound(s).""" + + if not sounds: + return + + if not isinstance(sounds, list): + sounds = [sounds] + + player = sound.get_player() + player.play(sounds[0], interrupt) + for i in range(1, len(sounds)): + player.play(sounds[i], interrupt=False) + + def init_sound(self) -> None: + """Initializes sound if enabled.""" + + if not self.get_sound_is_enabled(): + return + + sound.get_player().init() + + def shutdown_sound(self) -> None: + """Shuts down sound.""" + + sound.get_player().shutdown() + + +_presenter: SoundPresenter = SoundPresenter() + + +def get_presenter() -> SoundPresenter: + """Returns the Sound Presenter""" + + return _presenter diff --git a/src/cthulhu/speech.py b/src/cthulhu/speech.py index 7d18353..e874645 100644 --- a/src/cthulhu/speech.py +++ b/src/cthulhu/speech.py @@ -352,6 +352,19 @@ def __resolveACSS(acss: Optional[Any] = None) -> ACSS: voices = settings.voices return ACSS(voices[settings.DEFAULT_VOICE]) + +def get_mute_speech() -> bool: + """Returns whether speech output is temporarily muted.""" + + return settings.silenceSpeech + + +def set_mute_speech(mute: bool) -> None: + """Sets whether speech output should be temporarily muted.""" + + settings.silenceSpeech = mute + + def sayAll(utteranceIterator: Any, progressCallback: Any) -> None: _ensureLogger() if settings.silenceSpeech: @@ -381,6 +394,13 @@ def sayAll(utteranceIterator: Any, progressCallback: Any) -> None: except Exception: debug.printException(debug.LEVEL_INFO) + +def say_all(utterance_iterator: Any, progress_callback: Any) -> None: + """Snake_case adapter for Orca 50 say-all callers.""" + + sayAll(utterance_iterator, progress_callback) + + def _speak(text: str, acss: Optional[Any], interrupt: bool) -> None: """Speaks the individual string using the given ACSS.""" @@ -596,6 +616,13 @@ def speakEchoKeyEvent(event: Any, acss: Optional[Any] = None) -> None: if server: server.speakKeyEvent(event, acss) # type: ignore + +def speak_key_event(event: Any, acss: Optional[Any] = None) -> None: + """Snake_case adapter for Orca 50 key-event speech callers.""" + + speakKeyEvent(event, acss) + + def speakCharacter(character: str, acss: Optional[Any] = None) -> None: """Speaks a single character immediately. @@ -630,6 +657,18 @@ def speakCharacter(character: str, acss: Optional[Any] = None) -> None: if _speechserver: _speechserver.speakCharacter(character, acss=acss) # type: ignore + +def speak_character( + character: str, + acss: Optional[Any] = None, + cap_style: Any = None, +) -> None: + """Snake_case adapter for Orca 50 character speech callers.""" + + del cap_style + speakCharacter(character, acss) + + def speakEchoCharacter(character: str, acss: Optional[Any] = None) -> None: """Speaks a single character for key/typing echo using echo speech settings.""" diff --git a/src/cthulhu/speech_generator.py b/src/cthulhu/speech_generator.py index 3c048e7..3a1557f 100644 --- a/src/cthulhu/speech_generator.py +++ b/src/cthulhu/speech_generator.py @@ -31,7 +31,10 @@ __date__ = "$Date:$" __copyright__ = "Copyright (c) 2005-2009 Sun Microsystems Inc." __license__ = "LGPL" +from dataclasses import dataclass import functools +from typing import Any + import gi gi.require_version("Atspi", "2.0") from gi.repository import Atspi @@ -60,6 +63,37 @@ from .ax_text import AXText from .ax_utilities import AXUtilities from .ax_utilities_relation import AXUtilitiesRelation + +@dataclass(frozen=True) +class SpeechGeneratorContext: + """Settings context for Orca 50 speech generators.""" + + enabled: bool + verbose: bool + focus: Any + in_say_all: bool + in_focus_mode: bool + active_mode: str | None + where_am_i_type: Any + in_preferences_window: bool + only_displayed_text: bool + speak_description: bool + speak_tutorial_messages: bool + speak_position_in_set: bool + speak_widget_mnemonic: bool + speak_blank_lines: bool + speak_indentation: bool + announce_cell_headers: bool + announce_cell_coordinates: bool + announce_spreadsheet_cell_coordinates: bool + announce_blockquote: bool + announce_form: bool + announce_landmark: bool + announce_list: bool + announce_grouping: bool + announce_table: bool + + class Pause: """A dummy class to indicate we want to insert a pause into an utterance.""" diff --git a/src/cthulhu/speech_manager.py b/src/cthulhu/speech_manager.py new file mode 100644 index 0000000..21e6114 --- /dev/null +++ b/src/cthulhu/speech_manager.py @@ -0,0 +1,2882 @@ +# Cthulhu +# +# Copyright 2005-2008 Sun Microsystems Inc. +# Copyright 2011-2026 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-arguments +# pylint: disable=too-many-positional-arguments +# pylint: disable=too-many-statements +# pylint: disable=too-many-locals +# pylint: disable=too-many-lines + +"""Manages the speech engine: server, synthesizer, voice, and output parameters.""" + +from __future__ import annotations + +import importlib +import locale +import queue +import threading +from enum import Enum +from typing import TYPE_CHECKING, Any + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import GObject, Gtk + +from . import ( + cmdnames, + command_manager, + dbus_service, + debug, + gsettings_registry, + guilabels, + input_event, + keybindings, + messages, + preferences_grid_base, + presentation_manager, + speech, + speechserver, +) +from .acss import ACSS +from .speechserver import CapitalizationStyle, PunctuationStyle + +if TYPE_CHECKING: + from .scripts import default + from .speechserver import SpeechServer + +SPEECH_FACTORY_MODULES: list[str] = ["speechdispatcherfactory", "spiel"] + + +# pylint: disable-next=too-many-instance-attributes +class VoicesPreferencesGrid(preferences_grid_base.PreferencesGridBase): + """GtkGrid containing the Voice settings page.""" + + _VOICE_SCHEMA = "voice" + + class VoiceType(Enum): + """Voice type enumeration for voice settings.""" + + DEFAULT = 0 + UPPERCASE = 1 + HYPERLINK = 2 + SYSTEM = 3 + + def __init__(self, manager: SpeechManager, app_name: str = "") -> None: + super().__init__(guilabels.SPEECH) + self._manager = manager + self._app_name: str = app_name + self._initializing = True + + self._default_voice = manager.get_voice_properties( + speechserver.DEFAULT_VOICE, + app_name=self._app_name, + ) + self._uppercase_voice = manager.get_voice_properties( + speechserver.UPPERCASE_VOICE, + app_name=self._app_name, + ) + self._hyperlink_voice = manager.get_voice_properties( + speechserver.HYPERLINK_VOICE, + app_name=self._app_name, + ) + self._system_voice = manager.get_voice_properties( + speechserver.SYSTEM_VOICE, + app_name=self._app_name, + ) + + # All voice family dicts from server + self._voice_families: list[speechserver.VoiceFamily] = [] + # Filtered families for each voice type + self._default_family_choices: list[speechserver.VoiceFamily] = [] + self._hyperlink_family_choices: list[speechserver.VoiceFamily] = [] + self._uppercase_family_choices: list[speechserver.VoiceFamily] = [] + self._system_family_choices: list[speechserver.VoiceFamily] = [] + + self._speech_systems_combo: Gtk.ComboBox + self._speech_synthesizers_combo: Gtk.ComboBox + self._punctuation_combo: Gtk.ComboBox + self._capitalization_combo: Gtk.ComboBox + self._global_frame: Gtk.Frame | None = None + self._voice_types_frame: Gtk.Frame | None = None + + # Default voice widgets (created on-demand in dialogs) + self._default_languages_combo: Gtk.ComboBox | None = None + self._default_families_combo: Gtk.ComboBox | None = None + self._default_rate_scale: Gtk.Scale | None = None + self._default_pitch_scale: Gtk.Scale | None = None + self._default_volume_scale: Gtk.Scale | None = None + + # Hyperlink voice widgets (created on-demand in dialogs) + self._hyperlink_languages_combo: Gtk.ComboBox | None = None + self._hyperlink_families_combo: Gtk.ComboBox | None = None + self._hyperlink_rate_scale: Gtk.Scale | None = None + self._hyperlink_pitch_scale: Gtk.Scale | None = None + self._hyperlink_volume_scale: Gtk.Scale | None = None + + # Uppercase voice widgets (created on-demand in dialogs) + self._uppercase_languages_combo: Gtk.ComboBox | None = None + self._uppercase_families_combo: Gtk.ComboBox | None = None + self._uppercase_rate_scale: Gtk.Scale | None = None + self._uppercase_pitch_scale: Gtk.Scale | None = None + self._uppercase_volume_scale: Gtk.Scale | None = None + + # System voice widgets (created on-demand in dialogs) + self._system_languages_combo: Gtk.ComboBox | None = None + self._system_families_combo: Gtk.ComboBox | None = None + self._system_rate_scale: Gtk.Scale | None = None + self._system_pitch_scale: Gtk.Scale | None = None + self._system_volume_scale: Gtk.Scale | None = None + + self._families_sorted: bool = False + + self._build() + self._populate_speech_systems() + self.refresh() + + def _build(self) -> None: + """Create the Gtk widgets composing the grid.""" + + row = 0 + + self._global_frame, global_content = self._create_frame( + guilabels.VOICE_GLOBAL_VOICE_SETTINGS, + margin_top=12, + ) + + punctuation_model = Gtk.ListStore(GObject.TYPE_STRING, GObject.TYPE_INT) + punctuation_model.append([guilabels.PUNCTUATION_STYLE_NONE, PunctuationStyle.NONE.value]) + punctuation_model.append([guilabels.PUNCTUATION_STYLE_SOME, PunctuationStyle.SOME.value]) + punctuation_model.append([guilabels.PUNCTUATION_STYLE_MOST, PunctuationStyle.MOST.value]) + punctuation_model.append([guilabels.PUNCTUATION_STYLE_ALL, PunctuationStyle.ALL.value]) + + capitalization_model = Gtk.ListStore(GObject.TYPE_STRING, GObject.TYPE_STRING) + capitalization_model.append( + [guilabels.CAPITALIZATION_STYLE_NONE, CapitalizationStyle.NONE.value], + ) + capitalization_model.append( + [guilabels.CAPITALIZATION_STYLE_ICON, CapitalizationStyle.ICON.value], + ) + capitalization_model.append( + [guilabels.CAPITALIZATION_STYLE_SPELL, CapitalizationStyle.SPELL.value], + ) + + global_listbox = preferences_grid_base.FocusManagedListBox() + combo_size_group = Gtk.SizeGroup(mode=Gtk.SizeGroupMode.HORIZONTAL) + + row_data = [ + ( + guilabels.VOICE_SPEECH_SYSTEM, + Gtk.ListStore(GObject.TYPE_STRING), + self._on_speech_system_changed, + ), + ( + guilabels.VOICE_SPEECH_SYNTHESIZER, + Gtk.ListStore(GObject.TYPE_STRING), + self._on_speech_synthesizer_changed, + ), + (guilabels.PUNCTUATION_STYLE, punctuation_model, self._on_punctuation_changed), + ( + guilabels.VOICE_CAPITALIZATION_STYLE, + capitalization_model, + self._on_capitalization_changed, + ), + ] + + global_combos = [] + for label_text, model, changed_handler in row_data: + row_widget, combo, _label = self._create_combo_box_row( + label_text, + model, + changed_handler, + include_top_separator=False, + ) + combo_size_group.add_widget(combo) + global_listbox.add_row_with_widget(row_widget, combo) + global_combos.append(combo) + + self._speech_systems_combo = global_combos[0] + self._speech_synthesizers_combo = global_combos[1] + self._punctuation_combo = global_combos[2] + self._capitalization_combo = global_combos[3] + + switch_data = [ + ( + guilabels.VOICE_SPEAK_NUMBERS_AS_DIGITS, + self._on_speak_numbers_toggled, + self._manager.get_speak_numbers_as_digits(), + ), + ( + guilabels.SPEECH_SPEAK_COLORS_AS_NAMES, + self._on_use_color_names_toggled, + self._manager.get_use_color_names(), + ), + ( + guilabels.SPEECH_BREAK_INTO_CHUNKS, + self._on_enable_pause_breaks_toggled, + self._manager.get_insert_pauses_between_utterances(), + ), + ( + guilabels.SPEECH_USE_PRONUNCIATION_DICTIONARY, + self._on_use_pronunciation_dict_toggled, + self._manager.get_use_pronunciation_dictionary(), + ), + ( + guilabels.AUTO_LANGUAGE_SWITCHING, + self._on_auto_language_switching_toggled, + self._manager.get_auto_language_switching(), + ), + ] + + switches = [] + for label_text, handler, state in switch_data: + row_widget, switch, _label = self._create_switch_row( + label_text, + handler, + state, + include_top_separator=False, + ) + global_listbox.add_row_with_widget(row_widget, switch) + switches.append(switch) + + self._speak_numbers_switch = switches[0] + self._use_color_names_switch = switches[1] + self._enable_pause_breaks_switch = switches[2] + self._use_pronunciation_dict_switch = switches[3] + self._auto_language_switching_switch = switches[4] + + global_content.add(global_listbox) # pylint: disable=no-member + self.attach(self._global_frame, 0, row, 1, 1) + row += 1 + + self._voice_types_frame, voice_types_content = self._create_frame( + guilabels.VOICE_VOICE_TYPE_SETTINGS, + margin_top=12, + ) + + voice_types_listbox, voice_buttons = self._create_button_listbox( + [ + ( + guilabels.SPEECH_VOICE_TYPE_DEFAULT, + "applications-system-symbolic", + lambda _btn: self._show_voice_settings_dialog(self.VoiceType.DEFAULT), + ), + ( + guilabels.SPEECH_VOICE_TYPE_HYPERLINK, + "applications-system-symbolic", + lambda _btn: self._show_voice_settings_dialog(self.VoiceType.HYPERLINK), + ), + ( + guilabels.SPEECH_VOICE_TYPE_UPPERCASE, + "applications-system-symbolic", + lambda _btn: self._show_voice_settings_dialog(self.VoiceType.UPPERCASE), + ), + ( + guilabels.SPEECH_VOICE_TYPE_SYSTEM, + "applications-system-symbolic", + lambda _btn: self._show_voice_settings_dialog(self.VoiceType.SYSTEM), + ), + ], + ) + + voice_type_labels = [ + guilabels.SPEECH_VOICE_TYPE_DEFAULT, + guilabels.SPEECH_VOICE_TYPE_HYPERLINK, + guilabels.SPEECH_VOICE_TYPE_UPPERCASE, + guilabels.SPEECH_VOICE_TYPE_SYSTEM, + ] + for button, voice_label in zip(voice_buttons, voice_type_labels, strict=True): + accessible_name = guilabels.VOICE_TYPE_SETTINGS % voice_label + button.set_tooltip_text(accessible_name) + accessible = button.get_accessible() + if accessible: + accessible.set_name(accessible_name) + + voice_types_content.add(voice_types_listbox) # pylint: disable=no-member + self.attach(self._voice_types_frame, 0, row, 1, 1) + + self.show_all() # pylint: disable=no-member + + def _show_voice_settings_dialog(self, voice_type: VoicesPreferencesGrid.VoiceType) -> None: + """Show a dialog for editing settings for a specific voice type.""" + + voice_type_labels = { + self.VoiceType.DEFAULT: guilabels.SPEECH_VOICE_TYPE_DEFAULT, + self.VoiceType.HYPERLINK: guilabels.SPEECH_VOICE_TYPE_HYPERLINK, + self.VoiceType.UPPERCASE: guilabels.SPEECH_VOICE_TYPE_UPPERCASE, + self.VoiceType.SYSTEM: guilabels.SPEECH_VOICE_TYPE_SYSTEM, + } + title = voice_type_labels.get(voice_type, "Voice Settings") + + # Save current ACSS state in case user cancels + voice_acss = self._get_acss_for_voice_type(voice_type) + saved_acss = ACSS(dict(voice_acss)) + + dialog, ok_button = self._create_header_bar_dialog( + title, + guilabels.BTN_CANCEL, + guilabels.BTN_OK, + ) + + content_area = dialog.get_content_area() + + voice_listbox = preferences_grid_base.FocusManagedListBox() + combo_size_group = Gtk.SizeGroup(mode=Gtk.SizeGroupMode.HORIZONTAL) + + def on_language_changed(widget: Gtk.ComboBox) -> None: + self._on_speech_language_changed(widget, voice_type) + + lang_row, lang_combo, _lang_label = self._create_combo_box_row( + guilabels.VOICE_LANGUAGE, + Gtk.ListStore(GObject.TYPE_STRING), + on_language_changed, + include_top_separator=False, + ) + combo_size_group.add_widget(lang_combo) + voice_listbox.add_row_with_widget(lang_row, lang_combo) + + def on_family_changed(widget: Gtk.ComboBox) -> None: + self._on_speech_family_changed(widget, voice_type) + + person_row, person_combo, _person_label = self._create_combo_box_row( + guilabels.VOICE_PERSON, + Gtk.ListStore(GObject.TYPE_STRING), + on_family_changed, + include_top_separator=False, + ) + combo_size_group.add_widget(person_combo) + voice_listbox.add_row_with_widget(person_row, person_combo) + + def on_rate_changed(widget: Gtk.Scale) -> None: + self._on_rate_changed(widget, voice_type) + + rate_adj = Gtk.Adjustment(value=50, lower=0, upper=100, step_increment=1, page_increment=10) + rate_row, rate_scale, _rate_label = self._create_slider_row( + guilabels.VOICE_RATE, + rate_adj, + changed_handler=on_rate_changed, + include_top_separator=False, + ) + voice_listbox.add_row_with_widget(rate_row, rate_scale) + + def on_pitch_changed(widget: Gtk.Scale) -> None: + self._on_pitch_changed(widget, voice_type) + + pitch_adj = Gtk.Adjustment( + value=5.0, + lower=0, + upper=10, + step_increment=0.1, + page_increment=1, + ) + pitch_row, pitch_scale, _pitch_label = self._create_slider_row( + guilabels.VOICE_PITCH, + pitch_adj, + changed_handler=on_pitch_changed, + include_top_separator=False, + digits=1, + ) + voice_listbox.add_row_with_widget(pitch_row, pitch_scale) + + def on_volume_changed(widget: Gtk.Scale) -> None: + self._on_volume_changed(widget, voice_type) + + volume_adj = Gtk.Adjustment( + value=10.0, + lower=0, + upper=10, + step_increment=0.1, + page_increment=1, + ) + volume_row, volume_scale, _volume_label = self._create_slider_row( + guilabels.VOICE_VOLUME, + volume_adj, + changed_handler=on_volume_changed, + include_top_separator=False, + digits=1, + ) + voice_listbox.add_row_with_widget(volume_row, volume_scale) + + languages_combo = lang_combo + families_combo = person_combo + + if voice_type == self.VoiceType.DEFAULT: + self._default_languages_combo = languages_combo + self._default_families_combo = families_combo + self._default_rate_scale = rate_scale + self._default_pitch_scale = pitch_scale + self._default_volume_scale = volume_scale + elif voice_type == self.VoiceType.HYPERLINK: + self._hyperlink_languages_combo = languages_combo + self._hyperlink_families_combo = families_combo + self._hyperlink_rate_scale = rate_scale + self._hyperlink_pitch_scale = pitch_scale + self._hyperlink_volume_scale = volume_scale + elif voice_type == self.VoiceType.UPPERCASE: + self._uppercase_languages_combo = languages_combo + self._uppercase_families_combo = families_combo + self._uppercase_rate_scale = rate_scale + self._uppercase_pitch_scale = pitch_scale + self._uppercase_volume_scale = volume_scale + elif voice_type == self.VoiceType.SYSTEM: + self._system_languages_combo = languages_combo + self._system_families_combo = families_combo + self._system_rate_scale = rate_scale + self._system_pitch_scale = pitch_scale + self._system_volume_scale = volume_scale + + self._populate_languages_for_voice_type(voice_type) + self._populate_families_for_voice_type(voice_type, apply_changes=False) + + self._initializing = True + self._refresh_voice_widgets(voice_type, rate_scale, pitch_scale, volume_scale) + self._initializing = False + + def on_response(dlg, response_id): + if response_id in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT): + # User cancelled - revert local copy and sync runtime values + voice_acss.clear() + voice_acss.update(saved_acss) + else: + # User clicked OK. The combo may show a selection the user + # didn't interact with (e.g. auto-selected first entry after + # a synthesizer change). Sync it so the ACSS dict matches + # what the UI shows. + if ACSS.FAMILY not in voice_acss: + _lang, families_combo, family_choices = self._get_widgets_for_voice_type( + voice_type + ) + active = families_combo.get_active() + if 0 <= active < len(family_choices): + voice_acss[ACSS.FAMILY] = family_choices[active] + self._has_unsaved_changes = True + + self._sync_voice_to_settings(voice_type) + dlg.destroy() + + dialog.connect("response", on_response) + + parent = self.get_toplevel() # pylint: disable=no-member + + def on_parent_destroy(*_args): + if not dialog.get_property("visible"): + return + # Trigger cancel response which will clean up and destroy the dialog + dialog.response(Gtk.ResponseType.DELETE_EVENT) + + parent.connect("destroy", on_parent_destroy) + + content_area.pack_start(voice_listbox, True, True, 0) + dialog.show_all() # pylint: disable=no-member + ok_button.grab_default() + + # TODO - JD: Remove this function if it continues to prove unnecessary + # pylint: disable-next=useless-parent-delegation + def has_changes(self) -> bool: + """Return True if there are unsaved changes.""" + + return super().has_changes() + + def reload(self) -> None: + """Reload settings from manager and refresh the UI.""" + + app = self._app_name + self._default_voice = self._manager.get_voice_properties( + speechserver.DEFAULT_VOICE, + app_name=app, + ) + self._uppercase_voice = self._manager.get_voice_properties( + speechserver.UPPERCASE_VOICE, + app_name=app, + ) + self._hyperlink_voice = self._manager.get_voice_properties( + speechserver.HYPERLINK_VOICE, + app_name=app, + ) + self._system_voice = self._manager.get_voice_properties( + speechserver.SYSTEM_VOICE, + app_name=app, + ) + + self._voice_families = self._manager.get_voice_families() + self._families_sorted = False + + self._has_unsaved_changes = False + self.refresh() + + def save_settings(self) -> dict[str, dict | list | int | str | bool]: + """Save settings and return a dictionary of the current values for those settings.""" + + result: dict[str, dict | list | int | str | bool] = { + "voices": { + speechserver.DEFAULT_VOICE: dict(self._default_voice), + speechserver.UPPERCASE_VOICE: dict(self._uppercase_voice), + speechserver.HYPERLINK_VOICE: dict(self._hyperlink_voice), + speechserver.SYSTEM_VOICE: dict(self._system_voice), + }, + } + + result[SpeechManager.KEY_SPEECH_SERVER] = self._manager.get_current_server() + result[SpeechManager.KEY_SYNTHESIZER] = self._manager.get_current_synthesizer() + result["speechServerFactory"] = self._manager.get_speech_server_factory() + + model = self._punctuation_combo.get_model() + active = self._punctuation_combo.get_active() + if model and active >= 0: + result[SpeechManager.KEY_PUNCTUATION_LEVEL] = PunctuationStyle( + model[active][1] + ).string_name + + model = self._capitalization_combo.get_model() + active = self._capitalization_combo.get_active() + if model and active >= 0: + result[SpeechManager.KEY_CAPITALIZATION_STYLE] = model[active][1] + + result[SpeechManager.KEY_SPEAK_NUMBERS_AS_DIGITS] = self._speak_numbers_switch.get_active() + result[SpeechManager.KEY_USE_COLOR_NAMES] = self._use_color_names_switch.get_active() + result[SpeechManager.KEY_INSERT_PAUSES_BETWEEN_UTTERANCES] = ( + self._enable_pause_breaks_switch.get_active() + ) + result[SpeechManager.KEY_USE_PRONUNCIATION_DICTIONARY] = ( + self._use_pronunciation_dict_switch.get_active() + ) + result[SpeechManager.KEY_AUTO_LANGUAGE_SWITCHING] = ( + self._auto_language_switching_switch.get_active() + ) + + self._has_unsaved_changes = False + return result + + def refresh(self) -> None: + """Update widget states to reflect current settings.""" + + self._initializing = True + + self._populate_speech_systems() + self._initializing = True + + app = self._app_name + model = self._punctuation_combo.get_model() + if model: + current_level = PunctuationStyle[ + self._manager.get_punctuation_level(app_name=app).upper() + ].value + for i, row in enumerate(model): + if row[1] == current_level: + self._punctuation_combo.set_active(i) + break + + model = self._capitalization_combo.get_model() + if model: + for i, row in enumerate(model): + if row[1] == self._manager.get_capitalization_style(app_name=app): + self._capitalization_combo.set_active(i) + break + + self._speak_numbers_switch.set_active( + self._manager.get_speak_numbers_as_digits(app_name=app), + ) + self._use_color_names_switch.set_active( + self._manager.get_use_color_names(app_name=app), + ) + self._enable_pause_breaks_switch.set_active( + self._manager.get_insert_pauses_between_utterances(app_name=app), + ) + self._use_pronunciation_dict_switch.set_active( + self._manager.get_use_pronunciation_dictionary(app_name=app), + ) + self._auto_language_switching_switch.set_active( + self._manager.get_auto_language_switching(app_name=app), + ) + + # Note: Voice type widgets are created on-demand in dialogs, so no need to refresh them here + + self._initializing = False + + def _refresh_voice_widgets( + self, + voice_type: VoicesPreferencesGrid.VoiceType, + rate_scale: Gtk.Scale, + pitch_scale: Gtk.Scale, + volume_scale: Gtk.Scale, + ) -> None: + """Update widgets for a specific voice type.""" + + voice_acss = self._get_acss_for_voice_type(voice_type) + + rate = voice_acss.get(ACSS.RATE, 50) + rate_scale.set_value(rate) + + pitch = voice_acss.get(ACSS.AVERAGE_PITCH, 5.0) + pitch_scale.set_value(pitch) + + volume = voice_acss.get(ACSS.GAIN, 10.0) + volume_scale.set_value(volume) + + def _get_acss_for_voice_type(self, voice_type: VoicesPreferencesGrid.VoiceType) -> ACSS: + """Return the local ACSS copy for the given voice type.""" + + if voice_type == self.VoiceType.DEFAULT: + return self._default_voice + if voice_type == self.VoiceType.UPPERCASE: + return self._uppercase_voice + if voice_type == self.VoiceType.HYPERLINK: + return self._hyperlink_voice + if voice_type == self.VoiceType.SYSTEM: + return self._system_voice + return self._default_voice + + def _get_widgets_for_voice_type( + self, + voice_type: VoicesPreferencesGrid.VoiceType, + ) -> tuple[Gtk.ComboBox, Gtk.ComboBox, list[speechserver.VoiceFamily]]: + """Return the widgets and family choices for a given voice type.""" + + if voice_type == self.VoiceType.DEFAULT: + return ( + self._default_languages_combo, + self._default_families_combo, + self._default_family_choices, + ) + if voice_type == self.VoiceType.HYPERLINK: + return ( + self._hyperlink_languages_combo, + self._hyperlink_families_combo, + self._hyperlink_family_choices, + ) + if voice_type == self.VoiceType.UPPERCASE: + return ( + self._uppercase_languages_combo, + self._uppercase_families_combo, + self._uppercase_family_choices, + ) + if voice_type == self.VoiceType.SYSTEM: + return ( + self._system_languages_combo, + self._system_families_combo, + self._system_family_choices, + ) + return ( + self._default_languages_combo, + self._default_families_combo, + self._default_family_choices, + ) + + def _set_family_choices_for_voice_type( + self, + voice_type: VoicesPreferencesGrid.VoiceType, + choices: list[speechserver.VoiceFamily], + ) -> None: + """Set the family choices for a given voice type.""" + + if voice_type == self.VoiceType.DEFAULT: + self._default_family_choices = choices + elif voice_type == self.VoiceType.HYPERLINK: + self._hyperlink_family_choices = choices + elif voice_type == self.VoiceType.UPPERCASE: + self._uppercase_family_choices = choices + elif voice_type == self.VoiceType.SYSTEM: + self._system_family_choices = choices + + def _populate_speech_systems(self) -> None: + """Populate the speech systems combo.""" + + self._initializing = True + + model = self._speech_systems_combo.get_model() + if not model: + model = Gtk.ListStore(str) + self._speech_systems_combo.set_model(None) + model.clear() + + available = self._manager.get_available_servers() + for server_name in available: + model.append([server_name]) + + self._speech_systems_combo.set_model(model) + + current = self._manager.get_current_server() + found = False + selected_server = None + for i, row in enumerate(model): + if row[0] == current: + self._speech_systems_combo.set_active(i) + selected_server = current + found = True + break + + if not found and len(model) > 0: + self._speech_systems_combo.set_active(0) + tree_iter = model.get_iter_first() + if tree_iter: + selected_server = model.get_value(tree_iter, 0) + + if selected_server: + self._manager.set_current_server(selected_server) + + self._initializing = False + self._populate_speech_synthesizers() + + def _populate_speech_synthesizers(self) -> None: + """Populate the speech synthesizers combo.""" + + self._initializing = True + + model = self._speech_synthesizers_combo.get_model() + if not model: + model = Gtk.ListStore(str) + self._speech_synthesizers_combo.set_model(None) + model.clear() + + available = self._manager.get_available_synthesizers() + for synth_name in available: + model.append([synth_name]) + + self._speech_synthesizers_combo.set_model(model) + + current = self._manager.get_current_synthesizer() + found = False + selected_synth = None + for i, row in enumerate(model): + if row[0] == current: + self._speech_synthesizers_combo.set_active(i) + selected_synth = current + found = True + break + + if not found and len(model) > 0: + self._speech_synthesizers_combo.set_active(0) + tree_iter = model.get_iter_first() + if tree_iter: + selected_synth = model.get_value(tree_iter, 0) + + if selected_synth: + self._manager.set_current_synthesizer(selected_synth) + + self._voice_families = self._manager.get_voice_families() + self._initializing = False + # Note: Voice widgets are created on-demand in dialogs, so we don't populate them here + + # pylint: disable-next=too-many-branches + def _populate_languages_for_voice_type( + self, + voice_type: VoicesPreferencesGrid.VoiceType, + ) -> None: + """Populate the languages combo for a specific voice type.""" + + languages_combo, _, _ = self._get_widgets_for_voice_type(voice_type) + + self._initializing = True + + model = languages_combo.get_model() + if not model: + model = Gtk.ListStore(str, str) + languages_combo.set_model(None) + model.clear() + + if len(self._voice_families) == 0: + languages_combo.set_model(model) + self._initializing = False + return + + if not self._families_sorted: + default_marker = guilabels.SPEECH_DEFAULT_VOICE.replace("%s", "").strip().lower() + + def _get_sort_key(family): + variant = family.get(speechserver.VoiceFamily.VARIANT) + name = family.get(speechserver.VoiceFamily.NAME, "") + if default_marker in name.lower() or "default" in name.lower(): + return (0, "") + if variant not in (None, "none", "None"): + return (1, variant.lower()) + return (1, name.lower()) + + self._voice_families.sort(key=_get_sort_key) + self._families_sorted = True + + done = {} + languages = [] + for family in self._voice_families: + lang = family.get(speechserver.VoiceFamily.LANG, "") + dialect = family.get(speechserver.VoiceFamily.DIALECT, "") + + if (lang, dialect) in done: + continue + done[(lang, dialect)] = True + + if dialect: + language = f"{lang}-{dialect}" + else: + language = lang + + msg = language or "default language" + languages.append(language) + model.append([msg]) + + languages_combo.set_model(model) + + voice_acss = self._get_acss_for_voice_type(voice_type) + saved_family: speechserver.VoiceFamily | None = voice_acss.get(ACSS.FAMILY) + selected_index = 0 + saved_language = "" + + if saved_family: + lang = saved_family.get(speechserver.VoiceFamily.LANG, "") + dialect = saved_family.get(speechserver.VoiceFamily.DIALECT, "") + if dialect: + saved_language = f"{lang}-{dialect}" + else: + saved_language = lang + elif voice_type == self.VoiceType.DEFAULT: + family_locale, _encoding = locale.getlocale(locale.LC_MESSAGES) + if family_locale: + locale_parts = family_locale.split("_") + lang = locale_parts[0] + dialect = locale_parts[1] if len(locale_parts) > 1 else "" + saved_language = f"{lang}-{dialect}" if dialect else lang + + if saved_language: + lang_only = saved_language.partition("-")[0] + partial_match = -1 + for i, language in enumerate(languages): + if language == saved_language: + selected_index = i + break + if partial_match < 0: + if language == lang_only or language.startswith(f"{lang_only}-"): + partial_match = i + else: + if partial_match >= 0: + selected_index = partial_match + + if len(languages) > 0: + languages_combo.set_active(selected_index) + + self._initializing = False + + # pylint: disable-next=too-many-branches + def _populate_families_for_voice_type( + self, + voice_type: VoicesPreferencesGrid.VoiceType, + apply_changes: bool = True, + ) -> None: + """Populate the families/persons combo for a specific voice type.""" + + languages_combo, families_combo, _ = self._get_widgets_for_voice_type(voice_type) + + self._initializing = True + + families_model = families_combo.get_model() + if not families_model: + families_model = Gtk.ListStore(str, str) + families_combo.set_model(None) + families_model.clear() + + active = languages_combo.get_active() + if active < 0: + families_combo.set_model(families_model) + self._initializing = False + return + + languages_model = languages_combo.get_model() + tree_iter = languages_model.get_iter(active) + current_language = languages_model.get_value(tree_iter, 0) + + family_choices = [] + for family in self._voice_families: + lang = family.get(speechserver.VoiceFamily.LANG, "") + dialect = family.get(speechserver.VoiceFamily.DIALECT, "") + + if dialect: + language = f"{lang}-{dialect}" + else: + language = lang + + if language != current_language: + continue + + name = family.get(speechserver.VoiceFamily.NAME, "") + variant = family.get(speechserver.VoiceFamily.VARIANT, "") + + # Show variant if it exists and is not "none", otherwise show name + display_name = name + if variant and variant not in ("none", "None"): + display_name = variant + + family_choices.append(family) + families_model.append([display_name]) + + families_combo.set_model(families_model) + + self._set_family_choices_for_voice_type(voice_type, family_choices) + + voice_acss = self._get_acss_for_voice_type(voice_type) + saved_family: speechserver.VoiceFamily | None = voice_acss.get(ACSS.FAMILY) + selected_index = 0 + + if saved_family and len(family_choices) > 0: + saved_name = saved_family.get(speechserver.VoiceFamily.NAME, "") + + for i, family in enumerate(family_choices): + family_name = family.get(speechserver.VoiceFamily.NAME, "") + if family_name == saved_name: + selected_index = i + break + + if len(family_choices) > 0: + families_combo.set_active(selected_index) + + if apply_changes: + family = family_choices[selected_index] + voice_name = family.get(speechserver.VoiceFamily.NAME, "") + + voice_acss[ACSS.FAMILY] = family + voice_acss["established"] = True + + # Sync runtime values so the voice change is heard immediately + self._sync_voice_to_settings(voice_type) + + # Only set as current voice if this is the default voice type + if voice_type == self.VoiceType.DEFAULT: + self._manager.set_current_voice(voice_name) + + self._initializing = False + + def _sync_voice_to_settings(self, voice_type: VoicesPreferencesGrid.VoiceType) -> None: + """Sync local voice copy to runtime values for immediate preview.""" + + voice_map = { + self.VoiceType.DEFAULT: (self._default_voice, speechserver.DEFAULT_VOICE), + self.VoiceType.UPPERCASE: (self._uppercase_voice, speechserver.UPPERCASE_VOICE), + self.VoiceType.HYPERLINK: (self._hyperlink_voice, speechserver.HYPERLINK_VOICE), + self.VoiceType.SYSTEM: (self._system_voice, speechserver.SYSTEM_VOICE), + } + + local_voice, settings_key = voice_map[voice_type] + voice = ACSS(local_voice) + registry = gsettings_registry.get_registry() + schema = self._VOICE_SCHEMA + + if ACSS.RATE in voice: + registry.set_runtime_value( + schema, SpeechManager.KEY_RATE, voice[ACSS.RATE], voice_type=settings_key + ) + if ACSS.AVERAGE_PITCH in voice: + registry.set_runtime_value( + schema, + SpeechManager.KEY_PITCH, + voice[ACSS.AVERAGE_PITCH], + voice_type=settings_key, + ) + if ACSS.GAIN in voice: + registry.set_runtime_value( + schema, SpeechManager.KEY_VOLUME, voice[ACSS.GAIN], voice_type=settings_key + ) + family = voice.get(ACSS.FAMILY, {}) + for dconf_key, family_key in ( + (SpeechManager.KEY_FAMILY_NAME, speechserver.VoiceFamily.NAME), + (SpeechManager.KEY_FAMILY_LANG, speechserver.VoiceFamily.LANG), + (SpeechManager.KEY_FAMILY_DIALECT, speechserver.VoiceFamily.DIALECT), + (SpeechManager.KEY_FAMILY_GENDER, speechserver.VoiceFamily.GENDER), + (SpeechManager.KEY_FAMILY_VARIANT, speechserver.VoiceFamily.VARIANT), + ): + if family_key in family: + registry.set_runtime_value( + schema, + dconf_key, + family[family_key], + voice_type=settings_key, + ) + + server = self._manager.get_server() + if server is not None: + if settings_key == speechserver.DEFAULT_VOICE: + server.set_default_voice(voice) + server.clear_cached_voice_properties() + + def _on_rate_changed( + self, + widget: Gtk.Scale, + voice_type: VoicesPreferencesGrid.VoiceType, + ) -> None: + """Handle rate slider change for a specific voice type.""" + + if self._initializing: + return + + rate = widget.get_value() + voice_acss = self._get_acss_for_voice_type(voice_type) + voice_acss[ACSS.RATE] = rate + voice_acss["established"] = True + self._sync_voice_to_settings(voice_type) + self._has_unsaved_changes = True + + def _on_pitch_changed( + self, + widget: Gtk.Scale, + voice_type: VoicesPreferencesGrid.VoiceType, + ) -> None: + """Handle pitch slider change for a specific voice type.""" + + if self._initializing: + return + + pitch = widget.get_value() + voice_acss = self._get_acss_for_voice_type(voice_type) + voice_acss[ACSS.AVERAGE_PITCH] = pitch + voice_acss["established"] = True + self._sync_voice_to_settings(voice_type) + self._has_unsaved_changes = True + + def _on_volume_changed( + self, + widget: Gtk.Scale, + voice_type: VoicesPreferencesGrid.VoiceType, + ) -> None: + """Handle volume slider change for a specific voice type.""" + + if self._initializing: + return + + volume = widget.get_value() + voice_acss = self._get_acss_for_voice_type(voice_type) + voice_acss[ACSS.GAIN] = volume + voice_acss["established"] = True + self._sync_voice_to_settings(voice_type) + self._has_unsaved_changes = True + + def _on_punctuation_changed(self, widget: Gtk.ComboBox) -> None: + """Handle punctuation combo box change.""" + + if self._initializing: + return + + active = widget.get_active() + if active < 0: + return + + model = widget.get_model() + tree_iter = model.get_iter(active) + level = model.get_value(tree_iter, 1) + + gsettings_registry.get_registry().set_runtime_value( + SpeechManager.SPEECH_SCHEMA, + SpeechManager.KEY_PUNCTUATION_LEVEL, + PunctuationStyle(level).string_name, + ) + self._manager.update_punctuation_level() + self._has_unsaved_changes = True + + def _on_capitalization_changed(self, widget: Gtk.ComboBox) -> None: + """Handle capitalization combo box change.""" + + if self._initializing: + return + + active = widget.get_active() + if active < 0: + return + + model = widget.get_model() + tree_iter = model.get_iter(active) + style = model.get_value(tree_iter, 1) + + gsettings_registry.get_registry().set_runtime_value( + SpeechManager.SPEECH_SCHEMA, + SpeechManager.KEY_CAPITALIZATION_STYLE, + CapitalizationStyle(style).string_name, + ) + self._manager.update_capitalization_style() + self._has_unsaved_changes = True + + def _on_speak_numbers_toggled(self, switch: Gtk.Switch, _state: Any) -> None: + """Handle speak numbers as digits switch change.""" + if self._initializing: + return + self._manager.set_speak_numbers_as_digits(switch.get_active()) + self._has_unsaved_changes = True + + def _on_use_color_names_toggled(self, switch: Gtk.Switch, _state: Any) -> None: + """Handle use color names switch change.""" + if self._initializing: + return + self._manager.set_use_color_names(switch.get_active()) + self._has_unsaved_changes = True + + def _on_enable_pause_breaks_toggled(self, switch: Gtk.Switch, _state: Any) -> None: + """Handle enable pause breaks switch change.""" + if self._initializing: + return + self._manager.set_insert_pauses_between_utterances(switch.get_active()) + self._has_unsaved_changes = True + + def _on_use_pronunciation_dict_toggled(self, switch: Gtk.Switch, _state: Any) -> None: + """Handle use pronunciation dictionary switch change.""" + if self._initializing: + return + self._manager.set_use_pronunciation_dictionary(switch.get_active()) + self._has_unsaved_changes = True + + def _on_auto_language_switching_toggled(self, switch: Gtk.Switch, _state: Any) -> None: + """Handle auto language switching switch change.""" + if self._initializing: + return + self._manager.set_auto_language_switching(switch.get_active()) + self._has_unsaved_changes = True + + def _on_speech_system_changed(self, widget: Gtk.ComboBox) -> None: + """Handle speech system combo change.""" + + if self._initializing: + return + + active = widget.get_active() + if active < 0: + return + + model = widget.get_model() + tree_iter = model.get_iter(active) + server_name = model.get_value(tree_iter, 0) + + self._manager.set_current_server(server_name) + + self._populate_speech_synthesizers() + self._has_unsaved_changes = True + + def _on_speech_synthesizer_changed(self, widget: Gtk.ComboBox) -> None: + """Handle speech synthesizer combo change.""" + + if self._initializing: + return + + active = widget.get_active() + if active < 0: + return + + model = widget.get_model() + tree_iter = model.get_iter(active) + synth_name = model.get_value(tree_iter, 0) + + self._manager.set_current_synthesizer(synth_name) + + self._voice_families = self._manager.get_voice_families() + self._families_sorted = False + + # When synthesizer changes, replace the old family with a default + # from the new synthesizer. Without this, import_voice only writes + # keys present in the ACSS dict, so old family values persist in dconf. + default_family = self._voice_families[0] if self._voice_families else None + for voice_type in [ + self.VoiceType.DEFAULT, + self.VoiceType.HYPERLINK, + self.VoiceType.UPPERCASE, + self.VoiceType.SYSTEM, + ]: + voice_acss = self._get_acss_for_voice_type(voice_type) + if ACSS.FAMILY in voice_acss: + del voice_acss[ACSS.FAMILY] + if default_family is not None: + voice_acss[ACSS.FAMILY] = default_family + + self._has_unsaved_changes = True + + def _on_speech_language_changed( + self, + widget: Gtk.ComboBox, + voice_type: VoicesPreferencesGrid.VoiceType, + ) -> None: + """Handle speech language combo change for a specific voice type.""" + + if self._initializing: + return + + self._populate_families_for_voice_type(voice_type) + self._has_unsaved_changes = True + + if voice_type == self.VoiceType.DEFAULT: + self._propagate_language_to_other_voices(widget) + + def _propagate_language_to_other_voices(self, _language_combo: Gtk.ComboBox) -> None: + """Update other voice types to use the same voice family as the Default voice.""" + + default_voice = self._get_acss_for_voice_type(self.VoiceType.DEFAULT) + default_family = default_voice.get(ACSS.FAMILY) + if not default_family: + return + + voice_types = [self.VoiceType.HYPERLINK, self.VoiceType.UPPERCASE, self.VoiceType.SYSTEM] + for voice_type in voice_types: + voice_acss = self._get_acss_for_voice_type(voice_type) + voice_acss[ACSS.FAMILY] = default_family + voice_acss["established"] = True + self._sync_voice_to_settings(voice_type) + + def _on_speech_family_changed( + self, + widget: Gtk.ComboBox, + voice_type: VoicesPreferencesGrid.VoiceType, + ) -> None: + """Handle speech family combo change for a specific voice type.""" + + if self._initializing: + return + + _, _, family_choices = self._get_widgets_for_voice_type(voice_type) + + active = widget.get_active() + if active < 0 or active >= len(family_choices): + return + + family = family_choices[active] + voice_name = family.get(speechserver.VoiceFamily.NAME, "") + + voice_acss = self._get_acss_for_voice_type(voice_type) + voice_acss[ACSS.FAMILY] = family + voice_acss["established"] = True + self._sync_voice_to_settings(voice_type) + + # Only set as current voice if this is the default voice type + if voice_type == self.VoiceType.DEFAULT: + self._manager.set_current_voice(voice_name) + + self._has_unsaved_changes = True + + +@gsettings_registry.get_registry().gsettings_schema("org.stormux.Cthulhu.Speech", name="speech") +@gsettings_registry.get_registry().gsettings_schema("org.stormux.Cthulhu.Voice", name="voice") +class SpeechManager: + """Manages the speech engine: server, synthesizer, voice, and output parameters.""" + + SPEECH_SCHEMA = "speech" + _VOICE_SCHEMA = "voice" + + KEY_ENABLE = "enable" + KEY_SPEECH_SERVER = "speech-server" + KEY_SPEECH_SERVER_FACTORY = "speech-server-factory" + KEY_SYNTHESIZER = "synthesizer" + KEY_SPEAK_NUMBERS_AS_DIGITS = "speak-numbers-as-digits" + KEY_USE_COLOR_NAMES = "use-color-names" + KEY_INSERT_PAUSES_BETWEEN_UTTERANCES = "insert-pauses-between-utterances" + KEY_USE_PRONUNCIATION_DICTIONARY = "use-pronunciation-dictionary" + KEY_AUTO_LANGUAGE_SWITCHING = "auto-language-switching" + KEY_CAPITALIZATION_STYLE = "capitalization-style" + KEY_PUNCTUATION_LEVEL = "punctuation-level" + + KEY_ESTABLISHED = "established" + KEY_RATE = "rate" + KEY_PITCH = "pitch" + KEY_VOLUME = "volume" + KEY_FAMILY_NAME = "family-name" + KEY_FAMILY_LANG = "family-lang" + KEY_FAMILY_DIALECT = "family-dialect" + KEY_FAMILY_GENDER = "family-gender" + KEY_FAMILY_VARIANT = "family-variant" + + def _get_setting(self, key: str, gtype: str, default: Any, app_name: str | None = None) -> Any: + """Returns the dconf value for key, or default if not in dconf.""" + + return gsettings_registry.get_registry().layered_lookup( + self.SPEECH_SCHEMA, + key, + gtype, + default=default, + app_name=app_name, + ) + + def get_voice_properties( + self, + voice_type: str = "", + app_name: str | None = None, + ) -> ACSS: + """Returns voice properties from dconf for the given voice type.""" + + vtype = voice_type or speechserver.DEFAULT_VOICE + lookup = gsettings_registry.get_registry().layered_lookup + voice: dict[str, Any] = {} + + established = lookup( + self._VOICE_SCHEMA, + self.KEY_ESTABLISHED, + "b", + voice_type=vtype, + app_name=app_name, + ) + if established is not None: + voice["established"] = established + + rate = lookup(self._VOICE_SCHEMA, self.KEY_RATE, "i", voice_type=vtype, app_name=app_name) + if rate is not None: + voice[ACSS.RATE] = rate + pitch = lookup(self._VOICE_SCHEMA, self.KEY_PITCH, "d", voice_type=vtype, app_name=app_name) + if pitch is not None: + voice[ACSS.AVERAGE_PITCH] = pitch + volume = lookup( + self._VOICE_SCHEMA, self.KEY_VOLUME, "d", voice_type=vtype, app_name=app_name + ) + if volume is not None: + voice[ACSS.GAIN] = volume + + family: dict[str, str] = {} + for dconf_key, family_key in ( + (self.KEY_FAMILY_NAME, speechserver.VoiceFamily.NAME), + (self.KEY_FAMILY_LANG, speechserver.VoiceFamily.LANG), + (self.KEY_FAMILY_DIALECT, speechserver.VoiceFamily.DIALECT), + (self.KEY_FAMILY_GENDER, speechserver.VoiceFamily.GENDER), + (self.KEY_FAMILY_VARIANT, speechserver.VoiceFamily.VARIANT), + ): + value = lookup(self._VOICE_SCHEMA, dconf_key, "s", voice_type=vtype, app_name=app_name) + if value: + family[family_key] = value + if family: + voice[ACSS.FAMILY] = family + + return ACSS(voice) + + def __init__(self) -> None: + self._families_sorted: bool = False + self._initialized: bool = False + self._server: SpeechServer | None = None + + msg = "SPEECH MANAGER: Registering D-Bus commands." + debug.print_message(debug.LEVEL_INFO, msg, True) + controller = dbus_service.get_remote_controller() + controller.register_decorated_module("SpeechManager", 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_SPEECH_VERBOSITY + + # Common keybindings (same for desktop and laptop) + kb_s = keybindings.KeyBinding("s", keybindings.CTHULHU_MODIFIER_MASK) + + # (name, function, description, desktop_kb, laptop_kb) + commands_data = [ + ( + "cycleCapitalizationStyleHandler", + self.cycle_capitalization_style, + cmdnames.CYCLE_CAPITALIZATION_STYLE, + None, + None, + ), + ( + "cycleSpeakingPunctuationLevelHandler", + self.cycle_punctuation_level, + cmdnames.CYCLE_PUNCTUATION_LEVEL, + None, + None, + ), + ( + "cycleSynthesizerHandler", + self.cycle_synthesizer, + cmdnames.CYCLE_SYNTHESIZER, + None, + None, + ), + ("toggleSilenceSpeechHandler", self.toggle_speech, cmdnames.TOGGLE_SPEECH, kb_s, kb_s), + ( + "decreaseSpeechRateHandler", + self.decrease_rate, + cmdnames.DECREASE_SPEECH_RATE, + None, + None, + ), + ( + "increaseSpeechRateHandler", + self.increase_rate, + cmdnames.INCREASE_SPEECH_RATE, + None, + None, + ), + ( + "decreaseSpeechPitchHandler", + self.decrease_pitch, + cmdnames.DECREASE_SPEECH_PITCH, + None, + None, + ), + ( + "increaseSpeechPitchHandler", + self.increase_pitch, + cmdnames.INCREASE_SPEECH_PITCH, + None, + None, + ), + ( + "decreaseSpeechVolumeHandler", + self.decrease_volume, + cmdnames.DECREASE_SPEECH_VOLUME, + None, + None, + ), + ( + "increaseSpeechVolumeHandler", + self.increase_volume, + cmdnames.INCREASE_SPEECH_VOLUME, + None, + None, + ), + ] + + for name, function, description, desktop_kb, laptop_kb in commands_data: + manager.add_command( + command_manager.KeyboardCommand( + name, + function, + group_label, + description, + desktop_keybinding=desktop_kb, + laptop_keybinding=laptop_kb, + ), + ) + + msg = "SPEECH MANAGER: Commands set up." + debug.print_message(debug.LEVEL_INFO, msg, True) + + def get_server(self) -> SpeechServer | None: + """Returns the speech server instance, or None if not initialized.""" + + return self._server + + def _get_server(self) -> SpeechServer | None: + """Returns the speech server if it is responsive..""" + + result = self._server + if result is None: + msg = "SPEECH MANAGER: Speech server is None." + debug.print_message(debug.LEVEL_INFO, msg, True) + return None + + result_queue: queue.Queue[bool] = queue.Queue() + + def health_check_thread(): + result.get_output_module() + result_queue.put(True) + + thread = threading.Thread(target=health_check_thread, daemon=True) + thread.start() + + try: + result_queue.get(timeout=2.0) + except queue.Empty: + msg = "SPEECH MANAGER: Speech server health check timed out" + debug.print_message(debug.LEVEL_WARNING, msg, True) + return None + + tokens = ["SPEECH MANAGER: Speech server is", result] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return result + + def _get_available_servers(self) -> list[str]: + """Returns a list of available speech servers.""" + + return list(self._get_server_module_map().keys()) + + def _get_server_module_map(self) -> dict[str, str]: + """Returns a mapping of server names to module names.""" + + result = {} + for module_name in SPEECH_FACTORY_MODULES: + try: + factory = importlib.import_module(f"cthulhu.{module_name}") + except ImportError: + try: + factory = importlib.import_module(module_name) + except ImportError: + continue + + try: + speech_server_class = factory.SpeechServer + if server_name := speech_server_class.get_factory_name(): + result[server_name] = module_name + + except (AttributeError, TypeError, ImportError) as error: + tokens = [f"SPEECH MANAGER: {module_name} not available:", error] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + return result + + def _switch_server(self, target_server: str) -> bool: + """Switches to the specified server.""" + + server_module_map = self._get_server_module_map() + target_module = server_module_map.get(target_server) + if not target_module: + return False + + self.shutdown_speech() + gsettings_registry.get_registry().set_runtime_value( + self.SPEECH_SCHEMA, + self.KEY_SPEECH_SERVER_FACTORY, + target_module, + ) + self.start_speech() + return self.get_current_server() == target_server + + @dbus_service.getter + def get_available_servers(self) -> list[str]: + """Returns a list of available servers.""" + + result = self._get_available_servers() + msg = f"SPEECH MANAGER: Available servers: {result}." + debug.print_message(debug.LEVEL_INFO, msg, True) + return result + + @gsettings_registry.get_registry().gsetting( + key=KEY_SPEECH_SERVER, + schema="speech", + gtype="s", + default="", + summary="Speech server name", + ) + @dbus_service.getter + def get_current_server(self) -> str: + """Returns the name of the current speech server (Speech Dispatcher or Spiel).""" + + server = self._get_server() + if server is None: + msg = "SPEECH MANAGER: Cannot get speech server." + debug.print_message(debug.LEVEL_INFO, msg, True) + return "" + + name = server.get_factory_name() + msg = f"SPEECH MANAGER: Server is: {name}." + debug.print_message(debug.LEVEL_INFO, msg, True) + return name + + @dbus_service.setter + def set_current_server(self, value: str) -> bool: + """Sets the current speech server (e.g. Speech Dispatcher or Spiel).""" + + return self._switch_server(value) + + @gsettings_registry.get_registry().gsetting( + key=KEY_SPEECH_SERVER_FACTORY, + schema="speech", + gtype="s", + default="speechdispatcherfactory", + summary="Speech server factory module", + migration_key="speechServerFactory", + ) + def get_speech_server_factory(self) -> str: + """Returns the speech server factory module name.""" + + return self._get_setting(self.KEY_SPEECH_SERVER_FACTORY, "s", "speechdispatcherfactory") + + @gsettings_registry.get_registry().gsetting( + key=KEY_SYNTHESIZER, + schema="speech", + gtype="s", + default="", + summary="Speech synthesizer", + ) + @dbus_service.getter + def get_current_synthesizer(self) -> str: + """Returns the current synthesizer of the speech server.""" + + server = self._get_server() + if server is None: + msg = "SPEECH MANAGER: Cannot get speech server." + debug.print_message(debug.LEVEL_INFO, msg, True) + return "" + + result = server.get_output_module() + msg = f"SPEECH MANAGER: Synthesizer is: {result}." + debug.print_message(debug.LEVEL_INFO, msg, True) + return result + + @dbus_service.setter + def set_current_synthesizer(self, value: str) -> bool: + """Sets the current synthesizer of the active speech server.""" + + server = self._get_server() + if server is None: + msg = "SPEECH MANAGER: Cannot get speech server." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + available = self.get_available_synthesizers() + if value not in available: + tokens = [f"SPEECH MANAGER: '{value}' is not in", available] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + msg = f"SPEECH MANAGER: Setting synthesizer to: {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + server.set_output_module(value) + return server.get_output_module() == value + + @dbus_service.getter + def get_available_synthesizers(self) -> list[str]: + """Returns a list of available synthesizers of the speech server.""" + + server = self._get_server() + if server is None: + msg = "SPEECH MANAGER: Cannot get speech server." + debug.print_message(debug.LEVEL_INFO, msg, True) + return [] + + synthesizers = server.get_speech_servers() + result = [s.get_info()[1] for s in synthesizers] + msg = f"SPEECH MANAGER: Available synthesizers: {result}." + debug.print_message(debug.LEVEL_INFO, msg, True) + return result + + @dbus_service.getter + def get_available_voices(self) -> list[str]: + """Returns a list of available voices for the current synthesizer.""" + + server = self._get_server() + if server is None: + return [] + + voices = server.get_voice_families() + if not voices: + return [] + + result = sorted( + { + voice_name + for voice in voices + if (voice_name := voice.get(speechserver.VoiceFamily.NAME, "")) + }, + ) + return result + + def get_voice_families(self) -> list[speechserver.VoiceFamily]: + """Returns the full list of voice family dictionaries for the current synthesizer. + Each dictionary contains NAME, LANG, DIALECT, and VARIANT fields.""" + + server = self._get_server() + if server is None: + return [] + + return server.get_voice_families() or [] + + @dbus_service.parameterized_command + def get_voices_for_language( + self, + language: str, + variant: str = "", + script: default.Script | None = None, + event: input_event.InputEvent | None = None, + notify_user: bool = False, + ) -> list[tuple[str, str, str]]: + """Returns a list of available voices for the specified language.""" + + tokens = [ + "SPEECH MANAGER: get_voices_for_language. Language:", + language, + "Variant:", + variant, + "Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + server = self._get_server() + if server is None: + return [] + + voices = server.get_voice_families_for_language(language=language, variant=variant) + result = [] + for name, lang, var in voices: + result.append((name, lang or "", var or "")) + + msg = f"SPEECH MANAGER: Found {len(result)} voice(s) for '{language}'." + debug.print_message(debug.LEVEL_INFO, msg, True) + return result + + @gsettings_registry.get_registry().gsetting( + key=KEY_FAMILY_NAME, + schema="voice", + gtype="s", + default="", + summary="Voice family name", + ) + @dbus_service.getter + def get_current_voice(self) -> str: + """Returns the current voice name.""" + + server = self._get_server() + if server is None: + return "" + + result = "" + if voice_family := server.get_voice_family(): + result = voice_family.get(speechserver.VoiceFamily.NAME, "") + + return result + + @gsettings_registry.get_registry().gsetting( + key=KEY_FAMILY_LANG, + schema="voice", + gtype="s", + default="", + summary="Voice family language", + ) + def get_current_voice_lang(self) -> str: + """Returns the language of the current voice.""" + + server = self._get_server() + if server is None: + return "" + + if voice_family := server.get_voice_family(): + return voice_family.get(speechserver.VoiceFamily.LANG, "") or "" + + return "" + + @gsettings_registry.get_registry().gsetting( + key=KEY_FAMILY_DIALECT, + schema="voice", + gtype="s", + default="", + summary="Voice family dialect", + ) + def get_current_voice_dialect(self) -> str: + """Returns the dialect of the current voice.""" + + server = self._get_server() + if server is None: + return "" + + if voice_family := server.get_voice_family(): + return voice_family.get(speechserver.VoiceFamily.DIALECT, "") or "" + + return "" + + @gsettings_registry.get_registry().gsetting( + key=KEY_FAMILY_GENDER, + schema="voice", + gtype="s", + default="", + summary="Voice family gender", + ) + def get_current_voice_gender(self) -> str: + """Returns the gender of the current voice.""" + + server = self._get_server() + if server is None: + return "" + + if voice_family := server.get_voice_family(): + return voice_family.get(speechserver.VoiceFamily.GENDER, "") or "" + + return "" + + @gsettings_registry.get_registry().gsetting( + key=KEY_FAMILY_VARIANT, + schema="voice", + gtype="s", + default="", + summary="Voice family variant", + ) + def get_current_voice_variant(self) -> str: + """Returns the variant of the current voice.""" + + server = self._get_server() + if server is None: + return "" + + if voice_family := server.get_voice_family(): + return voice_family.get(speechserver.VoiceFamily.VARIANT, "") or "" + + return "" + + @dbus_service.setter + def set_current_voice(self, voice_name: str) -> bool: + """Sets the current voice for the active synthesizer.""" + + server = self._get_server() + if server is None: + return False + + available = self.get_available_voices() + if voice_name not in available: + msg = f"SPEECH MANAGER: '{voice_name}' is not in {available}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + voices = server.get_voice_families() + if not voices: + return False + + result = False + for voice_family in voices: + family_name = voice_family.get(speechserver.VoiceFamily.NAME, "") + if family_name == voice_name: + server.set_voice_family(voice_family) + result = True + break + + msg = f"SPEECH MANAGER: Set voice to '{voice_name}': {result}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return result + + def get_current_speech_server_info(self) -> tuple[str, str]: + """Returns the name and ID of the current speech server.""" + + # TODO - JD: The result is not in sync with the current output module. Should it be? + # TODO - JD: The only caller is the preferences dialog. And the useful functionality is in + # the methods to get (and set) the output module. So why exactly do we need this? + server = self._get_server() + if server is None: + return ("", "") + + server_name, server_id = server.get_info() + msg = f"SPEECH MANAGER: Speech server info: {server_name}, {server_id}." + debug.print_message(debug.LEVEL_INFO, msg, True) + return server_name, server_id + + def check_speech_setting(self) -> None: + """Checks the speech setting and initializes speech if necessary.""" + + if not self.get_speech_is_enabled(): + msg = "SPEECH MANAGER: Speech is not enabled. Shutting down speech." + debug.print_message(debug.LEVEL_INFO, msg, True) + self.shutdown_speech() + return + + msg = "SPEECH MANAGER: Speech is enabled." + debug.print_message(debug.LEVEL_INFO, msg, True) + self.start_speech() + + @dbus_service.command + def start_speech( + self, + script: default.Script | None = None, + event: input_event.InputEvent | None = None, + notify_user: bool = False, + ) -> bool: + """Starts the speech server.""" + + tokens = [ + "SPEECH MANAGER: start_speech. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._init_server() + return True + + def _init_server(self) -> None: + """Initializes the speech server.""" + + debug.print_message(debug.LEVEL_INFO, "SPEECH MANAGER: Initializing server", True) + if self._server: + debug.print_message(debug.LEVEL_INFO, "SPEECH MANAGER: Already initialized", True) + return + + factory = self.get_speech_server_factory() + self._server = self._init_server_from_module(factory, None) + + if not self._server: + for module_name in SPEECH_FACTORY_MODULES: + if module_name != factory: + self._server = self._init_server_from_module(module_name, None) + if self._server: + factory = module_name + break + + if self._server: + tokens = ["SPEECH MANAGER: Using speech server factory:", factory] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + synth = gsettings_registry.get_registry().layered_lookup( + self.SPEECH_SCHEMA, + self.KEY_SYNTHESIZER, + "s", + ) + if synth: + self._server.set_output_module(synth) + + self._server.set_default_voice(self.get_voice_properties()) + level_str = self.get_punctuation_level() + self._server.update_punctuation_level(PunctuationStyle[level_str.upper()]) + self._server.update_capitalization_style(self.get_capitalization_style()) + else: + msg = "SPEECH MANAGER: Speech not available" + debug.print_message(debug.LEVEL_INFO, msg, True) + + speech.set_server(self._server) + speech.set_mute_speech(self.get_speech_is_muted()) + debug.print_message(debug.LEVEL_INFO, "SPEECH MANAGER: Server initialized", True) + + @staticmethod + def _init_server_from_module( + module_name: str, + speech_server_info: list[str] | None, + ) -> SpeechServer | None: + """Attempts to initialize a speech server from the given module.""" + + if not module_name: + return None + + factory = None + try: + factory = importlib.import_module(f"cthulhu.{module_name}") + except ImportError: + try: + factory = importlib.import_module(module_name) + except ImportError: + debug.print_exception(debug.LEVEL_SEVERE) + + if not factory: + msg = f"SPEECH MANAGER: Failed to import module: {module_name}" + debug.print_message(debug.LEVEL_WARNING, msg, True) + return None + + server = None + if speech_server_info: + server = factory.SpeechServer.get_speech_server(speech_server_info) + + if not server: + if speech_server_info: + tokens = ["SPEECH MANAGER: Could not use server info:", speech_server_info] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + server = factory.SpeechServer.get_speech_server() + + if not server: + msg = f"SPEECH MANAGER: No speech server for factory: {module_name}" + debug.print_message(debug.LEVEL_WARNING, msg, True) + + return server + + @dbus_service.command + def interrupt_speech( + self, + script: default.Script | None = None, + event: input_event.InputEvent | None = None, + notify_user: bool = False, + ) -> bool: + """Interrupts the speech server.""" + + tokens = [ + "SPEECH MANAGER: interrupt_speech. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if server := self._get_server(): + server.stop() + + return True + + @dbus_service.command + def shutdown_speech( + self, + script: default.Script | None = None, + event: input_event.InputEvent | None = None, + notify_user: bool = False, + ) -> bool: + """Shuts down the speech server.""" + + tokens = [ + "SPEECH MANAGER: shutdown_speech. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if server := self._get_server(): + server.shutdown_active_servers() + self._server = None + speech.set_server(None) + + return True + + @dbus_service.command + def refresh_speech( + self, + script: default.Script | None = None, + event: input_event.InputEvent | None = None, + notify_user: bool = False, + ) -> bool: + """Shuts down and re-initializes speech.""" + + tokens = [ + "SPEECH MANAGER: refresh_speech. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self.shutdown_speech() + self.start_speech() + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_ESTABLISHED, + schema="voice", + gtype="b", + default=False, + migration_key="established", + summary="Whether this voice type has been user-customized", + ) + def get_established(self) -> bool: + """Returns whether the current voice type has been customized.""" + + return False + + @gsettings_registry.get_registry().gsetting( + key=KEY_RATE, + schema="voice", + gtype="i", + default=50, + summary="Speech rate (0-100)", + ) + @dbus_service.getter + def get_rate(self) -> int: + """Returns the current speech rate.""" + + return gsettings_registry.get_registry().layered_lookup( + self._VOICE_SCHEMA, + self.KEY_RATE, + "i", + default=50, + ) + + @dbus_service.setter + def set_rate(self, value: int) -> bool: + """Sets the current speech rate (0-100, default: 50).""" + + if not isinstance(value, (int, float)): + return False + + registry = gsettings_registry.get_registry() + registry.set_runtime_value(self._VOICE_SCHEMA, self.KEY_RATE, value) + registry.set_runtime_value( + self._VOICE_SCHEMA, self.KEY_RATE, value, voice_type=speechserver.DEFAULT_VOICE + ) + + msg = f"SPEECH MANAGER: Set rate to: {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + @dbus_service.command + def decrease_rate( + self, + script: default.Script | None = None, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Decreases the speech rate.""" + + tokens = [ + "SPEECH MANAGER: decrease_rate. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + server = self._get_server() + if server is None: + msg = "SPEECH MANAGER: Cannot get speech server." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + server.decrease_speech_rate() + new_rate = max(0, self.get_rate() - 5) + self.set_rate(new_rate) + if notify_user and script is not None: + full = f"{messages.SPEECH_SLOWER} {new_rate}" + presentation_manager.get_manager().present_message(full, str(new_rate)) + + return True + + @dbus_service.command + def increase_rate( + self, + script: default.Script | None = None, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Increases the speech rate.""" + + tokens = [ + "SPEECH MANAGER: increase_rate. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + server = self._get_server() + if server is None: + msg = "SPEECH MANAGER: Cannot get speech server." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + server.increase_speech_rate() + new_rate = min(99, self.get_rate() + 5) + self.set_rate(new_rate) + if notify_user and script is not None: + full = f"{messages.SPEECH_FASTER} {new_rate}" + presentation_manager.get_manager().present_message(full, str(new_rate)) + + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_PITCH, + schema="voice", + gtype="d", + default=5.0, + summary="Speech pitch (0.0-10.0)", + ) + @dbus_service.getter + def get_pitch(self) -> float: + """Returns the current speech pitch.""" + + return gsettings_registry.get_registry().layered_lookup( + self._VOICE_SCHEMA, + self.KEY_PITCH, + "d", + default=5.0, + ) + + @dbus_service.setter + def set_pitch(self, value: float) -> bool: + """Sets the current speech pitch (0.0-10.0, default: 5.0).""" + + if not isinstance(value, (int, float)): + return False + + registry = gsettings_registry.get_registry() + registry.set_runtime_value(self._VOICE_SCHEMA, self.KEY_PITCH, value) + registry.set_runtime_value( + self._VOICE_SCHEMA, self.KEY_PITCH, value, voice_type=speechserver.DEFAULT_VOICE + ) + + msg = f"SPEECH MANAGER: Set pitch to: {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + @dbus_service.command + def decrease_pitch( + self, + script: default.Script | None = None, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Decreases the speech pitch""" + + tokens = [ + "SPEECH MANAGER: decrease_pitch. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + server = self._get_server() + if server is None: + msg = "SPEECH MANAGER: Cannot get speech server." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + server.decrease_speech_pitch() + new_pitch = max(0.0, self.get_pitch() - 0.5) + self.set_pitch(new_pitch) + if notify_user and script is not None: + full = f"{messages.SPEECH_LOWER} {new_pitch:g}" + presentation_manager.get_manager().present_message(full, f"{new_pitch:g}") + + return True + + @dbus_service.command + def increase_pitch( + self, + script: default.Script | None = None, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Increase the speech pitch""" + + tokens = [ + "SPEECH MANAGER: increase_pitch. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + server = self._get_server() + if server is None: + msg = "SPEECH MANAGER: Cannot get speech server." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + server.increase_speech_pitch() + new_pitch = min(9.0, self.get_pitch() + 0.5) + self.set_pitch(new_pitch) + if notify_user and script is not None: + full = f"{messages.SPEECH_HIGHER} {new_pitch:g}" + presentation_manager.get_manager().present_message(full, f"{new_pitch:g}") + + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_VOLUME, + schema="voice", + gtype="d", + default=10.0, + summary="Speech volume (0.0-10.0)", + ) + @dbus_service.getter + def get_volume(self) -> float: + """Returns the current speech volume.""" + + return gsettings_registry.get_registry().layered_lookup( + self._VOICE_SCHEMA, + self.KEY_VOLUME, + "d", + default=10.0, + ) + + @dbus_service.setter + def set_volume(self, value: float) -> bool: + """Sets the current speech volume (0.0-10.0, default: 10.0).""" + + if not isinstance(value, (int, float)): + return False + + registry = gsettings_registry.get_registry() + registry.set_runtime_value(self._VOICE_SCHEMA, self.KEY_VOLUME, value) + registry.set_runtime_value( + self._VOICE_SCHEMA, self.KEY_VOLUME, value, voice_type=speechserver.DEFAULT_VOICE + ) + + msg = f"SPEECH MANAGER: Set volume to: {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + @dbus_service.command + def decrease_volume( + self, + script: default.Script | None = None, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Decreases the speech volume""" + + tokens = [ + "SPEECH MANAGER: decrease_volume. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + server = self._get_server() + if server is None: + msg = "SPEECH MANAGER: Cannot get speech server." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + server.decrease_speech_volume() + new_volume = max(0.0, self.get_volume() - 0.5) + self.set_volume(new_volume) + if notify_user and script is not None: + full = f"{messages.SPEECH_SOFTER} {new_volume:g}" + presentation_manager.get_manager().present_message(full, f"{new_volume:g}") + + return True + + @dbus_service.command + def increase_volume( + self, + script: default.Script | None = None, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Increases the speech volume""" + + tokens = [ + "SPEECH MANAGER: increase_volume. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + server = self._get_server() + if server is None: + msg = "SPEECH MANAGER: Cannot get speech server." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + server.increase_speech_volume() + new_volume = min(9.0, self.get_volume() + 0.5) + self.set_volume(new_volume) + if notify_user and script is not None: + full = f"{messages.SPEECH_LOUDER} {new_volume:g}" + presentation_manager.get_manager().present_message(full, f"{new_volume:g}") + + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_CAPITALIZATION_STYLE, + schema="speech", + genum="org.stormux.Cthulhu.CapitalizationStyle", + default="none", + summary="Capitalization style (none, spell, icon)", + migration_key="capitalizationStyle", + ) + @dbus_service.getter + def get_capitalization_style(self, app_name: str | None = None) -> str: + """Returns the current capitalization style.""" + + value = gsettings_registry.get_registry().layered_lookup( + self.SPEECH_SCHEMA, + self.KEY_CAPITALIZATION_STYLE, + "", + genum="org.stormux.Cthulhu.CapitalizationStyle", + default="none", + app_name=app_name, + ) + return value + + @dbus_service.setter + def set_capitalization_style(self, value: str) -> bool: + """Sets the capitalization style.""" + + try: + style = CapitalizationStyle[value.upper()] + except KeyError: + msg = f"SPEECH MANAGER: Invalid capitalization style: {value}" + debug.print_message(debug.LEVEL_WARNING, msg, True) + return False + + msg = f"SPEECH MANAGER: Setting capitalization style to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self.SPEECH_SCHEMA, + self.KEY_CAPITALIZATION_STYLE, + style.string_name, + ) + self.update_capitalization_style() + return True + + @dbus_service.command + def cycle_capitalization_style( + self, + script: default.Script | None = None, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Cycle through the speech-dispatcher capitalization styles.""" + + tokens = [ + "SPEECH MANAGER: cycle_capitalization_style. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + current_style = self.get_capitalization_style() + if current_style == "none": + self.set_capitalization_style("spell") + full = messages.CAPITALIZATION_SPELL_FULL + brief = messages.CAPITALIZATION_SPELL_BRIEF + elif current_style == "spell": + self.set_capitalization_style("icon") + full = messages.CAPITALIZATION_ICON_FULL + brief = messages.CAPITALIZATION_ICON_BRIEF + else: + self.set_capitalization_style("none") + full = messages.CAPITALIZATION_NONE_FULL + brief = messages.CAPITALIZATION_NONE_BRIEF + + if script is not None and notify_user: + presentation_manager.get_manager().present_message(full, brief) + return True + + def update_capitalization_style(self) -> bool: + """Updates the capitalization style on the speech server.""" + + server = self._get_server() + if server is None: + msg = "SPEECH MANAGER: Cannot get speech server." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + server.update_capitalization_style(self.get_capitalization_style()) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_PUNCTUATION_LEVEL, + schema="speech", + genum="org.stormux.Cthulhu.PunctuationStyle", + default="most", + summary="Punctuation verbosity level (none, some, most, all)", + migration_key="verbalizePunctuationStyle", + ) + @dbus_service.getter + def get_punctuation_level(self, app_name: str | None = None) -> str: + """Returns the current punctuation level.""" + + value = gsettings_registry.get_registry().layered_lookup( + self.SPEECH_SCHEMA, + self.KEY_PUNCTUATION_LEVEL, + "", + genum="org.stormux.Cthulhu.PunctuationStyle", + default="most", + app_name=app_name, + ) + return value + + @dbus_service.setter + def set_punctuation_level(self, value: str) -> bool: + """Sets the punctuation level.""" + + try: + style = PunctuationStyle[value.upper()] + except KeyError: + msg = f"SPEECH MANAGER: Invalid punctuation level: {value}" + debug.print_message(debug.LEVEL_WARNING, msg, True) + return False + + msg = f"SPEECH MANAGER: Setting punctuation level to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self.SPEECH_SCHEMA, + self.KEY_PUNCTUATION_LEVEL, + style.string_name, + ) + self.update_punctuation_level() + return True + + @dbus_service.command + def cycle_punctuation_level( + self, + script: default.Script | None = None, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Cycles through punctuation levels for speech.""" + + tokens = [ + "SPEECH MANAGER: cycle_punctuation_level. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + current_level = self.get_punctuation_level() + if current_level == "none": + self.set_punctuation_level("some") + full = messages.PUNCTUATION_SOME_FULL + brief = messages.PUNCTUATION_SOME_BRIEF + elif current_level == "some": + self.set_punctuation_level("most") + full = messages.PUNCTUATION_MOST_FULL + brief = messages.PUNCTUATION_MOST_BRIEF + elif current_level == "most": + self.set_punctuation_level("all") + full = messages.PUNCTUATION_ALL_FULL + brief = messages.PUNCTUATION_ALL_BRIEF + else: + self.set_punctuation_level("none") + full = messages.PUNCTUATION_NONE_FULL + brief = messages.PUNCTUATION_NONE_BRIEF + + if script is not None and notify_user: + presentation_manager.get_manager().present_message(full, brief) + return True + + def update_punctuation_level(self) -> bool: + """Updates the punctuation level on the speech server.""" + + server = self._get_server() + if server is None: + msg = "SPEECH MANAGER: Cannot get speech server." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + level_str = self.get_punctuation_level() + server.update_punctuation_level(PunctuationStyle[level_str.upper()]) + return True + + def update_synthesizer(self, server_id: str | None = "") -> None: + """Updates the synthesizer to the specified id or value from settings.""" + + server = self._get_server() + if server is None: + msg = "SPEECH MANAGER: Cannot get speech server." + debug.print_message(debug.LEVEL_INFO, msg, True) + return + + active_id = server.get_output_module() + if not server_id: + server_id = gsettings_registry.get_registry().layered_lookup( + self.SPEECH_SCHEMA, + self.KEY_SYNTHESIZER, + "s", + ) + + if server_id and server_id != active_id: + msg = f"SPEECH MANAGER: Updating synthesizer from {active_id} to {server_id}." + debug.print_message(debug.LEVEL_INFO, msg, True) + server.set_output_module(server_id) + + @dbus_service.command + def cycle_synthesizer( + self, + script: default.Script | None = None, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Cycles through available speech synthesizers.""" + + tokens = [ + "SPEECH MANAGER: cycle_synthesizer. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + server = self._get_server() + if server is None: + msg = "SPEECH MANAGER: Cannot get speech server." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + available = server.list_output_modules() + if not available: + msg = "SPEECH MANAGER: Cannot get output modules." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + current = server.get_output_module() + if not current: + msg = "SPEECH MANAGER: Cannot get current output module." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + try: + index = available.index(current) + 1 + if index == len(available): + index = 0 + except ValueError: + index = 0 + + server.set_output_module(available[index]) + if script is not None and notify_user: + presentation_manager.get_manager().present_message(available[index]) + return True + + def get_speech_is_enabled_and_not_muted(self) -> bool: + """Returns whether speech is enabled and not muted.""" + + return self.get_speech_is_enabled() and not self.get_speech_is_muted() + + @dbus_service.getter + def get_speech_is_muted(self) -> bool: + """Returns whether speech output is temporarily muted.""" + + return speech.get_mute_speech() + + @dbus_service.setter + def set_speech_is_muted(self, value: bool) -> bool: + """Sets whether speech output is temporarily muted.""" + + msg = f"SPEECH MANAGER: Setting speech muted to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + speech.set_mute_speech(value) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_ENABLE, + schema="speech", + gtype="b", + default=True, + summary="Enable speech output", + migration_key="enableSpeech", + ) + @dbus_service.getter + def get_speech_is_enabled(self) -> bool: + """Returns whether the speech server is enabled. See also is-muted.""" + + return self._get_setting(self.KEY_ENABLE, "b", True) + + @dbus_service.setter + def set_speech_is_enabled(self, value: bool) -> bool: + """Sets whether the speech server is enabled. See also is-muted.""" + + if value == self.get_speech_is_enabled(): + return True + + msg = f"SPEECH MANAGER: Setting speech enabled to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + + gsettings_registry.get_registry().set_runtime_value( + self.SPEECH_SCHEMA, self.KEY_ENABLE, value + ) + if value: + self.start_speech() + presentation_manager.get_manager().present_message(messages.SPEECH_ENABLED) + else: + presentation_manager.get_manager().present_message(messages.SPEECH_DISABLED) + self.shutdown_speech() + + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_SPEAK_NUMBERS_AS_DIGITS, + schema="speech", + gtype="b", + default=False, + summary="Speak numbers as individual digits", + migration_key="speakNumbersAsDigits", + ) + @dbus_service.getter + def get_speak_numbers_as_digits(self, app_name: str | None = None) -> bool: + """Returns whether numbers are spoken as digits.""" + + return self._get_setting(self.KEY_SPEAK_NUMBERS_AS_DIGITS, "b", False, app_name=app_name) + + @dbus_service.setter + def set_speak_numbers_as_digits(self, value: bool) -> bool: + """Sets whether numbers are spoken as digits.""" + + msg = f"SPEECH MANAGER: Setting speak numbers as digits to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self.SPEECH_SCHEMA, + self.KEY_SPEAK_NUMBERS_AS_DIGITS, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_USE_COLOR_NAMES, + schema="speech", + gtype="b", + default=True, + summary="Use color names instead of values", + migration_key="useColorNames", + ) + @dbus_service.getter + def get_use_color_names(self, app_name: str | None = None) -> bool: + """Returns whether colors are announced by name or as RGB values.""" + + return self._get_setting(self.KEY_USE_COLOR_NAMES, "b", True, app_name=app_name) + + @dbus_service.setter + def set_use_color_names(self, value: bool) -> bool: + """Sets whether colors are announced by name or as RGB values.""" + + msg = f"SPEECH MANAGER: Setting use color names to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self.SPEECH_SCHEMA, + self.KEY_USE_COLOR_NAMES, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_INSERT_PAUSES_BETWEEN_UTTERANCES, + schema="speech", + gtype="b", + default=True, + summary="Insert pauses between utterances", + migration_key="enablePauseBreaks", + ) + @dbus_service.getter + def get_insert_pauses_between_utterances(self, app_name: str | None = None) -> bool: + """Returns whether pauses are inserted between utterances, e.g. between name and role.""" + + return self._get_setting( + self.KEY_INSERT_PAUSES_BETWEEN_UTTERANCES, "b", True, app_name=app_name + ) + + @dbus_service.setter + def set_insert_pauses_between_utterances(self, value: bool) -> bool: + """Sets whether pauses are inserted between utterances, e.g. between name and role.""" + + msg = f"SPEECH MANAGER: Setting insert pauses to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self.SPEECH_SCHEMA, + self.KEY_INSERT_PAUSES_BETWEEN_UTTERANCES, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_USE_PRONUNCIATION_DICTIONARY, + schema="speech", + gtype="b", + default=True, + summary="Apply user pronunciation dictionary", + migration_key="usePronunciationDictionary", + ) + @dbus_service.getter + def get_use_pronunciation_dictionary(self, app_name: str | None = None) -> bool: + """Returns whether the user's pronunciation dictionary should be applied.""" + + return self._get_setting( + self.KEY_USE_PRONUNCIATION_DICTIONARY, "b", True, app_name=app_name + ) + + @dbus_service.setter + def set_use_pronunciation_dictionary(self, value: bool) -> bool: + """Sets whether the user's pronunciation dictionary should be applied.""" + + msg = f"SPEECH MANAGER: Setting use pronunciation dictionary to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self.SPEECH_SCHEMA, + self.KEY_USE_PRONUNCIATION_DICTIONARY, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_AUTO_LANGUAGE_SWITCHING, + schema="speech", + gtype="b", + default=True, + summary="Automatically switch voice based on text language", + migration_key="enableAutoLanguageSwitching", + ) + @dbus_service.getter + def get_auto_language_switching(self, app_name: str | None = None) -> bool: + """Returns whether automatic language switching is enabled.""" + + return self._get_setting(self.KEY_AUTO_LANGUAGE_SWITCHING, "b", True, app_name=app_name) + + @dbus_service.setter + def set_auto_language_switching(self, value: bool) -> bool: + """Sets whether automatic language switching is enabled.""" + + msg = f"SPEECH MANAGER: Setting auto language switching to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self.SPEECH_SCHEMA, + self.KEY_AUTO_LANGUAGE_SWITCHING, + value, + ) + return True + + @dbus_service.command + def toggle_speech( + self, + script: default.Script | None = None, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Toggles speech on and off.""" + + tokens = [ + "SPEECH MANAGER: toggle_speech. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if script is not None: + presentation_manager.get_manager().interrupt_presentation() + if self.get_speech_is_muted(): + self.set_speech_is_muted(False) + if script is not None and notify_user: + presentation_manager.get_manager().present_message(messages.SPEECH_ENABLED) + elif not self.get_speech_is_enabled(): + gsettings_registry.get_registry().set_runtime_value( + self.SPEECH_SCHEMA, self.KEY_ENABLE, True + ) + self._init_server() + if script is not None and notify_user: + presentation_manager.get_manager().present_message(messages.SPEECH_ENABLED) + else: + if script is not None and notify_user: + presentation_manager.get_manager().present_message(messages.SPEECH_DISABLED) + gsettings_registry.get_registry().remove_runtime_value( + self.SPEECH_SCHEMA, self.KEY_ENABLE + ) + if not self.get_speech_is_enabled(): + self.shutdown_speech() + else: + self.set_speech_is_muted(True) + return True + + def create_voices_preferences_grid(self, app_name: str = "") -> VoicesPreferencesGrid: + """Returns the GtkGrid containing the voices preferences UI.""" + + return VoicesPreferencesGrid(self, app_name=app_name) + + +_manager: SpeechManager = SpeechManager() + + +def get_manager() -> SpeechManager: + """Returns the Speech Manager""" + + return _manager diff --git a/src/cthulhu/speech_monitor.py b/src/cthulhu/speech_monitor.py new file mode 100644 index 0000000..8db0dd4 --- /dev/null +++ b/src/cthulhu/speech_monitor.py @@ -0,0 +1,259 @@ +# Cthulhu +# +# Copyright 2026 Igalia, S.L. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +"""Provides a graphical speech monitor, mainly for development tasks.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gdk, Gtk + +from . import guilabels + +if TYPE_CHECKING: + from collections.abc import Callable + +MIN_FONT_SIZE = 8 +MAX_FONT_SIZE = 72 +ZOOM_STEP = 2 + + +class SpeechMonitor(Gtk.Window): # pylint: disable=too-many-instance-attributes + """Displays a GUI speech monitor showing a scrolling log of spoken text.""" + + _shared_css_provider: Gtk.CssProvider | None = None + + # pylint: disable-next=too-many-statements + def __init__( + self, + font_size: int = 14, + foreground: str = "#ffffff", + background: str = "#000000", + on_close: Callable[[], None] | None = None, + ) -> None: + """Create a new SpeechMonitor.""" + + # pylint: disable=no-member + + super().__init__() + self._on_close = on_close + self._font_size = font_size + self._default_font_size = font_size + self._foreground = foreground + self._background = background + self.set_title(guilabels.SPEECH_MONITOR) + self.set_default_size(1000, 400) + self.set_icon_name("cthulhu") + + titlebar = Gtk.Box() + self.set_titlebar(titlebar) + + self._close_icon = Gtk.Image.new_from_icon_name( + "window-close-symbolic", + Gtk.IconSize.LARGE_TOOLBAR, + ) + close_btn = Gtk.Button() + close_btn.set_image(self._close_icon) + close_btn.set_relief(Gtk.ReliefStyle.NONE) + close_btn.connect("clicked", self._on_close_clicked) + + title_label = Gtk.Label(label=guilabels.SPEECH_MONITOR) + title_label.set_halign(Gtk.Align.CENTER) + + drag_bar = Gtk.EventBox() + drag_bar.add(title_label) + drag_bar.connect("button-press-event", self._on_drag_bar_press) + + close_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + close_row.pack_start(drag_bar, True, True, 0) + close_row.pack_end(close_btn, False, False, 0) + + self._text_view = Gtk.TextView() + self._text_view.set_editable(False) + self._text_view.set_cursor_visible(True) + self._text_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + self._text_view.set_left_margin(6) + self._text_view.set_right_margin(6) + self._text_view.set_top_margin(6) + self._text_view.set_bottom_margin(6) + self._buffer = self._text_view.get_buffer() + self._end_mark = self._buffer.create_mark("end", self._buffer.get_end_iter(), False) + self._line_count = 0 + + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scrolled.add(self._text_view) + + border_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + border_box.pack_start(close_row, False, False, 0) + border_box.pack_start(scrolled, True, True, 0) + self.add(border_box) + + self.get_style_context().add_class("speech-monitor") + titlebar.get_style_context().add_class("speech-monitor-titlebar") + title_label.get_style_context().add_class("speech-monitor-title") + close_btn.get_style_context().add_class("speech-monitor-close") + border_box.get_style_context().add_class("speech-monitor-border") + self._text_view.get_style_context().add_class("speech-monitor-text") + + if SpeechMonitor._shared_css_provider is None: + SpeechMonitor._shared_css_provider = Gtk.CssProvider() + Gtk.StyleContext.add_provider_for_screen( + Gdk.Screen.get_default(), # pylint: disable=no-value-for-parameter + SpeechMonitor._shared_css_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + self._apply_css() + + self.set_keep_above(True) + self.set_skip_taskbar_hint(True) + self.set_skip_pager_hint(True) + + self.set_focus(self._text_view) + self.connect("notify::is-active", self._on_window_activated) + self.connect("delete-event", self._on_delete_event) + self.connect("key-press-event", self._on_key_press) + + def _on_window_activated(self, _window: Gtk.Window, _pspec: Any) -> None: + """Grab focus on the text view whenever the window becomes active.""" + + if self.is_active(): + self._text_view.grab_focus() + + def _on_close_clicked(self, _button: Gtk.Button) -> None: + """Handle the close button click.""" + + if self._on_close is not None: + self._on_close() + + def _on_drag_bar_press(self, _widget: Gtk.Widget, event: Gdk.EventButton) -> bool: + """Initiate a window move when the drag bar is clicked.""" + + # pylint: disable=no-member + if event.button == 1: + self.begin_move_drag(event.button, int(event.x_root), int(event.y_root), event.time) + return True + return False + + def _on_delete_event(self, _window: Gtk.Window, _event: Gdk.Event) -> bool: + """Intercept the close request to toggle the monitor off.""" + + if self._on_close is not None: + self._on_close() + return True + + def _on_key_press(self, _window: Gtk.Window, event: Gdk.EventKey) -> bool: + """Handle Ctrl+Plus/Minus/0 for zoom.""" + + if not event.state & Gdk.ModifierType.CONTROL_MASK: + return False + + if event.keyval in (Gdk.KEY_plus, Gdk.KEY_equal, Gdk.KEY_KP_Add): + self._font_size = min(MAX_FONT_SIZE, self._font_size + ZOOM_STEP) + self._apply_css() + return True + if event.keyval in (Gdk.KEY_minus, Gdk.KEY_KP_Subtract): + self._font_size = max(MIN_FONT_SIZE, self._font_size - ZOOM_STEP) + self._apply_css() + return True + if event.keyval in (Gdk.KEY_0, Gdk.KEY_KP_0): + self._font_size = self._default_font_size + self._apply_css() + return True + + return False + + def set_font_size(self, size: int) -> None: + """Updates the font size and reapplies CSS.""" + + self._font_size = size + self._apply_css() + + def reapply_css( + self, + foreground: str | None = None, + background: str | None = None, + ) -> None: + """Reapplies CSS styling (e.g. after color changes).""" + + if foreground is not None: + self._foreground = foreground + if background is not None: + self._background = background + self._apply_css() + + def _apply_css(self) -> None: + """Apply CSS styling for background color, text color, font size, and border.""" + + bg = self._background + fg = self._foreground + close_px = max(16, self._font_size * 2) + css = ( + f".speech-monitor {{ background-color: {bg}; }}\n" + f".speech-monitor-titlebar {{ min-height: 0; padding: 0; margin: 0; " + f"background-color: {bg}; border: none; box-shadow: none; }}\n" + f".speech-monitor decoration {{ box-shadow: none; " + f"border: none; }}\n" + f".speech-monitor-title {{ color: {fg}; font-weight: bold; }}\n" + f".speech-monitor-close {{ min-height: {close_px}px; min-width: {close_px}px; " + f"padding: 2px; margin: 0; color: {fg}; }}\n" + f".speech-monitor-close:hover {{ opacity: 0.7; }}\n" + f".speech-monitor-border {{ border: 3px solid {fg}; }}\n" + f".speech-monitor-text {{ font-size: {self._font_size}pt; }}\n" + f".speech-monitor-text text {{ color: {fg}; background-color: {bg}; }}" + ) + self._close_icon.set_pixel_size(close_px) + if SpeechMonitor._shared_css_provider is not None: + SpeechMonitor._shared_css_provider.load_from_data(css.encode()) + + def clear(self) -> None: + """Clears the speech monitor display.""" + + self._buffer.set_text("") + self._line_count = 0 + + def _append_text(self, text: str) -> None: + """Appends text, trims excess lines, and scrolls to the bottom.""" + + self._buffer.insert(self._buffer.get_end_iter(), text) + self._line_count += text.count("\n") + if self._line_count > 500: + excess = self._line_count - 500 + start = self._buffer.get_start_iter() + cut_point = self._buffer.get_iter_at_line(excess) + self._buffer.delete(start, cut_point) + self._line_count = 500 + + self._buffer.place_cursor(self._buffer.get_end_iter()) + self._text_view.scroll_mark_onscreen(self._end_mark) + + def write_text(self, text: str) -> None: + """Appends spoken text to the monitor.""" + + self._append_text(f"{text}\n") + + def write_key_event(self, key_description: str) -> None: + """Appends a formatted key event entry to the monitor.""" + + self._append_text(f"[{key_description}]\n") diff --git a/src/cthulhu/speech_presenter.py b/src/cthulhu/speech_presenter.py new file mode 100644 index 0000000..1f6c8b2 --- /dev/null +++ b/src/cthulhu/speech_presenter.py @@ -0,0 +1,2788 @@ +# Cthulhu +# +# 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 +# pylint: disable=too-many-arguments +# pylint: disable=too-many-positional-arguments +# pylint: disable=too-many-instance-attributes +# pylint: disable=too-many-locals +# pylint: disable=too-many-branches + +"""Configures verbosity settings and adjusts strings for speech presentation.""" + +from __future__ import annotations + +import re +import string +import time +from dataclasses import dataclass +from enum import Enum +from typing import TYPE_CHECKING, Any + +import gi + +gi.require_version("Gtk", "3.0") + +from . import ( + cmdnames, + command_manager, + dbus_service, + debug, + document_presenter, + focus_manager, + gsettings_registry, + guilabels, + input_event, + keybindings, + mathsymbols, + messages, + object_properties, + phonnames, + preferences_grid_base, + presentation_manager, + pronunciation_dictionary_manager, + say_all_presenter, + speech, + speech_manager, + speech_monitor, + speechserver, +) +from .ax_document import AXDocument +from .ax_hypertext import AXHypertext +from .ax_text import AXText +from .ax_utilities import AXUtilities + +if TYPE_CHECKING: + from collections.abc import Callable, Iterable + + from .generator import WhereAmI + from .speech_generator import SpeechGeneratorContext + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi, Gio + + from .acss import ACSS + from .input_event import KeyboardEvent + from .scripts import default + + +class VerbosityLevel(Enum): + """Verbosity level enumeration.""" + + BRIEF = 0 + VERBOSE = 1 + + @property + def string_name(self) -> str: + """Returns the lowercase string name for this enum value.""" + + return self.name.lower() + + +@gsettings_registry.get_registry().gsettings_enum( + "org.stormux.Cthulhu.ProgressBarVerbosity", + values={"all": 0, "application": 1, "window": 2}, +) +class ProgressBarVerbosity(Enum): + """Progress bar verbosity level enumeration.""" + + ALL = 0 + APPLICATION = 1 + WINDOW = 2 + + +@dataclass(frozen=True) +class SpeechPreference: + """Descriptor for a single preference.""" + + prefs_key: str + label: str + getter: Callable[[], bool] + setter: Callable[[bool], bool] + + +class AnnouncementsPreferencesGrid(preferences_grid_base.AutoPreferencesGrid): + """GtkGrid containing the Container Announcements preferences page.""" + + def __init__(self, presenter: SpeechPresenter) -> None: + ( + _general_prefs, + _object_details_prefs, + announcements_prefs, + ) = presenter.get_speech_preferences() + + controls = [ + preferences_grid_base.BooleanPreferenceControl( + label=announcements_prefs[0].label, + getter=announcements_prefs[0].getter, + setter=announcements_prefs[0].setter, + prefs_key=announcements_prefs[0].prefs_key, + member_of=guilabels.ANNOUNCE_WHEN_ENTERING, + ), + preferences_grid_base.BooleanPreferenceControl( + label=announcements_prefs[1].label, + getter=announcements_prefs[1].getter, + setter=announcements_prefs[1].setter, + prefs_key=announcements_prefs[1].prefs_key, + member_of=guilabels.ANNOUNCE_WHEN_ENTERING, + ), + preferences_grid_base.BooleanPreferenceControl( + label=announcements_prefs[2].label, + getter=announcements_prefs[2].getter, + setter=announcements_prefs[2].setter, + prefs_key=announcements_prefs[2].prefs_key, + member_of=guilabels.ANNOUNCE_WHEN_ENTERING, + ), + preferences_grid_base.BooleanPreferenceControl( + label=announcements_prefs[3].label, + getter=announcements_prefs[3].getter, + setter=announcements_prefs[3].setter, + prefs_key=announcements_prefs[3].prefs_key, + member_of=guilabels.ANNOUNCE_WHEN_ENTERING, + ), + preferences_grid_base.BooleanPreferenceControl( + label=announcements_prefs[4].label, + getter=announcements_prefs[4].getter, + setter=announcements_prefs[4].setter, + prefs_key=announcements_prefs[4].prefs_key, + member_of=guilabels.ANNOUNCE_WHEN_ENTERING, + ), + preferences_grid_base.BooleanPreferenceControl( + label=announcements_prefs[5].label, + getter=announcements_prefs[5].getter, + setter=announcements_prefs[5].setter, + prefs_key=announcements_prefs[5].prefs_key, + member_of=guilabels.ANNOUNCE_WHEN_ENTERING, + ), + ] + + super().__init__(guilabels.ANNOUNCEMENTS, controls) + + +class ProgressBarsPreferencesGrid(preferences_grid_base.AutoPreferencesGrid): + """GtkGrid containing the Progress Bars preferences page.""" + + def __init__(self, presenter: SpeechPresenter) -> None: + controls: list[preferences_grid_base.ControlType] = [ + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.GENERAL_SPEAK_UPDATES, + getter=presenter.get_speak_progress_bar_updates, + setter=presenter.set_speak_progress_bar_updates, + prefs_key=SpeechPresenter.KEY_SPEAK_PROGRESS_BAR_UPDATES, + ), + preferences_grid_base.IntRangePreferenceControl( + label=guilabels.GENERAL_FREQUENCY_SECS, + getter=presenter.get_progress_bar_speech_interval, + setter=presenter.set_progress_bar_speech_interval, + prefs_key=SpeechPresenter.KEY_PROGRESS_BAR_SPEECH_INTERVAL, + minimum=0, + maximum=100, + ), + preferences_grid_base.EnumPreferenceControl( + label=guilabels.GENERAL_APPLIES_TO, + getter=presenter.get_progress_bar_speech_verbosity, + setter=presenter.set_progress_bar_speech_verbosity, + prefs_key=SpeechPresenter.KEY_PROGRESS_BAR_SPEECH_VERBOSITY, + options=[ + guilabels.PROGRESS_BAR_ALL, + guilabels.PROGRESS_BAR_APPLICATION, + guilabels.PROGRESS_BAR_WINDOW, + ], + values=[ + ProgressBarVerbosity.ALL.value, + ProgressBarVerbosity.APPLICATION.value, + ProgressBarVerbosity.WINDOW.value, + ], + ), + ] + + super().__init__(guilabels.PROGRESS_BARS, controls) + + +class VerbosityPreferencesGrid(preferences_grid_base.AutoPreferencesGrid): + """GtkGrid containing the Verbosity preferences page.""" + + def __init__(self, presenter: SpeechPresenter) -> None: + self._presenter = presenter + ( + general_prefs, + object_details_prefs, + _announcements_prefs, + ) = presenter.get_speech_preferences() + + text_speak_blank_lines = SpeechPreference( + SpeechPresenter.KEY_SPEAK_BLANK_LINES, + guilabels.SPEECH_SPEAK_BLANK_LINES, + presenter.get_speak_blank_lines, + presenter.set_speak_blank_lines, + ) + text_speak_misspelled = SpeechPreference( + SpeechPresenter.KEY_SPEAK_MISSPELLED_INDICATOR, + guilabels.SPEECH_SPEAK_MISSPELLED_WORD_INDICATOR, + presenter.get_speak_misspelled_indicator, + presenter.set_speak_misspelled_indicator, + ) + text_speak_indentation = SpeechPreference( + SpeechPresenter.KEY_SPEAK_INDENTATION_AND_JUSTIFICATION, + guilabels.SPEECH_SPEAK_INDENTATION_AND_JUSTIFICATION, + presenter.get_speak_indentation_and_justification, + presenter.set_speak_indentation_and_justification, + ) + text_indentation_only_if_changed = SpeechPreference( + SpeechPresenter.KEY_SPEAK_INDENTATION_ONLY_IF_CHANGED, + guilabels.SPEECH_INDENTATION_ONLY_IF_CHANGED, + presenter.get_speak_indentation_only_if_changed, + presenter.set_speak_indentation_only_if_changed, + ) + + self._only_speak_displayed_control = preferences_grid_base.BooleanPreferenceControl( + label=object_details_prefs[0].label, + getter=object_details_prefs[0].getter, + setter=object_details_prefs[0].setter, + prefs_key=object_details_prefs[0].prefs_key, + member_of=guilabels.SPEECH_OBJECT_DETAILS, + ) + + self._enable_indentation_control = preferences_grid_base.BooleanPreferenceControl( + label=text_speak_indentation.label, + getter=text_speak_indentation.getter, + setter=text_speak_indentation.setter, + prefs_key=text_speak_indentation.prefs_key, + member_of=guilabels.SPEECH_OBJECT_DETAILS, + determine_sensitivity=self._only_speak_displayed_text_is_off, + ) + + controls = [ + preferences_grid_base.BooleanPreferenceControl( + label=general_prefs[0].label, + getter=general_prefs[0].getter, + setter=general_prefs[0].setter, + prefs_key=general_prefs[0].prefs_key, + member_of=guilabels.GENERAL, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.OBJECT_PRESENTATION_IS_DETAILED, + getter=presenter._get_verbosity_is_verbose, + setter=presenter._set_verbosity_from_bool, + member_of=guilabels.GENERAL, + ), + self._only_speak_displayed_control, + preferences_grid_base.BooleanPreferenceControl( + label=object_details_prefs[1].label, + getter=object_details_prefs[1].getter, + setter=object_details_prefs[1].setter, + prefs_key=object_details_prefs[1].prefs_key, + member_of=guilabels.SPEECH_OBJECT_DETAILS, + determine_sensitivity=self._only_speak_displayed_text_is_off, + ), + preferences_grid_base.BooleanPreferenceControl( + label=object_details_prefs[2].label, + getter=object_details_prefs[2].getter, + setter=object_details_prefs[2].setter, + prefs_key=object_details_prefs[2].prefs_key, + member_of=guilabels.SPEECH_OBJECT_DETAILS, + determine_sensitivity=self._only_speak_displayed_text_is_off, + ), + preferences_grid_base.BooleanPreferenceControl( + label=object_details_prefs[3].label, + getter=object_details_prefs[3].getter, + setter=object_details_prefs[3].setter, + prefs_key=object_details_prefs[3].prefs_key, + member_of=guilabels.SPEECH_OBJECT_DETAILS, + determine_sensitivity=self._only_speak_displayed_text_is_off, + ), + preferences_grid_base.BooleanPreferenceControl( + label=object_details_prefs[4].label, + getter=object_details_prefs[4].getter, + setter=object_details_prefs[4].setter, + prefs_key=object_details_prefs[4].prefs_key, + member_of=guilabels.SPEECH_OBJECT_DETAILS, + determine_sensitivity=self._only_speak_displayed_text_is_off, + ), + preferences_grid_base.BooleanPreferenceControl( + label=text_speak_blank_lines.label, + getter=text_speak_blank_lines.getter, + setter=text_speak_blank_lines.setter, + prefs_key=text_speak_blank_lines.prefs_key, + member_of=guilabels.SPEECH_OBJECT_DETAILS, + determine_sensitivity=self._only_speak_displayed_text_is_off, + ), + preferences_grid_base.BooleanPreferenceControl( + label=text_speak_misspelled.label, + getter=text_speak_misspelled.getter, + setter=text_speak_misspelled.setter, + prefs_key=text_speak_misspelled.prefs_key, + member_of=guilabels.SPEECH_OBJECT_DETAILS, + determine_sensitivity=self._only_speak_displayed_text_is_off, + ), + self._enable_indentation_control, + preferences_grid_base.BooleanPreferenceControl( + label=text_indentation_only_if_changed.label, + getter=text_indentation_only_if_changed.getter, + setter=text_indentation_only_if_changed.setter, + prefs_key=text_indentation_only_if_changed.prefs_key, + member_of=guilabels.SPEECH_OBJECT_DETAILS, + determine_sensitivity=self._indentation_enabled, + ), + ] + + super().__init__(guilabels.VERBOSITY, controls) + + def save_settings(self, profile: str = "", app_name: str = "") -> dict[str, Any]: + """Save settings, writing the verbosity-level enum from the presenter.""" + + result = super().save_settings(profile, app_name) + result[SpeechPresenter.KEY_VERBOSITY_LEVEL] = self._presenter.get_verbosity_level() + return result + + def _only_speak_displayed_text_is_off(self) -> bool: + """Returns True if only-speak-displayed-text is off in the UI.""" + + only_displayed_widget = self.get_widget_for_control(self._only_speak_displayed_control) + if only_displayed_widget: + return not only_displayed_widget.get_active() + return True + + def _indentation_enabled(self) -> bool: + """Check if speak indentation is enabled in the UI (widget state, not settings).""" + + if not self._only_speak_displayed_text_is_off(): + return False + widget = self.get_widget_for_control(self._enable_indentation_control) + return widget.get_active() if widget else True + + +class TablesPreferencesGrid(preferences_grid_base.AutoPreferencesGrid): + """GtkGrid containing the Tables preferences page.""" + + def __init__(self, presenter: SpeechPresenter) -> None: + # Table preferences + table_gui_rows = SpeechPreference( + SpeechPresenter.KEY_SPEAK_ROW_IN_GUI_TABLE, + guilabels.SPEECH_SPEAK_FULL_ROW_IN_GUI_TABLES, + presenter.get_speak_row_in_gui_table, + presenter.set_speak_row_in_gui_table, + ) + table_doc_rows = SpeechPreference( + SpeechPresenter.KEY_SPEAK_ROW_IN_DOCUMENT_TABLE, + guilabels.SPEECH_SPEAK_FULL_ROW_IN_DOCUMENT_TABLES, + presenter.get_speak_row_in_document_table, + presenter.set_speak_row_in_document_table, + ) + table_spreadsheet_rows = SpeechPreference( + SpeechPresenter.KEY_SPEAK_ROW_IN_SPREADSHEET, + guilabels.SPEECH_SPEAK_FULL_ROW_IN_SPREADSHEETS, + presenter.get_speak_row_in_spreadsheet, + presenter.set_speak_row_in_spreadsheet, + ) + table_cell_headers = SpeechPreference( + SpeechPresenter.KEY_ANNOUNCE_CELL_HEADERS, + guilabels.TABLE_SPEAK_CELL_HEADER, + presenter.get_announce_cell_headers, + presenter.set_announce_cell_headers, + ) + table_cell_coords = SpeechPreference( + SpeechPresenter.KEY_ANNOUNCE_CELL_COORDINATES, + guilabels.TABLE_SPEAK_CELL_COORDINATES, + presenter.get_announce_cell_coordinates, + presenter.set_announce_cell_coordinates, + ) + table_spreadsheet_coords = SpeechPreference( + SpeechPresenter.KEY_ANNOUNCE_SPREADSHEET_CELL_COORDINATES, + guilabels.SPREADSHEET_SPEAK_CELL_COORDINATES, + presenter.get_announce_spreadsheet_cell_coordinates, + presenter.set_announce_spreadsheet_cell_coordinates, + ) + table_cell_span = SpeechPreference( + SpeechPresenter.KEY_ANNOUNCE_CELL_SPAN, + guilabels.TABLE_SPEAK_CELL_SPANS, + presenter.get_announce_cell_span, + presenter.set_announce_cell_span, + ) + table_selected_range = SpeechPreference( + SpeechPresenter.KEY_ALWAYS_ANNOUNCE_SELECTED_RANGE_IN_SPREADSHEET, + guilabels.SPREADSHEET_SPEAK_SELECTED_RANGE, + presenter.get_always_announce_selected_range_in_spreadsheet, + presenter.set_always_announce_selected_range_in_spreadsheet, + ) + + controls = [ + preferences_grid_base.BooleanPreferenceControl( + label=table_gui_rows.label, + getter=table_gui_rows.getter, + setter=table_gui_rows.setter, + prefs_key=table_gui_rows.prefs_key, + member_of=guilabels.TABLE_ROW_NAVIGATION, + ), + preferences_grid_base.BooleanPreferenceControl( + label=table_doc_rows.label, + getter=table_doc_rows.getter, + setter=table_doc_rows.setter, + prefs_key=table_doc_rows.prefs_key, + member_of=guilabels.TABLE_ROW_NAVIGATION, + ), + preferences_grid_base.BooleanPreferenceControl( + label=table_spreadsheet_rows.label, + getter=table_spreadsheet_rows.getter, + setter=table_spreadsheet_rows.setter, + prefs_key=table_spreadsheet_rows.prefs_key, + member_of=guilabels.TABLE_ROW_NAVIGATION, + ), + preferences_grid_base.BooleanPreferenceControl( + label=table_cell_headers.label, + getter=table_cell_headers.getter, + setter=table_cell_headers.setter, + prefs_key=table_cell_headers.prefs_key, + member_of=guilabels.TABLE_CELL_NAVIGATION, + ), + preferences_grid_base.BooleanPreferenceControl( + label=table_cell_coords.label, + getter=table_cell_coords.getter, + setter=table_cell_coords.setter, + prefs_key=table_cell_coords.prefs_key, + member_of=guilabels.TABLE_CELL_NAVIGATION, + ), + preferences_grid_base.BooleanPreferenceControl( + label=table_spreadsheet_coords.label, + getter=table_spreadsheet_coords.getter, + setter=table_spreadsheet_coords.setter, + prefs_key=table_spreadsheet_coords.prefs_key, + member_of=guilabels.TABLE_CELL_NAVIGATION, + ), + preferences_grid_base.BooleanPreferenceControl( + label=table_cell_span.label, + getter=table_cell_span.getter, + setter=table_cell_span.setter, + prefs_key=table_cell_span.prefs_key, + member_of=guilabels.TABLE_CELL_NAVIGATION, + ), + preferences_grid_base.BooleanPreferenceControl( + label=table_selected_range.label, + getter=table_selected_range.getter, + setter=table_selected_range.setter, + prefs_key=table_selected_range.prefs_key, + member_of=guilabels.TABLE_CELL_NAVIGATION, + ), + ] + + super().__init__(guilabels.TABLES, controls) + + +class SpeechOSDPreferencesGrid(preferences_grid_base.AutoPreferencesGrid): + """GtkGrid containing the speech on-screen display preferences page.""" + + def __init__(self, presenter: SpeechPresenter) -> None: + controls: list[preferences_grid_base.ControlType] = [ + preferences_grid_base.IntRangePreferenceControl( + label=guilabels.SPEECH_MONITOR_FONT_SIZE, + getter=presenter.get_monitor_font_size, + setter=presenter.set_monitor_font_size, + prefs_key=SpeechPresenter.KEY_MONITOR_FONT_SIZE, + minimum=8, + maximum=72, + apply_immediately=True, + ), + preferences_grid_base.ColorPreferenceControl( + label=guilabels.SPEECH_MONITOR_FOREGROUND, + getter=presenter.get_monitor_foreground, + setter=presenter.set_monitor_foreground, + prefs_key=SpeechPresenter.KEY_MONITOR_FOREGROUND, + ), + preferences_grid_base.ColorPreferenceControl( + label=guilabels.SPEECH_MONITOR_BACKGROUND, + getter=presenter.get_monitor_background, + setter=presenter.set_monitor_background, + prefs_key=SpeechPresenter.KEY_MONITOR_BACKGROUND, + ), + ] + + super().__init__( + guilabels.ON_SCREEN_DISPLAY, + controls, + info_message=guilabels.SPEECH_MONITOR_INFO, + ) + + +class SpeechPreferencesGrid(preferences_grid_base.PreferencesGridBase): + """Main speech preferences grid with enable toggle and categorized settings.""" + + _VOICE_PROPERTY_MAP = ( + ("rate", "rate", "i", 50), + ("average-pitch", "pitch", "d", 5.0), + ("gain", "volume", "d", 10.0), + ("established", "established", "b", False), + ) + + _VOICE_FAMILY_MAP = ( + ("name", "family-name"), + ("lang", "family-lang"), + ("dialect", "family-dialect"), + ("gender", "family-gender"), + ("variant", "family-variant"), + ) + + def __init__( + self, + presenter: SpeechPresenter, + title_change_callback: Callable[[str], None] | None = None, + app_name: str = "", + ) -> None: + super().__init__(guilabels.SPEECH) + self._presenter = presenter + self._initializing = True + self._title_change_callback = title_change_callback + + manager = speech_manager.get_manager() + + # Create child grids (but don't attach them yet - they'll go in the stack detail) + self._voices_grid = manager.create_voices_preferences_grid(app_name=app_name) + self._verbosity_grid = VerbosityPreferencesGrid(presenter) + self._tables_grid = TablesPreferencesGrid(presenter) + self._progress_bars_grid = ProgressBarsPreferencesGrid(presenter) + self._announcements_grid = AnnouncementsPreferencesGrid(presenter) + self._osd_grid = SpeechOSDPreferencesGrid(presenter) + + self._build() + self._initializing = False + + def _build(self) -> None: + row = 0 + + manager = speech_manager.get_manager() + + categories = [ + (guilabels.VOICE, "voice", self._voices_grid), + (guilabels.VERBOSITY, "verbosity", self._verbosity_grid), + (guilabels.TABLES, "tables", self._tables_grid), + (guilabels.PROGRESS_BARS, "progress-bars", self._progress_bars_grid), + (guilabels.ANNOUNCEMENTS, "announcements", self._announcements_grid), + (guilabels.ON_SCREEN_DISPLAY, "osd", self._osd_grid), + ] + + enable_listbox, stack, _categories_listbox = self._create_multi_page_stack( + enable_label=guilabels.SPEECH_ENABLE_SPEECH, + enable_getter=manager.get_speech_is_enabled, + enable_setter=manager.set_speech_is_enabled, + categories=categories, + title_change_callback=self._title_change_callback, + main_title=guilabels.SPEECH, + ) + + self.attach(enable_listbox, 0, row, 1, 1) + row += 1 + self.attach(stack, 0, row, 1, 1) + + def on_becoming_visible(self) -> None: + """Reset to the categories view when this grid becomes visible.""" + + self.multipage_on_becoming_visible() + + def reload(self) -> None: + """Reload all child grids.""" + + self._initializing = True + self._has_unsaved_changes = False + self._voices_grid.reload() + self._verbosity_grid.reload() + self._tables_grid.reload() + self._progress_bars_grid.reload() + self._announcements_grid.reload() + self._osd_grid.reload() + self._initializing = False + + def _save_voice(self, voice_gs: Gio.Settings, voice_data: dict, skip_defaults: bool) -> None: + """Save voice properties and family for a profile.""" + + for acss_key, gs_key, gs_type, default in self._VOICE_PROPERTY_MAP: + if acss_key not in voice_data: + continue + value = voice_data[acss_key] + if skip_defaults: + if gs_type == "i" and int(value) == default: + continue + if gs_type == "d" and float(value) == default: + continue + if gs_type == "b" and bool(value) == default: + continue + if gs_type == "i": + voice_gs.set_int(gs_key, int(value)) + elif gs_type == "d": + voice_gs.set_double(gs_key, float(value)) + else: + voice_gs.set_boolean(gs_key, bool(value)) + + family = voice_data.get("family", {}) + if isinstance(family, dict): + for json_field, gs_key in self._VOICE_FAMILY_MAP: + val = family.get(json_field) + if val is not None and str(val): + voice_gs.set_string(gs_key, str(val)) + + def _save_app_voice( + self, + voice_gs: Gio.Settings, + voice_data: dict, + profile_voice_gs: Gio.Settings, + default_voice_gs: Gio.Settings | None, + ) -> None: + """Save voice properties for an app, only writing genuine overrides.""" + + for acss_key, gs_key, gs_type, _default in self._VOICE_PROPERTY_MAP: + if acss_key not in voice_data: + continue + value = voice_data[acss_key] + profile_value = self._get_effective_voice_value( + gs_key, + profile_voice_gs, + default_voice_gs, + _default, + ) + if gs_type == "i": + matches = int(value) == profile_value + elif gs_type == "d": + matches = float(value) == profile_value + else: + matches = bool(value) == profile_value + if matches: + voice_gs.reset(gs_key) + elif gs_type == "i": + voice_gs.set_int(gs_key, int(value)) + elif gs_type == "d": + voice_gs.set_double(gs_key, float(value)) + else: + voice_gs.set_boolean(gs_key, bool(value)) + + family = voice_data.get("family", {}) + if isinstance(family, dict): + for json_field, gs_key in self._VOICE_FAMILY_MAP: + val = family.get(json_field) + if val is None or not str(val): + continue + profile_val = self._get_effective_voice_value( + gs_key, + profile_voice_gs, + default_voice_gs, + "", + ) + if str(val) == profile_val: + voice_gs.reset(gs_key) + else: + voice_gs.set_string(gs_key, str(val)) + + @staticmethod + def _get_effective_voice_value( + gs_key: str, + profile_gs: Gio.Settings, + default_gs: Gio.Settings | None, + fallback: Any, + ) -> Any: + """Returns the effective profile voice value, checking default profile if needed.""" + + if (val := profile_gs.get_user_value(gs_key)) is not None: + return val.unpack() + if default_gs is not None and (val := default_gs.get_user_value(gs_key)) is not None: + return val.unpack() + return fallback + + def save_settings(self, profile: str = "", app_name: str = "") -> dict: + """Save all settings from child grids.""" + + assert self._multipage_enable_switch is not None + result: dict[str, Any] = {} + result["enable"] = self._multipage_enable_switch.get_active() + result.update(self._voices_grid.save_settings()) + result.update(self._verbosity_grid.save_settings()) + result.update(self._tables_grid.save_settings()) + result.update(self._progress_bars_grid.save_settings()) + result.update(self._announcements_grid.save_settings()) + result.update(self._osd_grid.save_settings()) + + if profile: + registry = gsettings_registry.get_registry() + p = registry.sanitize_gsettings_path(profile) + skip = not app_name and profile == "default" + + # For app saves, remove synthesizer from result before save_schema + # so it doesn't get written to the app dconf path unconditionally. + # save_schema writes all matched keys, which would shadow the + # profile-level synthesizer even when the user didn't change it. + app_synth = result.pop("synthesizer", None) if app_name else None + app_server = result.pop("speech-server", None) if app_name else None + + registry.save_schema("speech", result, p, app_name, skip) + + voices = result.get("voices", {}) + for voice_type, voice_data in voices.items(): + if not voice_data: + continue + vt = registry.sanitize_gsettings_path(voice_type) + voice_gs = registry.get_settings("voice", p, f"voices/{vt}", app_name) + if voice_gs is None: + continue + if app_name: + profile_voice_gs = registry.get_settings("voice", p, f"voices/{vt}") + if profile_voice_gs is None: + continue + default_voice_gs = None + if p != "default": + default_voice_gs = registry.get_settings( + "voice", + "default", + f"voices/{vt}", + ) + self._save_app_voice( + voice_gs, + voice_data, + profile_voice_gs, + default_voice_gs, + ) + else: + self._save_voice(voice_gs, voice_data, skip) + + if app_name and app_synth is not None: + profile_synth = registry.layered_lookup("speech", "synthesizer", "s") + if speech_gs := registry.get_settings("speech", p, "speech", app_name): + if app_synth != profile_synth: + speech_gs.set_string("synthesizer", app_synth) + speech_gs.set_string("speech-server", app_server or "") + elif speech_gs.get_user_value("synthesizer") is not None: + speech_gs.reset("synthesizer") + speech_gs.reset("speech-server") + + return result + + def has_changes(self) -> bool: + """Return True if there are unsaved changes.""" + + return ( + self._has_unsaved_changes + or self._voices_grid.has_changes() + or self._verbosity_grid.has_changes() + or self._tables_grid.has_changes() + or self._progress_bars_grid.has_changes() + or self._announcements_grid.has_changes() + or self._osd_grid.has_changes() + ) + + def refresh(self) -> None: + """Refresh all child grids.""" + + self._initializing = True + self._voices_grid.refresh() + self._verbosity_grid.refresh() + self._tables_grid.refresh() + self._progress_bars_grid.refresh() + self._announcements_grid.refresh() + self._osd_grid.refresh() + self._initializing = False + + +@gsettings_registry.get_registry().gsettings_schema("org.stormux.Cthulhu.Speech", name="speech") +class SpeechPresenter: + """Configures verbosity settings and adjusts strings for speech presentation.""" + + _SCHEMA = "speech" + + KEY_SPEAK_MISSPELLED_INDICATOR = "speak-misspelled-indicator" + KEY_SPEAK_DESCRIPTION = "speak-description" + KEY_SPEAK_POSITION_IN_SET = "speak-position-in-set" + KEY_SPEAK_WIDGET_MNEMONIC = "speak-widget-mnemonic" + KEY_SPEAK_TUTORIAL_MESSAGES = "speak-tutorial-messages" + KEY_REPEATED_CHARACTER_LIMIT = "repeated-character-limit" + KEY_SPEAK_BLANK_LINES = "speak-blank-lines" + KEY_SPEAK_ROW_IN_GUI_TABLE = "speak-row-in-gui-table" + KEY_SPEAK_ROW_IN_DOCUMENT_TABLE = "speak-row-in-document-table" + KEY_SPEAK_ROW_IN_SPREADSHEET = "speak-row-in-spreadsheet" + KEY_ANNOUNCE_CELL_SPAN = "announce-cell-span" + KEY_ANNOUNCE_CELL_COORDINATES = "announce-cell-coordinates" + KEY_ANNOUNCE_SPREADSHEET_CELL_COORDINATES = "announce-spreadsheet-cell-coordinates" + KEY_ALWAYS_ANNOUNCE_SELECTED_RANGE_IN_SPREADSHEET = ( + "always-announce-selected-range-in-spreadsheet" + ) + KEY_ANNOUNCE_CELL_HEADERS = "announce-cell-headers" + KEY_ANNOUNCE_BLOCKQUOTE = "announce-blockquote" + KEY_ANNOUNCE_FORM = "announce-form" + KEY_ANNOUNCE_GROUPING = "announce-grouping" + KEY_ANNOUNCE_LANDMARK = "announce-landmark" + KEY_ANNOUNCE_LIST = "announce-list" + KEY_ANNOUNCE_TABLE = "announce-table" + KEY_ONLY_SPEAK_DISPLAYED_TEXT = "only-speak-displayed-text" + KEY_SPEAK_PROGRESS_BAR_UPDATES = "speak-progress-bar-updates" + KEY_PROGRESS_BAR_SPEECH_INTERVAL = "progress-bar-speech-interval" + KEY_PROGRESS_BAR_SPEECH_VERBOSITY = "progress-bar-speech-verbosity" + KEY_MESSAGES_ARE_DETAILED = "messages-are-detailed" + KEY_VERBOSITY_LEVEL = "verbosity-level" + KEY_SPEAK_INDENTATION_AND_JUSTIFICATION = "speak-indentation-and-justification" + KEY_SPEAK_INDENTATION_ONLY_IF_CHANGED = "speak-indentation-only-if-changed" + KEY_MONITOR_FONT_SIZE = "monitor-font-size" + KEY_MONITOR_FOREGROUND = "monitor-foreground" + KEY_MONITOR_BACKGROUND = "monitor-background" + + def _get_setting(self, key: str, gtype: str, default: Any) -> Any: + """Returns the dconf value for key, or default if not in dconf.""" + + return gsettings_registry.get_registry().layered_lookup( + self._SCHEMA, + key, + gtype, + default=default, + ) + + def __init__(self) -> None: + self._last_indentation_description: str = "" + self._last_error_description: str = "" + self._initialized: bool = False + self._monitor: speech_monitor.SpeechMonitor | None = None + self._monitor_enabled_override: bool | None = None + self._speech_history: list[tuple[str, str]] = [] + self._group_buffer: list[str] | None = None + self._progress_bar_cache: dict = {} + + msg = "SPEECH PRESENTER: Registering D-Bus commands." + debug.print_message(debug.LEVEL_INFO, msg, True) + controller = dbus_service.get_remote_controller() + controller.register_decorated_module("SpeechPresenter", self) + + def set_up_commands(self) -> None: + """Sets up commands with CommandManager.""" + + if self._initialized: + return + self._initialized = True + + speech.set_monitor_callbacks( + write_text=self.write_to_monitor, + write_key=self.write_key_to_monitor, + begin_group=self._begin_monitor_group, + end_group=self._end_monitor_group, + ) + + manager = command_manager.get_manager() + group_label = guilabels.KB_GROUP_SPEECH_VERBOSITY + + # Common keybindings (same for desktop and laptop) + kb_v = keybindings.KeyBinding("v", keybindings.CTHULHU_MODIFIER_MASK) + kb_f11 = keybindings.KeyBinding("F11", keybindings.CTHULHU_MODIFIER_MASK) + kb_shift_d = keybindings.KeyBinding("d", keybindings.CTHULHU_SHIFT_MODIFIER_MASK) + + # (name, function, description, desktop_kb, laptop_kb) + commands_data = [ + ( + "changeNumberStyleHandler", + self.change_number_style, + cmdnames.CHANGE_NUMBER_STYLE, + None, + None, + ), + ( + "toggleSpeechVerbosityHandler", + self.toggle_verbosity, + cmdnames.TOGGLE_SPEECH_VERBOSITY, + kb_v, + kb_v, + ), + ( + "toggleSpeakingIndentationJustificationHandler", + self.toggle_indentation_and_justification, + cmdnames.TOGGLE_SPOKEN_INDENTATION_AND_JUSTIFICATION, + None, + None, + ), + ( + "toggleTableCellReadModeHandler", + self.toggle_table_cell_reading_mode, + cmdnames.TOGGLE_TABLE_CELL_READ_MODE, + kb_f11, + kb_f11, + ), + ( + "toggle_speech_monitor", + self.toggle_monitor, + cmdnames.TOGGLE_SPEECH_MONITOR, + kb_shift_d, + kb_shift_d, + ), + ] + + for name, function, description, desktop_kb, laptop_kb in commands_data: + manager.add_command( + command_manager.KeyboardCommand( + name, + function, + group_label, + description, + desktop_keybinding=desktop_kb, + laptop_keybinding=laptop_kb, + ), + ) + + msg = "SPEECH PRESENTER: Commands set up." + debug.print_message(debug.LEVEL_INFO, msg, True) + + @gsettings_registry.get_registry().gsetting( + key=KEY_SPEAK_MISSPELLED_INDICATOR, + schema="speech", + gtype="b", + default=True, + summary="Speak misspelled word indicator", + migration_key="speakMisspelledIndicator", + ) + @dbus_service.getter + def get_speak_misspelled_indicator(self) -> bool: + """Returns whether the misspelled indicator is spoken.""" + + return self._get_setting(self.KEY_SPEAK_MISSPELLED_INDICATOR, "b", True) + + @dbus_service.setter + def set_speak_misspelled_indicator(self, value: bool) -> bool: + """Sets whether the misspelled indicator is spoken.""" + + msg = f"SPEECH PRESENTER: Setting speak misspelled indicator to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_SPEAK_MISSPELLED_INDICATOR, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_SPEAK_DESCRIPTION, + schema="speech", + gtype="b", + default=True, + summary="Speak object descriptions", + migration_key="speakDescription", + ) + @dbus_service.getter + def get_speak_description(self) -> bool: + """Returns whether object descriptions are spoken.""" + + return self._get_setting(self.KEY_SPEAK_DESCRIPTION, "b", True) + + @dbus_service.setter + def set_speak_description(self, value: bool) -> bool: + """Sets whether object descriptions are spoken.""" + + msg = f"SPEECH PRESENTER: Setting speak description to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_SPEAK_DESCRIPTION, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_SPEAK_POSITION_IN_SET, + schema="speech", + gtype="b", + default=False, + summary="Speak position in set", + migration_key="enablePositionSpeaking", + ) + @dbus_service.getter + def get_speak_position_in_set(self) -> bool: + """Returns whether the position and set size of objects are spoken.""" + + return self._get_setting(self.KEY_SPEAK_POSITION_IN_SET, "b", False) + + @dbus_service.setter + def set_speak_position_in_set(self, value: bool) -> bool: + """Sets whether the position and set size of objects are spoken.""" + + msg = f"SPEECH PRESENTER: Setting speak position in set to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_SPEAK_POSITION_IN_SET, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_SPEAK_WIDGET_MNEMONIC, + schema="speech", + gtype="b", + default=True, + summary="Speak widget mnemonics", + migration_key="enableMnemonicSpeaking", + ) + @dbus_service.getter + def get_speak_widget_mnemonic(self) -> bool: + """Returns whether widget mnemonics are spoken.""" + + return self._get_setting(self.KEY_SPEAK_WIDGET_MNEMONIC, "b", True) + + @dbus_service.setter + def set_speak_widget_mnemonic(self, value: bool) -> bool: + """Sets whether widget mnemonics are spoken.""" + + msg = f"SPEECH PRESENTER: Setting speak widget mnemonics to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_SPEAK_WIDGET_MNEMONIC, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_SPEAK_TUTORIAL_MESSAGES, + schema="speech", + gtype="b", + default=True, + summary="Speak tutorial messages", + migration_key="enableTutorialMessages", + ) + @dbus_service.getter + def get_speak_tutorial_messages(self) -> bool: + """Returns whether tutorial messages are spoken.""" + + return self._get_setting(self.KEY_SPEAK_TUTORIAL_MESSAGES, "b", True) + + @dbus_service.setter + def set_speak_tutorial_messages(self, value: bool) -> bool: + """Sets whether tutorial messages are spoken.""" + + msg = f"SPEECH PRESENTER: Setting speak tutorial messages to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_SPEAK_TUTORIAL_MESSAGES, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_REPEATED_CHARACTER_LIMIT, + schema="speech", + gtype="i", + default=4, + summary="Threshold for repeated character compression", + migration_key="repeatCharacterLimit", + ) + @dbus_service.getter + def get_repeated_character_limit(self) -> int: + """Returns the count at which repeated, non-alphanumeric symbols will be described.""" + + return self._get_setting(self.KEY_REPEATED_CHARACTER_LIMIT, "i", 4) + + @dbus_service.setter + def set_repeated_character_limit(self, value: int) -> bool: + """Sets the count at which repeated, non-alphanumeric symbols will be described.""" + + msg = f"SPEECH PRESENTER: Setting repeated character limit to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_REPEATED_CHARACTER_LIMIT, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_SPEAK_BLANK_LINES, + schema="speech", + gtype="b", + default=True, + summary="Speak blank lines", + migration_key="speakBlankLines", + ) + @dbus_service.getter + def get_speak_blank_lines(self) -> bool: + """Returns whether blank lines will be spoken.""" + + return self._get_setting(self.KEY_SPEAK_BLANK_LINES, "b", True) + + @dbus_service.setter + def set_speak_blank_lines(self, value: bool) -> bool: + """Sets whether blank lines will be spoken.""" + + msg = f"SPEECH PRESENTER: Setting speak blank lines to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_SPEAK_BLANK_LINES, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_SPEAK_ROW_IN_GUI_TABLE, + schema="speech", + gtype="b", + default=True, + summary="Speak full row in GUI tables", + migration_key="readFullRowInGUITable", + ) + @dbus_service.getter + def get_speak_row_in_gui_table(self) -> bool: + """Returns whether Up/Down in GUI tables speaks the row or just the cell.""" + + return self._get_setting(self.KEY_SPEAK_ROW_IN_GUI_TABLE, "b", True) + + @dbus_service.setter + def set_speak_row_in_gui_table(self, value: bool) -> bool: + """Sets whether Up/Down in GUI tables speaks the row or just the cell.""" + + msg = f"SPEECH PRESENTER: Setting speak row in GUI table to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_SPEAK_ROW_IN_GUI_TABLE, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_SPEAK_ROW_IN_DOCUMENT_TABLE, + schema="speech", + gtype="b", + default=True, + summary="Speak full row in document tables", + migration_key="readFullRowInDocumentTable", + ) + @dbus_service.getter + def get_speak_row_in_document_table(self) -> bool: + """Returns whether Up/Down in text-document tables speaks the row or just the cell.""" + + return self._get_setting(self.KEY_SPEAK_ROW_IN_DOCUMENT_TABLE, "b", True) + + @dbus_service.setter + def set_speak_row_in_document_table(self, value: bool) -> bool: + """Sets whether Up/Down in text-document tables speaks the row or just the cell.""" + + msg = f"SPEECH PRESENTER: Setting speak row in document table to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_SPEAK_ROW_IN_DOCUMENT_TABLE, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_SPEAK_ROW_IN_SPREADSHEET, + schema="speech", + gtype="b", + default=False, + summary="Speak full row in spreadsheets", + migration_key="readFullRowInSpreadSheet", + ) + @dbus_service.getter + def get_speak_row_in_spreadsheet(self) -> bool: + """Returns whether Up/Down in spreadsheets speaks the row or just the cell.""" + + return self._get_setting(self.KEY_SPEAK_ROW_IN_SPREADSHEET, "b", False) + + @dbus_service.setter + def set_speak_row_in_spreadsheet(self, value: bool) -> bool: + """Sets whether Up/Down in spreadsheets speaks the row or just the cell.""" + + msg = f"SPEECH PRESENTER: Setting speak row in spreadsheet to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_SPEAK_ROW_IN_SPREADSHEET, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_ANNOUNCE_CELL_SPAN, + schema="speech", + gtype="b", + default=True, + summary="Announce cell span", + migration_key="speakCellSpan", + ) + @dbus_service.getter + def get_announce_cell_span(self) -> bool: + """Returns whether cell spans are announced when greater than 1.""" + + return self._get_setting(self.KEY_ANNOUNCE_CELL_SPAN, "b", True) + + @dbus_service.setter + def set_announce_cell_span(self, value: bool) -> bool: + """Sets whether cell spans are announced when greater than 1.""" + + msg = f"SPEECH PRESENTER: Setting announce cell spans to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_ANNOUNCE_CELL_SPAN, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_ANNOUNCE_CELL_COORDINATES, + schema="speech", + gtype="b", + default=True, + summary="Announce cell coordinates", + migration_key="speakCellCoordinates", + ) + @dbus_service.getter + def get_announce_cell_coordinates(self) -> bool: + """Returns whether (non-spreadsheet) cell coordinates are announced.""" + + return self._get_setting(self.KEY_ANNOUNCE_CELL_COORDINATES, "b", True) + + @dbus_service.setter + def set_announce_cell_coordinates(self, value: bool) -> bool: + """Sets whether (non-spreadsheet) cell coordinates are announced.""" + + msg = f"SPEECH PRESENTER: Setting announce cell coordinates to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_ANNOUNCE_CELL_COORDINATES, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_ANNOUNCE_SPREADSHEET_CELL_COORDINATES, + schema="speech", + gtype="b", + default=True, + summary="Announce spreadsheet cell coordinates", + migration_key="speakSpreadsheetCoordinates", + ) + @dbus_service.getter + def get_announce_spreadsheet_cell_coordinates(self) -> bool: + """Returns whether spreadsheet cell coordinates are announced.""" + + return self._get_setting(self.KEY_ANNOUNCE_SPREADSHEET_CELL_COORDINATES, "b", True) + + @dbus_service.setter + def set_announce_spreadsheet_cell_coordinates(self, value: bool) -> bool: + """Sets whether spreadsheet cell coordinates are announced.""" + + msg = f"SPEECH PRESENTER: Setting announce spreadsheet cell coordinates to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_ANNOUNCE_SPREADSHEET_CELL_COORDINATES, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_ALWAYS_ANNOUNCE_SELECTED_RANGE_IN_SPREADSHEET, + schema="speech", + gtype="b", + default=False, + summary="Always announce selected range in spreadsheets", + migration_key="alwaysSpeakSelectedSpreadsheetRange", + ) + @dbus_service.getter + def get_always_announce_selected_range_in_spreadsheet(self) -> bool: + """Returns whether the selected range in spreadsheets is always announced.""" + + return self._get_setting( + self.KEY_ALWAYS_ANNOUNCE_SELECTED_RANGE_IN_SPREADSHEET, + "b", + False, + ) + + @dbus_service.setter + def set_always_announce_selected_range_in_spreadsheet(self, value: bool) -> bool: + """Sets whether the selected range in spreadsheets is always announced.""" + + msg = f"SPEECH PRESENTER: Setting always announce selected spreadsheet range to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_ALWAYS_ANNOUNCE_SELECTED_RANGE_IN_SPREADSHEET, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_ANNOUNCE_CELL_HEADERS, + schema="speech", + gtype="b", + default=True, + summary="Announce cell headers", + migration_key="speakCellHeaders", + ) + @dbus_service.getter + def get_announce_cell_headers(self) -> bool: + """Returns whether cell headers are announced.""" + + return self._get_setting(self.KEY_ANNOUNCE_CELL_HEADERS, "b", True) + + @dbus_service.setter + def set_announce_cell_headers(self, value: bool) -> bool: + """Sets whether cell headers are announced.""" + + msg = f"SPEECH PRESENTER: Setting announce cell headers to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_ANNOUNCE_CELL_HEADERS, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_ANNOUNCE_BLOCKQUOTE, + schema="speech", + gtype="b", + default=True, + summary="Announce blockquotes", + migration_key="speakContextBlockquote", + ) + @dbus_service.getter + def get_announce_blockquote(self) -> bool: + """Returns whether blockquotes are announced when entered.""" + + return self._get_setting(self.KEY_ANNOUNCE_BLOCKQUOTE, "b", True) + + @dbus_service.setter + def set_announce_blockquote(self, value: bool) -> bool: + """Sets whether blockquotes are announced when entered.""" + + msg = f"SPEECH PRESENTER: Setting announce blockquotes to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_ANNOUNCE_BLOCKQUOTE, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_ANNOUNCE_FORM, + schema="speech", + gtype="b", + default=True, + summary="Announce forms", + migration_key="speakContextNonLandmarkForm", + ) + @dbus_service.getter + def get_announce_form(self) -> bool: + """Returns whether non-landmark forms are announced when entered.""" + + return self._get_setting(self.KEY_ANNOUNCE_FORM, "b", True) + + @dbus_service.setter + def set_announce_form(self, value: bool) -> bool: + """Sets whether non-landmark forms are announced when entered.""" + + msg = f"SPEECH PRESENTER: Setting announce forms to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, self.KEY_ANNOUNCE_FORM, value + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_ANNOUNCE_GROUPING, + schema="speech", + gtype="b", + default=True, + summary="Announce groupings/panels", + migration_key="speakContextPanel", + ) + @dbus_service.getter + def get_announce_grouping(self) -> bool: + """Returns whether groupings are announced when entered.""" + + return self._get_setting(self.KEY_ANNOUNCE_GROUPING, "b", True) + + @dbus_service.setter + def set_announce_grouping(self, value: bool) -> bool: + """Sets whether groupings are announced when entered.""" + + msg = f"SPEECH PRESENTER: Setting announce groupings to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_ANNOUNCE_GROUPING, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_ANNOUNCE_LANDMARK, + schema="speech", + gtype="b", + default=True, + summary="Announce landmarks", + migration_key="speakContextLandmark", + ) + @dbus_service.getter + def get_announce_landmark(self) -> bool: + """Returns whether landmarks are announced when entered.""" + + return self._get_setting(self.KEY_ANNOUNCE_LANDMARK, "b", True) + + @dbus_service.setter + def set_announce_landmark(self, value: bool) -> bool: + """Sets whether landmarks are announced when entered.""" + + msg = f"SPEECH PRESENTER: Setting announce landmarks to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_ANNOUNCE_LANDMARK, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_ANNOUNCE_LIST, + schema="speech", + gtype="b", + default=True, + summary="Announce lists", + migration_key="speakContextList", + ) + @dbus_service.getter + def get_announce_list(self) -> bool: + """Returns whether lists are announced when entered.""" + + return self._get_setting(self.KEY_ANNOUNCE_LIST, "b", True) + + @dbus_service.setter + def set_announce_list(self, value: bool) -> bool: + """Sets whether lists are announced when entered.""" + + msg = f"SPEECH PRESENTER: Setting announce lists to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, self.KEY_ANNOUNCE_LIST, value + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_ANNOUNCE_TABLE, + schema="speech", + gtype="b", + default=True, + summary="Announce tables", + migration_key="speakContextTable", + ) + @dbus_service.getter + def get_announce_table(self) -> bool: + """Returns whether tables are announced when entered.""" + + return self._get_setting(self.KEY_ANNOUNCE_TABLE, "b", True) + + @dbus_service.setter + def set_announce_table(self, value: bool) -> bool: + """Sets whether tables are announced when entered.""" + + msg = f"SPEECH PRESENTER: Setting announce tables to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, self.KEY_ANNOUNCE_TABLE, value + ) + return True + + @dbus_service.command + def change_number_style( + self, + script: default.Script | None = None, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Changes spoken number style between digits and words.""" + + tokens = [ + "SPEECH PRESENTER: change_number_style. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + mgr = speech_manager.get_manager() + speak_digits = mgr.get_speak_numbers_as_digits() + if speak_digits: + brief = messages.NUMBER_STYLE_WORDS_BRIEF + full = messages.NUMBER_STYLE_WORDS_FULL + else: + brief = messages.NUMBER_STYLE_DIGITS_BRIEF + full = messages.NUMBER_STYLE_DIGITS_FULL + + mgr.set_speak_numbers_as_digits(not speak_digits) + 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_ONLY_SPEAK_DISPLAYED_TEXT, + schema="speech", + gtype="b", + default=False, + summary="Only speak displayed text", + migration_key="onlySpeakDisplayedText", + ) + @dbus_service.getter + def get_only_speak_displayed_text(self) -> bool: + """Returns whether only displayed text should be spoken.""" + + return self._get_setting(self.KEY_ONLY_SPEAK_DISPLAYED_TEXT, "b", False) + + @dbus_service.setter + def set_only_speak_displayed_text(self, value: bool) -> bool: + """Sets whether only displayed text should be spoken.""" + + msg = f"SPEECH PRESENTER: Setting only speak displayed text to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_ONLY_SPEAK_DISPLAYED_TEXT, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_SPEAK_PROGRESS_BAR_UPDATES, + schema="speech", + gtype="b", + default=True, + summary="Speak progress bar updates", + migration_key="speakProgressBarUpdates", + ) + @dbus_service.getter + def get_speak_progress_bar_updates(self) -> bool: + """Returns whether speech progress bar updates are enabled.""" + + return self._get_setting(self.KEY_SPEAK_PROGRESS_BAR_UPDATES, "b", True) + + @dbus_service.setter + def set_speak_progress_bar_updates(self, value: bool) -> bool: + """Sets whether speech progress bar updates are enabled.""" + + msg = f"SPEECH PRESENTER: Setting speak progress bar updates to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_SPEAK_PROGRESS_BAR_UPDATES, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_PROGRESS_BAR_SPEECH_INTERVAL, + schema="speech", + gtype="i", + default=10, + summary="Progress bar speech update interval in seconds", + migration_key="progressBarSpeechInterval", + ) + @dbus_service.getter + def get_progress_bar_speech_interval(self) -> int: + """Returns the speech progress bar update interval in seconds.""" + + return self._get_setting(self.KEY_PROGRESS_BAR_SPEECH_INTERVAL, "i", 10) + + @dbus_service.setter + def set_progress_bar_speech_interval(self, value: int) -> bool: + """Sets the speech progress bar update interval in seconds.""" + + msg = f"SPEECH PRESENTER: Setting progress bar speech interval to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_PROGRESS_BAR_SPEECH_INTERVAL, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_PROGRESS_BAR_SPEECH_VERBOSITY, + schema="speech", + genum="org.stormux.Cthulhu.ProgressBarVerbosity", + default="application", + summary="Progress bar speech verbosity (all, application, window)", + migration_key="progressBarSpeechVerbosity", + ) + @dbus_service.getter + def get_progress_bar_speech_verbosity(self) -> int: + """Returns the speech progress bar verbosity level.""" + + value = gsettings_registry.get_registry().layered_lookup( + self._SCHEMA, + self.KEY_PROGRESS_BAR_SPEECH_VERBOSITY, + "", + genum="org.stormux.Cthulhu.ProgressBarVerbosity", + default="application", + ) + return ProgressBarVerbosity[value.upper()].value + + @dbus_service.setter + def set_progress_bar_speech_verbosity(self, value: int) -> bool: + """Sets the speech progress bar verbosity level.""" + + msg = f"SPEECH PRESENTER: Setting progress bar speech verbosity to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + level = ProgressBarVerbosity(value) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_PROGRESS_BAR_SPEECH_VERBOSITY, + level.name.lower(), + ) + return True + + def should_present_progress_bar_update( + self, + obj: Atspi.Accessible, + percent: int | None, + is_same_app: bool, + is_same_window: bool, + ) -> bool: + """Returns True if the progress bar update should be spoken.""" + + if not self.get_speak_progress_bar_updates(): + return False + + last_time, last_value = self._progress_bar_cache.get(id(obj), (0.0, None)) + if percent == last_value: + return False + + if percent != 100: + interval = int(time.time() - last_time) + if interval < self.get_progress_bar_speech_interval(): + return False + + verbosity = self.get_progress_bar_speech_verbosity() + if verbosity == ProgressBarVerbosity.ALL.value: + present = True + elif verbosity == ProgressBarVerbosity.APPLICATION.value: + present = is_same_app + elif verbosity == ProgressBarVerbosity.WINDOW.value: + present = is_same_window + else: + present = True + + if present: + self._progress_bar_cache[id(obj)] = (time.time(), percent) + + return present + + @gsettings_registry.get_registry().gsetting( + key=KEY_MESSAGES_ARE_DETAILED, + schema="speech", + gtype="b", + default=True, + summary="Use detailed informative messages", + migration_key="messagesAreDetailed", + ) + @dbus_service.getter + def get_messages_are_detailed(self) -> bool: + """Returns whether informative messages will be detailed or brief.""" + + return self._get_setting(self.KEY_MESSAGES_ARE_DETAILED, "b", True) + + @dbus_service.setter + def set_messages_are_detailed(self, value: bool) -> bool: + """Sets whether informative messages will be detailed or brief.""" + + msg = f"SPEECH PRESENTER: Setting messages are detailed to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_MESSAGES_ARE_DETAILED, + value, + ) + return True + + def use_verbose_speech(self) -> bool: + """Returns whether the speech verbosity level is set to verbose.""" + + return self.get_verbosity_level() == "verbose" + + @gsettings_registry.get_registry().gsetting( + key=KEY_VERBOSITY_LEVEL, + schema="speech", + genum="org.stormux.Cthulhu.VerbosityLevel", + default="verbose", + summary="Speech verbosity level (brief, verbose)", + migration_key="speechVerbosityLevel", + ) + @dbus_service.getter + def get_verbosity_level(self) -> str: + """Returns the current speech verbosity level for object presentation.""" + + return gsettings_registry.get_registry().layered_lookup( + self._SCHEMA, + self.KEY_VERBOSITY_LEVEL, + "", + genum="org.stormux.Cthulhu.VerbosityLevel", + default="verbose", + ) + + @dbus_service.setter + def set_verbosity_level(self, value: str) -> bool: + """Sets the speech verbosity level for object presentation.""" + + try: + level = VerbosityLevel[value.upper()] + except KeyError: + msg = f"SPEECH PRESENTER: Invalid verbosity level: {value}" + debug.print_message(debug.LEVEL_WARNING, msg, True) + return False + + msg = f"SPEECH PRESENTER: Setting verbosity level to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_VERBOSITY_LEVEL, + level.string_name, + ) + return True + + def _get_verbosity_is_verbose(self) -> bool: + """Returns True if verbosity level is VERBOSE, False if BRIEF.""" + + return self.get_verbosity_level() == VerbosityLevel.VERBOSE.string_name + + def _set_verbosity_from_bool(self, value: bool) -> bool: + """Sets verbosity level to VERBOSE if True, BRIEF if False.""" + + if value: + level_name = VerbosityLevel.VERBOSE.string_name + else: + level_name = VerbosityLevel.BRIEF.string_name + return self.set_verbosity_level(level_name) + + def _speech_enabled_and_only_speak_displayed_text_is_off(self) -> bool: + """Returns True if speech is enabled AND only-speak-displayed-text is off.""" + + return ( + speech_manager.get_manager().get_speech_is_enabled() + and not self.get_only_speak_displayed_text() + ) + + @dbus_service.command + def toggle_verbosity( + self, + script: default.Script | None = None, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Toggles speech verbosity level between verbose and brief.""" + + tokens = [ + "SPEECH PRESENTER: toggle_verbosity. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if self.get_verbosity_level() == VerbosityLevel.BRIEF.string_name: + if script is not None and notify_user: + presentation_manager.get_manager().present_message( + messages.SPEECH_VERBOSITY_VERBOSE, + ) + self.set_verbosity_level(VerbosityLevel.VERBOSE.string_name) + else: + if script is not None and notify_user: + presentation_manager.get_manager().present_message(messages.SPEECH_VERBOSITY_BRIEF) + self.set_verbosity_level(VerbosityLevel.BRIEF.string_name) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_SPEAK_INDENTATION_AND_JUSTIFICATION, + schema="speech", + gtype="b", + default=False, + summary="Speak indentation and justification", + migration_key="enableSpeechIndentation", + ) + @dbus_service.getter + def get_speak_indentation_and_justification(self) -> bool: + """Returns whether speaking of indentation and justification is enabled.""" + + return self._get_setting(self.KEY_SPEAK_INDENTATION_AND_JUSTIFICATION, "b", False) + + @dbus_service.setter + def set_speak_indentation_and_justification(self, value: bool) -> bool: + """Sets whether speaking of indentation and justification is enabled.""" + + msg = f"SPEECH PRESENTER: Setting speak indentation and justification to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_SPEAK_INDENTATION_AND_JUSTIFICATION, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_SPEAK_INDENTATION_ONLY_IF_CHANGED, + schema="speech", + gtype="b", + default=False, + summary="Speak indentation only if changed", + migration_key="speakIndentationOnlyIfChanged", + ) + @dbus_service.getter + def get_speak_indentation_only_if_changed(self) -> bool: + """Returns whether indentation will be announced only if it has changed.""" + + return self._get_setting(self.KEY_SPEAK_INDENTATION_ONLY_IF_CHANGED, "b", False) + + @dbus_service.setter + def set_speak_indentation_only_if_changed(self, value: bool) -> bool: + """Sets whether indentation will be announced only if it has changed.""" + + msg = f"SPEECH PRESENTER: Setting speak indentation only if changed to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_SPEAK_INDENTATION_ONLY_IF_CHANGED, + value, + ) + return True + + @dbus_service.command + def toggle_indentation_and_justification( + self, + script: default.Script | None = None, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Toggles the speaking of indentation and justification.""" + + tokens = [ + "SPEECH PRESENTER: toggle_indentation_and_justification. ", + "Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + value = self.get_speak_indentation_and_justification() + self.set_speak_indentation_and_justification(not value) + if self.get_speak_indentation_and_justification(): + full = messages.INDENTATION_JUSTIFICATION_ON_FULL + brief = messages.INDENTATION_JUSTIFICATION_ON_BRIEF + else: + full = messages.INDENTATION_JUSTIFICATION_OFF_FULL + brief = messages.INDENTATION_JUSTIFICATION_OFF_BRIEF + if script is not None and notify_user: + presentation_manager.get_manager().present_message(full, brief) + return True + + @dbus_service.command + def toggle_table_cell_reading_mode( + self, + script: default.Script | None = None, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Toggles between speak cell and speak row.""" + + tokens = [ + "SPEECH PRESENTER: toggle_table_cell_reading_mode. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + # TODO - JD: This is due to the requirement on script utilities. + if script is None: + msg = "SPEECH PRESENTER: Toggling table cell reading mode requires script." + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + table = AXUtilities.get_table(focus_manager.get_manager().get_locus_of_focus()) + if table is None and notify_user: + presentation_manager.get_manager().present_message(messages.TABLE_NOT_IN_A) + return True + + if not script.utilities.get_document_for_object(table): + getter = self.get_speak_row_in_gui_table + setter = self.set_speak_row_in_gui_table + elif AXUtilities.is_spreadsheet_table(table): + getter = self.get_speak_row_in_spreadsheet + setter = self.set_speak_row_in_spreadsheet + else: + getter = self.get_speak_row_in_document_table + setter = self.set_speak_row_in_document_table + + speak_row = getter() + setter(not speak_row) + + if not speak_row: + msg = messages.TABLE_MODE_ROW + else: + msg = messages.TABLE_MODE_CELL + + if notify_user: + presentation_manager.get_manager().present_message(msg) + return True + + @dbus_service.command + def toggle_monitor( + self, + script: default.Script | None = None, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Toggles the speech monitor on and off.""" + + tokens = [ + "SPEECH PRESENTER: toggle_monitor. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if self.get_monitor_is_enabled(): + self.set_monitor_is_enabled(False) + if script is not None and notify_user: + presentation_manager.get_manager().present_message(messages.SPEECH_MONITOR_DISABLED) + else: + self.set_monitor_is_enabled(True) + if script is not None and notify_user: + presentation_manager.get_manager().present_message(messages.SPEECH_MONITOR_ENABLED) + return True + + @staticmethod + def adjust_for_digits(obj: Atspi.Accessible, text: str) -> str: + """Adjusts text to present numbers as digits.""" + + def _convert(word): + if word.isnumeric(): + word = " ".join(list(word)) + return word + + speak_digits = gsettings_registry.get_registry().layered_lookup( + "speech", + "speak-numbers-as-digits", + "b", + default=False, + ) + if not (speak_digits or AXUtilities.is_text_input_telephone(obj)): + return text + + return " ".join(map(_convert, text.split())) + + @staticmethod + def _adjust_for_links(obj: Atspi.Accessible, line: str, start_offset: int) -> str: + """Adjust line to include the word "link" after any hypertext links.""" + + # This adjustment should only be made in cases where there is only presentable text. + # In content where embedded objects are present, "link" is presented as the role of any + # embedded link children. + if "\ufffc" in line: + return line + + end_offset = start_offset + len(line) + links = AXHypertext.get_all_links_in_range(obj, start_offset, end_offset) + offsets = [AXHypertext.get_link_end_offset(link) for link in links] + offsets = sorted([offset - start_offset for offset in offsets], reverse=True) + tokens = list(line) + for o in offsets: + if 0 <= o <= len(tokens): + text = f" {messages.LINK}" + if o < len(tokens) and tokens[o].isalnum(): + text += " " + tokens[o:o] = text + return "".join(tokens) + + def _adjust_for_repeats(self, text: str) -> str: + """Adjust line to include a description of repeated symbols.""" + + def replacement(match): + char = match.group(1) + count = len(match.group(0)) + if match.start() > 0 and text[match.start() - 1].isalnum(): + return f" {messages.repeated_char_count(char, count)}" + return messages.repeated_char_count(char, count) + + limit = self.get_repeated_character_limit() + if len(text) < 4 or limit < 4: + return text + + pattern = re.compile(r"([^a-zA-Z0-9\s])\1{" + str(limit - 1) + ",}") + return re.sub(pattern, replacement, text) + + @staticmethod + def _should_verbalize_punctuation(obj: Atspi.Accessible) -> bool: + """Returns True if punctuation should be verbalized.""" + + ancestor = AXUtilities.find_ancestor_inclusive(obj, AXUtilities.is_code) + if ancestor is None: + return False + + document = AXUtilities.find_ancestor_inclusive(ancestor, AXUtilities.is_document) + if AXDocument.is_plain_text(document): + return False + + # If the user has set their punctuation level to All, then the synthesizer will + # do the work for us. If the user has set their punctuation level to None, then + # they really don't want punctuation and we mustn't override that. + + punct_level = speech_manager.get_manager().get_punctuation_level() + return punct_level not in ("all", "none") + + @staticmethod + def _adjust_for_verbalized_punctuation(obj: Atspi.Accessible, text: str) -> str: + """Surrounds punctuation symbols with spaces to increase the likelihood of presentation.""" + + if not SpeechPresenter._should_verbalize_punctuation(obj): + return text + + result = text + punctuation = set(re.findall(r"[^\w\s]", result)) + for symbol in punctuation: + result = result.replace(symbol, f" {symbol} ") + + return result + + def _apply_pronunciation_dictionary(self, text: str) -> str: + """Applies the pronunciation dictionary to the text.""" + + if not speech_manager.get_manager().get_use_pronunciation_dictionary(): + return text + + manager = pronunciation_dictionary_manager.get_manager() + words = re.split(r"(\W+)", text) + return "".join(map(manager.get_pronunciation, words)) + + def get_indentation_description(self, line: str, only_if_changed: bool | None = None) -> str: + """Returns a description of the indentation in the given line.""" + + if ( + self.get_only_speak_displayed_text() + or not self.get_speak_indentation_and_justification() + ): + return "" + + line = line.replace("\u00a0", " ") + end = re.search("[^ \t]", line) + if end: + line = line[: end.start()] + + result = "" + spaces = [m.span() for m in re.finditer(" +", line)] + tabs = [m.span() for m in re.finditer("\t+", line)] + spans = sorted(spaces + tabs) + for span in spans: + if span in spaces: + result += f"{messages.spaces_count(span[1] - span[0])} " + else: + result += f"{messages.tabs_count(span[1] - span[0])} " + + if only_if_changed is None: + only_if_changed = self.get_speak_indentation_only_if_changed() + + if only_if_changed: + if self._last_indentation_description == result: + return "" + + if not result and self._last_indentation_description: + self._last_indentation_description = "" + return messages.spaces_count(0) + + self._last_indentation_description = result + return result + + def get_error_description( + self, + obj: Atspi.Accessible, + offset: int | None = None, + only_if_changed: bool | None = True, + ) -> str: + """Returns a description of the error at the current offset.""" + + if not self.get_speak_misspelled_indicator(): + return "" + + # If we're on whitespace or punctuation, we cannot be on an error. + char = AXText.get_character_at_offset(obj, offset)[0] + if char in string.punctuation + string.whitespace + "\u00a0": + self._last_error_description = "" + return "" + + msg = "" + if AXUtilities.string_has_spelling_error(obj, offset): + # TODO - JD: We're using the message here to preserve existing behavior. + msg = messages.MISSPELLED + elif AXUtilities.string_has_grammar_error(obj, offset): + msg = object_properties.STATE_INVALID_GRAMMAR_SPEECH + + if only_if_changed and msg == self._last_error_description: + return "" + + self._last_error_description = msg + return msg + + def adjust_for_presentation( + self, + obj: Atspi.Accessible | None, + text: str, + start_offset: int | None = None, + ) -> str: + """Adjusts text for spoken presentation.""" + + tokens = [ + f"SPEECH PRESENTER: Adjusting '{text}' from", + obj, + f"start_offset: {start_offset}", + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if obj is not None and AXUtilities.is_math_related(obj): + text = mathsymbols.adjust_for_speech(text) + + if start_offset is not None and obj is not None: + text = self._adjust_for_links(obj, text, start_offset) + + if obj is not None: + text = self.adjust_for_digits(obj, text) + text = self._adjust_for_repeats(text) + if obj is not None: + text = self._adjust_for_verbalized_punctuation(obj, text) + text = self._apply_pronunciation_dictionary(text) + + msg = f"SPEECH PRESENTER: Adjusted text: '{text}'" + debug.print_message(debug.LEVEL_INFO, msg, True) + return text + + def _get_active_script(self) -> default.Script | None: + """Returns the active script.""" + + from . import script_manager # pylint: disable=import-outside-toplevel + + return script_manager.get_manager().get_active_script() + + def _get_voice(self, text: str = "", obj: Atspi.Accessible | None = None) -> list[ACSS]: + """Returns the voice to use for the given string.""" + + if active_script := self._get_active_script(): + generator = active_script.get_speech_generator() + context = self._build_generator_context() + return generator.voice(obj=obj, string=text, context=context) + return [] + + @dbus_service.getter + def get_monitor_is_enabled(self) -> bool: + """Returns whether the speech monitor is enabled.""" + + return self._monitor_enabled_override or False + + @dbus_service.setter + def set_monitor_is_enabled(self, value: bool) -> bool: + """Sets whether the speech monitor is enabled.""" + + msg = f"SPEECH PRESENTER: Setting enable speech monitor to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + self._monitor_enabled_override = value + if not value: + self.destroy_monitor() + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_MONITOR_FONT_SIZE, + schema="speech", + gtype="i", + default=14, + summary="Speech monitor font size", + migration_key="speechMonitorFontSize", + ) + @dbus_service.getter + def get_monitor_font_size(self) -> int: + """Returns the speech monitor font size.""" + + return self._get_setting(self.KEY_MONITOR_FONT_SIZE, "i", 14) + + @dbus_service.setter + def set_monitor_font_size(self, value: int) -> bool: + """Sets the speech monitor font size.""" + + msg = f"SPEECH PRESENTER: Setting speech monitor font size to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_MONITOR_FONT_SIZE, + value, + ) + if self._monitor is not None: + self._monitor.set_font_size(value) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_MONITOR_FOREGROUND, + schema="speech", + gtype="s", + default="#ffffff", + summary="Speech monitor foreground color", + migration_key="speechMonitorForeground", + ) + @dbus_service.getter + def get_monitor_foreground(self) -> str: + """Returns the speech monitor foreground color.""" + + return self._get_setting(self.KEY_MONITOR_FOREGROUND, "s", "#ffffff") + + @dbus_service.setter + def set_monitor_foreground(self, value: str) -> bool: + """Sets the speech monitor foreground color.""" + + msg = f"SPEECH PRESENTER: Setting speech monitor foreground to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_MONITOR_FOREGROUND, + value, + ) + if self._monitor is not None: + self._monitor.reapply_css(foreground=value) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_MONITOR_BACKGROUND, + schema="speech", + gtype="s", + default="#000000", + summary="Speech monitor background color", + migration_key="speechMonitorBackground", + ) + @dbus_service.getter + def get_monitor_background(self) -> str: + """Returns the speech monitor background color.""" + + return self._get_setting(self.KEY_MONITOR_BACKGROUND, "s", "#000000") + + @dbus_service.setter + def set_monitor_background(self, value: str) -> bool: + """Sets the speech monitor background color.""" + + msg = f"SPEECH PRESENTER: Setting speech monitor background to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_MONITOR_BACKGROUND, + value, + ) + if self._monitor is not None: + self._monitor.reapply_css(background=value) + return True + + def init_monitor(self) -> None: + """Shows the speech monitor if enabled in settings. Called at startup.""" + + self._ensure_monitor() + + def _ensure_monitor(self) -> speech_monitor.SpeechMonitor | None: + """Creates the speech monitor on demand if enabled, returns it or None.""" + + if not self.get_monitor_is_enabled(): + return None + + if self._monitor is None: + self._monitor = speech_monitor.SpeechMonitor( + font_size=self.get_monitor_font_size(), + foreground=self.get_monitor_foreground(), + background=self.get_monitor_background(), + on_close=lambda: self.set_monitor_is_enabled(False), + ) + self._monitor.show_all() # pylint: disable=no-member + self._monitor.present_with_time(time.time()) + self._replay_history() + + return self._monitor + + def destroy_monitor(self) -> None: + """Destroys the speech monitor widget if it exists.""" + + if self._monitor is not None: + self._monitor.destroy() + self._monitor = None + + def _append_to_history(self, kind: str, value: str) -> None: + """Appends an entry to the speech history buffer.""" + + self._speech_history.append((kind, value)) + if len(self._speech_history) > 500: + self._speech_history = self._speech_history[-500:] + + def _replay_history(self) -> None: + """Replays stored speech history into the current monitor.""" + + if self._monitor is None or not self._speech_history: + return + for kind, value in self._speech_history: + if kind == "text": + self._monitor.write_text(value) + elif kind == "key": + self._monitor.write_key_event(value) + + def _monitor_is_writable(self) -> speech_monitor.SpeechMonitor | None: + """Returns the monitor if it exists, is enabled, and doesn't have focus.""" + + monitor = self._ensure_monitor() + if monitor is None: + return None + if monitor.has_toplevel_focus(): # pylint: disable=no-member + return None + return monitor + + def _begin_monitor_group(self) -> None: + """Begins buffering speech monitor writes for grouped output.""" + + self._group_buffer = [] + + def _end_monitor_group(self) -> None: + """Flushes the group buffer as a single line to the monitor and history.""" + + buffered = self._group_buffer + self._group_buffer = None + if not buffered: + return + + combined = " ".join(buffered) + monitor = self._monitor_is_writable() + if monitor is not None: + monitor.write_text(combined) + self._append_to_history("text", combined) + + def write_to_monitor(self, text: str) -> None: + """Writes spoken text to the speech monitor if active and not focused.""" + + if self._group_buffer is not None: + self._group_buffer.append(text) + return + + monitor = self._monitor_is_writable() + if monitor is not None: + monitor.write_text(text) + self._append_to_history("text", text) + + def write_key_to_monitor(self, key_description: str) -> None: + """Writes a key event to the speech monitor if active and not focused.""" + + monitor = self._monitor_is_writable() + if monitor is not None: + monitor.write_key_event(key_description) + self._append_to_history("key", key_description) + + def present_key_event(self, event: KeyboardEvent) -> None: + """Presents a key event via speech.""" + + key_name = event.get_key_name() if event.is_printable_key() else None + voice = self._get_voice(text=key_name or "") + speech.speak_key_event(event, voice[0] if voice else None) + + def speak_accessible_text(self, obj: Atspi.Accessible | None, text: str) -> None: + """Speaks text from an accessible object, determining voice automatically.""" + + voice = self._get_voice(text, obj) + text = self.adjust_for_presentation(obj, text) + speech.speak(text, voice[0] if voice else None) + + def speak_message(self, text: str) -> None: + """Speaks a message using the system voice.""" + + try: + assert isinstance(text, str) + except AssertionError: + tokens = ["SPEECH PRESENTER: speak_message called with non-string:", text] + debug.print_tokens(debug.LEVEL_INFO, tokens, True, True) + debug.print_exception(debug.LEVEL_WARNING) + return + + if self.get_only_speak_displayed_text(): + return + + mgr = speech_manager.get_manager() + voice = mgr.get_voice_properties(speechserver.SYSTEM_VOICE) + + server = mgr.get_server() + if server is not None: + server.update_capitalization_style("none") + user_level = speechserver.PunctuationStyle[mgr.get_punctuation_level().upper()] + level = max(user_level, speechserver.PunctuationStyle.SOME, key=lambda s: s.value) + server.update_punctuation_level(level) + + text = self.adjust_for_presentation(None, text) + speech.speak(text, voice) + + if server is not None: + mgr.update_capitalization_style() + mgr.update_punctuation_level() + + def _build_generator_context( + self, + where_am_i_type: WhereAmI | None = None, + ) -> SpeechGeneratorContext: + """Builds the settings context for speech generators.""" + + from .speech_generator import ( # pylint: disable=import-outside-toplevel + SpeechGeneratorContext, + ) + + mgr = focus_manager.get_manager() + in_say_all = mgr.in_say_all() + if in_say_all: + p = say_all_presenter.get_presenter() + else: + p = self # type: ignore[assignment] + + active_mode, _obj = mgr.get_active_mode_and_object_of_interest() + + return SpeechGeneratorContext( + enabled=speech_manager.get_manager().get_speech_is_enabled(), + verbose=self.use_verbose_speech(), + focus=mgr.get_locus_of_focus(), + in_say_all=in_say_all, + in_focus_mode=document_presenter.get_presenter().get_in_focus_mode(), + active_mode=active_mode, + where_am_i_type=where_am_i_type, + in_preferences_window=mgr.is_in_preferences_window(), + only_displayed_text=self.get_only_speak_displayed_text(), + speak_description=self.get_speak_description(), + speak_tutorial_messages=self.get_speak_tutorial_messages(), + speak_position_in_set=self.get_speak_position_in_set(), + speak_widget_mnemonic=self.get_speak_widget_mnemonic(), + speak_blank_lines=self.get_speak_blank_lines(), + speak_indentation=self.get_speak_indentation_and_justification(), + announce_cell_headers=self.get_announce_cell_headers(), + announce_cell_coordinates=self.get_announce_cell_coordinates(), + announce_spreadsheet_cell_coordinates=self.get_announce_spreadsheet_cell_coordinates(), + announce_blockquote=p.get_announce_blockquote(), + announce_form=p.get_announce_form(), + announce_landmark=p.get_announce_landmark(), + announce_list=p.get_announce_list(), + announce_grouping=p.get_announce_grouping(), + announce_table=p.get_announce_table(), + ) + + def generate_speech_contents( + self, + script: default.Script, + contents: list[tuple[Atspi.Accessible, int, int, str]], + **args: Any, + ) -> list: + """Generates speech utterances for contents without speaking them.""" + + context = self._build_generator_context() + return script.get_speech_generator().generate_contents(contents, context, **args) + + def generate_speech_string(self, script: default.Script, obj: Atspi.Accessible) -> str: + """Generates speech for obj and returns it as a string.""" + + context = self._build_generator_context() + generator = script.get_speech_generator() + utterances = generator.generate_speech(obj, context) + return generator.utterances_to_string(utterances) + + def generate_window_title_strings( + self, + script: default.Script, + obj: Atspi.Accessible, + ) -> list[str]: + """Returns the window title as a list of strings.""" + + context = self._build_generator_context() + return [s for s, _ in script.get_speech_generator().generate_window_title(obj, context)] + + def speak_contents( + self, + contents: list[tuple[Atspi.Accessible, int, int, str]], + **args: Any, + ) -> None: + """Speaks the specified contents.""" + + tokens = ["SPEECH PRESENTER: Speaking", contents, args] + debug.print_tokens(debug.LEVEL_INFO, tokens, True, True) + + if not (active_script := self._get_active_script()): + return + + context = self._build_generator_context() + generator = active_script.get_speech_generator() + utterances = generator.generate_contents(contents, context, **args) + speech.speak(utterances) + + def present_generated_speech( + self, + script: default.Script, + obj: Atspi.Accessible, + **args: Any, + ) -> None: + """Generates speech for obj using the script's speech generator and speaks it.""" + + where_am_i_type = args.pop("where_am_i_type", None) + context = self._build_generator_context(where_am_i_type) + utterances = script.get_speech_generator().generate_speech(obj, context, **args) + speech.speak(utterances) + + def speak_line( + self, + script: default.Script, + obj: Atspi.Accessible, + start_offset: int, + end_offset: int, + line: str, + ) -> None: + """Generates and speaks a line using the script's speech generator.""" + + indentation = self.get_indentation_description(line) + if indentation: + self.speak_message(indentation) + + context = self._build_generator_context() + generator = script.get_speech_generator() + utterances = generator.generate_line(obj, start_offset, end_offset, line, context) + speech.speak(utterances) + + def speak_phrase( + self, + script: default.Script, + obj: Atspi.Accessible, + start_offset: int, + end_offset: int, + phrase: str, + ) -> None: + """Generates and speaks a phrase using the script's speech generator.""" + + if len(phrase) <= 1 and not phrase.isalnum(): + self.speak_character(phrase, obj=obj) + return + + indentation = self.get_indentation_description(phrase) + if indentation: + self.speak_message(indentation) + + context = self._build_generator_context() + generator = script.get_speech_generator() + utterances = generator.generate_phrase(obj, start_offset, end_offset, phrase, context) + speech.speak(utterances) + + def speak_word( + self, + script: default.Script, + obj: Atspi.Accessible, + offset: int, + ) -> None: + """Generates and speaks a word using the script's speech generator.""" + + context = self._build_generator_context() + utterances = script.get_speech_generator().generate_word(obj, offset, context) + speech.speak(utterances) + + def speak_character_at_offset( + self, + obj: Atspi.Accessible, + offset: int, + character: str, + cap_style: speechserver.CapitalizationStyle | None = None, + ) -> None: + """Handles presentation of a character at the given offset.""" + + if not character or character == "\r": + character = "\n" + + if character == "\n": + line_string = AXText.get_line_at_offset(obj, max(0, offset))[0] + if not line_string or line_string == "\n": + if self.get_speak_blank_lines(): + self.speak_message(messages.BLANK) + return + + if character in ["\n", "\r\n"]: + if self.get_speak_blank_lines(): + self.speak_message(messages.BLANK) + return + + if error := self.get_error_description(obj, offset): + self.speak_message(error) + + self.speak_character(character, voice_from=character, cap_style=cap_style, obj=obj) + + def say_all(self, utterance_iterator: Any, progress_callback: Callable[..., Any]) -> None: + """Speaks each item in the utterance_iterator.""" + + speech.say_all(utterance_iterator, progress_callback) + + def speak_character( + self, + character: str, + voice_from: str = "", + cap_style: speechserver.CapitalizationStyle | None = None, + obj: Atspi.Accessible | None = None, + ) -> None: + """Speaks a single character using the voice for voice_from.""" + + voice = self._get_voice(text=voice_from or character, obj=obj) + speech.speak_character(character, voice[0] if voice else None, cap_style=cap_style) + + def spell_item(self, text: str) -> None: + """Speak the characters in the string one by one.""" + + for character in text: + self.speak_character(character) + + def spell_phonetically(self, item_string: str) -> None: + """Phonetically spell item_string.""" + + for character in item_string: + voice = self._get_voice(text=character) + phonetic_string = phonnames.get_phonetic_name(character.lower()) + speech.speak(phonetic_string, voice[0] if voice else None) + + def create_speech_preferences_grid( + self, + title_change_callback: Callable[[str], None] | None = None, + app_name: str = "", + ) -> SpeechPreferencesGrid: + """Returns the GtkGrid containing the combined speech preferences UI.""" + + return SpeechPreferencesGrid(self, title_change_callback, app_name=app_name) + + def get_speech_preferences( + self, + ) -> tuple[ + tuple[SpeechPreference, ...], # general + tuple[SpeechPreference, ...], # object_details + tuple[SpeechPreference, ...], # announcements + ]: + """Return descriptors for speech preferences, organized by section.""" + + general = ( + SpeechPreference( + self.KEY_MESSAGES_ARE_DETAILED, + guilabels.SPEECH_SYSTEM_MESSAGES_ARE_DETAILED, + self.get_messages_are_detailed, + self.set_messages_are_detailed, + ), + ) + + object_details = ( + SpeechPreference( + self.KEY_ONLY_SPEAK_DISPLAYED_TEXT, + guilabels.SPEECH_ONLY_SPEAK_DISPLAYED_TEXT, + self.get_only_speak_displayed_text, + self.set_only_speak_displayed_text, + ), + SpeechPreference( + self.KEY_SPEAK_DESCRIPTION, + guilabels.SPEECH_SPEAK_DESCRIPTION, + self.get_speak_description, + self.set_speak_description, + ), + SpeechPreference( + self.KEY_SPEAK_POSITION_IN_SET, + guilabels.SPEECH_SPEAK_CHILD_POSITION, + self.get_speak_position_in_set, + self.set_speak_position_in_set, + ), + SpeechPreference( + self.KEY_SPEAK_WIDGET_MNEMONIC, + guilabels.PRESENT_OBJECT_MNEMONICS, + self.get_speak_widget_mnemonic, + self.set_speak_widget_mnemonic, + ), + SpeechPreference( + self.KEY_SPEAK_TUTORIAL_MESSAGES, + guilabels.SPEECH_SPEAK_TUTORIAL_MESSAGES, + self.get_speak_tutorial_messages, + self.set_speak_tutorial_messages, + ), + ) + + announcements = ( + SpeechPreference( + self.KEY_ANNOUNCE_BLOCKQUOTE, + guilabels.ANNOUNCE_BLOCKQUOTES, + self.get_announce_blockquote, + self.set_announce_blockquote, + ), + SpeechPreference( + self.KEY_ANNOUNCE_FORM, + guilabels.ANNOUNCE_FORMS, + self.get_announce_form, + self.set_announce_form, + ), + SpeechPreference( + self.KEY_ANNOUNCE_LANDMARK, + guilabels.ANNOUNCE_LANDMARKS, + self.get_announce_landmark, + self.set_announce_landmark, + ), + SpeechPreference( + self.KEY_ANNOUNCE_LIST, + guilabels.ANNOUNCE_LISTS, + self.get_announce_list, + self.set_announce_list, + ), + SpeechPreference( + self.KEY_ANNOUNCE_GROUPING, + guilabels.ANNOUNCE_PANELS, + self.get_announce_grouping, + self.set_announce_grouping, + ), + SpeechPreference( + self.KEY_ANNOUNCE_TABLE, + guilabels.ANNOUNCE_TABLES, + self.get_announce_table, + self.set_announce_table, + ), + ) + + return general, object_details, announcements + + def apply_speech_preferences( + self, + updates: Iterable[tuple[SpeechPreference, bool]], + ) -> dict[str, bool]: + """Apply the provided speech preference values.""" + + result = {} + for descriptor, value in updates: + descriptor.setter(value) + result[descriptor.prefs_key] = value + return result + + +_presenter: SpeechPresenter = SpeechPresenter() + + +def get_presenter() -> SpeechPresenter: + """Returns the Speech Presenter""" + + return _presenter diff --git a/src/cthulhu/speechserver.py b/src/cthulhu/speechserver.py index eb95d82..a814161 100644 --- a/src/cthulhu/speechserver.py +++ b/src/cthulhu/speechserver.py @@ -36,6 +36,53 @@ __date__ = "$Date$" __copyright__ = "Copyright (c) 2005-2008 Sun Microsystems Inc." __license__ = "LGPL" +from enum import Enum + +from . import gsettings_registry + +DEFAULT_VOICE = "default" +UPPERCASE_VOICE = "uppercase" +HYPERLINK_VOICE = "hyperlink" +SYSTEM_VOICE = "system" + + +@gsettings_registry.get_registry().gsettings_enum( + "org.stormux.Cthulhu.PunctuationStyle", + values={"all": 0, "most": 1, "some": 2, "none": 3}, +) +class PunctuationStyle(Enum): + """Punctuation style enumeration.""" + + NONE = 3 + SOME = 2 + MOST = 1 + ALL = 0 + + @property + def string_name(self): + """Returns the lowercase string name for this enum value.""" + + return self.name.lower() + + +@gsettings_registry.get_registry().gsettings_enum( + "org.stormux.Cthulhu.CapitalizationStyle", + values={"none": 0, "spell": 1, "icon": 2}, +) +class CapitalizationStyle(Enum): + """Capitalization style enumeration with string values from settings.""" + + NONE = "none" + SPELL = "spell" + ICON = "icon" + + @property + def string_name(self): + """Returns the lowercase string name for this enum value.""" + + return self.name.lower() + + class VoiceFamily(dict): """Holds the family description for a voice.""" @@ -85,30 +132,72 @@ class SayAllContext: -startOffset: the start offset of the Accessible's text -endOffset: the end offset of the Accessible's text """ - self.obj = obj - self.utterance = utterance - self.startOffset = startOffset - self.endOffset = endOffset - self.currentOffset = startOffset - self.currentEndOffset = None + self.obj = obj + self.utterance = utterance + self.start_offset = startOffset + self.end_offset = endOffset + self.current_offset = startOffset + self.current_end_offset = None + + @property + def startOffset(self): # pylint: disable=invalid-name + """Legacy camelCase alias for start_offset.""" + + return self.start_offset + + @startOffset.setter + def startOffset(self, value): # pylint: disable=invalid-name + self.start_offset = value + + @property + def endOffset(self): # pylint: disable=invalid-name + """Legacy camelCase alias for end_offset.""" + + return self.end_offset + + @endOffset.setter + def endOffset(self, value): # pylint: disable=invalid-name + self.end_offset = value + + @property + def currentOffset(self): # pylint: disable=invalid-name + """Legacy camelCase alias for current_offset.""" + + return self.current_offset + + @currentOffset.setter + def currentOffset(self, value): # pylint: disable=invalid-name + self.current_offset = value + + @property + def currentEndOffset(self): # pylint: disable=invalid-name + """Legacy camelCase alias for current_end_offset.""" + + return self.current_end_offset + + @currentEndOffset.setter + def currentEndOffset(self, value): # pylint: disable=invalid-name + self.current_end_offset = value def __str__(self): return "SAY ALL: %s '%s' (%i-%i, current: %i)" % \ - (self.obj, self.utterance, self.startOffset, self.endOffset, self.currentOffset) + (self.obj, self.utterance, self.start_offset, self.end_offset, self.current_offset) def copy(self): - new = SayAllContext(self.obj, self.utterance, - self.startOffset, self.endOffset) - new.currentOffset = self.currentOffset - new.currentEndOffset = self.currentEndOffset + new = SayAllContext(self.obj, self.utterance, self.start_offset, self.end_offset) + new.current_offset = self.current_offset + new.current_end_offset = self.current_end_offset return new def __eq__(self, other): - return (self.startOffset == other.startOffset and - self.endOffset == other.endOffset and + return (self.start_offset == other.start_offset and + self.end_offset == other.end_offset and self.obj == other.obj and self.utterance == other.utterance) + def __hash__(self): + return hash((self.start_offset, self.end_offset, self.obj, self.utterance)) + class SpeechServer(object): """Provides speech server abstraction.""" diff --git a/src/cthulhu/spellcheck_presenter.py b/src/cthulhu/spellcheck_presenter.py new file mode 100644 index 0000000..4087cf7 --- /dev/null +++ b/src/cthulhu/spellcheck_presenter.py @@ -0,0 +1,967 @@ +# Cthulhu +# +# Copyright 2014-2026 Igalia, S.L. +# +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +# pylint: disable=too-many-instance-attributes + +"""Script-customizable support for application spellcheckers.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +import gi + +gi.require_version("Atspi", "2.0") +from gi.repository import Atspi + +from cthulhu import ( + dbus_service, + debug, + focus_manager, + gsettings_registry, + guilabels, + messages, + object_properties, + preferences_grid_base, + presentation_manager, + speech_presenter, +) +from cthulhu.ax_object import AXObject +from cthulhu.ax_text import AXText +from cthulhu.ax_utilities import AXUtilities + +if TYPE_CHECKING: + from .scripts import default + + +@dataclass +class _SpellCheckWidgets: + """Widget references for an active spellcheck session.""" + + window: Atspi.Accessible + error_widget: Atspi.Accessible + suggestions_list: Atspi.Accessible + change_to_entry: Atspi.Accessible | None + document: Atspi.Accessible | None + + +@dataclass +class _PresentationState: + """Tracking state to avoid duplicate presentations.""" + + last_presented_suggestion: Atspi.Accessible | None = None + error_widget_text: str = "" + context_line: tuple[str, int, int] = field(default_factory=lambda: ("", -1, -1)) + completion_announced: bool = False + + def reset(self) -> None: + """Reset presentation state for a new spellcheck session.""" + self.last_presented_suggestion = None + self.error_widget_text = "" + self.context_line = ("", -1, -1) + self.completion_announced = False + + +@gsettings_registry.get_registry().gsettings_schema("org.stormux.Cthulhu.Spellcheck", name="spellcheck") +class SpellCheckPresenter: + """Singleton presenter for spell check support and preferences.""" + + _SCHEMA = "spellcheck" + KEY_SPELL_ERROR = "spell-error" + KEY_SPELL_SUGGESTION = "spell-suggestion" + KEY_PRESENT_CONTEXT = "present-context" + + 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._script: default.Script | None = None + self._widgets: _SpellCheckWidgets | None = None + self._state: _PresentationState = _PresentationState() + self._listener: Atspi.EventListener = Atspi.EventListener.new(self._on_event) + + msg = "SPELLCHECK PRESENTER: Registering D-Bus commands." + debug.print_message(debug.LEVEL_INFO, msg, True) + controller = dbus_service.get_remote_controller() + controller.register_decorated_module("SpellCheckPresenter", self) + + @gsettings_registry.get_registry().gsetting( + key=KEY_SPELL_ERROR, + schema="spellcheck", + gtype="b", + default=True, + summary="Spell misspelled word", + migration_key="spellcheckSpellError", + ) + @dbus_service.getter + def get_spell_error(self) -> bool: + """Returns whether misspelled word should be spelled.""" + + return self._get_setting(self.KEY_SPELL_ERROR, True) + + @dbus_service.setter + def set_spell_error(self, value: bool) -> bool: + """Sets whether misspelled word should be spelled.""" + + if self.get_spell_error() == value: + return True + + msg = f"SPELLCHECK PRESENTER: Setting spell error to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, self.KEY_SPELL_ERROR, value + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_SPELL_SUGGESTION, + schema="spellcheck", + gtype="b", + default=True, + summary="Spell suggested correction", + migration_key="spellcheckSpellSuggestion", + ) + @dbus_service.getter + def get_spell_suggestion(self) -> bool: + """Returns whether the suggested correction should be spelled.""" + + return self._get_setting(self.KEY_SPELL_SUGGESTION, True) + + @dbus_service.setter + def set_spell_suggestion(self, value: bool) -> bool: + """Sets whether the suggested correction should be spelled.""" + + if self.get_spell_suggestion() == value: + return True + + msg = f"SPELLCHECK PRESENTER: Setting spell suggestion to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, self.KEY_SPELL_SUGGESTION, value + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_PRESENT_CONTEXT, + schema="spellcheck", + gtype="b", + default=True, + summary="Present context/surrounding sentence", + migration_key="spellcheckPresentContext", + ) + @dbus_service.getter + def get_present_context(self) -> bool: + """Returns whether to present the context/surrounding sentence.""" + + return self._get_setting(self.KEY_PRESENT_CONTEXT, True) + + @dbus_service.setter + def set_present_context(self, value: bool) -> bool: + """Sets whether to present the context/surrounding sentence.""" + + if self.get_present_context() == value: + return True + + msg = f"SPELLCHECK PRESENTER: Setting present context to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, self.KEY_PRESENT_CONTEXT, value + ) + return True + + def create_preferences_grid(self) -> SpellCheckPreferencesGrid: + """Create and return the spell check preferences grid.""" + + return SpellCheckPreferencesGrid(self) + + def _on_event(self, event: Atspi.Event) -> None: + """Listener for spellcheck-related events.""" + + if self._widgets is None: + return + + if AXObject.is_dead(self._widgets.window): + self.deactivate() + return + + if AXUtilities.get_application(event.source) != AXUtilities.get_application( + self._widgets.window, + ): + return + + debug.print_message(debug.LEVEL_INFO, "\nvvvvv PROCESS SPELL CHECK EVENT vvvvv") + tokens = ["SPELL CHECK PRESENTER: Event:", event] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if not AXUtilities.is_ancestor(event.source, self._widgets.window, inclusive=True): + debug.print_message(debug.LEVEL_INFO, "^^^^^ PROCESS SPELL CHECK EVENT ^^^^^\n") + return + + if event.type.startswith("object:property-change:accessible-name"): + self._handle_name_changed(event) + elif ( + event.type.startswith("object:state-changed:sensitive") + and self._widgets.change_to_entry + ): + self._handle_change_to_entry_sensitive_changed(event) + elif event.type.startswith("object:selection-changed") or event.type.startswith( + "object:active-descendant-changed", + ): + self._handle_suggestions_list_change(event) + debug.print_message(debug.LEVEL_INFO, "^^^^^ PROCESS SPELL CHECK EVENT ^^^^^\n") + + def activate(self, window: Atspi.Accessible) -> bool: + """Activates spellcheck support.""" + + tokens = ["SPELL CHECK PRESENTER: Attempting activation for", window] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + error_widget, suggestions_list, change_to_entry = self._find_spellcheck_widgets(window) + if error_widget is None or suggestions_list is None: + tokens = ["SPELL CHECK PRESENTER:", window, "is not spellcheck window"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + document = self._get_document_from_cursor_history(window) + tokens = ["SPELL CHECK PRESENTER: Document:", document] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._widgets = _SpellCheckWidgets( + window=window, + error_widget=error_widget, + suggestions_list=suggestions_list, + change_to_entry=change_to_entry, + document=document, + ) + self._state.reset() + + msg = "SPELL CHECK PRESENTER: Registering event listeners" + debug.print_message(debug.LEVEL_INFO, msg, True) + self._listener.register("object:property-change:accessible-name") + self._listener.register("object:state-changed:sensitive") + self._listener.register("object:selection-changed") + self._listener.register("object:active-descendant-changed") + + # Silently set locus of focus to the deepest focused child in the dialog + # to prevent focus claims from document objects from interrupting presentation. + focused = AXUtilities.get_focused_object(window) + while focused is not None: + child = AXUtilities.get_focused_object(focused) + if child is None or child == focused: + break + focused = child + if focused is not None: + tokens = ["SPELL CHECK PRESENTER: Setting locus of focus to", focused] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + focus_manager.get_manager().set_locus_of_focus(None, focused, notify_script=False) + + msg = "SPELL CHECK PRESENTER: Activation complete" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + def deactivate(self) -> None: + """Deactivates spellcheck support.""" + + if self._widgets is None: + return + + msg = "SPELL CHECK PRESENTER: Deregistering event listeners" + debug.print_message(debug.LEVEL_INFO, msg, True) + self._listener.deregister("object:property-change:accessible-name") + self._listener.deregister("object:state-changed:sensitive") + self._listener.deregister("object:selection-changed") + self._listener.deregister("object:active-descendant-changed") + + self._clear_state() + + def _get_document_from_cursor_history( + self, + window: Atspi.Accessible, + ) -> Atspi.Accessible | None: + """Gets the document object from the focus manager's cursor history.""" + + manager = focus_manager.get_manager() + + def is_valid_document(obj: Atspi.Accessible | None) -> bool: + if obj is None or AXObject.is_dead(obj): + return False + if obj == window or AXUtilities.is_ancestor(obj, window): + return False + if not AXObject.supports_text(obj): + return False + return AXUtilities.is_editable(obj) or bool( + AXUtilities.find_ancestor(obj, AXUtilities.is_editable), + ) + + last_obj, _ = manager.get_last_cursor_position() + if is_valid_document(last_obj): + return last_obj + + penult_obj, _ = manager.get_penultimate_cursor_position() + if is_valid_document(penult_obj): + return penult_obj + + return None + + def _get_misspelled_word(self) -> str: + """Returns the misspelled word.""" + + if self._widgets is None: + return "" + + text = AXText.get_all_text(self._widgets.error_widget) or AXObject.get_name( + self._widgets.error_widget, + ) + if not text: + return "" + + # For most apps, the error widget text/name contains only the misspelled word. + if len(text.split()) == 1: + return text + + # For soffice, the error widget contains the full sentence with the misspelled word + # highlighted in red. + app = AXUtilities.get_application(self._widgets.window) + app_name = AXObject.get_name(app).lower().split(".")[-1] + if app_name == "soffice": + word = self._find_red_text(self._widgets.error_widget) + if word and not word.isspace(): + return word.strip(".,;:!?") + + return text + + def _find_red_text(self, obj: Atspi.Accessible) -> str: + """Finds text with red foreground color (misspelled word indicator in soffice).""" + + length = AXText.get_character_count(obj) + if length <= 0: + return "" + + offset = 0 + while offset < length: + attrs, start, end = AXText.get_text_attributes_at_offset(obj, offset) + if attrs.get("fg-color") == "255,0,0": + word = AXText.get_substring(obj, start, end) + tokens = ["SPELL CHECK PRESENTER: Found red text:", word] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return word + offset = max(end, offset + 1) + + return "" + + def is_active(self) -> bool: + """Returns True if spellcheck support is currently being used.""" + + return self._widgets is not None + + def is_spell_check_window(self, window: Atspi.Accessible) -> bool: + """Returns True if window is the window/dialog containing the spellcheck.""" + + return self._widgets is not None and window == self._widgets.window + + def _is_complete(self) -> bool: + """Returns True if we have reason to conclude the check is complete.""" + + if self._widgets is None or AXObject.is_dead(self._widgets.window): + msg = "SPELL CHECK PRESENTER: Window is gone; spellcheck complete" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + AXObject.clear_cache(self._widgets.suggestions_list) + if not AXUtilities.is_sensitive(self._widgets.suggestions_list): + msg = "SPELL CHECK PRESENTER: Suggestions list is insensitive; spellcheck complete" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + return False + + def handle_window_event(self, event: Atspi.Event, script: default.Script) -> bool: + """Handles window activate/deactivate events. Returns True if spellcheck-related.""" + + self._script = script + if event.type.startswith("window:activate"): + return self._handle_window_activated(event) + + return False + + def _handle_change_to_entry_sensitive_changed(self, event: Atspi.Event) -> bool: + """Handles sensitive state change on the change-to entry.""" + + tokens = [ + "SPELL CHECK PRESENTER: Handling change-to entry sensitive change for", + event.source, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if self._widgets is None or event.source != self._widgets.change_to_entry: + tokens = ["SPELL CHECK PRESENTER:", event.source, "is not the change-to entry"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + msg = "SPELL CHECK PRESENTER: Change-to entry sensitivity changed. Checking for completion." + debug.print_message(debug.LEVEL_INFO, msg, True) + return self._present_completion_message() + + def _handle_name_changed(self, event: Atspi.Event) -> bool: + """Handles name-changed events that might indicate a new misspelled word.""" + + tokens = ["SPELL CHECK PRESENTER: Handling name change for", event.source] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if self._widgets is None: + msg = "SPELL CHECK PRESENTER: Spellcheck is not active." + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + if not AXUtilities.is_ancestor(event.source, self._widgets.window, inclusive=True): + tokens = ["SPELL CHECK PRESENTER:", event.source, "is not in the spellcheck window"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + msg = "SPELL CHECK PRESENTER: Name changed in spellcheck window; checking for error change" + debug.print_message(debug.LEVEL_INFO, msg, True) + return self._check_and_present_if_error_changed() + + def _handle_suggestions_list_change(self, event: Atspi.Event) -> bool: + """Handles selection-changed and active-descendant-changed events for suggestions list.""" + + tokens = [ + "SPELL CHECK PRESENTER: Handling suggestions list change for", + event.source, + "Event type:", + event.type, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if self._widgets is None or event.source != self._widgets.suggestions_list: + tokens = ["SPELL CHECK PRESENTER:", event.source, "is not the suggestions list"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + items = AXUtilities.get_selected_children(event.source) + selected_item = items[0] if len(items) == 1 else None + + if AXUtilities.is_focused(event.source): + if selected_item is not None: + assert self._script is not None + msg = "SPELL CHECK PRESENTER: List is focused; presenting selected suggestion" + debug.print_message(debug.LEVEL_INFO, msg, True) + # If focus is newly entering the list, clear last_presented_suggestion + # so we announce the item even if it was presented during error details. + current_focus = focus_manager.get_manager().get_locus_of_focus() + if not AXUtilities.is_ancestor(current_focus, self._widgets.suggestions_list): + self._state.last_presented_suggestion = None + focus_manager.get_manager().set_locus_of_focus( + None, + selected_item, + notify_script=False, + ) + self._script.update_braille(selected_item) + self._present_suggestion_list_item() + return True + + if selected_item == self._state.last_presented_suggestion: + tokens = [ + "SPELL CHECK PRESENTER: Selection unchanged since last presentation:", + selected_item, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return True + + msg = "SPELL CHECK PRESENTER: List is not focused; checking for error change" + debug.print_message(debug.LEVEL_INFO, msg, True) + + # If we have no document, reset last error text to force presentation. + if self._widgets.document is None: + self._state.error_widget_text = "" + + self._check_and_present_if_error_changed() + return True + + def _check_and_present_if_error_changed(self) -> bool: + """Checks if the misspelled word changed and presents if so.""" + + if self._widgets is None or self._state.completion_announced: + reason = "Not active" if self._widgets is None else "Completion already announced" + msg = f"SPELL CHECK PRESENTER: {reason}; not checking for error change" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + if self._is_complete(): + msg = "SPELL CHECK PRESENTER: Spellcheck is complete; presenting completion" + debug.print_message(debug.LEVEL_INFO, msg, True) + return self._present_completion_message() + + # Check full error widget text first - if it changed, we have a new error + error_widget_text = AXText.get_all_text(self._widgets.error_widget) or AXObject.get_name( + self._widgets.error_widget, + ) + error_text_changed = error_widget_text != self._state.error_widget_text + + current_word = self._get_misspelled_word() + if not current_word: + msg = "SPELL CHECK PRESENTER: No current error word" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + # Multi-word content in non-editable widget is not a misspelled word (may be completion msg) + if len(current_word.split()) > 1 and not AXUtilities.is_editable( + self._widgets.error_widget, + ): + return self._present_completion_message() + + # Check if context line changed (detects same word at different position) + current_context_line: tuple[str, int, int] = ("", -1, -1) + if self._widgets.document is not None: + current_context_line = AXText.get_line_at_offset(self._widgets.document) + context_changed = current_context_line != self._state.context_line + + msg = ( + f"SPELL CHECK PRESENTER: error_text_changed={error_text_changed} " + f"context_changed={context_changed}" + ) + debug.print_message(debug.LEVEL_INFO, msg, True) + + if not error_text_changed and not context_changed: + msg = "SPELL CHECK PRESENTER: Error text and context unchanged; not presenting" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + msg = "SPELL CHECK PRESENTER: Error changed; presenting" + debug.print_message(debug.LEVEL_INFO, msg, True) + self._state.last_presented_suggestion = None + self.present_error_details() + self._state.error_widget_text = error_widget_text + self._state.context_line = current_context_line + return True + + def _handle_window_activated(self, event: Atspi.Event) -> bool: + """Handles window activation.""" + + tokens = ["SPELL CHECK PRESENTER: Handling window:activate for", event.source] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if not (self.is_spell_check_window(event.source) or self.activate(event.source)): + msg = "SPELL CHECK PRESENTER: Not a spellcheck window. Deactivating." + debug.print_message(debug.LEVEL_INFO, msg, True) + self.deactivate() + return False + + msg = "SPELL CHECK PRESENTER: Window activated; checking for error to present" + debug.print_message(debug.LEVEL_INFO, msg, True) + self._state.error_widget_text = "" # Reset to force presentation + self._state.context_line = ("", -1, -1) + self._check_and_present_if_error_changed() + if self._widgets is not None and self._widgets.change_to_entry is not None: + assert self._script is not None + focus_manager.get_manager().set_locus_of_focus( + None, + self._widgets.change_to_entry, + False, + ) + self._script.update_braille(self._widgets.change_to_entry) + return True + + def _present_context(self) -> bool: + """Presents the context/surrounding content of the misspelled word.""" + + if self._widgets is None: + return False + + word = self._get_misspelled_word() + if not word: + return False + + if self._script is None: + return False + + string = self._get_context_string(word) + if not string: + return False + + msg = messages.MISSPELLED_WORD_CONTEXT % string.strip() + presentation_manager.get_manager().speak_message(msg) + return True + + def _get_context_string(self, word: str) -> str: + """Returns the context string containing the misspelled word.""" + + if self._widgets is None: + return "" + + error_text = AXText.get_all_text(self._widgets.error_widget) + if error_text and word in error_text and len(error_text.split()) > 1: + tokens = ["SPELL CHECK PRESENTER: Using error widget text as context"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return error_text + + if self._widgets.document is None: + return "" + + string, start, end = AXText.get_line_at_offset(self._widgets.document) + msg = f"SPELL CHECK PRESENTER: Line at offset {start}-{end}: {string}" + debug.print_message(debug.LEVEL_INFO, msg, True) + + if not string or word not in string: + return "" + + if len(string) > 80: + sentences = re.split(r"(?:\.|\!|\?)", string) + if string.count(word) == 1: + match = list(filter(lambda x: word in x, sentences)) + if match: + return match[0].strip() + + return string + + def _present_completion_message(self) -> bool: + """Presents the message that spellcheck is complete.""" + + if self._widgets is None or not self._is_complete(): + return False + + if self._state.completion_announced: + msg = "SPELL CHECK PRESENTER: Completion already announced" + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + if self._script is None: + return False + + msg = AXText.get_all_text(self._widgets.error_widget) or AXObject.get_name( + self._widgets.error_widget, + ) + manager = presentation_manager.get_manager() + manager.speak_message(msg) + + # Don't restore previous braille content to prevent accidental clicks on old elements. + manager.present_braille_message(msg, restore_previous=False) + self._state.completion_announced = True + return True + + def present_error_details( + self, + detailed: bool = False, + script: default.Script | None = None, + ) -> bool: + """Presents the details of the error.""" + + if script is not None: + self._script = script + + if self._is_complete(): + msg = "SPELL CHECK PRESENTER: Not presenting error details: spellcheck is complete" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + if self._present_mistake(detailed): + self._present_suggestion(detailed) + if detailed or self.get_present_context(): + self._present_context() + return True + + return False + + def _present_mistake(self, detailed: bool = False) -> bool: + """Presents the misspelled word.""" + + if self._widgets is None: + msg = "SPELL CHECK PRESENTER: Not presenting mistake: spellcheck is not active" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + if self._script is None: + return False + + word = self._get_misspelled_word() + if not word: + msg = "SPELL CHECK PRESENTER: Not presenting mistake: misspelled word not found" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + msg = messages.MISSPELLED_WORD % word + presentation_manager.get_manager().speak_message(msg) + if detailed or self.get_spell_error(): + presentation_manager.get_manager().spell_item(word) + + return True + + def _present_suggestion(self, detailed: bool = False) -> bool: + """Presents the suggested correction.""" + + if self._widgets is None: + msg = "SPELL CHECK PRESENTER: Not presenting suggestion: spellcheck is not active" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + if not self._widgets.change_to_entry: + return self._present_suggestion_list_item(detailed, include_label=True) + + if self._script is None: + return False + + label = AXUtilities.get_displayed_label(self._widgets.change_to_entry) or AXObject.get_name( + self._widgets.change_to_entry, + ) + string = AXText.get_substring(self._widgets.change_to_entry, 0, -1) + msg = f"{label} {string}" + presentation_manager.get_manager().speak_message(msg) + if detailed or self.get_spell_suggestion(): + presentation_manager.get_manager().spell_item(string) + + items = AXUtilities.get_selected_children(self._widgets.suggestions_list) + if len(items) == 1: + self._state.last_presented_suggestion = items[0] + + return True + + def _present_suggestion_list_item( + self, + detailed: bool = False, + include_label: bool = False, + ) -> bool: + """Presents the current item from the suggestions list.""" + + if self._widgets is None: + msg = "SPELL CHECK PRESENTER: Not presenting suggested item: spellcheck is not active" + debug.print_message(debug.LEVEL_INFO, msg, True) + return False + + if self._script is None: + return False + + items = AXUtilities.get_selected_children(self._widgets.suggestions_list) + if len(items) != 1: + return False + + if items[0] == self._state.last_presented_suggestion: + return False + self._state.last_presented_suggestion = items[0] + + if include_label: + label = AXUtilities.get_displayed_label( + self._widgets.suggestions_list, + ) or AXObject.get_name(self._widgets.suggestions_list) + else: + label = "" + string = AXObject.get_name(items[0]) + + msg = f"{label} {string}" + presentation_manager.get_manager().speak_message(msg.strip()) + if detailed or self.get_spell_suggestion(): + presentation_manager.get_manager().spell_item(string) + + if ( + speech_presenter.get_presenter().get_speak_position_in_set() + and items[0] == focus_manager.get_manager().get_locus_of_focus() + ): + index = AXUtilities.get_position_in_set(items[0]) + 1 + total = AXUtilities.get_set_size(items[0]) + msg = object_properties.GROUP_INDEX_SPEECH % {"index": index, "total": total} + presentation_manager.get_manager().speak_message(msg) + + return True + + def _clear_state(self) -> None: + """Clears all session state.""" + self._script = None + self._widgets = None + self._state.reset() + + def _has_id(self, obj: Atspi.Accessible, target_id: str) -> bool: + """Returns True if obj has target_id as accessible ID or object attribute 'id'.""" + + return ( + AXObject.get_accessible_id(obj) == target_id + or AXObject.get_attribute(obj, "id") == target_id + ) + + def _could_be_spellcheck_window(self, window: Atspi.Accessible, app_name: str) -> bool: + """Returns True if window could be the spellcheck window.""" + + # What we want. LibreOffice uses this. + if self._has_id(window, "SpellingDialog"): + return True + + # Fallback: thunderbird uses frame with tag=body; no ID. + # Unfortunately, thunderbird does this for the main window with tons of descendants. + if app_name == "thunderbird": + if AXObject.get_attribute(window, "tag") != "body": + return False + return AXUtilities.get_menu(window) is None + + # Fallback: gedit/pluma uses a modal dialog (gedit) or frame (pluma); no ID. + if app_name in ("gedit", "pluma"): + return AXUtilities.is_modal(window) + + return False + + def _could_be_error_widget(self, obj: Atspi.Accessible, app_name: str) -> bool: + """Returns True if obj could be the error widget.""" + + # What we want. Thunderbird uses this. + if self._has_id(obj, "MisspelledWord"): + return True + + # Fallback: soffice uses "errorsentence". + if app_name == "soffice": + return AXObject.get_accessible_id(obj) == "errorsentence" + + # Fallback: gedit/pluma uses label with single word, no colon. + if app_name in ("gedit", "pluma") and AXUtilities.is_label(obj): + name_tokens = AXObject.get_name(obj).split() + return len(name_tokens) == 1 and ":" not in name_tokens[0] + + return False + + def _could_be_suggestions_list(self, obj: Atspi.Accessible, app_name: str) -> bool: + """Returns True if obj could be the suggestions list.""" + + # What we want. Thunderbird uses "SuggestedList", but the "SuggestionsList" seems nicer, + # given the label is typically "Suggestions:". But we'll happily accept either. + if self._has_id(obj, "SuggestedList") or self._has_id(obj, "SuggestionsList"): + return True + + # Fallback: soffice uses "suggestionslb". + if app_name == "soffice": + return AXObject.get_accessible_id(obj) == "suggestionslb" + + # Fallback: gedit/pluma uses a table which manages descendants. + if app_name in ("gedit", "pluma"): + return AXUtilities.is_table(obj) and AXUtilities.manages_descendants(obj) + + return False + + def _could_be_change_to_entry(self, obj: Atspi.Accessible, app_name: str) -> bool: + """Returns True if obj could be the change-to entry.""" + + # TODO - JD: What we want (need to try to find consensus). + + # soffice lacks a change-to entry. + if app_name == "soffice": + return False + + # Fallback: Thunderbird uses this. + if self._has_id(obj, "ReplaceWordInput"): + return True + + # Fallback: gedit/pluma uses labelled single-line editable + if app_name in ("gedit", "pluma"): + return AXUtilities.is_single_line_entry(obj) + + return False + + def _find_spellcheck_widgets( + self, + window: Atspi.Accessible, + ) -> tuple[Atspi.Accessible | None, Atspi.Accessible | None, Atspi.Accessible | None]: + """Finds spellcheck widgets. Returns (error_widget, suggestions_list, change_to_entry).""" + + app = AXUtilities.get_application(window) + app_name = AXObject.get_name(app).lower().split(".")[-1] + + if not self._could_be_spellcheck_window(window, app_name): + return None, None, None + + error_widget = AXUtilities.find_descendant( + window, + lambda obj: self._could_be_error_widget(obj, app_name), + ) + suggestions_list = AXUtilities.find_descendant( + window, + lambda obj: self._could_be_suggestions_list(obj, app_name), + ) + change_to_entry = ( + None + if app_name == "soffice" + else AXUtilities.find_descendant( + window, + lambda obj: self._could_be_change_to_entry(obj, app_name), + ) + ) + + # soffice: errorsentence ID is on panel; get text child if needed + if error_widget is not None and not AXObject.supports_text(error_widget): + if text_child := AXUtilities.get_descendant_supporting_text(error_widget): + error_widget = text_child + + tokens = [ + "SPELL CHECK PRESENTER: Error widget:", + error_widget, + "Suggestions list:", + suggestions_list, + "Change-to entry:", + change_to_entry, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return error_widget, suggestions_list, change_to_entry + + +class SpellCheckPreferencesGrid(preferences_grid_base.AutoPreferencesGrid): + """GtkGrid containing the Spell Check preferences page.""" + + _gsettings_schema = "spellcheck" + + def __init__(self, presenter: SpellCheckPresenter) -> None: + controls = [ + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.SPELL_CHECK_SPELL_ERROR, + getter=presenter.get_spell_error, + setter=presenter.set_spell_error, + prefs_key=SpellCheckPresenter.KEY_SPELL_ERROR, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.SPELL_CHECK_SPELL_SUGGESTION, + getter=presenter.get_spell_suggestion, + setter=presenter.set_spell_suggestion, + prefs_key=SpellCheckPresenter.KEY_SPELL_SUGGESTION, + ), + preferences_grid_base.BooleanPreferenceControl( + label=guilabels.SPELL_CHECK_PRESENT_CONTEXT, + getter=presenter.get_present_context, + setter=presenter.set_present_context, + prefs_key=SpellCheckPresenter.KEY_PRESENT_CONTEXT, + ), + ] + + super().__init__( + guilabels.SPELL_CHECK, + controls, + info_message=guilabels.SPELL_CHECK_DESCRIPTION, + ) + + +_presenter: SpellCheckPresenter = SpellCheckPresenter() + + +def get_presenter() -> SpellCheckPresenter: + """Returns the Spell Check Presenter""" + + return _presenter diff --git a/src/cthulhu/structural_navigator.py b/src/cthulhu/structural_navigator.py new file mode 100644 index 0000000..9bb36a8 --- /dev/null +++ b/src/cthulhu/structural_navigator.py @@ -0,0 +1,3856 @@ +# Cthulhu +# +# Copyright 2005-2009 Sun Microsystems Inc. +# Copyright 2011-2025 Igalia, S.L. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +# pylint: disable=too-many-lines +# pylint: disable=too-many-arguments +# pylint: disable=too-many-positional-arguments +# pylint: disable=too-many-public-methods + +"""Implements structural navigation.""" + +from __future__ import annotations + +from enum import Enum +from typing import TYPE_CHECKING, Any + +import gi + +gi.require_version("Atspi", "2.0") +from gi.repository import Atspi + +from . import ( + cmdnames, + command_manager, + dbus_service, + debug, + focus_manager, + gsettings_registry, + guilabels, + input_event_manager, + keybindings, + live_region_presenter, + messages, + object_properties, + cthulhu_gui_navlist, + presentation_manager, + say_all_presenter, + script_manager, +) +from .ax_hypertext import AXHypertext +from .ax_object import AXObject +from .ax_table import AXTable +from .ax_text import AXText +from .ax_utilities import AXUtilities + +if TYPE_CHECKING: + from collections.abc import Callable + + from .input_event import InputEvent + from .scripts import default + + +class NavigationMode(Enum): + """Represents the structural navigation modes available.""" + + OFF = "OFF" + DOCUMENT = "DOCUMENT" + GUI = "GUI" + + +@gsettings_registry.get_registry().gsettings_schema( + "org.stormux.Cthulhu.StructuralNavigation", + name="structural-navigation", +) +class StructuralNavigator: + """Implements the structural navigation support available to scripts.""" + + _SCHEMA = "structural-navigation" + KEY_WRAPS = "wraps" + KEY_LARGE_OBJECT_TEXT_LENGTH = "large-object-text-length" + KEY_ENABLED = "enabled" + KEY_TRIGGERS_FOCUS_MODE = "triggers-focus-mode" + + def _get_setting(self, key: str, gtype: str, default: Any) -> Any: + """Returns the dconf value for key, or default if not in dconf.""" + + return gsettings_registry.get_registry().layered_lookup( + self._SCHEMA, + key, + gtype, + default=default, + ) + + def __init__(self) -> None: + self._last_input_event: InputEvent | None = None + + # To make it possible for focus mode to suspend this navigation without + # changing the user's preferred setting. + self._suspended: bool = False + self._mode_for_script: dict[default.Script, NavigationMode] = {} + self._previous_mode_for_script: dict[default.Script, NavigationMode] = {} + self._initialized: bool = False + + msg = "STRUCTURAL NAVIGATOR: Registering D-Bus commands." + debug.print_message(debug.LEVEL_INFO, msg, True) + controller = dbus_service.get_remote_controller() + controller.register_decorated_module("StructuralNavigator", self) + + # pylint: disable-next=too-many-locals + 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_STRUCTURAL_NAVIGATION + + # Mode cycle command + kb_z = keybindings.KeyBinding("z", keybindings.CTHULHU_MODIFIER_MASK) + manager.add_command( + command_manager.KeyboardCommand( + "structural_navigator_mode_cycle", + self.cycle_mode, + group_label, + cmdnames.STRUCTURAL_NAVIGATION_MODE_CYCLE, + desktop_keybinding=kb_z, + laptop_keybinding=kb_z, + is_group_toggle=True, + ), + ) + + # Navigation bindings - (key, prev_mod, next_mod, list_mod, base_name) + nav_bindings = [ + ( + "q", + keybindings.SHIFT_MODIFIER_MASK, + keybindings.NO_MODIFIER_MASK, + keybindings.SHIFT_ALT_MODIFIER_MASK, + "blockquote", + ), + ( + "b", + keybindings.SHIFT_MODIFIER_MASK, + keybindings.NO_MODIFIER_MASK, + keybindings.SHIFT_ALT_MODIFIER_MASK, + "button", + ), + ( + "x", + keybindings.SHIFT_MODIFIER_MASK, + keybindings.NO_MODIFIER_MASK, + keybindings.SHIFT_ALT_MODIFIER_MASK, + "checkbox", + ), + ( + "c", + keybindings.SHIFT_MODIFIER_MASK, + keybindings.NO_MODIFIER_MASK, + keybindings.SHIFT_ALT_MODIFIER_MASK, + "combobox", + ), + ( + "e", + keybindings.SHIFT_MODIFIER_MASK, + keybindings.NO_MODIFIER_MASK, + keybindings.SHIFT_ALT_MODIFIER_MASK, + "entry", + ), + ( + "f", + keybindings.SHIFT_MODIFIER_MASK, + keybindings.NO_MODIFIER_MASK, + keybindings.SHIFT_ALT_MODIFIER_MASK, + "form_field", + ), + ( + "h", + keybindings.SHIFT_MODIFIER_MASK, + keybindings.NO_MODIFIER_MASK, + keybindings.SHIFT_ALT_MODIFIER_MASK, + "heading", + ), + ( + "g", + keybindings.SHIFT_MODIFIER_MASK, + keybindings.NO_MODIFIER_MASK, + keybindings.SHIFT_ALT_MODIFIER_MASK, + "image", + ), + ( + "m", + keybindings.SHIFT_MODIFIER_MASK, + keybindings.NO_MODIFIER_MASK, + keybindings.SHIFT_ALT_MODIFIER_MASK, + "landmark", + ), + ( + "l", + keybindings.SHIFT_MODIFIER_MASK, + keybindings.NO_MODIFIER_MASK, + keybindings.SHIFT_ALT_MODIFIER_MASK, + "list", + ), + ( + "i", + keybindings.SHIFT_MODIFIER_MASK, + keybindings.NO_MODIFIER_MASK, + keybindings.SHIFT_ALT_MODIFIER_MASK, + "list_item", + ), + ( + "p", + keybindings.SHIFT_MODIFIER_MASK, + keybindings.NO_MODIFIER_MASK, + keybindings.SHIFT_ALT_MODIFIER_MASK, + "paragraph", + ), + ( + "r", + keybindings.SHIFT_MODIFIER_MASK, + keybindings.NO_MODIFIER_MASK, + keybindings.SHIFT_ALT_MODIFIER_MASK, + "radio_button", + ), + ( + "t", + keybindings.SHIFT_MODIFIER_MASK, + keybindings.NO_MODIFIER_MASK, + keybindings.SHIFT_ALT_MODIFIER_MASK, + "table", + ), + ( + "k", + keybindings.SHIFT_MODIFIER_MASK, + keybindings.NO_MODIFIER_MASK, + keybindings.SHIFT_ALT_MODIFIER_MASK, + "link", + ), + ( + "u", + keybindings.SHIFT_MODIFIER_MASK, + keybindings.NO_MODIFIER_MASK, + keybindings.SHIFT_ALT_MODIFIER_MASK, + "unvisited_link", + ), + ( + "v", + keybindings.SHIFT_MODIFIER_MASK, + keybindings.NO_MODIFIER_MASK, + keybindings.SHIFT_ALT_MODIFIER_MASK, + "visited_link", + ), + ( + "o", + keybindings.SHIFT_MODIFIER_MASK, + keybindings.NO_MODIFIER_MASK, + keybindings.SHIFT_ALT_MODIFIER_MASK, + "large_object", + ), + ( + "a", + keybindings.SHIFT_MODIFIER_MASK, + keybindings.NO_MODIFIER_MASK, + keybindings.SHIFT_ALT_MODIFIER_MASK, + "clickable", + ), + ] + + # Build command name -> keybinding mapping + cmd_bindings: dict[str, keybindings.KeyBinding | None] = {} + for key, prev_mod, next_mod, list_mod, base_name in nav_bindings: + cmd_bindings[f"previous_{base_name}"] = keybindings.KeyBinding(key, prev_mod) + cmd_bindings[f"next_{base_name}"] = keybindings.KeyBinding(key, next_mod) + # Handle plurals for list commands + if base_name == "entry": + plural = "entries" + elif base_name in ("checkbox", "combobox"): + plural = f"{base_name}es" + else: + plural = f"{base_name}s" + cmd_bindings[f"list_{plural}"] = keybindings.KeyBinding(key, list_mod) + + # Additional bindings + cmd_bindings["previous_separator"] = keybindings.KeyBinding( + "s", + keybindings.SHIFT_MODIFIER_MASK, + ) + cmd_bindings["next_separator"] = keybindings.KeyBinding("s", keybindings.NO_MODIFIER_MASK) + cmd_bindings["previous_live_region"] = keybindings.KeyBinding( + "d", + keybindings.SHIFT_MODIFIER_MASK, + ) + cmd_bindings["next_live_region"] = keybindings.KeyBinding("d", keybindings.NO_MODIFIER_MASK) + cmd_bindings["last_live_region"] = keybindings.KeyBinding("y", keybindings.NO_MODIFIER_MASK) + cmd_bindings["container_start"] = keybindings.KeyBinding( + "comma", + keybindings.SHIFT_MODIFIER_MASK, + ) + cmd_bindings["container_end"] = keybindings.KeyBinding( + "comma", + keybindings.NO_MODIFIER_MASK, + ) + # Commands with no bindings + cmd_bindings["previous_iframe"] = None + cmd_bindings["next_iframe"] = None + cmd_bindings["list_iframes"] = None + + commands_data = [ + ("previous_blockquote", self.previous_blockquote, cmdnames.BLOCKQUOTE_PREV), + ("next_blockquote", self.next_blockquote, cmdnames.BLOCKQUOTE_NEXT), + ("list_blockquotes", self.list_blockquotes, cmdnames.BLOCKQUOTE_LIST), + ("previous_button", self.previous_button, cmdnames.BUTTON_PREV), + ("next_button", self.next_button, cmdnames.BUTTON_NEXT), + ("list_buttons", self.list_buttons, cmdnames.BUTTON_LIST), + ("previous_checkbox", self.previous_checkbox, cmdnames.CHECK_BOX_PREV), + ("next_checkbox", self.next_checkbox, cmdnames.CHECK_BOX_NEXT), + ("list_checkboxes", self.list_checkboxes, cmdnames.CHECK_BOX_LIST), + ("previous_combobox", self.previous_combobox, cmdnames.COMBO_BOX_PREV), + ("next_combobox", self.next_combobox, cmdnames.COMBO_BOX_NEXT), + ("list_comboboxes", self.list_comboboxes, cmdnames.COMBO_BOX_LIST), + ("previous_entry", self.previous_entry, cmdnames.ENTRY_PREV), + ("next_entry", self.next_entry, cmdnames.ENTRY_NEXT), + ("list_entries", self.list_entries, cmdnames.ENTRY_LIST), + ("previous_form_field", self.previous_form_field, cmdnames.FORM_FIELD_PREV), + ("next_form_field", self.next_form_field, cmdnames.FORM_FIELD_NEXT), + ("list_form_fields", self.list_form_fields, cmdnames.FORM_FIELD_LIST), + ("previous_heading", self.previous_heading, cmdnames.HEADING_PREV), + ("next_heading", self.next_heading, cmdnames.HEADING_NEXT), + ("list_headings", self.list_headings, cmdnames.HEADING_LIST), + ("previous_iframe", self.previous_iframe, cmdnames.IFRAME_PREV), + ("next_iframe", self.next_iframe, cmdnames.IFRAME_NEXT), + ("list_iframes", self.list_iframes, cmdnames.IFRAME_LIST), + ("previous_image", self.previous_image, cmdnames.IMAGE_PREV), + ("next_image", self.next_image, cmdnames.IMAGE_NEXT), + ("list_images", self.list_images, cmdnames.IMAGE_LIST), + ("previous_landmark", self.previous_landmark, cmdnames.LANDMARK_PREV), + ("next_landmark", self.next_landmark, cmdnames.LANDMARK_NEXT), + ("list_landmarks", self.list_landmarks, cmdnames.LANDMARK_LIST), + ("previous_list", self.previous_list, cmdnames.LIST_PREV), + ("next_list", self.next_list, cmdnames.LIST_NEXT), + ("list_lists", self.list_lists, cmdnames.LIST_LIST), + ("previous_list_item", self.previous_list_item, cmdnames.LIST_ITEM_PREV), + ("next_list_item", self.next_list_item, cmdnames.LIST_ITEM_NEXT), + ("list_list_items", self.list_list_items, cmdnames.LIST_ITEM_LIST), + ("previous_live_region", self.previous_live_region, cmdnames.LIVE_REGION_PREV), + ("next_live_region", self.next_live_region, cmdnames.LIVE_REGION_NEXT), + ("last_live_region", self._last_live_region, cmdnames.LIVE_REGION_LAST), + ("previous_paragraph", self.previous_paragraph, cmdnames.PARAGRAPH_PREV), + ("next_paragraph", self.next_paragraph, cmdnames.PARAGRAPH_NEXT), + ("list_paragraphs", self.list_paragraphs, cmdnames.PARAGRAPH_LIST), + ("previous_radio_button", self.previous_radio_button, cmdnames.RADIO_BUTTON_PREV), + ("next_radio_button", self.next_radio_button, cmdnames.RADIO_BUTTON_NEXT), + ("list_radio_buttons", self.list_radio_buttons, cmdnames.RADIO_BUTTON_LIST), + ("previous_separator", self.previous_separator, cmdnames.SEPARATOR_PREV), + ("next_separator", self.next_separator, cmdnames.SEPARATOR_NEXT), + ("previous_table", self.previous_table, cmdnames.TABLE_PREV), + ("next_table", self.next_table, cmdnames.TABLE_NEXT), + ("list_tables", self.list_tables, cmdnames.TABLE_LIST), + ("previous_link", self.previous_link, cmdnames.LINK_PREV), + ("next_link", self.next_link, cmdnames.LINK_NEXT), + ("list_links", self.list_links, cmdnames.LINK_LIST), + ("previous_unvisited_link", self.previous_unvisited_link, cmdnames.UNVISITED_LINK_PREV), + ("next_unvisited_link", self.next_unvisited_link, cmdnames.UNVISITED_LINK_NEXT), + ("list_unvisited_links", self.list_unvisited_links, cmdnames.UNVISITED_LINK_LIST), + ("previous_visited_link", self.previous_visited_link, cmdnames.VISITED_LINK_PREV), + ("next_visited_link", self.next_visited_link, cmdnames.VISITED_LINK_NEXT), + ("list_visited_links", self.list_visited_links, cmdnames.VISITED_LINK_LIST), + ("previous_large_object", self.previous_large_object, cmdnames.LARGE_OBJECT_PREV), + ("next_large_object", self.next_large_object, cmdnames.LARGE_OBJECT_NEXT), + ("list_large_objects", self.list_large_objects, cmdnames.LARGE_OBJECT_LIST), + ("previous_clickable", self.previous_clickable, cmdnames.CLICKABLE_PREV), + ("next_clickable", self.next_clickable, cmdnames.CLICKABLE_NEXT), + ("list_clickables", self.list_clickables, cmdnames.CLICKABLE_LIST), + ("container_start", self.container_start, cmdnames.CONTAINER_START), + ("container_end", self.container_end, cmdnames.CONTAINER_END), + ] + + for name, function, description in commands_data: + kb = cmd_bindings.get(name) + manager.add_command( + command_manager.KeyboardCommand( + name, + function, + group_label, + description, + desktop_keybinding=kb, + laptop_keybinding=kb, + ), + ) + + # Heading levels 1-6 + for i in range(1, 7): + kb_prev = keybindings.KeyBinding(str(i), keybindings.SHIFT_MODIFIER_MASK) + kb_next = keybindings.KeyBinding(str(i), keybindings.NO_MODIFIER_MASK) + kb_list = keybindings.KeyBinding(str(i), keybindings.SHIFT_ALT_MODIFIER_MASK) + + heading_commands = [ + ( + f"previous_heading_level_{i}", + getattr(self, f"previous_heading_level_{i}"), + cmdnames.HEADING_AT_LEVEL_PREV % i, + kb_prev, + ), + ( + f"next_heading_level_{i}", + getattr(self, f"next_heading_level_{i}"), + cmdnames.HEADING_AT_LEVEL_NEXT % i, + kb_next, + ), + ( + f"list_headings_level_{i}", + getattr(self, f"list_headings_level_{i}"), + cmdnames.HEADING_AT_LEVEL_LIST % i, + kb_list, + ), + ] + for name, function, description, kb in heading_commands: + manager.add_command( + command_manager.KeyboardCommand( + name, + function, + group_label, + description, + desktop_keybinding=kb, + laptop_keybinding=kb, + ), + ) + + msg = f"STRUCTURAL NAVIGATOR: Commands set up. Suspended: {self._suspended}" + debug.print_message(debug.LEVEL_INFO, msg, True) + + def _is_active_script(self, script): + active_script = script_manager.get_manager().get_active_script() + if active_script == script: + return True + + tokens = ["STRUCTURAL NAVIGATOR:", script, "is not the active script", active_script] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + def get_mode(self, script: default.Script) -> NavigationMode: + """Returns the current structural-navigator mode associated with script.""" + + mode = self._mode_for_script.get(script, NavigationMode.OFF) + tokens = ["STRUCTURAL NAVIGATOR: Mode for", script, f"is {mode}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return mode + + def set_mode(self, script: default.Script, mode: NavigationMode) -> None: + """Sets the structural-navigator mode.""" + + tokens = ["STRUCTURAL NAVIGATOR: Setting mode for", script, f"to {mode}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._mode_for_script[script] = mode + + if not (script and self._is_active_script(script)): + return + + # Use the per-script mode combined with the user's preference to determine + # whether commands should be active, without overwriting the preference. + effective = mode != NavigationMode.OFF and self.get_is_enabled() + command_manager.get_manager().set_group_enabled( + guilabels.KB_GROUP_STRUCTURAL_NAVIGATION, + effective, + ) + + def last_input_event_was_navigation_command(self) -> bool: + """Returns true if the last input event was a navigation command.""" + + if self._last_input_event is None: + return False + + manager = input_event_manager.get_manager() + result = manager.last_event_equals_or_is_release_for_event(self._last_input_event) + if self._last_input_event is not None: + string = self._last_input_event.as_single_line_string() + else: + string = "None" + + msg = ( + f"STRUCTURAL NAVIGATOR: Last navigation event ({string}) is last input event: {result}" + ) + debug.print_message(debug.LEVEL_INFO, msg, True) + return result + + def last_command_prevents_focus_mode(self) -> bool: + """Returns True if the last command was navigation but the setting disallows focus mode.""" + + if not self.last_input_event_was_navigation_command(): + return False + + return not self.get_triggers_focus_mode() + + @gsettings_registry.get_registry().gsetting( + key=KEY_WRAPS, + schema="structural-navigation", + gtype="b", + default=True, + summary="Wrap when reaching top/bottom", + migration_key="wrappedStructuralNavigation", + ) + @dbus_service.getter + def get_navigation_wraps(self) -> bool: + """Returns whether navigation wraps when reaching the top/bottom of the document.""" + + return self._get_setting(self.KEY_WRAPS, "b", True) + + @dbus_service.setter + def set_navigation_wraps(self, value: bool) -> bool: + """Sets whether navigation wraps when reaching the top/bottom of the document.""" + + msg = f"STRUCTURAL NAVIGATOR: Setting navigation wraps to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value(self._SCHEMA, self.KEY_WRAPS, value) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_LARGE_OBJECT_TEXT_LENGTH, + schema="structural-navigation", + gtype="i", + default=75, + summary="Minimum text length for large objects", + migration_key="largeObjectTextLength", + ) + @dbus_service.getter + def get_large_object_text_length(self) -> int: + """Returns the minimum number of characters to be considered a 'large object'.""" + + return self._get_setting(self.KEY_LARGE_OBJECT_TEXT_LENGTH, "i", 75) + + @dbus_service.setter + def set_large_object_text_length(self, value: int) -> bool: + """Sets the minimum number of characters to be considered a 'large object'.""" + + msg = f"STRUCTURAL NAVIGATOR: Setting large object text length to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_LARGE_OBJECT_TEXT_LENGTH, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_ENABLED, + schema="structural-navigation", + gtype="b", + default=True, + summary="Enable structural navigation", + migration_key="structuralNavigationEnabled", + ) + @dbus_service.getter + def get_is_enabled(self) -> bool: + """Returns whether structural navigation is enabled.""" + + return self._get_setting(self.KEY_ENABLED, "b", True) + + @dbus_service.setter + def set_is_enabled(self, value: bool) -> bool: + """Sets whether structural navigation is enabled.""" + + if self.get_is_enabled() == value: + msg = f"STRUCTURAL NAVIGATOR: Enabled already {value}. Refreshing command group." + debug.print_message(debug.LEVEL_INFO, msg, True) + command_manager.get_manager().set_group_enabled( + guilabels.KB_GROUP_STRUCTURAL_NAVIGATION, + value, + ) + return True + + msg = f"STRUCTURAL NAVIGATOR: Setting enabled to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_ENABLED, + value, + ) + + script = script_manager.get_manager().get_active_script() + if not script: + return True + + current_mode = self.get_mode(script) + if not value and current_mode == NavigationMode.OFF: + return True + + self._last_input_event = None + if value: + if previous_mode := self._previous_mode_for_script.get(script): + tokens = ["STRUCTURAL NAVIGATOR: Restoring mode for", script, "to", previous_mode] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._mode_for_script[script] = previous_mode + else: + self._previous_mode_for_script[script] = current_mode + tokens = ["STRUCTURAL NAVIGATOR: Saving", current_mode, "as previous mode for", script] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._mode_for_script[script] = NavigationMode.OFF + + command_manager.get_manager().set_group_enabled( + guilabels.KB_GROUP_STRUCTURAL_NAVIGATION, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_TRIGGERS_FOCUS_MODE, + schema="structural-navigation", + gtype="b", + default=False, + summary="Structural navigation triggers focus mode", + migration_key="structNavTriggersFocusMode", + ) + @dbus_service.getter + def get_triggers_focus_mode(self) -> bool: + """Returns whether structural navigation triggers focus mode.""" + + return self._get_setting(self.KEY_TRIGGERS_FOCUS_MODE, "b", False) + + @dbus_service.setter + def set_triggers_focus_mode(self, value: bool) -> bool: + """Sets whether structural navigation triggers focus mode.""" + + if self.get_triggers_focus_mode() == value: + return True + + msg = f"STRUCTURAL NAVIGATOR: Setting triggers focus mode to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_TRIGGERS_FOCUS_MODE, + value, + ) + return True + + @dbus_service.command + def cycle_mode( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Cycles among the structural navigation modes.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: cycle_mode. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if not (script and self._is_active_script(script)): + return False + + self._last_input_event = None + previous_mode = self.get_mode(script) + msg = "" + mode = None + if previous_mode == NavigationMode.OFF: + mode = NavigationMode.DOCUMENT + msg = messages.STRUCTURAL_NAVIGATION_KEYS_DOCUMENT + elif previous_mode == NavigationMode.GUI: + mode = NavigationMode.OFF + msg = messages.STRUCTURAL_NAVIGATION_KEYS_OFF + else: + mode = NavigationMode.GUI + msg = messages.STRUCTURAL_NAVIGATION_KEYS_GUI + + if notify_user: + presentation_manager.get_manager().present_message(msg) + self.set_mode(script, mode) + if mode == NavigationMode.DOCUMENT: + root = self._determine_root_container(script) + if not AXObject.supports_collection(root) and notify_user: + presentation_manager.get_manager().present_message( + messages.STRUCTURAL_NAVIGATION_NOT_SUPPORTED_FULL, + messages.STRUCTURAL_NAVIGATION_NOT_SUPPORTED_BRIEF, + ) + return True + + def suspend_commands(self, script, suspended, reason=""): + """Suspends structural navigation independent of the enabled setting.""" + + if not (script and self._is_active_script(script)): + return + + msg = f"STRUCTURAL NAVIGATOR: Suspended: {suspended}" + if reason: + msg += f": {reason}" + debug.print_message(debug.LEVEL_INFO, msg, True) + + self._suspended = suspended + command_manager.get_manager().set_group_suspended( + guilabels.KB_GROUP_STRUCTURAL_NAVIGATION, + suspended, + ) + + def _get_container_for_nested_item(self, obj: Atspi.Accessible) -> Atspi.Accessible: + # If an author put an ARIA heading inside a native heading (or vice versa), obj + # could be the inner heading. If we treat the outer heading as as the previous heading + # and then set the caret context to the first position inside the outer heading, i.e. + # the inner heading, we'll get stuck. Thanks authors. + if AXUtilities.is_heading(obj): + if ancestor := AXUtilities.find_ancestor(obj, AXUtilities.is_heading): + tokens = [ + "STRUCTURAL NAVIGATOR: Current heading", + obj, + "is inside another heading", + ancestor, + "Treating the outer heading as current.", + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return ancestor + return obj + + candidate = obj + if AXUtilities.is_live_region(obj): + while ancestor := AXUtilities.find_ancestor(candidate, AXUtilities.is_live_region): + candidate = ancestor + if candidate != obj: + tokens = [ + "STRUCTURAL NAVIGATOR: Current live region", + obj, + "is inside another ", + "live region", + candidate, + "Treating the outer region as current.", + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + return candidate + + @staticmethod + def _get_adjacent_or_wrap( + objects: list[Atspi.Accessible], + index: int, + is_next: bool, + should_wrap: bool, + notify_user: bool, + ) -> Atspi.Accessible | None: + """Returns the adjacent object in the list, wrapping if enabled.""" + + if is_next: + if index + 1 < len(objects): + return objects[index + 1] + wrap_msg = messages.WRAPPING_TO_TOP + wrap_target = objects[0] + else: + if index > 0: + return objects[index - 1] + wrap_msg = messages.WRAPPING_TO_BOTTOM + wrap_target = objects[-1] + + if not should_wrap: + return None + if notify_user: + presentation_manager.get_manager().present_message(wrap_msg) + return wrap_target + + def _get_object_in_direction( + self, + script: default.Script, + objects: list[Atspi.Accessible], + is_next: bool, + should_wrap: bool | None = None, + notify_user: bool = True, + ) -> Atspi.Accessible | None: + """Returns the next/previous object in relation to the current location.""" + + if not objects: + return None + + if should_wrap is None: + should_wrap = self.get_navigation_wraps() + + # If we're in a matching object, return the next/previous one in the list. + obj = focus_manager.get_manager().get_locus_of_focus() + candidate = obj + while candidate: + if candidate not in objects: + candidate = AXObject.get_parent(candidate) + continue + + if not is_next: + alternative = self._get_container_for_nested_item(candidate) + if alternative in objects: + candidate = alternative + + index = objects.index(candidate) + return self._get_adjacent_or_wrap( + objects, + index, + is_next, + should_wrap, + notify_user, + ) + + # If we're not in a matching object, find the next/previous one based on the path. + if not is_next: + objects.reverse() + + current_path = AXObject.get_path(obj) + for match in objects: + path = AXObject.get_path(match) + comparison = script.utilities.path_comparison(path, current_path) + if (comparison > 0 and is_next) or (comparison < 0 and not is_next): + return match + + if not should_wrap: + return None + + wrap_msg = messages.WRAPPING_TO_TOP if is_next else messages.WRAPPING_TO_BOTTOM + if notify_user: + presentation_manager.get_manager().present_message(wrap_msg) + return objects[0] if obj != objects[0] else None + + def _get_state_string(self, obj: Atspi.Accessible) -> str: + if AXUtilities.is_switch(obj): + off, on = object_properties.SWITCH_INDICATORS_SPEECH + return on if AXUtilities.is_checked(obj) else off + + if AXUtilities.is_check_box(obj): + unchecked, checked, partially = object_properties.CHECK_BOX_INDICATORS_SPEECH + if AXUtilities.is_indeterminate(obj): + return partially + return checked if AXUtilities.is_checked(obj) else unchecked + + if AXUtilities.is_radio_button(obj): + unselected, selected = object_properties.RADIO_BUTTON_INDICATORS_SPEECH + return selected if AXUtilities.is_checked(obj) else unselected + + if AXUtilities.is_link(obj): + return ( + object_properties.STATE_VISITED + if AXUtilities.is_visited(obj) + else object_properties.STATE_UNVISITED + ) + + return "" + + def _get_item_string_by_role( + self, + script: default.Script, + obj: Atspi.Accessible, + ) -> str | None: + """Returns a string for the object based on its role, or None if not role-specific.""" + + if AXUtilities.is_table(obj): + caption = AXTable.get_caption(obj) + return AXText.get_all_text(caption) if caption else "" + + if AXUtilities.is_internal_frame(obj): + result = self._get_item_string(script, AXObject.get_child(obj, 0)) + return result or AXUtilities.get_localized_role_name(obj) + + if AXUtilities.is_list(obj): + children = list(AXObject.iter_children(obj, AXUtilities.is_list_item)) + count = len(children) + counter = ( + messages.nested_list_item_count + if AXUtilities.get_nesting_level(obj) + else messages.list_item_count + ) + return counter(count) + + if AXUtilities.is_description_list(obj): + return messages.description_list_term_count( + len(AXUtilities.find_all_description_terms(obj)), + ) + + if AXUtilities.is_image(obj): + result = AXObject.get_image_description(obj) + if not result: + parent = AXObject.get_parent(obj) + if AXUtilities.is_link(parent): + result = self._get_item_string(script, parent) + else: + result = AXUtilities.get_localized_role_name(obj) + return result + + return None + + def _get_item_string(self, script: default.Script, obj: Atspi.Accessible) -> str: + if obj is None: + return "" + + result = ( + AXObject.get_name(obj) + or AXObject.get_description(obj) + or AXUtilities.get_displayed_label(obj) + or AXUtilities.get_displayed_description(obj) + ) + if result: + return result + + role_result = self._get_item_string_by_role(script, obj) + if role_result is not None: + return role_result + + if AXUtilities.is_page_tab_list(obj): + return messages.tab_list_item_count( + len(list(AXObject.iter_children(obj, AXUtilities.is_page_tab))), + ) + + if result := script.utilities.expand_eocs(obj): + return result + + if AXUtilities.is_link(obj): + result = AXHypertext.get_link_basename(obj) + + return result + + def _present_line( + self, + script: default.Script, + obj: Atspi.Accessible | None = None, + offset: int | None = None, + notify_user: bool = True, + ) -> None: + if obj is None: + return + + manager = focus_manager.get_manager() + presenter = say_all_presenter.get_presenter() + if manager.in_say_all() and presenter.get_structural_navigation_enabled(): + presenter.say_all(script, event=None, obj=obj, offset=offset) + return + + manager.emit_region_changed(obj, offset, mode=focus_manager.STRUCTURAL_NAVIGATOR) + if not notify_user: + msg = "STRUCTURAL NAVIGATOR: _present_line called with notify_user=False" + debug.print_message(debug.LEVEL_INFO, msg, True) + manager.set_locus_of_focus(None, obj, False) + if AXObject.supports_text(obj): + script.utilities.set_caret_position(obj, offset or 0) + return + + script.update_braille(obj) + script.say_line(obj, offset) + + def _present_object( + self, + script: default.Script, + obj: Atspi.Accessible | None = None, + not_found_message: str = messages.STRUCTURAL_NAVIGATION_NOT_FOUND, + offset: int | None = None, + notify_user: bool = True, + ) -> None: + if obj is None: + if notify_user: + presentation_manager.get_manager().present_message( + not_found_message, + messages.STRUCTURAL_NAVIGATION_NOT_FOUND, + ) + return + + if offset is None: + offset = 0 + + manager = focus_manager.get_manager() + if self.get_mode(script) == NavigationMode.GUI: + manager.set_locus_of_focus(None, obj) + AXObject.grab_focus(obj) + AXObject.clear_cache(obj, False, "Checking state after focus grab") + if not AXUtilities.is_focused(obj) and notify_user: + presentation_manager.get_manager().present_message(messages.NOT_FOCUSED) + return + + presenter = say_all_presenter.get_presenter() + if manager.in_say_all() and presenter.get_structural_navigation_enabled(): + presenter.say_all(script, event=None, obj=obj, offset=offset) + return + + manager.emit_region_changed(obj, offset, mode=focus_manager.STRUCTURAL_NAVIGATOR) + if not notify_user: + msg = "STRUCTURAL NAVIGATOR: _present_object called with notify_user=False" + debug.print_message(debug.LEVEL_INFO, msg, True) + manager.set_locus_of_focus(None, obj, False) + if AXObject.supports_text(obj): + script.utilities.set_caret_position(obj, offset) + return + + script.present_object(obj, offset=offset, interrupt=True) + + def _present_object_list( + self, + script: default.Script, + objects: list[Atspi.Accessible], + dialog_title: str, + column_headers: list[str], + row_data_func: Callable, + notify_user: bool = True, + ) -> None: + dialog_title = f"{dialog_title}: {messages.items_found(len(objects))}" + if not objects: + if notify_user: + presentation_manager.get_manager().present_message(dialog_title) + return + + current_object = script.utilities.get_caret_context()[0] + try: + index = objects.index(current_object) + except ValueError: + index = 0 + + rows = [(obj, -1, *row_data_func(obj)) for obj in objects] + cthulhu_gui_navlist.show_ui(dialog_title, column_headers, rows, index) + + def _determine_root_container(self, script: default.Script) -> Atspi.Accessible: + mode = self.get_mode(script) + focus = focus_manager.get_manager().get_locus_of_focus() + root = AXUtilities.find_ancestor_inclusive(focus, AXUtilities.is_modal_dialog) + if root is None: + if mode == NavigationMode.DOCUMENT: + root = script.utilities.get_top_level_document_for_object(focus) + elif mode == NavigationMode.GUI: + root = AXUtilities.find_ancestor_inclusive(focus, AXUtilities.is_dialog_or_window) + if root is None: + root = focus_manager.get_manager().get_active_window() + + tokens = ["STRUCTURAL NAVIGATOR: Root for", focus, "is", root, f"mode: {mode}"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return root + + def _is_non_document_object(self, obj: Atspi.Accessible, must_be_showing: bool = True) -> bool: + if AXUtilities.find_ancestor_inclusive(obj, AXUtilities.is_document) is not None: + return False + return not (must_be_showing and not AXUtilities.is_showing(obj)) + + ######################## + # # + # Blockquotes # + # # + ######################## + + def _get_all_blockquotes(self, script: default.Script) -> list[Atspi.Accessible]: + pred = None + if self.get_mode(script) == NavigationMode.GUI: + pred = self._is_non_document_object + + root = self._determine_root_container(script) + return AXUtilities.find_all_block_quotes(root, pred=pred) + + @dbus_service.command + def previous_blockquote( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous blockquote.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_blockquote. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_blockquotes(script) + result = self._get_object_in_direction(script, matches, False) + self._present_object(script, result, messages.NO_MORE_BLOCKQUOTES, notify_user=notify_user) + return True + + @dbus_service.command + def next_blockquote( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next blockquote.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_blockquote. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_blockquotes(script) + result = self._get_object_in_direction(script, matches, True) + self._present_object(script, result, messages.NO_MORE_BLOCKQUOTES, notify_user=notify_user) + return True + + @dbus_service.command + def list_blockquotes( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Displays a list of blockquotes.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: list_blockquotes. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + self._present_object_list( + script, + self._get_all_blockquotes(script), + guilabels.SN_TITLE_BLOCKQUOTE, + [guilabels.SN_HEADER_BLOCKQUOTE], + lambda obj: [self._get_item_string(script, obj)], + notify_user=notify_user, + ) + return True + + ######################## + # # + # Buttons # + # # + ######################## + + def _get_all_buttons(self, script: default.Script) -> list[Atspi.Accessible]: + pred = None + if self.get_mode(script) == NavigationMode.GUI: + pred = self._is_non_document_object + + root = self._determine_root_container(script) + return AXUtilities.find_all_buttons(root, pred=pred) + + @dbus_service.command + def previous_button( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous button.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_button. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_buttons(script) + result = self._get_object_in_direction(script, matches, False) + self._present_object(script, result, messages.NO_MORE_BUTTONS, notify_user=notify_user) + return True + + @dbus_service.command + def next_button( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next button.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_button. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_buttons(script) + result = self._get_object_in_direction(script, matches, True) + self._present_object(script, result, messages.NO_MORE_BUTTONS, notify_user=notify_user) + return True + + @dbus_service.command + def list_buttons( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Displays a list of buttons.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: list_buttons. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + self._present_object_list( + script, + self._get_all_buttons(script), + guilabels.SN_TITLE_BUTTON, + [guilabels.SN_HEADER_BUTTON], + lambda obj: [self._get_item_string(script, obj)], + notify_user=notify_user, + ) + return True + + ######################## + # # + # Check boxes # + # # + ######################## + + def _get_all_checkboxes(self, script: default.Script) -> list[Atspi.Accessible]: + pred = None + if self.get_mode(script) == NavigationMode.GUI: + pred = self._is_non_document_object + + root = self._determine_root_container(script) + return AXUtilities.find_all_check_boxes(root, pred=pred) + + @dbus_service.command + def previous_checkbox( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous checkbox.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_checkbox. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_checkboxes(script) + result = self._get_object_in_direction(script, matches, False) + self._present_object(script, result, messages.NO_MORE_CHECK_BOXES, notify_user=notify_user) + return True + + @dbus_service.command + def next_checkbox( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next checkbox.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_checkbox. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_checkboxes(script) + result = self._get_object_in_direction(script, matches, True) + self._present_object(script, result, messages.NO_MORE_CHECK_BOXES, notify_user=notify_user) + return True + + @dbus_service.command + def list_checkboxes( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Displays a list of checkboxes.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: list_checkboxes. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + self._present_object_list( + script, + self._get_all_checkboxes(script), + guilabels.SN_TITLE_CHECK_BOX, + [guilabels.SN_HEADER_CHECK_BOX, guilabels.SN_HEADER_STATE], + lambda obj: [self._get_item_string(script, obj), self._get_state_string(obj)], + notify_user=notify_user, + ) + return True + + ######################## + # # + # Large Objects # + # # + ######################## + + def _get_all_large_objects(self, script: default.Script) -> list[Atspi.Accessible]: + minimum_length = self.get_large_object_text_length() + + def _is_large(obj): + if AXUtilities.is_heading(obj): + return True + if AXUtilities.is_list(obj): + return True + if AXUtilities.is_table(obj): + return True + text = AXText.get_all_text(obj) + return len(text) > minimum_length and text.count("\ufffc") / len(text) < 0.05 + + root = self._determine_root_container(script) + roles = [ + *AXUtilities.get_large_container_roles(), + Atspi.Role.HEADING, + Atspi.Role.PARAGRAPH, + Atspi.Role.SECTION, + ] + return AXUtilities.find_all_with_role(root, roles, pred=_is_large) + + @dbus_service.command + def previous_large_object( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous large object.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_large_object. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_large_objects(script) + result = self._get_object_in_direction(script, matches, False) + self._present_object( + script, + result, + messages.NO_MORE_LARGE_OBJECTS, + notify_user=notify_user, + ) + return True + + @dbus_service.command + def next_large_object( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next large object.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_large_object. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_large_objects(script) + result = self._get_object_in_direction(script, matches, True) + self._present_object( + script, + result, + messages.NO_MORE_LARGE_OBJECTS, + notify_user=notify_user, + ) + return True + + @dbus_service.command + def list_large_objects( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Displays a list of large objects.""" + + tokens = ["STRUCTURAL NAVIGATOR: list_large_objects. Script:", script, "Event:", event] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + self._present_object_list( + script, + self._get_all_large_objects(script), + guilabels.SN_TITLE_LARGE_OBJECT, + [guilabels.SN_HEADER_OBJECT, guilabels.SN_HEADER_ROLE], + lambda obj: [ + self._get_item_string(script, obj), + AXUtilities.get_localized_role_name(obj), + ], + notify_user=notify_user, + ) + return True + + ######################## + # # + # Combo Boxes # + # # + ######################## + + def _get_all_comboboxes(self, script: default.Script) -> list[Atspi.Accessible]: + pred = None + if self.get_mode(script) == NavigationMode.GUI: + pred = self._is_non_document_object + + root = self._determine_root_container(script) + return AXUtilities.find_all_combo_boxes(root, pred=pred) + + @dbus_service.command + def previous_combobox( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous combo box.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_combobox. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_comboboxes(script) + result = self._get_object_in_direction(script, matches, False) + self._present_object(script, result, messages.NO_MORE_COMBO_BOXES, notify_user=notify_user) + return True + + @dbus_service.command + def next_combobox( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next combo box.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_combobox. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_comboboxes(script) + result = self._get_object_in_direction(script, matches, True) + self._present_object(script, result, messages.NO_MORE_COMBO_BOXES, notify_user=notify_user) + return True + + @dbus_service.command + def list_comboboxes( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Displays a list of combo boxes.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: list_comboboxes. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + self._present_object_list( + script, + self._get_all_comboboxes(script), + guilabels.SN_TITLE_COMBO_BOX, + [guilabels.SN_HEADER_COMBO_BOX], + lambda obj: [self._get_item_string(script, obj)], + notify_user=notify_user, + ) + return True + + ######################## + # # + # Entries # + # # + ######################## + + def _get_all_entries(self, script: default.Script) -> list[Atspi.Accessible]: + def parent_is_not_editable(obj): + parent = AXObject.get_parent(obj) + return parent is not None and not AXUtilities.is_editable(parent) + + if self.get_mode(script) == NavigationMode.GUI: + + def pred(x): + return self._is_non_document_object(x) + else: + pred = parent_is_not_editable + + root = self._determine_root_container(script) + return AXUtilities.find_all_editable_objects(root, pred=pred) + + @dbus_service.command + def previous_entry( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous entry.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_entry. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_entries(script) + result = self._get_object_in_direction(script, matches, False) + self._present_object(script, result, messages.NO_MORE_ENTRIES, notify_user=notify_user) + return True + + @dbus_service.command + def next_entry( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next entry.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_entry. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_entries(script) + result = self._get_object_in_direction(script, matches, True) + self._present_object(script, result, messages.NO_MORE_ENTRIES, notify_user=notify_user) + return True + + @dbus_service.command + def list_entries( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Displays a list of entries.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: list_entries. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + self._present_object_list( + script, + self._get_all_entries(script), + guilabels.SN_TITLE_ENTRY, + [guilabels.SN_HEADER_LABEL, guilabels.SN_HEADER_VALUE], + lambda obj: [self._get_item_string(script, obj), AXText.get_all_text(obj)], + notify_user=notify_user, + ) + return True + + ######################## + # # + # Form Fields # + # # + ######################## + + def _get_all_form_fields(self, script: default.Script) -> list[Atspi.Accessible]: + def is_not_noneditable_doc_frame(obj): + if AXUtilities.is_document_frame(obj): + return AXUtilities.is_editable(obj) + return True + + def pred(x): + if self.get_mode(script) == NavigationMode.GUI: + return self._is_non_document_object(x) + return is_not_noneditable_doc_frame(x) + + root = self._determine_root_container(script) + return AXUtilities.find_all_form_fields(root, pred=pred) + + @dbus_service.command + def previous_form_field( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous form field.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_form_field. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_form_fields(script) + result = self._get_object_in_direction(script, matches, False) + self._present_object(script, result, messages.NO_MORE_FORM_FIELDS, notify_user=notify_user) + return True + + @dbus_service.command + def next_form_field( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next form field.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_form_field. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_form_fields(script) + result = self._get_object_in_direction(script, matches, True) + self._present_object(script, result, messages.NO_MORE_FORM_FIELDS, notify_user=notify_user) + return True + + @dbus_service.command + def list_form_fields( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Displays a list of form fields.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: list_form_fields. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + self._present_object_list( + script, + self._get_all_form_fields(script), + guilabels.SN_TITLE_FORM_FIELD, + [guilabels.SN_HEADER_LABEL, guilabels.SN_HEADER_ROLE, guilabels.SN_HEADER_VALUE], + lambda obj: [ + self._get_item_string(script, obj), + AXUtilities.get_localized_role_name(obj), + AXText.get_all_text(obj), + ], + notify_user=notify_user, + ) + return True + + ######################## + # # + # Headings # + # # + ######################## + + def _get_all_headings( + self, + script: default.Script, + level: int | None = None, + ) -> list[Atspi.Accessible]: + pred = None + if self.get_mode(script) == NavigationMode.GUI: + pred = self._is_non_document_object + + root = self._determine_root_container(script) + if level is None: + return AXUtilities.find_all_headings(root, pred=pred) + return AXUtilities.find_all_headings_at_level(root, level, pred=pred) + + @dbus_service.command + def previous_heading( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous heading.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_heading. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_headings(script) + result = self._get_object_in_direction(script, matches, False) + self._present_object(script, result, messages.NO_MORE_HEADINGS, notify_user=notify_user) + return True + + @dbus_service.command + def next_heading( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next heading.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_heading. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_headings(script) + result = self._get_object_in_direction(script, matches, True) + self._present_object(script, result, messages.NO_MORE_HEADINGS, notify_user=notify_user) + return True + + @dbus_service.command + def list_headings( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Displays a list of headings.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: list_headings. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + self._present_object_list( + script, + self._get_all_headings(script), + guilabels.SN_TITLE_HEADING, + [guilabels.SN_HEADER_HEADING, guilabels.SN_HEADER_LEVEL], + lambda obj: [ + self._get_item_string(script, obj), + str(AXUtilities.get_heading_level(obj)), + ], + notify_user=notify_user, + ) + return True + + @dbus_service.command + def previous_heading_level_1( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous level 1 heading.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_heading_level_1. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_headings(script, 1) + result = self._get_object_in_direction(script, matches, False) + self._present_object( + script, + result, + messages.NO_MORE_HEADINGS_AT_LEVEL % 1, + notify_user=notify_user, + ) + return True + + @dbus_service.command + def next_heading_level_1( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next level 1 heading.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_heading_level_1. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_headings(script, 1) + result = self._get_object_in_direction(script, matches, True) + self._present_object( + script, + result, + messages.NO_MORE_HEADINGS_AT_LEVEL % 1, + notify_user=notify_user, + ) + return True + + @dbus_service.command + def list_headings_level_1( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Displays a list of level 1 headings.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: list_headings_level_1. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + self._present_object_list( + script, + self._get_all_headings(script), + guilabels.SN_TITLE_HEADING_AT_LEVEL % 1, + [guilabels.SN_HEADER_HEADING], + lambda obj: [self._get_item_string(script, obj)], + notify_user=notify_user, + ) + return True + + @dbus_service.command + def previous_heading_level_2( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous level 2 heading.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_heading_level_2. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_headings(script, 2) + result = self._get_object_in_direction(script, matches, False) + self._present_object( + script, + result, + messages.NO_MORE_HEADINGS_AT_LEVEL % 2, + notify_user=notify_user, + ) + return True + + @dbus_service.command + def next_heading_level_2( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next level 2 heading.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_heading_level_2. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_headings(script, 2) + result = self._get_object_in_direction(script, matches, True) + self._present_object( + script, + result, + messages.NO_MORE_HEADINGS_AT_LEVEL % 2, + notify_user=notify_user, + ) + return True + + @dbus_service.command + def list_headings_level_2( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Displays a list of level 2 headings.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: list_headings_level_2. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + self._present_object_list( + script, + self._get_all_headings(script), + guilabels.SN_TITLE_HEADING_AT_LEVEL % 2, + [guilabels.SN_HEADER_HEADING], + lambda obj: [self._get_item_string(script, obj)], + notify_user=notify_user, + ) + return True + + @dbus_service.command + def previous_heading_level_3( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous level 3 heading.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_heading_level_3. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_headings(script, 3) + result = self._get_object_in_direction(script, matches, False) + self._present_object( + script, + result, + messages.NO_MORE_HEADINGS_AT_LEVEL % 3, + notify_user=notify_user, + ) + return True + + @dbus_service.command + def next_heading_level_3( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next level 3 heading.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_heading_level_3. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_headings(script, 3) + result = self._get_object_in_direction(script, matches, True) + self._present_object( + script, + result, + messages.NO_MORE_HEADINGS_AT_LEVEL % 3, + notify_user=notify_user, + ) + return True + + @dbus_service.command + def list_headings_level_3( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Displays a list of level 3 headings.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: list_headings_level_3. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + self._present_object_list( + script, + self._get_all_headings(script), + guilabels.SN_TITLE_HEADING_AT_LEVEL % 3, + [guilabels.SN_HEADER_HEADING], + lambda obj: [self._get_item_string(script, obj)], + notify_user=notify_user, + ) + return True + + @dbus_service.command + def previous_heading_level_4( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous level 4 heading.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_heading_level_4. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_headings(script, 4) + result = self._get_object_in_direction(script, matches, False) + self._present_object( + script, + result, + messages.NO_MORE_HEADINGS_AT_LEVEL % 4, + notify_user=notify_user, + ) + return True + + @dbus_service.command + def next_heading_level_4( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next level 4 heading.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_heading_level_4. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_headings(script, 4) + result = self._get_object_in_direction(script, matches, True) + self._present_object( + script, + result, + messages.NO_MORE_HEADINGS_AT_LEVEL % 4, + notify_user=notify_user, + ) + return True + + @dbus_service.command + def list_headings_level_4( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Displays a list of level 4 headings.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: list_headings_level_4. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + self._present_object_list( + script, + self._get_all_headings(script), + guilabels.SN_TITLE_HEADING_AT_LEVEL % 4, + [guilabels.SN_HEADER_HEADING], + lambda obj: [self._get_item_string(script, obj)], + notify_user=notify_user, + ) + return True + + @dbus_service.command + def previous_heading_level_5( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous level 5 heading.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_heading_level_5. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_headings(script, 5) + result = self._get_object_in_direction(script, matches, False) + self._present_object( + script, + result, + messages.NO_MORE_HEADINGS_AT_LEVEL % 5, + notify_user=notify_user, + ) + return True + + @dbus_service.command + def next_heading_level_5( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next level 5 heading.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_heading_level_5. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_headings(script, 5) + result = self._get_object_in_direction(script, matches, True) + self._present_object( + script, + result, + messages.NO_MORE_HEADINGS_AT_LEVEL % 5, + notify_user=notify_user, + ) + return True + + @dbus_service.command + def list_headings_level_5( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Displays a list of level 5 headings.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: list_headings_level_5. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + self._present_object_list( + script, + self._get_all_headings(script), + guilabels.SN_TITLE_HEADING_AT_LEVEL % 5, + [guilabels.SN_HEADER_HEADING], + lambda obj: [self._get_item_string(script, obj)], + notify_user=notify_user, + ) + return True + + @dbus_service.command + def previous_heading_level_6( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous level 6 heading.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_heading_level_6. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_headings(script, 6) + result = self._get_object_in_direction(script, matches, False) + self._present_object( + script, + result, + messages.NO_MORE_HEADINGS_AT_LEVEL % 6, + notify_user=notify_user, + ) + return True + + @dbus_service.command + def next_heading_level_6( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next level 6 heading.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_heading_level_6. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_headings(script, 6) + result = self._get_object_in_direction(script, matches, True) + self._present_object( + script, + result, + messages.NO_MORE_HEADINGS_AT_LEVEL % 6, + notify_user=notify_user, + ) + return True + + @dbus_service.command + def list_headings_level_6( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Displays a list of level 6 headings.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: list_headings_level_6. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + self._present_object_list( + script, + self._get_all_headings(script), + guilabels.SN_TITLE_HEADING_AT_LEVEL % 6, + [guilabels.SN_HEADER_HEADING], + lambda obj: [self._get_item_string(script, obj)], + notify_user=notify_user, + ) + return True + + ######################## + # # + # Iframes # + # # + ######################## + + def _get_all_iframes(self, script: default.Script) -> list[Atspi.Accessible]: + pred = None + if self.get_mode(script) == NavigationMode.GUI: + pred = self._is_non_document_object + + root = self._determine_root_container(script) + return AXUtilities.find_all_internal_frames(root, pred=pred) + + @dbus_service.command + def previous_iframe( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous iframe.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_iframe. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_iframes(script) + result = self._get_object_in_direction(script, matches, False) + self._present_object(script, result, messages.NO_MORE_IFRAMES, notify_user=notify_user) + return True + + @dbus_service.command + def next_iframe( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next iframe.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_iframe. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_iframes(script) + result = self._get_object_in_direction(script, matches, True) + self._present_object(script, result, messages.NO_MORE_IFRAMES, notify_user=notify_user) + return True + + @dbus_service.command + def list_iframes( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Displays a list of iframes.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: list_iframes. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + self._present_object_list( + script, + self._get_all_iframes(script), + guilabels.SN_TITLE_IFRAME, + [guilabels.SN_HEADER_IFRAME], + lambda obj: [self._get_item_string(script, obj)], + notify_user=notify_user, + ) + return True + + ######################## + # # + # Images # + # # + ######################## + + def _get_all_images(self, script: default.Script) -> list[Atspi.Accessible]: + pred = None + if self.get_mode(script) == NavigationMode.GUI: + pred = self._is_non_document_object + + root = self._determine_root_container(script) + return AXUtilities.find_all_images_and_image_maps(root, pred=pred) + + @dbus_service.command + def previous_image( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous image.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_image. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_images(script) + result = self._get_object_in_direction(script, matches, False) + self._present_object(script, result, messages.NO_MORE_IMAGES, notify_user=notify_user) + return True + + @dbus_service.command + def next_image( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next image.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_image. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_images(script) + result = self._get_object_in_direction(script, matches, True) + self._present_object(script, result, messages.NO_MORE_IMAGES, notify_user=notify_user) + return True + + @dbus_service.command + def list_images( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Displays a list of images.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: list_images. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + self._present_object_list( + script, + self._get_all_images(script), + guilabels.SN_TITLE_IMAGE, + [guilabels.SN_HEADER_IMAGE], + lambda obj: [self._get_item_string(script, obj)], + notify_user=notify_user, + ) + return True + + ######################## + # # + # Landmarks # + # # + ######################## + + def _get_all_landmarks(self, script: default.Script) -> list[Atspi.Accessible]: + pred = None + if self.get_mode(script) == NavigationMode.GUI: + pred = self._is_non_document_object + + root = self._determine_root_container(script) + return AXUtilities.find_all_landmarks(root, pred=pred) + + def _present_landmark( + self, + script: default.Script, + obj: Atspi.Accessible, + notify_user: bool, + ) -> None: + if obj is None: + self._present_object(script, obj, messages.NO_LANDMARK_FOUND, notify_user=notify_user) + return + + if notify_user: + presentation_manager.get_manager().present_message(AXObject.get_name(obj)) + self._present_line(script, obj, 0) + + @dbus_service.command + def previous_landmark( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous landmark.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_landmark. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_landmarks(script) + result = self._get_object_in_direction(script, matches, False) + self._present_landmark(script, result, notify_user) + return True + + @dbus_service.command + def next_landmark( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next landmark.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_landmark. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_landmarks(script) + result = self._get_object_in_direction(script, matches, True) + self._present_landmark(script, result, notify_user) + return True + + @dbus_service.command + def list_landmarks( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Displays a list of landmarks.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: list_landmarks. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + self._present_object_list( + script, + self._get_all_landmarks(script), + guilabels.SN_TITLE_LANDMARK, + [guilabels.SN_HEADER_LANDMARK, guilabels.SN_HEADER_ROLE], + lambda obj: [ + self._get_item_string(script, obj), + AXUtilities.get_localized_role_name(obj), + ], + notify_user=notify_user, + ) + return True + + ######################## + # # + # Lists # + # # + ######################## + + def _get_all_lists(self, script: default.Script) -> list[Atspi.Accessible]: + pred = None + if self.get_mode(script) == NavigationMode.GUI: + pred = self._is_non_document_object + + root = self._determine_root_container(script) + return AXUtilities.find_all_lists( + root, + include_description_lists=True, + include_tab_lists=True, + pred=pred, + ) + + def _get_first_item(self, obj: Atspi.Accessible) -> Atspi.Accessible | None: + # The reason we present the item (or first child) rather than the full list are twofold: + # 1. Given a huge list, navigating to the item and presenting the ancestor list is more + # performant. + # 2. When we calculate what's on the same line, it should be based on the item's bounding + # box; not the list's. + # TODO - JD: Handle the second issue in the utilities which calculate the line. + return AXObject.get_child(obj, 0) + + @dbus_service.command + def previous_list( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous list.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_list. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_lists(script) + result = self._get_object_in_direction(script, matches, False) + result = self._get_first_item(result) or result + self._present_object(script, result, messages.NO_MORE_LISTS, notify_user=notify_user) + return True + + @dbus_service.command + def next_list( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next list.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_list. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_lists(script) + result = self._get_object_in_direction(script, matches, True) + result = self._get_first_item(result) or result + self._present_object(script, result, messages.NO_MORE_LISTS, notify_user=notify_user) + return True + + @dbus_service.command + def list_lists( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Displays a list of lists.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: list_lists. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + self._present_object_list( + script, + self._get_all_lists(script), + guilabels.SN_TITLE_LIST, + [guilabels.SN_HEADER_LIST], + lambda obj: [self._get_item_string(script, obj)], + notify_user=notify_user, + ) + return True + + ######################## + # # + # List Items # + # # + ######################## + + def _get_all_list_items(self, script: default.Script) -> list[Atspi.Accessible]: + pred = None + if self.get_mode(script) == NavigationMode.GUI: + pred = self._is_non_document_object + + root = self._determine_root_container(script) + return AXUtilities.find_all_list_items( + root, + include_description_terms=True, + include_tabs=True, + pred=pred, + ) + + @dbus_service.command + def previous_list_item( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous list item.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_list_item. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_list_items(script) + result = self._get_object_in_direction(script, matches, False) + self._present_object(script, result, messages.NO_MORE_LIST_ITEMS, notify_user=notify_user) + return True + + @dbus_service.command + def next_list_item( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next list item.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_list_item. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_list_items(script) + result = self._get_object_in_direction(script, matches, True) + self._present_object(script, result, messages.NO_MORE_LIST_ITEMS, notify_user=notify_user) + return True + + @dbus_service.command + def list_list_items( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Displays a list of list items.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: list_list_items. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + self._present_object_list( + script, + self._get_all_list_items(script), + guilabels.SN_TITLE_LIST_ITEM, + [guilabels.SN_HEADER_LIST_ITEM], + lambda obj: [self._get_item_string(script, obj)], + notify_user=notify_user, + ) + return True + + ######################## + # # + # Live Regions # + # # + ######################## + + def _get_all_live_regions(self, script: default.Script) -> list[Atspi.Accessible]: + pred = None + if self.get_mode(script) == NavigationMode.GUI: + pred = self._is_non_document_object + + root = self._determine_root_container(script) + return AXUtilities.find_all_live_regions(root, pred=pred) + + @dbus_service.command + def previous_live_region( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous live region.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_live_region. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_live_regions(script) + result = self._get_object_in_direction(script, matches, False) + self._present_object(script, result, messages.NO_MORE_LIVE_REGIONS, notify_user=notify_user) + return True + + @dbus_service.command + def next_live_region( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next live region.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_live_region. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_live_regions(script) + result = self._get_object_in_direction(script, matches, True) + self._present_object(script, result, messages.NO_MORE_LIVE_REGIONS, notify_user=notify_user) + return True + + def _last_live_region( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the last live region.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: last_live_region. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + live_region_presenter.get_presenter().go_last_live_region(script, event) + return True + + ######################## + # # + # Paragraphs # + # # + ######################## + + def _get_all_paragraphs(self, script: default.Script) -> list[Atspi.Accessible]: + def has_at_least_three_characters(obj): + if AXUtilities.is_heading(obj): + return True + # We're choosing 3 characters as the minimum because some paragraphs contain a single + # image or link and a text of length 2: An embedded object character and a space. + # We want to skip these. + return AXText.get_character_count(obj) > 2 + + def pred(x): + if self.get_mode(script) == NavigationMode.GUI: + return self._is_non_document_object(x) + return has_at_least_three_characters(x) + + root = self._determine_root_container(script) + return AXUtilities.find_all_paragraphs(root, True, pred=pred) + + @dbus_service.command + def previous_paragraph( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous paragraph.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_paragraph. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_paragraphs(script) + result = self._get_object_in_direction(script, matches, False) + self._present_object(script, result, messages.NO_MORE_PARAGRAPHS, notify_user=notify_user) + return True + + @dbus_service.command + def next_paragraph( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next paragraph.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_paragraph. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_paragraphs(script) + result = self._get_object_in_direction(script, matches, True) + self._present_object(script, result, messages.NO_MORE_PARAGRAPHS, notify_user=notify_user) + return True + + @dbus_service.command + def list_paragraphs( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Displays a list of paragraphs.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: list_paragraphs. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + self._present_object_list( + script, + self._get_all_paragraphs(script), + guilabels.SN_TITLE_PARAGRAPH, + [guilabels.SN_HEADER_PARAGRAPH], + lambda obj: [self._get_item_string(script, obj)], + notify_user=notify_user, + ) + return True + + ######################## + # # + # Radio Buttons # + # # + ######################## + + def _get_all_radio_buttons(self, script: default.Script) -> list[Atspi.Accessible]: + pred = None + if self.get_mode(script) == NavigationMode.GUI: + pred = self._is_non_document_object + + root = self._determine_root_container(script) + return AXUtilities.find_all_radio_buttons(root, pred=pred) + + @dbus_service.command + def previous_radio_button( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous radio button.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_radio_button. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_radio_buttons(script) + result = self._get_object_in_direction(script, matches, False) + self._present_object( + script, + result, + messages.NO_MORE_RADIO_BUTTONS, + notify_user=notify_user, + ) + return True + + @dbus_service.command + def next_radio_button( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next radio button.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_radio_button. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_radio_buttons(script) + result = self._get_object_in_direction(script, matches, True) + self._present_object( + script, + result, + messages.NO_MORE_RADIO_BUTTONS, + notify_user=notify_user, + ) + return True + + @dbus_service.command + def list_radio_buttons( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Displays a list of radio buttons.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: list_radio_buttons. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + self._present_object_list( + script, + self._get_all_radio_buttons(script), + guilabels.SN_TITLE_RADIO_BUTTON, + [guilabels.SN_HEADER_RADIO_BUTTON, guilabels.SN_HEADER_STATE], + lambda obj: [self._get_item_string(script, obj), self._get_state_string(obj)], + notify_user=notify_user, + ) + return True + + ######################## + # # + # Separators # + # # + ######################## + + def _get_all_separators(self, script: default.Script) -> list[Atspi.Accessible]: + pred = None + if self.get_mode(script) == NavigationMode.GUI: + pred = self._is_non_document_object + + root = self._determine_root_container(script) + return AXUtilities.find_all_separators(root, pred=pred) + + @dbus_service.command + def previous_separator( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous separator.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_separator. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_separators(script) + result = self._get_object_in_direction(script, matches, False) + self._present_object(script, result, messages.NO_MORE_SEPARATORS, notify_user=notify_user) + return True + + @dbus_service.command + def next_separator( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next separator.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_separator. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_separators(script) + result = self._get_object_in_direction(script, matches, True) + self._present_object(script, result, messages.NO_MORE_SEPARATORS, notify_user=notify_user) + return True + + ######################## + # # + # Tables # + # # + ######################## + + def _get_all_tables(self, script: default.Script) -> list[Atspi.Accessible]: + pred = None + if self.get_mode(script) == NavigationMode.GUI: + pred = self._is_non_document_object + + root = self._determine_root_container(script) + return AXUtilities.find_all_tables(root, pred=pred) + + def _get_first_table_cell(self, table: Atspi.Accessible) -> Atspi.Accessible | None: + # The reason we present the cell rather than the full table are twofold: + # 1. Given a huge table, navigating to the cell and presenting the ancestor table is more + # performant. + # 2. When we calculate what's on the same line, it should be based on the cell's bounding + # box; not the table's. + # TODO - JD: Handle the second issue in the utilities which calculate the line. + if not AXUtilities.is_table(table): + return None + + if cell := AXTable.get_cell_at(table, 0, 0): + return cell + + tokens = ["STRUCTURAL NAVIGATOR: Broken table interface for", table] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + cell = AXUtilities.get_table_cell(table) + if cell: + tokens = ["STRUCTURAL NAVIGATOR: Located", cell, "for first cell"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + return None + + @dbus_service.command + def previous_table( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous table.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_table. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_tables(script) + result = self._get_object_in_direction(script, matches, False) + obj = self._get_first_table_cell(result) or result + self._present_object(script, obj, messages.NO_MORE_TABLES, notify_user=notify_user) + return True + + @dbus_service.command + def next_table( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next table.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_table. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_tables(script) + result = self._get_object_in_direction(script, matches, True) + obj = self._get_first_table_cell(result) or result + self._present_object(script, obj, messages.NO_MORE_TABLES, notify_user=notify_user) + return True + + @dbus_service.command + def list_tables( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Displays a list of tables.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: list_tables. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + self._present_object_list( + script, + self._get_all_tables(script), + guilabels.SN_TITLE_TABLE, + [guilabels.SN_HEADER_CAPTION, guilabels.SN_HEADER_DESCRIPTION], + lambda obj: [ + self._get_item_string(script, obj), + AXUtilities.get_table_description_for_presentation(obj), + ], + notify_user=notify_user, + ) + return True + + ######################## + # # + # Unvisited Links # + # # + ######################## + + def _get_all_unvisited_links(self, script: default.Script) -> list[Atspi.Accessible]: + pred = None + if self.get_mode(script) == NavigationMode.GUI: + pred = self._is_non_document_object + + root = self._determine_root_container(script) + return AXUtilities.find_all_unvisited_links(root, pred=pred) + + @dbus_service.command + def previous_unvisited_link( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous unvisited link.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_unvisited_link. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_unvisited_links(script) + result = self._get_object_in_direction(script, matches, False) + self._present_object( + script, + result, + messages.NO_MORE_UNVISITED_LINKS, + notify_user=notify_user, + ) + return True + + @dbus_service.command + def next_unvisited_link( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next unvisited link.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_unvisited_link. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_unvisited_links(script) + result = self._get_object_in_direction(script, matches, True) + self._present_object( + script, + result, + messages.NO_MORE_UNVISITED_LINKS, + notify_user=notify_user, + ) + return True + + @dbus_service.command + def list_unvisited_links( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Displays a list of unvisited links.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: list_unvisited_links. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + self._present_object_list( + script, + self._get_all_unvisited_links(script), + guilabels.SN_TITLE_UNVISITED_LINK, + [guilabels.SN_HEADER_LINK, guilabels.SN_HEADER_URI], + lambda obj: [self._get_item_string(script, obj), AXHypertext.get_link_uri(obj)], + notify_user=notify_user, + ) + return True + + ######################## + # # + # Visited Links # + # # + ######################## + + def _get_all_visited_links(self, script: default.Script) -> list[Atspi.Accessible]: + pred = None + if self.get_mode(script) == NavigationMode.GUI: + pred = self._is_non_document_object + + root = self._determine_root_container(script) + return AXUtilities.find_all_visited_links(root, pred=pred) + + @dbus_service.command + def previous_visited_link( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous visited link.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_visited_link. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_visited_links(script) + result = self._get_object_in_direction(script, matches, False) + self._present_object( + script, + result, + messages.NO_MORE_VISITED_LINKS, + notify_user=notify_user, + ) + return True + + @dbus_service.command + def next_visited_link( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next visited link.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_visited_link. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_visited_links(script) + result = self._get_object_in_direction(script, matches, True) + self._present_object( + script, + result, + messages.NO_MORE_VISITED_LINKS, + notify_user=notify_user, + ) + return True + + @dbus_service.command + def list_visited_links( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Displays a list of visited links.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: list_visited_links. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + self._present_object_list( + script, + self._get_all_visited_links(script), + guilabels.SN_TITLE_VISITED_LINK, + [guilabels.SN_HEADER_LINK, guilabels.SN_HEADER_URI], + lambda obj: [self._get_item_string(script, obj), AXHypertext.get_link_uri(obj)], + notify_user=notify_user, + ) + return True + + ######################## + # # + # Links # + # # + ######################## + + def _get_all_links(self, script: default.Script) -> list[Atspi.Accessible]: + pred = None + if self.get_mode(script) == NavigationMode.GUI: + pred = self._is_non_document_object + + root = self._determine_root_container(script) + return AXUtilities.find_all_links(root, pred=pred) + + @dbus_service.command + def previous_link( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous link.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_link. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_links(script) + result = self._get_object_in_direction(script, matches, False) + self._present_object(script, result, messages.NO_MORE_LINKS, notify_user=notify_user) + return True + + @dbus_service.command + def next_link( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next link.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_link. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_links(script) + result = self._get_object_in_direction(script, matches, True) + self._present_object(script, result, messages.NO_MORE_LINKS, notify_user=notify_user) + return True + + @dbus_service.command + def list_links( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Displays a list of links.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: list_links. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + self._present_object_list( + script, + self._get_all_links(script), + guilabels.SN_TITLE_LINK, + [guilabels.SN_HEADER_LINK, guilabels.SN_HEADER_STATE, guilabels.SN_HEADER_URI], + lambda obj: [ + self._get_item_string(script, obj), + self._get_state_string(obj), + AXHypertext.get_link_uri(obj), + ], + notify_user=notify_user, + ) + return True + + ######################## + # # + # Clickables # + # # + ######################## + + def _get_all_clickables(self, script: default.Script) -> list[Atspi.Accessible]: + pred = None + if self.get_mode(script) == NavigationMode.GUI: + pred = self._is_non_document_object + + root = self._determine_root_container(script) + result = AXUtilities.find_all_clickables(root, pred=pred) + result += AXUtilities.find_all_focusable_objects_with_click_ancestor(root, pred=pred) + return result + + @dbus_service.command + def previous_clickable( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the previous clickable.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: previous_clickable. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_clickables(script) + result = self._get_object_in_direction(script, matches, False) + self._present_object(script, result, messages.NO_MORE_CLICKABLES, notify_user=notify_user) + return True + + @dbus_service.command + def next_clickable( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Goes to the next clickable.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: next_clickable. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + matches = self._get_all_clickables(script) + result = self._get_object_in_direction(script, matches, True) + self._present_object(script, result, messages.NO_MORE_CLICKABLES, notify_user=notify_user) + return True + + @dbus_service.command + def list_clickables( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Displays a list of clickables.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: list_clickables. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + self._present_object_list( + script, + self._get_all_clickables(script), + guilabels.SN_TITLE_CLICKABLE, + [guilabels.SN_HEADER_CLICKABLE, guilabels.SN_HEADER_ROLE], + lambda obj: [ + self._get_item_string(script, obj), + AXUtilities.get_localized_role_name(obj), + ], + ) + return True + + ######################## + # # + # Containers # + # # + ######################## + + def _get_current_container(self, script: default.Script) -> Atspi.Accessible | None: + focus = focus_manager.get_manager().get_locus_of_focus() + if container := AXUtilities.find_ancestor_inclusive(focus, AXUtilities.is_large_container): + root = self._determine_root_container(script) + if not AXUtilities.is_ancestor(container, root): + return None + return container + + @dbus_service.command + def container_start( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Moves to the start of the current container.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: container_start. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + container = self._get_current_container(script) + if container is None: + if notify_user: + presentation_manager.get_manager().present_message(messages.CONTAINER_NOT_IN_A) + return True + + obj, offset = script.utilities.next_context(container, -1) + self._present_line(script, obj, offset, notify_user) + return True + + @dbus_service.command + def container_end( + self, + script: default.Script, + event: InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Moves to the end of the current container.""" + + tokens = [ + "STRUCTURAL NAVIGATOR: container_end. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + container = self._get_current_container(script) + if container is None: + if notify_user: + presentation_manager.get_manager().present_message(messages.CONTAINER_NOT_IN_A) + return True + + # Unlike going to the start of the container, when we move to the next edge + # we pass beyond it on purpose. This makes us consistent with NVDA. + obj, offset = script.utilities.last_context(container) + next_object, next_offset = script.utilities.next_context(obj, offset) + if next_object is None: + next_object, next_offset = obj, offset + + self._present_line(script, next_object, next_offset, notify_user) + return True + + +_navigator = StructuralNavigator() + + +def get_navigator() -> StructuralNavigator: + """Returns the Structural Navigator""" + + return _navigator diff --git a/src/cthulhu/system_information_presenter.py b/src/cthulhu/system_information_presenter.py new file mode 100644 index 0000000..759c658 --- /dev/null +++ b/src/cthulhu/system_information_presenter.py @@ -0,0 +1,466 @@ +# Cthulhu +# +# Copyright 2005-2008 Sun Microsystems Inc. +# Copyright 2016-2024 Igalia, S.L. +# Copyright 2024 GNOME Foundation 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. + +"""Module for presenting system information""" + +from __future__ import annotations + +import time +from enum import Enum +from typing import TYPE_CHECKING, Any + +_PSUTIL_AVAILABLE = False # pylint: disable=invalid-name +try: + import psutil # type: ignore + + _PSUTIL_AVAILABLE = True # pylint: disable=invalid-name +except ModuleNotFoundError: + pass + +from . import ( + cmdnames, + command_manager, + dbus_service, + debug, + gsettings_registry, + guilabels, + input_event, + keybindings, + messages, + preferences_grid_base, + presentation_manager, +) + + +class DateFormat(Enum): + """Date format enumeration with format strings.""" + + LOCALE = "%x" + NUMBERS_DM = "%d/%m" + NUMBERS_MD = "%m/%d" + NUMBERS_DMY = "%d/%m/%Y" + NUMBERS_MDY = "%m/%d/%Y" + NUMBERS_YMD = "%Y/%m/%d" + FULL_DM = "%A, %-d %B" + FULL_MD = "%A, %B %-d" + FULL_DMY = "%A, %-d %B, %Y" + FULL_MDY = "%A, %B %-d, %Y" + FULL_YMD = "%Y. %B %-d, %A" + ABBREVIATED_DM = "%a, %-d %b" + ABBREVIATED_MD = "%a, %b %-d" + ABBREVIATED_DMY = "%a, %-d %b, %Y" + ABBREVIATED_MDY = "%a, %b %-d, %Y" + ABBREVIATED_YMD = "%Y. %b %-d, %a" + + @property + def string_name(self) -> str: + """Returns the lowercase string name for this enum value.""" + + return self.name.lower() + + +class TimeFormat(Enum): + """Time format enumeration with format strings.""" + + LOCALE = "%X" + TWELVE_HM = "%I:%M %p" + TWELVE_HMS = "%I:%M:%S %p" + TWENTYFOUR_HM = "%H:%M" + TWENTYFOUR_HMS = "%H:%M:%S" + TWENTYFOUR_HM_WITH_WORDS = messages.TIME_FORMAT_24_HM_WITH_WORDS + TWENTYFOUR_HMS_WITH_WORDS = messages.TIME_FORMAT_24_HMS_WITH_WORDS + + @property + def string_name(self) -> str: + """Returns the lowercase string name for this enum value.""" + + return self.name.lower() + + +class TimeAndDatePreferencesGrid(preferences_grid_base.AutoPreferencesGrid): + """GtkGrid containing the Time and Date preferences page.""" + + _gsettings_schema = "system-information" + + def __init__(self, presenter: SystemInformationPresenter) -> None: + """Initialize the preferences grid.""" + + # Generate display options (strftime examples) and values (format strings) + date_options = [] + date_values = [] + for fmt in DateFormat: + example = time.strftime(fmt.value, time.localtime()) + date_options.append(example) + date_values.append(fmt.value) + + time_options = [] + time_values = [] + for time_fmt in TimeFormat: + example = time.strftime(time_fmt.value, time.localtime()) + time_options.append(example) + time_values.append(time_fmt.value) + + controls = [ + preferences_grid_base.EnumPreferenceControl( + label=guilabels.GENERAL_DATE_FORMAT, + options=date_options, + values=date_values, + getter=presenter._get_date_format_string, + setter=presenter._set_date_format_string, + prefs_key=SystemInformationPresenter.KEY_DATE_FORMAT, + member_of=guilabels.TIME_AND_DATE, + ), + preferences_grid_base.EnumPreferenceControl( + label=guilabels.GENERAL_TIME_FORMAT, + options=time_options, + values=time_values, + getter=presenter._get_time_format_string, + setter=presenter._set_time_format_string, + prefs_key=SystemInformationPresenter.KEY_TIME_FORMAT, + member_of=guilabels.TIME_AND_DATE, + ), + ] + + super().__init__(guilabels.KB_GROUP_SYSTEM_INFORMATION, controls) + + +if TYPE_CHECKING: + from .scripts import default + + +@gsettings_registry.get_registry().gsettings_schema( + "org.stormux.Cthulhu.SystemInformation", + name="system-information", +) +class SystemInformationPresenter: + """Provides commands to present system information.""" + + _SCHEMA = "system-information" + KEY_DATE_FORMAT = "date-format" + KEY_TIME_FORMAT = "time-format" + + def _get_setting(self, key: str, gtype: str, default: Any) -> Any: + """Returns the dconf value for key, or default if not in dconf.""" + + return gsettings_registry.get_registry().layered_lookup( + self._SCHEMA, + key, + gtype, + default=default, + ) + + def __init__(self) -> None: + self._initialized: bool = False + + msg = "SYSTEM INFORMATION PRESENTER: Registering D-Bus commands." + debug.print_message(debug.LEVEL_INFO, msg, True) + controller = dbus_service.get_remote_controller() + controller.register_decorated_module("SystemInformationPresenter", 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_SYSTEM_INFORMATION + + # Keybindings (same for desktop and laptop) + kb_t = keybindings.KeyBinding("t", keybindings.CTHULHU_MODIFIER_MASK) + kb_t_2 = keybindings.KeyBinding("t", keybindings.CTHULHU_MODIFIER_MASK, click_count=2) + + # (name, function, description, keybinding) + commands_data = [ + ("presentTimeHandler", self.present_time, cmdnames.PRESENT_CURRENT_TIME, kb_t), + ("presentDateHandler", self.present_date, cmdnames.PRESENT_CURRENT_DATE, kb_t_2), + ( + "present_battery_status", + self.present_battery_status, + cmdnames.PRESENT_BATTERY_STATUS, + None, + ), + ( + "present_cpu_and_memory_usage", + self.present_cpu_and_memory_usage, + cmdnames.PRESENT_CPU_AND_MEMORY_USAGE, + None, + ), + ] + + for name, function, description, kb in commands_data: + manager.add_command( + command_manager.KeyboardCommand( + name, + function, + group_label, + description, + desktop_keybinding=kb, + laptop_keybinding=kb, + ), + ) + + msg = "SYSTEM INFORMATION PRESENTER: Commands set up." + debug.print_message(debug.LEVEL_INFO, msg, True) + + def create_time_and_date_preferences_grid(self) -> TimeAndDatePreferencesGrid: + """Returns the GtkGrid containing the time and date preferences UI.""" + + return TimeAndDatePreferencesGrid(self) + + def _get_date_format_string(self) -> str: + """Returns the current date format string for internal use.""" + + return self._get_setting(self.KEY_DATE_FORMAT, "s", "%x") + + def _get_time_format_string(self) -> str: + """Returns the current time format string for internal use.""" + + return self._get_setting(self.KEY_TIME_FORMAT, "s", "%X") + + def _set_date_format_string(self, value: str) -> bool: + """Sets the date format string directly for internal use.""" + + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, self.KEY_DATE_FORMAT, value + ) + return True + + def _set_time_format_string(self, value: str) -> bool: + """Sets the time format string directly for internal use.""" + + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, self.KEY_TIME_FORMAT, value + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_DATE_FORMAT, + schema="system-information", + gtype="s", + default="%x", + summary="Date format string", + migration_key="presentDateFormat", + ) + @dbus_service.getter + def get_date_format(self) -> str: + """Returns the current date format name.""" + + string_value = self._get_date_format_string() + for fmt in DateFormat: + if fmt.value == string_value: + return fmt.string_name + return string_value + + @dbus_service.setter + def set_date_format(self, value: str) -> bool: + """Sets the date format.""" + + try: + fmt = DateFormat[value.upper()] + except KeyError: + msg = f"SYSTEM INFORMATION PRESENTER: Invalid date format: {value}" + debug.print_message(debug.LEVEL_WARNING, msg, True) + return False + + msg = f"SYSTEM INFORMATION PRESENTER: Setting date format to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, self.KEY_DATE_FORMAT, fmt.value + ) + return True + + @dbus_service.getter + def get_available_date_formats(self) -> list[str]: + """Returns a list of available date format names.""" + + return [fmt.string_name for fmt in DateFormat] + + @gsettings_registry.get_registry().gsetting( + key=KEY_TIME_FORMAT, + schema="system-information", + gtype="s", + default="%X", + summary="Time format string", + migration_key="presentTimeFormat", + ) + @dbus_service.getter + def get_time_format(self) -> str: + """Returns the current time format name.""" + + string_value = self._get_time_format_string() + for fmt in TimeFormat: + if fmt.value == string_value: + return fmt.string_name + return string_value + + @dbus_service.setter + def set_time_format(self, value: str) -> bool: + """Sets the time format.""" + + try: + fmt = TimeFormat[value.upper()] + except KeyError: + msg = f"SYSTEM INFORMATION PRESENTER: Invalid time format: {value}" + debug.print_message(debug.LEVEL_WARNING, msg, True) + return False + + msg = f"SYSTEM INFORMATION PRESENTER: Setting time format to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, self.KEY_TIME_FORMAT, fmt.value + ) + return True + + @dbus_service.getter + def get_available_time_formats(self) -> list[str]: + """Returns a list of available time format names.""" + + return [fmt.string_name for fmt in TimeFormat] + + @dbus_service.command + def present_time( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Presents the current time.""" + + tokens = [ + "SYSTEM INFORMATION PRESENTER: present_time. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + time_format = self._get_time_format_string() + presentation_manager.get_manager().present_message( + time.strftime(time_format, time.localtime()), + ) + return True + + @dbus_service.command + def present_date( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Presents the current date.""" + + tokens = [ + "SYSTEM INFORMATION PRESENTER: present_date. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + date_format = self._get_date_format_string() + presentation_manager.get_manager().present_message( + time.strftime(date_format, time.localtime()), + ) + return True + + @dbus_service.command + def present_battery_status( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Presents the battery status.""" + + tokens = [ + "SYSTEM INFORMATION PRESENTER: present_battery_status. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + battery = psutil.sensors_battery() if _PSUTIL_AVAILABLE else None + if battery is None: + presentation_manager.get_manager().present_message(messages.BATTERY_STATUS_UNKNOWN) + return True + if battery.power_plugged: + msg = f"{messages.BATTERY_LEVEL % battery.percent} {messages.BATTERY_PLUGGED_IN_TRUE}" + else: + msg = f"{messages.BATTERY_LEVEL % battery.percent} {messages.BATTERY_PLUGGED_IN_FALSE}" + + presentation_manager.get_manager().present_message(msg) + return True + + @dbus_service.command + def present_cpu_and_memory_usage( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Presents the cpu and memory usage.""" + + tokens = [ + "SYSTEM INFORMATION PRESENTER: present_cpu_and_memory_usage. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if not _PSUTIL_AVAILABLE: + presentation_manager.get_manager().present_message( + messages.CPU_AND_MEMORY_USAGE_UNKNOWN, + ) + return True + + cpu_usage = round(psutil.cpu_percent()) + + memory = psutil.virtual_memory() + memory_percent = round(memory.percent) + if memory.total > 1024**3: + details = messages.memory_usage_gb(memory.used / (1024**3), memory.total / (1024**3)) + else: + details = messages.memory_usage_mb(memory.used / (1024**2), memory.total / (1024**2)) + + msg = f"{messages.CPU_AND_MEMORY_USAGE_LEVELS % (cpu_usage, memory_percent)}. {details}" + presentation_manager.get_manager().present_message(msg) + return True + + +_presenter = SystemInformationPresenter() + + +def get_presenter() -> SystemInformationPresenter: + """Returns the system-information-presenter singleton.""" + + return _presenter diff --git a/src/cthulhu/table_navigator.py b/src/cthulhu/table_navigator.py new file mode 100644 index 0000000..6c831b5 --- /dev/null +++ b/src/cthulhu/table_navigator.py @@ -0,0 +1,984 @@ +# Cthulhu +# +# Copyright 2005-2009 Sun Microsystems Inc. +# Copyright 2011-2023 Igalia, S.L. +# Copyright 2023 GNOME Foundation Inc. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +# pylint: disable=too-many-arguments +# pylint: disable=too-many-positional-arguments +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-lines + +"""Provides Cthulhu-controlled navigation for tabular content.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from . import ( + cmdnames, + command_manager, + dbus_service, + debug, + focus_manager, + gsettings_registry, + guilabels, + input_event, + input_event_manager, + keybindings, + messages, + presentation_manager, + speech_presenter, +) +from .ax_object import AXObject +from .ax_table import AXTable +from .ax_utilities import AXUtilities + +if TYPE_CHECKING: + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + + from .input_event import InputEvent + from .scripts import default + + +@gsettings_registry.get_registry().gsettings_schema( + "org.stormux.Cthulhu.TableNavigation", + name="table-navigation", +) +class TableNavigator: + """Provides Cthulhu-controlled navigation for tabular content.""" + + _SCHEMA = "table-navigation" + KEY_ENABLED = "enabled" + KEY_SKIP_BLANK_CELLS = "skip-blank-cells" + + 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._previous_reported_row: int | None = None + self._previous_reported_col: int | None = None + self._last_input_event: InputEvent | None = None + self._initialized: bool = False + + msg = "TABLE NAVIGATOR: Registering D-Bus commands." + debug.print_message(debug.LEVEL_INFO, msg, True) + controller = dbus_service.get_remote_controller() + controller.register_decorated_module("TableNavigator", self) + + def last_input_event_was_navigation_command(self) -> bool: + """Returns true if the last input event was a navigation command.""" + + if self._last_input_event is None: + return False + + manager = input_event_manager.get_manager() + result = manager.last_event_equals_or_is_release_for_event(self._last_input_event) + if self._last_input_event is not None: + string = self._last_input_event.as_single_line_string() + else: + string = "None" + + msg = f"TABLE NAVIGATOR: Last navigation event ({string}) is last input event: {result}" + debug.print_message(debug.LEVEL_INFO, msg, True) + return result + + # pylint: disable-next=too-many-locals + 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_TABLE_NAVIGATION + + # Keybindings (same for desktop and laptop) + kb_t = keybindings.KeyBinding("t", keybindings.CTHULHU_SHIFT_MODIFIER_MASK) + kb_left = keybindings.KeyBinding("Left", keybindings.SHIFT_ALT_MODIFIER_MASK) + kb_right = keybindings.KeyBinding("Right", keybindings.SHIFT_ALT_MODIFIER_MASK) + kb_up = keybindings.KeyBinding("Up", keybindings.SHIFT_ALT_MODIFIER_MASK) + kb_down = keybindings.KeyBinding("Down", keybindings.SHIFT_ALT_MODIFIER_MASK) + kb_home = keybindings.KeyBinding("Home", keybindings.SHIFT_ALT_MODIFIER_MASK) + kb_end = keybindings.KeyBinding("End", keybindings.SHIFT_ALT_MODIFIER_MASK) + kb_left_cthulhu = keybindings.KeyBinding("Left", keybindings.CTHULHU_ALT_SHIFT_MODIFIER_MASK) + kb_right_cthulhu = keybindings.KeyBinding("Right", keybindings.CTHULHU_ALT_SHIFT_MODIFIER_MASK) + kb_up_cthulhu = keybindings.KeyBinding("Up", keybindings.CTHULHU_ALT_SHIFT_MODIFIER_MASK) + kb_down_cthulhu = keybindings.KeyBinding("Down", keybindings.CTHULHU_ALT_SHIFT_MODIFIER_MASK) + kb_r = keybindings.KeyBinding("r", keybindings.CTHULHU_SHIFT_MODIFIER_MASK) + kb_r_2 = keybindings.KeyBinding("r", keybindings.CTHULHU_SHIFT_MODIFIER_MASK, click_count=2) + kb_c = keybindings.KeyBinding("c", keybindings.CTHULHU_SHIFT_MODIFIER_MASK) + kb_c_2 = keybindings.KeyBinding("c", keybindings.CTHULHU_SHIFT_MODIFIER_MASK, click_count=2) + + manager.add_command( + command_manager.KeyboardCommand( + "table_navigator_toggle_enabled", + self.toggle_enabled, + group_label, + cmdnames.TABLE_NAVIGATION_TOGGLE, + desktop_keybinding=kb_t, + laptop_keybinding=kb_t, + is_group_toggle=True, + ), + ) + + # (name, function, description, keybinding) + commands_data = [ + ("table_cell_left", self.move_left, cmdnames.TABLE_CELL_LEFT, kb_left), + ("table_cell_right", self.move_right, cmdnames.TABLE_CELL_RIGHT, kb_right), + ("table_cell_up", self.move_up, cmdnames.TABLE_CELL_UP, kb_up), + ("table_cell_down", self.move_down, cmdnames.TABLE_CELL_DOWN, kb_down), + ("table_cell_first", self.move_to_first_cell, cmdnames.TABLE_CELL_FIRST, kb_home), + ("table_cell_last", self.move_to_last_cell, cmdnames.TABLE_CELL_LAST, kb_end), + ( + "table_cell_beginning_of_row", + self.move_to_beginning_of_row, + cmdnames.TABLE_CELL_BEGINNING_OF_ROW, + kb_left_cthulhu, + ), + ( + "table_cell_end_of_row", + self.move_to_end_of_row, + cmdnames.TABLE_CELL_END_OF_ROW, + kb_right_cthulhu, + ), + ( + "table_cell_top_of_column", + self.move_to_top_of_column, + cmdnames.TABLE_CELL_TOP_OF_COLUMN, + kb_up_cthulhu, + ), + ( + "table_cell_bottom_of_column", + self.move_to_bottom_of_column, + cmdnames.TABLE_CELL_BOTTOM_OF_COLUMN, + kb_down_cthulhu, + ), + ( + "set_dynamic_column_headers_row", + self.set_dynamic_column_headers_row, + cmdnames.DYNAMIC_COLUMN_HEADER_SET, + kb_r, + ), + ( + "clear_dynamic_column_headers_row", + self.clear_dynamic_column_headers_row, + cmdnames.DYNAMIC_COLUMN_HEADER_CLEAR, + kb_r_2, + ), + ( + "set_dynamic_row_headers_column", + self.set_dynamic_row_headers_column, + cmdnames.DYNAMIC_ROW_HEADER_SET, + kb_c, + ), + ( + "clear_dynamic_row_headers_column", + self.clear_dynamic_row_headers_column, + cmdnames.DYNAMIC_ROW_HEADER_CLEAR, + kb_c_2, + ), + ] + + for name, function, description, kb in commands_data: + manager.add_command( + command_manager.KeyboardCommand( + name, + function, + group_label, + description, + desktop_keybinding=kb, + laptop_keybinding=kb, + ), + ) + + msg = "TABLE NAVIGATOR: Commands set up." + debug.print_message(debug.LEVEL_INFO, msg, True) + + @dbus_service.command + def toggle_enabled( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Toggles table navigation.""" + + tokens = [ + "TABLE NAVIGATOR: toggle_enabled. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + enabled = not command_manager.get_manager().is_group_enabled( + guilabels.KB_GROUP_TABLE_NAVIGATION, + ) + + if notify_user: + if enabled: + presentation_manager.get_manager().present_message( + messages.TABLE_NAVIGATION_ENABLED, + ) + else: + presentation_manager.get_manager().present_message( + messages.TABLE_NAVIGATION_DISABLED, + ) + + self.set_is_enabled(enabled) + return True + + def _is_blank(self, obj: Atspi.Accessible) -> bool: + """Returns True if obj is empty or consists of only whitespace.""" + + if AXUtilities.is_focusable(obj): + tokens = ["TABLE NAVIGATOR:", obj, "is not blank: it is focusable"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + if AXObject.get_name(obj): + tokens = ["TABLE NAVIGATOR:", obj, "is not blank: it has a name"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + if AXObject.get_child_count(obj): + for child in AXObject.iter_children(obj): + if not self._is_blank(child): + tokens = ["TABLE NAVIGATOR:", obj, "is not blank:", child, "is not blank"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + return True + + if not AXUtilities.is_whitespace_or_empty(obj): + tokens = ["TABLE NAVIGATOR:", obj, "is not blank: it has text"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + tokens = ["TABLE NAVIGATOR: Treating", obj, "as blank"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return True + + def _get_current_cell(self) -> Atspi.Accessible: + """Returns the current cell.""" + + cell = focus_manager.get_manager().get_locus_of_focus() + + # We might have nested cells. So far this has only been seen in Gtk, where the + # parent of a table cell is also a table cell. From the user's perspective, we + # are on the parent. This check also covers Writer documents in which the caret + # is likely in a paragraph child of the cell. + parent = AXObject.get_parent(cell) + if AXUtilities.is_table_cell_or_header(parent): + cell = parent + + # And we might instead be in some deeply-nested elements which display text in + # a web table, so we do one more check. + if not AXUtilities.is_table_cell_or_header(cell): + cell = AXUtilities.find_ancestor(cell, AXUtilities.is_table_cell_or_header) + + tokens = ["TABLE NAVIGATOR: Current cell is", cell] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return cell + + def _get_cell_coordinates(self, cell: Atspi.Accessible) -> tuple: + """Returns the coordinates of cell, possibly adjusted for linear movement.""" + + row, col = AXTable.get_cell_coordinates(cell, prefer_attribute=False) + if self._previous_reported_row is None or self._previous_reported_col is None: + return row, col + + # If we're in a cell that spans multiple rows and/or columns, the coordinates will refer to + # the upper left cell in the spanned range(s). We're storing the last row and column that + # we presented in order to facilitate more linear movement. Therefore, if the cell at the + # stored coordinates is the same as cell, we prefer the stored coordinates. + last_cell = AXTable.get_cell_at( + AXUtilities.get_table(cell), + self._previous_reported_row, + self._previous_reported_col, + ) + if last_cell == cell: + return self._previous_reported_row, self._previous_reported_col + + return row, col + + @dbus_service.command + def move_left( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Moves to the cell on the left.""" + + tokens = [ + "TABLE NAVIGATOR: move_left. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + current = self._get_current_cell() + if current is None: + if notify_user: + presentation_manager.get_manager().present_message(messages.TABLE_NOT_IN_A) + return True + + if AXTable.is_start_of_row(current): + if notify_user: + presentation_manager.get_manager().present_message(messages.TABLE_ROW_BEGINNING) + return True + + row, col = self._get_cell_coordinates(current) + cell = AXTable.get_cell_on_left(current) + + if self.get_skip_blank_cells(): + while cell and self._is_blank(cell) and not AXTable.is_start_of_row(cell): + cell = AXTable.get_cell_on_left(cell) + + self._present_cell(script, cell, row, col - 1, current, notify_user) + return True + + @dbus_service.command + def move_right( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Moves to the cell on the right.""" + + tokens = [ + "TABLE NAVIGATOR: move_right. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + current = self._get_current_cell() + if current is None: + if notify_user: + presentation_manager.get_manager().present_message(messages.TABLE_NOT_IN_A) + return True + + if AXTable.is_end_of_row(current): + if notify_user: + presentation_manager.get_manager().present_message(messages.TABLE_ROW_END) + return True + + row, col = self._get_cell_coordinates(current) + cell = AXTable.get_cell_on_right(current) + + if self.get_skip_blank_cells(): + while cell and self._is_blank(cell) and not AXTable.is_end_of_row(cell): + cell = AXTable.get_cell_on_right(cell) + + self._present_cell(script, cell, row, col + 1, current, notify_user) + return True + + @dbus_service.command + def move_up( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Moves to the cell above.""" + + tokens = [ + "TABLE NAVIGATOR: move_up. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + current = self._get_current_cell() + if current is None: + if notify_user: + presentation_manager.get_manager().present_message(messages.TABLE_NOT_IN_A) + return True + + if AXTable.is_top_of_column(current): + if notify_user: + presentation_manager.get_manager().present_message(messages.TABLE_COLUMN_TOP) + return True + + row, col = self._get_cell_coordinates(current) + cell = AXTable.get_cell_above(current) + + if self.get_skip_blank_cells(): + while cell and self._is_blank(cell) and not AXTable.is_top_of_column(cell): + cell = AXTable.get_cell_above(cell) + + self._present_cell(script, cell, row - 1, col, current, notify_user) + return True + + @dbus_service.command + def move_down( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Moves to the cell below.""" + + tokens = [ + "TABLE NAVIGATOR: move_down. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + current = self._get_current_cell() + if current is None: + if notify_user: + presentation_manager.get_manager().present_message(messages.TABLE_NOT_IN_A) + return True + + if AXTable.is_bottom_of_column(current): + if notify_user: + presentation_manager.get_manager().present_message(messages.TABLE_COLUMN_BOTTOM) + return True + + row, col = self._get_cell_coordinates(current) + cell = AXTable.get_cell_below(current) + + if self.get_skip_blank_cells(): + while cell and self._is_blank(cell) and not AXTable.is_bottom_of_column(cell): + cell = AXTable.get_cell_below(cell) + + self._present_cell(script, cell, row + 1, col, current, notify_user) + return True + + @dbus_service.command + def move_to_first_cell( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Moves to the first cell.""" + + tokens = [ + "TABLE NAVIGATOR: move_to_first_cell. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + current = self._get_current_cell() + if current is None: + if notify_user: + presentation_manager.get_manager().present_message(messages.TABLE_NOT_IN_A) + return True + + table = AXUtilities.get_table(current) + cell = AXTable.get_first_cell(table) + self._present_cell(script, cell, 0, 0, current, notify_user) + return True + + @dbus_service.command + def move_to_last_cell( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Moves to the last cell.""" + + tokens = [ + "TABLE NAVIGATOR: move_to_last_cell. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + current = self._get_current_cell() + if current is None: + if notify_user: + presentation_manager.get_manager().present_message(messages.TABLE_NOT_IN_A) + return True + + table = AXUtilities.get_table(current) + cell = AXTable.get_last_cell(table) + self._present_cell( + script, + cell, + AXTable.get_row_count(table), + AXTable.get_column_count(table), + current, + notify_user, + ) + return True + + @dbus_service.command + def move_to_beginning_of_row( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Moves to the beginning of the row.""" + + tokens = [ + "TABLE NAVIGATOR: move_to_beginning_of_row. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + current = self._get_current_cell() + if current is None: + if notify_user: + presentation_manager.get_manager().present_message(messages.TABLE_NOT_IN_A) + return True + + if AXTable.is_start_of_row(current): + if notify_user: + presentation_manager.get_manager().present_message(messages.TABLE_ROW_BEGINNING) + return True + + cell = AXTable.get_start_of_row(current) + row, col = self._get_cell_coordinates(cell) + self._present_cell(script, cell, row, col, current, notify_user) + return True + + @dbus_service.command + def move_to_end_of_row( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Moves to the end of the row.""" + + tokens = [ + "TABLE NAVIGATOR: move_to_end_of_row. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + current = self._get_current_cell() + if current is None: + if notify_user: + presentation_manager.get_manager().present_message(messages.TABLE_NOT_IN_A) + return True + + if AXTable.is_end_of_row(current): + if notify_user: + presentation_manager.get_manager().present_message(messages.TABLE_ROW_END) + return True + + cell = AXTable.get_end_of_row(current) + row, col = self._get_cell_coordinates(cell) + self._present_cell(script, cell, row, col, current, notify_user) + return True + + @dbus_service.command + def move_to_top_of_column( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Moves to the top of the column.""" + + tokens = [ + "TABLE NAVIGATOR: move_to_top_of_column. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + current = self._get_current_cell() + if current is None: + if notify_user: + presentation_manager.get_manager().present_message(messages.TABLE_NOT_IN_A) + return True + + if AXTable.is_top_of_column(current): + if notify_user: + presentation_manager.get_manager().present_message(messages.TABLE_COLUMN_TOP) + return True + + row = self._get_cell_coordinates(current)[0] + cell = AXTable.get_top_of_column(current) + col = self._get_cell_coordinates(cell)[1] + self._present_cell(script, cell, row, col, current, notify_user) + return True + + @dbus_service.command + def move_to_bottom_of_column( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Moves to the bottom of the column.""" + + tokens = [ + "TABLE NAVIGATOR: move_to_bottom_of_column. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + current = self._get_current_cell() + if current is None: + if notify_user: + presentation_manager.get_manager().present_message(messages.TABLE_NOT_IN_A) + return True + + if AXTable.is_bottom_of_column(current): + if notify_user: + presentation_manager.get_manager().present_message(messages.TABLE_COLUMN_BOTTOM) + return True + + row = self._get_cell_coordinates(current)[0] + cell = AXTable.get_bottom_of_column(current) + col = self._get_cell_coordinates(cell)[1] + self._present_cell(script, cell, row, col, current, notify_user) + return True + + @dbus_service.command + def set_dynamic_column_headers_row( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Sets the row for the dynamic header columns to the current row.""" + + tokens = [ + "TABLE NAVIGATOR: set_dynamic_column_headers_row. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + current = self._get_current_cell() + if current is None: + if notify_user: + presentation_manager.get_manager().present_message(messages.TABLE_NOT_IN_A) + return True + + table = AXUtilities.get_table(current) + if table: + row = AXTable.get_cell_coordinates(current)[0] + AXUtilities.set_dynamic_column_headers_row(table, row) + if notify_user: + presentation_manager.get_manager().present_message( + messages.DYNAMIC_COLUMN_HEADER_SET % (row + 1), + ) + + return True + + @dbus_service.command + def clear_dynamic_column_headers_row( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Clears the row for the dynamic column headers.""" + + tokens = [ + "TABLE NAVIGATOR: clear_dynamic_column_headers_row. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + current = self._get_current_cell() + if current is None: + if notify_user: + presentation_manager.get_manager().present_message(messages.TABLE_NOT_IN_A) + return True + + table = AXUtilities.get_table(focus_manager.get_manager().get_locus_of_focus()) + if table: + AXUtilities.clear_dynamic_column_headers_row(table) + if notify_user: + presentation_manager.get_manager().interrupt_presentation() + presentation_manager.get_manager().present_message( + messages.DYNAMIC_COLUMN_HEADER_CLEARED, + ) + + return True + + @dbus_service.command + def set_dynamic_row_headers_column( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Sets the column for the dynamic row headers to the current column.""" + + tokens = [ + "TABLE NAVIGATOR: set_dynamic_row_headers_column. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + current = self._get_current_cell() + if current is None: + if notify_user: + presentation_manager.get_manager().present_message(messages.TABLE_NOT_IN_A) + return True + + table = AXUtilities.get_table(current) + if table: + column = AXTable.get_cell_coordinates(current)[1] + AXUtilities.set_dynamic_row_headers_column(table, column) + if notify_user: + presentation_manager.get_manager().present_message( + messages.DYNAMIC_ROW_HEADER_SET % AXUtilities.get_column_label(table, column), + ) + + return True + + @dbus_service.command + def clear_dynamic_row_headers_column( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Clears the column for the dynamic row headers.""" + + tokens = [ + "TABLE NAVIGATOR: clear_dynamic_row_headers_column. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + self._last_input_event = event + current = self._get_current_cell() + if current is None: + if notify_user: + presentation_manager.get_manager().present_message(messages.TABLE_NOT_IN_A) + return True + + table = AXUtilities.get_table(focus_manager.get_manager().get_locus_of_focus()) + if table: + AXUtilities.clear_dynamic_row_headers_column(table) + if notify_user: + presentation_manager.get_manager().interrupt_presentation() + presentation_manager.get_manager().present_message( + messages.DYNAMIC_ROW_HEADER_CLEARED, + ) + + return True + + def _present_cell( + self, + script: default.Script, + cell: Atspi.Accessible, + row: int, + col: int, + previous_cell: Atspi.Accessible, + notify_user: bool = True, + ) -> None: + """Presents cell to the user.""" + + if not AXUtilities.is_table_cell_or_header(cell): + tokens = ["TABLE NAVIGATOR: ", cell, f"(row {row}, column {col}) is not cell or header"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return + + self._previous_reported_row = row + self._previous_reported_col = col + + if script.utilities.grab_focus_when_setting_caret(cell): + AXObject.grab_focus(cell) + + obj = AXUtilities.get_descendant_supporting_text(cell) or cell + focus_mgr = focus_manager.get_manager() + focus_mgr.set_locus_of_focus(None, obj, False) + focus_mgr.emit_region_changed(obj, mode=focus_manager.TABLE_NAVIGATOR) + + if AXObject.supports_text(obj) and not AXUtilities.is_gui_cell(cell): + script.utilities.set_caret_position(obj, 0) + + if not notify_user: + msg = "TABLE NAVIGATOR: _present_cell called with notify_user=False" + debug.print_message(debug.LEVEL_INFO, msg, True) + return + + script.present_object(cell, offset=0, priorObj=previous_cell, interrupt=True) + + manager = speech_presenter.get_presenter() + # TODO - JD: This should be part of the normal table cell presentation. + if manager.get_announce_cell_coordinates(): + presentation_manager.get_manager().present_message( + messages.TABLE_CELL_COORDINATES % {"row": row + 1, "column": col + 1}, + ) + + # TODO - JD: Ditto. + if manager.get_announce_cell_span(): + rowspan, colspan = AXTable.get_cell_spans(cell) + if rowspan > 1 or colspan > 1: + presentation_manager.get_manager().present_message( + messages.cell_span(rowspan, colspan), + ) + + @gsettings_registry.get_registry().gsetting( + key=KEY_ENABLED, + schema="table-navigation", + gtype="b", + default=True, + summary="Enable table navigation", + migration_key="tableNavigationEnabled", + ) + @dbus_service.getter + def get_is_enabled(self) -> bool: + """Returns whether table navigation is enabled.""" + + return self._get_setting(self.KEY_ENABLED, True) + + @dbus_service.setter + def set_is_enabled(self, value: bool) -> bool: + """Sets whether table navigation is enabled.""" + + if self.get_is_enabled() == value: + msg = f"TABLE NAVIGATOR: Enabled already {value}. Refreshing command group." + debug.print_message(debug.LEVEL_INFO, msg, True) + command_manager.get_manager().set_group_enabled( + guilabels.KB_GROUP_TABLE_NAVIGATION, + value, + ) + return True + + msg = f"TABLE NAVIGATOR: Setting enabled to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value(self._SCHEMA, self.KEY_ENABLED, value) + + self._last_input_event = None + command_manager.get_manager().set_group_enabled(guilabels.KB_GROUP_TABLE_NAVIGATION, value) + + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_SKIP_BLANK_CELLS, + schema="table-navigation", + gtype="b", + default=False, + summary="Skip blank cells during navigation", + migration_key="skipBlankCells", + ) + @dbus_service.getter + def get_skip_blank_cells(self) -> bool: + """Returns whether blank cells should be skipped during navigation.""" + + return self._get_setting(self.KEY_SKIP_BLANK_CELLS, False) + + @dbus_service.setter + def set_skip_blank_cells(self, value: bool) -> bool: + """Sets whether blank cells should be skipped during navigation.""" + + if self.get_skip_blank_cells() == value: + return True + + msg = f"TABLE NAVIGATOR: Setting skip blank cells to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, self.KEY_SKIP_BLANK_CELLS, value + ) + return True + + +_navigator: TableNavigator = TableNavigator() + + +def get_navigator() -> TableNavigator: + """Returns the Table Navigator""" + + return _navigator diff --git a/src/cthulhu/text_attribute_manager.py b/src/cthulhu/text_attribute_manager.py new file mode 100644 index 0000000..09e0804 --- /dev/null +++ b/src/cthulhu/text_attribute_manager.py @@ -0,0 +1,516 @@ +# Cthulhu +# +# Copyright 2005-2009 Sun Microsystems Inc. +# Copyright 2011-2026 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. + +"""Manager for text attribute presentation preferences.""" + +from __future__ import annotations + +import enum +from typing import Any + +import gi + +gi.require_version("Gdk", "3.0") +gi.require_version("Gtk", "3.0") +from gi.repository import Gdk, GObject, Gtk + +from . import dbus_service, debug, gsettings_registry, guilabels +from .ax_text import AXTextAttribute +from .ax_utilities import AXUtilities +from .preferences_grid_base import PreferencesGridBase + + +class PresentationMode(enum.IntEnum): + """How a text attribute should be presented.""" + + NONE = 0 + SPEAK = 1 + BRAILLE = 2 + SPEAK_AND_BRAILLE = 3 + + +class TextAttributePreferencesGrid(PreferencesGridBase): + """Preferences grid for text attribute presentation settings.""" + + # pylint: disable=no-member + def __init__(self) -> None: + super().__init__(guilabels.TEXT_ATTRIBUTES) + self._initializing: bool = True + self._listbox: Gtk.ListBox | None = None + self._attributes: list[tuple[AXTextAttribute, PresentationMode]] = [] + self._drag_source_index: int | None = None + self._focus_target_index: int | None = None + + self._build() + self._initializing = False + self.reload() + + def _build(self) -> None: + """Build the UI components.""" + + row = 0 + + info_listbox = self._create_info_listbox(guilabels.TEXT_ATTRIBUTES_INFO) + self.attach(info_listbox, 0, row, 1, 1) + row += 1 + + self._listbox = Gtk.ListBox() + self._listbox.set_selection_mode(Gtk.SelectionMode.NONE) + self._listbox.get_accessible().set_name(guilabels.TEXT_ATTRIBUTES) + self._listbox.get_style_context().add_class("frame") + scrolled_window = self._create_scrolled_window(self._listbox) + self.attach(scrolled_window, 0, row, 1, 1) + self.show_all() + + def _create_presentation_mode_model(self) -> Gtk.ListStore: + """Create the model for presentation mode combo boxes.""" + + model = Gtk.ListStore(GObject.TYPE_STRING, GObject.TYPE_INT) + model.append([guilabels.TEXT_ATTRIBUTES_PRESENTATION_NONE, PresentationMode.NONE]) + model.append([guilabels.PRESENTATION_SPEAK, PresentationMode.SPEAK]) + model.append([guilabels.PRESENTATION_MARK_IN_BRAILLE, PresentationMode.BRAILLE]) + model.append([guilabels.PRESENTATION_SPEAK_AND_MARK, PresentationMode.SPEAK_AND_BRAILLE]) + return model + + # pylint: disable-next=too-many-locals,too-many-statements + def _create_attribute_row( + self, + attribute: AXTextAttribute, + presentation_mode: PresentationMode, + index: int, + include_top_separator: bool = True, + ) -> Gtk.ListBoxRow: + """Create a ListBoxRow for a text attribute.""" + + row = Gtk.ListBoxRow() + row.set_activatable(False) + row.get_accessible().set_name(attribute.get_localized_name()) + + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + + if include_top_separator: + separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) + vbox.pack_start(separator, False, False, 0) + + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + hbox.set_margin_start(12) + hbox.set_margin_end(12) + hbox.set_margin_top(12) + hbox.set_margin_bottom(12) + + drag_area = Gtk.EventBox() + drag_area_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) + + drag_icon = Gtk.Image.new_from_icon_name("list-drag-handle-symbolic", Gtk.IconSize.DND) + drag_icon.set_opacity(0.5) + drag_icon.set_margin_end(6) + drag_area_hbox.pack_start(drag_icon, False, False, 0) + + name_label = Gtk.Label(label=attribute.get_localized_name(), xalign=0) + name_label.set_hexpand(True) + drag_area_hbox.pack_start(name_label, True, True, 0) + + drag_area.add(drag_area_hbox) + hbox.pack_start(drag_area, True, True, 0) + + presentation_combo = Gtk.ComboBox() + presentation_combo.set_model(self._create_presentation_mode_model()) + renderer = Gtk.CellRendererText() + presentation_combo.pack_start(renderer, True) + presentation_combo.add_attribute(renderer, "text", 0) + presentation_combo.set_active(presentation_mode) + presentation_combo.connect("changed", self._on_presentation_mode_changed, index) + hbox.pack_start(presentation_combo, False, False, 0) + + menu_button = Gtk.MenuButton() + menu_button.set_relief(Gtk.ReliefStyle.NONE) + icon = Gtk.Image.new_from_icon_name("view-more-symbolic", Gtk.IconSize.DND) + menu_button.set_image(icon) + menu_button.get_accessible().set_name(guilabels.TEXT_ATTRIBUTES_REORDER) + + menu = Gtk.Menu() + move_to_top = Gtk.MenuItem(label=guilabels.TEXT_ATTRIBUTES_MOVE_TO_TOP) + move_to_top.connect("activate", self._on_menu_move, row, "top") + menu.append(move_to_top) + + move_up = Gtk.MenuItem(label=guilabels.TEXT_ATTRIBUTES_MOVE_UP_ONE) + move_up.connect("activate", self._on_menu_move, row, "up") + menu.append(move_up) + + move_down = Gtk.MenuItem(label=guilabels.TEXT_ATTRIBUTES_MOVE_DOWN_ONE) + move_down.connect("activate", self._on_menu_move, row, "down") + menu.append(move_down) + + move_to_bottom = Gtk.MenuItem(label=guilabels.TEXT_ATTRIBUTES_MOVE_TO_BOTTOM) + move_to_bottom.connect("activate", self._on_menu_move, row, "bottom") + menu.append(move_to_bottom) + + menu.show_all() + menu_button.set_popup(menu) + hbox.pack_end(menu_button, False, False, 0) + + vbox.pack_start(hbox, False, False, 0) + row.add(vbox) + + row.attribute_index = index + row.presentation_combo = presentation_combo + + target_entry = Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0) + + drag_area.drag_source_set( + Gdk.ModifierType.BUTTON1_MASK, + [target_entry], + Gdk.DragAction.MOVE, + ) + drag_area.connect("drag-begin", self._on_drag_begin, row) + drag_area.connect("drag-data-get", self._on_drag_data_get, row) + drag_area.connect("drag-end", self._on_drag_end, row) + + row.drag_dest_set(Gtk.DestDefaults.ALL, [target_entry], Gdk.DragAction.MOVE) + row.connect("drag-data-received", self._on_drag_data_received) + + return row + + def _on_presentation_mode_changed(self, combo: Gtk.ComboBox, index: int) -> None: + """Handle presentation mode combo box changes.""" + + if self._initializing: + return + + tree_iter = combo.get_active_iter() + if tree_iter: + model = combo.get_model() + mode = model[tree_iter][1] + self._attributes[index] = (self._attributes[index][0], mode) + self._has_unsaved_changes = True + + def _on_menu_move(self, _menu_item: Gtk.MenuItem, row: Gtk.ListBoxRow, direction: str) -> None: + """Handle menu item activation for moving attributes.""" + + index = row.attribute_index + self._move_attribute(index, direction) + + def _move_attribute(self, index: int, direction: str) -> None: + """Move an attribute in the list.""" + + new_index = index + + if direction == "top": + attribute = self._attributes.pop(index) + self._attributes.insert(0, attribute) + new_index = 0 + elif direction == "up" and index > 0: + self._attributes[index], self._attributes[index - 1] = ( + self._attributes[index - 1], + self._attributes[index], + ) + new_index = index - 1 + elif direction == "down" and index < len(self._attributes) - 1: + self._attributes[index], self._attributes[index + 1] = ( + self._attributes[index + 1], + self._attributes[index], + ) + new_index = index + 1 + elif direction == "bottom": + attribute = self._attributes.pop(index) + self._attributes.append(attribute) + new_index = len(self._attributes) - 1 + + self._focus_target_index = new_index + self._has_unsaved_changes = True + self.refresh() + + def _on_drag_begin( + self, + _widget: Gtk.EventBox, + _drag_context: Gdk.DragContext, + row: Gtk.ListBoxRow, + ) -> None: + """Handle drag begin - store source index.""" + + self._drag_source_index = row.attribute_index + + def _on_drag_data_get( + self, + _widget: Gtk.EventBox, + _drag_context: Gdk.DragContext, + data: Gtk.SelectionData, + _info: int, + _time: int, + row: Gtk.ListBoxRow, + ) -> None: + """Handle drag data get - send source index.""" + + data.set_text(str(row.attribute_index), -1) + + def _on_drag_data_received( + self, + row: Gtk.ListBoxRow, + _drag_context: Gdk.DragContext, + _x: int, + _y: int, + data: Gtk.SelectionData, + _info: int, + _time: int, + ) -> None: + """Handle drag data received - perform the move.""" + + source_index_str = data.get_text() + if source_index_str is None: + return + + try: + source_index = int(source_index_str) + except ValueError: + return + + target_index = row.attribute_index + + if source_index == target_index: + return + + attribute = self._attributes.pop(source_index) + self._attributes.insert(target_index, attribute) + self._has_unsaved_changes = True + self.refresh() + + def _on_drag_end( + self, + _widget: Gtk.EventBox, + _drag_context: Gdk.DragContext, + _row: Gtk.ListBoxRow, + ) -> None: + """Handle drag end - clean up.""" + + self._drag_source_index = None + + def reload(self) -> None: + """Reload settings from the settings manager and update UI.""" + + self._initializing = True + self._has_unsaved_changes = False + + spoken_attrs = _manager.get_attributes_to_speak() + if not spoken_attrs: + spoken_attrs = [ + attr.get_attribute_name() + for attr in AXUtilities.get_all_supported_text_attributes() + if attr.should_present_by_default() + ] + + brailled_attrs = _manager.get_attributes_to_braille() + if not brailled_attrs: + brailled_attrs = [] + + self._attributes = [] + spoken_set = set(spoken_attrs) + brailled_set = set(brailled_attrs) + + for attr_name in spoken_attrs: + attr = AXTextAttribute.from_string(attr_name) + if attr is None: + continue + + if attr_name in spoken_set and attr_name in brailled_set: + mode = PresentationMode.SPEAK_AND_BRAILLE + elif attr_name in spoken_set: + mode = PresentationMode.SPEAK + elif attr_name in brailled_set: + mode = PresentationMode.BRAILLE + else: + mode = PresentationMode.NONE + + self._attributes.append((attr, mode)) + + for attr in AXUtilities.get_all_supported_text_attributes(): + attr_name = attr.get_attribute_name() + if attr_name not in spoken_set: + if attr_name in brailled_set: + mode = PresentationMode.BRAILLE + else: + mode = PresentationMode.NONE + self._attributes.append((attr, mode)) + + self._initializing = False + self.refresh() + + def refresh(self) -> None: + """Update UI widgets from current state.""" + + if self._listbox is None: + return + + self._initializing = True + + for child in self._listbox.get_children(): + self._listbox.remove(child) + + for index, (attribute, mode) in enumerate(self._attributes): + row = self._create_attribute_row( + attribute, + mode, + index, + include_top_separator=index > 0, + ) + self._listbox.add(row) + + self._listbox.show_all() + self._initializing = False + + if self._focus_target_index is not None: + target_row = self._listbox.get_row_at_index(self._focus_target_index) + if target_row: + target_row.grab_focus() + self._focus_target_index = None + + def save_settings(self, profile: str = "", app_name: str = "") -> dict[str, list[str]]: + """Save current settings and return dict of changed preferences.""" + + spoken_attributes = [] + brailled_attributes = [] + + for attribute, mode in self._attributes: + key = attribute.get_attribute_name() + if mode in (PresentationMode.SPEAK, PresentationMode.SPEAK_AND_BRAILLE): + spoken_attributes.append(key) + if mode in (PresentationMode.BRAILLE, PresentationMode.SPEAK_AND_BRAILLE): + brailled_attributes.append(key) + + result = { + TextAttributeManager.KEY_ATTRIBUTES_TO_SPEAK: spoken_attributes, + TextAttributeManager.KEY_ATTRIBUTES_TO_BRAILLE: brailled_attributes, + } + + if profile and self._has_unsaved_changes: + skip = not app_name and profile == "default" + gsettings_registry.get_registry().save_schema( + "text-attributes", + result, + profile, + app_name, + skip, + ) + + self._has_unsaved_changes = False + return result + + # pylint: enable=no-member + + +@gsettings_registry.get_registry().gsettings_schema( + "org.stormux.Cthulhu.TextAttributes", + name="text-attributes", +) +class TextAttributeManager: + """Manager for text attribute presentation settings.""" + + _SCHEMA = "text-attributes" + KEY_ATTRIBUTES_TO_SPEAK = "attributes-to-speak" + KEY_ATTRIBUTES_TO_BRAILLE = "attributes-to-braille" + + def _get_setting(self, key: str, gtype: str, default: Any) -> Any: + """Returns the dconf value for key, or default if not in dconf.""" + + return gsettings_registry.get_registry().layered_lookup( + self._SCHEMA, + key, + gtype, + default=default, + ) + + def __init__(self) -> None: + msg = "TEXT ATTRIBUTE MANAGER: Registering D-Bus commands." + debug.print_message(debug.LEVEL_INFO, msg, True) + controller = dbus_service.get_remote_controller() + controller.register_decorated_module("TextAttributeManager", self) + + def create_preferences_grid(self) -> TextAttributePreferencesGrid: + """Create and return a preferences grid for text attributes.""" + + return TextAttributePreferencesGrid() + + @gsettings_registry.get_registry().gsetting( + key=KEY_ATTRIBUTES_TO_SPEAK, + schema="text-attributes", + gtype="as", + default=[], + summary="Text attributes to speak", + migration_key="textAttributesToSpeak", + ) + @dbus_service.getter + def get_attributes_to_speak(self) -> list[str]: + """Returns the list of text attributes to speak.""" + + return self._get_setting(self.KEY_ATTRIBUTES_TO_SPEAK, "as", []) + + @dbus_service.setter + def set_attributes_to_speak(self, value: list[str]) -> bool: + """Sets the list of text attributes to speak.""" + + if self.get_attributes_to_speak() == value: + return True + + msg = f"TEXT ATTRIBUTE MANAGER: Setting attributes to speak to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_ATTRIBUTES_TO_SPEAK, + value, + ) + return True + + @gsettings_registry.get_registry().gsetting( + key=KEY_ATTRIBUTES_TO_BRAILLE, + schema="text-attributes", + gtype="as", + default=[], + summary="Text attributes to mark in braille", + migration_key="textAttributesToBraille", + ) + @dbus_service.getter + def get_attributes_to_braille(self) -> list[str]: + """Returns the list of text attributes to mark in braille.""" + + return self._get_setting(self.KEY_ATTRIBUTES_TO_BRAILLE, "as", []) + + @dbus_service.setter + def set_attributes_to_braille(self, value: list[str]) -> bool: + """Sets the list of text attributes to mark in braille.""" + + if self.get_attributes_to_braille() == value: + return True + + msg = f"TEXT ATTRIBUTE MANAGER: Setting attributes to braille to {value}." + debug.print_message(debug.LEVEL_INFO, msg, True) + gsettings_registry.get_registry().set_runtime_value( + self._SCHEMA, + self.KEY_ATTRIBUTES_TO_BRAILLE, + value, + ) + return True + + +_manager = TextAttributeManager() + + +def get_manager() -> TextAttributeManager: + """Return the singleton TextAttributeManager instance.""" + + return _manager diff --git a/src/cthulhu/typing_echo_presenter.py b/src/cthulhu/typing_echo_presenter.py index 4a19798..2b68e69 100644 --- a/src/cthulhu/typing_echo_presenter.py +++ b/src/cthulhu/typing_echo_presenter.py @@ -1,9 +1,7 @@ -#!/usr/bin/env python3 # Cthulhu # # Copyright 2005-2008 Sun Microsystems Inc. # Copyright 2011-2025 Igalia, S.L. -# Copyright 2025 Stormux # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -20,313 +18,969 @@ # Free Software Foundation, Inc., Franklin Street, Fifth Floor, # Boston MA 02110-1301 USA. -"""Provides typing echo support with D-Bus controls.""" +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-lines -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2005-2008 Sun Microsystems Inc." \ - "Copyright (c) 2011-2025 Igalia, S.L." -__license__ = "LGPL" +"""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 braille -from . import cmdnames -from . import dbus_service -from . import debug -from . import input_event -from . import keybindings -from . import messages -from . import settings -from . import settings_manager -from . import speech +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 . import default + from collections.abc import Callable, Iterable -_settings_manager = settings_manager.getManager() + 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.stormux.Cthulhu.TypingEcho", name="typing-echo") class TypingEchoPresenter: - """Provides typing echo functionality with D-Bus remote control support.""" + """Provides typing echo support.""" - def __init__(self): - """Initialize the typing echo presenter.""" - debug.printMessage(debug.LEVEL_INFO, "TYPING ECHO PRESENTER: Initializing", True) + _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" - # D-Bus getters and setters for key echo settings + 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.""" - return _settings_manager.getSetting('enableKeyEcho') + """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.""" - try: - _settings_manager.setSetting('enableKeyEcho', value) - return True - except Exception as e: - debug.printMessage(debug.LEVEL_WARNING, f"Error setting key echo: {e}", True) - return False + """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 _settings_manager.getSetting('enableEchoByCharacter') + + 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.""" - try: - _settings_manager.setSetting('enableEchoByCharacter', value) - return True - except Exception as e: - debug.printMessage(debug.LEVEL_WARNING, f"Error setting character echo: {e}", True) - return False + 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 _settings_manager.getSetting('enableEchoByWord') + + 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.""" - try: - _settings_manager.setSetting('enableEchoByWord', value) - return True - except Exception as e: - debug.printMessage(debug.LEVEL_WARNING, f"Error setting word echo: {e}", True) - return False + 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 _settings_manager.getSetting('enableEchoBySentence') + + 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.""" - try: - _settings_manager.setSetting('enableEchoBySentence', value) - return True - except Exception as e: - debug.printMessage(debug.LEVEL_WARNING, f"Error setting sentence echo: {e}", True) - return False + 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 _settings_manager.getSetting('enableAlphabeticKeys') + + 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.""" - try: - _settings_manager.setSetting('enableAlphabeticKeys', value) - return True - except Exception as e: - debug.printMessage(debug.LEVEL_WARNING, f"Error setting alphabetic keys: {e}", True) - return False + 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 _settings_manager.getSetting('enableNumericKeys') + + 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.""" - try: - _settings_manager.setSetting('enableNumericKeys', value) - return True - except Exception as e: - debug.printMessage(debug.LEVEL_WARNING, f"Error setting numeric keys: {e}", True) - return False + 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 _settings_manager.getSetting('enablePunctuationKeys') + + 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.""" - try: - _settings_manager.setSetting('enablePunctuationKeys', value) - return True - except Exception as e: - debug.printMessage(debug.LEVEL_WARNING, f"Error setting punctuation keys: {e}", True) - return False + 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 _settings_manager.getSetting('enableSpace') + + 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.""" - try: - _settings_manager.setSetting('enableSpace', value) - return True - except Exception as e: - debug.printMessage(debug.LEVEL_WARNING, f"Error setting space key: {e}", True) - return False + 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 _settings_manager.getSetting('enableModifierKeys') + + 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.""" - try: - _settings_manager.setSetting('enableModifierKeys', value) - return True - except Exception as e: - debug.printMessage(debug.LEVEL_WARNING, f"Error setting modifier keys: {e}", True) - return False + 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 _settings_manager.getSetting('enableFunctionKeys') + + 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.""" - try: - _settings_manager.setSetting('enableFunctionKeys', value) - return True - except Exception as e: - debug.printMessage(debug.LEVEL_WARNING, f"Error setting function keys: {e}", True) - return False + 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 _settings_manager.getSetting('enableActionKeys') + + 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.""" - try: - _settings_manager.setSetting('enableActionKeys', value) - return True - except Exception as e: - debug.printMessage(debug.LEVEL_WARNING, f"Error setting action keys: {e}", True) - return False + 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 _settings_manager.getSetting('enableNavigationKeys') + + 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.""" - try: - _settings_manager.setSetting('enableNavigationKeys', value) - return True - except Exception as e: - debug.printMessage(debug.LEVEL_WARNING, f"Error setting navigation keys: {e}", True) - return False + 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 _settings_manager.getSetting('enableDiacriticalKeys') + + 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.""" - try: - _settings_manager.setSetting('enableDiacriticalKeys', value) - return True - except Exception as e: - debug.printMessage(debug.LEVEL_WARNING, f"Error setting diacritical keys: {e}", True) + + 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 - @dbus_service.command - def cycle_key_echo(self, script: 'default.Script', event=None): - """Cycles through key echo modes.""" - if not _settings_manager.getSetting('enableKeyEcho'): - _settings_manager.setSetting('enableKeyEcho', True) - script.presentMessage(messages.KEY_ECHO_ENABLED) - else: - _settings_manager.setSetting('enableKeyEcho', False) - script.presentMessage(messages.KEY_ECHO_DISABLED) + 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_cthulhu_modifier(self, event: input_event.KeyboardEvent) -> bool: + """Returns whether an Cthulhu modifier event should be echoed.""" + + click_count = event.get_click_count() + if click_count == 2: + msg = "TYPING ECHO PRESENTER: Echoing Cthulhu 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 Cthulhu 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.""" - if not _settings_manager.getSetting('enableKeyEcho'): + + 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.event_string in ["shift", "control", "alt", "meta"]: - return _settings_manager.getSetting('enableModifierKeys') + if event.is_cthulhu_modifier(): + return self._should_echo_cthulhu_modifier(event) - if event.event_string.startswith("f") and event.event_string[1:].isdigit(): - return _settings_manager.getSetting('enableFunctionKeys') + # Treat all command modifiers the same and suppress echo. + if event.is_alt_control_or_cthulhu_modified() or self.is_character_echoable(event): + reason = "modifier" if event.is_alt_control_or_cthulhu_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.event_string in ["return", "enter", "tab", "escape", "backspace", "delete"]: - return _settings_manager.getSetting('enableActionKeys') + 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 event.event_string in ["up", "down", "left", "right", "home", "end", "page_up", "page_down"]: - return _settings_manager.getSetting('enableNavigationKeys') + 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 - if event.event_string == "space": - return _settings_manager.getSetting('enableSpace') - - if len(event.event_string) == 1: - char = event.event_string - if char.isalpha(): - return _settings_manager.getSetting('enableAlphabeticKeys') - elif char.isdigit(): - return _settings_manager.getSetting('enableNumericKeys') - elif char in string.punctuation: - return _settings_manager.getSetting('enablePunctuationKeys') - - 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 _settings_manager.getSetting('enableEchoByCharacter'): + + if not self.get_character_echo_enabled(): return False - # Character echo is for printable characters being inserted - if len(event.event_string) == 1 and event.event_string.isprintable(): + if event.is_alt_control_or_cthulhu_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_keyboard_event(self, script: 'default.Script', event: input_event.KeyboardEvent) -> None: + 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 self.should_echo_keyboard_event(event): - if event.event_string == "space": - script.presentMessage(messages.SPACE) - elif event.event_string == "tab": - script.presentMessage(messages.TAB) - elif event.event_string == "return" or event.event_string == "enter": - script.presentMessage(messages.ENTER) - elif event.event_string == "backspace": - script.presentMessage(messages.BACKSPACE) - elif event.event_string == "delete": - script.presentMessage(messages.DELETE) - else: - # For simple characters and other keys, just speak the event string - script.presentMessage(event.event_string) -# Global instance -_manager = None + if not event.is_pressed_key(): + presentation_manager.get_manager().clear_command_announcement() + return -def getManager(): - """Get the typing echo presenter manager.""" - global _manager - if not _manager: - _manager = TypingEchoPresenter() - return _manager \ No newline at end of file + 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 diff --git a/src/cthulhu/where_am_i_presenter.py b/src/cthulhu/where_am_i_presenter.py index 0ac9a73..ffcdbd2 100644 --- a/src/cthulhu/where_am_i_presenter.py +++ b/src/cthulhu/where_am_i_presenter.py @@ -1,9 +1,7 @@ -#!/usr/bin/env python3 +# Cthulhu # -# Copyright (c) 2024 Stormux -# Copyright (c) 2010-2012 The Orca Team -# Copyright (c) 2012 Igalia, S.L. -# Copyright (c) 2005-2010 Sun Microsystems Inc. +# Copyright 2005-2008 Sun Microsystems Inc. +# Copyright 2016-2023 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 @@ -19,478 +17,667 @@ # 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 """Module for commands related to the current accessible object.""" -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2005-2008 Sun Microsystems Inc." \ - "Copyright (c) 2016-2023 Igalia, S.L." -__license__ = "LGPL" +from __future__ import annotations -from . import cmdnames -from . import debug -from . import input_event -from . import keybindings -from . import messages -from . import cthulhu_state -from . import settings_manager +from typing import TYPE_CHECKING + +from . import ( + cmdnames, + command_manager, + dbus_service, + debug, + flat_review_presenter, + focus_manager, + guilabels, + input_event, + keybindings, + messages, + presentation_manager, + speech_presenter, + spellcheck_presenter, + text_attribute_manager, +) +from .ax_component import AXComponent from .ax_object import AXObject +from .ax_text import AXText, AXTextAttribute from .ax_utilities import AXUtilities +from .generator import WhereAmI + +if TYPE_CHECKING: + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + + from .scripts import default -_settingsManager = settings_manager.getManager() class WhereAmIPresenter: """Module for commands related to the current accessible object.""" - def __init__(self): - self._handlers = self._setup_handlers() - self._desktop_bindings = self._setup_desktop_bindings() - self._laptop_bindings = self._setup_laptop_bindings() + def __init__(self) -> None: + self._initialized: bool = False - def get_bindings(self, is_desktop): - """Returns the where-am-i-presenter keybindings.""" + msg = "WHERE AM I PRESENTER: Registering D-Bus commands." + debug.print_message(debug.LEVEL_INFO, msg, True) + controller = dbus_service.get_remote_controller() + controller.register_decorated_module("WhereAmIPresenter", self) - if is_desktop: - return self._desktop_bindings - return self._laptop_bindings + # pylint: disable-next=too-many-locals + def set_up_commands(self) -> None: + """Sets up commands with CommandManager.""" - def get_handlers(self): - """Returns the where-am-i-presenter handlers.""" + if self._initialized: + return + self._initialized = True - return self._handlers + manager = command_manager.get_manager() + group_label = guilabels.KB_GROUP_WHERE_AM_I - def _setup_handlers(self): - """Sets up and returns the where-am-i-presenter input event handlers.""" + # Common keybindings (same for desktop and laptop) + kb_f = keybindings.KeyBinding("f", keybindings.CTHULHU_MODIFIER_MASK) + kb_e = keybindings.KeyBinding("e", keybindings.CTHULHU_MODIFIER_MASK) + kb_up = keybindings.KeyBinding("Up", keybindings.CTHULHU_SHIFT_MODIFIER_MASK) - handlers = {} + # Desktop-specific keybindings + kb_equal = keybindings.KeyBinding("equal", keybindings.CTHULHU_MODIFIER_MASK) + kb_kp_enter_cthulhu = keybindings.KeyBinding("KP_Enter", keybindings.CTHULHU_MODIFIER_MASK) + kb_kp_enter_cthulhu_2 = keybindings.KeyBinding( + "KP_Enter", + keybindings.CTHULHU_MODIFIER_MASK, + click_count=2, + ) + kb_kp_enter = keybindings.KeyBinding("KP_Enter", keybindings.NO_MODIFIER_MASK) + kb_kp_enter_2 = keybindings.KeyBinding( + "KP_Enter", + keybindings.NO_MODIFIER_MASK, + click_count=2, + ) - handlers["readCharAttributesHandler"] = \ - input_event.InputEventHandler( + # Laptop-specific keybindings + kb_slash = keybindings.KeyBinding("slash", keybindings.CTHULHU_MODIFIER_MASK) + kb_slash_2 = keybindings.KeyBinding("slash", keybindings.CTHULHU_MODIFIER_MASK, click_count=2) + kb_return = keybindings.KeyBinding("Return", keybindings.CTHULHU_MODIFIER_MASK) + kb_return_2 = keybindings.KeyBinding( + "Return", + keybindings.CTHULHU_MODIFIER_MASK, + click_count=2, + ) + + # (name, function, description, desktop_kb, laptop_kb) + commands_data = [ + ( + "readCharAttributesHandler", self.present_character_attributes, - cmdnames.READ_CHAR_ATTRIBUTES) - - handlers["presentSizeAndPositionHandler"] = \ - input_event.InputEventHandler( + cmdnames.READ_CHAR_ATTRIBUTES, + kb_f, + kb_f, + ), + ( + "presentSizeAndPositionHandler", self.present_size_and_position, - cmdnames.PRESENT_SIZE_AND_POSITION) - - handlers["getTitleHandler"] = \ - input_event.InputEventHandler( + cmdnames.PRESENT_SIZE_AND_POSITION, + None, + None, + ), + ( + "getTitleHandler", self.present_title, - cmdnames.PRESENT_TITLE) - - handlers["getStatusBarHandler"] = \ - input_event.InputEventHandler( + cmdnames.PRESENT_TITLE, + kb_kp_enter_cthulhu, + kb_slash, + ), + ( + "getStatusBarHandler", self.present_status_bar, - cmdnames.PRESENT_STATUS_BAR) - - handlers["present_default_button"] = \ - input_event.InputEventHandler( + cmdnames.PRESENT_STATUS_BAR, + kb_kp_enter_cthulhu_2, + kb_slash_2, + ), + ( + "present_default_button", self.present_default_button, - cmdnames.PRESENT_DEFAULT_BUTTON) - - handlers["whereAmIBasicHandler"] = \ - input_event.InputEventHandler( + cmdnames.PRESENT_DEFAULT_BUTTON, + kb_e, + kb_e, + ), + ( + "present_cell_formula", + self.present_cell_formula, + cmdnames.PRESENT_CELL_FORMULA, + kb_equal, + None, + ), + ( + "whereAmIBasicHandler", self.where_am_i_basic, - cmdnames.WHERE_AM_I_BASIC) - - handlers["whereAmIDetailedHandler"] = \ - input_event.InputEventHandler( + cmdnames.WHERE_AM_I_BASIC, + kb_kp_enter, + kb_return, + ), + ( + "whereAmIDetailedHandler", self.where_am_i_detailed, - cmdnames.WHERE_AM_I_DETAILED) - - handlers["whereAmILinkHandler"] = \ - input_event.InputEventHandler( - self.present_link, - cmdnames.WHERE_AM_I_LINK) - - handlers["whereAmISelectionHandler"] = \ - input_event.InputEventHandler( + cmdnames.WHERE_AM_I_DETAILED, + kb_kp_enter_2, + kb_return_2, + ), + ("whereAmILinkHandler", self.present_link, cmdnames.WHERE_AM_I_LINK, None, None), + ( + "whereAmISelectionHandler", self.present_selection, - cmdnames.WHERE_AM_I_SELECTION) + cmdnames.WHERE_AM_I_SELECTION, + kb_up, + kb_up, + ), + ] - return handlers + for name, function, description, desktop_kb, laptop_kb in commands_data: + manager.add_command( + command_manager.KeyboardCommand( + name, + function, + group_label, + description, + desktop_keybinding=desktop_kb, + laptop_keybinding=laptop_kb, + ), + ) - def _setup_desktop_bindings(self): - """Sets up and returns the where-am-i-presenter desktop key bindings.""" + msg = "WHERE AM I PRESENTER: Commands set up." + debug.print_message(debug.LEVEL_INFO, msg, True) - bindings = keybindings.KeyBindings() + def _localize_text_attribute(self, key, value): + if value is None: + return "" - bindings.add( - keybindings.KeyBinding( - "f", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("readCharAttributesHandler"))) + if key == "weight" and (value == "bold" or int(value) > 400): + return messages.BOLD - bindings.add( - keybindings.KeyBinding( - "e", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("present_default_button"))) + if key.endswith("spelling") or value == "spelling": + return messages.MISSPELLED - bindings.add( - keybindings.KeyBinding( - "", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("presentSizeAndPositionHandler"))) + ax_text_attribute = AXTextAttribute.from_string(key) + localized_key = ax_text_attribute.get_localized_name() + localized_value = ax_text_attribute.get_localized_value(value) + return f"{localized_key}: {localized_value}" - bindings.add( - keybindings.KeyBinding( - "KP_Enter", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("getTitleHandler"), - 1)) - - bindings.add( - keybindings.KeyBinding( - "KP_Enter", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("getStatusBarHandler"), - 2)) - - bindings.add( - keybindings.KeyBinding( - "KP_Enter", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("whereAmIBasicHandler"), - 1)) - - bindings.add( - keybindings.KeyBinding( - "KP_Enter", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("whereAmIDetailedHandler"), - 2)) - - bindings.add( - keybindings.KeyBinding( - "", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("whereAmILinkHandler"))) - - bindings.add( - keybindings.KeyBinding( - "Up", - keybindings.defaultModifierMask, - keybindings.CTHULHU_SHIFT_MODIFIER_MASK, - self._handlers.get("whereAmISelectionHandler"))) - - return bindings - - def _setup_laptop_bindings(self): - """Sets up and returns the where-am-i-presenter laptop key bindings.""" - - bindings = keybindings.KeyBindings() - - bindings.add( - keybindings.KeyBinding( - "f", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("readCharAttributesHandler"))) - - bindings.add( - keybindings.KeyBinding( - "e", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("present_default_button"))) - - bindings.add( - keybindings.KeyBinding( - "", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("presentSizeAndPositionHandler"))) - - bindings.add( - keybindings.KeyBinding( - "slash", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("getTitleHandler"), - 1)) - - bindings.add( - keybindings.KeyBinding( - "slash", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("getStatusBarHandler"), - 2)) - - bindings.add( - keybindings.KeyBinding( - "Return", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("whereAmIBasicHandler"), - 1)) - - bindings.add( - keybindings.KeyBinding( - "Return", - keybindings.defaultModifierMask, - keybindings.CTHULHU_MODIFIER_MASK, - self._handlers.get("whereAmIDetailedHandler"), - 2)) - - bindings.add( - keybindings.KeyBinding( - "", - keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, - self._handlers.get("whereAmILinkHandler"))) - - bindings.add( - keybindings.KeyBinding( - "Up", - keybindings.defaultModifierMask, - keybindings.CTHULHU_SHIFT_MODIFIER_MASK, - self._handlers.get("whereAmISelectionHandler"))) - - return bindings - - def present_character_attributes(self, script, event=None): + @dbus_service.command + def present_character_attributes( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Presents the font and formatting details for the current character.""" - attrs = script.utilities.textAttributes(cthulhu_state.locusOfFocus, None, True)[0] + tokens = [ + "WHERE AM I PRESENTER: present_character_attributes. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) - # Get a dictionary of text attributes that the user cares about. - [user_attr_list, user_attr_dict] = script.utilities.stringToKeysAndDict( - _settingsManager.getSetting('enabledSpokenTextAttributes')) + focus = focus_manager.get_manager().get_locus_of_focus() + attrs = AXText.get_text_attributes_at_offset(focus)[0] - null_values = ['0', '0mm', 'none', 'false'] - for key in user_attr_list: - # Convert the standard key into the non-standard implementor variant. - app_key = script.utilities.getAppNameForAttribute(key) - value = attrs.get(app_key) - ignore_if_value = user_attr_dict.get(key) - if value in null_values and ignore_if_value in null_values: - continue + # Get a dictionary of text attributes that the user cares about, falling back on the + # default presentable attributes if the user has not specified any. + attr_list = list( + filter( + None, + map( + AXTextAttribute.from_string, + text_attribute_manager.get_manager().get_attributes_to_speak(), + ), + ), + ) + if not attr_list: + attr_list = AXUtilities.get_all_supported_text_attributes() - if value and value != ignore_if_value: - script.speakMessage(script.utilities.localizeTextAttribute(key, value)) + for ax_text_attr in attr_list: + key = ax_text_attr.get_attribute_name() + value = attrs.get(key) + if not ax_text_attr.value_is_default(value): + presentation_manager.get_manager().speak_message( + self._localize_text_attribute(key, value), + ) return True - def present_size_and_position(self, script, event=None): + @dbus_service.command + def present_size_and_position( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Presents the size and position of the current object.""" - if script.flatReviewPresenter.is_active(): - obj = script.flatReviewPresenter.get_current_object(script, event) - else: - obj = cthulhu_state.locusOfFocus + tokens = [ + "WHERE AM I PRESENTER: present_size_and_position. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) - x_coord, y_coord, width, height = script.utilities.getBoundingBox(obj) - if (x_coord, y_coord, width, height) == (-1, -1, 0, 0): + if flat_review_presenter.get_presenter().is_active(): + obj = flat_review_presenter.get_presenter().get_current_object(script, event) + else: + obj = focus_manager.get_manager().get_locus_of_focus() + + rect = AXComponent.get_rect(obj) + if AXUtilities.is_empty_rect(rect): full = messages.LOCATION_NOT_FOUND_FULL brief = messages.LOCATION_NOT_FOUND_BRIEF - script.presentMessage(full, brief) + presentation_manager.get_manager().present_message(full, brief) return True - full = messages.SIZE_AND_POSITION_FULL % (width, height, x_coord, y_coord) - brief = messages.SIZE_AND_POSITION_BRIEF % (width, height, x_coord, y_coord) - script.presentMessage(full, brief) + full = messages.SIZE_AND_POSITION_FULL % (rect.width, rect.height, rect.x, rect.y) + brief = messages.SIZE_AND_POSITION_BRIEF % (rect.width, rect.height, rect.x, rect.y) + presentation_manager.get_manager().present_message(full, brief) return True - def present_title(self, script, event=None): + @dbus_service.command + def present_title( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Presents the title of the current window.""" - obj = cthulhu_state.locusOfFocus + tokens = [ + "WHERE AM I PRESENTER: present_title. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + obj = focus_manager.get_manager().get_locus_of_focus() if AXObject.is_dead(obj): - obj = cthulhu_state.activeWindow + obj = focus_manager.get_manager().get_active_window() if obj is None or AXObject.is_dead(obj): - script.presentMessage(messages.LOCATION_NOT_FOUND_FULL) + presentation_manager.get_manager().present_message(messages.LOCATION_NOT_FOUND_FULL) return True - title = script.speechGenerator.generateTitle(obj) - for (string, voice) in title: - script.presentMessage(string, voice=voice) + presentation_manager.get_manager().present_window_title(script, obj) return True - def _present_default_button(self, script, event=None, dialog=None, error_messages=True): + @dbus_service.command + def present_default_button( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + dialog: Atspi.Accessible | None = None, + notify_user: bool = True, + ) -> bool: """Presents the default button of the current dialog.""" - obj = cthulhu_state.locusOfFocus - frame, dialog = script.utilities.frameAndDialog(obj) + tokens = [ + "WHERE AM I PRESENTER: present_default_button. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + obj = focus_manager.get_manager().get_locus_of_focus() if dialog is None: - if error_messages: - script.presentMessage(messages.DIALOG_NOT_IN_A) + _frame, dialog = script.utilities.frame_and_dialog(obj) + if dialog is None: + if notify_user: + presentation_manager.get_manager().present_message(messages.DIALOG_NOT_IN_A) return True button = AXUtilities.get_default_button(dialog) if button is None: - if error_messages: - script.presentMessage(messages.DEFAULT_BUTTON_NOT_FOUND) + if notify_user: + presentation_manager.get_manager().present_message( + messages.DEFAULT_BUTTON_NOT_FOUND, + ) return True name = AXObject.get_name(button) if not AXUtilities.is_sensitive(button): - script.presentMessage(messages.DEFAULT_BUTTON_IS_GRAYED % name) + presentation_manager.get_manager().present_message( + messages.DEFAULT_BUTTON_IS_GRAYED % name, + ) return True - script.presentMessage(messages.DEFAULT_BUTTON_IS % name) + presentation_manager.get_manager().present_message(messages.DEFAULT_BUTTON_IS % name) return True - def present_status_bar(self, script, event=None): - """Presents the status bar of the current window.""" + @dbus_service.command + def present_cell_formula( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Presents the formula associated with the current spreadsheet cell.""" - obj = cthulhu_state.locusOfFocus - frame, dialog = script.utilities.frameAndDialog(obj) + tokens = [ + "WHERE AM I PRESENTER: present_cell_formula. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + focus = focus_manager.get_manager().get_locus_of_focus() + cell = AXUtilities.find_ancestor_inclusive(focus, AXUtilities.is_table_cell) + if cell is None: + if notify_user: + presentation_manager.get_manager().present_message(messages.TABLE_NOT_IN_A) + return True + + text = AXUtilities.get_cell_formula(cell) + if not text: + text = AXText.get_all_text(cell) or AXText.get_all_text(focus) or messages.EMPTY + if notify_user: + presentation_manager.get_manager().present_message(text) + + return True + + @dbus_service.command + def present_status_bar( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: + """Presents the status bar and info bar of the current window.""" + + tokens = [ + "WHERE AM I PRESENTER: present_status_bar. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + obj = focus_manager.get_manager().get_locus_of_focus() + frame, _dialog = script.utilities.frame_and_dialog(obj) if frame: statusbar = AXUtilities.get_status_bar(frame) if statusbar: - script.pointOfReference['statusBarItems'] = None - script.presentObject(statusbar, interrupt=True) - script.pointOfReference['statusBarItems'] = None + script.present_object(statusbar, interrupt=True) else: full = messages.STATUS_BAR_NOT_FOUND_FULL brief = messages.STATUS_BAR_NOT_FOUND_BRIEF - script.presentMessage(full, brief) + presentation_manager.get_manager().present_message(full, brief) - infobar = script.utilities.infoBar(frame) - if infobar: - script.presentObject(infobar, interrupt=statusbar is None) - - # TODO - JD: Pending user feedback, this should be removed. - if dialog: - self._present_default_button(script, event, dialog, False) + infobar = AXUtilities.get_info_bar(frame) + if infobar and AXUtilities.is_showing(infobar) and AXUtilities.is_visible(infobar): + script.present_object(infobar, interrupt=statusbar is None) return True - def present_default_button(self, script, event=None): - """Presents the default button of the current window.""" - - return self._present_default_button(script, event) - - def present_link(self, script, event=None, link=None): + @dbus_service.command + def present_link( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Presents details about the current link.""" - link = link or cthulhu_state.locusOfFocus - if not script.utilities.isLink(link): - script.presentMessage(messages.NOT_ON_A_LINK) + tokens = [ + "WHERE AM I PRESENTER: present_link. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + link = focus_manager.get_manager().get_locus_of_focus() + if not script.utilities.is_link(link): + if notify_user: + presentation_manager.get_manager().present_message(messages.NOT_ON_A_LINK) return True - return self._do_where_am_i(script, event, True, link) + return self._do_where_am_i(script, True, link) - def present_selected_text(self, script, event=None, obj=None): + def _get_all_selected_text(self, script: default.Script, obj: Atspi.Accessible) -> str: + """Returns the selected text of obj plus any adjacent text objects.""" + + string = AXUtilities.get_selected_text(obj)[0] + if AXUtilities.is_spreadsheet_cell(obj): + return string + + prev_obj = script.utilities.find_previous_object(obj) + while prev_obj: + selection = AXUtilities.get_selected_text(prev_obj)[0] + if not selection: + break + string = f"{selection} {string}" + prev_obj = script.utilities.find_previous_object(prev_obj) + + next_obj = script.utilities.find_next_object(obj) + while next_obj: + selection = AXUtilities.get_selected_text(next_obj)[0] + if not selection: + break + string = f"{string} {selection}" + next_obj = script.utilities.find_next_object(next_obj) + + return string + + @dbus_service.command + def present_selected_text( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Presents the selected text.""" - obj = obj or cthulhu_state.locusOfFocus + tokens = [ + "WHERE AM I PRESENTER: present_selected_text. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + obj = focus_manager.get_manager().get_locus_of_focus() if obj is None: - script.speakMessage(messages.LOCATION_NOT_FOUND_FULL) + presentation_manager.get_manager().speak_message(messages.LOCATION_NOT_FOUND_FULL) return True - text = script.utilities.allSelectedText(obj)[0] + text = self._get_all_selected_text(script, obj) if not text: - script.speakMessage(messages.NO_SELECTED_TEXT) + presentation_manager.get_manager().speak_message(messages.NO_SELECTED_TEXT) return True - if script.utilities.shouldVerbalizeAllPunctuation(obj): - text = script.utilities.verbalizeAllPunctuation(text) - - msg = messages.SELECTED_TEXT_IS % text - script.speakMessage(msg) + manager = speech_presenter.get_presenter() + indentation = manager.get_indentation_description(text, only_if_changed=False) + text = manager.adjust_for_presentation(obj, text) + msg = messages.SELECTED_TEXT_IS % f"{indentation} {text}" + presentation_manager.get_manager().speak_message(msg) return True - def present_selection(self, script, event=None, obj=None): + @dbus_service.command + def present_selection( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Presents the selected text or selected objects.""" - obj = obj or cthulhu_state.locusOfFocus + tokens = [ + "WHERE AM I PRESENTER: present_selection. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + obj = focus_manager.get_manager().get_locus_of_focus() if obj is None: - script.speakMessage(messages.LOCATION_NOT_FOUND_FULL) + if not script.utilities.is_link(obj): + presentation_manager.get_manager().speak_message(messages.LOCATION_NOT_FOUND_FULL) return True tokens = ["WHERE AM I PRESENTER: presenting selection for", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) - spreadsheet = AXObject.find_ancestor(obj, script.utilities.isSpreadSheetTable) - if spreadsheet is not None and script.utilities.speakSelectedCellRange(spreadsheet): + spreadsheet = AXUtilities.find_ancestor(obj, AXUtilities.is_spreadsheet_table) + if spreadsheet is not None and script.utilities.speak_selected_cell_range(spreadsheet): return True - container = script.utilities.getSelectionContainer(obj) + container = AXUtilities.get_selection_container(obj) if container is None: tokens = ["WHERE AM I PRESENTER: Selection container not found for", obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) return self.present_selected_text(script, event, obj) - selected_count = script.utilities.selectedChildCount(container) - child_count = script.utilities.selectableChildCount(container) - script.presentMessage(messages.selectedItemsCount(selected_count, child_count)) + selected_count = AXUtilities.selected_child_count(container) + child_count = AXUtilities.selectable_child_count(container) + presentation_manager.get_manager().present_message( + messages.selected_items_count(selected_count, child_count), + ) if not selected_count: return True - selected_items = script.utilities.selectedChildren(container) + selected_items = AXUtilities.selected_children(container) item_names = ",".join(map(AXObject.get_name, selected_items)) - script.speakMessage(item_names) + presentation_manager.get_manager().speak_message(item_names) return True - def _do_where_am_i(self, script, event=None, basic_only=True, obj=None): + def _do_where_am_i( + self, + script: default.Script, + basic_only: bool = True, + obj: Atspi.Accessible | None = None, + notify_user: bool = True, + ) -> bool: """Presents details about the current location at the specified level.""" - if script.spellcheck and script.spellcheck.isActive(): - script.spellcheck.presentErrorDetails(not basic_only) + presenter = spellcheck_presenter.get_presenter() + if presenter.is_active(): + presenter.present_error_details(not basic_only, script) if obj is None: - obj = cthulhu_state.locusOfFocus + obj = focus_manager.get_manager().get_locus_of_focus() if AXObject.is_dead(obj): - obj = cthulhu_state.activeWindow + obj = focus_manager.get_manager().get_active_window() if obj is None or AXObject.is_dead(obj): - script.presentMessage(messages.LOCATION_NOT_FOUND_FULL) + if notify_user: + presentation_manager.get_manager().present_message(messages.LOCATION_NOT_FOUND_FULL) return True if basic_only: - format_type = 'basicWhereAmI' + where_am_i_type = WhereAmI.BASIC else: - format_type = 'detailedWhereAmI' + where_am_i_type = WhereAmI.DETAILED - script.presentObject( - script.utilities.realActiveAncestor(obj), + def real_object(acc: Atspi.Accessible) -> Atspi.Accessible: + if AXUtilities.is_focused(acc): + return acc + + def pred(x): + return AXUtilities.is_table_cell_or_header(x) or AXUtilities.is_list_item(x) + + ancestor = AXUtilities.find_ancestor(acc, pred) + if ancestor is not None and not AXUtilities.is_layout_only( + AXObject.get_parent(ancestor), + ): + acc = ancestor + + return acc + + script.present_object( + real_object(obj), alreadyFocused=True, - formatType=format_type, + where_am_i_type=where_am_i_type, forceMnemonic=True, forceList=True, forceTutorial=True, - speechOnly=True) + speechOnly=True, + ) return True - def where_am_i_basic(self, script, event=None): + @dbus_service.command + def where_am_i_basic( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Presents basic information about the current location.""" - return self._do_where_am_i(script, event) + tokens = [ + "WHERE AM I PRESENTER: where_am_i_basic. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return self._do_where_am_i(script, notify_user=notify_user) - def where_am_i_detailed(self, script, event=None): + @dbus_service.command + def where_am_i_detailed( + self, + script: default.Script, + event: input_event.InputEvent | None = None, + notify_user: bool = True, + ) -> bool: """Presents detailed information about the current location.""" + tokens = [ + "WHERE AM I PRESENTER: where_am_i_detailed. Script:", + script, + "Event:", + event, + "notify_user:", + notify_user, + ] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + # TODO - JD: For some reason, we are starting the basic where am I # in response to the first click. Then we do the detailed one in # response to the second click. Until that's fixed, interrupt the # first one. - script.presentationInterrupt() - return self._do_where_am_i(script, event, False) + presentation_manager.get_manager().interrupt_presentation() + return self._do_where_am_i(script, False, notify_user=notify_user) -_presenter = None -def getPresenter(): + +_presenter = WhereAmIPresenter() + + +def get_presenter() -> WhereAmIPresenter: """Returns the Where Am I Presenter""" - - global _presenter - if _presenter is None: - _presenter = WhereAmIPresenter() + return _presenter diff --git a/tests/cthulhu_test_context.py b/tests/cthulhu_test_context.py index 4b5475f..9050cd3 100644 --- a/tests/cthulhu_test_context.py +++ b/tests/cthulhu_test_context.py @@ -21,6 +21,7 @@ from __future__ import annotations +import importlib import os import sys from typing import TYPE_CHECKING, Any @@ -52,6 +53,7 @@ class CthulhuTestContext: def patch(self, target: str, **kwargs) -> MagicMock: """Convenience method for creating patches.""" + self._import_patch_target_parent(target) return self.mocker.patch(target, **kwargs) def patch_object(self, target: object, attribute: str, **kwargs) -> MagicMock: @@ -59,6 +61,24 @@ class CthulhuTestContext: return self.mocker.patch.object(target, attribute, **kwargs) + @staticmethod + def _import_patch_target_parent(target: str) -> None: + """Import the deepest module prefix for string patch targets.""" + + parts = target.split(".") + for index in range(len(parts) - 1, 0, -1): + module_name = ".".join(parts[:index]) + try: + module = importlib.import_module(module_name) + except ImportError: + continue + if "." in module_name: + package_name, attr_name = module_name.rsplit(".", 1) + package = sys.modules.get(package_name) + if package is not None: + setattr(package, attr_name, module) + break + def Mock(self, **kwargs) -> MagicMock: # pylint: disable=invalid-name """Convenience method for creating Mock objects.""" @@ -146,7 +166,6 @@ class CthulhuTestContext: "cthulhu.focus_manager", "cthulhu.braille", "cthulhu.cthulhu_platform", - "cthulhu.presentation_manager", ] if additional_modules: diff --git a/tests/test_braille_presenter.py b/tests/test_braille_presenter.py new file mode 100644 index 0000000..4d06c66 --- /dev/null +++ b/tests/test_braille_presenter.py @@ -0,0 +1,1224 @@ +# Unit tests for braille_presenter.py methods. +# +# Copyright 2025-2026 Igalia, S.L. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +# pylint: disable=wrong-import-position +# pylint: disable=import-outside-toplevel +# pylint: disable=protected-access +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-lines + +"""Unit tests for braille_presenter.py methods.""" + +from __future__ import annotations + +import unittest.mock +from typing import TYPE_CHECKING + +import gi + +gi.require_version("Gtk", "3.0") + +import pytest + +if TYPE_CHECKING: + from unittest.mock import MagicMock + + from cthulhu_test_context import CthulhuTestContext + + +@pytest.mark.unit +class TestBraillePresenter: + """Test BraillePresenter methods.""" + + def _setup_dependencies(self, test_context: CthulhuTestContext) -> dict[str, MagicMock]: + """Set up mocks for braille_presenter dependencies.""" + + additional_modules = ["cthulhu.braille", "cthulhu.braille_monitor", "cthulhu.cthulhu_platform"] + essential_modules = test_context.setup_shared_dependencies(additional_modules) + + platform_mock = essential_modules["cthulhu.cthulhu_platform"] + platform_mock.tablesdir = "/usr/share/liblouis/tables" + + from cthulhu import gsettings_registry + + registry = gsettings_registry.get_registry() + registry.clear_runtime_values() + + test_context.patch( + "cthulhu.braille_presenter.BraillePresenter._get_table_files", + return_value=[ + "en-us-g1.ctb", + "en-us-g2.ctb", + "en-us-comp8.ctb", + "fr-bfu-g2.ctb", + "de-g2.ctb", + ], + ) + + return essential_modules + + def test_get_braille_is_enabled(self, test_context: CthulhuTestContext): + """Test getting braille enabled status.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + assert presenter.get_braille_is_enabled() is True + + presenter.set_braille_is_enabled(False) + assert presenter.get_braille_is_enabled() is False + + def test_set_braille_is_enabled(self, test_context: CthulhuTestContext): + """Test setting braille enabled status.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + result = presenter.set_braille_is_enabled(True) + assert result is True + assert presenter.get_braille_is_enabled() is True + + result = presenter.set_braille_is_enabled(False) + assert result is True + assert presenter.get_braille_is_enabled() is False + + def test_get_contraction_table(self, test_context: CthulhuTestContext): + """Test getting contraction table.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + assert presenter.get_contraction_table() == "" + + presenter.set_contraction_table("en-us-g2") + assert presenter.get_contraction_table() == "en-us-g2" + + def test_get_available_contraction_tables(self, test_context: CthulhuTestContext): + """Test getting available contraction tables.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + tables = presenter.get_available_contraction_tables() + expected = ["en-us-g1", "en-us-g2", "en-us-comp8", "fr-bfu-g2", "de-g2"] + assert tables == expected + + def test_set_contraction_table_valid(self, test_context: CthulhuTestContext): + """Test setting valid contraction table.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + result = presenter.set_contraction_table("en-us-g2") + assert result is True + assert presenter.get_contraction_table_path() == "/usr/share/liblouis/tables/en-us-g2.ctb" + + result = presenter.set_contraction_table("en-us-g1.ctb") + assert result is True + assert presenter.get_contraction_table_path() == "/usr/share/liblouis/tables/en-us-g1.ctb" + + def test_set_contraction_table_invalid(self, test_context: CthulhuTestContext): + """Test setting invalid contraction table.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + result = presenter.set_contraction_table("nonexistent") + assert result is False + + result = presenter.set_contraction_table("nonexistent.ctb") + assert result is False + + def test_set_contraction_table_empty_string(self, test_context: CthulhuTestContext): + """Test setting empty contraction table returns False.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + result = presenter.set_contraction_table("") + assert result is False + + def test_get_indicator_styles(self, test_context: CthulhuTestContext): + """Test getting indicator styles.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + assert presenter.get_selector_indicator() == "dots78" + assert presenter.get_link_indicator() == "dots78" + assert presenter.get_text_attributes_indicator() == "none" + + presenter.set_link_indicator("dot7") + assert presenter.get_link_indicator() == "dot7" + + def test_set_indicator_styles(self, test_context: CthulhuTestContext): + """Test setting indicator styles.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + result = presenter.set_selector_indicator("dots78") + assert result is True + assert presenter.get_selector_indicator() == "dots78" + + result = presenter.set_link_indicator("dot8") + assert result is True + assert presenter.get_link_indicator() == "dot8" + + result = presenter.set_text_attributes_indicator("none") + assert result is True + assert presenter.get_text_attributes_indicator() == "none" + + result = presenter.set_selector_indicator("invalid") + assert result is False + + result = presenter.set_link_indicator("invalid") + assert result is False + + result = presenter.set_text_attributes_indicator("invalid") + assert result is False + + def test_end_of_line_indicator(self, test_context: CthulhuTestContext): + """Test end of line indicator settings.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + assert presenter.get_end_of_line_indicator_is_enabled() is True + + result = presenter.set_end_of_line_indicator_is_enabled(False) + assert result is True + assert presenter.get_end_of_line_indicator_is_enabled() is False + + result = presenter.set_end_of_line_indicator_is_enabled(True) + assert result is True + assert presenter.get_end_of_line_indicator_is_enabled() is True + + def test_use_braille(self, test_context: CthulhuTestContext): + """Test use_braille method.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + presenter.set_monitor_is_enabled(False) + assert presenter.use_braille() is True + + presenter.set_braille_is_enabled(False) + presenter.set_monitor_is_enabled(True) + assert presenter.use_braille() is True + + presenter.set_monitor_is_enabled(False) + assert presenter.use_braille() is False + + def test_use_verbose_braille(self, test_context: CthulhuTestContext): + """Test use_verbose_braille method.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + assert presenter.use_verbose_braille() is True + + presenter.set_verbosity_level("brief") + assert presenter.use_verbose_braille() is False + + def test_get_verbosity_level(self, test_context: CthulhuTestContext): + """Test getting verbosity level.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + assert presenter.get_verbosity_level() == "verbose" + + presenter.set_verbosity_level("brief") + assert presenter.get_verbosity_level() == "brief" + + def test_set_verbosity_level(self, test_context: CthulhuTestContext): + """Test setting verbosity level.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + result = presenter.set_verbosity_level("brief") + assert result is True + assert presenter.get_verbosity_level() == "brief" + + result = presenter.set_verbosity_level("verbose") + assert result is True + assert presenter.get_verbosity_level() == "verbose" + + result = presenter.set_verbosity_level("invalid") + assert result is False + + def test_use_full_rolenames(self, test_context: CthulhuTestContext): + """Test use_full_rolenames method.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + assert presenter.use_full_rolenames() is True + + presenter.set_rolename_style("brief") + assert presenter.use_full_rolenames() is False + + def test_get_rolename_style(self, test_context: CthulhuTestContext): + """Test getting rolename style.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + assert presenter.get_rolename_style() == "verbose" + + presenter.set_rolename_style("brief") + assert presenter.get_rolename_style() == "brief" + + def test_set_rolename_style(self, test_context: CthulhuTestContext): + """Test setting rolename style.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + result = presenter.set_rolename_style("brief") + assert result is True + assert presenter.get_rolename_style() == "brief" + + result = presenter.set_rolename_style("verbose") + assert result is True + assert presenter.get_rolename_style() == "verbose" + + result = presenter.set_rolename_style("invalid") + assert result is False + + def test_get_display_ancestors(self, test_context: CthulhuTestContext): + """Test getting display ancestors setting.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + assert presenter.get_display_ancestors() is True + + presenter.set_display_ancestors(False) + assert presenter.get_display_ancestors() is False + + def test_set_display_ancestors(self, test_context: CthulhuTestContext): + """Test setting display ancestors.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + result = presenter.set_display_ancestors(True) + assert result is True + assert presenter.get_display_ancestors() is True + + result = presenter.set_display_ancestors(False) + assert result is True + assert presenter.get_display_ancestors() is False + + def test_get_contracted_braille_is_enabled(self, test_context: CthulhuTestContext): + """Test getting contracted braille enabled status.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + assert presenter.get_contracted_braille_is_enabled() is False + + presenter.set_contracted_braille_is_enabled(True) + assert presenter.get_contracted_braille_is_enabled() is True + + def test_set_contracted_braille_is_enabled(self, test_context: CthulhuTestContext): + """Test setting contracted braille enabled status.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + result = presenter.set_contracted_braille_is_enabled(True) + assert result is True + assert presenter.get_contracted_braille_is_enabled() is True + + result = presenter.set_contracted_braille_is_enabled(False) + assert result is True + assert presenter.get_contracted_braille_is_enabled() is False + + def test_get_computer_braille_at_cursor_is_enabled(self, test_context: CthulhuTestContext): + """Test getting computer braille at cursor enabled status.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + assert presenter.get_computer_braille_at_cursor_is_enabled() is True + + presenter.set_computer_braille_at_cursor_is_enabled(False) + assert presenter.get_computer_braille_at_cursor_is_enabled() is False + + def test_set_computer_braille_at_cursor_is_enabled(self, test_context: CthulhuTestContext): + """Test setting computer braille at cursor enabled status.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + result = presenter.set_computer_braille_at_cursor_is_enabled(True) + assert result is True + assert presenter.get_computer_braille_at_cursor_is_enabled() is True + + result = presenter.set_computer_braille_at_cursor_is_enabled(False) + assert result is True + assert presenter.get_computer_braille_at_cursor_is_enabled() is False + + def test_get_word_wrap_is_enabled(self, test_context: CthulhuTestContext): + """Test getting word wrap enabled status.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + assert presenter.get_word_wrap_is_enabled() is False + + presenter.set_word_wrap_is_enabled(True) + assert presenter.get_word_wrap_is_enabled() is True + + def test_set_word_wrap_is_enabled(self, test_context: CthulhuTestContext): + """Test setting word wrap enabled status.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + result = presenter.set_word_wrap_is_enabled(True) + assert result is True + assert presenter.get_word_wrap_is_enabled() is True + + result = presenter.set_word_wrap_is_enabled(False) + assert result is True + assert presenter.get_word_wrap_is_enabled() is False + + def test_get_flash_messages_are_enabled(self, test_context: CthulhuTestContext): + """Test getting flash messages enabled status.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + assert presenter.get_flash_messages_are_enabled() is True + + presenter.set_flash_messages_are_enabled(False) + assert presenter.get_flash_messages_are_enabled() is False + + def test_set_flash_messages_are_enabled(self, test_context: CthulhuTestContext): + """Test setting flash messages enabled status.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + result = presenter.set_flash_messages_are_enabled(True) + assert result is True + assert presenter.get_flash_messages_are_enabled() is True + + result = presenter.set_flash_messages_are_enabled(False) + assert result is True + assert presenter.get_flash_messages_are_enabled() is False + + def test_get_flashtime_from_settings(self, test_context: CthulhuTestContext): + """Test getting flashtime from settings.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + presenter.set_flash_messages_are_persistent(True) + assert presenter.get_flashtime_from_settings() == -1 + + presenter.set_flash_messages_are_persistent(False) + presenter.set_flash_message_duration(3000) + assert presenter.get_flashtime_from_settings() == 3000 + + def test_get_flash_message_duration(self, test_context: CthulhuTestContext): + """Test getting flash message duration.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + assert presenter.get_flash_message_duration() == 5000 + + def test_set_flash_message_duration(self, test_context: CthulhuTestContext): + """Test setting flash message duration.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + result = presenter.set_flash_message_duration(4000) + assert result is True + assert presenter.get_flash_message_duration() == 4000 + + def test_get_flash_messages_are_persistent(self, test_context: CthulhuTestContext): + """Test getting flash messages persistent status.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + assert presenter.get_flash_messages_are_persistent() is False + + presenter.set_flash_messages_are_persistent(True) + assert presenter.get_flash_messages_are_persistent() is True + + def test_set_flash_messages_are_persistent(self, test_context: CthulhuTestContext): + """Test setting flash messages persistent status.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + result = presenter.set_flash_messages_are_persistent(True) + assert result is True + assert presenter.get_flash_messages_are_persistent() is True + + result = presenter.set_flash_messages_are_persistent(False) + assert result is True + assert presenter.get_flash_messages_are_persistent() is False + + def test_get_flash_messages_are_detailed(self, test_context: CthulhuTestContext): + """Test getting flash messages detailed status.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + assert presenter.get_flash_messages_are_detailed() is True + + presenter.set_flash_messages_are_detailed(False) + assert presenter.get_flash_messages_are_detailed() is False + + def test_set_flash_messages_are_detailed(self, test_context: CthulhuTestContext): + """Test setting flash messages detailed status.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + result = presenter.set_flash_messages_are_detailed(True) + assert result is True + assert presenter.get_flash_messages_are_detailed() is True + + result = presenter.set_flash_messages_are_detailed(False) + assert result is True + assert presenter.get_flash_messages_are_detailed() is False + + def test_get_verbosity_is_detailed(self, test_context: CthulhuTestContext): + """Test _get_verbosity_is_detailed returns correct boolean.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + assert presenter._get_verbosity_is_detailed() is True + + presenter.set_verbosity_level("brief") + assert presenter._get_verbosity_is_detailed() is False + + def test_set_verbosity_is_detailed(self, test_context: CthulhuTestContext): + """Test _set_verbosity_is_detailed sets verbosity level from boolean.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + result = presenter._set_verbosity_is_detailed(True) + assert result is True + assert presenter.get_verbosity_level() == "verbose" + + result = presenter._set_verbosity_is_detailed(False) + assert result is True + assert presenter.get_verbosity_level() == "brief" + + def test_get_eol_enabled(self, test_context: CthulhuTestContext): + """Test get_end_of_line_indicator_is_enabled returns default then setter value.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + assert presenter.get_end_of_line_indicator_is_enabled() is True + + presenter.set_end_of_line_indicator_is_enabled(False) + assert presenter.get_end_of_line_indicator_is_enabled() is False + + def test_set_eol_enabled(self, test_context: CthulhuTestContext): + """Test set_end_of_line_indicator_is_enabled sets end-of-line-indicator.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + result = presenter.set_end_of_line_indicator_is_enabled(True) + assert result is True + assert presenter.get_end_of_line_indicator_is_enabled() is True + + result = presenter.set_end_of_line_indicator_is_enabled(False) + assert result is True + assert presenter.get_end_of_line_indicator_is_enabled() is False + + def test_get_use_abbreviated_rolenames(self, test_context: CthulhuTestContext): + """Test _get_use_abbreviated_rolenames returns True when brief style.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + assert presenter._get_use_abbreviated_rolenames() is False + + presenter.set_rolename_style("brief") + assert presenter._get_use_abbreviated_rolenames() is True + + def test_set_use_abbreviated_rolenames(self, test_context: CthulhuTestContext): + """Test _set_use_abbreviated_rolenames sets rolename style from boolean.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + result = presenter._set_use_abbreviated_rolenames(True) + assert result is True + assert presenter.get_rolename_style() == "brief" + + result = presenter._set_use_abbreviated_rolenames(False) + assert result is True + assert presenter.get_rolename_style() == "verbose" + + def test_get_flash_duration_seconds(self, test_context: CthulhuTestContext): + """Test _get_flash_duration_seconds converts milliseconds to seconds.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + assert presenter._get_flash_duration_seconds() == 5 + + presenter.set_flash_message_duration(2500) + assert presenter._get_flash_duration_seconds() == 2 + + presenter.set_flash_message_duration(1000) + assert presenter._get_flash_duration_seconds() == 1 + + def test_set_flash_duration_seconds(self, test_context: CthulhuTestContext): + """Test _set_flash_duration_seconds converts seconds to milliseconds.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + presenter._set_flash_duration_seconds(5) + assert presenter.get_flash_message_duration() == 5000 + + presenter._set_flash_duration_seconds(1) + assert presenter.get_flash_message_duration() == 1000 + + presenter._set_flash_duration_seconds(10) + assert presenter.get_flash_message_duration() == 10000 + + def test_get_present_mnemonics(self, test_context: CthulhuTestContext): + """Test getting present mnemonics setting.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + assert presenter.get_present_mnemonics() is True + + presenter.set_present_mnemonics(False) + assert presenter.get_present_mnemonics() is False + + def test_set_present_mnemonics(self, test_context: CthulhuTestContext): + """Test setting present mnemonics.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + result = presenter.set_present_mnemonics(True) + assert result is True + assert presenter.get_present_mnemonics() is True + + result = presenter.set_present_mnemonics(False) + assert result is True + assert presenter.get_present_mnemonics() is False + + def test_get_set_monitor_is_enabled(self, test_context: CthulhuTestContext): + """Test getting and setting braille monitor enabled status.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + result = presenter.set_monitor_is_enabled(True) + assert result is True + assert presenter.get_monitor_is_enabled() is True + + result = presenter.set_monitor_is_enabled(False) + assert result is True + assert presenter.get_monitor_is_enabled() is False + + def test_set_braille_monitor_disabled_destroys_monitor(self, test_context: CthulhuTestContext): + """Test explicitly disabling braille monitor destroys the monitor widget.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + mock_monitor = test_context.Mock() + presenter._monitor = mock_monitor + + presenter.set_monitor_is_enabled(False) + + mock_monitor.destroy.assert_called_once() + assert presenter._monitor is None + + def test_init_braille_registers_monitor_callback(self, test_context: CthulhuTestContext): + """Test init_braille registers the monitor callback with braille.""" + + essential_modules = self._setup_dependencies(test_context) + braille_mock = essential_modules["cthulhu.braille"] + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + + presenter.init_braille() + + braille_mock.set_monitor_callback.assert_called_once_with(presenter.update_monitor) + + def test_update_monitor_creates_when_enabled(self, test_context: CthulhuTestContext): + """Test update_monitor creates monitor on demand when enabled.""" + + essential_modules = self._setup_dependencies(test_context) + braille_monitor_mock = essential_modules["cthulhu.braille_monitor"] + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + presenter.set_monitor_is_enabled(True) + presenter.set_monitor_cell_count(40) + mock_monitor = test_context.Mock() + braille_monitor_mock.BrailleMonitor.return_value = mock_monitor + + presenter.update_monitor(1, "hello", None, 40) + + braille_monitor_mock.BrailleMonitor.assert_called_once_with( + 40, + on_close=unittest.mock.ANY, + foreground=unittest.mock.ANY, + background=unittest.mock.ANY, + ) + mock_monitor.show_all.assert_called_once() + mock_monitor.write_text.assert_called_once_with(1, "hello", None) + + def test_update_monitor_uses_cell_count_setting(self, test_context: CthulhuTestContext): + """Test update_monitor uses the configured cell count instead of display size.""" + + essential_modules = self._setup_dependencies(test_context) + braille_monitor_mock = essential_modules["cthulhu.braille_monitor"] + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + presenter.set_monitor_is_enabled(True) + presenter.set_monitor_cell_count(20) + mock_monitor = test_context.Mock() + braille_monitor_mock.BrailleMonitor.return_value = mock_monitor + + presenter.update_monitor(1, "hello", None, 40) + + braille_monitor_mock.BrailleMonitor.assert_called_once_with( + 20, + on_close=unittest.mock.ANY, + foreground=unittest.mock.ANY, + background=unittest.mock.ANY, + ) + + def test_update_monitor_skips_when_disabled(self, test_context: CthulhuTestContext): + """Test update_monitor skips update when disabled without destroying.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + presenter.set_monitor_is_enabled(False) + mock_monitor = test_context.Mock() + presenter._monitor = mock_monitor + + presenter.update_monitor(1, "hello", None, 40) + + mock_monitor.write_text.assert_not_called() + assert presenter._monitor is mock_monitor + + def test_destroy_monitor(self, test_context: CthulhuTestContext): + """Test destroy_monitor destroys existing monitor.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + mock_monitor = test_context.Mock() + presenter._monitor = mock_monitor + + presenter.destroy_monitor() + + mock_monitor.destroy.assert_called_once() + assert presenter._monitor is None + + def test_destroy_monitor_no_op_when_none(self, test_context: CthulhuTestContext): + """Test destroy_monitor does nothing when no monitor exists.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + assert presenter._monitor is None + + presenter.destroy_monitor() + + assert presenter._monitor is None + + def test_shutdown_braille_does_not_destroy_monitor(self, test_context: CthulhuTestContext): + """Test shutdown_braille does not destroy the monitor.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + mock_monitor = test_context.Mock() + presenter._monitor = mock_monitor + + presenter.shutdown_braille() + + mock_monitor.destroy.assert_not_called() + assert presenter._monitor is mock_monitor + essential_modules["cthulhu.braille"].shutdown.assert_called_once() + + def test_set_braille_disabled_keeps_monitor(self, test_context: CthulhuTestContext): + """Test disabling braille does not destroy the monitor.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import get_presenter + + presenter = get_presenter() + mock_monitor = test_context.Mock() + presenter._monitor = mock_monitor + + presenter.set_braille_is_enabled(False) + + mock_monitor.destroy.assert_not_called() + assert presenter._monitor is mock_monitor + + +@pytest.mark.unit +class TestBraillePreferencesGridUI: + """Test Braille preferences grid UI creation.""" + + # pylint: disable-next=too-many-statements + def _setup_dependencies(self, test_context: CthulhuTestContext) -> dict[str, MagicMock]: + """Set up mocks for braille_presenter GUI dependencies.""" + + additional_modules = ["cthulhu.braille", "cthulhu.braille_monitor", "cthulhu.cthulhu_platform"] + essential_modules = test_context.setup_shared_dependencies(additional_modules) + + platform_mock = essential_modules["cthulhu.cthulhu_platform"] + platform_mock.tablesdir = "/usr/share/liblouis/tables" + + from cthulhu import gsettings_registry + + registry = gsettings_registry.get_registry() + registry.clear_runtime_values() + + test_context.patch( + "cthulhu.braille_presenter.BraillePresenter._get_table_files", + return_value=[ + "en-us-g1.ctb", + "en-us-g2.ctb", + ], + ) + + guilabels_mock = essential_modules["cthulhu.guilabels"] + guilabels_mock.OBJECT_PRESENTATION_IS_DETAILED = "Detailed" + guilabels_mock.BRAILLE_SHOW_CONTEXT = "Show context" + guilabels_mock.BRAILLE_ABBREVIATED_ROLE_NAMES = "Abbreviated role names" + guilabels_mock.PRESENT_OBJECT_MNEMONICS = "Present mnemonics" + guilabels_mock.VERBOSITY = "Verbosity" + guilabels_mock.BRAILLE_ENABLE_CONTRACTED_BRAILLE = "Enable contracted braille" + guilabels_mock.BRAILLE_COMPUTER_BRAILLE_AT_CURSOR = "Expand word at cursor" + guilabels_mock.BRAILLE_ENABLE_END_OF_LINE_SYMBOL = "End of line symbol" + guilabels_mock.BRAILLE_ENABLE_WORD_WRAP = "Enable word wrap" + guilabels_mock.BRAILLE_CONTRACTION_TABLE = "Contraction table" + guilabels_mock.BRAILLE_HYPERLINK_INDICATOR = "Hyperlink indicator" + guilabels_mock.BRAILLE_DOT_NONE = "None" + guilabels_mock.BRAILLE_DOT_7 = "Dot 7" + guilabels_mock.BRAILLE_DOT_8 = "Dot 8" + guilabels_mock.BRAILLE_DOT_7_8 = "Dots 7 and 8" + guilabels_mock.BRAILLE_INDICATORS = "Indicators" + guilabels_mock.BRAILLE_SELECTION_INDICATOR = "Selection indicator" + guilabels_mock.BRAILLE_TEXT_ATTRIBUTES_INDICATOR = "Text attributes indicator" + guilabels_mock.BRAILLE_DISPLAY_SETTINGS = "Display Settings" + guilabels_mock.BRAILLE_MESSAGES_ARE_PERSISTENT = "Messages are persistent" + guilabels_mock.BRAILLE_ENABLE_FLASH_MESSAGES = "Enable flash messages" + guilabels_mock.BRAILLE_MESSAGES_ARE_DETAILED = "Messages are detailed" + guilabels_mock.BRAILLE_DURATION_SECS = "Duration (secs)" + guilabels_mock.BRAILLE_FLASH_MESSAGES = "Flash Messages" + guilabels_mock.GENERAL_BRAILLE_UPDATES = "Braille updates" + guilabels_mock.GENERAL_FREQUENCY_SECS = "Frequency (secs)" + guilabels_mock.GENERAL_APPLIES_TO = "Applies to" + guilabels_mock.PROGRESS_BAR_ALL = "All" + guilabels_mock.PROGRESS_BAR_APPLICATION = "Application" + guilabels_mock.PROGRESS_BAR_WINDOW = "Window" + guilabels_mock.PROGRESS_BARS = "Progress Bars" + guilabels_mock.BRAILLE = "Braille" + guilabels_mock.BRAILLE_MONITOR = "On-screen braille" + guilabels_mock.ON_SCREEN_DISPLAY = "On-Screen Display" + guilabels_mock.BRAILLE_MONITOR_CELL_COUNT = "Cell count" + guilabels_mock.BRAILLE_MONITOR_SHOW_DOTS = "Show braille dot patterns" + guilabels_mock.BRAILLE_MONITOR_FOREGROUND = "Text color" + guilabels_mock.BRAILLE_MONITOR_BACKGROUND = "Background color" + guilabels_mock.BRAILLE_MONITOR_INFO = "On-screen braille info" + + return essential_modules + + def test_braille_verbosity_grid_creates_widgets(self, test_context: CthulhuTestContext) -> None: + """Test BrailleVerbosityPreferencesGrid creates correct widgets.""" + + from gi.repository import Gtk + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import ( + BraillePresenter, + BrailleVerbosityPreferencesGrid, + ) + + presenter = BraillePresenter() + grid = BrailleVerbosityPreferencesGrid(presenter) + + assert isinstance(grid, Gtk.Grid) + assert len(grid._widgets) == 4 + + def test_braille_display_settings_grid_creates_widgets( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test BrailleDisplaySettingsPreferencesGrid creates correct widgets.""" + + from gi.repository import Gtk + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import ( + BrailleDisplaySettingsPreferencesGrid, + BraillePresenter, + ) + + presenter = BraillePresenter() + grid = BrailleDisplaySettingsPreferencesGrid(presenter) + + assert isinstance(grid, Gtk.Grid) + assert len(grid._widgets) == 8 + + def test_braille_display_settings_grid_has_contracted_control( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test BrailleDisplaySettingsPreferencesGrid has contracted control.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import ( + BrailleDisplaySettingsPreferencesGrid, + BraillePresenter, + ) + + presenter = BraillePresenter() + grid = BrailleDisplaySettingsPreferencesGrid(presenter) + + contracted_switch = grid.get_widget(2) + assert contracted_switch is not None + + computer_braille_at_cursor_switch = grid.get_widget(3) + assert computer_braille_at_cursor_switch is not None + + contraction_table_combo = grid.get_widget(4) + assert contraction_table_combo is not None + + def test_braille_flash_messages_grid_creates_widgets( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test BrailleFlashMessagesPreferencesGrid creates correct widgets.""" + + from gi.repository import Gtk + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import ( + BrailleFlashMessagesPreferencesGrid, + BraillePresenter, + ) + + presenter = BraillePresenter() + grid = BrailleFlashMessagesPreferencesGrid(presenter) + + assert isinstance(grid, Gtk.Grid) + assert len(grid._widgets) == 4 + + def test_braille_flash_messages_grid_has_persistent_control( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test BrailleFlashMessagesPreferencesGrid has persistent control.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import ( + BrailleFlashMessagesPreferencesGrid, + BraillePresenter, + ) + + presenter = BraillePresenter() + grid = BrailleFlashMessagesPreferencesGrid(presenter) + + persistent_switch = grid.get_widget(2) + assert persistent_switch is not None + + duration_spinbutton = grid.get_widget(3) + assert duration_spinbutton is not None + + def test_braille_progress_bars_grid_creates_widgets( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test BrailleProgressBarsPreferencesGrid creates correct widgets.""" + + from gi.repository import Gtk + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import ( + BraillePresenter, + BrailleProgressBarsPreferencesGrid, + ) + + presenter = BraillePresenter() + grid = BrailleProgressBarsPreferencesGrid(presenter) + + assert isinstance(grid, Gtk.Grid) + assert len(grid._widgets) == 3 + + def test_braille_preferences_grid_creates_multi_page_stack( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test BraillePreferencesGrid creates multi-page stack.""" + + from gi.repository import Gtk + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import BraillePreferencesGrid, BraillePresenter + + presenter = BraillePresenter() + grid = BraillePreferencesGrid(presenter) + + assert isinstance(grid, Gtk.Grid) + assert grid._verbosity_grid is not None + assert grid._display_settings_grid is not None + assert grid._flash_messages_grid is not None + assert grid._progress_bars_grid is not None + + def test_braille_preferences_grid_save_settings(self, test_context: CthulhuTestContext) -> None: + """Test BraillePreferencesGrid save_settings returns combined dict.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import BraillePreferencesGrid, BraillePresenter + + presenter = BraillePresenter() + grid = BraillePreferencesGrid(presenter) + + result = grid.save_settings() + + assert isinstance(result, dict) + assert "verbosity-level" in result + assert "end-of-line-indicator" in result + assert "flash-messages" in result + assert "braille-progress-bar-updates" in result + + def test_braille_preferences_grid_title_callback(self, test_context: CthulhuTestContext) -> None: + """Test BraillePreferencesGrid stores title change callback.""" + + self._setup_dependencies(test_context) + from cthulhu.braille_presenter import BraillePreferencesGrid, BraillePresenter + + presenter = BraillePresenter() + callback_calls: list[str] = [] + + def title_callback(title: str) -> None: + callback_calls.append(title) + + grid = BraillePreferencesGrid(presenter, title_change_callback=title_callback) + + assert grid._title_change_callback is title_callback + + def test_verbosity_grid_switch_reflects_initial_value( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test verbosity grid switch shows correct initial value.""" + + self._setup_dependencies(test_context) + + from cthulhu.braille_presenter import ( + BraillePresenter, + BrailleVerbosityPreferencesGrid, + ) + + presenter = BraillePresenter() + presenter.set_verbosity_level("verbose") + grid = BrailleVerbosityPreferencesGrid(presenter) + + detailed_switch = grid.get_widget(0) + assert detailed_switch is not None + assert detailed_switch.get_active() is True + + def test_verbosity_grid_switch_toggle_updates_setting( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test toggling verbosity switch updates the setting.""" + + self._setup_dependencies(test_context) + + from cthulhu.braille_presenter import ( + BraillePresenter, + BrailleVerbosityPreferencesGrid, + ) + + presenter = BraillePresenter() + presenter.set_verbosity_level("brief") + grid = BrailleVerbosityPreferencesGrid(presenter) + + detailed_switch = grid.get_widget(0) + assert detailed_switch is not None + assert detailed_switch.get_active() is False + + grid._initializing = False + detailed_switch.set_active(True) + + result = grid.save_settings() + assert result["verbosity-level"] == "verbose" + + def test_flash_messages_duration_initial_value(self, test_context: CthulhuTestContext) -> None: + """Test flash duration spinbutton shows correct initial value.""" + + self._setup_dependencies(test_context) + + from cthulhu.braille_presenter import ( + BrailleFlashMessagesPreferencesGrid, + BraillePresenter, + ) + + presenter = BraillePresenter() + presenter.set_flash_message_duration(3000) + grid = BrailleFlashMessagesPreferencesGrid(presenter) + + duration_spinbutton = grid.get_widget(3) + assert duration_spinbutton is not None + assert duration_spinbutton.get_value() == 3 + + def test_display_settings_combobox_initial_value(self, test_context: CthulhuTestContext) -> None: + """Test indicator combobox shows correct initial selection.""" + + self._setup_dependencies(test_context) + + from cthulhu.braille_presenter import ( + BrailleDisplaySettingsPreferencesGrid, + BraillePresenter, + ) + + presenter = BraillePresenter() + presenter.set_link_indicator("dot7") + grid = BrailleDisplaySettingsPreferencesGrid(presenter) + + link_combo = grid.get_widget(5) + assert link_combo is not None + assert link_combo.get_active() == 1 diff --git a/tests/test_caret_navigator.py b/tests/test_caret_navigator.py new file mode 100644 index 0000000..4fe9ca5 --- /dev/null +++ b/tests/test_caret_navigator.py @@ -0,0 +1,847 @@ +# Unit tests for caret_navigator.py methods. +# +# Copyright 2025 Igalia, S.L. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +# pylint: disable=wrong-import-position +# pylint: disable=import-outside-toplevel +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-statements +# pylint: disable=protected-access +# pylint: disable=too-many-arguments +# pylint: disable=too-many-positional-arguments +# pylint: disable=too-many-locals + +"""Unit tests for caret_navigator.py methods.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest + +if TYPE_CHECKING: + from cthulhu_test_context import CthulhuTestContext + + +@pytest.mark.unit +class TestCaretNavigator: + """Test CaretNavigator class methods.""" + + def _setup_dependencies(self, test_context: CthulhuTestContext) -> dict[str, Any]: + """Set up mocks for caret_navigator dependencies.""" + + additional_modules = [ + "cthulhu.command_manager", + "cthulhu.input_event_manager", + "cthulhu.keybindings", + "cthulhu.cmdnames", + "cthulhu.guilabels", + "cthulhu.debug", + "cthulhu.ax_object", + "cthulhu.ax_text", + "cthulhu.script_manager", + "cthulhu.messages", + "cthulhu.object_properties", + "cthulhu.cthulhu_gui_navlist", + "cthulhu.cthulhu_i18n", + "cthulhu.AXHypertext", + "cthulhu.AXObject", + "cthulhu.AXTable", + "cthulhu.AXText", + "cthulhu.AXUtilities", + "cthulhu.input_event", + "cthulhu.braille_presenter", + "cthulhu.presentation_manager", + ] + essential_modules = test_context.setup_shared_dependencies(additional_modules) + + # Set up cmdnames with all required values for structural_navigator + cmdnames = essential_modules["cthulhu.cmdnames"] + cmdnames.STRUCTURAL_NAVIGATION_MODE_CYCLE = "cycle_mode" + cmdnames.BLOCKQUOTE_PREV = "previous_blockquote" + cmdnames.BLOCKQUOTE_NEXT = "next_blockquote" + cmdnames.BLOCKQUOTE_LIST = "list_blockquotes" + cmdnames.BUTTON_PREV = "previous_button" + cmdnames.BUTTON_NEXT = "next_button" + cmdnames.BUTTON_LIST = "list_buttons" + cmdnames.CHECK_BOX_PREV = "previous_checkbox" + cmdnames.CHECK_BOX_NEXT = "next_checkbox" + cmdnames.CHECK_BOX_LIST = "list_checkboxes" + cmdnames.COMBO_BOX_PREV = "previous_combobox" + cmdnames.COMBO_BOX_NEXT = "next_combobox" + cmdnames.COMBO_BOX_LIST = "list_comboboxes" + cmdnames.ENTRY_PREV = "previous_entry" + cmdnames.ENTRY_NEXT = "next_entry" + cmdnames.ENTRY_LIST = "list_entries" + cmdnames.FORM_FIELD_PREV = "previous_form_field" + cmdnames.FORM_FIELD_NEXT = "next_form_field" + cmdnames.FORM_FIELD_LIST = "list_form_fields" + cmdnames.HEADING_PREV = "previous_heading" + cmdnames.HEADING_NEXT = "next_heading" + cmdnames.HEADING_LIST = "list_headings" + cmdnames.HEADING_AT_LEVEL_PREV = "previous_heading_level_%d" + cmdnames.HEADING_AT_LEVEL_NEXT = "next_heading_level_%d" + cmdnames.HEADING_AT_LEVEL_LIST = "list_headings_level_%d" + cmdnames.IFRAME_PREV = "previous_iframe" + cmdnames.IFRAME_NEXT = "next_iframe" + cmdnames.IFRAME_LIST = "list_iframes" + cmdnames.IMAGE_PREV = "previous_image" + cmdnames.IMAGE_NEXT = "next_image" + cmdnames.IMAGE_LIST = "list_images" + cmdnames.LANDMARK_PREV = "previous_landmark" + cmdnames.LANDMARK_NEXT = "next_landmark" + cmdnames.LANDMARK_LIST = "list_landmarks" + cmdnames.LIST_PREV = "previous_list" + cmdnames.LIST_NEXT = "next_list" + cmdnames.LIST_LIST = "list_lists" + cmdnames.LIST_ITEM_PREV = "previous_list_item" + cmdnames.LIST_ITEM_NEXT = "next_list_item" + cmdnames.LIST_ITEM_LIST = "list_list_items" + cmdnames.LIVE_REGION_PREV = "previous_live_region" + cmdnames.LIVE_REGION_NEXT = "next_live_region" + cmdnames.LIVE_REGION_LAST = "last_live_region" + cmdnames.PARAGRAPH_PREV = "previous_paragraph" + cmdnames.PARAGRAPH_NEXT = "next_paragraph" + cmdnames.PARAGRAPH_LIST = "list_paragraphs" + cmdnames.RADIO_BUTTON_PREV = "previous_radio_button" + cmdnames.RADIO_BUTTON_NEXT = "next_radio_button" + cmdnames.RADIO_BUTTON_LIST = "list_radio_buttons" + cmdnames.SEPARATOR_PREV = "previous_separator" + cmdnames.SEPARATOR_NEXT = "next_separator" + cmdnames.TABLE_PREV = "previous_table" + cmdnames.TABLE_NEXT = "next_table" + cmdnames.TABLE_LIST = "list_tables" + cmdnames.UNVISITED_LINK_PREV = "previous_unvisited_link" + cmdnames.UNVISITED_LINK_NEXT = "next_unvisited_link" + cmdnames.UNVISITED_LINK_LIST = "list_unvisited_links" + cmdnames.VISITED_LINK_PREV = "previous_visited_link" + cmdnames.VISITED_LINK_NEXT = "next_visited_link" + cmdnames.VISITED_LINK_LIST = "list_visited_links" + cmdnames.LINK_PREV = "previous_link" + cmdnames.LINK_NEXT = "next_link" + cmdnames.LINK_LIST = "list_links" + cmdnames.CLICKABLE_PREV = "previous_clickable" + cmdnames.CLICKABLE_NEXT = "next_clickable" + cmdnames.CLICKABLE_LIST = "list_clickables" + cmdnames.LARGE_OBJECT_PREV = "previous_large_object" + cmdnames.LARGE_OBJECT_NEXT = "next_large_object" + cmdnames.LARGE_OBJECT_LIST = "list_large_objects" + cmdnames.CONTAINER_START = "container_start" + cmdnames.CONTAINER_END = "container_end" + + essential_modules["cthulhu.cthulhu_i18n"]._ = lambda x: x + essential_modules["cthulhu.debug"].print_message = test_context.Mock() + essential_modules["cthulhu.debug"].LEVEL_INFO = 800 + + controller_mock = test_context.Mock() + controller_mock.register_decorated_module.return_value = None + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = controller_mock + + focus_manager_instance = test_context.Mock() + focus_manager_instance.get_locus_of_focus.return_value = None + essential_modules["cthulhu.focus_manager"].get_manager.return_value = focus_manager_instance + + essential_modules["cthulhu.AXObject"].supports_collection.return_value = True + essential_modules["cthulhu.AXUtilities"].is_heading.return_value = False + + return essential_modules + + @pytest.mark.parametrize( + "direction,event_provided,context_available,expected_result", + [ + pytest.param("next", False, True, True, id="next_char_no_event_returns_true"), + pytest.param("next", True, False, False, id="next_char_no_context_returns_false"), + pytest.param("next", True, True, True, id="next_char_valid_navigation_succeeds"), + pytest.param("previous", False, True, True, id="prev_char_no_event_returns_true"), + pytest.param("previous", True, False, False, id="prev_char_no_context_returns_false"), + pytest.param("previous", True, True, True, id="prev_char_valid_navigation_succeeds"), + ], + ) + def test_character_navigation( + self, + test_context: CthulhuTestContext, + direction: str, + event_provided: bool, + context_available: bool, + expected_result: bool, + ) -> None: + """Test character navigation (next/previous) with various conditions.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.caret_navigator import CaretNavigator # pylint: disable=import-outside-toplevel + + ax_object_mock = essential_modules["cthulhu.ax_object"] + ax_object_mock.AXObject.supports_text.side_effect = lambda obj: obj is not None + ax_object_mock.AXObject.is_valid.side_effect = lambda obj: obj is not None + ax_object_mock.AXObject.is_ancestor.side_effect = ( + lambda obj, root, same: obj is not None and root is not None + ) + + navigator = CaretNavigator() + test_context.patch_object(navigator, "_get_root_object", return_value=None) + mock_script = test_context.Mock() + mock_event = test_context.Mock() if event_provided else None + + if context_available: + mock_obj = test_context.Mock() + if direction == "next": + mock_script.utilities.next_context.return_value = (mock_obj, 10) + else: + mock_script.utilities.previous_context.return_value = (mock_obj, 5) + elif direction == "next": + mock_script.utilities.next_context.return_value = (None, 0) + else: + mock_script.utilities.previous_context.return_value = (None, 0) + + navigation_method = getattr(navigator, f"{direction}_character") + result = navigation_method(mock_script, mock_event) + assert result == expected_result + + if expected_result: + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + assert navigator._last_input_event == mock_event + mock_script.utilities.set_caret_position.assert_called_once() + pres_manager.interrupt_presentation.assert_called_once() + mock_script.update_braille.assert_called_once() + mock_script.say_character.assert_called_once() + + @pytest.mark.parametrize( + "direction,context_result,word_contents,expected_result", + [ + pytest.param("next", (None, 0), None, False, id="next_word_no_context"), + pytest.param("next", ("obj", 20), [], False, id="next_word_no_contents"), + pytest.param( + "next", + ("obj", 20), + [("obj", 20, 25, "word")], + True, + id="next_word_success", + ), + pytest.param("previous", (None, 0), None, False, id="previous_word_no_context"), + pytest.param("previous", ("obj", 15), [], False, id="previous_word_no_contents"), + pytest.param( + "previous", + ("obj", 15), + [("obj", 10, 15, "word")], + True, + id="previous_word_success", + ), + ], + ) + def test_word_navigation( + self, + test_context: CthulhuTestContext, + direction: str, + context_result: tuple, + word_contents: list | None, + expected_result: bool, + ) -> None: + """Test word navigation (next/previous) with various error conditions.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.caret_navigator import CaretNavigator # pylint: disable=import-outside-toplevel + + navigator = CaretNavigator() + mock_script = test_context.Mock() + mock_event = test_context.Mock() + + if direction == "next": + mock_script.utilities.next_context.return_value = context_result + else: + mock_script.utilities.previous_context.return_value = context_result + + mock_script.utilities.get_word_contents_at_offset.return_value = word_contents or [] + + mock_script.utilities.set_caret_position = test_context.Mock() + mock_script.update_braille = test_context.Mock() + mock_script.say_word = test_context.Mock() + + navigation_method = getattr(navigator, f"{direction}_word") + result = navigation_method(mock_script, mock_event) + + assert result == expected_result + + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + if expected_result: + assert navigator._last_input_event == mock_event + mock_script.utilities.set_caret_position.assert_called() + pres_manager.interrupt_presentation.assert_called_once() + mock_script.update_braille.assert_called_once() + mock_script.say_word.assert_called_once() + else: + mock_script.utilities.set_caret_position.assert_not_called() + pres_manager.interrupt_presentation.assert_not_called() + + @pytest.mark.parametrize( + "test_method,expected_result", + [ + pytest.param("suspend_commands", True, id="suspend_commands"), + pytest.param("toggle_enabled", True, id="toggle_enabled"), + ], + ) + def test_navigator_control_methods( + self, + test_context: CthulhuTestContext, + test_method: str, + expected_result: bool, + ) -> None: + """Test CaretNavigator control methods.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.caret_navigator import CaretNavigator # pylint: disable=import-outside-toplevel + + navigator = CaretNavigator() + mock_script = test_context.Mock() + mock_event = test_context.Mock() + + if test_method == "suspend_commands": + mock_cmd_mgr = test_context.Mock() + essential_modules["cthulhu.command_manager"].get_manager.return_value = mock_cmd_mgr + test_context.patch_object(navigator, "_is_active_script", return_value=True) + navigator._suspended = False + navigator.suspend_commands(mock_script, True, "test reason") + assert navigator._suspended == expected_result + mock_cmd_mgr.set_group_suspended.assert_called_once() + + elif test_method == "toggle_enabled": + mock_cmd_mgr = test_context.Mock() + essential_modules["cthulhu.command_manager"].get_manager.return_value = mock_cmd_mgr + + guilabels_mock = essential_modules["cthulhu.guilabels"] + guilabels_mock.CARET_NAVIGATION_ENABLED = "Caret navigation enabled" + guilabels_mock.CARET_NAVIGATION_DISABLED = "Caret navigation disabled" + + result = navigator.toggle_enabled(mock_script, mock_event) + assert result == expected_result + mock_cmd_mgr.set_group_enabled.assert_called_once() + + def test_navigator_initialization(self, test_context: CthulhuTestContext) -> None: + """Test CaretNavigator initialization.""" + self._setup_dependencies(test_context) + from cthulhu import command_manager + from cthulhu.caret_navigator import CaretNavigator + + navigator = CaretNavigator() + + assert hasattr(navigator, "_last_input_event") + assert hasattr(navigator, "_suspended") + assert navigator._last_input_event is None + assert navigator._suspended is False + # Commands are registered in CommandManager + cmd_manager = command_manager.get_manager() + assert cmd_manager is not None + + @pytest.mark.parametrize( + "navigation_type,in_say_all,current_line,next_prev_contents,expected_result", + [ + pytest.param( + "next_line", + True, + [("obj", 0, 10, "text")], + [], + True, + id="next_line_in_say_all", + ), + pytest.param("next_line", False, [], [], False, id="next_line_no_current_line"), + pytest.param( + "next_line", + False, + [("obj", 0, 10, "text")], + [], + False, + id="next_line_no_next_contents", + ), + pytest.param( + "next_line", + False, + [("obj", 0, 10, "text")], + [("obj2", 11, 21, "next")], + True, + id="next_line_success", + ), + pytest.param( + "previous_line", + True, + [("obj", 0, 10, "text")], + [], + True, + id="previous_line_in_say_all", + ), + pytest.param("previous_line", False, [], [], False, id="previous_line_no_contents"), + pytest.param( + "previous_line", + False, + [("obj", 0, 10, "text")], + [("obj", 0, 10, "prev")], + True, + id="previous_line_success", + ), + ], + ) + def test_line_navigation( + self, + test_context: CthulhuTestContext, + navigation_type: str, + in_say_all: bool, + current_line: list | None, + next_prev_contents: list, + expected_result: bool, + ) -> None: + """Test line navigation including say-all mode handling.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.caret_navigator import CaretNavigator # pylint: disable=import-outside-toplevel + + navigator = CaretNavigator() + mock_script = test_context.Mock() + mock_event = test_context.Mock() + + focus_manager_mock = essential_modules["cthulhu.focus_manager"] + manager_instance = test_context.Mock() + focus_manager_mock.get_manager.return_value = manager_instance + manager_instance.in_say_all.return_value = in_say_all + + if in_say_all: + from cthulhu import say_all_presenter # pylint: disable=import-outside-toplevel + + say_all_presenter.get_presenter().set_rewind_and_fast_forward_enabled(True) + + if navigation_type == "next_line" and not in_say_all: + mock_script.utilities.get_caret_context.return_value = ("obj", 5) + mock_script.utilities.get_line_contents_at_offset.return_value = current_line + mock_script.utilities.get_next_line_contents.return_value = next_prev_contents + test_context.patch_object(navigator, "_get_end_of_file", return_value=(None, -1)) + test_context.patch_object(navigator, "_line_contains_context", return_value=False) + test_context.patch_object(navigator, "_is_navigable_object", return_value=True) + elif navigation_type == "previous_line" and not in_say_all: + mock_script.utilities.get_caret_context.return_value = ("obj", 5) + mock_script.utilities.get_line_contents_at_offset.return_value = current_line + mock_script.utilities.get_previous_line_contents.return_value = next_prev_contents + test_context.patch_object(navigator, "_get_start_of_file", return_value=(None, -1)) + test_context.patch_object(navigator, "_line_contains_context", return_value=False) + test_context.patch_object(navigator, "_is_navigable_object", return_value=True) + + mock_script.utilities.set_caret_position = test_context.Mock() + + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.interrupt_presentation.reset_mock() + pres_manager.speak_contents.reset_mock() + pres_manager.display_contents.reset_mock() + + navigation_method = getattr(navigator, f"{navigation_type}") + result = navigation_method(mock_script, mock_event) + + assert result == expected_result + + if expected_result and not in_say_all: + assert navigator._last_input_event == mock_event + mock_script.utilities.set_caret_position.assert_called() + pres_manager.interrupt_presentation.assert_called_once() + pres_manager.speak_contents.assert_called_once() + pres_manager.display_contents.assert_called_once() + elif in_say_all: + assert navigator._last_input_event != mock_event + + @pytest.mark.parametrize( + "navigation_type,line_contents,expected_result", + [ + pytest.param("start_of_line", [], False, id="start_of_line_no_line"), + pytest.param( + "start_of_line", + [("obj", 5, 15, "text")], + True, + id="start_of_line_success", + ), + pytest.param("end_of_line", [], False, id="end_of_line_no_line"), + pytest.param( + "end_of_line", + [("obj", 5, 15, "text ")], + True, + id="end_of_line_with_space", + ), + pytest.param("end_of_line", [("obj", 5, 15, "text")], True, id="end_of_line_no_space"), + ], + ) + def test_line_boundary_navigation( + self, + test_context: CthulhuTestContext, + navigation_type: str, + line_contents: list, + expected_result: bool, + ) -> None: + """Test start/end of line navigation.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.caret_navigator import CaretNavigator # pylint: disable=import-outside-toplevel + + navigator = CaretNavigator() + mock_script = test_context.Mock() + mock_event = test_context.Mock() + + mock_script.utilities.get_caret_context.return_value = ("obj", 10) + mock_script.utilities.get_line_contents_at_offset.return_value = line_contents + + mock_script.utilities.set_caret_position = test_context.Mock() + mock_script.say_character = test_context.Mock() + + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.interrupt_presentation.reset_mock() + pres_manager.display_contents.reset_mock() + + navigation_method = getattr(navigator, f"{navigation_type}") + result = navigation_method(mock_script, mock_event) + + assert result == expected_result + + if expected_result: + assert navigator._last_input_event == mock_event + mock_script.utilities.set_caret_position.assert_called() + pres_manager.interrupt_presentation.assert_called_once() + mock_script.say_character.assert_called_once() + pres_manager.display_contents.assert_called_once() + + @pytest.mark.parametrize( + "script_is_active,expected_result", + [ + pytest.param(True, True, id="script_is_active"), + pytest.param(False, False, id="script_is_not_active"), + ], + ) + def test_is_active_script( + self, + test_context: CthulhuTestContext, + script_is_active: bool, + expected_result: bool, + ) -> None: + """Test _is_active_script method with active and non-active scripts.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.caret_navigator import CaretNavigator # pylint: disable=import-outside-toplevel + + navigator = CaretNavigator() + mock_script = test_context.Mock() + mock_active_script = test_context.Mock() + + script_manager_mock = essential_modules["cthulhu.script_manager"] + manager_instance = test_context.Mock() + script_manager_mock.get_manager.return_value = manager_instance + + if script_is_active: + manager_instance.get_active_script.return_value = mock_script + else: + manager_instance.get_active_script.return_value = mock_active_script + + debug_mock = essential_modules["cthulhu.debug"] + debug_mock.LEVEL_INFO = 800 + debug_mock.print_tokens = test_context.Mock() + + result = navigator._is_active_script(mock_script) + assert result == expected_result + + if not script_is_active: + debug_mock.print_tokens.assert_called_once() + + def test_get_is_enabled(self, test_context: CthulhuTestContext) -> None: + """Test get_is_enabled returns setting value.""" + + self._setup_dependencies(test_context) + from cthulhu.caret_navigator import CaretNavigator # pylint: disable=import-outside-toplevel + + navigator = CaretNavigator() + result = navigator.get_is_enabled() + assert result is True + + def test_set_is_enabled_no_change(self, test_context: CthulhuTestContext) -> None: + """Test set_is_enabled still calls set_group_enabled even if value unchanged.""" + + essential_modules = self._setup_dependencies(test_context) + mock_cmd_mgr = test_context.Mock() + essential_modules["cthulhu.command_manager"].get_manager.return_value = mock_cmd_mgr + from cthulhu.caret_navigator import CaretNavigator # pylint: disable=import-outside-toplevel + + navigator = CaretNavigator() + result = navigator.set_is_enabled(True) + assert result is True + mock_cmd_mgr.set_group_enabled.assert_called_once() + + def test_set_is_enabled_updates_setting(self, test_context: CthulhuTestContext) -> None: + """Test set_is_enabled updates setting and calls CommandManager.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu import gsettings_registry + + gsettings_registry.get_registry().set_runtime_value("caret-navigation", "enabled", False) + mock_script = test_context.Mock() + essential_modules[ + "cthulhu.script_manager" + ].get_manager.return_value.get_active_script.return_value = mock_script + mock_cmd_mgr = test_context.Mock() + essential_modules["cthulhu.command_manager"].get_manager.return_value = mock_cmd_mgr + from cthulhu.caret_navigator import CaretNavigator # pylint: disable=import-outside-toplevel + + navigator = CaretNavigator() + + result = navigator.set_is_enabled(True) + assert result is True + assert navigator.get_is_enabled() is True + assert navigator._last_input_event is None + mock_cmd_mgr.set_group_enabled.assert_called_once() + + def test_set_is_enabled_no_active_script(self, test_context: CthulhuTestContext) -> None: + """Test set_is_enabled updates state even with no active script.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu import gsettings_registry + + gsettings_registry.get_registry().set_runtime_value("caret-navigation", "enabled", False) + essential_modules[ + "cthulhu.script_manager" + ].get_manager.return_value.get_active_script.return_value = None + mock_cmd_mgr = test_context.Mock() + essential_modules["cthulhu.command_manager"].get_manager.return_value = mock_cmd_mgr + from cthulhu.caret_navigator import CaretNavigator # pylint: disable=import-outside-toplevel + + navigator = CaretNavigator() + + result = navigator.set_is_enabled(True) + assert result is True + mock_cmd_mgr.set_group_enabled.assert_called_once() + + def test_get_triggers_focus_mode(self, test_context: CthulhuTestContext) -> None: + """Test get_triggers_focus_mode returns setting value.""" + + self._setup_dependencies(test_context) + from cthulhu.caret_navigator import CaretNavigator # pylint: disable=import-outside-toplevel + + navigator = CaretNavigator() + result = navigator.get_triggers_focus_mode() + assert result is False + + def test_set_triggers_focus_mode(self, test_context: CthulhuTestContext) -> None: + """Test set_triggers_focus_mode updates setting.""" + + self._setup_dependencies(test_context) + from cthulhu import gsettings_registry + + gsettings_registry.get_registry().set_runtime_value( + "caret-navigation", + "triggers-focus-mode", + True, + ) + from cthulhu.caret_navigator import CaretNavigator # pylint: disable=import-outside-toplevel + + navigator = CaretNavigator() + result = navigator.set_triggers_focus_mode(False) + assert result is True + assert navigator.get_triggers_focus_mode() is False + + def test_set_triggers_focus_mode_no_change(self, test_context: CthulhuTestContext) -> None: + """Test set_triggers_focus_mode returns early if unchanged.""" + + self._setup_dependencies(test_context) + from cthulhu import gsettings_registry + + gsettings_registry.get_registry().set_runtime_value( + "caret-navigation", + "triggers-focus-mode", + True, + ) + from cthulhu.caret_navigator import CaretNavigator # pylint: disable=import-outside-toplevel + + navigator = CaretNavigator() + result = navigator.set_triggers_focus_mode(True) + assert result is True + # set_setting no longer used - settings are set directly + + def test_get_enabled_for_script(self, test_context: CthulhuTestContext) -> None: + """Test get_enabled_for_script returns script-specific state.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + from cthulhu.caret_navigator import CaretNavigator # pylint: disable=import-outside-toplevel + + navigator = CaretNavigator() + navigator._enabled_for_script[mock_script] = True + result = navigator.get_enabled_for_script(mock_script) + assert result is True + + def test_get_enabled_for_script_default(self, test_context: CthulhuTestContext) -> None: + """Test get_enabled_for_script returns False by default.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + from cthulhu.caret_navigator import CaretNavigator # pylint: disable=import-outside-toplevel + + navigator = CaretNavigator() + result = navigator.get_enabled_for_script(mock_script) + assert result is False + + def test_set_enabled_for_script(self, test_context: CthulhuTestContext) -> None: + """Test set_enabled_for_script updates script-specific state and calls set_is_enabled.""" + + essential_modules = self._setup_dependencies(test_context) + mock_script = test_context.Mock() + from cthulhu import gsettings_registry + + gsettings_registry.get_registry().set_runtime_value("caret-navigation", "enabled", False) + essential_modules[ + "cthulhu.script_manager" + ].get_manager.return_value.get_active_script.return_value = mock_script + mock_cmd_mgr = test_context.Mock() + essential_modules["cthulhu.command_manager"].get_manager.return_value = mock_cmd_mgr + from cthulhu.caret_navigator import CaretNavigator # pylint: disable=import-outside-toplevel + + navigator = CaretNavigator() + test_context.patch_object(navigator, "_is_active_script", return_value=True) + + navigator.set_enabled_for_script(mock_script, True) + assert navigator._enabled_for_script[mock_script] is True + mock_cmd_mgr.set_group_enabled.assert_called_once() + + def test_set_enabled_for_script_inactive_script(self, test_context: CthulhuTestContext) -> None: + """Test set_enabled_for_script doesn't call set_group_enabled for inactive script.""" + + essential_modules = self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_cmd_mgr = test_context.Mock() + essential_modules["cthulhu.command_manager"].get_manager.return_value = mock_cmd_mgr + from cthulhu.caret_navigator import CaretNavigator # pylint: disable=import-outside-toplevel + + navigator = CaretNavigator() + test_context.patch_object(navigator, "_is_active_script", return_value=False) + + navigator.set_enabled_for_script(mock_script, True) + assert navigator._enabled_for_script[mock_script] is True + mock_cmd_mgr.set_group_enabled.assert_not_called() + + def test_set_enabled_for_script_always_calls_set_group_enabled( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test set_enabled_for_script always calls set_group_enabled even if setting matches. + + This is a regression test for issue #655 where caret navigation commands + were not being enabled because set_is_enabled() would early-return when + the setting already matched the desired value. + """ + + essential_modules = self._setup_dependencies(test_context) + mock_script = test_context.Mock() + essential_modules[ + "cthulhu.script_manager" + ].get_manager.return_value.get_active_script.return_value = mock_script + mock_cmd_mgr = test_context.Mock() + essential_modules["cthulhu.command_manager"].get_manager.return_value = mock_cmd_mgr + from cthulhu.caret_navigator import CaretNavigator # pylint: disable=import-outside-toplevel + + navigator = CaretNavigator() + test_context.patch_object(navigator, "_is_active_script", return_value=True) + + navigator.set_enabled_for_script(mock_script, True) + assert navigator._enabled_for_script[mock_script] is True + mock_cmd_mgr.set_group_enabled.assert_called_once() + + def test_last_command_prevents_focus_mode_true(self, test_context: CthulhuTestContext) -> None: + """Test last_command_prevents_focus_mode returns True.""" + + self._setup_dependencies(test_context) + from cthulhu.caret_navigator import CaretNavigator # pylint: disable=import-outside-toplevel + + navigator = CaretNavigator() + mock_event = test_context.Mock() + navigator._last_input_event = mock_event + test_context.patch_object( + navigator, + "last_input_event_was_navigation_command", + return_value=True, + ) + result = navigator.last_command_prevents_focus_mode() + assert result is True + + def test_last_command_prevents_focus_mode_false_no_event( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test last_command_prevents_focus_mode returns False if no event.""" + + self._setup_dependencies(test_context) + from cthulhu.caret_navigator import CaretNavigator # pylint: disable=import-outside-toplevel + + navigator = CaretNavigator() + navigator._last_input_event = None + result = navigator.last_command_prevents_focus_mode() + assert result is False + + def test_last_command_prevents_focus_mode_false_setting_true( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test last_command_prevents_focus_mode returns False if setting True.""" + + self._setup_dependencies(test_context) + from cthulhu.caret_navigator import CaretNavigator # pylint: disable=import-outside-toplevel + + navigator = CaretNavigator() + navigator.set_triggers_focus_mode(True) + mock_event = test_context.Mock() + navigator._last_input_event = mock_event + test_context.patch_object( + navigator, + "last_input_event_was_navigation_command", + return_value=True, + ) + result = navigator.last_command_prevents_focus_mode() + assert result is False + + def test_successful_navigation_emits_region_changed( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test successful caret navigation emits region_changed with CARET_NAVIGATOR mode.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu import focus_manager + from cthulhu.caret_navigator import CaretNavigator + + ax_object_mock = essential_modules["cthulhu.ax_object"] + ax_object_mock.AXObject.supports_text.side_effect = lambda obj: obj is not None + ax_object_mock.AXObject.is_valid.side_effect = lambda obj: obj is not None + ax_object_mock.AXObject.is_ancestor.side_effect = ( + lambda obj, root, same: obj is not None and root is not None + ) + + focus_manager_mock = essential_modules["cthulhu.focus_manager"] + manager_instance = test_context.Mock() + focus_manager_mock.get_manager.return_value = manager_instance + focus_manager_mock.CARET_NAVIGATOR = focus_manager.CARET_NAVIGATOR + + navigator = CaretNavigator() + test_context.patch_object(navigator, "_get_root_object", return_value=None) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + mock_obj = test_context.Mock() + + mock_script.utilities.next_context.return_value = (mock_obj, 10) + + result = navigator.next_character(mock_script, mock_event) + + assert result is True + manager_instance.emit_region_changed.assert_called() + call_kwargs = manager_instance.emit_region_changed.call_args + assert call_kwargs.kwargs.get("mode") == focus_manager.CARET_NAVIGATOR diff --git a/tests/test_document_presenter.py b/tests/test_document_presenter.py new file mode 100644 index 0000000..92bf93d --- /dev/null +++ b/tests/test_document_presenter.py @@ -0,0 +1,2331 @@ +# Unit tests for document_presenter.py. +# +# Copyright 2026 Igalia, S.L. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +# pylint: disable=wrong-import-position +# pylint: disable=protected-access +# pylint: disable=import-outside-toplevel +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-locals + +# pylint: disable=too-many-lines +"""Unit tests for document_presenter.py.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from cthulhu_test_context import CthulhuTestContext + + +@pytest.mark.unit +class TestDocumentPresenter: + """Test DocumentPresenter class.""" + + def _setup_presenter(self, test_context: CthulhuTestContext): + """Set up mocks for document_presenter dependencies.""" + + additional_modules = [ + "cthulhu.ax_document", + "cthulhu.ax_object", + "cthulhu.ax_table", + "cthulhu.ax_text", + "cthulhu.ax_utilities", + "cthulhu.caret_navigator", + "cthulhu.focus_manager", + "cthulhu.guilabels", + "cthulhu.input_event_manager", + "cthulhu.live_region_presenter", + "cthulhu.preferences_grid_base", + "cthulhu.script_manager", + "cthulhu.structural_navigator", + "cthulhu.table_navigator", + "cthulhu.braille_presenter", + "cthulhu.presentation_manager", + ] + essential_modules = test_context.setup_shared_dependencies(additional_modules) + + # Set up guilabels with required constants + guilabels = essential_modules["cthulhu.guilabels"] + guilabels.DOCUMENTS = "Documents" + guilabels.KB_GROUP_CARET_NAVIGATION = "Caret Navigation" + guilabels.KB_GROUP_STRUCTURAL_NAVIGATION = "Structural Navigation" + guilabels.KB_GROUP_TABLE_NAVIGATION = "Table Navigation" + guilabels.NATIVE_NAVIGATION = "Native Navigation" + guilabels.AUTOMATIC_FOCUS_MODE = "Automatic focus mode" + guilabels.CONTENT_LAYOUT_MODE = "Layout mode" + guilabels.TABLE_SKIP_BLANK_CELLS = "Skip blank cells" + guilabels.FIND_SPEAK_RESULTS = "Speak find results" + guilabels.FIND_ONLY_SPEAK_CHANGED_LINES = "Only speak changed lines" + guilabels.FIND_MINIMUM_MATCH_LENGTH = "Minimum match length" + guilabels.FIND_OPTIONS = "Find Options" + guilabels.READ_PAGE_UPON_LOAD = "Read page upon load" + guilabels.PAGE_SUMMARY_UPON_LOAD = "Page summary upon load" + guilabels.PAGE_LOAD = "Page Load" + guilabels.CARET_NAVIGATION_INFO = "Caret navigation info" + guilabels.STRUCTURAL_NAVIGATION_INFO = "Structural navigation info" + guilabels.NATIVE_NAVIGATION_INFO = "Native navigation info" + guilabels.AUTOMATIC_FOCUS_MODE_INFO = "Auto focus mode info" + + from cthulhu import gsettings_registry + + registry = gsettings_registry.get_registry() + registry.clear_runtime_values() + + # Import and return the module + from cthulhu import document_presenter + + return document_presenter, essential_modules + + def test_get_presenter_returns_singleton(self, test_context: CthulhuTestContext) -> None: + """Test get_presenter returns the same instance.""" + + module, _mocks = self._setup_presenter(test_context) + presenter1 = module.get_presenter() + presenter2 = module.get_presenter() + + assert presenter1 is presenter2 + + def test_get_native_nav_triggers_focus_mode_default( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test get_native_nav_triggers_focus_mode returns default value.""" + + module, _mocks = self._setup_presenter(test_context) + + presenter = module.get_presenter() + result = presenter.get_native_nav_triggers_focus_mode() + + assert result is True + + def test_set_native_nav_triggers_focus_mode(self, test_context: CthulhuTestContext) -> None: + """Test set_native_nav_triggers_focus_mode updates setting.""" + + module, _mocks = self._setup_presenter(test_context) + + presenter = module.get_presenter() + result = presenter.set_native_nav_triggers_focus_mode(False) + + assert result is True + assert presenter.get_native_nav_triggers_focus_mode() is False + + def test_set_native_nav_triggers_focus_mode_no_change( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test set_native_nav_triggers_focus_mode returns True when value unchanged.""" + + module, _mocks = self._setup_presenter(test_context) + + presenter = module.get_presenter() + result = presenter.set_native_nav_triggers_focus_mode(True) + + assert result is True + + def test_get_say_all_on_load_default(self, test_context: CthulhuTestContext) -> None: + """Test get_say_all_on_load returns default value.""" + + module, _mocks = self._setup_presenter(test_context) + + presenter = module.get_presenter() + result = presenter.get_say_all_on_load() + + assert result is True + + def test_set_say_all_on_load(self, test_context: CthulhuTestContext) -> None: + """Test set_say_all_on_load updates setting.""" + + module, _mocks = self._setup_presenter(test_context) + + presenter = module.get_presenter() + result = presenter.set_say_all_on_load(False) + + assert result is True + assert presenter.get_say_all_on_load() is False + + def test_set_say_all_on_load_no_change(self, test_context: CthulhuTestContext) -> None: + """Test set_say_all_on_load returns True when value unchanged.""" + + module, _mocks = self._setup_presenter(test_context) + + presenter = module.get_presenter() + result = presenter.set_say_all_on_load(True) + + assert result is True + + def test_get_page_summary_on_load_default(self, test_context: CthulhuTestContext) -> None: + """Test get_page_summary_on_load returns default value.""" + + module, _mocks = self._setup_presenter(test_context) + + presenter = module.get_presenter() + result = presenter.get_page_summary_on_load() + + assert result is True + + def test_set_page_summary_on_load(self, test_context: CthulhuTestContext) -> None: + """Test set_page_summary_on_load updates setting.""" + + module, _mocks = self._setup_presenter(test_context) + + presenter = module.get_presenter() + result = presenter.set_page_summary_on_load(False) + + assert result is True + assert presenter.get_page_summary_on_load() is False + + def test_set_page_summary_on_load_no_change(self, test_context: CthulhuTestContext) -> None: + """Test set_page_summary_on_load returns True when value unchanged.""" + + module, _mocks = self._setup_presenter(test_context) + + presenter = module.get_presenter() + result = presenter.set_page_summary_on_load(True) + + assert result is True + + def test_get_speak_find_results_true(self, test_context: CthulhuTestContext) -> None: + """Test get_speak_find_results returns True when verbosity is not NONE.""" + + module, _mocks = self._setup_presenter(test_context) + + presenter = module.get_presenter() + result = presenter.get_speak_find_results() + + assert result is True + + def test_get_speak_find_results_false(self, test_context: CthulhuTestContext) -> None: + """Test get_speak_find_results returns False when verbosity is NONE.""" + + module, _mocks = self._setup_presenter(test_context) + from cthulhu import gsettings_registry # pylint: disable=import-outside-toplevel + + presenter = module.get_presenter() + gsettings_registry.get_registry().set_runtime_value( + "document", + "find-results-verbosity", + "none", + ) + result = presenter.get_speak_find_results() + + assert result is False + + def test_set_speak_find_results_enable(self, test_context: CthulhuTestContext) -> None: + """Test set_speak_find_results enables speech.""" + + module, _mocks = self._setup_presenter(test_context) + from cthulhu import gsettings_registry # pylint: disable=import-outside-toplevel + + presenter = module.get_presenter() + gsettings_registry.get_registry().set_runtime_value( + "document", + "find-results-verbosity", + "none", + ) + result = presenter.set_speak_find_results(True) + + assert result is True + assert presenter.get_speak_find_results() is True + + def test_set_speak_find_results_disable(self, test_context: CthulhuTestContext) -> None: + """Test set_speak_find_results disables speech.""" + + module, _mocks = self._setup_presenter(test_context) + + presenter = module.get_presenter() + result = presenter.set_speak_find_results(False) + + assert result is True + assert presenter.get_speak_find_results() is False + + def test_get_only_speak_changed_lines_true(self, test_context: CthulhuTestContext) -> None: + """Test get_only_speak_changed_lines returns True when set.""" + + module, _mocks = self._setup_presenter(test_context) + from cthulhu import gsettings_registry # pylint: disable=import-outside-toplevel + + presenter = module.get_presenter() + gsettings_registry.get_registry().set_runtime_value( + "document", + "find-results-verbosity", + "if-line-changed", + ) + result = presenter.get_only_speak_changed_lines() + + assert result is True + + def test_get_only_speak_changed_lines_false(self, test_context: CthulhuTestContext) -> None: + """Test get_only_speak_changed_lines returns False when not set.""" + + module, _mocks = self._setup_presenter(test_context) + + presenter = module.get_presenter() + result = presenter.get_only_speak_changed_lines() + + assert result is False + + def test_set_only_speak_changed_lines_enable(self, test_context: CthulhuTestContext) -> None: + """Test set_only_speak_changed_lines enables the option.""" + + module, _mocks = self._setup_presenter(test_context) + + presenter = module.get_presenter() + result = presenter.set_only_speak_changed_lines(True) + + assert result is True + assert presenter.get_only_speak_changed_lines() is True + + def test_set_only_speak_changed_lines_disable(self, test_context: CthulhuTestContext) -> None: + """Test set_only_speak_changed_lines disables the option.""" + + module, _mocks = self._setup_presenter(test_context) + from cthulhu import gsettings_registry # pylint: disable=import-outside-toplevel + + presenter = module.get_presenter() + gsettings_registry.get_registry().set_runtime_value( + "document", + "find-results-verbosity", + "if-line-changed", + ) + result = presenter.set_only_speak_changed_lines(False) + + assert result is True + assert presenter.get_only_speak_changed_lines() is False + + def test_get_find_results_minimum_length(self, test_context: CthulhuTestContext) -> None: + """Test get_find_results_minimum_length returns current value.""" + + module, _mocks = self._setup_presenter(test_context) + + presenter = module.get_presenter() + presenter.set_find_results_minimum_length(5) + result = presenter.get_find_results_minimum_length() + + assert result == 5 + + def test_set_find_results_minimum_length(self, test_context: CthulhuTestContext) -> None: + """Test set_find_results_minimum_length updates setting.""" + + module, _mocks = self._setup_presenter(test_context) + + presenter = module.get_presenter() + presenter.set_find_results_minimum_length(5) + result = presenter.set_find_results_minimum_length(10) + + assert result is True + assert presenter.get_find_results_minimum_length() == 10 + + def test_set_find_results_minimum_length_no_change(self, test_context: CthulhuTestContext) -> None: + """Test set_find_results_minimum_length returns True when value unchanged.""" + + module, _mocks = self._setup_presenter(test_context) + + presenter = module.get_presenter() + presenter.set_find_results_minimum_length(5) + result = presenter.set_find_results_minimum_length(5) + + assert result is True + + def test_reset_find_announcement_state(self, test_context: CthulhuTestContext) -> None: + """Test reset_find_announcement_state resets the internal state.""" + + module, _mocks = self._setup_presenter(test_context) + presenter = module.get_presenter() + + # Set the internal state to True + presenter._made_find_announcement = True + assert presenter._made_find_announcement is True + + # Reset and verify + presenter.reset_find_announcement_state() + assert presenter._made_find_announcement is False + + def test_present_find_results_no_active_script(self, test_context: CthulhuTestContext) -> None: + """Test present_find_results returns False when no active script.""" + + module, mocks = self._setup_presenter(test_context) + script_manager = mocks["cthulhu.script_manager"] + script_manager.get_manager.return_value.get_active_script.return_value = None + + presenter = module.get_presenter() + result = presenter.present_find_results(None, 0) + + assert result is False + + def test_present_find_results_no_document(self, test_context: CthulhuTestContext) -> None: + """Test present_find_results returns False when no document.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + # Set up mock script with utilities + mock_script = MagicMock() + mock_script.utilities.get_document_for_object.return_value = None + script_manager = mocks["cthulhu.script_manager"] + script_manager.get_manager.return_value.get_active_script.return_value = mock_script + + presenter = module.get_presenter() + mock_obj = MagicMock() + result = presenter.present_find_results(mock_obj, 0) + + assert result is False + + def test_present_find_results_selection_too_short(self, test_context: CthulhuTestContext) -> None: + """Test present_find_results returns False when selection is too short.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + from cthulhu import gsettings_registry # pylint: disable=import-outside-toplevel + + gsettings_registry.get_registry().set_runtime_value( + "document", + "find-results-minimum-length", + 5, + ) + + # Set up mock script + mock_script = MagicMock() + mock_script.utilities.get_document_for_object.return_value = MagicMock() + mock_script.utilities.get_caret_context.return_value = (None, 0) + script_manager = mocks["cthulhu.script_manager"] + script_manager.get_manager.return_value.get_active_script.return_value = mock_script + + # Set up AXText mock to return short selection (length 3) + ax_utilities = mocks["cthulhu.ax_utilities"] + ax_utilities.AXUtilities.get_selection_start_offset.return_value = 0 + ax_utilities.AXUtilities.get_selection_end_offset.return_value = 3 + + presenter = module.get_presenter() + mock_obj = MagicMock() + result = presenter.present_find_results(mock_obj, 0) + + assert result is False + + def test_present_find_results_speak_disabled(self, test_context: CthulhuTestContext) -> None: + """Test present_find_results returns False when speak is disabled.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + from cthulhu import gsettings_registry # pylint: disable=import-outside-toplevel + + registry = gsettings_registry.get_registry() + registry.set_runtime_value("document", "find-results-minimum-length", 3) + registry.set_runtime_value("document", "find-results-verbosity", "none") + + # Set up mock script + mock_script = MagicMock() + mock_script.utilities.get_document_for_object.return_value = MagicMock() + mock_script.utilities.get_caret_context.return_value = (None, 0) + script_manager = mocks["cthulhu.script_manager"] + script_manager.get_manager.return_value.get_active_script.return_value = mock_script + + # Set up AXText mock with valid selection length + ax_utilities = mocks["cthulhu.ax_utilities"] + ax_utilities.AXUtilities.get_selection_start_offset.return_value = 0 + ax_utilities.AXUtilities.get_selection_end_offset.return_value = 10 + + presenter = module.get_presenter() + mock_obj = MagicMock() + result = presenter.present_find_results(mock_obj, 0) + + assert result is False + + def test_present_find_results_success(self, test_context: CthulhuTestContext) -> None: + """Test present_find_results returns True and presents when conditions are met.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + from cthulhu import gsettings_registry # pylint: disable=import-outside-toplevel + + gsettings_registry.get_registry().set_runtime_value( + "document", + "find-results-minimum-length", + 3, + ) + + # Set up mock script + mock_script = MagicMock() + mock_script.utilities.get_document_for_object.return_value = MagicMock() + mock_script.utilities.get_caret_context.return_value = (None, 0) + mock_script.utilities.get_line_contents_at_offset.return_value = ["test content"] + mock_script.utilities.get_find_results_count.return_value = "1 of 5" + script_manager = mocks["cthulhu.script_manager"] + script_manager.get_manager.return_value.get_active_script.return_value = mock_script + + # Set up AXText mock with valid selection length + ax_utilities = mocks["cthulhu.ax_utilities"] + ax_utilities.AXUtilities.get_selection_start_offset.return_value = 0 + ax_utilities.AXUtilities.get_selection_end_offset.return_value = 10 + + presenter = module.get_presenter() + presenter._made_find_announcement = False + pres_manager = mocks["cthulhu.presentation_manager"].get_manager() + pres_manager.speak_contents.reset_mock() + pres_manager.present_message.reset_mock() + mock_obj = MagicMock() + result = presenter.present_find_results(mock_obj, 0) + + assert result is True + assert presenter._made_find_announcement is True + pres_manager.speak_contents.assert_called_once() + mock_script.update_braille.assert_called_once() + pres_manager.present_message.assert_called_once_with("1 of 5") + + def test_present_find_results_skips_same_line_when_only_changed( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test present_find_results skips when same line and only_speak_changed_lines.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + from cthulhu import gsettings_registry # pylint: disable=import-outside-toplevel + + registry = gsettings_registry.get_registry() + registry.set_runtime_value("document", "find-results-minimum-length", 3) + registry.set_runtime_value("document", "find-results-verbosity", "if-line-changed") + + # Set up mock script + mock_script = MagicMock() + mock_script.utilities.get_document_for_object.return_value = MagicMock() + mock_script.utilities.get_caret_context.return_value = (MagicMock(), 5) + script_manager = mocks["cthulhu.script_manager"] + script_manager.get_manager.return_value.get_active_script.return_value = mock_script + + # Set up AXText mock with valid selection length + ax_utilities = mocks["cthulhu.ax_utilities"] + ax_utilities.AXUtilities.get_selection_start_offset.return_value = 0 + ax_utilities.AXUtilities.get_selection_end_offset.return_value = 10 + ax_text = mocks["cthulhu.ax_text"] + ax_text.AXText.get_range_rect.return_value = MagicMock() + + ax_utilities.AXUtilities.rects_are_on_same_line = MagicMock(return_value=True) + + presenter = module.get_presenter() + presenter._made_find_announcement = True # Already announced + pres_manager = mocks["cthulhu.presentation_manager"].get_manager() + pres_manager.speak_contents.reset_mock() + mock_obj = MagicMock() + result = presenter.present_find_results(mock_obj, 5) + + assert result is False + pres_manager.speak_contents.assert_not_called() + + def test_use_focus_mode_no_active_script(self, test_context: CthulhuTestContext) -> None: + """Test use_focus_mode returns False when no active script.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + script_manager = mocks["cthulhu.script_manager"] + script_manager.get_manager.return_value.get_active_script.return_value = None + + presenter = module.get_presenter() + result = presenter.use_focus_mode(MagicMock()) + + assert result is False + + def test_use_focus_mode_sticky_focus_mode(self, test_context: CthulhuTestContext) -> None: + """Test use_focus_mode returns True when focus mode is sticky.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + script_manager = mocks["cthulhu.script_manager"] + script_manager.get_manager.return_value.get_active_script.return_value = mock_script + + presenter = module.get_presenter() + presenter._app_states[hash(mock_app)] = module._AppModeState( + in_focus_mode=True, + focus_mode_is_sticky=True, + browse_mode_is_sticky=False, + ) + + result = presenter.use_focus_mode(MagicMock()) + + assert result is True + + def test_use_focus_mode_sticky_browse_mode(self, test_context: CthulhuTestContext) -> None: + """Test use_focus_mode returns False when browse mode is sticky.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + script_manager = mocks["cthulhu.script_manager"] + script_manager.get_manager.return_value.get_active_script.return_value = mock_script + + presenter = module.get_presenter() + presenter._app_states[hash(mock_app)] = module._AppModeState( + in_focus_mode=False, + focus_mode_is_sticky=False, + browse_mode_is_sticky=True, + ) + + result = presenter.use_focus_mode(MagicMock()) + + assert result is False + + def test_use_focus_mode_in_say_all(self, test_context: CthulhuTestContext) -> None: + """Test use_focus_mode returns False when in say all.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + script_manager = mocks["cthulhu.script_manager"] + script_manager.get_manager.return_value.get_active_script.return_value = mock_script + + focus_manager = mocks["cthulhu.focus_manager"] + focus_manager.get_manager.return_value.in_say_all.return_value = True + + presenter = module.get_presenter() + # No sticky state set (defaults to browse mode) + result = presenter.use_focus_mode(MagicMock()) + + assert result is False + + def test_use_focus_mode_struct_nav_prevents(self, test_context: CthulhuTestContext) -> None: + """Test use_focus_mode returns False when struct nav prevents it.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + script_manager = mocks["cthulhu.script_manager"] + script_manager.get_manager.return_value.get_active_script.return_value = mock_script + + focus_manager = mocks["cthulhu.focus_manager"] + focus_manager.get_manager.return_value.in_say_all.return_value = False + + struct_nav = mocks["cthulhu.structural_navigator"] + struct_nav.get_navigator.return_value.last_command_prevents_focus_mode.return_value = True + + presenter = module.get_presenter() + result = presenter.use_focus_mode(MagicMock()) + + assert result is False + + def test_use_focus_mode_table_nav_prevents(self, test_context: CthulhuTestContext) -> None: + """Test use_focus_mode returns False when table nav was last command.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + script_manager = mocks["cthulhu.script_manager"] + script_manager.get_manager.return_value.get_active_script.return_value = mock_script + + focus_manager = mocks["cthulhu.focus_manager"] + focus_manager.get_manager.return_value.in_say_all.return_value = False + + struct_nav = mocks["cthulhu.structural_navigator"] + struct_nav.get_navigator.return_value.last_command_prevents_focus_mode.return_value = False + + table_nav = mocks["cthulhu.table_navigator"] + nav_mock = table_nav.get_navigator.return_value + nav_mock.last_input_event_was_navigation_command.return_value = True + + presenter = module.get_presenter() + result = presenter.use_focus_mode(MagicMock()) + + assert result is False + + def test_use_focus_mode_caret_nav_prevents(self, test_context: CthulhuTestContext) -> None: + """Test use_focus_mode returns False when caret nav prevents it.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + script_manager = mocks["cthulhu.script_manager"] + script_manager.get_manager.return_value.get_active_script.return_value = mock_script + + focus_manager = mocks["cthulhu.focus_manager"] + focus_manager.get_manager.return_value.in_say_all.return_value = False + + struct_nav = mocks["cthulhu.structural_navigator"] + struct_nav.get_navigator.return_value.last_command_prevents_focus_mode.return_value = False + + table_nav = mocks["cthulhu.table_navigator"] + nav_mock = table_nav.get_navigator.return_value + nav_mock.last_input_event_was_navigation_command.return_value = False + + caret_nav = mocks["cthulhu.caret_navigator"] + caret_nav.get_navigator.return_value.last_command_prevents_focus_mode.return_value = True + + ax_object = mocks["cthulhu.ax_object"] + ax_object.AXObject.is_dead.return_value = False + + ax_utilities = mocks["cthulhu.ax_utilities"] + ax_utilities.AXUtilities.find_ancestor_inclusive.return_value = None + + presenter = module.get_presenter() + result = presenter.use_focus_mode(MagicMock(), MagicMock()) + + assert result is False + + def test_use_focus_mode_focus_mode_widget(self, test_context: CthulhuTestContext) -> None: + """Test use_focus_mode returns True for focus mode widget.""" + + from unittest.mock import MagicMock, patch + + module, mocks = self._setup_presenter(test_context) + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + script_manager = mocks["cthulhu.script_manager"] + script_manager.get_manager.return_value.get_active_script.return_value = mock_script + + focus_manager = mocks["cthulhu.focus_manager"] + focus_manager.get_manager.return_value.in_say_all.return_value = False + + struct_nav = mocks["cthulhu.structural_navigator"] + struct_nav.get_navigator.return_value.last_command_prevents_focus_mode.return_value = False + + table_nav = mocks["cthulhu.table_navigator"] + nav_mock = table_nav.get_navigator.return_value + nav_mock.last_input_event_was_navigation_command.return_value = False + + caret_nav = mocks["cthulhu.caret_navigator"] + caret_nav.get_navigator.return_value.last_command_prevents_focus_mode.return_value = False + + presenter = module.get_presenter() + with patch.object(presenter, "is_focus_mode_widget", return_value=True): + result = presenter.use_focus_mode(MagicMock()) + + assert result is True + + def test_use_focus_mode_entering_web_app(self, test_context: CthulhuTestContext) -> None: + """Test use_focus_mode returns True when entering a web application.""" + + from unittest.mock import MagicMock, patch + + module, mocks = self._setup_presenter(test_context) + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + script_manager = mocks["cthulhu.script_manager"] + script_manager.get_manager.return_value.get_active_script.return_value = mock_script + + focus_manager = mocks["cthulhu.focus_manager"] + focus_manager.get_manager.return_value.in_say_all.return_value = False + + struct_nav = mocks["cthulhu.structural_navigator"] + struct_nav.get_navigator.return_value.last_command_prevents_focus_mode.return_value = False + + table_nav = mocks["cthulhu.table_navigator"] + nav_mock = table_nav.get_navigator.return_value + nav_mock.last_input_event_was_navigation_command.return_value = False + + caret_nav = mocks["cthulhu.caret_navigator"] + caret_nav.get_navigator.return_value.last_command_prevents_focus_mode.return_value = False + + ax_utilities = mocks["cthulhu.ax_utilities"] + ax_utilities.AXUtilities.is_link.return_value = False + ax_utilities.AXUtilities.is_radio_button.return_value = False + ax_utilities.AXUtilities.is_embedded = MagicMock() + + # prev_obj not in app, obj in app + ax_utilities.AXUtilities.find_ancestor.side_effect = [None, MagicMock()] + + presenter = module.get_presenter() + with patch.object(presenter, "is_focus_mode_widget", return_value=False): + result = presenter.use_focus_mode(MagicMock(), MagicMock()) + + assert result is True + + def test_use_focus_mode_default_false(self, test_context: CthulhuTestContext) -> None: + """Test use_focus_mode returns False by default.""" + + from unittest.mock import MagicMock, patch + + module, mocks = self._setup_presenter(test_context) + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + script_manager = mocks["cthulhu.script_manager"] + script_manager.get_manager.return_value.get_active_script.return_value = mock_script + + focus_manager = mocks["cthulhu.focus_manager"] + focus_manager.get_manager.return_value.in_say_all.return_value = False + + struct_nav = mocks["cthulhu.structural_navigator"] + struct_nav.get_navigator.return_value.last_command_prevents_focus_mode.return_value = False + + table_nav = mocks["cthulhu.table_navigator"] + nav_mock = table_nav.get_navigator.return_value + nav_mock.last_input_event_was_navigation_command.return_value = False + + caret_nav = mocks["cthulhu.caret_navigator"] + caret_nav.get_navigator.return_value.last_command_prevents_focus_mode.return_value = False + + ax_utilities = mocks["cthulhu.ax_utilities"] + ax_utilities.AXUtilities.is_link.return_value = False + ax_utilities.AXUtilities.is_radio_button.return_value = False + ax_utilities.AXUtilities.is_embedded = MagicMock() + + ax_utilities.AXUtilities.find_ancestor.return_value = None + + presenter = module.get_presenter() + with patch.object(presenter, "is_focus_mode_widget", return_value=False): + result = presenter.use_focus_mode(MagicMock()) + + assert result is False + + def test_use_focus_mode_native_nav_no_trigger(self, test_context: CthulhuTestContext) -> None: + """Test use_focus_mode returns current mode when native nav doesn't trigger.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + script_manager = mocks["cthulhu.script_manager"] + script_manager.get_manager.return_value.get_active_script.return_value = mock_script + + focus_manager = mocks["cthulhu.focus_manager"] + focus_manager.get_manager.return_value.in_say_all.return_value = False + + struct_nav = mocks["cthulhu.structural_navigator"] + struct_nav.get_navigator.return_value.last_command_prevents_focus_mode.return_value = False + nav_mock = struct_nav.get_navigator.return_value + nav_mock.last_input_event_was_navigation_command.return_value = False + + table_nav = mocks["cthulhu.table_navigator"] + nav_mock = table_nav.get_navigator.return_value + nav_mock.last_input_event_was_navigation_command.return_value = False + + caret_nav = mocks["cthulhu.caret_navigator"] + caret_nav.get_navigator.return_value.last_command_prevents_focus_mode.return_value = False + nav_mock = caret_nav.get_navigator.return_value + nav_mock.last_input_event_was_navigation_command.return_value = False + + presenter = module.get_presenter() + presenter.set_native_nav_triggers_focus_mode(False) + presenter._app_states[hash(mock_app)] = module._AppModeState( + in_focus_mode=True, + focus_mode_is_sticky=False, + browse_mode_is_sticky=False, + ) + + result = presenter.use_focus_mode(MagicMock()) + + assert result is True + + def test_in_focus_mode_default_false(self, test_context: CthulhuTestContext) -> None: + """Test in_focus_mode returns False when no state exists for app.""" + + from unittest.mock import MagicMock + + module, _mocks = self._setup_presenter(test_context) + presenter = module.get_presenter() + mock_app = MagicMock() + + result = presenter.in_focus_mode(mock_app) + + assert result is False + + def test_in_focus_mode_returns_state(self, test_context: CthulhuTestContext) -> None: + """Test in_focus_mode returns stored state.""" + + from unittest.mock import MagicMock + + module, _mocks = self._setup_presenter(test_context) + presenter = module.get_presenter() + mock_app = MagicMock() + + presenter._app_states[hash(mock_app)] = module._AppModeState( + in_focus_mode=True, + focus_mode_is_sticky=False, + browse_mode_is_sticky=False, + ) + + result = presenter.in_focus_mode(mock_app) + + assert result is True + + def test_focus_mode_is_sticky_default_false(self, test_context: CthulhuTestContext) -> None: + """Test focus_mode_is_sticky returns False by default.""" + + from unittest.mock import MagicMock + + module, _mocks = self._setup_presenter(test_context) + presenter = module.get_presenter() + mock_app = MagicMock() + + result = presenter.focus_mode_is_sticky(mock_app) + + assert result is False + + def test_focus_mode_is_sticky_returns_state(self, test_context: CthulhuTestContext) -> None: + """Test focus_mode_is_sticky returns stored state.""" + + from unittest.mock import MagicMock + + module, _mocks = self._setup_presenter(test_context) + presenter = module.get_presenter() + mock_app = MagicMock() + + presenter._app_states[hash(mock_app)] = module._AppModeState( + in_focus_mode=True, + focus_mode_is_sticky=True, + browse_mode_is_sticky=False, + ) + + result = presenter.focus_mode_is_sticky(mock_app) + + assert result is True + + def test_browse_mode_is_sticky_default_false(self, test_context: CthulhuTestContext) -> None: + """Test browse_mode_is_sticky returns False by default.""" + + from unittest.mock import MagicMock + + module, _mocks = self._setup_presenter(test_context) + presenter = module.get_presenter() + mock_app = MagicMock() + + result = presenter.browse_mode_is_sticky(mock_app) + + assert result is False + + def test_browse_mode_is_sticky_returns_state(self, test_context: CthulhuTestContext) -> None: + """Test browse_mode_is_sticky returns stored state.""" + + from unittest.mock import MagicMock + + module, _mocks = self._setup_presenter(test_context) + presenter = module.get_presenter() + mock_app = MagicMock() + + presenter._app_states[hash(mock_app)] = module._AppModeState( + in_focus_mode=False, + focus_mode_is_sticky=False, + browse_mode_is_sticky=True, + ) + + result = presenter.browse_mode_is_sticky(mock_app) + + assert result is True + + def test_get_state_for_app_sets_mode(self, test_context: CthulhuTestContext) -> None: + """Test _get_state_for_app can be used to set mode state.""" + + from unittest.mock import MagicMock + + module, _mocks = self._setup_presenter(test_context) + presenter = module.get_presenter() + mock_app = MagicMock() + + state = presenter._get_state_for_app(mock_app) + state.in_focus_mode = True + + assert presenter.in_focus_mode(mock_app) is True + + state = presenter._get_state_for_app(mock_app) + state.in_focus_mode = False + + assert presenter.in_focus_mode(mock_app) is False + + def test_clear_state_for_app(self, test_context: CthulhuTestContext) -> None: + """Test clear_state_for_app removes app state.""" + + from unittest.mock import MagicMock + + module, _mocks = self._setup_presenter(test_context) + presenter = module.get_presenter() + mock_app = MagicMock() + + presenter._app_states[hash(mock_app)] = module._AppModeState( + in_focus_mode=False, + focus_mode_is_sticky=False, + browse_mode_is_sticky=True, + ) + + presenter.clear_state_for_app(mock_app) + + assert presenter.in_focus_mode(mock_app) is False + assert presenter.focus_mode_is_sticky(mock_app) is False + + def test_per_app_state_isolation(self, test_context: CthulhuTestContext) -> None: + """Test that different apps have isolated mode state.""" + + from unittest.mock import MagicMock + + module, _mocks = self._setup_presenter(test_context) + presenter = module.get_presenter() + + app1 = MagicMock() + app2 = MagicMock() + + presenter._app_states[hash(app1)] = module._AppModeState( + in_focus_mode=True, + focus_mode_is_sticky=True, + browse_mode_is_sticky=False, + ) + presenter._app_states[hash(app2)] = module._AppModeState( + in_focus_mode=False, + focus_mode_is_sticky=False, + browse_mode_is_sticky=True, + ) + + assert presenter.in_focus_mode(app1) is True + assert presenter.focus_mode_is_sticky(app1) is True + assert presenter.browse_mode_is_sticky(app1) is False + + assert presenter.in_focus_mode(app2) is False + assert presenter.focus_mode_is_sticky(app2) is False + assert presenter.browse_mode_is_sticky(app2) is True + + def test_set_presentation_mode_not_in_document(self, test_context: CthulhuTestContext) -> None: + """Test set_presentation_mode returns False when not in document.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + mock_script = MagicMock() + mock_script.utilities.in_document_content.return_value = False + + mocks["cthulhu.messages"] = MagicMock() + mocks["cthulhu.messages"].DOCUMENT_NOT_IN_A = "Not in document" + + presenter = module.get_presenter() + pres_manager = mocks["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = presenter._set_presentation_mode(mock_script, True, obj=MagicMock()) + + assert result is False + pres_manager.present_message.assert_called_once() + + def test_set_presentation_mode_same_mode(self, test_context: CthulhuTestContext) -> None: + """Test set_presentation_mode returns False when already in requested mode.""" + + from unittest.mock import MagicMock + + module, _mocks = self._setup_presenter(test_context) + + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + mock_script.utilities.in_document_content.return_value = True + + presenter = module.get_presenter() + presenter._app_states[hash(mock_app)] = module._AppModeState( + in_focus_mode=True, + focus_mode_is_sticky=False, + browse_mode_is_sticky=False, + ) + + result = presenter._set_presentation_mode(mock_script, True, obj=MagicMock()) + + assert result is False + + def test_set_presentation_mode_to_browse(self, test_context: CthulhuTestContext) -> None: + """Test set_presentation_mode switches to browse mode.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + mock_script.utilities.in_document_content.return_value = True + mock_script.utilities.get_caret_context.return_value = (MagicMock(), 0) + + struct_nav = mocks["cthulhu.structural_navigator"] + caret_nav = mocks["cthulhu.caret_navigator"] + + presenter = module.get_presenter() + presenter._app_states[hash(mock_app)] = module._AppModeState( + in_focus_mode=True, + focus_mode_is_sticky=False, + browse_mode_is_sticky=False, + ) + + result = presenter._set_presentation_mode(mock_script, False, obj=MagicMock()) + + assert result is True + assert presenter.in_focus_mode(mock_app) is False + struct_nav.get_navigator.return_value.set_mode.assert_called() + caret_nav.get_navigator.return_value.set_enabled_for_script.assert_called() + + def test_set_presentation_mode_to_focus(self, test_context: CthulhuTestContext) -> None: + """Test set_presentation_mode switches to focus mode.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + mock_script.utilities.in_document_content.return_value = True + mock_script.utilities.get_caret_context.return_value = (MagicMock(), 0) + + caret_nav = mocks["cthulhu.caret_navigator"] + nav_mock = caret_nav.get_navigator.return_value + nav_mock.last_input_event_was_navigation_command.return_value = False + struct_nav = mocks["cthulhu.structural_navigator"] + nav_mock = struct_nav.get_navigator.return_value + nav_mock.last_input_event_was_navigation_command.return_value = False + table_nav = mocks["cthulhu.table_navigator"] + nav_mock = table_nav.get_navigator.return_value + nav_mock.last_input_event_was_navigation_command.return_value = False + + presenter = module.get_presenter() + presenter._app_states[hash(mock_app)] = module._AppModeState( + in_focus_mode=False, + focus_mode_is_sticky=False, + browse_mode_is_sticky=False, + ) + + result = presenter._set_presentation_mode(mock_script, True, obj=MagicMock()) + + assert result is True + assert presenter.in_focus_mode(mock_app) is True + + def test_set_presentation_mode_dead_obj_fallback(self, test_context: CthulhuTestContext) -> None: + """Test set_presentation_mode uses locus of focus when obj is dead.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + mock_script.utilities.in_document_content.return_value = True + mock_script.utilities.get_caret_context.return_value = (MagicMock(), 0) + + caret_nav = mocks["cthulhu.caret_navigator"] + nav_mock = caret_nav.get_navigator.return_value + nav_mock.last_input_event_was_navigation_command.return_value = False + struct_nav = mocks["cthulhu.structural_navigator"] + nav_mock = struct_nav.get_navigator.return_value + nav_mock.last_input_event_was_navigation_command.return_value = False + table_nav = mocks["cthulhu.table_navigator"] + nav_mock = table_nav.get_navigator.return_value + nav_mock.last_input_event_was_navigation_command.return_value = False + + ax_object = mocks["cthulhu.ax_object"] + ax_object.AXObject.is_dead.return_value = True + + presenter = module.get_presenter() + presenter._app_states[hash(mock_app)] = module._AppModeState( + in_focus_mode=False, + focus_mode_is_sticky=False, + browse_mode_is_sticky=False, + ) + + dead_obj = MagicMock() + result = presenter._set_presentation_mode(mock_script, True, obj=dead_obj) + + assert result is True + mock_script.utilities.in_document_content.assert_called_with(None) + + def test_suspend_navigators(self, test_context: CthulhuTestContext) -> None: + """Test suspend_navigators suspends all navigators.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + + caret_nav = mocks["cthulhu.caret_navigator"] + struct_nav = mocks["cthulhu.structural_navigator"] + + presenter = module.get_presenter() + result = presenter.suspend_navigators(mock_script, True, "test") + + assert result is True + caret_nav.get_navigator.return_value.suspend_commands.assert_called_with( + mock_script, + True, + "test", + ) + struct_nav.get_navigator.return_value.suspend_commands.assert_called_with( + mock_script, + True, + "test", + ) + + def test_enable_sticky_focus_mode(self, test_context: CthulhuTestContext) -> None: + """Test enable_sticky_focus_mode sets sticky focus mode.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + + presenter = module.get_presenter() + pres_manager = mocks["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = presenter.enable_sticky_focus_mode(mock_script) + + assert result is True + assert presenter.in_focus_mode(mock_app) is True + assert presenter.focus_mode_is_sticky(mock_app) is True + assert presenter.browse_mode_is_sticky(mock_app) is False + pres_manager.present_message.assert_called() + + def test_enable_sticky_browse_mode(self, test_context: CthulhuTestContext) -> None: + """Test enable_sticky_browse_mode sets sticky browse mode.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + + presenter = module.get_presenter() + pres_manager = mocks["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = presenter.enable_sticky_browse_mode(mock_script) + + assert result is True + assert presenter.in_focus_mode(mock_app) is False + assert presenter.focus_mode_is_sticky(mock_app) is False + assert presenter.browse_mode_is_sticky(mock_app) is True + pres_manager.present_message.assert_called() + + def test_toggle_presentation_mode_not_in_document(self, test_context: CthulhuTestContext) -> None: + """Test toggle_presentation_mode when not in document.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + mock_script = MagicMock() + mock_script.utilities.in_document_content.return_value = False + + presenter = module.get_presenter() + pres_manager = mocks["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = presenter.toggle_presentation_mode(mock_script) + + assert result is True + pres_manager.present_message.assert_called() + + def test_toggle_presentation_mode_to_focus(self, test_context: CthulhuTestContext) -> None: + """Test toggle_presentation_mode switches to focus mode.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + mock_script.utilities.in_document_content.return_value = True + mock_script.utilities.get_caret_context.return_value = (MagicMock(), 0) + + caret_nav = mocks["cthulhu.caret_navigator"] + nav_mock = caret_nav.get_navigator.return_value + nav_mock.last_input_event_was_navigation_command.return_value = False + struct_nav = mocks["cthulhu.structural_navigator"] + nav_mock = struct_nav.get_navigator.return_value + nav_mock.last_input_event_was_navigation_command.return_value = False + table_nav = mocks["cthulhu.table_navigator"] + nav_mock = table_nav.get_navigator.return_value + nav_mock.last_input_event_was_navigation_command.return_value = False + + presenter = module.get_presenter() + presenter._app_states[hash(mock_app)] = module._AppModeState( + in_focus_mode=False, + focus_mode_is_sticky=False, + browse_mode_is_sticky=False, + ) + + result = presenter.toggle_presentation_mode(mock_script) + + assert result is True + assert presenter.in_focus_mode(mock_app) is True + + def test_restore_mode_for_script_no_app(self, test_context: CthulhuTestContext) -> None: + """Test restore_mode_for_script with no app does nothing.""" + + from unittest.mock import MagicMock + + module, _mocks = self._setup_presenter(test_context) + + mock_script = MagicMock() + mock_script.app = None + + presenter = module.get_presenter() + presenter.restore_mode_for_script(mock_script) + + def test_restore_mode_for_script_no_state(self, test_context: CthulhuTestContext) -> None: + """Test restore_mode_for_script with no existing state does nothing.""" + + from unittest.mock import MagicMock + + module, _mocks = self._setup_presenter(test_context) + + mock_script = MagicMock() + mock_script.app = MagicMock() + + presenter = module.get_presenter() + presenter.restore_mode_for_script(mock_script) + + def test_restore_mode_for_script_with_state(self, test_context: CthulhuTestContext) -> None: + """Test restore_mode_for_script restores navigator suspension state.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + + caret_nav = mocks["cthulhu.caret_navigator"] + + presenter = module.get_presenter() + presenter._app_states[hash(mock_app)] = module._AppModeState( + in_focus_mode=True, + focus_mode_is_sticky=False, + browse_mode_is_sticky=False, + ) + + presenter.restore_mode_for_script(mock_script) + + caret_nav.get_navigator.return_value.suspend_commands.assert_called() + + def test_restore_mode_for_script_browse_mode_enables_navigators( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test restore_mode_for_script re-enables navigators when restoring browse mode.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + + struct_nav = mocks["cthulhu.structural_navigator"] + caret_nav = mocks["cthulhu.caret_navigator"] + + presenter = module.get_presenter() + presenter._app_states[hash(mock_app)] = module._AppModeState( + in_focus_mode=False, + focus_mode_is_sticky=False, + browse_mode_is_sticky=False, + ) + + presenter.restore_mode_for_script(mock_script) + + struct_nav.get_navigator.return_value.set_mode.assert_called_once() + caret_nav.get_navigator.return_value.set_enabled_for_script.assert_called_once_with( + mock_script, + True, + ) + + def test_update_mode_if_needed_not_in_doc(self, test_context: CthulhuTestContext) -> None: + """Test update_mode_if_needed when neither in document suspends navigators.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + mock_script = MagicMock() + mock_script.utilities.get_top_level_document_for_object.return_value = None + + caret_nav = mocks["cthulhu.caret_navigator"] + + presenter = module.get_presenter() + result = presenter.update_mode_if_needed(mock_script, MagicMock(), MagicMock()) + + # When focus is not in a document, navigators should be suspended + assert result is True + caret_nav.get_navigator.return_value.suspend_commands.assert_called() + + def test_update_mode_if_needed_leaving_doc(self, test_context: CthulhuTestContext) -> None: + """Test update_mode_if_needed when leaving document.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + mock_doc = MagicMock() + mock_script.utilities.get_top_level_document_for_object.side_effect = [mock_doc, None] + + caret_nav = mocks["cthulhu.caret_navigator"] + + presenter = module.get_presenter() + old_focus = MagicMock() + new_focus = MagicMock() + result = presenter.update_mode_if_needed(mock_script, old_focus, new_focus) + + assert result is True + caret_nav.get_navigator.return_value.suspend_commands.assert_called() + + def test_update_mode_if_needed_sticky_focus(self, test_context: CthulhuTestContext) -> None: + """Test update_mode_if_needed with sticky focus mode when entering document.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + mock_doc = MagicMock() + mock_script.utilities.get_top_level_document_for_object.side_effect = [None, mock_doc] + + focus_manager = mocks["cthulhu.focus_manager"] + focus_manager.get_manager.return_value.old_focus_was_dead.return_value = False + + presenter = module.get_presenter() + presenter._app_states[hash(mock_app)] = module._AppModeState( + in_focus_mode=True, + focus_mode_is_sticky=True, + browse_mode_is_sticky=False, + ) + + pres_manager = mocks["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = presenter.update_mode_if_needed(mock_script, MagicMock(), MagicMock()) + + assert result is True + pres_manager.present_message.assert_called() + + def test_update_mode_if_needed_sticky_browse(self, test_context: CthulhuTestContext) -> None: + """Test update_mode_if_needed with sticky browse mode when entering document.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + mock_doc = MagicMock() + mock_script.utilities.get_top_level_document_for_object.side_effect = [None, mock_doc] + + focus_manager = mocks["cthulhu.focus_manager"] + focus_manager.get_manager.return_value.old_focus_was_dead.return_value = False + + struct_nav = mocks["cthulhu.structural_navigator"] + caret_nav = mocks["cthulhu.caret_navigator"] + + presenter = module.get_presenter() + presenter._app_states[hash(mock_app)] = module._AppModeState( + in_focus_mode=False, + focus_mode_is_sticky=False, + browse_mode_is_sticky=True, + ) + + pres_manager = mocks["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = presenter.update_mode_if_needed(mock_script, MagicMock(), MagicMock()) + + assert result is True + pres_manager.present_message.assert_called() + struct_nav.get_navigator.return_value.set_mode.assert_called() + caret_nav.get_navigator.return_value.set_enabled_for_script.assert_called() + + def test_handle_entering_document_refreshes_commands( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test entering document refreshes commands even when mode unchanged.""" + + from unittest.mock import MagicMock, patch + + module, mocks = self._setup_presenter(test_context) + + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + mock_doc = MagicMock() + mock_script.utilities.get_top_level_document_for_object.side_effect = [None, mock_doc] + + caret_nav = mocks["cthulhu.caret_navigator"] + nav_mock = caret_nav.get_navigator.return_value + nav_mock.last_input_event_was_navigation_command.return_value = False + caret_nav.get_navigator.return_value.last_command_prevents_focus_mode.return_value = False + struct_nav = mocks["cthulhu.structural_navigator"] + nav_mock = struct_nav.get_navigator.return_value + nav_mock.last_input_event_was_navigation_command.return_value = False + struct_nav.get_navigator.return_value.last_command_prevents_focus_mode.return_value = False + table_nav = mocks["cthulhu.table_navigator"] + nav_mock = table_nav.get_navigator.return_value + nav_mock.last_input_event_was_navigation_command.return_value = False + focus_manager = mocks["cthulhu.focus_manager"] + focus_manager.get_manager.return_value.in_say_all.return_value = False + focus_manager.get_manager.return_value.old_focus_was_dead.return_value = False + + script_manager = mocks["cthulhu.script_manager"] + script_manager.get_manager.return_value.get_active_script.return_value = mock_script + + presenter = module.get_presenter() + # Already in browse mode - entering document should still refresh commands + presenter._app_states[hash(mock_app)] = module._AppModeState( + in_focus_mode=False, + focus_mode_is_sticky=False, + browse_mode_is_sticky=False, + ) + + with patch.object(presenter, "is_focus_mode_widget", return_value=False): + result = presenter.update_mode_if_needed(mock_script, MagicMock(), MagicMock()) + + assert result is True + # Verify commands were refreshed (suspend_commands called with suspended=False for browse) + caret_nav.get_navigator.return_value.suspend_commands.assert_called() + struct_nav.get_navigator.return_value.suspend_commands.assert_called() + + def test_is_likely_electron_app_true(self, test_context: CthulhuTestContext) -> None: + """Test _is_likely_electron_app returns True for Chromium toolkit non-browser.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + ax_object = mocks["cthulhu.ax_object"] + ax_object.AXObject.get_toolkit_name.return_value = "Chromium" + ax_object.AXObject.get_name.return_value = "code" + + mock_app = MagicMock() + presenter = module.get_presenter() + result = presenter._is_likely_electron_app(mock_app) + + assert result is True + + def test_is_likely_electron_app_false_for_browser(self, test_context: CthulhuTestContext) -> None: + """Test _is_likely_electron_app returns False for known browsers.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + ax_object = mocks["cthulhu.ax_object"] + ax_object.AXObject.get_toolkit_name.return_value = "Chromium" + ax_object.AXObject.get_name.return_value = "google-chrome" + + mock_app = MagicMock() + presenter = module.get_presenter() + result = presenter._is_likely_electron_app(mock_app) + + assert result is False + + def test_is_likely_electron_app_false_for_non_chromium( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test _is_likely_electron_app returns False for non-Chromium toolkit.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + ax_object = mocks["cthulhu.ax_object"] + ax_object.AXObject.get_toolkit_name.return_value = "gtk" + ax_object.AXObject.get_name.return_value = "gedit" + + mock_app = MagicMock() + presenter = module.get_presenter() + result = presenter._is_likely_electron_app(mock_app) + + assert result is False + + def test_update_mode_electron_app_sticky_focus(self, test_context: CthulhuTestContext) -> None: + """Test update_mode_if_needed enables sticky focus for Electron apps when entering.""" + + from unittest.mock import MagicMock, patch + + module, mocks = self._setup_presenter(test_context) + + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + mock_doc = MagicMock() + mock_script.utilities.get_top_level_document_for_object.side_effect = [None, mock_doc] + mock_script.utilities.in_document_content.return_value = True + + focus_manager = mocks["cthulhu.focus_manager"] + focus_manager.get_manager.return_value.old_focus_was_dead.return_value = False + + presenter = module.get_presenter() + + with patch.object(presenter, "_is_likely_electron_app", return_value=True): + result = presenter.update_mode_if_needed(mock_script, MagicMock(), MagicMock()) + + assert result is True + assert presenter.in_focus_mode(mock_app) is True + assert presenter.focus_mode_is_sticky(mock_app) is True + + def test_is_top_level_web_app_true(self, test_context: CthulhuTestContext) -> None: + """Test _is_top_level_web_app returns True for embedded doc with http URI.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + mock_doc = MagicMock() + mock_script = MagicMock() + mock_script.utilities.active_document.return_value = mock_doc + + ax_utilities = mocks["cthulhu.ax_utilities"] + ax_utilities.AXUtilities.is_embedded.return_value = True + + ax_document = mocks["cthulhu.ax_document"] + ax_document.AXDocument.get_uri.return_value = "https://docs.google.com/document" + + presenter = module.get_presenter() + + result = presenter._is_top_level_web_app(mock_script, MagicMock()) + + assert result is True + + def test_is_top_level_web_app_false_no_http(self, test_context: CthulhuTestContext) -> None: + """Test _is_top_level_web_app returns False for non-http URI.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + mock_doc = MagicMock() + mock_script = MagicMock() + mock_script.utilities.active_document.return_value = mock_doc + + ax_utilities = mocks["cthulhu.ax_utilities"] + ax_utilities.AXUtilities.is_embedded.return_value = True + + ax_document = mocks["cthulhu.ax_document"] + ax_document.AXDocument.get_uri.return_value = "file:///home/user/doc.html" + + presenter = module.get_presenter() + + result = presenter._is_top_level_web_app(mock_script, MagicMock()) + + assert result is False + + def test_is_top_level_web_app_false_no_document(self, test_context: CthulhuTestContext) -> None: + """Test _is_top_level_web_app returns False when no active document.""" + + from unittest.mock import MagicMock + + module, _mocks = self._setup_presenter(test_context) + + mock_script = MagicMock() + mock_script.utilities.active_document.return_value = None + + presenter = module.get_presenter() + + result = presenter._is_top_level_web_app(mock_script, MagicMock()) + + assert result is False + + def test_is_top_level_web_app_false_not_embedded(self, test_context: CthulhuTestContext) -> None: + """Test _is_top_level_web_app returns False when document is not embedded role.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + mock_doc = MagicMock() + mock_script = MagicMock() + mock_script.utilities.active_document.return_value = mock_doc + + ax_utilities = mocks["cthulhu.ax_utilities"] + ax_utilities.AXUtilities.is_embedded.return_value = False + + presenter = module.get_presenter() + + result = presenter._is_top_level_web_app(mock_script, MagicMock()) + + assert result is False + + def test_update_mode_top_level_web_app_sticky_focus( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test update_mode_if_needed enables sticky focus for top-level web apps when entering.""" + + from unittest.mock import MagicMock, patch + + module, mocks = self._setup_presenter(test_context) + + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + mock_doc = MagicMock() + mock_script.utilities.get_top_level_document_for_object.side_effect = [None, mock_doc] + mock_script.utilities.in_document_content.return_value = True + + focus_manager = mocks["cthulhu.focus_manager"] + focus_manager.get_manager.return_value.old_focus_was_dead.return_value = False + + presenter = module.get_presenter() + + with ( + patch.object(presenter, "_is_likely_electron_app", return_value=False), + patch.object(presenter, "_is_top_level_web_app", return_value=True), + ): + result = presenter.update_mode_if_needed(mock_script, MagicMock(), MagicMock()) + + assert result is True + assert presenter.in_focus_mode(mock_app) is True + assert presenter.focus_mode_is_sticky(mock_app) is True + + def test_is_focus_mode_widget_editable(self, test_context: CthulhuTestContext) -> None: + """Test is_focus_mode_widget returns True for editable objects.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + ax_utilities = mocks["cthulhu.ax_utilities"] + ax_utilities.AXUtilities.is_editable.return_value = True + + mock_script = MagicMock() + mock_obj = MagicMock() + + presenter = module.get_presenter() + result = presenter.is_focus_mode_widget(mock_script, mock_obj) + + assert result is True + + def test_is_focus_mode_widget_combo_box(self, test_context: CthulhuTestContext) -> None: + """Test is_focus_mode_widget returns True for combo boxes.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + ax_utilities = mocks["cthulhu.ax_utilities"] + ax_utilities.AXUtilities.is_editable.return_value = False + ax_utilities.AXUtilities.is_combo_box.return_value = True + + mock_script = MagicMock() + mock_obj = MagicMock() + + presenter = module.get_presenter() + result = presenter.is_focus_mode_widget(mock_script, mock_obj) + + assert result is True + + def test_is_focus_mode_widget_expandable_focusable(self, test_context: CthulhuTestContext) -> None: + """Test is_focus_mode_widget returns True for expandable focusable non-links.""" + + from unittest.mock import MagicMock + + module, mocks = self._setup_presenter(test_context) + + ax_utilities = mocks["cthulhu.ax_utilities"] + ax_utilities.AXUtilities.is_editable.return_value = False + ax_utilities.AXUtilities.is_expandable.return_value = True + ax_utilities.AXUtilities.is_focusable.return_value = True + ax_utilities.AXUtilities.is_link.return_value = False + + mock_script = MagicMock() + mock_obj = MagicMock() + + presenter = module.get_presenter() + result = presenter.is_focus_mode_widget(mock_script, mock_obj) + + assert result is True + + def test_is_focus_mode_widget_expandable_link_false( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test is_focus_mode_widget returns False for expandable focusable links.""" + + from unittest.mock import MagicMock + + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + + module, mocks = self._setup_presenter(test_context) + + ax_utilities = mocks["cthulhu.ax_utilities"] + ax_utilities.AXUtilities.is_editable.return_value = False + ax_utilities.AXUtilities.is_expandable.return_value = True + ax_utilities.AXUtilities.is_focusable.return_value = True + ax_utilities.AXUtilities.is_link.return_value = True + ax_utilities.AXUtilities.is_list_box_item.return_value = False + ax_utilities.AXUtilities.is_button_with_popup.return_value = False + ax_utilities.AXUtilities.is_grid = MagicMock() + ax_utilities.AXUtilities.is_menu = MagicMock() + ax_utilities.AXUtilities.is_tool_bar = MagicMock() + + ax_object = mocks["cthulhu.ax_object"] + ax_object.AXObject.get_role.return_value = Atspi.Role.LINK + ax_utilities.AXUtilities.find_ancestor.return_value = None + + mock_script = MagicMock() + mock_script.utilities.is_content_editable_with_embedded_objects.return_value = False + mock_obj = MagicMock() + + presenter = module.get_presenter() + result = presenter.is_focus_mode_widget(mock_script, mock_obj) + + assert result is False + + def test_is_focus_mode_widget_always_focus_role(self, test_context: CthulhuTestContext) -> None: + """Test is_focus_mode_widget returns True for always-focus-mode roles.""" + + from unittest.mock import MagicMock + + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + + module, mocks = self._setup_presenter(test_context) + + ax_utilities = mocks["cthulhu.ax_utilities"] + ax_utilities.AXUtilities.is_editable.return_value = False + ax_utilities.AXUtilities.is_expandable.return_value = False + + ax_object = mocks["cthulhu.ax_object"] + ax_object.AXObject.get_role.return_value = Atspi.Role.SLIDER + + mock_script = MagicMock() + mock_obj = MagicMock() + + presenter = module.get_presenter() + result = presenter.is_focus_mode_widget(mock_script, mock_obj) + + assert result is True + + def test_is_focus_mode_widget_layout_table_false(self, test_context: CthulhuTestContext) -> None: + """Test is_focus_mode_widget returns False for layout tables.""" + + from unittest.mock import MagicMock + + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + + module, mocks = self._setup_presenter(test_context) + + ax_utilities = mocks["cthulhu.ax_utilities"] + ax_utilities.AXUtilities.is_editable.return_value = False + ax_utilities.AXUtilities.is_expandable.return_value = False + + ax_object = mocks["cthulhu.ax_object"] + ax_object.AXObject.get_role.return_value = Atspi.Role.TABLE_CELL + + ax_utilities.AXUtilities.get_table.return_value = MagicMock() + ax_utilities.AXUtilities.is_layout_table.return_value = True + + mock_script = MagicMock() + mock_obj = MagicMock() + + presenter = module.get_presenter() + result = presenter.is_focus_mode_widget(mock_script, mock_obj) + + assert result is False + + def test_is_focus_mode_widget_list_box_item(self, test_context: CthulhuTestContext) -> None: + """Test is_focus_mode_widget returns True for list box items.""" + + from unittest.mock import MagicMock + + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + + module, mocks = self._setup_presenter(test_context) + + ax_utilities = mocks["cthulhu.ax_utilities"] + ax_utilities.AXUtilities.is_editable.return_value = False + ax_utilities.AXUtilities.is_expandable.return_value = False + ax_utilities.AXUtilities.is_list_box_item.return_value = True + + ax_object = mocks["cthulhu.ax_object"] + ax_object.AXObject.get_role.return_value = Atspi.Role.LIST_ITEM + + ax_utilities.AXUtilities.is_layout_table.return_value = False + + mock_script = MagicMock() + mock_obj = MagicMock() + + presenter = module.get_presenter() + result = presenter.is_focus_mode_widget(mock_script, mock_obj) + + assert result is True + + def test_is_focus_mode_widget_button_with_popup(self, test_context: CthulhuTestContext) -> None: + """Test is_focus_mode_widget returns True for buttons with popup.""" + + from unittest.mock import MagicMock + + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + + module, mocks = self._setup_presenter(test_context) + + ax_utilities = mocks["cthulhu.ax_utilities"] + ax_utilities.AXUtilities.is_editable.return_value = False + ax_utilities.AXUtilities.is_expandable.return_value = False + ax_utilities.AXUtilities.is_list_box_item.return_value = False + ax_utilities.AXUtilities.is_button_with_popup.return_value = True + + ax_object = mocks["cthulhu.ax_object"] + ax_object.AXObject.get_role.return_value = Atspi.Role.BUTTON + + ax_utilities.AXUtilities.is_layout_table.return_value = False + + mock_script = MagicMock() + mock_obj = MagicMock() + + presenter = module.get_presenter() + result = presenter.is_focus_mode_widget(mock_script, mock_obj) + + assert result is True + + def test_is_focus_mode_widget_table_cell_not_layout( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test is_focus_mode_widget returns True for non-layout table cells.""" + + from unittest.mock import MagicMock + + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + + module, mocks = self._setup_presenter(test_context) + + ax_utilities = mocks["cthulhu.ax_utilities"] + ax_utilities.AXUtilities.is_editable.return_value = False + ax_utilities.AXUtilities.is_expandable.return_value = False + ax_utilities.AXUtilities.is_list_box_item.return_value = False + ax_utilities.AXUtilities.is_button_with_popup.return_value = False + + ax_object = mocks["cthulhu.ax_object"] + ax_object.AXObject.get_role.return_value = Atspi.Role.TABLE_CELL + + ax_utilities.AXUtilities.get_table.return_value = MagicMock() + ax_utilities.AXUtilities.is_layout_table.return_value = False + + ax_document = mocks["cthulhu.ax_document"] + ax_document.AXDocument.is_pdf.return_value = False + + mock_script = MagicMock() + mock_script.utilities.is_text_block_element.return_value = False + mock_script.utilities.has_name_and_action_and_no_useful_children.return_value = False + mock_obj = MagicMock() + + presenter = module.get_presenter() + result = presenter.is_focus_mode_widget(mock_script, mock_obj) + + assert result is True + + def test_is_focus_mode_widget_grid_descendant(self, test_context: CthulhuTestContext) -> None: + """Test is_focus_mode_widget returns True for grid descendants.""" + + from unittest.mock import MagicMock + + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + + module, mocks = self._setup_presenter(test_context) + + ax_utilities = mocks["cthulhu.ax_utilities"] + ax_utilities.AXUtilities.is_editable.return_value = False + ax_utilities.AXUtilities.is_expandable.return_value = False + ax_utilities.AXUtilities.is_list_box_item.return_value = False + ax_utilities.AXUtilities.is_button_with_popup.return_value = False + ax_utilities.AXUtilities.is_grid = MagicMock() + + ax_object = mocks["cthulhu.ax_object"] + ax_object.AXObject.get_role.return_value = Atspi.Role.PARAGRAPH + ax_utilities.AXUtilities.find_ancestor.return_value = MagicMock() + + ax_utilities.AXUtilities.is_layout_table.return_value = False + + mock_script = MagicMock() + mock_script.utilities.is_text_block_element.return_value = True + mock_obj = MagicMock() + + presenter = module.get_presenter() + result = presenter.is_focus_mode_widget(mock_script, mock_obj) + + assert result is True + + def test_is_focus_mode_widget_menu_descendant(self, test_context: CthulhuTestContext) -> None: + """Test is_focus_mode_widget returns True for menu descendants.""" + + from unittest.mock import MagicMock + + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + + module, mocks = self._setup_presenter(test_context) + + ax_utilities = mocks["cthulhu.ax_utilities"] + ax_utilities.AXUtilities.is_editable.return_value = False + ax_utilities.AXUtilities.is_expandable.return_value = False + ax_utilities.AXUtilities.is_list_box_item.return_value = False + ax_utilities.AXUtilities.is_button_with_popup.return_value = False + ax_utilities.AXUtilities.is_grid = MagicMock() + ax_utilities.AXUtilities.is_menu = MagicMock() + + ax_object = mocks["cthulhu.ax_object"] + ax_object.AXObject.get_role.return_value = Atspi.Role.PARAGRAPH + ax_utilities.AXUtilities.find_ancestor.side_effect = [None, MagicMock()] + + ax_utilities.AXUtilities.is_layout_table.return_value = False + + mock_script = MagicMock() + mock_script.utilities.is_text_block_element.return_value = True + mock_obj = MagicMock() + + presenter = module.get_presenter() + result = presenter.is_focus_mode_widget(mock_script, mock_obj) + + assert result is True + + def test_is_focus_mode_widget_toolbar_descendant(self, test_context: CthulhuTestContext) -> None: + """Test is_focus_mode_widget returns True for toolbar descendants.""" + + from unittest.mock import MagicMock + + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + + module, mocks = self._setup_presenter(test_context) + + ax_utilities = mocks["cthulhu.ax_utilities"] + ax_utilities.AXUtilities.is_editable.return_value = False + ax_utilities.AXUtilities.is_expandable.return_value = False + ax_utilities.AXUtilities.is_list_box_item.return_value = False + ax_utilities.AXUtilities.is_button_with_popup.return_value = False + ax_utilities.AXUtilities.is_grid = MagicMock() + ax_utilities.AXUtilities.is_menu = MagicMock() + ax_utilities.AXUtilities.is_tool_bar = MagicMock() + + ax_object = mocks["cthulhu.ax_object"] + ax_object.AXObject.get_role.return_value = Atspi.Role.PARAGRAPH + ax_utilities.AXUtilities.find_ancestor.side_effect = [None, None, MagicMock()] + + ax_utilities.AXUtilities.is_layout_table.return_value = False + + mock_script = MagicMock() + mock_script.utilities.is_text_block_element.return_value = True + mock_obj = MagicMock() + + presenter = module.get_presenter() + result = presenter.is_focus_mode_widget(mock_script, mock_obj) + + assert result is True + + def test_is_focus_mode_widget_content_editable(self, test_context: CthulhuTestContext) -> None: + """Test is_focus_mode_widget returns True for content editable with embedded.""" + + from unittest.mock import MagicMock + + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + + module, mocks = self._setup_presenter(test_context) + + ax_utilities = mocks["cthulhu.ax_utilities"] + ax_utilities.AXUtilities.is_editable.return_value = False + ax_utilities.AXUtilities.is_expandable.return_value = False + ax_utilities.AXUtilities.is_list_box_item.return_value = False + ax_utilities.AXUtilities.is_button_with_popup.return_value = False + ax_utilities.AXUtilities.is_grid = MagicMock() + ax_utilities.AXUtilities.is_menu = MagicMock() + ax_utilities.AXUtilities.is_tool_bar = MagicMock() + + ax_object = mocks["cthulhu.ax_object"] + ax_object.AXObject.get_role.return_value = Atspi.Role.PARAGRAPH + ax_utilities.AXUtilities.find_ancestor.return_value = None + + ax_utilities.AXUtilities.is_layout_table.return_value = False + + mock_script = MagicMock() + mock_script.utilities.is_text_block_element.return_value = True + mock_script.utilities.is_content_editable_with_embedded_objects.return_value = True + mock_obj = MagicMock() + + presenter = module.get_presenter() + result = presenter.is_focus_mode_widget(mock_script, mock_obj) + + assert result is True + + def test_is_focus_mode_widget_default_false(self, test_context: CthulhuTestContext) -> None: + """Test is_focus_mode_widget returns False by default.""" + + from unittest.mock import MagicMock + + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + + module, mocks = self._setup_presenter(test_context) + + ax_utilities = mocks["cthulhu.ax_utilities"] + ax_utilities.AXUtilities.is_editable.return_value = False + ax_utilities.AXUtilities.is_expandable.return_value = False + ax_utilities.AXUtilities.is_list_box_item.return_value = False + ax_utilities.AXUtilities.is_button_with_popup.return_value = False + ax_utilities.AXUtilities.is_grid = MagicMock() + ax_utilities.AXUtilities.is_menu = MagicMock() + ax_utilities.AXUtilities.is_tool_bar = MagicMock() + + ax_object = mocks["cthulhu.ax_object"] + ax_object.AXObject.get_role.return_value = Atspi.Role.PARAGRAPH + ax_utilities.AXUtilities.find_ancestor.return_value = None + + ax_utilities.AXUtilities.is_layout_table.return_value = False + + mock_script = MagicMock() + mock_script.utilities.is_text_block_element.return_value = True + mock_script.utilities.is_content_editable_with_embedded_objects.return_value = False + mock_obj = MagicMock() + + presenter = module.get_presenter() + result = presenter.is_focus_mode_widget(mock_script, mock_obj) + + assert result is False + + def test_is_focus_mode_widget_pdf_table_false(self, test_context: CthulhuTestContext) -> None: + """Test is_focus_mode_widget returns False for table cells in PDFs.""" + + from unittest.mock import MagicMock + + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + + module, mocks = self._setup_presenter(test_context) + + ax_utilities = mocks["cthulhu.ax_utilities"] + ax_utilities.AXUtilities.is_editable.return_value = False + ax_utilities.AXUtilities.is_expandable.return_value = False + ax_utilities.AXUtilities.is_list_box_item.return_value = False + ax_utilities.AXUtilities.is_button_with_popup.return_value = False + ax_utilities.AXUtilities.is_grid = MagicMock() + ax_utilities.AXUtilities.is_menu = MagicMock() + ax_utilities.AXUtilities.is_tool_bar = MagicMock() + + ax_object = mocks["cthulhu.ax_object"] + ax_object.AXObject.get_role.return_value = Atspi.Role.TABLE_CELL + ax_utilities.AXUtilities.find_ancestor.return_value = None + + ax_utilities.AXUtilities.get_table.return_value = MagicMock() + ax_utilities.AXUtilities.is_layout_table.return_value = False + + ax_document = mocks["cthulhu.ax_document"] + ax_document.AXDocument.is_pdf.return_value = True + + mock_script = MagicMock() + mock_script.utilities.is_text_block_element.return_value = False + mock_script.utilities.has_name_and_action_and_no_useful_children.return_value = False + mock_script.utilities.is_content_editable_with_embedded_objects.return_value = False + mock_obj = MagicMock() + + presenter = module.get_presenter() + result = presenter.is_focus_mode_widget(mock_script, mock_obj) + + assert result is False + + def test_get_in_focus_mode_no_script(self, test_context: CthulhuTestContext) -> None: + """Test get_in_focus_mode returns False when no active script.""" + + module, mocks = self._setup_presenter(test_context) + + script_manager = mocks["cthulhu.script_manager"] + script_manager.get_manager.return_value.get_active_script.return_value = None + + presenter = module.get_presenter() + result = presenter.get_in_focus_mode() + + assert result is False + + def test_get_focus_mode_is_sticky_no_script(self, test_context: CthulhuTestContext) -> None: + """Test get_focus_mode_is_sticky returns False when no active script.""" + + module, mocks = self._setup_presenter(test_context) + + script_manager = mocks["cthulhu.script_manager"] + script_manager.get_manager.return_value.get_active_script.return_value = None + + presenter = module.get_presenter() + result = presenter.get_focus_mode_is_sticky() + + assert result is False + + def test_get_browse_mode_is_sticky_no_script(self, test_context: CthulhuTestContext) -> None: + """Test get_browse_mode_is_sticky returns False when no active script.""" + + module, mocks = self._setup_presenter(test_context) + + script_manager = mocks["cthulhu.script_manager"] + script_manager.get_manager.return_value.get_active_script.return_value = None + + presenter = module.get_presenter() + result = presenter.get_browse_mode_is_sticky() + + assert result is False + + def test_has_state_for_app(self, test_context: CthulhuTestContext) -> None: + """Test has_state_for_app returns correct value.""" + + from unittest.mock import MagicMock + + module, _mocks = self._setup_presenter(test_context) + + mock_app = MagicMock() + presenter = module.get_presenter() + + assert presenter.has_state_for_app(mock_app) is False + + presenter._app_states[hash(mock_app)] = module._AppModeState() + + assert presenter.has_state_for_app(mock_app) is True + + def test_update_mode_if_needed_within_doc(self, test_context: CthulhuTestContext) -> None: + """Test update_mode_if_needed when moving within document.""" + + from unittest.mock import MagicMock, patch + + module, mocks = self._setup_presenter(test_context) + + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + mock_doc = MagicMock() + mock_script.utilities.get_top_level_document_for_object.return_value = mock_doc + mock_script.utilities.in_document_content.return_value = True + mock_script.utilities.get_caret_context.return_value = (MagicMock(), 0) + + caret_nav = mocks["cthulhu.caret_navigator"] + nav_mock = caret_nav.get_navigator.return_value + nav_mock.last_input_event_was_navigation_command.return_value = False + caret_nav.get_navigator.return_value.last_command_prevents_focus_mode.return_value = False + struct_nav = mocks["cthulhu.structural_navigator"] + nav_mock = struct_nav.get_navigator.return_value + nav_mock.last_input_event_was_navigation_command.return_value = False + struct_nav.get_navigator.return_value.last_command_prevents_focus_mode.return_value = False + table_nav = mocks["cthulhu.table_navigator"] + nav_mock = table_nav.get_navigator.return_value + nav_mock.last_input_event_was_navigation_command.return_value = False + focus_manager = mocks["cthulhu.focus_manager"] + focus_manager.get_manager.return_value.in_say_all.return_value = False + + script_manager = mocks["cthulhu.script_manager"] + script_manager.get_manager.return_value.get_active_script.return_value = mock_script + + presenter = module.get_presenter() + presenter._app_states[hash(mock_app)] = module._AppModeState( + in_focus_mode=False, + focus_mode_is_sticky=False, + browse_mode_is_sticky=False, + ) + + with patch.object(presenter, "is_focus_mode_widget", return_value=False): + result = presenter.update_mode_if_needed(mock_script, MagicMock(), MagicMock()) + + assert result is True + + def test_update_mode_if_needed_sticky_within_doc(self, test_context: CthulhuTestContext) -> None: + """Test update_mode_if_needed within doc with sticky mode returns False.""" + + from unittest.mock import MagicMock + + module, _mocks = self._setup_presenter(test_context) + + mock_app = MagicMock() + mock_script = MagicMock() + mock_script.app = mock_app + mock_doc = MagicMock() + mock_script.utilities.get_top_level_document_for_object.return_value = mock_doc + + presenter = module.get_presenter() + presenter._app_states[hash(mock_app)] = module._AppModeState( + in_focus_mode=True, + focus_mode_is_sticky=True, + browse_mode_is_sticky=False, + ) + + result = presenter.update_mode_if_needed(mock_script, MagicMock(), MagicMock()) + + assert result is False diff --git a/tests/test_live_region_presenter.py b/tests/test_live_region_presenter.py new file mode 100644 index 0000000..1c5207d --- /dev/null +++ b/tests/test_live_region_presenter.py @@ -0,0 +1,1020 @@ +# Unit tests for live_region_presenter.py methods. +# +# Copyright 2025 Igalia, S.L. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +# pylint: disable=wrong-import-position +# pylint: disable=import-outside-toplevel +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-statements +# pylint: disable=protected-access +# pylint: disable=too-many-arguments +# pylint: disable=too-many-positional-arguments +# pylint: disable=too-many-locals +# pylint: disable=too-many-lines + +"""Unit tests for live_region_presenter.py methods.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from unittest.mock import MagicMock + + from cthulhu_test_context import CthulhuTestContext + + +class Fake: + """Stub used as a stand-in for objects that need an identity.""" + + +@pytest.mark.unit +class TestLivePoliteness: + """Test LivePoliteness enum.""" + + def _setup_dependencies(self, test_context: CthulhuTestContext) -> dict[str, MagicMock]: + """Set up dependencies for live_region_presenter module testing.""" + + additional_modules = [ + "gi", + "gi.repository", + "gi.repository.Atspi", + "gi.repository.GLib", + "cthulhu.braille_presenter", + "cthulhu.presentation_manager", + ] + essential_modules = test_context.setup_shared_dependencies(additional_modules) + + gi_mock = essential_modules["gi"] + gi_mock.require_version = test_context.Mock() + + gi_repository_mock = essential_modules["gi.repository"] + atspi_mock = essential_modules["gi.repository.Atspi"] + atspi_mock.Role = Fake + atspi_mock.Accessible = Fake + atspi_mock.MatchRule = Fake + atspi_mock.Relation = Fake + gi_repository_mock.Atspi = atspi_mock + + glib_mock = essential_modules["gi.repository.GLib"] + glib_mock.timeout_add = test_context.Mock(return_value=1) + + return essential_modules + + def test_politeness_values(self, test_context: CthulhuTestContext) -> None: + """Test LivePoliteness enum values and properties.""" + + self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import LivePoliteness + + assert LivePoliteness.ASSERTIVE.priority == 0 + assert LivePoliteness.ASSERTIVE.name_str == "assertive" + assert LivePoliteness.POLITE.priority == 1 + assert LivePoliteness.POLITE.name_str == "polite" + assert LivePoliteness.OFF.priority == 2 + assert LivePoliteness.OFF.name_str == "off" + + @pytest.mark.parametrize( + "input_str,expected", + [ + pytest.param("assertive", "ASSERTIVE", id="assertive"), + pytest.param("polite", "POLITE", id="polite"), + pytest.param("off", "OFF", id="off"), + pytest.param("invalid", "OFF", id="invalid_defaults_to_off"), + pytest.param(None, "OFF", id="none_defaults_to_off"), + ], + ) + def test_from_string(self, test_context: CthulhuTestContext, input_str, expected) -> None: + """Test LivePoliteness.from_string conversion.""" + + self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import LivePoliteness + + result = LivePoliteness.OFF.from_string(input_str) + assert result.name == expected + + @pytest.mark.parametrize( + "politeness,expected_str", + [ + pytest.param("ASSERTIVE", "assertive", id="assertive"), + pytest.param("POLITE", "polite", id="polite"), + pytest.param("OFF", "off", id="off"), + ], + ) + def test_to_string(self, test_context: CthulhuTestContext, politeness, expected_str) -> None: + """Test LivePoliteness.to_string conversion.""" + + self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import LivePoliteness + + politeness_enum = getattr(LivePoliteness, politeness) + assert politeness_enum.to_string() == expected_str + + +@pytest.mark.unit +class TestLiveRegionMessage: + """Test LiveRegionMessage class.""" + + def _setup_dependencies(self, test_context: CthulhuTestContext): + """Set up dependencies for live_region_presenter module testing.""" + + additional_modules = [ + "gi", + "gi.repository", + "gi.repository.Atspi", + "gi.repository.GLib", + "cthulhu.braille_presenter", + "cthulhu.presentation_manager", + ] + essential_modules = test_context.setup_shared_dependencies(additional_modules) + + gi_mock = essential_modules["gi"] + gi_mock.require_version = test_context.Mock() + + gi_repository_mock = essential_modules["gi.repository"] + atspi_mock = essential_modules["gi.repository.Atspi"] + atspi_mock.Role = Fake + atspi_mock.Accessible = Fake + atspi_mock.MatchRule = Fake + atspi_mock.Relation = Fake + gi_repository_mock.Atspi = atspi_mock + + glib_mock = essential_modules["gi.repository.GLib"] + glib_mock.timeout_add = test_context.Mock(return_value=1) + + return essential_modules + + def test_message_creation(self, test_context: CthulhuTestContext) -> None: + """Test LiveRegionMessage creation.""" + + self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import LivePoliteness, LiveRegionMessage + + mock_obj = test_context.Mock() + test_context.patch("time.time", return_value=1234567890.0) + + message = LiveRegionMessage( + text="Test message", + politeness=LivePoliteness.POLITE, + obj=mock_obj, + ) + + assert message.text == "Test message" + assert message.politeness == LivePoliteness.POLITE + assert message.obj == mock_obj + assert message.timestamp == 1234567890.0 + + def test_message_with_explicit_timestamp(self, test_context: CthulhuTestContext) -> None: + """Test LiveRegionMessage with explicit timestamp.""" + + self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import LivePoliteness, LiveRegionMessage + + mock_obj = test_context.Mock() + message = LiveRegionMessage( + text="Test", + politeness=LivePoliteness.ASSERTIVE, + obj=mock_obj, + timestamp=999.0, + ) + + assert message.timestamp == 999.0 + + @pytest.mark.parametrize( + "politeness1,politeness2,timestamp1,timestamp2,expected_less_than", + [ + pytest.param("ASSERTIVE", "POLITE", 100.0, 100.0, True, id="assertive_before_polite"), + pytest.param("POLITE", "ASSERTIVE", 100.0, 100.0, False, id="polite_after_assertive"), + pytest.param("POLITE", "POLITE", 100.0, 200.0, True, id="older_before_newer"), + pytest.param("POLITE", "POLITE", 200.0, 100.0, False, id="newer_after_older"), + ], + ) + def test_message_comparison( + self, + test_context: CthulhuTestContext, + politeness1, + politeness2, + timestamp1, + timestamp2, + expected_less_than, + ) -> None: + """Test LiveRegionMessage comparison for priority queue ordering.""" + + self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import LivePoliteness, LiveRegionMessage + + mock_obj = test_context.Mock() + msg1 = LiveRegionMessage( + text="Message 1", + politeness=getattr(LivePoliteness, politeness1), + obj=mock_obj, + timestamp=timestamp1, + ) + msg2 = LiveRegionMessage( + text="Message 2", + politeness=getattr(LivePoliteness, politeness2), + obj=mock_obj, + timestamp=timestamp2, + ) + + assert (msg1 < msg2) == expected_less_than + + @pytest.mark.parametrize( + "text1,text2,time_diff,expected_duplicate", + [ + pytest.param("Same", "Same", 0.1, True, id="duplicate_within_window"), + pytest.param("Same", "Same", 0.25, True, id="duplicate_at_boundary"), + pytest.param("Same", "Same", 0.26, False, id="not_duplicate_outside_window"), + pytest.param("Different", "Text", 0.1, False, id="different_text"), + ], + ) + def test_is_duplicate_of( + self, + test_context: CthulhuTestContext, + text1, + text2, + time_diff, + expected_duplicate, + ) -> None: + """Test LiveRegionMessage.is_duplicate_of.""" + + self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import LivePoliteness, LiveRegionMessage + + mock_obj = test_context.Mock() + base_time = 1000.0 + + msg1 = LiveRegionMessage( + text=text1, + politeness=LivePoliteness.POLITE, + obj=mock_obj, + timestamp=base_time, + ) + msg2 = LiveRegionMessage( + text=text2, + politeness=LivePoliteness.POLITE, + obj=mock_obj, + timestamp=base_time + time_diff, + ) + + assert msg2.is_duplicate_of(msg1) == expected_duplicate + + def test_is_duplicate_of_none(self, test_context: CthulhuTestContext) -> None: + """Test LiveRegionMessage.is_duplicate_of with None.""" + + self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import LivePoliteness, LiveRegionMessage + + mock_obj = test_context.Mock() + message = LiveRegionMessage(text="Test", politeness=LivePoliteness.POLITE, obj=mock_obj) + + assert message.is_duplicate_of(None) is False + + +@pytest.mark.unit +class TestLiveRegionMessageQueue: + """Test LiveRegionMessageQueue class.""" + + def _setup_dependencies(self, test_context: CthulhuTestContext): + """Set up dependencies for live_region_presenter module testing.""" + + additional_modules = [ + "gi", + "gi.repository", + "gi.repository.Atspi", + "gi.repository.GLib", + "cthulhu.braille_presenter", + "cthulhu.presentation_manager", + ] + essential_modules = test_context.setup_shared_dependencies(additional_modules) + + gi_mock = essential_modules["gi"] + gi_mock.require_version = test_context.Mock() + + gi_repository_mock = essential_modules["gi.repository"] + atspi_mock = essential_modules["gi.repository.Atspi"] + atspi_mock.Role = Fake + atspi_mock.Accessible = Fake + atspi_mock.MatchRule = Fake + atspi_mock.Relation = Fake + gi_repository_mock.Atspi = atspi_mock + + glib_mock = essential_modules["gi.repository.GLib"] + glib_mock.timeout_add = test_context.Mock(return_value=1) + + return essential_modules + + def test_queue_creation(self, test_context: CthulhuTestContext) -> None: + """Test LiveRegionMessageQueue creation.""" + + self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import LiveRegionMessageQueue + + queue = LiveRegionMessageQueue(max_size=5) + assert len(queue) == 0 + + def test_enqueue_dequeue(self, test_context: CthulhuTestContext) -> None: + """Test basic enqueue and dequeue operations.""" + + self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import ( + LivePoliteness, + LiveRegionMessage, + LiveRegionMessageQueue, + ) + + queue = LiveRegionMessageQueue(max_size=5) + mock_obj = test_context.Mock() + + msg = LiveRegionMessage("Test", LivePoliteness.POLITE, mock_obj, timestamp=100.0) + queue.enqueue(msg) + + assert len(queue) == 1 + dequeued = queue.dequeue() + assert dequeued == msg + assert len(queue) == 0 + + def test_priority_ordering(self, test_context: CthulhuTestContext) -> None: + """Test messages are dequeued in priority order.""" + + self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import ( + LivePoliteness, + LiveRegionMessage, + LiveRegionMessageQueue, + ) + + queue = LiveRegionMessageQueue(max_size=5) + mock_obj = test_context.Mock() + + polite_msg = LiveRegionMessage("Polite", LivePoliteness.POLITE, mock_obj, timestamp=100.0) + assertive_msg = LiveRegionMessage( + "Assertive", + LivePoliteness.ASSERTIVE, + mock_obj, + timestamp=200.0, + ) + + queue.enqueue(polite_msg) + queue.enqueue(assertive_msg) + + # Assertive should come out first + first = queue.dequeue() + assert first is not None + assert first.text == "Assertive" + second = queue.dequeue() + assert second is not None + assert second.text == "Polite" + + def test_max_size_enforcement(self, test_context: CthulhuTestContext) -> None: + """Test queue enforces max size limit.""" + + self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import ( + LivePoliteness, + LiveRegionMessage, + LiveRegionMessageQueue, + ) + + queue = LiveRegionMessageQueue(max_size=3) + mock_obj = test_context.Mock() + + for i in range(5): + msg = LiveRegionMessage( + f"Message {i}", + LivePoliteness.POLITE, + mock_obj, + timestamp=float(i), + ) + queue.enqueue(msg) + + assert len(queue) == 3 + + def test_clear(self, test_context: CthulhuTestContext) -> None: + """Test queue clear operation.""" + + self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import ( + LivePoliteness, + LiveRegionMessage, + LiveRegionMessageQueue, + ) + + queue = LiveRegionMessageQueue(max_size=5) + mock_obj = test_context.Mock() + + for i in range(3): + msg = LiveRegionMessage(f"Message {i}", LivePoliteness.POLITE, mock_obj) + queue.enqueue(msg) + + assert len(queue) == 3 + queue.clear() + assert len(queue) == 0 + + def test_dequeue_empty_returns_none(self, test_context: CthulhuTestContext) -> None: + """Test dequeue on empty queue returns None.""" + + self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import LiveRegionMessageQueue + + queue = LiveRegionMessageQueue(max_size=5) + assert queue.dequeue() is None + + def test_purge_by_keep_alive(self, test_context: CthulhuTestContext) -> None: + """Test purge_by_keep_alive removes old messages.""" + + self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import ( + LivePoliteness, + LiveRegionMessage, + LiveRegionMessageQueue, + ) + + queue = LiveRegionMessageQueue(max_size=10) + mock_obj = test_context.Mock() + + current_time = 1000.0 + test_context.patch("time.time", return_value=current_time) + + # Add old message (older than MSG_KEEPALIVE_TIME) + old_msg = LiveRegionMessage( + "Old", + LivePoliteness.POLITE, + mock_obj, + timestamp=current_time - 50, + ) + queue.enqueue(old_msg) + + # Add recent message + new_msg = LiveRegionMessage( + "New", + LivePoliteness.POLITE, + mock_obj, + timestamp=current_time - 10, + ) + queue.enqueue(new_msg) + + assert len(queue) == 2 + queue.purge_by_keep_alive() + assert len(queue) == 1 + + remaining = queue.dequeue() + assert remaining is not None + assert remaining.text == "New" + + def test_purge_by_priority(self, test_context: CthulhuTestContext) -> None: + """Test purge_by_priority removes lower priority messages.""" + + self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import ( + LivePoliteness, + LiveRegionMessage, + LiveRegionMessageQueue, + ) + + queue = LiveRegionMessageQueue(max_size=10) + mock_obj = test_context.Mock() + + assertive_msg = LiveRegionMessage( + "Assertive", + LivePoliteness.ASSERTIVE, + mock_obj, + timestamp=100.0, + ) + polite_msg = LiveRegionMessage("Polite", LivePoliteness.POLITE, mock_obj, timestamp=200.0) + off_msg = LiveRegionMessage("Off", LivePoliteness.OFF, mock_obj, timestamp=300.0) + + queue.enqueue(assertive_msg) + queue.enqueue(polite_msg) + queue.enqueue(off_msg) + + assert len(queue) == 3 + + # Purge messages with priority >= POLITE (removes POLITE and OFF, keeps ASSERTIVE) + queue.purge_by_priority(LivePoliteness.POLITE) + assert len(queue) == 1 + + remaining = queue.dequeue() + assert remaining is not None + assert remaining.text == "Assertive" + + +@pytest.mark.unit +class TestLiveRegionPresenter: + """Test LiveRegionPresenter class methods.""" + + def _setup_dependencies(self, test_context: CthulhuTestContext): + """Set up dependencies for LiveRegionPresenter testing.""" + + additional_modules = [ + "gi", + "gi.repository", + "gi.repository.Atspi", + "gi.repository.GLib", + "cthulhu.cmdnames", + "cthulhu.dbus_service", + "cthulhu.debug", + "cthulhu.focus_manager", + "cthulhu.input_event", + "cthulhu.keybindings", + "cthulhu.messages", + "cthulhu.script_manager", + "cthulhu.AXObject", + "cthulhu.AXUtilities", + "cthulhu.braille_presenter", + "cthulhu.presentation_manager", + ] + essential_modules = test_context.setup_shared_dependencies(additional_modules) + + gi_mock = essential_modules["gi"] + gi_mock.require_version = test_context.Mock() + + gi_repository_mock = essential_modules["gi.repository"] + atspi_mock = essential_modules["gi.repository.Atspi"] + atspi_mock.Role = Fake + atspi_mock.Accessible = Fake + atspi_mock.MatchRule = Fake + atspi_mock.Relation = Fake + gi_repository_mock.Atspi = atspi_mock + + glib_mock = essential_modules["gi.repository.GLib"] + glib_mock.timeout_add = test_context.Mock(return_value=1) + + cmdnames_mock = essential_modules["cthulhu.cmdnames"] + cmdnames_mock.LIVE_REGIONS_MONITOR = "toggleLiveRegionMonitoring" + cmdnames_mock.LIVE_REGIONS_PREVIOUS = "presentPreviousLiveRegionMessage" + cmdnames_mock.LIVE_REGIONS_ADVANCE_POLITENESS = "advanceLivePoliteness" + cmdnames_mock.LIVE_REGIONS_ARE_ANNOUNCED = "toggleLiveRegionPresentation" + cmdnames_mock.LIVE_REGIONS_NEXT = "presentNextLiveRegionMessage" + + dbus_service_mock = essential_modules["cthulhu.dbus_service"] + controller_mock = test_context.Mock() + controller_mock.register_decorated_module = test_context.Mock() + dbus_service_mock.get_remote_controller = test_context.Mock(return_value=controller_mock) + + debug_mock = essential_modules["cthulhu.debug"] + debug_mock.print_message = test_context.Mock() + debug_mock.print_tokens = test_context.Mock() + debug_mock.LEVEL_INFO = 800 + + input_event_mock = essential_modules["cthulhu.input_event"] + input_event_handler_class = test_context.Mock() + input_event_handler_instance = test_context.Mock() + input_event_handler_class.return_value = input_event_handler_instance + input_event_mock.InputEventHandler = input_event_handler_class + + keybindings_mock = essential_modules["cthulhu.keybindings"] + + def create_keybindings_instance(): + instance = test_context.Mock() + instance.is_empty = test_context.Mock(return_value=True) + instance.remove_key_grabs = test_context.Mock() + instance.add = test_context.Mock() + instance.remove = test_context.Mock() + instance.key_bindings = [] + return instance + + keybindings_mock.KeyBindings = test_context.Mock(side_effect=create_keybindings_instance) + keybinding_instance = test_context.Mock() + keybindings_mock.KeyBinding = test_context.Mock(return_value=keybinding_instance) + keybindings_mock.DEFAULT_MODIFIER_MASK = 1 + keybindings_mock.NO_MODIFIER_MASK = 0 + keybindings_mock.CTHULHU_MODIFIER_MASK = 8 + + messages_mock = essential_modules["cthulhu.messages"] + messages_mock.LIVE_REGIONS_SUPPORT_DISABLED = "Live regions support disabled" + messages_mock.LIVE_REGIONS_LEVEL_POLITE = "Live regions set to polite" + messages_mock.LIVE_REGIONS_LEVEL_ASSERTIVE = "Live regions set to assertive" + messages_mock.LIVE_REGIONS_LEVEL_OFF = "Live regions set to off" + messages_mock.LIVE_REGIONS_NO_MESSAGES = "No live region messages" + messages_mock.LIVE_REGIONS_LIST_TOP = "Top of live region list" + messages_mock.LIVE_REGIONS_LIST_BOTTOM = "Bottom of live region list" + messages_mock.LIVE_REGIONS_ENABLED = "Live regions monitoring enabled" + messages_mock.LIVE_REGIONS_DISABLED = "Live regions monitoring disabled" + messages_mock.LIVE_REGIONS_ALL_OFF = "Live regions all off" + messages_mock.LIVE_REGIONS_ALL_RESTORED = "Live regions restored" + + focus_manager_mock = essential_modules["cthulhu.focus_manager"] + focus_instance = test_context.Mock() + focus_instance.get_locus_of_focus = test_context.Mock(return_value=None) + focus_manager_mock.get_manager = test_context.Mock(return_value=focus_instance) + + script_manager_mock = essential_modules["cthulhu.script_manager"] + script_instance = test_context.Mock() + script_instance.present_message = test_context.Mock() + script_instance.utilities = test_context.Mock() + script_manager_mock.get_manager = test_context.Mock() + script_manager_mock.get_manager.return_value.get_active_script = test_context.Mock( + return_value=script_instance, + ) + + axobject_mock = essential_modules["cthulhu.AXObject"] + axobject_mock.get_attributes_dict = test_context.Mock(return_value={}) + axobject_mock.get_name = test_context.Mock(return_value="") + axobject_mock.find_ancestor = test_context.Mock(return_value=None) + axobject_mock.find_ancestor_inclusive = test_context.Mock(return_value=None) + + axutilities_mock = essential_modules["cthulhu.AXUtilities"] + axutilities_mock.is_live_region = test_context.Mock(return_value=True) + axutilities_mock.is_aria_alert = test_context.Mock(return_value=False) + axutilities_mock.get_focused_object = test_context.Mock(return_value=None) + axutilities_mock.find_all_live_regions = test_context.Mock(return_value=[]) + + from cthulhu import gsettings_registry + + registry = gsettings_registry.get_registry() + registry.clear_runtime_values() + + return essential_modules + + def test_init(self, test_context: CthulhuTestContext) -> None: + """Test LiveRegionPresenter.__init__.""" + + self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import LiveRegionPresenter + + presenter = LiveRegionPresenter() + + assert presenter.msg_queue is not None + assert len(presenter.msg_queue) == 0 + assert len(presenter.msg_cache) == 0 + assert presenter._monitoring is True + assert presenter._current_index == 9 # QUEUE_SIZE + + def test_commands_registered(self, test_context: CthulhuTestContext) -> None: + """Test LiveRegionPresenter commands are registered in CommandManager.""" + + self._setup_dependencies(test_context) + from cthulhu import command_manager + from cthulhu.live_region_presenter import LiveRegionPresenter + + presenter = LiveRegionPresenter() + presenter.set_up_commands() + + cmd_manager = command_manager.get_manager() + expected_commands = [ + "toggle_live_region_support", + "present_previous_live_region_message", + "advance_live_politeness", + "toggle_live_region_presentation", + "present_next_live_region_message", + ] + for cmd_name in expected_commands: + assert cmd_manager.get_keyboard_command(cmd_name) is not None + + def test_reset(self, test_context: CthulhuTestContext) -> None: + """Test LiveRegionPresenter.reset.""" + + self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import LivePoliteness, LiveRegionPresenter + + presenter = LiveRegionPresenter() + presenter._politeness_overrides = {123: LivePoliteness.POLITE} + presenter.reset() + assert not presenter._politeness_overrides + + def test_get_is_enabled(self, test_context: CthulhuTestContext) -> None: + """Test LiveRegionPresenter.get_is_enabled.""" + + self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import LiveRegionPresenter + + presenter = LiveRegionPresenter() + assert presenter.get_is_enabled() is True + + def test_set_is_enabled(self, test_context: CthulhuTestContext) -> None: + """Test LiveRegionPresenter.set_is_enabled.""" + + self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import LiveRegionPresenter + + presenter = LiveRegionPresenter() + presenter.set_is_enabled(False) + result = presenter.set_is_enabled(True) + + assert result is True + assert presenter.get_is_enabled() is True + + def test_toggle_monitoring_enable(self, test_context: CthulhuTestContext) -> None: + """Test LiveRegionPresenter.toggle_monitoring enables monitoring.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import LiveRegionPresenter + + presenter = LiveRegionPresenter() + presenter.set_is_enabled(False) + mock_script = test_context.Mock() + mock_script.present_message = test_context.Mock() + mock_event = test_context.Mock() + + result = presenter.toggle_monitoring(mock_script, mock_event) + + assert result is True + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.assert_called_with("Live regions monitoring enabled") + + def test_toggle_monitoring_disable(self, test_context: CthulhuTestContext) -> None: + """Test LiveRegionPresenter.toggle_monitoring disables monitoring.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import LiveRegionPresenter + + presenter = LiveRegionPresenter() + mock_script = test_context.Mock() + mock_script.present_message = test_context.Mock() + mock_event = test_context.Mock() + + result = presenter.toggle_monitoring(mock_script, mock_event) + + assert result is True + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.assert_called_with("Live regions monitoring disabled") + + def test_flush_messages(self, test_context: CthulhuTestContext) -> None: + """Test LiveRegionPresenter.flush_messages.""" + + self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import ( + LivePoliteness, + LiveRegionMessage, + LiveRegionPresenter, + ) + + presenter = LiveRegionPresenter() + mock_obj = test_context.Mock() + msg = LiveRegionMessage("Test", LivePoliteness.POLITE, mock_obj) + presenter.msg_queue.enqueue(msg) + + assert len(presenter.msg_queue) > 0 + presenter.flush_messages() + assert len(presenter.msg_queue) == 0 + + def test_present_previous_live_region_message_no_messages( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test present_previous_live_region_message with no messages.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import LiveRegionPresenter + + presenter = LiveRegionPresenter() + mock_script = test_context.Mock() + mock_script.present_message = test_context.Mock() + + result = presenter.present_previous_live_region_message(mock_script, None) + + assert result is True + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.assert_called_with("No live region messages") + + def test_present_previous_live_region_message_disabled( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test present_previous_live_region_message when disabled.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import LiveRegionPresenter + + presenter = LiveRegionPresenter() + presenter.set_is_enabled(False) + mock_script = test_context.Mock() + mock_script.present_message = test_context.Mock() + + result = presenter.present_previous_live_region_message(mock_script, None) + + assert result is False + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.assert_called_with("Live regions support disabled") + + def test_present_next_live_region_message_no_messages( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test present_next_live_region_message with no messages.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import LiveRegionPresenter + + presenter = LiveRegionPresenter() + mock_script = test_context.Mock() + mock_script.present_message = test_context.Mock() + + result = presenter.present_next_live_region_message(mock_script, None) + + assert result is True + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.assert_called_with("No live region messages") + + def test_go_last_live_region_no_message(self, test_context: CthulhuTestContext) -> None: + """Test go_last_live_region with no last message.""" + + self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import LiveRegionPresenter + + presenter = LiveRegionPresenter() + mock_script = test_context.Mock() + + result = presenter.go_last_live_region(mock_script, None) + assert result is False + + def test_is_presentable_live_region_event_wrong_type( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test is_presentable_live_region_event with wrong event type.""" + + self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import LiveRegionPresenter + + presenter = LiveRegionPresenter() + mock_script = test_context.Mock() + mock_event = test_context.Mock() + mock_event.type = "object:state-changed:focused" + mock_event.source = test_context.Mock() + + result = presenter.is_presentable_live_region_event(mock_script, mock_event) + assert result is False + + def test_is_presentable_live_region_event_disabled(self, test_context: CthulhuTestContext) -> None: + """Test is_presentable_live_region_event when presenter is disabled.""" + + self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import LiveRegionPresenter + + presenter = LiveRegionPresenter() + presenter.set_is_enabled(False) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + mock_event.type = "object:text-changed:insert" + mock_event.source = test_context.Mock() + + result = presenter.is_presentable_live_region_event(mock_script, mock_event) + assert result is False + + def test_is_presentable_live_region_event_not_live_region( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test is_presentable_live_region_event when source is not a live region.""" + + self._setup_dependencies(test_context) + from cthulhu import ax_utilities + from cthulhu.live_region_presenter import LiveRegionPresenter + + presenter = LiveRegionPresenter() + test_context.patch_object(ax_utilities.AXUtilities, "is_live_region", return_value=False) + + mock_script = test_context.Mock() + mock_event = test_context.Mock() + mock_event.type = "object:text-changed:insert" + mock_event.source = test_context.Mock() + + result = presenter.is_presentable_live_region_event(mock_script, mock_event) + assert result is False + + +@pytest.mark.unit +class TestLiveRegionPresenterModule: + """Test module-level functions.""" + + def _setup_dependencies(self, test_context: CthulhuTestContext): + """Set up dependencies for live_region_presenter module testing.""" + + additional_modules = [ + "gi", + "gi.repository", + "gi.repository.Atspi", + "gi.repository.GLib", + "cthulhu.cmdnames", + "cthulhu.dbus_service", + "cthulhu.debug", + "cthulhu.focus_manager", + "cthulhu.input_event", + "cthulhu.keybindings", + "cthulhu.messages", + "cthulhu.script_manager", + "cthulhu.AXObject", + "cthulhu.AXUtilities", + "cthulhu.braille_presenter", + "cthulhu.presentation_manager", + ] + essential_modules = test_context.setup_shared_dependencies(additional_modules) + + gi_mock = essential_modules["gi"] + gi_mock.require_version = test_context.Mock() + + gi_repository_mock = essential_modules["gi.repository"] + atspi_mock = essential_modules["gi.repository.Atspi"] + atspi_mock.Role = Fake + atspi_mock.Accessible = Fake + atspi_mock.MatchRule = Fake + atspi_mock.Relation = Fake + gi_repository_mock.Atspi = atspi_mock + + glib_mock = essential_modules["gi.repository.GLib"] + glib_mock.timeout_add = test_context.Mock(return_value=1) + + dbus_service_mock = essential_modules["cthulhu.dbus_service"] + controller_mock = test_context.Mock() + controller_mock.register_decorated_module = test_context.Mock() + dbus_service_mock.get_remote_controller = test_context.Mock(return_value=controller_mock) + + debug_mock = essential_modules["cthulhu.debug"] + debug_mock.print_message = test_context.Mock() + debug_mock.print_tokens = test_context.Mock() + debug_mock.LEVEL_INFO = 800 + + essential_modules["cthulhu.cmdnames"].LIVE_REGIONS_MONITOR = "test" + essential_modules["cthulhu.cmdnames"].LIVE_REGIONS_PREVIOUS = "test" + essential_modules["cthulhu.cmdnames"].LIVE_REGIONS_ADVANCE_POLITENESS = "test" + essential_modules["cthulhu.cmdnames"].LIVE_REGIONS_ARE_ANNOUNCED = "test" + essential_modules["cthulhu.cmdnames"].LIVE_REGIONS_NEXT = "test" + + input_event_mock = essential_modules["cthulhu.input_event"] + input_event_handler_class = test_context.Mock() + input_event_handler_instance = test_context.Mock() + input_event_handler_class.return_value = input_event_handler_instance + input_event_mock.InputEventHandler = input_event_handler_class + + keybindings_mock = essential_modules["cthulhu.keybindings"] + + def create_keybindings_instance(): + instance = test_context.Mock() + instance.is_empty = test_context.Mock(return_value=True) + instance.remove_key_grabs = test_context.Mock() + instance.add = test_context.Mock() + instance.remove = test_context.Mock() + instance.key_bindings = [] + return instance + + keybindings_mock.KeyBindings = test_context.Mock(side_effect=create_keybindings_instance) + keybindings_mock.KeyBinding = test_context.Mock() + keybindings_mock.DEFAULT_MODIFIER_MASK = 1 + keybindings_mock.NO_MODIFIER_MASK = 0 + keybindings_mock.CTHULHU_MODIFIER_MASK = 8 + + essential_modules["cthulhu.messages"].LIVE_REGIONS_SUPPORT_DISABLED = "test" + + focus_manager_mock = essential_modules["cthulhu.focus_manager"] + focus_instance = test_context.Mock() + focus_instance.get_locus_of_focus = test_context.Mock(return_value=None) + focus_manager_mock.get_manager = test_context.Mock(return_value=focus_instance) + + script_manager_mock = essential_modules["cthulhu.script_manager"] + script_instance = test_context.Mock() + script_manager_mock.get_manager = test_context.Mock() + script_manager_mock.get_manager.return_value.get_active_script = test_context.Mock( + return_value=script_instance, + ) + + axobject_mock = essential_modules["cthulhu.AXObject"] + axobject_mock.get_attributes_dict = test_context.Mock(return_value={}) + axobject_mock.get_name = test_context.Mock(return_value="") + axobject_mock.find_ancestor = test_context.Mock(return_value=None) + axobject_mock.find_ancestor_inclusive = test_context.Mock(return_value=None) + + axutilities_mock = essential_modules["cthulhu.AXUtilities"] + axutilities_mock.is_live_region = test_context.Mock(return_value=True) + axutilities_mock.is_aria_alert = test_context.Mock(return_value=False) + axutilities_mock.get_focused_object = test_context.Mock(return_value=None) + axutilities_mock.find_all_live_regions = test_context.Mock(return_value=[]) + + from cthulhu import gsettings_registry + + registry = gsettings_registry.get_registry() + registry.clear_runtime_values() + + return essential_modules + + def test_get_presenter(self, test_context: CthulhuTestContext) -> None: + """Test get_presenter function returns singleton.""" + + _ = self._setup_dependencies(test_context) + from cthulhu.live_region_presenter import get_presenter + + presenter1 = get_presenter() + assert presenter1 is not None + assert presenter1.msg_queue is not None + + presenter2 = get_presenter() + assert presenter1 is presenter2 diff --git a/tests/test_ocr_preferences_grid.py b/tests/test_ocr_preferences_grid.py new file mode 100644 index 0000000..51386a0 --- /dev/null +++ b/tests/test_ocr_preferences_grid.py @@ -0,0 +1,17 @@ +import pytest + + +@pytest.mark.unit +def test_ocr_grid_saves_schema_keys(): + from cthulhu.plugins.OCR import plugin + + grid = plugin.OCRPreferencesGrid() + grid.set_language_code("eng") + grid.set_scale_factor(4) + grid.set_copy_to_clipboard(True) + + result = grid.save_settings("default", "") + + assert result["language-code"] == "eng" + assert result["scale-factor"] == 4 + assert result["copy-to-clipboard"] is True diff --git a/tests/test_preferences_grid_base.py b/tests/test_preferences_grid_base.py new file mode 100644 index 0000000..ac57580 --- /dev/null +++ b/tests/test_preferences_grid_base.py @@ -0,0 +1,982 @@ +# Unit tests for preferences_grid_base.py. +# +# Copyright 2026 Igalia, S.L. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +# pylint: disable=wrong-import-position +# pylint: disable=protected-access +# pylint: disable=import-outside-toplevel + +"""Unit tests for preferences_grid_base.py.""" + +from __future__ import annotations + +from typing import Any + +import gi +import pytest + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +from cthulhu.preferences_grid_base import ( + AutoPreferencesGrid, + BooleanPreferenceControl, + CategoryListBoxRow, + CommandListBoxRow, + ControlType, + EnumPreferenceControl, + FloatRangePreferenceControl, + FocusManagedListBox, + IntRangePreferenceControl, + PreferencesGridBase, + RadioButtonWithActions, + SelectionPreferenceControl, + StackedPreferencesHelper, +) + + +@pytest.mark.unit +class TestPreferenceControlDataclasses: + """Test preference control dataclass creation and properties.""" + + def test_boolean_preference_control_creation(self) -> None: + """Test BooleanPreferenceControl can be created with required fields.""" + + getter_called: list[bool] = [False] + setter_value: list[bool | None] = [None] + + def getter() -> bool: + getter_called[0] = True + return True + + def setter(value: bool) -> bool: + setter_value[0] = value + return True + + control = BooleanPreferenceControl( + label="Test Label", + getter=getter, + setter=setter, + prefs_key="testKey", + ) + + assert control.label == "Test Label" + assert control.prefs_key == "testKey" + assert control.getter() is True + assert getter_called[0] is True + control.setter(False) + assert setter_value[0] is False + + def test_boolean_preference_control_optional_fields(self) -> None: + """Test BooleanPreferenceControl optional fields have correct defaults.""" + + control = BooleanPreferenceControl(label="Test", getter=lambda: True, setter=lambda x: True) + + assert control.prefs_key is None + assert control.member_of is None + assert control.determine_sensitivity is None + assert control.apply_immediately is True + + def test_int_range_preference_control_creation(self) -> None: + """Test IntRangePreferenceControl can be created with all fields.""" + + control = IntRangePreferenceControl( + label="Volume", + minimum=0, + maximum=100, + getter=lambda: 50, + setter=lambda x: True, + prefs_key="volume", + ) + + assert control.label == "Volume" + assert control.minimum == 0 + assert control.maximum == 100 + assert control.getter() == 50 + + def test_float_range_preference_control_creation(self) -> None: + """Test FloatRangePreferenceControl can be created with all fields.""" + + control = FloatRangePreferenceControl( + label="Rate", + minimum=0.0, + maximum=1.0, + getter=lambda: 0.5, + setter=lambda x: True, + ) + + assert control.label == "Rate" + assert control.minimum == 0.0 + assert control.maximum == 1.0 + assert control.getter() == 0.5 + + def test_enum_preference_control_creation(self) -> None: + """Test EnumPreferenceControl can be created with options and values.""" + + control = EnumPreferenceControl( + label="Style", + options=["Option A", "Option B", "Option C"], + values=[1, 2, 3], + getter=lambda: 2, + setter=lambda x: True, + ) + + assert control.label == "Style" + assert len(control.options) == 3 + assert control.values == [1, 2, 3] + assert control.getter() == 2 + + def test_selection_preference_control_creation(self) -> None: + """Test SelectionPreferenceControl can be created with actions callback.""" + + def get_actions(_value: Any) -> list[tuple[str, str, Any]]: + return [("Edit", "edit-symbolic", lambda: None)] + + control = SelectionPreferenceControl( + label="Profile", + options=["Default", "Custom"], + getter=lambda: "Default", + setter=lambda x: True, + get_actions_for_option=get_actions, + ) + + assert control.label == "Profile" + assert len(control.options) == 2 + assert control.get_actions_for_option is not None + actions = control.get_actions_for_option("Default") + assert len(actions) == 1 + assert actions[0][0] == "Edit" + + +@pytest.mark.unit +class TestHelperClasses: + """Test helper classes in preferences_grid_base.""" + + def test_category_list_box_row_stores_category(self) -> None: + """Test CategoryListBoxRow stores and returns category identifier.""" + + row = CategoryListBoxRow("speech_settings") + assert row.category == "speech_settings" + + def test_category_list_box_row_is_gtk_list_box_row(self) -> None: + """Test CategoryListBoxRow inherits from Gtk.ListBoxRow.""" + + row = CategoryListBoxRow("test") + assert isinstance(row, Gtk.ListBoxRow) + + def test_command_list_box_row_properties(self) -> None: + """Test CommandListBoxRow property getters and setters.""" + + row = CommandListBoxRow() + assert row.command is None + assert row.vbox is None + assert row.binding_label is None + + mock_command = object() + mock_vbox = Gtk.Box() + mock_label = Gtk.Label() + + row.command = mock_command + row.vbox = mock_vbox + row.binding_label = mock_label + + assert row.command is mock_command + assert row.vbox is mock_vbox + assert row.binding_label is mock_label + + def test_radio_button_with_actions_stores_buttons(self) -> None: + """Test RadioButtonWithActions stores action buttons list.""" + + radio = RadioButtonWithActions(label="Test Option") + assert not radio.action_buttons + + button1 = Gtk.Button() + button2 = Gtk.Button() + radio.action_buttons = [button1, button2] + + assert len(radio.action_buttons) == 2 + assert radio.action_buttons[0] is button1 + + def test_radio_button_with_actions_is_gtk_radio_button(self) -> None: + """Test RadioButtonWithActions inherits from Gtk.RadioButton.""" + + radio = RadioButtonWithActions(label="Test") + assert isinstance(radio, Gtk.RadioButton) + + def test_stacked_preferences_helper_initial_state(self) -> None: + """Test StackedPreferencesHelper initializes with None values.""" + + helper = StackedPreferencesHelper() + assert helper.stack is None + assert helper.categories_listbox is None + assert helper.detail_listbox is None + assert not helper.disable_widgets + assert helper.on_category_activated_callback is None + + def test_stacked_preferences_helper_register_widgets(self) -> None: + """Test StackedPreferencesHelper can register widgets to disable.""" + + helper = StackedPreferencesHelper() + button1 = Gtk.Button() + button2 = Gtk.Button() + + helper.register_disable_widgets(button1, button2) + assert len(helper.disable_widgets) == 2 + assert button1 in helper.disable_widgets + + def test_stacked_preferences_helper_show_categories(self) -> None: + """Test StackedPreferencesHelper show_categories enables registered widgets.""" + + helper = StackedPreferencesHelper() + helper.stack = Gtk.Stack() + cat_label = Gtk.Label(label="Categories") + det_label = Gtk.Label(label="Detail") + helper.stack.add_named(cat_label, "categories") + helper.stack.add_named(det_label, "detail") + + # Show the stack to ensure children are realized + helper.stack.show_all() # pylint: disable=no-member + helper.stack.set_visible_child_name("detail") + + button = Gtk.Button() + button.set_sensitive(False) + helper.register_disable_widgets(button) + + helper.show_categories() + assert button.get_sensitive() is True + # Verify the visible child is the categories child + assert helper.stack.get_visible_child() is cat_label + + def test_stacked_preferences_helper_show_detail(self) -> None: + """Test StackedPreferencesHelper show_detail disables registered widgets.""" + + helper = StackedPreferencesHelper() + helper.stack = Gtk.Stack() + cat_label = Gtk.Label(label="Categories") + det_label = Gtk.Label(label="Detail") + helper.stack.add_named(cat_label, "categories") + helper.stack.add_named(det_label, "detail") + + # Show the stack to ensure children are realized + helper.stack.show_all() # pylint: disable=no-member + helper.stack.set_visible_child_name("categories") + + button = Gtk.Button() + button.set_sensitive(True) + helper.register_disable_widgets(button) + + helper.show_detail() + assert button.get_sensitive() is False + # Verify the visible child is the detail child + assert helper.stack.get_visible_child() is det_label + + +@pytest.mark.unit +class TestFocusManagedListBox: + """Test FocusManagedListBox focus management.""" + + def test_focus_managed_listbox_initial_state(self) -> None: + """Test FocusManagedListBox initializes with correct settings.""" + + listbox = FocusManagedListBox() + assert listbox.get_selection_mode() == Gtk.SelectionMode.NONE + assert listbox.get_can_focus() is False + + def test_focus_managed_listbox_add_row_with_widget(self) -> None: + """Test FocusManagedListBox tracks added rows and widgets.""" + + listbox = FocusManagedListBox() + row = Gtk.ListBoxRow() + switch = Gtk.Switch() + + listbox.add_row_with_widget(row, switch) + + assert len(listbox._widgets) == 1 + assert len(listbox._rows) == 1 + assert listbox._widgets[0] is switch + assert listbox._rows[0] is row + + def test_focus_managed_listbox_get_last_row(self) -> None: + """Test FocusManagedListBox.get_last_row returns last added row.""" + + listbox = FocusManagedListBox() + assert listbox.get_last_row() is None + + row1 = Gtk.ListBoxRow() + row2 = Gtk.ListBoxRow() + listbox.add_row_with_widget(row1, Gtk.Switch()) + listbox.add_row_with_widget(row2, Gtk.Switch()) + + assert listbox.get_last_row() is row2 + + def test_focus_managed_listbox_multiple_rows(self) -> None: + """Test FocusManagedListBox handles multiple rows correctly.""" + + listbox = FocusManagedListBox() + + for _i in range(5): + row = Gtk.ListBoxRow() + switch = Gtk.Switch() + listbox.add_row_with_widget(row, switch) + + assert len(listbox._widgets) == 5 + assert len(listbox._rows) == 5 + + +@pytest.mark.unit +class TestPreferencesGridBase: + """Test PreferencesGridBase UI helper methods.""" + + def test_preferences_grid_base_initialization(self) -> None: + """Test PreferencesGridBase initializes with correct properties.""" + + grid = PreferencesGridBase("Test Tab") + assert grid._tab_label == "Test Tab" + assert grid._has_unsaved_changes is False + assert grid.get_border_width() == 24 # pylint: disable=no-member + + def test_preferences_grid_base_get_label(self) -> None: + """Test PreferencesGridBase.get_label returns Gtk.Label.""" + + grid = PreferencesGridBase("My Tab") + label = grid.get_label() + + assert isinstance(label, Gtk.Label) + assert label.get_text() == "My Tab" + + def test_preferences_grid_base_has_changes(self) -> None: + """Test PreferencesGridBase.has_changes tracks unsaved changes.""" + + grid = PreferencesGridBase("Test") + assert grid.has_changes() is False + + grid._has_unsaved_changes = True + assert grid.has_changes() is True + + def test_create_frame_returns_frame_and_grid(self) -> None: + """Test _create_frame returns Gtk.Frame and content Gtk.Grid.""" + + grid = PreferencesGridBase("Test") + frame, content = grid._create_frame("Section Label") + + assert isinstance(frame, Gtk.Frame) + assert isinstance(content, Gtk.Grid) + assert frame.get_child() is content + + def test_create_label_with_mnemonic(self) -> None: + """Test _create_label creates label with mnemonic support.""" + + grid = PreferencesGridBase("Test") + label = grid._create_label("_Test Label") + + assert isinstance(label, Gtk.Label) + # Label should use underline for mnemonic + assert label.get_use_underline() is True + + def test_create_switch_row(self) -> None: + """Test _create_switch_row creates row with label and switch.""" + + grid = PreferencesGridBase("Test") + handler_called = [False] + + def handler(_switch, _param): + handler_called[0] = True + + row, switch, label = grid._create_switch_row("Enable Feature", handler, state=True) + + assert isinstance(row, Gtk.ListBoxRow) + assert isinstance(switch, Gtk.Switch) + assert isinstance(label, Gtk.Label) + assert switch.get_active() is True + + def test_create_slider_row(self) -> None: + """Test _create_slider_row creates row with label and scale.""" + + grid = PreferencesGridBase("Test") + adjustment = Gtk.Adjustment( + value=50, + lower=0, + upper=100, + step_increment=1, + page_increment=10, + ) + + row, scale, label = grid._create_slider_row("Volume", adjustment) + + assert isinstance(row, Gtk.ListBoxRow) + assert isinstance(scale, Gtk.Scale) + assert isinstance(label, Gtk.Label) + assert scale.get_value() == 50 + + def test_create_info_listbox(self) -> None: + """Test _create_info_listbox creates listbox with info message.""" + + grid = PreferencesGridBase("Test") + listbox = grid._create_info_listbox("This is an informational message.") + + assert isinstance(listbox, Gtk.ListBox) + assert listbox.get_selection_mode() == Gtk.SelectionMode.NONE + + def test_create_scrolled_window(self) -> None: + """Test _create_scrolled_window wraps widget in scrolled container.""" + + grid = PreferencesGridBase("Test") + label = Gtk.Label(label="Content") + scrolled = grid._create_scrolled_window(label) + + assert isinstance(scrolled, Gtk.ScrolledWindow) + assert scrolled.get_hexpand() is True + assert scrolled.get_vexpand() is True + + +@pytest.mark.unit +class TestAutoPreferencesGrid: + """Test AutoPreferencesGrid automatic UI building.""" + + def _create_test_grid(self) -> tuple: + """Create a test grid with various controls for testing.""" + + values: dict[str, Any] = { + "bool_setting": True, + "int_setting": 5, + "float_setting": 0.5, + "enum_setting": "Option B", + } + + def set_bool(x: bool) -> bool: + values["bool_setting"] = x + return True + + def set_int(x: int) -> bool: + values["int_setting"] = x + return True + + def set_float(x: float) -> bool: + values["float_setting"] = x + return True + + def set_enum(x: str) -> bool: + values["enum_setting"] = x + return True + + controls: list[ControlType] = [ + BooleanPreferenceControl( + label="Enable Feature", + getter=lambda: values["bool_setting"], + setter=set_bool, + prefs_key="boolSetting", + ), + IntRangePreferenceControl( + label="Count", + minimum=0, + maximum=10, + getter=lambda: values["int_setting"], + setter=set_int, + prefs_key="intSetting", + ), + FloatRangePreferenceControl( + label="Rate", + minimum=0.0, + maximum=1.0, + getter=lambda: values["float_setting"], + setter=set_float, + prefs_key="floatSetting", + ), + EnumPreferenceControl( + label="Style", + options=["Option A", "Option B", "Option C"], + getter=lambda: values["enum_setting"], + setter=set_enum, + prefs_key="enumSetting", + ), + ] + + grid = AutoPreferencesGrid("Test Tab", controls) + return grid, values, controls + + def test_auto_preferences_grid_builds_widgets(self) -> None: + """Test AutoPreferencesGrid creates widgets for all controls.""" + + grid, _values, controls = self._create_test_grid() + + assert len(grid._widgets) == len(controls) + assert len(grid._controls) == len(controls) + + def test_auto_preferences_grid_boolean_control_creates_switch(self) -> None: + """Test AutoPreferencesGrid creates Switch for boolean control.""" + + grid, _values, _controls = self._create_test_grid() + + # First control is boolean, should create a Switch + widget = grid.get_widget(0) + assert isinstance(widget, Gtk.Switch) + assert widget.get_active() is True + + def test_auto_preferences_grid_int_range_creates_spinbutton(self) -> None: + """Test AutoPreferencesGrid creates SpinButton for int range control.""" + + grid, _values, _controls = self._create_test_grid() + + widget = grid.get_widget(1) + assert isinstance(widget, Gtk.SpinButton) + assert int(widget.get_value()) == 5 + + def test_auto_preferences_grid_float_range_creates_scale(self) -> None: + """Test AutoPreferencesGrid creates Scale for float range control.""" + + grid, _values, _controls = self._create_test_grid() + + widget = grid.get_widget(2) + assert isinstance(widget, Gtk.Scale) + assert widget.get_value() == pytest.approx(0.5, abs=0.01) + + def test_auto_preferences_grid_enum_creates_combobox(self) -> None: + """Test AutoPreferencesGrid creates ComboBoxText for enum control.""" + + grid, _values, _controls = self._create_test_grid() + + widget = grid.get_widget(3) + assert isinstance(widget, Gtk.ComboBoxText) + # Option B is at index 1 + assert widget.get_active() == 1 + + def test_auto_preferences_grid_refresh_updates_widgets(self) -> None: + """Test AutoPreferencesGrid.refresh updates widget values from getters.""" + + current_value = [True] + + controls = [ + BooleanPreferenceControl( + label="Test", + getter=lambda: current_value[0], + setter=lambda x: True, + ), + ] + + grid = AutoPreferencesGrid("Test", controls) + switch = grid.get_widget(0) + assert switch and switch.get_active() + + current_value[0] = False + grid.refresh() + assert switch and not switch.get_active() + + def test_auto_preferences_grid_save_settings_calls_setters(self) -> None: + """Test AutoPreferencesGrid.save_settings calls all setters.""" + + grid, values, _controls = self._create_test_grid() + + switch = grid.get_widget(0) + switch.set_active(False) + spin = grid.get_widget(1) + spin.set_value(8) + result = grid.save_settings() + + assert values["bool_setting"] is False + assert values["int_setting"] == 8 + assert "boolSetting" in result + assert result["boolSetting"] is False + + def test_auto_preferences_grid_save_returns_prefs_dict(self) -> None: + """Test AutoPreferencesGrid.save_settings returns dict with prefs_keys.""" + + grid, _values, _controls = self._create_test_grid() + + result = grid.save_settings() + + assert "boolSetting" in result + assert "intSetting" in result + assert "floatSetting" in result + assert "enumSetting" in result + + def test_auto_preferences_grid_reload_clears_changes_flag(self) -> None: + """Test AutoPreferencesGrid.reload clears unsaved changes flag.""" + + grid, _values, _controls = self._create_test_grid() + + grid._has_unsaved_changes = True + grid.reload() + + assert grid._has_unsaved_changes is False + + def test_auto_preferences_grid_get_widget_returns_none_for_invalid_index(self) -> None: + """Test AutoPreferencesGrid.get_widget returns None for out-of-range index.""" + + grid, _values, controls = self._create_test_grid() + + assert grid.get_widget(-1) is None + assert grid.get_widget(len(controls)) is None + assert grid.get_widget(100) is None + + def test_auto_preferences_grid_get_widget_for_control(self) -> None: + """Test AutoPreferencesGrid.get_widget_for_control finds correct widget.""" + + grid, _values, controls = self._create_test_grid() + + widget = grid.get_widget_for_control(controls[0]) + assert widget is not None + assert isinstance(widget, Gtk.Switch) + + # Non-existent control should return None + other_control = BooleanPreferenceControl( + label="Other", + getter=lambda: True, + setter=lambda x: True, + ) + assert grid.get_widget_for_control(other_control) is None + + def test_auto_preferences_grid_with_grouped_controls(self) -> None: + """Test AutoPreferencesGrid groups controls by member_of field.""" + + controls = [ + BooleanPreferenceControl(label="Ungrouped", getter=lambda: True, setter=lambda x: True), + BooleanPreferenceControl( + label="Group A Item 1", + getter=lambda: True, + setter=lambda x: True, + member_of="Group A", + ), + BooleanPreferenceControl( + label="Group A Item 2", + getter=lambda: False, + setter=lambda x: True, + member_of="Group A", + ), + BooleanPreferenceControl( + label="Group B Item", + getter=lambda: True, + setter=lambda x: True, + member_of="Group B", + ), + ] + + grid = AutoPreferencesGrid("Test", controls) + + assert "Group A" in grid._group_labels + assert "Group B" in grid._group_labels + assert len(grid._widgets) == 4 + + def test_auto_preferences_grid_sensitivity_callback(self) -> None: + """Test AutoPreferencesGrid updates sensitivity based on callback.""" + + primary_enabled = [True] + + def set_primary(x: bool) -> bool: + primary_enabled[0] = x + return True + + controls = [ + BooleanPreferenceControl( + label="Primary", + getter=lambda: primary_enabled[0], + setter=set_primary, + apply_immediately=True, + ), + BooleanPreferenceControl( + label="Dependent", + getter=lambda: True, + setter=lambda x: True, + determine_sensitivity=lambda: primary_enabled[0], + ), + ] + + grid = AutoPreferencesGrid("Test", controls) + + primary_switch = grid.get_widget(0) + dependent_switch = grid.get_widget(1) + + assert dependent_switch and dependent_switch.get_sensitive() + + grid._initializing = False # Allow change handlers to run + assert primary_switch + primary_switch.set_active(False) + + assert dependent_switch and not dependent_switch.get_sensitive() + + def test_auto_preferences_grid_with_info_message(self) -> None: + """Test AutoPreferencesGrid displays info message when provided.""" + + controls = [ + BooleanPreferenceControl(label="Test", getter=lambda: True, setter=lambda x: True), + ] + + grid = AutoPreferencesGrid("Test", controls, info_message="This is helpful information.") + + assert grid._info_listbox is not None + + +@pytest.mark.unit +class TestAutoPreferencesGridSelectionControl: + """Test AutoPreferencesGrid with SelectionPreferenceControl.""" + + def test_selection_control_creates_combobox_without_actions(self) -> None: + """Test SelectionPreferenceControl without actions creates ComboBoxText.""" + + controls = [ + SelectionPreferenceControl( + label="Choose", + options=["A", "B", "C"], + getter=lambda: "B", + setter=lambda x: True, + ), + ] + + grid = AutoPreferencesGrid("Test", controls) + widget = grid.get_widget(0) + + assert isinstance(widget, Gtk.ComboBoxText) + assert widget.get_active() == 1 + + def test_selection_control_creates_radio_buttons_with_actions(self) -> None: + """Test SelectionPreferenceControl with actions creates RadioButtons.""" + + def get_actions(_value): + return [("Edit", "edit-symbolic", lambda: None)] + + controls = [ + SelectionPreferenceControl( + label="Profile", + options=["Default", "Custom"], + getter=lambda: "Default", + setter=lambda x: True, + get_actions_for_option=get_actions, + ), + ] + + grid = AutoPreferencesGrid("Test", controls) + widget = grid.get_widget(0) + + assert isinstance(widget, RadioButtonWithActions) + + def test_selection_control_with_values_mapping(self) -> None: + """Test SelectionPreferenceControl maps display options to values.""" + + current_value = [100] + + def set_level(x: int) -> bool: + current_value[0] = x + return True + + controls = [ + SelectionPreferenceControl( + label="Level", + options=["Low", "Medium", "High"], + values=[50, 100, 150], + getter=lambda: current_value[0], + setter=set_level, + prefs_key="level", + ), + ] + + grid = AutoPreferencesGrid("Test", controls) + widget = grid.get_widget(0) + + assert widget and widget.get_active() == 1 + + widget.set_active(2) + result = grid.save_settings() + + assert current_value[0] == 150 + assert result["level"] == 150 + + +@pytest.mark.unit +class TestPreferencesGridBaseMultiPageStack: + """Test PreferencesGridBase multi-page stack functionality.""" + + def test_create_multi_page_stack_basic(self) -> None: + """Test _create_multi_page_stack creates stack with categories.""" + + class TestGrid(PreferencesGridBase): + """Test grid.""" + + def __init__(self): + super().__init__("Test") + self._initializing = True + + child1 = PreferencesGridBase("Child 1") + child2 = PreferencesGridBase("Child 2") + + enable_listbox, stack, categories = self._create_multi_page_stack( + enable_label="Enable", + enable_getter=lambda: True, + enable_setter=lambda x: True, + categories=[ + ("General", "general", child1), + ("Advanced", "advanced", child2), + ], + main_title="Test Settings", + ) + + self.my_enable_listbox = enable_listbox + self.my_stack = stack + self.my_categories = categories + self._initializing = False + + grid = TestGrid() + + assert grid.my_stack is not None + assert grid.my_categories is not None + assert grid.my_enable_listbox is not None + + def test_create_multi_page_stack_without_enable(self) -> None: + """Test _create_multi_page_stack without enable switch.""" + + class TestGrid(PreferencesGridBase): + """Test grid.""" + + def __init__(self): + super().__init__("Test") + self._initializing = True + + child = PreferencesGridBase("Child") + + enable_listbox, stack, _categories = self._create_multi_page_stack( + enable_label=None, + enable_getter=None, + enable_setter=None, + categories=[("General", "general", child)], + main_title="Test", + ) + + self.my_enable_listbox = enable_listbox + self.my_stack = stack + self._initializing = False + + grid = TestGrid() + + assert grid.my_enable_listbox is None + assert grid.my_stack is not None + + +@pytest.mark.unit +class TestPreferencesGridBaseStackedPreferences: + """Test PreferencesGridBase stacked drill-down preferences.""" + + def test_create_stacked_preferences(self) -> None: + """Test _create_stacked_preferences creates stack with categories and detail.""" + + class TestGrid(PreferencesGridBase): + """Test grid.""" + + def __init__(self): + super().__init__("Test") + self.activated_row = None + + stack, categories, detail = self._create_stacked_preferences( + on_category_activated=self._on_category, + on_detail_row_activated=None, + ) + self.my_stack = stack + self.my_categories = categories + self.my_detail = detail + + def _on_category(self, row): + self.activated_row = row + + grid = TestGrid() + + assert grid.my_stack is not None + assert grid.my_categories is not None + assert grid.my_detail is not None + assert isinstance(grid.my_stack, Gtk.Stack) + assert isinstance(grid.my_categories, Gtk.ListBox) + assert isinstance(grid.my_detail, Gtk.ListBox) + + def test_add_stack_category_row(self) -> None: + """Test _add_stack_category_row adds category with correct properties.""" + + class TestGrid(PreferencesGridBase): + """Test grid.""" + + def __init__(self): + super().__init__("Test") + _stack, categories, _detail = self._create_stacked_preferences( + on_category_activated=lambda r: None, + ) + self.my_categories = categories + + grid = TestGrid() + + row = grid._add_stack_category_row(grid.my_categories, "Speech Settings", category="speech") + + assert isinstance(row, CategoryListBoxRow) + assert row.category == "speech" + + def test_add_stack_category_row_chevron_accessible_role(self) -> None: + """Test _add_stack_category_row chevron has PUSH_BUTTON accessible role.""" + + gi.require_version("Atk", "1.0") + from gi.repository import Atk + + class TestGrid(PreferencesGridBase): + """Test grid.""" + + def __init__(self): + super().__init__("Test") + _stack, categories, _detail = self._create_stacked_preferences( + on_category_activated=lambda r: None, + ) + self.my_categories = categories + + grid = TestGrid() + row = grid._add_stack_category_row(grid.my_categories, "Test Category", category="test") + + # Find the chevron image in the row's hbox + hbox = row.get_child() + chevron = None + for child in hbox.get_children(): + if isinstance(child, Gtk.Image): + chevron = child + break + + assert chevron is not None, "Chevron image not found in category row" + + chevron_accessible = chevron.get_accessible() + assert chevron_accessible is not None + assert chevron_accessible.get_role() == Atk.Role.BUTTON + assert chevron_accessible.get_name() == "" + + def test_show_stack_categories_and_detail(self) -> None: + """Test _show_stack_categories and _show_stack_detail switch views.""" + + class TestGrid(PreferencesGridBase): + """Test grid.""" + + def __init__(self): + super().__init__("Test") + stack, categories, detail = self._create_stacked_preferences( + on_category_activated=lambda r: None, + ) + self.my_stack = stack + self.my_categories = categories + self.my_detail = detail + + grid = TestGrid() + + grid.my_stack.show_all() # pylint: disable=no-member + + cat_scrolled = grid.my_stack.get_child_by_name("categories") + det_scrolled = grid.my_stack.get_child_by_name("detail") + + grid._show_stack_detail() + assert grid.my_stack.get_visible_child() is det_scrolled + + grid._show_stack_categories() + assert grid.my_stack.get_visible_child() is cat_scrolled diff --git a/tests/test_presentation_manager.py b/tests/test_presentation_manager.py new file mode 100644 index 0000000..d523342 --- /dev/null +++ b/tests/test_presentation_manager.py @@ -0,0 +1,580 @@ +# Unit tests for presentation_manager.py methods. +# +# Copyright 2026 Igalia, S.L. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +# pylint: disable=wrong-import-position +# pylint: disable=import-outside-toplevel +# pylint: disable=protected-access +# pylint: disable=too-many-locals +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-statements + +"""Unit tests for presentation_manager.py methods.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from unittest.mock import MagicMock + + from cthulhu_test_context import CthulhuTestContext + + +@pytest.mark.unit +class TestPresentationManager: + """Test PresentationManager class methods.""" + + def _setup_dependencies(self, test_context: CthulhuTestContext) -> dict[str, MagicMock]: + """Returns dependencies for presentation_manager module testing.""" + + additional_modules = [ + "cthulhu.braille_presenter", + "cthulhu.live_region_presenter", + "cthulhu.sound_presenter", + "cthulhu.speech_manager", + "cthulhu.speech_presenter", + "cthulhu.typing_echo_presenter", + ] + essential_modules = test_context.setup_shared_dependencies(additional_modules) + + debug_mock = essential_modules["cthulhu.debug"] + debug_mock.print_message = test_context.Mock() + debug_mock.print_tokens = test_context.Mock() + debug_mock.print_exception = test_context.Mock() + debug_mock.debugFile = None + debug_mock.LEVEL_INFO = 800 + debug_mock.LEVEL_WARNING = 600 + + braille_mock = essential_modules["cthulhu.braille"] + braille_mock.killFlash = test_context.Mock() + braille_mock.displayMessage = test_context.Mock() + braille_mock.clear = test_context.Mock() + mock_line = test_context.Mock() + mock_line.regions = [] + mock_line.add_regions = test_context.Mock(side_effect=mock_line.regions.append) + braille_mock.Line = test_context.Mock(return_value=mock_line) + braille_mock.add_line = test_context.Mock() + braille_mock.setFocus = test_context.Mock() + braille_mock.refresh = test_context.Mock() + + braille_presenter_mock = essential_modules["cthulhu.braille_presenter"] + braille_presenter_instance = test_context.Mock() + braille_presenter_instance.use_braille = test_context.Mock(return_value=True) + braille_presenter_instance.get_flash_messages_are_enabled = test_context.Mock( + return_value=True, + ) + braille_presenter_instance.get_flash_messages_are_detailed = test_context.Mock( + return_value=True, + ) + braille_presenter_instance.get_flashtime_from_settings = test_context.Mock( + return_value=5000, + ) + braille_presenter_instance.kill_flash = test_context.Mock() + braille_presenter_instance.present_message = test_context.Mock() + braille_presenter_instance.present_regions = test_context.Mock() + braille_presenter_mock.get_presenter = test_context.Mock( + return_value=braille_presenter_instance, + ) + + live_region_presenter_mock = essential_modules["cthulhu.live_region_presenter"] + live_region_instance = test_context.Mock() + live_region_instance.flush_messages = test_context.Mock() + live_region_presenter_mock.get_presenter = test_context.Mock( + return_value=live_region_instance, + ) + + sound_presenter_mock = essential_modules["cthulhu.sound_presenter"] + sound_presenter_instance = test_context.Mock() + sound_presenter_instance.play = test_context.Mock() + sound_presenter_mock.get_presenter = test_context.Mock( + return_value=sound_presenter_instance, + ) + + speech_manager_mock = essential_modules["cthulhu.speech_manager"] + speech_manager_instance = test_context.Mock() + speech_manager_instance.interrupt_speech = test_context.Mock() + speech_manager_instance.start_speech = test_context.Mock() + speech_manager_instance.shutdown_speech = test_context.Mock() + speech_manager_instance.refresh_speech = test_context.Mock() + speech_manager_instance.get_speech_is_enabled_and_not_muted = test_context.Mock( + return_value=True, + ) + speech_manager_instance.get_speech_is_muted = test_context.Mock(return_value=False) + speech_manager_mock.get_manager = test_context.Mock(return_value=speech_manager_instance) + + speech_presenter_mock = essential_modules["cthulhu.speech_presenter"] + speech_presenter_instance = test_context.Mock() + speech_presenter_instance.present_key_event = test_context.Mock() + speech_presenter_instance.speak_message = test_context.Mock() + speech_presenter_instance.speak_contents = test_context.Mock() + speech_presenter_instance.speak_character = test_context.Mock() + speech_presenter_instance.spell_item = test_context.Mock() + speech_presenter_instance.spell_phonetically = test_context.Mock() + speech_presenter_instance.get_messages_are_detailed = test_context.Mock(return_value=True) + speech_presenter_mock.get_presenter = test_context.Mock( + return_value=speech_presenter_instance, + ) + + typing_echo_presenter_mock = essential_modules["cthulhu.typing_echo_presenter"] + typing_echo_instance = test_context.Mock() + typing_echo_instance.echo_keyboard_event = test_context.Mock() + typing_echo_presenter_mock.get_presenter = test_context.Mock( + return_value=typing_echo_instance, + ) + + script_manager_mock = essential_modules["cthulhu.script_manager"] + mock_script = test_context.Mock() + speech_gen = test_context.Mock() + speech_gen.voice = test_context.Mock(return_value=[{"family": "default"}]) + speech_gen.generate_contents = test_context.Mock(return_value=["generated speech"]) + mock_script.get_speech_generator = test_context.Mock(return_value=speech_gen) + + braille_gen = test_context.Mock() + braille_gen.generate_contents = test_context.Mock( + return_value=([test_context.Mock()], test_context.Mock()), + ) + mock_script.get_braille_generator = test_context.Mock(return_value=braille_gen) + script_manager_instance = test_context.Mock() + script_manager_instance.get_active_script = test_context.Mock(return_value=mock_script) + script_manager_mock.get_manager = test_context.Mock(return_value=script_manager_instance) + + return essential_modules + + def test_get_manager_returns_singleton(self, test_context: CthulhuTestContext) -> None: + """Test get_manager returns the same instance.""" + + self._setup_dependencies(test_context) + from cthulhu.presentation_manager import get_manager + + manager1 = get_manager() + manager2 = get_manager() + + assert manager1 is manager2 + assert manager1.__class__.__name__ == "PresentationManager" + + def test_interrupt_presentation(self, test_context: CthulhuTestContext) -> None: + """Test interrupt_presentation interrupts speech and braille.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.presentation_manager import get_manager + + manager = get_manager() + manager.interrupt_presentation() + + speech_manager = essential_modules["cthulhu.speech_manager"].get_manager() + speech_manager.interrupt_speech.assert_called_once() + braille_presenter = essential_modules["cthulhu.braille_presenter"].get_presenter() + braille_presenter.kill_flash.assert_called_once() + live_region = essential_modules["cthulhu.live_region_presenter"].get_presenter() + live_region.flush_messages.assert_called_once() + + def test_interrupt_presentation_without_kill_flash(self, test_context: CthulhuTestContext) -> None: + """Test interrupt_presentation with kill_flash=False.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.presentation_manager import get_manager + + manager = get_manager() + manager.interrupt_presentation(kill_flash=False) + + speech_manager = essential_modules["cthulhu.speech_manager"].get_manager() + speech_manager.interrupt_speech.assert_called_once() + essential_modules["cthulhu.braille"].killFlash.assert_not_called() + + def test_present_keyboard_event(self, test_context: CthulhuTestContext) -> None: + """Test present_keyboard_event delegates to typing_echo_presenter.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.presentation_manager import get_manager + + manager = get_manager() + mock_event = test_context.Mock() + + manager.present_keyboard_event(mock_event) + + typing_echo = essential_modules["cthulhu.typing_echo_presenter"].get_presenter() + typing_echo.echo_keyboard_event.assert_called_once_with(mock_event) + + def test_present_key_event_delegates(self, test_context: CthulhuTestContext) -> None: + """Test present_key_event delegates to speech_presenter.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.presentation_manager import get_manager + + manager = get_manager() + mock_event = test_context.Mock() + mock_event.get_key_name.return_value = "BackSpace" + manager.present_key_event(mock_event) + + speech_pres = essential_modules["cthulhu.speech_presenter"].get_presenter() + speech_pres.present_key_event.assert_called_once_with(mock_event) + + def test_present_message_delegates_speech(self, test_context: CthulhuTestContext) -> None: + """Test present_message delegates speech to speech_presenter.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.presentation_manager import get_manager + + manager = get_manager() + manager.present_message("This is a full message") + + speech_pres = essential_modules["cthulhu.speech_presenter"].get_presenter() + speech_pres.speak_message.assert_called_once_with( + "This is a full message", + ) + braille_presenter = essential_modules["cthulhu.braille_presenter"].get_presenter() + braille_presenter.present_message.assert_called() + + def test_present_message_with_brief(self, test_context: CthulhuTestContext) -> None: + """Test present_message uses full when messages_are_detailed is True.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.presentation_manager import get_manager + + manager = get_manager() + manager.present_message("Full message", brief="Brief") + + speech_pres = essential_modules["cthulhu.speech_presenter"].get_presenter() + speech_pres.speak_message.assert_called_once_with( + "Full message", + ) + + def test_present_message_empty_string(self, test_context: CthulhuTestContext) -> None: + """Test present_message with empty string returns early.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.presentation_manager import get_manager + + manager = get_manager() + manager.present_message("") + + speech_pres = essential_modules["cthulhu.speech_presenter"].get_presenter() + speech_pres.speak_message.assert_not_called() + essential_modules["cthulhu.braille"].displayMessage.assert_not_called() + + def test_present_message_braille_disabled(self, test_context: CthulhuTestContext) -> None: + """Test present_message when braille is disabled.""" + + essential_modules = self._setup_dependencies(test_context) + braille_presenter = essential_modules["cthulhu.braille_presenter"].get_presenter() + braille_presenter.use_braille.return_value = False + + from cthulhu.presentation_manager import get_manager + + manager = get_manager() + manager.present_message("Test message") + + speech_pres = essential_modules["cthulhu.speech_presenter"].get_presenter() + speech_pres.speak_message.assert_called() + essential_modules["cthulhu.braille"].displayMessage.assert_not_called() + + def test_present_message_uses_brief(self, test_context: CthulhuTestContext) -> None: + """Test present_message uses brief when messages_are_detailed is False.""" + + essential_modules = self._setup_dependencies(test_context) + speech_pres = essential_modules["cthulhu.speech_presenter"].get_presenter() + speech_pres.get_messages_are_detailed.return_value = False + + from cthulhu.presentation_manager import get_manager + + manager = get_manager() + manager.present_message("Full message", brief="Brief") + + speech_pres.speak_message.assert_called_once_with( + "Brief", + ) + + def test_present_message_speech_disabled(self, test_context: CthulhuTestContext) -> None: + """Test present_message skips speech when speech is disabled.""" + + essential_modules = self._setup_dependencies(test_context) + speech_mgr = essential_modules["cthulhu.speech_manager"].get_manager() + speech_mgr.get_speech_is_enabled_and_not_muted.return_value = False + + from cthulhu.presentation_manager import get_manager + + manager = get_manager() + manager.present_message("Test message") + + speech_pres = essential_modules["cthulhu.speech_presenter"].get_presenter() + speech_pres.speak_message.assert_not_called() + + def test_speak_message_muted(self, test_context: CthulhuTestContext) -> None: + """Test speak_message skips speech when muted.""" + + essential_modules = self._setup_dependencies(test_context) + speech_mgr = essential_modules["cthulhu.speech_manager"].get_manager() + speech_mgr.get_speech_is_muted.return_value = True + + from cthulhu.presentation_manager import get_manager + + manager = get_manager() + manager.speak_message("Hello world") + + speech_pres = essential_modules["cthulhu.speech_presenter"].get_presenter() + speech_pres.speak_message.assert_not_called() + + def test_speak_accessible_text_muted(self, test_context: CthulhuTestContext) -> None: + """Test speak_accessible_text skips speech when muted.""" + + essential_modules = self._setup_dependencies(test_context) + speech_mgr = essential_modules["cthulhu.speech_manager"].get_manager() + speech_mgr.get_speech_is_muted.return_value = True + + from cthulhu.presentation_manager import get_manager + + manager = get_manager() + mock_obj = test_context.Mock() + manager.speak_accessible_text(mock_obj, "Hello") + + speech_pres = essential_modules["cthulhu.speech_presenter"].get_presenter() + speech_pres.speak_accessible_text.assert_not_called() + + def test_play_sound(self, test_context: CthulhuTestContext) -> None: + """Test play_sound delegates to sound_presenter.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.presentation_manager import PresentationManager + + mock_sound = test_context.Mock() + PresentationManager.play_sound(mock_sound) + + sound_presenter = essential_modules["cthulhu.sound_presenter"].get_presenter() + sound_presenter.play.assert_called_once_with(mock_sound, True) + + def test_play_sound_no_interrupt(self, test_context: CthulhuTestContext) -> None: + """Test play_sound with interrupt=False.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.presentation_manager import PresentationManager + + mock_sound = test_context.Mock() + PresentationManager.play_sound(mock_sound, interrupt=False) + + sound_presenter = essential_modules["cthulhu.sound_presenter"].get_presenter() + sound_presenter.play.assert_called_once_with(mock_sound, False) + + def test_present_braille_message(self, test_context: CthulhuTestContext) -> None: + """Test present_braille_message shows braille message.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.presentation_manager import PresentationManager + + PresentationManager.present_braille_message("Test braille", restore_previous=False) + + braille_presenter = essential_modules["cthulhu.braille_presenter"].get_presenter() + braille_presenter.present_message.assert_called_once_with( + "Test braille", + restore_previous=False, + ) + + def test_present_braille_message_braille_disabled(self, test_context: CthulhuTestContext) -> None: + """Test present_braille_message delegates even when braille is disabled.""" + + essential_modules = self._setup_dependencies(test_context) + braille_presenter_instance = essential_modules["cthulhu.braille_presenter"].get_presenter() + braille_presenter_instance.use_braille.return_value = False + + from cthulhu.presentation_manager import PresentationManager + + PresentationManager.present_braille_message("Test braille") + + braille_presenter_instance.present_message.assert_called_once() + + def test_spell_item_delegates(self, test_context: CthulhuTestContext) -> None: + """Test spell_item delegates to speech_presenter.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.presentation_manager import get_manager + + manager = get_manager() + manager.spell_item("abc") + + speech_pres = essential_modules["cthulhu.speech_presenter"].get_presenter() + speech_pres.spell_item.assert_called_once_with("abc") + + def test_spell_phonetically_delegates(self, test_context: CthulhuTestContext) -> None: + """Test spell_phonetically delegates to speech_presenter.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.presentation_manager import get_manager + + manager = get_manager() + manager.spell_phonetically("ab") + + speech_pres = essential_modules["cthulhu.speech_presenter"].get_presenter() + speech_pres.spell_phonetically.assert_called_once_with("ab") + + def test_speak_character_delegates(self, test_context: CthulhuTestContext) -> None: + """Test speak_character delegates to speech_presenter.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.presentation_manager import get_manager + + manager = get_manager() + manager.speak_character("a") + + speech_pres = essential_modules["cthulhu.speech_presenter"].get_presenter() + speech_pres.speak_character.assert_called_once_with( + "a", + voice_from="a", + cap_style=None, + obj=None, + ) + + def test_speak_message_delegates(self, test_context: CthulhuTestContext) -> None: + """Test speak_message delegates to speech_presenter.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.presentation_manager import get_manager + + manager = get_manager() + manager.speak_message("Hello world") + + speech_pres = essential_modules["cthulhu.speech_presenter"].get_presenter() + speech_pres.speak_message.assert_called_once_with( + "Hello world", + ) + + def test_speak_contents_delegates(self, test_context: CthulhuTestContext) -> None: + """Test speak_contents delegates to speech_presenter.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.presentation_manager import get_manager + + manager = get_manager() + mock_contents = [(test_context.Mock(), 0, 10, "test text")] + manager.speak_contents(mock_contents) + + speech_pres = essential_modules["cthulhu.speech_presenter"].get_presenter() + speech_pres.speak_contents.assert_called_once_with(mock_contents) + + def test_speak_contents_with_kwargs_delegates(self, test_context: CthulhuTestContext) -> None: + """Test speak_contents passes keyword arguments to speech_presenter.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.presentation_manager import get_manager + + manager = get_manager() + mock_contents = [(test_context.Mock(), 0, 10, "test text")] + mock_prior = test_context.Mock() + manager.speak_contents(mock_contents, priorObj=mock_prior) + + speech_pres = essential_modules["cthulhu.speech_presenter"].get_presenter() + speech_pres.speak_contents.assert_called_once_with(mock_contents, priorObj=mock_prior) + + def test_display_contents(self, test_context: CthulhuTestContext) -> None: + """Test display_contents delegates to braille presenter.""" + + essential_modules = self._setup_dependencies(test_context) + + script_manager = essential_modules["cthulhu.script_manager"].get_manager() + script = script_manager.get_active_script() + + from cthulhu.presentation_manager import get_manager + + manager = get_manager() + mock_contents = [(test_context.Mock(), 0, 10, "test text")] + manager.display_contents(mock_contents) + + braille_presenter_instance = essential_modules["cthulhu.braille_presenter"].get_presenter() + braille_presenter_instance.display_generated_contents.assert_called_once_with( + script, + mock_contents, + ) + + def test_display_contents_braille_disabled(self, test_context: CthulhuTestContext) -> None: + """Test display_contents still delegates to braille presenter (which checks internally).""" + + essential_modules = self._setup_dependencies(test_context) + + from cthulhu.presentation_manager import get_manager + + manager = get_manager() + mock_contents = [(test_context.Mock(), 0, 10, "test text")] + manager.display_contents(mock_contents) + + braille_presenter_instance = essential_modules["cthulhu.braille_presenter"].get_presenter() + braille_presenter_instance.display_generated_contents.assert_called_once() + + def test_display_contents_no_active_script(self, test_context: CthulhuTestContext) -> None: + """Test display_contents returns early when no active script.""" + + essential_modules = self._setup_dependencies(test_context) + script_manager = essential_modules["cthulhu.script_manager"].get_manager() + script_manager.get_active_script.return_value = None + + from cthulhu.presentation_manager import get_manager + + manager = get_manager() + mock_contents = [(test_context.Mock(), 0, 10, "test text")] + manager.display_contents(mock_contents) + + braille_presenter_instance = essential_modules["cthulhu.braille_presenter"].get_presenter() + braille_presenter_instance.display_generated_contents.assert_not_called() + + def test_display_contents_empty_regions(self, test_context: CthulhuTestContext) -> None: + """Test display_contents delegates even with empty contents.""" + + essential_modules = self._setup_dependencies(test_context) + + from cthulhu.presentation_manager import get_manager + + manager = get_manager() + mock_contents: list = [] + manager.display_contents(mock_contents) + + braille_presenter_instance = essential_modules["cthulhu.braille_presenter"].get_presenter() + braille_presenter_instance.display_generated_contents.assert_called_once() + + @pytest.mark.parametrize( + "flash_enabled,use_braille,expected_flash_called", + [ + pytest.param(True, True, True, id="flash_and_braille_enabled"), + pytest.param(False, True, False, id="flash_disabled"), + pytest.param(True, False, False, id="braille_disabled"), + pytest.param(False, False, False, id="both_disabled"), + ], + ) + def test_present_message_braille_conditions( + self, + test_context: CthulhuTestContext, + flash_enabled: bool, + use_braille: bool, + expected_flash_called: bool, + ) -> None: + """Test present_message braille behavior under various conditions.""" + + essential_modules = self._setup_dependencies(test_context) + braille_presenter_instance = essential_modules["cthulhu.braille_presenter"].get_presenter() + braille_presenter_instance.use_braille.return_value = use_braille + braille_presenter_instance.get_flash_messages_are_enabled.return_value = flash_enabled + + from cthulhu.presentation_manager import get_manager + + manager = get_manager() + manager.present_message("Test message") + + if expected_flash_called: + braille_presenter_instance.present_message.assert_called() + else: + braille_presenter_instance.present_message.assert_not_called() diff --git a/tests/test_profile_manager.py b/tests/test_profile_manager.py new file mode 100644 index 0000000..c9edaf6 --- /dev/null +++ b/tests/test_profile_manager.py @@ -0,0 +1,501 @@ +# Unit tests for profile_manager.py methods. +# +# Copyright 2025-2026 Igalia, S.L. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +# pylint: disable=wrong-import-position +# pylint: disable=import-outside-toplevel +# pylint: disable=protected-access + +"""Unit tests for profile_manager.py methods.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import gi + +gi.require_version("Gtk", "3.0") + +import pytest + +if TYPE_CHECKING: + from unittest.mock import MagicMock + + from cthulhu_test_context import CthulhuTestContext + + +@pytest.mark.unit +class TestProfileManager: + """Test ProfileManager methods.""" + + def _setup_dependencies(self, test_context: CthulhuTestContext) -> dict[str, MagicMock]: + """Set up mocks for profile_manager dependencies.""" + + additional_modules = [ + "cthulhu.braille", + "cthulhu.cthulhu", + "cthulhu.cthulhu_modifier_manager", + "cthulhu.speech_manager", + "cthulhu.braille_presenter", + "cthulhu.presentation_manager", + ] + essential_modules = test_context.setup_shared_dependencies(additional_modules) + + from cthulhu import gsettings_registry + + registry = gsettings_registry.get_registry() + registry.set_active_profile("default") + + test_context.patch( + "cthulhu.profile_manager.ProfileManager._get_stored_profiles", + return_value=[ + ["Default", "default"], + ["Spanish", "spanish"], + ["Work", "work"], + ], + ) + + speech_manager_mock = essential_modules["cthulhu.speech_manager"] + speech_manager_mock.get_manager.return_value.refresh_speech.return_value = None + + braille_mock = essential_modules["cthulhu.braille"] + braille_mock.check_braille_setting.return_value = None + + cthulhu_mock = essential_modules["cthulhu.cthulhu"] + cthulhu_mock.load_user_settings.return_value = None + + messages_mock = essential_modules["cthulhu.messages"] + messages_mock.PROFILE_NOT_FOUND = "No profiles found." + messages_mock.PROFILE_CHANGED = "Profile set to %s." + messages_mock.PROFILE_CURRENT = "Current profile is %s." + + guilabels_mock = essential_modules["cthulhu.guilabels"] + guilabels_mock.PROFILE_DEFAULT = "Default" + + return essential_modules + + def test_get_active_profile(self, test_context: CthulhuTestContext) -> None: + """Test getting active profile.""" + + self._setup_dependencies(test_context) + from cthulhu.profile_manager import ProfileManager + + manager = ProfileManager() + profile = manager.get_active_profile() + + assert profile == "default" + + def test_set_active_profile(self, test_context: CthulhuTestContext) -> None: + """Test setting active profile.""" + + self._setup_dependencies(test_context) + from cthulhu import gsettings_registry + from cthulhu.profile_manager import ProfileManager + + manager = ProfileManager() + manager.set_active_profile("spanish") + + assert gsettings_registry.get_registry().get_active_profile() == "spanish" + + def test_load_profile(self, test_context: CthulhuTestContext) -> None: + """Test loading a profile calls set_active_profile and load_user_settings.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu import gsettings_registry + from cthulhu.profile_manager import ProfileManager + + manager = ProfileManager() + manager.load_profile("spanish") + + assert gsettings_registry.get_registry().get_active_profile() == "spanish" + essential_modules["cthulhu.cthulhu"].load_user_settings.assert_called_once_with( + skip_reload_message=True, + ) + + def test_remove_profile(self, test_context: CthulhuTestContext) -> None: + """Test removing a profile.""" + + self._setup_dependencies(test_context) + from cthulhu.profile_manager import ProfileManager + + mock_run = test_context.patch("subprocess.run") + manager = ProfileManager() + manager.remove_profile("spanish") + + mock_run.assert_called_once_with( + ["dconf", "reset", "-f", "/org/gnome/cthulhu/spanish/"], + check=True, + ) + + def test_remove_profile_dconf_failure(self, test_context: CthulhuTestContext) -> None: + """Test removing a profile when dconf reset fails.""" + + import subprocess + + self._setup_dependencies(test_context) + from cthulhu.profile_manager import ProfileManager + + mock_run = test_context.patch("subprocess.run") + mock_run.side_effect = subprocess.CalledProcessError(1, "dconf") + manager = ProfileManager() + manager.remove_profile("spanish") + + mock_run.assert_called_once() + + def test_remove_profile_dconf_not_found(self, test_context: CthulhuTestContext) -> None: + """Test removing a profile when dconf is not installed.""" + + self._setup_dependencies(test_context) + from cthulhu.profile_manager import ProfileManager + + mock_run = test_context.patch("subprocess.run") + mock_run.side_effect = FileNotFoundError("dconf not found") + manager = ProfileManager() + manager.remove_profile("spanish") + + mock_run.assert_called_once() + + def test_remove_profile_sanitizes_name(self, test_context: CthulhuTestContext) -> None: + """Test removing a profile sanitizes the name for the dconf path.""" + + self._setup_dependencies(test_context) + from cthulhu.profile_manager import ProfileManager + + mock_run = test_context.patch("subprocess.run") + manager = ProfileManager() + manager.remove_profile("My Profile") + + mock_run.assert_called_once_with( + ["dconf", "reset", "-f", "/org/gnome/cthulhu/my-profile/"], + check=True, + ) + + def test_rename_profile(self, test_context: CthulhuTestContext) -> None: + """Test renaming a profile.""" + + self._setup_dependencies(test_context) + from cthulhu import gsettings_registry + from cthulhu.profile_manager import ProfileManager + + registry = gsettings_registry.get_registry() + mock_rename = test_context.patch_object(registry, "rename_profile") + + manager = ProfileManager() + manager.rename_profile("spanish", ["Espanol", "espanol"]) + + mock_rename.assert_called_once_with("spanish", "Espanol", "espanol") + + def test_commands_registered(self, test_context: CthulhuTestContext) -> None: + """Test that profile manager commands are registered with CommandManager.""" + + self._setup_dependencies(test_context) + from cthulhu import command_manager + from cthulhu.profile_manager import ProfileManager + + manager = ProfileManager() + manager.set_up_commands() + cmd_manager = command_manager.get_manager() + + assert cmd_manager.get_keyboard_command("cycleSettingsProfileHandler") is not None + assert cmd_manager.get_keyboard_command("presentCurrentProfileHandler") is not None + + def test_cycle_settings_profile_cycles_to_next(self, test_context: CthulhuTestContext) -> None: + """Test cycle_settings_profile cycles to next profile.""" + + essential_modules = self._setup_dependencies(test_context) + from unittest.mock import MagicMock + + from cthulhu import gsettings_registry + from cthulhu.profile_manager import ProfileManager + + manager = ProfileManager() + mock_script = MagicMock() + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + + result = manager.cycle_settings_profile(script=mock_script) + + assert result is True + assert gsettings_registry.get_registry().get_active_profile() == "spanish" + pres_manager.present_message.assert_called() + + def test_cycle_settings_profile_wraps_around(self, test_context: CthulhuTestContext) -> None: + """Test cycle_settings_profile wraps to first profile at end.""" + + self._setup_dependencies(test_context) + from unittest.mock import MagicMock + + from cthulhu import gsettings_registry + from cthulhu.profile_manager import ProfileManager + + gsettings_registry.get_registry().set_active_profile("work") + + manager = ProfileManager() + mock_script = MagicMock() + + result = manager.cycle_settings_profile(script=mock_script) + + assert result is True + assert gsettings_registry.get_registry().get_active_profile() == "default" + + def test_cycle_settings_profile_no_profiles(self, test_context: CthulhuTestContext) -> None: + """Test cycle_settings_profile handles no profiles.""" + + essential_modules = self._setup_dependencies(test_context) + from unittest.mock import MagicMock + + from cthulhu.profile_manager import ProfileManager + + manager = ProfileManager() + mock_script = MagicMock() + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + + result = manager.cycle_settings_profile(script=mock_script) + + assert result is True + pres_manager.present_message.assert_called() + + def test_present_current_profile(self, test_context: CthulhuTestContext) -> None: + """Test present_current_profile presents the current profile name.""" + + essential_modules = self._setup_dependencies(test_context) + from unittest.mock import MagicMock + + from cthulhu.profile_manager import ProfileManager + + manager = ProfileManager() + mock_script = MagicMock() + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + + result = manager.present_current_profile(script=mock_script) + + assert result is True + pres_manager.present_message.assert_called() + call_args = pres_manager.present_message.call_args + assert "Default" in call_args[0][0] + + +@pytest.mark.unit +class TestProfilePreferencesGridUI: + """Test ProfilePreferencesGrid UI creation.""" + + def _setup_dependencies(self, test_context: CthulhuTestContext) -> dict[str, MagicMock]: + """Set up mocks for ProfilePreferencesGrid dependencies.""" + + additional_modules = [ + "cthulhu.braille", + "cthulhu.cthulhu", + "cthulhu.speech_manager", + "cthulhu.braille_presenter", + "cthulhu.presentation_manager", + ] + essential_modules = test_context.setup_shared_dependencies(additional_modules) + + from cthulhu import gsettings_registry + + registry = gsettings_registry.get_registry() + registry.set_active_profile("default") + + test_context.patch( + "cthulhu.profile_manager.ProfileManager._get_stored_profiles", + return_value=[ + ["Default", "default"], + ["Spanish", "spanish"], + ], + ) + + guilabels_mock = essential_modules["cthulhu.guilabels"] + guilabels_mock.GENERAL_PROFILES = "Profiles" + guilabels_mock.GENERAL_START_UP_PROFILE = "Start-up profile" + guilabels_mock.PROFILE_DEFAULT = "Default" + guilabels_mock.PROFILE_CONFLICT_MESSAGE = "Profile %s already exists" + guilabels_mock.PROFILE_REMOVE_LABEL = "Remove Profile" + guilabels_mock.PROFILE_REMOVE_MESSAGE = "Remove profile %s?" + guilabels_mock.MENU_REMOVE_PROFILE = "Remove" + guilabels_mock.MENU_RENAME = "Rename" + guilabels_mock.PROFILE_SAVE_AS_TITLE = "Save Profile As" + guilabels_mock.PROFILE_NAME_LABEL = "Profile name:" + guilabels_mock.DIALOG_CANCEL = "Cancel" + guilabels_mock.DIALOG_APPLY = "Apply" + guilabels_mock.DIALOG_ADD = "Add" + guilabels_mock.PROFILES_INFO = "Select a profile to edit or create a new one." + guilabels_mock.CURRENT_PROFILE = "Current Profile" + guilabels_mock.PROFILE_CREATE_NEW = "_Create New Profile" + + return essential_modules + + def test_grid_creates_successfully(self, test_context: CthulhuTestContext) -> None: + """Test ProfilePreferencesGrid creates without error.""" + + from gi.repository import Gtk # pylint: disable=no-name-in-module + + self._setup_dependencies(test_context) + from cthulhu.profile_manager import ProfileManager, ProfilePreferencesGrid + + manager = ProfileManager() + + def callback(_profile): + return None + + grid = ProfilePreferencesGrid(manager, callback) + + assert isinstance(grid, Gtk.Grid) + + def test_grid_has_auto_grid(self, test_context: CthulhuTestContext) -> None: + """Test ProfilePreferencesGrid has auto_grid with controls.""" + + self._setup_dependencies(test_context) + from cthulhu.profile_manager import ProfileManager, ProfilePreferencesGrid + + manager = ProfileManager() + + def callback(_profile): + return None + + grid = ProfilePreferencesGrid(manager, callback) + + assert grid._auto_grid is not None + + def test_grid_save_settings_returns_dict(self, test_context: CthulhuTestContext) -> None: + """Test save_settings returns a dictionary.""" + + self._setup_dependencies(test_context) + from cthulhu.profile_manager import ProfileManager, ProfilePreferencesGrid + + manager = ProfileManager() + + def callback(_profile): + return None + + grid = ProfilePreferencesGrid(manager, callback) + + result = grid.save_settings() + + assert isinstance(result, dict) + + def test_grid_has_changes_initially_false(self, test_context: CthulhuTestContext) -> None: + """Test has_changes returns False initially.""" + + self._setup_dependencies(test_context) + from cthulhu.profile_manager import ProfileManager, ProfilePreferencesGrid + + manager = ProfileManager() + + def callback(_profile): + return None + + grid = ProfilePreferencesGrid(manager, callback) + + assert grid.has_changes() is False + + def test_grid_reload_clears_pending_renames(self, test_context: CthulhuTestContext) -> None: + """Test reload clears pending renames.""" + + self._setup_dependencies(test_context) + from cthulhu.profile_manager import ProfileManager, ProfilePreferencesGrid + + manager = ProfileManager() + + def callback(_profile): + return None + + grid = ProfilePreferencesGrid(manager, callback) + + grid._pending_renames["old"] = ["New", "new"] + assert len(grid._pending_renames) == 1 + + grid.reload() + + assert len(grid._pending_renames) == 0 + + def test_grid_app_specific_disables_startup_setter(self, test_context: CthulhuTestContext) -> None: + """Test app-specific grid disables startup profile setter.""" + + self._setup_dependencies(test_context) + from cthulhu.profile_manager import ProfileManager, ProfilePreferencesGrid + + manager = ProfileManager() + + def callback(_profile): + return None + + grid = ProfilePreferencesGrid(manager, callback, is_app_specific=True) + + assert grid._is_app_specific is True + + def test_validate_profile_name_detects_conflict(self, test_context: CthulhuTestContext) -> None: + """Test _validate_profile_name detects existing profile names.""" + + self._setup_dependencies(test_context) + from cthulhu.profile_manager import ProfileManager, ProfilePreferencesGrid + + manager = ProfileManager() + + def callback(_profile): + return None + + grid = ProfilePreferencesGrid(manager, callback) + + is_valid, error_msg = grid._validate_profile_name("Default") + + assert is_valid is False + assert "Default" in error_msg + + def test_validate_profile_name_allows_unique(self, test_context: CthulhuTestContext) -> None: + """Test _validate_profile_name allows unique names.""" + + self._setup_dependencies(test_context) + from cthulhu.profile_manager import ProfileManager, ProfilePreferencesGrid + + manager = ProfileManager() + + def callback(_profile): + return None + + grid = ProfilePreferencesGrid(manager, callback) + + is_valid, error_msg = grid._validate_profile_name("NewProfile") + + assert is_valid is True + assert error_msg == "" + + def test_get_available_profiles_includes_pending_renames( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test _get_available_profiles includes pending renames.""" + + self._setup_dependencies(test_context) + from cthulhu.profile_manager import ProfileManager, ProfilePreferencesGrid + + manager = ProfileManager() + + def callback(_profile): + return None + + grid = ProfilePreferencesGrid(manager, callback) + + grid._pending_renames["spanish"] = ["Espanol", "spanish"] + profiles = grid._get_available_profiles() + + profile_names = [p[0] for p in profiles] + assert "Espanol" in profile_names + assert "Spanish" not in profile_names diff --git a/tests/test_pronunciation_dictionary_manager.py b/tests/test_pronunciation_dictionary_manager.py new file mode 100644 index 0000000..6e44147 --- /dev/null +++ b/tests/test_pronunciation_dictionary_manager.py @@ -0,0 +1,278 @@ +# Unit tests for pronunciation_dictionary_manager.py. +# +# Copyright 2026 Igalia, S.L. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +# pylint: disable=wrong-import-position +# pylint: disable=protected-access +# pylint: disable=import-outside-toplevel + +"""Unit tests for pronunciation_dictionary_manager.py.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from cthulhu_test_context import CthulhuTestContext + + +@pytest.mark.unit +class TestPronunciationDictionaryManager: + """Test PronunciationDictionaryManager class.""" + + def _setup_manager(self, test_context: CthulhuTestContext): + """Set up mocks for pronunciation_dictionary_manager dependencies.""" + + additional_modules = [ + "cthulhu.guilabels", + "cthulhu.messages", + "cthulhu.preferences_grid_base", + "cthulhu.script_manager", + "cthulhu.speech_manager", + ] + essential_modules = test_context.setup_shared_dependencies(additional_modules) + + # Set up guilabels with required constants + guilabels = essential_modules["cthulhu.guilabels"] + guilabels.PRONUNCIATION = "Pronunciation" + guilabels.PRONUNCIATION_DICTIONARY = "Pronunciation Dictionary" + guilabels.PRONUNCIATION_DICTIONARY_INFO = "Add custom pronunciations." + guilabels.DICTIONARY_NEW_ENTRY = "New Entry" + guilabels.DICTIONARY_DELETE = "Delete" + guilabels.DICTIONARY_EMPTY = "No entries" + guilabels.DICTIONARY_ACTUAL_STRING = "Actual String" + guilabels.DICTIONARY_REPLACEMENT_STRING = "Replacement String" + guilabels.ADD_NEW_PRONUNCIATION = "Add New Pronunciation" + guilabels.EDIT_PRONUNCIATION = "Edit Pronunciation" + guilabels.DIALOG_CANCEL = "Cancel" + guilabels.DIALOG_ADD = "Add" + guilabels.DIALOG_EDIT = "Edit" + + # Set up messages + messages = essential_modules["cthulhu.messages"] + messages.PRONUNCIATION_DELETED = "Pronunciation %s deleted" + + # Set up speech_manager + speech_manager_mock = essential_modules["cthulhu.speech_manager"] + speech_instance = speech_manager_mock.get_manager.return_value + speech_instance.get_use_pronunciation_dictionary.return_value = True + speech_instance.set_use_pronunciation_dictionary.return_value = True + + # Import and return the module + from cthulhu import pronunciation_dictionary_manager + + return pronunciation_dictionary_manager, essential_modules + + def test_get_manager_returns_singleton(self, test_context: CthulhuTestContext) -> None: + """Test get_manager returns the same instance.""" + + module, _mocks = self._setup_manager(test_context) + manager1 = module.get_manager() + manager2 = module.get_manager() + + assert manager1 is manager2 + + def test_manager_initial_state(self, test_context: CthulhuTestContext) -> None: + """Test manager initializes with empty dictionary.""" + + module, _mocks = self._setup_manager(test_context) + manager = module.get_manager() + + # Reset the dictionary to ensure clean state + manager.set_dictionary({}) + + assert manager.get_dictionary() == {} + + def test_get_pronunciation_returns_word_when_not_found( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test get_pronunciation returns the original word when not in dictionary.""" + + module, _mocks = self._setup_manager(test_context) + manager = module.get_manager() + manager.set_dictionary({}) + + result = manager.get_pronunciation("hello") + + assert result == "hello" + + def test_get_pronunciation_returns_replacement_when_found( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test get_pronunciation returns replacement when word is in dictionary.""" + + module, _mocks = self._setup_manager(test_context) + manager = module.get_manager() + manager.set_dictionary({}) + + manager.set_pronunciation("hello", "hi there") + result = manager.get_pronunciation("hello") + + assert result == "hi there" + + def test_get_pronunciation_is_case_insensitive(self, test_context: CthulhuTestContext) -> None: + """Test get_pronunciation lookup is case insensitive.""" + + module, _mocks = self._setup_manager(test_context) + manager = module.get_manager() + manager.set_dictionary({}) + + manager.set_pronunciation("HELLO", "hi there") + + assert manager.get_pronunciation("hello") == "hi there" + assert manager.get_pronunciation("Hello") == "hi there" + assert manager.get_pronunciation("HELLO") == "hi there" + + def test_set_pronunciation_stores_lowercase_key(self, test_context: CthulhuTestContext) -> None: + """Test set_pronunciation stores key as lowercase.""" + + module, _mocks = self._setup_manager(test_context) + manager = module.get_manager() + manager.set_dictionary({}) + + manager.set_pronunciation("MixedCase", "replacement") + + dictionary = manager.get_dictionary() + assert "mixedcase" in dictionary + assert "MixedCase" not in dictionary + + def test_set_dictionary_replaces_entire_dictionary(self, test_context: CthulhuTestContext) -> None: + """Test set_dictionary replaces the entire dictionary.""" + + module, _mocks = self._setup_manager(test_context) + manager = module.get_manager() + + manager.set_pronunciation("old", "old replacement") + manager.set_dictionary({"new": "new replacement"}) + + dictionary = manager.get_dictionary() + assert "old" not in dictionary + assert dictionary.get("new") == "new replacement" + + def test_multiple_pronunciations(self, test_context: CthulhuTestContext) -> None: + """Test manager handles multiple pronunciations correctly.""" + + module, _mocks = self._setup_manager(test_context) + manager = module.get_manager() + manager.set_dictionary({}) + + manager.set_pronunciation("word1", "replacement1") + manager.set_pronunciation("word2", "replacement2") + manager.set_pronunciation("word3", "replacement3") + + assert manager.get_pronunciation("word1") == "replacement1" + assert manager.get_pronunciation("word2") == "replacement2" + assert manager.get_pronunciation("word3") == "replacement3" + assert len(manager.get_dictionary()) == 3 + + def test_set_pronunciation_overwrites_existing(self, test_context: CthulhuTestContext) -> None: + """Test set_pronunciation overwrites existing entry for same word.""" + + module, _mocks = self._setup_manager(test_context) + manager = module.get_manager() + manager.set_dictionary({}) + + manager.set_pronunciation("word", "first replacement") + manager.set_pronunciation("word", "second replacement") + + assert manager.get_pronunciation("word") == "second replacement" + assert len(manager.get_dictionary()) == 1 + + +@pytest.mark.unit +class TestPronunciationDictionaryManagerIntegration: + """Integration tests for pronunciation dictionary manager.""" + + def _setup_manager(self, test_context: CthulhuTestContext): + """Set up mocks for pronunciation_dictionary_manager dependencies.""" + + additional_modules = [ + "cthulhu.guilabels", + "cthulhu.messages", + "cthulhu.preferences_grid_base", + "cthulhu.script_manager", + "cthulhu.speech_manager", + ] + essential_modules = test_context.setup_shared_dependencies(additional_modules) + + # Set up guilabels with required constants + guilabels = essential_modules["cthulhu.guilabels"] + guilabels.PRONUNCIATION = "Pronunciation" + guilabels.PRONUNCIATION_DICTIONARY = "Pronunciation Dictionary" + guilabels.PRONUNCIATION_DICTIONARY_INFO = "Add custom pronunciations." + guilabels.DICTIONARY_NEW_ENTRY = "New Entry" + guilabels.DICTIONARY_DELETE = "Delete" + guilabels.DICTIONARY_EMPTY = "No entries" + guilabels.DICTIONARY_ACTUAL_STRING = "Actual String" + guilabels.DICTIONARY_REPLACEMENT_STRING = "Replacement String" + guilabels.ADD_NEW_PRONUNCIATION = "Add New Pronunciation" + guilabels.EDIT_PRONUNCIATION = "Edit Pronunciation" + guilabels.DIALOG_CANCEL = "Cancel" + guilabels.DIALOG_ADD = "Add" + guilabels.DIALOG_EDIT = "Edit" + + # Set up messages + messages = essential_modules["cthulhu.messages"] + messages.PRONUNCIATION_DELETED = "Pronunciation %s deleted" + + # Set up speech_manager + speech_manager_mock = essential_modules["cthulhu.speech_manager"] + speech_instance = speech_manager_mock.get_manager.return_value + speech_instance.get_use_pronunciation_dictionary.return_value = True + speech_instance.set_use_pronunciation_dictionary.return_value = True + + # Import and return the module + from cthulhu import pronunciation_dictionary_manager + + return pronunciation_dictionary_manager, essential_modules + + def test_pronunciation_application_to_text(self, test_context: CthulhuTestContext) -> None: + """Test that pronunciation dictionary can be used to process text.""" + + module, _mocks = self._setup_manager(test_context) + manager = module.get_manager() + manager.set_dictionary({}) + + manager.set_pronunciation("github", "git hub") + manager.set_pronunciation("cli", "command line interface") + + # Simulate applying pronunciations to a sentence + words = ["Check", " ", "out", " ", "the", " ", "GitHub", " ", "CLI"] + result = "".join(map(manager.get_pronunciation, words)) + + # GitHub becomes "git hub" because lookup is case insensitive + assert "git hub" in result + # CLI becomes "command line interface" + assert "command line interface" in result + + def test_empty_dictionary_returns_original_words(self, test_context: CthulhuTestContext) -> None: + """Test that empty dictionary returns all original words unchanged.""" + + module, _mocks = self._setup_manager(test_context) + manager = module.get_manager() + manager.set_dictionary({}) + + words = ["Hello", " ", "world", "!"] + result = "".join(map(manager.get_pronunciation, words)) + + assert result == "Hello world!" diff --git a/tests/test_say_all_presenter.py b/tests/test_say_all_presenter.py new file mode 100644 index 0000000..4b02d6b --- /dev/null +++ b/tests/test_say_all_presenter.py @@ -0,0 +1,909 @@ +# Unit tests for say_all_presenter.py methods. +# +# Copyright 2025 Igalia, S.L. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +# pylint: disable=wrong-import-position +# pylint: disable=import-outside-toplevel +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-statements +# pylint: disable=protected-access +# pylint: disable=too-many-arguments +# pylint: disable=too-many-positional-arguments +# pylint: disable=too-many-locals + +"""Unit tests for say_all_presenter.py methods.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import gi +import pytest + +gi.require_version("Atspi", "2.0") +from gi.repository import Atspi + +if TYPE_CHECKING: + from cthulhu_test_context import CthulhuTestContext + + +@pytest.mark.unit +class TestSayAllPresenter: + """Test SayAllPresenter class methods.""" + + def _setup_dependencies(self, test_context: CthulhuTestContext) -> dict[str, Any]: + """Set up mocks for say_all_presenter dependencies.""" + + additional_modules = [ + "cthulhu.ax_event_synthesizer", + "cthulhu.structural_navigator", + "cthulhu.input_event", + "cthulhu.keybindings", + "cthulhu.cmdnames", + "cthulhu.guilabels", + "cthulhu.ax_object", + "cthulhu.ax_text", + "cthulhu.ax_utilities", + "cthulhu.messages", + "cthulhu.input_event_manager", + "cthulhu.object_properties", + "cthulhu.cthulhu_gui_navlist", + "cthulhu.cthulhu_i18n", + "cthulhu.AXHypertext", + "cthulhu.AXObject", + "cthulhu.speech_presenter", + "cthulhu.AXTable", + "cthulhu.AXText", + "cthulhu.AXUtilities", + "cthulhu.braille_presenter", + "cthulhu.presentation_manager", + ] + essential_modules = test_context.setup_shared_dependencies(additional_modules) + + # Set up cmdnames with all required values for structural_navigator + cmdnames = essential_modules["cthulhu.cmdnames"] + cmdnames.STRUCTURAL_NAVIGATION_MODE_CYCLE = "cycle_mode" + cmdnames.BLOCKQUOTE_PREV = "previous_blockquote" + cmdnames.BLOCKQUOTE_NEXT = "next_blockquote" + cmdnames.BLOCKQUOTE_LIST = "list_blockquotes" + cmdnames.BUTTON_PREV = "previous_button" + cmdnames.BUTTON_NEXT = "next_button" + cmdnames.BUTTON_LIST = "list_buttons" + cmdnames.CHECK_BOX_PREV = "previous_checkbox" + cmdnames.CHECK_BOX_NEXT = "next_checkbox" + cmdnames.CHECK_BOX_LIST = "list_checkboxes" + cmdnames.COMBO_BOX_PREV = "previous_combobox" + cmdnames.COMBO_BOX_NEXT = "next_combobox" + cmdnames.COMBO_BOX_LIST = "list_comboboxes" + cmdnames.ENTRY_PREV = "previous_entry" + cmdnames.ENTRY_NEXT = "next_entry" + cmdnames.ENTRY_LIST = "list_entries" + cmdnames.FORM_FIELD_PREV = "previous_form_field" + cmdnames.FORM_FIELD_NEXT = "next_form_field" + cmdnames.FORM_FIELD_LIST = "list_form_fields" + cmdnames.HEADING_PREV = "previous_heading" + cmdnames.HEADING_NEXT = "next_heading" + cmdnames.HEADING_LIST = "list_headings" + cmdnames.HEADING_AT_LEVEL_PREV = "previous_heading_level_%d" + cmdnames.HEADING_AT_LEVEL_NEXT = "next_heading_level_%d" + cmdnames.HEADING_AT_LEVEL_LIST = "list_headings_level_%d" + cmdnames.IFRAME_PREV = "previous_iframe" + cmdnames.IFRAME_NEXT = "next_iframe" + cmdnames.IFRAME_LIST = "list_iframes" + cmdnames.IMAGE_PREV = "previous_image" + cmdnames.IMAGE_NEXT = "next_image" + cmdnames.IMAGE_LIST = "list_images" + cmdnames.LANDMARK_PREV = "previous_landmark" + cmdnames.LANDMARK_NEXT = "next_landmark" + cmdnames.LANDMARK_LIST = "list_landmarks" + cmdnames.LIST_PREV = "previous_list" + cmdnames.LIST_NEXT = "next_list" + cmdnames.LIST_LIST = "list_lists" + cmdnames.LIST_ITEM_PREV = "previous_list_item" + cmdnames.LIST_ITEM_NEXT = "next_list_item" + cmdnames.LIST_ITEM_LIST = "list_list_items" + cmdnames.LIVE_REGION_PREV = "previous_live_region" + cmdnames.LIVE_REGION_NEXT = "next_live_region" + cmdnames.LIVE_REGION_LAST = "last_live_region" + cmdnames.PARAGRAPH_PREV = "previous_paragraph" + cmdnames.PARAGRAPH_NEXT = "next_paragraph" + cmdnames.PARAGRAPH_LIST = "list_paragraphs" + cmdnames.RADIO_BUTTON_PREV = "previous_radio_button" + cmdnames.RADIO_BUTTON_NEXT = "next_radio_button" + cmdnames.RADIO_BUTTON_LIST = "list_radio_buttons" + cmdnames.SEPARATOR_PREV = "previous_separator" + cmdnames.SEPARATOR_NEXT = "next_separator" + cmdnames.TABLE_PREV = "previous_table" + cmdnames.TABLE_NEXT = "next_table" + cmdnames.TABLE_LIST = "list_tables" + cmdnames.UNVISITED_LINK_PREV = "previous_unvisited_link" + cmdnames.UNVISITED_LINK_NEXT = "next_unvisited_link" + cmdnames.UNVISITED_LINK_LIST = "list_unvisited_links" + cmdnames.VISITED_LINK_PREV = "previous_visited_link" + cmdnames.VISITED_LINK_NEXT = "next_visited_link" + cmdnames.VISITED_LINK_LIST = "list_visited_links" + cmdnames.LINK_PREV = "previous_link" + cmdnames.LINK_NEXT = "next_link" + cmdnames.LINK_LIST = "list_links" + cmdnames.CLICKABLE_PREV = "previous_clickable" + cmdnames.CLICKABLE_NEXT = "next_clickable" + cmdnames.CLICKABLE_LIST = "list_clickables" + cmdnames.LARGE_OBJECT_PREV = "previous_large_object" + cmdnames.LARGE_OBJECT_NEXT = "next_large_object" + cmdnames.LARGE_OBJECT_LIST = "list_large_objects" + cmdnames.CONTAINER_START = "container_start" + cmdnames.CONTAINER_END = "container_end" + + essential_modules["cthulhu.cthulhu_i18n"]._ = lambda x: x + essential_modules["cthulhu.debug"].print_message = test_context.Mock() + essential_modules["cthulhu.debug"].LEVEL_INFO = 800 + + controller_mock = test_context.Mock() + controller_mock.register_decorated_module.return_value = None + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = controller_mock + + focus_manager_instance = test_context.Mock() + focus_manager_instance.get_locus_of_focus.return_value = None + essential_modules["cthulhu.focus_manager"].get_manager.return_value = focus_manager_instance + + essential_modules["cthulhu.AXObject"].supports_collection.return_value = True + essential_modules["cthulhu.AXUtilities"].is_heading.return_value = False + + from cthulhu import gsettings_registry + + registry = gsettings_registry.get_registry() + registry.clear_runtime_values() + + return essential_modules + + def test_say_all_should_skip_content(self, test_context: CthulhuTestContext) -> None: + """Test SayAllPresenter._say_all_should_skip_content empty content handling.""" + + self._setup_dependencies(test_context) + from cthulhu.say_all_presenter import SayAllPresenter + + presenter = SayAllPresenter() + mock_obj = test_context.Mock(spec=Atspi.Accessible) + + content = (mock_obj, 0, 0, "test text") + should_skip, reason = presenter._say_all_should_skip_content(content, []) + assert should_skip is True + assert reason == "start_offset equals end_offset" + + def test_parse_utterances(self, test_context: CthulhuTestContext) -> None: + """Test SayAllPresenter._parse_utterances with various input formats.""" + + self._setup_dependencies(test_context) + from cthulhu import speech + from cthulhu.say_all_presenter import SayAllPresenter + + presenter = SayAllPresenter() + + mock_acss = test_context.Mock(spec=speech.ACSS) + + elements, voices = presenter._parse_utterances([]) + assert len(elements) == 0 + assert len(voices) == 0 + + elements, voices = presenter._parse_utterances(["Hello world"]) + assert len(elements) == 1 + assert elements[0] == "Hello world" + + elements, voices = presenter._parse_utterances([["Hello"], ["world"]]) + assert len(elements) == 2 + assert elements[0] == "Hello" + assert elements[1] == "world" + + elements, voices = presenter._parse_utterances(["Hello", mock_acss]) + assert len(elements) == 1 + assert len(voices) == 1 + assert elements[0] == "Hello" + assert voices[0] == mock_acss + + def test_get_presenter_singleton(self, test_context: CthulhuTestContext) -> None: + """Test that get_presenter returns a singleton instance.""" + + self._setup_dependencies(test_context) + from cthulhu.say_all_presenter import get_presenter + + presenter1 = get_presenter() + presenter2 = get_presenter() + assert presenter1 is presenter2 + assert presenter1 is not None + + def test_say_all_presenter_initialization(self, test_context: CthulhuTestContext) -> None: + """Test SayAllPresenter initialization sets up required attributes.""" + + self._setup_dependencies(test_context) + from cthulhu import command_manager + from cthulhu.say_all_presenter import SayAllPresenter + + presenter = SayAllPresenter() + assert presenter is not None + + # Verify commands are registered after setup + presenter.set_up_commands() + cmd_manager = command_manager.get_manager() + assert cmd_manager.get_keyboard_command("sayAllHandler") is not None + + def test_say_all_no_object_scenario(self, test_context: CthulhuTestContext) -> None: + """Test SayAllPresenter.say_all with no focus object scenario.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.say_all_presenter import SayAllPresenter + + presenter = SayAllPresenter() + mock_script = test_context.Mock() + + focus_manager_mock = essential_modules["cthulhu.focus_manager"] + manager_instance = test_context.Mock() + focus_manager_mock.get_manager.return_value = manager_instance + manager_instance.get_locus_of_focus.return_value = None + + messages_mock = essential_modules["cthulhu.messages"] + messages_mock.LOCATION_NOT_FOUND_FULL = "Location not found" + + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + pres_manager.interrupt_presentation.reset_mock() + result = presenter.say_all(mock_script, obj=None) + assert result is True + + pres_manager.interrupt_presentation.assert_called_once() + pres_manager.present_message.assert_called_once_with("Location not found") + + @pytest.mark.parametrize( + "direction,enabled,contents_available,obj_valid,expected_result", + [ + pytest.param("rewind", False, True, True, False, id="rewind_disabled"), + pytest.param("rewind", True, False, True, True, id="rewind_no_contents_valid_context"), + pytest.param( + "rewind", + True, + False, + False, + False, + id="rewind_no_contents_invalid_context_obj", + ), + pytest.param("rewind", True, True, True, True, id="rewind_success"), + pytest.param("fast_forward", False, True, True, False, id="fast_forward_disabled"), + pytest.param( + "fast_forward", + True, + False, + True, + True, + id="fast_forward_no_contents_valid_context", + ), + pytest.param( + "fast_forward", + True, + False, + False, + False, + id="fast_forward_no_contents_invalid_context_obj", + ), + pytest.param("fast_forward", True, True, True, True, id="fast_forward_success"), + ], + ) + def test_navigation_controls( + self, + test_context: CthulhuTestContext, + direction: str, + enabled: bool, + contents_available: bool, + obj_valid: bool, + expected_result: bool, + ) -> None: + """Test _rewind and _fast_forward navigation controls with various conditions.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu import speechserver + from cthulhu.say_all_presenter import SayAllPresenter + + presenter = SayAllPresenter() + mock_script = test_context.Mock() + presenter._script = mock_script + + presenter.set_rewind_and_fast_forward_enabled(enabled) + + mock_context = test_context.Mock(spec=speechserver.SayAllContext) + mock_context.obj = "context_obj" if obj_valid else None + + if direction == "rewind": + mock_context.start_offset = 15 + else: + mock_context.end_offset = 25 + + if contents_available: + if direction == "rewind": + presenter._contents = [("content_obj", 5, 10, "text")] + else: + presenter._contents = [("first_obj", 0, 5, "first"), ("last_obj", 20, 30, "last")] + else: + presenter._contents = [] + + focus_manager_mock = essential_modules["cthulhu.focus_manager"] + focus_instance = test_context.Mock() + focus_manager_mock.get_manager.return_value = focus_instance + + mock_script.utilities.set_caret_context = test_context.Mock() + + if direction == "rewind": + mock_script.utilities.previous_context.return_value = ("prev_obj", 8) + else: + mock_script.utilities.next_context.return_value = ("next_obj", 35) + + presenter.say_all = test_context.Mock(return_value=True) + navigation_method = getattr(presenter, f"_{direction}") + result = navigation_method(mock_context) + assert result == expected_result + + if expected_result: + focus_instance.set_locus_of_focus.assert_called() + mock_script.utilities.set_caret_context.assert_called() + if direction == "rewind": + mock_script.utilities.previous_context.assert_called() + else: + mock_script.utilities.next_context.assert_called() + presenter.say_all.assert_called_once() + + @pytest.mark.parametrize( + "command_method", + [ + pytest.param("rewind", id="rewind_command"), + pytest.param("fast_forward", id="fast_forward_command"), + ], + ) + def test_dbus_navigation_commands( + self, + test_context: CthulhuTestContext, + command_method: str, + ) -> None: + """Test D-Bus navigation commands delegate to private methods.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.say_all_presenter import SayAllPresenter + + presenter = SayAllPresenter() + mock_script = test_context.Mock() + mock_event = test_context.Mock() + + debug_mock = essential_modules["cthulhu.debug"] + debug_mock.LEVEL_INFO = 800 + debug_mock.print_tokens = test_context.Mock() + + private_method_name = f"_{command_method}" + test_context.patch_object(presenter, private_method_name, return_value=True) + + command = getattr(presenter, command_method) + result = command(mock_script, mock_event, notify_user=True) + + assert result is True + debug_mock.print_tokens.assert_called_once() + private_method = getattr(presenter, private_method_name) + private_method.assert_called_once_with(None, True) + + @pytest.mark.parametrize( + "case", + [ + { + "id": "rewind_setting_disabled_no_override", + "direction": "rewind", + "override_setting": False, + "setting_enabled": False, + "context_provided": False, + "expected_result": False, + }, + { + "id": "rewind_setting_disabled_with_override", + "direction": "rewind", + "override_setting": True, + "setting_enabled": False, + "context_provided": False, + "expected_result": True, + }, + { + "id": "rewind_setting_enabled_no_override", + "direction": "rewind", + "override_setting": False, + "setting_enabled": True, + "context_provided": False, + "expected_result": True, + }, + { + "id": "rewind_setting_enabled_with_override", + "direction": "rewind", + "override_setting": True, + "setting_enabled": True, + "context_provided": False, + "expected_result": True, + }, + { + "id": "rewind_with_provided_context", + "direction": "rewind", + "override_setting": False, + "setting_enabled": True, + "context_provided": True, + "expected_result": True, + }, + { + "id": "fast_forward_setting_disabled_no_override", + "direction": "fast_forward", + "override_setting": False, + "setting_enabled": False, + "context_provided": False, + "expected_result": False, + }, + { + "id": "fast_forward_setting_disabled_with_override", + "direction": "fast_forward", + "override_setting": True, + "setting_enabled": False, + "context_provided": False, + "expected_result": True, + }, + { + "id": "fast_forward_setting_enabled_no_override", + "direction": "fast_forward", + "override_setting": False, + "setting_enabled": True, + "context_provided": False, + "expected_result": True, + }, + { + "id": "fast_forward_setting_enabled_with_override", + "direction": "fast_forward", + "override_setting": True, + "setting_enabled": True, + "context_provided": False, + "expected_result": True, + }, + { + "id": "fast_forward_with_provided_context", + "direction": "fast_forward", + "override_setting": False, + "setting_enabled": True, + "context_provided": True, + "expected_result": True, + }, + ], + ids=lambda case: case["id"], + ) + def test_navigation_methods_with_parameters( + self, + test_context: CthulhuTestContext, + case: dict, + ) -> None: + """Test _rewind and _fast_forward methods with override_setting parameter.""" + + direction = case["direction"] + override_setting = case["override_setting"] + setting_enabled = case["setting_enabled"] + context_provided = case["context_provided"] + expected_result = case["expected_result"] + + essential_modules = self._setup_dependencies(test_context) + from cthulhu import speechserver + from cthulhu.say_all_presenter import SayAllPresenter + + presenter = SayAllPresenter() + mock_script = test_context.Mock() + presenter._script = mock_script + + presenter.set_rewind_and_fast_forward_enabled(setting_enabled) + + mock_context = ( + test_context.Mock(spec=speechserver.SayAllContext) if context_provided else None + ) + if context_provided and mock_context is not None: + mock_context.obj = "provided_obj" + if direction == "rewind": + mock_context.start_offset = 10 + else: + mock_context.end_offset = 20 + + current_context = test_context.Mock(spec=speechserver.SayAllContext) + current_context.obj = "current_obj" + if direction == "rewind": + current_context.start_offset = 5 + else: + current_context.end_offset = 15 + presenter._current_context = current_context + + if direction == "rewind": + presenter._contents = [("content_obj", 0, 10, "text")] + else: + presenter._contents = [("first_obj", 0, 5, "first"), ("last_obj", 10, 20, "last")] + + focus_manager_mock = essential_modules["cthulhu.focus_manager"] + focus_instance = test_context.Mock() + focus_manager_mock.get_manager.return_value = focus_instance + + mock_script.utilities.set_caret_context = test_context.Mock() + if direction == "rewind": + mock_script.utilities.previous_context.return_value = ("prev_obj", 3) + else: + mock_script.utilities.next_context.return_value = ("next_obj", 25) + + presenter.say_all = test_context.Mock(return_value=True) + + navigation_method = getattr(presenter, f"_{direction}") + result = navigation_method(mock_context, override_setting) + + assert result == expected_result + + if expected_result: + focus_instance.set_locus_of_focus.assert_called() + mock_script.utilities.set_caret_context.assert_called() + presenter.say_all.assert_called_once() + + def test_say_all_initialization_clears_state(self, test_context: CthulhuTestContext) -> None: + """Test say_all method clears contexts, contents, and current_context at start.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu import speechserver + from cthulhu.say_all_presenter import SayAllPresenter + + presenter = SayAllPresenter() + mock_script = test_context.Mock() + + presenter._contexts = [test_context.Mock(spec=speechserver.SayAllContext)] + presenter._contents = [("old_obj", 0, 5, "old")] + presenter._current_context = test_context.Mock(spec=speechserver.SayAllContext) + + focus_manager_mock = essential_modules["cthulhu.focus_manager"] + manager_instance = test_context.Mock() + focus_manager_mock.get_manager.return_value = manager_instance + manager_instance.get_locus_of_focus.return_value = "focus_obj" + + debug_mock = essential_modules["cthulhu.debug"] + debug_mock.LEVEL_INFO = 800 + debug_mock.print_tokens = test_context.Mock() + + from cthulhu import speech + + test_context.patch_object(speech, "say_all", return_value=None) + + mock_script.utilities.get_caret_context.return_value = ("obj", 10) + + result = presenter.say_all(mock_script, None) + assert result is True + + assert not presenter._contexts + assert not presenter._contents + assert presenter._current_context is None + + def test_progress_callback_sets_current_context(self, test_context: CthulhuTestContext) -> None: + """Test that _progress_callback sets the current context.""" + + self._setup_dependencies(test_context) + from cthulhu import speechserver + from cthulhu.say_all_presenter import SayAllPresenter + + presenter = SayAllPresenter() + + mock_context = test_context.Mock(spec=speechserver.SayAllContext) + assert presenter._current_context is None + + presenter._current_context = mock_context + assert presenter._current_context is mock_context + + def test_say_all_is_running_initialized_to_false(self, test_context: CthulhuTestContext) -> None: + """Test that _say_all_is_running is initialized to False.""" + + self._setup_dependencies(test_context) + from cthulhu.say_all_presenter import SayAllPresenter + + presenter = SayAllPresenter() + assert presenter._say_all_is_running is False + + def test_say_all_clears_say_all_is_running(self, test_context: CthulhuTestContext) -> None: + """Test that say_all resets _say_all_is_running to False.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.say_all_presenter import SayAllPresenter + + presenter = SayAllPresenter() + mock_script = test_context.Mock() + + presenter._say_all_is_running = True + + focus_manager_mock = essential_modules["cthulhu.focus_manager"] + manager_instance = test_context.Mock() + focus_manager_mock.get_manager.return_value = manager_instance + manager_instance.get_locus_of_focus.return_value = None + + messages_mock = essential_modules["cthulhu.messages"] + messages_mock.LOCATION_NOT_FOUND_FULL = "Location not found" + + presenter.say_all(mock_script) + assert presenter._say_all_is_running is False + + def test_progress_callback_sets_say_all_is_running_true( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test that _progress_callback sets _say_all_is_running to True.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu import speechserver + from cthulhu.say_all_presenter import SayAllPresenter + + presenter = SayAllPresenter() + mock_script = test_context.Mock() + presenter._script = mock_script + + mock_context = test_context.Mock(spec=speechserver.SayAllContext) + mock_context.obj = test_context.Mock() + mock_context.current_offset = 5 + mock_context.current_end_offset = 10 + + from cthulhu.ax_utilities import AXUtilities + + test_context.patch_object(AXUtilities, "character_at_offset_is_eoc", return_value=False) + + focus_manager_mock = essential_modules["cthulhu.focus_manager"] + focus_instance = test_context.Mock() + focus_manager_mock.get_manager.return_value = focus_instance + focus_manager_mock.SAY_ALL = "say-all" + + assert presenter._say_all_is_running is False + + presenter._progress_callback(mock_context, speechserver.SayAllContext.PROGRESS) + + assert presenter._say_all_is_running is True + + def test_progress_callback_uses_say_all_mode_when_running( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test that _progress_callback uses SAY_ALL mode when _say_all_is_running is True.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu import speechserver + from cthulhu.say_all_presenter import SayAllPresenter + + presenter = SayAllPresenter() + mock_script = test_context.Mock() + presenter._script = mock_script + + mock_context = test_context.Mock(spec=speechserver.SayAllContext) + mock_context.obj = test_context.Mock() + mock_context.current_offset = 5 + mock_context.current_end_offset = 10 + + from cthulhu.ax_utilities import AXUtilities + + test_context.patch_object(AXUtilities, "character_at_offset_is_eoc", return_value=False) + + focus_manager_mock = essential_modules["cthulhu.focus_manager"] + focus_instance = test_context.Mock() + focus_manager_mock.get_manager.return_value = focus_instance + focus_manager_mock.SAY_ALL = "say-all" + + presenter._progress_callback(mock_context, speechserver.SayAllContext.PROGRESS) + + focus_instance.emit_region_changed.assert_called_once_with( + mock_context.obj, + mock_context.current_offset, + mock_context.current_end_offset, + "say-all", + ) + + def test_progress_callback_uses_focus_tracking_mode_when_interrupted( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test that _progress_callback uses FOCUS_TRACKING mode when interrupted by keyboard.""" + + self._setup_dependencies(test_context) + from cthulhu import focus_manager as fm + from cthulhu import input_event_manager, speechserver + from cthulhu.ax_text import AXText + from cthulhu.say_all_presenter import SayAllPresenter + + presenter = SayAllPresenter() + mock_script = test_context.Mock() + presenter._script = mock_script + + mock_context = test_context.Mock(spec=speechserver.SayAllContext) + mock_context.obj = test_context.Mock() + mock_context.current_offset = 5 + mock_context.current_end_offset = 10 + + from cthulhu.ax_utilities import AXUtilities + + test_context.patch_object(AXUtilities, "character_at_offset_is_eoc", return_value=False) + test_context.patch_object(AXText, "set_caret_offset", return_value=True) + + focus_instance = test_context.Mock() + test_context.patch_object(fm, "get_manager", return_value=focus_instance) + + iem_instance = test_context.Mock() + iem_instance.last_event_was_keyboard.return_value = True + iem_instance.last_event_was_down.return_value = False + iem_instance.last_event_was_up.return_value = False + test_context.patch_object(input_event_manager, "get_manager", return_value=iem_instance) + + presenter._progress_callback(mock_context, speechserver.SayAllContext.INTERRUPTED) + + assert presenter._say_all_is_running is False + focus_instance.emit_region_changed.assert_called_once_with( + mock_context.obj, + mock_context.current_offset, + None, + fm.FOCUS_TRACKING, + ) + + @pytest.mark.parametrize( + "end_offset, expected_next_context_offset", + [ + pytest.param(16, 15, id="normal_offset_passes_end_minus_one"), + pytest.param(1, 0, id="small_offset_passes_end_minus_one"), + pytest.param(0, 0, id="zero_offset_passes_zero_not_negative"), + pytest.param(100, 99, id="large_offset_passes_end_minus_one"), + ], + ) + def test_say_all_iter_next_context_uses_end_offset_minus_one( + self, + test_context: CthulhuTestContext, + end_offset: int, + expected_next_context_offset: int, + ) -> None: + """Test that _say_all_iter passes end_offset - 1 to next_context. + + The end offset from sentence contents is exclusive (position end is NOT + part of the content). find_next_caret_in_order looks at offset + 1, so + passing end directly would skip position end. We must pass end - 1 so + that find_next_caret_in_order looks at position end, which is where + embedded object characters (FFFC) representing child elements may be. + """ + + essential_modules = self._setup_dependencies(test_context) + from cthulhu import gsettings_registry + from cthulhu.say_all_presenter import SayAllPresenter + + presenter = SayAllPresenter() + mock_script = test_context.Mock() + mock_obj = test_context.Mock(spec=Atspi.Accessible) + + # Set up the presenter's script + presenter._script = mock_script + + # Set up settings for sentence-by-sentence say all + gsettings_registry.get_registry().set_runtime_value("say-all", "style", "sentence") + + # Mock utilities - return contents once, then return empty to exit loop + call_count = [0] + + def mock_get_sentence_contents(_obj, _offset): + call_count[0] += 1 + if call_count[0] == 1: + return [(mock_obj, 0, end_offset, "Test sentence.")] + return [] + + mock_script.utilities.get_sentence_contents_at_offset.side_effect = ( + mock_get_sentence_contents + ) + mock_script.utilities.filter_contents_for_presentation.side_effect = lambda x: x + + # next_context returns None to end the loop + mock_script.utilities.next_context.return_value = (None, -1) + + # Mock speech presenter to return something so the content is processed + speech_pres = essential_modules["cthulhu.speech_presenter"].get_presenter() + speech_pres.generate_speech_contents.return_value = [["Test"], []] + + # Mock AXUtilities + ax_utilities_mock = essential_modules["cthulhu.ax_utilities"] + ax_utilities_mock.is_text.return_value = False + ax_utilities_mock.is_terminal.return_value = False + + # Mock _say_all_should_skip_content to avoid dependency issues + test_context.patch_object( + presenter, + "_say_all_should_skip_content", + return_value=(False, ""), + ) + + # Mock debug + debug_mock = essential_modules["cthulhu.debug"] + debug_mock.LEVEL_INFO = 800 + debug_mock.print_tokens = test_context.Mock() + debug_mock.print_message = test_context.Mock() + + # Mock focus_manager for set_locus_of_focus + focus_manager_mock = essential_modules["cthulhu.focus_manager"] + manager_instance = test_context.Mock() + focus_manager_mock.get_manager.return_value = manager_instance + + # Mock event_synthesizer + essential_modules[ + "cthulhu.ax_event_synthesizer" + ].get_synthesizer.return_value.scroll_into_view = test_context.Mock() + + # Mock utilities.set_caret_offset + mock_script.utilities.set_caret_offset = test_context.Mock() + + # Consume the generator to trigger next_context call + generator = presenter._say_all_iter(mock_obj, 0) + for _ in generator: + pass + + # Verify next_context was called with end_offset - 1 (or 0 if that would be negative) + mock_script.utilities.next_context.assert_called_once() + call_args = mock_script.utilities.next_context.call_args + # next_context is called with positional args: (last_obj, offset, restrict_to=...) + actual_offset = call_args[0][1] + assert actual_offset == expected_next_context_offset, ( + f"Expected next_context to be called with offset {expected_next_context_offset}, " + f"but was called with {actual_offset}" + ) + + def test_stop_clears_all_state(self, test_context: CthulhuTestContext) -> None: + """Test SayAllPresenter.stop clears contexts, contents, current_context and running flag.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu import speechserver + from cthulhu.say_all_presenter import SayAllPresenter + + presenter = SayAllPresenter() + presenter._contexts = [test_context.Mock(spec=speechserver.SayAllContext)] + presenter._contents = [("obj", 0, 5, "text")] + presenter._current_context = test_context.Mock(spec=speechserver.SayAllContext) + presenter._say_all_is_running = True + + focus_manager_mock = essential_modules["cthulhu.focus_manager"] + manager_instance = test_context.Mock() + focus_manager_mock.get_manager.return_value = manager_instance + + presenter.stop() + + assert not presenter._contexts + assert not presenter._contents + assert presenter._current_context is None + assert presenter._say_all_is_running is False + manager_instance.reset_active_mode.assert_called_once_with( + "SAY ALL PRESENTER: Stopped Say All.", + ) + + def test_stop_from_empty_state(self, test_context: CthulhuTestContext) -> None: + """Test SayAllPresenter.stop works correctly when already in empty state.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.say_all_presenter import SayAllPresenter + + presenter = SayAllPresenter() + assert not presenter._contexts + assert not presenter._contents + assert presenter._current_context is None + assert presenter._say_all_is_running is False + + focus_manager_mock = essential_modules["cthulhu.focus_manager"] + manager_instance = test_context.Mock() + focus_manager_mock.get_manager.return_value = manager_instance + + presenter.stop() + + assert not presenter._contexts + assert not presenter._contents + assert presenter._current_context is None + assert presenter._say_all_is_running is False + manager_instance.reset_active_mode.assert_called_once() diff --git a/tests/test_sound_presenter.py b/tests/test_sound_presenter.py new file mode 100644 index 0000000..7d6f9e0 --- /dev/null +++ b/tests/test_sound_presenter.py @@ -0,0 +1,235 @@ +# Unit tests for sound_presenter.py methods. +# +# Copyright 2025 Igalia, S.L. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +# pylint: disable=wrong-import-position +# pylint: disable=import-outside-toplevel +# pylint: disable=protected-access + +"""Unit tests for sound_presenter.py methods.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from unittest.mock import MagicMock + + from cthulhu_test_context import CthulhuTestContext + + +@pytest.mark.unit +class TestSoundPresenter: + """Test SoundPresenter class methods.""" + + def _setup_dependencies(self, test_context: CthulhuTestContext) -> dict[str, MagicMock]: + """Set up mocks for sound_presenter dependencies.""" + + essential_modules = test_context.setup_shared_dependencies([]) + + debug_mock = essential_modules["cthulhu.debug"] + debug_mock.print_message = test_context.Mock() + debug_mock.LEVEL_INFO = 800 + + dbus_service_mock = essential_modules["cthulhu.dbus_service"] + controller_mock = test_context.Mock() + controller_mock.register_decorated_module = test_context.Mock() + dbus_service_mock.get_remote_controller = test_context.Mock(return_value=controller_mock) + dbus_service_mock.getter = lambda func: func + dbus_service_mock.setter = lambda func: func + + essential_modules["controller"] = controller_mock + + from cthulhu import gsettings_registry + + registry = gsettings_registry.get_registry() + registry.clear_runtime_values() + + return essential_modules + + def test_initialization_registers_dbus_module(self, test_context: CthulhuTestContext) -> None: + """Test that SoundPresenter registers itself with D-Bus on initialization.""" + + mocks = self._setup_dependencies(test_context) + + from cthulhu import sound_presenter + + presenter = sound_presenter.SoundPresenter() + mocks["controller"].register_decorated_module.assert_called_with( + "SoundPresenter", + presenter, + ) + + def test_get_sound_is_enabled_returns_setting(self, test_context: CthulhuTestContext) -> None: + """Test get_sound_is_enabled returns the enableSound setting.""" + + self._setup_dependencies(test_context) + + from cthulhu.sound_presenter import SoundPresenter + + presenter = SoundPresenter() + assert presenter.get_sound_is_enabled() is True + + presenter.set_sound_is_enabled(False) + assert presenter.get_sound_is_enabled() is False + + def test_set_sound_is_enabled_updates_setting(self, test_context: CthulhuTestContext) -> None: + """Test set_sound_is_enabled updates the enableSound setting.""" + + self._setup_dependencies(test_context) + + from cthulhu.sound_presenter import SoundPresenter + + presenter = SoundPresenter() + result = presenter.set_sound_is_enabled(False) + + assert result is True + assert presenter.get_sound_is_enabled() is False + + def test_get_sound_volume_returns_setting(self, test_context: CthulhuTestContext) -> None: + """Test get_sound_volume returns the soundVolume setting.""" + + self._setup_dependencies(test_context) + + from cthulhu.sound_presenter import SoundPresenter + + presenter = SoundPresenter() + presenter.set_sound_volume(0.75) + assert presenter.get_sound_volume() == 0.75 + + def test_set_sound_volume_updates_setting(self, test_context: CthulhuTestContext) -> None: + """Test set_sound_volume updates the soundVolume setting.""" + + self._setup_dependencies(test_context) + + from cthulhu.sound_presenter import SoundPresenter + + presenter = SoundPresenter() + result = presenter.set_sound_volume(0.8) + + assert result is True + assert presenter.get_sound_volume() == 0.8 + + def test_get_beep_progress_bar_updates_returns_setting( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test get_beep_progress_bar_updates returns the beepProgressBarUpdates setting.""" + + self._setup_dependencies(test_context) + + from cthulhu.sound_presenter import SoundPresenter + + presenter = SoundPresenter() + presenter.set_beep_progress_bar_updates(True) + assert presenter.get_beep_progress_bar_updates() is True + + presenter.set_beep_progress_bar_updates(False) + assert presenter.get_beep_progress_bar_updates() is False + + def test_set_beep_progress_bar_updates_updates_setting( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test set_beep_progress_bar_updates updates the beepProgressBarUpdates setting.""" + + self._setup_dependencies(test_context) + + from cthulhu.sound_presenter import SoundPresenter + + presenter = SoundPresenter() + result = presenter.set_beep_progress_bar_updates(True) + + assert result is True + assert presenter.get_beep_progress_bar_updates() is True + + def test_get_progress_bar_beep_interval_returns_setting( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test get_progress_bar_beep_interval returns the progressBarBeepInterval setting.""" + + self._setup_dependencies(test_context) + + from cthulhu.sound_presenter import SoundPresenter + + presenter = SoundPresenter() + presenter.set_progress_bar_beep_interval(10) + assert presenter.get_progress_bar_beep_interval() == 10 + + def test_set_progress_bar_beep_interval_updates_setting( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test set_progress_bar_beep_interval updates the progressBarBeepInterval setting.""" + + self._setup_dependencies(test_context) + + from cthulhu.sound_presenter import SoundPresenter + + presenter = SoundPresenter() + result = presenter.set_progress_bar_beep_interval(15) + + assert result is True + assert presenter.get_progress_bar_beep_interval() == 15 + + def test_get_progress_bar_beep_verbosity_returns_setting( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test get_progress_bar_beep_verbosity returns the progressBarBeepVerbosity setting.""" + + self._setup_dependencies(test_context) + + from cthulhu.sound_presenter import SoundPresenter + + presenter = SoundPresenter() + presenter.set_progress_bar_beep_verbosity(2) + assert presenter.get_progress_bar_beep_verbosity() == 2 + + def test_set_progress_bar_beep_verbosity_updates_setting( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test set_progress_bar_beep_verbosity updates the progressBarBeepVerbosity setting.""" + + self._setup_dependencies(test_context) + + from cthulhu.sound_presenter import SoundPresenter + + presenter = SoundPresenter() + result = presenter.set_progress_bar_beep_verbosity(2) + + assert result is True + assert presenter.get_progress_bar_beep_verbosity() == 2 + + def test_get_presenter_returns_singleton(self, test_context: CthulhuTestContext) -> None: + """Test get_presenter returns the module-level presenter instance.""" + + self._setup_dependencies(test_context) + + from cthulhu import sound_presenter + + presenter1 = sound_presenter.get_presenter() + presenter2 = sound_presenter.get_presenter() + + assert presenter1 is presenter2 + assert isinstance(presenter1, sound_presenter.SoundPresenter) diff --git a/tests/test_speech_presenter.py b/tests/test_speech_presenter.py new file mode 100644 index 0000000..6fb9df9 --- /dev/null +++ b/tests/test_speech_presenter.py @@ -0,0 +1,1095 @@ +# Unit tests for speech_presenter.py methods. +# +# Copyright 2025 Igalia, S.L. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +# pylint: disable=wrong-import-position +# pylint: disable=import-outside-toplevel +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-statements +# pylint: disable=too-many-lines +# pylint: disable=protected-access +# pylint: disable=too-many-arguments +# pylint: disable=too-many-positional-arguments +# pylint: disable=too-many-locals + +"""Unit tests for speech_presenter.py methods.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from unittest.mock import MagicMock + + from cthulhu_test_context import CthulhuTestContext + + +@pytest.mark.unit +class TestSpeechPresenter: + """Test SpeechPresenter class methods.""" + + def _setup_dependencies(self, test_context: CthulhuTestContext) -> dict[str, MagicMock]: + """Set up mocks for speech_presenter dependencies.""" + + additional_modules = [ + "cthulhu.document_presenter", + "cthulhu.mathsymbols", + "cthulhu.object_properties", + "cthulhu.phonnames", + "cthulhu.pronunciation_dictionary_manager", + "cthulhu.ax_hypertext", + "cthulhu.ax_table", + "cthulhu.ax_text", + "cthulhu.ax_utilities", + "cthulhu.ax_document", + "cthulhu.presentation_manager", + "cthulhu.preferences_grid_base", + "cthulhu.speech", + "cthulhu.speech_manager", + "cthulhu.speech_monitor", + ] + essential_modules = test_context.setup_shared_dependencies(additional_modules) + + document_presenter_mock = essential_modules["cthulhu.document_presenter"] + document_presenter_instance = test_context.Mock() + document_presenter_instance.get_in_focus_mode = test_context.Mock(return_value=False) + document_presenter_mock.get_presenter = test_context.Mock( + return_value=document_presenter_instance, + ) + + dbus_service_mock = essential_modules["cthulhu.dbus_service"] + dbus_service_mock.get_remote_controller.return_value = test_context.Mock() + + def passthrough_decorator(func): + return func + + dbus_service_mock.getter = passthrough_decorator + dbus_service_mock.setter = passthrough_decorator + dbus_service_mock.command = passthrough_decorator + dbus_service_mock.parameterized_command = passthrough_decorator + + debug_mock = essential_modules["cthulhu.debug"] + debug_mock.print_message.return_value = None + debug_mock.print_tokens.return_value = None + debug_mock.LEVEL_INFO = 800 + debug_mock.LEVEL_WARNING = 900 + + ax_hypertext_mock = essential_modules["cthulhu.ax_hypertext"] + ax_hypertext_mock.AXHypertext = test_context.Mock() + ax_hypertext_mock.AXHypertext.get_all_links_in_range = test_context.Mock(return_value=[]) + ax_hypertext_mock.AXHypertext.get_link_end_offset = test_context.Mock(return_value=0) + + ax_object_mock = essential_modules["cthulhu.ax_object"] + ax_object_mock.AXObject = test_context.Mock() + + ax_text_mock = essential_modules["cthulhu.ax_text"] + ax_text_mock.AXText = test_context.Mock() + ax_text_mock.AXText.get_character_at_offset = test_context.Mock(return_value=("a", 0)) + + ax_utilities_mock = essential_modules["cthulhu.ax_utilities"] + ax_utilities_mock.AXUtilities = test_context.Mock() + ax_utilities_mock.AXUtilities.find_ancestor_inclusive = test_context.Mock(return_value=None) + ax_utilities_mock.AXUtilities.string_has_spelling_error = test_context.Mock( + return_value=False, + ) + ax_utilities_mock.AXUtilities.string_has_grammar_error = test_context.Mock( + return_value=False, + ) + ax_utilities_mock.AXUtilities.get_table = test_context.Mock(return_value=None) + ax_utilities_mock.AXUtilities.is_math_related = test_context.Mock(return_value=False) + ax_utilities_mock.AXUtilities.is_text_input_telephone = test_context.Mock( + return_value=False, + ) + ax_utilities_mock.AXUtilities.is_code = test_context.Mock(return_value=False) + + ax_document_mock = essential_modules["cthulhu.ax_document"] + ax_document_mock.AXDocument = test_context.Mock() + ax_document_mock.AXDocument.is_plain_text = test_context.Mock(return_value=False) + + mathsymbols_mock = essential_modules["cthulhu.mathsymbols"] + mathsymbols_mock.adjust_for_speech = test_context.Mock(side_effect=lambda x: x) + + object_properties_mock = essential_modules["cthulhu.object_properties"] + object_properties_mock.STATE_INVALID_GRAMMAR_SPEECH = "grammar error" + + pronunciation_dict_mock = essential_modules["cthulhu.pronunciation_dictionary_manager"] + pron_manager_instance = test_context.Mock() + pron_manager_instance.get_pronunciation = test_context.Mock(side_effect=lambda x: x) + pronunciation_dict_mock.get_manager = test_context.Mock(return_value=pron_manager_instance) + + from cthulhu import gsettings_registry + + gsettings_registry.get_registry().clear_runtime_values() + + cthulhu_i18n_mock = essential_modules["cthulhu.cthulhu_i18n"] + cthulhu_i18n_mock._ = lambda x: x + cthulhu_i18n_mock.C_ = lambda c, x: x + cthulhu_i18n_mock.ngettext = lambda s, p, n: s if n == 1 else p + + messages_mock = essential_modules["cthulhu.messages"] + messages_mock.LINK = "link" + messages_mock.MISSPELLED = "misspelled" + messages_mock.repeated_char_count = test_context.Mock( + side_effect=lambda char, count: f"{char} repeated {count} times", + ) + messages_mock.spaces_count = test_context.Mock(side_effect=lambda count: f"{count} spaces") + messages_mock.tabs_count = test_context.Mock(side_effect=lambda count: f"{count} tabs") + + return essential_modules + + def test_init(self, test_context: CthulhuTestContext) -> None: + """Test presenter initialization and D-Bus registration.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + + # Verify D-Bus registration occurred + controller = essential_modules["cthulhu.dbus_service"].get_remote_controller() + controller.register_decorated_module.assert_called_with("SpeechPresenter", presenter) + + # Monitor callbacks are not registered in __init__ (deferred to set_up_commands + # to avoid circular imports during module loading). + + def test_set_up_commands(self, test_context: CthulhuTestContext) -> None: + """Test that set_up_commands registers commands in CommandManager.""" + + self._setup_dependencies(test_context) + from cthulhu import command_manager + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + presenter.set_up_commands() + + # Verify commands are registered in CommandManager + cmd_manager = command_manager.get_manager() + assert cmd_manager.get_command("changeNumberStyleHandler") is not None + assert cmd_manager.get_command("toggleSpeechVerbosityHandler") is not None + assert cmd_manager.get_command("toggleSpeakingIndentationJustificationHandler") is not None + assert cmd_manager.get_command("toggleTableCellReadModeHandler") is not None + + def test_set_up_commands_registers_monitor_callbacks( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test that set_up_commands registers speech monitor callbacks.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + speech_mock = essential_modules["cthulhu.speech"] + speech_mock.set_monitor_callbacks.reset_mock() + + presenter.set_up_commands() + + speech_mock.set_monitor_callbacks.assert_called_once_with( + write_text=presenter.write_to_monitor, + write_key=presenter.write_key_to_monitor, + begin_group=presenter._begin_monitor_group, + end_group=presenter._end_monitor_group, + ) + + @pytest.mark.parametrize( + "case", + [ + {"id": "verbose_true", "setting_value": 1, "expected": True}, + {"id": "verbose_false", "setting_value": 0, "expected": False}, + ], + ids=lambda case: case["id"], + ) + def test_use_verbose_speech(self, test_context: CthulhuTestContext, case: dict) -> None: + """Test use_verbose_speech method.""" + + self._setup_dependencies(test_context) + + from cthulhu import gsettings_registry + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + nick = "verbose" if case["setting_value"] == 1 else "brief" + gsettings_registry.get_registry().set_runtime_value("speech", "verbosity-level", nick) + + result = presenter.use_verbose_speech() + assert result == case["expected"] + + @pytest.mark.parametrize( + "case", + [ + {"id": "verbosity_brief", "setting_value": 0, "expected": "brief"}, + {"id": "verbosity_verbose", "setting_value": 1, "expected": "verbose"}, + ], + ids=lambda case: case["id"], + ) + def test_get_verbosity_level(self, test_context: CthulhuTestContext, case: dict) -> None: + """Test get_verbosity_level method.""" + + self._setup_dependencies(test_context) + + from cthulhu import gsettings_registry + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + nick = "verbose" if case["setting_value"] == 1 else "brief" + gsettings_registry.get_registry().set_runtime_value("speech", "verbosity-level", nick) + + result = presenter.get_verbosity_level() + assert result == case["expected"] + + @pytest.mark.parametrize( + "case", + [ + { + "id": "set_verbosity_brief", + "input_value": "brief", + "expected": True, + }, + { + "id": "set_verbosity_verbose", + "input_value": "verbose", + "expected": True, + }, + { + "id": "set_verbosity_invalid", + "input_value": "invalid", + "expected": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_set_verbosity_level(self, test_context: CthulhuTestContext, case: dict) -> None: + """Test set_verbosity_level method.""" + + self._setup_dependencies(test_context) + + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + + result = presenter.set_verbosity_level(case["input_value"]) + assert result == case["expected"] + + if case["expected"]: + assert presenter.get_verbosity_level() == case["input_value"] + + @pytest.mark.parametrize( + "case", + [ + {"id": "speak_blank_lines_true", "setting_value": True, "expected": True}, + {"id": "speak_blank_lines_false", "setting_value": False, "expected": False}, + ], + ids=lambda case: case["id"], + ) + def test_get_speak_blank_lines(self, test_context: CthulhuTestContext, case: dict) -> None: + """Test get_speak_blank_lines method.""" + + self._setup_dependencies(test_context) + + from cthulhu import gsettings_registry + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + gsettings_registry.get_registry().set_runtime_value( + "speech", + "speak-blank-lines", + case["setting_value"], + ) + + result = presenter.get_speak_blank_lines() + assert result == case["expected"] + + @pytest.mark.parametrize( + "case", + [ + {"id": "only_speak_displayed_true", "setting_value": True, "expected": True}, + {"id": "only_speak_displayed_false", "setting_value": False, "expected": False}, + ], + ids=lambda case: case["id"], + ) + def test_get_only_speak_displayed_text(self, test_context: CthulhuTestContext, case: dict) -> None: + """Test get_only_speak_displayed_text method.""" + + self._setup_dependencies(test_context) + + from cthulhu import gsettings_registry + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + gsettings_registry.get_registry().set_runtime_value( + "speech", + "only-speak-displayed-text", + case["setting_value"], + ) + + result = presenter.get_only_speak_displayed_text() + assert result == case["expected"] + + @pytest.mark.parametrize( + "case", + [ + {"id": "set_only_speak_displayed_true", "input_value": True, "expected": True}, + {"id": "set_only_speak_displayed_false", "input_value": False, "expected": True}, + ], + ids=lambda case: case["id"], + ) + def test_set_only_speak_displayed_text(self, test_context: CthulhuTestContext, case: dict) -> None: + """Test set_only_speak_displayed_text method.""" + + self._setup_dependencies(test_context) + + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + + result = presenter.set_only_speak_displayed_text(case["input_value"]) + assert result == case["expected"] + assert presenter.get_only_speak_displayed_text() == case["input_value"] + + @pytest.mark.parametrize( + "case", + [ + {"id": "speak_indentation_true", "setting_value": True, "expected": True}, + {"id": "speak_indentation_false", "setting_value": False, "expected": False}, + ], + ids=lambda case: case["id"], + ) + def test_get_speak_indentation_and_justification( + self, + test_context: CthulhuTestContext, + case: dict, + ) -> None: + """Test get_speak_indentation_and_justification method.""" + + self._setup_dependencies(test_context) + + from cthulhu import gsettings_registry + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + gsettings_registry.get_registry().set_runtime_value( + "speech", + "speak-indentation-and-justification", + case["setting_value"], + ) + + result = presenter.get_speak_indentation_and_justification() + assert result == case["expected"] + + @pytest.mark.parametrize( + "case", + [ + {"id": "set_speak_indentation_true", "input_value": True, "expected": True}, + {"id": "set_speak_indentation_false", "input_value": False, "expected": True}, + ], + ids=lambda case: case["id"], + ) + def test_set_speak_indentation_and_justification( + self, + test_context: CthulhuTestContext, + case: dict, + ) -> None: + """Test set_speak_indentation_and_justification method.""" + + self._setup_dependencies(test_context) + + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + + result = presenter.set_speak_indentation_and_justification(case["input_value"]) + assert result == case["expected"] + assert presenter.get_speak_indentation_and_justification() == case["input_value"] + + def test_get_indentation_description_disabled(self, test_context: CthulhuTestContext) -> None: + """Test get_indentation_description method when disabled.""" + + self._setup_dependencies(test_context) + + from cthulhu import gsettings_registry + from cthulhu.speech_presenter import SpeechPresenter + + registry = gsettings_registry.get_registry() + registry.set_runtime_value("speech", "only-speak-displayed-text", True) + registry.set_runtime_value("speech", "speak-indentation-and-justification", False) + + presenter = SpeechPresenter() + line = " Hello world" + result = presenter.get_indentation_description(line) + assert result == "" + + def test_get_indentation_description_enabled(self, test_context: CthulhuTestContext) -> None: + """Test get_indentation_description method when enabled.""" + + self._setup_dependencies(test_context) + + from cthulhu import gsettings_registry + from cthulhu.speech_presenter import SpeechPresenter + + registry = gsettings_registry.get_registry() + registry.set_runtime_value("speech", "only-speak-displayed-text", False) + registry.set_runtime_value("speech", "speak-indentation-and-justification", True) + registry.set_runtime_value("speech", "speak-indentation-only-if-changed", False) + + presenter = SpeechPresenter() + line = " Hello world" + result = presenter.get_indentation_description(line) + assert result != "" + + def test_get_indentation_description_only_if_changed( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test get_indentation_description with only-if-changed enabled.""" + + self._setup_dependencies(test_context) + + from cthulhu import gsettings_registry + from cthulhu.speech_presenter import SpeechPresenter + + registry = gsettings_registry.get_registry() + registry.set_runtime_value("speech", "only-speak-displayed-text", False) + registry.set_runtime_value("speech", "speak-indentation-and-justification", True) + registry.set_runtime_value("speech", "speak-indentation-only-if-changed", True) + + presenter = SpeechPresenter() + line = " Hello world" + + # First call should return a description + result1 = presenter.get_indentation_description(line) + assert result1 != "" + + # Second call with same indentation should return empty + result2 = presenter.get_indentation_description(line) + assert result2 == "" + + def test_get_error_description_basic(self, test_context: CthulhuTestContext) -> None: + """Test get_error_description method with basic scenarios.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + + ax_text_mock = essential_modules["cthulhu.ax_text"] + ax_text_mock.AXText.get_character_at_offset = test_context.Mock(return_value=("a", 0)) + ax_utilities_mock = essential_modules["cthulhu.ax_utilities"] + ax_utilities_mock.AXUtilities.string_has_spelling_error = test_context.Mock( + return_value=True, + ) + ax_utilities_mock.AXUtilities.string_has_grammar_error = test_context.Mock( + return_value=False, + ) + + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + mock_obj = test_context.Mock() + result = presenter.get_error_description(mock_obj, 0) + assert result == "misspelled" + + def test_get_error_description_disabled(self, test_context: CthulhuTestContext) -> None: + """Test get_error_description method when misspelled indicator is disabled.""" + + self._setup_dependencies(test_context) + + from cthulhu import gsettings_registry + from cthulhu.speech_presenter import SpeechPresenter + + gsettings_registry.get_registry().set_runtime_value( + "speech", + "speak-misspelled-indicator", + False, + ) + + presenter = SpeechPresenter() + mock_obj = test_context.Mock() + result = presenter.get_error_description(mock_obj, 0) + assert result == "" + + def test_adjust_for_presentation(self, test_context: CthulhuTestContext) -> None: + """Test adjust_for_presentation with all sub-adjustments mocked.""" + + self._setup_dependencies(test_context) + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + return_text = "Hello world" + + test_context.patch_object(presenter, "_adjust_for_links", return_value=return_text) + test_context.patch_object(presenter, "adjust_for_digits", return_value=return_text) + test_context.patch_object(presenter, "_adjust_for_repeats", return_value=return_text) + test_context.patch_object( + presenter, + "_adjust_for_verbalized_punctuation", + return_value=return_text, + ) + test_context.patch_object( + presenter, + "_apply_pronunciation_dictionary", + return_value=return_text, + ) + + mock_obj = test_context.Mock() + result = presenter.adjust_for_presentation(mock_obj, "Hello world", start_offset=0) + assert result == return_text + + @pytest.mark.parametrize( + "case", + [ + { + "id": "digits_on", + "speak_digits": True, + "is_telephone": False, + "input_text": "123 Main", + "expected": "1 2 3 Main", + }, + { + "id": "digits_off", + "speak_digits": False, + "is_telephone": False, + "input_text": "123 Main", + "expected": "123 Main", + }, + { + "id": "telephone_field", + "speak_digits": False, + "is_telephone": True, + "input_text": "555", + "expected": "5 5 5", + }, + ], + ids=lambda case: case["id"], + ) + def test_adjust_for_digits(self, test_context: CthulhuTestContext, case: dict) -> None: + """Test adjust_for_digits method with speakNumbersAsDigits on/off.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + + from cthulhu import gsettings_registry + + gsettings_registry.get_registry().set_runtime_value( + "speech", + "speak-numbers-as-digits", + case["speak_digits"], + ) + + essential_modules[ + "cthulhu.ax_utilities" + ].AXUtilities.is_text_input_telephone = test_context.Mock(return_value=case["is_telephone"]) + + from cthulhu.speech_presenter import SpeechPresenter + + mock_obj = test_context.Mock() + result = SpeechPresenter.adjust_for_digits(mock_obj, case["input_text"]) + assert result == case["expected"] + + def test_adjust_for_repeats(self, test_context: CthulhuTestContext) -> None: + """Test _adjust_for_repeats with repeated characters.""" + + self._setup_dependencies(test_context) + + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + text = "----" + result = presenter._adjust_for_repeats(text) + # Should describe repeated characters + assert "repeated" in result or result != text + + def test_adjust_for_repeats_short_text(self, test_context: CthulhuTestContext) -> None: + """Test _adjust_for_repeats with text shorter than limit.""" + + self._setup_dependencies(test_context) + + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + text = "hi" + result = presenter._adjust_for_repeats(text) + # Short text should be returned unchanged + assert result == text + + def test_get_speech_preferences(self, test_context: CthulhuTestContext) -> None: + """Test get_speech_preferences returns correct tuple structure.""" + + self._setup_dependencies(test_context) + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + result = presenter.get_speech_preferences() + + assert isinstance(result, tuple) + assert len(result) == 3 + + general, object_details, announcements = result + + # general should have 1 preference + assert len(general) == 1 + assert general[0].prefs_key == "messages-are-detailed" + + # object_details should have 5 preferences + assert len(object_details) == 5 + assert object_details[0].prefs_key == "only-speak-displayed-text" + + # announcements should have 6 preferences + assert len(announcements) == 6 + assert announcements[0].prefs_key == "announce-blockquote" + + def test_apply_speech_preferences(self, test_context: CthulhuTestContext) -> None: + """Test apply_speech_preferences applies values correctly.""" + + self._setup_dependencies(test_context) + from cthulhu.speech_presenter import SpeechPreference, SpeechPresenter + + presenter = SpeechPresenter() + + mock_setter1 = test_context.Mock(return_value=True) + mock_setter2 = test_context.Mock(return_value=True) + pref1 = SpeechPreference("key1", "Label 1", lambda: True, mock_setter1) + pref2 = SpeechPreference("key2", "Label 2", lambda: False, mock_setter2) + + updates = [(pref1, False), (pref2, True)] + result = presenter.apply_speech_preferences(updates) + + assert result == {"key1": False, "key2": True} + mock_setter1.assert_called_once_with(False) + mock_setter2.assert_called_once_with(True) + + def test_toggle_indentation_and_justification(self, test_context: CthulhuTestContext) -> None: + """Test toggle_indentation_and_justification method.""" + + self._setup_dependencies(test_context) + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + result = presenter.toggle_indentation_and_justification() + assert result is True + + def test_change_number_style(self, test_context: CthulhuTestContext) -> None: + """Test change_number_style method.""" + + self._setup_dependencies(test_context) + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + result = presenter.change_number_style() + assert result is True + + def test_should_verbalize_punctuation_false(self, test_context: CthulhuTestContext) -> None: + """Test _should_verbalize_punctuation static method returns False.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.speech_presenter import SpeechPresenter + + essential_modules[ + "cthulhu.ax_utilities" + ].AXUtilities.find_ancestor_inclusive.return_value = None + mock_obj = test_context.Mock() + result = SpeechPresenter._should_verbalize_punctuation(mock_obj) + assert result is False + + def test_should_verbalize_punctuation_true(self, test_context: CthulhuTestContext) -> None: + """Test _should_verbalize_punctuation static method returns True.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.speech_presenter import SpeechPresenter + + mock_code_obj = test_context.Mock() + ( + essential_modules["cthulhu.ax_utilities"].AXUtilities.find_ancestor_inclusive.return_value + ) = mock_code_obj + essential_modules["cthulhu.ax_document"].AXDocument.is_plain_text.return_value = False + mock_obj = test_context.Mock() + result = SpeechPresenter._should_verbalize_punctuation(mock_obj) + assert result is True + + def test_adjust_for_verbalized_punctuation(self, test_context: CthulhuTestContext) -> None: + """Test _adjust_for_verbalized_punctuation static method.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.speech_presenter import SpeechPresenter + + mock_code_obj = test_context.Mock() + ( + essential_modules["cthulhu.ax_utilities"].AXUtilities.find_ancestor_inclusive.return_value + ) = mock_code_obj + essential_modules["cthulhu.ax_document"].AXDocument.is_plain_text.return_value = False + mock_obj = test_context.Mock() + text = "Hello, world! How are you?" + result = SpeechPresenter._adjust_for_verbalized_punctuation(mock_obj, text) + + expected = "Hello , world ! How are you ? " + assert result == expected + + @pytest.mark.parametrize( + "case", + [ + {"id": "set_speak_blank_lines_true", "input_value": True, "expected": True}, + {"id": "set_speak_blank_lines_false", "input_value": False, "expected": True}, + ], + ids=lambda case: case["id"], + ) + def test_set_speak_blank_lines(self, test_context: CthulhuTestContext, case: dict) -> None: + """Test set_speak_blank_lines method.""" + + self._setup_dependencies(test_context) + + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + + result = presenter.set_speak_blank_lines(case["input_value"]) + assert result == case["expected"] + assert presenter.get_speak_blank_lines() == case["input_value"] + + def test_get_presenter_singleton(self, test_context: CthulhuTestContext) -> None: + """Test get_presenter function returns the same instance.""" + + self._setup_dependencies(test_context) + from cthulhu import speech_presenter + + presenter1 = speech_presenter.get_presenter() + presenter2 = speech_presenter.get_presenter() + + assert presenter1 is presenter2 + assert isinstance(presenter1, speech_presenter.SpeechPresenter) + + def _setup_speech_output_dependencies( + self, + test_context: CthulhuTestContext, + ) -> dict[str, MagicMock]: + """Set up additional mocks needed for speech output method testing.""" + + essential_modules = self._setup_dependencies(test_context) + + # Add speech module mock + speech_mock = essential_modules["cthulhu.speech"] + speech_mock.speak = test_context.Mock() + speech_mock.speak_character = test_context.Mock() + speech_mock.speak_key_event = test_context.Mock() + + # Add phonnames module mock + phonnames_mock = essential_modules["cthulhu.phonnames"] + phonnames_mock.get_phonetic_name = test_context.Mock(side_effect=lambda c: f"phonetic_{c}") + + # Add speech_manager mock + speech_manager_mock = essential_modules["cthulhu.speech_manager"] + speech_manager_instance = test_context.Mock() + speech_manager_instance.get_speech_is_muted = test_context.Mock(return_value=False) + speech_manager_instance.get_speech_is_enabled_and_not_muted = test_context.Mock( + return_value=True, + ) + speech_manager_instance.get_capitalization_style = test_context.Mock(return_value="icon") + speech_manager_instance.set_capitalization_style = test_context.Mock() + speech_manager_instance.get_punctuation_level = test_context.Mock(return_value="all") + speech_manager_instance.set_punctuation_level = test_context.Mock() + speech_manager_mock.get_manager = test_context.Mock(return_value=speech_manager_instance) + + # Add script_manager mock for _get_active_script / _get_voice + script_manager_mock = essential_modules["cthulhu.script_manager"] + mock_script = test_context.Mock() + speech_gen = test_context.Mock() + speech_gen.voice = test_context.Mock(return_value=[{"family": "default"}]) + speech_gen.generate_contents = test_context.Mock(return_value=["generated speech"]) + mock_script.get_speech_generator = test_context.Mock(return_value=speech_gen) + script_manager_instance = test_context.Mock() + script_manager_instance.get_active_script = test_context.Mock(return_value=mock_script) + script_manager_mock.get_manager = test_context.Mock(return_value=script_manager_instance) + + return essential_modules + + def test_get_voice_with_active_script(self, test_context: CthulhuTestContext) -> None: + """Test _get_voice returns voice from active script.""" + + essential_modules = self._setup_speech_output_dependencies(test_context) + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + voice = presenter._get_voice(text="test") + + script_manager = essential_modules["cthulhu.script_manager"].get_manager() + script = script_manager.get_active_script() + script.get_speech_generator().voice.assert_called_once() + kwargs = script.get_speech_generator().voice.call_args.kwargs + assert kwargs["obj"] is None + assert kwargs["string"] == "test" + assert kwargs["context"].in_preferences_window is False + assert voice == [{"family": "default"}] + + def test_get_voice_no_active_script(self, test_context: CthulhuTestContext) -> None: + """Test _get_voice returns empty list when no active script.""" + + essential_modules = self._setup_speech_output_dependencies(test_context) + script_manager = essential_modules["cthulhu.script_manager"].get_manager() + script_manager.get_active_script.return_value = None + + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + voice = presenter._get_voice(text="test") + + assert voice == [] + + def test_speak_message(self, test_context: CthulhuTestContext) -> None: + """Test speak_message speaks text via speech module.""" + + essential_modules = self._setup_speech_output_dependencies(test_context) + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + presenter.speak_message("Hello world") + + essential_modules["cthulhu.speech"].speak.assert_called() + + def test_speak_message_non_string(self, test_context: CthulhuTestContext) -> None: + """Test speak_message with non-string returns early.""" + + essential_modules = self._setup_speech_output_dependencies(test_context) + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + presenter.speak_message(123) # type: ignore + + essential_modules["cthulhu.debug"].print_exception.assert_called() + essential_modules["cthulhu.speech"].speak.assert_not_called() + + def test_speak_message_only_displayed_text(self, test_context: CthulhuTestContext) -> None: + """Test speak_message when only_speak_displayed_text is true.""" + + essential_modules = self._setup_speech_output_dependencies(test_context) + + from cthulhu import gsettings_registry + from cthulhu.speech_presenter import SpeechPresenter + + gsettings_registry.get_registry().set_runtime_value( + "speech", + "only-speak-displayed-text", + True, + ) + + presenter = SpeechPresenter() + presenter.speak_message("Hello world") + + essential_modules["cthulhu.speech"].speak.assert_not_called() + + def test_speak_character(self, test_context: CthulhuTestContext) -> None: + """Test speak_character speaks a single character.""" + + essential_modules = self._setup_speech_output_dependencies(test_context) + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + presenter.speak_character("a") + + essential_modules["cthulhu.speech"].speak_character.assert_called_once() + + def test_spell_item(self, test_context: CthulhuTestContext) -> None: + """Test spell_item speaks each character.""" + + essential_modules = self._setup_speech_output_dependencies(test_context) + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + presenter.spell_item("abc") + + assert essential_modules["cthulhu.speech"].speak_character.call_count == 3 + + def test_spell_phonetically(self, test_context: CthulhuTestContext) -> None: + """Test spell_phonetically speaks phonetic names.""" + + essential_modules = self._setup_speech_output_dependencies(test_context) + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + presenter.spell_phonetically("ab") + + essential_modules["cthulhu.phonnames"].get_phonetic_name.assert_called() + assert essential_modules["cthulhu.speech"].speak.call_count >= 2 + + def test_speak_contents(self, test_context: CthulhuTestContext) -> None: + """Test speak_contents generates and speaks contents.""" + + essential_modules = self._setup_speech_output_dependencies(test_context) + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + mock_contents = [(test_context.Mock(), 0, 10, "test text")] + presenter.speak_contents(mock_contents) + + script_manager = essential_modules["cthulhu.script_manager"].get_manager() + script = script_manager.get_active_script() + script.get_speech_generator().generate_contents.assert_called_once() + essential_modules["cthulhu.speech"].speak.assert_called() + + def test_speak_contents_no_active_script(self, test_context: CthulhuTestContext) -> None: + """Test speak_contents returns early when no active script.""" + + essential_modules = self._setup_speech_output_dependencies(test_context) + script_manager = essential_modules["cthulhu.script_manager"].get_manager() + script_manager.get_active_script.return_value = None + + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + mock_contents = [(test_context.Mock(), 0, 10, "test text")] + presenter.speak_contents(mock_contents) + + essential_modules["cthulhu.speech"].speak.assert_not_called() + + def test_present_key_event(self, test_context: CthulhuTestContext) -> None: + """Test present_key_event speaks key via speech module.""" + + essential_modules = self._setup_speech_output_dependencies(test_context) + from cthulhu.speech_presenter import SpeechPresenter + + presenter = SpeechPresenter() + mock_event = test_context.Mock() + mock_event.is_printable_key.return_value = True + mock_event.get_key_name.return_value = "a" + presenter.present_key_event(mock_event) + + essential_modules["cthulhu.speech"].speak_key_event.assert_called_once() + + def test_get_set_monitor_is_enabled(self, test_context: CthulhuTestContext) -> None: + """Test getting and setting speech monitor enabled status.""" + + self._setup_dependencies(test_context) + from cthulhu.speech_presenter import get_presenter + + presenter = get_presenter() + + result = presenter.set_monitor_is_enabled(True) + assert result is True + assert presenter.get_monitor_is_enabled() is True + + result = presenter.set_monitor_is_enabled(False) + assert result is True + assert presenter.get_monitor_is_enabled() is False + + def test_ensure_monitor_creates_when_enabled(self, test_context: CthulhuTestContext) -> None: + """Test _ensure_monitor creates monitor on demand when enabled.""" + + essential_modules = self._setup_dependencies(test_context) + speech_monitor_mock = essential_modules["cthulhu.speech_monitor"] + from cthulhu.speech_presenter import get_presenter + + presenter = get_presenter() + presenter.set_monitor_is_enabled(True) + mock_monitor = test_context.Mock() + speech_monitor_mock.SpeechMonitor.return_value = mock_monitor + + result = presenter._ensure_monitor() + + speech_monitor_mock.SpeechMonitor.assert_called_once() + mock_monitor.show_all.assert_called_once() + assert result is mock_monitor + + def test_ensure_monitor_returns_none_when_disabled(self, test_context: CthulhuTestContext) -> None: + """Test _ensure_monitor returns None when disabled.""" + + self._setup_dependencies(test_context) + from cthulhu.speech_presenter import get_presenter + + presenter = get_presenter() + presenter.set_monitor_is_enabled(False) + mock_monitor = test_context.Mock() + presenter._monitor = mock_monitor + + result = presenter._ensure_monitor() + + assert result is None + + def test_write_to_monitor(self, test_context: CthulhuTestContext) -> None: + """Test write_to_monitor writes text when monitor active and not focused.""" + + essential_modules = self._setup_dependencies(test_context) + speech_monitor_mock = essential_modules["cthulhu.speech_monitor"] + from cthulhu.speech_presenter import get_presenter + + presenter = get_presenter() + presenter.set_monitor_is_enabled(True) + mock_monitor = test_context.Mock() + mock_monitor.has_toplevel_focus.return_value = False + speech_monitor_mock.SpeechMonitor.return_value = mock_monitor + + presenter.write_to_monitor("hello world") + + mock_monitor.write_text.assert_called_once_with("hello world") + + def test_write_to_monitor_skips_when_focused(self, test_context: CthulhuTestContext) -> None: + """Test write_to_monitor skips when monitor has focus.""" + + essential_modules = self._setup_dependencies(test_context) + speech_monitor_mock = essential_modules["cthulhu.speech_monitor"] + from cthulhu.speech_presenter import get_presenter + + presenter = get_presenter() + presenter.set_monitor_is_enabled(True) + mock_monitor = test_context.Mock() + mock_monitor.has_toplevel_focus.return_value = True + speech_monitor_mock.SpeechMonitor.return_value = mock_monitor + + presenter.write_to_monitor("hello world") + + mock_monitor.write_text.assert_not_called() + + def test_write_key_to_monitor(self, test_context: CthulhuTestContext) -> None: + """Test write_key_to_monitor writes key event.""" + + essential_modules = self._setup_dependencies(test_context) + speech_monitor_mock = essential_modules["cthulhu.speech_monitor"] + from cthulhu.speech_presenter import get_presenter + + presenter = get_presenter() + presenter.set_monitor_is_enabled(True) + mock_monitor = test_context.Mock() + mock_monitor.has_toplevel_focus.return_value = False + speech_monitor_mock.SpeechMonitor.return_value = mock_monitor + + presenter.write_key_to_monitor("Return") + + mock_monitor.write_key_event.assert_called_once_with("Return") + + def test_destroy_monitor(self, test_context: CthulhuTestContext) -> None: + """Test destroy_monitor destroys existing speech monitor.""" + + self._setup_dependencies(test_context) + from cthulhu.speech_presenter import get_presenter + + presenter = get_presenter() + mock_monitor = test_context.Mock() + presenter._monitor = mock_monitor + + presenter.destroy_monitor() + + mock_monitor.destroy.assert_called_once() + assert presenter._monitor is None + + def test_destroy_monitor_no_op_when_none(self, test_context: CthulhuTestContext) -> None: + """Test destroy_monitor does nothing when no monitor exists.""" + + self._setup_dependencies(test_context) + from cthulhu.speech_presenter import get_presenter + + presenter = get_presenter() + assert presenter._monitor is None + + presenter.destroy_monitor() + + assert presenter._monitor is None diff --git a/tests/test_spellcheck_presenter.py b/tests/test_spellcheck_presenter.py new file mode 100644 index 0000000..3610b62 --- /dev/null +++ b/tests/test_spellcheck_presenter.py @@ -0,0 +1,239 @@ +# Unit tests for spellcheck_presenter.py methods. +# +# Copyright 2026 Igalia, S.L. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +# pylint: disable=import-outside-toplevel +# pylint: disable=protected-access +# pylint: disable=no-member + +"""Unit tests for spellcheck_presenter.py methods.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from unittest.mock import MagicMock + + from cthulhu_test_context import CthulhuTestContext + + +@pytest.mark.unit +class TestSpellCheckPresenter: + """Test SpellCheckPresenter class methods.""" + + def _setup_dependencies(self, test_context: CthulhuTestContext) -> dict[str, MagicMock]: + """Returns dependencies for spellcheck_presenter module testing.""" + + additional_modules: list[str] = [ + "cthulhu.ax_utilities", + "cthulhu.ax_text", + "cthulhu.ax_object", + "cthulhu.braille", + "cthulhu.focus_manager", + "cthulhu.input_event_manager", + "cthulhu.messages", + "cthulhu.object_properties", + "cthulhu.preferences_grid_base", + "cthulhu.presentation_manager", + "cthulhu.speech_presenter", + ] + essential_modules = test_context.setup_shared_dependencies(additional_modules) + + debug_mock = essential_modules["cthulhu.debug"] + debug_mock.print_message = test_context.Mock() + debug_mock.LEVEL_INFO = 800 + + from cthulhu import gsettings_registry + + gsettings_registry.get_registry().clear_runtime_values() + + return essential_modules + + def test_get_presenter(self, test_context: CthulhuTestContext) -> None: + """Test get_presenter returns a SpellCheckPresenter instance.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.spellcheck_presenter import SpellCheckPresenter, get_presenter + + presenter = get_presenter() + assert presenter is not None + assert isinstance(presenter, SpellCheckPresenter) + + dbus_service_mock = essential_modules["cthulhu.dbus_service"] + controller = dbus_service_mock.get_remote_controller.return_value + controller.register_decorated_module.assert_called_with("SpellCheckPresenter", presenter) + + def test_get_spell_error_true(self, test_context: CthulhuTestContext) -> None: + """Test get_spell_error returns True when setting is True.""" + + self._setup_dependencies(test_context) + from cthulhu.spellcheck_presenter import SpellCheckPresenter + + presenter = SpellCheckPresenter() + result = presenter.get_spell_error() + assert result is True + + def test_get_spell_error_false(self, test_context: CthulhuTestContext) -> None: + """Test get_spell_error returns False when setting is False.""" + + self._setup_dependencies(test_context) + + from cthulhu import gsettings_registry + from cthulhu.spellcheck_presenter import SpellCheckPresenter + + presenter = SpellCheckPresenter() + gsettings_registry.get_registry().set_runtime_value("spellcheck", "spell-error", False) + result = presenter.get_spell_error() + assert result is False + + def test_set_spell_error(self, test_context: CthulhuTestContext) -> None: + """Test set_spell_error updates settings.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.spellcheck_presenter import SpellCheckPresenter + + presenter = SpellCheckPresenter() + result = presenter.set_spell_error(False) + + assert result is True + assert presenter.get_spell_error() is False + essential_modules["cthulhu.debug"].print_message.assert_called() + + def test_set_spell_error_same_value(self, test_context: CthulhuTestContext) -> None: + """Test set_spell_error returns early when value unchanged.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.spellcheck_presenter import SpellCheckPresenter + + presenter = SpellCheckPresenter() + essential_modules["cthulhu.debug"].print_message.reset_mock() + + result = presenter.set_spell_error(True) + assert result is True + # Debug message for setting change should NOT be called + calls = essential_modules["cthulhu.debug"].print_message.call_args_list + setting_calls = [c for c in calls if "Setting spell error" in str(c)] + assert len(setting_calls) == 0 + + def test_get_spell_suggestion_true(self, test_context: CthulhuTestContext) -> None: + """Test get_spell_suggestion returns True when setting is True.""" + + self._setup_dependencies(test_context) + from cthulhu.spellcheck_presenter import SpellCheckPresenter + + presenter = SpellCheckPresenter() + result = presenter.get_spell_suggestion() + assert result is True + + def test_get_spell_suggestion_false(self, test_context: CthulhuTestContext) -> None: + """Test get_spell_suggestion returns False when setting is False.""" + + self._setup_dependencies(test_context) + + from cthulhu import gsettings_registry + from cthulhu.spellcheck_presenter import SpellCheckPresenter + + presenter = SpellCheckPresenter() + gsettings_registry.get_registry().set_runtime_value("spellcheck", "spell-suggestion", False) + result = presenter.get_spell_suggestion() + assert result is False + + def test_set_spell_suggestion(self, test_context: CthulhuTestContext) -> None: + """Test set_spell_suggestion updates settings.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.spellcheck_presenter import SpellCheckPresenter + + presenter = SpellCheckPresenter() + result = presenter.set_spell_suggestion(False) + + assert result is True + assert presenter.get_spell_suggestion() is False + essential_modules["cthulhu.debug"].print_message.assert_called() + + def test_set_spell_suggestion_same_value(self, test_context: CthulhuTestContext) -> None: + """Test set_spell_suggestion returns early when value unchanged.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.spellcheck_presenter import SpellCheckPresenter + + presenter = SpellCheckPresenter() + essential_modules["cthulhu.debug"].print_message.reset_mock() + + result = presenter.set_spell_suggestion(True) + assert result is True + # Debug message for setting change should NOT be called + calls = essential_modules["cthulhu.debug"].print_message.call_args_list + setting_calls = [c for c in calls if "Setting spell suggestion" in str(c)] + assert len(setting_calls) == 0 + + def test_get_present_context_true(self, test_context: CthulhuTestContext) -> None: + """Test get_present_context returns True when setting is True.""" + + self._setup_dependencies(test_context) + from cthulhu.spellcheck_presenter import SpellCheckPresenter + + presenter = SpellCheckPresenter() + result = presenter.get_present_context() + assert result is True + + def test_get_present_context_false(self, test_context: CthulhuTestContext) -> None: + """Test get_present_context returns False when setting is False.""" + + self._setup_dependencies(test_context) + + from cthulhu import gsettings_registry + from cthulhu.spellcheck_presenter import SpellCheckPresenter + + presenter = SpellCheckPresenter() + gsettings_registry.get_registry().set_runtime_value("spellcheck", "present-context", False) + result = presenter.get_present_context() + assert result is False + + def test_set_present_context(self, test_context: CthulhuTestContext) -> None: + """Test set_present_context updates settings.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.spellcheck_presenter import SpellCheckPresenter + + presenter = SpellCheckPresenter() + result = presenter.set_present_context(False) + + assert result is True + assert presenter.get_present_context() is False + essential_modules["cthulhu.debug"].print_message.assert_called() + + def test_set_present_context_same_value(self, test_context: CthulhuTestContext) -> None: + """Test set_present_context returns early when value unchanged.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.spellcheck_presenter import SpellCheckPresenter + + presenter = SpellCheckPresenter() + essential_modules["cthulhu.debug"].print_message.reset_mock() + + result = presenter.set_present_context(True) + assert result is True + # Debug message for setting change should NOT be called + calls = essential_modules["cthulhu.debug"].print_message.call_args_list + setting_calls = [c for c in calls if "Setting present context" in str(c)] + assert len(setting_calls) == 0 diff --git a/tests/test_structural_navigator.py b/tests/test_structural_navigator.py new file mode 100644 index 0000000..498a18e --- /dev/null +++ b/tests/test_structural_navigator.py @@ -0,0 +1,1616 @@ +# Unit tests for structural_navigator.py methods. +# +# Copyright 2025 Igalia, S.L. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +# pylint: disable=wrong-import-position +# pylint: disable=import-outside-toplevel +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-statements +# pylint: disable=protected-access +# pylint: disable=too-many-arguments +# pylint: disable=too-many-positional-arguments +# pylint: disable=too-many-locals +# pylint: disable=too-many-lines + +"""Unit tests for structural_navigator.py methods.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import gi +import pytest + +gi.require_version("Atspi", "2.0") +from gi.repository import Atspi + +if TYPE_CHECKING: + from cthulhu_test_context import CthulhuTestContext + + +@pytest.mark.unit +class TestStructuralNavigator: + """Test StructuralNavigator class methods.""" + + def _setup_dependencies(self, test_context): + """Set up dependencies for structural_navigator module testing.""" + + additional_modules = [ + "cthulhu.cmdnames", + "cthulhu.command_manager", + "cthulhu.dbus_service", + "cthulhu.debug", + "cthulhu.focus_manager", + "cthulhu.guilabels", + "cthulhu.input_event_manager", + "cthulhu.keybindings", + "cthulhu.messages", + "cthulhu.object_properties", + "cthulhu.cthulhu_gui_navlist", + "cthulhu.cthulhu_i18n", + "cthulhu.script_manager", + "cthulhu.AXHypertext", + "cthulhu.AXObject", + "cthulhu.AXTable", + "cthulhu.AXText", + "cthulhu.AXUtilities", + "cthulhu.input_event", + "cthulhu.braille_presenter", + "cthulhu.presentation_manager", + ] + essential_modules = test_context.setup_shared_dependencies(additional_modules) + self._setup_mocks(test_context, essential_modules) + + return essential_modules + + def _setup_cycle_navigation_mode_mocks( + self, + test_context, + nav, + current_mode, + supports_collection=True, + ): + """Set up common navigator mocks for cycle_mode testing scenarios.""" + + mock_get_mode = test_context.Mock(return_value=current_mode) + test_context.patch_object(nav, "get_mode", new=mock_get_mode) + + mock_set_mode = test_context.Mock() + test_context.patch_object(nav, "set_mode", new=mock_set_mode) + + test_context.patch_object(nav, "_is_active_script", return_value=True) + + mock_determine_root = None + if supports_collection: + mock_root = test_context.Mock() + mock_determine_root = test_context.Mock(return_value=mock_root) + test_context.patch_object(nav, "_determine_root_container", new=mock_determine_root) + + return mock_get_mode, mock_set_mode, mock_determine_root + + def _setup_mocks(self, test_context, essential_modules) -> None: + """Set up common mocks for StructuralNavigator tests.""" + + input_event_handler_mock = test_context.Mock() + essential_modules["cthulhu.input_event"].InputEventHandler = test_context.Mock( + return_value=input_event_handler_mock, + ) + key_bindings_instance = test_context.Mock() + essential_modules["cthulhu.keybindings"].KeyBindings = test_context.Mock( + return_value=key_bindings_instance, + ) + controller_mock = test_context.Mock() + controller_mock.register_decorated_module.return_value = None + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = controller_mock + essential_modules["cthulhu.debug"].print_message = test_context.Mock() + essential_modules["cthulhu.debug"].LEVEL_INFO = 800 + focus_manager_instance = test_context.Mock() + focus_manager_instance.get_locus_of_focus.return_value = None + essential_modules["cthulhu.focus_manager"].get_manager.return_value = focus_manager_instance + essential_modules["cthulhu.AXObject"].get_parent.return_value = None + essential_modules["cthulhu.AXObject"].supports_collection.return_value = True + essential_modules["cthulhu.AXObject"].find_ancestor.return_value = None + essential_modules["cthulhu.AXObject"].get_attributes_dict.return_value = {} + essential_modules["cthulhu.AXUtilities"].is_web_application.return_value = False + essential_modules["cthulhu.AXUtilities"].is_heading.return_value = False + essential_modules["cthulhu.AXUtilities"].is_live_region.return_value = False + essential_modules["cthulhu.AXUtilities"].find_descendant.return_value = None + script_manager_instance = test_context.Mock() + script_manager_instance.get_active_script.return_value = None + essential_modules["cthulhu.script_manager"].get_manager.return_value = script_manager_instance + essential_modules["cthulhu.cthulhu_i18n"]._ = lambda msg: msg + essential_modules["cthulhu.messages"].STRUCTURAL_NAVIGATION_KEYS_DOCUMENT = "Document mode" + essential_modules["cthulhu.messages"].STRUCTURAL_NAVIGATION_KEYS_GUI = "GUI mode" + essential_modules["cthulhu.messages"].STRUCTURAL_NAVIGATION_KEYS_OFF = "Off mode" + essential_modules[ + "cthulhu.messages" + ].STRUCTURAL_NAVIGATION_NOT_SUPPORTED_FULL = "Not supported full" + essential_modules[ + "cthulhu.messages" + ].STRUCTURAL_NAVIGATION_NOT_SUPPORTED_BRIEF = "Not supported brief" + essential_modules["cthulhu.messages"].WRAPPING_TO_TOP = "Wrapping to top" + essential_modules["cthulhu.messages"].WRAPPING_TO_BOTTOM = "Wrapping to bottom" + essential_modules["cthulhu.cmdnames"].STRUCTURAL_NAVIGATION_MODE_CYCLE = "cycle_mode" + essential_modules["cthulhu.cmdnames"].BLOCKQUOTE_PREV = "previous_blockquote" + essential_modules["cthulhu.cmdnames"].BLOCKQUOTE_NEXT = "next_blockquote" + essential_modules["cthulhu.cmdnames"].BLOCKQUOTE_LIST = "list_blockquotes" + essential_modules["cthulhu.cmdnames"].BUTTON_PREV = "previous_button" + essential_modules["cthulhu.cmdnames"].BUTTON_NEXT = "next_button" + essential_modules["cthulhu.cmdnames"].BUTTON_LIST = "list_buttons" + essential_modules["cthulhu.cmdnames"].CHECK_BOX_PREV = "previous_checkbox" + essential_modules["cthulhu.cmdnames"].CHECK_BOX_NEXT = "next_checkbox" + essential_modules["cthulhu.cmdnames"].CHECK_BOX_LIST = "list_checkboxes" + essential_modules["cthulhu.cmdnames"].COMBO_BOX_PREV = "previous_combobox" + essential_modules["cthulhu.cmdnames"].COMBO_BOX_NEXT = "next_combobox" + essential_modules["cthulhu.cmdnames"].COMBO_BOX_LIST = "list_comboboxes" + essential_modules["cthulhu.cmdnames"].ENTRY_PREV = "previous_entry" + essential_modules["cthulhu.cmdnames"].ENTRY_NEXT = "next_entry" + essential_modules["cthulhu.cmdnames"].ENTRY_LIST = "list_entries" + essential_modules["cthulhu.cmdnames"].FORM_FIELD_PREV = "previous_form_field" + essential_modules["cthulhu.cmdnames"].FORM_FIELD_NEXT = "next_form_field" + essential_modules["cthulhu.cmdnames"].FORM_FIELD_LIST = "list_form_fields" + essential_modules["cthulhu.cmdnames"].HEADING_PREV = "previous_heading" + essential_modules["cthulhu.cmdnames"].HEADING_NEXT = "next_heading" + essential_modules["cthulhu.cmdnames"].HEADING_LIST = "list_headings" + essential_modules["cthulhu.cmdnames"].HEADING_AT_LEVEL_PREV = "previous_heading_level_%d" + essential_modules["cthulhu.cmdnames"].HEADING_AT_LEVEL_NEXT = "next_heading_level_%d" + essential_modules["cthulhu.cmdnames"].HEADING_AT_LEVEL_LIST = "list_headings_level_%d" + essential_modules["cthulhu.cmdnames"].IFRAME_PREV = "previous_iframe" + essential_modules["cthulhu.cmdnames"].IFRAME_NEXT = "next_iframe" + essential_modules["cthulhu.cmdnames"].IFRAME_LIST = "list_iframes" + essential_modules["cthulhu.cmdnames"].IMAGE_PREV = "previous_image" + essential_modules["cthulhu.cmdnames"].IMAGE_NEXT = "next_image" + essential_modules["cthulhu.cmdnames"].IMAGE_LIST = "list_images" + essential_modules["cthulhu.cmdnames"].LANDMARK_PREV = "previous_landmark" + essential_modules["cthulhu.cmdnames"].LANDMARK_NEXT = "next_landmark" + essential_modules["cthulhu.cmdnames"].LANDMARK_LIST = "list_landmarks" + essential_modules["cthulhu.cmdnames"].LIST_PREV = "previous_list" + essential_modules["cthulhu.cmdnames"].LIST_NEXT = "next_list" + essential_modules["cthulhu.cmdnames"].LIST_LIST = "list_lists" + essential_modules["cthulhu.cmdnames"].LIST_ITEM_PREV = "previous_list_item" + essential_modules["cthulhu.cmdnames"].LIST_ITEM_NEXT = "next_list_item" + essential_modules["cthulhu.cmdnames"].LIST_ITEM_LIST = "list_list_items" + essential_modules["cthulhu.cmdnames"].LIVE_REGION_PREV = "previous_live_region" + essential_modules["cthulhu.cmdnames"].LIVE_REGION_NEXT = "next_live_region" + essential_modules["cthulhu.cmdnames"].LIVE_REGION_LAST = "last_live_region" + essential_modules["cthulhu.cmdnames"].PARAGRAPH_PREV = "previous_paragraph" + essential_modules["cthulhu.cmdnames"].PARAGRAPH_NEXT = "next_paragraph" + essential_modules["cthulhu.cmdnames"].PARAGRAPH_LIST = "list_paragraphs" + essential_modules["cthulhu.cmdnames"].RADIO_BUTTON_PREV = "previous_radio_button" + essential_modules["cthulhu.cmdnames"].RADIO_BUTTON_NEXT = "next_radio_button" + essential_modules["cthulhu.cmdnames"].RADIO_BUTTON_LIST = "list_radio_buttons" + essential_modules["cthulhu.cmdnames"].SEPARATOR_PREV = "previous_separator" + essential_modules["cthulhu.cmdnames"].SEPARATOR_NEXT = "next_separator" + essential_modules["cthulhu.cmdnames"].TABLE_PREV = "previous_table" + essential_modules["cthulhu.cmdnames"].TABLE_NEXT = "next_table" + essential_modules["cthulhu.cmdnames"].TABLE_LIST = "list_tables" + essential_modules["cthulhu.cmdnames"].LINK_PREV = "previous_link" + essential_modules["cthulhu.cmdnames"].LINK_NEXT = "next_link" + essential_modules["cthulhu.cmdnames"].LINK_LIST = "list_links" + essential_modules["cthulhu.cmdnames"].UNVISITED_LINK_PREV = "previous_unvisited_link" + essential_modules["cthulhu.cmdnames"].UNVISITED_LINK_NEXT = "next_unvisited_link" + essential_modules["cthulhu.cmdnames"].UNVISITED_LINK_LIST = "list_unvisited_links" + essential_modules["cthulhu.cmdnames"].VISITED_LINK_PREV = "previous_visited_link" + essential_modules["cthulhu.cmdnames"].VISITED_LINK_NEXT = "next_visited_link" + essential_modules["cthulhu.cmdnames"].VISITED_LINK_LIST = "list_visited_links" + essential_modules["cthulhu.cmdnames"].LARGE_OBJECT_PREV = "previous_large_object" + essential_modules["cthulhu.cmdnames"].LARGE_OBJECT_NEXT = "next_large_object" + essential_modules["cthulhu.cmdnames"].LARGE_OBJECT_LIST = "list_large_objects" + essential_modules["cthulhu.cmdnames"].CLICKABLE_PREV = "previous_clickable" + essential_modules["cthulhu.cmdnames"].CLICKABLE_NEXT = "next_clickable" + essential_modules["cthulhu.cmdnames"].CLICKABLE_LIST = "list_clickables" + + def test_init(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator initialization through get_navigator function.""" + + self._setup_dependencies(test_context) + from cthulhu import command_manager + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + assert ( + nav._last_input_event is None or nav._last_input_event is not None + ) # May be set by other tests + assert isinstance(nav._suspended, bool) + assert isinstance(nav._mode_for_script, dict) + # Verify commands are registered in CommandManager + cmd_manager = command_manager.get_manager() + assert cmd_manager.get_command("structural_navigator_mode_cycle") is not None + assert cmd_manager.get_command("previous_button") is not None + assert cmd_manager.get_command("next_button") is not None + + def test_commands_registered(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.setup registers commands with CommandManager.""" + + self._setup_dependencies(test_context) + from cthulhu import command_manager + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + nav.set_up_commands() + # Commands are now registered in CommandManager + cmd_manager = command_manager.get_manager() + assert cmd_manager.get_command("structural_navigator_mode_cycle") is not None + assert cmd_manager.get_command("previous_button") is not None + assert cmd_manager.get_command("next_button") is not None + assert cmd_manager.get_command("list_buttons") is not None + + def test_setup_creates_heading_level_commands(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.setup creates heading level commands.""" + + self._setup_dependencies(test_context) + from cthulhu import command_manager + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + nav.set_up_commands() + cmd_manager = command_manager.get_manager() + for level in range(1, 7): + assert cmd_manager.get_command(f"previous_heading_level_{level}") is not None + assert cmd_manager.get_command(f"next_heading_level_{level}") is not None + assert cmd_manager.get_command(f"list_headings_level_{level}") is not None + + @pytest.mark.parametrize( + "current_mode, expected_next_mode, supports_collection", + [ + pytest.param("OFF", "DOCUMENT", True, id="off_to_document"), + pytest.param("DOCUMENT", "GUI", True, id="document_to_gui"), + pytest.param("GUI", "OFF", False, id="gui_to_off"), + ], + ) + def test_cycle_mode_transitions( + self, + test_context: CthulhuTestContext, + current_mode, + expected_next_mode, + supports_collection, + ) -> None: + """Test StructuralNavigator.cycle_mode transitions.""" + + essential_modules = self._setup_dependencies(test_context) + mock_script = test_context.Mock() + from cthulhu.structural_navigator import NavigationMode, get_navigator + + nav = get_navigator() + current_nav_mode = getattr(NavigationMode, current_mode) + expected_nav_mode = getattr(NavigationMode, expected_next_mode) + + mock_get_mode, mock_set_mode = self._setup_cycle_navigation_mode_mocks( + test_context, + nav, + current_nav_mode, + supports_collection=supports_collection, + )[:2] + + if supports_collection: + essential_modules["cthulhu.AXObject"].supports_collection.return_value = True + + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = nav.cycle_mode(mock_script, None, True) + assert result is True + mock_get_mode.assert_called_once_with(mock_script) + mock_set_mode.assert_called_once_with(mock_script, expected_nav_mode) + pres_manager.present_message.assert_called() + + @pytest.mark.parametrize( + "scenario, notify_user, is_active_script, expected_result, expects_message_call", + [ + pytest.param("no_notify", False, True, True, False, id="no_notify_no_message"), + pytest.param( + "inactive_script", + True, + False, + False, + None, + id="inactive_script_returns_false", + ), + ], + ) + def test_cycle_mode_edge_scenarios( + self, + test_context: CthulhuTestContext, + scenario, + notify_user, + is_active_script, + expected_result, + expects_message_call, + ) -> None: + """Test StructuralNavigator.cycle_mode edge scenarios.""" + + essential_modules = self._setup_dependencies(test_context) + mock_script = test_context.Mock() + from cthulhu.structural_navigator import NavigationMode, get_navigator + + nav = get_navigator() + + if scenario == "no_notify": + self._setup_cycle_navigation_mode_mocks(test_context, nav, NavigationMode.OFF) + essential_modules["cthulhu.AXObject"].supports_collection.return_value = True + else: + mock_is_active_script = test_context.Mock(return_value=is_active_script) + test_context.patch_object(nav, "_is_active_script", new=mock_is_active_script) + + result = nav.cycle_mode(mock_script, None, notify_user) + assert result is expected_result + + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + if expects_message_call is False: + pres_manager.present_message.assert_not_called() + elif scenario == "inactive_script": + mock_is_active_script.assert_called_once_with(mock_script) + + @pytest.mark.parametrize( + "suspend_value, is_active, initial_suspended, expected_suspended, expects_debug, " + "expects_cmd_mgr", + [ + pytest.param(True, True, False, True, True, True, id="suspend_navigation"), + pytest.param(False, True, True, False, True, True, id="resume_navigation"), + pytest.param( + True, + False, + False, + False, + False, + False, + id="inactive_script_no_change", + ), + ], + ) + def test_suspend_commands_scenarios( + self, + test_context: CthulhuTestContext, + suspend_value, + is_active, + initial_suspended, + expected_suspended, + expects_debug, + expects_cmd_mgr, + ) -> None: + """Test StructuralNavigator.suspend_commands various scenarios.""" + + essential_modules = self._setup_dependencies(test_context) + + # Mock command_manager + mock_cmd_mgr = test_context.Mock() + essential_modules["cthulhu.command_manager"].get_manager.return_value = mock_cmd_mgr + + mock_script = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + nav._suspended = initial_suspended + test_context.patch_object(nav, "_is_active_script", return_value=is_active) + mock_cmd_mgr = test_context.Mock() + essential_modules["cthulhu.command_manager"].get_manager.return_value = mock_cmd_mgr + + nav.suspend_commands(mock_script, suspend_value, "test reason") + + assert nav._suspended == expected_suspended + + if expects_debug: + essential_modules["cthulhu.debug"].print_message.assert_any_call( + essential_modules["cthulhu.debug"].LEVEL_INFO, + f"STRUCTURAL NAVIGATOR: Suspended: {suspend_value}: test reason", + True, + ) + + if expects_cmd_mgr: + mock_cmd_mgr.set_group_suspended.assert_called_once() + else: + mock_cmd_mgr.set_group_suspended.assert_not_called() + + def test_get_object_in_direction_empty_objects(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator._get_object_in_direction with empty objects list.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + result = nav._get_object_in_direction(mock_script, [], True) + assert result is None + + def test_get_object_in_direction_next_object(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator._get_object_in_direction returns next object.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + mock_obj1 = test_context.Mock() + mock_obj2 = test_context.Mock() + mock_obj3 = test_context.Mock() + objects = [mock_obj1, mock_obj2, mock_obj3] + + essential_modules[ + "cthulhu.focus_manager" + ].get_manager.return_value.get_locus_of_focus.return_value = mock_obj2 + essential_modules["cthulhu.AXObject"].get_parent.return_value = mock_obj2 + + def mock_get_path(obj): + if obj == mock_obj1: + return [0, 0] + if obj == mock_obj2: + return [0, 1] + if obj == mock_obj3: + return [0, 2] + return [] + + essential_modules["cthulhu.AXObject"].get_path.side_effect = mock_get_path + + mock_script = test_context.Mock() + + def mock_path_comparison(path1, _path2): + if path1 == [0, 0]: # obj1 + return -1 + if path1 == [0, 1]: # obj2 + return 0 + if path1 == [0, 2]: # obj3 + return 1 + return 0 + + mock_script.utilities.path_comparison.side_effect = mock_path_comparison + + result = nav._get_object_in_direction(mock_script, objects, True) + assert result == mock_obj3 + + def test_get_object_in_direction_previous_object(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator._get_object_in_direction returns previous object.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu import ax_utilities + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(ax_utilities.AXUtilities, "is_live_region", return_value=False) + mock_obj1 = test_context.Mock() + mock_obj2 = test_context.Mock() + mock_obj3 = test_context.Mock() + objects = [mock_obj1, mock_obj2, mock_obj3] + + essential_modules[ + "cthulhu.focus_manager" + ].get_manager.return_value.get_locus_of_focus.return_value = mock_obj2 + essential_modules["cthulhu.AXObject"].get_parent.return_value = mock_obj2 + + def mock_get_path(obj): + if obj == mock_obj1: + return [0, 0] + if obj == mock_obj2: + return [0, 1] + if obj == mock_obj3: + return [0, 2] # Later in tree + return [] + + essential_modules["cthulhu.AXObject"].get_path.side_effect = mock_get_path + + mock_script = test_context.Mock() + + def mock_path_comparison(path1, _path2): + if path1 == [0, 0]: # obj1 + return -1 + if path1 == [0, 1]: # obj2 + return 0 + if path1 == [0, 2]: # obj3 + return 1 + return 0 + + mock_script.utilities.path_comparison.side_effect = mock_path_comparison + + result = nav._get_object_in_direction(mock_script, objects, False) + assert result == mock_obj1 + + def test_get_object_in_direction_wrap_to_beginning(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator._get_object_in_direction wraps to beginning when at end.""" + + self._setup_dependencies(test_context) + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + mock_result = test_context.Mock() + mock_script = test_context.Mock() + + test_context.patch_object(nav, "_get_object_in_direction", return_value=mock_result) + + result = nav._get_object_in_direction(mock_script, [], True, True) + assert result == mock_result + + def test_get_object_in_direction_wrap_to_end(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator._get_object_in_direction wraps to end when at beginning.""" + + self._setup_dependencies(test_context) + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + mock_result = test_context.Mock() + mock_script = test_context.Mock() + + test_context.patch_object(nav, "_get_object_in_direction", return_value=mock_result) + + result = nav._get_object_in_direction(mock_script, [], False, True) + assert result == mock_result + + def test_get_object_in_direction_no_wrap_at_end(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator._get_object_in_direction returns None at end when no wrap.""" + + self._setup_dependencies(test_context) + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + mock_script = test_context.Mock() + + test_context.patch_object(nav, "_get_object_in_direction", return_value=None) + + result = nav._get_object_in_direction(mock_script, [], True, False) + assert result is None + + def test_previous_button_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.previous_button method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + mock_get_buttons = test_context.Mock(return_value=[]) + test_context.patch_object(nav, "_get_all_buttons", new=mock_get_buttons) + mock_get_in_direction = test_context.Mock(return_value=None) + test_context.patch_object(nav, "_get_object_in_direction", new=mock_get_in_direction) + mock_present = test_context.Mock() + test_context.patch_object(nav, "_present_object", new=mock_present) + result = nav.previous_button(mock_script, mock_event, True) + assert result is True + mock_get_buttons.assert_called_once_with(mock_script) + mock_get_in_direction.assert_called_once() + mock_present.assert_called_once() + + def test_next_button_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.next_button method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + mock_get_buttons = test_context.Mock(return_value=[]) + test_context.patch_object(nav, "_get_all_buttons", new=mock_get_buttons) + mock_get_in_direction = test_context.Mock(return_value=None) + test_context.patch_object(nav, "_get_object_in_direction", new=mock_get_in_direction) + mock_present = test_context.Mock() + test_context.patch_object(nav, "_present_object", new=mock_present) + result = nav.next_button(mock_script, mock_event, True) + assert result is True + mock_get_buttons.assert_called_once_with(mock_script) + mock_get_in_direction.assert_called_once() + mock_present.assert_called_once() + + def test_list_buttons_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.list_buttons method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + mock_get_buttons = test_context.Mock(return_value=[]) + test_context.patch_object(nav, "_get_all_buttons", new=mock_get_buttons) + mock_present = test_context.Mock() + test_context.patch_object(nav, "_present_object_list", new=mock_present) + result = nav.list_buttons(mock_script, mock_event) + assert result is True + mock_get_buttons.assert_called_once_with(mock_script) + mock_present.assert_called_once() + + @pytest.mark.parametrize( + "is_same_script,expected", + [ + (True, True), + (False, False), + ], + ) + def test_is_active_script( + self, + test_context: CthulhuTestContext, + is_same_script: bool, + expected: bool, + ) -> None: + """Test StructuralNavigator._is_active_script with active and inactive scripts.""" + + essential_modules = self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_active_script = mock_script if is_same_script else test_context.Mock() + essential_modules[ + "cthulhu.script_manager" + ].get_manager.return_value.get_active_script.return_value = mock_active_script + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + result = nav._is_active_script(mock_script) + assert result is expected + + def test_get_mode_default(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.get_mode returns default mode for new script.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + from cthulhu.structural_navigator import NavigationMode, get_navigator + + nav = get_navigator() + result = nav.get_mode(mock_script) + assert result == NavigationMode.OFF + + def test_get_mode_cached(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.get_mode returns cached mode for known script.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + from cthulhu.structural_navigator import NavigationMode, get_navigator + + nav = get_navigator() + nav._mode_for_script[mock_script] = NavigationMode.DOCUMENT + result = nav.get_mode(mock_script) + assert result == NavigationMode.DOCUMENT + + def test_set_mode(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.set_mode sets mode for script.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + from cthulhu.structural_navigator import NavigationMode, get_navigator + + nav = get_navigator() + nav.set_mode(mock_script, NavigationMode.GUI) + assert nav._mode_for_script[mock_script] == NavigationMode.GUI + + def test_determine_root_container_document_mode(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator._determine_root_container for document mode.""" + essential_modules = self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_focus = test_context.Mock() + mock_document = test_context.Mock() + + essential_modules[ + "cthulhu.focus_manager" + ].get_manager().get_locus_of_focus.return_value = mock_focus + essential_modules["cthulhu.AXObject"].find_ancestor_inclusive.side_effect = ( + lambda focus, predicate: None + ) + mock_script.utilities.get_top_level_document_for_object.return_value = mock_document + + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_determine_root_container", return_value=mock_document) + + result = nav._determine_root_container(mock_script) + assert result == mock_document + + def test_determine_root_container_fallback(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator._determine_root_container fallback to app.""" + + essential_modules = self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_app = test_context.Mock(spec=Atspi.Accessible) + mock_script.app = mock_app + essential_modules["cthulhu.AXUtilities"].is_web_application.return_value = False + essential_modules["cthulhu.AXObject"].find_ancestor_inclusive.return_value = mock_app + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_determine_root_container", return_value=mock_app) + result = nav._determine_root_container(mock_script) + assert result == mock_app + + @pytest.mark.parametrize( + "element_type,ax_method", + [ + pytest.param("buttons", "find_all_buttons", id="get_all_buttons"), + pytest.param("headings", "find_all_headings", id="get_all_headings"), + pytest.param("links", "find_all_links", id="get_all_links"), + pytest.param("tables", "find_all_tables", id="get_all_tables"), + pytest.param("lists", "find_all_lists", id="get_all_lists"), + pytest.param("checkboxes", "find_all_check_boxes", id="get_all_checkboxes"), + pytest.param("entries", "find_all_editable_objects", id="get_all_entries"), + pytest.param("form_fields", "find_all_form_fields", id="get_all_form_fields"), + pytest.param("images", "find_all_images_and_image_maps", id="get_all_images"), + pytest.param("landmarks", "find_all_landmarks", id="get_all_landmarks"), + pytest.param("paragraphs", "find_all_paragraphs", id="get_all_paragraphs"), + ], + ) + def test_get_all_elements( + self, + test_context: CthulhuTestContext, + element_type: str, + ax_method: str, + ) -> None: + """Test StructuralNavigator._get_all_* methods with various element types.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + mock_elements = [test_context.Mock(), test_context.Mock()] + + if element_type == "lists": + test_context.patch( + f"cthulhu.structural_navigator.AXUtilities.{ax_method}", + side_effect=lambda root, + include_description_lists=False, + include_tab_lists=False, + pred=None: mock_elements, + ) + elif element_type == "paragraphs": + test_context.patch( + f"cthulhu.structural_navigator.AXUtilities.{ax_method}", + side_effect=lambda root, include_items=False, pred=None: mock_elements, + ) + else: + test_context.patch( + f"cthulhu.structural_navigator.AXUtilities.{ax_method}", + side_effect=lambda root, pred=None: mock_elements, + ) + + if element_type in ["links", "entries", "images"]: + mock_root = test_context.Mock() + test_context.patch_object( + nav, + "_determine_root_container", + side_effect=lambda script: mock_root, + ) + + navigation_method = getattr(nav, f"_get_all_{element_type}") + result = navigation_method(mock_script) + assert result == mock_elements + + def test_get_mode(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.get_mode method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + from cthulhu.structural_navigator import NavigationMode, get_navigator + + nav = get_navigator() + result = nav.get_mode(mock_script) + assert result == NavigationMode.OFF + + def test_last_input_event_was_navigation_command(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.last_input_event_was_navigation_command method.""" + + essential_modules = self._setup_dependencies(test_context) + essential_modules[ + "cthulhu.input_event_manager" + ].get_manager().last_event_equals_or_is_release_for_event.return_value = True + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + mock_event = test_context.Mock() + mock_event.as_single_line_string = test_context.Mock(return_value="mock event") + nav._last_input_event = mock_event + + result = nav.last_input_event_was_navigation_command() + assert isinstance(result, bool) + assert result is True + + def test_get_state_string(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator._get_state_string method.""" + + essential_modules = self._setup_dependencies(test_context) + mock_obj = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + essential_modules["cthulhu.AXObject"].get_state_set.return_value = None + essential_modules["cthulhu.AXUtilities"].is_switch.return_value = False + essential_modules["cthulhu.AXUtilities"].is_checked.return_value = False + essential_modules["cthulhu.AXUtilities"].is_expanded.return_value = False + essential_modules["cthulhu.AXUtilities"].is_pressed.return_value = False + essential_modules["cthulhu.AXUtilities"].is_selected.return_value = False + test_context.patch_object(nav, "_get_state_string", return_value="test state") + result = nav._get_state_string(mock_obj) + assert isinstance(result, str) + assert result == "test state" + + def test_get_item_string(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator._get_item_string method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_obj = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object( + nav, + "_get_item_string", + return_value="Test Object: Test Description", + ) + result = nav._get_item_string(mock_script, mock_obj) + assert isinstance(result, str) + assert "Test Description" in result + + def test_is_non_document_object(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator._is_non_document_object method.""" + + essential_modules = self._setup_dependencies(test_context) + mock_obj = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + essential_modules["cthulhu.AXUtilities"].is_application.return_value = False + essential_modules["cthulhu.AXUtilities"].is_document.return_value = False + essential_modules["cthulhu.AXUtilities"].is_frame.return_value = False + essential_modules["cthulhu.AXObject"].get_role.return_value = None + result = nav._is_non_document_object(mock_obj) + assert isinstance(result, bool) + + def test_previous_heading_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.previous_heading method.""" + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_get_all_headings", return_value=[]) + test_context.patch_object(nav, "_get_object_in_direction", return_value=None) + test_context.patch_object(nav, "_present_object", new=test_context.Mock()) + result = nav.previous_heading(mock_script, mock_event, True) + assert result is True + + def test_next_heading_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.next_heading method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_get_all_headings", return_value=[]) + test_context.patch_object(nav, "_get_object_in_direction", return_value=None) + test_context.patch_object(nav, "_present_object", new=test_context.Mock()) + result = nav.next_heading(mock_script, mock_event, True) + assert result is True + + def test_list_headings_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.list_headings method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_get_all_headings", return_value=[]) + test_context.patch_object(nav, "_present_object_list", new=test_context.Mock()) + result = nav.list_headings(mock_script, mock_event) + assert result is True + + def test_previous_link_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.previous_link method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_get_all_links", return_value=[]) + test_context.patch_object(nav, "_get_object_in_direction", return_value=None) + test_context.patch_object(nav, "_present_object", new=test_context.Mock()) + result = nav.previous_link(mock_script, mock_event, True) + assert result is True + + def test_next_link_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.next_link method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_get_all_links", return_value=[]) + test_context.patch_object(nav, "_get_object_in_direction", return_value=None) + test_context.patch_object(nav, "_present_object", new=test_context.Mock()) + result = nav.next_link(mock_script, mock_event, True) + assert result is True + + def test_list_links_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.list_links method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_get_all_links", return_value=[]) + test_context.patch_object(nav, "_present_object_list", new=test_context.Mock()) + result = nav.list_links(mock_script, mock_event) + assert result is True + + def test_previous_table_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.previous_table method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_get_all_tables", return_value=[]) + test_context.patch_object(nav, "_get_object_in_direction", return_value=None) + test_context.patch_object(nav, "_present_object", new=test_context.Mock()) + result = nav.previous_table(mock_script, mock_event, True) + assert result is True + + def test_next_table_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.next_table method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_get_all_tables", return_value=[]) + test_context.patch_object(nav, "_get_object_in_direction", return_value=None) + test_context.patch_object(nav, "_present_object", new=test_context.Mock()) + result = nav.next_table(mock_script, mock_event, True) + assert result is True + + def test_list_tables_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.list_tables method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_get_all_tables", return_value=[]) + test_context.patch_object(nav, "_present_object_list", new=test_context.Mock()) + result = nav.list_tables(mock_script, mock_event) + assert result is True + + def test_previous_list_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.previous_list method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_get_all_lists", return_value=[]) + test_context.patch_object(nav, "_get_object_in_direction", return_value=None) + test_context.patch_object(nav, "_present_object", new=test_context.Mock()) + result = nav.previous_list(mock_script, mock_event, True) + assert result is True + + def test_next_list_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.next_list method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_get_all_lists", return_value=[]) + test_context.patch_object(nav, "_get_object_in_direction", return_value=None) + test_context.patch_object(nav, "_present_object", new=test_context.Mock()) + result = nav.next_list(mock_script, mock_event, True) + assert result is True + + def test_list_lists_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.list_lists method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_get_all_lists", return_value=[]) + test_context.patch_object(nav, "_present_object_list", new=test_context.Mock()) + result = nav.list_lists(mock_script, mock_event) + assert result is True + + def test_previous_blockquote_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.previous_blockquote method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_get_all_blockquotes", return_value=[]) + test_context.patch_object(nav, "_get_object_in_direction", return_value=None) + test_context.patch_object(nav, "_present_object", new=test_context.Mock()) + result = nav.previous_blockquote(mock_script, mock_event, True) + assert result is True + + def test_next_blockquote_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.next_blockquote method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_get_all_blockquotes", return_value=[]) + test_context.patch_object(nav, "_get_object_in_direction", return_value=None) + test_context.patch_object(nav, "_present_object", new=test_context.Mock()) + result = nav.next_blockquote(mock_script, mock_event, True) + assert result is True + + def test_previous_checkbox_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.previous_checkbox method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_get_all_checkboxes", return_value=[]) + test_context.patch_object(nav, "_get_object_in_direction", return_value=None) + test_context.patch_object(nav, "_present_object", new=test_context.Mock()) + result = nav.previous_checkbox(mock_script, mock_event, True) + assert result is True + + def test_next_checkbox_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.next_checkbox method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_get_all_checkboxes", return_value=[]) + test_context.patch_object(nav, "_get_object_in_direction", return_value=None) + test_context.patch_object(nav, "_present_object", new=test_context.Mock()) + result = nav.next_checkbox(mock_script, mock_event, True) + assert result is True + + def test_previous_entry_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.previous_entry method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_get_all_entries", return_value=[]) + test_context.patch_object(nav, "_get_object_in_direction", return_value=None) + test_context.patch_object(nav, "_present_object", new=test_context.Mock()) + result = nav.previous_entry(mock_script, mock_event, True) + assert result is True + + def test_next_entry_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.next_entry method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_get_all_entries", return_value=[]) + test_context.patch_object(nav, "_get_object_in_direction", return_value=None) + test_context.patch_object(nav, "_present_object", new=test_context.Mock()) + result = nav.next_entry(mock_script, mock_event, True) + assert result is True + + def test_previous_form_field_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.previous_form_field method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_get_all_form_fields", return_value=[]) + test_context.patch_object(nav, "_get_object_in_direction", return_value=None) + test_context.patch_object(nav, "_present_object", new=test_context.Mock()) + result = nav.previous_form_field(mock_script, mock_event, True) + assert result is True + + def test_next_form_field_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.next_form_field method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_get_all_form_fields", return_value=[]) + test_context.patch_object(nav, "_get_object_in_direction", return_value=None) + test_context.patch_object(nav, "_present_object", new=test_context.Mock()) + result = nav.next_form_field(mock_script, mock_event, True) + assert result is True + + def test_previous_image_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.previous_image method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_get_all_images", return_value=[]) + test_context.patch_object(nav, "_get_object_in_direction", return_value=None) + test_context.patch_object(nav, "_present_object", new=test_context.Mock()) + result = nav.previous_image(mock_script, mock_event, True) + assert result is True + + def test_next_image_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.next_image method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_get_all_images", return_value=[]) + test_context.patch_object(nav, "_get_object_in_direction", return_value=None) + test_context.patch_object(nav, "_present_object", new=test_context.Mock()) + result = nav.next_image(mock_script, mock_event, True) + assert result is True + + def test_previous_landmark_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.previous_landmark method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_get_all_landmarks", return_value=[]) + test_context.patch_object(nav, "_get_object_in_direction", return_value=None) + test_context.patch_object(nav, "_present_object", new=test_context.Mock()) + result = nav.previous_landmark(mock_script, mock_event, True) + assert result is True + + def test_next_landmark_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.next_landmark method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_get_all_landmarks", return_value=[]) + test_context.patch_object(nav, "_get_object_in_direction", return_value=None) + test_context.patch_object(nav, "_present_object", new=test_context.Mock()) + result = nav.next_landmark(mock_script, mock_event, True) + assert result is True + + def test_previous_paragraph_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.previous_paragraph method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_get_all_paragraphs", return_value=[]) + test_context.patch_object(nav, "_get_object_in_direction", return_value=None) + test_context.patch_object(nav, "_present_object", new=test_context.Mock()) + result = nav.previous_paragraph(mock_script, mock_event, True) + assert result is True + + def test_next_paragraph_method(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.next_paragraph method.""" + + self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + test_context.patch_object(nav, "_get_all_paragraphs", return_value=[]) + test_context.patch_object(nav, "_get_object_in_direction", return_value=None) + test_context.patch_object(nav, "_present_object", new=test_context.Mock()) + result = nav.next_paragraph(mock_script, mock_event, True) + assert result is True + + @pytest.mark.parametrize( + "expected_next_method,expected_prev_method", + [ + pytest.param("next_heading_level_1", "previous_heading_level_1", id="heading_level_1"), + pytest.param("next_heading_level_2", "previous_heading_level_2", id="heading_level_2"), + pytest.param("next_heading_level_3", "previous_heading_level_3", id="heading_level_3"), + pytest.param("next_heading_level_4", "previous_heading_level_4", id="heading_level_4"), + pytest.param("next_heading_level_5", "previous_heading_level_5", id="heading_level_5"), + pytest.param("next_heading_level_6", "previous_heading_level_6", id="heading_level_6"), + ], + ) + def test_heading_level_navigation_methods_exist( + self, + test_context: CthulhuTestContext, + expected_next_method: str, + expected_prev_method: str, + ) -> None: + """Test StructuralNavigator has navigation methods for each heading level.""" + + essential_modules = test_context.setup_shared_dependencies( + [ + "cthulhu.keybindings", + "cthulhu.dbus_service", + "cthulhu.debug", + "cthulhu.focus_manager", + "cthulhu.AXObject", + "cthulhu.AXUtilities", + ], + ) + self._setup_mocks(test_context, essential_modules) + + from cthulhu.structural_navigator import StructuralNavigator + + navigator = StructuralNavigator() + assert hasattr(navigator, expected_next_method) + assert hasattr(navigator, expected_prev_method) + assert callable(getattr(navigator, expected_next_method)) + assert callable(getattr(navigator, expected_prev_method)) + + @pytest.mark.parametrize( + "mode_value", + [ + pytest.param("OFF", id="off_mode"), + pytest.param("DOCUMENT", id="document_mode"), + pytest.param("GUI", id="gui_mode"), + ], + ) + def test_navigation_mode_values( + self, + test_context: CthulhuTestContext, + mode_value: str, + ) -> None: + """Test NavigationMode enum values.""" + + essential_modules = test_context.setup_shared_dependencies( + [ + "cthulhu.keybindings", + "cthulhu.dbus_service", + "cthulhu.debug", + "cthulhu.focus_manager", + "cthulhu.AXObject", + "cthulhu.AXUtilities", + ], + ) + self._setup_mocks(test_context, essential_modules) + + from cthulhu.structural_navigator import NavigationMode + + if mode_value == "OFF": + assert NavigationMode.OFF.value == "OFF" + elif mode_value == "DOCUMENT": + assert NavigationMode.DOCUMENT.value == "DOCUMENT" + elif mode_value == "GUI": + assert NavigationMode.GUI.value == "GUI" + + def test_basic_structural_navigator_state(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator basic state management.""" + + essential_modules = test_context.setup_shared_dependencies( + [ + "cthulhu.keybindings", + "cthulhu.dbus_service", + "cthulhu.debug", + "cthulhu.focus_manager", + "cthulhu.AXObject", + "cthulhu.AXUtilities", + ], + ) + self._setup_mocks(test_context, essential_modules) + + from cthulhu.structural_navigator import StructuralNavigator + + navigator = StructuralNavigator() + assert navigator._last_input_event is None + assert navigator._suspended is False + assert isinstance(navigator._mode_for_script, dict) + + def test_get_is_enabled(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.get_is_enabled returns setting value.""" + + self._setup_dependencies(test_context) + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + result = nav.get_is_enabled() + assert result is True + + def test_set_is_enabled_no_change(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.set_is_enabled returns early if value unchanged.""" + + self._setup_dependencies(test_context) + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + result = nav.set_is_enabled(True) + assert result is True + + def test_set_is_enabled_true_with_previous_mode(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.set_is_enabled restores previous mode when enabling.""" + + essential_modules = self._setup_dependencies(test_context) + mock_script = test_context.Mock() + essential_modules[ + "cthulhu.script_manager" + ].get_manager.return_value.get_active_script.return_value = mock_script + mock_cmd_mgr = test_context.Mock() + essential_modules["cthulhu.command_manager"].get_manager.return_value = mock_cmd_mgr + + from cthulhu import gsettings_registry + from cthulhu.structural_navigator import NavigationMode, get_navigator + + gsettings_registry.get_registry().set_runtime_value( + "structural-navigation", + "enabled", + False, + ) + nav = get_navigator() + nav._previous_mode_for_script[mock_script] = NavigationMode.DOCUMENT + test_context.patch_object(nav, "_is_active_script", return_value=True) + + result = nav.set_is_enabled(True) + assert result is True + assert nav._mode_for_script[mock_script] == NavigationMode.DOCUMENT + mock_cmd_mgr.set_group_enabled.assert_called_once() + + def test_set_is_enabled_true_without_previous_mode(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.set_is_enabled without previous mode when enabling.""" + + essential_modules = self._setup_dependencies(test_context) + mock_script = test_context.Mock() + essential_modules[ + "cthulhu.script_manager" + ].get_manager.return_value.get_active_script.return_value = mock_script + mock_cmd_mgr = test_context.Mock() + essential_modules["cthulhu.command_manager"].get_manager.return_value = mock_cmd_mgr + + from cthulhu import gsettings_registry + from cthulhu.structural_navigator import get_navigator + + gsettings_registry.get_registry().set_runtime_value( + "structural-navigation", + "enabled", + False, + ) + nav = get_navigator() + test_context.patch_object(nav, "_is_active_script", return_value=True) + + result = nav.set_is_enabled(True) + assert result is True + mock_cmd_mgr.set_group_enabled.assert_called_once() + + def test_set_is_enabled_false_saves_mode(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.set_is_enabled saves current mode when disabling.""" + + essential_modules = self._setup_dependencies(test_context) + mock_script = test_context.Mock() + essential_modules[ + "cthulhu.script_manager" + ].get_manager.return_value.get_active_script.return_value = mock_script + mock_cmd_mgr = test_context.Mock() + essential_modules["cthulhu.command_manager"].get_manager.return_value = mock_cmd_mgr + from cthulhu.structural_navigator import NavigationMode, get_navigator + + nav = get_navigator() + nav._mode_for_script[mock_script] = NavigationMode.DOCUMENT + test_context.patch_object(nav, "_is_active_script", return_value=True) + + result = nav.set_is_enabled(False) + assert result is True + assert nav._previous_mode_for_script[mock_script] == NavigationMode.DOCUMENT + assert nav._mode_for_script[mock_script] == NavigationMode.OFF + mock_cmd_mgr.set_group_enabled.assert_called_once() + + def test_set_is_enabled_false_already_off(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.set_is_enabled returns early if already OFF.""" + + essential_modules = self._setup_dependencies(test_context) + mock_script = test_context.Mock() + essential_modules[ + "cthulhu.script_manager" + ].get_manager.return_value.get_active_script.return_value = mock_script + mock_cmd_mgr = test_context.Mock() + essential_modules["cthulhu.command_manager"].get_manager.return_value = mock_cmd_mgr + from cthulhu.structural_navigator import NavigationMode, get_navigator + + nav = get_navigator() + nav._mode_for_script[mock_script] = NavigationMode.OFF + + result = nav.set_is_enabled(False) + assert result is True + mock_cmd_mgr.set_group_enabled.assert_not_called() + + def test_get_triggers_focus_mode(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.get_triggers_focus_mode returns setting value.""" + + self._setup_dependencies(test_context) + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + result = nav.get_triggers_focus_mode() + assert result is False + + def test_set_triggers_focus_mode(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.set_triggers_focus_mode updates setting.""" + + self._setup_dependencies(test_context) + from cthulhu import gsettings_registry + from cthulhu.structural_navigator import get_navigator + + gsettings_registry.get_registry().set_runtime_value( + "structural-navigation", + "triggers-focus-mode", + True, + ) + + nav = get_navigator() + result = nav.set_triggers_focus_mode(False) + assert result is True + assert nav.get_triggers_focus_mode() is False + + def test_set_triggers_focus_mode_no_change(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.set_triggers_focus_mode returns early if unchanged.""" + + self._setup_dependencies(test_context) + from cthulhu import gsettings_registry + from cthulhu.structural_navigator import get_navigator + + gsettings_registry.get_registry().set_runtime_value( + "structural-navigation", + "triggers-focus-mode", + True, + ) + + nav = get_navigator() + result = nav.set_triggers_focus_mode(True) + assert result is True + + def test_last_command_prevents_focus_mode_true(self, test_context: CthulhuTestContext) -> None: + """Test StructuralNavigator.last_command_prevents_focus_mode returns True.""" + + essential_modules = self._setup_dependencies(test_context) + essential_modules[ + "cthulhu.input_event_manager" + ].get_manager.return_value.last_event_equals_or_is_release_for_event.return_value = True + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + mock_event = test_context.Mock() + nav._last_input_event = mock_event + result = nav.last_command_prevents_focus_mode() + assert result is True + + def test_last_command_prevents_focus_mode_false_no_event( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test StructuralNavigator.last_command_prevents_focus_mode returns False if no event.""" + + self._setup_dependencies(test_context) + from cthulhu.structural_navigator import get_navigator + + nav = get_navigator() + nav._last_input_event = None + result = nav.last_command_prevents_focus_mode() + assert result is False + + def test_last_command_prevents_focus_mode_false_setting_true( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test last_command_prevents_focus_mode returns False if setting True.""" + + self._setup_dependencies(test_context) + + from cthulhu import gsettings_registry + from cthulhu.structural_navigator import get_navigator + + gsettings_registry.get_registry().set_runtime_value( + "structural-navigation", + "triggers-focus-mode", + True, + ) + nav = get_navigator() + mock_event = test_context.Mock() + nav._last_input_event = mock_event + result = nav.last_command_prevents_focus_mode() + assert result is False + + def test_present_line_emits_region_changed(self, test_context: CthulhuTestContext) -> None: + """Test _present_line emits region_changed with STRUCTURAL_NAVIGATOR mode.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu import focus_manager + from cthulhu.structural_navigator import NavigationMode, get_navigator + + focus_manager_mock = essential_modules["cthulhu.focus_manager"] + manager_instance = test_context.Mock() + focus_manager_mock.get_manager.return_value = manager_instance + manager_instance.in_say_all.return_value = False + focus_manager_mock.STRUCTURAL_NAVIGATOR = focus_manager.STRUCTURAL_NAVIGATOR + + nav = get_navigator() + mock_script = test_context.Mock() + mock_obj = test_context.Mock() + test_offset = 5 + + test_context.patch_object(nav, "get_mode", return_value=NavigationMode.DOCUMENT) + mock_script.utilities.get_line_contents_at_offset.return_value = [ + (mock_obj, test_offset, test_offset + 10, "test text"), + ] + + nav._present_line(mock_script, mock_obj, test_offset, notify_user=True) + + manager_instance.emit_region_changed.assert_called() + call_kwargs = manager_instance.emit_region_changed.call_args + assert call_kwargs.kwargs.get("mode") == focus_manager.STRUCTURAL_NAVIGATOR + + def test_present_object_emits_region_changed_in_document_mode( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test _present_object emits region_changed with STRUCTURAL_NAVIGATOR mode.""" + + essential_modules = self._setup_dependencies(test_context) + from cthulhu import focus_manager + from cthulhu.structural_navigator import NavigationMode, get_navigator + + focus_manager_mock = essential_modules["cthulhu.focus_manager"] + manager_instance = test_context.Mock() + focus_manager_mock.get_manager.return_value = manager_instance + manager_instance.in_say_all.return_value = False + focus_manager_mock.STRUCTURAL_NAVIGATOR = focus_manager.STRUCTURAL_NAVIGATOR + + nav = get_navigator() + mock_script = test_context.Mock() + mock_obj = test_context.Mock() + test_offset = 10 + + test_context.patch_object(nav, "get_mode", return_value=NavigationMode.DOCUMENT) + + nav._present_object(mock_script, mock_obj, offset=test_offset, notify_user=True) + + manager_instance.emit_region_changed.assert_called() + call_kwargs = manager_instance.emit_region_changed.call_args + assert call_kwargs.kwargs.get("mode") == focus_manager.STRUCTURAL_NAVIGATOR diff --git a/tests/test_table_navigator.py b/tests/test_table_navigator.py new file mode 100644 index 0000000..dfcf039 --- /dev/null +++ b/tests/test_table_navigator.py @@ -0,0 +1,2241 @@ +# Unit tests for table_navigator.py methods. +# +# Copyright 2025 Igalia, S.L. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +# pylint: disable=wrong-import-position +# pylint: disable=import-outside-toplevel +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-statements +# pylint: disable=protected-access +# pylint: disable=too-many-arguments +# pylint: disable=too-many-positional-arguments +# pylint: disable=too-many-locals +# pylint: disable=too-many-lines + +"""Unit tests for table_navigator.py methods.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import gi +import pytest + +gi.require_version("Atspi", "2.0") +from gi.repository import Atspi + +if TYPE_CHECKING: + from unittest.mock import MagicMock + + from cthulhu_test_context import CthulhuTestContext + + +@pytest.mark.unit +class TestTableNavigator: + """Test TableNavigator class methods.""" + + def _setup_dependencies(self, test_context: CthulhuTestContext) -> dict[str, MagicMock]: + """Set up dependencies for table_navigator module testing.""" + + additional_modules = [ + "cthulhu.command_manager", + "cthulhu.guilabels", + "cthulhu.input_event_manager", + "cthulhu.cmdnames", + "cthulhu.messages", + "cthulhu.object_properties", + "cthulhu.cthulhu_gui_navlist", + "cthulhu.cthulhu_i18n", + "cthulhu.AXHypertext", + "cthulhu.AXObject", + "cthulhu.AXTable", + "cthulhu.AXText", + "cthulhu.AXUtilities", + "cthulhu.input_event", + "cthulhu.braille_presenter", + "cthulhu.presentation_manager", + ] + essential_modules = test_context.setup_shared_dependencies(additional_modules) + + # Set up cmdnames with all required values for structural_navigator + cmdnames = essential_modules["cthulhu.cmdnames"] + cmdnames.STRUCTURAL_NAVIGATION_MODE_CYCLE = "cycle_mode" + cmdnames.BLOCKQUOTE_PREV = "previous_blockquote" + cmdnames.BLOCKQUOTE_NEXT = "next_blockquote" + cmdnames.BLOCKQUOTE_LIST = "list_blockquotes" + cmdnames.BUTTON_PREV = "previous_button" + cmdnames.BUTTON_NEXT = "next_button" + cmdnames.BUTTON_LIST = "list_buttons" + cmdnames.CHECK_BOX_PREV = "previous_checkbox" + cmdnames.CHECK_BOX_NEXT = "next_checkbox" + cmdnames.CHECK_BOX_LIST = "list_checkboxes" + cmdnames.COMBO_BOX_PREV = "previous_combobox" + cmdnames.COMBO_BOX_NEXT = "next_combobox" + cmdnames.COMBO_BOX_LIST = "list_comboboxes" + cmdnames.ENTRY_PREV = "previous_entry" + cmdnames.ENTRY_NEXT = "next_entry" + cmdnames.ENTRY_LIST = "list_entries" + cmdnames.FORM_FIELD_PREV = "previous_form_field" + cmdnames.FORM_FIELD_NEXT = "next_form_field" + cmdnames.FORM_FIELD_LIST = "list_form_fields" + cmdnames.HEADING_PREV = "previous_heading" + cmdnames.HEADING_NEXT = "next_heading" + cmdnames.HEADING_LIST = "list_headings" + cmdnames.HEADING_AT_LEVEL_PREV = "previous_heading_level_%d" + cmdnames.HEADING_AT_LEVEL_NEXT = "next_heading_level_%d" + cmdnames.HEADING_AT_LEVEL_LIST = "list_headings_level_%d" + cmdnames.IFRAME_PREV = "previous_iframe" + cmdnames.IFRAME_NEXT = "next_iframe" + cmdnames.IFRAME_LIST = "list_iframes" + cmdnames.IMAGE_PREV = "previous_image" + cmdnames.IMAGE_NEXT = "next_image" + cmdnames.IMAGE_LIST = "list_images" + cmdnames.LANDMARK_PREV = "previous_landmark" + cmdnames.LANDMARK_NEXT = "next_landmark" + cmdnames.LANDMARK_LIST = "list_landmarks" + cmdnames.LIST_PREV = "previous_list" + cmdnames.LIST_NEXT = "next_list" + cmdnames.LIST_LIST = "list_lists" + cmdnames.LIST_ITEM_PREV = "previous_list_item" + cmdnames.LIST_ITEM_NEXT = "next_list_item" + cmdnames.LIST_ITEM_LIST = "list_list_items" + cmdnames.LIVE_REGION_PREV = "previous_live_region" + cmdnames.LIVE_REGION_NEXT = "next_live_region" + cmdnames.LIVE_REGION_LAST = "last_live_region" + cmdnames.PARAGRAPH_PREV = "previous_paragraph" + cmdnames.PARAGRAPH_NEXT = "next_paragraph" + cmdnames.PARAGRAPH_LIST = "list_paragraphs" + cmdnames.RADIO_BUTTON_PREV = "previous_radio_button" + cmdnames.RADIO_BUTTON_NEXT = "next_radio_button" + cmdnames.RADIO_BUTTON_LIST = "list_radio_buttons" + cmdnames.SEPARATOR_PREV = "previous_separator" + cmdnames.SEPARATOR_NEXT = "next_separator" + cmdnames.TABLE_PREV = "previous_table" + cmdnames.TABLE_NEXT = "next_table" + cmdnames.TABLE_LIST = "list_tables" + cmdnames.UNVISITED_LINK_PREV = "previous_unvisited_link" + cmdnames.UNVISITED_LINK_NEXT = "next_unvisited_link" + cmdnames.UNVISITED_LINK_LIST = "list_unvisited_links" + cmdnames.VISITED_LINK_PREV = "previous_visited_link" + cmdnames.VISITED_LINK_NEXT = "next_visited_link" + cmdnames.VISITED_LINK_LIST = "list_visited_links" + cmdnames.LINK_PREV = "previous_link" + cmdnames.LINK_NEXT = "next_link" + cmdnames.LINK_LIST = "list_links" + cmdnames.CLICKABLE_PREV = "previous_clickable" + cmdnames.CLICKABLE_NEXT = "next_clickable" + cmdnames.CLICKABLE_LIST = "list_clickables" + cmdnames.LARGE_OBJECT_PREV = "previous_large_object" + cmdnames.LARGE_OBJECT_NEXT = "next_large_object" + cmdnames.LARGE_OBJECT_LIST = "list_large_objects" + cmdnames.CONTAINER_START = "container_start" + cmdnames.CONTAINER_END = "container_end" + + essential_modules["cthulhu.cthulhu_i18n"]._ = lambda x: x + essential_modules["cthulhu.debug"].print_message = test_context.Mock() + essential_modules["cthulhu.debug"].LEVEL_INFO = 800 + + controller_mock = test_context.Mock() + controller_mock.register_decorated_module.return_value = None + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = controller_mock + + focus_manager_instance = test_context.Mock() + focus_manager_instance.get_locus_of_focus.return_value = None + essential_modules["cthulhu.focus_manager"].get_manager.return_value = focus_manager_instance + + essential_modules["cthulhu.AXObject"].supports_collection.return_value = True + essential_modules["cthulhu.AXUtilities"].is_heading.return_value = False + + return essential_modules + + def test_init(self, test_context: CthulhuTestContext) -> None: + """Test TableNavigator.__init__ creates instance with correct default values.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu import command_manager + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + assert navigator._previous_reported_row is None + assert navigator._previous_reported_col is None + assert navigator._last_input_event is None + assert navigator.get_is_enabled() is True + # D-Bus registration and commands are registered during setup() + navigator.set_up_commands() + # Verify commands are registered in CommandManager + cmd_manager = command_manager.get_manager() + assert cmd_manager.get_command("table_navigator_toggle_enabled") is not None + assert cmd_manager.get_command("table_cell_down") is not None + mock_controller.register_decorated_module.assert_called_with("TableNavigator", navigator) + + def test_is_enabled_default_true(self, test_context: CthulhuTestContext) -> None: + """Test TableNavigator.is_enabled returns True by default.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + assert navigator.get_is_enabled() is True + + def test_is_enabled_after_disable(self, test_context: CthulhuTestContext) -> None: + """Test TableNavigator.get_is_enabled returns False when disabled.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu import gsettings_registry + from cthulhu.table_navigator import TableNavigator + + gsettings_registry.get_registry().set_runtime_value("table-navigation", "enabled", False) + navigator = TableNavigator() + assert navigator.get_is_enabled() is False + + def test_last_input_event_was_navigation_command_none( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test last_input_event_was_navigation_command with None event returns False.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + mock_manager = test_context.Mock() + essential_modules["cthulhu.input_event_manager"].get_manager.return_value = mock_manager + mock_manager.last_event_equals_or_is_release_for_event.return_value = False + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + result = navigator.last_input_event_was_navigation_command() + assert result is False + mock_manager.last_event_equals_or_is_release_for_event.assert_not_called() + + def test_last_input_event_was_navigation_command_with_event( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test last_input_event_was_navigation_command with event returns True when matching.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + mock_manager = test_context.Mock() + essential_modules["cthulhu.input_event_manager"].get_manager.return_value = mock_manager + mock_manager.last_event_equals_or_is_release_for_event.return_value = True + mock_event = test_context.Mock() + mock_event.as_single_line_string.return_value = "test_event" + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._last_input_event = mock_event + result = navigator.last_input_event_was_navigation_command() + assert result is True + mock_manager.last_event_equals_or_is_release_for_event.assert_called_once_with(mock_event) + + @pytest.mark.parametrize( + "initial_enabled,expected_enabled,expected_message_attr", + [ + (False, True, "TABLE_NAVIGATION_ENABLED"), + (True, False, "TABLE_NAVIGATION_DISABLED"), + ], + ) + def test_toggle_enabled( + self, + test_context: CthulhuTestContext, + initial_enabled: bool, + expected_enabled: bool, + expected_message_attr: str, + ) -> None: + """Test TableNavigator.toggle_enabled toggles state and presents appropriate message.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + mock_cmd_mgr = test_context.Mock() + essential_modules["cthulhu.command_manager"].get_manager.return_value = mock_cmd_mgr + from cthulhu.table_navigator import TableNavigator + + guilabels_mock = essential_modules["cthulhu.guilabels"] + guilabels_mock.KB_GROUP_TABLE_NAVIGATION = "Table navigation" + + navigator = TableNavigator() + mock_cmd_mgr.is_group_enabled.return_value = initial_enabled + mock_script = test_context.Mock() + mock_event = test_context.Mock() + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = navigator.toggle_enabled(mock_script, mock_event, notify_user=True) + assert result is True + expected_message = getattr(essential_modules["cthulhu.messages"], expected_message_attr) + pres_manager.present_message.assert_called_once_with(expected_message) + + def test_toggle_enabled_no_notify(self, test_context: CthulhuTestContext) -> None: + """Test TableNavigator.toggle_enabled does not present message when notify_user=False.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + mock_cmd_mgr = test_context.Mock() + essential_modules["cthulhu.command_manager"].get_manager.return_value = mock_cmd_mgr + from cthulhu.table_navigator import TableNavigator + + guilabels_mock = essential_modules["cthulhu.guilabels"] + guilabels_mock.KB_GROUP_TABLE_NAVIGATION = "Table navigation" + + navigator = TableNavigator() + mock_cmd_mgr.is_group_enabled.return_value = False + mock_script = test_context.Mock() + mock_event = test_context.Mock() + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = navigator.toggle_enabled(mock_script, mock_event, notify_user=False) + assert result is True + pres_manager.present_message.assert_not_called() + + @pytest.mark.parametrize( + "is_focusable, has_name, has_children, is_whitespace, expected_result", + [ + pytest.param(True, False, False, True, False, id="focusable_object"), + pytest.param(False, True, False, True, False, id="object_with_name"), + pytest.param(False, False, True, True, False, id="object_with_non_blank_children"), + pytest.param(False, False, False, False, False, id="object_with_text"), + pytest.param(False, False, False, True, True, id="blank_object"), + pytest.param(False, False, True, True, True, id="object_with_only_blank_children"), + ], + ) + def test_is_blank( + self, + test_context, + is_focusable, + has_name, + has_children, + is_whitespace, + expected_result, + ) -> None: + """Test _is_blank correctly identifies blank objects based on various criteria.""" + + essential_modules = self._setup_dependencies(test_context) + test_context.patch( + "cthulhu.table_navigator.AXUtilities.is_focusable", + return_value=is_focusable, + ) + test_context.patch( + "cthulhu.table_navigator.AXObject.get_name", + return_value="name" if has_name else "", + ) + test_context.patch( + "cthulhu.table_navigator.AXObject.get_child_count", + return_value=1 if has_children else 0, + ) + test_context.patch( + "cthulhu.table_navigator.AXUtilities.is_whitespace_or_empty", + return_value=is_whitespace, + ) + if has_children: + mock_child = test_context.Mock() + test_context.patch( + "cthulhu.table_navigator.AXObject.iter_children", + return_value=[mock_child], + ) + # For the recursive call on child, configure mock to handle both expected cases + if expected_result and not is_focusable and not has_name: + # Child should also be blank - override child-specific mocking + test_context.patch( + "cthulhu.table_navigator.AXUtilities.is_focusable", + side_effect=lambda obj: False if obj == mock_child else is_focusable, + ) + test_context.patch( + "cthulhu.table_navigator.AXObject.get_name", + side_effect=lambda obj: ( + "" if obj == mock_child else ("name" if has_name else "") + ), + ) + test_context.patch( + "cthulhu.table_navigator.AXObject.get_child_count", + side_effect=lambda obj: 0 if obj == mock_child else (1 if has_children else 0), + ) + test_context.patch( + "cthulhu.table_navigator.AXUtilities.is_whitespace_or_empty", + side_effect=lambda obj: True if obj == mock_child else is_whitespace, + ) + else: + # Child should not be blank - make it focusable to stop recursion + test_context.patch( + "cthulhu.table_navigator.AXUtilities.is_focusable", + side_effect=lambda obj: True if obj == mock_child else is_focusable, + ) + else: + test_context.patch("cthulhu.table_navigator.AXObject.iter_children", return_value=[]) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + mock_obj = test_context.Mock(spec=Atspi.Accessible) + result = navigator._is_blank(mock_obj) + assert result == expected_result + + def test_get_current_cell_basic(self, test_context: CthulhuTestContext) -> None: + """Test TableNavigator._get_current_cell returns focus manager's locus when it is a cell.""" + + essential_modules = self._setup_dependencies(test_context) + mock_cell = test_context.Mock(spec=Atspi.Accessible) + mock_focus_manager = test_context.Mock() + mock_focus_manager.get_locus_of_focus.return_value = mock_cell + test_context.patch( + "cthulhu.table_navigator.focus_manager.get_manager", + return_value=mock_focus_manager, + ) + test_context.patch("cthulhu.table_navigator.AXObject.get_parent", return_value=None) + + def mock_is_cell_or_header(obj): + return obj == mock_cell + + test_context.patch( + "cthulhu.table_navigator.AXUtilities.is_table_cell_or_header", + new=mock_is_cell_or_header, + ) + test_context.patch("cthulhu.table_navigator.AXUtilities.find_ancestor", return_value=None) + test_context.patch("cthulhu.table_navigator.debug.print_tokens", return_value=None) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + result = navigator._get_current_cell() + assert result == mock_cell + + def test_get_current_cell_nested(self, test_context: CthulhuTestContext) -> None: + """Test TableNavigator._get_current_cell returns parent when it is also a table cell.""" + + essential_modules = self._setup_dependencies(test_context) + mock_focus_manager = test_context.Mock() + essential_modules["cthulhu.focus_manager"].get_manager.return_value = mock_focus_manager + mock_inner_cell = test_context.Mock(spec=Atspi.Accessible) + mock_parent_cell = test_context.Mock(spec=Atspi.Accessible) + mock_focus_manager.get_locus_of_focus.return_value = mock_inner_cell + test_context.patch( + "cthulhu.table_navigator.AXObject.get_parent", + side_effect=lambda obj: mock_parent_cell if obj == mock_inner_cell else None, + ) + + def mock_is_table_cell(obj): + return obj in [mock_inner_cell, mock_parent_cell] + + test_context.patch( + "cthulhu.table_navigator.AXUtilities.is_table_cell_or_header", + new=mock_is_table_cell, + ) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + result = navigator._get_current_cell() + assert result == mock_parent_cell + + def test_get_cell_coordinates_basic(self, test_context: CthulhuTestContext) -> None: + """Test TableNavigator._get_cell_coordinates returns coordinates from AXTable.""" + + essential_modules = self._setup_dependencies(test_context) + test_context.patch( + "cthulhu.table_navigator.AXTable.get_cell_coordinates", + return_value=(2, 3), + ) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + mock_cell = test_context.Mock(spec=Atspi.Accessible) + result = navigator._get_cell_coordinates(mock_cell) + assert result == (2, 3) + + def test_get_cell_coordinates_with_previous(self, test_context: CthulhuTestContext) -> None: + """Test _get_cell_coordinates returns previous coordinates when cell matches.""" + + essential_modules = self._setup_dependencies(test_context) + mock_cell = test_context.Mock(spec=Atspi.Accessible) + mock_table = test_context.Mock(spec=Atspi.Accessible) + test_context.patch( + "cthulhu.table_navigator.AXTable.get_cell_coordinates", + return_value=(2, 3), + ) + test_context.patch("cthulhu.table_navigator.AXUtilities.get_table", return_value=mock_table) + test_context.patch("cthulhu.table_navigator.AXTable.get_cell_at", return_value=mock_cell) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._previous_reported_row = 1 + navigator._previous_reported_col = 2 + result = navigator._get_cell_coordinates(mock_cell) + assert result == (1, 2) + + @pytest.mark.parametrize( + "direction, boundary_check, boundary_message, get_next_cell_method", + [ + pytest.param( + "left", + "is_start_of_row", + "TABLE_ROW_BEGINNING", + "get_cell_on_left", + id="move_left", + ), + pytest.param( + "right", + "is_end_of_row", + "TABLE_ROW_END", + "get_cell_on_right", + id="move_right", + ), + pytest.param( + "up", + "is_top_of_column", + "TABLE_COLUMN_TOP", + "get_cell_above", + id="move_up", + ), + pytest.param( + "down", + "is_bottom_of_column", + "TABLE_COLUMN_BOTTOM", + "get_cell_below", + id="move_down", + ), + ], + ) + def test_move_direction_scenarios( + self, + test_context: CthulhuTestContext, + direction: str, + boundary_check: str, + boundary_message: str, + get_next_cell_method: str, + ) -> None: + """Test TableNavigator move methods for various scenarios.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + mock_script = test_context.Mock() + mock_event = test_context.Mock() + move_method = getattr(navigator, f"move_{direction}") + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + + navigator._get_current_cell = test_context.Mock(return_value=None) + pres_manager.present_message.reset_mock() + result = move_method(mock_script, mock_event) + assert result is True + pres_manager.present_message.assert_called_with( + essential_modules["cthulhu.messages"].TABLE_NOT_IN_A, + ) + assert navigator._last_input_event == mock_event + + mock_cell = test_context.Mock(spec=Atspi.Accessible) + test_context.patch(f"cthulhu.table_navigator.AXTable.{boundary_check}", return_value=True) + navigator._get_current_cell = test_context.Mock(return_value=mock_cell) + pres_manager.present_message.reset_mock() + result = move_method(mock_script, mock_event) + assert result is True + pres_manager.present_message.assert_called_with( + getattr(essential_modules["cthulhu.messages"], boundary_message), + ) + + mock_current_cell = test_context.Mock(spec=Atspi.Accessible) + mock_next_cell = test_context.Mock(spec=Atspi.Accessible) + test_context.patch(f"cthulhu.table_navigator.AXTable.{boundary_check}", return_value=False) + test_context.patch( + f"cthulhu.table_navigator.AXTable.{get_next_cell_method}", + return_value=mock_next_cell, + ) + navigator._get_current_cell = test_context.Mock(return_value=mock_current_cell) + navigator._get_cell_coordinates = test_context.Mock(return_value=(1, 2)) + mock_present_cell = test_context.Mock() + navigator._present_cell = mock_present_cell + mock_script.reset_mock() + result = move_method(mock_script, mock_event) + assert result is True + mock_present_cell.assert_called_once() + call_args = mock_present_cell.call_args[0] + assert call_args[0] == mock_script + assert call_args[1] == mock_next_cell + assert call_args[4] == mock_current_cell + assert call_args[5] is True + + def test_move_left_successful(self, test_context: CthulhuTestContext) -> None: + """Test TableNavigator.move_left successfully moves to left cell and presents it.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + mock_current_cell = test_context.Mock(spec=Atspi.Accessible) + mock_left_cell = test_context.Mock(spec=Atspi.Accessible) + test_context.patch("cthulhu.table_navigator.AXTable.is_start_of_row", return_value=False) + test_context.patch( + "cthulhu.table_navigator.AXTable.get_cell_on_left", + return_value=mock_left_cell, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=mock_current_cell) + navigator._get_cell_coordinates = test_context.Mock(return_value=(1, 2)) + mock_present_cell = test_context.Mock() + navigator._present_cell = mock_present_cell + mock_script = test_context.Mock() + mock_event = test_context.Mock() + result = navigator.move_left(mock_script, mock_event) + assert result is True + mock_present_cell.assert_called_once_with( + mock_script, + mock_left_cell, + 1, + 1, + mock_current_cell, + True, + ) + + def test_move_right_successful(self, test_context: CthulhuTestContext) -> None: + """Test TableNavigator.move_right successfully moves to right cell and presents it.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + mock_current_cell = test_context.Mock(spec=Atspi.Accessible) + mock_right_cell = test_context.Mock(spec=Atspi.Accessible) + test_context.patch("cthulhu.table_navigator.AXTable.is_end_of_row", return_value=False) + test_context.patch( + "cthulhu.table_navigator.AXTable.get_cell_on_right", + return_value=mock_right_cell, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=mock_current_cell) + navigator._get_cell_coordinates = test_context.Mock(return_value=(1, 2)) + mock_present_cell = test_context.Mock() + navigator._present_cell = mock_present_cell + mock_script = test_context.Mock() + mock_event = test_context.Mock() + result = navigator.move_right(mock_script, mock_event) + assert result is True + mock_present_cell.assert_called_once_with( + mock_script, + mock_right_cell, + 1, + 3, + mock_current_cell, + True, + ) + + def test_move_up_not_in_table(self, test_context: CthulhuTestContext) -> None: + """Test TableNavigator.move_up presents not in table message when current cell is None.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=None) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = navigator.move_up(mock_script, mock_event) + assert result is True + pres_manager.present_message.assert_called_once_with( + essential_modules["cthulhu.messages"].TABLE_NOT_IN_A, + ) + assert navigator._last_input_event == mock_event + + def test_move_up_at_top_of_column(self, test_context: CthulhuTestContext) -> None: + """Test TableNavigator.move_up presents column top message when at top of column.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + mock_cell = test_context.Mock(spec=Atspi.Accessible) + test_context.patch("cthulhu.table_navigator.AXTable.is_top_of_column", return_value=True) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=mock_cell) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = navigator.move_up(mock_script, mock_event) + assert result is True + pres_manager.present_message.assert_called_once_with( + essential_modules["cthulhu.messages"].TABLE_COLUMN_TOP, + ) + + def test_move_up_successful(self, test_context: CthulhuTestContext) -> None: + """Test TableNavigator.move_up successfully moves to cell above and presents it.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + mock_current_cell = test_context.Mock(spec=Atspi.Accessible) + mock_up_cell = test_context.Mock(spec=Atspi.Accessible) + test_context.patch("cthulhu.table_navigator.AXTable.is_top_of_column", return_value=False) + test_context.patch( + "cthulhu.table_navigator.AXTable.get_cell_above", + return_value=mock_up_cell, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=mock_current_cell) + navigator._get_cell_coordinates = test_context.Mock(return_value=(2, 1)) + mock_present_cell = test_context.Mock() + navigator._present_cell = mock_present_cell + mock_script = test_context.Mock() + mock_event = test_context.Mock() + result = navigator.move_up(mock_script, mock_event) + assert result is True + mock_present_cell.assert_called_once_with( + mock_script, + mock_up_cell, + 1, + 1, + mock_current_cell, + True, + ) + + def test_move_down_successful(self, test_context: CthulhuTestContext) -> None: + """Test TableNavigator.move_down successfully moves to cell below and presents it.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + mock_current_cell = test_context.Mock(spec=Atspi.Accessible) + mock_down_cell = test_context.Mock(spec=Atspi.Accessible) + test_context.patch( + "cthulhu.table_navigator.AXTable.is_bottom_of_column", + return_value=False, + ) + test_context.patch( + "cthulhu.table_navigator.AXTable.get_cell_below", + return_value=mock_down_cell, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=mock_current_cell) + navigator._get_cell_coordinates = test_context.Mock(return_value=(1, 1)) + mock_present_cell = test_context.Mock() + navigator._present_cell = mock_present_cell + mock_script = test_context.Mock() + mock_event = test_context.Mock() + result = navigator.move_down(mock_script, mock_event) + assert result is True + mock_present_cell.assert_called_once_with( + mock_script, + mock_down_cell, + 2, + 1, + mock_current_cell, + True, + ) + + def test_move_to_first_cell_not_in_table(self, test_context: CthulhuTestContext) -> None: + """Test move_to_first_cell presents not in table message when current cell is None.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=None) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = navigator.move_to_first_cell(mock_script, mock_event) + assert result is True + pres_manager.present_message.assert_called_once_with( + essential_modules["cthulhu.messages"].TABLE_NOT_IN_A, + ) + + def test_move_to_first_cell_successful(self, test_context: CthulhuTestContext) -> None: + """Test move_to_first_cell successfully moves to first cell and presents it.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + mock_current_cell = test_context.Mock(spec=Atspi.Accessible) + mock_table = test_context.Mock(spec=Atspi.Accessible) + mock_first_cell = test_context.Mock(spec=Atspi.Accessible) + test_context.patch("cthulhu.table_navigator.AXUtilities.get_table", return_value=mock_table) + test_context.patch( + "cthulhu.table_navigator.AXTable.get_first_cell", + return_value=mock_first_cell, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=mock_current_cell) + mock_present_cell = test_context.Mock() + navigator._present_cell = mock_present_cell + mock_script = test_context.Mock() + mock_event = test_context.Mock() + result = navigator.move_to_first_cell(mock_script, mock_event) + assert result is True + mock_present_cell.assert_called_once_with( + mock_script, + mock_first_cell, + 0, + 0, + mock_current_cell, + True, + ) + + def test_present_cell_not_cell_or_header(self, test_context: CthulhuTestContext) -> None: + """Test TableNavigator._present_cell returns early when object is not a cell or header.""" + + essential_modules = self._setup_dependencies(test_context) + test_context.patch( + "cthulhu.table_navigator.AXUtilities.is_table_cell_or_header", + return_value=False, + ) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + mock_script = test_context.Mock() + mock_cell = test_context.Mock(spec=Atspi.Accessible) + mock_previous_cell = test_context.Mock(spec=Atspi.Accessible) + navigator._present_cell(mock_script, mock_cell, 1, 2, mock_previous_cell) + assert navigator._previous_reported_row != 1 + assert navigator._previous_reported_col != 2 + + def test_present_cell_successful(self, test_context: CthulhuTestContext) -> None: + """Test TableNavigator._present_cell sets focus, coordinates, and presents cell.""" + + essential_modules = self._setup_dependencies(test_context) + test_context.patch( + "cthulhu.table_navigator.AXUtilities.is_table_cell_or_header", + return_value=True, + ) + test_context.patch("cthulhu.table_navigator.AXObject.grab_focus", return_value=None) + test_context.patch( + "cthulhu.table_navigator.AXUtilities.get_descendant_supporting_text", return_value=None + ) + test_context.patch("cthulhu.table_navigator.AXObject.supports_text", return_value=False) + test_context.patch("cthulhu.table_navigator.AXTable.get_cell_spans", return_value=(1, 1)) + mock_focus_manager = test_context.Mock() + essential_modules["cthulhu.focus_manager"].get_manager.return_value = mock_focus_manager + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + + from cthulhu import gsettings_registry + from cthulhu.table_navigator import TableNavigator + + registry = gsettings_registry.get_registry() + registry.set_runtime_value("speech", "announce-cell-coordinates", False) + registry.set_runtime_value("speech", "announce-cell-span", False) + + navigator = TableNavigator() + mock_script = test_context.Mock() + mock_script.utilities.grab_focus_when_setting_caret.return_value = False + test_context.patch("cthulhu.table_navigator.AXUtilities.is_gui_cell", return_value=False) + mock_script.present_object = test_context.Mock() + mock_cell = test_context.Mock(spec=Atspi.Accessible) + mock_previous_cell = test_context.Mock(spec=Atspi.Accessible) + navigator._present_cell(mock_script, mock_cell, 1, 2, mock_previous_cell) + assert navigator._previous_reported_row == 1 + assert navigator._previous_reported_col == 2 + mock_focus_manager.set_locus_of_focus.assert_called() + mock_script.present_object.assert_called_once_with( + mock_cell, + offset=0, + priorObj=mock_previous_cell, + interrupt=True, + ) + + def test_get_navigator(self, test_context: CthulhuTestContext) -> None: + """Test table_navigator.get_navigator returns singleton TableNavigator instance.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu import table_navigator + + navigator1 = table_navigator.get_navigator() + navigator2 = table_navigator.get_navigator() + assert navigator1 is navigator2 + assert isinstance(navigator1, table_navigator.TableNavigator) + + def test_get_current_cell_needs_ancestor_search(self, test_context: CthulhuTestContext) -> None: + """Test _get_current_cell when cell is not table cell and needs ancestor search.""" + + essential_modules = self._setup_dependencies(test_context) + mock_initial_cell = test_context.Mock(spec=Atspi.Accessible) + mock_ancestor_cell = test_context.Mock(spec=Atspi.Accessible) + mock_focus_manager = test_context.Mock() + mock_focus_manager.get_locus_of_focus.return_value = mock_initial_cell + test_context.patch( + "cthulhu.table_navigator.focus_manager.get_manager", + return_value=mock_focus_manager, + ) + test_context.patch("cthulhu.table_navigator.AXObject.get_parent", return_value=None) + + def mock_is_cell_or_header(obj): + return obj == mock_ancestor_cell + + test_context.patch( + "cthulhu.table_navigator.AXUtilities.is_table_cell_or_header", + new=mock_is_cell_or_header, + ) + test_context.patch( + "cthulhu.table_navigator.AXUtilities.find_ancestor", + return_value=mock_ancestor_cell, + ) + test_context.patch("cthulhu.table_navigator.debug.print_tokens", return_value=None) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + result = navigator._get_current_cell() + assert result == mock_ancestor_cell + + def test_move_left_skip_blank_cells(self, test_context: CthulhuTestContext) -> None: + """Test TableNavigator.move_left skipping blank cells when setting is enabled.""" + + essential_modules = self._setup_dependencies(test_context) + + from cthulhu import gsettings_registry + + gsettings_registry.get_registry().set_runtime_value( + "table-navigation", + "skip-blank-cells", + True, + ) + mock_script = test_context.Mock() + mock_current_cell = test_context.Mock(spec=Atspi.Accessible) + mock_blank_cell = test_context.Mock(spec=Atspi.Accessible) + mock_final_cell = test_context.Mock(spec=Atspi.Accessible) + test_context.patch( + "cthulhu.table_navigator.AXTable.get_cell_coordinates", + return_value=(1, 1), + ) + test_context.patch( + "cthulhu.table_navigator.AXTable.is_start_of_row", + side_effect=lambda cell: cell == mock_final_cell, + ) + cell_sequence = [mock_blank_cell, mock_final_cell] + call_count = [0] + + def mock_get_cell_on_left(_cell): + if call_count[0] < len(cell_sequence): + result = cell_sequence[call_count[0]] + call_count[0] += 1 + return result + return None + + test_context.patch( + "cthulhu.table_navigator.AXTable.get_cell_on_left", + new=mock_get_cell_on_left, + ) + + def mock_is_blank(cell): + return cell == mock_blank_cell + + test_context.patch("cthulhu.table_navigator.debug.print_tokens", return_value=None) + test_context.patch("cthulhu.table_navigator.TableNavigator._present_cell", return_value=None) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._is_blank = mock_is_blank + result = navigator.move_left(mock_script, mock_current_cell) + assert result is True + + def test_move_right_at_end_of_row(self, test_context: CthulhuTestContext) -> None: + """Test TableNavigator.move_right when at end of row returns True and notifies user.""" + + essential_modules = self._setup_dependencies(test_context) + mock_script = test_context.Mock() + mock_current_cell = test_context.Mock(spec=Atspi.Accessible) + test_context.patch( + "cthulhu.table_navigator.AXTable.get_cell_coordinates", + return_value=(1, 5), + ) + test_context.patch("cthulhu.table_navigator.AXTable.get_cell_on_right", return_value=None) + test_context.patch("cthulhu.table_navigator.AXTable.is_end_of_row", return_value=True) + test_context.patch("cthulhu.table_navigator.debug.print_tokens", return_value=None) + test_context.patch("cthulhu.table_navigator.TableNavigator._present_cell", return_value=None) + mock_focus_manager = test_context.Mock() + mock_focus_manager.get_locus_of_focus.return_value = mock_current_cell + test_context.patch( + "cthulhu.table_navigator.focus_manager.get_manager", + return_value=mock_focus_manager, + ) + test_context.patch("cthulhu.table_navigator.AXObject.get_parent", return_value=None) + test_context.patch( + "cthulhu.table_navigator.AXUtilities.is_table_cell_or_header", + return_value=True, + ) + test_context.patch("cthulhu.table_navigator.AXUtilities.find_ancestor", return_value=None) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = navigator.move_right(mock_script) + # move_right returns True even at end of row, but presents a message + assert result is True + pres_manager.present_message.assert_called_once() + + def test_move_to_last_cell_not_in_table(self, test_context: CthulhuTestContext) -> None: + """Test move_to_last_cell presents not in table message when current cell is None.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=None) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = navigator.move_to_last_cell(mock_script, mock_event) + assert result is True + pres_manager.present_message.assert_called_once_with( + essential_modules["cthulhu.messages"].TABLE_NOT_IN_A, + ) + assert navigator._last_input_event == mock_event + + def test_move_to_last_cell_successful(self, test_context: CthulhuTestContext) -> None: + """Test TableNavigator.move_to_last_cell successfully moves to last cell and presents it.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + mock_current_cell = test_context.Mock(spec=Atspi.Accessible) + mock_table = test_context.Mock(spec=Atspi.Accessible) + mock_last_cell = test_context.Mock(spec=Atspi.Accessible) + test_context.patch("cthulhu.table_navigator.AXUtilities.get_table", return_value=mock_table) + test_context.patch( + "cthulhu.table_navigator.AXTable.get_last_cell", + return_value=mock_last_cell, + ) + test_context.patch("cthulhu.table_navigator.AXTable.get_row_count", return_value=5) + test_context.patch("cthulhu.table_navigator.AXTable.get_column_count", return_value=3) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=mock_current_cell) + mock_present_cell = test_context.Mock() + navigator._present_cell = mock_present_cell + mock_script = test_context.Mock() + mock_event = test_context.Mock() + result = navigator.move_to_last_cell(mock_script, mock_event) + assert result is True + mock_present_cell.assert_called_once_with( + mock_script, + mock_last_cell, + 5, + 3, + mock_current_cell, + True, + ) + + def test_move_to_top_of_column_not_in_table(self, test_context: CthulhuTestContext) -> None: + """Test move_to_top_of_column presents not in table message when current cell is None.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=None) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = navigator.move_to_top_of_column(mock_script, mock_event) + assert result is True + pres_manager.present_message.assert_called_once_with( + essential_modules["cthulhu.messages"].TABLE_NOT_IN_A, + ) + + def test_move_to_top_of_column_already_at_top(self, test_context: CthulhuTestContext) -> None: + """Test move_to_top_of_column presents column top message when already at top.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + mock_cell = test_context.Mock(spec=Atspi.Accessible) + test_context.patch("cthulhu.table_navigator.AXTable.is_top_of_column", return_value=True) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=mock_cell) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = navigator.move_to_top_of_column(mock_script, mock_event) + assert result is True + pres_manager.present_message.assert_called_once_with( + essential_modules["cthulhu.messages"].TABLE_COLUMN_TOP, + ) + + def test_move_to_top_of_column_successful(self, test_context: CthulhuTestContext) -> None: + """Test move_to_top_of_column successfully moves to top of column and presents it.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + mock_current_cell = test_context.Mock(spec=Atspi.Accessible) + mock_top_cell = test_context.Mock(spec=Atspi.Accessible) + test_context.patch("cthulhu.table_navigator.AXTable.is_top_of_column", return_value=False) + test_context.patch( + "cthulhu.table_navigator.AXTable.get_top_of_column", + return_value=mock_top_cell, + ) + + def mock_get_cell_coordinates(cell, prefer_attribute=False): # pylint: disable=unused-argument + if cell == mock_current_cell: + return (3, 1) + if cell == mock_top_cell: + return (0, 1) + return (0, 0) + + test_context.patch( + "cthulhu.table_navigator.AXTable.get_cell_coordinates", + new=mock_get_cell_coordinates, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=mock_current_cell) + mock_present_cell = test_context.Mock() + navigator._present_cell = mock_present_cell + mock_script = test_context.Mock() + mock_event = test_context.Mock() + result = navigator.move_to_top_of_column(mock_script, mock_event) + assert result is True + mock_present_cell.assert_called_once_with( + mock_script, + mock_top_cell, + 3, + 1, + mock_current_cell, + True, + ) + + def test_move_to_bottom_of_column_not_in_table(self, test_context: CthulhuTestContext) -> None: + """Test move_to_bottom_of_column presents not in table message when current cell is None.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=None) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = navigator.move_to_bottom_of_column(mock_script, mock_event) + assert result is True + pres_manager.present_message.assert_called_once_with( + essential_modules["cthulhu.messages"].TABLE_NOT_IN_A, + ) + + def test_move_to_bottom_of_column_already_at_bottom( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test move_to_bottom_of_column presents column bottom message when already at bottom.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + mock_cell = test_context.Mock(spec=Atspi.Accessible) + test_context.patch( + "cthulhu.table_navigator.AXTable.is_bottom_of_column", + return_value=True, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=mock_cell) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = navigator.move_to_bottom_of_column(mock_script, mock_event) + assert result is True + pres_manager.present_message.assert_called_once_with( + essential_modules["cthulhu.messages"].TABLE_COLUMN_BOTTOM, + ) + + def test_move_to_bottom_of_column_successful(self, test_context: CthulhuTestContext) -> None: + """Test move_to_bottom_of_column successfully moves to bottom of column and presents it.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + mock_current_cell = test_context.Mock(spec=Atspi.Accessible) + mock_bottom_cell = test_context.Mock(spec=Atspi.Accessible) + test_context.patch( + "cthulhu.table_navigator.AXTable.is_bottom_of_column", + return_value=False, + ) + test_context.patch( + "cthulhu.table_navigator.AXTable.get_bottom_of_column", + return_value=mock_bottom_cell, + ) + + def mock_get_cell_coordinates(cell, prefer_attribute=False): # pylint: disable=unused-argument + if cell == mock_current_cell: + return (1, 2) + if cell == mock_bottom_cell: + return (4, 2) + return (0, 0) + + test_context.patch( + "cthulhu.table_navigator.AXTable.get_cell_coordinates", + new=mock_get_cell_coordinates, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=mock_current_cell) + mock_present_cell = test_context.Mock() + navigator._present_cell = mock_present_cell + mock_script = test_context.Mock() + mock_event = test_context.Mock() + result = navigator.move_to_bottom_of_column(mock_script, mock_event) + assert result is True + mock_present_cell.assert_called_once_with( + mock_script, + mock_bottom_cell, + 1, + 2, + mock_current_cell, + True, + ) + + def test_move_to_beginning_of_row_not_in_table(self, test_context: CthulhuTestContext) -> None: + """Test move_to_beginning_of_row presents not in table message when current cell is None.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=None) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = navigator.move_to_beginning_of_row(mock_script, mock_event) + assert result is True + pres_manager.present_message.assert_called_once_with( + essential_modules["cthulhu.messages"].TABLE_NOT_IN_A, + ) + + def test_move_to_beginning_of_row_already_at_start(self, test_context: CthulhuTestContext) -> None: + """Test move_to_beginning_of_row presents row beginning message when already at start.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + mock_cell = test_context.Mock(spec=Atspi.Accessible) + test_context.patch("cthulhu.table_navigator.AXTable.is_start_of_row", return_value=True) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=mock_cell) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = navigator.move_to_beginning_of_row(mock_script, mock_event) + assert result is True + pres_manager.present_message.assert_called_once_with( + essential_modules["cthulhu.messages"].TABLE_ROW_BEGINNING, + ) + + def test_move_to_beginning_of_row_successful(self, test_context: CthulhuTestContext) -> None: + """Test move_to_beginning_of_row successfully moves to beginning of row and presents it.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + mock_current_cell = test_context.Mock(spec=Atspi.Accessible) + mock_start_cell = test_context.Mock(spec=Atspi.Accessible) + test_context.patch("cthulhu.table_navigator.AXTable.is_start_of_row", return_value=False) + test_context.patch( + "cthulhu.table_navigator.AXTable.get_start_of_row", + return_value=mock_start_cell, + ) + + def mock_get_cell_coordinates(cell, prefer_attribute=False): # pylint: disable=unused-argument + if cell == mock_start_cell: + return (2, 0) + return (2, 3) + + test_context.patch( + "cthulhu.table_navigator.AXTable.get_cell_coordinates", + new=mock_get_cell_coordinates, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=mock_current_cell) + mock_present_cell = test_context.Mock() + navigator._present_cell = mock_present_cell + mock_script = test_context.Mock() + mock_event = test_context.Mock() + result = navigator.move_to_beginning_of_row(mock_script, mock_event) + assert result is True + mock_present_cell.assert_called_once_with( + mock_script, + mock_start_cell, + 2, + 0, + mock_current_cell, + True, + ) + + def test_move_to_end_of_row_not_in_table(self, test_context: CthulhuTestContext) -> None: + """Test move_to_end_of_row presents not in table message when current cell is None.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=None) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = navigator.move_to_end_of_row(mock_script, mock_event) + assert result is True + pres_manager.present_message.assert_called_once_with( + essential_modules["cthulhu.messages"].TABLE_NOT_IN_A, + ) + + def test_move_to_end_of_row_already_at_end(self, test_context: CthulhuTestContext) -> None: + """Test move_to_end_of_row presents row end message when already at end.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + mock_cell = test_context.Mock(spec=Atspi.Accessible) + test_context.patch("cthulhu.table_navigator.AXTable.is_end_of_row", return_value=True) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=mock_cell) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = navigator.move_to_end_of_row(mock_script, mock_event) + assert result is True + pres_manager.present_message.assert_called_once_with( + essential_modules["cthulhu.messages"].TABLE_ROW_END, + ) + + def test_move_to_end_of_row_successful(self, test_context: CthulhuTestContext) -> None: + """Test move_to_end_of_row successfully moves to end of row and presents it.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + mock_current_cell = test_context.Mock(spec=Atspi.Accessible) + mock_end_cell = test_context.Mock(spec=Atspi.Accessible) + test_context.patch("cthulhu.table_navigator.AXTable.is_end_of_row", return_value=False) + test_context.patch( + "cthulhu.table_navigator.AXTable.get_end_of_row", + return_value=mock_end_cell, + ) + + def mock_get_cell_coordinates(cell, prefer_attribute=False): # pylint: disable=unused-argument + if cell == mock_end_cell: + return (2, 4) + return (2, 1) + + test_context.patch( + "cthulhu.table_navigator.AXTable.get_cell_coordinates", + new=mock_get_cell_coordinates, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=mock_current_cell) + mock_present_cell = test_context.Mock() + navigator._present_cell = mock_present_cell + mock_script = test_context.Mock() + mock_event = test_context.Mock() + result = navigator.move_to_end_of_row(mock_script, mock_event) + assert result is True + mock_present_cell.assert_called_once_with( + mock_script, + mock_end_cell, + 2, + 4, + mock_current_cell, + True, + ) + + def test_set_dynamic_column_headers_row_not_in_table( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test set_dynamic_column_headers_row presents not in table message. + + Test case when current cell is None. + """ + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=None) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = navigator.set_dynamic_column_headers_row(mock_script, mock_event) + assert result is True + pres_manager.present_message.assert_called_once_with( + essential_modules["cthulhu.messages"].TABLE_NOT_IN_A, + ) + + def test_set_dynamic_column_headers_row_successful(self, test_context: CthulhuTestContext) -> None: + """Test set_dynamic_column_headers_row successfully sets column headers row.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + mock_current_cell = test_context.Mock(spec=Atspi.Accessible) + mock_table = test_context.Mock(spec=Atspi.Accessible) + test_context.patch("cthulhu.table_navigator.AXUtilities.get_table", return_value=mock_table) + test_context.patch( + "cthulhu.table_navigator.AXTable.get_cell_coordinates", + return_value=(1, 2), + ) + test_context.patch( + "cthulhu.table_navigator.AXUtilities.set_dynamic_column_headers_row", + return_value=None, + ) + essential_modules["cthulhu.messages"].DYNAMIC_COLUMN_HEADER_SET = "Column header row set to %d" + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=mock_current_cell) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = navigator.set_dynamic_column_headers_row(mock_script, mock_event) + assert result is True + pres_manager.present_message.assert_called_once_with("Column header row set to 2") + + def test_clear_dynamic_column_headers_row_not_in_table( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test clear_dynamic_column_headers_row presents not in table message. + + Test case when current cell is None. + """ + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=None) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = navigator.clear_dynamic_column_headers_row(mock_script, mock_event) + assert result is True + pres_manager.present_message.assert_called_once_with( + essential_modules["cthulhu.messages"].TABLE_NOT_IN_A, + ) + + def test_clear_dynamic_column_headers_row_successful( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test clear_dynamic_column_headers_row successfully clears column headers row.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + mock_current_cell = test_context.Mock(spec=Atspi.Accessible) + mock_table = test_context.Mock(spec=Atspi.Accessible) + mock_focus_manager = test_context.Mock() + essential_modules["cthulhu.focus_manager"].get_manager.return_value = mock_focus_manager + mock_focus_manager.get_locus_of_focus.return_value = mock_current_cell + test_context.patch("cthulhu.table_navigator.AXUtilities.get_table", return_value=mock_table) + test_context.patch( + "cthulhu.table_navigator.AXUtilities.clear_dynamic_column_headers_row", + return_value=None, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=mock_current_cell) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + pres_manager.interrupt_presentation.reset_mock() + result = navigator.clear_dynamic_column_headers_row(mock_script, mock_event) + assert result is True + pres_manager.interrupt_presentation.assert_called_once() + pres_manager.present_message.assert_called_once_with( + essential_modules["cthulhu.messages"].DYNAMIC_COLUMN_HEADER_CLEARED, + ) + + def test_set_dynamic_row_headers_column_not_in_table( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test set_dynamic_row_headers_column presents not in table message. + + Test case when current cell is None. + """ + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=None) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = navigator.set_dynamic_row_headers_column(mock_script, mock_event) + assert result is True + pres_manager.present_message.assert_called_once_with( + essential_modules["cthulhu.messages"].TABLE_NOT_IN_A, + ) + + def test_set_dynamic_row_headers_column_successful(self, test_context: CthulhuTestContext) -> None: + """Test set_dynamic_row_headers_column successfully sets row headers column.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + mock_current_cell = test_context.Mock(spec=Atspi.Accessible) + mock_table = test_context.Mock(spec=Atspi.Accessible) + test_context.patch("cthulhu.table_navigator.AXUtilities.get_table", return_value=mock_table) + test_context.patch( + "cthulhu.table_navigator.AXTable.get_cell_coordinates", + return_value=(2, 1), + ) + test_context.patch( + "cthulhu.table_navigator.AXUtilities.set_dynamic_row_headers_column", + return_value=None, + ) + essential_modules["cthulhu.messages"].DYNAMIC_ROW_HEADER_SET = "Row header column set to %s" + test_context.patch( + "cthulhu.table_navigator.AXUtilities.get_column_label", + create=True, + return_value="B", + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=mock_current_cell) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = navigator.set_dynamic_row_headers_column(mock_script, mock_event) + assert result is True + pres_manager.present_message.assert_called_once_with("Row header column set to B") + + def test_clear_dynamic_row_headers_column_not_in_table( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test clear_dynamic_row_headers_column presents not in table message. + + Test case when current cell is None. + """ + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=None) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + result = navigator.clear_dynamic_row_headers_column(mock_script, mock_event) + assert result is True + pres_manager.present_message.assert_called_once_with( + essential_modules["cthulhu.messages"].TABLE_NOT_IN_A, + ) + + def test_clear_dynamic_row_headers_column_successful( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test clear_dynamic_row_headers_column successfully clears row headers column.""" + + essential_modules = self._setup_dependencies(test_context) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + mock_current_cell = test_context.Mock(spec=Atspi.Accessible) + mock_table = test_context.Mock(spec=Atspi.Accessible) + mock_focus_manager = test_context.Mock() + essential_modules["cthulhu.focus_manager"].get_manager.return_value = mock_focus_manager + mock_focus_manager.get_locus_of_focus.return_value = mock_current_cell + test_context.patch("cthulhu.table_navigator.AXUtilities.get_table", return_value=mock_table) + test_context.patch( + "cthulhu.table_navigator.AXUtilities.clear_dynamic_row_headers_column", + return_value=None, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._get_current_cell = test_context.Mock(return_value=mock_current_cell) + mock_script = test_context.Mock() + mock_event = test_context.Mock() + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + pres_manager.interrupt_presentation.reset_mock() + result = navigator.clear_dynamic_row_headers_column(mock_script, mock_event) + assert result is True + pres_manager.interrupt_presentation.assert_called_once() + pres_manager.present_message.assert_called_once_with( + essential_modules["cthulhu.messages"].DYNAMIC_ROW_HEADER_CLEARED, + ) + + def test_get_cell_coordinates_with_different_previous_cell( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test _get_cell_coordinates returns actual coordinates when previous cell differs.""" + + essential_modules = self._setup_dependencies(test_context) + mock_cell = test_context.Mock(spec=Atspi.Accessible) + mock_table = test_context.Mock(spec=Atspi.Accessible) + mock_different_cell = test_context.Mock(spec=Atspi.Accessible) + test_context.patch( + "cthulhu.table_navigator.AXTable.get_cell_coordinates", + return_value=(2, 3), + ) + test_context.patch("cthulhu.table_navigator.AXUtilities.get_table", return_value=mock_table) + test_context.patch( + "cthulhu.table_navigator.AXTable.get_cell_at", + return_value=mock_different_cell, + ) + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + navigator._previous_reported_row = 1 + navigator._previous_reported_col = 2 + result = navigator._get_cell_coordinates(mock_cell) + assert result == (2, 3) + + def test_present_cell_with_settings_enabled(self, test_context: CthulhuTestContext) -> None: + """Test _present_cell with speakCellCoordinates and speakCellSpan enabled.""" + + essential_modules = self._setup_dependencies(test_context) + test_context.patch( + "cthulhu.table_navigator.AXUtilities.is_table_cell_or_header", + return_value=True, + ) + test_context.patch("cthulhu.table_navigator.AXObject.grab_focus", return_value=None) + test_context.patch( + "cthulhu.table_navigator.AXUtilities.get_descendant_supporting_text", return_value=None + ) + test_context.patch("cthulhu.table_navigator.AXObject.supports_text", return_value=False) + test_context.patch("cthulhu.table_navigator.AXTable.get_cell_spans", return_value=(2, 3)) + mock_focus_manager = test_context.Mock() + essential_modules["cthulhu.focus_manager"].get_manager.return_value = mock_focus_manager + essential_modules["cthulhu.messages"].TABLE_CELL_COORDINATES = "Row %(row)s, column %(column)s" + essential_modules["cthulhu.messages"].cell_span.return_value = "spans 2 rows and 3 columns" + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + mock_script = test_context.Mock() + mock_script.utilities.grab_focus_when_setting_caret.return_value = False + test_context.patch("cthulhu.table_navigator.AXUtilities.is_gui_cell", return_value=False) + mock_script.present_object = test_context.Mock() + mock_cell = test_context.Mock(spec=Atspi.Accessible) + mock_previous_cell = test_context.Mock(spec=Atspi.Accessible) + pres_manager = essential_modules["cthulhu.presentation_manager"].get_manager() + pres_manager.present_message.reset_mock() + navigator._present_cell(mock_script, mock_cell, 1, 2, mock_previous_cell) + assert navigator._previous_reported_row == 1 + assert navigator._previous_reported_col == 2 + mock_focus_manager.set_locus_of_focus.assert_called() + mock_script.present_object.assert_called_once_with( + mock_cell, + offset=0, + priorObj=mock_previous_cell, + interrupt=True, + ) + assert pres_manager.present_message.call_count == 2 + + def test_present_cell_with_text_descendant_and_gui_cell( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test _present_cell when cell has text descendant and is gui cell.""" + + essential_modules = self._setup_dependencies(test_context) + mock_text_obj = test_context.Mock(spec=Atspi.Accessible) + test_context.patch( + "cthulhu.table_navigator.AXUtilities.is_table_cell_or_header", + return_value=True, + ) + test_context.patch("cthulhu.table_navigator.AXObject.grab_focus", return_value=None) + test_context.patch( + "cthulhu.table_navigator.AXUtilities.get_descendant_supporting_text", + return_value=mock_text_obj, + ) + test_context.patch( + "cthulhu.table_navigator.AXObject.supports_text", + side_effect=lambda obj: obj == mock_text_obj, + ) + test_context.patch("cthulhu.table_navigator.AXTable.get_cell_spans", return_value=(1, 1)) + mock_focus_manager = test_context.Mock() + essential_modules["cthulhu.focus_manager"].get_manager.return_value = mock_focus_manager + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + + from cthulhu import gsettings_registry + from cthulhu.table_navigator import TableNavigator + + registry = gsettings_registry.get_registry() + registry.set_runtime_value("speech", "announce-cell-coordinates", False) + registry.set_runtime_value("speech", "announce-cell-span", False) + + navigator = TableNavigator() + mock_script = test_context.Mock() + mock_script.utilities.grab_focus_when_setting_caret.return_value = True + test_context.patch("cthulhu.table_navigator.AXUtilities.is_gui_cell", return_value=True) + mock_script.utilities.set_caret_position = test_context.Mock() + mock_script.present_object = test_context.Mock() + mock_cell = test_context.Mock(spec=Atspi.Accessible) + mock_previous_cell = test_context.Mock(spec=Atspi.Accessible) + navigator._present_cell(mock_script, mock_cell, 1, 2, mock_previous_cell) + mock_focus_manager.set_locus_of_focus.assert_called_with(None, mock_text_obj, False) + assert navigator._previous_reported_row == 1 + assert navigator._previous_reported_col == 2 + + def test_get_skip_blank_cells(self, test_context: CthulhuTestContext) -> None: + """Test TableNavigator.get_skip_blank_cells returns setting value.""" + + self._setup_dependencies(test_context) + + from cthulhu import gsettings_registry + from cthulhu.table_navigator import get_navigator + + gsettings_registry.get_registry().set_runtime_value( + "table-navigation", + "skip-blank-cells", + True, + ) + nav = get_navigator() + result = nav.get_skip_blank_cells() + assert result is True + + def test_set_skip_blank_cells(self, test_context: CthulhuTestContext) -> None: + """Test TableNavigator.set_skip_blank_cells updates setting.""" + + self._setup_dependencies(test_context) + from cthulhu.table_navigator import get_navigator + + nav = get_navigator() + result = nav.set_skip_blank_cells(True) + assert result is True + assert nav.get_skip_blank_cells() is True + + def test_set_skip_blank_cells_no_change(self, test_context: CthulhuTestContext) -> None: + """Test TableNavigator.set_skip_blank_cells returns early if value unchanged.""" + + self._setup_dependencies(test_context) + + from cthulhu import gsettings_registry + from cthulhu.table_navigator import get_navigator + + gsettings_registry.get_registry().set_runtime_value( + "table-navigation", + "skip-blank-cells", + True, + ) + nav = get_navigator() + result = nav.set_skip_blank_cells(True) + assert result is True + + def test_present_cell_emits_region_changed(self, test_context: CthulhuTestContext) -> None: + """Test _present_cell emits region_changed with TABLE_NAVIGATOR mode.""" + + essential_modules = self._setup_dependencies(test_context) + test_context.patch( + "cthulhu.table_navigator.AXUtilities.is_table_cell_or_header", + return_value=True, + ) + test_context.patch("cthulhu.table_navigator.AXObject.grab_focus", return_value=None) + test_context.patch( + "cthulhu.table_navigator.AXUtilities.get_descendant_supporting_text", return_value=None + ) + test_context.patch("cthulhu.table_navigator.AXObject.supports_text", return_value=False) + test_context.patch("cthulhu.table_navigator.AXTable.get_cell_spans", return_value=(1, 1)) + mock_focus_manager = test_context.Mock() + essential_modules["cthulhu.focus_manager"].get_manager.return_value = mock_focus_manager + + from cthulhu import focus_manager, gsettings_registry + + essential_modules["cthulhu.focus_manager"].TABLE_NAVIGATOR = focus_manager.TABLE_NAVIGATOR + registry = gsettings_registry.get_registry() + registry.set_runtime_value("speech", "announce-cell-coordinates", False) + registry.set_runtime_value("speech", "announce-cell-span", False) + + mock_controller = test_context.Mock() + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = mock_controller + mock_keybindings_class = test_context.Mock() + mock_keybindings_instance = test_context.Mock() + mock_keybindings_class.return_value = mock_keybindings_instance + test_context.patch( + "cthulhu.table_navigator.keybindings.KeyBindings", + new=mock_keybindings_class, + ) + from cthulhu.table_navigator import TableNavigator + + navigator = TableNavigator() + mock_script = test_context.Mock() + mock_script.utilities.grab_focus_when_setting_caret.return_value = False + test_context.patch("cthulhu.table_navigator.AXUtilities.is_gui_cell", return_value=False) + mock_script.present_object = test_context.Mock() + mock_cell = test_context.Mock(spec=Atspi.Accessible) + mock_previous_cell = test_context.Mock(spec=Atspi.Accessible) + + navigator._present_cell(mock_script, mock_cell, 1, 2, mock_previous_cell) + + mock_focus_manager.emit_region_changed.assert_called() + call_kwargs = mock_focus_manager.emit_region_changed.call_args + assert call_kwargs.kwargs.get("mode") == focus_manager.TABLE_NAVIGATOR diff --git a/tests/test_text_attribute_manager.py b/tests/test_text_attribute_manager.py new file mode 100644 index 0000000..66bf791 --- /dev/null +++ b/tests/test_text_attribute_manager.py @@ -0,0 +1,177 @@ +# Unit tests for text_attribute_manager.py methods. +# +# Copyright 2026 Igalia, S.L. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +# pylint: disable=import-outside-toplevel +# pylint: disable=protected-access +# pylint: disable=no-member + +"""Unit tests for text_attribute_manager.py methods.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from unittest.mock import MagicMock + + from cthulhu_test_context import CthulhuTestContext + + +@pytest.mark.unit +class TestTextAttributeManager: + """Test TextAttributeManager class methods.""" + + def _setup_dependencies(self, test_context: CthulhuTestContext) -> dict[str, MagicMock]: + """Returns dependencies for text_attribute_manager module testing.""" + + additional_modules: list[str] = [] + essential_modules = test_context.setup_shared_dependencies(additional_modules) + + debug_mock = essential_modules["cthulhu.debug"] + debug_mock.print_message = test_context.Mock() + debug_mock.LEVEL_INFO = 800 + + from cthulhu import gsettings_registry + + registry = gsettings_registry.get_registry() + registry.clear_runtime_values() + + return essential_modules + + def test_init(self, test_context: CthulhuTestContext) -> None: + """Test TextAttributeManager initialization.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.text_attribute_manager import TextAttributeManager + + manager = TextAttributeManager() + assert manager is not None + + dbus_service_mock = essential_modules["cthulhu.dbus_service"] + controller = dbus_service_mock.get_remote_controller.return_value + controller.register_decorated_module.assert_called_with("TextAttributeManager", manager) + + def test_get_attributes_to_speak_empty(self, test_context: CthulhuTestContext) -> None: + """Test get_attributes_to_speak returns empty list by default.""" + + self._setup_dependencies(test_context) + from cthulhu.text_attribute_manager import TextAttributeManager + + manager = TextAttributeManager() + result = manager.get_attributes_to_speak() + assert result == [] + + def test_get_attributes_to_speak_with_values(self, test_context: CthulhuTestContext) -> None: + """Test get_attributes_to_speak returns configured attributes.""" + + self._setup_dependencies(test_context) + expected = ["bold", "italic", "underline"] + from cthulhu.text_attribute_manager import TextAttributeManager + + manager = TextAttributeManager() + manager.set_attributes_to_speak(expected) + result = manager.get_attributes_to_speak() + assert result == expected + + def test_set_attributes_to_speak(self, test_context: CthulhuTestContext) -> None: + """Test set_attributes_to_speak updates settings.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.text_attribute_manager import TextAttributeManager + + manager = TextAttributeManager() + new_value = ["bold", "italic"] + result = manager.set_attributes_to_speak(new_value) + + assert result is True + assert manager.get_attributes_to_speak() == new_value + essential_modules["cthulhu.debug"].print_message.assert_called() + + def test_set_attributes_to_speak_same_value(self, test_context: CthulhuTestContext) -> None: + """Test set_attributes_to_speak returns early when value unchanged.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + existing = ["bold", "italic"] + from cthulhu.text_attribute_manager import TextAttributeManager + + manager = TextAttributeManager() + manager.set_attributes_to_speak(existing) + essential_modules["cthulhu.debug"].print_message.reset_mock() + + result = manager.set_attributes_to_speak(existing) + assert result is True + calls = essential_modules["cthulhu.debug"].print_message.call_args_list + setting_calls = [c for c in calls if "Setting attributes to speak" in str(c)] + assert len(setting_calls) == 0 + + def test_get_attributes_to_braille_empty(self, test_context: CthulhuTestContext) -> None: + """Test get_attributes_to_braille returns empty list by default.""" + + self._setup_dependencies(test_context) + from cthulhu.text_attribute_manager import TextAttributeManager + + manager = TextAttributeManager() + result = manager.get_attributes_to_braille() + assert result == [] + + def test_get_attributes_to_braille_with_values(self, test_context: CthulhuTestContext) -> None: + """Test get_attributes_to_braille returns configured attributes.""" + + self._setup_dependencies(test_context) + expected = ["bold", "strikethrough"] + from cthulhu.text_attribute_manager import TextAttributeManager + + manager = TextAttributeManager() + manager.set_attributes_to_braille(expected) + result = manager.get_attributes_to_braille() + assert result == expected + + def test_set_attributes_to_braille(self, test_context: CthulhuTestContext) -> None: + """Test set_attributes_to_braille updates settings.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu.text_attribute_manager import TextAttributeManager + + manager = TextAttributeManager() + new_value = ["bold", "underline"] + result = manager.set_attributes_to_braille(new_value) + + assert result is True + assert manager.get_attributes_to_braille() == new_value + essential_modules["cthulhu.debug"].print_message.assert_called() + + def test_set_attributes_to_braille_same_value(self, test_context: CthulhuTestContext) -> None: + """Test set_attributes_to_braille returns early when value unchanged.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + existing = ["bold"] + from cthulhu.text_attribute_manager import TextAttributeManager + + manager = TextAttributeManager() + manager.set_attributes_to_braille(existing) + essential_modules["cthulhu.debug"].print_message.reset_mock() + + result = manager.set_attributes_to_braille(existing) + assert result is True + calls = essential_modules["cthulhu.debug"].print_message.call_args_list + setting_calls = [c for c in calls if "Setting attributes to braille" in str(c)] + assert len(setting_calls) == 0 diff --git a/tests/test_typing_echo_presenter.py b/tests/test_typing_echo_presenter.py new file mode 100644 index 0000000..1ac9fe5 --- /dev/null +++ b/tests/test_typing_echo_presenter.py @@ -0,0 +1,936 @@ +# Unit tests for typing_echo_presenter.py methods. +# +# Copyright 2025 Igalia, S.L. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +# pylint: disable=wrong-import-position +# pylint: disable=import-outside-toplevel +# pylint: disable=protected-access +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-arguments +# pylint: disable=too-many-positional-arguments +# pylint: disable=too-many-lines + +"""Unit tests for typing_echo_presenter.py methods.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import Mock + +import pytest + +if TYPE_CHECKING: + from typing import ClassVar + + from cthulhu_test_context import CthulhuTestContext + + +class _FakeGtkGrid: + """Minimal stub used in unit tests.""" + + def __init__(self, *_args, **_kwargs): + """Initialize fake GTK grid.""" + self._children = [] + + def attach(self, *args, **kwargs): + """Attach widget to grid.""" + self._children.append((args, kwargs)) + + def set_border_width(self, *_args, **_kwargs): + """Set border width.""" + return None + + def set_margin_start(self, *_args, **_kwargs): + """Set margin start.""" + return None + + def show_all(self): + """Show all widgets.""" + return None + + +class _FakeCheckButton: + """Minimal stub emulating Gtk.CheckButton for unit tests.""" + + def __init__(self, label: str): + self.label = label + self.name = label + self._active = False + self._signal_handlers: dict[str, tuple] = {} + + @classmethod + def new_with_mnemonic(cls, label: str) -> _FakeCheckButton: + """Create new check button with mnemonic.""" + return cls(label) + + def set_name(self, name: str) -> None: + """Set widget name.""" + self.name = name + + def set_use_underline(self, *_args, **_kwargs) -> None: # pragma: no cover - unused + """Set use underline.""" + return None + + def set_receives_default(self, *_args, **_kwargs) -> None: # pragma: no cover + """Set receives default.""" + return None + + def connect(self, signal: str, handler, data) -> None: + """Connect signal handler.""" + self._signal_handlers[signal] = (handler, data) + + def set_active(self, active: bool) -> None: + """Set active state.""" + self._active = active + + def get_active(self) -> bool: # pragma: no cover - helper when needed + """Get active state.""" + return self._active + + def set_sensitive(self, _sensitive: bool) -> None: + """Set sensitive state.""" + return None + + +@pytest.mark.unit +class TestTypingEchoPresenter: + """Test TypingEchoPresenter and TypingEchoPreferencesGrid.""" + + _CMDNAME_VALUES: ClassVar[dict[str, str]] = { + "STRUCTURAL_NAVIGATION_MODE_CYCLE": "cycle_mode", + "BLOCKQUOTE_PREV": "previous_blockquote", + "BLOCKQUOTE_NEXT": "next_blockquote", + "BLOCKQUOTE_LIST": "list_blockquotes", + "BUTTON_PREV": "previous_button", + "BUTTON_NEXT": "next_button", + "BUTTON_LIST": "list_buttons", + "CHECK_BOX_PREV": "previous_checkbox", + "CHECK_BOX_NEXT": "next_checkbox", + "CHECK_BOX_LIST": "list_checkboxes", + "COMBO_BOX_PREV": "previous_combobox", + "COMBO_BOX_NEXT": "next_combobox", + "COMBO_BOX_LIST": "list_comboboxes", + "ENTRY_PREV": "previous_entry", + "ENTRY_NEXT": "next_entry", + "ENTRY_LIST": "list_entries", + "FORM_FIELD_PREV": "previous_form_field", + "FORM_FIELD_NEXT": "next_form_field", + "FORM_FIELD_LIST": "list_form_fields", + "HEADING_PREV": "previous_heading", + "HEADING_NEXT": "next_heading", + "HEADING_LIST": "list_headings", + "HEADING_AT_LEVEL_PREV": "previous_heading_level_%d", + "HEADING_AT_LEVEL_NEXT": "next_heading_level_%d", + "HEADING_AT_LEVEL_LIST": "list_headings_level_%d", + "IFRAME_PREV": "previous_iframe", + "IFRAME_NEXT": "next_iframe", + "IFRAME_LIST": "list_iframes", + "IMAGE_PREV": "previous_image", + "IMAGE_NEXT": "next_image", + "IMAGE_LIST": "list_images", + "LANDMARK_PREV": "previous_landmark", + "LANDMARK_NEXT": "next_landmark", + "LANDMARK_LIST": "list_landmarks", + "LIST_PREV": "previous_list", + "LIST_NEXT": "next_list", + "LIST_LIST": "list_lists", + "LIST_ITEM_PREV": "previous_list_item", + "LIST_ITEM_NEXT": "next_list_item", + "LIST_ITEM_LIST": "list_list_items", + "LIVE_REGION_PREV": "previous_live_region", + "LIVE_REGION_NEXT": "next_live_region", + "LIVE_REGION_LAST": "last_live_region", + "PARAGRAPH_PREV": "previous_paragraph", + "PARAGRAPH_NEXT": "next_paragraph", + "PARAGRAPH_LIST": "list_paragraphs", + "RADIO_BUTTON_PREV": "previous_radio_button", + "RADIO_BUTTON_NEXT": "next_radio_button", + "RADIO_BUTTON_LIST": "list_radio_buttons", + "SEPARATOR_PREV": "previous_separator", + "SEPARATOR_NEXT": "next_separator", + "TABLE_PREV": "previous_table", + "TABLE_NEXT": "next_table", + "TABLE_LIST": "list_tables", + "UNVISITED_LINK_PREV": "previous_unvisited_link", + "UNVISITED_LINK_NEXT": "next_unvisited_link", + "UNVISITED_LINK_LIST": "list_unvisited_links", + "VISITED_LINK_PREV": "previous_visited_link", + "VISITED_LINK_NEXT": "next_visited_link", + "VISITED_LINK_LIST": "list_visited_links", + "LINK_PREV": "previous_link", + "LINK_NEXT": "next_link", + "LINK_LIST": "list_links", + "CLICKABLE_PREV": "previous_clickable", + "CLICKABLE_NEXT": "next_clickable", + "CLICKABLE_LIST": "list_clickables", + "LARGE_OBJECT_PREV": "previous_large_object", + "LARGE_OBJECT_NEXT": "next_large_object", + "LARGE_OBJECT_LIST": "list_large_objects", + "CONTAINER_START": "container_start", + "CONTAINER_END": "container_end", + } + + @staticmethod + def _setup_cmdnames(cmdnames) -> None: + """Set up cmdnames with all required values for structural_navigator.""" + + for attr, value in TestTypingEchoPresenter._CMDNAME_VALUES.items(): + setattr(cmdnames, attr, value) + + @staticmethod + def _setup_guilabels(guilabels_mock) -> None: + """Set up guilabels mock with typing echo label values.""" + + guilabels_mock.ECHO_ENABLE_KEY_ECHO = "Enable _key echo" + guilabels_mock.ECHO_ALPHABETIC_KEYS = "Enable _alphabetic keys" + guilabels_mock.ECHO_NUMERIC_KEYS = "Enable n_umeric keys" + guilabels_mock.ECHO_PUNCTUATION_KEYS = "Enable _punctuation keys" + guilabels_mock.ECHO_SPACE = "Enable _space" + guilabels_mock.ECHO_MODIFIER_KEYS = "Enable _modifier keys" + guilabels_mock.ECHO_FUNCTION_KEYS = "Enable _function keys" + guilabels_mock.ECHO_ACTION_KEYS = "Enable ac_tion keys" + guilabels_mock.ECHO_NAVIGATION_KEYS = "Enable _navigation keys" + guilabels_mock.ECHO_DIACRITICAL_KEYS = "Enable non-spacing _diacritical keys" + guilabels_mock.ECHO_CHARACTER = "Enable echo by cha_racter" + guilabels_mock.ECHO_WORD = "Enable echo by _word" + guilabels_mock.ECHO_SENTENCE = "Enable echo by _sentence" + + @staticmethod + def _setup_atspi_patches(test_context: CthulhuTestContext) -> None: + """Set up Atspi type patches for testing.""" + + from gi.repository import Atspi + + test_context.patch_object(Atspi, "Accessible", new=type("Accessible", (), {})) + test_context.patch_object(Atspi, "Hyperlink", new=type("Hyperlink", (), {})) + test_context.patch_object( + Atspi, + "Role", + new=type("Role", (), {"PASSWORD_TEXT": 42, "PANEL": 36}), + ) + test_context.patch_object( + Atspi, + "CollectionMatchType", + new=type("CollectionMatchType", (), {"ALL": 0, "ANY": 1}), + ) + test_context.patch_object(Atspi, "MatchRule", new=type("MatchRule", (), {})) + test_context.patch_object( + Atspi, + "RelationType", + new=type("RelationType", (), {"LABELLED_BY": 0}), + ) + test_context.patch_object(Atspi, "Relation", new=type("Relation", (), {})) + + _ADDITIONAL_MODULES: ClassVar[list[str]] = [ + "gi", + "gi.repository", + "cthulhu.cmdnames", + "cthulhu.messages", + "cthulhu.object_properties", + "cthulhu.cthulhu_gui_navlist", + "cthulhu.cthulhu_i18n", + "cthulhu.AXHypertext", + "cthulhu.AXObject", + "cthulhu.AXTable", + "cthulhu.AXText", + "cthulhu.AXUtilities", + "cthulhu.input_event", + "cthulhu.braille_presenter", + "cthulhu.presentation_manager", + "cthulhu.speech_presenter", + ] + + _DEFAULT_VALUES: ClassVar[dict[str, bool]] = { + "enableKeyEcho": True, + "enableAlphabeticKeys": True, + "enableNumericKeys": True, + "enablePunctuationKeys": True, + "enableSpace": True, + "enableModifierKeys": True, + "enableFunctionKeys": True, + "enableActionKeys": True, + "enableNavigationKeys": False, + "enableDiacriticalKeys": False, + "enableEchoByCharacter": False, + "enableEchoByWord": False, + "enableEchoBySentence": False, + } + + @staticmethod + def _setup_essential_mocks(test_context: CthulhuTestContext, essential_modules: dict) -> None: + """Set up essential mock objects for testing.""" + + essential_modules["cthulhu.cthulhu_i18n"]._ = lambda x: x + essential_modules["cthulhu.debug"].print_message = test_context.Mock() + essential_modules["cthulhu.debug"].LEVEL_INFO = 800 + essential_modules["cthulhu.debug"].LEVEL_SEVERE = 1000 + essential_modules["cthulhu.debug"].debugLevel = 1000 + + controller_mock = test_context.Mock() + controller_mock.register_decorated_module.return_value = None + essential_modules["cthulhu.dbus_service"].get_remote_controller.return_value = controller_mock + + focus_manager_instance = test_context.Mock() + focus_manager_instance.get_locus_of_focus.return_value = None + essential_modules["cthulhu.focus_manager"].get_manager.return_value = focus_manager_instance + + essential_modules["cthulhu.AXObject"].supports_collection.return_value = True + essential_modules["cthulhu.AXUtilities"].is_heading.return_value = False + + test_context.patch("gi.repository.Gtk.Grid", new=_FakeGtkGrid) + test_context.patch("gi.repository.Gtk.CheckButton", new=_FakeCheckButton) + + def _setup_presenter(self, test_context: CthulhuTestContext): + """Set up presenter and dependencies for testing.""" + + essential_modules = test_context.setup_shared_dependencies(self._ADDITIONAL_MODULES) + + self._setup_cmdnames(essential_modules["cthulhu.cmdnames"]) + self._setup_essential_mocks(test_context, essential_modules) + self._setup_guilabels(essential_modules["cthulhu.guilabels"]) + + self._setup_atspi_patches(test_context) + + from cthulhu import gsettings_registry + from cthulhu.typing_echo_presenter import TypingEchoPresenter + + registry = gsettings_registry.get_registry() + registry.clear_runtime_values() + + presenter = TypingEchoPresenter() + return presenter + + @pytest.mark.parametrize( + "getter_name,setter_name,setting_key,test_value", + [ + ("get_key_echo_enabled", "set_key_echo_enabled", "enableKeyEcho", False), + ( + "get_character_echo_enabled", + "set_character_echo_enabled", + "enableEchoByCharacter", + False, + ), + ("get_word_echo_enabled", "set_word_echo_enabled", "enableEchoByWord", True), + ( + "get_sentence_echo_enabled", + "set_sentence_echo_enabled", + "enableEchoBySentence", + False, + ), + ( + "get_alphabetic_keys_enabled", + "set_alphabetic_keys_enabled", + "enableAlphabeticKeys", + False, + ), + ("get_numeric_keys_enabled", "set_numeric_keys_enabled", "enableNumericKeys", True), + ( + "get_punctuation_keys_enabled", + "set_punctuation_keys_enabled", + "enablePunctuationKeys", + True, + ), + ("get_space_enabled", "set_space_enabled", "enableSpace", False), + ("get_modifier_keys_enabled", "set_modifier_keys_enabled", "enableModifierKeys", True), + ("get_function_keys_enabled", "set_function_keys_enabled", "enableFunctionKeys", False), + ("get_action_keys_enabled", "set_action_keys_enabled", "enableActionKeys", True), + ( + "get_navigation_keys_enabled", + "set_navigation_keys_enabled", + "enableNavigationKeys", + False, + ), + ( + "get_diacritical_keys_enabled", + "set_diacritical_keys_enabled", + "enableDiacriticalKeys", + True, + ), + ], + ) + def test_presenter_getters_and_setters( + self, + test_context: CthulhuTestContext, + getter_name: str, + setter_name: str, + setting_key: str, + test_value: bool, + ) -> None: + """Test presenter getter and setter methods.""" + presenter = self._setup_presenter(test_context) + + getter = getattr(presenter, getter_name) + setter = getattr(presenter, setter_name) + + assert getter() is self._DEFAULT_VALUES[setting_key] + setter(test_value) + assert getter() == test_value + + def test_locking_keys_presented_getter_and_setter(self, test_context: CthulhuTestContext) -> None: + """Test locking keys presented getter and setter with special logic.""" + presenter = self._setup_presenter(test_context) + + presenter._present_locking_keys = True + assert presenter.get_locking_keys_presented() is True + + presenter._present_locking_keys = False + assert presenter.get_locking_keys_presented() is False + + presenter._present_locking_keys = None + + speech_presenter_patch = test_context.patch("cthulhu.speech_presenter.get_presenter") + speech_presenter_instance = speech_presenter_patch.return_value + + speech_presenter_instance.get_only_speak_displayed_text.return_value = False + assert presenter.get_locking_keys_presented() is True + + speech_presenter_instance.get_only_speak_displayed_text.return_value = True + assert presenter.get_locking_keys_presented() is False + + presenter.set_locking_keys_presented(True) + assert presenter._present_locking_keys is True + + presenter.set_locking_keys_presented(None) + assert presenter._present_locking_keys is None + + def test_cycle_key_echo_basic_transitions(self, test_context: CthulhuTestContext) -> None: + """Test cycle_key_echo method basic state transitions.""" + presenter = self._setup_presenter(test_context) + + script_mock = test_context.mocker.MagicMock() + + presenter.set_key_echo_enabled(False) + + result = presenter.cycle_key_echo(script_mock, None, True) + assert result is True + assert presenter.get_key_echo_enabled() is True + assert presenter.get_word_echo_enabled() is False + assert presenter.get_sentence_echo_enabled() is False + + result = presenter.cycle_key_echo(script_mock, None, True) + assert result is True + assert presenter.get_key_echo_enabled() is False + assert presenter.get_word_echo_enabled() is True + assert presenter.get_sentence_echo_enabled() is False + + def test_cycle_key_echo_advanced_transitions(self, test_context: CthulhuTestContext) -> None: + """Test cycle_key_echo method advanced state transitions.""" + presenter = self._setup_presenter(test_context) + + script_mock = test_context.mocker.MagicMock() + + presenter.set_key_echo_enabled(False) + presenter.set_word_echo_enabled(True) + + result = presenter.cycle_key_echo(script_mock, None, True) + assert result is True + assert presenter.get_key_echo_enabled() is False + assert presenter.get_word_echo_enabled() is False + assert presenter.get_sentence_echo_enabled() is True + + result = presenter.cycle_key_echo(script_mock, None, True) + assert result is True + assert presenter.get_key_echo_enabled() is True + assert presenter.get_word_echo_enabled() is True + assert presenter.get_sentence_echo_enabled() is False + + result = presenter.cycle_key_echo(script_mock, None, True) + assert result is True + assert presenter.get_key_echo_enabled() is False + assert presenter.get_word_echo_enabled() is True + assert presenter.get_sentence_echo_enabled() is True + + result = presenter.cycle_key_echo(script_mock, None, True) + assert result is True + assert presenter.get_key_echo_enabled() is False + assert presenter.get_word_echo_enabled() is False + assert presenter.get_sentence_echo_enabled() is False + + def test_cycle_key_echo_with_script_presentation(self, test_context: CthulhuTestContext) -> None: + """Test cycle_key_echo calls present_message when script is provided.""" + presenter = self._setup_presenter(test_context) + + script_mock = test_context.mocker.MagicMock() + + presenter.set_key_echo_enabled(False) + + from cthulhu import presentation_manager + + present_msg = presentation_manager.get_manager().present_message + assert isinstance(present_msg, Mock) + present_msg.reset_mock() # pylint: disable=no-member + + presenter.cycle_key_echo(script_mock, None, True) + assert present_msg.call_count == 1 # pylint: disable=no-member + + present_msg.reset_mock() # pylint: disable=no-member + presenter.cycle_key_echo(script_mock, None, False) + assert present_msg.call_count == 0 # pylint: disable=no-member + + presenter.cycle_key_echo(None, None, True) + + def test_should_echo_keyboard_event_basic_cases(self, test_context: CthulhuTestContext) -> None: + """Test should_echo_keyboard_event for basic cases.""" + presenter = self._setup_presenter(test_context) + + event_mock = test_context.mocker.MagicMock() + event_mock.should_obscure.return_value = False + event_mock.get_key_name.return_value = "a" + + event_mock.is_pressed_key.return_value = False + assert presenter.should_echo_keyboard_event(event_mock) is False + + event_mock.is_pressed_key.return_value = True + presenter.set_key_echo_enabled(False) + event_mock.is_cthulhu_modifier.return_value = False + event_mock.is_alt_control_or_cthulhu_modified.return_value = False + event_mock.is_locking_key.return_value = False + presenter.is_character_echoable = test_context.mocker.MagicMock(return_value=False) + assert presenter.should_echo_keyboard_event(event_mock) is False + + def test_should_echo_keyboard_event_cthulhu_modifier(self, test_context: CthulhuTestContext) -> None: + """Test should_echo_keyboard_event for Cthulhu modifier keys.""" + presenter = self._setup_presenter(test_context) + + event_mock = test_context.mocker.MagicMock() + event_mock.should_obscure.return_value = False + event_mock.get_key_name.return_value = "Insert_L" + event_mock.is_pressed_key.return_value = True + event_mock.is_cthulhu_modifier.return_value = True + + event_mock.get_click_count.return_value = 2 + assert presenter.should_echo_keyboard_event(event_mock) is True + + event_mock.get_click_count.return_value = 1 + assert presenter.should_echo_keyboard_event(event_mock) is True + + presenter.set_modifier_keys_enabled(False) + assert presenter.should_echo_keyboard_event(event_mock) is False + + def test_should_echo_keyboard_event_modified_keys(self, test_context: CthulhuTestContext) -> None: + """Test should_echo_keyboard_event for modified keys.""" + presenter = self._setup_presenter(test_context) + + event_mock = test_context.mocker.MagicMock() + event_mock.should_obscure.return_value = False + event_mock.get_key_name.return_value = "a" + event_mock.is_pressed_key.return_value = True + event_mock.is_cthulhu_modifier.return_value = False + + event_mock.is_alt_control_or_cthulhu_modified.return_value = True + assert presenter.should_echo_keyboard_event(event_mock) is False + + def test_should_echo_keyboard_event_character_echoable( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test should_echo_keyboard_event when character is echoable.""" + presenter = self._setup_presenter(test_context) + + event_mock = test_context.mocker.MagicMock() + event_mock.should_obscure.return_value = False + event_mock.get_key_name.return_value = "a" + event_mock.is_pressed_key.return_value = True + event_mock.is_cthulhu_modifier.return_value = False + event_mock.is_alt_control_or_cthulhu_modified.return_value = False + + presenter.is_character_echoable = test_context.mocker.MagicMock(return_value=True) + assert presenter.should_echo_keyboard_event(event_mock) is False + + @pytest.mark.parametrize( + "key_type,_setting_key,expected_result", + [ + # Standard key types - enabled + ("alphabetic", "enableAlphabeticKeys", True), + ("numeric", "enableNumericKeys", True), + ("punctuation", "enablePunctuationKeys", True), + ("modifier", "enableModifierKeys", True), + ("function", "enableFunctionKeys", True), + ("action", "enableActionKeys", True), + ("navigation", "enableNavigationKeys", True), + ("diacritical", "enableDiacriticalKeys", True), + # Standard key types - disabled + ("alphabetic", "enableAlphabeticKeys", False), + ("numeric", "enableNumericKeys", False), + ("punctuation", "enablePunctuationKeys", False), + ("modifier", "enableModifierKeys", False), + ("function", "enableFunctionKeys", False), + ("action", "enableActionKeys", False), + ("navigation", "enableNavigationKeys", False), + ("diacritical", "enableDiacriticalKeys", False), + ], + ) + def test_should_echo_keyboard_event_key_types( + self, + test_context: CthulhuTestContext, + key_type: str, + _setting_key: str, + expected_result: bool, + ) -> None: + """Test should_echo_keyboard_event for different key types.""" + presenter = self._setup_presenter(test_context) + + from cthulhu import gsettings_registry + + event_mock = test_context.mocker.MagicMock() + event_mock.should_obscure.return_value = False + event_mock.get_key_name.return_value = "test_key" + event_mock.is_pressed_key.return_value = True + event_mock.is_cthulhu_modifier.return_value = False + event_mock.is_alt_control_or_cthulhu_modified.return_value = False + event_mock.is_locking_key.return_value = False + event_mock.is_space.return_value = False + presenter.is_character_echoable = test_context.mocker.MagicMock(return_value=False) + + # Set all key type checks to False first + event_mock.is_alphabetic_key.return_value = False + event_mock.is_numeric_key.return_value = False + event_mock.is_punctuation_key.return_value = False + event_mock.is_modifier_key.return_value = False + event_mock.is_function_key.return_value = False + event_mock.is_action_key.return_value = False + event_mock.is_navigation_key.return_value = False + event_mock.is_diacritical_key.return_value = False + + # Enable the specific key type being tested + setattr(event_mock, f"is_{key_type}_key", lambda: True) + + gs_key = f"{key_type}-keys" + gsettings_registry.get_registry().set_runtime_value("typing-echo", gs_key, expected_result) + + result = presenter.should_echo_keyboard_event(event_mock) + assert result is expected_result + + def test_should_echo_keyboard_event_space_key_scenarios( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test should_echo_keyboard_event for space key with different settings.""" + presenter = self._setup_presenter(test_context) + + event_mock = test_context.mocker.MagicMock() + event_mock.should_obscure.return_value = False + event_mock.get_key_name.return_value = "space" + event_mock.is_pressed_key.return_value = True + event_mock.is_cthulhu_modifier.return_value = False + event_mock.is_alt_control_or_cthulhu_modified.return_value = False + event_mock.is_locking_key.return_value = False + event_mock.is_space.return_value = True + presenter.is_character_echoable = test_context.mocker.MagicMock(return_value=False) + + # Set all other key types to False + event_mock.is_alphabetic_key.return_value = False + event_mock.is_numeric_key.return_value = False + event_mock.is_punctuation_key.return_value = False + event_mock.is_modifier_key.return_value = False + event_mock.is_function_key.return_value = False + event_mock.is_action_key.return_value = False + event_mock.is_navigation_key.return_value = False + event_mock.is_diacritical_key.return_value = False + + # Test space key with space setting enabled (default), character echo disabled (default) + assert presenter.should_echo_keyboard_event(event_mock) is True + + # Test space key with space disabled but character echo enabled (should echo) + presenter.set_space_enabled(False) + presenter.set_character_echo_enabled(True) + assert presenter.should_echo_keyboard_event(event_mock) is True + + # Test space key with both space and character echo disabled + presenter.set_character_echo_enabled(False) + assert presenter.should_echo_keyboard_event(event_mock) is False + + def test_should_echo_keyboard_event_locking_keys(self, test_context: CthulhuTestContext) -> None: + """Test should_echo_keyboard_event for locking keys.""" + presenter = self._setup_presenter(test_context) + + event_mock = test_context.mocker.MagicMock() + event_mock.should_obscure.return_value = False + event_mock.get_key_name.return_value = "Caps_Lock" + event_mock.is_pressed_key.return_value = True + event_mock.is_cthulhu_modifier.return_value = False + event_mock.is_alt_control_or_cthulhu_modified.return_value = False + event_mock.is_locking_key.return_value = True + presenter.is_character_echoable = test_context.mocker.MagicMock(return_value=False) + + # Test locking key when locking keys are presented + presenter._present_locking_keys = True + assert presenter.should_echo_keyboard_event(event_mock) is True + + # Test locking key when locking keys are not presented + presenter._present_locking_keys = False + assert presenter.should_echo_keyboard_event(event_mock) is False + + def test_should_echo_keyboard_event_password_text_obscuring( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test should_echo_keyboard_event with password text that should be obscured.""" + presenter = self._setup_presenter(test_context) + + event_mock = test_context.mocker.MagicMock() + event_mock.get_key_name.return_value = "a" + event_mock.is_pressed_key.return_value = True + event_mock.is_cthulhu_modifier.return_value = False + event_mock.is_alt_control_or_cthulhu_modified.return_value = False + event_mock.is_locking_key.return_value = False + # Set all other key type checks to False so we get to password text check + event_mock.is_navigation_key.return_value = False + event_mock.is_action_key.return_value = False + event_mock.is_modifier_key.return_value = False + event_mock.is_function_key.return_value = False + event_mock.is_diacritical_key.return_value = False + event_mock.is_alphabetic_key.return_value = True + presenter.is_character_echoable = test_context.mocker.MagicMock(return_value=False) + + # Test with password text that should be obscured - should not echo + event_mock.should_obscure.return_value = True + test_context.patch("cthulhu.ax_utilities.AXUtilities.is_password_text", return_value=True) + assert presenter.should_echo_keyboard_event(event_mock) is False + + # Test with password text that should not be obscured - should echo alphabetic + event_mock.should_obscure.return_value = False + test_context.patch("cthulhu.ax_utilities.AXUtilities.is_password_text", return_value=False) + assert presenter.should_echo_keyboard_event(event_mock) is True + + def test_is_character_echoable(self, test_context: CthulhuTestContext) -> None: + """Test is_character_echoable method.""" + presenter = self._setup_presenter(test_context) + + event_mock = test_context.mocker.MagicMock() + + assert presenter.is_character_echoable(event_mock) is False + + presenter.set_character_echo_enabled(True) + + event_mock.is_alt_control_or_cthulhu_modified.return_value = True + assert presenter.is_character_echoable(event_mock) is False + + event_mock.is_alt_control_or_cthulhu_modified.return_value = False + event_mock.is_printable_key.return_value = False + assert presenter.is_character_echoable(event_mock) is False + + event_mock.is_printable_key.return_value = True + obj_mock = test_context.mocker.MagicMock() + event_mock.get_object.return_value = obj_mock + + test_context.patch("cthulhu.ax_utilities.AXUtilities.is_password_text", return_value=True) + assert presenter.is_character_echoable(event_mock) is False + + test_context.patch("cthulhu.ax_utilities.AXUtilities.is_password_text", return_value=False) + test_context.patch("cthulhu.ax_utilities.AXUtilities.is_editable", return_value=True) + test_context.patch("cthulhu.ax_utilities.AXUtilities.is_terminal", return_value=False) + assert presenter.is_character_echoable(event_mock) is True + + test_context.patch("cthulhu.ax_utilities.AXUtilities.is_editable", return_value=False) + test_context.patch("cthulhu.ax_utilities.AXUtilities.is_terminal", return_value=True) + assert presenter.is_character_echoable(event_mock) is True + + test_context.patch("cthulhu.ax_utilities.AXUtilities.is_terminal", return_value=False) + assert presenter.is_character_echoable(event_mock) is False + + def test_echo_previous_word(self, test_context: CthulhuTestContext) -> None: + """Test echo_previous_word method.""" + presenter = self._setup_presenter(test_context) + + obj_mock = test_context.mocker.MagicMock() + + assert presenter.echo_previous_word(obj_mock) is False + + presenter.set_word_echo_enabled(True) + + test_context.patch("cthulhu.ax_text.AXText.get_caret_offset", return_value=5) + test_context.patch("cthulhu.ax_text.AXText.get_character_count", return_value=10) + + test_context.patch("cthulhu.ax_text.AXText.get_caret_offset", return_value=0) + assert presenter.echo_previous_word(obj_mock) is False + + test_context.patch("cthulhu.ax_text.AXText.get_caret_offset", return_value=5) + char_mock_1 = test_context.patch("cthulhu.ax_text.AXText.get_character_at_offset") + char_mock_1.side_effect = [("a", 4, 5), ("b", 3, 4)] + assert presenter.echo_previous_word(obj_mock) is False + + char_mock_2 = test_context.patch("cthulhu.ax_text.AXText.get_character_at_offset") + char_mock_2.side_effect = [(" ", 4, 5), (" ", 3, 4)] + assert presenter.echo_previous_word(obj_mock) is False + + char_mock_3 = test_context.patch("cthulhu.ax_text.AXText.get_character_at_offset") + char_mock_3.side_effect = [(" ", 4, 5), ("a", 3, 4)] + test_context.patch("cthulhu.ax_text.AXText.get_word_at_offset", return_value=("hello", 0, 5)) + + from cthulhu import presentation_manager + + speak_text = presentation_manager.get_manager().speak_accessible_text + assert isinstance(speak_text, Mock) + speak_text.reset_mock() # pylint: disable=no-member + + result = presenter.echo_previous_word(obj_mock) + assert result is True + speak_text.assert_called_with(obj_mock, "hello") # pylint: disable=no-member + + char_mock_4 = test_context.patch("cthulhu.ax_text.AXText.get_character_at_offset") + char_mock_4.side_effect = [(" ", 4, 5), ("a", 3, 4)] + test_context.patch("cthulhu.ax_text.AXText.get_word_at_offset", return_value=("", 0, 0)) + assert presenter.echo_previous_word(obj_mock) is False + + def test_echo_previous_sentence(self, test_context: CthulhuTestContext) -> None: + """Test echo_previous_sentence method.""" + presenter = self._setup_presenter(test_context) + + obj_mock = test_context.mocker.MagicMock() + + assert presenter.echo_previous_sentence(obj_mock) is False + + presenter.set_sentence_echo_enabled(True) + + test_context.patch("cthulhu.ax_text.AXText.get_caret_offset", return_value=10) + + char_mock_1 = test_context.patch("cthulhu.ax_text.AXText.get_character_at_offset") + char_mock_1.side_effect = [("a", 9, 10), ("b", 8, 9)] + assert presenter.echo_previous_sentence(obj_mock) is False + + char_mock_2 = test_context.patch("cthulhu.ax_text.AXText.get_character_at_offset") + char_mock_2.side_effect = [(" ", 9, 10), (".", 8, 9)] + test_context.patch( + "cthulhu.ax_text.AXText.get_sentence_at_offset", + return_value=("Hello world.", 0, 12), + ) + + from cthulhu import presentation_manager + + speak_text = presentation_manager.get_manager().speak_accessible_text + assert isinstance(speak_text, Mock) + speak_text.reset_mock() # pylint: disable=no-member + + result = presenter.echo_previous_sentence(obj_mock) + assert result is True + speak_text.assert_called_with(obj_mock, "Hello world.") # pylint: disable=no-member + + char_mock_3 = test_context.patch("cthulhu.ax_text.AXText.get_character_at_offset") + char_mock_3.side_effect = [(" ", 9, 10), (".", 8, 9)] + test_context.patch("cthulhu.ax_text.AXText.get_sentence_at_offset", return_value=("", 0, 0)) + assert presenter.echo_previous_sentence(obj_mock) is False + + def test_commands_and_bindings(self, test_context: CthulhuTestContext) -> None: + """Test commands are registered in CommandManager.""" + presenter = self._setup_presenter(test_context) + from cthulhu import command_manager + + # Commands are registered during setup() + presenter.set_up_commands() + + # Verify commands are registered in CommandManager + cmd_manager = command_manager.get_manager() + assert cmd_manager.get_keyboard_command("cycleKeyEchoHandler") is not None + + @pytest.mark.parametrize( + "getter_name,gs_key,_setting_key", + [ + ("get_key_echo_enabled", "key-echo", "enableKeyEcho"), + ("get_character_echo_enabled", "character-echo", "enableEchoByCharacter"), + ("get_word_echo_enabled", "word-echo", "enableEchoByWord"), + ("get_sentence_echo_enabled", "sentence-echo", "enableEchoBySentence"), + ("get_alphabetic_keys_enabled", "alphabetic-keys", "enableAlphabeticKeys"), + ("get_numeric_keys_enabled", "numeric-keys", "enableNumericKeys"), + ("get_punctuation_keys_enabled", "punctuation-keys", "enablePunctuationKeys"), + ("get_space_enabled", "space", "enableSpace"), + ("get_modifier_keys_enabled", "modifier-keys", "enableModifierKeys"), + ("get_function_keys_enabled", "function-keys", "enableFunctionKeys"), + ("get_action_keys_enabled", "action-keys", "enableActionKeys"), + ("get_navigation_keys_enabled", "navigation-keys", "enableNavigationKeys"), + ("get_diacritical_keys_enabled", "diacritical-keys", "enableDiacriticalKeys"), + ], + ) + def test_getter_returns_dconf_value_when_available( + self, + test_context: CthulhuTestContext, + getter_name: str, + gs_key: str, + _setting_key: str, + ) -> None: + """Test getter returns dconf value when layered_lookup returns a value.""" + presenter = self._setup_presenter(test_context) + + from cthulhu import gsettings_registry + from cthulhu.gsettings_registry import GSettingsSchemaHandle + + registry = gsettings_registry.get_registry() + + mock_handle = test_context.Mock(spec=GSettingsSchemaHandle) + mock_handle.get_boolean.return_value = False + registry._handles["typing-echo"] = mock_handle + + getter = getattr(presenter, getter_name) + assert getter() is False + mock_handle.get_boolean.assert_called_with(gs_key, "", None) + + registry._handles.pop("typing-echo", None) + + @pytest.mark.parametrize( + "getter_name,setting_key", + [ + ("get_key_echo_enabled", "enableKeyEcho"), + ("get_character_echo_enabled", "enableEchoByCharacter"), + ("get_word_echo_enabled", "enableEchoByWord"), + ("get_sentence_echo_enabled", "enableEchoBySentence"), + ("get_alphabetic_keys_enabled", "enableAlphabeticKeys"), + ("get_numeric_keys_enabled", "enableNumericKeys"), + ("get_punctuation_keys_enabled", "enablePunctuationKeys"), + ("get_space_enabled", "enableSpace"), + ("get_modifier_keys_enabled", "enableModifierKeys"), + ("get_function_keys_enabled", "enableFunctionKeys"), + ("get_action_keys_enabled", "enableActionKeys"), + ("get_navigation_keys_enabled", "enableNavigationKeys"), + ("get_diacritical_keys_enabled", "enableDiacriticalKeys"), + ], + ) + def test_getter_returns_default_when_disabled( + self, + test_context: CthulhuTestContext, + getter_name: str, + setting_key: str, + ) -> None: + """Test getter returns module-owned default when registry is disabled.""" + presenter = self._setup_presenter(test_context) + + getter = getattr(presenter, getter_name) + assert getter() is self._DEFAULT_VALUES[setting_key] + + def test_get_setting_logs_dconf_layer(self, test_context: CthulhuTestContext) -> None: + """Test that dconf lookup logs the source layer and value.""" + presenter = self._setup_presenter(test_context) + + from cthulhu import debug as debug_mock + from cthulhu import gsettings_registry + from cthulhu.gsettings_registry import GSettingsSchemaHandle + + registry = gsettings_registry.get_registry() + + mock_handle = test_context.Mock(spec=GSettingsSchemaHandle) + mock_handle.get_boolean.return_value = False + registry._handles["typing-echo"] = mock_handle + + print_msg = debug_mock.print_message + assert isinstance(print_msg, Mock) + debug_mock.debugLevel = 800 + print_msg.reset_mock() # pylint: disable=no-member + + assert presenter.get_key_echo_enabled() is False + + registry._handles.pop("typing-echo", None)