Skip to content
Merged
1 change: 1 addition & 0 deletions NEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ thread. New tab and split surfaces do not inherit the IO fields.
| embedded: a host-size present that never waits | `ghostty_surface_present_host_frame_ready` is `ghostty_surface_present_host_frame` without the bounded wait (`Metal.presentHostSizedWait(false)`): it presents the renderer thread's frame at the host size in the caller's Core Animation transaction only if that frame is already published, otherwise it keeps the last frame (counted in `host_late_presents`) and the new frame lands through `setSurface` when it completes. cmux-next measured the waiting present at up to 4 ms of main per drag frame (main p95 20.5 -> 24.1 ms, more dropped frames), so it calls only this one, from one observer just before the commit. |
| renderer: a content-only frame goes to the screen without the main thread | A frame of a host-sized surface whose drawable size is the host size and equals the frame the layer shows (no geometry change pending) is presented from the GPU completion thread in its own `CATransaction` (`IOSurfaceLayer.setSurfaceOffMain`), not by a `dispatch_async` to main: a terminal echo reaches the screen while the app's main thread is busy (cmux-next G9). A frame for a new size still goes through main with its geometry. Frames are numbered in completion order (`Metal.present_seq`) and both paths check the number under the Core Animation lock, so a late main-thread present never replaces a newer frame. Counted in `host_off_main_presents`. Covered by cmux-next `TerminalEchoOffMainTests`. |
| input: one compiled link regex per pattern for the whole process | `input/link_regex.zig`: surfaces and their renderers share each compiled link regex, counted by its users; up to 16 unused patterns stay compiled. A new surface compiled the URL regex twice on the main thread (Surface `DerivedConfig` and the renderer `link.Set`, about 0.5 ms each: cmux workspace switch trace, 6.3 ms of `onig_compile` per switch). Oniguruma 6.9.9 search never writes to a `regex_t`, so one regex serves every thread. |
| renderer + embedded: the embedder presents a new host size with its bounds | `ghostty_surface_set_host_present(surface, GHOSTTY_HOST_PRESENT_EMBEDDER)` (macOS, host-sized surfaces; default `GHOSTTY_HOST_PRESENT_RENDERER`): the renderer still draws each new host size at once with cells rebuilt for it and keeps that frame (`Metal.fresh`, with its present sequence), but never shows a frame of another size than the shown one itself: `setHostDrawableSize` asks for no display pass, a display pass never waits (`displayWhileEmbedderPresents`), and `setSurfaceCallback` refuses such a frame unless the layer's bounds already have its size. `ghostty_surface_host_frame(surface, present)` (`Metal.hostFrame`) answers NONE, HIDDEN, SHOWN, READY, PRESENTED, PENDING or HELD; with `present` a READY frame becomes the layer's contents in the caller's Core Animation transaction (`IOSurfaceLayer.setSurfacePresented`, ordered by the present sequence, so a late main-thread frame never replaces it). cmux-next (frame owner refactor S4) changes a terminal view's bounds only in the transaction that shows the frame at the new size, or keeps both: before, a new-size frame landed in the old bounds 2-4 refreshes after the geometry. Frames at the shown size still go off main, holding the Core Animation lock across their commit, except from the mode's start and after a main-thread change of the shown size until the embedder reports its commit (`ghostty_surface_host_frame_committed`: Ghostty cannot see the main thread's commit); a first frame presents itself; `ghostty_surface_present_host_frame(_ready)` follow the same ordered present in this mode. Tests: `host present:` in `renderer/Metal.zig` and `renderer/metal/IOSurfaceLayer.zig` (real layer and IOSurfaces, no GPU). |

