listless

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

commit b6947ab9c1df71ddd081901fa2f227f32cbd615a
parent 81a1c8a1d2d850c85d8d55406f7dde29d9a43489
Author: Michael Camilleri <[email protected]>
Date:   Fri, 13 Mar 2026 00:28:00 +0900

Use draft task abstraction for new rows

Previous to this commit, the project used a 'phantom row' abstraction in
certain places where a new row was added but not in others. This commit
replaces the inconsistent approach with a consistent 'draft row'
abstraction. This same abstraction is used across iOS and macOS
versions.

Co-Authored-By: Claude 4.6 Opus <[email protected]>
Co-Authored-By: Codex GPT 4.6 <[email protected]>

Diffstat:
MListless/Extensions/TaskListView+Logic.swift | 120++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
MListless/Helpers/TaskListTypes.swift | 8++++++++
MListless/Helpers/TaskListViewProtocol.swift | 3+++
MListlessMac/Helpers/ClickableTextField.swift | 4++++
MListlessMac/Views/TaskListView.swift | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MListlessiOS/Extensions/TaskListView+PullToCreate.swift | 50+++-----------------------------------------------
MListlessiOS/Helpers/TappableTextField.swift | 7+++++++
MListlessiOS/Views/TaskListView.swift | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
8 files changed, 351 insertions(+), 95 deletions(-)

