From 43d87cf9f8f1f6ced10ea78a17dc11db313cdeee Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 27 Dec 2025 13:41:09 -0800 Subject: [PATCH] macos: setup drop UI on our split views --- .../Splits/TerminalSplitTreeView.swift | 145 ++++++++++++++++-- 1 file changed, 134 insertions(+), 11 deletions(-) diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift index 44123de6d..5c291dcba 100644 --- a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -29,17 +29,7 @@ struct TerminalSplitSubtreeView: View { var body: some View { switch (node) { case .leaf(let leafView): - Ghostty.InspectableSurface( - surfaceView: leafView, - isSplit: !isRoot) - .dropDestination(for: Ghostty.SurfaceView.self) { views, point in - Ghostty.logger.warning("BABY WHAT!") - return false - } isTargeted: { targeted in - Ghostty.logger.warning("BABY TARGETED=\(targeted)") - } - .accessibilityElement(children: .contain) - .accessibilityLabel("Terminal pane") + TerminalSplitLeaf(surfaceView: leafView, isSplit: !isRoot) case .split(let split): let splitViewDirection: SplitViewDirection = switch (split.direction) { @@ -70,3 +60,136 @@ struct TerminalSplitSubtreeView: View { } } } + +struct TerminalSplitLeaf: View { + let surfaceView: Ghostty.SurfaceView + let isSplit: Bool + + @State private var dropZone: DropZone = .none + + var body: some View { + Ghostty.InspectableSurface( + surfaceView: surfaceView, + isSplit: isSplit) + .background { + // We use background for the drop delegate and overlay for the visual indicator + // so that we don't block mouse events from reaching the surface view. The + // background receives drop events while the overlay (with allowsHitTesting + // disabled) only provides visual feedback. + GeometryReader { geometry in + Color.clear + .onDrop(of: [.ghosttySurfaceId], delegate: SplitDropDelegate( + dropZone: $dropZone, + viewSize: geometry.size + )) + } + } + .overlay { + if dropZone != .none { + GeometryReader { geometry in + dropZoneOverlay(for: dropZone, in: geometry) + } + .allowsHitTesting(false) + } + } + .accessibilityElement(children: .contain) + .accessibilityLabel("Terminal pane") + } + + @ViewBuilder + private func dropZoneOverlay(for zone: DropZone, in geometry: GeometryProxy) -> some View { + let overlayColor = Color.accentColor.opacity(0.3) + + switch zone { + case .none: + EmptyView() + 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) + } + } + } + + enum DropZone: Equatable { + case none + case top + case bottom + case left + case right + } + + struct SplitDropDelegate: DropDelegate { + @Binding var dropZone: DropZone + let viewSize: CGSize + + func validateDrop(info: DropInfo) -> Bool { + info.hasItemsConforming(to: [.ghosttySurfaceId]) + } + + func dropEntered(info: DropInfo) { + _ = dropUpdated(info: info) + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + dropZone = calculateDropZone(at: info.location) + return DropProposal(operation: .move) + } + + func dropExited(info: DropInfo) { + dropZone = .none + } + + func performDrop(info: DropInfo) -> Bool { + dropZone = .none + return false + } + + /// 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. + private func calculateDropZone(at point: CGPoint) -> DropZone { + guard viewSize.width > 0, viewSize.height > 0 else { return .none } + + let relX = point.x / viewSize.width + let relY = point.y / viewSize.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 + } + } +}