Sources/Orgstar/AgendaView.swift
789 lines · 34025 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 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 let paths = workspace.files.filter { $0.kind == .org }.map(\.path)
487 Task {
488 let outcome = await clock.clockIn(session, target: .entry(path: entry.path, offset: offset), paths: paths, ask: commands.askClock)
489 if let jump = outcome.jump { commands.jump(path: jump.path, offset: jump.offset) }
490 message = outcome.messages.last { !$0.isEmpty }
491 tick += 1
492 }
493 }
494
495 private func archive() {
496 guard let entry = selectedEntry, let offset = session.locateHeading(path: entry.path, line: entry.line, near: entry.offset) else {
497 message = "No entry selected"
498 return
499 }
500 message = session.archive(from: (entry.path, offset))
501 tick += 1
502 }
503
504 /// Refiles the selected entry, or with `marked` the marked ones, to a target asked for in
505 /// the main window, where the list of targets is searchable.
506 private func refile(marked bulk: Bool) {
507 let entries = bulk ? marked : selectedEntry.map { [$0] } ?? []
508 guard !entries.isEmpty else {
509 message = "No entry selected"
510 return
511 }
512 let targets = workspace.refileTargets(openPath: session.path, document: session.document)
513 commands.ask(Prompt(key: "refile", message: "Refile to:", choices: targets.map(\.label))) { answer in
514 guard let answer, targets.contains(where: { $0.label == answer }) else { return }
515 message = eachEntry(entries) { entry, offset in
516 // Targets in the entry's own file from its text now, as earlier moves shift them.
517 let document = session.openDocuments.first { $0.path == entry.path }?.document
518 ?? FileManager.default.contents(atPath: entry.path).map { DocumentState(bytes: [UInt8]($0)) }
519 guard let target = workspace.refileTargets(openPath: entry.path, document: document).first(where: { $0.label == answer }) else {
520 return "Can't find \(answer) any more."
521 }
522 return session.refile(to: target, from: (entry.path, offset))
523 }
524 if bulk { marked = [] }
525 tick += 1
526 }
527 openWindow(id: "main")
528 }
529
530 /// Runs `action` on each entry, finding it again first: later entries in a file first, so
531 /// earlier ones keep their place. Returns the last message.
532 private func eachEntry(_ entries: [EntryRef], _ action: (EntryRef, Int) -> String?) -> String? {
533 var last: String?
534 var failed = 0
535 for entry in entries.sorted(by: { ($0.path, $1.offset) < ($1.path, $0.offset) }) {
536 guard let offset = session.locateHeading(path: entry.path, line: entry.line, near: entry.offset) else {
537 failed += 1
538 continue
539 }
540 last = action(entry, offset)
541 }
542 return failed > 0 ? "\(failed) marked \(failed == 1 ? "entry" : "entries") changed and \(failed == 1 ? "was" : "were") skipped" : last
543 }
544
545 private func mark(_ on: Bool) {
546 guard let entry = selectedEntry else {
547 message = "No entry selected"
548 return
549 }
550 if on {
551 if !marked.contains(entry) { marked.append(entry) }
552 } else {
553 marked.removeAll { $0 == entry }
554 }
555 message = "\(marked.count) marked"
556 }
557
558 /// `B`: an action for every marked entry.
559 private func askBulk() {
560 guard !marked.isEmpty else {
561 message = "No entries are marked"
562 return
563 }
564 textPrompt = TextPrompt(title: "Bulk action on \(marked.count): [$] archive [r] refile [t] TODO state [+] tag [-] untag [s] schedule [d] deadline") { answer in
565 switch answer.trimmingCharacters(in: .whitespaces) {
566 case "$": bulk { session.archive(from: ($0.path, $1)) }
567 case "r": refile(marked: true)
568 case "t":
569 textPrompt = TextPrompt(title: "TODO state (empty for none):") { keyword in
570 let word = keyword.trimmingCharacters(in: .whitespaces)
571 bulk { performAt($0, $1, SetTodo(keyword: word.isEmpty ? nil : word)) }
572 }
573 case "+", "-":
574 let on = answer.trimmingCharacters(in: .whitespaces) == "+"
575 textPrompt = TextPrompt(title: on ? "Tag to add:" : "Tag to remove:") { tag in
576 let tag = tag.trimmingCharacters(in: .whitespaces)
577 guard !tag.isEmpty else { return }
578 bulk { performAt($0, $1, ToggleTag(tag: tag, on: on)) }
579 }
580 case "s", "d":
581 let deadline = answer.trimmingCharacters(in: .whitespaces) == "d"
582 textPrompt = TextPrompt(title: deadline ? "Deadline (+2d, fri, 2026-10-12):" : "Schedule (+2d, fri, 2026-10-12):") { date in
583 bulk { performAt($0, $1, SetPlanning(deadline: deadline), answers: ["date": date]) }
584 }
585 default:
586 message = "No bulk action \(answer)"
587 }
588 }
589 }
590
591 private func bulk(_ action: (EntryRef, Int) -> String?) {
592 message = eachEntry(marked, action) ?? "Done with \(marked.count) entries"
593 marked = []
594 tick += 1
595 }
596
597 /// Runs `command` at the heading at `offset`, as `run` does for the selection.
598 private func performAt(_ entry: EntryRef, _ offset: Int, _ command: any OrgCommand, answers: [String: String] = [:]) -> String? {
599 switch session.perform(command, path: entry.path, at: offset, expect: (offset, entry.line), answers: answers) {
600 case .failed(let text): text
601 case .prompt: "\(command.title) needs an answer; run it on the entry itself"
602 default: nil
603 }
604 }
605
606 /// Runs `command` on the selected entry, at its heading or, with `atMarker`, at the
607 /// timestamp the entry is listed for.
608 private func run(_ command: any OrgCommand, atMarker: Bool = false, answers: [String: String] = [:], itemID: String? = nil) {
609 guard let id = itemID ?? selection, let item = item(id), let path = item.path, let start = item.headingOffset else {
610 message = "No entry selected"
611 return
612 }
613 if atMarker, ![.deadline, .upcomingDeadline, .scheduled, .pastScheduled, .timestamp].contains(item.kind) {
614 message = "Only dated entries can move"
615 return
616 }
617 let offset = atMarker ? (item.markerOffset ?? start) : start
618 let step = session.perform(command, path: path, at: offset, expect: item.headingLine.map { (start, $0) }, answers: answers)
619 switch step {
620 case .prompt(let request):
621 promptText = request.initial
622 prompt = PendingPrompt(prompt: request, command: command, atMarker: atMarker, answers: answers, itemID: id)
623 case .failed(let text):
624 message = text
625 tick += 1
626 case .external:
627 message = "Not available from the agenda"
628 case .commit:
629 message = nil
630 tick += 1
631 }
632 }
633
634 private func answerPrompt() {
635 guard let pending = prompt else { return }
636 prompt = nil
637 var answers = pending.answers
638 answers[pending.prompt.key] = promptText
639 run(pending.command, atMarker: pending.atMarker, answers: answers, itemID: pending.itemID)
640 }
641}
642
643struct AgendaRow: View {
644 let item: AgendaItem
645 /// The agenda day the row is on, for habit graphs.
646 var day: Int?
647 /// A user `org-agenda-prefix-format`: the prefix as Emacs prints it, not the columns.
648 var customPrefix = false
649 /// Marked for a bulk action.
650 var marked = false
651 @Environment(\.orgTheme) private var theme
652
653 var body: some View {
654 switch item.kind {
655 case .timeGrid:
656 HStack(spacing: 8) {
657 Text(Self.trim(item.time))
658 .monospacedDigit()
659 .frame(width: 140, alignment: .trailing)
660 VStack { Divider() }
661 }
662 .font(.caption)
663 .foregroundStyle(theme.color("agenda-time").map(AnyShapeStyle.init) ?? AnyShapeStyle(.tertiary))
664 case .currentTime:
665 HStack(spacing: 8) {
666 Text("now " + Self.trim(item.time))
667 .monospacedDigit()
668 .frame(width: 140, alignment: .trailing)
669 Rectangle().fill(theme.color("agenda-today") ?? .accentColor).frame(height: 1)
670 }
671 .font(.caption)
672 .foregroundStyle(theme.color("agenda-today") ?? .accentColor)
673 default:
674 HStack(spacing: 4) {
675 if marked { Text("›").bold().foregroundStyle(theme.color("agenda-today") ?? .accentColor) }
676 entryRow
677 }
678 }
679 }
680
681 @ViewBuilder
682 private var entryRow: some View {
683 if customPrefix {
684 HStack(spacing: 0) {
685 Text(item.prefix)
686 .font(.body.monospaced())
687 .foregroundStyle(theme.color("agenda-category") ?? .secondary)
688 title.lineLimit(1)
689 Spacer(minLength: 8)
690 if let habit = item.habit, let day { HabitGraph(cells: habit.graph(today: day), theme: theme) }
691 if !item.tags.isEmpty {
692 Text(":" + item.tags.joined(separator: ":") + ":").font(.caption).foregroundStyle(theme.color("tags") ?? .secondary)
693 }
694 }
695 } else {
696 HStack(spacing: 8) {
697 Text(item.category)
698 .lineLimit(1)
699 .foregroundStyle(theme.color("agenda-category") ?? .secondary)
700 .frame(width: 80, alignment: .leading)
701 if item.kind != .todo, item.kind != .tagsMatch {
702 Text(Self.trim(item.time))
703 .monospacedDigit()
704 .foregroundStyle(theme.color("agenda-time") ?? .primary)
705 .frame(width: 80, alignment: .leading)
706 }
707 if !item.extra.isEmpty {
708 Text(item.extra.trimmingCharacters(in: .whitespaces))
709 .monospacedDigit()
710 .foregroundStyle(leaderColor)
711 }
712 title
713 .lineLimit(1)
714 Spacer(minLength: 8)
715 if let habit = item.habit, let day {
716 HabitGraph(cells: habit.graph(today: day), theme: theme)
717 }
718 if !item.tags.isEmpty {
719 Text(":" + item.tags.joined(separator: ":") + ":")
720 .font(.caption)
721 .foregroundStyle(theme.color("tags") ?? .secondary)
722 }
723 }
724 }
725 }
726
727 static func trim(_ time: String) -> String {
728 time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄."))
729 }
730
731 private var leaderColor: Color {
732 switch item.kind {
733 case .deadline: theme.color("agenda-deadline") ?? .red
734 case .upcomingDeadline: theme.color("agenda-upcoming") ?? .orange
735 case .pastScheduled: theme.color("agenda-scheduled-past") ?? .brown
736 default: theme.color("agenda-scheduled") ?? .secondary
737 }
738 }
739
740 /// The TODO keyword colored as in the editor.
741 private var title: Text {
742 let text = item.text.trimmingCharacters(in: .whitespaces)
743 guard let todo = item.todo, text.hasPrefix(todo + " ") else { return Text(text) }
744 let rest = String(text.dropFirst(todo.count))
745 let rest_ = Text(rest).foregroundColor(item.isDone ? theme.color("comment") ?? .secondary : theme.color("foreground") ?? .primary)
746 let todo_ = Text(todo).foregroundColor(theme.keywordColor(todo, done: item.isDone)).bold()
747 return Text("\(todo_)\(rest_)")
748 }
749}
750
751/// org-habit's consistency graph: a cell per day, `*` where the habit was done, `!` today.
752struct HabitGraph: View {
753 let cells: [Habit.Cell]
754 var theme: ThemeSpec = .default
755
756 var body: some View {
757 HStack(spacing: 1) {
758 ForEach(cells, id: \.day) { cell in
759 Text(String(cell.glyph))
760 .font(.system(size: 9, weight: .bold, design: .monospaced))
761 .frame(width: 8, height: 14)
762 .background(color(cell.face))
763 .help(Self.label(cell))
764 }
765 }
766 .accessibilityElement(children: .ignore)
767 .accessibilityLabel("Done \(cells.filter { $0.glyph == "*" }.count) of the last \(Habit.precedingDays) days")
768 }
769
770 /// Due-state colors, lighter for days to come, as org-habit's faces.
771 func color(_ face: Habit.Face) -> Color {
772 func themed(_ key: String, _ fallback: Color) -> Color { theme.color(key) ?? fallback }
773 switch face {
774 case .clear: return themed("habit-clear", .blue).opacity(0.7)
775 case .clearFuture: return themed("habit-clear", .blue).opacity(0.35)
776 case .ready: return themed("habit-ready", .green).opacity(0.75)
777 case .readyFuture: return themed("habit-ready", .green).opacity(0.35)
778 case .alert: return themed("habit-alert", .yellow).opacity(0.85)
779 case .alertFuture: return themed("habit-alert", .yellow).opacity(0.4)
780 case .overdue: return themed("habit-overdue", .red).opacity(0.75)
781 case .overdueFuture: return themed("habit-overdue", .red).opacity(0.35)
782 }
783 }
784
785 static func label(_ cell: Habit.Cell) -> String {
786 let date = Days.date(cell.day)
787 return String(format: "%04d-%02d-%02d", date.year, date.month, date.day) + (cell.glyph == "*" ? " DONE" : "")
788 }
789}