# Orca # # Copyright 2023 Igalia, S.L. # Copyright 2023 GNOME Foundation Inc. # 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-locals # pylint: disable=too-many-arguments # pylint: disable=too-many-positional-arguments # pylint: disable=too-many-instance-attributes """Manages the Orca 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") 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 OrcaModifierManager: """Manages the Orca modifier.""" 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 # Related to hacks which will soon die. self._original_xmodmap: bytes = b"" self._caps_lock_cleared: bool = False self._need_to_restore_orca_modifier: bool = False # 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 = "ORCA MODIFIER MANAGER: Cannot listen for input device changes." debug.print_message(debug.LEVEL_INFO, msg, True) def _on_device_changed(self, _device_manager, device: Gdk.Device) -> None: """Handles device-* signals.""" source = device.get_source() tokens = ["ORCA MODIFIER MANAGER: Device changed", source] debug.print_tokens(debug.LEVEL_INFO, tokens, True) if source == Gdk.InputSource.KEYBOARD: self.refresh_orca_modifiers("Keyboard change detected.") def is_orca_modifier(self, modifier: str) -> bool: """Returns True if modifier is one of the user's Orca modifier keys.""" if modifier not in self.get_orca_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 Orca 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"ORCA 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_orca_modifiers(self) -> None: """Adds grabs for all of the user's Orca modifier keys.""" for modifier in self.get_orca_modifier_keys(): if modifier in ["Insert", "KP_Insert"]: self.add_modifier_grab(modifier) def remove_grabs_for_orca_modifiers(self) -> None: """Removes grabs for all of the user's Orca modifier keys.""" for modifier in self.get_orca_modifier_keys(): if modifier in ["Insert", "KP_Insert"]: self.remove_modifier_grab(modifier) msg = "ORCA 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 keyboard_event.keyval_name in ["Caps_Lock", "Shift_Lock"]: self._toggle_modifier_lock(keyboard_event) return 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 = "ORCA MODIFIER MANAGER: Removing grab pre-toggle" debug.print_message(debug.LEVEL_INFO, msg, True) self.remove_modifier_grab(keyboard_event.keyval_name) msg = f"ORCA 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 = "ORCA 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 keyboard_event.is_pressed_key(): return def toggle(modifiers, modifier): if modifiers & modifier: lock = Atspi.KeySynthType.UNLOCKMODIFIERS msg = "ORCA MODIFIER MANAGER: Unlocking CapsLock" debug.print_message(debug.LEVEL_INFO, msg, True) else: lock = Atspi.KeySynthType.LOCKMODIFIERS msg = "ORCA MODIFIER MANAGER: Locking CapsLock" debug.print_message(debug.LEVEL_INFO, msg, True) Atspi.generate_keyboard_event(modifier, "", lock) if keyboard_event.keyval_name == "Caps_Lock": modifier = 1 << Atspi.ModifierType.SHIFTLOCK elif keyboard_event.keyval_name == "Shift_Lock": modifier = 1 << Atspi.ModifierType.SHIFT else: return msg = "ORCA MODIFIER MANAGER: Scheduling lock change" debug.print_message(debug.LEVEL_INFO, msg, True) GLib.timeout_add(1, toggle, keyboard_event.modifiers, modifier) def get_orca_modifier_keys(self) -> list[str]: """Returns the active Orca 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.gnome.Orca.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_orca_modifier_keys() != self._applied_modifier_keys def set_modifiers_for_layout(self) -> None: """Unsets and refreshes modifier keys for the current layout.""" self.unset_orca_modifiers("Keyboard layout changing.") self.refresh_orca_modifiers("Keyboard layout changed.") def refresh_orca_modifiers(self, reason: str = "") -> None: """Refreshes the Orca modifier keys, including grabs and xmodmap.""" msg = "ORCA MODIFIER MANAGER: Refreshing Orca 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_orca_modifier_keys()) self.add_grabs_for_orca_modifiers() self._modifiers_are_set = True display = os.environ.get("DISPLAY") if not display: msg = "ORCA 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_orca_xmodmap() def _create_orca_xmodmap(self) -> None: """Makes an Orca-specific Xmodmap so that the Orca modifier works.""" msg = "ORCA MODIFIER MANAGER: Creating Orca xmodmap" debug.print_message(debug.LEVEL_INFO, msg, True) orca_modifiers = self.get_orca_modifier_keys() if "Caps_Lock" in orca_modifiers or "Shift_Lock" in orca_modifiers: self.set_caps_lock_as_orca_modifier(True) self._caps_lock_cleared = True elif self._caps_lock_cleared: self.set_caps_lock_as_orca_modifier(False) self._caps_lock_cleared = False def unset_orca_modifiers(self, reason: str = "") -> None: """Turns the Orca modifiers back into their original purpose.""" msg = "ORCA MODIFIER MANAGER: Unsetting Orca 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 = "ORCA MODIFIER MANAGER: No stored xmodmap found" debug.print_message(debug.LEVEL_INFO, msg, True) return display = os.environ.get("DISPLAY") if not display: msg = "ORCA 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 = "ORCA 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_orca_modifier(self, enable: bool) -> None: """Enable or disable use of the caps lock key as an Orca modifier key.""" msg = "ORCA MODIFIER MANAGER: Setting caps lock as the Orca modifier" debug.print_message(debug.LEVEL_INFO, msg, True) display = os.environ.get("DISPLAY") if not display: msg = "ORCA MODIFIER MANAGER: DISPLAY not set, cannot modify caps lock" debug.print_message(debug.LEVEL_INFO, msg, True) return if not self._original_xmodmap: msg = "ORCA 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 = "ORCA MODIFIER MANAGER: Not updating xmodmap" debug.print_message(debug.LEVEL_INFO, msg, True) return msg = "ORCA 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: OrcaModifierManager = OrcaModifierManager() def get_manager() -> OrcaModifierManager: """Returns the OrcaModifierManager singleton.""" return _manager