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