From fed8f24126a1fa078bc199f8a52b476cde0b016f Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Thu, 7 May 2026 01:45:00 -0400 Subject: [PATCH] Add X11 terminal input mode --- README.md | 18 + config/settings/settings.conf | 2 + docs/user.md | 22 + setup.py | 1 + src/fenrir | 26 +- src/fenrirscreenreader/core/inputManager.py | 8 + src/fenrirscreenreader/core/settingsData.py | 1 + .../core/settingsManager.py | 8 + src/fenrirscreenreader/fenrirVersion.py | 4 +- .../inputDriver/x11Driver.py | 620 ++++++++++++++++++ tests/unit/test_settings_validation.py | 1 + tests/unit/test_x11_terminal_mode.py | 221 +++++++ 12 files changed, 927 insertions(+), 5 deletions(-) create mode 100644 src/fenrirscreenreader/inputDriver/x11Driver.py create mode 100644 tests/unit/test_x11_terminal_mode.py diff --git a/README.md b/README.md index 80d28017..f2d1dc6c 100644 --- a/README.md +++ b/README.md @@ -713,6 +713,8 @@ fenrir [OPTIONS] - `-p, --print` - Print debug messages to screen - `-e, --emulated-pty` - Use PTY emulation with escape sequences for input (enables desktop/X/Wayland usage) - `-E, --emulated-evdev` - Use PTY emulation with evdev for input (single instance) +- `-x, --x11` - Use PTY emulation with X11 keyboard input scoped to the terminal window +- `--x11-window-id WINDOWID` - X11 window id to use for `--x11` terminal mode - `-F, --force-all-screens` - Force Fenrir to respond on all screens, ignoring ignore_screen setting - `-i, -I, --ignore-screen SCREEN` - Ignore specific screen(s). Can be used multiple times. Combines with existing ignore settings. @@ -724,6 +726,12 @@ sudo fenrir -f -d # Use PTY emulation for desktop use sudo fenrir -e +# Use PTY emulation with X11 terminal-scoped keybindings +fenrir -x + +# Use X11 mode with an explicit terminal window id +fenrir -x --x11-window-id 0x123456 + # Override settings via command line sudo fenrir -o "speech#rate=0.8;sound#volume=0.5" @@ -735,6 +743,16 @@ sudo fenrir --ignore-screen 1 sudo fenrir -i 1 -i 2 # Ignore screens 1 and 2 ``` +### X11 Terminal Mode + +`fenrir -x` runs Fenrir inside a GUI terminal without root. It uses the PTY screen driver for terminal contents and the X11 input driver for Fenrir keybindings. Unlike evdev mode, it does not listen to the whole desktop; key grabs are scoped to the target terminal window so desktop screen readers can keep their own global bindings outside that terminal. + +By default, Fenrir targets the X11 window from `WINDOWID`, falling back to the active X11 window. If a terminal does not set `WINDOWID`, pass the window explicitly with `--x11-window-id`. + +X11 terminal mode uses the normal keyboard layout files, including desktop and laptop bindings. It supports Fenrir keys such as numpad Insert/`KEY_KP0`, CapsLock/`KEY_CAPSLOCK`, Insert/`KEY_INSERT`, and Super/Windows/`KEY_META`. Script keys such as Compose use `KEY_COMPOSE`. + +This mode requires `python-xlib`. + ## Localization Translation files are located in the `locale/` directory. To install translations: diff --git a/config/settings/settings.conf b/config/settings/settings.conf index 3779f968..2ff60347 100644 --- a/config/settings/settings.conf +++ b/config/settings/settings.conf @@ -131,6 +131,8 @@ driver=evdevDriver device=ALL # gives Fenrir exclusive access to the keyboard and lets it control keystrokes. grab_devices=True +# Optional X11 target window id for x11Driver. Empty uses WINDOWID or active window. +x11_window_id= # Ignore shortcut bindings and pass all keys through without processing Fenrir commands. # When True, Fenrir will only monitor screen content without intercepting keyboard input. # the current shortcut layout located in /etc/fenrirscreenreader/keyboard diff --git a/docs/user.md b/docs/user.md index 5358eb97..600fe12f 100644 --- a/docs/user.md +++ b/docs/user.md @@ -343,9 +343,31 @@ fenrir [OPTIONS] - `-p, --print` - Print debug to screen - `-e, --emulated-pty` - PTY emulation for desktop use - `-E, --emulated-evdev` - PTY + evdev emulation +- `-x, --x11` - PTY + X11 keyboard input scoped to the terminal window +- `--x11-window-id WINDOWID` - X11 window id to use for `--x11` terminal mode - `-F, --force-all-screens` - Ignore ignoreScreen setting - `-i, -I, --ignore-screen SCREEN` - Ignore specific screen(s), can be used multiple times +### X11 Terminal Mode + +Use `fenrir -x` to run Fenrir in an X11 terminal as a normal user: + +```bash +fenrir -x +``` + +This mode uses PTY screen monitoring and X11 keyboard input. It is intended for GUI terminals such as xterm and VTE-based terminals, while keeping Fenrir key handling scoped to that terminal window. Desktop screen readers keep their global bindings outside the focused terminal. + +Fenrir normally detects the target terminal from `WINDOWID` or the active X11 window. If needed, pass a window id explicitly: + +```bash +fenrir -x --x11-window-id 0x123456 +``` + +X11 terminal mode uses the same keyboard layout files as TTY Fenrir. Supported Fenrir keys include numpad Insert/`KEY_KP0`, CapsLock/`KEY_CAPSLOCK`, Insert/`KEY_INSERT`, and Super/Windows/`KEY_META`. Compose script keys are exposed as `KEY_COMPOSE`. + +This mode requires `python-xlib`. + ## Troubleshooting ### No Speech diff --git a/setup.py b/setup.py index efb3b90e..731ffdc4 100755 --- a/setup.py +++ b/setup.py @@ -101,6 +101,7 @@ setup( "dbus-python>=1.2.8", "pyperclip", "pyudev>=0.21.0", + "python-xlib>=0.33", "setuptools", "setproctitle", "pexpect", diff --git a/src/fenrir b/src/fenrir index fd771695..0da21156 100755 --- a/src/fenrir +++ b/src/fenrir @@ -70,6 +70,17 @@ def create_argument_parser(): action='store_true', help='Use PTY emulation with evdev for input (single instance)' ) + argumentParser.add_argument( + '-x', '--x11', + action='store_true', + help='Use PTY emulation with X11 keyboard input scoped to the terminal window' + ) + argumentParser.add_argument( + '--x11-window-id', + metavar='WINDOWID', + default='', + help='X11 window id to use for --x11 terminal mode' + ) argumentParser.add_argument( '-F', '--force-all-screens', @@ -91,8 +102,13 @@ def validate_arguments(cliArgs): if option and ('#' not in option or '=' not in option): return False, f"Invalid option format: {option}\nExpected format: SECTION#SETTING=VALUE" - if cliArgs.emulated_pty and cliArgs.emulated_evdev: - return False, "Cannot use both --emulated-pty and --emulated-evdev simultaneously" + emulated_modes = [ + cliArgs.emulated_pty, + cliArgs.emulated_evdev, + cliArgs.x11, + ] + if sum(bool(mode) for mode in emulated_modes) > 1: + return False, "Cannot combine --emulated-pty, --emulated-evdev, and --x11" return True, None @@ -124,6 +140,10 @@ def run_fenrir(): pass +def should_run_foreground(cliArgs): + return cliArgs.foreground or cliArgs.emulated_pty or cliArgs.x11 + + def main(): global cliArgs argumentParser = create_argument_parser() @@ -135,7 +155,7 @@ def main(): argumentParser.error(errorMsg) sys.exit(1) - if cliArgs.foreground or cliArgs.emulated_pty: + if should_run_foreground(cliArgs): # Run directly in foreground run_fenrir() else: diff --git a/src/fenrirscreenreader/core/inputManager.py b/src/fenrirscreenreader/core/inputManager.py index 298a981e..601ae3da 100644 --- a/src/fenrirscreenreader/core/inputManager.py +++ b/src/fenrirscreenreader/core/inputManager.py @@ -340,6 +340,14 @@ class InputManager: except Exception as e: pass + def reset_input_state(self): + self.env["input"]["curr_input"] = [] + self.env["input"]["prev_input"] = [] + self.env["input"]["event_buffer"] = [] + self.env["input"]["shortcut_repeat"] = 1 + self.clear_last_deep_input() + self.clear_event_buffer() + def set_last_deepest_input(self, currentDeepestInput): self.lastDeepestInput = currentDeepestInput diff --git a/src/fenrirscreenreader/core/settingsData.py b/src/fenrirscreenreader/core/settingsData.py index efc39bb5..07c2fede 100644 --- a/src/fenrirscreenreader/core/settingsData.py +++ b/src/fenrirscreenreader/core/settingsData.py @@ -119,6 +119,7 @@ settings_data = { "driver": "evdev", "device": "all", "grab_devices": True, + "x11_window_id": "", "ignore_shortcuts": False, "keyboard_layout": "desktop", "char_echo_mode": 2, # while capslock diff --git a/src/fenrirscreenreader/core/settingsManager.py b/src/fenrirscreenreader/core/settingsManager.py index 1511164b..081a84f9 100644 --- a/src/fenrirscreenreader/core/settingsManager.py +++ b/src/fenrirscreenreader/core/settingsManager.py @@ -411,6 +411,7 @@ class SettingsManager: valid_drivers = [ "evdevDriver", "ptyDriver", + "x11Driver", "atspiDriver", "dummyDriver", ] @@ -507,6 +508,13 @@ class SettingsManager: if cliArgs.emulated_evdev: self.set_setting("screen", "driver", "ptyDriver") self.set_setting("keyboard", "driver", "evdevDriver") + if cliArgs.x11: + self.set_setting("screen", "driver", "ptyDriver") + self.set_setting("keyboard", "driver", "x11Driver") + if cliArgs.x11_window_id: + self.set_setting( + "keyboard", "x11_window_id", cliArgs.x11_window_id + ) self.set_fenrir_keys(self.get_setting("general", "fenrir_keys")) self.set_script_keys(self.get_setting("general", "script_keys")) diff --git a/src/fenrirscreenreader/fenrirVersion.py b/src/fenrirscreenreader/fenrirVersion.py index e057fedd..a877ff56 100644 --- a/src/fenrirscreenreader/fenrirVersion.py +++ b/src/fenrirscreenreader/fenrirVersion.py @@ -4,5 +4,5 @@ # Fenrir TTY screen reader # By Chrys, Storm Dragon, and contributors. -version = "2026.04.04" -code_name = "master" +version = "2026.05.07" +code_name = "testing" diff --git a/src/fenrirscreenreader/inputDriver/x11Driver.py b/src/fenrirscreenreader/inputDriver/x11Driver.py new file mode 100644 index 00000000..c0782e39 --- /dev/null +++ b/src/fenrirscreenreader/inputDriver/x11Driver.py @@ -0,0 +1,620 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributors. + +import os +import sys +import time + +from fenrirscreenreader.core import debug +from fenrirscreenreader.core.eventData import FenrirEventType +from fenrirscreenreader.core.inputDriver import InputDriver as inputDriver + +try: + from Xlib import X + from Xlib import XK + from Xlib import display + from Xlib.error import BadAccess + from Xlib.error import BadWindow +except Exception as x_error: + X = None + XK = None + display = None + BadAccess = Exception + BadWindow = Exception + _x_error = x_error +else: + _x_error = None + + +def build_keysym_name_map(): + if XK is None: + return {} + names = {} + for attr_name in dir(XK): + if not attr_name.startswith("XK_"): + continue + value = getattr(XK, attr_name) + if not isinstance(value, int): + continue + names.setdefault(value, attr_name[3:]) + return names + + +KEYSYM_NAME_MAP = build_keysym_name_map() + + +class X11DriverError(RuntimeError): + pass + + +class driver(inputDriver): + """X11 terminal-scoped keyboard input driver.""" + + key_name_overrides = { + "BackSpace": "KEY_BACKSPACE", + "Return": "KEY_ENTER", + "ISO_Enter": "KEY_ENTER", + "Tab": "KEY_TAB", + "Escape": "KEY_ESC", + "space": "KEY_SPACE", + "minus": "KEY_MINUS", + "equal": "KEY_EQUAL", + "bracketleft": "KEY_LEFTBRACE", + "bracketright": "KEY_RIGHTBRACE", + "backslash": "KEY_BACKSLASH", + "semicolon": "KEY_SEMICOLON", + "apostrophe": "KEY_APOSTROPHE", + "grave": "KEY_GRAVE", + "comma": "KEY_COMMA", + "period": "KEY_DOT", + "slash": "KEY_SLASH", + "Shift_L": "KEY_LEFTSHIFT", + "Shift_R": "KEY_RIGHTSHIFT", + "Control_L": "KEY_LEFTCTRL", + "Control_R": "KEY_RIGHTCTRL", + "Alt_L": "KEY_LEFTALT", + "Alt_R": "KEY_RIGHTALT", + "Meta_L": "KEY_LEFTMETA", + "Meta_R": "KEY_RIGHTMETA", + "Super_L": "KEY_LEFTMETA", + "Super_R": "KEY_RIGHTMETA", + "Multi_key": "KEY_COMPOSE", + "Caps_Lock": "KEY_CAPSLOCK", + "Num_Lock": "KEY_NUMLOCK", + "Scroll_Lock": "KEY_SCROLLLOCK", + "Insert": "KEY_INSERT", + "Delete": "KEY_DELETE", + "Home": "KEY_HOME", + "End": "KEY_END", + "Page_Up": "KEY_PAGEUP", + "Page_Down": "KEY_PAGEDOWN", + "Up": "KEY_UP", + "Down": "KEY_DOWN", + "Left": "KEY_LEFT", + "Right": "KEY_RIGHT", + "KP_0": "KEY_KP0", + "KP_Insert": "KEY_KP0", + "KP_1": "KEY_KP1", + "KP_End": "KEY_KP1", + "KP_2": "KEY_KP2", + "KP_Down": "KEY_KP2", + "KP_3": "KEY_KP3", + "KP_Page_Down": "KEY_KP3", + "KP_4": "KEY_KP4", + "KP_Left": "KEY_KP4", + "KP_5": "KEY_KP5", + "KP_Begin": "KEY_KP5", + "KP_6": "KEY_KP6", + "KP_Right": "KEY_KP6", + "KP_7": "KEY_KP7", + "KP_Home": "KEY_KP7", + "KP_8": "KEY_KP8", + "KP_Up": "KEY_KP8", + "KP_9": "KEY_KP9", + "KP_Page_Up": "KEY_KP9", + "KP_Decimal": "KEY_KPDOT", + "KP_Delete": "KEY_KPDOT", + "KP_Add": "KEY_KPPLUS", + "KP_Subtract": "KEY_KPMINUS", + "KP_Multiply": "KEY_KPASTERISK", + "KP_Divide": "KEY_KPSLASH", + "KP_Enter": "KEY_KPENTER", + "KP_Equal": "KEY_KPEQUAL", + } + + modifier_masks = { + "KEY_SHIFT": X.ShiftMask if X else 1, + "KEY_LEFTSHIFT": X.ShiftMask if X else 1, + "KEY_RIGHTSHIFT": X.ShiftMask if X else 1, + "KEY_CTRL": X.ControlMask if X else 4, + "KEY_LEFTCTRL": X.ControlMask if X else 4, + "KEY_RIGHTCTRL": X.ControlMask if X else 4, + "KEY_ALT": X.Mod1Mask if X else 8, + "KEY_LEFTALT": X.Mod1Mask if X else 8, + "KEY_RIGHTALT": X.Mod1Mask if X else 8, + "KEY_META": X.Mod4Mask if X else 64, + "KEY_LEFTMETA": X.Mod4Mask if X else 64, + "KEY_RIGHTMETA": X.Mod4Mask if X else 64, + } + + modifier_key_names = set(modifier_masks.keys()) + + def __init__(self): + inputDriver.__init__(self) + self.display = None + self.root = None + self.window = None + self.window_id = None + self.active = True + self.num_lock_mask = 0 + self.grabbed = set() + self.grab_signature = None + self.interesting_keys = set() + self.fenrir_keys = set() + self.failed_grabs = 0 + + def initialize(self, environment): + self.env = environment + self.env["runtime"]["InputManager"].set_shortcut_type("KEY") + if display is None: + self.fail_startup("python-xlib is not available: " + str(_x_error)) + self.display = display.Display() + self.root = self.display.screen().root + self.window_id = self.resolve_window_id() + if self.window_id is None: + self.fail_startup( + "No X11 target window found. Use --x11-window-id or launch from an X11 terminal." + ) + self.write_debug( + "x11Driver target window " + + self.format_window_id(self.window_id) + + ", active window " + + self.format_window_id(self.get_active_window_id()) + + ", WINDOWID=" + + os.environ.get("WINDOWID", ""), + debug.DebugLevel.INFO, + ) + self.window = self.display.create_resource_object("window", self.window_id) + self.window.change_attributes( + event_mask=( + X.KeyPressMask + | X.KeyReleaseMask + | X.FocusChangeMask + | X.StructureNotifyMask + ) + ) + self.num_lock_mask = self.find_num_lock_mask() + self.refresh_interesting_keys() + self.refresh_grabs(force=True) + self.env["runtime"]["ProcessManager"].add_custom_event_thread( + self.input_watchdog + ) + self._initialized = True + + def shutdown(self): + self.ungrab_all_devices() + try: + if self.display: + self.display.close() + except Exception: + pass + self._initialized = False + + def fail_startup(self, message): + print("Fenrir X11 driver error: " + message, file=sys.stderr) + raise SystemExit(1) + + def resolve_window_id(self): + configured_window = self.env["runtime"]["SettingsManager"].get_setting( + "keyboard", "x11_window_id" + ) + for candidate in [configured_window, os.environ.get("WINDOWID", "")]: + window_id = self.parse_window_id(candidate) + if window_id is not None: + return window_id + return self.get_active_window_id() + + def parse_window_id(self, value): + if value is None: + return None + value = str(value).strip() + if value == "": + return None + try: + return int(value, 0) + except ValueError: + raise X11DriverError("Invalid X11 window id: " + value) + + def get_active_window_id(self): + atom = self.display.intern_atom("_NET_ACTIVE_WINDOW") + prop = self.root.get_full_property(atom, X.AnyPropertyType) + if prop is None or len(prop.value) == 0: + return None + return int(prop.value[0]) + + def format_window_id(self, window_id): + if window_id is None: + return "None" + return hex(int(window_id)) + + def find_num_lock_mask(self): + num_lock_keysym = XK.string_to_keysym("Num_Lock") + if not num_lock_keysym: + return 0 + num_lock_keycode = self.display.keysym_to_keycode(num_lock_keysym) + if not num_lock_keycode: + return 0 + modifier_map = self.display.get_modifier_mapping() + masks = [ + X.ShiftMask, + X.LockMask, + X.ControlMask, + X.Mod1Mask, + X.Mod2Mask, + X.Mod3Mask, + X.Mod4Mask, + X.Mod5Mask, + ] + for index, keycodes in enumerate(modifier_map): + if num_lock_keycode in keycodes: + return masks[index] + return 0 + + def input_watchdog(self, active, event_queue): + while active.value: + try: + self.refresh_grabs() + if not self.display.pending_events(): + time.sleep(0.01) + continue + event = self.display.next_event() + self.handle_x_event(event, event_queue) + except BadWindow: + self.env["runtime"]["DebugManager"].write_debug_out( + "x11Driver target window disappeared", + debug.DebugLevel.ERROR, + ) + break + except Exception as e: + self.env["runtime"]["DebugManager"].write_debug_out( + "x11Driver input watchdog error: " + str(e), + debug.DebugLevel.ERROR, + ) + time.sleep(0.1) + + def handle_x_event(self, event, event_queue): + event_type = getattr(event, "type", None) + if event_type == X.FocusIn: + self.active = True + self.clear_event_buffer() + return + if event_type == X.FocusOut: + self.active = False + self.reset_input_state() + return + if event_type not in [X.KeyPress, X.KeyRelease]: + return + if not self.active and not self.event_is_in_target_tree(event): + return + input_event = self.map_event(event) + if input_event is None: + return + key_name = input_event["event_name"] + if not self.should_emit_key(key_name): + return + self.write_debug( + "x11Driver key event " + + key_name + + " state " + + str(input_event["event_state"]) + + " raw state " + + str(getattr(event, "state", 0)), + debug.DebugLevel.INFO, + ) + self.env["input"]["event_buffer"].append(input_event.copy()) + event_queue.put( + { + "Type": FenrirEventType.keyboard_input, + "data": input_event, + } + ) + + def event_is_in_target_tree(self, event): + event_window = getattr(event, "event", None) + if event_window is None: + return False + window_id = getattr(event_window, "id", None) + if window_id is None: + return False + return window_id == self.window_id or self.window_has_ancestor( + window_id, self.window_id + ) + + def window_has_ancestor(self, window_id, ancestor_id): + try: + window = self.display.create_resource_object("window", window_id) + while window.id != self.root.id: + if window.id == ancestor_id: + return True + parent = window.query_tree().parent + if parent is None or parent.id == window.id: + return False + window = parent + except Exception: + return False + return False + + def map_event(self, event): + key_name = self.keycode_to_key_name(event.detail, event.state) + if key_name is None: + return None + event_time = time.time() + return { + "event_name": key_name, + "event_value": event.detail, + "event_sec": int(event_time), + "event_usec": int((event_time % 1) * 1000000), + "event_state": 1 if event.type == X.KeyPress else 0, + "event_type": event.type, + } + + def keycode_to_key_name(self, keycode, state=0): + key_names = [] + for keysym_name in self.keycode_to_keysym_names(keycode): + key_name = self.keysym_name_to_key_name(keysym_name) + if key_name is None or key_name in key_names: + continue + key_names.append(key_name) + for key_name in key_names: + if key_name.startswith("KEY_KP"): + return key_name + if key_names: + return key_names[0] + return None + + def keycode_to_keysym_names(self, keycode): + names = [] + for column in range(8): + keysym = self.display.keycode_to_keysym(keycode, column) + keysym_name = self.keysym_to_name(keysym) + if keysym_name and keysym_name not in names: + names.append(keysym_name) + return names + + def keysym_to_name(self, keysym): + keysym_name = XK.keysym_to_string(keysym) + if keysym_name: + return keysym_name + return KEYSYM_NAME_MAP.get(keysym) + + def keysym_name_to_key_name(self, keysym_name): + if not keysym_name: + return None + if keysym_name in self.key_name_overrides: + return self.key_name_overrides[keysym_name] + if len(keysym_name) == 1: + if keysym_name.isalpha(): + return "KEY_" + keysym_name.upper() + if keysym_name.isdigit(): + return "KEY_" + keysym_name + if keysym_name.startswith("F") and keysym_name[1:].isdigit(): + return "KEY_" + keysym_name + return None + + def should_emit_key(self, key_name): + if key_name in self.modifier_key_names: + return True + if key_name in self.interesting_keys: + return True + input_manager = self.env["runtime"]["InputManager"] + curr_input = self.env["input"]["curr_input"] + normalized_curr = [input_manager.convert_event_name(key) for key in curr_input] + return "KEY_FENRIR" in normalized_curr or "KEY_SCRIPT" in normalized_curr + + def refresh_interesting_keys(self): + input_manager = self.env["runtime"]["InputManager"] + self.fenrir_keys = set(self.env["input"]["fenrir_key"]) + interesting = set(self.fenrir_keys) + interesting.update(self.env["input"]["script_key"]) + interesting.update(self.modifier_key_names) + for shortcut in self.env.get("rawBindings", {}).values(): + for key_name in shortcut[1]: + if key_name == "KEY_FENRIR": + interesting.update(self.fenrir_keys) + elif key_name == "KEY_SCRIPT": + interesting.update(self.env["input"]["script_key"]) + else: + interesting.add(input_manager.convert_event_name(key_name)) + interesting.add(key_name) + self.interesting_keys = interesting + + def refresh_grabs(self, force=False): + signature = ( + tuple(sorted(self.env.get("rawBindings", {}).keys())), + tuple(sorted(self.env["input"]["fenrir_key"])), + tuple(sorted(self.env["input"]["script_key"])), + ) + if not force and signature == self.grab_signature: + return + self.ungrab_all_devices() + self.refresh_interesting_keys() + passive_grabs = self.build_passive_grabs() + failed_before = self.failed_grabs + for key_name, modifier_mask in passive_grabs: + self.grab_key_name(key_name, modifier_mask) + self.display.flush() + self.grab_signature = signature + self.write_debug( + "x11Driver passive grabs planned " + + str(len(passive_grabs)) + + ", installed " + + str(len(self.grabbed)) + + ", failed " + + str(self.failed_grabs - failed_before), + debug.DebugLevel.INFO, + ) + + def build_passive_grabs(self): + grabs = set() + for fenrir_key in self.env["input"]["fenrir_key"]: + grabs.add((fenrir_key, 0)) + for script_key in self.env["input"]["script_key"]: + grabs.add((script_key, 0)) + for shortcut in self.env.get("rawBindings", {}).values(): + keys = shortcut[1] + expanded_keys = self.expand_special_keys(keys) + modifier_mask = self.shortcut_modifier_mask(expanded_keys) + non_modifier_keys = [ + key for key in expanded_keys + if key not in self.modifier_key_names + ] + if not non_modifier_keys: + continue + final_key = non_modifier_keys[-1] + if "KEY_FENRIR" in keys: + for fenrir_key in self.env["input"]["fenrir_key"]: + grabs.add((fenrir_key, modifier_mask)) + elif "KEY_SCRIPT" in keys: + for script_key in self.env["input"]["script_key"]: + grabs.add((script_key, modifier_mask)) + else: + grabs.add((final_key, modifier_mask)) + return grabs + + def expand_special_keys(self, keys): + expanded = [] + for key_name in keys: + if key_name == "KEY_FENRIR": + expanded.extend(self.env["input"]["fenrir_key"]) + elif key_name == "KEY_SCRIPT": + expanded.extend(self.env["input"]["script_key"]) + else: + expanded.append(key_name) + return expanded + + def shortcut_modifier_mask(self, keys): + modifier_mask = 0 + for key_name in keys: + modifier_mask |= self.modifier_masks.get(key_name, 0) + return modifier_mask + + def grab_key_name(self, key_name, modifier_mask=0): + keysym_names = self.key_name_to_keysym_names(key_name) + for keysym_name in keysym_names: + keysym = XK.string_to_keysym(keysym_name) + if not keysym: + continue + keycode = self.display.keysym_to_keycode(keysym) + if not keycode: + continue + for effective_mask in self.optional_modifier_masks(modifier_mask): + try: + self.window.grab_key( + keycode, + effective_mask, + False, + X.GrabModeAsync, + X.GrabModeAsync, + ) + self.grabbed.add((keycode, effective_mask)) + except BadAccess: + self.failed_grabs += 1 + self.env["runtime"]["DebugManager"].write_debug_out( + "x11Driver could not grab " + + key_name + + " with modifier mask " + + str(effective_mask), + debug.DebugLevel.WARNING, + ) + + def optional_modifier_masks(self, modifier_mask): + optional_masks = [0, X.LockMask] + if self.num_lock_mask: + optional_masks += [self.num_lock_mask, self.num_lock_mask | X.LockMask] + return {modifier_mask | optional for optional in optional_masks} + + def key_name_to_keysym_names(self, key_name): + reverse_map = { + value: key for key, value in self.key_name_overrides.items() + } + aliases = { + "KEY_META": ["Super_L", "Super_R", "Meta_L", "Meta_R"], + "KEY_LEFTMETA": ["Super_L", "Meta_L"], + "KEY_RIGHTMETA": ["Super_R", "Meta_R"], + "KEY_COMPOSE": ["Multi_key"], + "KEY_KP0": ["KP_0", "KP_Insert"], + "KEY_KP1": ["KP_1", "KP_End"], + "KEY_KP2": ["KP_2", "KP_Down"], + "KEY_KP3": ["KP_3", "KP_Page_Down"], + "KEY_KP4": ["KP_4", "KP_Left"], + "KEY_KP5": ["KP_5", "KP_Begin"], + "KEY_KP6": ["KP_6", "KP_Right"], + "KEY_KP7": ["KP_7", "KP_Home"], + "KEY_KP8": ["KP_8", "KP_Up"], + "KEY_KP9": ["KP_9", "KP_Page_Up"], + "KEY_KPDOT": ["KP_Decimal", "KP_Delete"], + } + if key_name in aliases: + return aliases[key_name] + if key_name in reverse_map: + return [reverse_map[key_name]] + if key_name.startswith("KEY_F") and key_name[5:].isdigit(): + return [key_name[4:]] + if key_name.startswith("KEY_") and len(key_name) == 5: + return [key_name[4:].lower()] + if key_name.startswith("KEY_") and key_name[4:].isdigit(): + return [key_name[4:]] + return [] + + def reset_input_state(self): + try: + self.env["runtime"]["InputManager"].reset_input_state() + except Exception: + self.clear_event_buffer() + + def write_event_buffer(self): + self.clear_event_buffer() + + def clear_event_buffer(self): + if not self._initialized: + return + del self.env["input"]["event_buffer"][:] + + def write_debug(self, message, level): + try: + self.env["runtime"]["DebugManager"].write_debug_out(message, level) + except Exception: + pass + + def update_input_devices(self, new_devices=None, init=False): + return + + def grab_all_devices(self): + return True + + def ungrab_all_devices(self): + if not self.display or not self.window: + return True + for keycode, modifier_mask in list(self.grabbed): + try: + self.window.ungrab_key(keycode, modifier_mask) + except Exception: + pass + self.grabbed.clear() + try: + self.display.flush() + except Exception: + pass + return True + + def remove_all_devices(self): + self.ungrab_all_devices() + + def get_led_state(self, led=0): + return False + + def set_led_state(self, led_dict): + return False diff --git a/tests/unit/test_settings_validation.py b/tests/unit/test_settings_validation.py index 10be49e1..10ad1771 100644 --- a/tests/unit/test_settings_validation.py +++ b/tests/unit/test_settings_validation.py @@ -137,6 +137,7 @@ class TestDriverValidation: # Valid drivers self.manager._validate_setting_value("keyboard", "driver", "evdevDriver") self.manager._validate_setting_value("keyboard", "driver", "ptyDriver") + self.manager._validate_setting_value("keyboard", "driver", "x11Driver") self.manager._validate_setting_value("keyboard", "driver", "atspiDriver") self.manager._validate_setting_value("keyboard", "driver", "dummyDriver") diff --git a/tests/unit/test_x11_terminal_mode.py b/tests/unit/test_x11_terminal_mode.py new file mode 100644 index 00000000..4550cdba --- /dev/null +++ b/tests/unit/test_x11_terminal_mode.py @@ -0,0 +1,221 @@ +import importlib.machinery +import importlib.util +from pathlib import Path +from unittest.mock import Mock + +import pytest + +from fenrirscreenreader.inputDriver.x11Driver import X +from fenrirscreenreader.inputDriver.x11Driver import XK +from fenrirscreenreader.inputDriver.x11Driver import driver as X11Driver + + +def load_fenrir_entrypoint(): + fenrir_path = Path(__file__).parents[2] / "src" / "fenrir" + loader = importlib.machinery.SourceFileLoader( + "fenrir_entrypoint_x11", str(fenrir_path) + ) + spec = importlib.util.spec_from_loader(loader.name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +@pytest.mark.unit +def test_x11_mode_runs_in_foreground(): + fenrir = load_fenrir_entrypoint() + args = fenrir.create_argument_parser().parse_args(["-x"]) + + assert fenrir.should_run_foreground(args) is True + + +@pytest.mark.unit +def test_x11_mode_rejects_other_emulated_modes(): + fenrir = load_fenrir_entrypoint() + args = fenrir.create_argument_parser().parse_args(["-x", "-e"]) + + is_valid, error = fenrir.validate_arguments(args) + + assert is_valid is False + assert "--x11" in error + + +@pytest.mark.unit +def test_x11_cli_accepts_window_id(): + fenrir = load_fenrir_entrypoint() + args = fenrir.create_argument_parser().parse_args( + ["-x", "--x11-window-id", "0x123"] + ) + + assert args.x11_window_id == "0x123" + + +@pytest.mark.unit +def test_x11_key_name_mapping_for_keypad_and_capslock(): + x11 = X11Driver() + + assert x11.keysym_name_to_key_name("KP_0") == "KEY_KP0" + assert x11.keysym_name_to_key_name("KP_Insert") == "KEY_KP0" + assert x11.keysym_name_to_key_name("Caps_Lock") == "KEY_CAPSLOCK" + assert x11.keysym_name_to_key_name("Super_L") == "KEY_LEFTMETA" + assert x11.keysym_name_to_key_name("Multi_key") == "KEY_COMPOSE" + assert x11.keysym_name_to_key_name("a") == "KEY_A" + assert x11.keysym_name_to_key_name("F10") == "KEY_F10" + + +@pytest.mark.unit +def test_x11_keycode_mapping_prefers_keypad_aliases(): + x11 = X11Driver() + x11.display = Mock() + keysyms = { + 0: XK.string_to_keysym("Up"), + 1: XK.string_to_keysym("KP_8"), + 2: 0, + 3: 0, + 4: 0, + 5: 0, + 6: 0, + 7: 0, + } + x11.display.keycode_to_keysym.side_effect = lambda keycode, column: keysyms[column] + + assert x11.keycode_to_key_name(80) == "KEY_KP8" + + +@pytest.mark.unit +def test_x11_keycode_mapping_preserves_plain_insert(): + x11 = X11Driver() + x11.display = Mock() + keysyms = { + 0: XK.string_to_keysym("Insert"), + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 0, + 6: 0, + 7: 0, + } + x11.display.keycode_to_keysym.side_effect = lambda keycode, column: keysyms[column] + + assert x11.keycode_to_key_name(118) == "KEY_INSERT" + + +@pytest.mark.unit +def test_x11_keycode_mapping_detects_keypad_insert(): + x11 = X11Driver() + x11.display = Mock() + keysyms = { + 0: XK.string_to_keysym("Insert"), + 1: XK.string_to_keysym("KP_Insert"), + 2: 0, + 3: 0, + 4: 0, + 5: 0, + 6: 0, + 7: 0, + } + x11.display.keycode_to_keysym.side_effect = lambda keycode, column: keysyms[column] + + assert x11.keycode_to_key_name(90) == "KEY_KP0" + + +@pytest.mark.unit +def test_x11_key_name_to_keysym_names_includes_numlock_aliases(): + x11 = X11Driver() + + assert x11.key_name_to_keysym_names("KEY_KP0") == ["KP_0", "KP_Insert"] + assert "KP_Delete" in x11.key_name_to_keysym_names("KEY_KPDOT") + assert "Caps_Lock" in x11.key_name_to_keysym_names("KEY_CAPSLOCK") + assert "Super_L" in x11.key_name_to_keysym_names("KEY_META") + assert "Meta_R" in x11.key_name_to_keysym_names("KEY_META") + assert x11.key_name_to_keysym_names("KEY_COMPOSE") == ["Multi_key"] + + +@pytest.mark.unit +def test_x11_build_passive_grabs_for_fenrir_keys_and_shortcuts(): + x11 = X11Driver() + input_manager = Mock(convert_event_name=lambda key: key) + x11.env = { + "input": { + "fenrir_key": ["KEY_KP0", "KEY_CAPSLOCK"], + "script_key": [], + }, + "rawBindings": { + "fenrir_combo": [1, ["KEY_FENRIR", "KEY_KP8"]], + "bare_keypad": [1, ["KEY_KP5"]], + "ctrl_keypad": [1, ["KEY_CTRL", "KEY_KP2"]], + }, + "runtime": {"InputManager": input_manager}, + } + + grabs = x11.build_passive_grabs() + + assert ("KEY_KP0", 0) in grabs + assert ("KEY_CAPSLOCK", 0) in grabs + assert ("KEY_KP5", 0) in grabs + assert ("KEY_KP2", X.ControlMask) in grabs + + +@pytest.mark.unit +def test_x11_parse_window_id_accepts_decimal_and_hex(): + x11 = X11Driver() + + assert x11.parse_window_id("123") == 123 + assert x11.parse_window_id("0x123") == 0x123 + assert x11.parse_window_id("") is None + + +@pytest.mark.unit +def test_x11_write_event_buffer_clears_buffer(): + x11 = X11Driver() + x11._initialized = True + x11.env = {"input": {"event_buffer": ["KEY_KP0"]}} + + x11.write_event_buffer() + + assert x11.env["input"]["event_buffer"] == [] + + +@pytest.mark.unit +def test_x11_find_num_lock_mask_uses_modifier_mapping(): + x11 = X11Driver() + x11.display = Mock() + x11.display.keysym_to_keycode.return_value = 77 + x11.display.get_modifier_mapping.return_value = [ + [], + [], + [], + [], + [77], + [], + [], + [], + ] + + assert x11.find_num_lock_mask() == X.Mod2Mask + + +@pytest.mark.unit +def test_x11_handle_key_event_keeps_event_buffer_for_input_manager(): + x11 = X11Driver() + x11.active = True + x11.interesting_keys = {"KEY_KP0"} + x11.env = { + "input": { + "curr_input": [], + "event_buffer": [], + }, + "runtime": { + "InputManager": Mock(convert_event_name=lambda key: key), + "DebugManager": Mock(), + }, + } + event_queue = Mock() + event = Mock(type=X.KeyPress, detail=90, state=0) + x11.keycode_to_key_name = Mock(return_value="KEY_KP0") + + x11.handle_x_event(event, event_queue) + + assert x11.env["input"]["event_buffer"][0]["event_name"] == "KEY_KP0" + event_queue.put.assert_called_once()