Sources/Orgstar/BoardView.swift
248 lines · 10922 bytes
1import OrgApp
2import OrgCore
3import OrgPresentation
4import SwiftUI
5import UniformTypeIdentifiers
6
7/// Entries of the agenda files as a sortable table with property columns, or as a kanban
8/// board by TODO keyword. Dragging a card to a column sets its keyword; edits go through the
9/// open buffer or the file.
10struct BoardView: View {
11 let workspace: WorkspaceModel
12 let session: DocumentSession
13 let commands: AppCommands
14 @State private var agenda: AgendaModel
15 @State private var entries: [BoardEntry] = []
16 @State private var keywords: [String] = []
17 @State private var selection: Set<String> = []
18 @State private var sortOrder = [KeyPathComparator(\BoardRow.todoOrder), KeyPathComparator(\BoardRow.title)]
19 @State private var message: String?
20 @State private var tick = 0
21 @AppStorage("boardKanban") private var kanban = false
22 @AppStorage("boardMatch") private var match = ""
23 @AppStorage("boardColumns") private var columnText = "EFFORT"
24 @AppStorage("agendaSubfolders") private var includeSubfolders = false
25 @Environment(\.openWindow) private var openWindow
26 @Environment(\.orgTheme) private var theme
27
28 init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands) {
29 self.workspace = workspace
30 self.session = session
31 self.commands = commands
32 _agenda = State(initialValue: AgendaModel(workspace: workspace))
33 }
34
35 private var propertyColumns: [String] {
36 columnText.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces).uppercased() }.filter { !$0.isEmpty }
37 }
38
39 private var rows: [BoardRow] {
40 entries.map { BoardRow(entry: $0, todoOrder: $0.todo.flatMap { keywords.firstIndex(of: $0) } ?? Int.max) }.sorted(using: sortOrder)
41 }
42
43 private struct RefreshKey: Hashable {
44 var match: String
45 var subfolders: Bool
46 var files: Int
47 var changed: Int?
48 var edits: Int
49 var path: String?
50 var tick: Int
51 }
52
53 var body: some View {
54 Group {
55 if kanban { board } else { table }
56 }
57 .safeAreaInset(edge: .bottom, spacing: 0) {
58 if let message {
59 Text(message).font(.callout).frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 12).padding(.vertical, 6).background(.bar)
60 }
61 }
62 .toolbar {
63 ToolbarItemGroup(placement: .navigation) {
64 Picker("View", selection: $kanban) {
65 Label("Table", systemImage: "tablecells").tag(false)
66 Label("Kanban", systemImage: "rectangle.split.3x1").tag(true)
67 }
68 .pickerStyle(.segmented)
69 TextField("Match: +work/!TODO", text: $match)
70 .frame(minWidth: 200)
71 if !kanban {
72 TextField("Property columns", text: $columnText)
73 .frame(minWidth: 140)
74 .help("Property names to show as columns, separated by commas")
75 }
76 }
77 }
78 .navigationTitle(kanban ? "Kanban" : "Entries")
79 .task(id: RefreshKey(match: match, subfolders: includeSubfolders, files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, path: session.path, tick: tick)) {
80 agenda.includeSubfolders = includeSubfolders
81 let open = session.openTexts
82 let result = await agenda.board(open: open, match: match)
83 entries = result.entries
84 keywords = result.keywords
85 }
86 }
87
88 // MARK: - Table
89
90 private var table: some View {
91 Table(rows, selection: $selection, sortOrder: $sortOrder) {
92 TableColumn("TODO", value: \.todoOrder) { row in
93 Text(row.entry.todo ?? "").foregroundStyle(theme.keywordColor(row.entry.todo ?? "", done: row.entry.isDone)).bold()
94 }
95 .width(min: 50, ideal: 60, max: 90)
96 TableColumn("P", value: \.priority) { row in Text(row.priority) }
97 .width(min: 20, ideal: 24, max: 40)
98 TableColumn("Title", value: \.title) { row in Text(row.title).lineLimit(1) }
99 .width(min: 160, ideal: 320)
100 TableColumn("Tags", value: \.tags) { row in Text(row.tags).foregroundStyle(.secondary).lineLimit(1) }
101 .width(min: 60, ideal: 120)
102 TableColumn("Scheduled", value: \.scheduled) { row in Text(row.scheduled).monospacedDigit() }
103 .width(min: 80, ideal: 110)
104 TableColumn("Deadline", value: \.deadline) { row in Text(row.deadline).monospacedDigit() }
105 .width(min: 80, ideal: 110)
106 TableColumnForEach(propertyColumns, id: \.self) { key in
107 TableColumn(key.capitalized) { (row: BoardRow) in Text(row.entry.properties[key] ?? "") }
108 .width(min: 50, ideal: 80)
109 }
110 TableColumn("File", value: \.file) { row in Text(row.file).foregroundStyle(.secondary).lineLimit(1) }
111 .width(min: 60, ideal: 120)
112 }
113 .contextMenu(forSelectionType: String.self) { ids in
114 if let entry = entries.first(where: { ids.contains($0.id) }) {
115 Menu("Set TODO") {
116 ForEach(keywords, id: \.self) { keyword in Button(keyword) { setTodo(entry, keyword) } }
117 Divider()
118 Button("None") { setTodo(entry, nil) }
119 }
120 Button("Set Property…") { askProperty(entry) }
121 Button("Open") { jump(entry) }
122 }
123 } primaryAction: { ids in
124 if let entry = entries.first(where: { ids.contains($0.id) }) { jump(entry) }
125 }
126 }
127
128 // MARK: - Kanban
129
130 private var board: some View {
131 ScrollView(.horizontal) {
132 HStack(alignment: .top, spacing: 12) {
133 ForEach(keywords, id: \.self) { keyword in
134 column(keyword)
135 }
136 }
137 .padding(12)
138 }
139 .background(theme.color("agenda-background") ?? .clear)
140 }
141
142 private func column(_ keyword: String) -> some View {
143 let cards = entries.filter { $0.todo == keyword }
144 return VStack(alignment: .leading, spacing: 8) {
145 HStack {
146 Text(keyword).font(.headline).foregroundStyle(theme.keywordColor(keyword, done: cards.first?.isDone ?? false))
147 Text("\(cards.count)").foregroundStyle(.secondary)
148 }
149 ScrollView {
150 VStack(alignment: .leading, spacing: 6) {
151 ForEach(cards) { entry in
152 card(entry)
153 .draggable(entry.id)
154 .onTapGesture(count: 2) { jump(entry) }
155 .accessibilityElement(children: .combine)
156 .accessibilityAddTraits(.isButton)
157 .accessibilityAction { jump(entry) }
158 .accessibilityActions {
159 // Dragging between columns, for VoiceOver.
160 ForEach(keywords.filter { $0 != keyword }, id: \.self) { other in
161 Button("Move to \(other)") { setTodo(entry, other) }
162 }
163 }
164 }
165 }
166 }
167 }
168 .frame(width: 240, alignment: .topLeading)
169 .frame(maxHeight: .infinity, alignment: .top)
170 .padding(8)
171 .background(RoundedRectangle(cornerRadius: 8).fill(Color.secondary.opacity(0.08)))
172 .dropDestination(for: String.self) { ids, _ in
173 for id in ids {
174 if let entry = entries.first(where: { $0.id == id }), entry.todo != keyword { setTodo(entry, keyword) }
175 }
176 return true
177 }
178 }
179
180 private func card(_ entry: BoardEntry) -> some View {
181 VStack(alignment: .leading, spacing: 4) {
182 Text((entry.priority.map { "[#\($0)] " } ?? "") + entry.title).lineLimit(3)
183 HStack(spacing: 6) {
184 Text(entry.category).font(.caption).foregroundStyle(.secondary)
185 if !entry.tags.isEmpty {
186 Text(entry.tags.joined(separator: " ")).font(.caption).foregroundStyle(.secondary).lineLimit(1)
187 }
188 }
189 if let deadline = entry.deadline {
190 Text("Deadline \(deadline)").font(.caption).foregroundStyle(.red)
191 } else if let scheduled = entry.scheduled {
192 Text("Scheduled \(scheduled)").font(.caption).foregroundStyle(.secondary)
193 }
194 }
195 .padding(8)
196 .frame(maxWidth: .infinity, alignment: .leading)
197 .background(RoundedRectangle(cornerRadius: 6).fill(Color(nsColor: .controlBackgroundColor)))
198 }
199
200 // MARK: - Editing
201
202 private func perform(_ command: any OrgCommand, on entry: BoardEntry, answers: [String: String] = [:]) {
203 switch session.perform(command, path: entry.path, at: entry.headingOffset, expect: (entry.headingOffset, entry.headingLine), answers: answers) {
204 case .failed(let text): message = text
205 case .prompt(let prompt):
206 // A state note: asked in the main window's echo area.
207 commands.ask(prompt) { answer in
208 guard let answer else { return }
209 perform(command, on: entry, answers: answers.merging([prompt.key: answer]) { $1 })
210 }
211 openWindow(id: "main")
212 default: message = nil
213 }
214 tick += 1
215 }
216
217 private func setTodo(_ entry: BoardEntry, _ keyword: String?) {
218 perform(SetTodo(keyword: keyword), on: entry)
219 }
220
221 private func askProperty(_ entry: BoardEntry) {
222 commands.ask(Prompt(key: "property", message: "Property (NAME value):")) { answer in
223 guard let answer, let space = answer.firstIndex(of: " ") else { return }
224 let key = String(answer[..<space])
225 let value = String(answer[answer.index(after: space)...]).trimmingCharacters(in: .whitespaces)
226 perform(SetProperty(key: key, value: value), on: entry)
227 }
228 openWindow(id: "main")
229 }
230
231 private func jump(_ entry: BoardEntry) {
232 commands.jump(path: entry.path, offset: entry.headingOffset)
233 openWindow(id: "main")
234 }
235}
236
237/// A table row with sortable values.
238struct BoardRow: Identifiable {
239 let entry: BoardEntry
240 let todoOrder: Int
241 var id: String { entry.id }
242 var title: String { entry.title }
243 var priority: String { entry.priority ?? "" }
244 var tags: String { entry.tags.joined(separator: " ") }
245 var scheduled: String { entry.scheduled ?? "" }
246 var deadline: String { entry.deadline ?? "" }
247 var file: String { (entry.path as NSString).lastPathComponent }
248}