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

Frames and output

A frame is one rendered view of the interface. To produce it, the native library paints terminal cells and encodes them as output bytes. Your program, the host, chooses native delivery or forwards those bytes through its own stream.

Drawing and delivery do not necessarily finish together. The cells can be ready while a slow connection is still sending earlier output. The frame API keeps these stages separate. It also lets the host draw at specific positions or give other work a turn during preparation.

Paint, submit, and deliver

Start with a frame that needs only native drawing:

  1. ot_scene_paint calculates layout, paints cells in memory, and returns a retained DONE draft.

  2. ot_scene_frame_commit consumes that draft, encodes its cells, and queues output.

  3. ot_session_drain_stdout writes queued bytes directly and records delivery. For a host-owned stream, copy bytes with ot_session_read_output, write them, and report delivery with ot_session_complete_output.

A finished cell grid is not yet a presented frame. Native code publishes the frame only after delivery completes. Before commit, ot_scene_paint and ot_scene_frame_step_with_geometry return OT_FRAME_BUSY. After commit queues the frame's output, they return OT_OUTPUT_BUSY until delivery completes.

ot_scene_paint completes that painting in one call. It returns OT_UNSUPPORTED_RESOURCE if any node registered an update, resize, layout-changed, or paint hook. To draw part of a frame yourself, use the step path below. It uses the same scene and drawing engine.

Draw after the label

Suppose the interface contains a box with a text label, and the host needs to draw a mark at the right of that row:

┌──────────┐
│Hello    !│
└──────────┘

An after-paint hook reserves a position for host drawing after the label's own drawing. Registering that hook does not install a function pointer for native code to call during painting.

ot_scene_frame_step_with_geometry starts or continues the frame. When preparation finishes, it returns one RECORD request instead of painting. The request's slots list every node whose hooks the host must record, in paint order. The host records ! for the label's after slot, then calls the same operation with that exact request and the recording. Native code paints the whole frame in one pass. It plays the recording at the label's position and returns a request of kind DONE. The finished cells form a draft: they still need to be committed and delivered.

┌─ Native frame step ─────────────────┐
│ Lay out the box and label           │
│ Return RECORD with the label's slot │
└────────────────┬────────────────────┘
                 ↓
┌─ Host ──────────────────────────────┐
│ Record ! at cell (10, 1)            │
│ Call the frame step with it         │
└────────────────┬────────────────────┘
                 ↓
┌─ Native frame step ─────────────────┐
│ Paint the frame and return DONE     │
└────────────────┬────────────────────┘
                 ↓
┌─ Host ──────────────────────────────┐
│ Commit, deliver output, acknowledge │
└─────────────────────────────────────┘

The first step uses a NULL previous request and starts an attempt. Later steps pass back the returned record without reconstructing it. The attempt ends when a step returns DONE, when an accepted step fails, or when the host cancels it. A rejected reply, such as a stale request, leaves the attempt unchanged. No worker or asynchronous paint callback is involved: every host hook runs before native code paints any cell.

The C guide implements this sequence in a complete program.

Read and acknowledge requests

A node registers hooks with ot_scene_set_hooks. On OT_OK, the returned request kind tells the host what to do:

Request kindHost action
OT_SCENE_FRAME_DONE (0)Use the finished cells, then commit or cancel the draft
OT_SCENE_FRAME_UPDATE (1)Run update work for a node with OT_SCENE_HOOK_UPDATE
OT_SCENE_FRAME_RESIZE (2)Handle the new width and height of a node with OT_SCENE_HOOK_RESIZE
OT_SCENE_FRAME_LAYOUT_CHANGED (3)Handle a new Yoga layout for the root, which has OT_SCENE_HOOK_LAYOUT_CHANGED
OT_SCENE_FRAME_RECORD (4)Record every slot's paint hooks and submit them

The entire ot_scene_frame_request is a frame ticket. It identifies the Session, root, node, frame, request, layout epoch, and hook generation. It also holds the kind, public node number, dimensions, and reserved fields. Preserve the whole record. A frame ID alone cannot acknowledge a request. Native code allows one outstanding request at a time. It rejects altered or stale replies without consuming them.

