@alchemy.run/sigil 0.0.0-alpha.4 → 0.0.0-alpha.6

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.
Files changed (138) hide show
  1. package/README.md +21 -9
  2. package/THIRD_PARTY_NOTICES.md +39 -1
  3. package/dist/Text-BobFKi74.d.ts +452 -0
  4. package/dist/ansi.d.ts +122 -131
  5. package/dist/ansi.js +86 -6
  6. package/dist/capabilities.d.ts +5 -0
  7. package/dist/capabilities.js +3 -0
  8. package/dist/cell-_ZVhbfl0.js +44 -0
  9. package/dist/color-CkbalRqK.js +2 -0
  10. package/dist/color-policy-BMzMwV7Q.d.ts +22 -0
  11. package/dist/color-policy-SVj1pYTA.js +560 -0
  12. package/dist/color-profile-DHhQHY55.js +36 -0
  13. package/dist/color-profile-u0Nhe9Nv.d.ts +97 -0
  14. package/dist/color.d.ts +21 -0
  15. package/dist/color.js +3 -0
  16. package/dist/cursor-position-D2LAkRG0.d.ts +7 -0
  17. package/dist/detect-Bh4yGP6w.d.ts +186 -0
  18. package/dist/detect-BuTXtY6e.js +373 -0
  19. package/dist/env-YVw64yZS.js +9 -0
  20. package/dist/escapes-CB_6CWOE.d.ts +72 -0
  21. package/dist/geometry-BxXOzJgo.d.ts +11 -0
  22. package/dist/index-DZ88EXJv.d.ts +21 -0
  23. package/dist/index.d.ts +143 -498
  24. package/dist/index.js +1026 -2244
  25. package/dist/osc-CCH7xDoS.js +71 -0
  26. package/dist/osc-Cn0fw77g.d.ts +23 -0
  27. package/dist/paint-C19minOS.d.ts +81 -0
  28. package/dist/query-vaIeGOkH.d.ts +152 -0
  29. package/dist/router.d.ts +392 -0
  30. package/dist/router.js +709 -0
  31. package/dist/sample-Cqw1bjUL.js +445 -0
  32. package/dist/screen-BOSLQ8fF.d.ts +49 -0
  33. package/dist/screen-CiPytswf.js +342 -0
  34. package/dist/screen.d.ts +5 -0
  35. package/dist/screen.js +5 -0
  36. package/dist/semantic-text-style-DIMzC7xt.js +91 -0
  37. package/dist/serialize-BTkAZgw1.js +79 -0
  38. package/dist/session-aZr9O8h3.js +665 -0
  39. package/dist/sgr-BhwaWAJB.js +246 -0
  40. package/dist/store-CgrG9K4y.d.ts +72 -0
  41. package/dist/string-width-CijQwpIk.js +69 -0
  42. package/dist/strip-BvU4toXG.js +6 -0
  43. package/dist/terminal.d.ts +118 -0
  44. package/dist/terminal.js +2 -0
  45. package/dist/tokenize-AjqbvtiT.js +1242 -0
  46. package/dist/tokenize-Dx1y_l5H.d.ts +57 -0
  47. package/dist/truncate-D31fhU6i.js +562 -0
  48. package/dist/use-focus-BzqAJi0n.js +1337 -0
  49. package/package.json +41 -9
  50. package/src/ansi/chalk.ts +5 -3
  51. package/src/ansi/escapes.ts +14 -0
  52. package/src/ansi/graphemes.ts +8 -0
  53. package/src/ansi/hyperlink.ts +44 -0
  54. package/src/ansi/index.ts +3 -1
  55. package/src/ansi/osc.ts +77 -0
  56. package/src/ansi/tokenize.ts +3 -4
  57. package/src/capabilities/color-policy.ts +34 -0
  58. package/src/capabilities/detect.ts +594 -0
  59. package/src/capabilities/index.ts +37 -0
  60. package/src/capabilities/query.ts +657 -0
  61. package/src/capabilities/store.ts +379 -0
  62. package/src/color/index.ts +3 -0
  63. package/src/color/paint.ts +169 -0
  64. package/src/color/palette.ts +48 -0
  65. package/src/color/sample.ts +323 -0
  66. package/src/color.ts +1 -0
  67. package/src/components/AnsiText.tsx +42 -0
  68. package/src/components/App.tsx +98 -10
  69. package/src/components/BackgroundContext.ts +2 -3
  70. package/src/components/Box.tsx +0 -8
  71. package/src/components/CursorContext.ts +1 -1
  72. package/src/components/Hyperlink.tsx +56 -0
  73. package/src/components/TerminalOscContext.ts +25 -0
  74. package/src/components/Text.tsx +22 -45
  75. package/src/components/Transform.tsx +1 -1
  76. package/src/dom.ts +11 -2
  77. package/src/global.d.ts +3 -0
  78. package/src/hooks/use-capabilities.ts +73 -0
  79. package/src/hooks/use-cursor.ts +2 -2
  80. package/src/hooks/use-terminal-osc.ts +59 -0
  81. package/src/index.ts +45 -1
  82. package/src/ink.tsx +213 -153
  83. package/src/{render-node-to-output.ts → paint-tree.ts} +75 -48
  84. package/src/reconciler.ts +24 -3
  85. package/src/render-background.ts +36 -15
  86. package/src/render-border.ts +94 -61
  87. package/src/render-frame.ts +83 -0
  88. package/src/render-to-string.ts +21 -6
  89. package/src/render.ts +15 -6
  90. package/src/router/components.tsx +343 -0
  91. package/src/router/context.ts +41 -0
  92. package/src/router/history.ts +194 -0
  93. package/src/router/hooks.tsx +391 -0
  94. package/src/router/index.ts +34 -0
  95. package/src/router/matcher.ts +571 -0
  96. package/src/screen/ansi.ts +184 -0
  97. package/src/screen/canvas.ts +160 -0
  98. package/src/screen/cell.ts +138 -0
  99. package/src/screen/color-profile.ts +47 -0
  100. package/src/screen/geometry.ts +9 -0
  101. package/src/screen/index.ts +6 -0
  102. package/src/screen/screen.ts +305 -0
  103. package/src/screen/serialize.ts +129 -0
  104. package/src/screen.ts +1 -0
  105. package/src/semantic-text-style.ts +118 -0
  106. package/src/squash-text-nodes.ts +2 -5
  107. package/src/structured-text.ts +325 -0
  108. package/src/styles.ts +19 -14
  109. package/src/terminal/index.ts +2 -0
  110. package/src/terminal/inline-presenter.ts +120 -0
  111. package/src/terminal/input.ts +86 -0
  112. package/src/terminal/render-scheduler.ts +37 -0
  113. package/src/terminal/screen-presenter.ts +188 -0
  114. package/src/terminal/session.ts +407 -0
  115. package/src/terminal.ts +1 -0
  116. package/src/testing/browser.ts +588 -0
  117. package/src/testing/emulators.ts +205 -0
  118. package/src/testing/explorer-app/index.html +12 -0
  119. package/src/testing/explorer-app/main.ts +381 -0
  120. package/src/testing/explorer-app/style.css +194 -0
  121. package/src/testing/explorer-app/tsconfig.json +15 -0
  122. package/src/testing/explorer-app/vite-env.d.ts +1 -0
  123. package/src/testing/index.ts +26 -0
  124. package/src/testing/keys.ts +56 -0
  125. package/src/testing/live.ts +85 -0
  126. package/src/testing/matchers.ts +70 -0
  127. package/src/testing/public.ts +94 -0
  128. package/src/testing/terminal.ts +349 -0
  129. package/src/testing/vitest.ts +157 -0
  130. package/src/transform-adapter.ts +14 -0
  131. package/src/wrap-text.ts +4 -0
  132. package/dist/sgr-CMfEpjSk.d.ts +0 -91
  133. package/dist/truncate-Cr6xVFMa.js +0 -2330
  134. package/src/ansi/supports-color.ts +0 -207
  135. package/src/colorize.ts +0 -60
  136. package/src/log-update.ts +0 -370
  137. package/src/output.ts +0 -308
  138. package/src/renderer.ts +0 -73