diff --git a/Listless/Extensions/TaskListView+Logic.swift b/Listless/Extensions/TaskListView+Logic.swift @@ -58,38 +58,98 @@ extension TaskListViewProtocol { return lastTask.id == taskID } + func draftTaskID(for placement: DraftTaskPlacement) -> UUID { + switch placement { + case .prepend: + draftPrependRowID + case .append: + draftAppendRowID + } + } + + func draftTaskPlacement(for taskID: UUID) -> DraftTaskPlacement? { + switch taskID { + case draftPrependRowID: + .prepend + case draftAppendRowID: + .append + default: + nil + } + } + // MARK: - Task Creation func createNewTaskAtTop() -> UUID { - clearDragState() - do { - managedObjectContext.undoManager?.disableUndoRegistration() - let task = try store.createTask(title: "", atBeginning: true) - managedObjectContext.undoManager?.enableUndoRegistration() - pendingFocus = .task(task.id) - focusedField = .task(task.id) - selectedTaskID = task.id - return task.id - } catch { - managedObjectContext.undoManager?.enableUndoRegistration() - presentStoreError(error) - return UUID() - } + revealDraftTask(at: .prepend) + return draftPrependRowID } func createNewTask() { + revealDraftTask(at: .append) + } + + func revealDraftTask(at placement: DraftTaskPlacement) { + if draftTaskPlacement != placement, draftTaskPlacement != nil { + commitDraftTask() + } + clearDragState() + let taskID = draftTaskID(for: placement) + draftTaskTitle = "" + draftTaskPlacement = placement + pendingFocus = .task(taskID) + focusedField = .task(taskID) + selectedTaskID = taskID + } + + func beginDraftTaskEditing(_ placement: DraftTaskPlacement) { + guard draftTaskPlacement == placement else { return } + let taskID = draftTaskID(for: placement) + selectedTaskID = taskID + if case .task(let id) = pendingFocus, id == taskID { + pendingFocus = nil + } + } + + func commitDraftTask(shouldCreateNewTask: Bool = false) { + guard let placement = draftTaskPlacement else { return } + let taskID = draftTaskID(for: placement) + let title = draftTaskTitle.trimmingCharacters(in: .whitespacesAndNewlines) + + // Clear pendingFocus before clearDraftTaskUI so that the iOS + // onChange(of: focusedFieldBinding) nil-redirect doesn't re-focus + // the draft row via a stale pendingFocus value. + if case .task(let id) = pendingFocus, id == taskID { + pendingFocus = nil + } + + clearDraftTaskUI(at: placement, hasTitle: !title.isEmpty) + + if selectedTaskID == taskID { + selectedTaskID = nil + } + + guard !title.isEmpty else { return } + do { - managedObjectContext.undoManager?.disableUndoRegistration() - let task = try store.createTask(title: "") - managedObjectContext.undoManager?.enableUndoRegistration() - pendingFocus = .task(task.id) - focusedField = .task(task.id) - selectedTaskID = task.id + let task = switch placement { + case .prepend: + try store.createTask(title: title, atBeginning: true) + case .append: + try store.createTask(title: title) + } + try store.save() + if placement == .append { + selectedTaskID = task.id + } } catch { - managedObjectContext.undoManager?.enableUndoRegistration() presentStoreError(error) } + + if shouldCreateNewTask, placement == .append { + revealDraftTask(at: .append) + } } func createTask(title: String, afterTaskID: UUID) { @@ -132,10 +192,14 @@ extension TaskListViewProtocol { let isTaskFocused = if case .task = focusedField { true } else { false } if isTaskFocused || selectedTaskID != nil { + pendingFocus = nil + if draftTaskPlacement != nil { + commitDraftTask() + } selectedTaskID = nil focusedField = nil } else { - createNewTask() + revealDraftTask(at: .append) } } @@ -146,6 +210,11 @@ extension TaskListViewProtocol { guard oldID != newID, let oldID else { return } + + if draftTaskPlacement(for: oldID) != nil { + return + } + deleteIfEmpty(taskID: oldID) } @@ -353,6 +422,11 @@ extension TaskListViewProtocol { } func endEditing(_ taskID: UUID, shouldCreateNewTask: Bool) { + if draftTaskPlacement(for: taskID) != nil { + commitDraftTask(shouldCreateNewTask: shouldCreateNewTask) + return + } + do { try store.save() } catch { @@ -366,7 +440,7 @@ extension TaskListViewProtocol { selectedTaskID = nil deleteIfEmpty(taskID: taskID) } else if wasLastActiveTask && shouldCreateNewTask { - createNewTask() + revealDraftTask(at: .append) } else if shouldCreateNewTask { focusedField = .scrollView } diff --git a/Listless/Helpers/TaskListTypes.swift b/Listless/Helpers/TaskListTypes.swift @@ -9,3 +9,11 @@ enum DragState: Equatable { case idle case dragging(id: UUID, order: [UUID]) } + +enum DraftTaskPlacement: Equatable { + case prepend + case append +} + +let draftPrependRowID = UUID() +let draftAppendRowID = UUID() diff --git a/Listless/Helpers/TaskListViewProtocol.swift b/Listless/Helpers/TaskListViewProtocol.swift @@ -11,5 +11,8 @@ protocol TaskListViewProtocol { var selectedTaskID: UUID? { get nonmutating set } var pendingFocus: FocusField? { get nonmutating set } var dragState: DragState { get nonmutating set } + var draftTaskPlacement: DraftTaskPlacement? { get nonmutating set } + var draftTaskTitle: String { get nonmutating set } func didStartDrag() + func clearDraftTaskUI(at placement: DraftTaskPlacement, hasTitle: Bool) } diff --git a/ListlessMac/Helpers/ClickableTextField.swift b/ListlessMac/Helpers/ClickableTextField.swift @@ -60,6 +60,10 @@ struct ClickableTextField: NSViewRepresentable { // Apply styling (sets attributedStringValue) context.coordinator.applyStyle(to: textField, text: text, isCompleted: isCompleted) + } else if text.isEmpty && !textField.stringValue.isEmpty { + // External reset (e.g. phantom row chaining) — clear the field + // even though the field editor is active. + textField.stringValue = "" } // Disable if completed diff --git a/ListlessMac/Views/TaskListView.swift b/ListlessMac/Views/TaskListView.swift @@ -11,6 +11,8 @@ struct TaskListView: View, TaskListViewProtocol { struct InteractionStateData { var dragState: DragState = .idle var liftedTaskID: UUID? + var draftTaskPlacement: DraftTaskPlacement? + var draftTaskTitle: String = "" } struct TaskStateData { @@ -61,6 +63,16 @@ struct TaskListView: View, TaskListViewProtocol { nonmutating set { iState.liftedTaskID = newValue } } + var draftTaskPlacement: DraftTaskPlacement? { + get { iState.draftTaskPlacement } + nonmutating set { iState.draftTaskPlacement = newValue } + } + + var draftTaskTitle: String { + get { iState.draftTaskTitle } + nonmutating set { iState.draftTaskTitle = newValue } + } + var refreshID: UUID { get { tState.refreshID } nonmutating set { tState.refreshID = newValue } @@ -168,6 +180,17 @@ struct TaskListView: View, TaskListViewProtocol { liftedTaskID == taskID || draggedTaskID == taskID } + func clearDraftTaskUI(at placement: DraftTaskPlacement, hasTitle _: Bool) { + if draftTaskPlacement == placement { + draftTaskPlacement = nil + } + draftTaskTitle = "" + if selectedTaskID == draftTaskID(for: placement) { + selectedTaskID = nil + } + focusedField = nil + } + func didStartDrag() {} var body: some View { @@ -250,6 +273,69 @@ struct TaskListView: View, TaskListViewProtocol { } } + if draftTaskPlacement == .append { + let total = max(1, displayActiveTasks.count + 1) + let index = displayActiveTasks.count + let accentColor = cachedTaskColor( + forIndex: index, total: total + ) + let isSelected = selectedTaskID == draftAppendRowID + HStack(alignment: .firstTextBaseline, spacing: 12) { + Image(systemName: "circle") + .foregroundStyle(.primary) + .font(.system(size: 17)) + .fontWeight(.thin) + .alignmentGuide(.firstTextBaseline) { d in + d[VerticalAlignment.center] + 5 + } + + ClickableTextField( + text: Binding( + get: { iState.draftTaskTitle }, + set: { iState.draftTaskTitle = $0 } + ), + isCompleted: false, + onEditingChanged: { editing, shouldCreateNewTask in + if editing { + beginDraftTaskEditing(.append) + } else { + commitDraftTask( + shouldCreateNewTask: shouldCreateNewTask + ) + } + } + ) + .focused( + $focusedFieldBinding, + equals: .task(draftAppendRowID) + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.top, 4) + .padding(.vertical, 8) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .background( + isSelected + ? RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + : nil + ) + .overlay(alignment: .leading) { + Rectangle() + .fill(accentColor) + .frame(width: 4) + .padding(.vertical, 1) + } + .overlay { + if isSelected { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(accentColor.opacity(0.40), lineWidth: 2) + } + } + } + ForEach(completedTasks) { task in let taskID = task.id TaskRowView( diff --git a/ListlessiOS/Extensions/TaskListView+PullToCreate.swift b/ListlessiOS/Extensions/TaskListView+PullToCreate.swift @@ -2,57 +2,13 @@ import SwiftUI extension TaskListView { - // MARK: - Phantom Entry Row Helpers + // MARK: - Pull-to-Create Draft Helpers - /// Show the phantom row and focus its text field. func revealPhantomRow() -> UUID { - phantomTitle = "" - phantomRowVisible = true - pendingFocus = .task(Self.phantomRowID) - focusedField = .task(Self.phantomRowID) - selectedTaskID = Self.phantomRowID - return Self.phantomRowID + return createNewTaskAtTop() } - /// Commit the phantom row: create a real task if the user typed something, - /// then hide the phantom and reset its state. func commitPhantomRow() { - let title = phantomTitle.trimmingCharacters(in: .whitespacesAndNewlines) - - let collapse: () -> Void = { - phantomRowVisible = false - phantomTitle = "" - selectedTaskID = nil - var state = pullToCreate - state.isInsertionPending = false - state.indicatorOffset = 0 - pullToCreate = state - } - - if title.isEmpty { - withAnimation(.spring(response: 0.24, dampingFraction: 0.95)) { - collapse() - } - } else { - // Keep the successful create swap immediate so focus can move - // straight into the real task row without a visible intermediate state. - var t = Transaction(animation: nil) - t.disablesAnimations = true - withTransaction(t) { - collapse() - } - } - - focusedField = nil - - guard !title.isEmpty else { return } - - do { - let task = try store.createTask(title: title, atBeginning: true) - try store.save() - selectedTaskID = task.id - } catch { - presentStoreError(error) - } + commitDraftTask() } } diff --git a/ListlessiOS/Helpers/TappableTextField.swift b/ListlessiOS/Helpers/TappableTextField.swift @@ -42,6 +42,13 @@ struct TappableTextField: UIViewRepresentable { func updateUIView(_ textView: UITextView, context: Context) { if !textView.isFirstResponder { applyStyle(to: textView, text: text, isCompleted: isCompleted) + } else if text.isEmpty && !textView.text.isEmpty { + // External reset (e.g. phantom row chaining) — clear the view + // even though it's first responder. + textView.text = "" + if let placeholder = textView.viewWithTag(100) as? UILabel { + placeholder.isHidden = false + } } if textView.returnKeyType != returnKeyType { textView.returnKeyType = returnKeyType diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift @@ -18,12 +18,10 @@ struct TaskListView: View, TaskListViewProtocol { var clearingTaskIDs: Set<UUID> = [] var rowFrames: [UUID: CGRect] = [:] var undoToast: UndoToastData? = nil - var phantomRowVisible: Bool = false - var phantomTitle: String = "" + var draftTaskPlacement: DraftTaskPlacement? + var draftTaskTitle: String = "" } - static let phantomRowID = UUID() - struct TaskStateData { var refreshID = UUID() } @@ -97,20 +95,28 @@ struct TaskListView: View, TaskListViewProtocol { nonmutating set { iState.undoToast = newValue } } - var phantomRowVisible: Bool { - get { iState.phantomRowVisible } - nonmutating set { iState.phantomRowVisible = newValue } + var draftTaskPlacement: DraftTaskPlacement? { + get { iState.draftTaskPlacement } + nonmutating set { iState.draftTaskPlacement = newValue } + } + + var draftTaskTitle: String { + get { iState.draftTaskTitle } + nonmutating set { iState.draftTaskTitle = newValue } + } + + private var isPrependDraftVisible: Bool { + draftTaskPlacement == .prepend } - var phantomTitle: String { - get { iState.phantomTitle } - nonmutating set { iState.phantomTitle = newValue } + private var isAppendDraftVisible: Bool { + draftTaskPlacement == .append } - var phantomTitleBinding: Binding<String> { + var draftTaskTitleBinding: Binding<String> { Binding( - get: { iState.phantomTitle }, - set: { iState.phantomTitle = $0 } + get: { iState.draftTaskTitle }, + set: { iState.draftTaskTitle = $0 } ) } @@ -203,6 +209,43 @@ struct TaskListView: View, TaskListViewProtocol { self.syncMonitor = syncMonitor } + func clearDraftTaskUI(at placement: DraftTaskPlacement, hasTitle: Bool) { + let clear: () -> Void = { + if draftTaskPlacement == placement { + draftTaskPlacement = nil + } + draftTaskTitle = "" + if selectedTaskID == draftTaskID(for: placement) { + selectedTaskID = nil + } + + guard placement == .prepend else { return } + + var state = pullToCreate + state.isInsertionPending = false + state.indicatorOffset = 0 + pullToCreate = state + } + + if placement == .prepend, !hasTitle { + withAnimation(.spring(response: 0.24, dampingFraction: 0.95)) { + clear() + } + } else if placement == .prepend { + var transaction = Transaction(animation: nil) + transaction.disablesAnimations = true + withTransaction(transaction) { + clear() + } + } else { + clear() + } + + if placement == .prepend || !hasTitle { + focusedField = nil + } + } + func didStartDrag() { isDragging = true let generator = UIImpactFeedbackGenerator(style: .medium) @@ -231,7 +274,7 @@ struct TaskListView: View, TaskListViewProtocol { } private var pullToCreateGap: CGFloat { - guard pullToCreate.shouldShowIndicator, !iState.phantomRowVisible else { return 0 } + guard pullToCreate.shouldShowIndicator, !isPrependDraftVisible else { return 0 } let exposedPull = pullToCreate.indicatorDisplayOffset(threshold: pullCreateThreshold) return min( vStackSpacing, @@ -240,7 +283,7 @@ struct TaskListView: View, TaskListViewProtocol { } private var pullToCreateRowOverlap: CGFloat { - guard pullToCreate.shouldShowIndicator, !iState.phantomRowVisible else { + guard pullToCreate.shouldShowIndicator, !isPrependDraftVisible else { return 0 } return PullToCreateIndicator.indicatorHeight - pullToCreateRevealHeight @@ -251,7 +294,7 @@ struct TaskListView: View, TaskListViewProtocol { /// (during the pull), so it's ready when the user releases. @ViewBuilder var pullToCreateIndicatorRow: some View { let showIndicator = pullToCreate.shouldShowIndicator - let showPhantom = iState.phantomRowVisible + let showPhantom = isPrependDraftVisible if showIndicator || showPhantom { ZStack(alignment: .topLeading) { PullToCreateIndicator( @@ -283,7 +326,7 @@ struct TaskListView: View, TaskListViewProtocol { let accentColor = taskColor( forIndex: 0, total: max(1, displayActiveTasks.count + 1) ) - let isSelected = selectedTaskID == Self.phantomRowID + let isSelected = selectedTaskID == draftPrependRowID HStack(alignment: .center, spacing: TaskRowMetrics.contentSpacing) { Image(systemName: "circle") .frame(width: 22, height: 22) @@ -291,19 +334,21 @@ struct TaskListView: View, TaskListViewProtocol { .font(.system(size: 17)) TappableTextField( - text: phantomTitleBinding, + text: draftTaskTitleBinding, isCompleted: false, isDragging: false, onEditingChanged: { editing, _ in DispatchQueue.main.async { - if !editing { - commitPhantomRow() + if editing { + beginDraftTaskEditing(.prepend) + } else { + commitDraftTask() } } }, returnKeyType: .done ) - .focused($focusedFieldBinding, equals: .task(Self.phantomRowID)) + .focused($focusedFieldBinding, equals: .task(draftPrependRowID)) .frame(maxWidth: .infinity, alignment: .leading) } .padding(.vertical, TaskRowMetrics.contentVerticalPadding) @@ -338,6 +383,74 @@ struct TaskListView: View, TaskListViewProtocol { ) } + @ViewBuilder private var phantomAppendRowContent: some View { + if isAppendDraftVisible { + let total = max(1, displayActiveTasks.count + 1) + let index = displayActiveTasks.count + let accentColor = taskColor(forIndex: index, total: total) + let isSelected = selectedTaskID == draftAppendRowID + HStack(alignment: .center, spacing: TaskRowMetrics.contentSpacing) { + Image(systemName: "circle") + .frame(width: 22, height: 22) + .foregroundStyle(Color.secondary) + .font(.system(size: 17)) + + TappableTextField( + text: draftTaskTitleBinding, + isCompleted: false, + isDragging: false, + onEditingChanged: { editing, shouldCreateNewTask in + DispatchQueue.main.async { + if editing { + beginDraftTaskEditing(.append) + } else { + commitDraftTask( + shouldCreateNewTask: shouldCreateNewTask + ) + } + } + }, + returnKeyType: draftTaskTitle.trimmingCharacters( + in: .whitespacesAndNewlines + ).isEmpty ? .done : .next + ) + .focused($focusedFieldBinding, equals: .task(draftAppendRowID)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, TaskRowMetrics.contentVerticalPadding) + .padding(.trailing, TaskRowMetrics.contentHorizontalPadding) + .padding(.leading, TaskRowMetrics.activeLeadingPadding) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .background { + Color.taskCard.overlay(accentColor.opacity(0.15)) + } + .clipShape( + UnevenRoundedRectangle( + topLeadingRadius: 0, bottomLeadingRadius: 0, + bottomTrailingRadius: TaskRowMetrics.trailingCornerRadius, + topTrailingRadius: TaskRowMetrics.trailingCornerRadius + ) + ) + .overlay(alignment: .leading) { + Rectangle() + .fill(accentColor) + .frame(width: TaskRowMetrics.accentBarWidth) + } + .overlay( + isSelected + ? UnevenRoundedRectangle( + topLeadingRadius: 0, bottomLeadingRadius: 0, + bottomTrailingRadius: TaskRowMetrics.trailingCornerRadius, + topTrailingRadius: TaskRowMetrics.trailingCornerRadius + ) + .stroke(accentColor.opacity(0.40), lineWidth: 2) + : nil + ) + .id(draftAppendRowID) + } + } + @ViewBuilder private var taskRows: some View { ForEach(Array(displayActiveTasks.enumerated()), id: \.element.id) { index, task in let taskID = task.id @@ -356,7 +469,9 @@ struct TaskListView: View, TaskListViewProtocol { onSelect: { selectTask($0) }, onStartEdit: { startEditing($0) }, onEndEdit: { - selectedTaskID = nil + if selectedTaskID == $0 { + selectedTaskID = nil + } endEditing($0, shouldCreateNewTask: $1) } ) @@ -381,6 +496,8 @@ struct TaskListView: View, TaskListViewProtocol { .id(taskID) } + phantomAppendRowContent + ForEach(completedTasks) { task in let taskID = task.id let isBeingCleared = iState.clearingTaskIDs.contains(taskID) @@ -479,7 +596,7 @@ struct TaskListView: View, TaskListViewProtocol { } .padding( .bottom, - (pullToCreate.shouldShowIndicator && !iState.phantomRowVisible) + (pullToCreate.shouldShowIndicator && !isPrependDraftVisible) ? (pullToCreateGap - vStackSpacing) : 0 ) taskRows @@ -509,7 +626,7 @@ struct TaskListView: View, TaskListViewProtocol { if case .task(let id) = (newValue ?? fState.focusedField), draggedTaskID == nil, - id != Self.phantomRowID + id != draftPrependRowID { withAnimation { scrollProxy.scrollTo(id) @@ -517,7 +634,8 @@ struct TaskListView: View, TaskListViewProtocol { } } .onChange(of: fState.selectedTaskID) { _, newID in - if let newID, draggedTaskID == nil, newID != Self.phantomRowID { + if let newID, draggedTaskID == nil { + guard newID != draftPrependRowID else { return } withAnimation { scrollProxy.scrollTo(newID) }