aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/renderer/Thread.zig
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-10 21:51:57 +0300
committerGitHub <noreply@github.com>2026-09-10 21:51:57 +0300
commit425c86179791cd32e2d4ce0ae7f269940b4b0663 (patch)
treee52c4383a6d69216cbe2a32638bf90841d7e2189 /vendor/libghostty-vt/src/renderer/Thread.zig
parent90e947a6e26e02f10ec7f1694c4eb5b78a86406b (diff)
chore: upgrade libghostty and preserve terminal behavior (#3906)
Diffstat (limited to 'vendor/libghostty-vt/src/renderer/Thread.zig')
-rw-r--r--vendor/libghostty-vt/src/renderer/Thread.zig210
1 files changed, 101 insertions, 109 deletions
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) {