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:
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)
}