listless

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

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 + } +}