From be97b5bede3cbdf73b1d46e56ddd2124ed9023b5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 28 Dec 2025 13:59:41 -0800 Subject: [PATCH] macOS: convert Surface dragging to use NSDraggingSource --- macos/Ghostty.xcodeproj/project.pbxproj | 2 + .../Splits/TerminalSplitTreeView.swift | 1 - .../Surface View/SurfaceDragSource.swift | 196 ++++++++++++++++++ .../Surface View/SurfaceGrabHandle.swift | 67 +----- .../SurfaceView+Transferable.swift | 5 + .../Ghostty/Surface View/SurfaceView.swift | 4 + 6 files changed, 215 insertions(+), 60 deletions(-) create mode 100644 macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index a2516fe10..100ddeaf5 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -145,6 +145,8 @@ Ghostty/Ghostty.Surface.swift, "Ghostty/NSEvent+Extension.swift", "Ghostty/Surface View/InspectorView.swift", + "Ghostty/Surface View/SurfaceDragSource.swift", + "Ghostty/Surface View/SurfaceGrabHandle.swift", "Ghostty/Surface View/SurfaceScrollView.swift", "Ghostty/Surface View/SurfaceView_AppKit.swift", Helpers/AppInfo.swift, diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift index eb946673b..73d61439c 100644 --- a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -112,7 +112,6 @@ fileprivate struct TerminalSplitLeaf: View { } } .onPreferenceChange(Ghostty.DraggingSurfaceKey.self) { value in - Ghostty.logger.warning("BABY WE DRAGGING \(String(describing: value))") isSelfDragging = value == surfaceView.id } .accessibilityElement(children: .contain) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift b/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift new file mode 100644 index 000000000..534834af3 --- /dev/null +++ b/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift @@ -0,0 +1,196 @@ +import AppKit +import SwiftUI + +extension Ghostty { + /// A preference key that propagates the ID of the SurfaceView currently being dragged, + /// or nil if no surface is being dragged. + struct DraggingSurfaceKey: PreferenceKey { + static var defaultValue: SurfaceView.ID? = nil + + static func reduce(value: inout SurfaceView.ID?, nextValue: () -> SurfaceView.ID?) { + value = nextValue() ?? value + } + } + + /// A SwiftUI view that provides drag source functionality for terminal surfaces. + /// + /// This view wraps an AppKit-based drag source to enable drag-and-drop reordering + /// of terminal surfaces within split views. When the user drags this view, it initiates + /// an `NSDraggingSession` with the surface's UUID encoded in the pasteboard, allowing + /// drop targets to identify which surface is being moved. + /// + /// The view also publishes the dragging state via `DraggingSurfaceKey` preference, + /// enabling parent views to react to ongoing drag operations. + struct SurfaceDragSource: View { + /// The surface view that will be dragged. + let surfaceView: SurfaceView + + /// Binding that reflects whether a drag session is currently active. + @Binding var isDragging: Bool + + /// Binding that reflects whether the mouse is hovering over this view. + @Binding var isHovering: Bool + + var body: some View { + SurfaceDragSourceViewRepresentable( + surfaceView: surfaceView, + isDragging: $isDragging, + isHovering: $isHovering) + .preference(key: DraggingSurfaceKey.self, value: isDragging ? surfaceView.id : nil) + } + } + + /// An NSViewRepresentable that provides AppKit-based drag source functionality. + /// This gives us control over the drag lifecycle, particularly detecting drag start. + fileprivate struct SurfaceDragSourceViewRepresentable: NSViewRepresentable { + let surfaceView: SurfaceView + @Binding var isDragging: Bool + @Binding var isHovering: Bool + + func makeNSView(context: Context) -> SurfaceDragSourceView { + let view = SurfaceDragSourceView() + view.surfaceView = surfaceView + view.onDragStateChanged = { dragging in + isDragging = dragging + } + view.onHoverChanged = { hovering in + withAnimation(.easeInOut(duration: 0.15)) { + isHovering = hovering + } + } + return view + } + + func updateNSView(_ nsView: SurfaceDragSourceView, context: Context) { + nsView.surfaceView = surfaceView + nsView.onDragStateChanged = { dragging in + isDragging = dragging + } + nsView.onHoverChanged = { hovering in + withAnimation(.easeInOut(duration: 0.15)) { + isHovering = hovering + } + } + } + } + + /// The underlying NSView that handles drag operations. + /// + /// This view manages mouse tracking and drag initiation for surface reordering. + /// It uses a local event loop to detect drag gestures and initiates an + /// `NSDraggingSession` when the user drags beyond the threshold distance. + fileprivate class SurfaceDragSourceView: NSView, NSDraggingSource { + /// Scale factor applied to the surface snapshot for the drag preview image. + private static let previewScale: CGFloat = 0.2 + + /// The surface view that will be dragged. Its UUID is encoded into the + /// pasteboard for drop targets to identify which surface is being moved. + var surfaceView: SurfaceView? + + /// Callback invoked when the drag state changes. Called with `true` when + /// a drag session begins, and `false` when it ends (completed or cancelled). + var onDragStateChanged: ((Bool) -> Void)? + + /// Callback invoked when the mouse enters or exits this view's bounds. + /// Used to update the hover state for visual feedback in the parent view. + var onHoverChanged: ((Bool) -> Void)? + + /// Whether we are currently in a mouse tracking loop (between mouseDown + /// and either mouseUp or drag initiation). Used to determine cursor state. + private var isTracking: Bool = false + + override func updateTrackingAreas() { + super.updateTrackingAreas() + + // To update our tracking area we just recreate it all. + trackingAreas.forEach { removeTrackingArea($0) } + + // Add our tracking area for mouse events + addTrackingArea(NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .activeInActiveApp], + owner: self, + userInfo: nil + )) + } + + override func resetCursorRects() { + addCursorRect(bounds, cursor: isTracking ? .closedHand : .openHand) + } + + override func mouseEntered(with event: NSEvent) { + onHoverChanged?(true) + } + + override func mouseExited(with event: NSEvent) { + onHoverChanged?(false) + } + + override func mouseDragged(with event: NSEvent) { + guard !isTracking, let surfaceView = surfaceView else { return } + + // Create the pasteboard item with the surface ID + let data = withUnsafeBytes(of: surfaceView.id.uuid) { Data($0) } + let pasteboardItem = NSPasteboardItem() + pasteboardItem.setData(data, forType: .ghosttySurfaceId) + let item = NSDraggingItem(pasteboardWriter: pasteboardItem) + + // Create a scaled preview image from the surface snapshot + if let snapshot = surfaceView.asImage { + let imageSize = NSSize( + width: snapshot.size.width * Self.previewScale, + height: snapshot.size.height * Self.previewScale + ) + let scaledImage = NSImage(size: imageSize) + scaledImage.lockFocus() + snapshot.draw( + in: NSRect(origin: .zero, size: imageSize), + from: NSRect(origin: .zero, size: snapshot.size), + operation: .copy, + fraction: 1.0 + ) + scaledImage.unlockFocus() + + item.setDraggingFrame( + NSRect(origin: .zero, size: imageSize), + contents: scaledImage + ) + } + + onDragStateChanged?(true) + beginDraggingSession(with: [item], event: event, source: self) + } + + // MARK: NSDraggingSource + + func draggingSession( + _ session: NSDraggingSession, + sourceOperationMaskFor context: NSDraggingContext + ) -> NSDragOperation { + return context == .withinApplication ? .move : [] + } + + func draggingSession( + _ session: NSDraggingSession, + willBeginAt screenPoint: NSPoint + ) { + isTracking = true + } + + func draggingSession( + _ session: NSDraggingSession, + movedTo screenPoint: NSPoint + ) { + NSCursor.closedHand.set() + } + + func draggingSession( + _ session: NSDraggingSession, + endedAt screenPoint: NSPoint, + operation: NSDragOperation + ) { + isTracking = false + onDragStateChanged?(false) + } + } +} diff --git a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift index 2d2ce59e3..0b0b394ce 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift @@ -1,21 +1,11 @@ +import AppKit import SwiftUI -extension Ghostty { - /// A preference key that propagates the ID of the SurfaceView currently being dragged, - /// or nil if no surface is being dragged. - struct DraggingSurfaceKey: PreferenceKey { - static var defaultValue: SurfaceView.ID? = nil - - static func reduce(value: inout SurfaceView.ID?, nextValue: () -> SurfaceView.ID?) { - value = nextValue() ?? value - } - } - +extension Ghostty { /// A grab handle overlay at the top of the surface for dragging the window. /// Only appears when hovering in the top region of the surface. struct SurfaceGrabHandle: View { private let handleHeight: CGFloat = 10 - private let previewScale: CGFloat = 0.2 let surfaceView: SurfaceView @@ -35,58 +25,17 @@ extension Ghostty { } } .contentShape(Rectangle()) - .onHover { hovering in - withAnimation(.easeInOut(duration: 0.15)) { - isHovering = hovering - } + .overlay { + SurfaceDragSource( + surfaceView: surfaceView, + isDragging: $isDragging, + isHovering: $isHovering + ) } - .backport.pointerStyle(isHovering ? .grabIdle : nil) Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) - .draggable(surfaceView) { - SurfaceDragPreview(surfaceView: surfaceView, scale: previewScale) - } - .preference(key: DraggingSurfaceKey.self, value: isDragging ? surfaceView.id : nil) - } - } - - /// A miniature preview of the surface view for drag operations that updates periodically. - private struct SurfaceDragPreview: View { - let surfaceView: SurfaceView - let scale: CGFloat - - var body: some View { - // We need to use a TimelineView to ensure that this doesn't - // cache forever. This will NOT let the view live update while - // being dragged; macOS doesn't seem to allow that. But it will - // make sure on new drags the screenshot is updated. - TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { _ in - if let snapshot = surfaceView.asImage { - #if canImport(AppKit) - Image(nsImage: snapshot) - .resizable() - .aspectRatio(contentMode: .fit) - .frame( - width: snapshot.size.width * scale, - height: snapshot.size.height * scale - ) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .shadow(radius: 10) - #elseif canImport(UIKit) - Image(uiImage: snapshot) - .resizable() - .aspectRatio(contentMode: .fit) - .frame( - width: snapshot.size.width * scale, - height: snapshot.size.height * scale - ) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .shadow(radius: 10) - #endif - } - } } } } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift b/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift index d5d47f601..7eef69a71 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift @@ -49,3 +49,8 @@ extension UTType { /// a way to look up a surface by ID. static let ghosttySurfaceId = UTType(exportedAs: "com.mitchellh.ghosttySurfaceId") } + +extension NSPasteboard.PasteboardType { + /// Pasteboard type for dragging surface IDs. + static let ghosttySurfaceId = NSPasteboard.PasteboardType(UTType.ghosttySurfaceId.identifier) +} diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index 872b89d30..c224d373e 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -225,9 +225,13 @@ extension Ghostty { } } + #if canImport(AppKit) // Grab handle for dragging the window. We want this to appear at the very // top Z-index os it isn't faded by the unfocused overlay. + // + // This is disabled except on macOS because it uses AppKit drag/drop APIs. SurfaceGrabHandle(surfaceView: surfaceView) + #endif } }