@svelterm/core 0.1.0 → 0.23.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 (166) hide show
  1. package/CHANGELOG.md +465 -0
  2. package/README.md +42 -29
  3. package/dist/src/cli/build.d.ts +13 -0
  4. package/dist/src/cli/build.js +119 -0
  5. package/dist/src/cli/bundle.d.ts +25 -0
  6. package/dist/src/cli/bundle.js +61 -0
  7. package/dist/src/cli/dev.d.ts +10 -0
  8. package/dist/src/cli/dev.js +152 -0
  9. package/dist/src/cli/devtools.d.ts +9 -0
  10. package/dist/src/cli/devtools.js +47 -0
  11. package/dist/src/cli/init.d.ts +8 -0
  12. package/dist/src/cli/init.js +153 -0
  13. package/dist/src/cli/main.d.ts +9 -0
  14. package/dist/src/cli/main.js +52 -0
  15. package/dist/src/cli/svt-bin.d.ts +2 -0
  16. package/dist/src/cli/svt-bin.js +6 -0
  17. package/dist/src/cli/svt.d.ts +14 -0
  18. package/dist/src/cli/svt.js +76 -0
  19. package/dist/src/components/text-buffer.js +8 -5
  20. package/dist/src/css/animation-runner.d.ts +15 -6
  21. package/dist/src/css/animation-runner.js +80 -29
  22. package/dist/src/css/animation.d.ts +12 -0
  23. package/dist/src/css/animation.js +21 -0
  24. package/dist/src/css/calc.js +4 -3
  25. package/dist/src/css/color.d.ts +19 -0
  26. package/dist/src/css/color.js +371 -62
  27. package/dist/src/css/compute.d.ts +31 -4
  28. package/dist/src/css/compute.js +273 -34
  29. package/dist/src/css/defaults.d.ts +1 -1
  30. package/dist/src/css/defaults.js +9 -0
  31. package/dist/src/css/easing.d.ts +9 -0
  32. package/dist/src/css/easing.js +95 -0
  33. package/dist/src/css/incremental.d.ts +1 -1
  34. package/dist/src/css/incremental.js +2 -2
  35. package/dist/src/css/interpolate.d.ts +13 -0
  36. package/dist/src/css/interpolate.js +41 -0
  37. package/dist/src/css/parser.js +59 -3
  38. package/dist/src/css/pseudo-elements.d.ts +9 -0
  39. package/dist/src/css/pseudo-elements.js +97 -0
  40. package/dist/src/css/selector.d.ts +17 -2
  41. package/dist/src/css/selector.js +128 -13
  42. package/dist/src/css/specificity.js +17 -6
  43. package/dist/src/css/values.d.ts +6 -1
  44. package/dist/src/css/values.js +13 -6
  45. package/dist/src/debug/context.d.ts +13 -0
  46. package/dist/src/debug/context.js +11 -0
  47. package/dist/src/debug/css.d.ts +12 -0
  48. package/dist/src/debug/css.js +28 -0
  49. package/dist/src/debug/dom.d.ts +17 -0
  50. package/dist/src/debug/dom.js +92 -0
  51. package/dist/src/devtools/DevTools.compiled.js +327 -0
  52. package/dist/src/devtools/DevTools.css.js +1 -0
  53. package/dist/src/devtools/client.d.ts +36 -0
  54. package/dist/src/devtools/client.js +76 -0
  55. package/dist/src/framelog.d.ts +54 -0
  56. package/dist/src/framelog.js +99 -0
  57. package/dist/src/headless.js +12 -4
  58. package/dist/src/index.d.ts +66 -3
  59. package/dist/src/index.js +610 -81
  60. package/dist/src/input/checkable.d.ts +8 -0
  61. package/dist/src/input/checkable.js +66 -0
  62. package/dist/src/input/details.d.ts +6 -0
  63. package/dist/src/input/details.js +34 -0
  64. package/dist/src/input/focus.d.ts +6 -0
  65. package/dist/src/input/focus.js +27 -9
  66. package/dist/src/input/keyboard.d.ts +2 -2
  67. package/dist/src/input/keyboard.js +32 -5
  68. package/dist/src/input/label.d.ts +8 -0
  69. package/dist/src/input/label.js +53 -0
  70. package/dist/src/input/modal.d.ts +9 -0
  71. package/dist/src/input/modal.js +28 -0
  72. package/dist/src/input/mouse.d.ts +2 -2
  73. package/dist/src/input/mouse.js +15 -2
  74. package/dist/src/input/select.d.ts +12 -0
  75. package/dist/src/input/select.js +63 -0
  76. package/dist/src/input/selection.d.ts +48 -0
  77. package/dist/src/input/selection.js +150 -0
  78. package/dist/src/layout/engine.d.ts +2 -0
  79. package/dist/src/layout/engine.js +1092 -142
  80. package/dist/src/layout/flex.js +4 -4
  81. package/dist/src/layout/size.js +3 -2
  82. package/dist/src/layout/text.d.ts +3 -2
  83. package/dist/src/layout/text.js +96 -17
  84. package/dist/src/layout/unicode.d.ts +20 -0
  85. package/dist/src/layout/unicode.js +121 -0
  86. package/dist/src/render/animation-clock.d.ts +57 -0
  87. package/dist/src/render/animation-clock.js +221 -0
  88. package/dist/src/render/ansi-text.d.ts +26 -0
  89. package/dist/src/render/ansi-text.js +131 -0
  90. package/dist/src/render/ansi.d.ts +18 -0
  91. package/dist/src/render/ansi.js +64 -19
  92. package/dist/src/render/border.js +166 -17
  93. package/dist/src/render/buffer.d.ts +1 -0
  94. package/dist/src/render/buffer.js +5 -2
  95. package/dist/src/render/clock.d.ts +35 -0
  96. package/dist/src/render/clock.js +67 -0
  97. package/dist/src/render/color-depth.d.ts +8 -0
  98. package/dist/src/render/color-depth.js +59 -0
  99. package/dist/src/render/context.d.ts +1 -0
  100. package/dist/src/render/context.js +17 -21
  101. package/dist/src/render/cursor-emit.d.ts +18 -0
  102. package/dist/src/render/cursor-emit.js +50 -0
  103. package/dist/src/render/diff.d.ts +12 -0
  104. package/dist/src/render/diff.js +120 -0
  105. package/dist/src/render/generation.d.ts +9 -0
  106. package/dist/src/render/generation.js +14 -0
  107. package/dist/src/render/graphics-layer.d.ts +27 -0
  108. package/dist/src/render/graphics-layer.js +86 -0
  109. package/dist/src/render/image.d.ts +27 -0
  110. package/dist/src/render/image.js +113 -0
  111. package/dist/src/render/incremental-paint.d.ts +7 -3
  112. package/dist/src/render/incremental-paint.js +52 -79
  113. package/dist/src/render/inline.d.ts +59 -0
  114. package/dist/src/render/inline.js +219 -0
  115. package/dist/src/render/kitty-graphics.d.ts +24 -0
  116. package/dist/src/render/kitty-graphics.js +58 -0
  117. package/dist/src/render/paint-text.js +68 -22
  118. package/dist/src/render/paint.d.ts +8 -1
  119. package/dist/src/render/paint.js +358 -31
  120. package/dist/src/render/png.d.ts +13 -0
  121. package/dist/src/render/png.js +145 -0
  122. package/dist/src/render/scrollbar.d.ts +8 -2
  123. package/dist/src/render/scrollbar.js +71 -14
  124. package/dist/src/render/snapshot.js +3 -1
  125. package/dist/src/renderer/default.d.ts +7 -0
  126. package/dist/src/renderer/default.js +11 -0
  127. package/dist/src/renderer/index.d.ts +8 -2
  128. package/dist/src/renderer/index.js +4 -2
  129. package/dist/src/renderer/node.d.ts +109 -0
  130. package/dist/src/renderer/node.js +165 -1
  131. package/dist/src/terminal/capabilities.d.ts +33 -0
  132. package/dist/src/terminal/capabilities.js +66 -0
  133. package/dist/src/terminal/clipboard.d.ts +9 -0
  134. package/dist/src/terminal/clipboard.js +39 -0
  135. package/dist/src/terminal/io.d.ts +82 -0
  136. package/dist/src/terminal/io.js +155 -0
  137. package/dist/src/terminal/screen.d.ts +3 -10
  138. package/dist/src/terminal/screen.js +5 -28
  139. package/dist/src/terminal/stdin-router.d.ts +8 -5
  140. package/dist/src/terminal/stdin-router.js +22 -11
  141. package/dist/src/utils/node-map.d.ts +24 -0
  142. package/dist/src/utils/node-map.js +75 -0
  143. package/dist/src/vite/config.d.ts +62 -0
  144. package/dist/src/vite/config.js +191 -0
  145. package/docs/compatibility.md +67 -0
  146. package/docs/debug/devtools.md +40 -0
  147. package/docs/debug/svt.md +50 -0
  148. package/docs/distribution.md +106 -0
  149. package/docs/elements.md +120 -0
  150. package/docs/getting-started.md +177 -0
  151. package/docs/guide/css.md +187 -0
  152. package/docs/guide/input.md +143 -0
  153. package/docs/guide/layout.md +171 -0
  154. package/docs/guide/theming.md +94 -0
  155. package/docs/how-it-works.md +115 -0
  156. package/docs/inline-mode.md +77 -0
  157. package/docs/layout.md +112 -0
  158. package/docs/motion.md +91 -0
  159. package/docs/reference/README.md +65 -0
  160. package/docs/reference/css/properties/border-corner.md +82 -0
  161. package/docs/reference/css/properties/border-style.md +168 -0
  162. package/docs/reference.md +227 -0
  163. package/docs/selectors.md +80 -0
  164. package/docs/terminal-css.md +149 -0
  165. package/docs/terminals.md +83 -0
  166. package/package.json +28 -7
