fix(shortcuts): record unmapped F19/F24 and XF86-aliased F13–F23 (#844)

* fix(shortcuts): record unmapped F19/F24 and XF86-aliased F13–F23

* fix(shortcuts): keep split-keyboard modifiers while recording

The recorder only looked at the keyboard that sent the function key, so a modifier held on the other half was dropped. A dead device also stayed in the watch list, which left F19/F24 uncapturable until Record was restarted.

Union held keys across every open keyboard before committing, and drop a device on hangup or read error while scanning for a replacement.

* fix(shortcuts): keep capture tests off real input devices

CI opened real /dev/input paths because the harness patched a stale evdev module, so io watches never attached. Patch the live module, and move the capture device protocol to common_types.

* fix(tests): patch the recorder's own GLib globals

test_settings_dialog reimports the dialog module and leaves the replacement in sys.modules. Recorder methods still close over the original dict, so the harness never recorded timeouts or io watches.
This commit is contained in:
Jatin K Malik
2026-09-29 01:05:46 -07:00
committed by GitHub
parent a38cff59ea
commit 06f049ac76
3 changed files with 853 additions and 20 deletions
+21 -1
View File
@@ -4,7 +4,7 @@ This module provides type definitions to avoid circular imports.
"""
from enum import Enum, auto
from typing import Callable, Optional, Protocol # noqa: F401
from typing import Any, Callable, Iterable, Optional, Protocol # noqa: F401
class RecognitionState(Enum):
@@ -79,3 +79,23 @@ class TextInjectorProtocol(Protocol):
def inject_text(self, text: str) -> bool:
"""Inject text into the active application."""
...
class _EvdevCaptureDevice(Protocol):
"""Minimal evdev InputDevice surface used by the shortcut recorder."""
def fileno(self) -> int:
"""Return the device file descriptor for GLib.io_add_watch."""
...
def read(self) -> Iterable[Any]:
"""Return pending input events."""
...
def close(self) -> None:
"""Close the device file."""
...
def active_keys(self) -> Iterable[int]:
"""Return evdev codes currently down on this device."""
...
+342 -14
View File
@@ -22,7 +22,7 @@ import os
import re
import threading
import time
from typing import TYPE_CHECKING, Any, NamedTuple, Optional
from typing import TYPE_CHECKING, Any, Callable, NamedTuple, Optional
import gi
@@ -31,7 +31,7 @@ gi.require_version("Gdk", "3.0")
# Need GLib for idle_add
from gi.repository import Gdk, GLib, GObject, Gtk, Pango # noqa: E402
from ..common_types import RecognitionState # noqa: E402
from ..common_types import RecognitionState, _EvdevCaptureDevice # noqa: E402
from ..speech_recognition.silero_vad import is_silero_available # noqa: E402
from ..utils import parakeet_model_info as parakeet # noqa: E402
from ..utils.faster_whisper_model_info import (
@@ -114,6 +114,7 @@ from .keyboard_backends import ( # noqa: E402
is_valid_shortcut,
parse_shortcut_spec,
)
from .keyboard_backends.evdev_backend import MODIFIER_KEY_CODES # noqa: E402
# Avoid circular imports for type checking
if TYPE_CHECKING:
@@ -1644,6 +1645,42 @@ def _gdk_keyname_to_token(name: Optional[str]) -> Optional[str]:
return None
def function_token_from_gdk_hardware_keycode(hardware_keycode: int) -> Optional[str]:
"""Map XKB/GDK hardware keycodes 191–202 (FK13–FK24) to f13–f24.
Default evdev maps those keys to XF86* keysyms or NoSymbol; the XKB
keycode is the unambiguous signal. F1–F12 use other keycodes and already
arrive as F1–F12 keysyms.
"""
try:
code = int(hardware_keycode)
except (TypeError, ValueError):
return None
if 191 <= code <= 202:
return f"f{code - 178}"
return None
def function_token_from_evdev_code(code: int) -> Optional[str]:
"""Map evdev KEY_F13–KEY_F24 (183–194) to f13–f24."""
try:
key = int(code)
except (TypeError, ValueError):
return None
if 183 <= key <= 194:
return f"f{key - 170}"
return None
def modifiers_from_active_evdev_codes(active_codes: set[int]) -> list[str]:
"""Return ctrl/alt/shift/super currently held in *active_codes*, in that order."""
held: list[str] = []
for name in ("ctrl", "alt", "shift", "super"):
if active_codes & MODIFIER_KEY_CODES[name]:
held.append(name)
return held
def _shortcut_from_capture(modifiers: list[str], token: Optional[str]) -> Optional[str]:
"""Build a canonical shortcut from recorded modifiers and a main key."""
if token is None:
@@ -1652,6 +1689,255 @@ def _shortcut_from_capture(modifiers: list[str], token: Optional[str]) -> Option
return candidate if is_valid_shortcut(candidate) else None
def _gdk_capture_to_shortcut(
modifiers: list[str],
keyval_name: Optional[str],
hardware_keycode: int = 0,
) -> Optional[str]:
"""Build a shortcut from recorder inputs, preferring FK13–FK24 keycodes."""
token = function_token_from_gdk_hardware_keycode(hardware_keycode)
if token is None:
token = _gdk_keyname_to_token(keyval_name)
return _shortcut_from_capture(modifiers, token)
# linux/input.h EV_KEY; python-evdev uses the same value.
_EVDEV_EV_KEY = 1
class _EvdevShortcutRecorder:
"""Read-only F13–F24 capture from keyboard evdev nodes (no grab, no thread).
GTK 3 on Wayland drops XKB NoSymbol keys before they become GdkEvents, so
FK19/FK24 never reach the dialog key-press handler on the default map.
Unions modifiers across watched keyboards, and drops a failed device while
still scanning until recording stops.
"""
def __init__(self, on_shortcut: Callable[[str], None]) -> None:
self._on_shortcut = on_shortcut
self._active = False
self._devices: dict[str, _EvdevCaptureDevice] = {}
self._watch_ids: dict[str, Any] = {}
self._held: dict[str, set[int]] = {}
self._scan_timeout_id: Optional[Any] = None
self._input_device_cls: Any = None
self._find_keyboard_devices: Optional[Callable[[], list[str]]] = None
def start(self) -> None:
try:
from .keyboard_backends.evdev_backend import (
DEVICE_RESCAN_SECONDS,
EVDEV_AVAILABLE,
find_keyboard_devices,
)
except ImportError:
logger.debug("evdev unavailable for shortcut recording; using GDK events only")
return
if not EVDEV_AVAILABLE:
logger.debug("evdev unavailable for shortcut recording; using GDK events only")
return
try:
from evdev import InputDevice
except ImportError:
logger.debug("evdev unavailable for shortcut recording; using GDK events only")
return
self._active = True
self._input_device_cls = InputDevice
self._find_keyboard_devices = find_keyboard_devices
paths: list[str] = []
listed = False
try:
paths = list(find_keyboard_devices())
listed = True
except Exception as e:
logger.warning("Cannot list keyboard devices for shortcut recording: %s", e)
open_error: Optional[tuple[str, BaseException]] = None
for path in paths:
err = self._attach_device(path)
if err is not None and open_error is None:
open_error = (path, err)
if not self._devices:
if open_error is not None:
logger.warning(
"Cannot open keyboard devices for shortcut recording "
"(%s: %s); F-keys without keysyms will not be captured",
open_error[0],
open_error[1],
)
elif listed and not paths:
logger.debug("No keyboard devices found for shortcut recording")
elif listed:
logger.debug("No keyboard devices opened for shortcut recording")
if self._active:
self._scan_timeout_id = GLib.timeout_add(
int(DEVICE_RESCAN_SECONDS * 1000),
self._on_device_rescan,
)
def stop(self) -> None:
self._active = False
timeout_id = self._scan_timeout_id
self._scan_timeout_id = None
if timeout_id is not None:
try:
GLib.source_remove(timeout_id)
except Exception:
pass
for watch_id in list(self._watch_ids.values()):
try:
GLib.source_remove(watch_id)
except Exception:
pass
self._watch_ids.clear()
for device in list(self._devices.values()):
self._close_device(device)
self._devices.clear()
self._held.clear()
def _close_device(self, device: _EvdevCaptureDevice) -> None:
try:
device.close()
except Exception:
pass
def _attach_device(self, path: str, *, hotplug: bool = False) -> Optional[BaseException]:
"""Open and watch *path* if recording is active and it is not already watched.
Returns the InputDevice open error, or None if attached/skipped.
"""
if not self._active or path in self._devices or self._input_device_cls is None:
return None
try:
device = self._input_device_cls(path)
except (OSError, IOError) as e:
return e
if not self._active:
self._close_device(device)
return None
# Do not grab: compositor and the runtime backend must still see keys.
try:
held = set(device.active_keys())
except Exception:
held = set()
try:
watch_id = GLib.io_add_watch(
device.fileno(),
GLib.IO_IN | GLib.IO_ERR | GLib.IO_HUP,
self._make_io_callback(device),
)
except Exception as e:
logger.debug("GLib.io_add_watch failed for %s: %s", path, e)
self._close_device(device)
return None
if not self._active:
try:
GLib.source_remove(watch_id)
except Exception:
pass
self._close_device(device)
return None
self._devices[path] = device
self._watch_ids[path] = watch_id
self._held[path] = held
if hotplug:
logger.debug("Added keyboard for shortcut recording: %s", path)
return None
def _drop_device(self, path: str) -> None:
"""Close and forget *path* without source_remove of its in-flight watch."""
self._watch_ids.pop(path, None)
device = self._devices.pop(path, None)
self._held.pop(path, None)
if device is not None:
self._close_device(device)
logger.debug("Dropped keyboard from shortcut recording: %s", path)
def _union_held_codes(self) -> set[int]:
union: set[int] = set()
for codes in self._held.values():
union.update(codes)
return union
def _path_for_device(self, device: _EvdevCaptureDevice) -> Optional[str]:
for path, tracked in self._devices.items():
if tracked is device:
return path
return None
def _on_device_rescan(self) -> bool:
if not self._active:
return False
finder = self._find_keyboard_devices
if finder is None:
return False
try:
paths = list(finder())
except Exception:
return True
for path in paths:
if not self._active:
return False
self._attach_device(path, hotplug=True)
return self._active
def _make_io_callback(self, device: _EvdevCaptureDevice) -> Callable[[int, int], bool]:
def _on_io(fd: int, condition: int) -> bool:
return self._on_fd(device, condition)
return _on_io
def _on_fd(self, device: _EvdevCaptureDevice, condition: int) -> bool:
if not self._active:
return False
path = self._path_for_device(device)
if path is None:
return False
try:
if condition & (GLib.IO_ERR | GLib.IO_HUP):
self._drop_device(path)
return False
except Exception:
pass
held = self._held.setdefault(path, set())
try:
events = list(device.read())
except BlockingIOError:
return True
except (OSError, IOError):
self._drop_device(path)
return False
for event in events:
if getattr(event, "type", None) != _EVDEV_EV_KEY:
continue
code = event.code
value = event.value
if value == 1:
held.add(code)
token = function_token_from_evdev_code(code)
if token is None:
continue
shortcut = _shortcut_from_capture(
modifiers_from_active_evdev_codes(self._union_held_codes()),
token,
)
if shortcut:
# Pop before on_shortcut: the dialog stop() must not
# source_remove this in-flight watch (returning False does).
self._watch_ids.pop(path, None)
self._on_shortcut(shortcut)
return False
elif value == 0:
held.discard(code)
return True
def _row_matches_query(query: str, title: str, subtitle: str = "", keywords=()) -> bool:
"""Return whether a settings row matches a search query.
@@ -3667,7 +3953,9 @@ class SettingsDialog(Gtk.Dialog):
# Key-capture state for the Record button.
self._recording_shortcut = False
self._evdev_shortcut_recorder = None
self.connect("key-press-event", self._on_shortcut_key_press)
self.connect("destroy", self._on_shortcut_recorder_destroy)
self.shortcuts_tab.pack_start(group, False, False, 0)
@@ -3816,11 +4104,50 @@ class SettingsDialog(Gtk.Dialog):
self.shortcut_info_label.set_markup(
"<i>Press a modifier + key (e.g. Alt+R), or an F-key. Press Esc to cancel.</i>"
)
self._start_evdev_shortcut_recorder()
def _stop_recording_shortcut(self):
"""Exit key-capture mode and restore the Record button."""
self._recording_shortcut = False
self.record_shortcut_button.set_label("Record")
self._stop_evdev_shortcut_recorder()
if getattr(self, "record_shortcut_button", None) is not None:
try:
self.record_shortcut_button.set_label("Record")
except Exception:
pass
def _start_evdev_shortcut_recorder(self) -> None:
"""Listen on keyboard evdev nodes for F13–F24 while recording."""
self._stop_evdev_shortcut_recorder()
recorder = _EvdevShortcutRecorder(self._on_evdev_recorded_shortcut)
recorder.start()
self._evdev_shortcut_recorder = recorder
def _stop_evdev_shortcut_recorder(self) -> None:
recorder = getattr(self, "_evdev_shortcut_recorder", None)
if recorder is not None:
recorder.stop()
self._evdev_shortcut_recorder = None
def _on_shortcut_recorder_destroy(self, widget) -> None:
"""Drop evdev watches if the dialog closes while recording."""
self._recording_shortcut = False
self._stop_evdev_shortcut_recorder()
def _commit_recorded_shortcut(self, shortcut: Optional[str]) -> bool:
"""Apply a captured shortcut if still recording. Returns True if consumed."""
if not getattr(self, "_recording_shortcut", False):
return False
if not shortcut or not is_valid_shortcut(shortcut):
return False
self.custom_shortcut_entry.set_text(shortcut)
self._stop_recording_shortcut()
self._apply_custom_shortcut(shortcut)
return True
def _on_evdev_recorded_shortcut(self, shortcut: str) -> None:
"""Apply an F13–F24 capture from the evdev watcher (main loop)."""
self._commit_recorded_shortcut(shortcut)
def _gdk_event_to_shortcut(self, event) -> Optional[str]:
"""Build a canonical shortcut string from a GDK key-press event."""
@@ -3834,8 +4161,8 @@ class SettingsDialog(Gtk.Dialog):
modifiers.append("shift")
if state & Gdk.ModifierType.SUPER_MASK:
modifiers.append("super")
token = _gdk_keyname_to_token(Gdk.keyval_name(event.keyval))
return _shortcut_from_capture(modifiers, token)
hardware = getattr(event, "hardware_keycode", 0) or 0
return _gdk_capture_to_shortcut(modifiers, Gdk.keyval_name(event.keyval), hardware)
def _on_shortcut_key_press(self, widget, event):
"""Capture a pressed combo while recording; otherwise pass through."""
@@ -3847,20 +4174,19 @@ class SettingsDialog(Gtk.Dialog):
return True # wait for the non-modifier key
shortcut = self._gdk_event_to_shortcut(event)
if self._commit_recorded_shortcut(shortcut):
return True
if not getattr(self, "_recording_shortcut", False):
return True
if keyname == "Escape" and not shortcut:
self._stop_recording_shortcut()
self.shortcut_info_label.set_text("Recording cancelled.")
return True
if shortcut and is_valid_shortcut(shortcut):
self.custom_shortcut_entry.set_text(shortcut)
self._stop_recording_shortcut()
self._apply_custom_shortcut(shortcut)
else:
self.shortcut_info_label.set_markup(
"<span foreground='#e01b24'>Need a modifier + key, or an F1–F24 "
"function key alone. Try again or press Esc to cancel.</span>"
)
self.shortcut_info_label.set_markup(
"<span foreground='#e01b24'>Need a modifier + key, or an F1–F24 "
"function key alone. Try again or press Esc to cancel.</span>"
)
return True
def _update_shortcut_ui_for_mode(self, mode: str):
@@ -7271,6 +7597,8 @@ For now, the engine has been reverted to VOSK."""
def _on_dialog_destroy(self, widget):
"""Clean up callbacks when dialog is destroyed."""
self._recording_shortcut = False
self._stop_evdev_shortcut_recorder()
if hasattr(self, "speech_engine") and self.speech_engine:
if self._on_recognition_state_changed in self.speech_engine.state_callbacks:
self.speech_engine.state_callbacks.remove(self._on_recognition_state_changed)