listless

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

ItemRowView.swift (7163B)


      1 import SwiftUI
      2 
      3 struct ItemRowView: View {
      4     let item: ItemValue
      5     let itemID: UUID
      6     let index: Int
      7     let totalItems: Int
      8     let isSelected: Bool
      9     let onToggle: (UUID) -> Void
     10     let onTitleChange: (UUID, String) -> Void
     11     let onDelete: (UUID) -> Void
     12     let onSelect: (UUID) -> Void
     13     let onStartEdit: (UUID) -> Void
     14     let onEndEdit: (UUID, _ shouldCreateNewItem: Bool) -> Void
     15     let onPaste: (String) -> Void
     16     @FocusState.Binding var focusedField: FocusField?
     17 
     18     @AppStorage("colorTheme") private var colorThemeRaw = 0
     19     private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .pilbara }
     20 
     21     @State private var editingTitle: String = ""
     22     @State private var isCurrentlyEditing: Bool = false
     23     @State private var cachedAccentColor: Color = .clear
     24 
     25     private let horizontalPadding: CGFloat = 16
     26     private let checkboxTextSpacing: CGFloat = 12
     27     @ScaledMetric private var checkboxSize: CGFloat = 20
     28 
     29     private var dividerInset: CGFloat {
     30         horizontalPadding + checkboxSize + checkboxTextSpacing
     31     }
     32 
     33     @MainActor
     34     private func computeAccentColor() -> Color {
     35         guard !item.isCompleted else { return .clear }
     36         return cachedItemColor(forIndex: index, total: totalItems, theme: colorTheme)
     37     }
     38 
     39     init(
     40         item: ItemValue,
     41         itemID: UUID,
     42         index: Int = 0,
     43         totalItems: Int = 1,
     44         isSelected: Bool,
     45         focusedField: FocusState<FocusField?>.Binding,
     46         onToggle: @escaping (UUID) -> Void,
     47         onTitleChange: @escaping (UUID, String) -> Void,
     48         onDelete: @escaping (UUID) -> Void,
     49         onSelect: @escaping (UUID) -> Void,
     50         onStartEdit: @escaping (UUID) -> Void = { _ in },
     51         onEndEdit: @escaping (UUID, _ shouldCreateNewItem: Bool) -> Void = { _, _ in },
     52         onPaste: @escaping (String) -> Void = { _ in }
     53     ) {
     54         self.item = item
     55         self.itemID = itemID
     56         self.index = index
     57         self.totalItems = totalItems
     58         self.isSelected = isSelected
     59         self.onToggle = onToggle
     60         self.onTitleChange = onTitleChange
     61         self.onDelete = onDelete
     62         self.onSelect = onSelect
     63         self.onStartEdit = onStartEdit
     64         self.onEndEdit = onEndEdit
     65         self.onPaste = onPaste
     66         _focusedField = focusedField
     67     }
     68 
     69     var body: some View {
     70         HStack(alignment: .firstTextBaseline, spacing: 12) {
     71             Button {
     72                 onToggle(itemID)
     73             } label: {
     74                 Image(systemName: item.isCompleted ? "checkmark.circle.fill" : "circle")
     75                     .foregroundStyle(item.isCompleted ? .secondary : .primary)
     76                     .font(.system(size: 17))
     77                     .fontWeight(.thin)
     78             }
     79             .buttonStyle(.borderless)
     80             .alignmentGuide(.firstTextBaseline) { d in
     81                 d[VerticalAlignment.center] + 5
     82             }
     83             .accessibilityIdentifier("item-checkbox")
     84             .accessibilityValue(item.isCompleted ? "checkmark.circle.fill" : "circle")
     85 
     86             ClickableTextField(
     87                 text: $editingTitle,
     88                 isCompleted: item.isCompleted,
     89                 onEditingChanged: { editing, shouldCreateNewItem in
     90                     isCurrentlyEditing = editing
     91                     if editing {
     92                         onStartEdit(itemID)
     93                     } else {
     94                         onEndEdit(itemID, shouldCreateNewItem)
     95                     }
     96                 },
     97                 itemID: itemID,
     98                 onContentChange: { newTitle in
     99                     guard !item.isCompleted else { return }
    100                     onTitleChange(itemID, newTitle)
    101                 }
    102             )
    103             .focused($focusedField, equals: .item(itemID))
    104             .frame(maxWidth: .infinity, alignment: .leading)
    105             .accessibilityIdentifier(
    106                 isCurrentlyEditing ? "item-textfield" : "item-text-\(itemID.uuidString)")
    107         }
    108         .padding(.top, 4)
    109         .padding(.vertical, 8)
    110         .padding(.horizontal, 16)
    111         .frame(maxWidth: .infinity, alignment: .leading)
    112         .contentShape(Rectangle())
    113         .onTapGesture {
    114             onSelect(itemID)
    115         }
    116         .background(selectionBackground)
    117         .overlay(alignment: .leading) {
    118             // Colored accent bar on the left edge
    119             Rectangle()
    120                 .fill(cachedAccentColor)
    121                 .frame(width: 4)
    122                 .padding(.vertical, 1)
    123         }
    124         .overlay(alignment: .bottom) {
    125             // Hairline border between rows, inset to align with text
    126             // Only show for active (non-completed) items
    127             if !item.isCompleted {
    128                 Rectangle()
    129                     .fill(.separator)
    130                     .frame(height: 0.5)
    131                     .padding(.leading, dividerInset)
    132             }
    133         }
    134         .overlay {
    135             if isSelected && !item.isCompleted {
    136                 RoundedRectangle(cornerRadius: 6, style: .continuous)
    137                     .strokeBorder(cachedAccentColor.opacity(0.40), lineWidth: 2)
    138             }
    139         }
    140         .contextMenu {
    141             Button(item.isCompleted ? "Mark as Incomplete" : "Mark as Complete") {
    142                 onToggle(itemID)
    143             }
    144             Divider()
    145             Button("Cut") {
    146                 cutToPasteboard()
    147             }
    148             Button("Copy") {
    149                 copyToPasteboard()
    150             }
    151             Button("Paste") {
    152                 pasteFromPasteboard()
    153             }
    154             .disabled(item.isCompleted)
    155             Divider()
    156             Button("Delete", role: .destructive) {
    157                 onDelete(itemID)
    158             }
    159         }
    160         .onChange(of: item.title) { _, newValue in
    161             if !isCurrentlyEditing {
    162                 editingTitle = newValue
    163             }
    164         }
    165         .onChange(of: colorThemeRaw) { _, _ in
    166             cachedAccentColor = computeAccentColor()
    167         }
    168         .onChange(of: index) { _, _ in
    169             cachedAccentColor = computeAccentColor()
    170         }
    171         .onChange(of: totalItems) { _, _ in
    172             cachedAccentColor = computeAccentColor()
    173         }
    174         .onAppear {
    175             editingTitle = item.title
    176             cachedAccentColor = computeAccentColor()
    177         }
    178     }
    179 
    180     @ViewBuilder
    181     private var selectionBackground: some View {
    182         if isSelected {
    183             if item.isCompleted {
    184                 Color(nsColor: .controlBackgroundColor)
    185             } else {
    186                 RoundedRectangle(cornerRadius: 6, style: .continuous)
    187                     .fill(Color(nsColor: .controlBackgroundColor))
    188             }
    189         }
    190     }
    191 
    192     private func cutToPasteboard() {
    193         copyToPasteboard()
    194         onDelete(itemID)
    195     }
    196 
    197     private func copyToPasteboard() {
    198         guard !item.title.isEmpty else { return }
    199         let pasteboard = NSPasteboard.general
    200         pasteboard.clearContents()
    201         pasteboard.setString(item.title, forType: .string)
    202     }
    203 
    204     private func pasteFromPasteboard() {
    205         guard let string = NSPasteboard.general.string(forType: .string) else { return }
    206         onPaste(string)
    207     }
    208 }