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
+23 -4
View File
@@ -56,7 +56,8 @@ final class Overlays {
var leftTab = LeftTab.lead
var leadFocus = 0
var tabFocus = 0
var projectFocus = false
var listKeys = false
var listCursor: String?
var projectTab = ProjectTab.tasks
var taskList = false
@ObservationIgnored var pickerFrame: CGRect = .zero
@@ -70,6 +71,7 @@ final class Overlays {
@ObservationIgnored private var holders = 0
@ObservationIgnored private var ctrl: @MainActor (String) -> Bool = { _ in false }
@ObservationIgnored private var escape: @MainActor () -> Bool = { false }
@ObservationIgnored private var list: @MainActor (UInt16) -> Bool = { _ in false }
#endif
var isOpen: Bool {
@@ -138,6 +140,14 @@ final class Overlays {
switcher = SwitcherModel(store: store, overlays: self, anchor: pickerFrame)
}
func takeListKeys(_ cursor: String?) {
listKeys = true
listCursor = cursor
#if os(macOS)
window?.makeFirstResponder(nil)
#endif
}
func closeTop() {
if !alerts.isEmpty {
alerts.removeLast()
@@ -151,10 +161,11 @@ final class Overlays {
}
#if os(macOS)
func install(_ ctrl: @escaping @MainActor (String) -> Bool, escape: @escaping @MainActor () -> Bool) {
func install(_ ctrl: @escaping @MainActor (String) -> Bool, escape: @escaping @MainActor () -> Bool, list: @escaping @MainActor (UInt16) -> Bool) {
holders += 1
self.ctrl = ctrl
self.escape = escape
self.list = list
if monitor != nil {
return
}
@@ -174,7 +185,11 @@ final class Overlays {
mouseMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { [weak self] event in
let win = event.window.map(ObjectIdentifier.init)
MainActor.assumeIsolated {
if let self, let window = self.window, win == ObjectIdentifier(window), self.menu != nil, !self.menuHover {
guard let self, let window = self.window, win == ObjectIdentifier(window) else {
return
}
self.listKeys = false
if self.menu != nil, !self.menuHover {
self.menu = nil
}
}
@@ -198,10 +213,13 @@ final class Overlays {
_ = escape()
return true
}
let enter = code == 36 || code == 76
if !isOpen {
if listKeys && (enter || code == 125 || code == 126) && flags.isDisjoint(with: [.command, .control, .option, .shift]) {
return list(code)
}
return false
}
let enter = code == 36 || code == 76
if !alerts.isEmpty {
if enter {
alerts.removeLast()
@@ -248,6 +266,7 @@ final class Overlays {
}
ctrl = { _ in false }
escape = { false }
list = { _ in false }
if let monitor {
NSEvent.removeMonitor(monitor)
}
+2
View File
@@ -60,6 +60,8 @@ struct MainView: View {
ctrlGo(key, store, overlays)
} escape: {
store.closeSheet()
} list: { code in
listKey(code, store, overlays)
}
}
.onDisappear {
+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)
+9 -2
View File
@@ -19,11 +19,13 @@ func runGo(_ key: String, _ store: Store, _ overlays: Overlays) {
overlays.switcher?.close()
store.sheet = nil
overlays.taskList = true
overlays.takeListKeys(store.selectedTaskId)
case "p":
overlays.switcher?.close()
store.sheet = nil
if let project = store.focusedProject() ?? store.projects.first {
overlays.projectFocus = true
overlays.projectTab = .tasks
overlays.takeListKeys(nil)
store.focusProject(project.id)
}
case "l":
@@ -94,6 +96,10 @@ private let shortcuts: [(String, [(String, String)])] = [
("name: message", "Start the task in the project whose name starts with name"),
("workflow title", "Start the workflow named workflow, titled title"),
]),
("Task lists, after ⌘T or ⌘P", [
("↑ ↓", "Move the highlight"),
("Enter", "Open the highlighted task"),
]),
("Composer and answers", [
("Enter", "Send"),
("Shift+Enter", "New line"),
@@ -285,7 +291,8 @@ final class SwitcherModel {
go(task.id)
case .project(let p):
close()
overlays.projectFocus = true
overlays.projectTab = .tasks
overlays.takeListKeys(nil)
store.focusProject(p.id)
case .newTask(let p):
create(p.id, .init())