fix(hotkeys): consume the dictation shortcut so it stops leaking to apps (#873)

* fix(hotkeys): consume the dictation shortcut so it stops leaking to apps

Grab each monitored keyboard with EVIOCGRAB and re-emit every event the
shortcut does not own through a paired uinput clone. The configured
shortcut key is now consumed by Vocalinux instead of also reaching the
focused app (right-alt opening menus in Thunderbird/Firefox, F11/F12
triggering browser actions).

- Pure-modifier gestures reserve the modifier's events entirely; bare
  function-key shortcuts consume that key; modifier+key combos consume
  the main key only while the combo owns it, so typing and chords like
  Alt+Tab keep working and injected text is untouched
- BUS_VIRTUAL devices (our clones, ydotool, remappers) are never grabbed
- EV_REP is stripped from clones and repeats are forwarded verbatim, so
  repeat rate is preserved without doubling
- Feedback event types (EV_LED/EV_SND/EV_FF) are never re-injected
- Falls back to ungrabbed listening when /dev/uinput or the grab is
  unavailable, warning once that keys will also reach the app

Fixes #871

* test(hotkeys): cover grab/forward edge cases

Race cleanup on duplicate open, forwarder close errors during stop and
device removal, mid-SYN_DROPPED forwarding, write failures, and the
combo consume edge cases (unowned repeat/release, missing main key).

* fix(installer): grant read on /dev/uinput so key suppression works

The evdev backend's uinput clone opens /dev/uinput O_RDWR, but the
installed 80-dotool.rules gave the input group write-only access
(0620), so shortcut suppression silently fell back to ungrabbed
monitoring on stock installs. Bump the rule to 0660 and refresh it when
the installed copy differs so existing installs pick up the fix.

* fix(hotkeys): harden evdev grab/forward lifecycle and udev install

- release the device grab and close the clone when a uinput write fails
- close all devices if the monitor exits unexpectedly (releases grabs)
- monitor virtual devices too; skip only our own uinput clones
- track consumed combo presses per device so releases stay paired
- after SYN_DROPPED, release keys the clone still believes are held
- append the uinput udev rule instead of overwriting local rules
- annotate new signatures and narrow the clone-creation catch

* fix(hotkeys): reconcile swallowed combo releases after SYN_DROPPED

- resync the swallowed-press pairing even when the clone holds no keys;
  a dropped combo release otherwise leaves the next ordinary press
  consumed, eating a keystroke
- narrow every new close-cleanup catch to OSError/IOError/RuntimeError
  with debug context instead of blanket Exception passes

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
devin-ai-integration[bot]
2026-09-29 22:59:35 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent 004696bbe0
commit ff108595fd
3 changed files with 1045 additions and 84 deletions
@@ -3,6 +3,12 @@ evdev keyboard backend for Wayland support.
This backend uses python-evdev to read keyboard events directly from
input devices, which works on both X11 and Wayland (with proper permissions).
When /dev/uinput is writable, each opened keyboard is grabbed (EVIOCGRAB)
and its events are re-emitted through a paired uinput clone, so the keys
that make up the dictation shortcut are consumed here and never reach the
focused application. Without uinput access the backend still listens
ungrabbed, but shortcut presses also pass through to apps.
"""
import errno
@@ -12,17 +18,20 @@ import re
import select
import threading
import time
from typing import Optional, TextIO
from typing import Optional, Sequence, TextIO
# Try to import evdev
try:
import evdev
from evdev import InputDevice, ecodes
from evdev import InputDevice, InputEvent, UInput, UInputError, ecodes
EVDEV_AVAILABLE = True
except ImportError:
evdev = None # type: ignore
InputDevice = None # type: ignore
InputEvent = None # type: ignore
UInput = None # type: ignore
UInputError = OSError # type: ignore
ecodes = None # type: ignore
EVDEV_AVAILABLE = False
@@ -49,6 +58,23 @@ KEY_LEFTMETA = 125 # Super/Windows key
KEY_RIGHTMETA = 126
DEVICE_RESCAN_SECONDS = 2.0
# Event types re-emitted on the uinput clone. Feedback events (EV_LED,
# EV_SND, EV_FF) are kernel->device traffic already delivered to every
# capable device — re-injecting them could feed back into the LED layer.
if EVDEV_AVAILABLE:
_FORWARDED_EVENT_TYPES = frozenset(
{
ecodes.EV_KEY,
ecodes.EV_REL,
ecodes.EV_ABS,
ecodes.EV_MSC,
ecodes.EV_SW,
getattr(ecodes, "EV_ROT", -1),
}
)
else:
_FORWARDED_EVENT_TYPES = frozenset()
# Map modifier key names to evdev key codes
MODIFIER_KEY_CODES: dict[str, set[int]] = {
"ctrl": {KEY_LEFTCTRL, KEY_RIGHTCTRL},
@@ -311,11 +337,25 @@ class EvdevKeyboardBackend(KeyboardBackend):
self._combo_all_codes: set[int] = set()
self._combo_pressed: set[int] = set() # currently-held combo-relevant codes
self._combo_active = False # True while a push-to-talk combo hold is live
# fds whose combo main-key press was consumed but not yet released —
# per-device so one keyboard's release can't consume another's press.
self._combo_swallowed: set[int] = set()
self._resolve_combo_targets()
self._devices_lock = threading.Lock()
self._dropped_devices: set[int] = set() # fds with SYN_DROPPED pending
self._device_paths_by_fd: dict[int, str] = {}
# Grabbed fd -> uinput clone that re-emits events we do not consume.
self._forwarders: dict[int, UInput] = {}
# Grabbed fd -> its clone's /dev/input/eventN path, and the live set
# of clone paths. Clones are never monitored: their events are the
# ones we forwarded, so reading them would loop input back in.
self._forwarder_paths: dict[int, str] = {}
self._clone_paths: set[str] = set()
# Grabbed fd -> key codes the clone currently believes are held.
# Needed to reconcile after SYN_DROPPED eats release events.
self._forwarded_held: dict[int, set[int]] = {}
self._uinput_warned = False
if not EVDEV_AVAILABLE:
logger.error("python-evdev not available")
@@ -336,6 +376,7 @@ class EvdevKeyboardBackend(KeyboardBackend):
self._combo_all_codes = set()
self._combo_pressed = set()
self._combo_active = False
self._combo_swallowed = set()
spec = getattr(self, "_spec", None)
if spec is None or not spec.is_combo:
@@ -374,6 +415,12 @@ class EvdevKeyboardBackend(KeyboardBackend):
push-to-talk hold could stay stuck on. Clearing the pressed set fails
safe (the user simply re-presses the modifier), and ending any live hold
prevents a stuck session.
``_combo_swallowed`` is deliberately left alone: it records consumed
presses per device, so clearing it here would let the matching
release of a still-held key leak to the application without its
press. Stale entries are pruned per-device on removal and during
the SYN_DROPPED resync instead.
"""
self._combo_pressed = set()
# Ends an active push-to-talk hold (fires the release callback); no-op
@@ -486,6 +533,11 @@ class EvdevKeyboardBackend(KeyboardBackend):
self.key_pressed_devices = set()
self._dropped_devices = set()
self._device_paths_by_fd = {}
self._forwarders = {}
self._forwarder_paths = {}
self._clone_paths = set()
self._forwarded_held = {}
self._uinput_warned = False
# Refresh combo targets and clear any stale held-key state.
self._resolve_combo_targets()
@@ -520,20 +572,39 @@ class EvdevKeyboardBackend(KeyboardBackend):
self.monitor_thread.join(timeout=2.0)
self.monitor_thread = None
# Close devices
self._close_all_devices()
def _close_all_devices(self) -> None:
"""Close every monitored device and uinput clone.
Closing a grabbed device releases its EVIOCGRAB, and closing a
uinput clone makes the kernel release any keys the clone still
holds — together they hand input delivery back to applications.
"""
with self._devices_lock:
devices = list(self.devices)
forwarders = list(self._forwarders.values())
self.devices = []
self.device_fds = []
self.device_paths = set()
self._dropped_devices = set()
self._device_paths_by_fd = {}
self._forwarders = {}
self._forwarder_paths = {}
self._clone_paths = set()
self._forwarded_held = {}
self._combo_swallowed = set()
for device in devices:
try:
device.close()
except Exception:
pass
except (OSError, IOError, RuntimeError) as e:
logger.debug(f"Ignoring device close failure during cleanup: {e}")
for forwarder in forwarders:
try:
forwarder.close()
except (OSError, IOError, RuntimeError) as e:
logger.debug(f"Ignoring clone close failure during cleanup: {e}")
def _open_keyboard_device(self, device_path: str) -> bool:
"""Open a keyboard device if it is not already monitored."""
@@ -548,22 +619,229 @@ class EvdevKeyboardBackend(KeyboardBackend):
logger.warning(f"Cannot open {device_path}: {e}")
return False
# Only Vocalinux's own uinput clones are never monitored — reading
# them would loop the events we forwarded straight back in. Clones
# are matched by their registered device path; the " (vocalinux)"
# name suffix also covers clones of other Vocalinux processes whose
# paths we never registered. Other virtual devices (key remappers,
# accessibility keyboards) ARE monitored: users can bind shortcuts
# to them, and grabbing is what lets the shortcut reach us at all
# when a remapper has already grabbed the physical device.
device_name = str(getattr(device, "name", "") or "")
if device_path in self._clone_paths or device_name.endswith(" (vocalinux)"):
logger.debug(f"Skipping Vocalinux clone device: {device_path} ({device_name})")
try:
device.close()
except (OSError, IOError, RuntimeError) as e:
logger.debug(f"Ignoring close failure for clone {device_path}: {e}")
return False
# Exclusive grab hides the device from the compositor; the paired
# clone re-emits every event we do not consume so apps keep typing.
forwarder = self._create_forwarder(device)
if forwarder is not None:
try:
device.grab()
except (OSError, IOError) as e:
logger.warning(
f"Cannot grab {device_path} ({e}); "
"shortcut keys will also reach the focused app"
)
try:
forwarder.close()
except (OSError, IOError, RuntimeError) as e:
logger.debug(f"Ignoring clone close failure for {device_path}: {e}")
forwarder = None
with self._devices_lock:
if device_path in self.device_paths or fd in self.device_fds:
try:
device.close()
except Exception:
pass
except (OSError, IOError, RuntimeError) as e:
logger.debug(f"Ignoring close failure for duplicate {device_path}: {e}")
if forwarder is not None:
try:
forwarder.close()
except (OSError, IOError, RuntimeError) as e:
logger.debug(
f"Ignoring clone close failure for duplicate {device_path}: {e}"
)
return False
self.devices.append(device)
self.device_fds.append(fd)
self.device_paths.add(device_path)
self._device_paths_by_fd[fd] = device_path
if forwarder is not None:
self._forwarders[fd] = forwarder
clone_path = self._forwarder_device_path(forwarder)
if clone_path is not None:
self._forwarder_paths[fd] = clone_path
self._clone_paths.add(clone_path)
self._forwarded_held[fd] = set()
logger.debug(f"Opened keyboard device: {device_path} ({device.name})")
logger.debug(
f"Opened keyboard device: {device_path} ({device.name})"
f"{' [grabbed]' if forwarder is not None else ''}"
)
return True
@staticmethod
def _forwarder_device_path(forwarder: UInput) -> Optional[str]:
"""Return the clone's /dev/input/eventN path, or None if unknowable.
The path is what lets ``_open_keyboard_device`` recognize (and skip)
our clones even when the clone name was truncated to the uinput
80-byte limit.
"""
try:
path = forwarder.device.path
except (AttributeError, OSError, IOError):
return None
return path if isinstance(path, str) else None
def _create_forwarder(self, device: InputDevice) -> Optional[UInput]:
"""Create a uinput clone of a keyboard for pass-through forwarding.
EV_REP is filtered out so the clone never autorepeats: the physical
device's own repeat events are forwarded verbatim, preserving its
repeat rate without doubling repeats.
"""
try:
capabilities: dict[int, Sequence[int]] = {
k: v for k, v in device.capabilities().items()
}
for event_type in (ecodes.EV_SYN, ecodes.EV_FF, ecodes.EV_REP):
capabilities.pop(event_type, None)
return UInput(
events=capabilities,
name=f"{device.name} (vocalinux)",
bustype=ecodes.BUS_VIRTUAL,
)
except (OSError, IOError, TypeError, ValueError, UInputError) as e:
if not self._uinput_warned:
self._uinput_warned = True
logger.warning(
"Cannot create a virtual keyboard for key suppression "
f"(/dev/uinput not writable?): {e}. The dictation shortcut "
"will also reach the focused application."
)
return None
def _release_failed_forwarder(self, fd: int) -> None:
"""Drop a dead uinput clone and release the source device's grab.
A keyboard that stays grabbed while its clone can no longer accept
events swallows all user input; releasing EVIOCGRAB hands delivery
back to the kernel so applications see keys again (the shortcut can
then no longer be suppressed, matching the ungrabbed fallback). If
the grab itself cannot be released, the device is removed outright
— a grabbed device with a dead clone is worse than no listener.
"""
with self._devices_lock:
device = next((d for d in self.devices if d.fileno() == fd), None)
forwarder = self._forwarders.pop(fd, None)
clone_path = self._forwarder_paths.pop(fd, None)
if clone_path is not None:
self._clone_paths.discard(clone_path)
self._forwarded_held.pop(fd, None)
if forwarder is not None:
try:
forwarder.close()
except (OSError, IOError, RuntimeError) as e:
logger.debug(f"Ignoring clone close failure for fd {fd}: {e}")
if device is None:
return
try:
device.ungrab()
except (OSError, IOError) as e:
logger.error(
f"Cannot release grab on fd {fd} ({e}); "
"removing the device so it stops swallowing input"
)
self._remove_keyboard_device(fd, device)
return
logger.warning(
f"Released grab on {getattr(device, 'name', 'device')} (fd={fd}) "
"after a forwarding failure; keys pass through to applications "
"but the dictation shortcut can no longer be suppressed"
)
def _forward_event(self, fd: int, event: InputEvent) -> None:
"""Re-emit an event on the uinput clone paired with a grabbed device.
Successful EV_KEY writes also update ``_forwarded_held`` so
``_resync_clone_key_state`` can tell which keys the clone still
believes are held after a SYN_DROPPED burst.
"""
if event.type != ecodes.EV_SYN and event.type not in _FORWARDED_EVENT_TYPES:
return
forwarder = self._forwarders.get(fd)
if forwarder is None:
return
try:
forwarder.write_event(event)
except (OSError, IOError) as e:
logger.error(f"Failed to forward event on fd {fd}: {e}")
self._release_failed_forwarder(fd)
return
if event.type == ecodes.EV_KEY:
held = self._forwarded_held.get(fd)
if held is not None:
if event.value == 0:
held.discard(event.code)
else:
held.add(event.code)
def _event_is_shortcut(self, fd: int, event: InputEvent) -> bool:
"""Return True if an EV_KEY event belongs to the dictation shortcut.
Consumed events are handled locally and never re-emitted, so the
focused application never sees them:
- Pure-modifier gestures (``right_alt+right_alt``, ``ctrl+ctrl``):
every event of the configured modifier side(s) is consumed — the
modifier is reserved for Vocalinux while the listener runs.
- Bare function-key shortcuts (``f11``): every event of that key.
- Modifier+key combos (``alt+r``): the main key is consumed only
while the combo owns it (press with all modifiers held, plus the
matching repeat/release). Required modifiers and unrelated keys
pass through, so typing and chords like Alt+Tab keep working.
Consumption of a combo main-key press is tracked per device fd:
when two keyboards both press the main key while the modifiers are
held, each device's release must still find its own consumed press
— a shared flag would let the first release expose the second
keyboard's release without its press. Stale entries are pruned on
device removal and by the post-SYN_DROPPED resync.
"""
if event.type != ecodes.EV_KEY:
return False
spec = getattr(self, "_spec", None)
if spec is None:
return False
if not spec.is_combo:
return event.code in self._get_target_key_codes()
main_code = self._combo_main_code
if event.code != main_code or main_code is None:
return False
if not spec.modifiers:
return True # bare function key
if event.value == 1 and self._required_modifiers_held():
self._combo_swallowed.add(fd)
return True
if event.value == 0:
# Consume the release of a swallowed press on THIS device exactly
# once, so apps never see a press/release pair we half-forwarded.
if fd not in self._combo_swallowed:
return False
self._combo_swallowed.discard(fd)
return True
return fd in self._combo_swallowed
def _scan_for_new_devices(self) -> int:
"""Find and open keyboard devices that appeared after startup."""
new_device_count = 0
@@ -587,8 +865,8 @@ class EvdevKeyboardBackend(KeyboardBackend):
"""Close and forget a disconnected keyboard device."""
try:
device.close()
except Exception:
pass
except (OSError, IOError, RuntimeError) as e:
logger.debug(f"Ignoring close failure for disconnected fd {fd}: {e}")
with self._devices_lock:
try:
@@ -604,94 +882,182 @@ class EvdevKeyboardBackend(KeyboardBackend):
self.device_paths.discard(device_path)
self._dropped_devices.discard(fd)
self.key_pressed_devices.discard(id(device))
# A disconnect can swallow the modifier release (e.g. a wireless
# split half dropping mid-hold); don't leave the combo logically held.
self._reset_combo_state()
forwarder = self._forwarders.pop(fd, None)
clone_path = self._forwarder_paths.pop(fd, None)
if clone_path is not None:
self._clone_paths.discard(clone_path)
self._forwarded_held.pop(fd, None)
# The fd can be recycled for a newly plugged device; a stale
# swallowed press would then eat that device's first release.
self._combo_swallowed.discard(fd)
if forwarder is not None:
try:
forwarder.close()
except (OSError, IOError, RuntimeError) as e:
logger.debug(f"Ignoring clone close failure for disconnected fd {fd}: {e}")
# A disconnect can swallow the modifier release (e.g. a wireless
# split half dropping mid-hold); don't leave the combo logically held.
self._reset_combo_state()
def _resync_clone_key_state(self, fd: int, device: InputDevice) -> None:
"""Release clone-side keys whose releases were lost to SYN_DROPPED.
The kernel's dropped-event burst may have skipped release events,
but the paired clone only saw what we forwarded — a forwarded press
whose release was dropped leaves the key held on the clone forever,
and no later event supplies the missing release. The burst can also
eat the release of a swallowed combo press, which the clone never
saw at all. Compare what the clone believes (``_forwarded_held``)
and what we paired (``_combo_swallowed``) against the device's live
kernel key state and emit releases for the phantom keys.
"""
forwarder = self._forwarders.get(fd)
held = self._forwarded_held.get(fd)
if (forwarder is None or not held) and fd not in self._combo_swallowed:
return
try:
actually_held = set(device.active_keys())
except (OSError, IOError) as e:
logger.warning(f"Cannot resync key state for fd {fd}: {e}")
return
if forwarder is not None and held:
for code in sorted(held - actually_held):
try:
forwarder.write(ecodes.EV_KEY, code, 0)
except (OSError, IOError) as e:
logger.error(f"Failed to release stuck key {code} on fd {fd}: {e}")
self._release_failed_forwarder(fd)
return
held.discard(code)
if self._combo_main_code is not None and self._combo_main_code not in actually_held:
# A dropped burst can also eat the release of a swallowed combo
# press — a key the clone never saw, so ``held`` cannot reflect
# it. Without this prune the next ordinary press of that key
# would be consumed too, eating a keystroke.
self._combo_swallowed.discard(fd)
def _monitor_devices(self) -> None:
"""Monitor keyboard devices for events."""
logger.debug("Starting device monitor thread")
last_scan = time.monotonic()
while self.running:
try:
now = time.monotonic()
if now - last_scan >= DEVICE_RESCAN_SECONDS:
self._scan_for_new_devices()
last_scan = now
try:
while self.running:
try:
now = time.monotonic()
if now - last_scan >= DEVICE_RESCAN_SECONDS:
self._scan_for_new_devices()
last_scan = now
# Use select to wait for events on any device
with self._devices_lock:
device_fds = list(self.device_fds)
# Use select to wait for events on any device
with self._devices_lock:
device_fds = list(self.device_fds)
if not device_fds:
time.sleep(1.0)
continue
readable, _, _ = select.select(device_fds, [], [], 1.0) # 1 second timeout
for fd in readable:
try:
# Find the device for this fd
with self._devices_lock:
device = None
for d in self.devices:
if d.fileno() == fd:
device = d
break
if device is None:
continue
# Read events from this device
for event in device.read():
if event.type == ecodes.EV_SYN:
if event.code == ecodes.SYN_DROPPED:
# Kernel buffer overflowed — discard until SYN_REPORT
self._dropped_devices.add(fd)
logger.warning(
f"SYN_DROPPED on {device.name} (fd={fd}), "
"resetting key state"
)
elif event.code == ecodes.SYN_REPORT:
if fd in self._dropped_devices:
# End of dropped sequence — clear stale state
self._dropped_devices.discard(fd)
self.key_pressed_devices.discard(id(device))
# A dropped modifier release must not leave
# the combo logically held.
self._reset_combo_state()
continue
if fd in self._dropped_devices:
continue
if event.type == ecodes.EV_KEY:
self._handle_key_event(event, device)
except (OSError, IOError) as e:
# Device was disconnected - remove it to avoid busy loop
device_name = (
device.name if device and hasattr(device, "name") else "unknown"
)
logger.info(f"Device disconnected: {device_name} (fd={fd})")
if device is not None:
self._remove_keyboard_device(fd, device)
if not device_fds:
time.sleep(1.0)
continue
except (OSError, ValueError) as e:
if self.running:
logger.error(f"Error monitoring devices: {e}")
break
readable, _, _ = select.select(device_fds, [], [], 1.0) # 1 second timeout
for fd in readable:
try:
# Find the device for this fd
with self._devices_lock:
device = None
for d in self.devices:
if d.fileno() == fd:
device = d
break
if device is None:
continue
# Read events from this device. On grabbed devices,
# everything we do not consume is re-emitted on the
# paired uinput clone so the focused app keeps typing.
for event in device.read():
if event.type == ecodes.EV_SYN:
if event.code == ecodes.SYN_DROPPED:
# Kernel buffer overflowed — discard until SYN_REPORT
self._dropped_devices.add(fd)
logger.warning(
f"SYN_DROPPED on {device.name} (fd={fd}), "
"resetting key state"
)
elif event.code == ecodes.SYN_REPORT:
if fd in self._dropped_devices:
# End of dropped sequence — clear stale state
self._dropped_devices.discard(fd)
self.key_pressed_devices.discard(id(device))
# Release keys the clone still thinks
# are held before the SYN_REPORT
# reaches it, atomically.
self._resync_clone_key_state(fd, device)
# A dropped modifier release must not leave
# the combo logically held.
self._reset_combo_state()
self._forward_event(fd, event)
continue
if fd in self._dropped_devices:
# Handling shortcut state mid-drop is unsafe,
# but surviving non-shortcut events still pass
# through so the app sees what the kernel kept.
if not self._event_is_shortcut(fd, event):
self._forward_event(fd, event)
continue
if event.type == ecodes.EV_KEY:
# Every key event updates shortcut state (combo
# modifiers must be tracked AND forwarded); only
# the forwarding decision differs.
consumed = self._event_is_shortcut(fd, event)
self._handle_key_event(event, device)
if not consumed:
self._forward_event(fd, event)
else:
self._forward_event(fd, event)
except (OSError, IOError) as e:
# Device was disconnected - remove it to avoid busy loop
device_name = (
device.name if device and hasattr(device, "name") else "unknown"
)
logger.info(f"Device disconnected: {device_name} (fd={fd})")
if device is not None:
self._remove_keyboard_device(fd, device)
continue
except (OSError, ValueError) as e:
if self.running:
logger.error(f"Error monitoring devices: {e}")
break
finally:
if self.running:
# Exiting while still "running" means the loop died
# unexpectedly — a failed select, an fd closed underneath
# it, or an error escaping the handler. Nothing will ever
# read the grabbed keyboards again, so close every device
# and clone: closing releases each grab and hands input
# delivery back to the kernel instead of leaving the
# user's keyboards dead.
logger.error(
"Keyboard monitor exited unexpectedly; closing devices "
"to release grabs so keyboards keep working"
)
self.running = False
self.active = False
self._close_all_devices()
logger.debug("Device monitor thread stopped")
def _handle_key_event(self, event, device) -> None:
def _handle_key_event(self, event: InputEvent, device: InputDevice) -> None:
"""Handle a key event from evdev."""
if getattr(self, "_spec", None) is not None and self._spec.is_combo:
self._handle_combo_key_event(event)
return
self._handle_modifier_key_event(event, device)
def _handle_combo_key_event(self, event) -> None:
def _handle_combo_key_event(self, event: InputEvent) -> None:
"""Handle a key event for a modifier+key combo (e.g. Alt+R).
Combo state is tracked globally across all keyboard devices, which is
@@ -748,7 +1114,7 @@ class EvdevKeyboardBackend(KeyboardBackend):
logger.debug(f"Combo {self._shortcut} released (evdev)")
threading.Thread(target=self.key_release_callback, daemon=True).start()
def _handle_modifier_key_event(self, event, device) -> None:
def _handle_modifier_key_event(self, event: InputEvent, device: InputDevice) -> None:
"""Handle a key event for a single-modifier gesture (double-tap / hold)."""
try:
code = event.code