These docs describe the main branch, including unreleased changes. Read the 0.6.0 docs.

Editing buffers and views

This advanced reference is for component authors who need OpenTUI’s public native text-buffer and editor layers.

Use Input, Textarea, or Code for normal application UI. Use these classes when a custom renderable must own native text storage, wrapping, selection, editing, or viewport state.

Layer and ownership model#

Layer Purpose Ownership
TextBuffer Stores read-only or replaceable styled text and highlights The creator calls destroy()
TextBufferView Adds wrapping, alignment, viewport, truncation, and selection to a TextBuffer The creator destroys the view before its buffer
EditBuffer Adds a cursor, incremental edits, word movement, and undo history The creator calls destroy()
EditorView Adds wrapping, viewport, visual movement, selection, and extmarks to an EditBuffer The creator destroys the view before its edit buffer
TextBufferRenderable Uses private scene text by default, or explicit buffers and views for resource-backed subclasses Its destroy() releases its owned text resources
EditBufferRenderable Owns an EditBuffer and editor view bound to a native scene node Its destroy() releases the view before the buffer

Buffers and styles require an explicit owner. Use the destination’s renderer.nativeScene, or a ResourceContext for standalone work. Views inherit their source buffer’s owner and do not extend its lifetime.

import { ResourceContext, TextBuffer, TextBufferView } from "@opentui/core"

const owner = new ResourceContext({ objectCapacity: 2, renderCellsMax: 1 })
try {
  const buffer = TextBuffer.create("unicode", owner)
  const view = TextBufferView.create(buffer)
  buffer.setText("first line\nsecond line")
  view.setWrapMode("word")
  view.setViewport(0, 0, 20, 4)
  console.log(view.lineInfo)
  view.destroy()
  buffer.destroy()
} finally {
  owner.destroy()
}

Every class rejects most operations after destruction. Each destroy() method is idempotent.

These native classes run in the supported Bun and Node.js Core runtimes. Read Runtime and platform support for native artifact and FFI requirements.

Offset units#

Do not interchange these units:

Unit Meaning Used by
UTF-8 bytes Encoded storage and output-buffer capacity TextBuffer.byteSize and native text storage
UTF-16 code units JavaScript string indexing and string.length JavaScript only
Unicode code points Scalar values setTabIndicator() when given a string uses its first code point
Graphemes User-perceived text clusters Cursor movement, deletion, and boundary snapping
Display columns Terminal cell width under the selected width method Rows, columns, highlights, selection, and cursor positions
Global display offset Display columns from the buffer start, with each line break counted as one Cursor offsets, selections, ranges, and extmarks

TextBuffer.length is the sum of line display widths. It excludes line breaks. TextBuffer.byteSize is the UTF-8 output size and includes one byte for each normalized line break.

LogicalCursor.row and LogicalCursor.col are document coordinates. col uses display columns. Its offset is a global display offset.

VisualCursor.visualRow and visualCol are relative to the viewport. logicalRow and logicalCol are document coordinates. Its offset is also a global display offset.

TextBuffer#

Create a buffer with TextBuffer.create(widthMethod, owner). The width method is "unicode" or "wcwidth".

API Behavior
setText(text) Replaces text with a native-owned copy
append(text) Appends a native-owned copy of the supplied text
loadFile(path) Reads a file into native-owned text storage and rejects invalid UTF-8
setStyledText(styledText) Replaces content with styled chunks and optional links
getPlainText() Queries the exact UTF-8 byte count, then copies and decodes the full text
getTextRange(start, end) Reads a half-open global display-offset range
getLineCount() Returns logical line count
setDefaultFg, setDefaultBg, setDefaultAttributes, resetDefaults Set defaults for content without explicit style
setSyntaxStyle, getSyntaxStyle Attach or inspect a borrowed SyntaxStyle
setTabWidth, getTabWidth Set or read tab width in display columns
clear() Clears text but keeps highlights, arena capacity, and the reusable memory slot
reset() Clears text, highlights, arena state, and the complete memory registry

TextBuffer does not supply a cursor or incremental edits. Use EditBuffer for those operations.

