diff --git a/src/cthulhu/command_manager.py b/src/cthulhu/command_manager.py new file mode 100644 index 0000000..fa0d877 --- /dev/null +++ b/src/cthulhu/command_manager.py @@ -0,0 +1,2141 @@ +# Cthulhu +# +# 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, + cthulhu_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 Cthulhu 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 Cthulhu 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._cthulhu_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._cthulhu_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], + ) + + cthulhu_model = Gtk.ListStore(str) + cthulhu_model.append(["Insert, KP_Insert"]) + cthulhu_model.append(["KP_Insert"]) + cthulhu_model.append(["Insert"]) + cthulhu_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, + cthulhu_model, + self._on_cthulhu_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._cthulhu_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.stormux.Cthulhu.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() + get_manager().apply_user_overrides() + self._populate_keybindings() + self._modified_keybindings.clear() + self._has_unsaved_changes = False + self.refresh() + + def revert_changes(self) -> None: + """Revert keyboard layout and cthulhu 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) + + cthulhu_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._cthulhu_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) + cthulhu_mods = cthulhu_modifier_manager.get_manager().get_cthulhu_modifier_keys() + if event_string in cthulhu_mods: + self._cthulhu_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._cthulhu_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._cthulhu_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() + cthulhu_modifier_manager.get_manager().remove_grabs_for_cthulhu_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") + cthulhu_modifier_manager.get_manager().add_grabs_for_cthulhu_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 + + cthulhu_mods = cthulhu_modifier_manager.get_manager().get_cthulhu_modifier_keys() + if event_string in cthulhu_mods: + self._cthulhu_modifier_pressed_during_capture = True + self._captured_key = ("", keybindings.CTHULHU_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._cthulhu_modifier_pressed_during_capture: + return False + + if not self._captured_key[0]: + # Preserve Cthulhu modifier if it was already captured. + if self._captured_key[1] & keybindings.CTHULHU_MODIFIER_MASK: + event_state |= keybindings.CTHULHU_MODIFIER_MASK + self._captured_key = (event_string, event_state, 1) + return True + + string, modifiers, click_count = self._captured_key + + # Preserve Cthulhu modifier from previous key if present before comparing + if modifiers & keybindings.CTHULHU_MODIFIER_MASK: + event_state |= keybindings.CTHULHU_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() + cthulhu_modifier_manager.get_manager().remove_grabs_for_cthulhu_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") + cthulhu_modifier_manager.get_manager().add_grabs_for_cthulhu_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._cthulhu_modifier_combo is not None: + tree_iter = self._cthulhu_modifier_combo.get_active_iter() + if tree_iter is not None: + model = self._cthulhu_modifier_combo.get_model() + cthulhu_modifier = model.get_value(tree_iter, 0) + modifier_keys = cthulhu_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 cthulhu 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._cthulhu_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) + cthulhu_model = self._cthulhu_modifier_combo.get_model() + if cthulhu_model: + for i, row in enumerate(cthulhu_model): + if row[0] == key_string: + self._cthulhu_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._cthulhu_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) + cthulhu_model = self._cthulhu_modifier_combo.get_model() + matched = False + for i, row in enumerate(cthulhu_model): + if row[0] == key_string: + self._cthulhu_modifier_combo.set_active(i) + matched = True + break + + if not matched: + if is_desktop: + self._cthulhu_modifier_combo.set_active(0) + else: + self._cthulhu_modifier_combo.set_active(3) + saved_keys = cthulhu_model[self._cthulhu_modifier_combo.get_active()][0].split(", ") + + cthulhu_modifier_manager.get_manager().set_modifier_keys_override(saved_keys) + + get_manager().apply_user_overrides() + self._populate_keybindings() + self._has_unsaved_changes = True + + def _on_cthulhu_modifier_changed(self, combo: Gtk.ComboBox) -> None: + """Handle cthulhu modifier combo box changes.""" + + if self._initializing: + return + + tree_iter = combo.get_active_iter() + if tree_iter is None: + return + + model = combo.get_model() + cthulhu_modifier = model.get_value(tree_iter, 0) + cthulhu_modifier_manager.get_manager().set_modifier_keys_override( + cthulhu_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.stormux.Cthulhu.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.stormux.Cthulhu.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.stormux.Cthulhu.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.stormux.Cthulhu.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 Cthulhu 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 Cthulhu 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 = cthulhu_modifier_manager.get_manager() + if not mod_mgr.needs_modifier_refresh(): + return + + msg = f"COMMAND MANAGER: Modifier keys changing to {mod_mgr.get_cthulhu_modifier_keys()}" + debug.print_message(debug.LEVEL_INFO, msg, True) + mod_mgr.refresh_cthulhu_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", + ) + cthulhu_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) + + cthulhu_modifiers = cthulhu_modifier_manager.get_manager().get_cthulhu_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(cthulhu_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 + + cthulhu_modifiers = cthulhu_modifier_manager.get_manager().get_cthulhu_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(cthulhu_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.""" + + cthulhu_modifiers = cthulhu_modifier_manager.get_manager().get_cthulhu_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(cthulhu_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.""" + + cthulhu_modifiers = cthulhu_modifier_manager.get_manager().get_cthulhu_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(cthulhu_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.""" + + cthulhu_modifiers = cthulhu_modifier_manager.get_manager().get_cthulhu_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(cthulhu_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 diff --git a/src/cthulhu/cthulhu_modifier_manager.py b/src/cthulhu/cthulhu_modifier_manager.py index 86ea50f..3ec50a0 100644 --- a/src/cthulhu/cthulhu_modifier_manager.py +++ b/src/cthulhu/cthulhu_modifier_manager.py @@ -1,7 +1,7 @@ -#!/usr/bin/env python3 +# Cthulhu # -# Copyright (c) 2026 Stormux -# Copyright (c) 2023 Igalia, S.L. +# Copyright 2023 Igalia, S.L. +# Copyright 2023 GNOME Foundation Inc. # Author: Joanmarie Diggs # # This library is free software; you can redistribute it and/or @@ -18,212 +18,449 @@ # License along with this library; if not, write to the # Free Software Foundation, Inc., Franklin Street, Fifth Floor, # Boston MA 02110-1301 USA. -# -# Forked from Orca screen reader. -# Cthulhu project: https://git.stormux.org/storm/cthulhu -"""Manages the Cthulhu modifier key(s).""" +# pylint: disable=too-many-locals +# pylint: disable=too-many-arguments +# pylint: disable=too-many-positional-arguments +# pylint: disable=too-many-instance-attributes -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2026 Stormux" -__license__ = "LGPL" +"""Manages the Cthulhu modifier key.""" + +from __future__ import annotations import os import re import subprocess +from typing import TYPE_CHECKING import gi -gi.require_version("Atspi", "2.0") -from gi.repository import Atspi -from gi.repository import GLib -import cthulhu.debug as debug -import cthulhu.settings as settings +gi.require_version("Atspi", "2.0") +gi.require_version("Gdk", "3.0") +from gi.repository import ( + Atspi, + Gdk, # pylint: disable=no-name-in-module + GLib, +) + +from . import debug, gsettings_registry, input_event_manager, keybindings + +DESKTOP_MODIFIER_KEYS: list[str] = ["Insert", "KP_Insert"] +LAPTOP_MODIFIER_KEYS: list[str] = ["Caps_Lock", "Shift_Lock"] +_SCHEMA = "keybindings" + +if TYPE_CHECKING: + from .input_event import KeyboardEvent + class CthulhuModifierManager: """Manages the Cthulhu modifier.""" - def __init__(self): - self._originalXmodmap = b"" - self._capsLockCleared = False + def __init__(self) -> None: + self._modifier_keys_override: list[str] | None = None + self._applied_modifier_keys: list[str] = [] + self._grabbed_modifiers: dict = {} + self._is_pressed: bool = False + self._modifiers_are_set: bool = False - def refreshCthulhuModifiers(self, reason=""): - """Refreshes the Cthulhu modifier keys.""" + # Related to hacks which will soon die. + self._original_xmodmap: bytes = b"" + self._caps_lock_cleared: bool = False + self._need_to_restore_cthulhu_modifier: bool = False - msg = "CTHULHU MODIFIER MANAGER: Refreshing Cthulhu modifiers" - if reason: - msg += f": {reason}" - debug.printMessage(debug.LEVEL_INFO, msg, True) - - display = os.environ.get("DISPLAY") - if not display: - msg = "CTHULHU MODIFIER MANAGER: DISPLAY not set, skipping xkbcomp operations" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - self.unsetCthulhuModifiers(reason) - with subprocess.Popen(["xkbcomp", display, "-"], - stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) as process: - self._originalXmodmap, _ = process.communicate() - self._createCthulhuXmodmap() - - def unsetCthulhuModifiers(self, reason=""): - """Turns the Cthulhu modifiers back into their original purpose.""" - - msg = "CTHULHU MODIFIER MANAGER: Attempting to restore original xmodmap" - if reason: - msg += f": {reason}" - debug.printMessage(debug.LEVEL_INFO, msg, True) - - if not self._originalXmodmap: - msg = "CTHULHU MODIFIER MANAGER: No stored xmodmap found" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - display = os.environ.get("DISPLAY") - if not display: - msg = "CTHULHU MODIFIER MANAGER: DISPLAY not set, skipping xmodmap restoration" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - self._capsLockCleared = False - with subprocess.Popen(["xkbcomp", "-w0", "-", display], - stdin=subprocess.PIPE, stdout=None, stderr=None) as process: - process.communicate(self._originalXmodmap) - - msg = "CTHULHU MODIFIER MANAGER: Original xmodmap restored" - debug.printMessage(debug.LEVEL_INFO, msg, True) - - def _createCthulhuXmodmap(self): - """Makes a Cthulhu-specific Xmodmap so that the modifier works.""" - - msg = "CTHULHU MODIFIER MANAGER: Creating Cthulhu xmodmap" - debug.printMessage(debug.LEVEL_INFO, msg, True) - - if "Caps_Lock" in settings.cthulhuModifierKeys \ - or "Shift_Lock" in settings.cthulhuModifierKeys: - self.setCapsLockAsCthulhuModifier(True) - self._capsLockCleared = True - elif self._capsLockCleared: - self.setCapsLockAsCthulhuModifier(False) - self._capsLockCleared = False - - def setCapsLockAsCthulhuModifier(self, enable): - """Enable or disable use of the caps lock key as a Cthulhu modifier key.""" - - msg = "CTHULHU MODIFIER MANAGER: Setting caps lock as the Cthulhu modifier" - debug.printMessage(debug.LEVEL_INFO, msg, True) - - display = os.environ.get("DISPLAY") - if not display: - msg = "CTHULHU MODIFIER MANAGER: DISPLAY not set, cannot modify caps lock" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - if not self._originalXmodmap: - msg = "CTHULHU MODIFIER MANAGER: No xmodmap available, cannot modify caps lock" - debug.printMessage(debug.LEVEL_INFO, msg, True) - return - - interpretCapsLineProg = re.compile( - r'^\s*interpret\s+Caps[_+]Lock[_+]AnyOfOrNone\s*\(all\)\s*{\s*$', re.I) - normalCapsLineProg = re.compile( - r'^\s*action\s*=\s*LockMods\s*\(\s*modifiers\s*=\s*Lock\s*\)\s*;\s*$', re.I) - interpretShiftLineProg = re.compile( - r'^\s*interpret\s+Shift[_+]Lock[_+]AnyOf\s*\(\s*Shift\s*\+\s*Lock\s*\)\s*{\s*$', re.I) - normalShiftLineProg = re.compile( - r'^\s*action\s*=\s*LockMods\s*\(\s*modifiers\s*=\s*Shift\s*\)\s*;\s*$', re.I) - disabledModLineProg = re.compile( - r'^\s*action\s*=\s*NoAction\s*\(\s*\)\s*;\s*$', re.I) - normalCapsLine = ' action= LockMods(modifiers=Lock);' - normalShiftLine = ' action= LockMods(modifiers=Shift);' - disabledModLine = ' action= NoAction();' - lines = self._originalXmodmap.decode('UTF-8').split('\n') - foundCapsInterpretSection = False - foundShiftInterpretSection = False - modified = False - for i, line in enumerate(lines): - if not foundCapsInterpretSection and not foundShiftInterpretSection: - if interpretCapsLineProg.match(line): - foundCapsInterpretSection = True - elif interpretShiftLineProg.match(line): - foundShiftInterpretSection = True - elif foundCapsInterpretSection: - if enable: - if normalCapsLineProg.match(line): - lines[i] = disabledModLine - modified = True - else: - if disabledModLineProg.match(line): - lines[i] = normalCapsLine - modified = True - if line.find('}'): - foundCapsInterpretSection = False - elif foundShiftInterpretSection: - if enable: - if normalShiftLineProg.match(line): - lines[i] = disabledModLine - modified = True - else: - if disabledModLineProg.match(line): - lines[i] = normalShiftLine - modified = True - if line.find('}'): - foundShiftInterpretSection = False - if modified: - msg = "CTHULHU MODIFIER MANAGER: Updating xmodmap" - debug.printMessage(debug.LEVEL_INFO, msg, True) - - with subprocess.Popen(["xkbcomp", "-w0", "-", display], - stdin=subprocess.PIPE, stdout=None, stderr=None) as process: - process.communicate(bytes('\n'.join(lines), 'UTF-8')) + # Event handlers for input devices being plugged in/unplugged. + display = Gdk.Display.get_default() # pylint: disable=no-value-for-parameter + if display is not None: + device_manager = display.get_device_manager() + device_manager.connect("device-added", self._on_device_changed) + device_manager.connect("device-removed", self._on_device_changed) else: - msg = "CTHULHU MODIFIER MANAGER: Not updating xmodmap" - debug.printMessage(debug.LEVEL_INFO, msg, True) + msg = "CTHULHU MODIFIER MANAGER: Cannot listen for input device changes." + debug.print_message(debug.LEVEL_INFO, msg, True) - def toggleModifier(self, keyboardEvent): + def _on_device_changed(self, _device_manager, device: Gdk.Device) -> None: + """Handles device-* signals.""" + + source = device.get_source() + tokens = ["CTHULHU MODIFIER MANAGER: Device changed", source] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + if source == Gdk.InputSource.KEYBOARD: + self.refresh_cthulhu_modifiers("Keyboard change detected.") + + def is_cthulhu_modifier(self, modifier: str) -> bool: + """Returns True if modifier is one of the user's Cthulhu modifier keys.""" + + if modifier not in self.get_cthulhu_modifier_keys(): + return False + + if modifier in ["Insert", "KP_Insert"]: + return self.is_modifier_grabbed(modifier) + + return self._modifiers_are_set + + def get_pressed_state(self) -> bool: + """Returns True if the Cthulhu modifier has been pressed but not yet released.""" + + return self._is_pressed + + def set_pressed_state(self, is_pressed: bool) -> None: + """Updates the pressed state of the modifier based on event.""" + + msg = f"CTHULHU MODIFIER MANAGER: Setting pressed state to {is_pressed}" + debug.print_message(debug.LEVEL_INFO, msg, True) + self._is_pressed = is_pressed + + def is_modifier_grabbed(self, modifier: str) -> bool: + """Returns True if there is an existing grab for modifier.""" + + return modifier in self._grabbed_modifiers + + def add_grabs_for_cthulhu_modifiers(self) -> None: + """Adds grabs for all of the user's Cthulhu modifier keys.""" + + for modifier in self.get_cthulhu_modifier_keys(): + if modifier in ["Insert", "KP_Insert"]: + self.add_modifier_grab(modifier) + + def remove_grabs_for_cthulhu_modifiers(self) -> None: + """Removes grabs for all of the user's Cthulhu modifier keys.""" + + for modifier in self.get_cthulhu_modifier_keys(): + if modifier in ["Insert", "KP_Insert"]: + self.remove_modifier_grab(modifier) + + msg = "CTHULHU MODIFIER MANAGER: Setting pressed state to False for grab removal" + debug.print_message(debug.LEVEL_INFO, msg, True) + self._is_pressed = False + + def add_modifier_grab(self, modifier: str) -> None: + """Adds a grab for modifier.""" + + if modifier in self._grabbed_modifiers: + return + + keyval, keycode = keybindings.get_keycodes(modifier) + grab_id = input_event_manager.get_manager().add_grab_for_modifier(modifier, keyval, keycode) + if grab_id != -1: + self._grabbed_modifiers[modifier] = grab_id + + def remove_modifier_grab(self, modifier: str) -> None: + """Removes the grab for modifier.""" + + grab_id = self._grabbed_modifiers.get(modifier) + if grab_id is None: + return + + input_event_manager.get_manager().remove_grab_for_modifier(modifier, grab_id) + del self._grabbed_modifiers[modifier] + + def toggle_modifier(self, keyboard_event: KeyboardEvent) -> None: """Toggles the modifier to enable double-clicking causing normal behavior.""" - if keyboardEvent.keyval_name in ["Caps_Lock", "Shift_Lock"]: - self._toggleModifierLock(keyboardEvent) + if keyboard_event.keyval_name in ["Caps_Lock", "Shift_Lock"]: + self._toggle_modifier_lock(keyboard_event) return - def _toggleModifierLock(self, keyboardEvent): + self._toggle_modifier_grab(keyboard_event) + + def _toggle_modifier_grab(self, keyboard_event: KeyboardEvent) -> None: + """Toggles the grab for a modifier to enable double-clicking causing normal behavior.""" + + # Because we will synthesize another press and release, wait until the real release. + if keyboard_event.is_pressed_key(): + return + + def toggle(hw_code): + Atspi.generate_keyboard_event(hw_code, "", Atspi.KeySynthType.PRESSRELEASE) + return False + + def restore_grab(modifier): + self.add_modifier_grab(modifier) + return False + + msg = "CTHULHU MODIFIER MANAGER: Removing grab pre-toggle" + debug.print_message(debug.LEVEL_INFO, msg, True) + self.remove_modifier_grab(keyboard_event.keyval_name) + + msg = f"CTHULHU MODIFIER MANAGER: Scheduling toggle of {keyboard_event.keyval_name}" + debug.print_message(debug.LEVEL_INFO, msg, True) + GLib.timeout_add(1, toggle, keyboard_event.hw_code) + + msg = "CTHULHU MODIFIER MANAGER: Scheduling re-adding grab post-toggle" + debug.print_message(debug.LEVEL_INFO, msg, True) + GLib.timeout_add(500, restore_grab, keyboard_event.keyval_name) + + def _toggle_modifier_lock(self, keyboard_event: KeyboardEvent) -> None: """Toggles the lock for a modifier to enable double-clicking causing normal behavior.""" - if not keyboardEvent.is_pressed_key(): + if not keyboard_event.is_pressed_key(): return def toggle(modifiers, modifier): if modifiers & modifier: lock = Atspi.KeySynthType.UNLOCKMODIFIERS msg = "CTHULHU MODIFIER MANAGER: Unlocking CapsLock" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) else: lock = Atspi.KeySynthType.LOCKMODIFIERS msg = "CTHULHU MODIFIER MANAGER: Locking CapsLock" - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) Atspi.generate_keyboard_event(modifier, "", lock) - if keyboardEvent.keyval_name == "Caps_Lock": + if keyboard_event.keyval_name == "Caps_Lock": modifier = 1 << Atspi.ModifierType.SHIFTLOCK - elif keyboardEvent.keyval_name == "Shift_Lock": + elif keyboard_event.keyval_name == "Shift_Lock": modifier = 1 << Atspi.ModifierType.SHIFT else: return msg = "CTHULHU MODIFIER MANAGER: Scheduling lock change" - debug.printMessage(debug.LEVEL_INFO, msg, True) - GLib.timeout_add(1, toggle, keyboardEvent.modifiers, modifier) + debug.print_message(debug.LEVEL_INFO, msg, True) + GLib.timeout_add(1, toggle, keyboard_event.modifiers, modifier) -_manager = None -def getManager(): - """Returns the Cthulhu Modifier Manager""" + def get_cthulhu_modifier_keys(self) -> list[str]: + """Returns the active Cthulhu modifier keys via override or layered lookup.""" + + if self._modifier_keys_override is not None: + return self._modifier_keys_override + return self._lookup_modifier_keys() + + def _lookup_modifier_keys(self) -> list[str]: + """Returns modifier keys via two-part layered lookup: layout then per-layout keys.""" + + registry = gsettings_registry.get_registry() + layout = registry.layered_lookup( + _SCHEMA, + "keyboard-layout", + "", + genum="org.stormux.Cthulhu.KeyboardLayout", + default="desktop", + ) + if layout == "desktop": + return registry.layered_lookup( + _SCHEMA, + "desktop-modifier-keys", + "as", + default=DESKTOP_MODIFIER_KEYS, + ) + return registry.layered_lookup( + _SCHEMA, + "laptop-modifier-keys", + "as", + default=LAPTOP_MODIFIER_KEYS, + ) + + def set_modifier_keys_override(self, keys: list[str] | None) -> None: + """Sets or clears a temporary override for the modifier keys.""" + + self._modifier_keys_override = keys + + def needs_modifier_refresh(self) -> bool: + """Returns True if the current modifier keys differ from what was last applied.""" + + return self.get_cthulhu_modifier_keys() != self._applied_modifier_keys + + def set_modifiers_for_layout(self) -> None: + """Unsets and refreshes modifier keys for the current layout.""" + + self.unset_cthulhu_modifiers("Keyboard layout changing.") + self.refresh_cthulhu_modifiers("Keyboard layout changed.") + + def refresh_cthulhu_modifiers(self, reason: str = "") -> None: + """Refreshes the Cthulhu modifier keys, including grabs and xmodmap.""" + + msg = "CTHULHU MODIFIER MANAGER: Refreshing Cthulhu modifiers" + if reason: + msg += f": {reason}" + debug.print_message(debug.LEVEL_INFO, msg, True) + + for modifier in list(self._grabbed_modifiers.keys()): + self.remove_modifier_grab(modifier) + self._is_pressed = False + self._applied_modifier_keys = list(self.get_cthulhu_modifier_keys()) + self.add_grabs_for_cthulhu_modifiers() + self._modifiers_are_set = True + + display = os.environ.get("DISPLAY") + if not display: + msg = "CTHULHU MODIFIER MANAGER: DISPLAY not set, skipping xkbcomp operations" + debug.print_message(debug.LEVEL_INFO, msg, True) + return + + self._restore_original_xkbcomp() + with subprocess.Popen( + ["xkbcomp", display, "-"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) as p: + self._original_xmodmap, _ = p.communicate() + self._create_cthulhu_xmodmap() + + def _create_cthulhu_xmodmap(self) -> None: + """Makes an Cthulhu-specific Xmodmap so that the Cthulhu modifier works.""" + + msg = "CTHULHU MODIFIER MANAGER: Creating Cthulhu xmodmap" + debug.print_message(debug.LEVEL_INFO, msg, True) + + cthulhu_modifiers = self.get_cthulhu_modifier_keys() + if "Caps_Lock" in cthulhu_modifiers or "Shift_Lock" in cthulhu_modifiers: + self.set_caps_lock_as_cthulhu_modifier(True) + self._caps_lock_cleared = True + elif self._caps_lock_cleared: + self.set_caps_lock_as_cthulhu_modifier(False) + self._caps_lock_cleared = False + + def unset_cthulhu_modifiers(self, reason: str = "") -> None: + """Turns the Cthulhu modifiers back into their original purpose.""" + + msg = "CTHULHU MODIFIER MANAGER: Unsetting Cthulhu modifiers" + if reason: + msg += f": {reason}" + debug.print_message(debug.LEVEL_INFO, msg, True) + + self._modifiers_are_set = False + self._restore_original_xkbcomp() + input_event_manager.get_manager().unmap_all_modifiers() + + def _restore_original_xkbcomp(self) -> None: + """Restores the original xkbcomp keymap.""" + + if not self._original_xmodmap: + msg = "CTHULHU MODIFIER MANAGER: No stored xmodmap found" + debug.print_message(debug.LEVEL_INFO, msg, True) + return + + display = os.environ.get("DISPLAY") + if not display: + msg = "CTHULHU MODIFIER MANAGER: DISPLAY not set, skipping xmodmap restoration" + debug.print_message(debug.LEVEL_INFO, msg, True) + return + + self._caps_lock_cleared = False + with subprocess.Popen( + ["xkbcomp", "-w0", "-", display], + stdin=subprocess.PIPE, + stdout=None, + stderr=None, + ) as p: + p.communicate(self._original_xmodmap) + + msg = "CTHULHU MODIFIER MANAGER: Original xmodmap restored" + debug.print_message(debug.LEVEL_INFO, msg, True) + + @staticmethod + def _update_xkb_line( + line: str, + enable: bool, + normal_pattern: re.Pattern[str], + normal_line: str, + disabled_pattern: re.Pattern[str], + disabled_line: str, + ) -> tuple[str, bool]: + """Returns the possibly-updated line and whether it was modified.""" + + if enable and normal_pattern.match(line): + return disabled_line, True + if not enable and disabled_pattern.match(line): + return normal_line, True + return line, False + + def set_caps_lock_as_cthulhu_modifier(self, enable: bool) -> None: + """Enable or disable use of the caps lock key as an Cthulhu modifier key.""" + + msg = "CTHULHU MODIFIER MANAGER: Setting caps lock as the Cthulhu modifier" + debug.print_message(debug.LEVEL_INFO, msg, True) + + display = os.environ.get("DISPLAY") + if not display: + msg = "CTHULHU MODIFIER MANAGER: DISPLAY not set, cannot modify caps lock" + debug.print_message(debug.LEVEL_INFO, msg, True) + return + + if not self._original_xmodmap: + msg = "CTHULHU MODIFIER MANAGER: No xmodmap available, cannot modify caps lock" + debug.print_message(debug.LEVEL_INFO, msg, True) + return + + interpret_caps_line_prog = re.compile( + r"^\s*interpret\s+Caps[_+]Lock[_+]AnyOfOrNone\s*\(all\)\s*{\s*$", + re.IGNORECASE, + ) + normal_caps_line_prog = re.compile( + r"^\s*action\s*=\s*LockMods\s*\(\s*modifiers\s*=\s*Lock\s*\)\s*;\s*$", + re.IGNORECASE, + ) + interpret_shift_line_prog = re.compile( + r"^\s*interpret\s+Shift[_+]Lock[_+]AnyOf\s*\(\s*Shift\s*\+\s*Lock\s*\)\s*{\s*$", + re.IGNORECASE, + ) + normal_shift_line_prog = re.compile( + r"^\s*action\s*=\s*LockMods\s*\(\s*modifiers\s*=\s*Shift\s*\)\s*;\s*$", + re.IGNORECASE, + ) + disabled_mod_line_prog = re.compile( + r"^\s*action\s*=\s*NoAction\s*\(\s*\)\s*;\s*$", + re.IGNORECASE, + ) + normal_caps_line = " action= LockMods(modifiers=Lock);" + normal_shift_line = " action= LockMods(modifiers=Shift);" + disabled_mod_line = " action= NoAction();" + lines = self._original_xmodmap.decode("UTF-8").split("\n") + found_caps_interpret_section = False + found_shift_interpret_section = False + modified = False + for i, line in enumerate(lines): + if not found_caps_interpret_section and not found_shift_interpret_section: + if interpret_caps_line_prog.match(line): + found_caps_interpret_section = True + elif interpret_shift_line_prog.match(line): + found_shift_interpret_section = True + elif found_caps_interpret_section: + lines[i], changed = self._update_xkb_line( + line, + enable, + normal_caps_line_prog, + normal_caps_line, + disabled_mod_line_prog, + disabled_mod_line, + ) + modified = modified or changed + if line.find("}"): + found_caps_interpret_section = False + elif found_shift_interpret_section: + lines[i], changed = self._update_xkb_line( + line, + enable, + normal_shift_line_prog, + normal_shift_line, + disabled_mod_line_prog, + disabled_mod_line, + ) + modified = modified or changed + if line.find("}"): + found_shift_interpret_section = False + + if not modified: + msg = "CTHULHU MODIFIER MANAGER: Not updating xmodmap" + debug.print_message(debug.LEVEL_INFO, msg, True) + return + + msg = "CTHULHU MODIFIER MANAGER: Updating xmodmap" + debug.print_message(debug.LEVEL_INFO, msg, True) + + with subprocess.Popen( + ["xkbcomp", "-w0", "-", display], + stdin=subprocess.PIPE, + stdout=None, + stderr=None, + ) as p: + p.communicate(bytes("\n".join(lines), "UTF-8")) + + +_manager: CthulhuModifierManager = CthulhuModifierManager() + + +def get_manager() -> CthulhuModifierManager: + """Returns the CthulhuModifierManager singleton.""" - global _manager - if _manager is None: - _manager = CthulhuModifierManager() return _manager - diff --git a/src/cthulhu/input_event.py b/src/cthulhu/input_event.py index 3ee95e9..d6c0715 100644 --- a/src/cthulhu/input_event.py +++ b/src/cthulhu/input_event.py @@ -1,9 +1,7 @@ -#!/usr/bin/env python3 +# Cthulhu # -# Copyright (c) 2024 Stormux -# Copyright (c) 2010-2012 The Orca Team -# Copyright (c) 2012 Igalia, S.L. -# Copyright (c) 2005-2010 Sun Microsystems Inc. +# Copyright 2005-2008 Sun Microsystems Inc. +# Copyright 2011-2016 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 @@ -19,437 +17,131 @@ # License along with this library; if not, write to the # Free Software Foundation, Inc., Franklin Street, Fifth Floor, # Boston MA 02110-1301 USA. -# -# Forked from Orca screen reader. -# Cthulhu project: https://git.stormux.org/storm/cthulhu + +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-instance-attributes """Provides support for handling input events.""" -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2005-2008 Sun Microsystems Inc." \ - "Copyright (c) 2011-2016 Igalia, S.L." -__license__ = "LGPL" - -import gi -gi.require_version("Atspi", "2.0") -gi.require_version("Gdk", "3.0") -from gi.repository import Atspi +from __future__ import annotations +import inspect import math import time -from typing import Optional, Any -from gi.repository import Gdk -from gi.repository import GLib +import unicodedata +from typing import TYPE_CHECKING -from . import debug -from . import keybindings -from . import keynames -from . import messages -from . import cthulhu -from . import cthulhu_modifier_manager -from . import cthulhu_state -from . import script_manager -from . import settings -from .ax_object import AXObject +import gi + +gi.require_version("Atspi", "2.0") +gi.require_version("Gdk", "3.0") +from gi.repository import ( + Atspi, + Gdk, # pylint: disable=no-name-in-module + GLib, +) + +from . import ( + command_manager, + debug, + focus_manager, + keybindings, + keynames, + messages, + cthulhu_modifier_manager, + presentation_manager, + script_manager, +) from .ax_utilities import AXUtilities -KEYBOARD_EVENT: str = "keyboard" -BRAILLE_EVENT: str = "braille" -MOUSE_BUTTON_EVENT: str = "mouse:button" -REMOTE_CONTROLLER_EVENT: str = "remote controller" +if TYPE_CHECKING: + from collections.abc import Callable + + from .scripts import default + +KEYBOARD_EVENT = "keyboard" +BRAILLE_EVENT = "braille" +MOUSE_BUTTON_EVENT = "mouse:button" +REMOTE_CONTROLLER_EVENT = "remote controller" + class InputEvent: + """Provides support for handling input events.""" - def __init__(self, eventType: str) -> None: + def __init__(self, event_type: str) -> None: """Creates a new KEYBOARD_EVENT, BRAILLE_EVENT, or MOUSE_BUTTON_EVENT.""" - self.type: str = eventType + self.type: str = event_type self.time: float = time.time() - self._clickCount: int = 0 + self._click_count: int = 0 def get_click_count(self) -> int: """Return the count of the number of clicks a user has made.""" - return self._clickCount + return self._click_count - def set_click_count(self, count: Optional[int] = None) -> None: + def set_click_count(self, count: int) -> None: """Updates the count of the number of clicks a user has made.""" - if count is None: - return + self._click_count = count + + def as_single_line_string(self) -> str: + """Returns a single-line string representation of this event.""" + + return f"{self.type}" - self._clickCount = count class KeyboardEvent(InputEvent): - stickyKeys: bool = False + """Provides support for handling keyboard events.""" - duplicateCount: int = 0 - cthulhuModifierPressed: bool = False - - # Whether last press of the Cthulhu modifier was alone - lastCthulhuModifierAlone: bool = False - lastCthulhuModifierAloneTime: Optional[float] = None - # Whether the current press of the Cthulhu modifier is alone - currentCthulhuModifierAlone: bool = False - currentCthulhuModifierAloneTime: Optional[float] = None - # When the second cthulhu press happened - secondCthulhuModifierTime: Optional[float] = None - # Sticky modifiers state, to be applied to the next keyboard event - cthulhuStickyModifiers: int = 0 - - TYPE_UNKNOWN = "unknown" - TYPE_PRINTABLE = "printable" - TYPE_MODIFIER = "modifier" - TYPE_LOCKING = "locking" - TYPE_FUNCTION = "function" - TYPE_ACTION = "action" - TYPE_NAVIGATION = "navigation" - TYPE_DIACRITICAL = "diacritical" - TYPE_ALPHABETIC = "alphabetic" - TYPE_NUMERIC = "numeric" - TYPE_PUNCTUATION = "punctuation" - TYPE_SPACE = "space" - - GDK_PUNCTUATION_KEYS = [Gdk.KEY_acute, - Gdk.KEY_ampersand, - Gdk.KEY_apostrophe, - Gdk.KEY_asciicircum, - Gdk.KEY_asciitilde, - Gdk.KEY_asterisk, - Gdk.KEY_at, - Gdk.KEY_backslash, - Gdk.KEY_bar, - Gdk.KEY_braceleft, - Gdk.KEY_braceright, - Gdk.KEY_bracketleft, - Gdk.KEY_bracketright, - Gdk.KEY_brokenbar, - Gdk.KEY_cedilla, - Gdk.KEY_cent, - Gdk.KEY_colon, - Gdk.KEY_comma, - Gdk.KEY_copyright, - Gdk.KEY_currency, - Gdk.KEY_degree, - Gdk.KEY_diaeresis, - Gdk.KEY_dollar, - Gdk.KEY_EuroSign, - Gdk.KEY_equal, - Gdk.KEY_exclam, - Gdk.KEY_exclamdown, - Gdk.KEY_grave, - Gdk.KEY_greater, - Gdk.KEY_guillemotleft, - Gdk.KEY_guillemotright, - Gdk.KEY_hyphen, - Gdk.KEY_less, - Gdk.KEY_macron, - Gdk.KEY_minus, - Gdk.KEY_notsign, - Gdk.KEY_numbersign, - Gdk.KEY_paragraph, - Gdk.KEY_parenleft, - Gdk.KEY_parenright, - Gdk.KEY_percent, - Gdk.KEY_period, - Gdk.KEY_periodcentered, - Gdk.KEY_plus, - Gdk.KEY_plusminus, - Gdk.KEY_question, - Gdk.KEY_questiondown, - Gdk.KEY_quotedbl, - Gdk.KEY_quoteleft, - Gdk.KEY_quoteright, - Gdk.KEY_registered, - Gdk.KEY_section, - Gdk.KEY_semicolon, - Gdk.KEY_slash, - Gdk.KEY_sterling, - Gdk.KEY_underscore, - Gdk.KEY_yen] - - GDK_ACCENTED_LETTER_KEYS = [Gdk.KEY_Aacute, - Gdk.KEY_aacute, - Gdk.KEY_Acircumflex, - Gdk.KEY_acircumflex, - Gdk.KEY_Adiaeresis, - Gdk.KEY_adiaeresis, - Gdk.KEY_Agrave, - Gdk.KEY_agrave, - Gdk.KEY_Aring, - Gdk.KEY_aring, - Gdk.KEY_Atilde, - Gdk.KEY_atilde, - Gdk.KEY_Ccedilla, - Gdk.KEY_ccedilla, - Gdk.KEY_Eacute, - Gdk.KEY_eacute, - Gdk.KEY_Ecircumflex, - Gdk.KEY_ecircumflex, - Gdk.KEY_Ediaeresis, - Gdk.KEY_ediaeresis, - Gdk.KEY_Egrave, - Gdk.KEY_egrave, - Gdk.KEY_Iacute, - Gdk.KEY_iacute, - Gdk.KEY_Icircumflex, - Gdk.KEY_icircumflex, - Gdk.KEY_Idiaeresis, - Gdk.KEY_idiaeresis, - Gdk.KEY_Igrave, - Gdk.KEY_igrave, - Gdk.KEY_Ntilde, - Gdk.KEY_ntilde, - Gdk.KEY_Oacute, - Gdk.KEY_oacute, - Gdk.KEY_Ocircumflex, - Gdk.KEY_ocircumflex, - Gdk.KEY_Odiaeresis, - Gdk.KEY_odiaeresis, - Gdk.KEY_Ograve, - Gdk.KEY_ograve, - Gdk.KEY_Ooblique, - Gdk.KEY_ooblique, - Gdk.KEY_Otilde, - Gdk.KEY_otilde, - Gdk.KEY_Uacute, - Gdk.KEY_uacute, - Gdk.KEY_Ucircumflex, - Gdk.KEY_ucircumflex, - Gdk.KEY_Udiaeresis, - Gdk.KEY_udiaeresis, - Gdk.KEY_Ugrave, - Gdk.KEY_ugrave, - Gdk.KEY_Yacute, - Gdk.KEY_yacute] - - def __init__(self, pressed, keycode, keysym, modifiers, text): + # pylint:disable=too-many-arguments + # pylint:disable=too-many-positional-arguments + def __init__(self, pressed: bool, keycode: int, keysym: int, modifiers: int, text: str) -> None: """Creates a new InputEvent of type KEYBOARD_EVENT. Arguments: - - pressed: True if key is pressed, False if released - - keycode: hardware keycode - - keysym: keysym value - - modifiers: modifier mask - - text: text representation of the key + - pressed: True if this is a key press, False for a release. + - keycode: the hardware keycode. + - keysym: the translated keysym. + - modifiers: a bitflag giving the active modifiers. + - text: the text that would be inserted if this key is pressed. """ super().__init__(KEYBOARD_EVENT) - self.id = keysym - self.type = Atspi.EventType.KEY_PRESSED_EVENT if pressed else Atspi.EventType.KEY_RELEASED_EVENT - self.hw_code = keycode - self.modifiers = modifiers & Gdk.ModifierType.MODIFIER_MASK + self.id: int = keysym + self.type: Atspi.EventType = ( + Atspi.EventType.KEY_PRESSED_EVENT if pressed else Atspi.EventType.KEY_RELEASED_EVENT + ) + self.hw_code: int = keycode + self._text: str = text + self.modifiers: int = modifiers & Gdk.ModifierType.MODIFIER_MASK # pylint: disable=no-member if modifiers & (1 << Atspi.ModifierType.NUMLOCK): - self.modifiers |= (1 << Atspi.ModifierType.NUMLOCK) - self.event_string = text - self.keyval_name = Gdk.keyval_name(keysym) - if self.event_string == "": - self.event_string = self.keyval_name - self.timestamp = time.time() * 1000 # Convert to milliseconds - self.is_duplicate = False # Will be set by InputEventManager - self._script = None - self._app = None - self._window = None - self._obj = None - self._handler = None - self._consumer = None - self._should_consume = None - self._consume_reason = None - self._did_consume = None - self._result_reason = None - self._bypassCthulhu = None - self._is_kp_with_numlock = False - - # Some implementors don't populate this field at all. More often than not, - # the event_string and the keyval_name coincide for input events. - if not self.event_string: - self.event_string = self.keyval_name - - # Some implementors do populate the field, but with the keyname rather than - # the printable character. This messes us up with punctuation and other symbols. - if len(self.event_string) > 1 \ - and (self.id in KeyboardEvent.GDK_PUNCTUATION_KEYS or \ - self.id in KeyboardEvent.GDK_ACCENTED_LETTER_KEYS): - self.event_string = chr(self.id) + self.modifiers |= 1 << Atspi.ModifierType.NUMLOCK + self.keyval_name: str = Gdk.keyval_name(keysym) or "" + self.timestamp: float = time.time() + self._script: default.Script | None = None + self._window: Atspi.Accessible | None = None + self._obj: Atspi.Accessible | None = None + self._handler: Callable[[], bool] | None = None + self._is_kp_with_numlock: bool = False # Some implementors don't include numlock in the modifiers. Unfortunately, # trying to heuristically hack around this just by looking at the event # is not reliable. Ditto regarding asking Gdk for the numlock state. - if self.keyval_name.startswith("KP"): - if modifiers & (1 << Atspi.ModifierType.NUMLOCK): - self._is_kp_with_numlock = True + if self._is_keypad_key() and self.modifiers & (1 << Atspi.ModifierType.NUMLOCK): + self._is_kp_with_numlock = True - self.keyType = None - self.shouldEcho = False - - # InputEventManager will call _finalize_initialization after setting - # script/object/window to ensure shouldConsume uses correct context. - - def _finalize_initialization(self): - """Finalize initialization after object creation. - This is separated to allow InputEventManager to set additional properties first.""" - - if self.is_duplicate: - KeyboardEvent.duplicateCount += 1 - else: - KeyboardEvent.duplicateCount = 0 - - _isPressed = self.type == Atspi.EventType.KEY_PRESSED_EVENT - role = AXObject.get_role(self._obj) if self._obj else None - _mayEcho = _isPressed or role == Atspi.Role.TERMINAL - - if KeyboardEvent.stickyKeys and not self.isCthulhuModifier() \ - and not KeyboardEvent.lastCthulhuModifierAlone: - doubleEvent = self._getDoubleClickCandidate() - if doubleEvent and \ - doubleEvent.modifiers & keybindings.CTHULHU_MODIFIER_MASK: - # this is the second event of a double-click, and sticky Cthulhu - # affected the first, so copy over the modifiers to the second - KeyboardEvent.cthulhuStickyModifiers = doubleEvent.modifiers - - if not self.isCthulhuModifier(): - if KeyboardEvent.cthulhuModifierPressed: - KeyboardEvent.currentCthulhuModifierAlone = False - KeyboardEvent.currentCthulhuModifierAloneTime = None - else: - KeyboardEvent.lastCthulhuModifierAlone = False - KeyboardEvent.lastCthulhuModifierAloneTime = None - - if self.isNavigationKey(): - self.keyType = KeyboardEvent.TYPE_NAVIGATION - self.shouldEcho = _mayEcho and settings.enableNavigationKeys - elif self.isActionKey(): - self.keyType = KeyboardEvent.TYPE_ACTION - self.shouldEcho = _mayEcho and settings.enableActionKeys - elif self.is_modifier_key(): - self.keyType = KeyboardEvent.TYPE_MODIFIER - self.shouldEcho = _mayEcho and settings.enableModifierKeys - if self.isCthulhuModifier() and not self.is_duplicate: - now = time.time() - if KeyboardEvent.lastCthulhuModifierAlone: - if _isPressed: - KeyboardEvent.secondCthulhuModifierTime = now - if (KeyboardEvent.secondCthulhuModifierTime is not None and - KeyboardEvent.lastCthulhuModifierAloneTime is not None and - KeyboardEvent.secondCthulhuModifierTime < - KeyboardEvent.lastCthulhuModifierAloneTime + 0.5): - # double-cthulhu, let the real action happen - if self.event_string in ["Caps_Lock", "Shift_Lock"]: - cthulhu_modifier_manager.getManager().toggleModifier(self) - else: - self._bypassCthulhu = True - if not _isPressed: - KeyboardEvent.lastCthulhuModifierAlone = False - KeyboardEvent.lastCthulhuModifierAloneTime = False - else: - KeyboardEvent.cthulhuModifierPressed = _isPressed - if _isPressed: - KeyboardEvent.currentCthulhuModifierAlone = True - KeyboardEvent.currentCthulhuModifierAloneTime = now - else: - KeyboardEvent.lastCthulhuModifierAlone = \ - KeyboardEvent.currentCthulhuModifierAlone - KeyboardEvent.lastCthulhuModifierAloneTime = \ - KeyboardEvent.currentCthulhuModifierAloneTime - elif self.isFunctionKey(): - self.keyType = KeyboardEvent.TYPE_FUNCTION - self.shouldEcho = _mayEcho and settings.enableFunctionKeys - elif self.isDiacriticalKey(): - self.keyType = KeyboardEvent.TYPE_DIACRITICAL - self.shouldEcho = _mayEcho and settings.enableDiacriticalKeys - elif self.isLockingKey(): - self.keyType = KeyboardEvent.TYPE_LOCKING - self.shouldEcho = settings.presentLockingKeys - if self.shouldEcho is None: - self.shouldEcho = not settings.onlySpeakDisplayedText - self.shouldEcho = self.shouldEcho and _isPressed - elif self.isAlphabeticKey(): - self.keyType = KeyboardEvent.TYPE_ALPHABETIC - self.shouldEcho = _mayEcho \ - and (settings.enableAlphabeticKeys or settings.enableEchoByCharacter) - elif self.isNumericKey(): - self.keyType = KeyboardEvent.TYPE_NUMERIC - self.shouldEcho = _mayEcho \ - and (settings.enableNumericKeys or settings.enableEchoByCharacter) - elif self.isPunctuationKey(): - self.keyType = KeyboardEvent.TYPE_PUNCTUATION - self.shouldEcho = _mayEcho \ - and (settings.enablePunctuationKeys or settings.enableEchoByCharacter) - elif self.isSpace(): - self.keyType = KeyboardEvent.TYPE_SPACE - self.shouldEcho = _mayEcho \ - and (settings.enableSpace or settings.enableEchoByCharacter) - else: - self.keyType = KeyboardEvent.TYPE_UNKNOWN - self.shouldEcho = False - - if not self.isLockingKey(): - self.shouldEcho = self.shouldEcho and settings.enableKeyEcho - - if not self.is_modifier_key(): - self.set_click_count() - - if cthulhu_state.bypassNextCommand and _isPressed: - KeyboardEvent.cthulhuModifierPressed = False - - if KeyboardEvent.cthulhuModifierPressed: + modifier_manager = cthulhu_modifier_manager.get_manager() + if self.is_cthulhu_modifier(): + modifier_manager.set_pressed_state(pressed) + if modifier_manager.get_pressed_state(): self.modifiers |= keybindings.CTHULHU_MODIFIER_MASK - if KeyboardEvent.stickyKeys: - # apply all recorded sticky modifiers - self.modifiers |= KeyboardEvent.cthulhuStickyModifiers - if self.is_modifier_key(): - # add this modifier to the sticky ones - KeyboardEvent.cthulhuStickyModifiers |= self.modifiers - else: - # Non-modifier key, so clear the sticky modifiers. If the user - # actually double-presses that key, the modifiers of this event - # will be copied over to the second event, see earlier in this - # function. - KeyboardEvent.cthulhuStickyModifiers = 0 + # pylint:enable=too-many-arguments + # pylint:enable=too-many-positional-arguments - self._should_consume, self._consume_reason = self.shouldConsume() - - def _getDoubleClickCandidate(self): - lastEvent = cthulhu_state.lastNonModifierKeyEvent - if isinstance(lastEvent, KeyboardEvent) \ - and lastEvent.event_string == self.event_string \ - and self.time - lastEvent.time <= settings.doubleClickTimeout: - return lastEvent - return None - - def set_click_count(self, count=None): - """Updates the count of the number of clicks a user has made. - - If count is provided, sets the click count to that value. - Otherwise, calculates the click count based on event timing.""" - - if count is not None: - self._clickCount = count - return - - doubleEvent = self._getDoubleClickCandidate() - if not doubleEvent: - self._clickCount = 1 - return - - self._clickCount = doubleEvent.get_click_count() - if self.is_duplicate: - return - - if self.type == Atspi.EventType.KEY_RELEASED_EVENT: - return - - if self._clickCount < 3: - self._clickCount += 1 - return - - self._clickCount = 1 - - def __eq__(self, other): - if not other: + def __eq__(self, other: object) -> bool: + if not isinstance(other, KeyboardEvent): return False if self.type == other.type and self.hw_code == other.hw_code: @@ -457,778 +149,675 @@ class KeyboardEvent(InputEvent): return False - def __str__(self): - if self._shouldObscure(): - keyid = hw_code = modifiers = event_string = keyval_name = key_type = "*" + def __hash__(self) -> int: + return hash((self.type, self.hw_code, self.timestamp)) + + def __str__(self) -> str: + if self.should_obscure(): + keyid = hw_code = modifiers = text = keyval_name = "*" else: - keyid = self.id - hw_code = self.hw_code - modifiers = self.modifiers - event_string = self.event_string + keyid = str(self.id) + hw_code = str(self.hw_code) + modifiers = str(self.modifiers) keyval_name = self.keyval_name - key_type = self.keyType + text = self._text - return (f"KEYBOARD_EVENT: type={self.type.value_name.upper()}\n") \ - + f" id={keyid}\n" \ - + f" hw_code={hw_code}\n" \ - + f" modifiers={modifiers}\n" \ - + f" event_string=({event_string})\n" \ - + f" keyval_name=({keyval_name})\n" \ - + (" timestamp=%d\n" % self.timestamp) \ - + f" time={time.time():f}\n" \ - + f" keyType={key_type}\n" \ - + f" clickCount={self._clickCount}\n" \ - + f" shouldEcho={self.shouldEcho}\n" + return ( + f"KEYBOARD_EVENT: type={self.type.value_name.upper()}\n" + f" id={keyid}\n" + f" hw_code={hw_code}\n" + f" modifiers={modifiers}\n" + f" text='{text}'\n" + f" keyval_name='{keyval_name}'\n" + f" timestamp={self.timestamp}\n" + f" clickCount={self._click_count}" + ) + + def as_single_line_string(self) -> str: + """Returns a single-line string representation of this event.""" + + if self.should_obscure(): + return "(obscured)" + + return ( + f"'{self.keyval_name}' ({self.hw_code}) mods: {self.modifiers} {self.type.value_nick}" + ) + + def is_alt_control_or_cthulhu_modified(self) -> bool: + """Return True if this key is Alt, Control, or Cthulhu modified.""" + + return bool( + self.modifiers & keybindings.CTRL_MODIFIER_MASK + or self.modifiers & keybindings.ALT_MODIFIER_MASK + or self.modifiers & keybindings.CTHULHU_MODIFIER_MASK, + ) + + def should_obscure(self) -> bool: + """Returns True if we should obscure the details of this event.""" - def _shouldObscure(self): if not AXUtilities.is_password_text(self._obj): return False - if not self.is_printable_key(): + if not self.is_printable_key() and not self.is_space(): return False - if self.modifiers & keybindings.CTRL_MODIFIER_MASK \ - or self.modifiers & keybindings.ALT_MODIFIER_MASK \ - or self.modifiers & keybindings.CTHULHU_MODIFIER_MASK: - return False + return not self.is_alt_control_or_cthulhu_modified() - return True - - def _isReleaseForLastNonModifierKeyEvent(self): - last = cthulhu_state.lastNonModifierKeyEvent - if not last: - return False - - if not last.is_pressed_key() or self.is_pressed_key(): - return False - - if self.id == last.id and self.hw_code == last.hw_code: - return self.modifiers == last.modifiers - - return False - - def isReleaseFor(self, other): - """Return True if this is the release event for other.""" - - if not other: - return False - - if not other.is_pressed_key() or self.is_pressed_key(): - return False - - return self.id == other.id \ - and self.hw_code == other.hw_code \ - and self.modifiers == other.modifiers \ - and self.event_string == other.event_string \ - and self.keyval_name == other.keyval_name \ - and self.keyType == other.keyType \ - and self._clickCount == other._clickCount - - def isNavigationKey(self): + def is_navigation_key(self) -> bool: """Return True if this is a navigation key.""" - if self.keyType: - return self.keyType == KeyboardEvent.TYPE_NAVIGATION + keys = [ + Gdk.KEY_Down, + Gdk.KEY_End, + Gdk.KEY_Home, + Gdk.KEY_Left, + Gdk.KEY_Right, + Gdk.KEY_Up, + ] + return self.id in keys - return self.event_string in \ - ["Left", "Right", "Up", "Down", "Home", "End"] - - def isActionKey(self): + def is_action_key(self) -> bool: """Return True if this is an action key.""" - if self.keyType: - return self.keyType == KeyboardEvent.TYPE_ACTION + keys = [ + Gdk.KEY_BackSpace, + Gdk.KEY_Delete, + Gdk.KEY_Escape, + Gdk.KEY_KP_Enter, + Gdk.KEY_Page_Down, + Gdk.KEY_Page_Up, + Gdk.KEY_Return, + Gdk.KEY_Tab, + ] + return self.id in keys - return self.event_string in \ - ["Return", "Escape", "Tab", "BackSpace", "Delete", - "Page_Up", "Page_Down"] - - def isAlphabeticKey(self): + def is_alphabetic_key(self) -> bool: """Return True if this is an alphabetic key.""" - if self.keyType: - return self.keyType == KeyboardEvent.TYPE_ALPHABETIC - - if not len(self.event_string) == 1: + name = self.get_key_name() + if len(name) != 1: return False - return self.event_string.isalpha() + return name.isalpha() - def isDiacriticalKey(self): + def is_diacritical_key(self) -> bool: """Return True if this is a non-spacing diacritical key.""" - if self.keyType: - return self.keyType == KeyboardEvent.TYPE_DIACRITICAL + keys = [ + Gdk.KEY_dead_A, + Gdk.KEY_dead_a, + Gdk.KEY_dead_abovecomma, + Gdk.KEY_dead_abovedot, + Gdk.KEY_dead_abovereversedcomma, + Gdk.KEY_dead_abovering, + Gdk.KEY_dead_aboveverticalline, + Gdk.KEY_dead_acute, + Gdk.KEY_dead_belowbreve, + Gdk.KEY_dead_belowcircumflex, + Gdk.KEY_dead_belowcomma, + Gdk.KEY_dead_belowdiaeresis, + Gdk.KEY_dead_belowdot, + Gdk.KEY_dead_belowmacron, + Gdk.KEY_dead_belowring, + Gdk.KEY_dead_belowtilde, + Gdk.KEY_dead_belowverticalline, + Gdk.KEY_dead_breve, + Gdk.KEY_dead_capital_schwa, + Gdk.KEY_dead_caron, + Gdk.KEY_dead_cedilla, + Gdk.KEY_dead_circumflex, + Gdk.KEY_dead_currency, + Gdk.KEY_dead_dasia, + Gdk.KEY_dead_diaeresis, + Gdk.KEY_dead_doubleacute, + Gdk.KEY_dead_doublegrave, + Gdk.KEY_dead_E, + Gdk.KEY_dead_e, + Gdk.KEY_dead_grave, + Gdk.KEY_dead_greek, + Gdk.KEY_dead_hook, + Gdk.KEY_dead_horn, + Gdk.KEY_dead_I, + Gdk.KEY_dead_i, + Gdk.KEY_dead_invertedbreve, + Gdk.KEY_dead_iota, + Gdk.KEY_dead_longsolidusoverlay, + Gdk.KEY_dead_lowline, + Gdk.KEY_dead_macron, + Gdk.KEY_dead_O, + Gdk.KEY_dead_o, + Gdk.KEY_dead_ogonek, + Gdk.KEY_dead_perispomeni, + Gdk.KEY_dead_psili, + Gdk.KEY_dead_semivoiced_sound, + Gdk.KEY_dead_small_schwa, + Gdk.KEY_dead_stroke, + Gdk.KEY_dead_tilde, + Gdk.KEY_dead_U, + Gdk.KEY_dead_u, + Gdk.KEY_dead_voiced_sound, + ] - return self.event_string.startswith("dead_") - - def isFunctionKey(self): - """Return True if this is a function key.""" - - if self.keyType: - return self.keyType == KeyboardEvent.TYPE_FUNCTION - - return self.event_string in \ - ["F1", "F2", "F3", "F4", "F5", "F6", - "F7", "F8", "F9", "F10", "F11", "F12"] - - def isLockingKey(self): - """Return True if this is a locking key.""" - - if self.keyType: - return self.keyType in KeyboardEvent.TYPE_LOCKING - - lockingKeys = ["Caps_Lock", "Shift_Lock", "Num_Lock", "Scroll_Lock"] - if self.event_string not in lockingKeys: - return False - - if not cthulhu_state.bypassNextCommand and not self._bypassCthulhu: - return self.event_string not in settings.cthulhuModifierKeys - - return True - - def is_modifier_key(self): - """Return True if this is a modifier key.""" - - if self.keyType: - return self.keyType == KeyboardEvent.TYPE_MODIFIER - - if self.isCthulhuModifier(): + if self.id in keys: return True - return self.event_string in \ - ['Alt_L', 'Alt_R', 'Control_L', 'Control_R', - 'Shift_L', 'Shift_R', 'Meta_L', 'Meta_R', - 'ISO_Level3_Shift'] - - def isNumericKey(self): - """Return True if this is a numeric key.""" - - if self.keyType: - return self.keyType == KeyboardEvent.TYPE_NUMERIC - - if not len(self.event_string) == 1: - return False - - return self.event_string.isnumeric() - - def isCthulhuModifier(self, checkBypassMode=True): - """Return True if this is the Cthulhu modifier key.""" - - if checkBypassMode and cthulhu_state.bypassNextCommand: - return False - - if self.event_string in settings.cthulhuModifierKeys: - return True - - if self.keyval_name == "KP_0" \ - and "KP_Insert" in settings.cthulhuModifierKeys \ - and self.modifiers & keybindings.SHIFT_MODIFIER_MASK: - return True + name = self.get_key_name() + if len(name) == 1: + category = unicodedata.category(name) + # Mn = Mark, nonspacing; Mc = Mark, spacing combining; Me = Mark, enclosing + if category in ("Mn", "Mc", "Me"): + return True return False - def isCthulhuModified(self): + def is_function_key(self) -> bool: + """Return True if this is a function key.""" + + keys = [ + Gdk.KEY_F1, + Gdk.KEY_F2, + Gdk.KEY_F3, + Gdk.KEY_F4, + Gdk.KEY_F5, + Gdk.KEY_F6, + Gdk.KEY_F7, + Gdk.KEY_F8, + Gdk.KEY_F9, + Gdk.KEY_F10, + Gdk.KEY_F11, + Gdk.KEY_F12, + ] + return self.id in keys + + def is_locking_key(self) -> bool: + """Return True if this is a locking key.""" + + if self.is_cthulhu_modifier(): + return self._click_count == 2 + + keys = [ + Gdk.KEY_Caps_Lock, + Gdk.KEY_Num_Lock, + Gdk.KEY_Scroll_Lock, + Gdk.KEY_Shift_Lock, + ] + return self.id in keys + + def is_modifier_key(self) -> bool: + """Return True if this is a modifier key.""" + + keys = [ + Gdk.KEY_Alt_L, + Gdk.KEY_Alt_R, + Gdk.KEY_Control_L, + Gdk.KEY_Control_R, + Gdk.KEY_Meta_L, + Gdk.KEY_Meta_R, + Gdk.KEY_Super_L, + Gdk.KEY_Super_R, + Gdk.KEY_Shift_L, + Gdk.KEY_Shift_R, + Gdk.KEY_ISO_Level3_Shift, + ] + return self.id in keys or self.is_cthulhu_modifier() + + def is_numeric_key(self) -> bool: + """Return True if this is a numeric key.""" + + keys = [ + Gdk.KEY_0, + Gdk.KEY_1, + Gdk.KEY_2, + Gdk.KEY_3, + Gdk.KEY_4, + Gdk.KEY_5, + Gdk.KEY_6, + Gdk.KEY_7, + Gdk.KEY_8, + Gdk.KEY_9, + Gdk.KEY_KP_0, + Gdk.KEY_KP_1, + Gdk.KEY_KP_2, + Gdk.KEY_KP_3, + Gdk.KEY_KP_4, + Gdk.KEY_KP_5, + Gdk.KEY_KP_6, + Gdk.KEY_KP_7, + Gdk.KEY_KP_8, + Gdk.KEY_KP_9, + ] + return self.id in keys + + def is_cthulhu_modifier(self) -> bool: + """Return True if this is the Cthulhu modifier key.""" + + if self.id == Gdk.KEY_KP_0 and self.modifiers & keybindings.SHIFT_MODIFIER_MASK: + return cthulhu_modifier_manager.get_manager().is_cthulhu_modifier("KP_Insert") + + return cthulhu_modifier_manager.get_manager().is_cthulhu_modifier(self.keyval_name) + + def is_cthulhu_modified(self) -> bool: """Return True if this key is Cthulhu modified.""" - if cthulhu_state.bypassNextCommand: + if self.is_cthulhu_modifier(): return False - return self.modifiers & keybindings.CTHULHU_MODIFIER_MASK + return bool(self.modifiers & keybindings.CTHULHU_MODIFIER_MASK) - def isKeyPadKeyWithNumlockOn(self): + def _is_keypad_key(self) -> bool: + """Return True if this is a keypad key based on keyval name or hardware keycode.""" + + if self.keyval_name.startswith("KP"): + return True + keypad_keycodes = {63, 77, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 104, 106} + return self.hw_code in keypad_keycodes + + def is_keypad_key_with_numlock_on(self) -> bool: """Return True if this is a key pad key with numlock on.""" return self._is_kp_with_numlock - def is_printable_key(self): + def is_printable_key(self) -> bool: """Return True if this is a printable key.""" - if self.event_string in ["space", " "]: - return True - - if not len(self.event_string) == 1: + name = self.get_key_name() + if len(name) != 1: return False - return self.event_string.isprintable() + return name.isprintable() - def is_pressed_key(self): + def is_pressed_key(self) -> bool: """Returns True if the key is pressed""" return self.type == Atspi.EventType.KEY_PRESSED_EVENT - def isPunctuationKey(self): + def is_punctuation_key(self) -> bool: """Return True if this is a punctuation key.""" - if self.keyType: - return self.keyType == KeyboardEvent.TYPE_PUNCTUATION + keys = [ + Gdk.KEY_acute, + Gdk.KEY_ampersand, + Gdk.KEY_apostrophe, + Gdk.KEY_asciicircum, + Gdk.KEY_asciitilde, + Gdk.KEY_asterisk, + Gdk.KEY_at, + Gdk.KEY_backslash, + Gdk.KEY_bar, + Gdk.KEY_braceleft, + Gdk.KEY_braceright, + Gdk.KEY_bracketleft, + Gdk.KEY_bracketright, + Gdk.KEY_brokenbar, + Gdk.KEY_cedilla, + Gdk.KEY_cent, + Gdk.KEY_colon, + Gdk.KEY_comma, + Gdk.KEY_copyright, + Gdk.KEY_currency, + Gdk.KEY_degree, + Gdk.KEY_diaeresis, + Gdk.KEY_dollar, + Gdk.KEY_EuroSign, + Gdk.KEY_equal, + Gdk.KEY_exclam, + Gdk.KEY_exclamdown, + Gdk.KEY_grave, + Gdk.KEY_greater, + Gdk.KEY_guillemotleft, + Gdk.KEY_guillemotright, + Gdk.KEY_hyphen, + Gdk.KEY_KP_Decimal, + Gdk.KEY_KP_Add, + Gdk.KEY_KP_Divide, + Gdk.KEY_KP_Multiply, + Gdk.KEY_KP_Subtract, + Gdk.KEY_less, + Gdk.KEY_macron, + Gdk.KEY_minus, + Gdk.KEY_notsign, + Gdk.KEY_numbersign, + Gdk.KEY_paragraph, + Gdk.KEY_parenleft, + Gdk.KEY_parenright, + Gdk.KEY_percent, + Gdk.KEY_period, + Gdk.KEY_periodcentered, + Gdk.KEY_plus, + Gdk.KEY_plusminus, + Gdk.KEY_question, + Gdk.KEY_questiondown, + Gdk.KEY_quotedbl, + Gdk.KEY_quoteleft, + Gdk.KEY_quoteright, + Gdk.KEY_registered, + Gdk.KEY_section, + Gdk.KEY_semicolon, + Gdk.KEY_slash, + Gdk.KEY_sterling, + Gdk.KEY_underscore, + Gdk.KEY_yen, + ] + return self.id in keys - if not len(self.event_string) == 1: - return False - - if self.isAlphabeticKey() or self.isNumericKey(): - return False - - return self.event_string.isprintable() and not self.event_string.isspace() - - def isSpace(self): + def is_space(self) -> bool: """Return True if this is the space key.""" - if self.keyType: - return self.keyType == KeyboardEvent.TYPE_SPACE + return self.id == Gdk.KEY_space - return self.event_string in ["space", " "] + def get_locking_state(self) -> bool | None: + """Returns True if the event locked a locking key, False if the event unlocked it, and None + if not a locking key.""" - def isFromApplication(self, app): - """Return True if this is associated with the specified app.""" - - return self._app == app - - def isCharacterEchoable(self): - """Returns True if the script will echo this event as part of - character echo. We do this to not double-echo a given printable - character.""" - - if not self.is_printable_key(): - return False - - script = cthulhu_state.activeScript - return script and script.utilities.willEchoCharacter(self) - - def getLockingState(self): - """Returns True if the event locked a locking key, False if the - event unlocked a locking key, and None if we do not know or this - is not a locking key.""" - - if not self.isLockingKey(): + if not self.is_locking_key(): return None - if self.event_string == "Caps_Lock": + if self.id == Gdk.KEY_Caps_Lock: mod = Atspi.ModifierType.SHIFTLOCK - elif self.event_string == "Shift_Lock": + elif self.id == Gdk.KEY_Shift_Lock: mod = Atspi.ModifierType.SHIFT - elif self.event_string == "Num_Lock": + elif self.id == Gdk.KEY_Num_Lock: mod = Atspi.ModifierType.NUMLOCK else: return None return not self.modifiers & (1 << mod) - def getLockingStateString(self): - """Returns the string which reflects the locking state we wish to - include when presenting a locking key.""" + def get_locking_state_string(self) -> str: + """Returns the string reflecting the locking state.""" - locked = self.getLockingState() + locked = self.get_locking_state() if locked is None: - return '' + return "" if not locked: return messages.LOCKING_KEY_STATE_OFF return messages.LOCKING_KEY_STATE_ON - def getKeyName(self): - """Returns the string to be used for presenting the key to the user.""" + def get_key_name(self) -> str: + """Returns the string to be used for presenting the key.""" - return keynames.getKeyName(self.event_string) + if self._text.strip() and self._text.isprintable(): + return self._text - def get_object(self): + name = keynames.get_key_name(self.keyval_name) + if name is not None: + return name + + unicode_codepoint = Gdk.keyval_to_unicode(self.id) + if unicode_codepoint: + char = chr(unicode_codepoint) + if char.isprintable(): + return char + + return self.keyval_name + + def get_object(self) -> Atspi.Accessible | None: """Returns the object believed to be associated with this key event.""" return self._obj - - def set_object(self, obj): + + def set_object(self, obj: Atspi.Accessible | None) -> None: """Sets the object believed to be associated with this key event.""" - + + module_name = inspect.getmodulename(inspect.stack()[1].filename) + if not (module_name and module_name.startswith("input_event")): + raise PermissionError("Unauthorized setter of input event property") + self._obj = obj - - def get_window(self): - """Returns the window associated with this key event.""" - + + def get_window(self) -> Atspi.Accessible | None: + """Returns the window believed to be associated with this key event.""" + return self._window - - def set_window(self, window): - """Sets the window associated with this key event.""" - + + def set_window(self, window: Atspi.Accessible | None) -> None: + """Sets the window believed to be associated with this key event.""" + + module_name = inspect.getmodulename(inspect.stack()[1].filename) + if not (module_name and module_name.startswith("input_event")): + raise PermissionError("Unauthorized setter of input event property") + self._window = window - - def get_script(self): - """Returns the script associated with this key event.""" - + + def get_script(self) -> default.Script | None: + """Returns the script believed to be associated with this key event.""" + return self._script - - def set_script(self, script): - """Sets the script associated with this key event.""" - + + def set_script(self, script: default.Script | None) -> None: + """Sets the script believed to be associated with this key event.""" + + module_name = inspect.getmodulename(inspect.stack()[1].filename) + if not (module_name and module_name.startswith("input_event")): + raise PermissionError("Unauthorized setter of input event property") + self._script = script - if script: - self._app = script.app - - def get_click_count(self): - """Returns the click count for this event.""" - - return self._clickCount - - def as_single_line_string(self): - """Returns a single-line string representation of this event.""" - - return f"KeyboardEvent({self.keyval_name}, pressed={self.is_pressed_key()}, modifiers={self.modifiers})" - def getHandler(self): - """Returns the handler associated with this key event.""" - - return self._handler - - def _resolveHandler(self): - """Resolve handler for this event, returning True if a global handler was used.""" - - if not self._handler and self._script: - self._handler = self._script.keyBindings.getInputHandler(self) - - if not self._handler: - globalHandler = self._getGlobalHandler() - if globalHandler: - self._handler = globalHandler - return True - - return False - - def shouldConsume(self): - """Returns True if this event should be consumed.""" - - # Debug logging to understand handler matching - debug.print_log( - debug.LEVEL_INFO, - "INPUT EVENT", - f"shouldConsume: key='{self.event_string}' hw_code={self.hw_code} modifiers={self.modifiers}", - timestamp=True, - ) - - if not self.timestamp: - return False, 'No timestamp' - - globalHandlerUsed = False + def _present(self) -> None: if not self._script: - globalHandler = self._getGlobalHandler() - if globalHandler: - self._handler = globalHandler - self._script = script_manager.get_manager().get_default_script() - globalHandlerUsed = True - else: - debug.print_log(debug.LEVEL_INFO, "INPUT EVENT", - "shouldConsume: No active script", - reason="no-active-script", timestamp=True) - return False, 'No active script when received' + return - debug.print_log(debug.LEVEL_INFO, "INPUT EVENT", - f"shouldConsume: Active script={self._script.__class__.__name__}", - timestamp=True) - - if self.is_duplicate: - return False, 'Is duplicate' - - if cthulhu_state.capturingKeys: - return False, 'Capturing keys' - - if cthulhu_state.bypassNextCommand: - return False, 'Bypass next command' - - globalHandlerUsed = globalHandlerUsed or self._resolveHandler() - - if self._handler: - debug.print_log(debug.LEVEL_INFO, "INPUT EVENT", - f"shouldConsume: Handler found: {self._handler.description}", - timestamp=True) - else: - debug.print_log(debug.LEVEL_INFO, "INPUT EVENT", - "shouldConsume: No handler found", - reason="no-handler", timestamp=True) - - if self._isSleepModeActive(): - if self._isSleepModeToggleHandler(): - return True, 'Sleep mode toggle command' - return False, 'Sleep mode active' - - self._script.updateKeyboardEventState(self, self._handler) - scriptConsumes = self._script.shouldConsumeKeyboardEvent(self, self._handler) - if globalHandlerUsed: - scriptConsumes = True - debug.print_log(debug.LEVEL_INFO, "INPUT EVENT", - f"shouldConsume: scriptConsumes={scriptConsumes}", - timestamp=True) - - if self._isReleaseForLastNonModifierKeyEvent(): - return scriptConsumes, 'Is release for last non-modifier keyevent' - - if self._script.learnModePresenter.is_active(): - self._consumer = self._script.learnModePresenter.handle_event - return True, 'In Learn Mode' - - if self.is_modifier_key(): - if not self.isCthulhuModifier(): - return False, 'Non-Cthulhu modifier not in Learn Mode' - return True, 'Cthulhu modifier' - - if not self._handler: - if scriptConsumes: - return True, 'Script consumed without handler' - return False, 'No handler' - - return scriptConsumes, 'Script indication' - - def _getGlobalHandler(self): - try: - plugin_manager = cthulhu.cthulhuApp.getPluginSystemManager() - except Exception: - return None - if not plugin_manager: - return None - global_bindings = plugin_manager.get_global_keybindings() - if not global_bindings: - return None - return global_bindings.getInputHandler(self) - - def _isSleepModeActive(self): - """Returns True if the script for this event is in sleep mode.""" - - if not self._script: - return False - - if "scripts.sleepmode" in self._script.__module__: - return True - - app = getattr(self._script, "app", None) - if app is None: - return False - - try: - from . import sleep_mode_manager - manager = sleep_mode_manager.getManager() - return bool(manager and manager.isActiveForApp(app)) - except Exception: - return False - - def _isSleepModeToggleHandler(self): - """Returns True if the resolved handler toggles sleep mode.""" - - if not self._handler or not self._handler.function: - return False - - functionName = getattr(self._handler.function, "__name__", "") - return "toggleSleepMode" in functionName - - def didConsume(self): - """Returns True if this event was consumed.""" - - if self._did_consume is not None: - return self._did_consume - - return False - - def isHandledBy(self, method): - if not self._handler: - return False - - return method.__func__ == self._handler.function - - def _should_interrupt_presentation_on_press(self): - if not settings.gameMode: - return True - - if cthulhu_state.bypassNextCommand and not self.is_modifier_key(): - return False - - if self._handler or self._consumer: - return True - - if self.isCthulhuModifier(): - return True - - return False - - def _present(self, inputEvent=None): if self.is_pressed_key(): - if self._should_interrupt_presentation_on_press(): - self._script.presentationInterrupt() + presentation_manager.get_manager().interrupt_presentation() - if self._script.learnModePresenter.is_active(): - return False + # pylint: disable=import-outside-toplevel + from . import learn_mode_presenter, sleep_mode_manager - return self._script.presentKeyboardEvent(self) + if learn_mode_presenter.get_presenter().is_active(): + return - def process(self): + if sleep_mode_manager.get_manager().is_active_for_app(self._script.app): + return + + presentation_manager.get_manager().present_keyboard_event(self) + + def process(self) -> None: """Processes this input event.""" - startTime = time.time() - if not self._shouldObscure(): - data = "'%s' (%d)" % (self.event_string, self.hw_code) + start_time = time.time() + should_obscure = self.should_obscure() + if not should_obscure: + data = f"'{self.keyval_name}' ({self.hw_code})" else: data = "(obscured)" - if self.is_duplicate: - data = '%s DUPLICATE EVENT #%i' % (data, KeyboardEvent.duplicateCount) + debug.print_message(debug.LEVEL_INFO, f"\n{self}") - msg = f'\nvvvvv PROCESS {self.type.value_name.upper()}: {data} vvvvv' - debug.printMessage(debug.LEVEL_INFO, msg, False) + msg = f"\nvvvvv PROCESS {self.type.value_name.upper()}: {data} vvvvv" + debug.print_message(debug.LEVEL_INFO, msg, False) - tokens = ["HOST_APP:", self._app] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + tokens = ["SCRIPT:", self._script] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) tokens = ["WINDOW:", self._window] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) tokens = ["LOCATION:", self._obj] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + debug.print_tokens(debug.LEVEL_INFO, tokens, True) - tokens = ["CONSUME:", self._should_consume, self._consume_reason] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + if self._script: + script = self._script + command = command_manager.get_manager().get_command_for_event(self) + if not should_obscure and command is not None: + tokens = ["COMMAND:", command.get_name()] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) - self._did_consume, self._result_reason = self._process() + # pylint: disable-next=import-outside-toplevel + from . import learn_mode_presenter - if self._should_consume != self._did_consume: - tokens = ["CONSUMED:", self._did_consume, self._result_reason] - debug.printTokens(debug.LEVEL_INFO, tokens, True) + learn_mode = learn_mode_presenter.get_presenter().is_active() + if learn_mode: + tokens = ["KEYBOARD EVENT: Learn mode is active"] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + self._handler = lambda: learn_mode_presenter.get_presenter().handle_event( + self, + command, + ) + elif command is not None and command.is_enabled(): + self._handler = lambda: command.execute(script, self) - if debug.LEVEL_INFO >= debug.debugLevel and cthulhu_state.activeScript: - attributes = cthulhu_state.activeScript.getTransferableAttributes() - for key, value in attributes.items(): - msg = f"INPUT EVENT: {key}: {value}" - debug.printMessage(debug.LEVEL_INFO, msg, True) + if self.is_cthulhu_modifier(): + if self._click_count == 2: + cthulhu_modifier_manager.get_manager().toggle_modifier(self) - msg = f"TOTAL PROCESSING TIME: {time.time() - startTime:.4f}" - debug.printMessage(debug.LEVEL_INFO, msg, True) - - msg = f"^^^^^ PROCESS {self.type.value_name.upper()}: {data} ^^^^^\n" - debug.printMessage(debug.LEVEL_INFO, msg, False) - - return self._did_consume - - def _process(self): - """Processes this input event.""" - - if self._bypassCthulhu: - if (self.event_string == "Caps_Lock" \ - or self.event_string == "Shift_Lock") \ - and self.type == Atspi.EventType.KEY_PRESSED_EVENT: - self.keyType = KeyboardEvent.TYPE_LOCKING - self._present() - return False, 'Bypassed cthulhu modifier' - - cthulhu_state.lastInputEvent = self - if not self.is_modifier_key(): - cthulhu_state.lastNonModifierKeyEvent = self - - if not self._script: - return False, 'No active script' - - if self.is_duplicate: - return False, 'Is duplicate' + if self.is_pressed_key() and self.keyval_name == "Num_Lock": + numlock_on = self.get_locking_state() + if numlock_on is not None: + command_manager.get_manager().handle_numlock_toggled(numlock_on) self._present() - if not self.is_pressed_key(): - return self._should_consume, 'Consumed based on handler' + if self.is_pressed_key() and self._handler: + GLib.timeout_add(1, self._handle) - if cthulhu_state.capturingKeys: - return False, 'Capturing keys' + msg = f"TOTAL PROCESSING TIME: {time.time() - start_time:.4f}" + debug.print_message(debug.LEVEL_INFO, msg, True) - if self.isCthulhuModifier(): - return True, 'Cthulhu modifier' + msg = f"^^^^^ PROCESS {self.type.value_name.upper()}: {data} ^^^^^\n" + debug.print_message(debug.LEVEL_INFO, msg, False) - if cthulhu_state.bypassNextCommand: - if not self.is_modifier_key(): - cthulhu_state.bypassNextCommand = False - cthulhu_modifier_manager.getManager().refreshCthulhuModifiers("Bypass next command disabled") - self._script.addKeyGrabs() - return False, 'Bypass next command' + def _handle(self) -> bool: + """Handles this event after a timeout. Returns False to stop the timeout.""" - if not self._should_consume: - return False, 'Should not consume' + start_time = time.time() + data = f"'{self.keyval_name}' ({self.hw_code})" + msg = f"\nvvvvv HANDLE {self.type.value_name.upper()}: {data} vvvvv" + debug.print_message(debug.LEVEL_INFO, msg, False) - if not (self._consumer or self._handler): - return True, 'Consumed during shouldConsume' + if self._handler: + try: + self._handler() + except GLib.GError as error: + msg = f"KEYBOARD EVENT: Exception calling handler: {error}" + debug.print_message(debug.LEVEL_WARNING, msg, True) - if self._consumer or self._handler.function: - GLib.timeout_add(1, self._consume) - return True, 'Will be consumed' + msg = f"TOTAL PROCESSING TIME: {time.time() - start_time:.4f}" + debug.print_message(debug.LEVEL_INFO, msg, True) - return False, 'Unaddressed case' - - def _consume(self): - startTime = time.time() - data = "'%s' (%d)" % (self.event_string, self.hw_code) - msg = f'vvvvv CONSUME {self.type.value_name.upper()}: {data} vvvvv' - debug.printMessage(debug.LEVEL_INFO, msg, False) - - if self._consumer: - msg = f'INFO: Consumer is {self._consumer.__name__}' - debug.printMessage(debug.LEVEL_INFO, msg, True) - self._consumer(self) - elif self._handler.function: - msg = f'INFO: Handler is {self._handler.description}' - debug.printMessage(debug.LEVEL_INFO, msg, True) - self._handler.function(self._script, self) - else: - msg = 'INFO: No handler or consumer' - debug.printMessage(debug.LEVEL_INFO, msg, True) - - msg = f'TOTAL PROCESSING TIME: {time.time() - startTime:.4f}' - debug.printMessage(debug.LEVEL_INFO, msg, True) - - msg = f'^^^^^ CONSUME {self.type.value_name.upper()}: {data} ^^^^^' - debug.printMessage(debug.LEVEL_INFO, msg, False) + msg = f"^^^^^ HANDLE {self.type.value_name.upper()}: {data} ^^^^^\n" + debug.print_message(debug.LEVEL_INFO, msg, False) return False + class BrailleEvent(InputEvent): + """Provides support for handling braille events.""" - def __init__(self, event): - """Creates a new InputEvent of type BRAILLE_EVENT. - - Arguments: - - event: the integer BrlTTY command for this event. - """ + def __init__(self, event: dict) -> None: super().__init__(BRAILLE_EVENT) - self.event = event + self.event: dict = event + self._script: default.Script | None = script_manager.get_manager().get_active_script() + + def __str__(self) -> str: + return f"{self.type.upper()} {self.event}" + + def get_command(self) -> command_manager.BrailleCommand | None: + """Returns the BrailleCommand associated with this event.""" + + braille_key: int = self.event["command"] + command = command_manager.get_manager().get_command_for_braille_event(braille_key) + tokens = [f"BRAILLE EVENT: Command for braille key {braille_key} is", command] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return command + + def process(self): + """Processes this event.""" + + tokens = ["\nvvvvv PROCESS", self, "vvvvv"] + debug.print_tokens(debug.LEVEL_INFO, tokens, False) + + start_time = time.time() + result = self._process() + msg = f"TOTAL PROCESSING TIME: {time.time() - start_time:.4f}" + debug.print_message(debug.LEVEL_INFO, msg, False) + + tokens = ["^^^^^ PROCESS", self, "^^^^^"] + debug.print_tokens(debug.LEVEL_INFO, tokens, False) + return result + + def _process(self): + # pylint: disable-next=import-outside-toplevel + from . import learn_mode_presenter + + presenter = learn_mode_presenter.get_presenter() + + command = self.get_command() + if not command: + if presenter.is_active(): + tokens = ["BRAILLE EVENT: No command, but in learn mode", self] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return True + + tokens = ["BRAILLE EVENT: No command found for", self] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return False + + tokens = ["BRAILLE EVENT: Command is:", command] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + + if presenter.is_active(): + if presenter.handle_braille_event(self._script, self, command): + return True + + command.execute(self._script, self) + return True + class MouseButtonEvent(InputEvent): + """Provides support for handling mouse button events.""" + # TODO - JD: Remove this and the validation logic once we have a fix for + # https://gitlab.gnome.org/GNOME/at-spi2-core/-/issues/194. try: - display = Gdk.Display.get_default() + display = Gdk.Display.get_default() # pylint: disable=no-value-for-parameter seat = Gdk.Display.get_default_seat(display) _pointer = seat.get_pointer() - except Exception: + except Exception: # pylint: disable=broad-exception-caught _pointer = None def __init__(self, event): - """Creates a new InputEvent of type MOUSE_BUTTON_EVENT.""" - super().__init__(MOUSE_BUTTON_EVENT) self.x = event.detail1 self.y = event.detail2 - self.pressed = event.type.endswith('p') - self.button = event.type[len("mouse:button:"):-1] - self._script = cthulhu_state.activeScript - self.window = cthulhu_state.activeWindow - self.obj = None + self.pressed = event.type.endswith("p") + self.button = event.type[len("mouse:button:") : -1] + self._script = script_manager.get_manager().get_active_script() + self.window = focus_manager.get_manager().get_active_window() + self.app = None if self.pressed: - self._validateCoordinates() + self._validate_coordinates() if not self._script: return - if not self._script.utilities.canBeActiveWindow(self.window): - self.window = self._script.utilities.activeWindow() + if not AXUtilities.can_be_active_window(self.window): + self.window = AXUtilities.find_active_window() if not self.window: return - self.obj = self._script.utilities.descendantAtPoint( - self.window, self.x, self.y, event.any_data) + self.app = AXUtilities.get_application(self.window) - def _validateCoordinates(self): + def _validate_coordinates(self): if not self._pointer: return - screen, x, y = self._pointer.get_position() - if math.sqrt((self.x - x)**2 + (self.y - y)**2) < 25: + x, y = self._pointer.get_position()[1:] + if math.sqrt((self.x - x) ** 2 + (self.y - y) ** 2) < 25: return msg = ( - f"WARNING: Event coordinates ({self.x}, {self.y}) may be bogus. " - f"Updating to ({x}, {y})" + f"WARNING: Event coordinates ({self.x}, {self.y}) may be bogus. Updating to ({x}, {y})" ) - debug.printMessage(debug.LEVEL_INFO, msg, True) + debug.print_message(debug.LEVEL_INFO, msg, True) self.x, self.y = x, y - def set_click_count(self, count=None): - """Updates the count of the number of clicks a user has made.""" - - if count is not None: - self._clickCount = count - return - - if not self.pressed: - return - - lastInputEvent = cthulhu_state.lastInputEvent - if not isinstance(lastInputEvent, MouseButtonEvent): - self._clickCount = 1 - return - - if self.time - lastInputEvent.time < settings.doubleClickTimeout \ - and lastInputEvent.button == self.button: - if self._clickCount < 2: - self._clickCount += 1 - return - - self._clickCount = 1 - class RemoteControllerEvent(InputEvent): """A simple input event whose main purpose is identification of the origin.""" def __init__(self): super().__init__(REMOTE_CONTROLLER_EVENT) - - -class InputEventHandler: - - def __init__(self, function, description, learnModeEnabled=True, enabled=True): - """Creates a new InputEventHandler instance. All bindings - (e.g., key bindings and braille bindings) will be handled - by an instance of an InputEventHandler. - - Arguments: - - function: the function to call with an InputEvent instance as its - sole argument. The function is expected to return True - if it consumes the event; otherwise it should return - False - - description: a localized string describing what this InputEvent - does - - learnModeEnabled: if True, the description will be spoken and - brailled if learn mode is enabled. If False, - the function will be called no matter what. - """ - - self.function = function - self.description = description - self.learnModeEnabled = learnModeEnabled - self._enabled = enabled - - def is_enabled(self): - """Returns True if this handler is enabled.""" - return self._enabled - - def set_enabled(self, enabled): - """Sets enabled state of this handler.""" - self._enabled = enabled - - def __eq__(self, other): - """Compares one input handler to another.""" - - if not other: - return False - - return (self.function == other.function) - - def processInputEvent(self, script, inputEvent): - """Processes an input event. - - This function is expected to return True if it consumes the - event; otherwise it is expected to return False. - - Arguments: - - script: the script (if any) associated with this event - - inputEvent: the input event to pass to the function bound - to this InputEventHandler instance. - """ - - consumed = False - try: - consumed = self.function(script, inputEvent) - except Exception: - debug.printException(debug.LEVEL_SEVERE) - - return consumed diff --git a/src/cthulhu/input_event_manager.py b/src/cthulhu/input_event_manager.py index a483654..29885b9 100644 --- a/src/cthulhu/input_event_manager.py +++ b/src/cthulhu/input_event_manager.py @@ -1,6 +1,5 @@ -#!/usr/bin/env python3 +# Cthulhu # -# Copyright (c) 2024 Stormux # Copyright 2024 Igalia, S.L. # Copyright 2024 GNOME Foundation Inc. # Author: Joanmarie Diggs @@ -19,146 +18,80 @@ # License along with this library; if not, write to the # Free Software Foundation, Inc., Franklin Street, Fifth Floor, # Boston MA 02110-1301 USA. -# -# Forked from Orca screen reader. -# Cthulhu project: https://git.stormux.org/storm/cthulhu -# pylint: disable=wrong-import-position # pylint: disable=too-many-public-methods # pylint: disable=too-many-lines +# pylint: disable=too-many-instance-attributes +# pylint: disable=too-many-arguments +# pylint: disable=too-many-positional-arguments """Provides utilities for managing input events.""" -# This has to be the first non-docstring line in the module to make linters happy. from __future__ import annotations -__id__ = "$Id$" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2024 Igalia, S.L." \ - "Copyright (c) 2024 GNOME Foundation Inc." -__license__ = "LGPL" - -from typing import TYPE_CHECKING, Optional, Union, Tuple, List, Dict +from typing import TYPE_CHECKING import gi + gi.require_version("Atspi", "2.0") from gi.repository import Atspi -from gi.repository import GLib -from . import debug -from . import focus_manager -from . import input_event -from . import script_manager -from . import settings -from . import cthulhu_state -from .ax_object import AXObject +from . import ax_device_manager, debug, focus_manager, input_event, script_manager from .ax_utilities import AXUtilities if TYPE_CHECKING: from . import keybindings +_DOUBLE_CLICK_TIMEOUT: float = 0.5 + + class InputEventManager: """Provides utilities for managing input events.""" def __init__(self) -> None: - self._last_input_event: Optional[input_event.InputEvent] = None - self._last_non_modifier_key_event: Optional[input_event.KeyboardEvent] = None - self._device: Optional[Atspi.Device] = None - self._pointer_moved_id: int = 0 - self._mapped_keycodes: List[int] = [] - self._mapped_keysyms: List[int] = [] - self._grabbed_bindings: Dict[int, keybindings.KeyBinding] = {} + self._last_input_event: input_event.InputEvent | None = None + self._last_non_modifier_key_event: input_event.KeyboardEvent | None = None + self._device: Atspi.Device | None = None + self._mapped_keycodes: list[int] = [] + self._mapped_keysyms: list[int] = [] + self._grabbed_bindings: dict[int, keybindings.KeyBinding] = {} self._paused: bool = False - - def activate_device(self) -> Atspi.Device: - """Creates and returns the AT-SPI device used by this manager.""" - - if self._device is not None: - return self._device - - if Atspi.get_version() >= (2, 55, 90): - self._device = Atspi.Device.new_full("org.stormux.Cthulhu") - else: - self._device = Atspi.Device.new() - - return self._device - - def get_device(self) -> Optional[Atspi.Device]: - """Returns the active AT-SPI device, if any.""" - - return self._device + self._key_pressed_id: int = 0 + self._key_released_id: int = 0 def start_key_watcher(self) -> None: """Starts the watcher for keyboard input events.""" msg = "INPUT EVENT MANAGER: Starting key watcher." debug.print_message(debug.LEVEL_INFO, msg, True) - self.activate_device().add_key_watcher(self.process_keyboard_event) + self._device = ax_device_manager.get_manager().get_device() + + atspi_version = Atspi.get_version() # pylint: disable=no-value-for-parameter + if atspi_version[0] > 2 or atspi_version[1] >= 60: + self._key_pressed_id = self._device.connect("key-pressed", self._on_key_pressed) + self._key_released_id = self._device.connect("key-released", self._on_key_released) + else: + self._device.add_key_watcher(self.process_keyboard_event) def stop_key_watcher(self) -> None: - """Starts the watcher for keyboard input events.""" + """Stops the watcher for keyboard input events.""" msg = "INPUT EVENT MANAGER: Stopping key watcher." debug.print_message(debug.LEVEL_INFO, msg, True) - self._device = None - - def enable_pointer_monitoring(self) -> bool: - """Enables pointer monitoring on the current device, if possible.""" - - device = self.get_device() - if device is None: - return False - - deviceCapability = getattr(Atspi, "DeviceCapability", None) - if deviceCapability is None: - return False - - pointerMonitor = getattr(deviceCapability, "POINTER_MONITOR", None) - if pointerMonitor is None: - return False - - setCapabilities = getattr(device, "set_capabilities", None) - if not callable(setCapabilities): - return False - - currentCapabilities = 0 - getCapabilities = getattr(device, "get_capabilities", None) - if callable(getCapabilities): - currentCapabilities = getCapabilities() - - try: - grantedCapabilities = setCapabilities(currentCapabilities | pointerMonitor) - except GLib.GError: - return False - - if isinstance(grantedCapabilities, bool): - return grantedCapabilities - - try: - return bool(int(grantedCapabilities) & int(pointerMonitor)) - except (TypeError, ValueError): - return False - - def start_pointer_watcher(self, callback) -> None: - """Starts the watcher for pointer movement events.""" - - device = self.get_device() - if device is None: + if self._device is None: return - self._pointer_moved_id = device.connect("pointer-moved", callback) + atspi_version = Atspi.get_version() # pylint: disable=no-value-for-parameter + if atspi_version[0] > 2 or atspi_version[1] >= 60: + self._device.disconnect(self._key_pressed_id) + self._device.disconnect(self._key_released_id) + else: + self._device = None - def stop_pointer_watcher(self) -> None: - """Stops the watcher for pointer movement events.""" + def has_device(self) -> bool: + """Returns True if there is an active input device.""" - device = self.get_device() - if device is None or not self._pointer_moved_id: - self._pointer_moved_id = 0 - return - - device.disconnect(self._pointer_moved_id) - self._pointer_moved_id = 0 + return self._device is not None def pause_key_watcher(self, pause: bool = True, reason: str = "") -> None: """Pauses processing of keyboard input events.""" @@ -167,25 +100,17 @@ class InputEventManager: debug.print_message(debug.LEVEL_INFO, msg, True) self._paused = pause - def check_grabbed_bindings(self) -> None: - """Checks the grabbed key bindings.""" - - msg = f"INPUT EVENT MANAGER: {len(self._grabbed_bindings)} grabbed key bindings." - debug.print_message(debug.LEVEL_INFO, msg, True) - for grab_id, binding in self._grabbed_bindings.items(): - msg = f"INPUT EVENT MANAGER: {grab_id} for: {binding}" - debug.print_message(debug.LEVEL_INFO, msg, True) - - def add_grabs_for_keybinding(self, binding: keybindings.KeyBinding) -> List[int]: - """Adds grabs for binding if it is enabled, returns grab IDs.""" - - if not (binding.is_enabled() and binding.is_bound()): - return [] + def add_grabs_for_keybinding( + self, + binding: keybindings.KeyBinding, + cthulhu_modifiers: list[str], + ) -> list[int]: + """Adds grabs for binding, returns grab IDs.""" if binding.has_grabs(): tokens = ["INPUT EVENT MANAGER:", binding, "already has grabs."] debug.print_tokens(debug.LEVEL_INFO, tokens, True) - return [] + return binding.get_grab_ids() if self._device is None: tokens = ["INPUT EVENT MANAGER: No device to add grab for", binding] @@ -193,8 +118,12 @@ class InputEventManager: return [] grab_ids = [] - for kd in binding.key_definitions(): + for kd in binding.key_definitions(cthulhu_modifiers): grab_id = self._device.add_key_grab(kd, None) + # When we have double/triple-click bindings, the single-click binding will be + # registered first, and subsequent attempts to register what is externally the + # same grab will fail. If we only have a double/triple-click, it succeeds. + # A grab id of 0 indicates failure. if grab_id == 0: continue grab_ids.append(grab_id) @@ -212,8 +141,6 @@ class InputEventManager: grab_ids = binding.get_grab_ids() if not grab_ids: - tokens = ["INPUT EVENT MANAGER:", binding, "doesn't have grabs to remove."] - debug.print_tokens(debug.LEVEL_INFO, tokens, True) return for grab_id in grab_ids: @@ -223,22 +150,6 @@ class InputEventManager: msg = f"INPUT EVENT MANAGER: No key binding for grab id {grab_id}" debug.print_message(debug.LEVEL_INFO, msg, True) - def remove_grab_by_id(self, grab_id: int) -> None: - """Removes a grab by id.""" - - if self._device is None: - msg = f"INPUT EVENT MANAGER: No device to remove grab id {grab_id}" - debug.print_message(debug.LEVEL_INFO, msg, True) - return - - self._device.remove_key_grab(grab_id) - binding = self._grabbed_bindings.pop(grab_id, None) - if binding and grab_id in binding._grab_ids: - binding._grab_ids.remove(grab_id) - if binding: - tokens = ["INPUT EVENT MANAGER: Removed grab", grab_id, "for", binding] - debug.print_tokens(debug.LEVEL_INFO, tokens, True) - def map_keysym_to_modifier(self, keysym: int) -> int: """Maps keysym as a modifier, returns the newly-mapped modifier.""" @@ -250,17 +161,6 @@ class InputEventManager: self._mapped_keysyms.append(keysym) return self._device.map_keysym_modifier(keysym) - def map_keycode_to_modifier(self, keycode: int) -> int: - """Maps keycode as a modifier, returns the newly-mapped modifier.""" - - if self._device is None: - msg = f"INPUT EVENT MANAGER: No device to map keycode {keycode} to modifier" - debug.print_message(debug.LEVEL_INFO, msg, True) - return 0 - - self._mapped_keycodes.append(keycode) - return self._device.map_modifier(keycode) - def unmap_all_modifiers(self) -> None: """Unmaps all previously mapped modifiers.""" @@ -350,25 +250,26 @@ class InputEventManager: self._last_input_event = event self._last_non_modifier_key_event = None - @staticmethod - def _get_top_level_window(obj: Optional[Atspi.Accessible]) -> Optional[Atspi.Accessible]: - """Returns the top-level window containing obj, if one can be found.""" + def _on_key_pressed( + self, + _device: Atspi.Device, + keycode: int, + keysym: int, + modifiers: int, + text: str, + ) -> None: + self.process_keyboard_event(_device, True, keycode, keysym, modifiers, text) - if obj is None: - return None + def _on_key_released( + self, + _device: Atspi.Device, + keycode: int, + keysym: int, + modifiers: int, + text: str, + ) -> None: + self.process_keyboard_event(_device, False, keycode, keysym, modifiers, text) - if AXUtilities.is_frame(obj) or AXUtilities.is_window(obj) or AXUtilities.is_dialog_or_alert(obj): - return obj - - return AXObject.find_ancestor( - obj, - lambda x: AXUtilities.is_frame(x) - or AXUtilities.is_window(x) - or AXUtilities.is_dialog_or_alert(x), - ) - - # pylint: disable=too-many-arguments - # pylint: disable=too-many-positional-arguments def process_keyboard_event( self, _device: Atspi.Device, @@ -376,7 +277,7 @@ class InputEventManager: keycode: int, keysym: int, modifiers: int, - text: str + text: str, ) -> bool: """Processes this Atspi keyboard event.""" @@ -384,10 +285,6 @@ class InputEventManager: msg = "INPUT EVENT MANAGER: Keyboard event processing is paused." debug.print_message(debug.LEVEL_INFO, msg, True) return False - if cthulhu_state.capturingKeys: - msg = "INPUT EVENT MANAGER: Capturing keys; ignoring keyboard event." - debug.print_message(debug.LEVEL_INFO, msg, True) - return False event = input_event.KeyboardEvent(pressed, keycode, keysym, modifiers, text) if event in [self._last_input_event, self._last_non_modifier_key_event]: @@ -396,13 +293,9 @@ class InputEventManager: return False manager = focus_manager.get_manager() - pendingFocus = cthulhu_state.pendingSelfHostedFocus - if pendingFocus is not None: - tokens = ["INPUT EVENT MANAGER: Using pending self-hosted focus for keyboard event:", pendingFocus] - debug.print_tokens(debug.LEVEL_INFO, tokens, True) if pressed: window = manager.get_active_window() - if not AXUtilities.can_be_active_window(window, clear_cache=True): + if not AXUtilities.can_be_active_window(window): new_window = AXUtilities.find_active_window() if new_window is not None: window = new_window @@ -410,27 +303,11 @@ class InputEventManager: debug.print_tokens(debug.LEVEL_INFO, tokens, True) manager.set_active_window(window) else: - focus_window = self._get_top_level_window(pendingFocus or manager.get_locus_of_focus()) - if focus_window is not None: - window = focus_window - tokens = [ - "INPUT EVENT MANAGER: Recovering active window from locus of focus:", - window, - ] - debug.print_tokens(debug.LEVEL_INFO, tokens, True) - manager.set_active_window(window) - else: - # One example: Brave's popup menus live in frames which lack the active - # state. Failing to revalidate the window on a key press is inconclusive; - # do not wipe out the last known window and focus state. - tokens = [ - "WARNING:", - window, - "cannot be confirmed as active. No alternative found; preserving existing context.", - ] - debug.print_tokens(debug.LEVEL_WARNING, tokens, True) + # One example: Brave's popup menus live in frames which lack the active state. + tokens = ["WARNING:", window, "cannot be active window. No alternative found."] + debug.print_tokens(debug.LEVEL_WARNING, tokens, True) event.set_window(window) - event.set_object(pendingFocus or manager.get_locus_of_focus()) + event.set_object(manager.get_locus_of_focus()) event.set_script(script_manager.get_manager().get_active_script()) elif self.last_event_was_keyboard(): assert isinstance(self._last_input_event, input_event.KeyboardEvent) @@ -439,10 +316,9 @@ class InputEventManager: event.set_script(self._last_input_event.get_script()) else: event.set_window(manager.get_active_window()) - event.set_object(pendingFocus or manager.get_locus_of_focus()) + event.set_object(manager.get_locus_of_focus()) event.set_script(script_manager.get_manager().get_active_script()) - event._finalize_initialization() event.set_click_count(self._determine_keyboard_event_click_count(event)) event.process() @@ -456,9 +332,6 @@ class InputEventManager: self._last_input_event = event return True - # pylint: enable=too-many-arguments - # pylint: enable=too-many-positional-arguments - def _determine_keyboard_event_click_count(self, event: input_event.KeyboardEvent) -> int: """Determines the click count of event.""" @@ -471,19 +344,35 @@ class InputEventManager: last_event = self._last_non_modifier_key_event or self._last_input_event assert isinstance(last_event, input_event.KeyboardEvent) - if (event.time - last_event.time > settings.doubleClickTimeout) or \ - (event.keyval_name != last_event.keyval_name) or \ - (event.get_object() != last_event.get_object()): + if ( + (event.time - last_event.time > _DOUBLE_CLICK_TIMEOUT) + or (event.keyval_name != last_event.keyval_name) + or (event.get_object() != last_event.get_object()) + ): return 1 last_count = last_event.get_click_count() - if not event.is_pressed_key(): - return last_count - if last_event.is_pressed_key(): + if not event.is_pressed_key() or last_event.is_pressed_key(): return last_count if (event.is_modifier_key() and last_count == 2) or last_count == 3: return 1 - return last_count + 1 + + # Only increment click count if there are multi-click bindings for this key, + # OR if it's an Cthulhu modifier (double-click toggles the locking state). + # This prevents accidental "double-clicks" from breaking commands that only + # have single-click bindings. + if event.is_cthulhu_modifier(): + return last_count + 1 + + # pylint: disable-next=import-outside-toplevel + from . import command_manager + + has_multi_click = command_manager.get_manager().has_multi_click_bindings( + event.id, + event.hw_code, + event.modifiers, + ) + return last_count + 1 if has_multi_click else 1 def _determine_mouse_event_click_count(self, event: input_event.MouseButtonEvent) -> int: """Determines the click count of event.""" @@ -496,7 +385,7 @@ class InputEventManager: return self._last_input_event.get_click_count() if self._last_input_event.button != event.button: return 1 - if event.time - self._last_input_event.time > settings.doubleClickTimeout: + if event.time - self._last_input_event.time > _DOUBLE_CLICK_TIMEOUT: return 1 return self._last_input_event.get_click_count() + 1 @@ -511,22 +400,26 @@ class InputEventManager: return isinstance(self._last_input_event, input_event.MouseButtonEvent) - def is_release_for(self, event1: Optional[input_event.InputEvent], event2: Optional[input_event.InputEvent]) -> bool: + def is_release_for(self, event1, event2): """Returns True if event1 is a release for event2.""" if event1 is None or event2 is None: return False - if not isinstance(event1, input_event.KeyboardEvent) \ - or not isinstance(event2, input_event.KeyboardEvent): + if not isinstance(event1, input_event.KeyboardEvent) or not isinstance( + event2, + input_event.KeyboardEvent, + ): return False if event1.is_pressed_key() or not event2.is_pressed_key(): return False - result = event1.id == event2.id \ - and event1.hw_code == event2.hw_code \ + result = ( + event1.id == event2.id + and event1.hw_code == event2.hw_code and event1.keyval_name == event2.keyval_name + ) if result and not event1.is_modifier_key(): result = event1.modifiers == event2.modifiers @@ -538,7 +431,7 @@ class InputEventManager: debug.print_message(debug.LEVEL_INFO, msg, True) return result - def last_event_equals_or_is_release_for_event(self, event: Optional[input_event.InputEvent]) -> bool: + def last_event_equals_or_is_release_for_event(self, event): """Returns True if the last event equals the provided event, or is the release for it.""" if self._last_input_event is event: @@ -555,7 +448,7 @@ class InputEventManager: return self.is_release_for(self._last_non_modifier_key_event, event) - def _last_key_and_modifiers(self) -> Tuple[str, int]: + def _last_key_and_modifiers(self): """Returns the last keyval name and modifiers""" if self._last_non_modifier_key_event is None: @@ -564,12 +457,9 @@ class InputEventManager: if not self.last_event_was_keyboard(): return "", 0 - if self._last_input_event is None: - return self._last_non_modifier_key_event.keyval_name, 0 - return self._last_non_modifier_key_event.keyval_name, self._last_input_event.modifiers - def last_event_was_command(self) -> bool: + def last_event_was_command(self): """Returns True if the last event is believed to be a command.""" if bool(self._last_key_and_modifiers()[1] & 1 << Atspi.ModifierType.CONTROL): @@ -579,32 +469,25 @@ class InputEventManager: return False - def last_event_was_shortcut_for(self, obj: Atspi.Accessible) -> bool: + def last_event_was_shortcut_for(self, obj): """Returns True if the last event is believed to be a shortcut key for obj.""" string = self._last_key_and_modifiers()[0] if not string: return False - rv = False - keys = AXObject.get_action_key_binding(obj, 0).split(";") - for key in keys: - if key.endswith(string.upper()): - rv = True - break - + rv = AXUtilities.has_matching_shortcut(obj, string) if rv: tokens = ["INPUT EVENT MANAGER: Last event was shortcut for", obj] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return rv - def last_event_was_printable_key(self) -> bool: + def last_event_was_printable_key(self): """Returns True if the last event is believed to be a printable key.""" if not self.last_event_was_keyboard(): return False - assert isinstance(self._last_input_event, input_event.KeyboardEvent) if self._last_input_event.is_printable_key(): msg = "INPUT EVENT MANAGER: Last event was printable key" debug.print_message(debug.LEVEL_INFO, msg, True) @@ -612,17 +495,19 @@ class InputEventManager: return False - def last_event_was_caret_navigation(self) -> bool: + def last_event_was_caret_navigation(self): """Returns True if the last event is believed to be caret navigation.""" - return self.last_event_was_character_navigation() \ - or self.last_event_was_word_navigation() \ - or self.last_event_was_line_navigation() \ - or self.last_event_was_line_boundary_navigation() \ - or self.last_event_was_file_boundary_navigation() \ + return ( + self.last_event_was_character_navigation() + or self.last_event_was_word_navigation() + or self.last_event_was_line_navigation() + or self.last_event_was_line_boundary_navigation() + or self.last_event_was_file_boundary_navigation() or self.last_event_was_page_navigation() + ) - def last_event_was_caret_selection(self) -> bool: + def last_event_was_caret_selection(self): """Returns True if the last event is believed to be caret selection.""" string, mods = self._last_key_and_modifiers() @@ -636,7 +521,7 @@ class InputEventManager: debug.print_message(debug.LEVEL_INFO, msg, True) return rv - def last_event_was_backward_caret_navigation(self) -> bool: + def last_event_was_backward_caret_navigation(self): """Returns True if the last event is believed to be backward caret navigation.""" string, mods = self._last_key_and_modifiers() @@ -650,7 +535,7 @@ class InputEventManager: debug.print_message(debug.LEVEL_INFO, msg, True) return rv - def last_event_was_forward_caret_navigation(self) -> bool: + def last_event_was_forward_caret_navigation(self): """Returns True if the last event is believed to be forward caret navigation.""" string, mods = self._last_key_and_modifiers() @@ -664,7 +549,7 @@ class InputEventManager: debug.print_message(debug.LEVEL_INFO, msg, True) return rv - def last_event_was_forward_caret_selection(self) -> bool: + def last_event_was_forward_caret_selection(self): """Returns True if the last event is believed to be forward caret selection.""" string, mods = self._last_key_and_modifiers() @@ -678,13 +563,15 @@ class InputEventManager: debug.print_message(debug.LEVEL_INFO, msg, True) return rv - def last_event_was_character_navigation(self) -> bool: + def last_event_was_character_navigation(self): """Returns True if the last event is believed to be character navigation.""" string, mods = self._last_key_and_modifiers() - if string not in ["Left", "Right"]: - rv = False - elif mods & 1 << Atspi.ModifierType.CONTROL or mods & 1 << Atspi.ModifierType.ALT: + if ( + string not in ["Left", "Right"] + or mods & 1 << Atspi.ModifierType.CONTROL + or mods & 1 << Atspi.ModifierType.ALT + ): rv = False else: rv = True @@ -694,7 +581,7 @@ class InputEventManager: debug.print_message(debug.LEVEL_INFO, msg, True) return rv - def last_event_was_word_navigation(self) -> bool: + def last_event_was_word_navigation(self): """Returns True if the last event is believed to be word navigation.""" string, mods = self._last_key_and_modifiers() @@ -708,7 +595,7 @@ class InputEventManager: debug.print_message(debug.LEVEL_INFO, msg, True) return rv - def last_event_was_previous_word_navigation(self) -> bool: + def last_event_was_previous_word_navigation(self): """Returns True if the last event is believed to be previous-word navigation.""" string, mods = self._last_key_and_modifiers() @@ -722,7 +609,7 @@ class InputEventManager: debug.print_message(debug.LEVEL_INFO, msg, True) return rv - def last_event_was_next_word_navigation(self) -> bool: + def last_event_was_next_word_navigation(self): """Returns True if the last event is believed to be next-word navigation.""" string, mods = self._last_key_and_modifiers() @@ -736,13 +623,11 @@ class InputEventManager: debug.print_message(debug.LEVEL_INFO, msg, True) return rv - def last_event_was_line_navigation(self) -> bool: + def last_event_was_line_navigation(self): """Returns True if the last event is believed to be line navigation.""" string, mods = self._last_key_and_modifiers() - if string not in ["Up", "Down"]: - rv = False - elif mods & 1 << Atspi.ModifierType.CONTROL: + if string not in ["Up", "Down"] or mods & 1 << Atspi.ModifierType.CONTROL: rv = False else: focus = focus_manager.get_manager().get_locus_of_focus() @@ -756,7 +641,7 @@ class InputEventManager: debug.print_message(debug.LEVEL_INFO, msg, True) return rv - def last_event_was_paragraph_navigation(self) -> bool: + def last_event_was_paragraph_navigation(self): """Returns True if the last event is believed to be paragraph navigation.""" string, mods = self._last_key_and_modifiers() @@ -770,7 +655,7 @@ class InputEventManager: debug.print_message(debug.LEVEL_INFO, msg, True) return rv - def last_event_was_line_boundary_navigation(self) -> bool: + def last_event_was_line_boundary_navigation(self): """Returns True if the last event is believed to be navigation to start/end of line.""" string, mods = self._last_key_and_modifiers() @@ -784,7 +669,7 @@ class InputEventManager: debug.print_message(debug.LEVEL_INFO, msg, True) return rv - def last_event_was_file_boundary_navigation(self) -> bool: + def last_event_was_file_boundary_navigation(self): """Returns True if the last event is believed to be navigation to top/bottom of file.""" string, mods = self._last_key_and_modifiers() @@ -798,13 +683,11 @@ class InputEventManager: debug.print_message(debug.LEVEL_INFO, msg, True) return rv - def last_event_was_page_navigation(self) -> bool: + def last_event_was_page_navigation(self): """Returns True if the last event is believed to be page navigation.""" string, mods = self._last_key_and_modifiers() - if string not in ["Page_Up", "Page_Down"]: - rv = False - elif mods & 1 << Atspi.ModifierType.CONTROL: + if string not in ["Page_Up", "Page_Down"] or mods & 1 << Atspi.ModifierType.CONTROL: rv = False else: focus = focus_manager.get_manager().get_locus_of_focus() @@ -818,7 +701,7 @@ class InputEventManager: debug.print_message(debug.LEVEL_INFO, msg, True) return rv - def last_event_was_page_switch(self) -> bool: + def last_event_was_page_switch(self): """Returns True if the last event is believed to be a page switch.""" string, mods = self._last_key_and_modifiers() @@ -834,13 +717,15 @@ class InputEventManager: debug.print_message(debug.LEVEL_INFO, msg, True) return rv - def last_event_was_tab_navigation(self) -> bool: + def last_event_was_tab_navigation(self): """Returns True if the last event is believed to be Tab navigation.""" string, mods = self._last_key_and_modifiers() - if string not in ["Tab", "ISO_Left_Tab"]: - rv = False - elif mods & 1 << Atspi.ModifierType.CONTROL or mods & 1 << Atspi.ModifierType.ALT: + if ( + string not in ["Tab", "ISO_Left_Tab"] + or mods & 1 << Atspi.ModifierType.CONTROL + or mods & 1 << Atspi.ModifierType.ALT + ): rv = False else: rv = True @@ -850,7 +735,7 @@ class InputEventManager: debug.print_message(debug.LEVEL_INFO, msg, True) return rv - def last_event_was_table_sort(self) -> bool: + def last_event_was_table_sort(self): """Returns True if the last event is believed to be a table sort.""" focus = focus_manager.get_manager().get_locus_of_focus() @@ -868,102 +753,101 @@ class InputEventManager: debug.print_message(debug.LEVEL_INFO, msg, True) return rv - def last_event_was_unmodified_arrow(self) -> bool: + def last_event_was_unmodified_arrow(self): """Returns True if the last event is an unmodified arrow.""" string, mods = self._last_key_and_modifiers() if string not in ["Left", "Right", "Up", "Down"]: return False - if mods & 1 << Atspi.ModifierType.CONTROL \ - or mods & 1 << Atspi.ModifierType.SHIFT \ - or mods & 1 << Atspi.ModifierType.ALT: + if ( + mods & 1 << Atspi.ModifierType.CONTROL + or mods & 1 << Atspi.ModifierType.SHIFT + or mods & 1 << Atspi.ModifierType.ALT + ): return False - # TODO: JD - 8 is the value of keybindings.MODIFIER_ORCA, but we need to + # TODO: JD - 8 is the value of keybindings.MODIFIER_CTHULHU, but we need to # avoid a circular import. - if mods & 1 << 8: - return False + return not mods & 1 << 8 - return True - - def last_event_was_alt_modified(self) -> bool: + def last_event_was_alt_modified(self): """Returns True if the last event was alt-modified.""" mods = self._last_key_and_modifiers()[-1] - return bool(mods & 1 << Atspi.ModifierType.ALT) + return mods & 1 << Atspi.ModifierType.ALT - def last_event_was_backspace(self) -> bool: + def last_event_was_backspace(self): """Returns True if the last event is BackSpace.""" return self._last_key_and_modifiers()[0] == "BackSpace" - def last_event_was_down(self) -> bool: + def last_event_was_down(self): """Returns True if the last event is Down.""" return self._last_key_and_modifiers()[0] == "Down" - def last_event_was_f1(self) -> bool: + def last_event_was_f1(self): """Returns True if the last event is F1.""" return self._last_key_and_modifiers()[0] == "F1" - def last_event_was_left(self) -> bool: + def last_event_was_left(self): """Returns True if the last event is Left.""" return self._last_key_and_modifiers()[0] == "Left" - def last_event_was_left_or_right(self) -> bool: + def last_event_was_left_or_right(self): """Returns True if the last event is Left or Right.""" return self._last_key_and_modifiers()[0] in ["Left", "Right"] - def last_event_was_page_up_or_page_down(self) -> bool: + def last_event_was_page_up_or_page_down(self): """Returns True if the last event is Page_Up or Page_Down.""" return self._last_key_and_modifiers()[0] in ["Page_Up", "Page_Down"] - def last_event_was_right(self) -> bool: + def last_event_was_right(self): """Returns True if the last event is Right.""" return self._last_key_and_modifiers()[0] == "Right" - def last_event_was_return(self) -> bool: + def last_event_was_return(self): """Returns True if the last event is Return.""" return self._last_key_and_modifiers()[0] == "Return" - def last_event_was_return_or_space(self) -> bool: + def last_event_was_return_or_space(self): """Returns True if the last event is Return or space.""" return self._last_key_and_modifiers()[0] in ["Return", "space", " "] - def last_event_was_return_tab_or_space(self) -> bool: + def last_event_was_return_tab_or_space(self): """Returns True if the last event is Return, Tab, or space.""" return self._last_key_and_modifiers()[0] in ["Return", "Tab", "space", " "] - def last_event_was_space(self) -> bool: + def last_event_was_space(self): """Returns True if the last event is space.""" return self._last_key_and_modifiers()[0] in [" ", "space"] - def last_event_was_tab(self) -> bool: + def last_event_was_tab(self): """Returns True if the last event is Tab.""" return self._last_key_and_modifiers()[0] == "Tab" - def last_event_was_up(self) -> bool: + def last_event_was_up(self): """Returns True if the last event is Up.""" return self._last_key_and_modifiers()[0] == "Up" - def last_event_was_up_or_down(self) -> bool: + def last_event_was_up_or_down(self): """Returns True if the last event is Up or Down.""" return self._last_key_and_modifiers()[0] in ["Up", "Down"] - def last_event_was_delete(self) -> bool: + def last_event_was_delete(self): """Returns True if the last event is believed to be delete.""" string, mods = self._last_key_and_modifiers() @@ -979,7 +863,7 @@ class InputEventManager: debug.print_message(debug.LEVEL_INFO, msg, True) return rv - def last_event_was_cut(self) -> bool: + def last_event_was_cut(self): """Returns True if the last event is believed to be the cut command.""" string, mods = self._last_key_and_modifiers() @@ -992,39 +876,39 @@ class InputEventManager: return True return False - def last_event_was_copy(self) -> bool: + def last_event_was_copy(self): """Returns True if the last event is believed to be the copy command.""" string, mods = self._last_key_and_modifiers() if string.lower() != "c" or not mods & 1 << Atspi.ModifierType.CONTROL: rv = False elif AXUtilities.is_terminal(self._last_input_event.get_object()): - rv = bool(mods & 1 << Atspi.ModifierType.SHIFT) + rv = mods & 1 << Atspi.ModifierType.SHIFT else: - rv = not bool(mods & 1 << Atspi.ModifierType.SHIFT) + rv = not mods & 1 << Atspi.ModifierType.SHIFT if rv: msg = "INPUT EVENT MANAGER: Last event was copy" debug.print_message(debug.LEVEL_INFO, msg, True) return rv - def last_event_was_paste(self) -> bool: + def last_event_was_paste(self): """Returns True if the last event is believed to be the paste command.""" string, mods = self._last_key_and_modifiers() if string.lower() != "v" or not mods & 1 << Atspi.ModifierType.CONTROL: rv = False elif AXUtilities.is_terminal(self._last_input_event.get_object()): - rv = bool(mods & 1 << Atspi.ModifierType.SHIFT) + rv = mods & 1 << Atspi.ModifierType.SHIFT else: - rv = not bool(mods & 1 << Atspi.ModifierType.SHIFT) + rv = not mods & 1 << Atspi.ModifierType.SHIFT if rv: msg = "INPUT EVENT MANAGER: Last event was paste" debug.print_message(debug.LEVEL_INFO, msg, True) return rv - def last_event_was_undo(self) -> bool: + def last_event_was_undo(self): """Returns True if the last event is believed to be the undo command.""" string, mods = self._last_key_and_modifiers() @@ -1036,16 +920,15 @@ class InputEventManager: return True return False - def last_event_was_redo(self) -> bool: + def last_event_was_redo(self): """Returns True if the last event is believed to be the redo command.""" string, mods = self._last_key_and_modifiers() if string.lower() == "z": - rv = bool(mods & 1 << Atspi.ModifierType.CONTROL and mods & 1 << Atspi.ModifierType.SHIFT) + rv = mods & 1 << Atspi.ModifierType.CONTROL and mods & 1 << Atspi.ModifierType.SHIFT elif string.lower() == "y": # LibreOffice - rv = bool(mods & 1 << Atspi.ModifierType.CONTROL \ - and not mods & 1 << Atspi.ModifierType.SHIFT) + rv = mods & 1 << Atspi.ModifierType.CONTROL and not mods & 1 << Atspi.ModifierType.SHIFT else: rv = False @@ -1054,20 +937,20 @@ class InputEventManager: debug.print_message(debug.LEVEL_INFO, msg, True) return rv - def last_event_was_select_all(self) -> bool: + def last_event_was_select_all(self): """Returns True if the last event is believed to be the select all command.""" string, mods = self._last_key_and_modifiers() if string.lower() != "a": return False - if (mods & 1 << Atspi.ModifierType.CONTROL and not mods & 1 << Atspi.ModifierType.SHIFT): + if mods & 1 << Atspi.ModifierType.CONTROL and not mods & 1 << Atspi.ModifierType.SHIFT: msg = "INPUT EVENT MANAGER: Last event was select all" debug.print_message(debug.LEVEL_INFO, msg, True) return True return False - def last_event_was_primary_click(self) -> bool: + def last_event_was_primary_click(self): """Returns True if the last event is a primary mouse click.""" if not self.last_event_was_mouse_button(): @@ -1079,7 +962,7 @@ class InputEventManager: return True return False - def last_event_was_primary_release(self) -> bool: + def last_event_was_primary_release(self): """Returns True if the last event is a primary mouse release.""" if not self.last_event_was_mouse_button(): @@ -1091,7 +974,7 @@ class InputEventManager: return True return False - def last_event_was_primary_click_or_release(self) -> bool: + def last_event_was_primary_click_or_release(self): """Returns True if the last event is a primary mouse click or release.""" if not self.last_event_was_mouse_button(): @@ -1103,7 +986,7 @@ class InputEventManager: return True return False - def last_event_was_middle_click(self) -> bool: + def last_event_was_middle_click(self): """Returns True if the last event is a middle mouse click.""" if not self.last_event_was_mouse_button(): @@ -1115,7 +998,7 @@ class InputEventManager: return True return False - def last_event_was_middle_release(self) -> bool: + def last_event_was_middle_release(self): """Returns True if the last event is a middle mouse release.""" if not self.last_event_was_mouse_button(): @@ -1127,7 +1010,7 @@ class InputEventManager: return True return False - def last_event_was_secondary_click(self) -> bool: + def last_event_was_secondary_click(self): """Returns True if the last event is a secondary mouse click.""" if not self.last_event_was_mouse_button(): @@ -1139,7 +1022,7 @@ class InputEventManager: return True return False - def last_event_was_secondary_release(self) -> bool: + def last_event_was_secondary_release(self): """Returns True if the last event is a secondary mouse release.""" if not self.last_event_was_mouse_button(): @@ -1152,10 +1035,9 @@ class InputEventManager: return False -_manager: Optional[InputEventManager] = None -if _manager is None: - _manager = InputEventManager() +_manager = InputEventManager() -def get_manager() -> InputEventManager: + +def get_manager(): """Returns the Input Event Manager singleton.""" return _manager diff --git a/src/cthulhu/meson.build b/src/cthulhu/meson.build index fb01849..377ea22 100644 --- a/src/cthulhu/meson.build +++ b/src/cthulhu/meson.build @@ -44,6 +44,7 @@ cthulhu_python_sources = files([ 'clipboard.py', 'cmdnames.py', 'colornames.py', + 'command_manager.py', 'compositor_state_adapter.py', 'compositor_state_types.py', 'compositor_state_wayland.py', diff --git a/tests/conftest.py b/tests/conftest.py index 790b160..da8c5ec 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -73,6 +73,7 @@ from cthulhu_test_fixtures import test_context # noqa: E402,F401 def clean_all_cthulhu_modules() -> None: + cthulhu_package = sys.modules.get("cthulhu") modules_to_remove = [ module_name for module_name in sys.modules @@ -81,6 +82,15 @@ def clean_all_cthulhu_modules() -> None: ] for module_name in modules_to_remove: sys.modules.pop(module_name, None) + if cthulhu_package is not None: + attr_name = module_name.rsplit(".", 1)[-1] + if hasattr(cthulhu_package, attr_name): + delattr(cthulhu_package, attr_name) + if cthulhu_package is not None: + for attr_name in list(vars(cthulhu_package)): + if attr_name.startswith("_") or attr_name in {"cthulhu_i18n", "cthulhu_platform"}: + continue + delattr(cthulhu_package, attr_name) def pytest_configure(config: pytest.Config) -> None: diff --git a/tests/cthulhu_test_context.py b/tests/cthulhu_test_context.py index 8ca1167..4b5475f 100644 --- a/tests/cthulhu_test_context.py +++ b/tests/cthulhu_test_context.py @@ -83,7 +83,13 @@ class CthulhuTestContext: def patch_module(self, module_name: str, mock_module: Any) -> MagicMock: """Convenience method for patching sys.modules entries.""" - return self.mocker.patch.dict(sys.modules, {module_name: mock_module}) + patch = self.mocker.patch.dict(sys.modules, {module_name: mock_module}) + if "." in module_name: + package_name, attr_name = module_name.rsplit(".", 1) + package = sys.modules.get(package_name) + if package is not None: + self.monkeypatch.setattr(package, attr_name, mock_module, raising=False) + return patch def patch_modules(self, modules: dict[str, Any]) -> MagicMock: """Convenience method for patching multiple sys.modules entries.""" @@ -140,6 +146,7 @@ class CthulhuTestContext: "cthulhu.focus_manager", "cthulhu.braille", "cthulhu.cthulhu_platform", + "cthulhu.presentation_manager", ] if additional_modules: diff --git a/tests/test_command_manager.py b/tests/test_command_manager.py new file mode 100644 index 0000000..e6d9490 --- /dev/null +++ b/tests/test_command_manager.py @@ -0,0 +1,1417 @@ +# Unit tests for command_manager.py methods. +# +# 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=wrong-import-position +# pylint: disable=import-outside-toplevel +# pylint: disable=protected-access +# pylint: disable=too-many-lines +# pylint: disable=too-many-arguments +# pylint: disable=too-many-positional-arguments + +"""Unit tests for command_manager.py methods.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from unittest.mock import Mock + + from cthulhu_test_context import CthulhuTestContext + + +@pytest.mark.unit +class TestCommand: + """Test Command base class methods.""" + + def _setup_dependencies(self, test_context: CthulhuTestContext) -> dict[str, Mock]: + """Returns dependencies for command_manager module testing.""" + + essential_modules = test_context.setup_shared_dependencies([]) + + input_event_mock = essential_modules["cthulhu.input_event"] + input_event_mock.InputEventHandler = test_context.Mock + + keybindings_mock = essential_modules["cthulhu.keybindings"] + keybindings_mock.KeyBinding = test_context.Mock + keybindings_mock.KeyBindings = test_context.Mock + + return essential_modules + + def _create_mock_function(self, test_context: CthulhuTestContext) -> Mock: + """Creates a mock function for a Command.""" + + function = test_context.Mock() + function.return_value = True + return function + + def test_init_minimal(self, test_context: CthulhuTestContext) -> None: + """Test Command.__init__ with minimal arguments.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import Command + + function = self._create_mock_function(test_context) + command = Command("testCommand", function, "Test Group") + + assert command.get_name() == "testCommand" + assert command.get_function() == function + assert command.get_group_label() == "Test Group" + assert command.get_description() == "" + + def test_init_full(self, test_context: CthulhuTestContext) -> None: + """Test Command.__init__ with all arguments.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import Command + + function = self._create_mock_function(test_context) + command = Command( + "fullCommand", + function, + "Full Group", + "Full description", + enabled=False, + suspended=True, + ) + + assert command.get_name() == "fullCommand" + assert command.get_function() == function + assert command.get_group_label() == "Full Group" + assert command.get_description() == "Full description" + assert command.is_enabled() is False + assert command.is_suspended() is True + + def test_set_group_label(self, test_context: CthulhuTestContext) -> None: + """Test Command.set_group_label.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import Command + + function = self._create_mock_function(test_context) + command = Command("testCommand", function, "Original Group") + + assert command.get_group_label() == "Original Group" + + command.set_group_label("New Group") + assert command.get_group_label() == "New Group" + + def test_init_enabled_suspended_defaults(self, test_context: CthulhuTestContext) -> None: + """Test that enabled defaults to True and suspended defaults to False.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import Command + + function = self._create_mock_function(test_context) + command = Command("testCommand", function, "Test Group") + + assert command.is_enabled() is True + assert command.is_suspended() is False + + def test_init_enabled_suspended_explicit(self, test_context: CthulhuTestContext) -> None: + """Test Command.__init__ with explicit enabled and suspended values.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import Command + + function = self._create_mock_function(test_context) + command = Command("testCommand", function, "Test Group", enabled=False, suspended=True) + + assert command.is_enabled() is False + assert command.is_suspended() is True + + def test_set_enabled(self, test_context: CthulhuTestContext) -> None: + """Test Command.set_enabled.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import Command + + function = self._create_mock_function(test_context) + command = Command("testCommand", function, "Test Group") + + assert command.is_enabled() is True + + command.set_enabled(False) + assert command.is_enabled() is False + + command.set_enabled(True) + assert command.is_enabled() is True + + def test_set_suspended(self, test_context: CthulhuTestContext) -> None: + """Test Command.set_suspended.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import Command + + function = self._create_mock_function(test_context) + command = Command("testCommand", function, "Test Group") + + assert command.is_suspended() is False + + command.set_suspended(True) + assert command.is_suspended() is True + + command.set_suspended(False) + assert command.is_suspended() is False + + def test_execute_calls_function(self, test_context: CthulhuTestContext) -> None: + """Test that execute calls the command's function.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import Command + + function = self._create_mock_function(test_context) + function.return_value = True + command = Command("testCommand", function, "Test Group") + + mock_script = test_context.Mock() + mock_event = test_context.Mock() + + result = command.execute(mock_script, mock_event) + + function.assert_called_once_with(mock_script, mock_event) + assert result is True + + def test_execute_returns_function_result(self, test_context: CthulhuTestContext) -> None: + """Test that execute returns the function's return value.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import Command + + function = self._create_mock_function(test_context) + function.return_value = False + command = Command("testCommand", function, "Test Group") + + mock_script = test_context.Mock() + + result = command.execute(mock_script, None) + + assert result is False + + +@pytest.mark.unit +class TestKeyboardCommand: + """Test KeyboardCommand class methods.""" + + def _setup_dependencies(self, test_context: CthulhuTestContext) -> dict[str, Mock]: + """Returns dependencies for command_manager module testing.""" + + essential_modules = test_context.setup_shared_dependencies([]) + + input_event_mock = essential_modules["cthulhu.input_event"] + input_event_mock.InputEventHandler = test_context.Mock + + keybindings_mock = essential_modules["cthulhu.keybindings"] + keybindings_mock.KeyBinding = test_context.Mock + keybindings_mock.KeyBindings = test_context.Mock + + return essential_modules + + def _create_mock_function(self, test_context: CthulhuTestContext) -> Mock: + """Creates a mock function for a Command.""" + + function = test_context.Mock() + function.return_value = True + return function + + def _create_mock_keybinding( + self, + test_context: CthulhuTestContext, + keyval: int = 65, + keycode: int = 38, + ) -> Mock: + """Creates a mock KeyBinding with default keyval/keycode for indexing.""" + + kb = test_context.Mock() + kb.keyval = keyval + kb.keycode = keycode + return kb + + def test_init_minimal(self, test_context: CthulhuTestContext) -> None: + """Test KeyboardCommand.__init__ with minimal arguments.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import KeyboardCommand + + function = self._create_mock_function(test_context) + command = KeyboardCommand("testCommand", function, "Test Group") + + assert command.get_name() == "testCommand" + assert command.get_function() == function + assert command.get_group_label() == "Test Group" + assert command.get_description() == "" + assert command.get_keybinding() is None + + def test_init_with_keybindings(self, test_context: CthulhuTestContext) -> None: + """Test KeyboardCommand.__init__ with keybindings.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import KeyboardCommand + + function = self._create_mock_function(test_context) + desktop_kb = self._create_mock_keybinding(test_context) + laptop_kb = self._create_mock_keybinding(test_context) + + command = KeyboardCommand( + "fullCommand", + function, + "Full Group", + "Full description", + desktop_keybinding=desktop_kb, + laptop_keybinding=laptop_kb, + ) + + assert command.get_name() == "fullCommand" + assert command.get_default_keybinding(is_desktop=True) == desktop_kb + assert command.get_default_keybinding(is_desktop=False) == laptop_kb + + def test_set_keybinding(self, test_context: CthulhuTestContext) -> None: + """Test KeyboardCommand.set_keybinding.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import KeyboardCommand + + function = self._create_mock_function(test_context) + command = KeyboardCommand("testCommand", function, "Test Group") + + assert command.get_keybinding() is None + + new_kb = self._create_mock_keybinding(test_context) + command.set_keybinding(new_kb) + assert command.get_keybinding() == new_kb + + command.set_keybinding(None) + assert command.get_keybinding() is None + + def test_is_active_enabled_not_suspended_with_keybinding( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test is_active returns True when enabled, not suspended, and has keybinding.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import KeyboardCommand + + function = self._create_mock_function(test_context) + keybinding = self._create_mock_keybinding(test_context) + command = KeyboardCommand( + "testCommand", + function, + "Test Group", + desktop_keybinding=keybinding, + ) + command.set_keybinding(keybinding) + + assert command.is_active() is True + + def test_is_active_disabled(self, test_context: CthulhuTestContext) -> None: + """Test is_active returns False when disabled.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import KeyboardCommand + + function = self._create_mock_function(test_context) + keybinding = self._create_mock_keybinding(test_context) + command = KeyboardCommand( + "testCommand", + function, + "Test Group", + desktop_keybinding=keybinding, + enabled=False, + ) + command.set_keybinding(keybinding) + + assert command.is_active() is False + + def test_is_active_suspended(self, test_context: CthulhuTestContext) -> None: + """Test is_active returns False when suspended.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import KeyboardCommand + + function = self._create_mock_function(test_context) + keybinding = self._create_mock_keybinding(test_context) + command = KeyboardCommand( + "testCommand", + function, + "Test Group", + desktop_keybinding=keybinding, + suspended=True, + ) + command.set_keybinding(keybinding) + + assert command.is_active() is False + + def test_is_active_no_keybinding(self, test_context: CthulhuTestContext) -> None: + """Test is_active returns False when no keybinding is assigned.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import KeyboardCommand + + function = self._create_mock_function(test_context) + command = KeyboardCommand("testCommand", function, "Test Group") + + assert command.is_active() is False + + def test_is_group_toggle_init(self, test_context: CthulhuTestContext) -> None: + """Test KeyboardCommand initializes is_group_toggle correctly.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import KeyboardCommand + + function = self._create_mock_function(test_context) + + # Default is False + cmd_default = KeyboardCommand("cmd1", function, "Test Group") + assert cmd_default.is_group_toggle() is False + + # Explicit True + cmd_toggle = KeyboardCommand("cmd2", function, "Test Group", is_group_toggle=True) + assert cmd_toggle.is_group_toggle() is True + + +@pytest.mark.unit +class TestBrailleCommand: + """Test BrailleCommand class methods.""" + + def _setup_dependencies(self, test_context: CthulhuTestContext) -> dict[str, Mock]: + """Returns dependencies for command_manager module testing.""" + + essential_modules = test_context.setup_shared_dependencies([]) + + input_event_mock = essential_modules["cthulhu.input_event"] + input_event_mock.InputEventHandler = test_context.Mock + + keybindings_mock = essential_modules["cthulhu.keybindings"] + keybindings_mock.KeyBinding = test_context.Mock + keybindings_mock.KeyBindings = test_context.Mock + + return essential_modules + + def _create_mock_function(self, test_context: CthulhuTestContext) -> Mock: + """Creates a mock function for a Command.""" + + function = test_context.Mock() + function.return_value = True + return function + + def test_braille_bindings_default_empty(self, test_context: CthulhuTestContext) -> None: + """Test that braille bindings default to empty tuple.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import BrailleCommand + + function = self._create_mock_function(test_context) + command = BrailleCommand("testCommand", function, "Test Group") + + assert command.get_braille_bindings() == () + + def test_init_with_braille_bindings(self, test_context: CthulhuTestContext) -> None: + """Test BrailleCommand.__init__ with braille bindings.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import BrailleCommand + + function = self._create_mock_function(test_context) + command = BrailleCommand( + "testCommand", + function, + "Test Group", + "Test description", + braille_bindings=(1, 2, 3), + ) + + assert command.get_braille_bindings() == (1, 2, 3) + + +@pytest.mark.unit +class TestCommandManager: + """Test CommandManager class methods.""" + + def _setup_dependencies(self, test_context: CthulhuTestContext) -> dict[str, Mock]: + """Returns dependencies for command_manager module testing.""" + + essential_modules = test_context.setup_shared_dependencies([]) + + input_event_mock = essential_modules["cthulhu.input_event"] + input_event_mock.InputEventHandler = test_context.Mock + + keybindings_mock = essential_modules["cthulhu.keybindings"] + keybindings_mock.KeyBinding = test_context.Mock + keybindings_mock.KeyBindings = test_context.Mock + + from cthulhu import gsettings_registry + + gsettings_registry.get_registry().clear_runtime_values() + + return essential_modules + + def _create_mock_function( + self, + test_context: CthulhuTestContext, + _description: str = "Test function", + ) -> Mock: + """Creates a mock function for a Command.""" + + function = test_context.Mock() + function.return_value = True + return function + + def _create_mock_keybinding( + self, + test_context: CthulhuTestContext, + keyval: int = 65, + keycode: int = 38, + ) -> Mock: + """Creates a mock KeyBinding with default keyval/keycode for indexing.""" + + kb = test_context.Mock() + kb.keyval = keyval + kb.keycode = keycode + kb.keysymstring = "a" + return kb + + def test_init(self, test_context: CthulhuTestContext) -> None: + """Test CommandManager.__init__.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager + + manager = CommandManager() + assert not manager.get_all_keyboard_commands() + assert not manager.get_all_braille_commands() + + def test_add_and_get_keyboard_command(self, test_context: CthulhuTestContext) -> None: + """Test CommandManager.add_command and get_keyboard_command.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, KeyboardCommand + + manager = CommandManager() + function = self._create_mock_function(test_context) + command = KeyboardCommand("testCommand", function, "Test Group") + + manager.add_command(command) + + retrieved = manager.get_keyboard_command("testCommand") + assert retrieved == command + + assert manager.get_keyboard_command("nonexistent") is None + + def test_get_all_keyboard_commands(self, test_context: CthulhuTestContext) -> None: + """Test CommandManager.get_all_keyboard_commands.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, KeyboardCommand + + manager = CommandManager() + + function1 = self._create_mock_function(test_context) + function2 = self._create_mock_function(test_context) + function3 = self._create_mock_function(test_context) + + cmd1 = KeyboardCommand("cmd1", function1, "Group A") + cmd2 = KeyboardCommand("cmd2", function2, "Group B") + cmd3 = KeyboardCommand("cmd3", function3, "Group A") + + manager.add_command(cmd1) + manager.add_command(cmd2) + manager.add_command(cmd3) + + all_commands = manager.get_all_keyboard_commands() + assert len(all_commands) == 3 + assert cmd1 in all_commands + assert cmd2 in all_commands + assert cmd3 in all_commands + + def test_get_command_for_event_finds_match(self, test_context: CthulhuTestContext) -> None: + """Test get_command_for_event returns matching command.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, KeyboardCommand + + manager = CommandManager() + + function = self._create_mock_function(test_context) + kb = self._create_mock_keybinding(test_context) + kb.matches.return_value = True + kb.modifiers = 4 + kb.click_count = 1 + + cmd = KeyboardCommand("cmd", function, "Test Group", desktop_keybinding=kb) + cmd.set_keybinding(kb) + manager.add_command(cmd) + + event = test_context.Mock() + event.get_click_count.return_value = 1 + event.id = 65 + event.hw_code = 38 + event.modifiers = 4 + event.is_keypad_key_with_numlock_on.return_value = False + + result = manager.get_command_for_event(event) + assert result == cmd + + def test_get_command_for_event_no_match(self, test_context: CthulhuTestContext) -> None: + """Test get_command_for_event returns None when no match.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, KeyboardCommand + + manager = CommandManager() + + function = self._create_mock_function(test_context) + kb = self._create_mock_keybinding(test_context) + kb.matches.return_value = False + + cmd = KeyboardCommand("cmd", function, "Test Group", desktop_keybinding=kb) + cmd.set_keybinding(kb) + manager.add_command(cmd) + + event = test_context.Mock() + event.get_click_count.return_value = 1 + event.id = 65 + event.hw_code = 38 + event.modifiers = 4 + + result = manager.get_command_for_event(event) + assert result is None + + def test_get_command_for_event_skips_inactive(self, test_context: CthulhuTestContext) -> None: + """Test get_command_for_event skips inactive commands by default.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, KeyboardCommand + + manager = CommandManager() + + function = self._create_mock_function(test_context) + kb = self._create_mock_keybinding(test_context) + kb.matches.return_value = True + kb.modifiers = 4 + kb.click_count = 1 + + cmd = KeyboardCommand("cmd", function, "Test Group", desktop_keybinding=kb, suspended=True) + cmd.set_keybinding(kb) + manager.add_command(cmd) + + event = test_context.Mock() + event.get_click_count.return_value = 1 + event.id = 65 + event.hw_code = 38 + event.modifiers = 4 + + result = manager.get_command_for_event(event) + assert result is None + + def test_get_command_for_event_includes_inactive(self, test_context: CthulhuTestContext) -> None: + """Test get_command_for_event can include inactive commands.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, KeyboardCommand + + manager = CommandManager() + + function = self._create_mock_function(test_context) + kb = self._create_mock_keybinding(test_context) + kb.matches.return_value = True + kb.modifiers = 4 + kb.click_count = 1 + + cmd = KeyboardCommand("cmd", function, "Test Group", desktop_keybinding=kb, suspended=True) + cmd.set_keybinding(kb) + manager.add_command(cmd) + + event = test_context.Mock() + event.get_click_count.return_value = 1 + event.id = 65 + event.hw_code = 38 + event.modifiers = 4 + event.is_keypad_key_with_numlock_on.return_value = False + + result = manager.get_command_for_event(event, active_only=False) + assert result == cmd + + def test_get_command_for_braille_event_finds_match(self, test_context: CthulhuTestContext) -> None: + """Test get_command_for_braille_event returns matching command.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import BrailleCommand, CommandManager + + manager = CommandManager() + + function = self._create_mock_function(test_context) + cmd = BrailleCommand("cmd", function, "Test Group", braille_bindings=(100, 200, 300)) + manager.add_command(cmd) + + result = manager.get_command_for_braille_event(200) + assert result == cmd + + def test_get_command_for_braille_event_no_match(self, test_context: CthulhuTestContext) -> None: + """Test get_command_for_braille_event returns None when no match.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import BrailleCommand, CommandManager + + manager = CommandManager() + + function = self._create_mock_function(test_context) + cmd = BrailleCommand("cmd", function, "Test Group", braille_bindings=(100, 200, 300)) + manager.add_command(cmd) + + result = manager.get_command_for_braille_event(999) + assert result is None + + def test_get_command_for_braille_event_empty_bindings( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test get_command_for_braille_event returns None when command has no braille bindings.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import BrailleCommand, CommandManager + + manager = CommandManager() + + function = self._create_mock_function(test_context) + cmd = BrailleCommand("cmd", function, "Test Group") + manager.add_command(cmd) + + result = manager.get_command_for_braille_event(100) + assert result is None + + def test_set_group_enabled(self, test_context: CthulhuTestContext) -> None: + """Test set_group_enabled sets enabled state for all commands in group.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, KeyboardCommand + + manager = CommandManager() + + function1 = self._create_mock_function(test_context) + function2 = self._create_mock_function(test_context) + function3 = self._create_mock_function(test_context) + + cmd1 = KeyboardCommand("cmd1", function1, "Group A") + cmd2 = KeyboardCommand("cmd2", function2, "Group A") + cmd3 = KeyboardCommand("cmd3", function3, "Group B") + + manager.add_command(cmd1) + manager.add_command(cmd2) + manager.add_command(cmd3) + + manager.set_group_enabled("Group A", False) + + assert cmd1.is_enabled() is False + assert cmd2.is_enabled() is False + assert cmd3.is_enabled() is True + + def test_set_group_suspended(self, test_context: CthulhuTestContext) -> None: + """Test set_group_suspended sets suspended state for all commands in group.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, KeyboardCommand + + manager = CommandManager() + + function1 = self._create_mock_function(test_context) + function2 = self._create_mock_function(test_context) + function3 = self._create_mock_function(test_context) + + cmd1 = KeyboardCommand("cmd1", function1, "Group A") + cmd2 = KeyboardCommand("cmd2", function2, "Group A") + cmd3 = KeyboardCommand("cmd3", function3, "Group B") + + manager.add_command(cmd1) + manager.add_command(cmd2) + manager.add_command(cmd3) + + manager.set_group_suspended("Group A", True) + + assert cmd1.is_suspended() is True + assert cmd2.is_suspended() is True + assert cmd3.is_suspended() is False + + def test_has_multi_click_bindings_true(self, test_context: CthulhuTestContext) -> None: + """Test has_multi_click_bindings returns True when multi-click binding exists.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, KeyboardCommand + + manager = CommandManager() + + function = self._create_mock_function(test_context) + + # Single-click binding (KP_Up keyval = 65431, keycode = 80) + kb1 = self._create_mock_keybinding(test_context, keyval=65431, keycode=80) + kb1.matches.return_value = True + kb1.click_count = 1 + cmd1 = KeyboardCommand("readLine", function, "Flat Review", desktop_keybinding=kb1) + cmd1.set_keybinding(kb1) + manager.add_command(cmd1) + + # Double-click binding for same key + kb2 = self._create_mock_keybinding(test_context, keyval=65431, keycode=80) + kb2.matches.return_value = True + kb2.click_count = 2 + cmd2 = KeyboardCommand("spellLine", function, "Flat Review", desktop_keybinding=kb2) + cmd2.set_keybinding(kb2) + manager.add_command(cmd2) + + assert manager.has_multi_click_bindings(65431, 80, 0) is True + + def test_has_multi_click_bindings_false_single_only( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test has_multi_click_bindings returns False when only single-click binding exists.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, KeyboardCommand + + manager = CommandManager() + + function = self._create_mock_function(test_context) + + # Only single-click binding (KP_Home keyval = 65429, keycode = 79) + kb = self._create_mock_keybinding(test_context, keyval=65429, keycode=79) + kb.matches.return_value = True + kb.click_count = 1 + cmd = KeyboardCommand("previousLine", function, "Flat Review", desktop_keybinding=kb) + cmd.set_keybinding(kb) + manager.add_command(cmd) + + assert manager.has_multi_click_bindings(65429, 79, 0) is False + + def test_has_multi_click_bindings_false_no_bindings( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test has_multi_click_bindings returns False when no bindings exist for key.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager + + manager = CommandManager() + + # KP_Home keyval = 65429, keycode = 79 + assert manager.has_multi_click_bindings(65429, 79, 0) is False + + def test_has_multi_click_bindings_respects_modifiers( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test has_multi_click_bindings distinguishes by modifiers.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, KeyboardCommand + + manager = CommandManager() + + function = self._create_mock_function(test_context) + + # Double-click binding with Cthulhu modifier (KP_Up keyval = 65431, keycode = 80) + kb = self._create_mock_keybinding(test_context, keyval=65431, keycode=80) + # matches() returns True only when modifiers=256 + kb.matches.side_effect = lambda kv, kc, mods: mods == 256 + kb.click_count = 2 + cmd = KeyboardCommand("someCommand", function, "Test", desktop_keybinding=kb) + cmd.set_keybinding(kb) + manager.add_command(cmd) + + # With modifier: has multi-click + assert manager.has_multi_click_bindings(65431, 80, 256) is True + # Without modifier: no multi-click bindings + assert manager.has_multi_click_bindings(65431, 80, 0) is False + + def test_get_command_for_event_shifted_key(self, test_context: CthulhuTestContext) -> None: + """Test get_command_for_event finds command via keycode when keyval differs (shifted).""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, KeyboardCommand + + manager = CommandManager() + + function = self._create_mock_function(test_context) + # Command bound to 'h' (keyval=104, keycode=38) + kb = self._create_mock_keybinding(test_context, keyval=104, keycode=38) + kb.matches.return_value = True + kb.click_count = 1 + cmd = KeyboardCommand("prevHeading", function, "Structural Nav", desktop_keybinding=kb) + cmd.set_keybinding(kb) + manager.add_command(cmd) + + # Event has 'H' keyval (72) due to Shift, but same keycode (38) + event = test_context.Mock() + event.get_click_count.return_value = 1 + event.id = 72 # 'H' keyval (shifted) + event.hw_code = 38 # Same keycode as 'h' + event.modifiers = 1 # Shift + event.is_keypad_key_with_numlock_on.return_value = False + + result = manager.get_command_for_event(event) + assert result == cmd + + def test_get_command_for_event_unshifted_key(self, test_context: CthulhuTestContext) -> None: + """Test get_command_for_event finds command via keyval for unshifted key.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, KeyboardCommand + + manager = CommandManager() + + function = self._create_mock_function(test_context) + # Command bound to 'h' (keyval=104, keycode=38) + kb = self._create_mock_keybinding(test_context, keyval=104, keycode=38) + kb.matches.return_value = True + kb.click_count = 1 + cmd = KeyboardCommand("nextHeading", function, "Structural Nav", desktop_keybinding=kb) + cmd.set_keybinding(kb) + manager.add_command(cmd) + + # Event has 'h' keyval (104), no shift + event = test_context.Mock() + event.get_click_count.return_value = 1 + event.id = 104 # 'h' keyval (unshifted) + event.hw_code = 38 + event.modifiers = 0 + event.is_keypad_key_with_numlock_on.return_value = False + + result = manager.get_command_for_event(event) + assert result == cmd + + def test_has_multi_click_bindings_shifted_key(self, test_context: CthulhuTestContext) -> None: + """Test has_multi_click_bindings finds binding via keycode when keyval differs.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, KeyboardCommand + + manager = CommandManager() + + function = self._create_mock_function(test_context) + # Single-click binding for 'h' (keyval=104, keycode=38) + kb1 = self._create_mock_keybinding(test_context, keyval=104, keycode=38) + kb1.matches.return_value = True + kb1.click_count = 1 + cmd1 = KeyboardCommand("nextHeading", function, "Structural Nav", desktop_keybinding=kb1) + cmd1.set_keybinding(kb1) + manager.add_command(cmd1) + + # Double-click binding for same key + kb2 = self._create_mock_keybinding(test_context, keyval=104, keycode=38) + kb2.matches.return_value = True + kb2.click_count = 2 + cmd2 = KeyboardCommand("listHeadings", function, "Structural Nav", desktop_keybinding=kb2) + cmd2.set_keybinding(kb2) + manager.add_command(cmd2) + + # Query with 'H' keyval (72) due to Shift, but same keycode (38) + # Should find multi-click binding via keycode + assert manager.has_multi_click_bindings(72, 38, 1) is True + + def test_has_multi_click_bindings_unshifted_key(self, test_context: CthulhuTestContext) -> None: + """Test has_multi_click_bindings finds binding via keyval for unshifted key.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, KeyboardCommand + + manager = CommandManager() + + function = self._create_mock_function(test_context) + # Single-click binding for 'h' (keyval=104, keycode=38) + kb1 = self._create_mock_keybinding(test_context, keyval=104, keycode=38) + kb1.matches.return_value = True + kb1.click_count = 1 + cmd1 = KeyboardCommand("nextHeading", function, "Structural Nav", desktop_keybinding=kb1) + cmd1.set_keybinding(kb1) + manager.add_command(cmd1) + + # Double-click binding for same key + kb2 = self._create_mock_keybinding(test_context, keyval=104, keycode=38) + kb2.matches.return_value = True + kb2.click_count = 2 + cmd2 = KeyboardCommand("listHeadings", function, "Structural Nav", desktop_keybinding=kb2) + cmd2.set_keybinding(kb2) + manager.add_command(cmd2) + + # Query with 'h' keyval (104), unshifted - should find via keyval + assert manager.has_multi_click_bindings(104, 38, 0) is True + + +@pytest.mark.unit +class TestGetManager: + """Test the get_manager singleton function.""" + + def _setup_dependencies(self, test_context: CthulhuTestContext) -> dict[str, Mock]: + """Returns dependencies for command_manager module testing.""" + + essential_modules = test_context.setup_shared_dependencies([]) + + input_event_mock = essential_modules["cthulhu.input_event"] + input_event_mock.InputEventHandler = test_context.Mock + + keybindings_mock = essential_modules["cthulhu.keybindings"] + keybindings_mock.KeyBinding = test_context.Mock + keybindings_mock.KeyBindings = test_context.Mock + + from cthulhu import gsettings_registry + + gsettings_registry.get_registry().clear_runtime_values() + + return essential_modules + + def test_get_manager_returns_singleton(self, test_context: CthulhuTestContext) -> None: + """Test that get_manager returns the same instance.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import get_manager + + manager1 = get_manager() + manager2 = get_manager() + + assert manager1 is manager2 + + def test_get_manager_returns_command_manager(self, test_context: CthulhuTestContext) -> None: + """Test that get_manager returns a CommandManager instance.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, get_manager + + manager = get_manager() + assert isinstance(manager, CommandManager) + + +@pytest.mark.unit +class TestDiffBasedGrabUpdates: + """Test diff-based grab updates in CommandManager.""" + + def _setup_dependencies(self, test_context: CthulhuTestContext) -> dict[str, Mock]: + """Returns dependencies for command_manager module testing.""" + + essential_modules = test_context.setup_shared_dependencies(["cthulhu.cthulhu_modifier_manager"]) + + input_event_mock = essential_modules["cthulhu.input_event"] + input_event_mock.InputEventHandler = test_context.Mock + + keybindings_mock = essential_modules["cthulhu.keybindings"] + keybindings_mock.KeyBinding = test_context.Mock + keybindings_mock.KeyBindings = test_context.Mock + + cthulhu_modifier_manager_mock = essential_modules["cthulhu.cthulhu_modifier_manager"] + modifier_manager_instance = test_context.Mock() + modifier_manager_instance.refresh_cthulhu_modifiers = test_context.Mock() + cthulhu_modifier_manager_mock.get_manager = test_context.Mock( + return_value=modifier_manager_instance, + ) + + from cthulhu import gsettings_registry + + gsettings_registry.get_registry().clear_runtime_values() + + essential_modules["modifier_manager_instance"] = modifier_manager_instance + return essential_modules + + def _create_mock_function(self, test_context: CthulhuTestContext) -> Mock: + """Creates a mock function for a Command.""" + + function = test_context.Mock() + function.return_value = True + return function + + def _create_mock_keybinding( + self, + test_context: CthulhuTestContext, + keysymstring: str = "a", + modifiers: int = 0, + click_count: int = 1, + keyval: int = 65, + keycode: int = 38, + ) -> Mock: + """Creates a mock KeyBinding with specified properties.""" + + kb = test_context.Mock() + kb.keysymstring = keysymstring + kb.modifiers = modifiers + kb.click_count = click_count + kb.keyval = keyval + kb.keycode = keycode + kb.has_grabs = test_context.Mock(return_value=False) + kb.add_grabs = test_context.Mock() + kb.remove_grabs = test_context.Mock() + kb.get_grab_ids = test_context.Mock(return_value=[]) + kb.set_grab_ids = test_context.Mock() + return kb + + def test_binding_key_returns_tuple(self, test_context: CthulhuTestContext) -> None: + """Test _binding_key returns correct tuple.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager + + manager = CommandManager() + kb = self._create_mock_keybinding( + test_context, + keysymstring="h", + modifiers=256, + click_count=2, + ) + + result = manager._binding_key(kb) + assert result == ("h", 256, 2) + + def test_binding_key_returns_none_for_none(self, test_context: CthulhuTestContext) -> None: + """Test _binding_key returns None for None keybinding.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager + + manager = CommandManager() + assert manager._binding_key(None) is None + + def test_binding_key_returns_none_for_empty_keysymstring( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test _binding_key returns None when keysymstring is empty.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager + + manager = CommandManager() + kb = self._create_mock_keybinding(test_context, keysymstring="") + assert manager._binding_key(kb) is None + + def test_diff_transfers_grab_ids_for_matching_bindings( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test that grab IDs are transferred when bindings match.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, KeyboardCommand + + manager = CommandManager() + function = self._create_mock_function(test_context) + + # Old command with grabs + old_kb = self._create_mock_keybinding(test_context, keysymstring="h", modifiers=256) + old_kb.has_grabs.return_value = True + old_kb.get_grab_ids.return_value = [42, 43] + old_cmd = KeyboardCommand("cmd", function, "Group", desktop_keybinding=old_kb) + old_cmd.set_keybinding(old_kb) + old_commands = {"cmd": old_cmd} + + # New command with same binding but no grabs yet + new_kb = self._create_mock_keybinding(test_context, keysymstring="h", modifiers=256) + new_kb.has_grabs.return_value = False + new_cmd = KeyboardCommand("cmd", function, "Group", desktop_keybinding=new_kb) + new_cmd.set_keybinding(new_kb) + new_commands = {"cmd": new_cmd} + + manager._keyboard_commands = old_commands + manager._diff_and_update_grabs(new_commands, "test") + + # Verify grab IDs were transferred + new_kb.set_grab_ids.assert_called_once_with([42, 43]) + old_kb.set_grab_ids.assert_called_once_with([]) + # Neither add nor remove should be called + new_kb.add_grabs.assert_not_called() + old_kb.remove_grabs.assert_not_called() + + def test_diff_removes_grabs_for_old_only_bindings(self, test_context: CthulhuTestContext) -> None: + """Test that grabs are removed for bindings only in old commands.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, KeyboardCommand + + manager = CommandManager() + function = self._create_mock_function(test_context) + + # Old command with grabs + old_kb = self._create_mock_keybinding(test_context, keysymstring="h", modifiers=256) + old_kb.has_grabs.return_value = True + old_cmd = KeyboardCommand("old_cmd", function, "Group", desktop_keybinding=old_kb) + old_cmd.set_keybinding(old_kb) + old_commands = {"old_cmd": old_cmd} + + # Empty new commands + new_commands: dict = {} + + manager._keyboard_commands = old_commands + manager._diff_and_update_grabs(new_commands, "test") + + # Verify grabs were removed + old_kb.remove_grabs.assert_called_once() + + def test_diff_adds_grabs_for_new_only_bindings(self, test_context: CthulhuTestContext) -> None: + """Test that grabs are added for bindings only in new commands.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, KeyboardCommand + + manager = CommandManager() + function = self._create_mock_function(test_context) + + # Empty old commands + old_commands: dict = {} + + # New command without grabs + new_kb = self._create_mock_keybinding(test_context, keysymstring="h", modifiers=256) + new_kb.has_grabs.return_value = False + new_cmd = KeyboardCommand("new_cmd", function, "Group", desktop_keybinding=new_kb) + new_cmd.set_keybinding(new_kb) + new_commands = {"new_cmd": new_cmd} + + manager._keyboard_commands = old_commands + manager._diff_and_update_grabs(new_commands, "test") + + # Verify grabs were added + new_kb.add_grabs.assert_called_once() + + def test_set_active_commands_uses_diff(self, test_context: CthulhuTestContext) -> None: + """Test set_active_commands uses diff-based updates.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, KeyboardCommand + + manager = CommandManager() + function = self._create_mock_function(test_context) + + # Add initial command with grabs + old_kb = self._create_mock_keybinding(test_context, keysymstring="a") + old_kb.has_grabs.return_value = True + old_cmd = KeyboardCommand("old_cmd", function, "Group", desktop_keybinding=old_kb) + old_cmd.set_keybinding(old_kb) + manager.add_command(old_cmd) + + # Set new commands + new_kb = self._create_mock_keybinding(test_context, keysymstring="b") + new_kb.has_grabs.return_value = False + new_cmd = KeyboardCommand("new_cmd", function, "Group", desktop_keybinding=new_kb) + new_cmd.set_keybinding(new_kb) + + manager.set_active_commands({"new_cmd": new_cmd}, "test") + + # Old binding should have grabs removed, new binding should have grabs added + old_kb.remove_grabs.assert_called_once() + new_kb.add_grabs.assert_called_once() + + def test_activate_commands_applies_overrides(self, test_context: CthulhuTestContext) -> None: + """Test activate_commands applies user overrides.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, KeyboardCommand + + manager = CommandManager() + function = self._create_mock_function(test_context) + + # Add a command + kb = self._create_mock_keybinding(test_context, keysymstring="a") + cmd = KeyboardCommand("test_cmd", function, "Group", desktop_keybinding=kb) + cmd.set_keybinding(kb) + manager.add_command(cmd) + + # activate_commands reads overrides via layered_lookup on the registry. + # With no user values in the memory backend, no overrides are applied. + manager.activate_commands("test") + + # The command should still have its original keybinding unchanged. + assert cmd.get_keybinding() is kb + + def test_diff_skips_inactive_commands(self, test_context: CthulhuTestContext) -> None: + """Test that diff skips inactive commands.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, KeyboardCommand + + manager = CommandManager() + function = self._create_mock_function(test_context) + + # Old command that is disabled (inactive) + old_kb = self._create_mock_keybinding(test_context, keysymstring="h") + old_kb.has_grabs.return_value = True + old_cmd = KeyboardCommand( + "cmd", + function, + "Group", + desktop_keybinding=old_kb, + enabled=False, + ) + old_cmd.set_keybinding(old_kb) + old_commands = {"cmd": old_cmd} + + # New command that is also disabled + new_kb = self._create_mock_keybinding(test_context, keysymstring="h") + new_cmd = KeyboardCommand( + "cmd", + function, + "Group", + desktop_keybinding=new_kb, + enabled=False, + ) + new_cmd.set_keybinding(new_kb) + new_commands = {"cmd": new_cmd} + + manager._keyboard_commands = old_commands + manager._diff_and_update_grabs(new_commands, "test") + + # Neither should have grabs modified since both are inactive + old_kb.remove_grabs.assert_not_called() + new_kb.add_grabs.assert_not_called() + new_kb.set_grab_ids.assert_not_called() + + def test_set_group_suspended_removes_grabs_when_suspending( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test set_group_suspended removes grabs when suspending active commands.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, KeyboardCommand + + manager = CommandManager() + function = self._create_mock_function(test_context) + + # Active command with grabs + kb = self._create_mock_keybinding(test_context, keysymstring="h") + kb.has_grabs.return_value = True + cmd = KeyboardCommand("cmd", function, "Test Group", desktop_keybinding=kb) + cmd.set_keybinding(kb) + manager.add_command(cmd) + + # Suspend the group + manager.set_group_suspended("Test Group", True) + + # Grabs should be removed + kb.remove_grabs.assert_called_once() + kb.add_grabs.assert_not_called() + + def test_set_group_suspended_adds_grabs_when_unsuspending( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test set_group_suspended adds grabs when unsuspending commands.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, KeyboardCommand + + manager = CommandManager() + function = self._create_mock_function(test_context) + + # Suspended command without grabs + kb = self._create_mock_keybinding(test_context, keysymstring="h") + kb.has_grabs.return_value = False + cmd = KeyboardCommand("cmd", function, "Test Group", desktop_keybinding=kb, suspended=True) + cmd.set_keybinding(kb) + manager.add_command(cmd) + + # Unsuspend the group + manager.set_group_suspended("Test Group", False) + + # Grabs should be added + kb.add_grabs.assert_called_once() + kb.remove_grabs.assert_not_called() + + def test_set_group_suspended_no_change_when_already_in_state( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test set_group_suspended does nothing when commands already in desired state.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, KeyboardCommand + + manager = CommandManager() + function = self._create_mock_function(test_context) + + # Already suspended command + kb = self._create_mock_keybinding(test_context, keysymstring="h") + kb.has_grabs.return_value = False + cmd = KeyboardCommand("cmd", function, "Test Group", desktop_keybinding=kb, suspended=True) + cmd.set_keybinding(kb) + manager.add_command(cmd) + + # Suspend again (no change) + manager.set_group_suspended("Test Group", True) + + # No grabs should be modified + kb.add_grabs.assert_not_called() + kb.remove_grabs.assert_not_called() + + def test_set_group_enabled_removes_grabs_when_disabling( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test set_group_enabled removes grabs when disabling active commands.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, KeyboardCommand + + manager = CommandManager() + function = self._create_mock_function(test_context) + + # Active command with grabs + kb = self._create_mock_keybinding(test_context, keysymstring="h") + kb.has_grabs.return_value = True + cmd = KeyboardCommand("cmd", function, "Test Group", desktop_keybinding=kb) + cmd.set_keybinding(kb) + manager.add_command(cmd) + + # Disable the group + manager.set_group_enabled("Test Group", False) + + # Grabs should be removed + kb.remove_grabs.assert_called_once() + kb.add_grabs.assert_not_called() + + def test_set_group_enabled_skips_group_toggle_commands( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test set_group_enabled skips group toggle commands.""" + + self._setup_dependencies(test_context) + from cthulhu.command_manager import CommandManager, KeyboardCommand + + manager = CommandManager() + function = self._create_mock_function(test_context) + + # Group toggle command with grabs + kb = self._create_mock_keybinding(test_context, keysymstring="z") + kb.has_grabs.return_value = True + cmd = KeyboardCommand( + "toggle_cmd", + function, + "Test Group", + desktop_keybinding=kb, + is_group_toggle=True, + ) + cmd.set_keybinding(kb) + manager.add_command(cmd) + + # Disable the group + manager.set_group_enabled("Test Group", False) + + # Group toggle should not have grabs removed (it stays active) + kb.remove_grabs.assert_not_called() + # Command should still be enabled + assert cmd.is_enabled() is True diff --git a/tests/test_cthulhu_modifier_manager.py b/tests/test_cthulhu_modifier_manager.py new file mode 100644 index 0000000..4a5c575 --- /dev/null +++ b/tests/test_cthulhu_modifier_manager.py @@ -0,0 +1,910 @@ +# Unit tests for cthulhu_modifier_manager.py methods. +# +# 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=wrong-import-position +# pylint: disable=import-outside-toplevel +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-statements +# pylint: disable=protected-access +# pylint: disable=too-many-arguments +# pylint: disable=too-many-positional-arguments +# pylint: disable=too-many-locals + +"""Unit tests for cthulhu_modifier_manager.py methods.""" + +from __future__ import annotations + +import subprocess +from typing import TYPE_CHECKING +from unittest.mock import call + +import pytest + +if TYPE_CHECKING: + from unittest.mock import MagicMock + + from cthulhu_test_context import CthulhuTestContext + + +@pytest.mark.unit +class TestCthulhuModifierManager: + """Test CthulhuModifierManager class methods.""" + + def _setup_dependencies(self, test_context: CthulhuTestContext) -> dict[str, MagicMock]: + """Returns dependencies for cthulhu_modifier_manager module testing.""" + + additional_modules = ["cthulhu.input_event_manager", "gi.repository"] + essential_modules = test_context.setup_shared_dependencies(additional_modules) + + debug_mock = essential_modules["cthulhu.debug"] + debug_mock.print_message = test_context.Mock() + debug_mock.LEVEL_INFO = 800 + + keybindings_mock = essential_modules["cthulhu.keybindings"] + keybindings_mock.get_keycodes = test_context.Mock(return_value=(0, 0)) + + input_event_manager_mock = essential_modules["cthulhu.input_event_manager"] + input_manager_instance = test_context.Mock() + input_manager_instance.add_grab_for_modifier = test_context.Mock(return_value=123) + input_manager_instance.remove_grab_for_modifier = test_context.Mock() + input_event_manager_mock.get_manager = test_context.Mock( + return_value=input_manager_instance, + ) + + gi_repository_mock = essential_modules["gi.repository"] + + gdk_mock = test_context.Mock() + display_mock = test_context.Mock() + device_manager_mock = test_context.Mock() + display_mock.get_device_manager = test_context.Mock(return_value=device_manager_mock) + gdk_mock.Display = test_context.Mock() + gdk_mock.Display.get_default = test_context.Mock(return_value=display_mock) + gdk_mock.InputSource = test_context.Mock() + gdk_mock.InputSource.KEYBOARD = 4 + gi_repository_mock.Gdk = gdk_mock + + atspi_mock = test_context.Mock() + atspi_mock.generate_keyboard_event = test_context.Mock() + atspi_mock.ModifierType = test_context.Mock() + atspi_mock.ModifierType.SHIFTLOCK = 1 + atspi_mock.ModifierType.SHIFT = 0 + gi_repository_mock.Atspi = atspi_mock + + glib_mock = test_context.Mock() + glib_mock.timeout_add = test_context.Mock() + gi_repository_mock.GLib = glib_mock + + essential_modules["input_manager_instance"] = input_manager_instance + essential_modules["gdk"] = gdk_mock + essential_modules["atspi"] = atspi_mock + essential_modules["glib"] = glib_mock + + from cthulhu import gsettings_registry + + gsettings_registry.get_registry().clear_runtime_values() + + return essential_modules + + def test_init(self, test_context: CthulhuTestContext) -> None: + """Test CthulhuModifierManager.__init__.""" + + self._setup_dependencies(test_context) + from cthulhu import cthulhu_modifier_manager + + mock_gdk = test_context.Mock() + test_context.patch("cthulhu.cthulhu_modifier_manager.Gdk", new=mock_gdk) + mock_display = test_context.Mock() + mock_device_manager = test_context.Mock() + mock_display.get_device_manager.return_value = mock_device_manager + mock_gdk.Display.get_default.return_value = mock_display + manager = cthulhu_modifier_manager.CthulhuModifierManager() + assert not manager._grabbed_modifiers + assert manager._is_pressed is False + assert manager._original_xmodmap == b"" + assert manager._caps_lock_cleared is False + assert manager._need_to_restore_cthulhu_modifier is False + + assert manager is not None + + def test_init_no_display(self, test_context: CthulhuTestContext) -> None: + """Test CthulhuModifierManager.__init__ with no display available.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu import cthulhu_modifier_manager + + mock_gdk = test_context.Mock() + test_context.patch("cthulhu.cthulhu_modifier_manager.Gdk", new=mock_gdk) + mock_gdk.Display.get_default.return_value = None + essential_modules["cthulhu.debug"].reset_mock() + manager = cthulhu_modifier_manager.CthulhuModifierManager() + + assert not manager._grabbed_modifiers + assert manager._is_pressed is False + assert manager._original_xmodmap == b"" + assert manager._caps_lock_cleared is False + assert manager._need_to_restore_cthulhu_modifier is False + + @pytest.mark.parametrize( + "case", + [ + { + "id": "keyboard_device", + "device_source": 4, + "should_refresh": True, + }, # Gdk.InputSource.KEYBOARD = 4 + { + "id": "mouse_device", + "device_source": 0, + "should_refresh": False, + }, # Gdk.InputSource.MOUSE = 0 + { + "id": "touchscreen_device", + "device_source": 5, + "should_refresh": False, + }, # Gdk.InputSource.TOUCHSCREEN = 5 + ], + ids=lambda case: case["id"], + ) + def test_on_device_changed( + self, + test_context: CthulhuTestContext, + case: dict, + ) -> None: + """Test CthulhuModifierManager._on_device_changed.""" + + self._setup_dependencies(test_context) + from cthulhu import cthulhu_modifier_manager + + manager = cthulhu_modifier_manager.CthulhuModifierManager() + + mock_device = test_context.Mock() + mock_device.get_source.return_value = case["device_source"] + + mock_refresh = test_context.Mock() + test_context.patch_object(manager, "refresh_cthulhu_modifiers", new=mock_refresh) + test_context.patch("cthulhu.cthulhu_modifier_manager.Gdk.InputSource.KEYBOARD", new=4) + manager._on_device_changed(None, mock_device) + if case["should_refresh"]: + mock_refresh.assert_called_once_with("Keyboard change detected.") + else: + mock_refresh.assert_not_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "insert_grabbed", + "modifier": "Insert", + "cthulhu_modifier_keys": ["Insert", "KP_Insert"], + "is_grabbed": True, + "expected_result": True, + }, + { + "id": "insert_not_grabbed", + "modifier": "Insert", + "cthulhu_modifier_keys": ["Insert", "KP_Insert"], + "is_grabbed": False, + "expected_result": False, + }, + { + "id": "kp_insert_grabbed", + "modifier": "KP_Insert", + "cthulhu_modifier_keys": ["Insert", "KP_Insert"], + "is_grabbed": True, + "expected_result": True, + }, + { + "id": "kp_insert_not_grabbed", + "modifier": "KP_Insert", + "cthulhu_modifier_keys": ["Insert", "KP_Insert"], + "is_grabbed": False, + "expected_result": False, + }, + { + "id": "caps_lock_modifiers_set", + "modifier": "Caps_Lock", + "cthulhu_modifier_keys": ["Caps_Lock"], + "is_grabbed": False, + "modifiers_are_set": True, + "expected_result": True, + }, + { + "id": "caps_lock_modifiers_not_set", + "modifier": "Caps_Lock", + "cthulhu_modifier_keys": ["Caps_Lock"], + "is_grabbed": False, + "modifiers_are_set": False, + "expected_result": False, + }, + { + "id": "shift_lock_modifiers_set", + "modifier": "Shift_Lock", + "cthulhu_modifier_keys": ["Shift_Lock"], + "is_grabbed": False, + "modifiers_are_set": True, + "expected_result": True, + }, + { + "id": "shift_lock_modifiers_not_set", + "modifier": "Shift_Lock", + "cthulhu_modifier_keys": ["Shift_Lock"], + "is_grabbed": False, + "modifiers_are_set": False, + "expected_result": False, + }, + { + "id": "not_cthulhu_modifier", + "modifier": "Control_L", + "cthulhu_modifier_keys": ["Insert"], + "is_grabbed": False, + "expected_result": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_is_cthulhu_modifier( + self, + test_context, + case: dict, + ) -> None: + """Test CthulhuModifierManager.is_cthulhu_modifier.""" + + self._setup_dependencies(test_context) + from cthulhu import cthulhu_modifier_manager + + manager = cthulhu_modifier_manager.CthulhuModifierManager() + manager._grabbed_modifiers = {"Insert": 1, "KP_Insert": 2} if case["is_grabbed"] else {} + manager._modifiers_are_set = case.get("modifiers_are_set", False) + + manager._modifier_keys_override = case["cthulhu_modifier_keys"] + result = manager.is_cthulhu_modifier(case["modifier"]) + assert result == case["expected_result"] + + @pytest.mark.parametrize( + "case", + [ + { + "id": "get_initial_false", + "test_type": "get", + "initial_state": False, + "new_state": None, + "expected_get": False, + "expected_set": None, + }, + { + "id": "get_set_true", + "test_type": "get", + "initial_state": True, + "new_state": None, + "expected_get": True, + "expected_set": None, + }, + { + "id": "set_to_true", + "test_type": "set", + "initial_state": None, + "new_state": True, + "expected_get": None, + "expected_set": True, + }, + { + "id": "set_to_false", + "test_type": "set", + "initial_state": None, + "new_state": False, + "expected_get": None, + "expected_set": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_pressed_state_operations(self, test_context, case: dict) -> None: + """Test CthulhuModifierManager get_pressed_state and set_pressed_state.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu import cthulhu_modifier_manager + + manager = cthulhu_modifier_manager.CthulhuModifierManager() + essential_modules["cthulhu.debug"].reset_mock() + + if case["test_type"] == "get": + if case["initial_state"] is not None: + manager._is_pressed = case["initial_state"] + result = manager.get_pressed_state() + assert result == case["expected_get"] + else: + manager.set_pressed_state(case["new_state"]) + assert manager._is_pressed == case["expected_set"] + + @pytest.mark.parametrize( + "case", + [ + { + "id": "modifier_grabbed", + "grabbed_modifiers": {"Insert": 1, "KP_Insert": 2}, + "modifier": "Insert", + "expected_result": True, + }, + { + "id": "kp_modifier_grabbed", + "grabbed_modifiers": {"Insert": 1, "KP_Insert": 2}, + "modifier": "KP_Insert", + "expected_result": True, + }, + { + "id": "modifier_not_grabbed", + "grabbed_modifiers": {"Insert": 1}, + "modifier": "KP_Insert", + "expected_result": False, + }, + { + "id": "no_grabs", + "grabbed_modifiers": {}, + "modifier": "Insert", + "expected_result": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_is_modifier_grabbed( + self, + test_context, + case: dict, + ) -> None: + """Test CthulhuModifierManager.is_modifier_grabbed.""" + + self._setup_dependencies(test_context) + from cthulhu import cthulhu_modifier_manager + + manager = cthulhu_modifier_manager.CthulhuModifierManager() + manager._grabbed_modifiers = case["grabbed_modifiers"] + result = manager.is_modifier_grabbed(case["modifier"]) + assert result == case["expected_result"] + + @pytest.mark.parametrize( + "cthulhu_modifier_keys, expected_calls", + [ + pytest.param(["Insert", "KP_Insert"], ["Insert", "KP_Insert"], id="insert_keys"), + pytest.param(["Caps_Lock"], [], id="caps_lock_no_grab"), + pytest.param( + ["Insert", "Caps_Lock", "KP_Insert"], + ["Insert", "KP_Insert"], + id="mixed_keys", + ), + pytest.param([], [], id="no_keys"), + ], + ) + def test_add_grabs_for_cthulhu_modifiers( + self, + test_context, + cthulhu_modifier_keys, + expected_calls, + ) -> None: + """Test CthulhuModifierManager.add_grabs_for_cthulhu_modifiers.""" + + self._setup_dependencies(test_context) + from cthulhu import cthulhu_modifier_manager + + manager = cthulhu_modifier_manager.CthulhuModifierManager() + manager._modifier_keys_override = cthulhu_modifier_keys + + mock_add_grab = test_context.Mock() + test_context.patch_object(manager, "add_modifier_grab", new=mock_add_grab) + manager.add_grabs_for_cthulhu_modifiers() + calls = [call(modifier) for modifier in expected_calls] + mock_add_grab.assert_has_calls(calls, any_order=True) + assert mock_add_grab.call_count == len(expected_calls) + + @pytest.mark.parametrize( + "cthulhu_modifier_keys, expected_calls", + [ + pytest.param(["Insert", "KP_Insert"], ["Insert", "KP_Insert"], id="insert_keys"), + pytest.param(["Caps_Lock"], [], id="caps_lock_no_ungrab"), + pytest.param( + ["Insert", "Caps_Lock", "KP_Insert"], + ["Insert", "KP_Insert"], + id="mixed_keys", + ), + pytest.param([], [], id="no_keys"), + ], + ) + def test_remove_grabs_for_cthulhu_modifiers( + self, + test_context, + cthulhu_modifier_keys, + expected_calls, + ) -> None: + """Test CthulhuModifierManager.remove_grabs_for_cthulhu_modifiers.""" + + self._setup_dependencies(test_context) + from cthulhu import cthulhu_modifier_manager + + manager = cthulhu_modifier_manager.CthulhuModifierManager() + manager._modifier_keys_override = cthulhu_modifier_keys + + mock_remove_grab = test_context.Mock() + test_context.patch_object(manager, "remove_modifier_grab", new=mock_remove_grab) + manager.remove_grabs_for_cthulhu_modifiers() + calls = [call(modifier) for modifier in expected_calls] + mock_remove_grab.assert_has_calls(calls, any_order=True) + assert mock_remove_grab.call_count == len(expected_calls) + assert manager._is_pressed is False + + @pytest.mark.parametrize( + "scenario,has_existing,grab_result,expects_keycodes_call,expects_grab_call,expects_in_dict", + [ + pytest.param("new", False, 123, True, True, True, id="new_modifier"), + pytest.param("existing", True, None, False, False, True, id="existing_modifier"), + pytest.param("failed", False, -1, True, True, False, id="failed_grab"), + ], + ) + def test_add_modifier_grab( + self, + test_context, + scenario: str, + has_existing: bool, + grab_result: int | None, + expects_keycodes_call: bool, + expects_grab_call: bool, + expects_in_dict: bool, + ) -> None: + """Test CthulhuModifierManager.add_modifier_grab with various scenarios.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu import cthulhu_modifier_manager + + manager = cthulhu_modifier_manager.CthulhuModifierManager() + + if has_existing: + manager._grabbed_modifiers["Insert"] = 123 + + if scenario != "existing": + mock_get_keycodes = test_context.Mock() + test_context.patch( + "cthulhu.cthulhu_modifier_manager.keybindings.get_keycodes", + new=mock_get_keycodes, + ) + mock_iem = test_context.Mock() + test_context.patch( + "cthulhu.cthulhu_modifier_manager.input_event_manager.get_manager", + new=mock_iem, + ) + mock_get_keycodes.return_value = (65379, 110) + mock_input_manager = test_context.Mock() + mock_input_manager.add_grab_for_modifier.return_value = grab_result + mock_iem.return_value = mock_input_manager + + manager.add_modifier_grab("Insert") + + if expects_keycodes_call: + mock_get_keycodes.assert_called_once_with("Insert") + elif scenario == "existing": + essential_modules["cthulhu.keybindings"].get_keycodes.assert_not_called() + + if expects_grab_call: + mock_input_manager.add_grab_for_modifier.assert_called_once_with("Insert", 65379, 110) + elif scenario == "existing": + essential_modules["input_manager_instance"].add_grab_for_modifier.assert_not_called() + + if expects_in_dict: + if scenario in ("new", "existing"): + assert manager._grabbed_modifiers["Insert"] == 123 + else: + assert "Insert" not in manager._grabbed_modifiers + + @pytest.mark.parametrize( + "has_grabbed,expects_call", + [ + pytest.param(True, True, id="grabbed_modifier"), + pytest.param(False, False, id="not_grabbed_modifier"), + ], + ) + def test_remove_modifier_grab( + self, + test_context, + has_grabbed: bool, + expects_call: bool, + ) -> None: + """Test CthulhuModifierManager.remove_modifier_grab with grabbed and non-grabbed modifiers.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu import cthulhu_modifier_manager + + manager = cthulhu_modifier_manager.CthulhuModifierManager() + + if has_grabbed: + manager._grabbed_modifiers["Insert"] = 123 + mock_iem = test_context.Mock() + test_context.patch( + "cthulhu.cthulhu_modifier_manager.input_event_manager.get_manager", + new=mock_iem, + ) + mock_input_manager = test_context.Mock() + mock_iem.return_value = mock_input_manager + + manager.remove_modifier_grab("Insert") + + if expects_call: + mock_input_manager.remove_grab_for_modifier.assert_called_once_with("Insert", 123) + assert "Insert" not in manager._grabbed_modifiers + else: + essential_modules["input_manager_instance"].remove_grab_for_modifier.assert_not_called() + + @pytest.mark.parametrize( + "keyval_name, expected_method", + [ + pytest.param("Caps_Lock", "_toggle_modifier_lock", id="caps_lock"), + pytest.param("Shift_Lock", "_toggle_modifier_lock", id="shift_lock"), + pytest.param("Insert", "_toggle_modifier_grab", id="insert"), + pytest.param("KP_Insert", "_toggle_modifier_grab", id="kp_insert"), + ], + ) + def test_toggle_modifier( + self, + test_context, + keyval_name, + expected_method, + ) -> None: + """Test CthulhuModifierManager.toggle_modifier.""" + + self._setup_dependencies(test_context) + from cthulhu import cthulhu_modifier_manager + + manager = cthulhu_modifier_manager.CthulhuModifierManager() + mock_keyboard_event = test_context.Mock() + mock_keyboard_event.keyval_name = keyval_name + mock_keyboard_event.hw_code = 110 + mock_keyboard_event.modifiers = 0 + mock_keyboard_event.is_pressed_key.return_value = False + mock_method = test_context.Mock() + test_context.patch_object(manager, expected_method, new=mock_method) + manager.toggle_modifier(mock_keyboard_event) + mock_method.assert_called_once_with(mock_keyboard_event) + + @pytest.mark.parametrize( + "is_pressed_key, expects_remove_call, expects_timeout_calls", + [ + pytest.param(True, False, 0, id="pressed_key_no_action"), + pytest.param(False, True, 2, id="released_key_full_action"), + ], + ) + def test_toggle_modifier_grab( + self, + test_context: CthulhuTestContext, + is_pressed_key, + expects_remove_call, + expects_timeout_calls, + ) -> None: + """Test CthulhuModifierManager._toggle_modifier_grab with pressed and released keys.""" + self._setup_dependencies(test_context) + from cthulhu import cthulhu_modifier_manager + + manager = cthulhu_modifier_manager.CthulhuModifierManager() + mock_keyboard_event = test_context.Mock() + mock_keyboard_event.keyval_name = "Insert" + mock_keyboard_event.hw_code = 110 + mock_keyboard_event.modifiers = 0 + mock_keyboard_event.is_pressed_key.return_value = is_pressed_key + mock_remove = test_context.Mock() + test_context.patch_object(manager, "remove_modifier_grab", new=mock_remove) + mock_add_grab = test_context.Mock() + test_context.patch_object(manager, "add_modifier_grab", new=mock_add_grab) + mock_timeout = test_context.Mock() + test_context.patch("cthulhu.cthulhu_modifier_manager.GLib.timeout_add", new=mock_timeout) + test_context.patch( + "cthulhu.cthulhu_modifier_manager.Atspi.generate_keyboard_event", + side_effect=lambda *args, **kwargs: None, + ) + + manager._toggle_modifier_grab(mock_keyboard_event) + + if expects_remove_call: + mock_remove.assert_called_once_with("Insert") + assert mock_timeout.call_count == expects_timeout_calls + if expects_timeout_calls > 0: + timeout_calls = mock_timeout.call_args_list + assert timeout_calls[0][0][0] == 1 + assert timeout_calls[0][0][2] == 110 # hw_code + if expects_timeout_calls > 1: + assert timeout_calls[1][0][0] == 500 + assert timeout_calls[1][0][2] == "Insert" # modifier name + else: + mock_remove.assert_not_called() + assert mock_timeout.call_count == expects_timeout_calls + + @pytest.mark.parametrize( + "keyval_name, is_pressed, expected_modifier", + [ + pytest.param( + "Caps_Lock", + True, + 1 << 1, + id="caps_lock_pressed", # 1 << Atspi.ModifierType.SHIFTLOCK + ), + pytest.param( + "Shift_Lock", + True, + 1 << 0, + id="shift_lock_pressed", # 1 << Atspi.ModifierType.SHIFT + ), + pytest.param("Caps_Lock", False, None, id="caps_lock_released"), + pytest.param("Other_Key", True, None, id="other_key"), + ], + ) + def test_toggle_modifier_lock( + self, + test_context, + keyval_name, + is_pressed, + expected_modifier, + ) -> None: + """Test CthulhuModifierManager._toggle_modifier_lock.""" + + self._setup_dependencies(test_context) + from cthulhu import cthulhu_modifier_manager + + test_context.patch("cthulhu.cthulhu_modifier_manager.Atspi.ModifierType.SHIFTLOCK", new=1) + test_context.patch("cthulhu.cthulhu_modifier_manager.Atspi.ModifierType.SHIFT", new=0) + manager = cthulhu_modifier_manager.CthulhuModifierManager() + mock_keyboard_event = test_context.Mock() + mock_keyboard_event.keyval_name = keyval_name + mock_keyboard_event.hw_code = 110 + mock_keyboard_event.modifiers = 0 + mock_keyboard_event.is_pressed_key.return_value = is_pressed + mock_timeout = test_context.Mock() + test_context.patch("cthulhu.cthulhu_modifier_manager.GLib.timeout_add", new=mock_timeout) + manager._toggle_modifier_lock(mock_keyboard_event) + if expected_modifier is not None: + mock_timeout.assert_called_once() + timeout_call = mock_timeout.call_args_list[0] + assert timeout_call[0][0] == 1 # 1ms delay + assert timeout_call[0][2] == 0 # modifiers + assert timeout_call[0][3] == expected_modifier # modifier value + else: + mock_timeout.assert_not_called() + + def test_refresh_cthulhu_modifiers(self, test_context: CthulhuTestContext) -> None: + """Test CthulhuModifierManager.refresh_cthulhu_modifiers.""" + + self._setup_dependencies(test_context) + from cthulhu import cthulhu_modifier_manager + + manager = cthulhu_modifier_manager.CthulhuModifierManager() + test_context.patch("os.environ", new={"DISPLAY": ":0"}) + mock_popen = test_context.Mock() + test_context.patch("cthulhu.cthulhu_modifier_manager.subprocess.Popen", new=mock_popen) + mock_process = test_context.Mock() + mock_process.communicate.return_value = (b"xmodmap_content", b"") + mock_context_manager = test_context.Mock() + mock_context_manager.__enter__ = test_context.Mock(return_value=mock_process) + mock_context_manager.__exit__ = test_context.Mock(return_value=None) + mock_popen.return_value = mock_context_manager + mock_restore = test_context.Mock() + test_context.patch_object(manager, "_restore_original_xkbcomp", new=mock_restore) + mock_create = test_context.Mock() + test_context.patch_object(manager, "_create_cthulhu_xmodmap", new=mock_create) + manager.refresh_cthulhu_modifiers("test reason") + mock_restore.assert_called_once() + mock_popen.assert_called_once_with( + ["xkbcomp", ":0", "-"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + assert manager._original_xmodmap == b"xmodmap_content" + assert manager._modifiers_are_set is True + mock_create.assert_called_once() + + @pytest.mark.parametrize( + "caps_lock_cthulhu, shift_lock_cthulhu, caps_cleared, expected_calls", + [ + pytest.param(True, False, False, [("set_caps", True)], id="caps_lock_enable"), + pytest.param(False, True, False, [("set_caps", True)], id="shift_lock_enable"), + pytest.param(True, True, False, [("set_caps", True)], id="both_enable"), + pytest.param( + False, + False, + True, + [("set_caps", False)], + id="disable_previously_cleared", + ), + pytest.param(False, False, False, [], id="no_changes"), + ], + ) + def test_create_cthulhu_xmodmap( + self, + test_context, + caps_lock_cthulhu, + shift_lock_cthulhu, + caps_cleared, + expected_calls, + ) -> None: + """Test CthulhuModifierManager._create_cthulhu_xmodmap.""" + + self._setup_dependencies(test_context) + from cthulhu import cthulhu_modifier_manager + + manager = cthulhu_modifier_manager.CthulhuModifierManager() + manager._caps_lock_cleared = caps_cleared + + cthulhu_modifier_keys: list[str] = [] + if caps_lock_cthulhu: + cthulhu_modifier_keys.append("Caps_Lock") + if shift_lock_cthulhu: + cthulhu_modifier_keys.append("Shift_Lock") + manager._modifier_keys_override = cthulhu_modifier_keys + mock_set_caps = test_context.Mock() + test_context.patch_object(manager, "set_caps_lock_as_cthulhu_modifier", new=mock_set_caps) + manager._create_cthulhu_xmodmap() + for call_type, enable in expected_calls: + if call_type == "set_caps": + mock_set_caps.assert_called_with(enable) + + if caps_lock_cthulhu or shift_lock_cthulhu: + assert manager._caps_lock_cleared is True + elif caps_cleared and not caps_lock_cthulhu and not shift_lock_cthulhu: + assert manager._caps_lock_cleared is False + + @pytest.mark.parametrize( + "has_xmodmap, expects_popen_call", + [ + pytest.param(True, True, id="with_xmodmap"), + pytest.param(False, False, id="no_xmodmap"), + ], + ) + def test_unset_cthulhu_modifiers( + self, + test_context: CthulhuTestContext, + has_xmodmap, + expects_popen_call, + ) -> None: + """Test CthulhuModifierManager.unset_cthulhu_modifiers with and without xmodmap.""" + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu import cthulhu_modifier_manager + + manager = cthulhu_modifier_manager.CthulhuModifierManager() + manager._modifiers_are_set = True + if has_xmodmap: + test_context.patch("os.environ", new={"DISPLAY": ":0"}) + manager._original_xmodmap = b"original_xmodmap_content" + else: + manager._original_xmodmap = b"" + + essential_modules["cthulhu.debug"].reset_mock() + mock_popen = test_context.Mock() + test_context.patch("cthulhu.cthulhu_modifier_manager.subprocess.Popen", new=mock_popen) + + mock_unmap = test_context.Mock() + mock_iem = test_context.Mock() + mock_iem.unmap_all_modifiers = mock_unmap + test_context.patch( + "cthulhu.cthulhu_modifier_manager.input_event_manager.get_manager", + return_value=mock_iem, + ) + + if has_xmodmap: + mock_process = test_context.Mock() + mock_context_manager = test_context.Mock() + mock_context_manager.__enter__ = test_context.Mock(return_value=mock_process) + mock_context_manager.__exit__ = test_context.Mock(return_value=None) + mock_popen.return_value = mock_context_manager + + manager.unset_cthulhu_modifiers("test reason" if has_xmodmap else "") + + assert manager._modifiers_are_set is False + mock_unmap.assert_called_once() + if expects_popen_call: + mock_popen.assert_called_once_with( + ["xkbcomp", "-w0", "-", ":0"], + stdin=subprocess.PIPE, + stdout=None, + stderr=None, + ) + mock_process.communicate.assert_called_once_with(b"original_xmodmap_content") + assert manager._caps_lock_cleared is False + else: + mock_popen.assert_not_called() + + @pytest.mark.parametrize( + "enable, xmodmap_content, expects_popen_call, expected_content", + [ + pytest.param( + True, + """interpret Caps_Lock+AnyOfOrNone(all) { + action= LockMods(modifiers=Lock); + };""", + True, + b"NoAction()", + id="enable_caps_lock", + ), + pytest.param( + False, + """interpret Caps_Lock+AnyOfOrNone(all) { + action= NoAction(); + };""", + True, + b"LockMods(modifiers=Lock)", + id="disable_caps_lock", + ), + pytest.param( + True, + "some other xmodmap content", + False, + None, + id="no_changes_needed", + ), + ], + ) + def test_set_caps_lock_as_cthulhu_modifier( + self, + test_context: CthulhuTestContext, + enable, + xmodmap_content, + expects_popen_call, + expected_content, + ) -> None: + """Test CthulhuModifierManager.set_caps_lock_as_cthulhu_modifier with various scenarios.""" + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + from cthulhu import cthulhu_modifier_manager + + manager = cthulhu_modifier_manager.CthulhuModifierManager() + if expects_popen_call: + test_context.patch("os.environ", new={"DISPLAY": ":0"}) + + manager._original_xmodmap = xmodmap_content.encode("UTF-8") + essential_modules["cthulhu.debug"].reset_mock() + mock_popen = test_context.Mock() + test_context.patch("cthulhu.cthulhu_modifier_manager.subprocess.Popen", new=mock_popen) + + if expects_popen_call: + mock_process = test_context.Mock() + mock_context_manager = test_context.Mock() + mock_context_manager.__enter__ = test_context.Mock(return_value=mock_process) + mock_context_manager.__exit__ = test_context.Mock(return_value=None) + mock_popen.return_value = mock_context_manager + + manager.set_caps_lock_as_cthulhu_modifier(enable) + + if expects_popen_call: + mock_popen.assert_called_once_with( + ["xkbcomp", "-w0", "-", ":0"], + stdin=subprocess.PIPE, + stdout=None, + stderr=None, + ) + called_data = mock_process.communicate.call_args[0][0] + assert expected_content in called_data + else: + mock_popen.assert_not_called() + + def test_get_manager( + self, + test_context, + ) -> None: + """Test cthulhu_modifier_manager.get_manager.""" + + self._setup_dependencies(test_context) + from cthulhu import cthulhu_modifier_manager + + manager1 = cthulhu_modifier_manager.get_manager() + assert manager1 is not None + assert isinstance(manager1, cthulhu_modifier_manager.CthulhuModifierManager) + + manager2 = cthulhu_modifier_manager.get_manager() + assert manager2 is manager1 diff --git a/tests/test_input_event_manager.py b/tests/test_input_event_manager.py new file mode 100644 index 0000000..bc92625 --- /dev/null +++ b/tests/test_input_event_manager.py @@ -0,0 +1,3261 @@ +# Unit tests for input_event_manager.py methods. +# +# 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=wrong-import-position +# pylint: disable=import-outside-toplevel +# pylint: disable=too-many-public-methods +# pylint: disable=too-many-statements +# pylint: disable=protected-access +# pylint: disable=too-many-arguments +# pylint: disable=too-many-positional-arguments +# pylint: disable=too-many-locals +# pylint: disable=too-many-lines +# pylint: disable=too-many-instance-attributes + +"""Unit tests for input_event_manager.py methods.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import call + +import pytest + +if TYPE_CHECKING: + from unittest.mock import MagicMock + + from cthulhu_test_context import CthulhuTestContext + + +@pytest.mark.unit +class TestInputEventManager: + """Test InputEventManager class methods.""" + + def _setup_dependencies(self, test_context: CthulhuTestContext) -> dict[str, MagicMock]: + """Returns dependencies for input_event_manager module testing.""" + + additional_modules = ["cthulhu.ax_utilities", "gi.repository"] + essential_modules = test_context.setup_shared_dependencies(additional_modules) + + debug_mock = essential_modules["cthulhu.debug"] + debug_mock.LEVEL_INFO = 800 + debug_mock.LEVEL_WARNING = 2 + debug_mock.LEVEL_SEVERE = 3 + debug_mock.debugLevel = 0 + + focus_manager_mock = essential_modules["cthulhu.focus_manager"] + focus_mgr_instance = test_context.Mock() + focus_mgr_instance.get_locus_of_focus = test_context.Mock() + focus_mgr_instance.get_active_window = test_context.Mock() + focus_mgr_instance.focus_and_window_are_unknown = test_context.Mock(return_value=False) + focus_mgr_instance.clear_state = test_context.Mock() + focus_manager_mock.get_manager = test_context.Mock(return_value=focus_mgr_instance) + + script_manager_mock = essential_modules["cthulhu.script_manager"] + script_mgr_instance = test_context.Mock() + script_instance = test_context.Mock() + script_instance.app = test_context.Mock() + script_instance.event_cache = {} + script_instance.listeners = {} + script_instance.is_activatable_event = test_context.Mock(return_value=True) + script_instance.force_script_activation = test_context.Mock(return_value=False) + script_instance.present_if_inactive = False + script_mgr_instance.get_active_script = test_context.Mock(return_value=script_instance) + script_mgr_instance.get_script = test_context.Mock(return_value=script_instance) + script_mgr_instance.set_active_script = test_context.Mock() + script_mgr_instance.get_default_script = test_context.Mock(return_value=script_instance) + script_mgr_instance.reclaim_scripts = test_context.Mock() + script_manager_mock.get_manager = test_context.Mock(return_value=script_mgr_instance) + + input_event_mock = essential_modules["cthulhu.input_event"] + + class MockKeyboardEvent: + """Mock KeyboardEvent class for testing.""" + + def __init__(self, pressed=True, keycode=65, keysym=97, modifiers=0, text="a"): + self.process = test_context.Mock() + self.set_window = test_context.Mock() + self.set_object = test_context.Mock() + self.set_script = test_context.Mock() + self.set_click_count = test_context.Mock() + self.is_modifier_key = test_context.Mock(return_value=False) + self.is_pressed_key = test_context.Mock(return_value=True) + self.get_window = test_context.Mock() + self.get_object = test_context.Mock() + self.get_script = test_context.Mock() + self.get_click_count = test_context.Mock(return_value=1) + self.is_printable_key = test_context.Mock(return_value=True) + self.as_single_line_string = test_context.Mock(return_value="KeyboardEvent") + self.pressed = pressed + self.keycode = keycode + self.keysym = keysym + self.modifiers = modifiers + self.text = text + self.id = 1 + self.hw_code = keycode + self.keyval_name = text + self.time = 1000 + self.button = None + + def __eq__(self, other): + """Enable equality comparison for duplicate detection.""" + + if not isinstance(other, MockKeyboardEvent): + return False + return ( + self.pressed == other.pressed + and self.keycode == other.keycode + and self.keysym == other.keysym + and self.modifiers == other.modifiers + and self.text == other.text + ) + + def __hash__(self): + return hash((self.pressed, self.keycode, self.keysym, self.modifiers, self.text)) + + class MockBrailleEvent: + """Mock BrailleEvent class for testing.""" + + def __init__(self, *_args, **_kwargs): + self.process = test_context.Mock(return_value=True) + + class MockMouseButtonEvent: + """Mock MouseButtonEvent class for testing.""" + + def __init__(self, *_args, **_kwargs): + self.set_click_count = test_context.Mock() + self.get_click_count = test_context.Mock(return_value=1) + self.button = "1" + self.pressed = True + self.time = 1000 + + input_event_mock.KeyboardEvent = MockKeyboardEvent + input_event_mock.BrailleEvent = MockBrailleEvent + input_event_mock.MouseButtonEvent = MockMouseButtonEvent + + ax_utilities_mock = essential_modules["cthulhu.ax_utilities"] + ax_utilities_class = test_context.Mock() + ax_utilities_class.can_be_active_window = test_context.Mock(return_value=True) + ax_utilities_class.find_active_window = test_context.Mock(return_value=None) + ax_utilities_class.is_single_line = test_context.Mock(return_value=False) + ax_utilities_class.is_widget_controlled_by_line_navigation = test_context.Mock( + return_value=False, + ) + ax_utilities_class.has_matching_shortcut = test_context.Mock(return_value=False) + ax_utilities_class.is_table_header = test_context.Mock(return_value=False) + ax_utilities_class.is_terminal = test_context.Mock(return_value=False) + ax_utilities_mock.AXUtilities = ax_utilities_class + + gi_repo_mock = essential_modules["gi.repository"] + + atspi_mock = test_context.Mock() + atspi_mock.get_version = test_context.Mock(return_value=(2, 56, 0)) + atspi_mock.Device = test_context.Mock() + atspi_mock.Device.new_full = test_context.Mock() + atspi_mock.Device.new = test_context.Mock() + atspi_mock.Device.grab_keyboard = test_context.Mock() + atspi_mock.Device.ungrab_keyboard = test_context.Mock() + atspi_mock.KeyDefinition = test_context.Mock() + atspi_mock.ModifierType = test_context.Mock() + atspi_mock.ModifierType.CONTROL = 2 + atspi_mock.ModifierType.SHIFT = 0 + atspi_mock.ModifierType.ALT = 3 + gi_repo_mock.Atspi = atspi_mock + + essential_modules["focus_manager_instance"] = focus_mgr_instance + essential_modules["script_manager_instance"] = script_mgr_instance + essential_modules["script_instance"] = script_instance + + essential_modules["ax_utilities_class"] = ax_utilities_class + essential_modules["atspi"] = atspi_mock + return essential_modules + + def _setup_input_event_manager(self, test_context) -> tuple: + """Helper method to set up InputEventManager with dependencies.""" + + essential_modules: dict[str, MagicMock] = self._setup_dependencies(test_context) + + test_context.patch("cthulhu.input_event_manager.debug", new=essential_modules["cthulhu.debug"]) + test_context.patch( + "cthulhu.input_event_manager.focus_manager", + new=essential_modules["cthulhu.focus_manager"], + ) + test_context.patch( + "cthulhu.input_event_manager.script_manager", + new=essential_modules["cthulhu.script_manager"], + ) + test_context.patch( + "cthulhu.input_event_manager.input_event", + new=essential_modules["cthulhu.input_event"], + ) + test_context.patch( + "cthulhu.input_event_manager.AXUtilities", + new=essential_modules["ax_utilities_class"], + ) + test_context.patch("cthulhu.input_event_manager.Atspi", new=essential_modules["atspi"]) + + ax_device_mgr_mock = test_context.Mock() + essential_modules["ax_device_manager"] = ax_device_mgr_mock + test_context.patch("cthulhu.input_event_manager.ax_device_manager", new=ax_device_mgr_mock) + + from cthulhu.input_event_manager import InputEventManager + + return InputEventManager(), essential_modules + + def test_init(self, test_context: CthulhuTestContext) -> None: + """Test InputEventManager.__init__.""" + + input_event_manager, _essential_modules = self._setup_input_event_manager(test_context) + assert input_event_manager._last_input_event is None + assert input_event_manager._last_non_modifier_key_event is None + assert input_event_manager._device is None + assert not input_event_manager._mapped_keycodes + assert not input_event_manager._mapped_keysyms + assert not input_event_manager._grabbed_bindings + + def test_start_key_watcher(self, test_context) -> None: + """Test InputEventManager.start_key_watcher.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + mock_device = test_context.Mock() + ax_device_mgr = essential_modules["ax_device_manager"] + ax_device_mgr.get_manager.return_value.get_device.return_value = mock_device + + input_event_manager.start_key_watcher() + + ax_device_mgr.get_manager.return_value.get_device.assert_called_once() + mock_device.add_key_watcher.assert_called_once_with( + input_event_manager.process_keyboard_event, + ) + assert input_event_manager._device is mock_device + + def test_stop_key_watcher(self, test_context: CthulhuTestContext) -> None: + """Test InputEventManager.stop_key_watcher.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._device = test_context.Mock() + input_event_manager.stop_key_watcher() + assert input_event_manager._device is None + essential_modules["cthulhu.debug"].print_message.assert_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "disabled", + "scenario": "disabled", + "is_enabled": False, + "is_bound": True, + "has_grabs": False, + "has_device": False, + "expected_result": [], + "expects_debug_call": False, + }, + { + "id": "unbound", + "scenario": "unbound", + "is_enabled": True, + "is_bound": False, + "has_grabs": False, + "has_device": False, + "expected_result": [], + "expects_debug_call": False, + }, + { + "id": "has_grabs", + "scenario": "has_grabs", + "is_enabled": True, + "is_bound": True, + "has_grabs": True, + "has_device": False, + "existing_grab_ids": [333, 444], + "expected_result": [333, 444], + "expects_debug_call": True, + }, + { + "id": "no_device", + "scenario": "no_device", + "is_enabled": True, + "is_bound": True, + "has_grabs": False, + "has_device": False, + "expected_result": [], + "expects_debug_call": True, + }, + { + "id": "success", + "scenario": "success", + "is_enabled": True, + "is_bound": True, + "has_grabs": False, + "has_device": True, + "expected_result": [111, 222], + "expects_debug_call": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_add_grabs_for_keybinding_scenarios( + self, + test_context: CthulhuTestContext, + case: dict, + ) -> None: + """Test InputEventManager.add_grabs_for_keybinding with various scenarios.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + + mock_binding = test_context.Mock() + mock_binding.is_enabled.return_value = case["is_enabled"] + mock_binding.is_bound.return_value = case["is_bound"] + mock_binding.has_grabs.return_value = case["has_grabs"] + mock_binding.get_grab_ids.return_value = case.get("existing_grab_ids", []) + + if case["has_device"]: + mock_device = test_context.Mock() + mock_device.add_key_grab.side_effect = [111, 222] + input_event_manager._device = mock_device + mock_kd1 = test_context.Mock() + mock_kd2 = test_context.Mock() + mock_binding.key_definitions.return_value = [mock_kd1, mock_kd2] + + result = input_event_manager.add_grabs_for_keybinding(mock_binding, ["Insert", "KP_Insert"]) + + if case["scenario"] == "success": + assert result == case["expected_result"] + assert input_event_manager._grabbed_bindings[111] == mock_binding + assert input_event_manager._grabbed_bindings[222] == mock_binding + mock_device.add_key_grab.assert_has_calls([call(mock_kd1, None), call(mock_kd2, None)]) + else: + assert result == case["expected_result"] + + if case["expects_debug_call"]: + essential_modules["cthulhu.debug"].print_tokens.assert_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "no_device", + "scenario": "no_device", + "has_device": False, + "grab_ids": [], + "has_grabbed_bindings": False, + "expects_debug_tokens": True, + "expects_debug_message": False, + "expects_device_calls": False, + }, + { + "id": "no_grabs", + "scenario": "no_grabs", + "has_device": True, + "grab_ids": [], + "has_grabbed_bindings": False, + "expects_debug_tokens": False, + "expects_debug_message": False, + "expects_device_calls": False, + }, + { + "id": "success", + "scenario": "success", + "has_device": True, + "grab_ids": [111, 222], + "has_grabbed_bindings": True, + "expects_debug_tokens": False, + "expects_debug_message": False, + "expects_device_calls": True, + }, + { + "id": "missing_grab", + "scenario": "missing_grab", + "has_device": True, + "grab_ids": [999], + "has_grabbed_bindings": False, + "expects_debug_tokens": False, + "expects_debug_message": True, + "expects_device_calls": True, + }, + ], + ids=lambda case: case["id"], + ) + def test_remove_grabs_for_keybinding_scenarios( + self, + test_context: CthulhuTestContext, + case: dict, + ) -> None: + """Test InputEventManager.remove_grabs_for_keybinding with various scenarios.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + + if case["has_device"]: + mock_device = test_context.Mock() + input_event_manager._device = mock_device + + if case["has_grabbed_bindings"]: + input_event_manager._grabbed_bindings = { + 111: test_context.Mock(), + 222: test_context.Mock(), + } + + mock_binding = test_context.Mock() + mock_binding.get_grab_ids.return_value = case["grab_ids"] + + input_event_manager.remove_grabs_for_keybinding(mock_binding) + + if case["expects_debug_tokens"]: + essential_modules["cthulhu.debug"].print_tokens.assert_called() + + if case["expects_debug_message"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + + if case["expects_device_calls"]: + if case["scenario"] == "success": + mock_device.remove_key_grab.assert_has_calls([call(111), call(222)]) + assert 111 not in input_event_manager._grabbed_bindings + assert 222 not in input_event_manager._grabbed_bindings + elif case["scenario"] == "missing_grab": + mock_device.remove_key_grab.assert_called_once_with(999) + + @pytest.mark.parametrize( + "case", + [ + { + "id": "keysym_no_device", + "has_device": False, + "input_value": 0x61, + "expected_result": 0, + "device_method": None, + "device_return_value": None, + "mapped_collection": None, + }, + { + "id": "keysym_success", + "has_device": True, + "input_value": 0x61, + "expected_result": 16, + "device_method": "map_keysym_modifier", + "device_return_value": 16, + "mapped_collection": "_mapped_keysyms", + }, + ], + ids=lambda case: case["id"], + ) + def test_map_keysym_to_modifier_scenarios( + self, + test_context: CthulhuTestContext, + case: dict, + ) -> None: + """Test InputEventManager.map_keysym_to_modifier with various scenarios.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + if case["has_device"]: + mock_device = test_context.Mock() + if case["device_method"]: + getattr(mock_device, case["device_method"]).return_value = case[ + "device_return_value" + ] + input_event_manager._device = mock_device + + result = input_event_manager.map_keysym_to_modifier(case["input_value"]) + + assert result == case["expected_result"] + + if case["has_device"] and case["device_method"] and case["mapped_collection"]: + getattr(mock_device, case["device_method"]).assert_called_once_with(case["input_value"]) + collection = getattr(input_event_manager, case["mapped_collection"]) + assert case["input_value"] in collection + elif not case["has_device"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "no_device", + "has_device": False, + "expects_debug_call": True, + "expects_unmap_calls": False, + }, + { + "id": "success", + "has_device": True, + "expects_debug_call": False, + "expects_unmap_calls": True, + }, + ], + ids=lambda case: case["id"], + ) + def test_unmap_all_modifiers_scenarios(self, test_context, case: dict) -> None: + """Test InputEventManager.unmap_all_modifiers scenarios.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._mapped_keycodes = [42, 43] + input_event_manager._mapped_keysyms = [0x61, 0x62] + + if case["has_device"]: + mock_device = test_context.Mock() + input_event_manager._device = mock_device + + input_event_manager.unmap_all_modifiers() + + if case["expects_debug_call"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + + if case["expects_unmap_calls"]: + mock_device.unmap_modifier.assert_has_calls([call(42), call(43)]) + mock_device.unmap_keysym_modifier.assert_has_calls([call(0x61), call(0x62)]) + assert not input_event_manager._mapped_keycodes + assert not input_event_manager._mapped_keysyms + + @pytest.mark.parametrize( + "case", + [ + { + "id": "add_no_device", + "operation": "add", + "scenario": "no_device", + "has_device": False, + "grab_id": None, + "expected_result": -1, + "expects_debug_call": True, + }, + { + "id": "add_success", + "operation": "add", + "scenario": "success", + "has_device": True, + "grab_id": 789, + "expected_result": 789, + "expects_debug_call": False, + }, + { + "id": "remove_no_device", + "operation": "remove", + "scenario": "no_device", + "has_device": False, + "grab_id": 789, + "expected_result": None, + "expects_debug_call": True, + }, + { + "id": "remove_success", + "operation": "remove", + "scenario": "success", + "has_device": True, + "grab_id": 789, + "expected_result": None, + "expects_debug_call": True, + }, + ], + ids=lambda case: case["id"], + ) + def test_grab_for_modifier_scenarios(self, test_context, case: dict) -> None: + """Test InputEventManager add/remove_grab_for_modifier scenarios.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + + if case["has_device"]: + mock_device = test_context.Mock() + if case["operation"] == "add": + mock_device.add_key_grab.return_value = case["grab_id"] + input_event_manager._device = mock_device + + if case["operation"] == "add": + result = input_event_manager.add_grab_for_modifier("Shift", 0xFFE1, 50) + if case["expected_result"] is not None: + assert result == case["expected_result"] + if case["has_device"] and case["scenario"] == "success": + mock_device.add_key_grab.assert_called_once() + else: + input_event_manager.remove_grab_for_modifier("Shift", case["grab_id"]) + if case["has_device"] and case["scenario"] == "success": + mock_device.remove_key_grab.assert_called_once_with(case["grab_id"]) + + if case["expects_debug_call"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "grab_without_reason", + "operation": "grab", + "reason": None, + "expected_reason_text": None, + }, + { + "id": "grab_with_reason", + "operation": "grab", + "reason": "learn mode", + "expected_reason_text": "learn mode", + }, + { + "id": "ungrab_without_reason", + "operation": "ungrab", + "reason": None, + "expected_reason_text": None, + }, + { + "id": "ungrab_with_reason", + "operation": "ungrab", + "reason": "exiting learn mode", + "expected_reason_text": "exiting learn mode", + }, + ], + ids=lambda case: case["id"], + ) + def test_keyboard_grab_scenarios(self, test_context: CthulhuTestContext, case: dict) -> None: + """Test InputEventManager keyboard grab/ungrab operations with and without reasons.""" + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + mock_device = test_context.Mock() + input_event_manager._device = mock_device + + if case["operation"] == "grab": + if case["reason"]: + input_event_manager.grab_keyboard(case["reason"]) + else: + input_event_manager.grab_keyboard() + essential_modules["atspi"].Device.grab_keyboard.assert_called_once_with(mock_device) + else: + if case["reason"]: + input_event_manager.ungrab_keyboard(case["reason"]) + else: + input_event_manager.ungrab_keyboard() + essential_modules["atspi"].Device.ungrab_keyboard.assert_called_once_with(mock_device) + + if case["expected_reason_text"]: + debug_calls = essential_modules["cthulhu.debug"].print_message.call_args_list + assert any(case["expected_reason_text"] in str(call) for call in debug_calls) + + def test_process_braille_event(self, test_context: CthulhuTestContext) -> None: + """Test InputEventManager.process_braille_event.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + mock_event = test_context.Mock() + result = input_event_manager.process_braille_event(mock_event) + assert result is True + assert isinstance( + input_event_manager._last_input_event, + essential_modules["cthulhu.input_event"].BrailleEvent, + ) + assert input_event_manager._last_non_modifier_key_event is None + + def test_process_mouse_button_event(self, test_context: CthulhuTestContext) -> None: + """Test InputEventManager.process_mouse_button_event.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + mock_event = test_context.Mock() + input_event_manager._determine_mouse_event_click_count = test_context.Mock(return_value=2) + input_event_manager.process_mouse_button_event(mock_event) + assert isinstance( + input_event_manager._last_input_event, + essential_modules["cthulhu.input_event"].MouseButtonEvent, + ) + input_event_manager._last_input_event.set_click_count.assert_called_once_with(2) + + @pytest.mark.parametrize( + "case", + [ + { + "id": "key_press_active_window", + "pressed": True, + "keycode": 65, + "keysym": 97, + "modifiers": 0, + "text": "a", + "window_can_be_active": True, + "new_window_found": None, + }, + { + "id": "key_press_inactive_window_with_alternative", + "pressed": True, + "keycode": 65, + "keysym": 97, + "modifiers": 0, + "text": "a", + "window_can_be_active": False, + "new_window_found": "mock_window", + }, + { + "id": "key_press_inactive_window_no_alternative", + "pressed": True, + "keycode": 65, + "keysym": 97, + "modifiers": 0, + "text": "a", + "window_can_be_active": False, + "new_window_found": None, + }, + { + "id": "key_release", + "pressed": False, + "keycode": 65, + "keysym": 97, + "modifiers": 0, + "text": "a", + "window_can_be_active": True, + "new_window_found": None, + }, + ], + ids=lambda case: case["id"], + ) + def test_process_keyboard_event(self, test_context, case: dict) -> None: + """Test InputEventManager.process_keyboard_event.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + new_window_found = case["new_window_found"] + if new_window_found == "mock_window": + new_window_found = test_context.Mock() + mock_device = test_context.Mock() + mock_window = test_context.Mock() + mock_focus = test_context.Mock() + mock_script = test_context.Mock() + mock_focus_manager = essential_modules["cthulhu.focus_manager"] + mock_focus_manager.get_active_window.return_value = mock_window + mock_focus_manager.get_locus_of_focus.return_value = mock_focus + mock_script_manager = essential_modules["cthulhu.script_manager"] + mock_script_manager.get_active_script.return_value = mock_script + essential_modules["ax_utilities_class"].can_be_active_window.return_value = case[ + "window_can_be_active" + ] + essential_modules["ax_utilities_class"].find_active_window.return_value = new_window_found + input_event_manager._determine_keyboard_event_click_count = test_context.Mock( + return_value=1, + ) + result = input_event_manager.process_keyboard_event( + mock_device, + case["pressed"], + case["keycode"], + case["keysym"], + case["modifiers"], + case["text"], + ) + assert result is True + assert isinstance( + input_event_manager._last_input_event, + essential_modules["cthulhu.input_event"].KeyboardEvent, + ) + input_event_manager._last_input_event.process.assert_called_once() + + def test_process_keyboard_event_duplicate(self, test_context: CthulhuTestContext) -> None: + """Test InputEventManager.process_keyboard_event with duplicate event.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + mock_device = test_context.Mock() + keyboard_event_instance = essential_modules["cthulhu.input_event"].KeyboardEvent( + True, + 65, + 97, + 0, + "a", + ) + input_event_manager._last_input_event = keyboard_event_instance + result = input_event_manager.process_keyboard_event(mock_device, True, 65, 97, 0, "a") + assert result is False + essential_modules["cthulhu.debug"].print_message.assert_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "keyboard_true", + "method_name": "last_event_was_keyboard", + "event_class_name": "KeyboardEvent", + "expected_result": True, + }, + { + "id": "keyboard_false", + "method_name": "last_event_was_keyboard", + "event_class_name": "MouseButtonEvent", + "expected_result": False, + }, + { + "id": "mouse_button_true", + "method_name": "last_event_was_mouse_button", + "event_class_name": "MouseButtonEvent", + "expected_result": True, + }, + { + "id": "mouse_button_false", + "method_name": "last_event_was_mouse_button", + "event_class_name": "KeyboardEvent", + "expected_result": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_basic_event_types( + self, + test_context: CthulhuTestContext, + case: dict, + ) -> None: + """Test InputEventManager basic event type detection methods.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + + if case["event_class_name"] == "KeyboardEvent": + event_class = essential_modules["cthulhu.input_event"].KeyboardEvent + mock_event = event_class() + elif case["event_class_name"] == "MouseButtonEvent": + event_class = essential_modules["cthulhu.input_event"].MouseButtonEvent + mock_event = event_class() + else: + mock_event = test_context.Mock() + + input_event_manager._last_input_event = mock_event + method = getattr(input_event_manager, case["method_name"]) + result = method() + assert result is case["expected_result"] + + @pytest.mark.parametrize( + "case", + [ + { + "id": "event1_none", + "event1": None, + "event2": "mock_event", + "event1_pressed": None, + "event2_pressed": None, + "same_id": None, + "same_hw_code": None, + "same_keyval": None, + "event1_modifier": None, + "same_modifiers": None, + "expected_result": False, + }, + { + "id": "event2_none", + "event1": "mock_event", + "event2": None, + "event1_pressed": None, + "event2_pressed": None, + "same_id": None, + "same_hw_code": None, + "same_keyval": None, + "event1_modifier": None, + "same_modifiers": None, + "expected_result": False, + }, + { + "id": "release_for_press", + "event1": "mock_event1", + "event2": "mock_event2", + "event1_pressed": False, + "event2_pressed": True, + "same_id": True, + "same_hw_code": True, + "same_keyval": True, + "event1_modifier": False, + "same_modifiers": True, + "expected_result": True, + }, + { + "id": "both_pressed", + "event1": "mock_event1", + "event2": "mock_event2", + "event1_pressed": True, + "event2_pressed": True, + "same_id": True, + "same_hw_code": True, + "same_keyval": True, + "event1_modifier": False, + "same_modifiers": True, + "expected_result": False, + }, + { + "id": "both_released", + "event1": "mock_event1", + "event2": "mock_event2", + "event1_pressed": False, + "event2_pressed": False, + "same_id": True, + "same_hw_code": True, + "same_keyval": True, + "event1_modifier": False, + "same_modifiers": True, + "expected_result": False, + }, + { + "id": "different_id", + "event1": "mock_event1", + "event2": "mock_event2", + "event1_pressed": False, + "event2_pressed": True, + "same_id": False, + "same_hw_code": True, + "same_keyval": True, + "event1_modifier": False, + "same_modifiers": True, + "expected_result": False, + }, + { + "id": "modifier_key_ignore_modifiers", + "event1": "mock_event1", + "event2": "mock_event2", + "event1_pressed": False, + "event2_pressed": True, + "same_id": True, + "same_hw_code": True, + "same_keyval": True, + "event1_modifier": True, + "same_modifiers": True, + "expected_result": True, + }, + { + "id": "modifier_key_different_modifiers", + "event1": "mock_event1", + "event2": "mock_event2", + "event1_pressed": False, + "event2_pressed": True, + "same_id": True, + "same_hw_code": True, + "same_keyval": True, + "event1_modifier": True, + "same_modifiers": False, + "expected_result": True, + }, + { + "id": "non_modifier_different_modifiers", + "event1": "mock_event1", + "event2": "mock_event2", + "event1_pressed": False, + "event2_pressed": True, + "same_id": True, + "same_hw_code": True, + "same_keyval": True, + "event1_modifier": False, + "same_modifiers": False, + "expected_result": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_is_release_for( + self, + test_context, + case: dict, + ) -> None: + """Test InputEventManager.is_release_for.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + event1 = case["event1"] + event2 = case["event2"] + if event1 in ("mock_event", "mock_event1"): + event1 = test_context.Mock() + if event2 in ("mock_event", "mock_event2"): + event2 = test_context.Mock() + if event1 is not None and event2 is not None: + keyboard_event_class = essential_modules["cthulhu.input_event"].KeyboardEvent + event1 = keyboard_event_class() + event2 = keyboard_event_class() + event1.is_pressed_key.return_value = case["event1_pressed"] + event2.is_pressed_key.return_value = case["event2_pressed"] + event1.id = "test_id" if case["same_id"] else "other_id" + event2.id = "test_id" + event1.hw_code = 42 if case["same_hw_code"] else 43 + event2.hw_code = 42 + event1.keyval_name = "a" if case["same_keyval"] else "b" + event2.keyval_name = "a" + event1.is_modifier_key.return_value = case["event1_modifier"] + event1.modifiers = 4 if case["same_modifiers"] else 8 + event2.modifiers = 4 + event1.as_single_line_string.return_value = "event1" + event2.as_single_line_string.return_value = "event2" + result = input_event_manager.is_release_for(event1, event2) + assert result == case["expected_result"] + + def test_last_event_equals_or_is_release_for_event_no_last_event( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test InputEventManager.last_event_equals_or_is_release_for_event with no last event.""" + + input_event_manager, _essential_modules = self._setup_input_event_manager(test_context) + mock_event = test_context.Mock() + result = input_event_manager.last_event_equals_or_is_release_for_event(mock_event) + assert result is False + + def test_last_event_equals_or_is_release_for_event_equal( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test InputEventManager.last_event_equals_or_is_release_for_event with equal events.""" + + input_event_manager, _essential_modules = self._setup_input_event_manager(test_context) + mock_event = test_context.Mock() + input_event_manager._last_non_modifier_key_event = mock_event + input_event_manager.last_event_was_keyboard = test_context.Mock(return_value=True) + result = input_event_manager.last_event_equals_or_is_release_for_event(mock_event) + assert result is True + + def test_last_event_equals_or_is_release_for_event_is_release( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test InputEventManager.last_event_equals_or_is_release_for_event with release event.""" + + input_event_manager, _essential_modules = self._setup_input_event_manager(test_context) + mock_event = test_context.Mock() + mock_last_event = test_context.Mock() + input_event_manager._last_non_modifier_key_event = mock_last_event + input_event_manager.last_event_was_keyboard = test_context.Mock(return_value=True) + + input_event_manager.is_release_for = test_context.Mock(return_value=True) + result = input_event_manager.last_event_equals_or_is_release_for_event(mock_event) + assert result is True + input_event_manager.is_release_for.assert_called_once_with(mock_last_event, mock_event) + + @pytest.mark.parametrize( + "case", + [ + { + "id": "no_last_non_modifier", + "has_last_non_modifier": False, + "last_event_keyboard": True, + "expected_key": "", + "expected_modifiers": 0, + }, + { + "id": "last_not_keyboard", + "has_last_non_modifier": True, + "last_event_keyboard": False, + "expected_key": "", + "expected_modifiers": 0, + }, + { + "id": "has_both", + "has_last_non_modifier": True, + "last_event_keyboard": True, + "expected_key": "a", + "expected_modifiers": 4, + }, + ], + ids=lambda case: case["id"], + ) + def test_last_key_and_modifiers( + self, + test_context, + case: dict, + ) -> None: + """Test InputEventManager._last_key_and_modifiers.""" + + input_event_manager, _essential_modules = self._setup_input_event_manager(test_context) + if case["has_last_non_modifier"]: + mock_last_non_modifier = test_context.Mock() + mock_last_non_modifier.keyval_name = "a" + input_event_manager._last_non_modifier_key_event = mock_last_non_modifier + else: + input_event_manager._last_non_modifier_key_event = None + if case["last_event_keyboard"]: + mock_last_event = test_context.Mock() + mock_last_event.modifiers = 4 + input_event_manager._last_input_event = mock_last_event + input_event_manager.last_event_was_keyboard = test_context.Mock( + return_value=case["last_event_keyboard"], + ) + result = input_event_manager._last_key_and_modifiers() + assert result == (case["expected_key"], case["expected_modifiers"]) + + @pytest.mark.parametrize( + "case", + [ + { + "id": "command_true", + "method_name": "last_event_was_command", + "key_modifiers": ("a", 1 << 2), + "expected_result": True, + "expects_debug": True, + }, + { + "id": "command_false", + "method_name": "last_event_was_command", + "key_modifiers": ("a", 0), + "expected_result": False, + "expects_debug": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_command( + self, + test_context: CthulhuTestContext, + case: dict, + ) -> None: + """Test InputEventManager command detection methods.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=case["key_modifiers"], + ) + method = getattr(input_event_manager, case["method_name"]) + result = method() + assert result is case["expected_result"] + + if case["expects_debug"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + else: + essential_modules["cthulhu.debug"].print_message.assert_not_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "empty_key_string", + "key_string": "", + "has_shortcut": False, + "expected_result": False, + }, + { + "id": "matching_shortcut", + "key_string": "f", + "has_shortcut": True, + "expected_result": True, + }, + { + "id": "no_matching_shortcut", + "key_string": "f", + "has_shortcut": False, + "expected_result": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_shortcut_for( + self, + test_context, + case: dict, + ) -> None: + """Test InputEventManager.last_event_was_shortcut_for.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + mock_obj = test_context.Mock() + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=(case["key_string"], 0), + ) + ax_utilities_class = essential_modules["cthulhu.ax_utilities"].AXUtilities + ax_utilities_class.has_matching_shortcut.return_value = case["has_shortcut"] + result = input_event_manager.last_event_was_shortcut_for(mock_obj) + assert result == case["expected_result"] + if case["expected_result"]: + essential_modules["cthulhu.debug"].print_tokens.assert_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "printable_key_true", + "is_keyboard": True, + "is_printable": True, + "expected_result": True, + "expects_debug": True, + }, + { + "id": "not_keyboard", + "is_keyboard": False, + "is_printable": None, + "expected_result": False, + "expects_debug": False, + }, + { + "id": "not_printable", + "is_keyboard": True, + "is_printable": False, + "expected_result": False, + "expects_debug": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_printable_key( + self, + test_context: CthulhuTestContext, + case: dict, + ) -> None: + """Test InputEventManager.last_event_was_printable_key with various scenarios.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + input_event_manager.last_event_was_keyboard = test_context.Mock( + return_value=case["is_keyboard"], + ) + + if case["is_keyboard"] and case["is_printable"] is not None: + mock_last_event = test_context.Mock() + mock_last_event.is_printable_key.return_value = case["is_printable"] + input_event_manager._last_input_event = mock_last_event + + result = input_event_manager.last_event_was_printable_key() + assert result is case["expected_result"] + + if case["expects_debug"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + else: + essential_modules["cthulhu.debug"].print_message.assert_not_called() + + def test_last_event_was_caret_navigation(self, test_context: CthulhuTestContext) -> None: + """Test InputEventManager.last_event_was_caret_navigation.""" + + input_event_manager, _essential_modules = self._setup_input_event_manager(test_context) + input_event_manager.last_event_was_character_navigation = test_context.Mock( + return_value=False, + ) + input_event_manager.last_event_was_word_navigation = test_context.Mock(return_value=True) + input_event_manager.last_event_was_line_navigation = test_context.Mock(return_value=False) + input_event_manager.last_event_was_line_boundary_navigation = test_context.Mock( + return_value=False, + ) + input_event_manager.last_event_was_file_boundary_navigation = test_context.Mock( + return_value=False, + ) + input_event_manager.last_event_was_page_navigation = test_context.Mock(return_value=False) + result = input_event_manager.last_event_was_caret_navigation() + assert result is True + + @pytest.mark.parametrize( + "case", + [ + { + "id": "left_with_shift", + "key_string": "Left", + "modifiers": 1 << 0, + "expected_result": True, + }, + { + "id": "right_with_shift", + "key_string": "Right", + "modifiers": 1 << 0, + "expected_result": True, + }, + { + "id": "up_with_shift", + "key_string": "Up", + "modifiers": 1 << 0, + "expected_result": True, + }, + { + "id": "down_with_shift", + "key_string": "Down", + "modifiers": 1 << 0, + "expected_result": True, + }, + { + "id": "left_without_shift", + "key_string": "Left", + "modifiers": 0, + "expected_result": False, + }, + { + "id": "non_arrow_with_shift", + "key_string": "a", + "modifiers": 1 << 0, + "expected_result": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_caret_selection( + self, + test_context, + case: dict, + ) -> None: + """Test InputEventManager.last_event_was_caret_selection.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=(case["key_string"], case["modifiers"]), + ) + result = input_event_manager.last_event_was_caret_selection() + assert result == case["expected_result"] + if case["expected_result"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + + @pytest.mark.parametrize( + "case", + [ + {"id": "up_without_shift", "key_string": "Up", "modifiers": 0, "expected_result": True}, + { + "id": "left_without_shift", + "key_string": "Left", + "modifiers": 0, + "expected_result": True, + }, + { + "id": "down_without_shift", + "key_string": "Down", + "modifiers": 0, + "expected_result": False, + }, + { + "id": "right_without_shift", + "key_string": "Right", + "modifiers": 0, + "expected_result": False, + }, + { + "id": "up_with_shift", + "key_string": "Up", + "modifiers": 1 << 0, + "expected_result": False, + }, + {"id": "non_arrow", "key_string": "a", "modifiers": 0, "expected_result": False}, + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_backward_caret_navigation( + self, + test_context, + case: dict, + ) -> None: + """Test InputEventManager.last_event_was_backward_caret_navigation.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=(case["key_string"], case["modifiers"]), + ) + result = input_event_manager.last_event_was_backward_caret_navigation() + assert result == case["expected_result"] + if case["expected_result"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "down_without_shift", + "key_string": "Down", + "modifiers": 0, + "expected_result": True, + }, + { + "id": "right_without_shift", + "key_string": "Right", + "modifiers": 0, + "expected_result": True, + }, + { + "id": "up_without_shift", + "key_string": "Up", + "modifiers": 0, + "expected_result": False, + }, + { + "id": "left_without_shift", + "key_string": "Left", + "modifiers": 0, + "expected_result": False, + }, + { + "id": "down_with_shift", + "key_string": "Down", + "modifiers": 1 << 0, + "expected_result": False, + }, + {"id": "non_arrow", "key_string": "a", "modifiers": 0, "expected_result": False}, + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_forward_caret_navigation( + self, + test_context, + case: dict, + ) -> None: + """Test InputEventManager.last_event_was_forward_caret_navigation.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=(case["key_string"], case["modifiers"]), + ) + result = input_event_manager.last_event_was_forward_caret_navigation() + assert result == case["expected_result"] + if case["expected_result"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "down_with_shift", + "key_string": "Down", + "modifiers": 1 << 0, + "expected_result": True, + }, + { + "id": "right_with_shift", + "key_string": "Right", + "modifiers": 1 << 0, + "expected_result": True, + }, + { + "id": "up_with_shift", + "key_string": "Up", + "modifiers": 1 << 0, + "expected_result": False, + }, + { + "id": "left_with_shift", + "key_string": "Left", + "modifiers": 1 << 0, + "expected_result": False, + }, + { + "id": "down_without_shift", + "key_string": "Down", + "modifiers": 0, + "expected_result": False, + }, + {"id": "non_arrow", "key_string": "a", "modifiers": 1 << 0, "expected_result": False}, + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_forward_caret_selection( + self, + test_context, + case: dict, + ) -> None: + """Test InputEventManager.last_event_was_forward_caret_selection.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=(case["key_string"], case["modifiers"]), + ) + result = input_event_manager.last_event_was_forward_caret_selection() + assert result == case["expected_result"] + if case["expected_result"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "left_no_modifiers", + "key_string": "Left", + "modifiers": 0, + "expected_result": True, + }, + { + "id": "right_no_modifiers", + "key_string": "Right", + "modifiers": 0, + "expected_result": True, + }, + { + "id": "left_with_ctrl", + "key_string": "Left", + "modifiers": 1 << 2, + "expected_result": False, + }, + { + "id": "right_with_alt", + "key_string": "Right", + "modifiers": 1 << 3, + "expected_result": False, + }, + { + "id": "non_horizontal_arrow", + "key_string": "Up", + "modifiers": 0, + "expected_result": False, + }, + {"id": "non_arrow", "key_string": "a", "modifiers": 0, "expected_result": False}, + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_character_navigation( + self, + test_context, + case: dict, + ) -> None: + """Test InputEventManager.last_event_was_character_navigation.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=(case["key_string"], case["modifiers"]), + ) + result = input_event_manager.last_event_was_character_navigation() + assert result == case["expected_result"] + if case["expected_result"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "left_with_ctrl", + "key_string": "Left", + "modifiers": 1 << 2, + "expected_result": True, + }, + { + "id": "right_with_ctrl", + "key_string": "Right", + "modifiers": 1 << 2, + "expected_result": True, + }, + {"id": "left_no_ctrl", "key_string": "Left", "modifiers": 0, "expected_result": False}, + { + "id": "right_no_ctrl", + "key_string": "Right", + "modifiers": 0, + "expected_result": False, + }, + { + "id": "non_horizontal_arrow", + "key_string": "Up", + "modifiers": 1 << 2, + "expected_result": False, + }, + {"id": "non_arrow", "key_string": "a", "modifiers": 1 << 2, "expected_result": False}, + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_word_navigation( + self, + test_context, + case: dict, + ) -> None: + """Test InputEventManager.last_event_was_word_navigation.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=(case["key_string"], case["modifiers"]), + ) + result = input_event_manager.last_event_was_word_navigation() + assert result == case["expected_result"] + if case["expected_result"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "left_with_ctrl", + "key_string": "Left", + "modifiers": 1 << 2, + "expected_result": True, + }, + { + "id": "right_with_ctrl", + "key_string": "Right", + "modifiers": 1 << 2, + "expected_result": False, + }, + {"id": "left_no_ctrl", "key_string": "Left", "modifiers": 0, "expected_result": False}, + {"id": "non_arrow", "key_string": "a", "modifiers": 1 << 2, "expected_result": False}, + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_previous_word_navigation( + self, + test_context, + case: dict, + ) -> None: + """Test InputEventManager.last_event_was_previous_word_navigation.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=(case["key_string"], case["modifiers"]), + ) + result = input_event_manager.last_event_was_previous_word_navigation() + assert result == case["expected_result"] + if case["expected_result"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "right_with_ctrl", + "key_string": "Right", + "modifiers": 1 << 2, + "expected_result": True, + }, + { + "id": "left_with_ctrl", + "key_string": "Left", + "modifiers": 1 << 2, + "expected_result": False, + }, + { + "id": "right_no_ctrl", + "key_string": "Right", + "modifiers": 0, + "expected_result": False, + }, + {"id": "non_arrow", "key_string": "a", "modifiers": 1 << 2, "expected_result": False}, + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_next_word_navigation( + self, + test_context, + case: dict, + ) -> None: + """Test InputEventManager.last_event_was_next_word_navigation.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=(case["key_string"], case["modifiers"]), + ) + result = input_event_manager.last_event_was_next_word_navigation() + assert result == case["expected_result"] + if case["expected_result"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "up_multiline_not_controlled", + "key_string": "Up", + "modifiers": 0, + "is_single_line": False, + "is_widget_controlled": False, + "expected_result": True, + }, + { + "id": "down_multiline_not_controlled", + "key_string": "Down", + "modifiers": 0, + "is_single_line": False, + "is_widget_controlled": False, + "expected_result": True, + }, + { + "id": "up_with_ctrl", + "key_string": "Up", + "modifiers": 1 << 2, + "is_single_line": False, + "is_widget_controlled": False, + "expected_result": False, + }, + { + "id": "up_single_line", + "key_string": "Up", + "modifiers": 0, + "is_single_line": True, + "is_widget_controlled": False, + "expected_result": False, + }, + { + "id": "up_widget_controlled", + "key_string": "Up", + "modifiers": 0, + "is_single_line": False, + "is_widget_controlled": True, + "expected_result": False, + }, + { + "id": "non_vertical_arrow", + "key_string": "Left", + "modifiers": 0, + "is_single_line": False, + "is_widget_controlled": False, + "expected_result": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_line_navigation( + self, + test_context, + case: dict, + ) -> None: + """Test InputEventManager.last_event_was_line_navigation.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=(case["key_string"], case["modifiers"]), + ) + mock_focus = test_context.Mock() + essential_modules["cthulhu.focus_manager"].get_locus_of_focus.return_value = mock_focus + essential_modules["ax_utilities_class"].is_single_line.return_value = case["is_single_line"] + essential_modules[ + "ax_utilities_class" + ].is_widget_controlled_by_line_navigation.return_value = case["is_widget_controlled"] + result = input_event_manager.last_event_was_line_navigation() + assert result == case["expected_result"] + if case["expected_result"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "up_with_ctrl_no_shift", + "key_string": "Up", + "modifiers": (1 << 2), + "expected_result": True, + }, + { + "id": "down_with_ctrl_no_shift", + "key_string": "Down", + "modifiers": (1 << 2), + "expected_result": True, + }, + { + "id": "up_with_ctrl_and_shift", + "key_string": "Up", + "modifiers": (1 << 2) | (1 << 0), + "expected_result": False, + }, + {"id": "up_no_ctrl", "key_string": "Up", "modifiers": 0, "expected_result": False}, + { + "id": "non_vertical_arrow", + "key_string": "Left", + "modifiers": 1 << 2, + "expected_result": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_paragraph_navigation( + self, + test_context, + case: dict, + ) -> None: + """Test InputEventManager.last_event_was_paragraph_navigation.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=(case["key_string"], case["modifiers"]), + ) + result = input_event_manager.last_event_was_paragraph_navigation() + assert result == case["expected_result"] + if case["expected_result"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + + @pytest.mark.parametrize( + "case", + [ + {"id": "home_no_ctrl", "key_string": "Home", "modifiers": 0, "expected_result": True}, + {"id": "end_no_ctrl", "key_string": "End", "modifiers": 0, "expected_result": True}, + { + "id": "home_with_ctrl", + "key_string": "Home", + "modifiers": 1 << 2, + "expected_result": False, + }, + { + "id": "end_with_ctrl", + "key_string": "End", + "modifiers": 1 << 2, + "expected_result": False, + }, + { + "id": "non_boundary_key", + "key_string": "Left", + "modifiers": 0, + "expected_result": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_line_boundary_navigation( + self, + test_context, + case: dict, + ) -> None: + """Test InputEventManager.last_event_was_line_boundary_navigation.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=(case["key_string"], case["modifiers"]), + ) + result = input_event_manager.last_event_was_line_boundary_navigation() + assert result == case["expected_result"] + if case["expected_result"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "home_with_ctrl", + "key_string": "Home", + "modifiers": 1 << 2, + "expected_result": True, + }, + { + "id": "end_with_ctrl", + "key_string": "End", + "modifiers": 1 << 2, + "expected_result": True, + }, + {"id": "home_no_ctrl", "key_string": "Home", "modifiers": 0, "expected_result": False}, + {"id": "end_no_ctrl", "key_string": "End", "modifiers": 0, "expected_result": False}, + { + "id": "non_boundary_key", + "key_string": "Left", + "modifiers": 1 << 2, + "expected_result": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_file_boundary_navigation( + self, + test_context, + case: dict, + ) -> None: + """Test InputEventManager.last_event_was_file_boundary_navigation.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=(case["key_string"], case["modifiers"]), + ) + result = input_event_manager.last_event_was_file_boundary_navigation() + assert result == case["expected_result"] + if case["expected_result"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "page_up_multiline_not_controlled", + "key_string": "Page_Up", + "modifiers": 0, + "is_single_line": False, + "is_widget_controlled": False, + "expected_result": True, + }, + { + "id": "page_down_multiline_not_controlled", + "key_string": "Page_Down", + "modifiers": 0, + "is_single_line": False, + "is_widget_controlled": False, + "expected_result": True, + }, + { + "id": "page_up_with_ctrl", + "key_string": "Page_Up", + "modifiers": 1 << 2, + "is_single_line": False, + "is_widget_controlled": False, + "expected_result": False, + }, + { + "id": "page_up_single_line", + "key_string": "Page_Up", + "modifiers": 0, + "is_single_line": True, + "is_widget_controlled": False, + "expected_result": False, + }, + { + "id": "page_up_widget_controlled", + "key_string": "Page_Up", + "modifiers": 0, + "is_single_line": False, + "is_widget_controlled": True, + "expected_result": False, + }, + { + "id": "non_page_key", + "key_string": "Left", + "modifiers": 0, + "is_single_line": False, + "is_widget_controlled": False, + "expected_result": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_page_navigation( + self, + test_context, + case: dict, + ) -> None: + """Test InputEventManager.last_event_was_page_navigation.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=(case["key_string"], case["modifiers"]), + ) + mock_focus = test_context.Mock() + essential_modules["cthulhu.focus_manager"].get_locus_of_focus.return_value = mock_focus + essential_modules["ax_utilities_class"].is_single_line.return_value = case["is_single_line"] + essential_modules[ + "ax_utilities_class" + ].is_widget_controlled_by_line_navigation.return_value = case["is_widget_controlled"] + result = input_event_manager.last_event_was_page_navigation() + assert result == case["expected_result"] + if case["expected_result"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "numeric_with_alt", + "key_string": "1", + "modifiers": 1 << 3, + "expected_result": True, + }, + { + "id": "numeric_2_with_alt", + "key_string": "2", + "modifiers": 1 << 3, + "expected_result": True, + }, + { + "id": "page_up_with_ctrl", + "key_string": "Page_Up", + "modifiers": 1 << 2, + "expected_result": True, + }, + { + "id": "page_down_with_ctrl", + "key_string": "Page_Down", + "modifiers": 1 << 2, + "expected_result": True, + }, + {"id": "numeric_no_alt", "key_string": "1", "modifiers": 0, "expected_result": False}, + { + "id": "page_up_no_ctrl", + "key_string": "Page_Up", + "modifiers": 0, + "expected_result": False, + }, + { + "id": "non_numeric_non_page", + "key_string": "a", + "modifiers": 1 << 3, + "expected_result": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_page_switch( + self, + test_context, + case: dict, + ) -> None: + """Test InputEventManager.last_event_was_page_switch.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=(case["key_string"], case["modifiers"]), + ) + result = input_event_manager.last_event_was_page_switch() + assert result == case["expected_result"] + if case["expected_result"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "tab_no_modifiers", + "key_string": "Tab", + "modifiers": 0, + "expected_result": True, + }, + { + "id": "shift_tab_no_modifiers", + "key_string": "ISO_Left_Tab", + "modifiers": 0, + "expected_result": True, + }, + { + "id": "tab_with_ctrl", + "key_string": "Tab", + "modifiers": 1 << 2, + "expected_result": False, + }, + { + "id": "tab_with_alt", + "key_string": "Tab", + "modifiers": 1 << 3, + "expected_result": False, + }, + {"id": "non_tab_key", "key_string": "a", "modifiers": 0, "expected_result": False}, + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_tab_navigation( + self, + test_context, + case: dict, + ) -> None: + """Test InputEventManager.last_event_was_tab_navigation.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=(case["key_string"], case["modifiers"]), + ) + result = input_event_manager.last_event_was_tab_navigation() + assert result == case["expected_result"] + if case["expected_result"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "not_table_header", + "is_table_header": False, + "is_mouse_button": True, + "is_primary_click": True, + "is_keyboard": False, + "is_return_or_space": False, + "expected_result": False, + }, + { + "id": "table_header_mouse_primary_click", + "is_table_header": True, + "is_mouse_button": True, + "is_primary_click": True, + "is_keyboard": False, + "is_return_or_space": False, + "expected_result": True, + }, + { + "id": "table_header_mouse_not_primary", + "is_table_header": True, + "is_mouse_button": True, + "is_primary_click": False, + "is_keyboard": False, + "is_return_or_space": False, + "expected_result": False, + }, + { + "id": "table_header_keyboard_return_space", + "is_table_header": True, + "is_mouse_button": False, + "is_primary_click": False, + "is_keyboard": True, + "is_return_or_space": True, + "expected_result": True, + }, + { + "id": "table_header_keyboard_not_return_space", + "is_table_header": True, + "is_mouse_button": False, + "is_primary_click": False, + "is_keyboard": True, + "is_return_or_space": False, + "expected_result": False, + }, + { + "id": "table_header_neither_mouse_nor_keyboard", + "is_table_header": True, + "is_mouse_button": False, + "is_primary_click": False, + "is_keyboard": False, + "is_return_or_space": False, + "expected_result": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_table_sort( + self, + test_context, + case: dict, + ) -> None: + """Test InputEventManager.last_event_was_table_sort.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + mock_focus = test_context.Mock() + essential_modules["cthulhu.focus_manager"].get_locus_of_focus.return_value = mock_focus + essential_modules["ax_utilities_class"].is_table_header.return_value = case[ + "is_table_header" + ] + input_event_manager.last_event_was_mouse_button = test_context.Mock( + return_value=case["is_mouse_button"], + ) + input_event_manager.last_event_was_primary_click = test_context.Mock( + return_value=case["is_primary_click"], + ) + input_event_manager.last_event_was_keyboard = test_context.Mock( + return_value=case["is_keyboard"], + ) + input_event_manager.last_event_was_return_or_space = test_context.Mock( + return_value=case["is_return_or_space"], + ) + result = input_event_manager.last_event_was_table_sort() + assert result == case["expected_result"] + if case["expected_result"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "left_no_modifiers", + "key_string": "Left", + "modifiers": 0, + "expected_result": True, + }, + { + "id": "right_no_modifiers", + "key_string": "Right", + "modifiers": 0, + "expected_result": True, + }, + {"id": "up_no_modifiers", "key_string": "Up", "modifiers": 0, "expected_result": True}, + { + "id": "down_no_modifiers", + "key_string": "Down", + "modifiers": 0, + "expected_result": True, + }, + { + "id": "left_with_ctrl", + "key_string": "Left", + "modifiers": 1 << 2, + "expected_result": False, + }, + { + "id": "left_with_shift", + "key_string": "Left", + "modifiers": 1 << 0, + "expected_result": False, + }, + { + "id": "left_with_alt", + "key_string": "Left", + "modifiers": 1 << 3, + "expected_result": False, + }, + { + "id": "left_with_cthulhu_modifier", + "key_string": "Left", + "modifiers": 1 << 8, + "expected_result": False, + }, + {"id": "non_arrow_key", "key_string": "a", "modifiers": 0, "expected_result": False}, + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_unmodified_arrow( + self, + test_context, + case: dict, + ) -> None: + """Test InputEventManager.last_event_was_unmodified_arrow.""" + + input_event_manager, _essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=(case["key_string"], case["modifiers"]), + ) + result = input_event_manager.last_event_was_unmodified_arrow() + assert result == case["expected_result"] + + @pytest.mark.parametrize( + "case", + [ + { + "id": "has_alt_modifier", + "modifiers": 1 << 3, + "expected_result": 8, + }, # Returns bitwise result, not boolean + {"id": "no_alt_modifier", "modifiers": 0, "expected_result": 0}, + { + "id": "alt_and_ctrl_modifiers", + "modifiers": (1 << 3) | (1 << 2), + "expected_result": 8, + }, # Alt bit is still 8 + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_alt_modified(self, test_context, case: dict) -> None: + """Test InputEventManager.last_event_was_alt_modified.""" + + input_event_manager, _essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=("a", case["modifiers"]), + ) + result = input_event_manager.last_event_was_alt_modified() + assert result == case["expected_result"] + + @pytest.mark.parametrize( + "case", + [ + {"id": "backspace_key", "key_string": "BackSpace", "expected_result": True}, + {"id": "delete_key", "key_string": "Delete", "expected_result": False}, + {"id": "regular_key", "key_string": "a", "expected_result": False}, + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_backspace(self, test_context, case: dict) -> None: + """Test InputEventManager.last_event_was_backspace.""" + + input_event_manager, _essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=(case["key_string"], 0), + ) + result = input_event_manager.last_event_was_backspace() + assert result == case["expected_result"] + + @pytest.mark.parametrize( + "case", + [ + {"id": "down_key", "key_string": "Down", "expected_result": True}, + {"id": "up_key", "key_string": "Up", "expected_result": False}, + {"id": "regular_key", "key_string": "a", "expected_result": False}, + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_down(self, test_context, case: dict) -> None: + """Test InputEventManager.last_event_was_down.""" + + input_event_manager, _essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=(case["key_string"], 0), + ) + result = input_event_manager.last_event_was_down() + assert result == case["expected_result"] + + @pytest.mark.parametrize( + "case", + [ + {"id": "f1_key", "key_string": "F1", "expected_result": True}, + {"id": "f2_key", "key_string": "F2", "expected_result": False}, + {"id": "regular_key", "key_string": "a", "expected_result": False}, + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_f1(self, test_context, case: dict) -> None: + """Test InputEventManager.last_event_was_f1.""" + + input_event_manager, _essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=(case["key_string"], 0), + ) + result = input_event_manager.last_event_was_f1() + assert result == case["expected_result"] + + @pytest.mark.parametrize( + "case", + [ + {"id": "left_key", "key_string": "Left", "expected_result": True}, + {"id": "right_key", "key_string": "Right", "expected_result": False}, + {"id": "regular_key", "key_string": "a", "expected_result": False}, + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_left(self, test_context, case: dict) -> None: + """Test InputEventManager.last_event_was_left.""" + + input_event_manager, _essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=(case["key_string"], 0), + ) + result = input_event_manager.last_event_was_left() + assert result == case["expected_result"] + + @pytest.mark.parametrize( + "case", + [ + { + "id": "left_or_right_left_key", + "method_name": "last_event_was_left_or_right", + "key_string": "Left", + "expected_result": True, + }, + { + "id": "left_or_right_right_key", + "method_name": "last_event_was_left_or_right", + "key_string": "Right", + "expected_result": True, + }, + { + "id": "left_or_right_up_key", + "method_name": "last_event_was_left_or_right", + "key_string": "Up", + "expected_result": False, + }, + { + "id": "page_up_or_down_page_up", + "method_name": "last_event_was_page_up_or_page_down", + "key_string": "Page_Up", + "expected_result": True, + }, + { + "id": "page_up_or_down_page_down", + "method_name": "last_event_was_page_up_or_page_down", + "key_string": "Page_Down", + "expected_result": True, + }, + { + "id": "page_up_or_down_up_key", + "method_name": "last_event_was_page_up_or_page_down", + "key_string": "Up", + "expected_result": False, + }, + { + "id": "right_right_key", + "method_name": "last_event_was_right", + "key_string": "Right", + "expected_result": True, + }, + { + "id": "right_left_key", + "method_name": "last_event_was_right", + "key_string": "Left", + "expected_result": False, + }, + { + "id": "return_return_key", + "method_name": "last_event_was_return", + "key_string": "Return", + "expected_result": True, + }, + { + "id": "return_enter_key", + "method_name": "last_event_was_return", + "key_string": "Enter", + "expected_result": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_single_key_detection(self, test_context, case: dict) -> None: + """Test InputEventManager single key detection methods.""" + + input_event_manager, _essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=(case["key_string"], 0), + ) + method = getattr(input_event_manager, case["method_name"]) + result = method() + assert result == case["expected_result"] + + @pytest.mark.parametrize( + "case", + [ + { + "id": "return_or_space_return", + "method_name": "last_event_was_return_or_space", + "key_string": "Return", + "expected_result": True, + }, + { + "id": "return_or_space_space_key", + "method_name": "last_event_was_return_or_space", + "key_string": "space", + "expected_result": True, + }, + { + "id": "return_or_space_space_char", + "method_name": "last_event_was_return_or_space", + "key_string": " ", + "expected_result": True, + }, + { + "id": "return_or_space_enter", + "method_name": "last_event_was_return_or_space", + "key_string": "Enter", + "expected_result": False, + }, + { + "id": "return_tab_space_return", + "method_name": "last_event_was_return_tab_or_space", + "key_string": "Return", + "expected_result": True, + }, + { + "id": "return_tab_space_tab", + "method_name": "last_event_was_return_tab_or_space", + "key_string": "Tab", + "expected_result": True, + }, + { + "id": "return_tab_space_space_key", + "method_name": "last_event_was_return_tab_or_space", + "key_string": "space", + "expected_result": True, + }, + { + "id": "return_tab_space_space_char", + "method_name": "last_event_was_return_tab_or_space", + "key_string": " ", + "expected_result": True, + }, + { + "id": "return_tab_space_enter", + "method_name": "last_event_was_return_tab_or_space", + "key_string": "Enter", + "expected_result": False, + }, + { + "id": "space_space_character", + "method_name": "last_event_was_space", + "key_string": " ", + "expected_result": True, + }, + { + "id": "space_space_key", + "method_name": "last_event_was_space", + "key_string": "space", + "expected_result": True, + }, + { + "id": "space_return_key", + "method_name": "last_event_was_space", + "key_string": "Return", + "expected_result": False, + }, + { + "id": "tab_tab_key", + "method_name": "last_event_was_tab", + "key_string": "Tab", + "expected_result": True, + }, + { + "id": "tab_shift_tab_key", + "method_name": "last_event_was_tab", + "key_string": "ISO_Left_Tab", + "expected_result": False, + }, + { + "id": "up_up_key", + "method_name": "last_event_was_up", + "key_string": "Up", + "expected_result": True, + }, + { + "id": "up_down_key", + "method_name": "last_event_was_up", + "key_string": "Down", + "expected_result": False, + }, + { + "id": "up_or_down_up_key", + "method_name": "last_event_was_up_or_down", + "key_string": "Up", + "expected_result": True, + }, + { + "id": "up_or_down_down_key", + "method_name": "last_event_was_up_or_down", + "key_string": "Down", + "expected_result": True, + }, + { + "id": "up_or_down_left_key", + "method_name": "last_event_was_up_or_down", + "key_string": "Left", + "expected_result": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_composite_key_detection(self, test_context, case: dict) -> None: + """Test InputEventManager composite key detection methods.""" + + input_event_manager, _essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=(case["key_string"], 0), + ) + method = getattr(input_event_manager, case["method_name"]) + result = method() + assert result == case["expected_result"] + + @pytest.mark.parametrize( + "case", + [ + { + "id": "delete_delete_key", + "method_name": "last_event_was_delete", + "keynames": ["Delete"], + "modifiers": 0, + "expected_result": True, + }, + { + "id": "delete_keypad_delete", + "method_name": "last_event_was_delete", + "keynames": ["KP_Delete"], + "modifiers": 0, + "expected_result": True, + }, + { + "id": "delete_ctrl_d", + "method_name": "last_event_was_delete", + "keynames": ["d"], + "modifiers": 1 << 2, + "expected_result": True, + }, + { + "id": "delete_d_without_ctrl", + "method_name": "last_event_was_delete", + "keynames": ["d"], + "modifiers": 0, + "expected_result": False, + }, + { + "id": "delete_unknown_key", + "method_name": "last_event_was_delete", + "keynames": ["x"], + "modifiers": 0, + "expected_result": False, + }, + { + "id": "cut_ctrl_x", + "method_name": "last_event_was_cut", + "keynames": ["x"], + "modifiers": (1 << 2), + "expected_result": True, + }, + { + "id": "cut_ctrl_shift_x", + "method_name": "last_event_was_cut", + "keynames": ["x"], + "modifiers": (1 << 2) | (1 << 0), + "expected_result": False, + }, + { + "id": "cut_x_without_ctrl", + "method_name": "last_event_was_cut", + "keynames": ["x"], + "modifiers": 0, + "expected_result": False, + }, + { + "id": "cut_ctrl_y", + "method_name": "last_event_was_cut", + "keynames": ["y"], + "modifiers": 1 << 2, + "expected_result": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_simple_editing_actions( + self, + test_context, + case: dict, + ) -> None: + """Test InputEventManager simple editing action methods.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=(case["keynames"][0] if case["keynames"] else "", case["modifiers"]), + ) + method = getattr(input_event_manager, case["method_name"]) + result = method() + assert result == case["expected_result"] + if case["expected_result"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "copy_ctrl_c_non_terminal", + "method_name": "last_event_was_copy", + "keynames": ["c"], + "modifiers": (1 << 2), + "is_terminal": False, + "expected_result": True, + }, + { + "id": "copy_ctrl_shift_c_terminal", + "method_name": "last_event_was_copy", + "keynames": ["c"], + "modifiers": (1 << 2) | (1 << 0), + "is_terminal": True, + "expected_result": True, + }, + { + "id": "copy_ctrl_shift_c_non_terminal", + "method_name": "last_event_was_copy", + "keynames": ["c"], + "modifiers": (1 << 2) | (1 << 0), + "is_terminal": False, + "expected_result": False, + }, + { + "id": "copy_ctrl_c_terminal", + "method_name": "last_event_was_copy", + "keynames": ["c"], + "modifiers": (1 << 2), + "is_terminal": True, + "expected_result": False, + }, + { + "id": "copy_c_without_ctrl", + "method_name": "last_event_was_copy", + "keynames": ["c"], + "modifiers": 0, + "is_terminal": False, + "expected_result": False, + }, + { + "id": "paste_ctrl_v_non_terminal", + "method_name": "last_event_was_paste", + "keynames": ["v"], + "modifiers": (1 << 2), + "is_terminal": False, + "expected_result": True, + }, + { + "id": "paste_ctrl_shift_v_terminal", + "method_name": "last_event_was_paste", + "keynames": ["v"], + "modifiers": (1 << 2) | (1 << 0), + "is_terminal": True, + "expected_result": True, + }, + { + "id": "paste_ctrl_shift_v_non_terminal", + "method_name": "last_event_was_paste", + "keynames": ["v"], + "modifiers": (1 << 2) | (1 << 0), + "is_terminal": False, + "expected_result": False, + }, + { + "id": "paste_ctrl_v_terminal", + "method_name": "last_event_was_paste", + "keynames": ["v"], + "modifiers": (1 << 2), + "is_terminal": True, + "expected_result": False, + }, + { + "id": "paste_v_without_ctrl", + "method_name": "last_event_was_paste", + "keynames": ["v"], + "modifiers": 0, + "is_terminal": False, + "expected_result": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_terminal_aware_editing_actions( + self, + test_context, + case: dict, + ) -> None: + """Test InputEventManager terminal-aware editing action methods.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + mock_object = test_context.Mock() + mock_last_event = test_context.Mock() + mock_last_event.get_object.return_value = mock_object + input_event_manager._last_input_event = mock_last_event + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=(case["keynames"][0] if case["keynames"] else "", case["modifiers"]), + ) + essential_modules["ax_utilities_class"].is_terminal.return_value = case["is_terminal"] + method = getattr(input_event_manager, case["method_name"]) + result = method() + assert result == case["expected_result"] + if case["expected_result"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "undo_ctrl_z", + "method_name": "last_event_was_undo", + "keynames": ["z"], + "modifiers": (1 << 2), + "expected_result": True, + }, + { + "id": "undo_ctrl_shift_z", + "method_name": "last_event_was_undo", + "keynames": ["z"], + "modifiers": (1 << 2) | (1 << 0), + "expected_result": False, + }, + { + "id": "undo_z_without_ctrl", + "method_name": "last_event_was_undo", + "keynames": ["z"], + "modifiers": 0, + "expected_result": False, + }, + { + "id": "undo_ctrl_y", + "method_name": "last_event_was_undo", + "keynames": ["y"], + "modifiers": 1 << 2, + "expected_result": False, + }, + { + "id": "redo_ctrl_shift_z", + "method_name": "last_event_was_redo", + "keynames": ["z"], + "modifiers": (1 << 2) | (1 << 0), + "expected_result": True, + }, + { + "id": "redo_ctrl_y_libreoffice", + "method_name": "last_event_was_redo", + "keynames": ["y"], + "modifiers": (1 << 2), + "expected_result": True, + }, + { + "id": "redo_ctrl_z", + "method_name": "last_event_was_redo", + "keynames": ["z"], + "modifiers": (1 << 2), + "expected_result": False, + }, + { + "id": "redo_ctrl_shift_y", + "method_name": "last_event_was_redo", + "keynames": ["y"], + "modifiers": (1 << 2) | (1 << 0), + "expected_result": False, + }, + { + "id": "redo_z_without_ctrl", + "method_name": "last_event_was_redo", + "keynames": ["z"], + "modifiers": 0, + "expected_result": False, + }, + { + "id": "redo_unknown_key", + "method_name": "last_event_was_redo", + "keynames": ["x"], + "modifiers": 1 << 2, + "expected_result": False, + }, + { + "id": "select_all_ctrl_a", + "method_name": "last_event_was_select_all", + "keynames": ["a"], + "modifiers": (1 << 2), + "expected_result": True, + }, + { + "id": "select_all_ctrl_shift_a", + "method_name": "last_event_was_select_all", + "keynames": ["a"], + "modifiers": (1 << 2) | (1 << 0), + "expected_result": False, + }, + { + "id": "select_all_a_without_ctrl", + "method_name": "last_event_was_select_all", + "keynames": ["a"], + "modifiers": 0, + "expected_result": False, + }, + { + "id": "select_all_unknown_key", + "method_name": "last_event_was_select_all", + "keynames": ["x"], + "modifiers": 1 << 2, + "expected_result": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_undo_redo_select_editing_actions( + self, + test_context, + case: dict, + ) -> None: + """Test InputEventManager undo/redo/select editing action methods.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + input_event_manager._last_key_and_modifiers = test_context.Mock( + return_value=(case["keynames"][0] if case["keynames"] else "", case["modifiers"]), + ) + method = getattr(input_event_manager, case["method_name"]) + result = method() + assert result == case["expected_result"] + if case["expected_result"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "primary_click_primary_click", + "method_name": "last_event_was_primary_click", + "is_mouse_button": True, + "button": "1", + "pressed": True, + "expected_result": True, + }, + { + "id": "primary_click_primary_release", + "method_name": "last_event_was_primary_click", + "is_mouse_button": True, + "button": "1", + "pressed": False, + "expected_result": False, + }, + { + "id": "primary_click_middle_click", + "method_name": "last_event_was_primary_click", + "is_mouse_button": True, + "button": "2", + "pressed": True, + "expected_result": False, + }, + { + "id": "primary_click_not_mouse_button", + "method_name": "last_event_was_primary_click", + "is_mouse_button": False, + "button": "1", + "pressed": True, + "expected_result": False, + }, + { + "id": "primary_release_primary_release", + "method_name": "last_event_was_primary_release", + "is_mouse_button": True, + "button": "1", + "pressed": False, + "expected_result": True, + }, + { + "id": "primary_release_primary_click", + "method_name": "last_event_was_primary_release", + "is_mouse_button": True, + "button": "1", + "pressed": True, + "expected_result": False, + }, + { + "id": "primary_release_middle_release", + "method_name": "last_event_was_primary_release", + "is_mouse_button": True, + "button": "2", + "pressed": False, + "expected_result": False, + }, + { + "id": "primary_release_not_mouse_button", + "method_name": "last_event_was_primary_release", + "is_mouse_button": False, + "button": "1", + "pressed": False, + "expected_result": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_primary_mouse_button_actions( + self, + test_context, + case: dict, + ) -> None: + """Test InputEventManager primary mouse button action methods.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + mock_last_event = test_context.Mock() + mock_last_event.button = case["button"] + mock_last_event.pressed = case["pressed"] + input_event_manager._last_input_event = mock_last_event + input_event_manager.last_event_was_mouse_button = test_context.Mock( + return_value=case["is_mouse_button"], + ) + method = getattr(input_event_manager, case["method_name"]) + result = method() + assert result == case["expected_result"] + if case["expected_result"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "primary_button", + "is_mouse_button": True, + "button": "1", + "expected_result": True, + }, + { + "id": "middle_button", + "is_mouse_button": True, + "button": "2", + "expected_result": False, + }, + { + "id": "secondary_button", + "is_mouse_button": True, + "button": "3", + "expected_result": False, + }, + { + "id": "not_mouse_button", + "is_mouse_button": False, + "button": "1", + "expected_result": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_last_event_was_primary_click_or_release( + self, + test_context, + case: dict, + ) -> None: + """Test InputEventManager.last_event_was_primary_click_or_release.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + mock_last_event = test_context.Mock() + mock_last_event.button = case["button"] + input_event_manager._last_input_event = mock_last_event + input_event_manager.last_event_was_mouse_button = test_context.Mock( + return_value=case["is_mouse_button"], + ) + result = input_event_manager.last_event_was_primary_click_or_release() + assert result == case["expected_result"] + if case["expected_result"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + + @pytest.mark.parametrize( + "case", + [ + { + "id": "middle_click_middle_click", + "method_name": "last_event_was_middle_click", + "is_mouse_button": True, + "button": "2", + "pressed": True, + "expected_result": True, + }, + { + "id": "middle_click_middle_release", + "method_name": "last_event_was_middle_click", + "is_mouse_button": True, + "button": "2", + "pressed": False, + "expected_result": False, + }, + { + "id": "middle_click_primary_click", + "method_name": "last_event_was_middle_click", + "is_mouse_button": True, + "button": "1", + "pressed": True, + "expected_result": False, + }, + { + "id": "middle_click_not_mouse_button", + "method_name": "last_event_was_middle_click", + "is_mouse_button": False, + "button": "2", + "pressed": True, + "expected_result": False, + }, + { + "id": "middle_release_middle_release", + "method_name": "last_event_was_middle_release", + "is_mouse_button": True, + "button": "2", + "pressed": False, + "expected_result": True, + }, + { + "id": "middle_release_middle_click", + "method_name": "last_event_was_middle_release", + "is_mouse_button": True, + "button": "2", + "pressed": True, + "expected_result": False, + }, + { + "id": "middle_release_primary_release", + "method_name": "last_event_was_middle_release", + "is_mouse_button": True, + "button": "1", + "pressed": False, + "expected_result": False, + }, + { + "id": "middle_release_not_mouse_button", + "method_name": "last_event_was_middle_release", + "is_mouse_button": False, + "button": "2", + "pressed": False, + "expected_result": False, + }, + { + "id": "secondary_click_secondary_click", + "method_name": "last_event_was_secondary_click", + "is_mouse_button": True, + "button": "3", + "pressed": True, + "expected_result": True, + }, + { + "id": "secondary_click_secondary_release", + "method_name": "last_event_was_secondary_click", + "is_mouse_button": True, + "button": "3", + "pressed": False, + "expected_result": False, + }, + { + "id": "secondary_click_primary_click", + "method_name": "last_event_was_secondary_click", + "is_mouse_button": True, + "button": "1", + "pressed": True, + "expected_result": False, + }, + { + "id": "secondary_click_not_mouse_button", + "method_name": "last_event_was_secondary_click", + "is_mouse_button": False, + "button": "3", + "pressed": True, + "expected_result": False, + }, + { + "id": "secondary_release_secondary_release", + "method_name": "last_event_was_secondary_release", + "is_mouse_button": True, + "button": "3", + "pressed": False, + "expected_result": True, + }, + { + "id": "secondary_release_secondary_click", + "method_name": "last_event_was_secondary_release", + "is_mouse_button": True, + "button": "3", + "pressed": True, + "expected_result": False, + }, + { + "id": "secondary_release_primary_release", + "method_name": "last_event_was_secondary_release", + "is_mouse_button": True, + "button": "1", + "pressed": False, + "expected_result": False, + }, + { + "id": "secondary_release_not_mouse_button", + "method_name": "last_event_was_secondary_release", + "is_mouse_button": False, + "button": "3", + "pressed": False, + "expected_result": False, + }, + ], + ids=lambda case: case["id"], + ) + def test_middle_secondary_mouse_button_actions( + self, + test_context, + case: dict, + ) -> None: + """Test InputEventManager middle and secondary mouse button action methods.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + mock_last_event = test_context.Mock() + mock_last_event.button = case["button"] + mock_last_event.pressed = case["pressed"] + input_event_manager._last_input_event = mock_last_event + input_event_manager.last_event_was_mouse_button = test_context.Mock( + return_value=case["is_mouse_button"], + ) + method = getattr(input_event_manager, case["method_name"]) + result = method() + assert result == case["expected_result"] + if case["expected_result"]: + essential_modules["cthulhu.debug"].print_message.assert_called() + + def test_get_manager(self, test_context: CthulhuTestContext) -> None: + """Test get_manager function returns singleton.""" + + self._setup_input_event_manager(test_context) + from cthulhu.input_event_manager import get_manager + + manager1 = get_manager() + manager2 = get_manager() + assert manager1 is manager2 + + def test_pause_key_watcher_with_debug_message(self, test_context: CthulhuTestContext) -> None: + """Test InputEventManager.pause_key_watcher logs debug message.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + + input_event_manager.pause_key_watcher(True, "Testing pause functionality") + + essential_modules["cthulhu.debug"].print_message.assert_called() + assert input_event_manager._paused is True + + input_event_manager.pause_key_watcher(False, "Testing unpause functionality") + + assert input_event_manager._paused is False + + def test_determine_click_count_no_multi_click_bindings( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test click count stays at 1 when no multi-click bindings exist for the key.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + + # Mock command_manager to return False for has_multi_click_bindings + command_manager_instance = test_context.Mock() + command_manager_instance.has_multi_click_bindings = test_context.Mock(return_value=False) + test_context.patch( + "cthulhu.command_manager.get_manager", + return_value=command_manager_instance, + ) + + # Create first event (press) + first_event = essential_modules["cthulhu.input_event"].KeyboardEvent( + pressed=True, + keycode=79, + keysym=65429, + modifiers=0, + text="KP_Home", + ) + first_event.keyval_name = "KP_Home" + first_event.time = 1000.0 + first_event.is_pressed_key = test_context.Mock(return_value=True) + first_event.is_modifier_key = test_context.Mock(return_value=False) + first_event.get_click_count = test_context.Mock(return_value=1) + first_event.get_object = test_context.Mock(return_value=None) + + # Set first event as last event + input_event_manager._last_input_event = first_event + input_event_manager._last_non_modifier_key_event = first_event + + # Create release event + release_event = essential_modules["cthulhu.input_event"].KeyboardEvent( + pressed=False, + keycode=79, + keysym=65429, + modifiers=0, + text="KP_Home", + ) + release_event.keyval_name = "KP_Home" + release_event.time = 1000.1 + release_event.is_pressed_key = test_context.Mock(return_value=False) + release_event.is_modifier_key = test_context.Mock(return_value=False) + release_event.get_click_count = test_context.Mock(return_value=1) + release_event.get_object = test_context.Mock(return_value=None) + + # Set release as last event + input_event_manager._last_input_event = release_event + input_event_manager._last_non_modifier_key_event = release_event + + # Create second press event within doubleClickTimeout + second_event = essential_modules["cthulhu.input_event"].KeyboardEvent( + pressed=True, + keycode=79, + keysym=65429, + modifiers=0, + text="KP_Home", + ) + second_event.keyval_name = "KP_Home" + second_event.id = 65429 # keyval for has_multi_click_bindings call + second_event.hw_code = 79 # keycode + second_event.modifiers = 0 + second_event.time = 1000.3 # Within 0.5s timeout + second_event.is_pressed_key = test_context.Mock(return_value=True) + second_event.is_modifier_key = test_context.Mock(return_value=False) + second_event.is_cthulhu_modifier = test_context.Mock(return_value=False) + second_event.get_object = test_context.Mock(return_value=None) + + # Determine click count - should stay at 1 since no multi-click bindings + result = input_event_manager._determine_keyboard_event_click_count(second_event) + + assert result == 1 + command_manager_instance.has_multi_click_bindings.assert_called_with(65429, 79, 0) + + def test_determine_click_count_with_multi_click_bindings( + self, + test_context: CthulhuTestContext, + ) -> None: + """Test click count increments when multi-click bindings exist for the key.""" + + input_event_manager, essential_modules = self._setup_input_event_manager(test_context) + + # Mock command_manager to return True for has_multi_click_bindings + command_manager_instance = test_context.Mock() + command_manager_instance.has_multi_click_bindings = test_context.Mock(return_value=True) + test_context.patch( + "cthulhu.command_manager.get_manager", + return_value=command_manager_instance, + ) + + # Create first event (press) + first_event = essential_modules["cthulhu.input_event"].KeyboardEvent( + pressed=True, + keycode=80, + keysym=65431, + modifiers=0, + text="KP_Up", + ) + first_event.keyval_name = "KP_Up" + first_event.time = 1000.0 + first_event.is_pressed_key = test_context.Mock(return_value=True) + first_event.is_modifier_key = test_context.Mock(return_value=False) + first_event.get_click_count = test_context.Mock(return_value=1) + first_event.get_object = test_context.Mock(return_value=None) + + # Set first event as last event + input_event_manager._last_input_event = first_event + input_event_manager._last_non_modifier_key_event = first_event + + # Create release event + release_event = essential_modules["cthulhu.input_event"].KeyboardEvent( + pressed=False, + keycode=80, + keysym=65431, + modifiers=0, + text="KP_Up", + ) + release_event.keyval_name = "KP_Up" + release_event.time = 1000.1 + release_event.is_pressed_key = test_context.Mock(return_value=False) + release_event.is_modifier_key = test_context.Mock(return_value=False) + release_event.get_click_count = test_context.Mock(return_value=1) + release_event.get_object = test_context.Mock(return_value=None) + + # Set release as last event + input_event_manager._last_input_event = release_event + input_event_manager._last_non_modifier_key_event = release_event + + # Create second press event within doubleClickTimeout + second_event = essential_modules["cthulhu.input_event"].KeyboardEvent( + pressed=True, + keycode=80, + keysym=65431, + modifiers=0, + text="KP_Up", + ) + second_event.keyval_name = "KP_Up" + second_event.id = 65431 # keyval for has_multi_click_bindings call + second_event.hw_code = 80 # keycode + second_event.modifiers = 0 + second_event.time = 1000.3 # Within 0.5s timeout + second_event.is_pressed_key = test_context.Mock(return_value=True) + second_event.is_modifier_key = test_context.Mock(return_value=False) + second_event.is_cthulhu_modifier = test_context.Mock(return_value=False) + second_event.get_object = test_context.Mock(return_value=None) + + # Determine click count - should increment to 2 since multi-click bindings exist + result = input_event_manager._determine_keyboard_event_click_count(second_event) + + assert result == 2 + command_manager_instance.has_multi_click_bindings.assert_called_with(65431, 80, 0)