diff --git a/NEXT.md b/NEXT.md index 4bf01f0fe..3e3dd36dc 100644 --- a/NEXT.md +++ b/NEXT.md @@ -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 diff --git a/include/ghostty.h b/include/ghostty.h index 7393795fc..f613a56f2 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -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 diff --git a/src/apprt.zig b/src/apprt.zig index 504c9d454..1c389221d 100644 --- a/src/apprt.zig +++ b/src/apprt.zig @@ -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. diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index d1562db91..2b9afe9c0 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -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 { diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index 1d27ebaf6..5ec3b8c55 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -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, diff --git a/src/renderer.zig b/src/renderer.zig index 780a495fb..86be450e8 100644 --- a/src/renderer.zig +++ b/src/renderer.zig @@ -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"); + } } diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 7ec246432..8d3b76f83 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -81,6 +81,13 @@ frames_held: std.atomic.Value(bool) = .init(false), /// When the host size last changed (`setHostDrawableSize`). Main thread. host_size_set_at: ?std.Io.Timestamp = null, +/// macOS host-sized surfaces, opt-in (`ghostty_surface_set_host_present`): +/// the embedder, not the renderer, shows a frame whose size differs from the +/// frame the layer shows, so it can change the view's bounds in the same +/// Core Animation transaction. Main thread writes; the GPU completion thread +/// and the display pass read. +embedder_presents: std.atomic.Value(bool) = .init(false), + /// Numbers presented frames in completion order, so a frame the main thread /// presents late never replaces a newer one presented off main. GPU /// completion thread. @@ -95,7 +102,8 @@ pub const HostFrameCounters = struct { display_passes: std.atomic.Value(u64) = .init(0), /// Frames presented from the completion thread (content only, `setSurfaceOffMain`). off_main_presents: std.atomic.Value(u64) = .init(0), - /// `ghostty_surface_present_host_frame` calls (the embedder's own + /// `ghostty_surface_present_host_frame` calls and + /// `ghostty_surface_host_frame` calls that present (the embedder's own /// pre-commit hook, for a layer whose display pass does not run). present_calls: std.atomic.Value(u64) = .init(0), main_draws: std.atomic.Value(u64) = .init(0), @@ -114,10 +122,20 @@ const FreshFrame = struct { mutex: std.Io.Mutex = .init, /// Retained; null after a take or before the first fresh frame. surface: ?*IOSurface = null, + /// The present sequence number (`Metal.present_seq`) of `surface`. + surface_seq: u64 = 0, /// Bumped on each publish; the display pass waits on it (futex). seq: std.atomic.Value(u32) = .init(0), }; +/// A fresh frame taken for a present (`takeFresh`): retained, the caller +/// releases `surface`. +const TakenFrame = struct { + surface: *IOSurface, + /// Its present sequence number (`Metal.present_seq`). + seq: u64, +}; + /// How long after a new host size the display pass waits for the renderer /// thread's frame at that size. The renderer thread starts it when the size /// is set (during layout), so the wait overlaps layout and the other @@ -277,10 +295,35 @@ pub fn setHostDrawableSize(self: *Metal, enabled: bool, width_px: u32, height_px self.fresh.armed.store(value != 0, .release); self.layer.setHostSize(value); self.layer.layer.setProperty("needsDisplayOnBoundsChange", value == 0); + // An embedder that presents host sizes shows this size's frame itself + // (`hostFrame`), with its bounds: no display pass draws, presents or + // waits for it. A first frame (nothing shown yet) still draws in the next + // display pass, as it does by default. Only the main thread makes the + // layer show nothing, so this read needs no lock. + if (value != 0 and self.embedder_presents.load(.acquire) and + self.layer.layer.getProperty(?*anyopaque, "contents") != null) return; // One synchronous frame at the new target size (or at the bounds again). self.layer.layer.msgSend(void, objc.sel("setNeedsDisplay"), .{}); } +/// macOS host-sized surfaces (`ghostty_surface_set_host_present`): with +/// `enabled`, the embedder shows each frame of a new host size itself +/// (`hostFrame`), in the Core Animation transaction that changes the view's +/// bounds. Frames at the shown size still go to the screen off the main +/// thread, and a first frame (nothing shown yet) still presents itself. +/// Main thread. +pub fn setEmbedderPresents(self: *Metal, enabled: bool) void { + if (comptime builtin.os.tag != .macos) return; + if (self.embedder_presents.swap(enabled, .acq_rel) == enabled) return; + self.layer.setEmbedderPresents(enabled); + // Back to the renderer: a frame it drew for the host size while the + // embedder owned size changes goes to the screen now. One still drawing + // presents itself when it completes. + if (enabled) return; + const host = self.host_size.load(.acquire); + if (host != 0) _ = self.presentFresh(@intCast(host >> 32), @intCast(host & 0xffff_ffff)); +} + /// iOS only: detach the IOSurfaceLayer from the embedder's view before /// the renderer's memory is freed. /// @@ -312,7 +355,9 @@ pub fn loopEnter(self: *Metal) void { fn displayCallback(renderer: *Renderer) align(8) void { _ = renderer.api.counters.display_passes.fetchAdd(1, .monotonic); if (comptime builtin.os.tag == .macos) { - if (renderer.api.presentHostSized()) return; + if (renderer.api.embedder_presents.load(.acquire)) { + if (renderer.api.displayWhileEmbedderPresents()) return; + } else if (renderer.api.presentHostSized()) return; } _ = renderer.api.counters.main_draws.fetchAdd(1, .monotonic); renderer.drawFrame(true) catch |err| { @@ -407,12 +452,12 @@ pub inline fn present(self: *Metal, target: Target, sync: bool) !void { if (sync) { self.layer.setSurfaceSync(target.surface); } else { + const seq = self.present_seq.fetchAdd(1, .monotonic) + 1; if (comptime builtin.os.tag == .macos) { if (target.cells_fresh and self.fresh.armed.load(.acquire) and target.size_generation == self.fresh.generation.load(.acquire) and - self.host_size.load(.acquire) != 0) self.publishFresh(target.surface); + self.host_size.load(.acquire) != 0) self.publishFresh(target.surface, seq); } - const seq = self.present_seq.fetchAdd(1, .monotonic) + 1; // A content-only frame of a host-sized surface goes to the screen from // this (completion) thread: a terminal echo does not wait for main. if (comptime builtin.os.tag == .macos) { @@ -425,16 +470,19 @@ pub inline fn present(self: *Metal, target: Target, sync: bool) !void { } } -/// Keeps `surface` as the newest fresh frame and wakes a display pass that -/// waits for it. GPU completion thread. -fn publishFresh(self: *Metal, surface: *IOSurface) void { +/// Keeps `surface`, the frame of present sequence `seq`, as the newest fresh +/// frame and wakes a display pass that waits for it. A frame older than the +/// one kept (completions that raced) is not kept. GPU completion thread. +fn publishFresh(self: *Metal, surface: *IOSurface, seq: u64) void { const io = global.io(); - surface.retain(); const old = old: { self.fresh.mutex.lockUncancelable(io); defer self.fresh.mutex.unlock(io); + if (self.fresh.surface != null and seq <= self.fresh.surface_seq) return; + surface.retain(); const old = self.fresh.surface; self.fresh.surface = surface; + self.fresh.surface_seq = seq; break :old old; }; if (old) |o| o.release(); @@ -457,7 +505,7 @@ fn dropFresh(self: *Metal) void { /// The fresh frame if it has `width` x `height` pixels (retained; the caller /// releases it). A fresh frame of another size stays for its own pass. -fn takeFresh(self: *Metal, width: usize, height: usize) ?*IOSurface { +fn takeFresh(self: *Metal, width: usize, height: usize) ?TakenFrame { const io = global.io(); self.fresh.mutex.lockUncancelable(io); defer self.fresh.mutex.unlock(io); @@ -465,7 +513,33 @@ fn takeFresh(self: *Metal, width: usize, height: usize) ?*IOSurface { if (surface.getWidth() != width or surface.getHeight() != height) return null; self.fresh.surface = null; self.fresh.armed.store(false, .release); - return surface; + return .{ .surface = surface, .seq = self.fresh.surface_seq }; +} + +/// Whether a fresh frame of `width` x `height` pixels waits (`takeFresh` +/// would take it). Main thread. +fn hasFresh(self: *Metal, width: usize, height: usize) bool { + const io = global.io(); + self.fresh.mutex.lockUncancelable(io); + defer self.fresh.mutex.unlock(io); + const surface = self.fresh.surface orelse return false; + return surface.getWidth() == width and surface.getHeight() == height; +} + +/// Shows the fresh frame of `width` x `height` pixels in the caller's Core +/// Animation transaction, unless the layer shows a newer frame. Returns +/// whether it did. Main thread. +fn presentFresh(self: *Metal, width: usize, height: usize) bool { + const taken = self.takeFresh(width, height) orelse return false; + defer taken.surface.release(); + if (!self.layer.setSurfacePresented(taken.surface, taken.seq)) { + // Not shown (the layer shows a newer frame): the next frame of this + // size publishes for the next present. + self.fresh.armed.store(true, .release); + return false; + } + _ = self.counters.fresh_presents.fetchAdd(1, .monotonic); + return true; } /// The display pass of a host-sized surface (main thread): present the @@ -483,6 +557,11 @@ pub fn presentHostSized(self: *Metal) bool { /// `presentHostSized`; with `wait` false it never blocks: it presents the fresh frame only if the /// renderer already published it, and otherwise keeps the last frame (counted late). pub fn presentHostSizedWait(self: *Metal, wait: bool) bool { + // An embedder that presents host sizes: the same ordered present as + // `ghostty_surface_host_frame`, which never waits. + if (comptime builtin.os.tag == .macos) { + if (self.embedder_presents.load(.acquire)) return self.hostFrame(true) != .none; + } const host = self.host_size.load(.acquire); if (host == 0) return false; // Hidden: nothing to draw now (the renderer draws when shown again), @@ -494,7 +573,7 @@ pub fn presentHostSizedWait(self: *Metal, wait: bool) bool { const shown_surface: *IOSurface = @ptrCast(shown); if (shown_surface.getWidth() == width and shown_surface.getHeight() == height) { // The renderer thread's frame at this size already landed. - if (self.takeFresh(width, height)) |surface| surface.release(); + if (self.takeFresh(width, height)) |taken| taken.surface.release(); return true; } @@ -502,9 +581,9 @@ pub fn presentHostSizedWait(self: *Metal, wait: bool) bool { const set_at = self.host_size_set_at orelse std.Io.Timestamp.now(io, .awake); while (true) { const seq = self.fresh.seq.load(.acquire); - if (self.takeFresh(width, height)) |surface| { - defer surface.release(); - self.layer.setSurfaceSync(surface); + if (self.takeFresh(width, height)) |taken| { + defer taken.surface.release(); + self.layer.setSurfaceSync(taken.surface); _ = self.counters.fresh_presents.fetchAdd(1, .monotonic); return true; } @@ -528,6 +607,59 @@ pub fn presentHostSizedWait(self: *Metal, wait: bool) bool { return true; } +/// What an embedder that presents host sizes can show for the host size +/// (`ghostty_surface_host_frame`); with `show`, a frame the renderer drew for +/// it becomes the layer's contents in the caller's Core Animation +/// transaction, where the embedder gives the view its new bounds. Never +/// waits and never draws. Main thread. +pub fn hostFrame(self: *Metal, show: bool) apprt.HostFrame { + if (comptime builtin.os.tag != .macos) return .none; + const host = self.host_size.load(.acquire); + if (host == 0) return .none; + // Hidden: the renderer draws when shown again (a reveal arms the present). + if (!self.visible_hint.load(.acquire)) return .hidden; + const width: usize = @intCast(host >> 32); + const height: usize = @intCast(host & 0xffff_ffff); + // Nothing shown: the first frame presents itself. + const shown = self.layer.shownSize() orelse return .none; + if (shown.width == width and shown.height == height) { + // A frame at this size is on screen (a first frame, a frame that fit + // bounds the embedder had set, or an earlier present): a kept fresh + // frame is not newer, and no frame needs to publish until the next + // size (frames at this size go off main). + if (self.takeFresh(width, height)) |taken| taken.surface.release(); + self.fresh.armed.store(false, .release); + return .shown; + } + if (!show) { + if (self.hasFresh(width, height)) return .ready; + return if (self.frames_held.load(.acquire)) .held else .pending; + } + if (self.presentFresh(width, height)) return .presented; + // The layer keeps its frame; the new one waits, published, for the next call. + _ = self.counters.late.fetchAdd(1, .monotonic); + return if (self.frames_held.load(.acquire)) .held else .pending; +} + +/// The embedder committed the transaction of its last present or bounds change +/// (`ghostty_surface_host_frame_committed`). Main thread. +pub fn hostFrameCommitted(self: *Metal) void { + if (comptime builtin.os.tag != .macos) return; + self.layer.setEmbedderCommitted(); +} + +/// A display pass while the embedder presents host sizes (Core Animation runs +/// one on its own, for example for a new contents scale): it never waits and +/// never shows a frame of another size than the one shown; the embedder does, +/// with the view's bounds (`hostFrame`). Returns false when the pass should +/// draw itself: not host-sized, or nothing shown yet (the first frame). +fn displayWhileEmbedderPresents(self: *Metal) bool { + if (self.host_size.load(.acquire) == 0) return false; + // Hidden: nothing to draw now, and drawing would rebuild the released swap chain. + if (!self.visible_hint.load(.acquire)) return true; + return self.layer.shownSize() != null; +} + /// The host size generation a frame drawn now belongs to (Target.size_generation). pub fn sizeGeneration(self: *const Metal) u64 { return self.fresh.generation.load(.acquire); @@ -712,3 +844,373 @@ pub inline fn beginFrame( ) !Frame { return try Frame.begin(.{ .queue = self.queue }, renderer, target); } + +fn testDevice() Device { + var device: Device = undefined; + device.max_texture_size = 16384; + return device; +} + +/// A host-sized Metal API with a real layer and no GPU: the paths under test +/// touch only the layer, the host size and the fresh frame. +fn testMetal(device: *const Device) !Metal { + return .{ + .layer = try IOSurfaceLayer.init(), + .device = device, + .queue = undefined, + .blending = .native, + }; +} + +fn testDeinit(self: *Metal) void { + self.dropFresh(); + self.layer.release(); +} + +fn testSurface(width: c_int, height: c_int) !*IOSurface { + return try IOSurface.init(.{ + .width = width, + .height = height, + .pixel_format = .@"32BGRA", + .bytes_per_element = 4, + .colorspace = null, + }); +} + +/// The width of the frame the layer shows; null while it shows none. +fn testShownWidth(self: *const Metal) ?usize { + const shown = self.layer.layer.getProperty(?*anyopaque, "contents") orelse return null; + const surface: *IOSurface = @ptrCast(shown); + return surface.getWidth(); +} + +/// A frame the renderer drew at `surface`'s size, with cells built for it. +fn testTarget(self: *const Metal, surface: *IOSurface) Target { + return .{ + .surface = surface, + .texture = undefined, + .width = surface.getWidth(), + .height = surface.getHeight(), + .cells_fresh = true, + .size_generation = self.sizeGeneration(), + }; +} + +fn testIsMainThread() bool { + return objc.getClass("NSThread").?.msgSend(bool, "isMainThread", .{}); +} + +test "host present: a new host size asks no display pass while the embedder presents sizes" { + const testing = std.testing; + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + const device = testDevice(); + var metal = try testMetal(&device); + defer testDeinit(&metal); + const shown = try testSurface(80, 60); + defer shown.release(); + metal.layer.setSurfaceSync(shown); + metal.setEmbedderPresents(true); + metal.layer.layer.msgSend(void, objc.sel("displayIfNeeded"), .{}); + try testing.expect(!metal.layer.layer.getProperty(bool, "needsDisplay")); + + metal.setHostDrawableSize(true, 120, 60); + // No display pass: Core Animation never presents (or waits for) the new + // size's frame on its own; the embedder shows it with its bounds. + try testing.expect(!metal.layer.layer.getProperty(bool, "needsDisplay")); +} + +test "host present: a new host size asks a display pass by default" { + const testing = std.testing; + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + const device = testDevice(); + var metal = try testMetal(&device); + defer testDeinit(&metal); + const shown = try testSurface(80, 60); + defer shown.release(); + metal.layer.setSurfaceSync(shown); + metal.layer.layer.msgSend(void, objc.sel("displayIfNeeded"), .{}); + try testing.expect(!metal.layer.layer.getProperty(bool, "needsDisplay")); + + metal.setHostDrawableSize(true, 120, 60); + try testing.expect(metal.layer.layer.getProperty(bool, "needsDisplay")); +} + +test "host present: the renderer never shows a frame of a new host size while the embedder presents sizes" { + const testing = std.testing; + // The main-thread present runs at once only on the main thread. + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + const device = testDevice(); + var metal = try testMetal(&device); + defer testDeinit(&metal); + const shown = try testSurface(80, 60); + defer shown.release(); + const drawn = try testSurface(120, 60); + defer drawn.release(); + metal.layer.setSurfaceSync(shown); + metal.setEmbedderPresents(true); + metal.setHostDrawableSize(true, 120, 60); + + // The renderer completed the frame of the new size. + try metal.present(testTarget(&metal, drawn), false); + // The layer keeps the shown frame and its size; the new frame waits, as + // the fresh frame, for the embedder's present. + try testing.expectEqual(@as(?usize, 80), testShownWidth(&metal)); + try testing.expectEqual(@as(?*IOSurface, drawn), metal.fresh.surface); +} + +test "host present: the renderer shows a frame of a new host size by default" { + const testing = std.testing; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + const device = testDevice(); + var metal = try testMetal(&device); + defer testDeinit(&metal); + const shown = try testSurface(80, 60); + defer shown.release(); + const drawn = try testSurface(120, 60); + defer drawn.release(); + metal.layer.setSurfaceSync(shown); + metal.setHostDrawableSize(true, 120, 60); + + try metal.present(testTarget(&metal, drawn), false); + try testing.expectEqual(@as(?usize, 120), testShownWidth(&metal)); +} + +test "host present: a first frame presents itself while the embedder presents sizes" { + const testing = std.testing; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + const device = testDevice(); + var metal = try testMetal(&device); + defer testDeinit(&metal); + const drawn = try testSurface(120, 60); + defer drawn.release(); + metal.setEmbedderPresents(true); + metal.setHostDrawableSize(true, 120, 60); + + // Nothing is shown yet: there is no size to keep. + try metal.present(testTarget(&metal, drawn), false); + try testing.expectEqual(@as(?usize, 120), testShownWidth(&metal)); +} + +test "host present: a frame at the shown size goes to the screen off main while the embedder presents sizes" { + const testing = std.testing; + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + const device = testDevice(); + var metal = try testMetal(&device); + defer testDeinit(&metal); + const shown = try testSurface(120, 60); + defer shown.release(); + const drawn = try testSurface(120, 60); + defer drawn.release(); + metal.layer.setSurfaceSync(shown); + metal.setEmbedderPresents(true); + metal.setHostDrawableSize(true, 120, 60); + // The embedder committed its last change: frames at the shown size may go off main. + metal.hostFrameCommitted(); + + // A content-only frame (a terminal echo) needs no geometry change. + try metal.present(testTarget(&metal, drawn), false); + const contents = metal.layer.layer.getProperty(?*anyopaque, "contents"); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(drawn)), contents); + try testing.expectEqual(@as(u64, 1), metal.counters.off_main_presents.load(.monotonic)); +} + +test "host present: hostFrame reports the frame drawn for a new host size and shows it in the caller's transaction" { + const testing = std.testing; + const HostFrame = apprt.HostFrame; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + const device = testDevice(); + var metal = try testMetal(&device); + defer testDeinit(&metal); + const shown = try testSurface(80, 60); + defer shown.release(); + const drawn = try testSurface(120, 60); + defer drawn.release(); + metal.layer.setSurfaceSync(shown); + metal.setEmbedderPresents(true); + metal.setHostDrawableSize(true, 120, 60); + try metal.present(testTarget(&metal, drawn), false); + + // A query shows nothing. + try testing.expectEqual(HostFrame.ready, metal.hostFrame(false)); + try testing.expectEqual(@as(?usize, 80), testShownWidth(&metal)); + // A present shows the frame now; the embedder gives the view its new + // bounds in the same transaction. + try testing.expectEqual(HostFrame.presented, metal.hostFrame(true)); + try testing.expectEqual(@as(?usize, 120), testShownWidth(&metal)); + try testing.expectEqual(@as(u64, 1), metal.counters.fresh_presents.load(.monotonic)); + // Nothing is left to show for this size. + try testing.expectEqual(HostFrame.shown, metal.hostFrame(true)); +} + +test "host present: hostFrame keeps the shown frame until the renderer drew the new host size" { + const testing = std.testing; + const HostFrame = apprt.HostFrame; + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + const device = testDevice(); + var metal = try testMetal(&device); + defer testDeinit(&metal); + const shown = try testSurface(80, 60); + defer shown.release(); + metal.layer.setSurfaceSync(shown); + metal.setEmbedderPresents(true); + metal.setHostDrawableSize(true, 120, 60); + + try testing.expectEqual(HostFrame.pending, metal.hostFrame(false)); + try testing.expectEqual(HostFrame.pending, metal.hostFrame(true)); + try testing.expectEqual(@as(?usize, 80), testShownWidth(&metal)); + // Only a present that kept the shown frame counts as late. + try testing.expectEqual(@as(u64, 1), metal.counters.late.load(.monotonic)); +} + +test "host present: hostFrame reports nothing to keep, held frames and a hidden surface" { + const testing = std.testing; + const HostFrame = apprt.HostFrame; + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + const device = testDevice(); + var metal = try testMetal(&device); + defer testDeinit(&metal); + + // Not host-sized: the target follows the bounds. + try testing.expectEqual(HostFrame.none, metal.hostFrame(true)); + metal.setEmbedderPresents(true); + metal.setHostDrawableSize(true, 120, 60); + // Nothing shown yet: the first frame presents itself. + try testing.expectEqual(HostFrame.none, metal.hostFrame(true)); + const shown = try testSurface(80, 60); + defer shown.release(); + metal.layer.setSurfaceSync(shown); + // Synchronized output or a restore holds frame updates: no frame can come now. + metal.setFramesHeld(true); + try testing.expectEqual(HostFrame.held, metal.hostFrame(false)); + try testing.expectEqual(HostFrame.held, metal.hostFrame(true)); + metal.setFramesHeld(false); + // A hidden surface draws nothing until it is shown again. + metal.setVisibleHint(false); + try testing.expectEqual(HostFrame.hidden, metal.hostFrame(true)); + try testing.expectEqual(@as(?usize, 80), testShownWidth(&metal)); +} + +test "host present: a frame that reaches the main thread late never replaces the one the embedder showed" { + const testing = std.testing; + const HostFrame = apprt.HostFrame; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + const device = testDevice(); + var metal = try testMetal(&device); + defer testDeinit(&metal); + const shown = try testSurface(80, 60); + defer shown.release(); + const stale = try testSurface(120, 60); + defer stale.release(); + const fresh = try testSurface(120, 60); + defer fresh.release(); + metal.layer.setSurfaceSync(shown); + metal.setEmbedderPresents(true); + metal.setHostDrawableSize(true, 120, 60); + + // The renderer drew the new size twice: first with the cells of the old + // size (present sequence 1), then with cells built for it (sequence 2). + var stale_target = testTarget(&metal, stale); + stale_target.cells_fresh = false; + try metal.present(stale_target, false); + try metal.present(testTarget(&metal, fresh), false); + try testing.expectEqual(HostFrame.presented, metal.hostFrame(true)); + + // The older frame's main-thread present runs after that (a block queued + // before it): it must not replace the newer frame. + try metal.layer.setSurface(stale, 1); + const contents = metal.layer.layer.getProperty(?*anyopaque, "contents"); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(fresh)), contents); +} + +test "host present: back to the renderer, a frame drawn for the host size goes to the screen" { + const testing = std.testing; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + const device = testDevice(); + var metal = try testMetal(&device); + defer testDeinit(&metal); + const shown = try testSurface(80, 60); + defer shown.release(); + const drawn = try testSurface(120, 60); + defer drawn.release(); + metal.layer.setSurfaceSync(shown); + metal.setEmbedderPresents(true); + metal.setHostDrawableSize(true, 120, 60); + try metal.present(testTarget(&metal, drawn), false); + + metal.setEmbedderPresents(false); + try testing.expectEqual(@as(?usize, 120), testShownWidth(&metal)); +} + +test "host present: a refused present keeps the next frame of the host size publishing" { + const testing = std.testing; + const HostFrame = apprt.HostFrame; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + const device = testDevice(); + var metal = try testMetal(&device); + defer testDeinit(&metal); + const shown = try testSurface(80, 60); + defer shown.release(); + const newer = try testSurface(80, 60); + defer newer.release(); + const drawn = try testSurface(120, 60); + defer drawn.release(); + metal.layer.setSurfaceSync(shown); + metal.setEmbedderPresents(true); + metal.setHostDrawableSize(true, 120, 60); + try metal.present(testTarget(&metal, drawn), false); + // The layer shows a newer frame (of the old size) than the one drawn for the + // host size, so the present refuses it. + try testing.expect(metal.layer.setSurfacePresented(newer, 50)); + + try testing.expectEqual(HostFrame.pending, metal.hostFrame(true)); + // The next frame at the host size must publish for the next present. + try testing.expect(metal.fresh.armed.load(.acquire)); +} + +test "host present: the old present calls follow the embedder's ordered present" { + const testing = std.testing; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + const device = testDevice(); + var metal = try testMetal(&device); + defer testDeinit(&metal); + const shown = try testSurface(80, 60); + defer shown.release(); + const stale = try testSurface(120, 60); + defer stale.release(); + const fresh = try testSurface(120, 60); + defer fresh.release(); + metal.layer.setSurfaceSync(shown); + metal.setEmbedderPresents(true); + metal.setHostDrawableSize(true, 120, 60); + var stale_target = testTarget(&metal, stale); + stale_target.cells_fresh = false; + try metal.present(stale_target, false); + try metal.present(testTarget(&metal, fresh), false); + + // ghostty_surface_present_host_frame(_ready) while the embedder presents sizes. + try testing.expect(metal.presentHostSizedWait(true)); + try metal.layer.setSurface(stale, 1); + const contents = metal.layer.layer.getProperty(?*anyopaque, "contents"); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(fresh)), contents); +} diff --git a/src/renderer/metal/IOSurfaceLayer.zig b/src/renderer/metal/IOSurfaceLayer.zig index a0755e364..04b428bfb 100644 --- a/src/renderer/metal/IOSurfaceLayer.zig +++ b/src/renderer/metal/IOSurfaceLayer.zig @@ -37,6 +37,8 @@ pub fn init() !IOSurfaceLayer { layer.setInstanceVariable("display_ctx", .{ .value = null }); layer.setInstanceVariable("host_size", .{ .value = null }); layer.setInstanceVariable("present_seq", .{ .value = null }); + layer.setInstanceVariable("embedder_presents", .{ .value = null }); + layer.setInstanceVariable("main_pending", .{ .value = null }); return .{ .layer = layer }; } @@ -93,6 +95,90 @@ fn hostSize(layer: objc.Object) u64 { return @as(u64, @intFromPtr(layer.getInstanceVariable("host_size").value)) >> 3; } +/// Records whether the embedder presents the frames of a new host size +/// (`Metal.setEmbedderPresents`, ghostty_surface_set_host_present), which +/// `setSurfaceCallback` checks frames against. Main thread. +pub fn setEmbedderPresents(self: *IOSurfaceLayer, enabled: bool) void { + // Stored like `setHostSize`: an 8-aligned value in an object-typed ivar, + // never retained or released. + const stored: usize = @as(usize, @intFromBool(enabled)) << 3; + const CATransaction = objc.getClass("CATransaction").?; + CATransaction.msgSend(void, objc.sel("lock"), .{}); + defer CATransaction.msgSend(void, objc.sel("unlock"), .{}); + self.layer.setInstanceVariable("embedder_presents", .{ .value = @ptrFromInt(stored) }); + // The mode starts closed: a change the main thread made before it may + // still be in its open transaction. Leaving it, nothing is pending. + setMainPending(self.layer, enabled); +} + +/// Whether showing `surface` changes the size of the frame the layer shows +/// (or shows a first frame): only such a change can leave the view's bounds +/// behind. Call under the Core Animation lock. +fn changesShownSize(layer: objc.Object, surface: *IOSurface) bool { + const shown = layer.getProperty(?*anyopaque, "contents") orelse return true; + const shown_surface: *IOSurface = @ptrCast(shown); + return shown_surface.getWidth() != surface.getWidth() or shown_surface.getHeight() != surface.getHeight(); +} + +/// The value `setEmbedderPresents` stored. +fn embedderPresents(layer: objc.Object) bool { + return @intFromPtr(layer.getInstanceVariable("embedder_presents").value) != 0; +} + +/// The pixel size of the frame the layer shows, or null while it shows +/// none. Read under the Core Animation lock: a frame presented off the main +/// thread (`setSurfaceOffMain`) can replace the shown one. +pub fn shownSize(self: *IOSurfaceLayer) ?struct { width: usize, height: usize } { + const CATransaction = objc.getClass("CATransaction").?; + CATransaction.msgSend(void, objc.sel("lock"), .{}); + defer CATransaction.msgSend(void, objc.sel("unlock"), .{}); + const shown = self.layer.getProperty(?*anyopaque, "contents") orelse return null; + const surface: *IOSurface = @ptrCast(shown); + return .{ .width = surface.getWidth(), .height = surface.getHeight() }; +} + +/// Shows `surface`, the frame of present sequence `seq`, in the caller's +/// Core Animation transaction (the embedder's, which gives the view its new +/// bounds), unless the layer shows a newer frame (`setSurface`, +/// `setSurfaceOffMain`). Returns whether it did. Main thread. +pub fn setSurfacePresented(self: *IOSurfaceLayer, surface: *IOSurface, seq: u64) bool { + const CATransaction = objc.getClass("CATransaction").?; + CATransaction.msgSend(void, objc.sel("lock"), .{}); + defer CATransaction.msgSend(void, objc.sel("unlock"), .{}); + if (seq <= presentSeq(self.layer)) return false; + setPresentSeq(self.layer, seq); + setMainPending(self.layer, true); + self.layer.setProperty("contents", surface); + return true; +} + +/// The embedder committed the transaction of its last present or bounds change +/// (`ghostty_surface_host_frame_committed`): frames at the shown size go to the +/// screen off the main thread again. Main thread. +pub fn setEmbedderCommitted(self: *IOSurfaceLayer) void { + const CATransaction = objc.getClass("CATransaction").?; + CATransaction.msgSend(void, objc.sel("lock"), .{}); + defer CATransaction.msgSend(void, objc.sel("unlock"), .{}); + setMainPending(self.layer, false); +} + +/// Whether, while the embedder presents host sizes, the main thread changed +/// the layer's contents in a transaction the embedder has not reported +/// committed (`setEmbedderCommitted`). Ghostty cannot see the main thread's +/// commit: until the embedder reports it, a frame committed off the main +/// thread in its own transaction could reach the screen ahead of the view's +/// new bounds, so frames go through main. Read and written under the Core +/// Animation lock. +fn mainPending(layer: objc.Object) bool { + return @intFromPtr(layer.getInstanceVariable("main_pending").value) != 0; +} + +fn setMainPending(layer: objc.Object, pending: bool) void { + // Stored like `setHostSize`: an 8-aligned value in an object-typed ivar, never retained. + const stored: usize = @as(usize, @intFromBool(pending)) << 3; + layer.setInstanceVariable("main_pending", .{ .value = @ptrFromInt(stored) }); +} + /// Sets the layer's `contents` to the provided IOSurface. /// /// Makes sure to do so on the main thread to avoid visual artifacts. @@ -132,6 +218,15 @@ pub inline fn setSurface(self: *IOSurfaceLayer, surface: *IOSurface, seq: u64) ! /// /// Does not ensure this happens on the main thread. pub inline fn setSurfaceSync(self: *IOSurfaceLayer, surface: *IOSurface) void { + // Main thread (a display pass, ghostty_surface_draw): with the embedder + // presenting host sizes, a new size goes with main's commit, so frames go + // through main until the embedder reports it. + if (embedderPresents(self.layer)) { + const CATransaction = objc.getClass("CATransaction").?; + CATransaction.msgSend(void, objc.sel("lock"), .{}); + defer CATransaction.msgSend(void, objc.sel("unlock"), .{}); + if (changesShownSize(self.layer, surface)) setMainPending(self.layer, true); + } self.layer.setProperty("contents", surface); } @@ -155,6 +250,28 @@ pub fn setSurfaceOffMain(self: *IOSurfaceLayer, surface: *IOSurface, seq: u64) b const host = hostSize(self.layer); if (host == 0 or (host >> 32) != width or (host & 0xffff_ffff) != height) return false; const CATransaction = objc.getClass("CATransaction").?; + if (embedderPresents(self.layer)) { + // The embedder presents host sizes: the Core Animation lock is held + // across this transaction's commit, so it can never run inside the + // embedder's present pass (which holds the lock: a layout or display + // pass of main's commit) or ship a contents change main made after the + // checks below. Nothing commits when a check refuses. + CATransaction.msgSend(void, objc.sel("lock"), .{}); + defer CATransaction.msgSend(void, objc.sel("unlock"), .{}); + const shown = self.layer.getProperty(?*anyopaque, "contents") orelse return false; + const shown_surface: *IOSurface = @ptrCast(shown); + if (shown_surface.getWidth() != width or shown_surface.getHeight() != height) return false; + if (seq <= presentSeq(self.layer)) return true; + // The main thread changed the shown size in a transaction the + // embedder has not reported committed: a transaction committed here + // could reach the screen ahead of the view's new bounds. + if (mainPending(self.layer)) return false; + CATransaction.msgSend(void, objc.sel("begin"), .{}); + defer CATransaction.msgSend(void, objc.sel("commit"), .{}); + setPresentSeq(self.layer, seq); + self.layer.setProperty("contents", surface); + return true; + } CATransaction.msgSend(void, objc.sel("begin"), .{}); defer CATransaction.msgSend(void, objc.sel("commit"), .{}); // The Core Animation lock orders this against `setSurfaceCallback` on main. @@ -164,6 +281,8 @@ pub fn setSurfaceOffMain(self: *IOSurfaceLayer, surface: *IOSurface, seq: u64) b const shown_surface: *IOSurface = @ptrCast(shown); if (shown_surface.getWidth() != width or shown_surface.getHeight() != height) return false; if (seq <= presentSeq(self.layer)) return true; + // The mode turned on after the check above: the embedder path's gate. + if (embedderPresents(self.layer) and mainPending(self.layer)) return false; setPresentSeq(self.layer, seq); self.layer.setProperty("contents", surface); return true; @@ -238,7 +357,25 @@ fn setSurfaceCallback( CATransaction.msgSend(void, objc.sel("lock"), .{}); defer CATransaction.msgSend(void, objc.sel("unlock"), .{}); if (block.seq != 0 and block.seq <= presentSeq(layer)) return; + + // An embedder that presents host sizes shows a frame of another size + // than the shown one itself, in the transaction that gives the view its + // bounds (`Metal.hostFrame`): such a frame shows here only if the bounds + // already have its size (the embedder set them without a present), so a + // commit never carries new pixels in the old bounds. A first frame + // (nothing shown) presents itself. + if (embedderPresents(layer)) embedder: { + const shown = layer.getProperty(?*anyopaque, "contents") orelse break :embedder; + const shown_surface: *IOSurface = @ptrCast(shown); + if (shown_surface.getWidth() == surface.getWidth() and + shown_surface.getHeight() == surface.getHeight()) break :embedder; + const bounds_width: usize = @intFromFloat(bounds.size.width * scale); + const bounds_height: usize = @intFromFloat(bounds.size.height * scale); + if (bounds_width != surface.getWidth() or bounds_height != surface.getHeight()) return; + } + if (block.seq != 0) setPresentSeq(layer, block.seq); + if (embedderPresents(layer) and changesShownSize(layer, surface)) setMainPending(layer, true); layer.setProperty("contents", surface); } @@ -273,6 +410,8 @@ fn getSubclass() error{ObjCFailed}!objc.Class { if (!subclass.addIvar("display_ctx")) return error.ObjCFailed; if (!subclass.addIvar("host_size")) return error.ObjCFailed; if (!subclass.addIvar("present_seq")) return error.ObjCFailed; + if (!subclass.addIvar("embedder_presents")) return error.ObjCFailed; + if (!subclass.addIvar("main_pending")) return error.ObjCFailed; subclass.replaceMethod("display", struct { fn display(target: objc.c.id, sel: objc.c.SEL) callconv(.c) void { @@ -307,3 +446,293 @@ fn getSubclass() error{ObjCFailed}!objc.Class { return subclass; } + +fn testSurface(width: c_int, height: c_int) !*IOSurface { + return try IOSurface.init(.{ + .width = width, + .height = height, + .pixel_format = .@"32BGRA", + .bytes_per_element = 4, + .colorspace = null, + }); +} + +/// The width of the frame the layer shows; null while it shows none. +fn testShownWidth(self: *const IOSurfaceLayer) ?usize { + const shown = self.layer.getProperty(?*anyopaque, "contents") orelse return null; + const surface: *IOSurface = @ptrCast(shown); + return surface.getWidth(); +} + +fn testIsMainThread() bool { + return objc.getClass("NSThread").?.msgSend(bool, "isMainThread", .{}); +} + +test "host present: a frame of a new host size on main keeps the shown frame while the embedder presents sizes" { + const testing = std.testing; + // setSurface runs the main-thread present at once only on the main thread. + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + + var layer = try IOSurfaceLayer.init(); + defer layer.release(); + const shown = try testSurface(80, 60); + defer shown.release(); + const drawn = try testSurface(120, 60); + defer drawn.release(); + layer.setSurfaceSync(shown); + layer.setHostSize((120 << 32) | 60); + layer.setEmbedderPresents(true); + + // A frame of the new size that reached the main thread (queued before the + // embedder took over size changes): the embedder shows a new size with its + // bounds in its own transaction, so this frame does not replace the shown one. + try layer.setSurface(drawn, 1); + try testing.expectEqual(@as(?usize, 80), testShownWidth(&layer)); +} + +test "host present: a frame of a new host size on main is shown by default" { + const testing = std.testing; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + + var layer = try IOSurfaceLayer.init(); + defer layer.release(); + const shown = try testSurface(80, 60); + defer shown.release(); + const drawn = try testSurface(120, 60); + defer drawn.release(); + layer.setSurfaceSync(shown); + layer.setHostSize((120 << 32) | 60); + + try layer.setSurface(drawn, 1); + try testing.expectEqual(@as(?usize, 120), testShownWidth(&layer)); +} + +test "host present: a frame of a new host size on main is shown when the layer's bounds already have that size" { + const testing = std.testing; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + + var layer = try IOSurfaceLayer.init(); + defer layer.release(); + const shown = try testSurface(80, 60); + defer shown.release(); + const drawn = try testSurface(120, 60); + defer drawn.release(); + layer.setSurfaceSync(shown); + layer.setHostSize((120 << 32) | 60); + layer.setEmbedderPresents(true); + // The embedder gave the view its new bounds without a present (a hidden + // surface, a frame that came too late): the new frame fits them. + layer.layer.setProperty("contentsScale", @as(f64, 1)); + layer.layer.setProperty("bounds", macos.graphics.Rect{ + .origin = .{ .x = 0, .y = 0 }, + .size = .{ .width = 120, .height = 60 }, + }); + + try layer.setSurface(drawn, 1); + try testing.expectEqual(@as(?usize, 120), testShownWidth(&layer)); +} + +test "host present: an off-main frame never joins a present the embedder has not committed yet" { + const testing = std.testing; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + + var layer = try IOSurfaceLayer.init(); + defer layer.release(); + const shown = try testSurface(80, 60); + defer shown.release(); + const presented = try testSurface(120, 60); + defer presented.release(); + const next = try testSurface(120, 60); + defer next.release(); + const after = try testSurface(120, 60); + defer after.release(); + layer.setSurfaceSync(shown); + layer.setHostSize((120 << 32) | 60); + layer.setEmbedderPresents(true); + + // The embedder showed the new size; its transaction (with the view's new + // bounds) is not committed yet. + try testing.expect(layer.setSurfacePresented(presented, 5)); + // A newer frame at that size completes: an off-main transaction would + // commit it, alone, before the embedder's bounds. It goes through main. + try testing.expect(!layer.setSurfaceOffMain(next, 6)); + const contents = layer.layer.getProperty(?*anyopaque, "contents"); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(presented)), contents); + // On main it shows; once the embedder committed, frames go off main again. + try layer.setSurface(next, 6); + layer.setEmbedderCommitted(); + try testing.expect(layer.setSurfaceOffMain(after, 7)); + const later = layer.layer.getProperty(?*anyopaque, "contents"); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(after)), later); +} + +test "host present: a main-thread frame after the embedder's present keeps frames on main until the embedder committed" { + const testing = std.testing; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + + var layer = try IOSurfaceLayer.init(); + defer layer.release(); + const shown = try testSurface(80, 60); + defer shown.release(); + const presented = try testSurface(120, 60); + defer presented.release(); + const queued = try testSurface(120, 60); + defer queued.release(); + const next = try testSurface(120, 60); + defer next.release(); + const later = try testSurface(120, 60); + defer later.release(); + layer.setSurfaceSync(shown); + layer.setHostSize((120 << 32) | 60); + layer.setEmbedderPresents(true); + + try testing.expect(layer.setSurfacePresented(presented, 5)); + // A main-queue block runs before the embedder's commit (CFRunLoop runs + // main-queue blocks before the before-waiting observers): it shows a + // newer frame at that size in main's open transaction. + try layer.setSurface(queued, 6); + // An off-main commit now would still go out ahead of the embedder's bounds. + try testing.expect(!layer.setSurfaceOffMain(next, 7)); + // The embedder committed: frames go off main again. + layer.setEmbedderCommitted(); + try testing.expect(layer.setSurfaceOffMain(later, 8)); +} + +test "host present: a frame shown because the bounds already fit keeps frames on main until the embedder committed" { + const testing = std.testing; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + + var layer = try IOSurfaceLayer.init(); + defer layer.release(); + const shown = try testSurface(80, 60); + defer shown.release(); + const drawn = try testSurface(120, 60); + defer drawn.release(); + const next = try testSurface(120, 60); + defer next.release(); + layer.setSurfaceSync(shown); + layer.setHostSize((120 << 32) | 60); + layer.setEmbedderPresents(true); + layer.setEmbedderCommitted(); + // The embedder gave the view its new bounds without a present. + layer.layer.setProperty("contentsScale", @as(f64, 1)); + layer.layer.setProperty("bounds", macos.graphics.Rect{ + .origin = .{ .x = 0, .y = 0 }, + .size = .{ .width = 120, .height = 60 }, + }); + try layer.setSurface(drawn, 1); + try testing.expectEqual(@as(?usize, 120), testShownWidth(&layer)); + // Those bounds are not committed yet either. + try testing.expect(!layer.setSurfaceOffMain(next, 2)); +} + +test "host present: a frame at the shown size shown on main keeps frames going off main" { + const testing = std.testing; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + + var layer = try IOSurfaceLayer.init(); + defer layer.release(); + const shown = try testSurface(120, 60); + defer shown.release(); + const queued = try testSurface(120, 60); + defer queued.release(); + const next = try testSurface(120, 60); + defer next.release(); + layer.setSurfaceSync(shown); + layer.setHostSize((120 << 32) | 60); + layer.setEmbedderPresents(true); + layer.setEmbedderCommitted(); + + // A frame of the shown size on main changes no geometry: terminal echo + // after it still goes off main. + try layer.setSurface(queued, 1); + try testing.expect(layer.setSurfaceOffMain(next, 2)); + const contents = layer.layer.getProperty(?*anyopaque, "contents"); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(next)), contents); +} + +test "host present: the mode starts with frames on main until the embedder committed" { + const testing = std.testing; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + + var layer = try IOSurfaceLayer.init(); + defer layer.release(); + const shown = try testSurface(120, 60); + defer shown.release(); + const first = try testSurface(120, 60); + defer first.release(); + const later = try testSurface(120, 60); + defer later.release(); + layer.setSurfaceSync(shown); + layer.setHostSize((120 << 32) | 60); + + // A change the main thread made before the mode may still be in its open + // transaction. + layer.setEmbedderPresents(true); + try testing.expect(!layer.setSurfaceOffMain(first, 1)); + layer.setEmbedderCommitted(); + try testing.expect(layer.setSurfaceOffMain(later, 2)); +} + +test "host present: a sync present of a new size keeps frames on main until the embedder committed" { + const testing = std.testing; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + + var layer = try IOSurfaceLayer.init(); + defer layer.release(); + const shown = try testSurface(80, 60); + defer shown.release(); + const drawn = try testSurface(120, 60); + defer drawn.release(); + const next = try testSurface(120, 60); + defer next.release(); + layer.setSurfaceSync(shown); + layer.setHostSize((120 << 32) | 60); + layer.setEmbedderPresents(true); + layer.setEmbedderCommitted(); + + // ghostty_surface_draw on main at the new size. + layer.setSurfaceSync(drawn); + try testing.expect(!layer.setSurfaceOffMain(next, 1)); +} + +test "host present: a sync present at the shown size keeps frames going off main" { + const testing = std.testing; + try testing.expect(testIsMainThread()); + const pool = objc.AutoreleasePool.init(); + defer pool.deinit(); + + var layer = try IOSurfaceLayer.init(); + defer layer.release(); + const shown = try testSurface(120, 60); + defer shown.release(); + const drawn = try testSurface(120, 60); + defer drawn.release(); + const next = try testSurface(120, 60); + defer next.release(); + layer.setSurfaceSync(shown); + layer.setHostSize((120 << 32) | 60); + layer.setEmbedderPresents(true); + layer.setEmbedderCommitted(); + + layer.setSurfaceSync(drawn); + try testing.expect(layer.setSurfaceOffMain(next, 1)); +}