krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/Orgstar/AgendaView.swift

main
orgstar/Sources/Orgstar/AgendaView.swift history · blame · raw

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}