From 674b3219195154668b849fdc05772905aafc5632 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Sat, 15 Aug 2026 18:19:35 -0700 Subject: [PATCH] Support dragging clips out of the bar to other apps Adds a native NSDraggingSession-based drag source (ClipDragSource) used for every clip type, replacing SwiftUI's .onDrag - a single-payload API with no visibility into where the drag actually is on screen. Tracking that position is what lets the bar stay open while a drag is still hovering over it (e.g. onto a Pinboard tab) and only drop once the drag has genuinely left the bar's window for whatever's underneath. ClipDragProvider builds the pasteboard payload per clip type: one file URL per item for multi-file .file clips (dragging out as that many items, matching Finder's own multi-selection hand-off), or a single NSPasteboardItem for everything else (text, rich text, links, colors, images) built directly rather than through NSItemProvider, since NSItemProvider isn't NSPasteboardWriting-conformant outside of SwiftUI's private .onDrag bridging. Capture now records every file in a multi-file copy, not just the first. Some sources put each copied file in its own pasteboard item while others only fill the legacy NSFilenamesPboardType array and leave a single URL item behind, so the monitor reads both and keeps whichever list is longer. Without that half, a clip that only ever recorded one file could never drag out as several, and the multi-item hand-off above would have nothing to exercise it. --- Sources/Pesty/Monitor/ClipboardMonitor.swift | 13 ++ Sources/Pesty/UI/ClipCardView.swift | 16 +- Sources/Pesty/UI/ClipDragSource.swift | 187 +++++++++++++++++++ Sources/Pesty/Util/ClipDragProvider.swift | 132 ++++++++----- 4 files changed, 301 insertions(+), 47 deletions(-) create mode 100644 Sources/Pesty/UI/ClipDragSource.swift diff --git a/Sources/Pesty/Monitor/ClipboardMonitor.swift b/Sources/Pesty/Monitor/ClipboardMonitor.swift index 2590070..8915e46 100644 --- a/Sources/Pesty/Monitor/ClipboardMonitor.swift +++ b/Sources/Pesty/Monitor/ClipboardMonitor.swift @@ -38,6 +38,19 @@ final class ClipboardMonitor { ClipboardStore.shared.addCaptured(item) } + /// Every file in a multi-file copy, not just the first. Some sources put + /// each file in its own pasteboard item, others only fill the legacy + /// filenames array and leave a single URL item behind — reading both and + /// keeping the longer list captures the whole selection either way. + private func copiedFileURLs() -> [URL]? { + let fromItems = (pasteboard.readObjects(forClasses: [NSURL.self], + options: [.urlReadingFileURLsOnly: true]) as? [URL]) ?? [] + let legacy = pasteboard.propertyList(forType: NSPasteboard.PasteboardType("NSFilenamesPboardType")) + let fromFilenames = (legacy as? [String])?.map { URL(fileURLWithPath: $0) } ?? [] + let urls = fromFilenames.count > fromItems.count ? fromFilenames : fromItems + return urls.isEmpty ? nil : urls + } + private func makeItem() -> ClipItem? { let types = pasteboard.types ?? [] diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index a8b4725..c4ea463 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -44,8 +44,22 @@ struct ClipCardView: View { .onTapGesture { store.select(item.id) } .highPriorityGesture(TapGesture().modifiers(.shift).onEnded { store.extendSelection(to: item.id) }) .highPriorityGesture(TapGesture().modifiers(.command).onEnded { store.toggleSelection(item.id) }) - .onDrag { ClipDragProvider.make(for: item) } .contextMenu { menu } + .overlay { + // Writers are built lazily at drag start: constructing them here + // would re-encode every image clip on every card render, and a + // hover re-renders constantly. + if ClipDragProvider.canDrag(item) { + ClipDragSource( + makeWriters: { ClipDragProvider.pasteboardWriters(for: item) }, + onSelect: { store.select(item.id) }, + onToggleSelect: { store.toggleSelection(item.id) }, + onExtendSelect: { store.extendSelection(to: item.id) }, + onOpen: { AppController.shared.pasteItem(item) }, + onDragExitedBar: { AppController.shared.hideBar() } + ) + } + } } private var header: some View { diff --git a/Sources/Pesty/UI/ClipDragSource.swift b/Sources/Pesty/UI/ClipDragSource.swift new file mode 100644 index 0000000..06d81fd --- /dev/null +++ b/Sources/Pesty/UI/ClipDragSource.swift @@ -0,0 +1,187 @@ +import AppKit +import SwiftUI + +/// Drags a clip card out of the bar via a native pasteboard session - this +/// replaces SwiftUI's `.onDrag`, which only supports a single payload and +/// gives no visibility into where the drag is on screen. Tracking that +/// position is what lets the bar stay open while a drag is still hovering +/// over it (e.g. dropping onto a Pinboard tab to pin the clip) and only +/// drop once the drag has genuinely left the bar for whatever's underneath. +struct ClipDragSource: NSViewRepresentable { + /// Deferred: building writers re-encodes an image clip's full bitmap, so + /// it must only run when a drag actually starts - never as part of + /// evaluating the card's view body. + let makeWriters: () -> [NSPasteboardWriting] + let onSelect: () -> Void + let onToggleSelect: () -> Void + let onExtendSelect: () -> Void + let onOpen: () -> Void + let onDragExitedBar: () -> Void + + func makeNSView(context: Context) -> DragSourceView { + let view = DragSourceView() + update(view) + return view + } + + func updateNSView(_ view: DragSourceView, context: Context) { + update(view) + } + + private func update(_ view: DragSourceView) { + view.makeWriters = makeWriters + view.onSelect = onSelect + view.onToggleSelect = onToggleSelect + view.onExtendSelect = onExtendSelect + view.onOpen = onOpen + view.onDragExitedBar = onDragExitedBar + } +} + +final class DragSourceView: NSView, NSDraggingSource { + var makeWriters: () -> [NSPasteboardWriting] = { [] } + var onSelect: () -> Void = {} + var onToggleSelect: () -> Void = {} + var onExtendSelect: () -> Void = {} + var onOpen: () -> Void = {} + var onDragExitedBar: () -> Void = {} + + private var mouseDownLocation: NSPoint? + private var mouseDownClickCount = 0 + private var startedDragging = false + private var dragAttemptFailed = false + private var hasExitedBar = false + + override var isOpaque: Bool { false } + override var mouseDownCanMoveWindow: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { + guard NSApp.currentEvent?.type == .leftMouseDown else { return nil } + return super.hitTest(point) + } + + override func mouseDown(with event: NSEvent) { + mouseDownLocation = convert(event.locationInWindow, from: nil) + mouseDownClickCount = event.clickCount + startedDragging = false + dragAttemptFailed = false + } + + override func mouseDragged(with event: NSEvent) { + guard !startedDragging, !dragAttemptFailed, let start = mouseDownLocation else { return } + let current = convert(event.locationInWindow, from: nil) + guard hypot(current.x - start.x, current.y - start.y) >= 4 else { return } + + let writers = makeWriters() + guard !writers.isEmpty else { + // Nothing draggable after all (e.g. the backing image file is + // gone). Don't retry the expensive build on every drag pixel; + // the release still counts as a normal click. + dragAttemptFailed = true + return + } + + startedDragging = true + hasExitedBar = false + let preview = snapshot(writers: writers) + let items = writers.map { writer -> NSDraggingItem in + let item = NSDraggingItem(pasteboardWriter: writer) + item.setDraggingFrame(bounds, contents: preview) + return item + } + let session = beginDraggingSession(with: items, event: event, source: self) + session.animatesToStartingPositionsOnCancelOrFail = true + session.draggingFormation = writers.count > 1 ? .pile : .none + } + + override func mouseUp(with event: NSEvent) { + defer { + mouseDownLocation = nil + mouseDownClickCount = 0 + startedDragging = false + } + guard !startedDragging else { return } + // This view's hitTest claims every left-mouse-down over the card, so + // it has to reproduce the shift/cmd multi-select gestures SwiftUI + // would otherwise own instead of just falling through to them. + if event.modifierFlags.contains(.shift) { + onExtendSelect() + } else if event.modifierFlags.contains(.command) { + onToggleSelect() + } else { + onSelect() + if mouseDownClickCount >= 2 { onOpen() } + } + } + + func draggingSession( + _ session: NSDraggingSession, + sourceOperationMaskFor context: NSDraggingContext + ) -> NSDragOperation { + .copy + } + + /// The bar should stay open while a drag is still hovering over it - e.g. + /// dropping a clip onto a Pinboard tab to pin it - and only drop once the + /// drag genuinely leaves the bar's window, freeing it to land on whatever + /// pasteboard-accepting app or field is underneath. + func draggingSession(_ session: NSDraggingSession, movedTo screenPoint: NSPoint) { + guard !hasExitedBar, let window else { return } + guard !window.frame.contains(screenPoint) else { return } + hasExitedBar = true + onDragExitedBar() + } + + private func snapshot(writers: [NSPasteboardWriting]) -> NSImage { + guard let contentView = window?.contentView else { + return fallbackPreview(writers: writers) + } + let rectInWindow = convert(bounds, to: nil) + let rectInContent = contentView.convert(rectInWindow, from: nil) + guard let representation = contentView.bitmapImageRepForCachingDisplay(in: rectInContent) else { + return fallbackPreview(writers: writers) + } + contentView.cacheDisplay(in: rectInContent, to: representation) + representation.size = bounds.size + let image = NSImage(size: bounds.size) + image.addRepresentation(representation) + return roundedPreview(image) + } + + private func fallbackPreview(writers: [NSPasteboardWriting]) -> NSImage { + let icon: NSImage + if let fileURL = writers.first as? NSURL, fileURL.isFileURL, let path = fileURL.path { + icon = NSWorkspace.shared.icon(forFile: path) + } else { + icon = NSImage(systemSymbolName: "doc.on.clipboard", accessibilityDescription: nil) ?? NSImage() + } + return NSImage(size: bounds.size, flipped: false) { rect in + let side = min(64, rect.width, rect.height) + let iconRect = NSRect( + x: rect.midX - side / 2, + y: rect.midY - side / 2, + width: side, + height: side + ) + icon.draw(in: iconRect) + return true + } + } + + private func roundedPreview(_ source: NSImage) -> NSImage { + let size = bounds.size + let clipPath = RoundedRectangle( + cornerRadius: Theme.cardCorner, + style: .continuous + ).path(in: CGRect(origin: .zero, size: size)).cgPath + return NSImage(size: size, flipped: false) { rect in + guard let context = NSGraphicsContext.current?.cgContext else { return false } + context.saveGState() + context.addPath(clipPath) + context.clip() + source.draw(in: rect) + context.restoreGState() + return true + } + } +} diff --git a/Sources/Pesty/Util/ClipDragProvider.swift b/Sources/Pesty/Util/ClipDragProvider.swift index 02c7b6d..2a79654 100644 --- a/Sources/Pesty/Util/ClipDragProvider.swift +++ b/Sources/Pesty/Util/ClipDragProvider.swift @@ -3,65 +3,105 @@ import UniformTypeIdentifiers @MainActor enum ClipDragProvider { - private static let colorTypeIdentifier = "com.apple.cocoa.pasteboard.color" + static func fileURLs(for item: ClipItem) -> [URL] { + guard item.type == .file else { return [] } + return item.fileURLs.compactMap { value in + guard let url = URL(string: value), url.isFileURL else { return nil } + return url + } + } - static func make(for item: ClipItem) -> NSItemProvider { - let provider = NSItemProvider() + /// A cheap draggability test for view code. This is deliberately not + /// `!pasteboardWriters(for:).isEmpty`: building writers reads and + /// re-encodes an image clip's full bitmap, far too heavy for something + /// SwiftUI evaluates on every card render. Views gate on this and build + /// the writers only once a drag actually starts. + static func canDrag(_ item: ClipItem) -> Bool { switch item.type { + case .file: + return !fileURLs(for: item).isEmpty case .image: - registerImage(item, on: provider) + return item.imageFileName != nil + case .richText: + return item.rtfData != nil || item.text != nil + case .link, .text: + return item.text != nil + case .color: + return item.colorHex != nil + } + } + + /// The full set of pasteboard writers for dragging this clip out - one + /// per file for `.file` clips (so a multi-file clip drags as that many + /// items, matching how Finder itself hands off a multi-selection), or a + /// single `NSPasteboardItem` for everything else. Empty when the clip has + /// nothing draggable. + /// + /// `NSDraggingItem` needs an `NSPasteboardWriting` conformer, which + /// `NSItemProvider` doesn't satisfy - SwiftUI's `.onDrag` bridges that + /// gap privately, but a native `NSDraggingSession` has to build its own + /// `NSPasteboardItem` instead. + static func pasteboardWriters(for item: ClipItem) -> [NSPasteboardWriting] { + switch item.type { case .file: - let urls = item.fileURLs.compactMap(URL.init(string:)).filter(\.isFileURL) - if urls.count == 1, let url = urls.first { - provider.registerObject(url as NSURL, visibility: .all) - provider.suggestedName = url.lastPathComponent - } else { - registerText(item.text ?? item.displayTitle, on: provider) + return fileURLs(for: item).map { $0 as NSURL } + default: + guard let writer = pasteboardItem(for: item) else { return [] } + return [writer] + } + } + + private static func pasteboardItem(for item: ClipItem) -> NSPasteboardItem? { + let pbItem = NSPasteboardItem() + + switch item.type { + case .text: + guard let text = item.text else { return nil } + pbItem.setString(text, forType: .string) + + case .richText: + guard item.rtfData != nil || item.text != nil else { return nil } + if let rtfData = item.rtfData { + pbItem.setData(rtfData, forType: .rtf) } - case .link: - if let text = item.text, let url = URL(string: text) { - provider.registerObject(url as NSURL, visibility: .all) + if let text = item.text { + pbItem.setString(text, forType: .string) } - registerText(item.text ?? "", on: provider) + + case .link: + guard let text = item.text, let url = linkURL(from: text) else { return nil } + pbItem.setString(url.absoluteString, forType: .URL) + pbItem.setString(text, forType: .string) + case .color: - if let hex = item.colorHex, let color = NSColor(hex: hex), - let data = try? NSKeyedArchiver.archivedData(withRootObject: color, requiringSecureCoding: true) { - register(data, as: colorTypeIdentifier, on: provider) - } - registerText(item.colorHex ?? "", on: provider) - case .richText: - if let rtf = item.rtfData { - register(rtf, as: UTType.rtf.identifier, on: provider) - } - registerText(item.text ?? "", on: provider) - case .text: - registerText(item.text ?? "", on: provider) - } - if provider.suggestedName == nil { provider.suggestedName = item.displayTitle } - return provider - } + guard let hex = item.colorHex, let color = NSColor(hex: hex) else { return nil } + guard let data = try? NSKeyedArchiver.archivedData( + withRootObject: color, requiringSecureCoding: true + ) else { return nil } + pbItem.setData(data, forType: .color) + pbItem.setString(hex, forType: .string) - private static func registerImage(_ item: ClipItem, on provider: NSItemProvider) { - guard let url = ClipboardStore.shared.imageURL(for: item) else { return } - provider.registerDataRepresentation(forTypeIdentifier: UTType.png.identifier, visibility: .all) { completion in - do { - completion(try Data(contentsOf: url), nil) - } catch { - completion(nil, error) + case .image: + guard let imageURL = ClipboardStore.shared.imageURL(for: item), + let data = try? Data(contentsOf: imageURL, options: .mappedIfSafe) else { return nil } + pbItem.setData(data, forType: NSPasteboard.PasteboardType(UTType.png.identifier)) + if let image = NSImage(contentsOf: imageURL), let tiff = image.tiffRepresentation { + pbItem.setData(tiff, forType: .tiff) } + + case .file: return nil } - provider.suggestedName = "Pesty Image \(item.id.uuidString.prefix(8)).png" - } - private static func registerText(_ text: String, on provider: NSItemProvider) { - register(Data(text.utf8), as: UTType.utf8PlainText.identifier, on: provider) + return pbItem } - private static func register(_ data: Data, as identifier: String, on provider: NSItemProvider) { - provider.registerDataRepresentation(forTypeIdentifier: identifier, visibility: .all) { completion in - completion(data, nil) - return nil - } + private static func linkURL(from text: String) -> URL? { + let value = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: value), + let scheme = url.scheme?.lowercased(), + ["http", "https"].contains(scheme), + url.host != nil else { return nil } + return url } }