rethocker 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1 -1
- package/bin/rethocker-native +0 -0
- package/package.json +12 -3
- package/src/actions.ts +1 -2
- package/src/cli.ts +350 -0
- package/src/daemon.ts +2 -4
- package/src/index.test.ts +654 -30
- package/src/parse-key.test.ts +534 -0
- package/src/parse-key.ts +1 -1
- package/src/register-rule.ts +13 -7
- package/src/rethocker.ts +8 -2
- package/src/scripts/debug-keys.ts +0 -5
- package/src/scripts/example.ts +2 -2
- package/src/scripts/log.ts +233 -0
- package/src/types.ts +0 -21
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the key string parser (parse-key.ts).
|
|
3
|
+
*
|
|
4
|
+
* These are justified as focused unit tests because parseKey is a complex pure
|
|
5
|
+
* function with many edge cases (aliases, case insensitivity, modifiers,
|
|
6
|
+
* sequences) that are best caught at this level before they cause subtle bugs
|
|
7
|
+
* in the rule compilation layer.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from "bun:test";
|
|
11
|
+
import { KEY_CODE_MAP } from "./keys.ts";
|
|
12
|
+
import { parseKey } from "./parse-key.ts";
|
|
13
|
+
|
|
14
|
+
/** Look up a key code, throwing if missing. */
|
|
15
|
+
function kc(name: string): number {
|
|
16
|
+
const code = KEY_CODE_MAP[name];
|
|
17
|
+
if (code === undefined)
|
|
18
|
+
throw new Error(`Unknown key in KEY_CODE_MAP: "${name}"`);
|
|
19
|
+
return code;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ─── Single keys ──────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
describe("parseKey — single keys", () => {
|
|
25
|
+
test("plain letter", () => {
|
|
26
|
+
const result = parseKey("a");
|
|
27
|
+
expect(result.kind).toBe("single");
|
|
28
|
+
if (result.kind !== "single") return;
|
|
29
|
+
expect(result.combo.keyCode).toBe(kc("a"));
|
|
30
|
+
expect(result.combo.modifiers).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("escape", () => {
|
|
34
|
+
const result = parseKey("escape");
|
|
35
|
+
expect(result.kind).toBe("single");
|
|
36
|
+
if (result.kind !== "single") return;
|
|
37
|
+
expect(result.combo.keyCode).toBe(kc("escape"));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("capsLock", () => {
|
|
41
|
+
const result = parseKey("capsLock");
|
|
42
|
+
expect(result.kind).toBe("single");
|
|
43
|
+
if (result.kind !== "single") return;
|
|
44
|
+
expect(result.combo.keyCode).toBe(kc("capslock"));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("function key F1", () => {
|
|
48
|
+
const result = parseKey("F1");
|
|
49
|
+
expect(result.kind).toBe("single");
|
|
50
|
+
if (result.kind !== "single") return;
|
|
51
|
+
expect(result.combo.keyCode).toBe(kc("f1"));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("function key F20", () => {
|
|
55
|
+
const result = parseKey("f20");
|
|
56
|
+
expect(result.kind).toBe("single");
|
|
57
|
+
if (result.kind !== "single") return;
|
|
58
|
+
expect(result.combo.keyCode).toBe(kc("f20"));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("space", () => {
|
|
62
|
+
const result = parseKey("space");
|
|
63
|
+
expect(result.kind).toBe("single");
|
|
64
|
+
if (result.kind !== "single") return;
|
|
65
|
+
expect(result.combo.keyCode).toBe(kc("space"));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("return", () => {
|
|
69
|
+
const result = parseKey("return");
|
|
70
|
+
expect(result.kind).toBe("single");
|
|
71
|
+
if (result.kind !== "single") return;
|
|
72
|
+
expect(result.combo.keyCode).toBe(kc("return"));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("tab", () => {
|
|
76
|
+
const result = parseKey("tab");
|
|
77
|
+
expect(result.kind).toBe("single");
|
|
78
|
+
if (result.kind !== "single") return;
|
|
79
|
+
expect(result.combo.keyCode).toBe(kc("tab"));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("media key: volumeUp", () => {
|
|
83
|
+
const result = parseKey("volumeUp");
|
|
84
|
+
expect(result.kind).toBe("single");
|
|
85
|
+
if (result.kind !== "single") return;
|
|
86
|
+
expect(result.combo.keyCode).toBe(kc("volumeup"));
|
|
87
|
+
expect(result.combo.keyCode).toBeGreaterThan(999); // 1000+ offset for media keys
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("media key: playPause", () => {
|
|
91
|
+
const result = parseKey("playPause");
|
|
92
|
+
expect(result.kind).toBe("single");
|
|
93
|
+
if (result.kind !== "single") return;
|
|
94
|
+
expect(result.combo.keyCode).toBe(kc("playpause"));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("numpad key: numpadEnter", () => {
|
|
98
|
+
const result = parseKey("numpadEnter");
|
|
99
|
+
expect(result.kind).toBe("single");
|
|
100
|
+
if (result.kind !== "single") return;
|
|
101
|
+
expect(result.combo.keyCode).toBe(kc("numpadenter"));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("arrow key: left", () => {
|
|
105
|
+
const result = parseKey("left");
|
|
106
|
+
expect(result.kind).toBe("single");
|
|
107
|
+
if (result.kind !== "single") return;
|
|
108
|
+
expect(result.combo.keyCode).toBe(kc("left"));
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ─── Key aliases ──────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
describe("parseKey — key aliases", () => {
|
|
115
|
+
test("esc → escape", () => {
|
|
116
|
+
const r = parseKey("esc");
|
|
117
|
+
expect(r.kind).toBe("single");
|
|
118
|
+
if (r.kind !== "single") return;
|
|
119
|
+
expect(r.combo.keyCode).toBe(kc("escape"));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("enter → return", () => {
|
|
123
|
+
const r = parseKey("enter");
|
|
124
|
+
expect(r.kind).toBe("single");
|
|
125
|
+
if (r.kind !== "single") return;
|
|
126
|
+
expect(r.combo.keyCode).toBe(kc("return"));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("backspace → delete", () => {
|
|
130
|
+
const r = parseKey("backspace");
|
|
131
|
+
expect(r.kind).toBe("single");
|
|
132
|
+
if (r.kind !== "single") return;
|
|
133
|
+
expect(r.combo.keyCode).toBe(kc("delete"));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("back → delete", () => {
|
|
137
|
+
const r = parseKey("back");
|
|
138
|
+
expect(r.kind).toBe("single");
|
|
139
|
+
if (r.kind !== "single") return;
|
|
140
|
+
expect(r.combo.keyCode).toBe(kc("delete"));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("del → forwardDelete", () => {
|
|
144
|
+
const r = parseKey("del");
|
|
145
|
+
expect(r.kind).toBe("single");
|
|
146
|
+
if (r.kind !== "single") return;
|
|
147
|
+
expect(r.combo.keyCode).toBe(kc("forwarddelete"));
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("caps → capsLock", () => {
|
|
151
|
+
const r = parseKey("caps");
|
|
152
|
+
expect(r.kind).toBe("single");
|
|
153
|
+
if (r.kind !== "single") return;
|
|
154
|
+
expect(r.combo.keyCode).toBe(kc("capslock"));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("arrowLeft → left", () => {
|
|
158
|
+
const r = parseKey("arrowLeft");
|
|
159
|
+
expect(r.kind).toBe("single");
|
|
160
|
+
if (r.kind !== "single") return;
|
|
161
|
+
expect(r.combo.keyCode).toBe(kc("left"));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("arrowRight → right", () => {
|
|
165
|
+
const r = parseKey("arrowRight");
|
|
166
|
+
expect(r.kind).toBe("single");
|
|
167
|
+
if (r.kind !== "single") return;
|
|
168
|
+
expect(r.combo.keyCode).toBe(kc("right"));
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("arrowUp → up", () => {
|
|
172
|
+
const r = parseKey("arrowUp");
|
|
173
|
+
expect(r.kind).toBe("single");
|
|
174
|
+
if (r.kind !== "single") return;
|
|
175
|
+
expect(r.combo.keyCode).toBe(kc("up"));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("arrowDown → down", () => {
|
|
179
|
+
const r = parseKey("arrowDown");
|
|
180
|
+
expect(r.kind).toBe("single");
|
|
181
|
+
if (r.kind !== "single") return;
|
|
182
|
+
expect(r.combo.keyCode).toBe(kc("down"));
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("num0 → numpad0", () => {
|
|
186
|
+
const r = parseKey("num0");
|
|
187
|
+
expect(r.kind).toBe("single");
|
|
188
|
+
if (r.kind !== "single") return;
|
|
189
|
+
expect(r.combo.keyCode).toBe(kc("numpad0"));
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("numenter → numpadEnter", () => {
|
|
193
|
+
const r = parseKey("numenter");
|
|
194
|
+
expect(r.kind).toBe("single");
|
|
195
|
+
if (r.kind !== "single") return;
|
|
196
|
+
expect(r.combo.keyCode).toBe(kc("numpadenter"));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("numdecimal → numpadDecimal", () => {
|
|
200
|
+
const r = parseKey("numdecimal");
|
|
201
|
+
expect(r.kind).toBe("single");
|
|
202
|
+
if (r.kind !== "single") return;
|
|
203
|
+
expect(r.combo.keyCode).toBe(kc("numpaddecimal"));
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("numadd → numpadAdd", () => {
|
|
207
|
+
const r = parseKey("numadd");
|
|
208
|
+
expect(r.kind).toBe("single");
|
|
209
|
+
if (r.kind !== "single") return;
|
|
210
|
+
expect(r.combo.keyCode).toBe(kc("numpadadd"));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("numpadplus → numpadAdd", () => {
|
|
214
|
+
const r = parseKey("numpadplus");
|
|
215
|
+
expect(r.kind).toBe("single");
|
|
216
|
+
if (r.kind !== "single") return;
|
|
217
|
+
expect(r.combo.keyCode).toBe(kc("numpadadd"));
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("numsubtract → numpadSubtract", () => {
|
|
221
|
+
const r = parseKey("numsubtract");
|
|
222
|
+
expect(r.kind).toBe("single");
|
|
223
|
+
if (r.kind !== "single") return;
|
|
224
|
+
expect(r.combo.keyCode).toBe(kc("numpadsubtract"));
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("numdivide → numpadDivide", () => {
|
|
228
|
+
const r = parseKey("numdivide");
|
|
229
|
+
expect(r.kind).toBe("single");
|
|
230
|
+
if (r.kind !== "single") return;
|
|
231
|
+
expect(r.combo.keyCode).toBe(kc("numpaddivide"));
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("playpause alias", () => {
|
|
235
|
+
const r = parseKey("playpause");
|
|
236
|
+
expect(r.kind).toBe("single");
|
|
237
|
+
if (r.kind !== "single") return;
|
|
238
|
+
expect(r.combo.keyCode).toBe(kc("playpause"));
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("play alias → playpause", () => {
|
|
242
|
+
const r = parseKey("play");
|
|
243
|
+
expect(r.kind).toBe("single");
|
|
244
|
+
if (r.kind !== "single") return;
|
|
245
|
+
expect(r.combo.keyCode).toBe(kc("playpause"));
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("nexttrack alias → mediaPrevious", () => {
|
|
249
|
+
const r = parseKey("nexttrack");
|
|
250
|
+
expect(r.kind).toBe("single");
|
|
251
|
+
if (r.kind !== "single") return;
|
|
252
|
+
expect(r.combo.keyCode).toBe(kc("medianext"));
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("prevtrack alias → mediaPrevious", () => {
|
|
256
|
+
const r = parseKey("prevtrack");
|
|
257
|
+
expect(r.kind).toBe("single");
|
|
258
|
+
if (r.kind !== "single") return;
|
|
259
|
+
expect(r.combo.keyCode).toBe(kc("mediaprevious"));
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// ─── Modifier combos ──────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
describe("parseKey — modifier combos", () => {
|
|
266
|
+
test("Cmd+A", () => {
|
|
267
|
+
const r = parseKey("Cmd+A");
|
|
268
|
+
expect(r.kind).toBe("single");
|
|
269
|
+
if (r.kind !== "single") return;
|
|
270
|
+
expect(r.combo.keyCode).toBe(kc("a"));
|
|
271
|
+
expect(r.combo.modifiers).toEqual(["cmd"]);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("Shift+A", () => {
|
|
275
|
+
const r = parseKey("Shift+A");
|
|
276
|
+
expect(r.kind).toBe("single");
|
|
277
|
+
if (r.kind !== "single") return;
|
|
278
|
+
expect(r.combo.modifiers).toEqual(["shift"]);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("Cmd+Shift+K", () => {
|
|
282
|
+
const r = parseKey("Cmd+Shift+K");
|
|
283
|
+
expect(r.kind).toBe("single");
|
|
284
|
+
if (r.kind !== "single") return;
|
|
285
|
+
expect(r.combo.keyCode).toBe(kc("k"));
|
|
286
|
+
expect(r.combo.modifiers).toContain("cmd");
|
|
287
|
+
expect(r.combo.modifiers).toContain("shift");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("Ctrl+Alt+Delete", () => {
|
|
291
|
+
const r = parseKey("Ctrl+Alt+Delete");
|
|
292
|
+
expect(r.kind).toBe("single");
|
|
293
|
+
if (r.kind !== "single") return;
|
|
294
|
+
expect(r.combo.keyCode).toBe(kc("delete"));
|
|
295
|
+
expect(r.combo.modifiers).toContain("ctrl");
|
|
296
|
+
expect(r.combo.modifiers).toContain("alt");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("Cmd+Shift+Alt+Ctrl+A (all four)", () => {
|
|
300
|
+
const r = parseKey("Cmd+Shift+Alt+Ctrl+A");
|
|
301
|
+
expect(r.kind).toBe("single");
|
|
302
|
+
if (r.kind !== "single") return;
|
|
303
|
+
expect(r.combo.modifiers).toContain("cmd");
|
|
304
|
+
expect(r.combo.modifiers).toContain("shift");
|
|
305
|
+
expect(r.combo.modifiers).toContain("alt");
|
|
306
|
+
expect(r.combo.modifiers).toContain("ctrl");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("no modifiers → modifiers omitted from combo", () => {
|
|
310
|
+
const r = parseKey("a");
|
|
311
|
+
expect(r.kind).toBe("single");
|
|
312
|
+
if (r.kind !== "single") return;
|
|
313
|
+
expect(r.combo.modifiers).toBeUndefined();
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ─── Modifier aliases ─────────────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
describe("parseKey — modifier aliases", () => {
|
|
320
|
+
test("Opt → alt", () => {
|
|
321
|
+
const r = parseKey("Opt+A");
|
|
322
|
+
expect(r.kind).toBe("single");
|
|
323
|
+
if (r.kind !== "single") return;
|
|
324
|
+
expect(r.combo.modifiers).toEqual(["alt"]);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("Option → alt", () => {
|
|
328
|
+
const r = parseKey("Option+A");
|
|
329
|
+
expect(r.kind).toBe("single");
|
|
330
|
+
if (r.kind !== "single") return;
|
|
331
|
+
expect(r.combo.modifiers).toEqual(["alt"]);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("Command → cmd", () => {
|
|
335
|
+
const r = parseKey("Command+A");
|
|
336
|
+
expect(r.kind).toBe("single");
|
|
337
|
+
if (r.kind !== "single") return;
|
|
338
|
+
expect(r.combo.modifiers).toEqual(["cmd"]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("Control → ctrl", () => {
|
|
342
|
+
const r = parseKey("Control+A");
|
|
343
|
+
expect(r.kind).toBe("single");
|
|
344
|
+
if (r.kind !== "single") return;
|
|
345
|
+
expect(r.combo.modifiers).toEqual(["ctrl"]);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("Win → ctrl", () => {
|
|
349
|
+
const r = parseKey("Win+A");
|
|
350
|
+
expect(r.kind).toBe("single");
|
|
351
|
+
if (r.kind !== "single") return;
|
|
352
|
+
expect(r.combo.modifiers).toEqual(["ctrl"]);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("Fn modifier", () => {
|
|
356
|
+
const r = parseKey("Fn+F1");
|
|
357
|
+
expect(r.kind).toBe("single");
|
|
358
|
+
if (r.kind !== "single") return;
|
|
359
|
+
expect(r.combo.modifiers).toEqual(["fn"]);
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// ─── Side-specific modifiers ──────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
describe("parseKey — side-specific modifiers", () => {
|
|
366
|
+
test("leftCmd", () => {
|
|
367
|
+
const r = parseKey("leftCmd+A");
|
|
368
|
+
expect(r.kind).toBe("single");
|
|
369
|
+
if (r.kind !== "single") return;
|
|
370
|
+
expect(r.combo.modifiers).toEqual(["leftCmd"]);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("rightCmd", () => {
|
|
374
|
+
const r = parseKey("rightCmd+A");
|
|
375
|
+
expect(r.kind).toBe("single");
|
|
376
|
+
if (r.kind !== "single") return;
|
|
377
|
+
expect(r.combo.modifiers).toEqual(["rightCmd"]);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("leftAlt", () => {
|
|
381
|
+
const r = parseKey("leftAlt+A");
|
|
382
|
+
expect(r.kind).toBe("single");
|
|
383
|
+
if (r.kind !== "single") return;
|
|
384
|
+
expect(r.combo.modifiers).toEqual(["leftAlt"]);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("rightAlt (was broken by righttalt typo — fixed)", () => {
|
|
388
|
+
const r = parseKey("rightAlt+A");
|
|
389
|
+
expect(r.kind).toBe("single");
|
|
390
|
+
if (r.kind !== "single") return;
|
|
391
|
+
expect(r.combo.modifiers).toEqual(["rightAlt"]);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("rightopt → rightAlt", () => {
|
|
395
|
+
const r = parseKey("rightopt+A");
|
|
396
|
+
expect(r.kind).toBe("single");
|
|
397
|
+
if (r.kind !== "single") return;
|
|
398
|
+
expect(r.combo.modifiers).toEqual(["rightAlt"]);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("leftCtrl", () => {
|
|
402
|
+
const r = parseKey("leftCtrl+A");
|
|
403
|
+
expect(r.kind).toBe("single");
|
|
404
|
+
if (r.kind !== "single") return;
|
|
405
|
+
expect(r.combo.modifiers).toEqual(["leftCtrl"]);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("rightCtrl", () => {
|
|
409
|
+
const r = parseKey("rightCtrl+A");
|
|
410
|
+
expect(r.kind).toBe("single");
|
|
411
|
+
if (r.kind !== "single") return;
|
|
412
|
+
expect(r.combo.modifiers).toEqual(["rightCtrl"]);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("leftShift", () => {
|
|
416
|
+
const r = parseKey("leftShift+A");
|
|
417
|
+
expect(r.kind).toBe("single");
|
|
418
|
+
if (r.kind !== "single") return;
|
|
419
|
+
expect(r.combo.modifiers).toEqual(["leftShift"]);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("rightShift", () => {
|
|
423
|
+
const r = parseKey("rightShift+A");
|
|
424
|
+
expect(r.kind).toBe("single");
|
|
425
|
+
if (r.kind !== "single") return;
|
|
426
|
+
expect(r.combo.modifiers).toEqual(["rightShift"]);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// ─── Case insensitivity ───────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
describe("parseKey — case insensitivity", () => {
|
|
433
|
+
test("CMD+a == Cmd+a == cmd+a", () => {
|
|
434
|
+
const r1 = parseKey("CMD+a");
|
|
435
|
+
const r2 = parseKey("Cmd+a");
|
|
436
|
+
const r3 = parseKey("cmd+a");
|
|
437
|
+
expect(r1).toEqual(r2);
|
|
438
|
+
expect(r2).toEqual(r3);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("ESCAPE == escape == Escape", () => {
|
|
442
|
+
const r1 = parseKey("ESCAPE");
|
|
443
|
+
const r2 = parseKey("escape");
|
|
444
|
+
const r3 = parseKey("Escape");
|
|
445
|
+
expect(r1).toEqual(r2);
|
|
446
|
+
expect(r2).toEqual(r3);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("F1 == f1", () => {
|
|
450
|
+
expect(parseKey("F1")).toEqual(parseKey("f1"));
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("CAPSLOCK == capsLock == capslock", () => {
|
|
454
|
+
const r1 = parseKey("CAPSLOCK");
|
|
455
|
+
const r2 = parseKey("capsLock");
|
|
456
|
+
const r3 = parseKey("capslock");
|
|
457
|
+
expect(r1.kind).toBe("single");
|
|
458
|
+
expect(r2.kind).toBe("single");
|
|
459
|
+
expect(r3.kind).toBe("single");
|
|
460
|
+
if (r1.kind !== "single" || r2.kind !== "single" || r3.kind !== "single")
|
|
461
|
+
return;
|
|
462
|
+
expect(r1.combo.keyCode).toBe(r2.combo.keyCode);
|
|
463
|
+
expect(r2.combo.keyCode).toBe(r3.combo.keyCode);
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// ─── Sequences ────────────────────────────────────────────────────────────────
|
|
468
|
+
|
|
469
|
+
describe("parseKey — sequences", () => {
|
|
470
|
+
test("two-token sequence", () => {
|
|
471
|
+
const r = parseKey("Cmd+R T");
|
|
472
|
+
expect(r.kind).toBe("sequence");
|
|
473
|
+
if (r.kind !== "sequence") return;
|
|
474
|
+
expect(r.steps).toHaveLength(2);
|
|
475
|
+
expect(r.steps[0]?.keyCode).toBe(kc("r"));
|
|
476
|
+
expect(r.steps[0]?.modifiers).toContain("cmd");
|
|
477
|
+
expect(r.steps[1]?.keyCode).toBe(kc("t"));
|
|
478
|
+
expect(r.steps[1]?.modifiers).toBeUndefined();
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("three-token sequence", () => {
|
|
482
|
+
const r = parseKey("Ctrl+J Ctrl+K Ctrl+L");
|
|
483
|
+
expect(r.kind).toBe("sequence");
|
|
484
|
+
if (r.kind !== "sequence") return;
|
|
485
|
+
expect(r.steps).toHaveLength(3);
|
|
486
|
+
for (const step of r.steps) {
|
|
487
|
+
expect(step.modifiers).toContain("ctrl");
|
|
488
|
+
}
|
|
489
|
+
expect(r.steps[0]?.keyCode).toBe(kc("j"));
|
|
490
|
+
expect(r.steps[1]?.keyCode).toBe(kc("k"));
|
|
491
|
+
expect(r.steps[2]?.keyCode).toBe(kc("l"));
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("sequence of plain keys (no modifiers)", () => {
|
|
495
|
+
const r = parseKey("a b c");
|
|
496
|
+
expect(r.kind).toBe("sequence");
|
|
497
|
+
if (r.kind !== "sequence") return;
|
|
498
|
+
expect(r.steps).toHaveLength(3);
|
|
499
|
+
expect(r.steps[0]?.keyCode).toBe(kc("a"));
|
|
500
|
+
expect(r.steps[1]?.keyCode).toBe(kc("b"));
|
|
501
|
+
expect(r.steps[2]?.keyCode).toBe(kc("c"));
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test("extra whitespace is trimmed", () => {
|
|
505
|
+
const r = parseKey(" a b ");
|
|
506
|
+
expect(r.kind).toBe("sequence");
|
|
507
|
+
if (r.kind !== "sequence") return;
|
|
508
|
+
expect(r.steps).toHaveLength(2);
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// ─── Error cases ──────────────────────────────────────────────────────────────
|
|
513
|
+
|
|
514
|
+
describe("parseKey — error cases", () => {
|
|
515
|
+
test("empty string throws", () => {
|
|
516
|
+
expect(() => parseKey("")).toThrow();
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("whitespace-only string throws", () => {
|
|
520
|
+
expect(() => parseKey(" ")).toThrow();
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test("unknown key name throws with descriptive message", () => {
|
|
524
|
+
expect(() => parseKey("notakey")).toThrow(/Unknown key name/);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test("unknown modifier throws with descriptive message", () => {
|
|
528
|
+
expect(() => parseKey("SuperMod+A")).toThrow(/Unknown modifier/);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("invalid combo (no key part) throws", () => {
|
|
532
|
+
expect(() => parseKey("Cmd+")).toThrow();
|
|
533
|
+
});
|
|
534
|
+
});
|
package/src/parse-key.ts
CHANGED
package/src/register-rule.ts
CHANGED
|
@@ -64,11 +64,6 @@ function registerRemap(
|
|
|
64
64
|
rule: RemapRule,
|
|
65
65
|
): RuleHandle {
|
|
66
66
|
const parsed = parseKey(rule.key);
|
|
67
|
-
if (parsed.kind === "sequence") {
|
|
68
|
-
throw new Error(
|
|
69
|
-
`remap does not support sequences as trigger: "${rule.key}"`,
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
67
|
const target = parseKey(rule.remap);
|
|
73
68
|
const action =
|
|
74
69
|
target.kind === "sequence"
|
|
@@ -78,10 +73,21 @@ function registerRemap(
|
|
|
78
73
|
keyCode: target.combo.keyCode,
|
|
79
74
|
modifiers: target.combo.modifiers,
|
|
80
75
|
} as const);
|
|
81
|
-
|
|
76
|
+
|
|
77
|
+
if (parsed.kind === "single") {
|
|
78
|
+
return addRule(send, parsed.combo, action, {
|
|
79
|
+
id: rule.id,
|
|
80
|
+
conditions: buildConditions(rule),
|
|
81
|
+
disabled: rule.disabled,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Sequence trigger: intercept the full sequence then post the remap target
|
|
86
|
+
return addSequence(send, parsed.steps, action, {
|
|
82
87
|
id: rule.id,
|
|
83
|
-
conditions:
|
|
88
|
+
conditions: rule.conditions,
|
|
84
89
|
disabled: rule.disabled,
|
|
90
|
+
consume: true, // always consume — we're replacing the sequence
|
|
85
91
|
});
|
|
86
92
|
}
|
|
87
93
|
|
package/src/rethocker.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* the public RethockerHandle interface.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { join } from "node:path";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
9
|
import { createDaemon } from "./daemon.ts";
|
|
10
10
|
import { registerRule } from "./register-rule.ts";
|
|
11
11
|
import type {
|
|
@@ -43,9 +43,15 @@ export function rethocker(
|
|
|
43
43
|
rules: RethockerRule | RethockerRule[] = [],
|
|
44
44
|
options: RethockerOptions = {},
|
|
45
45
|
): RethockerHandle {
|
|
46
|
+
// In a compiled binary, import.meta.dir is a virtual path (/$bunfs/root).
|
|
47
|
+
// Use process.execPath to find rethocker-native next to the real executable.
|
|
48
|
+
// In dev/npm mode, import.meta.dir points to src/ so we go up one level to bin/.
|
|
49
|
+
const isCompiled = import.meta.dir.startsWith("/$bunfs");
|
|
46
50
|
const binaryPath =
|
|
47
51
|
options.binaryPath ??
|
|
48
|
-
|
|
52
|
+
(isCompiled
|
|
53
|
+
? join(dirname(process.execPath), "rethocker-native")
|
|
54
|
+
: join(import.meta.dir, "..", "bin", "rethocker-native"));
|
|
49
55
|
|
|
50
56
|
const daemon = createDaemon(binaryPath);
|
|
51
57
|
|
|
@@ -5,11 +5,6 @@
|
|
|
5
5
|
* Useful for discovering key codes and verifying app conditions before
|
|
6
6
|
* writing rules.
|
|
7
7
|
*
|
|
8
|
-
* Note on device discrimination: CGEventTap does not expose which physical
|
|
9
|
-
* keyboard generated an event. Use key codes to distinguish devices — numpad
|
|
10
|
-
* keys have dedicated codes (Numpad0–Numpad9, NumpadEnter, etc.) that differ
|
|
11
|
-
* from the main keyboard, so "NumpadEnter" and "return" are already distinct.
|
|
12
|
-
*
|
|
13
8
|
* Run with:
|
|
14
9
|
* bun src/scripts/debug-keys.ts
|
|
15
10
|
*/
|
package/src/scripts/example.ts
CHANGED
|
@@ -20,9 +20,9 @@ const rk = rethocker([
|
|
|
20
20
|
remap: "escape",
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
|
-
//
|
|
23
|
+
// remap from a chord to a sequence of keys (chord to chord!)
|
|
24
24
|
key: "Ctrl+H E",
|
|
25
|
-
//
|
|
25
|
+
// use `Key` for autocomplete and typesafety
|
|
26
26
|
remap: `h e l l o Shift+n1 n1 ${Key.delete}`,
|
|
27
27
|
},
|
|
28
28
|
{
|