diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index f0cbd51f..08bb0b63 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -17,6 +17,9 @@ A5001093 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001090 /* AppDelegate.swift */; }; A5001094 /* NotificationsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001091 /* NotificationsPage.swift */; }; A5001095 /* TerminalNotificationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001092 /* TerminalNotificationStore.swift */; }; + A50010A4 /* SplitTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50010A0 /* SplitTree.swift */; }; + A50010A5 /* SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50010A1 /* SplitView.swift */; }; + A50010A6 /* TerminalSplitTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50010A2 /* TerminalSplitTreeView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -46,6 +49,9 @@ A5001090 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; A5001091 /* NotificationsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPage.swift; sourceTree = ""; }; A5001092 /* TerminalNotificationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationStore.swift; sourceTree = ""; }; + A50010A0 /* SplitTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Splits/SplitTree.swift; sourceTree = ""; }; + A50010A1 /* SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Splits/SplitView.swift; sourceTree = ""; }; + A50010A2 /* TerminalSplitTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Splits/TerminalSplitTreeView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -83,6 +89,9 @@ A5001090 /* AppDelegate.swift */, A5001091 /* NotificationsPage.swift */, A5001092 /* TerminalNotificationStore.swift */, + A50010A0 /* SplitTree.swift */, + A50010A1 /* SplitView.swift */, + A50010A2 /* TerminalSplitTreeView.swift */, ); path = Sources; sourceTree = ""; @@ -157,6 +166,9 @@ A5001093 /* AppDelegate.swift in Sources */, A5001094 /* NotificationsPage.swift in Sources */, A5001095 /* TerminalNotificationStore.swift in Sources */, + A50010A4 /* SplitTree.swift in Sources */, + A50010A5 /* SplitView.swift in Sources */, + A50010A6 /* TerminalSplitTreeView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 864aa03c..db0987a0 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -55,9 +55,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { - let shouldPresent = shouldPresentNotification(notification) - let options: UNNotificationPresentationOptions = shouldPresent ? [.banner, .sound] : [] - completionHandler(options) + completionHandler([.banner, .sound]) } private func handleNotificationResponse(_ response: UNNotificationResponse) { @@ -65,6 +63,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let tabId = UUID(uuidString: tabIdString) else { return } + let surfaceId: UUID? = { + guard let surfaceIdString = response.notification.request.content.userInfo["surfaceId"] as? String else { + return nil + } + return UUID(uuidString: surfaceIdString) + }() switch response.actionIdentifier { case UNNotificationDefaultActionIdentifier, TerminalNotificationStore.actionShowIdentifier: @@ -75,7 +79,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let notificationId = UUID(uuidString: notificationIdString) { self.notificationStore?.markRead(id: notificationId) } - self.tabManager?.focusTab(tabId) + self.tabManager?.focusTab(tabId, surfaceId: surfaceId) } case UNNotificationDismissActionIdentifier: DispatchQueue.main.async { @@ -91,21 +95,4 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } - private func shouldPresentNotification(_ notification: UNNotification) -> Bool { - guard let tabManager else { return true } - guard let tabIdString = notification.request.content.userInfo["tabId"] as? String, - let tabId = UUID(uuidString: tabIdString) else { - return true - } - - let isAppActive = NSApp.isActive - let isTabActive = tabManager.selectedTabId == tabId - let isKeyWindow = NSApp.keyWindow?.isKeyWindow ?? false - - if isAppActive && isTabActive && isKeyWindow { - return false - } - - return true - } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 3292e271..51a22f29 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -42,7 +42,7 @@ struct ContentView: View { ZStack { ForEach(tabManager.tabs) { tab in let isActive = tabManager.selectedTabId == tab.id - GhosttyTerminalView(terminalSurface: tab.terminalSurface, isActive: isActive) + TerminalSplitTreeView(tab: tab, isTabActive: isActive) .opacity(isActive ? 1 : 0) .allowsHitTesting(isActive) .focusable() diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 4c641e63..386a0db3 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -151,7 +151,19 @@ class GhosttyApp { } } runtimeConfig.close_surface_cb = { userdata, processAlive in - // Surface closed + guard let userdata else { return } + let surfaceView = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + guard let tabId = surfaceView.tabId, + let surfaceId = surfaceView.terminalSurface?.id else { + return + } + + DispatchQueue.main.async { + _ = AppDelegate.shared?.tabManager?.closeSurface( + tabId: tabId, + surfaceId: surfaceId + ) + } } // Create app @@ -191,18 +203,64 @@ class GhosttyApp { ghostty_app_tick(app) } + private func performOnMain(_ work: () -> T) -> T { + if Thread.isMainThread { + return work() + } + return DispatchQueue.main.sync(execute: work) + } + + private func splitDirection(from direction: ghostty_action_split_direction_e) -> SplitTree.NewDirection? { + switch direction { + case GHOSTTY_SPLIT_DIRECTION_RIGHT: return .right + case GHOSTTY_SPLIT_DIRECTION_LEFT: return .left + case GHOSTTY_SPLIT_DIRECTION_DOWN: return .down + case GHOSTTY_SPLIT_DIRECTION_UP: return .up + default: return nil + } + } + + private func focusDirection(from direction: ghostty_action_goto_split_e) -> SplitTree.FocusDirection? { + switch direction { + case GHOSTTY_GOTO_SPLIT_PREVIOUS: return .previous + case GHOSTTY_GOTO_SPLIT_NEXT: return .next + case GHOSTTY_GOTO_SPLIT_UP: return .spatial(.up) + case GHOSTTY_GOTO_SPLIT_DOWN: return .spatial(.down) + case GHOSTTY_GOTO_SPLIT_LEFT: return .spatial(.left) + case GHOSTTY_GOTO_SPLIT_RIGHT: return .spatial(.right) + default: return nil + } + } + + private func resizeDirection(from direction: ghostty_action_resize_split_direction_e) -> SplitTree.Spatial.Direction? { + switch direction { + case GHOSTTY_RESIZE_SPLIT_UP: return .up + case GHOSTTY_RESIZE_SPLIT_DOWN: return .down + case GHOSTTY_RESIZE_SPLIT_LEFT: return .left + case GHOSTTY_RESIZE_SPLIT_RIGHT: return .right + default: return nil + } + } + private func handleAction(target: ghostty_target_s, action: ghostty_action_s) -> Bool { if target.tag != GHOSTTY_TARGET_SURFACE { if action.tag == GHOSTTY_ACTION_DESKTOP_NOTIFICATION, - let tabId = AppDelegate.shared?.tabManager?.selectedTabId { + let tabManager = AppDelegate.shared?.tabManager, + let tabId = tabManager.selectedTabId { let actionTitle = action.action.desktop_notification.title .flatMap { String(cString: $0) } ?? "" let actionBody = action.action.desktop_notification.body .flatMap { String(cString: $0) } ?? "" let tabTitle = AppDelegate.shared?.tabManager?.titleForTab(tabId) ?? "Terminal" let body = actionBody.isEmpty ? actionTitle : actionBody + let surfaceId = tabManager.focusedSurfaceId(for: tabId) DispatchQueue.main.async { - TerminalNotificationStore.shared.addNotification(tabId: tabId, title: tabTitle, body: body) + TerminalNotificationStore.shared.addNotification( + tabId: tabId, + surfaceId: surfaceId, + title: tabTitle, + body: body + ) } return true } @@ -213,6 +271,59 @@ class GhosttyApp { let surfaceView = Unmanaged.fromOpaque(userdata).takeUnretainedValue() switch action.tag { + case GHOSTTY_ACTION_NEW_SPLIT: + guard let tabId = surfaceView.tabId, + let surfaceId = surfaceView.terminalSurface?.id, + let direction = splitDirection(from: action.action.new_split), + let tabManager = AppDelegate.shared?.tabManager else { + return false + } + return performOnMain { + tabManager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) + } + case GHOSTTY_ACTION_GOTO_SPLIT: + guard let tabId = surfaceView.tabId, + let surfaceId = surfaceView.terminalSurface?.id, + let direction = focusDirection(from: action.action.goto_split), + let tabManager = AppDelegate.shared?.tabManager else { + return false + } + return performOnMain { + tabManager.moveSplitFocus(tabId: tabId, surfaceId: surfaceId, direction: direction) + } + case GHOSTTY_ACTION_RESIZE_SPLIT: + guard let tabId = surfaceView.tabId, + let surfaceId = surfaceView.terminalSurface?.id, + let direction = resizeDirection(from: action.action.resize_split.direction), + let tabManager = AppDelegate.shared?.tabManager else { + return false + } + let amount = action.action.resize_split.amount + return performOnMain { + tabManager.resizeSplit( + tabId: tabId, + surfaceId: surfaceId, + direction: direction, + amount: amount + ) + } + case GHOSTTY_ACTION_EQUALIZE_SPLITS: + guard let tabId = surfaceView.tabId, + let tabManager = AppDelegate.shared?.tabManager else { + return false + } + return performOnMain { + tabManager.equalizeSplits(tabId: tabId) + } + case GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM: + guard let tabId = surfaceView.tabId, + let surfaceId = surfaceView.terminalSurface?.id, + let tabManager = AppDelegate.shared?.tabManager else { + return false + } + return performOnMain { + tabManager.toggleSplitZoom(tabId: tabId, surfaceId: surfaceId) + } case GHOSTTY_ACTION_SCROLLBAR: let scrollbar = GhosttyScrollbar(c: action.action.scrollbar) surfaceView.scrollbar = scrollbar @@ -252,6 +363,7 @@ class GhosttyApp { return true case GHOSTTY_ACTION_DESKTOP_NOTIFICATION: guard let tabId = surfaceView.tabId else { return true } + let surfaceId = surfaceView.terminalSurface?.id let actionTitle = action.action.desktop_notification.title .flatMap { String(cString: $0) } ?? "" let actionBody = action.action.desktop_notification.body @@ -259,7 +371,12 @@ class GhosttyApp { let tabTitle = AppDelegate.shared?.tabManager?.titleForTab(tabId) ?? "Terminal" let body = actionBody.isEmpty ? actionTitle : actionBody DispatchQueue.main.async { - TerminalNotificationStore.shared.addNotification(tabId: tabId, title: tabTitle, body: body) + TerminalNotificationStore.shared.addNotification( + tabId: tabId, + surfaceId: surfaceId, + title: tabTitle, + body: body + ) } return true default: @@ -270,15 +387,27 @@ class GhosttyApp { // MARK: - Terminal Surface (owns the ghostty_surface_t lifecycle) -class TerminalSurface { +class TerminalSurface: Identifiable { private(set) var surface: ghostty_surface_t? private var displayLink: CVDisplayLink? private weak var attachedView: GhosttyNSView? + let id: UUID let tabId: UUID + private let surfaceContext: ghostty_surface_context_e + private let configTemplate: ghostty_surface_config_s? + let hostedView: GhosttySurfaceScrollView + private let surfaceView: GhosttyNSView - init(tabId: UUID) { + init(tabId: UUID, context: ghostty_surface_context_e, configTemplate: ghostty_surface_config_s?) { + self.id = UUID() self.tabId = tabId + self.surfaceContext = context + self.configTemplate = configTemplate + let view = GhosttyNSView(frame: .zero) + self.surfaceView = view + self.hostedView = GhosttySurfaceScrollView(surfaceView: view) // Surface is created when attached to a view + hostedView.attachSurface(self) } func attachToView(_ view: GhosttyNSView) { @@ -288,14 +417,15 @@ class TerminalSurface { return } + if let attachedView, attachedView !== view { + return + } + attachedView = view // If surface doesn't exist yet, create it if surface == nil { createSurface(for: view) - } else { - // Re-attach existing surface to new view - reattachSurface(to: view) } } @@ -309,12 +439,12 @@ class TerminalSurface { updateMetalLayer(for: view) - var surfaceConfig = ghostty_surface_config_new() + var surfaceConfig = configTemplate ?? ghostty_surface_config_new() surfaceConfig.platform_tag = GHOSTTY_PLATFORM_MACOS surfaceConfig.platform.macos.nsview = Unmanaged.passUnretained(view).toOpaque() surfaceConfig.userdata = Unmanaged.passUnretained(view).toOpaque() surfaceConfig.scale_factor = scale - surfaceConfig.context = GHOSTTY_SURFACE_CONTEXT_TAB + surfaceConfig.context = surfaceContext surface = ghostty_surface_new(app, &surfaceConfig) @@ -332,21 +462,6 @@ class TerminalSurface { setupDisplayLink() } - private func reattachSurface(to view: GhosttyNSView) { - guard let surface = surface else { return } - - let scale = view.window?.screen?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0 - - updateMetalLayer(for: view) - // Update the nsview pointer in the surface - ghostty_surface_set_content_scale(surface, scale, scale) - ghostty_surface_set_size( - surface, - UInt32(view.bounds.width * scale), - UInt32(view.bounds.height * scale) - ) - } - private func updateMetalLayer(for view: GhosttyNSView) { let scale = view.window?.screen?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0 if let metalLayer = view.layer as? CAMetalLayer { @@ -408,12 +523,13 @@ class TerminalSurface { // MARK: - Ghostty Surface View class GhosttyNSView: NSView, NSUserInterfaceValidations { - var terminalSurface: TerminalSurface? + weak var terminalSurface: TerminalSurface? private var surfaceAttached = false var scrollbar: GhosttyScrollbar? var cellSize: CGSize = .zero var desiredFocus: Bool = false var tabId: UUID? + var onFocus: (() -> Void)? private var eventMonitor: Any? private var trackingArea: NSTrackingArea? @@ -561,6 +677,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override func becomeFirstResponder() -> Bool { let result = super.becomeFirstResponder() if result, let surface = surface { + onFocus?() ghostty_surface_set_focus(surface, true) } return result @@ -1033,6 +1150,10 @@ final class GhosttySurfaceScrollView: NSView { surfaceView.attachSurface(terminalSurface) } + func setFocusHandler(_ handler: (() -> Void)?) { + surfaceView.onFocus = handler + } + func setActive(_ active: Bool) { isActive = active updateFocusForWindow() @@ -1250,17 +1371,19 @@ extension GhosttyNSView: NSTextInputClient { struct GhosttyTerminalView: NSViewRepresentable { let terminalSurface: TerminalSurface var isActive: Bool = true + var onFocus: ((UUID) -> Void)? = nil func makeNSView(context: Context) -> GhosttySurfaceScrollView { - let surfaceView = GhosttyNSView(frame: .zero) - let view = GhosttySurfaceScrollView(surfaceView: surfaceView) + let view = terminalSurface.hostedView view.attachSurface(terminalSurface) view.setActive(isActive) + view.setFocusHandler { onFocus?(terminalSurface.id) } return view } func updateNSView(_ nsView: GhosttySurfaceScrollView, context: Context) { nsView.attachSurface(terminalSurface) nsView.setActive(isActive) + nsView.setFocusHandler { onFocus?(terminalSurface.id) } } } diff --git a/Sources/NotificationsPage.swift b/Sources/NotificationsPage.swift index 32146fa1..9b20da50 100644 --- a/Sources/NotificationsPage.swift +++ b/Sources/NotificationsPage.swift @@ -20,7 +20,7 @@ struct NotificationsPage: View { notification: notification, tabTitle: tabTitle(for: notification.tabId), onOpen: { - tabManager.focusTab(notification.tabId) + tabManager.focusTab(notification.tabId, surfaceId: notification.surfaceId) notificationStore.markRead(id: notification.id) selection = .tabs }, diff --git a/Sources/Splits/SplitTree.swift b/Sources/Splits/SplitTree.swift new file mode 100644 index 00000000..2bce8214 --- /dev/null +++ b/Sources/Splits/SplitTree.swift @@ -0,0 +1,820 @@ +import CoreGraphics +import Foundation + +/// SplitTree represents a tree of views that can be divided. +struct SplitTree { + /// The root of the tree. This can be nil to indicate the tree is empty. + let root: Node? + + /// The node that is currently zoomed. A zoomed split is expected to take up the full + /// size of the view area where the splits are shown. + let zoomed: Node? + + /// A single node in the tree is either a leaf node (a view) or a split (has a + /// left/right or top/bottom). + indirect enum Node { + case leaf(view: ViewType) + case split(Split) + + struct Split: Equatable { + let direction: Direction + let ratio: Double + let left: Node + let right: Node + } + } + + enum Direction: Hashable { + case horizontal // Splits are laid out left and right + case vertical // Splits are laid out top and bottom + } + + /// The path to a specific node in the tree. + struct Path { + let path: [Component] + + var isEmpty: Bool { path.isEmpty } + + enum Component { + case left + case right + } + } + + /// Spatial representation of the split tree. This can be used to better understand + /// its physical representation to perform tasks such as navigation. + struct Spatial { + let slots: [Slot] + + /// A single slot within the spatial mapping of a tree. Note that the bounds are + /// _relative_. They can't be mapped to physical pixels because the SplitTree + /// isn't aware of actual rendering. But relative to each other the bounds are + /// correct. + struct Slot { + let node: Node + let bounds: CGRect + } + + /// Direction for spatial navigation within the split tree. + enum Direction { + case left + case right + case up + case down + } + } + + enum SplitError: Error { + case viewNotFound + } + + enum NewDirection { + case left + case right + case down + case up + } + + /// The direction that focus can move from a node. + enum FocusDirection { + // Follow a consistent tree-like structure. + case previous + case next + + // Spatially-aware navigation targets. These take into account the + // layout to find the spatially correct node to move to. Spatial navigation + // is always from the top-left corner for now. + case spatial(Spatial.Direction) + } +} + +// MARK: SplitTree + +extension SplitTree { + var isEmpty: Bool { + root == nil + } + + /// Returns true if this tree is split. + var isSplit: Bool { + if case .split = root { true } else { false } + } + + init() { + self.init(root: nil, zoomed: nil) + } + + init(view: ViewType) { + self.init(root: .leaf(view: view), zoomed: nil) + } + + /// Checks if the tree contains the specified node. + func contains(_ node: Node) -> Bool { + guard let root else { return false } + return root.path(to: node) != nil + } + + /// Checks if the tree contains the specified view. + func contains(_ view: ViewType) -> Bool { + guard let root else { return false } + return root.node(view: view) != nil + } + + /// Insert a new view at the given view point by creating a split in the given direction. + /// This will always reset the zoomed state of the tree. + func inserting(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self { + guard let root else { throw SplitError.viewNotFound } + return .init( + root: try root.inserting(view: view, at: at, direction: direction), + zoomed: nil) + } + + /// Find a node containing a view with the specified ID. + func find(id: ViewType.ID) -> Node? { + guard let root else { return nil } + return root.find(id: id) + } + + /// Remove a node from the tree. If the node being removed is part of a split, + /// the sibling node takes the place of the parent split. + func removing(_ target: Node) -> Self { + guard let root else { return self } + + if root == target { + return .init(root: nil, zoomed: nil) + } + + let newRoot = root.remove(target) + let newZoomed = (zoomed == target) ? nil : zoomed + return .init(root: newRoot, zoomed: newZoomed) + } + + /// Replace a node in the tree with a new node. + func replacing(node: Node, with newNode: Node) throws -> Self { + guard let root else { throw SplitError.viewNotFound } + guard let path = root.path(to: node) else { + throw SplitError.viewNotFound + } + + let newRoot = try root.replacingNode(at: path, with: newNode) + let newZoomed = (zoomed == node) ? newNode : zoomed + return .init(root: newRoot, zoomed: newZoomed) + } + + /// Find the next view to focus based on the current focused node and direction. + func focusTarget(for direction: FocusDirection, from currentNode: Node) -> ViewType? { + guard let root else { return nil } + + switch direction { + case .previous: + let allLeaves = root.leaves() + let currentView = currentNode.leftmostLeaf() + guard let currentIndex = allLeaves.firstIndex(where: { $0 === currentView }) else { + return nil + } + let index = allLeaves.indexWrapping(before: currentIndex) + return allLeaves[index] + + case .next: + let allLeaves = root.leaves() + let currentView = currentNode.rightmostLeaf() + guard let currentIndex = allLeaves.firstIndex(where: { $0 === currentView }) else { + return nil + } + let index = allLeaves.indexWrapping(after: currentIndex) + return allLeaves[index] + + case .spatial(let spatialDirection): + let spatial = root.spatial() + let nodes = spatial.slots(in: spatialDirection, from: currentNode) + if nodes.isEmpty { + return nil + } + + let bestNode = nodes.first(where: { + if case .leaf = $0.node { return true } else { return false } + }) ?? nodes[0] + switch bestNode.node { + case .leaf(let view): + return view + case .split: + return switch (spatialDirection) { + case .up, .left: bestNode.node.leftmostLeaf() + case .down, .right: bestNode.node.rightmostLeaf() + } + } + } + } + + /// Equalize all splits in the tree so that each split's ratio is based on the + /// relative weight (number of leaves) of its children. + func equalized() -> Self { + guard let root else { return self } + let newRoot = root.equalize() + return .init(root: newRoot, zoomed: zoomed) + } + + /// Resize a node in the tree by the given pixel amount in the specified direction. + func resizing(node: Node, by pixels: UInt16, in direction: Spatial.Direction, with bounds: CGRect) throws -> Self { + guard let root else { throw SplitError.viewNotFound } + guard let path = root.path(to: node) else { + throw SplitError.viewNotFound + } + + let targetSplitDirection: Direction = switch direction { + case .up, .down: .vertical + case .left, .right: .horizontal + } + + var splitPath: Path? + var splitNode: Node? + + for i in stride(from: path.path.count - 1, through: 0, by: -1) { + let parentPath = Path(path: Array(path.path.prefix(i))) + if let parent = root.node(at: parentPath), case .split(let split) = parent { + if split.direction == targetSplitDirection { + splitPath = parentPath + splitNode = parent + break + } + } + } + + guard let splitPath = splitPath, + let splitNode = splitNode, + case .split(let split) = splitNode else { + throw SplitError.viewNotFound + } + + let spatial = root.spatial(within: bounds.size) + guard let splitSlot = spatial.slots.first(where: { $0.node == splitNode }) else { + throw SplitError.viewNotFound + } + + let pixelOffset = Double(pixels) + let newRatio: Double + + switch (split.direction, direction) { + case (.horizontal, .left): + newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio - (pixelOffset / splitSlot.bounds.width))) + case (.horizontal, .right): + newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio + (pixelOffset / splitSlot.bounds.width))) + case (.vertical, .up): + newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio - (pixelOffset / splitSlot.bounds.height))) + case (.vertical, .down): + newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio + (pixelOffset / splitSlot.bounds.height))) + default: + throw SplitError.viewNotFound + } + + let newSplit = Node.Split( + direction: split.direction, + ratio: newRatio, + left: split.left, + right: split.right + ) + + let newRoot = try root.replacingNode(at: splitPath, with: .split(newSplit)) + return .init(root: newRoot, zoomed: nil) + } +} + +// MARK: SplitTree.Node + +extension SplitTree.Node { + typealias Node = SplitTree.Node + typealias NewDirection = SplitTree.NewDirection + typealias SplitError = SplitTree.SplitError + typealias Path = SplitTree.Path + + /// Find a node containing a view with the specified ID. + func find(id: ViewType.ID) -> Node? { + switch self { + case .leaf(let view): + return view.id == id ? self : nil + case .split(let split): + if let found = split.left.find(id: id) { + return found + } + return split.right.find(id: id) + } + } + + /// Returns the node in the tree that contains the given view. + func node(view: ViewType) -> Node? { + switch self { + case .leaf(let nodeView): + return nodeView === view ? self : nil + case .split(let split): + if let result = split.left.node(view: view) { + return result + } else if let result = split.right.node(view: view) { + return result + } + return nil + } + } + + /// Returns the path to a given node in the tree. + func path(to node: Self) -> Path? { + var components: [Path.Component] = [] + func search(_ current: Self) -> Bool { + if current == node { + return true + } + + switch current { + case .leaf: + return false + case .split(let split): + components.append(.left) + if search(split.left) { + return true + } + components.removeLast() + + components.append(.right) + if search(split.right) { + return true + } + components.removeLast() + return false + } + } + + return search(self) ? Path(path: components) : nil + } + + /// Returns the node at the given path from this node as root. + func node(at path: Path) -> Node? { + if path.isEmpty { + return self + } + + guard case .split(let split) = self else { + return nil + } + + let component = path.path[0] + let remainingPath = Path(path: Array(path.path.dropFirst())) + + switch component { + case .left: + return split.left.node(at: remainingPath) + case .right: + return split.right.node(at: remainingPath) + } + } + + /// Inserts a new view into the split tree by creating a split at the location of an existing view. + func inserting(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self { + guard let path = path(to: .leaf(view: at)) else { + throw SplitError.viewNotFound + } + + let splitDirection: SplitTree.Direction + let newViewOnLeft: Bool + switch direction { + case .left: + splitDirection = .horizontal + newViewOnLeft = true + case .right: + splitDirection = .horizontal + newViewOnLeft = false + case .up: + splitDirection = .vertical + newViewOnLeft = true + case .down: + splitDirection = .vertical + newViewOnLeft = false + } + + let newNode: Node = .split(.init( + direction: splitDirection, + ratio: 0.5, + left: newViewOnLeft ? .leaf(view: view) : .leaf(view: at), + right: newViewOnLeft ? .leaf(view: at) : .leaf(view: view) + )) + + return try replacingNode(at: path, with: newNode) + } + + /// Replace a node at the specified path with a new node. + func replacingNode(at path: Path, with newNode: Node) throws -> Node { + if path.isEmpty { + return newNode + } + + guard case .split(let split) = self else { + throw SplitError.viewNotFound + } + + let component = path.path[0] + let remainingPath = Path(path: Array(path.path.dropFirst())) + + switch component { + case .left: + return .split(.init( + direction: split.direction, + ratio: split.ratio, + left: try split.left.replacingNode(at: remainingPath, with: newNode), + right: split.right + )) + case .right: + return .split(.init( + direction: split.direction, + ratio: split.ratio, + left: split.left, + right: try split.right.replacingNode(at: remainingPath, with: newNode) + )) + } + } + + /// Remove a node from the tree. + func remove(_ target: Node) -> Node? { + if self == target { + return nil + } + + switch self { + case .leaf: + return self + case .split(let split): + let newLeft = split.left.remove(target) + let newRight = split.right.remove(target) + + if newLeft == nil && newRight == nil { + return nil + } else if newLeft == nil { + return newRight + } else if newRight == nil { + return newLeft + } + + return .split(.init( + direction: split.direction, + ratio: split.ratio, + left: newLeft!, + right: newRight! + )) + } + } + + /// Resize a split node to the specified ratio. + func resizing(to ratio: Double) -> Self { + switch self { + case .leaf: + return self + case .split(let split): + return .split(.init( + direction: split.direction, + ratio: ratio, + left: split.left, + right: split.right + )) + } + } + + /// Get the leftmost leaf in this subtree. + func leftmostLeaf() -> ViewType { + switch self { + case .leaf(let view): + return view + case .split(let split): + return split.left.leftmostLeaf() + } + } + + /// Get the rightmost leaf in this subtree. + func rightmostLeaf() -> ViewType { + switch self { + case .leaf(let view): + return view + case .split(let split): + return split.right.rightmostLeaf() + } + } + + /// Equalize this node and all its children. + func equalize() -> Node { + let (equalizedNode, _) = equalizeWithWeight() + return equalizedNode + } + + private func equalizeWithWeight() -> (node: Node, weight: Int) { + switch self { + case .leaf: + return (self, 1) + case .split(let split): + let leftWeight = split.left.weightForDirection(split.direction) + let rightWeight = split.right.weightForDirection(split.direction) + let totalWeight = leftWeight + rightWeight + let newRatio = Double(leftWeight) / Double(totalWeight) + let (leftNode, _) = split.left.equalizeWithWeight() + let (rightNode, _) = split.right.equalizeWithWeight() + let newSplit = Split( + direction: split.direction, + ratio: newRatio, + left: leftNode, + right: rightNode + ) + return (.split(newSplit), totalWeight) + } + } + + private func weightForDirection(_ direction: SplitTree.Direction) -> Int { + switch self { + case .leaf: + return 1 + case .split(let split): + if split.direction == direction { + return split.left.weightForDirection(direction) + split.right.weightForDirection(direction) + } + return 1 + } + } + + /// Returns all leaf nodes in order. + func leaves() -> [ViewType] { + switch self { + case .leaf(let view): + return [view] + case .split(let split): + return split.left.leaves() + split.right.leaves() + } + } +} + +// MARK: SplitTree.Node Spatial + +extension SplitTree.Node { + func spatial(within bounds: CGSize? = nil) -> SplitTree.Spatial { + let width: Double + let height: Double + if let bounds { + width = bounds.width + height = bounds.height + } else { + let (w, h) = self.dimensions() + width = Double(w) + height = Double(h) + } + + let slots = spatialSlots(in: CGRect(x: 0, y: 0, width: width, height: height)) + return SplitTree.Spatial(slots: slots) + } + + private func dimensions() -> (width: UInt, height: UInt) { + switch self { + case .leaf: + return (1, 1) + case .split(let split): + let leftDimensions = split.left.dimensions() + let rightDimensions = split.right.dimensions() + + switch split.direction { + case .horizontal: + return ( + width: leftDimensions.width + rightDimensions.width, + height: Swift.max(leftDimensions.height, rightDimensions.height) + ) + case .vertical: + return ( + width: Swift.max(leftDimensions.width, rightDimensions.width), + height: leftDimensions.height + rightDimensions.height + ) + } + } + } + + private func spatialSlots(in bounds: CGRect) -> [SplitTree.Spatial.Slot] { + switch self { + case .leaf: + return [.init(node: self, bounds: bounds)] + case .split(let split): + let leftBounds: CGRect + let rightBounds: CGRect + + switch split.direction { + case .horizontal: + let splitX = bounds.minX + bounds.width * split.ratio + leftBounds = CGRect( + x: bounds.minX, + y: bounds.minY, + width: bounds.width * split.ratio, + height: bounds.height + ) + rightBounds = CGRect( + x: splitX, + y: bounds.minY, + width: bounds.width * (1 - split.ratio), + height: bounds.height + ) + case .vertical: + let splitY = bounds.minY + bounds.height * split.ratio + leftBounds = CGRect( + x: bounds.minX, + y: bounds.minY, + width: bounds.width, + height: bounds.height * split.ratio + ) + rightBounds = CGRect( + x: bounds.minX, + y: splitY, + width: bounds.width, + height: bounds.height * (1 - split.ratio) + ) + } + + var slots: [SplitTree.Spatial.Slot] = [.init(node: self, bounds: bounds)] + slots += split.left.spatialSlots(in: leftBounds) + slots += split.right.spatialSlots(in: rightBounds) + + return slots + } + } +} + +// MARK: SplitTree.Spatial + +extension SplitTree.Spatial { + func slots(in direction: Direction, from referenceNode: SplitTree.Node) -> [Slot] { + guard let refSlot = slots.first(where: { $0.node == referenceNode }) else { return [] } + + func distance(from rect1: CGRect, to rect2: CGRect) -> Double { + let dx = rect2.minX - rect1.minX + let dy = rect2.minY - rect1.minY + return sqrt(dx * dx + dy * dy) + } + + let result = switch direction { + case .left: + slots.filter { + $0.node != referenceNode && $0.bounds.maxX <= refSlot.bounds.minX + }.sorted { + distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) + } + case .right: + slots.filter { + $0.node != referenceNode && $0.bounds.minX >= refSlot.bounds.maxX + }.sorted { + distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) + } + case .up: + slots.filter { + $0.node != referenceNode && $0.bounds.maxY <= refSlot.bounds.minY + }.sorted { + distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) + } + case .down: + slots.filter { + $0.node != referenceNode && $0.bounds.minY >= refSlot.bounds.maxY + }.sorted { + distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) + } + } + + return result + } +} + +// MARK: SplitTree.Node Protocols + +extension SplitTree.Node: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case let (.leaf(leftView), .leaf(rightView)): + return leftView === rightView + case let (.split(split1), .split(split2)): + return split1 == split2 + default: + return false + } + } +} + +// MARK: Structural Identity + +extension SplitTree.Node { + var structuralIdentity: StructuralIdentity { + StructuralIdentity(self) + } + + struct StructuralIdentity: Hashable { + private let node: SplitTree.Node + + init(_ node: SplitTree.Node) { + self.node = node + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.node.isStructurallyEqual(to: rhs.node) + } + + func hash(into hasher: inout Hasher) { + node.hashStructure(into: &hasher) + } + } + + fileprivate func isStructurallyEqual(to other: Node) -> Bool { + switch (self, other) { + case let (.leaf(view1), .leaf(view2)): + return view1 === view2 + case let (.split(split1), .split(split2)): + return split1.direction == split2.direction && + split1.left.isStructurallyEqual(to: split2.left) && + split1.right.isStructurallyEqual(to: split2.right) + default: + return false + } + } + + private enum HashKey: UInt8 { + case leaf = 0 + case split = 1 + } + + fileprivate func hashStructure(into hasher: inout Hasher) { + switch self { + case .leaf(let view): + hasher.combine(HashKey.leaf) + hasher.combine(ObjectIdentifier(view)) + case .split(let split): + hasher.combine(HashKey.split) + hasher.combine(split.direction) + split.left.hashStructure(into: &hasher) + split.right.hashStructure(into: &hasher) + } + } +} + +extension SplitTree { + var structuralIdentity: StructuralIdentity { + StructuralIdentity(self) + } + + struct StructuralIdentity: Hashable { + private let root: Node? + private let zoomed: Node? + + init(_ tree: SplitTree) { + self.root = tree.root + self.zoomed = tree.zoomed + } + + static func == (lhs: Self, rhs: Self) -> Bool { + areNodesStructurallyEqual(lhs.root, rhs.root) && + areNodesStructurallyEqual(lhs.zoomed, rhs.zoomed) + } + + func hash(into hasher: inout Hasher) { + hasher.combine(0) + if let root = root { + root.hashStructure(into: &hasher) + } + hasher.combine(1) + if let zoomed = zoomed { + zoomed.hashStructure(into: &hasher) + } + } + + private static func areNodesStructurallyEqual(_ lhs: Node?, _ rhs: Node?) -> Bool { + switch (lhs, rhs) { + case (nil, nil): + return true + case let (node1?, node2?): + return node1.isStructurallyEqual(to: node2) + default: + return false + } + } + } +} + +// MARK: SplitTree Sequence + +extension SplitTree: Sequence { + func makeIterator() -> IndexingIterator<[ViewType]> { + return (root?.leaves() ?? []).makeIterator() + } +} + +// MARK: Array Helpers + +extension Array { + /// Returns the index before i, with wraparound. Assumes i is a valid index. + func indexWrapping(before i: Int) -> Int { + if i == 0 { + return count - 1 + } + return i - 1 + } + + /// Returns the index after i, with wraparound. Assumes i is a valid index. + func indexWrapping(after i: Int) -> Int { + if i == count - 1 { + return 0 + } + return i + 1 + } +} diff --git a/Sources/Splits/SplitView.swift b/Sources/Splits/SplitView.swift new file mode 100644 index 00000000..818d0ca1 --- /dev/null +++ b/Sources/Splits/SplitView.swift @@ -0,0 +1,163 @@ +import SwiftUI + +/// A split view shows a left and right (or top and bottom) view with a divider in the middle to do resizing. +/// The terminology "left" and "right" is always used but for vertical splits "left" is "top" and "right" is "bottom". +struct SplitView: View { + /// Direction of the split + let direction: SplitViewDirection + + /// Divider color + let dividerColor: Color + + /// 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 + + /// The left and right views to render. + let left: L + let right: R + + /// Called when the divider is double-tapped to equalize splits. + let onEqualize: () -> Void + + /// The minimum size (in points) of a split + let minSize: CGFloat = 10 + + /// The current fractional width of the split view. 0.5 means L/R are equally sized, for example. + @Binding var split: CGFloat + + /// 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 + private let splitterInvisibleSize: CGFloat = 6 + + var body: some View { + GeometryReader { geo in + let leftRect = self.leftRect(for: geo.size) + let rightRect = self.rightRect(for: geo.size, leftRect: leftRect) + let splitterPoint = self.splitterPoint(for: geo.size, leftRect: leftRect) + + ZStack(alignment: .topLeading) { + left + .frame(width: leftRect.size.width, height: leftRect.size.height) + .offset(x: leftRect.origin.x, y: leftRect.origin.y) + right + .frame(width: rightRect.size.width, height: rightRect.size.height) + .offset(x: rightRect.origin.x, y: rightRect.origin.y) + Divider(direction: direction, + visibleSize: splitterVisibleSize, + invisibleSize: splitterInvisibleSize, + color: dividerColor, + split: $split) + .position(splitterPoint) + .gesture(dragGesture(geo.size, splitterPoint: splitterPoint)) + .onTapGesture(count: 2) { + onEqualize() + } + } + } + } + + /// Initialize a split view that can be resized by manually dragging the divider. + init( + _ direction: SplitViewDirection, + _ split: Binding, + dividerColor: Color, + resizeIncrements: NSSize = .init(width: 1, height: 1), + @ViewBuilder left: (() -> L), + @ViewBuilder right: (() -> R), + onEqualize: @escaping () -> Void + ) { + self.direction = direction + self._split = split + self.dividerColor = dividerColor + self.resizeIncrements = resizeIncrements + self.left = left() + self.right = right() + self.onEqualize = onEqualize + } + + private func dragGesture(_ size: CGSize, splitterPoint: CGPoint) -> some Gesture { + DragGesture() + .onChanged { gesture in + switch direction { + case .horizontal: + let new = min(max(minSize, gesture.location.x), size.width - minSize) + split = new / size.width + case .vertical: + let new = min(max(minSize, gesture.location.y), size.height - minSize) + split = new / size.height + } + } + } + + /// Calculates the bounding rect for the left view. + private func leftRect(for size: CGSize) -> CGRect { + var result = CGRect(x: 0, y: 0, width: size.width, height: size.height) + switch direction { + 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 + } + + /// Calculates the bounding rect for the right view. + private func rightRect(for size: CGSize, leftRect: CGRect) -> CGRect { + var result = CGRect(x: 0, y: 0, width: size.width, height: size.height) + switch direction { + case .horizontal: + result.origin.x += leftRect.size.width + result.origin.x += splitterVisibleSize / 2 + result.size.width -= result.origin.x + case .vertical: + result.origin.y += leftRect.size.height + result.origin.y += splitterVisibleSize / 2 + result.size.height -= result.origin.y + } + return result + } + + /// Calculates the point at which the splitter should be rendered. + private func splitterPoint(for size: CGSize, leftRect: CGRect) -> CGPoint { + switch direction { + case .horizontal: + return CGPoint(x: leftRect.size.width, y: size.height / 2) + case .vertical: + return CGPoint(x: size.width / 2, y: leftRect.size.height) + } + } +} + +private struct Divider: View { + let direction: SplitViewDirection + let visibleSize: CGFloat + let invisibleSize: CGFloat + let color: Color + @Binding var split: CGFloat + + var body: some View { + ZStack { + Rectangle() + .fill(Color.clear) + .frame(width: direction == .horizontal ? invisibleSize : nil, + height: direction == .vertical ? invisibleSize : nil) + Rectangle() + .fill(color) + .frame(width: direction == .horizontal ? visibleSize : nil, + height: direction == .vertical ? visibleSize : nil) + } + .frame(width: direction == .horizontal ? invisibleSize : nil, + height: direction == .vertical ? invisibleSize : nil) + } +} + +enum SplitViewDirection: Codable { + case horizontal, vertical +} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 458d882a..9ea72082 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -6,13 +6,162 @@ class Tab: Identifiable, ObservableObject { let id: UUID @Published var title: String @Published var currentDirectory: String - let terminalSurface: TerminalSurface + @Published var splitTree: SplitTree + @Published var focusedSurfaceId: UUID? + var splitViewSize: CGSize = .zero init(title: String = "Terminal") { self.id = UUID() self.title = title self.currentDirectory = FileManager.default.homeDirectoryForCurrentUser.path - self.terminalSurface = TerminalSurface(tabId: id) + let surface = TerminalSurface(tabId: id, context: GHOSTTY_SURFACE_CONTEXT_TAB, configTemplate: nil) + self.splitTree = SplitTree(view: surface) + self.focusedSurfaceId = surface.id + } + + var focusedSurface: TerminalSurface? { + guard let focusedSurfaceId else { return nil } + return surface(for: focusedSurfaceId) + } + + func surface(for id: UUID) -> TerminalSurface? { + guard let node = splitTree.root?.find(id: id) else { return nil } + if case .leaf(let view) = node { + return view + } + return nil + } + + func focusSurface(_ id: UUID) { + guard focusedSurfaceId != id else { return } + focusedSurfaceId = id + } + + func updateSplitViewSize(_ size: CGSize) { + guard splitViewSize != size else { return } + splitViewSize = size + } + + func updateSplitRatio(node: SplitTree.Node, ratio: Double) { + do { + splitTree = try splitTree.replacing(node: node, with: node.resizing(to: ratio)) + } catch { + return + } + } + + func equalizeSplits() { + splitTree = splitTree.equalized() + } + + func newSplit(from surfaceId: UUID, direction: SplitTree.NewDirection) -> TerminalSurface? { + guard let targetSurface = surface(for: surfaceId) else { return nil } + let inheritedConfig: ghostty_surface_config_s? = if let existing = targetSurface.surface { + ghostty_surface_inherited_config(existing, GHOSTTY_SURFACE_CONTEXT_SPLIT) + } else { + nil + } + + let newSurface = TerminalSurface( + tabId: id, + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: inheritedConfig + ) + + do { + splitTree = try splitTree.inserting(view: newSurface, at: targetSurface, direction: direction) + focusedSurfaceId = newSurface.id + return newSurface + } catch { + return nil + } + } + + func moveFocus(from surfaceId: UUID, direction: SplitTree.FocusDirection) -> Bool { + guard let root = splitTree.root, + let targetNode = root.find(id: surfaceId), + let nextSurface = splitTree.focusTarget(for: direction, from: targetNode) else { + return false + } + + focusedSurfaceId = nextSurface.id + return true + } + + func resizeSplit(from surfaceId: UUID, direction: SplitTree.Spatial.Direction, amount: UInt16) -> Bool { + guard let root = splitTree.root, + let targetNode = root.find(id: surfaceId), + splitViewSize.width > 0, + splitViewSize.height > 0 else { + return false + } + + do { + splitTree = try splitTree.resizing( + node: targetNode, + by: amount, + in: direction, + with: CGRect(origin: .zero, size: splitViewSize) + ) + return true + } catch { + return false + } + } + + func toggleZoom(on surfaceId: UUID) -> Bool { + guard let root = splitTree.root, + let targetNode = root.find(id: surfaceId) else { + return false + } + + guard splitTree.isSplit else { return false } + + if splitTree.zoomed == targetNode { + splitTree = SplitTree(root: splitTree.root, zoomed: nil) + } else { + splitTree = SplitTree(root: splitTree.root, zoomed: targetNode) + } + return true + } + + func closeSurface(_ surfaceId: UUID) -> Bool { + guard let root = splitTree.root, + let targetNode = root.find(id: surfaceId) else { + return false + } + + let shouldMoveFocus = focusedSurfaceId == surfaceId + let nextFocus: TerminalSurface? = if shouldMoveFocus { + if root.leftmostLeaf() === targetNode.leftmostLeaf() { + splitTree.focusTarget(for: .next, from: targetNode) + } else { + splitTree.focusTarget(for: .previous, from: targetNode) + } + } else { + nil + } + + splitTree = splitTree.removing(targetNode) + + if splitTree.isEmpty { + focusedSurfaceId = nil + return true + } + + if shouldMoveFocus { + if let nextFocus { + focusedSurfaceId = nextFocus.id + } else { + focusedSurfaceId = splitTree.root?.leftmostLeaf().id + } + } + + if !splitTree.isSplit { + splitTree = SplitTree(root: splitTree.root, zoomed: nil) + } + + return true } } @@ -76,6 +225,10 @@ class TabManager: ObservableObject { tabs.first(where: { $0.id == tabId })?.title } + func focusedSurfaceId(for tabId: UUID) -> UUID? { + tabs.first(where: { $0.id == tabId })?.focusedSurfaceId + } + private func updateTabTitle(tabId: UUID, title: String) { guard !title.isEmpty else { return } guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } @@ -84,7 +237,7 @@ class TabManager: ObservableObject { } } - func focusTab(_ tabId: UUID) { + func focusTab(_ tabId: UUID, surfaceId: UUID? = nil) { guard tabs.contains(where: { $0.id == tabId }) else { return } selectedTabId = tabId NotificationCenter.default.post( @@ -100,6 +253,15 @@ class TabManager: ObservableObject { window.makeKeyAndOrderFront(nil) } } + + if let surfaceId { + focusSurface(tabId: tabId, surfaceId: surfaceId) + } + } + + func focusSurface(tabId: UUID, surfaceId: UUID) { + guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + tab.focusSurface(surfaceId) } func selectNextTab() { @@ -120,6 +282,55 @@ class TabManager: ObservableObject { guard index >= 0 && index < tabs.count else { return } selectedTabId = tabs[index].id } + + func newSplit(tabId: UUID, surfaceId: UUID, direction: SplitTree.NewDirection) -> Bool { + guard let tab = tabs.first(where: { $0.id == tabId }) else { return false } + return tab.newSplit(from: surfaceId, direction: direction) != nil + } + + func moveSplitFocus(tabId: UUID, surfaceId: UUID, direction: SplitTree.FocusDirection) -> Bool { + guard let tab = tabs.first(where: { $0.id == tabId }) else { return false } + return tab.moveFocus(from: surfaceId, direction: direction) + } + + func resizeSplit(tabId: UUID, surfaceId: UUID, direction: SplitTree.Spatial.Direction, amount: UInt16) -> Bool { + guard let tab = tabs.first(where: { $0.id == tabId }) else { return false } + return tab.resizeSplit(from: surfaceId, direction: direction, amount: amount) + } + + func equalizeSplits(tabId: UUID) -> Bool { + guard let tab = tabs.first(where: { $0.id == tabId }) else { return false } + guard tab.splitTree.isSplit else { return false } + tab.equalizeSplits() + return true + } + + func toggleSplitZoom(tabId: UUID, surfaceId: UUID) -> Bool { + guard let tab = tabs.first(where: { $0.id == tabId }) else { return false } + return tab.toggleZoom(on: surfaceId) + } + + func closeSurface(tabId: UUID, surfaceId: UUID) -> Bool { + guard let tabIndex = tabs.firstIndex(where: { $0.id == tabId }) else { return false } + let tab = tabs[tabIndex] + guard tab.closeSurface(surfaceId) else { return false } + + if tab.splitTree.isEmpty { + if tabs.count > 1 { + closeTab(tab) + } else { + let newSurface = TerminalSurface( + tabId: tab.id, + context: GHOSTTY_SURFACE_CONTEXT_TAB, + configTemplate: nil + ) + tab.splitTree = SplitTree(view: newSurface) + tab.focusSurface(newSurface.id) + } + } + + return true + } } extension Notification.Name { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 8a0a491f..bcc02025 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -254,7 +254,7 @@ class TerminalController { DispatchQueue.main.sync { guard let selectedId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == selectedId }), - let surface = tab.terminalSurface.surface else { + let surface = tab.focusedSurface?.surface else { return } @@ -290,7 +290,7 @@ class TerminalController { DispatchQueue.main.sync { guard let selectedId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == selectedId }), - let surface = tab.terminalSurface.surface else { + let surface = tab.focusedSurface?.surface else { return } diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 56064374..73812960 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -5,6 +5,7 @@ import UserNotifications struct TerminalNotification: Identifiable, Hashable { let id: UUID let tabId: UUID + let surfaceId: UUID? let title: String let body: String let createdAt: Date @@ -28,21 +29,20 @@ final class TerminalNotificationStore: ObservableObject { notifications.filter { !$0.isRead }.count } - func addNotification(tabId: UUID, title: String, body: String) { + func addNotification(tabId: UUID, surfaceId: UUID?, title: String, body: String) { let isActiveTab = AppDelegate.shared?.tabManager?.selectedTabId == tabId let shouldMarkRead = NSApp.isActive && (NSApp.keyWindow?.isKeyWindow ?? false) && isActiveTab let notification = TerminalNotification( id: UUID(), tabId: tabId, + surfaceId: surfaceId, title: title, body: body, createdAt: Date(), isRead: shouldMarkRead ) notifications.insert(notification, at: 0) - if !shouldMarkRead { - scheduleUserNotification(notification) - } + scheduleUserNotification(notification) } func markRead(id: UUID) { @@ -91,6 +91,9 @@ final class TerminalNotificationStore: ObservableObject { "tabId": notification.tabId.uuidString, "notificationId": notification.id.uuidString, ] + if let surfaceId = notification.surfaceId { + content.userInfo["surfaceId"] = surfaceId.uuidString + } let request = UNNotificationRequest( identifier: notification.id.uuidString,