# Orca # # 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=too-many-lines # 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 """Manager for script commands and keybindings.""" from __future__ import annotations import contextlib import time from enum import Enum from typing import TYPE_CHECKING, Any import gi gi.require_version("Atk", "1.0") gi.require_version("Gdk", "3.0") gi.require_version("Gtk", "3.0") from gi.repository import Gdk, GLib, Gtk # pylint: disable=no-name-in-module from . import ( cmdnames, dbus_service, debug, gsettings_migrator, gsettings_registry, guilabels, input_event, input_event_manager, keybindings, keynames, messages, orca_modifier_manager, preferences_grid_base, presentation_manager, script_manager, ) from .ax_object import AXObject if TYPE_CHECKING: from collections.abc import Callable from .scripts import default class Command: """Base class for Orca commands. Commands have two independent activity states: enabled: User preference for whether this command should be active. - Set via user settings or toggle commands (e.g., "toggle caret navigation") - Persists across sessions - Example: User prefers caret navigation on suspended: Temporary system override that deactivates the command. - Set by Orca modes (e.g., focus mode suspends browse-mode commands) - Does NOT change the user's enabled preference - When suspension is lifted, command returns to its enabled state - Example: Focus mode suspends structural navigation; leaving focus mode automatically restores it """ # pylint: disable-next=too-many-arguments, too-many-positional-arguments def __init__( self, name: str, function: Callable[..., bool], group_label: str, description: str = "", enabled: bool = True, suspended: bool = False, ) -> None: """Initializes a command.""" self._name = name self._function = function self._group_label = group_label self._description = description self._enabled = enabled self._suspended = suspended def __str__(self) -> str: """Returns a string representation of the command.""" parts = [f"Command({self._name})"] if self._suspended: parts.append("SUSPENDED") return " ".join(parts) def get_name(self) -> str: """Returns the command name.""" return self._name def get_function(self) -> Callable[..., bool]: """Returns the command function.""" return self._function def get_group_label(self) -> str: """Returns the group label for display grouping.""" return self._group_label def get_description(self) -> str: """Returns the command description.""" return self._description def set_group_label(self, group_label: str) -> None: """Sets the group label.""" self._group_label = group_label def is_enabled(self) -> bool: """Returns True if the user has enabled this command.""" return self._enabled def set_enabled(self, enabled: bool) -> None: """Sets whether the user has enabled this command.""" self._enabled = enabled def is_suspended(self) -> bool: """Returns True if this command is temporarily suspended by the system.""" return self._suspended def set_suspended(self, suspended: bool) -> None: """Sets whether this command is temporarily suspended by the system.""" self._suspended = suspended def execute(self, script: default.Script, event: input_event.InputEvent | None = None) -> bool: """Executes this command's function and returns True if handled.""" return self._function(script, event) class KeyboardCommand(Command): # pylint: disable=too-many-instance-attributes """A command that can be bound to keyboard keys.""" # pylint: disable-next=too-many-arguments, too-many-positional-arguments def __init__( self, name: str, function: Callable[..., bool], group_label: str, description: str = "", desktop_keybinding: keybindings.KeyBinding | None = None, laptop_keybinding: keybindings.KeyBinding | None = None, enabled: bool = True, suspended: bool = False, is_group_toggle: bool = False, ) -> None: """Initializes a keyboard command.""" super().__init__(name, function, group_label, description, enabled, suspended) # The default bindings. self._desktop_keybinding = desktop_keybinding self._laptop_keybinding = laptop_keybinding # The actual binding, taking into account user overrides. self._keybinding: keybindings.KeyBinding | None = None self._is_group_toggle = is_group_toggle def __str__(self) -> str: """Returns a string representation of the command.""" parts = [f"KeyboardCommand({self._name})"] if self._keybinding: parts.append(str(self._keybinding)) else: parts.append("UNBOUND") if self._suspended: parts.append("SUSPENDED") return " ".join(parts) def get_keybinding(self) -> keybindings.KeyBinding | None: """Returns the current key binding, or None if unbound.""" return self._keybinding def get_default_keybinding( self, is_desktop: bool | None = None, ) -> keybindings.KeyBinding | None: """Returns the default key binding for the specified or current layout.""" if is_desktop is None: is_desktop = get_manager().is_desktop_layout() return self._desktop_keybinding if is_desktop else self._laptop_keybinding def has_default_keybinding(self) -> bool: """Returns True if this command has a default keybinding for either layout.""" return self._desktop_keybinding is not None or self._laptop_keybinding is not None def set_keybinding(self, keybinding: keybindings.KeyBinding | None) -> None: """Sets the current key binding.""" self._keybinding = keybinding def is_group_toggle(self) -> bool: """Returns True if this command toggles its group's enabled state.""" return self._is_group_toggle def is_active(self) -> bool: """Returns True if this command should respond to key events.""" return self._enabled and not self._suspended and self._keybinding is not None class BrailleCommand(Command): """A command that can only be triggered by braille hardware.""" # pylint: disable=too-many-arguments, too-many-positional-arguments def __init__( self, name: str, function: Callable[..., bool], group_label: str, description: str = "", enabled: bool = True, suspended: bool = False, braille_bindings: tuple[int, ...] = (), executes_in_learn_mode: bool = False, ) -> None: """Initializes a braille command.""" super().__init__(name, function, group_label, description, enabled, suspended) self._braille_bindings = braille_bindings self._executes_in_learn_mode = executes_in_learn_mode def __str__(self) -> str: """Returns a string representation of the command.""" parts = [f"BrailleCommand({self._name})"] if self._braille_bindings: parts.append(f"braille={self._braille_bindings}") if self._suspended: parts.append("SUSPENDED") return " ".join(parts) def get_braille_bindings(self) -> tuple[int, ...]: """Returns the braille bindings (BrlAPI key codes).""" return self._braille_bindings def executes_in_learn_mode(self) -> bool: """Returns True if this command should execute in learn mode (e.g., pan commands).""" return self._executes_in_learn_mode # pylint: disable-next=too-many-instance-attributes class KeybindingsPreferencesGrid(preferences_grid_base.PreferencesGridBase): """Grid widget for keybindings preferences.""" # pylint: disable=no-member def __init__( self, script: default.Script, title_change_callback: Callable[[str], None] | None = None, ) -> None: """Initialize the keybindings preferences grid.""" super().__init__(guilabels.COMMANDS) self._script = script self._initializing = True self._title_change_callback = title_change_callback self._categories: dict[str, list[KeyboardCommand]] = {} self._current_category: str | None = None self._captured_key: tuple[str, int, int] = ("", 0, 0) self._orca_modifier_pressed_during_capture: bool = False self._binding_cleared: bool = False self._pending_key_bindings: dict[str, str] = {} self._pending_already_bound_message_id: int | None = None # Store modified keybindings separately so they survive apply_user_overrides() self._modified_keybindings: dict[str, keybindings.KeyBinding | None] = {} self._keybinding_being_edited: str | None = None self._saved_commands: dict[str, KeyboardCommand] = {} self._original_keyboard_layout_is_desktop: bool = ( get_manager().get_keyboard_layout_is_desktop() ) self.keyboard_layout_combo: Gtk.ComboBox | None = None self._orca_modifier_combo: Gtk.ComboBox | None = None self._combos_listbox: preferences_grid_base.FocusManagedListBox | None = None self._build() self._initializing = False # pylint: disable-next=too-many-locals def _build(self) -> None: """Build the keybindings UI.""" row = 0 keyboard_layout_model = Gtk.ListStore(str, int) keyboard_layout_model.append( [guilabels.KEYBOARD_LAYOUT_DESKTOP, KeyboardLayout.DESKTOP.value], ) keyboard_layout_model.append( [guilabels.KEYBOARD_LAYOUT_LAPTOP, KeyboardLayout.LAPTOP.value], ) orca_model = Gtk.ListStore(str) orca_model.append(["Insert, KP_Insert"]) orca_model.append(["KP_Insert"]) orca_model.append(["Insert"]) orca_model.append(["Caps_Lock, Shift_Lock"]) self._combos_listbox = preferences_grid_base.FocusManagedListBox() combo_size_group = Gtk.SizeGroup(mode=Gtk.SizeGroupMode.HORIZONTAL) row_data = [ (guilabels.KEYBOARD_LAYOUT, keyboard_layout_model, self._on_keyboard_layout_changed), ( guilabels.KEY_BINDINGS_SCREEN_READER_MODIFIER_KEY_S, orca_model, self._on_orca_modifier_changed, ), ] combos: list[Gtk.ComboBox] = [] 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) self._combos_listbox.add_row_with_widget(row_widget, combo) combos.append(combo) self.keyboard_layout_combo = combos[0] self._orca_modifier_combo = combos[1] self.attach(self._combos_listbox, 0, row, 1, 1) row += 1 stack, _categories_listbox, _detail_listbox = self._create_stacked_preferences( on_category_activated=self._on_category_activated, on_detail_row_activated=self._on_keybinding_activated, ) if self._categories_listbox: self._categories_listbox.get_accessible().set_name(guilabels.COMMANDS) self.attach(stack, 0, row, 1, 1) self._register_stack_disable_widgets(self._combos_listbox) def reload(self) -> None: """Reload keybindings from the script.""" app_name = AXObject.get_name(self._script.app) if self._script.app else "" layout = gsettings_registry.get_registry().layered_lookup( "keybindings", "keyboard-layout", "", genum="org.gnome.Orca.KeyboardLayout", app_name=app_name or None, default="desktop", ) get_manager().set_keyboard_layout_is_desktop(layout == "desktop") self._original_keyboard_layout_is_desktop = get_manager().get_keyboard_layout_is_desktop() # set_keyboard_layout_is_desktop() already applied the overrides and updated the grabs. self._populate_keybindings() self._modified_keybindings.clear() self._has_unsaved_changes = False self.refresh() def revert_changes(self) -> None: """Revert keyboard layout and orca modifier to their original values.""" current_is_desktop = get_manager().get_keyboard_layout_is_desktop() if current_is_desktop != self._original_keyboard_layout_is_desktop: get_manager().load_keyboard_layout(self._original_keyboard_layout_is_desktop) orca_modifier_manager.get_manager().set_modifier_keys_override(None) def _populate_keybindings(self) -> None: """Build categories dictionary and populate the categories list.""" if self._categories_listbox is None: return self._categories.clear() for child in self._categories_listbox.get_children(): self._categories_listbox.remove(child) app_name = AXObject.get_name(self._script.app) if self._script.app else "" if app_name: self._categories[app_name] = [] all_commands = get_manager().get_all_keyboard_commands() for cmd in all_commands: group_label = cmd.get_group_label() if group_label not in self._categories: self._categories[group_label] = [] self._categories[group_label].append(cmd) if app_name and app_name in self._categories and not self._categories[app_name]: del self._categories[app_name] self._categories_listbox.set_header_func(self._separator_header_func, None) # Custom sort: For app-specific, app name first then Default. For non-app, Default first. # After that, sort alphabetically. def sort_key(category_name): if app_name and category_name == app_name: return (0, category_name) if category_name == guilabels.KB_GROUP_DEFAULT: if app_name and app_name in self._categories: return (1, category_name) return (0, category_name) return (2, category_name) sorted_categories = sorted(self._categories.keys(), key=sort_key) for category_name in sorted_categories: self._add_stack_category_row( self._categories_listbox, category_name, category=category_name, ) self._categories_listbox.show_all() self._pending_key_bindings = {} @staticmethod def _separator_header_func(row, before, _user_data): """Add separator between rows (standard GTK ListBox pattern).""" if before is not None: row.set_header(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) def _on_category_activated(self, row: Gtk.ListBoxRow) -> None: """Handle category selection - navigate to detail page.""" if not isinstance(row, preferences_grid_base.CategoryListBoxRow): return category_name = row.category if not category_name or category_name not in self._categories: return self._current_category = category_name self._populate_category_detail(category_name) self._show_stack_detail() def on_becoming_visible(self) -> None: """Reset to the categories view when this grid becomes visible.""" self.reload() self._show_stack_categories() if self._categories_listbox: self._categories_listbox.grab_focus() def _show_stack_categories(self) -> None: """Switch to categories view and update title to main page.""" super()._show_stack_categories() if self._title_change_callback: self._title_change_callback(guilabels.COMMANDS) def _show_stack_detail(self) -> None: """Switch to detail view and update title to category name.""" super()._show_stack_detail() if self._current_category: if self._title_change_callback: self._title_change_callback(self._current_category) if self._detail_listbox: self._detail_listbox.get_accessible().set_name(self._current_category) # pylint: disable=no-member def _populate_category_detail(self, category_name: str) -> None: """Populate the detail page with keybindings for the given category.""" if self._detail_listbox is None or category_name not in self._categories: return for child in self._detail_listbox.get_children(): self._detail_listbox.remove(child) commands = self._categories[category_name] for i, cmd in enumerate(commands): row = preferences_grid_base.CommandListBoxRow() row.set_activatable(True) outer_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) if i > 0: separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) outer_vbox.pack_start(separator, False, False, 0) vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) vbox.set_margin_start(12) vbox.set_margin_end(12) vbox.set_margin_top(12) vbox.set_margin_bottom(12) description = cmd.get_description() or cmd.get_name() desc_label = Gtk.Label(label=description, xalign=0) desc_label.set_line_wrap(True) desc_label.set_hexpand(True) vbox.pack_start(desc_label, False, False, 0) binding = cmd.get_keybinding() binding_text = self._format_keybinding_text(binding) or "" binding_label = Gtk.Label(xalign=0) binding_label.set_markup(f"{GLib.markup_escape_text(binding_text, -1)}") binding_label.get_style_context().add_class("dim-label") vbox.pack_start(binding_label, False, False, 0) outer_vbox.pack_start(vbox, False, False, 0) row.add(outer_vbox) row.command = cmd row.vbox = vbox row.binding_label = binding_label row.show_all() self._detail_listbox.add(row) self._detail_listbox.show_all() def _on_keybinding_activated(self, _listbox: Gtk.ListBox, row: Gtk.ListBoxRow) -> None: """Handle keybinding row activation - start inline editing.""" if self._keybinding_being_edited is not None: return if not isinstance(row, preferences_grid_base.CommandListBoxRow): return command = row.command if not command: return vbox = row.vbox binding_label = row.binding_label if not vbox or not binding_label: return self._start_inline_editing(row, command, vbox, binding_label) def _create_capture_entry(self, command: KeyboardCommand) -> Gtk.Entry: """Creates and returns an entry widget configured for key capture.""" entry = Gtk.Entry() entry.set_alignment(0.0) binding = command.get_keybinding() if binding and binding.keysymstring: current_text = self._format_keybinding_text(binding) entry.set_text(current_text or "") else: entry.set_text("") return entry def _start_inline_editing( self, row: Gtk.ListBoxRow, command: KeyboardCommand, vbox: Gtk.Box, binding_label: Gtk.Label, ) -> None: """Start inline editing of a keybinding.""" binding_label.hide() capture_entry = self._create_capture_entry(command) vbox.pack_start(capture_entry, False, False, 0) capture_entry.show() def swap_widgets(): vbox.remove(binding_label) capture_entry.grab_focus() return False GLib.idle_add(swap_widgets) self._captured_key = ("", 0, 0) self._orca_modifier_pressed_during_capture = False self._keybinding_being_edited = command.get_name() def on_key_release(_widget: Gtk.Widget, event: Gdk.EventKey) -> bool: event_string = Gdk.keyval_name(event.keyval) orca_mods = orca_modifier_manager.get_manager().get_orca_modifier_keys() if event_string in orca_mods: self._orca_modifier_pressed_during_capture = False return False def on_key_press(_widget: Gtk.Widget, event: Gdk.EventKey) -> bool: if event.keyval == Gdk.KEY_Escape and not self._orca_modifier_pressed_during_capture: self._finish_inline_editing( row, command, vbox, capture_entry, canceled=True, ) return True if event.keyval == Gdk.KEY_Return and not self._orca_modifier_pressed_during_capture: if self._captured_key[0]: key_name, modifiers, click_count = self._captured_key handler_name = command.get_name() description_dup = self._find_duplicate_binding( key_name, modifiers, click_count, handler_name, ) if description_dup: def present_duplicate_error(): presentation_manager.get_manager().present_message( messages.KB_ALREADY_BOUND % description_dup, ) return False GLib.idle_add(present_duplicate_error) capture_entry.set_text("") self._captured_key = ("", 0, 0) return True self._finish_inline_editing( row, command, vbox, capture_entry, canceled=False, ) return True key_processed = self._process_key_captured(event) if not key_processed or not self._captured_key[0]: return True key_name, modifiers, click_count = self._captured_key if key_name in ["Delete", "BackSpace"] and not modifiers: capture_entry.set_text("") self._captured_key = ("", 0, 0) def present_delete_message(): presentation_manager.get_manager().present_message(messages.KB_DELETED) return False GLib.idle_add(present_delete_message) return True modifier_names = keybindings.get_modifier_names(modifiers) click_count_string = keynames.get_click_count_string(click_count) if click_count_string: click_count_string = f" ({click_count_string})" new_string = modifier_names + key_name + click_count_string capture_entry.set_text(new_string) def present_message_after_keypress(): presentation_manager.get_manager().present_message( messages.KB_CAPTURED % new_string, ) return False GLib.idle_add(present_message_after_keypress) return True capture_entry.connect("key-press-event", on_key_press) capture_entry.connect("key-release-event", on_key_release) row.capture_entry = capture_entry script = script_manager.get_manager().get_active_script() assert script presentation_manager.get_manager().present_message(messages.KB_ENTER_NEW_KEY) self._saved_commands = get_manager().get_keyboard_commands() orca_modifier_manager.get_manager().remove_grabs_for_orca_modifiers() get_manager().set_active_commands({}, "Capturing keys") input_event_manager.get_manager().unmap_all_modifiers() def _finish_inline_editing( self, row: Gtk.ListBoxRow, command: KeyboardCommand, vbox: Gtk.Box, capture_entry: Gtk.Entry, canceled: bool, ) -> None: """Finish inline editing of a keybinding.""" # Update keybinding before restoring commands so grabs match actual keybindings. # Otherwise a deleted binding's grab remains, consuming keystrokes without executing # anything (is_active() returns False when keybinding is None). if not canceled: captured_text = capture_entry.get_text().strip() script_manager.get_manager().get_active_script() handler_name = command.get_name() if not captured_text: command.set_keybinding(None) self._modified_keybindings[handler_name] = None self._has_unsaved_changes = True def present_delete_confirmation(): presentation_manager.get_manager().present_message( messages.KB_DELETED_CONFIRMATION, ) return False GLib.idle_add(present_delete_confirmation) else: key_name, modifiers, click_count = self._captured_key if key_name: new_kb = keybindings.KeyBinding(key_name, modifiers, click_count) command.set_keybinding(new_kb) self._modified_keybindings[handler_name] = new_kb self._has_unsaved_changes = True def present_confirmation(): msg = messages.KB_CAPTURED_CONFIRMATION % captured_text presentation_manager.get_manager().present_message(msg) return False GLib.idle_add(present_confirmation) get_manager().set_active_commands(self._saved_commands, "Done capturing keys") orca_modifier_manager.get_manager().add_grabs_for_orca_modifiers() binding = command.get_keybinding() binding_text = self._format_keybinding_text(binding) or "" new_label = Gtk.Label(xalign=0) new_label.set_markup( f"{GLib.markup_escape_text(binding_text, -1)}", ) new_label.get_style_context().add_class("dim-label") row.binding_label = new_label capture_entry.hide() vbox.pack_start(new_label, False, False, 0) new_label.show() def swap_widgets(): vbox.remove(capture_entry) row.grab_focus() return False GLib.idle_add(swap_widgets) self._captured_key = ("", 0, 0) self._keybinding_being_edited = None def _find_duplicate_binding( self, key_name: str, modifiers: int, click_count: int, exclude_handler: str | None = None, ) -> str | None: """Find if a keybinding is already used and return its description.""" for category_commands in self._categories.values(): for cmd in category_commands: if exclude_handler and cmd.get_name() == exclude_handler: continue binding = cmd.get_keybinding() if not binding or not binding.keysymstring: continue if ( binding.keysymstring == key_name and binding.modifiers == modifiers and binding.click_count == click_count ): return cmd.get_description() or cmd.get_name() return None def _process_key_captured(self, event: Gdk.EventKey) -> bool: """Process a captured key press event.""" keycode = event.hardware_keycode keymap = Gdk.Keymap.get_default() # pylint: disable=no-value-for-parameter entries_for_keycode = keymap.get_entries_for_keycode(keycode) entries = entries_for_keycode[-1] event_string = Gdk.keyval_name(entries[0]) event_state = event.state orca_mods = orca_modifier_manager.get_manager().get_orca_modifier_keys() if event_string in orca_mods: self._orca_modifier_pressed_during_capture = True self._captured_key = ("", keybindings.ORCA_MODIFIER_MASK, 0) return False modifier_keys = [ "Alt_L", "Alt_R", "Control_L", "Control_R", "Shift_L", "Shift_R", "Meta_L", "Meta_R", "Super_L", "Super_R", "Num_Lock", "Caps_Lock", "Shift_Lock", "ISO_Level3_Shift", ] if event_string in modifier_keys: return False event_state = event_state & keybindings.NON_LOCKING_MODIFIER_MASK # Return and Escape are used to confirm/cancel editing, not as captured keys # Return False to let GTK process them normally if event_string in ["Return", "Escape"] and not self._orca_modifier_pressed_during_capture: return False if not self._captured_key[0]: # Preserve Orca modifier if it was already captured. if self._captured_key[1] & keybindings.ORCA_MODIFIER_MASK: event_state |= keybindings.ORCA_MODIFIER_MASK self._captured_key = (event_string, event_state, 1) return True string, modifiers, click_count = self._captured_key # Preserve Orca modifier from previous key if present before comparing if modifiers & keybindings.ORCA_MODIFIER_MASK: event_state |= keybindings.ORCA_MODIFIER_MASK if string != event_string or modifiers != event_state: self._captured_key = (event_string, event_state, 1) return True # Same key pressed again - increment click count self._captured_key = (event_string, event_state, click_count + 1) return True def _create_key_capture_dialog( self, description: str, command: KeyboardCommand, ) -> tuple[Gtk.Dialog, Gtk.Entry]: """Creates and returns a dialog and entry configured for key capture.""" dialog = Gtk.Dialog(transient_for=self.get_toplevel()) dialog.set_modal(True) dialog.set_title(guilabels.KB_HEADER_KEY_BINDING) dialog.set_default_size(500, -1) content = dialog.get_content_area() content.set_spacing(18) content.set_margin_start(24) content.set_margin_end(24) content.set_margin_top(24) content.set_margin_bottom(24) desc_label = Gtk.Label(label=description) desc_label.set_line_wrap(True) desc_label.set_xalign(0) content.pack_start(desc_label, False, False, 0) entry = Gtk.Entry() entry.set_editable(False) entry.set_can_focus(True) entry.set_width_chars(40) binding = command.get_keybinding() if binding and binding.keysymstring: current_text = self._format_keybinding_text(binding) entry.set_text(current_text or "") else: entry.set_text("") content.pack_start(entry, False, False, 0) instructions = Gtk.Label(label=messages.KB_ENTER_NEW_KEY) instructions.set_line_wrap(True) instructions.set_xalign(0) instructions.get_style_context().add_class("dim-label") content.pack_start(instructions, False, False, 0) dialog.add_button(guilabels.BTN_CANCEL, Gtk.ResponseType.CANCEL) dialog.add_button(guilabels.BTN_OK, Gtk.ResponseType.OK) return dialog, entry def _handle_dialog_key_press( self, event: Gdk.EventKey, entry: Gtk.Entry, handler_name: str, dialog: Gtk.Dialog, ) -> bool: """Handles a key press event in the key capture dialog.""" if event.keyval == Gdk.KEY_Escape: dialog.response(Gtk.ResponseType.CANCEL) return True if event.keyval in (Gdk.KEY_Tab, Gdk.KEY_ISO_Left_Tab): return False if not self._process_key_captured(event) or not self._captured_key[0]: return False key_name, modifiers, click_count = self._captured_key if key_name in ("Delete", "BackSpace") and not modifiers: entry.set_text("") presentation_manager.get_manager().present_message(messages.KB_DELETED) self._captured_key = ("", 0, 0) self._binding_cleared = True return True modifier_names = keybindings.get_modifier_names(modifiers) click_count_string = keynames.get_click_count_string(click_count) if click_count_string: click_count_string = f" ({click_count_string})" new_string = modifier_names + key_name + click_count_string entry.set_text(new_string) description_dup = self._find_duplicate_binding( key_name, modifiers, click_count, handler_name, ) if description_dup: msg = messages.KB_ALREADY_BOUND % description_dup else: msg = messages.KB_CAPTURED % new_string presentation_manager.get_manager().present_message(msg) return True def _apply_dialog_key_capture( self, response: Gtk.ResponseType, command: KeyboardCommand, ) -> None: """Applies the result of a key capture dialog.""" if response != Gtk.ResponseType.OK: return handler_name = command.get_name() key_name, modifiers, click_count = self._captured_key if self._binding_cleared: command.set_keybinding(None) self._modified_keybindings[handler_name] = None elif key_name: new_kb = keybindings.KeyBinding(key_name, modifiers, click_count) command.set_keybinding(new_kb) self._modified_keybindings[handler_name] = new_kb self._has_unsaved_changes = True if self._current_category: self._populate_category_detail(self._current_category) def _show_key_capture_dialog(self, command: KeyboardCommand) -> None: """Show dialog to capture a new key binding for the given command.""" description = command.get_description() or command.get_name() handler_name = command.get_name() dialog, entry = self._create_key_capture_dialog(description, command) self._captured_key = ("", 0, 0) self._binding_cleared = False self._keybinding_being_edited = handler_name script = script_manager.get_manager().get_active_script() assert script def on_key_press(_widget: Gtk.Widget, event: Gdk.EventKey) -> bool: return self._handle_dialog_key_press(event, entry, handler_name, dialog) entry.connect("key-press-event", on_key_press) presentation_manager.get_manager().present_message(messages.KB_ENTER_NEW_KEY) dialog.show_all() entry.grab_focus() entry.grab_add() while Gtk.events_pending(): # pylint: disable=no-value-for-parameter Gtk.main_iteration() saved_commands = get_manager().get_keyboard_commands() orca_modifier_manager.get_manager().remove_grabs_for_orca_modifiers() get_manager().set_active_commands({}, "Capturing keys") input_event_manager.get_manager().unmap_all_modifiers() response = dialog.run() entry.grab_remove() get_manager().set_active_commands(saved_commands, "Done capturing keys") orca_modifier_manager.get_manager().add_grabs_for_orca_modifiers() self._apply_dialog_key_capture(response, command) dialog.destroy() self._captured_key = ("", 0, 0) self._keybinding_being_edited = None # pylint: enable=no-member def save_settings( # pylint: disable=too-many-locals, too-many-branches self, profile: str = "", app_name: str = "", ) -> tuple[dict[str, int | list[str]], dict[str, list[list[Any]]]]: """Save settings and return (general_settings, keybindings) tuple.""" general: dict[str, int | list[str]] = {} bindings: dict[str, list[list[Any]]] = {} layout_value = get_manager().get_keyboard_layout_value() if self.keyboard_layout_combo is not None: layout_iter = self.keyboard_layout_combo.get_active_iter() if layout_iter is not None: layout_value = self.keyboard_layout_combo.get_model().get_value(layout_iter, 1) general["keyboard-layout"] = layout_value is_desktop = layout_value == KeyboardLayout.DESKTOP.value if self._orca_modifier_combo is not None: tree_iter = self._orca_modifier_combo.get_active_iter() if tree_iter is not None: model = self._orca_modifier_combo.get_model() orca_modifier = model.get_value(tree_iter, 0) modifier_keys = orca_modifier.split(", ") if is_desktop: general["desktop-modifier-keys"] = modifier_keys else: general["laptop-modifier-keys"] = modifier_keys parent_overrides: dict[str, list[list[str]]] = {} if profile and (profile != "default" or app_name): registry = gsettings_registry.get_registry() if profile != "default": parent_overrides |= registry.get_keybindings("default", "") if app_name: parent_overrides |= registry.get_keybindings(profile, "") for category_commands in self._categories.values(): for cmd in category_commands: handler_name = cmd.get_name() if handler_name in self._modified_keybindings: current_kb = self._modified_keybindings[handler_name] else: current_kb = cmd.get_keybinding() current_text = self._format_keybinding_text(current_kb) if handler_name in parent_overrides: parent_text = self._format_binding_data_text(parent_overrides[handler_name]) else: parent_text = self._format_keybinding_text(cmd.get_default_keybinding()) if current_text != parent_text: msg = ( f"KEYBINDINGS GRID: Saving {handler_name}: '{current_text}' " f"(parent '{parent_text}')" ) debug.print_message(debug.LEVEL_INFO, msg, True) if current_kb and current_kb.keysymstring: binding_data = [ current_kb.keysymstring, str(current_kb.modifier_mask), str(current_kb.modifiers), str(current_kb.click_count), ] bindings[handler_name] = [binding_data] elif parent_text is not None: bindings[handler_name] = [] self._modified_keybindings.clear() self._has_unsaved_changes = False if profile: registry = gsettings_registry.get_registry() skip = not app_name and profile == "default" registry.save_schema("keybindings", general, profile, app_name, skip) kb_gs = registry.get_settings("keybindings", profile, "keybindings", app_name) if kb_gs is not None: if bindings: gsettings_migrator.import_keybindings(kb_gs, bindings) elif self._categories and kb_gs.get_user_value("entries") is not None: kb_gs.reset("entries") return general, bindings def refresh(self) -> None: """Refresh the keyboard layout and orca modifier displays.""" self._initializing = True if self.keyboard_layout_combo is not None: current_layout = get_manager().get_keyboard_layout_value() model = self.keyboard_layout_combo.get_model() if model: for i, row in enumerate(model): if row[1] == current_layout: self.keyboard_layout_combo.set_active(i) break if self._orca_modifier_combo is not None: is_desktop = get_manager().get_keyboard_layout_value() == KeyboardLayout.DESKTOP.value app_name = AXObject.get_name(self._script.app) if self._script.app else "" modifier_keys = get_manager().get_modifier_keys_for_layout(is_desktop, app_name) key_string = ", ".join(modifier_keys) orca_model = self._orca_modifier_combo.get_model() if orca_model: for i, row in enumerate(orca_model): if row[0] == key_string: self._orca_modifier_combo.set_active(i) break self._initializing = False def _on_keyboard_layout_changed(self, combo: Gtk.ComboBox) -> None: """Handle keyboard layout changes.""" if self._initializing: return tree_iter = combo.get_active_iter() if tree_iter is not None: model = combo.get_model() layout_value = model.get_value(tree_iter, 1) is_desktop = layout_value == KeyboardLayout.DESKTOP.value get_manager().load_keyboard_layout(is_desktop) if self._orca_modifier_combo is not None: app_name = AXObject.get_name(self._script.app) if self._script.app else "" saved_keys = get_manager().get_modifier_keys_for_layout(is_desktop, app_name) key_string = ", ".join(saved_keys) orca_model = self._orca_modifier_combo.get_model() matched = False for i, row in enumerate(orca_model): if row[0] == key_string: self._orca_modifier_combo.set_active(i) matched = True break if not matched: if is_desktop: self._orca_modifier_combo.set_active(0) else: self._orca_modifier_combo.set_active(3) saved_keys = orca_model[self._orca_modifier_combo.get_active()][0].split(", ") orca_modifier_manager.get_manager().set_modifier_keys_override(saved_keys) # load_keyboard_layout() already applied the overrides and updated the grabs. self._populate_keybindings() self._has_unsaved_changes = True def _on_orca_modifier_changed(self, combo: Gtk.ComboBox) -> None: """Handle orca modifier combo box changes.""" if self._initializing: return tree_iter = combo.get_active_iter() if tree_iter is None: return model = combo.get_model() orca_modifier = model.get_value(tree_iter, 0) orca_modifier_manager.get_manager().set_modifier_keys_override( orca_modifier.split(", "), ) self._has_unsaved_changes = True def _format_keybinding_text(self, kb: keybindings.KeyBinding | None) -> str | None: """Format a keybinding as text for display.""" if not kb or not kb.keysymstring: return None click_count_str = keynames.get_click_count_string(kb.click_count) if click_count_str: click_count_str = f" ({click_count_str})" return keybindings.get_modifier_names(kb.modifiers) + kb.keysymstring + click_count_str @staticmethod def _format_binding_data_text(binding_data: list[list[str]]) -> str | None: """Format raw dconf binding data as text, matching _format_keybinding_text output.""" if not binding_data: return None entry = binding_data[0] if len(entry) < 4 or not entry[0]: return None click_count_str = keynames.get_click_count_string(int(entry[3])) if click_count_str: click_count_str = f" ({click_count_str})" return keybindings.get_modifier_names(int(entry[2])) + entry[0] + click_count_str @gsettings_registry.get_registry().gsettings_enum( "org.gnome.Orca.KeyboardLayout", values={"desktop": 1, "laptop": 2}, ) class KeyboardLayout(Enum): """Keyboard layout enumeration.""" DESKTOP = 1 LAPTOP = 2 @property def string_name(self) -> str: """Returns the lowercase string name for this enum value.""" return self.name.lower() # pylint: disable-next=too-many-public-methods @gsettings_registry.get_registry().gsettings_schema( "org.gnome.Orca.Keybindings", name="keybindings", ) class CommandManager: # pylint: disable=too-many-instance-attributes """Singleton manager for coordinating commands between scripts and UI.""" _SCHEMA = "keybindings" def __init__(self) -> None: """Initializes the command manager.""" self._keyboard_commands: dict[str, KeyboardCommand] = {} self._braille_commands: dict[str, BrailleCommand] = {} self._commands_by_keyval: dict[int, list[KeyboardCommand]] = {} self._commands_by_keycode: dict[int, list[KeyboardCommand]] = {} self._is_desktop: bool = True self._initialized: bool = False self._group_enabled: dict[str, bool | None] = {} self._numlock_on: bool = False self._learn_mode_active: bool = False msg = "COMMAND MANAGER: Registering D-Bus commands." debug.print_message(debug.LEVEL_INFO, msg, True) controller = dbus_service.get_remote_controller() controller.register_decorated_module("CommandManager", self) def is_desktop_layout(self) -> bool: """Returns True if the current keyboard layout is desktop.""" return self._is_desktop @gsettings_registry.get_registry().gsetting( key="keyboard-layout", schema="keybindings", genum="org.gnome.Orca.KeyboardLayout", default="desktop", summary="Keyboard layout (desktop, laptop)", migration_key="keyboardLayout", ) @dbus_service.getter def get_keyboard_layout_is_desktop(self) -> bool: """Returns True if the current keyboard layout is desktop.""" return self._is_desktop def get_keyboard_layout_value(self) -> int: """Returns the keyboard layout as an integer value for saving.""" if self._is_desktop: return KeyboardLayout.DESKTOP.value return KeyboardLayout.LAPTOP.value @dbus_service.setter def set_keyboard_layout_is_desktop(self, is_desktop: bool) -> bool: """Sets whether the keyboard layout is desktop (True) or laptop (False).""" msg = f"COMMAND MANAGER: Setting keyboard layout is_desktop to {is_desktop}." debug.print_message(debug.LEVEL_INFO, msg, True) layout_changed = self._is_desktop != is_desktop if layout_changed: self._is_desktop = is_desktop has_device = input_event_manager.get_manager().has_device() if has_device: old_bindings = self._get_active_bindings(self._keyboard_commands) old_key_to_cmd = self._get_key_to_cmd_mapping(self._keyboard_commands) if layout_changed: self._apply_layout_to_commands() self.apply_user_overrides() if has_device: self._diff_and_update_grabs( self._keyboard_commands, "keyboard layout change", old_bindings, old_key_to_cmd, ) else: msg = "COMMAND MANAGER: Device not ready, skipping grab updates." debug.print_message(debug.LEVEL_INFO, msg, True) layout = "desktop" if is_desktop else "laptop" msg = f"COMMAND MANAGER: Keyboard layout set to {layout}." debug.print_message(debug.LEVEL_INFO, msg, True) return True def load_keyboard_layout(self, is_desktop: bool | None = None) -> None: """Loads the keyboard layout from dconf or sets it explicitly.""" if is_desktop is None: layout = gsettings_registry.get_registry().layered_lookup( self._SCHEMA, "keyboard-layout", "", genum="org.gnome.Orca.KeyboardLayout", default="desktop", ) is_desktop = layout == "desktop" self.set_keyboard_layout_is_desktop(is_desktop) @gsettings_registry.get_registry().gsetting( key="desktop-modifier-keys", schema="keybindings", gtype="as", default=["Insert", "KP_Insert"], summary="Keys used as the Orca modifier for the desktop layout", ) @dbus_service.getter def get_desktop_modifier_keys(self) -> list[str]: """Returns the per-layout modifier keys for the desktop layout.""" return gsettings_registry.get_registry().layered_lookup( self._SCHEMA, "desktop-modifier-keys", "as", default=["Insert", "KP_Insert"], ) @dbus_service.setter def set_desktop_modifier_keys(self, keys: list[str]) -> bool: """Sets the per-layout modifier keys for the desktop layout.""" gsettings_registry.get_registry().set_runtime_value( self._SCHEMA, "desktop-modifier-keys", keys, ) return True @gsettings_registry.get_registry().gsetting( key="laptop-modifier-keys", schema="keybindings", gtype="as", default=["Caps_Lock", "Shift_Lock"], summary="Keys used as the Orca modifier for the laptop layout", ) @dbus_service.getter def get_laptop_modifier_keys(self) -> list[str]: """Returns the per-layout modifier keys for the laptop layout.""" return gsettings_registry.get_registry().layered_lookup( self._SCHEMA, "laptop-modifier-keys", "as", default=["Caps_Lock", "Shift_Lock"], ) @dbus_service.setter def set_laptop_modifier_keys(self, keys: list[str]) -> bool: """Sets the per-layout modifier keys for the laptop layout.""" gsettings_registry.get_registry().set_runtime_value( self._SCHEMA, "laptop-modifier-keys", keys, ) return True def get_modifier_keys_for_layout(self, is_desktop: bool, app_name: str = "") -> list[str]: """Returns the per-layout modifier keys for the given layout.""" if app_name: key = "desktop-modifier-keys" if is_desktop else "laptop-modifier-keys" if ( keys := gsettings_registry.get_registry().layered_lookup( self._SCHEMA, key, "as", app_name=app_name, ) ) is not None: return keys if is_desktop: return self.get_desktop_modifier_keys() return self.get_laptop_modifier_keys() def check_keyboard_settings(self) -> None: """Checks if keyboard layout or modifier keys changed and updates if needed.""" self.load_keyboard_layout() mod_mgr = orca_modifier_manager.get_manager() if not mod_mgr.needs_modifier_refresh(): return msg = f"COMMAND MANAGER: Modifier keys changing to {mod_mgr.get_orca_modifier_keys()}" debug.print_message(debug.LEVEL_INFO, msg, True) mod_mgr.refresh_orca_modifiers("Keyboard settings changed.") @dbus_service.command def toggle_keyboard_layout( self, script: default.Script | None = None, event: input_event.InputEvent | None = None, notify_user: bool = True, ) -> bool: """Toggles between desktop and laptop keyboard layout.""" tokens = [ "COMMAND MANAGER: toggle_keyboard_layout. Script:", script, "Event:", event, "notify_user:", notify_user, ] debug.print_tokens(debug.LEVEL_INFO, tokens, True) new_is_desktop = not self._is_desktop gsettings_registry.get_registry().set_runtime_value( self._SCHEMA, "keyboard-layout", "desktop" if new_is_desktop else "laptop", ) orca_modifier_manager.get_manager().set_modifiers_for_layout() self.set_keyboard_layout_is_desktop(new_is_desktop) if script is not None and notify_user: if new_is_desktop: presentation_manager.get_manager().present_message(messages.KEYBOARD_LAYOUT_DESKTOP) else: presentation_manager.get_manager().present_message(messages.KEYBOARD_LAYOUT_LAPTOP) return True def set_learn_mode_active(self, active: bool) -> None: """Called by learn_mode_presenter to notify of learn mode state changes.""" self._learn_mode_active = active msg = f"COMMAND MANAGER: Learn mode is now {'active' if active else 'inactive'}." debug.print_message(debug.LEVEL_INFO, msg, True) if not active and self._is_desktop: self._update_numlock_grabs() def handle_numlock_toggled(self, numlock_on: bool) -> None: """Handles NumLock state changes by updating grabs for keypad commands.""" self._numlock_on = numlock_on if not self._is_desktop: return msg = f"COMMAND MANAGER: NumLock toggled to {'on' if numlock_on else 'off'}." debug.print_message(debug.LEVEL_INFO, msg, True) if self._learn_mode_active: msg = "COMMAND MANAGER: Skipping grab updates while in learn mode." debug.print_message(debug.LEVEL_INFO, msg, True) return self._update_numlock_grabs() def _update_numlock_grabs(self) -> None: """Updates KP_* grabs based on current NumLock state.""" msg = f"COMMAND MANAGER: Updating NumLock grabs. NumLock is on: {self._numlock_on}." debug.print_message(debug.LEVEL_INFO, msg, True) orca_modifiers = orca_modifier_manager.get_manager().get_orca_modifier_keys() def update_grabs() -> bool: for cmd in self._keyboard_commands.values(): if not cmd.is_active(): continue kb = cmd.get_keybinding() if kb is None or not kb.keysymstring.startswith("KP_"): continue if self._numlock_on and kb.has_grabs(): kb.remove_grabs() elif not self._numlock_on and not kb.has_grabs(): kb.add_grabs(orca_modifiers) return False GLib.idle_add(update_grabs) def set_up_commands(self) -> None: """Sets up commands owned by CommandManager.""" if self._initialized: return self._initialized = True msg = "COMMAND MANAGER: Setting up commands." debug.print_message(debug.LEVEL_INFO, msg, True) self.add_command( KeyboardCommand( "toggle_keyboard_layout", self.toggle_keyboard_layout, guilabels.KB_GROUP_DEFAULT, cmdnames.TOGGLE_KEYBOARD_LAYOUT, desktop_keybinding=None, laptop_keybinding=None, ), ) def _apply_layout_to_commands(self) -> None: """Updates all keyboard commands' active keybindings based on current layout.""" restored_names: list[str] = [] for cmd in self._keyboard_commands.values(): old_kb = cmd.get_keybinding() default_kb = cmd.get_default_keybinding(self._is_desktop) if old_kb is not default_kb: self._remove_from_key_index(cmd) cmd.set_keybinding(default_kb) self._add_to_key_index(cmd) if old_kb is None and default_kb is not None: restored_names.append(cmd.get_name()) if restored_names: msg = f"COMMAND MANAGER: Restored {len(restored_names)} commands to default bindings:" debug.print_message(debug.LEVEL_INFO, msg, True) for name in restored_names: debug.print_message(debug.LEVEL_INFO, f" {name}", True) def _add_to_key_index(self, cmd: KeyboardCommand) -> None: """Adds a command to the key indexes for fast lookup.""" kb = cmd.get_keybinding() if kb is None: return if not kb.keycode: kb.keyval, kb.keycode = keybindings.get_keycodes(kb.keysymstring) if kb.keyval not in self._commands_by_keyval: self._commands_by_keyval[kb.keyval] = [] self._commands_by_keyval[kb.keyval].append(cmd) if kb.keycode not in self._commands_by_keycode: self._commands_by_keycode[kb.keycode] = [] self._commands_by_keycode[kb.keycode].append(cmd) def _remove_from_key_index(self, cmd: KeyboardCommand) -> None: """Removes a command from the key indexes.""" kb = cmd.get_keybinding() if kb is None: return if kb.keyval in self._commands_by_keyval: with contextlib.suppress(ValueError): self._commands_by_keyval[kb.keyval].remove(cmd) if kb.keycode in self._commands_by_keycode: with contextlib.suppress(ValueError): self._commands_by_keycode[kb.keycode].remove(cmd) def add_command(self, command: Command) -> None: """Adds a command to the registry and sets its active keybinding.""" if isinstance(command, KeyboardCommand): name = command.get_name() old_cmd = self._keyboard_commands.get(name) if old_cmd is not None: tokens = ["COMMAND MANAGER: Unexpected re-registration of", command] debug.print_tokens(debug.LEVEL_WARNING, tokens, True) self._remove_from_key_index(old_cmd) self._keyboard_commands[name] = command command.set_keybinding(command.get_default_keybinding(self._is_desktop)) self._add_to_key_index(command) elif isinstance(command, BrailleCommand): self._braille_commands[command.get_name()] = command def get_command(self, command_name: str) -> Command | None: """Returns the command with the specified name, or None.""" if command_name in self._keyboard_commands: return self._keyboard_commands[command_name] return self._braille_commands.get(command_name) def get_keyboard_command(self, command_name: str) -> KeyboardCommand | None: """Returns the keyboard command with the specified name, or None.""" return self._keyboard_commands.get(command_name) def get_all_keyboard_commands(self) -> tuple[KeyboardCommand, ...]: """Returns all registered keyboard commands.""" return tuple(self._keyboard_commands.values()) def get_all_braille_commands(self) -> tuple[BrailleCommand, ...]: """Returns all registered braille commands.""" return tuple(self._braille_commands.values()) def _get_keyboard_commands_by_group_label( self, group_label: str, ) -> tuple[KeyboardCommand, ...]: """Returns all keyboard commands with the specified group label.""" return tuple( cmd for cmd in self._keyboard_commands.values() if cmd.get_group_label() == group_label ) # pylint: disable-next=too-many-locals def apply_user_overrides(self) -> None: """Applies user-customized keybindings from settings to Commands.""" # First, reset all keybindings to their layout defaults. # This ensures that app-specific unbindings from a previous app # don't persist when switching to a different app. self._apply_layout_to_commands() keybindings_dict = gsettings_registry.get_registry().layered_lookup( "keybindings", "entries", "a{saas}", default={}, ) if keybindings_dict: msg = f"COMMAND MANAGER: Applying {len(keybindings_dict)} user overrides" debug.print_message(debug.LEVEL_INFO, msg, True) else: msg = "COMMAND MANAGER: No user overrides to apply" debug.print_message(debug.LEVEL_INFO, msg, True) for command_name, binding_tuples in keybindings_dict.items(): cmd = self.get_keyboard_command(command_name) if cmd is None: msg = f"COMMAND MANAGER: Override for unknown command '{command_name}'" debug.print_message(debug.LEVEL_INFO, msg, True) continue old_kb = cmd.get_keybinding() # Empty list means the user explicitly unbound this command if binding_tuples == []: if old_kb is not None: tokens = ["COMMAND MANAGER: Unbinding", command_name, "(user override)"] debug.print_tokens(debug.LEVEL_INFO, tokens, True) self._remove_from_key_index(cmd) cmd.set_keybinding(None) continue # Apply the customized binding for binding_tuple in binding_tuples: keysym, _mask, mods, clicks = binding_tuple if not keysym: if old_kb is not None: tokens = ["COMMAND MANAGER: Unbinding", command_name, "(empty keysym)"] debug.print_tokens(debug.LEVEL_INFO, tokens, True) self._remove_from_key_index(cmd) cmd.set_keybinding(None) else: # Check if the binding actually changed old_key = self._binding_key(old_kb) if old_kb else None new_key = (keysym, int(mods), int(clicks)) if old_key == new_key: # Binding unchanged, skip to preserve grabs continue msg = ( f"COMMAND MANAGER: Applying override for '{command_name}': " f"{old_key} -> {new_key}" ) debug.print_message(debug.LEVEL_INFO, msg, True) self._remove_from_key_index(cmd) kb = keybindings.KeyBinding(keysym, int(mods), click_count=int(clicks)) cmd.set_keybinding(kb) self._add_to_key_index(cmd) def get_command_for_event( self, event: input_event.KeyboardEvent, active_only: bool = True, ) -> KeyboardCommand | None: """Returns the keyboard command matching the keyboard event, or None.""" click_count = event.get_click_count() # Check both keyval and keycode indexes since Shift changes the keyval # (e.g., 'h' vs 'H') but not the keycode. candidates = set(self._commands_by_keyval.get(event.id, [])) candidates.update(self._commands_by_keycode.get(event.hw_code, [])) for cmd in candidates: if active_only and not cmd.is_active(): continue kb = cmd.get_keybinding() if kb is None: continue if not kb.matches(event.id, event.hw_code, event.modifiers): continue if event.is_keypad_key_with_numlock_on() and kb.keysymstring.startswith("KP_"): continue if kb.click_count == click_count: return cmd return None def get_command_for_braille_event(self, braille_key: int) -> BrailleCommand | None: """Returns the braille command matching the braille key code, or None.""" for cmd in self._braille_commands.values(): if braille_key in cmd.get_braille_bindings(): return cmd return None def get_command_for_keybinding( self, keysymstring: str, modifiers: int, click_count: int, ) -> KeyboardCommand | None: """Returns the keyboard command matching the keybinding properties, or None.""" for cmd in self._keyboard_commands.values(): kb = cmd.get_keybinding() if kb is None: continue if ( kb.keysymstring == keysymstring and kb.modifiers == modifiers and kb.click_count == click_count ): return cmd return None def has_multi_click_bindings(self, keyval: int, keycode: int, modifiers: int) -> bool: """Returns True if there are any bindings for this key with click_count > 1.""" # Check both keyval and keycode indexes since Shift changes the keyval. candidates = set(self._commands_by_keyval.get(keyval, [])) candidates.update(self._commands_by_keycode.get(keycode, [])) for cmd in candidates: kb = cmd.get_keybinding() if kb is None: continue if kb.matches(keyval, keycode, modifiers) and kb.click_count > 1: return True return False def is_group_enabled(self, group_label: str) -> bool: """Returns the enabled state of the specified command group.""" stored = self._group_enabled.get(group_label) if stored is not None: return stored for cmd in self._get_keyboard_commands_by_group_label(group_label): if not cmd.is_group_toggle(): return cmd.is_enabled() return False def set_group_enabled(self, group_label: str, enabled: bool) -> None: """Sets the enabled state for all commands in a group.""" self._group_enabled[group_label] = enabled orca_modifiers = orca_modifier_manager.get_manager().get_orca_modifier_keys() added_count = 0 removed_count = 0 for cmd in self._get_keyboard_commands_by_group_label(group_label): # Group toggle commands are skipped since they must remain active to re-enable # the group. if cmd.is_group_toggle(): continue was_active = cmd.is_active() cmd.set_enabled(enabled) is_active = cmd.is_active() kb = cmd.get_keybinding() if kb is None: continue if was_active and not is_active and kb.has_grabs(): kb.remove_grabs() removed_count += 1 elif not was_active and is_active and not kb.has_grabs(): kb.add_grabs(orca_modifiers) added_count += 1 if removed_count or added_count: msg = ( f"COMMAND MANAGER: set_group_enabled({group_label}, {enabled}): " f"removed {removed_count}, added {added_count}" ) debug.print_message(debug.LEVEL_INFO, msg, True) def set_group_suspended(self, group_label: str, suspended: bool) -> None: """Sets the suspended state for all commands in a group.""" orca_modifiers = orca_modifier_manager.get_manager().get_orca_modifier_keys() added_count = 0 removed_count = 0 for cmd in self._get_keyboard_commands_by_group_label(group_label): was_active = cmd.is_active() cmd.set_suspended(suspended) is_active = cmd.is_active() kb = cmd.get_keybinding() if kb is None: continue if was_active and not is_active and kb.has_grabs(): kb.remove_grabs() removed_count += 1 elif not was_active and is_active and not kb.has_grabs(): kb.add_grabs(orca_modifiers) added_count += 1 if removed_count or added_count: msg = ( f"COMMAND MANAGER: set_group_suspended({group_label}, {suspended}): " f"removed {removed_count}, added {added_count}" ) debug.print_message(debug.LEVEL_INFO, msg, True) def set_all_suspended(self, suspended: bool, exceptions: frozenset[str] | None = None) -> None: """Sets the suspended state for all commands, optionally excluding exceptions.""" orca_modifiers = orca_modifier_manager.get_manager().get_orca_modifier_keys() added_count = 0 removed_count = 0 for cmd in self._keyboard_commands.values(): if exceptions and cmd.get_name() in exceptions: continue was_active = cmd.is_active() cmd.set_suspended(suspended) is_active = cmd.is_active() kb = cmd.get_keybinding() if kb is None: continue if was_active and not is_active and kb.has_grabs(): kb.remove_grabs() removed_count += 1 elif not was_active and is_active and not kb.has_grabs(): kb.add_grabs(orca_modifiers) added_count += 1 if removed_count or added_count: msg = ( f"COMMAND MANAGER: set_all_suspended({suspended}): " f"removed {removed_count}, added {added_count}" ) debug.print_message(debug.LEVEL_INFO, msg, True) @staticmethod def _binding_key(kb: keybindings.KeyBinding | None) -> tuple[str, int, int] | None: """Returns a hashable key for a keybinding, or None if no binding.""" if kb is None or not kb.keysymstring: return None return (kb.keysymstring, kb.modifiers, kb.click_count) def _format_binding_key(self, key: tuple[str, int, int]) -> str: """Formats a binding key tuple as a readable string.""" keysym, mods, clicks = key mod_str = keybindings.get_modifier_names(mods) if mods else "" click_str = f" x{clicks}" if clicks > 1 else "" return f"{mod_str}{keysym}{click_str}" # pylint: disable-next=too-many-arguments,too-many-positional-arguments,too-many-branches,too-many-locals def _diff_bindings( self, old_bindings: dict[tuple[str, int, int], keybindings.KeyBinding], new_bindings: dict[tuple[str, int, int], keybindings.KeyBinding], old_key_to_cmd: dict[tuple[str, int, int], str], new_key_to_cmd: dict[tuple[str, int, int], str], reason: str = "", ) -> None: """Updates grabs based on diff between old and new binding maps.""" orca_modifiers = orca_modifier_manager.get_manager().get_orca_modifier_keys() removed: list[tuple[str, int, int]] = [] added: list[tuple[str, int, int]] = [] transferred: list[tuple[str, int, int]] = [] start_time = time.time() for key, old_kb in old_bindings.items(): if key in new_bindings: new_kb = new_bindings[key] if old_kb is not new_kb and old_kb.has_grabs(): new_kb.set_grab_ids(old_kb.get_grab_ids()) old_kb.set_grab_ids([]) transferred.append(key) elif old_kb.has_grabs(): old_kb.remove_grabs() removed.append(key) transferred_keys = set(transferred) for key, new_kb in new_bindings.items(): if key not in transferred_keys and not new_kb.has_grabs(): if self._is_desktop and self._numlock_on and key[0].startswith("KP_"): continue new_kb.add_grabs(orca_modifiers) if new_kb.has_grabs(): added.append(key) msg = f"COMMAND MANAGER: Grab diff: {reason}" msg += f" (old: {len(old_bindings)}, new: {len(new_bindings)})" debug.print_message(debug.LEVEL_INFO, f"\nvvvvv {msg} vvvvv", False) if not removed and not added and not transferred: debug.print_message(debug.LEVEL_INFO, " No grab changes", True) else: if removed: debug.print_message(debug.LEVEL_INFO, f" Removed ({len(removed)}):", True) for key in removed: binding_str = self._format_binding_key(key) cmd_name = old_key_to_cmd.get(key, "unknown") debug.print_message(debug.LEVEL_INFO, f" {binding_str}: {cmd_name}", True) if added: debug.print_message(debug.LEVEL_INFO, f" Added ({len(added)}):", True) for key in added: binding_str = self._format_binding_key(key) cmd_name = new_key_to_cmd.get(key, "unknown") msg = f" {binding_str}: {cmd_name}" debug.print_message(debug.LEVEL_INFO, msg, True) if transferred: debug.print_message(debug.LEVEL_INFO, f" Transferred ({len(transferred)}):", True) for key in transferred: binding_str = self._format_binding_key(key) cmd_name = new_key_to_cmd.get(key, "unknown") debug.print_message(debug.LEVEL_INFO, f" {binding_str}: {cmd_name}", True) msg = ( f"^^^^^ COMMAND MANAGER: Diff completed in {time.time() - start_time:.4f}s. " f"Removed {len(removed)}, added {len(added)}, transferred {len(transferred)} ^^^^^\n" ) debug.print_message(debug.LEVEL_INFO, msg, False) def _get_active_bindings( self, commands: dict[str, KeyboardCommand], ) -> dict[tuple[str, int, int], keybindings.KeyBinding]: """Returns a map of binding keys to KeyBinding objects for active commands.""" bindings: dict[tuple[str, int, int], keybindings.KeyBinding] = {} for cmd in commands.values(): if cmd.is_active(): kb = cmd.get_keybinding() key = self._binding_key(kb) if key is not None and kb is not None: bindings[key] = kb return bindings def _get_key_to_cmd_mapping( self, commands: dict[str, KeyboardCommand], ) -> dict[tuple[str, int, int], str]: """Returns a map of binding keys to command names.""" key_to_cmd: dict[tuple[str, int, int], str] = {} for cmd in commands.values(): kb = cmd.get_keybinding() key = self._binding_key(kb) if key is not None: key_to_cmd[key] = cmd.get_name() return key_to_cmd def _diff_and_update_grabs( self, new_commands: dict[str, KeyboardCommand], reason: str = "", old_bindings: dict[tuple[str, int, int], keybindings.KeyBinding] | None = None, old_key_to_cmd: dict[tuple[str, int, int], str] | None = None, ) -> None: """Updates grabs by diffing old vs new bindings; computes old state if not provided.""" if old_bindings is None: old_bindings = self._get_active_bindings(self._keyboard_commands) if old_key_to_cmd is None: old_key_to_cmd = self._get_key_to_cmd_mapping(self._keyboard_commands) new_bindings = self._get_active_bindings(new_commands) new_key_to_cmd = self._get_key_to_cmd_mapping(new_commands) self._diff_bindings(old_bindings, new_bindings, old_key_to_cmd, new_key_to_cmd, reason) def set_active_commands(self, commands: dict[str, KeyboardCommand], reason: str = "") -> None: """Sets the active commands.""" msg = "COMMAND MANAGER: Setting active commands" if reason: msg += f": {reason}" debug.print_message(debug.LEVEL_INFO, msg, True) old_bindings = self._get_active_bindings(self._keyboard_commands) old_key_to_cmd = self._get_key_to_cmd_mapping(self._keyboard_commands) self._keyboard_commands = commands self._commands_by_keyval.clear() self._commands_by_keycode.clear() for cmd in commands.values(): self._add_to_key_index(cmd) self._diff_and_update_grabs(commands, reason, old_bindings, old_key_to_cmd) def activate_commands(self, reason: str = "") -> None: """Applies user overrides and updates grabs for the active script.""" msg = "COMMAND MANAGER: Activating commands" if reason: msg += f": {reason}" debug.print_message(debug.LEVEL_INFO, msg, True) old_bindings = self._get_active_bindings(self._keyboard_commands) old_key_to_cmd = self._get_key_to_cmd_mapping(self._keyboard_commands) self.apply_user_overrides() self._diff_and_update_grabs(self._keyboard_commands, reason, old_bindings, old_key_to_cmd) def get_keyboard_commands(self) -> dict[str, KeyboardCommand]: """Returns the current keyboard commands dict.""" return self._keyboard_commands @gsettings_registry.get_registry().gsetting( key="entries", schema="keybindings", gtype="a{saas}", default={}, summary="User keybinding overrides", ) def get_keybinding_overrides(self) -> dict: """Returns the user's keybinding overrides.""" return gsettings_registry.get_registry().layered_lookup( "keybindings", "entries", "a{saas}", default={}, ) # pylint: disable-next=too-many-arguments, too-many-positional-arguments def register_command( self, name: str, function: Callable[..., bool], description: str = "", key: str = "", modifiers: int = 0, click_count: int = 1, group_label: str = "", ) -> KeyboardCommand: """Convenience method to create and register a command with optional key binding.""" kb = None if key: kb = keybindings.KeyBinding(key, modifiers, click_count=click_count) cmd = KeyboardCommand( name, function, group_label, description, desktop_keybinding=kb, laptop_keybinding=kb, ) self.add_command(cmd) return cmd def create_preferences_grid( self, script: default.Script, title_change_callback: Callable[[str], None] | None = None, ) -> KeybindingsPreferencesGrid: """Returns the GtkGrid containing the keybindings preferences UI.""" return KeybindingsPreferencesGrid(script, title_change_callback) _manager: CommandManager = CommandManager() def get_manager() -> CommandManager: """Returns the CommandManager singleton.""" return _manager