commit e675e13bb78191e69153cc1c3d06112ee831f818
parent f2e04bfab4caed167b734f75b70efad61c49d42c
Author: Michael Camilleri <[email protected]>
Date: Fri, 20 Mar 2026 07:07:32 +0900
Document multi-select rules
This commit adds a document that contains some of the analysis that
Claude did as we were investigating how to support multi-select in the
macOS version. This probably won't be a necessary but just in case, this
commit adds it to the repository for future reference.
Co-Authored-By: Claude 4.6 Opus <[email protected]>
Diffstat:
| A | Docs/Selection.md | | | 97 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
1 file changed, 97 insertions(+), 0 deletions(-)
diff --git a/Docs/Selection.md b/Docs/Selection.md
@@ -0,0 +1,97 @@
+# Findings: Cmd+Click Toggle for Multi-Select (Issue 1)
+
+## Current Implementation
+
+The macOS selection model uses `FocusStateData` in
+`Listless/Helpers/TaskListTypes.swift` with:
+
+- `selectedTaskIDs: Set<UUID>` — the full set of selected task IDs
+- `anchorTaskID: UUID?` — fixed end of a Shift+Arrow range
+- `cursorTaskID: UUID?` — moving end of the range
+- `selectedTaskID: UUID?` — convenience getter/setter that resets to
+ single-element selection
+
+`extendSelection(to:displayOrder:)` computes a contiguous range from anchor to
+cursor and replaces `selectedTaskIDs` entirely. This means the current model
+has no concept of discontinuous selections.
+
+Shift+Click is handled in `TaskListView+Logic.swift` via `selectTask(_:extendSelection:)`.
+The macOS `TaskListView` body checks `NSEvent.modifierFlags.contains(.shift)` and
+passes `extendSelection: true`.
+
+Shift+Up/Down are `navigateUpExtend()`/`navigateDownExtend()` in `TaskListView+Logic.swift`.
+They move the cursor one step and call `extendSelection(to:displayOrder:)`.
+
+## Proposed Cmd+Click Behaviour
+
+Cmd+Click should toggle individual items in and out of a multi-selection.
+
+### State Model
+
+After a Cmd+Click, the state consists of:
+
+- **anchor** — set to the item immediately below the Cmd+Clicked item
+- **cursor** — reset to the same position as anchor
+- **inactive selections** — all other selected items outside the anchor-cursor
+ range (these are carried forward from the previous selection)
+
+The visible selection is: `inactive ∪ range(anchor, cursor)`.
+
+### Shift+Arrow After Cmd+Click
+
+Shift+Up/Down moves the cursor. The selection is recomputed as
+`inactive ∪ range(anchor, cursor)`. The range flips direction when the cursor
+crosses the anchor.
+
+**Merge rule**: when the active range becomes adjacent to (or overlaps with) an
+inactive range, the inactive items are absorbed into the active range and the
+cursor jumps to the far end of the merged region (away from the anchor). After
+a merge, the selection is fully contiguous and subsequent Shift+Arrow presses
+behave as normal anchored selection.
+
+### Worked Example
+
+Starting list: A, B, C, D, E, F, G, H, I
+
+| Step | Action | Cursor | Anchor | Active Range | Inactive | Selection |
+|------|----------------|--------|--------|--------------|----------|-------------|
+| 1 | Select D-G | G | D | {D,E,F,G} | {} | {D,E,F,G} |
+| 2 | Cmd+Click E | F | F | {F} | {D,G} | {D,F,G} |
+| 3 | Shift+Up | E | F | {E,F} | {D,G} | {D,E,F,G} |
+
+Wait — this doesn't match. The user's test showed step 2 → step 3 as
+{D,F,G} → {D,F} with Shift+Up. That means the cursor after Cmd+Click was
+at G (the previous cursor), not reset to the anchor.
+
+**Corrected model**: after Cmd+Click, the cursor stays at its previous position
+(G), and the anchor moves to the item below the clicked item (F).
+
+| Step | Action | Cursor | Anchor | Active Range | Inactive | Selection |
+|------|----------------|--------|--------|--------------|----------|---------------|
+| 1 | Select D-G | G | D | {D,E,F,G} | {} | {D,E,F,G} |
+| 2 | Cmd+Click E | G | F | {F,G} | {D} | {D,F,G} |
+| 3 | Shift+Up | F | F | {F} | {D} | {D,F} |
+| 4 | Shift+Down | G | F | {F,G} | {D} | {D,F,G} |
+| 5 | Shift+Up | F | F | {F} | {D} | {D,F} |
+| 6 | Shift+Up | E | F | {E,F} | {D}* | {D,E,F} |
+| 7 | Shift+Up | C | F | {C,D,E,F} | {} | {C,D,E,F} |
+| 8 | Shift+Down | D | F | {D,E,F} | {} | {D,E,F} |
+| 9 | Shift+Down | E | F | {E,F} | {} | {E,F} |
+| 10 | Shift+Down | F | F | {F} | {} | {F} |
+
+*At step 6, the active range {E,F} becomes adjacent to inactive {D}. Merge
+occurs: inactive is cleared and the cursor jumps from E to D (the far end of
+the merged region, away from the anchor). This is why step 7's Shift+Up moves
+the cursor from D to C, not from E to D.
+
+### Edge Cases Still to Decide
+
+- **Cmd+Click on the bottom-most item**: there is no item below to anchor to.
+ Options: anchor to the clicked item itself, or anchor to the item above.
+- **Multiple Cmd+Clicks**: each Cmd+Click sets the anchor to the item below the
+ clicked item; cursor stays at its previous position. The inactive set is
+ whatever is selected outside the active range. (This is a simplification;
+ actual Finder behaviour in macOS 15 varies depending on whether you deselect
+ going up vs down the list.)
+- **Cmd+Click to add** (clicking an unselected item): needs testing. Presumably
+ the item is added to the selection and anchor/cursor update the same way.