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