# Orca # # 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