ot_scene_frame_geometry is a separate observation, not part of the ticket. Its flags identify valid paint and public-layout snapshots. RECORD and DONE carry no snapshots. Accepted mutations can invalidate the snapshots before the next step. Do not copy geometry fields into an acknowledgement.

Record paint hooks

To register paint hooks, pass OT_SCENE_HOOK_RENDER_BEFORE, OT_SCENE_HOOK_RENDER_SELF, or OT_SCENE_HOOK_RENDER_AFTER to ot_scene_set_hooks. The root cannot register paint hooks. Each ot_scene_set_hooks call needs a nonzero generation that is greater than the node's previous generation.

For a RECORD request, ot_scene_frame_get_paint_slots copies one ot_scene_paint_slot for each visible node with paint hooks, in paint order. Call it with zero capacity to get the slot count. A slot names the node, its hooks, and the geometry, clip, and opacity that native code paints it with. Run the hooks of every slot in slot order, then acknowledge the request with one recording.

While the RECORD request is pending, render, resize, terminal setup, and suspend return OT_FRAME_BUSY. The RECORD ticket gives no access to the framebuffer.

A recording is a byte stream of records. It must start at an 8-byte aligned address and be at most OT_SCENE_RECORD_BYTES_MAX (64 MiB). Each record starts with ot_scene_record_header. Its size counts the whole record, padded to a multiple of 8.

A SLOT record starts the commands for one phase of one slot. Slots and phases must appear in increasing order, at most once each, and only for phases set in the slot's hooks. A phase that draws nothing needs no SLOT record, and an empty recording is valid.

The other records are drawing commands, such as DRAW for text, fills, boxes, and buffer composition. Each command belongs to the latest SLOT record. Commands use the same fields and checks as the matching ot_buffer_draw_* and ot_buffer_* operations.

For each node, native code takes these steps before it paints the node's children:

  1. It plays the before phase.

  2. It plays the self phase, which replaces the node's native body. Without RENDER_SELF, the native body draws.

  3. For a focused editor node, it updates the terminal cursor and mouse pointer.

  4. It plays the after phase.

  5. When use_mouse is set, it adds the node's bounds to the hit list. ot_scene_hit_test reads that list after the frame is presented.

An after hook on the box would therefore draw before the label, not after the whole subtree. Native code sorts siblings by z_index within each parent. It does not sort all nodes globally.

Each phase starts with only the slot's clip and opacity on the drawing stacks. STACK commands push and pop above that entry but cannot remove it. Native code resets the stacks between phases. Two commands ignore the clip and opacity and act on the whole frame, like their ot_buffer_* forms:

  • A DRAW with OT_BUFFER_DRAW_CLEAR overwrites every cell, including cells that earlier nodes painted, and removes their image placements.

  • COLOR_MATRIX transforms every cell that it selects, inside or outside the clip.

An IMAGE or VIDEO node with a backing buffer from ot_scene_set_image or ot_scene_set_video is an exception to these rules. Native code clears that buffer, plays the node's phases into it in buffer coordinates with empty stacks, and then composes it into the frame.

Native code reads each resource that a command names when it plays the command, not when the host records it. A command whose buffer, view, image, node, or Unicode handle was destroyed before playback draws nothing. Native code checks the stream's framing and slot order before painting. It checks each command's contents when it plays the command. Any other invalid stream or command fails the step and cancels the attempt without presenting cells. The checked header defines every record layout.

Commit the draft, then complete output

DONE means painting finished. The host must now commit or cancel the draft. Commit with ot_scene_frame_commit. While a draft is live, ot_session_render returns OT_FRAME_BUSY. The commit lets native code check that the submitted cells belong to that exact finished frame.

Check both the call's ot_status and the separate render outcome:

Render outcomeMeaning
OT_RENDER_PRESENTEDOutput was accepted and presentation is complete, including no-byte frames
OT_RENDER_PENDINGThis draft was accepted. Its output completion remains pending
OT_RENDER_SKIPPEDOutput pressure. Paint a new draft after queued output drains
OT_RENDER_FAILEDThe frame is larger than the empty output queue, or encoding or allocation failed. No output was accepted

