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()