listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

commit 98daee6a460db2678bb95bcbddda008d866e3c7a
parent b519835d06d458d99207703f426a5648a16dd12a
Author: Michael Camilleri <[email protected]>
Date:   Fri, 13 Mar 2026 19:37:18 +0900

Use NSDraggingSource on macOS

SwiftUI on macOS does not provide the same support for dragging gestures
as AppKit. This commit uses AppKit in the macOS version.

Co-Authored-By: Claude 4.6 Opus <[email protected]>

Diffstat:
MListlessMac/Helpers/TaskRowDragGesture.swift | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
MListlessMac/Views/TaskListView.swift | 5++++-
2 files changed, 112 insertions(+), 24 deletions(-)

diff --git a/ListlessMac/Helpers/TaskRowDragGesture.swift b/ListlessMac/Helpers/TaskRowDragGesture.swift @@ -28,27 +28,20 @@ struct TaskRowDragGesture: ViewModifier { let onLift: () -> Void let onLiftEnd: () -> Void - @State private var dragStarted = false - @State private var mouseUpMonitor: Any? + @State private var isLifted = false + @State private var dragSource = DragSourceManager() + @State private var monitors: [Any] = [] func body(content: Content) -> some View { if isActive { content - .onDrag { - if !dragStarted { - dragStarted = true - onDragStart() - } - return NSItemProvider(object: taskID.uuidString as NSString) - } preview: { - Color.clear.frame(width: 1, height: 1) - } + .background { DragSourceAnchor(manager: dragSource) } .simultaneousGesture( LongPressGesture(minimumDuration: 0.4) .onEnded { _ in - dragStarted = false + isLifted = true onLift() - installMouseUpMonitor() + installMonitors() } ) } else { @@ -56,20 +49,112 @@ struct TaskRowDragGesture: ViewModifier { } } - private func installMouseUpMonitor() { - removeMouseUpMonitor() - mouseUpMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { event in - removeMouseUpMonitor() - dragStarted = false - onLiftEnd() - return event + private func installMonitors() { + removeMonitors() + + dragSource.onDragEnd = { + endLift() } + + // Mouse dragged: begin drag session + monitors.append( + NSEvent.addLocalMonitorForEvents(matching: .leftMouseDragged) { event in + if isLifted && !dragSource.isActive { + onDragStart() + dragSource.beginDrag(taskID: taskID, event: event) + } + return event + }! + ) + + // Mouse up: end lift if no drag session started + monitors.append( + NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { event in + if isLifted && !dragSource.isActive { + endLift() + } + return event + }! + ) + + // Escape: cancel lift (during a drag session, AppKit handles + // Escape internally and calls draggingSession(_:endedAt:operation:)) + monitors.append( + NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + if event.keyCode == 53 && isLifted && !dragSource.isActive { + endLift() + return nil + } + return event + }! + ) + } + + private func endLift() { + removeMonitors() + isLifted = false + dragSource.isActive = false + onLiftEnd() } - private func removeMouseUpMonitor() { - if let monitor = mouseUpMonitor { + private func removeMonitors() { + for monitor in monitors { NSEvent.removeMonitor(monitor) - mouseUpMonitor = nil } + monitors = [] + } +} + +// MARK: - Drag Source + +@MainActor +class DragSourceManager: NSObject, @preconcurrency NSDraggingSource { + weak var sourceView: NSView? + var isActive = false + var onDragEnd: (() -> Void)? + + func beginDrag(taskID: UUID, event: NSEvent) { + guard let sourceView, !isActive else { return } + let item = NSDraggingItem(pasteboardWriter: taskID.uuidString as NSString) + let image = NSImage(size: NSSize(width: 1, height: 1)) + item.setDraggingFrame(sourceView.bounds, contents: image) + isActive = true + sourceView.beginDraggingSession(with: [item], event: event, source: self) + } + + func draggingSession( + _ session: NSDraggingSession, + sourceOperationMaskFor context: NSDraggingContext + ) -> NSDragOperation { + context == .withinApplication ? .move : [] + } + + func draggingSession( + _ session: NSDraggingSession, + endedAt screenPoint: NSPoint, + operation: NSDragOperation + ) { + isActive = false + onDragEnd?() + } +} + +// MARK: - Drag Source Anchor View + +private class DragPassthroughView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + +struct DragSourceAnchor: NSViewRepresentable { + let manager: DragSourceManager + + func makeNSView(context: Context) -> NSView { + let view = DragPassthroughView() + manager.sourceView = view + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + manager.sourceView = nsView } } diff --git a/ListlessMac/Views/TaskListView.swift b/ListlessMac/Views/TaskListView.swift @@ -201,7 +201,10 @@ struct TaskListView: View, TaskListViewProtocol { startDrag(taskID: task.id) }, onLift: { iState.liftedTaskID = task.id }, - onLiftEnd: { if iState.liftedTaskID == task.id { iState.liftedTaskID = nil } } + onLiftEnd: { + if iState.liftedTaskID == task.id { iState.liftedTaskID = nil } + if draggedTaskID == task.id { clearDragState() } + } ) .scaleEffect(isRowLifted(taskID) ? 1.03 : 1.0) .shadow(