← All repositories

listless

A simple list app for Apple platforms

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


commit 3593ebb10a03e6a706c7afc55ccb7c927bb8d02b
parent 246674fd26ccd4a99ab2dc4d946a226527cb02c0
Author: Michael Camilleri <[email protected]>
Date:   Thu,  5 Feb 2026 01:25:52 +0900

Implement live reordering

Co-Authored-By: Claude 4.5 Sonnet <[email protected]>

Diffstat

MListless.xcodeproj/project.pbxproj | 8++++++++
MListless/Views/TaskListView.swift | 281++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
MListless/Views/TaskRowView.swift | 92+++++++++++++++++++++++++++++++++++++++++++------------------------------------
AListlessMac/Views/HoverCursorModifier.swift | 18++++++++++++++++++
AListlessiOS/Views/HoverCursorModifier.swift | 10++++++++++

5 files changed, 292 insertions(+), 117 deletions(-)

diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj

@@ -8,6 +8,7 @@
 
 /* Begin PBXBuildFile section */
 		0ACA67F6578EFF181EE5C9A7 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBB8A3BEB346267B30B4675F /* TaskItem.swift */; };
+		15B71073767FB4766A6BA2BE /* HoverCursorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D41D6E0CED14D79F31C45062 /* HoverCursorModifier.swift */; };
 		3ABE52A15C2059D8D5570528 /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; };
 		42E4CDE1D17463554CC4F41F /* TaskListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537A913AC421BAEF60D26D9C /* TaskListView.swift */; };
 		5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; };
@@ -29,6 +30,7 @@
 		DC71C45D92524C3893BF9FDB /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */; };
 		DC73A39A269AB495BCE1AC48 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; };
 		F0B2B806BD84A4F2FDF8E038 /* ListlessiOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC245331D715EA85887C0BA0 /* ListlessiOSApp.swift */; };
+		FEC96DAA2BF8BB5D6D8504EB /* HoverCursorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82A3509AD32A54434BCC8017 /* HoverCursorModifier.swift */; };
 /* End PBXBuildFile section */
 
 /* Begin PBXFileReference section */
@@ -46,6 +48,7 @@
 		74255E6B6C40899E9B17D927 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
 		7C73E9D4C42CCABBF0F33543 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; };
 		81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; };