An OT_OK commit consumes the draft for every render outcome, including OT_RENDER_SKIPPED and OT_RENDER_FAILED. OT_RENDER_FAILED is a render outcome, not an ot_status error. Do not submit the ticket again. Pumping output does not repaint or retry the frame.

If commit returns an error status, the draft stays live for a retry, and native code does not write the render outcome. For example, a frame-qualified lease makes commit return OT_FRAME_BUSY until you release the lease.

ot_session_render_split with a DONE ticket uses the same consumption rules. A consumed draft is stale even while earlier output remains pending. A call without a frame ticket, such as ot_session_render, can return OT_RENDER_PENDING for earlier output without accepting new work. Draft consumption and transport completion are separate events.

Choose how to deliver the Session queue:

  • ot_session_drain_stdout writes directly from native storage to stdout and records completion.

  • ot_session_drain_output passes native storage to a synchronous writer callback and records the delivered prefix.

  • ot_session_read_output copies bytes into your buffer. Complete its ticket after your stream finishes writing.

Core uses native delivery for ordinary stdout and copied tickets for custom streams, worker stdout, and overlapped Windows stdout. All three operations preserve output order and the frame's presentation endpoint. See Output without a host copy for limits and writer rules.

When you use copied output, its ticket has a different job from the frame ticket:

┌─ Session output queue ──────────────┐
│ Bytes waiting for delivery          │
└────────────────┬────────────────────┘
                 ↓ read_output
┌─ Host-owned byte buffer ────────────┐
│ Write all ticket.byte_count bytes   │
└────────────────┬────────────────────┘
                 ↓ complete_output
┌─ Session ───────────────────────────┐
│ Record delivery                     │
│ Publish frames whose output ended   │
└─────────────────────────────────────┘

At most one output ticket can remain outstanding per Session. For a partial write, keep the ticket and the remaining bytes until delivery finishes. Complete with success = 0 if delivery fails. Failure stops transport and presentation without replay.

Earlier queued output delays a frame's presentation endpoint. Output queued after that frame does not extend its endpoint. At the endpoint, native code publishes pending hit data and frame statistics. Completion means your transport accepted delivery, not that a remote terminal visibly displayed the result.

Access the framebuffer

Paint hooks cannot read the frame: they run before native code paints it. A hook that needs to read cells draws into an offscreen buffer it owns, reads that buffer, and records a COMPOSE command.

After DONE, checked drawing operations can change the finished draft before commit, for example to apply a whole-frame effect. The Session handle and the DONE ticket identify the destination. Drawing to an independent offscreen buffer instead uses that buffer's handle and a NULL frame argument.

For an effect that reads existing cells, acquire a frame-qualified lease with ot_scene_frame_acquire_buffer_lease. It is a storage lease that also checks the DONE ticket. It accepts no other ticket. Release every frame-qualified lease before commit or another frame, including leases that became stale.

These values answer different questions:

ValuePurpose
Frame ticketWhich finished frame may this call use?
Storage leaseWhich cell allocation stays alive during this access?
Output ticketWhich copied bytes did the transport deliver?

DONE does not acquire a lease automatically. While a draft is live, ot_session_acquire_buffer_lease returns OT_FRAME_BUSY, so an ordinary storage lease cannot bypass the frame checks. Resources and ownership explains array lifetime and text-reference restrictions.

The next buffer is drawing storage, which encoding clears after use. The current buffer is encoding comparison state, not a guaranteed copy of the last presented frame.

Submit property updates

ot_scene_flush applies one bounded byte stream of property records in one call. One Context admission covers the whole call. Records still apply one at a time, as described below. Each record selects a node and the properties to change. A style record (OT_SCENE_PROPERTY_STYLE) contains one layout style operation. A visual record contains only the paint fields selected by its mask. Unselected fields keep their accepted native values.

Records apply in stream order without host callbacks between them. Each record publishes atomically, including border appearance, Yoga border widths, and a requested custom-character reset. The first rejected record stops the flush. out_applied counts complete accepted records, not bytes. The rejected record and the records after it remain unapplied. Accepted records do not roll back.

