aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/renderer
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-16 23:28:05 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-16 23:29:28 +0300
commit4a3302d19a2baa3a33e910e0088c24e780905b90 (patch)
treedcaf76fae1c82102d933c9225b17ca7ac5d3a847 /vendor/libghostty-vt/src/renderer
parent550e9f8cc085f428429f5cf2da0bb2dba897e8b8 (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.zig73
-rw-r--r--vendor/libghostty-vt/src/renderer/Thread.zig142
-rw-r--r--vendor/libghostty-vt/src/renderer/generic.zig22
-rw-r--r--vendor/libghostty-vt/src/renderer/image.zig30
-rw-r--r--vendor/libghostty-vt/src/renderer/link.zig96
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;