@@ -1,37 +1,21 @@
1
- import { widestLine } from "#/ansi/string-width.ts";
2
1
  import { type DOMElement } from "#/dom.ts";
3
2
  import { getMaxWidth } from "#/get-max-width.ts";
4
- import type { Output } from "#/output.ts";
5
3
  import { renderBackground } from "#/render-background.ts";
6
4
  import { renderBorder } from "#/render-border.ts";
5
+ import type { Canvas } from "#/screen/canvas.ts";
6
+ import { serializeLine } from "#/screen/serialize.ts";
7
7
  import { squashTextNodes } from "#/squash-text-nodes.ts";
8
- import { wrapText } from "#/wrap-text.ts";
8
+ import {
9
+ hasCompatibilityText,
10
+ structuredTextBaseStyle,
11
+ structuredTextLines,
12
+ wrapStructuredText,
13
+ sampleStructuredText,
14
+ } from "#/structured-text.ts";
15
+ import type { AnsiTransformer } from "#/transform-adapter.ts";
9
16
  import { Yoga } from "#/yoga/index.ts";
10
17
 
11
- // If parent container is `<Box>`, text nodes will be treated as separate nodes in
12
- // the tree and will have their own coordinates in the layout.
13
- // To ensure text nodes are aligned correctly, take X and Y of the first text node
14
- // and use it as offset for the rest of the nodes
15
- // Only first node is taken into account, because other text nodes can't have margin or padding,
16
- // so their coordinates will be relative to the first node anyway
17
- const applyPaddingToText = (node: DOMElement, text: string): string => {
18
- const yogaNode = node.childNodes[0]?.yogaNode;
19
-
20
- if (yogaNode) {
21
- const offsetX = yogaNode.getComputedLeft();
22
- const offsetY = yogaNode.getComputedTop();
23
-
24
- text =
25
- "\n".repeat(offsetY) +
26
- (offsetX > 0 ? text.replace(/^(?!\s*$)/gm, " ".repeat(offsetX)) : text);
27
- }
28
-
29
- return text;
30
- };
31
-
32
- export type OutputTransformer = (s: string, index: number) => string;
33
-
34
- export const renderNodeToScreenReaderOutput = (
18
+ export const renderAccessibleText = (
35
19
  node: DOMElement,
36
20
  options: {
37
21
  parentRole?: string;
@@ -61,7 +45,7 @@ export const renderNodeToScreenReaderOutput = (
61
45
 
62
46
  output = childNodes
63
47
  .map((childNode) => {
64
- const screenReaderOutput = renderNodeToScreenReaderOutput(childNode as DOMElement, {
48
+ const screenReaderOutput = renderAccessibleText(childNode as DOMElement, {
65
49
  parentRole: node.internal_accessibility?.role,
66
50
  skipStaticElements: options.skipStaticElements,
67
51
  });
@@ -92,13 +76,13 @@ export const renderNodeToScreenReaderOutput = (
92
76
  };
93
77
 
94
78
  // After nodes are laid out, render each to output object, which later gets rendered to terminal
95
- export const renderNodeToOutput = (
79
+ export const paintTree = (
96
80
  node: DOMElement,
97
- output: Output,
81
+ output: Canvas,
98
82
  options: {
99
83
  offsetX?: number;
100
84
  offsetY?: number;
101
- transformers?: OutputTransformer[];
85
+ transformers?: AnsiTransformer[];
102
86
  skipStaticElements: boolean;
103
87
  },
104
88
  ) => {
@@ -120,28 +104,58 @@ export const renderNodeToOutput = (
120
104
  const y = offsetY + yogaNode.getComputedTop();
121
105
 
122
106
  // Transformers are functions that transform final text output of each component
123
- // See Output class for logic that applies transformers
124
- let newTransformers = transformers;
125
-
126
- if (typeof node.internal_transform === "function") {
127
- newTransformers = [node.internal_transform, ...transformers];
128
- }
107
+ // The canvas applies explicit compatibility transformers at this boundary.
108
+ const newTransformers = node.internal_transform
109
+ ? [node.internal_transform, ...transformers]
110
+ : transformers;
129
111
 
130
112
  if (node.nodeName === "ink-text") {
131
- let text = squashTextNodes(node);
113
+ const text = squashTextNodes(node);
132
114
 
133
115
  if (text.length > 0) {
134
- const currentWidth = widestLine(text);
135
116
  const maxWidth = getMaxWidth(yogaNode);
136
-
137
- if (currentWidth > maxWidth) {
138
- const textWrap = node.style.textWrap ?? "wrap";
139
- text = wrapText(text, maxWidth, textWrap);
117
+ const firstChildYoga = node.childNodes[0]?.yogaNode;
118
+ const paddingX = firstChildYoga?.getComputedLeft() ?? 0;
119
+ const paddingY = firstChildYoga?.getComputedTop() ?? 0;
120
+ const textWrap = node.style.textWrap ?? "wrap";
121
+ // `none` text may paint past the screen's nominal width; the screen
122
+ // grows the touched rows on demand. Clip rects still apply.
123
+ const overflow = textWrap === "none";
124
+ const lines = wrapStructuredText(
125
+ structuredTextLines(node),
126
+ maxWidth,
127
+ textWrap,
128
+ structuredTextBaseStyle(node),
129
+ );
130
+ const paintBounds = {
131
+ x: x + paddingX,
132
+ y: y + paddingY,
133
+ width: Math.max(
134
+ 1,
135
+ ...lines.map((line) => line.reduce((width, cell) => width + cell.width, 0)),
136
+ ),
137
+ height: lines.length,
138
+ };
139
+ const sampled = sampleStructuredText(lines, paintBounds, output.paintContext);
140
+
141
+ if (hasCompatibilityText(node)) {
142
+ const textTransformers = collectTransformers(node, transformers);
143
+ output.writeAnsi(
144
+ paintBounds.x,
145
+ paintBounds.y,
146
+ sampled
147
+ .map((line) =>
148
+ serializeLine(line, {
149
+ colorProfile: output.paintContext.profile ?? "truecolor",
150
+ trimEnd: false,
151
+ }),
152
+ )
153
+ .join("\n"),
154
+ { transformers: textTransformers, overflow },
155
+ );
156
+ } else {
157
+ output.writeCells(paintBounds.x, paintBounds.y, sampled, { overflow });
140
158
  }
141
-
142
- text = applyPaddingToText(node, text);
143
-
144
- output.write(x, y, text, { transformers: newTransformers });
145
159
  }
146
160
 
147
161
  return;
@@ -177,7 +191,7 @@ export const renderNodeToOutput = (
177
191
 
178
192
  if (node.nodeName === "ink-root" || node.nodeName === "ink-box") {
179
193
  for (const childNode of node.childNodes) {
180
- renderNodeToOutput(childNode as DOMElement, output, {
194
+ paintTree(childNode as DOMElement, output, {
181
195
  offsetX: x,
182
196
  offsetY: y,
183
197
  transformers: newTransformers,
@@ -191,3 +205,16 @@ export const renderNodeToOutput = (
191
205
  }
192
206
  }
193
207
  };
208
+
209
+ function collectTransformers(
210
+ node: DOMElement,
211
+ inherited: readonly AnsiTransformer[],
212
+ ): AnsiTransformer[] {
213
+ return [
214
+ ...node.childNodes.flatMap((child) =>
215
+ child.nodeName === "#text" ? [] : collectTransformers(child, []),
216
+ ),
217
+ ...(node.internal_transform ? [node.internal_transform] : []),
218
+ ...inherited,
219
+ ];
220
+ }
package/src/reconciler.ts CHANGED
@@ -20,8 +20,9 @@ import {
20
20
  type DOMElement,
21
21
  } from "#/dom.ts";
22
22
  import { isSigilDev } from "#/env.ts";
23
- import { type OutputTransformer } from "#/render-node-to-output.ts";
23
+ import type { SemanticTextStyle } from "#/semantic-text-style.ts";
24
24
  import { styles as applyStyles, type Styles } from "#/styles.ts";
25
+ import { type AnsiTransformer } from "#/transform-adapter.ts";
25
26
  import { Yoga } from "#/yoga/index.ts";
26
27
 
27
28
  import pkg from "../package.json" with { type: "json" };
@@ -226,7 +227,17 @@ export const reconciler = createReconciler<
226
227
 
227
228
  if (key === "internal_transform") {
228
229
  // oxlint-disable-next-line typescript/no-unsafe-type-assertion
229
- node.internal_transform = value as OutputTransformer;
230
+ node.internal_transform = value as AnsiTransformer;
231
+ continue;
232
+ }
233
+
234
+ if (key === "internal_ansi") {
235
+ node.internal_ansi = value === true;
236
+ continue;
237
+ }
238
+
239
+ if (key === "internal_textStyle") {
240
+ node.internal_textStyle = value as SemanticTextStyle;
230
241
  continue;
231
242
  }
232
243
 
@@ -331,7 +342,17 @@ export const reconciler = createReconciler<
331
342
 
332
343
  if (key === "internal_transform") {
333
344
  // oxlint-disable-next-line typescript/no-unsafe-type-assertion
334
- node.internal_transform = value as OutputTransformer;
345
+ node.internal_transform = value as AnsiTransformer;
346
+ continue;
347
+ }
348
+
349
+ if (key === "internal_ansi") {
350
+ node.internal_ansi = value === true;
351
+ continue;
352
+ }
353
+
354
+ if (key === "internal_textStyle") {
355
+ node.internal_textStyle = value as SemanticTextStyle | undefined;
335
356
  continue;
336
357
  }
337
358
 
@@ -1,9 +1,10 @@
1
- import { colorize } from "#/colorize.ts";
1
+ import { samplePaint } from "#/color/sample.ts";
2
2
  import { type DOMNode } from "#/dom.ts";
3
- import type { Output } from "#/output.ts";
3
+ import type { Canvas } from "#/screen/canvas.ts";
4
+ import { cellAttributes, createCell } from "#/screen/index.ts";
4
5
 
5
- export const renderBackground = (x: number, y: number, node: DOMNode, output: Output): void => {
6
- if (!node.style.backgroundColor) {
6
+ export const renderBackground = (x: number, y: number, node: DOMNode, output: Canvas): void => {
7
+ if (node.style.backgroundColor === undefined) {
7
8
  return;
8
9
  }
9
10
 
@@ -23,16 +24,36 @@ export const renderBackground = (x: number, y: number, node: DOMNode, output: Ou
23
24
  return;
24
25
  }
25
26
 
26
- // Create background fill for each row
27
- const backgroundLine = colorize(
28
- " ".repeat(contentWidth),
29
- node.style.backgroundColor,
30
- "background",
27
+ const bounds = {
28
+ x: x + leftBorderWidth,
29
+ y: y + topBorderHeight,
30
+ width: contentWidth,
31
+ height: contentHeight,
32
+ };
33
+ const lines = Array.from({ length: contentHeight }, (_unused, row) =>
34
+ Array.from({ length: contentWidth }, (_empty, column) => {
35
+ const resetBackground = node.style.backgroundColor === "";
36
+ const background = resetBackground
37
+ ? undefined
38
+ : samplePaint(
39
+ node.style.backgroundColor!,
40
+ bounds.x + column,
41
+ bounds.y + row,
42
+ bounds,
43
+ output.paintContext,
44
+ );
45
+ return createCell(
46
+ " ",
47
+ 1,
48
+ {
49
+ ...(background ? { background } : {}),
50
+ underline: "none",
51
+ attributes: cellAttributes.none,
52
+ },
53
+ undefined,
54
+ resetBackground ? { background: true } : undefined,
55
+ );
56
+ }),
31
57
  );
32
-
33
- for (let row = 0; row < contentHeight; row++) {
34
- output.write(x + leftBorderWidth, y + topBorderHeight + row, backgroundLine, {
35
- transformers: [],
36
- });
37
- }
58
+ output.writeCells(bounds.x, bounds.y, lines);
38
59
  };
@@ -1,23 +1,43 @@
1
- import { chalk } from "#/ansi/chalk.ts";
2
- import { colorize } from "#/colorize.ts";
1
+ import { graphemes } from "#/ansi/graphemes.ts";
2
+ import { stringWidth } from "#/ansi/string-width.ts";
3
+ import type { Paint, PaintContext } from "#/color/paint.ts";
4
+ import { samplePaint } from "#/color/sample.ts";
3
5
  import { type DOMNode } from "#/dom.ts";
4
6
  import { BOXES } from "#/glyphs.ts";
5
- import type { Output } from "#/output.ts";
6
-
7
- const stylePiece = (segment: string, fg?: string, bg?: string, dim?: boolean): string => {
8
- let styled = colorize(segment, fg, "foreground");
9
- styled = colorize(styled, bg, "background");
10
- if (dim) {
11
- styled = chalk.dim(styled);
12
- }
13
-
14
- return styled;
7
+ import type { Canvas } from "#/screen/canvas.ts";
8
+ import type { Rect } from "#/screen/geometry.ts";
9
+ import { cellAttributes, createCell, type Cell } from "#/screen/index.ts";
10
+
11
+ const stylePiece = (
12
+ segment: string,
13
+ x: number,
14
+ y: number,
15
+ bounds: Rect,
16
+ context: PaintContext,
17
+ fg?: Paint,
18
+ bg?: Paint,
19
+ dim?: boolean,
20
+ ): Cell[] => {
21
+ let column = x;
22
+ return [...graphemes(segment)].map((grapheme) => {
23
+ const foreground = fg ? samplePaint(fg, column, y, bounds, context) : undefined;
24
+ const background = bg ? samplePaint(bg, column, y, bounds, context) : undefined;
25
+ const width = Math.max(1, stringWidth(grapheme));
26
+ column += width;
27
+ return createCell(grapheme, width, {
28
+ ...(foreground ? { foreground } : {}),
29
+ ...(background ? { background } : {}),
30
+ underline: "none",
31
+ attributes: dim ? cellAttributes.faint : cellAttributes.none,
32
+ });
33
+ });
15
34
  };
16
35
 
17
- export const renderBorder = (x: number, y: number, node: DOMNode, output: Output): void => {
36
+ export const renderBorder = (x: number, y: number, node: DOMNode, output: Canvas): void => {
18
37
  if (node.style.borderStyle) {
19
38
  const width = node.yogaNode!.getComputedWidth();
20
39
  const height = node.yogaNode!.getComputedHeight();
40
+ const bounds = { x, y, width, height };
21
41
  const box =
22
42
  typeof node.style.borderStyle === "string"
23
43
  ? BOXES[node.style.borderStyle]
@@ -52,19 +72,12 @@ export const renderBorder = (x: number, y: number, node: DOMNode, output: Output
52
72
 
53
73
  const contentWidth = width - (showLeftBorder ? 1 : 0) - (showRightBorder ? 1 : 0);
54
74
 
55
- let topBorder = showTopBorder
75
+ const topBorder = showTopBorder
56
76
  ? (showLeftBorder ? box.topLeft : "") +
57
77
  box.top.repeat(contentWidth) +
58
78
  (showRightBorder ? box.topRight : "")
59
79
  : undefined;
60
80
 
61
- topBorder &&= stylePiece(
62
- topBorder,
63
- topBorderColor,
64
- topBorderBackgroundColor,
65
- dimTopBorderColor,
66
- );
67
-
68
81
  let verticalBorderHeight = height;
69
82
 
70
83
  if (showTopBorder) {
@@ -75,60 +88,80 @@ export const renderBorder = (x: number, y: number, node: DOMNode, output: Output
75
88
  verticalBorderHeight -= 1;
76
89
  }
77
90
 
78
- let leftBorder = "";
79
-
80
- if (showLeftBorder) {
81
- const one = stylePiece(
82
- box.left,
83
- leftBorderColor,
84
- leftBorderBackgroundColor,
85
- dimLeftBorderColor,
86
- );
87
- leftBorder = (one + "\n").repeat(verticalBorderHeight);
88
- }
89
-
90
- let rightBorder = "";
91
-
92
- if (showRightBorder) {
93
- const one = stylePiece(
94
- box.right,
95
- rightBorderColor,
96
- rightBorderBackgroundColor,
97
- dimRightBorderColor,
98
- );
99
- rightBorder = (one + "\n").repeat(verticalBorderHeight);
100
- }
91
+ const offsetY = showTopBorder ? 1 : 0;
101
92
 
102
- let bottomBorder = showBottomBorder
93
+ const bottomBorder = showBottomBorder
103
94
  ? (showLeftBorder ? box.bottomLeft : "") +
104
95
  box.bottom.repeat(contentWidth) +
105
96
  (showRightBorder ? box.bottomRight : "")
106
97
  : undefined;
107
- bottomBorder &&= stylePiece(
108
- bottomBorder,
109
- bottomBorderColor,
110
- bottomBorderBackgroundColor,
111
- dimBottomBorderColor,
112
- );
113
-
114
- const offsetY = showTopBorder ? 1 : 0;
115
98
 
116
99
  if (topBorder) {
117
- output.write(x, y, topBorder, { transformers: [] });
100
+ output.writeCells(x, y, [
101
+ stylePiece(
102
+ topBorder,
103
+ x,
104
+ y,
105
+ bounds,
106
+ output.paintContext,
107
+ topBorderColor,
108
+ topBorderBackgroundColor,
109
+ dimTopBorderColor,
110
+ ),
111
+ ]);
118
112
  }
119
113
 
120
- if (leftBorder) {
121
- output.write(x, y + offsetY, leftBorder, { transformers: [] });
114
+ if (showLeftBorder) {
115
+ output.writeCells(
116
+ x,
117
+ y + offsetY,
118
+ Array.from({ length: verticalBorderHeight }, (_, row) =>
119
+ stylePiece(
120
+ box.left,
121
+ x,
122
+ y + offsetY + row,
123
+ bounds,
124
+ output.paintContext,
125
+ leftBorderColor,
126
+ leftBorderBackgroundColor,
127
+ dimLeftBorderColor,
128
+ ),
129
+ ),
130
+ );
122
131
  }
123
132
 
124
- if (rightBorder) {
125
- output.write(x + width - 1, y + offsetY, rightBorder, {
126
- transformers: [],
127
- });
133
+ if (showRightBorder) {
134
+ output.writeCells(
135
+ x + width - 1,
136
+ y + offsetY,
137
+ Array.from({ length: verticalBorderHeight }, (_, row) =>
138
+ stylePiece(
139
+ box.right,
140
+ x + width - 1,
141
+ y + offsetY + row,
142
+ bounds,
143
+ output.paintContext,
144
+ rightBorderColor,
145
+ rightBorderBackgroundColor,
146
+ dimRightBorderColor,
147
+ ),
148
+ ),
149
+ );
128
150
  }
129
151
 
130
152
  if (bottomBorder) {
131
- output.write(x, y + height - 1, bottomBorder, { transformers: [] });
153
+ output.writeCells(x, y + height - 1, [
154
+ stylePiece(
155
+ bottomBorder,
156
+ x,
157
+ y + height - 1,
158
+ bounds,
159
+ output.paintContext,
160
+ bottomBorderColor,
161
+ bottomBorderBackgroundColor,
162
+ dimBottomBorderColor,
163
+ ),
164
+ ]);
132
165
  }
133
166
  }
134
167
  };
@@ -0,0 +1,83 @@
1
+ import type { PaintContext } from "#/color/paint.ts";
2
+ import { type DOMElement } from "#/dom.ts";
3
+ import { paintTree, renderAccessibleText } from "#/paint-tree.ts";
4
+ import { Canvas } from "#/screen/canvas.ts";
5
+ import { type ColorProfile } from "#/screen/index.ts";
6
+ import type { Screen } from "#/screen/screen.ts";
7
+
8
+ type Result = {
9
+ screen?: Screen;
10
+ staticScreen?: Screen;
11
+ accessibleText?: string;
12
+ staticAccessibleText?: string;
13
+ };
14
+
15
+ type RendererOptions = {
16
+ colorProfile?: ColorProfile;
17
+ paintContext?: PaintContext;
18
+ };
19
+
20
+ const renderDimension = (value: number): number =>
21
+ Number.isFinite(value) ? Math.max(0, Math.ceil(value)) : 0;
22
+
23
+ export const renderFrame = (
24
+ node: DOMElement,
25
+ isScreenReaderEnabled: boolean,
26
+ options: RendererOptions = {},
27
+ ): Result => {
28
+ if (node.yogaNode) {
29
+ if (isScreenReaderEnabled) {
30
+ const output = renderAccessibleText(node, {
31
+ skipStaticElements: true,
32
+ });
33
+
34
+ let staticOutput = "";
35
+
36
+ if (node.staticNode) {
37
+ staticOutput = renderAccessibleText(node.staticNode, {
38
+ skipStaticElements: false,
39
+ });
40
+ }
41
+
42
+ return {
43
+ accessibleText: output,
44
+ staticAccessibleText: staticOutput,
45
+ };
46
+ }
47
+
48
+ const output = new Canvas({
49
+ width: renderDimension(node.yogaNode.getComputedWidth()),
50
+ height: renderDimension(node.yogaNode.getComputedHeight()),
51
+ colorProfile: options.colorProfile,
52
+ paintContext: options.paintContext,
53
+ });
54
+
55
+ paintTree(node, output, {
56
+ skipStaticElements: true,
57
+ });
58
+
59
+ let staticOutput;
60
+
61
+ if (node.staticNode?.yogaNode) {
62
+ staticOutput = new Canvas({
63
+ width: renderDimension(node.staticNode.yogaNode.getComputedWidth()),
64
+ height: renderDimension(node.staticNode.yogaNode.getComputedHeight()),
65
+ colorProfile: options.colorProfile,
66
+ paintContext: options.paintContext,
67
+ });
68
+
69
+ paintTree(node.staticNode, staticOutput, {
70
+ skipStaticElements: false,
71
+ });
72
+ }
73
+
74
+ return {
75
+ screen: output.finish(),
76
+ ...(staticOutput ? { staticScreen: staticOutput.finish() } : {}),
77
+ };
78
+ }
79
+
80
+ return {
81
+ accessibleText: "",
82
+ };
83
+ };
@@ -1,9 +1,12 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { LegacyRoot } from "react-reconciler/constants.js";
3
3
 
4
+ import { detectColorLevel } from "#/capabilities/detect.ts";
4
5
  import { createNode, type DOMElement } from "#/dom.ts";
5
6
  import { reconciler } from "#/reconciler.ts";
6
- import { renderer } from "#/renderer.ts";
7
+ import { renderFrame } from "#/render-frame.ts";
8
+ import { colorProfileFromLevel, type ColorProfile } from "#/screen/index.ts";
9
+ import { serializeScreen } from "#/screen/serialize.ts";
7
10
  import { Yoga } from "#/yoga/index.ts";
8
11
 
9
12
  export type RenderToStringOptions = {
@@ -13,6 +16,12 @@ export type RenderToStringOptions = {
13
16
  @default 80
14
17
  */
15
18
  columns?: number;
19
+
20
+ /**
21
+ Color profile used for deterministic serialization. By default this retains
22
+ the process color level for Ink compatibility.
23
+ */
24
+ colorProfile?: ColorProfile;
16
25
  };
17
26
 
18
27
  /**
@@ -44,6 +53,7 @@ console.log(output);
44
53
  */
45
54
  export const renderToString = (node: ReactNode, options?: RenderToStringOptions): string => {
46
55
  const columns = options?.columns ?? 80;
56
+ const colorProfile = options?.colorProfile ?? colorProfileFromLevel(detectColorLevel());
47
57
 
48
58
  // Create a standalone root node — no stdout, stdin, or terminal bindings
49
59
  const rootNode: DOMElement = createNode("ink-root");
@@ -62,7 +72,10 @@ export const renderToString = (node: ReactNode, options?: RenderToStringOptions)
62
72
  };
63
73
 
64
74
  rootNode.onImmediateRender = () => {
65
- const { staticOutput } = renderer(rootNode, false);
75
+ const { staticScreen } = renderFrame(rootNode, false, { colorProfile });
76
+ const staticOutput = staticScreen
77
+ ? `${serializeScreen(staticScreen, { colorProfile, styles: colorProfile !== "none" })}\n`
78
+ : "";
66
79
  if (staticOutput && staticOutput !== "\n") {
67
80
  capturedStaticOutput += staticOutput;
68
81
  }
@@ -99,7 +112,10 @@ export const renderToString = (node: ReactNode, options?: RenderToStringOptions)
99
112
 
100
113
  // Yoga layout has already been calculated by onComputeLayout during commit.
101
114
  // Render the DOM tree to a string — this captures the dynamic (non-static) output.
102
- const { output } = renderer(rootNode, false);
115
+ const { screen } = renderFrame(rootNode, false, { colorProfile });
116
+ const output = screen
117
+ ? serializeScreen(screen, { colorProfile, styles: colorProfile !== "none" })
118
+ : "";
103
119
 
104
120
  // Tear down: unmount the tree so the reconciler cleans up child nodes
105
121
  // and runs effect cleanup functions. The reconciler detaches removed
@@ -116,9 +132,8 @@ export const renderToString = (node: ReactNode, options?: RenderToStringOptions)
116
132
  new Error(String(uncaughtError));
117
133
  }
118
134
 
119
- // The renderer appends a trailing newline to static output for terminal
120
- // rendering (so dynamic output starts on a fresh line). Strip it here
121
- // so renderToString returns clean output.
135
+ // Static terminal output ends with a newline so dynamic output starts on a
136
+ // fresh line. Strip it here so renderToString returns clean output.
122
137
  const normalizedStaticOutput = capturedStaticOutput.endsWith("\n")
123
138
  ? capturedStaticOutput.slice(0, -1)
124
139
  : capturedStaticOutput;