@textui/core 0.1.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/LICENSE +21 -0
- package/README.md +67 -0
- package/dist/adapters/index.d.ts +12 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +11 -0
- package/dist/app/app.d.ts +200 -0
- package/dist/app/app.d.ts.map +1 -0
- package/dist/app/app.js +980 -0
- package/dist/core/animation.d.ts +34 -0
- package/dist/core/animation.d.ts.map +1 -0
- package/dist/core/animation.js +125 -0
- package/dist/core/clipboard.d.ts +29 -0
- package/dist/core/clipboard.d.ts.map +1 -0
- package/dist/core/clipboard.js +30 -0
- package/dist/core/commands.d.ts +42 -0
- package/dist/core/commands.d.ts.map +1 -0
- package/dist/core/commands.js +146 -0
- package/dist/core/components.d.ts +27 -0
- package/dist/core/components.d.ts.map +1 -0
- package/dist/core/components.js +78 -0
- package/dist/core/events.d.ts +23 -0
- package/dist/core/events.d.ts.map +1 -0
- package/dist/core/events.js +77 -0
- package/dist/core/focus.d.ts +69 -0
- package/dist/core/focus.d.ts.map +1 -0
- package/dist/core/focus.js +336 -0
- package/dist/core/i18n.d.ts +27 -0
- package/dist/core/i18n.d.ts.map +1 -0
- package/dist/core/i18n.js +88 -0
- package/dist/core/keybindings.d.ts +53 -0
- package/dist/core/keybindings.d.ts.map +1 -0
- package/dist/core/keybindings.js +163 -0
- package/dist/core/layers.d.ts +28 -0
- package/dist/core/layers.d.ts.map +1 -0
- package/dist/core/layers.js +84 -0
- package/dist/core/manifest.d.ts +22 -0
- package/dist/core/manifest.d.ts.map +1 -0
- package/dist/core/manifest.js +85 -0
- package/dist/core/navigation.d.ts +47 -0
- package/dist/core/navigation.d.ts.map +1 -0
- package/dist/core/navigation.js +110 -0
- package/dist/core/resources.d.ts +115 -0
- package/dist/core/resources.d.ts.map +1 -0
- package/dist/core/resources.js +321 -0
- package/dist/core/services.d.ts +21 -0
- package/dist/core/services.d.ts.map +1 -0
- package/dist/core/services.js +63 -0
- package/dist/core/store.d.ts +60 -0
- package/dist/core/store.d.ts.map +1 -0
- package/dist/core/store.js +593 -0
- package/dist/core/surfaces.d.ts +81 -0
- package/dist/core/surfaces.d.ts.map +1 -0
- package/dist/core/surfaces.js +237 -0
- package/dist/core/syntax.d.ts +49 -0
- package/dist/core/syntax.d.ts.map +1 -0
- package/dist/core/syntax.js +172 -0
- package/dist/core/when.d.ts +15 -0
- package/dist/core/when.d.ts.map +1 -0
- package/dist/core/when.js +224 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +54 -0
- package/dist/jsx/factory.d.ts +27 -0
- package/dist/jsx/factory.d.ts.map +1 -0
- package/dist/jsx/factory.js +102 -0
- package/dist/jsx/intrinsics.d.ts +96 -0
- package/dist/jsx/intrinsics.d.ts.map +1 -0
- package/dist/jsx/intrinsics.js +1 -0
- package/dist/jsx/jsx-dev-runtime.d.ts +13 -0
- package/dist/jsx/jsx-dev-runtime.d.ts.map +1 -0
- package/dist/jsx/jsx-dev-runtime.js +12 -0
- package/dist/jsx/jsx-runtime.d.ts +42 -0
- package/dist/jsx/jsx-runtime.d.ts.map +1 -0
- package/dist/jsx/jsx-runtime.js +16 -0
- package/dist/render/buffer.d.ts +77 -0
- package/dist/render/buffer.d.ts.map +1 -0
- package/dist/render/buffer.js +275 -0
- package/dist/render/color.d.ts +35 -0
- package/dist/render/color.d.ts.map +1 -0
- package/dist/render/color.js +160 -0
- package/dist/render/diff.d.ts +37 -0
- package/dist/render/diff.d.ts.map +1 -0
- package/dist/render/diff.js +66 -0
- package/dist/render/layout.d.ts +72 -0
- package/dist/render/layout.d.ts.map +1 -0
- package/dist/render/layout.js +633 -0
- package/dist/render/static.d.ts +63 -0
- package/dist/render/static.d.ts.map +1 -0
- package/dist/render/static.js +209 -0
- package/dist/runtime/bindings.d.ts +46 -0
- package/dist/runtime/bindings.d.ts.map +1 -0
- package/dist/runtime/bindings.js +116 -0
- package/dist/runtime/hooks.d.ts +284 -0
- package/dist/runtime/hooks.d.ts.map +1 -0
- package/dist/runtime/hooks.js +846 -0
- package/dist/runtime/instance.d.ts +106 -0
- package/dist/runtime/instance.d.ts.map +1 -0
- package/dist/runtime/instance.js +181 -0
- package/dist/runtime/paint.d.ts +27 -0
- package/dist/runtime/paint.d.ts.map +1 -0
- package/dist/runtime/paint.js +567 -0
- package/dist/runtime/reconcile.d.ts +23 -0
- package/dist/runtime/reconcile.d.ts.map +1 -0
- package/dist/runtime/reconcile.js +260 -0
- package/dist/runtime/runtime.d.ts +42 -0
- package/dist/runtime/runtime.d.ts.map +1 -0
- package/dist/runtime/runtime.js +1 -0
- package/dist/runtime/style.d.ts +55 -0
- package/dist/runtime/style.d.ts.map +1 -0
- package/dist/runtime/style.js +143 -0
- package/dist/themes/borders.d.ts +10 -0
- package/dist/themes/borders.d.ts.map +1 -0
- package/dist/themes/borders.js +88 -0
- package/dist/themes/builtin.d.ts +31 -0
- package/dist/themes/builtin.d.ts.map +1 -0
- package/dist/themes/builtin.js +291 -0
- package/dist/themes/glyphs.d.ts +15 -0
- package/dist/themes/glyphs.d.ts.map +1 -0
- package/dist/themes/glyphs.js +97 -0
- package/dist/themes/index.d.ts +5 -0
- package/dist/themes/index.d.ts.map +1 -0
- package/dist/themes/index.js +4 -0
- package/dist/themes/registry.d.ts +20 -0
- package/dist/themes/registry.d.ts.map +1 -0
- package/dist/themes/registry.js +204 -0
- package/dist/types/adapter.d.ts +38 -0
- package/dist/types/adapter.d.ts.map +1 -0
- package/dist/types/adapter.js +1 -0
- package/dist/types/animation.d.ts +31 -0
- package/dist/types/animation.d.ts.map +1 -0
- package/dist/types/animation.js +1 -0
- package/dist/types/app.d.ts +148 -0
- package/dist/types/app.d.ts.map +1 -0
- package/dist/types/app.js +1 -0
- package/dist/types/async.d.ts +30 -0
- package/dist/types/async.d.ts.map +1 -0
- package/dist/types/async.js +1 -0
- package/dist/types/capabilities.d.ts +44 -0
- package/dist/types/capabilities.d.ts.map +1 -0
- package/dist/types/capabilities.js +33 -0
- package/dist/types/cells.d.ts +72 -0
- package/dist/types/cells.d.ts.map +1 -0
- package/dist/types/cells.js +10 -0
- package/dist/types/command.d.ts +127 -0
- package/dist/types/command.d.ts.map +1 -0
- package/dist/types/command.js +1 -0
- package/dist/types/component-registry.d.ts +77 -0
- package/dist/types/component-registry.d.ts.map +1 -0
- package/dist/types/component-registry.js +1 -0
- package/dist/types/disposable.d.ts +8 -0
- package/dist/types/disposable.d.ts.map +1 -0
- package/dist/types/disposable.js +1 -0
- package/dist/types/focus.d.ts +72 -0
- package/dist/types/focus.d.ts.map +1 -0
- package/dist/types/focus.js +1 -0
- package/dist/types/geometry.d.ts +28 -0
- package/dist/types/geometry.d.ts.map +1 -0
- package/dist/types/geometry.js +14 -0
- package/dist/types/graph.d.ts +129 -0
- package/dist/types/graph.d.ts.map +1 -0
- package/dist/types/graph.js +31 -0
- package/dist/types/i18n.d.ts +23 -0
- package/dist/types/i18n.d.ts.map +1 -0
- package/dist/types/i18n.js +1 -0
- package/dist/types/index.d.ts +32 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +31 -0
- package/dist/types/input.d.ts +65 -0
- package/dist/types/input.d.ts.map +1 -0
- package/dist/types/input.js +1 -0
- package/dist/types/keybinding.d.ts +49 -0
- package/dist/types/keybinding.d.ts.map +1 -0
- package/dist/types/keybinding.js +1 -0
- package/dist/types/layer.d.ts +54 -0
- package/dist/types/layer.d.ts.map +1 -0
- package/dist/types/layer.js +1 -0
- package/dist/types/manifest.d.ts +114 -0
- package/dist/types/manifest.d.ts.map +1 -0
- package/dist/types/manifest.js +1 -0
- package/dist/types/markdown.d.ts +58 -0
- package/dist/types/markdown.d.ts.map +1 -0
- package/dist/types/markdown.js +1 -0
- package/dist/types/navigation.d.ts +40 -0
- package/dist/types/navigation.d.ts.map +1 -0
- package/dist/types/navigation.js +1 -0
- package/dist/types/render.d.ts +72 -0
- package/dist/types/render.d.ts.map +1 -0
- package/dist/types/render.js +1 -0
- package/dist/types/resource.d.ts +170 -0
- package/dist/types/resource.d.ts.map +1 -0
- package/dist/types/resource.js +1 -0
- package/dist/types/services.d.ts +24 -0
- package/dist/types/services.d.ts.map +1 -0
- package/dist/types/services.js +3 -0
- package/dist/types/shell.d.ts +32 -0
- package/dist/types/shell.d.ts.map +1 -0
- package/dist/types/shell.js +1 -0
- package/dist/types/store.d.ts +124 -0
- package/dist/types/store.d.ts.map +1 -0
- package/dist/types/store.js +1 -0
- package/dist/types/stream.d.ts +18 -0
- package/dist/types/stream.d.ts.map +1 -0
- package/dist/types/stream.js +1 -0
- package/dist/types/style.d.ts +166 -0
- package/dist/types/style.d.ts.map +1 -0
- package/dist/types/style.js +1 -0
- package/dist/types/surface.d.ts +97 -0
- package/dist/types/surface.d.ts.map +1 -0
- package/dist/types/surface.js +1 -0
- package/dist/types/syntax.d.ts +68 -0
- package/dist/types/syntax.d.ts.map +1 -0
- package/dist/types/syntax.js +5 -0
- package/dist/types/terminal.d.ts +52 -0
- package/dist/types/terminal.d.ts.map +1 -0
- package/dist/types/terminal.js +1 -0
- package/dist/types/theme.d.ts +117 -0
- package/dist/types/theme.d.ts.map +1 -0
- package/dist/types/theme.js +1 -0
- package/dist/types/when.d.ts +19 -0
- package/dist/types/when.d.ts.map +1 -0
- package/dist/types/when.js +1 -0
- package/dist/ui/primitives.d.ts +56 -0
- package/dist/ui/primitives.d.ts.map +1 -0
- package/dist/ui/primitives.js +108 -0
- package/dist/ui/screen.d.ts +23 -0
- package/dist/ui/screen.d.ts.map +1 -0
- package/dist/ui/screen.js +18 -0
- package/dist/util/disposable.d.ts +6 -0
- package/dist/util/disposable.d.ts.map +1 -0
- package/dist/util/disposable.js +49 -0
- package/dist/util/markdown.d.ts +24 -0
- package/dist/util/markdown.d.ts.map +1 -0
- package/dist/util/markdown.js +220 -0
- package/dist/util/paths.d.ts +47 -0
- package/dist/util/paths.d.ts.map +1 -0
- package/dist/util/paths.js +134 -0
- package/dist/util/stream.d.ts +32 -0
- package/dist/util/stream.d.ts.map +1 -0
- package/dist/util/stream.js +206 -0
- package/dist/util/text.d.ts +65 -0
- package/dist/util/text.d.ts.map +1 -0
- package/dist/util/text.js +419 -0
- package/package.json +70 -0
- package/src/adapters/index.ts +11 -0
- package/src/app/app.ts +1096 -0
- package/src/core/animation.ts +144 -0
- package/src/core/clipboard.ts +40 -0
- package/src/core/commands.ts +169 -0
- package/src/core/components.ts +91 -0
- package/src/core/events.ts +95 -0
- package/src/core/focus.ts +358 -0
- package/src/core/i18n.ts +107 -0
- package/src/core/keybindings.ts +184 -0
- package/src/core/layers.ts +94 -0
- package/src/core/manifest.ts +84 -0
- package/src/core/navigation.ts +135 -0
- package/src/core/resources.ts +362 -0
- package/src/core/services.ts +69 -0
- package/src/core/store.ts +640 -0
- package/src/core/surfaces.ts +292 -0
- package/src/core/syntax.ts +200 -0
- package/src/core/when.ts +238 -0
- package/src/index.ts +76 -0
- package/src/jsx/factory.ts +124 -0
- package/src/jsx/intrinsics.ts +99 -0
- package/src/jsx/jsx-dev-runtime.ts +23 -0
- package/src/jsx/jsx-runtime.ts +76 -0
- package/src/render/buffer.ts +318 -0
- package/src/render/color.ts +180 -0
- package/src/render/diff.ts +99 -0
- package/src/render/layout.ts +764 -0
- package/src/render/static.ts +290 -0
- package/src/runtime/bindings.ts +150 -0
- package/src/runtime/hooks.ts +1057 -0
- package/src/runtime/instance.ts +284 -0
- package/src/runtime/paint.ts +737 -0
- package/src/runtime/reconcile.ts +335 -0
- package/src/runtime/runtime.ts +47 -0
- package/src/runtime/style.ts +205 -0
- package/src/themes/borders.ts +95 -0
- package/src/themes/builtin.ts +301 -0
- package/src/themes/glyphs.ts +101 -0
- package/src/themes/index.ts +4 -0
- package/src/themes/registry.ts +224 -0
- package/src/types/adapter.ts +41 -0
- package/src/types/animation.ts +34 -0
- package/src/types/app.ts +149 -0
- package/src/types/async.ts +35 -0
- package/src/types/capabilities.ts +79 -0
- package/src/types/cells.ts +79 -0
- package/src/types/command.ts +139 -0
- package/src/types/component-registry.ts +82 -0
- package/src/types/disposable.ts +8 -0
- package/src/types/focus.ts +77 -0
- package/src/types/geometry.ts +50 -0
- package/src/types/graph.ts +174 -0
- package/src/types/i18n.ts +25 -0
- package/src/types/index.ts +31 -0
- package/src/types/input.ts +81 -0
- package/src/types/keybinding.ts +51 -0
- package/src/types/layer.ts +46 -0
- package/src/types/manifest.ts +101 -0
- package/src/types/markdown.ts +47 -0
- package/src/types/navigation.ts +42 -0
- package/src/types/render.ts +86 -0
- package/src/types/resource.ts +185 -0
- package/src/types/services.ts +28 -0
- package/src/types/shell.ts +30 -0
- package/src/types/store.ts +148 -0
- package/src/types/stream.ts +24 -0
- package/src/types/style.ts +205 -0
- package/src/types/surface.ts +119 -0
- package/src/types/syntax.ts +93 -0
- package/src/types/terminal.ts +58 -0
- package/src/types/theme.ts +121 -0
- package/src/types/when.ts +21 -0
- package/src/ui/primitives.ts +118 -0
- package/src/ui/screen.ts +41 -0
- package/src/util/disposable.ts +49 -0
- package/src/util/markdown.ts +225 -0
- package/src/util/paths.ts +138 -0
- package/src/util/stream.ts +213 -0
- package/src/util/text.ts +428 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FocusableOptions, FocusDirection, FocusManager, FocusScopeOptions,
|
|
3
|
+
} from '../types/focus.js';
|
|
4
|
+
import type { KeyEvent } from '../types/input.js';
|
|
5
|
+
import type { Rect } from '../types/geometry.js';
|
|
6
|
+
import type { Disposable } from '../types/disposable.js';
|
|
7
|
+
import { toDisposable } from '../util/disposable.js';
|
|
8
|
+
import { rectContains } from '../types/geometry.js';
|
|
9
|
+
|
|
10
|
+
interface Scope extends FocusScopeOptions {
|
|
11
|
+
/** What was focused when this scope activated, for `restore`. */
|
|
12
|
+
restoreTo: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The scope every global handler lives in, always active and always last. */
|
|
16
|
+
export const GLOBAL_SCOPE = '__global__';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Focus.
|
|
20
|
+
*
|
|
21
|
+
* Tab order is registration order within a scope unless a node states one.
|
|
22
|
+
* Directional navigation is geometric, because in a terminal "the thing to the
|
|
23
|
+
* right" is a real question a table, a menu and a dashboard all ask, and none
|
|
24
|
+
* of them can answer it from document order.
|
|
25
|
+
*/
|
|
26
|
+
export class Focus implements FocusManager {
|
|
27
|
+
private scopes = new Map<string, Scope>();
|
|
28
|
+
private nodes = new Map<string, FocusableOptions>();
|
|
29
|
+
/** Registration order, which is tab order when nothing states otherwise. */
|
|
30
|
+
private order_: string[] = [];
|
|
31
|
+
private stack: string[] = [];
|
|
32
|
+
private current: string | null = null;
|
|
33
|
+
|
|
34
|
+
constructor(private onChange: () => void = () => {}) {
|
|
35
|
+
this.scopes.set(GLOBAL_SCOPE, { id: GLOBAL_SCOPE, priority: -1, restoreTo: null });
|
|
36
|
+
this.stack.push(GLOBAL_SCOPE);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ------------------------------------------------------------- scopes
|
|
40
|
+
|
|
41
|
+
registerScope(options: FocusScopeOptions): Disposable {
|
|
42
|
+
this.scopes.set(options.id, { ...options, restoreTo: null });
|
|
43
|
+
return toDisposable(() => {
|
|
44
|
+
this.deactivateScope(options.id);
|
|
45
|
+
this.scopes.delete(options.id);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
activateScope(id: string): void {
|
|
50
|
+
const scope = this.scopes.get(id);
|
|
51
|
+
if (!scope) return;
|
|
52
|
+
if (this.stack.includes(id)) return;
|
|
53
|
+
|
|
54
|
+
scope.restoreTo = this.current;
|
|
55
|
+
this.stack.push(id);
|
|
56
|
+
|
|
57
|
+
if (scope.autoFocus) {
|
|
58
|
+
const first = this.order(id)[0];
|
|
59
|
+
if (first) this.focus(first);
|
|
60
|
+
}
|
|
61
|
+
this.onChange();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
deactivateScope(id: string): void {
|
|
65
|
+
const index = this.stack.indexOf(id);
|
|
66
|
+
if (index <= 0) return;
|
|
67
|
+
const scope = this.scopes.get(id);
|
|
68
|
+
this.stack.splice(index, 1);
|
|
69
|
+
|
|
70
|
+
// A scope closing may only move focus it was holding. Blurring regardless
|
|
71
|
+
// took focus off whatever was driving the change - a sidebar that swaps
|
|
72
|
+
// the screen beside it lost focus on every arrow press, and the incoming
|
|
73
|
+
// screen then claimed it, so the sidebar could be moved exactly once.
|
|
74
|
+
// "Nothing holds focus" counts as held: a scope closing takes its own
|
|
75
|
+
// contents with it, so by the time this runs the thing it had focused has
|
|
76
|
+
// usually already unmounted. What must not count is focus sitting on
|
|
77
|
+
// something *outside* - that belongs to whoever put it there.
|
|
78
|
+
const held = this.current === null || this.scopeOf(this.current) === id;
|
|
79
|
+
|
|
80
|
+
if (scope?.restore && held) {
|
|
81
|
+
const target = scope.restoreTo;
|
|
82
|
+
scope.restoreTo = null;
|
|
83
|
+
if (target && this.nodes.has(target)) {
|
|
84
|
+
this.focus(target);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
this.blur();
|
|
88
|
+
} else if (scope?.restore) {
|
|
89
|
+
scope.restoreTo = null;
|
|
90
|
+
} else if (held) {
|
|
91
|
+
this.blur();
|
|
92
|
+
}
|
|
93
|
+
this.onChange();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
activeScope(): string | null {
|
|
97
|
+
const top = this.stack[this.stack.length - 1];
|
|
98
|
+
return top === GLOBAL_SCOPE ? null : top ?? null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The innermost active scope that traps, if any. */
|
|
102
|
+
private trappingScope(): string | null {
|
|
103
|
+
for (let i = this.stack.length - 1; i >= 0; i--) {
|
|
104
|
+
const id = this.stack[i] as string;
|
|
105
|
+
if (this.scopes.get(id)?.trap) return id;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Which scope a focusable belongs to. `GLOBAL_SCOPE` when it names none. */
|
|
111
|
+
scopeOf(nodeId: string): string {
|
|
112
|
+
return this.nodes.get(nodeId)?.scopeId ?? GLOBAL_SCOPE;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------- focusables
|
|
116
|
+
|
|
117
|
+
register(options: FocusableOptions): Disposable {
|
|
118
|
+
const existing = this.nodes.has(options.id);
|
|
119
|
+
this.nodes.set(options.id, options);
|
|
120
|
+
if (!existing) this.order_.push(options.id);
|
|
121
|
+
|
|
122
|
+
// A scope that asked to take focus takes it here, as its first control
|
|
123
|
+
// arrives - not when it was activated, because a scope is empty then. Its
|
|
124
|
+
// contents register on the way up, one effect at a time, and effects run
|
|
125
|
+
// parent-first, so `autoFocus` at activation could only ever have found
|
|
126
|
+
// something in a scope that already had something in it.
|
|
127
|
+
//
|
|
128
|
+
// Only when *nothing at all* holds focus. That is the difference between
|
|
129
|
+
// the screen this is for - pushed over whatever had focus, which the push
|
|
130
|
+
// unmounted, leaving none - and a dialog, which opens while its opener
|
|
131
|
+
// still holds focus and whose own controls say which of them wants it.
|
|
132
|
+
if (!existing && options.disabled !== true && this.current === null) {
|
|
133
|
+
const scopeId = options.scopeId ?? GLOBAL_SCOPE;
|
|
134
|
+
if (this.scopes.get(scopeId)?.autoFocus === true && this.stack.includes(scopeId)) {
|
|
135
|
+
this.focus(options.id);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return toDisposable(() => {
|
|
140
|
+
this.nodes.delete(options.id);
|
|
141
|
+
const i = this.order_.indexOf(options.id);
|
|
142
|
+
if (i >= 0) this.order_.splice(i, 1);
|
|
143
|
+
if (this.current === options.id) {
|
|
144
|
+
this.current = null;
|
|
145
|
+
this.onChange();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
update(id: string, patch: Partial<FocusableOptions>): void {
|
|
151
|
+
const node = this.nodes.get(id);
|
|
152
|
+
if (!node) return;
|
|
153
|
+
this.nodes.set(id, { ...node, ...patch });
|
|
154
|
+
|
|
155
|
+
// Focus cannot sit on something that has just been disabled. Nothing else
|
|
156
|
+
// would move it - the keys it would have handled are now nobody's - so the
|
|
157
|
+
// tab order is the only way out and this is what puts the reader back in
|
|
158
|
+
// it.
|
|
159
|
+
if (patch.disabled === true && this.current === id) {
|
|
160
|
+
this.current = null;
|
|
161
|
+
node.onBlur?.();
|
|
162
|
+
this.onChange();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
has(id: string): boolean {
|
|
167
|
+
return this.nodes.has(id);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
focus(id: string): boolean {
|
|
171
|
+
const node = this.nodes.get(id);
|
|
172
|
+
if (!node || node.disabled) return false;
|
|
173
|
+
if (this.current === id) return true;
|
|
174
|
+
|
|
175
|
+
const previous = this.current ? this.nodes.get(this.current) : undefined;
|
|
176
|
+
this.current = id;
|
|
177
|
+
previous?.onBlur?.();
|
|
178
|
+
node.onFocus?.();
|
|
179
|
+
this.onChange();
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
blur(): void {
|
|
184
|
+
if (!this.current) return;
|
|
185
|
+
const node = this.nodes.get(this.current);
|
|
186
|
+
this.current = null;
|
|
187
|
+
node?.onBlur?.();
|
|
188
|
+
this.onChange();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
focused(): string | null {
|
|
192
|
+
return this.current;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
chain(): string[] {
|
|
196
|
+
const out: string[] = [];
|
|
197
|
+
if (this.current) out.push(this.current);
|
|
198
|
+
for (let i = this.stack.length - 1; i >= 0; i--) out.push(this.stack[i] as string);
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Tab order: explicit `order` first, then registration order. */
|
|
203
|
+
order(scopeId?: string): string[] {
|
|
204
|
+
const trap = this.trappingScope();
|
|
205
|
+
const scope = scopeId ?? trap ?? undefined;
|
|
206
|
+
|
|
207
|
+
const candidates = this.order_
|
|
208
|
+
.map((id) => this.nodes.get(id))
|
|
209
|
+
.filter((n): n is FocusableOptions => {
|
|
210
|
+
if (!n || n.disabled || n.skipTab) return false;
|
|
211
|
+
if (scope === undefined) return true;
|
|
212
|
+
return (n.scopeId ?? GLOBAL_SCOPE) === scope;
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
return candidates
|
|
216
|
+
.map((n, i) => ({ n, i }))
|
|
217
|
+
.sort((a, b) => {
|
|
218
|
+
const ao = a.n.order ?? Number.MAX_SAFE_INTEGER;
|
|
219
|
+
const bo = b.n.order ?? Number.MAX_SAFE_INTEGER;
|
|
220
|
+
return ao === bo ? a.i - b.i : ao - bo;
|
|
221
|
+
})
|
|
222
|
+
.map((e) => e.n.id);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
move(direction: FocusDirection): string | null {
|
|
226
|
+
if (direction === 'next' || direction === 'previous') {
|
|
227
|
+
const list = this.order();
|
|
228
|
+
if (list.length === 0) return null;
|
|
229
|
+
const index = this.current ? list.indexOf(this.current) : -1;
|
|
230
|
+
const step = direction === 'next' ? 1 : -1;
|
|
231
|
+
const nextIndex = index === -1
|
|
232
|
+
? (direction === 'next' ? 0 : list.length - 1)
|
|
233
|
+
: (index + step + list.length) % list.length;
|
|
234
|
+
const id = list[nextIndex] as string;
|
|
235
|
+
return this.focus(id) ? id : null;
|
|
236
|
+
}
|
|
237
|
+
return this.moveDirectional(direction);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Geometric navigation. Candidates must lie in the requested direction; the
|
|
242
|
+
* winner is the nearest, with distance along the axis weighted more heavily
|
|
243
|
+
* than drift across it, so `right` from a table cell finds the next column
|
|
244
|
+
* rather than a distant button that happens to be closer in a straight line.
|
|
245
|
+
*/
|
|
246
|
+
private moveDirectional(direction: 'up' | 'down' | 'left' | 'right'): string | null {
|
|
247
|
+
const from = this.current ? this.nodes.get(this.current) : undefined;
|
|
248
|
+
const origin = from?.rect;
|
|
249
|
+
if (!origin) {
|
|
250
|
+
const first = this.order()[0];
|
|
251
|
+
return first && this.focus(first) ? first : null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const trap = this.trappingScope();
|
|
255
|
+
const cx = origin.x + origin.width / 2;
|
|
256
|
+
const cy = origin.y + origin.height / 2;
|
|
257
|
+
|
|
258
|
+
let best: { id: string; score: number } | null = null;
|
|
259
|
+
|
|
260
|
+
for (const node of this.nodes.values()) {
|
|
261
|
+
if (node.id === from.id || node.disabled || !node.rect) continue;
|
|
262
|
+
if (trap && (node.scopeId ?? GLOBAL_SCOPE) !== trap) continue;
|
|
263
|
+
|
|
264
|
+
const r = node.rect;
|
|
265
|
+
const nx = r.x + r.width / 2;
|
|
266
|
+
const ny = r.y + r.height / 2;
|
|
267
|
+
const dx = nx - cx;
|
|
268
|
+
const dy = ny - cy;
|
|
269
|
+
|
|
270
|
+
const along = direction === 'left' ? -dx : direction === 'right' ? dx
|
|
271
|
+
: direction === 'up' ? -dy : dy;
|
|
272
|
+
if (along <= 0) continue;
|
|
273
|
+
|
|
274
|
+
const across = direction === 'left' || direction === 'right'
|
|
275
|
+
? Math.abs(dy)
|
|
276
|
+
: Math.abs(dx);
|
|
277
|
+
|
|
278
|
+
const score = along + across * 3;
|
|
279
|
+
if (!best || score < best.score) best = { id: node.id, score };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!best) return null;
|
|
283
|
+
return this.focus(best.id) ? best.id : null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
at(x: number, y: number): string | null {
|
|
287
|
+
let found: string | null = null;
|
|
288
|
+
let smallest = Infinity;
|
|
289
|
+
for (const node of this.nodes.values()) {
|
|
290
|
+
if (node.disabled || !node.rect) continue;
|
|
291
|
+
if (!rectContains(node.rect as Rect, x, y)) continue;
|
|
292
|
+
// The innermost hit wins, and "innermost" is the smallest area.
|
|
293
|
+
const area = node.rect.width * node.rect.height;
|
|
294
|
+
if (area < smallest) {
|
|
295
|
+
smallest = area;
|
|
296
|
+
found = node.id;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return found;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Focused node first, then outward through active scopes, then global -
|
|
304
|
+
* except that a trap stops the walk at itself.
|
|
305
|
+
*
|
|
306
|
+
* `order` and `move` already honoured the trap, so tab could not leave a
|
|
307
|
+
* modal; keys could, and did. A menu bar whose labels live in the global
|
|
308
|
+
* scope kept receiving every arrow while its own dropdown was open, so down
|
|
309
|
+
* re-opened the menu instead of moving inside it, and a palette opened over
|
|
310
|
+
* the top inherited the same problem - two layers reading one keystroke, and
|
|
311
|
+
* two escapes needed to get out.
|
|
312
|
+
*
|
|
313
|
+
* A trap owns the keyboard. Nothing outside it is a target, including a
|
|
314
|
+
* focused node left over from before it opened.
|
|
315
|
+
*/
|
|
316
|
+
dispatch(event: KeyEvent): boolean {
|
|
317
|
+
const trap = this.trappingScope();
|
|
318
|
+
const inTrap = (node: { scopeId?: string }): boolean =>
|
|
319
|
+
trap === null || (node.scopeId ?? GLOBAL_SCOPE) === trap;
|
|
320
|
+
|
|
321
|
+
if (this.current) {
|
|
322
|
+
const node = this.nodes.get(this.current);
|
|
323
|
+
if (node && inTrap(node) && (node.onKey?.(event) === true || event.handled)) return true;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Then the handlers that are not about one control.
|
|
327
|
+
//
|
|
328
|
+
// Only those. This loop used to offer the event to every registered
|
|
329
|
+
// handler, focused or not, which quietly undid the line above it: a list
|
|
330
|
+
// moved on an arrow it never received, because the arrow reached it here.
|
|
331
|
+
// With nothing focused it went to whichever focusable registered first -
|
|
332
|
+
// so a page with two lists moved the earlier one, and a tab strip and a
|
|
333
|
+
// list beneath it split the arrow keys between them by accident. That
|
|
334
|
+
// reads as working until the second list exists.
|
|
335
|
+
for (let i = this.stack.length - 1; i >= 0; i--) {
|
|
336
|
+
const scopeId = this.stack[i] as string;
|
|
337
|
+
if (trap !== null && scopeId !== trap) continue;
|
|
338
|
+
for (const node of this.nodes.values()) {
|
|
339
|
+
if (node.id === this.current) continue;
|
|
340
|
+
if (node.global !== true) continue;
|
|
341
|
+
if ((node.scopeId ?? GLOBAL_SCOPE) !== scopeId) continue;
|
|
342
|
+
if (!node.onKey) continue;
|
|
343
|
+
if (node.onKey(event) === true || event.handled) return true;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Called by the renderer after layout, so hit-testing has real rects. */
|
|
350
|
+
setRect(id: string, rect: Rect): void {
|
|
351
|
+
const node = this.nodes.get(id);
|
|
352
|
+
if (node) node.rect = rect;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function createFocus(onChange?: () => void): Focus {
|
|
357
|
+
return new Focus(onChange);
|
|
358
|
+
}
|
package/src/core/i18n.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { I18n, LocaleId, TranslationBundle } from '../types/i18n.js';
|
|
2
|
+
import type { Disposable } from '../types/disposable.js';
|
|
3
|
+
import { toDisposable } from '../util/disposable.js';
|
|
4
|
+
|
|
5
|
+
function lookup(messages: Record<string, unknown>, key: string): string | undefined {
|
|
6
|
+
if (key in messages) {
|
|
7
|
+
const direct = messages[key];
|
|
8
|
+
return typeof direct === 'string' ? direct : undefined;
|
|
9
|
+
}
|
|
10
|
+
let cursor: unknown = messages;
|
|
11
|
+
for (const part of key.split('.')) {
|
|
12
|
+
if (cursor === null || typeof cursor !== 'object') return undefined;
|
|
13
|
+
cursor = (cursor as Record<string, unknown>)[part];
|
|
14
|
+
}
|
|
15
|
+
return typeof cursor === 'string' ? cursor : undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function interpolate(template: string, values: Record<string, unknown>): string {
|
|
19
|
+
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
|
|
20
|
+
name in values ? String(values[name]) : match,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Lightweight i18n. Formatting is `Intl`'s job - recreating number, date and
|
|
26
|
+
* list formatting would be a second, worse implementation of something the
|
|
27
|
+
* runtime already ships.
|
|
28
|
+
*/
|
|
29
|
+
export class I18nRegistry implements I18n {
|
|
30
|
+
locale: LocaleId;
|
|
31
|
+
private bundles = new Map<LocaleId, Record<string, unknown>>();
|
|
32
|
+
private listeners = new Set<(locale: LocaleId) => void>();
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
locale: LocaleId = 'en',
|
|
36
|
+
private fallbackLocale: LocaleId = 'en',
|
|
37
|
+
) {
|
|
38
|
+
this.locale = locale;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
setLocale(locale: LocaleId): void {
|
|
42
|
+
if (this.locale === locale) return;
|
|
43
|
+
this.locale = locale;
|
|
44
|
+
for (const fn of [...this.listeners]) fn(locale);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
register(bundle: TranslationBundle): Disposable {
|
|
48
|
+
const existing = this.bundles.get(bundle.locale) ?? {};
|
|
49
|
+
this.bundles.set(bundle.locale, { ...existing, ...bundle.messages });
|
|
50
|
+
if (this.locale === bundle.locale) {
|
|
51
|
+
for (const fn of [...this.listeners]) fn(this.locale);
|
|
52
|
+
}
|
|
53
|
+
return toDisposable(() => this.bundles.delete(bundle.locale));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
locales(): LocaleId[] {
|
|
57
|
+
return [...this.bundles.keys()];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Missing keys fall through to the fallback locale, then to the key. */
|
|
61
|
+
t(key: string, values?: Record<string, unknown>): string {
|
|
62
|
+
const primary = this.bundles.get(this.locale);
|
|
63
|
+
const base = this.locale.includes('-')
|
|
64
|
+
? this.bundles.get(this.locale.split('-')[0] as string)
|
|
65
|
+
: undefined;
|
|
66
|
+
const fallback = this.bundles.get(this.fallbackLocale);
|
|
67
|
+
|
|
68
|
+
const template =
|
|
69
|
+
(primary && lookup(primary, key)) ??
|
|
70
|
+
(base && lookup(base, key)) ??
|
|
71
|
+
(fallback && lookup(fallback, key)) ??
|
|
72
|
+
key;
|
|
73
|
+
|
|
74
|
+
return values ? interpolate(template, values) : template;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
number(value: number, options?: Intl.NumberFormatOptions): string {
|
|
78
|
+
return new Intl.NumberFormat(this.locale, options).format(value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
date(value: Date | number, options?: Intl.DateTimeFormatOptions): string {
|
|
82
|
+
return new Intl.DateTimeFormat(this.locale, options).format(value);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
relative(value: number, unit: Intl.RelativeTimeFormatUnit): string {
|
|
86
|
+
return new Intl.RelativeTimeFormat(this.locale, { numeric: 'auto' }).format(value, unit);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
list(items: string[], options?: Intl.ListFormatOptions): string {
|
|
90
|
+
return new Intl.ListFormat(this.locale, options).format(items);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
plural(count: number, forms: Record<string, string>): string {
|
|
94
|
+
const rule = new Intl.PluralRules(this.locale).select(count);
|
|
95
|
+
const template = forms[rule] ?? forms.other ?? '';
|
|
96
|
+
return interpolate(template, { count });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
onChange(fn: (locale: LocaleId) => void): Disposable {
|
|
100
|
+
this.listeners.add(fn);
|
|
101
|
+
return toDisposable(() => this.listeners.delete(fn));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function createI18n(locale?: string, fallback?: string): I18nRegistry {
|
|
106
|
+
return new I18nRegistry(locale, fallback);
|
|
107
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { Chord, KeybindingDefinition, KeybindingRegistry } from '../types/keybinding.js';
|
|
2
|
+
import type { KeyEvent } from '../types/input.js';
|
|
3
|
+
import type { Disposable } from '../types/disposable.js';
|
|
4
|
+
import type { WhenEngine } from '../types/when.js';
|
|
5
|
+
import type { CommandRegistry } from '../types/command.js';
|
|
6
|
+
import { toDisposable } from '../util/disposable.js';
|
|
7
|
+
|
|
8
|
+
/** `ctrl+shift+p` or a sequence: `ctrl+k ctrl+s`, `g g`. */
|
|
9
|
+
function parseChord(chord: Chord): string[] {
|
|
10
|
+
return chord.trim().split(/\s+/).map(normalizeStroke);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const MODIFIER = /^(ctrl|control|alt|option|shift|meta|cmd|super)\+/i;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Split one stroke into its modifiers and its key.
|
|
17
|
+
*
|
|
18
|
+
* Modifiers are consumed off the front by name and whatever survives is the
|
|
19
|
+
* key, verbatim. Splitting on `+` cannot work here: `+` is the separator and
|
|
20
|
+
* a key, so `'+'.split('+')` loses the only thing it was asked about. Parsing
|
|
21
|
+
* forwards means `+` is `+`, and `ctrl++` is ctrl and `+`.
|
|
22
|
+
*
|
|
23
|
+
* One parser, exported, because a chord that the test harness presses and a
|
|
24
|
+
* chord that the registry stored have to agree - and two implementations of
|
|
25
|
+
* one grammar disagree eventually.
|
|
26
|
+
*/
|
|
27
|
+
export function splitStroke(stroke: string): { mods: string[]; key: string } {
|
|
28
|
+
const mods: string[] = [];
|
|
29
|
+
let rest = stroke.trim();
|
|
30
|
+
for (let match = MODIFIER.exec(rest); match !== null; match = MODIFIER.exec(rest)) {
|
|
31
|
+
const [whole, name = ''] = match;
|
|
32
|
+
mods.push(name.toLowerCase());
|
|
33
|
+
rest = rest.slice(whole.length);
|
|
34
|
+
}
|
|
35
|
+
return { mods, key: rest };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The canonical stroke a chord is filed under. `strokeOf` must agree with it. */
|
|
39
|
+
export function normalizeStroke(stroke: string): string {
|
|
40
|
+
const { mods: names, key } = splitStroke(stroke);
|
|
41
|
+
const mods = new Set<string>();
|
|
42
|
+
for (const name of names) {
|
|
43
|
+
if (name === 'ctrl' || name === 'control') mods.add('ctrl');
|
|
44
|
+
else if (name === 'alt' || name === 'option') mods.add('alt');
|
|
45
|
+
else if (name === 'shift') mods.add('shift');
|
|
46
|
+
else mods.add('meta');
|
|
47
|
+
}
|
|
48
|
+
// Shift is not part of a stroke for a single character it *shifted*. A
|
|
49
|
+
// terminal reports a bare shift+p as the character `P`, and `strokeOf` reads
|
|
50
|
+
// it back that way, so `shift+p` would wait for a stroke no keypress makes.
|
|
51
|
+
//
|
|
52
|
+
// Held with another modifier it is the opposite. `ctrl+shift+p` cannot
|
|
53
|
+
// arrive as an uppercase letter, because a control code carries no case:
|
|
54
|
+
// a terminal speaking a keyboard protocol reports the *unshifted* key and a
|
|
55
|
+
// shift bit beside it. Dropping shift here filed `ctrl+shift+f` under
|
|
56
|
+
// `ctrl+f`, where it sat behind whichever binding registered first - so the
|
|
57
|
+
// workspace search had a documented key that ran the in-file search.
|
|
58
|
+
if ([...key].length === 1 && mods.size === 1 && mods.has('shift')) mods.delete('shift');
|
|
59
|
+
|
|
60
|
+
const order = ['ctrl', 'alt', 'shift', 'meta'].filter((m) => mods.has(m));
|
|
61
|
+
return [...order, key.toLowerCase()].join('+');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function strokeOf(event: KeyEvent): string {
|
|
65
|
+
const mods: string[] = [];
|
|
66
|
+
if (event.ctrl) mods.push('ctrl');
|
|
67
|
+
if (event.alt) mods.push('alt');
|
|
68
|
+
// Shift is implied by an uppercase character, so it is not named for a
|
|
69
|
+
// single character on its own. Beside another modifier nothing is implied -
|
|
70
|
+
// the name is the unshifted key - so there it has to be said. Must agree
|
|
71
|
+
// with `normalizeStroke`.
|
|
72
|
+
const modified = event.ctrl || event.alt || event.meta;
|
|
73
|
+
if (event.shift && (event.name.length > 1 || modified)) mods.push('shift');
|
|
74
|
+
if (event.meta) mods.push('meta');
|
|
75
|
+
return [...mods, event.name.toLowerCase()].join('+');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Keybindings resolve to commands, never to handlers, so the same action is
|
|
80
|
+
* reachable from a chord, a menu row and the palette without three copies.
|
|
81
|
+
* A partially typed chord is remembered, which is what makes `ctrl+k ctrl+s`
|
|
82
|
+
* possible without blocking `ctrl+k` on its own.
|
|
83
|
+
*/
|
|
84
|
+
export class Keybindings implements KeybindingRegistry {
|
|
85
|
+
private bindings: (KeybindingDefinition & { strokes: string[] })[] = [];
|
|
86
|
+
private pending: string[] = [];
|
|
87
|
+
private pendingTimer: ReturnType<typeof setTimeout> | null = null;
|
|
88
|
+
|
|
89
|
+
constructor(
|
|
90
|
+
private deps: {
|
|
91
|
+
when: WhenEngine;
|
|
92
|
+
commands: CommandRegistry;
|
|
93
|
+
activeScopes(): string[];
|
|
94
|
+
onError(err: unknown, context: string): void;
|
|
95
|
+
/** How long a half-typed chord waits for its next stroke. */
|
|
96
|
+
chordTimeoutMs?: number;
|
|
97
|
+
},
|
|
98
|
+
) {}
|
|
99
|
+
|
|
100
|
+
register(def: KeybindingDefinition): Disposable {
|
|
101
|
+
const entry = { ...def, strokes: parseChord(def.keys) };
|
|
102
|
+
this.bindings.push(entry);
|
|
103
|
+
this.bindings.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
104
|
+
return toDisposable(() => {
|
|
105
|
+
const i = this.bindings.indexOf(entry);
|
|
106
|
+
if (i >= 0) this.bindings.splice(i, 1);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
unregister(keys: Chord, commandId?: string): void {
|
|
111
|
+
const strokes = parseChord(keys).join(' ');
|
|
112
|
+
this.bindings = this.bindings.filter(
|
|
113
|
+
(b) => !(b.strokes.join(' ') === strokes && (commandId === undefined || b.commandId === commandId)),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
list(): KeybindingDefinition[] {
|
|
118
|
+
return this.bindings.map(({ strokes: _strokes, ...def }) => def);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
forCommand(commandId: string): Chord[] {
|
|
122
|
+
return this.bindings
|
|
123
|
+
// An argument-bearing binding is a key for one invocation, not for the
|
|
124
|
+
// command. `ctrl+shift+b` opens the palette at the sidebar panels; it is
|
|
125
|
+
// not the key for "Command Palette", and offering it as one put a wrong
|
|
126
|
+
// - and wider - hint beside that menu row.
|
|
127
|
+
.filter((b) => b.commandId === commandId && b.args === undefined)
|
|
128
|
+
.map((b) => b.keys);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private applicable(binding: KeybindingDefinition): boolean {
|
|
132
|
+
if (!this.deps.when.evaluate(binding.when)) return false;
|
|
133
|
+
if (!binding.scopeId) return true;
|
|
134
|
+
return this.deps.activeScopes().includes(binding.scopeId);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
handle(event: KeyEvent): 'handled' | 'pending' | 'unhandled' {
|
|
138
|
+
const stroke = strokeOf(event);
|
|
139
|
+
const sequence = [...this.pending, stroke];
|
|
140
|
+
|
|
141
|
+
let sawPrefix = false;
|
|
142
|
+
for (const binding of this.bindings) {
|
|
143
|
+
if (!this.applicable(binding)) continue;
|
|
144
|
+
|
|
145
|
+
const { strokes } = binding;
|
|
146
|
+
if (strokes.length === sequence.length && strokes.every((s, i) => s === sequence[i])) {
|
|
147
|
+
this.reset();
|
|
148
|
+
void this.deps.commands
|
|
149
|
+
.execute(binding.commandId, binding.args ?? {}, 'keybinding')
|
|
150
|
+
.catch((err: unknown) => this.deps.onError(err, `keybinding ${binding.keys}`));
|
|
151
|
+
return 'handled';
|
|
152
|
+
}
|
|
153
|
+
if (
|
|
154
|
+
strokes.length > sequence.length &&
|
|
155
|
+
sequence.every((s, i) => strokes[i] === s)
|
|
156
|
+
) {
|
|
157
|
+
sawPrefix = true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (sawPrefix) {
|
|
162
|
+
this.pending = sequence;
|
|
163
|
+
if (this.pendingTimer) clearTimeout(this.pendingTimer);
|
|
164
|
+
this.pendingTimer = setTimeout(() => this.reset(), this.deps.chordTimeoutMs ?? 1200);
|
|
165
|
+
this.pendingTimer.unref?.();
|
|
166
|
+
return 'pending';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
this.reset();
|
|
170
|
+
return 'unhandled';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
reset(): void {
|
|
174
|
+
this.pending = [];
|
|
175
|
+
if (this.pendingTimer) {
|
|
176
|
+
clearTimeout(this.pendingTimer);
|
|
177
|
+
this.pendingTimer = null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function createKeybindings(deps: ConstructorParameters<typeof Keybindings>[0]): Keybindings {
|
|
183
|
+
return new Keybindings(deps);
|
|
184
|
+
}
|