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:
loject
2026-10-26 20:24:14 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent 90adde05a4
commit 2fce81e94d
11 changed files with 504 additions and 4 deletions
+2
View File
@@ -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).
+2
View File
@@ -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
+5
View File
@@ -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."""
+39 -1
View File
@@ -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:
+13
View File
@@ -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,
+231
View File
@@ -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
+18
View File
@@ -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
+71 -1
View File
@@ -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)
+56 -1
View File
@@ -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)