@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,846 @@
|
|
|
1
|
+
import { strokeOf } from '../core/keybindings.js';
|
|
2
|
+
import { overflowOn } from '../render/layout.js';
|
|
3
|
+
import { markDirty, readContext } from './instance.js';
|
|
4
|
+
import { resolvePath } from '../util/paths.js';
|
|
5
|
+
import { toStream } from '../util/stream.js';
|
|
6
|
+
import { GLOBAL_SCOPE } from '../core/focus.js';
|
|
7
|
+
import { plainTokens } from '../core/syntax.js';
|
|
8
|
+
import { CLIPBOARD_PATH, readClipboard, writeClipboard } from '../core/clipboard.js';
|
|
9
|
+
/**
|
|
10
|
+
* Hooks.
|
|
11
|
+
*
|
|
12
|
+
* The rules are React's, and for the same reason: slots are matched by call
|
|
13
|
+
* order, so a hook behind a condition breaks the instance it lives in. What is
|
|
14
|
+
* different is what they reach - the store, the focus manager, the command
|
|
15
|
+
* registry - because those, not component state, are where a terminal
|
|
16
|
+
* application actually keeps things.
|
|
17
|
+
*/
|
|
18
|
+
let current = null;
|
|
19
|
+
export function setCurrentInstance(instance) {
|
|
20
|
+
current = instance;
|
|
21
|
+
if (instance)
|
|
22
|
+
instance.hookIndex = 0;
|
|
23
|
+
}
|
|
24
|
+
export function currentInstance() {
|
|
25
|
+
if (!current) {
|
|
26
|
+
throw new Error('[textui] a hook was called outside a component render');
|
|
27
|
+
}
|
|
28
|
+
return current;
|
|
29
|
+
}
|
|
30
|
+
export function useRuntime() {
|
|
31
|
+
return currentInstance().runtime;
|
|
32
|
+
}
|
|
33
|
+
function slot(kind, init) {
|
|
34
|
+
const instance = currentInstance();
|
|
35
|
+
const index = instance.hookIndex++;
|
|
36
|
+
let entry = instance.hooks[index];
|
|
37
|
+
if (!entry) {
|
|
38
|
+
entry = { kind, value: init() };
|
|
39
|
+
instance.hooks[index] = entry;
|
|
40
|
+
}
|
|
41
|
+
else if (entry.kind !== kind) {
|
|
42
|
+
throw new Error(`[textui] hook order changed in <${instance.component}>: slot ${index} was ` +
|
|
43
|
+
`${entry.kind}, now ${kind}. A hook behind a condition does this.`);
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
value: entry.value,
|
|
47
|
+
write(v) {
|
|
48
|
+
instance.hooks[index].value = v;
|
|
49
|
+
},
|
|
50
|
+
instance,
|
|
51
|
+
index,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function invalidate(instance, reason) {
|
|
55
|
+
markDirty(instance, reason);
|
|
56
|
+
instance.runtime.requestRender();
|
|
57
|
+
}
|
|
58
|
+
export function useState(initial) {
|
|
59
|
+
const s = slot('state', () => ({
|
|
60
|
+
value: typeof initial === 'function' ? initial() : initial,
|
|
61
|
+
}));
|
|
62
|
+
const { instance } = s;
|
|
63
|
+
const set = (next) => {
|
|
64
|
+
const box = s.value;
|
|
65
|
+
const value = typeof next === 'function' ? next(box.value) : next;
|
|
66
|
+
if (Object.is(value, box.value))
|
|
67
|
+
return;
|
|
68
|
+
box.value = value;
|
|
69
|
+
invalidate(instance, 'useState');
|
|
70
|
+
};
|
|
71
|
+
return [s.value.value, set];
|
|
72
|
+
}
|
|
73
|
+
export function useReducer(reducer, initial) {
|
|
74
|
+
const [state, setState] = useState(initial);
|
|
75
|
+
return [state, (action) => setState((prev) => reducer(prev, action))];
|
|
76
|
+
}
|
|
77
|
+
export function useRef(initial) {
|
|
78
|
+
return slot('ref', () => ({ current: initial })).value;
|
|
79
|
+
}
|
|
80
|
+
function depsChanged(prev, next) {
|
|
81
|
+
if (!prev || !next)
|
|
82
|
+
return true;
|
|
83
|
+
if (prev.length !== next.length)
|
|
84
|
+
return true;
|
|
85
|
+
for (let i = 0; i < prev.length; i++) {
|
|
86
|
+
if (!Object.is(prev[i], next[i]))
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
export function useMemo(factory, deps) {
|
|
92
|
+
const instance = currentInstance();
|
|
93
|
+
const index = instance.hookIndex++;
|
|
94
|
+
const entry = instance.hooks[index];
|
|
95
|
+
if (!entry || entry.kind !== 'memo' || depsChanged(entry.deps, deps)) {
|
|
96
|
+
instance.hooks[index] = { kind: 'memo', value: factory(), deps: [...deps] };
|
|
97
|
+
}
|
|
98
|
+
return instance.hooks[index].value;
|
|
99
|
+
}
|
|
100
|
+
export function useCallback(fn, deps) {
|
|
101
|
+
return useMemo(() => fn, deps);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Runs after the frame is painted. The returned function runs before the next
|
|
105
|
+
* run and on unmount - so a subscription set up here is torn down exactly once.
|
|
106
|
+
*/
|
|
107
|
+
export function useEffect(effect, deps) {
|
|
108
|
+
const instance = currentInstance();
|
|
109
|
+
const index = instance.hookIndex++;
|
|
110
|
+
const entry = instance.hooks[index];
|
|
111
|
+
const changed = !entry || entry.kind !== 'effect' || deps === undefined || depsChanged(entry.deps, deps);
|
|
112
|
+
if (!changed)
|
|
113
|
+
return;
|
|
114
|
+
const previousCleanup = entry?.kind === 'effect' ? entry.cleanup : undefined;
|
|
115
|
+
instance.hooks[index] = { kind: 'effect', value: undefined, deps: deps ? [...deps] : undefined };
|
|
116
|
+
instance.pendingEffects.push(() => {
|
|
117
|
+
if (typeof previousCleanup === 'function') {
|
|
118
|
+
try {
|
|
119
|
+
previousCleanup();
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
instance.runtime.onError(err, `effect cleanup in <${instance.component}>`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const cleanup = effect();
|
|
127
|
+
instance.hooks[index].cleanup = cleanup;
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
instance.runtime.onError(err, `effect in <${instance.component}>`);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/** Same contract as `useEffect`, but flushed before the frame is painted. */
|
|
135
|
+
export function useLayoutEffect(effect, deps) {
|
|
136
|
+
useEffect(effect, deps);
|
|
137
|
+
}
|
|
138
|
+
let contextCounter = 0;
|
|
139
|
+
export function createContext(name, defaultValue) {
|
|
140
|
+
const id = `${name}#${++contextCounter}`;
|
|
141
|
+
const ctx = {
|
|
142
|
+
id,
|
|
143
|
+
defaultValue,
|
|
144
|
+
Provider: (props) => {
|
|
145
|
+
const instance = currentInstance();
|
|
146
|
+
if (!instance.contexts)
|
|
147
|
+
instance.contexts = new Map();
|
|
148
|
+
instance.contexts.set(id, props.value);
|
|
149
|
+
return props.children;
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
ctx.Provider.displayName = `${name}.Provider`;
|
|
153
|
+
return ctx;
|
|
154
|
+
}
|
|
155
|
+
export function useContext(context) {
|
|
156
|
+
const instance = currentInstance();
|
|
157
|
+
const found = readContext(instance, context.id);
|
|
158
|
+
return found === undefined ? context.defaultValue : found;
|
|
159
|
+
}
|
|
160
|
+
// ------------------------------------------------------------------ store
|
|
161
|
+
/** Subscribe to a path and read it. Shared by the two store hooks. */
|
|
162
|
+
function useStorePath(path) {
|
|
163
|
+
const instance = currentInstance();
|
|
164
|
+
const runtime = instance.runtime;
|
|
165
|
+
const absolute = resolvePath(path, instance.dataContext);
|
|
166
|
+
const value = runtime.store.get(absolute);
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
const sub = runtime.store.subscribe(absolute, () => invalidate(instance, `store ${absolute}`));
|
|
169
|
+
/*
|
|
170
|
+
* And catch a write that landed between the render and this line.
|
|
171
|
+
*
|
|
172
|
+
* Subscribing happens in an effect, and effects run after the render that
|
|
173
|
+
* asked for them - so a component that reads a path in the same frame that
|
|
174
|
+
* something else writes it has already missed the notification, and will
|
|
175
|
+
* never hear about that value again unless it happens to change twice.
|
|
176
|
+
*
|
|
177
|
+
* It is not a rare shape: a status bar reading which panel has the
|
|
178
|
+
* keyboard renders before the panel's own effect publishes it, and stayed
|
|
179
|
+
* empty for the life of the process.
|
|
180
|
+
*/
|
|
181
|
+
if (runtime.store.get(absolute) !== value)
|
|
182
|
+
invalidate(instance, `store ${absolute}`);
|
|
183
|
+
return () => sub.dispose();
|
|
184
|
+
}, [absolute]);
|
|
185
|
+
return { absolute, value };
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Store-backed state, in the shape of `useState`.
|
|
189
|
+
*
|
|
190
|
+
* The second argument is an initial value, and it behaves like one: if the
|
|
191
|
+
* path is empty the first time a component asks for it, it is written. That is
|
|
192
|
+
* what makes the hook safe to read like state - every other reader of the same
|
|
193
|
+
* path sees the same thing, immediately, rather than each one privately
|
|
194
|
+
* imagining its own default.
|
|
195
|
+
*
|
|
196
|
+
* The store stays authoritative. Copying a value out into `useState` and
|
|
197
|
+
* editing the copy creates a second answer to a question the store already
|
|
198
|
+
* answers; this hook is the way to avoid needing to.
|
|
199
|
+
*
|
|
200
|
+
* For a reader that must not write - a component displaying a path that
|
|
201
|
+
* something else owns - use `useStoreValue`, whose second argument is a
|
|
202
|
+
* display fallback and nothing more.
|
|
203
|
+
*/
|
|
204
|
+
export function useStore(path, initial) {
|
|
205
|
+
const instance = currentInstance();
|
|
206
|
+
const runtime = instance.runtime;
|
|
207
|
+
const { absolute, value } = useStorePath(path);
|
|
208
|
+
// Seeding during render rather than in an effect is deliberate: an effect
|
|
209
|
+
// runs after the frame, so the first frame would paint the empty state and
|
|
210
|
+
// every other reader would see a hole for one frame.
|
|
211
|
+
if (initial !== undefined && value === undefined && !runtime.store.has(absolute)) {
|
|
212
|
+
runtime.store.set(absolute, initial);
|
|
213
|
+
return [initial, (next) => runtime.store.set(absolute, next)];
|
|
214
|
+
}
|
|
215
|
+
return [
|
|
216
|
+
value === undefined ? initial : value,
|
|
217
|
+
(next) => runtime.store.set(absolute, next),
|
|
218
|
+
];
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Read a store path without ever writing it.
|
|
222
|
+
*
|
|
223
|
+
* `fallback` is what this reader shows while the path is empty. It is not an
|
|
224
|
+
* initial value and it is not shared: another component reading the same path
|
|
225
|
+
* still sees nothing.
|
|
226
|
+
*/
|
|
227
|
+
export function useStoreValue(path, fallback) {
|
|
228
|
+
const { value } = useStorePath(path);
|
|
229
|
+
return value === undefined ? fallback : value;
|
|
230
|
+
}
|
|
231
|
+
/** Subscribe to a whole subtree - a namespace, a collection, a scope. */
|
|
232
|
+
export function useStoreSubtree(path) {
|
|
233
|
+
const instance = currentInstance();
|
|
234
|
+
const runtime = instance.runtime;
|
|
235
|
+
const absolute = resolvePath(path, instance.dataContext);
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
const sub = runtime.store.subscribe(absolute, () => invalidate(instance, `store subtree ${absolute}`), { subtree: true });
|
|
238
|
+
return () => sub.dispose();
|
|
239
|
+
}, [absolute]);
|
|
240
|
+
return runtime.store.get(absolute);
|
|
241
|
+
}
|
|
242
|
+
export function useCollection(path) {
|
|
243
|
+
const instance = currentInstance();
|
|
244
|
+
const absolute = resolvePath(path, instance.dataContext);
|
|
245
|
+
useStoreValue(absolute);
|
|
246
|
+
return instance.runtime.store.collection(absolute);
|
|
247
|
+
}
|
|
248
|
+
// ----------------------------------------------------------------- events
|
|
249
|
+
export function useEvent(path, handler, options) {
|
|
250
|
+
const runtime = useRuntime();
|
|
251
|
+
const ref = useRef(handler);
|
|
252
|
+
ref.current = handler;
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
const sub = runtime.events.on(path, (payload) => ref.current(payload), options);
|
|
255
|
+
return () => sub.dispose();
|
|
256
|
+
}, [path, options?.subtree]);
|
|
257
|
+
}
|
|
258
|
+
export function useEmit() {
|
|
259
|
+
const runtime = useRuntime();
|
|
260
|
+
return (path, payload) => runtime.events.emit(path, payload);
|
|
261
|
+
}
|
|
262
|
+
// -------------------------------------------------- environment and theme
|
|
263
|
+
export function useApp() {
|
|
264
|
+
const app = useRuntime().app();
|
|
265
|
+
if (!app) {
|
|
266
|
+
throw new Error('[textui] useApp() outside an application - use useRuntime() instead');
|
|
267
|
+
}
|
|
268
|
+
return app;
|
|
269
|
+
}
|
|
270
|
+
export function useTheme() {
|
|
271
|
+
return useRuntime().theme();
|
|
272
|
+
}
|
|
273
|
+
export function useCapabilities() {
|
|
274
|
+
return useRuntime().capabilities();
|
|
275
|
+
}
|
|
276
|
+
/** Terminal size, re-rendering on resize. */
|
|
277
|
+
export function useSize() {
|
|
278
|
+
const instance = currentInstance();
|
|
279
|
+
const runtime = instance.runtime;
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
const sub = runtime.store.subscribe('$/modus/size', () => invalidate(instance, 'resize'), { subtree: true });
|
|
282
|
+
return () => sub.dispose();
|
|
283
|
+
}, []);
|
|
284
|
+
return runtime.size();
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Adapt to the space this component was actually given, not to the terminal.
|
|
288
|
+
* A sidebar and the main area are different widths on the same screen.
|
|
289
|
+
*/
|
|
290
|
+
/**
|
|
291
|
+
* The content rect this component was last laid out into.
|
|
292
|
+
*
|
|
293
|
+
* A component that fills the space it is given cannot size itself from its
|
|
294
|
+
* content - a file viewer that renders one row per line makes every pane
|
|
295
|
+
* around it move when a different file is opened. Measuring inverts that: the
|
|
296
|
+
* layout decides the size, and the component renders exactly what fits.
|
|
297
|
+
*
|
|
298
|
+
* The value is the previous frame's, and asking for it schedules another pass
|
|
299
|
+
* when it changed, so the first frame after a resize is one frame behind and
|
|
300
|
+
* every frame after it is exact.
|
|
301
|
+
*/
|
|
302
|
+
export function useMeasure() {
|
|
303
|
+
const instance = currentInstance();
|
|
304
|
+
measureWatchers.add(instance);
|
|
305
|
+
return instance.measured ?? EMPTY_RECT;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* How big the content is, when it is bigger than the box holding it.
|
|
309
|
+
*
|
|
310
|
+
* `null` when everything fits. The layout has always recorded this - the
|
|
311
|
+
* comment where it does says "so a scroll container knows how far it can go" -
|
|
312
|
+
* but nothing read it, so no scroll container knew, and every one of them
|
|
313
|
+
* scrolled for ever past its own last line.
|
|
314
|
+
*
|
|
315
|
+
* Reported for the nearest *scroll container* at or below this component's own
|
|
316
|
+
* box - a viewport is a row holding the scrolling part beside a scrollbar, and
|
|
317
|
+
* the row is not the part that scrolls.
|
|
318
|
+
*/
|
|
319
|
+
export function useScrollExtent() {
|
|
320
|
+
const instance = currentInstance();
|
|
321
|
+
measureWatchers.add(instance);
|
|
322
|
+
return instance.scrollExtent ?? null;
|
|
323
|
+
}
|
|
324
|
+
const EMPTY_RECT = { x: 0, y: 0, width: 0, height: 0 };
|
|
325
|
+
/** Instances that called `useMeasure`. Pruned as they unmount. */
|
|
326
|
+
const measureWatchers = new Set();
|
|
327
|
+
function firstHostBox(instance) {
|
|
328
|
+
if (instance.kind === 'host')
|
|
329
|
+
return instance.box;
|
|
330
|
+
for (const child of instance.children) {
|
|
331
|
+
const found = firstHostBox(child);
|
|
332
|
+
if (found)
|
|
333
|
+
return found;
|
|
334
|
+
}
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* The nearest box below this one that scrolls and has somewhere to scroll to.
|
|
339
|
+
*
|
|
340
|
+
* Not always the component's own first box: a viewport is usually a row
|
|
341
|
+
* holding the scrolling part beside a scrollbar, and it is the part, not the
|
|
342
|
+
* row, that overflows.
|
|
343
|
+
*
|
|
344
|
+
* It has to be a scroll container, not merely a box with more in it than fits.
|
|
345
|
+
* The layout records an extent on anything that overflows, including a row of
|
|
346
|
+
* text too wide for its pane - and a detail panel with one such row in it
|
|
347
|
+
* reported that row's width as its own scroll extent, which is a number about
|
|
348
|
+
* a different box on a different axis.
|
|
349
|
+
*/
|
|
350
|
+
function firstScrollingBox(box) {
|
|
351
|
+
if (!box)
|
|
352
|
+
return undefined;
|
|
353
|
+
if (overflowOn(box.style, 'y') === 'scroll' && box.scrollSize)
|
|
354
|
+
return box;
|
|
355
|
+
for (const child of box.children) {
|
|
356
|
+
const found = firstScrollingBox(child);
|
|
357
|
+
if (found)
|
|
358
|
+
return found;
|
|
359
|
+
}
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Publish every watcher's laid-out rect. Returns true when one changed, which
|
|
364
|
+
* means the frame is not final and the caller should render again.
|
|
365
|
+
*/
|
|
366
|
+
export function flushMeasures() {
|
|
367
|
+
if (measureWatchers.size === 0)
|
|
368
|
+
return false;
|
|
369
|
+
let changed = false;
|
|
370
|
+
for (const instance of measureWatchers) {
|
|
371
|
+
if (!instance.mounted) {
|
|
372
|
+
measureWatchers.delete(instance);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
const box = firstHostBox(instance);
|
|
376
|
+
const rect = box?.content;
|
|
377
|
+
if (!rect)
|
|
378
|
+
continue;
|
|
379
|
+
const extent = firstScrollingBox(box)?.scrollSize;
|
|
380
|
+
const was = instance.scrollExtent;
|
|
381
|
+
const extentSame = extent === undefined
|
|
382
|
+
? was === undefined
|
|
383
|
+
: was !== undefined && was.width === extent.width && was.height === extent.height;
|
|
384
|
+
const previous = instance.measured;
|
|
385
|
+
if (extentSame &&
|
|
386
|
+
previous &&
|
|
387
|
+
previous.x === rect.x && previous.y === rect.y &&
|
|
388
|
+
previous.width === rect.width && previous.height === rect.height) {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
instance.measured = { ...rect };
|
|
392
|
+
instance.scrollExtent = extent ? { ...extent } : undefined;
|
|
393
|
+
markDirty(instance, 'useMeasure');
|
|
394
|
+
changed = true;
|
|
395
|
+
}
|
|
396
|
+
return changed;
|
|
397
|
+
}
|
|
398
|
+
export function useBreakpoint(width, breakpoints = {}) {
|
|
399
|
+
const { compact = 60, minimal = 30 } = breakpoints;
|
|
400
|
+
if (width < minimal)
|
|
401
|
+
return 'minimal';
|
|
402
|
+
if (width < compact)
|
|
403
|
+
return 'compact';
|
|
404
|
+
return 'full';
|
|
405
|
+
}
|
|
406
|
+
export function useI18n() {
|
|
407
|
+
const instance = currentInstance();
|
|
408
|
+
const runtime = instance.runtime;
|
|
409
|
+
useEffect(() => {
|
|
410
|
+
const sub = runtime.i18n.onChange(() => invalidate(instance, 'locale'));
|
|
411
|
+
return () => sub.dispose();
|
|
412
|
+
}, []);
|
|
413
|
+
return runtime.i18n;
|
|
414
|
+
}
|
|
415
|
+
export function useService(key) {
|
|
416
|
+
return useRuntime().services.get(key);
|
|
417
|
+
}
|
|
418
|
+
export function useRequiredService(key) {
|
|
419
|
+
return useRuntime().services.require(key);
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* The context key a focus scope publishes itself under.
|
|
423
|
+
*
|
|
424
|
+
* Not a `createContext` value because nothing renders a provider: a scope is
|
|
425
|
+
* declared by a hook inside the component that owns it, and every focusable
|
|
426
|
+
* below it has to inherit the scope without being wrapped in anything.
|
|
427
|
+
*/
|
|
428
|
+
const FOCUS_SCOPE_CONTEXT = 'textui.focusScope';
|
|
429
|
+
/** The focus scope this instance sits inside, if any. */
|
|
430
|
+
export function focusScopeOf(instance) {
|
|
431
|
+
const found = readContext(instance, FOCUS_SCOPE_CONTEXT);
|
|
432
|
+
return typeof found === 'string' ? found : undefined;
|
|
433
|
+
}
|
|
434
|
+
export function useFocus(options = {}) {
|
|
435
|
+
const instance = currentInstance();
|
|
436
|
+
const runtime = instance.runtime;
|
|
437
|
+
const idRef = useRef(options.id ?? `${instance.id}:focus`);
|
|
438
|
+
const id = options.id ?? idRef.current;
|
|
439
|
+
// A control inside a dialog belongs to the dialog's scope. Registering in
|
|
440
|
+
// the global one instead is invisible until something traps focus, and then
|
|
441
|
+
// tab stops working entirely: the trap filters the tab order down to its own
|
|
442
|
+
// scope, and every control it contains has been filed somewhere else.
|
|
443
|
+
const scopeId = options.scopeId ?? focusScopeOf(instance);
|
|
444
|
+
useEffect(() => {
|
|
445
|
+
const registration = runtime.focus.register({
|
|
446
|
+
id,
|
|
447
|
+
disabled: options.disabled,
|
|
448
|
+
skipTab: options.skipTab,
|
|
449
|
+
order: options.order,
|
|
450
|
+
scopeId,
|
|
451
|
+
onFocus: () => {
|
|
452
|
+
invalidate(instance, 'focus');
|
|
453
|
+
options.onFocus?.();
|
|
454
|
+
},
|
|
455
|
+
onBlur: () => {
|
|
456
|
+
invalidate(instance, 'blur');
|
|
457
|
+
options.onBlur?.();
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
// `autoFocus` claims focus, it does not steal it. A prompt dialog has an
|
|
461
|
+
// auto-focused field *and* a default button, and whichever mounted last
|
|
462
|
+
// would otherwise win - which is how a text field ends up unfocused in the
|
|
463
|
+
// dialog that exists to ask for text.
|
|
464
|
+
if (options.autoFocus) {
|
|
465
|
+
const current = runtime.focus.focused();
|
|
466
|
+
const claimed = current !== null
|
|
467
|
+
&& runtime.focus.scopeOf(current) === (scopeId ?? GLOBAL_SCOPE);
|
|
468
|
+
if (!claimed)
|
|
469
|
+
runtime.focus.focus(id);
|
|
470
|
+
}
|
|
471
|
+
return () => registration.dispose();
|
|
472
|
+
// Only identity is a reason to register again. `disabled`, `skipTab` and
|
|
473
|
+
// `order` are *state on* a focusable, not a different focusable - and
|
|
474
|
+
// re-registering to change one costs the control its place in the tab
|
|
475
|
+
// order, because a registration that was disposed and made again goes on
|
|
476
|
+
// the end. A Submit button that is disabled until a field is filled in
|
|
477
|
+
// therefore ended up after Cancel the moment it became usable, which is
|
|
478
|
+
// the one control the reader was tabbing towards.
|
|
479
|
+
}, [id, scopeId]);
|
|
480
|
+
// The mutable half, pushed rather than re-registered.
|
|
481
|
+
useEffect(() => {
|
|
482
|
+
if (!runtime.focus.has(id))
|
|
483
|
+
return;
|
|
484
|
+
runtime.focus.update(id, {
|
|
485
|
+
disabled: options.disabled,
|
|
486
|
+
skipTab: options.skipTab,
|
|
487
|
+
order: options.order,
|
|
488
|
+
});
|
|
489
|
+
}, [id, options.disabled, options.skipTab, options.order]);
|
|
490
|
+
return {
|
|
491
|
+
id,
|
|
492
|
+
focused: runtime.focus.focused() === id,
|
|
493
|
+
focus: () => runtime.focus.focus(id),
|
|
494
|
+
blur: () => runtime.focus.blur(),
|
|
495
|
+
move: (direction) => runtime.focus.move(direction),
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
/** A focus scope. Modals trap; a sidebar does not. */
|
|
499
|
+
export function useFocusScope(options = {}) {
|
|
500
|
+
const instance = currentInstance();
|
|
501
|
+
const runtime = instance.runtime;
|
|
502
|
+
const idRef = useRef(options.id ?? `${instance.id}:scope`);
|
|
503
|
+
const id = options.id ?? idRef.current;
|
|
504
|
+
const active = options.active ?? true;
|
|
505
|
+
// Publish to the subtree during render, before any descendant registers.
|
|
506
|
+
if (!instance.contexts)
|
|
507
|
+
instance.contexts = new Map();
|
|
508
|
+
instance.contexts.set(FOCUS_SCOPE_CONTEXT, id);
|
|
509
|
+
useEffect(() => {
|
|
510
|
+
const registration = runtime.focus.registerScope({
|
|
511
|
+
id,
|
|
512
|
+
trap: options.trap,
|
|
513
|
+
restore: options.restore,
|
|
514
|
+
autoFocus: options.autoFocus,
|
|
515
|
+
});
|
|
516
|
+
if (active)
|
|
517
|
+
runtime.focus.activateScope(id);
|
|
518
|
+
return () => {
|
|
519
|
+
runtime.focus.deactivateScope(id);
|
|
520
|
+
registration.dispose();
|
|
521
|
+
};
|
|
522
|
+
}, [id, options.trap, options.restore, active]);
|
|
523
|
+
return id;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Keyboard input. Scoped to focus by default - a handler that fires while
|
|
527
|
+
* something else is focused is nearly always a bug, so `global` has to be
|
|
528
|
+
* asked for.
|
|
529
|
+
*/
|
|
530
|
+
/**
|
|
531
|
+
* A key that is not this control's to take.
|
|
532
|
+
*
|
|
533
|
+
* A list handles `pagedown`, and `ctrl+pagedown` is an application saying
|
|
534
|
+
* "next file" over the top of it - one is navigation inside the control, the
|
|
535
|
+
* other is a chord aimed past it. A control that switches on `event.name`
|
|
536
|
+
* alone takes both, and the application's binding then works everywhere except
|
|
537
|
+
* in the pane a person is actually looking at.
|
|
538
|
+
*/
|
|
539
|
+
export function chorded(event) {
|
|
540
|
+
return event.ctrl || event.alt || event.meta;
|
|
541
|
+
}
|
|
542
|
+
export function useInput(handler, options = {}) {
|
|
543
|
+
const instance = currentInstance();
|
|
544
|
+
const runtime = instance.runtime;
|
|
545
|
+
const ref = useRef(handler);
|
|
546
|
+
ref.current = handler;
|
|
547
|
+
const enabled = options.enabled ?? true;
|
|
548
|
+
const focusId = options.focusId ?? `${instance.id}:focus`;
|
|
549
|
+
useEffect(() => {
|
|
550
|
+
if (!enabled)
|
|
551
|
+
return;
|
|
552
|
+
const onKey = (event) => ref.current(event);
|
|
553
|
+
// A global handler is its own node. A scoped one attaches to the focusable
|
|
554
|
+
// this component already registered - re-registering the same id would
|
|
555
|
+
// replace it, quietly dropping its tab order and its focus callbacks.
|
|
556
|
+
if (options.global) {
|
|
557
|
+
// "Global" means the handler is not tied to a focusable, not that it
|
|
558
|
+
// outranks a modal. A component inside a trapping scope - or one that
|
|
559
|
+
// opened the trap itself, like the palette - files its handler there, or
|
|
560
|
+
// the trap that owns the keyboard would exclude the very keys the layer
|
|
561
|
+
// exists to read.
|
|
562
|
+
const registration = runtime.focus.register({
|
|
563
|
+
id: `${instance.id}:global`,
|
|
564
|
+
skipTab: true,
|
|
565
|
+
global: true,
|
|
566
|
+
scopeId: focusScopeOf(instance) ?? '__global__',
|
|
567
|
+
onKey,
|
|
568
|
+
});
|
|
569
|
+
return () => registration.dispose();
|
|
570
|
+
}
|
|
571
|
+
if (runtime.focus.has(focusId)) {
|
|
572
|
+
runtime.focus.update(focusId, { onKey });
|
|
573
|
+
return () => runtime.focus.update(focusId, { onKey: undefined });
|
|
574
|
+
}
|
|
575
|
+
const registration = runtime.focus.register({ id: focusId, onKey });
|
|
576
|
+
return () => registration.dispose();
|
|
577
|
+
}, [enabled, focusId, options.global]);
|
|
578
|
+
}
|
|
579
|
+
// --------------------------------------------------------------- commands
|
|
580
|
+
/**
|
|
581
|
+
* The screen this is drawn inside: which one, and what it was given.
|
|
582
|
+
*
|
|
583
|
+
* Reads the published entry rather than props, so a control eight levels down
|
|
584
|
+
* can ask which task it is showing without every box between it and the screen
|
|
585
|
+
* forwarding an id it does not care about.
|
|
586
|
+
*/
|
|
587
|
+
/**
|
|
588
|
+
* Keys, by the name they are written under everywhere else.
|
|
589
|
+
*
|
|
590
|
+
* `useInput` hands you a `KeyEvent` and leaves you to compare its fields,
|
|
591
|
+
* which is four lines of `event.ctrl && event.name === 's'` per key and a bug
|
|
592
|
+
* the first time somebody forgets that shift is implied by an uppercase
|
|
593
|
+
* letter. The keybinding registry already had the answer - one canonical
|
|
594
|
+
* spelling per stroke - so this reads the same strings.
|
|
595
|
+
*
|
|
596
|
+
* useKeymap({
|
|
597
|
+
* '+': () => setCount((c) => c + 1),
|
|
598
|
+
* '-': () => setCount((c) => c - 1),
|
|
599
|
+
* space: () => setRunning((r) => !r),
|
|
600
|
+
* 'ctrl+s': save,
|
|
601
|
+
* });
|
|
602
|
+
*
|
|
603
|
+
* Global by default, unlike `useInput`. A component that lists the keys it
|
|
604
|
+
* wants almost never also wants them to stop working the moment focus lands
|
|
605
|
+
* somewhere else - and the screens where that is wrong have a focusable to
|
|
606
|
+
* name, so they can say `{ global: false }` and mean it.
|
|
607
|
+
*
|
|
608
|
+
* A handler that returns nothing has handled the key. Return `false` to let
|
|
609
|
+
* it carry on to whatever is behind.
|
|
610
|
+
*/
|
|
611
|
+
export function useKeymap(map, options = {}) {
|
|
612
|
+
const ref = useRef(map);
|
|
613
|
+
ref.current = map;
|
|
614
|
+
useInput((event) => {
|
|
615
|
+
const handler = ref.current[strokeOf(event)];
|
|
616
|
+
if (!handler)
|
|
617
|
+
return;
|
|
618
|
+
// Silence is consent: a key you named is a key you meant to take.
|
|
619
|
+
return handler(event) ?? true;
|
|
620
|
+
}, { ...options, global: options.global ?? true });
|
|
621
|
+
}
|
|
622
|
+
export function useScreen() {
|
|
623
|
+
const id = useStoreValue('$/layout/screen/current', null);
|
|
624
|
+
const params = useStoreValue('$/layout/screen/params');
|
|
625
|
+
return { id: id ?? null, params: (params ?? {}) };
|
|
626
|
+
}
|
|
627
|
+
/** The stack, for a component that moves between screens. */
|
|
628
|
+
export function useNavigate() {
|
|
629
|
+
return useApp().screens;
|
|
630
|
+
}
|
|
631
|
+
export function useCommand(def, deps = []) {
|
|
632
|
+
const instance = currentInstance();
|
|
633
|
+
const runtime = instance.runtime;
|
|
634
|
+
const app = runtime.app();
|
|
635
|
+
useEffect(() => {
|
|
636
|
+
if (!app)
|
|
637
|
+
return;
|
|
638
|
+
const registration = app.commands.register({ ...def, scopeId: instance.id });
|
|
639
|
+
return () => registration.dispose();
|
|
640
|
+
}, [def.id, ...deps]);
|
|
641
|
+
}
|
|
642
|
+
export function useExecute() {
|
|
643
|
+
const runtime = useRuntime();
|
|
644
|
+
return (id, args) => runtime.execute(id, args);
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* An async unit of work with a lifecycle a component can render: idle,
|
|
648
|
+
* running, success, error, cancelled - plus progress and cancellation.
|
|
649
|
+
*/
|
|
650
|
+
export function useTask(fn, deps = []) {
|
|
651
|
+
const instance = currentInstance();
|
|
652
|
+
const [state, setState] = useState({ status: 'idle' });
|
|
653
|
+
const controller = useRef(null);
|
|
654
|
+
const fnRef = useRef(fn);
|
|
655
|
+
fnRef.current = fn;
|
|
656
|
+
useEffect(() => () => controller.current?.abort(), []);
|
|
657
|
+
const run = useCallback(async (...args) => {
|
|
658
|
+
controller.current?.abort();
|
|
659
|
+
const ac = new AbortController();
|
|
660
|
+
controller.current = ac;
|
|
661
|
+
setState({ status: 'running', startedAt: Date.now() });
|
|
662
|
+
try {
|
|
663
|
+
const value = await fnRef.current({
|
|
664
|
+
signal: ac.signal,
|
|
665
|
+
progress: (progress, step) => setState((prev) => ({ ...prev, progress, step })),
|
|
666
|
+
}, ...args);
|
|
667
|
+
if (ac.signal.aborted) {
|
|
668
|
+
setState((prev) => ({ ...prev, status: 'cancelled', finishedAt: Date.now() }));
|
|
669
|
+
return undefined;
|
|
670
|
+
}
|
|
671
|
+
setState({ status: 'success', data: value, finishedAt: Date.now() });
|
|
672
|
+
return value;
|
|
673
|
+
}
|
|
674
|
+
catch (error) {
|
|
675
|
+
if (ac.signal.aborted) {
|
|
676
|
+
setState((prev) => ({ ...prev, status: 'cancelled', finishedAt: Date.now() }));
|
|
677
|
+
return undefined;
|
|
678
|
+
}
|
|
679
|
+
setState({ status: 'error', error, finishedAt: Date.now() });
|
|
680
|
+
instance.runtime.onError(error, `task in <${instance.component}>`);
|
|
681
|
+
return undefined;
|
|
682
|
+
}
|
|
683
|
+
}, deps);
|
|
684
|
+
return {
|
|
685
|
+
...state,
|
|
686
|
+
run,
|
|
687
|
+
cancel: () => controller.current?.abort(),
|
|
688
|
+
reset: () => setState({ status: 'idle' }),
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
/** A task that runs on mount and can be refreshed. */
|
|
692
|
+
export function useResource(fn, deps = []) {
|
|
693
|
+
const task = useTask(fn, deps);
|
|
694
|
+
useEffect(() => {
|
|
695
|
+
void task.run();
|
|
696
|
+
}, deps);
|
|
697
|
+
return { ...task, refresh: () => void task.run() };
|
|
698
|
+
}
|
|
699
|
+
/** Read a resource through the registry, by URI. */
|
|
700
|
+
export function useResourceUri(uri) {
|
|
701
|
+
const app = useRuntime().app();
|
|
702
|
+
const task = useResource(async () => {
|
|
703
|
+
if (!uri || !app)
|
|
704
|
+
return null;
|
|
705
|
+
const resource = await app.resources.stat(uri);
|
|
706
|
+
if (!resource)
|
|
707
|
+
return null;
|
|
708
|
+
const content = resource.capabilities.includes('read')
|
|
709
|
+
? await app.resources.read(uri)
|
|
710
|
+
: null;
|
|
711
|
+
return { resource, content };
|
|
712
|
+
}, [uri]);
|
|
713
|
+
return {
|
|
714
|
+
resource: task.data?.resource ?? null,
|
|
715
|
+
content: task.data?.content ?? null,
|
|
716
|
+
status: task.status,
|
|
717
|
+
error: task.error,
|
|
718
|
+
refresh: task.refresh,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
// ---------------------------------------------------------------- syntax
|
|
722
|
+
/** The highlighter registry, or undefined outside an application. */
|
|
723
|
+
export function useSyntax() {
|
|
724
|
+
return useRuntime().app()?.syntax;
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Tokenise text for display, memoised on the text and the query.
|
|
728
|
+
*
|
|
729
|
+
* Nothing registered for this kind means one plain token per line, which is
|
|
730
|
+
* exactly what an uncoloured viewer wants - so a caller never branches on
|
|
731
|
+
* whether highlighting exists.
|
|
732
|
+
*/
|
|
733
|
+
export function useHighlight(text, query = {}) {
|
|
734
|
+
const syntax = useSyntax();
|
|
735
|
+
const { kind, uri, language } = query;
|
|
736
|
+
return useMemo(() => (syntax ? syntax.tokenize(text, { kind, uri, language }) : plainTokens(text)), [syntax, text, kind, uri, language]);
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* The clipboard, as a hook.
|
|
740
|
+
*
|
|
741
|
+
* `write` puts the text on the system clipboard as well, when the terminal
|
|
742
|
+
* can take it. Nothing reads the system clipboard back - see
|
|
743
|
+
* `core/clipboard.ts` for why - so a paste is whatever this application last
|
|
744
|
+
* copied, plus whatever the terminal delivers as a bracketed paste.
|
|
745
|
+
*/
|
|
746
|
+
export function useClipboard() {
|
|
747
|
+
const runtime = useRuntime();
|
|
748
|
+
const text = useStoreValue(CLIPBOARD_PATH, '') ?? '';
|
|
749
|
+
return {
|
|
750
|
+
text,
|
|
751
|
+
read: () => readClipboard(runtime.store),
|
|
752
|
+
write: (next) => writeClipboard(runtime.store, next, runtime.app()?.terminal),
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
// ---------------------------------------------------------------- streams
|
|
756
|
+
/** The most recent `limit` values from any stream source. */
|
|
757
|
+
export function useStream(source, options = {}) {
|
|
758
|
+
const instance = currentInstance();
|
|
759
|
+
const limit = options.limit ?? 500;
|
|
760
|
+
const buffer = useRef([]);
|
|
761
|
+
useEffect(() => {
|
|
762
|
+
if (!source)
|
|
763
|
+
return;
|
|
764
|
+
buffer.current = [];
|
|
765
|
+
const stream = toStream(source);
|
|
766
|
+
const sub = stream.subscribe({
|
|
767
|
+
next(value) {
|
|
768
|
+
buffer.current.push(value);
|
|
769
|
+
if (buffer.current.length > limit) {
|
|
770
|
+
buffer.current.splice(0, buffer.current.length - limit);
|
|
771
|
+
}
|
|
772
|
+
invalidate(instance, 'stream');
|
|
773
|
+
},
|
|
774
|
+
error(err) {
|
|
775
|
+
instance.runtime.onError(err, `stream in <${instance.component}>`);
|
|
776
|
+
},
|
|
777
|
+
});
|
|
778
|
+
return () => sub.dispose();
|
|
779
|
+
}, [source, limit]);
|
|
780
|
+
return buffer.current;
|
|
781
|
+
}
|
|
782
|
+
// -------------------------------------------------------------- animation
|
|
783
|
+
/** A frame ticker, throttled and globally disableable by the driver. */
|
|
784
|
+
export function useTicker(onTick, options = {}) {
|
|
785
|
+
const runtime = useRuntime();
|
|
786
|
+
const ref = useRef(onTick);
|
|
787
|
+
ref.current = onTick;
|
|
788
|
+
const enabled = options.enabled ?? true;
|
|
789
|
+
useEffect(() => {
|
|
790
|
+
if (!enabled)
|
|
791
|
+
return;
|
|
792
|
+
const ticker = runtime.animation.ticker({
|
|
793
|
+
fps: options.fps,
|
|
794
|
+
onTick: (frame, elapsed) => ref.current(frame, elapsed),
|
|
795
|
+
});
|
|
796
|
+
return () => ticker.dispose();
|
|
797
|
+
}, [enabled, options.fps]);
|
|
798
|
+
}
|
|
799
|
+
/** A frame counter, for spinners and marquees. Frozen when animation is off. */
|
|
800
|
+
export function useFrame(fps = 10) {
|
|
801
|
+
const instance = currentInstance();
|
|
802
|
+
const [frame, setFrame] = useState(0);
|
|
803
|
+
const disabled = instance.runtime.animation.disabled;
|
|
804
|
+
useTicker(() => setFrame((f) => f + 1), { fps, enabled: !disabled });
|
|
805
|
+
return disabled ? 0 : frame;
|
|
806
|
+
}
|
|
807
|
+
/** A value that eases towards its target. Snaps when animation is off. */
|
|
808
|
+
export function useTween(target, durationMs = 200) {
|
|
809
|
+
const runtime = useRuntime();
|
|
810
|
+
const [value, setValue] = useState(target);
|
|
811
|
+
const from = useRef(target);
|
|
812
|
+
useEffect(() => {
|
|
813
|
+
if (runtime.animation.disabled || durationMs <= 0) {
|
|
814
|
+
from.current = target;
|
|
815
|
+
setValue(target);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
const tween = runtime.animation.tween({
|
|
819
|
+
from: from.current,
|
|
820
|
+
to: target,
|
|
821
|
+
durationMs,
|
|
822
|
+
onUpdate: (v) => setValue(v),
|
|
823
|
+
onComplete: () => { from.current = target; },
|
|
824
|
+
});
|
|
825
|
+
return () => tween.dispose();
|
|
826
|
+
}, [target, durationMs]);
|
|
827
|
+
return runtime.animation.disabled ? target : value;
|
|
828
|
+
}
|
|
829
|
+
export function useInterval(fn, ms, enabled = true) {
|
|
830
|
+
const ref = useRef(fn);
|
|
831
|
+
ref.current = fn;
|
|
832
|
+
useEffect(() => {
|
|
833
|
+
if (!enabled || ms <= 0)
|
|
834
|
+
return;
|
|
835
|
+
const timer = setInterval(() => ref.current(), ms);
|
|
836
|
+
timer.unref?.();
|
|
837
|
+
return () => clearInterval(timer);
|
|
838
|
+
}, [ms, enabled]);
|
|
839
|
+
}
|
|
840
|
+
/** Register a disposable for the lifetime of this component. */
|
|
841
|
+
export function useDisposable(factory, deps = []) {
|
|
842
|
+
useEffect(() => {
|
|
843
|
+
const d = factory();
|
|
844
|
+
return () => d.dispose();
|
|
845
|
+
}, deps);
|
|
846
|
+
}
|