@blit-sh/core 0.17.1 → 0.19.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.
@@ -0,0 +1,1464 @@
1
+ import { DEFAULT_FONT, DEFAULT_FONT_SIZE } from "./types";
2
+ import { measureCell, cssFontFamily } from "./measure";
3
+ import { keyToBytes, encoder } from "./keyboard";
4
+ import { MOUSE_DOWN, MOUSE_UP, MOUSE_MOVE } from "./protocol";
5
+ // ---------------------------------------------------------------------------
6
+ // DPR detection
7
+ // ---------------------------------------------------------------------------
8
+ const isSafari = typeof navigator !== "undefined"
9
+ ? /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
10
+ : false;
11
+ function effectiveDpr() {
12
+ if (typeof window === "undefined")
13
+ return 1;
14
+ const base = window.devicePixelRatio || 1;
15
+ if (isSafari && window.outerWidth && window.innerWidth) {
16
+ const zoom = window.outerWidth / window.innerWidth;
17
+ if (zoom > 0.25 && zoom < 8)
18
+ return Math.round(base * zoom * 100) / 100;
19
+ }
20
+ return base;
21
+ }
22
+ // ---------------------------------------------------------------------------
23
+ // BlitTerminalSurface
24
+ // ---------------------------------------------------------------------------
25
+ /**
26
+ * Framework-agnostic terminal surface. Manages DOM elements, WebGL rendering,
27
+ * keyboard/mouse input, selection, scrollbar, DPR tracking, and resize
28
+ * observation. Framework bindings (React, Solid, etc.) attach this to a
29
+ * container element and forward option changes.
30
+ */
31
+ export class BlitTerminalSurface {
32
+ constructor(options) {
33
+ // --- configuration (set via setters) ---
34
+ this._sessionId = null;
35
+ // --- external collaborators ---
36
+ this._workspace = null;
37
+ this._blitConn = null;
38
+ // --- DOM elements ---
39
+ this.container = null;
40
+ this.glCanvas = null;
41
+ this.inputEl = null;
42
+ // --- mutable state ---
43
+ this.viewId = null;
44
+ this.terminal = null;
45
+ this.renderer = null;
46
+ this.displayCtx = null;
47
+ this._rows = 24;
48
+ this._cols = 80;
49
+ this.contentDirty = true;
50
+ this.lastOffset = 0;
51
+ this.lastWasmBuffer = null;
52
+ this.raf = 0;
53
+ this.renderScheduled = false;
54
+ this.scrollOffset = 0;
55
+ this.scrollFade = 0;
56
+ this.scrollFadeTimer = null;
57
+ this.scrollbarGeo = null;
58
+ this.scrollDragging = false;
59
+ this.scrollDragOffset = 0;
60
+ this.cursorBlinkOn = true;
61
+ this.cursorBlinkTimer = null;
62
+ this.selStart = null;
63
+ this.selEnd = null;
64
+ this.selecting = false;
65
+ this.hoveredUrl = null;
66
+ this.predicted = "";
67
+ this.predictedFromRow = 0;
68
+ this.predictedFromCol = 0;
69
+ this.wasmReady = false;
70
+ this.disposed = false;
71
+ // --- subscriptions / observers ---
72
+ this.dirtyUnsub = null;
73
+ this.readyUnsub = null;
74
+ this.resizeObserver = null;
75
+ this.dprMq = null;
76
+ this.dprCheckHandler = null;
77
+ this.fontsHandler = null;
78
+ // --- event handler refs (for cleanup) ---
79
+ this.boundKeyDown = null;
80
+ this.boundCompositionEnd = null;
81
+ this.boundInput = null;
82
+ this.boundContainerWheel = null;
83
+ this.mouseCleanup = null;
84
+ this.windowResizeHandler = null;
85
+ this._sessionId = options.sessionId;
86
+ this._fontFamily = options.fontFamily ?? DEFAULT_FONT;
87
+ this._fontSize = options.fontSize ?? DEFAULT_FONT_SIZE;
88
+ this._palette = options.palette;
89
+ this._readOnly = options.readOnly ?? false;
90
+ this._showCursor = options.showCursor ?? true;
91
+ this._onRender = options.onRender;
92
+ this._scrollbarColor = options.scrollbarColor;
93
+ this._scrollbarWidth = options.scrollbarWidth ?? 4;
94
+ this._advanceRatio = options.advanceRatio;
95
+ this.dpr = effectiveDpr();
96
+ this.cell = measureCell(this._fontFamily, this._fontSize, this.dpr, this._advanceRatio);
97
+ }
98
+ // =========================================================================
99
+ // Public API
100
+ // =========================================================================
101
+ get rows() {
102
+ return this._rows;
103
+ }
104
+ get cols() {
105
+ return this._cols;
106
+ }
107
+ get currentTerminal() {
108
+ return this.terminal;
109
+ }
110
+ get status() {
111
+ // Derive from the connection snapshot; callers can also check directly.
112
+ return this._blitConn
113
+ ? (this._blitConn.getSnapshot?.()?.status ?? "disconnected")
114
+ : "disconnected";
115
+ }
116
+ focus() {
117
+ this.inputEl?.focus();
118
+ }
119
+ /** Attach to a container element. Creates the canvas + textarea inside it. */
120
+ attach(container) {
121
+ if (this.container === container)
122
+ return;
123
+ this.detach();
124
+ this.container = container;
125
+ // Create canvas
126
+ this.glCanvas = document.createElement("canvas");
127
+ if (this._readOnly) {
128
+ Object.assign(this.glCanvas.style, {
129
+ display: "block",
130
+ width: "100%",
131
+ height: "100%",
132
+ objectFit: "contain",
133
+ objectPosition: "center",
134
+ });
135
+ }
136
+ else {
137
+ Object.assign(this.glCanvas.style, {
138
+ display: "block",
139
+ position: "absolute",
140
+ top: "0",
141
+ left: "0",
142
+ cursor: "text",
143
+ });
144
+ }
145
+ container.appendChild(this.glCanvas);
146
+ // Create hidden textarea for keyboard input (unless readOnly)
147
+ if (!this._readOnly) {
148
+ this.inputEl = document.createElement("textarea");
149
+ this.inputEl.setAttribute("aria-label", "Terminal input");
150
+ this.inputEl.setAttribute("autocapitalize", "off");
151
+ this.inputEl.setAttribute("autocomplete", "off");
152
+ this.inputEl.setAttribute("autocorrect", "off");
153
+ this.inputEl.setAttribute("spellcheck", "false");
154
+ this.inputEl.setAttribute("tabindex", "0");
155
+ Object.assign(this.inputEl.style, {
156
+ position: "absolute",
157
+ opacity: "0",
158
+ width: "1px",
159
+ height: "1px",
160
+ top: "0",
161
+ left: "0",
162
+ padding: "0",
163
+ border: "none",
164
+ outline: "none",
165
+ resize: "none",
166
+ overflow: "hidden",
167
+ });
168
+ container.appendChild(this.inputEl);
169
+ }
170
+ this.setupDprDetection();
171
+ this.setupCursorBlink();
172
+ this.setupRenderer();
173
+ this.setupCellMeasure();
174
+ this.setupTerminal();
175
+ this.setupDirtyListener();
176
+ this.setupResizeObserver();
177
+ this.setupRenderLoop();
178
+ this.setupKeyboard();
179
+ this.setupContainerWheel();
180
+ this.setupMouse();
181
+ this.scheduleRender();
182
+ }
183
+ /** Detach from the current container. Removes all DOM elements and listeners. */
184
+ detach() {
185
+ this.teardownMouse();
186
+ this.teardownContainerWheel();
187
+ this.teardownKeyboard();
188
+ this.teardownRenderLoop();
189
+ this.teardownResizeObserver();
190
+ this.teardownDirtyListener();
191
+ this.teardownTerminal();
192
+ this.teardownCellMeasure();
193
+ this.teardownRenderer();
194
+ this.teardownCursorBlink();
195
+ this.teardownDprDetection();
196
+ if (this.glCanvas && this.container?.contains(this.glCanvas)) {
197
+ this.container.removeChild(this.glCanvas);
198
+ }
199
+ if (this.inputEl && this.container?.contains(this.inputEl)) {
200
+ this.container.removeChild(this.inputEl);
201
+ }
202
+ this.glCanvas = null;
203
+ this.inputEl = null;
204
+ this.displayCtx = null;
205
+ this.container = null;
206
+ }
207
+ /** Clean up all resources. Must be called when the surface is no longer needed. */
208
+ dispose() {
209
+ this.detach();
210
+ this.disposed = true;
211
+ }
212
+ // --- Setters for configuration ---
213
+ setWorkspace(workspace) {
214
+ this._workspace = workspace;
215
+ }
216
+ setConnection(conn) {
217
+ if (this._blitConn === conn)
218
+ return;
219
+ this.teardownDirtyListener();
220
+ this.teardownTerminal();
221
+ this.teardownResizeObserver();
222
+ this.teardownRenderer();
223
+ this._blitConn = conn;
224
+ if (this.container) {
225
+ this.setupRenderer();
226
+ this.setupWasmReady();
227
+ this.setupTerminal();
228
+ this.setupDirtyListener();
229
+ this.setupResizeObserver();
230
+ this.contentDirty = true;
231
+ this.scheduleRender();
232
+ }
233
+ }
234
+ setSessionId(id) {
235
+ if (this._sessionId === id)
236
+ return;
237
+ this.teardownDirtyListener();
238
+ this.teardownTerminal();
239
+ this.teardownResizeObserver();
240
+ this._sessionId = id;
241
+ if (this.container) {
242
+ this.setupTerminal();
243
+ this.setupDirtyListener();
244
+ this.setupResizeObserver();
245
+ this.contentDirty = true;
246
+ this.scheduleRender();
247
+ }
248
+ }
249
+ setPalette(palette) {
250
+ this._palette = palette;
251
+ this.applyPaletteToTerminal(this.terminal);
252
+ }
253
+ setFontFamily(fontFamily) {
254
+ const resolved = fontFamily ?? DEFAULT_FONT;
255
+ if (this._fontFamily === resolved)
256
+ return;
257
+ this._fontFamily = resolved;
258
+ this.remeasureCells(true);
259
+ }
260
+ setFontSize(fontSize) {
261
+ const resolved = fontSize ?? DEFAULT_FONT_SIZE;
262
+ if (this._fontSize === resolved)
263
+ return;
264
+ this._fontSize = resolved;
265
+ this.remeasureCells(true);
266
+ }
267
+ /**
268
+ * Update the read-only flag. Note: this only takes full effect when set
269
+ * before `attach()`. Changing it while attached will not create/remove the
270
+ * input textarea or toggle keyboard/mouse listeners.
271
+ */
272
+ /**
273
+ * Update the read-only flag. Note: this only takes full effect when set
274
+ * before `attach()`. Changing it while attached will not create/remove the
275
+ * input textarea or toggle keyboard/mouse listeners.
276
+ */
277
+ setReadOnly(readOnly) {
278
+ this._readOnly = readOnly ?? false;
279
+ }
280
+ setShowCursor(show) {
281
+ const resolved = show ?? true;
282
+ if (this._showCursor === resolved)
283
+ return;
284
+ this._showCursor = resolved;
285
+ this.contentDirty = true;
286
+ this.scheduleRender();
287
+ }
288
+ setOnRender(fn) {
289
+ this._onRender = fn;
290
+ }
291
+ setAdvanceRatio(ratio) {
292
+ if (this._advanceRatio === ratio)
293
+ return;
294
+ this._advanceRatio = ratio;
295
+ this.remeasureCells(true);
296
+ }
297
+ // =========================================================================
298
+ // Private setup/teardown methods
299
+ // =========================================================================
300
+ scheduleRender() {
301
+ if (this.renderScheduled || this.disposed)
302
+ return;
303
+ this.renderScheduled = true;
304
+ this.raf = requestAnimationFrame(() => {
305
+ this.renderScheduled = false;
306
+ this.doRender();
307
+ });
308
+ }
309
+ // --- DPR detection ---
310
+ setupDprDetection() {
311
+ this.dprCheckHandler = () => {
312
+ const next = effectiveDpr();
313
+ if (next !== this.dpr) {
314
+ this.dpr = next;
315
+ this.remeasureCells(true);
316
+ }
317
+ };
318
+ if (typeof window.matchMedia === "function") {
319
+ this.dprMq = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
320
+ this.dprMq.addEventListener("change", this.dprCheckHandler);
321
+ }
322
+ window.addEventListener("resize", this.dprCheckHandler);
323
+ }
324
+ teardownDprDetection() {
325
+ if (this.dprCheckHandler) {
326
+ this.dprMq?.removeEventListener("change", this.dprCheckHandler);
327
+ window.removeEventListener("resize", this.dprCheckHandler);
328
+ this.dprCheckHandler = null;
329
+ this.dprMq = null;
330
+ }
331
+ }
332
+ // --- Cell measurement ---
333
+ setupCellMeasure() {
334
+ this.remeasureCells(true);
335
+ this.fontsHandler = () => this.remeasureCells(true);
336
+ document.fonts?.addEventListener("loadingdone", this.fontsHandler);
337
+ if (document.fonts?.status === "loaded")
338
+ this.remeasureCells(true);
339
+ }
340
+ teardownCellMeasure() {
341
+ if (this.fontsHandler) {
342
+ document.fonts?.removeEventListener("loadingdone", this.fontsHandler);
343
+ this.fontsHandler = null;
344
+ }
345
+ }
346
+ remeasureCells(forceInvalidate = false) {
347
+ const cell = measureCell(this._fontFamily, this._fontSize, this.dpr, this._advanceRatio);
348
+ const changed = cell.pw !== this.cell.pw || cell.ph !== this.cell.ph;
349
+ const shouldInvalidate = forceInvalidate || changed;
350
+ this.cell = cell;
351
+ const rasterFontSize = this._fontSize * this.dpr;
352
+ if (!this._readOnly) {
353
+ const t = this.terminal;
354
+ if (t) {
355
+ t.set_cell_size(cell.pw, cell.ph);
356
+ t.set_font_family(this._fontFamily);
357
+ t.set_font_size(rasterFontSize);
358
+ if (shouldInvalidate)
359
+ t.invalidate_render_cache();
360
+ }
361
+ if (this._blitConn) {
362
+ this._blitConn.setCellSize(cell.pw, cell.ph);
363
+ this._blitConn.setFontFamily(this._fontFamily);
364
+ this._blitConn.setFontSize(rasterFontSize);
365
+ }
366
+ }
367
+ if (shouldInvalidate) {
368
+ this.contentDirty = true;
369
+ this.scheduleRender();
370
+ }
371
+ if (changed) {
372
+ this.handleResize();
373
+ }
374
+ }
375
+ // --- Cursor blink ---
376
+ setupCursorBlink() {
377
+ if (this._readOnly)
378
+ return;
379
+ this.cursorBlinkOn = true;
380
+ this.cursorBlinkTimer = setInterval(() => {
381
+ this.cursorBlinkOn = !this.cursorBlinkOn;
382
+ this.scheduleRender();
383
+ }, 530);
384
+ }
385
+ teardownCursorBlink() {
386
+ if (this.cursorBlinkTimer) {
387
+ clearInterval(this.cursorBlinkTimer);
388
+ this.cursorBlinkTimer = null;
389
+ }
390
+ }
391
+ // --- GL renderer ---
392
+ setupRenderer() {
393
+ if (!this._blitConn)
394
+ return;
395
+ const shared = this._blitConn.getSharedRenderer();
396
+ if (shared)
397
+ this.renderer = shared.renderer;
398
+ }
399
+ teardownRenderer() {
400
+ // renderer is shared, don't dispose
401
+ this.renderer = null;
402
+ }
403
+ // --- WASM ready ---
404
+ setupWasmReady() {
405
+ this.readyUnsub?.();
406
+ this.readyUnsub = null;
407
+ if (!this._blitConn) {
408
+ this.wasmReady = false;
409
+ return;
410
+ }
411
+ this.readyUnsub = this._blitConn.onReady(() => {
412
+ this.wasmReady = true;
413
+ });
414
+ if (this._blitConn.isReady())
415
+ this.wasmReady = true;
416
+ }
417
+ // --- Terminal lifecycle ---
418
+ setupTerminal() {
419
+ if (!this._blitConn) {
420
+ this.terminal = null;
421
+ return;
422
+ }
423
+ this.setupWasmReady();
424
+ if (this._sessionId !== null) {
425
+ this._blitConn.retain(this._sessionId);
426
+ const t = this._blitConn.getTerminal(this._sessionId);
427
+ if (t) {
428
+ this.terminal = t;
429
+ this.applyPaletteToTerminal(t);
430
+ if (!this._readOnly) {
431
+ t.set_cell_size(this.cell.pw, this.cell.ph);
432
+ t.set_font_family(this._fontFamily);
433
+ t.set_font_size(this._fontSize * this.dpr);
434
+ }
435
+ this.contentDirty = true;
436
+ this.scheduleRender();
437
+ }
438
+ }
439
+ else {
440
+ this.terminal = null;
441
+ }
442
+ }
443
+ teardownTerminal() {
444
+ this.terminal = null;
445
+ if (this._sessionId !== null && this._blitConn) {
446
+ this._blitConn.release(this._sessionId);
447
+ }
448
+ this.readyUnsub?.();
449
+ this.readyUnsub = null;
450
+ }
451
+ // --- Dirty listener ---
452
+ setupDirtyListener() {
453
+ if (!this._blitConn || this._sessionId === null)
454
+ return;
455
+ const conn = this._blitConn;
456
+ const sessionId = this._sessionId;
457
+ this.dirtyUnsub = conn.addDirtyListener(sessionId, () => {
458
+ const t = conn.getTerminal(sessionId);
459
+ if (!t)
460
+ return;
461
+ if (this.terminal !== t) {
462
+ this.terminal = t;
463
+ this.applyPaletteToTerminal(t);
464
+ this.applyMetricsToTerminal(t);
465
+ }
466
+ this.contentDirty = true;
467
+ this.scheduleRender();
468
+ this.reconcilePrediction();
469
+ if (this._readOnly)
470
+ this.syncReadOnlySize(t);
471
+ });
472
+ // Check for terminal that was created between setup steps.
473
+ const t = conn.getTerminal(sessionId);
474
+ if (t) {
475
+ if (this.terminal !== t) {
476
+ this.terminal = t;
477
+ this.applyPaletteToTerminal(t);
478
+ this.applyMetricsToTerminal(t);
479
+ }
480
+ this.contentDirty = true;
481
+ this.scheduleRender();
482
+ if (this._readOnly)
483
+ this.syncReadOnlySize(t);
484
+ }
485
+ }
486
+ teardownDirtyListener() {
487
+ this.dirtyUnsub?.();
488
+ this.dirtyUnsub = null;
489
+ }
490
+ // --- Palette ---
491
+ applyPaletteToTerminal(t) {
492
+ if (!t || !this._palette)
493
+ return;
494
+ t.set_default_colors(...this._palette.fg, ...this._palette.bg);
495
+ for (let i = 0; i < 16; i++)
496
+ t.set_ansi_color(i, ...this._palette.ansi[i]);
497
+ this.contentDirty = true;
498
+ this.scheduleRender();
499
+ }
500
+ applyMetricsToTerminal(t) {
501
+ t.set_cell_size(this.cell.pw, this.cell.ph);
502
+ t.set_font_family(this._fontFamily);
503
+ t.set_font_size(this._fontSize * this.dpr);
504
+ t.invalidate_render_cache();
505
+ }
506
+ syncReadOnlySize(t) {
507
+ const tr = t.rows;
508
+ const tc = t.cols;
509
+ if (tr !== this._rows || tc !== this._cols) {
510
+ this._rows = tr;
511
+ this._cols = tc;
512
+ }
513
+ this.scheduleRender();
514
+ }
515
+ // --- Resize observer ---
516
+ setupResizeObserver() {
517
+ if (!this.container || this._readOnly)
518
+ return;
519
+ if (!this.viewId && this._blitConn) {
520
+ this.viewId = this._blitConn.allocViewId();
521
+ }
522
+ this.windowResizeHandler = () => this.handleResize();
523
+ this.resizeObserver = new ResizeObserver(() => this.handleResize());
524
+ this.resizeObserver.observe(this.container);
525
+ window.addEventListener("resize", this.windowResizeHandler);
526
+ this.handleResize();
527
+ }
528
+ teardownResizeObserver() {
529
+ this.resizeObserver?.disconnect();
530
+ this.resizeObserver = null;
531
+ if (this.windowResizeHandler) {
532
+ window.removeEventListener("resize", this.windowResizeHandler);
533
+ this.windowResizeHandler = null;
534
+ }
535
+ if (this._sessionId !== null && this._blitConn && this.viewId) {
536
+ this._blitConn.removeView(this._sessionId, this.viewId);
537
+ }
538
+ }
539
+ handleResize() {
540
+ if (!this.container || this._readOnly)
541
+ return;
542
+ const w = this.container.clientWidth;
543
+ const h = this.container.clientHeight;
544
+ const cols = Math.max(1, Math.floor(w / this.cell.w));
545
+ const rows = Math.max(1, Math.floor(h / this.cell.h));
546
+ const sizeChanged = cols !== this._cols || rows !== this._rows;
547
+ if (sizeChanged) {
548
+ this._rows = rows;
549
+ this._cols = cols;
550
+ if (this._sessionId !== null && this._blitConn && this.viewId) {
551
+ this._blitConn.setViewSize(this._sessionId, this.viewId, rows, cols);
552
+ }
553
+ }
554
+ this.contentDirty = true;
555
+ this.scheduleRender();
556
+ }
557
+ /** Re-send dimensions when connection becomes ready. */
558
+ resendSize() {
559
+ if (this._sessionId !== null &&
560
+ !this._readOnly &&
561
+ this._blitConn &&
562
+ this.viewId &&
563
+ this._rows > 0 &&
564
+ this._cols > 0) {
565
+ this._blitConn.setViewSize(this._sessionId, this.viewId, this._rows, this._cols);
566
+ }
567
+ }
568
+ // --- Render loop ---
569
+ setupRenderLoop() {
570
+ this.scheduleRender();
571
+ }
572
+ teardownRenderLoop() {
573
+ cancelAnimationFrame(this.raf);
574
+ this.renderScheduled = false;
575
+ }
576
+ doRender() {
577
+ const t0 = performance.now();
578
+ const conn = this._blitConn;
579
+ if (!conn)
580
+ return;
581
+ if (!this.renderer?.supported) {
582
+ const shared = conn.getSharedRenderer();
583
+ if (shared)
584
+ this.renderer = shared.renderer;
585
+ if (!this.renderer?.supported) {
586
+ if (!this._readOnly)
587
+ conn.noteFrameRendered();
588
+ return;
589
+ }
590
+ }
591
+ if (!this.terminal) {
592
+ if (!this._readOnly)
593
+ conn.noteFrameRendered();
594
+ return;
595
+ }
596
+ const t = this.terminal;
597
+ const cell = this.cell;
598
+ const renderer = this.renderer;
599
+ const termCols = t.cols;
600
+ const termRows = t.rows;
601
+ const pw = termCols * cell.pw;
602
+ const ph = termRows * cell.ph;
603
+ if (!this._readOnly) {
604
+ const cssW = `${termCols * cell.w}px`;
605
+ const cssH = `${termRows * cell.h}px`;
606
+ const glCanvas = this.glCanvas;
607
+ if (glCanvas) {
608
+ if (glCanvas.style.width !== cssW)
609
+ glCanvas.style.width = cssW;
610
+ if (glCanvas.style.height !== cssH)
611
+ glCanvas.style.height = cssH;
612
+ }
613
+ }
614
+ const mem = conn.wasmMemory();
615
+ if (!mem) {
616
+ if (!this._readOnly)
617
+ conn.noteFrameRendered();
618
+ return;
619
+ }
620
+ if (mem.buffer !== this.lastWasmBuffer) {
621
+ this.lastWasmBuffer = mem.buffer;
622
+ this.contentDirty = true;
623
+ }
624
+ {
625
+ const gridH = t.rows * cell.ph;
626
+ const gridW = t.cols * cell.pw;
627
+ const xOff = Math.max(0, Math.floor((pw - gridW) / 2));
628
+ const yOff = Math.max(0, Math.floor((ph - gridH) / 2));
629
+ const combined = xOff * 65536 + yOff;
630
+ if (combined !== this.lastOffset) {
631
+ this.lastOffset = combined;
632
+ t.set_render_offset(xOff, yOff);
633
+ this.contentDirty = true;
634
+ }
635
+ }
636
+ if (this.contentDirty) {
637
+ this.contentDirty = false;
638
+ t.prepare_render_ops();
639
+ }
640
+ const bgVerts = new Float32Array(mem.buffer, t.bg_verts_ptr(), t.bg_verts_len());
641
+ const glyphVerts = new Float32Array(mem.buffer, t.glyph_verts_ptr(), t.glyph_verts_len());
642
+ renderer.resize(pw, ph);
643
+ renderer.render(bgVerts, glyphVerts, t.glyph_atlas_canvas(), t.glyph_atlas_version(), t.cursor_visible(), t.cursor_col, t.cursor_row, t.cursor_style(), this.cursorBlinkOn, cell, this._palette?.bg ?? [0, 0, 0], this._showCursor);
644
+ // Copy GL to display canvas, then draw overlay content on top.
645
+ const shared = conn.getSharedRenderer();
646
+ const displayCanvas = this.glCanvas;
647
+ if (shared && displayCanvas) {
648
+ if (displayCanvas.width !== pw) {
649
+ displayCanvas.width = pw;
650
+ this.displayCtx = null;
651
+ }
652
+ if (displayCanvas.height !== ph) {
653
+ displayCanvas.height = ph;
654
+ this.displayCtx = null;
655
+ }
656
+ if (!this.displayCtx) {
657
+ this.displayCtx = displayCanvas.getContext("2d");
658
+ this.displayCtx?.resetTransform();
659
+ }
660
+ const ctx = this.displayCtx;
661
+ if (ctx) {
662
+ ctx.drawImage(shared.canvas, 0, 0, pw, ph, 0, 0, pw, ph);
663
+ this.drawSelectionOverlay(ctx, cell);
664
+ this.drawUrlOverlay(ctx, cell);
665
+ this.drawOverflowText(ctx, t, cell);
666
+ this.drawPredictedEcho(ctx, t, cell);
667
+ this.drawScrollbar(ctx, t, cell);
668
+ }
669
+ }
670
+ if (!this._readOnly)
671
+ conn.noteFrameRendered();
672
+ this._onRender?.(performance.now() - t0);
673
+ }
674
+ // --- Overlay drawing helpers ---
675
+ drawSelectionOverlay(ctx, cell) {
676
+ const ss = this.selStart;
677
+ const se = this.selEnd;
678
+ if (!ss || !se)
679
+ return;
680
+ const curScroll = this.scrollOffset;
681
+ const rows = this._rows;
682
+ const toViewRow = (p) => rows - 1 - p.tailOffset + curScroll;
683
+ let sr = toViewRow(ss), sc = ss.col;
684
+ let er = toViewRow(se), ec = se.col;
685
+ if (sr > er || (sr === er && sc > ec)) {
686
+ [sr, sc, er, ec] = [er, ec, sr, sc];
687
+ }
688
+ const r0 = Math.max(0, sr);
689
+ const r1 = Math.min(rows - 1, er);
690
+ ctx.fillStyle = "rgba(100,150,255,0.3)";
691
+ for (let r = r0; r <= r1; r++) {
692
+ const c0 = r === sr ? sc : 0;
693
+ const c1 = r === er ? ec : this._cols - 1;
694
+ ctx.fillRect(c0 * cell.pw, r * cell.ph, (c1 - c0 + 1) * cell.pw, cell.ph);
695
+ }
696
+ }
697
+ drawUrlOverlay(ctx, cell) {
698
+ const hurl = this.hoveredUrl;
699
+ if (!hurl)
700
+ return;
701
+ const [fgR, fgG, fgB] = this._palette?.fg ?? [204, 204, 204];
702
+ ctx.strokeStyle = `rgba(${fgR},${fgG},${fgB},0.6)`;
703
+ ctx.lineWidth = Math.max(1, Math.round(cell.ph * 0.06));
704
+ const y = hurl.row * cell.ph + cell.ph - ctx.lineWidth;
705
+ ctx.beginPath();
706
+ ctx.moveTo(hurl.startCol * cell.pw, y);
707
+ ctx.lineTo((hurl.endCol + 1) * cell.pw, y);
708
+ ctx.stroke();
709
+ }
710
+ drawOverflowText(ctx, t, cell) {
711
+ const overflowCount = t.overflow_text_count();
712
+ if (overflowCount <= 0)
713
+ return;
714
+ const cw = cell.pw;
715
+ const ch = cell.ph;
716
+ const scale = 0.85;
717
+ const scaledH = ch * scale;
718
+ const fSize = Math.max(1, Math.round(scaledH));
719
+ ctx.font = `${fSize}px ${cssFontFamily(this._fontFamily)}`;
720
+ ctx.textBaseline = "bottom";
721
+ const [fgR, fgG, fgB] = this._palette?.fg ?? [204, 204, 204];
722
+ ctx.fillStyle = `#${fgR.toString(16).padStart(2, "0")}${fgG.toString(16).padStart(2, "0")}${fgB.toString(16).padStart(2, "0")}`;
723
+ for (let i = 0; i < overflowCount; i++) {
724
+ const op = t.overflow_text_op(i);
725
+ if (!op)
726
+ continue;
727
+ const [row, col, colSpan, text] = op;
728
+ const x = col * cw;
729
+ const y = row * ch;
730
+ const w = colSpan * cw;
731
+ const padX = (w - w * scale) / 2;
732
+ const padY = (ch - scaledH) / 2;
733
+ ctx.save();
734
+ ctx.beginPath();
735
+ ctx.rect(x, y, w, ch);
736
+ ctx.clip();
737
+ ctx.fillText(text, x + padX, y + padY + scaledH);
738
+ ctx.restore();
739
+ }
740
+ }
741
+ drawPredictedEcho(ctx, t, cell) {
742
+ if (this._readOnly || !this.predicted)
743
+ return;
744
+ if (!t.echo())
745
+ return;
746
+ const cw = cell.pw;
747
+ const ch = cell.ph;
748
+ const [fR, fG, fB] = this._palette?.fg ?? [204, 204, 204];
749
+ ctx.fillStyle = `rgba(${fR},${fG},${fB},0.5)`;
750
+ const fSize = Math.max(1, Math.round(ch * 0.85));
751
+ ctx.font = `${fSize}px ${cssFontFamily(this._fontFamily)}`;
752
+ ctx.textBaseline = "bottom";
753
+ const cc = t.cursor_col;
754
+ const cr = t.cursor_row;
755
+ for (let i = 0; i < this.predicted.length && cc + i < this._cols; i++) {
756
+ ctx.fillText(this.predicted[i], (cc + i) * cw, cr * ch + ch);
757
+ }
758
+ }
759
+ drawScrollbar(ctx, t, cell) {
760
+ const totalLines = t.scrollback_lines() + this._rows;
761
+ const viewportRows = this._rows;
762
+ if (totalLines <= viewportRows) {
763
+ this.scrollbarGeo = null;
764
+ return;
765
+ }
766
+ const ch = cell.ph;
767
+ const canvasH = viewportRows * ch;
768
+ const barW = this._scrollbarWidth;
769
+ const barH = Math.max(barW, (viewportRows / totalLines) * canvasH);
770
+ const maxScroll = totalLines - viewportRows;
771
+ const scrollFraction = Math.min(this.scrollOffset, maxScroll) / maxScroll;
772
+ const barY = (1 - scrollFraction) * (canvasH - barH);
773
+ const barX = this._cols * cell.pw - barW - 2;
774
+ this.scrollbarGeo = {
775
+ barX,
776
+ barY,
777
+ barW,
778
+ barH,
779
+ canvasH,
780
+ totalLines,
781
+ viewportRows,
782
+ };
783
+ const show = this.scrollFade > 0 || this.scrollDragging || this.scrollOffset > 0;
784
+ if (show) {
785
+ if (this._scrollbarColor) {
786
+ ctx.fillStyle = this._scrollbarColor;
787
+ }
788
+ else {
789
+ const [r, g, b] = this._palette?.fg ?? [204, 204, 204];
790
+ ctx.fillStyle = `rgba(${r},${g},${b},0.35)`;
791
+ }
792
+ ctx.beginPath();
793
+ ctx.roundRect(barX, barY, barW, barH, barW / 2);
794
+ ctx.fill();
795
+ }
796
+ }
797
+ // --- Prediction ---
798
+ reconcilePrediction() {
799
+ const t = this.terminal;
800
+ if (!t || !this.predicted)
801
+ return;
802
+ const cr = t.cursor_row;
803
+ const cc = t.cursor_col;
804
+ if (cr !== this.predictedFromRow) {
805
+ this.predicted = "";
806
+ return;
807
+ }
808
+ const advance = cc - this.predictedFromCol;
809
+ if (advance > 0 && advance <= this.predicted.length) {
810
+ this.predicted = this.predicted.slice(advance);
811
+ this.predictedFromCol = cc;
812
+ }
813
+ else if (advance < 0 || advance > this.predicted.length) {
814
+ this.predicted = "";
815
+ }
816
+ }
817
+ // --- Keyboard ---
818
+ setupKeyboard() {
819
+ const input = this.inputEl;
820
+ if (!input || this._readOnly)
821
+ return;
822
+ this.boundKeyDown = (e) => {
823
+ if (e.defaultPrevented)
824
+ return;
825
+ if (this._sessionId === null || this.status !== "connected")
826
+ return;
827
+ if (e.isComposing)
828
+ return;
829
+ if (e.key === "Dead")
830
+ return;
831
+ if (e.shiftKey && (e.key === "PageUp" || e.key === "PageDown")) {
832
+ const t2 = this.terminal;
833
+ const maxScroll = t2 ? t2.scrollback_lines() : 0;
834
+ if (maxScroll > 0 || this.scrollOffset > 0) {
835
+ e.preventDefault();
836
+ const delta = e.key === "PageUp" ? this._rows : -this._rows;
837
+ this.scrollOffset = Math.max(0, Math.min(maxScroll, this.scrollOffset + delta));
838
+ this.sendScroll(this._sessionId, this.scrollOffset);
839
+ this.flashScrollbar();
840
+ this.scheduleRender();
841
+ }
842
+ return;
843
+ }
844
+ if (e.shiftKey && (e.key === "Home" || e.key === "End")) {
845
+ const t2 = this.terminal;
846
+ const maxScroll = t2 ? t2.scrollback_lines() : 0;
847
+ if (maxScroll > 0 || this.scrollOffset > 0) {
848
+ e.preventDefault();
849
+ this.scrollOffset = e.key === "Home" ? maxScroll : 0;
850
+ this.sendScroll(this._sessionId, this.scrollOffset);
851
+ this.flashScrollbar();
852
+ this.scheduleRender();
853
+ }
854
+ return;
855
+ }
856
+ const t = this.terminal;
857
+ const appCursor = t ? t.app_cursor() : false;
858
+ const bytes = keyToBytes(e, appCursor);
859
+ if (bytes) {
860
+ e.preventDefault();
861
+ if (this.scrollOffset > 0) {
862
+ this.scrollOffset = 0;
863
+ this.sendScroll(this._sessionId, 0);
864
+ }
865
+ if (t &&
866
+ t.echo() &&
867
+ e.key.length === 1 &&
868
+ !e.ctrlKey &&
869
+ !e.metaKey &&
870
+ !e.altKey) {
871
+ if (!this.predicted) {
872
+ this.predictedFromRow = t.cursor_row;
873
+ this.predictedFromCol = t.cursor_col;
874
+ }
875
+ this.predicted += e.key;
876
+ this.scheduleRender();
877
+ }
878
+ else {
879
+ this.predicted = "";
880
+ }
881
+ this.sendInput(this._sessionId, bytes);
882
+ }
883
+ };
884
+ this.boundCompositionEnd = (e) => {
885
+ if (e.data && this._sessionId !== null && this.status === "connected") {
886
+ this.sendInput(this._sessionId, encoder.encode(e.data));
887
+ }
888
+ input.value = "";
889
+ };
890
+ this.boundInput = (e) => {
891
+ const inputEvent = e;
892
+ if (inputEvent.isComposing) {
893
+ if (inputEvent.inputType === "deleteContentBackward" &&
894
+ !input.value &&
895
+ this._sessionId !== null &&
896
+ this.status === "connected") {
897
+ this.sendInput(this._sessionId, new Uint8Array([0x7f]));
898
+ }
899
+ return;
900
+ }
901
+ if (inputEvent.inputType === "deleteContentBackward" && !input.value) {
902
+ if (this._sessionId !== null && this.status === "connected") {
903
+ this.sendInput(this._sessionId, new Uint8Array([0x7f]));
904
+ }
905
+ }
906
+ else if (input.value &&
907
+ this._sessionId !== null &&
908
+ this.status === "connected") {
909
+ this.sendInput(this._sessionId, encoder.encode(input.value.replace(/\n/g, "\r")));
910
+ }
911
+ input.value = "";
912
+ };
913
+ input.addEventListener("keydown", this.boundKeyDown);
914
+ input.addEventListener("compositionend", this.boundCompositionEnd);
915
+ input.addEventListener("input", this.boundInput);
916
+ }
917
+ teardownKeyboard() {
918
+ const input = this.inputEl;
919
+ if (!input)
920
+ return;
921
+ if (this.boundKeyDown)
922
+ input.removeEventListener("keydown", this.boundKeyDown);
923
+ if (this.boundCompositionEnd)
924
+ input.removeEventListener("compositionend", this.boundCompositionEnd);
925
+ if (this.boundInput)
926
+ input.removeEventListener("input", this.boundInput);
927
+ this.boundKeyDown = null;
928
+ this.boundCompositionEnd = null;
929
+ this.boundInput = null;
930
+ }
931
+ // --- Container wheel ---
932
+ setupContainerWheel() {
933
+ if (!this.container || this._readOnly)
934
+ return;
935
+ this.boundContainerWheel = (e) => {
936
+ const t = this.terminal;
937
+ if (t && t.mouse_mode() > 0 && !e.shiftKey)
938
+ return;
939
+ if (this._sessionId !== null && this.status === "connected") {
940
+ const maxScroll = t ? t.scrollback_lines() : 0;
941
+ if (maxScroll === 0 && this.scrollOffset === 0)
942
+ return;
943
+ e.preventDefault();
944
+ const delta = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
945
+ const lines = Math.round(-delta / 20) || (delta > 0 ? -3 : 3);
946
+ this.scrollOffset = Math.max(0, Math.min(maxScroll, this.scrollOffset + lines));
947
+ this.sendScroll(this._sessionId, this.scrollOffset);
948
+ if (this.scrollOffset > 0)
949
+ this.flashScrollbar();
950
+ this.scheduleRender();
951
+ }
952
+ };
953
+ this.container.addEventListener("wheel", this.boundContainerWheel, {
954
+ passive: false,
955
+ });
956
+ }
957
+ teardownContainerWheel() {
958
+ if (this.boundContainerWheel && this.container) {
959
+ this.container.removeEventListener("wheel", this.boundContainerWheel);
960
+ }
961
+ this.boundContainerWheel = null;
962
+ }
963
+ // --- Mouse input ---
964
+ setupMouse() {
965
+ const canvas = this.glCanvas;
966
+ if (!canvas || this._readOnly)
967
+ return;
968
+ const SCROLLBAR_HIT_PX = 20;
969
+ const WORD_CHARS = /[A-Za-z0-9_\-./~:@]/;
970
+ const URL_RE = /https?:\/\/[^\s<>"'`)\]},;]+/g;
971
+ const AUTO_SCROLL_INTERVAL_MS = 50;
972
+ const AUTO_SCROLL_LINES = 3;
973
+ let mouseDownButton = -1;
974
+ let lastMouseCell = { row: -1, col: -1 };
975
+ let selecting = false;
976
+ let selGranularity = 1;
977
+ let selAnchorStart = null;
978
+ let selAnchorEnd = null;
979
+ let autoScrollTimer = null;
980
+ let autoScrollDir = 0;
981
+ let lastHoverUrl = null;
982
+ const mouseToCell = (e) => {
983
+ const rect = canvas.getBoundingClientRect();
984
+ return {
985
+ row: Math.min(Math.max(Math.floor((e.clientY - rect.top) / this.cell.h), 0), this._rows - 1),
986
+ col: Math.min(Math.max(Math.floor((e.clientX - rect.left) / this.cell.w), 0), this._cols - 1),
987
+ };
988
+ };
989
+ const canvasYFromEvent = (e) => {
990
+ const rect = canvas.getBoundingClientRect();
991
+ const dpr = this.cell.pw / this.cell.w;
992
+ return (e.clientY - rect.top) * dpr;
993
+ };
994
+ const isNearScrollbar = (e) => {
995
+ const rect = canvas.getBoundingClientRect();
996
+ return e.clientX >= rect.right - SCROLLBAR_HIT_PX;
997
+ };
998
+ const scrollToCanvasY = (y) => {
999
+ const geo = this.scrollbarGeo;
1000
+ if (!geo || this._sessionId === null || this.status !== "connected")
1001
+ return;
1002
+ const fraction = 1 - y / (geo.canvasH - geo.barH);
1003
+ const maxScroll = geo.totalLines - geo.viewportRows;
1004
+ const offset = Math.round(Math.max(0, Math.min(maxScroll, fraction * maxScroll)));
1005
+ this.scrollOffset = offset;
1006
+ this.sendScroll(this._sessionId, offset);
1007
+ this.scrollFade = 1;
1008
+ this.scheduleRender();
1009
+ };
1010
+ const sendMouseEvent = (type, e, button) => {
1011
+ if (this._sessionId === null || this.status !== "connected")
1012
+ return false;
1013
+ const t = this.terminal;
1014
+ if (t && t.mouse_mode() === 0)
1015
+ return false;
1016
+ const pos = mouseToCell(e);
1017
+ const typeCode = type === "down" ? MOUSE_DOWN : type === "up" ? MOUSE_UP : MOUSE_MOVE;
1018
+ this._workspace?.sendMouse(this._sessionId, typeCode, button, pos.col, pos.row);
1019
+ return true;
1020
+ };
1021
+ const cellToSel = (cell) => ({
1022
+ row: cell.row,
1023
+ col: cell.col,
1024
+ tailOffset: this.scrollOffset + (this._rows - 1 - cell.row),
1025
+ });
1026
+ const stopAutoScroll = () => {
1027
+ if (autoScrollTimer !== null) {
1028
+ clearInterval(autoScrollTimer);
1029
+ autoScrollTimer = null;
1030
+ }
1031
+ autoScrollDir = 0;
1032
+ };
1033
+ const getRowText = (row) => {
1034
+ const t = this.terminal;
1035
+ return t ? t.get_text(row, 0, row, this._cols - 1) : "";
1036
+ };
1037
+ const getRowColMap = (row) => {
1038
+ const t = this.terminal;
1039
+ return t ? t.row_col_map(row) : null;
1040
+ };
1041
+ const colToTextIdx = (colMap, col) => {
1042
+ for (let i = 0; i < colMap.length; i++) {
1043
+ if (colMap[i] === col)
1044
+ return i;
1045
+ }
1046
+ return -1;
1047
+ };
1048
+ const wordBoundsAt = (row, col) => {
1049
+ const text = getRowText(row);
1050
+ const colMap = getRowColMap(row);
1051
+ const idx = colMap ? colToTextIdx(colMap, col) : col;
1052
+ if (idx < 0 || idx >= text.length || !WORD_CHARS.test(text[idx]))
1053
+ return { start: col, end: col };
1054
+ let start = idx;
1055
+ while (start > 0 && WORD_CHARS.test(text[start - 1]))
1056
+ start--;
1057
+ let end = idx;
1058
+ while (end < text.length - 1 && WORD_CHARS.test(text[end + 1]))
1059
+ end++;
1060
+ const startCol = colMap ? (colMap[start] ?? start) : start;
1061
+ const endCol = colMap ? (colMap[end] ?? end) : end;
1062
+ return { start: startCol, end: endCol };
1063
+ };
1064
+ const isWrapped = (row) => {
1065
+ const t = this.terminal;
1066
+ return t ? t.is_wrapped(row) : false;
1067
+ };
1068
+ const logicalLineRange = (row) => {
1069
+ const maxRow = this._rows - 1;
1070
+ let startRow = row;
1071
+ while (startRow > 0 && isWrapped(startRow - 1))
1072
+ startRow--;
1073
+ let endRow = row;
1074
+ while (endRow < maxRow && isWrapped(endRow))
1075
+ endRow++;
1076
+ return { startRow, endRow };
1077
+ };
1078
+ const applyGranularity = (cell) => {
1079
+ if (selGranularity === 3) {
1080
+ const { startRow, endRow } = logicalLineRange(cell.row);
1081
+ return {
1082
+ start: { row: startRow, col: 0 },
1083
+ end: { row: endRow, col: this._cols - 1 },
1084
+ };
1085
+ }
1086
+ if (selGranularity === 2) {
1087
+ const wb = wordBoundsAt(cell.row, cell.col);
1088
+ return {
1089
+ start: { row: cell.row, col: wb.start },
1090
+ end: { row: cell.row, col: wb.end },
1091
+ };
1092
+ }
1093
+ return { start: cell, end: cell };
1094
+ };
1095
+ const applyGranularitySel = (pos) => {
1096
+ const curScroll = this.scrollOffset;
1097
+ const viewRow = this._rows - 1 - pos.tailOffset + curScroll;
1098
+ const cell = { row: viewRow, col: pos.col };
1099
+ const { start, end } = applyGranularity(cell);
1100
+ return {
1101
+ start: {
1102
+ ...start,
1103
+ tailOffset: curScroll + (this._rows - 1 - start.row),
1104
+ },
1105
+ end: {
1106
+ ...end,
1107
+ tailOffset: curScroll + (this._rows - 1 - end.row),
1108
+ },
1109
+ };
1110
+ };
1111
+ const selPosBefore = (a, b) => a.tailOffset > b.tailOffset ||
1112
+ (a.tailOffset === b.tailOffset && a.col < b.col);
1113
+ const startAutoScroll = (dir) => {
1114
+ if (autoScrollDir === dir && autoScrollTimer !== null)
1115
+ return;
1116
+ stopAutoScroll();
1117
+ autoScrollDir = dir;
1118
+ autoScrollTimer = setInterval(() => {
1119
+ if (!selecting ||
1120
+ this._sessionId === null ||
1121
+ this.status !== "connected") {
1122
+ stopAutoScroll();
1123
+ return;
1124
+ }
1125
+ const t = this.terminal;
1126
+ if (!t)
1127
+ return;
1128
+ const maxScroll = t.scrollback_lines();
1129
+ const prev = this.scrollOffset;
1130
+ const next = Math.max(0, Math.min(maxScroll, prev + dir * AUTO_SCROLL_LINES));
1131
+ if (next === prev)
1132
+ return;
1133
+ this.scrollOffset = next;
1134
+ this.sendScroll(this._sessionId, next);
1135
+ this.flashScrollbar();
1136
+ const edgeRow = dir === 1 ? 0 : this._rows - 1;
1137
+ const edgeCol = dir === 1 ? 0 : this._cols - 1;
1138
+ const edgeSel = cellToSel({ row: edgeRow, col: edgeCol });
1139
+ if (selGranularity >= 2 && selAnchorStart && selAnchorEnd) {
1140
+ const { start: dragStart, end: dragEnd } = applyGranularitySel(edgeSel);
1141
+ if (selPosBefore(dragStart, selAnchorStart)) {
1142
+ this.selStart = dragStart;
1143
+ this.selEnd = selAnchorEnd;
1144
+ }
1145
+ else {
1146
+ this.selStart = selAnchorStart;
1147
+ this.selEnd = dragEnd;
1148
+ }
1149
+ }
1150
+ else {
1151
+ this.selEnd = edgeSel;
1152
+ }
1153
+ this.scheduleRender();
1154
+ }, AUTO_SCROLL_INTERVAL_MS);
1155
+ };
1156
+ const clearSelection = () => {
1157
+ this.selStart = this.selEnd = null;
1158
+ this.scheduleRender();
1159
+ };
1160
+ const copySelection = () => {
1161
+ if (!this.selStart || !this.selEnd)
1162
+ return;
1163
+ const t = this.terminal;
1164
+ if (!t)
1165
+ return;
1166
+ let start = this.selStart;
1167
+ let end = this.selEnd;
1168
+ if (selPosBefore(end, start))
1169
+ [start, end] = [end, start];
1170
+ const curScroll = this.scrollOffset;
1171
+ const rows = this._rows;
1172
+ const startViewRow = rows - 1 - start.tailOffset + curScroll;
1173
+ const endViewRow = rows - 1 - end.tailOffset + curScroll;
1174
+ const inViewport = startViewRow >= 0 &&
1175
+ startViewRow < rows &&
1176
+ endViewRow >= 0 &&
1177
+ endViewRow < rows;
1178
+ if (inViewport) {
1179
+ const text = t.get_text(startViewRow, start.col, endViewRow, end.col);
1180
+ if (text)
1181
+ navigator.clipboard.writeText(text);
1182
+ }
1183
+ else if (this._blitConn &&
1184
+ this._sessionId !== null &&
1185
+ this._blitConn.supportsCopyRange()) {
1186
+ this._blitConn
1187
+ .copyRange(this._sessionId, start.tailOffset, start.col, end.tailOffset, end.col)
1188
+ .then((text) => {
1189
+ if (text)
1190
+ navigator.clipboard.writeText(text);
1191
+ })
1192
+ .catch(() => { });
1193
+ }
1194
+ };
1195
+ const urlAt = (row, col) => {
1196
+ const text = getRowText(row);
1197
+ const colMap = getRowColMap(row);
1198
+ URL_RE.lastIndex = 0;
1199
+ let m;
1200
+ while ((m = URL_RE.exec(text)) !== null) {
1201
+ const raw = m[0].replace(/[.),:;]+$/, "");
1202
+ const startCol = colMap ? (colMap[m.index] ?? m.index) : m.index;
1203
+ const endIdx = m.index + raw.length - 1;
1204
+ const endCol = colMap ? (colMap[endIdx] ?? endIdx) : endIdx;
1205
+ if (col >= startCol && col <= endCol)
1206
+ return { url: raw, startCol, endCol };
1207
+ }
1208
+ return null;
1209
+ };
1210
+ const handleMouseDown = (e) => {
1211
+ if (e.button === 0 && this.scrollbarGeo && isNearScrollbar(e)) {
1212
+ e.preventDefault();
1213
+ const geo = this.scrollbarGeo;
1214
+ const y = canvasYFromEvent(e);
1215
+ this.scrollDragging = true;
1216
+ canvas.style.cursor = "grabbing";
1217
+ if (y >= geo.barY && y <= geo.barY + geo.barH) {
1218
+ this.scrollDragOffset = y - geo.barY;
1219
+ }
1220
+ else {
1221
+ this.scrollDragOffset = geo.barH / 2;
1222
+ scrollToCanvasY(y - geo.barH / 2);
1223
+ }
1224
+ return;
1225
+ }
1226
+ if (!e.shiftKey && sendMouseEvent("down", e, e.button)) {
1227
+ mouseDownButton = e.button;
1228
+ e.preventDefault();
1229
+ return;
1230
+ }
1231
+ if (e.button === 0) {
1232
+ e.preventDefault();
1233
+ clearSelection();
1234
+ selecting = true;
1235
+ this.selecting = true;
1236
+ const cell = mouseToCell(e);
1237
+ const sel = cellToSel(cell);
1238
+ const detail = Math.min(e.detail, 3);
1239
+ selGranularity = detail;
1240
+ if (detail >= 2) {
1241
+ const { start, end } = applyGranularitySel(sel);
1242
+ this.selStart = start;
1243
+ this.selEnd = end;
1244
+ selAnchorStart = start;
1245
+ selAnchorEnd = end;
1246
+ this.scheduleRender();
1247
+ }
1248
+ else {
1249
+ this.selStart = sel;
1250
+ this.selEnd = sel;
1251
+ selAnchorStart = null;
1252
+ selAnchorEnd = null;
1253
+ }
1254
+ }
1255
+ };
1256
+ const handleMouseMove = (e) => {
1257
+ if (this.scrollDragging) {
1258
+ scrollToCanvasY(canvasYFromEvent(e) - this.scrollDragOffset);
1259
+ return;
1260
+ }
1261
+ const overCanvas = mouseDownButton >= 0 || canvas.contains(e.target);
1262
+ if (!e.shiftKey && overCanvas) {
1263
+ const t = this.terminal;
1264
+ if (t) {
1265
+ const mode = t.mouse_mode();
1266
+ if (mode >= 3) {
1267
+ const cell = mouseToCell(e);
1268
+ if (cell.row === lastMouseCell.row &&
1269
+ cell.col === lastMouseCell.col)
1270
+ return;
1271
+ lastMouseCell = cell;
1272
+ if (e.buttons) {
1273
+ const button = e.buttons & 1 ? 0 : e.buttons & 2 ? 2 : e.buttons & 4 ? 1 : 0;
1274
+ sendMouseEvent("move", e, button + 32);
1275
+ return;
1276
+ }
1277
+ else if (mode === 4) {
1278
+ sendMouseEvent("move", e, 35);
1279
+ return;
1280
+ }
1281
+ }
1282
+ }
1283
+ }
1284
+ if (selecting) {
1285
+ const rect = canvas.getBoundingClientRect();
1286
+ if (e.clientY < rect.top) {
1287
+ startAutoScroll(1);
1288
+ return;
1289
+ }
1290
+ else if (e.clientY > rect.bottom) {
1291
+ startAutoScroll(-1);
1292
+ return;
1293
+ }
1294
+ else {
1295
+ stopAutoScroll();
1296
+ }
1297
+ const cell = mouseToCell(e);
1298
+ const sel = cellToSel(cell);
1299
+ if (selGranularity >= 2 && selAnchorStart && selAnchorEnd) {
1300
+ const { start: dragStart, end: dragEnd } = applyGranularitySel(sel);
1301
+ if (selPosBefore(dragStart, selAnchorStart)) {
1302
+ this.selStart = dragStart;
1303
+ this.selEnd = selAnchorEnd;
1304
+ }
1305
+ else {
1306
+ this.selStart = selAnchorStart;
1307
+ this.selEnd = dragEnd;
1308
+ }
1309
+ }
1310
+ else {
1311
+ this.selEnd = sel;
1312
+ }
1313
+ this.scheduleRender();
1314
+ }
1315
+ };
1316
+ const handleMouseUp = (e) => {
1317
+ if (this.scrollDragging) {
1318
+ this.scrollDragging = false;
1319
+ canvas.style.cursor = "text";
1320
+ this.scheduleRender();
1321
+ return;
1322
+ }
1323
+ if (mouseDownButton >= 0) {
1324
+ sendMouseEvent("up", e, mouseDownButton);
1325
+ mouseDownButton = -1;
1326
+ return;
1327
+ }
1328
+ if (selecting) {
1329
+ stopAutoScroll();
1330
+ selecting = false;
1331
+ this.selecting = false;
1332
+ if (selGranularity === 1)
1333
+ this.selEnd = cellToSel(mouseToCell(e));
1334
+ this.scheduleRender();
1335
+ if (this.selStart &&
1336
+ this.selEnd &&
1337
+ (this.selStart.tailOffset !== this.selEnd.tailOffset ||
1338
+ this.selStart.col !== this.selEnd.col)) {
1339
+ copySelection();
1340
+ }
1341
+ clearSelection();
1342
+ }
1343
+ if (canvas.contains(e.target)) {
1344
+ this.inputEl?.focus();
1345
+ }
1346
+ };
1347
+ const handleCanvasWheel = (e) => {
1348
+ const t = this.terminal;
1349
+ if (t && t.mouse_mode() > 0 && !e.shiftKey) {
1350
+ e.preventDefault();
1351
+ const button = e.deltaY < 0 ? 64 : 65;
1352
+ sendMouseEvent("down", e, button);
1353
+ }
1354
+ };
1355
+ const handleContextMenu = (e) => {
1356
+ const t = this.terminal;
1357
+ if (t && t.mouse_mode() > 0)
1358
+ e.preventDefault();
1359
+ };
1360
+ const handleClick = (e) => {
1361
+ if (e.altKey && e.button === 0) {
1362
+ const cell = mouseToCell(e);
1363
+ const hit = urlAt(cell.row, cell.col);
1364
+ if (hit) {
1365
+ e.preventDefault();
1366
+ window.open(hit.url, "_blank", "noopener");
1367
+ return;
1368
+ }
1369
+ }
1370
+ this.inputEl?.focus();
1371
+ };
1372
+ const handleHoverMove = (e) => {
1373
+ if (this.scrollDragging) {
1374
+ canvas.style.cursor = "grabbing";
1375
+ return;
1376
+ }
1377
+ if (this.scrollbarGeo && isNearScrollbar(e)) {
1378
+ canvas.style.cursor = "default";
1379
+ return;
1380
+ }
1381
+ if (selecting) {
1382
+ if (this.hoveredUrl) {
1383
+ this.hoveredUrl = null;
1384
+ this.scheduleRender();
1385
+ canvas.style.cursor = "text";
1386
+ lastHoverUrl = null;
1387
+ }
1388
+ return;
1389
+ }
1390
+ const cell = mouseToCell(e);
1391
+ const hit = urlAt(cell.row, cell.col);
1392
+ const url = hit?.url ?? null;
1393
+ if (url !== lastHoverUrl) {
1394
+ lastHoverUrl = url;
1395
+ canvas.style.cursor = hit ? "pointer" : "text";
1396
+ this.hoveredUrl = hit
1397
+ ? {
1398
+ row: cell.row,
1399
+ startCol: hit.startCol,
1400
+ endCol: hit.endCol,
1401
+ url: hit.url,
1402
+ }
1403
+ : null;
1404
+ this.scheduleRender();
1405
+ }
1406
+ };
1407
+ const handleBlur = () => {
1408
+ if (mouseDownButton >= 0) {
1409
+ if (this._sessionId !== null && this.status === "connected") {
1410
+ this._workspace?.sendMouse(this._sessionId, MOUSE_UP, mouseDownButton, 0, 0);
1411
+ }
1412
+ mouseDownButton = -1;
1413
+ }
1414
+ if (selecting) {
1415
+ stopAutoScroll();
1416
+ selecting = false;
1417
+ this.selecting = false;
1418
+ clearSelection();
1419
+ }
1420
+ };
1421
+ canvas.addEventListener("mousedown", handleMouseDown);
1422
+ window.addEventListener("mousemove", handleMouseMove);
1423
+ canvas.addEventListener("mousemove", handleHoverMove);
1424
+ window.addEventListener("mouseup", handleMouseUp);
1425
+ window.addEventListener("blur", handleBlur);
1426
+ canvas.addEventListener("wheel", handleCanvasWheel, { passive: false });
1427
+ canvas.addEventListener("contextmenu", handleContextMenu);
1428
+ canvas.addEventListener("click", handleClick);
1429
+ this.mouseCleanup = () => {
1430
+ canvas.removeEventListener("mousedown", handleMouseDown);
1431
+ window.removeEventListener("mousemove", handleMouseMove);
1432
+ canvas.removeEventListener("mousemove", handleHoverMove);
1433
+ window.removeEventListener("mouseup", handleMouseUp);
1434
+ window.removeEventListener("blur", handleBlur);
1435
+ canvas.removeEventListener("wheel", handleCanvasWheel);
1436
+ canvas.removeEventListener("contextmenu", handleContextMenu);
1437
+ canvas.removeEventListener("click", handleClick);
1438
+ if (this.scrollFadeTimer)
1439
+ clearTimeout(this.scrollFadeTimer);
1440
+ stopAutoScroll();
1441
+ };
1442
+ }
1443
+ teardownMouse() {
1444
+ this.mouseCleanup?.();
1445
+ this.mouseCleanup = null;
1446
+ }
1447
+ // --- Helpers ---
1448
+ flashScrollbar() {
1449
+ this.scrollFade = 1;
1450
+ if (this.scrollFadeTimer)
1451
+ clearTimeout(this.scrollFadeTimer);
1452
+ this.scrollFadeTimer = setTimeout(() => {
1453
+ this.scrollFade = 0;
1454
+ this.scheduleRender();
1455
+ }, 1000);
1456
+ }
1457
+ sendInput(sessionId, data) {
1458
+ this._workspace?.sendInput(sessionId, data);
1459
+ }
1460
+ sendScroll(sessionId, offset) {
1461
+ this._workspace?.scrollSession(sessionId, offset);
1462
+ }
1463
+ }
1464
+ //# sourceMappingURL=BlitTerminalSurface.js.map