ListlessMac/Views/ItemRowView.swift (7163B)
1import SwiftUI
2
3struct 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
119 Rectangle()
120 .fill(cachedAccentColor)
121 .frame(width: 4)
122 .padding(.vertical, 1)
123 }
124 .overlay(alignment: .bottom) {
125
126
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}