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