aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/renderer
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-10 21:51:57 +0300
committerGitHub <noreply@github.com>2026-09-10 21:51:57 +0300
commit425c86179791cd32e2d4ce0ae7f269940b4b0663 (patch)
treee52c4383a6d69216cbe2a32638bf90841d7e2189 /vendor/libghostty-vt/src/renderer
parent90e947a6e26e02f10ec7f1694c4eb5b78a86406b (diff)
chore: upgrade libghostty and preserve terminal behavior (#3906)
Diffstat (limited to 'vendor/libghostty-vt/src/renderer')
-rw-r--r--vendor/libghostty-vt/src/renderer/Metal.zig38
-rw-r--r--vendor/libghostty-vt/src/renderer/OpenGL.zig6
-rw-r--r--vendor/libghostty-vt/src/renderer/Overlay.zig5
-rw-r--r--vendor/libghostty-vt/src/renderer/State.zig21
-rw-r--r--vendor/libghostty-vt/src/renderer/Thread.zig210
-rw-r--r--vendor/libghostty-vt/src/renderer/cell.zig156
-rw-r--r--vendor/libghostty-vt/src/renderer/cursor.zig12
-rw-r--r--vendor/libghostty-vt/src/renderer/generic.zig540
-rw-r--r--vendor/libghostty-vt/src/renderer/image.zig733
-rw-r--r--vendor/libghostty-vt/src/renderer/link.zig9
-rw-r--r--vendor/libghostty-vt/src/renderer/metal/IOSurfaceLayer.zig4
-rw-r--r--vendor/libghostty-vt/src/renderer/metal/api.zig9
-rw-r--r--vendor/libghostty-vt/src/renderer/metal/buffer.zig8
-rw-r--r--vendor/libghostty-vt/src/renderer/metal/shaders.zig30
-rw-r--r--vendor/libghostty-vt/src/renderer/opengl/shaders.zig24
-rw-r--r--vendor/libghostty-vt/src/renderer/shadertoy.zig12
16 files changed, 1315 insertions, 502 deletions
diff --git a/vendor/libghostty-vt/src/renderer/Metal.zig b/vendor/libghostty-vt/src/renderer/Metal.zig
index 6c7432d2..9247454d 100644
--- a/vendor/libghostty-vt/src/renderer/Metal.zig
+++ b/vendor/libghostty-vt/src/renderer/Metal.zig
@@ -407,6 +407,44 @@ pub inline fn beginFrame(
return try Frame.begin(.{ .queue = self.queue }, renderer, target);
}
+/// Warm up the Metal device machinery. The first Metal device query in
+/// a process takes multiple milliseconds; once warm, subsequent queries
+/// are effectively free. Calling this early (e.g. on a background
+/// thread at app startup; Metal device queries are thread-safe) moves
+/// that one-time cost off the critical path of the first surface's
+/// renderer initialization.
+pub fn warmup() void {
+ const device = chooseDevice() catch return;
+ defer device.release();
+
+ // Create and release a command queue. The first command queue
+ // created for a device pays additional one-time driver setup
+ // costs; subsequent creations are much cheaper.
+ const queue = device.msgSend(objc.Object, objc.sel("newCommandQueue"), .{});
+ queue.release();
+
+ // Build and discard our shader pipelines for both pixel formats we
+ // may use (which one is used depends on the blending config). The
+ // first pipeline state creation compiles shaders which is slow;
+ // once warm, later creations hit driver and OS caches.
+ inline for (.{
+ mtl.MTLPixelFormat.bgra8unorm_srgb,
+ mtl.MTLPixelFormat.bgra8unorm,
+ }) |format| {
+ if (shaders.Shaders.init(
+ std.heap.c_allocator,
+ device,
+ &.{},
+ format,
+ )) |s| {
+ var s_mut = s;
+ s_mut.deinit(std.heap.c_allocator);
+ } else |err| {
+ log.warn("metal warmup shader init failed err={}", .{err});
+ }
+ }
+}
+
fn chooseDevice() error{NoMetalDevice}!objc.Object {
var chosen_device: ?objc.Object = null;
diff --git a/vendor/libghostty-vt/src/renderer/OpenGL.zig b/vendor/libghostty-vt/src/renderer/OpenGL.zig
index 4b01da0c..40c11f60 100644
--- a/vendor/libghostty-vt/src/renderer/OpenGL.zig
+++ b/vendor/libghostty-vt/src/renderer/OpenGL.zig
@@ -335,6 +335,12 @@ pub fn presentLastTarget(self: *OpenGL) !void {
if (self.last_target) |target| try self.present(target);
}
+/// Called when the renderer released its GPU resources; the last
+/// presented target is deinited with them so we must drop our copy.
+pub fn gpuResourcesReleased(self: *OpenGL) void {
+ self.last_target = null;
+}
+
/// Returns the options to use when constructing buffers.
pub inline fn bufferOptions(self: OpenGL) bufferpkg.Options {
_ = self;
diff --git a/vendor/libghostty-vt/src/renderer/Overlay.zig b/vendor/libghostty-vt/src/renderer/Overlay.zig
index 62eb004b..46a75be8 100644
--- a/vendor/libghostty-vt/src/renderer/Overlay.zig
+++ b/vendor/libghostty-vt/src/renderer/Overlay.zig
@@ -20,6 +20,7 @@ const size = @import("size.zig");
const Size = size.Size;
const CellSize = size.CellSize;
const Image = @import("image.zig").Image;
+const global = @import("../global.zig");
const log = std.log.scoped(.renderer_overlay);
@@ -337,7 +338,7 @@ fn highlightGridRect(
const end_y: f64 = start_y + @as(f64, @floatFromInt(px_height));
// Grab our context to draw
- var ctx: z2d.Context = .init(alloc, &self.surface);
+ var ctx: z2d.Context = .init(global.io(), alloc, &self.surface);
defer ctx.deinit();
// Don't need AA because we use sharp edges
@@ -394,7 +395,7 @@ fn highlightPixelRect(
const end_x: f64 = start_x + @as(f64, @floatFromInt(px_width));
const end_y: f64 = start_y + @as(f64, @floatFromInt(px_height));
- var ctx: z2d.Context = .init(alloc, &self.surface);
+ var ctx: z2d.Context = .init(global.io(), alloc, &self.surface);
defer ctx.deinit();
ctx.setAntiAliasingMode(.none);
diff --git a/vendor/libghostty-vt/src/renderer/State.zig b/vendor/libghostty-vt/src/renderer/State.zig
index 8693458f..1e38d64a 100644
--- a/vendor/libghostty-vt/src/renderer/State.zig
+++ b/vendor/libghostty-vt/src/renderer/State.zig
@@ -14,7 +14,7 @@ const renderer = @import("../renderer.zig");
/// members of this state. Note that the state itself is NOT protected
/// by the mutex and is NOT thread-safe, only the members values of the
/// state (i.e. the terminal, devmode, etc. values).
-mutex: *std.Thread.Mutex,
+mutex: *std.Io.Mutex,
/// The terminal data.
terminal: *terminalpkg.Terminal,
@@ -64,19 +64,19 @@ const handoff_timeout_ns = 1 * std.time.ns_per_ms;
/// scheduled. Under sustained pty output the IO parse thread is
/// exactly such a loop, so without this signal the renderer can
/// starve for as long as the output lasts.
-pub fn lockDemand(self: *State) void {
+pub fn lockDemand(self: *State, io: std.Io) void {
_ = self.demand.fetchAdd(1, .monotonic);
- self.mutex.lock();
+ self.mutex.lockUncancelable(io);
const prev = self.demand.fetchSub(1, .monotonic);
assert(prev > 0);
}
/// Release `mutex` acquired via `lockDemand` and notify hot loops
/// parked in `yieldToDemand` that the demanding waiter had its turn.
-pub fn unlockDemand(self: *State) void {
- self.mutex.unlock();
+pub fn unlockDemand(self: *State, io: std.Io) void {
+ self.mutex.unlock(io);
_ = self.handoff_gen.fetchAdd(1, .monotonic);
- std.Thread.Futex.wake(&self.handoff_gen, 1);
+ io.futexWake(@TypeOf(self.handoff_gen), &self.handoff_gen, 1);
}
/// Called by hot lock/unlock loops between critical sections, with
@@ -88,7 +88,7 @@ pub fn unlockDemand(self: *State) void {
/// scheduling heuristic, not a synchronization boundary: the mutex
/// itself orders the protected data, and the timeout bounds any
/// staleness.
-pub fn yieldToDemand(self: *State) void {
+pub fn yieldToDemand(self: *State, io: std.Io) void {
if (self.demand.load(.monotonic) == 0) return;
// Snapshot the generation before rechecking demand: if the waiter
@@ -96,10 +96,11 @@ pub fn yieldToDemand(self: *State) void {
// generation no longer matches and timedWait returns immediately.
const gen = self.handoff_gen.load(.monotonic);
if (self.demand.load(.monotonic) == 0) return;
- std.Thread.Futex.timedWait(
+ io.futexWaitTimeout(
+ @TypeOf(self.handoff_gen),
&self.handoff_gen,
- gen,
- handoff_timeout_ns,
+ .init(gen),
+ .{ .duration = .{ .raw = .fromNanoseconds(handoff_timeout_ns), .clock = .awake } },
) catch {};
}
diff --git a/vendor/libghostty-vt/src/renderer/Thread.zig b/vendor/libghostty-vt/src/renderer/Thread.zig
index e397a55a..f66fd384 100644
--- a/vendor/libghostty-vt/src/renderer/Thread.zig
+++ b/vendor/libghostty-vt/src/renderer/Thread.zig
@@ -4,7 +4,8 @@ pub const Thread = @This();
const std = @import("std");
const builtin = @import("builtin");
-const xev = @import("../global.zig").xev;
+const global = @import("../global.zig");
+const xev = global.xev;
const crash = @import("../crash/main.zig");
const internal_os = @import("../os/main.zig");
const rendererpkg = @import("../renderer.zig");
@@ -17,19 +18,10 @@ const App = @import("../App.zig");
const Allocator = std.mem.Allocator;
const log = std.log.scoped(.renderer_thread);
-const DRAW_INTERVAL = 8; // 120 FPS
const CURSOR_BLINK_INTERVAL = 600;
/// Whether calls to `drawFrame` must be done from the app thread.
///
-/// If this is `true` then we send a `redraw_surface` message to the apprt
-/// whenever we need to draw instead of calling `drawFrame` directly.
-const must_draw_from_app_thread =
- if (@hasDecl(apprt.App, "must_draw_from_app_thread"))
- apprt.App.must_draw_from_app_thread
- else
- false;
-
/// The type used for sending messages to the IO thread. For now this is
/// hardcoded with a capacity. We can make this a comptime parameter in
/// the future if we want it configurable.
@@ -52,16 +44,15 @@ wakeup_c: xev.Completion = .{},
stop: xev.Async,
stop_c: xev.Completion = .{},
-/// The timer used for rendering
+/// The timer used for animations (custom shaders, Kitty graphics).
+/// Normal rendering is driven by wakeup messages instead.
render_h: xev.Timer,
render_c: xev.Completion = .{},
+render_c_cancel: xev.Completion = .{},
-/// The timer used for draw calls. Draw calls don't update from the
-/// terminal state so they're much cheaper. They're used for animation
-/// and are paused when the terminal is not focused.
-draw_h: xev.Timer,
-draw_c: xev.Completion = .{},
-draw_active: bool = false,
+/// The kind of work the currently scheduled animation wake needs,
+/// stored when the timer is armed.
+animation_wake: rendererpkg.Renderer.AnimationWake.Kind = .draw,
/// This async is used to force a draw immediately. This does not
/// coalesce like the wakeup does.
@@ -114,12 +105,10 @@ flags: packed struct {
} = .{},
pub const DerivedConfig = struct {
- custom_shader_animation: configpkg.CustomShaderAnimation,
scrollback_compression: bool,
pub fn init(config: *const configpkg.Config) DerivedConfig {
return .{
- .custom_shader_animation = config.@"custom-shader-animation",
.scrollback_compression = config.@"scrollback-compression",
};
}
@@ -152,10 +141,6 @@ pub fn init(
var render_h = try xev.Timer.init();
errdefer render_h.deinit();
- // Draw timer, see comments.
- var draw_h = try xev.Timer.init();
- errdefer draw_h.deinit();
-
// Draw now async, see comments.
var draw_now = try xev.Async.init();
errdefer draw_now.deinit();
@@ -175,7 +160,6 @@ pub fn init(
.wakeup = wakeup_h,
.stop = stop_h,
.render_h = render_h,
- .draw_h = draw_h,
.draw_now = draw_now,
.cursor_h = cursor_timer,
.surface = surface,
@@ -200,7 +184,6 @@ pub fn deinit(self: *Thread) void {
self.stop.deinit();
self.wakeup.deinit();
self.render_h.deinit();
- self.draw_h.deinit();
self.draw_now.deinit();
self.cursor_h.deinit();
if (comptime terminalpkg.compression_enabled)
@@ -269,8 +252,9 @@ fn threadMain_(self: *Thread) !void {
cursorTimerCallback,
);
- // Start the draw timer
- self.syncDrawTimer();
+ // Arm the animation timer in case the renderer already needs
+ // animation wakes (e.g. custom shaders loaded at startup).
+ self.armAnimationTimer();
// Run
log.debug("starting renderer thread", .{});
@@ -308,47 +292,6 @@ fn setQosClass(self: *const Thread) void {
}
}
-fn syncDrawTimer(self: *Thread) void {
- skip: {
- // If our renderer supports animations and has them, then we
- // can apply draw timer based on custom shader animation configuration.
- if (@hasDecl(rendererpkg.Renderer, "hasAnimations") and
- self.renderer.hasAnimations())
- {
- // If our config says to always animate, we do so.
- switch (self.config.custom_shader_animation) {
- // Always animate
- .always => break :skip,
- // Only when focused
- .true => if (self.flags.focused) break :skip,
- // Never animate
- .false => {},
- }
- }
-
- // We're skipping the draw timer. Stop it on the next iteration.
- self.draw_active = false;
- return;
- }
-
- // Set our active state so it knows we're running. We set this before
- // even checking the active state in case we have a pending shutdown.
- self.draw_active = true;
-
- // If our draw timer is already active, then we don't have to do anything.
- if (self.draw_c.state() == .active) return;
-
- // Start the timer which loops
- self.draw_h.run(
- &self.loop,
- &self.draw_c,
- DRAW_INTERVAL,
- Thread,
- self,
- drawCallback,
- );
-}
-
/// Drain the mailbox.
fn drainMailbox(self: *Thread) !void {
// There's probably a more elegant way to do this...
@@ -361,7 +304,7 @@ fn drainMailbox(self: *Thread) !void {
void;
defer if (builtin.os.tag.isDarwin()) pool.deinit();
- while (self.mailbox.pop()) |message| {
+ while (self.mailbox.pop(global.io())) |message| {
log.debug("mailbox message={}", .{message});
switch (message) {
.crash => @panic("crash request, crashing intentionally"),
@@ -377,15 +320,11 @@ fn drainMailbox(self: *Thread) !void {
self.setQosClass();
// If we became visible then we immediately rebuild cells
- // (renderCallback skips updateFrame while invisible) and draw.
- if (v) {
- self.renderer.updateFrame(
- self.state,
- self.flags.cursor_blink_visible,
- ) catch |err|
- log.warn("error rendering on visibility regain err={}", .{err});
- self.drawFrame(false);
- }
+ // (renderCallback skips updateFrame while invisible) and
+ // draw. Going through renderCallback also reschedules
+ // any Kitty graphics animation wakeup that lapsed
+ // while we were invisible.
+ if (v) _ = renderCallback(self, undefined, undefined, {});
// Notify the renderer so it can update any state.
self.renderer.setVisible(v);
@@ -411,8 +350,9 @@ fn drainMailbox(self: *Thread) !void {
// Set it on the renderer
try self.renderer.setFocus(v);
- // We always resync our draw timer (may disable it)
- self.syncDrawTimer();
+ // Focus gates custom shader animation, so re-arm
+ // the animation timer for the new state.
+ self.armAnimationTimer();
if (!v) {
// If we're not focused, then we stop the cursor blink
@@ -473,9 +413,9 @@ fn drainMailbox(self: *Thread) !void {
try self.changeConfig(config.thread);
try self.renderer.changeConfig(config.impl);
- // Stop and start the draw timer to capture the new
- // hasAnimations value.
- self.syncDrawTimer();
+ // The config affects what animation wakes the
+ // renderer needs (custom shaders, animation mode).
+ self.armAnimationTimer();
},
.search_viewport_matches => |v| {
@@ -500,7 +440,7 @@ fn drainMailbox(self: *Thread) !void {
.macos_display_id => |v| {
if (@hasDecl(rendererpkg.Renderer, "setMacOSDisplayID")) {
- try self.renderer.setMacOSDisplayID(v);
+ try self.renderer.setMacOSDisplayID(v, &self.draw_now);
}
},
}
@@ -531,7 +471,7 @@ fn drawFrame(self: *Thread, now: bool) void {
// when we're forced to via `now`.
if (!now and self.renderer.hasVsync()) return;
- if (must_draw_from_app_thread) {
+ if (apprt.must_draw_from_app_thread) {
_ = self.app_mailbox.push(
.{ .redraw_surface = self.surface },
.{ .instant = {} },
@@ -605,58 +545,110 @@ fn drawNowCallback(
return .rearm;
}
-fn drawCallback(
+fn renderCallback(
self_: ?*Thread,
_: *xev.Loop,
_: *xev.Completion,
r: xev.Timer.RunError!void,
) xev.CallbackAction {
- _ = r catch unreachable;
+ _ = r catch |err| switch (err) {
+ // Sent when a scheduled animation wakeup is superseded by a
+ // newer one (Timer.reset cancels the pending run). Nothing to
+ // do; the replacement timer carries on.
+ error.Canceled => return .disarm,
+ else => unreachable,
+ };
const t: *Thread = self_ orelse {
// This shouldn't happen so we log it.
log.warn("render callback fired without data set", .{});
return .disarm;
};
+ // If we're not visible there's no point spending CPU rebuilding cells —
+ // we'll catch up when the .visible mailbox message flips us back on.
+ // Kitty graphics animations pause with us and resume on visibility.
+ if (!t.flags.visible) return .disarm;
+
+ // Update our frame data
+ t.renderer.updateFrame(
+ t.state,
+ t.flags.cursor_blink_visible,
+ ) catch |err|
+ log.warn("error rendering err={}", .{err});
+
// Draw
t.drawFrame(false);
- // Only continue if we're still active
- if (t.draw_active) {
- t.draw_h.run(&t.loop, &t.draw_c, DRAW_INTERVAL, Thread, t, drawCallback);
- }
+ // Schedule the next animation wake, if the renderer needs one.
+ t.armAnimationTimer();
return .disarm;
}
-fn renderCallback(
+/// Schedule the animation timer for the renderer's next animation
+/// wake, if it needs one.
+///
+/// This is called after every frame update or animation draw and
+/// whenever the wake inputs change (focus, config, visibility
+/// regain). Resetting a pending timer is always safe: every call
+/// recomputes the wake, so the deadline only ever moves toward the
+/// actual next wake.
+fn armAnimationTimer(self: *Thread) void {
+ const wake = self.renderer.animationWake() orelse return;
+ self.animation_wake = wake.kind;
+ self.render_h.reset(
+ &self.loop,
+ &self.render_c,
+ &self.render_c_cancel,
+ wake.delay_ms,
+ Thread,
+ self,
+ animationTimerCallback,
+ );
+}
+
+fn animationTimerCallback(
self_: ?*Thread,
_: *xev.Loop,
_: *xev.Completion,
r: xev.Timer.RunError!void,
) xev.CallbackAction {
- _ = r catch unreachable;
+ _ = r catch |err| switch (err) {
+ // Sent when a scheduled animation wake is superseded by a
+ // newer one (Timer.reset cancels the pending run). Nothing to
+ // do; the replacement timer carries on.
+ error.Canceled => return .disarm,
+ else => unreachable,
+ };
const t: *Thread = self_ orelse {
// This shouldn't happen so we log it.
- log.warn("render callback fired without data set", .{});
+ log.warn("animation callback fired without data set", .{});
return .disarm;
};
- // If we're not visible there's no point spending CPU rebuilding cells —
- // we'll catch up when the .visible mailbox message flips us back on.
+ // Animations pause entirely while we're invisible; the .visible
+ // mailbox message re-arms us when we can be seen again.
if (!t.flags.visible) return .disarm;
- // Update our frame data
- t.renderer.updateFrame(
- t.state,
- t.flags.cursor_blink_visible,
- ) catch |err|
- log.warn("error rendering err={}", .{err});
-
- // Draw
- t.drawFrame(false);
-
- return .disarm;
+ switch (t.animation_wake) {
+ // Frame data must be updated (a Kitty animation frame is
+ // due). renderCallback updates, draws, and re-arms us.
+ .update => return renderCallback(
+ t,
+ undefined,
+ undefined,
+ {},
+ ),
+
+ // A redraw alone suffices (custom shader time uniform).
+ // Draw calls don't update from the terminal state so they
+ // are much cheaper than a frame update.
+ .draw => {
+ t.drawFrame(false);
+ t.armAnimationTimer();
+ return .disarm;
+ },
+ }
}
fn cursorTimerCallback(
@@ -788,7 +780,7 @@ const Compression = struct {
// frame without changing terminal contents and must not starve this
// timer indefinitely.
if (thread.state.mutex.tryLock()) {
- defer thread.state.mutex.unlock();
+ defer thread.state.mutex.unlock(global.io());
const activity = thread.state.terminal.compressionActivity();
if (self.activity == activity) return;
self.activity = activity;
@@ -847,7 +839,7 @@ const Compression = struct {
const state = thread.state;
if (!state.mutex.tryLock()) return idle_interval;
- defer state.mutex.unlock();
+ defer state.mutex.unlock(global.io());
const activity = state.terminal.compressionActivity();
if (self.activity != activity) {
diff --git a/vendor/libghostty-vt/src/renderer/cell.zig b/vendor/libghostty-vt/src/renderer/cell.zig
index 196ebb17..8753d1ad 100644
--- a/vendor/libghostty-vt/src/renderer/cell.zig
+++ b/vendor/libghostty-vt/src/renderer/cell.zig
@@ -5,9 +5,10 @@ const font = @import("../font/main.zig");
const terminal = @import("../terminal/main.zig");
const renderer = @import("../renderer.zig");
const shaderpkg = renderer.Renderer.API.shaders;
-const ArrayListCollection = @import("../datastruct/array_list_collection.zig").ArrayListCollection;
const symbols = @import("../unicode/symbols_table.zig").table;
+const CellTextRow = std.ArrayListUnmanaged(shaderpkg.CellText);
+
/// The possible cell content keys that exist.
pub const Key = enum {
bg,
@@ -47,11 +48,11 @@ pub const Contents = struct {
///
/// Prefer accessing with `Contents.bgCell(row, col).*` instead
/// of directly indexing in order to avoid integer size bugs.
- bg_cells: []shaderpkg.CellBg = undefined,
+ bg_cells: []shaderpkg.CellBg = &.{},
- /// The ArrayListCollection which holds all of the foreground cells. When
- /// sized with Contents.resize the individual ArrayLists are given enough
- /// room that they can hold a single row with #cols glyphs, underlines, and
+ /// The lists which hold all of the foreground cells. When sized with
+ /// Contents.resize the individual ArrayLists are given enough room that
+ /// they can hold a single row with #cols glyphs, underlines, and
/// strikethroughs; however, appendAssumeCapacity MUST NOT be used since
/// it is possible to exceed this with combining glyphs that add a glyph
/// but take up no column since they combine with the previous one, as
@@ -70,11 +71,12 @@ pub const Contents = struct {
///
/// Must be initialized by calling resize on the Contents struct before
/// calling any operations.
- fg_rows: ArrayListCollection(shaderpkg.CellText) = .{ .lists = &.{} },
+ fg_rows: []CellTextRow = &.{},
pub fn deinit(self: *Contents, alloc: Allocator) void {
alloc.free(self.bg_cells);
- self.fg_rows.deinit(alloc);
+ for (self.fg_rows) |*row| row.deinit(alloc);
+ alloc.free(self.fg_rows);
}
/// Resize the cell contents for the given grid size. This will
@@ -84,55 +86,50 @@ pub const Contents = struct {
alloc: Allocator,
size: renderer.GridSize,
) Allocator.Error!void {
- self.size = size;
+ const row_count: usize = size.rows;
+
+ // The two extra lists hold cursor cells: index 0 is drawn before the
+ // row contents, and index row_count + 1 is drawn after them.
+ const fg_rows = try alloc.alloc(CellTextRow, row_count + 2);
+ @memset(fg_rows, .empty);
+ errdefer {
+ for (fg_rows) |*row| row.deinit(alloc);
+ alloc.free(fg_rows);
+ }
- const cell_count = @as(usize, size.columns) * @as(usize, size.rows);
-
- const bg_cells = try alloc.alloc(shaderpkg.CellBg, cell_count);
- errdefer alloc.free(bg_cells);
- @memset(bg_cells, .{ 0, 0, 0, 0 });
-
- // The foreground lists can hold 3 types of items:
- // - Glyphs
- // - Underlines
- // - Strikethroughs
- // So we give them an initial capacity of size.columns * 3, which will
- // avoid any further allocations in the vast majority of cases. Sadly
- // we can not assume capacity though, since with combining glyphs that
- // form a single grapheme, and multi-substitutions in fonts, the number
- // of glyphs in a row is theoretically unlimited.
- //
- // We have size.rows + 2 lists because indexes 0 and size.rows - 1 are
- // used for special lists containing the cursor cell which need to
- // be first and last in the buffer, respectively.
- var fg_rows: ArrayListCollection(shaderpkg.CellText) = try .init(
- alloc,
- size.rows + 2,
- size.columns * 3,
- );
- errdefer fg_rows.deinit(alloc);
+ // Foreground rows hold glyphs plus underlines, overlines, and
+ // strikethroughs. Three entries per column cover the common cases
+ // without reserving space for every decoration combination. We can't
+ // assume this capacity because combining glyphs and font substitutions
+ // can produce arbitrarily many glyphs in one column.
+ const fg_row_capacity = @as(usize, size.columns) * 3;
+
+ // The cursor lists need just one cell. The rest get the full capacity.
+ fg_rows[0] = try .initCapacity(alloc, 1);
+ fg_rows[row_count + 1] = try .initCapacity(alloc, 1);
+ for (fg_rows[1 .. row_count + 1]) |*row| {
+ row.* = try .initCapacity(alloc, fg_row_capacity);
+ }
- // We don't need 3*cols worth of cells for the cursor lists, so we can
- // replace them with smaller lists. This is technically a tiny bit of
- // extra work but resize is not a hot function so it's worth it to not
- // waste the memory.
- fg_rows.lists[0].deinit(alloc);
- fg_rows.lists[0] = try .initCapacity(alloc, 1);
- fg_rows.lists[size.rows + 1].deinit(alloc);
- fg_rows.lists[size.rows + 1] = try .initCapacity(alloc, 1);
+ const bg_cells = try alloc.realloc(
+ self.bg_cells,
+ row_count * @as(usize, size.columns),
+ );
// Perform the swap, no going back from here.
errdefer comptime unreachable;
- alloc.free(self.bg_cells);
- self.fg_rows.deinit(alloc);
+ for (self.fg_rows) |*row| row.deinit(alloc);
+ alloc.free(self.fg_rows);
+ self.size = size;
self.bg_cells = bg_cells;
self.fg_rows = fg_rows;
+ self.reset();
}
/// Reset the cell contents to an empty state without resizing.
pub fn reset(self: *Contents) void {
@memset(self.bg_cells, .{ 0, 0, 0, 0 });
- self.fg_rows.reset();
+ for (self.fg_rows) |*row| row.clearRetainingCapacity();
}
/// Set the cursor value. If the value is null then the cursor is hidden.
@@ -142,28 +139,28 @@ pub const Contents = struct {
cursor_style: ?renderer.CursorStyle,
) void {
if (self.size.rows == 0) return;
- self.fg_rows.lists[0].clearRetainingCapacity();
- self.fg_rows.lists[self.size.rows + 1].clearRetainingCapacity();
+ self.fg_rows[0].clearRetainingCapacity();
+ self.fg_rows[self.size.rows + 1].clearRetainingCapacity();
const cell = v orelse return;
const style = cursor_style orelse return;
switch (style) {
// Block cursors should be drawn first
- .block => self.fg_rows.lists[0].appendAssumeCapacity(cell),
+ .block => self.fg_rows[0].appendAssumeCapacity(cell),
// Other cursor styles should be drawn last
- .block_hollow, .bar, .underline, .lock => self.fg_rows.lists[self.size.rows + 1].appendAssumeCapacity(cell),
+ .block_hollow, .bar, .underline, .lock => self.fg_rows[self.size.rows + 1].appendAssumeCapacity(cell),
}
}
/// Returns the current cursor glyph if present, checking both cursor lists.
pub fn getCursorGlyph(self: *Contents) ?shaderpkg.CellText {
if (self.size.rows == 0) return null;
- if (self.fg_rows.lists[0].items.len > 0) {
- return self.fg_rows.lists[0].items[0];
+ if (self.fg_rows[0].items.len > 0) {
+ return self.fg_rows[0].items[0];
}
- if (self.fg_rows.lists[self.size.rows + 1].items.len > 0) {
- return self.fg_rows.lists[self.size.rows + 1].items[0];
+ if (self.fg_rows[self.size.rows + 1].items.len > 0) {
+ return self.fg_rows[self.size.rows + 1].items[0];
}
return null;
}
@@ -201,7 +198,7 @@ pub const Contents = struct {
// We have a special list containing the cursor cell at the start
// of our fg row collection, so we need to add 1 to the y to get
// the correct index.
- => try self.fg_rows.lists[y + 1].append(alloc, cell),
+ => try self.fg_rows[y + 1].append(alloc, cell),
}
}
@@ -214,7 +211,7 @@ pub const Contents = struct {
// We have a special list containing the cursor cell at the start
// of our fg row collection, so we need to add 1 to the y to get
// the correct index.
- self.fg_rows.lists[y + 1].clearRetainingCapacity();
+ self.fg_rows[y + 1].clearRetainingCapacity();
}
};
@@ -362,14 +359,14 @@ test Contents {
// We should start off empty after resizing.
for (0..rows) |y| {
- try testing.expect(c.fg_rows.lists[y + 1].items.len == 0);
+ try testing.expect(c.fg_rows[y + 1].items.len == 0);
for (0..cols) |x| {
try testing.expectEqual(.{ 0, 0, 0, 0 }, c.bgCell(y, x).*);
}
}
// And the cursor row should have a capacity of 1 and also be empty.
- try testing.expect(c.fg_rows.lists[0].capacity == 1);
- try testing.expect(c.fg_rows.lists[0].items.len == 0);
+ try testing.expect(c.fg_rows[0].capacity == 1);
+ try testing.expect(c.fg_rows[0].items.len == 0);
// Add some contents.
const bg_cell: shaderpkg.CellBg = .{ 0, 0, 0, 1 };
@@ -382,12 +379,12 @@ test Contents {
try c.add(alloc, .text, fg_cell);
try testing.expectEqual(bg_cell, c.bgCell(1, 4).*);
// The fg row index is offset by 1 because of the cursor list.
- try testing.expectEqual(fg_cell, c.fg_rows.lists[2].items[0]);
+ try testing.expectEqual(fg_cell, c.fg_rows[2].items[0]);
// And we should be able to clear it.
c.clear(1);
for (0..rows) |y| {
- try testing.expect(c.fg_rows.lists[y + 1].items.len == 0);
+ try testing.expect(c.fg_rows[y + 1].items.len == 0);
for (0..cols) |x| {
try testing.expectEqual(.{ 0, 0, 0, 0 }, c.bgCell(y, x).*);
}
@@ -401,20 +398,49 @@ test Contents {
.color = .{ 0, 0, 0, 1 },
};
c.setCursor(cursor_cell, .block);
- try testing.expectEqual(cursor_cell, c.fg_rows.lists[0].items[0]);
+ try testing.expectEqual(cursor_cell, c.fg_rows[0].items[0]);
try testing.expectEqual(cursor_cell, c.getCursorGlyph().?);
// And remove it.
c.setCursor(null, null);
- try testing.expectEqual(0, c.fg_rows.lists[0].items.len);
+ try testing.expectEqual(0, c.fg_rows[0].items.len);
try testing.expect(c.getCursorGlyph() == null);
// Add a hollow cursor.
c.setCursor(cursor_cell, .block_hollow);
- try testing.expectEqual(cursor_cell, c.fg_rows.lists[rows + 1].items[0]);
+ try testing.expectEqual(cursor_cell, c.fg_rows[rows + 1].items[0]);
try testing.expectEqual(cursor_cell, c.getCursorGlyph().?);
}
+test "Contents resize grows and shrinks" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var c: Contents = .{};
+ defer c.deinit(alloc);
+
+ try c.resize(alloc, .{ .rows = 2, .columns = 2 });
+ var cell: shaderpkg.CellText = .{
+ .atlas = .grayscale,
+ .grid_pos = .{ 1, 1 },
+ .color = .{ 0, 0, 0, 1 },
+ };
+ try c.add(alloc, .text, cell);
+ c.bgCell(1, 1).* = .{ 0, 0, 0, 1 };
+ c.setCursor(cell, .bar);
+
+ try c.resize(alloc, .{ .rows = 3, .columns = 4 });
+ try testing.expectEqual(.{ 0, 0, 0, 0 }, c.bgCell(1, 1).*);
+ try testing.expectEqual(@as(usize, 0), c.fg_rows[2].items.len);
+ try testing.expectEqual(@as(?shaderpkg.CellText, null), c.getCursorGlyph());
+ cell.grid_pos = .{ 3, 2 };
+ try c.add(alloc, .text, cell);
+
+ try c.resize(alloc, .{ .rows = 1, .columns = 1 });
+ cell.grid_pos = .{ 0, 0 };
+ try c.add(alloc, .text, cell);
+}
+
test "Contents clear retains other content" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -452,7 +478,7 @@ test "Contents clear retains other content" {
// Row 2 should still contain its cells.
try testing.expectEqual(bg_cell_2, c.bgCell(2, 4).*);
// Fg row index is +1 because of cursor list at start
- try testing.expectEqual(fg_cell_2, c.fg_rows.lists[3].items[0]);
+ try testing.expectEqual(fg_cell_2, c.fg_rows[3].items[0]);
}
test "Contents clear last added content" {
@@ -492,7 +518,7 @@ test "Contents clear last added content" {
// Row 1 should still contain its cells.
try testing.expectEqual(bg_cell_1, c.bgCell(1, 4).*);
// Fg row index is +1 because of cursor list at start
- try testing.expectEqual(fg_cell_1, c.fg_rows.lists[2].items[0]);
+ try testing.expectEqual(fg_cell_1, c.fg_rows[2].items[0]);
}
test "Contents with zero-sized screen" {
@@ -510,7 +536,7 @@ test "Cell constraint widths" {
const testing = std.testing;
const alloc = testing.allocator;
- var t: terminal.Terminal = try .init(alloc, .{
+ var t: terminal.Terminal = try .init(testing.io, alloc, .{
.cols = 4,
.rows = 1,
});
diff --git a/vendor/libghostty-vt/src/renderer/cursor.zig b/vendor/libghostty-vt/src/renderer/cursor.zig
index 33992bc5..3314db29 100644
--- a/vendor/libghostty-vt/src/renderer/cursor.zig
+++ b/vendor/libghostty-vt/src/renderer/cursor.zig
@@ -70,7 +70,8 @@ pub fn style(
test "cursor: default uses configured style" {
const testing = std.testing;
const alloc = testing.allocator;
- var term: terminal.Terminal = try .init(alloc, .{ .cols = 10, .rows = 10 });
+ const io = testing.io;
+ var term: terminal.Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 10 });
defer term.deinit(alloc);
term.screens.active.cursor.cursor_style = .bar;
@@ -89,7 +90,8 @@ test "cursor: default uses configured style" {
test "cursor: blinking disabled" {
const testing = std.testing;
const alloc = testing.allocator;
- var term = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 10 });
+ const io = testing.io;
+ var term = try terminal.Terminal.init(io, alloc, .{ .cols = 10, .rows = 10 });
defer term.deinit(alloc);
term.screens.active.cursor.cursor_style = .bar;
@@ -108,7 +110,8 @@ test "cursor: blinking disabled" {
test "cursor: explicitly not visible" {
const testing = std.testing;
const alloc = testing.allocator;
- var term = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 10 });
+ const io = testing.io;
+ var term = try terminal.Terminal.init(io, alloc, .{ .cols = 10, .rows = 10 });
defer term.deinit(alloc);
term.screens.active.cursor.cursor_style = .bar;
@@ -128,7 +131,8 @@ test "cursor: explicitly not visible" {
test "cursor: always block with preedit" {
const testing = std.testing;
const alloc = testing.allocator;
- var term = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 10 });
+ const io = testing.io;
+ var term = try terminal.Terminal.init(io, alloc, .{ .cols = 10, .rows = 10 });
defer term.deinit(alloc);
var state: terminal.RenderState = .empty;
diff --git a/vendor/libghostty-vt/src/renderer/generic.zig b/vendor/libghostty-vt/src/renderer/generic.zig
index 14b79036..e376e1bf 100644
--- a/vendor/libghostty-vt/src/renderer/generic.zig
+++ b/vendor/libghostty-vt/src/renderer/generic.zig
@@ -1,6 +1,7 @@
const std = @import("std");
const builtin = @import("builtin");
-const xev = @import("xev");
+const global = @import("../global.zig");
+const xev = global.xev;
const wuffs = @import("wuffs");
const apprt = @import("../apprt.zig");
const configpkg = @import("../config.zig");
@@ -26,6 +27,7 @@ const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const Terminal = terminal.Terminal;
const Health = renderer.Health;
+const compat_file = @import("../lib/compat/file.zig");
const getConstraint = @import("../font/nerd_font_attributes.zig").getConstraint;
@@ -98,7 +100,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
/// This mutex must be held whenever any state used in `drawFrame` is
/// being modified, and also when it's being accessed in `drawFrame`.
- draw_mutex: std.Thread.Mutex = .{},
+ draw_mutex: std.Io.Mutex = .init,
/// The configuration we need derived from the main config.
config: DerivedConfig,
@@ -115,6 +117,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
/// True if the window is focused
focused: bool,
+ /// True if the window is visible.
+ visible: bool,
+
/// Flag to indicate that our focus state changed for custom
/// shaders to update their state.
custom_shader_focused_changed: bool = false,
@@ -161,12 +166,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
/// Timestamp we rendered out first frame.
///
/// This is used when updating custom shader uniforms.
- first_frame_time: ?std.time.Instant = null,
+ first_frame_time: ?std.Io.Timestamp = null,
/// Timestamp when we rendered out more recent frame.
///
/// This is used when updating custom shader uniforms.
- last_frame_time: ?std.time.Instant = null,
+ last_frame_time: ?std.Io.Timestamp = null,
/// The font structures.
font_grid: *font.SharedGrid,
@@ -202,8 +207,15 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
/// Health of the most recently completed frame.
health: std.atomic.Value(Health) = .{ .raw = .healthy },
- /// Our swap chain (multiple buffering)
- swap_chain: SwapChain,
+ /// True when we have a graphics context that can create GPU
+ /// resources. Creating any GPU resource while this is false is invalid.
+ display_realized: bool = true,
+
+ /// Our swap chain (multiple buffering). Null when it has
+ /// been released, either because the surface is hidden
+ /// (`releaseGpuResources`) or because the display is
+ /// unrealized. Rebuilt on the next `drawFrame`.
+ swap_chain: ?SwapChain,
/// This value is used to force-update swap chain targets in the
/// event of a config change that requires it (such as blending mode).
@@ -231,6 +243,18 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
/// Our overlay state, if any.
overlay: ?Overlay = null,
+ /// The base timestamp for the Kitty graphics animation clock.
+ /// Animation frame timing is expressed as milliseconds since
+ /// this instant. Set on the first frame update that observes
+ /// Kitty images.
+ kitty_animation_clock: ?std.Io.Timestamp = null,
+
+ /// When the next Kitty animation frame is due, in
+ /// milliseconds on the animation clock, from the most recent
+ /// frame update. Null when no running animation needs a
+ /// wakeup.
+ kitty_animation_next_ms: ?u64 = null,
+
const HighlightTag = enum(u8) {
search_match,
search_match_selected,
@@ -252,15 +276,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
frame_index: std.math.IntFittingRange(0, buf_count) = 0,
/// Semaphore that we wait on to make sure we have an available
/// frame state struct so we can start working on a new frame.
- frame_sema: std.Thread.Semaphore = .{ .permits = buf_count },
-
- /// Set to true when deinited, if you try to deinit a defunct
- /// swap chain it will just be ignored, to prevent double-free.
- ///
- /// This is required because of `displayUnrealized`, since it
- /// `deinits` the swapchain, which leads to a double-free if
- /// the renderer is deinited after that.
- defunct: bool = false,
+ frame_sema: std.Io.Semaphore = .{ .permits = buf_count },
pub fn init(api: GraphicsAPI, custom_shaders: bool) !SwapChain {
var result: SwapChain = .{ .frames = undefined };
@@ -274,30 +290,26 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
}
pub fn deinit(self: *SwapChain) void {
- if (self.defunct) return;
- self.defunct = true;
-
// Wait for all of our inflight draws to complete
// so that we can cleanly deinit our GPU state.
- for (0..buf_count) |_| self.frame_sema.wait();
+ for (0..buf_count) |_| self.frame_sema.waitUncancelable(
+ global.io(),
+ );
for (&self.frames) |*frame| frame.deinit();
}
/// Get the next frame state to draw to. This will wait on the
/// semaphore to ensure that the frame is available. This must
/// always be paired with a call to releaseFrame.
- pub fn nextFrame(self: *SwapChain) error{Defunct}!*FrameState {
- if (self.defunct) return error.Defunct;
-
- self.frame_sema.wait();
- errdefer self.frame_sema.post();
+ pub fn nextFrame(self: *SwapChain) *FrameState {
+ self.frame_sema.waitUncancelable(global.io());
self.frame_index = (self.frame_index + 1) % buf_count;
return &self.frames[self.frame_index];
}
/// This should be called when the frame has completed drawing.
pub fn releaseFrame(self: *SwapChain) void {
- self.frame_sema.post();
+ self.frame_sema.post(global.io());
}
};
@@ -570,6 +582,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
blending: configpkg.Config.AlphaBlending,
background_blur: configpkg.Config.BackgroundBlur,
scroll_to_bottom_on_output: bool,
+ custom_shader_animation: configpkg.CustomShaderAnimation,
pub fn init(
alloc_gpa: Allocator,
@@ -644,6 +657,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
.blending = config.@"alpha-blending",
.background_blur = config.@"background-blur",
.scroll_to_bottom_on_output = config.@"scroll-to-bottom".output,
+ .custom_shader_animation = config.@"custom-shader-animation",
.arena = arena,
};
}
@@ -682,22 +696,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
metrics: font.Metrics,
} = font_critical: {
const grid: *font.SharedGrid = options.font_grid;
- grid.lock.lockShared();
- defer grid.lock.unlockShared();
+ grid.lock.lockSharedUncancelable(global.io());
+ defer grid.lock.unlockShared(global.io());
break :font_critical .{
.metrics = grid.metrics,
};
};
- const display_link: ?DisplayLink = switch (builtin.os.tag) {
- .macos => if (options.config.vsync)
- try macos.video.DisplayLink.createWithActiveCGDisplays()
- else
- null,
- else => null,
- };
- errdefer if (display_link) |v| v.release();
-
var result: Self = .{
.alloc = alloc,
.config = options.config,
@@ -705,6 +710,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
.grid_metrics = font_critical.metrics,
.size = options.size,
.focused = true,
+ .visible = true,
.scrollbar = .zero,
.scrollbar_dirty = false,
.last_bottom_node = null,
@@ -783,7 +789,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Graphics API stuff
.api = api,
.swap_chain = swap_chain,
- .display_link = display_link,
};
try result.initShaders();
@@ -802,7 +807,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.terminal_state.deinit(self.alloc);
if (self.search_selected_match) |*m| m.arena.deinit();
if (self.search_matches) |*m| m.arena.deinit();
- self.swap_chain.deinit();
+ if (self.swap_chain) |*sc| sc.deinit();
if (DisplayLink != void) {
if (self.display_link) |display_link| {
@@ -903,16 +908,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// If we don't support a display link we have no work to do.
if (comptime DisplayLink == void) return;
- // This is when we know our "self" pointer is stable so we can
- // setup the display link. To setup the display link we set our
- // callback and we can start it immediately.
- const display_link = self.display_link orelse return;
- try display_link.setOutputCallback(
- xev.Async,
- &displayLinkCallback,
- &thr.draw_now,
- );
- display_link.start() catch {};
+ self.syncDisplayLink(null, &thr.draw_now);
}
/// Called by renderer.Thread when it exits the main loop.
@@ -943,13 +939,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Lock the draw mutex so that we can
// safely reinitialize our GPU resources.
- self.draw_mutex.lock();
- defer self.draw_mutex.unlock();
+ self.draw_mutex.lockUncancelable(global.io());
+ defer self.draw_mutex.unlock(global.io());
// We assume that the swap chain was deinited in
- // `displayUnrealized`, in which case it should be
- // marked defunct. If not, we have a problem.
- assert(self.swap_chain.defunct);
+ // `displayUnrealized`. If not, we have a problem.
+ assert(self.swap_chain == null);
+ assert(!self.display_realized);
// We reinitialize our shaders and our swap chain.
try self.initShaders();
@@ -957,6 +953,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.api,
self.has_custom_shaders,
);
+ self.display_realized = true;
self.reinitialize_shaders = false;
self.target_config_modified = 1;
}
@@ -972,14 +969,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Lock the draw mutex so that we can
// safely deinitialize our GPU resources.
- self.draw_mutex.lock();
- defer self.draw_mutex.unlock();
-
- // We deinit our swap chain and shaders.
- //
- // This will mark them as defunct so that they
- // can't be double-freed or used in draw calls.
- self.swap_chain.deinit();
+ self.draw_mutex.lockUncancelable(global.io());
+ defer self.draw_mutex.unlock(global.io());
+
+ // We deinit our swap chain and shaders. Clearing
+ // `display_realized` ensures drawFrame doesn't attempt
+ // to rebuild the swap chain (we have no GPU context);
+ // displayRealized will.
+ if (self.swap_chain) |*sc| sc.deinit();
+ self.swap_chain = null;
+ self.display_realized = false;
self.shaders.deinit(self.alloc);
}
@@ -999,19 +998,83 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
}
/// Called when we get an updated display ID for our display link.
- pub fn setMacOSDisplayID(self: *Self, id: u32) !void {
+ pub fn setMacOSDisplayID(
+ self: *Self,
+ id: u32,
+ draw_now: *xev.Async,
+ ) !void {
if (comptime DisplayLink == void) return;
- const display_link = self.display_link orelse return;
- log.info("updating display link display id={}", .{id});
- display_link.setCurrentCGDisplay(id) catch |err| {
- log.warn("error setting display link display id err={}", .{err});
- };
+ self.syncDisplayLink(id, draw_now);
}
- /// True if our renderer has animations so that a higher frequency
- /// timer is used.
- pub fn hasAnimations(self: *const Self) bool {
- return self.has_custom_shaders;
+ /// The cadence of continuous (draw-only) animation wakes,
+ /// i.e. 120fps, and the floor for any animation wake delay.
+ pub const draw_interval_ms: u64 = 8;
+
+ /// A point in the future when the renderer needs to be driven
+ /// again to keep animating, and what kind of drive it needs.
+ pub const AnimationWake = struct {
+ /// Delay in milliseconds until the wake is due.
+ delay_ms: u64,
+ kind: Kind,
+
+ pub const Kind = enum {
+ /// A redraw alone suffices, no updateFrame. Much cheaper
+ /// than `update`.
+ draw,
+
+ /// Frame data must be updated first: updateFrame, then draw.
+ update,
+ };
+ };
+
+ /// The soonest animation wake this renderer needs, if any:
+ /// custom shader animation wants continuous draw-only wakes
+ /// at draw_interval_ms while active, and a running Kitty
+ /// graphics animation wants an update wake when its next
+ /// frame is due. The renderer thread drives its animation
+ /// timer off this, re-querying after every wake.
+ ///
+ /// Must be called on the render thread.
+ pub fn animationWake(self: *const Self) ?AnimationWake {
+ // Custom shaders animate by redrawing on a fixed cadence,
+ // gated by configuration and focus.
+ const shader_delay: ?u64 = shader: {
+ if (!self.has_custom_shaders) break :shader null;
+ break :shader switch (self.config.custom_shader_animation) {
+ .false => null,
+ .always => draw_interval_ms,
+ .true => if (self.focused) draw_interval_ms else null,
+ };
+ };
+
+ // Kitty animations tick during updateFrame; between
+ // updates the deadline is absolute on the animation
+ // clock, so a stream of draw wakes recomputing this
+ // cannot starve it into the future.
+ const kitty_delay: ?u64 = kitty: {
+ const next = self.kitty_animation_next_ms orelse break :kitty null;
+ const base = self.kitty_animation_clock orelse break :kitty null;
+ const now: std.Io.Timestamp = .now(global.io(), .awake);
+ const now_ms: u64 = @intCast(@divTrunc(
+ base.durationTo(now).nanoseconds,
+ std.time.ns_per_ms,
+ ));
+ // Never wake faster than the draw interval; an
+ // overdue frame is picked up on the next wake.
+ break :kitty @max(next -| now_ms, draw_interval_ms);
+ };
+
+ // An update wake includes a draw, so it wins ties.
+ if (kitty_delay) |k| {
+ if (shader_delay == null or k <= shader_delay.?) {
+ return .{ .delay_ms = k, .kind = .update };
+ }
+ }
+
+ if (shader_delay) |s| return .{ .delay_ms = s, .kind = .draw };
+
+ return null;
}
/// True if our renderer is using vsync. If true, the renderer or apprt
@@ -1034,33 +1097,118 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Flag that we need to update our custom shaders
self.custom_shader_focused_changed = true;
- // If we're not focused, then we want to stop the display link
- // because it is a waste of resources and we can move to pure
- // change-driven updates.
- if (comptime DisplayLink != void) link: {
- const display_link = self.display_link orelse break :link;
- if (focus) {
- display_link.start() catch {};
- } else {
- display_link.stop() catch {};
- }
- }
+ self.syncDisplayLink(null, null);
}
/// Callback when the window is visible or occluded.
///
/// Must be called on the render thread.
pub fn setVisible(self: *Self, visible: bool) void {
- // If we're not visible, then we want to stop the display link
- // because it is a waste of resources and we can move to pure
- // change-driven updates.
- if (comptime DisplayLink != void) link: {
- const display_link = self.display_link orelse break :link;
- if (visible and self.focused) {
+ self.visible = visible;
+ self.syncDisplayLink(null, null);
+
+ // When we're hidden, release our GPU resources if GPU
+ // operations are allowed from this thread. Apprts where
+ // they aren't (GTK owns the OpenGL context on the app
+ // thread) call `releaseGpuResources` at the appropriate
+ // time instead.
+ if (comptime !apprt.must_draw_from_app_thread) {
+ if (!visible) self.releaseGpuResources();
+ }
+ }
+
+ /// Release the GPU resources we hold while the surface is not
+ /// visible. Today this is the swap chain (render targets, font
+ /// atlas texture copies, cell buffers, custom shader textures),
+ /// which makes up nearly all of a surface's GPU memory usage;
+ /// a hidden surface doesn't draw, so it doesn't need it. The
+ /// swap chain is rebuilt on the next `drawFrame`.
+ ///
+ /// This is safe to call in any state; resources that are
+ /// already released are skipped.
+ ///
+ /// For OpenGL this must be called on the app thread with the
+ /// GL context current (the same requirement as
+ /// `displayUnrealized`). Other APIs may call this from the
+ /// render thread; see `apprt.must_draw_from_app_thread`.
+ pub fn releaseGpuResources(self: *Self) void {
+ self.draw_mutex.lockUncancelable(global.io());
+ defer self.draw_mutex.unlock(global.io());
+
+ if (self.swap_chain) |*sc| {
+ // Waits for any in-flight frames to complete, then
+ // frees all GPU resources.
+ sc.deinit();
+ self.swap_chain = null;
+
+ // Let the API drop any references it holds to swap
+ // chain resources (e.g. OpenGL's last presented
+ // target).
+ if (comptime @hasDecl(GraphicsAPI, "gpuResourcesReleased")) {
+ self.api.gpuResourcesReleased();
+ }
+ }
+ }
+
+ /// Create or update the display link and match it to the current
+ /// surface state.
+ ///
+ /// Must be called on the render thread and must NOT be called
+ /// while holding `draw_mutex`. Stopping a CVDisplayLink is a
+ /// blocking join on CoreVideo's IO thread, and the apprt calls
+ /// `drawFrame` (which takes `draw_mutex`) from the CoreAnimation
+ /// layer display path on the main thread.
+ fn syncDisplayLink(
+ self: *Self,
+ display_id: ?u32,
+ draw_now: ?*xev.Async,
+ ) void {
+ if (comptime DisplayLink == void) return;
+
+ const display_link = self.display_link orelse display_link: {
+ if (!self.config.vsync) return;
+ const callback = draw_now orelse return;
+ const result = macos.video.DisplayLink.createWithActiveCGDisplays() catch |err| {
+ // A locked macOS session can temporarily have no active
+ // displays. Rendering can continue without vsync and a
+ // later display update will retry this method.
+ log.warn("error creating display link; using fallback rendering err={}", .{err});
+ return;
+ };
+ result.setOutputCallback(
+ xev.Async,
+ &displayLinkCallback,
+ callback,
+ ) catch |err| {
+ log.warn("error configuring display link err={}", .{err});
+ result.release();
+ return;
+ };
+
+ self.display_link = result;
+ log.info("created display link", .{});
+ break :display_link result;
+ };
+
+ if (display_id) |id| {
+ log.info("updating display link display id={}", .{id});
+ display_link.setCurrentCGDisplay(id) catch |err| {
+ log.warn("error setting display link display id err={}", .{err});
+ };
+ }
+
+ const should_run =
+ // Non-visible windows never vsync
+ self.visible and
+ // Only vsync if we have cell changes or animation
+ (self.cells_rebuilt or self.animationWake() != null);
+
+ if (should_run) {
+ if (!display_link.isRunning()) {
display_link.start() catch {};
- } else {
- display_link.stop() catch {};
}
+ } else {
+ display_link.stop() catch {};
}
}
@@ -1068,19 +1216,20 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
///
/// Must be called on the render thread.
pub fn setFontGrid(self: *Self, grid: *font.SharedGrid) void {
- self.draw_mutex.lock();
- defer self.draw_mutex.unlock();
+ self.draw_mutex.lockUncancelable(global.io());
+ defer self.draw_mutex.unlock(global.io());
// Update our grid
self.font_grid = grid;
// Update all our textures so that they sync on the next frame.
// We can modify this without a lock because the GPU does not
- // touch this data.
- for (&self.swap_chain.frames) |*frame| {
+ // touch this data. A released swap chain is rebuilt with
+ // fresh frames that sync all textures on first use.
+ if (self.swap_chain) |*sc| for (&sc.frames) |*frame| {
frame.grayscale_modified = 0;
frame.color_modified = 0;
- }
+ };
// Get our metrics from the grid. This doesn't require a lock because
// the metrics are never recalculated.
@@ -1125,15 +1274,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
state: *renderer.State,
cursor_blink_visible: bool,
) Allocator.Error!void {
- // const start = std.time.Instant.now() catch unreachable;
- // const start_micro = std.time.microTimestamp();
- // defer {
- // const end = std.time.Instant.now() catch unreachable;
- // log.warn(
- // "[updateFrame time] start_micro={} duration={}ns",
- // .{ start_micro, end.since(start) / std.time.ns_per_us },
- // );
- // }
+ // CoreText shaping accumulates objects for deferred release over
+ // the course of a frame. Always flush those objects, including
+ // when rebuilding the frame fails due to memory pressure.
+ defer self.font_shaper.endFrame();
// We fully deinit and reset the terminal state every so often
// so that a particularly large terminal state doesn't cause
@@ -1144,6 +1288,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
if (self.terminal_state_frame_count >= max_terminal_state_frame_count) {
self.terminal_state.deinit(self.alloc);
self.terminal_state = .empty;
+ self.terminal_state_frame_count = 0;
}
self.terminal_state_frame_count += 1;
@@ -1163,6 +1308,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Update all our data as tightly as possible within the mutex.
var critical: Critical = critical: {
+ // NOTE: This code needs be updated to 0.16.0 before you
+ // un-comment it ;)
+ //
// const start = try std.time.Instant.now();
// const start_micro = std.time.microTimestamp();
// defer {
@@ -1170,10 +1318,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// std.log.err("[updateFrame critical time] start={}\tduration={} us", .{ start_micro, end.since(start) / std.time.ns_per_us });
// }
- // Lock while signaling demand so the IO parse thread
- // can't starve us. See renderer.State.lockDemand.
- state.lockDemand();
- defer state.unlockDemand();
+ state.lockDemand(global.io());
+ defer state.unlockDemand(global.io());
// If we're in a synchronized output state, we pause all rendering.
if (state.terminal.modes.get(.synchronized_output)) {
@@ -1230,6 +1376,33 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
break :preedit try p.clone(arena_alloc);
};
+ // Advance any running Kitty graphics animations to the
+ // frame due now, and remember when the next frame is
+ // due (as an absolute deadline, see animationWake) so
+ // the renderer thread can schedule a wakeup for it.
+ // This must happen before the dirty check below:
+ // advancing a frame marks the image state dirty.
+ self.kitty_animation_next_ms = next: {
+ // Likely case: we have no kitty images, so do nothing.
+ const storage = &state.terminal.screens.active.kitty_images;
+ if (storage.images.count() == 0) break :next null;
+
+ const now: std.Io.Timestamp = .now(global.io(), .awake);
+ const base = self.kitty_animation_clock orelse base: {
+ self.kitty_animation_clock = now;
+ break :base now;
+ };
+ const now_ms: u64 = @intCast(@divTrunc(
+ base.durationTo(now).nanoseconds,
+ std.time.ns_per_ms,
+ ));
+ const delay = storage.animationTick(
+ global.io(),
+ now_ms,
+ ) orelse break :next null;
+ break :next now_ms + delay;
+ };
+
// If we have Kitty graphics data, we enter a SLOW SLOW SLOW path.
// We only do this if the Kitty image state is dirty meaning only if
// it changes.
@@ -1240,8 +1413,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
if (self.images.kittyRequiresUpdate(state.terminal)) {
// We need to grab the draw mutex since this updates
// our image state that drawFrame uses.
- self.draw_mutex.lock();
- defer self.draw_mutex.unlock();
+ self.draw_mutex.lockUncancelable(global.io());
+ defer self.draw_mutex.unlock(global.io());
self.images.kittyUpdate(
self.alloc,
state.terminal,
@@ -1371,8 +1544,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Acquire the draw mutex for all remaining state updates.
{
- self.draw_mutex.lock();
- defer self.draw_mutex.unlock();
+ self.draw_mutex.lockUncancelable(global.io());
+ defer self.draw_mutex.unlock(global.io());
// Build our GPU cells
self.rebuildCells(
@@ -1432,9 +1605,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.updateCustomShaderUniformsFromState();
}
- // Notify our shaper we're done for the frame. For some shapers,
- // such as CoreText, this triggers off-thread cleanup logic.
- self.font_shaper.endFrame();
+ // Start the display link now that the rebuilt frame is ready.
+ self.syncDisplayLink(null, null);
}
/// Draw the frame to the screen.
@@ -1445,21 +1617,30 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self: *Self,
sync: bool,
) !void {
- // const start = std.time.Instant.now() catch unreachable;
- // const start_micro = std.time.microTimestamp();
- // defer {
- // const end = std.time.Instant.now() catch unreachable;
- // log.warn(
- // "[drawFrame time] start_micro={} duration={}ns",
- // .{ start_micro, end.since(start) / std.time.ns_per_us },
- // );
- // }
-
- // We hold a the draw mutex to prevent changes to any
- // data we access while we're in the middle of drawing.
- self.draw_mutex.lock();
- defer self.draw_mutex.unlock();
+ // Everything that touches draw state happens under the draw
+ // mutex. The display link is synced only after the mutex is
+ // released; see `syncDisplayLink` for why it must never be
+ // called with the draw mutex held.
+ const sync_display_link = locked: {
+ self.draw_mutex.lockUncancelable(global.io());
+ defer self.draw_mutex.unlock(global.io());
+ break :locked try self.drawFrameLocked(sync);
+ };
+
+ if (sync_display_link) self.syncDisplayLink(null, null);
+ }
+ /// The body of `drawFrame`. Must be called with `draw_mutex` held.
+ ///
+ /// Returns true if the display link should be resynced once the
+ /// draw mutex is released. This is only ever true on the no-redraw
+ /// path, which a sync draw never takes, so the main thread's sync
+ /// draws never touch the display link and `syncDisplayLink` stays
+ /// on the render thread.
+ fn drawFrameLocked(
+ self: *Self,
+ sync: bool,
+ ) !bool {
// After the graphics API is complete (so we defer) we want to
// update our scrollbar state.
defer if (self.scrollbar_dirty) {
@@ -1480,7 +1661,26 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// If either of our surface dimensions is zero
// then drawing is absurd, so we just return.
- if (surface_size.width == 0 or surface_size.height == 0) return;
+ if (surface_size.width == 0 or surface_size.height == 0) return false;
+
+ // If we have no graphics context we can't draw. This is
+ // only the case while unrealized (GTK); displayRealized
+ // rebuilds the swap chain.
+ if (!self.display_realized) return false;
+
+ // Get our swap chain, rebuilding it if it was released
+ // while we were hidden. Rebuilding is deferred to draw
+ // time because resource creation must happen somewhere
+ // our graphics API allows it (OpenGL requires a current
+ // context, which drawFrame guarantees).
+ const swap_chain: *SwapChain, const swap_chain_rebuilt: bool =
+ if (self.swap_chain) |*sc| .{ sc, false } else rebuild: {
+ self.swap_chain = try SwapChain.init(
+ self.api,
+ self.has_custom_shaders,
+ );
+ break :rebuild .{ &self.swap_chain.?, true };
+ };
const size_changed =
self.size.screen.width != surface_size.width or
@@ -1488,10 +1688,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Conditions under which we need to draw the frame, otherwise we
// don't need to since the previous frame should be identical.
+ //
+ // While any animation is in progress (a pending animation wake)
+ // every draw must actually render.
const needs_redraw =
size_changed or
+ swap_chain_rebuilt or
self.cells_rebuilt or
- self.hasAnimations() or
+ self.animationWake() != null or
sync;
if (!needs_redraw) {
@@ -1499,14 +1703,18 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// apprt may be swapping buffers and display an outdated frame
// if we don't draw something new.
try self.api.presentLastTarget();
- return;
+
+ // Ask our caller to resync the display link once the draw
+ // mutex is released, because we can probably pause the
+ // display link at this point.
+ return true;
}
self.cells_rebuilt = false;
// Wait for a frame to be available.
- const frame = try self.swap_chain.nextFrame();
- errdefer self.swap_chain.releaseFrame();
- // log.debug("drawing frame index={}", .{self.swap_chain.frame_index});
+ const frame = swap_chain.nextFrame();
+ errdefer swap_chain.releaseFrame();
+ // log.debug("drawing frame index={}", .{swap_chain.frame_index});
// If we need to reinitialize our shaders, do so.
if (self.reinitialize_shaders) {
@@ -1572,7 +1780,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// Setup our frame data
try frame.uniforms.sync(&.{self.uniforms});
try frame.cells_bg.sync(self.cells.bg_cells);
- const fg_count = try frame.cells.syncFromArrayLists(self.cells.fg_rows.lists);
+ const fg_count = try frame.cells.syncFromArrayLists(self.cells.fg_rows);
// If our background image buffer has changed, sync it.
if (frame.bg_image_buffer_modified != self.bg_image_buffer_modified) {
@@ -1585,16 +1793,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
texture: {
const modified = self.font_grid.atlas_grayscale.modified.load(.monotonic);
if (modified <= frame.grayscale_modified) break :texture;
- self.font_grid.lock.lockShared();
- defer self.font_grid.lock.unlockShared();
+ self.font_grid.lock.lockSharedUncancelable(global.io());
+ defer self.font_grid.lock.unlockShared(global.io());
frame.grayscale_modified = self.font_grid.atlas_grayscale.modified.load(.monotonic);
try self.syncAtlasTexture(&self.font_grid.atlas_grayscale, &frame.grayscale);
}
texture: {
const modified = self.font_grid.atlas_color.modified.load(.monotonic);
if (modified <= frame.color_modified) break :texture;
- self.font_grid.lock.lockShared();
- defer self.font_grid.lock.unlockShared();
+ self.font_grid.lock.lockSharedUncancelable(global.io());
+ defer self.font_grid.lock.unlockShared(global.io());
frame.color_modified = self.font_grid.atlas_color.modified.load(.monotonic);
try self.syncAtlasTexture(&self.font_grid.atlas_color, &frame.color);
}
@@ -1732,6 +1940,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
});
}
}
+
+ return false;
}
// Callback from the graphics API when a frame is completed.
@@ -1751,8 +1961,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
}, .{ .forever = {} });
}
- // Always release our semaphore
- self.swap_chain.releaseFrame();
+ // Always release our semaphore. The swap chain is
+ // guaranteed to exist here: it is only torn down after
+ // waiting for all in-flight frames to complete, and this
+ // callback is what signals that completion.
+ self.swap_chain.?.releaseFrame();
}
/// Call this any time the background image path changes.
@@ -1766,17 +1979,22 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
};
// Open the file
- var file = std.fs.openFileAbsolute(path, .{}) catch |err| {
+ var file = std.Io.Dir.openFileAbsolute(
+ global.io(),
+ path,
+ .{},
+ ) catch |err| {
log.warn(
"error opening background image file \"{s}\": {}",
.{ path, err },
);
break :load_background;
};
- defer file.close();
+ defer file.close(global.io());
// Read it
- const contents = file.readToEndAlloc(
+ const contents = compat_file.readToEndAlloc(
+ file,
self.alloc,
std.math.maxInt(u32), // Max size of 4 GiB, for now.
) catch |err| {
@@ -1853,8 +2071,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
/// Update the configuration.
pub fn changeConfig(self: *Self, config: *DerivedConfig) !void {
- self.draw_mutex.lock();
- defer self.draw_mutex.unlock();
+ self.draw_mutex.lockUncancelable(global.io());
+ defer self.draw_mutex.unlock(global.io());
// We always redo the font shaper in case font features changed. We
// could check to see if there was an actual config change but this is
@@ -1934,8 +2152,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self: *Self,
size: renderer.Size,
) void {
- self.draw_mutex.lock();
- defer self.draw_mutex.unlock();
+ self.draw_mutex.lockUncancelable(global.io());
+ defer self.draw_mutex.unlock(global.io());
// We only actually need the padding from this,
// everything else is derived elsewhere.
@@ -2119,7 +2337,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
const uniforms: *shadertoy.Uniforms = &self.custom_shader_uniforms;
- const now = try std.time.Instant.now();
+ const now: std.Io.Timestamp = .now(global.io(), .awake);
defer self.last_frame_time = now;
const first_frame_time = self.first_frame_time orelse t: {
self.first_frame_time = now;
@@ -2127,10 +2345,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
};
const last_frame_time = self.last_frame_time orelse now;
- const since_ns: f32 = @floatFromInt(now.since(first_frame_time));
+ const since_ns: f32 = @floatFromInt(first_frame_time.durationTo(now).nanoseconds);
uniforms.time = since_ns / std.time.ns_per_s;
- const delta_ns: f32 = @floatFromInt(now.since(last_frame_time));
+ const delta_ns: f32 = @floatFromInt(last_frame_time.durationTo(now).nanoseconds);
uniforms.time_delta = delta_ns / std.time.ns_per_s;
uniforms.frame += 1;
@@ -2241,16 +2459,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self: *Self,
features: []const Overlay.Feature,
) Overlay.InitError!void {
- // const start = std.time.Instant.now() catch unreachable;
- // const start_micro = std.time.microTimestamp();
- // defer {
- // const end = std.time.Instant.now() catch unreachable;
- // log.warn(
- // "[rebuildOverlay time] start_micro={} duration={}ns",
- // .{ start_micro, end.since(start) / std.time.ns_per_us },
- // );
- // }
-
const alloc = self.alloc;
// If we have no features enabled, don't build an overlay.
@@ -2326,14 +2534,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
) Allocator.Error!void {
const state: *terminal.RenderState = &self.terminal_state;
- // const start = try std.time.Instant.now();
- // const start_micro = std.time.microTimestamp();
- // defer {
- // const end = std.time.Instant.now() catch unreachable;
- // // "[rebuildCells time] <START us>\t<TIME_TAKEN us>"
- // std.log.warn("[rebuildCells time] {}\t{}", .{start_micro, end.since(start) / std.time.ns_per_us});
- // }
-
const grid_size_diff =
self.cells.size.rows != state.rows or
self.cells.size.columns != state.cols;
diff --git a/vendor/libghostty-vt/src/renderer/image.zig b/vendor/libghostty-vt/src/renderer/image.zig
index f8269354..ef0367bd 100644
--- a/vendor/libghostty-vt/src/renderer/image.zig
+++ b/vendor/libghostty-vt/src/renderer/image.zig
@@ -3,6 +3,7 @@ const Allocator = std.mem.Allocator;
const assert = @import("../quirks.zig").inlineAssert;
const wuffs = @import("wuffs");
const terminal = @import("../terminal/main.zig");
+const global = @import("../global.zig");
const Renderer = @import("../renderer.zig").Renderer;
const GraphicsAPI = Renderer.API;
@@ -194,7 +195,7 @@ pub const State = struct {
// Overlays are always considered new content, so we take a
// fresh generation stamp to force replacing any existing one.
- const generation = terminal.kitty.graphics.nextGeneration();
+ const generation = terminal.kitty.graphics.nextGeneration(global.io());
// Ensure we have space for our overlay placement. Do this before
// we upload our image so we don't have to deal with cleaning
@@ -276,7 +277,13 @@ pub const State = struct {
while (it.next()) |kv| {
switch (kv.key_ptr.*) {
// We're only looking at Kitty images
- .kitty => |id| if (storage.imageById(id) == null) {
+ .kitty => |id| if (storage.imageById(id)) |image| {
+ // A pending source must not keep an older texture for
+ // the same ID renderable while its placements wait.
+ if (image.data.isPending()) {
+ kv.value_ptr.image.markForUnload();
+ }
+ } else {
kv.value_ptr.image.markForUnload();
},
@@ -292,6 +299,18 @@ pub const State = struct {
const top_y = t.screens.active.pages.pointFromPin(.screen, top).?.screen.y;
const bot_y = t.screens.active.pages.pointFromPin(.screen, bot).?.screen.y;
+ // Relative placements whose parent chain roots at a virtual
+ // placement can only be positioned once the placeholder cells
+ // have been scanned below, so they are collected here first.
+ var pending_relative: std.ArrayListUnmanaged(struct {
+ image_id: u32,
+ p: terminal.kitty.graphics.ImageStorage.Placement,
+ root_key: terminal.kitty.graphics.ImageStorage.PlacementKey,
+ horizontal_offset: i32,
+ vertical_offset: i32,
+ }) = .empty;
+ defer pending_relative.deinit(alloc);
+
// Go through the placements and ensure the image is
// on the GPU or else is ready to be sent to the GPU.
var it = storage.placements.iterator();
@@ -299,8 +318,9 @@ pub const State = struct {
const p = kv.value_ptr;
// Special logic based on location
- switch (p.location) {
- .pin => {},
+ const origin: Origin = switch (p.location) {
+ .pin => |pin| .{ .pin = pin },
+
.virtual => {
// We need to mark virtual placements on our renderer so that
// we know to rebuild in more scenarios since cell changes can
@@ -312,7 +332,46 @@ pub const State = struct {
// placement itself.
continue;
},
- }
+
+ .relative => |rel| origin: {
+ // An unresolvable chain is never drawn. This only
+ // happens transiently (storage reaps orphans) or for
+ // chains re-parented too deep, which kitty doesn't
+ // draw either.
+ const chain = storage.resolveChain(rel) orelse continue;
+ switch (chain.root.location) {
+ // Rooted at a pin: anchored at the root's pin,
+ // offset by the accumulated chain offsets.
+ .pin => |root_pin| break :origin .{
+ .pin = root_pin,
+ .horizontal_offset = chain.horizontal_offset,
+ .vertical_offset = chain.vertical_offset,
+ },
+
+ // Rooted at a virtual placement: positioned from
+ // the root's placeholder cells, which we only
+ // know after the placeholder scan below. The
+ // placeholders also move with cell changes so we
+ // must rebuild every frame, like virtuals.
+ .virtual => {
+ self.kitty_virtual = true;
+ pending_relative.append(alloc, .{
+ .image_id = kv.key_ptr.image_id,
+ .p = p.*,
+ .root_key = chain.root_key,
+ .horizontal_offset = chain.horizontal_offset,
+ .vertical_offset = chain.vertical_offset,
+ }) catch |err| {
+ log.warn("error deferring relative placement err={}", .{err});
+ };
+ continue;
+ },
+
+ // resolveChain roots are never relative.
+ .relative => unreachable,
+ }
+ },
+ };
// Get the image for the placement
const image = storage.imageById(kv.key_ptr.image_id) orelse {
@@ -330,6 +389,7 @@ pub const State = struct {
bot_y,
&image,
p,
+ origin,
) catch |err| {
// For errors we log and continue. We try to place
// other placements even if one fails.
@@ -339,6 +399,17 @@ pub const State = struct {
// If we have virtual placements then we need to scan for placeholders.
if (self.kitty_virtual) {
+ // The minimum placeholder cell seen per virtual placement,
+ // in viewport coordinates. This is the origin for relative
+ // placements rooted at a virtual placement: kitty positions
+ // those at the min-x/min-y of the parent's placeholder
+ // cells. Only tracked when such placements exist.
+ var virtual_origins: std.AutoHashMapUnmanaged(
+ terminal.kitty.graphics.ImageStorage.PlacementKey,
+ struct { x: u32, y: u32 },
+ ) = .empty;
+ defer virtual_origins.deinit(alloc);
+
var v_it = terminal.kitty.graphics.unicode.placementIterator(top, bot);
while (v_it.next()) |virtual_p| {
self.prepKittyVirtualPlacement(
@@ -351,6 +422,69 @@ pub const State = struct {
// other placements even if one fails.
log.warn("error preparing kitty placement err={}", .{err});
};
+
+ // We need to track the origins of all the placeholders
+ // when we have relative cells so that we can calculate
+ // the proper offsets later.
+ if (pending_relative.items.len > 0) fold: {
+ // Find the target for this virtual placeholder.
+ const target = storage.placeholderTarget(
+ virtual_p.image_id,
+ virtual_p.placement_id,
+ ) orelse break :fold;
+
+ // Get the actual viewport position for it.
+ const vp = t.screens.active.pages.pointFromPin(
+ .viewport,
+ virtual_p.pin,
+ ) orelse break :fold;
+
+ // Add the origin for this target
+ const gop = virtual_origins.getOrPut(
+ alloc,
+ target.key,
+ ) catch |err| {
+ log.warn("error tracking virtual origin err={}", .{err});
+ break :fold;
+ };
+ if (!gop.found_existing) {
+ gop.value_ptr.* = .{ .x = vp.viewport.x, .y = vp.viewport.y };
+ } else {
+ gop.value_ptr.x = @min(gop.value_ptr.x, vp.viewport.x);
+ gop.value_ptr.y = @min(gop.value_ptr.y, vp.viewport.y);
+ }
+ }
+ }
+
+ // Position the relative placements rooted at virtual
+ // placements now that the placeholder cells are known. A
+ // root with no placeholders on screen leaves its relative
+ // placements undrawn, matching kitty.
+ for (pending_relative.items) |pr| {
+ const origin = virtual_origins.get(pr.root_key) orelse continue;
+ const image = storage.imageById(pr.image_id) orelse continue;
+ if (image.data.isPending()) continue;
+
+ const grid = pr.p.gridSize(image, t);
+ if (grid.cols == 0 or grid.rows == 0) continue;
+
+ // Viewport-relative signed position; cull placements
+ // entirely outside the viewport.
+ const x: i64 = @as(i64, origin.x) + pr.horizontal_offset;
+ const y: i64 = @as(i64, origin.y) + pr.vertical_offset;
+ if (y >= t.rows or y + grid.rows - 1 < 0) continue;
+ if (x >= t.cols or x + grid.cols - 1 < 0) continue;
+
+ self.appendKittyPlacement(
+ alloc,
+ t,
+ &image,
+ &pr.p,
+ std.math.cast(i32, x) orelse continue,
+ std.math.cast(i32, y) orelse continue,
+ ) catch |err| {
+ log.warn("error preparing kitty placement err={}", .{err});
+ };
}
}
@@ -396,8 +530,19 @@ pub const State = struct {
ImageConversionError,
};
- /// Get the viewport-relative position for this
- /// placement and add it to the placements list.
+ /// Where a placement is anchored on screen: a pin, plus cell
+ /// offsets from it for relative placements. The pin is the
+ /// placement's own pin for pin placements; for relative placements
+ /// it is the root of the parent chain and the offsets are the
+ /// accumulated chain offsets in cells.
+ const Origin = struct {
+ pin: *const terminal.Pin,
+ horizontal_offset: i32 = 0,
+ vertical_offset: i32 = 0,
+ };
+
+ /// Get the viewport-relative position for this placement and add it
+ /// to the placements list.
fn prepKittyPlacement(
self: *State,
alloc: Allocator,
@@ -406,19 +551,58 @@ pub const State = struct {
bot_y: u32,
image: *const terminal.kitty.graphics.Image,
p: *const terminal.kitty.graphics.ImageStorage.Placement,
+ origin: Origin,
) PrepImageError!void {
- // Get the rect for the placement. If this placement doesn't have
- // a rect then its virtual or something so skip it.
- const rect = p.rect(image.*, t) orelse return;
+ // Keep the native placement but do not create a renderer placement or
+ // texture until the decoded bytes arrive.
+ if (image.data.isPending()) return;
- // This is expensive but necessary.
- const img_top_y = t.screens.active.pages.pointFromPin(.screen, rect.top_left).?.screen.y;
- const img_bot_y = t.screens.active.pages.pointFromPin(.screen, rect.bottom_right).?.screen.y;
+ // An origin whose tracked content was pruned has no position.
+ if (origin.pin.garbage) return;
- // If the selection isn't within our viewport then skip it.
- if (img_top_y > bot_y) return;
- if (img_bot_y < top_y) return;
+ // The size of the placement in grid cells. A zero size can
+ // occur when pixel geometry is unavailable; nothing to place.
+ const grid = p.gridSize(image.*, t);
+ if (grid.cols == 0 or grid.rows == 0) return;
+
+ // This is expensive but necessary.
+ const origin_y = t.screens.active.pages.pointFromPin(
+ .screen,
+ origin.pin.*,
+ ).?.screen.y;
+
+ // The placement's edges in screen coordinates. Chain offsets
+ // are signed: a relative placement can hang above or to the
+ // left of its origin, so this math must be signed and widened.
+ const img_top_y: i64 = @as(i64, origin_y) + origin.vertical_offset;
+ const img_bot_y: i64 = img_top_y + grid.rows - 1;
+ const img_left_x: i64 = @as(i64, origin.pin.x) + origin.horizontal_offset;
+ const img_right_x: i64 = img_left_x + grid.cols - 1;
+
+ // If the placement isn't within our viewport then skip it.
+ if (img_top_y > bot_y or img_bot_y < top_y) return;
+ if (img_left_x >= t.cols or img_right_x < 0) return;
+
+ // Viewport-relative position. Offsets so extreme that the
+ // position is unrepresentable have no renderable pixels.
+ const y_pos = std.math.cast(i32, img_top_y - top_y) orelse return;
+ const x_pos = std.math.cast(i32, img_left_x) orelse return;
+
+ try self.appendKittyPlacement(alloc, t, image, p, x_pos, y_pos);
+ }
+ /// Compute the sizes for a native kitty placement positioned at the
+ /// given viewport cell position, prepare its image for the GPU, and
+ /// append it to the placements list.
+ fn appendKittyPlacement(
+ self: *State,
+ alloc: Allocator,
+ t: *const terminal.Terminal,
+ image: *const terminal.kitty.graphics.Image,
+ p: *const terminal.kitty.graphics.ImageStorage.Placement,
+ x: i32,
+ y: i32,
+ ) PrepImageError!void {
// We need to prep this image for upload if it isn't in the
// cache OR it is in the cache but the transmit time doesn't
// match meaning this image is different.
@@ -427,37 +611,25 @@ pub const State = struct {
// Calculate the dimensions of our image, taking in to
// account the rows / columns specified by the placement.
const dest_size = p.pixelSize(image.*, t);
+ const cell_offset = p.cellOffset(t);
- // Calculate the source rectangle
- const source_x = @min(image.width, p.source_x);
- const source_y = @min(image.height, p.source_y);
- const source_width = if (p.source_width > 0)
- @min(image.width - source_x, p.source_width)
- else
- image.width;
- const source_height = if (p.source_height > 0)
- @min(image.height - source_y, p.source_height)
- else
- image.height;
-
- // Get the viewport-relative Y position of the placement.
- const y_pos: i32 = @as(i32, @intCast(img_top_y)) - @as(i32, @intCast(top_y));
+ const source = p.sourceRect(image.*);
// Accumulate the placement
if (dest_size.width > 0 and dest_size.height > 0) {
try self.kitty_placements.append(alloc, .{
.image_id = .{ .kitty = image.id },
- .x = @intCast(rect.top_left.x),
- .y = y_pos,
+ .x = x,
+ .y = y,
.z = p.z,
.width = dest_size.width,
.height = dest_size.height,
- .cell_offset_x = p.x_offset,
- .cell_offset_y = p.y_offset,
- .source_x = source_x,
- .source_y = source_y,
- .source_width = source_width,
- .source_height = source_height,
+ .cell_offset_x = cell_offset.x,
+ .cell_offset_y = cell_offset.y,
+ .source_x = source.x,
+ .source_y = source.y,
+ .source_width = source.width,
+ .source_height = source.height,
});
}
}
@@ -477,6 +649,7 @@ pub const State = struct {
);
return;
};
+ if (image.data.isPending()) return;
const rp = p.renderPlacement(
storage,
@@ -537,11 +710,8 @@ pub const State = struct {
return;
}
- // Copy the data so we own it.
- const data = if (alloc.dupe(
- u8,
- pending.dataSlice(),
- )) |v| v else |_| {
+ // Store it in the map
+ const new_image: Image = if (pending.convertCopy(alloc)) |v| v else |_| {
if (!gop.found_existing) {
// If this is a new entry we can just remove it since it
// was never sent to the GPU.
@@ -558,15 +728,6 @@ pub const State = struct {
// put into the map immediately below and our errdefer to
// handle our map state will fix this up.
- // Store it in the map
- const new_image: Image = .{
- .pending = .{
- .width = pending.width,
- .height = pending.height,
- .pixel_format = pending.pixel_format,
- .data = data.ptr,
- },
- };
if (!gop.found_existing) {
gop.value_ptr.* = .{
.image = new_image,
@@ -582,7 +743,7 @@ pub const State = struct {
// If any error happens, we unload the image and it is invalid.
errdefer gop.value_ptr.image.markForUnload();
- gop.value_ptr.image.prepForUpload(alloc) catch |err| {
+ gop.value_ptr.image.getPendingPointer().?.prepForUpload(alloc) catch |err| {
log.warn("error preparing image for upload err={}", .{err});
return error.ImageConversionError;
};
@@ -596,6 +757,10 @@ pub const State = struct {
alloc: Allocator,
image: *const terminal.kitty.graphics.Image,
) PrepImageError!void {
+ // For animated images this is the current animation frame;
+ // the image generation changes whenever the current frame
+ // does, so the upload cache stays coherent.
+ const data = image.renderData().bytes() orelse unreachable;
try self.prepImage(
alloc,
.{ .kitty = image.id },
@@ -614,7 +779,7 @@ pub const State = struct {
// constCasts are always gross but this one is safe is because
// the data is only read from here and copied into its own
// buffer.
- .data = @constCast(image.data.ptr),
+ .data = @constCast(data.ptr),
},
);
}
@@ -767,6 +932,61 @@ pub const Image = union(enum) {
};
}
};
+
+ /// Converts the image data and replaces it with a format that can be uploaded to the GPU.
+ /// If the data is already in a format that can be uploaded, this is a
+ /// no-op.
+ /// Use `convertCopy()` to convert and copy in a single-pass, such as for owning the data to upload.
+ fn convertReplace(self: *Image.Pending, alloc: Allocator) wuffs.Error!void {
+ // As things stand, we currently convert all images to RGBA before
+ // uploading to the GPU. This just makes things easier. In the future
+ // we may want to support other formats.
+ if (self.pixel_format == .rgba) return;
+ // If the pending data isn't RGBA we'll need to swizzle it.
+ const data = self.dataSlice();
+ const rgba = try switch (self.pixel_format) {
+ .gray => wuffs.swizzle.gToRgba(alloc, data),
+ .gray_alpha => wuffs.swizzle.gaToRgba(alloc, data),
+ .rgb => wuffs.swizzle.rgbToRgba(alloc, data),
+ .bgr => wuffs.swizzle.bgrToRgba(alloc, data),
+ .rgba => unreachable,
+ .bgra => wuffs.swizzle.bgraToRgba(alloc, data),
+ };
+ alloc.free(data);
+ self.data = rgba.ptr;
+ self.pixel_format = .rgba;
+ }
+
+ /// Converts the image data to a copy with a format that can be uploaded to the GPU.
+ /// If the data is already owned, use `convertReplace()` to be a no-op for data already
+ /// in a format that can be uploaded.
+ fn convertCopy(self: *const Image.Pending, alloc: Allocator) wuffs.Error!Image {
+ // As things stand, we currently convert all images to RGBA before
+ // uploading to the GPU. This just makes things easier. In the future
+ // we may want to support other formats.
+ const data = self.dataSlice();
+ const rgba = try switch (self.pixel_format) {
+ .gray => wuffs.swizzle.gToRgba(alloc, data),
+ .gray_alpha => wuffs.swizzle.gaToRgba(alloc, data),
+ .rgb => wuffs.swizzle.rgbToRgba(alloc, data),
+ .bgr => wuffs.swizzle.bgrToRgba(alloc, data),
+ .rgba => alloc.dupe(u8, data),
+ .bgra => wuffs.swizzle.bgraToRgba(alloc, data),
+ };
+ const result: Image = .{ .pending = .{
+ .height = self.height,
+ .width = self.width,
+ .pixel_format = .rgba,
+ .data = rgba.ptr,
+ } };
+ return result;
+ }
+
+ /// Prepare the pending image data for upload to the GPU.
+ /// This doesn't need GPU access so is safe to call any time.
+ fn prepForUpload(self: *Image.Pending, alloc: Allocator) wuffs.Error!void {
+ try self.convertReplace(alloc);
+ }
};
pub fn deinit(self: Image, alloc: Allocator) void {
@@ -847,37 +1067,6 @@ pub const Image = union(enum) {
};
}
- /// Converts the image data to a format that can be uploaded to the GPU.
- /// If the data is already in a format that can be uploaded, this is a
- /// no-op.
- fn convert(self: *Image, alloc: Allocator) wuffs.Error!void {
- const p = self.getPendingPointer().?;
- // As things stand, we currently convert all images to RGBA before
- // uploading to the GPU. This just makes things easier. In the future
- // we may want to support other formats.
- if (p.pixel_format == .rgba) return;
- // If the pending data isn't RGBA we'll need to swizzle it.
- const data = p.dataSlice();
- const rgba = try switch (p.pixel_format) {
- .gray => wuffs.swizzle.gToRgba(alloc, data),
- .gray_alpha => wuffs.swizzle.gaToRgba(alloc, data),
- .rgb => wuffs.swizzle.rgbToRgba(alloc, data),
- .bgr => wuffs.swizzle.bgrToRgba(alloc, data),
- .rgba => unreachable,
- .bgra => wuffs.swizzle.bgraToRgba(alloc, data),
- };
- alloc.free(data);
- p.data = rgba.ptr;
- p.pixel_format = .rgba;
- }
-
- /// Prepare the pending image data for upload to the GPU.
- /// This doesn't need GPU access so is safe to call any time.
- fn prepForUpload(self: *Image, alloc: Allocator) wuffs.Error!void {
- assert(self.isPending());
- try self.convert(alloc);
- }
-
/// Upload the pending image to the GPU and change the state of this
/// image to ready.
pub fn upload(
@@ -890,12 +1079,12 @@ pub const Image = union(enum) {
})!void {
assert(self.isPending());
+ // Get our pending info
+ const p = self.getPendingPointer().?;
+
// No error recover is required after this call because it just
// converts in place and is idempotent.
- try self.prepForUpload(alloc);
-
- // Get our pending info
- const p = self.getPending().?;
+ try p.prepForUpload(alloc);
// Create our texture
const texture = Texture.init(
@@ -962,3 +1151,369 @@ pub const Image = union(enum) {
};
}
};
+
+test "kitty renderer ignores pending payloads and removes replaced placements" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ const io = testing.io;
+
+ var t = try terminal.Terminal.init(io, alloc, .{ .rows = 3, .cols = 3 });
+ defer t.deinit(alloc);
+ t.width_px = 30;
+ t.height_px = 30;
+
+ var state: State = .empty;
+ defer state.deinit(alloc);
+
+ const storage = &t.screens.active.kitty_images;
+ const tracked = t.screens.active.pages.countTrackedPins();
+ const pending = try storage.addPendingImage(io, alloc, t.screens.active, .{
+ .id = 1,
+ .width = 1,
+ .height = 1,
+ .format = .rgba,
+ .data = .{ .pending = 4 },
+ });
+ const pin = try t.screens.active.pages.trackPin(
+ t.screens.active.cursor.page_pin.*,
+ );
+ try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{
+ .location = .{ .pin = pin },
+ .columns = 1,
+ .rows = 1,
+ });
+
+ state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 });
+ try testing.expectEqual(@as(usize, 1), storage.placements.count());
+ try testing.expectEqual(@as(usize, 0), state.kitty_placements.items.len);
+ try testing.expect(state.images.get(.{ .kitty = 1 }) == null);
+
+ const pixels = try alloc.dupe(u8, "rgba");
+ try testing.expect(pending.complete(storage, io, pixels));
+ state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 });
+ try testing.expectEqual(@as(usize, 1), state.kitty_placements.items.len);
+ try testing.expectEqual(
+ pending.generation,
+ state.images.get(.{ .kitty = 1 }).?.generation,
+ );
+
+ // A newer pending replacement deletes the native placement and marks the
+ // copied texture data for unload.
+ _ = try storage.addPendingImage(io, alloc, t.screens.active, .{
+ .id = 1,
+ .width = 1,
+ .height = 1,
+ .format = .rgba,
+ .data = .{ .pending = 4 },
+ });
+ state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 });
+ try testing.expectEqual(@as(usize, 0), storage.placements.count());
+ try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins());
+ try testing.expectEqual(@as(usize, 0), state.kitty_placements.items.len);
+ try testing.expect(state.images.get(.{ .kitty = 1 }).?.image.isUnloading());
+}
+
+test "kitty renderer uses the intersected source rectangle" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ const io = testing.io;
+
+ var t = try terminal.Terminal.init(io, alloc, .{ .rows = 3, .cols = 3 });
+ defer t.deinit(alloc);
+ t.width_px = 30;
+ t.height_px = 30;
+
+ var state: State = .empty;
+ defer state.deinit(alloc);
+
+ const storage = &t.screens.active.kitty_images;
+ const pixels = try alloc.alloc(u8, 4 * 3 * 3);
+ @memset(pixels, 0);
+ try storage.addImage(io, alloc, t.screens.active, .{
+ .id = 1,
+ .width = 4,
+ .height = 3,
+ .format = .rgb,
+ .data = .{ .complete = pixels },
+ });
+ const pin = try t.screens.active.pages.trackPin(
+ t.screens.active.cursor.page_pin.*,
+ );
+ try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{
+ .location = .{ .pin = pin },
+ .source_x = 3,
+ .source_y = 1,
+ });
+
+ state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 });
+ try testing.expectEqual(@as(usize, 1), state.kitty_placements.items.len);
+
+ const placement = state.kitty_placements.items[0];
+ try testing.expectEqual(@as(u32, 1), placement.width);
+ try testing.expectEqual(@as(u32, 2), placement.height);
+ try testing.expectEqual(@as(u32, 3), placement.source_x);
+ try testing.expectEqual(@as(u32, 1), placement.source_y);
+ try testing.expectEqual(@as(u32, 1), placement.source_width);
+ try testing.expectEqual(@as(u32, 2), placement.source_height);
+}
+
+test "kitty renderer positions relative placements from the parent pin" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ const io = testing.io;
+
+ var t = try terminal.Terminal.init(io, alloc, .{ .rows = 10, .cols = 10 });
+ defer t.deinit(alloc);
+ t.width_px = 100;
+ t.height_px = 100;
+
+ var state: State = .empty;
+ defer state.deinit(alloc);
+
+ const storage = &t.screens.active.kitty_images;
+ const pixels = try alloc.alloc(u8, 3);
+ @memset(pixels, 0);
+ try storage.addImage(io, alloc, t.screens.active, .{
+ .id = 1,
+ .width = 1,
+ .height = 1,
+ .format = .rgb,
+ .data = .{ .complete = pixels },
+ });
+
+ // Parent at (2, 1).
+ const pin = try t.screens.active.pages.trackPin(
+ t.screens.active.pages.pin(.{ .active = .{ .x = 2, .y = 1 } }).?,
+ );
+ try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{
+ .location = .{ .pin = pin },
+ .columns = 1,
+ .rows = 1,
+ });
+
+ // Child offset (3, 2) cells from the parent.
+ try storage.addPlacement(io, alloc, t.screens.active, 1, 2, .{
+ .location = .{ .relative = .{
+ .parent = .{
+ .image_id = 1,
+ .placement_id = .{ .tag = .external, .id = 1 },
+ },
+ .horizontal_offset = 3,
+ .vertical_offset = 2,
+ } },
+ .columns = 1,
+ .rows = 1,
+ .z = 1,
+ });
+
+ state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 });
+ try testing.expectEqual(@as(usize, 2), state.kitty_placements.items.len);
+
+ // Sorted by z: parent (z=0) first, child (z=1) second.
+ const parent = state.kitty_placements.items[0];
+ try testing.expectEqual(@as(i32, 2), parent.x);
+ try testing.expectEqual(@as(i32, 1), parent.y);
+ const child = state.kitty_placements.items[1];
+ try testing.expectEqual(@as(i32, 5), child.x);
+ try testing.expectEqual(@as(i32, 3), child.y);
+
+ // Pin-rooted relative placements don't force per-frame rebuilds.
+ try testing.expect(!state.kitty_virtual);
+}
+
+test "kitty renderer relative placement with negative offsets" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ const io = testing.io;
+
+ var t = try terminal.Terminal.init(io, alloc, .{ .rows = 10, .cols = 10 });
+ defer t.deinit(alloc);
+ t.width_px = 100;
+ t.height_px = 100;
+
+ var state: State = .empty;
+ defer state.deinit(alloc);
+
+ const storage = &t.screens.active.kitty_images;
+ const pixels = try alloc.alloc(u8, 3);
+ @memset(pixels, 0);
+ try storage.addImage(io, alloc, t.screens.active, .{
+ .id = 1,
+ .width = 1,
+ .height = 1,
+ .format = .rgb,
+ .data = .{ .complete = pixels },
+ });
+
+ const pin = try t.screens.active.pages.trackPin(
+ t.screens.active.pages.pin(.{ .active = .{ .x = 2, .y = 2 } }).?,
+ );
+ try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{
+ .location = .{ .pin = pin },
+ .columns = 1,
+ .rows = 1,
+ });
+
+ // Hangs up and to the left of the viewport but still has visible
+ // cells at (0, 0), so it must be kept with a negative position.
+ try storage.addPlacement(io, alloc, t.screens.active, 1, 2, .{
+ .location = .{ .relative = .{
+ .parent = .{
+ .image_id = 1,
+ .placement_id = .{ .tag = .external, .id = 1 },
+ },
+ .horizontal_offset = -3,
+ .vertical_offset = -3,
+ } },
+ .columns = 2,
+ .rows = 2,
+ .z = 1,
+ });
+
+ // Entirely off the left edge of the screen: culled.
+ try storage.addPlacement(io, alloc, t.screens.active, 1, 3, .{
+ .location = .{ .relative = .{
+ .parent = .{
+ .image_id = 1,
+ .placement_id = .{ .tag = .external, .id = 1 },
+ },
+ .horizontal_offset = -9,
+ } },
+ .columns = 2,
+ .rows = 2,
+ .z = 2,
+ });
+
+ state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 });
+ try testing.expectEqual(@as(usize, 2), state.kitty_placements.items.len);
+ const child = state.kitty_placements.items[1];
+ try testing.expectEqual(@as(i32, -1), child.x);
+ try testing.expectEqual(@as(i32, -1), child.y);
+}
+
+test "kitty renderer positions relative placements from virtual parent placeholders" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ const io = testing.io;
+
+ var t = try terminal.Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 });
+ defer t.deinit(alloc);
+ t.width_px = 50;
+ t.height_px = 50;
+ t.modes.set(.grapheme_cluster, true);
+
+ var state: State = .empty;
+ defer state.deinit(alloc);
+
+ const storage = &t.screens.active.kitty_images;
+ const pixels = try alloc.alloc(u8, 10 * 10 * 3);
+ @memset(pixels, 0);
+ try storage.addImage(io, alloc, t.screens.active, .{
+ .id = 1,
+ .width = 10,
+ .height = 10,
+ .format = .rgb,
+ .data = .{ .complete = pixels },
+ });
+ try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{
+ .location = .{ .virtual = {} },
+ });
+
+ // Placeholder cells for the virtual placement at (1, 1) and
+ // (3, 2): the minimum cell (1, 1) is the anchor for relative
+ // placements rooted at the virtual placement.
+ try t.setAttribute(.{ .@"256_fg" = 1 });
+ try t.setAttribute(.{ .@"256_underline_color" = 1 });
+ t.screens.active.cursorAbsolute(1, 1);
+ try t.printString("\u{10EEEE}\u{0305}\u{0305}");
+ t.screens.active.cursorAbsolute(3, 2);
+ try t.printString("\u{10EEEE}\u{0305}\u{0305}");
+
+ // Child of the virtual placement, offset (1, 2) cells.
+ try storage.addPlacement(io, alloc, t.screens.active, 1, 2, .{
+ .location = .{ .relative = .{
+ .parent = .{
+ .image_id = 1,
+ .placement_id = .{ .tag = .external, .id = 1 },
+ },
+ .horizontal_offset = 1,
+ .vertical_offset = 2,
+ } },
+ .columns = 1,
+ .rows = 1,
+ .z = 5,
+ });
+
+ state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 });
+ try testing.expect(state.kitty_virtual);
+
+ // Two placeholder runs (z=-1) plus the child (z=5), sorted by z.
+ try testing.expectEqual(@as(usize, 3), state.kitty_placements.items.len);
+ const child = state.kitty_placements.items[2];
+ try testing.expectEqual(@as(i32, 5), child.z);
+ try testing.expectEqual(@as(i32, 2), child.x);
+ try testing.expectEqual(@as(i32, 3), child.y);
+}
+
+test "kitty renderer uploads the current animation frame" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ const io = testing.io;
+
+ var t = try terminal.Terminal.init(io, alloc, .{ .rows = 3, .cols = 3 });
+ defer t.deinit(alloc);
+ t.width_px = 30;
+ t.height_px = 30;
+
+ var state: State = .empty;
+ defer state.deinit(alloc);
+
+ const storage = &t.screens.active.kitty_images;
+ try storage.addImage(io, alloc, t.screens.active, .{
+ .id = 1,
+ .width = 1,
+ .height = 1,
+ .format = .rgba,
+ .data = .{ .complete = try alloc.dupe(u8, &.{ 255, 0, 0, 255 }) },
+ });
+ const pin = try t.screens.active.pages.trackPin(
+ t.screens.active.cursor.page_pin.*,
+ );
+ try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{
+ .location = .{ .pin = pin },
+ .columns = 1,
+ .rows = 1,
+ });
+
+ state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 });
+ const gen1 = state.images.get(.{ .kitty = 1 }).?.generation;
+ try testing.expectEqualSlices(
+ u8,
+ &.{ 255, 0, 0, 255 },
+ state.images.get(.{ .kitty = 1 }).?.image.pending.dataSlice(),
+ );
+
+ // Attach an animation and make its extra frame current, the way
+ // an animation tick would.
+ const img = storage.images.getPtr(1).?;
+ const anim = try alloc.create(terminal.kitty.graphics.Animation);
+ anim.* = .{};
+ img.animation = anim;
+ try anim.frames.append(alloc, .{
+ .data = try alloc.dupe(u8, &.{ 0, 0, 255, 255 }),
+ .gap_ms = 40,
+ });
+ anim.current_index = 1;
+ storage.markImageContentChanged(io, img);
+
+ // The renderer must pick up the frame's pixels under a fresh
+ // generation.
+ state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 });
+ const entry = state.images.get(.{ .kitty = 1 }).?;
+ try testing.expect(entry.generation > gen1);
+ try testing.expectEqualSlices(
+ u8,
+ &.{ 0, 0, 255, 255 },
+ entry.image.pending.dataSlice(),
+ );
+}
diff --git a/vendor/libghostty-vt/src/renderer/link.zig b/vendor/libghostty-vt/src/renderer/link.zig
index 11ecc87a..dfc6fe67 100644
--- a/vendor/libghostty-vt/src/renderer/link.zig
+++ b/vendor/libghostty-vt/src/renderer/link.zig
@@ -148,7 +148,7 @@ test "renderCellMap" {
const testing = std.testing;
const alloc = testing.allocator;
- var t: terminal.Terminal = try .init(alloc, .{
+ var t: terminal.Terminal = try .init(testing.io, alloc, .{
.cols = 5,
.rows = 3,
});
@@ -201,7 +201,7 @@ test "renderCellMap hover links" {
const testing = std.testing;
const alloc = testing.allocator;
- var t: terminal.Terminal = try .init(alloc, .{
+ var t: terminal.Terminal = try .init(testing.io, alloc, .{
.cols = 5,
.rows = 3,
});
@@ -278,8 +278,9 @@ test "renderCellMap hover links" {
test "renderCellMap inactive links don't allocate" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
- var t: terminal.Terminal = try .init(alloc, .{
+ var t: terminal.Terminal = try .init(io, alloc, .{
.cols = 5,
.rows = 3,
});
@@ -338,7 +339,7 @@ test "renderCellMap mods no match" {
const testing = std.testing;
const alloc = testing.allocator;
- var t: terminal.Terminal = try .init(alloc, .{
+ var t: terminal.Terminal = try .init(testing.io, alloc, .{
.cols = 5,
.rows = 3,
});
diff --git a/vendor/libghostty-vt/src/renderer/metal/IOSurfaceLayer.zig b/vendor/libghostty-vt/src/renderer/metal/IOSurfaceLayer.zig
index 34fbfbed..cd97126a 100644
--- a/vendor/libghostty-vt/src/renderer/metal/IOSurfaceLayer.zig
+++ b/vendor/libghostty-vt/src/renderer/metal/IOSurfaceLayer.zig
@@ -40,6 +40,10 @@ pub fn init() !IOSurfaceLayer {
}
pub fn release(self: *IOSurfaceLayer) void {
+ // The layer may be retained by the view after we release our reference.
+ // Clear the callback first so that a later display pass can't access the
+ // renderer that owned this wrapper after it has been freed.
+ self.setDisplayCallback(null, null);
self.layer.release();
}
diff --git a/vendor/libghostty-vt/src/renderer/metal/api.zig b/vendor/libghostty-vt/src/renderer/metal/api.zig
index a2d8a135..8c195e0f 100644
--- a/vendor/libghostty-vt/src/renderer/metal/api.zig
+++ b/vendor/libghostty-vt/src/renderer/metal/api.zig
@@ -43,9 +43,7 @@ pub const MTLResourceOptions = packed struct(c_ulong) {
/// https://developer.apple.com/documentation/metal/mtlhazardtrackingmode?language=objc
hazard_tracking_mode: HazardTrackingMode = .default,
- _pad: @Type(.{
- .int = .{ .signedness = .unsigned, .bits = @bitSizeOf(c_ulong) - 10 },
- }) = 0,
+ _pad: @Int(.unsigned, @bitSizeOf(c_ulong) - 10) = 0,
pub const CPUCacheMode = enum(u4) {
default = 0,
@@ -366,10 +364,7 @@ pub const MTLTextureUsage = packed struct(c_ulong) {
/// https://developer.apple.com/documentation/metal/mtltextureusage/shaderatomic?language=objc
shader_atomic: bool = false, // TextureUsageShaderAtomic = 32,
- __reserved: @Type(.{ .int = .{
- .signedness = .unsigned,
- .bits = @bitSizeOf(c_ulong) - 6,
- } }) = 0,
+ __reserved: @Int(.unsigned, @bitSizeOf(c_ulong) - 6) = 0,
/// https://developer.apple.com/documentation/metal/mtltextureusage/unknown?language=objc
const unknown: MTLTextureUsage = @bitCast(0); // TextureUsageUnknown = 0,
diff --git a/vendor/libghostty-vt/src/renderer/metal/buffer.zig b/vendor/libghostty-vt/src/renderer/metal/buffer.zig
index f91f89e9..d892da2a 100644
--- a/vendor/libghostty-vt/src/renderer/metal/buffer.zig
+++ b/vendor/libghostty-vt/src/renderer/metal/buffer.zig
@@ -81,12 +81,12 @@ pub fn Buffer(comptime T: type) type {
self.buffer.msgSend(void, objc.sel("release"), .{});
// Allocate a new buffer with enough to hold double what we require.
- const size = req_bytes * 2;
+ self.len = data.len * 2;
self.buffer = self.opts.device.msgSend(
objc.Object,
objc.sel("newBufferWithLength:options:"),
.{
- @as(c_ulong, @intCast(size * @sizeOf(T))),
+ @as(c_ulong, @intCast(self.len * @sizeOf(T))),
self.opts.resource_options,
},
);
@@ -138,12 +138,12 @@ pub fn Buffer(comptime T: type) type {
self.buffer.msgSend(void, objc.sel("release"), .{});
// Allocate a new buffer with enough to hold double what we require.
- const size = req_bytes * 2;
+ self.len = total_len * 2;
self.buffer = self.opts.device.msgSend(
objc.Object,
objc.sel("newBufferWithLength:options:"),
.{
- @as(c_ulong, @intCast(size * @sizeOf(T))),
+ @as(c_ulong, @intCast(self.len * @sizeOf(T))),
self.opts.resource_options,
},
);
diff --git a/vendor/libghostty-vt/src/renderer/metal/shaders.zig b/vendor/libghostty-vt/src/renderer/metal/shaders.zig
index 0be02357..6ae32106 100644
--- a/vendor/libghostty-vt/src/renderer/metal/shaders.zig
+++ b/vendor/libghostty-vt/src/renderer/metal/shaders.zig
@@ -3,6 +3,7 @@ const Allocator = std.mem.Allocator;
const macos = @import("macos");
const objc = @import("objc");
const math = @import("../../math.zig");
+const global = @import("../../global.zig");
const mtl = @import("api.zig");
const Pipeline = @import("Pipeline.zig");
@@ -76,22 +77,16 @@ const PipelineDescription = struct {
/// We create a type for the pipeline collection based on our desc array.
const PipelineCollection = t: {
- var fields: [pipeline_descs.len]std.builtin.Type.StructField = undefined;
- for (pipeline_descs, 0..) |pipeline, i| {
- fields[i] = .{
- .name = pipeline[0],
- .type = Pipeline,
- .default_value_ptr = null,
- .is_comptime = false,
- .alignment = @alignOf(Pipeline),
- };
+ const StructField = std.builtin.Type.StructField;
+
+ var names: [pipeline_descs.len][]const u8 = undefined;
+ var types = [_]type{Pipeline} ** pipeline_descs.len;
+ var attrs = [_]StructField.Attributes{.{ .@"align" = @alignOf(Pipeline) }} ** pipeline_descs.len;
+
+ for (pipeline_descs, &names) |pipeline, *name| {
+ name.* = pipeline[0];
}
- break :t @Type(.{ .@"struct" = .{
- .layout = .auto,
- .fields = &fields,
- .decls = &.{},
- .is_tuple = false,
- } });
+ break :t @Struct(.auto, null, &names, &types, &attrs);
};
/// This contains the state for the shaders used by the Metal renderer.
@@ -332,7 +327,7 @@ pub const BgImage = extern struct {
/// Initialize the MTLLibrary. A MTLLibrary is a collection of shaders.
fn initLibrary(device: objc.Object) !objc.Object {
- const start = try std.time.Instant.now();
+ const start: std.Io.Timestamp = .now(global.io(), .awake);
const data = try macos.dispatch.Data.create(
@embedFile("ghostty_metallib"),
@@ -352,8 +347,7 @@ fn initLibrary(device: objc.Object) !objc.Object {
);
try checkError(err);
- const end = try std.time.Instant.now();
- log.debug("shader library loaded time={}us", .{end.since(start) / std.time.ns_per_us});
+ log.debug("shader library loaded time={}us", .{start.untilNow(global.io(), .awake).toMicroseconds()});
return library;
}
diff --git a/vendor/libghostty-vt/src/renderer/opengl/shaders.zig b/vendor/libghostty-vt/src/renderer/opengl/shaders.zig
index 68c1f36a..68d2babf 100644
--- a/vendor/libghostty-vt/src/renderer/opengl/shaders.zig
+++ b/vendor/libghostty-vt/src/renderer/opengl/shaders.zig
@@ -63,22 +63,16 @@ const PipelineDescription = struct {
/// We create a type for the pipeline collection based on our desc array.
const PipelineCollection = t: {
- var fields: [pipeline_descs.len]std.builtin.Type.StructField = undefined;
- for (pipeline_descs, 0..) |pipeline, i| {
- fields[i] = .{
- .name = pipeline[0],
- .type = Pipeline,
- .default_value_ptr = null,
- .is_comptime = false,
- .alignment = @alignOf(Pipeline),
- };
+ const StructField = std.builtin.Type.StructField;
+
+ var names: [pipeline_descs.len][]const u8 = undefined;
+ var types = [_]type{Pipeline} ** pipeline_descs.len;
+ var attrs = [_]StructField.Attributes{.{ .@"align" = @alignOf(Pipeline) }} ** pipeline_descs.len;
+
+ for (pipeline_descs, &names) |pipeline, *name| {
+ name.* = pipeline[0];
}
- break :t @Type(.{ .@"struct" = .{
- .layout = .auto,
- .fields = &fields,
- .decls = &.{},
- .is_tuple = false,
- } });
+ break :t @Struct(.auto, null, &names, &types, &attrs);
};
/// This contains the state for the shaders used by the Metal renderer.
diff --git a/vendor/libghostty-vt/src/renderer/shadertoy.zig b/vendor/libghostty-vt/src/renderer/shadertoy.zig
index 556c2829..6dee18b0 100644
--- a/vendor/libghostty-vt/src/renderer/shadertoy.zig
+++ b/vendor/libghostty-vt/src/renderer/shadertoy.zig
@@ -5,6 +5,8 @@ const ArenaAllocator = std.heap.ArenaAllocator;
const glslang = @import("glslang");
const spvcross = @import("spirv_cross");
const configpkg = @import("../config.zig");
+const compat_file = @import("../lib/compat/file.zig");
+const global = @import("../global.zig");
const log = std.log.scoped(.shadertoy);
@@ -87,11 +89,11 @@ pub fn loadFromFile(
// Read it all into memory -- we don't expect shaders to be large.
const src = src: {
// Load the shader file
- const cwd = std.fs.cwd();
- const file = try cwd.openFile(path, .{});
- defer file.close();
-
- break :src try file.readToEndAlloc(
+ const cwd = std.Io.Dir.cwd();
+ const file = try cwd.openFile(global.io(), path, .{});
+ defer file.close(global.io());
+ break :src try compat_file.readToEndAlloc(
+ file,
alloc,
4 * 1024 * 1024, // 4MB
);