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
186
187
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
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
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
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
444
445
446
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
455
456
457
458
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}