type
WrapMode
export type WrapMode = "word" | "char" | "none";How a run of text breaks when it does not fit. OpenTUI's three modes.
API reference
A React renderer whose host is a terminal, following OpenTUI, for the Unified Toolchain for Flow.
Written from the source by uf doc when this site was built: the signature and the comment above each export, grouped by the specifier a program imports it from.
@uniflowed/tuitype
WrapModeexport type WrapMode = "word" | "char" | "none";How a run of text breaks when it does not fit. OpenTUI's three modes.
type
Rendererexport type Renderer = {
root: TuiNode,
capabilities: Capabilities,
width: number,
height: number,
/** The frame currently on the terminal, or `null` before the first draw. */
previous: Frame | null,
/** Global key handlers, in registration order, as OpenTUI orders them. */
keyHandlers: Array<KeyHandler>,
/** Called after every commit, so a driver knows to draw. */
onCommit: (() => void) | null,
/**
* The current size, as one object that is replaced rather than mutated.
*
* `useSyncExternalStore` requires a snapshot that is referentially stable
* between changes — it compares the value it is given with `Object.is` and
* re-renders forever if a fresh object comes back each time. Keeping the
* snapshot here, and replacing it only in `resize`, is what makes that
* true; a `getSnapshot` that returned `{ width, height }` would be the
* infinite-loop bug that hook's documentation warns about.
*/
size: { readonly width: number, readonly height: number },
/** Who to tell when the terminal is resized. */
sizeListeners: Set<() => void>,
/**
* Whether this renderer routes mouse reports.
*
* False by default, and the hit grid is not built when it is: a keyboard
* application should not pay a per-frame cost for a device it never reads.
* `terminal.js` sets it from `render`'s `mouse` option, which is also what
* decides whether the terminal is asked to report the mouse at all — the two
* must agree, or an application receives reports it has no grid to route.
*/
mouseEnabled: boolean,
/** Which node owned each cell of the last frame, or `null`. */
hits: HitGrid | null,
/** The node the pointer was last over, so `over`/`out` can be derived. */
hovered: TuiNode | null,
/** The node a left-button drag started on, while one is in progress. */
dragSource: TuiNode | null,
/** Whether that press has actually moved yet: a click is not a drag. */
dragging: boolean,
/**
* The reader's selection, or `null`.
*
* One per renderer, which is OpenTUI's rule and a terminal's: a frame has
* one way of showing that a cell is selected, so a second selection would
* have nowhere to be.
*/
selection: Selection | null,
/**
* Where the press that is building a selection landed, while it still is.
*
* Separate from `selection.anchor` because a press that has not moved yet
* has an anchor and no selection: a click is not a selection of one cell,
* it is a click. It is also separate from `dragSource`, which is a node —
* this is a cell, because that is what a selection is made of.
*/
selectionAnchor: SelectionPoint | null,
/** What OpenTUI calls a selection, and the four things a caller does with one. */
getSelection(): Selection | null,
hasSelection(): boolean,
clearSelection(): void,
getSelectedText(): string,
/**
* The terminal clipboard transport.
*
* It is installed by `terminal.js`, not by the reconciler. A renderer that
* lives in memory or behind a pipe has no terminal that can receive OSC 52,
* and the unsupported object says so explicitly instead of accepting text
* and dropping it.
*/
clipboard: Clipboard,
};Everything one mounted application owns.
Mutable, and owned by exactly one React root. The fields React writes (root) and the fields the terminal writes (width, height) are deliberately in the same object: a resize has to invalidate the previous frame, and putting the two in separate places is how a renderer ends up diffing an 80-column frame against a 120-column one.
type
Rootexport type Root = {
/** Render an element into this renderer, synchronously. */
render(node: React.Node): void,
/** Unmount it, running every effect cleanup. */
unmount(): void,
};A mounted React tree, and the two things a caller does with one.
type
SelectOptionexport type SelectOption = {
readonly name: string,
readonly description: string,
readonly value?: mixed,
};One entry in a Select or a TabSelect: OpenTUI's SelectOption.
type
EditWrapModeexport type EditWrapMode = "word" | "char" | "none";A Textarea's wrap modes, which are OpenTUI's.
@uniflowed/tui/capabilitytype
ColorLevelexport type ColorLevel = "none" | "ansi16" | "ansi256" | "truecolor";How much colour a stream can carry.
type
GlyphSetexport type GlyphSet = "unicode" | "ascii";Which glyph vocabulary is safe to print.
type
Ttyexport type Tty = "interactive" | "piped";Whether a human is looking at the stream.
type
ColorChoiceexport type ColorChoice = "auto" | "always" | "never";What a caller asked for, before the environment gets a say.
type
TerminalEnvexport type TerminalEnv = {
readonly NO_COLOR?: string,
readonly FORCE_COLOR?: string,
readonly CLICOLOR?: string,
readonly CLICOLOR_FORCE?: string,
readonly TERM?: string,
readonly COLORTERM?: string,
readonly COLUMNS?: string,
readonly LINES?: string,
readonly LC_ALL?: string,
readonly LC_CTYPE?: string,
readonly LANG?: string,
};The environment variables that influence terminal rendering.
type
Capabilitiesexport type Capabilities = {
readonly color: ColorLevel,
readonly glyphs: GlyphSet,
readonly tty: Tty,
};The resolved rendering capability of one stream.
function
detectCapabilitiesexport function detectCapabilities(choice: ColorChoice, tty: Tty, env: TerminalEnv): Capabilities { ... }Resolve capability from a choice, a stream classification, and an environment.
Precedence, highest first — this list is crates/uf_term/src/capability.rs's, and changing it here alone is how the CLI and the library start disagreeing:
1. an explicit "never" or "always" 2. NO_COLOR (any non-empty value) 3. FORCE_COLOR 4. CLICOLOR_FORCE 5. TERM=dumb 6. CLICOLOR=0 7. whether the stream is a terminal 8. COLORTERM / TERM
function
plainCapabilitiesexport function plainCapabilities(): Capabilities { ... }The most conservative capability there is.
What a redirected stream and a snapshot test both use: no escape sequences at all, ASCII glyphs, nobody watching.
variable
FALLBACK_COLUMNSexport const FALLBACK_COLUMNS: number = 80;How wide a terminal is assumed to be when nothing will say.
variable
FALLBACK_ROWSexport const FALLBACK_ROWS: number = 24;How tall a terminal is assumed to be when nothing will say.
type
TerminalSizeexport type TerminalSize = {
readonly columns: number,
readonly rows: number,
};How big the terminal is, in cells.
type
TerminalReportexport type TerminalReport = {
readonly columns?: number,
readonly rows?: number,
...
};What the stream said about itself, if anything.
process.stdout.columns is undefined on a stream that is not a terminal, and a stand-in a test wrote may not have the properties at all — so this is every field optional rather than a size, and the difference between "the terminal says 80" and "nothing said anything" is kept.
function
detectSizeexport function detectSize(env: TerminalEnv, reported: TerminalReport): TerminalSize { ... }Resolve how big the terminal is.
Precedence, highest first — this list is crates/uf_term/src/capability.rs's detect_size, chain for chain, and packages/tui/tui.test.js compares the two rather than believing this sentence:
1. COLUMNS and LINES, each on its own. POSIX makes them the override, and they are what a watch, a script or a CI wrapper sets when the stream itself cannot answer. 2. what the stream reported, which on Node is the terminal's own answer 3. 80 by 24
The two dimensions are resolved separately, because COLUMNS without LINES is the common shape: a wrapper that cares about width sets one of them.
type
BorderStyleexport type BorderStyle = "single" | "double" | "rounded" | "heavy";The border characters this terminal can print.
OpenTUI's four border styles, plus the ASCII fallback that is the whole reason this is a lookup rather than a constant. The ASCII set is not a different design, it is the same design drawn with the characters a TERM=dumb terminal will not replace with a question mark: every glyph below is one column wide in both vocabularies, so a box's geometry does not change when its characters do.
type
BorderGlyphsexport type BorderGlyphs = {
readonly topLeft: string,
readonly topRight: string,
readonly bottomLeft: string,
readonly bottomRight: string,
readonly top: string,
readonly right: string,
readonly bottom: string,
readonly left: string,
};Eight characters: the four corners, then top, right, bottom, left.
function
borderGlyphsexport function borderGlyphs(style: BorderStyle, glyphs: GlyphSet): BorderGlyphs { ... }The border glyphs for a style, downgraded to ASCII when they cannot be printed.
@uniflowed/tui/cellstype
Colorexport type Color = number;A colour, as a packed 0xRRGGBB, or INHERIT.
Colours are numbers rather than strings or objects because they sit in a typed array beside every cell, and because the comparison the diff performs a million times a second is fg[i] === fg[i].
variable
INHERITexport const INHERIT: Color = -1;"Whatever the terminal's default is."
Distinct from black: a reader with a light terminal theme and a renderer that resolved INHERIT to 0x000000 gets black text that stops being readable the moment the reader switches themes, and the terminal's own default is the only value that follows them.
variable
Attributesexport const Attributes = {
NONE: 0,
BOLD: 1,
DIM: 2,
ITALIC: 4,
UNDERLINE: 8,
BLINK: 16,
INVERSE: 32,
STRIKETHROUGH: 64,
};Text attributes, as a bit mask.
The bits are OpenTUI's TextAttributes, in its order, so a value that crosses between the two libraries means the same thing.
type
Styleexport type Style = {
/** Foreground colour, or `INHERIT`. */
readonly fg: Color,
/** Background colour, or `INHERIT`. */
readonly bg: Color,
/** A mask of `Attributes`. */
readonly attributes: number,
};How one cell is painted.
variable
PLAINexport const PLAIN: Style = { fg: INHERIT, bg: INHERIT, attributes: Attributes.NONE };The style of a cell nobody has painted.
function
parseColorexport function parseColor(value: string | number | void | null): Color { ... }Read a colour written as a name, a hex string, or a packed number.
Anything unrecognised resolves to INHERIT rather than raising. A colour is decoration: a typo in one should leave the interface readable in the terminal's own colours, not stop the program that was drawing it.
type
Frameexport type Frame = {
/** Columns. */
readonly width: number,
/** Rows. */
readonly height: number,
/** One grapheme cluster per cell, or `""` for a wide cluster's second cell. */
readonly chars: Array<string>,
/** Foreground per cell. */
readonly fg: Int32Array,
/** Background per cell. */
readonly bg: Int32Array,
/** Attribute mask per cell. */
readonly attributes: Uint8Array,
};One rendered frame.
Mutable on purpose. A frame is filled in by one painter pass and then read by one diff, and copying it to keep it immutable would double the allocation this representation exists to avoid. The rule that keeps that safe is that a frame belongs to exactly one owner at a time: the painter owns it until paint() returns, the renderer owns it afterwards and never writes again.
type
Rectexport type Rect = {
readonly x: number,
readonly y: number,
readonly width: number,
readonly height: number,
};A rectangle in frame coordinates; x/y are the top-left cell.
function
createFrameexport function createFrame(width: number, height: number): Frame { ... }A frame of blanks, width by height.
function
sameSizeexport function sameSize(a: Frame, b: Frame): boolean { ... }Whether two frames describe the same rectangle.
function
writeGraphemeexport function writeGrapheme(
frame: Frame,
x: number,
y: number,
cluster: string,
width: number,
style: Style,
clip: Rect,
): number { ... }Write one grapheme cluster at x, y, in style.
Returns the number of columns consumed, which is what a caller advances by: zero when the write fell outside the frame or outside clip, one or two otherwise. A two-column cluster that would straddle the right edge is written as a space instead of being split, because half of a wide character is not a character.
function
fillRectexport function fillRect(frame: Frame, area: Rect, style: Style, clip: Rect): void { ... }Fill a rectangle with style, leaving the characters in it blank.
Used for a box's background. It clears rather than preserving what is under it: a background is opaque, and a box drawn over another box's text has to hide that text or the terminal shows both.
function
intersectexport function intersect(a: Rect, b: Rect): Rect { ... }The intersection of two rectangles, empty when they do not overlap.
function
frameRowexport function frameRow(frame: Frame, y: number): string { ... }One row of a frame, as the text a reader would see.
Continuation cells contribute nothing, because their cluster was already emitted by the cell to their left. Trailing blanks are kept: a test that asserts on a row is asserting on a rectangle, and trimming would make "painted a space here" and "painted nothing here" indistinguishable.
function
frameTextexport function frameText(frame: Frame): string { ... }Every row of a frame, newline separated. Snapshots and toContain read this.
@uniflowed/tui/clipboardtype
Clipboardexport type Clipboard = {
/** Put text on the terminal clipboard. */
copy(text: string): boolean,
/** Whether this renderer has a clipboard transport at all. */
supported: boolean,
};What useClipboard() hands back.
variable
unsupportedClipboardexport const unsupportedClipboard: Clipboard = {
supported: false,
copy() {
return false;
},
};A clipboard for renderers that have nowhere to send OSC 52.
function
osc52Clipboardexport function osc52Clipboard(write: (chunk: string) => mixed): Clipboard { ... }A clipboard that writes OSC 52 to a terminal stream.
function
osc52export function osc52(text: string): string { ... }Encode text as the OSC 52 "copy to clipboard" sequence.
@uniflowed/tui/componentstype
ColorValueexport type ColorValue = string | number;A colour, as "#rrggbb", one of the sixteen names, or a packed number.
type
TitleAlignmentexport type TitleAlignment = "left" | "center" | "right";Where a border title sits along its edge.
type
BoxLayoutPropsexport type BoxLayoutProps = {
readonly flexDirection?: FlexDirection,
readonly flexWrap?: FlexWrap,
readonly justifyContent?: JustifyContent,
readonly alignItems?: AlignItems,
readonly alignContent?: AlignContent,
readonly alignSelf?: AlignSelf,
readonly flexGrow?: number,
readonly flexShrink?: number,
readonly flexBasis?: Dimension,
readonly width?: Dimension,
readonly height?: Dimension,
readonly minWidth?: Dimension,
readonly minHeight?: Dimension,
readonly maxWidth?: Dimension,
readonly maxHeight?: Dimension,
readonly padding?: number,
readonly paddingX?: number,
readonly paddingY?: number,
readonly paddingTop?: number,
readonly paddingRight?: number,
readonly paddingBottom?: number,
readonly paddingLeft?: number,
readonly margin?: Margin,
readonly marginTop?: Margin,
readonly marginRight?: Margin,
readonly marginBottom?: Margin,
readonly marginLeft?: Margin,
readonly gap?: number,
readonly rowGap?: number,
readonly columnGap?: number,
readonly overflow?: Overflow,
/** In its parent's flex line (`"relative"`, the default) or out of it. */
readonly position?: Position,
/**
* Offsets: where an absolutely positioned box sits against the inside of
* its parent's border, or how far a relative one is nudged from where its
* line put it. Cells or a percentage; negative is allowed.
*/
readonly top?: Dimension,
readonly right?: Dimension,
readonly bottom?: Dimension,
readonly left?: Dimension,
/**
* Which of two overlapping siblings is on top: the higher one, painted
* later. Ties, and siblings that give none, stack in tree order.
*/
readonly zIndex?: number,
};Everything that positions a node.
Accepted both as individual props and inside style, exactly as OpenTUI accepts them, because both spellings are in its documentation and a caller copying an example should not have to translate.
type
TextStylePropsexport type TextStyleProps = {
readonly fg?: ColorValue,
readonly bg?: ColorValue,
readonly bold?: boolean,
readonly dim?: boolean,
readonly italic?: boolean,
readonly underline?: boolean,
readonly blink?: boolean,
readonly inverse?: boolean,
readonly strikethrough?: boolean,
/**
* Whether a reader may select this text with the mouse.
*
* `true` unless something says otherwise, which is OpenTUI's default for
* text and a terminal's for everything. It is inherited the way a colour
* is, so `selectable={false}` on a `Box` covers everything inside it — which
* is how a status bar or a decorative frame stays out of a copy without
* every `Text` in it repeating the prop.
*/
readonly selectable?: boolean,
/**
* How a selection over this text looks.
*
* Both default to unset, and unset means inverse video: `SGR 7` exists on
* terminals with no colour at all, and it is what the terminal's own
* selection would have looked like. Naming either colour replaces that
* rather than adding to it.
*/
readonly selectionFg?: ColorValue,
readonly selectionBg?: ColorValue,
};Everything that paints a node's text. Inherited by nested Text.
type
MousePropsexport type MouseProps = {
/** Every mouse event, after the handler for its own type. */
readonly onMouse?: (event: MouseEvent) => void,
readonly onMouseDown?: (event: MouseEvent) => void,
readonly onMouseUp?: (event: MouseEvent) => void,
/** The pointer moved over this box with nothing held down. */
readonly onMouseMove?: (event: MouseEvent) => void,
/** The pointer moved with a button held, since it was pressed on this box. */
readonly onMouseDrag?: (event: MouseEvent) => void,
/** That drag ended, wherever the pointer had reached. */
readonly onMouseDragEnd?: (event: MouseEvent) => void,
/** A drag that began somewhere else ended here; `event.source` says where. */
readonly onMouseDrop?: (event: MouseEvent) => void,
/** The pointer entered this box, or a box inside it. */
readonly onMouseOver?: (event: MouseEvent) => void,
/** And left it. */
readonly onMouseOut?: (event: MouseEvent) => void,
/** The wheel turned; `event.scroll` says which way. */
readonly onMouseScroll?: (event: MouseEvent) => void,
};The mouse handlers a node can carry, under OpenTUI's names.
Every one of them receives events that started on this box *or on anything inside it*, because a mouse event bubbles: a panel can handle a click anywhere in it without every child forwarding one. event.target says which box the pointer is over and event.currentTarget says which box is handling it, exactly as they do in a browser, and event.stopPropagation() is how a child keeps one to itself.
A captured drag is the one case where those two are not on the same path: the event goes to the box the press landed on — event.source — while event.target keeps naming what the pointer has since moved over, because that is what a drag handler needs in order to know what it would drop onto.
Nothing arrives unless the application asked render for the mouse. A tree with handlers on it and mouse: false is not an error and is not silently broken either — it is an application that has not turned the device on, and testRender turns it on by default so that a test does not have to.
event.preventDefault() in an onMouseDown keeps the press from clearing the reader's selection and from starting a new one. That is the renderer's only default, so it is the only thing that method does; a box that means something else by a drag — a slider, a splitter, a canvas — is what it is for.
type
BoxPropsexport type BoxProps = {
...MouseProps,
...BoxLayoutProps,
...TextStyleProps,
/**
* A name for this box, carried by the mouse events it is involved in.
*
* `event.target`, `event.currentTarget` and `event.source` are ids rather
* than nodes, so a box that a drop has to be able to name needs one. Nothing
* else reads it, and two boxes with the same id are not an error — the
* events simply cannot tell them apart.
*/
readonly id?: string,
readonly style?: BoxLayoutProps,
readonly backgroundColor?: ColorValue,
readonly border?: boolean,
readonly borderStyle?: BorderStyle,
readonly borderColor?: ColorValue,
readonly title?: string,
readonly titleColor?: ColorValue,
readonly titleAlignment?: TitleAlignment,
readonly bottomTitle?: string,
readonly bottomTitleAlignment?: TitleAlignment,
/** Whether this box may hold focus at all. */
readonly focusable?: boolean,
/** Whether it holds focus now. Focus is state, as it is in OpenTUI. */
readonly focused?: boolean,
/** Keys delivered to this box while it holds focus. */
readonly onKeyDown?: (key: KeyEvent) => void,
};Everything a Box accepts beyond its children.
component
Boxexport component Box(children?: React.Node, ...props: BoxProps) { ... }A flex container that can draw a background, a border, and two titles.
type
TextPropsexport type TextProps = {
...BoxLayoutProps,
...TextStyleProps,
readonly id?: string,
readonly style?: BoxLayoutProps,
/** How lines break: at word boundaries, anywhere, or not at all. */
readonly wrap?: WrapMode,
};Everything a Text accepts beyond its children.
component
Textexport component Text(children?: React.Node, ...props: TextProps) { ... }A run of styled text.
Nest one inside another to change part of a line without repeating the style of the rest: the inner one inherits every attribute the outer one set and overrides only what it names.
type
ScrollBoxPropsexport type ScrollBoxProps = {
...BoxProps,
/**
* The first content row to show.
*
* Clamped by layout to the range the content actually has, which is what
* makes `Number.MAX_SAFE_INTEGER` mean "the bottom" — a log that has just
* grown by a line does not have to know how long it is to keep following
* it.
*/
readonly scrollTop?: number,
/** Whether to draw the bar. On by default; it costs a column. */
readonly scrollbar?: boolean,
/** The bar's colour. Falls back to `borderColor`. */
readonly scrollbarColor?: ColorValue,
};Everything a ScrollBox accepts beyond its children.
component
ScrollBoxexport component ScrollBox(
children?: React.Node,
scrollTop?: number = 0,
scrollbar?: boolean = true,
scrollbarColor?: ColorValue,
...props: BoxProps
) { ... }A window onto content taller than itself.
Give it a height — an explicit one, or flexGrow inside a parent that has one. A ScrollBox with neither is as tall as its content and scrolls nothing, which is flexbox behaving correctly and not what anybody meant; and between a header and a footer those two want flexShrink={0}, because a box asking for ten thousand rows shrinks whatever is allowed to shrink.
const [top, setTop] = useState<number>(Number.MAX_SAFE_INTEGER);
useKeyboard((key) => {
if (key.name === "up") setTop((row) => Math.max(0, row - 1));
if (key.name === "down") setTop((row) => row + 1);
});
return (
<ScrollBox height={10} scrollTop={top}>
{lines.map((line) => <Text key={line.id}>{line.text}</Text>)}
</ScrollBox>
);# Why the offset is the caller's and the keys are not bound
OpenTUI's rule for focus is that it is a prop rather than something the library moves for you, and scrolling is the same question one level down: what an arrow key should do inside a scrolling region is the application's business — a log follows its tail, a file viewer does not, and a list moves a selection and lets the box follow *that*. A component that owned the offset would also have to own "how far is a page", which is the viewport's height, which it does not know until after layout has run. Clamping in layout is what lets the caller ask for the bottom without knowing where the bottom is.
# The wheel is an event, not a behaviour
A wheel over this box arrives as onMouseScroll, and moving the offset is still the caller's — the same rule as the keys, for the same reason. Three lines is the whole of it:
<ScrollBox
height={10}
scrollTop={top}
onMouseScroll={(event) => {
setTop((row) => Math.max(0, row + (event.scroll?.direction === "up" ? -3 : 3)));
}}
/>Three rows a notch is this example's choice, not this component's: how far a notch goes is a question about the content — a log, a form, a picture — and a component that answered it would be answering it for all three.
There is still no horizontal scrolling: a terminal column is not a pixel, and content wider than the window is nearly always content that should have wrapped.
# What it costs
The window, and not the content. Moving the offset over a hundred thousand rows measures none of them, lays out and paints the ones on the screen, and never visits the rest; appending a line to that log measures the line. The first frame is the exception and has to be: the height of the content is what Number.MAX_SAFE_INTEGER is clamped against, so every row is asked its height once, and after that the answer is kept until something under the row changes.
That is a property of layout.js rather than of this component, which is why this is a component at all — a caller cannot decide which of their children to render, because which ones are visible is not known until after layout has run.
function
useRendererexport function useRenderer(): Renderer { ... }The renderer this tree is mounted in.
Raises rather than returning null when there is none, because every way to reach this hook goes through a mounted root and a null here means the component is being rendered by something else — react-dom, say, which will then fail much further away with a message about uf-box not being a valid HTML element.
function
useKeyboardexport function useKeyboard(handler: (key: KeyEvent) => void): void { ... }Handle keys before the focused node sees them.
Registered in mount order and removed on cleanup, so a handler belonging to a component that has unmounted cannot receive a key — the leak that makes an application respond to a shortcut belonging to a screen it has left.
The handler is kept in a ref and the subscription depends on nothing, which is deliberate: a caller who writes useKeyboard((key) => …) with an inline arrow would otherwise re-subscribe on every render, and the order handlers run in — which OpenTUI specifies as registration order — would silently become "whichever component rendered last".
function
useTerminalSizeexport function useTerminalSize(): { readonly width: number, readonly height: number } { ... }The terminal's current size, re-rendering the caller when it changes.
function
useClipboardexport function useClipboard(): Clipboard { ... }The terminal clipboard.
Interactive terminals get OSC 52: copy(text) writes the escape sequence that asks the terminal emulator to put text on the system clipboard. Redirected output and in-memory renders report supported: false and return false, because a log file cannot carry an operating-system clipboard side effect. OSC 52 has no acknowledgement, so a true return means the request was written, not that the terminal policy accepted it.
type
InputPropsexport type InputProps = {
...BoxLayoutProps,
/** The current text, when the caller controls it. */
readonly value?: string,
/** The initial text, when it does not. */
readonly defaultValue?: string,
/** What to show when the value is empty. */
readonly placeholder?: string,
/** Whether this input has focus. */
readonly focused?: boolean,
/** Called with the new text on every edit. */
readonly onInput?: (value: string) => void,
/** Called with the text when Enter is pressed. */
readonly onSubmit?: (value: string) => void,
readonly fg?: ColorValue,
readonly bg?: ColorValue,
readonly placeholderColor?: ColorValue,
};Everything an Input accepts.
component
Inputexport component Input(
value?: string,
defaultValue?: string = "",
placeholder?: string = "",
focused?: boolean = false,
onInput?: (value: string) => void,
onSubmit?: (value: string) => void,
fg?: ColorValue,
bg?: ColorValue,
placeholderColor?: ColorValue = "gray",
...layout: BoxLayoutProps
) { ... }One line of text a reader types into.
Controlled when value is given and uncontrolled otherwise, which is React's convention and the one a caller expects. The cursor is always this component's own state: it is a property of the editing session and not of the value, and a controlled input whose parent re-sends the same string must not have its cursor jump to the end — the single most common bug in hand-written terminal inputs.
The cursor is drawn as an inverse-video cell rather than by moving the terminal's real cursor. A real cursor is one per terminal and this renderer has no idea whether the application wants it here, over a list selection, or hidden; an inverse cell is a property of the frame, so it composes.
type
SelectColorPropsexport type SelectColorProps = {
/** Behind the whole list. Unset is the terminal's own background. */
readonly backgroundColor?: ColorValue,
/** Every name but the selected one. Unset is the terminal's own colour. */
readonly textColor?: ColorValue,
/** `backgroundColor` while the select has focus. */
readonly focusedBackgroundColor?: ColorValue,
/** `textColor` while the select has focus. */
readonly focusedTextColor?: ColorValue,
/** Behind the selected item. OpenTUI's `#334455` by default. */
readonly selectedBackgroundColor?: ColorValue,
/** The selected item's name. OpenTUI's `#FFFF00` by default. */
readonly selectedTextColor?: ColorValue,
/** The selected item's description. OpenTUI's `#CCCCCC` by default. */
readonly selectedDescriptionColor?: ColorValue,
};The colours both selects accept, under OpenTUI's names.
type
SelectPropsexport type SelectProps = {
...BoxLayoutProps,
...SelectColorProps,
readonly id?: string,
/** The items, in order. OpenTUI's `SelectOption`: a name, a description, a value. */
readonly options?: $ReadOnlyArray<SelectOption>,
/** The item selected to begin with, and whenever this prop changes. */
readonly selectedIndex?: number,
/** Whether this select has focus, and so receives the keys. */
readonly focused?: boolean,
/** The selection moved: up, down, or by `fastScrollStep` with Shift. */
readonly onChange?: (index: number, option: SelectOption | null) => void,
/** Enter was pressed on an item. */
readonly onSelect?: (index: number, option: SelectOption | null) => void,
/** Whether moving past either end comes round to the other. Off by default. */
readonly wrapSelection?: boolean,
/** Whether each item has its description under it. On by default. */
readonly showDescription?: boolean,
/** Whether the selected item has `▶ ` in front of it. On by default. */
readonly showSelectionIndicator?: boolean,
/** Whether a `█` down the right edge says where in the list the window is. */
readonly showScrollIndicator?: boolean,
/** Blank rows after each item. */
readonly itemSpacing?: number,
/** How many items Shift+Up and Shift+Down move. Five by default. */
readonly fastScrollStep?: number,
/** Every description but the selected one's. OpenTUI's `#888888` by default. */
readonly descriptionColor?: ColorValue,
};Everything a Select accepts.
component
Selectexport component Select(...props: SelectProps) { ... }A vertical list a reader moves through and chooses from.
OpenTUI's select, with its keys: Up or k and Down or j move one item, Shift+Up and Shift+Down move fastScrollStep, and Enter chooses. Moving calls onChange(index, option) and choosing calls onSelect(index, option) — the two events OpenTUI's React binding exposes, under the same names and with the same arguments.
<Select
focused={true}
height={6}
options={[
{ name: "build", description: "Compile the project" },
{ name: "test", description: "Run the suite" },
]}
onSelect={(index, option) => run(option?.name)}
/>Give it a height to make it scroll. The selected item then stays in the middle of the rows there are, and the window stops at either end of the list, which is OpenTUI's rule. Without one it is as tall as all its items — a box here is as tall as its content, where OpenTUI's is as tall as its style and no taller.
# What is not here
font, which draws each name in one of OpenTUI's ASCII-art fonts: this package has no fonts, and AsciiFont is still ubugeeei-prod/uf#314. keyBindings and keyAliasMap, which rebind the keys: the keys above are the only ones, and a caller who wants others binds them with useKeyboard and moves selectedIndex. And the default colours for text that is *not* selected: OpenTUI draws it white, on #1a1a1a when focused, which is unreadable on a light terminal, so unset here means the terminal's own colours. The selected item's colours are OpenTUI's, because "selected" has to be visible whatever the theme.
type
TabSelectPropsexport type TabSelectProps = {
...BoxLayoutProps,
...SelectColorProps,
readonly id?: string,
/** The tabs, in order. The selected one's description is drawn under them. */
readonly options?: $ReadOnlyArray<SelectOption>,
/** The tab selected to begin with, and whenever this prop changes. */
readonly selectedIndex?: number,
/** Whether this select has focus, and so receives the keys. */
readonly focused?: boolean,
/** The selection moved left or right. */
readonly onChange?: (index: number, option: SelectOption | null) => void,
/** Enter was pressed on a tab. */
readonly onSelect?: (index: number, option: SelectOption | null) => void,
/** Cells each tab is given. Twenty by default, as in OpenTUI. */
readonly tabWidth?: number,
/** Whether moving past either end comes round to the other. Off by default. */
readonly wrapSelection?: boolean,
/** Whether the selected tab's description is drawn. On by default. */
readonly showDescription?: boolean,
/** Whether a `▬` rule is drawn under the selected tab. On by default. */
readonly showUnderline?: boolean,
/** Whether `‹` and `›` say there are tabs off either edge. On by default. */
readonly showScrollArrows?: boolean,
};Everything a TabSelect accepts.
component
TabSelectexport component TabSelect(...props: TabSelectProps) { ... }A row of tabs a reader moves along and chooses from.
OpenTUI's tab-select, with its keys: Left or [ and Right or ] move, and Enter chooses; onChange and onSelect are called as a Select's are. Unlike a Select, moving past an end of a row that does not wrap is not reported — the index did not move, and OpenTUI says nothing either.
Its height is not a prop. It is one row for the names, one for the rule under the selected tab and one for its description, less whichever of the two is turned off — which is OpenTUI's rule, and the reason a height given to one is overridden rather than obeyed. As many tabs as fit across its width are shown, the selected one kept in the middle.
The same things are missing as from Select, for the same reasons: keyBindings, keyAliasMap, and OpenTUI's default colours for the tabs that are not selected.
type
TextareaPropsexport type TextareaProps = {
...BoxLayoutProps,
readonly id?: string,
/** The text, when the caller controls it. */
readonly value?: string,
/** The text to begin with, when it does not. OpenTUI's name for it. */
readonly initialValue?: string,
/** What to show while there is no text. */
readonly placeholder?: string,
/** OpenTUI's `#666666` by default. */
readonly placeholderColor?: ColorValue,
/** Whether this textarea has focus, and so receives the keys. */
readonly focused?: boolean,
/** How long lines break: at a word, anywhere, or not at all. `"word"` by default. */
readonly wrapMode?: EditWrapMode,
readonly textColor?: ColorValue,
readonly backgroundColor?: ColorValue,
readonly focusedTextColor?: ColorValue,
readonly focusedBackgroundColor?: ColorValue,
/** Called with the new text on every edit. */
readonly onContentChange?: (value: string) => void,
/** Called with the text when Meta+Enter is pressed. */
readonly onSubmit?: (value: string) => void,
};Everything a Textarea accepts.
component
Textareaexport component Textarea(...props: TextareaProps) { ... }Several lines a reader types into.
OpenTUI's textarea, and its keys. The arrows move a character or a line — a line as it is *drawn*, so Down in a wrapped paragraph goes to the next row of it rather than the next paragraph. Home and End go to the start and end of the whole text, Ctrl+A and Ctrl+E to the start and end of the line, and Meta+A and Meta+E to the start and end of the row it is wrapped onto. Meta+F/Meta+B, Meta+→/Meta+← and Ctrl+→/Ctrl+← move by word; Ctrl+F and Ctrl+B by character. Backspace and Delete (and Ctrl+D) delete a character; Ctrl+W, Meta+Backspace and Ctrl+Backspace the word before the cursor, Meta+D, Meta+Delete and Ctrl+Delete the word after it; Ctrl+K to the end of the line, Ctrl+U to its start, and Ctrl+Shift+D the whole line. Enter is a newline and Meta+Enter submits. Ctrl+- undoes and Ctrl+. redoes. A paste goes in whole, newlines and all.
Some of those only exist where the terminal can say them. A terminal without the Kitty protocol sends the same byte for Backspace and Ctrl+Backspace, has no Ctrl+Shift+D at all, and sends Ctrl+- as a control character nothing can tell from Ctrl+_; on one that has it, all of them arrive as themselves.
Controlled when value is given and uncontrolled otherwise, as Input is; OpenTUI's is always uncontrolled and is read through a ref, which this package does not hand out, so onContentChange and onSubmit carry the text rather than an empty event. The cursor is this component's own state for the reason Input gives, and is drawn the way Input draws it.
Give it a height and it scrolls to keep the cursor in view, moving no further than it has to. Without one it is as tall as its text.
# What is not here
Selection inside the text with Shift and the arrows, and the select-* bindings that go with it: a drag over a textarea selects its cells as it does any other text, but there is no range inside the buffer for an edit to replace. The Super bindings, which need a modifier KeyEvent does not carry. keyBindings and keyAliasMap. Syntax styles, extmarks and line numbers, which are OpenTUI's Code and LineNumbers territory and still ubugeeei-prod/uf#314. And a column the cursor remembers: moving up through a short line and on to a long one lands at the short line's end, not at the column the cursor started in.
@uniflowed/tui/difftype
Updateexport type Update = {
/** The bytes to write to the terminal. Empty when nothing changed. */
readonly output: string,
/** How many cells were re-sent. The number the performance claim is about. */
readonly cells: number,
};What one diff produced, and what it cost.
function
diffFramesexport function diffFrames(
previous: Frame | null,
next: Frame,
capabilities: Capabilities,
): Update { ... }The bytes that turn previous into next.
A null previous frame, or one of a different size, means a full repaint: the terminal was just entered or has just been resized, and there is nothing on it this renderer can claim to know.
function
sgrexport function sgr(style: Style, level: ColorLevel): string { ... }The escape sequence that selects style.
Empty at "none", and that is the whole of the no-colour answer: a terminal that cannot carry colour also cannot carry bold or underline, because both are the same SGR mechanism and a TERM=dumb terminal prints them as literal text. The frame's *shape* still arrives — the layout, the borders in their ASCII vocabulary, the text — which is the part a reader needs.
@uniflowed/tui/keystype
KeySourceexport type KeySource = "raw" | "escape";Which of the two parsers produced an event.
type
KeyEventTypeexport type KeyEventType = "press" | "repeat" | "release";What the terminal says happened to the key.
type
KeyEventexport type KeyEvent = {
/**
* Which of the two things a terminal's byte stream carries.
*
* A stream holds keys and, when mouse reporting is on, mouse reports. This
* is what tells them apart, and it is on the event rather than inferred from
* the presence of a field so that a `switch` over it is exhaustive.
*/
readonly kind: "key",
/**
* The canonical name: `"a"`, `"space"`, `"return"`, `"escape"`, `"up"`.
*
* `"paste"` is the one name that is not a key. A terminal in bracketed
* paste mode wraps pasted text in `ESC[200~` and `ESC[201~` so that an
* application can tell it from typing, and the whole point of knowing is to
* treat it as *text* — so it arrives as one event carrying all of it rather
* than as the burst of key presses it would otherwise look like.
*/
readonly name: string,
/** The text this key stands for, empty for keys that stand for none. */
readonly sequence: string,
/** The bytes as they arrived. */
readonly raw: string,
/** Which parser produced it. */
readonly source: KeySource,
readonly ctrl: boolean,
readonly shift: boolean,
readonly meta: boolean,
/** Whether this was a press, terminal repeat, or release event. */
readonly eventType: KeyEventType,
/** Skip the focused node's handler, without silencing later global ones. */
preventDefault(): void,
/** Silence later global handlers, and the focused node's. */
stopPropagation(): void,
/** Whether `preventDefault()` was called. */
defaultPrevented: boolean,
/** Whether `stopPropagation()` was called. */
propagationStopped: boolean,
};One key event.
sequence is the text the key stands for and raw is the bytes it arrived as; they differ for every key that is not a printable character, and a handler that inserts sequence into a buffer rather than raw is the difference between typing a and typing ^[[A.
type
InputEventexport type InputEvent = KeyEvent | MouseEvent;One thing that arrived from a terminal.
Everything a driver reads is one of these two, and kind is how a caller tells them apart without a type test on a field that might one day exist on both.
type
InputDecoderexport type InputDecoder = {
/** Decode one chunk, holding back a sequence that has not ended yet. */
push(chunk: string): Array<InputEvent>,
/** Give up on an unfinished sequence and emit what arrived. */
flush(): Array<InputEvent>,
};A decoder that survives a sequence arriving in pieces.
{@link decodeInput} is a pure function of one chunk, which is right for every key: a terminal delivers a key's escape sequence in a single read, and ESC at the end of a chunk is the Escape key. Two things a terminal sends are not keys and do not keep that promise — a paste, which is as long as the clipboard, and a mouse report, which a terminal in any-motion mode sends one of per cell the pointer crosses. The operating system splits either wherever it likes. So a driver reading a real stream holds one of these across chunks.
# The one rule, and what bounds it
push holds back a trailing run of bytes that **cannot be anything but the beginning of a sequence this decoder must see whole** — see incomplete, which is the whole of that judgement and the only place it is made. Nothing else is buffered: a chunk that ends anywhere else is decoded completely, because every other sequence a terminal sends either fits in a read or is ambiguous with a key that must fire now.
A held run is released by exactly two things. The next chunk completes it, or {@link InputDecoder.flush} says no next chunk is coming and the bytes are decoded as they stand. And a run that grows past {@link HOLD_LIMIT} without completing is not one of these sequences however it began, so it is decoded rather than held — which is what keeps a terminal emitting nonsense from wedging the decoder even where nobody calls flush.
function
createInputDecoderexport function createInputDecoder(): InputDecoder { ... }A decoder with somewhere to keep a half-arrived sequence.
function
decodeInputexport function decodeInput(input: string): Array<InputEvent> { ... }Everything in a chunk of terminal input: keys, and mouse reports.
A chunk is not a key. Holding a key down, pasting, or simply typing fast delivers several at once, and a decoder that returns the first and drops the rest loses characters under exactly the conditions — fast typing — where losing them is most obvious.
One chunk, decoded completely. A sequence this chunk begins and does not end is not held, because there is no later chunk for a pure function to wait for: an unfinished paste is emitted as the paste it was becoming, and an unfinished mouse report is decoded as the bytes it is. A driver reading a stream wants {@link createInputDecoder} instead, which holds them.
function
decodeKeysexport function decodeKeys(input: string): Array<KeyEvent> { ... }The key events in a chunk, with any mouse reports left out.
The narrow view, for a caller that has not turned mouse reporting on and therefore cannot receive one — which is every caller of this function until an application asks render for the mouse. A caller that has wants {@link decodeInput}, because dropping half of what a terminal said is a poor way to find out it was said.
@uniflowed/tui/layouttype
Dimensionexport type Dimension = number | string;A length: a number of cells, a percentage of the containing block, or "auto" for "as large as the content needs".
type
Marginexport type Margin = number | "auto";A margin: a number of cells, or "auto" inside a flex container.
type
FlexDirectionexport type FlexDirection = "row" | "row-reverse" | "column" | "column-reverse";Main-axis direction. "column" is the default, as in OpenTUI.
type
JustifyContentexport type JustifyContent =
| "flex-start"
| "center"
| "flex-end"
| "space-between"
| "space-around"
| "space-evenly";Main-axis distribution.
type
AlignItemsexport type AlignItems = "flex-start" | "center" | "flex-end" | "stretch";Cross-axis alignment of every child.
type
AlignSelfexport type AlignSelf = "auto" | AlignItems;Cross-axis alignment of one child, or "auto" to follow the parent.
type
FlexWrapexport type FlexWrap = "no-wrap" | "wrap" | "wrap-reverse";Whether children that do not fit on one line go on to another.
"no-wrap" — the default, OpenTUI's and Yoga's — keeps them on one and shrinks them. "wrap" starts a new line below (or, in a column, to the right), and "wrap-reverse" stacks the lines from the other side.
type
AlignContentexport type AlignContent =
| "flex-start"
| "center"
| "flex-end"
| "stretch"
| "space-between"
| "space-around"
| "space-evenly";Where the lines of a wrapping box go in the space they leave over.
type
Positionexport type Position = "relative" | "absolute";Whether a node takes part in its parent's flex line.
"relative" — the default, as it is in OpenTUI and Yoga — does, and its top/right/bottom/left then nudge where it is drawn without moving anything around it. "absolute" does not: its parent lays out as if it were not there, and it is placed against the inside of its parent's border by those four offsets.
type
Overflowexport type Overflow = "visible" | "hidden" | "scroll";What happens to content larger than its box.
"scroll" is "hidden" plus an offset: the children are stacked at their own heights, the box shows a window onto them, and scrollTop says which rows. It is the only value that changes how children are *placed* rather than only what is drawn, which is why the scroll layout is a branch of {@link layout} rather than a flag the painter reads.
type
LayoutStyleexport type LayoutStyle = {
readonly flexDirection?: FlexDirection,
readonly flexWrap?: FlexWrap,
readonly justifyContent?: JustifyContent,
readonly alignItems?: AlignItems,
readonly alignContent?: AlignContent,
readonly alignSelf?: AlignSelf,
readonly flexGrow?: number,
readonly flexShrink?: number,
readonly flexBasis?: Dimension,
readonly width?: Dimension,
readonly height?: Dimension,
readonly minWidth?: Dimension,
readonly minHeight?: Dimension,
readonly maxWidth?: Dimension,
readonly maxHeight?: Dimension,
readonly padding?: number,
readonly paddingTop?: number,
readonly paddingRight?: number,
readonly paddingBottom?: number,
readonly paddingLeft?: number,
readonly margin?: Margin,
readonly marginTop?: Margin,
readonly marginRight?: Margin,
readonly marginBottom?: Margin,
readonly marginLeft?: Margin,
readonly gap?: number,
readonly rowGap?: number,
readonly columnGap?: number,
readonly overflow?: Overflow,
readonly position?: Position,
/** Offsets: cells, or a percentage of the parent's inside. Negative is allowed. */
readonly top?: Dimension,
readonly right?: Dimension,
readonly bottom?: Dimension,
readonly left?: Dimension,
/**
* The first content row a scrolling box shows.
*
* Read only when `overflow` is `"scroll"`, and clamped by layout to the
* range the content actually has — so `Number.MAX_SAFE_INTEGER` means "the
* bottom" and needs no separate prop, and a caller that has just appended a
* line to a log does not have to know how long the log is to follow it.
*/
readonly scrollTop?: number,
};Everything layout reads off a node.
Every field is optional and every default is flexbox's, so a node with no style at all is a column that grows to fit its content — which is what a caller who wrote <Box> meant.
type
LayoutNodeexport type LayoutNode = {
style: LayoutStyle,
children: Array<LayoutNode>,
/** Cells the node's own frame occupies on each edge; a border is 1. */
borderWidth: number,
measure: ((availableWidth: number, availableHeight: number) => Size) | null,
x: number,
y: number,
width: number,
height: number,
/**
* The index of this node's first child that is inside a scrolling window,
* and how many of them are. Written by layout, read by the painter.
*
* Zero and zero for everything that does not scroll, and for a scrolling box
* whose window has reached past the end of its content. The painter walks
* this range instead of the whole child list, which is the half of "only the
* visible window" that paint is responsible for: a child outside the range
* has geometry from whichever frame last showed it, and drawing that would
* put last frame's rows on top of this one's.
*/
scrollFirst: number,
scrollCount: number,
/** Rows of content a scrolling box holds. Written by layout. */
scrollHeight: number,
/** The first row it is actually showing, after clamping. Written by layout. */
scrollOffset: number,
/**
* Where the window is, in frame coordinates: its first row, how many rows it
* has, and the column the bar goes in.
*
* Written by layout because layout is what resolved the padding, and the
* painter must not resolve it a second time — a bar drawn against the border
* box rather than the content box is a bar over the content on any box with
* padding on it.
*/
scrollViewTop: number,
scrollViewRows: number,
scrollBarColumn: number,
/**
* The last intrinsic size this node reported, and what was offered for it.
*
* `measuredFor*` is `-1` when there is nothing cached, which is what
* `invalidate` in `internal/tree.js` writes when anything under the node
* changes. Layout never invalidates this itself: a cache that layout could
* clear would be cleared on the frame that most needs it.
*/
measuredForWidth: number,
measuredForHeight: number,
measuredWidth: number,
measuredHeight: number,
/**
* The first child index whose height may have changed since the last frame,
* or `-1` when none has.
*
* Written by whoever changes the tree — `internal/tree.js`, which is to say
* React — and cleared by layout once it has acted on it. It is a number
* rather than a call into this module because the three participants named
* at the top of `internal/tree.js` do not import each other; a node's fields
* are the whole of what they say to one another.
*/
scrollDirtyFrom: number,
/** A scrolling box's stack of child heights. Owned by {@link layout}. */
scrollIndex: ScrollIndex | null,
...
};A node laid out by this module.
measure is how a leaf that knows its own size — a run of text, whose height depends on the width it is given — participates without layout knowing what text is. It is Yoga's measure callback under a shorter name.
The four geometry fields are written *by* layout and read by the painter. They are the node's border box in absolute frame coordinates.
type
ScrollIndexexport type ScrollIndex = {
/** The content width, viewport height and gap the stack was built for. */
width: number,
view: number,
gap: number,
/** The first index whose height is not known to be current. */
from: number,
tops: Array<number>,
heights: Array<number>,
/** Rows of content the whole stack adds up to. */
content: number,
};Where each child of a scrolling box sits in its content, kept between frames.
This is what makes scrolling cost the window rather than the content. The stack of child heights does not change when the offset does, so rebuilding it on every frame would be recomputing the answer to a question nobody asked — and it is the only part of a scrolling box that is proportional to how many children it has.
from is the first index that has to be rebuilt, and it is written from outside layout: internal/tree.js sets it when React mutates the tree, and sets it to the old child count when the mutation was an append, which is the shape a log has. A frame that changed nothing leaves it at the child count and rebuilds none of it.
tops[i] is where child i's border box starts, measured from the top of the content and including every margin and gap above it; heights[i] is how tall it is.
type
Sizeexport type Size = { readonly width: number, readonly height: number };A resolved size, in whole cells.
function
intrinsicSizeexport function intrinsicSize(
node: LayoutNode,
availableWidth: number,
availableHeight: number,
): Size { ... }The size a node wants when nothing constrains it.
available is what the parent can offer, and it is passed down rather than ignored because a text leaf's height is a function of the width it is given: the same paragraph is one line at 80 columns and four at 20. This is the pass Yoga calls "measure", and it is separate from layout because a parent has to know how large its children want to be before it can decide how large they get to be.
function
layoutexport function layout(
node: LayoutNode,
x: number,
y: number,
width: number,
height: number,
): void { ... }Lay node out into the border box at x, y, width by height.
Writes x, y, width and height onto every node in the subtree. The caller decides the root's box, which for a terminal is the whole screen.
@uniflowed/tui/mousetype
MouseEventTypeexport type MouseEventType =
| "down"
| "up"
| "move"
| "drag"
| "drag-end"
| "drop"
| "over"
| "out"
| "scroll";OpenTUI's nine mouse event types.
type
ScrollDirectionexport type ScrollDirection = "up" | "down" | "left" | "right";Which way a wheel turned.
type
Scrollexport type Scroll = {
readonly direction: ScrollDirection,
/** Notches. A terminal reports one report per notch, so this is always 1. */
readonly delta: number,
};One turn of the wheel: which way, and how far.
variable
MouseButtonexport const MouseButton: {
readonly LEFT: number,
readonly MIDDLE: number,
readonly RIGHT: number,
} = Object.freeze({ LEFT: 0, MIDDLE: 1, RIGHT: 2 });The three buttons a terminal can report, by the numbers it reports them as.
A terminal has no notion of a fourth button or of a chord: the two low bits of its report hold one of these three and a fourth value meaning "none", which is why {@link MouseEvent.button} is nullable rather than being a fourth constant here.
type
MouseEventexport type MouseEvent = {
/** Which of the two things a terminal's byte stream carries. */
readonly kind: "mouse",
readonly type: MouseEventType,
/**
* The button, as {@link MouseButton} names them, or `null`.
*
* `null` for a wheel report and for motion with nothing held down: both are
* encoded by the terminal as "no button", and reporting a left button for
* them would make `event.button === MouseButton.LEFT` true for a plain
* hover.
*/
readonly button: number | null,
/** The cell under the pointer, counted from zero. */
readonly x: number,
readonly y: number,
readonly ctrl: boolean,
readonly shift: boolean,
readonly meta: boolean,
/** The bytes this arrived as. */
readonly raw: string,
/** The wheel, on a `"scroll"` event, and `null` on every other. */
readonly scroll: Scroll | null,
/** The `id` of the topmost box under the pointer. */
target: string | null,
/** The `id` of the box whose handler is running; changes as it bubbles. */
currentTarget: string | null,
/**
* The `id` of the box a drag started on.
*
* Set on `"drag"`, `"drag-end"` and `"drop"`, and `null` otherwise. It is
* what makes a drop useful: the box that was dropped *on* receives the
* event, and this says what was dropped.
*/
source: string | null,
/** Stop the event reaching this node's ancestors. */
stopPropagation(): void,
/**
* Keep the renderer from doing its own thing with this event.
*
* On a left `"down"` that is the selection: the one the reader had is kept,
* and no new one begins under this press. Every other event type has no
* default, so calling this on one is harmless and does nothing.
*/
preventDefault(): void,
/** Whether `stopPropagation()` was called. */
propagationStopped: boolean,
/** Whether `preventDefault()` was called. */
defaultPrevented: boolean,
};One mouse report, as the handler on a node sees it.
x and y are cells of the frame, counted from zero at the top left — the same coordinates layout writes onto a node, so a handler can compare them with a node's geometry without converting. The terminal counts from one and that is converted here, once.
target, currentTarget and source are the id prop of a box rather than the box itself. This package has no public node type — the tree is internal/tree.js precisely so that nothing outside can hold a node across a commit and read stale geometry off it — so what an event can carry is the name the application gave the box. A box with no id reports null, which is the right answer for the common case: a handler already knows which node it is on, because it is its own closure.
function
mouseEventexport function mouseEvent(fields: MouseFields): MouseEvent { ... }Build a mouse event with its propagation flag wired up.
function
deriveexport function derive(from: MouseEvent, type: MouseEventType): MouseEvent { ... }The same report again, as another type.
"over" and "out" are not reported by a terminal; they are what the renderer says when the topmost node under the pointer changed, and they carry the position and modifiers of the report that moved it. Deriving them rather than synthesising a bare event is what keeps event.ctrl true for the "over" that a Ctrl-drag caused.
function
decodeMouseexport function decodeMouse(
input: string,
start: number,
): { event: MouseEvent, length: number } | null { ... }Decode one SGR-1006 report: ESC [ < button ; column ; row M or … m.
start must be the ESC, and the caller must already have established that [ and < follow. Returns null for anything that is not a complete report, which includes one cut in half by the end of a chunk — the caller then treats the bytes as it treats any other unfinished sequence.
The final byte is the whole of the press/release distinction: M is a press or a motion, m is a release, and the button bits say which button in both cases. That is the half of this encoding the original does not have.
variable
LEGACY_REPORT_LENGTHexport const LEGACY_REPORT_LENGTH: number = 6;How long an old-style ESC [ M report is: three introducer bytes and one each for the button, the column and the row.
Named rather than written twice, because keys.js needs the same number to know when one of these has not all arrived yet.
function
legacyReportLengthexport function legacyReportLength(input: string, start: number): number { ... }How many bytes an old-style ESC [ M report occupies, or zero.
{@link LEGACY_REPORT_LENGTH} of them. The caller consumes them and emits nothing, which is the documented behaviour of this package on a terminal that does not implement SGR mouse reporting — see this module's header for why that is better than decoding them.
Returns zero when fewer than that have arrived, so that a report split across two reads is not half-consumed. A decoder reading a stream holds those bytes until the rest of them arrive (keys.js, incomplete); the pure decodeInput has no later chunk to wait for and delivers three payload bytes as keys, which is the one case where this still leaks and needs a terminal without SGR *and* a caller that is not buffering.
@uniflowed/tui/selectiontype
SelectionPointexport type SelectionPoint = {
readonly x: number,
readonly y: number,
};One cell of the frame, counted from zero at the top left.
type
Selectionexport type Selection = {
/** The cell the press that began this selection landed on. */
readonly anchor: SelectionPoint,
/** The cell the pointer has reached. Moves as the drag continues. */
readonly focus: SelectionPoint,
/** The earlier of the two, by row and then by column. */
readonly start: SelectionPoint,
/** The later of the two. Both ends are *inside* the selection. */
readonly end: SelectionPoint,
};One selection, as the renderer holds it.
There is at most one per renderer, which is OpenTUI's rule and a terminal's: a second selection would have to be shown, and the frame has one way of showing a cell is selected.
anchor and focus are the gesture — where the press landed and where the pointer has reached — and are kept because that is what an application asking "which way is this drag going" needs. start and end are the same two points in reading order, which is what everything that walks the selection needs, and they are stored rather than recomputed so that no two readers of one selection can sort it differently.
function
selectionBetweenexport function selectionBetween(anchor: SelectionPoint, focus: SelectionPoint): Selection { ... }The selection a drag from anchor to focus describes.
Both ends are inclusive: a press and a release on the same cell select that one cell rather than nothing. A reader who drags across a single character expects to have selected it, and a half-open range would make the shortest possible selection the empty one.
function
selectionContainsexport function selectionContains(selection: Selection, x: number, y: number): boolean { ... }Whether a cell is inside a selection, in reading order.
@uniflowed/tui/terminaltype
Handleexport type Handle = {
/** Put the terminal back the way it was found and unmount the tree. */
stop(): void,
/** The frame currently on the screen. */
frame(): Frame,
/** That frame as text, which is what a snapshot asserts on. */
text(): string,
/**
* What the reader has selected with the mouse, or `""`.
*
* The same answer `useRenderer().getSelectedText()` gives a component, from
* outside the tree — which is where the caller wiring it to a clipboard
* usually is, since a program that wants to copy on Ctrl+C has a signal
* handler and not a component. `selection()` is the gesture behind it, for
* an application that wants to say something about how far it reaches.
*/
selectedText(): string,
/** The reader's selection, or `null` when there is none. */
selection(): Selection | null,
};What render gives back.
type
TestHandleexport type TestHandle = {
...Handle,
/** Feed raw terminal input, as a terminal would deliver it. */
press(input: string): void,
/** Draw the next frame and report what writing it would cost. */
update(): Update,
/** Resize the terminal, discarding what was on it. */
resize(width: number, height: number): void,
/** Every update produced since mounting, in order. */
updates(): $ReadOnlyArray<Update>,
};What testRender gives back: a Handle, plus the terminal's side.
type
OutputStreamexport type OutputStream = {
write(chunk: string): mixed,
readonly columns?: number,
readonly rows?: number,
readonly isTTY?: boolean,
/** A real `process.stdout` emits `"resize"`; a string collector does not. */
on?: (event: string, listener: () => mixed) => mixed,
off?: (event: string, listener: () => mixed) => mixed,
...
};Anything that can be written to; process.stdout, or a string collector.
type
InputStreamexport type InputStream = {
readonly isTTY?: boolean,
setRawMode?: (raw: boolean) => mixed,
resume?: () => mixed,
pause?: () => mixed,
setEncoding?: (encoding: string) => mixed,
on?: (event: string, listener: (chunk: string) => mixed) => mixed,
off?: (event: string, listener: (chunk: string) => mixed) => mixed,
...
};Anything keys arrive from; process.stdin.
type
RenderOptionsexport type RenderOptions = {
readonly stdin?: InputStream,
readonly stdout?: OutputStream,
/** `--color`, when the application has such a flag. */
readonly color?: ColorChoice,
/** The environment to detect from. Defaults to the process's. */
readonly env?: TerminalEnv,
/**
* Whether to take over the whole screen.
*
* On by default because a full-screen application that scrolls the shell's
* history away has destroyed something it cannot put back. Off for an
* application that wants to leave its last frame in the scrollback, which is
* what a progress display wants.
*/
readonly alternateScreen?: boolean,
/**
* Whether to ask the terminal to report the mouse.
*
* Off by default, which is a deliberate difference from OpenTUI's renderer.
* Mouse reporting is not free to a *reader*: a terminal in it stops handling
* click-and-drag itself, so selecting a line to copy out of an application
* that ignores the mouse anyway needs a modifier key the reader has to know
* about. An application that handles the mouse is trading that away on
* purpose; one that does not should not trade it away by default.
*
* What it trades it away *for* is this renderer's own selection, which
* arrives with the mouse and not separately: a drag over selectable text
* highlights it and `getSelectedText()` reads it back. That is a smaller
* promise than the terminal's, because the text it can offer is the text on
* the screen — but it is the same gesture, so a reader does not have to be
* told that this window is the one where dragging does nothing.
*/
readonly mouse?: boolean,
/**
* Whether `useClipboard()` may ask the terminal to copy text with OSC 52.
*
* On for interactive terminals, off for redirected output, and explicitly
* disableable for applications that prefer to expose "copy failed" over
* sending a sequence a terminal policy may reject without acknowledgement.
*/
readonly clipboard?: boolean,
};How to mount onto a real terminal.
function
testRenderexport function testRender(
element: React.Node,
options: {
readonly width?: number,
readonly height?: number,
readonly capabilities?: Capabilities,
readonly mouse?: boolean,
} = {},
): TestHandle { ... }Render into memory.
The way an application is *tested*, and the way one is rendered anywhere that is not a terminal. No environment is read, no stream is touched, and the capabilities are the caller's to choose — which is the point: a test asserting how a box degrades on a terminal with no colour should not have to arrange for the machine running it to have no colour.
function
renderexport function render(element: React.Node, options: RenderOptions = {}): Handle { ... }Render onto a terminal.
Returns as soon as the first frame is on the screen; the application keeps running because stdin is open, and stops when the caller calls stop(). That is deliberate — a render that never returned would make the calling program unable to do anything else, including install the signal handler that has to call stop().
@uniflowed/tui/widthsfunction
scalarWidthexport function scalarWidth(code: number): number { ... }The columns one Unicode scalar occupies.
Control characters and combining marks occupy none, East Asian Wide and Fullwidth characters and the default-emoji-presentation ranges occupy two, and everything else occupies one.
function
graphemeWidthexport function graphemeWidth(cluster: string): number { ... }The columns one grapheme cluster occupies.
A cluster is as wide as its widest scalar rather than the sum of them: the marks and joiners that make a cluster longer than one scalar are precisely the ones that draw on top of what came before. The exception is the emoji variation selector, which does not draw at all and instead widens the narrow scalar in front of it — ❤ is one column and ❤️ is two, and they differ by a code point that is invisible in every editor.
type
Graphemeexport type Grapheme = {
/** The cluster itself, as a string. */
readonly text: string,
/** How many columns it occupies: 0, 1, or 2. */
readonly width: number,
};One grapheme cluster, with the columns it will occupy.
function
graphemesexport function graphemes(text: string): Array<Grapheme> { ... }Split text into grapheme clusters, each carrying its width.
Zero-width clusters are dropped rather than kept: a renderer that writes them has to decide which cell they belong to, and the answer — "the one before, which has already been written" — means the only correct handling is to have merged them into that cluster, which Intl.Segmenter already did. A lone combining mark with nothing to combine with is the one case this loses, and losing it is better than reserving a column for something the terminal will not advance the cursor over.
function
displayWidthexport function displayWidth(text: string): number { ... }The columns a whole string occupies.