github anomalyco/opentui v0.6.0
Release v0.6.0

4 hours ago

OpenTUI 0.5 kept the renderable tree in JavaScript. On each frame, JavaScript
walked the tree and read each node's position back from Yoga. It then made one
native call for each draw, so the cost of a frame grew with the number of
nodes and draws. In 0.6.0, native code owns the tree. Each Renderable has a
node in a native scene, which holds the node's Yoga node and paint state.
Layout and paint setters stage their changes, and Core sends all staged
changes to native code in one call before each frame step. Native code then
lays out, clips, and paints the scene and fills the hit grid. It draws most
built-in renderables without a call to JavaScript. When no visible node has a
JavaScript hook, one native frame step lays out and paints the whole frame.
Rendering pipeline follows a
complete frame.

The built-in renderables and the React and Solid bindings keep their option
names, but a few option values now throw, such as enableLayout: false.
Custom renderables may need a little work. Renderable no longer has
render(), getLayoutNode(), or a protected yogaNode. Draw in
renderSelf(), renderBefore, or renderAfter, and report content size with
setMeasureProvider(). Paint hooks also work differently. A hook of an
unbuffered renderable now receives a recording buffer: OpenTUI stores the
hook's draw calls, and native code plays them back at the node's place in
paint order. Painting never stops to call JavaScript, so a hook cannot tear
the frame. But a hook can no longer read the frame's cells. To read cells, a
hook must draw into a buffer that the renderable owns and compose that
buffer, or the work must move to a post-process function. With
buffered: true, the hooks draw into such a buffer, and OpenTUI composes it.
Custom renderables shows a complete
example.

Native rendering resources now belong to one owner, a native Context.
Buffers, text buffers, views, syntax styles, images, and scene nodes cross the
native boundary as generational handles instead of raw pointers. A stale
handle fails with an error instead of touching freed memory, and destroying a
Context releases everything that it owns. The factories that used to create
resources without an owner now take one. For a resource that a renderer
draws, pass renderer.nativeScene:

// 0.5: SyntaxStyle.fromStyles(styles)
const syntaxStyle = SyntaxStyle.fromStyles(styles, renderer.nativeScene)

// 0.5: OptimizedBuffer.create(40, 10, "unicode")
const buffer = OptimizedBuffer.create(40, 10, "unicode", {
  owner: renderer.nativeScene,
})

Image factories take only a ResourceContext as their owner. For the
renderer's Context, pass renderer.nativeScene.resourceContext. Without a
renderer, create a Context with
new ResourceContext({ objectCapacity, renderCellsMax }), and call its
destroy() method when you are done. objectCapacity is the initial number
of object slots, and renderCellsMax limits the cells in each buffer.
API and symbol index lists each removed or
changed API with its replacement.

Terminal transitions are now asynchronous. suspend() and resume() return
promises. destroy() still returns at once, but the terminal restoration and
the remaining output finish later. Before you reuse the streams, or before a
deliberate exit, await renderer.closed:

await renderer.suspend()
// Run an editor or a shell in the terminal.
await renderer.resume()

renderer.destroy()
await renderer.closed

The native library now has a C interface. opentui.h declares a C ABI of
ot_* functions, and the opentui Zig module gains the same Context, scene,
and Session model. A C or Zig program can build a scene and write frames with
no JavaScript runtime. The ABI is experimental, so use the header, library,
and bindings from the same revision.
Native rendering starts with a box and a label.

One fix changes colors in some SSH sessions. Inside an SSH or mosh login,
OpenTUI detects remote mode automatically. In that mode, it used to stop
reading the environment before it read TERM and COLORTERM. As a result,
every remote terminal got truecolor output, and a 256-color terminal such as
GNU screen 4.x showed the UI without color. OpenTUI now reads these two keys.
This has a cost. A truecolor terminal that reaches the server with
TERM=xterm-256color and no COLORTERM used to get exact colors by accident.
Examples are iTerm2 and WezTerm over a stock macOS ssh client, which does not
forward COLORTERM. They now get 256-color approximations. A Kitty XTVERSION
reply still turns on truecolor. To keep truecolor in other terminals, export
COLORTERM=truecolor on the server, forward it with SendEnv COLORTERM and
AcceptEnv COLORTERM, or pass it in the renderer's environment option.

