macOS client overlays, menus and notifications
This commit is contained in:
@@ -0,0 +1,487 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
typealias WorkflowSummary = Components.Schemas.WorkflowSummary
|
||||
|
||||
let goKeys: Set<String> = ["k", "p", "l", "s", "/"]
|
||||
|
||||
@MainActor
|
||||
func runGo(_ key: String, _ store: Store, _ overlays: Overlays) {
|
||||
switch key {
|
||||
case "k":
|
||||
overlays.openSwitcher(store)
|
||||
case "p":
|
||||
store.focusTab(.project)
|
||||
case "l":
|
||||
overlays.leftTab = .lead
|
||||
overlays.leadFocus += 1
|
||||
case "s":
|
||||
if let taskId = store.selectedTaskId {
|
||||
Task {
|
||||
await confirmMerge(taskId, store, overlays)
|
||||
}
|
||||
}
|
||||
case "/":
|
||||
overlays.showAlert(ShortcutsHelp())
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func ctrlGo(_ key: String, _ store: Store, _ overlays: Overlays) -> Bool {
|
||||
if terminalFocused() {
|
||||
return false
|
||||
}
|
||||
runGo(key, store, overlays)
|
||||
return true
|
||||
}
|
||||
|
||||
private let shortcuts: [(String, [(String, String)])] = [
|
||||
("Anywhere", [
|
||||
("⌘K", "Switch task, or create one"),
|
||||
("⌘P", "Open the Project tab"),
|
||||
("⌘L", "Open the Lead tab and its composer"),
|
||||
("⌘S", "Merge the right pane's task"),
|
||||
("⌘/", "Show these shortcuts"),
|
||||
("Esc", "Close the switcher or a dialog"),
|
||||
]),
|
||||
("Task switcher", [
|
||||
("↑ ↓", "Move the highlight"),
|
||||
("Enter", "Pick the highlighted row"),
|
||||
("name: title", "Create the task in the project whose name starts with name"),
|
||||
("Esc", "Back from the workflow list to the task list"),
|
||||
]),
|
||||
("Composer and inbox answers", [
|
||||
("Enter", "Send"),
|
||||
("Shift+Enter", "New line"),
|
||||
("Enter / Space", "Pick the focused choice"),
|
||||
]),
|
||||
("Dialogs", [("Enter", "Confirm")]),
|
||||
("Terminal", [("Ctrl+…", "Goes to the shell, not to byre")]),
|
||||
]
|
||||
|
||||
private struct ShortcutsHelp: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Keyboard shortcuts")
|
||||
.font(Fonts.font(14, weight: 700))
|
||||
.padding(.bottom, 12)
|
||||
ForEach(shortcuts, id: \.0) { group, rows in
|
||||
Text(group)
|
||||
.font(Fonts.font(12, weight: 700))
|
||||
.foregroundStyle(Palette.muted)
|
||||
.padding(.bottom, 6)
|
||||
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 4) {
|
||||
ForEach(rows, id: \.0) { keys, what in
|
||||
GridRow {
|
||||
Text(keys)
|
||||
.padding(.horizontal, 6)
|
||||
.background(Palette.bg, in: RoundedRectangle(cornerRadius: 4))
|
||||
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Palette.border))
|
||||
Text(what)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 14)
|
||||
}
|
||||
Text("Ctrl works in place of ⌘.")
|
||||
.foregroundStyle(Palette.muted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private func includes(_ s: String, _ q: String) -> Bool {
|
||||
q.isEmpty || s.contains(q)
|
||||
}
|
||||
|
||||
private let switcherPlaceholder = "Go to a task, or type a title to create one…"
|
||||
private let projectPrefix = Re(#"^([^:\s]+):\s*(.*)$"#)
|
||||
|
||||
struct SwitcherRow: Identifiable {
|
||||
enum Kind {
|
||||
case task(Project, ByreTask, Int)
|
||||
case project(Project)
|
||||
case newTask(Project, String)
|
||||
case startWorkflow(Project, String)
|
||||
case workflow(Project, String, WorkflowSummary)
|
||||
}
|
||||
|
||||
let id: String
|
||||
let kind: Kind
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SwitcherModel {
|
||||
struct Picking {
|
||||
let seq: Int
|
||||
let project: Project
|
||||
let title: String
|
||||
let typed: String
|
||||
var workflows: [WorkflowSummary]?
|
||||
}
|
||||
|
||||
let store: Store
|
||||
let overlays: Overlays
|
||||
let anchor: CGRect
|
||||
var text = ""
|
||||
var selected = 0
|
||||
var picking: Picking?
|
||||
var focusRequest = 0
|
||||
@ObservationIgnored private var picks = 0
|
||||
|
||||
init(store: Store, overlays: Overlays, anchor: CGRect) {
|
||||
self.store = store
|
||||
self.overlays = overlays
|
||||
self.anchor = anchor
|
||||
}
|
||||
|
||||
var placeholder: String {
|
||||
picking.map { "Pick a workflow to start in \($0.project.name): \($0.title)" } ?? switcherPlaceholder
|
||||
}
|
||||
|
||||
func rows() -> (rows: [SwitcherRow], empty: String) {
|
||||
if let picking {
|
||||
return workflowRows(picking)
|
||||
}
|
||||
return taskRows()
|
||||
}
|
||||
|
||||
private func workflowRows(_ p: Picking) -> (rows: [SwitcherRow], empty: String) {
|
||||
let q = trimmed(text).lowercased()
|
||||
let rows = (p.workflows ?? [])
|
||||
.filter { includes("\($0.name) \($0.description)".lowercased(), q) }
|
||||
.map { SwitcherRow(id: "workflow:\($0.name)", kind: .workflow(p.project, p.title, $0)) }
|
||||
guard let workflows = p.workflows else {
|
||||
return (rows, "Loading workflows…")
|
||||
}
|
||||
return (rows, workflows.isEmpty ? "No saved workflows." : "No workflow matches.")
|
||||
}
|
||||
|
||||
private func taskRows() -> (rows: [SwitcherRow], empty: String) {
|
||||
let typed = trimmed(text)
|
||||
let prefix = projectPrefix.exec(typed)
|
||||
let named = prefix.map { m in store.projects.filter { $0.name.lowercased().hasPrefix(m[1]!.lowercased()) } } ?? []
|
||||
let targets = named.isEmpty ? nil : named
|
||||
let title = targets != nil ? prefix![2]! : typed
|
||||
let q = title.lowercased()
|
||||
let visits = store.lastVisits()
|
||||
let matches = store.activeTasks()
|
||||
.filter { row in
|
||||
if let targets {
|
||||
return targets.contains { $0.id == row.project.id } && includes(row.task.title.lowercased(), q)
|
||||
}
|
||||
return includes("\(row.project.name) \(row.task.title)".lowercased(), q)
|
||||
}
|
||||
.enumerated()
|
||||
.map { i, row in
|
||||
(i: i, project: row.project, task: row.task, count: store.inbox.filter { $0.taskId == row.task.id }.count, visited: row.task.id == store.selectedTaskId ? Double.infinity : visits[row.task.id] ?? 0)
|
||||
}
|
||||
.sorted { a, b in
|
||||
if (a.count > 0) != (b.count > 0) {
|
||||
return a.count > 0
|
||||
}
|
||||
if a.visited != b.visited {
|
||||
return a.visited < b.visited
|
||||
}
|
||||
return a.i < b.i
|
||||
}
|
||||
var rows = matches.map { SwitcherRow(id: "task:\($0.task.id)", kind: .task($0.project, $0.task, $0.count)) }
|
||||
for p in (targets ?? store.projects) where includes("\(p.name) project".lowercased(), q) {
|
||||
rows.append(SwitcherRow(id: "project:\(p.id)", kind: .project(p)))
|
||||
}
|
||||
let fallback = store.focusedProject() ?? store.projects.first
|
||||
for project in title.isEmpty ? [] : targets ?? (fallback.map { [$0] } ?? []) {
|
||||
rows.append(SwitcherRow(id: "new:\(project.id)", kind: .newTask(project, title)))
|
||||
rows.append(SwitcherRow(id: "start:\(project.id)", kind: .startWorkflow(project, title)))
|
||||
}
|
||||
return (rows, "No active tasks. Type a title to create one.")
|
||||
}
|
||||
|
||||
func setText(_ value: String) {
|
||||
text = value
|
||||
selected = 0
|
||||
}
|
||||
|
||||
func move(_ by: Int) {
|
||||
let count = rows().rows.count
|
||||
selected = max(0, min(selected + by, count - 1))
|
||||
}
|
||||
|
||||
func pick() {
|
||||
let all = rows().rows
|
||||
guard !all.isEmpty else {
|
||||
return
|
||||
}
|
||||
pick(all[min(selected, all.count - 1)])
|
||||
}
|
||||
|
||||
func pick(_ row: SwitcherRow) {
|
||||
switch row.kind {
|
||||
case .task(_, let task, _):
|
||||
go(task.id)
|
||||
case .project(let p):
|
||||
close()
|
||||
store.focusProject(p.id)
|
||||
case .newTask(let p, let title):
|
||||
create(p.id, title, nil)
|
||||
case .startWorkflow(let p, let title):
|
||||
pickWorkflow(p, title)
|
||||
case .workflow(let p, let title, let w):
|
||||
create(p.id, title, w.name)
|
||||
}
|
||||
}
|
||||
|
||||
func escape() {
|
||||
guard let picking else {
|
||||
close()
|
||||
return
|
||||
}
|
||||
text = picking.typed
|
||||
self.picking = nil
|
||||
selected = 0
|
||||
}
|
||||
|
||||
func close() {
|
||||
if overlays.switcher === self {
|
||||
overlays.switcher = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func go(_ taskId: String) {
|
||||
close()
|
||||
if store.inbox.contains(where: { $0.taskId == taskId }) {
|
||||
store.focusTask(taskId, .inbox)
|
||||
} else if let task = store.findTask(taskId), store.unreadSummary(task) {
|
||||
store.openSummary(task)
|
||||
} else {
|
||||
store.focusTask(taskId, .transcript)
|
||||
}
|
||||
}
|
||||
|
||||
private func create(_ projectId: String, _ title: String, _ workflow: String?) {
|
||||
close()
|
||||
Task {
|
||||
do {
|
||||
let task = try await store.api.client.createTask(path: .init(projectId: projectId), body: .json(.init(title: title, workflow: workflow))).created.body.json
|
||||
go(task.id)
|
||||
} catch {
|
||||
overlays.showAlert(errorText(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pickWorkflow(_ project: Project, _ title: String) {
|
||||
picks += 1
|
||||
let seq = picks
|
||||
picking = Picking(seq: seq, project: project, title: title, typed: text)
|
||||
text = ""
|
||||
selected = 0
|
||||
focusRequest += 1
|
||||
Task {
|
||||
do {
|
||||
let list = try await store.api.client.listWorkflows().ok.body.json
|
||||
if picking?.seq == seq {
|
||||
picking?.workflows = list
|
||||
}
|
||||
} catch {
|
||||
close()
|
||||
overlays.showAlert(errorText(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SwitcherPlacement: View {
|
||||
let model: SwitcherModel
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { g in
|
||||
SwitcherLayout(anchor: model.anchor) {
|
||||
SwitcherBox(model: model, maxList: g.size.height * 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SwitcherLayout: Layout {
|
||||
let anchor: CGRect
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
proposal.replacingUnspecifiedDimensions()
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
let width = min(560, bounds.width * 0.9)
|
||||
let size = subviews[0].sizeThatFits(ProposedViewSize(width: width, height: nil))
|
||||
let left = anchor.midX - width / 2
|
||||
let x = max(overlayMargin, min(left, bounds.width - width - overlayMargin))
|
||||
subviews[0].place(at: CGPoint(x: bounds.minX + x, y: bounds.minY + anchor.maxY + 4), proposal: ProposedViewSize(width: width, height: size.height))
|
||||
}
|
||||
}
|
||||
|
||||
private struct SwitcherBox: View {
|
||||
let model: SwitcherModel
|
||||
let maxList: CGFloat
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
var body: some View {
|
||||
let (rows, empty) = model.rows()
|
||||
let selected = min(model.selected, max(rows.count - 1, 0))
|
||||
let selectedId = rows.isEmpty ? nil : rows[selected].id
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
TextField(model.placeholder, text: Binding(get: { model.text }, set: { model.setText($0) }))
|
||||
.modifier(FieldStyle(focused: focused))
|
||||
.focused($focused)
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if rows.isEmpty {
|
||||
Text(empty)
|
||||
.foregroundStyle(Palette.muted)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
ForEach(Array(rows.enumerated()), id: \.element.id) { i, row in
|
||||
SwitcherRowView(row: row)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(i == selected ? Palette.accentTint : .clear, in: RoundedRectangle(cornerRadius: 6))
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
model.pick(row)
|
||||
}
|
||||
.id(row.id)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
.frame(maxHeight: maxList)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.onChange(of: "\(selectedId ?? "")\n\(model.text)") {
|
||||
if let selectedId {
|
||||
proxy.scrollTo(selectedId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Palette.border))
|
||||
.shadow(color: .black.opacity(0.18), radius: 14, y: 8)
|
||||
.onAppear {
|
||||
focused = true
|
||||
}
|
||||
.onChange(of: model.focusRequest) {
|
||||
focused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SwitcherRowView: View {
|
||||
let row: SwitcherRow
|
||||
@Environment(Store.self) private var store
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
switch row.kind {
|
||||
case .task(let project, let task, let count):
|
||||
let ts = store.taskState(task)
|
||||
let color = taskPalette[task.color]
|
||||
StateMark(state: ts, color: color)
|
||||
Text(taskTitle(project, task, color))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if count > 0 {
|
||||
Badge(count: count).help("inbox items")
|
||||
}
|
||||
if store.unreadSummary(task) {
|
||||
Text("new summary")
|
||||
.font(Fonts.font(11, weight: 700))
|
||||
.foregroundStyle(Palette.accent)
|
||||
.help("A turn summary you haven't read")
|
||||
.accessibilityLabel("New summary from \(task.title), unread")
|
||||
}
|
||||
Text(ts.label)
|
||||
.font(Fonts.font(11))
|
||||
.foregroundStyle(ts.kind == .answer ? Palette.warn : ts.kind == .error ? Palette.danger : Palette.muted)
|
||||
.help(ts.title)
|
||||
case .project(let p):
|
||||
StateDot(filled: nil)
|
||||
Text(p.name)
|
||||
.foregroundStyle(Palette.muted)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
state("project")
|
||||
case .newTask(let p, let title):
|
||||
Text(created([("New task in ", nil), (p.name, Palette.muted), (": \(title)", nil)]))
|
||||
.foregroundStyle(Palette.accent)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
state(models[0])
|
||||
.help("Model for the first message")
|
||||
case .startWorkflow(let p, let title):
|
||||
Text(created([("Start workflow… in ", nil), (p.name, Palette.muted), (": \(title)", nil)]))
|
||||
.foregroundStyle(Palette.accent)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
state("workflow")
|
||||
case .workflow(let p, let title, let w):
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(workflowTitle(p, title, w))
|
||||
if !w.description.isEmpty {
|
||||
Text(w.description)
|
||||
.foregroundStyle(Palette.muted)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(Palette.accent)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
state("workflow")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func state(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(Fonts.font(11))
|
||||
.foregroundStyle(Palette.muted)
|
||||
}
|
||||
|
||||
private func taskTitle(_ project: Project, _ task: ByreTask, _ color: Color) -> AttributedString {
|
||||
var parts: [(String, Color?)] = [(project.name, Palette.muted), (" ", nil)]
|
||||
if let parent = task.parentTaskId {
|
||||
parts.append(("\(store.findTask(parent)?.title ?? parent) ↳ ", Palette.muted))
|
||||
}
|
||||
parts.append((task.title, color))
|
||||
return created(parts)
|
||||
}
|
||||
|
||||
private func workflowTitle(_ p: Project, _ title: String, _ w: WorkflowSummary) -> AttributedString {
|
||||
var out = AttributedString("Start ")
|
||||
var name = AttributedString(w.name)
|
||||
name.font = Fonts.font(13, weight: 700)
|
||||
out += name
|
||||
out += AttributedString(" in ")
|
||||
var project = AttributedString(p.name)
|
||||
project.foregroundColor = Palette.muted
|
||||
out += project
|
||||
out += AttributedString(": \(title)")
|
||||
return out
|
||||
}
|
||||
|
||||
private func created(_ parts: [(String, Color?)]) -> AttributedString {
|
||||
var out = AttributedString()
|
||||
for (text, color) in parts {
|
||||
var a = AttributedString(text)
|
||||
if let color {
|
||||
a.foregroundColor = color
|
||||
}
|
||||
out += a
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user