listless

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

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