tinky 1.7.0 → 1.9.0

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.
@@ -97,7 +97,7 @@ export declare class App extends PureComponent<AppProps, State> {
97
97
  rawModeEnabledCount: number;
98
98
  internal_eventEmitter: EventEmitter;
99
99
  isRawModeSupported(): boolean;
100
- render(): import("react/jsx-runtime").JSX.Element;
100
+ render(): import("react").JSX.Element;
101
101
  componentDidMount(): void;
102
102
  componentWillUnmount(): void;
103
103
  componentDidCatch(error: Error): void;
@@ -4,5 +4,5 @@ interface ErrorProps {
4
4
  /**
5
5
  * Renders an overview of an error, including the message and stack trace.
6
6
  */
7
- export declare function ErrorOverview({ error }: ErrorProps): import("react/jsx-runtime").JSX.Element;
7
+ export declare function ErrorOverview({ error }: ErrorProps): import("react").JSX.Element;
8
8
  export {};
@@ -26,4 +26,4 @@ export interface NewlineProps {
26
26
  * );
27
27
  * ```
28
28
  */
29
- export declare function Newline({ count }: NewlineProps): import("react/jsx-runtime").JSX.Element;
29
+ export declare function Newline({ count }: NewlineProps): import("react").JSX.Element;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Props for the Raw component.
3
+ */
4
+ export interface RawProps {
5
+ /**
6
+ * Raw content to output unchanged at the component's layout position.
7
+ * This bypasses normal text rendering: no escaping, wrapping, or
8
+ * modification is applied.
9
+ */
10
+ readonly content: string;
11
+ /**
12
+ * Raw content to write to stdout when the component unmounts.
13
+ * Used as a cleanup sequence (e.g., to clear a terminal image).
14
+ */
15
+ readonly cleanup?: string;
16
+ }
17
+ /**
18
+ * Outputs raw terminal escape sequences at a specific layout position.
19
+ *
20
+ * The content bypasses tinky's normal text rendering pipeline and is written
21
+ * directly to stdout after the main frame is rendered. This is useful for
22
+ * terminal image protocols (Kitty, iTerm2, Sixel), custom animations, or
23
+ * any content that must not be modified.
24
+ *
25
+ * `Raw` has zero width and height in layout. To reserve space for the raw
26
+ * content (e.g., for an image), wrap it in a `Box` with the desired
27
+ * dimensions:
28
+ *
29
+ * @example
30
+ * ```tsx
31
+ * <Box width={40} height={10}>
32
+ * <Raw content={kittyImageSequence} />
33
+ * </Box>
34
+ * ```
35
+ *
36
+ * @example
37
+ * ```tsx
38
+ * <Raw
39
+ * content={imageSequence}
40
+ * cleanup={'\x1b_Ga=d\x1b\\'}
41
+ * />
42
+ * ```
43
+ */
44
+ export declare function Raw({ content, cleanup }: RawProps): import("react").JSX.Element;
@@ -0,0 +1,45 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useRef } from "react";
3
+ import { useStdout } from "../hooks/use-stdout.js";
4
+ const rawStyle = { width: 0, height: 0 };
5
+ /**
6
+ * Outputs raw terminal escape sequences at a specific layout position.
7
+ *
8
+ * The content bypasses tinky's normal text rendering pipeline and is written
9
+ * directly to stdout after the main frame is rendered. This is useful for
10
+ * terminal image protocols (Kitty, iTerm2, Sixel), custom animations, or
11
+ * any content that must not be modified.
12
+ *
13
+ * `Raw` has zero width and height in layout. To reserve space for the raw
14
+ * content (e.g., for an image), wrap it in a `Box` with the desired
15
+ * dimensions:
16
+ *
17
+ * @example
18
+ * ```tsx
19
+ * <Box width={40} height={10}>
20
+ * <Raw content={kittyImageSequence} />
21
+ * </Box>
22
+ * ```
23
+ *
24
+ * @example
25
+ * ```tsx
26
+ * <Raw
27
+ * content={imageSequence}
28
+ * cleanup={'\x1b_Ga=d\x1b\\'}
29
+ * />
30
+ * ```
31
+ */
32
+ export function Raw({ content, cleanup }) {
33
+ const { stdout } = useStdout();
34
+ const cleanupRef = useRef(cleanup);
35
+ cleanupRef.current = cleanup;
36
+ useEffect(() => {
37
+ return () => {
38
+ const output = cleanupRef.current;
39
+ if (output) {
40
+ stdout.write(output);
41
+ }
42
+ };
43
+ }, [stdout]);
44
+ return _jsx("tinky-raw", { style: rawStyle, internal_rawContent: content });
45
+ }
@@ -79,4 +79,4 @@ export interface SeparatorProps extends TextStyles {
79
79
  * />
80
80
  * ```
81
81
  */
82
- export declare function Separator({ char, direction, color, dimColor, backgroundColor, bold, italic, underline, strikethrough, inverse, }: SeparatorProps): import("react/jsx-runtime").JSX.Element;
82
+ export declare function Separator({ char, direction, color, dimColor, backgroundColor, bold, italic, underline, strikethrough, inverse, }: SeparatorProps): import("react").JSX.Element;
@@ -16,4 +16,4 @@
16
16
  * );
17
17
  * ```
18
18
  */
19
- export declare function Spacer(): import("react/jsx-runtime").JSX.Element;
19
+ export declare function Spacer(): import("react").JSX.Element;
@@ -45,4 +45,4 @@ export interface StaticProps<T> {
45
45
  * );
46
46
  * ```
47
47
  */
48
- export declare function Static<T>(props: StaticProps<T>): import("react/jsx-runtime").JSX.Element;
48
+ export declare function Static<T>(props: StaticProps<T>): import("react").JSX.Element;
@@ -47,4 +47,4 @@ export interface TextProps extends TextStyles {
47
47
  * );
48
48
  * ```
49
49
  */
50
- export declare function Text({ color, backgroundColor, dimColor, bold, italic, underline, strikethrough, inverse, wrap, children, "aria-label": ariaLabel, "aria-hidden": ariaHidden, }: TextProps): import("react/jsx-runtime").JSX.Element | null;
50
+ export declare function Text({ color, backgroundColor, dimColor, bold, italic, underline, strikethrough, inverse, wrap, children, "aria-label": ariaLabel, "aria-hidden": ariaHidden, }: TextProps): import("react").JSX.Element | null;
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useContext } from "react";
2
+ import { useContext, useCallback } from "react";
3
3
  import { applyTextStyles, } from "../utils/apply-text-styles.js";
4
4
  import { AccessibilityContext } from "../contexts/AccessibilityContext.js";
5
5
  import { backgroundContext } from "../contexts/BackgroundContext.js";
@@ -41,7 +41,7 @@ export function Text({ color, backgroundColor, dimColor = false, bold = false, i
41
41
  if (childrenOrAriaLabel === undefined || childrenOrAriaLabel === null) {
42
42
  return null;
43
43
  }
44
- const transform = (children) => {
44
+ const transform = useCallback((children) => {
45
45
  return applyTextStyles(children, {
46
46
  color,
47
47
  backgroundColor: effectiveBackgroundColor,
@@ -52,7 +52,16 @@ export function Text({ color, backgroundColor, dimColor = false, bold = false, i
52
52
  strikethrough,
53
53
  inverse,
54
54
  });
55
- };
55
+ }, [
56
+ color,
57
+ effectiveBackgroundColor,
58
+ dimColor,
59
+ bold,
60
+ italic,
61
+ underline,
62
+ strikethrough,
63
+ inverse,
64
+ ]);
56
65
  if (isScreenReaderEnabled && ariaHidden) {
57
66
  return null;
58
67
  }
@@ -38,4 +38,4 @@ export interface TransformProps {
38
38
  * // Output: HELLO WORLD
39
39
  * ```
40
40
  */
41
- export declare function Transform({ children, transform, accessibilityLabel, }: TransformProps): import("react/jsx-runtime").JSX.Element | null;
41
+ export declare function Transform({ children, transform, accessibilityLabel, }: TransformProps): import("react").JSX.Element | null;
@@ -2,17 +2,20 @@
2
2
  * Output implementation that writes directly into CellBuffer.
3
3
  */
4
4
  import { type CellBuffer } from "./cell-buffer.js";
5
- import { type Clip, type OutputLike, type OutputWriteOptions } from "./output.js";
5
+ import { type Clip, type OutputLike, type OutputWriteOptions, type RawEntry } from "./output.js";
6
6
  import { type AnsiCode } from "../types/ansi.js";
7
7
  export declare class CellOutput implements OutputLike {
8
8
  private readonly clips;
9
9
  private readonly styleIdByRef;
10
10
  private lastStyleRef;
11
11
  private lastStyleId;
12
+ private readonly rawEntries;
12
13
  readonly buffer: CellBuffer;
13
14
  constructor(buffer: CellBuffer);
14
15
  clip(clip: Clip): void;
15
16
  unclip(): void;
17
+ writeRaw(x: number, y: number, content: string): void;
18
+ getRawEntries(): RawEntry[];
16
19
  fill(x: number, y: number, length: number, char: string, charWidth: number, styles: AnsiCode[]): void;
17
20
  private resolveStyleId;
18
21
  private writePlainLine;
@@ -37,6 +37,7 @@ export class CellOutput {
37
37
  styleIdByRef = new WeakMap();
38
38
  lastStyleRef;
39
39
  lastStyleId = 0;
40
+ rawEntries = [];
40
41
  buffer;
41
42
  constructor(buffer) {
42
43
  this.buffer = buffer;
@@ -47,6 +48,15 @@ export class CellOutput {
47
48
  unclip() {
48
49
  this.clips.pop();
49
50
  }
51
+ writeRaw(x, y, content) {
52
+ if (!content) {
53
+ return;
54
+ }
55
+ this.rawEntries.push({ x, y, content });
56
+ }
57
+ getRawEntries() {
58
+ return this.rawEntries;
59
+ }
50
60
  fill(x, y, length, char, charWidth, styles) {
51
61
  if (length <= 0) {
52
62
  return;
@@ -1,3 +1,4 @@
1
+ import { type RawEntry } from "./output.js";
1
2
  import { type DOMElement } from "./dom.js";
2
3
  import { type CellBuffer } from "./cell-buffer.js";
3
4
  interface CellRendererResult {
@@ -9,6 +10,8 @@ interface CellRendererResult {
9
10
  staticOutput: string;
10
11
  /** Screen-reader text when screen-reader mode is enabled. */
11
12
  screenReaderOutput?: string;
13
+ /** Raw entries to write after the main output. */
14
+ rawEntries: RawEntry[];
12
15
  }
13
16
  /**
14
17
  * Renderer that writes interactive output into a CellBuffer.
@@ -16,7 +16,7 @@ export const cellRenderer = (node, buffer, isScreenReaderEnabled) => {
16
16
  if (!node.taffyNode) {
17
17
  buffer.resize(0, 0);
18
18
  buffer.clear();
19
- return { buffer, outputHeight: 0, staticOutput: "" };
19
+ return { buffer, outputHeight: 0, staticOutput: "", rawEntries: [] };
20
20
  }
21
21
  if (isScreenReaderEnabled) {
22
22
  const output = renderNodeToScreenReaderOutput(node, {
@@ -34,6 +34,7 @@ export const cellRenderer = (node, buffer, isScreenReaderEnabled) => {
34
34
  outputHeight,
35
35
  staticOutput: staticOutput ? `${staticOutput}\n` : "",
36
36
  screenReaderOutput: output,
37
+ rawEntries: [],
37
38
  };
38
39
  }
39
40
  const layout = node.taffyNode.tree.getLayout(node.taffyNode.id);
@@ -56,5 +57,6 @@ export const cellRenderer = (node, buffer, isScreenReaderEnabled) => {
56
57
  buffer,
57
58
  outputHeight: buffer.height,
58
59
  staticOutput: staticOutput ? `${staticOutput.get().output}\n` : "",
60
+ rawEntries: output.getRawEntries(),
59
61
  };
60
62
  };
package/lib/core/dom.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type Styles } from "./styles.js";
2
2
  import { type OutputTransformer } from "./render-node-to-output.js";
3
3
  import { TaffyNode } from "./taffy-node.js";
4
+ import { type SizeObserver } from "./size-observer.js";
4
5
  /**
5
6
  * Interface representing a node in the Tinky tree.
6
7
  */
@@ -21,7 +22,7 @@ export type TextName = "#text";
21
22
  /**
22
23
  * Type representing element names in the Tinky DOM.
23
24
  */
24
- export type ElementNames = "tinky-root" | "tinky-box" | "tinky-text" | "tinky-virtual-text" | "tinky-separator";
25
+ export type ElementNames = "tinky-root" | "tinky-box" | "tinky-text" | "tinky-virtual-text" | "tinky-separator" | "tinky-raw";
25
26
  /**
26
27
  * Union type of all possible node names.
27
28
  */
@@ -74,6 +75,8 @@ export type DOMElement = {
74
75
  onRender?: () => void;
75
76
  /** Callback to trigger an immediate render. */
76
77
  onImmediateRender?: () => void;
78
+ /** Set of resize observers attached to this element. */
79
+ resizeObservers?: Set<SizeObserver>;
77
80
  } & TinkyNode;
78
81
  /**
79
82
  * Interface representing a text node in Tinky.
@@ -21,6 +21,17 @@ export interface Clip {
21
21
  /** Bottom boundary (undefined for no limit). */
22
22
  y2: number | undefined;
23
23
  }
24
+ /**
25
+ * Represents a raw output entry that bypasses normal text rendering.
26
+ */
27
+ export interface RawEntry {
28
+ /** X coordinate (column) of the raw content. */
29
+ x: number;
30
+ /** Y coordinate (row) of the raw content. */
31
+ y: number;
32
+ /** Raw content to output unchanged. */
33
+ content: string;
34
+ }
24
35
  /**
25
36
  * Shared write/clip/unclip surface used by render traversal.
26
37
  */
@@ -29,6 +40,8 @@ export interface OutputLike {
29
40
  clip(clip: Clip): void;
30
41
  unclip(): void;
31
42
  fill(x: number, y: number, length: number, char: string, charWidth: number, styles: AnsiCode[]): void;
43
+ writeRaw(x: number, y: number, content: string): void;
44
+ getRawEntries(): RawEntry[];
32
45
  }
33
46
  /**
34
47
  * "Virtual" output class
@@ -42,6 +55,7 @@ export declare class Output implements OutputLike {
42
55
  width: number;
43
56
  height: number;
44
57
  private readonly operations;
58
+ private readonly rawEntries;
45
59
  /**
46
60
  * Creates a new Output instance.
47
61
  *
@@ -68,6 +82,8 @@ export declare class Output implements OutputLike {
68
82
  */
69
83
  unclip(): void;
70
84
  fill(x: number, y: number, length: number, char: string, _charWidth: number, styles: AnsiCode[]): void;
85
+ writeRaw(x: number, y: number, content: string): void;
86
+ getRawEntries(): RawEntry[];
71
87
  /**
72
88
  * Generates the final output string and its height.
73
89
  *
@@ -14,6 +14,7 @@ export class Output {
14
14
  width;
15
15
  height;
16
16
  operations = [];
17
+ rawEntries = [];
17
18
  /**
18
19
  * Creates a new Output instance.
19
20
  *
@@ -75,6 +76,15 @@ export class Output {
75
76
  const text = prefix + char.repeat(length) + suffix;
76
77
  this.write(x, y, text, { transformers: [] });
77
78
  }
79
+ writeRaw(x, y, content) {
80
+ if (!content) {
81
+ return;
82
+ }
83
+ this.rawEntries.push({ x, y, content });
84
+ }
85
+ getRawEntries() {
86
+ return this.rawEntries;
87
+ }
78
88
  /**
79
89
  * Generates the final output string and its height.
80
90
  *
@@ -0,0 +1,14 @@
1
+ import { type RawEntry } from "./output.js";
2
+ /**
3
+ * Builds a raw suffix string from collected raw entries.
4
+ *
5
+ * After the main frame is written to stdout, the raw suffix moves the cursor
6
+ * to each raw entry's layout position and outputs its content unchanged.
7
+ * After all entries, the cursor is moved back to the end of the frame.
8
+ *
9
+ * @param entries - Raw entries collected during render traversal.
10
+ * @param frameHeight - Height of the rendered frame in rows.
11
+ * @param rowOffset - Rows already written above the interactive frame.
12
+ * @returns The raw suffix string, or empty string if no entries.
13
+ */
14
+ export declare const buildRawSuffix: (entries: readonly RawEntry[], frameHeight: number, rowOffset?: number) => string;
@@ -0,0 +1,28 @@
1
+ import ansiEscapes from "../utils/ansi-escapes.js";
2
+ /**
3
+ * Builds a raw suffix string from collected raw entries.
4
+ *
5
+ * After the main frame is written to stdout, the raw suffix moves the cursor
6
+ * to each raw entry's layout position and outputs its content unchanged.
7
+ * After all entries, the cursor is moved back to the end of the frame.
8
+ *
9
+ * @param entries - Raw entries collected during render traversal.
10
+ * @param frameHeight - Height of the rendered frame in rows.
11
+ * @param rowOffset - Rows already written above the interactive frame.
12
+ * @returns The raw suffix string, or empty string if no entries.
13
+ */
14
+ export const buildRawSuffix = (entries, frameHeight, rowOffset = 0) => {
15
+ if (entries.length === 0) {
16
+ return "";
17
+ }
18
+ const normalizedRowOffset = Math.max(0, Math.floor(rowOffset));
19
+ const parts = [];
20
+ for (const entry of entries) {
21
+ const x = Math.max(0, Math.floor(entry.x));
22
+ const y = normalizedRowOffset + Math.max(0, Math.floor(entry.y));
23
+ parts.push(ansiEscapes.cursorTo(x, y), entry.content);
24
+ }
25
+ const frameEndY = normalizedRowOffset + Math.max(0, Math.floor(frameHeight) - 1);
26
+ parts.push(ansiEscapes.cursorTo(0, frameEndY));
27
+ return parts.join("");
28
+ };
@@ -5,6 +5,7 @@ import { createContext } from "react";
5
5
  import { createTextNode, appendChildNode, insertBeforeNode, removeChildNode, setStyle, setTextNodeValue, createNode, setAttribute, } from "./dom.js";
6
6
  import { applyStyles } from "./styles.js";
7
7
  import { process } from "../utils/node-adapter.js";
8
+ import { removeSizeObserversInSubtree } from "./size-observer.js";
8
9
  // We need to conditionally perform devtools connection to avoid
9
10
  // accidentally breaking other third-party code.
10
11
  if (process?.env?.["DEV"] === "true") {
@@ -111,6 +112,9 @@ export const reconciler = createReconciler({
111
112
  if (hostContext.isInsideText && originalType === "tinky-box") {
112
113
  throw new Error(`<Box> can’t be nested inside <Text> component`);
113
114
  }
115
+ if (hostContext.isInsideText && originalType === "tinky-raw") {
116
+ throw new Error(`<Raw> can’t be nested inside <Text> component`);
117
+ }
114
118
  const type = originalType === "tinky-text" && hostContext.isInsideText
115
119
  ? "tinky-virtual-text"
116
120
  : originalType;
@@ -204,6 +208,7 @@ export const reconciler = createReconciler({
204
208
  insertInContainerBefore: insertBeforeNode,
205
209
  removeChildFromContainer(node, removeNode) {
206
210
  removeChildNode(node, removeNode);
211
+ removeSizeObserversInSubtree(removeNode);
207
212
  cleanupTaffyNode(removeNode.taffyNode);
208
213
  },
209
214
  commitUpdate(node, _type, oldProps, newProps) {
@@ -241,6 +246,7 @@ export const reconciler = createReconciler({
241
246
  },
242
247
  removeChild(node, removeNode) {
243
248
  removeChildNode(node, removeNode);
249
+ removeSizeObserversInSubtree(removeNode);
244
250
  cleanupTaffyNode(removeNode.taffyNode);
245
251
  },
246
252
  setCurrentUpdatePriority(newPriority) {
@@ -143,6 +143,13 @@ export const renderNodeToOutput = (node, output, options) => {
143
143
  }
144
144
  return;
145
145
  }
146
+ if (node.nodeName === "tinky-raw") {
147
+ const content = node.attributes["internal_rawContent"];
148
+ if (content) {
149
+ output.writeRaw(x, y, content);
150
+ }
151
+ return;
152
+ }
146
153
  if (node.nodeName === "tinky-separator") {
147
154
  const separatorChar = node.attributes["internal_separatorChar"] || "─";
148
155
  const direction = node.attributes["internal_separatorDirection"] ||
@@ -1,4 +1,5 @@
1
1
  import { type DOMElement } from "./dom.js";
2
+ import { type RawEntry } from "./output.js";
2
3
  /**
3
4
  * Interface representing the result of a render operation.
4
5
  */
@@ -15,6 +16,8 @@ interface Result {
15
16
  * The static output generated during rendering (e.g., from <Static>).
16
17
  */
17
18
  staticOutput: string;
19
+ /** Raw entries to write after the main output. */
20
+ rawEntries: RawEntry[];
18
21
  }
19
22
  /**
20
23
  * Renders the DOM tree to a string output.
@@ -25,6 +25,7 @@ export const renderer = (node, isScreenReaderEnabled) => {
25
25
  output,
26
26
  outputHeight,
27
27
  staticOutput: staticOutput ? `${staticOutput}\n` : "",
28
+ rawEntries: [],
28
29
  };
29
30
  }
30
31
  const layout = node.taffyNode.tree.getLayout(node.taffyNode.id);
@@ -55,11 +56,13 @@ export const renderer = (node, isScreenReaderEnabled) => {
55
56
  // Newline at the end is needed, because static output doesn't have one,
56
57
  // so interactive output will override last line of static output
57
58
  staticOutput: staticOutput ? `${staticOutput.buffer.toString()}\n` : "",
59
+ rawEntries: output.getRawEntries(),
58
60
  };
59
61
  }
60
62
  return {
61
63
  output: "",
62
64
  outputHeight: 0,
63
65
  staticOutput: "",
66
+ rawEntries: [],
64
67
  };
65
68
  };
@@ -0,0 +1,60 @@
1
+ import { type DOMElement, type DOMNode } from "./dom.js";
2
+ import { type Dimension } from "../utils/dimension.js";
3
+ /**
4
+ * Interface for elements returned by the SizeObserver callback.
5
+ */
6
+ export interface SizeObserverEntry {
7
+ target: DOMElement;
8
+ dimension: Dimension;
9
+ }
10
+ /**
11
+ * Callback type for resize observers.
12
+ */
13
+ export type SizeObserverCallback = (entries: SizeObserverEntry[], observer: SizeObserver) => void;
14
+ /**
15
+ * Global registry mapping DOMElements to their registered SizeObservers.
16
+ */
17
+ export declare const resizeObserverRegistry: Map<DOMElement, Set<SizeObserver>>;
18
+ /**
19
+ * Implements a SizeObserver API similar to the HTML specification.
20
+ * It allows observing changes to the computed dimensions of Tinky DOM elements.
21
+ */
22
+ export declare class SizeObserver {
23
+ private callback;
24
+ /**
25
+ * Creates a new SizeObserver.
26
+ *
27
+ * @param callback - The function to call when observed elements resize.
28
+ */
29
+ constructor(callback: SizeObserverCallback);
30
+ /**
31
+ * Starts observing the specified element.
32
+ *
33
+ * @param target - The element to observe.
34
+ */
35
+ observe(target: DOMElement): void;
36
+ /**
37
+ * Stops observing the specified element.
38
+ *
39
+ * @param target - The element to stop observing.
40
+ */
41
+ unobserve(target: DOMElement): void;
42
+ /**
43
+ * Stops observing all elements.
44
+ */
45
+ disconnect(): void;
46
+ /** @internal */
47
+ _invokeCallback(entries: SizeObserverEntry[]): void;
48
+ }
49
+ /**
50
+ * Removes all resize observers from a node and its descendants.
51
+ * Called during mutation cleanup to prevent stale observer entries.
52
+ *
53
+ * @param node - Root of the removed subtree.
54
+ */
55
+ export declare const removeSizeObserversInSubtree: (node: DOMNode) => void;
56
+ /**
57
+ * Notifies all registered resize observers of their current dimensions.
58
+ * Called by the Tinky instance after each layout computation pass.
59
+ */
60
+ export declare const notifySizeObservers: () => void;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Global registry mapping DOMElements to their registered SizeObservers.
3
+ */
4
+ export const resizeObserverRegistry = new Map();
5
+ /**
6
+ * Implements a SizeObserver API similar to the HTML specification.
7
+ * It allows observing changes to the computed dimensions of Tinky DOM elements.
8
+ */
9
+ export class SizeObserver {
10
+ callback;
11
+ /**
12
+ * Creates a new SizeObserver.
13
+ *
14
+ * @param callback - The function to call when observed elements resize.
15
+ */
16
+ constructor(callback) {
17
+ this.callback = callback;
18
+ }
19
+ /**
20
+ * Starts observing the specified element.
21
+ *
22
+ * @param target - The element to observe.
23
+ */
24
+ observe(target) {
25
+ if (!target.resizeObservers) {
26
+ target.resizeObservers = new Set();
27
+ }
28
+ target.resizeObservers.add(this);
29
+ let observers = resizeObserverRegistry.get(target);
30
+ if (!observers) {
31
+ observers = new Set();
32
+ resizeObserverRegistry.set(target, observers);
33
+ }
34
+ observers.add(this);
35
+ }
36
+ /**
37
+ * Stops observing the specified element.
38
+ *
39
+ * @param target - The element to stop observing.
40
+ */
41
+ unobserve(target) {
42
+ target.resizeObservers?.delete(this);
43
+ if (target.resizeObservers?.size === 0) {
44
+ target.resizeObservers = undefined;
45
+ }
46
+ const observers = resizeObserverRegistry.get(target);
47
+ if (observers) {
48
+ observers.delete(this);
49
+ if (observers.size === 0) {
50
+ resizeObserverRegistry.delete(target);
51
+ }
52
+ }
53
+ }
54
+ /**
55
+ * Stops observing all elements.
56
+ */
57
+ disconnect() {
58
+ for (const [target, observers] of resizeObserverRegistry.entries()) {
59
+ if (observers.has(this)) {
60
+ this.unobserve(target);
61
+ }
62
+ }
63
+ }
64
+ /** @internal */
65
+ _invokeCallback(entries) {
66
+ this.callback(entries, this);
67
+ }
68
+ }
69
+ /**
70
+ * Removes all resize observers from a node and its descendants.
71
+ * Called during mutation cleanup to prevent stale observer entries.
72
+ *
73
+ * @param node - Root of the removed subtree.
74
+ */
75
+ export const removeSizeObserversInSubtree = (node) => {
76
+ if (node.nodeName === "#text") {
77
+ return;
78
+ }
79
+ if (node.resizeObservers) {
80
+ // Collect all observers to avoid disconnecting while iterating
81
+ const observers = Array.from(node.resizeObservers);
82
+ for (const observer of observers) {
83
+ observer.unobserve(node);
84
+ }
85
+ node.resizeObservers = undefined;
86
+ }
87
+ // Not strictly necessary here since unobserve handles it,
88
+ // but good for completeness in case it fell through
89
+ resizeObserverRegistry.delete(node);
90
+ for (const childNode of node.childNodes) {
91
+ removeSizeObserversInSubtree(childNode);
92
+ }
93
+ };
94
+ /**
95
+ * Notifies all registered resize observers of their current dimensions.
96
+ * Called by the Tinky instance after each layout computation pass.
97
+ */
98
+ export const notifySizeObservers = () => {
99
+ const entriesByObserver = new Map();
100
+ for (const [node, observers] of resizeObserverRegistry.entries()) {
101
+ if (!node.taffyNode) {
102
+ continue;
103
+ }
104
+ let layout;
105
+ try {
106
+ layout = node.taffyNode.tree.getLayout(node.taffyNode.id);
107
+ }
108
+ catch {
109
+ // Node was removed from the tree before passive effect cleanup ran.
110
+ const nodeObservers = Array.from(observers);
111
+ for (const observer of nodeObservers) {
112
+ observer.unobserve(node);
113
+ }
114
+ continue;
115
+ }
116
+ const dimension = {
117
+ width: layout?.width ?? 0,
118
+ height: layout?.height ?? 0,
119
+ };
120
+ const entry = { target: node, dimension };
121
+ for (const observer of observers) {
122
+ let entries = entriesByObserver.get(observer);
123
+ if (!entries) {
124
+ entries = [];
125
+ entriesByObserver.set(observer, entries);
126
+ }
127
+ entries.push(entry);
128
+ }
129
+ }
130
+ for (const [observer, entries] of entriesByObserver.entries()) {
131
+ observer._invokeCallback(entries);
132
+ }
133
+ };
@@ -101,6 +101,12 @@ export declare class Tinky {
101
101
  private readonly rootNode;
102
102
  /** Full static output for debug mode. */
103
103
  private fullStaticOutput;
104
+ /** Raw entries from the last rendered frame. */
105
+ private lastRawEntries;
106
+ /** Height of the last frame used to place raw output. */
107
+ private lastRawFrameHeight;
108
+ /** Raw suffix from the last rendered frame. */
109
+ private lastRawSuffix;
104
110
  /** Promise that resolves when the app exits. */
105
111
  private exitPromise?;
106
112
  /** Function to restore console after patching. */
@@ -160,6 +166,10 @@ export declare class Tinky {
160
166
  * the next render cycle.
161
167
  */
162
168
  private swapRunBuffers;
169
+ private updateRawState;
170
+ private clearRawState;
171
+ private buildRawSuffix;
172
+ private writeRawSuffix;
163
173
  private redrawRunBuffer;
164
174
  /**
165
175
  * Renders the given React node.
package/lib/core/tinky.js CHANGED
@@ -11,6 +11,7 @@ import wrapAnsi from "wrap-ansi";
11
11
  import { reconciler } from "./reconciler.js";
12
12
  import { renderer } from "./renderer.js";
13
13
  import { cellRenderer } from "./cell-renderer.js";
14
+ import { buildRawSuffix } from "./raw-suffix.js";
14
15
  import * as dom from "./dom.js";
15
16
  import { logUpdate } from "./log-update.js";
16
17
  import { cellLogUpdateRun, } from "./cell-log-update-run.js";
@@ -19,9 +20,19 @@ import { App } from "../components/App.js";
19
20
  import { AccessibilityContext } from "../contexts/AccessibilityContext.js";
20
21
  import { CellBuffer, StyleRegistry } from "./cell-buffer.js";
21
22
  import { normalizeIncrementalRendering, } from "./incremental-rendering.js";
23
+ import { notifySizeObservers } from "./size-observer.js";
22
24
  const noop = () => {
23
25
  // no-op
24
26
  };
27
+ const countOutputRows = (output) => {
28
+ let rows = 0;
29
+ for (const character of output) {
30
+ if (character === "\n") {
31
+ rows++;
32
+ }
33
+ }
34
+ return rows;
35
+ };
25
36
  /**
26
37
  * Tinky core class responsible for managing the React tree rendering,
27
38
  * lifecycle, and terminal output.
@@ -49,6 +60,12 @@ export class Tinky {
49
60
  rootNode;
50
61
  /** Full static output for debug mode. */
51
62
  fullStaticOutput;
63
+ /** Raw entries from the last rendered frame. */
64
+ lastRawEntries;
65
+ /** Height of the last frame used to place raw output. */
66
+ lastRawFrameHeight;
67
+ /** Raw suffix from the last rendered frame. */
68
+ lastRawSuffix;
52
69
  /** Promise that resolves when the app exits. */
53
70
  exitPromise;
54
71
  /** Function to restore console after patching. */
@@ -131,6 +148,9 @@ export class Tinky {
131
148
  // that it's rerendered every time, not just new static parts, like in
132
149
  // non-debug mode
133
150
  this.fullStaticOutput = "";
151
+ this.lastRawEntries = [];
152
+ this.lastRawFrameHeight = 0;
153
+ this.lastRawSuffix = "";
134
154
  this.container = reconciler.createContainer(this.rootNode, LegacyRoot, null, false, null, "id", noop, noop, noop, noop, null);
135
155
  // Unmount when process exits
136
156
  this.unsubscribeExit = onExit(() => {
@@ -222,6 +242,8 @@ export class Tinky {
222
242
  }
223
243
  return context.measureFunc(availableSpace.width);
224
244
  });
245
+ // Notify all registered resize observers of their current dimensions.
246
+ notifySizeObservers();
225
247
  };
226
248
  /**
227
249
  * Performs the render operation.
@@ -233,20 +255,26 @@ export class Tinky {
233
255
  }
234
256
  if (this.usesRunIncrementalRendering && this.backBuffer) {
235
257
  const startTime = performance.now();
236
- const { buffer, outputHeight, staticOutput } = cellRenderer(this.rootNode, this.backBuffer, false);
258
+ const { buffer, outputHeight, staticOutput, rawEntries } = cellRenderer(this.rootNode, this.backBuffer, false);
237
259
  this.options.onRender?.({ renderTime: performance.now() - startTime });
238
260
  const hasStaticOutput = staticOutput && staticOutput !== "\n";
239
261
  if (hasStaticOutput) {
240
262
  this.fullStaticOutput += staticOutput;
241
263
  }
242
- if (this.options.stdout.rows &&
243
- this.lastOutputHeight >= this.options.stdout.rows) {
264
+ const rawSuffix = this.updateRawState(rawEntries, outputHeight);
265
+ // Raw content uses absolute CUP positioning, so redraw from a known
266
+ // static prefix before writing the raw suffix.
267
+ const needsClearTerminal = rawSuffix.length > 0 ||
268
+ Boolean(this.options.stdout.rows &&
269
+ this.lastOutputHeight >= this.options.stdout.rows);
270
+ if (needsClearTerminal) {
244
271
  const output = buffer.toString();
245
272
  this.options.stdout.write(ansiEscapes.clearTerminal + this.fullStaticOutput + output);
246
273
  this.lastOutput = output;
247
274
  this.lastOutputHeight = outputHeight;
248
275
  this.cellLog?.sync(buffer);
249
276
  this.swapRunBuffers();
277
+ this.writeRawSuffix();
250
278
  return;
251
279
  }
252
280
  if (hasStaticOutput) {
@@ -266,7 +294,7 @@ export class Tinky {
266
294
  return;
267
295
  }
268
296
  const startTime = performance.now();
269
- const { output, outputHeight, staticOutput } = renderer(this.rootNode, this.isScreenReaderEnabled);
297
+ const { output, outputHeight, staticOutput, rawEntries } = renderer(this.rootNode, this.isScreenReaderEnabled);
270
298
  this.options.onRender?.({ renderTime: performance.now() - startTime });
271
299
  // If <Static> output isn't empty, it means new children have been added to
272
300
  // it
@@ -275,10 +303,13 @@ export class Tinky {
275
303
  if (hasStaticOutput) {
276
304
  this.fullStaticOutput += staticOutput;
277
305
  }
306
+ this.updateRawState(rawEntries, outputHeight);
278
307
  this.options.stdout.write(this.fullStaticOutput + output);
308
+ this.writeRawSuffix();
279
309
  return;
280
310
  }
281
311
  if (this.isCI) {
312
+ this.clearRawState();
282
313
  if (hasStaticOutput) {
283
314
  this.options.stdout.write(staticOutput);
284
315
  }
@@ -287,6 +318,7 @@ export class Tinky {
287
318
  return;
288
319
  }
289
320
  if (this.isScreenReaderEnabled) {
321
+ this.clearRawState();
290
322
  if (hasStaticOutput) {
291
323
  // We need to erase the main output before writing new static output
292
324
  const erase = this.lastOutputHeight > 0
@@ -322,12 +354,18 @@ export class Tinky {
322
354
  if (hasStaticOutput) {
323
355
  this.fullStaticOutput += staticOutput;
324
356
  }
325
- if (this.options.stdout.rows &&
326
- this.lastOutputHeight >= this.options.stdout.rows) {
357
+ const rawSuffix = this.updateRawState(rawEntries, outputHeight);
358
+ // Raw content uses absolute CUP positioning, so redraw from a known static
359
+ // prefix before writing the raw suffix.
360
+ const needsClearTerminal = rawSuffix.length > 0 ||
361
+ Boolean(this.options.stdout.rows &&
362
+ this.lastOutputHeight >= this.options.stdout.rows);
363
+ if (needsClearTerminal) {
327
364
  this.options.stdout.write(ansiEscapes.clearTerminal + this.fullStaticOutput + output);
328
365
  this.lastOutput = output;
329
366
  this.lastOutputHeight = outputHeight;
330
367
  this.log.sync(output);
368
+ this.writeRawSuffix();
331
369
  return;
332
370
  }
333
371
  // To ensure static output is cleanly rendered before main output, clear
@@ -354,6 +392,28 @@ export class Tinky {
354
392
  this.frontBuffer = this.backBuffer;
355
393
  this.backBuffer = previous;
356
394
  }
395
+ updateRawState(rawEntries, outputHeight) {
396
+ this.lastRawEntries = rawEntries;
397
+ this.lastRawFrameHeight = outputHeight;
398
+ this.lastRawSuffix = this.buildRawSuffix();
399
+ return this.lastRawSuffix;
400
+ }
401
+ clearRawState() {
402
+ this.lastRawEntries = [];
403
+ this.lastRawFrameHeight = 0;
404
+ this.lastRawSuffix = "";
405
+ }
406
+ buildRawSuffix(extraRowOffset = 0) {
407
+ return buildRawSuffix(this.lastRawEntries, this.lastRawFrameHeight, countOutputRows(this.fullStaticOutput) + extraRowOffset);
408
+ }
409
+ writeRawSuffix(prefix = "") {
410
+ const rawSuffix = prefix
411
+ ? this.buildRawSuffix(countOutputRows(prefix))
412
+ : this.lastRawSuffix;
413
+ if (rawSuffix) {
414
+ this.options.stdout.write(rawSuffix);
415
+ }
416
+ }
357
417
  redrawRunBuffer() {
358
418
  if (!this.frontBuffer || !this.cellLog) {
359
419
  return;
@@ -361,6 +421,7 @@ export class Tinky {
361
421
  this.cellLog(this.frontBuffer, this.frontBuffer, { forceFull: true });
362
422
  this.lastOutput = this.frontBuffer.toString();
363
423
  this.lastOutputHeight = this.frontBuffer.height;
424
+ this.writeRawSuffix();
364
425
  }
365
426
  /**
366
427
  * Renders the given React node.
@@ -383,6 +444,7 @@ export class Tinky {
383
444
  }
384
445
  if (this.options.debug) {
385
446
  this.options.stdout.write(data + this.fullStaticOutput + this.lastOutput);
447
+ this.writeRawSuffix(data);
386
448
  return;
387
449
  }
388
450
  if (this.isCI) {
@@ -398,6 +460,7 @@ export class Tinky {
398
460
  this.log.clear();
399
461
  this.options.stdout.write(data);
400
462
  this.log(this.lastOutput);
463
+ this.writeRawSuffix(data);
401
464
  }
402
465
  /**
403
466
  * Writes data to stderr.
@@ -411,6 +474,7 @@ export class Tinky {
411
474
  if (this.options.debug) {
412
475
  this.options.stderr.write(data);
413
476
  this.options.stdout.write(this.fullStaticOutput + this.lastOutput);
477
+ this.writeRawSuffix(data);
414
478
  return;
415
479
  }
416
480
  if (this.isCI) {
@@ -426,6 +490,7 @@ export class Tinky {
426
490
  this.log.clear();
427
491
  this.options.stderr.write(data);
428
492
  this.log(this.lastOutput);
493
+ this.writeRawSuffix(data);
429
494
  }
430
495
  /**
431
496
  * Unmounts the Tinky app.
@@ -0,0 +1,44 @@
1
+ import { type DOMElement } from "../core/dom.js";
2
+ /**
3
+ * Options for the `useSizeObserver` hook.
4
+ */
5
+ export interface SizeObserverOptions {
6
+ /**
7
+ * Enable or disable the size observer.
8
+ *
9
+ * @defaultValue true
10
+ */
11
+ isActive?: boolean;
12
+ }
13
+ /**
14
+ * Hook that observes the computed layout dimensions of a `<Box>` element
15
+ * and triggers a re-render whenever its size changes.
16
+ *
17
+ * The observer is registered in a global resize observer registry that is
18
+ * invoked by the Tinky core after each layout computation pass. This means
19
+ * size changes are detected regardless of which component caused the
20
+ * re-render — including sibling updates and terminal resize events.
21
+ *
22
+ * Multiple observers can be attached to the same element.
23
+ *
24
+ * This is similar to HTML's `SizeObserver` API.
25
+ *
26
+ * @example
27
+ * ```tsx
28
+ * import { Box, Text, useSizeObserver } from 'tinky';
29
+ *
30
+ * function MyComponent() {
31
+ * const [ref, width, height] = useSizeObserver();
32
+ *
33
+ * return (
34
+ * <Box ref={ref} width="50%" height={10}>
35
+ * <Text>Size: {width}x{height}</Text>
36
+ * </Box>
37
+ * );
38
+ * }
39
+ * ```
40
+ *
41
+ * @param options - Configuration options.
42
+ * @returns A tuple containing `[refCallback, width, height]`. Attach `refCallback` to a `<Box>` element.
43
+ */
44
+ export declare const useSizeObserver: (options?: SizeObserverOptions) => [(node: DOMElement | null) => void, number, number];
@@ -0,0 +1,70 @@
1
+ import { useCallback, useRef, useState } from "react";
2
+ import { measureElement } from "../utils/measure-element.js";
3
+ import { SizeObserver } from "../core/size-observer.js";
4
+ import { reconciler } from "../core/reconciler.js";
5
+ /**
6
+ * Hook that observes the computed layout dimensions of a `<Box>` element
7
+ * and triggers a re-render whenever its size changes.
8
+ *
9
+ * The observer is registered in a global resize observer registry that is
10
+ * invoked by the Tinky core after each layout computation pass. This means
11
+ * size changes are detected regardless of which component caused the
12
+ * re-render — including sibling updates and terminal resize events.
13
+ *
14
+ * Multiple observers can be attached to the same element.
15
+ *
16
+ * This is similar to HTML's `SizeObserver` API.
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * import { Box, Text, useSizeObserver } from 'tinky';
21
+ *
22
+ * function MyComponent() {
23
+ * const [ref, width, height] = useSizeObserver();
24
+ *
25
+ * return (
26
+ * <Box ref={ref} width="50%" height={10}>
27
+ * <Text>Size: {width}x{height}</Text>
28
+ * </Box>
29
+ * );
30
+ * }
31
+ * ```
32
+ *
33
+ * @param options - Configuration options.
34
+ * @returns A tuple containing `[refCallback, width, height]`. Attach `refCallback` to a `<Box>` element.
35
+ */
36
+ export const useSizeObserver = (options = {}) => {
37
+ const [size, setSize] = useState({ width: 0, height: 0 });
38
+ const observerRef = useRef(null);
39
+ const prevSize = useRef({ width: 0, height: 0 });
40
+ const { isActive = true } = options;
41
+ const callbackRef = useCallback((node) => {
42
+ // Cleanup previous observer
43
+ if (observerRef.current) {
44
+ observerRef.current.disconnect();
45
+ observerRef.current = null;
46
+ }
47
+ // If active and node is attached, start observing
48
+ if (isActive && node) {
49
+ const callback = (entries) => {
50
+ if (entries.length === 0)
51
+ return;
52
+ const { dimension } = entries[0];
53
+ if (dimension.width !== prevSize.current.width ||
54
+ dimension.height !== prevSize.current.height) {
55
+ prevSize.current = { ...dimension };
56
+ reconciler.batchedUpdates(() => {
57
+ setSize({ ...dimension });
58
+ }, null);
59
+ }
60
+ };
61
+ const observer = new SizeObserver(callback);
62
+ // Read initial dimensions
63
+ const initial = measureElement(node);
64
+ callback([{ target: node, dimension: initial }]);
65
+ observer.observe(node);
66
+ observerRef.current = observer;
67
+ }
68
+ }, [isActive]);
69
+ return [callbackRef, size.width, size.height];
70
+ };
package/lib/index.d.ts CHANGED
@@ -12,6 +12,7 @@ export { Transform, type TransformProps } from "./components/Transform.js";
12
12
  export { Newline, type NewlineProps } from "./components/Newline.js";
13
13
  export { Separator, type SeparatorProps } from "./components/Separator.js";
14
14
  export { Spacer } from "./components/Spacer.js";
15
+ export { Raw, type RawProps } from "./components/Raw.js";
15
16
  export { useInput, type InputHandler, type InputOptions, type Key, } from "./hooks/use-input.js";
16
17
  export { useApp } from "./hooks/use-app.js";
17
18
  export { useStdin } from "./hooks/use-stdin.js";
@@ -21,6 +22,7 @@ export { useFocus, type FocusOptions, type FocusState, } from "./hooks/use-focus
21
22
  export { useFocusManager, type FocusManager, } from "./hooks/use-focus-manager.js";
22
23
  export { useIsScreenReaderEnabled } from "./hooks/use-is-screen-reader-enabled.js";
23
24
  export { measureElement } from "./utils/measure-element.js";
25
+ export { useSizeObserver, type SizeObserverOptions, } from "./hooks/use-size-observer.js";
24
26
  export { type Dimension } from "./utils/dimension.js";
25
27
  export { type ForegroundColorName } from "./utils/colorize.js";
26
28
  export { applyTextStyles, type TextStyles } from "./utils/apply-text-styles.js";
package/lib/index.js CHANGED
@@ -10,6 +10,7 @@ export { Transform } from "./components/Transform.js";
10
10
  export { Newline } from "./components/Newline.js";
11
11
  export { Separator } from "./components/Separator.js";
12
12
  export { Spacer } from "./components/Spacer.js";
13
+ export { Raw } from "./components/Raw.js";
13
14
  export { useInput, } from "./hooks/use-input.js";
14
15
  export { useApp } from "./hooks/use-app.js";
15
16
  export { useStdin } from "./hooks/use-stdin.js";
@@ -19,6 +20,7 @@ export { useFocus, } from "./hooks/use-focus.js";
19
20
  export { useFocusManager, } from "./hooks/use-focus-manager.js";
20
21
  export { useIsScreenReaderEnabled } from "./hooks/use-is-screen-reader-enabled.js";
21
22
  export { measureElement } from "./utils/measure-element.js";
23
+ export { useSizeObserver, } from "./hooks/use-size-observer.js";
22
24
  export { applyTextStyles } from "./utils/apply-text-styles.js";
23
25
  export { boxStyles } from "./core/box-styles.js";
24
26
  export { EventEmitter } from "./utils/event-emitter.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tinky",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "React for CLIs, re-imagined with the Taffy engine",
5
5
  "keywords": [
6
6
  "react",