diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index aff782f..276db71 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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). diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index 7cfc9c9..d9f5423 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -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", diff --git a/src/vocalinux/ui/keyboard_backends/base.py b/src/vocalinux/ui/keyboard_backends/base.py index 438da65..9a194dd 100644 --- a/src/vocalinux/ui/keyboard_backends/base.py +++ b/src/vocalinux/ui/keyboard_backends/base.py @@ -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: diff --git a/src/vocalinux/ui/keyboard_backends/evdev_backend.py b/src/vocalinux/ui/keyboard_backends/evdev_backend.py index d859541..a45a7b5 100644 --- a/src/vocalinux/ui/keyboard_backends/evdev_backend.py +++ b/src/vocalinux/ui/keyboard_backends/evdev_backend.py @@ -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 diff --git a/src/vocalinux/ui/keyboard_shortcuts.py b/src/vocalinux/ui/keyboard_shortcuts.py index 4e04141..785b1da 100644 --- a/src/vocalinux/ui/keyboard_shortcuts.py +++ b/src/vocalinux/ui/keyboard_shortcuts.py @@ -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.""" diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index f097659..2cba7a1 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -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: diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index ab12a75..cd7d3fc 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -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, diff --git a/tests/test_evdev_backend_ext.py b/tests/test_evdev_backend_ext.py index 362cc9b..42e26d1 100644 --- a/tests/test_evdev_backend_ext.py +++ b/tests/test_evdev_backend_ext.py @@ -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 diff --git a/tests/test_keyboard_shortcuts.py b/tests/test_keyboard_shortcuts.py index f7e7663..5927603 100644 --- a/tests/test_keyboard_shortcuts.py +++ b/tests/test_keyboard_shortcuts.py @@ -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 diff --git a/tests/test_settings_shortcuts.py b/tests/test_settings_shortcuts.py index c912f75..da31857 100644 --- a/tests/test_settings_shortcuts.py +++ b/tests/test_settings_shortcuts.py @@ -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) diff --git a/tests/test_tray_indicator.py b/tests/test_tray_indicator.py index 02cdd7a..35c4e1f 100644 --- a/tests/test_tray_indicator.py +++ b/tests/test_tray_indicator.py @@ -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)