@swifttui/web 0.0.6

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 (39) hide show
  1. package/AGENTS.md +52 -0
  2. package/README.md +116 -0
  3. package/cli.ts +168 -0
  4. package/index.html +50 -0
  5. package/index.ts +8 -0
  6. package/manifest.ts +1 -0
  7. package/package.json +33 -0
  8. package/src/AccessibilityTree.ts +262 -0
  9. package/src/BoxDrawingRenderer.ts +585 -0
  10. package/src/PublicEntrypointBoundary.test.ts +20 -0
  11. package/src/WebHostApp.test.ts +222 -0
  12. package/src/WebHostApp.ts +269 -0
  13. package/src/WebHostSceneManifest.test.ts +38 -0
  14. package/src/WebHostSceneManifest.ts +156 -0
  15. package/src/WebHostSceneRuntime.test.ts +1752 -0
  16. package/src/WebHostSceneRuntime.ts +955 -0
  17. package/src/WebHostSurfaceTransport.test.ts +362 -0
  18. package/src/WebHostSurfaceTransport.ts +648 -0
  19. package/src/WebHostTerminalStyle.test.ts +123 -0
  20. package/src/WebHostTerminalStyle.ts +471 -0
  21. package/src/WebHostTestFixtures.ts +10 -0
  22. package/src/WebSocketSceneBridge.test.ts +198 -0
  23. package/src/WebSocketSceneBridge.ts +233 -0
  24. package/src/browser.ts +59 -0
  25. package/src/wasi/BrowserWASIBridge.test.ts +168 -0
  26. package/src/wasi/BrowserWASIBridge.ts +167 -0
  27. package/src/wasi/SharedInputQueue.test.ts +146 -0
  28. package/src/wasi/SharedInputQueue.ts +199 -0
  29. package/src/wasi/StdIOPipe.ts +72 -0
  30. package/src/wasi/WasiPollScheduler.test.ts +176 -0
  31. package/src/wasi/WasiPollScheduler.ts +305 -0
  32. package/src/wasi/WasmSceneRuntime.ts +205 -0
  33. package/src/wasi/WasmSceneWorker.ts +182 -0
  34. package/style.css +15 -0
  35. package/testing.ts +1 -0
  36. package/tsconfig.json +29 -0
  37. package/wasi-worker.ts +1 -0
  38. package/wasi.ts +4 -0
  39. package/websocket.ts +1 -0
