@svelterm/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 (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +174 -0
  3. package/dist/src/components/spinner.d.ts +11 -0
  4. package/dist/src/components/spinner.js +19 -0
  5. package/dist/src/components/text-buffer.d.ts +21 -0
  6. package/dist/src/components/text-buffer.js +87 -0
  7. package/dist/src/css/animation-runner.d.ts +17 -0
  8. package/dist/src/css/animation-runner.js +72 -0
  9. package/dist/src/css/animation.d.ts +5 -0
  10. package/dist/src/css/animation.js +6 -0
  11. package/dist/src/css/calc.d.ts +5 -0
  12. package/dist/src/css/calc.js +130 -0
  13. package/dist/src/css/color.d.ts +1 -0
  14. package/dist/src/css/color.js +157 -0
  15. package/dist/src/css/compute.d.ts +63 -0
  16. package/dist/src/css/compute.js +606 -0
  17. package/dist/src/css/defaults.d.ts +8 -0
  18. package/dist/src/css/defaults.js +44 -0
  19. package/dist/src/css/incremental.d.ts +9 -0
  20. package/dist/src/css/incremental.js +46 -0
  21. package/dist/src/css/index.d.ts +5 -0
  22. package/dist/src/css/index.js +3 -0
  23. package/dist/src/css/media.d.ts +11 -0
  24. package/dist/src/css/media.js +59 -0
  25. package/dist/src/css/parser.d.ts +20 -0
  26. package/dist/src/css/parser.js +241 -0
  27. package/dist/src/css/selector.d.ts +17 -0
  28. package/dist/src/css/selector.js +272 -0
  29. package/dist/src/css/specificity.d.ts +7 -0
  30. package/dist/src/css/specificity.js +89 -0
  31. package/dist/src/css/values.d.ts +17 -0
  32. package/dist/src/css/values.js +58 -0
  33. package/dist/src/css/variables.d.ts +6 -0
  34. package/dist/src/css/variables.js +42 -0
  35. package/dist/src/debug/console.d.ts +16 -0
  36. package/dist/src/debug/console.js +65 -0
  37. package/dist/src/debug/server.d.ts +22 -0
  38. package/dist/src/debug/server.js +90 -0
  39. package/dist/src/headless.d.ts +21 -0
  40. package/dist/src/headless.js +26 -0
  41. package/dist/src/index.d.ts +18 -0
  42. package/dist/src/index.js +485 -0
  43. package/dist/src/input/dispatch.d.ts +18 -0
  44. package/dist/src/input/dispatch.js +70 -0
  45. package/dist/src/input/focus.d.ts +18 -0
  46. package/dist/src/input/focus.js +81 -0
  47. package/dist/src/input/hit.d.ts +3 -0
  48. package/dist/src/input/hit.js +29 -0
  49. package/dist/src/input/keyboard.d.ts +9 -0
  50. package/dist/src/input/keyboard.js +100 -0
  51. package/dist/src/input/mouse.d.ts +7 -0
  52. package/dist/src/input/mouse.js +35 -0
  53. package/dist/src/input/scroll.d.ts +2 -0
  54. package/dist/src/input/scroll.js +24 -0
  55. package/dist/src/layout/cache.d.ts +4 -0
  56. package/dist/src/layout/cache.js +8 -0
  57. package/dist/src/layout/engine.d.ts +9 -0
  58. package/dist/src/layout/engine.js +455 -0
  59. package/dist/src/layout/flex.d.ts +4 -0
  60. package/dist/src/layout/flex.js +30 -0
  61. package/dist/src/layout/incremental.d.ts +8 -0
  62. package/dist/src/layout/incremental.js +58 -0
  63. package/dist/src/layout/size.d.ts +2 -0
  64. package/dist/src/layout/size.js +25 -0
  65. package/dist/src/layout/text.d.ts +7 -0
  66. package/dist/src/layout/text.js +52 -0
  67. package/dist/src/render/ansi.d.ts +23 -0
  68. package/dist/src/render/ansi.js +108 -0
  69. package/dist/src/render/border.d.ts +4 -0
  70. package/dist/src/render/border.js +60 -0
  71. package/dist/src/render/buffer.d.ts +23 -0
  72. package/dist/src/render/buffer.js +70 -0
  73. package/dist/src/render/context.d.ts +19 -0
  74. package/dist/src/render/context.js +98 -0
  75. package/dist/src/render/diff.d.ts +2 -0
  76. package/dist/src/render/diff.js +53 -0
  77. package/dist/src/render/incremental-paint.d.ts +10 -0
  78. package/dist/src/render/incremental-paint.js +94 -0
  79. package/dist/src/render/paint-text.d.ts +29 -0
  80. package/dist/src/render/paint-text.js +120 -0
  81. package/dist/src/render/paint.d.ts +5 -0
  82. package/dist/src/render/paint.js +220 -0
  83. package/dist/src/render/queue.d.ts +24 -0
  84. package/dist/src/render/queue.js +54 -0
  85. package/dist/src/render/scrollbar.d.ts +3 -0
  86. package/dist/src/render/scrollbar.js +19 -0
  87. package/dist/src/render/snapshot.d.ts +18 -0
  88. package/dist/src/render/snapshot.js +126 -0
  89. package/dist/src/renderer/default.d.ts +3 -0
  90. package/dist/src/renderer/default.js +3 -0
  91. package/dist/src/renderer/index.d.ts +11 -0
  92. package/dist/src/renderer/index.js +116 -0
  93. package/dist/src/renderer/node.d.ts +44 -0
  94. package/dist/src/renderer/node.js +153 -0
  95. package/dist/src/terminal/screen.d.ts +10 -0
  96. package/dist/src/terminal/screen.js +31 -0
  97. package/dist/src/terminal/stdin-router.d.ts +31 -0
  98. package/dist/src/terminal/stdin-router.js +133 -0
  99. package/package.json +64 -0
@@ -0,0 +1,485 @@
1
+ import { TermNode } from './renderer/index.js';
2
+ import renderer from './renderer/default.js';
3
+ import { CellBuffer } from './render/buffer.js';
4
+ import { diffBuffers } from './render/diff.js';
5
+ import { paint } from './render/paint.js';
6
+ import { parseCSS } from './css/parser.js';
7
+ import { resolveStyles, filterByMedia } from './css/compute.js';
8
+ import { resolveStylesIncremental } from './css/incremental.js';
9
+ import { computeLayout } from './layout/engine.js';
10
+ import { computeLayoutIncremental } from './layout/incremental.js';
11
+ import { syncLayoutCache } from './layout/cache.js';
12
+ import { RenderContext } from './render/context.js';
13
+ import { paintNodes } from './render/incremental-paint.js';
14
+ import { parseKeyEvent } from './input/keyboard.js';
15
+ import { parseMouseEvent } from './input/mouse.js';
16
+ import { hitTest } from './input/hit.js';
17
+ import { FocusManager } from './input/focus.js';
18
+ import { dispatchEvent } from './input/dispatch.js';
19
+ import { TextBuffer } from './components/text-buffer.js';
20
+ import { StdinRouter, matchOSC11, parseOSC11Scheme } from './terminal/stdin-router.js';
21
+ import { DebugServer } from './debug/server.js';
22
+ import { ConsoleDomain } from './debug/console.js';
23
+ import * as ansi from './render/ansi.js';
24
+ import { getTerminalSize, enterFullscreen, exitFullscreen, enableRawMode, disableRawMode, writeOutput, } from './terminal/screen.js';
25
+ export function run(AppComponent, options) {
26
+ const fullscreen = options?.fullscreen ?? true;
27
+ const mouseEnabled = options?.mouse ?? true;
28
+ const debugEnabled = options?.debug ?? false;
29
+ const debugPort = options?.debugPort ?? 9444;
30
+ const stylesheet = options?.css ? parseCSS(options.css) : null;
31
+ // Render context tracks mutations and determines minimum work
32
+ const ctx = new RenderContext();
33
+ const root = new TermNode('element', 'root');
34
+ root.ctx = ctx;
35
+ // Color scheme detection — updated by polling
36
+ let detectedScheme = 'dark';
37
+ // Wire schedule callback (defined below, hoisted via closure)
38
+ ctx.onScheduleRender = () => scheduleRender();
39
+ // Persisted render state
40
+ let prevBuffer = null;
41
+ let lastStyles;
42
+ let lastFilteredStylesheet = null;
43
+ let lastLayout;
44
+ let renderScheduled = false;
45
+ let initialRegistrationDone = false;
46
+ const scheduleRender = () => {
47
+ if (renderScheduled)
48
+ return;
49
+ renderScheduled = true;
50
+ queueMicrotask(() => {
51
+ renderScheduled = false;
52
+ processQueue();
53
+ });
54
+ };
55
+ const processQueue = () => {
56
+ const snap = ctx.queue.snapshot();
57
+ if (snap.fullRecompute || !lastStyles || !lastLayout) {
58
+ // Full recompute — initial render, resize, or CSS reload
59
+ fullRender();
60
+ }
61
+ else if (snap.paintOnly.size > 0 || snap.styleResolve.size > 0
62
+ || snap.layoutSubtree.size > 0 || snap.layoutBubble.size > 0) {
63
+ // Incremental render
64
+ incrementalRender(snap);
65
+ }
66
+ };
67
+ const fullRender = () => {
68
+ const size = getTerminalSize();
69
+ // Set root dimensions so children can use percentage width/height
70
+ root.attributes.set('data-width', String(size.width));
71
+ root.attributes.set('data-height', String(size.height));
72
+ const buffer = new CellBuffer(size.width, size.height);
73
+ const media = { colorScheme: detectedScheme, displayMode: 'terminal', width: size.width, height: size.height };
74
+ lastFilteredStylesheet = stylesheet ? filterByMedia(stylesheet, media) : null;
75
+ lastStyles = lastFilteredStylesheet ? resolveStyles(root, lastFilteredStylesheet) : undefined;
76
+ // Ensure root style has terminal dimensions for percentage resolution
77
+ if (lastStyles) {
78
+ const rootStyle = lastStyles.get(root.id);
79
+ if (rootStyle) {
80
+ rootStyle.width = size.width;
81
+ rootStyle.height = size.height;
82
+ }
83
+ }
84
+ lastLayout = lastStyles ? computeLayout(root, lastStyles, size.width, size.height) : undefined;
85
+ if (lastLayout)
86
+ syncLayoutCache(root, lastLayout);
87
+ paint(root, buffer, lastStyles, lastLayout);
88
+ const output = diffBuffers(prevBuffer, buffer);
89
+ if (output.length > 0)
90
+ writeOutput(output);
91
+ prevBuffer = buffer;
92
+ // Register focusable elements after initial render
93
+ if (!initialRegistrationDone) {
94
+ registerFocusableNodes(root, focusManager);
95
+ initialRegistrationDone = true;
96
+ }
97
+ };
98
+ const incrementalRender = (snap) => {
99
+ const size = getTerminalSize();
100
+ // Mutable copies for promoted nodes during processing
101
+ const layoutSubtree = new Set(snap.layoutSubtree);
102
+ const layoutBubble = new Set(snap.layoutBubble);
103
+ // Step 1: Incremental style resolution
104
+ if (snap.styleResolve.size > 0 && lastStyles && lastFilteredStylesheet) {
105
+ lastStyles = resolveStylesIncremental(root, lastFilteredStylesheet, lastStyles, snap.styleResolve, undefined, (node) => { layoutSubtree.add(node); });
106
+ }
107
+ // Step 2: Incremental layout
108
+ if (layoutSubtree.size > 0 || layoutBubble.size > 0) {
109
+ const dirtyLayoutNodes = new Set([...layoutSubtree, ...layoutBubble]);
110
+ if (lastStyles && lastLayout) {
111
+ lastLayout = computeLayoutIncremental(root, lastStyles, lastLayout, dirtyLayoutNodes, size.width, size.height);
112
+ }
113
+ else {
114
+ lastLayout = lastStyles ? computeLayout(root, lastStyles, size.width, size.height) : undefined;
115
+ }
116
+ if (lastLayout)
117
+ syncLayoutCache(root, lastLayout);
118
+ }
119
+ // Step 3: Repaint
120
+ const noLayoutChanges = layoutSubtree.size === 0 && layoutBubble.size === 0;
121
+ const dirtyPaintNodes = new Set(snap.paintOnly);
122
+ // Style-resolved nodes that didn't affect layout still need repaint
123
+ if (noLayoutChanges) {
124
+ for (const node of snap.styleResolve)
125
+ dirtyPaintNodes.add(node);
126
+ }
127
+ const hasScroll = hasScrolledNode(root);
128
+ if (noLayoutChanges && !hasScroll && dirtyPaintNodes.size > 0 && prevBuffer && lastStyles && lastLayout) {
129
+ const buffer = prevBuffer.clone();
130
+ paintNodes(dirtyPaintNodes, buffer, lastStyles, lastLayout, root);
131
+ const output = diffBuffers(prevBuffer, buffer);
132
+ if (output.length > 0)
133
+ writeOutput(output);
134
+ prevBuffer = buffer;
135
+ }
136
+ else {
137
+ const buffer = new CellBuffer(size.width, size.height);
138
+ paint(root, buffer, lastStyles, lastLayout);
139
+ const output = diffBuffers(prevBuffer, buffer);
140
+ if (output.length > 0)
141
+ writeOutput(output);
142
+ prevBuffer = buffer;
143
+ }
144
+ };
145
+ const focusManager = new FocusManager();
146
+ focusManager.onSetAttribute = (node, key, value) => ctx.onSetAttribute(node, key, value);
147
+ focusManager.onRemoveAttribute = (node, key) => ctx.onRemoveAttribute(node, key);
148
+ focusManager.onFocusChange = (focused, previous) => {
149
+ if (previous)
150
+ dispatchEvent(previous, 'blur');
151
+ if (focused) {
152
+ dispatchEvent(focused, 'focus');
153
+ scrollIntoView(focused, lastLayout, lastStyles, ctx);
154
+ }
155
+ };
156
+ // Register focusable nodes on insert, unregister on remove
157
+ const origInsert = ctx.onInsert.bind(ctx);
158
+ ctx.onInsert = (parent, child) => {
159
+ origInsert(parent, child);
160
+ if (initialRegistrationDone) {
161
+ registerFocusableNodes(child, focusManager);
162
+ }
163
+ };
164
+ const origRemove = ctx.onRemove.bind(ctx);
165
+ ctx.onRemove = (child, parent) => {
166
+ origRemove(child, parent);
167
+ unregisterFocusableNodes(child, focusManager);
168
+ child.cleanup();
169
+ };
170
+ enableRawMode();
171
+ if (fullscreen)
172
+ enterFullscreen();
173
+ // Write mode sequences directly — sync update wrapping can interfere
174
+ process.stdout.write(ansi.enableBracketedPaste());
175
+ if (mouseEnabled)
176
+ process.stdout.write(ansi.enableMouse());
177
+ // Single stdin router — all input flows through here
178
+ const router = new StdinRouter();
179
+ const handleKeyData = (data) => {
180
+ const key = parseKeyEvent(data);
181
+ if (!key)
182
+ return;
183
+ if (key.ctrl && key.key === 'c') {
184
+ doCleanup();
185
+ process.exit(0);
186
+ }
187
+ if (key.ctrl && key.key === 'z') {
188
+ doCleanup();
189
+ process.kill(process.pid, 'SIGTSTP');
190
+ return;
191
+ }
192
+ if (key.key === 'Tab' && key.shift) {
193
+ focusManager.focusPrevious();
194
+ scheduleRender();
195
+ return;
196
+ }
197
+ if (key.key === 'Tab') {
198
+ focusManager.focusNext();
199
+ scheduleRender();
200
+ return;
201
+ }
202
+ if (key.key === 'Enter' && focusManager.focused) {
203
+ const target = focusManager.focused;
204
+ const event = dispatchEvent(target, 'click');
205
+ // Default action: open links in browser (unless preventDefault was called)
206
+ if (!event.defaultPrevented && target.tag === 'a') {
207
+ const href = target.attributes.get('href');
208
+ if (href)
209
+ openUrl(href);
210
+ }
211
+ scheduleRender();
212
+ return;
213
+ }
214
+ // Text input for focused input/textarea
215
+ const focused = focusManager.focused;
216
+ if (focused && (focused.tag === 'input' || focused.tag === 'textarea')) {
217
+ if (!focused.textBuffer)
218
+ focused.textBuffer = new TextBuffer(focused.attributes.get('value') ?? '');
219
+ if (focused.textBuffer.handleKey(key)) {
220
+ const newValue = focused.textBuffer.text;
221
+ focused.attributes.set('value', newValue);
222
+ const textChild = focused.children.find(c => c.nodeType === 'text');
223
+ if (textChild)
224
+ ctx.onSetText(textChild, newValue);
225
+ // Enqueue the input element itself for repaint (cursor may have moved)
226
+ ctx.queue.enqueuePaintOnly(focused);
227
+ dispatchEvent(focused, 'input', { value: newValue, cursor: focused.textBuffer.cursor });
228
+ scheduleRender();
229
+ return;
230
+ }
231
+ }
232
+ const keyTarget = focused ?? findFirstElement(root);
233
+ if (keyTarget) {
234
+ dispatchEvent(keyTarget, 'keydown', key);
235
+ scheduleRender();
236
+ }
237
+ };
238
+ const handleMouseData = (data) => {
239
+ const mouse = parseMouseEvent(data);
240
+ if (!mouse)
241
+ return;
242
+ handleMouse(mouse, root, lastLayout, focusManager, scheduleRender, lastStyles, ctx);
243
+ };
244
+ const handlePaste = (text) => {
245
+ const focused = focusManager.focused;
246
+ if (focused && (focused.tag === 'input' || focused.tag === 'textarea')) {
247
+ if (!focused.textBuffer)
248
+ focused.textBuffer = new TextBuffer(focused.attributes.get('value') ?? '');
249
+ focused.textBuffer.insert(text);
250
+ const newValue = focused.textBuffer.text;
251
+ focused.attributes.set('value', newValue);
252
+ const textChild = focused.children.find(c => c.nodeType === 'text');
253
+ if (textChild)
254
+ ctx.onSetText(textChild, newValue);
255
+ dispatchEvent(focused, 'input', { value: newValue, cursor: focused.textBuffer.cursor });
256
+ scheduleRender();
257
+ }
258
+ else {
259
+ const target = focused ?? findFirstElement(root);
260
+ if (target)
261
+ dispatchEvent(target, 'paste', { text });
262
+ }
263
+ };
264
+ router.start({ onKey: handleKeyData, onMouse: handleMouseData, onPaste: handlePaste });
265
+ // Debug server (opt-in)
266
+ let debugServer = null;
267
+ let consoleDomain = null;
268
+ if (debugEnabled) {
269
+ debugServer = new DebugServer(debugPort);
270
+ consoleDomain = new ConsoleDomain(debugServer);
271
+ debugServer.registerDomain('Console', consoleDomain);
272
+ consoleDomain.start();
273
+ debugServer.start();
274
+ }
275
+ // Serialised color scheme detection via router query
276
+ const detectScheme = async () => {
277
+ const result = await router.query('\x1b]11;?\x07', matchOSC11, 200);
278
+ return result ? parseOSC11Scheme(result) : 'dark';
279
+ };
280
+ // Render immediately with default scheme
281
+ ctx.queue.setFullRecompute();
282
+ const { unmount: svUnmount } = renderer.render(AppComponent, { target: root, props: options?.props ?? {} });
283
+ scheduleRender();
284
+ setupResizeHandler(() => { ctx.onResize(); prevBuffer = null; scheduleRender(); });
285
+ // Detect color scheme in background and re-render if different
286
+ let pollRunning = true;
287
+ const pollScheme = async () => {
288
+ if (!pollRunning)
289
+ return;
290
+ try {
291
+ const scheme = await detectScheme();
292
+ if (scheme !== detectedScheme) {
293
+ detectedScheme = scheme;
294
+ lastFilteredStylesheet = stylesheet ? filterByMedia(stylesheet, { colorScheme: detectedScheme, displayMode: 'terminal', width: getTerminalSize().width, height: getTerminalSize().height }) : null;
295
+ ctx.onResize();
296
+ prevBuffer = null;
297
+ scheduleRender();
298
+ }
299
+ }
300
+ catch {
301
+ // Terminal may not support color scheme queries — ignore
302
+ }
303
+ if (pollRunning)
304
+ setTimeout(pollScheme, 1000);
305
+ };
306
+ pollScheme();
307
+ const appCleanup = createCleanup(svUnmount, fullscreen, mouseEnabled);
308
+ const doCleanup = () => {
309
+ pollRunning = false;
310
+ router.stop();
311
+ consoleDomain?.stop();
312
+ debugServer?.stop();
313
+ appCleanup();
314
+ };
315
+ process.on('SIGINT', () => { doCleanup(); process.exit(0); });
316
+ process.on('SIGTERM', () => { doCleanup(); process.exit(0); });
317
+ return doCleanup;
318
+ }
319
+ function createCleanup(unmountComponent, fullscreen, mouseEnabled) {
320
+ let cleaned = false;
321
+ return () => {
322
+ if (cleaned)
323
+ return;
324
+ cleaned = true;
325
+ unmountComponent();
326
+ if (mouseEnabled)
327
+ writeOutput(ansi.disableMouse());
328
+ writeOutput(ansi.disableBracketedPaste());
329
+ if (fullscreen)
330
+ exitFullscreen();
331
+ disableRawMode();
332
+ };
333
+ }
334
+ function handleMouse(mouse, root, layout, focusManager, scheduleRender, lastStyles, ctx) {
335
+ if (!layout)
336
+ return;
337
+ // Handle hover — set data-hovered on element under cursor
338
+ if (mouse.type === 'motion') {
339
+ const target = hitTest(root, layout, mouse.col, mouse.row);
340
+ const hoveredId = target?.id ?? -1;
341
+ // Walk tree and update data-hovered
342
+ updateHover(root, hoveredId, ctx);
343
+ scheduleRender();
344
+ return;
345
+ }
346
+ if (mouse.type !== 'press' && mouse.type !== 'scroll')
347
+ return;
348
+ if (mouse.button === 'left') {
349
+ const target = hitTest(root, layout, mouse.col, mouse.row);
350
+ if (target) {
351
+ // Focus clicked element if it's focusable
352
+ if (FOCUSABLE_TAGS.has(target.tag ?? '')) {
353
+ focusManager.focusByNode(target);
354
+ }
355
+ const event = dispatchEvent(target, 'click', mouse);
356
+ if (!event.defaultPrevented && target.tag === 'a') {
357
+ const href = target.attributes.get('href');
358
+ if (href)
359
+ openUrl(href);
360
+ }
361
+ scheduleRender();
362
+ }
363
+ }
364
+ else if (mouse.button === 'scrollUp' || mouse.button === 'scrollDown') {
365
+ const target = hitTest(root, layout, mouse.col, mouse.row);
366
+ if (target) {
367
+ const scrollTarget = findScrollableAncestor(target, lastStyles);
368
+ if (scrollTarget) {
369
+ const box = layout.get(scrollTarget.id);
370
+ if (box) {
371
+ const contentHeight = scrollTarget.children.reduce((sum, c) => {
372
+ const cBox = layout.get(c.id);
373
+ return cBox ? Math.max(sum, cBox.y - box.y + cBox.height) : sum;
374
+ }, 0);
375
+ const delta = mouse.button === 'scrollUp' ? -3 : 3;
376
+ const maxScroll = Math.max(0, contentHeight - box.height);
377
+ scrollTarget.scrollTop = Math.max(0, Math.min(scrollTarget.scrollTop + delta, maxScroll));
378
+ ctx.onScroll(scrollTarget);
379
+ }
380
+ }
381
+ dispatchEvent(target, 'scroll', mouse);
382
+ scheduleRender();
383
+ }
384
+ }
385
+ }
386
+ function setupResizeHandler(onResize) {
387
+ process.stdout.on('resize', onResize);
388
+ }
389
+ const FOCUSABLE_TAGS = new Set(['button', 'input', 'textarea', 'a', 'select']);
390
+ function registerFocusableNodes(node, focusManager) {
391
+ if (node.nodeType === 'element' && FOCUSABLE_TAGS.has(node.tag ?? '')) {
392
+ focusManager.register(node);
393
+ }
394
+ for (const child of node.children) {
395
+ registerFocusableNodes(child, focusManager);
396
+ }
397
+ }
398
+ function unregisterFocusableNodes(node, focusManager) {
399
+ if (node.nodeType === 'element' && FOCUSABLE_TAGS.has(node.tag ?? '')) {
400
+ focusManager.unregister(node);
401
+ }
402
+ for (const child of node.children) {
403
+ unregisterFocusableNodes(child, focusManager);
404
+ }
405
+ }
406
+ function updateHover(node, hoveredId, ctx) {
407
+ if (node.nodeType !== 'element')
408
+ return;
409
+ const isHovered = node.id === hoveredId;
410
+ const wasHovered = node.attributes.has('data-hovered');
411
+ if (isHovered && !wasHovered) {
412
+ ctx.onSetAttribute(node, 'data-hovered', 'true');
413
+ }
414
+ else if (!isHovered && wasHovered) {
415
+ ctx.onRemoveAttribute(node, 'data-hovered');
416
+ }
417
+ for (const child of node.children) {
418
+ updateHover(child, hoveredId, ctx);
419
+ }
420
+ }
421
+ function findScrollableAncestor(node, styles) {
422
+ let current = node;
423
+ while (current) {
424
+ const style = styles?.get(current.id);
425
+ if (style && (style.overflow === 'scroll' || style.overflow === 'auto' || style.overflow === 'hidden')) {
426
+ return current;
427
+ }
428
+ current = current.parent;
429
+ }
430
+ return null;
431
+ }
432
+ function scrollIntoView(node, layout, styles, ctx) {
433
+ if (!layout)
434
+ return;
435
+ const nodeBox = layout.get(node.id);
436
+ if (!nodeBox)
437
+ return;
438
+ const scroller = findScrollableAncestor(node, styles);
439
+ if (!scroller)
440
+ return;
441
+ const scrollerBox = layout.get(scroller.id);
442
+ if (!scrollerBox)
443
+ return;
444
+ const borderInset = (styles?.get(scroller.id)?.borderStyle !== 'none' &&
445
+ styles?.get(scroller.id)?.borderStyle !== undefined) ? 1 : 0;
446
+ const viewTop = scrollerBox.y + borderInset + scroller.scrollTop;
447
+ const viewBottom = viewTop + scrollerBox.height - borderInset * 2;
448
+ // Node position relative to scroller content
449
+ if (nodeBox.y < viewTop) {
450
+ scroller.scrollTop = nodeBox.y - scrollerBox.y - borderInset;
451
+ ctx.onScroll(scroller);
452
+ }
453
+ else if (nodeBox.y + nodeBox.height > viewBottom) {
454
+ scroller.scrollTop = nodeBox.y + nodeBox.height - scrollerBox.y - scrollerBox.height + borderInset;
455
+ ctx.onScroll(scroller);
456
+ }
457
+ }
458
+ function openUrl(url) {
459
+ const { exec } = require('child_process');
460
+ const cmd = process.platform === 'darwin' ? 'open'
461
+ : process.platform === 'win32' ? 'start'
462
+ : 'xdg-open';
463
+ exec(`${cmd} ${JSON.stringify(url)}`);
464
+ }
465
+ function hasScrolledNode(node) {
466
+ if (node.scrollTop !== 0 || node.scrollLeft !== 0)
467
+ return true;
468
+ for (const child of node.children) {
469
+ if (hasScrolledNode(child))
470
+ return true;
471
+ }
472
+ return false;
473
+ }
474
+ function findFirstElement(node) {
475
+ for (const child of node.children) {
476
+ if (child.nodeType === 'element')
477
+ return child;
478
+ }
479
+ return node;
480
+ }
481
+ export { TermNode } from './renderer/node.js';
482
+ export { CellBuffer } from './render/buffer.js';
483
+ export { parseCSS } from './css/parser.js';
484
+ export { resolveStyles } from './css/compute.js';
485
+ export { StdinRouter } from './terminal/stdin-router.js';
@@ -0,0 +1,18 @@
1
+ import { TermNode } from '../renderer/node.js';
2
+ export interface TermEvent {
3
+ type: string;
4
+ target: TermNode;
5
+ data?: any;
6
+ propagationStopped: boolean;
7
+ defaultPrevented: boolean;
8
+ stopPropagation(): void;
9
+ preventDefault(): void;
10
+ }
11
+ /**
12
+ * Dispatch an event with W3C-style capture and bubble phases.
13
+ *
14
+ * 1. Capture phase: root → ... → target.parent (type__capture listeners)
15
+ * 2. Target phase: fire listeners on target (both capture and bubble)
16
+ * 3. Bubble phase: target.parent → ... → root (type listeners)
17
+ */
18
+ export declare function dispatchEvent(target: TermNode, type: string, data?: any): TermEvent;
@@ -0,0 +1,70 @@
1
+ function createEvent(type, target, data) {
2
+ const event = {
3
+ type,
4
+ target,
5
+ data,
6
+ propagationStopped: false,
7
+ defaultPrevented: false,
8
+ stopPropagation() { event.propagationStopped = true; },
9
+ preventDefault() { event.defaultPrevented = true; },
10
+ };
11
+ return event;
12
+ }
13
+ /**
14
+ * Dispatch an event with W3C-style capture and bubble phases.
15
+ *
16
+ * 1. Capture phase: root → ... → target.parent (type__capture listeners)
17
+ * 2. Target phase: fire listeners on target (both capture and bubble)
18
+ * 3. Bubble phase: target.parent → ... → root (type listeners)
19
+ */
20
+ export function dispatchEvent(target, type, data) {
21
+ const event = createEvent(type, target, data);
22
+ // Build ancestor path: [root, ..., parent]
23
+ const path = [];
24
+ let ancestor = target.parent;
25
+ while (ancestor) {
26
+ path.unshift(ancestor);
27
+ ancestor = ancestor.parent;
28
+ }
29
+ // Phase 1: Capture (root → target.parent)
30
+ const captureType = type + '__capture';
31
+ for (const node of path) {
32
+ const handlers = node.listeners.get(captureType);
33
+ if (handlers) {
34
+ for (const handler of handlers) {
35
+ handler(event);
36
+ }
37
+ }
38
+ if (event.propagationStopped)
39
+ return event;
40
+ }
41
+ // Phase 2: Target (fire both capture and bubble listeners on target)
42
+ const captureHandlers = target.listeners.get(captureType);
43
+ if (captureHandlers) {
44
+ for (const handler of captureHandlers) {
45
+ handler(event);
46
+ }
47
+ }
48
+ if (!event.propagationStopped) {
49
+ const handlers = target.listeners.get(type);
50
+ if (handlers) {
51
+ for (const handler of handlers) {
52
+ handler(event);
53
+ }
54
+ }
55
+ }
56
+ if (event.propagationStopped)
57
+ return event;
58
+ // Phase 3: Bubble (target.parent → root)
59
+ for (let i = path.length - 1; i >= 0; i--) {
60
+ const handlers = path[i].listeners.get(type);
61
+ if (handlers) {
62
+ for (const handler of handlers) {
63
+ handler(event);
64
+ }
65
+ }
66
+ if (event.propagationStopped)
67
+ break;
68
+ }
69
+ return event;
70
+ }
@@ -0,0 +1,18 @@
1
+ import { TermNode } from '../renderer/node.js';
2
+ export declare class FocusManager {
3
+ private elements;
4
+ private focusIndex;
5
+ onSetAttribute?: (node: TermNode, key: string, value: string) => void;
6
+ onRemoveAttribute?: (node: TermNode, key: string) => void;
7
+ onFocusChange?: (focused: TermNode | null, previous: TermNode | null) => void;
8
+ get focused(): TermNode | null;
9
+ get count(): number;
10
+ register(node: TermNode): void;
11
+ unregister(node: TermNode): void;
12
+ focusNext(): void;
13
+ focusPrevious(): void;
14
+ focusByNode(node: TermNode): void;
15
+ clearFocus(): void;
16
+ private setFocusIndex;
17
+ private clearFocusAttribute;
18
+ }
@@ -0,0 +1,81 @@
1
+ export class FocusManager {
2
+ elements = [];
3
+ focusIndex = -1;
4
+ onSetAttribute;
5
+ onRemoveAttribute;
6
+ onFocusChange;
7
+ get focused() {
8
+ if (this.focusIndex < 0 || this.focusIndex >= this.elements.length)
9
+ return null;
10
+ return this.elements[this.focusIndex];
11
+ }
12
+ get count() {
13
+ return this.elements.length;
14
+ }
15
+ register(node) {
16
+ if (!this.elements.includes(node)) {
17
+ this.elements.push(node);
18
+ }
19
+ }
20
+ unregister(node) {
21
+ const idx = this.elements.indexOf(node);
22
+ if (idx === -1)
23
+ return;
24
+ const wasFocused = idx === this.focusIndex;
25
+ this.elements.splice(idx, 1);
26
+ if (wasFocused) {
27
+ this.clearFocusAttribute(node);
28
+ this.focusIndex = -1;
29
+ }
30
+ else if (idx < this.focusIndex) {
31
+ this.focusIndex--;
32
+ }
33
+ }
34
+ focusNext() {
35
+ if (this.elements.length === 0)
36
+ return;
37
+ this.setFocusIndex((this.focusIndex + 1) % this.elements.length);
38
+ }
39
+ focusPrevious() {
40
+ if (this.elements.length === 0)
41
+ return;
42
+ const next = this.focusIndex <= 0
43
+ ? this.elements.length - 1
44
+ : this.focusIndex - 1;
45
+ this.setFocusIndex(next);
46
+ }
47
+ focusByNode(node) {
48
+ const idx = this.elements.indexOf(node);
49
+ if (idx !== -1)
50
+ this.setFocusIndex(idx);
51
+ }
52
+ clearFocus() {
53
+ if (this.focused)
54
+ this.clearFocusAttribute(this.focused);
55
+ this.focusIndex = -1;
56
+ }
57
+ setFocusIndex(index) {
58
+ const prev = this.focused;
59
+ if (prev)
60
+ this.clearFocusAttribute(prev);
61
+ this.focusIndex = index;
62
+ const next = this.focused;
63
+ if (next) {
64
+ if (this.onSetAttribute) {
65
+ this.onSetAttribute(next, 'data-focused', 'true');
66
+ }
67
+ else {
68
+ next.attributes.set('data-focused', 'true');
69
+ }
70
+ }
71
+ this.onFocusChange?.(next, prev);
72
+ }
73
+ clearFocusAttribute(node) {
74
+ if (this.onRemoveAttribute) {
75
+ this.onRemoveAttribute(node, 'data-focused');
76
+ }
77
+ else {
78
+ node.attributes.delete('data-focused');
79
+ }
80
+ }
81
+ }
@@ -0,0 +1,3 @@
1
+ import { TermNode } from '../renderer/node.js';
2
+ import { LayoutBox } from '../layout/engine.js';
3
+ export declare function hitTest(root: TermNode, layout: Map<number, LayoutBox>, col: number, row: number): TermNode | null;