From 70bdc21d22bd2d3415bfe78a71f9bb6244772b58 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 2 Sep 2023 15:48:22 -0700 Subject: [PATCH] macos: support zoomed splits --- macos/Sources/Ghostty/AppState.swift | 15 ++ macos/Sources/Ghostty/Ghostty.SplitView.swift | 255 ++++++++++++------ macos/Sources/Ghostty/Package.swift | 6 + macos/Sources/Ghostty/SurfaceView.swift | 2 +- 4 files changed, 195 insertions(+), 83 deletions(-) diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index 7149f2b4a..e55031bc3 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -73,6 +73,7 @@ extension Ghostty { new_window_cb: { userdata, surfaceConfig in AppState.newWindow(userdata, config: surfaceConfig) }, close_surface_cb: { userdata, processAlive in AppState.closeSurface(userdata, processAlive: processAlive) }, focus_split_cb: { userdata, direction in AppState.focusSplit(userdata, direction: direction) }, + zoom_split_cb: { userdata, zoom in AppState.zoomSplit(userdata, zoom: zoom) }, goto_tab_cb: { userdata, n in AppState.gotoTab(userdata, n: n) }, toggle_fullscreen_cb: { userdata, nonNativeFullscreen in AppState.toggleFullscreen(userdata, nonNativeFullscreen: nonNativeFullscreen) } ) @@ -205,6 +206,20 @@ extension Ghostty { ) } + static func zoomSplit(_ userdata: UnsafeMutableRawPointer?, zoom: Bool) { + guard let surface = self.surfaceUserdata(from: userdata) else { return } + + var name = Notification.didZoomSplit + if (!zoom) { + name = Notification.didZoomResetSplit + } + + NotificationCenter.default.post( + name: name, + object: surface + ) + } + static func gotoTab(_ userdata: UnsafeMutableRawPointer?, n: Int32) { guard let surface = self.surfaceUserdata(from: userdata) else { return } NotificationCenter.default.post( diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index 9ea1a7351..0af024dda 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -6,13 +6,33 @@ extension Ghostty { /// 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)? let baseConfig: ghostty_surface_config_s? + @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 { - TerminalSplitRoot(app: app, onClose: onClose, baseConfig: baseConfig) + 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 { + SurfaceWrapper(surfaceView: surfaceView) + } + } } } } @@ -63,6 +83,22 @@ extension Ghostty { } } + /// 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) + } + } + class Leaf: ObservableObject { let app: ghostty_app_t @Published var surface: SurfaceView @@ -144,53 +180,111 @@ extension Ghostty { let onClose: (() -> Void)? let baseConfig: ghostty_surface_config_s? + /// 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, onClose: (() ->Void)? = nil, baseConfig: ghostty_surface_config_s? = nil) { + init(app: ghostty_app_t, + zoomedSurface: Binding, + onClose: (() ->Void)? = nil, + baseConfig: ghostty_surface_config_s? = nil) { self.onClose = onClose self.baseConfig = baseConfig + self._zoomedSurface = zoomedSurface _node = State(wrappedValue: SplitNode.noSplit(.init(app, baseConfig))) } var body: some View { - 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 } + let center = NotificationCenter.default + + // 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) { + let pubZoom = center.publisher(for: Notification.didZoomSplit) + + 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() + } - // Free any resources associated with this root, we're closing. - node.close() + case .horizontal(let container): + TerminalSplitContainer( + direction: .horizontal, + neighbors: .empty, + node: $node, + container: container + ) + .onReceive(pubZoom) { onZoom(notification: $0) } - // Call our callback - guard let onClose = self.onClose else { return } - onClose() + case .vertical(let container): + TerminalSplitContainer( + direction: .vertical, + neighbors: .empty, + node: $node, + container: container + ) + .onReceive(pubZoom) { onZoom(notification: $0) } } - - case .horizontal(let container): - TerminalSplitContainer( - direction: .horizontal, - neighbors: .empty, - node: $node, - container: container - ) - - case .vertical(let container): - TerminalSplitContainer( - direction: .vertical, - neighbors: .empty, - node: $node, - container: container - ) } + .navigationTitle(surfaceTitle ?? "Ghostty") + } else { + // If we're zoomed, we want to listen for zoom resets. + let pubZoomReset = center.publisher(for: Notification.didZoomResetSplit) + + ZStack {} + .onReceive(pubZoomReset) { onZoomReset(notification: $0) } } - .navigationTitle(surfaceTitle ?? "Ghostty") + } + + 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) } } } @@ -231,7 +325,7 @@ extension Ghostty { .keyboardShortcut(.defaultAction) } message: { Text("The terminal still has a running process. If you close the terminal " + - "the process will be killed.") + "the process will be killed.") } } @@ -285,7 +379,7 @@ extension Ghostty { } // See moveFocus comment, we have to run this whenever split changes. - Self.moveFocus(container.bottomRight, previous: node) + 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. @@ -294,48 +388,7 @@ extension Ghostty { 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 } - Self.moveFocus(next, 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 moveFocus(_ 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.moveFocus(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() - } - - // 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: view - ) - } - } + Ghostty.moveFocus(to: next.preferredFocus(), from: node.preferredFocus()) } } @@ -369,7 +422,7 @@ extension Ghostty { // When closing the topLeft, our parent becomes the bottomRight. node = container.bottomRight - TerminalSplitLeaf.moveFocus(node, previous: container.topLeft) + Ghostty.moveFocus(to: node.preferredFocus(), from: container.topLeft.preferredFocus()) } }, right: { let neighborKey: WritableKeyPath = direction == .horizontal ? \.left : \.top @@ -390,7 +443,7 @@ extension Ghostty { // When closing the bottomRight, our parent becomes the topLeft. node = container.topLeft - TerminalSplitLeaf.moveFocus(node, previous: container.bottomRight) + Ghostty.moveFocus(to: node.preferredFocus(), from: container.bottomRight.preferredFocus()) } }) } @@ -431,4 +484,42 @@ extension Ghostty { } } } + + /// 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 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/Package.swift b/macos/Sources/Ghostty/Package.swift index 1b4517c22..35b0dca89 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -95,6 +95,12 @@ extension Ghostty.Notification { /// Notification that a surface is becoming focused. This is only sent on macOS 12 to /// work around bugs. macOS 13+ should use the ".focused()" attribute. static let didBecomeFocusedSurface = Notification.Name("com.mitchellh.ghostty.didBecomeFocusedSurface") + + /// Notification that a surface is being zoomed or unzoomed. Note that these are sent + /// regardless of if the surface is part of a split or not. It is up to the receiver to validate + /// this. + static let didZoomSplit = Notification.Name("com.mitchellh.ghostty.didZoomSplit") + static let didZoomResetSplit = Notification.Name("com.mitchellh.ghostty.didZoomResetSplit") } // Make the input enum hashable. diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index d6e885d55..37812893a 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -287,7 +287,7 @@ extension Ghostty { discardCursorRects() addCursorRect(frame, cursor: .iBeam) } - + override func viewDidChangeBackingProperties() { guard let surface = self.surface else { return }