Sources/Orgstar/AgendaView.swift
784 lines · 33694 bytes
32 symbols in this file
1import OrgApp
2import OrgDocument
3import OrgCore
4import OrgPresentation
5import SwiftUI
6
7/// The agenda window: the day/week agenda, the TODO list, tag matches and saved views.
8/// Return or a double click jumps to the entry in the main window. Keys as in Emacs's agenda:
9/// f/b/. move the span, g refreshes, l shows the log (closed and clocked), / filters
10/// (`+cat-tag<0:10-/regexp/`), \ by tag, < by the line's category, = by regexp, _ by effort,
11/// | clears filters, I/O/X clock in, out and cancel, C-c C-w refiles, $ archives, m/u/U mark
12/// and unmark, B acts on the marked entries, t cycles the TODO state, +/- change the priority, : or
13/// C-c C-q sets tags, C-c C-s / C-c C-d schedule and set a deadline, S-→/S-← move the date.
14struct AgendaView: View {
15 let workspace: WorkspaceModel
16 let session: DocumentSession
17 let commands: AppCommands
18 let scheduler: ReminderScheduler?
19 let clock: ClockModel
20 @State private var agenda: AgendaModel
21 @State private var views = AgendaViewLoader.builtIn
22 @State private var viewName = AgendaViewLoader.builtIn[0].name
23 @State private var matchText = ""
24 @State private var selection: String?
25 @State private var tick = 0
26 /// The view picker moved to the agenda for a followed timestamp, which sets its own days.
27 @State private var keepRequestedDays = false
28 @State private var message: String?
29 @State private var prompt: PendingPrompt?
30 @State private var promptText = ""
31 @State private var prefix: String?
32 /// Entries marked for a bulk action.
33 @State private var marked: [EntryRef] = []
34 @State private var logMode = false
35 @State private var filter = AgendaFilter()
36 /// A filter question: its title and which filter it sets.
37 @State private var filterPrompt: (title: String, kind: Character)?
38 @State private var filterText = ""
39 /// A one-line question for an agenda action, and what to do with the answer.
40 struct TextPrompt {
41 let title: String
42 let apply: (String) -> Void
43 }
44 @State private var textPrompt: TextPrompt?
45 @State private var textAnswer = ""
46 @AppStorage("agendaSpan") private var span = 10
47 @AppStorage("agendaStartOffset") private var startOffset = -3
48 @AppStorage("agendaSubfolders") private var includeSubfolders = false
49 @AppStorage("agendaPrefixAgenda") private var prefixAgenda = PrefixFormat.defaults["agenda"]!
50 @AppStorage("agendaPrefixTodo") private var prefixTodo = PrefixFormat.defaults["todo"]!
51 @AppStorage("agendaPrefixTags") private var prefixTags = PrefixFormat.defaults["tags"]!
52 @Environment(\.openWindow) private var openWindow
53
54 static let match = "Match…"
55 static let todoMatch = "TODO Match…"
56
57 init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands, scheduler: ReminderScheduler?, clock: ClockModel) {
58 self.workspace = workspace
59 self.session = session
60 self.commands = commands
61 self.scheduler = scheduler
62 self.clock = clock
63 _agenda = State(initialValue: AgendaModel(workspace: workspace))
64 }
65
66 /// An agenda entry by its file, heading line and where that line was.
67 struct EntryRef: Hashable {
68 let path: String
69 let line: String
70 let offset: Int
71
72 init?(_ item: AgendaItem) {
73 guard let path = item.path, let line = item.headingLine, let offset = item.headingOffset else { return nil }
74 self.path = path
75 self.line = line
76 self.offset = offset
77 }
78 }
79
80 struct PendingPrompt: Identifiable {
81 let id = UUID()
82 let prompt: Prompt
83 let command: any OrgCommand
84 let atMarker: Bool
85 let answers: [String: String]
86 let itemID: String
87 }
88
89 private struct RefreshKey: Hashable {
90 var mode: AgendaMode
91 var start: Int?
92 var span: Int
93 var startOffset: Int
94 var includeSubfolders: Bool
95 var prefix: [String]
96 var logMode: Bool
97 var files: Int
98 var changed: Int?
99 var edits: Int
100 var path: String?
101 var tick: Int
102 }
103
104 private var refreshKey: RefreshKey {
105 RefreshKey(
106 mode: agenda.mode, start: agenda.start, span: span, startOffset: startOffset, includeSubfolders: includeSubfolders, prefix: [prefixAgenda, prefixTodo, prefixTags], logMode: logMode,
107 files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion,
108 path: session.path, tick: tick
109 )
110 }
111
112 private var isAgenda: Bool {
113 if case .agenda = agenda.mode { return true }
114 return false
115 }
116
117 private var isMatch: Bool { viewName == Self.match || viewName == Self.todoMatch }
118
119 private var customPrefix: Bool {
120 switch agenda.mode {
121 case .agenda: prefixAgenda != PrefixFormat.defaults["agenda"]
122 case .todo: prefixTodo != PrefixFormat.defaults["todo"]
123 case .match: prefixTags != PrefixFormat.defaults["tags"]
124 }
125 }
126
127 @Environment(\.orgTheme) private var theme
128
129 var body: some View {
130 List(selection: $selection) {
131 if isAgenda {
132 ForEach(agenda.days, id: \.day) { day in
133 Section {
134 rows(day.items.filter(filter.keeps), prefix: "\(day.day)", day: day.day)
135 } header: {
136 Text(day.header)
137 .font(.headline)
138 .foregroundStyle(day.isToday ? theme.color("agenda-today") ?? .accentColor : theme.color("agenda-date") ?? .primary)
139 }
140 }
141 } else {
142 Section {
143 rows(agenda.list.filter(filter.keeps), prefix: "list")
144 } header: {
145 Text(listTitle).font(.headline)
146 }
147 }
148 }
149 .themedListBackground(theme.color("agenda-background"))
150 .contextMenu(forSelectionType: String.self) { _ in
151 } primaryAction: { ids in
152 if let id = ids.first { jump(id) }
153 }
154 .onKeyPress(phases: .down) { press in handle(press) }
155 .safeAreaInset(edge: .bottom, spacing: 0) {
156 if let message = message ?? (filter.isEmpty ? reminderStatus : "Filter: " + filter.description) {
157 Text(message)
158 .font(.callout)
159 .frame(maxWidth: .infinity, alignment: .leading)
160 .padding(.horizontal, 12)
161 .padding(.vertical, 6)
162 .background(.bar)
163 }
164 }
165 .toolbar {
166 ToolbarItemGroup(placement: .navigation) {
167 Menu {
168 ForEach(views) { view in Button(view.name) { viewName = view.name } }
169 Divider()
170 Button(Self.match) { viewName = Self.match }
171 Button(Self.todoMatch) { viewName = Self.todoMatch }
172 } label: {
173 Label(viewName, systemImage: "calendar").labelStyle(.titleAndIcon)
174 }
175 .help("What the agenda shows")
176 if isMatch {
177 TextField("+work-boss|LEVEL>2/!TODO", text: $matchText)
178 .frame(minWidth: 220)
179 .onSubmit { applyView() }
180 }
181 }
182 ToolbarItemGroup {
183 if agenda.isComputing { ProgressView().controlSize(.small) }
184 if isAgenda {
185 Button("Earlier", systemImage: "chevron.left") { agenda.shift(forward: false) }
186 .help("Previous days (b)")
187 Button("Today") { agenda.showToday() }
188 .help("Back to today (.)")
189 Button("Later", systemImage: "chevron.right") { agenda.shift(forward: true) }
190 .help("Next days (f)")
191 }
192 }
193 }
194 .overlay {
195 if agenda.files.isEmpty {
196 ContentUnavailableView("No agenda files", systemImage: "calendar", description: Text("The agenda reads the org files at the top of each folder you add."))
197 }
198 }
199 .sheet(item: Binding(get: { prompt?.prompt.fast == nil ? nil : prompt }, set: { if $0 == nil { prompt = nil } })) { pending in
200 if let fast = pending.prompt.fast {
201 FastSelectionView(selection: fast, message: pending.prompt.message) { answer in
202 prompt = nil
203 guard let answer else { return }
204 promptText = answer
205 prompt = pending
206 answerPrompt()
207 }
208 .frame(minWidth: 420)
209 }
210 }
211 .alert(prompt?.prompt.message ?? "", isPresented: Binding(get: { prompt != nil && prompt?.prompt.fast == nil }, set: { if !$0 { prompt = nil } })) {
212 TextField("", text: $promptText)
213 Button("OK") { answerPrompt() }
214 Button("Cancel", role: .cancel) { prompt = nil }
215 } message: {
216 if let choices = prompt?.prompt.choices, !choices.isEmpty {
217 Text(choices.prefix(12).joined(separator: " "))
218 }
219 }
220 .alert(filterPrompt?.title ?? "", isPresented: Binding(get: { filterPrompt != nil }, set: { if !$0 { filterPrompt = nil } })) {
221 TextField("", text: $filterText)
222 Button("Filter") { applyFilter() }
223 Button("Cancel", role: .cancel) { filterPrompt = nil }
224 } message: {
225 if filterPrompt?.kind == "/" || filterPrompt?.kind == "\\" {
226 Text(shown.tags.sorted().prefix(20).joined(separator: " "))
227 }
228 }
229 .alert(textPrompt?.title ?? "", isPresented: Binding(get: { textPrompt != nil }, set: { if !$0 { textPrompt = nil } })) {
230 TextField("", text: $textAnswer)
231 Button("OK") {
232 let pending = textPrompt
233 let answer = textAnswer
234 textPrompt = nil
235 textAnswer = ""
236 // A follow-up question opens after this alert closes.
237 DispatchQueue.main.async { pending?.apply(answer) }
238 }
239 Button("Cancel", role: .cancel) {
240 textPrompt = nil
241 textAnswer = ""
242 }
243 }
244 .navigationTitle(viewName.replacingOccurrences(of: "…", with: ""))
245 .onChange(of: viewName) { applyView() }
246 .onChange(of: commands.agendaRequest?.start, initial: true) { showRequestedDays() }
247 .onChange(of: commands.agendaRequest?.days) { showRequestedDays() }
248 .task(id: refreshKey) {
249 agenda.span = span
250 agenda.startOffset = startOffset
251 agenda.includeSubfolders = includeSubfolders
252 agenda.options.prefixFormat = ["agenda": prefixAgenda, "todo": prefixTodo, "tags": prefixTags]
253 agenda.options.logMode = logMode
254 let open = session.openTexts
255 await agenda.refresh(open: open)
256 }
257 .task {
258 loadViews()
259 // The time grid's "now" line and the day itself move on.
260 while !Task.isCancelled {
261 try? await Task.sleep(for: .seconds(60))
262 tick += 1
263 }
264 }
265 }
266
267 @ViewBuilder
268 private func rows(_ items: [AgendaItem], prefix: String, day: Int? = nil) -> some View {
269 ForEach(Array(items.enumerated()), id: \.offset) { index, item in
270 AgendaRow(item: item, day: day, customPrefix: customPrefix, marked: EntryRef(item).map(marked.contains) ?? false)
271 .tag("\(prefix):\(index)")
272 .selectionDisabled(item.path == nil)
273 }
274 }
275
276 @AppStorage("reminders") private var remindersEnabled = true
277
278 /// How far ahead notifications are set, as the design asks the agenda to show.
279 private var reminderStatus: String? {
280 guard let scheduler, remindersEnabled else { return nil }
281 if !scheduler.isAuthorized { return "Notifications are off for Orgstar in System Settings." }
282 guard let through = scheduler.scheduledThrough else { return nil }
283 return "Reminders are set through " + through.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute()) + "."
284 }
285
286 private var listTitle: String {
287 switch agenda.mode {
288 case .todo(let keywords): "TODO items: \(keywords ?? "all")"
289 case .match(let match, let todoOnly): (todoOnly ? "TODO entries" : "Entries") + " matching \(match)"
290 case .agenda: ""
291 }
292 }
293
294 private func loadViews() {
295 let loaded = AgendaViewLoader.load()
296 views = loaded.views
297 message = loaded.problems.first
298 }
299
300 /// The days of a followed timestamp, in the agenda view.
301 private func showRequestedDays() {
302 guard let request = commands.agendaRequest else { return }
303 commands.agendaRequest = nil
304 selection = nil
305 if viewName != AgendaViewLoader.builtIn[0].name {
306 keepRequestedDays = true
307 viewName = AgendaViewLoader.builtIn[0].name
308 }
309 agenda.mode = .agenda(span: request.days, startOffset: nil)
310 agenda.start = request.start
311 }
312
313 private func applyView() {
314 if keepRequestedDays { return keepRequestedDays = false }
315 selection = nil
316 agenda.start = nil
317 switch viewName {
318 case Self.match, Self.todoMatch:
319 let match = matchText.trimmingCharacters(in: .whitespaces)
320 guard !match.isEmpty else { return }
321 agenda.mode = .match(match, todoOnly: viewName == Self.todoMatch)
322 default:
323 if let view = views.first(where: { $0.name == viewName }) { agenda.mode = view.mode }
324 }
325 }
326
327 // MARK: - Items
328
329 private func item(_ id: String) -> AgendaItem? {
330 let parts = id.split(separator: ":")
331 guard parts.count == 2, let index = Int(parts[1]) else { return nil }
332 let items = (parts[0] == "list" ? agenda.list : agenda.days.first { "\($0.day)" == parts[0] }?.items ?? []).filter(filter.keeps)
333 return index < items.count ? items[index] : nil
334 }
335
336 // MARK: - Filters
337
338 /// The tags and categories on the view's lines, which filter words are read against.
339 private var shown: (tags: Set<String>, categories: Set<String>) {
340 let items = (isAgenda ? agenda.days.flatMap(\.items) : agenda.list).filter { $0.path != nil }
341 return (Set(items.flatMap(\.tags)), Set(items.map(\.category)))
342 }
343
344 private func askFilter(_ kind: Character) {
345 switch kind {
346 case "/": filterText = filter.description
347 default: filterText = ""
348 }
349 let title = switch kind {
350 case "/": "Filter [+cat-tag<0:10-/regexp/]:"
351 case "\\": "Tag filter (+tag-tag):"
352 case "=": "Regexp filter (-re to drop):"
353 default: "Effort filter (<0:30, >1:00, =1:00):"
354 }
355 filterPrompt = (title, kind)
356 }
357
358 private func applyFilter() {
359 guard let kind = filterPrompt?.kind else { return }
360 filterPrompt = nil
361 let text = filterText.trimmingCharacters(in: .whitespaces)
362 var current = filter
363 switch kind {
364 case "/":
365 let (parsed, ignored) = AgendaFilter().parse(text, tags: shown.tags, categories: shown.categories)
366 current = parsed
367 if !ignored.isEmpty { message = "\(ignored.joined(separator: " ")) ignored: no line has that tag or category" }
368 case "\\":
369 // Every word as a tag.
370 current.tags = AgendaFilter().parse(text, tags: Set(text.split(whereSeparator: { "+- ".contains($0) }).map(String.init)), categories: []).filter.tags
371 case "=":
372 current.regexps = text.isEmpty ? [] : [text.hasPrefix("-") || text.hasPrefix("+") ? text : "+" + text]
373 default:
374 current.efforts = text.isEmpty ? [] : ["+" + text]
375 }
376 filter = current
377 selection = nil
378 }
379
380 /// `<`: keep only the selected line's category, or drop that filter.
381 private func toggleCategoryFilter() {
382 if !filter.categories.isEmpty {
383 filter.categories = []
384 } else if let selection, let item = item(selection) {
385 filter.categories = ["+" + item.category]
386 } else {
387 message = "No entry selected"
388 }
389 }
390
391 private func jump(_ id: String) {
392 guard let item = item(id), let path = item.path, let offset = item.markerOffset ?? item.headingOffset else { return }
393 commands.jump(path: path, offset: offset)
394 openWindow(id: "main")
395 }
396
397 // MARK: - Commands
398
399 private func handle(_ press: KeyPress) -> KeyPress.Result {
400 let control = press.modifiers.contains(.control)
401 // `C-x` and friends arrive as control characters or as letters with control.
402 func isControl(_ letter: String, _ code: String) -> Bool { control && (press.characters == letter || press.characters == code) }
403 if let pending = prefix {
404 prefix = nil
405 if pending == "C-c C-x" {
406 if isControl("s", "\u{13}") { archive() } else { message = "C-c C-x \(press.characters) is undefined" }
407 return .handled
408 }
409 if isControl("s", "\u{13}") {
410 run(SetPlanning(deadline: false))
411 } else if isControl("d", "\u{04}") {
412 run(SetPlanning(deadline: true))
413 } else if isControl("q", "\u{11}") {
414 run(SetTags())
415 } else if isControl("t", "\u{14}") {
416 run(TodoCycle())
417 } else if isControl("w", "\u{17}") {
418 refile(marked: false)
419 } else if isControl("x", "\u{18}") {
420 prefix = "C-c C-x"
421 message = "C-c C-x-"
422 } else if press.characters == "$" {
423 archive()
424 } else {
425 message = "C-c \(press.characters) is undefined"
426 }
427 return .handled
428 }
429 if isControl("c", "\u{03}") {
430 prefix = "C-c"
431 message = "C-c-"
432 return .handled
433 }
434 if press.key == .return, let selection {
435 jump(selection)
436 return .handled
437 }
438 if press.modifiers == .shift, press.key == .rightArrow || press.key == .leftArrow {
439 run(ShiftTimestampDay(later: press.key == .rightArrow), atMarker: true)
440 return .handled
441 }
442 guard press.modifiers.subtracting(.shift).isEmpty else { return .ignored }
443 switch press.characters {
444 case "f" where isAgenda: agenda.shift(forward: true)
445 case "b" where isAgenda: agenda.shift(forward: false)
446 case "." where isAgenda: agenda.showToday()
447 case "/", "\\", "=", "_": askFilter(press.characters.first!)
448 case "<": toggleCategoryFilter()
449 case "|":
450 filter = AgendaFilter()
451 message = "Filters removed"
452 case "l" where isAgenda:
453 logMode.toggle()
454 message = logMode ? "Log mode on" : "Log mode off"
455 case "g", "r":
456 loadViews()
457 tick += 1
458 case "I": clockIn()
459 case "O": message = clock.clockOut(session)
460 case "X": message = clock.clockOut(session, cancel: true)
461 case "$": archive()
462 case "m": mark(true)
463 case "u": mark(false)
464 case "U":
465 marked = []
466 message = "No entries marked"
467 case "B": askBulk()
468 case "t": run(TodoCycle())
469 case "+": run(PriorityUp())
470 case "-": run(PriorityDown())
471 case ":": run(SetTags())
472 default: return .ignored
473 }
474 return .handled
475 }
476
477 // MARK: - Clock, refile, archive, bulk
478
479 private var selectedEntry: EntryRef? { selection.flatMap(item).flatMap(EntryRef.init) }
480
481 private func clockIn() {
482 guard let entry = selectedEntry, let offset = session.locateHeading(path: entry.path, line: entry.line, near: entry.offset) else {
483 message = "No entry selected"
484 return
485 }
486 message = clock.clockIn(session, path: entry.path, at: offset)
487 tick += 1
488 }
489
490 private func archive() {
491 guard let entry = selectedEntry, let offset = session.locateHeading(path: entry.path, line: entry.line, near: entry.offset) else {
492 message = "No entry selected"
493 return
494 }
495 message = session.archive(from: (entry.path, offset))
496 tick += 1
497 }
498
499 /// Refiles the selected entry, or with `marked` the marked ones, to a target asked for in
500 /// the main window, where the list of targets is searchable.
501 private func refile(marked bulk: Bool) {
502 let entries = bulk ? marked : selectedEntry.map { [$0] } ?? []
503 guard !entries.isEmpty else {
504 message = "No entry selected"
505 return
506 }
507 let targets = workspace.refileTargets(openPath: session.path, document: session.document)
508 commands.ask(Prompt(key: "refile", message: "Refile to:", choices: targets.map(\.label))) { answer in
509 guard let answer, targets.contains(where: { $0.label == answer }) else { return }
510 message = eachEntry(entries) { entry, offset in
511 // Targets in the entry's own file from its text now, as earlier moves shift them.
512 let document = session.openDocuments.first { $0.path == entry.path }?.document
513 ?? FileManager.default.contents(atPath: entry.path).map { DocumentState(bytes: [UInt8]($0)) }
514 guard let target = workspace.refileTargets(openPath: entry.path, document: document).first(where: { $0.label == answer }) else {
515 return "Can't find \(answer) any more."
516 }
517 return session.refile(to: target, from: (entry.path, offset))
518 }
519 if bulk { marked = [] }
520 tick += 1
521 }
522 openWindow(id: "main")
523 }
524
525 /// Runs `action` on each entry, finding it again first: later entries in a file first, so
526 /// earlier ones keep their place. Returns the last message.
527 private func eachEntry(_ entries: [EntryRef], _ action: (EntryRef, Int) -> String?) -> String? {
528 var last: String?
529 var failed = 0
530 for entry in entries.sorted(by: { ($0.path, $1.offset) < ($1.path, $0.offset) }) {
531 guard let offset = session.locateHeading(path: entry.path, line: entry.line, near: entry.offset) else {
532 failed += 1
533 continue
534 }
535 last = action(entry, offset)
536 }
537 return failed > 0 ? "\(failed) marked \(failed == 1 ? "entry" : "entries") changed and \(failed == 1 ? "was" : "were") skipped" : last
538 }
539
540 private func mark(_ on: Bool) {
541 guard let entry = selectedEntry else {
542 message = "No entry selected"
543 return
544 }
545 if on {
546 if !marked.contains(entry) { marked.append(entry) }
547 } else {
548 marked.removeAll { $0 == entry }
549 }
550 message = "\(marked.count) marked"
551 }
552
553 /// `B`: an action for every marked entry.
554 private func askBulk() {
555 guard !marked.isEmpty else {
556 message = "No entries are marked"
557 return
558 }
559 textPrompt = TextPrompt(title: "Bulk action on \(marked.count): [$] archive [r] refile [t] TODO state [+] tag [-] untag [s] schedule [d] deadline") { answer in
560 switch answer.trimmingCharacters(in: .whitespaces) {
561 case "$": bulk { session.archive(from: ($0.path, $1)) }
562 case "r": refile(marked: true)
563 case "t":
564 textPrompt = TextPrompt(title: "TODO state (empty for none):") { keyword in
565 let word = keyword.trimmingCharacters(in: .whitespaces)
566 bulk { performAt($0, $1, SetTodo(keyword: word.isEmpty ? nil : word)) }
567 }
568 case "+", "-":
569 let on = answer.trimmingCharacters(in: .whitespaces) == "+"
570 textPrompt = TextPrompt(title: on ? "Tag to add:" : "Tag to remove:") { tag in
571 let tag = tag.trimmingCharacters(in: .whitespaces)
572 guard !tag.isEmpty else { return }
573 bulk { performAt($0, $1, ToggleTag(tag: tag, on: on)) }
574 }
575 case "s", "d":
576 let deadline = answer.trimmingCharacters(in: .whitespaces) == "d"
577 textPrompt = TextPrompt(title: deadline ? "Deadline (+2d, fri, 2026-10-12):" : "Schedule (+2d, fri, 2026-10-12):") { date in
578 bulk { performAt($0, $1, SetPlanning(deadline: deadline), answers: ["date": date]) }
579 }
580 default:
581 message = "No bulk action \(answer)"
582 }
583 }
584 }
585
586 private func bulk(_ action: (EntryRef, Int) -> String?) {
587 message = eachEntry(marked, action) ?? "Done with \(marked.count) entries"
588 marked = []
589 tick += 1
590 }
591
592 /// Runs `command` at the heading at `offset`, as `run` does for the selection.
593 private func performAt(_ entry: EntryRef, _ offset: Int, _ command: any OrgCommand, answers: [String: String] = [:]) -> String? {
594 switch session.perform(command, path: entry.path, at: offset, expect: (offset, entry.line), answers: answers) {
595 case .failed(let text): text
596 case .prompt: "\(command.title) needs an answer; run it on the entry itself"
597 default: nil
598 }
599 }
600
601 /// Runs `command` on the selected entry, at its heading or, with `atMarker`, at the
602 /// timestamp the entry is listed for.
603 private func run(_ command: any OrgCommand, atMarker: Bool = false, answers: [String: String] = [:], itemID: String? = nil) {
604 guard let id = itemID ?? selection, let item = item(id), let path = item.path, let start = item.headingOffset else {
605 message = "No entry selected"
606 return
607 }
608 if atMarker, ![.deadline, .upcomingDeadline, .scheduled, .pastScheduled, .timestamp].contains(item.kind) {
609 message = "Only dated entries can move"
610 return
611 }
612 let offset = atMarker ? (item.markerOffset ?? start) : start
613 let step = session.perform(command, path: path, at: offset, expect: item.headingLine.map { (start, $0) }, answers: answers)
614 switch step {
615 case .prompt(let request):
616 promptText = request.initial
617 prompt = PendingPrompt(prompt: request, command: command, atMarker: atMarker, answers: answers, itemID: id)
618 case .failed(let text):
619 message = text
620 tick += 1
621 case .external:
622 message = "Not available from the agenda"
623 case .commit:
624 message = nil
625 tick += 1
626 }
627 }
628
629 private func answerPrompt() {
630 guard let pending = prompt else { return }
631 prompt = nil
632 var answers = pending.answers
633 answers[pending.prompt.key] = promptText
634 run(pending.command, atMarker: pending.atMarker, answers: answers, itemID: pending.itemID)
635 }
636}
637
638struct AgendaRow: View {
639 let item: AgendaItem
640 /// The agenda day the row is on, for habit graphs.
641 var day: Int?
642 /// A user `org-agenda-prefix-format`: the prefix as Emacs prints it, not the columns.
643 var customPrefix = false
644 /// Marked for a bulk action.
645 var marked = false
646 @Environment(\.orgTheme) private var theme
647
648 var body: some View {
649 switch item.kind {
650 case .timeGrid:
651 HStack(spacing: 8) {
652 Text(Self.trim(item.time))
653 .monospacedDigit()
654 .frame(width: 140, alignment: .trailing)
655 VStack { Divider() }
656 }
657 .font(.caption)
658 .foregroundStyle(theme.color("agenda-time").map(AnyShapeStyle.init) ?? AnyShapeStyle(.tertiary))
659 case .currentTime:
660 HStack(spacing: 8) {
661 Text("now " + Self.trim(item.time))
662 .monospacedDigit()
663 .frame(width: 140, alignment: .trailing)
664 Rectangle().fill(theme.color("agenda-today") ?? .accentColor).frame(height: 1)
665 }
666 .font(.caption)
667 .foregroundStyle(theme.color("agenda-today") ?? .accentColor)
668 default:
669 HStack(spacing: 4) {
670 if marked { Text("›").bold().foregroundStyle(theme.color("agenda-today") ?? .accentColor) }
671 entryRow
672 }
673 }
674 }
675
676 @ViewBuilder
677 private var entryRow: some View {
678 if customPrefix {
679 HStack(spacing: 0) {
680 Text(item.prefix)
681 .font(.body.monospaced())
682 .foregroundStyle(theme.color("agenda-category") ?? .secondary)
683 title.lineLimit(1)
684 Spacer(minLength: 8)
685 if let habit = item.habit, let day { HabitGraph(cells: habit.graph(today: day), theme: theme) }
686 if !item.tags.isEmpty {
687 Text(":" + item.tags.joined(separator: ":") + ":").font(.caption).foregroundStyle(theme.color("tags") ?? .secondary)
688 }
689 }
690 } else {
691 HStack(spacing: 8) {
692 Text(item.category)
693 .lineLimit(1)
694 .foregroundStyle(theme.color("agenda-category") ?? .secondary)
695 .frame(width: 80, alignment: .leading)
696 if item.kind != .todo, item.kind != .tagsMatch {
697 Text(Self.trim(item.time))
698 .monospacedDigit()
699 .foregroundStyle(theme.color("agenda-time") ?? .primary)
700 .frame(width: 80, alignment: .leading)
701 }
702 if !item.extra.isEmpty {
703 Text(item.extra.trimmingCharacters(in: .whitespaces))
704 .monospacedDigit()
705 .foregroundStyle(leaderColor)
706 }
707 title
708 .lineLimit(1)
709 Spacer(minLength: 8)
710 if let habit = item.habit, let day {
711 HabitGraph(cells: habit.graph(today: day), theme: theme)
712 }
713 if !item.tags.isEmpty {
714 Text(":" + item.tags.joined(separator: ":") + ":")
715 .font(.caption)
716 .foregroundStyle(theme.color("tags") ?? .secondary)
717 }
718 }
719 }
720 }
721
722 static func trim(_ time: String) -> String {
723 time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄."))
724 }
725
726 private var leaderColor: Color {
727 switch item.kind {
728 case .deadline: theme.color("agenda-deadline") ?? .red
729 case .upcomingDeadline: theme.color("agenda-upcoming") ?? .orange
730 case .pastScheduled: theme.color("agenda-scheduled-past") ?? .brown
731 default: theme.color("agenda-scheduled") ?? .secondary
732 }
733 }
734
735 /// The TODO keyword colored as in the editor.
736 private var title: Text {
737 let text = item.text.trimmingCharacters(in: .whitespaces)
738 guard let todo = item.todo, text.hasPrefix(todo + " ") else { return Text(text) }
739 let rest = String(text.dropFirst(todo.count))
740 let rest_ = Text(rest).foregroundColor(item.isDone ? theme.color("comment") ?? .secondary : theme.color("foreground") ?? .primary)
741 let todo_ = Text(todo).foregroundColor(theme.keywordColor(todo, done: item.isDone)).bold()
742 return Text("\(todo_)\(rest_)")
743 }
744}
745
746/// org-habit's consistency graph: a cell per day, `*` where the habit was done, `!` today.
747struct HabitGraph: View {
748 let cells: [Habit.Cell]
749 var theme: ThemeSpec = .default
750
751 var body: some View {
752 HStack(spacing: 1) {
753 ForEach(cells, id: \.day) { cell in
754 Text(String(cell.glyph))
755 .font(.system(size: 9, weight: .bold, design: .monospaced))
756 .frame(width: 8, height: 14)
757 .background(color(cell.face))
758 .help(Self.label(cell))
759 }
760 }
761 .accessibilityElement(children: .ignore)
762 .accessibilityLabel("Done \(cells.filter { $0.glyph == "*" }.count) of the last \(Habit.precedingDays) days")
763 }
764
765 /// Due-state colors, lighter for days to come, as org-habit's faces.
766 func color(_ face: Habit.Face) -> Color {
767 func themed(_ key: String, _ fallback: Color) -> Color { theme.color(key) ?? fallback }
768 switch face {
769 case .clear: return themed("habit-clear", .blue).opacity(0.7)
770 case .clearFuture: return themed("habit-clear", .blue).opacity(0.35)
771 case .ready: return themed("habit-ready", .green).opacity(0.75)
772 case .readyFuture: return themed("habit-ready", .green).opacity(0.35)
773 case .alert: return themed("habit-alert", .yellow).opacity(0.85)
774 case .alertFuture: return themed("habit-alert", .yellow).opacity(0.4)
775 case .overdue: return themed("habit-overdue", .red).opacity(0.75)
776 case .overdueFuture: return themed("habit-overdue", .red).opacity(0.35)
777 }
778 }
779
780 static func label(_ cell: Habit.Cell) -> String {
781 let date = Days.date(cell.day)
782 return String(format: "%04d-%02d-%02d", date.year, date.month, date.day) + (cell.glyph == "*" ? " DONE" : "")
783 }
784}