mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-14 03:25:50 +00:00
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:
@@ -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() {
|
||||
|
||||
12
src/App.zig
12
src/App.zig
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user