These docs describe the main branch, including unreleased changes. Read the 0.6.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:

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.

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 kind Host 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
OT_SCENE_FRAME_YIELD (5) Schedule a later turn before continuing

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, DONE, and YIELD 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 to the hit grid. ot_scene_hit_test reads that grid 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 node with a backing buffer from ot_scene_set_image 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 outcome Meaning
OT_RENDER_PRESENTED Output was accepted and presentation is complete, including no-byte frames
OT_RENDER_PENDING This draft was accepted. Its output completion remains pending
OT_RENDER_SKIPPED Output pressure. Paint a new draft after queued output drains
OT_RENDER_FAILED The 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:

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:

Value Purpose
Frame ticket Which finished frame may this call use?
Storage lease Which cell allocation stays alive during this access?
Output ticket Which 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.

Native code sorts a parent’s children by z_index once per frame, when preparation reaches that parent. With update, resize, or layout-changed hooks, or with a Box viewport (ot_scene_set_viewport), the host can change the scene after that sort. It can do so in a hook acknowledgement or at a yield. A later z_index change or reparent in the same attempt does not sort the children again. The frame keeps the earlier order, and a node moved into that parent paints after its siblings. The next frame sorts again.

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 hook Effect on this frame
Change a color, such as a background, border, or text color Its drawing uses the new color, even for a node earlier in paint order
Change opacity, translation, or z_index It paints with its prepared values. The next frame uses the new values
Change a layout property, such as a width It paints with its prepared layout. The next frame uses the new layout
Insert a node It waits for the next prepared frame
Destroy a node, including itself Native code skips it, its recording, and its hit-grid entry
Hide a node with display: none It still paints this frame. The next prepared frame drops it
Reparent or move a prepared node It paints at its prepared position. The change waits a frame
Change clipping or inherited opacity Prepared 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.

Yield between pieces of work#

A host may need time for input or timers before a large frame finishes. The max_work_items argument to ot_scene_frame_step_with_geometry lets native code return OT_SCENE_FRAME_YIELD during preparation. It counts preparation visits, candidate-view preparation, and feedback records.

The quota is a positive unsigned 32-bit value. Zero returns OT_INVALID_ARGUMENT. Pass UINT32_MAX when you start the attempt or acknowledge a YIELD to select synchronous preparation without yields. A hook acknowledgement cannot switch a bounded attempt to synchronous preparation.

On YIELD, schedule another host turn and then acknowledge the exact request. A yield acknowledgement starts a new quota. A hook acknowledgement does not. Yield requests do not count against max_host_requests. They give no drawing or commit permission.

A host can change the scene while an attempt waits at a yield. If that change requires preparation to run again, preparation restarts once, and the rest of the attempt runs without yields. Steady changes at every turn therefore cannot exhaust max_layout_rounds.

Painting never yields. Once preparation finishes, the frame paints in one pass.

Quotas count work items, not milliseconds or cells. Yoga, allocation, sorting, final paint-list preparation, painting, and output encoding still contain synchronous work. A quota cannot interrupt a slow host hook.

If the host resizes the renderer at a YIELD and native code accepts the new size, native code cancels the attempt. Start another attempt. The accepted mutations stay in place. During a RECORD request, resize returns OT_FRAME_BUSY until the host submits the recording.

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 result Host action
OT_PUMP_IDLE Wait for application or terminal work
OT_PUMP_AGAIN Schedule another pump turn
OT_PUMP_OUTPUT_PENDING Deliver queued output until it completes
OT_PUMP_WAIT_UNTIL Schedule a pump at deadline_ns
OT_PUMP_CLOSED Finish 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:

Field Meaning
chunk_size Positive bytes per chunk
max_bytes Positive total chunk storage, divisible by chunk_size
span_capacity Positive count of queued spans and spans awaiting completion
control_capacity Bytes 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.6.0
0.6.0
Added OT_BUFFER_DRAW_CLEAR, OT_CONTROL_CAPABILITY_RESPONSE, OT_FRAME_REQUEST_LIMIT, OT_LAYOUT_LIMIT, OT_PUMP_AGAIN, OT_PUMP_CLOSED and 55 more.