Navigate the ⌘T and ⌘P task lists with arrow keys and Enter

This commit is contained in:
Ian Gulliver
2026-10-22 20:36:28 -07:00
parent 1c0effc244
commit 2e2b898486
4 changed files with 113 additions and 20 deletions
+79 -14
View File
@@ -11,7 +11,6 @@ struct ProjectView: View {
@Environment(Overlays.self) private var overlays
@ViewState private var todos: [String: Result<Todo, ApiError>] = [:]
@ViewState private var off: (() -> Void)?
@FocusState private var focused: Bool
var body: some View {
VStack(spacing: 0) {
@@ -46,20 +45,10 @@ struct ProjectView: View {
Muted(text: "No project selected.")
}
}
.followCursor()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.focusable()
.focusEffectDisabled()
.focused($focused)
.onChange(of: overlays.projectFocus, initial: true) {
if overlays.projectFocus {
overlays.projectFocus = false
DispatchQueue.main.async {
focused = true
}
}
}
.onAppear {
off = store.events.add { ev in
if refreshKinds.contains(ev.kind), let projectId = ev.projectId {
@@ -238,6 +227,77 @@ private func rowIndent(_ task: ByreTask, _ depth: Int) -> CGFloat {
task.parentTaskId != nil ? 20 * CGFloat(depth - 1) : 0
}
@MainActor
private func listedTasks(_ store: Store, _ overlays: Overlays) -> [ByreTask]? {
let active = { (p: Project) in nested(store, (store.tasks[p.id] ?? []).filter { $0.status == .active }).map(\.task) }
if overlays.taskList {
return store.projects.flatMap(active)
}
if store.selectedTaskId == nil, overlays.projectTab == .tasks, let p = store.focusedProject() {
return active(p)
}
return nil
}
@MainActor
private func cursorTask(_ store: Store, _ overlays: Overlays) -> String? {
guard overlays.listKeys, let tasks = listedTasks(store, overlays) else {
return nil
}
return tasks.first { $0.id == overlays.listCursor }?.id ?? tasks.first?.id
}
#if os(macOS)
@MainActor
func listKey(_ code: UInt16, _ store: Store, _ overlays: Overlays) -> Bool {
if NSApp.keyWindow?.firstResponder is NSTextView || terminalFocused() {
overlays.listKeys = false
return false
}
guard let tasks = listedTasks(store, overlays), !tasks.isEmpty else {
return false
}
let i = tasks.firstIndex { $0.id == cursorTask(store, overlays) } ?? 0
if code == 125 || code == 126 {
overlays.listCursor = tasks[max(0, min(i + (code == 125 ? 1 : -1), tasks.count - 1))].id
return true
}
overlays.listKeys = false
store.focusTask(tasks[i].id)
return true
}
#endif
extension EnvironmentValues {
@Entry var taskCursor: String?
}
private extension View {
func followCursor() -> some View {
modifier(FollowCursor())
}
}
private struct FollowCursor: ViewModifier {
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
func body(content: Content) -> some View {
let cursor = cursorTask(store, overlays)
ScrollViewReader { proxy in
content
.environment(\.taskCursor, cursor)
.onChange(of: cursor, initial: true) {
if let cursor {
DispatchQueue.main.async {
proxy.scrollTo(cursor)
}
}
}
}
}
}
struct TaskList: View {
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@@ -280,6 +340,7 @@ struct TaskList: View {
.padding(.horizontal, 8)
.padding(.bottom, 16)
}
.followCursor()
.safeAreaInset(edge: .top, spacing: 0) {
HStack {
Spacer(minLength: 0)
@@ -328,6 +389,7 @@ struct TaskRows: View {
struct HoverRow<Content: View>: View {
var selected = false
var cursor = false
var indent: CGFloat = 0
let action: () -> Void
@ViewBuilder let content: Content
@@ -343,6 +405,7 @@ struct HoverRow<Content: View>: View {
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(selected ? Palette.accentTint : hover ? Palette.surface : .clear, in: RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(cursor ? Palette.accent : .clear))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
@@ -354,12 +417,13 @@ private struct TaskRow: View {
let task: ByreTask
let depth: Int
@Environment(Store.self) private var store
@Environment(\.taskCursor) private var cursor
var body: some View {
let ts = store.taskState(task)
let color = taskPalette[task.color]
let count = store.inbox.filter { $0.taskId == task.id }.count
HoverRow(selected: task.id == store.selectedTaskId, indent: rowIndent(task, depth)) {
HoverRow(selected: task.id == store.selectedTaskId, cursor: task.id == cursor, indent: rowIndent(task, depth)) {
store.focusTask(task.id)
} content: {
SubtaskMark(task: task)
@@ -405,10 +469,11 @@ private struct BackgroundRow: View {
let task: ByreTask
let depth: Int
@Environment(Store.self) private var store
@Environment(\.taskCursor) private var cursor
var body: some View {
let ts = store.taskState(task)
HoverRow(selected: task.id == store.selectedTaskId, indent: rowIndent(task, depth)) {
HoverRow(selected: task.id == store.selectedTaskId, cursor: task.id == cursor, indent: rowIndent(task, depth)) {
store.focusTask(task.id)
} content: {
SubtaskMark(task: task)