From 3b2799ce971339ec7febce24a602b4a51b42caf3 Mon Sep 17 00:00:00 2001 From: Gregory Anders Date: Sun, 5 Nov 2023 19:41:14 -0600 Subject: [PATCH 1/9] macos: refactor SplitNode This commit does two things: adds a weak reference to the parent container of each SplitNode.Container and SplitNode.Leaf and moves the "direction" of a split out of the SplitNode enum and into the SplitNode.Container struct as a field. Both changes are required for supporting split resizing. A reference to the parent in each leaf and split container is needed in order to traverse upwards through the split tree. If the focused split is not part of a container that is split along the direction that was requested to be resized, then we instead try and resize the parent. If the parent is split along the requested direction, then it is resized appropriately; otherwise, repeat until the root of the tree is reached. The direction is needed inside the SplitNode.Container object itself so that the container knows whether or not it is able to resize itself in the direction requested by the user. Once the split direction was moved inside of SplitNode.Container, it became redundant to also have it as part of the SplitNode enum, so this simplifies things. --- .../Terminal/TerminalController.swift | 2 +- macos/Sources/Ghostty/Ghostty.SplitNode.swift | 63 ++++++++-------- .../Ghostty/Ghostty.TerminalSplit.swift | 72 +++++++++---------- macos/Sources/Ghostty/SurfaceView.swift | 2 +- 4 files changed, 64 insertions(+), 75 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 9897aeda5..56bc5d92e 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 diff --git a/macos/Sources/Ghostty/Ghostty.SplitNode.swift b/macos/Sources/Ghostty/Ghostty.SplitNode.swift index beba0ed8d..6e3adea96 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitNode.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitNode.swift @@ -11,9 +11,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 +20,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 +44,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 +56,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 +68,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 +81,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 +94,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 +118,37 @@ extension Ghostty { class Container: ObservableObject, Equatable, Hashable { let app: ghostty_app_t + let direction: SplitViewDirection + @Published var topLeft: SplitNode @Published var bottomRight: SplitNode + 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 } // MARK: - Hashable func hash(into hasher: inout Hasher) { hasher.combine(app) + hasher.combine(direction) hasher.combine(topLeft) hasher.combine(bottomRight) } @@ -161,6 +157,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..a684c2e1b 100644 --- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift +++ b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift @@ -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") } @@ -234,16 +224,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()) @@ -263,14 +247,13 @@ 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 var body: some View { - SplitView(direction, left: { - let neighborKey: WritableKeyPath = direction == .horizontal ? \.right : \.bottom + SplitView(container.direction, left: { + let neighborKey: WritableKeyPath = container.direction == .horizontal ? \.right : \.bottom TerminalSplitNested( node: closeableTopLeft(), @@ -280,8 +263,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 +287,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 +318,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 +351,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/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 2f491ae5e..97a347aab 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -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 } From e7745dc8205b9bbbb69ef4dd68f6896ee229532b Mon Sep 17 00:00:00 2001 From: Gregory Anders Date: Sun, 5 Nov 2023 19:48:52 -0600 Subject: [PATCH 2/9] core: add support for bindings with multiple parameters This commit adds support for bindings with multiple parameters. For example, a "resize_split" binding will have two parameters: the resize direction and the resize amount. Multiple parameters are supported by using a tuple as the enum value and are written in string form by separating each argument with a comma. Example: "resize_split:up,10". --- src/input/Binding.zig | 81 +++++++++++++++++++++++++++---------------- 1 file changed, 52 insertions(+), 29 deletions(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 0531d6b12..5bd9299b6 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -234,6 +234,50 @@ 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, + }; + } + + 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 +310,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), + ); }, } } From 0a2d435481fec499a6a416cb91d90db5785bbba6 Mon Sep 17 00:00:00 2001 From: Gregory Anders Date: Sun, 5 Nov 2023 19:50:58 -0600 Subject: [PATCH 3/9] core: add resize_split binding with default keys On macOS, use Cmd+Ctrl+Arrow keys as default bindings for resizing by 10 points in the given direction. --- src/Surface.zig | 8 ++++++++ src/config/Config.zig | 20 ++++++++++++++++++++ src/input.zig | 1 + src/input/Binding.zig | 17 +++++++++++++++++ 4 files changed, 46 insertions(+) diff --git a/src/Surface.zig b/src/Surface.zig index 7032ec8ae..aa5d89000 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2426,6 +2426,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/config/Config.zig b/src/config/Config.zig index a091191d4..23eed68f4 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -956,6 +956,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 5bd9299b6..dcce76b01 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, From 9b14398153386937a80dfee4d41d8592a44103ee Mon Sep 17 00:00:00 2001 From: Gregory Anders Date: Sun, 5 Nov 2023 19:52:30 -0600 Subject: [PATCH 4/9] macos: support resizing splits --- include/ghostty.h | 10 ++++ macos/Sources/AppDelegate.swift | 9 +++ .../Terminal/TerminalController.swift | 22 ++++++- .../Features/Terminal/TerminalView.swift | 1 + macos/Sources/Ghostty/AppState.swift | 19 ++++++ macos/Sources/Ghostty/Ghostty.SplitNode.swift | 27 +++++++++ .../Ghostty/Ghostty.TerminalSplit.swift | 34 ++++++++++- macos/Sources/Ghostty/Package.swift | 38 ++++++++++++ .../Sources/Helpers/SplitView/SplitView.swift | 58 ++++++++++++++++++- macos/Sources/MainMenu.xib | 35 +++++++++++ src/apprt/embedded.zig | 20 +++++++ 11 files changed, 270 insertions(+), 3 deletions(-) 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, From 582df33083cb56d9a6c5dce85a81d3026d672bcc Mon Sep 17 00:00:00 2001 From: Gregory Anders Date: Sun, 5 Nov 2023 20:09:38 -0600 Subject: [PATCH 5/9] macos: use Ghostty.AppState as @EnvironmentObject This is a small cleanup change. This provides the Ghostty.AppState object to any view that needs it within the TerminalSplit view hierarchy without needing to explicitly define EnvironmentKeys. --- .../Features/Terminal/TerminalView.swift | 2 -- macos/Sources/Ghostty/AppState.swift | 32 ------------------- macos/Sources/Ghostty/SurfaceView.swift | 8 ++--- 3 files changed, 4 insertions(+), 38 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 6da810690..7596c343a 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -85,8 +85,6 @@ struct TerminalView: View { Ghostty.TerminalSplit(node: $viewModel.surfaceTree) .environmentObject(ghostty) - .ghosttyApp(ghostty.app!) - .ghosttyConfig(ghostty.config!) .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 d966d6900..c4e5cc2b7 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -582,35 +582,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/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 97a347aab..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 } From 1ff0573518ed0cf239e22507c29c527ec420bca8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Nov 2023 09:06:20 -0800 Subject: [PATCH 6/9] macos: use normal swiftui parameters for resizable publisher/inc --- .../Ghostty/Ghostty.TerminalSplit.swift | 8 ++- .../Sources/Helpers/SplitView/SplitView.swift | 66 ++++++++----------- 2 files changed, 33 insertions(+), 41 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift index a4869d9b1..4e4b3d39c 100644 --- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift +++ b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift @@ -277,7 +277,11 @@ extension Ghostty { @State private var resizeIncrements: NSSize = .init(width: 1.0, height: 1.0) var body: some View { - SplitView(container.direction, left: { + SplitView( + container.direction, + resizeIncrements: resizeIncrements, + resizePublisher: container.resizeEvent, + left: { let neighborKey: WritableKeyPath = container.direction == .horizontal ? \.right : \.bottom TerminalSplitNested( @@ -303,8 +307,6 @@ extension Ghostty { guard ghostty.windowStepResize else { return } self.resizeIncrements = increments } - .resizeIncrements(resizeIncrements) - .resizePublisher(container.resizeEvent) } private func closeableTopLeft() -> Binding { diff --git a/macos/Sources/Helpers/SplitView/SplitView.swift b/macos/Sources/Helpers/SplitView/SplitView.swift index 79c8ed91c..3dd0f8129 100644 --- a/macos/Sources/Helpers/SplitView/SplitView.swift +++ b/macos/Sources/Helpers/SplitView/SplitView.swift @@ -10,6 +10,13 @@ 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 @@ -17,13 +24,6 @@ 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 @@ -52,8 +52,29 @@ struct SplitView: View { } } + /// 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() } @@ -142,34 +163,3 @@ 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) - } -} From bc0d1a9255332622243f6e5af5495fae16a43c50 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Nov 2023 09:11:50 -0800 Subject: [PATCH 7/9] macos: remove focused cell size resize increment setter --- .../Sources/Ghostty/Ghostty.TerminalSplit.swift | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift index 4e4b3d39c..65fabf939 100644 --- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift +++ b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift @@ -267,19 +267,10 @@ 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, - resizeIncrements: resizeIncrements, + resizeIncrements: .init(width: 1, height: 1), resizePublisher: container.resizeEvent, left: { let neighborKey: WritableKeyPath = container.direction == .horizontal ? \.right : \.bottom @@ -302,11 +293,6 @@ extension Ghostty { ]) ) }) - .onChange(of: focusedCellSize) { newValue in - guard let increments = newValue else { return } - guard ghostty.windowStepResize else { return } - self.resizeIncrements = increments - } } private func closeableTopLeft() -> Binding { From 1ff81682f766dfce40a7071daf41a67fdc515414 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Nov 2023 09:19:59 -0800 Subject: [PATCH 8/9] input: add format support for tuple args --- src/input/Binding.zig | 42 ++++++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index dcce76b01..568479d86 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -356,24 +356,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)), }, } } From 7be5eba9cba07178903479969efabe6b353f7df9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Nov 2023 09:23:24 -0800 Subject: [PATCH 9/9] input: unit tests for pasing tuples --- src/input/Binding.zig | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 568479d86..ed7a93d48 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -288,6 +288,9 @@ pub const Action = union(enum) { }; } + // If we have extra parameters it is an error + if (it.next() != null) return Error.InvalidFormat; + break :blk value; }, @@ -829,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;