@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,94 @@
1
+ import type { LayerEntry, LayerManager, LayerName } from '../types/layer.js';
2
+ import type { Disposable } from '../types/disposable.js';
3
+ import { toDisposable } from '../util/disposable.js';
4
+
5
+ const LAYER_ORDER: LayerName[] = ['base', 'floating', 'modal', 'notification', 'debug'];
6
+
7
+ /**
8
+ * Layers are planes, not components.
9
+ *
10
+ * Dialogs, dropdowns, context menus, tooltips, palettes and toasts all sit on
11
+ * one of five, which is what lets focus trapping, dismissal and paint order be
12
+ * decided once here rather than five times in five components.
13
+ */
14
+ export class Layers implements LayerManager {
15
+ private byId = new Map<string, LayerEntry>();
16
+ private timers = new Map<string, ReturnType<typeof setTimeout>>();
17
+ private counter = 0;
18
+
19
+ constructor(private onChange: () => void = () => {}) {}
20
+
21
+ open(entry: LayerEntry): Disposable {
22
+ const order = entry.order ?? ++this.counter;
23
+ this.byId.set(entry.id, { ...entry, order });
24
+
25
+ if (entry.timeoutMs && entry.timeoutMs > 0) {
26
+ const timer = setTimeout(() => this.close(entry.id, 'timeout'), entry.timeoutMs);
27
+ timer.unref?.();
28
+ this.timers.set(entry.id, timer);
29
+ }
30
+
31
+ this.onChange();
32
+ return toDisposable(() => this.close(entry.id, 'api'));
33
+ }
34
+
35
+ close(id: string, reason: 'escape' | 'outside' | 'timeout' | 'api' = 'api'): void {
36
+ const entry = this.byId.get(id);
37
+ if (!entry) return;
38
+
39
+ const timer = this.timers.get(id);
40
+ if (timer) {
41
+ clearTimeout(timer);
42
+ this.timers.delete(id);
43
+ }
44
+
45
+ this.byId.delete(id);
46
+ try {
47
+ entry.onClose?.(reason);
48
+ } finally {
49
+ this.onChange();
50
+ }
51
+ }
52
+
53
+ closeLayer(layer: LayerName): void {
54
+ for (const entry of this.entries(layer)) this.close(entry.id, 'api');
55
+ }
56
+
57
+ /** Sorted by plane, then by open order - which is paint order. */
58
+ entries(layer?: LayerName): LayerEntry[] {
59
+ const all = [...this.byId.values()];
60
+ const filtered = layer ? all.filter((e) => e.layer === layer) : all;
61
+ return filtered.sort((a, b) => {
62
+ const byLayer = LAYER_ORDER.indexOf(a.layer) - LAYER_ORDER.indexOf(b.layer);
63
+ return byLayer !== 0 ? byLayer : (a.order ?? 0) - (b.order ?? 0);
64
+ });
65
+ }
66
+
67
+ topmostTrap(): LayerEntry | null {
68
+ const trapping = this.entries().filter((e) => e.trapFocus);
69
+ return trapping[trapping.length - 1] ?? null;
70
+ }
71
+
72
+ /** The entry Escape should close: the last dismissible one. */
73
+ topmostDismissible(): LayerEntry | null {
74
+ const list = this.entries().filter((e) => e.dismissOnEscape !== false);
75
+ return list[list.length - 1] ?? null;
76
+ }
77
+
78
+ update(id: string, patch: Partial<LayerEntry>): void {
79
+ const entry = this.byId.get(id);
80
+ if (!entry) return;
81
+ this.byId.set(id, { ...entry, ...patch });
82
+ this.onChange();
83
+ }
84
+
85
+ dispose(): void {
86
+ for (const timer of this.timers.values()) clearTimeout(timer);
87
+ this.timers.clear();
88
+ this.byId.clear();
89
+ }
90
+ }
91
+
92
+ export function createLayers(onChange?: () => void): Layers {
93
+ return new Layers(onChange);
94
+ }
@@ -0,0 +1,84 @@
1
+ import type { Manifest, ManifestAPI, ManifestSource } from '../types/manifest.js';
2
+ import type { TextUIApp } from '../types/app.js';
3
+ import type { Disposable } from '../types/disposable.js';
4
+ import { createBag } from '../util/disposable.js';
5
+
6
+ /**
7
+ * Manifests.
8
+ *
9
+ * Separate typed lists rather than one generic plugin bag: every category has
10
+ * its own contract, so a bad contribution fails where it is written instead of
11
+ * at some later lookup. Loading returns one disposable that unwinds everything
12
+ * the source contributed, which is what makes unloading exact.
13
+ */
14
+ export class Manifests implements ManifestAPI {
15
+ private loaded_ = new Map<string, { source: ManifestSource; disposable: Disposable }>();
16
+
17
+ constructor(private app: TextUIApp) {}
18
+
19
+ async load(manifest: Manifest): Promise<Disposable> {
20
+ const { id } = manifest.source;
21
+ if (this.loaded_.has(id)) {
22
+ throw new Error(`[textui] manifest "${id}" is already loaded`);
23
+ }
24
+
25
+ for (const required of manifest.requires ?? []) {
26
+ if (!this.loaded_.has(required)) {
27
+ throw new Error(`[textui] manifest "${id}" requires "${required}", which is not loaded`);
28
+ }
29
+ }
30
+
31
+ const caps = this.app.capabilities as unknown as Record<string, unknown>;
32
+ const missing = (manifest.requiresCapabilities ?? []).filter((c) => !caps[c]);
33
+ if (missing.length > 0) {
34
+ throw new Error(
35
+ `[textui] manifest "${id}" needs terminal capabilities this session lacks: ${missing.join(', ')}`,
36
+ );
37
+ }
38
+
39
+ const bag = createBag();
40
+ const c = manifest.contributes ?? {};
41
+
42
+ for (const def of c.components ?? []) bag.add(this.app.components.register(def));
43
+ for (const def of c.commands ?? []) bag.add(this.app.commands.register(def));
44
+ for (const def of c.keybindings ?? []) bag.add(this.app.keybindings.register(def));
45
+ for (const def of c.themes ?? []) bag.add(this.app.themes.register(def));
46
+ for (const def of c.shells ?? []) bag.add(this.app.shells.register(def));
47
+ for (const def of c.layouts ?? []) bag.add(this.app.layouts.register(def));
48
+ for (const def of c.screens ?? []) bag.add(this.app.screens.register(def));
49
+ for (const def of c.resourceKinds ?? []) bag.add(this.app.resources.registerKind(def));
50
+ for (const def of c.resourceProviders ?? []) bag.add(this.app.resources.registerProvider(def));
51
+ for (const def of c.resourceViewers ?? []) bag.add(this.app.resources.registerViewer(def));
52
+ for (const def of c.resourceEditors ?? []) bag.add(this.app.resources.registerEditor(def));
53
+ for (const def of c.resourceActions ?? []) bag.add(this.app.resources.registerAction(def));
54
+ for (const def of c.dataProviders ?? []) bag.add(this.app.store.registerDataProvider(def));
55
+ for (const entry of c.computed ?? []) bag.add(this.app.store.computed(entry.path, entry.def));
56
+ // Views mount last: they may name a component this manifest just added.
57
+ for (const mount of c.views ?? []) bag.add(this.app.open(mount));
58
+
59
+ const disposable = {
60
+ dispose: () => {
61
+ bag.dispose();
62
+ this.loaded_.delete(id);
63
+ },
64
+ };
65
+ this.loaded_.set(id, { source: manifest.source, disposable });
66
+ return disposable;
67
+ }
68
+
69
+ unload(sourceId: string): void {
70
+ this.loaded_.get(sourceId)?.disposable.dispose();
71
+ }
72
+
73
+ sources(): ManifestSource[] {
74
+ return [...this.loaded_.values()].map((e) => e.source);
75
+ }
76
+
77
+ loaded(sourceId: string): boolean {
78
+ return this.loaded_.has(sourceId);
79
+ }
80
+ }
81
+
82
+ export function createManifests(app: TextUIApp): Manifests {
83
+ return new Manifests(app);
84
+ }
@@ -0,0 +1,135 @@
1
+ import type { Navigator, ScreenDefinition, ScreenEntry } from '../types/navigation.js';
2
+ import type { ReactiveStore } from '../types/store.js';
3
+ import type { FocusManager } from '../types/focus.js';
4
+ import type { Disposable } from '../types/disposable.js';
5
+ import { toDisposable } from '../util/disposable.js';
6
+
7
+ /**
8
+ * Screens and a stack, not a router.
9
+ *
10
+ * The whole navigation model is "what is on top", plus enough memory to put
11
+ * focus back where it was when you return. An application that wants URLs maps
12
+ * them onto screens itself; nothing here assumes a browser exists.
13
+ */
14
+ export class Navigation implements Navigator {
15
+ private defs = new Map<string, ScreenDefinition>();
16
+ private entries: ScreenEntry[] = [];
17
+
18
+ constructor(
19
+ private deps: {
20
+ store: ReactiveStore;
21
+ focus: FocusManager;
22
+ onChange(): void;
23
+ /**
24
+ * Put the current screen on screen.
25
+ *
26
+ * A callback rather than a surface registry, because what a screen *is*
27
+ * - a stack entry and a definition - has nothing to do with where the
28
+ * application decided to draw it.
29
+ */
30
+ mount(entry: ScreenEntry | null): void;
31
+ },
32
+ ) {}
33
+
34
+ register(screen: ScreenDefinition): Disposable {
35
+ this.defs.set(screen.id, screen);
36
+ return toDisposable(() => this.defs.delete(screen.id));
37
+ }
38
+
39
+ screens(): ScreenDefinition[] {
40
+ return [...this.defs.values()];
41
+ }
42
+
43
+ get(id: string): ScreenDefinition | undefined {
44
+ return this.defs.get(id);
45
+ }
46
+
47
+ private publish(): void {
48
+ const current = this.current();
49
+ this.deps.store.set('$/layout/screen/stack', this.entries.map((e) => e.id));
50
+ this.deps.store.set('$/layout/screen/current', current?.id ?? null);
51
+ // Published as well as passed as props, so a component deep inside a
52
+ // screen can read the parameters without every layer between forwarding
53
+ // them.
54
+ this.deps.store.set('$/layout/screen/params', current?.params ?? {});
55
+ this.deps.mount(current);
56
+ this.deps.onChange();
57
+ }
58
+
59
+ /** Remember where focus was, so `pop` can put it back. */
60
+ private snapshotFocus(): void {
61
+ const top = this.entries[this.entries.length - 1];
62
+ if (top) top.restoreFocus = this.deps.focus.focused();
63
+ }
64
+
65
+ push(id: string, params?: Record<string, unknown>): void {
66
+ if (!this.defs.has(id)) {
67
+ throw new Error(`[textui] no screen registered as "${id}"`);
68
+ }
69
+ this.snapshotFocus();
70
+ this.entries.push({ id, params });
71
+ this.publish();
72
+ }
73
+
74
+ replace(id: string, params?: Record<string, unknown>): void {
75
+ if (!this.defs.has(id)) {
76
+ throw new Error(`[textui] no screen registered as "${id}"`);
77
+ }
78
+ const previous = this.entries.pop();
79
+ this.clearScreenScope(previous);
80
+ this.entries.push({ id, params });
81
+ this.publish();
82
+ }
83
+
84
+ pop(): boolean {
85
+ if (this.entries.length <= 1) return false;
86
+ const previous = this.entries.pop();
87
+ this.clearScreenScope(previous);
88
+
89
+ const top = this.entries[this.entries.length - 1];
90
+ if (top?.restoreFocus) this.deps.focus.focus(top.restoreFocus);
91
+ this.publish();
92
+ return true;
93
+ }
94
+
95
+ popTo(id: string): boolean {
96
+ const index = this.entries.findIndex((e) => e.id === id);
97
+ if (index === -1) return false;
98
+ for (const entry of this.entries.slice(index + 1)) this.clearScreenScope(entry);
99
+ this.entries = this.entries.slice(0, index + 1);
100
+
101
+ const top = this.entries[this.entries.length - 1];
102
+ if (top?.restoreFocus) this.deps.focus.focus(top.restoreFocus);
103
+ this.publish();
104
+ return true;
105
+ }
106
+
107
+ reset(id: string, params?: Record<string, unknown>): void {
108
+ for (const entry of this.entries) this.clearScreenScope(entry);
109
+ this.entries = [{ id, params }];
110
+ this.publish();
111
+ }
112
+
113
+ current(): ScreenEntry | null {
114
+ return this.entries[this.entries.length - 1] ?? null;
115
+ }
116
+
117
+ stack(): ScreenEntry[] {
118
+ return [...this.entries];
119
+ }
120
+
121
+ canGoBack(): boolean {
122
+ return this.entries.length > 1;
123
+ }
124
+
125
+ /** A screen's own scope dies with it, unless it asked to be kept alive. */
126
+ private clearScreenScope(entry: ScreenEntry | undefined): void {
127
+ if (!entry) return;
128
+ if (this.defs.get(entry.id)?.keepAlive) return;
129
+ this.deps.store.clearScope(`screen.${entry.id}`);
130
+ }
131
+ }
132
+
133
+ export function createNavigation(deps: ConstructorParameters<typeof Navigation>[0]): Navigation {
134
+ return new Navigation(deps);
135
+ }
@@ -0,0 +1,362 @@
1
+ import type {
2
+ Resource, ResourceActionDefinition, ResourceEditorDefinition, ResourceKind,
3
+ ResourceMetadata, ResourceProvider, ResourceRegistry, ResourceURI,
4
+ ResourceRendererDefinition, ResourceViewerDefinition,
5
+ } from '../types/resource.js';
6
+ import type { ComponentNode } from '../types/graph.js';
7
+ import type { ComponentRegistry } from '../types/component-registry.js';
8
+ import type { WhenEngine } from '../types/when.js';
9
+ import type { Disposable } from '../types/disposable.js';
10
+ import { toDisposable } from '../util/disposable.js';
11
+ import { nameOf } from './syntax.js';
12
+ import type { SemanticVariant } from '../types/style.js';
13
+
14
+ function schemeOf(uri: ResourceURI): string {
15
+ const i = uri.indexOf(':');
16
+ return i === -1 ? 'file' : uri.slice(0, i);
17
+ }
18
+
19
+ function matchesGlob(name: string, pattern: string): boolean {
20
+ if (pattern.startsWith('*.')) return name.toLowerCase().endsWith(pattern.slice(1).toLowerCase());
21
+ return name.toLowerCase() === pattern.toLowerCase();
22
+ }
23
+
24
+ /**
25
+ * The resource model.
26
+ *
27
+ * A resource is anything addressable that something can be registered to
28
+ * display, edit or act on - a file, a record, a log stream, a service. The
29
+ * filesystem is one provider among several rather than the model itself, which
30
+ * is what lets an explorer browse services and files with the same component.
31
+ *
32
+ * Kinds form a hierarchy by dotted name: `file.markdown` specialises
33
+ * `file.text`, so a viewer registered for `file.text` still opens a markdown
34
+ * file when no more specific one exists.
35
+ */
36
+ export class Resources implements ResourceRegistry {
37
+ private kindDefs = new Map<string, ResourceKind>();
38
+ private providers = new Map<string, ResourceProvider>();
39
+ private viewers: ResourceViewerDefinition[] = [];
40
+ private editors: ResourceEditorDefinition[] = [];
41
+ private actions: ResourceActionDefinition[] = [];
42
+
43
+ constructor(
44
+ private deps: {
45
+ components: ComponentRegistry;
46
+ when: WhenEngine;
47
+ },
48
+ ) {}
49
+
50
+ registerKind(kind: ResourceKind): Disposable {
51
+ this.kindDefs.set(kind.id, kind);
52
+ return toDisposable(() => this.kindDefs.delete(kind.id));
53
+ }
54
+
55
+ registerProvider(provider: ResourceProvider): Disposable {
56
+ this.providers.set(provider.scheme, provider);
57
+ return toDisposable(() => this.providers.delete(provider.scheme));
58
+ }
59
+
60
+ registerViewer(def: ResourceViewerDefinition): Disposable {
61
+ this.viewers.push(def);
62
+ return toDisposable(() => {
63
+ const i = this.viewers.indexOf(def);
64
+ if (i >= 0) this.viewers.splice(i, 1);
65
+ });
66
+ }
67
+
68
+ registerEditor(def: ResourceEditorDefinition): Disposable {
69
+ this.editors.push(def);
70
+ return toDisposable(() => {
71
+ const i = this.editors.indexOf(def);
72
+ if (i >= 0) this.editors.splice(i, 1);
73
+ });
74
+ }
75
+
76
+ registerAction(def: ResourceActionDefinition): Disposable {
77
+ this.actions.push(def);
78
+ return toDisposable(() => {
79
+ const i = this.actions.indexOf(def);
80
+ if (i >= 0) this.actions.splice(i, 1);
81
+ });
82
+ }
83
+
84
+ kinds(): ResourceKind[] {
85
+ return [...this.kindDefs.values()];
86
+ }
87
+
88
+ /** Extension, then mime type, then an explicit `detect`. Best match wins. */
89
+ detectKind(uri: ResourceURI, meta: ResourceMetadata = { name: uri }): string {
90
+ const name = meta.name || nameOf(uri) || uri;
91
+ const candidates: { id: string; score: number }[] = [];
92
+
93
+ for (const kind of this.kindDefs.values()) {
94
+ let score = kind.priority ?? 0;
95
+ let matched = false;
96
+
97
+ if (kind.extensions?.some((p) => matchesGlob(name, p))) {
98
+ score += 100;
99
+ matched = true;
100
+ }
101
+ if (meta.mimeType && kind.mimeTypes?.includes(meta.mimeType)) {
102
+ score += 80;
103
+ matched = true;
104
+ }
105
+ if (kind.detect?.(uri, meta)) {
106
+ score += 200;
107
+ matched = true;
108
+ }
109
+ // A more specific kind beats its parent on equal evidence.
110
+ if (matched) {
111
+ score += kind.id.split('.').length;
112
+ candidates.push({ id: kind.id, score });
113
+ }
114
+ }
115
+
116
+ if (candidates.length === 0) return meta.mimeType ? 'file' : 'unknown';
117
+ candidates.sort((a, b) => b.score - a.score);
118
+ return (candidates[0] as { id: string }).id;
119
+ }
120
+
121
+ kindMatches(kind: string, ancestor: string): boolean {
122
+ if (kind === ancestor) return true;
123
+ if (kind.startsWith(ancestor + '.')) return true;
124
+
125
+ let cursor = this.kindDefs.get(kind);
126
+ const seen = new Set<string>();
127
+ while (cursor?.extends && !seen.has(cursor.id)) {
128
+ seen.add(cursor.id);
129
+ if (cursor.extends === ancestor) return true;
130
+ cursor = this.kindDefs.get(cursor.extends);
131
+ }
132
+ return false;
133
+ }
134
+
135
+ private provider(uri: ResourceURI): ResourceProvider {
136
+ const scheme = schemeOf(uri);
137
+ const provider = this.providers.get(scheme);
138
+ if (!provider) {
139
+ throw new Error(`[textui] no resource provider registered for "${scheme}:"`);
140
+ }
141
+ return provider;
142
+ }
143
+
144
+ async stat(uri: ResourceURI): Promise<Resource | null> {
145
+ const resource = await this.provider(uri).stat(uri);
146
+ if (!resource) return null;
147
+ // A provider may leave classification to the registry.
148
+ if (!resource.kind || resource.kind === 'unknown') {
149
+ return { ...resource, kind: this.detectKind(uri, resource.metadata) };
150
+ }
151
+ return resource;
152
+ }
153
+
154
+ async list(uri: ResourceURI): Promise<Resource[]> {
155
+ const provider = this.provider(uri);
156
+ if (!provider.list) return [];
157
+ const items = await provider.list(uri);
158
+ return items.map((r) =>
159
+ r.kind && r.kind !== 'unknown' ? r : { ...r, kind: this.detectKind(r.uri, r.metadata) },
160
+ );
161
+ }
162
+
163
+ async read(uri: ResourceURI): Promise<string | Uint8Array> {
164
+ const provider = this.provider(uri);
165
+ if (!provider.read) throw new Error(`[textui] "${schemeOf(uri)}:" cannot be read`);
166
+ return provider.read(uri);
167
+ }
168
+
169
+ async write(uri: ResourceURI, content: string | Uint8Array): Promise<void> {
170
+ const provider = this.provider(uri);
171
+ if (!provider.write) throw new Error(`[textui] "${schemeOf(uri)}:" is read-only`);
172
+ return provider.write(uri, content);
173
+ }
174
+
175
+ /**
176
+ * Destructive calls throw when the provider cannot serve them, the same way
177
+ * `write` does. A silent no-op is the failure mode worth designing against:
178
+ * a Delete that appears in the menu and removes nothing is worse than one
179
+ * that says it cannot.
180
+ *
181
+ * Whether to *offer* the action is a different question, answered before
182
+ * the call by `Resource.capabilities` - which is per resource rather than
183
+ * per provider, so a read-only mount, a file without permission and an
184
+ * inherently immutable resource are all the same shape. The throw is for
185
+ * the race that asking first cannot close: the remote goes away, the
186
+ * permission changes, between the offer and the call.
187
+ */
188
+ async delete(uri: ResourceURI): Promise<void> {
189
+ const provider = this.provider(uri);
190
+ if (!provider.delete) throw new Error(`[textui] "${schemeOf(uri)}:" cannot delete`);
191
+ return provider.delete(uri);
192
+ }
193
+
194
+ /**
195
+ * A rename is within one provider. Two schemes is a move between providers,
196
+ * which is a copy and a delete and belongs above a registry that dispatches
197
+ * on a single scheme by construction.
198
+ */
199
+ async rename(from: ResourceURI, to: ResourceURI): Promise<void> {
200
+ const scheme = schemeOf(from);
201
+ if (schemeOf(to) !== scheme) {
202
+ throw new Error(
203
+ `[textui] cannot rename across schemes: "${scheme}:" to "${schemeOf(to)}:"`,
204
+ );
205
+ }
206
+ const provider = this.provider(from);
207
+ if (!provider.rename) throw new Error(`[textui] "${scheme}:" cannot rename`);
208
+ return provider.rename(from, to);
209
+ }
210
+
211
+ /**
212
+ * Watching follows `list` rather than `write`: a provider that cannot watch
213
+ * gets a disposable that does nothing, because "I will never tell you about
214
+ * a change" is a truthful answer to an optional question. Making every
215
+ * caller guard a subscription would buy nothing.
216
+ */
217
+ watch(uri: ResourceURI, fn: (event: 'change' | 'create' | 'delete') => void): Disposable {
218
+ const provider = this.provider(uri);
219
+ return provider.watch?.(uri, fn) ?? toDisposable(() => {});
220
+ }
221
+
222
+ viewersFor(kind: string): ResourceViewerDefinition[] {
223
+ const specific = this.viewers
224
+ .filter((v) => !v.fallback && v.kinds.some((k) => this.kindMatches(kind, k)))
225
+ .filter((v) => this.deps.when.evaluate(v.when))
226
+ .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
227
+
228
+ const fallbacks = this.viewers
229
+ .filter((v) => v.fallback)
230
+ .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
231
+
232
+ return [...specific, ...fallbacks];
233
+ }
234
+
235
+ editorsFor(kind: string): ResourceEditorDefinition[] {
236
+ return this.editors
237
+ .filter((e) => e.kinds.some((k) => this.kindMatches(kind, k)))
238
+ .filter((e) => this.deps.when.evaluate(e.when))
239
+ .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
240
+ }
241
+
242
+ /**
243
+ * Every way of showing this kind, best first.
244
+ *
245
+ * Three registries answer the same question - an editor, a viewer and a
246
+ * component that declared `opens` are all "a component that can put this on
247
+ * screen" - and a panel offering "open with" wants one list. Saving is a
248
+ * property of the renderer here rather than the registry it came from, which
249
+ * is what lets `file.markdown` list an editor, a rendered view and plain
250
+ * text together and lets the panel pick between them.
251
+ */
252
+ /**
253
+ * What a resource looks like in a list.
254
+ *
255
+ * Asked of the *renderer* first, because the thing that knows what a
256
+ * markdown file is is the thing that opens markdown files - so a viewer an
257
+ * extension brought names and colours its own kind without anything here
258
+ * learning what it is. Then the kind, walking up `extends`, since
259
+ * `file.markdown` inheriting Text's icon is better than inheriting nothing.
260
+ *
261
+ * Answers nothing rather than a default: what a nameless file looks like is
262
+ * the caller's vocabulary, because which glyphs a terminal can draw is known
263
+ * where the terminal is and not here.
264
+ */
265
+ appearanceOf(resource: { kind: string }): { icon?: string; tone?: SemanticVariant } {
266
+ for (const renderer of this.renderersFor(resource.kind)) {
267
+ if (renderer.icon !== undefined || renderer.tone !== undefined) {
268
+ return {
269
+ ...(renderer.icon !== undefined ? { icon: renderer.icon } : {}),
270
+ ...(renderer.tone !== undefined ? { tone: renderer.tone } : {}),
271
+ };
272
+ }
273
+ }
274
+
275
+ const byId = new Map(this.kinds().map((k) => [k.id, k]));
276
+ // Bounded by the number of kinds: a cycle in `extends` is a bad
277
+ // registration and must not be an infinite loop in a list row.
278
+ const seen = new Set<string>();
279
+ let kind = byId.get(resource.kind);
280
+ while (kind && !seen.has(kind.id)) {
281
+ seen.add(kind.id);
282
+ if (kind.icon !== undefined || kind.tone !== undefined) {
283
+ return {
284
+ ...(kind.icon !== undefined ? { icon: kind.icon } : {}),
285
+ ...(kind.tone !== undefined ? { tone: kind.tone } : {}),
286
+ };
287
+ }
288
+ kind = kind.extends === undefined ? undefined : byId.get(kind.extends);
289
+ }
290
+ return {};
291
+ }
292
+
293
+ renderersFor(kind: string): ResourceRendererDefinition[] {
294
+ const seen = new Set<string>();
295
+ const out: ResourceRendererDefinition[] = [];
296
+ const add = (def: ResourceRendererDefinition): void => {
297
+ if (seen.has(def.id)) return;
298
+ seen.add(def.id);
299
+ out.push(def);
300
+ };
301
+
302
+ for (const e of this.editorsFor(kind)) add({ ...e, saves: true });
303
+ for (const v of this.viewersFor(kind)) add({ ...v, saves: false });
304
+ for (const c of this.deps.components.findOpeners(kind)) {
305
+ add({
306
+ id: c.component,
307
+ title: c.opens?.title ?? c.component,
308
+ kinds: c.opens?.resourceKinds ?? [],
309
+ component: c.component,
310
+ ...(c.opens?.icon !== undefined ? { icon: c.opens.icon } : {}),
311
+ ...(c.opens?.priority !== undefined ? { priority: c.opens.priority } : {}),
312
+ saves: c.opens?.mode === 'edit',
313
+ });
314
+ }
315
+
316
+ return out.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
317
+ }
318
+
319
+ actionsFor(kind: string, slot?: string): ResourceActionDefinition[] {
320
+ return this.actions
321
+ .filter((a) => a.kinds.some((k) => this.kindMatches(kind, k) || k === '*'))
322
+ .filter((a) => !slot || (a.slots ?? ['context']).includes(slot))
323
+ .filter((a) => this.deps.when.evaluate(a.when))
324
+ .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
325
+ }
326
+
327
+ /**
328
+ * The node that displays this resource. An explicit `viewerId` wins; then a
329
+ * registered editor when edit was asked for; then the best viewer; then a
330
+ * component that declared `opens` for this kind; then nothing.
331
+ */
332
+ nodeFor(
333
+ resource: Resource,
334
+ options: { viewerId?: string; mode?: 'view' | 'edit' } = {},
335
+ ): ComponentNode | null {
336
+ const props = { resource, uri: resource.uri };
337
+
338
+ if (options.viewerId) {
339
+ const chosen =
340
+ this.viewers.find((v) => v.id === options.viewerId) ??
341
+ this.editors.find((e) => e.id === options.viewerId);
342
+ return chosen ? { component: chosen.component, ...props } : null;
343
+ }
344
+
345
+ if (options.mode === 'edit') {
346
+ const editor = this.editorsFor(resource.kind)[0];
347
+ if (editor) return { component: editor.component, ...props };
348
+ }
349
+
350
+ const viewer = this.viewersFor(resource.kind)[0];
351
+ if (viewer) return { component: viewer.component, ...props };
352
+
353
+ const opener = this.deps.components.findOpeners(resource.kind)[0];
354
+ if (opener) return { component: opener.component, ...props };
355
+
356
+ return null;
357
+ }
358
+ }
359
+
360
+ export function createResources(deps: ConstructorParameters<typeof Resources>[0]): Resources {
361
+ return new Resources(deps);
362
+ }