← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


Listless/Helpers/ItemListTypes.swift (8000B)

1import Foundation
2
3enum FocusField: Hashable {
4    case item(UUID)
5    case scrollView
6}
7
8let pageNavigationSize = 10
9
10enum DragState: Equatable {
11    case idle
12    case dragging(id: UUID, order: [UUID])
13}
14
15enum DraftItemPlacement: Equatable {
16    case prepend
17    case append
18}
19
20struct 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
223let draftPrependRowID = UUID()
224let draftAppendRowID = UUID()