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