From b5826911851304193a639cc692993eda31ca8fbc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 11 Mar 2023 16:22:04 -0800 Subject: [PATCH 1/5] macos: hook up all the bindings so we're ready to handle focus event --- include/ghostty.h | 4 ++++ macos/Sources/Ghostty/AppState.swift | 15 +++++++++++- macos/Sources/Ghostty/Ghostty.SplitView.swift | 14 +++++++++-- macos/Sources/Ghostty/Package.swift | 24 +++++++++++++++++++ macos/Sources/Ghostty/SurfaceView.swift | 12 ---------- src/Surface.zig | 12 ++++++++++ src/apprt/embedded.zig | 24 ++++++++++++++++++- src/config.zig | 10 ++++++++ src/input/Binding.zig | 6 +++++ 9 files changed, 105 insertions(+), 16 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index ea62ca74b..329ff6171 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -32,6 +32,8 @@ 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 void (*ghostty_runtime_focus_next_split_cb)(void *); +typedef void (*ghostty_runtime_focus_previous_split_cb)(void *); typedef struct { void *userdata; @@ -41,6 +43,8 @@ typedef struct { 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_focus_next_split_cb focus_next_split_cb; + ghostty_runtime_focus_previous_split_cb focus_previous_split_cb; } ghostty_runtime_config_s; typedef struct { diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index 59f68785b..736ca915d 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -58,7 +58,9 @@ extension Ghostty { 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) } + close_surface_cb: { userdata in AppState.closeSurface(userdata) }, + focus_next_split_cb: { userdata in AppState.focusSplit(userdata, direction: .next) }, + focus_previous_split_cb: { userdata in AppState.focusSplit(userdata, direction: .previous) } ) // Create the ghostty app. @@ -106,6 +108,17 @@ extension Ghostty { NotificationCenter.default.post(name: Notification.ghosttyCloseSurface, object: surface) } + static func focusSplit(_ userdata: UnsafeMutableRawPointer?, direction: SplitFocusDirection) { + guard let surface = self.surfaceUserdata(from: userdata) else { return } + NotificationCenter.default.post( + name: Notification.ghosttyFocusSplit, + object: surface, + userInfo: [ + Notification.SplitDirectionKey: direction, + ] + ) + } + 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 } diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index 8dbbfd9bc..7e417e5c0 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -123,11 +123,14 @@ extension Ghostty { @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) + 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) SurfaceWrapper(surfaceView: leaf.surface) .onReceive(pub) { onNewSplit(notification: $0) } .onReceive(pubClose) { _ in requestClose = true } + .onReceive(pubFocus) { onMoveFocus(notification: $0) } } private func onNewSplit(notification: SwiftUI.Notification) { @@ -162,6 +165,13 @@ extension Ghostty { Self.fixFocus(container.bottomRight, previous: node) } + 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 } + print("MOVE FOCUS: \(direction)") + } + /// 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 diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index b673549e5..603dec3e2 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -1,4 +1,28 @@ +import SwiftUI + struct Ghostty { // All the notifications that will be emitted will be put here. struct Notification {} } + +// MARK: Surface Notifications + +extension Ghostty { + /// An enum that is used for the directions that a split focus event can change. + enum SplitFocusDirection { + case previous, next + } +} + +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") + + /// Focus previous/next split. Has a SplitFocusDirection in the userinfo. + static let ghosttyFocusSplit = Notification.Name("com.mitchellh.ghostty.focusSplit") + static let SplitDirectionKey = ghosttyFocusSplit.rawValue +} diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 2f76fce11..659de9da7 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -509,18 +509,6 @@ extension Ghostty { 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 diff --git a/src/Surface.zig b/src/Surface.zig index f2452b85b..e9c007a96 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -947,6 +947,18 @@ pub fn keyCallback( } else log.warn("runtime doesn't implement newSplit", .{}); }, + .next_split => { + if (@hasDecl(apprt.Surface, "gotoNextSplit")) { + self.rt_surface.gotoNextSplit(); + } else log.warn("runtime doesn't implement gotoNextSplit", .{}); + }, + + .previous_split => { + if (@hasDecl(apprt.Surface, "gotoPreviousSplit")) { + self.rt_surface.gotoPreviousSplit(); + } else log.warn("runtime doesn't implement gotoPreviousSplit", .{}); + }, + .close_surface => { if (@hasDecl(apprt.Surface, "closeSurface")) { try self.rt_surface.closeSurface(); diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 7ed69a514..bff1bb6cb 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -51,6 +51,10 @@ pub const App = struct { /// Close the current surface given by this function. close_surface: ?*const fn (SurfaceUD) callconv(.C) void = null, + + /// Focus the previous/next split (if any). + focus_next_split: ?*const fn (SurfaceUD) callconv(.C) void = null, + focus_previous_split: ?*const fn (SurfaceUD) callconv(.C) void = null, }; core_app: *CoreApp, @@ -166,7 +170,25 @@ pub const Surface = struct { pub fn closeSurface(self: *const Surface) !void { const func = self.app.opts.close_surface orelse { - log.info("runtime embedder does not closing a surface", .{}); + log.info("runtime embedder does not support closing a surface", .{}); + return; + }; + + func(self.opts.userdata); + } + + pub fn gotoNextSplit(self: *const Surface) void { + const func = self.app.opts.focus_next_split orelse { + log.info("runtime embedder does not support focus next split", .{}); + return; + }; + + func(self.opts.userdata); + } + + pub fn gotoPreviousSplit(self: *const Surface) void { + const func = self.app.opts.focus_previous_split orelse { + log.info("runtime embedder does not support focus previous split", .{}); return; }; diff --git a/src/config.zig b/src/config.zig index 2b6b3766f..193bd69a5 100644 --- a/src/config.zig +++ b/src/config.zig @@ -311,6 +311,16 @@ pub const Config = struct { .{ .key = .d, .mods = .{ .super = true, .shift = true } }, .{ .new_split = .down }, ); + try result.keybind.set.put( + alloc, + .{ .key = .left_bracket, .mods = .{ .super = true } }, + .{ .previous_split = {} }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .right_bracket, .mods = .{ .super = true } }, + .{ .next_split = {} }, + ); { // Cmd+N for goto tab N const start = @enumToInt(inputpkg.Key.one); diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 3eeb941a6..549e7a615 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -184,6 +184,12 @@ pub const Action = union(enum) { /// in the direction given. new_split: SplitDirection, + /// Go to the previous split. + previous_split: void, + + /// Go to the next split. + next_split: void, + /// Close the current "surface", whether that is a window, tab, split, /// etc. This only closes ONE surface. close_surface: void, From 4a5d92056f334976c96cef1d1cd4ef4c7caa9cc0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 11 Mar 2023 17:02:01 -0800 Subject: [PATCH 2/5] macos: hook up previous/next split focus --- macos/Sources/Ghostty/Ghostty.SplitView.swift | 154 ++++++++++++++---- 1 file changed, 122 insertions(+), 32 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index 7e417e5c0..b6020e18b 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -73,6 +73,34 @@ extension Ghostty { self.bottomRight = .noSplit(.init(app)) } } + + /// 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() + + /// 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 + } + } } /// The root of a split tree. This sets up the initial SplitNode state and renders. There is only ever @@ -93,18 +121,33 @@ extension Ghostty { 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() - } + TerminalSplitLeaf( + leaf: leaf, + neighbors: .empty, + 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) + TerminalSplitContainer( + direction: .horizontal, + neighbors: .empty, + node: $node, + container: container + ) case .vertical(let container): - TerminalSplitContainer(direction: .vertical, node: $node, container: container) + TerminalSplitContainer( + direction: .vertical, + neighbors: .empty, + node: $node, + container: container + ) } } .navigationTitle(surfaceTitle ?? "Ghostty") @@ -116,6 +159,9 @@ extension Ghostty { /// 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 @@ -161,29 +207,38 @@ extension Ghostty { node = .vertical(container) } - // See fixFocus comment, we have to run this whenever split changes. - Self.fixFocus(container.bottomRight, previous: node) + // See moveFocus comment, we have to run this whenever split changes. + Self.moveFocus(container.bottomRight, previous: node) } + /// 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 } - print("MOVE FOCUS: \(direction)") + switch (direction) { + case .previous: + guard let next = neighbors.previous else { return } + Self.moveFocus(next, previous: node) + + case .next: + guard let next = neighbors.next 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 fixFocus(_ target: SplitNode, previous: SplitNode) { + 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.fixFocus(target, previous: previous) + self.moveFocus(target, previous: previous) return } @@ -204,6 +259,7 @@ extension Ghostty { /// 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 @@ -212,23 +268,41 @@ extension Ghostty { 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) - } + 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 } + + // When closing the topLeft, our parent becomes the bottomRight. + node = container.bottomRight + TerminalSplitLeaf.moveFocus(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) - } + 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 } + + // When closing the bottomRight, our parent becomes the topLeft. + node = container.topLeft + TerminalSplitLeaf.moveFocus(node, previous: container.bottomRight) + } }) } } @@ -237,18 +311,34 @@ extension Ghostty { /// 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, node: $node, requestClose: $requestClose) + TerminalSplitLeaf( + leaf: leaf, + neighbors: neighbors, + node: $node, + requestClose: $requestClose + ) case .horizontal(let container): - TerminalSplitContainer(direction: .horizontal, node: $node, container: container) + TerminalSplitContainer( + direction: .horizontal, + neighbors: neighbors, + node: $node, + container: container + ) case .vertical(let container): - TerminalSplitContainer(direction: .vertical, node: $node, container: container) + TerminalSplitContainer( + direction: .vertical, + neighbors: neighbors, + node: $node, + container: container + ) } } } From 18cf0dc9fc1fccd28e5687352d5be8a7c9e9a354 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 11 Mar 2023 17:25:39 -0800 Subject: [PATCH 3/5] macos: put next/prev split focus into menu --- include/ghostty.h | 2 ++ macos/Sources/Ghostty/AppState.swift | 10 ++++++++++ macos/Sources/GhosttyApp.swift | 19 +++++++++++++++++++ src/apprt/embedded.zig | 10 ++++++++++ 4 files changed, 41 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index 329ff6171..e27ed6f43 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -258,6 +258,8 @@ 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); +void ghostty_surface_split_focus_previous(ghostty_surface_t); +void ghostty_surface_split_focus_next(ghostty_surface_t); #ifdef __cplusplus } diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index 736ca915d..3d4b5f009 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -94,6 +94,16 @@ extension Ghostty { ghostty_surface_split(surface, direction) } + func splitMoveFocus(surface: ghostty_surface_t, direction: SplitFocusDirection) { + switch (direction) { + case .previous: + ghostty_surface_split_focus_previous(surface) + + case .next: + ghostty_surface_split_focus_next(surface) + } + } + // MARK: Ghostty Callbacks static func newSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_direction_e) { diff --git a/macos/Sources/GhosttyApp.swift b/macos/Sources/GhosttyApp.swift index 4a14e6fe6..8fb421e99 100644 --- a/macos/Sources/GhosttyApp.swift +++ b/macos/Sources/GhosttyApp.swift @@ -37,6 +37,13 @@ struct GhosttyApp: App { Button("Close", action: close).keyboardShortcut("w", modifiers: [.command]) Button("Close Window", action: Self.closeWindow).keyboardShortcut("w", modifiers: [.command, .shift]) } + + CommandGroup(before: .windowArrangement) { + Divider() + Button("Select Previous Split", action: splitMoveFocusPrevious).keyboardShortcut("[", modifiers: .command) + Button("Select Next Split", action: splitMoveFocusNext).keyboardShortcut("]", modifiers: .command) + Divider() + } } Settings { @@ -76,6 +83,18 @@ struct GhosttyApp: App { guard let surface = surfaceView.surface else { return } ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DOWN) } + + func splitMoveFocusPrevious() { + guard let surfaceView = focusedSurface else { return } + guard let surface = surfaceView.surface else { return } + ghostty.splitMoveFocus(surface: surface, direction: .previous) + } + + func splitMoveFocusNext() { + guard let surfaceView = focusedSurface else { return } + guard let surface = surfaceView.surface else { return } + ghostty.splitMoveFocus(surface: surface, direction: .next) + } } class AppDelegate: NSObject, NSApplicationDelegate { diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index bff1bb6cb..a8ffafbd6 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -503,4 +503,14 @@ pub const CAPI = struct { export fn ghostty_surface_split(ptr: *Surface, direction: input.SplitDirection) void { ptr.newSplit(direction) catch {}; } + + /// Focus on the next split (if any). + export fn ghostty_surface_split_focus_next(ptr: *Surface) void { + ptr.gotoNextSplit(); + } + + /// Focus on the previous split (if any). + export fn ghostty_surface_split_focus_previous(ptr: *Surface) void { + ptr.gotoPreviousSplit(); + } }; From 04c38ef3b0e5f8ca8cc514bf9de6217b0266091a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 11 Mar 2023 17:44:00 -0800 Subject: [PATCH 4/5] macos: change focus callback to use an enum so we can get other dirs --- include/ghostty.h | 18 +++++++++++------ macos/Sources/Ghostty/AppState.swift | 17 ++++++---------- macos/Sources/Ghostty/Package.swift | 25 ++++++++++++++++++++++++ src/Surface.zig | 14 ++++---------- src/apprt/embedded.zig | 29 +++++++--------------------- src/config.zig | 4 ++-- src/input.zig | 1 + src/input/Binding.zig | 18 ++++++++++++----- 8 files changed, 70 insertions(+), 56 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index e27ed6f43..66f001e06 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -32,8 +32,7 @@ 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 void (*ghostty_runtime_focus_next_split_cb)(void *); -typedef void (*ghostty_runtime_focus_previous_split_cb)(void *); +typedef void (*ghostty_runtime_focus_split_cb)(void *, ghostty_split_focus_direction_e); typedef struct { void *userdata; @@ -43,8 +42,7 @@ typedef struct { 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_focus_next_split_cb focus_next_split_cb; - ghostty_runtime_focus_previous_split_cb focus_previous_split_cb; + ghostty_runtime_focus_split_cb focus_split_cb; } ghostty_runtime_config_s; typedef struct { @@ -58,6 +56,15 @@ typedef enum { GHOSTTY_SPLIT_DOWN } ghostty_split_direction_e; +typedef enum { + GHOSTTY_SPLIT_FOCUS_PREVIOUS, + GHOSTTY_SPLIT_FOCUS_NEXT, + GHOSTTY_SPLIT_FOCUS_TOP, + GHOSTTY_SPLIT_FOCUS_LEFT, + GHOSTTY_SPLIT_FOCUS_BOTTOM, + GHOSTTY_SPLIT_FOCUS_RIGHT, +} ghostty_split_focus_direction_e; + typedef enum { GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_PRESS, @@ -258,8 +265,7 @@ 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); -void ghostty_surface_split_focus_previous(ghostty_surface_t); -void ghostty_surface_split_focus_next(ghostty_surface_t); +void ghostty_surface_split_focus(ghostty_surface_t, ghostty_split_focus_direction_e); #ifdef __cplusplus } diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index 3d4b5f009..3ab7e25bb 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -59,8 +59,8 @@ extension Ghostty { 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) }, - focus_next_split_cb: { userdata in AppState.focusSplit(userdata, direction: .next) }, - focus_previous_split_cb: { userdata in AppState.focusSplit(userdata, direction: .previous) } + focus_split_cb: { userdata, direction in + AppState.focusSplit(userdata, direction: ghostty_split_focus_direction_e(UInt32(direction))) } ) // Create the ghostty app. @@ -95,13 +95,7 @@ extension Ghostty { } func splitMoveFocus(surface: ghostty_surface_t, direction: SplitFocusDirection) { - switch (direction) { - case .previous: - ghostty_surface_split_focus_previous(surface) - - case .next: - ghostty_surface_split_focus_next(surface) - } + ghostty_surface_split_focus(surface, direction.toNative()) } // MARK: Ghostty Callbacks @@ -118,13 +112,14 @@ extension Ghostty { NotificationCenter.default.post(name: Notification.ghosttyCloseSurface, object: surface) } - static func focusSplit(_ userdata: UnsafeMutableRawPointer?, direction: SplitFocusDirection) { + static func focusSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_focus_direction_e) { guard let surface = self.surfaceUserdata(from: userdata) else { return } + guard let splitDirection = SplitFocusDirection.from(direction: direction) else { return } NotificationCenter.default.post( name: Notification.ghosttyFocusSplit, object: surface, userInfo: [ - Notification.SplitDirectionKey: direction, + Notification.SplitDirectionKey: splitDirection, ] ) } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 603dec3e2..92f28b36a 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -1,4 +1,5 @@ import SwiftUI +import GhosttyKit struct Ghostty { // All the notifications that will be emitted will be put here. @@ -11,6 +12,30 @@ extension Ghostty { /// An enum that is used for the directions that a split focus event can change. enum SplitFocusDirection { case previous, next + + /// Initialize from a Ghostty API enum. + static func from(direction: ghostty_split_focus_direction_e) -> Self? { + switch (direction) { + case GHOSTTY_SPLIT_FOCUS_PREVIOUS: + return .previous + + case GHOSTTY_SPLIT_FOCUS_NEXT: + return .next + + default: + return nil + } + } + + func toNative() -> ghostty_split_focus_direction_e { + switch (self) { + case .previous: + return GHOSTTY_SPLIT_FOCUS_PREVIOUS + + case .next: + return GHOSTTY_SPLIT_FOCUS_NEXT + } + } } } diff --git a/src/Surface.zig b/src/Surface.zig index e9c007a96..5c1d51fe6 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -947,16 +947,10 @@ pub fn keyCallback( } else log.warn("runtime doesn't implement newSplit", .{}); }, - .next_split => { - if (@hasDecl(apprt.Surface, "gotoNextSplit")) { - self.rt_surface.gotoNextSplit(); - } else log.warn("runtime doesn't implement gotoNextSplit", .{}); - }, - - .previous_split => { - if (@hasDecl(apprt.Surface, "gotoPreviousSplit")) { - self.rt_surface.gotoPreviousSplit(); - } else log.warn("runtime doesn't implement gotoPreviousSplit", .{}); + .goto_split => |direction| { + if (@hasDecl(apprt.Surface, "gotoSplit")) { + self.rt_surface.gotoSplit(direction); + } else log.warn("runtime doesn't implement gotoSplit", .{}); }, .close_surface => { diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index a8ffafbd6..063743b44 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -53,8 +53,7 @@ pub const App = struct { close_surface: ?*const fn (SurfaceUD) callconv(.C) void = null, /// Focus the previous/next split (if any). - focus_next_split: ?*const fn (SurfaceUD) callconv(.C) void = null, - focus_previous_split: ?*const fn (SurfaceUD) callconv(.C) void = null, + focus_split: ?*const fn (SurfaceUD, input.SplitFocusDirection) callconv(.C) void = null, }; core_app: *CoreApp, @@ -177,22 +176,13 @@ pub const Surface = struct { func(self.opts.userdata); } - pub fn gotoNextSplit(self: *const Surface) void { - const func = self.app.opts.focus_next_split orelse { - log.info("runtime embedder does not support focus next split", .{}); + pub fn gotoSplit(self: *const Surface, direction: input.SplitFocusDirection) void { + const func = self.app.opts.focus_split orelse { + log.info("runtime embedder does not support focus split", .{}); return; }; - func(self.opts.userdata); - } - - pub fn gotoPreviousSplit(self: *const Surface) void { - const func = self.app.opts.focus_previous_split orelse { - log.info("runtime embedder does not support focus previous split", .{}); - return; - }; - - func(self.opts.userdata); + func(self.opts.userdata, direction); } pub fn getContentScale(self: *const Surface) !apprt.ContentScale { @@ -505,12 +495,7 @@ pub const CAPI = struct { } /// Focus on the next split (if any). - export fn ghostty_surface_split_focus_next(ptr: *Surface) void { - ptr.gotoNextSplit(); - } - - /// Focus on the previous split (if any). - export fn ghostty_surface_split_focus_previous(ptr: *Surface) void { - ptr.gotoPreviousSplit(); + export fn ghostty_surface_split_focus(ptr: *Surface, direction: input.SplitFocusDirection) void { + ptr.gotoSplit(direction); } }; diff --git a/src/config.zig b/src/config.zig index 193bd69a5..3df202cd4 100644 --- a/src/config.zig +++ b/src/config.zig @@ -314,12 +314,12 @@ pub const Config = struct { try result.keybind.set.put( alloc, .{ .key = .left_bracket, .mods = .{ .super = true } }, - .{ .previous_split = {} }, + .{ .goto_split = .previous }, ); try result.keybind.set.put( alloc, .{ .key = .right_bracket, .mods = .{ .super = true } }, - .{ .next_split = {} }, + .{ .goto_split = .next }, ); { // Cmd+N for goto tab N diff --git a/src/input.zig b/src/input.zig index 2bdcbe86a..53306eb26 100644 --- a/src/input.zig +++ b/src/input.zig @@ -4,6 +4,7 @@ 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; +pub const SplitFocusDirection = Binding.Action.SplitFocusDirection; test { std.testing.refAllDecls(@This()); diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 549e7a615..cac34826c 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -184,11 +184,8 @@ pub const Action = union(enum) { /// in the direction given. new_split: SplitDirection, - /// Go to the previous split. - previous_split: void, - - /// Go to the next split. - next_split: void, + /// Focus on a split in a given direction. + goto_split: SplitFocusDirection, /// Close the current "surface", whether that is a window, tab, split, /// etc. This only closes ONE surface. @@ -213,6 +210,17 @@ pub const Action = union(enum) { // Note: we don't support top or left yet }; + + // Extern because it is used in the embedded runtime ABI. + pub const SplitFocusDirection = enum(c_int) { + previous, + next, + + top, + left, + bottom, + right, + }; }; /// Trigger is the associated key state that can trigger an action. From 3976da8149544362a8210e5fdff5563c0dd39c72 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 11 Mar 2023 17:55:31 -0800 Subject: [PATCH 5/5] macos: navigate splits directionally --- macos/Sources/Ghostty/Ghostty.SplitView.swift | 26 +++++++++++------- macos/Sources/Ghostty/Package.swift | 26 +++++++++++++++++- macos/Sources/GhosttyApp.swift | 27 ++++++++++++------- src/config.zig | 20 ++++++++++++++ 4 files changed, 79 insertions(+), 20 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index b6020e18b..4cff3217c 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -92,6 +92,21 @@ extension Ghostty { /// 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 @@ -216,15 +231,8 @@ extension Ghostty { // Determine our desired direction guard let directionAny = notification.userInfo?[Notification.SplitDirectionKey] else { return } guard let direction = directionAny as? SplitFocusDirection else { return } - switch (direction) { - case .previous: - guard let next = neighbors.previous else { return } - Self.moveFocus(next, previous: node) - - case .next: - guard let next = neighbors.next else { return } - Self.moveFocus(next, previous: node) - } + 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 diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 92f28b36a..ea0b7fd50 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -11,7 +11,7 @@ struct Ghostty { extension Ghostty { /// An enum that is used for the directions that a split focus event can change. enum SplitFocusDirection { - case previous, next + case previous, next, top, bottom, left, right /// Initialize from a Ghostty API enum. static func from(direction: ghostty_split_focus_direction_e) -> Self? { @@ -22,6 +22,18 @@ extension Ghostty { case GHOSTTY_SPLIT_FOCUS_NEXT: return .next + case GHOSTTY_SPLIT_FOCUS_TOP: + return .top + + case GHOSTTY_SPLIT_FOCUS_BOTTOM: + return .bottom + + case GHOSTTY_SPLIT_FOCUS_LEFT: + return .left + + case GHOSTTY_SPLIT_FOCUS_RIGHT: + return .right + default: return nil } @@ -34,6 +46,18 @@ extension Ghostty { case .next: return GHOSTTY_SPLIT_FOCUS_NEXT + + case .top: + return GHOSTTY_SPLIT_FOCUS_TOP + + case .bottom: + return GHOSTTY_SPLIT_FOCUS_BOTTOM + + case .left: + return GHOSTTY_SPLIT_FOCUS_LEFT + + case .right: + return GHOSTTY_SPLIT_FOCUS_RIGHT } } } diff --git a/macos/Sources/GhosttyApp.swift b/macos/Sources/GhosttyApp.swift index 8fb421e99..7c12e8f4e 100644 --- a/macos/Sources/GhosttyApp.swift +++ b/macos/Sources/GhosttyApp.swift @@ -40,8 +40,21 @@ struct GhosttyApp: App { CommandGroup(before: .windowArrangement) { Divider() - Button("Select Previous Split", action: splitMoveFocusPrevious).keyboardShortcut("[", modifiers: .command) - Button("Select Next Split", action: splitMoveFocusNext).keyboardShortcut("]", modifiers: .command) + Button("Select Previous Split") { splitMoveFocus(direction: .previous) } + .keyboardShortcut("[", modifiers: .command) + Button("Select Next Split") { splitMoveFocus(direction: .next) } + .keyboardShortcut("]", modifiers: .command) + Menu("Select Split") { + Button("Select Split Above") { splitMoveFocus(direction: .top) } + .keyboardShortcut(.upArrow, modifiers: [.command, .option]) + Button("Select Split Below") { splitMoveFocus(direction: .bottom) } + .keyboardShortcut(.downArrow, modifiers: [.command, .option]) + Button("Select Split Left") { splitMoveFocus(direction: .left) } + .keyboardShortcut(.leftArrow, modifiers: [.command, .option]) + Button("Select Split Right") { splitMoveFocus(direction: .right)} + .keyboardShortcut(.rightArrow, modifiers: [.command, .option]) + } + Divider() } } @@ -84,16 +97,10 @@ struct GhosttyApp: App { ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DOWN) } - func splitMoveFocusPrevious() { + func splitMoveFocus(direction: Ghostty.SplitFocusDirection) { guard let surfaceView = focusedSurface else { return } guard let surface = surfaceView.surface else { return } - ghostty.splitMoveFocus(surface: surface, direction: .previous) - } - - func splitMoveFocusNext() { - guard let surfaceView = focusedSurface else { return } - guard let surface = surfaceView.surface else { return } - ghostty.splitMoveFocus(surface: surface, direction: .next) + ghostty.splitMoveFocus(surface: surface, direction: direction) } } diff --git a/src/config.zig b/src/config.zig index 3df202cd4..a3c88c1e0 100644 --- a/src/config.zig +++ b/src/config.zig @@ -321,6 +321,26 @@ pub const Config = struct { .{ .key = .right_bracket, .mods = .{ .super = true } }, .{ .goto_split = .next }, ); + try result.keybind.set.put( + alloc, + .{ .key = .up, .mods = .{ .super = true, .alt = true } }, + .{ .goto_split = .top }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .down, .mods = .{ .super = true, .alt = true } }, + .{ .goto_split = .bottom }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .left, .mods = .{ .super = true, .alt = true } }, + .{ .goto_split = .left }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .right, .mods = .{ .super = true, .alt = true } }, + .{ .goto_split = .right }, + ); { // Cmd+N for goto tab N const start = @enumToInt(inputpkg.Key.one);