@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,358 @@
1
+ import type {
2
+ FocusableOptions, FocusDirection, FocusManager, FocusScopeOptions,
3
+ } from '../types/focus.js';
4
+ import type { KeyEvent } from '../types/input.js';
5
+ import type { Rect } from '../types/geometry.js';
6
+ import type { Disposable } from '../types/disposable.js';
7
+ import { toDisposable } from '../util/disposable.js';
8
+ import { rectContains } from '../types/geometry.js';
9
+
10
+ interface Scope extends FocusScopeOptions {
11
+ /** What was focused when this scope activated, for `restore`. */
12
+ restoreTo: string | null;
13
+ }
14
+
15
+ /** The scope every global handler lives in, always active and always last. */
16
+ export const GLOBAL_SCOPE = '__global__';
17
+
18
+ /**
19
+ * Focus.
20
+ *
21
+ * Tab order is registration order within a scope unless a node states one.
22
+ * Directional navigation is geometric, because in a terminal "the thing to the
23
+ * right" is a real question a table, a menu and a dashboard all ask, and none
24
+ * of them can answer it from document order.
25
+ */
26
+ export class Focus implements FocusManager {
27
+ private scopes = new Map<string, Scope>();
28
+ private nodes = new Map<string, FocusableOptions>();
29
+ /** Registration order, which is tab order when nothing states otherwise. */
30
+ private order_: string[] = [];
31
+ private stack: string[] = [];
32
+ private current: string | null = null;
33
+
34
+ constructor(private onChange: () => void = () => {}) {
35
+ this.scopes.set(GLOBAL_SCOPE, { id: GLOBAL_SCOPE, priority: -1, restoreTo: null });
36
+ this.stack.push(GLOBAL_SCOPE);
37
+ }
38
+
39
+ // ------------------------------------------------------------- scopes
40
+
41
+ registerScope(options: FocusScopeOptions): Disposable {
42
+ this.scopes.set(options.id, { ...options, restoreTo: null });
43
+ return toDisposable(() => {
44
+ this.deactivateScope(options.id);
45
+ this.scopes.delete(options.id);
46
+ });
47
+ }
48
+
49
+ activateScope(id: string): void {
50
+ const scope = this.scopes.get(id);
51
+ if (!scope) return;
52
+ if (this.stack.includes(id)) return;
53
+
54
+ scope.restoreTo = this.current;
55
+ this.stack.push(id);
56
+
57
+ if (scope.autoFocus) {
58
+ const first = this.order(id)[0];
59
+ if (first) this.focus(first);
60
+ }
61
+ this.onChange();
62
+ }
63
+
64
+ deactivateScope(id: string): void {
65
+ const index = this.stack.indexOf(id);
66
+ if (index <= 0) return;
67
+ const scope = this.scopes.get(id);
68
+ this.stack.splice(index, 1);
69
+
70
+ // A scope closing may only move focus it was holding. Blurring regardless
71
+ // took focus off whatever was driving the change - a sidebar that swaps
72
+ // the screen beside it lost focus on every arrow press, and the incoming
73
+ // screen then claimed it, so the sidebar could be moved exactly once.
74
+ // "Nothing holds focus" counts as held: a scope closing takes its own
75
+ // contents with it, so by the time this runs the thing it had focused has
76
+ // usually already unmounted. What must not count is focus sitting on
77
+ // something *outside* - that belongs to whoever put it there.
78
+ const held = this.current === null || this.scopeOf(this.current) === id;
79
+
80
+ if (scope?.restore && held) {
81
+ const target = scope.restoreTo;
82
+ scope.restoreTo = null;
83
+ if (target && this.nodes.has(target)) {
84
+ this.focus(target);
85
+ return;
86
+ }
87
+ this.blur();
88
+ } else if (scope?.restore) {
89
+ scope.restoreTo = null;
90
+ } else if (held) {
91
+ this.blur();
92
+ }
93
+ this.onChange();
94
+ }
95
+
96
+ activeScope(): string | null {
97
+ const top = this.stack[this.stack.length - 1];
98
+ return top === GLOBAL_SCOPE ? null : top ?? null;
99
+ }
100
+
101
+ /** The innermost active scope that traps, if any. */
102
+ private trappingScope(): string | null {
103
+ for (let i = this.stack.length - 1; i >= 0; i--) {
104
+ const id = this.stack[i] as string;
105
+ if (this.scopes.get(id)?.trap) return id;
106
+ }
107
+ return null;
108
+ }
109
+
110
+ /** Which scope a focusable belongs to. `GLOBAL_SCOPE` when it names none. */
111
+ scopeOf(nodeId: string): string {
112
+ return this.nodes.get(nodeId)?.scopeId ?? GLOBAL_SCOPE;
113
+ }
114
+
115
+ // ---------------------------------------------------------- focusables
116
+
117
+ register(options: FocusableOptions): Disposable {
118
+ const existing = this.nodes.has(options.id);
119
+ this.nodes.set(options.id, options);
120
+ if (!existing) this.order_.push(options.id);
121
+
122
+ // A scope that asked to take focus takes it here, as its first control
123
+ // arrives - not when it was activated, because a scope is empty then. Its
124
+ // contents register on the way up, one effect at a time, and effects run
125
+ // parent-first, so `autoFocus` at activation could only ever have found
126
+ // something in a scope that already had something in it.
127
+ //
128
+ // Only when *nothing at all* holds focus. That is the difference between
129
+ // the screen this is for - pushed over whatever had focus, which the push
130
+ // unmounted, leaving none - and a dialog, which opens while its opener
131
+ // still holds focus and whose own controls say which of them wants it.
132
+ if (!existing && options.disabled !== true && this.current === null) {
133
+ const scopeId = options.scopeId ?? GLOBAL_SCOPE;
134
+ if (this.scopes.get(scopeId)?.autoFocus === true && this.stack.includes(scopeId)) {
135
+ this.focus(options.id);
136
+ }
137
+ }
138
+
139
+ return toDisposable(() => {
140
+ this.nodes.delete(options.id);
141
+ const i = this.order_.indexOf(options.id);
142
+ if (i >= 0) this.order_.splice(i, 1);
143
+ if (this.current === options.id) {
144
+ this.current = null;
145
+ this.onChange();
146
+ }
147
+ });
148
+ }
149
+
150
+ update(id: string, patch: Partial<FocusableOptions>): void {
151
+ const node = this.nodes.get(id);
152
+ if (!node) return;
153
+ this.nodes.set(id, { ...node, ...patch });
154
+
155
+ // Focus cannot sit on something that has just been disabled. Nothing else
156
+ // would move it - the keys it would have handled are now nobody's - so the
157
+ // tab order is the only way out and this is what puts the reader back in
158
+ // it.
159
+ if (patch.disabled === true && this.current === id) {
160
+ this.current = null;
161
+ node.onBlur?.();
162
+ this.onChange();
163
+ }
164
+ }
165
+
166
+ has(id: string): boolean {
167
+ return this.nodes.has(id);
168
+ }
169
+
170
+ focus(id: string): boolean {
171
+ const node = this.nodes.get(id);
172
+ if (!node || node.disabled) return false;
173
+ if (this.current === id) return true;
174
+
175
+ const previous = this.current ? this.nodes.get(this.current) : undefined;
176
+ this.current = id;
177
+ previous?.onBlur?.();
178
+ node.onFocus?.();
179
+ this.onChange();
180
+ return true;
181
+ }
182
+
183
+ blur(): void {
184
+ if (!this.current) return;
185
+ const node = this.nodes.get(this.current);
186
+ this.current = null;
187
+ node?.onBlur?.();
188
+ this.onChange();
189
+ }
190
+
191
+ focused(): string | null {
192
+ return this.current;
193
+ }
194
+
195
+ chain(): string[] {
196
+ const out: string[] = [];
197
+ if (this.current) out.push(this.current);
198
+ for (let i = this.stack.length - 1; i >= 0; i--) out.push(this.stack[i] as string);
199
+ return out;
200
+ }
201
+
202
+ /** Tab order: explicit `order` first, then registration order. */
203
+ order(scopeId?: string): string[] {
204
+ const trap = this.trappingScope();
205
+ const scope = scopeId ?? trap ?? undefined;
206
+
207
+ const candidates = this.order_
208
+ .map((id) => this.nodes.get(id))
209
+ .filter((n): n is FocusableOptions => {
210
+ if (!n || n.disabled || n.skipTab) return false;
211
+ if (scope === undefined) return true;
212
+ return (n.scopeId ?? GLOBAL_SCOPE) === scope;
213
+ });
214
+
215
+ return candidates
216
+ .map((n, i) => ({ n, i }))
217
+ .sort((a, b) => {
218
+ const ao = a.n.order ?? Number.MAX_SAFE_INTEGER;
219
+ const bo = b.n.order ?? Number.MAX_SAFE_INTEGER;
220
+ return ao === bo ? a.i - b.i : ao - bo;
221
+ })
222
+ .map((e) => e.n.id);
223
+ }
224
+
225
+ move(direction: FocusDirection): string | null {
226
+ if (direction === 'next' || direction === 'previous') {
227
+ const list = this.order();
228
+ if (list.length === 0) return null;
229
+ const index = this.current ? list.indexOf(this.current) : -1;
230
+ const step = direction === 'next' ? 1 : -1;
231
+ const nextIndex = index === -1
232
+ ? (direction === 'next' ? 0 : list.length - 1)
233
+ : (index + step + list.length) % list.length;
234
+ const id = list[nextIndex] as string;
235
+ return this.focus(id) ? id : null;
236
+ }
237
+ return this.moveDirectional(direction);
238
+ }
239
+
240
+ /**
241
+ * Geometric navigation. Candidates must lie in the requested direction; the
242
+ * winner is the nearest, with distance along the axis weighted more heavily
243
+ * than drift across it, so `right` from a table cell finds the next column
244
+ * rather than a distant button that happens to be closer in a straight line.
245
+ */
246
+ private moveDirectional(direction: 'up' | 'down' | 'left' | 'right'): string | null {
247
+ const from = this.current ? this.nodes.get(this.current) : undefined;
248
+ const origin = from?.rect;
249
+ if (!origin) {
250
+ const first = this.order()[0];
251
+ return first && this.focus(first) ? first : null;
252
+ }
253
+
254
+ const trap = this.trappingScope();
255
+ const cx = origin.x + origin.width / 2;
256
+ const cy = origin.y + origin.height / 2;
257
+
258
+ let best: { id: string; score: number } | null = null;
259
+
260
+ for (const node of this.nodes.values()) {
261
+ if (node.id === from.id || node.disabled || !node.rect) continue;
262
+ if (trap && (node.scopeId ?? GLOBAL_SCOPE) !== trap) continue;
263
+
264
+ const r = node.rect;
265
+ const nx = r.x + r.width / 2;
266
+ const ny = r.y + r.height / 2;
267
+ const dx = nx - cx;
268
+ const dy = ny - cy;
269
+
270
+ const along = direction === 'left' ? -dx : direction === 'right' ? dx
271
+ : direction === 'up' ? -dy : dy;
272
+ if (along <= 0) continue;
273
+
274
+ const across = direction === 'left' || direction === 'right'
275
+ ? Math.abs(dy)
276
+ : Math.abs(dx);
277
+
278
+ const score = along + across * 3;
279
+ if (!best || score < best.score) best = { id: node.id, score };
280
+ }
281
+
282
+ if (!best) return null;
283
+ return this.focus(best.id) ? best.id : null;
284
+ }
285
+
286
+ at(x: number, y: number): string | null {
287
+ let found: string | null = null;
288
+ let smallest = Infinity;
289
+ for (const node of this.nodes.values()) {
290
+ if (node.disabled || !node.rect) continue;
291
+ if (!rectContains(node.rect as Rect, x, y)) continue;
292
+ // The innermost hit wins, and "innermost" is the smallest area.
293
+ const area = node.rect.width * node.rect.height;
294
+ if (area < smallest) {
295
+ smallest = area;
296
+ found = node.id;
297
+ }
298
+ }
299
+ return found;
300
+ }
301
+
302
+ /**
303
+ * Focused node first, then outward through active scopes, then global -
304
+ * except that a trap stops the walk at itself.
305
+ *
306
+ * `order` and `move` already honoured the trap, so tab could not leave a
307
+ * modal; keys could, and did. A menu bar whose labels live in the global
308
+ * scope kept receiving every arrow while its own dropdown was open, so down
309
+ * re-opened the menu instead of moving inside it, and a palette opened over
310
+ * the top inherited the same problem - two layers reading one keystroke, and
311
+ * two escapes needed to get out.
312
+ *
313
+ * A trap owns the keyboard. Nothing outside it is a target, including a
314
+ * focused node left over from before it opened.
315
+ */
316
+ dispatch(event: KeyEvent): boolean {
317
+ const trap = this.trappingScope();
318
+ const inTrap = (node: { scopeId?: string }): boolean =>
319
+ trap === null || (node.scopeId ?? GLOBAL_SCOPE) === trap;
320
+
321
+ if (this.current) {
322
+ const node = this.nodes.get(this.current);
323
+ if (node && inTrap(node) && (node.onKey?.(event) === true || event.handled)) return true;
324
+ }
325
+
326
+ // Then the handlers that are not about one control.
327
+ //
328
+ // Only those. This loop used to offer the event to every registered
329
+ // handler, focused or not, which quietly undid the line above it: a list
330
+ // moved on an arrow it never received, because the arrow reached it here.
331
+ // With nothing focused it went to whichever focusable registered first -
332
+ // so a page with two lists moved the earlier one, and a tab strip and a
333
+ // list beneath it split the arrow keys between them by accident. That
334
+ // reads as working until the second list exists.
335
+ for (let i = this.stack.length - 1; i >= 0; i--) {
336
+ const scopeId = this.stack[i] as string;
337
+ if (trap !== null && scopeId !== trap) continue;
338
+ for (const node of this.nodes.values()) {
339
+ if (node.id === this.current) continue;
340
+ if (node.global !== true) continue;
341
+ if ((node.scopeId ?? GLOBAL_SCOPE) !== scopeId) continue;
342
+ if (!node.onKey) continue;
343
+ if (node.onKey(event) === true || event.handled) return true;
344
+ }
345
+ }
346
+ return false;
347
+ }
348
+
349
+ /** Called by the renderer after layout, so hit-testing has real rects. */
350
+ setRect(id: string, rect: Rect): void {
351
+ const node = this.nodes.get(id);
352
+ if (node) node.rect = rect;
353
+ }
354
+ }
355
+
356
+ export function createFocus(onChange?: () => void): Focus {
357
+ return new Focus(onChange);
358
+ }
@@ -0,0 +1,107 @@
1
+ import type { I18n, LocaleId, TranslationBundle } from '../types/i18n.js';
2
+ import type { Disposable } from '../types/disposable.js';
3
+ import { toDisposable } from '../util/disposable.js';
4
+
5
+ function lookup(messages: Record<string, unknown>, key: string): string | undefined {
6
+ if (key in messages) {
7
+ const direct = messages[key];
8
+ return typeof direct === 'string' ? direct : undefined;
9
+ }
10
+ let cursor: unknown = messages;
11
+ for (const part of key.split('.')) {
12
+ if (cursor === null || typeof cursor !== 'object') return undefined;
13
+ cursor = (cursor as Record<string, unknown>)[part];
14
+ }
15
+ return typeof cursor === 'string' ? cursor : undefined;
16
+ }
17
+
18
+ function interpolate(template: string, values: Record<string, unknown>): string {
19
+ return template.replace(/\{(\w+)\}/g, (match, name: string) =>
20
+ name in values ? String(values[name]) : match,
21
+ );
22
+ }
23
+
24
+ /**
25
+ * Lightweight i18n. Formatting is `Intl`'s job - recreating number, date and
26
+ * list formatting would be a second, worse implementation of something the
27
+ * runtime already ships.
28
+ */
29
+ export class I18nRegistry implements I18n {
30
+ locale: LocaleId;
31
+ private bundles = new Map<LocaleId, Record<string, unknown>>();
32
+ private listeners = new Set<(locale: LocaleId) => void>();
33
+
34
+ constructor(
35
+ locale: LocaleId = 'en',
36
+ private fallbackLocale: LocaleId = 'en',
37
+ ) {
38
+ this.locale = locale;
39
+ }
40
+
41
+ setLocale(locale: LocaleId): void {
42
+ if (this.locale === locale) return;
43
+ this.locale = locale;
44
+ for (const fn of [...this.listeners]) fn(locale);
45
+ }
46
+
47
+ register(bundle: TranslationBundle): Disposable {
48
+ const existing = this.bundles.get(bundle.locale) ?? {};
49
+ this.bundles.set(bundle.locale, { ...existing, ...bundle.messages });
50
+ if (this.locale === bundle.locale) {
51
+ for (const fn of [...this.listeners]) fn(this.locale);
52
+ }
53
+ return toDisposable(() => this.bundles.delete(bundle.locale));
54
+ }
55
+
56
+ locales(): LocaleId[] {
57
+ return [...this.bundles.keys()];
58
+ }
59
+
60
+ /** Missing keys fall through to the fallback locale, then to the key. */
61
+ t(key: string, values?: Record<string, unknown>): string {
62
+ const primary = this.bundles.get(this.locale);
63
+ const base = this.locale.includes('-')
64
+ ? this.bundles.get(this.locale.split('-')[0] as string)
65
+ : undefined;
66
+ const fallback = this.bundles.get(this.fallbackLocale);
67
+
68
+ const template =
69
+ (primary && lookup(primary, key)) ??
70
+ (base && lookup(base, key)) ??
71
+ (fallback && lookup(fallback, key)) ??
72
+ key;
73
+
74
+ return values ? interpolate(template, values) : template;
75
+ }
76
+
77
+ number(value: number, options?: Intl.NumberFormatOptions): string {
78
+ return new Intl.NumberFormat(this.locale, options).format(value);
79
+ }
80
+
81
+ date(value: Date | number, options?: Intl.DateTimeFormatOptions): string {
82
+ return new Intl.DateTimeFormat(this.locale, options).format(value);
83
+ }
84
+
85
+ relative(value: number, unit: Intl.RelativeTimeFormatUnit): string {
86
+ return new Intl.RelativeTimeFormat(this.locale, { numeric: 'auto' }).format(value, unit);
87
+ }
88
+
89
+ list(items: string[], options?: Intl.ListFormatOptions): string {
90
+ return new Intl.ListFormat(this.locale, options).format(items);
91
+ }
92
+
93
+ plural(count: number, forms: Record<string, string>): string {
94
+ const rule = new Intl.PluralRules(this.locale).select(count);
95
+ const template = forms[rule] ?? forms.other ?? '';
96
+ return interpolate(template, { count });
97
+ }
98
+
99
+ onChange(fn: (locale: LocaleId) => void): Disposable {
100
+ this.listeners.add(fn);
101
+ return toDisposable(() => this.listeners.delete(fn));
102
+ }
103
+ }
104
+
105
+ export function createI18n(locale?: string, fallback?: string): I18nRegistry {
106
+ return new I18nRegistry(locale, fallback);
107
+ }
@@ -0,0 +1,184 @@
1
+ import type { Chord, KeybindingDefinition, KeybindingRegistry } from '../types/keybinding.js';
2
+ import type { KeyEvent } from '../types/input.js';
3
+ import type { Disposable } from '../types/disposable.js';
4
+ import type { WhenEngine } from '../types/when.js';
5
+ import type { CommandRegistry } from '../types/command.js';
6
+ import { toDisposable } from '../util/disposable.js';
7
+
8
+ /** `ctrl+shift+p` or a sequence: `ctrl+k ctrl+s`, `g g`. */
9
+ function parseChord(chord: Chord): string[] {
10
+ return chord.trim().split(/\s+/).map(normalizeStroke);
11
+ }
12
+
13
+ const MODIFIER = /^(ctrl|control|alt|option|shift|meta|cmd|super)\+/i;
14
+
15
+ /**
16
+ * Split one stroke into its modifiers and its key.
17
+ *
18
+ * Modifiers are consumed off the front by name and whatever survives is the
19
+ * key, verbatim. Splitting on `+` cannot work here: `+` is the separator and
20
+ * a key, so `'+'.split('+')` loses the only thing it was asked about. Parsing
21
+ * forwards means `+` is `+`, and `ctrl++` is ctrl and `+`.
22
+ *
23
+ * One parser, exported, because a chord that the test harness presses and a
24
+ * chord that the registry stored have to agree - and two implementations of
25
+ * one grammar disagree eventually.
26
+ */
27
+ export function splitStroke(stroke: string): { mods: string[]; key: string } {
28
+ const mods: string[] = [];
29
+ let rest = stroke.trim();
30
+ for (let match = MODIFIER.exec(rest); match !== null; match = MODIFIER.exec(rest)) {
31
+ const [whole, name = ''] = match;
32
+ mods.push(name.toLowerCase());
33
+ rest = rest.slice(whole.length);
34
+ }
35
+ return { mods, key: rest };
36
+ }
37
+
38
+ /** The canonical stroke a chord is filed under. `strokeOf` must agree with it. */
39
+ export function normalizeStroke(stroke: string): string {
40
+ const { mods: names, key } = splitStroke(stroke);
41
+ const mods = new Set<string>();
42
+ for (const name of names) {
43
+ if (name === 'ctrl' || name === 'control') mods.add('ctrl');
44
+ else if (name === 'alt' || name === 'option') mods.add('alt');
45
+ else if (name === 'shift') mods.add('shift');
46
+ else mods.add('meta');
47
+ }
48
+ // Shift is not part of a stroke for a single character it *shifted*. A
49
+ // terminal reports a bare shift+p as the character `P`, and `strokeOf` reads
50
+ // it back that way, so `shift+p` would wait for a stroke no keypress makes.
51
+ //
52
+ // Held with another modifier it is the opposite. `ctrl+shift+p` cannot
53
+ // arrive as an uppercase letter, because a control code carries no case:
54
+ // a terminal speaking a keyboard protocol reports the *unshifted* key and a
55
+ // shift bit beside it. Dropping shift here filed `ctrl+shift+f` under
56
+ // `ctrl+f`, where it sat behind whichever binding registered first - so the
57
+ // workspace search had a documented key that ran the in-file search.
58
+ if ([...key].length === 1 && mods.size === 1 && mods.has('shift')) mods.delete('shift');
59
+
60
+ const order = ['ctrl', 'alt', 'shift', 'meta'].filter((m) => mods.has(m));
61
+ return [...order, key.toLowerCase()].join('+');
62
+ }
63
+
64
+ export function strokeOf(event: KeyEvent): string {
65
+ const mods: string[] = [];
66
+ if (event.ctrl) mods.push('ctrl');
67
+ if (event.alt) mods.push('alt');
68
+ // Shift is implied by an uppercase character, so it is not named for a
69
+ // single character on its own. Beside another modifier nothing is implied -
70
+ // the name is the unshifted key - so there it has to be said. Must agree
71
+ // with `normalizeStroke`.
72
+ const modified = event.ctrl || event.alt || event.meta;
73
+ if (event.shift && (event.name.length > 1 || modified)) mods.push('shift');
74
+ if (event.meta) mods.push('meta');
75
+ return [...mods, event.name.toLowerCase()].join('+');
76
+ }
77
+
78
+ /**
79
+ * Keybindings resolve to commands, never to handlers, so the same action is
80
+ * reachable from a chord, a menu row and the palette without three copies.
81
+ * A partially typed chord is remembered, which is what makes `ctrl+k ctrl+s`
82
+ * possible without blocking `ctrl+k` on its own.
83
+ */
84
+ export class Keybindings implements KeybindingRegistry {
85
+ private bindings: (KeybindingDefinition & { strokes: string[] })[] = [];
86
+ private pending: string[] = [];
87
+ private pendingTimer: ReturnType<typeof setTimeout> | null = null;
88
+
89
+ constructor(
90
+ private deps: {
91
+ when: WhenEngine;
92
+ commands: CommandRegistry;
93
+ activeScopes(): string[];
94
+ onError(err: unknown, context: string): void;
95
+ /** How long a half-typed chord waits for its next stroke. */
96
+ chordTimeoutMs?: number;
97
+ },
98
+ ) {}
99
+
100
+ register(def: KeybindingDefinition): Disposable {
101
+ const entry = { ...def, strokes: parseChord(def.keys) };
102
+ this.bindings.push(entry);
103
+ this.bindings.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
104
+ return toDisposable(() => {
105
+ const i = this.bindings.indexOf(entry);
106
+ if (i >= 0) this.bindings.splice(i, 1);
107
+ });
108
+ }
109
+
110
+ unregister(keys: Chord, commandId?: string): void {
111
+ const strokes = parseChord(keys).join(' ');
112
+ this.bindings = this.bindings.filter(
113
+ (b) => !(b.strokes.join(' ') === strokes && (commandId === undefined || b.commandId === commandId)),
114
+ );
115
+ }
116
+
117
+ list(): KeybindingDefinition[] {
118
+ return this.bindings.map(({ strokes: _strokes, ...def }) => def);
119
+ }
120
+
121
+ forCommand(commandId: string): Chord[] {
122
+ return this.bindings
123
+ // An argument-bearing binding is a key for one invocation, not for the
124
+ // command. `ctrl+shift+b` opens the palette at the sidebar panels; it is
125
+ // not the key for "Command Palette", and offering it as one put a wrong
126
+ // - and wider - hint beside that menu row.
127
+ .filter((b) => b.commandId === commandId && b.args === undefined)
128
+ .map((b) => b.keys);
129
+ }
130
+
131
+ private applicable(binding: KeybindingDefinition): boolean {
132
+ if (!this.deps.when.evaluate(binding.when)) return false;
133
+ if (!binding.scopeId) return true;
134
+ return this.deps.activeScopes().includes(binding.scopeId);
135
+ }
136
+
137
+ handle(event: KeyEvent): 'handled' | 'pending' | 'unhandled' {
138
+ const stroke = strokeOf(event);
139
+ const sequence = [...this.pending, stroke];
140
+
141
+ let sawPrefix = false;
142
+ for (const binding of this.bindings) {
143
+ if (!this.applicable(binding)) continue;
144
+
145
+ const { strokes } = binding;
146
+ if (strokes.length === sequence.length && strokes.every((s, i) => s === sequence[i])) {
147
+ this.reset();
148
+ void this.deps.commands
149
+ .execute(binding.commandId, binding.args ?? {}, 'keybinding')
150
+ .catch((err: unknown) => this.deps.onError(err, `keybinding ${binding.keys}`));
151
+ return 'handled';
152
+ }
153
+ if (
154
+ strokes.length > sequence.length &&
155
+ sequence.every((s, i) => strokes[i] === s)
156
+ ) {
157
+ sawPrefix = true;
158
+ }
159
+ }
160
+
161
+ if (sawPrefix) {
162
+ this.pending = sequence;
163
+ if (this.pendingTimer) clearTimeout(this.pendingTimer);
164
+ this.pendingTimer = setTimeout(() => this.reset(), this.deps.chordTimeoutMs ?? 1200);
165
+ this.pendingTimer.unref?.();
166
+ return 'pending';
167
+ }
168
+
169
+ this.reset();
170
+ return 'unhandled';
171
+ }
172
+
173
+ reset(): void {
174
+ this.pending = [];
175
+ if (this.pendingTimer) {
176
+ clearTimeout(this.pendingTimer);
177
+ this.pendingTimer = null;
178
+ }
179
+ }
180
+ }
181
+
182
+ export function createKeybindings(deps: ConstructorParameters<typeof Keybindings>[0]): Keybindings {
183
+ return new Keybindings(deps);
184
+ }