@swifttui/web 0.1.0 → 0.1.2
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.
- package/dist/src/AccessibilityTree.js +23 -14
- package/dist/src/AccessibilityTree.js.map +1 -1
- package/dist/src/CanvasSurfacePainter.js +263 -0
- package/dist/src/CanvasSurfacePainter.js.map +1 -0
- package/dist/src/InputEventEncoder.js +117 -0
- package/dist/src/InputEventEncoder.js.map +1 -0
- package/dist/src/PointerGeometry.js +72 -0
- package/dist/src/PointerGeometry.js.map +1 -0
- package/dist/src/WebHostSceneRuntime.d.ts +11 -20
- package/dist/src/WebHostSceneRuntime.js +40 -359
- package/dist/src/WebHostSceneRuntime.js.map +1 -1
- package/dist/src/wasi/SharedInputQueue.js +14 -8
- package/dist/src/wasi/SharedInputQueue.js.map +1 -1
- package/package.json +6 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PointerGeometry.js","names":[],"sources":["../../src/PointerGeometry.ts"],"sourcesContent":["import type {\n WebHostScrollRegion,\n} from \"./WebHostSurfaceTransport.ts\";\nimport type { CellLocation } from \"./InputEventEncoder.ts\";\n\n/**\n * The cell-grid geometry a pointer hit-test needs: the surface's bounding\n * client rect (the canvas's, falling back to the terminal mount's), the cell\n * dimensions, and the grid extent in cells. The host snapshots this per event.\n */\nexport interface PointerGeometryMetrics {\n rect?: { left: number; top: number } | null;\n cellWidth: number;\n cellHeight: number;\n columns: number;\n rows: number;\n}\n\n/**\n * Converts a pointer event to fractional cell coordinates, clamped to the\n * grid. Returns `undefined` when the pointer is outside the cell grid (in the\n * sub-cell margin/gutter), so the host can leave the event unhandled.\n */\nexport function cellLocationForEvent(\n event: MouseEvent,\n metrics: PointerGeometryMetrics\n): CellLocation | undefined {\n const location = rawCellLocationForEvent(event, metrics);\n if (!location) {\n return undefined;\n }\n\n const cellX = Math.floor(location.x);\n const cellY = Math.floor(location.y);\n if (cellX < 0 || cellY < 0 || cellX >= metrics.columns || cellY >= metrics.rows) {\n return undefined;\n }\n return location;\n}\n\n/**\n * Converts a pointer event to fractional cell coordinates *without* clamping to\n * the grid. Used while a pointer is captured (drag) so the host keeps tracking\n * the gesture even when it strays outside the surface bounds.\n */\nexport function rawCellLocationForEvent(\n event: MouseEvent,\n metrics: PointerGeometryMetrics\n): CellLocation | undefined {\n const rect = metrics.rect;\n if (!rect) {\n return undefined;\n }\n\n const x = (event.clientX - rect.left) / metrics.cellWidth;\n const y = (event.clientY - rect.top) / metrics.cellHeight;\n return { x, y };\n}\n\n/**\n * Whether any scrollable region under `location` can still scroll in the\n * wheel's direction. Mirrors the Swift host's scroll hit-test: a region\n * qualifies when its viewport contains the cell AND it has remaining headroom\n * in the delta's direction. Used by \"chain\" wheel mode to decide capture vs.\n * fall-through. With no published `scrollRegions`, nothing can scroll, so the\n * wheel chains to the page (a scene with no ScrollView stays fully passive).\n */\nexport function wheelTargetCanScroll(\n regions: readonly WebHostScrollRegion[] | undefined,\n location: CellLocation,\n deltaX: number,\n deltaY: number\n): boolean {\n if (!regions || regions.length === 0) {\n return false;\n }\n\n const cellX = Math.floor(location.x);\n const cellY = Math.floor(location.y);\n // Any region under the pointer that can move in this direction qualifies —\n // this is what lets an outer ScrollView take over when an inner one is at\n // its edge (nested scroll), and chains to the page only when none can.\n for (const region of regions) {\n const [rx, ry, rw, rh] = region.rect;\n if (cellX < rx || cellY < ry || cellX >= rx + rw || cellY >= ry + rh) {\n continue;\n }\n if (regionCanScrollInDirection(region, deltaX, deltaY)) {\n return true;\n }\n }\n return false;\n}\n\n/**\n * Whether a published scroll region has remaining headroom in the wheel's\n * direction, recomputing the per-direction extent from offset/content/viewport.\n * Mirrors SwiftTUI's clamp (`min(max(0, offset), max(0, content - viewport))`)\n * so the host and the app agree on \"at edge\". Wheel sign convention matches the\n * app: `deltaY > 0` scrolls down (offset grows toward the content bottom).\n * Diagonal wheels qualify if either axis has headroom.\n */\nfunction regionCanScrollInDirection(\n region: WebHostScrollRegion,\n deltaX: number,\n deltaY: number\n): boolean {\n const [, , viewportWidth, viewportHeight] = region.rect;\n const [offsetX, offsetY] = region.offset;\n const [contentWidth, contentHeight] = region.content;\n const maxX = Math.max(0, contentWidth - viewportWidth);\n const maxY = Math.max(0, contentHeight - viewportHeight);\n const clampedX = Math.min(Math.max(0, offsetX), maxX);\n const clampedY = Math.min(Math.max(0, offsetY), maxY);\n\n if (deltaY > 0 && clampedY < maxY) {\n return true;\n }\n if (deltaY < 0 && clampedY > 0) {\n return true;\n }\n if (deltaX > 0 && clampedX < maxX) {\n return true;\n }\n if (deltaX < 0 && clampedX > 0) {\n return true;\n }\n return false;\n}\n"],"mappings":";;;;;;AAuBA,SAAgB,qBACd,OACA,SAC0B;CAC1B,MAAM,WAAW,wBAAwB,OAAO,OAAO;CACvD,IAAI,CAAC,UACH;CAGF,MAAM,QAAQ,KAAK,MAAM,SAAS,CAAC;CACnC,MAAM,QAAQ,KAAK,MAAM,SAAS,CAAC;CACnC,IAAI,QAAQ,KAAK,QAAQ,KAAK,SAAS,QAAQ,WAAW,SAAS,QAAQ,MACzE;CAEF,OAAO;AACT;;;;;;AAOA,SAAgB,wBACd,OACA,SAC0B;CAC1B,MAAM,OAAO,QAAQ;CACrB,IAAI,CAAC,MACH;CAKF,OAAO;EAAE,IAFE,MAAM,UAAU,KAAK,QAAQ,QAAQ;EAEpC,IADD,MAAM,UAAU,KAAK,OAAO,QAAQ;CACjC;AAChB;;;;;;;;;AAUA,SAAgB,qBACd,SACA,UACA,QACA,QACS;CACT,IAAI,CAAC,WAAW,QAAQ,WAAW,GACjC,OAAO;CAGT,MAAM,QAAQ,KAAK,MAAM,SAAS,CAAC;CACnC,MAAM,QAAQ,KAAK,MAAM,SAAS,CAAC;CAInC,KAAK,MAAM,UAAU,SAAS;EAC5B,MAAM,CAAC,IAAI,IAAI,IAAI,MAAM,OAAO;EAChC,IAAI,QAAQ,MAAM,QAAQ,MAAM,SAAS,KAAK,MAAM,SAAS,KAAK,IAChE;EAEF,IAAI,2BAA2B,QAAQ,QAAQ,MAAM,GACnD,OAAO;CAEX;CACA,OAAO;AACT;;;;;;;;;AAUA,SAAS,2BACP,QACA,QACA,QACS;CACT,MAAM,KAAK,eAAe,kBAAkB,OAAO;CACnD,MAAM,CAAC,SAAS,WAAW,OAAO;CAClC,MAAM,CAAC,cAAc,iBAAiB,OAAO;CAC7C,MAAM,OAAO,KAAK,IAAI,GAAG,eAAe,aAAa;CACrD,MAAM,OAAO,KAAK,IAAI,GAAG,gBAAgB,cAAc;CACvD,MAAM,WAAW,KAAK,IAAI,KAAK,IAAI,GAAG,OAAO,GAAG,IAAI;CACpD,MAAM,WAAW,KAAK,IAAI,KAAK,IAAI,GAAG,OAAO,GAAG,IAAI;CAEpD,IAAI,SAAS,KAAK,WAAW,MAC3B,OAAO;CAET,IAAI,SAAS,KAAK,WAAW,GAC3B,OAAO;CAET,IAAI,SAAS,KAAK,WAAW,MAC3B,OAAO;CAET,IAAI,SAAS,KAAK,WAAW,GAC3B,OAAO;CAET,OAAO;AACT"}
|
|
@@ -41,6 +41,13 @@ interface WebHostSceneRuntimeOptions {
|
|
|
41
41
|
captureWheelInput?: boolean;
|
|
42
42
|
}
|
|
43
43
|
type WheelMode = "capture" | "chain" | "passive";
|
|
44
|
+
/**
|
|
45
|
+
* Coordinates a single SwiftTUI scene's browser presentation: it owns the DOM
|
|
46
|
+
* mount, canvas, accessibility tree, and bridge wiring, and delegates the heavy
|
|
47
|
+
* responsibilities to focused collaborators — {@link CanvasSurfacePainter} for
|
|
48
|
+
* canvas drawing, {@link InputEventEncoder} for wire-message encoding, and the
|
|
49
|
+
* {@link PointerGeometry} helpers for pixel→cell hit-testing and wheel chaining.
|
|
50
|
+
*/
|
|
44
51
|
declare class WebHostSceneRuntime {
|
|
45
52
|
readonly descriptor: WebHostSceneDescriptor;
|
|
46
53
|
readonly element: HTMLElement;
|
|
@@ -50,7 +57,8 @@ declare class WebHostSceneRuntime {
|
|
|
50
57
|
private readonly onFrameDiagnostic?;
|
|
51
58
|
private readonly synchronizeAccessibilityFocus;
|
|
52
59
|
private readonly wheelMode;
|
|
53
|
-
private readonly
|
|
60
|
+
private readonly painter;
|
|
61
|
+
private readonly inputEncoder;
|
|
54
62
|
private currentStyle;
|
|
55
63
|
private canvas?;
|
|
56
64
|
private accessibilityTree?;
|
|
@@ -87,26 +95,9 @@ declare class WebHostSceneRuntime {
|
|
|
87
95
|
private resizeCanvas;
|
|
88
96
|
private measureCells;
|
|
89
97
|
private draw;
|
|
90
|
-
private drawRows;
|
|
91
|
-
private drawRow;
|
|
92
98
|
private syncAccessibilityTree;
|
|
93
|
-
private
|
|
94
|
-
private
|
|
95
|
-
private cachedImage;
|
|
96
|
-
private drawCell;
|
|
97
|
-
private dirtyRegionForDamage;
|
|
98
|
-
private cellRect;
|
|
99
|
-
private drawTextLine;
|
|
100
|
-
private fontForStyle;
|
|
101
|
-
/**
|
|
102
|
-
* Whether any scrollable region under `location` can still scroll in the
|
|
103
|
-
* wheel's direction. Mirrors the Swift host's scroll hit-test: a region
|
|
104
|
-
* qualifies when its viewport contains the cell AND it has remaining headroom
|
|
105
|
-
* in the delta's direction. Used by "chain" wheel mode to decide capture vs.
|
|
106
|
-
* fall-through. With no published `scrollRegions`, nothing can scroll, so the
|
|
107
|
-
* wheel chains to the page (a scene with no ScrollView stays fully passive).
|
|
108
|
-
*/
|
|
109
|
-
private wheelTargetCanScroll;
|
|
99
|
+
private surfaceMetrics;
|
|
100
|
+
private pointerMetrics;
|
|
110
101
|
private cellLocation;
|
|
111
102
|
private rawCellLocation;
|
|
112
103
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { applyWebHostTerminalStyle, normalizeWebHostTerminalStyle, webTUITerminalBackgroundColor } from "./WebHostTerminalStyle.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { CanvasSurfacePainter, fontForStyle } from "./CanvasSurfacePainter.js";
|
|
3
|
+
import { InputEventEncoder } from "./InputEventEncoder.js";
|
|
4
|
+
import { cellLocationForEvent, rawCellLocationForEvent, wheelTargetCanScroll } from "./PointerGeometry.js";
|
|
4
5
|
import { AccessibilityTreeMounter } from "./AccessibilityTree.js";
|
|
5
6
|
//#region src/WebHostSceneRuntime.ts
|
|
6
7
|
/**
|
|
@@ -13,6 +14,13 @@ function legacyWheelMode(captureWheelInput) {
|
|
|
13
14
|
if (captureWheelInput === void 0) return "chain";
|
|
14
15
|
return captureWheelInput ? "capture" : "passive";
|
|
15
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Coordinates a single SwiftTUI scene's browser presentation: it owns the DOM
|
|
19
|
+
* mount, canvas, accessibility tree, and bridge wiring, and delegates the heavy
|
|
20
|
+
* responsibilities to focused collaborators — {@link CanvasSurfacePainter} for
|
|
21
|
+
* canvas drawing, {@link InputEventEncoder} for wire-message encoding, and the
|
|
22
|
+
* {@link PointerGeometry} helpers for pixel→cell hit-testing and wheel chaining.
|
|
23
|
+
*/
|
|
16
24
|
var WebHostSceneRuntime = class {
|
|
17
25
|
descriptor;
|
|
18
26
|
element;
|
|
@@ -22,7 +30,8 @@ var WebHostSceneRuntime = class {
|
|
|
22
30
|
onFrameDiagnostic;
|
|
23
31
|
synchronizeAccessibilityFocus;
|
|
24
32
|
wheelMode;
|
|
25
|
-
|
|
33
|
+
painter = new CanvasSurfacePainter();
|
|
34
|
+
inputEncoder = new InputEventEncoder();
|
|
26
35
|
currentStyle;
|
|
27
36
|
canvas;
|
|
28
37
|
accessibilityTree;
|
|
@@ -66,6 +75,7 @@ var WebHostSceneRuntime = class {
|
|
|
66
75
|
canvas.className = "webhost-scene__surface";
|
|
67
76
|
canvas.setAttribute("aria-hidden", "true");
|
|
68
77
|
this.canvas = canvas;
|
|
78
|
+
this.painter.attach(canvas, () => this.draw());
|
|
69
79
|
this.accessibilityTree = new AccessibilityTreeMounter();
|
|
70
80
|
this.terminalMount.replaceChildren(canvas, this.accessibilityTree.element, this.accessibilityTree.announcerElement);
|
|
71
81
|
this.installInputHandlers();
|
|
@@ -181,35 +191,26 @@ var WebHostSceneRuntime = class {
|
|
|
181
191
|
installInputHandlers() {
|
|
182
192
|
const handleKeyDown = (event) => {
|
|
183
193
|
if (event.metaKey || event.isComposing) return;
|
|
184
|
-
const
|
|
185
|
-
if (!
|
|
186
|
-
this.onInput(
|
|
187
|
-
...key,
|
|
188
|
-
modifiers: modifierMask(event)
|
|
189
|
-
}));
|
|
194
|
+
const message = this.inputEncoder.encodeKey(event);
|
|
195
|
+
if (!message) return;
|
|
196
|
+
this.onInput(message);
|
|
190
197
|
event.preventDefault();
|
|
191
198
|
};
|
|
192
199
|
const handlePaste = (event) => {
|
|
193
200
|
const text = event.clipboardData?.getData("text/plain") ?? "";
|
|
194
201
|
if (!text) return;
|
|
195
|
-
this.onInput(
|
|
202
|
+
this.onInput(this.inputEncoder.encodePaste(text));
|
|
196
203
|
event.preventDefault();
|
|
197
204
|
};
|
|
198
205
|
const handlePointerDown = (event) => {
|
|
199
206
|
const location = this.cellLocation(event);
|
|
200
207
|
if (!location) return;
|
|
201
|
-
const button = pointerButton(event.button);
|
|
208
|
+
const button = this.inputEncoder.pointerButton(event.button);
|
|
202
209
|
this.activePointerButton = button;
|
|
203
210
|
this.hasCapturedPointer = true;
|
|
204
211
|
this.terminalMount.focus?.({ preventScroll: true });
|
|
205
212
|
this.terminalMount.setPointerCapture?.(event.pointerId);
|
|
206
|
-
this.onInput(
|
|
207
|
-
kind: "down",
|
|
208
|
-
x: location.x,
|
|
209
|
-
y: location.y,
|
|
210
|
-
button,
|
|
211
|
-
modifiers: modifierMask(event)
|
|
212
|
-
}));
|
|
213
|
+
this.onInput(this.inputEncoder.encodePointerDown(location, button, event));
|
|
213
214
|
event.preventDefault();
|
|
214
215
|
};
|
|
215
216
|
const handlePointerUp = (event) => {
|
|
@@ -217,39 +218,21 @@ var WebHostSceneRuntime = class {
|
|
|
217
218
|
this.terminalMount.releasePointerCapture?.(event.pointerId);
|
|
218
219
|
this.hasCapturedPointer = false;
|
|
219
220
|
if (!location) return;
|
|
220
|
-
this.
|
|
221
|
-
|
|
222
|
-
x: location.x,
|
|
223
|
-
y: location.y,
|
|
224
|
-
button: pointerButton(event.button) ?? this.activePointerButton,
|
|
225
|
-
modifiers: modifierMask(event)
|
|
226
|
-
}));
|
|
221
|
+
const button = this.inputEncoder.pointerButton(event.button) ?? this.activePointerButton;
|
|
222
|
+
this.onInput(this.inputEncoder.encodePointerUp(location, button, event));
|
|
227
223
|
event.preventDefault();
|
|
228
224
|
};
|
|
229
225
|
const handlePointerMove = (event) => {
|
|
230
226
|
const location = event.buttons && this.hasCapturedPointer ? this.rawCellLocation(event) : this.cellLocation(event);
|
|
231
227
|
if (!location) return;
|
|
232
|
-
this.onInput(
|
|
233
|
-
kind: event.buttons ? "dragged" : "moved",
|
|
234
|
-
x: location.x,
|
|
235
|
-
y: location.y,
|
|
236
|
-
button: this.activePointerButton,
|
|
237
|
-
modifiers: modifierMask(event)
|
|
238
|
-
}));
|
|
228
|
+
this.onInput(this.inputEncoder.encodePointerMove(location, this.activePointerButton, event));
|
|
239
229
|
};
|
|
240
230
|
const handleWheel = (event) => {
|
|
241
231
|
if (this.wheelMode === "passive") return;
|
|
242
232
|
const location = this.cellLocation(event);
|
|
243
233
|
if (!location) return;
|
|
244
|
-
if (this.wheelMode === "chain" && !this.
|
|
245
|
-
this.onInput(
|
|
246
|
-
kind: "scrolled",
|
|
247
|
-
x: location.x,
|
|
248
|
-
y: location.y,
|
|
249
|
-
deltaX: normalizedWheelDelta(event.deltaX),
|
|
250
|
-
deltaY: normalizedWheelDelta(event.deltaY),
|
|
251
|
-
modifiers: modifierMask(event)
|
|
252
|
-
}));
|
|
234
|
+
if (this.wheelMode === "chain" && !wheelTargetCanScroll(this.currentFrame?.scrollRegions, location, event.deltaX, event.deltaY)) return;
|
|
235
|
+
this.onInput(this.inputEncoder.encodeWheel(location, event));
|
|
253
236
|
event.preventDefault();
|
|
254
237
|
};
|
|
255
238
|
this.terminalMount.addEventListener("keydown", handleKeyDown);
|
|
@@ -313,53 +296,12 @@ var WebHostSceneRuntime = class {
|
|
|
313
296
|
this.cellHeight = Math.max(1, Math.round(this.currentStyle.fontSize * 1.35));
|
|
314
297
|
return;
|
|
315
298
|
}
|
|
316
|
-
context.font = this.
|
|
299
|
+
context.font = fontForStyle(this.currentStyle);
|
|
317
300
|
this.cellWidth = Math.max(1, Math.ceil(context.measureText("W").width));
|
|
318
301
|
this.cellHeight = Math.max(1, Math.ceil(this.currentStyle.fontSize * 1.35));
|
|
319
302
|
}
|
|
320
303
|
draw(damage) {
|
|
321
|
-
|
|
322
|
-
const context = canvas?.getContext("2d");
|
|
323
|
-
if (!canvas || !context) return;
|
|
324
|
-
const frame = this.currentFrame;
|
|
325
|
-
const dirtyRegion = frame ? this.dirtyRegionForDamage(damage, frame) : void 0;
|
|
326
|
-
if (dirtyRegion?.rects.length === 0) return;
|
|
327
|
-
const scale = globalThis.window?.devicePixelRatio || 1;
|
|
328
|
-
context.setTransform(scale, 0, 0, scale, 0, 0);
|
|
329
|
-
context.textBaseline = "alphabetic";
|
|
330
|
-
context.fillStyle = webTUITerminalBackgroundColor(this.currentStyle);
|
|
331
|
-
if (dirtyRegion) for (const rect of dirtyRegion.rects) {
|
|
332
|
-
context.clearRect(rect.x, rect.y, rect.width, rect.height);
|
|
333
|
-
context.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
334
|
-
}
|
|
335
|
-
else {
|
|
336
|
-
context.clearRect(0, 0, canvas.width / scale, canvas.height / scale);
|
|
337
|
-
context.fillRect(0, 0, this.columns * this.cellWidth, this.rows * this.cellHeight);
|
|
338
|
-
}
|
|
339
|
-
if (!frame) return;
|
|
340
|
-
this.drawRows(context, frame, dirtyRegion);
|
|
341
|
-
this.drawImages(context, frame.images ?? [], dirtyRegion);
|
|
342
|
-
}
|
|
343
|
-
drawRows(context, frame, dirtyRegion) {
|
|
344
|
-
if (dirtyRegion) {
|
|
345
|
-
for (const [y, ranges] of dirtyRegion.rows) {
|
|
346
|
-
const row = frame.rows[y] ?? [];
|
|
347
|
-
this.drawRow(context, frame, row, y, ranges);
|
|
348
|
-
}
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
for (let y = 0; y < frame.rows.length; y += 1) {
|
|
352
|
-
const row = frame.rows[y] ?? [];
|
|
353
|
-
this.drawRow(context, frame, row, y);
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
drawRow(context, frame, row, y, ranges) {
|
|
357
|
-
for (const cell of row) {
|
|
358
|
-
const [x, text, span, styleIndex] = cell;
|
|
359
|
-
if (ranges !== void 0 && !cellIntersectsRanges(x, span, ranges)) continue;
|
|
360
|
-
const style = frame.styles[styleIndex] ?? void 0;
|
|
361
|
-
this.drawCell(context, x, y, text, span, style);
|
|
362
|
-
}
|
|
304
|
+
this.painter.paint(this.surfaceMetrics(), this.currentFrame, damage);
|
|
363
305
|
}
|
|
364
306
|
syncAccessibilityTree() {
|
|
365
307
|
const tree = this.accessibilityTree;
|
|
@@ -369,292 +311,31 @@ var WebHostSceneRuntime = class {
|
|
|
369
311
|
cellHeight: this.cellHeight
|
|
370
312
|
}, this.currentFrame.accessibilityAnnouncements ?? [], { synchronizeFocus: this.synchronizeAccessibilityFocus });
|
|
371
313
|
}
|
|
372
|
-
|
|
373
|
-
for (const image of images) this.drawImage(context, image, dirtyRegion);
|
|
374
|
-
}
|
|
375
|
-
drawImage(context, image, dirtyRegion) {
|
|
376
|
-
const decodedImage = this.cachedImage(image);
|
|
377
|
-
if (!decodedImage) return;
|
|
378
|
-
const [boundsX, boundsY, boundsWidth, boundsHeight] = image.bounds;
|
|
379
|
-
const [clipX, clipY, clipWidth, clipHeight] = image.visibleBounds;
|
|
380
|
-
if (boundsWidth <= 0 || boundsHeight <= 0 || clipWidth <= 0 || clipHeight <= 0) return;
|
|
381
|
-
if (dirtyRegion && !dirtyRegionIntersectsCellRect(dirtyRegion, clipX, clipY, clipWidth, clipHeight)) return;
|
|
382
|
-
context.save();
|
|
383
|
-
context.beginPath();
|
|
384
|
-
context.rect(clipX * this.cellWidth, clipY * this.cellHeight, clipWidth * this.cellWidth, clipHeight * this.cellHeight);
|
|
385
|
-
context.clip();
|
|
386
|
-
context.drawImage(decodedImage, boundsX * this.cellWidth, boundsY * this.cellHeight, boundsWidth * this.cellWidth, boundsHeight * this.cellHeight);
|
|
387
|
-
context.restore();
|
|
388
|
-
}
|
|
389
|
-
cachedImage(image) {
|
|
390
|
-
const cached = this.imageCache.get(image.id);
|
|
391
|
-
if (cached?.image) return cached.image;
|
|
392
|
-
if (!cached?.promise && image.dataBase64) {
|
|
393
|
-
const promise = decodeImage(image.dataBase64, image.format);
|
|
394
|
-
this.imageCache.set(image.id, { promise });
|
|
395
|
-
promise.then((decodedImage) => {
|
|
396
|
-
if (this.imageCache.get(image.id)?.promise !== promise) return;
|
|
397
|
-
this.imageCache.set(image.id, { image: decodedImage });
|
|
398
|
-
this.draw();
|
|
399
|
-
}).catch(() => {
|
|
400
|
-
this.imageCache.delete(image.id);
|
|
401
|
-
});
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
drawCell(context, x, y, text, span, style) {
|
|
405
|
-
const rectX = x * this.cellWidth;
|
|
406
|
-
const rectY = y * this.cellHeight;
|
|
407
|
-
const width = Math.max(1, span) * this.cellWidth;
|
|
408
|
-
const background = resolvedBackground(style, this.currentStyle);
|
|
409
|
-
const foreground = resolvedForeground(style, this.currentStyle);
|
|
410
|
-
const opacity = style?.opacity ?? 1;
|
|
411
|
-
if (background) {
|
|
412
|
-
context.globalAlpha = opacity;
|
|
413
|
-
context.fillStyle = background;
|
|
414
|
-
context.fillRect(rectX, rectY, width, this.cellHeight);
|
|
415
|
-
}
|
|
416
|
-
if (text !== " ") {
|
|
417
|
-
context.globalAlpha = opacity;
|
|
418
|
-
context.fillStyle = foreground;
|
|
419
|
-
context.strokeStyle = foreground;
|
|
420
|
-
if (!canRenderBoxDrawing(text) || !drawBoxDrawing(context, text, {
|
|
421
|
-
x: rectX,
|
|
422
|
-
y: rectY,
|
|
423
|
-
width,
|
|
424
|
-
height: this.cellHeight
|
|
425
|
-
})) {
|
|
426
|
-
context.font = this.fontForStyle(style);
|
|
427
|
-
context.fillText(text, rectX, rectY + Math.floor((this.cellHeight + this.currentStyle.fontSize) / 2) - 2);
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
this.drawTextLine(context, rectX, rectY, width, style?.underline, "underline", foreground);
|
|
431
|
-
this.drawTextLine(context, rectX, rectY, width, style?.strikethrough, "strike", foreground);
|
|
432
|
-
context.globalAlpha = 1;
|
|
433
|
-
}
|
|
434
|
-
dirtyRegionForDamage(damage, frame) {
|
|
435
|
-
if (!damage || damage.requiresFullTextRepaint || damage.requiresFullGraphicsReplay) return;
|
|
436
|
-
const rects = [];
|
|
437
|
-
const rows = /* @__PURE__ */ new Map();
|
|
438
|
-
for (const [row, ranges] of damage.textRows) {
|
|
439
|
-
if (row < 0 || row >= frame.height) continue;
|
|
440
|
-
if (ranges.length === 0) {
|
|
441
|
-
rects.push(this.cellRect(0, row, frame.width));
|
|
442
|
-
rows.set(row, "full");
|
|
443
|
-
continue;
|
|
444
|
-
}
|
|
445
|
-
const rowRanges = rows.get(row) === "full" ? [] : [...rows.get(row) ?? []];
|
|
446
|
-
for (const [start, end] of ranges) {
|
|
447
|
-
const lowerBound = Math.max(0, Math.min(frame.width, Math.floor(start)));
|
|
448
|
-
const upperBound = Math.max(lowerBound, Math.min(frame.width, Math.ceil(end)));
|
|
449
|
-
if (lowerBound >= upperBound) continue;
|
|
450
|
-
rects.push(this.cellRect(lowerBound, row, upperBound - lowerBound));
|
|
451
|
-
rowRanges.push({
|
|
452
|
-
start: lowerBound,
|
|
453
|
-
end: upperBound
|
|
454
|
-
});
|
|
455
|
-
}
|
|
456
|
-
if (rows.get(row) !== "full" && rowRanges.length > 0) rows.set(row, normalizeCellRanges(rowRanges));
|
|
457
|
-
}
|
|
314
|
+
surfaceMetrics() {
|
|
458
315
|
return {
|
|
459
|
-
|
|
460
|
-
rows
|
|
316
|
+
columns: this.columns,
|
|
317
|
+
rows: this.rows,
|
|
318
|
+
cellWidth: this.cellWidth,
|
|
319
|
+
cellHeight: this.cellHeight,
|
|
320
|
+
style: this.currentStyle
|
|
461
321
|
};
|
|
462
322
|
}
|
|
463
|
-
|
|
323
|
+
pointerMetrics() {
|
|
464
324
|
return {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
325
|
+
rect: this.canvas?.getBoundingClientRect?.() ?? this.terminalMount.getBoundingClientRect?.(),
|
|
326
|
+
cellWidth: this.cellWidth,
|
|
327
|
+
cellHeight: this.cellHeight,
|
|
328
|
+
columns: this.columns,
|
|
329
|
+
rows: this.rows
|
|
469
330
|
};
|
|
470
331
|
}
|
|
471
|
-
drawTextLine(context, x, y, width, line, placement, fallbackColor) {
|
|
472
|
-
if (!line) return;
|
|
473
|
-
context.strokeStyle = line.color ?? fallbackColor;
|
|
474
|
-
context.lineWidth = line.pattern === "double" ? 2 : 1;
|
|
475
|
-
if (line.pattern === "dot") context.setLineDash([1, 3]);
|
|
476
|
-
else if (line.pattern === "dash") context.setLineDash([4, 3]);
|
|
477
|
-
else context.setLineDash([]);
|
|
478
|
-
const lineY = placement === "underline" ? y + this.cellHeight - 2 : y + Math.floor(this.cellHeight / 2);
|
|
479
|
-
context.beginPath();
|
|
480
|
-
context.moveTo(x, lineY);
|
|
481
|
-
context.lineTo(x + width, lineY);
|
|
482
|
-
context.stroke();
|
|
483
|
-
context.setLineDash([]);
|
|
484
|
-
}
|
|
485
|
-
fontForStyle(style) {
|
|
486
|
-
const emphasis = style?.em ?? 0;
|
|
487
|
-
return `${(emphasis & 2) !== 0 ? "italic " : ""}${(emphasis & 1) !== 0 ? "700 " : ""}${this.currentStyle.fontSize}px ${this.currentStyle.fontFamily}`;
|
|
488
|
-
}
|
|
489
|
-
/**
|
|
490
|
-
* Whether any scrollable region under `location` can still scroll in the
|
|
491
|
-
* wheel's direction. Mirrors the Swift host's scroll hit-test: a region
|
|
492
|
-
* qualifies when its viewport contains the cell AND it has remaining headroom
|
|
493
|
-
* in the delta's direction. Used by "chain" wheel mode to decide capture vs.
|
|
494
|
-
* fall-through. With no published `scrollRegions`, nothing can scroll, so the
|
|
495
|
-
* wheel chains to the page (a scene with no ScrollView stays fully passive).
|
|
496
|
-
*/
|
|
497
|
-
wheelTargetCanScroll(location, deltaX, deltaY) {
|
|
498
|
-
const regions = this.currentFrame?.scrollRegions;
|
|
499
|
-
if (!regions || regions.length === 0) return false;
|
|
500
|
-
const cellX = Math.floor(location.x);
|
|
501
|
-
const cellY = Math.floor(location.y);
|
|
502
|
-
for (const region of regions) {
|
|
503
|
-
const [rx, ry, rw, rh] = region.rect;
|
|
504
|
-
if (cellX < rx || cellY < ry || cellX >= rx + rw || cellY >= ry + rh) continue;
|
|
505
|
-
if (regionCanScrollInDirection(region, deltaX, deltaY)) return true;
|
|
506
|
-
}
|
|
507
|
-
return false;
|
|
508
|
-
}
|
|
509
332
|
cellLocation(event) {
|
|
510
|
-
|
|
511
|
-
if (!location) return;
|
|
512
|
-
const cellX = Math.floor(location.x);
|
|
513
|
-
const cellY = Math.floor(location.y);
|
|
514
|
-
if (cellX < 0 || cellY < 0 || cellX >= this.columns || cellY >= this.rows) return;
|
|
515
|
-
return location;
|
|
333
|
+
return cellLocationForEvent(event, this.pointerMetrics());
|
|
516
334
|
}
|
|
517
335
|
rawCellLocation(event) {
|
|
518
|
-
|
|
519
|
-
if (!rect) return;
|
|
520
|
-
return {
|
|
521
|
-
x: (event.clientX - rect.left) / this.cellWidth,
|
|
522
|
-
y: (event.clientY - rect.top) / this.cellHeight
|
|
523
|
-
};
|
|
336
|
+
return rawCellLocationForEvent(event, this.pointerMetrics());
|
|
524
337
|
}
|
|
525
338
|
};
|
|
526
|
-
async function decodeImage(dataBase64, format) {
|
|
527
|
-
const bytes = decodeBase64Bytes(dataBase64);
|
|
528
|
-
const blob = new Blob([bytes], { type: `image/${format}` });
|
|
529
|
-
if (typeof createImageBitmap === "function") return createImageBitmap(blob);
|
|
530
|
-
return new Promise((resolve, reject) => {
|
|
531
|
-
const image = new Image();
|
|
532
|
-
const url = URL.createObjectURL(blob);
|
|
533
|
-
image.onload = () => {
|
|
534
|
-
URL.revokeObjectURL(url);
|
|
535
|
-
resolve(image);
|
|
536
|
-
};
|
|
537
|
-
image.onerror = () => {
|
|
538
|
-
URL.revokeObjectURL(url);
|
|
539
|
-
reject(/* @__PURE__ */ new Error(`Failed to decode ${format} image`));
|
|
540
|
-
};
|
|
541
|
-
image.src = url;
|
|
542
|
-
});
|
|
543
|
-
}
|
|
544
|
-
function decodeBase64Bytes(value) {
|
|
545
|
-
if (typeof atob === "function") {
|
|
546
|
-
const binary = atob(value);
|
|
547
|
-
const bytes = new Uint8Array(binary.length);
|
|
548
|
-
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
|
549
|
-
return bytes;
|
|
550
|
-
}
|
|
551
|
-
return new Uint8Array(Buffer.from(value, "base64"));
|
|
552
|
-
}
|
|
553
|
-
function keyInputFromKeyboardEvent(event) {
|
|
554
|
-
switch (event.key) {
|
|
555
|
-
case "Enter": return { key: "return" };
|
|
556
|
-
case " ": return { key: "space" };
|
|
557
|
-
case "Tab": return { key: "tab" };
|
|
558
|
-
case "ArrowLeft": return { key: "arrowLeft" };
|
|
559
|
-
case "ArrowRight": return { key: "arrowRight" };
|
|
560
|
-
case "ArrowUp": return { key: "arrowUp" };
|
|
561
|
-
case "ArrowDown": return { key: "arrowDown" };
|
|
562
|
-
case "Backspace": return { key: "backspace" };
|
|
563
|
-
case "Escape": return { key: "escape" };
|
|
564
|
-
case "Home": return { key: "home" };
|
|
565
|
-
case "End": return { key: "end" };
|
|
566
|
-
default: {
|
|
567
|
-
const characters = Array.from(event.key);
|
|
568
|
-
if (characters.length !== 1) return;
|
|
569
|
-
return {
|
|
570
|
-
key: "character",
|
|
571
|
-
character: characters[0]
|
|
572
|
-
};
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
function pointerButton(button) {
|
|
577
|
-
switch (button) {
|
|
578
|
-
case 1: return "middle";
|
|
579
|
-
case 2: return "secondary";
|
|
580
|
-
default: return "primary";
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
function modifierMask(event) {
|
|
584
|
-
let mask = 0;
|
|
585
|
-
if (event.shiftKey) mask |= 1;
|
|
586
|
-
if (event.altKey) mask |= 2;
|
|
587
|
-
if (event.ctrlKey) mask |= 4;
|
|
588
|
-
return mask;
|
|
589
|
-
}
|
|
590
|
-
function normalizedWheelDelta(delta) {
|
|
591
|
-
if (delta > 0) return 1;
|
|
592
|
-
if (delta < 0) return -1;
|
|
593
|
-
return 0;
|
|
594
|
-
}
|
|
595
|
-
/**
|
|
596
|
-
* Whether a published scroll region has remaining headroom in the wheel's
|
|
597
|
-
* direction, recomputing the per-direction extent from offset/content/viewport.
|
|
598
|
-
* Mirrors SwiftTUI's clamp (`min(max(0, offset), max(0, content - viewport))`)
|
|
599
|
-
* so the host and the app agree on "at edge". Wheel sign convention matches the
|
|
600
|
-
* app: `deltaY > 0` scrolls down (offset grows toward the content bottom).
|
|
601
|
-
* Diagonal wheels qualify if either axis has headroom.
|
|
602
|
-
*/
|
|
603
|
-
function regionCanScrollInDirection(region, deltaX, deltaY) {
|
|
604
|
-
const [, , viewportWidth, viewportHeight] = region.rect;
|
|
605
|
-
const [offsetX, offsetY] = region.offset;
|
|
606
|
-
const [contentWidth, contentHeight] = region.content;
|
|
607
|
-
const maxX = Math.max(0, contentWidth - viewportWidth);
|
|
608
|
-
const maxY = Math.max(0, contentHeight - viewportHeight);
|
|
609
|
-
const clampedX = Math.min(Math.max(0, offsetX), maxX);
|
|
610
|
-
const clampedY = Math.min(Math.max(0, offsetY), maxY);
|
|
611
|
-
if (deltaY > 0 && clampedY < maxY) return true;
|
|
612
|
-
if (deltaY < 0 && clampedY > 0) return true;
|
|
613
|
-
if (deltaX > 0 && clampedX < maxX) return true;
|
|
614
|
-
if (deltaX < 0 && clampedX > 0) return true;
|
|
615
|
-
return false;
|
|
616
|
-
}
|
|
617
|
-
function normalizeCellRanges(ranges) {
|
|
618
|
-
const sorted = ranges.filter((range) => range.end > range.start).sort((lhs, rhs) => lhs.start - rhs.start || lhs.end - rhs.end);
|
|
619
|
-
const normalized = [];
|
|
620
|
-
for (const range of sorted) {
|
|
621
|
-
const previous = normalized[normalized.length - 1];
|
|
622
|
-
if (previous && range.start <= previous.end) {
|
|
623
|
-
previous.end = Math.max(previous.end, range.end);
|
|
624
|
-
continue;
|
|
625
|
-
}
|
|
626
|
-
normalized.push({ ...range });
|
|
627
|
-
}
|
|
628
|
-
return normalized;
|
|
629
|
-
}
|
|
630
|
-
function cellIntersectsRanges(x, span, ranges) {
|
|
631
|
-
if (ranges === "full") return true;
|
|
632
|
-
const start = Math.floor(x);
|
|
633
|
-
const end = start + Math.max(1, Math.ceil(span));
|
|
634
|
-
return ranges.some((range) => start < range.end && end > range.start);
|
|
635
|
-
}
|
|
636
|
-
function dirtyRegionIntersectsCellRect(region, x, y, width, height) {
|
|
637
|
-
const startRow = Math.max(0, Math.floor(y));
|
|
638
|
-
const endRow = Math.max(startRow, Math.ceil(y + height));
|
|
639
|
-
const rectRange = {
|
|
640
|
-
start: Math.floor(x),
|
|
641
|
-
end: Math.floor(x) + Math.max(1, Math.ceil(width))
|
|
642
|
-
};
|
|
643
|
-
for (let row = startRow; row < endRow; row += 1) {
|
|
644
|
-
const ranges = region.rows.get(row);
|
|
645
|
-
if (!ranges) continue;
|
|
646
|
-
if (cellIntersectsRanges(rectRange.start, rectRange.end - rectRange.start, ranges)) return true;
|
|
647
|
-
}
|
|
648
|
-
return false;
|
|
649
|
-
}
|
|
650
|
-
function resolvedForeground(style, terminalStyle) {
|
|
651
|
-
if ((style?.em ?? 0) & 16) return style?.bg ?? terminalStyle.theme.background;
|
|
652
|
-
return style?.fg ?? terminalStyle.theme.foreground;
|
|
653
|
-
}
|
|
654
|
-
function resolvedBackground(style, terminalStyle) {
|
|
655
|
-
if ((style?.em ?? 0) & 16) return style?.fg ?? terminalStyle.theme.foreground;
|
|
656
|
-
return style?.bg;
|
|
657
|
-
}
|
|
658
339
|
//#endregion
|
|
659
340
|
export { WebHostSceneRuntime };
|
|
660
341
|
|