← All repositories

listless

A simple list app for Apple platforms

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


ListlessMac/Views/ItemListView.swift (21054B)

1import SwiftUI
2import UniformTypeIdentifiers
3
4struct ItemListView: View, ItemListViewProtocol {
5    struct InteractionStateData {
6        var dragState: DragState = .idle
7        var liftedItemID: UUID?
8        var draftPlacement: DraftItemPlacement?
9        var draftTitle: String = ""
10    }
11
12    @AppStorage("colorTheme") private var colorThemeRaw = 0
13    private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .pilbara }
14
15    @Environment(\.undoManager) var undoManager
16    @Environment(\.managedObjectContext) var managedObjectContext
17
18    let store: ItemStore
19    let windowCoordinator: WindowCoordinator
20    @ObservedObject var syncMonitor: CloudKitSyncMonitor
21    @FetchRequest(
22        sortDescriptors: [],
23        animation: .default
24    )
25    var items: FetchedResults<ItemEntity>
26    @FocusState private var focusedFieldBinding: FocusField?
27    @State var fState = FocusStateData()
28    @State private var iState = InteractionStateData()
29
30    var focusedField: FocusField? {
31        get { fState.focusedField }
32        nonmutating set {
33            fState.focusedField = newValue
34            focusedFieldBinding = newValue
35        }
36    }
37
38    var dragState: DragState {
39        get { iState.dragState }
40        nonmutating set { iState.dragState = newValue }
41    }
42
43    var draftPlacement: DraftItemPlacement? {
44        get { iState.draftPlacement }
45        nonmutating set { iState.draftPlacement = newValue }
46    }
47
48    var draftTitle: String {
49        get { iState.draftTitle }
50        nonmutating set { iState.draftTitle = newValue }
51    }
52
53    var vStackSpacing: CGFloat { 0 }
54    var isCompletelyEmpty: Bool { activeItems.isEmpty && completedItems.isEmpty }
55    var selectedIndex: Int? {
56        guard let currentID = fState.selectedItemID else { return nil }
57        return activeItems.firstIndex(where: { $0.id == currentID })
58    }
59
60    var canDeleteSelectionFromList: Bool {
61        !fState.selectedItemIDs.isEmpty && focusedField == .scrollView
62    }
63
64    var canMarkSelectionCompleted: Bool {
65        guard focusedField == .scrollView else { return false }
66        let selected = allItemsInDisplayOrder.filter { fState.isItemSelected($0.id) }
67        guard !selected.isEmpty else { return false }
68        let hasActive = selected.contains { !$0.isCompleted }
69        let hasCompleted = selected.contains { $0.isCompleted }
70        return !(hasActive && hasCompleted)
71    }
72
73    var markCompletedMenuTitle: String {
74        if fState.hasMultipleSelection {
75            let hasCompleted = completedItems.contains(where: { fState.isItemSelected($0.id) })
76            return hasCompleted ? "Mark as Incomplete" : "Mark as Complete"
77        }
78        return completedItems.contains(where: { $0.id == fState.selectedItemID })
79            ? "Mark as Incomplete" : "Mark as Complete"
80    }
81
82    var canMoveSelectionUp: Bool {
83        guard focusedField == .scrollView else { return false }
84        guard !fState.hasMultipleSelection else { return false }
85        guard let index = selectedIndex else { return false }
86        return index > 0
87    }
88
89    var canMoveSelectionDown: Bool {
90        guard focusedField == .scrollView else { return false }
91        guard !fState.hasMultipleSelection else { return false }
92        guard let index = selectedIndex else { return false }
93        return index < activeItems.count - 1
94    }
95
96    struct MenuState: Equatable {
97        let selectedItemIDs: Set<UUID>
98        let isScrollViewFocused: Bool
99        let activeItemCount: Int
100        let completedItemCount: Int
101        let selectedIndex: Int?
102    }
103
104    var windowCoordinatorTrigger: MenuState {
105        MenuState(
106            selectedItemIDs: fState.selectedItemIDs,
107            isScrollViewFocused: focusedField == .scrollView,
108            activeItemCount: activeItems.count,
109            completedItemCount: completedItems.count,
110            selectedIndex: selectedIndex
111        )
112    }
113
114    func updateWindowCoordinator() {
115        let coord = windowCoordinator
116        coord.newItem = { createNewItem() }
117        coord.copySelectedItem = {
118            guard let itemID = fState.selectedItemID,
119                  let item = allItemsInDisplayOrder.first(where: { $0.id == itemID }) else { return }
120            let pasteboard = NSPasteboard.general
121            pasteboard.clearContents()
122            pasteboard.setString(item.title, forType: .string)
123        }
124        coord.cutSelectedItem = {
125            guard let itemID = fState.selectedItemID,
126                  let item = allItemsInDisplayOrder.first(where: { $0.id == itemID }) else { return }
127            let pasteboard = NSPasteboard.general
128            pasteboard.clearContents()
129            pasteboard.setString(item.title, forType: .string)
130            deleteItem(itemID: itemID)
131        }
132        coord.pasteAfterSelectedItem = {
133            guard let itemID = fState.selectedItemID,
134                  let string = NSPasteboard.general.string(forType: .string) else { return }
135            createItem(title: string, afterItemID: itemID)
136        }
137        coord.deleteSelectedItem = { _ = deleteSelectedItem() }
138        coord.moveSelectedItemUp = { moveSelectedItemUp() }
139        coord.moveSelectedItemDown = { moveSelectedItemDown() }
140        coord.markSelectedItemCompleted = { markSelectedItemCompleted() }
141        coord.selectAllItems = {
142            fState.selectAll(displayOrder: allItemsInDisplayOrder.map(\.id))
143        }
144        coord.clearCompletedItems = { clearCompletedItems() }
145        let inNavMode = focusedField == .scrollView
146        let singleSelect = !fState.selectedItemIDs.isEmpty && !fState.hasMultipleSelection
147        coord.canSelectAllItems = inNavMode && !allItemsInDisplayOrder.isEmpty
148        coord.canCopySelectedItem = singleSelect && inNavMode
149        coord.canCutSelectedItem = singleSelect && inNavMode
150        coord.canPasteAfterSelectedItem = selectedIndex != nil && singleSelect && inNavMode
151        coord.canDeleteSelectedItem = canDeleteSelectionFromList
152        coord.canMoveSelectedItemUp = canMoveSelectionUp
153        coord.canMoveSelectedItemDown = canMoveSelectionDown
154        coord.canMarkSelectedItemCompleted = canMarkSelectionCompleted
155        coord.markCompletedTitle = markCompletedMenuTitle
156        coord.canClearCompletedItems = !completedItems.isEmpty
157    }
158
159    init(store: ItemStore, syncMonitor: CloudKitSyncMonitor, windowCoordinator: WindowCoordinator) {
160        self.store = store
161        self.syncMonitor = syncMonitor
162        self.windowCoordinator = windowCoordinator
163    }
164
165    func isRowLifted(_ itemID: UUID) -> Bool {
166        iState.liftedItemID == itemID || draggedItemID == itemID
167    }
168
169    func revealDraftItemUI(at placement: DraftItemPlacement, animated: Bool = false) {
170        let itemID = draftID(for: placement)
171        draftPlacement = placement
172        fState.pendingFocus = .item(itemID)
173        focusedField = .item(itemID)
174        fState.selectedItemID = itemID
175    }
176
177    func clearDraftItemUI(at placement: DraftItemPlacement, hasTitle _: Bool) {
178        if draftPlacement == placement {
179            draftPlacement = nil
180        }
181        draftTitle = ""
182        if fState.selectedItemID == draftID(for: placement) {
183            fState.selectedItemID = nil
184        }
185        // Resign AppKit first responder explicitly — SwiftUI's @FocusState
186        // and AppKit's responder chain are parallel systems, so setting
187        // focusedField alone may not dismiss the NSTextField.
188        NSApp.keyWindow?.makeFirstResponder(nil)
189        focusedField = nil
190    }
191
192    func didStartDrag() {}
193
194    var body: some View {
195        ScrollView {
196          ScrollViewReader { scrollProxy in
197            VStack(alignment: .leading, spacing: vStackSpacing) {
198                ForEach(Array(displayActiveItems.enumerated()), id: \.element.id) { index, item in
199                    let itemID = item.id
200                    ItemRowView(
201                        item: item,
202                        itemID: itemID,
203                        index: index,
204                        totalItems: displayActiveItems.count,
205                        isSelected: fState.isItemSelected(itemID),
206                        focusedField: $focusedFieldBinding,
207                        onToggle: { handleSwipeComplete($0) },
208                        onTitleChange: { updateTitle(itemID: $0, title: $1) },
209                        onDelete: { deleteItem(itemID: $0) },
210                        onSelect: {
211                            let modifiers = NSApp.currentEvent?.modifierFlags ?? []
212                            selectItem(
213                                $0,
214                                extendSelection: modifiers.contains(.shift),
215                                toggleSelection: modifiers.contains(.command)
216                            )
217                        },
218                        onStartEdit: { startEditing($0) },
219                        onEndEdit: { endEditing($0, shouldCreateNewItem: $1) },
220                        onPaste: { createItem(title: $0, afterItemID: itemID) }
221                    )
222                    .itemDragGesture(
223                        isActive: !item.isCompleted,
224                        itemID: itemID,
225                        onDragStart: {
226                            iState.liftedItemID = nil
227                            startDrag(itemID: itemID)
228                        },
229                        onLift: { iState.liftedItemID = itemID },
230                        onLiftEnd: {
231                            if iState.liftedItemID == itemID { iState.liftedItemID = nil }
232                            if draggedItemID == itemID { clearDragState() }
233                        }
234                    )
235                    .scaleEffect(isRowLifted(itemID) ? 1.03 : 1.0)
236                    .shadow(
237                        color: isRowLifted(itemID) ? .black.opacity(0.2) : .clear,
238                        radius: 8, y: 3
239                    )
240                    .zIndex(isRowLifted(itemID) ? 1 : 0)
241                    .animation(.spring(response: 0.3, dampingFraction: 0.8), value: isRowLifted(itemID))
242                    .overlay {
243                        if draggedItemID != nil && draggedItemID != itemID {
244                            VStack(spacing: 0) {
245                                // Top 1/6 - insert BEFORE
246                                Color.clear
247                                    .frame(maxHeight: .infinity)
248                                    .layoutPriority(1)
249                                    .onDrop(
250                                        of: [UTType.text],
251                                        delegate: ItemReorderDropDelegate(
252                                            onTargeted: { updateVisualOrder(insertBefore: itemID) },
253                                            onPerform: { commitCurrentDrag() }
254                                        )
255                                    )
256
257                                // Middle 2/3 - insert based on direction
258                                Color.clear
259                                    .frame(maxHeight: .infinity)
260                                    .layoutPriority(4)
261                                    .onDrop(
262                                        of: [UTType.text],
263                                        delegate: ItemReorderDropDelegate(
264                                            onTargeted: { updateVisualOrderSmart(relativeTo: itemID) },
265                                            onPerform: { commitCurrentDrag() }
266                                        )
267                                    )
268
269                                // Bottom 1/6 - insert AFTER
270                                Color.clear
271                                    .frame(maxHeight: .infinity)
272                                    .layoutPriority(1)
273                                    .onDrop(
274                                        of: [UTType.text],
275                                        delegate: ItemReorderDropDelegate(
276                                            onTargeted: { updateVisualOrder(insertAfter: itemID) },
277                                            onPerform: { commitCurrentDrag() }
278                                        )
279                                    )
280                            }
281                        }
282                    }
283                }
284
285                if draftPlacement == .append {
286                    let total = max(1, displayActiveItems.count + 1)
287                    let index = displayActiveItems.count
288                    let accentColor = cachedItemColor(
289                        forIndex: index, total: total, theme: colorTheme
290                    )
291                    let isSelected = fState.isItemSelected(draftAppendRowID)
292                    HStack(alignment: .firstTextBaseline, spacing: 12) {
293                        Image(systemName: "circle")
294                            .foregroundStyle(.primary)
295                            .font(.system(size: 17))
296                            .fontWeight(.thin)
297                            .alignmentGuide(.firstTextBaseline) { d in
298                                d[VerticalAlignment.center] + 5
299                            }
300
301                        ClickableTextField(
302                            text: Binding(
303                                get: { iState.draftTitle },
304                                set: { iState.draftTitle = $0 }
305                            ),
306                            isCompleted: false,
307                            onEditingChanged: { editing, shouldCreateNewItem in
308                                if editing {
309                                    beginDraftItemEditing(.append)
310                                } else {
311                                    commitDraftItem(
312                                        shouldCreateNewItem: shouldCreateNewItem
313                                    )
314                                }
315                            },
316                            itemID: draftAppendRowID
317                        )
318                        .focused(
319                            $focusedFieldBinding,
320                            equals: .item(draftAppendRowID)
321                        )
322                        .frame(maxWidth: .infinity, alignment: .leading)
323                    }
324                    .padding(.top, 4)
325                    .padding(.vertical, 8)
326                    .padding(.horizontal, 16)
327                    .frame(maxWidth: .infinity, alignment: .leading)
328                    .contentShape(Rectangle())
329                    .background(
330                        isSelected
331                            ? RoundedRectangle(cornerRadius: 6, style: .continuous)
332                                .fill(Color(nsColor: .controlBackgroundColor))
333                            : nil
334                    )
335                    .overlay(alignment: .leading) {
336                        Rectangle()
337                            .fill(accentColor)
338                            .frame(width: 4)
339                            .padding(.vertical, 1)
340                    }
341                    .overlay {
342                        if isSelected {
343                            RoundedRectangle(cornerRadius: 6, style: .continuous)
344                                .strokeBorder(accentColor.opacity(0.40), lineWidth: 2)
345                        }
346                    }
347                    .accessibilityIdentifier("draft-row-append")
348                    .id(draftAppendRowID)
349                }
350
351                ForEach(completedItems) { item in
352                    let itemID = item.id
353                    ItemRowView(
354                        item: item,
355                        itemID: itemID,
356                        isSelected: fState.isItemSelected(itemID),
357                        focusedField: $focusedFieldBinding,
358                        onToggle: { handleSwipeComplete($0) },
359                        onTitleChange: { updateTitle(itemID: $0, title: $1) },
360                        onDelete: { deleteItem(itemID: $0) },
361                        onSelect: {
362                            selectItem(
363                                $0,
364                                extendSelection: NSEvent.modifierFlags.contains(.shift)
365                            )
366                        }
367                    )
368                }
369            }
370            .frame(maxWidth: .infinity, alignment: .topLeading)
371            .onDrop(
372                of: [UTType.text],
373                delegate: ItemReorderDropDelegate(
374                    onTargeted: {},
375                    onPerform: { commitCurrentDrag() }
376                )
377            )
378            .onChange(of: focusedFieldBinding) { _, newValue in
379                if case .item(let id) = (newValue ?? fState.focusedField),
380                    draggedItemID == nil,
381                    id != draftPrependRowID
382                {
383                    withAnimation {
384                        scrollProxy.scrollTo(id)
385                    }
386                }
387            }
388            .onChange(of: fState.selectedItemID) { _, newID in
389                if let newID, draggedItemID == nil {
390                    guard newID != draftPrependRowID else { return }
391                    withAnimation {
392                        scrollProxy.scrollTo(newID)
393                    }
394                }
395            }
396          }
397        }
398        .onDrop(
399            of: [UTType.text],
400            delegate: ItemReorderDropDelegate(
401                onTargeted: {},
402                onPerform: { commitCurrentDrag() }
403            )
404        )
405        .background {
406            BackgroundClickMonitor {
407                handleBackgroundTap()
408            }
409        }
410        .background(Color.outerBackground)
411        .overlay {
412            if isCompletelyEmpty && draftPlacement == nil {
413                Text("Click to create")
414                    .font(.subheadline)
415                    .foregroundStyle(.secondary)
416                    .allowsHitTesting(false)
417                    .accessibilityIdentifier("empty-state-label")
418            }
419        }
420        .focusable()
421        .focused($focusedFieldBinding, equals: .scrollView)
422        .focusEffectDisabled()
423        .accessibilityIdentifier("item-list-scrollview")
424        .keyboardNavigation([
425            ShortcutKey(key: .upArrow): navigateUp,
426            ShortcutKey(key: .downArrow): navigateDown,
427            ShortcutKey(key: .upArrow, modifiers: .shift): navigateUpExtend,
428            ShortcutKey(key: .downArrow, modifiers: .shift): navigateDownExtend,
429            ShortcutKey(key: .home): navigateToFirst,
430            ShortcutKey(key: .end): navigateToLast,
431            ShortcutKey(key: .pageUp): navigatePageUp,
432            ShortcutKey(key: .pageDown): navigatePageDown,
433            ShortcutKey(key: .return): focusSelectedItem,
434        ])
435        .onAppear {
436            if focusedFieldBinding == nil {
437                focusedFieldBinding = .scrollView
438            }
439            fState.focusedField = focusedFieldBinding
440            updateWindowCoordinator()
441        }
442        .onChange(of: focusedFieldBinding) { oldValue, newValue in
443            // Clear the per-window focus gate once we've landed on
444            // a non-nil value (reconciliation is done). Keep it set
445            // while nil so the redirect below doesn't open a window
446            // for AppKit's key-view loop.
447            if newValue != nil {
448                windowCoordinator.allowedFocusTarget = nil
449            }
450            fState.focusedField = newValue
451            handleFocusChange(from: oldValue, to: newValue)
452
453            if let pending = fState.pendingFocus, newValue != pending {
454                // Focus landed somewhere other than the intended
455                // target (or went nil). Set the allowed target so
456                // the text field can claim focus in
457                // viewDidMoveToWindow if it's not yet in the
458                // hierarchy, and redirect immediately in case it is.
459                windowCoordinator.allowedFocusTarget = pending
460                fState.pendingFocus = nil
461                focusedField = pending
462            } else if newValue == nil {
463                focusedField = .scrollView
464            }
465
466            updateWindowCoordinator()
467        }
468        .onChange(of: windowCoordinatorTrigger) { _, _ in updateWindowCoordinator() }
469        .onChange(of: undoManager, initial: true) { _, newValue in
470            managedObjectContext.undoManager = newValue
471        }
472        .toolbar {
473            platformToolbar
474        }
475        .safeAreaInset(edge: .bottom) {
476            syncErrorBanner
477        }
478    }
479}
480
481private struct ItemReorderDropDelegate: DropDelegate {
482    let onTargeted: () -> Void
483    let onPerform: () -> Bool
484
485    func validateDrop(info: DropInfo) -> Bool {
486        true
487    }
488
489    func dropEntered(info: DropInfo) {
490        onTargeted()
491    }
492
493    func dropUpdated(info: DropInfo) -> DropProposal? {
494        return DropProposal(operation: .move)
495    }
496
497    func performDrop(info: DropInfo) -> Bool {
498        onPerform()
499    }
500}