← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


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            // 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}