From 50a1a52ae350067568e2d6c080865d05d71152a8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 2 Sep 2023 14:52:43 -0700 Subject: [PATCH 1/7] core: add zoom keybinding for splits --- include/ghostty.h | 2 ++ src/Surface.zig | 7 +++++++ src/apprt/embedded.zig | 12 ++++++++++++ src/config.zig | 6 ++++++ src/input/Binding.zig | 3 +++ 5 files changed, 30 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index df042ff95..95de1339e 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -262,6 +262,7 @@ typedef void (*ghostty_runtime_new_tab_cb)(void *, ghostty_surface_config_s); typedef void (*ghostty_runtime_new_window_cb)(void *, ghostty_surface_config_s); typedef void (*ghostty_runtime_close_surface_cb)(void *, bool); typedef void (*ghostty_runtime_focus_split_cb)(void *, ghostty_split_focus_direction_e); +typedef void (*ghostty_runtime_zoom_split_cb)(void *); typedef void (*ghostty_runtime_goto_tab_cb)(void *, int32_t); typedef void (*ghostty_runtime_toggle_fullscreen_cb)(void *, ghostty_non_native_fullscreen_e); @@ -278,6 +279,7 @@ typedef struct { ghostty_runtime_new_window_cb new_window_cb; ghostty_runtime_close_surface_cb close_surface_cb; ghostty_runtime_focus_split_cb focus_split_cb; + ghostty_runtime_zoom_split_cb zoom_split_cb; ghostty_runtime_goto_tab_cb goto_tab_cb; ghostty_runtime_toggle_fullscreen_cb toggle_fullscreen_cb; } ghostty_runtime_config_s; diff --git a/src/Surface.zig b/src/Surface.zig index f4ed94ba8..cbbf4c5f0 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2118,6 +2118,13 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !void } else log.warn("runtime doesn't implement gotoSplit", .{}); }, + .zoom_split => { + log.warn("ZOOM ZOOM", .{}); + if (@hasDecl(apprt.Surface, "zoomSplit")) { + self.rt_surface.zoomSplit(); + } else log.warn("runtime doesn't implement zoomSplit", .{}); + }, + .toggle_fullscreen => { if (@hasDecl(apprt.Surface, "toggleFullscreen")) { self.rt_surface.toggleFullscreen(self.config.macos_non_native_fullscreen); diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index bc6bc876e..59d934eb1 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -72,6 +72,9 @@ pub const App = struct { /// Focus the previous/next split (if any). focus_split: ?*const fn (SurfaceUD, input.SplitFocusDirection) callconv(.C) void = null, + /// Zoom the current split. + zoom_split: ?*const fn (SurfaceUD) callconv(.C) void = null, + /// Goto tab goto_tab: ?*const fn (SurfaceUD, usize) callconv(.C) void = null, @@ -270,6 +273,15 @@ pub const Surface = struct { func(self.opts.userdata, direction); } + pub fn zoomSplit(self: *const Surface) void { + const func = self.app.opts.zoom_split orelse { + log.info("runtime embedder does not support zoom split", .{}); + return; + }; + + func(self.opts.userdata); + } + pub fn getContentScale(self: *const Surface) !apprt.ContentScale { return self.content_scale; } diff --git a/src/config.zig b/src/config.zig index bd826c5d6..c73b288ca 100644 --- a/src/config.zig +++ b/src/config.zig @@ -682,6 +682,12 @@ pub const Config = struct { .{ .key = .right, .mods = .{ .super = true, .alt = true } }, .{ .goto_split = .right }, ); + + try result.keybind.set.put( + alloc, + .{ .key = .equal, .mods = .{ .super = true, .shift = true } }, + .{ .zoom_split = {} }, + ); } return result; diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 143f217d9..7324748be 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -158,6 +158,9 @@ pub const Action = union(enum) { /// Focus on a split in a given direction. goto_split: SplitFocusDirection, + /// Zoom and unzoom the current split. + zoom_split: void, + /// Reload the configuration. The exact meaning depends on the app runtime /// in use but this usually involves re-reading the configuration file /// and applying any changes. Note that not all changes can be applied at From 519a97b782e5a9b556661f220977d3bfb1b15cb9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 2 Sep 2023 15:15:12 -0700 Subject: [PATCH 2/7] core: add unzoom_split binding --- include/ghostty.h | 2 +- src/Surface.zig | 9 +++++++-- src/apprt/embedded.zig | 6 +++--- src/config.zig | 5 +++++ src/input/Binding.zig | 1 + 5 files changed, 17 insertions(+), 6 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 95de1339e..f5e4da8d9 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -262,7 +262,7 @@ typedef void (*ghostty_runtime_new_tab_cb)(void *, ghostty_surface_config_s); typedef void (*ghostty_runtime_new_window_cb)(void *, ghostty_surface_config_s); typedef void (*ghostty_runtime_close_surface_cb)(void *, bool); typedef void (*ghostty_runtime_focus_split_cb)(void *, ghostty_split_focus_direction_e); -typedef void (*ghostty_runtime_zoom_split_cb)(void *); +typedef void (*ghostty_runtime_zoom_split_cb)(void *, bool); typedef void (*ghostty_runtime_goto_tab_cb)(void *, int32_t); typedef void (*ghostty_runtime_toggle_fullscreen_cb)(void *, ghostty_non_native_fullscreen_e); diff --git a/src/Surface.zig b/src/Surface.zig index cbbf4c5f0..7633fd52e 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2119,9 +2119,14 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !void }, .zoom_split => { - log.warn("ZOOM ZOOM", .{}); if (@hasDecl(apprt.Surface, "zoomSplit")) { - self.rt_surface.zoomSplit(); + self.rt_surface.zoomSplit(true); + } else log.warn("runtime doesn't implement zoomSplit", .{}); + }, + + .unzoom_split => { + if (@hasDecl(apprt.Surface, "zoomSplit")) { + self.rt_surface.zoomSplit(false); } else log.warn("runtime doesn't implement zoomSplit", .{}); }, diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 59d934eb1..60e88c638 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -73,7 +73,7 @@ pub const App = struct { focus_split: ?*const fn (SurfaceUD, input.SplitFocusDirection) callconv(.C) void = null, /// Zoom the current split. - zoom_split: ?*const fn (SurfaceUD) callconv(.C) void = null, + zoom_split: ?*const fn (SurfaceUD, bool) callconv(.C) void = null, /// Goto tab goto_tab: ?*const fn (SurfaceUD, usize) callconv(.C) void = null, @@ -273,13 +273,13 @@ pub const Surface = struct { func(self.opts.userdata, direction); } - pub fn zoomSplit(self: *const Surface) void { + pub fn zoomSplit(self: *const Surface, zoom: bool) void { const func = self.app.opts.zoom_split orelse { log.info("runtime embedder does not support zoom split", .{}); return; }; - func(self.opts.userdata); + func(self.opts.userdata, zoom); } pub fn getContentScale(self: *const Surface) !apprt.ContentScale { diff --git a/src/config.zig b/src/config.zig index c73b288ca..c99845ef8 100644 --- a/src/config.zig +++ b/src/config.zig @@ -688,6 +688,11 @@ pub const Config = struct { .{ .key = .equal, .mods = .{ .super = true, .shift = true } }, .{ .zoom_split = {} }, ); + try result.keybind.set.put( + alloc, + .{ .key = .minus, .mods = .{ .super = true, .shift = true } }, + .{ .unzoom_split = {} }, + ); } return result; diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 7324748be..d146d7b52 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -160,6 +160,7 @@ pub const Action = union(enum) { /// Zoom and unzoom the current split. zoom_split: void, + unzoom_split: void, /// Reload the configuration. The exact meaning depends on the app runtime /// in use but this usually involves re-reading the configuration file From 70bdc21d22bd2d3415bfe78a71f9bb6244772b58 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 2 Sep 2023 15:48:22 -0700 Subject: [PATCH 3/7] 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 } From 4570356e577995578a7120c01837c24d918cf96c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 2 Sep 2023 16:03:51 -0700 Subject: [PATCH 4/7] turn zoom into a toggle rather than an explicit zoom/unzoom --- include/ghostty.h | 4 ++-- macos/Sources/Ghostty/AppState.swift | 11 +++-------- macos/Sources/Ghostty/Ghostty.SplitView.swift | 8 ++------ macos/Sources/Ghostty/Package.swift | 7 ++----- src/Surface.zig | 14 ++++---------- src/apprt/embedded.zig | 10 +++++----- src/config.zig | 18 +++++++----------- src/input/Binding.zig | 5 ++--- 8 files changed, 27 insertions(+), 50 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index f5e4da8d9..8d5c8cf8c 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -262,7 +262,7 @@ typedef void (*ghostty_runtime_new_tab_cb)(void *, ghostty_surface_config_s); typedef void (*ghostty_runtime_new_window_cb)(void *, ghostty_surface_config_s); typedef void (*ghostty_runtime_close_surface_cb)(void *, bool); typedef void (*ghostty_runtime_focus_split_cb)(void *, ghostty_split_focus_direction_e); -typedef void (*ghostty_runtime_zoom_split_cb)(void *, bool); +typedef void (*ghostty_runtime_toggle_split_zoom_cb)(void *); typedef void (*ghostty_runtime_goto_tab_cb)(void *, int32_t); typedef void (*ghostty_runtime_toggle_fullscreen_cb)(void *, ghostty_non_native_fullscreen_e); @@ -279,7 +279,7 @@ typedef struct { ghostty_runtime_new_window_cb new_window_cb; ghostty_runtime_close_surface_cb close_surface_cb; ghostty_runtime_focus_split_cb focus_split_cb; - ghostty_runtime_zoom_split_cb zoom_split_cb; + ghostty_runtime_toggle_split_zoom_cb toggle_split_zoom_cb; ghostty_runtime_goto_tab_cb goto_tab_cb; ghostty_runtime_toggle_fullscreen_cb toggle_fullscreen_cb; } ghostty_runtime_config_s; diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index e55031bc3..2eccd3c2a 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -73,7 +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) }, + toggle_split_zoom_cb: { userdata in AppState.toggleSplitZoom(userdata) }, goto_tab_cb: { userdata, n in AppState.gotoTab(userdata, n: n) }, toggle_fullscreen_cb: { userdata, nonNativeFullscreen in AppState.toggleFullscreen(userdata, nonNativeFullscreen: nonNativeFullscreen) } ) @@ -206,16 +206,11 @@ extension Ghostty { ) } - static func zoomSplit(_ userdata: UnsafeMutableRawPointer?, zoom: Bool) { + static func toggleSplitZoom(_ userdata: UnsafeMutableRawPointer?) { guard let surface = self.surfaceUserdata(from: userdata) else { return } - var name = Notification.didZoomSplit - if (!zoom) { - name = Notification.didZoomResetSplit - } - NotificationCenter.default.post( - name: name, + name: Notification.didToggleSplitZoom, object: surface ) } diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index 0af024dda..4dc9c24df 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -199,14 +199,13 @@ extension Ghostty { 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) { - let pubZoom = center.publisher(for: Notification.didZoomSplit) - ZStack { switch (node) { case .noSplit(let leaf): @@ -248,11 +247,8 @@ extension Ghostty { } .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) } + .onReceive(pubZoom) { onZoomReset(notification: $0) } } } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 35b0dca89..81404fbfb 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -96,11 +96,8 @@ extension Ghostty.Notification { /// 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") + /// Notification sent to toggle split maximize/unmaximize. + static let didToggleSplitZoom = Notification.Name("com.mitchellh.ghostty.didToggleSplitZoom") } // Make the input enum hashable. diff --git a/src/Surface.zig b/src/Surface.zig index 7633fd52e..5531ac986 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2118,16 +2118,10 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !void } else log.warn("runtime doesn't implement gotoSplit", .{}); }, - .zoom_split => { - if (@hasDecl(apprt.Surface, "zoomSplit")) { - self.rt_surface.zoomSplit(true); - } else log.warn("runtime doesn't implement zoomSplit", .{}); - }, - - .unzoom_split => { - if (@hasDecl(apprt.Surface, "zoomSplit")) { - self.rt_surface.zoomSplit(false); - } else log.warn("runtime doesn't implement zoomSplit", .{}); + .toggle_split_zoom => { + if (@hasDecl(apprt.Surface, "toggleSplitZoom")) { + self.rt_surface.toggleSplitZoom(); + } else log.warn("runtime doesn't implement toggleSplitZoom", .{}); }, .toggle_fullscreen => { diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 60e88c638..7d246c940 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -73,7 +73,7 @@ pub const App = struct { focus_split: ?*const fn (SurfaceUD, input.SplitFocusDirection) callconv(.C) void = null, /// Zoom the current split. - zoom_split: ?*const fn (SurfaceUD, bool) callconv(.C) void = null, + toggle_split_zoom: ?*const fn (SurfaceUD) callconv(.C) void = null, /// Goto tab goto_tab: ?*const fn (SurfaceUD, usize) callconv(.C) void = null, @@ -273,13 +273,13 @@ pub const Surface = struct { func(self.opts.userdata, direction); } - pub fn zoomSplit(self: *const Surface, zoom: bool) void { - const func = self.app.opts.zoom_split orelse { - log.info("runtime embedder does not support zoom split", .{}); + pub fn toggleSplitZoom(self: *const Surface) void { + const func = self.app.opts.toggle_split_zoom orelse { + log.info("runtime embedder does not support split zoom", .{}); return; }; - func(self.opts.userdata, zoom); + func(self.opts.userdata); } pub fn getContentScale(self: *const Surface) !apprt.ContentScale { diff --git a/src/config.zig b/src/config.zig index c99845ef8..7c111ca90 100644 --- a/src/config.zig +++ b/src/config.zig @@ -563,6 +563,13 @@ pub const Config = struct { .{ .toggle_fullscreen = {} }, ); + // Toggle zoom a split + try result.keybind.set.put( + alloc, + .{ .key = .enter, .mods = ctrlOrSuper(.{ .shift = true }) }, + .{ .toggle_split_zoom = {} }, + ); + // Mac-specific keyboard bindings. if (comptime builtin.target.isDarwin()) { try result.keybind.set.put( @@ -682,17 +689,6 @@ pub const Config = struct { .{ .key = .right, .mods = .{ .super = true, .alt = true } }, .{ .goto_split = .right }, ); - - try result.keybind.set.put( - alloc, - .{ .key = .equal, .mods = .{ .super = true, .shift = true } }, - .{ .zoom_split = {} }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .minus, .mods = .{ .super = true, .shift = true } }, - .{ .unzoom_split = {} }, - ); } return result; diff --git a/src/input/Binding.zig b/src/input/Binding.zig index d146d7b52..9cbd2f1c4 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -158,9 +158,8 @@ pub const Action = union(enum) { /// Focus on a split in a given direction. goto_split: SplitFocusDirection, - /// Zoom and unzoom the current split. - zoom_split: void, - unzoom_split: void, + /// zoom/unzoom the current split. + toggle_split_zoom: void, /// Reload the configuration. The exact meaning depends on the app runtime /// in use but this usually involves re-reading the configuration file From e2282f1f4d6ac60d6fe50f9af6828c5fcbfa9b8f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 2 Sep 2023 16:33:33 -0700 Subject: [PATCH 5/7] macos: zoomed splits put an emoji in the title bar --- .../Features/Primary Window/PrimaryView.swift | 25 ++++++++++++++++--- macos/Sources/Ghostty/Ghostty.SplitView.swift | 1 + macos/Sources/Ghostty/SurfaceView.swift | 10 ++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Primary Window/PrimaryView.swift b/macos/Sources/Features/Primary Window/PrimaryView.swift index 1aefbdd0e..f52aef9a4 100644 --- a/macos/Sources/Features/Primary Window/PrimaryView.swift +++ b/macos/Sources/Features/Primary Window/PrimaryView.swift @@ -26,6 +26,7 @@ struct PrimaryView: View { @FocusedValue(\.ghosttySurfaceView) private var focusedSurface @FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle + @FocusedValue(\.ghosttySurfaceZoomed) private var zoomedSplit // This is true if this view should be the one to show the quit confirmation. var ownsQuitConfirmation: Bool { @@ -49,6 +50,25 @@ struct PrimaryView: View { return window == firstWindow } + // The title for our window + private var title: String { + var title = "👻" + + if let surfaceTitle = surfaceTitle { + if (surfaceTitle.count > 0) { + title = surfaceTitle + } + } + + if let zoomedSplit = zoomedSplit { + if zoomedSplit { + title = "🔍 " + title + } + } + + return title + } + var body: some View { switch ghostty.readiness { case .loading: @@ -84,12 +104,11 @@ struct PrimaryView: View { .onChange(of: focusedSurface) { newValue in self.focusedSurfaceWrapper.surface = newValue?.surface } - .onChange(of: surfaceTitle) { newValue in + .onChange(of: title) { newValue in // We need to handle this manually because we are using AppKit lifecycle // so navigationTitle no longer works. guard let window = self.window else { return } - guard let title = newValue else { return } - window.title = title + window.title = newValue } .confirmationDialog( "Quit Ghostty?", diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index 4dc9c24df..924b24603 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -33,6 +33,7 @@ extension Ghostty { SurfaceWrapper(surfaceView: surfaceView) } } + .focusedValue(\.ghosttySurfaceZoomed, zoomedSurface != nil) } } } diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 37812893a..118213608 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -710,3 +710,13 @@ extension FocusedValues { } } +extension FocusedValues { + var ghosttySurfaceZoomed: Bool? { + get { self[FocusedGhosttySurfaceZoomed.self] } + set { self[FocusedGhosttySurfaceZoomed.self] = newValue } + } + + struct FocusedGhosttySurfaceZoomed: FocusedValueKey { + typealias Value = Bool + } +} From 76ae039701a02ceb7f8f0e99db917e083aa0c9c9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 2 Sep 2023 16:37:22 -0700 Subject: [PATCH 6/7] macos: new split on zoomed split unzooms --- macos/Sources/Ghostty/Ghostty.SplitView.swift | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index 924b24603..e8ee1bb78 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -248,8 +248,12 @@ extension Ghostty { } .navigationTitle(surfaceTitle ?? "Ghostty") } else { + // On split, we want to reset the zoom state. + let pubSplit = center.publisher(for: Notification.ghosttyNewSplit, object: zoomedSurface!) + ZStack {} .onReceive(pubZoom) { onZoomReset(notification: $0) } + .onReceive(pubSplit) { onZoomReset(notification: $0) } } } @@ -281,7 +285,19 @@ extension Ghostty { // 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) } + DispatchQueue.main.async { + Ghostty.moveFocus(to: surfaceView) + + // If the notification is a new split 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.ghosttyNewSplit) { + // We have to wait ANOTHER tick since we just established. + DispatchQueue.main.async { + NotificationCenter.default.post(notification) + } + } + } } } From e657a0f6719d923d9a7e7a6b3fc2dc83792d30fb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 2 Sep 2023 16:47:48 -0700 Subject: [PATCH 7/7] macos: close and refocus split work while zoomed --- macos/Sources/Ghostty/Ghostty.SplitView.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index e8ee1bb78..9f9d18c98 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -248,12 +248,16 @@ extension Ghostty { } .navigationTitle(surfaceTitle ?? "Ghostty") } else { - // On split, we want to reset the zoom state. + // 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) } } } @@ -288,10 +292,10 @@ extension Ghostty { DispatchQueue.main.async { Ghostty.moveFocus(to: surfaceView) - // If the notification is a new split notification, we want to re-publish + // 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.ghosttyNewSplit) { + if (notification.name != Notification.didToggleSplitZoom) { // We have to wait ANOTHER tick since we just established. DispatchQueue.main.async { NotificationCenter.default.post(notification)