feat(shortcuts): allow bare function keys for push-to-talk (#815)

* feat(shortcuts): allow bare function keys for push-to-talk

Accept F1–F24 shortcuts without a modifier so dictation can use a
dedicated key (e.g. F10) instead of Shift/Ctrl/Alt combos that fire
while typing. Settings shortcut capture records bare function keys too.

* fix(shortcuts): evdev availability for bare function keys

Check EV_KEY capabilities for bare F1-F24 shortcuts instead of an empty
modifier name. Add availability regression tests and type hints on new tests.

* refactor(shortcuts): fold bare F-keys into ShortcutSpec

Teach primary_modifier to return the bare key so parse_shortcut and the
backend stop special-casing empty modifiers. Capture now asks the parser
instead of re-encoding the F-key allow-list, and evdev shares one
capability probe for modifiers and main keys.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
Ariel Flesler
2026-09-12 12:39:25 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent 0ef1f09cec
commit 4d8c58f536
6 changed files with 237 additions and 51 deletions
+14 -8
View File
@@ -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")
@@ -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",
]
+17 -11
View File
@@ -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"<span foreground='#e01b24'>Invalid shortcut: "
f"<b>{GLib.markup_escape_text(shortcut or '(empty)')}</b>. "
"Try a modifier + key, e.g. alt+r.</span>"
"Try a modifier + key (alt+r) or a function key (f10).</span>"
)
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(
"<i>Press a modifier + key (e.g. Alt+R). Press Esc to cancel.</i>"
"<i>Press a modifier + key (e.g. Alt+R), or an F-key. Press Esc to cancel.</i>"
)
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(
"<span foreground='#e01b24'>Need a modifier + key. "
"Try again or press Esc to cancel.</span>"
"<span foreground='#e01b24'>Need a modifier + key, or an F1–F24 "
"function key alone. Try again or press Esc to cancel.</span>"
)
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(
"<i>Record or type a custom shortcut (e.g. alt+r), then click Set.</i>"
"<i>Record or type a custom shortcut (e.g. alt+r or f10), then click Set.</i>"
)
return
+105 -3
View File
@@ -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:
+29
View File
@@ -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."""
+24 -3
View File
@@ -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