diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 7bac50670..db2c9d893 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -53,7 +53,6 @@ A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */; }; A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */; }; A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */; }; - A55685E029A03A9F004303CE /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55685DF29A03A9F004303CE /* AppError.swift */; }; A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */; }; A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */; }; A5593FE32DF8D78600B47B10 /* TerminalHiddenTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */; }; @@ -124,6 +123,9 @@ A5E408302E0271320035FEAC /* GhosttyIntentError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */; }; A5E408322E02FEDF0035FEAC /* TerminalEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */; }; A5E408342E0320140035FEAC /* GetTerminalDetailsIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */; }; + A5E408382E03C7DA0035FEAC /* Ghostty.Surface.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408372E03C7D80035FEAC /* Ghostty.Surface.swift */; }; + A5E4083A2E0449BD0035FEAC /* Ghostty.Command.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408392E0449BB0035FEAC /* Ghostty.Command.swift */; }; + A5E4083C2E044DB50035FEAC /* Ghostty.Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4083B2E044DB40035FEAC /* Ghostty.Error.swift */; }; A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; }; AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; }; C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; }; @@ -175,7 +177,6 @@ A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIcon.swift; sourceTree = ""; }; A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIconImage.swift; sourceTree = ""; }; A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTerminalController.swift; sourceTree = ""; }; - A55685DF29A03A9F004303CE /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = ""; }; A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = ""; }; A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenTitlebarTerminalWindow.swift; sourceTree = ""; }; A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalHiddenTitlebar.xib; sourceTree = ""; }; @@ -248,6 +249,9 @@ A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyIntentError.swift; sourceTree = ""; }; A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalEntity.swift; sourceTree = ""; }; A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetTerminalDetailsIntent.swift; sourceTree = ""; }; + A5E408372E03C7D80035FEAC /* Ghostty.Surface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Surface.swift; sourceTree = ""; }; + A5E408392E0449BB0035FEAC /* Ghostty.Command.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Command.swift; sourceTree = ""; }; + A5E4083B2E044DB40035FEAC /* Ghostty.Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Error.swift; sourceTree = ""; }; A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = ""; }; C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = ""; }; @@ -440,12 +444,14 @@ A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */, A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */, A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */, + A5E408392E0449BB0035FEAC /* Ghostty.Command.swift */, A514C8D52B54A16400493A16 /* Ghostty.Config.swift */, A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */, + A5E4083B2E044DB40035FEAC /* Ghostty.Error.swift */, A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */, A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */, A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */, - A55685DF29A03A9F004303CE /* AppError.swift */, + A5E408372E03C7D80035FEAC /* Ghostty.Surface.swift */, A52FFF5A2CAA54A8000C6A5B /* FullscreenMode+Extension.swift */, A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */, ); @@ -766,6 +772,7 @@ A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */, A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */, A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */, + A5E408382E03C7DA0035FEAC /* Ghostty.Surface.swift in Sources */, A5593FE72DF927D200B47B10 /* TransparentTitlebarTerminalWindow.swift in Sources */, A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */, A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */, @@ -800,6 +807,7 @@ A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */, A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */, A5E408302E0271320035FEAC /* GhosttyIntentError.swift in Sources */, + A5E4083A2E0449BD0035FEAC /* Ghostty.Command.swift in Sources */, A5FEB3002ABB69450068369E /* main.swift in Sources */, A53A297F2DB4480F00B6E02C /* EventModifiers+Extension.swift in Sources */, A5E4082E2E0237460035FEAC /* NewTerminalIntent.swift in Sources */, @@ -809,13 +817,13 @@ A57D79272C9C879B001D522E /* SecureInput.swift in Sources */, A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */, A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */, + A5E4083C2E044DB50035FEAC /* Ghostty.Error.swift in Sources */, A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */, A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */, A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */, A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */, A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */, A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */, - A55685E029A03A9F004303CE /* AppError.swift in Sources */, A599CDB02CF103F60049FA26 /* NSAppearance+Extension.swift in Sources */, A52FFF572CA90484000C6A5B /* QuickTerminalScreen.swift in Sources */, A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */, diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 47f2baf23..d02828494 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -17,33 +17,19 @@ struct TerminalCommandPaletteView: View { // The commands available to the command palette. private var commandOptions: [CommandOption] { - guard let surface = surfaceView.surface else { return [] } - - var ptr: UnsafeMutablePointer? = nil - var count: Int = 0 - ghostty_surface_commands(surface, &ptr, &count) - guard let ptr else { return [] } - - let buffer = UnsafeBufferPointer(start: ptr, count: count) - return Array(buffer).filter { c in - let key = String(cString: c.action_key) - switch (key) { - case "toggle_tab_overview", - "toggle_window_decorations", - "show_gtk_inspector": - return false - default: - return true - } - }.map { c in - let action = String(cString: c.action) - return CommandOption( - title: String(cString: c.title), - description: String(cString: c.description), - symbols: ghosttyConfig.keyboardShortcut(for: action)?.keyList - ) { - onAction(action) + guard let surface = surfaceView.surfaceModel else { return [] } + do { + return try surface.commands().map { c in + return CommandOption( + title: c.title, + description: c.description, + symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList + ) { + onAction(c.action) + } } + } catch { + return [] } } diff --git a/macos/Sources/Ghostty/AppError.swift b/macos/Sources/Ghostty/AppError.swift deleted file mode 100644 index 55f191d3d..000000000 --- a/macos/Sources/Ghostty/AppError.swift +++ /dev/null @@ -1,3 +0,0 @@ -enum AppError: Error { - case surfaceCreateError -} diff --git a/macos/Sources/Ghostty/Ghostty.Command.swift b/macos/Sources/Ghostty/Ghostty.Command.swift new file mode 100644 index 000000000..1479ae92d --- /dev/null +++ b/macos/Sources/Ghostty/Ghostty.Command.swift @@ -0,0 +1,46 @@ +import GhosttyKit + +extension Ghostty { + /// `ghostty_command_s` + struct Command: Sendable { + private let cValue: ghostty_command_s + + /// The title of the command. + var title: String { + String(cString: cValue.title) + } + + /// Human-friendly description of what this command will do. + var description: String { + String(cString: cValue.description) + } + + /// The full action that must be performed to invoke this command. + var action: String { + String(cString: cValue.action) + } + + /// Only the key portion of the action so you can compare action types, e.g. `goto_split` + /// instead of `goto_split:left`. + var actionKey: String { + String(cString: cValue.action_key) + } + + /// True if this can be performed on this target. + var isSupported: Bool { + !Self.unsupportedActionKeys.contains(actionKey) + } + + /// Unsupported action keys, because they either don't make sense in the context of our + /// target platform or they just aren't implemented yet. + static let unsupportedActionKeys: [String] = [ + "toggle_tab_overview", + "toggle_window_decorations", + "show_gtk_inspector", + ] + + init(cValue: ghostty_command_s) { + self.cValue = cValue + } + } +} diff --git a/macos/Sources/Ghostty/Ghostty.Error.swift b/macos/Sources/Ghostty/Ghostty.Error.swift new file mode 100644 index 000000000..66f6857bf --- /dev/null +++ b/macos/Sources/Ghostty/Ghostty.Error.swift @@ -0,0 +1,12 @@ +extension Ghostty { + /// Possible errors from internal Ghostty calls. + enum Error: Swift.Error, CustomLocalizedStringResourceConvertible { + case apiFailed + + var localizedStringResource: LocalizedStringResource { + switch self { + case .apiFailed: return "libghostty API call failed" + } + } + } +} diff --git a/macos/Sources/Ghostty/Ghostty.Surface.swift b/macos/Sources/Ghostty/Ghostty.Surface.swift new file mode 100644 index 000000000..5560ff3a8 --- /dev/null +++ b/macos/Sources/Ghostty/Ghostty.Surface.swift @@ -0,0 +1,64 @@ +import GhosttyKit + +extension Ghostty { + /// Represents a single surface within Ghostty. + /// + /// NOTE(mitchellh): This is a work-in-progress class as part of a general refactor + /// of our Ghostty data model. At the time of writing there's still a ton of surface + /// functionality that is not encapsulated in this class. It is planned to migrate that + /// all over. + /// + /// Wraps a `ghostty_surface_t` + final class Surface: Sendable { + private let surface: ghostty_surface_t + + /// Read the underlying C value for this surface. This is unsafe because the value will be + /// freed when the Surface class is deinitialized. + var unsafeCValue: ghostty_surface_t { + surface + } + + /// Initialize from the C structure. + init(cSurface: ghostty_surface_t) { + self.surface = cSurface + } + + deinit { + // deinit is not guaranteed to happen on the main actor and our API + // calls into libghostty must happen there so we capture the surface + // value so we don't capture `self` and then we detach it in a task. + // We can't wait for the task to succeed so this will happen sometime + // but that's okay. + let surface = self.surface + Task.detached { @MainActor in + ghostty_surface_free(surface) + } + } + + /// Perform a keybinding action. + /// + /// The action can be any valid keybind parameter. e.g. `keybind = goto_tab:4` + /// you can perform `goto_tab:4` with this. + /// + /// Returns true if the action was performed. Invalid actions return false. + @MainActor + func perform(action: String) -> Bool { + let len = action.utf8CString.count + if (len == 0) { return false } + return action.withCString { cString in + ghostty_surface_binding_action(surface, cString, UInt(len - 1)) + } + } + + /// Command options for this surface. + @MainActor + func commands() throws -> [Command] { + var ptr: UnsafeMutablePointer? = nil + var count: Int = 0 + ghostty_surface_commands(surface, &ptr, &count) + guard let ptr else { throw Error.apiFailed } + let buffer = UnsafeBufferPointer(start: ptr, count: count) + return Array(buffer).map { Command(cValue: $0) }.filter { $0.isSupported } + } + } +} diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 82721c17e..125a09825 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -19,6 +19,15 @@ struct Ghostty { static let userNotificationActionShow = "com.mitchellh.ghostty.userNotification.Show" } +// MARK: C Extensions + +/// A command is fully self-contained so it is Sendable. +extension ghostty_command_s: @unchecked @retroactive Sendable {} + +/// A surface is sendable because it is just a reference type. Using the surface in parameters +/// may be unsafe but the value itself is safe to send across threads. +extension ghostty_surface_t: @unchecked @retroactive Sendable {} + // MARK: Build Info extension Ghostty { diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index f830da4ef..371e4ff41 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -79,7 +79,7 @@ extension Ghostty { let pubResign = center.publisher(for: NSWindow.didResignKeyNotification) #endif - Surface(view: surfaceView, size: geo.size) + SurfaceRepresentable(view: surfaceView, size: geo.size) .focused($surfaceFocus) .focusedValue(\.ghosttySurfacePwd, surfaceView.pwd) .focusedValue(\.ghosttySurfaceView, surfaceView) @@ -381,7 +381,7 @@ extension Ghostty { /// We just wrap an AppKit NSView here at the moment so that we can behave as low level as possible /// since that is what the Metal renderer in Ghostty expects. In the future, it may make more sense to /// wrap an MTKView and use that, but for legacy reasons we didn't do that to begin with. - struct Surface: OSViewRepresentable { + struct SurfaceRepresentable: OSViewRepresentable { /// The view to render for the terminal surface. let view: SurfaceView diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 131e39ba7..fe0851261 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -115,10 +115,20 @@ extension Ghostty { } } + /// Returns the data model for this surface. + /// + /// Note: eventually, all surface access will be through this, but presently its in a transition + /// state so we're mixing this with direct surface access. + private(set) var surfaceModel: Ghostty.Surface? + + /// Returns the underlying C value for the surface. See "note" on surfaceModel. + var surface: ghostty_surface_t? { + surfaceModel?.unsafeCValue + } + // Notification identifiers associated with this surface var notificationIdentifiers: Set = [] - private(set) var surface: ghostty_surface_t? private var markedText: NSMutableAttributedString private(set) var focused: Bool = true private var prevPressureStage: Int = 0 @@ -282,10 +292,10 @@ extension Ghostty { let surface_cfg = baseConfig ?? SurfaceConfiguration() var surface_cfg_c = surface_cfg.ghosttyConfig(view: self) guard let surface = ghostty_surface_new(app, &surface_cfg_c) else { - self.error = AppError.surfaceCreateError + self.error = Ghostty.Error.apiFailed return } - self.surface = surface; + self.surfaceModel = Ghostty.Surface(cSurface: surface) // Setup our tracking area so we get mouse moved events updateTrackingAreas() @@ -340,11 +350,6 @@ extension Ghostty { // Remove any notifications associated with this surface let identifiers = Array(self.notificationIdentifiers) UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers) - - // Free our core surface resources - if let surface = self.surface { - ghostty_surface_free(surface) - } } func focusDidChange(_ focused: Bool) { diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index a61c75e96..01e287d16 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1837,12 +1837,10 @@ pub const CAPI = struct { return false; }; - _ = ptr.core_surface.performBindingAction(action) catch |err| { + return ptr.core_surface.performBindingAction(action) catch |err| { log.err("error performing binding action action={} err={}", .{ action, err }); return false; }; - - return true; } /// Complete a clipboard read request started via the read callback.