Navigate the ⌘T and ⌘P task lists with arrow keys and Enter
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ struct MainView: View {
|
||||
ctrlGo(key, store, overlays)
|
||||
} escape: {
|
||||
store.closeSheet()
|
||||
} list: { code in
|
||||
listKey(code, store, overlays)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user