Add X11 terminal input mode

This commit is contained in:
Storm Dragon
2026-05-07 01:45:00 -04:00
parent 40120f825c
commit fed8f24126
12 changed files with 927 additions and 5 deletions
+18
View File
@@ -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:
+2
View File
@@ -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
+22
View File
@@ -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
+1
View File
@@ -101,6 +101,7 @@ setup(
"dbus-python>=1.2.8",
"pyperclip",
"pyudev>=0.21.0",
"python-xlib>=0.33",
"setuptools",
"setproctitle",
"pexpect",
+23 -3
View File
@@ -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:
@@ -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
@@ -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
@@ -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"))
+2 -2
View File
@@ -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"
@@ -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
+1
View File
@@ -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")
+221
View File
@@ -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()