diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-10 21:51:57 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-10 21:51:57 +0300 |
| commit | 425c86179791cd32e2d4ce0ae7f269940b4b0663 (patch) | |
| tree | e52c4383a6d69216cbe2a32638bf90841d7e2189 /vendor/libghostty-vt/src/renderer | |
| parent | 90e947a6e26e02f10ec7f1694c4eb5b78a86406b (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.zig | 38 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/renderer/OpenGL.zig | 6 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/renderer/Overlay.zig | 5 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/renderer/State.zig | 21 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/renderer/Thread.zig | 210 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/renderer/cell.zig | 156 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/renderer/cursor.zig | 12 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/renderer/generic.zig | 540 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/renderer/image.zig | 733 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/renderer/link.zig | 9 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/renderer/metal/IOSurfaceLayer.zig | 4 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/renderer/metal/api.zig | 9 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/renderer/metal/buffer.zig | 8 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/renderer/metal/shaders.zig | 30 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/renderer/opengl/shaders.zig | 24 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/renderer/shadertoy.zig | 12 |
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 ); |
