Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Sources/Pesty/Monitor/ClipboardMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? []

Expand Down
16 changes: 15 additions & 1 deletion Sources/Pesty/UI/ClipCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
187 changes: 187 additions & 0 deletions Sources/Pesty/UI/ClipDragSource.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
132 changes: 86 additions & 46 deletions Sources/Pesty/Util/ClipDragProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Loading