Breaking changes

  • core: Renderable.render(), updateLayout(), updateFromLayout(),
    canReuseRenderCommandList(), getLayoutNode(), the protected yogaNode,
    the RenderCommand type, and RootRenderable.calculateLayout() are
    removed. Draw in renderSelf(), renderBefore, or renderAfter, and use
    setMeasureProvider(), invalidateIntrinsicSize(), and getLayout() for
    size and layout. Protected members such as _x, _y, getScissorRect(),
    and _getVisibleChildren() are also removed. Use x, y, screenX, and
    screenY, and the viewportCulling option of ScrollBox. See
    Custom renderables.
    (#1479)
  • core: The enableLayout: false option throws. on("resized") and
    on("layout-changed") throw on a renderable that is not the root, where
    these listeners never ran. Use onSizeChange instead. See
    Layout.
    (#1479)
  • core: Paint hooks of an unbuffered renderable get a recording buffer
    that cannot read cells, and renderer.nextRenderBuffer accepts drawing only
    in a post-process function. buffer.buffers works only in a post-process
    function. Elsewhere, use buffer.withBuffers(callback). To read or change
    cells in a hook, set buffered: true, or draw into a buffer that you own and
    compose it. For the whole frame, use a post-process function. See
    Rendering pipeline.
    (#1479)
  • core: addToHitGrid(), pushHitGridScissorRect(),
    popHitGridScissorRect(), and clearHitGridScissorRects() are removed from
    CliRenderer and RenderContext. Native code adds a hit area for each
    visible node, and renderer.hitTest(x, y) queries the hit grid. A custom
    RenderContext must implement nativeScene, requestAnimationFrame(), and
    cancelAnimationFrame(). See
    Interaction, focus, and selection.
    (#1479)
  • core: OptimizedBuffer.create(), TextBuffer.create(),
    EditBuffer.create(), SyntaxStyle.create(), SyntaxStyle.fromStyles(),
    and SyntaxStyle.fromTheme() require an owner, and the ptr properties of
    buffers, views, and styles are removed. Pass renderer.nativeScene, or a
    ResourceContext when no renderer exists. Resources from different owners
    cannot be used together, and the resources that a renderer owns stop
    working when the renderer is destroyed. See
    How Core uses native.
    (#1479)
  • core: suspend() and resume() return a Promise<void>, and
    destroy() restores the terminal asynchronously. Await the transitions, and
    await renderer.closed before you reuse the streams. Both methods throw
    while another transition is pending, and when the terminal is not set up,
    for example on a test renderer or after destroy(). See
    Lifecycle and cleanup.
    (#1479)
  • core: The useThread option, renderer.useThread,
    renderer.rendererPtr, dumpBuffers(), dumpOutputBuffer(), and the
    OTUI_NO_NATIVE_RENDER environment variable are removed. Remove
    useThread. Instead of dumpBuffers(), use test frame capture or
    renderer.currentRenderBuffer.withBuffers(). Instead of
    dumpOutputBuffer(), give the renderer a custom stdout. Instead of
    OTUI_NO_NATIVE_RENDER, use createTestRenderer(). See
    Rendering diagnostics.
    (#1479)
  • core: The Clipboard class is removed. Use
    renderer.copyToClipboardOSC52() and renderer.clearClipboardOSC52(), or
    createClipboard() with createHostClipboard() and
    createRendererClipboardAdapter(renderer). See
    Clipboard.
    (#1479)
  • core: OpenTUI no longer installs a process-global
    requestAnimationFrame, cancelAnimationFrame, or window. Use
    renderer.requestAnimationFrame() and renderer.cancelAnimationFrame().
    See Animation and Timeline.
    (#1479)
  • core: After destroy(), renderable constructors, the timeline
    engine.attach(), and most renderer methods throw, for example
    setTerminalTitle(), copyToClipboardOSC52(), getStats(), and
    hitTest(). requestRender() and resize() do nothing. Check
    renderer.isDestroyed first. See
    Lifecycle and cleanup.
    (#1479)
  • core: When the output stream emits error, or closes or finishes before
    the renderer closes it, the renderer destroys itself and renderer.closed
    rejects. Handle the rejection of closed. See
    Lifecycle and cleanup.
    (#1479)
  • core: Drawing methods throw RangeError for fractional, NaN, or
    infinite coordinates and sizes. drawText() and box titles throw
    RangeError for text larger than 64 KiB of UTF-8. setCell(),
    setCellWithAlphaBlending(), drawChar(), and drawText() throw
    NativeError for attribute bits 8 to 31. In a paint hook, the frame fails
    instead. See Buffer API.
    (#1479)
  • 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. See Editing buffers and views.
    (#1479)
  • core: The minimum Bun version is 1.3.14 (was 1.3.0), the oldest version
    that CI tests. Bun 1.3.0 to 1.3.3 pass some FFI pointer arguments as wrong
    addresses, Bun 1.3.4 to 1.3.6 can crash after a Worker exits, and Bun 1.3.7
    can hang while it loads modules. See
    Runtime and platform support.
    (#1479)
  • core: The pointer-based RenderLib methods, such as createRenderer
    and bufferDrawText, and the handle types, such as RendererHandle and
    OptimizedBufferHandle, are removed. Use the public classes and the
    Context*Handle, SessionHandle, and SceneNodeHandle types. See
    How Core uses native.
    (#1479)
  • core: Standalone Yoga throws YogaError for invalid arguments, and
    Config.free() throws while nodes from that config remain. Measure and
    dirtied functions must return synchronously and must not change Yoga nodes.
    See Yoga API.
    (#1479)
  • ssh: session.write() can throw OutputPressureError or RangeError.
    cols and rows hold the requested size until the renderer attaches, and
    onClose runs at logical close, before the renderer finishes closing. See
    SSH.
    (#1479)
  • native: The legacy native exports, such as createRenderer and
    bufferDrawText, are removed. Use the ot_* functions in opentui.h. In
    Zig, the global grapheme and link pools are removed, so
    OptimizedBuffer.init() and CliRenderer.create() require a link_pool.
    GraphemePool.acquire() and LinkPool.acquire() replace alloc(). They
    return an ID that holds one reference, which decref() releases. See
    C and Zig.
    (#1479)

Added

  • core: renderer.requestAnimationFrame() and
    renderer.cancelAnimationFrame() schedule one-shot frame callbacks for one
    renderer. getTimelineEngine(renderer) and
    createTimeline(options, renderer) give each renderer its own timeline
    engine. See Animation and Timeline.
    (#1479)
  • core: renderer.closed resolves after destruction writes the remaining
    output and native teardown finishes. It rejects when the output fails or
    does not finish within one second. See
    Lifecycle and cleanup.
    (#1479)
  • core: renderer.requestResize(width, height) debounces size changes
    from a custom terminal and applies only the latest size. See
    Renderer.
    (#1479)
  • core: The nativeSceneWorkBudget option lets a frame yield to the event
    loop during preparation. Painting still runs in one native call. See
    Renderer.
    (#1479)
  • core: The environment option gives terminal environment values to one
    renderer, for example { TERM: pty.term } from an SSH client. These values
    replace the forwardEnvKeys values with the same names. In an automatically
    detected SSH login, only TERM and COLORTERM take effect unless you set
    remote: true. See
    Terminal capabilities.
    (#1603)
  • core: ResourceContext owns native resources without a renderer, and
    NativeError reports a failed native call with its operation and
    status. Image factories accept a ResourceContext in their owner
    option, such as renderer.nativeScene.resourceContext. See
    How Core uses native and
    NativeImage.
    (#1479)
  • core: Custom renderables get setMeasureProvider(),
    invalidateIntrinsicSize(), getLayout(), refreshHooks(), and
    defineNativeIntegration(). TextRenderable and CodeRenderable get
    drawToBuffer(), and OptimizedBuffer gets withBuffers() for scoped cell
    access. See Custom renderables.
    (#1479)
  • core: TestRendererSetup.dispose() and [Symbol.asyncDispose]()
    destroy the test renderer and wait for renderer.closed.
    ManualClock.pendingTimerCount counts the scheduled timeouts and active
    intervals. See Testing.
    (#1479)
  • native: opentui.h declares an experimental C ABI, and the opentui
    Zig module gains the same Context, scene, and Session model. A
    Context's object table starts at object_capacity slots and doubles when it
    is full, up to 4,194,304 slots or object_capacity, whichever is larger.
    Past that limit, object creation fails with OT_OBJECT_LIMIT. See
    Native rendering.
    (#1479,
    #1622)
  • native: Terminology gets OSC 8 hyperlinks and OSC 777 notifications when
    its XTVERSION reply names it. See
    Terminal capabilities.
    (#1566)
  • ssh: The server accepts COLORTERM, TERM_PROGRAM, and
    TERM_PROGRAM_VERSION env requests that arrive before the shell request,
    with values of at most 256 bytes. It rejects other keys. The renderer gets
    these values with the client's PTY TERM, so capability detection sees the
    client's color depth and hyperlink support, and GNU Screen or tmux from the
    TERM prefix. A client with TERM=xterm-256color and no COLORTERM now
    gets 256-color output instead of truecolor. To keep truecolor, add
    SendEnv COLORTERM to the client's ssh configuration. See
    SSH.
    (#1603)

Changed

  • core: Native code lays out, culls, and paints the scene and fills the
    hit grid, so JavaScript no longer walks the tree on each frame. JavaScript
    work in a frame now grows with changes and hooks, not with the number of
    renderables. Some built-in renderables, such as Select and ScrollBox, still
    run JavaScript hooks. See
    Rendering pipeline.
    (#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: Buffers write cells that are not grapheme clusters on a faster
    path, and drawText() writes printable ASCII without copying it. See
    Buffer API.
    (#1591)
  • core: EmbeddedTerminalRenderable encodes meta as Alt, and only
    super encodes as Super. Core's key parser sets meta for Alt, so Alt+b
    now reaches a legacy child as ESC b, not b. On macOS, an Alt modifier
    reaches the child as Alt, because the host terminal already applied its
    Option setting. See Embedded terminal.
    (#1594,
    1b25377b6,
    f0d7d1d3b,
    be14991e6)
  • react: root.render() does nothing when the renderer is destroyed, for
    example after exitOnCtrlC. See React bindings.
    (#1604)
  • solid: render() does not mount when the renderer is destroyed, for
    example after exitOnCtrlC. See Solid bindings.
    (#1604)

Fixed

  • native: In an SSH or mosh login, automatically detected remote mode
    reads the color depth from TERM and COLORTERM. A 256-color terminal such
    as GNU screen 4.x gets 256-color output instead of truecolor that it cannot
    show. See Terminal capabilities.
    (#1624)
  • core: In split-footer mode, a write that continues a scrollback row no
    longer loses or overwrites a wide character at the end of the row. Tabs in
    captured stdout count from the start of the terminal row and stop at the
    last column. See Renderer.
    (#1597,
    #1617)
  • native: In split-footer mode, a scrollback row that fills the terminal
    width keeps its last cell. Output no longer erases the footer when fewer
    than two rows remain above it, for example with the default footer in a
    terminal of 13 rows or fewer. See Renderer.
    (#1581,
    #1618)
  • 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)
  • native: Up and Down move the cursor to the start of a wide character or
    tab, not inside it. This applies to EditBuffer.moveCursorUp() and
    moveCursorDown(), EditorView.moveUpVisual() and moveDownVisual(), the
    Textarea arrow keys, and EditorView.setViewport() with moveCursor. See
    Textarea.
    (#1596)
  • native: Down does nothing in an empty Textarea while it shows a
    placeholder of more than one row. The next edit no longer fails with
    InvalidArgument. See Textarea.
    (#1619)
  • native: A width change in word-wrap mode releases the layout memory of a
    narrower wrap. Before this fix, a 94 KiB text wrapped at width 1 and then at
    width 80 kept about 46 MB. See Text.
    (#1602)
  • core: Key bindings use the Kitty base-layout key only when the typed key
    is one non-ASCII character. On Dvorak, Ctrl+J no longer matches
    exitOnCtrlC, and Ctrl+ㅊ still matches Ctrl+C. See
    Keyboard input.
    (#1599)
  • core: .tsx files and tsx Markdown fences use the tree-sitter TSX
    grammar through a separate typescriptreact parser. JSX closing tags no
    longer highlight as regular expressions. See
    Syntax highlighting with Tree-sitter.
    (#1598)
  • core: The tree-sitter worker changes #lua-match? predicates to
    #match? in bundled and registered queries when it can translate the
    pattern. TypeScript and Zig identifiers no longer all get the type and
    constant captures. See
    Syntax highlighting with Tree-sitter.
    (#1620)
  • core: EmbeddedTerminalRenderable sends F1 to F12 with or without
    modifiers, keys that the host terminal sends in SS3 form, and the keypad
    Begin key. It sends F13 to F25 to a child in Kitty keyboard mode. See
    Embedded terminal.
    (#1616)
  • native: The embedded terminal answers OSC 10, 11, and 12 and Kitty OSC
    21 color queries, and it applies an OSC 10 or OSC 11 that sets only one
    default color. OSC 110 resets the foreground to white, and OSC 111 resets the
    background to black. See
    Embedded terminal.
    (#1615)
  • native: The embedded terminal answers Primary, Secondary, and Tertiary
    Device Attributes queries. fish 4.1 and later no longer stall for about two
    seconds at startup. See
    Embedded terminal.
    (#1595)

API changes and documentation: https://opentui.com/docs/releases/0.6.0

What's Changed

  • Terminology supports hyperlinks (osc8) and notifications through osc777 by @borisfaure in #1566
  • native: move the render tree into Zig by @simonklee in #1479
  • skills: simplify hardening skill by @simonklee in #1576
  • docs: clarify runtime API contracts by @simonklee in #1577
  • skills: keep the opentui skill installable by @simonklee in #1579
  • native: keep the last cell of a split scrollback row that fills the width by @simonklee in #1581
  • rope: build slice trees in one allocation by @simonklee in #1582
  • buffer: optimize plain ASCII writes by @simonklee in #1591
  • native: reply to device attribute queries in the embedded terminal by @simonklee in #1595
  • native: count a trailing wide character in a split scrollback row's end by @simonklee in #1597
  • core: pin the ASCII boundary of the base-layout fallback rows by @simonklee in #1599
  • native: bound word-wrap layout memory after a width change by @simonklee in #1602
  • native: snap vertical cursor moves to the start of a wide character by @simonklee in #1596
  • native: check highlight counts after shrinking setText by @simonklee in #1601
  • ssh: send the client's terminal environment to the renderer by @simonklee in #1603
  • solid, react: skip the mount when the renderer is destroyed by @simonklee in #1604
  • core: highlight TSX with the tree-sitter TSX grammar by @simonklee in #1598
  • rope: simplify marker cache storage by @simonklee in #1610
  • core: encode embedded terminal function keys and SS3 keys by @simonklee in #1616
  • native: set embedded terminal default colors by @simonklee in #1615
  • core: cut captured stdout rows where the terminal row continues by @simonklee in #1617
  • native: keep split-footer output off the footer when fewer than two rows remain by @simonklee in #1618
  • native: keep Down on the empty buffer while a placeholder is shown by @simonklee in #1619
  • native: stop highlights from spreading onto text appended at the line end by @simonklee in #1621
  • core: evaluate #lua-match? predicates in tree-sitter queries by @simonklee in #1620
  • api: record the API history of every release by @simonklee in #1625
  • native: grow the object handle table instead of failing at object_capacity by @simonklee in #1622
  • core: add input parser conformance vectors by @simonklee in #1623
  • native: keep TERM and COLORTERM color depth in auto remote sessions by @simonklee in #1624
  • web: let release notes inspect source by @simonklee in #1627

New Contributors

Full Changelog: v0.5.17...v0.6.0

Don't miss a new opentui release

NewReleases is sending notifications on new releases.