libghostty-vt 0.0.0
Loading...
Searching...
No Matches
Rendering

A render state is a copy of everything you need to draw one frame: the rows, the cells, the cursor, and the colors.

Why a copy? Your renderer and your IO are usually on different threads. The terminal changes every time the program writes a byte, and you can't draw from something that is changing underneath you. You could hold a lock for the whole frame, but then the program stalls while you draw.

With a render state you take the lock, update, and let go. Everything after that reads only from the render state.

It's also built for being updated over and over from the same terminal. An update only redoes what changed, and it tells you what changed so you can redraw only that.

If you only want the screen as text, you don't need this page. Use the Formatter instead. Images have their own guide, Images.

Drawing a frame

There are three steps:

  1. Create an empty render state.
  2. Update it from a terminal whenever you need a new frame.
  3. Read from it to draw.
// Create a terminal and render state, then update the render state
// from the terminal. The render state captures a snapshot of everything
// needed to draw a frame.
GhosttyTerminal terminal = NULL;
result = ghostty_terminal_new(NULL, &terminal, 40, 5);
assert(result == GHOSTTY_SUCCESS);
GhosttyRenderState render_state = NULL;
result = ghostty_render_state_new(NULL, &render_state);
assert(result == GHOSTTY_SUCCESS);
// Feed some styled content into the terminal.
const char* content =
"Hello, \033[1;32mworld\033[0m!\r\n" // bold green "world"
"\033[4munderlined\033[0m text\r\n" // underlined text
"\033[38;2;255;128;0morange\033[0m\r\n"; // 24-bit orange fg
terminal, (const uint8_t*)content, strlen(content));
// Select "underlined" on the second row. Render state exposes this
// later as a row-local selected cell range.
GhosttyGridRef selection_start = {0};
GhosttyPoint selection_start_pt = {
.value = { .coordinate = { .x = 0, .y = 1 } },
};
terminal, selection_start_pt, &selection_start);
assert(result == GHOSTTY_SUCCESS);
GhosttyGridRef selection_end = {0};
GhosttyPoint selection_end_pt = {
.value = { .coordinate = { .x = 9, .y = 1 } },
};
result = ghostty_terminal_grid_ref(terminal, selection_end_pt, &selection_end);
assert(result == GHOSTTY_SUCCESS);
GhosttySelection selection = {0};
selection.start = selection_start;
selection.end = selection_end;
terminal, GHOSTTY_TERMINAL_OPT_SELECTION, &selection);
assert(result == GHOSTTY_SUCCESS);
result = ghostty_render_state_update(render_state, terminal);
assert(result == GHOSTTY_SUCCESS);

Full program: c-vt-render/src/main.c

Now read what you need. The colors come as one struct:

// Retrieve colors (background, foreground, palette) from the render
// state. These are needed to resolve palette-indexed cell colors.
render_state, GHOSTTY_RENDER_STATE_DATA_COLORS, &colors);
assert(result == GHOSTTY_SUCCESS);
printf("Background: #%02x%02x%02x\n",
colors.background.r, colors.background.g, colors.background.b);
printf("Foreground: #%02x%02x%02x\n",
colors.foreground.r, colors.foreground.g, colors.foreground.b);

Full program: c-vt-render/src/main.c

So does the cursor:

// Read all cursor state in one call.
render_state, GHOSTTY_RENDER_STATE_DATA_CURSOR, &cursor);
assert(result == GHOSTTY_SUCCESS);
if (cursor.visible && cursor.viewport_has_value) {
const char* style_name = "unknown";
switch (cursor.visual_style) {
style_name = "bar";
break;
style_name = "block";
break;
style_name = "underline";
break;
style_name = "hollow";
break;
}
printf("Cursor at (%u, %u), style: %s\n",
cursor.viewport_x, cursor.viewport_y, style_name);
}

Full program: c-vt-render/src/main.c

Rows and cells have their own iterators. Row and cell data is only valid until the next update. It's unsafe to use after that.

