Sources/Orgstar/AgendaView.swift
430 lines · 16908 bytes
1import OrgApp
2import OrgCore
3import SwiftUI
4
5/// The agenda window: the day/week agenda, the TODO list, tag matches and saved views.
6/// Return or a double click jumps to the entry in the main window. Keys as in Emacs's agenda:
7/// f/b/. move the span, g refreshes, t cycles the TODO state, +/- change the priority, : or
8/// C-c C-q sets tags, C-c C-s / C-c C-d schedule and set a deadline, S-→/S-← move the date.
9struct AgendaView: View {
10 let workspace: WorkspaceModel
11 let session: DocumentSession
12 let commands: AppCommands
13 let scheduler: ReminderScheduler?
14 @State private var agenda: AgendaModel
15 @State private var views = AgendaViewLoader.builtIn
16 @State private var viewName = AgendaViewLoader.builtIn[0].name
17 @State private var matchText = ""
18 @State private var selection: String?
19 @State private var tick = 0
20 @State private var message: String?
21 @State private var prompt: PendingPrompt?
22 @State private var promptText = ""
23 @State private var prefix = false
24 @AppStorage("agendaSpan") private var span = 10
25 @AppStorage("agendaStartOffset") private var startOffset = -3
26 @AppStorage("agendaSubfolders") private var includeSubfolders = false
27 @Environment(\.openWindow) private var openWindow
28
29 static let match = "Match…"
30 static let todoMatch = "TODO Match…"
31
32 init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands, scheduler: ReminderScheduler?) {
33 self.workspace = workspace
34 self.session = session
35 self.commands = commands
36 self.scheduler = scheduler
37 _agenda = State(initialValue: AgendaModel(workspace: workspace))
38 }
39
40 struct PendingPrompt: Identifiable {
41 let id = UUID()
42 let prompt: Prompt
43 let command: any OrgCommand
44 let atMarker: Bool
45 let answers: [String: String]
46 let itemID: String
47 }
48
49 private struct RefreshKey: Hashable {
50 var mode: AgendaMode
51 var start: Int?
52 var span: Int
53 var startOffset: Int
54 var includeSubfolders: Bool
55 var files: Int
56 var changed: Int?
57 var edits: Int
58 var path: String?
59 var tick: Int
60 }
61
62 private var refreshKey: RefreshKey {
63 RefreshKey(
64 mode: agenda.mode, start: agenda.start, span: span, startOffset: startOffset, includeSubfolders: includeSubfolders,
65 files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion,
66 path: session.path, tick: tick
67 )
68 }
69
70 private var isAgenda: Bool {
71 if case .agenda = agenda.mode { return true }
72 return false
73 }
74
75 private var isMatch: Bool { viewName == Self.match || viewName == Self.todoMatch }
76
77 var body: some View {
78 List(selection: $selection) {
79 if isAgenda {
80 ForEach(agenda.days, id: \.day) { day in
81 Section {
82 rows(day.items, prefix: "\(day.day)", day: day.day)
83 } header: {
84 Text(day.header)
85 .font(.headline)
86 .foregroundStyle(day.isToday ? Color.accentColor : .primary)
87 }
88 }
89 } else {
90 Section {
91 rows(agenda.list, prefix: "list")
92 } header: {
93 Text(listTitle).font(.headline)
94 }
95 }
96 }
97 .contextMenu(forSelectionType: String.self) { _ in
98 } primaryAction: { ids in
99 if let id = ids.first { jump(id) }
100 }
101 .onKeyPress(phases: .down) { press in handle(press) }
102 .safeAreaInset(edge: .bottom, spacing: 0) {
103 if let message = message ?? reminderStatus {
104 Text(message)
105 .font(.callout)
106 .frame(maxWidth: .infinity, alignment: .leading)
107 .padding(.horizontal, 12)
108 .padding(.vertical, 6)
109 .background(.bar)
110 }
111 }
112 .toolbar {
113 ToolbarItemGroup(placement: .navigation) {
114 Menu {
115 ForEach(views) { view in Button(view.name) { viewName = view.name } }
116 Divider()
117 Button(Self.match) { viewName = Self.match }
118 Button(Self.todoMatch) { viewName = Self.todoMatch }
119 } label: {
120 Label(viewName, systemImage: "calendar").labelStyle(.titleAndIcon)
121 }
122 .help("What the agenda shows")
123 if isMatch {
124 TextField("+work-boss|LEVEL>2/!TODO", text: $matchText)
125 .frame(minWidth: 220)
126 .onSubmit { applyView() }
127 }
128 }
129 ToolbarItemGroup {
130 if agenda.isComputing { ProgressView().controlSize(.small) }
131 if isAgenda {
132 Button("Earlier", systemImage: "chevron.left") { agenda.shift(forward: false) }
133 .help("Previous days (b)")
134 Button("Today") { agenda.showToday() }
135 .help("Back to today (.)")
136 Button("Later", systemImage: "chevron.right") { agenda.shift(forward: true) }
137 .help("Next days (f)")
138 }
139 }
140 }
141 .overlay {
142 if agenda.files.isEmpty {
143 ContentUnavailableView("No agenda files", systemImage: "calendar", description: Text("The agenda reads the org files at the top of each folder you add."))
144 }
145 }
146 .alert(prompt?.prompt.message ?? "", isPresented: Binding(get: { prompt != nil }, set: { if !$0 { prompt = nil } })) {
147 TextField("", text: $promptText)
148 Button("OK") { answerPrompt() }
149 Button("Cancel", role: .cancel) { prompt = nil }
150 } message: {
151 if let choices = prompt?.prompt.choices, !choices.isEmpty {
152 Text(choices.prefix(12).joined(separator: " "))
153 }
154 }
155 .navigationTitle(viewName.replacingOccurrences(of: "…", with: ""))
156 .onChange(of: viewName) { applyView() }
157 .task(id: refreshKey) {
158 agenda.span = span
159 agenda.startOffset = startOffset
160 agenda.includeSubfolders = includeSubfolders
161 let open = session.path.flatMap { path in session.document.map { (path, $0.text) } }
162 await agenda.refresh(open: open)
163 }
164 .task {
165 loadViews()
166 // The time grid's "now" line and the day itself move on.
167 while !Task.isCancelled {
168 try? await Task.sleep(for: .seconds(60))
169 tick += 1
170 }
171 }
172 }
173
174 @ViewBuilder
175 private func rows(_ items: [AgendaItem], prefix: String, day: Int? = nil) -> some View {
176 ForEach(Array(items.enumerated()), id: \.offset) { index, item in
177 AgendaRow(item: item, day: day)
178 .tag("\(prefix):\(index)")
179 .selectionDisabled(item.path == nil)
180 }
181 }
182
183 @AppStorage("reminders") private var remindersEnabled = true
184
185 /// How far ahead notifications are set, as the design asks the agenda to show.
186 private var reminderStatus: String? {
187 guard let scheduler, remindersEnabled else { return nil }
188 if !scheduler.isAuthorized { return "Notifications are off for Orgstar in System Settings." }
189 guard let through = scheduler.scheduledThrough else { return nil }
190 return "Reminders are set through " + through.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute()) + "."
191 }
192
193 private var listTitle: String {
194 switch agenda.mode {
195 case .todo(let keywords): "TODO items: \(keywords ?? "all")"
196 case .match(let match, let todoOnly): (todoOnly ? "TODO entries" : "Entries") + " matching \(match)"
197 case .agenda: ""
198 }
199 }
200
201 private func loadViews() {
202 let loaded = AgendaViewLoader.load()
203 views = loaded.views
204 message = loaded.problems.first
205 }
206
207 private func applyView() {
208 selection = nil
209 agenda.start = nil
210 switch viewName {
211 case Self.match, Self.todoMatch:
212 let match = matchText.trimmingCharacters(in: .whitespaces)
213 guard !match.isEmpty else { return }
214 agenda.mode = .match(match, todoOnly: viewName == Self.todoMatch)
215 default:
216 if let view = views.first(where: { $0.name == viewName }) { agenda.mode = view.mode }
217 }
218 }
219
220 // MARK: - Items
221
222 private func item(_ id: String) -> AgendaItem? {
223 let parts = id.split(separator: ":")
224 guard parts.count == 2, let index = Int(parts[1]) else { return nil }
225 let items = parts[0] == "list" ? agenda.list : agenda.days.first { "\($0.day)" == parts[0] }?.items ?? []
226 return index < items.count ? items[index] : nil
227 }
228
229 private func jump(_ id: String) {
230 guard let item = item(id), let path = item.path, let offset = item.markerOffset ?? item.headingOffset else { return }
231 commands.jump(path: path, offset: offset)
232 openWindow(id: "main")
233 }
234
235 // MARK: - Commands
236
237 private func handle(_ press: KeyPress) -> KeyPress.Result {
238 let control = press.modifiers.contains(.control)
239 if prefix {
240 prefix = false
241 switch (control, press.characters) {
242 case (true, "\u{13}"), (true, "s"): run(SetPlanning(deadline: false))
243 case (true, "\u{04}"), (true, "d"): run(SetPlanning(deadline: true))
244 case (true, "\u{11}"), (true, "q"): run(SetTags())
245 case (true, "\u{14}"), (true, "t"): run(TodoCycle())
246 default: message = "C-c \(press.characters) is undefined"
247 }
248 return .handled
249 }
250 if control, press.characters == "c" || press.characters == "\u{03}" {
251 prefix = true
252 message = "C-c-"
253 return .handled
254 }
255 if press.key == .return, let selection {
256 jump(selection)
257 return .handled
258 }
259 if press.modifiers == .shift, press.key == .rightArrow || press.key == .leftArrow {
260 run(ShiftTimestampDay(later: press.key == .rightArrow), atMarker: true)
261 return .handled
262 }
263 guard press.modifiers.subtracting(.shift).isEmpty else { return .ignored }
264 switch press.characters {
265 case "f" where isAgenda: agenda.shift(forward: true)
266 case "b" where isAgenda: agenda.shift(forward: false)
267 case "." where isAgenda: agenda.showToday()
268 case "g", "r":
269 loadViews()
270 tick += 1
271 case "t": run(TodoCycle())
272 case "+": run(PriorityUp())
273 case "-": run(PriorityDown())
274 case ":": run(SetTags())
275 default: return .ignored
276 }
277 return .handled
278 }
279
280 /// Runs `command` on the selected entry, at its heading or, with `atMarker`, at the
281 /// timestamp the entry is listed for.
282 private func run(_ command: any OrgCommand, atMarker: Bool = false, answers: [String: String] = [:], itemID: String? = nil) {
283 guard let id = itemID ?? selection, let item = item(id), let path = item.path, let start = item.headingOffset else {
284 message = "No entry selected"
285 return
286 }
287 if atMarker, ![.deadline, .upcomingDeadline, .scheduled, .pastScheduled, .timestamp].contains(item.kind) {
288 message = "Only dated entries can move"
289 return
290 }
291 let offset = atMarker ? (item.markerOffset ?? start) : start
292 let step = session.perform(command, path: path, at: offset, expect: item.headingLine.map { (start, $0) }, answers: answers)
293 switch step {
294 case .prompt(let request):
295 promptText = request.initial
296 prompt = PendingPrompt(prompt: request, command: command, atMarker: atMarker, answers: answers, itemID: id)
297 case .failed(let text):
298 message = text
299 tick += 1
300 case .external:
301 message = "Not available from the agenda"
302 case .commit:
303 message = nil
304 tick += 1
305 }
306 }
307
308 private func answerPrompt() {
309 guard let pending = prompt else { return }
310 prompt = nil
311 var answers = pending.answers
312 answers[pending.prompt.key] = promptText
313 run(pending.command, atMarker: pending.atMarker, answers: answers, itemID: pending.itemID)
314 }
315}
316
317struct AgendaRow: View {
318 let item: AgendaItem
319 /// The agenda day the row is on, for habit graphs.
320 var day: Int?
321
322 var body: some View {
323 switch item.kind {
324 case .timeGrid:
325 HStack(spacing: 8) {
326 Text(Self.trim(item.time))
327 .monospacedDigit()
328 .frame(width: 140, alignment: .trailing)
329 VStack { Divider() }
330 }
331 .font(.caption)
332 .foregroundStyle(.tertiary)
333 case .currentTime:
334 HStack(spacing: 8) {
335 Text("now " + Self.trim(item.time))
336 .monospacedDigit()
337 .frame(width: 140, alignment: .trailing)
338 Rectangle().fill(Color.accentColor).frame(height: 1)
339 }
340 .font(.caption)
341 .foregroundStyle(Color.accentColor)
342 default:
343 HStack(spacing: 8) {
344 Text(item.category)
345 .lineLimit(1)
346 .foregroundStyle(.secondary)
347 .frame(width: 80, alignment: .leading)
348 if item.kind != .todo, item.kind != .tagsMatch {
349 Text(Self.trim(item.time))
350 .monospacedDigit()
351 .frame(width: 80, alignment: .leading)
352 }
353 if !item.extra.isEmpty {
354 Text(item.extra.trimmingCharacters(in: .whitespaces))
355 .monospacedDigit()
356 .foregroundStyle(leaderColor)
357 }
358 title
359 .lineLimit(1)
360 Spacer(minLength: 8)
361 if let habit = item.habit, let day {
362 HabitGraph(cells: habit.graph(today: day))
363 }
364 if !item.tags.isEmpty {
365 Text(":" + item.tags.joined(separator: ":") + ":")
366 .font(.caption)
367 .foregroundStyle(.secondary)
368 }
369 }
370 }
371 }
372
373 static func trim(_ time: String) -> String {
374 time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄."))
375 }
376
377 private var leaderColor: Color {
378 switch item.kind {
379 case .deadline: .red
380 case .upcomingDeadline: .orange
381 case .pastScheduled: .brown
382 default: .secondary
383 }
384 }
385
386 /// The TODO keyword colored as in the editor.
387 private var title: Text {
388 let text = item.text.trimmingCharacters(in: .whitespaces)
389 guard let todo = item.todo, text.hasPrefix(todo + " ") else { return Text(text) }
390 let rest = String(text.dropFirst(todo.count))
391 return Text(todo).foregroundColor(item.isDone ? .green : .red).bold() + Text(rest).foregroundColor(item.isDone ? .secondary : .primary)
392 }
393}
394
395/// org-habit's consistency graph: a cell per day, `*` where the habit was done, `!` today.
396struct HabitGraph: View {
397 let cells: [Habit.Cell]
398
399 var body: some View {
400 HStack(spacing: 1) {
401 ForEach(cells, id: \.day) { cell in
402 Text(String(cell.glyph))
403 .font(.system(size: 9, weight: .bold, design: .monospaced))
404 .frame(width: 8, height: 14)
405 .background(Self.color(cell.face))
406 .help(Self.label(cell))
407 }
408 }
409 .accessibilityElement(children: .ignore)
410 .accessibilityLabel("Done \(cells.filter { $0.glyph == "*" }.count) of the last \(Habit.precedingDays) days")
411 }
412
413 static func color(_ face: Habit.Face) -> Color {
414 switch face {
415 case .clear: .blue.opacity(0.6)
416 case .clearFuture: .blue.opacity(0.3)
417 case .ready: .green.opacity(0.7)
418 case .readyFuture: .green.opacity(0.35)
419 case .alert: .yellow.opacity(0.8)
420 case .alertFuture: .yellow.opacity(0.4)
421 case .overdue: .red.opacity(0.7)
422 case .overdueFuture: .red.opacity(0.35)
423 }
424 }
425
426 static func label(_ cell: Habit.Cell) -> String {
427 let date = Days.date(cell.day)
428 return String(format: "%04d-%02d-%02d", date.year, date.month, date.day) + (cell.glyph == "*" ? " DONE" : "")
429 }
430}