diff --git a/include/ghostty.h b/include/ghostty.h index 6b058d78c..e9a5df125 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -331,6 +331,11 @@ typedef enum { GHOSTTY_BUILD_MODE_RELEASE_SMALL, } ghostty_build_mode_e; +typedef enum { + GHOSTTY_RENDERER_HEALTH_OK, + GHOSTTY_RENDERER_HEALTH_UNHEALTHY, +} ghostty_renderer_health_e; + // Fully defined types. This MUST be kept in sync with equivalent Zig // structs. To find the Zig struct, grep for this type name. The documentation // for all of these types is available in the Zig source. @@ -376,6 +381,7 @@ typedef void (*ghostty_runtime_set_initial_window_size_cb)(void *, uint32_t, uin typedef void (*ghostty_runtime_render_inspector_cb)(void *); typedef void (*ghostty_runtime_set_cell_size_cb)(void *, uint32_t, uint32_t); typedef void (*ghostty_runtime_show_desktop_notification_cb)(void *, const char *, const char *); +typedef void (*ghostty_runtime_update_renderer_health)(void *, ghostty_renderer_health_e); typedef struct { void *userdata; @@ -404,6 +410,7 @@ typedef struct { ghostty_runtime_render_inspector_cb render_inspector_cb; ghostty_runtime_set_cell_size_cb set_cell_size_cb; ghostty_runtime_show_desktop_notification_cb show_desktop_notification_cb; + ghostty_runtime_update_renderer_health update_renderer_health_cb; } ghostty_runtime_config_s; //------------------------------------------------------------------- diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 3afbc0870..f988c553c 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -92,8 +92,8 @@ extension Ghostty { render_inspector_cb: { userdata in App.renderInspector(userdata) }, set_cell_size_cb: { userdata, width, height in App.setCellSize(userdata, width: width, height: height) }, show_desktop_notification_cb: { userdata, title, body in - App.showUserNotification(userdata, title: title, body: body) - } + App.showUserNotification(userdata, title: title, body: body) }, + update_renderer_health_cb: { userdata, health in App.updateRendererHealth(userdata, health: health) } ) // Create the ghostty app. @@ -289,6 +289,7 @@ extension Ghostty { static func renderInspector(_ userdata: UnsafeMutableRawPointer?) {} static func setCellSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) {} static func showUserNotification(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer?, body: UnsafePointer?) {} + static func updateRendererHealth(_ userdata: UnsafeMutableRawPointer?, health: ghostty_renderer_health_e) {} #endif #if os(macOS) @@ -611,6 +612,17 @@ extension Ghostty { "mode": mode, ]) } + + static func updateRendererHealth(_ userdata: UnsafeMutableRawPointer?, health: ghostty_renderer_health_e) { + let surface = self.surfaceUserdata(from: userdata) + NotificationCenter.default.post( + name: Notification.didUpdateRendererHealth, + object: surface, + userInfo: [ + "health": health, + ] + ) + } /// Returns the GhosttyState from the given userdata value. static private func appState(fromView view: SurfaceView) -> App? { diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 5cf05694b..cf402355c 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -197,6 +197,24 @@ extension Ghostty { _ = ghostty_config_get(config, &v, key, UInt(key.count)) return v } + + var backgroundColor: Color { + var rgb: UInt32 = 0 + let bg_key = "background" + if (!ghostty_config_get(config, &rgb, bg_key, UInt(bg_key.count))) { + return Color(NSColor.windowBackgroundColor) + } + + let red = Double(rgb & 0xff) + let green = Double((rgb >> 8) & 0xff) + let blue = Double((rgb >> 16) & 0xff) + + return Color( + red: red / 255, + green: green / 255, + blue: blue / 255 + ) + } var backgroundOpacity: Double { guard let config = self.config else { return 1 } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 9f8fe5237..5644ad5c0 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -232,6 +232,9 @@ extension Ghostty.Notification { /// Notification sent to the split root to equalize split sizes static let didEqualizeSplits = Notification.Name("com.mitchellh.ghostty.didEqualizeSplits") + + /// Notification that renderer health changed + static let didUpdateRendererHealth = Notification.Name("com.mitchellh.ghostty.didUpdateRendererHealth") } // Make the input enum hashable. diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 0fb4c212d..bca6c6687 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -56,6 +56,8 @@ extension Ghostty { private var hasFocus: Bool { surfaceFocus && windowFocus } var body: some View { + let center = NotificationCenter.default + ZStack { // We use a GeometryReader to get the frame bounds so that our metal surface // is up to date. See TerminalSurfaceView for why we don't use the NSView @@ -63,9 +65,9 @@ extension Ghostty { GeometryReader { geo in // We use these notifications to determine when the window our surface is // attached to is or is not focused. - let pubBecomeFocused = NotificationCenter.default.publisher(for: Notification.didBecomeFocusedSurface, object: surfaceView) - let pubBecomeKey = NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification) - let pubResign = NotificationCenter.default.publisher(for: NSWindow.didResignKeyNotification) + let pubBecomeFocused = center.publisher(for: Notification.didBecomeFocusedSurface, object: surfaceView) + let pubBecomeKey = center.publisher(for: NSWindow.didBecomeKeyNotification) + let pubResign = center.publisher(for: NSWindow.didResignKeyNotification) Surface(view: surfaceView, hasFocus: hasFocus, size: geo.size) .focused($surfaceFocus) @@ -141,6 +143,12 @@ extension Ghostty { } } .ghosttySurfaceView(surfaceView) + + // If our surface is not healthy, then we render an error view over it. + if (!surfaceView.healthy) { + Rectangle().fill(ghostty.config.backgroundColor) + SurfaceUnhealthyView() + } // If we're part of a split view and don't have focus, we put a semi-transparent // rectangle above our view to make it look unfocused. We use "surfaceFocus" @@ -158,6 +166,29 @@ extension Ghostty { } } } + + struct SurfaceUnhealthyView: View { + var body: some View { + HStack { + Image("AppIconImage") + .resizable() + .scaledToFit() + .frame(width: 128, height: 128) + + VStack(alignment: .leading) { + Text("Oh, no. 😭").font(.title) + Text(""" + The renderer has failed. This is usually due to exhausting + available GPU memory. Please free up available resources. + """.replacingOccurrences(of: "\n", with: " ") + ) + .frame(maxWidth: 350) + } + } + .padding() + } + } + /// A surface is terminology in Ghostty for a terminal surface, or a place where a terminal is actually drawn /// and interacted with. The word "surface" is used because a surface may represent a window, a tab, @@ -248,6 +279,10 @@ extension Ghostty { // when the font size changes). This is used to allow windows to be // resized in discrete steps of a single cell. @Published var cellSize: NSSize = .zero + + // The health state of the surface. This currently only reflects the + // renderer health. In the future we may want to make this an enum. + @Published var healthy: Bool = true // An initial size to request for a window. This will only affect // then the view is moved to a new window. @@ -327,7 +362,16 @@ extension Ghostty { // is non-zero so that our layer bounds are non-zero so that our renderer // can do SOMETHING. super.init(frame: NSMakeRect(0, 0, 800, 600)) - + + // Before we initialize the surface we want to register our notifications + // so there is no window where we can't receive them. + let center = NotificationCenter.default + center.addObserver( + self, + selector: #selector(onUpdateRendererHealth), + name: Ghostty.Notification.didUpdateRendererHealth, + object: self) + // Setup our surface. This will also initialize all the terminal IO. let surface_cfg = baseConfig ?? SurfaceConfiguration() var surface_cfg_c = surface_cfg.ghosttyConfig(view: self) @@ -346,6 +390,10 @@ extension Ghostty { } deinit { + // Remove all of our notificationcenter subscriptions + let center = NotificationCenter.default + center.removeObserver(self) + // Whenever the surface is removed, we need to note that our restorable // state is invalid to prevent the surface from being restored. invalidateRestorableState() @@ -503,6 +551,16 @@ extension Ghostty { } } + // MARK: - Notifications + + @objc private func onUpdateRendererHealth(notification: SwiftUI.Notification) { + guard let healthAny = notification.userInfo?["health"] else { return } + guard let health = healthAny as? ghostty_renderer_health_e else { return } + healthy = health == GHOSTTY_RENDERER_HEALTH_OK + } + + // MARK: - NSView + override func viewDidMoveToWindow() { // Set our background blur if requested setWindowBackgroundBlur(window) diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index a88aa55b0..5c45409ab 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -11,6 +11,7 @@ const Allocator = std.mem.Allocator; const objc = @import("objc"); const apprt = @import("../apprt.zig"); const input = @import("../input.zig"); +const renderer = @import("../renderer.zig"); const terminal = @import("../terminal/main.zig"); const CoreApp = @import("../App.zig"); const CoreInspector = @import("../inspector/main.zig").Inspector; @@ -123,6 +124,9 @@ pub const App = struct { /// Show a desktop notification to the user. show_desktop_notification: ?*const fn (SurfaceUD, [*:0]const u8, [*:0]const u8) void = null, + + /// Called when the health of the renderer changes. + update_renderer_health: ?*const fn (SurfaceUD, renderer.Health) void = null, }; /// Special values for the goto_tab callback. @@ -960,6 +964,16 @@ pub const Surface = struct { func(self.opts.userdata, title, body); } + + /// Update the health of the renderer. + pub fn updateRendererHealth(self: *const Surface, health: renderer.Health) void { + const func = self.app.opts.update_renderer_health orelse { + log.info("runtime embedder does not support update_renderer_health", .{}); + return; + }; + + func(self.opts.userdata, health); + } }; /// Inspector is the state required for the terminal inspector. A terminal diff --git a/src/renderer.zig b/src/renderer.zig index 331773f31..72bba4c18 100644 --- a/src/renderer.zig +++ b/src/renderer.zig @@ -55,7 +55,7 @@ pub const Renderer = switch (build_config.renderer) { /// The health status of a renderer. These must be shared across all /// renderers even if some states aren't reachable so that our API users /// can use the same enum for all renderers. -pub const Health = enum(u8) { +pub const Health = enum(c_int) { healthy = 0, unhealthy = 1, }; diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 8e2e309e6..b1369b007 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -896,7 +896,7 @@ fn bufferCompleted( const status = buffer.getProperty(mtl.MTLCommandBufferStatus, "status"); const health: Health = switch (status) { .@"error" => .unhealthy, - else => .healthy, + else => .unhealthy, }; // If our health value hasn't changed, then we do nothing. We don't