diff --git a/src/vocalinux/ui/keyboard_backends/base.py b/src/vocalinux/ui/keyboard_backends/base.py index c22cb52..e6b0028 100644 --- a/src/vocalinux/ui/keyboard_backends/base.py +++ b/src/vocalinux/ui/keyboard_backends/base.py @@ -228,6 +228,7 @@ class ShortcutSpec: from "ctrl+ctrl". The double-tap / hold gesture applies to that modifier. - Combo: ``modifiers=("alt",), key="r"`` parsed from "alt+r"; may carry several modifiers, e.g. ``modifiers=("ctrl", "alt"), key="r"``. + - Bare function key: ``modifiers=(), key="f10"`` parsed from "f10". """ modifiers: Tuple[str, ...] @@ -235,13 +236,15 @@ class ShortcutSpec: @property def is_combo(self) -> bool: - """True if this shortcut is a modifier+key combo (not a bare modifier).""" + """True if this shortcut has a main key (combo or bare function key).""" return self.key is not None @property def primary_modifier(self) -> str: - """The first modifier, used for backward-compatible single-modifier APIs.""" - return self.modifiers[0] if self.modifiers else "" + """First modifier, or the bare main key when there are no modifiers.""" + if self.modifiers: + return self.modifiers[0] + return self.key or "" def canonical(self) -> str: """Canonical shortcut string (round-trips through parse_shortcut_spec).""" @@ -253,8 +256,8 @@ class ShortcutSpec: def parse_shortcut_spec(shortcut_string: str) -> ShortcutSpec: """Parse a shortcut string into a :class:`ShortcutSpec`. - Accepts legacy pure-modifier forms ("ctrl+ctrl", "left_shift+left_shift") - and modifier+key combos ("alt+r", "ctrl+alt+r"). + Accepts legacy pure-modifier forms ("ctrl+ctrl", "left_shift+left_shift"), + modifier+key combos ("alt+r", "ctrl+alt+r"), and bare function keys ("f10"). Raises: ValueError: if the string is empty, malformed, contains an unknown key, @@ -283,6 +286,8 @@ def parse_shortcut_spec(shortcut_string: str) -> ShortcutSpec: raise ValueError(f"Unknown key in shortcut: {token!r}") if not modifiers: + if main_key is not None and _FUNCTION_KEY_RE.fullmatch(main_key): + return ShortcutSpec(modifiers=(), key=main_key) raise ValueError(f"Shortcut needs at least one modifier: {shortcut_string}") # Deduplicate modifiers while preserving order ("ctrl+ctrl" -> ("ctrl",)). @@ -365,9 +370,10 @@ def parse_shortcut(shortcut_string: str) -> str: """ Parse a shortcut string and return its primary modifier key name. - Retained for backward compatibility. Accepts both legacy pure-modifier - shortcuts ("ctrl+ctrl") and modifier+key combos ("alt+r"); for a combo the - first (primary) modifier is returned. + Retained for backward compatibility. Accepts legacy pure-modifier shortcuts + ("ctrl+ctrl"), modifier+key combos ("alt+r"), and bare function keys ("f10"). + For combos the first (primary) modifier is returned; for bare function keys + the key token is returned. Args: shortcut_string: The shortcut string (e.g., "ctrl+ctrl", "alt+r") diff --git a/src/vocalinux/ui/keyboard_backends/evdev_backend.py b/src/vocalinux/ui/keyboard_backends/evdev_backend.py index 435e48c..62c195c 100644 --- a/src/vocalinux/ui/keyboard_backends/evdev_backend.py +++ b/src/vocalinux/ui/keyboard_backends/evdev_backend.py @@ -26,7 +26,13 @@ except ImportError: ecodes = None # type: ignore EVDEV_AVAILABLE = False -from .base import DEFAULT_SHORTCUT, DEFAULT_SHORTCUT_MODE, KeyboardBackend, parse_shortcut +from .base import ( + DEFAULT_SHORTCUT, + DEFAULT_SHORTCUT_MODE, + KeyboardBackend, + ShortcutSpec, + parse_shortcut, +) from .layout_key_map import get_active_char_to_evdev_map logger = logging.getLogger(__name__) @@ -217,6 +223,21 @@ def _find_keyboard_devices_from_evdev() -> list[str]: return keyboard_devices +def _device_has_any_code(device_path: str, codes: set[int]) -> bool: + """Return True if the device reports any of ``codes`` in its EV_KEY caps.""" + if not EVDEV_AVAILABLE or not codes: + return False + + try: + device = InputDevice(device_path) + capabilities = device.capabilities() + device.close() + key_caps = capabilities.get(ecodes.EV_KEY, ()) + return any(code in key_caps for code in codes) + except (OSError, IOError): + return False + + def device_has_modifier_key(device_path: str, modifier: str = "ctrl") -> bool: """ Check if a device has a specific modifier key capability. @@ -228,29 +249,31 @@ def device_has_modifier_key(device_path: str, modifier: str = "ctrl") -> bool: Returns: True if the device can send the specified modifier key events """ - if not EVDEV_AVAILABLE: + return _device_has_any_code(device_path, MODIFIER_KEY_CODES.get(modifier, set())) + + +def device_has_key(device_path: str, key_token: str) -> bool: + """ + Check if a device can emit events for a canonical main-key token. + + Args: + device_path: Path to the input device + key_token: Canonical main-key token (e.g. "f10", "r", "space") + + Returns: + True if the device reports the key in its EV_KEY capabilities + """ + key_code = evdev_code_for_key(key_token) + if key_code is None: return False + return _device_has_any_code(device_path, {key_code}) - key_codes = MODIFIER_KEY_CODES.get(modifier, set()) - if not key_codes: - return False - try: - device = InputDevice(device_path) - capabilities = device.capabilities() - device.close() - - # Check if device has EV_KEY capability and supports the modifier keys - if ecodes.EV_KEY in capabilities: - key_caps = capabilities[ecodes.EV_KEY] - # Check for left or right variant of the modifier - for key_code in key_codes: - if key_code in key_caps: - return True - except (OSError, IOError): - pass - - return False +def device_supports_shortcut(device_path: str, spec: ShortcutSpec) -> bool: + """Return True if the device can emit the configured shortcut.""" + if spec.modifiers: + return device_has_modifier_key(device_path, spec.modifiers[0]) + return spec.key is not None and device_has_key(device_path, spec.key) class EvdevKeyboardBackend(KeyboardBackend): @@ -358,22 +381,19 @@ class EvdevKeyboardBackend(KeyboardBackend): self._combo_released() def is_available(self) -> bool: - """Check if evdev is available and we can access a keyboard device with the modifier key.""" + """Check if evdev can access a keyboard device that supports this shortcut.""" if not EVDEV_AVAILABLE: return False - # Check if we can access at least one keyboard device with the modifier key capability try: devices = find_keyboard_devices() if not devices: return False - # Try to find at least one device with the modifier key that we can open for device_path in devices: - if device_has_modifier_key(device_path, self._modifier_key): + if device_supports_shortcut(device_path, self._spec): return True - # Could not find any accessible device with the modifier key return False except Exception: return False @@ -777,5 +797,7 @@ __all__ = [ "EvdevKeyboardBackend", "EVDEV_AVAILABLE", "find_keyboard_devices", + "device_has_key", "device_has_modifier_key", + "device_supports_shortcut", ] diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 0cf3d68..b5d45c8 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -1629,6 +1629,14 @@ def _gdk_keyname_to_token(name: Optional[str]) -> Optional[str]: return None +def _shortcut_from_capture(modifiers: list[str], token: Optional[str]) -> Optional[str]: + """Build a canonical shortcut from recorded modifiers and a main key.""" + if token is None: + return None + candidate = "+".join((*modifiers, token)) if modifiers else token + return candidate if is_valid_shortcut(candidate) else None + + def _row_matches_query(query: str, title: str, subtitle: str = "", keywords=()) -> bool: """Return whether a settings row matches a search query. @@ -3602,10 +3610,10 @@ class SettingsDialog(Gtk.Dialog): # something the preset modifiers can't express (split keyboards, etc.). custom_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) self.custom_shortcut_entry = Gtk.Entry() - self.custom_shortcut_entry.set_placeholder_text("e.g. alt+r") + self.custom_shortcut_entry.set_placeholder_text("e.g. alt+r or f10") self.custom_shortcut_entry.set_width_chars(12) self.custom_shortcut_entry.set_tooltip_text( - "A modifier plus a key, e.g. alt+r, ctrl+alt+r, super+space" + "A modifier plus a key (alt+r) or a function key (f10)" ) self.custom_shortcut_entry.connect("activate", self._on_custom_shortcut_apply) custom_box.pack_start(self.custom_shortcut_entry, False, False, 0) @@ -3622,7 +3630,7 @@ class SettingsDialog(Gtk.Dialog): self.custom_shortcut_row = PreferenceRow( title="Custom Shortcut", - subtitle="Modifier + key combo (great for split keyboards)", + subtitle="Modifier + key, or a function key", widget=custom_box, keywords=("record", "keybinding", "hotkey"), ) @@ -3736,7 +3744,7 @@ class SettingsDialog(Gtk.Dialog): self.shortcut_info_label.set_markup( f"Invalid shortcut: " f"{GLib.markup_escape_text(shortcut or '(empty)')}. " - "Try a modifier + key, e.g. alt+r." + "Try a modifier + key (alt+r) or a function key (f10)." ) return @@ -3779,7 +3787,7 @@ class SettingsDialog(Gtk.Dialog): self._recording_shortcut = True self.record_shortcut_button.set_label("Press keys…") self.shortcut_info_label.set_markup( - "Press a modifier + key (e.g. Alt+R). Press Esc to cancel." + "Press a modifier + key (e.g. Alt+R), or an F-key. Press Esc to cancel." ) def _stop_recording_shortcut(self): @@ -3800,9 +3808,7 @@ class SettingsDialog(Gtk.Dialog): if state & Gdk.ModifierType.SUPER_MASK: modifiers.append("super") token = _gdk_keyname_to_token(Gdk.keyval_name(event.keyval)) - if not modifiers or token is None: - return None - return "+".join(modifiers + [token]) + return _shortcut_from_capture(modifiers, token) def _on_shortcut_key_press(self, widget, event): """Capture a pressed combo while recording; otherwise pass through.""" @@ -3825,8 +3831,8 @@ class SettingsDialog(Gtk.Dialog): self._apply_custom_shortcut(shortcut) else: self.shortcut_info_label.set_markup( - "Need a modifier + key. " - "Try again or press Esc to cancel." + "Need a modifier + key, or an F1–F24 " + "function key alone. Try again or press Esc to cancel." ) return True @@ -3942,7 +3948,7 @@ class SettingsDialog(Gtk.Dialog): self._set_custom_shortcut_row_visible(True) self.custom_shortcut_entry.grab_focus() self.shortcut_info_label.set_markup( - "Record or type a custom shortcut (e.g. alt+r), then click Set." + "Record or type a custom shortcut (e.g. alt+r or f10), then click Set." ) return diff --git a/tests/test_configurable_hotkeys.py b/tests/test_configurable_hotkeys.py index 5102adf..6522352 100644 --- a/tests/test_configurable_hotkeys.py +++ b/tests/test_configurable_hotkeys.py @@ -46,6 +46,14 @@ class TestParseShortcutSpec: def test_function_key_combo(self): assert parse_shortcut_spec("alt+f5").key == "f5" + def test_bare_function_key(self) -> None: + spec = parse_shortcut_spec("f10") + assert spec.modifiers == () + assert spec.key == "f10" + assert spec.is_combo is True + assert spec.canonical() == "f10" + assert spec.primary_modifier == "f10" + def test_named_key_combo(self): assert parse_shortcut_spec("super+space").key == "space" @@ -56,14 +64,14 @@ class TestParseShortcutSpec: @pytest.mark.parametrize( "bad", - ["", " ", "ctrl", "ctrl+alt", "alt+r+t", "invalid_shortcut", "alt+", "+r"], + ["", " ", "ctrl", "ctrl+alt", "alt+r+t", "invalid_shortcut", "alt+", "+r", "r", "space"], ) - def test_invalid(self, bad): + def test_invalid(self, bad: str) -> None: with pytest.raises(ValueError): parse_shortcut_spec(bad) def test_canonical_round_trips(self): - for s in ["ctrl+ctrl", "alt+r", "ctrl+alt+r", "super+space", "alt+f5"]: + for s in ["ctrl+ctrl", "alt+r", "ctrl+alt+r", "super+space", "alt+f5", "f10"]: assert parse_shortcut_spec(parse_shortcut_spec(s).canonical()).canonical() == ( parse_shortcut_spec(s).canonical() ) @@ -78,6 +86,10 @@ class TestBackwardCompatibility: assert parse_shortcut("alt+r") == "alt" assert parse_shortcut("ctrl+alt+r") == "ctrl" + def test_parse_shortcut_bare_function_key_returns_key(self) -> None: + assert parse_shortcut("f10") == "f10" + assert parse_shortcut("F13") == "f13" + def test_parse_shortcut_invalid_raises_with_message(self): with pytest.raises(ValueError) as e: parse_shortcut("bogus") @@ -98,7 +110,9 @@ class TestValidationAndLabels: def test_is_valid_shortcut(self): assert is_valid_shortcut("alt+r") is True assert is_valid_shortcut("ctrl+ctrl") is True + assert is_valid_shortcut("f10") is True assert is_valid_shortcut("ctrl") is False + assert is_valid_shortcut("r") is False assert is_valid_shortcut("") is False assert is_valid_shortcut("nope") is False @@ -111,6 +125,10 @@ class TestValidationAndLabels: assert get_shortcut_display_name("alt+r", "toggle") == "Press Alt+R" assert get_shortcut_display_name("alt+r", "push_to_talk") == "Hold Alt+R" + def test_bare_function_key_labels(self) -> None: + assert format_shortcut_label(parse_shortcut_spec("f10")) == "F10" + assert get_shortcut_display_name("f10", "push_to_talk") == "Hold F10" + # -------------------------------------------------------------------------- # evdev combo detection @@ -206,6 +224,78 @@ class TestEvdevComboDetection: backend._handle_key_event(self._event(KEY_LEFTCTRL, 1), None) assert self._wait(fired) + def test_push_to_talk_bare_function_key(self) -> None: + from vocalinux.ui.keyboard_backends.evdev_backend import evdev_code_for_key + + key_f10 = evdev_code_for_key("f10") + backend = evdev_backend.EvdevKeyboardBackend(shortcut="f10", mode="push_to_talk") + pressed = threading.Event() + released = threading.Event() + backend.register_press_callback(pressed.set) + backend.register_release_callback(released.set) + + backend._handle_key_event(self._event(key_f10, 1), None) + assert self._wait(pressed) + backend._handle_key_event(self._event(key_f10, 0), None) + assert self._wait(released) + + def test_toggle_bare_function_key(self) -> None: + from vocalinux.ui.keyboard_backends.evdev_backend import evdev_code_for_key + + key_f10 = evdev_code_for_key("f10") + backend = evdev_backend.EvdevKeyboardBackend(shortcut="f10", mode="toggle") + fired = threading.Event() + backend.register_toggle_callback(fired.set) + backend._handle_key_event(self._event(key_f10, 1), None) + assert self._wait(fired) + + +@pytest.mark.skipif(not evdev_backend.EVDEV_AVAILABLE, reason="evdev not available") +class TestEvdevShortcutAvailability: + def test_bare_function_key_checks_main_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + backend = evdev_backend.EvdevKeyboardBackend(shortcut="f10", mode="push_to_talk") + modifier_calls: list[tuple[str, str]] = [] + key_calls: list[tuple[str, str]] = [] + + monkeypatch.setattr( + evdev_backend, + "find_keyboard_devices", + lambda: ["/dev/input/event0", "/dev/input/event1"], + ) + + def fake_has_modifier(device_path: str, modifier: str) -> bool: + modifier_calls.append((device_path, modifier)) + return False + + def fake_has_key(device_path: str, key_token: str) -> bool: + key_calls.append((device_path, key_token)) + return device_path == "/dev/input/event0" and key_token == "f10" + + monkeypatch.setattr(evdev_backend, "device_has_modifier_key", fake_has_modifier) + monkeypatch.setattr(evdev_backend, "device_has_key", fake_has_key) + + assert backend.is_available() is True + assert key_calls == [ + ("/dev/input/event0", "f10"), + ] + assert modifier_calls == [] + + def test_modifier_shortcut_still_checks_modifier(self, monkeypatch: pytest.MonkeyPatch) -> None: + backend = evdev_backend.EvdevKeyboardBackend(shortcut="alt+r", mode="toggle") + modifier_calls: list[tuple[str, str]] = [] + + monkeypatch.setattr(evdev_backend, "find_keyboard_devices", lambda: ["/dev/input/event0"]) + + def fake_has_modifier(device_path: str, modifier: str) -> bool: + modifier_calls.append((device_path, modifier)) + return modifier == "alt" + + monkeypatch.setattr(evdev_backend, "device_has_modifier_key", fake_has_modifier) + monkeypatch.setattr(evdev_backend, "device_has_key", lambda *_args: False) + + assert backend.is_available() is True + assert modifier_calls == [("/dev/input/event0", "alt")] + # -------------------------------------------------------------------------- # pynput combo detection @@ -275,6 +365,18 @@ class TestPynputComboDetection: backend._on_press(kb.KeyCode.from_char("\x12")) assert self._wait(fired) + def test_push_to_talk_bare_function_key(self) -> None: + kb = self._kb() + backend = pynput_backend.PynputKeyboardBackend(shortcut="f10", mode="push_to_talk") + pressed = threading.Event() + released = threading.Event() + backend.register_press_callback(pressed.set) + backend.register_release_callback(released.set) + backend._on_press(kb.Key.f10) + assert self._wait(pressed) + backend._on_release(kb.Key.f10) + assert self._wait(released) + @pytest.mark.skipif(not evdev_backend.EVDEV_AVAILABLE, reason="evdev not available") class TestEvdevComboRecovery: diff --git a/tests/test_evdev_backend_ext.py b/tests/test_evdev_backend_ext.py index a1e35d0..a8ddfec 100644 --- a/tests/test_evdev_backend_ext.py +++ b/tests/test_evdev_backend_ext.py @@ -24,6 +24,7 @@ from vocalinux.ui.keyboard_backends.evdev_backend import ( EVDEV_AVAILABLE, MODIFIER_KEY_CODES, EvdevKeyboardBackend, + device_has_key, device_has_modifier_key, find_keyboard_devices, ) @@ -225,6 +226,34 @@ class TestDeviceHasModifierKey: assert result is False +class TestDeviceHasKey: + """Test device_has_key() against EV_KEY capabilities.""" + + @patch("vocalinux.ui.keyboard_backends.evdev_backend.EVDEV_AVAILABLE", True) + @patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice") + def test_device_has_key_f10(self, mock_input_device): + mock_device = MagicMock() + mock_device.capabilities.return_value = {1: [68]} # KEY_F10 + mock_input_device.return_value = mock_device + + with patch("vocalinux.ui.keyboard_backends.evdev_backend.ecodes") as mock_ecodes: + mock_ecodes.EV_KEY = 1 + mock_ecodes.KEY_F10 = 68 + assert device_has_key("/dev/input/event0", "f10") is True + + @patch("vocalinux.ui.keyboard_backends.evdev_backend.EVDEV_AVAILABLE", True) + @patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice") + def test_device_has_key_missing(self, mock_input_device): + mock_device = MagicMock() + mock_device.capabilities.return_value = {1: [1, 2, 3]} + mock_input_device.return_value = mock_device + + with patch("vocalinux.ui.keyboard_backends.evdev_backend.ecodes") as mock_ecodes: + mock_ecodes.EV_KEY = 1 + mock_ecodes.KEY_F10 = 68 + assert device_has_key("/dev/input/event0", "f10") is False + + class TestEvdevKeyboardBackendInit: """Test EvdevKeyboardBackend initialization.""" diff --git a/tests/test_shortcut_capture.py b/tests/test_shortcut_capture.py index ec9fdcf..7a1f42d 100644 --- a/tests/test_shortcut_capture.py +++ b/tests/test_shortcut_capture.py @@ -8,7 +8,7 @@ module-level helper is pure and directly testable. import pytest -from vocalinux.ui.settings_dialog import _gdk_keyname_to_token +from vocalinux.ui.settings_dialog import _gdk_keyname_to_token, _shortcut_from_capture @pytest.mark.parametrize( @@ -27,11 +27,32 @@ from vocalinux.ui.settings_dialog import _gdk_keyname_to_token ("bracketleft", "leftbracket"), ], ) -def test_maps_known_keys(name, expected): +def test_maps_known_keys(name: str, expected: str) -> None: assert _gdk_keyname_to_token(name) == expected @pytest.mark.parametrize("name", ["Control_L", "Alt_R", "Shift_L", "Super_L", "", None, "F25"]) -def test_rejects_modifiers_and_unknown(name): +def test_rejects_modifiers_and_unknown(name: str | None) -> None: # Modifiers, empty/None, and out-of-range function keys are not main keys. assert _gdk_keyname_to_token(name) is None + + +@pytest.mark.parametrize( + "modifiers,token,expected", + [ + (["alt"], "r", "alt+r"), + (["ctrl", "alt"], "f5", "ctrl+alt+f5"), + ([], "f10", "f10"), + ([], "f24", "f24"), + ([], "r", None), + ([], "space", None), + ([], None, None), + (["shift"], "f10", "shift+f10"), + ], +) +def test_shortcut_from_capture( + modifiers: list[str], + token: str | None, + expected: str | None, +) -> None: + assert _shortcut_from_capture(modifiers, token) == expected