Text and terminal cells

OpenTUI stores text as Unicode data, measures grapheme clusters, and draws the result on a terminal cell grid.

String length and terminal width are different values. Keep that distinction when you wrap, truncate, select, or position text.

Style text

StyledText contains an ordered array of TextChunk values. A chunk contains text plus optional foreground, background, attributes, and link metadata.

Use t as a template tag. Style helpers return chunks that you can insert into the template.

OpenTUI preview

Run this example to open an interactive terminal.

Source code · typescript
import { RGBA, TextRenderable, bold, italic, t, underline, createCliRenderer } from "@opentui/core"

const renderer = await createCliRenderer()

const content = t`${bold("Status")}: ready\n${italic("Note")}: saved locally\n${bold(underline("Next"))}: review changes`

renderer.root.add(new TextRenderable(renderer, { content, fg: RGBA.defaultForeground() }))

Status is bold, Note is italic, and Next combines bold and underline. The rest of each line keeps its default attributes:

Status: ready
Note: saved locally
Next: review changes

The color helpers include normal, bright, and background named colors. The text helpers include bold, italic, underline, strikethrough, dim, blink, and reverse.

Helpers preserve a chunk's existing style and link while adding new attributes. Later foreground or background helpers replace that color.

Use TextAttributes when a component accepts an attributes bit mask. Combine values with bitwise OR.

import { TextAttributes, createTextAttributes } from "@opentui/core"

const attributes = TextAttributes.BOLD | TextAttributes.UNDERLINE
const inverse = createTextAttributes({ inverse: true })

The public inverse bit is TextAttributes.INVERSE. createTextAttributes() accepts inverse and the reverse alias. The reverse() style helper sets the same bit.

TextRenderable accepts strings and TextNodeRenderable children through its add() API.

See Text for component options. See Code and Markdown for parsed content.

Keep text units separate

UnitMeaning
UTF-8 byteEncoded storage and native transport unit. One code point can use several bytes.
Unicode code pointOne Unicode scalar value. A visible symbol can contain several code points.
UTF-16 code unitJavaScript string indexing unit. A code point outside the basic multilingual plane uses two units.
Grapheme clusterA user-perceived text unit. It can combine a base, marks, selectors, or joiners.
Terminal display cellOne terminal column in one row. A grapheme can occupy zero, one, two, or more cells.

OpenTUI receives JavaScript strings, encodes them as UTF-8, and segments text for measurement and drawing. The renderer's width method comes from terminal capabilities.

renderer.widthMethod is either "unicode" or "wcwidth". Use the same method for standalone buffers that share renderer output.

Neither method turns cell width into a character count. Read Terminal capabilities for width detection and remote-session rules.

Understand wide cells

Text drawing stores a printable ASCII character directly in its cell. It stores every other grapheme in a native grapheme pool, even a single code point such as é.

The first occupied cell stores the grapheme reference and its extent. Remaining occupied cells store continuation markers.

Continuation cells prevent later drawing and diffing from treating the tail of a wide grapheme as independent text. They contain no separate printable character.

Both "ABC".length and "A界B".length are 3. On this zero-based cell ruler, 界 occupies columns 1 and 2. It pushes B to column 3 and the following | to column 4:

column  01234
ASCII   ABC|  3 cells
wide    A界B|  4 cells

The stronger background shade marks the two cells occupied by 界.

The measured width is a column count, not a JavaScript string length. A string with length 2 can still occupy one or two terminal cells.

Wrap text

Wrapping and truncation operate on display width. They do not split a grapheme to make it fit at a line boundary.

Text renderables support wrapMode: "word", "char", or "none". The default is "word".

const text = new TextRenderable(renderer, {
  content: "A界B",
  width: 2,
  wrapMode: "char",
  fg: RGBA.defaultForeground(),
})

renderer.root.add(text)

The same text at widths of two, three, and four columns:

2 columns   3 columns   4 columns
01          012         0123
A           A界         A界B
界          B
B

At two columns, 界 cannot fit beside A, so it moves intact to the next row. The shaded area marks each view's cell bounds.

Align lines

textAlign pads each rendered line by display-cell width, not string length. After wrapping, each rendered line gets its own pad.

A 10-column textAlign: "center" view of hi then world draws hi with four cells of pad on each side. world gets two cells on the left and three on the right.

See Text for the component option.

Use buffer text operations

OptimizedBuffer.setCell() calls codePointAt(0) and stores that code point in one cell. It cannot represent a multi-code-point grapheme or create wide continuation cells. A control character becomes a space.

