diff --git a/mac/Sources/Byre/Dialogs.swift b/mac/Sources/Byre/Dialogs.swift index 691cee4..e5da92d 100644 --- a/mac/Sources/Byre/Dialogs.swift +++ b/mac/Sources/Byre/Dialogs.swift @@ -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) } diff --git a/mac/Sources/Byre/Panes.swift b/mac/Sources/Byre/Panes.swift index 66b840c..836bf16 100644 --- a/mac/Sources/Byre/Panes.swift +++ b/mac/Sources/Byre/Panes.swift @@ -60,6 +60,8 @@ struct MainView: View { ctrlGo(key, store, overlays) } escape: { store.closeSheet() + } list: { code in + listKey(code, store, overlays) } } .onDisappear { diff --git a/mac/Sources/Byre/Project.swift b/mac/Sources/Byre/Project.swift index 065c26f..27ff0be 100644 --- a/mac/Sources/Byre/Project.swift +++ b/mac/Sources/Byre/Project.swift @@ -11,7 +11,6 @@ struct ProjectView: View { @Environment(Overlays.self) private var overlays @ViewState private var todos: [String: Result] = [:] @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: View { var selected = false + var cursor = false var indent: CGFloat = 0 let action: () -> Void @ViewBuilder let content: Content @@ -343,6 +405,7 @@ struct HoverRow: 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) diff --git a/mac/Sources/Byre/Switcher.swift b/mac/Sources/Byre/Switcher.swift index 3e3dcf0..c0f5e44 100644 --- a/mac/Sources/Byre/Switcher.swift +++ b/mac/Sources/Byre/Switcher.swift @@ -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())