@alchemy.run/sigil 0.0.0-alpha.1 → 0.0.0-alpha.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.
Files changed (121) hide show
  1. package/README.md +299 -299
  2. package/dist/ansi.d.ts +223 -0
  3. package/dist/ansi.js +2 -0
  4. package/dist/{devtools-QpCMm9JH.mjs → devtools-BhYGjb7h.js} +1 -1
  5. package/dist/index-DDVME65c.d.ts +919 -0
  6. package/dist/index.d.ts +1657 -0
  7. package/dist/index.js +4643 -0
  8. package/dist/sgr-CMfEpjSk.d.ts +91 -0
  9. package/dist/truncate-CBiyyZzw.js +2156 -0
  10. package/dist/yoga-5jKhYCJC.js +3465 -0
  11. package/dist/yoga.d.ts +2 -0
  12. package/dist/yoga.js +2 -0
  13. package/package.json +37 -17
  14. package/src/ansi/chalk.ts +179 -0
  15. package/src/ansi/cursor.ts +48 -0
  16. package/src/ansi/east-asian-width.ts +215 -0
  17. package/src/ansi/escapes.ts +128 -0
  18. package/src/ansi/index.ts +27 -0
  19. package/src/ansi/sgr.ts +237 -0
  20. package/src/ansi/slice.ts +43 -0
  21. package/src/ansi/string-width.ts +236 -0
  22. package/src/ansi/strip.ts +33 -0
  23. package/src/ansi/supports-color.ts +213 -0
  24. package/src/ansi/tokenize.ts +453 -0
  25. package/src/ansi/truncate.ts +194 -0
  26. package/src/ansi/widest-line.ts +12 -0
  27. package/src/ansi/wrap.ts +766 -0
  28. package/src/ansi-tokenizer.ts +510 -0
  29. package/src/auto-bind.ts +41 -0
  30. package/src/boxes.ts +100 -0
  31. package/src/code-excerpt.ts +39 -0
  32. package/src/colorize.ts +60 -0
  33. package/src/components/AccessibilityContext.ts +5 -0
  34. package/src/components/AnimationContext.ts +24 -0
  35. package/src/components/App.tsx +781 -0
  36. package/src/components/AppContext.ts +111 -0
  37. package/src/components/BackgroundContext.ts +8 -0
  38. package/src/components/Box.tsx +116 -0
  39. package/src/components/CursorContext.ts +19 -0
  40. package/src/components/ErrorBoundary.tsx +38 -0
  41. package/src/components/ErrorOverview.tsx +133 -0
  42. package/src/components/FocusContext.ts +30 -0
  43. package/src/components/Newline.tsx +15 -0
  44. package/src/components/Spacer.tsx +10 -0
  45. package/src/components/Static.tsx +59 -0
  46. package/src/components/StderrContext.ts +26 -0
  47. package/src/components/StdinContext.ts +49 -0
  48. package/src/components/StdoutContext.ts +28 -0
  49. package/src/components/Text.tsx +144 -0
  50. package/src/components/Transform.tsx +37 -0
  51. package/src/cursor-position.ts +103 -0
  52. package/src/devtools-window-polyfill.ts +73 -0
  53. package/src/devtools.ts +43 -0
  54. package/src/dom.ts +292 -0
  55. package/src/get-max-width.ts +11 -0
  56. package/src/global.d.ts +36 -0
  57. package/src/hooks/use-animation.ts +142 -0
  58. package/src/hooks/use-app.ts +8 -0
  59. package/src/hooks/use-box-metrics.ts +134 -0
  60. package/src/hooks/use-cursor.ts +33 -0
  61. package/src/hooks/use-focus-manager.ts +62 -0
  62. package/src/hooks/use-focus.ts +83 -0
  63. package/src/hooks/use-input.ts +267 -0
  64. package/src/hooks/use-is-screen-reader-enabled.ts +12 -0
  65. package/src/hooks/use-paste.ts +78 -0
  66. package/src/hooks/use-stderr.ts +8 -0
  67. package/src/hooks/use-stdin.ts +10 -0
  68. package/src/hooks/use-stdout.ts +8 -0
  69. package/src/hooks/use-window-size.ts +41 -0
  70. package/src/indent-string.ts +16 -0
  71. package/src/index.ts +44 -0
  72. package/src/ink.tsx +1506 -0
  73. package/src/input-parser.ts +283 -0
  74. package/src/instances.ts +9 -0
  75. package/src/is-in-ci.ts +7 -0
  76. package/src/kitty-keyboard.ts +57 -0
  77. package/src/log-update.ts +370 -0
  78. package/src/measure-element.ts +62 -0
  79. package/src/measure-text.ts +31 -0
  80. package/src/output.ts +308 -0
  81. package/src/parse-keypress.ts +516 -0
  82. package/src/parse-stack-line.ts +139 -0
  83. package/src/patch-console.ts +62 -0
  84. package/src/quick-lru.ts +85 -0
  85. package/src/reconciler.ts +451 -0
  86. package/src/render-background.ts +38 -0
  87. package/src/render-border.ts +134 -0
  88. package/src/render-node-to-output.ts +191 -0
  89. package/src/render-to-string.ts +131 -0
  90. package/src/render.ts +276 -0
  91. package/src/renderer.ts +73 -0
  92. package/src/sanitize-ansi.ts +33 -0
  93. package/src/signal-exit.ts +107 -0
  94. package/src/squash-text-nodes.ts +40 -0
  95. package/src/stream.ts +30 -0
  96. package/src/styles.ts +748 -0
  97. package/src/terminal-size.ts +57 -0
  98. package/src/throttle.ts +73 -0
  99. package/src/types.ts +15 -0
  100. package/src/utils.ts +40 -0
  101. package/src/wrap-text.ts +50 -0
  102. package/src/write-synchronized.ts +9 -0
  103. package/src/yoga/config.ts +57 -0
  104. package/src/yoga/core/absoluteLayout.ts +626 -0
  105. package/src/yoga/core/baseline.ts +66 -0
  106. package/src/yoga/core/cache.ts +136 -0
  107. package/src/yoga/core/calculateLayout.ts +2920 -0
  108. package/src/yoga/core/config.ts +104 -0
  109. package/src/yoga/core/flexLine.ts +177 -0
  110. package/src/yoga/core/helpers.ts +293 -0
  111. package/src/yoga/core/layoutResults.ts +167 -0
  112. package/src/yoga/core/node.ts +611 -0
  113. package/src/yoga/core/numeric.ts +44 -0
  114. package/src/yoga/core/pixelGrid.ts +151 -0
  115. package/src/yoga/core/style.ts +887 -0
  116. package/src/yoga/core/types.ts +224 -0
  117. package/src/yoga/generated/YGEnums.ts +263 -0
  118. package/src/yoga/index.ts +19 -0
  119. package/src/yoga/node.ts +1140 -0
  120. package/dist/index.d.mts +0 -2379
  121. package/dist/index.mjs +0 -10072
