listless

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

Selection.md (4891B)


      1 # Findings: Cmd+Click Toggle for Multi-Select (Issue 1)
      2 
      3 ## Current Implementation
      4 
      5 The macOS selection model uses `FocusStateData` in
      6 `Listless/Helpers/TaskListTypes.swift` with:
      7 
      8 - `selectedTaskIDs: Set<UUID>` — the full set of selected task IDs
      9 - `anchorTaskID: UUID?` — fixed end of a Shift+Arrow range
     10 - `cursorTaskID: UUID?` — moving end of the range
     11 - `selectedTaskID: UUID?` — convenience getter/setter that resets to
     12   single-element selection
     13 
     14 `extendSelection(to:displayOrder:)` computes a contiguous range from anchor to
     15 cursor and replaces `selectedTaskIDs` entirely. This means the current model
     16 has no concept of discontinuous selections.
     17 
     18 Shift+Click is handled in `TaskListView+Logic.swift` via `selectTask(_:extendSelection:)`.
     19 The macOS `TaskListView` body checks `NSEvent.modifierFlags.contains(.shift)` and
     20 passes `extendSelection: true`.
     21 
     22 Shift+Up/Down are `navigateUpExtend()`/`navigateDownExtend()` in `TaskListView+Logic.swift`.
     23 They move the cursor one step and call `extendSelection(to:displayOrder:)`.
     24 
     25 ## Proposed Cmd+Click Behaviour
     26 
     27 Cmd+Click should toggle individual items in and out of a multi-selection.
     28 
     29 ### State Model
     30 
     31 After a Cmd+Click, the state consists of:
     32 
     33 - **anchor** — set to the item immediately below the Cmd+Clicked item
     34 - **cursor** — reset to the same position as anchor
     35 - **inactive selections** — all other selected items outside the anchor-cursor
     36   range (these are carried forward from the previous selection)
     37 
     38 The visible selection is: `inactive ∪ range(anchor, cursor)`.
     39 
     40 ### Shift+Arrow After Cmd+Click
     41 
     42 Shift+Up/Down moves the cursor. The selection is recomputed as
     43 `inactive ∪ range(anchor, cursor)`. The range flips direction when the cursor
     44 crosses the anchor.
     45 
     46 **Merge rule**: when the active range becomes adjacent to (or overlaps with) an
     47 inactive range, the inactive items are absorbed into the active range and the
     48 cursor jumps to the far end of the merged region (away from the anchor). After
     49 a merge, the selection is fully contiguous and subsequent Shift+Arrow presses
     50 behave as normal anchored selection.
     51 
     52 ### Worked Example
     53 
     54 Starting list: A, B, C, D, E, F, G, H, I
     55 
     56 | Step | Action         | Cursor | Anchor | Active Range | Inactive | Selection   |
     57 |------|----------------|--------|--------|--------------|----------|-------------|
     58 | 1    | Select D-G     | G      | D      | {D,E,F,G}   | {}       | {D,E,F,G}  |
     59 | 2    | Cmd+Click E    | F      | F      | {F}          | {D,G}   | {D,F,G}    |
     60 | 3    | Shift+Up       | E      | F      | {E,F}        | {D,G}   | {D,E,F,G}  |
     61 
     62 Wait — this doesn't match. The user's test showed step 2 → step 3 as
     63 {D,F,G} → {D,F} with Shift+Up. That means the cursor after Cmd+Click was
     64 at G (the previous cursor), not reset to the anchor.
     65 
     66 **Corrected model**: after Cmd+Click, the cursor stays at its previous position
     67 (G), and the anchor moves to the item below the clicked item (F).
     68 
     69 | Step | Action         | Cursor | Anchor | Active Range | Inactive | Selection     |
     70 |------|----------------|--------|--------|--------------|----------|---------------|
     71 | 1    | Select D-G     | G      | D      | {D,E,F,G}   | {}       | {D,E,F,G}    |
     72 | 2    | Cmd+Click E    | G      | F      | {F,G}        | {D}      | {D,F,G}      |
     73 | 3    | Shift+Up       | F      | F      | {F}          | {D}      | {D,F}        |
     74 | 4    | Shift+Down     | G      | F      | {F,G}        | {D}      | {D,F,G}      |
     75 | 5    | Shift+Up       | F      | F      | {F}          | {D}      | {D,F}        |
     76 | 6    | Shift+Up       | E      | F      | {E,F}        | {D}*     | {D,E,F}      |
     77 | 7    | Shift+Up       | C      | F      | {C,D,E,F}   | {}       | {C,D,E,F}    |
     78 | 8    | Shift+Down     | D      | F      | {D,E,F}     | {}       | {D,E,F}      |
     79 | 9    | Shift+Down     | E      | F      | {E,F}       | {}       | {E,F}        |
     80 | 10   | Shift+Down     | F      | F      | {F}         | {}       | {F}          |
     81 
     82 *At step 6, the active range {E,F} becomes adjacent to inactive {D}. Merge
     83 occurs: inactive is cleared and the cursor jumps from E to D (the far end of
     84 the merged region, away from the anchor). This is why step 7's Shift+Up moves
     85 the cursor from D to C, not from E to D.
     86 
     87 ### Edge Cases Still to Decide
     88 
     89 - **Cmd+Click on the bottom-most item**: there is no item below to anchor to.
     90   Options: anchor to the clicked item itself, or anchor to the item above.
     91 - **Multiple Cmd+Clicks**: each Cmd+Click sets the anchor to the item below the
     92   clicked item; cursor stays at its previous position. The inactive set is
     93   whatever is selected outside the active range. (This is a simplification;
     94   actual Finder behaviour in macOS 15 varies depending on whether you deselect
     95   going up vs down the list.)
     96 - **Cmd+Click to add** (clicking an unselected item): needs testing. Presumably
     97   the item is added to the selection and anchor/cursor update the same way.