The caller owns a SyntaxStyle passed to setSyntaxStyle(). The style and buffer must share the same ResourceContext. You can share a style across detached Sessions within that Context. For a different Context, pass the theme definitions to SyntaxStyle.fromStyles(definitions, destinationOwner). Attachment does not allocate or update a mirror. Before you detach or destroy a style, make sure that no consumer needs it.

TextBufferView#

A view tracks source-buffer changes automatically. It stores view state, not another copy of the text.

A new low-level view has no wrap width, "none" wrap mode, "left" text alignment, no viewport, no selection, and truncation disabled. Its "..." ellipsis uses static bytes, not a memory-registry slot.

API Behavior
setWrapMode("none" | "char" | "word") Selects wrapping policy
setWrapWidth(width | null) Sets the wrap width, where null passes native width 0
setTextAlign("left" | "center" | "right") Pads each rendered line within the viewport width. Default "left"
setFirstLineOffset(offset) Reduces available width on the first visual line
setViewportSize(width, height) Changes viewport dimensions while preserving its offset
setViewport(x, y, width, height) Sets horizontal and vertical cell offsets and dimensions
setTruncate(boolean) Enables or disables the view’s ellipsis form
setTabIndicator(value), setTabIndicatorColor(color) Sets the tab glyph and color
lineInfo Returns cached visual-line data for the active viewport
logicalLineInfo Returns visual-line data without viewport restriction
getVirtualLineCount() Returns wrapped visual-line count
measureForDimensions(width, height) Measures without changing the active viewport cache
getPlainText() Copies the full backing document through its exact-count reader

setSelection() and updateSelection() use global display offsets. setLocalSelection() and updateLocalSelection() convert viewport-relative cell coordinates. They subtract the draw-time alignment pad so a click maps to the painted character. Reset the matching form when the selection ends.

EditBuffer#

EditBuffer.create(widthMethod, owner) creates editable native text storage with one primary cursor.

Group API
Replace content setText, setTextOwned, replaceText, replaceTextOwned, clear
Insert and delete insertChar, insertText, deleteChar, deleteCharBackward, deleteRange, newLine, deleteLine
Move moveCursorLeft, moveCursorRight, moveCursorUp, moveCursorDown, gotoLine
Set cursor setCursor, setCursorToLineCol, setCursorByOffset
Read cursor getCursorPosition, getNextWordBoundary, getPrevWordBoundary, getEOL
Convert coordinates offsetToPosition, positionToOffset, getLineStartOffset
Read text getText, getTextRange, getTextRangeByCoords, getLineCount
History undo, redo, canUndo, canRedo, clearHistory
Style Default-style, syntax-style, and highlight methods shared with TextBuffer
Diagnostics debugLogRope writes the native rope structure to the debug logger

setText() resets history and the native add buffer. replaceText() creates an undo point. All replacement methods copy text into native-owned memory. The Owned variants retain the same public behavior without a borrowed-memory path.

Text that you replace or insert must not contain control characters other than tab, carriage return, and line feed. The replacement and insertion methods throw NativeError with status InvalidArgument for C0 controls, DEL, and C1 controls, and the edit buffer does not change. The same rule applies to editor placeholders, Textarea.initialValue, and Input.value. Textarea and Input remove these characters from pasted text and ignore key sequences that contain them.

The edit buffer emits native "cursor-changed" and "content-changed" events through its EventEmitter interface. undo() and redo() also emit "cursorChanged". Native code reports each event during the edit call. Core then delivers it to listeners in a microtask, so listeners run after the edit call returns. Events are scoped to the buffer’s Context and resource generation. Destroying the buffer or Context suppresses queued delivery.

EditorView#

Create a view with EditorView.create(editBuffer, viewportWidth, viewportHeight).

The new view starts at viewport offset (0, 0), uses "none" wrap mode, and has a scroll margin of 0.15. A later setScrollMargin() call clamps its value to 0..0.5.

Group API
Viewport setViewportSize, setViewport, getViewport, setScrollMargin
Wrapping and lines setWrapMode, getVirtualLineCount, getTotalVirtualLineCount, getLineInfo, getLogicalLineInfo
Selection Global and local selection methods, getSelection, hasSelection, getSelectedText, deleteSelectedText
Cursor getCursor, getVisualCursor, setCursorByOffset, visual up/down, word boundaries, visual and logical line ends
Content getText, setPlaceholderStyledText, tab-indicator methods
Measurement measureForDimensions
Experimental markers Lazy extmarks controller