// Iterate rows via the row iterator. For each dirty row, iterate its
// cells, read codepoints/graphemes and styles, and emit ANSI-colored
// output as a simple "renderer".
result = ghostty_render_state_row_iterator_new(NULL, &row_iter);
assert(result == GHOSTTY_SUCCESS);
render_state, GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR, &row_iter);
assert(result == GHOSTTY_SUCCESS);
result = ghostty_render_state_row_cells_new(NULL, &cells);
assert(result == GHOSTTY_SUCCESS);
uint16_t row_y = 0;
printf("Row %2u [dirty]: ", row_y);
// Query the row-local selection range. Rows without a selection return
// GHOSTTY_NO_VALUE; selected rows return inclusive start/end columns.
row_iter, GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION, &row_selection);
assert(result == GHOSTTY_SUCCESS || result == GHOSTTY_NO_VALUE);
if (result == GHOSTTY_SUCCESS) {
printf("selection=%u..%u ",
row_selection.start_x, row_selection.end_x);
}
// Get cells for this row (reuses the same cells handle).
assert(result == GHOSTTY_SUCCESS);
// Get the grapheme length; 0 means the cell is empty.
uint32_t grapheme_len = 0;
&grapheme_len);
if (grapheme_len == 0) {
putchar(' ');
continue;
}
// Read the style for this cell. Returns the default style for
// cells that have no explicit styling.
GhosttyStyle style;
// Resolve foreground color for this cell.
resolve_color(style.fg_color, &colors, colors.foreground);
// Emit ANSI true-color escape for the foreground.
printf("\033[38;2;%u;%u;%um", fg.r, fg.g, fg.b);
if (style.flags & GHOSTTY_STYLE_FLAG_BOLD) printf("\033[1m");
if (GHOSTTY_STYLE_UNDERLINE(style)) printf("\033[4m");
// Read grapheme codepoints into a buffer and print them. If the
// cell has more than 16 codepoints, only the first 16 are written
// and graphemes.len holds the full count, so print what fits.
uint32_t codepoints[16];
GhosttyCodepointBuffer graphemes = {
.ptr = codepoints,
.cap = sizeof(codepoints) / sizeof(codepoints[0]),
};
&graphemes);
size_t len = graphemes.len < graphemes.cap ? graphemes.len : graphemes.cap;
for (size_t i = 0; i < len; i++) {
// Simple ASCII print; a real renderer would handle UTF-8.
if (codepoints[i] < 128)
putchar((char)codepoints[i]);
else
printf("U+%04X", codepoints[i]);
}
printf("\033[0m"); // Reset style after each cell.
}
printf("\n");
}

Full program: c-vt-render/src/main.c

Two-phase updates

ghostty_render_state_update() needs exclusive access to the terminal for the whole call. If a renderer thread shares a lock with an IO thread, you can hold that lock for less time by splitting the update in two.

ghostty_render_state_begin_update() needs the terminal. ghostty_render_state_end_update() finishes any work that was deferred, and it only touches memory the render state owns. So lock, begin, unlock, and then end while the IO thread goes back to modifying the terminal:

lock(&terminal_lock);
unlock(&terminal_lock);
GHOSTTY_API GhosttyResult ghostty_render_state_begin_update(GhosttyRenderState state, GhosttyTerminal terminal)
GHOSTTY_API GhosttyResult ghostty_render_state_end_update(GhosttyRenderState state)

Every begin needs an end before you read the render state, because it's incomplete until then. Calling end without a begin is a safe no-op. ghostty_render_state_update() is the same as calling the two back to back.

Dirty tracking

Most frames change very little, so the render state tracks what changed and you redraw only that.

There are two independent layers. A global state says if the frame is clean, partially dirty or fully dirty. A flag on each row says which rows changed in a partially dirty frame.

// Check the global dirty state to decide how much work the renderer
// needs to do. After rendering, reset it to false.
render_state, GHOSTTY_RENDER_STATE_DATA_DIRTY, &dirty);
assert(result == GHOSTTY_SUCCESS);
switch (dirty) {
printf("Frame is clean, nothing to draw.\n");
break;
printf("Partial redraw needed.\n");
break;
printf("Full redraw needed.\n");
break;
}

Full program: c-vt-render/src/main.c

An update only ever sets dirty state, it never clears it. That's your job. Once you've drawn a complete frame, ghostty_render_state_clean() clears both layers in one call:

// After successfully rendering the complete frame, clear both the global
// and per-row dirty state in one call.
result = ghostty_render_state_clean(render_state);
assert(result == GHOSTTY_SUCCESS);

