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