Sources/Orgstar/AgendaView.swift
790 lines · 34099 bytes
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 agenda.options = OrgPreferences.agendaOptions(agenda.options)
255 let open = session.openTexts
256 await agenda.refresh(open: open)
257 }
258 .task {
259 loadViews()
260 // The time grid's "now" line and the day itself move on.
261 while !Task.isCancelled {
262 try? await Task.sleep(for: .seconds(60))
263 tick += 1
264 }
265 }
266 }
267
268 @ViewBuilder
269 private func rows(_ items: [AgendaItem], prefix: String, day: Int? = nil) -> some View {
270 ForEach(Array(items.enumerated()), id: \.offset) { index, item in
271 AgendaRow(item: item, day: day, customPrefix: customPrefix, marked: EntryRef(item).map(marked.contains) ?? false)
272 .tag("\(prefix):\(index)")
273 .selectionDisabled(item.path == nil)
274 }
275 }
276
277 @AppStorage("reminders") private var remindersEnabled = true
278
279 /// How far ahead notifications are set, as the design asks the agenda to show.
280 private var reminderStatus: String? {
281 guard let scheduler, remindersEnabled else { return nil }
282 if !scheduler.isAuthorized { return "Notifications are off for Orgstar in System Settings." }
283 guard let through = scheduler.scheduledThrough else { return nil }
284 return "Reminders are set through " + through.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute()) + "."
285 }
286
287 private var listTitle: String {
288 switch agenda.mode {
289 case .todo(let keywords): "TODO items: \(keywords ?? "all")"
290 case .match(let match, let todoOnly): (todoOnly ? "TODO entries" : "Entries") + " matching \(match)"
291 case .agenda: ""
292 }
293 }
294
295 private func loadViews() {
296 let loaded = AgendaViewLoader.load()
297 views = loaded.views
298 message = loaded.problems.first
299 }
300
301 /// The days of a followed timestamp, in the agenda view.
302 private func showRequestedDays() {
303 guard let request = commands.agendaRequest else { return }
304 commands.agendaRequest = nil
305 selection = nil
306 if viewName != AgendaViewLoader.builtIn[0].name {
307 keepRequestedDays = true
308 viewName = AgendaViewLoader.builtIn[0].name
309 }
310 agenda.mode = .agenda(span: request.days, startOffset: nil)
311 agenda.start = request.start
312 }
313
314 private func applyView() {
315 if keepRequestedDays { return keepRequestedDays = false }
316 selection = nil
317 agenda.start = nil
318 switch viewName {
319 case Self.match, Self.todoMatch:
320 let match = matchText.trimmingCharacters(in: .whitespaces)
321 guard !match.isEmpty else { return }
322 agenda.mode = .match(match, todoOnly: viewName == Self.todoMatch)
323 default:
324 if let view = views.first(where: { $0.name == viewName }) { agenda.mode = view.mode }
325 }
326 }
327
328 // MARK: - Items
329
330 private func item(_ id: String) -> AgendaItem? {
331 let parts = id.split(separator: ":")
332 guard parts.count == 2, let index = Int(parts[1]) else { return nil }
333 let items = (parts[0] == "list" ? agenda.list : agenda.days.first { "\($0.day)" == parts[0] }?.items ?? []).filter(filter.keeps)
334 return index < items.count ? items[index] : nil
335 }
336
337 // MARK: - Filters
338
339 /// The tags and categories on the view's lines, which filter words are read against.
340 private var shown: (tags: Set<String>, categories: Set<String>) {
341 let items = (isAgenda ? agenda.days.flatMap(\.items) : agenda.list).filter { $0.path != nil }
342 return (Set(items.flatMap(\.tags)), Set(items.map(\.category)))
343 }
344
345 private func askFilter(_ kind: Character) {
346 switch kind {
347 case "/": filterText = filter.description
348 default: filterText = ""
349 }
350 let title = switch kind {
351 case "/": "Filter [+cat-tag<0:10-/regexp/]:"
352 case "\\": "Tag filter (+tag-tag):"
353 case "=": "Regexp filter (-re to drop):"
354 default: "Effort filter (<0:30, >1:00, =1:00):"
355 }
356 filterPrompt = (title, kind)
357 }
358
359 private func applyFilter() {
360 guard let kind = filterPrompt?.kind else { return }
361 filterPrompt = nil
362 let text = filterText.trimmingCharacters(in: .whitespaces)
363 var current = filter
364 switch kind {
365 case "/":
366 let (parsed, ignored) = AgendaFilter().parse(text, tags: shown.tags, categories: shown.categories)
367 current = parsed
368 if !ignored.isEmpty { message = "\(ignored.joined(separator: " ")) ignored: no line has that tag or category" }
369 case "\\":
370 // Every word as a tag.
371 current.tags = AgendaFilter().parse(text, tags: Set(text.split(whereSeparator: { "+- ".contains($0) }).map(String.init)), categories: []).filter.tags
372 case "=":
373 current.regexps = text.isEmpty ? [] : [text.hasPrefix("-") || text.hasPrefix("+") ? text : "+" + text]
374 default:
375 current.efforts = text.isEmpty ? [] : ["+" + text]
376 }
377 filter = current
378 selection = nil
379 }
380
381 /// `<`: keep only the selected line's category, or drop that filter.
382 private func toggleCategoryFilter() {
383 if !filter.categories.isEmpty {
384 filter.categories = []
385 } else if let selection, let item = item(selection) {
386 filter.categories = ["+" + item.category]
387 } else {
388 message = "No entry selected"
389 }
390 }
391
392 private func jump(_ id: String) {
393 guard let item = item(id), let path = item.path, let offset = item.markerOffset ?? item.headingOffset else { return }
394 commands.jump(path: path, offset: offset)
395 openWindow(id: "main")
396 }
397
398 // MARK: - Commands
399
400 private func handle(_ press: KeyPress) -> KeyPress.Result {
401 let control = press.modifiers.contains(.control)
402 // `C-x` and friends arrive as control characters or as letters with control.
403 func isControl(_ letter: String, _ code: String) -> Bool { control && (press.characters == letter || press.characters == code) }
404 if let pending = prefix {
405 prefix = nil
406 if pending == "C-c C-x" {
407 if isControl("s", "\u{13}") { archive() } else { message = "C-c C-x \(press.characters) is undefined" }
408 return .handled
409 }
410 if isControl("s", "\u{13}") {
411 run(SetPlanning(deadline: false))
412 } else if isControl("d", "\u{04}") {
413 run(SetPlanning(deadline: true))
414 } else if isControl("q", "\u{11}") {
415 run(SetTags())
416 } else if isControl("t", "\u{14}") {
417 run(TodoCycle())
418 } else if isControl("w", "\u{17}") {
419 refile(marked: false)
420 } else if isControl("x", "\u{18}") {
421 prefix = "C-c C-x"
422 message = "C-c C-x-"
423 } else if press.characters == "$" {
424 archive()
425 } else {
426 message = "C-c \(press.characters) is undefined"
427 }
428 return .handled
429 }
430 if isControl("c", "\u{03}") {
431 prefix = "C-c"
432 message = "C-c-"
433 return .handled
434 }
435 if press.key == .return, let selection {
436 jump(selection)
437 return .handled
438 }
439 if press.modifiers == .shift, press.key == .rightArrow || press.key == .leftArrow {
440 run(ShiftTimestampDay(later: press.key == .rightArrow), atMarker: true)
441 return .handled
442 }
443 guard press.modifiers.subtracting(.shift).isEmpty else { return .ignored }
444 switch press.characters {
445 case "f" where isAgenda: agenda.shift(forward: true)
446 case "b" where isAgenda: agenda.shift(forward: false)
447 case "." where isAgenda: agenda.showToday()
448 case "/", "\\", "=", "_": askFilter(press.characters.first!)
449 case "<": toggleCategoryFilter()
450 case "|":
451 filter = AgendaFilter()
452 message = "Filters removed"
453 case "l" where isAgenda:
454 logMode.toggle()
455 message = logMode ? "Log mode on" : "Log mode off"
456 case "g", "r":
457 loadViews()
458 tick += 1
459 case "I": clockIn()
460 case "O": message = clock.clockOut(session)
461 case "X": message = clock.clockOut(session, cancel: true)
462 case "$": archive()
463 case "m": mark(true)
464 case "u": mark(false)
465 case "U":
466 marked = []
467 message = "No entries marked"
468 case "B": askBulk()
469 case "t": run(TodoCycle())
470 case "+": run(PriorityUp())
471 case "-": run(PriorityDown())
472 case ":": run(SetTags())
473 default: return .ignored
474 }
475 return .handled
476 }
477
478 // MARK: - Clock, refile, archive, bulk
479
480 private var selectedEntry: EntryRef? { selection.flatMap(item).flatMap(EntryRef.init) }
481
482 private func clockIn() {
483 guard let entry = selectedEntry, let offset = session.locateHeading(path: entry.path, line: entry.line, near: entry.offset) else {
484 message = "No entry selected"
485 return
486 }
487 let paths = workspace.files.filter { $0.kind == .org }.map(\.path)
488 Task {
489 let outcome = await clock.clockIn(session, target: .entry(path: entry.path, offset: offset), paths: paths, ask: commands.askClock)
490 if let jump = outcome.jump { commands.jump(path: jump.path, offset: jump.offset) }
491 message = outcome.messages.last { !$0.isEmpty }
492 tick += 1
493 }
494 }
495
496 private func archive() {
497 guard let entry = selectedEntry, let offset = session.locateHeading(path: entry.path, line: entry.line, near: entry.offset) else {
498 message = "No entry selected"
499 return
500 }
501 message = session.archive(from: (entry.path, offset))
502 tick += 1
503 }
504
505 /// Refiles the selected entry, or with `marked` the marked ones, to a target asked for in
506 /// the main window, where the list of targets is searchable.
507 private func refile(marked bulk: Bool) {
508 let entries = bulk ? marked : selectedEntry.map { [$0] } ?? []
509 guard !entries.isEmpty else {
510 message = "No entry selected"
511 return
512 }
513 let targets = workspace.refileTargets(openPath: session.path, document: session.document)
514 commands.ask(Prompt(key: "refile", message: "Refile to:", choices: targets.map(\.label))) { answer in
515 guard let answer, targets.contains(where: { $0.label == answer }) else { return }
516 message = eachEntry(entries) { entry, offset in
517 // Targets in the entry's own file from its text now, as earlier moves shift them.
518 let document = session.openDocuments.first { $0.path == entry.path }?.document
519 ?? FileManager.default.contents(atPath: entry.path).map { DocumentState(bytes: [UInt8]($0)) }
520 guard let target = workspace.refileTargets(openPath: entry.path, document: document).first(where: { $0.label == answer }) else {
521 return "Can't find \(answer) any more."
522 }
523 return session.refile(to: target, from: (entry.path, offset))
524 }
525 if bulk { marked = [] }
526 tick += 1
527 }
528 openWindow(id: "main")
529 }
530
531 /// Runs `action` on each entry, finding it again first: later entries in a file first, so
532 /// earlier ones keep their place. Returns the last message.
533 private func eachEntry(_ entries: [EntryRef], _ action: (EntryRef, Int) -> String?) -> String? {
534 var last: String?
535 var failed = 0
536 for entry in entries.sorted(by: { ($0.path, $1.offset) < ($1.path, $0.offset) }) {
537 guard let offset = session.locateHeading(path: entry.path, line: entry.line, near: entry.offset) else {
538 failed += 1
539 continue
540 }
541 last = action(entry, offset)
542 }
543 return failed > 0 ? "\(failed) marked \(failed == 1 ? "entry" : "entries") changed and \(failed == 1 ? "was" : "were") skipped" : last
544 }
545
546 private func mark(_ on: Bool) {
547 guard let entry = selectedEntry else {
548 message = "No entry selected"
549 return
550 }
551 if on {
552 if !marked.contains(entry) { marked.append(entry) }
553 } else {
554 marked.removeAll { $0 == entry }
555 }
556 message = "\(marked.count) marked"
557 }
558
559 /// `B`: an action for every marked entry.
560 private func askBulk() {
561 guard !marked.isEmpty else {
562 message = "No entries are marked"
563 return
564 }
565 textPrompt = TextPrompt(title: "Bulk action on \(marked.count): [$] archive [r] refile [t] TODO state [+] tag [-] untag [s] schedule [d] deadline") { answer in
566 switch answer.trimmingCharacters(in: .whitespaces) {
567 case "$": bulk { session.archive(from: ($0.path, $1)) }
568 case "r": refile(marked: true)
569 case "t":
570 textPrompt = TextPrompt(title: "TODO state (empty for none):") { keyword in
571 let word = keyword.trimmingCharacters(in: .whitespaces)
572 bulk { performAt($0, $1, SetTodo(keyword: word.isEmpty ? nil : word)) }
573 }
574 case "+", "-":
575 let on = answer.trimmingCharacters(in: .whitespaces) == "+"
576 textPrompt = TextPrompt(title: on ? "Tag to add:" : "Tag to remove:") { tag in
577 let tag = tag.trimmingCharacters(in: .whitespaces)
578 guard !tag.isEmpty else { return }
579 bulk { performAt($0, $1, ToggleTag(tag: tag, on: on)) }
580 }
581 case "s", "d":
582 let deadline = answer.trimmingCharacters(in: .whitespaces) == "d"
583 textPrompt = TextPrompt(title: deadline ? "Deadline (+2d, fri, 2026-10-12):" : "Schedule (+2d, fri, 2026-10-12):") { date in
584 bulk { performAt($0, $1, SetPlanning(deadline: deadline), answers: ["date": date]) }
585 }
586 default:
587 message = "No bulk action \(answer)"
588 }
589 }
590 }
591
592 private func bulk(_ action: (EntryRef, Int) -> String?) {
593 message = eachEntry(marked, action) ?? "Done with \(marked.count) entries"
594 marked = []
595 tick += 1
596 }
597
598 /// Runs `command` at the heading at `offset`, as `run` does for the selection.
599 private func performAt(_ entry: EntryRef, _ offset: Int, _ command: any OrgCommand, answers: [String: String] = [:]) -> String? {
600 switch session.perform(command, path: entry.path, at: offset, expect: (offset, entry.line), answers: answers) {
601 case .failed(let text): text
602 case .prompt: "\(command.title) needs an answer; run it on the entry itself"
603 default: nil
604 }
605 }
606
607 /// Runs `command` on the selected entry, at its heading or, with `atMarker`, at the
608 /// timestamp the entry is listed for.
609 private func run(_ command: any OrgCommand, atMarker: Bool = false, answers: [String: String] = [:], itemID: String? = nil) {
610 guard let id = itemID ?? selection, let item = item(id), let path = item.path, let start = item.headingOffset else {
611 message = "No entry selected"
612 return
613 }
614 if atMarker, ![.deadline, .upcomingDeadline, .scheduled, .pastScheduled, .timestamp].contains(item.kind) {
615 message = "Only dated entries can move"
616 return
617 }
618 let offset = atMarker ? (item.markerOffset ?? start) : start
619 let step = session.perform(command, path: path, at: offset, expect: item.headingLine.map { (start, $0) }, answers: answers)
620 switch step {
621 case .prompt(let request):
622 promptText = request.initial
623 prompt = PendingPrompt(prompt: request, command: command, atMarker: atMarker, answers: answers, itemID: id)
624 case .failed(let text):
625 message = text
626 tick += 1
627 case .external:
628 message = "Not available from the agenda"
629 case .commit:
630 message = nil
631 tick += 1
632 }
633 }
634
635 private func answerPrompt() {
636 guard let pending = prompt else { return }
637 prompt = nil
638 var answers = pending.answers
639 answers[pending.prompt.key] = promptText
640 run(pending.command, atMarker: pending.atMarker, answers: answers, itemID: pending.itemID)
641 }
642}
643
644struct AgendaRow: View {
645 let item: AgendaItem
646 /// The agenda day the row is on, for habit graphs.
647 var day: Int?
648 /// A user `org-agenda-prefix-format`: the prefix as Emacs prints it, not the columns.
649 var customPrefix = false
650 /// Marked for a bulk action.
651 var marked = false
652 @Environment(\.orgTheme) private var theme
653
654 var body: some View {
655 switch item.kind {
656 case .timeGrid:
657 HStack(spacing: 8) {
658 Text(Self.trim(item.time))
659 .monospacedDigit()
660 .frame(width: 140, alignment: .trailing)
661 VStack { Divider() }
662 }
663 .font(.caption)
664 .foregroundStyle(theme.color("agenda-time").map(AnyShapeStyle.init) ?? AnyShapeStyle(.tertiary))
665 case .currentTime:
666 HStack(spacing: 8) {
667 Text("now " + Self.trim(item.time))
668 .monospacedDigit()
669 .frame(width: 140, alignment: .trailing)
670 Rectangle().fill(theme.color("agenda-today") ?? .accentColor).frame(height: 1)
671 }
672 .font(.caption)
673 .foregroundStyle(theme.color("agenda-today") ?? .accentColor)
674 default:
675 HStack(spacing: 4) {
676 if marked { Text("›").bold().foregroundStyle(theme.color("agenda-today") ?? .accentColor) }
677 entryRow
678 }
679 }
680 }
681
682 @ViewBuilder
683 private var entryRow: some View {
684 if customPrefix {
685 HStack(spacing: 0) {
686 Text(item.prefix)
687 .font(.body.monospaced())
688 .foregroundStyle(theme.color("agenda-category") ?? .secondary)
689 title.lineLimit(1)
690 Spacer(minLength: 8)
691 if let habit = item.habit, let day { HabitGraph(cells: habit.graph(today: day), theme: theme) }
692 if !item.tags.isEmpty {
693 Text(":" + item.tags.joined(separator: ":") + ":").font(.caption).foregroundStyle(theme.color("tags") ?? .secondary)
694 }
695 }
696 } else {
697 HStack(spacing: 8) {
698 Text(item.category)
699 .lineLimit(1)
700 .foregroundStyle(theme.color("agenda-category") ?? .secondary)
701 .frame(width: 80, alignment: .leading)
702 if item.kind != .todo, item.kind != .tagsMatch {
703 Text(Self.trim(item.time))
704 .monospacedDigit()
705 .foregroundStyle(theme.color("agenda-time") ?? .primary)
706 .frame(width: 80, alignment: .leading)
707 }
708 if !item.extra.isEmpty {
709 Text(item.extra.trimmingCharacters(in: .whitespaces))
710 .monospacedDigit()
711 .foregroundStyle(leaderColor)
712 }
713 title
714 .lineLimit(1)
715 Spacer(minLength: 8)
716 if let habit = item.habit, let day {
717 HabitGraph(cells: habit.graph(today: day), theme: theme)
718 }
719 if !item.tags.isEmpty {
720 Text(":" + item.tags.joined(separator: ":") + ":")
721 .font(.caption)
722 .foregroundStyle(theme.color("tags") ?? .secondary)
723 }
724 }
725 }
726 }
727
728 static func trim(_ time: String) -> String {
729 time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄."))
730 }
731
732 private var leaderColor: Color {
733 switch item.kind {
734 case .deadline: theme.color("agenda-deadline") ?? .red
735 case .upcomingDeadline: theme.color("agenda-upcoming") ?? .orange
736 case .pastScheduled: theme.color("agenda-scheduled-past") ?? .brown
737 default: theme.color("agenda-scheduled") ?? .secondary
738 }
739 }
740
741 /// The TODO keyword colored as in the editor.
742 private var title: Text {
743 let text = item.text.trimmingCharacters(in: .whitespaces)
744 guard let todo = item.todo, text.hasPrefix(todo + " ") else { return Text(text) }
745 let rest = String(text.dropFirst(todo.count))
746 let rest_ = Text(rest).foregroundColor(item.isDone ? theme.color("comment") ?? .secondary : theme.color("foreground") ?? .primary)
747 let todo_ = Text(todo).foregroundColor(theme.keywordColor(todo, done: item.isDone)).bold()
748 return Text("\(todo_)\(rest_)")
749 }
750}
751
752/// org-habit's consistency graph: a cell per day, `*` where the habit was done, `!` today.
753struct HabitGraph: View {
754 let cells: [Habit.Cell]
755 var theme: ThemeSpec = .default
756
757 var body: some View {
758 HStack(spacing: 1) {
759 ForEach(cells, id: \.day) { cell in
760 Text(String(cell.glyph))
761 .font(.system(size: 9, weight: .bold, design: .monospaced))
762 .frame(width: 8, height: 14)
763 .background(color(cell.face))
764 .help(Self.label(cell))
765 }
766 }
767 .accessibilityElement(children: .ignore)
768 .accessibilityLabel("Done \(cells.filter { $0.glyph == "*" }.count) of the last \(Habit.precedingDays) days")
769 }
770
771 /// Due-state colors, lighter for days to come, as org-habit's faces.
772 func color(_ face: Habit.Face) -> Color {
773 func themed(_ key: String, _ fallback: Color) -> Color { theme.color(key) ?? fallback }
774 switch face {
775 case .clear: return themed("habit-clear", .blue).opacity(0.7)
776 case .clearFuture: return themed("habit-clear", .blue).opacity(0.35)
777 case .ready: return themed("habit-ready", .green).opacity(0.75)
778 case .readyFuture: return themed("habit-ready", .green).opacity(0.35)
779 case .alert: return themed("habit-alert", .yellow).opacity(0.85)
780 case .alertFuture: return themed("habit-alert", .yellow).opacity(0.4)
781 case .overdue: return themed("habit-overdue", .red).opacity(0.75)
782 case .overdueFuture: return themed("habit-overdue", .red).opacity(0.35)
783 }
784 }
785
786 static func label(_ cell: Habit.Cell) -> String {
787 let date = Days.date(cell.day)
788 return String(format: "%04d-%02d-%02d", date.year, date.month, date.day) + (cell.glyph == "*" ? " DONE" : "")
789 }
790}