@swifttui/web 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,40 +15,42 @@ var AccessibilityTreeMounter = class {
15
15
  applyScreenReaderOnlyStyle(this.announcerElement);
16
16
  }
17
17
  present(nodes, metrics, announcements = [], options = {}) {
18
- this.element.replaceChildren();
19
- this.nodesById.clear();
18
+ const previousById = this.nodesById;
19
+ const nextById = /* @__PURE__ */ new Map();
20
20
  for (const node of nodes) {
21
- const element = this.elementForNode(node, metrics);
22
- this.nodesById.set(node.id, element);
21
+ const element = previousById.get(node.id) ?? document.createElement("div");
22
+ this.applyNodeAttributes(element, node, metrics);
23
+ nextById.set(node.id, element);
23
24
  }
25
+ for (const id of previousById.keys()) if (!nextById.has(id)) previousById.get(id)?.remove();
26
+ this.nodesById = nextById;
24
27
  for (const node of nodes) {
25
- const element = this.nodesById.get(node.id);
28
+ const element = nextById.get(node.id);
26
29
  if (!element) continue;
27
- ((node.parentId ? this.nodesById.get(node.parentId) : void 0) ?? this.element).appendChild(element);
30
+ ((node.parentId ? nextById.get(node.parentId) : void 0) ?? this.element).appendChild(element);
28
31
  }
29
32
  this.announceLiveRegionChanges(nodes, announcements);
30
33
  const focused = nodes.find((node) => node.isFocused);
31
34
  if ((options.synchronizeFocus ?? true) && focused) this.nodesById.get(focused.id)?.focus?.({ preventScroll: true });
32
35
  }
33
- elementForNode(node, metrics) {
34
- const element = document.createElement("div");
36
+ applyNodeAttributes(element, node, metrics) {
35
37
  element.id = `swifttui-a11y-${stableDOMId(node.id)}`;
36
38
  element.dataset.accessibilityId = node.id;
37
39
  element.tabIndex = node.isFocused ? 0 : -1;
38
40
  const role = roleMapping(node.role);
39
- if (role.role) element.setAttribute("role", role.role);
40
- if (role.level !== void 0) element.setAttribute("aria-level", String(role.level));
41
- if (node.label) element.setAttribute("aria-label", node.label);
42
- if (node.hint) element.setAttribute("aria-description", node.hint);
43
- if (node.liveRegion) element.setAttribute("aria-live", node.liveRegion);
41
+ setOrRemoveAttribute(element, "role", role.role);
42
+ setOrRemoveAttribute(element, "aria-level", role.level !== void 0 ? String(role.level) : void 0);
43
+ setOrRemoveAttribute(element, "aria-label", node.label || void 0);
44
+ setOrRemoveAttribute(element, "aria-description", node.hint || void 0);
45
+ setOrRemoveAttribute(element, "aria-live", node.liveRegion || void 0);
44
46
  if (node.isFocused) element.dataset.focused = "true";
47
+ else delete element.dataset.focused;
45
48
  const [x, y, width, height] = node.rect;
46
49
  element.style.position = "absolute";
47
50
  element.style.left = `${x * metrics.cellWidth}px`;
48
51
  element.style.top = `${y * metrics.cellHeight}px`;
49
52
  element.style.width = `${Math.max(1, width) * metrics.cellWidth}px`;
50
53
  element.style.height = `${Math.max(1, height) * metrics.cellHeight}px`;
51
- return element;
52
54
  }
53
55
  announceLiveRegionChanges(nodes, announcements) {
54
56
  const candidates = nodes.filter((node) => node.liveRegion && node.liveRegion !== "off" && node.label);
@@ -86,6 +88,13 @@ var AccessibilityTreeMounter = class {
86
88
  }).join("\n");
87
89
  }
88
90
  };
