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:
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)
}
}