listless

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

ItemListView.swift (21054B)


      1 import SwiftUI
      2 import UniformTypeIdentifiers
      3 
      4 struct 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 
    481 private 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 }