@swifttui/web 0.0.14 → 0.0.16

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