package/dist/src/index.js CHANGED
@@ -1,10 +1,16 @@
1
+ import { mount, unmount } from 'svelte/renderer';
1
2
  import { TermNode } from './renderer/index.js';
3
+ import { hasBooleanAttribute } from './renderer/node.js';
4
+ import { AnimationClock } from './render/animation-clock.js';
5
+ import { getKeyframes } from './css/animation.js';
2
6
  import renderer from './renderer/default.js';
3
7
  import { CellBuffer } from './render/buffer.js';
4
8
  import { diffBuffers } from './render/diff.js';
5
9
  import { paint } from './render/paint.js';
6
10
  import { parseCSS } from './css/parser.js';
11
+ import { DEFAULT_STYLESHEET } from './css/defaults.js';
7
12
  import { resolveStyles, filterByMedia } from './css/compute.js';
13
+ import { collectVariables } from './css/variables.js';
8
14
  import { resolveStylesIncremental } from './css/incremental.js';
9
15
  import { computeLayout } from './layout/engine.js';
10
16
  import { computeLayoutIncremental } from './layout/incremental.js';
@@ -16,33 +22,159 @@ import { parseMouseEvent } from './input/mouse.js';
16
22
  import { hitTest } from './input/hit.js';
17
23
  import { FocusManager } from './input/focus.js';
18
24
  import { dispatchEvent } from './input/dispatch.js';
25
+ import { isCheckableInput, toggleCheckable } from './input/checkable.js';
26
+ import { toggleDetails } from './input/details.js';
27
+ import { cycleSelect } from './input/select.js';
28
+ import { labelledControl } from './input/label.js';
19
29
  import { TextBuffer } from './components/text-buffer.js';
20
30
  import { StdinRouter, matchOSC11, parseOSC11Scheme } from './terminal/stdin-router.js';
21
- import { DebugServer } from './debug/server.js';
22
- import { ConsoleDomain } from './debug/console.js';
31
+ import { detectCapabilities, matchCPR, parseCPRRow } from './terminal/capabilities.js';
32
+ import { copyToClipboard } from './terminal/clipboard.js';
33
+ import { SelectionController, applySelectionOverlay } from './input/selection.js';
34
+ import { InlineScreen } from './render/inline.js';
35
+ import { GraphicsLayer } from './render/graphics-layer.js';
36
+ import { registerInlineHooks } from './framelog.js';
37
+ import { activeModal, withinSubtree } from './input/modal.js';
23
38
  import * as ansi from './render/ansi.js';