Next in the stack (tracked in the design): presentation callbacks for
frame-exact acknowledgment, Kitty image replay after a snapshot, and a
Expand Down
88 changes: 88 additions & 0 deletions include/ghostty.h
Original file line number Diff line number Diff line change
Expand Up @@ -1492,6 +1492,94 @@ GHOSTTY_API bool ghostty_surface_present_host_frame(ghostty_surface_t);
// the last frame (counted in host_late_presents) and the new frame lands when
// it completes. Never blocks the main thread.
GHOSTTY_API bool ghostty_surface_present_host_frame_ready(ghostty_surface_t);
// macOS, host-sized surfaces: who shows the frame of a new host size.
typedef enum {
// The renderer (default): see ghostty_surface_set_host_sized.
GHOSTTY_HOST_PRESENT_RENDERER = 0,
// The embedder, with ghostty_surface_host_frame, in the Core Animation
// transaction that gives the view its new bounds, so a commit never
// carries new pixels in the old bounds or old pixels in the new bounds.
// A new size asks for no display pass and a display pass never waits.
// The renderer still draws each new size at once on its renderer thread
// (cells rebuilt for it, the reflow preview included) and keeps that frame
// for the embedder, but never shows a frame of another size than the one
// the layer shows, unless the view's bounds already have that size (the
// embedder set them without a present). A frame at the shown size still
// reaches the screen off the main thread, and a first frame (nothing
// shown yet) presents itself. ghostty_surface_draw (a synchronous draw the
// embedder asks for) still shows its frame at once; it and the first
// frame's display pass wait on the main thread for a free frame and the
// GPU, as they do by default. In this mode
// ghostty_surface_present_host_frame(_ready) are ghostty_surface_host_frame
// with present true (no wait). From the start of the mode, and after the
// main thread changed the size of the frame the layer shows (a present, a
// first frame, a new-size frame shown on main), every frame goes through
// the main thread until ghostty_surface_host_frame_committed: Ghostty cannot
// see the main thread's commit, and a frame committed off the main thread
// could reach the screen ahead of the view's new bounds. An off-main commit
// holds [CATransaction lock] across its commit. Going back to the renderer
// shows a frame drawn for the host size at once.
GHOSTTY_HOST_PRESENT_EMBEDDER = 1,
} ghostty_host_present_e;
// Default GHOSTTY_HOST_PRESENT_RENDERER. No effect on iOS or on a surface
// that is not host-sized. Main thread.
GHOSTTY_API void ghostty_surface_set_host_present(ghostty_surface_t,
ghostty_host_present_e);
// What ghostty_surface_host_frame found for the host size (the size of the
// last ghostty_surface_set_size).
typedef enum {
// Nothing to keep: not host-sized, iOS, or no frame shown yet (the first
// frame presents itself). Give the view its new bounds now.
GHOSTTY_HOST_FRAME_NONE = 0,
// The surface does not draw (occluded). No frame comes until it is shown
// again (ghostty_surface_set_occlusion true), which arms the present for
// its host size.
GHOSTTY_HOST_FRAME_HIDDEN = 1,
// The layer shows a frame at the host size: nothing is left to present.
GHOSTTY_HOST_FRAME_SHOWN = 2,
// present false: the renderer drew the host size's frame; it is not shown
// yet. A call with present true shows it.
GHOSTTY_HOST_FRAME_READY = 3,
// present true: that frame is the layer's contents now, in the caller's
// Core Animation transaction. Give the view its new bounds in the same
// transaction.
GHOSTTY_HOST_FRAME_PRESENTED = 4,
// Not drawn yet: the layer keeps the frame it shows. Keep the view's old
// bounds and call again before a later commit.
GHOSTTY_HOST_FRAME_PENDING = 5,
// Frame updates are held (synchronized output, a snapshot restore): no
// frame can come now, and the layer keeps the frame it shows.
GHOSTTY_HOST_FRAME_HELD = 6,
} ghostty_host_frame_e;
// macOS, a surface whose new host sizes the embedder presents
// (ghostty_surface_set_host_present): what it can show for its host size.
// With present false the layer does not change (a query: for example to
// present a group of surfaces together or not at all). With present true,
// a READY frame becomes the layer's contents in the caller's Core Animation
// transaction (PRESENTED); call it after every surface of the transaction
// got its size (their renderers draw in parallel) and before the commit, for
// example from a layer that Core Animation displays in that commit. A frame
// that reaches the main thread later never replaces a newer one. Never waits
// and never draws. Counted in host_present_calls (present true),
// host_fresh_presents (PRESENTED) and host_late_presents (PENDING or HELD
// with present true). A view whose bounds were given without a present
// shows the new frame when it is drawn; until then, or while the bounds stay
// old, call again until it returns SHOWN, NONE or HIDDEN. READY does not
// reserve the frame: a hide can drop it before the present (HIDDEN then).
// Call it with present true, and give the view its bounds, while holding
// [CATransaction lock], or inside a Core Animation layout or display pass of
// the commit (which holds it): then no frame commits off the main thread in
// between, for this surface or another one in the window. Main thread.
GHOSTTY_API ghostty_host_frame_e ghostty_surface_host_frame(ghostty_surface_t,
bool present);
// The embedder's transaction that carried its last present or bounds change
// was committed (for example: called after each commit while the embedder
// presents host sizes; a call with nothing pending does nothing). Frames at
// the shown size go to the screen off the main thread again, which keeps a
// terminal echo off the main thread. Without the call they go through main
// (correct, slower). No effect outside GHOSTTY_HOST_PRESENT_EMBEDDER. Main
// thread.
GHOSTTY_API void ghostty_surface_host_frame_committed(ghostty_surface_t);
GHOSTTY_API ghostty_surface_size_s ghostty_surface_size(ghostty_surface_t);
// Fills the grid metrics. Returns false while a resize of an unlocked grid
// is in flight; with a host-locked grid (ghostty_surface_set_grid) the
Expand Down
2 changes: 2 additions & 0 deletions src/apprt.zig
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ pub const SurfaceIoMode = structs.SurfaceIoMode;
pub const SurfaceGrid = structs.SurfaceGrid;
pub const SurfaceSnapshotPhase = structs.SurfaceSnapshotPhase;
pub const SurfaceLocalHistoryResult = structs.SurfaceLocalHistoryResult;
pub const HostPresent = structs.HostPresent;
pub const HostFrame = structs.HostFrame;

