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:
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 {