@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
package/src/app/app.ts
ADDED
|
@@ -0,0 +1,1096 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CreateAppOptions, InspectorNode, TextUIApp,
|
|
3
|
+
} from '../types/app.js';
|
|
4
|
+
import type { BindingPath, ComponentNode } from '../types/graph.js';
|
|
5
|
+
import type { Disposable } from '../types/disposable.js';
|
|
6
|
+
import type { ResourceAdapter } from '../types/adapter.js';
|
|
7
|
+
import type { Size, Rect } from '../types/geometry.js';
|
|
8
|
+
import type { ResolvedTheme } from '../types/theme.js';
|
|
9
|
+
import type { TerminalCapabilities, CapabilityOverrides } from '../types/capabilities.js';
|
|
10
|
+
import type { InputEvent, KeyEvent, MouseEvent } from '../types/input.js';
|
|
11
|
+
import type { LayerEntry } from '../types/layer.js';
|
|
12
|
+
import type { Instance } from '../runtime/instance.js';
|
|
13
|
+
import type { Runtime } from '../runtime/runtime.js';
|
|
14
|
+
import type { LayoutBox } from '../render/layout.js';
|
|
15
|
+
|
|
16
|
+
import { Buffer } from '../render/buffer.js';
|
|
17
|
+
import { diffFrame, type Frame } from '../render/diff.js';
|
|
18
|
+
import { layout } from '../render/layout.js';
|
|
19
|
+
import { buildBoxes, paintTree, type PaintEnv } from '../runtime/paint.js';
|
|
20
|
+
import { collectEffects, disposeTree, renderTree } from '../runtime/reconcile.js';
|
|
21
|
+
import { flushMeasures, focusScopeOf } from '../runtime/hooks.js';
|
|
22
|
+
import { walkInstances } from '../runtime/instance.js';
|
|
23
|
+
import type { InteractionState } from '../runtime/style.js';
|
|
24
|
+
|
|
25
|
+
import { serviceKey } from '../types/services.js';
|
|
26
|
+
import { createStore } from '../core/store.js';
|
|
27
|
+
import { createEvents } from '../core/events.js';
|
|
28
|
+
import { createWhen } from '../core/when.js';
|
|
29
|
+
import { createComponents } from '../core/components.js';
|
|
30
|
+
import { createServices } from '../core/services.js';
|
|
31
|
+
import { createCommands } from '../core/commands.js';
|
|
32
|
+
import { createKeybindings } from '../core/keybindings.js';
|
|
33
|
+
import { createFocus } from '../core/focus.js';
|
|
34
|
+
import { createLayers } from '../core/layers.js';
|
|
35
|
+
import { createAnimation } from '../core/animation.js';
|
|
36
|
+
import { createI18n } from '../core/i18n.js';
|
|
37
|
+
import { createSurfaces, createLayouts, createShells } from '../core/surfaces.js';
|
|
38
|
+
import { createNavigation } from '../core/navigation.js';
|
|
39
|
+
import { SCREEN_COMPONENTS } from '../ui/screen.js';
|
|
40
|
+
import type { ScreenEntry } from '../types/navigation.js';
|
|
41
|
+
import { createResources } from '../core/resources.js';
|
|
42
|
+
import { createSyntax } from '../core/syntax.js';
|
|
43
|
+
import { createManifests } from '../core/manifest.js';
|
|
44
|
+
import { createThemes } from '../themes/registry.js';
|
|
45
|
+
import { PRIMITIVES } from '../ui/primitives.js';
|
|
46
|
+
import { ZERO_EDGES } from '../types/geometry.js';
|
|
47
|
+
import { createBag } from '../util/disposable.js';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The application.
|
|
51
|
+
*
|
|
52
|
+
* Everything here is wiring: registries in, a frame loop, input routed to
|
|
53
|
+
* focus and commands, and deterministic teardown. The interesting decisions
|
|
54
|
+
* live in the pieces this assembles - what makes an app an app is that it owns
|
|
55
|
+
* a terminal and a clock.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
const FRAME_BUDGET_MS = 8;
|
|
59
|
+
|
|
60
|
+
/** The mount key an app's `root` option is opened under. */
|
|
61
|
+
const ROOT_KEY = 'root';
|
|
62
|
+
const SCREEN_KEY = 'screen';
|
|
63
|
+
|
|
64
|
+
/** Render/layout passes per frame. Measurement needs a second one. */
|
|
65
|
+
const MAX_LAYOUT_PASSES = 3;
|
|
66
|
+
|
|
67
|
+
export class App implements TextUIApp {
|
|
68
|
+
readonly store: ReturnType<typeof createStore>;
|
|
69
|
+
readonly events: ReturnType<typeof createEvents>;
|
|
70
|
+
readonly when: ReturnType<typeof createWhen>;
|
|
71
|
+
readonly components: ReturnType<typeof createComponents>;
|
|
72
|
+
readonly themes: ReturnType<typeof createThemes>;
|
|
73
|
+
readonly services: ReturnType<typeof createServices>;
|
|
74
|
+
readonly i18n: ReturnType<typeof createI18n>;
|
|
75
|
+
readonly layouts: ReturnType<typeof createLayouts>;
|
|
76
|
+
readonly shells: ReturnType<typeof createShells>;
|
|
77
|
+
readonly animation: ReturnType<typeof createAnimation>;
|
|
78
|
+
readonly focus: ReturnType<typeof createFocus>;
|
|
79
|
+
readonly layers: ReturnType<typeof createLayers>;
|
|
80
|
+
readonly commands: ReturnType<typeof createCommands>;
|
|
81
|
+
readonly keybindings: ReturnType<typeof createKeybindings>;
|
|
82
|
+
readonly resources: ReturnType<typeof createResources>;
|
|
83
|
+
readonly syntax: ReturnType<typeof createSyntax>;
|
|
84
|
+
readonly surfaces: ReturnType<typeof createSurfaces>;
|
|
85
|
+
readonly screens: ReturnType<typeof createNavigation>;
|
|
86
|
+
readonly manifest: ReturnType<typeof createManifests>;
|
|
87
|
+
readonly terminal: NonNullable<CreateAppOptions['terminal']>;
|
|
88
|
+
|
|
89
|
+
private buffer_: Buffer;
|
|
90
|
+
/** The mount holding the top of the screen stack, while there is one. */
|
|
91
|
+
private screenMount: Disposable | null = null;
|
|
92
|
+
private root: Instance | null = null;
|
|
93
|
+
private frameScheduled = false;
|
|
94
|
+
private frameTimer: ReturnType<typeof setTimeout> | null = null;
|
|
95
|
+
private running_ = false;
|
|
96
|
+
private disposed = false;
|
|
97
|
+
private themeId: string;
|
|
98
|
+
private shellId: string;
|
|
99
|
+
private resolvedTheme: ResolvedTheme;
|
|
100
|
+
private bag = createBag();
|
|
101
|
+
private hovered: string | null = null;
|
|
102
|
+
/** Focus registrations created from `focusable` props, by focus id. */
|
|
103
|
+
private declaredFocus = new Map<string, { instanceId: string; dispose(): void }>();
|
|
104
|
+
private lastFrame: Frame | null = null;
|
|
105
|
+
private renderCount = 0;
|
|
106
|
+
|
|
107
|
+
private runtime: Runtime;
|
|
108
|
+
|
|
109
|
+
constructor(private options: CreateAppOptions = {}) {
|
|
110
|
+
// Construction order is dependency order, and the comment is here because
|
|
111
|
+
// reordering these lines silently breaks the app rather than failing to
|
|
112
|
+
// compile: several of them capture `this` in closures.
|
|
113
|
+
this.store = createStore();
|
|
114
|
+
this.events = createEvents();
|
|
115
|
+
this.when = createWhen(this.store);
|
|
116
|
+
this.components = createComponents();
|
|
117
|
+
this.themes = createThemes(options.themes);
|
|
118
|
+
this.services = createServices();
|
|
119
|
+
this.i18n = createI18n(options.locale ?? 'en');
|
|
120
|
+
this.layouts = createLayouts();
|
|
121
|
+
this.shells = createShells();
|
|
122
|
+
|
|
123
|
+
this.animation = createAnimation({
|
|
124
|
+
enabled: options.animations ?? true,
|
|
125
|
+
maxFps: options.maxFps ?? 30,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Focus is published, not just held. A pane that wants to say "you are in
|
|
129
|
+
// here" would otherwise have to become a focusable itself just to be told
|
|
130
|
+
// when focus moved - which costs a tab stop for something that is not a
|
|
131
|
+
// control, and makes tabbing into a pane take two presses.
|
|
132
|
+
this.focus = createFocus(() => {
|
|
133
|
+
const id = this.focus.focused();
|
|
134
|
+
this.store.set('$/focus/id' as BindingPath, id);
|
|
135
|
+
this.store.set('$/focus/scope' as BindingPath, id ? this.focus.scopeOf(id) : null);
|
|
136
|
+
this.requestRender();
|
|
137
|
+
});
|
|
138
|
+
this.layers = createLayers(() => this.requestRender());
|
|
139
|
+
|
|
140
|
+
this.commands = createCommands({
|
|
141
|
+
store: this.store,
|
|
142
|
+
when: this.when,
|
|
143
|
+
app: () => this,
|
|
144
|
+
onError: (err, ctx) => this.handleError(err, ctx),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
this.keybindings = createKeybindings({
|
|
148
|
+
when: this.when,
|
|
149
|
+
commands: this.commands,
|
|
150
|
+
activeScopes: () => this.focus.chain(),
|
|
151
|
+
onError: (err, ctx) => this.handleError(err, ctx),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
this.resources = createResources({ components: this.components, when: this.when });
|
|
155
|
+
|
|
156
|
+
this.syntax = createSyntax({
|
|
157
|
+
kindMatches: (kind, ancestor) => this.resources.kindMatches(kind, ancestor),
|
|
158
|
+
onError: (err, ctx) => this.handleError(err, ctx),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
this.surfaces = createSurfaces({
|
|
162
|
+
store: this.store,
|
|
163
|
+
when: this.when,
|
|
164
|
+
resources: () => this.resources,
|
|
165
|
+
onChange: () => this.requestRender(),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
this.screens = createNavigation({
|
|
169
|
+
store: this.store,
|
|
170
|
+
focus: this.focus,
|
|
171
|
+
onChange: () => this.requestRender(),
|
|
172
|
+
mount: (entry) => this.mountScreen(entry),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
this.manifest = createManifests(this);
|
|
176
|
+
|
|
177
|
+
if (!options.terminal) this.requireTerminal();
|
|
178
|
+
this.terminal = options.terminal;
|
|
179
|
+
|
|
180
|
+
this.components.registerMany(PRIMITIVES);
|
|
181
|
+
// Registered here rather than by `registerBuiltins`, because an
|
|
182
|
+
// application that never registers the catalog can still navigate.
|
|
183
|
+
this.components.registerMany(SCREEN_COMPONENTS);
|
|
184
|
+
|
|
185
|
+
this.themeId = options.theme ?? 'dark';
|
|
186
|
+
this.shellId = options.shell ?? 'plain';
|
|
187
|
+
|
|
188
|
+
const size = this.terminal.size();
|
|
189
|
+
this.buffer_ = new Buffer(size.width, size.height);
|
|
190
|
+
this.resolvedTheme = this.themes.resolve(this.themeId, this.terminal.capabilities());
|
|
191
|
+
|
|
192
|
+
this.store.onError = (err, ctx) => this.handleError(err, ctx);
|
|
193
|
+
this.events.onError = (err, ctx) => this.handleError(err, ctx);
|
|
194
|
+
|
|
195
|
+
this.publishEnvironment();
|
|
196
|
+
|
|
197
|
+
this.runtime = {
|
|
198
|
+
store: this.store,
|
|
199
|
+
events: this.events,
|
|
200
|
+
when: this.when,
|
|
201
|
+
components: this.components,
|
|
202
|
+
services: this.services,
|
|
203
|
+
focus: this.focus,
|
|
204
|
+
layers: this.layers,
|
|
205
|
+
animation: this.animation,
|
|
206
|
+
i18n: this.i18n,
|
|
207
|
+
theme: () => this.resolvedTheme,
|
|
208
|
+
capabilities: () => this.terminal.capabilities(),
|
|
209
|
+
size: () => this.terminal.size(),
|
|
210
|
+
execute: (id, args) => this.commands.execute(id, args),
|
|
211
|
+
emit: (path, payload) => this.events.emit(path as `@/${string}`, payload),
|
|
212
|
+
requestRender: () => this.requestRender(),
|
|
213
|
+
app: () => this,
|
|
214
|
+
onError: (err, ctx) => this.handleError(err, ctx),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private requireTerminal(): never {
|
|
219
|
+
throw new Error(
|
|
220
|
+
'[textui] createApp needs a terminal adapter - pass one from @textui/terminal',
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ------------------------------------------------------------ environment
|
|
225
|
+
|
|
226
|
+
get capabilities(): TerminalCapabilities {
|
|
227
|
+
return this.terminal.capabilities();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
get theme(): ResolvedTheme {
|
|
231
|
+
return this.resolvedTheme;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
get size(): Size {
|
|
235
|
+
return this.terminal.size();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
get running(): boolean {
|
|
239
|
+
return this.running_;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private publishEnvironment(): void {
|
|
243
|
+
const size = this.terminal.size();
|
|
244
|
+
this.store.batch(() => {
|
|
245
|
+
this.store.set('$/modus/size', size);
|
|
246
|
+
this.store.set('$/modus/capabilities', this.terminal.capabilities());
|
|
247
|
+
this.store.set('$/modus/theme', this.themeId);
|
|
248
|
+
this.store.set('$/modus/locale', this.i18n.locale);
|
|
249
|
+
this.store.set('$/layout/shell', this.shellId);
|
|
250
|
+
// Named breakpoints, so a `when` clause reads well.
|
|
251
|
+
this.store.set('$/modus/class', size.width < 60 ? 'narrow' : size.width < 100 ? 'medium' : 'wide');
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
setTheme(id: string): void {
|
|
256
|
+
if (!this.themes.get(id)) {
|
|
257
|
+
throw new Error(`[textui] no theme registered as "${id}"`);
|
|
258
|
+
}
|
|
259
|
+
this.themeId = id;
|
|
260
|
+
this.resolvedTheme = this.themes.resolve(id, this.terminal.capabilities());
|
|
261
|
+
this.store.set('$/modus/theme', id);
|
|
262
|
+
this.buffer_.invalidate();
|
|
263
|
+
this.requestRender(true);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
setShell(id: string): void {
|
|
267
|
+
if (!this.shells.get(id)) {
|
|
268
|
+
throw new Error(`[textui] no shell registered as "${id}"`);
|
|
269
|
+
}
|
|
270
|
+
this.shellId = id;
|
|
271
|
+
this.store.set('$/layout/shell', id);
|
|
272
|
+
const shell = this.shells.get(id);
|
|
273
|
+
if (shell?.theme && this.themes.get(shell.theme)) this.setTheme(shell.theme);
|
|
274
|
+
this.buffer_.invalidate();
|
|
275
|
+
this.requestRender(true);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
activeShell(): string {
|
|
279
|
+
return this.shellId;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
setCapabilityOverrides(overrides: CapabilityOverrides): void {
|
|
283
|
+
this.terminal.setCapabilityOverrides(overrides);
|
|
284
|
+
this.resolvedTheme = this.themes.resolve(this.themeId, this.terminal.capabilities());
|
|
285
|
+
this.publishEnvironment();
|
|
286
|
+
this.buffer_.invalidate();
|
|
287
|
+
this.requestRender(true);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ------------------------------------------------------------- lifecycle
|
|
291
|
+
|
|
292
|
+
async start(): Promise<void> {
|
|
293
|
+
if (this.running_) return;
|
|
294
|
+
|
|
295
|
+
// A boot that hands back a disposable is asking for its registrations to
|
|
296
|
+
// come out again when the app stops, which is what `stop()` already does
|
|
297
|
+
// to everything else in the bag.
|
|
298
|
+
const booted = await this.options.onBoot?.(this);
|
|
299
|
+
if (booted) this.bag.add(booted);
|
|
300
|
+
await this.store.hydrate();
|
|
301
|
+
|
|
302
|
+
// `root` is a mount like any other, so the shell arranges it, the layouts
|
|
303
|
+
// apply to it, and everything that reads the surface registry sees it.
|
|
304
|
+
if (this.options.root && this.shells.get(this.shellId)) {
|
|
305
|
+
this.surfaces.open({ surface: 'main', key: ROOT_KEY, target: this.options.root });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// A shell may prefer a theme it was designed against. An explicit theme in
|
|
309
|
+
// the options always wins - the shell only fills in a default.
|
|
310
|
+
if (!this.options.theme) {
|
|
311
|
+
const shellTheme = this.shells.get(this.shellId)?.theme;
|
|
312
|
+
if (shellTheme && this.themes.get(shellTheme)) this.setTheme(shellTheme);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const caps = this.terminal.capabilities();
|
|
316
|
+
await this.terminal.acquire({
|
|
317
|
+
managed: true,
|
|
318
|
+
altScreen: true,
|
|
319
|
+
hideCursor: true,
|
|
320
|
+
paste: caps.paste,
|
|
321
|
+
mouse: caps.mouse,
|
|
322
|
+
wheel: caps.wheel,
|
|
323
|
+
focusEvents: caps.focusEvents,
|
|
324
|
+
enhancedKeys: caps.kittyKeyboard,
|
|
325
|
+
...this.options.session,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
this.bag.add(this.terminal.onInput((event) => this.handleInput(event)));
|
|
329
|
+
this.bag.add(this.terminal.onResize((size) => this.handleResize(size)));
|
|
330
|
+
|
|
331
|
+
this.running_ = true;
|
|
332
|
+
this.publishEnvironment();
|
|
333
|
+
this.renderFrame();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async stop(): Promise<void> {
|
|
337
|
+
if (!this.running_) return;
|
|
338
|
+
this.running_ = false;
|
|
339
|
+
|
|
340
|
+
if (this.frameTimer) clearTimeout(this.frameTimer);
|
|
341
|
+
this.frameTimer = null;
|
|
342
|
+
this.frameScheduled = false;
|
|
343
|
+
|
|
344
|
+
this.bag.dispose();
|
|
345
|
+
this.bag = createBag();
|
|
346
|
+
|
|
347
|
+
for (const entry of this.declaredFocus.values()) entry.dispose();
|
|
348
|
+
this.declaredFocus.clear();
|
|
349
|
+
|
|
350
|
+
// The terminal goes back first, before anything a component wrote to run
|
|
351
|
+
// on its way out.
|
|
352
|
+
//
|
|
353
|
+
// Disposing the tree runs every effect cleanup, and a cleanup that prints
|
|
354
|
+
// - which is how anyone debugs one - was printing into the alternate
|
|
355
|
+
// screen, which the terminal discards the moment we leave it. The log ran,
|
|
356
|
+
// did what it was told, and vanished. Releasing first puts the ordinary
|
|
357
|
+
// screen back, so `console.log` in a cleanup lands where a person can read
|
|
358
|
+
// it. A cleanup that paints instead is painting into a screen that is
|
|
359
|
+
// already gone either way.
|
|
360
|
+
await this.terminal.release();
|
|
361
|
+
|
|
362
|
+
if (this.root) {
|
|
363
|
+
disposeTree(this.root);
|
|
364
|
+
this.root = null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
for (const scope of this.options.clearOnStop ?? []) this.store.clearScope(scope);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
dispose(): void {
|
|
371
|
+
if (this.disposed) return;
|
|
372
|
+
this.disposed = true;
|
|
373
|
+
void this.stop();
|
|
374
|
+
this.animation.dispose();
|
|
375
|
+
this.layers.dispose();
|
|
376
|
+
this.events.dispose();
|
|
377
|
+
this.store.dispose();
|
|
378
|
+
this.terminal.dispose();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Swap the root node for another.
|
|
383
|
+
*
|
|
384
|
+
* Two paths, because there are two ways a root reaches the screen. With a
|
|
385
|
+
* shell registered it is a mount like any other and the surface registry
|
|
386
|
+
* owns it; with no shell at all - which is every application built out of
|
|
387
|
+
* primitives and nothing else - `rootNode` wraps `options.root` directly and
|
|
388
|
+
* the registry is never consulted. Setting one and not the other works in
|
|
389
|
+
* exactly half of the programs that can exist.
|
|
390
|
+
*/
|
|
391
|
+
setRoot(node: ComponentNode): void {
|
|
392
|
+
this.options = { ...this.options, root: node };
|
|
393
|
+
if (this.shells.get(this.shellId)) {
|
|
394
|
+
this.surfaces.open({ surface: 'main', key: ROOT_KEY, target: node });
|
|
395
|
+
}
|
|
396
|
+
this.requestRender(true);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---------------------------------------------------------------- frames
|
|
400
|
+
|
|
401
|
+
private requestRender(force = false): void {
|
|
402
|
+
if (force && this.root) {
|
|
403
|
+
walkInstances(this.root, (i) => {
|
|
404
|
+
i.dirty = true;
|
|
405
|
+
i.childDirty = true;
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
if (!this.running_ || this.frameScheduled) return;
|
|
409
|
+
this.frameScheduled = true;
|
|
410
|
+
|
|
411
|
+
// Coalesce a burst of state changes into one frame, and never render
|
|
412
|
+
// faster than the animation driver's ceiling.
|
|
413
|
+
const delay = Math.max(0, Math.floor(1000 / Math.max(1, this.animation.maxFps)) - FRAME_BUDGET_MS);
|
|
414
|
+
this.frameTimer = setTimeout(() => {
|
|
415
|
+
this.frameTimer = null;
|
|
416
|
+
this.frameScheduled = false;
|
|
417
|
+
this.renderFrame();
|
|
418
|
+
}, delay);
|
|
419
|
+
this.frameTimer.unref?.();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
flush(): void {
|
|
423
|
+
if (this.frameTimer) {
|
|
424
|
+
clearTimeout(this.frameTimer);
|
|
425
|
+
this.frameTimer = null;
|
|
426
|
+
}
|
|
427
|
+
this.frameScheduled = false;
|
|
428
|
+
this.renderFrame();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The tree the frame renders: the shell, always, when one is registered.
|
|
433
|
+
*
|
|
434
|
+
* `root` is an alternative to *screens*, not to the shell - it is mounted
|
|
435
|
+
* into `main` at boot. Returning it here instead meant an application built
|
|
436
|
+
* that way had no shell at all: no canvas background (so a light theme left
|
|
437
|
+
* the terminal's own dark one behind and only dialogs looked light), no
|
|
438
|
+
* status surface, no toast host, and `setShell` did nothing.
|
|
439
|
+
*/
|
|
440
|
+
/**
|
|
441
|
+
* Put the current screen into its surface.
|
|
442
|
+
*
|
|
443
|
+
* A screen is a mount like `root` is a mount: the shell arranges it, the
|
|
444
|
+
* layouts apply to it, and anything reading the surface registry sees it.
|
|
445
|
+
* Only the top of the stack is mounted - what a screen underneath keeps is
|
|
446
|
+
* its store scope, if it asked to, and not its instances.
|
|
447
|
+
*
|
|
448
|
+
* Parameters arrive as props. A screen that wants them deeper than its own
|
|
449
|
+
* signature reads `$/layout/screen/params` instead of forwarding them.
|
|
450
|
+
*/
|
|
451
|
+
private mountScreen(entry: ScreenEntry | null): void {
|
|
452
|
+
this.screenMount?.dispose();
|
|
453
|
+
this.screenMount = null;
|
|
454
|
+
if (!entry) return;
|
|
455
|
+
|
|
456
|
+
const def = this.screens.get(entry.id);
|
|
457
|
+
if (!def) return;
|
|
458
|
+
|
|
459
|
+
const node: ComponentNode = typeof def.component === 'string'
|
|
460
|
+
? { component: def.component, ...(entry.params ?? {}) }
|
|
461
|
+
: { ...def.component, ...(entry.params ?? {}) };
|
|
462
|
+
|
|
463
|
+
this.screenMount = this.surfaces.open({
|
|
464
|
+
surface: def.surface ?? 'main',
|
|
465
|
+
key: `${SCREEN_KEY}:${entry.id}`,
|
|
466
|
+
target: { component: 'Screen', screenId: entry.id, children: [node] },
|
|
467
|
+
...(def.display ? { display: def.display } : {}),
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private rootNode(): ComponentNode {
|
|
472
|
+
const shell = this.shells.get(this.shellId);
|
|
473
|
+
if (shell) return { component: shell.component };
|
|
474
|
+
|
|
475
|
+
// No shell registered at all: draw `root` on a themed canvas, so an
|
|
476
|
+
// application that registers nothing but primitives still works.
|
|
477
|
+
if (this.options.root) {
|
|
478
|
+
return {
|
|
479
|
+
component: 'box',
|
|
480
|
+
width: '100%',
|
|
481
|
+
height: '100%',
|
|
482
|
+
direction: 'column',
|
|
483
|
+
bg: 'canvas',
|
|
484
|
+
children: this.options.root,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
component: 'text',
|
|
490
|
+
content: `[textui] no shell registered as "${this.shellId}"`,
|
|
491
|
+
fg: 'danger',
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Layers are composed at the root rather than inside the tree, so an overlay
|
|
497
|
+
* is never clipped by whatever opened it.
|
|
498
|
+
*/
|
|
499
|
+
/**
|
|
500
|
+
* The root node: the shell, plus whatever is on the layers above it.
|
|
501
|
+
*
|
|
502
|
+
* The wrapper is unconditional, and that matters more than it looks. If the
|
|
503
|
+
* root were the bare shell whenever no layer is open, then opening the first
|
|
504
|
+
* toast would change the root's component - and a changed root is a full
|
|
505
|
+
* unmount and remount, so every screen would lose its state the moment
|
|
506
|
+
* anything notified it of anything. One shape, always.
|
|
507
|
+
*/
|
|
508
|
+
private composeRoot(): ComponentNode {
|
|
509
|
+
const entries = this.layers.entries().filter((e) => e.layer !== 'base');
|
|
510
|
+
const base = this.rootNode();
|
|
511
|
+
|
|
512
|
+
const children: ComponentNode[] = [
|
|
513
|
+
{ component: 'box', key: '__base__', position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, children: base },
|
|
514
|
+
];
|
|
515
|
+
|
|
516
|
+
const scrim = entries.find((e) => e.scrim);
|
|
517
|
+
if (scrim) {
|
|
518
|
+
children.push({
|
|
519
|
+
component: 'box',
|
|
520
|
+
key: '__scrim__',
|
|
521
|
+
position: 'absolute',
|
|
522
|
+
top: 0, left: 0, right: 0, bottom: 0,
|
|
523
|
+
// Washed, not covered: the screen behind a modal recedes and stays
|
|
524
|
+
// readable, instead of becoming a rectangle of nothing.
|
|
525
|
+
scrim: true,
|
|
526
|
+
zIndex: 50,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
for (const entry of entries) children.push(this.layerNode(entry));
|
|
531
|
+
|
|
532
|
+
return {
|
|
533
|
+
component: 'box',
|
|
534
|
+
key: '__root__',
|
|
535
|
+
width: '100%',
|
|
536
|
+
height: '100%',
|
|
537
|
+
children,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private layerNode(entry: LayerEntry): ComponentNode {
|
|
542
|
+
const size = this.terminal.size();
|
|
543
|
+
const position = entry.position ?? { kind: 'center' };
|
|
544
|
+
const zIndex = entry.layer === 'notification' ? 200 : entry.layer === 'modal' ? 100 : 60;
|
|
545
|
+
|
|
546
|
+
// Every layer gets a focus scope, so `trapFocus` is a fact rather than a
|
|
547
|
+
// flag. Without it a layer assembled from plain nodes cannot trap, and tab
|
|
548
|
+
// leaves the open thing on the first press.
|
|
549
|
+
const scoped: ComponentNode = {
|
|
550
|
+
component: 'LayerScope',
|
|
551
|
+
scopeId: entry.id,
|
|
552
|
+
trap: entry.trapFocus === true,
|
|
553
|
+
children: entry.node,
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
const wrap = (style: Record<string, unknown>): ComponentNode => ({
|
|
557
|
+
component: 'box',
|
|
558
|
+
key: entry.id,
|
|
559
|
+
position: 'absolute',
|
|
560
|
+
zIndex,
|
|
561
|
+
children: scoped,
|
|
562
|
+
...style,
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
switch (position.kind) {
|
|
566
|
+
case 'center':
|
|
567
|
+
// Centring without knowing the child's size means centring the band it
|
|
568
|
+
// sits in and letting the child align itself inside.
|
|
569
|
+
return wrap({
|
|
570
|
+
top: 0, left: 0, right: 0, bottom: 0,
|
|
571
|
+
align: 'center', justify: 'center',
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
case 'screen':
|
|
575
|
+
return wrap({
|
|
576
|
+
top: position.rect.y ?? 0,
|
|
577
|
+
left: position.rect.x ?? 0,
|
|
578
|
+
...(position.rect.width !== undefined ? { width: position.rect.width } : {}),
|
|
579
|
+
...(position.rect.height !== undefined ? { height: position.rect.height } : {}),
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
case 'point':
|
|
583
|
+
return wrap({ top: position.y, left: position.x });
|
|
584
|
+
|
|
585
|
+
case 'anchor': {
|
|
586
|
+
const rect = this.rectOf(position.targetId);
|
|
587
|
+
if (!rect) return wrap({ top: 0, left: 0 });
|
|
588
|
+
const offset = position.offset ?? 0;
|
|
589
|
+
const align = position.align ?? 'start';
|
|
590
|
+
|
|
591
|
+
if (position.side === 'bottom') {
|
|
592
|
+
return wrap({ top: rect.y + rect.height + offset, left: alignX(rect, align) });
|
|
593
|
+
}
|
|
594
|
+
if (position.side === 'top') {
|
|
595
|
+
return wrap({ bottom: Math.max(0, size.height - rect.y + offset), left: alignX(rect, align) });
|
|
596
|
+
}
|
|
597
|
+
if (position.side === 'right') {
|
|
598
|
+
return wrap({ top: rect.y, left: rect.x + rect.width + offset });
|
|
599
|
+
}
|
|
600
|
+
return wrap({ top: rect.y, right: Math.max(0, size.width - rect.x + offset) });
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
default:
|
|
604
|
+
return wrap({ top: 0, left: 0 });
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
private rectOf(focusId: string): Rect | null {
|
|
609
|
+
const order = this.focus.order();
|
|
610
|
+
if (!order.includes(focusId) && this.focus.focused() !== focusId) {
|
|
611
|
+
// The target may be non-tabbable but still registered.
|
|
612
|
+
}
|
|
613
|
+
let found: Rect | null = null;
|
|
614
|
+
if (this.root) {
|
|
615
|
+
walkInstances(this.root, (instance) => {
|
|
616
|
+
if (found) return;
|
|
617
|
+
if (instance.props.id === focusId && instance.box) found = instance.box.rect;
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
return found;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
private stateOf(instance: Instance): InteractionState {
|
|
624
|
+
const id = typeof instance.props.id === 'string' ? instance.props.id : instance.id;
|
|
625
|
+
const focusedId = this.focus.focused();
|
|
626
|
+
return {
|
|
627
|
+
focused: focusedId === id || focusedId === `${instance.id}:focus`,
|
|
628
|
+
hovered: this.hovered === id,
|
|
629
|
+
active: false,
|
|
630
|
+
selected: instance.props.selected === true,
|
|
631
|
+
disabled: instance.props.disabled === true,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
private renderFrame(): void {
|
|
636
|
+
if (!this.running_ || this.disposed) return;
|
|
637
|
+
|
|
638
|
+
const size = this.terminal.size();
|
|
639
|
+
if (size.width !== this.buffer_.width || size.height !== this.buffer_.height) {
|
|
640
|
+
this.buffer_.resize(size.width, size.height);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const env: PaintEnv = {
|
|
644
|
+
theme: this.resolvedTheme,
|
|
645
|
+
capabilities: this.terminal.capabilities(),
|
|
646
|
+
stateOf: (instance) => this.stateOf(instance),
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
const node = this.composeRoot();
|
|
650
|
+
const viewport = { x: 0, y: 0, width: size.width, height: size.height };
|
|
651
|
+
|
|
652
|
+
// Render, lay out, and hand every measured component its rect, then do it
|
|
653
|
+
// again if anything changed as a result. Two things change: a component
|
|
654
|
+
// that sized itself from its new rect, and an effect that ran during the
|
|
655
|
+
// pass - `autoFocus` is the one that shows, because painting before it
|
|
656
|
+
// lands means one frame of a dialog whose default button is not lit.
|
|
657
|
+
//
|
|
658
|
+
// Twice is the steady state; the bound is there for the pathological case
|
|
659
|
+
// where two of them chase each other.
|
|
660
|
+
for (let pass = 0; pass < MAX_LAYOUT_PASSES; pass++) {
|
|
661
|
+
try {
|
|
662
|
+
this.root = renderTree(this.runtime, this.root, node, {
|
|
663
|
+
diagnostics: this.options.diagnostics,
|
|
664
|
+
});
|
|
665
|
+
} catch (err) {
|
|
666
|
+
this.handleError(err, 'render');
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const effects = collectEffects(this.root);
|
|
671
|
+
for (const effect of effects) effect();
|
|
672
|
+
|
|
673
|
+
const boxes = buildBoxes(this.root, env);
|
|
674
|
+
const rootBox: LayoutBox = {
|
|
675
|
+
style: { direction: 'column' },
|
|
676
|
+
borderEdges: ZERO_EDGES,
|
|
677
|
+
children: boxes,
|
|
678
|
+
rect: { ...viewport },
|
|
679
|
+
content: { ...viewport },
|
|
680
|
+
};
|
|
681
|
+
layout(rootBox, viewport);
|
|
682
|
+
|
|
683
|
+
const remeasured = flushMeasures();
|
|
684
|
+
if (!remeasured && !this.isDirty()) break;
|
|
685
|
+
}
|
|
686
|
+
if (!this.root) return;
|
|
687
|
+
|
|
688
|
+
this.syncDeclaredFocusables();
|
|
689
|
+
this.updateFocusRects();
|
|
690
|
+
|
|
691
|
+
// Once. There were two, the first blanking to palette 0 and the second to
|
|
692
|
+
// the default background over the top of it - a whole extra pass over
|
|
693
|
+
// every cell on screen, every frame, with nothing to show for it.
|
|
694
|
+
this.buffer_.clear();
|
|
695
|
+
paintTree(this.buffer_, this.root, env, viewport);
|
|
696
|
+
|
|
697
|
+
const frame = diffFrame(this.buffer_, this.cursorPosition());
|
|
698
|
+
this.buffer_.commit();
|
|
699
|
+
this.lastFrame = frame;
|
|
700
|
+
this.renderCount++;
|
|
701
|
+
|
|
702
|
+
this.emitFrame(frame);
|
|
703
|
+
|
|
704
|
+
// An effect may have marked something dirty; give it the next frame.
|
|
705
|
+
if (this.isDirty()) this.requestRender();
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** Overridden by the test harness, which has no bytes to write. */
|
|
709
|
+
protected emitFrame(frame: Frame): void {
|
|
710
|
+
const writer = this.services.get(WRITER_KEY);
|
|
711
|
+
if (!writer) return;
|
|
712
|
+
const data = writer.write(frame);
|
|
713
|
+
if (data !== '') {
|
|
714
|
+
this.terminal.write(data);
|
|
715
|
+
void this.terminal.flush();
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
private cursorPosition(): { x: number; y: number; visible: boolean } | null {
|
|
720
|
+
const focused = this.focus.focused();
|
|
721
|
+
if (!focused || !this.root) return null;
|
|
722
|
+
|
|
723
|
+
let position: { x: number; y: number; visible: boolean } | null = null;
|
|
724
|
+
walkInstances(this.root, (instance) => {
|
|
725
|
+
if (position) return;
|
|
726
|
+
const cursor = instance.props.cursor;
|
|
727
|
+
if (!cursor || !instance.box) return;
|
|
728
|
+
const id = typeof instance.props.id === 'string' ? instance.props.id : instance.id;
|
|
729
|
+
if (id !== focused && `${instance.id}:focus` !== focused) return;
|
|
730
|
+
|
|
731
|
+
const offset = typeof cursor === 'number' ? cursor : 0;
|
|
732
|
+
position = {
|
|
733
|
+
x: instance.box.content.x + offset,
|
|
734
|
+
y: instance.box.content.y,
|
|
735
|
+
visible: true,
|
|
736
|
+
};
|
|
737
|
+
});
|
|
738
|
+
return position;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* `focusable` and `onKey` are props on every node, so a plain `box` can take
|
|
743
|
+
* focus without a hook. Those declarations are reconciled here rather than
|
|
744
|
+
* during render, because a node that has gone away must lose its
|
|
745
|
+
* registration - and only the render pass knows which are still mounted.
|
|
746
|
+
*/
|
|
747
|
+
private syncDeclaredFocusables(): void {
|
|
748
|
+
if (!this.root) return;
|
|
749
|
+
const seen = new Set<string>();
|
|
750
|
+
|
|
751
|
+
walkInstances(this.root, (instance) => {
|
|
752
|
+
if (instance.props.focusable !== true) return;
|
|
753
|
+
const id = typeof instance.props.id === 'string' ? instance.props.id : instance.id;
|
|
754
|
+
seen.add(id);
|
|
755
|
+
|
|
756
|
+
const onKey = typeof instance.props.onKey === 'function'
|
|
757
|
+
? (instance.props.onKey as (event: KeyEvent) => boolean | void)
|
|
758
|
+
: undefined;
|
|
759
|
+
const options = {
|
|
760
|
+
id,
|
|
761
|
+
disabled: instance.props.disabled === true,
|
|
762
|
+
skipTab: instance.props.skipTab === true,
|
|
763
|
+
global: instance.props.global === true,
|
|
764
|
+
order: typeof instance.props.order === 'number' ? instance.props.order : undefined,
|
|
765
|
+
scopeId: typeof instance.props.focusScope === 'string'
|
|
766
|
+
? instance.props.focusScope
|
|
767
|
+
: focusScopeOf(instance),
|
|
768
|
+
onKey,
|
|
769
|
+
rect: instance.box?.rect,
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
const existing = this.declaredFocus.get(id);
|
|
773
|
+
if (existing && existing.instanceId === instance.id) {
|
|
774
|
+
this.focus.update(id, options);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// A hook already owns this id: `useFocus` registered it and `useInput`
|
|
779
|
+
// put a handler on it. Registering over the top would replace that
|
|
780
|
+
// handler with this node's - usually with nothing - and the control
|
|
781
|
+
// would keep its focus ring while silently ignoring every key.
|
|
782
|
+
if (!existing && this.focus.has(id)) {
|
|
783
|
+
const { onKey: _declared, ...rest } = options;
|
|
784
|
+
this.focus.update(id, onKey ? { ...rest, onKey } : rest);
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
existing?.dispose();
|
|
789
|
+
this.declaredFocus.set(id, {
|
|
790
|
+
instanceId: instance.id,
|
|
791
|
+
dispose: this.focus.register(options).dispose,
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
if (instance.props.autoFocus === true && this.focus.focused() === null) {
|
|
795
|
+
this.focus.focus(id);
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
for (const [id, entry] of this.declaredFocus) {
|
|
800
|
+
if (seen.has(id)) continue;
|
|
801
|
+
entry.dispose();
|
|
802
|
+
this.declaredFocus.delete(id);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
private updateFocusRects(): void {
|
|
807
|
+
if (!this.root) return;
|
|
808
|
+
walkInstances(this.root, (instance) => {
|
|
809
|
+
if (!instance.box) return;
|
|
810
|
+
const id = typeof instance.props.id === 'string' ? instance.props.id : null;
|
|
811
|
+
if (id) this.focus.setRect(id, instance.box.rect);
|
|
812
|
+
this.focus.setRect(`${instance.id}:focus`, instance.box.rect);
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
private isDirty(): boolean {
|
|
817
|
+
const root = this.root;
|
|
818
|
+
return root ? root.dirty || root.childDirty : false;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
buffer(): Buffer {
|
|
822
|
+
return this.buffer_;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
frame(): Frame | null {
|
|
826
|
+
return this.lastFrame;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// ----------------------------------------------------------------- input
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Input is processed one event at a time, and the tree is re-rendered
|
|
833
|
+
* between events rather than once at the end of the batch.
|
|
834
|
+
*
|
|
835
|
+
* This matters more than it looks. A terminal delivers several keystrokes in
|
|
836
|
+
* a single read, and a handler closes over the props from its last render -
|
|
837
|
+
* so without settling in between, typing "ab" quickly makes the handler for
|
|
838
|
+
* "b" see the state from before "a", and the character is lost. Rendering
|
|
839
|
+
* per key is what every terminal application does, and the frame diff makes
|
|
840
|
+
* it cheap.
|
|
841
|
+
*/
|
|
842
|
+
handleInput(event: InputEvent): void {
|
|
843
|
+
// A handler that throws must not take the process with it. The screen is
|
|
844
|
+
// the output, so an uncaught error from a keystroke exits to a shell with
|
|
845
|
+
// a stack trace and no application - which is a worse answer than any
|
|
846
|
+
// wrong frame. It goes in the diagnostics like every other error.
|
|
847
|
+
try {
|
|
848
|
+
this.dispatchInput(event);
|
|
849
|
+
} catch (err) {
|
|
850
|
+
this.handleError(err, `input:${event.type}`);
|
|
851
|
+
}
|
|
852
|
+
if (this.running_ && this.isDirty()) this.renderFrame();
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
private dispatchInput(event: InputEvent): void {
|
|
856
|
+
switch (event.type) {
|
|
857
|
+
case 'key':
|
|
858
|
+
this.handleKey(event);
|
|
859
|
+
break;
|
|
860
|
+
case 'mouse':
|
|
861
|
+
this.handleMouse(event);
|
|
862
|
+
break;
|
|
863
|
+
case 'paste':
|
|
864
|
+
this.events.emit('@/input/paste', event.text);
|
|
865
|
+
this.focus.dispatch({
|
|
866
|
+
type: 'key', name: 'paste', char: event.text, raw: event.text,
|
|
867
|
+
ctrl: false, alt: false, shift: false, meta: false, handled: false,
|
|
868
|
+
});
|
|
869
|
+
break;
|
|
870
|
+
case 'terminal-focus':
|
|
871
|
+
this.store.set('$/modus/focused', event.focused);
|
|
872
|
+
break;
|
|
873
|
+
case 'resize':
|
|
874
|
+
this.handleResize({ width: event.width, height: event.height });
|
|
875
|
+
break;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Order matters. A focused text field must see a plain character before any
|
|
881
|
+
* keybinding does, or typing "q" in a search box quits the application. So
|
|
882
|
+
* the focused node gets first refusal, then chords, then global handlers.
|
|
883
|
+
*/
|
|
884
|
+
private handleKey(event: KeyEvent): void {
|
|
885
|
+
/*
|
|
886
|
+
* Every key, before anything decides what to do with it.
|
|
887
|
+
*
|
|
888
|
+
* "My binding does not fire" has two very different answers - the key
|
|
889
|
+
* never arrived, or something upstream took it - and from inside a
|
|
890
|
+
* full-screen application they look identical. A terminal that keeps
|
|
891
|
+
* `ctrl+s` for flow control, or an editor hosting the terminal that keeps
|
|
892
|
+
* it for itself, is invisible until the log can be asked whether the key
|
|
893
|
+
* was ever seen. `@/input/paste` was already here; this is the other half.
|
|
894
|
+
*/
|
|
895
|
+
this.events.emit('@/input/key', {
|
|
896
|
+
name: event.name,
|
|
897
|
+
...(event.ctrl ? { ctrl: true } : {}),
|
|
898
|
+
...(event.alt ? { alt: true } : {}),
|
|
899
|
+
...(event.shift ? { shift: true } : {}),
|
|
900
|
+
...(event.meta ? { meta: true } : {}),
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
const focusedNode = this.focus.focused();
|
|
904
|
+
|
|
905
|
+
if (focusedNode && this.focus.dispatch(event)) {
|
|
906
|
+
this.requestRender();
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
if (this.keybindings.handle(event) !== 'unhandled') {
|
|
911
|
+
this.requestRender();
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Escape closes the topmost dismissible layer, when nothing else took it.
|
|
916
|
+
if (event.name === 'escape') {
|
|
917
|
+
const top = this.layers.topmostDismissible();
|
|
918
|
+
if (top) {
|
|
919
|
+
this.layers.close(top.id, 'escape');
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
if (event.name === 'tab') {
|
|
925
|
+
this.focus.move(event.shift ? 'previous' : 'next');
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (!focusedNode && this.focus.dispatch(event)) this.requestRender();
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
private handleMouse(event: MouseEvent): void {
|
|
933
|
+
const hit = this.focus.at(event.x, event.y);
|
|
934
|
+
|
|
935
|
+
if (event.action === 'move') {
|
|
936
|
+
if (hit !== this.hovered) {
|
|
937
|
+
this.hovered = hit;
|
|
938
|
+
this.requestRender();
|
|
939
|
+
}
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
if (event.action === 'down' && hit) this.focus.focus(hit);
|
|
944
|
+
|
|
945
|
+
if (this.root) {
|
|
946
|
+
this.dispatchMouse(this.root, event);
|
|
947
|
+
}
|
|
948
|
+
this.requestRender();
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/** Innermost box under the pointer first, then outward. */
|
|
952
|
+
private dispatchMouse(instance: Instance, event: MouseEvent): boolean {
|
|
953
|
+
for (let i = instance.children.length - 1; i >= 0; i--) {
|
|
954
|
+
const child = instance.children[i] as Instance;
|
|
955
|
+
if (this.dispatchMouse(child, event)) return true;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const box = instance.box;
|
|
959
|
+
if (!box) return false;
|
|
960
|
+
const { x, y, width, height } = box.rect;
|
|
961
|
+
if (event.x < x || event.x >= x + width || event.y < y || event.y >= y + height) return false;
|
|
962
|
+
|
|
963
|
+
const onMouse = instance.props.onMouse;
|
|
964
|
+
if (typeof onMouse === 'function' && (onMouse as (e: MouseEvent) => boolean | void)(event) === true) {
|
|
965
|
+
return true;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
if (event.action === 'down' && event.button === 'left') {
|
|
969
|
+
const onClick = instance.props.onClick;
|
|
970
|
+
if (typeof onClick === 'function') {
|
|
971
|
+
(onClick as (e: MouseEvent) => void)(event);
|
|
972
|
+
return true;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
return false;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
private handleResize(size: Size): void {
|
|
979
|
+
this.buffer_.resize(size.width, size.height);
|
|
980
|
+
this.buffer_.invalidate();
|
|
981
|
+
this.publishEnvironment();
|
|
982
|
+
this.requestRender(true);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// ------------------------------------------------------------- shortcuts
|
|
986
|
+
|
|
987
|
+
open: TextUIApp['open'] = (mount) => this.surfaces.open(mount);
|
|
988
|
+
openResource: TextUIApp['openResource'] = (uri, options) =>
|
|
989
|
+
this.surfaces.openResource(uri, options);
|
|
990
|
+
/**
|
|
991
|
+
* Fan an adapter out across the registries it touches, and hand back one
|
|
992
|
+
* disposable for the lot. Order matters: kinds first, so a viewer registered
|
|
993
|
+
* for `file.data.json` has something to match before anything is classified.
|
|
994
|
+
*/
|
|
995
|
+
registerAdapter(adapter: ResourceAdapter): Disposable {
|
|
996
|
+
const bag = createBag();
|
|
997
|
+
|
|
998
|
+
for (const kind of adapter.kinds ?? []) bag.add(this.resources.registerKind(kind));
|
|
999
|
+
for (const provider of adapter.providers ?? []) bag.add(this.resources.registerProvider(provider));
|
|
1000
|
+
for (const component of adapter.components ?? []) bag.add(this.components.register(component));
|
|
1001
|
+
for (const highlighter of adapter.highlighters ?? []) bag.add(this.syntax.register(highlighter));
|
|
1002
|
+
for (const viewer of adapter.viewers ?? []) bag.add(this.resources.registerViewer(viewer));
|
|
1003
|
+
for (const editor of adapter.editors ?? []) bag.add(this.resources.registerEditor(editor));
|
|
1004
|
+
for (const action of adapter.actions ?? []) bag.add(this.resources.registerAction(action));
|
|
1005
|
+
for (const command of adapter.commands ?? []) bag.add(this.commands.register(command));
|
|
1006
|
+
for (const binding of adapter.keybindings ?? []) bag.add(this.keybindings.register(binding));
|
|
1007
|
+
|
|
1008
|
+
const extra = adapter.register?.(this);
|
|
1009
|
+
if (extra) bag.add(extra);
|
|
1010
|
+
|
|
1011
|
+
this.bag.add(bag);
|
|
1012
|
+
return bag;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
execute: TextUIApp['execute'] = (id, args, source) =>
|
|
1016
|
+
this.commands.execute(id, args, source);
|
|
1017
|
+
|
|
1018
|
+
// ------------------------------------------------------------- inspector
|
|
1019
|
+
|
|
1020
|
+
inspect(): InspectorNode | null {
|
|
1021
|
+
if (!this.root) return null;
|
|
1022
|
+
return describe(this.root, this.focus.focused());
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
stats(): { renders: number; runs: number; instances: number } {
|
|
1026
|
+
let instances = 0;
|
|
1027
|
+
if (this.root) walkInstances(this.root, () => { instances++; });
|
|
1028
|
+
return {
|
|
1029
|
+
renders: this.renderCount,
|
|
1030
|
+
runs: this.lastFrame?.runs.length ?? 0,
|
|
1031
|
+
instances,
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
private handleError(err: unknown, context: string): void {
|
|
1036
|
+
const message = err instanceof Error ? err.stack ?? err.message : String(err);
|
|
1037
|
+
this.store.collection('$/modus/diagnostics/errors').append({
|
|
1038
|
+
context,
|
|
1039
|
+
message,
|
|
1040
|
+
at: Date.now(),
|
|
1041
|
+
});
|
|
1042
|
+
this.store.collection('$/modus/diagnostics/errors').cap(50);
|
|
1043
|
+
if (!this.options.diagnostics) {
|
|
1044
|
+
console.error(`[textui] ${context}`, err);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function alignX(rect: Rect, align: 'start' | 'center' | 'end'): number {
|
|
1050
|
+
if (align === 'center') return rect.x + Math.floor(rect.width / 2);
|
|
1051
|
+
if (align === 'end') return rect.x + rect.width;
|
|
1052
|
+
return rect.x;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function describe(instance: Instance, focused: string | null): InspectorNode {
|
|
1056
|
+
const props: Record<string, unknown> = {};
|
|
1057
|
+
for (const [k, v] of Object.entries(instance.props)) {
|
|
1058
|
+
if (k === 'children') continue;
|
|
1059
|
+
props[k] = typeof v === 'function' ? '[function]' : v;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const id = typeof instance.props.id === 'string' ? instance.props.id : instance.id;
|
|
1063
|
+
const content = instance.props.content;
|
|
1064
|
+
|
|
1065
|
+
return {
|
|
1066
|
+
id: instance.id,
|
|
1067
|
+
component: instance.component,
|
|
1068
|
+
key: instance.key,
|
|
1069
|
+
rect: instance.box?.rect,
|
|
1070
|
+
props,
|
|
1071
|
+
role: typeof instance.props.role === 'string' ? instance.props.role : undefined,
|
|
1072
|
+
label: typeof instance.props.label === 'string' ? instance.props.label : undefined,
|
|
1073
|
+
text: typeof content === 'string' ? content : undefined,
|
|
1074
|
+
focusable: instance.props.focusable === true,
|
|
1075
|
+
focused: focused === id || focused === `${instance.id}:focus`,
|
|
1076
|
+
renderReason: instance.renderReason,
|
|
1077
|
+
bindings: instance.reads.size > 0 ? [...instance.reads] : undefined,
|
|
1078
|
+
children: instance.children.map((child) => describe(child, focused)),
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* The frame writer is a service rather than an import, so core never depends
|
|
1084
|
+
* on terminal encoding - the test harness and the static renderer simply do
|
|
1085
|
+
* not provide one.
|
|
1086
|
+
*/
|
|
1087
|
+
export interface FrameWriter {
|
|
1088
|
+
write(frame: Frame): string;
|
|
1089
|
+
invalidate(): void;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
export const WRITER_KEY = serviceKey<FrameWriter>('textui.writer');
|
|
1093
|
+
|
|
1094
|
+
export function createApp(options: CreateAppOptions = {}): App {
|
|
1095
|
+
return new App(options);
|
|
1096
|
+
}
|