← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


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(