91
+ function setOrRemoveAttribute(element, name, value) {
92
+ if (value === void 0) {
93
+ element.removeAttribute(name);
94
+ return;
95
+ }
96
+ element.setAttribute(name, value);
97
+ }
89
98
  function applyScreenReaderOnlyStyle(element) {
90
99
  element.style.position = "absolute";
91
100
  element.style.left = "0";
@@ -1 +1 @@
1
- {"version":3,"file":"AccessibilityTree.js","names":[],"sources":["../../src/AccessibilityTree.ts"],"sourcesContent":["import type {\n WebHostAccessibilityAnnouncement,\n WebHostAccessibilityNode,\n} from \"./WebHostSurfaceTransport.ts\";\n\ninterface AccessibilityTreeMetrics {\n cellWidth: number;\n cellHeight: number;\n}\n\ninterface AccessibilityTreePresentationOptions {\n synchronizeFocus?: boolean;\n}\n\ninterface RoleMapping {\n role?: string;\n level?: number;\n}\n\nexport class AccessibilityTreeMounter {\n readonly element: HTMLElement;\n readonly announcerElement: HTMLElement;\n\n private readonly nodesById = new Map<string, HTMLElement>();\n private previousLabelsById = new Map<string, string>();\n private hasLiveRegionBaseline = false;\n\n constructor() {\n this.element = document.createElement(\"div\");\n this.element.className = \"webhost-scene__accessibility-tree\";\n applyScreenReaderOnlyStyle(this.element);\n\n this.announcerElement = document.createElement(\"div\");\n this.announcerElement.className = \"webhost-scene__accessibility-announcer\";\n this.announcerElement.setAttribute(\"aria-atomic\", \"true\");\n applyScreenReaderOnlyStyle(this.announcerElement);\n }\n\n present(\n nodes: WebHostAccessibilityNode[],\n metrics: AccessibilityTreeMetrics,\n announcements: WebHostAccessibilityAnnouncement[] = [],\n options: AccessibilityTreePresentationOptions = {}\n ): void {\n this.element.replaceChildren();\n this.nodesById.clear();\n\n for (const node of nodes) {\n const element = this.elementForNode(node, metrics);\n this.nodesById.set(node.id, element);\n }\n\n for (const node of nodes) {\n const element = this.nodesById.get(node.id);\n if (!element) {\n continue;\n }\n\n const parent = node.parentId ? this.nodesById.get(node.parentId) : undefined;\n (parent ?? this.element).appendChild(element);\n }\n\n this.announceLiveRegionChanges(nodes, announcements);\n\n const focused = nodes.find((node) => node.isFocused);\n if ((options.synchronizeFocus ?? true) && focused) {\n this.nodesById.get(focused.id)?.focus?.({ preventScroll: true });\n }\n }\n\n private elementForNode(\n node: WebHostAccessibilityNode,\n metrics: AccessibilityTreeMetrics\n ): HTMLElement {\n const element = document.createElement(\"div\");\n element.id = `swifttui-a11y-${stableDOMId(node.id)}`;\n element.dataset.accessibilityId = node.id;\n element.tabIndex = node.isFocused ? 0 : -1;\n\n const role = roleMapping(node.role);\n if (role.role) {\n element.setAttribute(\"role\", role.role);\n }\n if (role.level !== undefined) {\n element.setAttribute(\"aria-level\", String(role.level));\n }\n if (node.label) {\n element.setAttribute(\"aria-label\", node.label);\n }\n if (node.hint) {\n element.setAttribute(\"aria-description\", node.hint);\n }\n if (node.liveRegion) {\n element.setAttribute(\"aria-live\", node.liveRegion);\n }\n if (node.isFocused) {\n element.dataset.focused = \"true\";\n }\n\n const [x, y, width, height] = node.rect;\n element.style.position = \"absolute\";\n element.style.left = `${x * metrics.cellWidth}px`;\n element.style.top = `${y * metrics.cellHeight}px`;\n element.style.width = `${Math.max(1, width) * metrics.cellWidth}px`;\n element.style.height = `${Math.max(1, height) * metrics.cellHeight}px`;\n\n return element;\n }\n\n private announceLiveRegionChanges(\n nodes: WebHostAccessibilityNode[],\n announcements: WebHostAccessibilityAnnouncement[]\n ): void {\n const candidates = nodes.filter(\n (node) => node.liveRegion && node.liveRegion !== \"off\" && node.label\n );\n const currentLabelsById = new Map(candidates.map((node) => [node.id, node.label ?? \"\"]));\n const imperativeAssertive = announcements.filter(\n (announcement) => announcement.politeness === \"assertive\"\n );\n const imperativePolite = announcements.filter(\n (announcement) => announcement.politeness === \"polite\"\n );\n\n if (!this.hasLiveRegionBaseline) {\n this.previousLabelsById = currentLabelsById;\n this.hasLiveRegionBaseline = true;\n this.publishAnnouncements([], imperativeAssertive, [], imperativePolite);\n return;\n }\n\n const changed = candidates.filter((node) => {\n const previous = this.previousLabelsById.get(node.id);\n return previous !== undefined && previous !== node.label;\n });\n this.previousLabelsById = currentLabelsById;\n\n const assertive = changed.filter((node) => node.liveRegion === \"assertive\");\n const polite = changed.filter((node) => node.liveRegion === \"polite\");\n this.publishAnnouncements(assertive, imperativeAssertive, polite, imperativePolite);\n }\n\n private publishAnnouncements(\n assertive: WebHostAccessibilityNode[],\n imperativeAssertive: WebHostAccessibilityAnnouncement[],\n polite: WebHostAccessibilityNode[],\n imperativePolite: WebHostAccessibilityAnnouncement[]\n ): void {\n const ordered = [...assertive, ...imperativeAssertive, ...polite, ...imperativePolite];\n if (ordered.length === 0) {\n return;\n }\n\n const politeness = assertive.length > 0 || imperativeAssertive.length > 0\n ? \"assertive\"\n : \"polite\";\n this.announcerElement.setAttribute(\"aria-live\", politeness);\n this.announcerElement.textContent = ordered.map((entry) => {\n if (\"message\" in entry) {\n return entry.message;\n }\n return entry.label ?? \"\";\n }).join(\"\\n\");\n }\n}\n\nfunction applyScreenReaderOnlyStyle(\n element: HTMLElement\n): void {\n element.style.position = \"absolute\";\n element.style.left = \"0\";\n element.style.top = \"0\";\n element.style.width = \"1px\";\n element.style.height = \"1px\";\n element.style.overflow = \"hidden\";\n element.style.clipPath = \"inset(50%)\";\n element.style.whiteSpace = \"nowrap\";\n}\n\nfunction roleMapping(\n role: string\n): RoleMapping {\n const heading = /^heading\\(level: ([0-9]+)\\)$/.exec(role);\n if (heading) {\n return {\n role: \"heading\",\n level: Math.max(1, Math.min(6, Number(heading[1]))),\n };\n }\n\n const custom = /^custom\\((.+)\\)$/.exec(role);\n if (custom) {\n return { role: custom[1] };\n }\n\n switch (role) {\n case \"alert\":\n case \"button\":\n case \"cell\":\n case \"checkbox\":\n case \"grid\":\n case \"group\":\n case \"link\":\n case \"list\":\n case \"menu\":\n case \"region\":\n case \"separator\":\n case \"slider\":\n case \"status\":\n case \"tab\":\n case \"table\":\n case \"timer\":\n return { role };\n case \"columnHeader\":\n return { role: \"columnheader\" };\n case \"confirmationDialog\":\n case \"sheet\":\n return { role: \"dialog\" };\n case \"disclosureGroup\":\n case \"scrollView\":\n case \"scrollViewWithIndicators\":\n case \"section\":\n return { role: \"region\" };\n case \"image\":\n return { role: \"img\" };\n case \"menuItem\":\n return { role: \"menuitem\" };\n case \"picker\":\n return { role: \"combobox\" };\n case \"progressBar\":\n return { role: \"progressbar\" };\n case \"rowHeader\":\n return { role: \"rowheader\" };\n case \"secureField\":\n case \"textEditor\":\n case \"textField\":\n return { role: \"textbox\" };\n case \"stepper\":\n return { role: \"spinbutton\" };\n case \"tabPanel\":\n return { role: \"tabpanel\" };\n case \"tableRow\":\n return { role: \"row\" };\n case \"tabView\":\n return { role: \"tablist\" };\n case \"toggle\":\n return { role: \"checkbox\" };\n default:\n return { role: \"group\" };\n }\n}\n\nfunction stableDOMId(\n id: string\n): string {\n return Array.from(id).map((character) => {\n if (/^[a-zA-Z0-9_-]$/.test(character)) {\n return character;\n }\n return `-${character.codePointAt(0)?.toString(16) ?? \"0\"}-`;\n }).join(\"\");\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"AccessibilityTree.js","names":[],"sources":["../../src/AccessibilityTree.ts"],"sourcesContent":["import type {\n WebHostAccessibilityAnnouncement,\n WebHostAccessibilityNode,\n} from \"./WebHostSurfaceTransport.ts\";\n\ninterface AccessibilityTreeMetrics {\n cellWidth: number;\n cellHeight: number;\n}\n\ninterface AccessibilityTreePresentationOptions {\n synchronizeFocus?: boolean;\n}\n\ninterface RoleMapping {\n role?: string;\n level?: number;\n}\n\nexport class AccessibilityTreeMounter {\n readonly element: HTMLElement;\n readonly announcerElement: HTMLElement;\n\n private nodesById = new Map<string, HTMLElement>();\n private previousLabelsById = new Map<string, string>();\n private hasLiveRegionBaseline = false;\n\n constructor() {\n this.element = document.createElement(\"div\");\n this.element.className = \"webhost-scene__accessibility-tree\";\n applyScreenReaderOnlyStyle(this.element);\n\n this.announcerElement = document.createElement(\"div\");\n this.announcerElement.className = \"webhost-scene__accessibility-announcer\";\n this.announcerElement.setAttribute(\"aria-atomic\", \"true\");\n applyScreenReaderOnlyStyle(this.announcerElement);\n }\n\n present(\n nodes: WebHostAccessibilityNode[],\n metrics: AccessibilityTreeMetrics,\n announcements: WebHostAccessibilityAnnouncement[] = [],\n options: AccessibilityTreePresentationOptions = {}\n ): void {\n const previousById = this.nodesById;\n const nextById = new Map<string, HTMLElement>();\n\n for (const node of nodes) {\n const existing = previousById.get(node.id);\n const element = existing ?? document.createElement(\"div\");\n this.applyNodeAttributes(element, node, metrics);\n nextById.set(node.id, element);\n }\n\n for (const id of previousById.keys()) {\n if (!nextById.has(id)) {\n previousById.get(id)?.remove();\n }\n }\n\n this.nodesById = nextById;\n\n for (const node of nodes) {\n const element = nextById.get(node.id);\n if (!element) {\n continue;\n }\n\n const parent = node.parentId ? nextById.get(node.parentId) : undefined;\n (parent ?? this.element).appendChild(element);\n }\n\n this.announceLiveRegionChanges(nodes, announcements);\n\n const focused = nodes.find((node) => node.isFocused);\n if ((options.synchronizeFocus ?? true) && focused) {\n this.nodesById.get(focused.id)?.focus?.({ preventScroll: true });\n }\n }\n\n private applyNodeAttributes(\n element: HTMLElement,\n node: WebHostAccessibilityNode,\n metrics: AccessibilityTreeMetrics\n ): void {\n element.id = `swifttui-a11y-${stableDOMId(node.id)}`;\n element.dataset.accessibilityId = node.id;\n element.tabIndex = node.isFocused ? 0 : -1;\n\n const role = roleMapping(node.role);\n setOrRemoveAttribute(element, \"role\", role.role);\n setOrRemoveAttribute(\n element,\n \"aria-level\",\n role.level !== undefined ? String(role.level) : undefined\n );\n setOrRemoveAttribute(element, \"aria-label\", node.label || undefined);\n setOrRemoveAttribute(element, \"aria-description\", node.hint || undefined);\n setOrRemoveAttribute(element, \"aria-live\", node.liveRegion || undefined);\n if (node.isFocused) {\n element.dataset.focused = \"true\";\n } else {\n delete element.dataset.focused;\n }\n\n const [x, y, width, height] = node.rect;\n element.style.position = \"absolute\";\n element.style.left = `${x * metrics.cellWidth}px`;\n element.style.top = `${y * metrics.cellHeight}px`;\n element.style.width = `${Math.max(1, width) * metrics.cellWidth}px`;\n element.style.height = `${Math.max(1, height) * metrics.cellHeight}px`;\n }\n\n private announceLiveRegionChanges(\n nodes: WebHostAccessibilityNode[],\n announcements: WebHostAccessibilityAnnouncement[]\n ): void {\n const candidates = nodes.filter(\n (node) => node.liveRegion && node.liveRegion !== \"off\" && node.label\n );\n const currentLabelsById = new Map(candidates.map((node) => [node.id, node.label ?? \"\"]));\n const imperativeAssertive = announcements.filter(\n (announcement) => announcement.politeness === \"assertive\"\n );\n const imperativePolite = announcements.filter(\n (announcement) => announcement.politeness === \"polite\"\n );\n\n if (!this.hasLiveRegionBaseline) {\n this.previousLabelsById = currentLabelsById;\n this.hasLiveRegionBaseline = true;\n this.publishAnnouncements([], imperativeAssertive, [], imperativePolite);\n return;\n }\n\n const changed = candidates.filter((node) => {\n const previous = this.previousLabelsById.get(node.id);\n return previous !== undefined && previous !== node.label;\n });\n this.previousLabelsById = currentLabelsById;\n\n const assertive = changed.filter((node) => node.liveRegion === \"assertive\");\n const polite = changed.filter((node) => node.liveRegion === \"polite\");\n this.publishAnnouncements(assertive, imperativeAssertive, polite, imperativePolite);\n }\n\n private publishAnnouncements(\n assertive: WebHostAccessibilityNode[],\n imperativeAssertive: WebHostAccessibilityAnnouncement[],\n polite: WebHostAccessibilityNode[],\n imperativePolite: WebHostAccessibilityAnnouncement[]\n ): void {\n const ordered = [...assertive, ...imperativeAssertive, ...polite, ...imperativePolite];\n if (ordered.length === 0) {\n return;\n }\n\n const politeness = assertive.length > 0 || imperativeAssertive.length > 0\n ? \"assertive\"\n : \"polite\";\n this.announcerElement.setAttribute(\"aria-live\", politeness);\n this.announcerElement.textContent = ordered.map((entry) => {\n if (\"message\" in entry) {\n return entry.message;\n }\n return entry.label ?? \"\";\n }).join(\"\\n\");\n }\n}\n\nfunction setOrRemoveAttribute(\n element: HTMLElement,\n name: string,\n value: string | undefined\n): void {\n if (value === undefined) {\n element.removeAttribute(name);\n return;\n }\n element.setAttribute(name, value);\n}\n\nfunction applyScreenReaderOnlyStyle(\n element: HTMLElement\n): void {\n element.style.position = \"absolute\";\n element.style.left = \"0\";\n element.style.top = \"0\";\n element.style.width = \"1px\";\n element.style.height = \"1px\";\n element.style.overflow = \"hidden\";\n element.style.clipPath = \"inset(50%)\";\n element.style.whiteSpace = \"nowrap\";\n}\n\nfunction roleMapping(\n role: string\n): RoleMapping {\n const heading = /^heading\\(level: ([0-9]+)\\)$/.exec(role);\n if (heading) {\n return {\n role: \"heading\",\n level: Math.max(1, Math.min(6, Number(heading[1]))),\n };\n }\n\n const custom = /^custom\\((.+)\\)$/.exec(role);\n if (custom) {\n return { role: custom[1] };\n }\n\n switch (role) {\n case \"alert\":\n case \"button\":\n case \"cell\":\n case \"checkbox\":\n case \"grid\":\n case \"group\":\n case \"link\":\n case \"list\":\n case \"menu\":\n case \"region\":\n case \"separator\":\n case \"slider\":\n case \"status\":\n case \"tab\":\n case \"table\":\n case \"timer\":\n return { role };\n case \"columnHeader\":\n return { role: \"columnheader\" };\n case \"confirmationDialog\":\n case \"sheet\":\n return { role: \"dialog\" };\n case \"disclosureGroup\":\n case \"scrollView\":\n case \"scrollViewWithIndicators\":\n case \"section\":\n return { role: \"region\" };\n case \"image\":\n return { role: \"img\" };\n case \"menuItem\":\n return { role: \"menuitem\" };\n case \"picker\":\n return { role: \"combobox\" };\n case \"progressBar\":\n return { role: \"progressbar\" };\n case \"rowHeader\":\n return { role: \"rowheader\" };\n case \"secureField\":\n case \"textEditor\":\n case \"textField\":\n return { role: \"textbox\" };\n case \"stepper\":\n return { role: \"spinbutton\" };\n case \"tabPanel\":\n return { role: \"tabpanel\" };\n case \"tableRow\":\n return { role: \"row\" };\n case \"tabView\":\n return { role: \"tablist\" };\n case \"toggle\":\n return { role: \"checkbox\" };\n default:\n return { role: \"group\" };\n }\n}\n\nfunction stableDOMId(\n id: string\n): string {\n return Array.from(id).map((character) => {\n if (/^[a-zA-Z0-9_-]$/.test(character)) {\n return character;\n }\n return `-${character.codePointAt(0)?.toString(16) ?? \"0\"}-`;\n }).join(\"\");\n}\n"],"mappings":"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"}
@@ -0,0 +1,263 @@
1
+ import { webTUITerminalBackgroundColor } from "./WebHostTerminalStyle.js";
2
+ import { canRenderBoxDrawing, drawBoxDrawing } from "./BoxDrawingRenderer.js";
3
+ //#region src/CanvasSurfacePainter.ts
4
+ /**
5
+ * Draws SwiftTUI surface frames onto a 2D canvas: background fills, per-cell
6
+ * text/box-drawing/decorations, surface images, and damage-scoped dirty-region
7
+ * painting. The painter caches decoded images and asks the host to repaint once
8
+ * an image finishes decoding (via the `requestRedraw` callback).
9
+ *
10
+ * Geometry and style are supplied per paint via {@link CanvasSurfaceMetrics};
11
+ * the painter holds only the canvas handle, the image cache, and the redraw
12
+ * callback as durable state.
13
+ */
14
+ var CanvasSurfacePainter = class {
15
+ imageCache = /* @__PURE__ */ new Map();
16
+ canvas;
17
+ requestRedraw = () => {};
18
+ /**
19
+ * Binds the canvas the painter draws into and the callback used to request a
20
+ * full repaint after an asynchronous image decode completes.
21
+ */
22
+ attach(canvas, requestRedraw) {
23
+ this.canvas = canvas;
24
+ this.requestRedraw = requestRedraw;
25
+ }
26
+ paint(metrics, frame, damage) {
27
+ const canvas = this.canvas;
28
+ const context = canvas?.getContext("2d");
29
+ if (!canvas || !context) return;
30
+ const dirtyRegion = frame ? this.dirtyRegionForDamage(damage, frame, metrics) : void 0;
31
+ if (dirtyRegion?.rects.length === 0) return;
32
+ const scale = globalThis.window?.devicePixelRatio || 1;
33
+ context.setTransform(scale, 0, 0, scale, 0, 0);
34
+ context.textBaseline = "alphabetic";
35
+ context.fillStyle = webTUITerminalBackgroundColor(metrics.style);
36
+ if (dirtyRegion) for (const rect of dirtyRegion.rects) {
37
+ context.clearRect(rect.x, rect.y, rect.width, rect.height);
38
+ context.fillRect(rect.x, rect.y, rect.width, rect.height);
39
+ }
40
+ else {
41
+ context.clearRect(0, 0, canvas.width / scale, canvas.height / scale);
42
+ context.fillRect(0, 0, metrics.columns * metrics.cellWidth, metrics.rows * metrics.cellHeight);
43
+ }
44
+ if (!frame) return;
45
+ this.drawRows(context, frame, metrics, dirtyRegion);
46
+ this.drawImages(context, frame.images ?? [], metrics, dirtyRegion);
47
+ }
48
+ drawRows(context, frame, metrics, dirtyRegion) {
49
+ if (dirtyRegion) {
50
+ for (const [y, ranges] of dirtyRegion.rows) {
51
+ const row = frame.rows[y] ?? [];
52
+ this.drawRow(context, frame, metrics, row, y, ranges);
53
+ }
54
+ return;
55
+ }
56
+ for (let y = 0; y < frame.rows.length; y += 1) {
57
+ const row = frame.rows[y] ?? [];
58
+ this.drawRow(context, frame, metrics, row, y);
59
+ }
60
+ }
61
+ drawRow(context, frame, metrics, row, y, ranges) {
62
+ for (const cell of row) {
63
+ const [x, text, span, styleIndex] = cell;
64
+ if (ranges !== void 0 && !cellIntersectsRanges(x, span, ranges)) continue;
65
+ const style = frame.styles[styleIndex] ?? void 0;
66
+ this.drawCell(context, metrics, x, y, text, span, style);
67
+ }
68
+ }
69
+ drawImages(context, images, metrics, dirtyRegion) {
70
+ for (const image of images) this.drawImage(context, image, metrics, dirtyRegion);
71
+ }
72
+ drawImage(context, image, metrics, dirtyRegion) {
73
+ const decodedImage = this.cachedImage(image);
74
+ if (!decodedImage) return;
75
+ const [boundsX, boundsY, boundsWidth, boundsHeight] = image.bounds;
76
+ const [clipX, clipY, clipWidth, clipHeight] = image.visibleBounds;
77
+ if (boundsWidth <= 0 || boundsHeight <= 0 || clipWidth <= 0 || clipHeight <= 0) return;
78
+ if (dirtyRegion && !dirtyRegionIntersectsCellRect(dirtyRegion, clipX, clipY, clipWidth, clipHeight)) return;
79
+ context.save();
80
+ context.beginPath();
81
+ context.rect(clipX * metrics.cellWidth, clipY * metrics.cellHeight, clipWidth * metrics.cellWidth, clipHeight * metrics.cellHeight);
82
+ context.clip();
83
+ context.drawImage(decodedImage, boundsX * metrics.cellWidth, boundsY * metrics.cellHeight, boundsWidth * metrics.cellWidth, boundsHeight * metrics.cellHeight);
84
+ context.restore();
85
+ }
86
+ cachedImage(image) {
87
+ const cached = this.imageCache.get(image.id);
88
+ if (cached?.image) return cached.image;
89
+ if (!cached?.promise && image.dataBase64) {
90
+ const promise = decodeImage(image.dataBase64, image.format);
91
+ this.imageCache.set(image.id, { promise });
92
+ promise.then((decodedImage) => {
93
+ if (this.imageCache.get(image.id)?.promise !== promise) return;
94
+ this.imageCache.set(image.id, { image: decodedImage });
95
+ this.requestRedraw();
96
+ }).catch(() => {
97
+ this.imageCache.delete(image.id);
98
+ });
99
+ }
100
+ }
101
+ drawCell(context, metrics, x, y, text, span, style) {
102
+ const rectX = x * metrics.cellWidth;
103
+ const rectY = y * metrics.cellHeight;
104
+ const width = Math.max(1, span) * metrics.cellWidth;
105
+ const background = resolvedBackground(style, metrics.style);
106
+ const foreground = resolvedForeground(style, metrics.style);
107
+ const opacity = style?.opacity ?? 1;
108
+ if (background) {
109
+ context.globalAlpha = opacity;
110
+ context.fillStyle = background;
111
+ context.fillRect(rectX, rectY, width, metrics.cellHeight);
112
+ }
113
+ if (text !== " ") {
114
+ context.globalAlpha = opacity;
115
+ context.fillStyle = foreground;
116
+ context.strokeStyle = foreground;
117
+ if (!canRenderBoxDrawing(text) || !drawBoxDrawing(context, text, {
118
+ x: rectX,
119
+ y: rectY,
120
+ width,
121
+ height: metrics.cellHeight
122
+ })) {
123
+ context.font = fontForStyle(metrics.style, style);
124
+ context.fillText(text, rectX, rectY + Math.floor((metrics.cellHeight + metrics.style.fontSize) / 2) - 2);
125
+ }
126
+ }
127
+ this.drawTextLine(context, metrics, rectX, rectY, width, style?.underline, "underline", foreground);
128
+ this.drawTextLine(context, metrics, rectX, rectY, width, style?.strikethrough, "strike", foreground);
129
+ context.globalAlpha = 1;
130
+ }
131
+ dirtyRegionForDamage(damage, frame, metrics) {
132
+ if (!damage || damage.requiresFullTextRepaint || damage.requiresFullGraphicsReplay) return;
133
+ const rects = [];
134
+ const rows = /* @__PURE__ */ new Map();
135
+ for (const [row, ranges] of damage.textRows) {
136
+ if (row < 0 || row >= frame.height) continue;
137
+ if (ranges.length === 0) {
138
+ rects.push(cellRect(metrics, 0, row, frame.width));
139
+ rows.set(row, "full");
140
+ continue;
141
+ }
142
+ const rowRanges = rows.get(row) === "full" ? [] : [...rows.get(row) ?? []];
143
+ for (const [start, end] of ranges) {
144
+ const lowerBound = Math.max(0, Math.min(frame.width, Math.floor(start)));
145
+ const upperBound = Math.max(lowerBound, Math.min(frame.width, Math.ceil(end)));
146
+ if (lowerBound >= upperBound) continue;
147
+ rects.push(cellRect(metrics, lowerBound, row, upperBound - lowerBound));
148
+ rowRanges.push({
149
+ start: lowerBound,
150
+ end: upperBound
151
+ });
152
+ }
153
+ if (rows.get(row) !== "full" && rowRanges.length > 0) rows.set(row, normalizeCellRanges(rowRanges));
154
+ }
155
+ return {
156
+ rects,
157
+ rows
158
+ };
159
+ }
160
+ drawTextLine(context, metrics, x, y, width, line, placement, fallbackColor) {
161
+ if (!line) return;
162
+ context.strokeStyle = line.color ?? fallbackColor;
163
+ context.lineWidth = line.pattern === "double" ? 2 : 1;
164
+ if (line.pattern === "dot") context.setLineDash([1, 3]);
165
+ else if (line.pattern === "dash") context.setLineDash([4, 3]);
166
+ else context.setLineDash([]);
167
+ const lineY = placement === "underline" ? y + metrics.cellHeight - 2 : y + Math.floor(metrics.cellHeight / 2);
168
+ context.beginPath();
169
+ context.moveTo(x, lineY);
170
+ context.lineTo(x + width, lineY);
171
+ context.stroke();
172
+ context.setLineDash([]);
173
+ }
174
+ };
175
+ /**
176
+ * The CSS font string for a cell, folding the surface emphasis bits (bold,
177
+ * italic) over the host terminal's font size/family. Exposed so the host can
178
+ * reuse the exact same metric when measuring cell dimensions.
179
+ */
180
+ function fontForStyle(terminalStyle, style) {
181
+ const emphasis = style?.em ?? 0;
182
+ return `${(emphasis & 2) !== 0 ? "italic " : ""}${(emphasis & 1) !== 0 ? "700 " : ""}${terminalStyle.fontSize}px ${terminalStyle.fontFamily}`;
183
+ }
184
+ function cellRect(metrics, x, y, span) {
185
+ return {
186
+ x: x * metrics.cellWidth,
187
+ y: y * metrics.cellHeight,
188
+ width: Math.max(1, span) * metrics.cellWidth,
189
+ height: metrics.cellHeight
190
+ };
191
+ }
192
+ async function decodeImage(dataBase64, format) {
193
+ const bytes = decodeBase64Bytes(dataBase64);
194
+ const blob = new Blob([bytes], { type: `image/${format}` });
195
+ if (typeof createImageBitmap === "function") return createImageBitmap(blob);
196
+ return new Promise((resolve, reject) => {
197
+ const image = new Image();
198
+ const url = URL.createObjectURL(blob);
199
+ image.onload = () => {
200
+ URL.revokeObjectURL(url);
201
+ resolve(image);
202
+ };
203
+ image.onerror = () => {
204
+ URL.revokeObjectURL(url);
205
+ reject(/* @__PURE__ */ new Error(`Failed to decode ${format} image`));
206
+ };
207
+ image.src = url;
208
+ });
209
+ }
210
+ function decodeBase64Bytes(value) {
211
+ if (typeof atob === "function") {
212
+ const binary = atob(value);
213
+ const bytes = new Uint8Array(binary.length);
214
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
215
+ return bytes;
216
+ }
217
+ return new Uint8Array(Buffer.from(value, "base64"));
218
+ }
219
+ function normalizeCellRanges(ranges) {
220
+ const sorted = ranges.filter((range) => range.end > range.start).sort((lhs, rhs) => lhs.start - rhs.start || lhs.end - rhs.end);
221
+ const normalized = [];
222
+ for (const range of sorted) {
223
+ const previous = normalized[normalized.length - 1];
224
+ if (previous && range.start <= previous.end) {
225
+ previous.end = Math.max(previous.end, range.end);
226
+ continue;
227
+ }
228
+ normalized.push({ ...range });
229
+ }
230
+ return normalized;
231
+ }
232
+ function cellIntersectsRanges(x, span, ranges) {
233
+ if (ranges === "full") return true;
234
+ const start = Math.floor(x);
235
+ const end = start + Math.max(1, Math.ceil(span));
236
+ return ranges.some((range) => start < range.end && end > range.start);
237
+ }
238
+ function dirtyRegionIntersectsCellRect(region, x, y, width, height) {
239
+ const startRow = Math.max(0, Math.floor(y));
240
+ const endRow = Math.max(startRow, Math.ceil(y + height));
241
+ const rectRange = {
242
+ start: Math.floor(x),
243
+ end: Math.floor(x) + Math.max(1, Math.ceil(width))
244
+ };
245
+ for (let row = startRow; row < endRow; row += 1) {
246
+ const ranges = region.rows.get(row);
247
+ if (!ranges) continue;
248
+ if (cellIntersectsRanges(rectRange.start, rectRange.end - rectRange.start, ranges)) return true;
249
+ }
250
+ return false;
251
+ }
252
+ function resolvedForeground(style, terminalStyle) {
253
+ if ((style?.em ?? 0) & 16) return style?.bg ?? terminalStyle.theme.background;
254
+ return style?.fg ?? terminalStyle.theme.foreground;
255
+ }
256
+ function resolvedBackground(style, terminalStyle) {
257
+ if ((style?.em ?? 0) & 16) return style?.fg ?? terminalStyle.theme.foreground;
258
+ return style?.bg;
259
+ }
260
+ //#endregion
261
+ export { CanvasSurfacePainter, fontForStyle };
262
+
263
+ //# sourceMappingURL=CanvasSurfacePainter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CanvasSurfacePainter.js","names":[],"sources":["../../src/CanvasSurfacePainter.ts"],"sourcesContent":["import {\n canRenderBoxDrawing,\n drawBoxDrawing,\n} from \"./BoxDrawingRenderer.ts\";\nimport {\n type ResolvedWebHostTerminalStyle,\n webTUITerminalBackgroundColor,\n} from \"./WebHostTerminalStyle.ts\";\nimport type {\n WebHostSurfaceDamage,\n WebHostSurfaceFrame,\n WebHostSurfaceImage,\n WebHostSurfaceImageFormat,\n WebHostSurfaceStyle,\n} from \"./WebHostSurfaceTransport.ts\";\n\n/**\n * A read-only snapshot of the cell grid geometry and active style the painter\n * needs for a single paint pass. The runtime owns this state and mutates it as\n * the surface resizes or restyles; passing a fresh snapshot per `paint` keeps\n * the painter stateless about geometry and avoids stale reads.\n */\nexport interface CanvasSurfaceMetrics {\n columns: number;\n rows: number;\n cellWidth: number;\n cellHeight: number;\n style: ResolvedWebHostTerminalStyle;\n}\n\ninterface CachedWebHostImage {\n image?: CanvasImageSource;\n promise?: Promise<CanvasImageSource>;\n}\n\ninterface DirtyRect {\n x: number;\n y: number;\n width: number;\n height: number;\n}\n\ninterface DirtyCellRange {\n start: number;\n end: number;\n}\n\ntype DirtyRowRanges = \"full\" | DirtyCellRange[];\n\ninterface DirtyRegion {\n rects: DirtyRect[];\n rows: Map<number, DirtyRowRanges>;\n}\n\n/**\n * Draws SwiftTUI surface frames onto a 2D canvas: background fills, per-cell\n * text/box-drawing/decorations, surface images, and damage-scoped dirty-region\n * painting. The painter caches decoded images and asks the host to repaint once\n * an image finishes decoding (via the `requestRedraw` callback).\n *\n * Geometry and style are supplied per paint via {@link CanvasSurfaceMetrics};\n * the painter holds only the canvas handle, the image cache, and the redraw\n * callback as durable state.\n */\nexport class CanvasSurfacePainter {\n private readonly imageCache = new Map<string, CachedWebHostImage>();\n private canvas?: HTMLCanvasElement;\n private requestRedraw: () => void = () => {};\n\n /**\n * Binds the canvas the painter draws into and the callback used to request a\n * full repaint after an asynchronous image decode completes.\n */\n attach(\n canvas: HTMLCanvasElement,\n requestRedraw: () => void\n ): void {\n this.canvas = canvas;\n this.requestRedraw = requestRedraw;\n }\n\n paint(\n metrics: CanvasSurfaceMetrics,\n frame: WebHostSurfaceFrame | undefined,\n damage?: WebHostSurfaceDamage\n ): void {\n const canvas = this.canvas;\n const context = canvas?.getContext(\"2d\");\n if (!canvas || !context) {\n return;\n }\n\n const dirtyRegion = frame\n ? this.dirtyRegionForDamage(damage, frame, metrics)\n : undefined;\n if (dirtyRegion?.rects.length === 0) {\n return;\n }\n\n const scale = globalThis.window?.devicePixelRatio || 1;\n context.setTransform(scale, 0, 0, scale, 0, 0);\n context.textBaseline = \"alphabetic\";\n\n context.fillStyle = webTUITerminalBackgroundColor(metrics.style);\n if (dirtyRegion) {\n for (const rect of dirtyRegion.rects) {\n context.clearRect(rect.x, rect.y, rect.width, rect.height);\n context.fillRect(rect.x, rect.y, rect.width, rect.height);\n }\n } else {\n context.clearRect(0, 0, canvas.width / scale, canvas.height / scale);\n context.fillRect(0, 0, metrics.columns * metrics.cellWidth, metrics.rows * metrics.cellHeight);\n }\n\n if (!frame) {\n return;\n }\n\n this.drawRows(context, frame, metrics, dirtyRegion);\n this.drawImages(context, frame.images ?? [], metrics, dirtyRegion);\n }\n\n private drawRows(\n context: CanvasRenderingContext2D,\n frame: WebHostSurfaceFrame,\n metrics: CanvasSurfaceMetrics,\n dirtyRegion?: DirtyRegion\n ): void {\n if (dirtyRegion) {\n for (const [y, ranges] of dirtyRegion.rows) {\n const row = frame.rows[y] ?? [];\n this.drawRow(context, frame, metrics, row, y, ranges);\n }\n return;\n }\n\n for (let y = 0; y < frame.rows.length; y += 1) {\n const row = frame.rows[y] ?? [];\n this.drawRow(context, frame, metrics, row, y);\n }\n }\n\n private drawRow(\n context: CanvasRenderingContext2D,\n frame: WebHostSurfaceFrame,\n metrics: CanvasSurfaceMetrics,\n row: WebHostSurfaceFrame[\"rows\"][number],\n y: number,\n ranges?: DirtyRowRanges\n ): void {\n for (const cell of row) {\n const [x, text, span, styleIndex] = cell;\n if (ranges !== undefined && !cellIntersectsRanges(x, span, ranges)) {\n continue;\n }\n const style = frame.styles[styleIndex] ?? undefined;\n this.drawCell(context, metrics, x, y, text, span, style);\n }\n }\n\n private drawImages(\n context: CanvasRenderingContext2D,\n images: WebHostSurfaceImage[],\n metrics: CanvasSurfaceMetrics,\n dirtyRegion?: DirtyRegion\n ): void {\n for (const image of images) {\n this.drawImage(context, image, metrics, dirtyRegion);\n }\n }\n\n private drawImage(\n context: CanvasRenderingContext2D,\n image: WebHostSurfaceImage,\n metrics: CanvasSurfaceMetrics,\n dirtyRegion?: DirtyRegion\n ): void {\n const decodedImage = this.cachedImage(image);\n if (!decodedImage) {\n return;\n }\n\n const [boundsX, boundsY, boundsWidth, boundsHeight] = image.bounds;\n const [clipX, clipY, clipWidth, clipHeight] = image.visibleBounds;\n if (boundsWidth <= 0 || boundsHeight <= 0 || clipWidth <= 0 || clipHeight <= 0) {\n return;\n }\n if (\n dirtyRegion\n && !dirtyRegionIntersectsCellRect(dirtyRegion, clipX, clipY, clipWidth, clipHeight)\n ) {\n return;\n }\n\n context.save();\n context.beginPath();\n context.rect(\n clipX * metrics.cellWidth,\n clipY * metrics.cellHeight,\n clipWidth * metrics.cellWidth,\n clipHeight * metrics.cellHeight\n );\n context.clip();\n context.drawImage(\n decodedImage,\n boundsX * metrics.cellWidth,\n boundsY * metrics.cellHeight,\n boundsWidth * metrics.cellWidth,\n boundsHeight * metrics.cellHeight\n );\n context.restore();\n }\n\n private cachedImage(\n image: WebHostSurfaceImage\n ): CanvasImageSource | undefined {\n const cached = this.imageCache.get(image.id);\n if (cached?.image) {\n return cached.image;\n }\n\n if (!cached?.promise && image.dataBase64) {\n const promise = decodeImage(image.dataBase64, image.format);\n this.imageCache.set(image.id, { promise });\n void promise.then((decodedImage) => {\n const latest = this.imageCache.get(image.id);\n if (latest?.promise !== promise) {\n return;\n }\n this.imageCache.set(image.id, { image: decodedImage });\n this.requestRedraw();\n }).catch(() => {\n this.imageCache.delete(image.id);\n });\n }\n\n return undefined;\n }\n\n private drawCell(\n context: CanvasRenderingContext2D,\n metrics: CanvasSurfaceMetrics,\n x: number,\n y: number,\n text: string,\n span: number,\n style?: WebHostSurfaceStyle | null\n ): void {\n const rectX = x * metrics.cellWidth;\n const rectY = y * metrics.cellHeight;\n const width = Math.max(1, span) * metrics.cellWidth;\n const background = resolvedBackground(style, metrics.style);\n const foreground = resolvedForeground(style, metrics.style);\n const opacity = style?.opacity ?? 1;\n\n if (background) {\n context.globalAlpha = opacity;\n context.fillStyle = background;\n context.fillRect(rectX, rectY, width, metrics.cellHeight);\n }\n\n if (text !== \" \") {\n context.globalAlpha = opacity;\n context.fillStyle = foreground;\n context.strokeStyle = foreground;\n if (!canRenderBoxDrawing(text) || !drawBoxDrawing(context, text, {\n x: rectX,\n y: rectY,\n width,\n height: metrics.cellHeight,\n })) {\n context.font = fontForStyle(metrics.style, style);\n context.fillText(\n text,\n rectX,\n rectY + Math.floor((metrics.cellHeight + metrics.style.fontSize) / 2) - 2\n );\n }\n }\n\n this.drawTextLine(context, metrics, rectX, rectY, width, style?.underline, \"underline\", foreground);\n this.drawTextLine(context, metrics, rectX, rectY, width, style?.strikethrough, \"strike\", foreground);\n context.globalAlpha = 1;\n }\n\n private dirtyRegionForDamage(\n damage: WebHostSurfaceDamage | undefined,\n frame: WebHostSurfaceFrame,\n metrics: CanvasSurfaceMetrics\n ): DirtyRegion | undefined {\n if (!damage || damage.requiresFullTextRepaint || damage.requiresFullGraphicsReplay) {\n return undefined;\n }\n\n const rects: DirtyRect[] = [];\n const rows = new Map<number, DirtyRowRanges>();\n for (const [row, ranges] of damage.textRows) {\n if (row < 0 || row >= frame.height) {\n continue;\n }\n if (ranges.length === 0) {\n rects.push(cellRect(metrics, 0, row, frame.width));\n rows.set(row, \"full\");\n continue;\n }\n const rowRanges: DirtyCellRange[] = rows.get(row) === \"full\"\n ? []\n : [...(rows.get(row) as DirtyCellRange[] | undefined ?? [])];\n for (const [start, end] of ranges) {\n const lowerBound = Math.max(0, Math.min(frame.width, Math.floor(start)));\n const upperBound = Math.max(lowerBound, Math.min(frame.width, Math.ceil(end)));\n if (lowerBound >= upperBound) {\n continue;\n }\n rects.push(cellRect(metrics, lowerBound, row, upperBound - lowerBound));\n rowRanges.push({ start: lowerBound, end: upperBound });\n }\n if (rows.get(row) !== \"full\" && rowRanges.length > 0) {\n rows.set(row, normalizeCellRanges(rowRanges));\n }\n }\n return { rects, rows };\n }\n\n private drawTextLine(\n context: CanvasRenderingContext2D,\n metrics: CanvasSurfaceMetrics,\n x: number,\n y: number,\n width: number,\n line: WebHostSurfaceStyle[\"underline\"],\n placement: \"underline\" | \"strike\",\n fallbackColor: string\n ): void {\n if (!line) {\n return;\n }\n context.strokeStyle = line.color ?? fallbackColor;\n context.lineWidth = line.pattern === \"double\" ? 2 : 1;\n if (line.pattern === \"dot\") {\n context.setLineDash([1, 3]);\n } else if (line.pattern === \"dash\") {\n context.setLineDash([4, 3]);\n } else {\n context.setLineDash([]);\n }\n\n const lineY = placement === \"underline\"\n ? y + metrics.cellHeight - 2\n : y + Math.floor(metrics.cellHeight / 2);\n context.beginPath();\n context.moveTo(x, lineY);\n context.lineTo(x + width, lineY);\n context.stroke();\n context.setLineDash([]);\n }\n}\n\n/**\n * The CSS font string for a cell, folding the surface emphasis bits (bold,\n * italic) over the host terminal's font size/family. Exposed so the host can\n * reuse the exact same metric when measuring cell dimensions.\n */\nexport function fontForStyle(\n terminalStyle: ResolvedWebHostTerminalStyle,\n style?: WebHostSurfaceStyle | null\n): string {\n const emphasis = style?.em ?? 0;\n const italic = (emphasis & 2) !== 0 ? \"italic \" : \"\";\n const weight = (emphasis & 1) !== 0 ? \"700 \" : \"\";\n return `${italic}${weight}${terminalStyle.fontSize}px ${terminalStyle.fontFamily}`;\n}\n\nfunction cellRect(\n metrics: CanvasSurfaceMetrics,\n x: number,\n y: number,\n span: number\n): DirtyRect {\n return {\n x: x * metrics.cellWidth,\n y: y * metrics.cellHeight,\n width: Math.max(1, span) * metrics.cellWidth,\n height: metrics.cellHeight,\n };\n}\n\nasync function decodeImage(\n dataBase64: string,\n format: WebHostSurfaceImageFormat\n): Promise<CanvasImageSource> {\n const bytes = decodeBase64Bytes(dataBase64);\n const blob = new Blob([bytes], { type: `image/${format}` });\n\n if (typeof createImageBitmap === \"function\") {\n // Animated GIFs collapse to their first frame in createImageBitmap\n // — that matches the Kitty path's first-frame composite. Phase 7\n // will replace this with a frame ticker.\n return createImageBitmap(blob);\n }\n\n return new Promise((resolve, reject) => {\n const image = new Image();\n const url = URL.createObjectURL(blob);\n image.onload = () => {\n URL.revokeObjectURL(url);\n resolve(image);\n };\n image.onerror = () => {\n URL.revokeObjectURL(url);\n reject(new Error(`Failed to decode ${format} image`));\n };\n image.src = url;\n });\n}\n\nfunction decodeBase64Bytes(\n value: string\n): Uint8Array {\n if (typeof atob === \"function\") {\n const binary = atob(value);\n const bytes = new Uint8Array(binary.length);\n for (let index = 0; index < binary.length; index += 1) {\n bytes[index] = binary.charCodeAt(index);\n }\n return bytes;\n }\n\n return new Uint8Array(Buffer.from(value, \"base64\"));\n}\n\nfunction normalizeCellRanges(\n ranges: DirtyCellRange[]\n): DirtyCellRange[] {\n const sorted = ranges\n .filter((range) => range.end > range.start)\n .sort((lhs, rhs) => lhs.start - rhs.start || lhs.end - rhs.end);\n const normalized: DirtyCellRange[] = [];\n for (const range of sorted) {\n const previous = normalized[normalized.length - 1];\n if (previous && range.start <= previous.end) {\n previous.end = Math.max(previous.end, range.end);\n continue;\n }\n normalized.push({ ...range });\n }\n return normalized;\n}\n\nfunction cellIntersectsRanges(\n x: number,\n span: number,\n ranges: DirtyRowRanges\n): boolean {\n if (ranges === \"full\") {\n return true;\n }\n const start = Math.floor(x);\n const end = start + Math.max(1, Math.ceil(span));\n return ranges.some((range) => start < range.end && end > range.start);\n}\n\nfunction dirtyRegionIntersectsCellRect(\n region: DirtyRegion,\n x: number,\n y: number,\n width: number,\n height: number\n): boolean {\n const startRow = Math.max(0, Math.floor(y));\n const endRow = Math.max(startRow, Math.ceil(y + height));\n const rectRange = {\n start: Math.floor(x),\n end: Math.floor(x) + Math.max(1, Math.ceil(width)),\n };\n for (let row = startRow; row < endRow; row += 1) {\n const ranges = region.rows.get(row);\n if (!ranges) {\n continue;\n }\n if (cellIntersectsRanges(rectRange.start, rectRange.end - rectRange.start, ranges)) {\n return true;\n }\n }\n return false;\n}\n\nfunction resolvedForeground(\n style: WebHostSurfaceStyle | null | undefined,\n terminalStyle: ResolvedWebHostTerminalStyle\n): string {\n if ((style?.em ?? 0) & 16) {\n return style?.bg ?? terminalStyle.theme.background;\n }\n return style?.fg ?? terminalStyle.theme.foreground;\n}\n\nfunction resolvedBackground(\n style: WebHostSurfaceStyle | null | undefined,\n terminalStyle: ResolvedWebHostTerminalStyle\n): string | undefined {\n if ((style?.em ?? 0) & 16) {\n return style?.fg ?? terminalStyle.theme.foreground;\n }\n return style?.bg;\n}\n"],"mappings":"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"}
@@ -0,0 +1,117 @@
1
+ import { encodeKeyInputMessage, encodeMouseInputMessage, encodePasteInputMessage } from "./WebHostSurfaceTransport.js";
2
+ //#region src/InputEventEncoder.ts
3
+ /**
4
+ * Encodes DOM keyboard/pointer/wheel/paste events into the `web-surface` wire
5
+ * messages the SwiftTUI host consumes. This collaborator owns the translation
6
+ * from browser event vocabulary (key names, button indices, wheel deltas,
7
+ * modifier flags) to the transport's message vocabulary; it produces raw byte
8
+ * chunks and holds no DOM or geometry state.
9
+ *
10
+ * Hit-testing (pixel → cell) lives in the host, which passes already-resolved
11
+ * {@link CellLocation}s here so the encoder stays free of layout concerns.
12
+ */
13
+ var InputEventEncoder = class {
14
+ /**
15
+ * Builds the key message for a keyboard event, or `undefined` when the event
16
+ * does not map to a forwarded key (e.g. a multi-codepoint composed string).
17
+ * Returning `undefined` lets the host leave the event unhandled.
18
+ */
19
+ encodeKey(event) {
20
+ const key = keyInputFromKeyboardEvent(event);
21
+ if (!key) return;
22
+ return encodeKeyInputMessage({
23
+ ...key,
24
+ modifiers: modifierMask(event)
25
+ });
26
+ }
27
+ encodePaste(text) {
28
+ return encodePasteInputMessage(text);
29
+ }
30
+ encodePointerDown(location, button, event) {
31
+ return encodeMouseInputMessage({
32
+ kind: "down",
33
+ x: location.x,
34
+ y: location.y,
35
+ button,
36
+ modifiers: modifierMask(event)
37
+ });
38
+ }
39
+ encodePointerUp(location, button, event) {
40
+ return encodeMouseInputMessage({
41
+ kind: "up",
42
+ x: location.x,
43
+ y: location.y,
44
+ button,
45
+ modifiers: modifierMask(event)
46
+ });
47
+ }
48
+ encodePointerMove(location, button, event) {
49
+ return encodeMouseInputMessage({
50
+ kind: event.buttons ? "dragged" : "moved",
51
+ x: location.x,
52
+ y: location.y,
53
+ button,
54
+ modifiers: modifierMask(event)
55
+ });
56
+ }
57
+ encodeWheel(location, event) {
58
+ return encodeMouseInputMessage({
59
+ kind: "scrolled",
60
+ x: location.x,
61
+ y: location.y,
62
+ deltaX: normalizedWheelDelta(event.deltaX),
63
+ deltaY: normalizedWheelDelta(event.deltaY),
64
+ modifiers: modifierMask(event)
65
+ });
66
+ }
67
+ /** Translates a DOM `MouseEvent.button` index into the wire button identity. */
68
+ pointerButton(button) {
69
+ return pointerButton(button);
70
+ }
71
+ };
72
+ function keyInputFromKeyboardEvent(event) {
73
+ switch (event.key) {
74
+ case "Enter": return { key: "return" };
75
+ case " ": return { key: "space" };
76
+ case "Tab": return { key: "tab" };
77
+ case "ArrowLeft": return { key: "arrowLeft" };
78
+ case "ArrowRight": return { key: "arrowRight" };
79
+ case "ArrowUp": return { key: "arrowUp" };
80
+ case "ArrowDown": return { key: "arrowDown" };
81
+ case "Backspace": return { key: "backspace" };
82
+ case "Escape": return { key: "escape" };
83
+ case "Home": return { key: "home" };
84
+ case "End": return { key: "end" };
85
+ default: {
86
+ const characters = Array.from(event.key);
87
+ if (characters.length !== 1) return;
88
+ return {
89
+ key: "character",
90
+ character: characters[0]
91
+ };
92
+ }
93
+ }
94
+ }
95
+ function pointerButton(button) {
96
+ switch (button) {
97
+ case 1: return "middle";
98
+ case 2: return "secondary";
99
+ default: return "primary";
100
+ }
101
+ }
102
+ function modifierMask(event) {
103
+ let mask = 0;
104
+ if (event.shiftKey) mask |= 1;
105
+ if (event.altKey) mask |= 2;
106
+ if (event.ctrlKey) mask |= 4;
107
+ return mask;
108
+ }
109
+ function normalizedWheelDelta(delta) {
110
+ if (delta > 0) return 1;
111
+ if (delta < 0) return -1;
112
+ return 0;
113
+ }
114
+ //#endregion
115
+ export { InputEventEncoder };
116
+
117
+ //# sourceMappingURL=InputEventEncoder.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"InputEventEncoder.js","names":[],"sources":["../../src/InputEventEncoder.ts"],"sourcesContent":["import {\n encodeKeyInputMessage,\n encodeMouseInputMessage,\n encodePasteInputMessage,\n type WebHostKeyInput,\n} from \"./WebHostSurfaceTransport.ts\";\n\n/** A resolved cell-grid location (fractional cell coordinates). */\nexport interface CellLocation {\n x: number;\n y: number;\n}\n\n/**\n * Pointer button identity in the wire vocabulary. Mirrors the values the Swift\n * host expects on `mouse:` records.\n */\nexport type PointerButton = \"primary\" | \"middle\" | \"secondary\";\n\n/**\n * Encodes DOM keyboard/pointer/wheel/paste events into the `web-surface` wire\n * messages the SwiftTUI host consumes. This collaborator owns the translation\n * from browser event vocabulary (key names, button indices, wheel deltas,\n * modifier flags) to the transport's message vocabulary; it produces raw byte\n * chunks and holds no DOM or geometry state.\n *\n * Hit-testing (pixel → cell) lives in the host, which passes already-resolved\n * {@link CellLocation}s here so the encoder stays free of layout concerns.\n */\nexport class InputEventEncoder {\n /**\n * Builds the key message for a keyboard event, or `undefined` when the event\n * does not map to a forwarded key (e.g. a multi-codepoint composed string).\n * Returning `undefined` lets the host leave the event unhandled.\n */\n encodeKey(\n event: KeyboardEvent\n ): Uint8Array | undefined {\n const key = keyInputFromKeyboardEvent(event);\n if (!key) {\n return undefined;\n }\n return encodeKeyInputMessage({\n ...key,\n modifiers: modifierMask(event),\n });\n }\n\n encodePaste(\n text: string\n ): Uint8Array {\n return encodePasteInputMessage(text);\n }\n\n encodePointerDown(\n location: CellLocation,\n button: PointerButton,\n event: PointerEvent\n ): Uint8Array {\n return encodeMouseInputMessage({\n kind: \"down\",\n x: location.x,\n y: location.y,\n button,\n modifiers: modifierMask(event),\n });\n }\n\n encodePointerUp(\n location: CellLocation,\n button: PointerButton,\n event: PointerEvent\n ): Uint8Array {\n return encodeMouseInputMessage({\n kind: \"up\",\n x: location.x,\n y: location.y,\n button,\n modifiers: modifierMask(event),\n });\n }\n\n encodePointerMove(\n location: CellLocation,\n button: PointerButton,\n event: PointerEvent\n ): Uint8Array {\n return encodeMouseInputMessage({\n kind: event.buttons ? \"dragged\" : \"moved\",\n x: location.x,\n y: location.y,\n button,\n modifiers: modifierMask(event),\n });\n }\n\n encodeWheel(\n location: CellLocation,\n event: WheelEvent\n ): Uint8Array {\n return encodeMouseInputMessage({\n kind: \"scrolled\",\n x: location.x,\n y: location.y,\n deltaX: normalizedWheelDelta(event.deltaX),\n deltaY: normalizedWheelDelta(event.deltaY),\n modifiers: modifierMask(event),\n });\n }\n\n /** Translates a DOM `MouseEvent.button` index into the wire button identity. */\n pointerButton(\n button: number\n ): PointerButton {\n return pointerButton(button);\n }\n}\n\nfunction keyInputFromKeyboardEvent(\n event: KeyboardEvent\n): Pick<WebHostKeyInput, \"key\" | \"character\"> | undefined {\n switch (event.key) {\n case \"Enter\":\n return { key: \"return\" };\n case \" \":\n return { key: \"space\" };\n case \"Tab\":\n return { key: \"tab\" };\n case \"ArrowLeft\":\n return { key: \"arrowLeft\" };\n case \"ArrowRight\":\n return { key: \"arrowRight\" };\n case \"ArrowUp\":\n return { key: \"arrowUp\" };\n case \"ArrowDown\":\n return { key: \"arrowDown\" };\n case \"Backspace\":\n return { key: \"backspace\" };\n case \"Escape\":\n return { key: \"escape\" };\n case \"Home\":\n return { key: \"home\" };\n case \"End\":\n return { key: \"end\" };\n default:\n {\n const characters = Array.from(event.key);\n if (characters.length !== 1) {\n return undefined;\n }\n return {\n key: \"character\",\n character: characters[0],\n };\n }\n }\n}\n\nfunction pointerButton(\n button: number\n): PointerButton {\n switch (button) {\n case 1:\n return \"middle\";\n case 2:\n return \"secondary\";\n default:\n return \"primary\";\n }\n}\n\nfunction modifierMask(\n event: MouseEvent | KeyboardEvent\n): number {\n let mask = 0;\n if (event.shiftKey) {\n mask |= 1;\n }\n if (event.altKey) {\n mask |= 2;\n }\n if (event.ctrlKey) {\n mask |= 4;\n }\n return mask;\n}\n\nfunction normalizedWheelDelta(\n delta: number\n): number {\n if (delta > 0) {\n return 1;\n }\n if (delta < 0) {\n return -1;\n }\n return 0;\n}\n"],"mappings":";;;;;;;;;;;;AA6BA,IAAa,oBAAb,MAA+B;;;;;;CAM7B,UACE,OACwB;EACxB,MAAM,MAAM,0BAA0B,KAAK;EAC3C,IAAI,CAAC,KACH;EAEF,OAAO,sBAAsB;GAC3B,GAAG;GACH,WAAW,aAAa,KAAK;EAC/B,CAAC;CACH;CAEA,YACE,MACY;EACZ,OAAO,wBAAwB,IAAI;CACrC;CAEA,kBACE,UACA,QACA,OACY;EACZ,OAAO,wBAAwB;GAC7B,MAAM;GACN,GAAG,SAAS;GACZ,GAAG,SAAS;GACZ;GACA,WAAW,aAAa,KAAK;EAC/B,CAAC;CACH;CAEA,gBACE,UACA,QACA,OACY;EACZ,OAAO,wBAAwB;GAC7B,MAAM;GACN,GAAG,SAAS;GACZ,GAAG,SAAS;GACZ;GACA,WAAW,aAAa,KAAK;EAC/B,CAAC;CACH;CAEA,kBACE,UACA,QACA,OACY;EACZ,OAAO,wBAAwB;GAC7B,MAAM,MAAM,UAAU,YAAY;GAClC,GAAG,SAAS;GACZ,GAAG,SAAS;GACZ;GACA,WAAW,aAAa,KAAK;EAC/B,CAAC;CACH;CAEA,YACE,UACA,OACY;EACZ,OAAO,wBAAwB;GAC7B,MAAM;GACN,GAAG,SAAS;GACZ,GAAG,SAAS;GACZ,QAAQ,qBAAqB,MAAM,MAAM;GACzC,QAAQ,qBAAqB,MAAM,MAAM;GACzC,WAAW,aAAa,KAAK;EAC/B,CAAC;CACH;;CAGA,cACE,QACe;EACf,OAAO,cAAc,MAAM;CAC7B;AACF;AAEA,SAAS,0BACP,OACwD;CACxD,QAAQ,MAAM,KAAd;EACA,KAAK,SACH,OAAO,EAAE,KAAK,SAAS;EACzB,KAAK,KACH,OAAO,EAAE,KAAK,QAAQ;EACxB,KAAK,OACH,OAAO,EAAE,KAAK,MAAM;EACtB,KAAK,aACH,OAAO,EAAE,KAAK,YAAY;EAC5B,KAAK,cACH,OAAO,EAAE,KAAK,aAAa;EAC7B,KAAK,WACH,OAAO,EAAE,KAAK,UAAU;EAC1B,KAAK,aACH,OAAO,EAAE,KAAK,YAAY;EAC5B,KAAK,aACH,OAAO,EAAE,KAAK,YAAY;EAC5B,KAAK,UACH,OAAO,EAAE,KAAK,SAAS;EACzB,KAAK,QACH,OAAO,EAAE,KAAK,OAAO;EACvB,KAAK,OACH,OAAO,EAAE,KAAK,MAAM;EACtB,SACE;GACE,MAAM,aAAa,MAAM,KAAK,MAAM,GAAG;GACvC,IAAI,WAAW,WAAW,GACxB;GAEF,OAAO;IACL,KAAK;IACL,WAAW,WAAW;GACxB;EACF;CACF;AACF;AAEA,SAAS,cACP,QACe;CACf,QAAQ,QAAR;EACA,KAAK,GACH,OAAO;EACT,KAAK,GACH,OAAO;EACT,SACE,OAAO;CACT;AACF;AAEA,SAAS,aACP,OACQ;CACR,IAAI,OAAO;CACX,IAAI,MAAM,UACR,QAAQ;CAEV,IAAI,MAAM,QACR,QAAQ;CAEV,IAAI,MAAM,SACR,QAAQ;CAEV,OAAO;AACT;AAEA,SAAS,qBACP,OACQ;CACR,IAAI,QAAQ,GACV,OAAO;CAET,IAAI,QAAQ,GACV,OAAO;CAET,OAAO;AACT"}
@@ -0,0 +1,72 @@
1
+ //#region src/PointerGeometry.ts
2
+ /**
3
+ * Converts a pointer event to fractional cell coordinates, clamped to the
4
+ * grid. Returns `undefined` when the pointer is outside the cell grid (in the
5
+ * sub-cell margin/gutter), so the host can leave the event unhandled.
6
+ */
7
+ function cellLocationForEvent(event, metrics) {
8
+ const location = rawCellLocationForEvent(event, metrics);
9
+ if (!location) return;
10
+ const cellX = Math.floor(location.x);
11
+ const cellY = Math.floor(location.y);
12
+ if (cellX < 0 || cellY < 0 || cellX >= metrics.columns || cellY >= metrics.rows) return;
13
+ return location;
14
+ }
15
+ /**
16
+ * Converts a pointer event to fractional cell coordinates *without* clamping to
17
+ * the grid. Used while a pointer is captured (drag) so the host keeps tracking
18
+ * the gesture even when it strays outside the surface bounds.
19
+ */
20
+ function rawCellLocationForEvent(event, metrics) {
21
+ const rect = metrics.rect;
22
+ if (!rect) return;
23
+ return {
24
+ x: (event.clientX - rect.left) / metrics.cellWidth,
25
+ y: (event.clientY - rect.top) / metrics.cellHeight
26
+ };
27
+ }
28
+ /**
29
+ * Whether any scrollable region under `location` can still scroll in the
30
+ * wheel's direction. Mirrors the Swift host's scroll hit-test: a region
31
+ * qualifies when its viewport contains the cell AND it has remaining headroom
32
+ * in the delta's direction. Used by "chain" wheel mode to decide capture vs.
33
+ * fall-through. With no published `scrollRegions`, nothing can scroll, so the
34
+ * wheel chains to the page (a scene with no ScrollView stays fully passive).
35
+ */
36
+ function wheelTargetCanScroll(regions, location, deltaX, deltaY) {
37
+ if (!regions || regions.length === 0) return false;
38
+ const cellX = Math.floor(location.x);
39
+ const cellY = Math.floor(location.y);
40
+ for (const region of regions) {
41
+ const [rx, ry, rw, rh] = region.rect;
42
+ if (cellX < rx || cellY < ry || cellX >= rx + rw || cellY >= ry + rh) continue;
43
+ if (regionCanScrollInDirection(region, deltaX, deltaY)) return true;
44
+ }
45
+ return false;
46
+ }
47
+ /**
48
+ * Whether a published scroll region has remaining headroom in the wheel's
49
+ * direction, recomputing the per-direction extent from offset/content/viewport.
50
+ * Mirrors SwiftTUI's clamp (`min(max(0, offset), max(0, content - viewport))`)
51
+ * so the host and the app agree on "at edge". Wheel sign convention matches the
52
+ * app: `deltaY > 0` scrolls down (offset grows toward the content bottom).
53
+ * Diagonal wheels qualify if either axis has headroom.
54
+ */
55
+ function regionCanScrollInDirection(region, deltaX, deltaY) {
56
+ const [, , viewportWidth, viewportHeight] = region.rect;
57
+ const [offsetX, offsetY] = region.offset;
58
+ const [contentWidth, contentHeight] = region.content;
59
+ const maxX = Math.max(0, contentWidth - viewportWidth);
60
+ const maxY = Math.max(0, contentHeight - viewportHeight);
61
+ const clampedX = Math.min(Math.max(0, offsetX), maxX);
62
+ const clampedY = Math.min(Math.max(0, offsetY), maxY);
63
+ if (deltaY > 0 && clampedY < maxY) return true;
64
+ if (deltaY < 0 && clampedY > 0) return true;
65
+ if (deltaX > 0 && clampedX < maxX) return true;
66
+ if (deltaX < 0 && clampedX > 0) return true;
67
+ return false;
68
+ }
69
+ //#endregion
70
+ export { cellLocationForEvent, rawCellLocationForEvent, wheelTargetCanScroll };
71
+
72
+ //# sourceMappingURL=PointerGeometry.js.map