krz/orgstar

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

Sources/OrgstarMobile/AgendaScreen.swift

3a3dedba062790e1580c66d38606eda4b02cb501
orgstar/Sources/OrgstarMobile/AgendaScreen.swift history · blame · raw

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