Use drawText() for normal text, including combining marks, emoji sequences, tabs, and wide code points. A tab draws as two spaces. A control character other than tab takes no cells.

import { OptimizedBuffer, ResourceContext, RGBA } from "@opentui/core"

const owner = new ResourceContext({ objectCapacity: 4, renderCellsMax: 40 })
try {
  const buffer = OptimizedBuffer.create(20, 2, "unicode", { owner })
  buffer.drawText("A\u0301 and \u754c", 0, 0, RGBA.fromHex("#ffffff"))
  buffer.destroy()
} finally {
  owner.destroy()
}

Read the Buffer API for clipping, alpha, drawing, and ownership rules.

Interpret selection offsets

Text-buffer selection uses a half-open range { start, end }. Both values are global display-width offsets from the start of that buffer.

Each logical line break adds one offset unit. Soft wrapping does not add a unit because it does not add text.

These three-column views look the same, but they select B at different offsets. "A界B" uses [3, 4); "A界\nB" uses [4, 5) because the newline adds one unit:

soft wrap          newline
012                012
A界                A界
B                  B
range [3, 4)       range [4, 5)

The highlighted cell marks the selection. Both views return "B" from getSelectedText().

Selection extraction snaps boundaries to complete grapheme clusters. A boundary inside a wide grapheme moves to include or exclude the complete cluster.

These offsets are not UTF-16 indexes. Do not pass them directly to String.prototype.slice() for text that can contain wide or combined graphemes.

The current cursorCharacterOffset getter has the same mismatch. It indexes plainText with a display-width cursor offset, so it is unreliable for wide or complex text.

See Interaction, focus, and selection for global screen bounds, selection creation, and selected-text assembly.

link(url)(text) adds link metadata to a TextChunk. OpenTUI emits an OSC 8 hyperlink only when capabilities.hyperlinks is true.

OpenTUI preview

Run this example to open an interactive terminal.

Source code · typescript
import { TextRenderable, createCliRenderer, link, t } from "@opentui/core"

const renderer = await createCliRenderer()
const content = t`Read ${link("https://opentui.dev/docs")("the documentation")}`
const text = new TextRenderable(renderer, { content })
renderer.root.add(text)

A URL can use at most 512 UTF-8 bytes. Longer URLs silently lose native link metadata and render as ordinary text.

Terminal support controls whether the link becomes clickable. Link metadata does not add focus, keyboard activation, or URL opening to the renderable.

React and Solid anchor text nodes with an href prop create the same terminal link metadata. They are not browser navigation elements.

Read Terminal capabilities for hyperlink detection.

Next

Read Colors for ColorInput, RGBA, and terminal palette intent. Read Layout before you size text containers.

Changes0.8.0, 0.7.0
0.8.0
core: When a clip, the buffer edge, or a text view’s edge cuts a wide glyph, each visible cell draws as a space in the glyph’s style. Before, the cell showed what was behind the text, and at a view’s left edge a cut wide glyph or tab shifted the rest of the line left. (#1838)
core: Under wcwidth, a cluster can span up to 16 cells, so ZWJ families of 6 or 8 cells no longer leave blanks or stale cells. A wider cluster draws as blank cells of its width, and OSC 66 explicit width is capped at 7 cells and padded with spaces. (#1779)
0.7.0
core: 896 symbols that terminals draw in one cell take one cell in every width method, also with VS15. Examples are ‼, ⁉, ☢, ⚛, 🖥, 🗑, and the Mahjong, domino, and playing-card tiles. Every measured terminal advances one cell for at least 892 of the 896. Before, they took two cells, so text after them moved one column right, the cursor landed past the text, and old cells stayed on screen. With VS16, as in ❤️, they take two cells in the grapheme methods and one cell in wcwidth, so under tmux and screen ❤️ is still one cell too narrow, until OpenTUI selects the width method correctly. In classic Windows conhost, which gives VS15 its own cell, 53 of these symbols followed by VS15 are now one cell off. Some terminals draw a glyph wider than the one cell that they advance, so it covers the next character, extends into a blank cell, or is cut off; this comes from the terminal, not from the layout. (#1762)
core: With the wcwidth width method, drawText and encodeUnicode give a multi-code-point cluster, such as 👩‍💻, the same width as the layout. Before, text after the cluster landed two cells to the left. Clusters wider than four cells, such as 👨‍👩‍👧, are still drawn too narrow. (#1773)
Changed detectLinks.