@swifttui/web 0.0.10 → 0.0.13

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.10",
3
+ "version": "0.0.13",
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();
@@ -1211,6 +1211,114 @@ test("runtime can run as a passive embed without stealing focus or wheel scroll"
1211
1211
  }
1212
1212
  });
1213
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
+
1214
1322
  test("runtime preserves pointer movement within one cell", async () => {
1215
1323
  const dom = installFakeDOM();
1216
1324
  try {
@@ -1443,6 +1551,62 @@ async function flushPromises(): Promise<void> {
1443
1551
  await Promise.resolve();
1444
1552
  }
1445
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
+
1446
1610
  function surfaceRecord(
1447
1611
  frame: Record<string, unknown>
1448
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>;
@@ -78,7 +98,7 @@ export class WebHostSceneRuntime {
78
98
  private readonly onInput: (chunk: Uint8Array) => void;
79
99
  private readonly onFrameDiagnostic?: (diagnostic: WebHostFrameDiagnosticRecord) => void;
80
100
  private readonly synchronizeAccessibilityFocus: boolean;
81
- private readonly captureWheelInput: boolean;
101
+ private readonly wheelMode: WheelMode;
82
102
  private readonly imageCache = new Map<string, CachedWebHostImage>();
83
103
  private currentStyle: ResolvedWebHostTerminalStyle;
84
104
  private canvas?: HTMLCanvasElement;
@@ -108,7 +128,8 @@ export class WebHostSceneRuntime {
108
128
  this.onInput = options.onInput;
109
129
  this.onFrameDiagnostic = options.onFrameDiagnostic;
110
130
  this.synchronizeAccessibilityFocus = options.synchronizeAccessibilityFocus ?? true;
111
- this.captureWheelInput = options.captureWheelInput ?? true;
131
+ this.wheelMode = options.wheelMode
132
+ ?? (options.captureWheelInput === false ? "passive" : "capture");
112
133
  this.element = document.createElement("section");
113
134
  this.element.className = "webhost-scene";
114
135
  this.element.dataset.sceneId = options.descriptor.id;
@@ -274,6 +295,9 @@ export class WebHostSceneRuntime {
274
295
 
275
296
  this.terminalMount.style.position = "relative";
276
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";
277
301
  this.terminalMount.style.outline = "none";
278
302
  this.terminalMount.style.background = webTUITerminalBackgroundColor(this.currentStyle);
279
303
  this.terminalMount.style.minHeight = `${this.cellHeight * 8}px`;
@@ -390,12 +414,23 @@ export class WebHostSceneRuntime {
390
414
  };
391
415
 
392
416
  const handleWheel = (event: WheelEvent) => {
393
- if (!this.captureWheelInput) {
417
+ if (this.wheelMode === "passive") {
394
418
  return;
395
419
  }
396
420
 
397
421
  const location = this.cellLocation(event);
398
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)) {
399
434
  return;
400
435
  }
401
436
 
@@ -804,6 +839,41 @@ export class WebHostSceneRuntime {
804
839
  return `${italic}${weight}${this.currentStyle.fontSize}px ${this.currentStyle.fontFamily}`;
805
840
  }
806
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
+
807
877
  private cellLocation(
808
878
  event: MouseEvent
809
879
  ): { x: number; y: number } | undefined {
@@ -959,6 +1029,42 @@ function normalizedWheelDelta(
959
1029
  return 0;
960
1030
  }
961
1031
 
1032
+ /**
1033
+ * Whether a published scroll region has remaining headroom in the wheel's
1034
+ * direction, recomputing the per-direction extent from offset/content/viewport.
1035
+ * Mirrors SwiftTUI's clamp (`min(max(0, offset), max(0, content - viewport))`)
1036
+ * so the host and the app agree on "at edge". Wheel sign convention matches the
1037
+ * app: `deltaY > 0` scrolls down (offset grows toward the content bottom).
1038
+ * Diagonal wheels qualify if either axis has headroom.
1039
+ */
1040
+ function regionCanScrollInDirection(
1041
+ region: WebHostScrollRegion,
1042
+ deltaX: number,
1043
+ deltaY: number
1044
+ ): boolean {
1045
+ const [, , viewportWidth, viewportHeight] = region.rect;
1046
+ const [offsetX, offsetY] = region.offset;
1047
+ const [contentWidth, contentHeight] = region.content;
1048
+ const maxX = Math.max(0, contentWidth - viewportWidth);
1049
+ const maxY = Math.max(0, contentHeight - viewportHeight);
1050
+ const clampedX = Math.min(Math.max(0, offsetX), maxX);
1051
+ const clampedY = Math.min(Math.max(0, offsetY), maxY);
1052
+
1053
+ if (deltaY > 0 && clampedY < maxY) {
1054
+ return true;
1055
+ }
1056
+ if (deltaY < 0 && clampedY > 0) {
1057
+ return true;
1058
+ }
1059
+ if (deltaX > 0 && clampedX < maxX) {
1060
+ return true;
1061
+ }
1062
+ if (deltaX < 0 && clampedX > 0) {
1063
+ return true;
1064
+ }
1065
+ return false;
1066
+ }
1067
+
962
1068
  function normalizeCellRanges(
963
1069
  ranges: DirtyCellRange[]
964
1070
  ): DirtyCellRange[] {
@@ -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 {