krz/orgstar

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

Sources/Orgstar/AgendaView.swift

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

430 lines · 16908 bytes

  1import OrgApp
  2import OrgCore
  3import SwiftUI
  4
  5/// The agenda window: the day/week agenda, the TODO list, tag matches and saved views.
  6/// Return or a double click jumps to the entry in the main window. Keys as in Emacs's agenda:
  7/// f/b/. move the span, g refreshes, t cycles the TODO state, +/- change the priority, : or
  8/// C-c C-q sets tags, C-c C-s / C-c C-d schedule and set a deadline, S-→/S-← move the date.
  9struct AgendaView: View {
 10    let workspace: WorkspaceModel
 11    let session: DocumentSession
 12    let commands: AppCommands
 13    let scheduler: ReminderScheduler?
 14    @State private var agenda: AgendaModel
 15    @State private var views = AgendaViewLoader.builtIn
 16    @State private var viewName = AgendaViewLoader.builtIn[0].name
 17    @State private var matchText = ""
 18    @State private var selection: String?
 19    @State private var tick = 0
 20    @State private var message: String?
 21    @State private var prompt: PendingPrompt?
 22    @State private var promptText = ""
 23    @State private var prefix = false
 24    @AppStorage("agendaSpan") private var span = 10
 25    @AppStorage("agendaStartOffset") private var startOffset = -3
 26    @AppStorage("agendaSubfolders") private var includeSubfolders = false
 27    @Environment(\.openWindow) private var openWindow
 28
 29    static let match = "Match…"
 30    static let todoMatch = "TODO Match…"
 31
 32    init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands, scheduler: ReminderScheduler?) {
 33        self.workspace = workspace
 34        self.session = session
 35        self.commands = commands
 36        self.scheduler = scheduler
 37        _agenda = State(initialValue: AgendaModel(workspace: workspace))
 38    }
 39
 40    struct PendingPrompt: Identifiable {
 41        let id = UUID()
 42        let prompt: Prompt
 43        let command: any OrgCommand
 44        let atMarker: Bool
 45        let answers: [String: String]
 46        let itemID: String
 47    }
 48
 49    private struct RefreshKey: Hashable {
 50        var mode: AgendaMode
 51        var start: Int?
 52        var span: Int
 53        var startOffset: Int
 54        var includeSubfolders: Bool
 55        var files: Int
 56        var changed: Int?
 57        var edits: Int
 58        var path: String?
 59        var tick: Int
 60    }
 61
 62    private var refreshKey: RefreshKey {
 63        RefreshKey(
 64            mode: agenda.mode, start: agenda.start, span: span, startOffset: startOffset, includeSubfolders: includeSubfolders,
 65            files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion,
 66            path: session.path, tick: tick
 67        )
 68    }
 69
 70    private var isAgenda: Bool {
 71        if case .agenda = agenda.mode { return true }
 72        return false
 73    }
 74
 75    private var isMatch: Bool { viewName == Self.match || viewName == Self.todoMatch }
 76
 77    var body: some View {
 78        List(selection: $selection) {
 79            if isAgenda {
 80                ForEach(agenda.days, id: \.day) { day in
 81                    Section {
 82                        rows(day.items, prefix: "\(day.day)", day: day.day)
 83                    } header: {
 84                        Text(day.header)
 85                            .font(.headline)
 86                            .foregroundStyle(day.isToday ? Color.accentColor : .primary)
 87                    }
 88                }
 89            } else {
 90                Section {
 91                    rows(agenda.list, prefix: "list")
 92                } header: {
 93                    Text(listTitle).font(.headline)
 94                }
 95            }
 96        }
 97        .contextMenu(forSelectionType: String.self) { _ in
 98        } primaryAction: { ids in
 99            if let id = ids.first { jump(id) }
100        }
101        .onKeyPress(phases: .down) { press in handle(press) }
102        .safeAreaInset(edge: .bottom, spacing: 0) {
103            if let message = message ?? reminderStatus {
104                Text(message)
105                    .font(.callout)
106                    .frame(maxWidth: .infinity, alignment: .leading)
107                    .padding(.horizontal, 12)
108                    .padding(.vertical, 6)
109                    .background(.bar)
110            }
111        }
112        .toolbar {
113            ToolbarItemGroup(placement: .navigation) {
114                Menu {
115                    ForEach(views) { view in Button(view.name) { viewName = view.name } }
116                    Divider()
117                    Button(Self.match) { viewName = Self.match }
118                    Button(Self.todoMatch) { viewName = Self.todoMatch }
119                } label: {
120                    Label(viewName, systemImage: "calendar").labelStyle(.titleAndIcon)
121                }
122                .help("What the agenda shows")
123                if isMatch {
124                    TextField("+work-boss|LEVEL>2/!TODO", text: $matchText)
125                        .frame(minWidth: 220)
126                        .onSubmit { applyView() }
127                }
128            }
129            ToolbarItemGroup {
130                if agenda.isComputing { ProgressView().controlSize(.small) }
131                if isAgenda {
132                    Button("Earlier", systemImage: "chevron.left") { agenda.shift(forward: false) }
133                        .help("Previous days (b)")
134                    Button("Today") { agenda.showToday() }
135                        .help("Back to today (.)")
136                    Button("Later", systemImage: "chevron.right") { agenda.shift(forward: true) }
137                        .help("Next days (f)")
138                }
139            }
140        }
141        .overlay {
142            if agenda.files.isEmpty {
143                ContentUnavailableView("No agenda files", systemImage: "calendar", description: Text("The agenda reads the org files at the top of each folder you add."))
144            }
145        }
146        .alert(prompt?.prompt.message ?? "", isPresented: Binding(get: { prompt != nil }, set: { if !$0 { prompt = nil } })) {
147            TextField("", text: $promptText)
148            Button("OK") { answerPrompt() }
149            Button("Cancel", role: .cancel) { prompt = nil }
150        } message: {
151            if let choices = prompt?.prompt.choices, !choices.isEmpty {
152                Text(choices.prefix(12).joined(separator: "  "))
153            }
154        }
155        .navigationTitle(viewName.replacingOccurrences(of: "…", with: ""))
156        .onChange(of: viewName) { applyView() }
157        .task(id: refreshKey) {
158            agenda.span = span
159            agenda.startOffset = startOffset
160            agenda.includeSubfolders = includeSubfolders
161            let open = session.path.flatMap { path in session.document.map { (path, $0.text) } }
162            await agenda.refresh(open: open)
163        }
164        .task {
165            loadViews()
166            // The time grid's "now" line and the day itself move on.
167            while !Task.isCancelled {
168                try? await Task.sleep(for: .seconds(60))
169                tick += 1
170            }
171        }
172    }
173
174    @ViewBuilder
175    private func rows(_ items: [AgendaItem], prefix: String, day: Int? = nil) -> some View {
176        ForEach(Array(items.enumerated()), id: \.offset) { index, item in
177            AgendaRow(item: item, day: day)
178                .tag("\(prefix):\(index)")
179                .selectionDisabled(item.path == nil)
180        }
181    }
182
183    @AppStorage("reminders") private var remindersEnabled = true
184
185    /// How far ahead notifications are set, as the design asks the agenda to show.
186    private var reminderStatus: String? {
187        guard let scheduler, remindersEnabled else { return nil }
188        if !scheduler.isAuthorized { return "Notifications are off for Orgstar in System Settings." }
189        guard let through = scheduler.scheduledThrough else { return nil }
190        return "Reminders are set through " + through.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute()) + "."
191    }
192
193    private var listTitle: String {
194        switch agenda.mode {
195        case .todo(let keywords): "TODO items: \(keywords ?? "all")"
196        case .match(let match, let todoOnly): (todoOnly ? "TODO entries" : "Entries") + " matching \(match)"
197        case .agenda: ""
198        }
199    }
200
201    private func loadViews() {
202        let loaded = AgendaViewLoader.load()
203        views = loaded.views
204        message = loaded.problems.first
205    }
206
207    private func applyView() {
208        selection = nil
209        agenda.start = nil
210        switch viewName {
211        case Self.match, Self.todoMatch:
212            let match = matchText.trimmingCharacters(in: .whitespaces)
213            guard !match.isEmpty else { return }
214            agenda.mode = .match(match, todoOnly: viewName == Self.todoMatch)
215        default:
216            if let view = views.first(where: { $0.name == viewName }) { agenda.mode = view.mode }
217        }
218    }
219
220    // MARK: - Items
221
222    private func item(_ id: String) -> AgendaItem? {
223        let parts = id.split(separator: ":")
224        guard parts.count == 2, let index = Int(parts[1]) else { return nil }
225        let items = parts[0] == "list" ? agenda.list : agenda.days.first { "\($0.day)" == parts[0] }?.items ?? []
226        return index < items.count ? items[index] : nil
227    }
228
229    private func jump(_ id: String) {
230        guard let item = item(id), let path = item.path, let offset = item.markerOffset ?? item.headingOffset else { return }
231        commands.jump(path: path, offset: offset)
232        openWindow(id: "main")
233    }
234
235    // MARK: - Commands
236
237    private func handle(_ press: KeyPress) -> KeyPress.Result {
238        let control = press.modifiers.contains(.control)
239        if prefix {
240            prefix = false
241            switch (control, press.characters) {
242            case (true, "\u{13}"), (true, "s"): run(SetPlanning(deadline: false))
243            case (true, "\u{04}"), (true, "d"): run(SetPlanning(deadline: true))
244            case (true, "\u{11}"), (true, "q"): run(SetTags())
245            case (true, "\u{14}"), (true, "t"): run(TodoCycle())
246            default: message = "C-c \(press.characters) is undefined"
247            }
248            return .handled
249        }
250        if control, press.characters == "c" || press.characters == "\u{03}" {
251            prefix = true
252            message = "C-c-"
253            return .handled
254        }
255        if press.key == .return, let selection {
256            jump(selection)
257            return .handled
258        }
259        if press.modifiers == .shift, press.key == .rightArrow || press.key == .leftArrow {
260            run(ShiftTimestampDay(later: press.key == .rightArrow), atMarker: true)
261            return .handled
262        }
263        guard press.modifiers.subtracting(.shift).isEmpty else { return .ignored }
264        switch press.characters {
265        case "f" where isAgenda: agenda.shift(forward: true)
266        case "b" where isAgenda: agenda.shift(forward: false)
267        case "." where isAgenda: agenda.showToday()
268        case "g", "r":
269            loadViews()
270            tick += 1
271        case "t": run(TodoCycle())
272        case "+": run(PriorityUp())
273        case "-": run(PriorityDown())
274        case ":": run(SetTags())
275        default: return .ignored
276        }
277        return .handled
278    }
279
280    /// Runs `command` on the selected entry, at its heading or, with `atMarker`, at the
281    /// timestamp the entry is listed for.
282    private func run(_ command: any OrgCommand, atMarker: Bool = false, answers: [String: String] = [:], itemID: String? = nil) {
283        guard let id = itemID ?? selection, let item = item(id), let path = item.path, let start = item.headingOffset else {
284            message = "No entry selected"
285            return
286        }
287        if atMarker, ![.deadline, .upcomingDeadline, .scheduled, .pastScheduled, .timestamp].contains(item.kind) {
288            message = "Only dated entries can move"
289            return
290        }
291        let offset = atMarker ? (item.markerOffset ?? start) : start
292        let step = session.perform(command, path: path, at: offset, expect: item.headingLine.map { (start, $0) }, answers: answers)
293        switch step {
294        case .prompt(let request):
295            promptText = request.initial
296            prompt = PendingPrompt(prompt: request, command: command, atMarker: atMarker, answers: answers, itemID: id)
297        case .failed(let text):
298            message = text
299            tick += 1
300        case .external:
301            message = "Not available from the agenda"
302        case .commit:
303            message = nil
304            tick += 1
305        }
306    }
307
308    private func answerPrompt() {
309        guard let pending = prompt else { return }
310        prompt = nil
311        var answers = pending.answers
312        answers[pending.prompt.key] = promptText
313        run(pending.command, atMarker: pending.atMarker, answers: answers, itemID: pending.itemID)
314    }
315}
316
317struct AgendaRow: View {
318    let item: AgendaItem
319    /// The agenda day the row is on, for habit graphs.
320    var day: Int?
321
322    var body: some View {
323        switch item.kind {
324        case .timeGrid:
325            HStack(spacing: 8) {
326                Text(Self.trim(item.time))
327                    .monospacedDigit()
328                    .frame(width: 140, alignment: .trailing)
329                VStack { Divider() }
330            }
331            .font(.caption)
332            .foregroundStyle(.tertiary)
333        case .currentTime:
334            HStack(spacing: 8) {
335                Text("now " + Self.trim(item.time))
336                    .monospacedDigit()
337                    .frame(width: 140, alignment: .trailing)
338                Rectangle().fill(Color.accentColor).frame(height: 1)
339            }
340            .font(.caption)
341            .foregroundStyle(Color.accentColor)
342        default:
343            HStack(spacing: 8) {
344                Text(item.category)
345                    .lineLimit(1)
346                    .foregroundStyle(.secondary)
347                    .frame(width: 80, alignment: .leading)
348                if item.kind != .todo, item.kind != .tagsMatch {
349                    Text(Self.trim(item.time))
350                        .monospacedDigit()
351                        .frame(width: 80, alignment: .leading)
352                }
353                if !item.extra.isEmpty {
354                    Text(item.extra.trimmingCharacters(in: .whitespaces))
355                        .monospacedDigit()
356                        .foregroundStyle(leaderColor)
357                }
358                title
359                    .lineLimit(1)
360                Spacer(minLength: 8)
361                if let habit = item.habit, let day {
362                    HabitGraph(cells: habit.graph(today: day))
363                }
364                if !item.tags.isEmpty {
365                    Text(":" + item.tags.joined(separator: ":") + ":")
366                        .font(.caption)
367                        .foregroundStyle(.secondary)
368                }
369            }
370        }
371    }
372
373    static func trim(_ time: String) -> String {
374        time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄."))
375    }
376
377    private var leaderColor: Color {
378        switch item.kind {
379        case .deadline: .red
380        case .upcomingDeadline: .orange
381        case .pastScheduled: .brown
382        default: .secondary
383        }
384    }
385
386    /// The TODO keyword colored as in the editor.
387    private var title: Text {
388        let text = item.text.trimmingCharacters(in: .whitespaces)
389        guard let todo = item.todo, text.hasPrefix(todo + " ") else { return Text(text) }
390        let rest = String(text.dropFirst(todo.count))
391        return Text(todo).foregroundColor(item.isDone ? .green : .red).bold() + Text(rest).foregroundColor(item.isDone ? .secondary : .primary)
392    }
393}
394
395/// org-habit's consistency graph: a cell per day, `*` where the habit was done, `!` today.
396struct HabitGraph: View {
397    let cells: [Habit.Cell]
398
399    var body: some View {
400        HStack(spacing: 1) {
401            ForEach(cells, id: \.day) { cell in
402                Text(String(cell.glyph))
403                    .font(.system(size: 9, weight: .bold, design: .monospaced))
404                    .frame(width: 8, height: 14)
405                    .background(Self.color(cell.face))
406                    .help(Self.label(cell))
407            }
408        }
409        .accessibilityElement(children: .ignore)
410        .accessibilityLabel("Done \(cells.filter { $0.glyph == "*" }.count) of the last \(Habit.precedingDays) days")
411    }
412
413    static func color(_ face: Habit.Face) -> Color {
414        switch face {
415        case .clear: .blue.opacity(0.6)
416        case .clearFuture: .blue.opacity(0.3)
417        case .ready: .green.opacity(0.7)
418        case .readyFuture: .green.opacity(0.35)
419        case .alert: .yellow.opacity(0.8)
420        case .alertFuture: .yellow.opacity(0.4)
421        case .overdue: .red.opacity(0.7)
422        case .overdueFuture: .red.opacity(0.35)
423        }
424    }
425
426    static func label(_ cell: Habit.Cell) -> String {
427        let date = Days.date(cell.day)
428        return String(format: "%04d-%02d-%02d", date.year, date.month, date.day) + (cell.glyph == "*" ? " DONE" : "")
429    }
430}