krz/orgstar

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

Sources/OrgstarMobile/AgendaScreen.swift

b98a6509c5ae75e5172dd333d1b6105fd6ceee0e
orgstar/Sources/OrgstarMobile/AgendaScreen.swift history · blame · raw

885 lines · 39931 bytes

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