@@ -0,0 +1,144 @@
1
+ import { useContext, type ReactNode } from "react";
2
+
3
+ import { chalk, type ForegroundColorName } from "../ansi/chalk.ts";
4
+ import { colorize } from "../colorize.ts";
5
+ import { type Styles } from "../styles.ts";
6
+ import { type LiteralUnion } from "../types.ts";
7
+ import { accessibilityContext } from "./AccessibilityContext.ts";
8
+ import { backgroundContext } from "./BackgroundContext.ts";
9
+
10
+ export type Props = {
11
+ /**
12
+ A label for the element for screen readers.
13
+ */
14
+ readonly "aria-label"?: string;
15
+
16
+ /**
17
+ Hide the element from screen readers.
18
+ */
19
+ readonly "aria-hidden"?: boolean;
20
+
21
+ /**
22
+ Change text color. Ink uses Chalk under the hood, so all its functionality is supported.
23
+ */
24
+ readonly color?: LiteralUnion<ForegroundColorName, string>;
25
+
26
+ /**
27
+ Same as `color`, but for the background.
28
+ */
29
+ readonly backgroundColor?: LiteralUnion<ForegroundColorName, string>;
30
+
31
+ /**
32
+ Dim the color (make it less bright).
33
+ */
34
+ readonly dimColor?: boolean;
35
+
36
+ /**
37
+ Make the text bold.
38
+ */
39
+ readonly bold?: boolean;
40
+
41
+ /**
42
+ Make the text italic.
43
+ */
44
+ readonly italic?: boolean;
45
+
46
+ /**
47
+ Make the text underlined.
48
+ */
49
+ readonly underline?: boolean;
50
+
51
+ /**
52
+ Make the text crossed out with a line.
53
+ */
54
+ readonly strikethrough?: boolean;
55
+
56
+ /**
57
+ Inverse background and foreground colors.
58
+ */
59
+ readonly inverse?: boolean;
60
+
61
+ /**
62
+ This property tells Ink to wrap or truncate text if its width is larger than the container. If `wrap` is passed (the default), Ink will wrap text and split it into multiple lines. If `hard` is passed, Ink will fill each line to the full column width, breaking words as necessary. If `truncate-*` is passed, Ink will truncate text instead, resulting in one line of text with the rest cut off.
63
+ */
64
+ readonly wrap?: Styles["textWrap"];
65
+
66
+ readonly children?: ReactNode;
67
+ };
68
+
69
+ /**
70
+ This component can display text and change its style to make it bold, underlined, italic, or strikethrough.
71
+ */
72
+ export function Text({
73
+ color,
74
+ backgroundColor,
75
+ dimColor = false,
76
+ bold = false,
77
+ italic = false,
78
+ underline = false,
79
+ strikethrough = false,
80
+ inverse = false,
81
+ wrap = "wrap",
82
+ children,
83
+ "aria-label": ariaLabel,
84
+ "aria-hidden": ariaHidden = false,
85
+ }: Props) {
86
+ const { isScreenReaderEnabled } = useContext(accessibilityContext);
87
+ const inheritedBackgroundColor = useContext(backgroundContext);
88
+ const childrenOrAriaLabel = isScreenReaderEnabled && ariaLabel ? ariaLabel : children;
89
+
90
+ if (childrenOrAriaLabel === undefined || childrenOrAriaLabel === null) {
91
+ return null;
92
+ }
93
+
94
+ const transform = (text: string): string => {
95
+ if (dimColor) {
96
+ text = chalk.dim(text);
97
+ }
98
+
99
+ if (color) {
100
+ text = colorize(text, color, "foreground");
101
+ }
102
+
103
+ // Use explicit backgroundColor if provided, otherwise use inherited from parent Box
104
+ const effectiveBackgroundColor = backgroundColor ?? inheritedBackgroundColor;
105
+ if (effectiveBackgroundColor) {
106
+ text = colorize(text, effectiveBackgroundColor, "background");
107
+ }
108
+
109
+ if (bold) {
110
+ text = chalk.bold(text);
111
+ }
112
+
113
+ if (italic) {
114
+ text = chalk.italic(text);
115
+ }
116
+
117
+ if (underline) {
118
+ text = chalk.underline(text);
119
+ }
120
+
121
+ if (strikethrough) {
122
+ text = chalk.strikethrough(text);
123
+ }
124
+
125
+ if (inverse) {
126
+ text = chalk.inverse(text);
127
+ }
128
+
129
+ return text;
130
+ };
131
+
132
+ if (isScreenReaderEnabled && ariaHidden) {
133
+ return null;
134
+ }
135
+
136
+ return (
137
+ <ink-text
138
+ style={{ flexGrow: 0, flexShrink: 1, flexDirection: "row", textWrap: wrap }}
139
+ internal_transform={transform}
140
+ >
141
+ {childrenOrAriaLabel}
142
+ </ink-text>
143
+ );
144
+ }
@@ -0,0 +1,37 @@
1
+ import { useContext, type ReactNode } from "react";
2
+
3
+ import { accessibilityContext } from "./AccessibilityContext.ts";
4
+
5
+ export type Props = {
6
+ /**
7
+ Screen-reader-specific text to output. If this is set, all children will be ignored.
8
+ */
9
+ readonly accessibilityLabel?: string;
10
+
11
+ /**
12
+ Function that transforms children output. It accepts children and must return transformed children as well. Note that when children use `<Text>` styling props (e.g. `color`, `bold`), the string will contain ANSI escape codes.
13
+ */
14
+ readonly transform: (children: string, index: number) => string;
15
+
16
+ readonly children?: ReactNode;
17
+ };
18
+
19
+ /**
20
+ Transform a string representation of React components before they're written to output. For example, you might want to apply a gradient to text, add a clickable link, or create some text effects. These use cases can't accept React nodes as input; they expect a string. That's what the <Transform> component does: it gives you an output string of its child components and lets you transform it in any way.
21
+ */
22
+ export function Transform({ children, transform, accessibilityLabel }: Props) {
23
+ const { isScreenReaderEnabled } = useContext(accessibilityContext);
24
+
25
+ if (children === undefined || children === null) {
26
+ return null;
27
+ }
28
+
29
+ return (
30
+ <ink-text
31
+ style={{ flexGrow: 0, flexShrink: 1, flexDirection: "row" }}
32
+ internal_transform={transform}
33
+ >
34
+ {isScreenReaderEnabled && accessibilityLabel ? accessibilityLabel : children}
35
+ </ink-text>
36
+ );
37
+ }
@@ -0,0 +1,103 @@
1
+ import { ansiEscapes } from "./ansi/escapes.ts";
2
+
3
+ export type CursorPosition = {
4
+ x: number;
5
+ y: number;
6
+ };
7
+
8
+ export const showCursorEscape = ansiEscapes.cursorShow;
9
+ export const hideCursorEscape = ansiEscapes.cursorHide;
10
+
11
+ /**
12
+ Compare two cursor positions. Returns true if they differ.
13
+ */
14
+ export const cursorPositionChanged = (
15
+ a: CursorPosition | undefined,
16
+ b: CursorPosition | undefined,
17
+ ): boolean => a?.x !== b?.x || a?.y !== b?.y;
18
+
19
+ /**
20
+ Build escape sequence to move cursor from the bottom of the output to the target position and show it.
21
+
22
+ `bottomLine` is the row the renderer left the cursor on, counted from the top of the output.
23
+ That is always `lines.length - 1` for `lines = str.split('\n')`, whether or not the output ends
24
+ with a newline:
25
+
26
+ - With a trailing newline, `split` yields one extra empty element and the renderer stops just
27
+ past the last visible line — which is `lines.length - 1`.
28
+ - Without one, there is no extra element and the renderer deliberately stops on the last visible
29
+ line instead of moving past it — which is also `lines.length - 1`.
30
+
31
+ This is the same row basis `buildReturnToBottom` measures from, so the two stay in step.
32
+ */
33
+ export const buildCursorSuffix = (
34
+ bottomLine: number,
35
+ cursorPosition: CursorPosition | undefined,
36
+ ): string => {
37
+ if (!cursorPosition) {
38
+ return "";
39
+ }
40
+
41
+ const moveUp = bottomLine - cursorPosition.y;
42
+ return (
43
+ (moveUp > 0 ? ansiEscapes.cursorUp(moveUp) : "") +
44
+ ansiEscapes.cursorTo(cursorPosition.x) +
45
+ showCursorEscape
46
+ );
47
+ };
48
+
49
+ /**
50
+ Build escape sequence to move cursor from previousCursorPosition back to the bottom of output.
51
+ This must be done before eraseLines or any operation that assumes cursor is at the bottom.
52
+ */
53
+ export const buildReturnToBottom = (
54
+ previousLineCount: number,
55
+ previousCursorPosition: CursorPosition | undefined,
56
+ ): string => {
57
+ if (!previousCursorPosition) {
58
+ return "";
59
+ }
60
+
61
+ // PreviousLineCount is the raw `split('\n')` length, so `previousLineCount - 1`
62
+ // is the row the cursor was left on regardless of a trailing newline — the same
63
+ // basis `buildCursorSuffix` takes as its `bottomLine`.
64
+ const down = previousLineCount - 1 - previousCursorPosition.y;
65
+ return (down > 0 ? ansiEscapes.cursorDown(down) : "") + ansiEscapes.cursorTo(0);
66
+ };
67
+
68
+ export type CursorOnlyInput = {
69
+ cursorWasShown: boolean;
70
+ previousLineCount: number;
71
+ previousCursorPosition: CursorPosition | undefined;
72
+ cursorPosition: CursorPosition | undefined;
73
+ };
74
+
75
+ /**
76
+ Build the escape sequence for cursor-only updates (output unchanged, cursor moved).
77
+ Hides cursor if it was previously shown, returns to bottom, then repositions.
78
+
79
+ `buildReturnToBottom` has just placed the cursor on row `previousLineCount - 1`, so the
80
+ suffix measures from there rather than recomputing the row from the output.
81
+ */
82
+ export const buildCursorOnlySequence = (input: CursorOnlyInput): string => {
83
+ const hidePrefix = input.cursorWasShown ? hideCursorEscape : "";
84
+ const returnToBottom = buildReturnToBottom(input.previousLineCount, input.previousCursorPosition);
85
+ const cursorSuffix = buildCursorSuffix(input.previousLineCount - 1, input.cursorPosition);
86
+ return hidePrefix + returnToBottom + cursorSuffix;
87
+ };
88
+
89
+ /**
90
+ Build the prefix that hides cursor and returns to bottom before erasing or rewriting.
91
+ Returns empty string if cursor was not shown.
92
+ */
93
+ export const buildReturnToBottomPrefix = (
94
+ cursorWasShown: boolean,
95
+ previousLineCount: number,
96
+ previousCursorPosition: CursorPosition | undefined,
97
+ ): string => {
98
+ if (!cursorWasShown) {
99
+ return "";
100
+ }
101
+
102
+ return hideCursorEscape + buildReturnToBottom(previousLineCount, previousCursorPosition);
103
+ };
@@ -0,0 +1,73 @@
1
+ // Ignoring missing types error to avoid adding another dependency for this hack to work
2
+ import ws from "ws";
3
+
4
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
5
+ const customGlobal = globalThis as any;
6
+
7
+ // These things must exist before importing `react-devtools-core`
8
+ // Using ||= intentionally to set falsy values, not just null/undefined
9
+
10
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
11
+ customGlobal.WebSocket ||= ws;
12
+
13
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
14
+ customGlobal.window ||= globalThis;
15
+
16
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
17
+ customGlobal.self ||= globalThis;
18
+
19
+ // Filter out Ink's internal components from devtools for a cleaner view.
20
+ // Also, ince `react-devtools-shared` package isn't published on npm, we can't
21
+ // use its types, that's why there are hard-coded values in `type` fields below.
22
+ // See https://github.com/facebook/react/blob/edf6eac8a181860fd8a2d076a43806f1237495a1/packages/react-devtools-shared/src/types.js#L24
23
+ customGlobal.window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = [
24
+ {
25
+ // ComponentFilterElementType
26
+ type: 1,
27
+ // ElementTypeHostComponent
28
+ value: 7,
29
+ isEnabled: true,
30
+ },
31
+ {
32
+ // ComponentFilterDisplayName
33
+ type: 2,
34
+ value: "InternalApp",
35
+ isEnabled: true,
36
+ isValid: true,
37
+ },
38
+ {
39
+ // ComponentFilterDisplayName
40
+ type: 2,
41
+ value: "InternalAppContext",
42
+ isEnabled: true,
43
+ isValid: true,
44
+ },
45
+ {
46
+ // ComponentFilterDisplayName
47
+ type: 2,
48
+ value: "InternalStdoutContext",
49
+ isEnabled: true,
50
+ isValid: true,
51
+ },
52
+ {
53
+ // ComponentFilterDisplayName
54
+ type: 2,
55
+ value: "InternalStderrContext",
56
+ isEnabled: true,
57
+ isValid: true,
58
+ },
59
+ {
60
+ // ComponentFilterDisplayName
61
+ type: 2,
62
+ value: "InternalStdinContext",
63
+ isEnabled: true,
64
+ isValid: true,
65
+ },
66
+ {
67
+ // ComponentFilterDisplayName
68
+ type: 2,
69
+ value: "InternalFocusContext",
70
+ isEnabled: true,
71
+ isValid: true,
72
+ },
73
+ ];
@@ -0,0 +1,43 @@
1
+ /* eslint-disable import-x/order */
2
+
3
+ // eslint-disable-next-line import-x/no-unassigned-import
4
+ import "./devtools-window-polyfill.ts";
5
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
6
+ // @ts-expect-error
7
+ import devtools from "react-devtools-core";
8
+ import WebSocket from "ws";
9
+
10
+ const isDevToolsReachable = async (): Promise<boolean> =>
11
+ new Promise((resolve) => {
12
+ const socket = new WebSocket("ws://localhost:8097");
13
+
14
+ const timeout = setTimeout(() => {
15
+ socket.terminate();
16
+ resolve(false);
17
+ }, 2000);
18
+ // Don't let the timeout keep the process alive on its own
19
+ timeout.unref();
20
+
21
+ socket.on("open", () => {
22
+ clearTimeout(timeout);
23
+ socket.terminate();
24
+ resolve(true);
25
+ });
26
+
27
+ socket.on("error", () => {
28
+ clearTimeout(timeout);
29
+ socket.terminate();
30
+ resolve(false);
31
+ });
32
+ });
33
+
34
+ if (await isDevToolsReachable()) {
35
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
36
+ (devtools as any).initialize();
37
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
38
+ (devtools as any).connectToDevTools();
39
+ } else {
40
+ console.warn(
41
+ "SIGIL_DEV is set to true, but the React DevTools server is not running. Start it with:\n\n$ npx react-devtools\n",
42
+ );
43
+ }
package/src/dom.ts ADDED
@@ -0,0 +1,292 @@
1
+ import { measureText } from "./measure-text.ts";
2
+ import { type OutputTransformer } from "./render-node-to-output.ts";
3
+ import { squashTextNodes } from "./squash-text-nodes.ts";
4
+ import { type Styles } from "./styles.ts";
5
+ import { wrapText } from "./wrap-text.ts";
6
+ import { Yoga, type Node as YogaNode } from "./yoga/index.ts";
7
+
8
+ type InkNode = {
9
+ parentNode: DOMElement | undefined;
10
+ yogaNode?: YogaNode;
11
+ internal_static?: boolean;
12
+ style: Styles;
13
+ };
14
+
15
+ type LayoutListener = () => void;
16
+
17
+ export type TextName = "#text";
18
+ export type ElementNames = "ink-root" | "ink-box" | "ink-text" | "ink-virtual-text";
19
+
20
+ export type NodeNames = ElementNames | TextName;
21
+
22
+ // eslint-disable-next-line @typescript-eslint/naming-convention
23
+ export type DOMElement = {
24
+ nodeName: ElementNames;
25
+ attributes: Record<string, DOMNodeAttribute>;
26
+ childNodes: DOMNode[];
27
+ internal_transform?: OutputTransformer;
28
+
29
+ internal_accessibility?: {
30
+ role?:
31
+ | "button"
32
+ | "checkbox"
33
+ | "combobox"
34
+ | "list"
35
+ | "listbox"
36
+ | "listitem"
37
+ | "menu"
38
+ | "menuitem"
39
+ | "option"
40
+ | "progressbar"
41
+ | "radio"
42
+ | "radiogroup"
43
+ | "tab"
44
+ | "tablist"
45
+ | "table"
46
+ | "textbox"
47
+ | "timer"
48
+ | "toolbar";
49
+ state?: {
50
+ busy?: boolean;
51
+ checked?: boolean;
52
+ disabled?: boolean;
53
+ expanded?: boolean;
54
+ multiline?: boolean;
55
+ multiselectable?: boolean;
56
+ readonly?: boolean;
57
+ required?: boolean;
58
+ selected?: boolean;
59
+ };
60
+ };
61
+
62
+ // Internal properties
63
+ isStaticDirty?: boolean;
64
+ staticNode?: DOMElement;
65
+ // Tracks the previous commit's `staticNode` so the reconciler can detect identity changes (mount, unmount, key-driven remount) and reset `fullStaticOutput`.
66
+ previousStaticNode?: DOMElement;
67
+ onComputeLayout?: () => void;
68
+ onRender?: () => void;
69
+ onImmediateRender?: () => void;
70
+ onStaticChange?: () => void;
71
+ internal_layoutListeners?: Set<LayoutListener>;
72
+ } & InkNode;
73
+
74
+ export type TextNode = {
75
+ nodeName: TextName;
76
+ nodeValue: string;
77
+ } & InkNode;
78
+
79
+ // eslint-disable-next-line @typescript-eslint/naming-convention
80
+ export type DOMNode<T = { nodeName: NodeNames }> = T extends {
81
+ nodeName: infer U;
82
+ }
83
+ ? U extends "#text"
84
+ ? TextNode
85
+ : DOMElement
86
+ : never;
87
+
88
+ // eslint-disable-next-line @typescript-eslint/naming-convention
89
+ export type DOMNodeAttribute = boolean | string | number;
90
+
91
+ export const createNode = (nodeName: ElementNames): DOMElement => {
92
+ const node: DOMElement = {
93
+ nodeName,
94
+ style: {},
95
+ attributes: {},
96
+ childNodes: [],
97
+ parentNode: undefined,
98
+ yogaNode: nodeName === "ink-virtual-text" ? undefined : Yoga.Node.create(),
99
+ // eslint-disable-next-line @typescript-eslint/naming-convention
100
+ internal_accessibility: {},
101
+ };
102
+
103
+ if (nodeName === "ink-text") {
104
+ node.yogaNode?.setMeasureFunc(measureTextNode.bind(null, node));
105
+ }
106
+
107
+ return node;
108
+ };
109
+
110
+ export const appendChildNode = (node: DOMElement, childNode: DOMElement): void => {
111
+ if (childNode.parentNode) {
112
+ removeChildNode(childNode.parentNode, childNode);
113
+ }
114
+
115
+ childNode.parentNode = node;
116
+ node.childNodes.push(childNode);
117
+
118
+ if (childNode.yogaNode) {
119
+ node.yogaNode?.insertChild(childNode.yogaNode, node.yogaNode.getChildCount());
120
+ }
121
+
122
+ if (node.nodeName === "ink-text" || node.nodeName === "ink-virtual-text") {
123
+ markNodeAsDirty(node);
124
+ }
125
+ };
126
+
127
+ export const insertBeforeNode = (
128
+ node: DOMElement,
129
+ newChildNode: DOMNode,
130
+ beforeChildNode: DOMNode,
131
+ ): void => {
132
+ if (newChildNode.parentNode) {
133
+ removeChildNode(newChildNode.parentNode, newChildNode);
134
+ }
135
+
136
+ newChildNode.parentNode = node;
137
+
138
+ const index = node.childNodes.indexOf(beforeChildNode);
139
+ if (index >= 0) {
140
+ node.childNodes.splice(index, 0, newChildNode);
141
+ if (newChildNode.yogaNode) {
142
+ node.yogaNode?.insertChild(newChildNode.yogaNode, index);
143
+ }
144
+ } else {
145
+ node.childNodes.push(newChildNode);
146
+
147
+ if (newChildNode.yogaNode) {
148
+ node.yogaNode?.insertChild(newChildNode.yogaNode, node.yogaNode.getChildCount());
149
+ }
150
+ }
151
+
152
+ if (node.nodeName === "ink-text" || node.nodeName === "ink-virtual-text") {
153
+ markNodeAsDirty(node);
154
+ }
155
+ };
156
+
157
+ export const removeChildNode = (node: DOMElement, removeNode: DOMNode): void => {
158
+ if (removeNode.yogaNode) {
159
+ removeNode.parentNode?.yogaNode?.removeChild(removeNode.yogaNode);
160
+ }
161
+
162
+ removeNode.parentNode = undefined;
163
+
164
+ const index = node.childNodes.indexOf(removeNode);
165
+ if (index >= 0) {
166
+ node.childNodes.splice(index, 1);
167
+ }
168
+
169
+ if (node.nodeName === "ink-text" || node.nodeName === "ink-virtual-text") {
170
+ markNodeAsDirty(node);
171
+ }
172
+ };
173
+
174
+ const nullifyYogaNodes = (node: DOMNode): void => {
175
+ node.yogaNode = undefined;
176
+
177
+ if (node.nodeName !== "#text") {
178
+ for (const childNode of node.childNodes) {
179
+ nullifyYogaNodes(childNode);
180
+ }
181
+ }
182
+ };
183
+
184
+ /**
185
+ Detach a removed subtree from the layout engine: drop the measure callback
186
+ and null the `yogaNode` reference on every DOM node within it.
187
+
188
+ Nulling the references makes every `?.yogaNode` guard in the codebase
189
+ effective for removed nodes, turns lingering access into a safe no-op (see
190
+ QwenLM/qwen-code#6820), and lets the garbage collector reclaim the Yoga
191
+ tree along with its closures.
192
+ */
193
+ export const detachYogaSubtree = (removeNode: DOMNode): void => {
194
+ removeNode.yogaNode?.unsetMeasureFunc();
195
+ nullifyYogaNodes(removeNode);
196
+ };
197
+
198
+ export const setAttribute = (node: DOMElement, key: string, value: DOMNodeAttribute): void => {
199
+ if (key === "internal_accessibility") {
200
+ node.internal_accessibility = value as DOMElement["internal_accessibility"];
201
+ return;
202
+ }
203
+
204
+ node.attributes[key] = value;
205
+ };
206
+
207
+ export const setStyle = (node: DOMNode, style?: Styles): void => {
208
+ // Rendering code assumes style is always an object.
209
+ node.style = style ?? {};
210
+ };
211
+
212
+ export const createTextNode = (text: string): TextNode => {
213
+ const node: TextNode = {
214
+ nodeName: "#text",
215
+ nodeValue: text,
216
+ yogaNode: undefined,
217
+ parentNode: undefined,
218
+ style: {},
219
+ };
220
+
221
+ setTextNodeValue(node, text);
222
+
223
+ return node;
224
+ };
225
+
226
+ const measureTextNode = function (node: DOMNode, width: number): { width: number; height: number } {
227
+ const text = node.nodeName === "#text" ? node.nodeValue : squashTextNodes(node);
228
+
229
+ const dimensions = measureText(text);
230
+
231
+ // Text fits into container, no need to wrap
232
+ if (dimensions.width <= width) {
233
+ return dimensions;
234
+ }
235
+
236
+ // This is happening when <Box> is shrinking child nodes and Yoga asks
237
+ // if we can fit this text node in a <1px space, so we just tell Yoga "no"
238
+ if (dimensions.width >= 1 && width > 0 && width < 1) {
239
+ return dimensions;
240
+ }
241
+
242
+ const textWrap = node.style?.textWrap ?? "wrap";
243
+ const wrappedText = wrapText(text, width, textWrap);
244
+
245
+ return measureText(wrappedText);
246
+ };
247
+
248
+ const findClosestYogaNode = (node?: DOMNode): YogaNode | undefined => {
249
+ if (!node?.parentNode) {
250
+ return;
251
+ }
252
+
253
+ return node.yogaNode ?? findClosestYogaNode(node.parentNode);
254
+ };
255
+
256
+ const markNodeAsDirty = (node?: DOMNode): void => {
257
+ // Mark closest Yoga node as dirty to measure text dimensions again
258
+ const yogaNode = findClosestYogaNode(node);
259
+ yogaNode?.markDirty();
260
+ };
261
+
262
+ export const setTextNodeValue = (node: TextNode, text: string): void => {
263
+ if (typeof text !== "string") {
264
+ text = String(text);
265
+ }
266
+
267
+ node.nodeValue = text;
268
+ markNodeAsDirty(node);
269
+ };
270
+
271
+ export const addLayoutListener = (rootNode: DOMElement, listener: LayoutListener): (() => void) => {
272
+ if (rootNode.nodeName !== "ink-root") {
273
+ return () => {};
274
+ }
275
+
276
+ rootNode.internal_layoutListeners ??= new Set();
277
+ rootNode.internal_layoutListeners.add(listener);
278
+
279
+ return () => {
280
+ rootNode.internal_layoutListeners?.delete(listener);
281
+ };
282
+ };
283
+
284
+ export const emitLayoutListeners = (rootNode: DOMElement): void => {
285
+ if (rootNode.nodeName !== "ink-root" || !rootNode.internal_layoutListeners) {
286
+ return;
287
+ }
288
+
289
+ for (const listener of rootNode.internal_layoutListeners) {
290
+ listener();
291
+ }
292
+ };
@@ -0,0 +1,11 @@
1
+ import { Yoga, type Node as YogaNode } from "./yoga/index.ts";
2
+
3
+ export const getMaxWidth = (yogaNode: YogaNode) => {
4
+ return (
5
+ yogaNode.getComputedWidth() -
6
+ yogaNode.getComputedPadding(Yoga.EDGE_LEFT) -
7
+ yogaNode.getComputedPadding(Yoga.EDGE_RIGHT) -
8
+ yogaNode.getComputedBorder(Yoga.EDGE_LEFT) -
9
+ yogaNode.getComputedBorder(Yoga.EDGE_RIGHT)
10
+ );
11
+ };