diff --git a/include/ghostty.h b/include/ghostty.h index 3302db522..ea62ca74b 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -30,6 +30,8 @@ typedef void (*ghostty_runtime_wakeup_cb)(void *); typedef void (*ghostty_runtime_set_title_cb)(void *, const char *); typedef const char* (*ghostty_runtime_read_clipboard_cb)(void *); typedef void (*ghostty_runtime_write_clipboard_cb)(void *, const char *); +typedef void (*ghostty_runtime_new_split_cb)(void *, ghostty_split_direction_e); +typedef void (*ghostty_runtime_close_surface_cb)(void *); typedef struct { void *userdata; @@ -37,6 +39,8 @@ typedef struct { ghostty_runtime_set_title_cb set_title_cb; ghostty_runtime_read_clipboard_cb read_clipboard_cb; ghostty_runtime_write_clipboard_cb write_clipboard_cb; + ghostty_runtime_new_split_cb new_split_cb; + ghostty_runtime_close_surface_cb close_surface_cb; } ghostty_runtime_config_s; typedef struct { @@ -45,6 +49,11 @@ typedef struct { double scale_factor; } ghostty_surface_config_s; +typedef enum { + GHOSTTY_SPLIT_RIGHT, + GHOSTTY_SPLIT_DOWN +} ghostty_split_direction_e; + typedef enum { GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_PRESS, @@ -243,6 +252,8 @@ void ghostty_surface_mouse_button(ghostty_surface_t, ghostty_input_mouse_state_e void ghostty_surface_mouse_pos(ghostty_surface_t, double, double); void ghostty_surface_mouse_scroll(ghostty_surface_t, double, double); void ghostty_surface_ime_point(ghostty_surface_t, double *, double *); +void ghostty_surface_request_close(ghostty_surface_t); +void ghostty_surface_split(ghostty_surface_t, ghostty_split_direction_e); #ifdef __cplusplus } diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 3ec37dec1..f97a91ae7 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -7,28 +7,34 @@ objects = { /* Begin PBXBuildFile section */ - A507573E299FF33C009D7DC7 /* TerminalSurfaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A507573D299FF33C009D7DC7 /* TerminalSurfaceView.swift */; }; - A518502429A197C700E4CC4F /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A518502329A197C700E4CC4F /* TerminalView.swift */; }; - A518502629A1A45100E4CC4F /* WindowTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A518502529A1A45100E4CC4F /* WindowTracker.swift */; }; A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A535B9D9299C569B0017E2E4 /* ErrorView.swift */; }; A55685E029A03A9F004303CE /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55685DF29A03A9F004303CE /* AppError.swift */; }; + A55B7BB629B6F47F0055DE60 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB529B6F47F0055DE60 /* AppState.swift */; }; + A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; }; + A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */; }; + A55B7BBE29B701360055DE60 /* Ghostty.SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BBD29B701360055DE60 /* Ghostty.SplitView.swift */; }; A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; }; A5B30535299BEAAA0047F10C /* GhosttyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B30534299BEAAA0047F10C /* GhosttyApp.swift */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; + A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDB29B8009000646FDA /* SplitView.swift */; }; + A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */; }; A5D495A2299BEC7E00DD1313 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - A507573D299FF33C009D7DC7 /* TerminalSurfaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSurfaceView.swift; sourceTree = ""; }; - A518502329A197C700E4CC4F /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = ""; }; - A518502529A1A45100E4CC4F /* WindowTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowTracker.swift; sourceTree = ""; }; A535B9D9299C569B0017E2E4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; A55685DF29A03A9F004303CE /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = ""; }; + A55B7BB529B6F47F0055DE60 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + A55B7BB729B6F53A0055DE60 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; + A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView.swift; sourceTree = ""; }; + A55B7BBD29B701360055DE60 /* Ghostty.SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.SplitView.swift; sourceTree = ""; }; A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; }; A5B30534299BEAAA0047F10C /* GhosttyApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyApp.swift; sourceTree = ""; }; A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; + A5CEAFDB29B8009000646FDA /* SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.swift; sourceTree = ""; }; + A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.Divider.swift; sourceTree = ""; }; A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = ""; }; /* End PBXFileReference section */ @@ -48,17 +54,27 @@ isa = PBXGroup; children = ( A5D495A0299BEC2200DD1313 /* Preview Content */, + A5CEAFDA29B8005900646FDA /* SplitView */, + A55B7BB429B6F4410055DE60 /* Ghostty */, A5B30534299BEAAA0047F10C /* GhosttyApp.swift */, A535B9D9299C569B0017E2E4 /* ErrorView.swift */, A55685DF29A03A9F004303CE /* AppError.swift */, A59444F629A2ED5200725BBA /* SettingsView.swift */, - A518502329A197C700E4CC4F /* TerminalView.swift */, - A507573D299FF33C009D7DC7 /* TerminalSurfaceView.swift */, - A518502529A1A45100E4CC4F /* WindowTracker.swift */, ); path = Sources; sourceTree = ""; }; + A55B7BB429B6F4410055DE60 /* Ghostty */ = { + isa = PBXGroup; + children = ( + A55B7BB729B6F53A0055DE60 /* Package.swift */, + A55B7BB529B6F47F0055DE60 /* AppState.swift */, + A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */, + A55B7BBD29B701360055DE60 /* Ghostty.SplitView.swift */, + ); + path = Ghostty; + sourceTree = ""; + }; A5B30528299BEAAA0047F10C = { isa = PBXGroup; children = ( @@ -78,6 +94,15 @@ name = Products; sourceTree = ""; }; + A5CEAFDA29B8005900646FDA /* SplitView */ = { + isa = PBXGroup; + children = ( + A5CEAFDB29B8009000646FDA /* SplitView.swift */, + A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */, + ); + path = SplitView; + sourceTree = ""; + }; A5D495A0299BEC2200DD1313 /* Preview Content */ = { isa = PBXGroup; children = ( @@ -162,13 +187,16 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, - A518502629A1A45100E4CC4F /* WindowTracker.swift in Sources */, - A518502429A197C700E4CC4F /* TerminalView.swift in Sources */, + A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */, + A55B7BBE29B701360055DE60 /* Ghostty.SplitView.swift in Sources */, + A55B7BB629B6F47F0055DE60 /* AppState.swift in Sources */, + A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */, A55685E029A03A9F004303CE /* AppError.swift in Sources */, - A507573E299FF33C009D7DC7 /* TerminalSurfaceView.swift in Sources */, A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */, A5B30535299BEAAA0047F10C /* GhosttyApp.swift in Sources */, + A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift new file mode 100644 index 000000000..59f68785b --- /dev/null +++ b/macos/Sources/Ghostty/AppState.swift @@ -0,0 +1,177 @@ +import SwiftUI +import GhosttyKit + +extension Ghostty { + enum AppReadiness { + case loading, error, ready + } + + /// The AppState is the global state that is associated with the Swift app. This handles initially + /// initializing Ghostty, loading the configuration, etc. + class AppState: ObservableObject { + /// The readiness value of the state. + @Published var readiness: AppReadiness = .loading + + /// The ghostty global configuration. + var config: ghostty_config_t? = nil + + /// The ghostty app instance. We only have one of these for the entire app, although I guess + /// in theory you can have multiple... I don't know why you would... + var app: ghostty_app_t? = nil + + /// Cached clipboard string for `read_clipboard` callback. + private var cached_clipboard_string: String? = nil + + init() { + // Initialize ghostty global state. This happens once per process. + guard ghostty_init() == GHOSTTY_SUCCESS else { + GhosttyApp.logger.critical("ghostty_init failed") + readiness = .error + return + } + + // Initialize the global configuration. + guard let cfg = ghostty_config_new() else { + GhosttyApp.logger.critical("ghostty_config_new failed") + readiness = .error + return + } + self.config = cfg; + + // Load our configuration files from the home directory. + ghostty_config_load_default_files(cfg); + ghostty_config_load_recursive_files(cfg); + + // TODO: we'd probably do some config loading here... for now we'd + // have to do this synchronously. When we support config updating we can do + // this async and update later. + + // Finalize will make our defaults available. + ghostty_config_finalize(cfg) + + // Create our "runtime" config. The "runtime" is the configuration that ghostty + // uses to interface with the application runtime environment. + var runtime_cfg = ghostty_runtime_config_s( + userdata: Unmanaged.passUnretained(self).toOpaque(), + wakeup_cb: { userdata in AppState.wakeup(userdata) }, + set_title_cb: { userdata, title in AppState.setTitle(userdata, title: title) }, + read_clipboard_cb: { userdata in AppState.readClipboard(userdata) }, + write_clipboard_cb: { userdata, str in AppState.writeClipboard(userdata, string: str) }, + new_split_cb: { userdata, direction in AppState.newSplit(userdata, direction: ghostty_split_direction_e(UInt32(direction))) }, + close_surface_cb: { userdata in AppState.closeSurface(userdata) } + ) + + // Create the ghostty app. + guard let app = ghostty_app_new(&runtime_cfg, cfg) else { + GhosttyApp.logger.critical("ghostty_app_new failed") + readiness = .error + return + } + self.app = app + + self.readiness = .ready + } + + deinit { + ghostty_app_free(app) + ghostty_config_free(config) + } + + func appTick() { + guard let app = self.app else { return } + ghostty_app_tick(app) + } + + /// Request that the given surface is closed. This will trigger the full normal surface close event + /// cycle which will call our close surface callback. + func requestClose(surface: ghostty_surface_t) { + ghostty_surface_request_close(surface) + } + + func split(surface: ghostty_surface_t, direction: ghostty_split_direction_e) { + ghostty_surface_split(surface, direction) + } + + // MARK: Ghostty Callbacks + + static func newSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_direction_e) { + guard let surface = self.surfaceUserdata(from: userdata) else { return } + NotificationCenter.default.post(name: Notification.ghosttyNewSplit, object: surface, userInfo: [ + "direction": direction, + ]) + } + + static func closeSurface(_ userdata: UnsafeMutableRawPointer?) { + guard let surface = self.surfaceUserdata(from: userdata) else { return } + NotificationCenter.default.post(name: Notification.ghosttyCloseSurface, object: surface) + } + + static func readClipboard(_ userdata: UnsafeMutableRawPointer?) -> UnsafePointer? { + guard let appState = self.appState(fromSurface: userdata) else { return nil } + guard let str = NSPasteboard.general.string(forType: .string) else { return nil } + + // Ghostty requires we cache the string because the pointer we return has to remain + // stable until the next call to readClipboard. + appState.cached_clipboard_string = str + return (str as NSString).utf8String + } + + static func writeClipboard(_ userdata: UnsafeMutableRawPointer?, string: UnsafePointer?) { + guard let valueStr = String(cString: string!, encoding: .utf8) else { return } + let pb = NSPasteboard.general + pb.declareTypes([.string], owner: nil) + pb.setString(valueStr, forType: .string) + } + + static func wakeup(_ userdata: UnsafeMutableRawPointer?) { + let state = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() + + // Wakeup can be called from any thread so we schedule the app tick + // from the main thread. There is probably some improvements we can make + // to coalesce multiple ticks but I don't think it matters from a performance + // standpoint since we don't do this much. + DispatchQueue.main.async { state.appTick() } + } + + static func setTitle(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer?) { + let surfaceView = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() + guard let titleStr = String(cString: title!, encoding: .utf8) else { return } + DispatchQueue.main.async { + surfaceView.title = titleStr + } + } + + /// Returns the GhosttyState from the given userdata value. + static private func appState(fromSurface userdata: UnsafeMutableRawPointer?) -> AppState? { + let surfaceView = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() + guard let surface = surfaceView.surface else { return nil } + guard let app = ghostty_surface_app(surface) else { return nil } + guard let app_ud = ghostty_app_userdata(app) else { return nil } + return Unmanaged.fromOpaque(app_ud).takeUnretainedValue() + } + + /// Returns the surface view from the userdata. + static private func surfaceUserdata(from userdata: UnsafeMutableRawPointer?) -> SurfaceView? { + return Unmanaged.fromOpaque(userdata!).takeUnretainedValue() + } + } +} + +// MARK: AppState Environment Keys + +private struct GhosttyAppKey: EnvironmentKey { + static let defaultValue: ghostty_app_t? = nil +} + +extension EnvironmentValues { + var ghosttyApp: ghostty_app_t? { + get { self[GhosttyAppKey.self] } + set { self[GhosttyAppKey.self] = newValue } + } +} + +extension View { + func ghosttyApp(_ app: ghostty_app_t?) -> some View { + environment(\.ghosttyApp, app) + } +} diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift new file mode 100644 index 000000000..8dbbfd9bc --- /dev/null +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -0,0 +1,245 @@ +import SwiftUI +import GhosttyKit + +extension Ghostty { + /// A spittable terminal view is one where the terminal allows for "splits" (vertical and horizontal) within the + /// view. The terminal starts in the unsplit state (a plain ol' TerminalView) but responds to changes to the + /// split direction by splitting the terminal. + struct TerminalSplit: View { + @Environment(\.ghosttyApp) private var app + let onClose: (() -> Void)? + + var body: some View { + if let app = app { + TerminalSplitRoot(app: app, onClose: onClose) + } + } + } + + /// This enum represents the possible states that a node in the split tree can be in. It is either: + /// + /// - noSplit - This is an unsplit, single pane. This contains only a "leaf" which has a single + /// terminal surface to render. + /// - horizontal/vertical - This is split into the horizontal or vertical direction. This contains a + /// "container" which has a recursive top/left SplitNode and bottom/right SplitNode. These + /// values can further be split infinitely. + /// + enum SplitNode { + case noSplit(Leaf) + case horizontal(Container) + case vertical(Container) + + /// Returns the view that would prefer receiving focus in this tree. This is always the + /// top-left-most view. This is used when creating a split or closing a split to find the + /// next view to send focus to. + func preferredFocus() -> SurfaceView { + switch (self) { + case .noSplit(let leaf): + return leaf.surface + + case .horizontal(let container): + return container.topLeft.preferredFocus() + + case .vertical(let container): + return container.topLeft.preferredFocus() + } + } + + class Leaf: ObservableObject { + let app: ghostty_app_t + @Published var surface: SurfaceView + + /// Initialize a new leaf which creates a new terminal surface. + init(_ app: ghostty_app_t) { + self.app = app + self.surface = SurfaceView(app) + } + } + + class Container: ObservableObject { + let app: ghostty_app_t + @Published var topLeft: SplitNode + @Published var bottomRight: SplitNode + + /// A container is always initialized from some prior leaf because a split has to originate + /// from a non-split value. When initializing, we inherit the leaf's surface and then + /// initialize a new surface for the new pane. + init(from: Leaf) { + self.app = from.app + + // Initially, both topLeft and bottomRight are in the "nosplit" + // state since this is a new split. + self.topLeft = .noSplit(from) + self.bottomRight = .noSplit(.init(app)) + } + } + } + + /// The root of a split tree. This sets up the initial SplitNode state and renders. There is only ever + /// one of these in a split tree. + private struct TerminalSplitRoot: View { + @State private var node: SplitNode + @State private var requestClose: Bool = false + let onClose: (() -> Void)? + + @FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle: String? + + init(app: ghostty_app_t, onClose: (() ->Void)? = nil) { + self.onClose = onClose + _node = State(wrappedValue: SplitNode.noSplit(.init(app))) + } + + var body: some View { + ZStack { + switch (node) { + case .noSplit(let leaf): + TerminalSplitLeaf(leaf: leaf, node: $node, requestClose: $requestClose) + .onChange(of: requestClose) { value in + guard value else { return } + guard let onClose = self.onClose else { return } + onClose() + } + + case .horizontal(let container): + TerminalSplitContainer(direction: .horizontal, node: $node, container: container) + + case .vertical(let container): + TerminalSplitContainer(direction: .vertical, node: $node, container: container) + } + } + .navigationTitle(surfaceTitle ?? "Ghostty") + } + } + + /// A noSplit leaf node of a split tree. + private struct TerminalSplitLeaf: View { + /// The leaf to draw the surface for. + let leaf: SplitNode.Leaf + + /// The SplitNode that the leaf belongs to. + @Binding var node: SplitNode + + /// This will be set to true when the split requests that is become closed. + @Binding var requestClose: Bool + + var body: some View { + let pub = NotificationCenter.default.publisher(for: Notification.ghosttyNewSplit, object: leaf.surface) + let pubClose = NotificationCenter.default.publisher(for: Notification.ghosttyCloseSurface, object: leaf.surface) + SurfaceWrapper(surfaceView: leaf.surface) + .onReceive(pub) { onNewSplit(notification: $0) } + .onReceive(pubClose) { _ in requestClose = true } + } + + private func onNewSplit(notification: SwiftUI.Notification) { + // Determine our desired direction + guard let directionAny = notification.userInfo?["direction"] else { return } + guard let direction = directionAny as? ghostty_split_direction_e else { return } + var splitDirection: SplitViewDirection + switch (direction) { + case GHOSTTY_SPLIT_RIGHT: + splitDirection = .horizontal + + case GHOSTTY_SPLIT_DOWN: + splitDirection = .vertical + + default: + return + } + + // Setup our new container since we are now split + let container = SplitNode.Container(from: leaf) + + // Depending on the direction, change the parent node. This will trigger + // the parent to relayout our views. + switch (splitDirection) { + case .horizontal: + node = .horizontal(container) + case .vertical: + node = .vertical(container) + } + + // See fixFocus comment, we have to run this whenever split changes. + Self.fixFocus(container.bottomRight, previous: node) + } + + /// There is a bug I can't figure out where when changing the split state, the terminal view + /// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't + /// figure it out so we're going to do this hacky thing to bring focus back to the terminal + /// that should have it. + fileprivate static func fixFocus(_ target: SplitNode, previous: SplitNode) { + let view = target.preferredFocus() + + DispatchQueue.main.async { + // If the callback runs before the surface is attached to a view + // then the window will be nil. We just reschedule in that case. + guard let window = view.window else { + self.fixFocus(target, previous: previous) + return + } + + window.makeFirstResponder(view) + + // If we had a previously focused node and its not where we're sending + // focus, make sure that we explicitly tell it to lose focus. In theory + // we should NOT have to do this but the focus callback isn't getting + // called for some reason. + let previous = previous.preferredFocus() + if previous != view { + _ = previous.resignFirstResponder() + } + } + } + } + + /// This represents a split view that is in the horizontal or vertical split state. + private struct TerminalSplitContainer: View { + let direction: SplitViewDirection + @Binding var node: SplitNode + @StateObject var container: SplitNode.Container + + @State private var closeTopLeft: Bool = false + @State private var closeBottomRight: Bool = false + + var body: some View { + SplitView(direction, left: { + TerminalSplitNested(node: $container.topLeft, requestClose: $closeTopLeft) + .onChange(of: closeTopLeft) { value in + guard value else { return } + + // When closing the topLeft, our parent becomes the bottomRight. + node = container.bottomRight + TerminalSplitLeaf.fixFocus(node, previous: container.topLeft) + } + }, right: { + TerminalSplitNested(node: $container.bottomRight, requestClose: $closeBottomRight) + .onChange(of: closeBottomRight) { value in + guard value else { return } + + // When closing the bottomRight, our parent becomes the topLeft. + node = container.topLeft + TerminalSplitLeaf.fixFocus(node, previous: container.bottomRight) + } + }) + } + } + + /// This is like TerminalSplitRoot, but... not the root. This renders a SplitNode in any state but + /// requires there be a binding to the parent node. + private struct TerminalSplitNested: View { + @Binding var node: SplitNode + @Binding var requestClose: Bool + + var body: some View { + switch (node) { + case .noSplit(let leaf): + TerminalSplitLeaf(leaf: leaf, node: $node, requestClose: $requestClose) + + case .horizontal(let container): + TerminalSplitContainer(direction: .horizontal, node: $node, container: container) + + case .vertical(let container): + TerminalSplitContainer(direction: .vertical, node: $node, container: container) + } + } + } +} diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift new file mode 100644 index 000000000..b673549e5 --- /dev/null +++ b/macos/Sources/Ghostty/Package.swift @@ -0,0 +1,4 @@ +struct Ghostty { + // All the notifications that will be emitted will be put here. + struct Notification {} +} diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift new file mode 100644 index 000000000..2f76fce11 --- /dev/null +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -0,0 +1,568 @@ +import SwiftUI +import GhosttyKit + +extension Ghostty { + /// Render a terminal for the active app in the environment. + struct Terminal: View { + @Environment(\.ghosttyApp) private var app + @FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle: String? + + var body: some View { + if let app = self.app { + SurfaceForApp(app) { surfaceView in + SurfaceWrapper(surfaceView: surfaceView) + } + .navigationTitle(surfaceTitle ?? "Ghostty") + } + } + } + + /// Yields a SurfaceView for a ghostty app that can then be used however you want. + struct SurfaceForApp: View { + let content: ((SurfaceView) -> Content) + + @StateObject private var surfaceView: SurfaceView + + init(_ app: ghostty_app_t, @ViewBuilder content: @escaping ((SurfaceView) -> Content)) { + _surfaceView = StateObject(wrappedValue: SurfaceView(app)) + self.content = content + } + + var body: some View { + content(surfaceView) + } + } + + struct SurfaceWrapper: View { + // The surface to create a view for. This must be created upstream. As long as this + // remains the same, the surface that is being rendered remains the same. + @ObservedObject var surfaceView: SurfaceView + + @FocusState private var surfaceFocus: Bool + + // https://nilcoalescing.com/blog/DetectFocusedWindowOnMacOS/ + @Environment(\.controlActiveState) var controlActiveState + + // This is true if the terminal is considered "focused". The terminal is focused if + // it is both individually focused and the containing window is key. + private var hasFocus: Bool { surfaceFocus && controlActiveState == .key } + + var body: some View { + // 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 + // resize callback. + GeometryReader { geo in + Surface(view: surfaceView, hasFocus: hasFocus, size: geo.size) + .focused($surfaceFocus) + .focusedValue(\.ghosttySurfaceTitle, surfaceView.title) + .focusedValue(\.ghosttySurfaceView, surfaceView) + } + .ghosttySurfaceView(surfaceView) + } + } + + /// 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, + /// a split, a small preview pane, etc. It is ANYTHING that has a terminal drawn to it. + /// + /// 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: NSViewRepresentable { + /// The view to render for the terminal surface. + let view: SurfaceView + + /// This should be set to true wen the surface has focus. This is up to the parent because + /// focus is also defined by window focus. It is important this is set correctly since if it is + /// false then the surface will idle at almost 0% CPU. + let hasFocus: Bool + + /// The size of the frame containing this view. We use this to update the the underlying + /// surface. This does not actually SET the size of our frame, this only sets the size + /// of our Metal surface for drawing. + /// + /// Note: we do NOT use the NSView.resize function because SwiftUI on macOS 12 + /// does not call this callback (macOS 13+ does). + /// + /// The best approach is to wrap this view in a GeometryReader and pass in the geo.size. + let size: CGSize + + func makeNSView(context: Context) -> SurfaceView { + // We need the view as part of the state to be created previously because + // the view is sent to the Ghostty API so that it can manipulate it + // directly since we draw on a render thread. + return view; + } + + func updateNSView(_ view: SurfaceView, context: Context) { + view.focusDidChange(hasFocus) + view.sizeDidChange(size) + } + } + + /// The NSView implementation for a terminal surface. + class SurfaceView: NSView, NSTextInputClient, ObservableObject { + // The current title of the surface as defined by the pty. This can be + // changed with escape codes. This is public because the callbacks go + // to the app level and it is set from there. + @Published var title: String = "" + + private(set) var surface: ghostty_surface_t? + var error: Error? = nil + + private var markedText: NSMutableAttributedString; + + // We need to support being a first responder so that we can get input events + override var acceptsFirstResponder: Bool { return true } + + // I don't think we need this but this lets us know we should redraw our layer + // so we'll use that to tell ghostty to refresh. + override var wantsUpdateLayer: Bool { return true } + + init(_ app: ghostty_app_t) { + self.markedText = NSMutableAttributedString() + + // Initialize with some default frame size. The important thing is that this + // 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)) + + // Setup our surface. This will also initialize all the terminal IO. + var surface_cfg = ghostty_surface_config_s( + userdata: Unmanaged.passUnretained(self).toOpaque(), + nsview: Unmanaged.passUnretained(self).toOpaque(), + scale_factor: NSScreen.main!.backingScaleFactor) + guard let surface = ghostty_surface_new(app, &surface_cfg) else { + self.error = AppError.surfaceCreateError + return + } + self.surface = surface; + + // Setup our tracking area so we get mouse moved events + updateTrackingAreas() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported for this view") + } + + deinit { + trackingAreas.forEach { removeTrackingArea($0) } + + guard let surface = self.surface else { return } + ghostty_surface_free(surface) + } + + func focusDidChange(_ focused: Bool) { + guard let surface = self.surface else { return } + ghostty_surface_set_focus(surface, focused) + } + + func sizeDidChange(_ size: CGSize) { + guard let surface = self.surface else { return } + + // Ghostty wants to know the actual framebuffer size... It is very important + // here that we use "size" and NOT the view frame. If we're in the middle of + // an animation (i.e. a fullscreen animation), the frame will not yet be updated. + // The size represents our final size we're going for. + let scaledSize = self.convertToBacking(size) + ghostty_surface_set_size(surface, UInt32(scaledSize.width), UInt32(scaledSize.height)) + } + + override func resignFirstResponder() -> Bool { + let result = super.resignFirstResponder() + + // We sometimes call this manually (see SplitView) as a way to force us to + // yield our focus state. + if (result) { focusDidChange(false) } + + return result + } + + override func updateTrackingAreas() { + // To update our tracking area we just recreate it all. + trackingAreas.forEach { removeTrackingArea($0) } + + // This tracking area is across the entire frame to notify us of mouse movements. + addTrackingArea(NSTrackingArea( + rect: frame, + options: [ + .mouseEnteredAndExited, + .mouseMoved, + .inVisibleRect, + + // It is possible this is incorrect when we have splits. This will make + // mouse events only happen while the terminal is focused. Is that what + // we want? + .activeWhenFirstResponder, + ], + owner: self, + userInfo: nil)) + } + + override func resetCursorRects() { + discardCursorRects() + addCursorRect(frame, cursor: .iBeam) + } + + override func viewDidChangeBackingProperties() { + guard let surface = self.surface else { return } + + // Detect our X/Y scale factor so we can update our surface + let fbFrame = self.convertToBacking(self.frame) + let xScale = fbFrame.size.width / self.frame.size.width + let yScale = fbFrame.size.height / self.frame.size.height + ghostty_surface_set_content_scale(surface, xScale, yScale) + + // When our scale factor changes, so does our fb size so we send that too + ghostty_surface_set_size(surface, UInt32(fbFrame.size.width), UInt32(fbFrame.size.height)) + } + + override func updateLayer() { + guard let surface = self.surface else { return } + ghostty_surface_refresh(surface); + } + + override func mouseDown(with event: NSEvent) { + guard let surface = self.surface else { return } + let mods = Self.translateFlags(event.modifierFlags) + ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) + } + + override func mouseUp(with event: NSEvent) { + guard let surface = self.surface else { return } + let mods = Self.translateFlags(event.modifierFlags) + ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) + } + + override func rightMouseDown(with event: NSEvent) { + guard let surface = self.surface else { return } + let mods = Self.translateFlags(event.modifierFlags) + ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mods) + } + + override func rightMouseUp(with event: NSEvent) { + guard let surface = self.surface else { return } + let mods = Self.translateFlags(event.modifierFlags) + ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, mods) + } + + override func mouseMoved(with event: NSEvent) { + guard let surface = self.surface else { return } + + // Convert window position to view position. Note (0, 0) is bottom left. + let pos = self.convert(event.locationInWindow, from: nil) + ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y) + + } + + override func mouseDragged(with event: NSEvent) { + self.mouseMoved(with: event) + } + + override func scrollWheel(with event: NSEvent) { + guard let surface = self.surface else { return } + + var x = event.scrollingDeltaX + var y = event.scrollingDeltaY + if event.hasPreciseScrollingDeltas { + x *= 0.1 + y *= 0.1 + } + + ghostty_surface_mouse_scroll(surface, x, y) + } + + override func keyDown(with event: NSEvent) { + guard let surface = self.surface else { return } + let key = Self.keycodes[event.keyCode] ?? GHOSTTY_KEY_INVALID + let mods = Self.translateFlags(event.modifierFlags) + let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS + ghostty_surface_key(surface, action, key, mods) + + self.interpretKeyEvents([event]) + } + + override func keyUp(with event: NSEvent) { + guard let surface = self.surface else { return } + let key = Self.keycodes[event.keyCode] ?? GHOSTTY_KEY_INVALID + let mods = Self.translateFlags(event.modifierFlags) + ghostty_surface_key(surface, GHOSTTY_ACTION_RELEASE, key, mods) + } + + // MARK: NSTextInputClient + + func hasMarkedText() -> Bool { + return markedText.length > 0 + } + + func markedRange() -> NSRange { + guard markedText.length > 0 else { return NSRange() } + return NSRange(0...(markedText.length-1)) + } + + func selectedRange() -> NSRange { + return NSRange() + } + + func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { + switch string { + case let v as NSAttributedString: + self.markedText = NSMutableAttributedString(attributedString: v) + + case let v as String: + self.markedText = NSMutableAttributedString(string: v) + + default: + print("unknown marked text: \(string)") + } + } + + func unmarkText() { + self.markedText.mutableString.setString("") + } + + func validAttributesForMarkedText() -> [NSAttributedString.Key] { + return [] + } + + func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { + return nil + } + + func characterIndex(for point: NSPoint) -> Int { + return 0 + } + + func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { + guard let surface = self.surface else { + return NSMakeRect(frame.origin.x, frame.origin.y, 0, 0) + } + + // Ghostty will tell us where it thinks an IME keyboard should render. + var x: Double = 0; + var y: Double = 0; + ghostty_surface_ime_point(surface, &x, &y) + + // Ghostty coordinates are in top-left (0, 0) so we have to convert to + // bottom-left since that is what UIKit expects + let rect = NSMakeRect(x, frame.size.height - y, 0, 0) + + // Convert from view to screen coordinates + guard let window = self.window else { return rect } + return window.convertToScreen(rect) + } + + func insertText(_ string: Any, replacementRange: NSRange) { + // We must have an associated event + guard NSApp.currentEvent != nil else { return } + guard let surface = self.surface else { return } + + // We want the string view of the any value + var chars = "" + switch (string) { + case let v as NSAttributedString: + chars = v.string + case let v as String: + chars = v + default: + return + } + + for codepoint in chars.unicodeScalars { + ghostty_surface_char(surface, codepoint.value) + } + } + + override func doCommand(by selector: Selector) { + // This currently just prevents NSBeep from interpretKeyEvents but in the future + // we may want to make some of this work. + + print("SEL: \(selector)") + } + + private static func translateFlags(_ flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e { + var mods: UInt32 = GHOSTTY_MODS_NONE.rawValue + if (flags.contains(.shift)) { mods |= GHOSTTY_MODS_SHIFT.rawValue } + if (flags.contains(.control)) { mods |= GHOSTTY_MODS_CTRL.rawValue } + if (flags.contains(.option)) { mods |= GHOSTTY_MODS_ALT.rawValue } + if (flags.contains(.command)) { mods |= GHOSTTY_MODS_SUPER.rawValue } + if (flags.contains(.capsLock)) { mods |= GHOSTTY_MODS_CAPS.rawValue } + + return ghostty_input_mods_e(mods) + } + + // Mapping of event keyCode to ghostty input key values. This is cribbed from + // glfw mostly since we started as a glfw-based app way back in the day! + static let keycodes: [UInt16 : ghostty_input_key_e] = [ + 0x1D: GHOSTTY_KEY_ZERO, + 0x12: GHOSTTY_KEY_ONE, + 0x13: GHOSTTY_KEY_TWO, + 0x14: GHOSTTY_KEY_THREE, + 0x15: GHOSTTY_KEY_FOUR, + 0x17: GHOSTTY_KEY_FIVE, + 0x16: GHOSTTY_KEY_SIX, + 0x1A: GHOSTTY_KEY_SEVEN, + 0x1C: GHOSTTY_KEY_EIGHT, + 0x19: GHOSTTY_KEY_NINE, + 0x00: GHOSTTY_KEY_A, + 0x0B: GHOSTTY_KEY_B, + 0x08: GHOSTTY_KEY_C, + 0x02: GHOSTTY_KEY_D, + 0x0E: GHOSTTY_KEY_E, + 0x03: GHOSTTY_KEY_F, + 0x05: GHOSTTY_KEY_G, + 0x04: GHOSTTY_KEY_H, + 0x22: GHOSTTY_KEY_I, + 0x26: GHOSTTY_KEY_J, + 0x28: GHOSTTY_KEY_K, + 0x25: GHOSTTY_KEY_L, + 0x2E: GHOSTTY_KEY_M, + 0x2D: GHOSTTY_KEY_N, + 0x1F: GHOSTTY_KEY_O, + 0x23: GHOSTTY_KEY_P, + 0x0C: GHOSTTY_KEY_Q, + 0x0F: GHOSTTY_KEY_R, + 0x01: GHOSTTY_KEY_S, + 0x11: GHOSTTY_KEY_T, + 0x20: GHOSTTY_KEY_U, + 0x09: GHOSTTY_KEY_V, + 0x0D: GHOSTTY_KEY_W, + 0x07: GHOSTTY_KEY_X, + 0x10: GHOSTTY_KEY_Y, + 0x06: GHOSTTY_KEY_Z, + + 0x27: GHOSTTY_KEY_APOSTROPHE, + 0x2A: GHOSTTY_KEY_BACKSLASH, + 0x2B: GHOSTTY_KEY_COMMA, + 0x18: GHOSTTY_KEY_EQUAL, + 0x32: GHOSTTY_KEY_GRAVE_ACCENT, + 0x21: GHOSTTY_KEY_LEFT_BRACKET, + 0x1B: GHOSTTY_KEY_MINUS, + 0x2F: GHOSTTY_KEY_PERIOD, + 0x1E: GHOSTTY_KEY_RIGHT_BRACKET, + 0x29: GHOSTTY_KEY_SEMICOLON, + 0x2C: GHOSTTY_KEY_SLASH, + + 0x33: GHOSTTY_KEY_BACKSPACE, + 0x39: GHOSTTY_KEY_CAPS_LOCK, + 0x75: GHOSTTY_KEY_DELETE, + 0x7D: GHOSTTY_KEY_DOWN, + 0x77: GHOSTTY_KEY_END, + 0x24: GHOSTTY_KEY_ENTER, + 0x35: GHOSTTY_KEY_ESCAPE, + 0x7A: GHOSTTY_KEY_F1, + 0x78: GHOSTTY_KEY_F2, + 0x63: GHOSTTY_KEY_F3, + 0x76: GHOSTTY_KEY_F4, + 0x60: GHOSTTY_KEY_F5, + 0x61: GHOSTTY_KEY_F6, + 0x62: GHOSTTY_KEY_F7, + 0x64: GHOSTTY_KEY_F8, + 0x65: GHOSTTY_KEY_F9, + 0x6D: GHOSTTY_KEY_F10, + 0x67: GHOSTTY_KEY_F11, + 0x6F: GHOSTTY_KEY_F12, + 0x69: GHOSTTY_KEY_PRINT_SCREEN, + 0x6B: GHOSTTY_KEY_F14, + 0x71: GHOSTTY_KEY_F15, + 0x6A: GHOSTTY_KEY_F16, + 0x40: GHOSTTY_KEY_F17, + 0x4F: GHOSTTY_KEY_F18, + 0x50: GHOSTTY_KEY_F19, + 0x5A: GHOSTTY_KEY_F20, + 0x73: GHOSTTY_KEY_HOME, + 0x72: GHOSTTY_KEY_INSERT, + 0x7B: GHOSTTY_KEY_LEFT, + 0x3A: GHOSTTY_KEY_LEFT_ALT, + 0x3B: GHOSTTY_KEY_LEFT_CONTROL, + 0x38: GHOSTTY_KEY_LEFT_SHIFT, + 0x37: GHOSTTY_KEY_LEFT_SUPER, + 0x47: GHOSTTY_KEY_NUM_LOCK, + 0x79: GHOSTTY_KEY_PAGE_DOWN, + 0x74: GHOSTTY_KEY_PAGE_UP, + 0x7C: GHOSTTY_KEY_RIGHT, + 0x3D: GHOSTTY_KEY_RIGHT_ALT, + 0x3E: GHOSTTY_KEY_RIGHT_CONTROL, + 0x3C: GHOSTTY_KEY_RIGHT_SHIFT, + 0x36: GHOSTTY_KEY_RIGHT_SUPER, + 0x31: GHOSTTY_KEY_SPACE, + 0x30: GHOSTTY_KEY_TAB, + 0x7E: GHOSTTY_KEY_UP, + + 0x52: GHOSTTY_KEY_KP_0, + 0x53: GHOSTTY_KEY_KP_1, + 0x54: GHOSTTY_KEY_KP_2, + 0x55: GHOSTTY_KEY_KP_3, + 0x56: GHOSTTY_KEY_KP_4, + 0x57: GHOSTTY_KEY_KP_5, + 0x58: GHOSTTY_KEY_KP_6, + 0x59: GHOSTTY_KEY_KP_7, + 0x5B: GHOSTTY_KEY_KP_8, + 0x5C: GHOSTTY_KEY_KP_9, + 0x45: GHOSTTY_KEY_KP_ADD, + 0x41: GHOSTTY_KEY_KP_DECIMAL, + 0x4B: GHOSTTY_KEY_KP_DIVIDE, + 0x4C: GHOSTTY_KEY_KP_ENTER, + 0x51: GHOSTTY_KEY_KP_EQUAL, + 0x43: GHOSTTY_KEY_KP_MULTIPLY, + 0x4E: GHOSTTY_KEY_KP_SUBTRACT, + ]; + } + +} + +// MARK: Surface Notifications + +extension Ghostty.Notification { + /// Posted when a new split is requested. The sending object will be the surface that had focus. The + /// userdata has one key "direction" with the direction to split to. + static let ghosttyNewSplit = Notification.Name("com.mitchellh.ghostty.newSplit") + + /// Close the calling surface. + static let ghosttyCloseSurface = Notification.Name("com.mitchellh.ghostty.closeSurface") +} + +// MARK: Surface Environment Keys + +private struct GhosttySurfaceViewKey: EnvironmentKey { + static let defaultValue: Ghostty.SurfaceView? = nil +} + +extension EnvironmentValues { + var ghosttySurfaceView: Ghostty.SurfaceView? { + get { self[GhosttySurfaceViewKey.self] } + set { self[GhosttySurfaceViewKey.self] = newValue } + } +} + +extension View { + func ghosttySurfaceView(_ surfaceView: Ghostty.SurfaceView?) -> some View { + environment(\.ghosttySurfaceView, surfaceView) + } +} + +// MARK: Surface Focus Keys + +extension FocusedValues { + var ghosttySurfaceView: Ghostty.SurfaceView? { + get { self[FocusedGhosttySurface.self] } + set { self[FocusedGhosttySurface.self] = newValue } + } + + struct FocusedGhosttySurface: FocusedValueKey { + typealias Value = Ghostty.SurfaceView + } +} + +extension FocusedValues { + var ghosttySurfaceTitle: String? { + get { self[FocusedGhosttySurfaceTitle.self] } + set { self[FocusedGhosttySurfaceTitle.self] = newValue } + } + + struct FocusedGhosttySurfaceTitle: FocusedValueKey { + typealias Value = String + } +} + diff --git a/macos/Sources/GhosttyApp.swift b/macos/Sources/GhosttyApp.swift index ecb6c9906..4a14e6fe6 100644 --- a/macos/Sources/GhosttyApp.swift +++ b/macos/Sources/GhosttyApp.swift @@ -10,8 +10,11 @@ struct GhosttyApp: App { ) /// The ghostty global state. Only one per process. - @StateObject private var ghostty = GhosttyState() - @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate; + @StateObject private var ghostty = Ghostty.AppState() + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + + /// The current focused Ghostty surface in this app + @FocusedValue(\.ghosttySurfaceView) private var focusedSurface var body: some Scene { WindowGroup { @@ -21,13 +24,19 @@ struct GhosttyApp: App { case .error: ErrorView() case .ready: - TerminalView(app: ghostty.app!) - .modifier(WindowObservationModifier()) + Ghostty.TerminalSplit(onClose: Self.closeWindow) + .ghosttyApp(ghostty.app!) } }.commands { CommandGroup(after: .newItem) { - Button("New Tab", action: newTab).keyboardShortcut("t", modifiers: [.command]) - } + Button("New Tab", action: Self.newTab).keyboardShortcut("t", modifiers: [.command]) + Divider() + Button("Split Horizontally", action: splitHorizontally).keyboardShortcut("d", modifiers: [.command]) + Button("Split Vertically", action: splitVertically).keyboardShortcut("d", modifiers: [.command, .shift]) + Divider() + Button("Close", action: close).keyboardShortcut("w", modifiers: [.command]) + Button("Close Window", action: Self.closeWindow).keyboardShortcut("w", modifiers: [.command, .shift]) + } } Settings { @@ -36,7 +45,7 @@ struct GhosttyApp: App { } // Create a new tab in the currently active window - func newTab() { + static func newTab() { guard let currentWindow = NSApp.keyWindow else { return } guard let windowController = currentWindow.windowController else { return } windowController.newWindowForTab(nil) @@ -44,135 +53,84 @@ struct GhosttyApp: App { currentWindow.addTabbedWindow(newWindow, ordered: .above) } } + + static func closeWindow() { + guard let currentWindow = NSApp.keyWindow else { return } + currentWindow.close() + } + + func close() { + guard let surfaceView = focusedSurface else { return } + guard let surface = surfaceView.surface else { return } + ghostty.requestClose(surface: surface) + } + + func splitHorizontally() { + guard let surfaceView = focusedSurface else { return } + guard let surface = surfaceView.surface else { return } + ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_RIGHT) + } + + func splitVertically() { + guard let surfaceView = focusedSurface else { return } + guard let surface = surfaceView.surface else { return } + ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DOWN) + } } class AppDelegate: NSObject, NSApplicationDelegate { + // See CursedMenuManager for more information. + private var menuManager: CursedMenuManager? + func applicationDidFinishLaunching(_ notification: Notification) { UserDefaults.standard.register(defaults: [ // Disable this so that repeated key events make it through to our terminal views. "ApplePressAndHoldEnabled": false, ]) + + // Create our menu manager to create some custom menu items that + // we can't create from SwiftUI. + menuManager = CursedMenuManager() } } -class GhosttyState: ObservableObject { - enum Readiness { - case loading, error, ready - } +/// SwiftUI as of macOS 13.x provides no way to manage the default menu items that are created +/// as part of a WindowGroup. This class is prefixed with "Cursed" because this is a truly cursed +/// solution to the problem and I think its quite brittle. As soon as SwiftUI supports a better option +/// we should conditionally compile for that when supported. +/// +/// The way this works is by setting up KVO on various menu objects and reacting to it. For example, +/// when SwiftUI tries to add a "Close" menu, we intercept it and delete it. Nice try! +private class CursedMenuManager { + var mainToken: NSKeyValueObservation? + var fileToken: NSKeyValueObservation? - /// The readiness value of the state. - @Published var readiness: Readiness = .loading - - /// The ghostty global configuration. - var config: ghostty_config_t? = nil - - /// The ghostty app instance. We only have one of these for the entire app, although I guess - /// in theory you can have multiple... I don't know why you would... - var app: ghostty_app_t? = nil - - /// Cached clipboard string for `read_clipboard` callback. - private var cached_clipboard_string: String? = nil - init() { - // Initialize ghostty global state. This happens once per process. - guard ghostty_init() == GHOSTTY_SUCCESS else { - GhosttyApp.logger.critical("ghostty_init failed") - readiness = .error - return + // If the whole menu changed we want to setup our new KVO + self.mainToken = NSApp.observe(\.mainMenu, options: .new) { app, change in + self.onNewMenu() } - // Initialize the global configuration. - guard let cfg = ghostty_config_new() else { - GhosttyApp.logger.critical("ghostty_config_new failed") - readiness = .error - return - } - self.config = cfg; - - // Load our configuration files from the home directory. - ghostty_config_load_default_files(cfg); - ghostty_config_load_recursive_files(cfg); - - // TODO: we'd probably do some config loading here... for now we'd - // have to do this synchronously. When we support config updating we can do - // this async and update later. - - // Finalize will make our defaults available. - ghostty_config_finalize(cfg) - - // Create our "runtime" config. The "runtime" is the configuration that ghostty - // uses to interface with the application runtime environment. - var runtime_cfg = ghostty_runtime_config_s( - userdata: Unmanaged.passUnretained(self).toOpaque(), - wakeup_cb: { userdata in GhosttyState.wakeup(userdata) }, - set_title_cb: { userdata, title in GhosttyState.setTitle(userdata, title: title) }, - read_clipboard_cb: { userdata in GhosttyState.readClipboard(userdata) }, - write_clipboard_cb: { userdata, str in GhosttyState.writeClipboard(userdata, string: str) }) - - // Create the ghostty app. - guard let app = ghostty_app_new(&runtime_cfg, cfg) else { - GhosttyApp.logger.critical("ghostty_app_new failed") - readiness = .error - return - } - self.app = app - - self.readiness = .ready + // Initial setup + onNewMenu() } - deinit { - ghostty_app_free(app) - ghostty_config_free(config) - } - - func appTick() { - guard let app = self.app else { return } - ghostty_app_tick(app) - } - - // MARK: Ghostty Callbacks - - static func readClipboard(_ userdata: UnsafeMutableRawPointer?) -> UnsafePointer? { - guard let appState = self.appState(fromSurface: userdata) else { return nil } - guard let str = NSPasteboard.general.string(forType: .string) else { return nil } - - // Ghostty requires we cache the string because the pointer we return has to remain - // stable until the next call to readClipboard. - appState.cached_clipboard_string = str - return (str as NSString).utf8String - } - - static func writeClipboard(_ userdata: UnsafeMutableRawPointer?, string: UnsafePointer?) { - guard let valueStr = String(cString: string!, encoding: .utf8) else { return } - let pb = NSPasteboard.general - pb.declareTypes([.string], owner: nil) - pb.setString(valueStr, forType: .string) - } - - static func wakeup(_ userdata: UnsafeMutableRawPointer?) { - let state = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() - - // Wakeup can be called from any thread so we schedule the app tick - // from the main thread. There is probably some improvements we can make - // to coalesce multiple ticks but I don't think it matters from a performance - // standpoint since we don't do this much. - DispatchQueue.main.async { state.appTick() } - } - - static func setTitle(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer?) { - let surfaceView = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() - guard let titleStr = String(cString: title!, encoding: .utf8) else { return } - DispatchQueue.main.async { - surfaceView.title = titleStr - } - } - - /// Returns the GhosttyState from the given userdata value. - static func appState(fromSurface userdata: UnsafeMutableRawPointer?) -> GhosttyState? { - let surfaceView = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() - guard let surface = surfaceView.surface else { return nil } - guard let app = ghostty_surface_app(surface) else { return nil } - guard let app_ud = ghostty_app_userdata(app) else { return nil } - return Unmanaged.fromOpaque(app_ud).takeUnretainedValue() + private func onNewMenu() { + guard let menu = NSApp.mainMenu else { return } + guard let file = menu.item(withTitle: "File") else { return } + guard let submenu = file.submenu else { return } + fileToken = submenu.observe(\.items) { (_, _) in + let remove = ["Close", "Close All"] + + // We look for the items in reverse since we're removing only the + // ones SwiftUI inserts which are at the end. We make replacements + // which we DON'T want deleted. + let items = submenu.items.reversed() + remove.forEach { title in + if let item = items.first(where: { $0.title.caseInsensitiveCompare(title) == .orderedSame }) { + submenu.removeItem(item) + } + } + } } } diff --git a/macos/Sources/SplitView/SplitView.Divider.swift b/macos/Sources/SplitView/SplitView.Divider.swift new file mode 100644 index 000000000..aba1c48f4 --- /dev/null +++ b/macos/Sources/SplitView/SplitView.Divider.swift @@ -0,0 +1,68 @@ +import SwiftUI + +extension SplitView { + /// The split divider that is rendered and can be used to resize a split view. + struct Divider: View { + let direction: SplitViewDirection + let visibleSize: CGFloat + let invisibleSize: CGFloat + + private var visibleWidth: CGFloat? { + switch (direction) { + case .horizontal: + return visibleSize + case .vertical: + return nil + } + } + + private var visibleHeight: CGFloat? { + switch (direction) { + case .horizontal: + return nil + case .vertical: + return visibleSize + } + } + + private var invisibleWidth: CGFloat? { + switch (direction) { + case .horizontal: + return visibleSize + invisibleSize + case .vertical: + return nil + } + } + + private var invisibleHeight: CGFloat? { + switch (direction) { + case .horizontal: + return nil + case .vertical: + return visibleSize + invisibleSize + } + } + + var body: some View { + ZStack { + Color.clear + .frame(width: invisibleWidth, height: invisibleHeight) + Rectangle() + .fill(Color.gray) + .frame(width: visibleWidth, height: visibleHeight) + } + .onHover { isHovered in + if (isHovered) { + switch (direction) { + case .horizontal: + NSCursor.resizeLeftRight.push() + case .vertical: + NSCursor.resizeUpDown.push() + } + } else { + NSCursor.pop() + } + } + } + } +} diff --git a/macos/Sources/SplitView/SplitView.swift b/macos/Sources/SplitView/SplitView.swift new file mode 100644 index 000000000..c28b6b578 --- /dev/null +++ b/macos/Sources/SplitView/SplitView.swift @@ -0,0 +1,119 @@ +import SwiftUI + +/// A split view shows a left and right (or top and bottom) view with a divider in the middle to do resizing. +/// The terminlogy "left" and "right" is always used but for vertical splits "left" is "top" and "right" is "bottom". +/// +/// This view is purpose built for our use case and I imagine we'll continue to make it more configurable +/// as time goes on. For example, the splitter divider size and styling is all hardcoded. +struct SplitView: View { + /// Direction of the split + let direction: SplitViewDirection + + /// The left and right views to render. + let left: L + let right: R + + /// The current fractional width of the split view. 0.5 means L/R are equally sized, for example. + @State var split: CGFloat = 0.5 + + /// The visible size of the splitter, in points. The invisible size is a transparent hitbox that can still + /// be used for getting a resize handle. The total width/height of the splitter is the sum of both. + private let splitterVisibleSize: CGFloat = 1 + private let splitterInvisibleSize: CGFloat = 6 + + var body: some View { + GeometryReader { geo in + let leftRect = self.leftRect(for: geo.size) + let rightRect = self.rightRect(for: geo.size, leftRect: leftRect) + let splitterPoint = self.splitterPoint(for: geo.size, leftRect: leftRect) + + ZStack(alignment: .topLeading) { + left + .frame(width: leftRect.size.width, height: leftRect.size.height) + .offset(x: leftRect.origin.x, y: leftRect.origin.y) + right + .frame(width: rightRect.size.width, height: rightRect.size.height) + .offset(x: rightRect.origin.x, y: rightRect.origin.y) + Divider(direction: direction, visibleSize: splitterVisibleSize, invisibleSize: splitterInvisibleSize) + .position(splitterPoint) + .gesture(dragGesture(geo.size, splitterPoint: splitterPoint)) + } + } + } + + init(_ direction: SplitViewDirection, @ViewBuilder left: (() -> L), @ViewBuilder right: (() -> R)) { + self.direction = direction + self.left = left() + self.right = right() + } + + private func dragGesture(_ size: CGSize, splitterPoint: CGPoint) -> some Gesture { + return DragGesture() + .onChanged { gesture in + let minSize: CGFloat = 10 + + switch (direction) { + case .horizontal: + let new = min(max(minSize, gesture.location.x), size.width - minSize) + split = new / size.width + + case .vertical: + let new = min(max(minSize, gesture.location.y), size.height - minSize) + split = new / size.height + } + } + } + + /// Calculates the bounding rect for the left view. + private func leftRect(for size: CGSize) -> CGRect { + // Initially the rect is the full size + var result = CGRect(x: 0, y: 0, width: size.width, height: size.height) + switch (direction) { + case .horizontal: + result.size.width = result.size.width * split + result.size.width -= splitterVisibleSize / 2 + + case .vertical: + result.size.height = result.size.height * split + result.size.height -= splitterVisibleSize / 2 + } + + return result + } + + /// Calculates the bounding rect for the right view. + private func rightRect(for size: CGSize, leftRect: CGRect) -> CGRect { + // Initially the rect is the full size + var result = CGRect(x: 0, y: 0, width: size.width, height: size.height) + switch (direction) { + case .horizontal: + // For horizontal layouts we offset the starting X by the left rect + // and make the width fit the remaining space. + result.origin.x += leftRect.size.width + result.origin.x += splitterVisibleSize / 2 + result.size.width -= result.origin.x + + case .vertical: + result.origin.y += leftRect.size.height + result.origin.y += splitterVisibleSize / 2 + result.size.height -= result.origin.y + } + + return result + } + + /// Calculates the point at which the splitter should be rendered. + private func splitterPoint(for size: CGSize, leftRect: CGRect) -> CGPoint { + switch (direction) { + case .horizontal: + return CGPoint(x: leftRect.size.width, y: size.height / 2) + + case .vertical: + return CGPoint(x: size.width / 2, y: leftRect.size.height) + } + } +} + +enum SplitViewDirection { + case horizontal, vertical +} diff --git a/macos/Sources/TerminalSurfaceView.swift b/macos/Sources/TerminalSurfaceView.swift deleted file mode 100644 index c790646ed..000000000 --- a/macos/Sources/TerminalSurfaceView.swift +++ /dev/null @@ -1,486 +0,0 @@ -import OSLog -import SwiftUI -import GhosttyKit - -/// 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, -/// a split, a small preview pane, etc. It is ANYTHING that has a terminal drawn to it. -/// -/// 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 TerminalSurfaceView: NSViewRepresentable { - static let logger = Logger( - subsystem: Bundle.main.bundleIdentifier!, - category: String(describing: TerminalSurfaceView.self) - ) - - /// This should be set to true wen the surface has focus. This is up to the parent because - /// focus is also defined by window focus. It is important this is set correctly since if it is - /// false then the surface will idle at almost 0% CPU. - let hasFocus: Bool - - /// The size of the frame containing this view. We use this to update the the underlying - /// surface. This does not actually SET the size of our frame, this only sets the size - /// of our Metal surface for drawing. - /// - /// Note: we do NOT use the NSView.resize function because SwiftUI on macOS 12 - /// does not call this callback (macOS 13+ does). - /// - /// The best approach is to wrap this view in a GeometryReader and pass in the geo.size. - let size: CGSize - - /// This is set to the title of the surface as defined by the pty. Callers should use this to - /// set the appropriate title of the window/tab/split/etc. if they care. - @Binding var title: String - - @StateObject private var state: TerminalSurfaceView_Real - - init(_ app: ghostty_app_t, hasFocus: Bool, size: CGSize, title: Binding) { - self._state = StateObject(wrappedValue: TerminalSurfaceView_Real(app)) - self._title = title - self.hasFocus = hasFocus - self.size = size - } - - func makeNSView(context: Context) -> TerminalSurfaceView_Real { - // We need the view as part of the state to be created previously because - // the view is sent to the Ghostty API so that it can manipulate it - // directly since we draw on a render thread. - state.delegate = context.coordinator - return state; - } - - func updateNSView(_ view: TerminalSurfaceView_Real, context: Context) { - state.delegate = context.coordinator - state.focusDidChange(hasFocus) - state.sizeDidChange(size) - } - - func makeCoordinator() -> Coordinator { - return Coordinator(self) - } - - class Coordinator : TerminalSurfaceDelegate { - let view: TerminalSurfaceView - - init(_ view: TerminalSurfaceView) { - self.view = view - } - - func titleDidChange(to: String) { - view.title = to - } - } -} - -/// We use the delegate pattern to receive notifications about important state changes in the surface. -protocol TerminalSurfaceDelegate: AnyObject { - func titleDidChange(to: String) -} - -/// The actual NSView implementation for the terminal surface. -class TerminalSurfaceView_Real: NSView, NSTextInputClient, ObservableObject { - weak var delegate: TerminalSurfaceDelegate? - - // The current title of the surface as defined by the pty. This can be - // changed with escape codes. - var title: String = "" { - didSet { - if let delegate = self.delegate { - delegate.titleDidChange(to: title) - } - } - } - - var surface: ghostty_surface_t? = nil - var error: Error? = nil - private var markedText: NSMutableAttributedString; - - // We need to support being a first responder so that we can get input events - override var acceptsFirstResponder: Bool { return true } - - // I don't thikn we need this but this lets us know we should redraw our layer - // so we'll use that to tell ghostty to refresh. - override var wantsUpdateLayer: Bool { return true } - - // Mapping of event keyCode to ghostty input key values. This is cribbed from - // glfw mostly since we started as a glfw-based app way back in the day! - static let keycodes: [UInt16 : ghostty_input_key_e] = [ - 0x1D: GHOSTTY_KEY_ZERO, - 0x12: GHOSTTY_KEY_ONE, - 0x13: GHOSTTY_KEY_TWO, - 0x14: GHOSTTY_KEY_THREE, - 0x15: GHOSTTY_KEY_FOUR, - 0x17: GHOSTTY_KEY_FIVE, - 0x16: GHOSTTY_KEY_SIX, - 0x1A: GHOSTTY_KEY_SEVEN, - 0x1C: GHOSTTY_KEY_EIGHT, - 0x19: GHOSTTY_KEY_NINE, - 0x00: GHOSTTY_KEY_A, - 0x0B: GHOSTTY_KEY_B, - 0x08: GHOSTTY_KEY_C, - 0x02: GHOSTTY_KEY_D, - 0x0E: GHOSTTY_KEY_E, - 0x03: GHOSTTY_KEY_F, - 0x05: GHOSTTY_KEY_G, - 0x04: GHOSTTY_KEY_H, - 0x22: GHOSTTY_KEY_I, - 0x26: GHOSTTY_KEY_J, - 0x28: GHOSTTY_KEY_K, - 0x25: GHOSTTY_KEY_L, - 0x2E: GHOSTTY_KEY_M, - 0x2D: GHOSTTY_KEY_N, - 0x1F: GHOSTTY_KEY_O, - 0x23: GHOSTTY_KEY_P, - 0x0C: GHOSTTY_KEY_Q, - 0x0F: GHOSTTY_KEY_R, - 0x01: GHOSTTY_KEY_S, - 0x11: GHOSTTY_KEY_T, - 0x20: GHOSTTY_KEY_U, - 0x09: GHOSTTY_KEY_V, - 0x0D: GHOSTTY_KEY_W, - 0x07: GHOSTTY_KEY_X, - 0x10: GHOSTTY_KEY_Y, - 0x06: GHOSTTY_KEY_Z, - - 0x27: GHOSTTY_KEY_APOSTROPHE, - 0x2A: GHOSTTY_KEY_BACKSLASH, - 0x2B: GHOSTTY_KEY_COMMA, - 0x18: GHOSTTY_KEY_EQUAL, - 0x32: GHOSTTY_KEY_GRAVE_ACCENT, - 0x21: GHOSTTY_KEY_LEFT_BRACKET, - 0x1B: GHOSTTY_KEY_MINUS, - 0x2F: GHOSTTY_KEY_PERIOD, - 0x1E: GHOSTTY_KEY_RIGHT_BRACKET, - 0x29: GHOSTTY_KEY_SEMICOLON, - 0x2C: GHOSTTY_KEY_SLASH, - - 0x33: GHOSTTY_KEY_BACKSPACE, - 0x39: GHOSTTY_KEY_CAPS_LOCK, - 0x75: GHOSTTY_KEY_DELETE, - 0x7D: GHOSTTY_KEY_DOWN, - 0x77: GHOSTTY_KEY_END, - 0x24: GHOSTTY_KEY_ENTER, - 0x35: GHOSTTY_KEY_ESCAPE, - 0x7A: GHOSTTY_KEY_F1, - 0x78: GHOSTTY_KEY_F2, - 0x63: GHOSTTY_KEY_F3, - 0x76: GHOSTTY_KEY_F4, - 0x60: GHOSTTY_KEY_F5, - 0x61: GHOSTTY_KEY_F6, - 0x62: GHOSTTY_KEY_F7, - 0x64: GHOSTTY_KEY_F8, - 0x65: GHOSTTY_KEY_F9, - 0x6D: GHOSTTY_KEY_F10, - 0x67: GHOSTTY_KEY_F11, - 0x6F: GHOSTTY_KEY_F12, - 0x69: GHOSTTY_KEY_PRINT_SCREEN, - 0x6B: GHOSTTY_KEY_F14, - 0x71: GHOSTTY_KEY_F15, - 0x6A: GHOSTTY_KEY_F16, - 0x40: GHOSTTY_KEY_F17, - 0x4F: GHOSTTY_KEY_F18, - 0x50: GHOSTTY_KEY_F19, - 0x5A: GHOSTTY_KEY_F20, - 0x73: GHOSTTY_KEY_HOME, - 0x72: GHOSTTY_KEY_INSERT, - 0x7B: GHOSTTY_KEY_LEFT, - 0x3A: GHOSTTY_KEY_LEFT_ALT, - 0x3B: GHOSTTY_KEY_LEFT_CONTROL, - 0x38: GHOSTTY_KEY_LEFT_SHIFT, - 0x37: GHOSTTY_KEY_LEFT_SUPER, - 0x47: GHOSTTY_KEY_NUM_LOCK, - 0x79: GHOSTTY_KEY_PAGE_DOWN, - 0x74: GHOSTTY_KEY_PAGE_UP, - 0x7C: GHOSTTY_KEY_RIGHT, - 0x3D: GHOSTTY_KEY_RIGHT_ALT, - 0x3E: GHOSTTY_KEY_RIGHT_CONTROL, - 0x3C: GHOSTTY_KEY_RIGHT_SHIFT, - 0x36: GHOSTTY_KEY_RIGHT_SUPER, - 0x31: GHOSTTY_KEY_SPACE, - 0x30: GHOSTTY_KEY_TAB, - 0x7E: GHOSTTY_KEY_UP, - - 0x52: GHOSTTY_KEY_KP_0, - 0x53: GHOSTTY_KEY_KP_1, - 0x54: GHOSTTY_KEY_KP_2, - 0x55: GHOSTTY_KEY_KP_3, - 0x56: GHOSTTY_KEY_KP_4, - 0x57: GHOSTTY_KEY_KP_5, - 0x58: GHOSTTY_KEY_KP_6, - 0x59: GHOSTTY_KEY_KP_7, - 0x5B: GHOSTTY_KEY_KP_8, - 0x5C: GHOSTTY_KEY_KP_9, - 0x45: GHOSTTY_KEY_KP_ADD, - 0x41: GHOSTTY_KEY_KP_DECIMAL, - 0x4B: GHOSTTY_KEY_KP_DIVIDE, - 0x4C: GHOSTTY_KEY_KP_ENTER, - 0x51: GHOSTTY_KEY_KP_EQUAL, - 0x43: GHOSTTY_KEY_KP_MULTIPLY, - 0x4E: GHOSTTY_KEY_KP_SUBTRACT, - ]; - - init(_ app: ghostty_app_t) { - self.markedText = NSMutableAttributedString() - - // Initialize with some default frame size. The important thing is that this - // 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)) - - // Setup our surface. This will also initialize all the terminal IO. - var surface_cfg = ghostty_surface_config_s( - userdata: Unmanaged.passUnretained(self).toOpaque(), - nsview: Unmanaged.passUnretained(self).toOpaque(), - scale_factor: NSScreen.main!.backingScaleFactor) - guard let surface = ghostty_surface_new(app, &surface_cfg) else { - self.error = AppError.surfaceCreateError - return - } - self.surface = surface; - - // Setup our tracking area so we get mouse moved events - updateTrackingAreas() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) is not supported for this view") - } - - deinit { - trackingAreas.forEach { removeTrackingArea($0) } - - guard let surface = self.surface else { return } - ghostty_surface_free(surface) - } - - func focusDidChange(_ focused: Bool) { - guard let surface = self.surface else { return } - ghostty_surface_set_focus(surface, focused) - } - - func sizeDidChange(_ size: CGSize) { - guard let surface = self.surface else { return } - - // Ghostty wants to know the actual framebuffer size... It is very important - // here that we use "size" and NOT the view frame. If we're in the middle of - // an animation (i.e. a fullscreen animation), the frame will not yet be updated. - // The size represents our final size we're going for. - let scaledSize = self.convertToBacking(size) - ghostty_surface_set_size(surface, UInt32(scaledSize.width), UInt32(scaledSize.height)) - } - - override func updateTrackingAreas() { - // To update our tracking area we just recreate it all. - trackingAreas.forEach { removeTrackingArea($0) } - - // This tracking area is across the entire frame to notify us of mouse movements. - addTrackingArea(NSTrackingArea( - rect: frame, - options: [ - .mouseEnteredAndExited, - .mouseMoved, - .inVisibleRect, - - // It is possible this is incorrect when we have splits. This will make - // mouse events only happen while the terminal is focused. Is that what - // we want? - .activeWhenFirstResponder, - ], - owner: self, - userInfo: nil)) - } - - override func resetCursorRects() { - discardCursorRects() - addCursorRect(frame, cursor: .iBeam) - } - - override func viewDidChangeBackingProperties() { - guard let surface = self.surface else { return } - - // Detect our X/Y scale factor so we can update our surface - let fbFrame = self.convertToBacking(self.frame) - let xScale = fbFrame.size.width / self.frame.size.width - let yScale = fbFrame.size.height / self.frame.size.height - ghostty_surface_set_content_scale(surface, xScale, yScale) - - // When our scale factor changes, so does our fb size so we send that too - ghostty_surface_set_size(surface, UInt32(fbFrame.size.width), UInt32(fbFrame.size.height)) - } - - override func updateLayer() { - guard let surface = self.surface else { return } - ghostty_surface_refresh(surface); - } - - override func mouseDown(with event: NSEvent) { - guard let surface = self.surface else { return } - let mods = Self.translateFlags(event.modifierFlags) - ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) - } - - override func mouseUp(with event: NSEvent) { - guard let surface = self.surface else { return } - let mods = Self.translateFlags(event.modifierFlags) - ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) - } - - override func rightMouseDown(with event: NSEvent) { - guard let surface = self.surface else { return } - let mods = Self.translateFlags(event.modifierFlags) - ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mods) - } - - override func rightMouseUp(with event: NSEvent) { - guard let surface = self.surface else { return } - let mods = Self.translateFlags(event.modifierFlags) - ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, mods) - } - - override func mouseMoved(with event: NSEvent) { - guard let surface = self.surface else { return } - - // Convert window position to view position. Note (0, 0) is bottom left. - let pos = self.convert(event.locationInWindow, from: nil) - ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y) - - } - - override func mouseDragged(with event: NSEvent) { - self.mouseMoved(with: event) - } - - override func scrollWheel(with event: NSEvent) { - guard let surface = self.surface else { return } - - var x = event.scrollingDeltaX - var y = event.scrollingDeltaY - if event.hasPreciseScrollingDeltas { - x *= 0.1 - y *= 0.1 - } - - ghostty_surface_mouse_scroll(surface, x, y) - } - - override func keyDown(with event: NSEvent) { - guard let surface = self.surface else { return } - let key = Self.keycodes[event.keyCode] ?? GHOSTTY_KEY_INVALID - let mods = Self.translateFlags(event.modifierFlags) - let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS - ghostty_surface_key(surface, action, key, mods) - - self.interpretKeyEvents([event]) - } - - override func keyUp(with event: NSEvent) { - guard let surface = self.surface else { return } - let key = Self.keycodes[event.keyCode] ?? GHOSTTY_KEY_INVALID - let mods = Self.translateFlags(event.modifierFlags) - ghostty_surface_key(surface, GHOSTTY_ACTION_RELEASE, key, mods) - } - - // MARK: NSTextInputClient - - func hasMarkedText() -> Bool { - return markedText.length > 0 - } - - func markedRange() -> NSRange { - guard markedText.length > 0 else { return NSRange() } - return NSRange(0...(markedText.length-1)) - } - - func selectedRange() -> NSRange { - return NSRange() - } - - func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { - switch string { - case let v as NSAttributedString: - self.markedText = NSMutableAttributedString(attributedString: v) - - case let v as String: - self.markedText = NSMutableAttributedString(string: v) - - default: - print("unknown marked text: \(string)") - } - } - - func unmarkText() { - self.markedText.mutableString.setString("") - } - - func validAttributesForMarkedText() -> [NSAttributedString.Key] { - return [] - } - - func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { - return nil - } - - func characterIndex(for point: NSPoint) -> Int { - return 0 - } - - func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { - guard let surface = self.surface else { - return NSMakeRect(frame.origin.x, frame.origin.y, 0, 0) - } - - // Ghostty will tell us where it thinks an IME keyboard should render. - var x: Double = 0; - var y: Double = 0; - ghostty_surface_ime_point(surface, &x, &y) - - // Ghostty coordinates are in top-left (0, 0) so we have to convert to - // bottom-left since that is what UIKit expects - let rect = NSMakeRect(x, frame.size.height - y, 0, 0) - - // Convert from view to screen coordinates - guard let window = self.window else { return rect } - return window.convertToScreen(rect) - } - - func insertText(_ string: Any, replacementRange: NSRange) { - // We must have an associated event - guard NSApp.currentEvent != nil else { return } - guard let surface = self.surface else { return } - - // We want the string view of the any value - var chars = "" - switch (string) { - case let v as NSAttributedString: - chars = v.string - case let v as String: - chars = v - default: - return - } - - for codepoint in chars.unicodeScalars { - ghostty_surface_char(surface, codepoint.value) - } - } - - override func doCommand(by selector: Selector) { - // This currently just prevents NSBeep from interpretKeyEvents but in the future - // we may want to make some of this work. - - print("SEL: \(selector)") - } - - private static func translateFlags(_ flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e { - var mods: UInt32 = GHOSTTY_MODS_NONE.rawValue - if (flags.contains(.shift)) { mods |= GHOSTTY_MODS_SHIFT.rawValue } - if (flags.contains(.control)) { mods |= GHOSTTY_MODS_CTRL.rawValue } - if (flags.contains(.option)) { mods |= GHOSTTY_MODS_ALT.rawValue } - if (flags.contains(.command)) { mods |= GHOSTTY_MODS_SUPER.rawValue } - if (flags.contains(.capsLock)) { mods |= GHOSTTY_MODS_CAPS.rawValue } - - return ghostty_input_mods_e(mods) - } -} diff --git a/macos/Sources/TerminalView.swift b/macos/Sources/TerminalView.swift deleted file mode 100644 index b03abbcb9..000000000 --- a/macos/Sources/TerminalView.swift +++ /dev/null @@ -1,24 +0,0 @@ -import SwiftUI -import GhosttyKit - -struct TerminalView: View { - let app: ghostty_app_t - @FocusState private var surfaceFocus: Bool - @Environment(\.isKeyWindow) private var isKeyWindow: Bool - @State private var title: String = "Ghostty" - - // This is true if the terminal is considered "focused". The terminal is focused if - // it is both individually focused and the containing window is key. - private var hasFocus: Bool { surfaceFocus && isKeyWindow } - - var body: some View { - // 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 - // resize callback. - GeometryReader { geo in - TerminalSurfaceView(app, hasFocus: hasFocus, size: geo.size, title: $title) - .focused($surfaceFocus) - .navigationTitle(title) - } - } -} diff --git a/macos/Sources/WindowTracker.swift b/macos/Sources/WindowTracker.swift deleted file mode 100644 index c936db253..000000000 --- a/macos/Sources/WindowTracker.swift +++ /dev/null @@ -1,80 +0,0 @@ -import SwiftUI - -/// This modifier tracks whether the window is the key window in the isKeyWindow environment value. -struct WindowObservationModifier: ViewModifier { - @StateObject var windowObserver: WindowObserver = WindowObserver() - - func body(content: Content) -> some View { - content.background( - HostingWindowFinder { [weak windowObserver] window in - windowObserver?.window = window - } - ).environment(\.isKeyWindow, windowObserver.isKeyWindow) - } -} - -extension EnvironmentValues { - struct IsKeyWindowKey: EnvironmentKey { - static var defaultValue: Bool = false - typealias Value = Bool - } - - fileprivate(set) var isKeyWindow: Bool { - get { - self[IsKeyWindowKey.self] - } - set { - self[IsKeyWindowKey.self] = newValue - } - } -} - -class WindowObserver: ObservableObject { - @Published public private(set) var isKeyWindow: Bool = false - - private var becomeKeyobserver: NSObjectProtocol? - private var resignKeyobserver: NSObjectProtocol? - - weak var window: NSWindow? { - didSet { - self.isKeyWindow = window?.isKeyWindow ?? false - guard let window = window else { - self.becomeKeyobserver = nil - self.resignKeyobserver = nil - return - } - - self.becomeKeyobserver = NotificationCenter.default.addObserver( - forName: NSWindow.didBecomeKeyNotification, - object: window, - queue: .main - ) { (n) in - self.isKeyWindow = true - } - - self.resignKeyobserver = NotificationCenter.default.addObserver( - forName: NSWindow.didResignKeyNotification, - object: window, - queue: .main - ) { (n) in - self.isKeyWindow = false - } - } - } -} - -/// This view calls the callback with the window value that hosts the view. -struct HostingWindowFinder: NSViewRepresentable { - var callback: (NSWindow?) -> () - - func makeNSView(context: Self.Context) -> NSView { - let view = NSView() - view.translatesAutoresizingMaskIntoConstraints = false - DispatchQueue.main.async { [weak view] in - self.callback(view?.window) - } - return view - } - - func updateNSView(_ nsView: NSView, context: Context) {} -} diff --git a/src/Surface.zig b/src/Surface.zig index 211a3cedc..f2452b85b 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -476,6 +476,7 @@ pub fn deinit(self: *Surface) void { self.alloc.destroy(self.font_group); self.alloc.destroy(self.renderer_state.mutex); + log.info("surface closed addr={x}", .{@ptrToInt(self)}); } /// Called from the app thread to handle mailbox messages to our specific @@ -940,6 +941,18 @@ pub fn keyCallback( } else log.warn("runtime doesn't implement gotoTab", .{}); }, + .new_split => |direction| { + if (@hasDecl(apprt.Surface, "newSplit")) { + try self.rt_surface.newSplit(direction); + } else log.warn("runtime doesn't implement newSplit", .{}); + }, + + .close_surface => { + if (@hasDecl(apprt.Surface, "closeSurface")) { + try self.rt_surface.closeSurface(); + } else log.warn("runtime doesn't implement closeSurface", .{}); + }, + .close_window => { _ = self.app_mailbox.push(.{ .close = self }, .{ .instant = {} }); }, diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 0a21504e7..7ed69a514 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -44,6 +44,13 @@ pub const App = struct { /// Write the clipboard value. write_clipboard: *const fn (SurfaceUD, [*:0]const u8) callconv(.C) void, + + /// Create a new split view. If the embedder doesn't support split + /// views then this can be null. + new_split: ?*const fn (SurfaceUD, input.SplitDirection) callconv(.C) void = null, + + /// Close the current surface given by this function. + close_surface: ?*const fn (SurfaceUD) callconv(.C) void = null, }; core_app: *CoreApp, @@ -148,6 +155,24 @@ pub const Surface = struct { self.core_surface.deinit(); } + pub fn newSplit(self: *const Surface, direction: input.SplitDirection) !void { + const func = self.app.opts.new_split orelse { + log.info("runtime embedder does not support splits", .{}); + return; + }; + + func(self.opts.userdata, direction); + } + + pub fn closeSurface(self: *const Surface) !void { + const func = self.app.opts.close_surface orelse { + log.info("runtime embedder does not closing a surface", .{}); + return; + }; + + func(self.opts.userdata); + } + pub fn getContentScale(self: *const Surface) !apprt.ContentScale { return self.content_scale; } @@ -445,4 +470,15 @@ pub const CAPI = struct { x.* = pos.x; y.* = pos.y; } + + /// Request that the surface become closed. This will go through the + /// normal trigger process that a close surface input binding would. + export fn ghostty_surface_request_close(ptr: *Surface) void { + ptr.closeSurface() catch {}; + } + + /// Request that the surface split in the given direction. + export fn ghostty_surface_split(ptr: *Surface, direction: input.SplitDirection) void { + ptr.newSplit(direction) catch {}; + } }; diff --git a/src/config.zig b/src/config.zig index 06adb6910..2b6b3766f 100644 --- a/src/config.zig +++ b/src/config.zig @@ -279,6 +279,11 @@ pub const Config = struct { try result.keybind.set.put( alloc, .{ .key = .w, .mods = .{ .super = true } }, + .{ .close_surface = {} }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .w, .mods = .{ .super = true, .shift = true } }, .{ .close_window = {} }, ); try result.keybind.set.put( @@ -296,6 +301,16 @@ pub const Config = struct { .{ .key = .right_bracket, .mods = .{ .super = true, .shift = true } }, .{ .next_tab = {} }, ); + try result.keybind.set.put( + alloc, + .{ .key = .d, .mods = .{ .super = true } }, + .{ .new_split = .right }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .d, .mods = .{ .super = true, .shift = true } }, + .{ .new_split = .down }, + ); { // Cmd+N for goto tab N const start = @enumToInt(inputpkg.Key.one); diff --git a/src/input.zig b/src/input.zig index da121890f..2bdcbe86a 100644 --- a/src/input.zig +++ b/src/input.zig @@ -3,6 +3,7 @@ const std = @import("std"); pub usingnamespace @import("input/mouse.zig"); pub usingnamespace @import("input/key.zig"); pub const Binding = @import("input/Binding.zig"); +pub const SplitDirection = Binding.Action.SplitDirection; test { std.testing.refAllDecls(@This()); diff --git a/src/input/Binding.zig b/src/input/Binding.zig index a788d0c5e..3eeb941a6 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -105,7 +105,20 @@ pub fn parse(input: []const u8) !Binding { // Cursor keys can't be set currently Action.CursorKey => return Error.InvalidAction, - else => unreachable, + else => switch (@typeInfo(field.type)) { + .Enum => { + const idx = colonIdx orelse return Error.InvalidFormat; + const param = actionRaw[idx + 1 ..]; + const value = std.meta.stringToEnum( + field.type, + param, + ) orelse return Error.InvalidFormat; + + break :action @unionInit(Action, field.name, value); + }, + + else => unreachable, + }, } } } @@ -167,7 +180,15 @@ pub const Action = union(enum) { /// Go to the tab with the specific number, 1-indexed. goto_tab: usize, - /// Close the current window or tab + /// Create a new split in the given direction. The new split will appear + /// in the direction given. + new_split: SplitDirection, + + /// Close the current "surface", whether that is a window, tab, split, + /// etc. This only closes ONE surface. + close_surface: void, + + /// Close the window, regardless of how many tabs or splits there may be. close_window: void, /// Quit ghostty @@ -177,6 +198,15 @@ pub const Action = union(enum) { normal: []const u8, application: []const u8, }; + + // This is made extern (c_int) to make interop easier with our embedded + // runtime. The small size cost doesn't make a difference in our union. + pub const SplitDirection = enum(c_int) { + right, + down, + + // Note: we don't support top or left yet + }; }; /// Trigger is the associated key state that can trigger an action. @@ -286,11 +316,15 @@ test "parse: triggers" { try testing.expectError(Error.InvalidFormat, parse("a+b=ignore")); } -test "parse: action" { +test "parse: action invalid" { const testing = std.testing; // invalid action try testing.expectError(Error.InvalidFormat, parse("a=nopenopenope")); +} + +test "parse: action no parameters" { + const testing = std.testing; // no parameters try testing.expectEqual( @@ -298,6 +332,10 @@ test "parse: action" { try parse("a=ignore"), ); try testing.expectError(Error.InvalidFormat, parse("a=ignore:A")); +} + +test "parse: action with string" { + const testing = std.testing; // parameter { @@ -306,3 +344,14 @@ test "parse: action" { try testing.expectEqualStrings("A", binding.action.csi); } } + +test "parse: action with enum" { + const testing = std.testing; + + // parameter + { + const binding = try parse("a=new_split:right"); + try testing.expect(binding.action == .new_split); + try testing.expectEqual(Action.SplitDirection.right, binding.action.new_split); + } +} diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index 49ae661cc..b1c03cc6e 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -18,6 +18,15 @@ const log = std.log.scoped(.io_thread); /// the future if we want it configurable. pub const Mailbox = BlockingQueue(termio.Message, 64); +/// This stores the information that is coalesced. +const Coalesce = struct { + /// The number of milliseconds to coalesce certain messages like resize for. + /// Not all message types are coalesced. + const min_ms = 25; + + resize: ?termio.Message.Resize = null, +}; + /// Allocator used for some state alloc: std.mem.Allocator, @@ -34,6 +43,12 @@ wakeup_c: xev.Completion = .{}, stop: xev.Async, stop_c: xev.Completion = .{}, +/// This is used to coalesce resize events. +coalesce: xev.Timer, +coalesce_c: xev.Completion = .{}, +coalesce_cancel_c: xev.Completion = .{}, +coalesce_data: Coalesce = .{}, + /// The underlying IO implementation. impl: *termio.Impl, @@ -60,6 +75,10 @@ pub fn init( var stop_h = try xev.Async.init(); errdefer stop_h.deinit(); + // This timer is used to coalesce resize events. + var coalesce_h = try xev.Timer.init(); + errdefer coalesce_h.deinit(); + // The mailbox for messaging this thread var mailbox = try Mailbox.create(alloc); errdefer mailbox.destroy(alloc); @@ -69,6 +88,7 @@ pub fn init( .loop = loop, .wakeup = wakeup_h, .stop = stop_h, + .coalesce = coalesce_h, .impl = impl, .mailbox = mailbox, }; @@ -77,6 +97,7 @@ pub fn init( /// Clean up the thread. This is only safe to call once the thread /// completes executing; the caller must join prior to this. pub fn deinit(self: *Thread) void { + self.coalesce.deinit(); self.stop.deinit(); self.wakeup.deinit(); self.loop.deinit(); @@ -129,7 +150,7 @@ fn drainMailbox(self: *Thread) !void { log.debug("mailbox message={}", .{message}); switch (message) { - .resize => |v| try self.impl.resize(v.grid_size, v.screen_size, v.padding), + .resize => |v| self.handleResize(v), .clear_screen => |v| try self.impl.clearScreen(v.history), .write_small => |v| try self.impl.queueWrite(v.data[0..v.len]), .write_stable => |v| try self.impl.queueWrite(v), @@ -147,6 +168,51 @@ fn drainMailbox(self: *Thread) !void { } } +fn handleResize(self: *Thread, resize: termio.Message.Resize) void { + self.coalesce_data.resize = resize; + + // If the timer is already active we just return. In the future we want + // to reset the timer up to a maximum wait time but for now this ensures + // relatively smooth resizing. + if (self.coalesce_c.state() == .active) return; + + self.coalesce.reset( + &self.loop, + &self.coalesce_c, + &self.coalesce_cancel_c, + Coalesce.min_ms, + Thread, + self, + coalesceCallback, + ); +} + +fn coalesceCallback( + self_: ?*Thread, + _: *xev.Loop, + _: *xev.Completion, + r: xev.Timer.RunError!void, +) xev.CallbackAction { + _ = r catch |err| switch (err) { + error.Canceled => {}, + else => { + log.warn("error during coalesce callback err={}", .{err}); + return .disarm; + }, + }; + + const self = self_ orelse return .disarm; + + if (self.coalesce_data.resize) |v| { + self.coalesce_data.resize = null; + self.impl.resize(v.grid_size, v.screen_size, v.padding) catch |err| { + log.warn("error during resize err={}", .{err}); + }; + } + + return .disarm; +} + fn wakeupCallback( self_: ?*Thread, _: *xev.Loop, diff --git a/src/termio/message.zig b/src/termio/message.zig index a9f9a9006..d1a75bc01 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -15,8 +15,7 @@ pub const Message = union(enum) { /// in the future. pub const WriteReq = MessageData(u8, 38); - /// Resize the window. - resize: struct { + pub const Resize = struct { /// The grid size for the given screen size with padding applied. grid_size: renderer.GridSize, @@ -27,7 +26,10 @@ pub const Message = union(enum) { /// The padding, so that the terminal implementation can subtract /// this to send to the pty. padding: renderer.Padding, - }, + }; + + /// Resize the window. + resize: Resize, /// Clear the screen. clear_screen: struct {