@dolphy-app/keybindings 0.4.0

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/dist/index.js ADDED
@@ -0,0 +1,1041 @@
1
+ //#region packages/keybindings/src/platform.ts
2
+ const PLATFORMS = [
3
+ "mac",
4
+ "windows",
5
+ "linux"
6
+ ];
7
+ /** Платформа окна по `navigator`: Mac, Windows, всё остальное — Linux. */
8
+ const detectPlatform = (nav) => {
9
+ const platform = nav.platform ?? "";
10
+ const agent = nav.userAgent ?? "";
11
+ if (/^(Mac|iPhone|iPad|iPod)/i.test(platform)) return "mac";
12
+ if (/^Win/i.test(platform)) return "windows";
13
+ if (platform === "" && /Macintosh/i.test(agent)) return "mac";
14
+ if (platform === "" && /Windows/i.test(agent)) return "windows";
15
+ return "linux";
16
+ };
17
+ /** Платформа процесса Node по `process.platform`. */
18
+ const platformFromNode = (name) => {
19
+ if (name === "darwin") return "mac";
20
+ if (name === "win32") return "windows";
21
+ return "linux";
22
+ };
23
+
24
+ //#endregion
25
+ //#region packages/keybindings/src/keystroke.ts
26
+ const MAX_CHORD_LENGTH = 2;
27
+ var KeybindingSyntaxError = class extends Error {
28
+ reason;
29
+ text;
30
+ constructor(reason, text) {
31
+ super(`invalid keybinding "${text}": ${reason}`);
32
+ this.name = "KeybindingSyntaxError";
33
+ this.reason = reason;
34
+ this.text = text;
35
+ }
36
+ };
37
+ const MODIFIER_ALIASES = {
38
+ mod: "mod",
39
+ ctrl: "ctrl",
40
+ control: "ctrl",
41
+ alt: "alt",
42
+ option: "alt",
43
+ opt: "alt",
44
+ shift: "shift",
45
+ meta: "meta",
46
+ cmd: "meta",
47
+ command: "meta",
48
+ win: "meta",
49
+ super: "meta"
50
+ };
51
+ const NAMED_KEYS = {
52
+ enter: "enter",
53
+ return: "enter",
54
+ escape: "escape",
55
+ esc: "escape",
56
+ tab: "tab",
57
+ space: "space",
58
+ spacebar: "space",
59
+ backspace: "backspace",
60
+ delete: "delete",
61
+ del: "delete",
62
+ insert: "insert",
63
+ ins: "insert",
64
+ home: "home",
65
+ end: "end",
66
+ pageup: "pageup",
67
+ pgup: "pageup",
68
+ pagedown: "pagedown",
69
+ pgdn: "pagedown",
70
+ arrowup: "arrowup",
71
+ up: "arrowup",
72
+ arrowdown: "arrowdown",
73
+ down: "arrowdown",
74
+ arrowleft: "arrowleft",
75
+ left: "arrowleft",
76
+ arrowright: "arrowright",
77
+ right: "arrowright",
78
+ plus: "+"
79
+ };
80
+ const CANONICAL_TEXT = {
81
+ enter: "Enter",
82
+ escape: "Escape",
83
+ tab: "Tab",
84
+ space: "Space",
85
+ backspace: "Backspace",
86
+ delete: "Delete",
87
+ insert: "Insert",
88
+ home: "Home",
89
+ end: "End",
90
+ pageup: "PageUp",
91
+ pagedown: "PageDown",
92
+ arrowup: "ArrowUp",
93
+ arrowdown: "ArrowDown",
94
+ arrowleft: "ArrowLeft",
95
+ arrowright: "ArrowRight"
96
+ };
97
+ const LABELS = {
98
+ enter: "Enter",
99
+ escape: "Esc",
100
+ tab: "Tab",
101
+ space: "Space",
102
+ backspace: "Backspace",
103
+ delete: "Delete",
104
+ insert: "Insert",
105
+ home: "Home",
106
+ end: "End",
107
+ pageup: "Page Up",
108
+ pagedown: "Page Down",
109
+ arrowup: "↑",
110
+ arrowdown: "↓",
111
+ arrowleft: "←",
112
+ arrowright: "→"
113
+ };
114
+ const CODE_KEYS = {
115
+ Backquote: "`",
116
+ Minus: "-",
117
+ Equal: "=",
118
+ BracketLeft: "[",
119
+ BracketRight: "]",
120
+ Backslash: "\\",
121
+ Semicolon: ";",
122
+ Quote: "'",
123
+ Comma: ",",
124
+ Period: ".",
125
+ Slash: "/"
126
+ };
127
+ const MODIFIER_ONLY_KEYS = /* @__PURE__ */ new Set([
128
+ "control",
129
+ "shift",
130
+ "alt",
131
+ "altgraph",
132
+ "meta",
133
+ "os",
134
+ "capslock",
135
+ "numlock",
136
+ "fn",
137
+ "process",
138
+ "unidentified"
139
+ ]);
140
+ const META_LABEL = {
141
+ mac: "⌘",
142
+ windows: "Win",
143
+ linux: "Super"
144
+ };
145
+ const META_WORD = {
146
+ mac: "command",
147
+ windows: "windows",
148
+ linux: "super"
149
+ };
150
+ const FUNCTION_KEY = /^f([1-9]|1\d|2[0-4])$/;
151
+ const PHYSICAL = /^\[([A-Za-z][A-Za-z0-9]*)\]$/;
152
+ const own = (table, key) => Object.hasOwn(table, key) ? table[key] : void 0;
153
+ /** Логическая клавиша, которую даёт физическая: `KeyK` → `k`, `Comma` → `,`; нет соответствия — `undefined`. */
154
+ const codeToKey = (code) => {
155
+ const letter = /^Key([A-Z])$/.exec(code);
156
+ if (letter) return letter[1].toLowerCase();
157
+ const digit = /^Digit(\d)$/.exec(code);
158
+ if (digit) return digit[1];
159
+ return own(CODE_KEYS, code);
160
+ };
161
+ const isSingleCharacter = (text) => Array.from(text).length === 1 && !/\s/u.test(text);
162
+ /** Имя клавиши записи в каноническую логическую форму; `null` — неизвестное имя. */
163
+ const normalizeKeyName = (part) => {
164
+ const lower = part.toLowerCase();
165
+ const named = own(NAMED_KEYS, lower);
166
+ if (named !== void 0) return named;
167
+ if (FUNCTION_KEY.test(lower)) return lower;
168
+ return isSingleCharacter(part) ? lower : null;
169
+ };
170
+ const stroke = (flags, key, code) => ({
171
+ ctrl: flags.ctrl,
172
+ alt: flags.alt,
173
+ shift: flags.shift,
174
+ meta: flags.meta,
175
+ key,
176
+ code
177
+ });
178
+ /** `Mod++` — клавиша `+`; `Mod+K` — модификаторы и клавиша. */
179
+ const splitStroke = (text) => {
180
+ if (text === "+") return {
181
+ modifiers: [],
182
+ key: "+"
183
+ };
184
+ const plus = text.endsWith("++");
185
+ const index = plus ? text.length - 2 : text.lastIndexOf("+");
186
+ if (index < 0) return {
187
+ modifiers: [],
188
+ key: text
189
+ };
190
+ return {
191
+ modifiers: text.slice(0, index).split("+"),
192
+ key: plus ? "+" : text.slice(index + 1)
193
+ };
194
+ };
195
+ const parseStroke = (text, platform, whole) => {
196
+ const { modifiers, key: keyPart } = splitStroke(text);
197
+ const flags = {
198
+ ctrl: false,
199
+ alt: false,
200
+ shift: false,
201
+ meta: false
202
+ };
203
+ for (const token of modifiers) {
204
+ if (token === "") throw new KeybindingSyntaxError("empty-part", whole);
205
+ const name = own(MODIFIER_ALIASES, token.toLowerCase());
206
+ if (name === void 0) throw new KeybindingSyntaxError("unknown-modifier", whole);
207
+ const resolved = name === "mod" ? platform === "mac" ? "meta" : "ctrl" : name;
208
+ if (flags[resolved]) throw new KeybindingSyntaxError("repeated-modifier", whole);
209
+ flags[resolved] = true;
210
+ }
211
+ if (keyPart === "" || Object.hasOwn(MODIFIER_ALIASES, keyPart.toLowerCase())) throw new KeybindingSyntaxError("modifier-only", whole);
212
+ const physical = PHYSICAL.exec(keyPart);
213
+ if (physical) return stroke(flags, "", physical[1]);
214
+ if (keyPart.length > 1 && (keyPart.startsWith("[") || keyPart.endsWith("]"))) throw new KeybindingSyntaxError("invalid-code", whole);
215
+ const key = normalizeKeyName(keyPart);
216
+ if (key === null) throw new KeybindingSyntaxError("unknown-key", whole);
217
+ return stroke(flags, key, "");
218
+ };
219
+ /**
220
+ * `Mod+Shift+L` или `Mod+K Mod+S` → сочетание для платформы. `Mod` — Meta на
221
+ * macOS и Ctrl на остальных; остальные модификаторы — по имени. Ошибка
222
+ * записи — `KeybindingSyntaxError` с причиной.
223
+ */
224
+ const parseChord = (text, platform) => {
225
+ const trimmed = text.trim();
226
+ if (trimmed === "") throw new KeybindingSyntaxError("empty", text);
227
+ const parts = trimmed.split(/\s+/);
228
+ if (parts.length > 2) throw new KeybindingSyntaxError("too-long", text);
229
+ return parts.map((part) => parseStroke(part, platform, text));
230
+ };
231
+ /** `null` вместо ошибки. */
232
+ const tryParseChord = (text, platform) => {
233
+ try {
234
+ return parseChord(text, platform);
235
+ } catch (error) {
236
+ if (error instanceof KeybindingSyntaxError) return null;
237
+ throw error;
238
+ }
239
+ };
240
+ const keyText = (item) => {
241
+ if (item.code !== "") return `[${item.code}]`;
242
+ return own(CANONICAL_TEXT, item.key) ?? item.key.toUpperCase();
243
+ };
244
+ const strokeToText = (item, platform) => {
245
+ const primary = platform === "mac" ? "meta" : "ctrl";
246
+ const parts = [];
247
+ if (item[primary]) parts.push("Mod");
248
+ if (primary !== "ctrl" && item.ctrl) parts.push("Ctrl");
249
+ if (item.alt) parts.push("Alt");
250
+ if (item.shift) parts.push("Shift");
251
+ if (primary !== "meta" && item.meta) parts.push("Meta");
252
+ parts.push(keyText(item));
253
+ return parts.join("+");
254
+ };
255
+ /** Каноническая запись для хранения: `Mod` вместо основного модификатора платформы. */
256
+ const chordToText = (chord, platform) => chord.map((item) => strokeToText(item, platform)).join(" ");
257
+ const labelOfKey = (item) => {
258
+ if (item.code === "") return own(LABELS, item.key) ?? item.key.toUpperCase();
259
+ return /^Key([A-Z])$/.exec(item.code)?.[1] ?? /^Digit(\d)$/.exec(item.code)?.[1] ?? item.code;
260
+ };
261
+ const strokeLabel = (item, platform) => {
262
+ const key = labelOfKey(item);
263
+ if (platform === "mac") return `${item.ctrl ? "⌃" : ""}${item.alt ? "⌥" : ""}${item.shift ? "⇧" : ""}${item.meta ? "⌘" : ""}${key}`;
264
+ return [
265
+ item.ctrl ? "Ctrl" : null,
266
+ item.alt ? "Alt" : null,
267
+ item.shift ? "Shift" : null,
268
+ item.meta ? META_LABEL[platform] : null,
269
+ key
270
+ ].filter((part) => part !== null).join("+");
271
+ };
272
+ /** Подпись для интерфейса: `⇧⌘L` на macOS, `Ctrl+Shift+L` на остальных; цепочка — через пробел. */
273
+ const formatChord = (chord, platform) => chord.map((item) => strokeLabel(item, platform)).join(" ");
274
+ const SPOKEN_KEYS = {
275
+ ",": "comma",
276
+ "+": "plus",
277
+ enter: "enter",
278
+ escape: "escape",
279
+ tab: "tab",
280
+ space: "space",
281
+ backspace: "backspace",
282
+ delete: "delete",
283
+ insert: "insert",
284
+ home: "home",
285
+ end: "end",
286
+ pageup: "pageup",
287
+ pagedown: "pagedown",
288
+ arrowup: "arrowup",
289
+ arrowdown: "arrowdown",
290
+ arrowleft: "arrowleft",
291
+ arrowright: "arrowright"
292
+ };
293
+ const spokenStroke = (item, platform, word) => {
294
+ const mac = platform === "mac";
295
+ const control = item.ctrl ? word("control") : null;
296
+ const meta = item.meta ? word(META_WORD[platform]) : null;
297
+ const spoken = Object.hasOwn(SPOKEN_KEYS, item.key) ? SPOKEN_KEYS[item.key] : void 0;
298
+ return [
299
+ ...mac ? [meta, control] : [control, meta],
300
+ item.alt ? word(mac ? "option" : "alt") : null,
301
+ item.shift ? word("shift") : null,
302
+ spoken === void 0 ? labelOfKey(item) : word(spoken)
303
+ ].filter((part) => part !== null).join(" ");
304
+ };
305
+ /**
306
+ * Сочетание словами для скринридера: `⌘K` без этого читается как набор
307
+ * символов. На macOS — «Command Option Shift L», иначе — «Control Alt Shift L»;
308
+ * нажатия цепочки разделены запятой.
309
+ */
310
+ const spokenChord = (chord, platform, word) => chord.map((item) => spokenStroke(item, platform, word)).join(", ");
311
+ /**
312
+ * Ключ сравнения нажатия: модификаторы и клавиша. Физическая клавиша, у
313
+ * которой есть логическое имя (`[KeyK]`), приравнена к нему (`k`): на любой
314
+ * раскладке такие записи могут сработать от одного нажатия.
315
+ */
316
+ const strokeKey = (item) => {
317
+ const key = item.code === "" ? item.key : codeToKey(item.code) ?? `[${item.code}]`;
318
+ return `${`${item.ctrl ? "c" : ""}${item.alt ? "a" : ""}${item.shift ? "s" : ""}${item.meta ? "m" : ""}`}:${key}`;
319
+ };
320
+ /** Сочетание начинается нажатиями `prefix` (или совпадает с ним). */
321
+ const chordStartsWith = (chord, prefix) => prefix.length <= chord.length && prefix.every((item, index) => strokeKey(item) === strokeKey(chord[index]));
322
+ /**
323
+ * Первое нажатие печатает или правит текст (`K`, `Shift+K`, `,`, `Enter`,
324
+ * стрелки): без Ctrl и Meta такое сочетание срабатывало бы при наборе. Не
325
+ * считаются `Escape` и F1–F24.
326
+ */
327
+ const isTypingChord = (chord) => {
328
+ const first = chord[0];
329
+ if (first === void 0 || first.ctrl || first.meta) return false;
330
+ if (first.code !== "") return true;
331
+ return first.key !== "escape" && !FUNCTION_KEY.test(first.key);
332
+ };
333
+ const eventKeyName = (key) => {
334
+ if (key === " ") return "space";
335
+ if (key.length > 1) return own(NAMED_KEYS, key.toLowerCase()) ?? key.toLowerCase();
336
+ return key.toLowerCase();
337
+ };
338
+ const isAsciiLetter = (key) => /^[A-Za-z]$/.test(key);
339
+ /** Модификаторы события; AltGr (Ctrl+Alt на Windows) не считается ни тем, ни другим. */
340
+ const eventFlags = (event) => {
341
+ const altGraph = event.getModifierState?.("AltGraph") === true;
342
+ return {
343
+ ctrl: altGraph ? false : event.ctrlKey,
344
+ alt: altGraph ? false : event.altKey,
345
+ shift: event.shiftKey,
346
+ meta: event.metaKey,
347
+ altGraph
348
+ };
349
+ };
350
+ /**
351
+ * Событие совпадает с нажатием. Модификаторы — ровно заданные (AltGr не
352
+ * считается Ctrl+Alt). Физическая запись сверяется по `code`. Логическая — по
353
+ * `event.key`: буква ASCII окончательна (Dvorak, Colemak, AZERTY); если `key`
354
+ * не ASCII-буква (русская раскладка, мёртвые клавиши, ⌥+буква на macOS),
355
+ * клавишу определяет `code`. Событие во время IME-ввода не совпадает.
356
+ */
357
+ const matchKeystroke = (event, expected) => {
358
+ if (event.isComposing === true) return false;
359
+ const flags = eventFlags(event);
360
+ if (flags.altGraph && (expected.ctrl || expected.alt)) return false;
361
+ if (flags.ctrl !== expected.ctrl || flags.alt !== expected.alt) return false;
362
+ if (flags.meta !== expected.meta) return false;
363
+ if (expected.code !== "") return flags.shift === expected.shift && event.code === expected.code;
364
+ const name = eventKeyName(event.key);
365
+ if (name === expected.key) {
366
+ const isSymbol = isSingleCharacter(name) && !/^\p{Letter}$/u.test(name);
367
+ return flags.shift === expected.shift || isSymbol && !expected.shift;
368
+ }
369
+ if (isAsciiLetter(event.key)) return false;
370
+ if (/^\p{Script=Latin}$/u.test(event.key) && event.code.startsWith("Key") && !event.altKey) return false;
371
+ return codeToKey(event.code) === expected.key && flags.shift === expected.shift;
372
+ };
373
+ /**
374
+ * Событие в нажатие для записи сочетания в интерфейсе; `null` — одиночный
375
+ * модификатор или клавиша без имени. Именованные клавиши и буква ASCII
376
+ * записываются по `key`, остальное — по логическому имени физической клавиши
377
+ * (`KeyK` на русской раскладке → `k`), без имени — физической записью.
378
+ */
379
+ const eventToKeystroke = (event) => {
380
+ const name = eventKeyName(event.key);
381
+ if (MODIFIER_ONLY_KEYS.has(name)) return null;
382
+ const { ctrl, alt, shift, meta } = eventFlags(event);
383
+ const flags = {
384
+ ctrl,
385
+ alt,
386
+ shift,
387
+ meta
388
+ };
389
+ if (name === "space" || Object.hasOwn(CANONICAL_TEXT, name) || FUNCTION_KEY.test(name) || isAsciiLetter(event.key)) return stroke(flags, name, "");
390
+ const mapped = codeToKey(event.code);
391
+ if (mapped !== void 0) return stroke(flags, mapped, "");
392
+ if (isSingleCharacter(event.key)) return stroke(flags, name, "");
393
+ return event.code === "" ? null : stroke(flags, "", event.code);
394
+ };
395
+
396
+ //#endregion
397
+ //#region packages/keybindings/src/when.ts
398
+ const WHEN_MAX_LENGTH = 256;
399
+ const MAX_DEPTH = 16;
400
+ const MAX_TERMS = 64;
401
+ var WhenSyntaxError = class extends Error {
402
+ reason;
403
+ /** Позиция ошибки в тексте (индекс символа). */
404
+ position;
405
+ constructor(reason, position) {
406
+ super(`invalid when clause: ${reason} at ${position}`);
407
+ this.name = "WhenSyntaxError";
408
+ this.reason = reason;
409
+ this.position = position;
410
+ }
411
+ };
412
+ const IDENT = /[A-Za-z_][A-Za-z0-9_.-]*/y;
413
+ const BARE = /[^\s&|()!='"]+/y;
414
+ const matchAt = (pattern, text, index) => {
415
+ pattern.lastIndex = index;
416
+ return pattern.exec(text)?.[0] ?? null;
417
+ };
418
+ /** После `==`/`!=` значение — слово или строка в одинарных кавычках. */
419
+ const tokenize = (text) => {
420
+ const tokens = [];
421
+ let index = 0;
422
+ let expectValue = false;
423
+ while (index < text.length) {
424
+ const char = text[index];
425
+ if (/\s/.test(char)) {
426
+ index++;
427
+ continue;
428
+ }
429
+ const two = text.slice(index, index + 2);
430
+ if (two === "&&" || two === "||" || two === "==" || two === "!=") {
431
+ tokens.push({
432
+ kind: "op",
433
+ text: two,
434
+ position: index
435
+ });
436
+ expectValue = two === "==" || two === "!=";
437
+ index += 2;
438
+ continue;
439
+ }
440
+ if (char === "(" || char === ")" || char === "!") {
441
+ tokens.push({
442
+ kind: "op",
443
+ text: char,
444
+ position: index
445
+ });
446
+ expectValue = false;
447
+ index++;
448
+ continue;
449
+ }
450
+ if (char === "'") {
451
+ const close = text.indexOf("'", index + 1);
452
+ if (close < 0) throw new WhenSyntaxError("unterminated-string", index);
453
+ tokens.push({
454
+ kind: "value",
455
+ text: text.slice(index + 1, close),
456
+ position: index
457
+ });
458
+ expectValue = false;
459
+ index = close + 1;
460
+ continue;
461
+ }
462
+ const word = matchAt(expectValue ? BARE : IDENT, text, index);
463
+ if (word === null) throw new WhenSyntaxError("unexpected-token", index);
464
+ tokens.push({
465
+ kind: expectValue ? "value" : "ident",
466
+ text: word,
467
+ position: index
468
+ });
469
+ expectValue = false;
470
+ index += word.length;
471
+ }
472
+ tokens.push({
473
+ kind: "end",
474
+ text: "",
475
+ position: text.length
476
+ });
477
+ return tokens;
478
+ };
479
+ var Parser = class {
480
+ index = 0;
481
+ tokens;
482
+ constructor(tokens) {
483
+ this.tokens = tokens;
484
+ }
485
+ parse() {
486
+ const result = this.parseOr(0);
487
+ if (this.peek().kind !== "end") this.fail();
488
+ return result;
489
+ }
490
+ peek() {
491
+ return this.tokens[this.index];
492
+ }
493
+ isOp(text) {
494
+ const token = this.peek();
495
+ return token.kind === "op" && token.text === text;
496
+ }
497
+ fail() {
498
+ const token = this.peek();
499
+ throw new WhenSyntaxError(token.kind === "end" ? "unexpected-end" : "unexpected-token", token.position);
500
+ }
501
+ parseOr(depth) {
502
+ if (depth > MAX_DEPTH) throw new WhenSyntaxError("too-deep", this.peek().position);
503
+ const terms = [this.parseAnd(depth)];
504
+ while (this.isOp("||")) {
505
+ this.index++;
506
+ terms.push(this.parseAnd(depth));
507
+ }
508
+ return terms.length === 1 ? terms[0] : {
509
+ type: "or",
510
+ terms
511
+ };
512
+ }
513
+ parseAnd(depth) {
514
+ const terms = [this.parseUnary(depth)];
515
+ while (this.isOp("&&")) {
516
+ this.index++;
517
+ terms.push(this.parseUnary(depth));
518
+ }
519
+ return terms.length === 1 ? terms[0] : {
520
+ type: "and",
521
+ terms
522
+ };
523
+ }
524
+ parseUnary(depth) {
525
+ if (this.isOp("!")) {
526
+ this.index++;
527
+ if (this.isOp("(")) return {
528
+ type: "not",
529
+ expr: this.parseGroup(depth)
530
+ };
531
+ const token = this.peek();
532
+ if (token.kind !== "ident") return this.fail();
533
+ this.index++;
534
+ return {
535
+ type: "not",
536
+ expr: {
537
+ type: "key",
538
+ key: token.text
539
+ }
540
+ };
541
+ }
542
+ if (this.isOp("(")) return this.parseGroup(depth);
543
+ const token = this.peek();
544
+ if (token.kind !== "ident") return this.fail();
545
+ this.index++;
546
+ if (this.isOp("==") || this.isOp("!=")) {
547
+ const operator = this.peek().text;
548
+ this.index++;
549
+ const value = this.peek();
550
+ if (value.kind !== "value") return this.fail();
551
+ this.index++;
552
+ return {
553
+ type: operator === "==" ? "eq" : "neq",
554
+ key: token.text,
555
+ value: value.text
556
+ };
557
+ }
558
+ return {
559
+ type: "key",
560
+ key: token.text
561
+ };
562
+ }
563
+ parseGroup(depth) {
564
+ this.index++;
565
+ const inner = this.parseOr(depth + 1);
566
+ if (!this.isOp(")")) return this.fail();
567
+ this.index++;
568
+ return inner;
569
+ }
570
+ };
571
+ /** Текст условия → выражение; ошибка — `WhenSyntaxError` с причиной и позицией. */
572
+ const parseWhen = (text) => {
573
+ if (text.length > 256) throw new WhenSyntaxError("too-long", 0);
574
+ if (text.trim() === "") throw new WhenSyntaxError("empty", 0);
575
+ return new Parser(tokenize(text)).parse();
576
+ };
577
+ /** `null` вместо ошибки. */
578
+ const tryParseWhen = (text) => {
579
+ try {
580
+ return parseWhen(text);
581
+ } catch (error) {
582
+ if (error instanceof WhenSyntaxError) return null;
583
+ throw error;
584
+ }
585
+ };
586
+ const needsQuotes = (value) => !/^[^\s&|()!='"]+$/.test(value);
587
+ /** Каноническая запись выражения (скобки только где нужны). */
588
+ const whenToText = (expr, parent = 0) => {
589
+ switch (expr.type) {
590
+ case "key": return expr.key;
591
+ case "not": return expr.expr.type === "key" ? `!${expr.expr.key}` : `!(${whenToText(expr.expr)})`;
592
+ case "eq":
593
+ case "neq": {
594
+ const value = needsQuotes(expr.value) ? `'${expr.value}'` : expr.value;
595
+ return `${expr.key} ${expr.type === "eq" ? "==" : "!="} ${value}`;
596
+ }
597
+ case "and": {
598
+ const text = expr.terms.map((term) => whenToText(term, 2)).join(" && ");
599
+ return parent > 2 ? `(${text})` : text;
600
+ }
601
+ default: {
602
+ const text = expr.terms.map((term) => whenToText(term, 1)).join(" || ");
603
+ return parent > 1 ? `(${text})` : text;
604
+ }
605
+ }
606
+ };
607
+ const isTruthy = (value) => value !== void 0 && value !== null && value !== false && value !== "" && value !== 0;
608
+ /**
609
+ * Выражение истинно в контексте. `null` (нет условия) — всегда истина;
610
+ * неизвестный ключ — ложь; сравнение — по строковому виду значения, у
611
+ * незаданного ключа `==` ложно, а `!=` истинно.
612
+ */
613
+ const evaluateWhen = (expr, lookup) => {
614
+ if (expr === null) return true;
615
+ switch (expr.type) {
616
+ case "key": return isTruthy(lookup(expr.key));
617
+ case "not": return !evaluateWhen(expr.expr, lookup);
618
+ case "eq": {
619
+ const value = lookup(expr.key);
620
+ return value !== void 0 && String(value) === expr.value;
621
+ }
622
+ case "neq": {
623
+ const value = lookup(expr.key);
624
+ return value === void 0 || String(value) !== expr.value;
625
+ }
626
+ case "and": return expr.terms.every((term) => evaluateWhen(term, lookup));
627
+ default: return expr.terms.some((term) => evaluateWhen(term, lookup));
628
+ }
629
+ };
630
+ /** Дизъюнкция конъюнкций литералов; `null` — слишком много термов. */
631
+ const toDnf = (expr, negated) => {
632
+ switch (expr.type) {
633
+ case "key": return [[{
634
+ kind: negated ? "falsy" : "truthy",
635
+ key: expr.key
636
+ }]];
637
+ case "eq":
638
+ case "neq": return [[{
639
+ kind: expr.type === "eq" !== negated ? "eq" : "neq",
640
+ key: expr.key,
641
+ value: expr.value
642
+ }]];
643
+ case "not": return toDnf(expr.expr, !negated);
644
+ default: {
645
+ const isConjunction = expr.type === "and" !== negated;
646
+ let result = isConjunction ? [[]] : [];
647
+ for (const part of expr.terms) {
648
+ const dnf = toDnf(part, negated);
649
+ if (dnf === null || result === null) return null;
650
+ const current = result;
651
+ result = isConjunction ? dnf.flatMap((right) => current.map((left) => [...left, ...right])) : [...current, ...dnf];
652
+ if (result.length > MAX_TERMS) return null;
653
+ }
654
+ return result;
655
+ }
656
+ }
657
+ };
658
+ /** Значение `value`, при котором `ключ == value` истинно, обязательно истинно или ложно как условие. */
659
+ const forcedTruth = (value) => {
660
+ if (value === "") return false;
661
+ if (value === "false" || value === "0") return null;
662
+ return true;
663
+ };
664
+ const isConsistent = (term) => {
665
+ const states = /* @__PURE__ */ new Map();
666
+ for (const literal of term) {
667
+ let state = states.get(literal.key);
668
+ if (state === void 0) {
669
+ state = {
670
+ truthy: null,
671
+ eq: null,
672
+ neq: /* @__PURE__ */ new Set()
673
+ };
674
+ states.set(literal.key, state);
675
+ }
676
+ if (literal.kind === "truthy" || literal.kind === "falsy") {
677
+ const wanted = literal.kind === "truthy";
678
+ if (state.truthy !== null && state.truthy !== wanted) return false;
679
+ state.truthy = wanted;
680
+ } else if (literal.kind === "eq") {
681
+ if (state.eq !== null && state.eq !== literal.value) return false;
682
+ state.eq = literal.value;
683
+ } else state.neq.add(literal.value);
684
+ }
685
+ for (const state of states.values()) {
686
+ if (state.eq === null) continue;
687
+ if (state.neq.has(state.eq)) return false;
688
+ const forced = forcedTruth(state.eq);
689
+ if (forced !== null && state.truthy !== null && state.truthy !== forced) return false;
690
+ }
691
+ return true;
692
+ };
693
+ /**
694
+ * Могут ли оба условия быть истинными одновременно. `null` — нет условия.
695
+ * Сверх 64 термов в нормальной форме ответ «да» (лучше лишнее предупреждение,
696
+ * чем молчание).
697
+ */
698
+ const whenOverlaps = (left, right) => {
699
+ if (left === null || right === null) return true;
700
+ const leftDnf = toDnf(left, false);
701
+ const rightDnf = toDnf(right, false);
702
+ if (leftDnf === null || rightDnf === null) return true;
703
+ return leftDnf.some((a) => rightDnf.some((b) => isConsistent([...a, ...b])));
704
+ };
705
+
706
+ //#endregion
707
+ //#region packages/keybindings/src/keymap.ts
708
+ const SOURCE_WEIGHT = {
709
+ extension: 0,
710
+ default: 1,
711
+ user: 2
712
+ };
713
+ const SOURCE_STEP = 1e6;
714
+ const issueReason = (error) => {
715
+ if (error instanceof KeybindingSyntaxError) return error.reason;
716
+ if (error instanceof WhenSyntaxError) return error.reason;
717
+ return null;
718
+ };
719
+ const compare = (left, right) => {
720
+ const common = Math.min(left.chord.length, right.chord.length);
721
+ for (let index = 0; index < common; index++) if (strokeKey(left.chord[index]) !== strokeKey(right.chord[index])) return null;
722
+ return left.chord.length === right.chord.length ? "same" : "prefix";
723
+ };
724
+ /** Пересечение двух привязок разных команд или `null`. */
725
+ const conflictOf = (left, right) => {
726
+ if (left.command === right.command) return null;
727
+ const kind = compare(left, right);
728
+ if (kind === null || !whenOverlaps(left.when, right.when)) return null;
729
+ return left.rank >= right.rank ? {
730
+ kind,
731
+ winner: left,
732
+ loser: right
733
+ } : {
734
+ kind,
735
+ winner: right,
736
+ loser: left
737
+ };
738
+ };
739
+ const findConflictsIn = (bindings) => {
740
+ const buckets = /* @__PURE__ */ new Map();
741
+ for (const binding of bindings) {
742
+ const key = strokeKey(binding.chord[0]);
743
+ const bucket = buckets.get(key);
744
+ if (bucket === void 0) buckets.set(key, [binding]);
745
+ else bucket.push(binding);
746
+ }
747
+ const conflicts = [];
748
+ for (const bucket of buckets.values()) for (let i = 0; i < bucket.length; i++) for (let j = i + 1; j < bucket.length; j++) {
749
+ const conflict = conflictOf(bucket[i], bucket[j]);
750
+ if (conflict !== null) conflicts.push(conflict);
751
+ }
752
+ return conflicts;
753
+ };
754
+ const compile = (raw, platform, issues) => {
755
+ const problem = (field, error) => {
756
+ const reason = issueReason(error);
757
+ if (reason === null) throw error;
758
+ issues.push({
759
+ source: raw.source,
760
+ command: raw.command,
761
+ key: raw.key,
762
+ when: raw.when,
763
+ field,
764
+ reason
765
+ });
766
+ return null;
767
+ };
768
+ let chord;
769
+ try {
770
+ chord = parseChord(raw.key, platform);
771
+ } catch (error) {
772
+ return problem("key", error);
773
+ }
774
+ let when = null;
775
+ if (raw.when !== null && raw.when.trim() !== "") try {
776
+ when = parseWhen(raw.when);
777
+ } catch (error) {
778
+ return problem("when", error);
779
+ }
780
+ return {
781
+ command: raw.command,
782
+ source: raw.source,
783
+ text: raw.key,
784
+ chord,
785
+ when,
786
+ whenText: when === null ? null : whenToText(when),
787
+ rank: SOURCE_WEIGHT[raw.source] * SOURCE_STEP + raw.index
788
+ };
789
+ };
790
+ const definitionKey = (definition, platform) => definition[platform] ?? definition.key;
791
+ /**
792
+ * Собирает действующие привязки: набор пользователя заменяет привязки команды
793
+ * из кода и расширений целиком; приоритет `user` > `default` > `extension`,
794
+ * внутри источника позднее выше. Записи, которые не разбираются (повреждённые
795
+ * данные), пропускаются и попадают в `issues`.
796
+ */
797
+ const buildKeymap = (input) => {
798
+ const { platform, user } = input;
799
+ const issues = [];
800
+ const replaced = new Set(Object.keys(user));
801
+ const raws = [];
802
+ const addDefinitions = (definitions, source) => {
803
+ definitions.forEach((definition, index) => {
804
+ if (replaced.has(definition.command)) return;
805
+ raws.push({
806
+ command: definition.command,
807
+ source,
808
+ key: definitionKey(definition, platform),
809
+ when: definition.when ?? null,
810
+ index
811
+ });
812
+ });
813
+ };
814
+ addDefinitions(input.defaults, "default");
815
+ addDefinitions(input.extensions, "extension");
816
+ let userIndex = 0;
817
+ for (const [command, entries] of Object.entries(user)) for (const entry of entries) raws.push({
818
+ command,
819
+ source: "user",
820
+ key: entry.key,
821
+ when: entry.when,
822
+ index: userIndex++
823
+ });
824
+ const bindings = raws.map((raw) => compile(raw, platform, issues)).filter((binding) => binding !== null).sort((left, right) => right.rank - left.rank);
825
+ const byCommand = /* @__PURE__ */ new Map();
826
+ for (const binding of bindings) {
827
+ const list = byCommand.get(binding.command);
828
+ if (list === void 0) byCommand.set(binding.command, [binding]);
829
+ else list.push(binding);
830
+ }
831
+ return {
832
+ bindings,
833
+ issues,
834
+ forCommand: (command) => byCommand.get(command) ?? [],
835
+ resolve: (presses, lookup, isEnabled) => {
836
+ for (const binding of bindings) {
837
+ if (binding.chord.length < presses.length) continue;
838
+ if (!presses.every((press, index) => matchKeystroke(press, binding.chord[index]))) continue;
839
+ if (!evaluateWhen(binding.when, lookup)) continue;
840
+ if (isEnabled !== void 0 && !isEnabled(binding.command)) continue;
841
+ return binding.chord.length > presses.length ? {
842
+ kind: "pending",
843
+ binding
844
+ } : {
845
+ kind: "run",
846
+ binding
847
+ };
848
+ }
849
+ return { kind: "none" };
850
+ },
851
+ conflicts: () => findConflictsIn(bindings)
852
+ };
853
+ };
854
+ /**
855
+ * Пересечения кандидата с действующими привязками **других** команд (набор
856
+ * самой команды заменяется кандидатами, поэтому его привязки не сравниваются).
857
+ * Для интерфейса записи сочетания: список до сохранения.
858
+ */
859
+ const findCandidateConflicts = (keymap, candidate) => {
860
+ const self = {
861
+ command: candidate.command,
862
+ source: "user",
863
+ text: "",
864
+ chord: candidate.chord,
865
+ when: candidate.when,
866
+ whenText: null,
867
+ rank: Number.MAX_SAFE_INTEGER
868
+ };
869
+ return keymap.bindings.map((binding) => conflictOf(self, binding)).filter((conflict) => conflict !== null);
870
+ };
871
+
872
+ //#endregion
873
+ //#region packages/keybindings/src/validate.ts
874
+ const KEY_MAX_LENGTH = 64;
875
+ /** Условие «фокус в поле ввода»: привязка с набором, который с ним пересекается, срабатывала бы при наборе. */
876
+ const WHILE_TYPING = {
877
+ type: "key",
878
+ key: "inputFocus"
879
+ };
880
+ const TYPING = {
881
+ field: "when",
882
+ reason: "typing",
883
+ detail: "typing"
884
+ };
885
+ /**
886
+ * Проверка привязки: клавиши (на каждой из `platforms`: `Mod` раскрывается по
887
+ * платформе, и запись вроде `Mod+Ctrl+K` верна не везде), условие и правило
888
+ * «сочетание без Ctrl и Meta, которое печатает или правит текст, должно быть
889
+ * неактивно, пока фокус в поле ввода». `null` — привязка верна.
890
+ */
891
+ const validateBinding = (binding, platforms) => {
892
+ if (binding.key.length > 64) return {
893
+ field: "key",
894
+ reason: "syntax",
895
+ detail: "too-long-text"
896
+ };
897
+ let typing = false;
898
+ for (const platform of platforms) try {
899
+ typing ||= isTypingChord(parseChord(binding.key, platform));
900
+ } catch (error) {
901
+ if (error instanceof KeybindingSyntaxError) return {
902
+ field: "key",
903
+ reason: "syntax",
904
+ detail: error.reason
905
+ };
906
+ throw error;
907
+ }
908
+ if (binding.when === null || binding.when.trim() === "") return typing ? TYPING : null;
909
+ try {
910
+ const when = parseWhen(binding.when);
911
+ return typing && whenOverlaps(when, WHILE_TYPING) ? TYPING : null;
912
+ } catch (error) {
913
+ if (error instanceof WhenSyntaxError) return {
914
+ field: "when",
915
+ reason: "syntax",
916
+ detail: error.reason,
917
+ position: error.position
918
+ };
919
+ throw error;
920
+ }
921
+ };
922
+
923
+ //#endregion
924
+ //#region packages/keybindings/src/user.ts
925
+ const KEYBINDING_LIMITS = {
926
+ commands: 512,
927
+ entriesPerCommand: 8,
928
+ commandLength: 200
929
+ };
930
+ /** Ключ команды в реестре окна: `app:<id>` или `extension:<extensionId>:<id>`. */
931
+ const COMMAND_KEY_PATTERN = /^(app|extension):[A-Za-z0-9._:-]+$/;
932
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
933
+ const decodeEntry = (value) => {
934
+ if (!isRecord(value) || typeof value.key !== "string") return null;
935
+ const when = value.when ?? null;
936
+ if (when !== null && typeof when !== "string") return null;
937
+ return {
938
+ key: value.key,
939
+ when
940
+ };
941
+ };
942
+ /**
943
+ * Хранимое значение → пользовательские привязки. Терпимо к мусору: команды с
944
+ * неверным ключом или не массивом и записи неверной формы отбрасываются,
945
+ * лишнее сверх лимитов обрезается; разбор клавиш и условий — дело карты и
946
+ * `validateUserKeybindings`.
947
+ */
948
+ const decodeUserKeybindings = (raw) => {
949
+ const result = {};
950
+ if (!isRecord(raw) || !isRecord(raw.commands)) return result;
951
+ for (const [command, entries] of Object.entries(raw.commands)) {
952
+ if (Object.keys(result).length >= KEYBINDING_LIMITS.commands) break;
953
+ if (!(command.length <= KEYBINDING_LIMITS.commandLength && COMMAND_KEY_PATTERN.test(command)) || !Array.isArray(entries)) continue;
954
+ result[command] = entries.map(decodeEntry).filter((entry) => entry !== null).slice(0, KEYBINDING_LIMITS.entriesPerCommand);
955
+ }
956
+ return result;
957
+ };
958
+ const keyIdentity = (entry, platform) => {
959
+ const chord = tryParseChord(entry.key, platform);
960
+ const when = entry.when === null ? null : tryParseWhen(entry.when);
961
+ return `${chord?.map(strokeKey).join(" ") ?? entry.key}|${when === null ? "" : whenToText(when)}`;
962
+ };
963
+ /**
964
+ * Проверка набора пользователя на платформе хоста: лимиты, форма ключей
965
+ * команд, клавиши и условия (`validateBinding`), повтор «клавиши + условие»
966
+ * в одной команде и пересечения привязок разных команд. Пустой список — набор
967
+ * верен.
968
+ */
969
+ const validateUserKeybindings = (user, platform) => {
970
+ const issues = [];
971
+ const commands = Object.entries(user);
972
+ if (commands.length > KEYBINDING_LIMITS.commands) issues.push({
973
+ reason: "limit",
974
+ field: "commands",
975
+ command: "",
976
+ message: `at most ${KEYBINDING_LIMITS.commands} commands`
977
+ });
978
+ const valid = {};
979
+ for (const [command, entries] of commands) {
980
+ const field = `commands.${command}`;
981
+ if (command.length > KEYBINDING_LIMITS.commandLength || !COMMAND_KEY_PATTERN.test(command)) {
982
+ issues.push({
983
+ reason: command.length > KEYBINDING_LIMITS.commandLength ? "limit" : "syntax",
984
+ field,
985
+ command,
986
+ message: "invalid command key"
987
+ });
988
+ continue;
989
+ }
990
+ if (entries.length > KEYBINDING_LIMITS.entriesPerCommand) issues.push({
991
+ reason: "limit",
992
+ field,
993
+ command,
994
+ message: `at most ${KEYBINDING_LIMITS.entriesPerCommand} bindings per command`
995
+ });
996
+ const seen = /* @__PURE__ */ new Set();
997
+ const accepted = [];
998
+ entries.forEach((entry, index) => {
999
+ const problem = validateBinding(entry, [platform]);
1000
+ if (problem !== null) {
1001
+ issues.push({
1002
+ reason: problem.reason,
1003
+ field: `${field}[${index}].${problem.field}`,
1004
+ command,
1005
+ message: `${problem.field}: ${problem.detail}`
1006
+ });
1007
+ return;
1008
+ }
1009
+ const identity = keyIdentity(entry, platform);
1010
+ if (seen.has(identity)) {
1011
+ issues.push({
1012
+ reason: "duplicate",
1013
+ field: `${field}[${index}]`,
1014
+ command,
1015
+ message: "the same key and condition are listed twice"
1016
+ });
1017
+ return;
1018
+ }
1019
+ seen.add(identity);
1020
+ accepted.push(entry);
1021
+ });
1022
+ valid[command] = accepted;
1023
+ }
1024
+ const keymap = buildKeymap({
1025
+ platform,
1026
+ defaults: [],
1027
+ extensions: [],
1028
+ user: valid
1029
+ });
1030
+ for (const { winner, loser } of keymap.conflicts()) issues.push({
1031
+ reason: "conflict",
1032
+ field: `commands.${loser.command}`,
1033
+ command: loser.command,
1034
+ other: winner.command,
1035
+ message: `${loser.text} overlaps ${winner.text} of ${winner.command}`
1036
+ });
1037
+ return issues;
1038
+ };
1039
+
1040
+ //#endregion
1041
+ export { COMMAND_KEY_PATTERN, KEYBINDING_LIMITS, KEY_MAX_LENGTH, KeybindingSyntaxError, MAX_CHORD_LENGTH, PLATFORMS, WHEN_MAX_LENGTH, WhenSyntaxError, buildKeymap, chordStartsWith, chordToText, codeToKey, decodeUserKeybindings, detectPlatform, evaluateWhen, eventToKeystroke, findCandidateConflicts, formatChord, isTypingChord, matchKeystroke, parseChord, parseWhen, platformFromNode, spokenChord, strokeKey, tryParseChord, tryParseWhen, validateBinding, validateUserKeybindings, whenOverlaps, whenToText };