Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
* text=auto eol=lf

*.json linguist-generated=true
package.json linguist-generated=false
*.svg linguist-generated=true
*.txt linguist-generated=true

test/replay/**/*.svg binary linguist-generated=true
test/replay/**/*.json binary linguist-generated=true
7 changes: 7 additions & 0 deletions GEMINI.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,10 @@ This module managed the actual output to the terminal `stdout` in the legacy ren

- **`<Static>`**: This component is considered a legacy feature. It is intended for permanently outputting text above the active Ink app (like a log). However, it is **not fully supported in alternate buffer mode** and will NEVER be supported by the new worker-based renderer. The architectural challenge is that `<Static>` relies on side-effects that can conflict with the strict timing requirements of `useLayoutEffect` used in the main rendering loop, potentially leading to out-of-order output or visual glitches in full-screen apps.
- **`<StaticRender>`**: This is the more modern and efficient replacement for `<Static>`, designed to work better with the new rendering pipeline and avoid the pitfalls of the legacy implementation. This is the only static-style component supported by the new renderer. Use this instead of `<Static>` for new developments.

### Running the Linter
The `xo` linter occasionally struggles with memory limits in this repository due to its size and complexity. If you see `FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory` when running the linter, use the `NODE_OPTIONS` environment variable to increase the memory limit to 8GB:

```bash
NODE_OPTIONS="--max-old-space-size=8192" npx xo
```
13 changes: 13 additions & 0 deletions examples/box_slices/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Box Border Slices Example

This example demonstrates how all 16 permutations of `borderTop`, `borderBottom`, `borderLeft`, and `borderRight` are rendered when a `Box` has `borderStyle="single"`.

## Usage

You can run the example interactively:

```bash
npm run example examples/box_slices/index.ts
```

Press `q` or `Escape` to quit the application.
57 changes: 57 additions & 0 deletions examples/box_slices/box-slices.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import React from 'react';
import {Box, Text, useApp, useInput} from '../../src/index.js';

export default function BoxSlices() {
const {exit} = useApp();

useInput((input, key) => {
if (input === 'q' || key.escape) {
exit();
}
});

const booleans = [true, false];
const permutations = [];

for (const top of booleans) {
for (const bottom of booleans) {
for (const left of booleans) {
for (const right of booleans) {
permutations.push({top, bottom, left, right});
}
}
}
}

return (
<Box flexDirection="column" padding={1} rowGap={1}>
<Text>Box Border Slices Permutations</Text>
<Box flexDirection="row" flexWrap="wrap" rowGap={1} columnGap={1}>
{permutations.map(perm => {
const key = `top-${String(perm.top)}-bot-${String(perm.bottom)}-left-${String(perm.left)}-right-${String(perm.right)}`;
return (
<Box
key={key}
borderStyle="single"
borderTop={perm.top}
borderBottom={perm.bottom}
borderLeft={perm.left}
borderRight={perm.right}
flexDirection="column"
width={16}
>
<Text>Top: {perm.top ? 'T' : 'F'}</Text>
<Text>Bot: {perm.bottom ? 'T' : 'F'}</Text>
</Box>
);
})}
</Box>
</Box>
);
}
20 changes: 20 additions & 0 deletions examples/box_slices/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import React from 'react';
import {render} from '../../src/index.js';
import BoxSlices from './box-slices.js';