+		82A3509AD32A54434BCC8017 /* HoverCursorModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoverCursorModifier.swift; sourceTree = "<group>"; };
 		9262207DAC21619BD9EDEE15 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; };
 		944BAE054AAC1B9C4FC954F9 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
 		9FBEB58DD41817F09B0EB9F0 /* Listless.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Listless.xcdatamodel; sourceTree = "<group>"; };
@@ -54,6 +57,7 @@
 		C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
 		D10B5491A53E77C80F8F75CD /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
 		D123BB181208FC825777B0A7 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
+		D41D6E0CED14D79F31C45062 /* HoverCursorModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoverCursorModifier.swift; sourceTree = "<group>"; };
 		DC3DEE364304587D280C5672 /* TaskStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStore.swift; sourceTree = "<group>"; };
 		EF6B0195EA0176B72DE9B092 /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; };
 		FBB8A3BEB346267B30B4675F /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = "<group>"; };
@@ -94,6 +98,7 @@
 		58F917D865E0BDF4EF282306 /* Views */ = {
 			isa = PBXGroup;
 			children = (
+				82A3509AD32A54434BCC8017 /* HoverCursorModifier.swift */,
 				48AE1AE43296C1692FA6F755 /* PlatformScrollIndicatorsModifier.swift */,
 				81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */,
 				1B3D0B4710C7A0EE4F227851 /* TaskRowDragGesture.swift */,
@@ -134,6 +139,7 @@
 		D12ECC901ABED96B86CC85B5 /* Views */ = {
 			isa = PBXGroup;
 			children = (
+				D41D6E0CED14D79F31C45062 /* HoverCursorModifier.swift */,
 				B4D74AE2501F35974F57D21F /* PlatformScrollIndicatorsModifier.swift */,
 				EF6B0195EA0176B72DE9B092 /* PlatformTextFieldWidthModifier.swift */,
 				138612F350ECE29526F689B9 /* TaskRowDragGesture.swift */,
@@ -252,6 +258,7 @@
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				15B71073767FB4766A6BA2BE /* HoverCursorModifier.swift in Sources */,
 				D8EFF49E9156083D675D47F0 /* KeyboardNavigationModifier.swift in Sources */,
 				96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */,
 				614FCCA450EC0BFFD8B40640 /* ListlessMacApp.swift in Sources */,
@@ -270,6 +277,7 @@
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				FEC96DAA2BF8BB5D6D8504EB /* HoverCursorModifier.swift in Sources */,
 				6C252050E62AED3A0A684EBF /* KeyboardNavigationModifier.swift in Sources */,
 				5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */,
 				F0B2B806BD84A4F2FDF8E038 /* ListlessiOSApp.swift in Sources */,

diff --git a/Listless/Views/TaskListView.swift b/Listless/Views/TaskListView.swift

@@ -18,7 +18,9 @@ struct TaskListView: View {
     @FocusState private var focusedField: FocusField?
     @State private var selectedTaskID: UUID?
     @State private var refreshID = UUID()
-    @State private var draggedTask: UUID?
+    @State private var draggedTaskID: UUID?
+    @State private var visualOrder: [UUID]?
+    @State private var editingTaskID: UUID?
 
     init(store: TaskStore = TaskStore()) {
         _store = State(wrappedValue: store)
@@ -26,78 +28,109 @@ struct TaskListView: View {
 
     var body: some View {
         ScrollView {
-            ScrollViewReader { proxy in
-                VStack(alignment: .leading, spacing: 0) {
-                    if !completedTasks.isEmpty {
-                        Text("Completed")
-                            .font(.caption)
-                            .foregroundStyle(.secondary)
-                            .padding(.leading, 16)
-                            .padding(.bottom, 6)
-                    }
+            VStack(alignment: .leading, spacing: 0) {
+                if !completedTasks.isEmpty {
+                    Text("Completed")
+                        .font(.caption)
+                        .foregroundStyle(.secondary)
+                        .padding(.leading, 16)
+                        .padding(.bottom, 6)
+                }
 
-                    ForEach(completedTasks) { task in
-                        TaskRowView(
-                            task: task,
-                            taskID: task.id,
-                            isSelected: selectedTaskID == task.id,
-                            focusedField: $focusedField,
-                            onToggle: toggleCompletion(_:),
-                            onSubmit: handleSubmit(_:),
-                            onTitleChange: updateTitle(_:_:),
-                            onDelete: deleteTask(_:),
-                            onSelect: { selectTask(task.id) }
-                        )
-                    }
+                ForEach(completedTasks) { task in
+                    TaskRowView(
+                        task: task,
+                        taskID: task.id,
+                        isSelected: selectedTaskID == task.id,
+                        isEditing: editingTaskID == task.id,
+                        focusedField: $focusedField,
+                        onToggle: toggleCompletion(_:),
+                        onSubmit: handleSubmit(_:),
+                        onTitleChange: updateTitle(_:_:),
+                        onDelete: deleteTask(_:),
+                        onSelect: { selectTask(task.id) },
+                        onStartEdit: { startEditing(task.id) },
+                        onEndEdit: { endEditing() }
+                    )
+                }
 
-                    if !activeTasks.isEmpty && !completedTasks.isEmpty {
-                        Divider()
-                            .padding(.vertical, 8)
-                    }
+                if !activeTasks.isEmpty && !completedTasks.isEmpty {
+                    Divider()
+                        .padding(.vertical, 8)
+                }
 
-                    ForEach(Array(activeTasks.enumerated()), id: \.element.id) { index, task in
-                        TaskRowView(
-                            task: task,
-                            taskID: task.id,
-                            isSelected: selectedTaskID == task.id,
-                            isDragging: draggedTask == task.id,
-                            focusedField: $focusedField,
-                            onToggle: toggleCompletion(_:),
-                            onSubmit: handleSubmit(_:),
-                            onTitleChange: updateTitle(_:_:),
-                            onDelete: deleteTask(_:),
-                            onSelect: { selectTask(task.id) },
-                            onDragStart: { handleDragStart(task.id) },
-                            onDragEnd: handleDragEnd,
-                            onDrop: { droppedTaskID in
-                                handleDrop(taskID: droppedTaskID, at: index)
-                            }
-                        )
+                ForEach(displayActiveTasks) { task in
+                    TaskRowView(
+                        task: task,
+                        taskID: task.id,
+                        isSelected: selectedTaskID == task.id,
+                        isEditing: editingTaskID == task.id,
+                        focusedField: $focusedField,
+                        onToggle: toggleCompletion(_:),
+                        onSubmit: handleSubmit(_:),
+                        onTitleChange: updateTitle(_:_:),
+                        onDelete: deleteTask(_:),
+                        onSelect: { selectTask(task.id) },
+                        onStartEdit: { startEditing(task.id) },
+                        onEndEdit: { endEditing() }
+                    )
+                    .onDrag {
+                        startDrag(taskID: task.id)
+                        return NSItemProvider(object: task.id.uuidString as NSString)
+                    } preview: {
+                        Color.clear.frame(width: 1, height: 1)
                     }
-
-                    // Drop zone at the end to allow dropping below the last item
-                    if !activeTasks.isEmpty {
-                        Color.clear
-                            .frame(height: 44)
-                            .contentShape(Rectangle())
-                            .dropDestination(for: String.self) { items, location in
-                                guard let droppedUUIDString = items.first,
-                                      let droppedUUID = UUID(uuidString: droppedUUIDString) else {
-                                    return false
-                                }
-                                handleDrop(taskID: droppedUUID, at: activeTasks.count)
-                                return true
+                    .overlay {
+                        if draggedTaskID != nil && draggedTaskID != task.id {
+                            VStack(spacing: 0) {
+                                // Top 1/6 - insert BEFORE
+                                Color.clear
+                                    .frame(maxHeight: .infinity)
+                                    .layoutPriority(1)
+                                    .dropDestination(for: String.self, action: { _, _ in false }, isTargeted: { isTargeted in
+                                        if isTargeted {
+                                            updateVisualOrder(insertBefore: task.id)
+                                        }
+                                    })
+
+                                // Middle 2/3 - insert based on direction
+                                Color.clear
+                                    .frame(maxHeight: .infinity)
+                                    .layoutPriority(4)
+                                    .dropDestination(for: String.self, action: { _, _ in false }, isTargeted: { isTargeted in
+                                        if isTargeted {
+                                            updateVisualOrderSmart(relativeTo: task.id)
+                                        }
+                                    })
+
+                                // Bottom 1/6 - insert AFTER
+                                Color.clear
+                                    .frame(maxHeight: .infinity)
+                                    .layoutPriority(1)
+                                    .dropDestination(for: String.self, action: { _, _ in false }, isTargeted: { isTargeted in
+                                        if isTargeted {
+                                            updateVisualOrder(insertAfter: task.id)
+                                        }
+                                    })
                             }
-                    }
-                }
-                .frame(maxWidth: .infinity, alignment: .topLeading)
-                .onChange(of: selectedTaskID) { oldValue, newValue in
-                    if let newValue {
-                        withAnimation(.easeInOut(duration: 0.2)) {
-                            proxy.scrollTo(newValue, anchor: .center)
                         }
                     }
                 }
+
+                // Drop zone at the end
+                if !activeTasks.isEmpty && draggedTaskID != nil {
+                    Color.clear
+                        .frame(height: 44)
+                        .dropDestination(for: String.self, action: { _, _ in false }, isTargeted: { isTargeted in
+                            if isTargeted {
+                                updateVisualOrder(insertAtEnd: true)
+                            }
+                        })
+                }
+            }
+            .frame(maxWidth: .infinity, alignment: .topLeading)
+            .dropDestination(for: String.self) { items, location in
+                handleDrop(items: items)
             }
         }
         .contentShape(Rectangle())
@@ -127,19 +160,29 @@ struct TaskListView: View {
             .sorted { $0.sortOrder < $1.sortOrder }
     }
 
+    private var displayActiveTasks: [TaskItem] {
+        guard let visualOrder = visualOrder else {
+            return activeTasks
+        }
+
+        return visualOrder.compactMap { id in
+            activeTasks.first(where: { $0.id == id })
+        }
+    }
+
     private var completedTasks: [TaskItem] {
         Array(tasks.filter { $0.isCompleted })
             .sorted { $0.updatedAt > $1.updatedAt }
     }
 
     private var allTasksInDisplayOrder: [TaskItem] {
-        completedTasks + activeTasks
+        completedTasks + displayActiveTasks
     }
 
     private func createTaskAndFocus() {
         let task = store.createTask(title: "")
         selectedTaskID = task.id
-        focusTextField(task.id)
+        startEditing(task.id)
     }
 
     private func handleBackgroundTap() {
@@ -254,12 +297,13 @@ struct TaskListView: View {
         guard let currentID = selectedTaskID else { return .handled }
         guard let task = allTasksInDisplayOrder.first(where: { $0.id == currentID }) else { return .handled }
         guard !task.isCompleted else { return .handled }
-        focusTextField(currentID)
+        startEditing(currentID)
         return .handled
     }
 
     private func unfocusTextField() -> KeyPress.Result {
         guard case .task = focusedField else { return .ignored }
+        endEditing()
         focusScrollView()
         return .handled
     }
@@ -274,18 +318,105 @@ struct TaskListView: View {
         focusedField = .task(taskID)
     }
 
+    private func startEditing(_ taskID: UUID) {
+        editingTaskID = taskID
+        focusedField = .task(taskID)
+    }
+
+    private func endEditing() {
+        if let editingID = editingTaskID {
+            deleteIfEmpty(taskID: editingID)
+        }
+        editingTaskID = nil
+    }
+
     // MARK: - Drag and Drop
 
-    private func handleDragStart(_ taskID: UUID) {
-        draggedTask = taskID
+    private func startDrag(taskID: UUID) {
+        draggedTaskID = taskID
+        visualOrder = activeTasks.map(\.id)
     }
 
-    private func handleDragEnd() {
-        draggedTask = nil
+    private func updateVisualOrder(insertBefore targetID: UUID) {
+        guard let draggedID = draggedTaskID,
+              let order = visualOrder else { return }
+
+        var newOrder = order.filter { $0 != draggedID }
+        if let targetIndex = newOrder.firstIndex(of: targetID) {
+            newOrder.insert(draggedID, at: targetIndex)
+        }
+
+        if newOrder != visualOrder {
+            withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+                visualOrder = newOrder
+            }
+        }
+    }
+
+    private func updateVisualOrder(insertAfter targetID: UUID) {
+        guard let draggedID = draggedTaskID,
+              let order = visualOrder else { return }
+
+        var newOrder = order.filter { $0 != draggedID }
+        if let targetIndex = newOrder.firstIndex(of: targetID) {
+            newOrder.insert(draggedID, at: targetIndex + 1)
+        }
+
+        if newOrder != visualOrder {
+            withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+                visualOrder = newOrder
+            }
+        }
+    }
+
+    private func updateVisualOrderSmart(relativeTo targetID: UUID) {
+        guard let draggedID = draggedTaskID,
+              let order = visualOrder else { return }
+
+        // Determine if dragged item is currently above or below target
+        guard let draggedIndex = order.firstIndex(of: draggedID),
+              let targetIndex = order.firstIndex(of: targetID) else { return }
+
+        if draggedIndex < targetIndex {
+            // Dragging from above → insert after target
+            updateVisualOrder(insertAfter: targetID)
+        } else {
+            // Dragging from below → insert before target
+            updateVisualOrder(insertBefore: targetID)
+        }
     }
 
-    private func handleDrop(taskID: UUID, at index: Int) {
-        store.moveTask(taskID: taskID, toIndex: index)
-        draggedTask = nil
+    private func updateVisualOrder(insertAtEnd: Bool) {
+        guard let draggedID = draggedTaskID,
+              let order = visualOrder else { return }
+
+        var newOrder = order.filter { $0 != draggedID }
+        newOrder.append(draggedID)
+
+        if newOrder != visualOrder {
+            withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+                visualOrder = newOrder
+            }
+        }
+    }
+
+    private func handleDrop(items: [String]) -> Bool {
+        guard let droppedUUIDString = items.first,
+              let droppedUUID = UUID(uuidString: droppedUUIDString),
+              let order = visualOrder,
+              let finalIndex = order.firstIndex(of: droppedUUID) else {
+            draggedTaskID = nil
+            visualOrder = nil
+            return false
+        }
+
+        // Commit the reorder
+        store.moveTask(taskID: droppedUUID, toIndex: finalIndex)
+
+        // Clear drag state
+        draggedTaskID = nil
+        visualOrder = nil
+
+        return true
     }
 }

diff --git a/Listless/Views/TaskRowView.swift b/Listless/Views/TaskRowView.swift

@@ -4,48 +4,44 @@ struct TaskRowView: View {
     let task: TaskItem
     let taskID: UUID
     let isSelected: Bool
-    let isDragging: Bool
+    let isEditing: Bool
     let onToggle: (TaskItem) -> Void
     let onSubmit: (TaskItem) -> Void
     let onTitleChange: (TaskItem, String) -> Void
     let onDelete: (TaskItem) -> Void
     let onSelect: () -> Void
-    let onDragStart: () -> Void
-    let onDragEnd: () -> Void
-    let onDrop: (UUID) -> Void
+    let onStartEdit: () -> Void
+    let onEndEdit: () -> Void
     @FocusState.Binding var focusedField: TaskListView.FocusField?
 
-    @State private var title: String
+    @State private var editingTitle: String = ""
 
     init(
         task: TaskItem,
         taskID: UUID,
         isSelected: Bool,
-        isDragging: Bool = false,
+        isEditing: Bool = false,
         focusedField: FocusState<TaskListView.FocusField?>.Binding,
         onToggle: @escaping (TaskItem) -> Void,
         onSubmit: @escaping (TaskItem) -> Void,
         onTitleChange: @escaping (TaskItem, String) -> Void,
         onDelete: @escaping (TaskItem) -> Void,
         onSelect: @escaping () -> Void,
-        onDragStart: @escaping () -> Void = {},
-        onDragEnd: @escaping () -> Void = {},
-        onDrop: @escaping (UUID) -> Void = { _ in }
+        onStartEdit: @escaping () -> Void = {},
+        onEndEdit: @escaping () -> Void = {}
     ) {
         self.task = task
         self.taskID = taskID
         self.isSelected = isSelected
-        self.isDragging = isDragging
+        self.isEditing = isEditing
         self.onToggle = onToggle
         self.onSubmit = onSubmit
         self.onTitleChange = onTitleChange
         self.onDelete = onDelete
         self.onSelect = onSelect
-        self.onDragStart = onDragStart
-        self.onDragEnd = onDragEnd
-        self.onDrop = onDrop
+        self.onStartEdit = onStartEdit
+        self.onEndEdit = onEndEdit
         _focusedField = focusedField
-        _title = State(initialValue: task.title)
     }
 
     var body: some View {
@@ -59,17 +55,33 @@ struct TaskRowView: View {
             }
             .buttonStyle(.borderless)
 
-            TextField("New task", text: $title)
-                .textFieldStyle(.plain)
-                .font(.body)
-                .focused($focusedField, equals: .task(taskID))
-                .onSubmit {
-                    onSubmit(task)
+            if isEditing {
+                TextField("New task", text: $editingTitle, axis: .vertical)
+                    .textFieldStyle(.plain)
+                    .font(.body)
+                    .lineLimit(1...5)
+                    .focused($focusedField, equals: .task(taskID))
+                    .onSubmit {
+                        onSubmit(task)
+                    }
+                    .frame(maxWidth: .infinity, alignment: .leading)
+                    .disabled(task.isCompleted)
+            } else {
+                HStack(spacing: 0) {
+                    Text(task.title.isEmpty ? "New task" : task.title)
+                        .font(.body)
+                        .foregroundStyle(task.title.isEmpty ? .secondary : (task.isCompleted ? .secondary : .primary))
+                        .strikethrough(task.isCompleted, color: .secondary)
+                        .modifier(TextHoverModifier(isCompleted: task.isCompleted))
+                        .onTapGesture {
+                            if !task.isCompleted {
+                                onStartEdit()
+                            }
+                        }
+
+                    Spacer(minLength: 0)
                 }
-                .platformTextFieldWidth(text: title, placeholder: "New task")
-                .disabled(task.isCompleted)
-                .strikethrough(task.isCompleted, color: .secondary)
-                .foregroundStyle(task.isCompleted ? .secondary : .primary)
+            }
         }
         .padding(.vertical, 8)
         .padding(.horizontal, 16)
@@ -98,29 +110,22 @@ struct TaskRowView: View {
                 onDelete(task)
             }
         }
-        .onChange(of: title) {
+        .onChange(of: editingTitle) {
             guard !task.isCompleted else { return }
-            onTitleChange(task, title)
+            onTitleChange(task, editingTitle)
         }
-        .onChange(of: task.title) {
-            if task.title != title {
-                title = task.title
+        .onChange(of: isEditing) { _, newValue in
+            if newValue {
+                editingTitle = task.title
             }
         }
         .onChange(of: focusedField) { oldValue, newValue in
             if newValue == .task(taskID) && oldValue != .task(taskID) {
                 onSelect()
+            } else if oldValue == .task(taskID) && newValue != .task(taskID) {
+                onEndEdit()
             }
         }
-        .opacity(isDragging ? 0.5 : 1.0)
-        .taskDragGesture(
-            isActive: !task.isCompleted,
-            taskID: task.id,
-            taskTitle: task.title,
-            onDragStart: onDragStart,
-            onDragEnd: onDragEnd,
-            onDrop: onDrop
-        )
     }
 
     private var selectionBackground: some View {
@@ -144,13 +149,14 @@ struct TaskRowView: View {
     }
 
     private func copyToPasteboard() {
-        guard !title.isEmpty else { return }
+        let text = isEditing ? editingTitle : task.title
+        guard !text.isEmpty else { return }
         #if os(macOS)
         let pasteboard = NSPasteboard.general
         pasteboard.clearContents()
-        pasteboard.setString(title, forType: .string)
+        pasteboard.setString(text, forType: .string)
         #else
-        UIPasteboard.general.string = title
+        UIPasteboard.general.string = text
         #endif
     }
 
@@ -160,7 +166,9 @@ struct TaskRowView: View {
         #else
         guard let string = UIPasteboard.general.string else { return }
         #endif
-        title = string
+        if isEditing {
+            editingTitle = string
+        }
         onTitleChange(task, string)
     }
 }

diff --git a/ListlessMac/Views/HoverCursorModifier.swift b/ListlessMac/Views/HoverCursorModifier.swift

@@ -0,0 +1,18 @@
+import SwiftUI
+import AppKit
+
+struct TextHoverModifier: ViewModifier {
+    let isCompleted: Bool
+
+    func body(content: Content) -> some View {
+        content.onHover { isHovering in
+            if !isCompleted {
+                if isHovering {
+                    NSCursor.iBeam.push()
+                } else {
+                    NSCursor.pop()
+                }
+            }
+        }
+    }
+}

diff --git a/ListlessiOS/Views/HoverCursorModifier.swift b/ListlessiOS/Views/HoverCursorModifier.swift

@@ -0,0 +1,10 @@
+import SwiftUI
+
+struct TextHoverModifier: ViewModifier {
+    let isCompleted: Bool
+
+    func body(content: Content) -> some View {
+        // No-op on iOS - cursors don't apply
+        content
+    }
+}