diff --git a/include/ghostty.h b/include/ghostty.h index ea62ca74b..66f001e06 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -32,6 +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_split_cb)(void *, ghostty_split_focus_direction_e); typedef struct { void *userdata; @@ -41,6 +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_split_cb focus_split_cb; } ghostty_runtime_config_s; typedef struct { @@ -54,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, @@ -254,6 +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(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 59f68785b..3ab7e25bb 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_split_cb: { userdata, direction in + AppState.focusSplit(userdata, direction: ghostty_split_focus_direction_e(UInt32(direction))) } ) // Create the ghostty app. @@ -92,6 +94,10 @@ extension Ghostty { ghostty_surface_split(surface, direction) } + func splitMoveFocus(surface: ghostty_surface_t, direction: SplitFocusDirection) { + ghostty_surface_split_focus(surface, direction.toNative()) + } + // MARK: Ghostty Callbacks static func newSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_direction_e) { @@ -106,6 +112,18 @@ extension Ghostty { NotificationCenter.default.post(name: Notification.ghosttyCloseSurface, object: surface) } + 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: splitDirection, + ] + ) + } + 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..4cff3217c 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -73,6 +73,49 @@ 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() + + /// 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 + } + } } /// The root of a split tree. This sets up the initial SplitNode state and renders. There is only ever @@ -93,18 +136,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 +174,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 @@ -123,11 +184,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) { @@ -158,22 +222,31 @@ 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 } + 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 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 } @@ -194,6 +267,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 @@ -202,23 +276,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) + } }) } } @@ -227,18 +319,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 + ) } } } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index b673549e5..ea0b7fd50 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -1,4 +1,77 @@ +import SwiftUI +import GhosttyKit + 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, top, bottom, left, right + + /// 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 + + 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 + } + } + + func toNative() -> ghostty_split_focus_direction_e { + switch (self) { + case .previous: + return GHOSTTY_SPLIT_FOCUS_PREVIOUS + + 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 + } + } + } +} + +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/macos/Sources/GhosttyApp.swift b/macos/Sources/GhosttyApp.swift index 4a14e6fe6..7c12e8f4e 100644 --- a/macos/Sources/GhosttyApp.swift +++ b/macos/Sources/GhosttyApp.swift @@ -37,6 +37,26 @@ 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") { 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() + } } Settings { @@ -76,6 +96,12 @@ struct GhosttyApp: App { guard let surface = surfaceView.surface else { return } ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DOWN) } + + func splitMoveFocus(direction: Ghostty.SplitFocusDirection) { + guard let surfaceView = focusedSurface else { return } + guard let surface = surfaceView.surface else { return } + ghostty.splitMoveFocus(surface: surface, direction: direction) + } } class AppDelegate: NSObject, NSApplicationDelegate { diff --git a/src/Surface.zig b/src/Surface.zig index f2452b85b..5c1d51fe6 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -947,6 +947,12 @@ pub fn keyCallback( } else log.warn("runtime doesn't implement newSplit", .{}); }, + .goto_split => |direction| { + if (@hasDecl(apprt.Surface, "gotoSplit")) { + self.rt_surface.gotoSplit(direction); + } else log.warn("runtime doesn't implement gotoSplit", .{}); + }, + .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..063743b44 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -51,6 +51,9 @@ 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_split: ?*const fn (SurfaceUD, input.SplitFocusDirection) callconv(.C) void = null, }; core_app: *CoreApp, @@ -166,13 +169,22 @@ 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 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, direction); + } + pub fn getContentScale(self: *const Surface) !apprt.ContentScale { return self.content_scale; } @@ -481,4 +493,9 @@ 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(ptr: *Surface, direction: input.SplitFocusDirection) void { + ptr.gotoSplit(direction); + } }; diff --git a/src/config.zig b/src/config.zig index 2b6b3766f..a3c88c1e0 100644 --- a/src/config.zig +++ b/src/config.zig @@ -311,6 +311,36 @@ 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 } }, + .{ .goto_split = .previous }, + ); + try result.keybind.set.put( + alloc, + .{ .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); 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 3eeb941a6..cac34826c 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -184,6 +184,9 @@ pub const Action = union(enum) { /// in the direction given. new_split: SplitDirection, + /// 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. close_surface: void, @@ -207,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.