fix(shortcuts): preserve Ctrl events for mouse-wheel zoom (#967)
* fix(shortcuts): allow shortcut key passthrough Fixes #950 * fix(shortcuts): prevent Ctrl release race during passthrough * test(shortcuts): add type hints to passthrough regression tests * test(shortcuts): cover passthrough edge cases and integration --------- Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
@@ -49,6 +49,8 @@ Configure under **Settings → Shortcuts**:
|
||||
|
||||
Existing configs keep their saved shortcut. Left/right modifier keys and custom modifier+key combos (for example `Alt+R`) are supported.
|
||||
|
||||
Enable **Pass Shortcut Keys to Applications** under **Settings → Dictation → Keyboard Shortcuts** to keep modifier actions such as Ctrl+mouse-wheel zoom working. Dictation still detects the configured shortcut, but the shortcut keys will also reach the foreground app, which may respond to them. This is off by default.
|
||||
|
||||
## Voice commands
|
||||
|
||||
Optional spoken commands for punctuation and editing (can be disabled in Settings).
|
||||
|
||||
@@ -154,6 +154,8 @@ DEFAULT_CONFIG = {
|
||||
# comes in over D-Bus instead (e.g. a KDE Plasma global shortcut running
|
||||
# `vocalinux --toggle`). Avoids /dev/input access and the input group.
|
||||
"disable_internal_hotkey": False,
|
||||
# Opt-in: forward shortcut key events while still detecting gestures.
|
||||
"pass_shortcut_to_apps": False,
|
||||
# Pure-modifier gestures: "ctrl+ctrl", "alt+alt", "shift+shift" (and
|
||||
# left_/right_ variants) — double-tap (toggle) or hold (push_to_talk).
|
||||
# Modifier+key combos are also supported, e.g. "alt+r", "ctrl+alt+r",
|
||||
|
||||
@@ -480,6 +480,11 @@ class KeyboardBackend(ABC):
|
||||
self._mode = mode
|
||||
self._spec = parse_shortcut_spec(shortcut)
|
||||
self._modifier_key = self._spec.primary_modifier
|
||||
self.pass_shortcut_to_apps = False
|
||||
|
||||
def set_pass_shortcut_to_apps(self, enabled: bool) -> None:
|
||||
"""Forward hotkey events to applications while detecting gestures."""
|
||||
self.pass_shortcut_to_apps = bool(enabled)
|
||||
|
||||
@property
|
||||
def spec(self) -> ShortcutSpec:
|
||||
|
||||
@@ -753,6 +753,7 @@ class EvdevDeviceHub:
|
||||
# additionally tells a monitor thread whose join() timed out that its
|
||||
# incarnation is over, even when running has gone True again.
|
||||
self._lifecycle_lock = threading.Lock()
|
||||
self._dispatch_lock = threading.RLock()
|
||||
self._generation = 0
|
||||
self._init_device_state()
|
||||
|
||||
@@ -953,6 +954,18 @@ class EvdevDeviceHub:
|
||||
event: InputEvent,
|
||||
engines: Sequence["EvdevKeyboardBackend"],
|
||||
generation: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Keep event handling atomic with pass-through changes."""
|
||||
with self._dispatch_lock:
|
||||
self._dispatch_event_locked(fd, device, event, engines, generation)
|
||||
|
||||
def _dispatch_event_locked(
|
||||
self,
|
||||
fd: int,
|
||||
device: InputDevice,
|
||||
event: InputEvent,
|
||||
engines: Sequence["EvdevKeyboardBackend"],
|
||||
generation: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Shortcut-check and forward one event (per-event body of dispatch)."""
|
||||
if event.type == ecodes.EV_SYN:
|
||||
@@ -1649,6 +1662,28 @@ class EvdevKeyboardBackend(KeyboardBackend):
|
||||
"""Get the evdev key codes for the configured modifier."""
|
||||
return MODIFIER_KEY_CODES.get(self._modifier_key, set())
|
||||
|
||||
def set_pass_shortcut_to_apps(self, enabled: bool) -> None:
|
||||
"""Apply pass-through, including any modifier already held.
|
||||
|
||||
Replay withheld presses and sync their input frame so mouse-wheel
|
||||
actions can see the modifier before the next keyboard event.
|
||||
"""
|
||||
with self._hub._dispatch_lock:
|
||||
enabled = bool(enabled)
|
||||
if enabled and not self.pass_shortcut_to_apps:
|
||||
for fd, withheld in list(self._withheld_modifier.items()):
|
||||
if not any(not entry[1] for entry in withheld.values()):
|
||||
continue
|
||||
self._replay_withheld_modifiers(fd)
|
||||
forwarder = self._forwarders.get(fd)
|
||||
if forwarder is not None:
|
||||
try:
|
||||
forwarder.syn()
|
||||
except (OSError, IOError) as exc:
|
||||
logger.error("Failed to sync replayed shortcut on fd %s: %s", fd, exc)
|
||||
self._hub._release_failed_forwarder(fd, forwarder)
|
||||
super().set_pass_shortcut_to_apps(enabled)
|
||||
|
||||
def set_shortcut(self, shortcut: str) -> None:
|
||||
"""Update the shortcut and recompute combo key targets."""
|
||||
super().set_shortcut(shortcut)
|
||||
@@ -1865,7 +1900,9 @@ class EvdevKeyboardBackend(KeyboardBackend):
|
||||
"""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:
|
||||
focused application never sees them. If pass_shortcut_to_apps is
|
||||
enabled, new hotkey events are forwarded without changing gesture
|
||||
detection:
|
||||
|
||||
- Pure-modifier gestures (``right_alt+right_alt``, ``ctrl+ctrl``):
|
||||
every event of the configured modifier side(s) is consumed — the
|
||||
@@ -1889,6 +1926,30 @@ class EvdevKeyboardBackend(KeyboardBackend):
|
||||
if spec is None:
|
||||
return False
|
||||
|
||||
if self.pass_shortcut_to_apps:
|
||||
# Gesture detection still runs through _handle_key_event.
|
||||
# Do not expose the release of a combo press swallowed
|
||||
# before this option was enabled.
|
||||
if spec.is_combo and event.code == self._combo_main_code:
|
||||
if fd in self._combo_swallowed:
|
||||
if event.value == 0:
|
||||
self._combo_swallowed.discard(fd)
|
||||
return True
|
||||
elif not spec.is_combo and event.value == 0:
|
||||
# A previously withheld press was replayed on opt-in.
|
||||
self._withheld_modifier.get(fd, {}).pop(event.code, None)
|
||||
return False
|
||||
|
||||
# Pass-through might have been disabled while a key was held.
|
||||
# A key-down the clone saw needs a matching key-up. Existing
|
||||
# AltGr replay state handles its own withheld entries.
|
||||
if (
|
||||
event.value == 0
|
||||
and event.code in self._forwarded_held.get(fd, set())
|
||||
and (spec.is_combo or event.code not in self._withheld_modifier.get(fd, {}))
|
||||
):
|
||||
return False
|
||||
|
||||
if not spec.is_combo:
|
||||
if event.code not in self._get_target_key_codes():
|
||||
# A press of any other key while a modifier press is withheld
|
||||
|
||||
@@ -131,6 +131,11 @@ class KeyboardShortcutManager:
|
||||
"""Get the current shortcut mode."""
|
||||
return self._mode
|
||||
|
||||
def set_pass_shortcut_to_apps(self, enabled: bool) -> None:
|
||||
"""Live-apply shortcut pass-through to the active backend."""
|
||||
if self.backend_instance is not None:
|
||||
self.backend_instance.set_pass_shortcut_to_apps(enabled)
|
||||
|
||||
@property
|
||||
def mode_display_name(self) -> str:
|
||||
"""Get the human-readable name for the current mode."""
|
||||
|
||||
@@ -2516,6 +2516,7 @@ class SettingsDialog(AboutSectionMixin):
|
||||
language_shortcuts_update_callback: Optional[Callable[[], None]] = None,
|
||||
history_update_callback: Optional[Callable[[], None]] = None,
|
||||
history_clear_callback: Optional[Callable[[], None]] = None,
|
||||
hotkey_passthrough_update_callback: Optional[Callable[[bool], None]] = None,
|
||||
dictation_pad_open_callback: Optional[Callable[[], None]] = None,
|
||||
) -> None:
|
||||
super().__init__(title="Vocalinux Settings", transient_for=parent, flags=0)
|
||||
@@ -2533,6 +2534,7 @@ class SettingsDialog(AboutSectionMixin):
|
||||
self.update_checks_callback = update_checks_callback
|
||||
self.overlay_enabled_callback = overlay_enabled_callback
|
||||
self.hotkey_listener_update_callback = hotkey_listener_update_callback
|
||||
self.hotkey_passthrough_update_callback = hotkey_passthrough_update_callback
|
||||
self.language_shortcuts_update_callback = language_shortcuts_update_callback
|
||||
self.history_update_callback = history_update_callback
|
||||
self.history_clear_callback = history_clear_callback
|
||||
@@ -5149,6 +5151,23 @@ class SettingsDialog(AboutSectionMixin):
|
||||
)
|
||||
group.add_row(self.shortcut_row)
|
||||
|
||||
# Optional non-suppressing shortcut detection (#950).
|
||||
self.pass_shortcut_to_apps_switch = Gtk.Switch()
|
||||
self.pass_shortcut_to_apps_switch.set_active(
|
||||
self.config_manager.get_bool("shortcuts", "pass_shortcut_to_apps", False)
|
||||
)
|
||||
self.pass_shortcut_to_apps_switch.set_tooltip_text(
|
||||
"Forward shortcut presses and releases to applications while "
|
||||
"still triggering dictation. Apps may also react to the shortcut."
|
||||
)
|
||||
self.pass_shortcut_to_apps_row = PreferenceRow(
|
||||
title="Pass Shortcut Keys to Applications",
|
||||
subtitle="Preserve Ctrl+wheel zoom and other modifier actions",
|
||||
widget=self.pass_shortcut_to_apps_switch,
|
||||
keywords=("passthrough", "suppress", "modifier", "mouse wheel"),
|
||||
)
|
||||
group.add_row(self.pass_shortcut_to_apps_row)
|
||||
|
||||
# Custom shortcut: modifier+key combos (e.g. Alt+R) for users who want
|
||||
# something the preset modifiers can't express (split keyboards, etc.).
|
||||
custom_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||
@@ -5220,6 +5239,9 @@ class SettingsDialog(AboutSectionMixin):
|
||||
# Connect signals
|
||||
self.shortcut_combo.connect("changed", self._on_shortcut_changed)
|
||||
self.shortcut_mode_combo.connect("changed", self._on_shortcut_mode_changed)
|
||||
self.pass_shortcut_to_apps_switch.connect(
|
||||
"state-set", self._on_pass_shortcut_to_apps_toggled
|
||||
)
|
||||
self.disable_internal_hotkey_switch.connect(
|
||||
"state-set", self._on_disable_internal_hotkey_toggled
|
||||
)
|
||||
@@ -5229,7 +5251,12 @@ class SettingsDialog(AboutSectionMixin):
|
||||
|
||||
def _update_internal_hotkey_sensitivity(self, disabled: bool) -> None:
|
||||
"""Grey out the built-in shortcut controls when external activation is on."""
|
||||
for row in (self.mode_row, self.shortcut_row, self.custom_shortcut_row):
|
||||
for row in (
|
||||
self.mode_row,
|
||||
self.shortcut_row,
|
||||
self.custom_shortcut_row,
|
||||
self.pass_shortcut_to_apps_row,
|
||||
):
|
||||
row.set_sensitive(not disabled)
|
||||
|
||||
if disabled:
|
||||
@@ -5243,6 +5270,17 @@ class SettingsDialog(AboutSectionMixin):
|
||||
self.config_manager.get_str("shortcuts", "mode", "toggle")
|
||||
)
|
||||
|
||||
def _on_pass_shortcut_to_apps_toggled(self, widget: Gtk.Switch, state: bool) -> bool:
|
||||
"""Persist and live-apply shortcut pass-through."""
|
||||
if self._initializing or self._applying_settings:
|
||||
return False
|
||||
enabled = bool(state)
|
||||
self.config_manager.set("shortcuts", "pass_shortcut_to_apps", enabled)
|
||||
self.config_manager.save_settings()
|
||||
if self.hotkey_passthrough_update_callback:
|
||||
self.hotkey_passthrough_update_callback(enabled)
|
||||
return False
|
||||
|
||||
def _on_disable_internal_hotkey_toggled(self, widget: Gtk.Switch, state: bool) -> bool:
|
||||
"""Handle toggle of the external-activation switch."""
|
||||
if self._initializing or self._applying_settings:
|
||||
|
||||
@@ -215,6 +215,9 @@ class TrayIndicator:
|
||||
|
||||
# Initialize keyboard shortcut manager with configured shortcut and mode
|
||||
self.shortcut_manager = KeyboardShortcutManager(shortcut=shortcut, mode=mode)
|
||||
self.shortcut_manager.set_pass_shortcut_to_apps(
|
||||
self.config_manager.get_bool("shortcuts", "pass_shortcut_to_apps", False)
|
||||
)
|
||||
# One listener per bound language shortcut (#805); rebuilt by
|
||||
# _setup_language_shortcuts alongside the main one.
|
||||
self._language_shortcut_managers: list[KeyboardShortcutManager] = []
|
||||
@@ -411,6 +414,12 @@ class TrayIndicator:
|
||||
|
||||
self._setup_language_shortcuts()
|
||||
|
||||
def update_shortcut_passthrough(self, enabled: bool) -> None:
|
||||
"""Live-update main and language shortcuts without stopping dictation."""
|
||||
self.shortcut_manager.set_pass_shortcut_to_apps(enabled)
|
||||
for manager in self._language_shortcut_managers:
|
||||
manager.set_pass_shortcut_to_apps(enabled)
|
||||
|
||||
def _setup_language_shortcuts(self) -> None:
|
||||
"""(Re)build a listener per configured language shortcut (#805).
|
||||
|
||||
@@ -458,6 +467,9 @@ class TrayIndicator:
|
||||
|
||||
try:
|
||||
manager = KeyboardShortcutManager(shortcut=shortcut, mode=mode)
|
||||
manager.set_pass_shortcut_to_apps(
|
||||
self.config_manager.get_bool("shortcuts", "pass_shortcut_to_apps", False)
|
||||
)
|
||||
except ValueError as exc:
|
||||
logger.warning(f"Skipping language shortcut {shortcut!r} for {language}: {exc}")
|
||||
continue
|
||||
@@ -1437,6 +1449,7 @@ class TrayIndicator:
|
||||
update_checks_callback=self._on_update_checks_toggled,
|
||||
overlay_enabled_callback=self.set_overlay_enabled,
|
||||
hotkey_listener_update_callback=self._setup_keyboard_shortcuts,
|
||||
hotkey_passthrough_update_callback=self.update_shortcut_passthrough,
|
||||
language_shortcuts_update_callback=self.refresh_language_shortcuts,
|
||||
history_update_callback=self._apply_history_settings,
|
||||
history_clear_callback=self.clear_transcription_history,
|
||||
|
||||
@@ -16,6 +16,7 @@ import errno
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from unittest.mock import MagicMock, Mock, call, mock_open, patch
|
||||
|
||||
import pytest
|
||||
@@ -24,6 +25,7 @@ from vocalinux.ui.keyboard_backends.evdev_backend import (
|
||||
DEVICE_RESCAN_SECONDS,
|
||||
EvdevDeviceHub,
|
||||
EvdevKeyboardBackend,
|
||||
InputEvent,
|
||||
_clone_device_name,
|
||||
_find_keyboard_devices_from_evdev,
|
||||
_is_virtual_keyboard,
|
||||
@@ -2137,6 +2139,235 @@ class TestEvdevGrabAndForwarding:
|
||||
assert backend._event_is_shortcut(10, self._key_event(30, 1)) is False # KEY_A
|
||||
assert backend._event_is_shortcut(10, self._syn_event()) is False
|
||||
|
||||
def test_passthrough_skips_replay_when_nothing_is_withheld(self) -> None:
|
||||
"""No synthetic presses or syncs are emitted without a pending modifier."""
|
||||
backend = EvdevKeyboardBackend(shortcut="ctrl+ctrl")
|
||||
fd = 10
|
||||
clone = MagicMock()
|
||||
backend._forwarders = {fd: clone}
|
||||
backend._forwarded_held = {fd: set()}
|
||||
backend._withheld_modifier = {fd: {}}
|
||||
|
||||
backend.set_pass_shortcut_to_apps(True)
|
||||
backend.set_pass_shortcut_to_apps(True)
|
||||
|
||||
assert backend.pass_shortcut_to_apps is True
|
||||
clone.write_event.assert_not_called()
|
||||
clone.syn.assert_not_called()
|
||||
|
||||
backend.set_pass_shortcut_to_apps(False)
|
||||
assert backend.pass_shortcut_to_apps is False
|
||||
|
||||
def test_passthrough_without_forwarder_handles_held_ctrl(self) -> None:
|
||||
"""An ungrabbed keyboard safely transitions while Ctrl is held."""
|
||||
backend = EvdevKeyboardBackend(shortcut="ctrl+ctrl", mode="toggle")
|
||||
fd = 10
|
||||
press = self._key_event(29, 1)
|
||||
release = self._key_event(29, 0)
|
||||
|
||||
assert backend._event_is_shortcut(fd, press) is True
|
||||
assert backend._withheld_modifier[fd][29][1] is False
|
||||
assert fd not in backend._forwarders
|
||||
|
||||
backend.set_pass_shortcut_to_apps(True)
|
||||
|
||||
assert backend.pass_shortcut_to_apps is True
|
||||
assert backend._withheld_modifier[fd][29][1] is True
|
||||
assert backend._event_is_shortcut(fd, release) is False
|
||||
assert backend._withheld_modifier[fd] == {}
|
||||
|
||||
def test_passthrough_sync_failure_releases_failed_forwarder(self) -> None:
|
||||
"""A failed uinput frame sync must release the broken virtual keyboard."""
|
||||
backend = EvdevKeyboardBackend(shortcut="ctrl+ctrl")
|
||||
fd = 10
|
||||
clone = MagicMock()
|
||||
clone.syn.side_effect = OSError(errno.EIO, "uinput unavailable")
|
||||
backend._forwarders = {fd: clone}
|
||||
backend._forwarded_held = {fd: set()}
|
||||
|
||||
press = self._key_event(29, 1)
|
||||
assert backend._event_is_shortcut(fd, press) is True
|
||||
|
||||
with patch.object(backend._hub, "_release_failed_forwarder") as release_failed:
|
||||
backend.set_pass_shortcut_to_apps(True)
|
||||
release_failed.assert_called_once_with(fd, clone)
|
||||
|
||||
clone.write_event.assert_called_once_with(press)
|
||||
clone.syn.assert_called_once_with()
|
||||
assert backend.pass_shortcut_to_apps is True
|
||||
|
||||
def test_passthrough_combo_keeps_swallowed_press_and_release_paired(self) -> None:
|
||||
"""Enabling pass-through must not leak a release without its press."""
|
||||
backend = EvdevKeyboardBackend(shortcut="ctrl+r", mode="toggle")
|
||||
fd = 10
|
||||
backend._combo_pressed = {29}
|
||||
|
||||
key_r = backend._combo_main_code
|
||||
assert key_r is not None
|
||||
|
||||
assert backend._event_is_shortcut(fd, self._key_event(key_r, 1)) is True
|
||||
|
||||
backend.set_pass_shortcut_to_apps(True)
|
||||
|
||||
assert backend._event_is_shortcut(fd, self._key_event(key_r, 2)) is True
|
||||
assert backend._event_is_shortcut(fd, self._key_event(key_r, 0)) is True
|
||||
assert fd not in backend._combo_swallowed
|
||||
|
||||
assert backend._event_is_shortcut(fd, self._key_event(key_r, 1)) is False
|
||||
assert backend._event_is_shortcut(fd, self._key_event(key_r, 0)) is False
|
||||
|
||||
def test_disabling_combo_passthrough_preserves_forwarded_key_up(self) -> None:
|
||||
"""Switching off pass-through cannot strand a previously forwarded key."""
|
||||
backend = EvdevKeyboardBackend(shortcut="ctrl+r", mode="toggle")
|
||||
fd = 10
|
||||
backend._forwarded_held = {fd: {19}}
|
||||
|
||||
backend.set_pass_shortcut_to_apps(True)
|
||||
backend.set_pass_shortcut_to_apps(False)
|
||||
|
||||
assert backend._event_is_shortcut(fd, self._key_event(19, 0)) is False
|
||||
|
||||
def test_pass_through_double_tap_still_fires_and_forwards_ctrl(self) -> None:
|
||||
"""Opted-in Ctrl taps must reach the app without losing detection (#950)."""
|
||||
backend = EvdevKeyboardBackend(shortcut="ctrl+ctrl", mode="toggle")
|
||||
backend.set_pass_shortcut_to_apps(True)
|
||||
fired = threading.Event()
|
||||
backend.register_toggle_callback(fired.set)
|
||||
fd = 10
|
||||
device = MagicMock()
|
||||
forwarder = MagicMock()
|
||||
backend._forwarders = {fd: forwarder}
|
||||
backend._forwarded_held = {fd: set()}
|
||||
events = [
|
||||
self._key_event(29, 1),
|
||||
self._key_event(29, 0),
|
||||
self._key_event(29, 1),
|
||||
self._key_event(29, 0),
|
||||
]
|
||||
with patch(
|
||||
"vocalinux.ui.keyboard_backends.evdev_backend.time.time",
|
||||
side_effect=[100.0, 100.15],
|
||||
):
|
||||
for event in events:
|
||||
backend._hub._dispatch_event(fd, device, event, (backend,))
|
||||
assert fired.wait(1.0)
|
||||
assert [c.args[0] for c in forwarder.write_event.call_args_list] == events
|
||||
assert backend._forwarded_held[fd] == set()
|
||||
|
||||
def test_pass_through_preserves_ctrl_state_for_mouse_wheel(self) -> None:
|
||||
"""Ctrl reaches the virtual keyboard without waiting for a chord."""
|
||||
backend = EvdevKeyboardBackend(shortcut="ctrl+ctrl", mode="toggle")
|
||||
backend.set_pass_shortcut_to_apps(True)
|
||||
fd = 10
|
||||
device = MagicMock()
|
||||
forwarder = MagicMock()
|
||||
backend._forwarders = {fd: forwarder}
|
||||
backend._forwarded_held = {fd: set()}
|
||||
press = self._key_event(29, 1)
|
||||
backend._hub._dispatch_event(fd, device, press, (backend,))
|
||||
assert backend._forwarded_held[fd] == {29}
|
||||
forwarder.write_event.assert_called_once_with(press)
|
||||
# Mouse wheel can arrive on a completely separate evdev device.
|
||||
release = self._key_event(29, 0)
|
||||
backend._hub._dispatch_event(fd, device, release, (backend,))
|
||||
assert backend._forwarded_held[fd] == set()
|
||||
forwarder.write_event.assert_called_with(release)
|
||||
|
||||
def test_enabling_pass_through_replays_held_modifier_and_syncs(self) -> None:
|
||||
backend = EvdevKeyboardBackend(shortcut="ctrl+ctrl", mode="toggle")
|
||||
fd = 10
|
||||
forwarder = MagicMock()
|
||||
backend._forwarders = {fd: forwarder}
|
||||
backend._forwarded_held = {fd: set()}
|
||||
press = self._key_event(29, 1)
|
||||
assert backend._event_is_shortcut(fd, press) is True
|
||||
backend.set_pass_shortcut_to_apps(True)
|
||||
forwarder.write_event.assert_called_once_with(press)
|
||||
forwarder.syn.assert_called_once_with()
|
||||
assert backend._event_is_shortcut(fd, self._key_event(29, 0)) is False
|
||||
assert backend._withheld_modifier[fd] == {}
|
||||
|
||||
def test_concurrent_pass_through_enable_does_not_stick_ctrl(self) -> None:
|
||||
"""A release cannot overtake replayed Ctrl-down on the virtual keyboard."""
|
||||
backend = EvdevKeyboardBackend(shortcut="ctrl+ctrl", mode="toggle")
|
||||
fd = 10
|
||||
press, release = self._key_event(29, 1), self._key_event(29, 0)
|
||||
device, forwarder = MagicMock(), MagicMock()
|
||||
backend._forwarders = {fd: forwarder}
|
||||
backend._forwarded_held = {fd: set()}
|
||||
backend._hub._dispatch_event(fd, device, press, (backend,))
|
||||
|
||||
replay_started, allow_replay = threading.Event(), threading.Event()
|
||||
release_started, release_done = threading.Event(), threading.Event()
|
||||
written, errors = [], []
|
||||
|
||||
def write_event(event: InputEvent) -> None:
|
||||
if event is press:
|
||||
replay_started.set()
|
||||
assert allow_replay.wait(3), "Replay timed out"
|
||||
written.append(event)
|
||||
|
||||
def run(callback: Callable[[], None]) -> None:
|
||||
try:
|
||||
callback()
|
||||
except BaseException as exc:
|
||||
errors.append(exc)
|
||||
|
||||
def release_ctrl() -> None:
|
||||
release_started.set()
|
||||
try:
|
||||
backend._hub._dispatch_event(fd, device, release, (backend,))
|
||||
finally:
|
||||
release_done.set()
|
||||
|
||||
forwarder.write_event.side_effect = write_event
|
||||
|
||||
enabler = threading.Thread(
|
||||
target=run,
|
||||
args=(lambda: backend.set_pass_shortcut_to_apps(True),),
|
||||
)
|
||||
releaser = threading.Thread(target=run, args=(release_ctrl,))
|
||||
|
||||
try:
|
||||
enabler.start()
|
||||
assert replay_started.wait(2), "Ctrl-down was not replayed"
|
||||
|
||||
releaser.start()
|
||||
assert release_started.wait(2)
|
||||
|
||||
assert not release_done.wait(0.1), "Release slipped through during replay"
|
||||
finally:
|
||||
allow_replay.set()
|
||||
if enabler.ident is not None:
|
||||
enabler.join(timeout=3)
|
||||
if releaser.ident is not None:
|
||||
releaser.join(timeout=3)
|
||||
|
||||
assert not errors, errors
|
||||
assert not enabler.is_alive() and not releaser.is_alive()
|
||||
assert written == [press, release]
|
||||
assert backend.pass_shortcut_to_apps is True
|
||||
assert backend._withheld_modifier[fd] == {}
|
||||
assert backend._forwarded_held[fd] == set()
|
||||
forwarder.syn.assert_called_once_with()
|
||||
|
||||
def test_disabling_pass_through_keeps_existing_key_release(self) -> None:
|
||||
backend = EvdevKeyboardBackend(shortcut="ctrl+ctrl", mode="toggle")
|
||||
fd = 10
|
||||
backend._forwarders = {fd: MagicMock()}
|
||||
backend._forwarded_held = {fd: set()}
|
||||
backend.set_pass_shortcut_to_apps(True)
|
||||
backend._hub._forward_event(fd, self._key_event(29, 1))
|
||||
backend.set_pass_shortcut_to_apps(False)
|
||||
assert backend._event_is_shortcut(fd, self._key_event(29, 0)) is False
|
||||
|
||||
def test_pass_through_main_key_in_combo(self) -> None:
|
||||
backend = EvdevKeyboardBackend(shortcut="ctrl+r", mode="toggle")
|
||||
backend.set_pass_shortcut_to_apps(True)
|
||||
assert backend._event_is_shortcut(10, self._key_event(29, 1)) is False
|
||||
assert backend._event_is_shortcut(10, self._key_event(19, 1)) is False
|
||||
assert backend._event_is_shortcut(10, self._key_event(19, 0)) is False
|
||||
|
||||
def test_event_is_shortcut_bare_function_key(self) -> None:
|
||||
"""A bare function-key shortcut consumes only that key."""
|
||||
backend = EvdevKeyboardBackend(shortcut="f11") # KEY_F11=87
|
||||
|
||||
@@ -46,6 +46,24 @@ class TestKeyboardShortcuts(unittest.TestCase):
|
||||
self.mock_create_backend.assert_called_once()
|
||||
self.assertIsNotNone(self.ksm.backend_instance)
|
||||
|
||||
def test_passthrough_is_forwarded_to_backend(self) -> None:
|
||||
"""The manager sends both enable and disable to the live backend."""
|
||||
self.ksm.set_pass_shortcut_to_apps(True)
|
||||
self.ksm.set_pass_shortcut_to_apps(False)
|
||||
|
||||
self.assertEqual(
|
||||
self.mock_backend.set_pass_shortcut_to_apps.call_args_list,
|
||||
[call(True), call(False)],
|
||||
)
|
||||
|
||||
def test_passthrough_without_backend_is_safe(self) -> None:
|
||||
"""A missing evdev/pynput backend cannot crash settings changes."""
|
||||
self.ksm.backend_instance = None
|
||||
|
||||
self.ksm.set_pass_shortcut_to_apps(True)
|
||||
|
||||
self.mock_backend.set_pass_shortcut_to_apps.assert_not_called()
|
||||
|
||||
def test_init_with_custom_shortcut(self):
|
||||
"""Test initialization with a custom shortcut."""
|
||||
# Create a new KSM with alt+alt shortcut
|
||||
|
||||
@@ -4,8 +4,12 @@ Tests for keyboard shortcuts section in settings dialog.
|
||||
Uses source code inspection to avoid GTK mocking issues.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import os
|
||||
import unittest
|
||||
from collections.abc import Callable
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
|
||||
def _get_source_code() -> str:
|
||||
@@ -37,6 +41,65 @@ class TestSettingsDialogShortcutsSection(unittest.TestCase):
|
||||
"""Test that shortcuts section is built in __init__."""
|
||||
self.assertIn("self._build_shortcuts_section()", self.source_code)
|
||||
|
||||
def _pass_through_handler(self) -> Callable[..., bool]:
|
||||
"""Compile the real method without importing the whole GTK UI."""
|
||||
cls = next(
|
||||
x
|
||||
for x in ast.parse(self.source_code).body
|
||||
if isinstance(x, ast.ClassDef) and x.name == "SettingsDialog"
|
||||
)
|
||||
method = next(
|
||||
x
|
||||
for x in cls.body
|
||||
if isinstance(x, ast.FunctionDef) and x.name == "_on_pass_shortcut_to_apps_toggled"
|
||||
)
|
||||
namespace = {"Gtk": SimpleNamespace(Switch=object)}
|
||||
exec(
|
||||
compile(ast.Module(body=[method], type_ignores=[]), "settings.py", "exec"),
|
||||
namespace,
|
||||
)
|
||||
return namespace[method.name]
|
||||
|
||||
def test_pass_through_signal_is_connected(self) -> None:
|
||||
self.assertIn(
|
||||
"self.pass_shortcut_to_apps_switch.connect(\n"
|
||||
' "state-set", self._on_pass_shortcut_to_apps_toggled\n'
|
||||
" )",
|
||||
self.source_code,
|
||||
)
|
||||
|
||||
def test_pass_through_handler_persists_and_calls_live_callback(self) -> None:
|
||||
handler = self._pass_through_handler()
|
||||
for enabled in (True, False):
|
||||
with self.subTest(enabled=enabled):
|
||||
config, callback = Mock(), Mock()
|
||||
dialog = SimpleNamespace(
|
||||
_initializing=False,
|
||||
_applying_settings=False,
|
||||
config_manager=config,
|
||||
hotkey_passthrough_update_callback=callback,
|
||||
)
|
||||
self.assertIs(handler(dialog, None, enabled), False)
|
||||
config.set.assert_called_once_with("shortcuts", "pass_shortcut_to_apps", enabled)
|
||||
config.save_settings.assert_called_once_with()
|
||||
callback.assert_called_once_with(enabled)
|
||||
|
||||
def test_pass_through_handler_respects_init_and_apply_guards(self) -> None:
|
||||
handler = self._pass_through_handler()
|
||||
for initializing, applying in ((True, False), (False, True)):
|
||||
with self.subTest(initializing=initializing, applying=applying):
|
||||
config, callback = Mock(), Mock()
|
||||
dialog = SimpleNamespace(
|
||||
_initializing=initializing,
|
||||
_applying_settings=applying,
|
||||
config_manager=config,
|
||||
hotkey_passthrough_update_callback=callback,
|
||||
)
|
||||
self.assertIs(handler(dialog, None, True), False)
|
||||
config.set.assert_not_called()
|
||||
config.save_settings.assert_not_called()
|
||||
callback.assert_not_called()
|
||||
|
||||
def test_shortcut_combo_widget_created(self):
|
||||
"""Test that shortcut combo box widget is created."""
|
||||
self.assertIn("self.shortcut_combo = Gtk.ComboBoxText()", self.source_code)
|
||||
@@ -306,7 +369,14 @@ class TestExternalActivationToggle(unittest.TestCase):
|
||||
self.source_code,
|
||||
)
|
||||
self.assertIn(
|
||||
"for row in (self.mode_row, self.shortcut_row, self.custom_shortcut_row):",
|
||||
(
|
||||
"for row in (\n"
|
||||
" self.mode_row,\n"
|
||||
" self.shortcut_row,\n"
|
||||
" self.custom_shortcut_row,\n"
|
||||
" self.pass_shortcut_to_apps_row,\n"
|
||||
" ):"
|
||||
),
|
||||
self.source_code,
|
||||
)
|
||||
self.assertIn("row.set_sensitive(not disabled)", self.source_code)
|
||||
|
||||
@@ -11,7 +11,7 @@ import threading
|
||||
import unittest
|
||||
from threading import Thread
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
from unittest.mock import MagicMock, PropertyMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -172,6 +172,61 @@ class TestTrayIndicator(unittest.TestCase):
|
||||
if hasattr(self, "tray_indicator") and hasattr(self.tray_indicator, "shortcut_manager"):
|
||||
self.tray_indicator.shortcut_manager.stop()
|
||||
|
||||
def test_passthrough_applied_to_initial_main_shortcut(self) -> None:
|
||||
"""Startup reads the saved opt-in state for the primary listener."""
|
||||
self.mock_ksm.set_pass_shortcut_to_apps.assert_called_once_with(False)
|
||||
self.mock_config_manager.get_bool.assert_any_call(
|
||||
"shortcuts", "pass_shortcut_to_apps", False
|
||||
)
|
||||
|
||||
def test_passthrough_live_updates_main_and_language_listeners(self) -> None:
|
||||
"""Changing the switch affects all listeners without restarting them."""
|
||||
first, second = MagicMock(), MagicMock()
|
||||
self.tray_indicator._language_shortcut_managers = [first, second]
|
||||
|
||||
self.mock_ksm.set_pass_shortcut_to_apps.reset_mock()
|
||||
self.mock_ksm.stop.reset_mock()
|
||||
|
||||
self.tray_indicator.update_shortcut_passthrough(True)
|
||||
self.tray_indicator.update_shortcut_passthrough(False)
|
||||
|
||||
for manager in (self.mock_ksm, first, second):
|
||||
self.assertEqual(
|
||||
manager.set_pass_shortcut_to_apps.call_args_list,
|
||||
[call(True), call(False)],
|
||||
)
|
||||
manager.stop.assert_not_called()
|
||||
|
||||
def test_language_shortcut_inherits_saved_passthrough(self) -> None:
|
||||
"""New per-language shortcuts use the same persisted preference."""
|
||||
language_manager = MagicMock()
|
||||
self.mock_config_manager.get_language_shortcuts.return_value = [
|
||||
{"shortcut": "right_ctrl+right_ctrl", "language": "en-us"}
|
||||
]
|
||||
|
||||
self.mock_config_manager.get_bool.side_effect = lambda section, key, default=False: (
|
||||
True if (section, key) == ("shortcuts", "pass_shortcut_to_apps") else default
|
||||
)
|
||||
|
||||
with patch(
|
||||
"vocalinux.ui.tray_indicator.KeyboardShortcutManager",
|
||||
return_value=language_manager,
|
||||
):
|
||||
self.tray_indicator._setup_language_shortcuts()
|
||||
|
||||
language_manager.set_pass_shortcut_to_apps.assert_called_once_with(True)
|
||||
language_manager.start.assert_called_once_with()
|
||||
self.assertIn(language_manager, self.tray_indicator._language_shortcut_managers)
|
||||
|
||||
def test_settings_dialog_receives_passthrough_callback(self) -> None:
|
||||
"""The UI must receive the live-apply callback when settings opens."""
|
||||
self.tray_indicator._on_settings_clicked(None)
|
||||
|
||||
callback = self.mock_settings_dialog_class.call_args.kwargs[
|
||||
"hotkey_passthrough_update_callback"
|
||||
]
|
||||
self.assertEqual(callback, self.tray_indicator.update_shortcut_passthrough)
|
||||
|
||||
def test_initialization(self):
|
||||
"""Test initialization of the tray indicator."""
|
||||
self.assertEqual(self.tray_indicator.speech_engine, self.mock_speech_engine)
|
||||
|
||||
Reference in New Issue
Block a user