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:
co-authored by
Jatin K Malik
parent
0ef1f09cec
commit
4d8c58f536
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user