commit 17df2c8881d654512cbe8ea6d13ad59af202906a
parent a2fa753e1ad15f9db59e4b410437ef229d2f5cfd
Author: Michael Camilleri <[email protected]>
Date: Sat, 7 Feb 2026 14:56:10 +0900
Fix bugs with empty view elimination
Co-Authored-By: Claude 4.5 Sonnet <[email protected]>
Diffstat:
4 files changed, 127 insertions(+), 100 deletions(-)
diff --git a/Listless/Views/KeyboardNavigationModifier.swift b/Listless/Views/KeyboardNavigationModifier.swift
@@ -5,8 +5,7 @@ extension View {
onUpArrow: @escaping () -> KeyPress.Result,
onDownArrow: @escaping () -> KeyPress.Result,
onSpace: @escaping () -> KeyPress.Result,
- onReturn: @escaping () -> KeyPress.Result,
- onEscape: @escaping () -> KeyPress.Result
+ onReturn: @escaping () -> KeyPress.Result
) -> some View {
self
.onKeyPress(.upArrow) {
@@ -21,8 +20,5 @@ extension View {
.onKeyPress(.return) {
onReturn()
}
- .onKeyPress(.escape) {
- onEscape()
- }
}
}
diff --git a/Listless/Views/TaskListView.swift b/Listless/Views/TaskListView.swift
@@ -20,8 +20,7 @@ struct TaskListView: View {
@State private var refreshID = UUID()
@State private var draggedTaskID: UUID?
@State private var visualOrder: [UUID]?
- @State private var editingTaskID: UUID?
- @State private var justCreatedTaskID: UUID?
+ @State private var pendingFocus: FocusField?
init(store: TaskStore = TaskStore()) {
_store = State(wrappedValue: store)
@@ -49,9 +48,9 @@ struct TaskListView: View {
onToggle: toggleCompletion(_:),
onTitleChange: updateTitle(_:_:),
onDelete: deleteTask(_:),
- onSelect: { selectTask(taskID) },
- onStartEdit: { startEditing(taskID) },
- onEndEdit: { endEditing(taskID) }
+ onSelect: selectTask(_:),
+ onStartEdit: startEditing(_:),
+ onEndEdit: endEditing(_:shouldCreateNewTask:)
)
}
@@ -71,9 +70,9 @@ struct TaskListView: View {
onToggle: toggleCompletion(_:),
onTitleChange: updateTitle(_:_:),
onDelete: deleteTask(_:),
- onSelect: { selectTask(taskID) },
- onStartEdit: { startEditing(taskID) },
- onEndEdit: { endEditing(taskID) }
+ onSelect: selectTask(_:),
+ onStartEdit: startEditing(_:),
+ onEndEdit: endEditing(_:shouldCreateNewTask:)
)
.taskDragGesture(
isActive: !task.isCompleted,
@@ -145,14 +144,25 @@ struct TaskListView: View {
onUpArrow: navigateUp,
onDownArrow: navigateDown,
onSpace: toggleSelectedTask,
- onReturn: focusSelectedTask,
- onEscape: unfocusTextField
+ onReturn: focusSelectedTask
)
.onAppear {
- focusScrollView()
+ // Focus repair will set to .scrollView if nil
}
.onChange(of: focusedField) { oldValue, newValue in
handleFocusChange(from: oldValue, to: newValue)
+
+ // Focus repair/resolution: when focus becomes nil, either resolve pending focus or repair to scrollView
+ if newValue == nil {
+ if let pending = pendingFocus {
+ print("🟣 onChange resolving pendingFocus: \(pending)")
+ focusedField = pending
+ pendingFocus = nil
+ } else {
+ print("🟣 onChange repairing nil focus to .scrollView")
+ focusedField = .scrollView
+ }
+ }
}
}
@@ -180,6 +190,13 @@ struct TaskListView: View {
completedTasks + displayActiveTasks
}
+ private var editingTaskID: UUID? {
+ if case .task(let id) = focusedField {
+ return id
+ }
+ return nil
+ }
+
private func isLastActiveTask(_ taskID: UUID) -> Bool {
guard let lastTask = activeTasks.last else { return false }
return lastTask.id == taskID
@@ -190,22 +207,13 @@ struct TaskListView: View {
draggedTaskID = nil
visualOrder = nil
- // Create Core Data task
+ // Create Core Data task (Core Data assigns the ID)
let task = store.createTask(title: "")
- // Protect from immediate deletion
- justCreatedTaskID = task.id
+ // Record intent to focus the new task
+ // This will be resolved in onChange(of: tasks) after view is created
+ pendingFocus = .task(task.id)
selectedTaskID = task.id
-
- // Set editing state to trigger TextField render
- editingTaskID = task.id
-
- // Wait for view to update, then focus the TextField
- DispatchQueue.main.async {
- self.focusedField = .task(task.id)
- // Clear the just-created protection once focused
- self.justCreatedTaskID = nil
- }
}
private func handleBackgroundTap() {
@@ -213,18 +221,23 @@ struct TaskListView: View {
let isTaskFocused = if case .task = focusedField { true } else { false }
if isTaskFocused || selectedTaskID != nil {
- focusScrollView()
selectedTaskID = nil
+ // Focus repair will set to .scrollView if needed
} else {
createTaskAndFocus()
}
}
private func handleFocusChange(from oldValue: FocusField?, to newValue: FocusField?) {
+ print("🟣 handleFocusChange() from: \(String(describing: oldValue)) to: \(String(describing: newValue))")
let oldID = taskID(from: oldValue)
let newID = taskID(from: newValue)
- guard oldID != newID, let oldID else { return }
+ guard oldID != newID, let oldID else {
+ print("🟣 handleFocusChange() no action needed")
+ return
+ }
+ print("🟣 handleFocusChange() calling deleteIfEmpty for task \(oldID)")
deleteIfEmpty(taskID: oldID)
}
@@ -234,8 +247,9 @@ struct TaskListView: View {
}
private func deleteIfEmpty(taskID: UUID) {
- // Don't delete tasks that were just created
- if taskID == justCreatedTaskID {
+ // Don't delete if this is the pending focus target
+ if case .task(let pendingTaskID) = pendingFocus, pendingTaskID == taskID {
+ print("🔴 deleteIfEmpty() skipping - task is pending focus")
return
}
@@ -251,11 +265,6 @@ struct TaskListView: View {
private func updateTitle(_ task: TaskItem, _ title: String) {
guard task.title != title else { return }
store.updateWithoutSaving(taskID: task.id, title: title)
-
- // Clear the justCreated flag once user starts typing
- if task.id == justCreatedTaskID && !title.isEmpty {
- justCreatedTaskID = nil
- }
}
private func toggleCompletion(_ task: TaskItem) {
@@ -272,17 +281,22 @@ struct TaskListView: View {
private func deleteTask(_ task: TaskItem) {
let taskID = task.id
- if focusedField == .task(taskID) {
- focusScrollView()
- }
+ print("🔴 deleteTask() called for task \(taskID)")
+
+ // Clear selection if this task was selected
if selectedTaskID == taskID {
+ print("🔴 deleteTask() clearing selectedTaskID")
selectedTaskID = nil
}
+
store.delete(taskID: taskID)
+ print("🔴 deleteTask() completed")
}
private func navigateUp() -> KeyPress.Result {
+ print("⬆️ navigateUp() called, focusedField: \(String(describing: focusedField))")
guard focusedField == .scrollView else {
+ print("⬆️ navigateUp() IGNORED - focus is not .scrollView")
return .ignored
}
@@ -303,7 +317,11 @@ struct TaskListView: View {
}
private func navigateDown() -> KeyPress.Result {
- guard focusedField == .scrollView else { return .ignored }
+ print("⬇️ navigateDown() called, focusedField: \(String(describing: focusedField))")
+ guard focusedField == .scrollView else {
+ print("⬇️ navigateDown() IGNORED - focus is not .scrollView")
+ return .ignored
+ }
guard let currentID = selectedTaskID else {
selectedTaskID = completedTasks.first?.id ?? activeTasks.first?.id
@@ -338,31 +356,9 @@ struct TaskListView: View {
return .handled
}
- private func unfocusTextField() -> KeyPress.Result {
- guard case .task = focusedField else {
- return .ignored
- }
- if let editingID = editingTaskID {
- endEditing(editingID)
- // Keep the task selected
- selectedTaskID = editingID
- }
- focusScrollView()
- return .handled
- }
// MARK: - Focus Management
- private func focusScrollView() {
- // Try clearing focus first, then setting to scrollView
- print("🔵 focusScrollView called, setting focusedField = nil")
- focusedField = nil
- DispatchQueue.main.async {
- print("🔵 focusScrollView async setting focusedField = .scrollView")
- self.focusedField = .scrollView
- }
- }
-
private func focusTextField(_ taskID: UUID) {
focusedField = .task(taskID)
}
@@ -370,32 +366,43 @@ struct TaskListView: View {
private func startEditing(_ taskID: UUID) {
print("🟢 startEditing called for task \(taskID)")
selectedTaskID = taskID
- editingTaskID = taskID
focusedField = .task(taskID)
print("🟢 startEditing set focusedField = .task(\(taskID))")
}
- private func endEditing(_ taskID: UUID) {
+ private func endEditing(_ taskID: UUID, shouldCreateNewTask: Bool) {
+ print("🟢 endEditing() called for task \(taskID), shouldCreateNewTask: \(shouldCreateNewTask)")
// Save any pending changes
store.save()
- // Delete if empty
- deleteIfEmpty(taskID: taskID)
+ // Check conditions BEFORE deleting the task
+ let wasLastActiveTask = isLastActiveTask(taskID)
+ let willBeDeleted = shouldDeleteIfEmpty(taskID: taskID)
+ print("🟢 endEditing() wasLastActiveTask: \(wasLastActiveTask), willBeDeleted: \(willBeDeleted)")
- // Only clear editingTaskID if it matches this task
- if editingTaskID == taskID {
- editingTaskID = nil
- }
-
- // Focus management (same for Return key and clicking away)
- if isLastActiveTask(taskID) {
- // Last task: create new task below it
+ if willBeDeleted {
+ print("🟢 endEditing() deleting task - focus will be repaired automatically by onChange")
+ selectedTaskID = nil
+ deleteIfEmpty(taskID: taskID)
+ // No explicit focus management - onChange will repair to .scrollView
+ } else if wasLastActiveTask && shouldCreateNewTask {
+ print("🟢 endEditing() creating new task")
createTaskAndFocus()
} else {
- // Not last task: keep task selected, return to navigation mode
+ print("🟢 endEditing() keeping task selected, returning to navigation")
selectedTaskID = taskID
- focusScrollView()
+ // Focus repair will set to .scrollView if needed
}
+
+ print("🟢 endEditing() completed, final focus: \(String(describing: focusedField))")
+ }
+
+ private func shouldDeleteIfEmpty(taskID: UUID) -> Bool {
+ guard let task = tasks.first(where: { $0.id == taskID }) else {
+ return false
+ }
+ let trimmedTitle = task.title.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmedTitle.isEmpty
}
// MARK: - Drag and Drop
diff --git a/Listless/Views/TaskRowView.swift b/Listless/Views/TaskRowView.swift
@@ -8,9 +8,9 @@ struct TaskRowView: View {
let onToggle: (TaskItem) -> Void
let onTitleChange: (TaskItem, String) -> Void
let onDelete: (TaskItem) -> Void
- let onSelect: () -> Void
- let onStartEdit: () -> Void
- let onEndEdit: () -> Void
+ let onSelect: (UUID) -> Void
+ let onStartEdit: (UUID) -> Void
+ let onEndEdit: (UUID, _ shouldCreateNewTask: Bool) -> Void
@FocusState.Binding var focusedField: TaskListView.FocusField?
@State private var editingTitle: String = ""
@@ -25,9 +25,9 @@ struct TaskRowView: View {
onToggle: @escaping (TaskItem) -> Void,
onTitleChange: @escaping (TaskItem, String) -> Void,
onDelete: @escaping (TaskItem) -> Void,
- onSelect: @escaping () -> Void,
- onStartEdit: @escaping () -> Void = {},
- onEndEdit: @escaping () -> Void = {}
+ onSelect: @escaping (UUID) -> Void,
+ onStartEdit: @escaping (UUID) -> Void = { _ in },
+ onEndEdit: @escaping (UUID, _ shouldCreateNewTask: Bool) -> Void = { _, _ in }
) {
self.task = task
self.taskID = taskID
@@ -43,27 +43,30 @@ struct TaskRowView: View {
}
var body: some View {
- HStack(spacing: 12) {
+ HStack(alignment: .firstTextBaseline, spacing: 12) {
Button {
onToggle(task)
} label: {
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundStyle(task.isCompleted ? .secondary : .primary)
- .frame(width: 20, height: 20)
+ .font(.system(size: 17))
}
.buttonStyle(.borderless)
+ .alignmentGuide(.firstTextBaseline) { d in
+ d[VerticalAlignment.center] + 5
+ }
.accessibilityIdentifier("task-checkbox")
.accessibilityValue(task.isCompleted ? "checkmark.circle.fill" : "circle")
ClickableTextField(
text: $editingTitle,
isCompleted: task.isCompleted,
- onEditingChanged: { editing in
+ onEditingChanged: { editing, shouldCreateNewTask in
isCurrentlyEditing = editing
if editing {
- onStartEdit()
+ onStartEdit(taskID)
} else {
- onEndEdit()
+ onEndEdit(taskID, shouldCreateNewTask)
}
}
)
@@ -76,7 +79,7 @@ struct TaskRowView: View {
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
- onSelect()
+ onSelect(taskID)
}
.background(selectionBackground)
.contextMenu {
diff --git a/ListlessMac/Views/ClickableTextField.swift b/ListlessMac/Views/ClickableTextField.swift
@@ -18,7 +18,7 @@ class ClickableNSTextField: NSTextField {
struct ClickableTextField: NSViewRepresentable {
@Binding var text: String
let isCompleted: Bool
- let onEditingChanged: (Bool) -> Void
+ let onEditingChanged: (Bool, _ shouldCreateNewTask: Bool) -> Void
func makeNSView(context: Context) -> ClickableNSTextField {
let textField = ClickableNSTextField()
@@ -66,14 +66,15 @@ struct ClickableTextField: NSViewRepresentable {
let maxWidth = proposal.width ?? 300
let isEditing = nsView.currentEditor() != nil
+ // Always calculate height based on maxWidth to preserve multiline wrapping
+ let height = calculateHeight(for: text, width: maxWidth, font: nsView.font ?? .systemFont(ofSize: NSFont.systemFontSize))
+
if isEditing {
// When editing, take full width
- let height = calculateHeight(for: text, width: maxWidth, font: nsView.font ?? .systemFont(ofSize: NSFont.systemFontSize))
return CGSize(width: maxWidth, height: max(height, 22))
} else {
- // When not editing, size to content
+ // When not editing, size width to content but maintain multiline height
let width = calculateWidth(for: text, font: nsView.font ?? .systemFont(ofSize: NSFont.systemFontSize))
- let height = calculateHeight(for: text, width: width, font: nsView.font ?? .systemFont(ofSize: NSFont.systemFontSize))
return CGSize(width: min(width, maxWidth), height: max(height, 22))
}
}
@@ -82,6 +83,12 @@ struct ClickableTextField: NSViewRepresentable {
Coordinator(text: $text, onEditingChanged: onEditingChanged)
}
+ enum EditEndReason {
+ case returnKey
+ case escape
+ case focusLost
+ }
+
// Calculate text width
private func calculateWidth(for text: String, font: NSFont) -> CGFloat {
let attributedString = NSAttributedString(
@@ -114,9 +121,10 @@ struct ClickableTextField: NSViewRepresentable {
final class Coordinator: NSObject, NSTextFieldDelegate {
@Binding var text: String
- let onEditingChanged: (Bool) -> Void
+ let onEditingChanged: (Bool, _ shouldCreateNewTask: Bool) -> Void
+ var editEndReason: EditEndReason = .focusLost
- init(text: Binding<String>, onEditingChanged: @escaping (Bool) -> Void) {
+ init(text: Binding<String>, onEditingChanged: @escaping (Bool, _ shouldCreateNewTask: Bool) -> Void) {
_text = text
self.onEditingChanged = onEditingChanged
}
@@ -135,12 +143,14 @@ struct ClickableTextField: NSViewRepresentable {
func handleBecomeFirstResponder() {
print("🟡 ClickableTextField.becomeFirstResponder - calling onEditingChanged(true)")
- onEditingChanged(true)
+ onEditingChanged(true, false)
}
func controlTextDidEndEditing(_ obj: Notification) {
- print("🟡 ClickableTextField.controlTextDidEndEditing fired")
- onEditingChanged(false)
+ print("🟡 ClickableTextField.controlTextDidEndEditing fired - reason: \(editEndReason)")
+ let shouldCreateNewTask = editEndReason == .returnKey
+ editEndReason = .focusLost // Reset for next time
+ onEditingChanged(false, shouldCreateNewTask)
}
func controlTextDidChange(_ obj: Notification) {
@@ -149,11 +159,22 @@ struct ClickableTextField: NSViewRepresentable {
}
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
+ print("🟡 ClickableTextField.doCommandBy selector: \(commandSelector)")
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
- // Resign first responder, which triggers controlTextDidEndEditing → onEditingChanged(false)
+ // Return key pressed
+ print("🟡 ClickableTextField.doCommandBy RETURN key detected")
+ editEndReason = .returnKey
control.window?.makeFirstResponder(nil)
return true // Prevent newline insertion
}
+ if commandSelector == #selector(NSResponder.cancelOperation(_:)) {
+ // Escape key pressed
+ print("🟡 ClickableTextField.doCommandBy ESCAPE key detected")
+ editEndReason = .escape
+ control.window?.makeFirstResponder(nil)
+ return true
+ }
+ print("🟡 ClickableTextField.doCommandBy unhandled selector, returning false")
return false
}
}