setViewport(x, y, width, height, moveCursor = true) moves the cursor into the visible area by default. Local selection methods default updateCursor and followCursor to false.

Line information#

LineInfo contains parallel arrays:

Field Meaning
lineStartCols Global display-column start for each reported visual line
lineWidthCols Display width of each reported visual line
lineWidthColsMax Maximum width in the result
lineSources Logical source-line index for each visual line
lineWraps Wrap index inside that logical line

LineInfoProvider is the shared contract used by line gutters. It exposes lineInfo, lineCount, virtualLineCount, and scrollY. See Line number gutter for its normal use.

Selection and highlight ranges#

Selection ranges are half-open global display-offset ranges. A line break contributes one unit. A wide grapheme contributes its terminal width.

If a boundary falls inside a grapheme, extraction snaps to grapheme boundaries. A start boundary snaps backward and includes that grapheme. An end boundary also includes a grapheme that starts before the boundary.

Range and selected-text extraction use the buffer’s selected width method, including "wcwidth". The byte-count query and copy use the same grapheme boundaries. Selected-text reads do not prepare virtual lines, follow the cursor, or change the viewport. Coordinate-based edit ranges can prepare the document’s marker cache. Read the native copy rules for output units and capacity failures.

addHighlight(line, highlight) uses display columns on one logical line. addHighlightByCharRange() has a historical name. Its offsets are global display-width units with line breaks excluded. Both forms use half-open start and end.

Highlight also carries styleId, optional priority, and optional hlRef. Native transport stores priority as u8 and the reference as u16, and it rejects larger values with InvalidArgument. Use removeHighlightsByRef(), line clearing, or full clearing to release highlight state.

The public highlight methods are addHighlight(), addHighlightByCharRange(), removeHighlightsByRef(), clearLineHighlights(), clearAllHighlights(), and getLineHighlights(). TextBuffer also exposes getHighlightCount().

Resource limits#

The Context’s resource handles, including buffers and views, start at objectCapacity slots and grow to at most 4,194,304, or objectCapacity if it is larger. Native text storage also requires memory and can reject allocation. A JavaScript string parameter does not imply unlimited capacity.

Each native text buffer also has 255 byte-source registrations. A Context-owned TextBuffer reserves one, and each nonempty append() uses another. After 254 appends without replacement, further appends reject with OutOfMemory, regardless of available memory or objectCapacity.

Batch small streamed chunks when possible. A successful setText() reclaims append registrations. For plain text, buffer.setText(buffer.getPlainText()) preserves the content and restores append capacity; it is not a lossless styled-content compaction operation. clear() does not recover these registrations. reset() does, but discards content and highlights.

Text getters query the exact byte count before copying. They skip the copy call for empty results. They do not truncate output at a fixed 1 MiB limit. TextBuffer.reset() preserves live views, including their truncation ellipsis.

cursorCharacterOffset limitation#

EditBufferRenderable.cursorCharacterOffset is not a general JavaScript string index. The implementation reads a global display-width offset and uses it to index a JavaScript string by UTF-16 code unit.

The result is unreliable after wide CJK text, emoji, line breaks, combining sequences, or other complex graphemes. It also returns a nearby character at some end-of-line and end-of-buffer positions. Use logicalCursor, visualCursor, offsetToPosition(), and positionToOffset() for display coordinates. Build a separate grapheme-to-UTF-16 map when a JavaScript string index is required.

Renderable base classes#

The default TextBufferRenderable constructor uses the scene’s private document/view aggregate. It does not create the protected TypeScript textBuffer, textBufferView, or _textBufferSyntaxStyle wrappers. Use TextRenderable.content for ordinary text updates.

A resource-backed subclass must declare native kind "text_view" and pass true as the third base-constructor argument. The base then creates the protected wrappers and binds the view to the node. This complete example uses that path:

import { TextBufferRenderable, type RenderContext, type TextBufferOptions } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"

class SharedText extends TextBufferRenderable {
  static override readonly nativeIntegration = this.defineNativeIntegration({
    ...TextBufferRenderable.nativeIntegration,
    kind: "text_view",
    construction: "prototype",
  })