No record applies if the Context rejects the call, the input is NULL with a nonzero byte count, or the stream exceeds the byte limit. The stream limits are OT_SCENE_MUTATIONS_MAX (4,096) records and OT_SCENE_PROPERTY_BYTES_MAX bytes. At the record limit, the flush stops before record 4,097 and keeps the accepted records. Style records use 40 bytes. Visual records use 32 to 88 bytes.

Use the checked header for masks, exact sizes, padding, and field encodings. The Core driver stages and coalesces property writes before this call.

Change the scene during a frame

Updates and resize notifications run during preparation, before painting. Native code calculates Yoga layout and prepares node geometry, clipping, and paint order. If a preparation hook changes the scene, native code repeats the necessary preparation. An update hook runs at most once per node per attempt. The relative order of update and resize calls between different nodes is not specified. A node with OT_SCENE_HOOK_IDLE_UPDATE that the latest layout round prepared as visible has used up its update for that frame. If an acknowledgement then gives it OT_SCENE_HOOK_UPDATE, directly or after other hook changes, its first update request comes in the next frame.

Native code sorts a parent's children by z_index before it paints them. A z_index change or reparent in a preparation hook's acknowledgement shows in the same frame. A change in a paint hook waits for the next frame, as described below.

The host sets positive max_layout_rounds and max_host_requests limits in ot_scene_frame_options. They must stay the same for the whole attempt. If preparation needs more than max_layout_rounds rounds, the step returns OT_LAYOUT_LIMIT. If the attempt needs more than max_host_requests host requests, the step returns OT_FRAME_REQUEST_LIMIT. Update, resize, layout-changed, and RECORD requests each count as one host request. Both failures happen before painting and cancel the attempt. They do not present partial cells or undo accepted changes.

Paint hooks run after preparation and before painting. The prepared membership stays fixed, but node properties stay live:

Change in a paint hookEffect on this frame
Change a color, such as a background, border, or text colorIts drawing uses the new color, even for a node earlier in paint order
Change opacity, translation, or z_indexIt paints with its prepared values. The next frame uses the new values
Change a layout property, such as a widthIt paints with its prepared layout. The next frame uses the new layout
Insert a nodeIt waits for the next prepared frame
Destroy a node, including itselfNative code skips it, its recording, and its hit-list entry
Hide a node with display: noneIt still paints this frame. The next prepared frame drops it
Reparent or move a prepared nodeIt paints at its prepared position. The change waits a frame
Change clipping or inherited opacityPrepared clipping and opacity stay fixed

Make application changes before painting when you can. These rules keep drawing effects in paint order. They do not make the changes in a paint hook atomic.

To abandon an attempt or draft while keeping the Session, call ot_scene_frame_cancel with its frame ID. A stale frame ID returns OT_STALE_FRAME and does not cancel the active frame. Cancellation removes that frame's framebuffer access and its right to commit. It does not undo mutations or hook effects, and it does not guarantee cleared cells. Outstanding leases still need release.

Preparation and painting never yield. Each step runs until it returns a host request, DONE, or an error. While an attempt waits for a host reply, resize and suspend return OT_FRAME_BUSY.

Measure custom content

Built-in text supplies its own size to Yoga. A custom leaf node can register a measurement function with ot_scene_set_measure. This function receives size constraints and returns width and height in terminal cells.

Measurement differs from paint hooks: Yoga calls the function before the native layout call returns. It needs the result immediately, so measurement is synchronous on the Context's thread. The callback can call scene style, layout, text, and measurement-provider queries. It cannot change or destroy objects.

Keep the callback alive until replacement, removal, node destruction, or Context destruction. Setting it to NULL clears the provider. It does not restore a replaced built-in provider.

Pump terminal work and close

An interactive terminal needs setup and restoration in addition to scene painting and frame output. That work uses ot_session_pump, not the frame-step operation.

Before the first accepted frame:

  1. Create a Session with reserved control capacity and attach its renderer.

  2. Request ot_session_setup_terminal.

  3. Pump and deliver setup output until the terminal state is active.

The pump never sleeps or reads a clock. Supply monotonic nanoseconds and a positive work budget on each call. Act on its result:

