listless

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

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