  constructor(ctx: RenderContext, options: TextBufferOptions) {
    super(ctx, options, true)
  }

  setText(text: string): void {
    this.textBuffer.setText(text)
    this.updateTextInfo()
  }
}

const { renderer, renderOnce, captureCharFrame } = await createTestRenderer({ width: 5, height: 1 })
try {
  const text = new SharedText(renderer, { width: 5, height: 1 })
  renderer.root.add(text)
  text.setText("Hello")
  await renderOnce()
  console.log(captureCharFrame().trimEnd())
} finally {
  renderer.destroy()
  await renderer.closed
}

Output:

Hello

Only the resource-backed path permits direct updates through the protected buffer. Call updateTextInfo() after those updates to refresh selection and request rendering. The integration description controls native drawing and host hooks.

EditBufferRenderable exposes readonly editBuffer and editorView properties. It supplies cursor display, selection, scrolling, edit commands, highlights, and content-change events. Subclasses add input policy and component options.

Do not destroy an owned buffer or view separately. Destroy the renderable. Read Custom renderables for final cleanup.

Experimental extmarks#

Extmarks are an experimental simulated implementation. They are scheduled to move to native code. Do not treat them as stable native markers.

The lazy EditorView.extmarks property creates an ExtmarksController. The controller monkey-patches cursor, edit, selection-delete, undo, and redo methods on its specific EditBuffer and EditorView. destroy() restores those methods, removes its content-changed listener, and clears marker state.

An extmark stores a half-open global display-offset range, virtual, optional style and priority values, arbitrary data, a numeric type, and optional metadata. Public operations are:

  • create, delete, get, getAll, getVirtual, and getAtOffset
  • registerType, getTypeId, getTypeName, and getAllForTypeId
  • getMetadataFor, adjustExtmarksAfterDeletion, clear, and destroy

Virtual extmarks make wrapped cursor methods skip the marked range. Styled extmarks rebuild all buffer highlights after changes. A styled extmark’s priority must be in the highlight range 0..255; otherwise create throws and keeps no extmark. The simulation scans JavaScript text and adjusts offsets in JavaScript, so it inherits the offset limitations on this page.

EditorView.destroy() destroys a lazy controller after the native view. If you call createExtmarksController() directly, destroy the controller before the view and edit buffer.

Next#

Changes0.6.0, 0.5.13, 0.5.12
0.6.0
core: EditBuffer.setText(), insertText(), insertChar(), and replaceText(), the same Textarea and Input methods, Textarea initialValue, Input.value, and both placeholder options throw NativeError for control characters other than tab, carriage return, and line feed. DEL and C1 control characters also throw. Remove these characters first. (#1479)
native: Text buffers build rope slices in one allocation. Setting and appending text use less time and memory. See Editing buffers and views. (#1582)
native: A highlight at the end of a line no longer colors text that an append, insert, or undo adds to that line later. setText() and replaceText() drop highlights on lines past the new line count, so getHighlightCount() and getLineHighlights() no longer report them. See Editing buffers and views. (#1621, #1601)
Added EditBuffer.runMutation, EditBufferRenderable.destroyOwnedResources, TextBufferRenderable.destroyOwnedResources, TextBufferRenderable.drawToBuffer, EditBufferRenderable.defaultFocusable, EditBufferRenderable.nativeIntegration, TextBufferRenderable.nativeIntegration.
Changed EditBuffer, EditorView, TextBuffer, TextBufferRenderable, TextBufferView, EditBuffer.create and 3 more.
Removed EditBufferRenderable.destroy, EditBufferRenderable.onRemove, EditBufferRenderable.render, EditBufferRenderable.renderCursor, TextBufferRenderable.destroy, TextBufferRenderable.render and 7 more.
0.5.13
native: Replacing text in a text buffer or edit buffer no longer keeps the previous document in memory. (#1544)
0.5.12
core: EditorView.isDestroyed reports whether the view has been destroyed. (#1504)
core: Reading the cursor of a destroyed editor returns an empty cursor. Listeners that run during destruction no longer throw. (#1504)
Added TextBufferView.setTextAlign, EditorView.isDestroyed, TextBufferOptions.textAlign, TextBufferRenderable._textAlign, TextBufferRenderable.textAlign.
Changed TextBufferRenderable._defaultOptions.