@next_term/web 0.1.0-next.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 (61) hide show
  1. package/dist/accessibility.d.ts +46 -0
  2. package/dist/accessibility.d.ts.map +1 -0
  3. package/dist/accessibility.js +196 -0
  4. package/dist/accessibility.js.map +1 -0
  5. package/dist/addon.d.ts.map +1 -0
  6. package/dist/addon.js +2 -0
  7. package/dist/addon.js.map +1 -0
  8. package/dist/addons/fit.d.ts.map +1 -0
  9. package/dist/addons/fit.js +40 -0
  10. package/dist/addons/fit.js.map +1 -0
  11. package/dist/addons/search.d.ts +56 -0
  12. package/dist/addons/search.d.ts.map +1 -0
  13. package/dist/addons/search.js +178 -0
  14. package/dist/addons/search.js.map +1 -0
  15. package/dist/addons/web-links.d.ts +30 -0
  16. package/dist/addons/web-links.d.ts.map +1 -0
  17. package/dist/addons/web-links.js +170 -0
  18. package/dist/addons/web-links.js.map +1 -0
  19. package/dist/fit.d.ts.map +1 -0
  20. package/dist/fit.js +14 -0
  21. package/dist/fit.js.map +1 -0
  22. package/dist/index.d.ts +24 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +14 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/input-handler.d.ts +185 -0
  27. package/dist/input-handler.d.ts.map +1 -0
  28. package/dist/input-handler.js +1197 -0
  29. package/dist/input-handler.js.map +1 -0
  30. package/dist/parser-worker.d.ts.map +1 -0
  31. package/dist/parser-worker.js +128 -0
  32. package/dist/parser-worker.js.map +1 -0
  33. package/dist/render-bridge.d.ts +56 -0
  34. package/dist/render-bridge.d.ts.map +1 -0
  35. package/dist/render-bridge.js +158 -0
  36. package/dist/render-bridge.js.map +1 -0
  37. package/dist/render-worker.d.ts +62 -0
  38. package/dist/render-worker.d.ts.map +1 -0
  39. package/dist/render-worker.js +720 -0
  40. package/dist/render-worker.js.map +1 -0
  41. package/dist/renderer.d.ts +86 -0
  42. package/dist/renderer.d.ts.map +1 -0
  43. package/dist/renderer.js +454 -0
  44. package/dist/renderer.js.map +1 -0
  45. package/dist/shared-context.d.ts +93 -0
  46. package/dist/shared-context.d.ts.map +1 -0
  47. package/dist/shared-context.js +561 -0
  48. package/dist/shared-context.js.map +1 -0
  49. package/dist/web-terminal.d.ts +152 -0
  50. package/dist/web-terminal.d.ts.map +1 -0
  51. package/dist/web-terminal.js +684 -0
  52. package/dist/web-terminal.js.map +1 -0
  53. package/dist/webgl-renderer.d.ts +146 -0
  54. package/dist/webgl-renderer.d.ts.map +1 -0
  55. package/dist/webgl-renderer.js +1047 -0
  56. package/dist/webgl-renderer.js.map +1 -0
  57. package/dist/worker-bridge.d.ts +51 -0
  58. package/dist/worker-bridge.d.ts.map +1 -0
  59. package/dist/worker-bridge.js +185 -0
  60. package/dist/worker-bridge.js.map +1 -0
  61. package/package.json +36 -0