@@ -0,0 +1,955 @@
1
+ import {
2
+ applyWebHostTerminalStyle,
3
+ normalizeWebHostTerminalStyle,
4
+ type ResolvedWebHostTerminalStyle,
5
+ type WebHostTerminalStyle,
6
+ webTUITerminalBackgroundColor,
7
+ } from "./WebHostTerminalStyle.ts";
8
+ import {
9
+ canRenderBoxDrawing,
10
+ drawBoxDrawing,
11
+ } from "./BoxDrawingRenderer.ts";
12
+ import { AccessibilityTreeMounter } from "./AccessibilityTree.ts";
13
+ import {
14
+ encodeKeyInputMessage,
15
+ encodeMouseInputMessage,
16
+ encodePasteInputMessage,
17
+ type WebHostFrameDiagnosticRecord,
18
+ type WebHostOutputSink,
19
+ type WebHostKeyInput,
20
+ type WebHostRuntimeIssue,
21
+ type WebHostSurfaceDamage,
22
+ type WebHostSurfaceFrame,
23
+ type WebHostSurfaceImage,
24
+ type WebHostSurfaceImageFormat,
25
+ type WebHostSurfaceStyle,
26
+ } from "./WebHostSurfaceTransport.ts";
27
+ import type { WebHostSceneDescriptor } from "./WebHostSceneManifest.ts";
28
+
29
+ export interface WebHostSceneBridge {
30
+ bindOutput(sink: WebHostOutputSink): void;
31
+ resize(columns: number, rows: number, cellWidth?: number, cellHeight?: number): void;
32
+ updateRenderStyle(style: WebHostTerminalStyle): void;
33
+ sendInput(chunk: Uint8Array): void;
34
+ dispose(): void;
35
+ }
36
+
37
+ export interface WebHostSceneRuntimeOptions {
38
+ mount: HTMLElement;
39
+ descriptor: WebHostSceneDescriptor;
40
+ style: WebHostTerminalStyle;
41
+ bridge?: WebHostSceneBridge;
42
+ onInput(chunk: Uint8Array): void;
43
+ onFrameDiagnostic?: (diagnostic: WebHostFrameDiagnosticRecord) => void;
44
+ synchronizeAccessibilityFocus?: boolean;
45
+ captureWheelInput?: boolean;
46
+ }
47
+
48
+ interface CachedWebHostImage {
49
+ image?: CanvasImageSource;
50
+ promise?: Promise<CanvasImageSource>;
51
+ }
52
+
53
+ interface DirtyRect {
54
+ x: number;
55
+ y: number;
56
+ width: number;
57
+ height: number;
58
+ }
59
+
60
+ export class WebHostSceneRuntime {
61
+ readonly descriptor: WebHostSceneDescriptor;
62
+ readonly element: HTMLElement;
63
+ readonly terminalMount: HTMLElement;
64
+
65
+ private readonly bridge?: WebHostSceneBridge;
66
+ private readonly onInput: (chunk: Uint8Array) => void;
67
+ private readonly onFrameDiagnostic?: (diagnostic: WebHostFrameDiagnosticRecord) => void;
68
+ private readonly synchronizeAccessibilityFocus: boolean;
69
+ private readonly captureWheelInput: boolean;
70
+ private readonly imageCache = new Map<string, CachedWebHostImage>();
71
+ private currentStyle: ResolvedWebHostTerminalStyle;
72
+ private canvas?: HTMLCanvasElement;
73
+ private accessibilityTree?: AccessibilityTreeMounter;
74
+ private diagnosticText?: HTMLElement;
75
+ private resizeObserver?: ResizeObserver;
76
+ private detachInputHandlers?: () => void;
77
+ private currentFrame?: WebHostSurfaceFrame;
78
+ private columns = 80;
79
+ private rows = 24;
80
+ private cellWidth = 8;
81
+ private cellHeight = 18;
82
+ private activePointerButton: "primary" | "middle" | "secondary" = "primary";
83
+ private hasCapturedPointer = false;
84
+ private lastSentResize?: {
85
+ columns: number;
86
+ rows: number;
87
+ cellWidth: number;
88
+ cellHeight: number;
89
+ };
90
+ private isVisible = false;
91
+
92
+ constructor(options: WebHostSceneRuntimeOptions) {
93
+ this.descriptor = options.descriptor;
94
+ this.currentStyle = normalizeWebHostTerminalStyle(options.style);
95
+ this.bridge = options.bridge;
96
+ this.onInput = options.onInput;
97
+ this.onFrameDiagnostic = options.onFrameDiagnostic;
98
+ this.synchronizeAccessibilityFocus = options.synchronizeAccessibilityFocus ?? true;
99
+ this.captureWheelInput = options.captureWheelInput ?? true;
100
+ this.element = document.createElement("section");
101
+ this.element.className = "webhost-scene";
102
+ this.element.dataset.sceneId = options.descriptor.id;
103
+ this.element.hidden = true;
104
+
105
+ const header = document.createElement("div");
106
+ header.className = "webhost-scene__header";
107
+ header.textContent = options.descriptor.title ?? options.descriptor.id;
108
+
109
+ this.terminalMount = document.createElement("div");
110
+ this.terminalMount.className = "webhost-scene__terminal";
111
+ this.terminalMount.tabIndex = 0;
112
+
113
+ this.element.append(header, this.terminalMount);
114
+ options.mount.appendChild(this.element);
115
+ this.applyVisibility();
116
+ }
117
+
118
+ async mount(): Promise<void> {
119
+ if (this.canvas) {
120
+ return;
121
+ }
122
+
123
+ const canvas = document.createElement("canvas");
124
+ canvas.className = "webhost-scene__surface";
125
+ canvas.setAttribute("aria-hidden", "true");
126
+ this.canvas = canvas;
127
+ this.accessibilityTree = new AccessibilityTreeMounter();
128
+ this.terminalMount.replaceChildren(
129
+ canvas,
130
+ this.accessibilityTree.element,
131
+ this.accessibilityTree.announcerElement
132
+ );
133
+ this.installInputHandlers();
134
+ this.installResizeObserver();
135
+
136
+ this.bridge?.bindOutput({
137
+ presentSurface: (frame) => this.presentSurface(frame),
138
+ writeClipboard: (text) => this.writeClipboard(text),
139
+ notifyRuntimeIssue: (issue) => this.notifyRuntimeIssue(issue),
140
+ recordFrameDiagnostic: (diagnostic) => this.recordFrameDiagnostic(diagnostic),
141
+ writeOutput: (text) => this.writeOutput(text),
142
+ writeError: (text) => this.writeOutput(text),
143
+ });
144
+
145
+ this.applyStyle(this.currentStyle);
146
+ this.measureCells();
147
+ this.resizeToMount();
148
+ this.draw();
149
+ this.syncAccessibilityTree();
150
+ }
151
+
152
+ setVisible(
153
+ visible: boolean
154
+ ): void {
155
+ this.isVisible = visible;
156
+ this.applyVisibility();
157
+ if (visible) {
158
+ this.resizeToMount();
159
+ if (this.synchronizeAccessibilityFocus) {
160
+ this.terminalMount.focus?.({ preventScroll: true });
161
+ }
162
+ }
163
+ }
164
+
165
+ setStyle(
166
+ style: WebHostTerminalStyle
167
+ ): void {
168
+ this.currentStyle = normalizeWebHostTerminalStyle(style);
169
+ this.applyStyle(this.currentStyle);
170
+ this.bridge?.updateRenderStyle(this.currentStyle);
171
+ this.measureCells();
172
+ this.resizeToMount();
173
+ this.draw();
174
+ this.syncAccessibilityTree();
175
+ }
176
+
177
+ resize(
178
+ columns: number,
179
+ rows: number
180
+ ): void {
181
+ this.columns = Math.max(1, Math.round(columns));
182
+ this.rows = Math.max(1, Math.round(rows));
183
+ this.resizeCanvas();
184
+ this.draw();
185
+ this.syncAccessibilityTree();
186
+ }
187
+
188
+ writeOutput(
189
+ text: string
190
+ ): void {
191
+ if (!this.diagnosticText) {
192
+ const diagnosticText = document.createElement("pre");
193
+ diagnosticText.className = "webhost-scene__diagnostic";
194
+ this.diagnosticText = diagnosticText;
195
+ this.terminalMount.appendChild(diagnosticText);
196
+ }
197
+ this.diagnosticText.textContent = `${this.diagnosticText.textContent ?? ""}${text}`;
198
+ }
199
+
200
+ notifyRuntimeIssue(
201
+ issue: WebHostRuntimeIssue
202
+ ): void {
203
+ console.log(issue.description);
204
+ }
205
+
206
+ private recordFrameDiagnostic(
207
+ diagnostic: WebHostFrameDiagnosticRecord
208
+ ): void {
209
+ this.onFrameDiagnostic?.(diagnostic);
210
+ }
211
+
212
+ async writeClipboard(
213
+ text: string
214
+ ): Promise<void> {
215
+ const clipboard = globalThis.navigator?.clipboard;
216
+ if (!clipboard?.writeText) {
217
+ return;
218
+ }
219
+
220
+ try {
221
+ await clipboard.writeText(text);
222
+ } catch {
223
+ // Clipboard permissions are browser/user-gesture dependent; hosts treat
224
+ // rejection as a best-effort no-op rather than surfacing diagnostics.
225
+ }
226
+ }
227
+
228
+ sendInput(
229
+ chunk: Uint8Array
230
+ ): void {
231
+ this.onInput(chunk);
232
+ }
233
+
234
+ dispose(): void {
235
+ this.detachInputHandlers?.();
236
+ this.resizeObserver?.disconnect();
237
+ this.element.remove();
238
+ }
239
+
240
+ private presentSurface(
241
+ frame: WebHostSurfaceFrame
242
+ ): void {
243
+ const previousFrame = this.currentFrame;
244
+ this.currentFrame = frame;
245
+ this.columns = Math.max(1, Math.round(frame.width));
246
+ this.rows = Math.max(1, Math.round(frame.height));
247
+ const resized = this.resizeCanvas();
248
+ this.draw(previousFrame && !resized ? frame.damage : undefined);
249
+ this.syncAccessibilityTree();
250
+ }
251
+
252
+ private applyStyle(
253
+ style: WebHostTerminalStyle
254
+ ): void {
255
+ applyWebHostTerminalStyle(this.element, style);
256
+ this.element.style.padding = "0.75rem";
257
+ this.element.style.borderRadius = "16px";
258
+ this.element.style.boxShadow = "0 20px 50px rgba(0, 0, 0, 0.28)";
259
+ this.element.style.overflow = "hidden";
260
+ this.element.style.gap = "0.5rem";
261
+ this.element.style.gridTemplateRows = "auto 1fr";
262
+
263
+ this.terminalMount.style.position = "relative";
264
+ this.terminalMount.style.overflow = "hidden";
265
+ this.terminalMount.style.outline = "none";
266
+ this.terminalMount.style.background = webTUITerminalBackgroundColor(this.currentStyle);
267
+ this.terminalMount.style.minHeight = `${this.cellHeight * 8}px`;
268
+
269
+ if (this.canvas) {
270
+ this.canvas.style.display = "block";
271
+ this.canvas.style.width = "100%";
272
+ this.canvas.style.height = "100%";
273
+ }
274
+ }
275
+
276
+ private applyVisibility(): void {
277
+ this.element.hidden = !this.isVisible;
278
+ this.element.style.setProperty(
279
+ "display",
280
+ this.isVisible ? "grid" : "none",
281
+ "important"
282
+ );
283
+ }
284
+
285
+ private installResizeObserver(): void {
286
+ if (typeof ResizeObserver === "undefined") {
287
+ return;
288
+ }
289
+
290
+ this.resizeObserver = new ResizeObserver(() => {
291
+ this.resizeToMount();
292
+ });
293
+ this.resizeObserver.observe(this.terminalMount);
294
+ }
295
+
296
+ private installInputHandlers(): void {
297
+ const handleKeyDown = (event: KeyboardEvent) => {
298
+ if (event.metaKey || event.isComposing) {
299
+ return;
300
+ }
301
+ const key = keyInputFromKeyboardEvent(event);
302
+ if (!key) {
303
+ return;
304
+ }
305
+
306
+ this.onInput(encodeKeyInputMessage({
307
+ ...key,
308
+ modifiers: modifierMask(event),
309
+ }));
310
+ event.preventDefault();
311
+ };
312
+
313
+ const handlePaste = (event: ClipboardEvent) => {
314
+ const text = event.clipboardData?.getData("text/plain") ?? "";
315
+ if (!text) {
316
+ return;
317
+ }
318
+ this.onInput(encodePasteInputMessage(text));
319
+ event.preventDefault();
320
+ };
321
+
322
+ const handlePointerDown = (event: PointerEvent) => {
323
+ const location = this.cellLocation(event);
324
+ if (!location) {
325
+ return;
326
+ }
327
+
328
+ const button = pointerButton(event.button);
329
+ this.activePointerButton = button;
330
+ this.hasCapturedPointer = true;
331
+ this.terminalMount.focus?.({ preventScroll: true });
332
+ this.terminalMount.setPointerCapture?.(event.pointerId);
333
+ this.onInput(encodeMouseInputMessage({
334
+ kind: "down",
335
+ x: location.x,
336
+ y: location.y,
337
+ button,
338
+ modifiers: modifierMask(event),
339
+ }));
340
+ event.preventDefault();
341
+ };
342
+
343
+ const handlePointerUp = (event: PointerEvent) => {
344
+ const location = this.hasCapturedPointer
345
+ ? this.rawCellLocation(event)
346
+ : this.cellLocation(event);
347
+ this.terminalMount.releasePointerCapture?.(event.pointerId);
348
+ this.hasCapturedPointer = false;
349
+ if (!location) {
350
+ return;
351
+ }
352
+
353
+ this.onInput(encodeMouseInputMessage({
354
+ kind: "up",
355
+ x: location.x,
356
+ y: location.y,
357
+ button: pointerButton(event.button) ?? this.activePointerButton,
358
+ modifiers: modifierMask(event),
359
+ }));
360
+ event.preventDefault();
361
+ };
362
+
363
+ const handlePointerMove = (event: PointerEvent) => {
364
+ const location = event.buttons && this.hasCapturedPointer
365
+ ? this.rawCellLocation(event)
366
+ : this.cellLocation(event);
367
+ if (!location) {
368
+ return;
369
+ }
370
+
371
+ this.onInput(encodeMouseInputMessage({
372
+ kind: event.buttons ? "dragged" : "moved",
373
+ x: location.x,
374
+ y: location.y,
375
+ button: this.activePointerButton,
376
+ modifiers: modifierMask(event),
377
+ }));
378
+ };
379
+
380
+ const handleWheel = (event: WheelEvent) => {
381
+ if (!this.captureWheelInput) {
382
+ return;
383
+ }
384
+
385
+ const location = this.cellLocation(event);
386
+ if (!location) {
387
+ return;
388
+ }
389
+
390
+ this.onInput(encodeMouseInputMessage({
391
+ kind: "scrolled",
392
+ x: location.x,
393
+ y: location.y,
394
+ deltaX: normalizedWheelDelta(event.deltaX),
395
+ deltaY: normalizedWheelDelta(event.deltaY),
396
+ modifiers: modifierMask(event),
397
+ }));
398
+ event.preventDefault();
399
+ };
400
+
401
+ this.terminalMount.addEventListener("keydown", handleKeyDown);
402
+ this.terminalMount.addEventListener("paste", handlePaste);
403
+ this.terminalMount.addEventListener("pointerdown", handlePointerDown);
404
+ this.terminalMount.addEventListener("pointerup", handlePointerUp);
405
+ this.terminalMount.addEventListener("pointermove", handlePointerMove);
406
+ this.terminalMount.addEventListener("wheel", handleWheel, { passive: false });
407
+
408
+ this.detachInputHandlers = () => {
409
+ this.terminalMount.removeEventListener("keydown", handleKeyDown);
410
+ this.terminalMount.removeEventListener("paste", handlePaste);
411
+ this.terminalMount.removeEventListener("pointerdown", handlePointerDown);
412
+ this.terminalMount.removeEventListener("pointerup", handlePointerUp);
413
+ this.terminalMount.removeEventListener("pointermove", handlePointerMove);
414
+ this.terminalMount.removeEventListener("wheel", handleWheel);
415
+ };
416
+ }
417
+
418
+ private resizeToMount(): void {
419
+ this.measureCells();
420
+ const rect = this.terminalMount.getBoundingClientRect?.();
421
+ const width = rect?.width && rect.width > 0 ? rect.width : this.columns * this.cellWidth;
422
+ const height = rect?.height && rect.height > 0 ? rect.height : this.rows * this.cellHeight;
423
+ const nextColumns = Math.max(1, Math.floor(width / this.cellWidth));
424
+ const nextRows = Math.max(1, Math.floor(height / this.cellHeight));
425
+
426
+ this.columns = nextColumns;
427
+ this.rows = nextRows;
428
+ this.sendResizeIfNeeded();
429
+ this.resizeCanvas();
430
+ }
431
+
432
+ private sendResizeIfNeeded(): void {
433
+ const current = {
434
+ columns: this.columns,
435
+ rows: this.rows,
436
+ cellWidth: this.cellWidth,
437
+ cellHeight: this.cellHeight,
438
+ };
439
+ if (this.lastSentResize
440
+ && this.lastSentResize.columns === current.columns
441
+ && this.lastSentResize.rows === current.rows
442
+ && this.lastSentResize.cellWidth === current.cellWidth
443
+ && this.lastSentResize.cellHeight === current.cellHeight
444
+ ) {
445
+ return;
446
+ }
447
+
448
+ this.lastSentResize = current;
449
+ this.bridge?.resize(current.columns, current.rows, current.cellWidth, current.cellHeight);
450
+ }
451
+
452
+ private resizeCanvas(): boolean {
453
+ if (!this.canvas) {
454
+ return false;
455
+ }
456
+
457
+ const cssWidth = Math.max(1, this.columns * this.cellWidth);
458
+ const cssHeight = Math.max(1, this.rows * this.cellHeight);
459
+ const scale = globalThis.window?.devicePixelRatio || 1;
460
+ const width = Math.ceil(cssWidth * scale);
461
+ const height = Math.ceil(cssHeight * scale);
462
+ const styleWidth = `${cssWidth}px`;
463
+ const styleHeight = `${cssHeight}px`;
464
+ if (this.canvas.width === width
465
+ && this.canvas.height === height
466
+ && this.canvas.style.width === styleWidth
467
+ && this.canvas.style.height === styleHeight
468
+ ) {
469
+ return false;
470
+ }
471
+
472
+ this.canvas.width = width;
473
+ this.canvas.height = height;
474
+ this.canvas.style.width = styleWidth;
475
+ this.canvas.style.height = styleHeight;
476
+ return true;
477
+ }
478
+
479
+ private measureCells(): void {
480
+ const canvas = this.canvas ?? document.createElement("canvas");
481
+ const context = canvas.getContext?.("2d");
482
+ if (!context) {
483
+ this.cellWidth = Math.max(1, Math.round(this.currentStyle.fontSize * 0.62));
484
+ this.cellHeight = Math.max(1, Math.round(this.currentStyle.fontSize * 1.35));
485
+ return;
486
+ }
487
+
488
+ context.font = this.fontForStyle();
489
+ this.cellWidth = Math.max(1, Math.ceil(context.measureText("W").width));
490
+ this.cellHeight = Math.max(1, Math.ceil(this.currentStyle.fontSize * 1.35));
491
+ }
492
+
493
+ private draw(
494
+ damage?: WebHostSurfaceDamage
495
+ ): void {
496
+ const canvas = this.canvas;
497
+ const context = canvas?.getContext("2d");
498
+ if (!canvas || !context) {
499
+ return;
500
+ }
501
+
502
+ const frame = this.currentFrame;
503
+ const dirtyRects = frame ? this.dirtyRectsForDamage(damage, frame) : undefined;
504
+ if (dirtyRects?.length === 0) {
505
+ return;
506
+ }
507
+
508
+ const scale = globalThis.window?.devicePixelRatio || 1;
509
+ context.setTransform(scale, 0, 0, scale, 0, 0);
510
+ context.textBaseline = "alphabetic";
511
+
512
+ context.fillStyle = webTUITerminalBackgroundColor(this.currentStyle);
513
+ if (dirtyRects) {
514
+ for (const rect of dirtyRects) {
515
+ context.clearRect(rect.x, rect.y, rect.width, rect.height);
516
+ context.fillRect(rect.x, rect.y, rect.width, rect.height);
517
+ }
518
+ } else {
519
+ context.clearRect(0, 0, canvas.width / scale, canvas.height / scale);
520
+ context.fillRect(0, 0, this.columns * this.cellWidth, this.rows * this.cellHeight);
521
+ }
522
+
523
+ if (!frame) {
524
+ return;
525
+ }
526
+
527
+ this.drawRows(context, frame, dirtyRects);
528
+ this.drawImages(context, frame.images ?? [], dirtyRects);
529
+ }
530
+
531
+ private drawRows(
532
+ context: CanvasRenderingContext2D,
533
+ frame: WebHostSurfaceFrame,
534
+ dirtyRects?: DirtyRect[]
535
+ ): void {
536
+ for (let y = 0; y < frame.rows.length; y += 1) {
537
+ const row = frame.rows[y] ?? [];
538
+ for (const cell of row) {
539
+ const [x, text, span, styleIndex] = cell;
540
+ const cellRect = this.cellRect(x, y, span);
541
+ if (dirtyRects && !dirtyRects.some((rect) => rectsIntersect(rect, cellRect))) {
542
+ continue;
543
+ }
544
+ const style = frame.styles[styleIndex] ?? undefined;
545
+ this.drawCell(context, x, y, text, span, style);
546
+ }
547
+ }
548
+ }
549
+
550
+ private syncAccessibilityTree(): void {
551
+ const tree = this.accessibilityTree;
552
+ if (!tree || !this.currentFrame) {
553
+ return;
554
+ }
555
+
556
+ tree.present(this.currentFrame.accessibilityTree ?? [], {
557
+ cellWidth: this.cellWidth,
558
+ cellHeight: this.cellHeight,
559
+ }, this.currentFrame.accessibilityAnnouncements ?? [], {
560
+ synchronizeFocus: this.synchronizeAccessibilityFocus,
561
+ });
562
+ }
563
+
564
+ private drawImages(
565
+ context: CanvasRenderingContext2D,
566
+ images: WebHostSurfaceImage[],
567
+ dirtyRects?: DirtyRect[]
568
+ ): void {
569
+ for (const image of images) {
570
+ this.drawImage(context, image, dirtyRects);
571
+ }
572
+ }
573
+
574
+ private drawImage(
575
+ context: CanvasRenderingContext2D,
576
+ image: WebHostSurfaceImage,
577
+ dirtyRects?: DirtyRect[]
578
+ ): void {
579
+ const decodedImage = this.cachedImage(image);
580
+ if (!decodedImage) {
581
+ return;
582
+ }
583
+
584
+ const [boundsX, boundsY, boundsWidth, boundsHeight] = image.bounds;
585
+ const [clipX, clipY, clipWidth, clipHeight] = image.visibleBounds;
586
+ if (boundsWidth <= 0 || boundsHeight <= 0 || clipWidth <= 0 || clipHeight <= 0) {
587
+ return;
588
+ }
589
+ const imageRect = {
590
+ x: clipX * this.cellWidth,
591
+ y: clipY * this.cellHeight,
592
+ width: clipWidth * this.cellWidth,
593
+ height: clipHeight * this.cellHeight,
594
+ };
595
+ if (dirtyRects && !dirtyRects.some((rect) => rectsIntersect(rect, imageRect))) {
596
+ return;
597
+ }
598
+
599
+ context.save();
600
+ context.beginPath();
601
+ context.rect(
602
+ clipX * this.cellWidth,
603
+ clipY * this.cellHeight,
604
+ clipWidth * this.cellWidth,
605
+ clipHeight * this.cellHeight
606
+ );
607
+ context.clip();
608
+ context.drawImage(
609
+ decodedImage,
610
+ boundsX * this.cellWidth,
611
+ boundsY * this.cellHeight,
612
+ boundsWidth * this.cellWidth,
613
+ boundsHeight * this.cellHeight
614
+ );
615
+ context.restore();
616
+ }
617
+
618
+ private cachedImage(
619
+ image: WebHostSurfaceImage
620
+ ): CanvasImageSource | undefined {
621
+ const cached = this.imageCache.get(image.id);
622
+ if (cached?.image) {
623
+ return cached.image;
624
+ }
625
+
626
+ if (!cached?.promise && image.dataBase64) {
627
+ const promise = decodeImage(image.dataBase64, image.format);
628
+ this.imageCache.set(image.id, { promise });
629
+ void promise.then((decodedImage) => {
630
+ const latest = this.imageCache.get(image.id);
631
+ if (latest?.promise !== promise) {
632
+ return;
633
+ }
634
+ this.imageCache.set(image.id, { image: decodedImage });
635
+ this.draw();
636
+ }).catch(() => {
637
+ this.imageCache.delete(image.id);
638
+ });
639
+ }
640
+
641
+ return undefined;
642
+ }
643
+
644
+ private drawCell(
645
+ context: CanvasRenderingContext2D,
646
+ x: number,
647
+ y: number,
648
+ text: string,
649
+ span: number,
650
+ style?: WebHostSurfaceStyle | null
651
+ ): void {
652
+ const rectX = x * this.cellWidth;
653
+ const rectY = y * this.cellHeight;
654
+ const width = Math.max(1, span) * this.cellWidth;
655
+ const background = resolvedBackground(style, this.currentStyle);
656
+ const foreground = resolvedForeground(style, this.currentStyle);
657
+ const opacity = style?.opacity ?? 1;
658
+
659
+ if (background) {
660
+ context.globalAlpha = opacity;
661
+ context.fillStyle = background;
662
+ context.fillRect(rectX, rectY, width, this.cellHeight);
663
+ }
664
+
665
+ if (text !== " ") {
666
+ context.globalAlpha = opacity;
667
+ context.fillStyle = foreground;
668
+ context.strokeStyle = foreground;
669
+ if (!canRenderBoxDrawing(text) || !drawBoxDrawing(context, text, {
670
+ x: rectX,
671
+ y: rectY,
672
+ width,
673
+ height: this.cellHeight,
674
+ })) {
675
+ context.font = this.fontForStyle(style);
676
+ context.fillText(
677
+ text,
678
+ rectX,
679
+ rectY + Math.floor((this.cellHeight + this.currentStyle.fontSize) / 2) - 2
680
+ );
681
+ }
682
+ }
683
+
684
+ this.drawTextLine(context, rectX, rectY, width, style?.underline, "underline", foreground);
685
+ this.drawTextLine(context, rectX, rectY, width, style?.strikethrough, "strike", foreground);
686
+ context.globalAlpha = 1;
687
+ }
688
+
689
+ private dirtyRectsForDamage(
690
+ damage: WebHostSurfaceDamage | undefined,
691
+ frame: WebHostSurfaceFrame
692
+ ): DirtyRect[] | undefined {
693
+ if (!damage || damage.requiresFullTextRepaint || damage.requiresFullGraphicsReplay) {
694
+ return undefined;
695
+ }
696
+
697
+ const rects: DirtyRect[] = [];
698
+ for (const [row, ranges] of damage.textRows) {
699
+ if (row < 0 || row >= frame.height) {
700
+ continue;
701
+ }
702
+ if (ranges.length === 0) {
703
+ rects.push(this.cellRect(0, row, frame.width));
704
+ continue;
705
+ }
706
+ for (const [start, end] of ranges) {
707
+ const lowerBound = Math.max(0, Math.min(frame.width, Math.floor(start)));
708
+ const upperBound = Math.max(lowerBound, Math.min(frame.width, Math.ceil(end)));
709
+ if (lowerBound >= upperBound) {
710
+ continue;
711
+ }
712
+ rects.push(this.cellRect(lowerBound, row, upperBound - lowerBound));
713
+ }
714
+ }
715
+ return rects;
716
+ }
717
+
718
+ private cellRect(
719
+ x: number,
720
+ y: number,
721
+ span: number
722
+ ): DirtyRect {
723
+ return {
724
+ x: x * this.cellWidth,
725
+ y: y * this.cellHeight,
726
+ width: Math.max(1, span) * this.cellWidth,
727
+ height: this.cellHeight,
728
+ };
729
+ }
730
+
731
+ private drawTextLine(
732
+ context: CanvasRenderingContext2D,
733
+ x: number,
734
+ y: number,
735
+ width: number,
736
+ line: WebHostSurfaceStyle["underline"],
737
+ placement: "underline" | "strike",
738
+ fallbackColor: string
739
+ ): void {
740
+ if (!line) {
741
+ return;
742
+ }
743
+ context.strokeStyle = line.color ?? fallbackColor;
744
+ context.lineWidth = line.pattern === "double" ? 2 : 1;
745
+ if (line.pattern === "dot") {
746
+ context.setLineDash([1, 3]);
747
+ } else if (line.pattern === "dash") {
748
+ context.setLineDash([4, 3]);
749
+ } else {
750
+ context.setLineDash([]);
751
+ }
752
+
753
+ const lineY = placement === "underline"
754
+ ? y + this.cellHeight - 2
755
+ : y + Math.floor(this.cellHeight / 2);
756
+ context.beginPath();
757
+ context.moveTo(x, lineY);
758
+ context.lineTo(x + width, lineY);
759
+ context.stroke();
760
+ context.setLineDash([]);
761
+ }
762
+
763
+ private fontForStyle(
764
+ style?: WebHostSurfaceStyle | null
765
+ ): string {
766
+ const emphasis = style?.em ?? 0;
767
+ const italic = (emphasis & 2) !== 0 ? "italic " : "";
768
+ const weight = (emphasis & 1) !== 0 ? "700 " : "";
769
+ return `${italic}${weight}${this.currentStyle.fontSize}px ${this.currentStyle.fontFamily}`;
770
+ }
771
+
772
+ private cellLocation(
773
+ event: MouseEvent
774
+ ): { x: number; y: number } | undefined {
775
+ const location = this.rawCellLocation(event);
776
+ if (!location) {
777
+ return undefined;
778
+ }
779
+
780
+ const cellX = Math.floor(location.x);
781
+ const cellY = Math.floor(location.y);
782
+ if (cellX < 0 || cellY < 0 || cellX >= this.columns || cellY >= this.rows) {
783
+ return undefined;
784
+ }
785
+ return location;
786
+ }
787
+
788
+ private rawCellLocation(
789
+ event: MouseEvent
790
+ ): { x: number; y: number } | undefined {
791
+ const rect = this.canvas?.getBoundingClientRect?.() ?? this.terminalMount.getBoundingClientRect?.();
792
+ if (!rect) {
793
+ return undefined;
794
+ }
795
+
796
+ const x = (event.clientX - rect.left) / this.cellWidth;
797
+ const y = (event.clientY - rect.top) / this.cellHeight;
798
+ return { x, y };
799
+ }
800
+ }
801
+
802
+ async function decodeImage(
803
+ dataBase64: string,
804
+ format: WebHostSurfaceImageFormat
805
+ ): Promise<CanvasImageSource> {
806
+ const bytes = decodeBase64Bytes(dataBase64);
807
+ const blob = new Blob([bytes], { type: `image/${format}` });
808
+
809
+ if (typeof createImageBitmap === "function") {
810
+ // Animated GIFs collapse to their first frame in createImageBitmap
811
+ // — that matches the Kitty path's first-frame composite. Phase 7
812
+ // will replace this with a frame ticker.
813
+ return createImageBitmap(blob);
814
+ }
815
+
816
+ return new Promise((resolve, reject) => {
817
+ const image = new Image();
818
+ const url = URL.createObjectURL(blob);
819
+ image.onload = () => {
820
+ URL.revokeObjectURL(url);
821
+ resolve(image);
822
+ };
823
+ image.onerror = () => {
824
+ URL.revokeObjectURL(url);
825
+ reject(new Error(`Failed to decode ${format} image`));
826
+ };
827
+ image.src = url;
828
+ });
829
+ }
830
+
831
+ function decodeBase64Bytes(
832
+ value: string
833
+ ): Uint8Array {
834
+ if (typeof atob === "function") {
835
+ const binary = atob(value);
836
+ const bytes = new Uint8Array(binary.length);
837
+ for (let index = 0; index < binary.length; index += 1) {
838
+ bytes[index] = binary.charCodeAt(index);
839
+ }
840
+ return bytes;
841
+ }
842
+
843
+ return new Uint8Array(Buffer.from(value, "base64"));
844
+ }
845
+
846
+ function keyInputFromKeyboardEvent(
847
+ event: KeyboardEvent
848
+ ): Pick<WebHostKeyInput, "key" | "character"> | undefined {
849
+ switch (event.key) {
850
+ case "Enter":
851
+ return { key: "return" };
852
+ case " ":
853
+ return { key: "space" };
854
+ case "Tab":
855
+ return { key: "tab" };
856
+ case "ArrowLeft":
857
+ return { key: "arrowLeft" };
858
+ case "ArrowRight":
859
+ return { key: "arrowRight" };
860
+ case "ArrowUp":
861
+ return { key: "arrowUp" };
862
+ case "ArrowDown":
863
+ return { key: "arrowDown" };
864
+ case "Backspace":
865
+ return { key: "backspace" };
866
+ case "Escape":
867
+ return { key: "escape" };
868
+ case "Home":
869
+ return { key: "home" };
870
+ case "End":
871
+ return { key: "end" };
872
+ default:
873
+ {
874
+ const characters = Array.from(event.key);
875
+ if (characters.length !== 1) {
876
+ return undefined;
877
+ }
878
+ return {
879
+ key: "character",
880
+ character: characters[0],
881
+ };
882
+ }
883
+ }
884
+ }
885
+
886
+ function pointerButton(
887
+ button: number
888
+ ): "primary" | "middle" | "secondary" {
889
+ switch (button) {
890
+ case 1:
891
+ return "middle";
892
+ case 2:
893
+ return "secondary";
894
+ default:
895
+ return "primary";
896
+ }
897
+ }
898
+
899
+ function modifierMask(
900
+ event: MouseEvent | KeyboardEvent
901
+ ): number {
902
+ let mask = 0;
903
+ if (event.shiftKey) {
904
+ mask |= 1;
905
+ }
906
+ if (event.altKey) {
907
+ mask |= 2;
908
+ }
909
+ if (event.ctrlKey) {
910
+ mask |= 4;
911
+ }
912
+ return mask;
913
+ }
914
+
915
+ function normalizedWheelDelta(
916
+ delta: number
917
+ ): number {
918
+ if (delta > 0) {
919
+ return 1;
920
+ }
921
+ if (delta < 0) {
922
+ return -1;
923
+ }
924
+ return 0;
925
+ }
926
+
927
+ function rectsIntersect(
928
+ lhs: DirtyRect,
929
+ rhs: DirtyRect
930
+ ): boolean {
931
+ return lhs.x < rhs.x + rhs.width
932
+ && lhs.x + lhs.width > rhs.x
933
+ && lhs.y < rhs.y + rhs.height
934
+ && lhs.y + lhs.height > rhs.y;
935
+ }
936
+
937
+ function resolvedForeground(
938
+ style: WebHostSurfaceStyle | null | undefined,
939
+ terminalStyle: ResolvedWebHostTerminalStyle
940
+ ): string {
941
+ if ((style?.em ?? 0) & 16) {
942
+ return style?.bg ?? terminalStyle.theme.background;
943
+ }
944
+ return style?.fg ?? terminalStyle.theme.foreground;
945
+ }
946
+
947
+ function resolvedBackground(
948
+ style: WebHostSurfaceStyle | null | undefined,
949
+ terminalStyle: ResolvedWebHostTerminalStyle
950
+ ): string | undefined {
951
+ if ((style?.em ?? 0) & 16) {
952
+ return style?.fg ?? terminalStyle.theme.foreground;
953
+ }
954
+ return style?.bg;
955
+ }