← All repositories

listless

A simple list app for Apple platforms

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


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 {