diff --git a/macos/Sources/Ghostty/Surface View/InspectorView.swift b/macos/Sources/Ghostty/Surface View/InspectorView.swift index 0ca48371e..03be794e9 100644 --- a/macos/Sources/Ghostty/Surface View/InspectorView.swift +++ b/macos/Sources/Ghostty/Surface View/InspectorView.swift @@ -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() { diff --git a/src/App.zig b/src/App.zig index 3e83e414d..33c8318db 100644 --- a/src/App.zig +++ b/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, diff --git a/src/apprt/gtk/class/imgui_widget.zig b/src/apprt/gtk/class/imgui_widget.zig index 8ad75f5d0..01b3f3e5c 100644 --- a/src/apprt/gtk/class/imgui_widget.zig +++ b/src/apprt/gtk/class/imgui_widget.zig @@ -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 diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index d651fed79..c6217fcd1 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -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,