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 }