krz/orgstar

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

Sources/Orgstar/AgendaView.swift

16086b4cf2caff5328774b2cd5ae3ffe1ab65ca4
orgstar/Sources/Orgstar/AgendaView.swift history · blame · raw

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}