diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-16 23:28:05 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-16 23:29:28 +0300 |
| commit | 4a3302d19a2baa3a33e910e0088c24e780905b90 (patch) | |
| tree | dcaf76fae1c82102d933c9225b17ca7ac5d3a847 /vendor/libghostty-vt/src/renderer | |
| parent | 550e9f8cc085f428429f5cf2da0bb2dba897e8b8 (diff) | |
chore: update vendored libghostty-vt
refs #276
Diffstat (limited to 'vendor/libghostty-vt/src/renderer')
| -rw-r--r-- | vendor/libghostty-vt/src/renderer/State.zig | 73 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/renderer/Thread.zig | 142 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/renderer/generic.zig | 22 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/renderer/image.zig | 30 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/renderer/link.zig | 96 |
5 files changed, 332 insertions, 31 deletions
diff --git a/vendor/libghostty-vt/src/renderer/State.zig b/vendor/libghostty-vt/src/renderer/State.zig index 22cae8fe..8693458f 100644 --- a/vendor/libghostty-vt/src/renderer/State.zig +++ b/vendor/libghostty-vt/src/renderer/State.zig @@ -1,6 +1,9 @@ //! This is the render state that is given to a renderer. +const State = @This(); + const std = @import("std"); +const assert = std.debug.assert; const Allocator = std.mem.Allocator; const Inspector = @import("../inspector/main.zig").Inspector; const terminalpkg = @import("../terminal/main.zig"); @@ -30,6 +33,76 @@ preedit: ?Preedit = null, /// need about the mouse. mouse: Mouse = .{}, +/// The number of threads currently waiting to acquire `mutex` via +/// `lockDemand`. This is not protected by the mutex; it is read by +/// hot lock/unlock loops (the IO parse thread) in `yieldToDemand` to +/// decide whether to hand the mutex off before relocking it. +demand: std.atomic.Value(u32) = .init(0), + +/// Handoff generation counter. Incremented (with a futex wake) by +/// `unlockDemand` after a demanding waiter releases the mutex, so that +/// `yieldToDemand` knows the waiter had its turn. +handoff_gen: std.atomic.Value(u32) = .init(0), + +/// How long `yieldToDemand` sleeps waiting for a demanding waiter to +/// take its turn before giving up. This bounds how long the IO parse +/// thread can stall if a wake is lost or the waiter is descheduled; a +/// demanding critical section (the renderer's frame snapshot) is +/// microseconds, so one millisecond is generous. +const handoff_timeout_ns = 1 * std.time.ns_per_ms; + +/// Acquire `mutex` while signaling demand for it. Use this instead of +/// locking the mutex directly on threads that must not be starved by +/// a hot lock/unlock loop (the renderer's frame snapshot). Must be +/// released with `unlockDemand`; releasing with `mutex.unlock` keeps +/// the data safe but makes parked `yieldToDemand` callers wait out +/// their full timeout. +/// +/// Both `std.Thread.Mutex` and os_unfair_lock are unfair: a running +/// thread that unlocks and immediately relocks beats a sleeping +/// waiter every time, because the waiter must first be woken and +/// 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 { + _ = self.demand.fetchAdd(1, .monotonic); + self.mutex.lock(); + 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(); + _ = self.handoff_gen.fetchAdd(1, .monotonic); + std.Thread.Futex.wake(&self.handoff_gen, 1); +} + +/// Called by hot lock/unlock loops between critical sections, with +/// `mutex` NOT held: if a `lockDemand` waiter exists, sleep until it +/// has acquired and released the mutex (or the timeout passes). This +/// is the handoff that unfair mutexes never do on their own. +/// +/// The orderings here are all monotonic because these atomics are a +/// 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 { + if (self.demand.load(.monotonic) == 0) return; + + // Snapshot the generation before rechecking demand: if the waiter + // acquires and releases between our check and the wait below, the + // 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( + &self.handoff_gen, + gen, + handoff_timeout_ns, + ) catch {}; +} + pub const Mouse = struct { /// The point on the viewport where the mouse currently is. We use /// viewport points to avoid the complexity of mapping the mouse to diff --git a/vendor/libghostty-vt/src/renderer/Thread.zig b/vendor/libghostty-vt/src/renderer/Thread.zig index 48864219..e397a55a 100644 --- a/vendor/libghostty-vt/src/renderer/Thread.zig +++ b/vendor/libghostty-vt/src/renderer/Thread.zig @@ -10,6 +10,7 @@ const internal_os = @import("../os/main.zig"); const rendererpkg = @import("../renderer.zig"); const apprt = @import("../apprt.zig"); const configpkg = @import("../config.zig"); +const terminalpkg = @import("../terminal/main.zig"); const BlockingQueue = @import("../datastruct/main.zig").BlockingQueue; const App = @import("../App.zig"); @@ -72,6 +73,9 @@ cursor_h: xev.Timer, cursor_c: xev.Completion = .{}, cursor_c_cancel: xev.Completion = .{}, +/// Incremental scrollback compression scheduling. +compression: Compression = undefined, + /// The surface we're rendering to. surface: *apprt.Surface, @@ -111,10 +115,12 @@ 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", }; } }; @@ -162,7 +168,7 @@ pub fn init( var mailbox = try Mailbox.create(alloc); errdefer mailbox.destroy(alloc); - return .{ + var result: Thread = .{ .alloc = alloc, .config = .init(config), .loop = loop, @@ -178,6 +184,14 @@ pub fn init( .mailbox = mailbox, .app_mailbox = app_mailbox, }; + + // Only enable compression if we have it enabled... save some + // minor resources. + if (comptime terminalpkg.compression_enabled) { + result.compression = try .init(); + } + + return result; } /// Clean up the thread. This is only safe to call once the thread @@ -189,6 +203,8 @@ pub fn deinit(self: *Thread) void { self.draw_h.deinit(); self.draw_now.deinit(); self.cursor_h.deinit(); + if (comptime terminalpkg.compression_enabled) + self.compression.deinit(); self.loop.deinit(); // Nothing can possibly access the mailbox anymore, destroy it. @@ -492,6 +508,16 @@ fn drainMailbox(self: *Thread) !void { } fn changeConfig(self: *Thread, config: *const DerivedConfig) !void { + // A newly enabled scheduler must reconsider existing history even when no + // terminal activity occurred while compression was disabled. + if (comptime terminalpkg.compression_enabled) { + if (!self.config.scrollback_compression and + config.scrollback_compression) + { + self.compression.activity = null; + } + } + self.config = config.*; } @@ -537,6 +563,10 @@ fn wakeupCallback( // Render immediately _ = renderCallback(t, undefined, undefined, {}); + // PageList mutations maintain their own compression dirty state. Checking + // it here covers output, resize, and viewport scrolling uniformly. + t.compression.wake(t); + // The below is not used anymore but if we ever want to introduce // a configuration to introduce a delay to coalesce renders, we can // use this. @@ -723,3 +753,113 @@ fn cursorBlinkInterval() u64 { return CURSOR_BLINK_INTERVAL; } + +/// Schedules incremental terminal compression after renderer activity stops. +/// +/// This owns all renderer-specific compression state. The terminal decides +/// when compression-relevant activity changes and performs the actual work; +/// the renderer only provides idle scheduling and avoids waiting for the +/// terminal lock. +const Compression = struct { + const idle_interval = 250; + const step_interval = 1; + + timer: xev.Timer, + completion: xev.Completion = .{}, + reset_completion: xev.Completion = .{}, + activity: ?u64 = null, + + fn init() !Compression { + return .{ .timer = try xev.Timer.init() }; + } + + fn deinit(self: *Compression) void { + self.timer.deinit(); + } + + /// Start or postpone compression after a renderer wake. + fn wake(self: *Compression, thread: *Thread) void { + // If we have no compression then don't do anything. + if (comptime !terminalpkg.compression_enabled) return; + if (!thread.config.scrollback_compression) return; + + // PageList activity, rather than a generic renderer wake, restarts the + // idle interval. In particular, the inspector wakes the renderer every + // frame without changing terminal contents and must not starve this + // timer indefinitely. + if (thread.state.mutex.tryLock()) { + defer thread.state.mutex.unlock(); + const activity = thread.state.terminal.compressionActivity(); + if (self.activity == activity) return; + self.activity = activity; + } else if (self.completion.state() == .active) { + // Contention doesn't prove that compression-relevant activity + // changed. Keep an existing deadline so frequent inspector frames + // cannot postpone compression forever. The timer rechecks both the + // activity token and lock availability before doing any work. + return; + } + + // Contention may mean parsing is active. Scheduling is a harmless + // false positive when no compression work is actually pending, but is + // necessary when no timer is already active. + self.schedule(thread, idle_interval); + } + + /// Start the one-shot timer, or move its deadline if it is already active. + fn schedule(self: *Compression, thread: *Thread, delay_ms: u64) void { + self.timer.reset( + &thread.loop, + &self.completion, + &self.reset_completion, + delay_ms, + Thread, + thread, + timerCallback, + ); + } + + fn timerCallback( + thread_: ?*Thread, + _: *xev.Loop, + _: *xev.Completion, + result: xev.Timer.RunError!void, + ) xev.CallbackAction { + _ = result catch |err| switch (err) { + error.Canceled => return .disarm, + else => { + log.warn("error in compression timer err={}", .{err}); + return .disarm; + }, + }; + + const thread = thread_ orelse return .disarm; + const self = &thread.compression; + + if (self.step(thread)) |delay| self.schedule(thread, delay); + return .disarm; + } + + /// Try one bounded step without waiting for the terminal lock. The return + /// value is the delay before another attempt, or null when work is done. + fn step(self: *Compression, thread: *Thread) ?u64 { + if (!thread.config.scrollback_compression) return null; + + const state = thread.state; + if (!state.mutex.tryLock()) return idle_interval; + defer state.mutex.unlock(); + + const activity = state.terminal.compressionActivity(); + if (self.activity != activity) { + self.activity = activity; + return idle_interval; + } + + return switch (state.terminal.compress(.incremental)) { + .pending => step_interval, + .unsupported, + .complete, + => null, + }; + } +}; diff --git a/vendor/libghostty-vt/src/renderer/generic.zig b/vendor/libghostty-vt/src/renderer/generic.zig index 0f4a294b..14b79036 100644 --- a/vendor/libghostty-vt/src/renderer/generic.zig +++ b/vendor/libghostty-vt/src/renderer/generic.zig @@ -1170,8 +1170,10 @@ 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 }); // } - state.mutex.lock(); - defer state.mutex.unlock(); + // Lock while signaling demand so the IO parse thread + // can't starve us. See renderer.State.lockDemand. + state.lockDemand(); + defer state.unlockDemand(); // If we're in a synchronized output state, we pause all rendering. if (state.terminal.modes.get(.synchronized_output)) { @@ -1199,8 +1201,15 @@ pub fn Renderer(comptime GraphicsAPI: type) type { state.terminal.scrollViewport(.bottom); } - // Update our terminal state - try self.terminal_state.update(self.alloc, state.terminal); + // Begin the update of our terminal state. Work that + // doesn't require terminal access (e.g. style + // denormalization) is deferred to the endUpdate call + // outside of this critical section, keeping our lock + // hold time as short as possible. + try self.terminal_state.beginUpdate( + self.alloc, + state.terminal, + ); // If our terminal state is dirty at all we need to redo // the viewport search. @@ -1279,6 +1288,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }; }; + // Outside the critical area, complete the update we began + // within it. This must be done before anything reads the + // render state (e.g. rebuildCells). + self.terminal_state.endUpdate(); + // Outside the critical area we can update our links to contain // our regex results. self.config.links.renderCellMap( diff --git a/vendor/libghostty-vt/src/renderer/image.zig b/vendor/libghostty-vt/src/renderer/image.zig index 442b7543..f8269354 100644 --- a/vendor/libghostty-vt/src/renderer/image.zig +++ b/vendor/libghostty-vt/src/renderer/image.zig @@ -192,9 +192,9 @@ pub const State = struct { return; }; - // For transmit time we always just use the current time - // and overwrite the overlay. - const transmit_time = try std.time.Instant.now(); + // 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(); // Ensure we have space for our overlay placement. Do this before // we upload our image so we don't have to deal with cleaning @@ -207,7 +207,7 @@ pub const State = struct { try self.prepImage( alloc, .overlay, - transmit_time, + generation, pending, ); errdefer comptime unreachable; @@ -525,14 +525,14 @@ pub const State = struct { self: *State, alloc: Allocator, id: Id, - transmit_time: std.time.Instant, + generation: u64, pending: Image.Pending, ) PrepImageError!void { - // If this image exists and its transmit time is the same we assume - // it is the identical image so we don't need to send it to the GPU. + // If this image exists and its generation is the same it is the + // identical image so we don't need to send it to the GPU. const gop = try self.images.getOrPut(alloc, id); if (gop.found_existing and - gop.value_ptr.transmit_time.order(transmit_time) == .eq) + gop.value_ptr.generation == generation) { return; } @@ -570,7 +570,7 @@ pub const State = struct { if (!gop.found_existing) { gop.value_ptr.* = .{ .image = new_image, - .transmit_time = undefined, + .generation = 0, }; } else { gop.value_ptr.image.markForReplace( @@ -586,7 +586,7 @@ pub const State = struct { log.warn("error preparing image for upload err={}", .{err}); return error.ImageConversionError; }; - gop.value_ptr.transmit_time = transmit_time; + gop.value_ptr.generation = generation; } /// Prepare the provided Kitty image for upload to the GPU by copying its @@ -599,7 +599,7 @@ pub const State = struct { try self.prepImage( alloc, .{ .kitty = image.id }, - image.transmit_time, + image.generation, .{ .width = image.width, .height = image.height, @@ -688,7 +688,13 @@ pub const Id = union(enum) { /// The map used for storing images. pub const ImageMap = std.AutoHashMapUnmanaged(Id, struct { image: Image, - transmit_time: std.time.Instant, + + /// The generation of the terminal image this was created from + /// (see terminal.kitty.graphics.Image.generation). Used to detect + /// staleness: a differing generation for the same ID means the + /// contents changed and the texture must be replaced. Zero is + /// never a valid stored generation so it marks "not yet uploaded". + generation: u64, }); /// The state for a single image that is to be rendered. diff --git a/vendor/libghostty-vt/src/renderer/link.zig b/vendor/libghostty-vt/src/renderer/link.zig index c5de6157..11ecc87a 100644 --- a/vendor/libghostty-vt/src/renderer/link.zig +++ b/vendor/libghostty-vt/src/renderer/link.zig @@ -21,6 +21,20 @@ pub const Link = struct { pub fn deinit(self: *Link) void { self.regex.deinit(); } + + /// Returns true if this link's highlight condition matches the given mouse state. + fn active( + self: *const Link, + mouse_viewport: ?point.Coordinate, + mouse_mods: inputpkg.Mods, + ) bool { + return switch (self.highlight) { + .always => true, + .always_mods => |v| mouse_mods.equal(v), + .hover => mouse_viewport != null, + .hover_mods => |v| mouse_viewport != null and mouse_mods.equal(v), + }; + } }; /// A set of links. This provides a higher level API for renderers @@ -66,6 +80,14 @@ pub const Set = struct { // Fast path, not very likely since we have default links. if (self.links.len == 0) return; + // Determine if any links are active before building the string and + // byte-to-cell map. Those buffers scale with viewport size and this + // function runs during frame updates, so avoid allocating them when + // the current mouse/modifier state can't highlight any regex links. + for (self.links) |*link| { + if (link.active(mouse_viewport, mouse_mods)) break; + } else return; + // Convert our render state to a string + byte map. var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); @@ -80,20 +102,7 @@ pub const Set = struct { // Go through each link and see if we have any matches. for (self.links) |*link| { - // Determine if our highlight conditions are met. We use a - // switch here instead of an if so that we can get a compile - // error if any other conditions are added. - switch (link.highlight) { - .always => {}, - .always_mods => |v| if (!mouse_mods.equal(v)) continue, - - // We check the hover points later. - .hover => if (mouse_viewport == null) continue, - .hover_mods => |v| { - if (mouse_viewport == null) continue; - if (!mouse_mods.equal(v)) continue; - }, - } + if (!link.active(mouse_viewport, mouse_mods)) continue; var offset: usize = 0; while (offset < str.len) { @@ -266,6 +275,65 @@ test "renderCellMap hover links" { } } +test "renderCellMap inactive links don't allocate" { + const testing = std.testing; + const alloc = testing.allocator; + + var t: terminal.Terminal = try .init(alloc, .{ + .cols = 5, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + const str = "1ABCD2EFGH\r\n3IJKL"; + s.nextSlice(str); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + var set = try Set.fromConfig(alloc, &.{ + .{ + .regex = "AB", + .action = .{ .open = {} }, + .highlight = .{ .hover = {} }, + }, + + .{ + .regex = "EF", + .action = .{ .open = {} }, + .highlight = .{ .always_mods = .{ .ctrl = true } }, + }, + + .{ + .regex = "IJ", + .action = .{ .open = {} }, + .highlight = .{ .hover_mods = .{ .shift = true } }, + }, + }); + defer set.deinit(alloc); + + var failing = std.testing.FailingAllocator.init( + alloc, + .{ .fail_index = 0 }, + ); + const failing_alloc = failing.allocator(); + + var result: terminal.RenderState.CellSet = .empty; + defer result.deinit(failing_alloc); + try set.renderCellMap( + failing_alloc, + &result, + &state, + null, + .{}, + ); + + try testing.expectEqual(@as(usize, 0), result.count()); +} + test "renderCellMap mods no match" { const testing = std.testing; const alloc = testing.allocator; |
