@swifttui/web 0.1.0 → 0.1.1

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.
@@ -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 imageCache;
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 drawImages;
94
- private drawImage;
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 { encodeKeyInputMessage, encodeMouseInputMessage, encodePasteInputMessage } from "./WebHostSurfaceTransport.js";
3
- import { canRenderBoxDrawing, drawBoxDrawing } from "./BoxDrawingRenderer.js";
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
- imageCache = /* @__PURE__ */ new Map();
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 key = keyInputFromKeyboardEvent(event);
185
- if (!key) return;
186
- this.onInput(encodeKeyInputMessage({
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(encodePasteInputMessage(text));
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(encodeMouseInputMessage({
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.onInput(encodeMouseInputMessage({
221
- kind: "up",
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(encodeMouseInputMessage({
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.wheelTargetCanScroll(location, event.deltaX, event.deltaY)) return;
245
- this.onInput(encodeMouseInputMessage({
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.fontForStyle();
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
- const canvas = this.canvas;
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
- drawImages(context, images, dirtyRegion) {
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
- rects,
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
- cellRect(x, y, span) {
323
+ pointerMetrics() {
464
324
  return {
465
- x: x * this.cellWidth,
466
- y: y * this.cellHeight,
467
- width: Math.max(1, span) * this.cellWidth,
468
- height: this.cellHeight
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
- const location = this.rawCellLocation(event);
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
- const rect = this.canvas?.getBoundingClientRect?.() ?? this.terminalMount.getBoundingClientRect?.();
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