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
51 changes: 51 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -2962,6 +2962,57 @@ const Example = () => {
render(<Example />);
```

#### getFrameController(stdout)

Returns the `FrameController` of the Ink instance rendering to `stdout`, or `undefined` when there is none.

The frame controller is a bridge for applications that own their input and implement text selection themselves, for example alternate-screen apps that handle mouse events. The app subscribes to composited frames to read what is on screen, and pushes a selection, which Ink highlights before serialization.

```jsx
import {render, Text, getFrameController} from 'ink';

const {unmount} = render(<Text>Hello World</Text>);

const controller = getFrameController(process.stdout);

const unsubscribe = controller.subscribe(frame => {
// frame.cells[y][x] is the cell at column x of row y
});

controller.setSelection({sx: 0, sy: 0, ex: 4, ey: 0});
```

Frames are only generated while at least one subscriber is registered, so apps that never use the frame controller pay no overhead. Listeners are notified outside of the render pass and notifications are coalesced, so a listener can safely call `setSelection()` without re-entering rendering or observing frames out of order.

##### stdout

Type: `NodeJS.WriteStream`

The output stream an Ink instance renders to, usually `process.stdout`.

##### controller.getFrame()

Returns the latest composited frame, or `undefined` when no frame has been published yet.

A frame is a read-only grid of cells: `frame.cells[y][x]` is the cell at column `x` of row `y`, with `(0, 0)` at the top-left of Ink's output region and `frame.width`/`frame.height` the grid dimensions. Each cell exposes `value` (the character, or `''` for the trailing half of a wide character) and `fullWidth` (`true` for wide characters such as CJK, which occupy two cells). To extract the text of a region, concatenate cell values and skip the ones with an empty `value`.

> [!NOTE]
> Cell coordinates are positions in Ink's output region, not terminal viewport coordinates. To map a mouse event to a cell, convert the event coordinates by the viewport position of the output region, like with [`measureElement()`](#measureelementref).

##### controller.getSelection()

Returns the current selection in reading order, or `undefined` when there is none.

##### controller.setSelection(selection)

Highlights `selection` and schedules a repaint through Ink's regular render throttle. Setting an identical selection is a no-op, and passing `undefined` clears the highlight.

Selections are stored in reading order: `(sx, sy)` is the first selected cell and `(ex, ey)` the last one. Reverse selections (right-to-left or bottom-to-top drags) are normalized, so they select the same region as forward drags. The region covers whole rows between the first and last row, and is partial on the first and last row.

##### controller.subscribe(listener)

Subscribes to composited frames. `listener` receives the frame after each render. Subscribing schedules a render, so the listener receives the current frame even when nothing else triggers one. Returns an unsubscribe function.

## Testing

Ink components are simple to test with [ink-testing-library](https://github.com/vadimdemedes/ink-testing-library).
Expand Down
201 changes: 201 additions & 0 deletions src/frame-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import instances from './instances.js';

/**
A selection region in the composited frame. Coordinates are zero-based cell
positions and are always stored in reading order: `(sx, sy)` is the
first selected cell and `(ex, ey)` the last one, so reverse (right-to-left or
bottom-to-top) drags select the same region as forward drags.
*/
export type ScreenSelection = {
readonly sx: number;
readonly sy: number;
readonly ex: number;
readonly ey: number;
};

/**
A single cell of the composited frame. Wide characters (e.g. CJK) occupy two
cells: the leading cell has `fullWidth` set to `true` and carries the
character, while the trailing placeholder cell has an empty `value`. Skip
cells with an empty `value` when extracting text.
*/
export type FrameCell = {
readonly value: string;
readonly fullWidth: boolean;
};

/**
A read-only snapshot of the composited frame. `cells[y][x]` is the cell at
column `x` of row `y`, with `(0, 0)` at the top-left of Ink's output region.
*/
export type ReadonlyFrame = {
readonly width: number;
readonly height: number;
readonly cells: ReadonlyArray<readonly FrameCell[]>;
};

/**
Bridge between an application that owns its input (for example an
alternate-screen app handling mouse events itself) and the renderer: the app
subscribes to composited frames and pushes a selection, which Ink highlights
before serialization.

Frames are only generated while at least one subscriber is registered, so
apps that never subscribe pay no overhead. Listeners are notified outside of
the render pass and notifications are coalesced, so a listener cannot
re-enter rendering or observe frames out of order.
*/
export type FrameController = {
/**
Returns the latest composited frame, or `undefined` when no frame has been
published yet. Frames are only generated while subscribers are registered.
*/
getFrame(): ReadonlyFrame | undefined;

/**
Returns the current selection in reading order, or `undefined` when there
is none.
*/
getSelection(): ScreenSelection | undefined;

/**
Highlights the given selection region (or clears it with `undefined`) and
schedules a repaint through Ink's regular render throttle. Reverse
selections are normalized to reading order. Setting an identical selection
is a no-op.
*/
setSelection(selection: ScreenSelection | undefined): void;

/**
Subscribes to composited frames. Schedules a render so the listener
receives the current frame even when nothing else triggers one. Returns an
unsubscribe function.
*/
subscribe(listener: (frame: ReadonlyFrame) => void): () => void;
};

type FrameListener = (frame: ReadonlyFrame) => void;

export type InternalFrameController = FrameController & {
/**
Whether frames should be generated on the next render.
*/
hasSubscribers(): boolean;

/**
Publishes the composited cells of the frame that was just rendered.
*/
publishFrame(cells: ReadonlyArray<readonly FrameCell[]>): void;
};

// Normalizes the selection to reading order so reverse drags select the same
// region as forward drags.
const normalizeSelection = (selection: ScreenSelection): ScreenSelection => {
let {sx, sy, ex, ey} = selection;

if (sy > ey || (sy === ey && sx > ex)) {
[sx, ex] = [ex, sx];
[sy, ey] = [ey, sy];
}

return {sx, sy, ex, ey};
};

const sameSelection = (
a: ScreenSelection | undefined,
b: ScreenSelection | undefined,
): boolean => {
if (a === b) {
return true;
}

if (!a || !b) {
return false;
}

return a.sx === b.sx && a.sy === b.sy && a.ex === b.ex && a.ey === b.ey;
};

export const createFrameController = (
requestRender: () => void,
): InternalFrameController => {
let currentSelection: ScreenSelection | undefined;
let lastFrame: ReadonlyFrame | undefined;
const listeners = new Set<FrameListener>();
let notificationScheduled = false;

// Runs in a microtask, after the render that published the frame has fully
// completed. A listener calling setSelection() from here schedules a new
// render instead of re-entering the one in flight.
const notifyListeners = () => {
notificationScheduled = false;

const frame = lastFrame;

if (!frame) {
return;
}

// Iterating the live set is safe here: listeners removed during
// notification are skipped, and setSelection() calls from a listener
// only schedule a new render.
for (const listener of listeners) {
listener(frame);
}
};

return {
getFrame: () => lastFrame,
getSelection: () => currentSelection,
setSelection(selection) {
const normalized = selection ? normalizeSelection(selection) : undefined;

if (sameSelection(currentSelection, normalized)) {
return;
}

currentSelection = normalized;
requestRender();
},
subscribe(listener) {
listeners.add(listener);

// Deliver the current frame even if nothing else triggers a render.
requestRender();

return () => {
listeners.delete(listener);
};
},
hasSubscribers: () => listeners.size > 0,
publishFrame(cells) {
let width = 0;

for (const row of cells) {
width = Math.max(width, row.length);
}

const frame: ReadonlyFrame = {width, height: cells.length, cells};

for (const row of cells) {
Object.freeze(row);
}

Object.freeze(frame);
lastFrame = frame;

if (listeners.size > 0 && !notificationScheduled) {
notificationScheduled = true;
queueMicrotask(notifyListeners);
}
},
};
};

/**
Returns the frame controller of the Ink instance rendering to `stdout`, or
`undefined` when there is none.
*/
export const getFrameController = (
stdout: NodeJS.WriteStream,
): FrameController | undefined => instances.get(stdout)?.frameController;
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,10 @@ export type {ElementMetrics} from './measure-element.js';
export type {DOMElement} from './dom.js';
export {kittyFlags, kittyModifiers} from './kitty-keyboard.js';
export type {KittyKeyboardOptions, KittyFlagName} from './kitty-keyboard.js';
export {getFrameController} from './frame-controller.js';
export type {
FrameController,
ReadonlyFrame,
FrameCell,
ScreenSelection,
} from './frame-controller.js';
36 changes: 35 additions & 1 deletion src/ink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import {hideCursorEscape, showCursorEscape} from './cursor-helpers.js';
import logUpdate, {type LogUpdate, type CursorPosition} from './log-update.js';
import {bsu, esu, shouldSynchronize} from './write-synchronized.js';
import instances from './instances.js';
import {
createFrameController,
type InternalFrameController,
} from './frame-controller.js';
import App from './components/App.js';
import {type TerminalSuspension} from './components/AppContext.js';
import {accessibilityContext as AccessibilityContext} from './components/AccessibilityContext.js';
Expand Down Expand Up @@ -293,6 +297,11 @@ export default class Ink {
*/
readonly isConcurrent: boolean;

/**
Bridge for application-owned text selection. See `getFrameController`.
*/
readonly frameController: InternalFrameController;

private readonly options: Options;
private readonly log: LogUpdate;
private cursorPosition: CursorPosition | undefined;
Expand Down Expand Up @@ -378,6 +387,15 @@ export default class Ink {
}

this.rootNode.onImmediateRender = this.onRender;

// Bridge for application-owned text selection: apps subscribe to composited
// frames and push a selection, which is highlighted before serialization
// (see onRender). Repaints scheduled by the controller go through the same
// (throttled) render path as regular updates.
this.frameController = createFrameController(() => {
this.rootNode.onRender?.();
});

this.rootNode.onStaticChange = this.handleStaticChange;
this.log = logUpdate.create(options.stdout, {
incremental: options.incrementalRendering,
Expand Down Expand Up @@ -567,11 +585,21 @@ export default class Ink {
}

const startTime = performance.now();
const {output, outputHeight, staticOutput} = render(
const {output, outputHeight, staticOutput, cells} = render(
this.rootNode,
this.isScreenReaderEnabled,
{
selection: this.frameController.getSelection(),
// Cells are only projected when a frame consumer subscribed, so
// apps that never use the frame controller pay no overhead.
captureCells: this.frameController.hasSubscribers(),
},
);

if (cells) {
this.frameController.publishFrame(cells);
}

this.options.onRender?.({renderTime: performance.now() - startTime});

// If <Static> output isn't empty, it means new children have been added to it
Expand Down Expand Up @@ -1112,6 +1140,12 @@ export default class Ink {
outputHeight: number,
staticOutput: string,
): void {
// Re-assert the app's cursor intent on every interactive render: log-update
// only applies a cursor position set since the previous render, so without
// this a repaint triggered by setSelection() would leave the cursor at the
// end of the output instead of where useCursor() placed it.
this.log.setCursorPosition(this.cursorPosition);

const hasStaticOutput = staticOutput !== '';
const isTty = this.options.stdout.isTTY;

Expand Down
Loading