/// The implementation to use for the app runtime. This is comptime chosen
/// so that every build has exactly one application runtime implementation.
Expand Down
30 changes: 30 additions & 0 deletions src/apprt/embedded.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2215,6 +2215,36 @@ pub const CAPI = struct {
return api.presentHostSized();
}

/// Who shows the frame of a new host size: the renderer (default) or the
/// embedder (ghostty_surface_host_frame). See ghostty.h.
export fn ghostty_surface_set_host_present(surface: *Surface, mode_raw: c_int) void {
if (comptime builtin.os.tag != .macos) return;
const mode = std.enums.fromInt(apprt.HostPresent, mode_raw) orelse return;
const api = &surface.core_surface.renderer.api;
if (comptime !@hasDecl(@TypeOf(api.*), "setEmbedderPresents")) return;
api.setEmbedderPresents(mode == .embedder);
}

/// What the embedder can show for the host size; with present, shows the
/// frame the renderer drew for it in the caller's Core Animation
/// transaction. See ghostty.h.
export fn ghostty_surface_host_frame(surface: *Surface, present: bool) apprt.HostFrame {
if (comptime builtin.os.tag != .macos) return .none;
const api = &surface.core_surface.renderer.api;
if (comptime !@hasDecl(@TypeOf(api.*), "hostFrame")) return .none;
if (present) _ = api.counters.present_calls.fetchAdd(1, .monotonic);
return api.hostFrame(present);
}

/// The embedder committed the transaction of its last host frame present or
/// bounds change. See ghostty.h.
export fn ghostty_surface_host_frame_committed(surface: *Surface) void {
if (comptime builtin.os.tag != .macos) return;
const api = &surface.core_surface.renderer.api;
if (comptime !@hasDecl(@TypeOf(api.*), "hostFrameCommitted")) return;
api.hostFrameCommitted();
}

/// Draw a reflow preview while the locked grid does not fit the view.
/// See ghostty.h.
export fn ghostty_surface_set_reflow_preview(surface: *Surface, enabled: bool) void {
Expand Down
52 changes: 52 additions & 0 deletions src/apprt/structs.zig
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,58 @@ pub const SurfaceLocalHistoryResult = enum(c_int) {
mismatch = 1,
};

/// Who shows the frame of a new host size of a host-sized macOS surface
/// (ghostty_surface_set_host_present).
///
/// If this is changed, you must also update ghostty.h
/// (ghostty_host_present_e).
pub const HostPresent = enum(c_int) {
/// The renderer (default).
renderer = 0,
/// The embedder, with ghostty_surface_host_frame.
embedder = 1,
};

test "ghostty.h HostPresent" {
try @import("../lib/enum.zig").checkGhosttyHEnum(
HostPresent,
"GHOSTTY_HOST_PRESENT_",
);
}

/// What an embedder that presents host sizes can show for a host-sized macOS
/// surface (ghostty_surface_set_host_present, ghostty_surface_host_frame).
///
/// If this is changed, you must also update ghostty.h
/// (ghostty_host_frame_e).
pub const HostFrame = enum(c_int) {
/// Nothing to keep: not host-sized, not macOS, or no frame shown yet
/// (the first frame presents itself).
none = 0,
/// The surface does not draw (occluded): no frame comes until it is
/// shown again.
hidden = 1,
/// The layer shows a frame at the host size.
shown = 2,
/// A query: the renderer drew the host size's frame, not shown yet.
ready = 3,
/// That frame is the layer's contents now, in the caller's Core
/// Animation transaction.
presented = 4,
/// Not drawn yet: the layer keeps the frame it shows.
pending = 5,
/// Frame updates are held (synchronized output, a restore): no frame
/// can come now, and the layer keeps the frame it shows.
held = 6,
};

test "ghostty.h HostFrame" {
try @import("../lib/enum.zig").checkGhosttyHEnum(
HostFrame,
"GHOSTTY_HOST_FRAME_",
);
}

test "ghostty.h SurfaceLocalHistoryResult" {
try @import("../lib/enum.zig").checkGhosttyHEnum(
SurfaceLocalHistoryResult,
Expand Down
6 changes: 6 additions & 0 deletions src/renderer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,10 @@ test {
_ = Thread;
_ = State;
_ = @import("renderer/mailbox.zig");

// The host-sized macOS present paths (Core Animation and IOSurface, no GPU).
if (comptime @import("builtin").os.tag == .macos) {
_ = @import("renderer/Metal.zig");
_ = @import("renderer/metal/IOSurfaceLayer.zig");
}
}
Loading