listless

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

commit ee9fb447330412e610862818a1b04c7821e0557d
parent 9115b8da4b267a70f484ad8d37789496be45c874
Author: Michael Camilleri <[email protected]>
Date:   Tue, 17 Mar 2026 04:10:08 +0900

Improve selection behaviour in macOS version

Listless's custom list implementation means that it does not inherit
the selection behaviour of lists that use standard AppKit views. This
commit attempts to add more of this behaviour. In particular, this
includes non-continuous selections (i.e. selections where command-click
is used to deselect individual elements).

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

Diffstat:
MListless/Extensions/TaskListView+Logic.swift | 13+++++++++++--
MListless/Helpers/TaskListTypes.swift | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
MListlessMac/Views/TaskListView.swift | 4+++-
MTests/UI/ListlessMacUITests.swift | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 300 insertions(+), 9 deletions(-)

diff --git a/Listless/Extensions/TaskListView+Logic.swift b/Listless/Extensions/TaskListView+Logic.swift @@ -271,8 +271,17 @@ extension TaskListViewProtocol { deleteTask(task) } - func selectTask(_ taskID: UUID, extendSelection: Bool = false) { - if extendSelection && fState.selectedTaskID != nil { + func selectTask( + _ taskID: UUID, + extendSelection: Bool = false, + toggleSelection: Bool = false + ) { + if toggleSelection { + fState.toggleSelection( + taskID: taskID, + displayOrder: allTasksInDisplayOrder.map(\.id) + ) + } else if extendSelection && fState.selectedTaskID != nil { if fState.anchorTaskID == nil { fState.anchorTaskID = fState.cursorTaskID } diff --git a/Listless/Helpers/TaskListTypes.swift b/Listless/Helpers/TaskListTypes.swift @@ -32,6 +32,10 @@ struct FocusStateData { /// anchor. During Shift+Arrow it tracks the moving end of the range. private(set) var cursorTaskID: UUID? + /// Selected items outside the active anchor–cursor range, preserved + /// across Shift+Arrow operations after a Cmd+Click toggle. + private(set) var inactiveSelections: Set<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 @@ -42,6 +46,7 @@ struct FocusStateData { anchorTaskID = newValue cursorTaskID = newValue selectedTaskIDs = newValue.map { Set([$0]) } ?? [] + inactiveSelections = [] } } @@ -60,10 +65,57 @@ struct FocusStateData { anchorTaskID = displayOrder.first cursorTaskID = displayOrder.last selectedTaskIDs = Set(displayOrder) + inactiveSelections = [] + } + + /// Toggle a single item in/out of the selection (Cmd+Click). + /// Sets anchor to the item below the toggled item in display order. + /// When deselecting, cursor stays at its previous position so + /// Shift+Arrow contracts from the far end. When adding, cursor + /// resets to anchor so the active range stays small and other + /// selections are preserved as inactive. + mutating func toggleSelection(taskID: UUID, displayOrder: [UUID]) { + guard let toggledIndex = displayOrder.firstIndex(of: taskID) else { return } + + let wasSelected = selectedTaskIDs.contains(taskID) + if wasSelected { + selectedTaskIDs.remove(taskID) + } else { + selectedTaskIDs.insert(taskID) + } + + guard !selectedTaskIDs.isEmpty else { + anchorTaskID = nil + cursorTaskID = nil + inactiveSelections = [] + return + } + + // Anchor = item below the toggled item (or self if at bottom). + anchorTaskID = + toggledIndex + 1 < displayOrder.count + ? displayOrder[toggledIndex + 1] + : displayOrder[toggledIndex] + + if wasSelected { + // Deselecting: cursor stays so Shift+Arrow contracts from + // the far end of the remaining selection. + if cursorTaskID == nil { + cursorTaskID = anchorTaskID + } + } else { + // Adding: cursor resets to anchor so the active range is + // small, preserving other selections as inactive. + cursorTaskID = anchorTaskID + } + + recomputeInactiveSelections(displayOrder: displayOrder) } /// Extend or contract the selection from the anchor to `targetID`, - /// selecting all tasks between them in `displayOrder`. + /// selecting all tasks between them in `displayOrder`. Inactive + /// selections are preserved and merged when they become adjacent + /// to the active range. mutating func extendSelection(to targetID: UUID, displayOrder: [UUID]) { guard let anchorID = anchorTaskID, let anchorIndex = displayOrder.firstIndex(of: anchorID), @@ -71,12 +123,75 @@ struct FocusStateData { else { return } - let range = - anchorIndex <= targetIndex - ? anchorIndex...targetIndex - : targetIndex...anchorIndex - selectedTaskIDs = Set(range.map { displayOrder[$0] }) + let lo = min(anchorIndex, targetIndex) + let hi = max(anchorIndex, targetIndex) + let activeRange = Set(displayOrder[lo...hi]) + selectedTaskIDs = inactiveSelections.union(activeRange) cursorTaskID = targetID + mergeAdjacentInactiveSelections(displayOrder: displayOrder) + } + + // MARK: - Private Helpers + + /// Partition `selectedTaskIDs` into those inside vs outside the + /// anchor–cursor range. + private mutating func recomputeInactiveSelections(displayOrder: [UUID]) { + guard let anchorID = anchorTaskID, let cursorID = cursorTaskID, + let anchorIndex = displayOrder.firstIndex(of: anchorID), + let cursorIndex = displayOrder.firstIndex(of: cursorID) + else { + inactiveSelections = [] + return + } + let lo = min(anchorIndex, cursorIndex) + let hi = max(anchorIndex, cursorIndex) + let activeRange = Set(displayOrder[lo...hi]) + inactiveSelections = selectedTaskIDs.subtracting(activeRange) + } + + /// When the active range becomes adjacent to inactive selections, + /// absorb them: clear the merged items from `inactiveSelections` + /// and jump the cursor to the far end of the merged region (away + /// from the anchor). + private mutating func mergeAdjacentInactiveSelections(displayOrder: [UUID]) { + guard !inactiveSelections.isEmpty, + let anchorID = anchorTaskID, + let anchorIndex = displayOrder.firstIndex(of: anchorID), + let cursorID = cursorTaskID, + let cursorIndex = displayOrder.firstIndex(of: cursorID) + else { + return + } + + var lo = min(anchorIndex, cursorIndex) + var hi = max(anchorIndex, cursorIndex) + var mergedIDs: Set<UUID> = [] + var changed = true + + while changed { + changed = false + for inactiveID in inactiveSelections where !mergedIDs.contains(inactiveID) { + guard let idx = displayOrder.firstIndex(of: inactiveID) else { continue } + if idx == lo - 1 || idx == hi + 1 || (idx >= lo && idx <= hi) { + if idx < lo { lo = idx } + if idx > hi { hi = idx } + mergedIDs.insert(inactiveID) + changed = true + } + } + } + + guard !mergedIDs.isEmpty else { return } + + inactiveSelections.subtract(mergedIDs) + + if cursorIndex <= anchorIndex { + cursorTaskID = displayOrder[lo] + } else { + cursorTaskID = displayOrder[hi] + } + + selectedTaskIDs = inactiveSelections.union(Set(displayOrder[lo...hi])) } } diff --git a/ListlessMac/Views/TaskListView.swift b/ListlessMac/Views/TaskListView.swift @@ -197,9 +197,11 @@ struct TaskListView: View, TaskListViewProtocol { onTitleChange: { updateTitle($0, $1) }, onDelete: { deleteTask($0) }, onSelect: { + let modifiers = NSApp.currentEvent?.modifierFlags ?? [] selectTask( $0, - extendSelection: NSEvent.modifierFlags.contains(.shift) + extendSelection: modifiers.contains(.shift), + toggleSelection: modifiers.contains(.command) ) }, onStartEdit: { startEditing($0) }, diff --git a/Tests/UI/ListlessMacUITests.swift b/Tests/UI/ListlessMacUITests.swift @@ -1,3 +1,4 @@ +import CoreGraphics import XCTest final class ListlessMacUITests: XCTestCase { @@ -63,6 +64,38 @@ final class ListlessMacUITests: XCTestCase { } } + /// Performs a Command+Click on the row containing the given task title. + /// Uses CGEvent mouse events with `.maskCommand` so the app sees the + /// modifier via `NSApp.currentEvent?.modifierFlags`. Clicks in the + /// row's left padding area (before the checkbox) so the tap gesture + /// fires rather than the text field or checkbox. + func cmdClickRow(withText title: String) { + let textField = taskText(title) + XCTAssertTrue(textField.waitForExistence(timeout: 2)) + // Offset to the left of the text field, into the row's 16pt left padding. + let coord = textField.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0.5)) + .withOffset(CGVector(dx: -40, dy: 0)) + let point = coord.screenPoint + + let src = CGEventSource(stateID: .combinedSessionState) + + let mouseDown = CGEvent( + mouseEventSource: src, mouseType: .leftMouseDown, + mouseCursorPosition: point, mouseButton: .left + )! + mouseDown.flags = .maskCommand + mouseDown.post(tap: .cgSessionEventTap) + usleep(50_000) + + let mouseUp = CGEvent( + mouseEventSource: src, mouseType: .leftMouseUp, + mouseCursorPosition: point, mouseButton: .left + )! + mouseUp.flags = .maskCommand + mouseUp.post(tap: .cgSessionEventTap) + usleep(200_000) + } + // MARK: - Empty State func testLaunchShowsEmptyState() { @@ -288,4 +321,136 @@ final class ListlessMacUITests: XCTestCase { "Empty state should reappear after clearing the only completed task" ) } + + // MARK: - Shift+Arrow Selection + + func testShiftDownExtendsSelection() { + createTask("Alpha") + createTask("Bravo") + createTask("Charlie") + navigateToTask(at: 0) + app.typeKey(.downArrow, modifierFlags: .shift) + app.typeKey(.downArrow, modifierFlags: .shift) + // All three should be selected; delete removes them all. + app.typeKey(.delete, modifierFlags: []) + XCTAssertTrue( + emptyStateLabel.waitForExistence(timeout: 2), + "All three tasks should be deleted after Shift+Down range select" + ) + } + + func testShiftUpContractsSelection() { + createTask("Alpha") + createTask("Bravo") + createTask("Charlie") + navigateToTask(at: 0) + // Extend down to select Alpha, Bravo, Charlie + app.typeKey(.downArrow, modifierFlags: .shift) + app.typeKey(.downArrow, modifierFlags: .shift) + // Contract back up: deselect Charlie, then Bravo + app.typeKey(.upArrow, modifierFlags: .shift) + app.typeKey(.upArrow, modifierFlags: .shift) + // Only Alpha should be selected now. + app.typeKey(.delete, modifierFlags: []) + XCTAssertTrue(taskText("Bravo").waitForExistence(timeout: 2), "Bravo should remain") + XCTAssertTrue(taskText("Charlie").exists, "Charlie should remain") + XCTAssertFalse(taskText("Alpha").exists, "Alpha should be deleted") + } + + func testSelectAllThenShiftDown() { + createTask("Alpha") + createTask("Bravo") + createTask("Charlie") + navigateToTask(at: 0) + // Select all via Cmd+A + app.typeKey("a", modifierFlags: .command) + // Shift+Down should be a no-op (cursor already at last item). + // All three should still be selected. + app.typeKey(.downArrow, modifierFlags: .shift) + app.typeKey(.delete, modifierFlags: []) + XCTAssertTrue( + emptyStateLabel.waitForExistence(timeout: 2), + "All tasks should still be selected after Shift+Down at end" + ) + } + + // MARK: - Cmd+Click Selection + + func testCmdClickDeselectsFromRange() { + createTask("Alpha") + createTask("Bravo") + createTask("Charlie") + navigateToTask(at: 0) + // Extend selection to all three + app.typeKey(.downArrow, modifierFlags: .shift) + app.typeKey(.downArrow, modifierFlags: .shift) + // Cmd+Click Bravo to deselect it + cmdClickRow(withText: "Bravo") + app.typeKey(.delete, modifierFlags: []) + + XCTAssertTrue( + taskText("Bravo").waitForExistence(timeout: 2), + "Bravo should remain (was deselected by Cmd+Click)" + ) + XCTAssertFalse(taskText("Alpha").exists, "Alpha should be deleted") + XCTAssertFalse(taskText("Charlie").exists, "Charlie should be deleted") + } + + func testCmdClickAddsToSelection() { + createTask("Alpha") + createTask("Bravo") + createTask("Charlie") + navigateToTask(at: 0) + // Cmd+Click Charlie to add it to selection + cmdClickRow(withText: "Charlie") + app.typeKey(.delete, modifierFlags: []) + + XCTAssertTrue( + taskText("Bravo").waitForExistence(timeout: 2), + "Bravo should remain (was not selected)" + ) + XCTAssertFalse(taskText("Alpha").exists, "Alpha should be deleted") + XCTAssertFalse(taskText("Charlie").exists, "Charlie should be deleted") + } + + func testShiftUpAfterCmdClickDeselect() { + createTask("Delta") + createTask("Echo") + createTask("Foxtrot") + createTask("Golf") + navigateToTask(at: 0) + // Select Delta through Golf + app.typeKey(.downArrow, modifierFlags: .shift) + app.typeKey(.downArrow, modifierFlags: .shift) + app.typeKey(.downArrow, modifierFlags: .shift) + // Cmd+Click Echo to deselect: {Delta, Foxtrot, Golf} + cmdClickRow(withText: "Echo") + // Shift+Up contracts from cursor end: removes Golf → {Delta, Foxtrot} + app.typeKey(.upArrow, modifierFlags: .shift) + app.typeKey(.delete, modifierFlags: []) + + XCTAssertTrue(taskText("Echo").waitForExistence(timeout: 2), "Echo should remain") + XCTAssertTrue(taskText("Golf").exists, "Golf should remain") + XCTAssertFalse(taskText("Delta").exists, "Delta should be deleted") + XCTAssertFalse(taskText("Foxtrot").exists, "Foxtrot should be deleted") + } + + func testSelectAllAfterCmdClick() { + createTask("Alpha") + createTask("Bravo") + createTask("Charlie") + navigateToTask(at: 0) + app.typeKey(.downArrow, modifierFlags: .shift) + app.typeKey(.downArrow, modifierFlags: .shift) + // Cmd+Click Bravo to create discontinuous selection {Alpha, Charlie} + cmdClickRow(withText: "Bravo") + // Cmd+A should select all, clearing any discontinuous state + app.typeKey("a", modifierFlags: .command) + app.typeKey(.delete, modifierFlags: []) + + XCTAssertTrue( + emptyStateLabel.waitForExistence(timeout: 2), + "All tasks should be deleted after Select All" + ) + } }