@@ -0,0 +1,684 @@
1
+ /**
2
+ * WebTerminal — main entry point for the @next_term/web package.
3
+ *
4
+ * Orchestrates the core BufferSet/VTParser, Canvas2DRenderer, InputHandler,
5
+ * and the requestAnimationFrame render loop.
6
+ *
7
+ * When `useWorker` is enabled (default: true when SAB is available) the VT
8
+ * parser runs in a Web Worker via WorkerBridge; otherwise it runs on the main
9
+ * thread as before.
10
+ *
11
+ * When `renderMode` is `'offscreen'` or `'auto'` (with SAB + OffscreenCanvas
12
+ * available), the WebGL2 render loop runs in a separate Web Worker via
13
+ * RenderBridge, leaving the main thread free for DOM event handling only.
14
+ */
15
+ import { BufferSet, CellGrid, DEFAULT_THEME, VTParser } from "@next_term/core";
16
+ import { AccessibilityManager } from "./accessibility.js";
17
+ import { calculateFit } from "./fit.js";
18
+ import { InputHandler } from "./input-handler.js";
19
+ import { canUseOffscreenCanvas, RenderBridge } from "./render-bridge.js";
20
+ import { Canvas2DRenderer } from "./renderer.js";
21
+ import { WebGLRenderer } from "./webgl-renderer.js";
22
+ import { WorkerBridge } from "./worker-bridge.js";
23
+ // ---------------------------------------------------------------------------
24
+ // Feature detection
25
+ // ---------------------------------------------------------------------------
26
+ const SAB_AVAILABLE = typeof SharedArrayBuffer !== "undefined" &&
27
+ (typeof crossOriginIsolated !== "undefined" ? crossOriginIsolated : true);
28
+ const OFFSCREEN_CANVAS_AVAILABLE = canUseOffscreenCanvas();
29
+ // ---------------------------------------------------------------------------
30
+ // Defaults
31
+ // ---------------------------------------------------------------------------
32
+ const DEFAULT_COLS = 80;
33
+ const DEFAULT_ROWS = 24;
34
+ const DEFAULT_FONT_SIZE = 14;
35
+ const DEFAULT_FONT_FAMILY = "'Menlo', 'DejaVu Sans Mono', 'Consolas', monospace";
36
+ const DEFAULT_SCROLLBACK = 1000;
37
+ // ---------------------------------------------------------------------------
38
+ // WebTerminal
39
+ // ---------------------------------------------------------------------------
40
+ function mergeTheme(partial) {
41
+ if (!partial)
42
+ return { ...DEFAULT_THEME };
43
+ return { ...DEFAULT_THEME, ...partial };
44
+ }
45
+ export class WebTerminal {
46
+ container;
47
+ canvas;
48
+ bufferSet;
49
+ parser = null;
50
+ renderer;
51
+ inputHandler;
52
+ disposed = false;
53
+ addons = [];
54
+ accessibilityManager = null;
55
+ /** WorkerBridge when using off-thread parsing, null otherwise. */
56
+ workerBridge = null;
57
+ /** RenderBridge when using off-thread rendering, null otherwise. */
58
+ renderBridge = null;
59
+ /** Whether the worker mode is active. */
60
+ useWorkerMode;
61
+ /** Whether the offscreen render mode is active. */
62
+ useOffscreenRender;
63
+ /** Track whether alternate buffer is active so we can detect switches. */
64
+ wasAlternate = false;
65
+ /** Track sync output mode to detect transitions. */
66
+ _syncedOutput = false;
67
+ // Scrollback viewport: 0 = live (bottom), positive = lines scrolled back
68
+ viewportOffset = 0;
69
+ /** Temporary display grid used when scrolled into scrollback. */
70
+ displayGrid = null;
71
+ /** Scrollbar overlay element. */
72
+ scrollbarEl = null;
73
+ /** Scrollbar thumb element. */
74
+ scrollbarThumb = null;
75
+ /** Timer to auto-hide scrollbar. */
76
+ scrollbarHideTimer = null;
77
+ // Text encoder for string -> Uint8Array
78
+ encoder = new TextEncoder();
79
+ // Callbacks
80
+ onDataCallback;
81
+ onResizeCallback;
82
+ onTitleChangeCallback;
83
+ constructor(container, options) {
84
+ this.container = container;
85
+ const cols = options?.cols ?? DEFAULT_COLS;
86
+ const rows = options?.rows ?? DEFAULT_ROWS;
87
+ const fontSize = options?.fontSize ?? DEFAULT_FONT_SIZE;
88
+ const fontFamily = options?.fontFamily ?? DEFAULT_FONT_FAMILY;
89
+ const theme = mergeTheme(options?.theme);
90
+ const scrollback = options?.scrollback ?? DEFAULT_SCROLLBACK;
91
+ this.onDataCallback = options?.onData ?? null;
92
+ this.onResizeCallback = options?.onResize ?? null;
93
+ this.onTitleChangeCallback = options?.onTitleChange ?? null;
94
+ // Determine whether to use the worker.
95
+ this.useWorkerMode = options?.useWorker ?? SAB_AVAILABLE;
96
+ // Determine render mode.
97
+ const renderMode = options?.renderMode ?? "auto";
98
+ if (renderMode === "offscreen") {
99
+ this.useOffscreenRender = true;
100
+ }
101
+ else if (renderMode === "main") {
102
+ this.useOffscreenRender = false;
103
+ }
104
+ else {
105
+ // 'auto': use offscreen if SAB + OffscreenCanvas both available
106
+ this.useOffscreenRender = SAB_AVAILABLE && OFFSCREEN_CANVAS_AVAILABLE;
107
+ }
108
+ // Create core buffer set
109
+ this.bufferSet = new BufferSet(cols, rows, scrollback);
110
+ // Create canvas element
111
+ this.canvas = document.createElement("canvas");
112
+ this.canvas.style.display = "block";
113
+ container.style.position = container.style.position || "relative";
114
+ container.style.overflow = "hidden";
115
+ container.appendChild(this.canvas);
116
+ // Create scrollbar overlay
117
+ this.createScrollbar(container);
118
+ // Create renderer based on selected backend
119
+ const rendererType = options?.renderer ?? "auto";
120
+ const rendererOpts = {
121
+ fontSize,
122
+ fontFamily,
123
+ theme,
124
+ devicePixelRatio: options?.devicePixelRatio,
125
+ };
126
+ if (this.useOffscreenRender && this.bufferSet.active.grid.isShared) {
127
+ // Full worker mode: rendering happens in a Web Worker via RenderBridge.
128
+ // We still need a main-thread renderer for getCellSize() measurements.
129
+ this.renderer = new Canvas2DRenderer(rendererOpts);
130
+ this.renderBridge = new RenderBridge(this.canvas, {
131
+ fontSize,
132
+ fontFamily,
133
+ theme,
134
+ devicePixelRatio: options?.devicePixelRatio,
135
+ onError: (message) => {
136
+ console.warn("[WebTerminal] Render worker error, falling back:", message);
137
+ this.fallbackToMainThreadRenderer(rendererOpts);
138
+ },
139
+ });
140
+ this.renderBridge.start(this.bufferSet.active.grid.getBuffer(), cols, rows);
141
+ // Sync cursor into SAB so the render worker can read it
142
+ this.syncCursorToSAB();
143
+ }
144
+ else {
145
+ if (rendererType === "webgl") {
146
+ this.renderer = new WebGLRenderer(rendererOpts);
147
+ }
148
+ else if (rendererType === "canvas2d") {
149
+ this.renderer = new Canvas2DRenderer(rendererOpts);
150
+ }
151
+ else {
152
+ // 'auto': try WebGL2 first, fall back to Canvas 2D
153
+ let useWebGL = false;
154
+ try {
155
+ const testCanvas = document.createElement("canvas");
156
+ const testGl = testCanvas.getContext("webgl2");
157
+ useWebGL = testGl !== null;
158
+ }
159
+ catch {
160
+ // WebGL2 not available
161
+ }
162
+ this.renderer = useWebGL
163
+ ? new WebGLRenderer(rendererOpts)
164
+ : new Canvas2DRenderer(rendererOpts);
165
+ }
166
+ this.renderer.attach(this.canvas, this.bufferSet.active.grid, this.bufferSet.active.cursor);
167
+ }
168
+ // Create input handler
169
+ this.inputHandler = new InputHandler({
170
+ onData: (data) => {
171
+ // User typed something — snap back to live view
172
+ this.snapToBottom();
173
+ this.onDataCallback?.(data);
174
+ },
175
+ onSelectionChange: (sel) => {
176
+ if (this.renderBridge) {
177
+ this.renderBridge.updateSelection(sel);
178
+ }
179
+ else {
180
+ this.renderer.setSelection(sel);
181
+ }
182
+ },
183
+ onScroll: (deltaRows) => {
184
+ // GestureHandler already negates: positive = scroll back (older content).
185
+ this.scrollViewport(deltaRows);
186
+ },
187
+ onFontSizeChange: (newFontSize) => {
188
+ this.setFont(newFontSize, fontFamily);
189
+ },
190
+ });
191
+ this.inputHandler.setGrid(this.bufferSet.active.grid);
192
+ this.inputHandler.setFontSize(fontSize);
193
+ const { width, height } = this.renderer.getCellSize();
194
+ this.inputHandler.attach(container, width, height);
195
+ // Set up parsing: worker mode or direct mode.
196
+ if (this.useWorkerMode) {
197
+ this.startWorkerMode(cols, rows, scrollback);
198
+ }
199
+ else {
200
+ this.parser = new VTParser(this.bufferSet);
201
+ // Wire up title change callback
202
+ this.parser.setTitleChangeCallback((title) => {
203
+ this.onTitleChangeCallback?.(title);
204
+ });
205
+ }
206
+ // Start render loop (only when NOT using offscreen — the worker has its own loop)
207
+ if (!this.renderBridge) {
208
+ this.renderer.startRenderLoop();
209
+ }
210
+ // Initialize accessibility manager
211
+ this.accessibilityManager = new AccessibilityManager(container, this.bufferSet.active.grid, rows, cols);
212
+ }
213
+ // -----------------------------------------------------------------------
214
+ // Worker management
215
+ // -----------------------------------------------------------------------
216
+ startWorkerMode(cols, rows, scrollback) {
217
+ try {
218
+ this.workerBridge = new WorkerBridge(this.bufferSet.active.grid, this.bufferSet.active.cursor, (isAlternate) => {
219
+ // When the alternate buffer is toggled the renderer needs to
220
+ // know which grid to read from.
221
+ const activeGrid = isAlternate
222
+ ? this.bufferSet.alternate.grid
223
+ : this.bufferSet.normal.grid;
224
+ const activeCursor = isAlternate
225
+ ? this.bufferSet.alternate.cursor
226
+ : this.bufferSet.normal.cursor;
227
+ if (this.renderBridge) {
228
+ // In offscreen mode, update the render worker's SAB reference
229
+ this.renderBridge.resize(activeGrid.cols, activeGrid.rows, activeGrid.getBuffer());
230
+ }
231
+ else {
232
+ this.renderer.attach(this.canvas, activeGrid, activeCursor);
233
+ }
234
+ }, (message) => {
235
+ // On worker error, fall back to main-thread parsing.
236
+ console.warn("[WebTerminal] Worker error, falling back to main thread:", message);
237
+ this.fallbackToMainThread();
238
+ });
239
+ this.workerBridge.start(cols, rows, scrollback);
240
+ }
241
+ catch {
242
+ // Worker could not be created — fall back silently.
243
+ this.fallbackToMainThread();
244
+ }
245
+ }
246
+ fallbackToMainThread() {
247
+ if (this.workerBridge) {
248
+ this.workerBridge.dispose();
249
+ this.workerBridge = null;
250
+ }
251
+ if (!this.parser) {
252
+ this.parser = new VTParser(this.bufferSet);
253
+ this.parser.setTitleChangeCallback((title) => {
254
+ this.onTitleChangeCallback?.(title);
255
+ });
256
+ }
257
+ }
258
+ /**
259
+ * Fall back from offscreen rendering to main-thread rendering.
260
+ */
261
+ fallbackToMainThreadRenderer(rendererOpts) {
262
+ if (this.renderBridge) {
263
+ this.renderBridge.dispose();
264
+ this.renderBridge = null;
265
+ }
266
+ // The canvas was transferred; we need a new one.
267
+ const newCanvas = document.createElement("canvas");
268
+ newCanvas.style.display = "block";
269
+ if (this.canvas.parentElement) {
270
+ this.canvas.parentElement.replaceChild(newCanvas, this.canvas);
271
+ }
272
+ this.canvas = newCanvas;
273
+ this.renderer = new Canvas2DRenderer(rendererOpts);
274
+ this.renderer.attach(this.canvas, this.bufferSet.active.grid, this.bufferSet.active.cursor);
275
+ this.renderer.startRenderLoop();
276
+ }
277
+ /**
278
+ * Write cursor state into the SAB so the render worker can read it.
279
+ */
280
+ syncCursorToSAB() {
281
+ const cursor = this.bufferSet.active.cursor;
282
+ const grid = this.bufferSet.active.grid;
283
+ grid.setCursor(cursor.row, cursor.col, cursor.visible, cursor.style);
284
+ }
285
+ // -----------------------------------------------------------------------
286
+ // Public API
287
+ // -----------------------------------------------------------------------
288
+ get cols() {
289
+ return this.bufferSet.cols;
290
+ }
291
+ get rows() {
292
+ return this.bufferSet.rows;
293
+ }
294
+ /** The active grid (for addons to read cell data). */
295
+ get activeGrid() {
296
+ return this.bufferSet.active.grid;
297
+ }
298
+ /** The active cursor state (for addons). */
299
+ get activeCursor() {
300
+ return this.bufferSet.active.cursor;
301
+ }
302
+ /** The container element. */
303
+ get element() {
304
+ return this.container;
305
+ }
306
+ /**
307
+ * Write data to the terminal. When worker mode is active the data is
308
+ * forwarded to the Web Worker; otherwise it is parsed on the main thread.
309
+ */
310
+ write(data) {
311
+ if (this.disposed)
312
+ return;
313
+ // New data arrived — snap back to live view
314
+ this.snapToBottom();
315
+ const bytes = typeof data === "string" ? this.encoder.encode(data) : data;
316
+ if (this.workerBridge) {
317
+ this.workerBridge.write(bytes);
318
+ }
319
+ else if (this.parser) {
320
+ this.parser.write(bytes);
321
+ // Sync mode flags from parser to input handler
322
+ this.syncParserModes();
323
+ }
324
+ // Update accessibility tree (throttled internally to 10 Hz)
325
+ this.accessibilityManager?.update();
326
+ }
327
+ /** Sync parser mode flags to the input handler, and detect buffer switches. */
328
+ syncParserModes() {
329
+ if (!this.parser)
330
+ return;
331
+ this.inputHandler.setApplicationCursorKeys(this.parser.applicationCursorKeys);
332
+ this.inputHandler.setBracketedPasteMode(this.parser.bracketedPasteMode);
333
+ this.inputHandler.setMouseProtocol(this.parser.mouseProtocol);
334
+ this.inputHandler.setMouseEncoding(this.parser.mouseEncoding);
335
+ this.inputHandler.setSendFocusEvents(this.parser.sendFocusEvents);
336
+ this.inputHandler.setKittyFlags(this.parser.kittyFlags);
337
+ // Synchronized output mode 2026: gate the main-thread render loop.
338
+ // The offscreen render worker has its own loop and is not gated here.
339
+ const isSynced = this.parser.syncedOutput;
340
+ if (isSynced !== this._syncedOutput) {
341
+ this._syncedOutput = isSynced;
342
+ if (!this.renderBridge) {
343
+ if (isSynced) {
344
+ this.renderer.stopRenderLoop();
345
+ }
346
+ else {
347
+ this.renderer.startRenderLoop();
348
+ this.renderer.render();
349
+ }
350
+ }
351
+ }
352
+ // Detect alternate buffer switch and re-attach renderer
353
+ const isAlt = this.bufferSet.isAlternate;
354
+ if (isAlt !== this.wasAlternate) {
355
+ this.wasAlternate = isAlt;
356
+ const activeGrid = this.bufferSet.active.grid;
357
+ const activeCursor = this.bufferSet.active.cursor;
358
+ if (this.renderBridge) {
359
+ this.renderBridge.resize(activeGrid.cols, activeGrid.rows, activeGrid.getBuffer());
360
+ }
361
+ else {
362
+ this.renderer.attach(this.canvas, activeGrid, activeCursor);
363
+ }
364
+ this.inputHandler.setGrid(activeGrid);
365
+ this.accessibilityManager?.setGrid(activeGrid, activeGrid.rows, activeGrid.cols);
366
+ }
367
+ }
368
+ resize(cols, rows) {
369
+ if (this.disposed)
370
+ return;
371
+ // Guard against bad values
372
+ if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols < 2 || rows < 1)
373
+ return;
374
+ const scrollback = this.bufferSet.maxScrollback;
375
+ const oldBufferSet = this.bufferSet;
376
+ const oldGrid = oldBufferSet.active.grid;
377
+ const oldCursor = oldBufferSet.active.cursor;
378
+ const oldCols = oldBufferSet.cols;
379
+ const oldRows = oldBufferSet.rows;
380
+ // Create new buffer set
381
+ this.bufferSet = new BufferSet(cols, rows, scrollback);
382
+ // Copy content from old grid to new grid.
383
+ // When rows shrink, keep the bottom portion (where the cursor is).
384
+ // When rows grow, content stays at the top.
385
+ const newGrid = this.bufferSet.active.grid;
386
+ const copyRows = Math.min(oldRows, rows);
387
+ const _copyCols = Math.min(oldCols, cols);
388
+ // Determine source start row: if cursor was below the new row count,
389
+ // shift content up so cursor remains visible.
390
+ let srcStartRow = 0;
391
+ if (oldCursor.row >= rows) {
392
+ srcStartRow = oldCursor.row - rows + 1;
393
+ }
394
+ for (let r = 0; r < copyRows; r++) {
395
+ const srcRow = srcStartRow + r;
396
+ if (srcRow >= oldRows)
397
+ break;
398
+ const rowData = oldGrid.copyRow(srcRow);
399
+ // If old cols > new cols, the row data is wider — pasteRow handles truncation
400
+ // If old cols < new cols, extra cells remain at default
401
+ newGrid.pasteRow(r, rowData);
402
+ }
403
+ // Adjust cursor position for the new dimensions
404
+ const newCursor = this.bufferSet.active.cursor;
405
+ newCursor.row = Math.max(0, Math.min(oldCursor.row - srcStartRow, rows - 1));
406
+ newCursor.col = Math.min(oldCursor.col, cols - 1);
407
+ newCursor.visible = oldCursor.visible;
408
+ newCursor.style = oldCursor.style;
409
+ // Copy scrollback
410
+ this.bufferSet.scrollback = oldBufferSet.scrollback;
411
+ newGrid.markAllDirty();
412
+ if (this.workerBridge) {
413
+ // Update the bridge's grid reference and notify the worker.
414
+ this.workerBridge.updateGrid(this.bufferSet.active.grid, this.bufferSet.active.cursor);
415
+ this.workerBridge.resize(cols, rows, scrollback);
416
+ }
417
+ else {
418
+ this.parser = new VTParser(this.bufferSet);
419
+ this.parser.setTitleChangeCallback((title) => {
420
+ this.onTitleChangeCallback?.(title);
421
+ });
422
+ }
423
+ if (this.renderBridge) {
424
+ // Notify render worker of resize with new SAB
425
+ this.renderBridge.resize(cols, rows, this.bufferSet.active.grid.getBuffer());
426
+ }
427
+ else {
428
+ // Re-attach renderer with new grid
429
+ this.renderer.attach(this.canvas, this.bufferSet.active.grid, this.bufferSet.active.cursor);
430
+ this.renderer.resize(cols, rows);
431
+ }
432
+ // Update input handler's grid reference
433
+ this.inputHandler.setGrid(this.bufferSet.active.grid);
434
+ // Update accessibility manager with new grid
435
+ if (this.accessibilityManager) {
436
+ this.accessibilityManager.setGrid(this.bufferSet.active.grid, rows, cols);
437
+ }
438
+ this.onResizeCallback?.({ cols, rows });
439
+ }
440
+ /** Fit the terminal to its container. */
441
+ fit() {
442
+ if (this.disposed)
443
+ return;
444
+ const { width, height } = this.renderer.getCellSize();
445
+ if (width <= 0 || height <= 0)
446
+ return;
447
+ const { cols, rows } = calculateFit(this.container, width, height);
448
+ if (cols !== this.bufferSet.cols || rows !== this.bufferSet.rows) {
449
+ this.resize(cols, rows);
450
+ }
451
+ }
452
+ focus() {
453
+ this.inputHandler.focus();
454
+ }
455
+ blur() {
456
+ this.inputHandler.blur();
457
+ }
458
+ setTheme(theme) {
459
+ const merged = mergeTheme(theme);
460
+ if (this.renderBridge) {
461
+ this.renderBridge.setTheme(merged);
462
+ }
463
+ this.renderer.setTheme(merged);
464
+ }
465
+ setFont(fontSize, fontFamily) {
466
+ if (this.renderBridge) {
467
+ this.renderBridge.setFont(fontSize, fontFamily);
468
+ }
469
+ if (this.renderer.setFont) {
470
+ this.renderer.setFont(fontSize, fontFamily);
471
+ }
472
+ const { width, height } = this.renderer.getCellSize();
473
+ this.inputHandler.updateCellSize(width, height);
474
+ this.inputHandler.setFontSize(fontSize);
475
+ }
476
+ getCellSize() {
477
+ return this.renderer.getCellSize();
478
+ }
479
+ onData(callback) {
480
+ this.onDataCallback = callback;
481
+ }
482
+ onResize(callback) {
483
+ this.onResizeCallback = callback;
484
+ }
485
+ /** Load an addon into this terminal. */
486
+ loadAddon(addon) {
487
+ addon.activate(this);
488
+ this.addons.push(addon);
489
+ }
490
+ /** Set highlight ranges on the renderer (used by SearchAddon). */
491
+ setHighlights(highlights) {
492
+ this.renderer.setHighlights(highlights);
493
+ }
494
+ // -----------------------------------------------------------------------
495
+ // Scrollback viewport
496
+ // -----------------------------------------------------------------------
497
+ createScrollbar(container) {
498
+ const bar = document.createElement("div");
499
+ Object.assign(bar.style, {
500
+ position: "absolute",
501
+ right: "0",
502
+ top: "0",
503
+ bottom: "0",
504
+ width: "6px",
505
+ zIndex: "10",
506
+ opacity: "0",
507
+ transition: "opacity 0.3s",
508
+ pointerEvents: "none",
509
+ });
510
+ const thumb = document.createElement("div");
511
+ Object.assign(thumb.style, {
512
+ position: "absolute",
513
+ right: "1px",
514
+ width: "4px",
515
+ borderRadius: "2px",
516
+ backgroundColor: "rgba(255, 255, 255, 0.4)",
517
+ minHeight: "20px",
518
+ });
519
+ bar.appendChild(thumb);
520
+ container.appendChild(bar);
521
+ this.scrollbarEl = bar;
522
+ this.scrollbarThumb = thumb;
523
+ }
524
+ updateScrollbar() {
525
+ if (!this.scrollbarEl || !this.scrollbarThumb)
526
+ return;
527
+ const totalLines = this.bufferSet.scrollback.length + this.bufferSet.rows;
528
+ const visibleRows = this.bufferSet.rows;
529
+ if (totalLines <= visibleRows || this.viewportOffset === 0) {
530
+ // At bottom or no scrollback — hide
531
+ this.scrollbarEl.style.opacity = "0";
532
+ return;
533
+ }
534
+ // Show scrollbar
535
+ this.scrollbarEl.style.opacity = "1";
536
+ // Calculate thumb size and position
537
+ const containerHeight = this.scrollbarEl.clientHeight || visibleRows * this.renderer.getCellSize().height;
538
+ const thumbHeight = Math.max(20, (visibleRows / totalLines) * containerHeight);
539
+ const maxScroll = this.bufferSet.scrollback.length;
540
+ const scrollFraction = (maxScroll - this.viewportOffset) / maxScroll;
541
+ const thumbTop = scrollFraction * (containerHeight - thumbHeight);
542
+ this.scrollbarThumb.style.height = `${thumbHeight}px`;
543
+ this.scrollbarThumb.style.top = `${thumbTop}px`;
544
+ // Auto-hide after 1.5s
545
+ if (this.scrollbarHideTimer)
546
+ clearTimeout(this.scrollbarHideTimer);
547
+ this.scrollbarHideTimer = setTimeout(() => {
548
+ if (this.scrollbarEl)
549
+ this.scrollbarEl.style.opacity = "0";
550
+ }, 1500);
551
+ }
552
+ /**
553
+ * Scroll the viewport into scrollback. deltaLines > 0 scrolls back (older),
554
+ * deltaLines < 0 scrolls forward (newer).
555
+ */
556
+ scrollViewport(deltaLines) {
557
+ const maxOffset = this.bufferSet.scrollback.length;
558
+ const newOffset = Math.max(0, Math.min(maxOffset, this.viewportOffset + deltaLines));
559
+ if (newOffset === this.viewportOffset)
560
+ return;
561
+ this.viewportOffset = newOffset;
562
+ if (newOffset === 0) {
563
+ // Back to live view — re-attach live grid
564
+ if (this.displayGrid) {
565
+ this.displayGrid = null;
566
+ if (!this.renderBridge) {
567
+ this.renderer.attach(this.canvas, this.bufferSet.active.grid, this.bufferSet.active.cursor);
568
+ }
569
+ }
570
+ }
571
+ else {
572
+ // Scrolled back — build display grid from scrollback + buffer
573
+ this.buildDisplayGrid();
574
+ }
575
+ this.updateScrollbar();
576
+ }
577
+ /**
578
+ * Build a display grid showing the correct mix of scrollback and buffer
579
+ * lines for the current viewportOffset.
580
+ */
581
+ buildDisplayGrid() {
582
+ const cols = this.bufferSet.cols;
583
+ const rows = this.bufferSet.rows;
584
+ const scrollback = this.bufferSet.scrollback;
585
+ // Reuse display grid if dimensions match, otherwise create new.
586
+ // Only call renderer.attach() when the grid is first created —
587
+ // subsequent updates just populate data and mark dirty.
588
+ let needsAttach = false;
589
+ if (!this.displayGrid || this.displayGrid.cols !== cols || this.displayGrid.rows !== rows) {
590
+ this.displayGrid = new CellGrid(cols, rows);
591
+ needsAttach = true;
592
+ }
593
+ // Virtual line numbering:
594
+ // [0 .. scrollback.length-1] = scrollback lines
595
+ // [scrollback.length .. scrollback.length+rows-1] = live buffer rows
596
+ // viewportTop = scrollback.length - viewportOffset
597
+ const viewportTop = scrollback.length - this.viewportOffset;
598
+ for (let r = 0; r < rows; r++) {
599
+ const virtualLine = viewportTop + r;
600
+ if (virtualLine < 0) {
601
+ // Before scrollback — show empty
602
+ this.displayGrid.clearRow(r);
603
+ }
604
+ else if (virtualLine < scrollback.length) {
605
+ // From scrollback
606
+ this.displayGrid.pasteRow(r, scrollback[virtualLine]);
607
+ }
608
+ else {
609
+ // From live buffer
610
+ const bufRow = virtualLine - scrollback.length;
611
+ if (bufRow < rows) {
612
+ const rowData = this.bufferSet.active.grid.copyRow(bufRow);
613
+ this.displayGrid.pasteRow(r, rowData);
614
+ }
615
+ else {
616
+ this.displayGrid.clearRow(r);
617
+ }
618
+ }
619
+ }
620
+ if (!this.renderBridge) {
621
+ if (needsAttach) {
622
+ // Create a fake cursor (hidden) when scrolled back
623
+ const fakeCursor = {
624
+ row: 0,
625
+ col: 0,
626
+ visible: false,
627
+ style: "block",
628
+ wrapPending: false,
629
+ };
630
+ this.renderer.attach(this.canvas, this.displayGrid, fakeCursor);
631
+ }
632
+ // markAllDirty is called by pasteRow/clearRow, but ensure full redraw
633
+ this.displayGrid.markAllDirty();
634
+ }
635
+ }
636
+ /** Snap viewport to live (bottom) — called when new data arrives or user types. */
637
+ snapToBottom() {
638
+ if (this.viewportOffset === 0)
639
+ return;
640
+ this.viewportOffset = 0;
641
+ this.displayGrid = null;
642
+ if (!this.renderBridge) {
643
+ this.renderer.attach(this.canvas, this.bufferSet.active.grid, this.bufferSet.active.cursor);
644
+ }
645
+ this.updateScrollbar();
646
+ }
647
+ dispose() {
648
+ if (this.disposed)
649
+ return;
650
+ this.disposed = true;
651
+ if (this.scrollbarHideTimer)
652
+ clearTimeout(this.scrollbarHideTimer);
653
+ for (const addon of this.addons)
654
+ addon.dispose();
655
+ this.addons = [];
656
+ if (this.workerBridge) {
657
+ this.workerBridge.dispose();
658
+ this.workerBridge = null;
659
+ }
660
+ if (this.renderBridge) {
661
+ this.renderBridge.dispose();
662
+ this.renderBridge = null;
663
+ }
664
+ if (this.accessibilityManager) {
665
+ this.accessibilityManager.dispose();
666
+ this.accessibilityManager = null;
667
+ }
668
+ this.renderer.dispose();
669
+ this.inputHandler.dispose();
670
+ if (this.canvas.parentElement) {
671
+ this.canvas.parentElement.removeChild(this.canvas);
672
+ }
673
+ if (this.scrollbarEl?.parentElement) {
674
+ this.scrollbarEl.parentElement.removeChild(this.scrollbarEl);
675
+ }
676
+ this.scrollbarEl = null;
677
+ this.scrollbarThumb = null;
678
+ this.displayGrid = null;
679
+ this.onDataCallback = null;
680
+ this.onResizeCallback = null;
681
+ this.onTitleChangeCallback = null;
682
+ }
683
+ }
684
+ //# sourceMappingURL=web-terminal.js.map