@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/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # @dolphy-app/keybindings
2
+
3
+ Keybinding registry core: key notation, platform and layout aware matching, when-clauses, precedence and conflict detection
4
+
5
+ The package version equals the version of the Dolphy app release it was published from (0.4.0).
6
+
7
+ Framework-free keybinding registry core: key notation with a
8
+ platform-aware `Mod`, layout-aware matching of keyboard events,
9
+ `when` clauses with overlap analysis, a keymap with source precedence
10
+ and conflict detection. No DOM and no dependencies.
11
+
12
+ ```ts
13
+ import { buildKeymap, parseChord } from '@dolphy-app/keybindings';
14
+ ```
15
+
16
+ ## Installation
17
+
18
+ ```sh
19
+ npm install @dolphy-app/keybindings
20
+ ```
21
+
22
+ ## Documentation
23
+
24
+ [Dolphy extensions](https://github.com/dolphy-app/dolphy/blob/main/docs/design/extensions.md).
@@ -0,0 +1,313 @@
1
+ //#region packages/keybindings/src/platform.d.ts
2
+ type Platform = 'mac' | 'windows' | 'linux';
3
+ export declare const PLATFORMS: readonly Platform[];
4
+ interface NavigatorLike {
5
+ readonly platform?: string;
6
+ readonly userAgent?: string;
7
+ }
8
+ /** Платформа окна по `navigator`: Mac, Windows, всё остальное — Linux. */
9
+ export declare const detectPlatform: (nav: NavigatorLike) => Platform;
10
+ /** Платформа процесса Node по `process.platform`. */
11
+ export declare const platformFromNode: (name: string) => Platform;
12
+ //#endregion
13
+ //#region packages/keybindings/src/keystroke.d.ts
14
+ /**
15
+ * Одно нажатие: модификаторы и клавиша. `key` — логическое имя в нижнем
16
+ * регистре (`k`, `,`, `enter`, `arrowup`, `f5`), `code` — физическая клавиша
17
+ * (`KeyK`) для записи `[KeyK]`; задано ровно одно из двух. `Mod` в нажатии
18
+ * уже раскрыт в Ctrl или Meta.
19
+ */
20
+ interface Keystroke {
21
+ readonly ctrl: boolean;
22
+ readonly alt: boolean;
23
+ readonly shift: boolean;
24
+ readonly meta: boolean;
25
+ readonly key: string;
26
+ readonly code: string;
27
+ }
28
+ /** Сочетание: одно нажатие или цепочка из двух. */
29
+ type Chord = readonly Keystroke[];
30
+ export declare const MAX_CHORD_LENGTH = 2;
31
+ type SyntaxReason = 'empty' | 'empty-part' | 'too-long' | 'modifier-only' | 'unknown-modifier' | 'repeated-modifier' | 'unknown-key' | 'invalid-code';
32
+ export declare class KeybindingSyntaxError extends Error {
33
+ readonly reason: SyntaxReason;
34
+ readonly text: string;
35
+ constructor(reason: SyntaxReason, text: string);
36
+ }
37
+ /** Логическая клавиша, которую даёт физическая: `KeyK` → `k`, `Comma` → `,`; нет соответствия — `undefined`. */
38
+ export declare const codeToKey: (code: string) => string | undefined;
39
+ /**
40
+ * `Mod+Shift+L` или `Mod+K Mod+S` → сочетание для платформы. `Mod` — Meta на
41
+ * macOS и Ctrl на остальных; остальные модификаторы — по имени. Ошибка
42
+ * записи — `KeybindingSyntaxError` с причиной.
43
+ */
44
+ export declare const parseChord: (text: string, platform: Platform) => Chord;
45
+ /** `null` вместо ошибки. */
46
+ export declare const tryParseChord: (text: string, platform: Platform) => Chord | null;
47
+ /** Каноническая запись для хранения: `Mod` вместо основного модификатора платформы. */
48
+ export declare const chordToText: (chord: Chord, platform: Platform) => string;
49
+ /** Подпись для интерфейса: `⇧⌘L` на macOS, `Ctrl+Shift+L` на остальных; цепочка — через пробел. */
50
+ export declare const formatChord: (chord: Chord, platform: Platform) => string;
51
+ /** Идентификаторы слов, которыми озвучивается сочетание (тексты — `keybinding.*` в общих сообщениях). */
52
+ type SpokenId = 'command' | 'control' | 'windows' | 'super' | 'shift' | 'option' | 'alt' | 'comma' | 'plus' | 'enter' | 'escape' | 'tab' | 'space' | 'backspace' | 'delete' | 'insert' | 'home' | 'end' | 'pageup' | 'pagedown' | 'arrowup' | 'arrowdown' | 'arrowleft' | 'arrowright';
53
+ /**
54
+ * Сочетание словами для скринридера: `⌘K` без этого читается как набор
55
+ * символов. На macOS — «Command Option Shift L», иначе — «Control Alt Shift L»;
56
+ * нажатия цепочки разделены запятой.
57
+ */
58
+ export declare const spokenChord: (chord: Chord, platform: Platform, word: (id: SpokenId) => string) => string;
59
+ /**
60
+ * Ключ сравнения нажатия: модификаторы и клавиша. Физическая клавиша, у
61
+ * которой есть логическое имя (`[KeyK]`), приравнена к нему (`k`): на любой
62
+ * раскладке такие записи могут сработать от одного нажатия.
63
+ */
64
+ export declare const strokeKey: (item: Keystroke) => string;
65
+ /** Сочетание начинается нажатиями `prefix` (или совпадает с ним). */
66
+ export declare const chordStartsWith: (chord: Chord, prefix: Chord) => boolean;
67
+ /**
68
+ * Первое нажатие печатает или правит текст (`K`, `Shift+K`, `,`, `Enter`,
69
+ * стрелки): без Ctrl и Meta такое сочетание срабатывало бы при наборе. Не
70
+ * считаются `Escape` и F1–F24.
71
+ */
72
+ export declare const isTypingChord: (chord: Chord) => boolean;
73
+ /** Событие клавиатуры в том виде, который нужен сопоставлению (подмножество `KeyboardEvent`). */
74
+ interface KeyEventLike {
75
+ readonly key: string;
76
+ readonly code: string;
77
+ readonly ctrlKey: boolean;
78
+ readonly shiftKey: boolean;
79
+ readonly altKey: boolean;
80
+ readonly metaKey: boolean;
81
+ readonly isComposing?: boolean;
82
+ getModifierState?(name: string): boolean;
83
+ }
84
+ /**
85
+ * Событие совпадает с нажатием. Модификаторы — ровно заданные (AltGr не
86
+ * считается Ctrl+Alt). Физическая запись сверяется по `code`. Логическая — по
87
+ * `event.key`: буква ASCII окончательна (Dvorak, Colemak, AZERTY); если `key`
88
+ * не ASCII-буква (русская раскладка, мёртвые клавиши, ⌥+буква на macOS),
89
+ * клавишу определяет `code`. Событие во время IME-ввода не совпадает.
90
+ */
91
+ export declare const matchKeystroke: (event: KeyEventLike, expected: Keystroke) => boolean;
92
+ /**
93
+ * Событие в нажатие для записи сочетания в интерфейсе; `null` — одиночный
94
+ * модификатор или клавиша без имени. Именованные клавиши и буква ASCII
95
+ * записываются по `key`, остальное — по логическому имени физической клавиши
96
+ * (`KeyK` на русской раскладке → `k`), без имени — физической записью.
97
+ */
98
+ export declare const eventToKeystroke: (event: KeyEventLike) => Keystroke | null;
99
+ //#endregion
100
+ //#region packages/keybindings/src/when.d.ts
101
+ /**
102
+ * Условие привязки (`when`): ключ, `!ключ`, `ключ == значение`,
103
+ * `ключ != значение`, `&&`, `||`, скобки. `!` стоит только перед ключом или
104
+ * группой в скобках. Приоритет: `!`, затем `&&`, затем `||`.
105
+ */
106
+ type WhenExpr = {
107
+ readonly type: 'key';
108
+ readonly key: string;
109
+ } | {
110
+ readonly type: 'not';
111
+ readonly expr: WhenExpr;
112
+ } | {
113
+ readonly type: 'eq';
114
+ readonly key: string;
115
+ readonly value: string;
116
+ } | {
117
+ readonly type: 'neq';
118
+ readonly key: string;
119
+ readonly value: string;
120
+ } | {
121
+ readonly type: 'and';
122
+ readonly terms: readonly WhenExpr[];
123
+ } | {
124
+ readonly type: 'or';
125
+ readonly terms: readonly WhenExpr[];
126
+ };
127
+ export declare const WHEN_MAX_LENGTH = 256;
128
+ type WhenReason = 'empty' | 'too-long' | 'too-deep' | 'unexpected-token' | 'unexpected-end' | 'unterminated-string';
129
+ export declare class WhenSyntaxError extends Error {
130
+ readonly reason: WhenReason;
131
+ /** Позиция ошибки в тексте (индекс символа). */
132
+ readonly position: number;
133
+ constructor(reason: WhenReason, position: number);
134
+ }
135
+ /** Текст условия → выражение; ошибка — `WhenSyntaxError` с причиной и позицией. */
136
+ export declare const parseWhen: (text: string) => WhenExpr;
137
+ /** `null` вместо ошибки. */
138
+ export declare const tryParseWhen: (text: string) => WhenExpr | null;
139
+ /** Каноническая запись выражения (скобки только где нужны). */
140
+ export declare const whenToText: (expr: WhenExpr, parent?: number) => string;
141
+ /** Значение контекстного ключа; `undefined` — ключ не задан. */
142
+ type ContextLookup = (key: string) => unknown;
143
+ /**
144
+ * Выражение истинно в контексте. `null` (нет условия) — всегда истина;
145
+ * неизвестный ключ — ложь; сравнение — по строковому виду значения, у
146
+ * незаданного ключа `==` ложно, а `!=` истинно.
147
+ */
148
+ export declare const evaluateWhen: (expr: WhenExpr | null, lookup: ContextLookup) => boolean;
149
+ /**
150
+ * Могут ли оба условия быть истинными одновременно. `null` — нет условия.
151
+ * Сверх 64 термов в нормальной форме ответ «да» (лучше лишнее предупреждение,
152
+ * чем молчание).
153
+ */
154
+ export declare const whenOverlaps: (left: WhenExpr | null, right: WhenExpr | null) => boolean;
155
+ //#endregion
156
+ //#region packages/keybindings/src/keymap.d.ts
157
+ /** Откуда привязка: код приложения, вклад расширения, настройки пользователя. */
158
+ type BindingSource = 'default' | 'extension' | 'user';
159
+ /** Привязка из кода приложения или вклада расширения; `mac`/`windows`/`linux` заменяют `key` на своей платформе. */
160
+ interface BindingDefinition {
161
+ readonly command: string;
162
+ readonly key: string;
163
+ readonly mac?: string | null;
164
+ readonly windows?: string | null;
165
+ readonly linux?: string | null;
166
+ readonly when?: string | null;
167
+ }
168
+ interface UserBindingEntry {
169
+ readonly key: string;
170
+ readonly when: string | null;
171
+ }
172
+ /** Набор пользователя по ключам команд: заменяет привязки команды целиком (пустой — «снято»). */
173
+ type UserKeybindings = Readonly<Record<string, readonly UserBindingEntry[]>>;
174
+ interface Binding {
175
+ readonly command: string;
176
+ readonly source: BindingSource;
177
+ /** Запись клавиш для платформы окна, как задана. */
178
+ readonly text: string;
179
+ readonly chord: Chord;
180
+ readonly when: WhenExpr | null;
181
+ readonly whenText: string | null;
182
+ /** Чем больше, тем выше приоритет. */
183
+ readonly rank: number;
184
+ }
185
+ /** Запись, которую не удалось разобрать: карта её пропускает и сообщает. */
186
+ interface BindingIssue {
187
+ readonly source: BindingSource;
188
+ readonly command: string;
189
+ readonly key: string;
190
+ readonly when: string | null;
191
+ readonly field: 'key' | 'when';
192
+ readonly reason: string;
193
+ }
194
+ interface KeymapInput {
195
+ readonly platform: Platform;
196
+ readonly defaults: readonly BindingDefinition[];
197
+ readonly extensions: readonly BindingDefinition[];
198
+ readonly user: UserKeybindings;
199
+ }
200
+ type Resolution = {
201
+ readonly kind: 'none';
202
+ } | {
203
+ readonly kind: 'pending';
204
+ readonly binding: Binding;
205
+ } | {
206
+ readonly kind: 'run';
207
+ readonly binding: Binding;
208
+ };
209
+ /**
210
+ * Пересечение привязок разных команд: `same` — клавиши совпадают, `prefix` —
211
+ * клавиши одной начинают цепочку другой; условия пересекаются. Побеждает
212
+ * привязка с большим приоритетом.
213
+ */
214
+ interface Conflict {
215
+ readonly kind: 'same' | 'prefix';
216
+ readonly winner: Binding;
217
+ readonly loser: Binding;
218
+ }
219
+ interface Keymap {
220
+ /** Действующие привязки по убыванию приоритета. */
221
+ readonly bindings: readonly Binding[];
222
+ readonly issues: readonly BindingIssue[];
223
+ /** Привязки команды по убыванию приоритета. */
224
+ forCommand(command: string): readonly Binding[];
225
+ /**
226
+ * Нажатия с начала цепочки (последнее — текущее): `run` — выполнить
227
+ * команду, `pending` — ждать следующее нажатие, `none` — привязки нет.
228
+ * Недоступные команды (`isEnabled`) и привязки с ложным условием не
229
+ * участвуют.
230
+ */
231
+ resolve(presses: readonly KeyEventLike[], lookup: ContextLookup, isEnabled?: (command: string) => boolean): Resolution;
232
+ conflicts(): readonly Conflict[];
233
+ }
234
+ /**
235
+ * Собирает действующие привязки: набор пользователя заменяет привязки команды
236
+ * из кода и расширений целиком; приоритет `user` > `default` > `extension`,
237
+ * внутри источника позднее выше. Записи, которые не разбираются (повреждённые
238
+ * данные), пропускаются и попадают в `issues`.
239
+ */
240
+ export declare const buildKeymap: (input: KeymapInput) => Keymap;
241
+ interface Candidate {
242
+ readonly command: string;
243
+ readonly chord: Chord;
244
+ readonly when: WhenExpr | null;
245
+ }
246
+ /**
247
+ * Пересечения кандидата с действующими привязками **других** команд (набор
248
+ * самой команды заменяется кандидатами, поэтому его привязки не сравниваются).
249
+ * Для интерфейса записи сочетания: список до сохранения.
250
+ */
251
+ export declare const findCandidateConflicts: (keymap: Keymap, candidate: Candidate) => Conflict[];
252
+ //#endregion
253
+ //#region packages/keybindings/src/validate.d.ts
254
+ export declare const KEY_MAX_LENGTH = 64;
255
+ type BindingProblem = {
256
+ readonly field: 'key';
257
+ readonly reason: 'syntax';
258
+ readonly detail: SyntaxReason | 'too-long-text';
259
+ } | {
260
+ readonly field: 'when';
261
+ readonly reason: 'syntax';
262
+ readonly detail: WhenReason;
263
+ readonly position: number;
264
+ } | {
265
+ readonly field: 'when';
266
+ readonly reason: 'typing';
267
+ readonly detail: 'typing';
268
+ };
269
+ /**
270
+ * Проверка привязки: клавиши (на каждой из `platforms`: `Mod` раскрывается по
271
+ * платформе, и запись вроде `Mod+Ctrl+K` верна не везде), условие и правило
272
+ * «сочетание без Ctrl и Meta, которое печатает или правит текст, должно быть
273
+ * неактивно, пока фокус в поле ввода». `null` — привязка верна.
274
+ */
275
+ export declare const validateBinding: (binding: {
276
+ readonly key: string;
277
+ readonly when: string | null;
278
+ }, platforms: readonly Platform[]) => BindingProblem | null;
279
+ //#endregion
280
+ //#region packages/keybindings/src/user.d.ts
281
+ export declare const KEYBINDING_LIMITS: {
282
+ readonly commands: 512;
283
+ readonly entriesPerCommand: 8;
284
+ readonly commandLength: 200;
285
+ };
286
+ /** Ключ команды в реестре окна: `app:<id>` или `extension:<extensionId>:<id>`. */
287
+ export declare const COMMAND_KEY_PATTERN: RegExp;
288
+ type UserIssueReason = 'syntax' | 'typing' | 'conflict' | 'limit' | 'duplicate';
289
+ interface UserIssue {
290
+ readonly reason: UserIssueReason;
291
+ /** Путь к полю: `commands`, `commands.<ключ>`, `commands.<ключ>[0].key`. */
292
+ readonly field: string;
293
+ readonly command: string;
294
+ /** Вторая команда при `conflict`. */
295
+ readonly other?: string;
296
+ readonly message: string;
297
+ }
298
+ /**
299
+ * Хранимое значение → пользовательские привязки. Терпимо к мусору: команды с
300
+ * неверным ключом или не массивом и записи неверной формы отбрасываются,
301
+ * лишнее сверх лимитов обрезается; разбор клавиш и условий — дело карты и
302
+ * `validateUserKeybindings`.
303
+ */
304
+ export declare const decodeUserKeybindings: (raw: unknown) => UserKeybindings;
305
+ /**
306
+ * Проверка набора пользователя на платформе хоста: лимиты, форма ключей
307
+ * команд, клавиши и условия (`validateBinding`), повтор «клавиши + условие»
308
+ * в одной команде и пересечения привязок разных команд. Пустой список — набор
309
+ * верен.
310
+ */
311
+ export declare const validateUserKeybindings: (user: UserKeybindings, platform: Platform) => UserIssue[];
312
+ //#endregion
313
+ export type { Binding, BindingDefinition, BindingIssue, BindingProblem, BindingSource, Candidate, Chord, Conflict, ContextLookup, KeyEventLike, Keymap, KeymapInput, Keystroke, Platform, Resolution, SpokenId, SyntaxReason, UserBindingEntry, UserIssue, UserIssueReason, UserKeybindings, WhenExpr, WhenReason };