Skip to content
Closed
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
28 changes: 28 additions & 0 deletions src/components/Text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,26 @@ export type Props = {
*/
readonly wrap?: Styles['textWrap'];

/**
Whether the text is selectable. Defaults to `true`.
*/
readonly selectable?: boolean;

/**
Selection flow key. Text nodes sharing the same flow key are treated as a single selection unit.
*/
readonly selectionFlow?: unknown;

/**
Insert a boundary after this text node. `'soft'` joins with surrounding text, `'hard'` starts a new line.
*/
readonly selectionBreakAfter?: 'soft' | 'hard';

/**
Custom joiner string used when `selectionBreakAfter` is `'soft'`.
*/
readonly selectionJoiner?: string;

readonly children?: ReactNode;
};

Expand All @@ -78,6 +98,10 @@ export default function Text({
strikethrough = false,
inverse = false,
wrap = 'wrap',
selectable = true,
selectionFlow,
selectionBreakAfter,
selectionJoiner = '',
children,
'aria-label': ariaLabel,
'aria-hidden': ariaHidden = false,
Expand Down Expand Up @@ -138,6 +162,10 @@ export default function Text({
<ink-text
style={{flexGrow: 0, flexShrink: 1, flexDirection: 'row', textWrap: wrap}}
internal_transform={transform}
selectable={selectable}
selectionFlow={selectionFlow}
selectionBreakAfter={selectionBreakAfter}
selectionJoiner={selectionJoiner}
>
{childrenOrAriaLabel}
</ink-text>
Expand Down
96 changes: 96 additions & 0 deletions src/frame-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import instances from './instances.js';

export type ScreenSelection = {
sx: number;
sy: number;
ex: number;
ey: number;
};

export type FrameCell = {
type: 'char';
value: string;
fullWidth: boolean;
styles: unknown[];
selectable: boolean;
flowId: number | undefined;
};

export type FrameBoundary = {
kind: 'soft' | 'hard';
joiner: string;
selectable: boolean;
flowId: number;
};

export type ReadonlyFrame = {
width: number;
height: number;
cells: ReadonlyArray<readonly FrameCell[]>;
boundaries: ReadonlyArray<ReadonlyArray<FrameBoundary | undefined>>;
};

export type FrameController = {
getFrame(): ReadonlyFrame | undefined;
getSelection(): ScreenSelection | undefined;
setSelection(selection: ScreenSelection | undefined): void;
subscribe(listener: (frame: ReadonlyFrame) => void): () => void;
publishFrame(frame: ReadonlyFrame): void;
};

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;
};

// Creates the bidirectional bridge between the application and the renderer:
// the app reads the latest composited frame (getFrame) and pushes a selection
// (setSelection) that the renderer highlights before serialization. setSelection
// deduplicates and schedules exactly one repaint through Ink's own throttle via
// the requestRender callback, so it never mutates already-committed frame state.
export const createFrameController = (
requestRender: () => void,
): FrameController => {
let currentSelection: ScreenSelection | undefined;
let lastFrame: ReadonlyFrame | undefined;
const listeners = new Set<(frame: ReadonlyFrame) => void>();

return {
getFrame: () => lastFrame,
getSelection: () => currentSelection,
setSelection(selection: ScreenSelection | undefined) {
if (sameSelection(currentSelection, selection)) {
return;
}

currentSelection = selection;
requestRender();
},
subscribe(listener: (frame: ReadonlyFrame) => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
publishFrame(frame: ReadonlyFrame) {
lastFrame = frame;
for (const listener of listeners) {
listener(frame);
}
},
};
};

export const getFrameController = (
stdout: NodeJS.WriteStream,
): FrameController | undefined => instances.get(stdout)?.frameController;
4 changes: 4 additions & 0 deletions src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,9 @@ declare namespace Ink {
// eslint-disable-next-line @typescript-eslint/naming-convention
internal_transform?: (children: string, index: number) => string;
internal_accessibility?: DOMElement['internal_accessibility'];
selectable?: boolean;
selectionFlow?: unknown;
selectionBreakAfter?: 'soft' | 'hard';
selectionJoiner?: string;
};
}
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,11 @@ 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,
FrameBoundary,
ScreenSelection,
} from './frame-controller.js';
34 changes: 33 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 FrameController,
} 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,8 @@ export default class Ink {
*/
readonly isConcurrent: boolean;

readonly frameController: FrameController;

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

this.rootNode.onImmediateRender = this.onRender;

// Bridge for application-level text selection: setSelection schedules a
// throttled repaint via the same path as a normal render, and each frame
// publishes its composited cells (see onRender).
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 +581,27 @@ export default class Ink {
}

const startTime = performance.now();
const {output, outputHeight, staticOutput} = render(
const selection = this.frameController.getSelection();
const {output, outputHeight, staticOutput, cells, boundaries} = render(
this.rootNode,
this.isScreenReaderEnabled,
selection,
);

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

this.frameController.publishFrame({
width,
height: cells.length,
cells,
boundaries: boundaries ?? [],
});
}

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 @@ -1045,6 +1075,7 @@ export default class Ink {
this.options.stdout,
ansiEscapes.enterAlternativeScreen,
);
this.writeBestEffort(this.options.stdout, ansiEscapes.clearTerminal);
this.writeBestEffort(this.options.stdout, hideCursorEscape);
}
}
Expand Down Expand Up @@ -1112,6 +1143,7 @@ export default class Ink {
outputHeight: number,
staticOutput: string,
): void {
this.log.setCursorPosition(this.cursorPosition);
const hasStaticOutput = staticOutput !== '';
const isTty = this.options.stdout.isTTY;

Expand Down
Loading