Pump resultHost action
OT_PUMP_IDLEWait for application or terminal work
OT_PUMP_AGAINSchedule another pump turn
OT_PUMP_OUTPUT_PENDINGDeliver queued output until it completes
OT_PUMP_WAIT_UNTILSchedule a pump at deadline_ns
OT_PUMP_CLOSEDFinish graceful Session cleanup

Setup does not wait for terminal capability replies. Your host must still read input and forward complete replies with ot_session_control and OT_CONTROL_CAPABILITY_RESPONSE. The native Session does not own your transport or restore host-side input settings.

On graceful shutdown, call ot_session_close and continue pumping and delivering restoration output until closed. On transport loss, call ot_session_cancel. It discards pending output without claiming terminal restoration. Read Host I/O and time for the shared Session clock, image deadlines, and native file cleanup.

Reserve output storage

ot_session_options bounds the output queue:

FieldMeaning
chunk_sizePositive bytes per chunk
max_bytesPositive total chunk storage, divisible by chunk_size
span_capacityPositive count of queued spans and spans awaiting completion
control_capacityBytes reserved inside max_bytes, rounded up to whole chunks

For terminal setup, reserve at least OT_SESSION_CONTROL_PACKET_BYTES (4,096) bytes of control storage. Leave at least one ordinary chunk and one ordinary span slot. For example, four 4,096-byte chunks, four span slots, and 4,096 reserved bytes leave room for both control and ordinary output.

The Session allocates its chunks at creation. Copied but unacknowledged output still uses that capacity. Ordinary writes cannot use the control reservation. A zero reservation disables it, as in the file examples.

ot_session_get_write_limit reports the largest ordinary write that fits an empty queue, not current free space. A smaller write can still fail while other output is pending. These limits do not bound all renderer memory or encoding time.

View both frames

The C and Zig guides write two frames to hello.ansi and ready.ansi. Once you have those files, this preview displays them in sequence without replacing your normal shell screen.

Run it in Bash from the directory containing the files. Use a VT-compatible terminal with at least 40 columns and six rows. The preview opens the alternate screen and returns to the previous screen when you finish or press Ctrl+C.

(
  if [ ! -t 0 ] || [ ! -t 1 ]; then
    printf '%s\n' 'Run this preview in a terminal.' >&2
    exit 1
  fi
  trap 'printf "\033[0m\033[?25h\033[?1049l"' EXIT
  trap 'exit 130' INT
  trap 'exit 143' TERM
  printf '\033[?1049h\033[2J'
  cat hello.ansi || exit 1
  printf '\033[5;1HEnter: show Ready'
  IFS= read -r answer || exit 1
  cat ready.ansi || exit 1
  printf '\033[5;1H\033[KEnter: return to shell'
  IFS= read -r answer
)

Press Enter to change Hello to Ready, then Enter again to return to the shell. The preview writes the files' bytes unchanged. Its only additions are screen selection, prompts, and exit cleanup. It is a transport for inspecting saved frames, not the native Session's terminal setup procedure.

Changes0.8.0, 0.7.0
0.8.0
native: A box can be a native scroll node. ot_scene_set_scroll, ot_scene_scroll_to, ot_scene_get_scroll, ot_scene_scroll_request, and ot_scene_set_scroll_arrow configure, scroll, and read it, and OT_SCENE_HOOK_SCROLL asks for an OT_SCENE_FRAME_SCROLL request when its state changes. (#1798, #1811)
native: ot_scene_stats.damage_rows counts the rows that a frame diffs, and the new OT_SCENE_FULL_PATH_* bits give the reason for a full repaint. OT_SCENE_DEBUG_VERIFY compares retained frames with a full repaint. (#1783, #1808)
0.7.0
native: ot_scene_stats changes from 64 to 224 bytes, and ot_scene_get_stats returns OT_INVALID_ARGUMENT for the 64-byte record. Generate the record again from the new header. (#1722)
native: ot_scene_stats reports the work of the last presented scene frame. ot_scene_set_debug_flags() with OT_SCENE_DEBUG_FULL_FRAME forces the full frame path, and full_path_reasons uses the OT_SCENE_FULL_PATH_* bits. (#1722)
Changed ot_scene_frame_step_with_geometry.