diff --git a/include/ghostty.h b/include/ghostty.h index bbb17eed1..4bb31d655 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -49,6 +49,13 @@ typedef enum { GHOSTTY_SPLIT_FOCUS_RIGHT, } ghostty_split_focus_direction_e; +typedef enum { + GHOSTTY_SPLIT_RESIZE_UP, + GHOSTTY_SPLIT_RESIZE_DOWN, + GHOSTTY_SPLIT_RESIZE_LEFT, + GHOSTTY_SPLIT_RESIZE_RIGHT, +} ghostty_split_resize_direction_e; + typedef enum { GHOSTTY_INSPECTOR_TOGGLE, GHOSTTY_INSPECTOR_SHOW, @@ -333,6 +340,7 @@ typedef void (*ghostty_runtime_new_window_cb)(void *, ghostty_surface_config_s); typedef void (*ghostty_runtime_control_inspector_cb)(void *, ghostty_inspector_mode_e); 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_resize_split_cb)(void *, ghostty_split_resize_direction_e, uint16_t); 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); @@ -357,6 +365,7 @@ typedef struct { ghostty_runtime_control_inspector_cb control_inspector_cb; ghostty_runtime_close_surface_cb close_surface_cb; ghostty_runtime_focus_split_cb focus_split_cb; + ghostty_runtime_resize_split_cb resize_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; @@ -412,6 +421,7 @@ 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); +void ghostty_surface_split_resize(ghostty_surface_t, ghostty_split_resize_direction_e, uint16_t); bool ghostty_surface_binding_action(ghostty_surface_t, const char *, uintptr_t); void ghostty_surface_complete_clipboard_request(ghostty_surface_t, const char *, void *, bool); diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift index 9a06c81f2..c57055ad1 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -38,6 +38,11 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp @IBOutlet private var menuResetFontSize: NSMenuItem? @IBOutlet private var menuTerminalInspector: NSMenuItem? + @IBOutlet private var menuMoveSplitDividerUp: NSMenuItem? + @IBOutlet private var menuMoveSplitDividerDown: NSMenuItem? + @IBOutlet private var menuMoveSplitDividerLeft: NSMenuItem? + @IBOutlet private var menuMoveSplitDividerRight: NSMenuItem? + /// The dock menu private var dockMenu: NSMenu = NSMenu() @@ -206,6 +211,10 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp syncMenuShortcut(action: "goto_split:bottom", menuItem: self.menuSelectSplitBelow) syncMenuShortcut(action: "goto_split:left", menuItem: self.menuSelectSplitLeft) syncMenuShortcut(action: "goto_split:right", menuItem: self.menuSelectSplitRight) + syncMenuShortcut(action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp) + syncMenuShortcut(action: "resize_split:down,10", menuItem: self.menuMoveSplitDividerDown) + syncMenuShortcut(action: "resize_split:right,10", menuItem: self.menuMoveSplitDividerRight) + syncMenuShortcut(action: "resize_split:left,10", menuItem: self.menuMoveSplitDividerLeft) syncMenuShortcut(action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize) syncMenuShortcut(action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 56bc5d92e..e95adb0ab 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -270,7 +270,27 @@ class TerminalController: NSWindowController, NSWindowDelegate, @IBAction func splitMoveFocusRight(_ sender: Any) { splitMoveFocus(direction: .right) } - + + @IBAction func moveSplitDividerUp(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.splitResize(surface: surface, direction: .up, amount: 10) + } + + @IBAction func moveSplitDividerDown(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.splitResize(surface: surface, direction: .down, amount: 10) + } + + @IBAction func moveSplitDividerLeft(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.splitResize(surface: surface, direction: .left, amount: 10) + } + + @IBAction func moveSplitDividerRight(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.splitResize(surface: surface, direction: .right, amount: 10) + } + private func splitMoveFocus(direction: Ghostty.SplitFocusDirection) { guard let surface = focusedSurface?.surface else { return } ghostty.splitMoveFocus(surface: surface, direction: direction) diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 42d15b38f..6da810690 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -84,6 +84,7 @@ struct TerminalView: View { } Ghostty.TerminalSplit(node: $viewModel.surfaceTree) + .environmentObject(ghostty) .ghosttyApp(ghostty.app!) .ghosttyConfig(ghostty.config!) .focused($focused) diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index 1cac36ae2..d966d6900 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -149,6 +149,8 @@ extension Ghostty { control_inspector_cb: { userdata, mode in AppState.controlInspector(userdata, mode: mode) }, close_surface_cb: { userdata, processAlive in AppState.closeSurface(userdata, processAlive: processAlive) }, focus_split_cb: { userdata, direction in AppState.focusSplit(userdata, direction: direction) }, + resize_split_cb: { userdata, direction, amount in + AppState.resizeSplit(userdata, direction: direction, amount: amount) }, 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) }, @@ -283,6 +285,10 @@ extension Ghostty { ghostty_surface_split_focus(surface, direction.toNative()) } + func splitResize(surface: ghostty_surface_t, direction: SplitResizeDirection, amount: UInt16) { + ghostty_surface_split_resize(surface, direction.toNative(), amount) + } + func splitToggleZoom(surface: ghostty_surface_t) { let action = "toggle_split_zoom" if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { @@ -355,6 +361,19 @@ extension Ghostty { ) } + static func resizeSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_resize_direction_e, amount: UInt16) { + guard let surface = self.surfaceUserdata(from: userdata) else { return } + guard let resizeDirection = SplitResizeDirection.from(direction: direction) else { return } + NotificationCenter.default.post( + name: Notification.didResizeSplit, + object: surface, + userInfo: [ + Notification.ResizeSplitDirectionKey: resizeDirection, + Notification.ResizeSplitAmountKey: amount, + ] + ) + } + static func toggleSplitZoom(_ userdata: UnsafeMutableRawPointer?) { guard let surface = self.surfaceUserdata(from: userdata) else { return } diff --git a/macos/Sources/Ghostty/Ghostty.SplitNode.swift b/macos/Sources/Ghostty/Ghostty.SplitNode.swift index 6e3adea96..9b0e4493b 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitNode.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitNode.swift @@ -1,4 +1,5 @@ import SwiftUI +import Combine import GhosttyKit extension Ghostty { @@ -123,6 +124,8 @@ extension Ghostty { @Published var topLeft: SplitNode @Published var bottomRight: SplitNode + var resizeEvent: PassthroughSubject = .init() + weak var parent: SplitNode.Container? /// A container is always initialized from some prior leaf because a split has to originate @@ -143,6 +146,30 @@ extension Ghostty { from.parent = self bottomRight.parent = self } + + /// Resize the split by moving the split divider in the given + /// direction by the given amount. If this container is not split + /// in the given direction, navigate up the tree until we find a + /// container that is + func resize(direction: SplitResizeDirection, amount: UInt16) { + // We send a resize event to our publisher which will be + // received by the SplitView. + switch (self.direction) { + case .horizontal: + switch (direction) { + case .left: resizeEvent.send(-Double(amount)) + case .right: resizeEvent.send(Double(amount)) + default: parent?.resize(direction: direction, amount: amount) + } + case .vertical: + switch (direction) { + case .up: resizeEvent.send(-Double(amount)) + case .down: resizeEvent.send(Double(amount)) + default: parent?.resize(direction: direction, amount: amount) + } + } + } + // MARK: - Hashable diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift index a684c2e1b..a4869d9b1 100644 --- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift +++ b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift @@ -33,7 +33,7 @@ extension Ghostty { .focusedValue(\.ghosttySurfaceZoomed, zoomedSurface != nil) } } - + /// The root of a split tree. This sets up the initial SplitNode state and renders. There is only ever /// one of these in a split tree. private struct TerminalSplitRoot: View { @@ -153,11 +153,13 @@ extension Ghostty { 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) + let pubResize = center.publisher(for: Notification.didResizeSplit, object: leaf.surface) InspectableSurface(surfaceView: leaf.surface, isSplit: !neighbors.isEmpty()) .onReceive(pub) { onNewSplit(notification: $0) } .onReceive(pubClose) { onClose(notification: $0) } .onReceive(pubFocus) { onMoveFocus(notification: $0) } + .onReceive(pubResize) { onResize(notification: $0) } } private func onClose(notification: SwiftUI.Notification) { @@ -243,6 +245,20 @@ extension Ghostty { to: next.preferredFocus(direction) ) } + + /// Handle a resize event. + private func onResize(notification: SwiftUI.Notification) { + // If this leaf is not part of a split then there is nothing to do + guard let parent = leaf.parent else { return } + + guard let directionAny = notification.userInfo?[Ghostty.Notification.ResizeSplitDirectionKey] else { return } + guard let direction = directionAny as? Ghostty.SplitResizeDirection else { return } + + guard let amountAny = notification.userInfo?[Ghostty.Notification.ResizeSplitAmountKey] else { return } + guard let amount = amountAny as? UInt16 else { return } + + parent.resize(direction: direction, amount: amount) + } } /// This represents a split view that is in the horizontal or vertical split state. @@ -251,6 +267,15 @@ extension Ghostty { @Binding var node: SplitNode? @StateObject var container: SplitNode.Container + @EnvironmentObject private var ghostty: Ghostty.AppState + + /// The cell size of the currently focused view. We use this to + /// (optionally) set the resizeIncrements binding for the SplitView + @FocusedValue(\.ghosttySurfaceCellSize) private var focusedCellSize + + /// Resize increments used by the split view + @State private var resizeIncrements: NSSize = .init(width: 1.0, height: 1.0) + var body: some View { SplitView(container.direction, left: { let neighborKey: WritableKeyPath = container.direction == .horizontal ? \.right : \.bottom @@ -273,6 +298,13 @@ extension Ghostty { ]) ) }) + .onChange(of: focusedCellSize) { newValue in + guard let increments = newValue else { return } + guard ghostty.windowStepResize else { return } + self.resizeIncrements = increments + } + .resizeIncrements(resizeIncrements) + .resizePublisher(container.resizeEvent) } private func closeableTopLeft() -> Binding { diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 51ba1e70d..823af9f4b 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -61,6 +61,39 @@ extension Ghostty { } } } + + /// Enum used for resizing splits. This is the direction the split divider will move. + enum SplitResizeDirection { + case up, down, left, right + + static func from(direction: ghostty_split_resize_direction_e) -> Self? { + switch (direction) { + case GHOSTTY_SPLIT_RESIZE_UP: + return .up; + case GHOSTTY_SPLIT_RESIZE_DOWN: + return .down; + case GHOSTTY_SPLIT_RESIZE_LEFT: + return .left; + case GHOSTTY_SPLIT_RESIZE_RIGHT: + return .right; + default: + return nil + } + } + + func toNative() -> ghostty_split_resize_direction_e { + switch (self) { + case .up: + return GHOSTTY_SPLIT_RESIZE_UP; + case .down: + return GHOSTTY_SPLIT_RESIZE_DOWN; + case .left: + return GHOSTTY_SPLIT_RESIZE_LEFT; + case .right: + return GHOSTTY_SPLIT_RESIZE_RIGHT; + } + } + } } extension Ghostty.Notification { @@ -112,6 +145,11 @@ extension Ghostty.Notification { static let confirmUnsafePaste = Notification.Name("com.mitchellh.ghostty.confirmUnsafePaste") static let UnsafePasteStrKey = confirmUnsafePaste.rawValue + ".str" static let UnsafePasteStateKey = confirmUnsafePaste.rawValue + ".state" + + /// Notification sent to the active split view to resize the split. + static let didResizeSplit = Notification.Name("com.mitchellh.ghostty.didResizeSplit") + static let ResizeSplitDirectionKey = didResizeSplit.rawValue + ".direction" + static let ResizeSplitAmountKey = didResizeSplit.rawValue + ".amount" } // Make the input enum hashable. diff --git a/macos/Sources/Helpers/SplitView/SplitView.swift b/macos/Sources/Helpers/SplitView/SplitView.swift index c28b6b578..79c8ed91c 100644 --- a/macos/Sources/Helpers/SplitView/SplitView.swift +++ b/macos/Sources/Helpers/SplitView/SplitView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Combine /// A split view shows a left and right (or top and bottom) view with a divider in the middle to do resizing. /// The terminlogy "left" and "right" is always used but for vertical splits "left" is "top" and "right" is "bottom". @@ -15,7 +16,14 @@ struct SplitView: View { /// The current fractional width of the split view. 0.5 means L/R are equally sized, for example. @State var split: CGFloat = 0.5 - + + /// Minimum increment (in points) that this split can be resized by, in + /// each direction. Both `height` and `width` should be whole numbers + /// greater than or equal to 1.0 + @Environment(\.resizeIncrements) var resizeIncrements + + @Environment(\.resizePublisher) var resizePublisher + /// The visible size of the splitter, in points. The invisible size is a transparent hitbox that can still /// be used for getting a resize handle. The total width/height of the splitter is the sum of both. private let splitterVisibleSize: CGFloat = 1 @@ -38,6 +46,9 @@ struct SplitView: View { .position(splitterPoint) .gesture(dragGesture(geo.size, splitterPoint: splitterPoint)) } + .onReceive(resizePublisher) { value in + resize(for: geo.size, amount: value) + } } } @@ -47,6 +58,18 @@ struct SplitView: View { self.right = right() } + private func resize(for size: CGSize, amount: Double) { + switch (direction) { + case .horizontal: + split += amount / size.width + case .vertical: + split += amount / size.height + } + + // Ensure split is clamped between 0 and 1 + split = max(0.0, min(split, 1.0)) + } + private func dragGesture(_ size: CGSize, splitterPoint: CGPoint) -> some Gesture { return DragGesture() .onChanged { gesture in @@ -72,10 +95,12 @@ struct SplitView: View { case .horizontal: result.size.width = result.size.width * split result.size.width -= splitterVisibleSize / 2 + result.size.width -= result.size.width.truncatingRemainder(dividingBy: self.resizeIncrements.width) case .vertical: result.size.height = result.size.height * split result.size.height -= splitterVisibleSize / 2 + result.size.height -= result.size.height.truncatingRemainder(dividingBy: self.resizeIncrements.height) } return result @@ -117,3 +142,34 @@ struct SplitView: View { enum SplitViewDirection { case horizontal, vertical } + +private struct ResizeIncrementsKey: EnvironmentKey { + static let defaultValue: CGSize = .init(width: 1.0, height: 1.0) +} + +private struct ResizePublisherKey: EnvironmentKey { + static let defaultValue: PassthroughSubject = .init() +} + +extension EnvironmentValues { + /// An environment value that specifies the resize increments of a resizable view + var resizeIncrements: CGSize { + get { self[ResizeIncrementsKey.self] } + set { self[ResizeIncrementsKey.self] = newValue } + } + + var resizePublisher: PassthroughSubject { + get { self[ResizePublisherKey.self] } + set { self[ResizePublisherKey.self] = newValue } + } +} + +extension View { + func resizeIncrements(_ increments: CGSize) -> some View { + environment(\.resizeIncrements, increments) + } + + func resizePublisher(_ publisher: PassthroughSubject) -> some View { + environment(\.resizePublisher, publisher) + } +} diff --git a/macos/Sources/MainMenu.xib b/macos/Sources/MainMenu.xib index f62121009..e2979cfee 100644 --- a/macos/Sources/MainMenu.xib +++ b/macos/Sources/MainMenu.xib @@ -19,6 +19,10 @@ + + + + @@ -262,6 +266,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 953da5486..8b361f573 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -92,6 +92,9 @@ pub const App = struct { /// Focus the previous/next split (if any). focus_split: ?*const fn (SurfaceUD, input.SplitFocusDirection) callconv(.C) void = null, + /// Resize the current split. + resize_split: ?*const fn (SurfaceUD, input.SplitResizeDirection, u16) callconv(.C) void = null, + /// Zoom the current split. toggle_split_zoom: ?*const fn (SurfaceUD) callconv(.C) void = null, @@ -384,6 +387,15 @@ pub const Surface = struct { func(self.opts.userdata, direction); } + pub fn resizeSplit(self: *const Surface, direction: input.SplitResizeDirection, amount: u16) void { + const func = self.app.opts.resize_split orelse { + log.info("runtime embedder does not support resize split", .{}); + return; + }; + + func(self.opts.userdata, direction, amount); + } + 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", .{}); @@ -1374,6 +1386,14 @@ pub const CAPI = struct { ptr.gotoSplit(direction); } + /// Resize the current split by moving the split divider in the given + /// direction. `direction` specifies which direction the split divider will + /// move relative to the focused split. `amount` is a fractional value + /// between 0 and 1 that specifies by how much the divider will move. + export fn ghostty_surface_split_resize(ptr: *Surface, direction: input.SplitResizeDirection, amount: u16) void { + ptr.resizeSplit(direction, amount); + } + /// Invoke an action on the surface. export fn ghostty_surface_binding_action( ptr: *Surface,