inspector: redraw on timed updates rather than on-demand (#10526)

Fixes #10524 

This changes our inspector from being renderer-change driven to being
FPS driven. Both macOS and GTK now draw the inspector at most at 30 FPS
on a timer. Details between platforms are slightly different and covered
later.

The motivation for this is that triggering an inspector redraw on frame
update was causing _too many_ draws, leading to high CPU usage. Further,
terminal change isn't a good proxy for all the state that the inspector
shows, and tracking changes to all those to trigger a redraw is just a
lot of complexity.

Instead, moving to a standard, game-like framerate driven redraw
simplifies a lot. It does cost some CPU when idle, but actually lowers
our CPU under normal usage since it's rendering less often (30 FPS isn't
much for what we're doing).

**For macOS,** this uses CADisplayLink, so the refresh rate is variable.
I've seen macOS drop it to 1fps when there isn't much happening, which
is nice. We also setup an occlusion event so when the window is fully
occluded we stop rendering entirely.

**For GTK,** the tools to control this are limited. We do a standard
max-30 FPS tick redraw but can't support occlusion beyond what the
window server supports. For Wayland, I believe we get it for free
(occluded windows aren't drawn).
This commit is contained in:
Mitchell Hashimoto
2026-02-01 14:40:46 -08:00
committed by GitHub
4 changed files with 72 additions and 41 deletions

View File

@@ -120,9 +120,9 @@ extension Ghostty {
self.commandQueue = commandQueue
super.init(frame: frame, device: device)
// This makes it so renders only happen when we request
self.enableSetNeedsDisplay = true
self.isPaused = true
// Use timed updates mode. This is required for the inspector.
self.isPaused = false
self.preferredFramesPerSecond = 30
// After initializing the parent we can set our own properties
self.device = MTLCreateSystemDefaultDevice()
@@ -130,6 +130,13 @@ extension Ghostty {
// Setup our tracking areas for mouse events
updateTrackingAreas()
// Observe occlusion state to pause rendering when not visible
NotificationCenter.default.addObserver(
self,
selector: #selector(windowDidChangeOcclusionState),
name: NSWindow.didChangeOcclusionStateNotification,
object: nil)
}
required init(coder: NSCoder) {
@@ -141,27 +148,19 @@ extension Ghostty {
NotificationCenter.default.removeObserver(self)
}
@objc private func windowDidChangeOcclusionState(_ notification: NSNotification) {
guard let window = notification.object as? NSWindow,
window == self.window else { return }
// Pause rendering when our window isn't visible.
isPaused = !window.occlusionState.contains(.visible)
}
// MARK: Internal Inspector Funcs
private func surfaceViewDidChange() {
let center = NotificationCenter.default
center.removeObserver(self)
guard let surfaceView = self.surfaceView else { return }
guard let inspector = self.inspector else { return }
guard let device = self.device else { return }
_ = inspector.metalInit(device: device)
// Register an observer for render requests
center.addObserver(
self,
selector: #selector(didRequestRender),
name: Ghostty.Notification.inspectorNeedsDisplay,
object: surfaceView)
}
@objc private func didRequestRender(notification: SwiftUI.Notification) {
self.needsDisplay = true
}
private func updateSize() {

View File

@@ -240,7 +240,7 @@ fn drainMailbox(self: *App, rt_app: *apprt.App) !void {
if (comptime std.log.logEnabled(.debug, .app)) {
switch (message) {
// these tend to be way too verbose for normal debugging
.redraw_surface, .redraw_inspector => {},
.redraw_surface => {},
else => log.debug("mailbox message={t}", .{message}),
}
}
@@ -250,7 +250,6 @@ fn drainMailbox(self: *App, rt_app: *apprt.App) !void {
.close => |surface| self.closeSurface(surface),
.surface_message => |msg| try self.surfaceMessage(msg.surface, msg.message),
.redraw_surface => |surface| try self.redrawSurface(rt_app, surface),
.redraw_inspector => |surface| self.redrawInspector(rt_app, surface),
// If we're quitting, then we set the quit flag and stop
// draining the mailbox immediately. This lets us defer
@@ -289,11 +288,6 @@ fn redrawSurface(
);
}
fn redrawInspector(self: *App, rt_app: *apprt.App, surface: *apprt.Surface) void {
if (!self.hasRtSurface(surface)) return;
rt_app.redrawInspector(surface);
}
/// Create a new window
pub fn newWindow(self: *App, rt_app: *apprt.App, msg: Message.NewWindow) !void {
const target: apprt.Target = target: {
@@ -565,10 +559,6 @@ pub const Message = union(enum) {
/// message if it needs to.
redraw_surface: *apprt.Surface,
/// Redraw the inspector. This is called whenever some non-OS event
/// causes the inspector to need to be redrawn.
redraw_inspector: *apprt.Surface,
const NewWindow = struct {
/// The parent surface
parent: ?*Surface = null,

View File

@@ -63,6 +63,12 @@ pub const ImguiWidget = extern struct {
/// Our previous instant used to calculate delta time for animations.
instant: ?std.time.Instant = null,
/// Tick callback ID for timed updates.
tick_callback_id: c_uint = 0,
/// Last render time for throttling to 30 FPS.
last_render_time: ?std.time.Instant = null,
pub var offset: c_int = 0;
};
@@ -231,11 +237,26 @@ pub const ImguiWidget = extern struct {
// Call the virtual method to setup the UI.
self.setup();
// Add a tick callback to drive timed updates via the frame clock.
priv.tick_callback_id = self.as(gtk.Widget).addTickCallback(
tickCallback,
null,
null,
);
}
/// Handle a request to unrealize the GLArea
fn glAreaUnrealize(_: *gtk.GLArea, self: *ImguiWidget) callconv(.c) void {
assert(self.private().ig_context != null);
const priv = self.private();
assert(priv.ig_context != null);
// Remove the tick callback if it was registered.
if (priv.tick_callback_id != 0) {
self.as(gtk.Widget).removeTickCallback(priv.tick_callback_id);
priv.tick_callback_id = 0;
}
self.setCurrentContext() catch return;
cimgui.ImGui_ImplOpenGL3_Shutdown();
}
@@ -265,6 +286,10 @@ pub const ImguiWidget = extern struct {
fn glAreaRender(_: *gtk.GLArea, _: *gdk.GLContext, self: *Self) callconv(.c) c_int {
self.setCurrentContext() catch return @intFromBool(false);
// Update last render time for tick callback throttling.
const priv = self.private();
priv.last_render_time = std.time.Instant.now() catch null;
// Setup our frame. We render twice because some ImGui behaviors
// take multiple renders to process. I don't know how to make this
// more efficient.
@@ -411,6 +436,34 @@ pub const ImguiWidget = extern struct {
cimgui.c.ImGuiIO_AddInputCharactersUTF8(io, bytes);
}
/// Tick callback for timed updates. This drives periodic redraws.
/// Redraws are limited to 30 FPS max since our imgui widgets don't
/// usually need higher frame rates than that.
fn tickCallback(
widget: *gtk.Widget,
_: *gdk.FrameClock,
_: ?*anyopaque,
) callconv(.c) c_int {
const self: *Self = gobject.ext.cast(Self, widget) orelse return 0;
const priv = self.private();
const now = std.time.Instant.now() catch {
self.queueRender();
return 1;
};
// Throttle to 30 FPS (~33ms between frames)
const frame_time_ns: u64 = std.time.ns_per_s / 30;
const should_render = if (priv.last_render_time) |last|
now.since(last) >= frame_time_ns
else
true;
if (should_render) self.queueRender();
return 1; // Continue the tick callback
}
//---------------------------------------------------------------
// Default virtual method handlers

View File

@@ -294,9 +294,6 @@ fn setQosClass(self: *const Thread) void {
fn syncDrawTimer(self: *Thread) void {
skip: {
// If we have an inspector, we always run the draw timer.
if (self.flags.has_inspector) break :skip;
// If our renderer supports animations and has them, then we
// always have a draw timer.
if (@hasDecl(rendererpkg.Renderer, "hasAnimations") and
@@ -479,9 +476,6 @@ fn drainMailbox(self: *Thread) !void {
.inspector => |v| {
self.flags.has_inspector = v;
// Reset our draw timer state, which might change due
// to the inspector change.
self.syncDrawTimer();
},
.macos_display_id => |v| {
@@ -614,11 +608,6 @@ fn renderCallback(
return .disarm;
};
// If we have an inspector, let the app know we want to rerender that.
if (t.flags.has_inspector) {
_ = t.app_mailbox.push(.{ .redraw_inspector = t.surface }, .{ .instant = {} });
}
// Update our frame data
t.renderer.updateFrame(
t.state,