export const instance = render(React.createElement(BoxSlices), {
renderProcess: true,
terminalBuffer: true,
alternateBuffer: false,
standardReactLayoutTiming: true,
incrementalRendering: true,
animatedScroll: true,
backbufferUpdateDelay: 100,
maxFps: 10_000,
});
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"dev": "tsc --watch",
"build": "tsc",
"prepare": "npm run build",
"test": "tsc --noEmit && xo && FORCE_COLOR=true ava",
"test": "tsc --noEmit && NODE_OPTIONS=\"--max-old-space-size=8192\" xo && FORCE_COLOR=true ava",
"example": "NODE_NO_WARNINGS=1 node --loader ts-node/esm",
"benchmark": "NODE_NO_WARNINGS=1 node --loader ts-node/esm"
},
Expand Down Expand Up @@ -93,6 +93,7 @@
"node-pty": "^1.0.0",
"p-queue": "^8.0.0",
"prettier": "^3.3.3",
"puppeteer": "^24.40.0",
"react": "^19.1.0",
"react-devtools-core": "^6.1.2",
"sinon": "^20.0.0",
Expand Down
3 changes: 3 additions & 0 deletions src/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export type StickyHeader = {
nodeId: number;
lines: StyledChar[][]; // Natural (scrolling) version
stuckLines?: StyledChar[][]; // Alternate (sticky) version
borders?: any[];
stuckBorders?: any[];
styledOutput: StyledChar[][]; // Legacy property
x: number; // Stuck X position relative to region
y: number; // Stuck Y position relative to region
Expand All @@ -41,6 +43,7 @@ export type StickyHeader = {
endRow: number; // Content-relative end row
scrollContainerId: number | string;
isStuckOnly: boolean; // If true, natural 'lines' are already in background content
isStuck?: boolean;

// Metadata for cached headers
relativeX?: number; // Relative to StaticRender
Expand Down
60 changes: 55 additions & 5 deletions src/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {type CursorPosition} from './log-update.js';
import {type StickyHeader, type DOMElement} from './dom.js';
import {calculateScrollbarLayout} from './measure-element.js';
import {renderScrollbar} from './render-scrollbar.js';
import {drawRegionBorders} from './render-border.js';

/**
"Virtual" output class
Expand Down Expand Up @@ -125,6 +126,7 @@ export type Region = {
opaque?: boolean;
borderTop?: number;
borderBottom?: number;
borders?: any[];

stickyHeaders: StickyHeader[];
cachedStickyHeaders?: StickyHeader[];
Expand Down Expand Up @@ -169,6 +171,7 @@ export type RegionUpdate = {
opaque?: boolean;
borderTop?: number;
borderBottom?: number;
borders?: any[];
stickyHeaders?: StickyHeader[];
lines?: {
updates: Array<{
Expand Down Expand Up @@ -202,6 +205,7 @@ export const regionLayoutProperties = [
'opaque',
'borderTop',
'borderBottom',
'borders',
] as const;

export type RegionLayoutProps = {
Expand Down Expand Up @@ -229,7 +233,7 @@ export type RegionLayoutProps = {

export function copyRegionProperty<
K extends (typeof regionLayoutProperties)[number],
>(target: RegionLayoutProps, source: RegionLayoutProps, key: K) {
>(target: any, source: any, key: K) {
const value = source[key];
if (value !== undefined) {
target[key] = value;
Expand Down Expand Up @@ -265,6 +269,7 @@ export default class Output {
children: [],
node,
selectableSpans: [],
borders: [],
};

this.initLines(this.root, width, height);
Expand Down Expand Up @@ -347,6 +352,11 @@ export default class Output {
const bufferWidth = scrollState?.scrollWidth ?? width;
const bufferHeight = scrollState?.scrollHeight ?? height;

const activeRegion = this.getActiveRegion();
const inheritedOverflowToBackbuffer = isScrollable
? overflowToBackbuffer
: (overflowToBackbuffer ?? activeRegion.overflowToBackbuffer);

const region: Region = {
id,
x,
Expand All @@ -363,7 +373,7 @@ export default class Output {
scrollHeight: scrollState?.scrollHeight,
scrollWidth: scrollState?.scrollWidth,
scrollbarVisible,
overflowToBackbuffer,
overflowToBackbuffer: inheritedOverflowToBackbuffer,
marginRight,
marginBottom,
scrollbarThumbColor,
Expand Down Expand Up @@ -515,13 +525,28 @@ export default class Output {

addRegionTree(region: Region, x: number, y: number) {
const activeRegion = this.getActiveRegion();
const clonedRegion = this.cloneRegion(region, x, y);
const clonedRegion = this.cloneRegion(
region,
x,
y,
activeRegion.overflowToBackbuffer,
);
activeRegion.children.push(clonedRegion);
}

private cloneRegion(region: Region, x: number, y: number): Region {
private cloneRegion(
region: Region,
x: number,
y: number,
inheritedOverflowToBackbuffer?: boolean,
): Region {
const overflowToBackbuffer = region.isScrollable
? region.overflowToBackbuffer
: (region.overflowToBackbuffer ?? inheritedOverflowToBackbuffer);

const cloned: Region = {
...region,
overflowToBackbuffer,
x: region.x + x,
y: region.y + y,
lines: region.lines.map(line =>
Expand All @@ -533,7 +558,9 @@ export default class Output {
x: header.x,
y: header.y,
})),
children: region.children.map(child => this.cloneRegion(child, 0, 0)),
children: region.children.map(child =>
this.cloneRegion(child, 0, 0, overflowToBackbuffer),
),
};

return cloned;
Expand Down Expand Up @@ -809,6 +836,7 @@ export function flattenRegion(
context?: {cursorPosition?: {row: number; col: number}};
skipScrollbars?: boolean;
skipStickyHeaders?: boolean;
skipBorders?: boolean;
},
): StyledChar[][] {
const {width, height} = root;
Expand Down Expand Up @@ -855,6 +883,7 @@ function composeRegion(
context?: {cursorPosition?: {row: number; col: number}};
skipScrollbars?: boolean;
skipStickyHeaders?: boolean;
skipBorders?: boolean;
},
) {
const {
Expand Down Expand Up @@ -931,10 +960,31 @@ function composeRegion(
);
}

if (!options?.skipBorders && region.borders) {
for (const border of region.borders) {
drawRegionBorders(border, absX, absY, myClip, (cx, cy, char) => {
if (cy >= 0 && cy < targetLines.length && cx >= 0 && cx < targetLines[cy]!.length) {
targetLines[cy]![cx] = char;
}
});
}
}

if (!options?.skipStickyHeaders) {
for (const header of stickyHeaders) {
const headerY = header.y + absY; // Absolute Y
const headerH = header.styledOutput.length;

const bordersToRender = header.stuckLines ? header.stuckBorders : header.borders;
if (bordersToRender) {
for (const border of bordersToRender) {
drawRegionBorders(border, absX + header.x, Math.round(headerY), clip ?? {x: 0, y: 0, w: targetLines[0]?.length || 0, h: targetLines.length}, (cx, cy, char) => {
if (cy >= 0 && cy < targetLines.length && cx >= 0 && cx < targetLines[cy]!.length) {
targetLines[cy]![cx] = char;
}
});
}
}

for (let i = 0; i < headerH; i++) {
const sy = headerY + i;
Expand Down
8 changes: 8 additions & 0 deletions src/reconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@ export default createReconciler<

if (key === 'internalStickyAlternate') {
node.internalStickyAlternate = value as boolean;
if (value && node.yogaNode) {
node.yogaNode.setPosition(Yoga.EDGE_TOP, 0);
node.yogaNode.setPosition(Yoga.EDGE_LEFT, 0);
}
continue;
}

Expand Down Expand Up @@ -318,6 +322,10 @@ export default createReconciler<

if (key === 'internalStickyAlternate') {
node.internalStickyAlternate = Boolean(value);
if (value && node.yogaNode) {
node.yogaNode.setPosition(Yoga.EDGE_TOP, 0);
node.yogaNode.setPosition(Yoga.EDGE_LEFT, 0);
}
continue;
}

Expand Down
Loading