← All repositories

listless

A simple list app for Apple platforms

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


commit 3b7e30f4a3f9fae5be4464314b1f9bf054e56e18
parent fa4040496b251aad12f0225f636f00c929540597
Author: Michael Camilleri <[email protected]>
Date:   Wed, 25 Feb 2026 14:21:33 +0900

Add cut/copy/paste for tasks

Co-Authored-By: Codex GPT 5.3 <[email protected]>

Diffstat

MListless/Extensions/TaskListView+Logic.swift | 24++++++++++++++++++++++++
MListless/Models/TaskStore.swift | 22+++++++++++++---------
MListlessMac/Helpers/AppCommands.swift | 6++++++
MListlessMac/ListlessMacApp.swift | 15+++++++++++++++
MListlessMac/Views/TaskListView.swift | 27++++++++++++++++++++++++++-
MListlessMac/Views/TaskRowView.swift | 8++++++--

6 files changed, 90 insertions(+), 12 deletions(-)

diff --git a/Listless/Extensions/TaskListView+Logic.swift b/Listless/Extensions/TaskListView+Logic.swift

@@ -86,6 +86,30 @@ extension TaskListView {
         }
     }
 
+    func createTask(title: String, afterTaskID: UUID) {
+        clearDragState()
+        do {
+            let newTask = try store.createTask(title: title, sortOrder: sortOrderAfter(taskID: afterTaskID))
+            selectedTaskID = newTask.id
+            focusedField = .scrollView
+        } catch {
+            presentStoreError(error)
+        }
+    }
+
+    private func sortOrderAfter(taskID: UUID) -> Int64? {
+        guard let afterIndex = activeTasks.firstIndex(where: { $0.id == taskID }) else {
+            return nil
+        }
+        let afterTask = activeTasks[afterIndex]
+        if afterIndex + 1 < activeTasks.count {
+            let nextTask = activeTasks[afterIndex + 1]
+            return (afterTask.sortOrder + nextTask.sortOrder) / 2
+        } else {
+            return afterTask.sortOrder + 1000
+        }
+    }
+
     // MARK: - Interaction Handlers
 
     func handleBackgroundTap() {

diff --git a/Listless/Models/TaskStore.swift b/Listless/Models/TaskStore.swift

@@ -49,19 +49,23 @@ final class TaskStore {
         }
     }
 
