@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swifttui/web",
3
- "version": "0.0.9",
3
+ "version": "0.0.12",
4
4
  "license": "MIT",
5
5
  "module": "index.ts",
6
6
  "exports": {
@@ -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 captureWheelInput: boolean;
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.captureWheelInput = options.captureWheelInput ?? true;
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 (!this.captureWheelInput) {
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 dirtyRects = frame ? this.dirtyRectsForDamage(damage, frame) : undefined;
504
- if (dirtyRects?.length === 0) {
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 (dirtyRects) {
514
- for (const rect of dirtyRects) {
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, dirtyRects);
528
- this.drawImages(context, frame.images ?? [], dirtyRects);
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
- dirtyRects?: DirtyRect[]
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
- for (const cell of row) {
539
- const [x, text, span, styleIndex] = cell;
540
- const cellRect = this.cellRect(x, y, span);
541
- if (dirtyRects && !dirtyRects.some((rect) => rectsIntersect(rect, cellRect))) {
542
- continue;
543
- }
544
- const style = frame.styles[styleIndex] ?? undefined;
545
- this.drawCell(context, x, y, text, span, style);
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
- dirtyRects?: DirtyRect[]
631
+ dirtyRegion?: DirtyRegion
568
632
  ): void {
569
633
  for (const image of images) {
570
- this.drawImage(context, image, dirtyRects);
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
- dirtyRects?: DirtyRect[]
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
- const imageRect = {
590
- x: clipX * this.cellWidth,
591
- y: clipY * this.cellHeight,
592
- width: clipWidth * this.cellWidth,
593
- height: clipHeight * this.cellHeight,
594
- };
595
- if (dirtyRects && !dirtyRects.some((rect) => rectsIntersect(rect, imageRect))) {
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 dirtyRectsForDamage(
750
+ private dirtyRegionForDamage(
690
751
  damage: WebHostSurfaceDamage | undefined,
691
752
  frame: WebHostSurfaceFrame
692
- ): DirtyRect[] | undefined {
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
- function rectsIntersect(
928
- lhs: DirtyRect,
929
- rhs: DirtyRect
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
- return lhs.x < rhs.x + rhs.width
932
- && lhs.x + lhs.width > rhs.x
933
- && lhs.y < rhs.y + rhs.height
934
- && lhs.y + lhs.height > rhs.y;
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 {