@swifttui/web 0.0.9 → 0.0.12
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/package.json +1 -1
- package/src/WebHostSceneRuntime.test.ts +231 -1
- package/src/WebHostSceneRuntime.ts +225 -38
- package/src/WebHostSurfaceTransport.ts +45 -2
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
} from "./wasi/BrowserWASIBridge.ts";
|
|
8
8
|
import { SharedInputQueueReader } from "./wasi/SharedInputQueue.ts";
|
|
9
9
|
import { createWasmSceneRuntimeFactory } from "./wasi/WasmSceneRuntime.ts";
|
|
10
|
-
import { WebHostSceneRuntime } from "./WebHostSceneRuntime.ts";
|
|
10
|
+
import { WebHostSceneRuntime, type WheelMode } from "./WebHostSceneRuntime.ts";
|
|
11
11
|
import { transportFixture } from "./WebHostTestFixtures.ts";
|
|
12
12
|
|
|
13
13
|
const encoder = new TextEncoder();
|
|
@@ -216,6 +216,72 @@ test("runtime redraws only damaged cells when a compatible frame includes damage
|
|
|
216
216
|
}
|
|
217
217
|
});
|
|
218
218
|
|
|
219
|
+
test("runtime redraws spanning cells that overlap a dirty range", async () => {
|
|
220
|
+
const dom = installFakeDOM();
|
|
221
|
+
try {
|
|
222
|
+
const bridge = new BrowserWASIBridge({
|
|
223
|
+
sceneId: "main",
|
|
224
|
+
columns: 6,
|
|
225
|
+
rows: 1,
|
|
226
|
+
});
|
|
227
|
+
const mount = new FakeElement("div");
|
|
228
|
+
const runtime = new WebHostSceneRuntime({
|
|
229
|
+
mount: mount as unknown as HTMLElement,
|
|
230
|
+
descriptor: { id: "main", title: "Main", isDefault: true },
|
|
231
|
+
style: {
|
|
232
|
+
fontSize: 20,
|
|
233
|
+
fontFamily: "Test Mono",
|
|
234
|
+
},
|
|
235
|
+
bridge,
|
|
236
|
+
onInput: () => {},
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await runtime.mount();
|
|
240
|
+
|
|
241
|
+
const canvas = dom.canvases[0]!;
|
|
242
|
+
const context = canvas.context;
|
|
243
|
+
bridge.stdout.write(encoder.encode(surfaceRecord({
|
|
244
|
+
version: 1,
|
|
245
|
+
width: 6,
|
|
246
|
+
height: 1,
|
|
247
|
+
styles: [null],
|
|
248
|
+
rows: [
|
|
249
|
+
[[0, "Wide", 4, 0], [4, "Z", 1, 0]],
|
|
250
|
+
],
|
|
251
|
+
images: [],
|
|
252
|
+
})));
|
|
253
|
+
|
|
254
|
+
context.operations = [];
|
|
255
|
+
bridge.stdout.write(encoder.encode(surfaceRecord({
|
|
256
|
+
version: 1,
|
|
257
|
+
width: 6,
|
|
258
|
+
height: 1,
|
|
259
|
+
styles: [null],
|
|
260
|
+
rows: [
|
|
261
|
+
[[0, "wide", 4, 0], [4, "Z", 1, 0]],
|
|
262
|
+
],
|
|
263
|
+
images: [],
|
|
264
|
+
damage: {
|
|
265
|
+
textRows: [[0, [[2, 3]]]],
|
|
266
|
+
requiresFullTextRepaint: false,
|
|
267
|
+
requiresFullGraphicsReplay: false,
|
|
268
|
+
},
|
|
269
|
+
})));
|
|
270
|
+
|
|
271
|
+
expect(context.operations).toContainEqual({
|
|
272
|
+
type: "clearRect",
|
|
273
|
+
x: 20,
|
|
274
|
+
y: 0,
|
|
275
|
+
width: 10,
|
|
276
|
+
height: 27,
|
|
277
|
+
});
|
|
278
|
+
expect(fillTextOperations(context, "wide")).toHaveLength(1);
|
|
279
|
+
expect(fillTextOperations(context, "Z")).toEqual([]);
|
|
280
|
+
} finally {
|
|
281
|
+
dom.restore();
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
219
285
|
test("runtime clears stale overlay text when dirty rects remove an overlay", async () => {
|
|
220
286
|
const dom = installFakeDOM();
|
|
221
287
|
try {
|
|
@@ -1145,6 +1211,114 @@ test("runtime can run as a passive embed without stealing focus or wheel scroll"
|
|
|
1145
1211
|
}
|
|
1146
1212
|
});
|
|
1147
1213
|
|
|
1214
|
+
test("chain mode captures the wheel when a region under the pointer can scroll", async () => {
|
|
1215
|
+
const dom = installFakeDOM();
|
|
1216
|
+
try {
|
|
1217
|
+
// Region covers the whole 4x2 surface; content is taller than the viewport
|
|
1218
|
+
// and scrolled to the top, so a downward wheel has headroom.
|
|
1219
|
+
const result = await wheelScenario({
|
|
1220
|
+
wheelMode: "chain",
|
|
1221
|
+
scrollRegions: [{ id: "list", rect: [0, 0, 4, 2], offset: [0, 0], content: [4, 10] }],
|
|
1222
|
+
wheel: { clientX: 5, clientY: 5, deltaY: 20 },
|
|
1223
|
+
});
|
|
1224
|
+
expect(result.captured).toBe(true);
|
|
1225
|
+
expect(result.wheelPrevented).toBe(true);
|
|
1226
|
+
} finally {
|
|
1227
|
+
dom.restore();
|
|
1228
|
+
}
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
test("chain mode lets the wheel fall through at the region's scroll edge", async () => {
|
|
1232
|
+
const dom = installFakeDOM();
|
|
1233
|
+
try {
|
|
1234
|
+
// Same region, but scrolled to the bottom (offset.y == maxY == 10 - 2),
|
|
1235
|
+
// so a further downward wheel has no headroom and must chain to the page.
|
|
1236
|
+
const result = await wheelScenario({
|
|
1237
|
+
wheelMode: "chain",
|
|
1238
|
+
scrollRegions: [{ id: "list", rect: [0, 0, 4, 2], offset: [0, 8], content: [4, 10] }],
|
|
1239
|
+
wheel: { clientX: 5, clientY: 5, deltaY: 20 },
|
|
1240
|
+
});
|
|
1241
|
+
expect(result.captured).toBe(false);
|
|
1242
|
+
expect(result.wheelPrevented).toBe(false);
|
|
1243
|
+
} finally {
|
|
1244
|
+
dom.restore();
|
|
1245
|
+
}
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
test("chain mode captures an upward wheel when scrolled away from the top", async () => {
|
|
1249
|
+
const dom = installFakeDOM();
|
|
1250
|
+
try {
|
|
1251
|
+
// At the bottom edge, downward chains but upward still has headroom.
|
|
1252
|
+
const result = await wheelScenario({
|
|
1253
|
+
wheelMode: "chain",
|
|
1254
|
+
scrollRegions: [{ id: "list", rect: [0, 0, 4, 2], offset: [0, 8], content: [4, 10] }],
|
|
1255
|
+
wheel: { clientX: 5, clientY: 5, deltaY: -20 },
|
|
1256
|
+
});
|
|
1257
|
+
expect(result.captured).toBe(true);
|
|
1258
|
+
expect(result.wheelPrevented).toBe(true);
|
|
1259
|
+
} finally {
|
|
1260
|
+
dom.restore();
|
|
1261
|
+
}
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
test("chain mode falls through when the pointer is outside every scroll region", async () => {
|
|
1265
|
+
const dom = installFakeDOM();
|
|
1266
|
+
try {
|
|
1267
|
+
// Region only covers the right half (cells x>=2); wheel at cell (0,0).
|
|
1268
|
+
const result = await wheelScenario({
|
|
1269
|
+
wheelMode: "chain",
|
|
1270
|
+
scrollRegions: [{ id: "list", rect: [2, 0, 2, 2], offset: [0, 0], content: [2, 10] }],
|
|
1271
|
+
wheel: { clientX: 5, clientY: 5, deltaY: 20 },
|
|
1272
|
+
});
|
|
1273
|
+
expect(result.captured).toBe(false);
|
|
1274
|
+
expect(result.wheelPrevented).toBe(false);
|
|
1275
|
+
} finally {
|
|
1276
|
+
dom.restore();
|
|
1277
|
+
}
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1280
|
+
test("chain mode falls through when the scene publishes no scroll regions", async () => {
|
|
1281
|
+
const dom = installFakeDOM();
|
|
1282
|
+
try {
|
|
1283
|
+
const result = await wheelScenario({
|
|
1284
|
+
wheelMode: "chain",
|
|
1285
|
+
wheel: { clientX: 5, clientY: 5, deltaY: 20 },
|
|
1286
|
+
});
|
|
1287
|
+
expect(result.captured).toBe(false);
|
|
1288
|
+
expect(result.wheelPrevented).toBe(false);
|
|
1289
|
+
} finally {
|
|
1290
|
+
dom.restore();
|
|
1291
|
+
}
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
test("capture mode always eats the wheel even without scroll regions", async () => {
|
|
1295
|
+
const dom = installFakeDOM();
|
|
1296
|
+
try {
|
|
1297
|
+
const result = await wheelScenario({
|
|
1298
|
+
wheelMode: "capture",
|
|
1299
|
+
wheel: { clientX: 5, clientY: 5, deltaY: 20 },
|
|
1300
|
+
});
|
|
1301
|
+
expect(result.captured).toBe(true);
|
|
1302
|
+
expect(result.wheelPrevented).toBe(true);
|
|
1303
|
+
} finally {
|
|
1304
|
+
dom.restore();
|
|
1305
|
+
}
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
test("legacy captureWheelInput:true maps to capture mode", async () => {
|
|
1309
|
+
const dom = installFakeDOM();
|
|
1310
|
+
try {
|
|
1311
|
+
const result = await wheelScenario({
|
|
1312
|
+
captureWheelInput: true,
|
|
1313
|
+
wheel: { clientX: 5, clientY: 5, deltaY: 20 },
|
|
1314
|
+
});
|
|
1315
|
+
expect(result.captured).toBe(true);
|
|
1316
|
+
expect(result.wheelPrevented).toBe(true);
|
|
1317
|
+
} finally {
|
|
1318
|
+
dom.restore();
|
|
1319
|
+
}
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1148
1322
|
test("runtime preserves pointer movement within one cell", async () => {
|
|
1149
1323
|
const dom = installFakeDOM();
|
|
1150
1324
|
try {
|
|
@@ -1377,6 +1551,62 @@ async function flushPromises(): Promise<void> {
|
|
|
1377
1551
|
await Promise.resolve();
|
|
1378
1552
|
}
|
|
1379
1553
|
|
|
1554
|
+
// Drives a single wheel event over a 4x2 surface (cellWidth 10, cellHeight 27
|
|
1555
|
+
// under the fake DOM) with the given wheel mode and published scroll regions,
|
|
1556
|
+
// and reports whether the wheel was forwarded to the app and/or preventDefault'd.
|
|
1557
|
+
// Assumes a fake DOM is already installed by the caller.
|
|
1558
|
+
async function wheelScenario(options: {
|
|
1559
|
+
wheelMode?: WheelMode;
|
|
1560
|
+
captureWheelInput?: boolean;
|
|
1561
|
+
scrollRegions?: Array<Record<string, unknown>>;
|
|
1562
|
+
wheel: { clientX: number; clientY: number; deltaX?: number; deltaY?: number };
|
|
1563
|
+
}): Promise<{ captured: boolean; wheelPrevented: boolean }> {
|
|
1564
|
+
const inputs: string[] = [];
|
|
1565
|
+
const bridge = new BrowserWASIBridge({ sceneId: "main", columns: 4, rows: 2 });
|
|
1566
|
+
const mount = new FakeElement("div");
|
|
1567
|
+
const runtime = new WebHostSceneRuntime({
|
|
1568
|
+
mount: mount as unknown as HTMLElement,
|
|
1569
|
+
descriptor: { id: "main", title: "Main", isDefault: true },
|
|
1570
|
+
style: { fontSize: 20 },
|
|
1571
|
+
bridge,
|
|
1572
|
+
onInput: (chunk) => {
|
|
1573
|
+
inputs.push(decoder.decode(chunk));
|
|
1574
|
+
},
|
|
1575
|
+
synchronizeAccessibilityFocus: false,
|
|
1576
|
+
wheelMode: options.wheelMode,
|
|
1577
|
+
captureWheelInput: options.captureWheelInput,
|
|
1578
|
+
});
|
|
1579
|
+
|
|
1580
|
+
await runtime.mount();
|
|
1581
|
+
const frame: Record<string, unknown> = {
|
|
1582
|
+
version: 2,
|
|
1583
|
+
width: 4,
|
|
1584
|
+
height: 2,
|
|
1585
|
+
styles: [null],
|
|
1586
|
+
rows: [[], []],
|
|
1587
|
+
};
|
|
1588
|
+
if (options.scrollRegions) {
|
|
1589
|
+
frame.scrollRegions = options.scrollRegions;
|
|
1590
|
+
}
|
|
1591
|
+
bridge.stdout.write(encoder.encode(surfaceRecord(frame)));
|
|
1592
|
+
|
|
1593
|
+
let wheelPrevented = false;
|
|
1594
|
+
runtime.terminalMount.dispatch("wheel", {
|
|
1595
|
+
clientX: options.wheel.clientX,
|
|
1596
|
+
clientY: options.wheel.clientY,
|
|
1597
|
+
deltaX: options.wheel.deltaX ?? 0,
|
|
1598
|
+
deltaY: options.wheel.deltaY ?? 0,
|
|
1599
|
+
shiftKey: false,
|
|
1600
|
+
altKey: false,
|
|
1601
|
+
ctrlKey: false,
|
|
1602
|
+
preventDefault() {
|
|
1603
|
+
wheelPrevented = true;
|
|
1604
|
+
},
|
|
1605
|
+
});
|
|
1606
|
+
|
|
1607
|
+
return { captured: inputs.some((i) => i.includes("scrolled")), wheelPrevented };
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1380
1610
|
function surfaceRecord(
|
|
1381
1611
|
frame: Record<string, unknown>
|
|
1382
1612
|
): string {
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type WebHostOutputSink,
|
|
19
19
|
type WebHostKeyInput,
|
|
20
20
|
type WebHostRuntimeIssue,
|
|
21
|
+
type WebHostScrollRegion,
|
|
21
22
|
type WebHostSurfaceDamage,
|
|
22
23
|
type WebHostSurfaceFrame,
|
|
23
24
|
type WebHostSurfaceImage,
|
|
@@ -42,9 +43,28 @@ export interface WebHostSceneRuntimeOptions {
|
|
|
42
43
|
onInput(chunk: Uint8Array): void;
|
|
43
44
|
onFrameDiagnostic?: (diagnostic: WebHostFrameDiagnosticRecord) => void;
|
|
44
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
|
+
*/
|
|
45
63
|
captureWheelInput?: boolean;
|
|
46
64
|
}
|
|
47
65
|
|
|
66
|
+
export type WheelMode = "capture" | "chain" | "passive";
|
|
67
|
+
|
|
48
68
|
interface CachedWebHostImage {
|
|
49
69
|
image?: CanvasImageSource;
|
|
50
70
|
promise?: Promise<CanvasImageSource>;
|
|
@@ -57,6 +77,18 @@ interface DirtyRect {
|
|
|
57
77
|
height: number;
|
|
58
78
|
}
|
|
59
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
|
+
|
|
60
92
|
export class WebHostSceneRuntime {
|
|
61
93
|
readonly descriptor: WebHostSceneDescriptor;
|
|
62
94
|
readonly element: HTMLElement;
|
|
@@ -66,7 +98,7 @@ export class WebHostSceneRuntime {
|
|
|
66
98
|
private readonly onInput: (chunk: Uint8Array) => void;
|
|
67
99
|
private readonly onFrameDiagnostic?: (diagnostic: WebHostFrameDiagnosticRecord) => void;
|
|
68
100
|
private readonly synchronizeAccessibilityFocus: boolean;
|
|
69
|
-
private readonly
|
|
101
|
+
private readonly wheelMode: WheelMode;
|
|
70
102
|
private readonly imageCache = new Map<string, CachedWebHostImage>();
|
|
71
103
|
private currentStyle: ResolvedWebHostTerminalStyle;
|
|
72
104
|
private canvas?: HTMLCanvasElement;
|
|
@@ -96,7 +128,8 @@ export class WebHostSceneRuntime {
|
|
|
96
128
|
this.onInput = options.onInput;
|
|
97
129
|
this.onFrameDiagnostic = options.onFrameDiagnostic;
|
|
98
130
|
this.synchronizeAccessibilityFocus = options.synchronizeAccessibilityFocus ?? true;
|
|
99
|
-
this.
|
|
131
|
+
this.wheelMode = options.wheelMode
|
|
132
|
+
?? (options.captureWheelInput === false ? "passive" : "capture");
|
|
100
133
|
this.element = document.createElement("section");
|
|
101
134
|
this.element.className = "webhost-scene";
|
|
102
135
|
this.element.dataset.sceneId = options.descriptor.id;
|
|
@@ -262,6 +295,9 @@ export class WebHostSceneRuntime {
|
|
|
262
295
|
|
|
263
296
|
this.terminalMount.style.position = "relative";
|
|
264
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";
|
|
265
301
|
this.terminalMount.style.outline = "none";
|
|
266
302
|
this.terminalMount.style.background = webTUITerminalBackgroundColor(this.currentStyle);
|
|
267
303
|
this.terminalMount.style.minHeight = `${this.cellHeight * 8}px`;
|
|
@@ -378,12 +414,23 @@ export class WebHostSceneRuntime {
|
|
|
378
414
|
};
|
|
379
415
|
|
|
380
416
|
const handleWheel = (event: WheelEvent) => {
|
|
381
|
-
if (
|
|
417
|
+
if (this.wheelMode === "passive") {
|
|
382
418
|
return;
|
|
383
419
|
}
|
|
384
420
|
|
|
385
421
|
const location = this.cellLocation(event);
|
|
386
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)) {
|
|
387
434
|
return;
|
|
388
435
|
}
|
|
389
436
|
|
|
@@ -500,8 +547,8 @@ export class WebHostSceneRuntime {
|
|
|
500
547
|
}
|
|
501
548
|
|
|
502
549
|
const frame = this.currentFrame;
|
|
503
|
-
const
|
|
504
|
-
if (
|
|
550
|
+
const dirtyRegion = frame ? this.dirtyRegionForDamage(damage, frame) : undefined;
|
|
551
|
+
if (dirtyRegion?.rects.length === 0) {
|
|
505
552
|
return;
|
|
506
553
|
}
|
|
507
554
|
|
|
@@ -510,8 +557,8 @@ export class WebHostSceneRuntime {
|
|
|
510
557
|
context.textBaseline = "alphabetic";
|
|
511
558
|
|
|
512
559
|
context.fillStyle = webTUITerminalBackgroundColor(this.currentStyle);
|
|
513
|
-
if (
|
|
514
|
-
for (const rect of
|
|
560
|
+
if (dirtyRegion) {
|
|
561
|
+
for (const rect of dirtyRegion.rects) {
|
|
515
562
|
context.clearRect(rect.x, rect.y, rect.width, rect.height);
|
|
516
563
|
context.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
517
564
|
}
|
|
@@ -524,26 +571,43 @@ export class WebHostSceneRuntime {
|
|
|
524
571
|
return;
|
|
525
572
|
}
|
|
526
573
|
|
|
527
|
-
this.drawRows(context, frame,
|
|
528
|
-
this.drawImages(context, frame.images ?? [],
|
|
574
|
+
this.drawRows(context, frame, dirtyRegion);
|
|
575
|
+
this.drawImages(context, frame.images ?? [], dirtyRegion);
|
|
529
576
|
}
|
|
530
577
|
|
|
531
578
|
private drawRows(
|
|
532
579
|
context: CanvasRenderingContext2D,
|
|
533
580
|
frame: WebHostSurfaceFrame,
|
|
534
|
-
|
|
581
|
+
dirtyRegion?: DirtyRegion
|
|
535
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
|
+
|
|
536
591
|
for (let y = 0; y < frame.rows.length; y += 1) {
|
|
537
592
|
const row = frame.rows[y] ?? [];
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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;
|
|
546
608
|
}
|
|
609
|
+
const style = frame.styles[styleIndex] ?? undefined;
|
|
610
|
+
this.drawCell(context, x, y, text, span, style);
|
|
547
611
|
}
|
|
548
612
|
}
|
|
549
613
|
|
|
@@ -564,17 +628,17 @@ export class WebHostSceneRuntime {
|
|
|
564
628
|
private drawImages(
|
|
565
629
|
context: CanvasRenderingContext2D,
|
|
566
630
|
images: WebHostSurfaceImage[],
|
|
567
|
-
|
|
631
|
+
dirtyRegion?: DirtyRegion
|
|
568
632
|
): void {
|
|
569
633
|
for (const image of images) {
|
|
570
|
-
this.drawImage(context, image,
|
|
634
|
+
this.drawImage(context, image, dirtyRegion);
|
|
571
635
|
}
|
|
572
636
|
}
|
|
573
637
|
|
|
574
638
|
private drawImage(
|
|
575
639
|
context: CanvasRenderingContext2D,
|
|
576
640
|
image: WebHostSurfaceImage,
|
|
577
|
-
|
|
641
|
+
dirtyRegion?: DirtyRegion
|
|
578
642
|
): void {
|
|
579
643
|
const decodedImage = this.cachedImage(image);
|
|
580
644
|
if (!decodedImage) {
|
|
@@ -586,13 +650,10 @@ export class WebHostSceneRuntime {
|
|
|
586
650
|
if (boundsWidth <= 0 || boundsHeight <= 0 || clipWidth <= 0 || clipHeight <= 0) {
|
|
587
651
|
return;
|
|
588
652
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
height: clipHeight * this.cellHeight,
|
|
594
|
-
};
|
|
595
|
-
if (dirtyRects && !dirtyRects.some((rect) => rectsIntersect(rect, imageRect))) {
|
|
653
|
+
if (
|
|
654
|
+
dirtyRegion
|
|
655
|
+
&& !dirtyRegionIntersectsCellRect(dirtyRegion, clipX, clipY, clipWidth, clipHeight)
|
|
656
|
+
) {
|
|
596
657
|
return;
|
|
597
658
|
}
|
|
598
659
|
|
|
@@ -686,23 +747,28 @@ export class WebHostSceneRuntime {
|
|
|
686
747
|
context.globalAlpha = 1;
|
|
687
748
|
}
|
|
688
749
|
|
|
689
|
-
private
|
|
750
|
+
private dirtyRegionForDamage(
|
|
690
751
|
damage: WebHostSurfaceDamage | undefined,
|
|
691
752
|
frame: WebHostSurfaceFrame
|
|
692
|
-
):
|
|
753
|
+
): DirtyRegion | undefined {
|
|
693
754
|
if (!damage || damage.requiresFullTextRepaint || damage.requiresFullGraphicsReplay) {
|
|
694
755
|
return undefined;
|
|
695
756
|
}
|
|
696
757
|
|
|
697
758
|
const rects: DirtyRect[] = [];
|
|
759
|
+
const rows = new Map<number, DirtyRowRanges>();
|
|
698
760
|
for (const [row, ranges] of damage.textRows) {
|
|
699
761
|
if (row < 0 || row >= frame.height) {
|
|
700
762
|
continue;
|
|
701
763
|
}
|
|
702
764
|
if (ranges.length === 0) {
|
|
703
765
|
rects.push(this.cellRect(0, row, frame.width));
|
|
766
|
+
rows.set(row, "full");
|
|
704
767
|
continue;
|
|
705
768
|
}
|
|
769
|
+
const rowRanges: DirtyCellRange[] = rows.get(row) === "full"
|
|
770
|
+
? []
|
|
771
|
+
: [...(rows.get(row) as DirtyCellRange[] | undefined ?? [])];
|
|
706
772
|
for (const [start, end] of ranges) {
|
|
707
773
|
const lowerBound = Math.max(0, Math.min(frame.width, Math.floor(start)));
|
|
708
774
|
const upperBound = Math.max(lowerBound, Math.min(frame.width, Math.ceil(end)));
|
|
@@ -710,9 +776,13 @@ export class WebHostSceneRuntime {
|
|
|
710
776
|
continue;
|
|
711
777
|
}
|
|
712
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));
|
|
713
783
|
}
|
|
714
784
|
}
|
|
715
|
-
return rects;
|
|
785
|
+
return { rects, rows };
|
|
716
786
|
}
|
|
717
787
|
|
|
718
788
|
private cellRect(
|
|
@@ -769,6 +839,41 @@ export class WebHostSceneRuntime {
|
|
|
769
839
|
return `${italic}${weight}${this.currentStyle.fontSize}px ${this.currentStyle.fontFamily}`;
|
|
770
840
|
}
|
|
771
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
|
+
|
|
772
877
|
private cellLocation(
|
|
773
878
|
event: MouseEvent
|
|
774
879
|
): { x: number; y: number } | undefined {
|
|
@@ -924,14 +1029,96 @@ function normalizedWheelDelta(
|
|
|
924
1029
|
return 0;
|
|
925
1030
|
}
|
|
926
1031
|
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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
|
|
930
1090
|
): boolean {
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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;
|
|
935
1122
|
}
|
|
936
1123
|
|
|
937
1124
|
function resolvedForeground(
|
|
@@ -88,6 +88,25 @@ export interface WebHostSurfaceDamage {
|
|
|
88
88
|
requiresFullGraphicsReplay: boolean;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Per-region scroll extent published with each frame so the host can implement
|
|
93
|
+
* scroll-chaining: capture the wheel only while the region under the pointer can
|
|
94
|
+
* still scroll in the wheel's direction, otherwise let it fall through to the
|
|
95
|
+
* page. The host recomputes the per-direction headroom from `offset`/`content`/
|
|
96
|
+
* the viewport `rect`, mirroring SwiftTUI's
|
|
97
|
+
* `min(max(0, offset), max(0, content - viewport))` clamp.
|
|
98
|
+
*/
|
|
99
|
+
export interface WebHostScrollRegion {
|
|
100
|
+
/** identity path — same key space as accessibility node ids */
|
|
101
|
+
id: string;
|
|
102
|
+
/** viewport rect in cells: [x, y, width, height] */
|
|
103
|
+
rect: WebHostSurfaceRect;
|
|
104
|
+
/** current clamped scroll offset in cells: [x, y] */
|
|
105
|
+
offset: WebHostAccessibilityPoint;
|
|
106
|
+
/** total content size in cells: [width, height] */
|
|
107
|
+
content: WebHostSurfaceSize;
|
|
108
|
+
}
|
|
109
|
+
|
|
91
110
|
export interface WebHostSurfaceFrame {
|
|
92
111
|
version: 1 | 2;
|
|
93
112
|
sequence?: number;
|
|
@@ -99,6 +118,7 @@ export interface WebHostSurfaceFrame {
|
|
|
99
118
|
damage?: WebHostSurfaceDamage;
|
|
100
119
|
accessibilityTree?: WebHostAccessibilityNode[];
|
|
101
120
|
accessibilityAnnouncements?: WebHostAccessibilityAnnouncement[];
|
|
121
|
+
scrollRegions?: WebHostScrollRegion[];
|
|
102
122
|
}
|
|
103
123
|
|
|
104
124
|
export type WebHostSurfaceDeltaRow = [
|
|
@@ -118,6 +138,7 @@ export interface WebHostSurfaceDeltaFrame {
|
|
|
118
138
|
damage?: WebHostSurfaceDamage;
|
|
119
139
|
accessibilityTree?: WebHostAccessibilityNode[];
|
|
120
140
|
accessibilityAnnouncements?: WebHostAccessibilityAnnouncement[];
|
|
141
|
+
scrollRegions?: WebHostScrollRegion[];
|
|
121
142
|
}
|
|
122
143
|
|
|
123
144
|
export interface WebHostRuntimeIssue {
|
|
@@ -314,6 +335,7 @@ export class WebHostOutputDecoder {
|
|
|
314
335
|
damage: frame.damage,
|
|
315
336
|
accessibilityTree: frame.accessibilityTree,
|
|
316
337
|
accessibilityAnnouncements: frame.accessibilityAnnouncements,
|
|
338
|
+
scrollRegions: frame.scrollRegions,
|
|
317
339
|
};
|
|
318
340
|
}
|
|
319
341
|
}
|
|
@@ -444,7 +466,8 @@ function isWebHostSurfaceFrame(
|
|
|
444
466
|
&& (
|
|
445
467
|
frame.accessibilityAnnouncements === undefined
|
|
446
468
|
|| isWebHostAccessibilityAnnouncements(frame.accessibilityAnnouncements)
|
|
447
|
-
)
|
|
469
|
+
)
|
|
470
|
+
&& (frame.scrollRegions === undefined || isWebHostScrollRegions(frame.scrollRegions));
|
|
448
471
|
}
|
|
449
472
|
|
|
450
473
|
function isWebHostSurfaceDeltaFrame(
|
|
@@ -474,7 +497,8 @@ function isWebHostSurfaceDeltaFrame(
|
|
|
474
497
|
&& (
|
|
475
498
|
frame.accessibilityAnnouncements === undefined
|
|
476
499
|
|| isWebHostAccessibilityAnnouncements(frame.accessibilityAnnouncements)
|
|
477
|
-
)
|
|
500
|
+
)
|
|
501
|
+
&& (frame.scrollRegions === undefined || isWebHostScrollRegions(frame.scrollRegions));
|
|
478
502
|
}
|
|
479
503
|
|
|
480
504
|
function isWebHostSurfaceDeltaRow(
|
|
@@ -625,6 +649,25 @@ function isWebHostSurfaceImageFormat(
|
|
|
625
649
|
return value === "png" || value === "jpeg" || value === "gif";
|
|
626
650
|
}
|
|
627
651
|
|
|
652
|
+
function isWebHostScrollRegions(
|
|
653
|
+
value: unknown
|
|
654
|
+
): value is WebHostScrollRegion[] {
|
|
655
|
+
return Array.isArray(value) && value.every(isWebHostScrollRegion);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function isWebHostScrollRegion(
|
|
659
|
+
value: unknown
|
|
660
|
+
): value is WebHostScrollRegion {
|
|
661
|
+
if (!value || typeof value !== "object") {
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
const region = value as Partial<WebHostScrollRegion>;
|
|
665
|
+
return typeof region.id === "string"
|
|
666
|
+
&& isWebHostSurfaceRect(region.rect)
|
|
667
|
+
&& isWebHostSurfaceSize(region.offset)
|
|
668
|
+
&& isWebHostSurfaceSize(region.content);
|
|
669
|
+
}
|
|
670
|
+
|
|
628
671
|
function isWebHostSurfaceRect(
|
|
629
672
|
value: unknown
|
|
630
673
|
): value is WebHostSurfaceRect {
|