-    func createTask(title: String = "", atBeginning: Bool = false) throws -> TaskItem {
+    func createTask(title: String = "", atBeginning: Bool = false, sortOrder: Int64? = nil) throws -> TaskItem {
         let task = TaskItem(context: context)
         task.title = title
 
-        context.processPendingChanges()
-        let activeTasks = try fetchTasks().filter { !$0.isCompleted }
-
-        if atBeginning {
-            let minOrder = activeTasks.map(\.sortOrder).min() ?? 0
-            task.sortOrder = minOrder - 1000
+        if let sortOrder {
+            task.sortOrder = sortOrder
         } else {
-            let maxOrder = activeTasks.map(\.sortOrder).max() ?? -1000
-            task.sortOrder = maxOrder + 1000
+            context.processPendingChanges()
+            let activeTasks = try fetchTasks().filter { !$0.isCompleted }
+
+            if atBeginning {
+                let minOrder = activeTasks.map(\.sortOrder).min() ?? 0
+                task.sortOrder = minOrder - 1000
+            } else {
+                let maxOrder = activeTasks.map(\.sortOrder).max() ?? -1000
+                task.sortOrder = maxOrder + 1000
+            }
         }
 
         try save()

diff --git a/ListlessMac/Helpers/AppCommands.swift b/ListlessMac/Helpers/AppCommands.swift

@@ -9,6 +9,9 @@ final class MenuCoordinator {
     // Actions — set by TaskListView on each relevant state change.
     var newTask: (() -> Void)?
     var newWindow: (() -> Void)?
+    var copySelectedTask: (() -> Void)?
+    var cutSelectedTask: (() -> Void)?
+    var pasteAfterSelectedTask: (() -> Void)?
     var deleteSelectedTask: (() -> Void)?
     var moveSelectedTaskUp: (() -> Void)?
     var moveSelectedTaskDown: (() -> Void)?
@@ -16,6 +19,9 @@ final class MenuCoordinator {
     var clearCompletedTasks: (() -> Void)?
 
     // Enabled state — read by AppDelegate in menuWillOpen and validateMenuItem.
+    var canCopySelectedTask = false
+    var canCutSelectedTask = false
+    var canPasteAfterSelectedTask = false
     var canDeleteSelectedTask = false
     var canMoveSelectedTaskUp = false
     var canMoveSelectedTaskDown = false

diff --git a/ListlessMac/ListlessMacApp.swift b/ListlessMac/ListlessMacApp.swift

@@ -32,6 +32,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
         let coord = MenuCoordinator.shared
         switch menuItem.action {
         case #selector(handleNewWindow):      return true
+        case #selector(cut(_:)):              return coord.canCutSelectedTask
+        case #selector(copy(_:)):             return coord.canCopySelectedTask
+        case #selector(paste(_:)):            return coord.canPasteAfterSelectedTask
         case #selector(handleDeleteTask):     return coord.canDeleteSelectedTask
         case #selector(handleMoveUp):         return coord.canMoveSelectedTaskUp
         case #selector(handleMoveDown):       return coord.canMoveSelectedTaskDown
@@ -49,6 +52,18 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
         MenuCoordinator.shared.newTask?()
     }
 
+    @objc func cut(_ sender: Any?) {
+        MenuCoordinator.shared.cutSelectedTask?()
+    }
+
+    @objc func copy(_ sender: Any?) {
+        MenuCoordinator.shared.copySelectedTask?()
+    }
+
+    @objc func paste(_ sender: Any?) {
+        MenuCoordinator.shared.pasteAfterSelectedTask?()
+    }
+
     @objc private func handleDeleteTask() {
         MenuCoordinator.shared.deleteSelectedTask?()
     }

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

@@ -131,11 +131,35 @@ struct TaskListView: View {
     func updateMenuCoordinator() {
         let coord = MenuCoordinator.shared
         coord.newTask = { createNewTask(); focusedField = nil }
+        coord.copySelectedTask = {
+            guard let taskID = selectedTaskID,
+                  let task = tasks.first(where: { $0.id == taskID }) else { return }
+            let pasteboard = NSPasteboard.general
+            pasteboard.clearContents()
+            pasteboard.setString(task.title, forType: .string)
+        }
+        coord.cutSelectedTask = {
+            guard let taskID = selectedTaskID,
+                  let task = tasks.first(where: { $0.id == taskID }) else { return }
+            let pasteboard = NSPasteboard.general
+            pasteboard.clearContents()
+            pasteboard.setString(task.title, forType: .string)
+            deleteTask(task)
+        }
+        coord.pasteAfterSelectedTask = {
+            guard let taskID = selectedTaskID,
+                  let string = NSPasteboard.general.string(forType: .string) else { return }
+            createTask(title: string, afterTaskID: taskID)
+        }
         coord.deleteSelectedTask = { _ = deleteSelectedTask() }
         coord.moveSelectedTaskUp = { moveSelectedTaskUp() }
         coord.moveSelectedTaskDown = { moveSelectedTaskDown() }
         coord.markSelectedTaskCompleted = { markSelectedTaskCompleted() }
         coord.clearCompletedTasks = { clearCompletedTasks() }
+        let inNavMode = focusedField == .scrollView
+        coord.canCopySelectedTask = selectedTaskID != nil && inNavMode
+        coord.canCutSelectedTask = selectedTaskID != nil && inNavMode
+        coord.canPasteAfterSelectedTask = selectedTaskID != nil && inNavMode
         coord.canDeleteSelectedTask = canDeleteSelectionFromList
         coord.canMoveSelectedTaskUp = canMoveSelectionUp
         coord.canMoveSelectedTaskDown = canMoveSelectionDown
@@ -168,7 +192,8 @@ struct TaskListView: View {
                         onDelete: { deleteTask($0) },
                         onSelect: { selectTask($0) },
                         onStartEdit: { startEditing($0) },
-                        onEndEdit: { endEditing($0, shouldCreateNewTask: $1) }
+                        onEndEdit: { endEditing($0, shouldCreateNewTask: $1) },
+                        onPaste: { createTask(title: $0, afterTaskID: taskID) }
                     )
                     .taskDragGesture(
                         isActive: !task.isCompleted,

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

@@ -12,6 +12,7 @@ struct TaskRowView: View {
     let onSelect: (UUID) -> Void
     let onStartEdit: (UUID) -> Void
     let onEndEdit: (UUID, _ shouldCreateNewTask: Bool) -> Void
+    let onPaste: (String) -> Void
     @FocusState.Binding var focusedField: TaskListView.FocusField?
 
     @State private var editingTitle: String = ""
@@ -44,7 +45,8 @@ struct TaskRowView: View {
         onDelete: @escaping (TaskItem) -> Void,
         onSelect: @escaping (UUID) -> Void,
         onStartEdit: @escaping (UUID) -> Void = { _ in },
-        onEndEdit: @escaping (UUID, _ shouldCreateNewTask: Bool) -> Void = { _, _ in }
+        onEndEdit: @escaping (UUID, _ shouldCreateNewTask: Bool) -> Void = { _, _ in },
+        onPaste: @escaping (String) -> Void = { _ in }
     ) {
         self.task = task
         self.taskID = taskID
@@ -57,6 +59,7 @@ struct TaskRowView: View {
         self.onSelect = onSelect
         self.onStartEdit = onStartEdit
         self.onEndEdit = onEndEdit
+        self.onPaste = onPaste
         _focusedField = focusedField
     }
 
@@ -198,7 +201,8 @@ struct TaskRowView: View {
         guard let string = NSPasteboard.general.string(forType: .string) else { return }
         if isCurrentlyEditing {
             editingTitle = string
+        } else {
+            onPaste(string)
         }
-        onTitleChange(task, string)
     }
 }