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