diff --git a/include/ghostty.h b/include/ghostty.h index d65eb7203..22d42449c 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -504,6 +504,7 @@ ghostty_app_t ghostty_surface_app(ghostty_surface_t); bool ghostty_surface_transparent(ghostty_surface_t); bool ghostty_surface_needs_confirm_quit(ghostty_surface_t); void ghostty_surface_refresh(ghostty_surface_t); +void ghostty_surface_draw(ghostty_surface_t); void ghostty_surface_set_content_scale(ghostty_surface_t, double, double); void ghostty_surface_set_focus(ghostty_surface_t, bool); void ghostty_surface_set_occlusion(ghostty_surface_t, bool); @@ -541,6 +542,10 @@ uintptr_t ghostty_surface_pwd(ghostty_surface_t, char*, uintptr_t); bool ghostty_surface_has_selection(ghostty_surface_t); uintptr_t ghostty_surface_selection(ghostty_surface_t, char*, uintptr_t); +#ifdef __APPLE__ +void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t); +#endif + ghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t); void ghostty_inspector_free(ghostty_surface_t); void ghostty_inspector_set_focus(ghostty_inspector_t, bool); diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index c6cddd30f..6f3c4a357 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -112,6 +112,11 @@ extension Ghostty { selector: #selector(onUpdateRendererHealth), name: Ghostty.Notification.didUpdateRendererHealth, object: self) + center.addObserver( + self, + selector: #selector(windowDidChangeScreen), + name: NSWindow.didChangeScreenNotification, + object: nil) // Setup our surface. This will also initialize all the terminal IO. let surface_cfg = baseConfig ?? SurfaceConfiguration() @@ -322,6 +327,19 @@ extension Ghostty { healthy = health == GHOSTTY_RENDERER_HEALTH_OK } + @objc private func windowDidChangeScreen(notification: SwiftUI.Notification) { + guard let window = self.window else { return } + guard let object = notification.object as? NSWindow, window == object else { return } + guard let screen = window.screen else { return } + guard let surface = self.surface else { return } + + // When the window changes screens, we need to update libghostty with the screen + // ID. If vsync is enabled, this will be used with the CVDisplayLink to ensure + // the proper refresh rate is going. + let id = (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as! NSNumber).uint32Value + ghostty_surface_set_display_id(surface, id) + } + // MARK: - NSView override func viewDidMoveToWindow() { @@ -439,7 +457,7 @@ extension Ghostty { override func updateLayer() { guard let surface = self.surface else { return } - ghostty_surface_refresh(surface); + ghostty_surface_draw(surface); } override func acceptsFirstMouse(for event: NSEvent?) -> Bool { diff --git a/pkg/macos/video/display_link.zig b/pkg/macos/video/display_link.zig index e7f4844a0..456d7f8d1 100644 --- a/pkg/macos/video/display_link.zig +++ b/pkg/macos/video/display_link.zig @@ -36,13 +36,25 @@ pub const DisplayLink = opaque { return c.CVDisplayLinkIsRunning(@ptrCast(self)) != 0; } + pub fn setCurrentCGDisplay( + self: *DisplayLink, + display_id: c.CGDirectDisplayID, + ) Error!void { + if (c.CVDisplayLinkSetCurrentCGDisplay( + @ptrCast(self), + display_id, + ) != c.kCVReturnSuccess) + return error.InvalidOperation; + } + // Note: this purposely throws away a ton of arguments I didn't need. // It would be trivial to refactor this into Zig types and properly // pass this through. pub fn setOutputCallback( self: *DisplayLink, - comptime callbackFn: *const fn (*DisplayLink, ?*anyopaque) void, - userinfo: ?*anyopaque, + comptime Userdata: type, + comptime callbackFn: *const fn (*DisplayLink, ?*Userdata) void, + userinfo: ?*Userdata, ) Error!void { if (c.CVDisplayLinkSetOutputCallback( @ptrCast(self), @@ -60,7 +72,10 @@ pub const DisplayLink = opaque { _ = flagsIn; _ = flagsOut; - callbackFn(displayLink, inner_userinfo); + callbackFn( + displayLink, + @alignCast(@ptrCast(inner_userinfo)), + ); return c.kCVReturnSuccess; } }).callback), diff --git a/src/Surface.zig b/src/Surface.zig index f142515c5..053d28dd1 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -553,6 +553,13 @@ pub fn close(self: *Surface) void { self.rt_surface.close(self.needsConfirmQuit()); } +/// Forces the surface to render. This is useful for when the surface +/// is in the middle of animation (such as a resize, etc.) or when +/// the render timer is managed manually by the apprt. +pub fn draw(self: *Surface) !void { + try self.renderer_thread.draw_now.notify(); +} + /// Activate the inspector. This will begin collecting inspection data. /// This will not affect the GUI. The GUI must use performAction to /// show/hide the inspector UI. diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index f3fa2f737..341de5eb7 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -653,6 +653,13 @@ pub const Surface = struct { }; } + pub fn draw(self: *Surface) void { + self.core_surface.draw() catch |err| { + log.err("error in draw err={}", .{err}); + return; + }; + } + pub fn updateContentScale(self: *Surface, x: f64, y: f64) void { // We are an embedded API so the caller can send us all sorts of // garbage. We want to make sure that the float values are valid @@ -1525,6 +1532,12 @@ pub const CAPI = struct { surface.refresh(); } + /// Tell the surface that it needs to schedule a render + /// call as soon as possible (NOW if possible). + export fn ghostty_surface_draw(surface: *Surface) void { + surface.draw(); + } + /// Update the size of a surface. This will trigger resize notifications /// to the pty and the renderer. export fn ghostty_surface_set_size(surface: *Surface, w: u32, h: u32) void { @@ -1724,6 +1737,15 @@ pub const CAPI = struct { // Inspector Metal APIs are only available on Apple systems usingnamespace if (builtin.target.isDarwin()) struct { + export fn ghostty_surface_set_display_id(ptr: *Surface, display_id: u32) void { + const surface = &ptr.core_surface; + _ = surface.renderer_thread.mailbox.push( + .{ .macos_display_id = display_id }, + .{ .forever = {} }, + ); + surface.renderer_thread.wakeup.notify() catch {}; + } + export fn ghostty_inspector_metal_init(ptr: *Inspector, device: objc.c.id) bool { return ptr.initMetal(objc.Object.fromId(device)); } diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 0d05b7983..36bd334d7 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -11,6 +11,7 @@ const objc = @import("objc"); const macos = @import("macos"); const imgui = @import("imgui"); const glslang = @import("glslang"); +const xev = @import("xev"); const apprt = @import("../apprt.zig"); const configpkg = @import("../config.zig"); const font = @import("../font/main.zig"); @@ -42,6 +43,11 @@ const InstanceBuffer = mtl_buffer.Buffer(u16); const ImagePlacementList = std.ArrayListUnmanaged(mtl_image.Placement); +const DisplayLink = switch (builtin.os.tag) { + .macos => *macos.video.DisplayLink, + else => void, +}; + // Get native API access on certain platforms so we can do more customization. const glfwNative = glfw.Native(.{ .cocoa = builtin.os.tag == .macos, @@ -92,8 +98,10 @@ current_background_color: terminal.color.RGB, /// cells goes into a separate shader. cells: mtl_cell.Contents, -/// If this is true, we do a full cell rebuild on the next frame. -cells_rebuild: bool = true, +/// Set to true after rebuildCells is called. This can be used +/// to determine if any possible changes have been made to the +/// cells for the draw call. +cells_rebuilt: bool = false, /// The current GPU uniform values. uniforms: mtl_shaders.Uniforms, @@ -115,6 +123,11 @@ shaders: Shaders, // Compiled shaders /// Metal objects layer: objc.Object, // CAMetalLayer +/// The CVDisplayLink used to drive the rendering loop in sync +/// with the display. This is void on platforms that don't support +/// a display link. +display_link: ?DisplayLink = null, + /// Custom shader state. This is only set if we have custom shaders. custom_shader_state: ?CustomShaderState = null, @@ -545,9 +558,19 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { }; }; - const cells = try mtl_cell.Contents.init(alloc); + var cells = try mtl_cell.Contents.init(alloc); errdefer cells.deinit(alloc); + const display_link: ?DisplayLink = null; + // Note(mitchellh): if/when we ever want to add vsync, we can use this + // display link to trigger rendering. We don't need this if vsync is off + // because any change will trigger a redraw immediately. + // const display_link: ?DisplayLink = switch (builtin.os.tag) { + // .macos => try macos.video.DisplayLink.createWithActiveCGDisplays(), + // else => null, + // }; + errdefer if (display_link) |v| v.release(); + return Metal{ .alloc = alloc, .config = options.config, @@ -581,6 +604,7 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { // Metal stuff .layer = layer, + .display_link = display_link, .custom_shader_state = custom_shader_state, .gpu_state = gpu_state, }; @@ -589,6 +613,13 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { pub fn deinit(self: *Metal) void { self.gpu_state.deinit(); + if (DisplayLink != void) { + if (self.display_link) |display_link| { + display_link.stop() catch {}; + display_link.release(); + } + } + self.cells.deinit(self.alloc); self.font_shaper.deinit(); @@ -635,6 +666,55 @@ pub fn threadExit(self: *const Metal) void { // Metal requires no per-thread state. } +/// Called by renderer.Thread when it starts the main loop. +pub fn loopEnter(self: *Metal, thr: *renderer.Thread) !void { + // If we don't support a display link we have no work to do. + if (comptime DisplayLink == void) return; + + // This is when we know our "self" pointer is stable so we can + // setup the display link. To setup the display link we set our + // callback and we can start it immediately. + const display_link = self.display_link orelse return; + try display_link.setOutputCallback( + xev.Async, + &displayLinkCallback, + &thr.draw_now, + ); + display_link.start() catch {}; +} + +/// Called by renderer.Thread when it exits the main loop. +pub fn loopExit(self: *const Metal) void { + // If we don't support a display link we have no work to do. + if (comptime DisplayLink == void) return; + + // Stop our display link. If this fails its okay it just means + // that we either never started it or the view its attached to + // is gone which is fine. + const display_link = self.display_link orelse return; + display_link.stop() catch {}; +} + +fn displayLinkCallback( + _: *macos.video.DisplayLink, + ud: ?*xev.Async, +) void { + const draw_now = ud orelse return; + draw_now.notify() catch |err| { + log.err("error notifying draw_now err={}", .{err}); + }; +} + +/// Called when we get an updated display ID for our display link. +pub fn setMacOSDisplayID(self: *Metal, id: u32) !void { + if (comptime DisplayLink == void) return; + const display_link = self.display_link orelse return; + log.info("updating display link display id={}", .{id}); + display_link.setCurrentCGDisplay(id) catch |err| { + log.warn("error setting display link display id err={}", .{err}); + }; +} + /// True if our renderer has animations so that a higher frequency /// timer is used. pub fn hasAnimations(self: *const Metal) bool { @@ -659,6 +739,35 @@ fn gridSize(self: *Metal) ?renderer.GridSize { /// Must be called on the render thread. pub fn setFocus(self: *Metal, focus: bool) !void { self.focused = focus; + + // If we're not focused, then we want to stop the display link + // because it is a waste of resources and we can move to pure + // change-driven updates. + if (comptime DisplayLink != void) link: { + const display_link = self.display_link orelse break :link; + if (focus) { + display_link.start() catch {}; + } else { + display_link.stop() catch {}; + } + } +} + +/// Callback when the window is visible or occluded. +/// +/// Must be called on the render thread. +pub fn setVisible(self: *Metal, visible: bool) void { + // If we're not visible, then we want to stop the display link + // because it is a waste of resources and we can move to pure + // change-driven updates. + if (comptime DisplayLink != void) link: { + const display_link = self.display_link orelse break :link; + if (visible and self.focused) { + display_link.start() catch {}; + } else { + display_link.stop() catch {}; + } + } } /// Set the new font size. @@ -720,6 +829,9 @@ pub fn updateFrame( preedit: ?renderer.State.Preedit, cursor_style: ?renderer.CursorStyle, color_palette: terminal.color.Palette, + + /// If true, rebuild the full screen. + full_rebuild: bool, }; // Update all our data as tightly as possible within the mutex. @@ -790,16 +902,20 @@ pub fn updateFrame( // If we have any terminal dirty flags set then we need to rebuild // the entire screen. This can be optimized in the future. - { - const Int = @typeInfo(terminal.Terminal.Dirty).Struct.backing_integer.?; - const v: Int = @bitCast(state.terminal.flags.dirty); - if (v > 0) self.cells_rebuild = true; - } - { - const Int = @typeInfo(terminal.Screen.Dirty).Struct.backing_integer.?; - const v: Int = @bitCast(state.terminal.screen.dirty); - if (v > 0) self.cells_rebuild = true; - } + const full_rebuild: bool = rebuild: { + { + const Int = @typeInfo(terminal.Terminal.Dirty).Struct.backing_integer.?; + const v: Int = @bitCast(state.terminal.flags.dirty); + if (v > 0) break :rebuild true; + } + { + const Int = @typeInfo(terminal.Screen.Dirty).Struct.backing_integer.?; + const v: Int = @bitCast(state.terminal.screen.dirty); + if (v > 0) break :rebuild true; + } + + break :rebuild false; + }; // Reset the dirty flags in the terminal and screen. We assume // that our rebuild will be successful since so we optimize for @@ -825,6 +941,7 @@ pub fn updateFrame( .preedit = preedit, .cursor_style = cursor_style, .color_palette = state.terminal.color_palette.colors, + .full_rebuild = full_rebuild, }; }; defer { @@ -834,6 +951,7 @@ pub fn updateFrame( // Build our GPU cells try self.rebuildCells( + critical.full_rebuild, &critical.screen, critical.mouse, critical.preedit, @@ -875,6 +993,12 @@ pub fn updateFrame( pub fn drawFrame(self: *Metal, surface: *apprt.Surface) !void { _ = surface; + // If our cells are not rebuilt, do a no-op draw. This means + // that no possible new data can exist that would warrant a full + // GPU update, our existing drawable is valid. + if (!self.cells_rebuilt) return; + self.cells_rebuilt = false; + // Wait for a frame to be available. const frame = self.gpu_state.nextFrame(); errdefer self.gpu_state.releaseFrame(); @@ -1695,6 +1819,7 @@ pub fn setScreenSize( /// memory and doesn't touch the GPU. fn rebuildCells( self: *Metal, + rebuild: bool, screen: *terminal.Screen, mouse: renderer.State.Mouse, preedit: ?renderer.State.Preedit, @@ -1735,6 +1860,9 @@ fn rebuildCells( }; } else null; + // If we are doing a full rebuild, then we clear the entire cell buffer. + if (rebuild) self.cells.reset(); + // Go row-by-row to build the cells. We go row by row because we do // font shaping by row. In the future, we will also do dirty tracking // by row. @@ -1743,12 +1871,14 @@ fn rebuildCells( while (row_it.next()) |row| { y = y - 1; - // Only rebuild if we are doing a full rebuild or this row is dirty. - // if (row.isDirty()) std.log.warn("dirty y={}", .{y}); - if (!self.cells_rebuild and !row.isDirty()) continue; + if (!rebuild) { + // Only rebuild if we are doing a full rebuild or this row is dirty. + // if (row.isDirty()) std.log.warn("dirty y={}", .{y}); + if (!row.isDirty()) continue; - // If we're rebuilding a row, then we always clear the cells - self.cells.clear(y); + // Clear the cells if the row is dirty + self.cells.clear(y); + } // True if we want to do font shaping around the cursor. We want to // do font shaping as long as the cursor is enabled. @@ -1892,8 +2022,8 @@ fn rebuildCells( } } - // We always mark our rebuild flag as false since we're done. - self.cells_rebuild = false; + // Update that our cells rebuilt + self.cells_rebuilt = true; // Log some things // log.debug("rebuildCells complete cached_runs={}", .{ diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 2a845d602..8fbf4654c 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -579,6 +579,14 @@ pub fn setFocus(self: *OpenGL, focus: bool) !void { self.focused = focus; } +/// Callback when the window is visible or occluded. +/// +/// Must be called on the render thread. +pub fn setVisible(self: *OpenGL, visible: bool) void { + _ = self; + _ = visible; +} + /// Set the new font size. /// /// Must be called on the render thread. diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index 91a213132..9345d8652 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -14,7 +14,7 @@ const App = @import("../App.zig"); const Allocator = std.mem.Allocator; const log = std.log.scoped(.renderer_thread); -const DRAW_INTERVAL = 33; // 30 FPS +const DRAW_INTERVAL = 8; // 120 FPS const CURSOR_BLINK_INTERVAL = 600; /// The type used for sending messages to the IO thread. For now this is @@ -50,6 +50,11 @@ draw_h: xev.Timer, draw_c: xev.Completion = .{}, draw_active: bool = false, +/// This async is used to force a draw immediately. This does not +/// coalesce like the wakeup does. +draw_now: xev.Async, +draw_now_c: xev.Completion = .{}, + /// The timer used for cursor blinking cursor_h: xev.Timer, cursor_c: xev.Completion = .{}, @@ -129,6 +134,10 @@ pub fn init( 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(); + // Setup a timer for blinking the cursor var cursor_timer = try xev.Timer.init(); errdefer cursor_timer.deinit(); @@ -137,7 +146,7 @@ pub fn init( var mailbox = try Mailbox.create(alloc); errdefer mailbox.destroy(alloc); - return Thread{ + return .{ .alloc = alloc, .config = DerivedConfig.init(config), .loop = loop, @@ -145,6 +154,7 @@ pub fn init( .stop = stop_h, .render_h = render_h, .draw_h = draw_h, + .draw_now = draw_now, .cursor_h = cursor_timer, .surface = surface, .renderer = renderer_impl, @@ -161,6 +171,7 @@ pub fn deinit(self: *Thread) void { self.wakeup.deinit(); self.render_h.deinit(); self.draw_h.deinit(); + self.draw_now.deinit(); self.cursor_h.deinit(); self.loop.deinit(); @@ -180,6 +191,11 @@ pub fn threadMain(self: *Thread) void { fn threadMain_(self: *Thread) !void { defer log.debug("renderer thread exited", .{}); + // Run our loop start/end callbacks if the renderer cares. + const has_loop = @hasDecl(renderer.Renderer, "loopEnter"); + if (has_loop) try self.renderer.loopEnter(self); + defer if (has_loop) self.renderer.loopExit(); + // Run our thread start/end callbacks. This is important because some // renderers have to do per-thread setup. For example, OpenGL has to set // some thread-local state since that is how it works. @@ -189,6 +205,7 @@ fn threadMain_(self: *Thread) !void { // Start the async handlers self.wakeup.wait(&self.loop, &self.wakeup_c, Thread, self, wakeupCallback); self.stop.wait(&self.loop, &self.stop_c, Thread, self, stopCallback); + self.draw_now.wait(&self.loop, &self.draw_now_c, Thread, self, drawNowCallback); // Send an initial wakeup message so that we render right away. try self.wakeup.notify(); @@ -255,6 +272,9 @@ fn drainMailbox(self: *Thread) !void { // still be happening. if (v) self.drawFrame(); + // Notify the renderer so it can update any state. + self.renderer.setVisible(v); + // Note that we're explicitly today not stopping any // cursor timers, draw timers, etc. These things have very // little resource cost and properly maintaining their active @@ -355,6 +375,12 @@ fn drainMailbox(self: *Thread) !void { }, .inspector => |v| self.flags.has_inspector = v, + + .macos_display_id => |v| { + if (@hasDecl(renderer.Renderer, "setMacOSDisplayID")) { + try self.renderer.setMacOSDisplayID(v); + } + }, } } } @@ -402,18 +428,43 @@ fn wakeupCallback( t.drainMailbox() catch |err| log.err("error draining mailbox err={}", .{err}); - // If the timer is already active then we don't have to do anything. - if (t.render_c.state() == .active) return .rearm; + // Render immediately + _ = renderCallback(t, undefined, undefined, {}); - // Timer is not active, let's start it - t.render_h.run( - &t.loop, - &t.render_c, - 10, - Thread, - t, - renderCallback, - ); + // The below is not used anymore but if we ever want to introduce + // a configuration to introduce a delay to coalesce renders, we can + // use this. + // + // // If the timer is already active then we don't have to do anything. + // if (t.render_c.state() == .active) return .rearm; + // + // // Timer is not active, let's start it + // t.render_h.run( + // &t.loop, + // &t.render_c, + // 10, + // Thread, + // t, + // renderCallback, + // ); + + return .rearm; +} + +fn drawNowCallback( + self_: ?*Thread, + _: *xev.Loop, + _: *xev.Completion, + r: xev.Async.WaitError!void, +) xev.CallbackAction { + _ = r catch |err| { + log.err("error in draw now err={}", .{err}); + return .rearm; + }; + + // Draw immediately + const t = self_.?; + t.drawFrame(); return .rearm; } @@ -468,7 +519,7 @@ fn renderCallback( ) catch |err| log.warn("error rendering err={}", .{err}); - // Draw + // Draw immediately t.drawFrame(); return .disarm; diff --git a/src/renderer/message.zig b/src/renderer/message.zig index c15854266..c2444b3c9 100644 --- a/src/renderer/message.zig +++ b/src/renderer/message.zig @@ -69,6 +69,9 @@ pub const Message = union(enum) { /// Activate or deactivate the inspector. inspector: bool, + /// The macOS display ID has changed for the window. + macos_display_id: u32, + /// Initialize a change_config message. pub fn initChangeConfig(alloc: Allocator, config: *const configpkg.Config) !Message { const thread_ptr = try alloc.create(renderer.Thread.DerivedConfig); diff --git a/src/renderer/metal/cell.zig b/src/renderer/metal/cell.zig index f6ee1a955..c0d7f56a3 100644 --- a/src/renderer/metal/cell.zig +++ b/src/renderer/metal/cell.zig @@ -124,6 +124,13 @@ pub const Contents = struct { self.text.shrinkAndFree(alloc, text_reserved_len); } + /// Reset the cell contents to an empty state without resizing. + pub fn reset(self: *Contents) void { + @memset(self.map, .{}); + self.bgs.clearRetainingCapacity(); + self.text.shrinkRetainingCapacity(text_reserved_len); + } + /// Returns the slice of fg cell contents to sync with the GPU. pub fn fgCells(self: *const Contents) []const mtl_shaders.CellText { const start: usize = if (self.cursor) 0 else 1;