krz/orgstar

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

Sources/OrgstarMobile/AgendaScreen.swift

191ddb506f3a91b93c755ba6e8123a4e8ac4f90d
orgstar/Sources/OrgstarMobile/AgendaScreen.swift history · blame · raw

880 lines · 39629 bytes

  1#if os(iOS)
  2import OrgApp
  3import OrgCore
  4import OrgDocument
  5import OrgPresentation
  6import SwiftUI
  7
  8/// The agenda over the workspace's agenda files: the day/week view, the TODO list, tag and
  9/// property matches, text search and the views in `views.toml`, with Org's filters and the
 10/// agenda's actions on each entry.
 11struct AgendaScreen: View {
 12    let workspace: WorkspaceModel
 13    let session: DocumentSession
 14    @Bindable var agenda: AgendaModel
 15    let scheduler: ReminderScheduler
 16    @State private var message: String?
 17    @Environment(ClockModel.self) private var clock
 18    @Environment(MobileModel.self) private var model
 19    @Environment(\.colorScheme) private var colorScheme
 20    @Environment(\.openURL) private var openURL
 21    @State private var tick = 0
 22    /// The time the now line shows, kept to the minute while the agenda is open.
 23    @State private var now = Date()
 24    @State private var views = AgendaViewLoader.builtIn
 25    @State private var viewName = "Agenda"
 26    @State private var filter = AgendaFilter()
 27    @State private var showingFilter = false
 28    @State private var asking: Asking?
 29    @State private var prompt: EditorScreen.PromptRequest?
 30    @State private var refiling: AgendaItem?
 31    @State private var archiving: AgendaItem?
 32    /// The day, week or month shown; nil for `org-agenda-span` and `org-agenda-start-day`.
 33    @State private var range: SpanRange?
 34    /// The mode `range` set, to notice when something else changes the view.
 35    @State private var rangeMode: AgendaMode?
 36    @State private var jumping = false
 37    @State private var rescheduling: AgendaRow?
 38    @AppStorage("agendaOverdueExpanded") private var overdueExpanded = true
 39    @State private var done = 0
 40    @State private var moved = 0
 41
 42    /// A match typed for the tag and property view, or a search string.
 43    private enum Asking: Identifiable {
 44        case match, search
 45        var id: Self { self }
 46    }
 47
 48    enum SpanRange: String, CaseIterable, Identifiable {
 49        case day, week, month
 50        var id: String { rawValue }
 51        var title: String { rawValue.capitalized }
 52    }
 53
 54    private struct Key: Equatable {
 55        var files: Int
 56        var changed: Int?
 57        var edits: Int
 58        var tick: Int
 59        var mode: AgendaMode
 60        var start: Int?
 61        var span: Int
 62        var startOffset: Int
 63        var subfolders: Bool
 64        var settings: Int
 65    }
 66
 67    private var isDark: Bool { colorScheme == .dark }
 68    private var theme: ThemeSpec { model.theme }
 69
 70    var body: some View {
 71        List {
 72            // With no folders the empty state stands alone, not over a column of empty days.
 73            if workspace.rootPaths.isEmpty {
 74            } else if case .agenda = agenda.mode {
 75                let _ = model.settingsVersion
 76                ForEach(agenda.groups(showAllDates: OrgPreferences.agendaShowAllDates(), keep: filter.keeps)) { group in
 77                    Section {
 78                        day(group)
 79                    } header: {
 80                        header(group)
 81                    }
 82                }
 83            } else {
 84                let rows = agenda.list.filter(filter.keeps).compactMap { AgendaRow(item: $0, day: agenda.today, today: agenda.today) }
 85                if rows.isEmpty, !agenda.isComputing { Text("No entries").foregroundStyle(.secondary) }
 86                ForEach(rows) { row($0, day: agenda.today) }
 87            }
 88            if let reminderStatus {
 89                Section { } footer: { Text(reminderStatus) }
 90            }
 91        }
 92        .listStyle(.insetGrouped)
 93        .listSectionSpacing(.compact)
 94        // The now line is shorter than a row.
 95        .environment(\.defaultMinListRowHeight, 16)
 96        .navigationTitle(title)
 97        .navigationBarTitleDisplayMode(.inline)
 98        .sensoryFeedback(.success, trigger: done)
 99        .sensoryFeedback(.impact(weight: .light), trigger: moved)
100        .onChange(of: agenda.start) {
101            // Days a followed timestamp asked for.
102            if agenda.start != nil, case .agenda = agenda.mode { viewName = "Agenda" }
103        }
104        .onChange(of: agenda.mode) {
105            if agenda.mode != rangeMode { range = nil }
106        }
107        .overlay {
108            if workspace.rootPaths.isEmpty {
109                ContentUnavailableView("No folders", systemImage: "folder", description: Text("Add a folder of org files under Folders."))
110            }
111        }
112        .safeAreaInset(edge: .bottom) {
113            if !workspace.rootPaths.isEmpty { bottomBar }
114        }
115        .toolbar {
116            ToolbarItem(placement: .topBarLeading) {
117                Menu {
118                    ForEach(views) { definition in
119                        Button(definition.name) { show(definition) }
120                    }
121                    Button("Tags and Properties…") { asking = .match }
122                    Button("Search…") { asking = .search }
123                } label: {
124                    Label("Views", systemImage: "list.bullet.rectangle")
125                }
126            }
127        }
128        .sheet(isPresented: $showingFilter) {
129            FilterSheet(filter: $filter, items: shownItems)
130        }
131        .sheet(isPresented: $jumping) {
132            DateSheet(title: "Go to Date", action: "Go", initial: date(firstDay)) { picked in
133                jumping = false
134                if let picked { jump(to: Days.absolute(picked, calendar: .current)) }
135            }
136        }
137        .sheet(item: $rescheduling) { row in
138            DateSheet(title: row.icon == .deadline ? "Deadline" : "Schedule", action: "Set", initial: date(row.planningDay ?? agenda.today)) { picked in
139                rescheduling = nil
140                if let picked, let item = row.item { reschedule(row, item, to: Days.absolute(picked, calendar: .current)) }
141            }
142        }
143        .sheet(item: $asking) { kind in
144            if kind == .search {
145                PromptSheet(prompt: Prompt(key: "search", message: "Search: a phrase, or +word -word {regexp}; * for headlines, ! for TODO entries")) { answer in
146                    asking = nil
147                    guard let answer, answer.contains(where: { !$0.isWhitespace }) else { return }
148                    agenda.mode = .search(answer, todoOnly: false)
149                    viewName = answer
150                }
151            } else {
152                PromptSheet(prompt: Prompt(key: "match", message: "Match: tags and properties, as +work-home or TODO=\"WAIT\"")) { answer in
153                    asking = nil
154                    guard let answer, !answer.isEmpty else { return }
155                    agenda.mode = .match(answer, todoOnly: false)
156                    viewName = answer
157                }
158            }
159        }
160        .sheet(item: $prompt) { request in
161            PromptSheet(prompt: request.prompt) { answer in
162                prompt = nil
163                request.reply(answer)
164            }
165        }
166        .sheet(item: $refiling) { item in
167            RefileSheet(targets: workspace.refileTargets(openPath: session.path, document: session.document)) { target in
168                refiling = nil
169                if let target { refile(item, to: target) }
170            }
171        }
172        .confirmationDialog("Archive this entry?", isPresented: Binding { archiving != nil } set: { if !$0 { archiving = nil } }, titleVisibility: .visible) {
173            Button("Archive", role: .destructive) {
174                if let item = archiving { archive(item) }
175                archiving = nil
176            }
177        } message: {
178            Text(archiving?.text ?? "")
179        }
180        .refreshable {
181            await workspace.refresh()
182            await agenda.refresh(open: session.openTexts)
183        }
184        .task {
185            let loaded = AgendaViewLoader.load()
186            views = loaded.views
187            if let problem = loaded.problems.first { message = problem }
188        }
189        // The now line moves each minute; a new day refreshes the statuses.
190        .task {
191            while !Task.isCancelled {
192                let next = Calendar.current.nextDate(after: Date(), matching: DateComponents(second: 0), matchingPolicy: .nextTime) ?? Date().addingTimeInterval(60)
193                try? await Task.sleep(for: .seconds(next.timeIntervalSinceNow))
194                now = Date()
195                if Days.absolute(now, calendar: .current) != agenda.today { tick += 1 }
196            }
197        }
198        // Calendar access is asked for when calendar-events is turned on.
199        .task(id: model.settingsVersion) {
200            guard OrgPreferences.calendarEvents(), !CalendarEvents.isAuthorized else { return }
201            if await model.calendarEvents.requestAccess() { tick += 1 }
202        }
203        .task(id: Key(files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, tick: tick,
204                      mode: agenda.mode, start: agenda.start, span: agenda.span, startOffset: agenda.startOffset,
205                      subfolders: agenda.includeSubfolders, settings: model.settingsVersion)) {
206            await agenda.refresh(open: session.openTexts)
207        }
208    }
209
210    private var shownItems: [AgendaItem] {
211        if case .agenda = agenda.mode { return agenda.days.flatMap(\.items).filter(\.isEntry) }
212        return agenda.list
213    }
214
215    private func show(_ definition: AgendaViewDefinition) {
216        viewName = definition.name
217        agenda.mode = definition.mode
218    }
219
220    /// How far ahead notifications are set, as the design asks the agenda to show: iOS keeps
221    /// at most 64, and sets no more while the app isn't opened.
222    private var reminderStatus: String? {
223        guard OrgPreferences.reminders() else { return nil }
224        if !scheduler.isAuthorized { return "Notifications are off for Orgstar in Settings." }
225        guard let through = scheduler.scheduledThrough else { return nil }
226        return "Reminders are set through " + through.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute())
227            + ". Open Orgstar to set later ones."
228    }
229
230    // MARK: - Days
231
232    private func day(_ group: AgendaDayGroup) -> some View {
233        Group {
234            if group.isToday, !group.overdue.isEmpty {
235                Button {
236                    withAnimation { overdueExpanded.toggle() }
237                } label: {
238                    HStack(spacing: 6) {
239                        Image(systemName: "chevron.right")
240                            .font(.caption.weight(.semibold))
241                            .rotationEffect(.degrees(overdueExpanded ? 90 : 0))
242                        Text("Overdue").font(.subheadline.weight(.semibold))
243                        Text("\(group.overdue.count)").font(.subheadline.monospacedDigit())
244                        Spacer()
245                    }
246                    .foregroundStyle(themed(theme.statusColor(.overdue, dark: isDark), .red))
247                    .contentShape(Rectangle())
248                }
249                .buttonStyle(.plain)
250                if overdueExpanded {
251                    ForEach(group.overdue) { row($0, day: group.day) }
252                }
253            }
254            if group.isEmpty {
255                Text("Nothing scheduled").font(.subheadline).foregroundStyle(.secondary)
256            }
257            let nowAt = nowIndex(group)
258            ForEach(Array(group.rows.enumerated()), id: \.element.id) { index, item in
259                if index == nowAt { NowLine(time: now, color: todayColor) }
260                row(item, day: group.day)
261            }
262            if let nowAt, nowAt == group.rows.count { NowLine(time: now, color: todayColor) }
263        }
264    }
265
266    private func nowIndex(_ group: AgendaDayGroup) -> Int? {
267        let c = Calendar.current.dateComponents([.hour, .minute], from: now)
268        return group.nowIndex(now: (c.hour ?? 0) * 100 + (c.minute ?? 0))
269    }
270
271    private var todayColor: Color { themed(theme.color("agenda-today", dark: isDark), .accentColor) }
272
273    private func header(_ group: AgendaDayGroup) -> some View {
274        let day = date(group.day).formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated))
275        let color = group.isToday ? todayColor : group.day < agenda.today ? Color.secondary : themed(theme.color("agenda-date", dark: isDark), .primary)
276        return HStack(alignment: .firstTextBaseline) {
277            Text(group.relativeName(today: agenda.today).map { $0 + " · " + day } ?? day)
278                .font(.subheadline.weight(group.isToday ? .bold : .semibold))
279                .foregroundStyle(color)
280            Spacer()
281            Button("Add to \(day)", systemImage: "plus.circle") { model.captureRequest = CaptureRequest(day: group.day) }
282                .labelStyle(.iconOnly)
283                .font(.body)
284                .foregroundStyle(group.isToday ? todayColor : .secondary)
285        }
286        .textCase(nil)
287    }
288
289    // MARK: - Span
290
291    /// The first day shown, as `Days` numbers it, and how many.
292    private var shown: (first: Int, span: Int) {
293        var span = agenda.span
294        var offset = agenda.startOffset
295        if case .agenda(let viewSpan, let viewStart) = agenda.mode {
296            span = viewSpan ?? span
297            offset = viewStart ?? offset
298        }
299        return (agenda.start ?? agenda.today + offset, span)
300    }
301
302    private var firstDay: Int { shown.first }
303
304    /// The span: `Thu 8 Oct`, `Week of 5 Oct`, `October 2026`, or the days, `5 – 14 Oct`.
305    private var title: String {
306        guard case .agenda = agenda.mode else { return viewName }
307        if viewName != "Agenda", range == nil, agenda.start == nil { return viewName }
308        let (first, span) = shown
309        let start = date(first)
310        if span == 1 { return start.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated)) }
311        if span == 7, first == weekStart(first) { return "Week of " + start.formatted(.dateTime.day().month(.abbreviated)) }
312        let d = Days.date(first)
313        if d.day == 1, span == Days.absolute(year: d.year, month: d.month + 1, day: 1) - first {
314            return start.formatted(.dateTime.month(.wide).year())
315        }
316        return start.formatted(.dateTime.day().month(.abbreviated)) + " – " + date(first + span - 1).formatted(.dateTime.day().month(.abbreviated))
317    }
318
319    private func weekStart(_ day: Int) -> Int {
320        day - (Days.weekday(day) + 6) % 7
321    }
322
323    /// Shows `range` around `day`; nil returns to the configured span.
324    private func show(_ range: SpanRange?, around day: Int) {
325        self.range = range
326        let mode: AgendaMode
327        let start: Int?
328        switch range {
329        case .day:
330            (mode, start) = (.agenda(span: 1, startOffset: 0), day)
331        case .week:
332            (mode, start) = (.agenda(span: 7, startOffset: 0), weekStart(day))
333        case .month:
334            let d = Days.date(day)
335            let first = Days.absolute(year: d.year, month: d.month, day: 1)
336            (mode, start) = (.agenda(span: Days.absolute(year: d.year, month: d.month + 1, day: 1) - first, startOffset: 0), first)
337        case nil:
338            (mode, start) = (.agenda(span: nil, startOffset: nil), day == agenda.today ? nil : day)
339        }
340        rangeMode = mode
341        viewName = "Agenda"
342        agenda.mode = mode
343        agenda.start = start
344    }
345
346    private func step(forward: Bool) {
347        if range == nil, case .agenda = agenda.mode {
348            agenda.shift(forward: forward)
349        } else {
350            let (first, span) = shown
351            show(range, around: forward ? first + span : first - 1)
352        }
353    }
354
355    private func jump(to day: Int) {
356        show(range, around: day)
357    }
358
359    private func goToday() {
360        if range == nil { show(nil, around: agenda.today) } else { show(range, around: agenda.today) }
361    }
362
363    private var bottomBar: some View {
364        VStack(spacing: 8) {
365            if let status = message ?? (filter.isEmpty ? nil : "Filter: " + filter.description) {
366                Text(status).font(.footnote).padding(.horizontal, 12).padding(.vertical, 6)
367                    .glassEffect(.regular, in: Capsule())
368                    .onTapGesture { message = nil }
369            }
370            HStack(spacing: 2) {
371                let isAgenda = if case .agenda = agenda.mode { true } else { false }
372                if isAgenda {
373                    barButton("Earlier", "chevron.left") { step(forward: false) }
374                }
375                barButton("Filter", filter.isEmpty ? "line.3.horizontal.decrease" : "line.3.horizontal.decrease.circle.fill") { showingFilter = true }
376                if isAgenda {
377                    barButton("Today", "smallcircle.filled.circle") { goToday() }
378                    barButton("Go to Date", "calendar") { jumping = true }
379                    Menu {
380                        Picker("Span", selection: Binding { range } set: { show($0, around: $0 == nil ? agenda.today : firstDay) }) {
381                            ForEach(SpanRange.allCases) { Text($0.title).tag(Optional($0)) }
382                            Text("\(agenda.span) Days").tag(SpanRange?.none)
383                        }
384                    } label: {
385                        Text(range?.title ?? "\(agenda.span) Days")
386                            .font(.footnote.weight(.semibold).monospacedDigit())
387                            .lineLimit(1)
388                            .frame(minWidth: 52, minHeight: 44)
389                            .contentShape(Rectangle())
390                    }
391                    .accessibilityLabel("Span")
392                    barButton("Later", "chevron.right") { step(forward: true) }
393                } else {
394                    barButton("Agenda", "calendar") { show(nil, around: agenda.today) }
395                }
396            }
397            .padding(.horizontal, 6)
398            .glassEffect(.regular.interactive(), in: Capsule())
399        }
400        .padding(.bottom, 8)
401        .frame(maxWidth: .infinity)
402    }
403
404    private func barButton(_ title: String, _ image: String, action: @escaping () -> Void) -> some View {
405        Button(title, systemImage: image, action: action)
406            .labelStyle(.iconOnly)
407            .font(.body.weight(.medium))
408            .frame(width: 44, height: 44)
409            .contentShape(Rectangle())
410    }
411
412    // MARK: - Rows
413
414    private func row(_ row: AgendaRow, day: Int) -> some View {
415        Group {
416            if let item = row.item, let path = item.path, let offset = item.headingOffset ?? item.markerOffset {
417                NavigationLink(value: ReaderRoute.offset(path, offset)) { AgendaRowView(row: row, day: day, theme: theme, dark: isDark) }
418            } else if let event = row.event {
419                // Calendar can't be asked for one event by URL; it opens on the event's day.
420                Button {
421                    if let url = URL(string: "calshow:\(event.start.timeIntervalSinceReferenceDate)") { openURL(url) }
422                } label: {
423                    AgendaRowView(row: row, day: day, theme: theme, dark: isDark)
424                }
425                .tint(.primary)
426            } else {
427                AgendaRowView(row: row, day: day, theme: theme, dark: isDark)
428            }
429        }
430        .swipeActions(edge: .trailing) {
431            if let item = row.item, item.todo != nil, !item.isDone, doneKeyword(for: item) != nil {
432                Button("Done", systemImage: "checkmark") { markDone(item) }.tint(.green)
433                Button("Next State", systemImage: "arrow.right.circle") { run("org.todo.next", on: item) }.tint(.blue)
434            }
435        }
436        .swipeActions(edge: .leading) {
437            if canReschedule(row), let item = row.item {
438                Button("Tomorrow", systemImage: "sunrise") { reschedule(row, item, to: agenda.today + 1) }.tint(.orange)
439                Button("Next Week", systemImage: "calendar.badge.plus") { reschedule(row, item, to: weekStart(agenda.today) + 7) }.tint(.indigo)
440                Button("Pick Date", systemImage: "calendar") { rescheduling = row }.tint(.gray)
441            }
442        }
443        .contextMenu {
444            if let item = row.item, item.path != nil {
445                Button("TODO State…", systemImage: "checkmark.circle") { run("org.todo.cycle", on: item) }
446                if canReschedule(row) {
447                    Menu("Reschedule", systemImage: "calendar.badge.clock") {
448                        Button("Tomorrow") { reschedule(row, item, to: agenda.today + 1) }
449                        Button("Next Week") { reschedule(row, item, to: weekStart(agenda.today) + 7) }
450                        Button("Pick Date…") { rescheduling = row }
451                    }
452                }
453                Button("Schedule…", systemImage: "calendar") { run("org.schedule", on: item) }
454                Button("Deadline…", systemImage: "calendar.badge.exclamationmark") { run("org.deadline", on: item) }
455                Button("Tags…", systemImage: "tag") { run("org.tags.set", on: item) }
456                Button("Priority…", systemImage: "exclamationmark.circle") { run("org.priority.set", on: item) }
457                Button("Clock In", systemImage: "stopwatch") {
458                    if let (path, offset) = locate(item) { clockIn(path, offset) }
459                }
460                Divider()
461                Button("Refile…", systemImage: "arrow.turn.down.right") { refiling = item }
462                Button("Archive…", systemImage: "archivebox") { archiving = item }
463            }
464        }
465    }
466
467    // MARK: - Actions
468
469    /// Entries with a heading, not done, that a schedule or deadline can move.
470    private func canReschedule(_ row: AgendaRow) -> Bool {
471        guard let item = row.item, item.path != nil, item.headingLine != nil, !row.isDone else { return false }
472        return item.todo != nil || row.icon == .scheduled || row.icon == .deadline || row.icon == .habit
473    }
474
475    /// Moves the row's deadline (on a deadline row) or schedule to `day`, keeping its times,
476    /// as `org-agenda-schedule` and `org-agenda-deadline` with a date typed.
477    private func reschedule(_ row: AgendaRow, _ item: AgendaItem, to day: Int) {
478        let d = Days.date(day)
479        var answer = String(format: "%04d-%02d-%02d", d.year, d.month, d.day)
480        if let start = row.start {
481            answer += String(format: " %02d:%02d", start / 100 % 24, start % 100)
482            if let end = row.end { answer += String(format: "-%02d:%02d", end / 100 % 24, end % 100) }
483        }
484        run(row.icon == .deadline ? "org.deadline" : "org.schedule", on: item, answers: ["date": answer])
485        moved += 1
486    }
487
488    /// The heading of `item` now, found again by its line in case the file changed.
489    private func locate(_ item: AgendaItem) -> (path: String, offset: Int)? {
490        guard let path = item.path, let line = item.headingLine, let near = item.headingOffset,
491              let offset = session.locateHeading(path: path, line: line, near: near) else {
492            message = "The entry changed; the agenda has been refreshed."
493            tick += 1
494            return nil
495        }
496        return (path, offset)
497    }
498
499    /// `org-agenda-clock-in`: `org-clock-in` at the entry, dangling clocks resolved first.
500    private func clockIn(_ path: String, _ offset: Int) {
501        let paths = workspace.files.filter { $0.kind == .org }.map(\.path)
502        Task {
503            let outcome = await clock.clockIn(session, target: .entry(path: path, offset: offset), paths: paths, ask: model.clockPrompts.ask)
504            if let jump = outcome.jump { model.openRequest = .offset(jump.path, jump.offset) }
505            message = outcome.messages.last { !$0.isEmpty }
506        }
507    }
508
509    /// Runs a command at the entry's heading, asking what it asks.
510    private func run(_ id: String, on item: AgendaItem, answers: [String: String] = [:]) {
511        guard let command = Commands.command(id) else { return }
512        run(command, on: item, answers: answers)
513    }
514
515    private func run(_ command: any OrgCommand, on item: AgendaItem, answers: [String: String] = [:]) {
516        guard let (path, offset) = locate(item), let line = item.headingLine else { return }
517        switch session.perform(command, path: path, at: offset, expect: (offset, line), answers: answers) {
518        case .prompt(let request):
519            prompt = EditorScreen.PromptRequest(prompt: request) { answer in
520                guard let answer else { return }
521                run(command, on: item, answers: answers.merging([request.key: answer]) { $1 })
522            }
523        case .failed(let text):
524            message = text
525        case .external(let request):
526            message = MobileEditor.unavailable(request, title: command.title)
527        case .commit:
528            message = nil
529        }
530        tick += 1
531    }
532
533    /// The first done keyword of the item's TODO sequence.
534    private func doneKeyword(for item: AgendaItem) -> String? {
535        guard let todo = item.todo, let path = item.path, let settings = session.settings(for: path) else { return nil }
536        return settings.todoSequences.first { ($0.active + $0.done).contains { $0.name == todo } }?.done.first?.name
537    }
538
539    private func markDone(_ item: AgendaItem) {
540        guard let keyword = doneKeyword(for: item) else { return }
541        run(SetTodo(keyword: keyword), on: item)
542        done += 1
543    }
544
545    private func refile(_ item: AgendaItem, to target: RefileTarget) {
546        guard let (path, offset) = locate(item) else { return }
547        // Targets in the entry's own file come from its text now.
548        let document = session.openDocuments.first { $0.path == path }?.document
549            ?? FileManager.default.contents(atPath: path).map { DocumentState(bytes: [UInt8]($0)) }
550        guard let current = workspace.refileTargets(openPath: path, document: document).first(where: { $0.label == target.label }) else {
551            message = "Can't find \(target.label) any more."
552            return
553        }
554        message = session.refile(to: current, from: (path, offset))
555        tick += 1
556    }
557
558    private func archive(_ item: AgendaItem) {
559        guard let (path, offset) = locate(item) else { return }
560        message = session.archive(from: (path, offset))
561        tick += 1
562    }
563
564    private func date(_ day: Int) -> Date {
565        let d = Days.date(day)
566        return Calendar.current.date(from: DateComponents(year: d.year, month: d.month, day: d.day)) ?? Date()
567    }
568
569    private func themed(_ color: RGBA?, _ fallback: Color) -> Color { color?.swiftUI ?? fallback }
570}
571
572extension RGBA {
573    var swiftUI: Color { Color(.sRGB, red: red, green: green, blue: blue, opacity: alpha) }
574}
575
576/// One agenda line: the time or status with an icon in the gutter; the category, title,
577/// progress and tags; the TODO keyword and priority on the right.
578struct AgendaRowView: View {
579    let row: AgendaRow
580    /// The day the row is on, for habit graphs.
581    let day: Int
582    let theme: ThemeSpec
583    let dark: Bool
584
585    var body: some View {
586        HStack(alignment: .top, spacing: 10) {
587            // TODO and match lists have no times or statuses.
588            if !row.gutter.isEmpty || row.icon != nil { gutter.frame(width: 82, alignment: .leading) }
589            VStack(alignment: .leading, spacing: 2) {
590                HStack(spacing: 5) {
591                    if !row.category.isEmpty {
592                        Circle().fill(categoryColor).frame(width: 7, height: 7)
593                        Text(row.category).lineLimit(1).layoutPriority(1)
594                    }
595                    ForEach([row.progress, row.detail].compactMap { $0 }, id: \.self) { progress in
596                        Text(progress).monospacedDigit()
597                            .padding(.horizontal, 5)
598                            .background(.quaternary.opacity(0.7), in: Capsule())
599                    }
600                    ForEach(row.tags.prefix(3), id: \.self) { tag in
601                        Text(tag).lineLimit(1)
602                            .padding(.horizontal, 5)
603                            .background(.quaternary.opacity(0.7), in: Capsule())
604                    }
605                    Spacer(minLength: 4)
606                    marks
607                }
608                .font(.caption)
609                .foregroundStyle(.secondary)
610                Text(row.title.isEmpty ? " " : row.title)
611                    .font(.body)
612                    .strikethrough(row.isDone)
613                    .foregroundStyle(row.isDone ? .secondary : .primary)
614                if let location = row.event?.location {
615                    Label(location, systemImage: "mappin").font(.caption).foregroundStyle(.secondary).lineLimit(1)
616                }
617                if let habit = row.item?.habit {
618                    HabitStrip(cells: habit.graph(today: day), theme: theme, dark: dark).padding(.top, 3)
619                }
620            }
621        }
622        .padding(.vertical, 2)
623        // Blocked entries dim, as `org-agenda-dim-blocked-tasks`.
624        .opacity(row.isBlocked ? 0.5 : row.isDone ? 0.75 : 1)
625    }
626
627    /// The TODO keyword and the priority, on the category's line.
628    private var marks: some View {
629        HStack(spacing: 5) {
630            if let todo = row.todo {
631                let color = theme.todoColor(todo, isDone: row.isDone, dark: dark)?.swiftUI ?? .secondary
632                Text(todo)
633                    .font(.caption2.weight(.semibold))
634                    .foregroundStyle(color)
635                    .padding(.horizontal, 6).padding(.vertical, 2)
636                    .background(color.opacity(0.14), in: Capsule())
637            }
638            if let priority = row.priority {
639                let color = theme.priorityColor(priority, dark: dark)?.swiftUI ?? .orange
640                Text(priority)
641                    .font(.caption2.weight(.bold))
642                    .foregroundStyle(color)
643                    .frame(width: 18, height: 18)
644                    .background(color.opacity(0.14), in: Circle())
645                    .overlay(Circle().strokeBorder(color, lineWidth: 1.2))
646                    .accessibilityLabel("Priority \(priority)")
647            }
648        }
649        .fixedSize()
650    }
651
652    private var statusColor: Color {
653        theme.statusColor(row.status, dark: dark)?.swiftUI ?? .secondary
654    }
655
656    private var categoryColor: Color {
657        if let event = row.event { return theme.eventColor(event, dark: dark)?.swiftUI ?? .purple }
658        return theme.categoryColor(row.category, dark: dark).swiftUI
659    }
660
661    /// The start over the end for timed rows, else the status; the icon under them.
662    private var gutter: some View {
663        HStack(alignment: .firstTextBaseline, spacing: 4) {
664            Image(systemName: icon ?? "circle").font(.caption2.weight(.semibold)).foregroundStyle(iconColor)
665                .frame(width: 14)
666                .opacity(icon == nil ? 0 : 1)
667            VStack(alignment: .leading, spacing: 1) {
668                if row.start != nil {
669                    let parts = row.gutter.split(separator: "–", maxSplits: 1)
670                    Text(parts.first.map(String.init) ?? row.gutter)
671                        .font(.subheadline.monospacedDigit().weight(.semibold))
672                        .foregroundStyle(row.status == .normal ? Color.primary : row.status == .done ? Color.secondary : statusColor)
673                    if parts.count > 1 {
674                        Text(parts[1]).font(.caption.monospacedDigit()).foregroundStyle(.secondary)
675                    }
676                } else if !row.gutter.isEmpty {
677                    Text(row.gutter)
678                        .font(.caption.weight(.medium).monospacedDigit())
679                        .foregroundStyle(statusColor)
680                        .lineLimit(1)
681                        .allowsTightening(true)
682                        .minimumScaleFactor(0.8)
683                }
684            }
685        }
686    }
687
688    private var icon: String? {
689        switch row.icon {
690        case .scheduled: row.status == .overdue ? "exclamationmark.circle.fill" : "calendar"
691        case .deadline: row.status == .overdue ? "exclamationmark.triangle.fill" : "flag.fill"
692        case .habit: "repeat"
693        case .event: "calendar"
694        case .timestamp: "clock"
695        case nil: nil
696        }
697    }
698
699    private var iconColor: Color {
700        if row.isEvent { return categoryColor }
701        return row.status == .normal ? Color(.tertiaryLabel) : statusColor
702    }
703}
704
705/// The current time between timed rows, as Org's `now - - -` line.
706struct NowLine: View {
707    let time: Date
708    let color: Color
709
710    var body: some View {
711        HStack(spacing: 6) {
712            Text(time.formatted(date: .omitted, time: .shortened))
713                .font(.caption2.weight(.semibold).monospacedDigit())
714                .foregroundStyle(color)
715            Circle().fill(color).frame(width: 6, height: 6)
716            Rectangle().fill(color).frame(height: 1.5)
717        }
718        .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
719        .listRowSeparator(.hidden)
720        .frame(height: 16)
721        .accessibilityLabel("Now")
722    }
723}
724
725/// org-habit's consistency graph, compact: a bar per day, a dot where the habit was done,
726/// today outlined.
727struct HabitStrip: View {
728    let cells: [Habit.Cell]
729    let theme: ThemeSpec
730    let dark: Bool
731
732    var body: some View {
733        HStack(spacing: 1) {
734            ForEach(cells, id: \.day) { cell in
735                RoundedRectangle(cornerRadius: 1.5)
736                    .fill(color(cell.face))
737                    .frame(width: 4, height: 12)
738                    .overlay {
739                        if cell.glyph == "*" { Circle().fill(.white.opacity(0.9)).frame(width: 3, height: 3) }
740                    }
741                    .overlay {
742                        if cell.glyph == "!" { RoundedRectangle(cornerRadius: 1.5).strokeBorder(Color.primary, lineWidth: 1) }
743                    }
744            }
745        }
746        .accessibilityElement(children: .ignore)
747        .accessibilityLabel("Done \(cells.filter { $0.glyph == "*" }.count) of the last \(Habit.precedingDays) days")
748    }
749
750    /// Due-state colors, lighter for days to come, as org-habit's faces.
751    private func color(_ face: Habit.Face) -> Color {
752        func themed(_ key: String, _ fallback: Color) -> Color { theme.color(key, dark: dark)?.swiftUI ?? fallback }
753        switch face {
754        case .clear: return themed("habit-clear", .blue).opacity(0.7)
755        case .clearFuture: return themed("habit-clear", .blue).opacity(0.3)
756        case .ready: return themed("habit-ready", .green).opacity(0.8)
757        case .readyFuture: return themed("habit-ready", .green).opacity(0.3)
758        case .alert: return themed("habit-alert", .yellow).opacity(0.85)
759        case .alertFuture: return themed("habit-alert", .yellow).opacity(0.35)
760        case .overdue: return themed("habit-overdue", .red).opacity(0.8)
761        case .overdueFuture: return themed("habit-overdue", .red).opacity(0.3)
762        }
763    }
764}
765
766/// A date to go to or to schedule on.
767struct DateSheet: View {
768    let title: String
769    let action: String
770    let initial: Date
771    let done: (Date?) -> Void
772    @State private var date = Date()
773
774    var body: some View {
775        NavigationStack {
776            DatePicker(title, selection: $date, displayedComponents: .date)
777                .datePickerStyle(.graphical)
778                .padding(.horizontal)
779                .navigationTitle(title)
780                .navigationBarTitleDisplayMode(.inline)
781                .toolbar {
782                    ToolbarItem(placement: .cancellationAction) { Button("Cancel") { done(nil) } }
783                    ToolbarItem(placement: .confirmationAction) { Button(action) { done(date) } }
784                }
785            Spacer()
786        }
787        .presentationDetents([.medium, .large])
788        .onAppear { date = initial }
789    }
790}
791
792extension AgendaItem: @retroactive Identifiable {
793    public var id: String { "\(path ?? "")|\(headingOffset ?? -1)|\(kind.rawValue)|\(text)" }
794
795    /// An entry, not a time-grid line.
796    var isEntry: Bool { kind != .timeGrid && kind != .currentTime }
797}
798
799/// Org's agenda filter: tags and categories of the entries shown, kept or left out, or the
800/// filter typed as `/` takes it.
801struct FilterSheet: View {
802    @Binding var filter: AgendaFilter
803    let items: [AgendaItem]
804    @State private var typed = ""
805    @Environment(\.dismiss) private var dismiss
806
807    var body: some View {
808        let tags = Set(items.flatMap(\.tags)).sorted()
809        let categories = Set(items.filter { $0.path != nil }.map(\.category)).sorted()
810        NavigationStack {
811            Form {
812                Section {
813                    TextField("+work-home", text: $typed)
814                        .autocorrectionDisabled()
815                        .textInputAutocapitalization(.never)
816                        .onSubmit(apply)
817                } footer: {
818                    Text("As Org's / filter: +keep and -drop tags or categories, <0:30 for effort, /regexp/.")
819                }
820                if !tags.isEmpty {
821                    Section("Tags") { ForEach(tags, id: \.self) { term($0, in: \.tags) } }
822                }
823                if !categories.isEmpty {
824                    Section("Categories") { ForEach(categories, id: \.self) { term($0, in: \.categories) } }
825                }
826            }
827            .navigationTitle("Filter")
828            .navigationBarTitleDisplayMode(.inline)
829            .toolbar {
830                ToolbarItem(placement: .cancellationAction) { Button("Clear") { filter = AgendaFilter(); dismiss() } }
831                ToolbarItem(placement: .confirmationAction) { Button("Done") { apply(); dismiss() } }
832            }
833        }
834        .onAppear { typed = filter.description }
835    }
836
837    /// Keep, leave out, or no filter on `name`.
838    private func term(_ name: String, in list: WritableKeyPath<AgendaFilter, [String]>) -> some View {
839        let state = filter[keyPath: list].contains("+" + name) ? 1 : filter[keyPath: list].contains("-" + name) ? -1 : 0
840        return Button {
841            var terms = filter[keyPath: list].filter { $0.dropFirst() != Substring(name) }
842            if state == 0 { terms.append("+" + name) } else if state == 1 { terms.append("-" + name) }
843            filter[keyPath: list] = terms
844            typed = filter.description
845        } label: {
846            HStack {
847                Text(name).foregroundStyle(.primary)
848                Spacer()
849                Text(state == 1 ? "Keep" : state == -1 ? "Leave out" : "").foregroundStyle(.secondary)
850            }
851        }
852    }
853
854    private func apply() {
855        guard typed != filter.description else { return }
856        let tags = Set(items.flatMap(\.tags))
857        let categories = Set(items.map(\.category))
858        filter = AgendaFilter().parse(typed, tags: tags, categories: categories).filter
859    }
860}
861
862/// Where to refile: the targets `org-refile` offers, searchable.
863struct RefileSheet: View {
864    let targets: [RefileTarget]
865    let choose: (RefileTarget?) -> Void
866    @State private var query = ""
867
868    var body: some View {
869        NavigationStack {
870            List(targets.filter { query.isEmpty || $0.label.localizedCaseInsensitiveContains(query) }, id: \.self) { target in
871                Button(target.label) { choose(target) }.foregroundStyle(.primary)
872            }
873            .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always))
874            .navigationTitle("Refile to")
875            .navigationBarTitleDisplayMode(.inline)
876            .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { choose(nil) } } }
877        }
878    }
879}
880#endif