Ports renderer/{generic,cell,row,link,State,Overlay,image,Thread}.zig:
- generic.rs builds GPU cell buffers from terminal RenderState each
frame: backgrounds, shaped text runs with glyph atlases and nerd font
constraints, all cursor styles, decorations via sprites, selection and
search colors, min-contrast, bold/faint/blink handling, preedit,
padding extension, background image, custom shader uniforms, link
hover underlines, scrollbar and health reporting.
- The renderer thread updates CPU-side state only and asks the host to
redraw; the app runtime calls draw_frame from the GLArea render signal
(the GL context lives on the GTK thread, as in Ghostty).
- ghostty_renderer::state::{State, SharedState} is the canonical shared
renderer state (termio's interim copy will switch to it).
Deviations: overlay uses a direct RGBA rasterizer instead of z2d; bg
images decode with png/jpeg-decoder instead of wuffs; zero-length link
matches advance by one char (Zig would loop forever).
Kitty image rendering is implemented in image::State but not yet called
from generic::update_kitty (raced with the terminal-side wiring).
Test Plan:
- cargo test -p ghostty-renderer (69 unit + 9 headless EGL tests that
render a Terminal through the full pipeline and check pixels)
11 KiB
11 KiB
ghostty-renderer — R2 (generic renderer, cells, links, images, overlay, state, thread)
Builds on R1 (ghostty-renderer-R1.md: OpenGL backend, shaders, shadertoy, size, cursor).
Deps re-added: ghostty-terminal, ghostty-config, ghostty-input, ghostty-unicode, ghostty-vt,
ghostty-datastruct (mailbox queue), crossbeam-channel, onig (link regexes), png + jpeg-decoder
(background image; replaces wuffs). No new unsafe (still only opengl::gl).
Module map (Zig -> Rust, crates/ghostty-renderer/src/)
| Zig | Rust | notes |
|---|---|---|
| generic.zig | generic.rs |
Renderer, DerivedConfig, Host trait, Error, NullHost, update_frame, draw_frame, rebuild_cells/row, cursor, preedit, decorations, custom-shader uniforms, bg image |
| renderer.zig | lib.rs |
pub type Renderer = generic::Renderer (OpenGL), DerivedConfig, Thread, Message, State re-exports |
| cell.zig | cell.rs |
Contents, Key, is_covering, is_symbol, constraint_width, no_min_contrast |
| row.zig | row.rs |
never_extend_bg |
| link.zig | link.rs |
Set::from_config, Set::render_cell_map (onig, uses RenderState::string + byte map) |
| State.zig | state.rs |
State, SharedState = Arc<Mutex<State>>, Mouse, Preedit(+range) |
| Overlay.zig | overlay.rs |
CPU RGBA canvas instead of z2d (only axis-aligned rects) |
| image.zig | image.rs |
State, Image state machine, Placement, Id, Pending, kitty placement/virtual placement building |
| Thread.zig | thread.rs |
Thread, Handle, Running, Mailbox, DerivedConfig |
| message.zig | message.rs |
stubs from R1 replaced by real types |
| cursor.zig | cursor.rs |
now takes &RenderState; the 4 Zig tests drive a real Terminal |
| Options.zig | options.rs |
Options<C>{config, font_grid, size, host} |
How the core Surface creates and drives the renderer
use ghostty_renderer::{generic::{Host, Renderer, DerivedConfig}, options::Options, opengl::OpenGL,
state::State, thread::Thread, message::Message};
// 1. In the GLArea `realize` handler (GL context current on the GTK thread):
let mut api = OpenGL::init(config.alpha_blending);
api.prepare_context(|name| gl_get_proc_address(name))?; // Zig Renderer.surfaceInit
let (key, grid) = font_set.ref_(&font_derived_config, font_size)?; // SharedGridSet
let renderer = Arc::new(Renderer::new(
Options { config: DerivedConfig::new(&config)?, font_grid: grid, size, host: host.clone() },
api)?); // creates buffers/textures/shaders: GL thread only
// 2. Shared state (replaces Zig's `renderer.State` + `renderer_state.mutex`):
let state: SharedState = State::new(terminal).into_shared(); // termio locks it to write the terminal
// state.preedit / state.mouse / state.overlay_features are set by the Surface under the same lock.
// 3. Thread:
let thread = Thread::new(&config, renderer.clone(), state.clone(), host.clone());
let running = thread.spawn()?; // `running.handle` is Clone + Send
let handle = running.handle.clone();
Host(implemented by core/apprt,Send + Sync) is the "surface mailbox":scrollbar(Scrollbar) -> bool(instant push; return false if full, the renderer retries next frame),renderer_health(Health), andredraw_surface() -> bool(renderer thread -> apprt: "please calldraw_frameon the main thread"). Implementations must not block:scrollbaris called fromdraw_framewith the draw mutex held.generic::NullHostdiscards everything.- Wake the thread after changing terminal state (termio does
handle.wakeup()after parsing, Zigrenderer_thread.wakeup.notify()). - Messages:
handle.send(Message::..)= push(forever) + wakeup;handle.push(msg, Timeout)without wakeup.Focus(bool),Visible(bool),ResetCursorBlink,Resize(Size),FontGrid(Box<FontGridChange{grid, set, new_key, old_key}>)(thread callsset.deref(&old_key)after adopting),ChangeConfig(Message::init_change_config(&Config)?builds both derived configs),SearchViewportMatches(Vec<Flattened>),SearchSelectedMatch(Option<Flattened>),Inspector(bool),Crash(panics on purpose).Handle::draw_now(),Handle::stop(). - GTK drawing: in the GLArea
rendersignal handler callrenderer.draw_frame(false)(true= sync; the apprt/Surface usestrueright after init and on resize like ZigSurface.zig:869). In theredraw_surfacehost callback callglarea.queue_render(). On GLAreaunrealizecallrenderer.display_unrealized()(context current) and on the nextrealizerenderer.display_realized(loader); then draw withsync = true. - Shutdown:
running.stop_and_join()(thread drops itsArc<Renderer>), then drop the lastArc<Renderer>on the GL thread with the context current (it deletes GL objects);font_set.deref(&key). - Debug/screenshot:
renderer.last_frame_pixels()(RGBA8, bottom-up) reads the last presented target.
Threading model (documented in generic.rs and thread.rs module docs)
GTK GLArea's context is only current on the main thread, so (as in Ghostty, must_draw_from_app_thread = true):
- renderer thread: owns timers (cursor blink 600ms; 8ms draw timer only when custom shaders animate), drains the mailbox,
and per wakeup runs
Renderer::update_frame(lockSharedState,RenderState::update, OSC8/regex links, search highlights, overlay, shaping with the harfbuzz shaper + cache, glyph rendering into the font atlases viaSharedGrid, rebuild CPUcell::Contents, uniforms) and thenHost::redraw_surface(). It performs no GL calls. - main thread:
Renderer::draw_frameuploads cells/uniforms/atlases/images/bg image, runs the render passes (bg image or bg color, kitty below-bg, cell bg, kitty below-text, text, kitty above-text, overlay, custom shader post passes),glFinishs and presents. - State split (no unsafe):
draw: Mutex<DrawState>(Zig draw_mutex: whatdraw_framereads) andthread: Mutex<ThreadState>(RenderState, shaper, cache, overlay, search; uncontended, only there forSync). Lock order:SharedState(terminal) is never held while waiting forthread;thread->draw-> font grid lock.Thread::draw_on_app_thread = falsemakes the thread calldraw_frameitself (needs the context current on that thread; used for headless use). - The swap chain keeps Zig's structure (
SWAP_CHAIN_COUNT = 1) minus the semaphore: frames complete synchronously and the draw mutex serializes.
Tests
- Ported: cell.zig 6/6 (
Contents,clear retains other content,clear last added content,zero-sized screen,Cell constraint widthsnow with real Terminal+RenderState; +resetand helper tests), link.zig 3/3, cursor.zig 4/4 (real Terminal) + password test, row.zig (none upstream; 4 added), State.zig (none upstream; 4 preedit tests added), Overlay.zig (none upstream; 4 added), image.zig (none upstream; 7 added incl. a realImageStorage->kitty_updatetest), generic.zig (none upstream; 6 added: derived config, color resolution, bold policy, file types, PNG bg image round trip), thread.rs (3). Totals: 69 unit tests + 9 integration tests intests/egl_headless.rs(3 from R1), all pass. - New EGL integration tests (surfaceless Mesa GL 4.3): full path Terminal -> RenderState ->
Renderer-> offscreen target ->last_frame_pixels: exact bg color at all four padding corners and the side padding, red glyph pixels in "hello", non-bg glyph pixels, exact palette color for cell backgrounds (SGR 42), block cursor drawn in the foreground color, blink-off hides the cursor, incremental row rebuild, underline sprite drawn, resize/mark_dirty, unrealize ->Defunct-> realize -> draw again, and a realThread(initial wakeup renders and requests a draw, Focus message -> hollow cursor outline with empty center, stop/join). - Skipped: none of the Zig tests (Metal-only/allocator-failure ones don't exist in these files).
Deviations
- Run iterator wants
&[RunCell](ownedVec<u32>graphemes); each rebuilt row copiesRenderStatecells into a reusable scratchVec<RunCell>(only cells with graphemes allocate). Shaped cells are copied from the shaper/cache into a scratch vec. renderer.State: one mutex-protected struct holdingterminal,preedit,mouse,overlay_features(inspector deferred:inspector: ?*Inspectorreplaced byoverlay_features). Zig*Mutex+ pointers ->SharedState.- Overlay: no z2d; direct RGBA rasterization (fill rect + 1px hairline border, src-over). Hairline placement is approximate.
- Background image decoding:
png/jpeg-decoderinstead of wuffs (png/jpeg only, same as Zig; other types warn). - Zig
cursor_text.color/selection_*.colorcustom-shader uniforms read the union payload unconditionally (invalid forcell-*); Rust only sets them for explicit colors. - Zero-length regex matches advance by one character (Zig would loop forever).
- Atlas textures in
FrameStatestart 2x2 (not 1x1:Atlas::newkeeps a 1px border); replaced on first sync. opengl::AlphaBlendingis nowghostty_config::types_b::AlphaBlending(R1 stub removed).message.rs: real derived-config types.display_unrealizedalso resets the image state and bg image (their textures die with the GL context); forces a kitty update.Host::renderer_healthis called while holding the draw mutex (Zig pushes.forevertoo); keep it non-blocking.- macOS-only paths (display link, glass blur, QoS) omitted.
TODO(integration)
- kitty graphics:
Screen.kitty_imagesis still the placeholderKittyImages { dirty }in ghostty-terminal.image::State::kitty_update(&ImageStorage, &PageList, &TerminalInfo, CellSize)is implemented and tested against the real storage, butgeneric::update_kittycannot call it yet; once K1's step 1 lands (screen.rs holdsImageStorage), replace the body with the call and clearstorage.dirty. Until then images never appear. - Unify
RendererStatewithghostty-termio::state(INTEGRATION_TODO): the canonical type is nowghostty_renderer::state::State/SharedState. ghostty_font::shared_grid::CellSizestill stands in forrenderer::CellSize(converted by hand at the call site).- apprt:
surface::Scrollbar/RendererHealthstand-ins should be replaced byghostty_terminal::page_list::Scrollbarandghostty_renderer::Health(whatHostuses). - apprt-gtk: GLArea render handler must set the GL viewport (the renderer reads
GL_VIEWPORTfor the surface size, R1) and the present blit goes to the framebuffer bound as draw FBO whendraw_frameis called (GLArea binds its own FBO beforerender). - Inspector (Phase 7) fills
State::overlay_features;Message::Inspectoronly records a flag.