listless

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

commit 8e06b2b8c9b00004155370d91009312c282b8d1a
parent 805c6cd40c41e5f7f513084c8f69b0332301a0bf
Author: Michael Camilleri <[email protected]>
Date:   Wed,  4 Feb 2026 16:17:42 +0900

Keyboard navigation working properly

Co-Authored-By: Claude 4.5 Sonnet <[email protected]>

Diffstat:
MListless/Views/KeyboardNavigationModifier.swift | 20++++++++++++++------
MListless/Views/TaskListView.swift | 102++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
MListless/Views/TaskRowView.swift | 2--
MListlessMac/ListlessMacApp.swift | 1+
MListlessiOS/ListlessiOSApp.swift | 1+
5 files changed, 81 insertions(+), 45 deletions(-)

diff --git a/Listless/Views/KeyboardNavigationModifier.swift b/Listless/Views/KeyboardNavigationModifier.swift @@ -2,19 +2,27 @@ import SwiftUI extension View { func keyboardNavigation( - onUpArrow: @escaping () -> Void, - onDownArrow: @escaping () -> Void + onUpArrow: @escaping () -> KeyPress.Result, + onDownArrow: @escaping () -> KeyPress.Result, + onSpace: @escaping () -> KeyPress.Result, + onReturn: @escaping () -> KeyPress.Result, + onEscape: @escaping () -> KeyPress.Result ) -> some View { self .onKeyPress(.upArrow) { - print("KeyboardNavigation: upArrow pressed") onUpArrow() - return .handled } .onKeyPress(.downArrow) { - print("KeyboardNavigation: downArrow pressed") onDownArrow() - return .handled + } + .onKeyPress(.space) { + onSpace() + } + .onKeyPress(.return) { + onReturn() + } + .onKeyPress(.escape) { + onEscape() } } } diff --git a/Listless/Views/TaskListView.swift b/Listless/Views/TaskListView.swift @@ -3,12 +3,19 @@ import SwiftUI struct TaskListView: View { enum FocusField: Hashable { case task(UUID) + case scrollView } @State private var store: TaskStore - @State private var tasks: [TaskItem] = [] + @FetchRequest( + sortDescriptors: [ + NSSortDescriptor(keyPath: \TaskItem.isCompleted, ascending: true), + NSSortDescriptor(keyPath: \TaskItem.createdAt, ascending: true), + ], + animation: .default + ) + private var tasks: FetchedResults<TaskItem> @FocusState private var focusedField: FocusField? - @FocusState private var scrollViewFocused: Bool @State private var selectedTaskID: UUID? @State private var refreshID = UUID() @@ -73,54 +80,47 @@ struct TaskListView: View { } .contentShape(Rectangle()) .onTapGesture { - print("TaskListView: background tap") handleBackgroundTap() } .focusable() - .focused($scrollViewFocused) + .focused($focusedField, equals: .scrollView) .focusEffectDisabled() .keyboardNavigation( onUpArrow: navigateUp, - onDownArrow: navigateDown + onDownArrow: navigateDown, + onSpace: toggleSelectedTask, + onReturn: focusSelectedTask, + onEscape: unfocusTextField ) .onAppear { - reloadTasks() - scrollViewFocused = true + focusScrollView() } .onChange(of: focusedField) { oldValue, newValue in handleFocusChange(from: oldValue, to: newValue) - // When a text field gets focus, remove ScrollView focus - // When text field loses focus, restore ScrollView focus - scrollViewFocused = (newValue == nil) } } private var activeTasks: [TaskItem] { - tasks.filter { !$0.isCompleted } + Array(tasks.filter { !$0.isCompleted }) } private var completedTasks: [TaskItem] { - tasks.filter { $0.isCompleted } + Array(tasks.filter { $0.isCompleted }) } private var allTasksInDisplayOrder: [TaskItem] { completedTasks + activeTasks } - private func reloadTasks() { - tasks = store.fetchTasks() - } - private func createTaskAndFocus() { let task = store.createTask(title: "") - reloadTasks() selectedTaskID = task.id - focusedField = .task(task.id) + focusTextField(task.id) } private func handleBackgroundTap() { if focusedField != nil || selectedTaskID != nil { - focusedField = nil + focusScrollView() selectedTaskID = nil } else { createTaskAndFocus() @@ -162,8 +162,6 @@ struct TaskListView: View { } else { store.complete(taskID: task.id) } - reloadTasks() - refreshID = UUID() } private func selectTask(_ taskID: UUID) { @@ -173,52 +171,82 @@ struct TaskListView: View { private func deleteTask(_ task: TaskItem) { let taskID = task.id if focusedField == .task(taskID) { - focusedField = nil + focusScrollView() } if selectedTaskID == taskID { selectedTaskID = nil } store.delete(taskID: taskID) - reloadTasks() } - private func navigateUp() { - print("TaskListView: navigateUp called, selectedTaskID: \(String(describing: selectedTaskID))") + private func navigateUp() -> KeyPress.Result { + guard focusedField == .scrollView else { return .ignored } + guard let currentID = selectedTaskID else { selectedTaskID = activeTasks.last?.id - print("TaskListView: No selection, selected last active: \(String(describing: selectedTaskID))") - return + return .handled } let displayOrder = allTasksInDisplayOrder guard let currentIndex = displayOrder.firstIndex(where: { $0.id == currentID }) else { - print("TaskListView: Current task not found in display order") - return + return .handled } if currentIndex > 0 { selectedTaskID = displayOrder[currentIndex - 1].id - print("TaskListView: Moved to previous task: \(String(describing: selectedTaskID))") } + return .handled } - private func navigateDown() { - print("TaskListView: navigateDown called, selectedTaskID: \(String(describing: selectedTaskID))") + private func navigateDown() -> KeyPress.Result { + guard focusedField == .scrollView else { return .ignored } + guard let currentID = selectedTaskID else { selectedTaskID = completedTasks.first?.id ?? activeTasks.first?.id - print("TaskListView: No selection, selected first: \(String(describing: selectedTaskID))") - return + return .handled } let displayOrder = allTasksInDisplayOrder guard let currentIndex = displayOrder.firstIndex(where: { $0.id == currentID }) else { - print("TaskListView: Current task not found in display order") - return + return .handled } if currentIndex < displayOrder.count - 1 { selectedTaskID = displayOrder[currentIndex + 1].id - print("TaskListView: Moved to next task: \(String(describing: selectedTaskID))") } + return .handled + } + + private func toggleSelectedTask() -> KeyPress.Result { + guard focusedField == .scrollView else { return .ignored } + guard let currentID = selectedTaskID else { return .handled } + guard let task = allTasksInDisplayOrder.first(where: { $0.id == currentID }) else { return .handled } + toggleCompletion(task) + return .handled + } + + private func focusSelectedTask() -> KeyPress.Result { + guard focusedField == .scrollView else { return .ignored } + guard let currentID = selectedTaskID else { return .handled } + guard let task = allTasksInDisplayOrder.first(where: { $0.id == currentID }) else { return .handled } + guard !task.isCompleted else { return .handled } + focusTextField(currentID) + return .handled + } + + private func unfocusTextField() -> KeyPress.Result { + guard case .task = focusedField else { return .ignored } + focusScrollView() + return .handled + } + + // MARK: - Focus Management + + private func focusScrollView() { + focusedField = .scrollView + } + + private func focusTextField(_ taskID: UUID) { + focusedField = .task(taskID) } } diff --git a/Listless/Views/TaskRowView.swift b/Listless/Views/TaskRowView.swift @@ -64,9 +64,7 @@ struct TaskRowView: View { .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .onTapGesture { - print("TaskRowView: row tap \(taskID)") onSelect() - focusedField = nil } .background(selectionBackground) .contextMenu { diff --git a/ListlessMac/ListlessMacApp.swift b/ListlessMac/ListlessMacApp.swift @@ -7,6 +7,7 @@ struct ListlessMacApp: App { var body: some Scene { WindowGroup { TaskListView(store: TaskStore(persistenceController: persistenceController)) + .environment(\.managedObjectContext, persistenceController.viewContext) } } } diff --git a/ListlessiOS/ListlessiOSApp.swift b/ListlessiOS/ListlessiOSApp.swift @@ -7,6 +7,7 @@ struct ListlessiOSApp: App { var body: some Scene { WindowGroup { TaskListView(store: TaskStore(persistenceController: persistenceController)) + .environment(\.managedObjectContext, persistenceController.viewContext) } } }