Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258import SwiftUI
/// A single operation within the split tree.////// Rather than binding the split tree (which is immutable), any mutable operations are/// exposed via this enum to the embedder to handle.enum TerminalSplitOperation { case resize(Resize) case drop(Drop)
struct Resize { let node: SplitTree<Ghostty.SurfaceView>.Node let ratio: Double }
struct Drop { /// The surface being dragged. let payload: Ghostty.SurfaceView
/// The surface it was dragged onto let destination: Ghostty.SurfaceView
/// The zone it was dropped to determine how to split the destination. let zone: TerminalSplitDropZone }}
struct TerminalSplitTreeView: View { let tree: SplitTree<Ghostty.SurfaceView> let action: (TerminalSplitOperation) -> Void
var body: some View { if let node = tree.zoomed ?? tree.root { TerminalSplitSubtreeView( node: node, isRoot: node == tree.root, action: action) // This is necessary because we can't rely on SwiftUI's implicit // structural identity to detect changes to this view. Due to // the tree structure of splits it could result in bad behaviors. // See: https://github.com/ghostty-org/ghostty/issues/7546 .id(node.structuralIdentity) } }}
private struct TerminalSplitSubtreeView: View { @EnvironmentObject var ghostty: Ghostty.App
let node: SplitTree<Ghostty.SurfaceView>.Node var isRoot: Bool = false let action: (TerminalSplitOperation) -> Void
var body: some View { switch node { case .leaf(let leafView): TerminalSplitLeaf(surfaceView: leafView, isSplit: !isRoot, action: action)
case .split(let split): let splitViewDirection: SplitViewDirection = switch split.direction { case .horizontal: .horizontal case .vertical: .vertical }
SplitView( splitViewDirection, .init(get: { CGFloat(split.ratio) }, set: { action(.resize(.init(node: node, ratio: $0))) }), dividerColor: ghostty.config.splitDividerColor, resizeIncrements: .init(width: 1, height: 1), left: { TerminalSplitSubtreeView(node: split.left, action: action) }, right: { TerminalSplitSubtreeView(node: split.right, action: action) }, onEqualize: { guard let surface = node.leftmostLeaf().surface else { return } ghostty.splitEqualize(surface: surface) } ) } }}
private struct TerminalSplitLeaf: View { let surfaceView: Ghostty.SurfaceView let isSplit: Bool let action: (TerminalSplitOperation) -> Void
@State private var dropState: DropState = .idle @State private var isSelfDragging: Bool = false
var body: some View { GeometryReader { geometry in Ghostty.InspectableSurface( surfaceView: surfaceView, isSplit: isSplit) .background { // If we're dragging ourself, we hide the entire drop zone. This makes // it so that a released drop animates back to its source properly // so it is a proper invalid drop zone. if !isSelfDragging { Color.clear .onDrop(of: [.ghosttySurfaceId], delegate: SplitDropDelegate( dropState: $dropState, viewSize: geometry.size, destinationSurface: surfaceView, action: action )) } } .overlay { if !isSelfDragging, case .dropping(let zone) = dropState { zone.overlay(in: geometry) .allowsHitTesting(false) } } .onPreferenceChange(Ghostty.DraggingSurfaceKey.self) { value in isSelfDragging = value == surfaceView.id if isSelfDragging { dropState = .idle } } .accessibilityElement(children: .contain) .accessibilityLabel("Terminal pane") } }
private enum DropState: Equatable { case idle case dropping(TerminalSplitDropZone) }
private struct SplitDropDelegate: DropDelegate { @Binding var dropState: DropState let viewSize: CGSize let destinationSurface: Ghostty.SurfaceView let action: (TerminalSplitOperation) -> Void
func validateDrop(info: DropInfo) -> Bool { info.hasItemsConforming(to: [.ghosttySurfaceId]) }
func dropEntered(info: DropInfo) { dropState = .dropping(.calculate(at: info.location, in: viewSize)) }
func dropUpdated(info: DropInfo) -> DropProposal? { // For some reason dropUpdated is sent after performDrop is called // and we don't want to reset our drop zone to show it so we have // to guard on the state here. guard case .dropping = dropState else { return DropProposal(operation: .forbidden) } dropState = .dropping(.calculate(at: info.location, in: viewSize)) return DropProposal(operation: .move) }
func dropExited(info: DropInfo) { dropState = .idle }
func performDrop(info: DropInfo) -> Bool { let zone = TerminalSplitDropZone.calculate(at: info.location, in: viewSize) dropState = .idle
// Load the dropped surface asynchronously using Transferable let providers = info.itemProviders(for: [.ghosttySurfaceId]) guard let provider = providers.first else { return false }
// Capture action before the async closure _ = provider.loadTransferable(type: Ghostty.SurfaceView.self) { [weak destinationSurface] result in switch result { case .success(let sourceSurface): DispatchQueue.main.async { // Don't allow dropping on self guard let destinationSurface else { return } guard sourceSurface !== destinationSurface else { return } action(.drop(.init(payload: sourceSurface, destination: destinationSurface, zone: zone))) }
case .failure: break } }
return true } }}
enum TerminalSplitDropZone: String, Equatable { case top case bottom case left case right
/// Determines which drop zone the cursor is in based on proximity to edges. /// /// Divides the view into four triangular regions by drawing diagonals from /// corner to corner. The drop zone is determined by which edge the cursor /// is closest to, creating natural triangular hit regions for each side. static func calculate(at point: CGPoint, in size: CGSize) -> TerminalSplitDropZone { let relX = point.x / size.width let relY = point.y / size.height
let distToLeft = relX let distToRight = 1 - relX let distToTop = relY let distToBottom = 1 - relY
let minDist = min(distToLeft, distToRight, distToTop, distToBottom)
if minDist == distToLeft { return .left } if minDist == distToRight { return .right } if minDist == distToTop { return .top } return .bottom }
@ViewBuilder func overlay(in geometry: GeometryProxy) -> some View { let overlayColor = Color.accentColor.opacity(0.3)
switch self { case .top: VStack(spacing: 0) { Rectangle() .fill(overlayColor) .frame(height: geometry.size.height / 2) Spacer() } case .bottom: VStack(spacing: 0) { Spacer() Rectangle() .fill(overlayColor) .frame(height: geometry.size.height / 2) } case .left: HStack(spacing: 0) { Rectangle() .fill(overlayColor) .frame(width: geometry.size.width / 2) Spacer() } case .right: HStack(spacing: 0) { Spacer() Rectangle() .fill(overlayColor) .frame(width: geometry.size.width / 2) } } }}