24
- import { getTerminalSize, enterFullscreen, exitFullscreen, enableRawMode, disableRawMode, writeOutput, } from './terminal/screen.js';
39
+ import { emitFocusCursor } from './render/cursor-emit.js';
40
+ import { enterFullscreen, exitFullscreen } from './terminal/screen.js';
41
+ import { ProcessIO } from './terminal/io.js';
42
+ /** Deduped so HMR re-evaluation doesn't accumulate copies. */
43
+ const registeredComponentCss = new Set();
44
+ /**
45
+ * Register a component's extracted CSS before run() is called. Bundled
46
+ * builds (`svelterm build`) and the dev-mode transform append a
47
+ * registration call to each compiled component so it carries its styles;
48
+ * run() falls back to the registry when no `css` option is given.
49
+ */
50
+ export function registerComponentCss(css) {
51
+ if (css)
52
+ registeredComponentCss.add(css);
53
+ }
25
54
  export function run(AppComponent, options) {
26
- const fullscreen = options?.fullscreen ?? true;
55
+ const io = options?.io ?? new ProcessIO();
56
+ // `fullscreen: false` without an explicit mode gets the inline
57
+ // renderer (what a main-buffer app actually needs). An explicit
58
+ // `mode` always wins — `mode: 'fullscreen', fullscreen: false` is
59
+ // full-viewport rendering without the alternate screen (embedded
60
+ // previews drive a virtual terminal that way).
61
+ const inline = options?.mode ? options.mode === 'inline' : options?.fullscreen === false;
62
+ const fullscreen = !inline && (options?.fullscreen ?? true);
27
63
  const mouseEnabled = options?.mouse ?? true;
28
64
  const debugEnabled = options?.debug ?? false;
29
65
  const debugPort = options?.debugPort ?? 9444;
30
- const stylesheet = options?.css ? parseCSS(options.css) : null;
66
+ const exitOn = options?.exitOn ?? ['ctrl+c'];
67
+ const userCss = options?.css ?? [...registeredComponentCss].join('\n');
68
+ let stylesheet = parseCSS(DEFAULT_STYLESHEET + userCss);
69
+ // Console capture — only intercept when our IO owns the JS runtime's
70
+ // stdout/stderr (ProcessIO). With InProcessIO (browser, tests) console
71
+ // writes don't corrupt anything, so leave them alone. When intercepting,
72
+ // route to onConsole if provided; otherwise throw — silent suppression
73
+ // hides log calls and lengthens the feedback loop.
74
+ const onConsole = options?.onConsole;
75
+ const ownsStdio = io instanceof ProcessIO;
76
+ const levels = ['log', 'warn', 'error', 'info', 'debug'];
77
+ let restoreConsole = () => { };
78
+ if (ownsStdio) {
79
+ const originals = {
80
+ log: console.log.bind(console),
81
+ warn: console.warn.bind(console),
82
+ error: console.error.bind(console),
83
+ info: console.info.bind(console),
84
+ debug: console.debug.bind(console),
85
+ };
86
+ for (const level of levels) {
87
+ ;
88
+ console[level] = (...args) => {
89
+ if (onConsole) {
90
+ onConsole({
91
+ level,
92
+ args: args.map(a => typeof a === 'string' ? a : JSON.stringify(a, null, 2) ?? String(a)),
93
+ timestamp: Date.now(),
94
+ });
95
+ return;
96
+ }
97
+ throw new Error(`console.${level} would corrupt the terminal — pass an onConsole option to run() to route log output, or remove the call`);
98
+ };
99
+ }
100
+ restoreConsole = () => {
101
+ for (const level of levels) {
102
+ console[level] = originals[level];
103
+ }
104
+ };
105
+ }
31
106
  // Render context tracks mutations and determines minimum work
32
107
  const ctx = new RenderContext();
33
108
  const root = new TermNode('element', 'root');
34
109
  root.ctx = ctx;
35
- // Color scheme detection — updated by polling
36
- let detectedScheme = 'dark';
110
+ // Color scheme detection — updated by polling unless the host
111
+ // overrode it via options.colorScheme.
112
+ const colorSchemeOverride = options?.colorScheme;
113
+ let detectedScheme = colorSchemeOverride ?? 'dark';
37
114
  // Wire schedule callback (defined below, hoisted via closure)
38
115
  ctx.onScheduleRender = () => scheduleRender();
39
116
  // Persisted render state
117
+ /** What the terminal currently shows (selection overlay included). */
40
118
  let prevBuffer = null;
119
+ /** The last paint without the selection overlay — diff/extraction base. */
120
+ let prevClean = null;
121
+ const selection = new SelectionController(() => prevClean);
122
+ const inlineScreen = new InlineScreen();
123
+ if (inline) {
124
+ registerInlineHooks(root, { releaseTop: n => inlineScreen.releaseTop(n) });
125
+ }
126
+ /** Learn where the inline zone starts so mouse coordinates can map. */
127
+ const queryInlineOrigin = () => {
128
+ // The terminal reports where the cursor is, which is wherever the
129
+ // last render left it *within* the zone — snapshot that at the
130
+ // moment the query bytes go out (queries queue behind others).
131
+ let cursorRowAtQuery = 0;
132
+ router.query('\x1b[6n', matchCPR, 200, () => { cursorRowAtQuery = inlineScreen.cursorZoneRow(); }).then(reply => {
133
+ const row = parseCPRRow(reply);
134
+ if (row !== null)
135
+ inlineScreen.setOriginRow(Math.max(1, row - cursorRowAtQuery));
136
+ }).catch(() => { });
137
+ };
138
+ /** The buffer as displayed: the clean paint plus any selection. */
139
+ const overlayed = (buffer) => {
140
+ const range = selection.range();
141
+ if (!range)
142
+ return buffer;
143
+ const display = buffer.clone();
144
+ applySelectionOverlay(display, range);
145
+ return display;
146
+ };
147
+ /** Re-diff after a selection change without repainting the tree. */
148
+ const redrawSelection = () => {
149
+ if (!prevClean)
150
+ return;
151
+ const display = overlayed(prevClean);
152
+ const output = diffBuffers(prevBuffer, display);
153
+ if (output.length > 0)
154
+ writeOutput(output);
155
+ prevBuffer = display;
156
+ };
41
157
  let lastStyles;
42
158
  let lastFilteredStylesheet = null;
43
159
  let lastLayout;
44
160
  let renderScheduled = false;
45
161
  let initialRegistrationDone = false;
162
+ // CSS animations — reapply the current keyframe and repaint while live
163
+ const animationClock = new AnimationClock();
164
+ animationClock.onFrame = () => {
165
+ if (!lastStyles)
166
+ return;
167
+ const dirty = animationClock.apply(lastStyles);
168
+ if (dirty.length === 0)
169
+ return;
170
+ for (const { node, touchesLayout } of dirty) {
171
+ if (touchesLayout)
172
+ ctx.queue.enqueueLayoutBubble(node);
173
+ else
174
+ ctx.queue.enqueuePaintOnly(node);
175
+ }
176
+ scheduleRender();
177
+ };
46
178
  const scheduleRender = () => {
47
179
  if (renderScheduled)
48
180
  return;
@@ -54,25 +186,46 @@ export function run(AppComponent, options) {
54
186
  };
55
187
  const processQueue = () => {
56
188
  const snap = ctx.queue.snapshot();
57
- if (snap.fullRecompute || !lastStyles || !lastLayout) {
189
+ const dirty = snap.paintOnly.size > 0 || snap.styleResolve.size > 0
190
+ || snap.layoutSubtree.size > 0 || snap.layoutBubble.size > 0;
191
+ if (inline) {
192
+ // The live area is content-sized, so any change can move
193
+ // everything — always render fully.
194
+ if (snap.fullRecompute || dirty || !lastStyles)
195
+ fullRender();
196
+ }
197
+ else if (snap.fullRecompute || !lastStyles || !lastLayout) {
58
198
  // Full recompute — initial render, resize, or CSS reload
59
199
  fullRender();
60
200
  }
61
- else if (snap.paintOnly.size > 0 || snap.styleResolve.size > 0
62
- || snap.layoutSubtree.size > 0 || snap.layoutBubble.size > 0) {
201
+ else if (dirty) {
63
202
  // Incremental render
64
203
  incrementalRender(snap);
65
204
  }
66
205
  };
67
- const fullRender = () => {
68
- const size = getTerminalSize();
69
- // Set root dimensions so children can use percentage width/height
206
+ // Synchronized output (DEC 2026) is on until detection says otherwise —
207
+ // terminals that don't know the mode ignore it.
208
+ let syncOutput = true;
209
+ const writeOutput = (data) => {
210
+ io.write(syncOutput ? ansi.beginSyncUpdate() + data + ansi.endSyncUpdate() : data);
211
+ };
212
+ // Kitty-graphics layer — off until capability detection turns it on.
213
+ const graphicsLayer = new GraphicsLayer();
214
+ let graphicsEnabled = false;
215
+ /** The per-frame tail written after the cell diff: cursor + images. */
216
+ const frameTail = () => {
217
+ const cursor = emitFocusCursor(root, focusManager.focused);
218
+ return graphicsEnabled ? cursor + graphicsLayer.render(root, lastLayout) : cursor;
219
+ };
220
+ /** Resolve styles + layout for the current terminal size. */
221
+ const resolveForRender = (size) => {
70
222
  root.attributes.set('data-width', String(size.width));
71
223
  root.attributes.set('data-height', String(size.height));
72
- const buffer = new CellBuffer(size.width, size.height);
73
224
  const media = { colorScheme: detectedScheme, displayMode: 'terminal', width: size.width, height: size.height };
74
225
  lastFilteredStylesheet = stylesheet ? filterByMedia(stylesheet, media) : null;
75
- lastStyles = lastFilteredStylesheet ? resolveStyles(root, lastFilteredStylesheet) : undefined;
226
+ // Passing `media` here threads colorScheme into computeStyle so
227
+ // light-dark() resolves against the active scheme.
228
+ lastStyles = lastFilteredStylesheet ? resolveStyles(root, lastFilteredStylesheet, media) : undefined;
76
229
  // Ensure root style has terminal dimensions for percentage resolution
77
230
  if (lastStyles) {
78
231
  const rootStyle = lastStyles.get(root.id);
@@ -81,28 +234,92 @@ export function run(AppComponent, options) {
81
234
  rootStyle.height = size.height;
82
235
  }
83
236
  }
237
+ if (lastStyles && lastFilteredStylesheet) {
238
+ animationClock.sync(root, lastStyles, getKeyframes(lastFilteredStylesheet), {
239
+ variables: collectVariables(root, lastFilteredStylesheet),
240
+ scheme: detectedScheme,
241
+ });
242
+ animationClock.syncTransitions(root, lastStyles);
243
+ animationClock.apply(lastStyles);
244
+ }
84
245
  lastLayout = lastStyles ? computeLayout(root, lastStyles, size.width, size.height) : undefined;
85
- if (lastLayout)
246
+ if (lastLayout) {
86
247
  syncLayoutCache(root, lastLayout);
248
+ clampScrollPositions(root, lastLayout, io);
249
+ }
250
+ };
251
+ const fullRender = () => {
252
+ if (inline) {
253
+ inlineRender();
254
+ return;
255
+ }
256
+ const size = io.getSize();
257
+ const buffer = new CellBuffer(size.width, size.height);
258
+ resolveForRender(size);
87
259
  paint(root, buffer, lastStyles, lastLayout);
88
- const output = diffBuffers(prevBuffer, buffer);
260
+ prevClean = buffer;
261
+ const display = overlayed(buffer);
262
+ const output = diffBuffers(prevBuffer, display) + frameTail();
89
263
  if (output.length > 0)
90
264
  writeOutput(output);
91
- prevBuffer = buffer;
265
+ prevBuffer = display;
92
266
  // Register focusable elements after initial render
93
267
  if (!initialRegistrationDone) {
94
268
  registerFocusableNodes(root, focusManager);
95
269
  initialRegistrationDone = true;
96
270
  }
97
271
  };
272
+ /**
273
+ * Inline mode always renders fully: the live area is content-sized
274
+ * (clamped to the terminal height — archive to keep it short) and the
275
+ * InlineScreen driver emits relative-movement diffs.
276
+ */
277
+ const inlineRender = () => {
278
+ const size = io.getSize();
279
+ resolveForRender(size);
280
+ const rootBox = lastLayout?.get(root.id);
281
+ const extent = lastLayout && rootBox
282
+ ? contentExtent(root, lastLayout, rootBox)
283
+ : { width: size.width, height: 1 };
284
+ const height = Math.max(1, Math.min(extent.height, size.height));
285
+ const buffer = new CellBuffer(size.width, height);
286
+ paint(root, buffer, lastStyles, lastLayout);
287
+ prevClean = buffer;
288
+ let output = inlineScreen.render(buffer);
289
+ const pos = focusManager.focused?.getCursorScreenPos();
290
+ if (pos && pos.inViewport && pos.y < height) {
291
+ output += inlineScreen.moveCursorTo(pos.x, pos.y)
292
+ + ansi.setCursorShape('bar') + ansi.showCursor();
293
+ }
294
+ else {
295
+ output += ansi.hideCursor() + ansi.resetCursorShape();
296
+ }
297
+ if (graphicsEnabled)
298
+ output += graphicsLayer.render(root, lastLayout);
299
+ if (output.length > 0)
300
+ writeOutput(output);
301
+ if (!initialRegistrationDone) {
302
+ registerFocusableNodes(root, focusManager);
303
+ initialRegistrationDone = true;
304
+ }
305
+ };
98
306
  const incrementalRender = (snap) => {
99
- const size = getTerminalSize();
307
+ const size = io.getSize();
100
308
  // Mutable copies for promoted nodes during processing
101
309
  const layoutSubtree = new Set(snap.layoutSubtree);
102
310
  const layoutBubble = new Set(snap.layoutBubble);
103
311
  // Step 1: Incremental style resolution
104
312
  if (snap.styleResolve.size > 0 && lastStyles && lastFilteredStylesheet) {
105
- lastStyles = resolveStylesIncremental(root, lastFilteredStylesheet, lastStyles, snap.styleResolve, undefined, (node) => { layoutSubtree.add(node); });
313
+ const resolvedIds = new Set();
314
+ lastStyles = resolveStylesIncremental(root, lastFilteredStylesheet, lastStyles, snap.styleResolve, (nodeId) => { resolvedIds.add(nodeId); }, (node) => { layoutSubtree.add(node); }, detectedScheme);
315
+ // Newly mounted or restyled nodes may start/stop animations,
316
+ // and re-resolution resets styles to their base keyframe.
317
+ animationClock.sync(root, lastStyles, getKeyframes(lastFilteredStylesheet), {
318
+ variables: collectVariables(root, lastFilteredStylesheet),
319
+ scheme: detectedScheme,
320
+ });
321
+ animationClock.syncTransitions(root, lastStyles, resolvedIds);
322
+ animationClock.apply(lastStyles);
106
323
  }
107
324
  // Step 2: Incremental layout
108
325
  if (layoutSubtree.size > 0 || layoutBubble.size > 0) {
@@ -125,21 +342,25 @@ export function run(AppComponent, options) {
125
342
  dirtyPaintNodes.add(node);
126
343
  }
127
344
  const hasScroll = hasScrolledNode(root);
128
- if (noLayoutChanges && !hasScroll && dirtyPaintNodes.size > 0 && prevBuffer && lastStyles && lastLayout) {
129
- const buffer = prevBuffer.clone();
345
+ if (noLayoutChanges && !hasScroll && dirtyPaintNodes.size > 0 && prevClean && lastStyles && lastLayout) {
346
+ const buffer = prevClean.clone();
130
347
  paintNodes(dirtyPaintNodes, buffer, lastStyles, lastLayout, root);
131
- const output = diffBuffers(prevBuffer, buffer);
348
+ prevClean = buffer;
349
+ const display = overlayed(buffer);
350
+ const output = diffBuffers(prevBuffer, display) + frameTail();
132
351
  if (output.length > 0)
133
352
  writeOutput(output);
134
- prevBuffer = buffer;
353
+ prevBuffer = display;
135
354
  }
136
355
  else {
137
356
  const buffer = new CellBuffer(size.width, size.height);
138
357
  paint(root, buffer, lastStyles, lastLayout);
139
- const output = diffBuffers(prevBuffer, buffer);
358
+ prevClean = buffer;
359
+ const display = overlayed(buffer);
360
+ const output = diffBuffers(prevBuffer, display) + frameTail();
140
361
  if (output.length > 0)
141
362
  writeOutput(output);
142
- prevBuffer = buffer;
363
+ prevBuffer = display;
143
364
  }
144
365
  };
145
366
  const focusManager = new FocusManager();
@@ -167,28 +388,56 @@ export function run(AppComponent, options) {
167
388
  unregisterFocusableNodes(child, focusManager);
168
389
  child.cleanup();
169
390
  };
170
- enableRawMode();
391
+ io.enableRawMode();
171
392
  if (fullscreen)
172
- enterFullscreen();
393
+ enterFullscreen(io);
394
+ // Cursor visibility is owned at the run-lifecycle level, not by
395
+ // enterFullscreen — non-fullscreen runs need it hidden too. The
396
+ // post-paint emitter takes over from here, showing the cursor only
397
+ // when something asks for it (focused input, region cursor).
398
+ io.write(ansi.hideCursor());
173
399
  // Write mode sequences directly — sync update wrapping can interfere
174
- process.stdout.write(ansi.enableBracketedPaste());
400
+ io.write(ansi.enableBracketedPaste());
401
+ // Kitty keyboard protocol: unsupported terminals ignore the push/pop
402
+ io.write(ansi.pushKittyKeyboard());
175
403
  if (mouseEnabled)
176
- process.stdout.write(ansi.enableMouse());
404
+ io.write(ansi.enableMouse());
177
405
  // Single stdin router — all input flows through here
178
- const router = new StdinRouter();
406
+ const router = new StdinRouter(io);
179
407
  const handleKeyData = (data) => {
180
408
  const key = parseKeyEvent(data);
181
409
  if (!key)
182
410
  return;
183
411
  if (key.ctrl && key.key === 'c') {
184
412
  doCleanup();
185
- process.exit(0);
413
+ if (typeof process !== 'undefined')
414
+ process.exit(0);
415
+ return;
186
416
  }
187
- if (key.ctrl && key.key === 'z') {
417
+ if (key.ctrl && key.key === 'd' && exitOn.includes('ctrl+d')) {
188
418
  doCleanup();
189
- process.kill(process.pid, 'SIGTSTP');
419
+ if (typeof process !== 'undefined')
420
+ process.exit(0);
421
+ return;
422
+ }
423
+ if (key.ctrl && key.key === 'z') {
424
+ suspend();
190
425
  return;
191
426
  }
427
+ // An open <dialog> captures keys: Escape closes it, Tab traps inside
428
+ const modal = activeModal(root);
429
+ focusManager.setScope(modal);
430
+ if (modal && key.key === 'Escape') {
431
+ modal.attributes.delete('open');
432
+ ctx.onRemoveAttribute(modal, 'open');
433
+ dispatchEvent(modal, 'close');
434
+ focusManager.setScope(null);
435
+ scheduleRender();
436
+ return;
437
+ }
438
+ if (modal && focusManager.focused && !withinSubtree(focusManager.focused, modal)) {
439
+ focusManager.focusNext(); // pull focus into the modal
440
+ }
192
441
  if (key.key === 'Tab' && key.shift) {
193
442
  focusManager.focusPrevious();
194
443
  scheduleRender();
@@ -201,6 +450,8 @@ export function run(AppComponent, options) {
201
450
  }
202
451
  if (key.key === 'Enter' && focusManager.focused) {
203
452
  const target = focusManager.focused;
453
+ if (hasBooleanAttribute(target, 'disabled'))
454
+ return;
204
455
  const event = dispatchEvent(target, 'click');
205
456
  // Default action: open links in browser (unless preventDefault was called)
206
457
  if (!event.defaultPrevented && target.tag === 'a') {
@@ -208,12 +459,35 @@ export function run(AppComponent, options) {
208
459
  if (href)
209
460
  openUrl(href);
210
461
  }
462
+ if (!event.defaultPrevented && target.tag === 'summary')
463
+ toggleDetails(target);
464
+ if (!event.defaultPrevented && target.tag === 'select')
465
+ cycleSelect(target, 1);
466
+ scheduleRender();
467
+ return;
468
+ }
469
+ // Space toggles a focused checkbox or selects a focused radio
470
+ if (key.key === ' ' && focusManager.focused && isCheckableInput(focusManager.focused)) {
471
+ toggleCheckable(focusManager.focused);
211
472
  scheduleRender();
212
473
  return;
213
474
  }
475
+ // A focused select cycles its options (popup-less interaction)
476
+ if (focusManager.focused?.tag === 'select') {
477
+ if (key.key === 'ArrowUp') {
478
+ cycleSelect(focusManager.focused, -1);
479
+ scheduleRender();
480
+ return;
481
+ }
482
+ if (key.key === 'ArrowDown' || key.key === ' ') {
483
+ cycleSelect(focusManager.focused, 1);
484
+ scheduleRender();
485
+ return;
486
+ }
487
+ }
214
488
  // Text input for focused input/textarea
215
489
  const focused = focusManager.focused;
216
- if (focused && (focused.tag === 'input' || focused.tag === 'textarea')) {
490
+ if (focused && (focused.tag === 'input' || focused.tag === 'textarea') && !isCheckableInput(focused)) {
217
491
  if (!focused.textBuffer)
218
492
  focused.textBuffer = new TextBuffer(focused.attributes.get('value') ?? '');
219
493
  if (focused.textBuffer.handleKey(key)) {
@@ -236,10 +510,18 @@ export function run(AppComponent, options) {
236
510
  }
237
511
  };
238
512
  const handleMouseData = (data) => {
239
- const mouse = parseMouseEvent(data);
513
+ let mouse = parseMouseEvent(data);
240
514
  if (!mouse)
241
515
  return;
242
- handleMouse(mouse, root, lastLayout, focusManager, scheduleRender, lastStyles, ctx);
516
+ if (inline) {
517
+ // Mouse rows are screen-absolute; the zone origin comes from a
518
+ // CPR query. Events above the zone (shell history) are ignored.
519
+ const zoneRow = inlineScreen.screenRowToZone(mouse.row, io.getSize().height);
520
+ if (zoneRow === null)
521
+ return;
522
+ mouse = { ...mouse, row: zoneRow };
523
+ }
524
+ handleMouse(mouse, root, lastLayout, focusManager, scheduleRender, lastStyles, ctx, io, selection, redrawSelection);
243
525
  };
244
526
  const handlePaste = (text) => {
245
527
  const focused = focusManager.focused;
@@ -262,27 +544,70 @@ export function run(AppComponent, options) {
262
544
  }
263
545
  };
264
546
  router.start({ onKey: handleKeyData, onMouse: handleMouseData, onPaste: handlePaste });
265
- // Debug server (opt-in)
547
+ // Debug server (opt-in, dynamically imported to avoid ws dependency in browser)
266
548
  let debugServer = null;
267
549
  let consoleDomain = null;
268
550
  if (debugEnabled) {
269
- debugServer = new DebugServer(debugPort);
270
- consoleDomain = new ConsoleDomain(debugServer);
271
- debugServer.registerDomain('Console', consoleDomain);
272
- consoleDomain.start();
273
- debugServer.start();
551
+ Promise.all([
552
+ import('./debug/server.js'),
553
+ import('./debug/console.js'),
554
+ import('./debug/dom.js'),
555
+ import('./debug/css.js'),
556
+ ]).then(([{ DebugServer }, { ConsoleDomain }, { DomDomain }, { CssDomain }]) => {
557
+ debugServer = new DebugServer(debugPort);
558
+ consoleDomain = new ConsoleDomain(debugServer);
559
+ const debugCtx = {
560
+ root,
561
+ styles: () => lastStyles,
562
+ layout: () => lastLayout,
563
+ requestRender: () => { ctx.queue.setFullRecompute(); scheduleRender(); },
564
+ };
565
+ debugServer.registerDomain('Console', consoleDomain);
566
+ debugServer.registerDomain('DOM', new DomDomain(debugCtx));
567
+ debugServer.registerDomain('CSS', new CssDomain(debugCtx));
568
+ consoleDomain.start();
569
+ debugServer.start();
570
+ });
274
571
  }
275
572
  // Serialised color scheme detection via router query
276
573
  const detectScheme = async () => {
277
574
  const result = await router.query('\x1b]11;?\x07', matchOSC11, 200);
278
575
  return result ? parseOSC11Scheme(result) : 'dark';
279
576
  };
280
- // Render immediately with default scheme
577
+ // Render immediately with default scheme. Seed a Svelte context so
578
+ // descendant components can detect they're rendered in the svelterm
579
+ // target (vs plain browser-Svelte) without resorting to globals —
580
+ // important for components like EmbeddedTerminal that branch their
581
+ // render path. Browser-Svelte mounts have no such key, so a
582
+ // `getContext` call there returns undefined and the component
583
+ // defaults to the browser path.
281
584
  ctx.queue.setFullRecompute();
282
- const { unmount: svUnmount } = renderer.render(AppComponent, { target: root, props: options?.props ?? {} });
585
+ const app = mount(AppComponent, {
586
+ renderer,
587
+ target: root,
588
+ props: options?.props ?? {},
589
+ context: new Map([[Symbol.for('@svelterm/target'), 'terminal']]),
590
+ });
591
+ const svUnmount = () => void unmount(app);
592
+ // Collect CSS from injected <style> elements (css: 'injected' mode).
593
+ // Skipped when the host passed an explicit `css` option.
594
+ if (!userCss) {
595
+ const injectedCss = collectInjectedCss(root);
596
+ if (injectedCss) {
597
+ stylesheet = parseCSS(DEFAULT_STYLESHEET + injectedCss);
598
+ }
599
+ }
283
600
  scheduleRender();
284
- setupResizeHandler(() => { ctx.onResize(); prevBuffer = null; scheduleRender(); });
285
- // Detect color scheme in background and re-render if different
601
+ io.onResize(() => {
602
+ ctx.onResize();
603
+ prevBuffer = null;
604
+ scheduleRender();
605
+ // Rewrap may have moved the zone — re-learn where it starts
606
+ if (inline && io instanceof ProcessIO)
607
+ queryInlineOrigin();
608
+ });
609
+ // Detect color scheme in background and re-render if different.
610
+ // Skipped when the host pinned a scheme via options.colorScheme.
286
611
  let pollRunning = true;
287
612
  const pollScheme = async () => {
288
613
  if (!pollRunning)
@@ -291,7 +616,8 @@ export function run(AppComponent, options) {
291
616
  const scheme = await detectScheme();
292
617
  if (scheme !== detectedScheme) {
293
618
  detectedScheme = scheme;
294
- lastFilteredStylesheet = stylesheet ? filterByMedia(stylesheet, { colorScheme: detectedScheme, displayMode: 'terminal', width: getTerminalSize().width, height: getTerminalSize().height }) : null;
619
+ const size = io.getSize();
620
+ lastFilteredStylesheet = stylesheet ? filterByMedia(stylesheet, { colorScheme: detectedScheme, displayMode: 'terminal', width: size.width, height: size.height }) : null;
295
621
  ctx.onResize();
296
622
  prevBuffer = null;
297
623
  scheduleRender();
@@ -303,61 +629,209 @@ export function run(AppComponent, options) {
303
629
  if (pollRunning)
304
630
  setTimeout(pollScheme, 1000);
305
631
  };
306
- pollScheme();
307
- const appCleanup = createCleanup(svUnmount, fullscreen, mouseEnabled);
632
+ if (!colorSchemeOverride)
633
+ pollScheme();
634
+ // Detect terminal capabilities in the background: colour depth for
635
+ // SGR quantization, DEC 2026 support for frame batching. Only the
636
+ // real terminal answers queries; embedded IO keeps the defaults.
637
+ if (options?.colorDepth) {
638
+ ansi.setColorDepth(options.colorDepth);
639
+ }
640
+ else if (io instanceof ProcessIO) {
641
+ if (inline)
642
+ queryInlineOrigin();
643
+ detectCapabilities(router).then(caps => {
644
+ syncOutput = caps.syncOutput;
645
+ // Crisp images on kitty-graphics terminals; half-blocks stay
646
+ // in the buffer as the fallback and layout truth.
647
+ if (caps.graphics && !graphicsEnabled) {
648
+ graphicsEnabled = true;
649
+ scheduleRender();
650
+ }
651
+ if (caps.colorDepth === ansi.getColorDepth())
652
+ return;
653
+ ansi.setColorDepth(caps.colorDepth);
654
+ // Full repaint so every cell re-emits at the new depth
655
+ ctx.onResize();
656
+ prevBuffer = null;
657
+ scheduleRender();
658
+ }).catch(() => { });
659
+ }
308
660
  const doCleanup = () => {
309
661
  pollRunning = false;
662
+ animationClock.stop();
310
663
  router.stop();
311
664
  consoleDomain?.stop();
312
665
  debugServer?.stop();
313
- appCleanup();
666
+ restoreConsole?.();
667
+ if (graphicsEnabled)
668
+ io.write(graphicsLayer.clear());
669
+ svUnmount();
670
+ if (mouseEnabled)
671
+ io.write(ansi.disableMouse());
672
+ io.write(ansi.popKittyKeyboard());
673
+ io.write(ansi.disableBracketedPaste());
674
+ if (fullscreen)
675
+ exitFullscreen(io);
676
+ if (inline) {
677
+ // Leave the rendered output in place; park the prompt below it.
678
+ io.write(inlineScreen.finish() + ansi.resetCursorShape());
679
+ }
680
+ else {
681
+ // Show cursor *after* exitFullscreen so it targets the main
682
+ // screen, not the alt screen we're leaving behind.
683
+ io.write(ansi.showCursor() + ansi.resetCursorShape());
684
+ }
685
+ io.disableRawMode();
686
+ io.dispose();
314
687
  };
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)
688
+ /**
689
+ * Ctrl+Z: release the terminal without unmounting, stop, and pick up
690
+ * where we left off on `fg` — modes re-enter and everything repaints.
691
+ */
692
+ const suspend = () => {
693
+ if (typeof process === 'undefined')
323
694
  return;
324
- cleaned = true;
325
- unmountComponent();
695
+ if (graphicsEnabled)
696
+ io.write(graphicsLayer.clear());
326
697
  if (mouseEnabled)
327
- writeOutput(ansi.disableMouse());
328
- writeOutput(ansi.disableBracketedPaste());
698
+ io.write(ansi.disableMouse());
699
+ io.write(ansi.popKittyKeyboard() + ansi.disableBracketedPaste());
329
700
  if (fullscreen)
330
- exitFullscreen();
331
- disableRawMode();
701
+ exitFullscreen(io);
702
+ if (inline)
703
+ inlineScreen.reset();
704
+ io.write(ansi.showCursor() + ansi.resetCursorShape());
705
+ io.disableRawMode();
706
+ process.kill(process.pid, 'SIGTSTP');
707
+ };
708
+ const resume = () => {
709
+ io.enableRawMode();
710
+ if (fullscreen)
711
+ enterFullscreen(io);
712
+ io.write(ansi.hideCursor() + ansi.enableBracketedPaste() + ansi.pushKittyKeyboard());
713
+ if (mouseEnabled)
714
+ io.write(ansi.enableMouse());
715
+ ctx.onResize();
716
+ prevBuffer = null;
717
+ prevClean = null;
718
+ scheduleRender();
719
+ if (inline && io instanceof ProcessIO)
720
+ queryInlineOrigin();
332
721
  };
722
+ if (typeof process !== 'undefined') {
723
+ process.on('SIGINT', () => { doCleanup(); process.exit(0); });
724
+ process.on('SIGTERM', () => { doCleanup(); process.exit(0); });
725
+ process.on('SIGCONT', resume);
726
+ }
727
+ const setColorScheme = (scheme) => {
728
+ // Host has spoken — silence the OSC-11 poller so it doesn't overwrite
729
+ // this value on its next 1s tick.
730
+ pollRunning = false;
731
+ if (scheme === detectedScheme)
732
+ return;
733
+ detectedScheme = scheme;
734
+ const size = io.getSize();
735
+ lastFilteredStylesheet = stylesheet ? filterByMedia(stylesheet, { colorScheme: detectedScheme, displayMode: 'terminal', width: size.width, height: size.height }) : null;
736
+ ctx.onResize();
737
+ prevBuffer = null;
738
+ scheduleRender();
739
+ };
740
+ return { cleanup: doCleanup, setColorScheme };
333
741
  }
334
- function handleMouse(mouse, root, layout, focusManager, scheduleRender, lastStyles, ctx) {
742
+ const SCROLLBAR_VISIBLE_MS = 600;
743
+ const SCROLLBAR_FADE_MS = 400;
744
+ const SCROLLBAR_FADE_FRAMES = 16;
745
+ const SCROLLBAR_TOTAL_MS = SCROLLBAR_VISIBLE_MS + SCROLLBAR_FADE_MS;
746
+ let lastHoveredId = -1;
747
+ function handleMouse(mouse, root, layout, focusManager, scheduleRender, lastStyles, ctx, io, selection, redrawSelection) {
335
748
  if (!layout)
336
749
  return;
337
- // Handle hover — set data-hovered on element under cursor
750
+ // Handle hover — only update when the hovered element changes.
751
+ // Dragging with the left button extends the text selection.
338
752
  if (mouse.type === 'motion') {
753
+ if (mouse.button === 'left' && selection.onMotion(mouse.col, mouse.row)) {
754
+ redrawSelection();
755
+ }
339
756
  const target = hitTest(root, layout, mouse.col, mouse.row);
340
757
  const hoveredId = target?.id ?? -1;
341
- // Walk tree and update data-hovered
342
- updateHover(root, hoveredId, ctx);
343
- scheduleRender();
758
+ if (hoveredId !== lastHoveredId) {
759
+ updateHover(root, hoveredId, ctx);
760
+ lastHoveredId = hoveredId;
761
+ }
762
+ return;
763
+ }
764
+ // Releasing the left button finishes a selection and copies it
765
+ if (mouse.type === 'release' && mouse.button === 'left') {
766
+ const text = selection.onRelease();
767
+ if (text)
768
+ copyToClipboard(text, data => io.write(data));
344
769
  return;
345
770
  }
346
771
  if (mouse.type !== 'press' && mouse.type !== 'scroll')
347
772
  return;
348
773
  if (mouse.button === 'left') {
774
+ const hadSelection = selection.range() !== null;
775
+ selection.onPress(mouse.col, mouse.row);
776
+ if (hadSelection || selection.range() !== null)
777
+ redrawSelection();
349
778
  const target = hitTest(root, layout, mouse.col, mouse.row);
350
779
  if (target) {
351
- // Focus clicked element if it's focusable
780
+ // Disabled interactive elements swallow the click, as in browsers
781
+ if (FOCUSABLE_TAGS.has(target.tag ?? '') && hasBooleanAttribute(target, 'disabled'))
782
+ return;
352
783
  if (FOCUSABLE_TAGS.has(target.tag ?? '')) {
353
784
  focusManager.focusByNode(target);
354
785
  }
786
+ if (isCheckableInput(target))
787
+ toggleCheckable(target);
355
788
  const event = dispatchEvent(target, 'click', mouse);
356
789
  if (!event.defaultPrevented && target.tag === 'a') {
357
790
  const href = target.attributes.get('href');
358
791
  if (href)
359
792
  openUrl(href);
360
793
  }
794
+ if (!event.defaultPrevented && target.tag === 'summary')
795
+ toggleDetails(target);
796
+ if (!event.defaultPrevented && target.tag === 'select')
797
+ cycleSelect(target, 1);
798
+ // Clicking a label activates its control, as in browsers
799
+ if (!event.defaultPrevented) {
800
+ const control = labelledControl(target);
801
+ if (control && control !== target && !hasBooleanAttribute(control, 'disabled')) {
802
+ if (FOCUSABLE_TAGS.has(control.tag ?? ''))
803
+ focusManager.focusByNode(control);
804
+ if (isCheckableInput(control))
805
+ toggleCheckable(control);
806
+ }
807
+ }
808
+ scheduleRender();
809
+ }
810
+ }
811
+ else if (mouse.button === 'scrollLeft' || mouse.button === 'scrollRight') {
812
+ const target = hitTest(root, layout, mouse.col, mouse.row);
813
+ if (target) {
814
+ const scrollTarget = findScrollableAncestor(target, lastStyles);
815
+ if (scrollTarget) {
816
+ const box = layout.get(scrollTarget.id);
817
+ if (box) {
818
+ const { width: contentWidth } = contentExtent(scrollTarget, layout, box);
819
+ const viewportWidth = scrollTarget.tag === 'root'
820
+ ? io.getSize().width
821
+ : box.width;
822
+ const maxScroll = Math.max(0, contentWidth - viewportWidth);
823
+ const delta = mouse.button === 'scrollLeft' ? -1 : 1;
824
+ scrollTarget.scrollLeft = Math.max(0, Math.min(scrollTarget.scrollLeft + delta, maxScroll));
825
+ scrollTarget.hScrollbarVisibleUntil = Date.now() + SCROLLBAR_TOTAL_MS;
826
+ const forceRepaint = () => { ctx.queue.setFullRecompute(); scheduleRender(); };
827
+ const frameInterval = SCROLLBAR_FADE_MS / SCROLLBAR_FADE_FRAMES;
828
+ for (let i = 0; i <= SCROLLBAR_FADE_FRAMES; i++) {
829
+ setTimeout(forceRepaint, SCROLLBAR_VISIBLE_MS + i * frameInterval);
830
+ }
831
+ ctx.onScroll(scrollTarget);
832
+ }
833
+ }
834
+ dispatchEvent(target, 'scroll', mouse);
361
835
  scheduleRender();
362
836
  }
363
837
  }
@@ -368,13 +842,19 @@ function handleMouse(mouse, root, layout, focusManager, scheduleRender, lastStyl
368
842
  if (scrollTarget) {
369
843
  const box = layout.get(scrollTarget.id);
370
844
  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);
845
+ const { height: contentHeight } = contentExtent(scrollTarget, layout, box);
846
+ const viewportHeight = scrollTarget.tag === 'root'
847
+ ? io.getSize().height
848
+ : box.height;
849
+ const maxScroll = Math.max(0, contentHeight - viewportHeight);
850
+ const delta = mouse.button === 'scrollUp' ? -1 : 1;
377
851
  scrollTarget.scrollTop = Math.max(0, Math.min(scrollTarget.scrollTop + delta, maxScroll));
852
+ scrollTarget.scrollbarVisibleUntil = Date.now() + SCROLLBAR_TOTAL_MS;
853
+ const forceRepaint = () => { ctx.queue.setFullRecompute(); scheduleRender(); };
854
+ const frameInterval = SCROLLBAR_FADE_MS / SCROLLBAR_FADE_FRAMES;
855
+ for (let i = 0; i <= SCROLLBAR_FADE_FRAMES; i++) {
856
+ setTimeout(forceRepaint, SCROLLBAR_VISIBLE_MS + i * frameInterval);
857
+ }
378
858
  ctx.onScroll(scrollTarget);
379
859
  }
380
860
  }
@@ -383,10 +863,7 @@ function handleMouse(mouse, root, layout, focusManager, scheduleRender, lastStyl
383
863
  }
384
864
  }
385
865
  }
386
- function setupResizeHandler(onResize) {
387
- process.stdout.on('resize', onResize);
388
- }
389
- const FOCUSABLE_TAGS = new Set(['button', 'input', 'textarea', 'a', 'select']);
866
+ const FOCUSABLE_TAGS = new Set(['button', 'input', 'textarea', 'a', 'select', 'summary']);
390
867
  function registerFocusableNodes(node, focusManager) {
391
868
  if (node.nodeType === 'element' && FOCUSABLE_TAGS.has(node.tag ?? '')) {
392
869
  focusManager.register(node);
@@ -421,6 +898,9 @@ function updateHover(node, hoveredId, ctx) {
421
898
  function findScrollableAncestor(node, styles) {
422
899
  let current = node;
423
900
  while (current) {
901
+ // Root element is implicitly scrollable (it's the viewport)
902
+ if (current.tag === 'root')
903
+ return current;
424
904
  const style = styles?.get(current.id);
425
905
  if (style && (style.overflow === 'scroll' || style.overflow === 'auto' || style.overflow === 'hidden')) {
426
906
  return current;
@@ -429,6 +909,42 @@ function findScrollableAncestor(node, styles) {
429
909
  }
430
910
  return null;
431
911
  }
912
+ /** Clamp scroll positions on all nodes after resize/relayout. */
913
+ function clampScrollPositions(node, layout, io) {
914
+ if (node.scrollTop !== 0 || node.scrollLeft !== 0) {
915
+ const box = layout.get(node.id);
916
+ if (box) {
917
+ const extent = contentExtent(node, layout, box);
918
+ const viewH = node.tag === 'root' ? io.getSize().height : box.height;
919
+ const viewW = node.tag === 'root' ? io.getSize().width : box.width;
920
+ const maxScrollY = Math.max(0, extent.height - viewH);
921
+ const maxScrollX = Math.max(0, extent.width - viewW);
922
+ if (node.scrollTop > maxScrollY)
923
+ node.scrollTop = maxScrollY;
924
+ if (node.scrollLeft > maxScrollX)
925
+ node.scrollLeft = maxScrollX;
926
+ }
927
+ }
928
+ for (const child of node.children)
929
+ clampScrollPositions(child, layout, io);
930
+ }
931
+ /** Find the maximum content extent of all descendants relative to the ancestor's position. */
932
+ function contentExtent(node, layout, ancestorBox) {
933
+ let maxW = 0;
934
+ let maxH = 0;
935
+ function walk(n) {
936
+ const box = layout.get(n.id);
937
+ if (box) {
938
+ maxW = Math.max(maxW, box.x - ancestorBox.x + box.width);
939
+ maxH = Math.max(maxH, box.y - ancestorBox.y + box.height);
940
+ }
941
+ for (const child of n.children)
942
+ walk(child);
943
+ }
944
+ for (const child of node.children)
945
+ walk(child);
946
+ return { width: maxW, height: maxH };
947
+ }
432
948
  function scrollIntoView(node, layout, styles, ctx) {
433
949
  if (!layout)
434
950
  return;
@@ -478,8 +994,21 @@ function findFirstElement(node) {
478
994
  }
479
995
  return node;
480
996
  }
997
+ function collectInjectedCss(root) {
998
+ const parts = [];
999
+ for (const child of root.children) {
1000
+ if (child.tag === 'style') {
1001
+ parts.push(child.collectText());
1002
+ }
1003
+ }
1004
+ return parts.join('\n');
1005
+ }
481
1006
  export { TermNode } from './renderer/node.js';
482
1007
  export { CellBuffer } from './render/buffer.js';
483
1008
  export { parseCSS } from './css/parser.js';
484
1009
  export { resolveStyles } from './css/compute.js';
485
1010
  export { StdinRouter } from './terminal/stdin-router.js';
1011
+ export { ProcessIO, InProcessIO } from './terminal/io.js';
1012
+ export { copyToClipboard, osc52Copy } from './terminal/clipboard.js';
1013
+ export { FrameLog, createFrameLog } from './framelog.js';
1014
+ export { TestClock, systemClock } from './render/clock.js';