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