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 9897aeda5..e95adb0ab 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -40,7 +40,7 @@ class TerminalController: NSWindowController, NSWindowDelegate, // Initialize our initial surface. guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") } - self.surfaceTree = .noSplit(.init(ghostty_app, base)) + self.surfaceTree = .leaf(.init(ghostty_app, base)) // Setup our notifications for behaviors let center = NotificationCenter.default @@ -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..7596c343a 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -84,8 +84,7 @@ struct TerminalView: View { } Ghostty.TerminalSplit(node: $viewModel.surfaceTree) - .ghosttyApp(ghostty.app!) - .ghosttyConfig(ghostty.config!) + .environmentObject(ghostty) .focused($focused) .onAppear { self.focused = true } .onChange(of: focusedSurface) { newValue in diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index f54c80ca3..11a390ae9 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -158,6 +158,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) }, @@ -292,6 +294,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))) { @@ -364,6 +370,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 } @@ -572,35 +591,3 @@ extension Ghostty { } } } - -// MARK: AppState Environment Keys - -private struct GhosttyAppKey: EnvironmentKey { - static let defaultValue: ghostty_app_t? = nil -} - -private struct GhosttyConfigKey: EnvironmentKey { - static let defaultValue: ghostty_config_t? = nil -} - -extension EnvironmentValues { - var ghosttyApp: ghostty_app_t? { - get { self[GhosttyAppKey.self] } - set { self[GhosttyAppKey.self] = newValue } - } - - var ghosttyConfig: ghostty_config_t? { - get { self[GhosttyConfigKey.self] } - set { self[GhosttyConfigKey.self] = newValue } - } -} - -extension View { - func ghosttyApp(_ app: ghostty_app_t?) -> some View { - environment(\.ghosttyApp, app) - } - - func ghosttyConfig(_ config: ghostty_config_t?) -> some View { - environment(\.ghosttyConfig, config) - } -} diff --git a/macos/Sources/Ghostty/Ghostty.SplitNode.swift b/macos/Sources/Ghostty/Ghostty.SplitNode.swift index beba0ed8d..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 { @@ -11,9 +12,8 @@ extension Ghostty { /// values can further be split infinitely. /// enum SplitNode: Equatable, Hashable { - case noSplit(Leaf) - case horizontal(Container) - case vertical(Container) + case leaf(Leaf) + case split(Container) /// Returns the view that would prefer receiving focus in this tree. This is always the /// top-left-most view. This is used when creating a split or closing a split to find the @@ -21,14 +21,11 @@ extension Ghostty { func preferredFocus(_ direction: SplitFocusDirection = .top) -> SurfaceView { let container: Container switch (self) { - case .noSplit(let leaf): + case .leaf(let leaf): // noSplit is easy because there is only one thing to focus return leaf.surface - case .horizontal(let c): - container = c - - case .vertical(let c): + case .split(let c): container = c } @@ -48,14 +45,10 @@ extension Ghostty { /// surface. At this point, the surface view in this node tree can never be used again. func close() { switch (self) { - case .noSplit(let leaf): + case .leaf(let leaf): leaf.surface.close() - case .horizontal(let container): - container.topLeft.close() - container.bottomRight.close() - - case .vertical(let container): + case .split(let container): container.topLeft.close() container.bottomRight.close() } @@ -64,14 +57,10 @@ extension Ghostty { /// Returns true if any surface in the split stack requires quit confirmation. func needsConfirmQuit() -> Bool { switch (self) { - case .noSplit(let leaf): + case .leaf(let leaf): return leaf.surface.needsConfirmQuit - case .horizontal(let container): - return container.topLeft.needsConfirmQuit() || - container.bottomRight.needsConfirmQuit() - - case .vertical(let container): + case .split(let container): return container.topLeft.needsConfirmQuit() || container.bottomRight.needsConfirmQuit() } @@ -80,14 +69,10 @@ extension Ghostty { /// Returns true if the split tree contains the given view. func contains(view: SurfaceView) -> Bool { switch (self) { - case .noSplit(let leaf): + case .leaf(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): + case .split(let container): return container.topLeft.contains(view: view) || container.bottomRight.contains(view: view) } @@ -97,11 +82,9 @@ extension Ghostty { static func == (lhs: SplitNode, rhs: SplitNode) -> Bool { switch (lhs, rhs) { - case (.noSplit(let lhs_v), .noSplit(let rhs_v)): + case (.leaf(let lhs_v), .leaf(let rhs_v)): return lhs_v === rhs_v - case (.horizontal(let lhs_v), .horizontal(let rhs_v)): - return lhs_v === rhs_v - case (.vertical(let lhs_v), .vertical(let rhs_v)): + case (.split(let lhs_v), .split(let rhs_v)): return lhs_v === rhs_v default: return false @@ -112,6 +95,8 @@ extension Ghostty { let app: ghostty_app_t @Published var surface: SurfaceView + weak var parent: SplitNode.Container? + /// Initialize a new leaf which creates a new terminal surface. init(_ app: ghostty_app_t, _ baseConfig: SurfaceConfiguration?) { self.app = app @@ -134,25 +119,63 @@ extension Ghostty { class Container: ObservableObject, Equatable, Hashable { let app: ghostty_app_t + let direction: SplitViewDirection + @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 /// from a non-split value. When initializing, we inherit the leaf's surface and then /// initialize a new surface for the new pane. - init(from: Leaf, baseConfig: SurfaceConfiguration? = nil) { + init(from: Leaf, direction: SplitViewDirection, baseConfig: SurfaceConfiguration? = nil) { self.app = from.app + self.direction = direction + self.parent = from.parent // Initially, both topLeft and bottomRight are in the "nosplit" // state since this is a new split. - self.topLeft = .noSplit(from) - self.bottomRight = .noSplit(.init(app, baseConfig)) + self.topLeft = .leaf(from) + + let bottomRight: Leaf = .init(app, baseConfig) + self.bottomRight = .leaf(bottomRight) + + 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 func hash(into hasher: inout Hasher) { hasher.combine(app) + hasher.combine(direction) hasher.combine(topLeft) hasher.combine(bottomRight) } @@ -161,6 +184,7 @@ extension Ghostty { static func == (lhs: Container, rhs: Container) -> Bool { return lhs.app == rhs.app && + lhs.direction == rhs.direction && lhs.topLeft == rhs.topLeft && lhs.bottomRight == rhs.bottomRight } diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift index 82cbd3a04..65fabf939 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 { @@ -61,25 +61,15 @@ extension Ghostty { case nil: Color(.clear) - case .noSplit(let leaf): + case .leaf(let leaf): TerminalSplitLeaf( leaf: leaf, neighbors: .empty, node: $node ) - case .horizontal(let container): + case .split(let container): TerminalSplitContainer( - direction: .horizontal, - neighbors: .empty, - node: $node, - container: container - ) - .onReceive(pubZoom) { onZoom(notification: $0) } - - case .vertical(let container): - TerminalSplitContainer( - direction: .vertical, neighbors: .empty, node: $node, container: container @@ -105,7 +95,7 @@ extension 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 { + if case .leaf = node { preconditionFailure("TerminalSplitRoom must not be zoom-able if no splits exist") } @@ -163,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) { @@ -234,16 +226,10 @@ extension Ghostty { } // Setup our new container since we are now split - let container = SplitNode.Container(from: leaf, baseConfig: config) + let container = SplitNode.Container(from: leaf, direction: splitDirection, baseConfig: config) - // Depending on the direction, change the parent node. This will trigger - // the parent to relayout our views. - switch (splitDirection) { - case .horizontal: - node = .horizontal(container) - case .vertical: - node = .vertical(container) - } + // Change the parent node. This will trigger the parent to relayout our views. + node = .split(container) // See moveFocus comment, we have to run this whenever split changes. Ghostty.moveFocus(to: container.bottomRight.preferredFocus(), from: node!.preferredFocus()) @@ -259,18 +245,35 @@ 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. private struct TerminalSplitContainer: View { - let direction: SplitViewDirection let neighbors: SplitNode.Neighbors @Binding var node: SplitNode? @StateObject var container: SplitNode.Container var body: some View { - SplitView(direction, left: { - let neighborKey: WritableKeyPath = direction == .horizontal ? \.right : \.bottom + SplitView( + container.direction, + resizeIncrements: .init(width: 1, height: 1), + resizePublisher: container.resizeEvent, + left: { + let neighborKey: WritableKeyPath = container.direction == .horizontal ? \.right : \.bottom TerminalSplitNested( node: closeableTopLeft(), @@ -280,8 +283,8 @@ extension Ghostty { ]) ) }, right: { - let neighborKey: WritableKeyPath = direction == .horizontal ? \.left : \.top - + let neighborKey: WritableKeyPath = container.direction == .horizontal ? \.left : \.top + TerminalSplitNested( node: closeableBottomRight(), neighbors: neighbors.update([ @@ -304,7 +307,16 @@ extension Ghostty { // Closing container.topLeft.close() node = container.bottomRight - + + switch (node) { + case .leaf(let l): + l.parent = container.parent + case .split(let c): + c.parent = container.parent + case .none: + break + } + DispatchQueue.main.async { Ghostty.moveFocus( to: container.bottomRight.preferredFocus(), @@ -326,7 +338,16 @@ extension Ghostty { // Closing container.bottomRight.close() node = container.topLeft - + + switch (node) { + case .leaf(let l): + l.parent = container.parent + case .split(let c): + c.parent = container.parent + case .none: + break + } + DispatchQueue.main.async { Ghostty.moveFocus( to: container.topLeft.preferredFocus(), @@ -350,24 +371,15 @@ extension Ghostty { case nil: Color(.clear) - case .noSplit(let leaf): + case .leaf(let leaf): TerminalSplitLeaf( leaf: leaf, neighbors: neighbors, node: $node ) - case .horizontal(let container): + case .split(let container): TerminalSplitContainer( - direction: .horizontal, - neighbors: neighbors, - node: $node, - container: container - ) - - case .vertical(let 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 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/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 2f491ae5e..d14602c5d 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -4,11 +4,11 @@ import GhosttyKit extension Ghostty { /// Render a terminal for the active app in the environment. struct Terminal: View { - @Environment(\.ghosttyApp) private var app + @EnvironmentObject private var ghostty: Ghostty.AppState @FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle: String? var body: some View { - if let app = self.app { + if let app = self.ghostty.app { SurfaceForApp(app) { surfaceView in SurfaceWrapper(surfaceView: surfaceView) } @@ -48,7 +48,7 @@ extension Ghostty { // Maintain whether our window has focus (is key) or not @State private var windowFocus: Bool = true - @Environment(\.ghosttyConfig) private var ghostty_config + @EnvironmentObject private var ghostty: Ghostty.AppState // This is true if the terminal is considered "focused". The terminal is focused if // it is both individually focused and the containing window is key. @@ -58,7 +58,7 @@ extension Ghostty { private var unfocusedOpacity: Double { var opacity: Double = 0.85 let key = "unfocused-split-opacity" - _ = ghostty_config_get(ghostty_config, &opacity, key, UInt(key.count)) + _ = ghostty_config_get(ghostty.config, &opacity, key, UInt(key.count)) return 1 - opacity } @@ -508,7 +508,7 @@ extension Ghostty { guard let window = self.window else { return } guard let windowControllerRaw = window.windowController else { return } guard let windowController = windowControllerRaw as? TerminalController else { return } - guard case .noSplit = windowController.surfaceTree else { return } + guard case .leaf = windowController.surfaceTree else { return } // If our window is full screen, we do not set the frame guard !window.styleMask.contains(.fullScreen) else { return } diff --git a/macos/Sources/Helpers/SplitView/SplitView.swift b/macos/Sources/Helpers/SplitView/SplitView.swift index c28b6b578..3dd0f8129 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". @@ -9,13 +10,20 @@ struct SplitView: View { /// Direction of the split let direction: SplitViewDirection + /// If set, the split view supports programmatic resizing via events sent via the publisher. + /// 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 + let resizeIncrements: NSSize + let resizePublisher: PassthroughSubject + /// The left and right views to render. let left: L let right: R /// The current fractional width of the split view. 0.5 means L/R are equally sized, for example. @State var split: CGFloat = 0.5 - + /// 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,15 +46,51 @@ struct SplitView: View { .position(splitterPoint) .gesture(dragGesture(geo.size, splitterPoint: splitterPoint)) } + .onReceive(resizePublisher) { value in + resize(for: geo.size, amount: value) + } } } + /// Initialize a split view. This view isn't programmatically resizable; it can only be resized + /// by manually dragging the divider. init(_ direction: SplitViewDirection, @ViewBuilder left: (() -> L), @ViewBuilder right: (() -> R)) { + self.init( + direction, + resizeIncrements: .init(width: 1, height: 1), + resizePublisher: .init(), + left: left, + right: right + ) + } + + /// Initialize a split view that supports programmatic resizing. + init( + _ direction: SplitViewDirection, + resizeIncrements: NSSize, + resizePublisher: PassthroughSubject, + @ViewBuilder left: (() -> L), + @ViewBuilder right: (() -> R) + ) { self.direction = direction + self.resizeIncrements = resizeIncrements + self.resizePublisher = resizePublisher self.left = left() 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 +116,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 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/Surface.zig b/src/Surface.zig index 903737a98..40b6a2f90 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2436,6 +2436,14 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool } else log.warn("runtime doesn't implement gotoSplit", .{}); }, + .resize_split => |param| { + if (@hasDecl(apprt.Surface, "resizeSplit")) { + const direction = param[0]; + const amount = param[1]; + self.rt_surface.resizeSplit(direction, amount); + } else log.warn("runtime doesn't implement resizeSplit", .{}); + }, + .toggle_split_zoom => { if (@hasDecl(apprt.Surface, "toggleSplitZoom")) { self.rt_surface.toggleSplitZoom(); 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, diff --git a/src/config/Config.zig b/src/config/Config.zig index 814c621ea..8b1ddad77 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -980,6 +980,26 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { .{ .key = .right, .mods = .{ .super = true, .alt = true } }, .{ .goto_split = .right }, ); + try result.keybind.set.put( + alloc, + .{ .key = .up, .mods = .{ .super = true, .ctrl = true } }, + .{ .resize_split = .{ .up, 10 } }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .down, .mods = .{ .super = true, .ctrl = true } }, + .{ .resize_split = .{ .down, 10 } }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .left, .mods = .{ .super = true, .ctrl = true } }, + .{ .resize_split = .{ .left, 10 } }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .right, .mods = .{ .super = true, .ctrl = true } }, + .{ .resize_split = .{ .right, 10 } }, + ); // Inspector, matching Chromium try result.keybind.set.put( diff --git a/src/input.zig b/src/input.zig index c90226b39..f3afce97d 100644 --- a/src/input.zig +++ b/src/input.zig @@ -11,6 +11,7 @@ pub const KeyEncoder = @import("input/KeyEncoder.zig"); pub const InspectorMode = Binding.Action.InspectorMode; pub const SplitDirection = Binding.Action.SplitDirection; pub const SplitFocusDirection = Binding.Action.SplitFocusDirection; +pub const SplitResizeDirection = Binding.Action.SplitResizeDirection; // Keymap is only available on macOS right now. We could implement it // in theory for XKB too on Linux but we don't need it right now. diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 0531d6b12..ed7a93d48 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -179,6 +179,10 @@ pub const Action = union(enum) { /// zoom/unzoom the current split. toggle_split_zoom: void, + /// Resize the current split by moving the split divider in the given + /// direction + resize_split: SplitResizeParameter, + /// Show, hide, or toggle the terminal inspector for the currently /// focused terminal. inspector: InspectorMode, @@ -227,6 +231,19 @@ pub const Action = union(enum) { right, }; + // Extern because it is used in the embedded runtime ABI. + pub const SplitResizeDirection = enum(c_int) { + up, + down, + left, + right, + }; + + pub const SplitResizeParameter = struct { + SplitResizeDirection, + u16, + }; + // Extern because it is used in the embedded runtime ABI. pub const InspectorMode = enum(c_int) { toggle, @@ -234,6 +251,53 @@ pub const Action = union(enum) { hide, }; + fn parseEnum(comptime T: type, value: []const u8) !T { + return std.meta.stringToEnum(T, value) orelse return Error.InvalidFormat; + } + + fn parseInt(comptime T: type, value: []const u8) !T { + return std.fmt.parseInt(T, value, 10) catch return Error.InvalidFormat; + } + + fn parseFloat(comptime T: type, value: []const u8) !T { + return std.fmt.parseFloat(T, value) catch return Error.InvalidFormat; + } + + fn parseParameter( + comptime field: std.builtin.Type.UnionField, + param: []const u8, + ) !field.type { + return switch (@typeInfo(field.type)) { + .Enum => try parseEnum(field.type, param), + .Int => try parseInt(field.type, param), + .Float => try parseFloat(field.type, param), + .Struct => |info| blk: { + // Only tuples are supported to avoid ambiguity with field + // ordering + comptime assert(info.is_tuple); + + var it = std.mem.split(u8, param, ","); + var value: field.type = undefined; + inline for (info.fields) |field_| { + const next = it.next() orelse return Error.InvalidFormat; + @field(value, field_.name) = switch (@typeInfo(field_.type)) { + .Enum => try parseEnum(field_.type, next), + .Int => try parseInt(field_.type, next), + .Float => try parseFloat(field_.type, next), + else => unreachable, + }; + } + + // If we have extra parameters it is an error + if (it.next() != null) return Error.InvalidFormat; + + break :blk value; + }, + + else => unreachable, + }; + } + /// Parse an action in the format of "key=value" where key is the /// action name and value is the action parameter. The parameter /// is optional depending on the action. @@ -266,35 +330,14 @@ pub const Action = union(enum) { // Cursor keys can't be set currently Action.CursorKey => return Error.InvalidAction, - else => switch (@typeInfo(field.type)) { - .Enum => { - const idx = colonIdx orelse return Error.InvalidFormat; - const param = input[idx + 1 ..]; - const value = std.meta.stringToEnum( - field.type, - param, - ) orelse return Error.InvalidFormat; - - return @unionInit(Action, field.name, value); - }, - - .Int => { - const idx = colonIdx orelse return Error.InvalidFormat; - const param = input[idx + 1 ..]; - const value = std.fmt.parseInt(field.type, param, 10) catch - return Error.InvalidFormat; - return @unionInit(Action, field.name, value); - }, - - .Float => { - const idx = colonIdx orelse return Error.InvalidFormat; - const param = input[idx + 1 ..]; - const value = std.fmt.parseFloat(field.type, param) catch - return Error.InvalidFormat; - return @unionInit(Action, field.name, value); - }, - - else => unreachable, + else => { + const idx = colonIdx orelse return Error.InvalidFormat; + const param = input[idx + 1 ..]; + return @unionInit( + Action, + field.name, + try parseParameter(field, param), + ); }, } } @@ -316,24 +359,38 @@ pub const Action = union(enum) { switch (self) { inline else => |value| { - const Value = @TypeOf(value); - const value_info = @typeInfo(Value); - // All actions start with the tag. try writer.print("{s}", .{@tagName(self)}); // Write the value depending on the type - switch (Value) { - void => {}, - []const u8 => try writer.print(":{s}", .{value}), - else => switch (value_info) { - .Enum => try writer.print(":{s}", .{@tagName(value)}), - .Float => try writer.print(":{d}", .{value}), - .Int => try writer.print(":{d}", .{value}), - .Struct => try writer.print("{} (not configurable)", .{value}), - else => @compileError("unhandled type: " ++ @typeName(Value)), - }, - } + try writer.writeAll(":"); + try formatValue(writer, value); + }, + } + } + + fn formatValue( + writer: anytype, + value: anytype, + ) !void { + const Value = @TypeOf(value); + const value_info = @typeInfo(Value); + switch (Value) { + void => {}, + []const u8 => try writer.print("{s}", .{value}), + else => switch (value_info) { + .Enum => try writer.print("{s}", .{@tagName(value)}), + .Float => try writer.print("{d}", .{value}), + .Int => try writer.print("{d}", .{value}), + .Struct => |info| if (!info.is_tuple) { + try writer.print("{} (not configurable)", .{value}); + } else { + inline for (info.fields, 0..) |field, i| { + try formatValue(writer, @field(value, field.name)); + if (i + 1 < info.fields.len) try writer.writeAll(","); + } + }, + else => @compileError("unhandled type: " ++ @typeName(Value)), }, } } @@ -775,6 +832,27 @@ test "parse: action with float" { } } +test "parse: action with a tuple" { + const testing = std.testing; + + // parameter + { + const binding = try parse("a=resize_split:up,10"); + try testing.expect(binding.action == .resize_split); + try testing.expectEqual(Action.SplitResizeDirection.up, binding.action.resize_split[0]); + try testing.expectEqual(@as(u16, 10), binding.action.resize_split[1]); + } + + // missing parameter + try testing.expectError(Error.InvalidFormat, parse("a=resize_split:up")); + + // too many + try testing.expectError(Error.InvalidFormat, parse("a=resize_split:up,10,12")); + + // invalid type + try testing.expectError(Error.InvalidFormat, parse("a=resize_split:up,four")); +} + test "set: maintains reverse mapping" { const testing = std.testing; const alloc = testing.allocator;