listless

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

commit 1d711ff52f6ef067db7d539827af52606f6e8bae
parent 45d6a4431551ee55ae3a2ad2a1342e0aeb51426e
Author: Michael Camilleri <[email protected]>
Date:   Fri, 13 Mar 2026 11:39:28 +0900

Add multi-select to macOS

This commit allows a user to use standard multi-selection keyboard
shortcuts to select multiple contiguous rows and then to interact with
them by toggling completion status or deleting.

Co-Authored-By: Claude 4.6 Opus <[email protected]>

Diffstat:
MListless/Extensions/TaskListView+Logic.swift | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
MListless/Helpers/TaskListTypes.swift | 52+++++++++++++++++++++++++++++++++++++++++++++++++++-
MListlessMac/Views/TaskListView.swift | 32++++++++++++++++++++------------
3 files changed, 147 insertions(+), 26 deletions(-)

diff --git a/Listless/Extensions/TaskListView+Logic.swift b/Listless/Extensions/TaskListView+Logic.swift @@ -341,18 +341,76 @@ extension TaskListViewProtocol { return .handled } + func navigateUpExtend() -> KeyPress.Result { + guard focusedField == .scrollView else { + return .ignored + } + + let displayOrder = allTasksInDisplayOrder.map(\.id) + + // If nothing is selected yet, start single-select at the bottom. + guard let cursorID = fState.cursorTaskID else { + fState.selectedTaskID = activeTasks.last?.id + return .handled + } + + guard let cursorIndex = displayOrder.firstIndex(of: cursorID), + cursorIndex > 0 + else { + return .handled + } + + let targetID = displayOrder[cursorIndex - 1] + // On the first extend, the anchor is wherever the cursor is. + if !fState.hasMultipleSelection { + fState.anchorTaskID = cursorID + } + fState.extendSelection(to: targetID, displayOrder: displayOrder) + return .handled + } + + func navigateDownExtend() -> KeyPress.Result { + guard focusedField == .scrollView else { + return .ignored + } + + let displayOrder = allTasksInDisplayOrder.map(\.id) + + guard let cursorID = fState.cursorTaskID else { + fState.selectedTaskID = activeTasks.first?.id ?? completedTasks.first?.id + return .handled + } + + guard let cursorIndex = displayOrder.firstIndex(of: cursorID), + cursorIndex < displayOrder.count - 1 + else { + return .handled + } + + let targetID = displayOrder[cursorIndex + 1] + if !fState.hasMultipleSelection { + fState.anchorTaskID = cursorID + } + fState.extendSelection(to: targetID, displayOrder: displayOrder) + return .handled + } + func toggleSelectedTask() -> KeyPress.Result { guard focusedField == .scrollView else { return .ignored } - guard let currentID = fState.selectedTaskID else { return .handled } - guard let task = allTasksInDisplayOrder.first(where: { $0.id == currentID }) else { - return .handled + let ids = fState.selectedTaskIDs + guard !ids.isEmpty else { return .handled } + let tasksToToggle = allTasksInDisplayOrder.filter { ids.contains($0.id) } + guard !tasksToToggle.isEmpty else { return .handled } + fState.selectedTaskID = nil + for task in tasksToToggle { + toggleCompletion(task) } - toggleCompletion(task) return .handled } func focusSelectedTask() -> KeyPress.Result { guard focusedField == .scrollView else { return .ignored } + guard !fState.hasMultipleSelection else { return .handled } guard let currentID = fState.selectedTaskID else { return .handled } guard let task = allTasksInDisplayOrder.first(where: { $0.id == currentID }) else { return .handled @@ -366,13 +424,14 @@ extension TaskListViewProtocol { guard focusedField == .scrollView else { return .ignored } - guard let currentID = fState.selectedTaskID else { - return .handled + let ids = fState.selectedTaskIDs + guard !ids.isEmpty else { return .handled } + let tasksToDelete = allTasksInDisplayOrder.filter { ids.contains($0.id) } + guard !tasksToDelete.isEmpty else { return .handled } + fState.selectedTaskID = nil + for task in tasksToDelete { + deleteTask(task) } - guard let task = allTasksInDisplayOrder.first(where: { $0.id == currentID }) else { - return .handled - } - deleteTask(task) return .handled } @@ -404,9 +463,13 @@ extension TaskListViewProtocol { func markSelectedTaskCompleted() { guard focusedField == .scrollView else { return } - guard let currentID = fState.selectedTaskID else { return } - guard let task = allTasksInDisplayOrder.first(where: { $0.id == currentID }) else { return } - toggleCompletion(task) + let ids = fState.selectedTaskIDs + guard !ids.isEmpty else { return } + let tasksToToggle = allTasksInDisplayOrder.filter { ids.contains($0.id) } + fState.selectedTaskID = nil + for task in tasksToToggle { + toggleCompletion(task) + } } // MARK: - Focus Management diff --git a/Listless/Helpers/TaskListTypes.swift b/Listless/Helpers/TaskListTypes.swift @@ -17,8 +17,58 @@ enum DraftTaskPlacement: Equatable { struct FocusStateData { var focusedField: FocusField? - var selectedTaskID: UUID? var pendingFocus: FocusField? + + // MARK: - Selection + + /// The full set of selected task IDs (supports multi-select on macOS). + private(set) var selectedTaskIDs: Set<UUID> = [] + + /// The start of a Shift+Arrow range selection. Stays fixed while the + /// cursor moves via repeated Shift+Arrow presses. + var anchorTaskID: UUID? + + /// The current cursor position. During single-select this equals the + /// anchor. During Shift+Arrow it tracks the moving end of the range. + private(set) var cursorTaskID: UUID? + + /// Single-select convenience. Getting returns the cursor (i.e. the + /// position plain Arrow keys navigate from); setting resets to a + /// single-element (or empty) selection, keeping all existing call + /// sites working without modification. + var selectedTaskID: UUID? { + get { cursorTaskID } + set { + anchorTaskID = newValue + cursorTaskID = newValue + selectedTaskIDs = newValue.map { Set([$0]) } ?? [] + } + } + + func isTaskSelected(_ id: UUID) -> Bool { + selectedTaskIDs.contains(id) + } + + var hasMultipleSelection: Bool { + selectedTaskIDs.count > 1 + } + + /// Extend or contract the selection from the anchor to `targetID`, + /// selecting all tasks between them in `displayOrder`. + mutating func extendSelection(to targetID: UUID, displayOrder: [UUID]) { + guard let anchorID = anchorTaskID, + let anchorIndex = displayOrder.firstIndex(of: anchorID), + let targetIndex = displayOrder.firstIndex(of: targetID) + else { + return + } + let range = + anchorIndex <= targetIndex + ? anchorIndex...targetIndex + : targetIndex...anchorIndex + selectedTaskIDs = Set(range.map { displayOrder[$0] }) + cursorTaskID = targetID + } } let draftPrependRowID = UUID() diff --git a/ListlessMac/Views/TaskListView.swift b/ListlessMac/Views/TaskListView.swift @@ -55,34 +55,39 @@ struct TaskListView: View, TaskListViewProtocol { } var canDeleteSelectionFromList: Bool { - fState.selectedTaskID != nil && focusedField == .scrollView + !fState.selectedTaskIDs.isEmpty && focusedField == .scrollView } var canMarkSelectionCompleted: Bool { guard focusedField == .scrollView else { return false } - guard let currentID = fState.selectedTaskID else { return false } - return allTasksInDisplayOrder.contains(where: { $0.id == currentID }) + return allTasksInDisplayOrder.contains(where: { fState.isTaskSelected($0.id) }) } var markCompletedMenuTitle: String { - completedTasks.contains(where: { $0.id == fState.selectedTaskID }) + if fState.hasMultipleSelection { + let hasCompleted = completedTasks.contains(where: { fState.isTaskSelected($0.id) }) + return hasCompleted ? "Mark as Incomplete" : "Mark as Complete" + } + return completedTasks.contains(where: { $0.id == fState.selectedTaskID }) ? "Mark as Incomplete" : "Mark as Complete" } var canMoveSelectionUp: Bool { guard focusedField == .scrollView else { return false } + guard !fState.hasMultipleSelection else { return false } guard let index = selectedIndex else { return false } return index > 0 } var canMoveSelectionDown: Bool { guard focusedField == .scrollView else { return false } + guard !fState.hasMultipleSelection else { return false } guard let index = selectedIndex else { return false } return index < activeTasks.count - 1 } struct MenuState: Equatable { - let selectedTaskID: UUID? + let selectedTaskIDs: Set<UUID> let isScrollViewFocused: Bool let activeTaskCount: Int let completedTaskCount: Int @@ -91,7 +96,7 @@ struct TaskListView: View, TaskListViewProtocol { var menuCoordinatorTrigger: MenuState { MenuState( - selectedTaskID: fState.selectedTaskID, + selectedTaskIDs: fState.selectedTaskIDs, isScrollViewFocused: focusedField == .scrollView, activeTaskCount: activeTasks.count, completedTaskCount: completedTasks.count, @@ -128,9 +133,10 @@ struct TaskListView: View, TaskListViewProtocol { coord.markSelectedTaskCompleted = { markSelectedTaskCompleted() } coord.clearCompletedTasks = { clearCompletedTasks() } let inNavMode = focusedField == .scrollView - coord.canCopySelectedTask = fState.selectedTaskID != nil && inNavMode - coord.canCutSelectedTask = fState.selectedTaskID != nil && inNavMode - coord.canPasteAfterSelectedTask = selectedIndex != nil && inNavMode + let singleSelect = !fState.selectedTaskIDs.isEmpty && !fState.hasMultipleSelection + coord.canCopySelectedTask = singleSelect && inNavMode + coord.canCutSelectedTask = singleSelect && inNavMode + coord.canPasteAfterSelectedTask = selectedIndex != nil && singleSelect && inNavMode coord.canDeleteSelectedTask = canDeleteSelectionFromList coord.canMoveSelectedTaskUp = canMoveSelectionUp coord.canMoveSelectedTaskDown = canMoveSelectionDown @@ -177,7 +183,7 @@ struct TaskListView: View, TaskListViewProtocol { taskID: taskID, index: index, totalTasks: displayActiveTasks.count, - isSelected: fState.selectedTaskID == taskID, + isSelected: fState.isTaskSelected(taskID), focusedField: $focusedFieldBinding, onToggle: { toggleCompletion($0) }, onTitleChange: { updateTitle($0, $1) }, @@ -253,7 +259,7 @@ struct TaskListView: View, TaskListViewProtocol { let accentColor = cachedTaskColor( forIndex: index, total: total ) - let isSelected = fState.selectedTaskID == draftAppendRowID + let isSelected = fState.isTaskSelected(draftAppendRowID) HStack(alignment: .firstTextBaseline, spacing: 12) { Image(systemName: "circle") .foregroundStyle(.primary) @@ -316,7 +322,7 @@ struct TaskListView: View, TaskListViewProtocol { TaskRowView( task: task, taskID: taskID, - isSelected: fState.selectedTaskID == taskID, + isSelected: fState.isTaskSelected(taskID), focusedField: $focusedFieldBinding, onToggle: { toggleCompletion($0) }, onTitleChange: { updateTitle($0, $1) }, @@ -380,6 +386,8 @@ struct TaskListView: View, TaskListViewProtocol { .keyboardNavigation([ ShortcutKey(key: .upArrow): navigateUp, ShortcutKey(key: .downArrow): navigateDown, + ShortcutKey(key: .upArrow, modifiers: .shift): navigateUpExtend, + ShortcutKey(key: .downArrow, modifiers: .shift): navigateDownExtend, ShortcutKey(key: .return): focusSelectedTask, ]) .onAppear {