@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.
Files changed (323) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +67 -0
  3. package/dist/adapters/index.d.ts +12 -0
  4. package/dist/adapters/index.d.ts.map +1 -0
  5. package/dist/adapters/index.js +11 -0
  6. package/dist/app/app.d.ts +200 -0
  7. package/dist/app/app.d.ts.map +1 -0
  8. package/dist/app/app.js +980 -0
  9. package/dist/core/animation.d.ts +34 -0
  10. package/dist/core/animation.d.ts.map +1 -0
  11. package/dist/core/animation.js +125 -0
  12. package/dist/core/clipboard.d.ts +29 -0
  13. package/dist/core/clipboard.d.ts.map +1 -0
  14. package/dist/core/clipboard.js +30 -0
  15. package/dist/core/commands.d.ts +42 -0
  16. package/dist/core/commands.d.ts.map +1 -0
  17. package/dist/core/commands.js +146 -0
  18. package/dist/core/components.d.ts +27 -0
  19. package/dist/core/components.d.ts.map +1 -0
  20. package/dist/core/components.js +78 -0
  21. package/dist/core/events.d.ts +23 -0
  22. package/dist/core/events.d.ts.map +1 -0
  23. package/dist/core/events.js +77 -0
  24. package/dist/core/focus.d.ts +69 -0
  25. package/dist/core/focus.d.ts.map +1 -0
  26. package/dist/core/focus.js +336 -0
  27. package/dist/core/i18n.d.ts +27 -0
  28. package/dist/core/i18n.d.ts.map +1 -0
  29. package/dist/core/i18n.js +88 -0
  30. package/dist/core/keybindings.d.ts +53 -0
  31. package/dist/core/keybindings.d.ts.map +1 -0
  32. package/dist/core/keybindings.js +163 -0
  33. package/dist/core/layers.d.ts +28 -0
  34. package/dist/core/layers.d.ts.map +1 -0
  35. package/dist/core/layers.js +84 -0
  36. package/dist/core/manifest.d.ts +22 -0
  37. package/dist/core/manifest.d.ts.map +1 -0
  38. package/dist/core/manifest.js +85 -0
  39. package/dist/core/navigation.d.ts +47 -0
  40. package/dist/core/navigation.d.ts.map +1 -0
  41. package/dist/core/navigation.js +110 -0
  42. package/dist/core/resources.d.ts +115 -0
  43. package/dist/core/resources.d.ts.map +1 -0
  44. package/dist/core/resources.js +321 -0
  45. package/dist/core/services.d.ts +21 -0
  46. package/dist/core/services.d.ts.map +1 -0
  47. package/dist/core/services.js +63 -0
  48. package/dist/core/store.d.ts +60 -0
  49. package/dist/core/store.d.ts.map +1 -0
  50. package/dist/core/store.js +593 -0
  51. package/dist/core/surfaces.d.ts +81 -0
  52. package/dist/core/surfaces.d.ts.map +1 -0
  53. package/dist/core/surfaces.js +237 -0
  54. package/dist/core/syntax.d.ts +49 -0
  55. package/dist/core/syntax.d.ts.map +1 -0
  56. package/dist/core/syntax.js +172 -0
  57. package/dist/core/when.d.ts +15 -0
  58. package/dist/core/when.d.ts.map +1 -0
  59. package/dist/core/when.js +224 -0
  60. package/dist/index.d.ts +49 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +54 -0
  63. package/dist/jsx/factory.d.ts +27 -0
  64. package/dist/jsx/factory.d.ts.map +1 -0
  65. package/dist/jsx/factory.js +102 -0
  66. package/dist/jsx/intrinsics.d.ts +96 -0
  67. package/dist/jsx/intrinsics.d.ts.map +1 -0
  68. package/dist/jsx/intrinsics.js +1 -0
  69. package/dist/jsx/jsx-dev-runtime.d.ts +13 -0
  70. package/dist/jsx/jsx-dev-runtime.d.ts.map +1 -0
  71. package/dist/jsx/jsx-dev-runtime.js +12 -0
  72. package/dist/jsx/jsx-runtime.d.ts +42 -0
  73. package/dist/jsx/jsx-runtime.d.ts.map +1 -0
  74. package/dist/jsx/jsx-runtime.js +16 -0
  75. package/dist/render/buffer.d.ts +77 -0
  76. package/dist/render/buffer.d.ts.map +1 -0
  77. package/dist/render/buffer.js +275 -0
  78. package/dist/render/color.d.ts +35 -0
  79. package/dist/render/color.d.ts.map +1 -0
  80. package/dist/render/color.js +160 -0
  81. package/dist/render/diff.d.ts +37 -0
  82. package/dist/render/diff.d.ts.map +1 -0
  83. package/dist/render/diff.js +66 -0
  84. package/dist/render/layout.d.ts +72 -0
  85. package/dist/render/layout.d.ts.map +1 -0
  86. package/dist/render/layout.js +633 -0
  87. package/dist/render/static.d.ts +63 -0
  88. package/dist/render/static.d.ts.map +1 -0
  89. package/dist/render/static.js +209 -0
  90. package/dist/runtime/bindings.d.ts +46 -0
  91. package/dist/runtime/bindings.d.ts.map +1 -0
  92. package/dist/runtime/bindings.js +116 -0
  93. package/dist/runtime/hooks.d.ts +284 -0
  94. package/dist/runtime/hooks.d.ts.map +1 -0
  95. package/dist/runtime/hooks.js +846 -0
  96. package/dist/runtime/instance.d.ts +106 -0
  97. package/dist/runtime/instance.d.ts.map +1 -0
  98. package/dist/runtime/instance.js +181 -0
  99. package/dist/runtime/paint.d.ts +27 -0
  100. package/dist/runtime/paint.d.ts.map +1 -0
  101. package/dist/runtime/paint.js +567 -0
  102. package/dist/runtime/reconcile.d.ts +23 -0
  103. package/dist/runtime/reconcile.d.ts.map +1 -0
  104. package/dist/runtime/reconcile.js +260 -0
  105. package/dist/runtime/runtime.d.ts +42 -0
  106. package/dist/runtime/runtime.d.ts.map +1 -0
  107. package/dist/runtime/runtime.js +1 -0
  108. package/dist/runtime/style.d.ts +55 -0
  109. package/dist/runtime/style.d.ts.map +1 -0
  110. package/dist/runtime/style.js +143 -0
  111. package/dist/themes/borders.d.ts +10 -0
  112. package/dist/themes/borders.d.ts.map +1 -0
  113. package/dist/themes/borders.js +88 -0
  114. package/dist/themes/builtin.d.ts +31 -0
  115. package/dist/themes/builtin.d.ts.map +1 -0
  116. package/dist/themes/builtin.js +291 -0
  117. package/dist/themes/glyphs.d.ts +15 -0
  118. package/dist/themes/glyphs.d.ts.map +1 -0
  119. package/dist/themes/glyphs.js +97 -0
  120. package/dist/themes/index.d.ts +5 -0
  121. package/dist/themes/index.d.ts.map +1 -0
  122. package/dist/themes/index.js +4 -0
  123. package/dist/themes/registry.d.ts +20 -0
  124. package/dist/themes/registry.d.ts.map +1 -0
  125. package/dist/themes/registry.js +204 -0
  126. package/dist/types/adapter.d.ts +38 -0
  127. package/dist/types/adapter.d.ts.map +1 -0
  128. package/dist/types/adapter.js +1 -0
  129. package/dist/types/animation.d.ts +31 -0
  130. package/dist/types/animation.d.ts.map +1 -0
  131. package/dist/types/animation.js +1 -0
  132. package/dist/types/app.d.ts +148 -0
  133. package/dist/types/app.d.ts.map +1 -0
  134. package/dist/types/app.js +1 -0
  135. package/dist/types/async.d.ts +30 -0
  136. package/dist/types/async.d.ts.map +1 -0
  137. package/dist/types/async.js +1 -0
  138. package/dist/types/capabilities.d.ts +44 -0
  139. package/dist/types/capabilities.d.ts.map +1 -0
  140. package/dist/types/capabilities.js +33 -0
  141. package/dist/types/cells.d.ts +72 -0
  142. package/dist/types/cells.d.ts.map +1 -0
  143. package/dist/types/cells.js +10 -0
  144. package/dist/types/command.d.ts +127 -0
  145. package/dist/types/command.d.ts.map +1 -0
  146. package/dist/types/command.js +1 -0
  147. package/dist/types/component-registry.d.ts +77 -0
  148. package/dist/types/component-registry.d.ts.map +1 -0
  149. package/dist/types/component-registry.js +1 -0
  150. package/dist/types/disposable.d.ts +8 -0
  151. package/dist/types/disposable.d.ts.map +1 -0
  152. package/dist/types/disposable.js +1 -0
  153. package/dist/types/focus.d.ts +72 -0
  154. package/dist/types/focus.d.ts.map +1 -0
  155. package/dist/types/focus.js +1 -0
  156. package/dist/types/geometry.d.ts +28 -0
  157. package/dist/types/geometry.d.ts.map +1 -0
  158. package/dist/types/geometry.js +14 -0
  159. package/dist/types/graph.d.ts +129 -0
  160. package/dist/types/graph.d.ts.map +1 -0
  161. package/dist/types/graph.js +31 -0
  162. package/dist/types/i18n.d.ts +23 -0
  163. package/dist/types/i18n.d.ts.map +1 -0
  164. package/dist/types/i18n.js +1 -0
  165. package/dist/types/index.d.ts +32 -0
  166. package/dist/types/index.d.ts.map +1 -0
  167. package/dist/types/index.js +31 -0
  168. package/dist/types/input.d.ts +65 -0
  169. package/dist/types/input.d.ts.map +1 -0
  170. package/dist/types/input.js +1 -0
  171. package/dist/types/keybinding.d.ts +49 -0
  172. package/dist/types/keybinding.d.ts.map +1 -0
  173. package/dist/types/keybinding.js +1 -0
  174. package/dist/types/layer.d.ts +54 -0
  175. package/dist/types/layer.d.ts.map +1 -0
  176. package/dist/types/layer.js +1 -0
  177. package/dist/types/manifest.d.ts +114 -0
  178. package/dist/types/manifest.d.ts.map +1 -0
  179. package/dist/types/manifest.js +1 -0
  180. package/dist/types/markdown.d.ts +58 -0
  181. package/dist/types/markdown.d.ts.map +1 -0
  182. package/dist/types/markdown.js +1 -0
  183. package/dist/types/navigation.d.ts +40 -0
  184. package/dist/types/navigation.d.ts.map +1 -0
  185. package/dist/types/navigation.js +1 -0
  186. package/dist/types/render.d.ts +72 -0
  187. package/dist/types/render.d.ts.map +1 -0
  188. package/dist/types/render.js +1 -0
  189. package/dist/types/resource.d.ts +170 -0
  190. package/dist/types/resource.d.ts.map +1 -0
  191. package/dist/types/resource.js +1 -0
  192. package/dist/types/services.d.ts +24 -0
  193. package/dist/types/services.d.ts.map +1 -0
  194. package/dist/types/services.js +3 -0
  195. package/dist/types/shell.d.ts +32 -0
  196. package/dist/types/shell.d.ts.map +1 -0
  197. package/dist/types/shell.js +1 -0
  198. package/dist/types/store.d.ts +124 -0
  199. package/dist/types/store.d.ts.map +1 -0
  200. package/dist/types/store.js +1 -0
  201. package/dist/types/stream.d.ts +18 -0
  202. package/dist/types/stream.d.ts.map +1 -0
  203. package/dist/types/stream.js +1 -0
  204. package/dist/types/style.d.ts +166 -0
  205. package/dist/types/style.d.ts.map +1 -0
  206. package/dist/types/style.js +1 -0
  207. package/dist/types/surface.d.ts +97 -0
  208. package/dist/types/surface.d.ts.map +1 -0
  209. package/dist/types/surface.js +1 -0
  210. package/dist/types/syntax.d.ts +68 -0
  211. package/dist/types/syntax.d.ts.map +1 -0
  212. package/dist/types/syntax.js +5 -0
  213. package/dist/types/terminal.d.ts +52 -0
  214. package/dist/types/terminal.d.ts.map +1 -0
  215. package/dist/types/terminal.js +1 -0
  216. package/dist/types/theme.d.ts +117 -0
  217. package/dist/types/theme.d.ts.map +1 -0
  218. package/dist/types/theme.js +1 -0
  219. package/dist/types/when.d.ts +19 -0
  220. package/dist/types/when.d.ts.map +1 -0
  221. package/dist/types/when.js +1 -0
  222. package/dist/ui/primitives.d.ts +56 -0
  223. package/dist/ui/primitives.d.ts.map +1 -0
  224. package/dist/ui/primitives.js +108 -0
  225. package/dist/ui/screen.d.ts +23 -0
  226. package/dist/ui/screen.d.ts.map +1 -0
  227. package/dist/ui/screen.js +18 -0
  228. package/dist/util/disposable.d.ts +6 -0
  229. package/dist/util/disposable.d.ts.map +1 -0
  230. package/dist/util/disposable.js +49 -0
  231. package/dist/util/markdown.d.ts +24 -0
  232. package/dist/util/markdown.d.ts.map +1 -0
  233. package/dist/util/markdown.js +220 -0
  234. package/dist/util/paths.d.ts +47 -0
  235. package/dist/util/paths.d.ts.map +1 -0
  236. package/dist/util/paths.js +134 -0
  237. package/dist/util/stream.d.ts +32 -0
  238. package/dist/util/stream.d.ts.map +1 -0
  239. package/dist/util/stream.js +206 -0
  240. package/dist/util/text.d.ts +65 -0
  241. package/dist/util/text.d.ts.map +1 -0
  242. package/dist/util/text.js +419 -0
  243. package/package.json +70 -0
  244. package/src/adapters/index.ts +11 -0
  245. package/src/app/app.ts +1096 -0
  246. package/src/core/animation.ts +144 -0
  247. package/src/core/clipboard.ts +40 -0
  248. package/src/core/commands.ts +169 -0
  249. package/src/core/components.ts +91 -0
  250. package/src/core/events.ts +95 -0
  251. package/src/core/focus.ts +358 -0
  252. package/src/core/i18n.ts +107 -0
  253. package/src/core/keybindings.ts +184 -0
  254. package/src/core/layers.ts +94 -0
  255. package/src/core/manifest.ts +84 -0
  256. package/src/core/navigation.ts +135 -0
  257. package/src/core/resources.ts +362 -0
  258. package/src/core/services.ts +69 -0
  259. package/src/core/store.ts +640 -0
  260. package/src/core/surfaces.ts +292 -0
  261. package/src/core/syntax.ts +200 -0
  262. package/src/core/when.ts +238 -0
  263. package/src/index.ts +76 -0
  264. package/src/jsx/factory.ts +124 -0
  265. package/src/jsx/intrinsics.ts +99 -0
  266. package/src/jsx/jsx-dev-runtime.ts +23 -0
  267. package/src/jsx/jsx-runtime.ts +76 -0
  268. package/src/render/buffer.ts +318 -0
  269. package/src/render/color.ts +180 -0
  270. package/src/render/diff.ts +99 -0
  271. package/src/render/layout.ts +764 -0
  272. package/src/render/static.ts +290 -0
  273. package/src/runtime/bindings.ts +150 -0
  274. package/src/runtime/hooks.ts +1057 -0
  275. package/src/runtime/instance.ts +284 -0
  276. package/src/runtime/paint.ts +737 -0
  277. package/src/runtime/reconcile.ts +335 -0
  278. package/src/runtime/runtime.ts +47 -0
  279. package/src/runtime/style.ts +205 -0
  280. package/src/themes/borders.ts +95 -0
  281. package/src/themes/builtin.ts +301 -0
  282. package/src/themes/glyphs.ts +101 -0
  283. package/src/themes/index.ts +4 -0
  284. package/src/themes/registry.ts +224 -0
  285. package/src/types/adapter.ts +41 -0
  286. package/src/types/animation.ts +34 -0
  287. package/src/types/app.ts +149 -0
  288. package/src/types/async.ts +35 -0
  289. package/src/types/capabilities.ts +79 -0
  290. package/src/types/cells.ts +79 -0
  291. package/src/types/command.ts +139 -0
  292. package/src/types/component-registry.ts +82 -0
  293. package/src/types/disposable.ts +8 -0
  294. package/src/types/focus.ts +77 -0
  295. package/src/types/geometry.ts +50 -0
  296. package/src/types/graph.ts +174 -0
  297. package/src/types/i18n.ts +25 -0
  298. package/src/types/index.ts +31 -0
  299. package/src/types/input.ts +81 -0
  300. package/src/types/keybinding.ts +51 -0
  301. package/src/types/layer.ts +46 -0
  302. package/src/types/manifest.ts +101 -0
  303. package/src/types/markdown.ts +47 -0
  304. package/src/types/navigation.ts +42 -0
  305. package/src/types/render.ts +86 -0
  306. package/src/types/resource.ts +185 -0
  307. package/src/types/services.ts +28 -0
  308. package/src/types/shell.ts +30 -0
  309. package/src/types/store.ts +148 -0
  310. package/src/types/stream.ts +24 -0
  311. package/src/types/style.ts +205 -0
  312. package/src/types/surface.ts +119 -0
  313. package/src/types/syntax.ts +93 -0
  314. package/src/types/terminal.ts +58 -0
  315. package/src/types/theme.ts +121 -0
  316. package/src/types/when.ts +21 -0
  317. package/src/ui/primitives.ts +118 -0
  318. package/src/ui/screen.ts +41 -0
  319. package/src/util/disposable.ts +49 -0
  320. package/src/util/markdown.ts +225 -0
  321. package/src/util/paths.ts +138 -0
  322. package/src/util/stream.ts +213 -0
  323. 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
+ }