← All repositories

listless

A simple list app for Apple platforms

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


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

MListless/Views/KeyboardNavigationModifier.swift | 6+-----
MListless/Views/TaskListView.swift | 153+++++++++++++++++++++++++++++++++++++++++--------------------------------------
MListless/Views/TaskRowView.swift | 27+++++++++++++++------------
MListlessMac/Views/ClickableTextField.swift | 41+++++++++++++++++++++++++++++++----------

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