mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-24 16:11:43 +00:00
renderer: drive kitty graphics animation
This commit is contained in:
@@ -18,7 +18,6 @@ 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.
|
||||
@@ -53,16 +52,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.
|
||||
@@ -115,12 +113,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",
|
||||
};
|
||||
}
|
||||
@@ -153,10 +149,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();
|
||||
@@ -176,7 +168,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,
|
||||
@@ -201,7 +192,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)
|
||||
@@ -270,8 +260,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", .{});
|
||||
@@ -309,47 +300,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...
|
||||
@@ -378,15 +328,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);
|
||||
@@ -412,8 +358,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
|
||||
@@ -474,9 +421,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| {
|
||||
@@ -606,37 +553,19 @@ fn drawNowCallback(
|
||||
return .rearm;
|
||||
}
|
||||
|
||||
fn drawCallback(
|
||||
self_: ?*Thread,
|
||||
_: *xev.Loop,
|
||||
_: *xev.Completion,
|
||||
r: xev.Timer.RunError!void,
|
||||
) xev.CallbackAction {
|
||||
_ = r catch unreachable;
|
||||
const t: *Thread = self_ orelse {
|
||||
// This shouldn't happen so we log it.
|
||||
log.warn("render callback fired without data set", .{});
|
||||
return .disarm;
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
return .disarm;
|
||||
}
|
||||
|
||||
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", .{});
|
||||
@@ -645,6 +574,7 @@ fn renderCallback(
|
||||
|
||||
// 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
|
||||
@@ -657,9 +587,78 @@ fn renderCallback(
|
||||
// Draw
|
||||
t.drawFrame(false);
|
||||
|
||||
// Schedule the next animation wake, if the renderer needs one.
|
||||
t.armAnimationTimer();
|
||||
|
||||
return .disarm;
|
||||
}
|
||||
|
||||
/// 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 |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("animation callback fired without data set", .{});
|
||||
return .disarm;
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
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(
|
||||
self_: ?*Thread,
|
||||
_: *xev.Loop,
|
||||
|
||||
@@ -236,6 +236,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,
|
||||
@@ -577,6 +589,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,
|
||||
@@ -651,6 +664,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,
|
||||
};
|
||||
}
|
||||
@@ -997,10 +1011,74 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
||||
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
|
||||
@@ -1248,6 +1326,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.
|
||||
@@ -1492,10 +1597,13 @@ 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
|
||||
self.cells_rebuilt or
|
||||
self.hasAnimations() or
|
||||
self.animationWake() != null or
|
||||
sync;
|
||||
|
||||
if (!needs_redraw) {
|
||||
|
||||
@@ -769,7 +769,10 @@ pub const State = struct {
|
||||
alloc: Allocator,
|
||||
image: *const terminal.kitty.graphics.Image,
|
||||
) PrepImageError!void {
|
||||
const data = image.data.bytes() orelse unreachable;
|
||||
// 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 },
|
||||
@@ -1439,3 +1442,66 @@ test "kitty renderer positions relative placements from virtual parent placehold
|
||||
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(),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user