listless

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

ItemListTypes.swift (8000B)


      1 import Foundation
      2 
      3 enum FocusField: Hashable {
      4     case item(UUID)
      5     case scrollView
      6 }
      7 
      8 let pageNavigationSize = 10
      9 
     10 enum DragState: Equatable {
     11     case idle
     12     case dragging(id: UUID, order: [UUID])
     13 }
     14 
     15 enum DraftItemPlacement: Equatable {
     16     case prepend
     17     case append
     18 }
     19 
     20 struct FocusStateData {
     21     var focusedField: FocusField?
     22     var pendingFocus: FocusField?
     23 
     24     // MARK: - Selection
     25 
     26     /// The full set of selected item IDs (supports multi-select on macOS).
     27     private(set) var selectedItemIDs: Set<UUID> = []
     28 
     29     /// The start of a Shift+Arrow range selection. Stays fixed while the
     30     /// cursor moves via repeated Shift+Arrow presses.
     31     var anchorItemID: UUID?
     32 
     33     /// The current cursor position. During single-select this equals the
     34     /// anchor. During Shift+Arrow it tracks the moving end of the range.
     35     private(set) var cursorItemID: UUID?
     36 
     37     /// Selected items outside the active anchor–cursor range, preserved
     38     /// across Shift+Arrow operations after a Cmd+Click toggle.
     39     private(set) var inactiveSelections: Set<UUID> = []
     40 
     41     /// Single-select convenience. Getting returns the cursor (i.e. the
     42     /// position plain Arrow keys navigate from); setting resets to a
     43     /// single-element (or empty) selection, keeping all existing call
     44     /// sites working without modification.
     45     var selectedItemID: UUID? {
     46         get { cursorItemID }
     47         set {
     48             anchorItemID = newValue
     49             cursorItemID = newValue
     50             selectedItemIDs = newValue.map { Set([$0]) } ?? []
     51             inactiveSelections = []
     52         }
     53     }
     54 
     55     func isItemSelected(_ id: UUID) -> Bool {
     56         selectedItemIDs.contains(id)
     57     }
     58 
     59     var hasMultipleSelection: Bool {
     60         selectedItemIDs.count > 1
     61     }
     62 
     63     /// Select all items in display order, anchoring at the first and
     64     /// placing the cursor at the last.
     65     mutating func selectAll(displayOrder: [UUID]) {
     66         guard !displayOrder.isEmpty else { return }
     67         anchorItemID = displayOrder.first
     68         cursorItemID = displayOrder.last
     69         selectedItemIDs = Set(displayOrder)
     70         inactiveSelections = []
     71     }
     72 
     73     /// Toggle a single item in/out of the selection (Cmd+Click).
     74     /// Sets anchor to the item below the toggled item in display order.
     75     /// When deselecting, cursor stays at its previous position so
     76     /// Shift+Arrow contracts from the far end. When adding, cursor
     77     /// resets to anchor so the active range stays small and other
     78     /// selections are preserved as inactive.
     79     mutating func toggleSelection(itemID: UUID, displayOrder: [UUID]) {
     80         guard let toggledIndex = displayOrder.firstIndex(of: itemID) else { return }
     81 
     82         let wasSelected = selectedItemIDs.contains(itemID)
     83         if wasSelected {
     84             selectedItemIDs.remove(itemID)
     85         } else {
     86             selectedItemIDs.insert(itemID)
     87         }
     88 
     89         guard !selectedItemIDs.isEmpty else {
     90             anchorItemID = nil
     91             cursorItemID = nil
     92             inactiveSelections = []
     93             return
     94         }
     95 
     96         // Anchor = item below the toggled item (or self if at bottom).
     97         anchorItemID =
     98             toggledIndex + 1 < displayOrder.count
     99             ? displayOrder[toggledIndex + 1]
    100             : displayOrder[toggledIndex]
    101 
    102         if wasSelected {
    103             // Deselecting: cursor stays so Shift+Arrow contracts from
    104             // the far end of the remaining selection.
    105             if cursorItemID == nil {
    106                 cursorItemID = anchorItemID
    107             }
    108         } else {
    109             // Adding: cursor resets to anchor so the active range is
    110             // small, preserving other selections as inactive.
    111             cursorItemID = anchorItemID
    112         }
    113 
    114         recomputeInactiveSelections(displayOrder: displayOrder)
    115     }
    116 
    117     /// Extend or contract the selection from the anchor to `targetID`,
    118     /// selecting all items between them in `displayOrder`. Inactive
    119     /// selections are preserved and merged when they become adjacent
    120     /// to the active range.
    121     mutating func extendSelection(to targetID: UUID, displayOrder: [UUID]) {
    122         guard let anchorID = anchorItemID,
    123             let anchorIndex = displayOrder.firstIndex(of: anchorID),
    124             let targetIndex = displayOrder.firstIndex(of: targetID)
    125         else {
    126             return
    127         }
    128         let lo = min(anchorIndex, targetIndex)
    129         let hi = max(anchorIndex, targetIndex)
    130         let activeRange = Set(displayOrder[lo...hi])
    131         selectedItemIDs = inactiveSelections.union(activeRange)
    132         cursorItemID = targetID
    133         mergeAdjacentInactiveSelections(displayOrder: displayOrder)
    134     }
    135 
    136     /// Remove IDs from selection state that are no longer in display order.
    137     /// Call after deleting items to prevent ghost selections.
    138     mutating func pruneDeletedItems(displayOrder: [UUID]) {
    139         let valid = Set(displayOrder)
    140         selectedItemIDs.formIntersection(valid)
    141         inactiveSelections.formIntersection(valid)
    142         if let id = anchorItemID, !valid.contains(id) {
    143             anchorItemID = nil
    144         }
    145         if let id = cursorItemID, !valid.contains(id) {
    146             cursorItemID = nil
    147         }
    148         // If the cursor was pruned, fall back to anchor or first selected.
    149         if cursorItemID == nil, !selectedItemIDs.isEmpty {
    150             cursorItemID = anchorItemID ?? selectedItemIDs.first
    151         }
    152         if selectedItemIDs.isEmpty {
    153             anchorItemID = nil
    154             cursorItemID = nil
    155             inactiveSelections = []
    156         }
    157     }
    158 
    159     // MARK: - Private Helpers
    160 
    161     /// Partition `selectedItemIDs` into those inside vs outside the
    162     /// anchor–cursor range.
    163     private mutating func recomputeInactiveSelections(displayOrder: [UUID]) {
    164         guard let anchorID = anchorItemID, let cursorID = cursorItemID,
    165             let anchorIndex = displayOrder.firstIndex(of: anchorID),
    166             let cursorIndex = displayOrder.firstIndex(of: cursorID)
    167         else {
    168             inactiveSelections = []
    169             return
    170         }
    171         let lo = min(anchorIndex, cursorIndex)
    172         let hi = max(anchorIndex, cursorIndex)
    173         let activeRange = Set(displayOrder[lo...hi])
    174         inactiveSelections = selectedItemIDs.subtracting(activeRange)
    175     }
    176 
    177     /// When the active range becomes adjacent to inactive selections,
    178     /// absorb them: clear the merged items from `inactiveSelections`
    179     /// and jump the cursor to the far end of the merged region (away
    180     /// from the anchor).
    181     private mutating func mergeAdjacentInactiveSelections(displayOrder: [UUID]) {
    182         guard !inactiveSelections.isEmpty,
    183             let anchorID = anchorItemID,
    184             let anchorIndex = displayOrder.firstIndex(of: anchorID),
    185             let cursorID = cursorItemID,
    186             let cursorIndex = displayOrder.firstIndex(of: cursorID)
    187         else {
    188             return
    189         }
    190 
    191         var lo = min(anchorIndex, cursorIndex)
    192         var hi = max(anchorIndex, cursorIndex)
    193         var mergedIDs: Set<UUID> = []
    194         var changed = true
    195 
    196         while changed {
    197             changed = false
    198             for inactiveID in inactiveSelections where !mergedIDs.contains(inactiveID) {
    199                 guard let idx = displayOrder.firstIndex(of: inactiveID) else { continue }
    200                 if idx == lo - 1 || idx == hi + 1 || (idx >= lo && idx <= hi) {
    201                     if idx < lo { lo = idx }
    202                     if idx > hi { hi = idx }
    203                     mergedIDs.insert(inactiveID)
    204                     changed = true
    205                 }
    206             }
    207         }
    208 
    209         guard !mergedIDs.isEmpty else { return }
    210 
    211         inactiveSelections.subtract(mergedIDs)
    212 
    213         if cursorIndex <= anchorIndex {
    214             cursorItemID = displayOrder[lo]
    215         } else {
    216             cursorItemID = displayOrder[hi]
    217         }
    218 
    219         selectedItemIDs = inactiveSelections.union(Set(displayOrder[lo...hi]))
    220     }
    221 }
    222 
    223 let draftPrependRowID = UUID()
    224 let draftAppendRowID = UUID()