Skip to content

Commit 37fe8a1

Browse files
committed
[3/7] commands bindings
1 parent 8f4d0f1 commit 37fe8a1

16 files changed

Lines changed: 615 additions & 76 deletions

File tree

shared/examples/config.example.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
{
22
"schema_version": 1,
3+
"bindings": {
4+
"mouse.side_front.press": "toggle_recording",
5+
"mouse.side_rear.press": "trigger_secondary_action",
6+
"hotkey.record_toggle": "toggle_recording"
7+
},
38
"transcriber": {
49
"backend": "funasr_onnx",
510
"model_name": "iic/SenseVoiceSmall",

shared/schema/config.schema.json

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,23 @@
1616
"bindings": {
1717
"type": "object",
1818
"default": {},
19+
"propertyNames": {
20+
"type": "string",
21+
"pattern": "^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)*$"
22+
},
1923
"additionalProperties": {
20-
"type": "string"
24+
"type": "string",
25+
"enum": [
26+
"noop",
27+
"reload_config",
28+
"send_enter",
29+
"shutdown",
30+
"submit_recording",
31+
"toggle_recording",
32+
"trigger_secondary_action",
33+
"workspace_left",
34+
"workspace_right"
35+
]
2136
}
2237
},
2338
"transcriber": {

tests/bindings/test_resolver.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
from __future__ import annotations
2+
3+
import unittest
4+
from types import SimpleNamespace
5+
6+
from vibemouse.bindings.actions import (
7+
build_default_bindings,
8+
build_resolved_bindings,
9+
command_for_legacy_gesture_action,
10+
)
11+
from vibemouse.bindings.resolver import BindingResolver
12+
from vibemouse.core.commands import (
13+
COMMAND_NOOP,
14+
COMMAND_SEND_ENTER,
15+
COMMAND_SUBMIT_RECORDING,
16+
COMMAND_TOGGLE_RECORDING,
17+
COMMAND_TRIGGER_SECONDARY_ACTION,
18+
COMMAND_WORKSPACE_LEFT,
19+
COMMAND_WORKSPACE_RIGHT,
20+
EVENT_GESTURE_DOWN,
21+
EVENT_GESTURE_LEFT,
22+
EVENT_GESTURE_RIGHT,
23+
EVENT_GESTURE_UP,
24+
EVENT_HOTKEY_RECORDING_SUBMIT,
25+
EVENT_HOTKEY_RECORD_TOGGLE,
26+
EVENT_MOUSE_SIDE_FRONT_PRESS,
27+
EVENT_MOUSE_SIDE_REAR_PRESS,
28+
)
29+
30+
31+
class BindingActionsTests(unittest.TestCase):
32+
@staticmethod
33+
def _make_config(**overrides: object) -> SimpleNamespace:
34+
values = {
35+
"bindings": {},
36+
"gesture_up_action": "record_toggle",
37+
"gesture_down_action": "noop",
38+
"gesture_left_action": "workspace_left",
39+
"gesture_right_action": "workspace_right",
40+
"recording_submit_keycode": 28,
41+
}
42+
values.update(overrides)
43+
return SimpleNamespace(**values)
44+
45+
def test_default_bindings_follow_runtime_defaults(self) -> None:
46+
config = self._make_config()
47+
48+
bindings = build_default_bindings(config)
49+
50+
self.assertEqual(bindings[EVENT_MOUSE_SIDE_FRONT_PRESS], COMMAND_TOGGLE_RECORDING)
51+
self.assertEqual(
52+
bindings[EVENT_MOUSE_SIDE_REAR_PRESS],
53+
COMMAND_TRIGGER_SECONDARY_ACTION,
54+
)
55+
self.assertEqual(bindings[EVENT_HOTKEY_RECORD_TOGGLE], COMMAND_TOGGLE_RECORDING)
56+
self.assertEqual(
57+
bindings[EVENT_HOTKEY_RECORDING_SUBMIT],
58+
COMMAND_SUBMIT_RECORDING,
59+
)
60+
self.assertEqual(bindings[EVENT_GESTURE_UP], COMMAND_TOGGLE_RECORDING)
61+
self.assertEqual(bindings[EVENT_GESTURE_DOWN], COMMAND_NOOP)
62+
self.assertEqual(bindings[EVENT_GESTURE_LEFT], COMMAND_WORKSPACE_LEFT)
63+
self.assertEqual(bindings[EVENT_GESTURE_RIGHT], COMMAND_WORKSPACE_RIGHT)
64+
65+
def test_custom_bindings_override_defaults(self) -> None:
66+
config = self._make_config(
67+
bindings={EVENT_MOUSE_SIDE_FRONT_PRESS: COMMAND_SEND_ENTER}
68+
)
69+
70+
bindings = build_resolved_bindings(config)
71+
72+
self.assertEqual(bindings[EVENT_MOUSE_SIDE_FRONT_PRESS], COMMAND_SEND_ENTER)
73+
74+
def test_legacy_gesture_action_names_translate_to_commands(self) -> None:
75+
self.assertEqual(
76+
command_for_legacy_gesture_action("record_toggle"),
77+
COMMAND_TOGGLE_RECORDING,
78+
)
79+
self.assertEqual(
80+
command_for_legacy_gesture_action("workspace_right"),
81+
COMMAND_WORKSPACE_RIGHT,
82+
)
83+
self.assertEqual(command_for_legacy_gesture_action("noop"), COMMAND_NOOP)
84+
85+
86+
class BindingResolverTests(unittest.TestCase):
87+
def test_resolve_returns_bound_command(self) -> None:
88+
resolver = BindingResolver({EVENT_MOUSE_SIDE_FRONT_PRESS: COMMAND_SEND_ENTER})
89+
90+
self.assertEqual(
91+
resolver.resolve(EVENT_MOUSE_SIDE_FRONT_PRESS),
92+
COMMAND_SEND_ENTER,
93+
)
94+
95+
def test_resolve_returns_none_for_unbound_event(self) -> None:
96+
resolver = BindingResolver({EVENT_MOUSE_SIDE_FRONT_PRESS: COMMAND_SEND_ENTER})
97+
98+
self.assertIsNone(resolver.resolve("mouse.middle.press"))

tests/core/test_app.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from typing import cast
1212
from unittest.mock import patch
1313

14+
from vibemouse.core.commands import COMMAND_SEND_ENTER, EVENT_MOUSE_SIDE_FRONT_PRESS
1415
from vibemouse.app import VoiceMouseApp
1516

1617

@@ -276,6 +277,33 @@ def test_recording_submit_press_is_ignored_when_idle(self) -> None:
276277

277278
self.assertEqual(rear_calls, [])
278279

280+
def test_handle_input_event_routes_through_binding_resolver(self) -> None:
281+
subject = self._make_subject()
282+
send_enter_calls: list[str] = []
283+
setattr(
284+
subject,
285+
"_binding_resolver",
286+
SimpleNamespace(
287+
resolve=lambda event_name: COMMAND_SEND_ENTER
288+
if event_name == EVENT_MOUSE_SIDE_FRONT_PRESS
289+
else None
290+
),
291+
)
292+
setattr(
293+
subject,
294+
"_output",
295+
SimpleNamespace(send_enter=lambda mode: send_enter_calls.append(mode)),
296+
)
297+
setattr(subject, "_config", SimpleNamespace(enter_mode="enter"))
298+
299+
handle_event = cast(
300+
Callable[[str], None],
301+
getattr(subject, "_handle_input_event"),
302+
)
303+
handle_event(EVENT_MOUSE_SIDE_FRONT_PRESS)
304+
305+
self.assertEqual(send_enter_calls, ["enter"])
306+
279307
def test_transcribe_and_output_openclaw_uses_openclaw_sender(self) -> None:
280308
subject = self._make_subject()
281309
recording = SimpleNamespace(duration_s=1.0, path=Path("/tmp/transcribe.wav"))

tests/listener/test_keyboard_listener.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from collections.abc import Callable
55
from typing import cast
66

7+
from vibemouse.core.commands import EVENT_HOTKEY_RECORD_TOGGLE
78
from vibemouse.keyboard_listener import KeyboardHotkeyListener
89

910

@@ -16,6 +17,13 @@ def test_constructor_rejects_empty_combo(self) -> None:
1617
with self.assertRaisesRegex(ValueError, "keycodes must not be empty"):
1718
_ = KeyboardHotkeyListener(on_hotkey=_noop, keycodes=())
1819

20+
def test_constructor_requires_callback_or_event(self) -> None:
21+
with self.assertRaisesRegex(
22+
ValueError,
23+
"on_hotkey or on_event/event_name must be configured",
24+
):
25+
_ = KeyboardHotkeyListener(keycodes=(42, 125, 193))
26+
1927
def test_combo_fires_once_until_released(self) -> None:
2028
listener = KeyboardHotkeyListener(
2129
on_hotkey=_noop, keycodes=(42, 125, 193), debounce_s=0.0
@@ -60,3 +68,17 @@ def test_reset_pressed_state_clears_latched_combo(self) -> None:
6068
self.assertFalse(process(42, 1))
6169
self.assertFalse(process(125, 1))
6270
self.assertTrue(process(193, 1))
71+
72+
def test_dispatch_hotkey_emits_configured_event(self) -> None:
73+
seen: list[str] = []
74+
listener = KeyboardHotkeyListener(
75+
on_event=seen.append,
76+
event_name=EVENT_HOTKEY_RECORD_TOGGLE,
77+
keycodes=(42, 125, 193),
78+
debounce_s=0.0,
79+
)
80+
81+
dispatch = cast(Callable[[], None], getattr(listener, "_dispatch_hotkey"))
82+
dispatch()
83+
84+
self.assertEqual(seen, [EVENT_HOTKEY_RECORD_TOGGLE])

tests/listener/test_mouse_listener.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import cast
77
from unittest.mock import patch
88

9+
from vibemouse.core.commands import EVENT_GESTURE_UP, EVENT_MOUSE_SIDE_FRONT_PRESS
910
from vibemouse.mouse_listener import SideButtonListener
1011

1112

@@ -98,6 +99,13 @@ def test_constructor_accepts_right_trigger_button(self) -> None:
9899

99100
self.assertIsNotNone(listener)
100101

102+
def test_constructor_requires_event_or_button_callbacks(self) -> None:
103+
with self.assertRaisesRegex(
104+
ValueError,
105+
"on_event or on_front_press/on_rear_press must be configured",
106+
):
107+
_ = SideButtonListener(front_button="x1", rear_button="x2")
108+
101109
def test_constructor_clamps_rescan_interval_to_minimum(self) -> None:
102110
listener = SideButtonListener(
103111
on_front_press=_noop_button,
@@ -131,6 +139,40 @@ def on_gesture(direction: str) -> None:
131139
dispatch_gesture("up")
132140
self.assertEqual(seen, ["up"])
133141

142+
def test_dispatch_front_press_emits_normalized_event_when_configured(self) -> None:
143+
seen: list[str] = []
144+
listener = SideButtonListener(
145+
on_event=seen.append,
146+
front_button="x1",
147+
rear_button="x2",
148+
debounce_s=0.0,
149+
)
150+
151+
dispatch_front = cast(
152+
Callable[[], None], getattr(listener, "_dispatch_front_press")
153+
)
154+
dispatch_front()
155+
156+
self.assertEqual(seen, [EVENT_MOUSE_SIDE_FRONT_PRESS])
157+
158+
def test_dispatch_gesture_emits_normalized_event_when_on_event_is_used(
159+
self,
160+
) -> None:
161+
seen: list[str] = []
162+
listener = SideButtonListener(
163+
on_event=seen.append,
164+
front_button="x1",
165+
rear_button="x2",
166+
)
167+
168+
dispatch_gesture = cast(
169+
Callable[[str], None],
170+
getattr(listener, "_dispatch_gesture"),
171+
)
172+
dispatch_gesture("up")
173+
174+
self.assertEqual(seen, [EVENT_GESTURE_UP])
175+
134176
def test_finish_gesture_restores_cursor_after_direction_action(self) -> None:
135177
seen: list[str] = []
136178
restored: list[tuple[int, int]] = []

tests/test_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def test_defaults_disable_trust_remote_code(self) -> None:
3737
self.assertEqual(config.rear_button, "x2")
3838
self.assertEqual(config.record_hotkey_keycodes, (42, 125, 193))
3939
self.assertIsNone(config.recording_submit_keycode)
40+
self.assertEqual(config.bindings, {})
4041

4142
def test_record_hotkey_keycodes_can_be_configured(self) -> None:
4243
with patch.dict(

tests/test_config_store.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ def test_json_config_values_are_loaded(self) -> None:
3030
json.dumps(
3131
{
3232
"schema_version": 1,
33+
"bindings": {
34+
"mouse.side_front.press": "send_enter",
35+
},
3336
"transcriber": {
3437
"backend": "funasr",
3538
"model_name": "custom/model",
@@ -64,6 +67,10 @@ def test_json_config_values_are_loaded(self) -> None:
6467
self.assertTrue(config.trust_remote_code)
6568
self.assertEqual(config.gesture_trigger_button, "right")
6669
self.assertEqual(config.record_hotkey_keycodes, (30, 31, 32))
70+
self.assertEqual(
71+
config.bindings,
72+
{"mouse.side_front.press": "send_enter"},
73+
)
6774
self.assertTrue(config.auto_paste)
6875
self.assertEqual(config.enter_mode, "ctrl_enter")
6976
self.assertEqual(config.log_level, "ERROR")
@@ -121,6 +128,9 @@ def test_save_document_writes_normalized_config(self) -> None:
121128
store.save_document(
122129
{
123130
"schema_version": 1,
131+
"bindings": {
132+
"mouse.side_front.press": "send_enter",
133+
},
124134
"input": {
125135
"front_button": "x2",
126136
"rear_button": "x1",
@@ -132,12 +142,37 @@ def test_save_document_writes_normalized_config(self) -> None:
132142
payload = json.loads(config_path.read_text(encoding="utf-8"))
133143

134144
self.assertEqual(payload["schema_version"], 1)
145+
self.assertEqual(
146+
payload["bindings"],
147+
{"mouse.side_front.press": "send_enter"},
148+
)
135149
self.assertEqual(payload["input"]["front_button"], "x2")
136150
self.assertEqual(payload["input"]["rear_button"], "x1")
137151
self.assertEqual(payload["input"]["record_hotkey_keycodes"], [10, 20, 30])
138152
self.assertIn("transcriber", payload)
139153
self.assertIn("runtime", payload)
140154

155+
def test_invalid_binding_command_is_rejected(self) -> None:
156+
with tempfile.TemporaryDirectory(prefix="vibemouse-config-") as tmp:
157+
config_path = Path(tmp) / "config.json"
158+
config_path.write_text(
159+
json.dumps(
160+
{
161+
"schema_version": 1,
162+
"bindings": {
163+
"mouse.side_front.press": "paste_now",
164+
},
165+
}
166+
),
167+
encoding="utf-8",
168+
)
169+
170+
with self.assertRaisesRegex(
171+
ValueError,
172+
"bindings\\['mouse\\.side_front\\.press'\\] must be one of",
173+
):
174+
_ = load_config(config_path, env={})
175+
141176

142177
class StatusStoreTests(unittest.TestCase):
143178
def test_write_persists_normalized_status_payload(self) -> None:

vibemouse/bindings/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__all__ = []

0 commit comments

Comments
 (0)