Full program: c-vt-render/src/main.c

If you only use part of a frame, clear what you used with ghostty_render_state_set() and ghostty_render_state_row_set() instead. Be careful here, because clearing one layer doesn't clear the other. Setting the global state to false leaves every row flag as it was, so you have to manage both.

Render holds

A render hold is the time between a program asking the terminal to stop updating the screen and letting it update again.

Programs use holds to avoid flicker. A full-screen program usually redraws in several steps: clear, draw the text, move the cursor. If you draw halfway through, the person using your terminal sees a broken frame. So the program starts a hold, draws everything, and releases it. The screen should keep showing the last finished frame the whole time and then switch to the new one all at once.

Today the only way to start a hold is synchronized output (DEC private mode 2026, GHOSTTY_MODE_SYNC_OUTPUT). The callback is named for what you should do rather than for that mode, so that other sources of holds can be added later.

ghostty_render_state_update() doesn't check for any of this. It always captures the terminal as it is right now, so a renderer that updates on every draw shows half-drawn frames. To avoid that:

  1. Set a GHOSTTY_TERMINAL_OPT_RENDER_HOLD callback on the terminal.
  2. When a hold begins, update the render state from inside the callback. At that moment the terminal holds exactly the frame the program wants left on screen. Nothing after the start of the hold has been processed yet, even if more bytes follow in the same ghostty_terminal_vt_write().
  3. Don't update the render state again until the hold ends. You can keep drawing it in the meantime, and it won't change.
  4. End the hold yourself if it lasts too long.
typedef struct {
GhosttyRenderState render_state;
bool held;
uint64_t hold_started_ms;
} Renderer;
void on_render_hold(GhosttyTerminal terminal, void* userdata, bool held) {
Renderer* r = userdata;
if (held) {
// Capture the frame the program wants left on screen.
ghostty_render_state_update(r->render_state, terminal);
r->hold_started_ms = now_ms();
}
r->held = held;
}
void draw(Renderer* r, GhosttyTerminal terminal) {
// Give up on a program that holds the screen for too long.
if (r->held && now_ms() - r->hold_started_ms >= 1000) {
.value = false,
};
r->held = false;
}
// During a hold, skip the update and draw the captured frame.
if (!r->held) ghostty_render_state_update(r->render_state, terminal);
draw_render_state(r->render_state);
}
#define GHOSTTY_MODE_SYNC_OUTPUT
Definition modes.h:89
GHOSTTY_API GhosttyResult ghostty_render_state_update(GhosttyRenderState state, GhosttyTerminal terminal)
struct GhosttyRenderStateImpl * GhosttyRenderState
Definition types.h:252
GHOSTTY_API GhosttyResult ghostty_terminal_set(GhosttyTerminal terminal, GhosttyTerminalOption option, const void *value)
struct GhosttyTerminalImpl * GhosttyTerminal
Definition types.h:196
@ GHOSTTY_TERMINAL_OPT_MODE
Definition terminal.h:2440

You need the timeout. The terminal has no clock, so it never ends a hold on its own, and a program that crashes or forgets to release its hold would freeze your screen forever. One second is a common limit. When it expires, reset the mode yourself and go back to updating normally. Setting the mode again during a hold does nothing, so a program can't keep pushing your deadline back.

Why a callback? You could check GHOSTTY_MODE_SYNC_OUTPUT before each draw and skip the update when it's set. That's simpler, but it has two problems. First, the frame left on screen is whatever you happened to draw last, which can be older than what the program intended or even half-drawn. Second, if the program releases a hold and starts the next one between two of your draws, you never see the mode turn off, and the finished frame in between is lost. A program that draws continuously can then look frozen. Capturing the frame when each hold begins avoids both.

You're free to ignore a hold whenever live content matters more, such as when someone scrolls or starts a selection.

Like every callback, this one runs on the thread that called ghostty_terminal_vt_write(). If another thread draws the render state, protect the update in the callback the same way you protect any other access to it.

Overscan

By default, a render state captures exactly the rows visible in the viewport. That's all you need if you draw whole rows, so most programs can skip this section.

Some renderers draw the grid shifted by a fraction of a row, usually to scroll smoothly. While the grid is shifted, part of a row just outside the viewport shows at one edge, and you need that row's content to draw it. Overscan captures extra rows above and below the viewport for this.

