macOS: convert Surface dragging to use NSDraggingSource

This commit is contained in:
Mitchell Hashimoto
2025-12-28 13:59:41 -08:00
parent 9b7124cf62
commit be97b5bede
6 changed files with 215 additions and 60 deletions

View File

@@ -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,

View File

@@ -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)

View File

@@ -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)
}
}
}

View File

@@ -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
}
}
}
}
}

View File

@@ -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)
}

View File

@@ -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
}
}