156 lines
9.0 KiB
Swift
156 lines
9.0 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
|
|
|
|
enum DJPasteboardPayload {
|
|
static func song(_ pasteboard: NSPasteboard) -> SongDragReference? {
|
|
guard let text = pasteboard.string(forType:.string),text.hasPrefix("KJMix.song.v1:"),
|
|
let data = Data(base64Encoded:String(text.dropFirst("KJMix.song.v1:".count))) else { return nil }
|
|
return try? JSONDecoder().decode(SongDragReference.self,from:data)
|
|
}
|
|
static func files(_ pasteboard: NSPasteboard) -> [URL] {
|
|
(pasteboard.readObjects(forClasses:[NSURL.self],options:[.urlReadingFileURLsOnly:true]) as? [URL] ?? []).filter(\.isFileURL)
|
|
}
|
|
}
|
|
|
|
struct DJLibraryDragTitle: NSViewRepresentable {
|
|
let title: String
|
|
let reference: SongDragReference
|
|
let fontSize: Double
|
|
let select: () -> Void
|
|
var doubleClick: (() -> Void)? = nil
|
|
var selectAll: (() -> Void)? = nil
|
|
func makeNSView(context: Context) -> DJLibraryDragView { DJLibraryDragView() }
|
|
func updateNSView(_ view: DJLibraryDragView,context: Context) {
|
|
let redraw = view.title != title || view.fontSize != fontSize
|
|
view.title = title;view.reference = reference;view.fontSize = fontSize;view.select = select;view.doubleClick = doubleClick;view.selectAllTracks = selectAll
|
|
if redraw {
|
|
view.toolTip = "Double-click to load alternating decks, or drag \(title) to either deck";view.setAccessibilityLabel(title);view.needsDisplay = true
|
|
}
|
|
}
|
|
func sizeThatFits(_ proposal: ProposedViewSize,nsView: DJLibraryDragView,context: Context) -> CGSize? { .init(width:proposal.width ?? 240,height:24) }
|
|
}
|
|
|
|
final class DJLibraryDragView: NSView {
|
|
var title = ""
|
|
var reference: SongDragReference?
|
|
var fontSize: Double = 11
|
|
var select: (() -> Void)?
|
|
var doubleClick: (() -> Void)?
|
|
var selectAllTracks: (() -> Void)?
|
|
override var acceptsFirstResponder: Bool { true }
|
|
@objc override func selectAll(_ sender: Any?) { selectAllTracks?() }
|
|
private var armed = false
|
|
private var dragging = false
|
|
override init(frame: NSRect) { super.init(frame:frame);setAccessibilityElement(true);setAccessibilityRole(.staticText) }
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
|
|
override func draw(_ dirtyRect: NSRect) {
|
|
let paragraph = NSMutableParagraphStyle();paragraph.lineBreakMode = .byTruncatingTail
|
|
let font = NSFont.systemFont(ofSize:fontSize,weight:.medium)
|
|
let rect = NSRect(x:0,y:(bounds.height-ceil(font.ascender-font.descender))/2,width:bounds.width,height:ceil(font.ascender-font.descender)+2)
|
|
(title as NSString).draw(in:rect,withAttributes:[.font:font,.foregroundColor:NSColor.white,.paragraphStyle:paragraph])
|
|
}
|
|
override func mouseDown(with event: NSEvent) { window?.makeFirstResponder(self);armed = true;dragging = false }
|
|
override func mouseDragged(with event: NSEvent) {
|
|
guard armed else { return }
|
|
dragging = true
|
|
NSCursor.closedHand.set()
|
|
for target in DJNativeDropView.targets.allObjects where target.window === window {
|
|
let inside = !target.isHiddenOrHasHiddenAncestor && target.bounds.contains(target.convert(event.locationInWindow,from:nil))
|
|
target.layer?.borderWidth = inside ? 2 : 0
|
|
target.layer?.borderColor = target.tint.cgColor
|
|
}
|
|
}
|
|
override func mouseUp(with event: NSEvent) {
|
|
defer {
|
|
armed = false;dragging = false;NSCursor.arrow.set()
|
|
for target in DJNativeDropView.targets.allObjects { target.layer?.borderWidth = 0 }
|
|
}
|
|
guard dragging,let reference,let window else { select?();if armed,event.clickCount == 2 { doubleClick?() };return }
|
|
for target in DJNativeDropView.targets.allObjects where target.window === window && !target.isHiddenOrHasHiddenAncestor {
|
|
if target.bounds.contains(target.convert(event.locationInWindow,from:nil)),target.validSong?(reference) == true {
|
|
let receive = target.acceptSong
|
|
DispatchQueue.main.async { receive?(reference) }
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
struct DJDeckDropZone<Content: View>: NSViewRepresentable {
|
|
let system: DJSystem
|
|
let letter: String
|
|
let content: Content
|
|
init(system: DJSystem,letter: String,@ViewBuilder content: () -> Content) { self.system = system;self.letter = letter;self.content = content() }
|
|
func makeNSView(context: Context) -> DJNativeDropView {
|
|
let view = DJNativeDropView();updateNSView(view,context:context);return view
|
|
}
|
|
func updateNSView(_ view: DJNativeDropView,context: Context) {
|
|
view.host.rootView = AnyView(content.environment(\.colorScheme,context.environment.colorScheme))
|
|
view.tint = letter == "A" ? .systemCyan : .systemOrange
|
|
view.acceptSong = { [weak system] reference in
|
|
guard let system,reference.library == system.dragSession,let song = system.songs.first(where:{$0.id == reference.song}) else { return }
|
|
if letter == "Queue" { system.automix.enqueue([song]) } else { system.load(song,into:letter) }
|
|
}
|
|
view.acceptFiles = letter == "Queue" ? nil : { [weak system] urls in system?.requestFileLoad(urls,into:letter) }
|
|
view.validSong = { [weak system] reference in reference.library == system?.dragSession }
|
|
}
|
|
func sizeThatFits(_ proposal: ProposedViewSize,nsView: DJNativeDropView,context: Context) -> CGSize? {
|
|
.init(width:proposal.width ?? nsView.host.fittingSize.width,height:proposal.height ?? nsView.host.fittingSize.height)
|
|
}
|
|
}
|
|
|
|
final class DJNativeDropView: NSView {
|
|
static let targets = NSHashTable<DJNativeDropView>.weakObjects()
|
|
let host = DJDropHostingView(rootView:AnyView(EmptyView()))
|
|
var tint = NSColor.systemCyan
|
|
var acceptSong: ((SongDragReference) -> Void)?
|
|
var acceptFiles: (([URL]) -> Void)?
|
|
var validSong: ((SongDragReference) -> Bool)?
|
|
override init(frame: NSRect) {
|
|
super.init(frame:frame);Self.targets.add(self);registerForDraggedTypes([.string,.fileURL]);wantsLayer = true
|
|
host.dropTarget = self;host.registerForDraggedTypes([.string,.fileURL])
|
|
host.translatesAutoresizingMaskIntoConstraints = false;addSubview(host)
|
|
NSLayoutConstraint.activate([host.leadingAnchor.constraint(equalTo:leadingAnchor),host.trailingAnchor.constraint(equalTo:trailingAnchor),host.topAnchor.constraint(equalTo:topAnchor),host.bottomAnchor.constraint(equalTo:bottomAnchor)])
|
|
}
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
private func accepts(_ pasteboard: NSPasteboard) -> Bool {
|
|
if let song = DJPasteboardPayload.song(pasteboard) { return validSong?(song) == true }
|
|
return !DJPasteboardPayload.files(pasteboard).isEmpty
|
|
}
|
|
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
|
let allowed = accepts(sender.draggingPasteboard)
|
|
layer?.borderWidth = allowed ? 2 : 0;layer?.borderColor = tint.cgColor
|
|
return allowed ? .copy : []
|
|
}
|
|
override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { accepts(sender.draggingPasteboard) ? .copy : [] }
|
|
override func draggingExited(_ sender: NSDraggingInfo?) { layer?.borderWidth = 0 }
|
|
override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { accepts(sender.draggingPasteboard) }
|
|
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
|
|
layer?.borderWidth = 0
|
|
if let song = DJPasteboardPayload.song(sender.draggingPasteboard),validSong?(song) == true {
|
|
let receive = acceptSong
|
|
// Finish the native dragging session before a load rebuilds any SwiftUI rows.
|
|
DispatchQueue.main.async { receive?(song) };return true
|
|
}
|
|
let files = DJPasteboardPayload.files(sender.draggingPasteboard)
|
|
guard !files.isEmpty else { return false }
|
|
let receive = acceptFiles;DispatchQueue.main.async { receive?(files) };return true
|
|
}
|
|
override func concludeDragOperation(_ sender: NSDraggingInfo?) { layer?.borderWidth = 0 }
|
|
}
|
|
|
|
// NSHostingView participates in AppKit drag destination lookup itself. Forward
|
|
// its callbacks so embedded SwiftUI content cannot shadow the deck destination.
|
|
final class DJDropHostingView: NSHostingView<AnyView> {
|
|
weak var dropTarget: DJNativeDropView?
|
|
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { dropTarget?.draggingEntered(sender) ?? [] }
|
|
override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { dropTarget?.draggingUpdated(sender) ?? [] }
|
|
override func draggingExited(_ sender: NSDraggingInfo?) { dropTarget?.draggingExited(sender) }
|
|
override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { dropTarget?.prepareForDragOperation(sender) ?? false }
|
|
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { dropTarget?.performDragOperation(sender) ?? false }
|
|
override func concludeDragOperation(_ sender: NSDraggingInfo?) { dropTarget?.concludeDragOperation(sender) }
|
|
}
|