Files
voculinux/tests/test_settings_mode_change.py
T
b3605bf3c6 feat(shortcuts): configurable modifier+key hotkeys (e.g. Alt+R) (#493)
* feat(shortcuts): configurable modifier+key hotkeys (e.g. Alt+R)

Generalize the shortcut system beyond single-modifier double-tap/hold to
support modifier+key combos (alt+r, ctrl+alt+r, super+space) in both toggle
(press once) and push-to-talk (hold) modes.

- base.py: ShortcutSpec + parse_shortcut_spec/is_valid_shortcut/format label;
  parse_shortcut kept for back-compat (bare modifiers still rejected).
- evdev + pynput backends: combo detection with global (cross-device) modifier
  tracking so split keyboards work; legacy gestures unchanged.
- keyboard_shortcuts: validate via is_valid_shortcut instead of a fixed allowlist.
- settings_dialog: additive Custom Shortcut row (typed entry + live Record capture).
- tests: 34 new tests (parser, back-compat, evdev + pynput combo, split keyboard).

* fix(shortcuts): apply saved shortcut (not stale preset) on mode change

When a custom shortcut (e.g. alt+r) is active, the preset combo still points at
a default/previous preset. _on_shortcut_mode_changed read the combo, so toggling
Toggle<->Push-to-Talk restarted the live listener on the wrong shortcut while
config still said alt+r. Read the saved shortcut from config instead.

Addresses Codex P2 review on PR #493.

* style: apply black formatting to configurable-hotkeys files

* test: cover _gdk_keyname_to_token shortcut-capture mapping

* fix(shortcuts): reset combo state after dropped/lost evdev events

SYN_DROPPED and device-disconnect recovery cleared key_pressed_devices but not
the combo state. A dropped modifier *release* left _combo_pressed thinking the
modifier was held, so the main key alone could falsely toggle/start dictation,
and a push-to-talk hold could stay stuck. Add _reset_combo_state() (clears the
pressed set and ends any live hold) and call it from both recovery paths.
Addresses maintainer review on PR #493.

* fix(settings): combo-aware toggle label (single press, not double-tap)

_update_shortcut_ui_for_mode always said 'Double-tap the key' for toggle mode,
but a modifier+key combo (e.g. alt+r) toggles on a single press. Read the saved
shortcut and use get_shortcut_display_name so a combo shows 'Press Alt+R'.
Addresses maintainer review on PR #493.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-07-13 14:43:36 -07:00

58 lines
2.3 KiB
Python

"""Regression test: switching shortcut mode must use the saved shortcut.
A custom shortcut (e.g. "alt+r") is stored in config but leaves the preset
combo pointing at a default/previous preset. When the user toggles between
Toggle and Push-to-Talk, the live listener must be restarted on the *saved*
shortcut, not the stale preset shown in the combo. (Codex P2 on PR #493.)
The settings dialog can't be instantiated under the mocked-GTK test harness, so
(like test_settings_shortcuts.py) this asserts against the source of the
_on_shortcut_mode_changed method.
"""
import inspect
import re
from vocalinux.ui import settings_dialog
def _method_source(name: str) -> str:
src = inspect.getsource(settings_dialog)
# Grab from `def <name>(` up to the next method definition.
match = re.search(rf"\n def {name}\(.*?(?=\n def )", src, re.DOTALL)
assert match, f"could not locate method {name}"
return match.group(0)
def test_mode_change_reads_saved_shortcut_from_config():
body = _method_source("_on_shortcut_mode_changed")
assert "self.config_manager.get_str(" in body
assert '"toggle_recognition"' in body
def test_mode_change_does_not_use_stale_preset_combo():
body = _method_source("_on_shortcut_mode_changed")
# The stale preset combo must not drive the live mode-change apply.
assert "self.shortcut_combo.get_active_id()" not in body
def test_mode_ui_toggle_is_combo_aware():
# A combo (alt+r) toggles on a single press, so the toggle label must branch
# on whether the shortcut is a combo rather than always saying "Double-tap".
body = _method_source("_update_shortcut_ui_for_mode")
assert "is_combo" in body
assert "get_shortcut_display_name(" in body
assert "self.config_manager.get_str(" in body
def test_mode_ui_double_tap_text_is_guarded_for_non_combo():
body = _method_source("_update_shortcut_ui_for_mode")
# The "Double-tap" wording must sit inside the non-combo branch, not fire
# unconditionally for every toggle shortcut.
idx_if = body.find("if is_combo:")
# The user-facing "Double-tap this key" subtitle (not the example in a
# comment) must sit inside the non-combo branch.
idx_double_tap = body.find("Double-tap this key")
assert idx_if != -1
assert idx_double_tap > idx_if