diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 012b1631d..98fd5e58d 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -17,7 +17,6 @@ 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 */; }; A56B880B2A840447007A0E29 /* Carbon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A56B880A2A840447007A0E29 /* Carbon.framework */; }; A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */; }; A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A56D58882ACDE6CA00508D2C /* ServiceProvider.swift */; }; @@ -29,6 +28,7 @@ A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309D2AEE1D6C00D64628 /* TerminalView.swift */; }; A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309F2AEF6AEB00D64628 /* TerminalManager.swift */; }; A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */; }; + A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */; }; A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */; }; A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5D02AE0DEA7009128F3 /* MetalView.swift */; }; A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; }; @@ -53,7 +53,6 @@ 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 = ""; }; A56B880A2A840447007A0E29 /* Carbon.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Carbon.framework; path = System/Library/Frameworks/Carbon.framework; sourceTree = SDKROOT; }; A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Shell.swift; sourceTree = ""; }; A56D58882ACDE6CA00508D2C /* ServiceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceProvider.swift; sourceTree = ""; }; @@ -65,6 +64,7 @@ A596309D2AEE1D6C00D64628 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = ""; }; A596309F2AEF6AEB00D64628 /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = ""; }; A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.TerminalSplit.swift; sourceTree = ""; }; + A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.SplitNode.swift; sourceTree = ""; }; A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorView.swift; sourceTree = ""; }; A59FB5D02AE0DEA7009128F3 /* MetalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetalView.swift; sourceTree = ""; }; A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = ""; }; @@ -149,9 +149,9 @@ A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */, A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */, A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */, - A55B7BBD29B701360055DE60 /* Ghostty.SplitView.swift */, - A55685DF29A03A9F004303CE /* AppError.swift */, + A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */, A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */, + A55685DF29A03A9F004303CE /* AppError.swift */, ); path = Ghostty; sourceTree = ""; @@ -298,6 +298,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */, A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */, A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */, A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, @@ -312,7 +313,6 @@ A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */, A5FEB3002ABB69450068369E /* main.swift in Sources */, A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */, - A55B7BBE29B701360055DE60 /* Ghostty.SplitView.swift in Sources */, A55B7BB629B6F47F0055DE60 /* AppState.swift in Sources */, A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */, A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */, diff --git a/macos/Sources/Ghostty/Ghostty.SplitNode.swift b/macos/Sources/Ghostty/Ghostty.SplitNode.swift new file mode 100644 index 000000000..beba0ed8d --- /dev/null +++ b/macos/Sources/Ghostty/Ghostty.SplitNode.swift @@ -0,0 +1,217 @@ +import SwiftUI +import GhosttyKit + +extension Ghostty { + /// 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: Equatable, Hashable { + 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(_ direction: SplitFocusDirection = .top) -> SurfaceView { + let container: Container + switch (self) { + case .noSplit(let leaf): + // noSplit is easy because there is only one thing to focus + return leaf.surface + + case .horizontal(let c): + container = c + + case .vertical(let c): + container = c + } + + let node: SplitNode + switch (direction) { + case .previous, .bottom, .left: + node = container.bottomRight + + case .next, .top, .right: + node = container.topLeft + } + + return node.preferredFocus(direction) + } + + /// Close the surface associated with this node. This will likely deinitialize the + /// surface. At this point, the surface view in this node tree can never be used again. + func close() { + switch (self) { + case .noSplit(let leaf): + leaf.surface.close() + + case .horizontal(let container): + container.topLeft.close() + container.bottomRight.close() + + case .vertical(let container): + container.topLeft.close() + container.bottomRight.close() + } + } + + /// Returns true if any surface in the split stack requires quit confirmation. + func needsConfirmQuit() -> Bool { + switch (self) { + case .noSplit(let leaf): + return leaf.surface.needsConfirmQuit + + case .horizontal(let container): + return container.topLeft.needsConfirmQuit() || + container.bottomRight.needsConfirmQuit() + + case .vertical(let container): + return container.topLeft.needsConfirmQuit() || + container.bottomRight.needsConfirmQuit() + } + } + + /// Returns true if the split tree contains the given view. + func contains(view: SurfaceView) -> Bool { + switch (self) { + case .noSplit(let leaf): + return leaf.surface == view + + case .horizontal(let container): + return container.topLeft.contains(view: view) || + container.bottomRight.contains(view: view) + + case .vertical(let container): + return container.topLeft.contains(view: view) || + container.bottomRight.contains(view: view) + } + } + + // MARK: - Equatable + + static func == (lhs: SplitNode, rhs: SplitNode) -> Bool { + switch (lhs, rhs) { + case (.noSplit(let lhs_v), .noSplit(let rhs_v)): + return lhs_v === rhs_v + case (.horizontal(let lhs_v), .horizontal(let rhs_v)): + return lhs_v === rhs_v + case (.vertical(let lhs_v), .vertical(let rhs_v)): + return lhs_v === rhs_v + default: + return false + } + } + + class Leaf: ObservableObject, Equatable, Hashable { + let app: ghostty_app_t + @Published var surface: SurfaceView + + /// Initialize a new leaf which creates a new terminal surface. + init(_ app: ghostty_app_t, _ baseConfig: SurfaceConfiguration?) { + self.app = app + self.surface = SurfaceView(app, baseConfig) + } + + // MARK: - Hashable + + func hash(into hasher: inout Hasher) { + hasher.combine(app) + hasher.combine(surface) + } + + // MARK: - Equatable + + static func == (lhs: Leaf, rhs: Leaf) -> Bool { + return lhs.app == rhs.app && lhs.surface === rhs.surface + } + } + + class Container: ObservableObject, Equatable, Hashable { + 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, baseConfig: SurfaceConfiguration? = nil) { + 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, baseConfig)) + } + + // MARK: - Hashable + + func hash(into hasher: inout Hasher) { + hasher.combine(app) + hasher.combine(topLeft) + hasher.combine(bottomRight) + } + + // MARK: - Equatable + + static func == (lhs: Container, rhs: Container) -> Bool { + return lhs.app == rhs.app && + lhs.topLeft == rhs.topLeft && + lhs.bottomRight == rhs.bottomRight + } + } + + /// This keeps track of the "neighbors" of a split: the immediately above/below/left/right + /// nodes. This is purposely weak so we don't have to worry about memory management + /// with this (although, it should always be correct). + struct Neighbors { + var left: SplitNode? + var right: SplitNode? + var top: SplitNode? + var bottom: SplitNode? + + /// These are the previous/next nodes. It will certainly be one of the above as well + /// but we keep track of these separately because depending on the split direction + /// of the containing node, previous may be left OR top (same for next). + var previous: SplitNode? + var next: SplitNode? + + /// No neighbors, used by the root node. + static let empty: Self = .init() + + /// Get the node for a given direction. + func get(direction: SplitFocusDirection) -> SplitNode? { + let map: [SplitFocusDirection : KeyPath] = [ + .previous: \.previous, + .next: \.next, + .top: \.top, + .bottom: \.bottom, + .left: \.left, + .right: \.right, + ] + + guard let path = map[direction] else { return nil } + return self[keyPath: path] + } + + /// Update multiple keys and return a new copy. + func update(_ attrs: [WritableKeyPath: SplitNode?]) -> Self { + var clone = self + attrs.forEach { (key, value) in + clone[keyPath: key] = value + } + return clone + } + + /// True if there are no neighbors + func isEmpty() -> Bool { + return self.previous == nil && self.next == nil + } + } + } +} diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift deleted file mode 100644 index 3f0b96b61..000000000 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ /dev/null @@ -1,635 +0,0 @@ -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 { - let onClose: (() -> Void)? - let baseConfig: SurfaceConfiguration? - - @Environment(\.ghosttyApp) private var app - - /// Non-nil if one of the surfaces in the split tree is currently "zoomed." A zoomed surface - /// becomes "full screen" on the split tree. - @State private var zoomedSurface: SurfaceView? = nil - - var body: some View { - if let app = app { - ZStack { - TerminalSplitRoot( - app: app, - zoomedSurface: $zoomedSurface, - onClose: onClose, - baseConfig: baseConfig - ) - - // If we have a zoomed surface, we overlay that on top of our split - // root. Our split root will become clear when there is a zoomed - // surface. We need to keep the split root around so that we don't - // lose all of the surface state so this must be a ZStack. - if let surfaceView = zoomedSurface { - InspectableSurface(surfaceView: surfaceView) - } - } - .focusedValue(\.ghosttySurfaceZoomed, zoomedSurface != nil) - } - } - } - - /// 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: Equatable, Hashable { - 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(_ direction: SplitFocusDirection = .top) -> SurfaceView { - let container: Container - switch (self) { - case .noSplit(let leaf): - // noSplit is easy because there is only one thing to focus - return leaf.surface - - case .horizontal(let c): - container = c - - case .vertical(let c): - container = c - } - - let node: SplitNode - switch (direction) { - case .previous, .bottom, .left: - node = container.bottomRight - - case .next, .top, .right: - node = container.topLeft - } - - return node.preferredFocus(direction) - } - - /// Close the surface associated with this node. This will likely deinitialize the - /// surface. At this point, the surface view in this node tree can never be used again. - func close() { - switch (self) { - case .noSplit(let leaf): - leaf.surface.close() - - case .horizontal(let container): - container.topLeft.close() - container.bottomRight.close() - - case .vertical(let container): - container.topLeft.close() - container.bottomRight.close() - } - } - - /// Returns true if any surface in the split stack requires quit confirmation. - func needsConfirmQuit() -> Bool { - switch (self) { - case .noSplit(let leaf): - return leaf.surface.needsConfirmQuit - - case .horizontal(let container): - return container.topLeft.needsConfirmQuit() || - container.bottomRight.needsConfirmQuit() - - case .vertical(let container): - return container.topLeft.needsConfirmQuit() || - container.bottomRight.needsConfirmQuit() - } - } - - /// Returns true if the split tree contains the given view. - func contains(view: SurfaceView) -> Bool { - switch (self) { - case .noSplit(let leaf): - return leaf.surface == view - - case .horizontal(let container): - return container.topLeft.contains(view: view) || - container.bottomRight.contains(view: view) - - case .vertical(let container): - return container.topLeft.contains(view: view) || - container.bottomRight.contains(view: view) - } - } - - // MARK: - Equatable - - static func == (lhs: SplitNode, rhs: SplitNode) -> Bool { - switch (lhs, rhs) { - case (.noSplit(let lhs_v), .noSplit(let rhs_v)): - return lhs_v === rhs_v - case (.horizontal(let lhs_v), .horizontal(let rhs_v)): - return lhs_v === rhs_v - case (.vertical(let lhs_v), .vertical(let rhs_v)): - return lhs_v === rhs_v - default: - return false - } - } - - class Leaf: ObservableObject, Equatable, Hashable { - let app: ghostty_app_t - @Published var surface: SurfaceView - - /// Initialize a new leaf which creates a new terminal surface. - init(_ app: ghostty_app_t, _ baseConfig: SurfaceConfiguration?) { - self.app = app - self.surface = SurfaceView(app, baseConfig) - } - - // MARK: - Hashable - - func hash(into hasher: inout Hasher) { - hasher.combine(app) - hasher.combine(surface) - } - - // MARK: - Equatable - - static func == (lhs: Leaf, rhs: Leaf) -> Bool { - return lhs.app == rhs.app && lhs.surface === rhs.surface - } - } - - class Container: ObservableObject, Equatable, Hashable { - 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, baseConfig: SurfaceConfiguration? = nil) { - 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, baseConfig)) - } - - // MARK: - Hashable - - func hash(into hasher: inout Hasher) { - hasher.combine(app) - hasher.combine(topLeft) - hasher.combine(bottomRight) - } - - // MARK: - Equatable - - static func == (lhs: Container, rhs: Container) -> Bool { - return lhs.app == rhs.app && - lhs.topLeft == rhs.topLeft && - lhs.bottomRight == rhs.bottomRight - } - } - - /// This keeps track of the "neighbors" of a split: the immediately above/below/left/right - /// nodes. This is purposely weak so we don't have to worry about memory management - /// with this (although, it should always be correct). - struct Neighbors { - var left: SplitNode? - var right: SplitNode? - var top: SplitNode? - var bottom: SplitNode? - - /// These are the previous/next nodes. It will certainly be one of the above as well - /// but we keep track of these separately because depending on the split direction - /// of the containing node, previous may be left OR top (same for next). - var previous: SplitNode? - var next: SplitNode? - - /// No neighbors, used by the root node. - static let empty: Self = .init() - - /// Get the node for a given direction. - func get(direction: SplitFocusDirection) -> SplitNode? { - let map: [SplitFocusDirection : KeyPath] = [ - .previous: \.previous, - .next: \.next, - .top: \.top, - .bottom: \.bottom, - .left: \.left, - .right: \.right, - ] - - guard let path = map[direction] else { return nil } - return self[keyPath: path] - } - - /// Update multiple keys and return a new copy. - func update(_ attrs: [WritableKeyPath: SplitNode?]) -> Self { - var clone = self - attrs.forEach { (key, value) in - clone[keyPath: key] = value - } - return clone - } - - /// True if there are no neighbors - func isEmpty() -> Bool { - return self.previous == nil && self.next == nil - } - } - } - - /// 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)? - let baseConfig: SurfaceConfiguration? - - /// Keeps track of whether we're in a zoomed split state or not. If one of the splits we own - /// is in the zoomed state, we clear our body since we expect a zoomed split to overlay - /// this one. - @Binding var zoomedSurface: SurfaceView? - - @FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle: String? - - init(app: ghostty_app_t, - zoomedSurface: Binding, - onClose: (() ->Void)? = nil, - baseConfig: SurfaceConfiguration? = nil) { - self.onClose = onClose - self.baseConfig = baseConfig - self._zoomedSurface = zoomedSurface - _node = State(wrappedValue: SplitNode.noSplit(.init(app, baseConfig))) - } - - var body: some View { - let center = NotificationCenter.default - let pubZoom = center.publisher(for: Notification.didToggleSplitZoom) - - // If we're zoomed, we don't render anything, we are transparent. This - // ensures that the View stays around so we don't lose our state, but - // also that the zoomed view on top can see through if background transparency - // is enabled. - if (zoomedSurface == nil) { - ZStack { - switch (node) { - case .noSplit(let leaf): - TerminalSplitLeaf( - leaf: leaf, - neighbors: .empty, - node: $node, - requestClose: $requestClose - ) - .onChange(of: requestClose) { value in - guard value else { return } - - // Free any resources associated with this root, we're closing. - node.close() - - // Call our callback - guard let onClose = self.onClose else { return } - onClose() - } - - case .horizontal(let container): - TerminalSplitContainer( - direction: .horizontal, - neighbors: .empty, - node: $node, - container: container - ) - .onReceive(pubZoom) { onZoom(notification: $0) } - - case .vertical(let container): - TerminalSplitContainer( - direction: .vertical, - neighbors: .empty, - node: $node, - container: container - ) - .onReceive(pubZoom) { onZoom(notification: $0) } - } - } - .navigationTitle(surfaceTitle ?? "Ghostty") - .id(node) // Required to detect node changes - } else { - // On these events we want to reset the split state and call it. - let pubSplit = center.publisher(for: Notification.ghosttyNewSplit, object: zoomedSurface!) - let pubClose = center.publisher(for: Notification.ghosttyCloseSurface, object: zoomedSurface!) - let pubFocus = center.publisher(for: Notification.ghosttyFocusSplit, object: zoomedSurface!) - - ZStack {} - .onReceive(pubZoom) { onZoomReset(notification: $0) } - .onReceive(pubSplit) { onZoomReset(notification: $0) } - .onReceive(pubClose) { onZoomReset(notification: $0) } - .onReceive(pubFocus) { onZoomReset(notification: $0) } - } - } - - func onZoom(notification: SwiftUI.Notification) { - // Our node must be split to receive zooms. You can't zoom an unsplit terminal. - if case .noSplit = node { - preconditionFailure("TerminalSplitRoom must not be zoom-able if no splits exist") - } - - // Make sure the notification has a surface and that this window owns the surface. - guard let surfaceView = notification.object as? SurfaceView else { return } - guard node.contains(view: surfaceView) else { return } - - // We are in the zoomed state. - zoomedSurface = surfaceView - - // See onZoomReset, same logic. - DispatchQueue.main.async { Ghostty.moveFocus(to: surfaceView) } - } - - func onZoomReset(notification: SwiftUI.Notification) { - // Make sure the notification has a surface and that this window owns the surface. - guard let surfaceView = notification.object as? SurfaceView else { return } - guard zoomedSurface == surfaceView else { return } - - // We are now unzoomed - zoomedSurface = nil - - // We need to stay focused on this view, but the view is going to change - // superviews. We need to do this async so it happens on the next event loop - // tick. - DispatchQueue.main.async { - Ghostty.moveFocus(to: surfaceView) - - // If the notification is not a toggle zoom notification, we want to re-publish - // it after a short delay so that the split tree has a chance to re-establish - // so the proper view gets this notification. - if (notification.name != Notification.didToggleSplitZoom) { - // We have to wait ANOTHER tick since we just established. - DispatchQueue.main.async { - NotificationCenter.default.post(notification) - } - } - } - } - } - - /// A noSplit leaf node of a split tree. - private struct TerminalSplitLeaf: View { - /// The leaf to draw the surface for. - let leaf: SplitNode.Leaf - - /// The neighbors, used for navigation. - let neighbors: SplitNode.Neighbors - - /// 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 center = NotificationCenter.default - let pub = center.publisher(for: Notification.ghosttyNewSplit, object: leaf.surface) - let pubClose = center.publisher(for: Notification.ghosttyCloseSurface, object: leaf.surface) - let pubFocus = center.publisher(for: Notification.ghosttyFocusSplit, object: leaf.surface) - - InspectableSurface(surfaceView: leaf.surface, isSplit: !neighbors.isEmpty()) - .onReceive(pub) { onNewSplit(notification: $0) } - .onReceive(pubClose) { onClose(notification: $0) } - .onReceive(pubFocus) { onMoveFocus(notification: $0) } - } - - private func onClose(notification: SwiftUI.Notification) { - var processAlive = false - if let valueAny = notification.userInfo?["process_alive"] { - if let value = valueAny as? Bool { - processAlive = value - } - } - - // If the child process is not alive, then we exit immediately - guard processAlive else { - requestClose = true - return - } - - // If we don't have a window to attach our modal to, we also exit immediately. - // This should NOT happen. - guard let window = leaf.surface.window else { - requestClose = true - return - } - - // Confirm close. We use an NSAlert instead of a SwiftUI confirmationDialog - // due to SwiftUI bugs (see Ghostty #560). To repeat from #560, the bug is that - // confirmationDialog allows the user to Cmd-W close the alert, but when doing - // so SwiftUI does not update any of the bindings to note that window is no longer - // being shown, and provides no callback to detect this. - let alert = NSAlert() - alert.messageText = "Close Terminal?" - alert.informativeText = "The terminal still has a running process. If you close the " + - "terminal the process will be killed." - alert.addButton(withTitle: "Close the Terminal") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - alert.beginSheetModal(for: window, completionHandler: { response in - switch (response) { - case .alertFirstButtonReturn: - requestClose = true - - default: - break - } - }) - } - - private func onNewSplit(notification: SwiftUI.Notification) { - let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] - let config = configAny as? SurfaceConfiguration - - // 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, baseConfig: config) - - // 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 moveFocus comment, we have to run this whenever split changes. - Ghostty.moveFocus(to: container.bottomRight.preferredFocus(), from: node.preferredFocus()) - } - - /// This handles the event to move the split focus (i.e. previous/next) from a keyboard event. - private func onMoveFocus(notification: SwiftUI.Notification) { - // Determine our desired direction - guard let directionAny = notification.userInfo?[Notification.SplitDirectionKey] else { return } - guard let direction = directionAny as? SplitFocusDirection else { return } - guard let next = neighbors.get(direction: direction) else { return } - Ghostty.moveFocus( - to: next.preferredFocus(direction), - from: node.preferredFocus() - ) - } - } - - /// This represents a split view that is in the horizontal or vertical split state. - private struct TerminalSplitContainer: View { - let direction: SplitViewDirection - let neighbors: SplitNode.Neighbors - @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: { - let neighborKey: WritableKeyPath = direction == .horizontal ? \.right : \.bottom - - TerminalSplitNested( - node: $container.topLeft, - neighbors: neighbors.update([ - neighborKey: container.bottomRight, - \.next: container.bottomRight, - ]), - requestClose: $closeTopLeft - ) - .onChange(of: closeTopLeft) { value in - guard value else { return } - - // Close the top left and release all resources - container.topLeft.close() - - // When closing the topLeft, our parent becomes the bottomRight. - node = container.bottomRight - Ghostty.moveFocus(to: node.preferredFocus(), from: container.topLeft.preferredFocus()) - } - }, right: { - let neighborKey: WritableKeyPath = direction == .horizontal ? \.left : \.top - - TerminalSplitNested( - node: $container.bottomRight, - neighbors: neighbors.update([ - neighborKey: container.topLeft, - \.previous: container.topLeft, - ]), - requestClose: $closeBottomRight - ) - .onChange(of: closeBottomRight) { value in - guard value else { return } - - // Close the node and release all resources - container.bottomRight.close() - - // When closing the bottomRight, our parent becomes the topLeft. - node = container.topLeft - Ghostty.moveFocus(to: node.preferredFocus(), from: container.bottomRight.preferredFocus()) - } - }) - } - } - - /// 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 - let neighbors: SplitNode.Neighbors - @Binding var requestClose: Bool - - var body: some View { - switch (node) { - case .noSplit(let leaf): - TerminalSplitLeaf( - leaf: leaf, - neighbors: neighbors, - node: $node, - requestClose: $requestClose - ) - - case .horizontal(let container): - TerminalSplitContainer( - direction: .horizontal, - neighbors: neighbors, - node: $node, - container: container - ) - - case .vertical(let container): - TerminalSplitContainer( - direction: .vertical, - neighbors: neighbors, - node: $node, - container: container - ) - } - } - } - - /// When changing the split state, or going full screen (native or non), 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. - static func moveFocus(to: SurfaceView, from: SurfaceView? = nil) { - 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 = to.window else { - moveFocus(to: to, from: from) - return - } - - // 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. - if let from = from { - _ = from.resignFirstResponder() - } - - window.makeFirstResponder(to) - - // On newer versions of macOS everything above works great so we're done. - if #available(macOS 13, *) { return } - - // On macOS 12, splits do not properly gain focus. I don't know why, but - // it seems like the `focused` SwiftUI method doesn't work. We use - // NotificationCenter as a blunt force instrument to make it work. - if #available(macOS 12, *) { - NotificationCenter.default.post( - name: Notification.didBecomeFocusedSurface, - object: to - ) - } - } - } -} diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift index 941bceb78..f662346a4 100644 --- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift +++ b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift @@ -363,4 +363,42 @@ extension Ghostty { } } } + + /// When changing the split state, or going full screen (native or non), 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. + static func moveFocus(to: SurfaceView, from: SurfaceView? = nil) { + 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 = to.window else { + moveFocus(to: to, from: from) + return + } + + // 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. + if let from = from { + _ = from.resignFirstResponder() + } + + window.makeFirstResponder(to) + + // On newer versions of macOS everything above works great so we're done. + if #available(macOS 13, *) { return } + + // On macOS 12, splits do not properly gain focus. I don't know why, but + // it seems like the `focused` SwiftUI method doesn't work. We use + // NotificationCenter as a blunt force instrument to make it work. + if #available(macOS 12, *) { + NotificationCenter.default.post( + name: Notification.didBecomeFocusedSurface, + object: to + ) + } + } + } }