Request it with GHOSTTY_RENDER_STATE_OPT_OVERSCAN. The request applies to every update after you set it, and you should expect a full redraw on the update after a change. The row iterator then visits the extra rows along with the viewport, from top to bottom. GHOSTTY_RENDER_STATE_ROW_DATA_VIEWPORT_Y tells you where each row belongs: rows above the viewport are negative, viewport rows are 0 through rows - 1, and rows below the viewport start at rows.

Extra rows are only captured when they exist. There's nothing above the first line of scrollback, and there's nothing below the viewport while it's scrolled to the bottom, which is the usual case. After an update, GHOSTTY_RENDER_STATE_DATA_OVERSCAN reports how many rows were actually captured on each side. Don't shift the grid toward a side where nothing was captured.

Extra rows carry the same data as viewport rows, including cells, styles, dirty flags and selection. The cursor is only reported when it's inside the viewport.

The render state doesn't store the scroll position. If you need it, read GHOSTTY_TERMINAL_DATA_SCROLLBAR or GHOSTTY_TERMINAL_DATA_VIEWPORT_ACTIVE with ghostty_terminal_get() at the same time as you call ghostty_render_state_begin_update(), while you have exclusive access to the terminal. The values then describe the same moment as the render state.

This draws one frame of a smooth scroll. offset_px comes from your own scroll animation. It's how far the grid is shifted up, from zero up to but not including one row height.

// Once, when setting up the render state. One row below the viewport
// is enough to draw the partially visible row at the bottom edge.
GhosttyRenderStateOverscan request = { .above = 0, .below = 1 };
&request);
// Each frame.
ghostty_render_state_update(state, terminal);
&captured);
// With no row below the viewport, there is nothing to scroll into.
if (captured.below == 0) offset_px = 0;
&rows);
int32_t y;
draw_row(rows, y * cell_height - offset_px);
}
GHOSTTY_API GhosttyResult ghostty_render_state_set(GhosttyRenderState state, GhosttyRenderStateOption option, const void *value)
GHOSTTY_API GhosttyResult ghostty_render_state_get(GhosttyRenderState state, GhosttyRenderStateData data, void *out)
GHOSTTY_API bool ghostty_render_state_row_iterator_next(GhosttyRenderStateRowIterator iterator)
GHOSTTY_API GhosttyResult ghostty_render_state_row_get(GhosttyRenderStateRowIterator iterator, GhosttyRenderStateRowData data, void *out)
@ GHOSTTY_RENDER_STATE_ROW_DATA_VIEWPORT_Y
Definition render.h:478
@ GHOSTTY_RENDER_STATE_DATA_OVERSCAN
Definition render.h:188
@ GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR
Definition render.h:128
@ GHOSTTY_RENDER_STATE_OPT_OVERSCAN
Definition render.h:242

Row identity

Every row has an id, GHOSTTY_RENDER_STATE_ROW_DATA_ID, that stays with the row as it moves. When the viewport scrolls by one row, each row shows up one position higher or lower in the next update but keeps its id. Ids work with or without overscan.

Ids let you keep expensive per-row work, such as shaped text or a prepared texture, in your own cache keyed by id. You can reuse a cached entry when both of these are true:

  1. A row with the same id is in the new update.
  2. That row's dirty flag isn't set.

The dirty flag is conservative. A row can be marked dirty even though its content didn't change. For example, every row is currently marked dirty after the viewport scrolls. Rebuilding a dirty row is always correct.

An id disappears when its row is no longer captured, is removed from scrollback, or is changed in place by the terminal, for example when a program scrolls only part of the screen. Ids are never reused, so an old id can never match a different row. You can throw away cache entries for ids that no longer appear.

bool dirty;
// `cache` is your own map from row id to prepared row.
PreparedRow* prepared = dirty ? NULL : cache_find(cache, id);
if (prepared == NULL) prepared = cache_prepare(cache, id, rows);
draw_prepared_row(prepared);
}
@ GHOSTTY_RENDER_STATE_ROW_DATA_ID
Definition render.h:483
@ GHOSTTY_RENDER_STATE_ROW_DATA_DIRTY
Definition render.h:443