krz/orgstar

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

Sources/OrgstarMobile/AgendaScreen.swift

16086b4cf2caff5328774b2cd5ae3ffe1ab65ca4
orgstar/Sources/OrgstarMobile/AgendaScreen.swift history · blame · raw

393 lines · 17400 bytes

  1#if os(iOS)
  2import OrgApp
  3import OrgCore
  4import OrgDocument
  5import SwiftUI
  6
  7/// The agenda over the workspace's agenda files: the day/week view, the TODO list, tag and
  8/// property matches and the views in `views.toml`, with Org's filters and the agenda's
  9/// actions on each entry.
 10struct AgendaScreen: View {
 11    let workspace: WorkspaceModel
 12    let session: DocumentSession
 13    @Bindable var agenda: AgendaModel
 14    let scheduler: ReminderScheduler
 15    @State private var message: String?
 16    @Environment(ClockModel.self) private var clock
 17    @Environment(MobileModel.self) private var model
 18    @State private var tick = 0
 19    @State private var views = AgendaViewLoader.builtIn
 20    @State private var viewName = "Agenda"
 21    @State private var filter = AgendaFilter()
 22    @State private var showingFilter = false
 23    @State private var asking: Asking?
 24    @State private var prompt: EditorScreen.PromptRequest?
 25    @State private var refiling: AgendaItem?
 26    @State private var archiving: AgendaItem?
 27
 28    /// A match typed for the tag and property view.
 29    private enum Asking: Identifiable {
 30        case match
 31        var id: Int { 0 }
 32    }
 33
 34    private struct Key: Equatable {
 35        var files: Int
 36        var changed: Int?
 37        var edits: Int
 38        var tick: Int
 39        var mode: AgendaMode
 40        var start: Int?
 41        var span: Int
 42        var startOffset: Int
 43        var subfolders: Bool
 44    }
 45
 46    var body: some View {
 47        List {
 48            // With no folders the empty state stands alone, not over a column of empty days.
 49            if workspace.rootPaths.isEmpty {
 50            } else if case .agenda = agenda.mode {
 51                ForEach(agenda.days, id: \.day) { day in
 52                    Section {
 53                        let items = day.items.filter(\.isEntry).filter(filter.keeps)
 54                        ForEach(items.indices, id: \.self) { i in row(items[i]) }
 55                    } header: {
 56                        Text(day.header.split(separator: " ", omittingEmptySubsequences: true).prefix(4).joined(separator: " "))
 57                            .foregroundStyle(day.isToday ? Color.accentColor : .secondary)
 58                    }
 59                }
 60            } else {
 61                let items = agenda.list.filter(filter.keeps)
 62                if items.isEmpty, !agenda.isComputing { Text("No entries").foregroundStyle(.secondary) }
 63                ForEach(items.indices, id: \.self) { i in row(items[i]) }
 64            }
 65            if let reminderStatus {
 66                Section { } footer: { Text(reminderStatus) }
 67            }
 68        }
 69        .listStyle(.insetGrouped)
 70        .navigationTitle(viewName)
 71        .onChange(of: agenda.start) {
 72            // Days a followed timestamp asked for.
 73            if agenda.start != nil, case .agenda = agenda.mode { viewName = "Agenda" }
 74        }
 75        .overlay {
 76            if workspace.rootPaths.isEmpty {
 77                ContentUnavailableView("No folders", systemImage: "folder", description: Text("Add a folder of org files under Folders."))
 78            }
 79        }
 80        .safeAreaInset(edge: .bottom) {
 81            if let status = message ?? (filter.isEmpty ? nil : "Filter: " + filter.description) {
 82                Text(status).font(.callout).padding(8).frame(maxWidth: .infinity).background(.bar)
 83                    .onTapGesture { message = nil }
 84            }
 85        }
 86        .toolbar {
 87            ToolbarItem(placement: .topBarLeading) {
 88                Menu {
 89                    ForEach(views) { definition in
 90                        Button(definition.name) { show(definition) }
 91                    }
 92                    Button("Tags and Properties…") { asking = .match }
 93                    if case .agenda = agenda.mode {
 94                        Divider()
 95                        Button("Today", systemImage: "calendar.circle") { agenda.showToday() }
 96                        Button("Earlier", systemImage: "chevron.left") { agenda.shift(forward: false) }
 97                        Button("Later", systemImage: "chevron.right") { agenda.shift(forward: true) }
 98                    }
 99                } label: {
100                    Label("Views", systemImage: "calendar")
101                }
102            }
103            ToolbarItem(placement: .topBarLeading) {
104                Button("Filter", systemImage: filter.isEmpty ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill") {
105                    showingFilter = true
106                }
107            }
108        }
109        .sheet(isPresented: $showingFilter) {
110            FilterSheet(filter: $filter, items: shownItems)
111        }
112        .sheet(item: $asking) { _ in
113            PromptSheet(prompt: Prompt(key: "match", message: "Match: tags and properties, as +work-home or TODO=\"WAIT\"")) { answer in
114                asking = nil
115                guard let answer, !answer.isEmpty else { return }
116                agenda.mode = .match(answer, todoOnly: false)
117                viewName = answer
118            }
119        }
120        .sheet(item: $prompt) { request in
121            PromptSheet(prompt: request.prompt) { answer in
122                prompt = nil
123                request.reply(answer)
124            }
125        }
126        .sheet(item: $refiling) { item in
127            RefileSheet(targets: workspace.refileTargets(openPath: session.path, document: session.document)) { target in
128                refiling = nil
129                if let target { refile(item, to: target) }
130            }
131        }
132        .confirmationDialog("Archive this entry?", isPresented: Binding { archiving != nil } set: { if !$0 { archiving = nil } }, titleVisibility: .visible) {
133            Button("Archive", role: .destructive) {
134                if let item = archiving { archive(item) }
135                archiving = nil
136            }
137        } message: {
138            Text(archiving?.text ?? "")
139        }
140        .refreshable {
141            await workspace.refresh()
142            await agenda.refresh(open: session.openTexts)
143        }
144        .task {
145            let loaded = AgendaViewLoader.load()
146            views = loaded.views
147            if let problem = loaded.problems.first { message = problem }
148        }
149        .task(id: Key(files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, tick: tick,
150                      mode: agenda.mode, start: agenda.start, span: agenda.span, startOffset: agenda.startOffset,
151                      subfolders: agenda.includeSubfolders)) {
152            await agenda.refresh(open: session.openTexts)
153        }
154    }
155
156    private var shownItems: [AgendaItem] {
157        if case .agenda = agenda.mode { return agenda.days.flatMap(\.items).filter(\.isEntry) }
158        return agenda.list
159    }
160
161    private func show(_ definition: AgendaViewDefinition) {
162        viewName = definition.name
163        agenda.mode = definition.mode
164    }
165
166    /// How far ahead notifications are set, as the design asks the agenda to show: iOS keeps
167    /// at most 64, and sets no more while the app isn't opened.
168    private var reminderStatus: String? {
169        guard OrgPreferences.reminders() else { return nil }
170        if !scheduler.isAuthorized { return "Notifications are off for Orgstar in Settings." }
171        guard let through = scheduler.scheduledThrough else { return nil }
172        return "Reminders are set through " + through.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute())
173            + ". Open Orgstar to set later ones."
174    }
175
176    // MARK: - Rows
177
178    private func row(_ item: AgendaItem) -> some View {
179        Group {
180            if let path = item.path, let offset = item.headingOffset ?? item.markerOffset {
181                NavigationLink(value: ReaderRoute.offset(path, offset)) { entry(item) }
182            } else {
183                entry(item)
184            }
185        }
186        .swipeActions(edge: .trailing) {
187            if item.todo != nil, !item.isDone, doneKeyword(for: item) != nil {
188                Button("Done", systemImage: "checkmark") { markDone(item) }.tint(.green)
189            }
190        }
191        .contextMenu {
192            if item.path != nil {
193                Button("TODO State…", systemImage: "checkmark.circle") { run("org.todo.cycle", on: item) }
194                Button("Schedule…", systemImage: "calendar") { run("org.schedule", on: item) }
195                Button("Deadline…", systemImage: "calendar.badge.exclamationmark") { run("org.deadline", on: item) }
196                Button("Tags…", systemImage: "tag") { run("org.tags.set", on: item) }
197                Button("Priority…", systemImage: "exclamationmark.circle") { run("org.priority.set", on: item) }
198                Button("Clock In", systemImage: "stopwatch") {
199                    if let (path, offset) = locate(item) { clockIn(path, offset) }
200                }
201                Divider()
202                Button("Refile…", systemImage: "arrow.turn.down.right") { refiling = item }
203                Button("Archive…", systemImage: "archivebox") { archiving = item }
204            }
205        }
206    }
207
208    private func entry(_ item: AgendaItem) -> some View {
209        VStack(alignment: .leading, spacing: 2) {
210            HStack(alignment: .firstTextBaseline, spacing: 6) {
211                if let time = item.time.firstMatch(of: /[0-9]{1,2}:[0-9]{2}(-[0-9]{1,2}:[0-9]{2})?/) {
212                    Text(time.output.0).font(.callout.monospacedDigit()).foregroundStyle(.secondary)
213                }
214                Text(item.text).strikethrough(item.isDone)
215            }
216            HStack(spacing: 6) {
217                Text(item.category)
218                let extra = item.extra.split(whereSeparator: \.isWhitespace).joined(separator: " ")
219                if !extra.isEmpty { Text(extra) }
220                if !item.tags.isEmpty { Text(item.tags.map { ":" + $0 }.joined() + ":") }
221            }
222            .font(.caption)
223            .foregroundStyle(.secondary)
224        }
225    }
226
227    // MARK: - Actions
228
229    /// The heading of `item` now, found again by its line in case the file changed.
230    private func locate(_ item: AgendaItem) -> (path: String, offset: Int)? {
231        guard let path = item.path, let line = item.headingLine, let near = item.headingOffset,
232              let offset = session.locateHeading(path: path, line: line, near: near) else {
233            message = "The entry changed; the agenda has been refreshed."
234            tick += 1
235            return nil
236        }
237        return (path, offset)
238    }
239
240    /// `org-agenda-clock-in`: `org-clock-in` at the entry, dangling clocks resolved first.
241    private func clockIn(_ path: String, _ offset: Int) {
242        let paths = workspace.files.filter { $0.kind == .org }.map(\.path)
243        Task {
244            let outcome = await clock.clockIn(session, target: .entry(path: path, offset: offset), paths: paths, ask: model.clockPrompts.ask)
245            if let jump = outcome.jump { model.openRequest = .offset(jump.path, jump.offset) }
246            message = outcome.messages.last { !$0.isEmpty }
247        }
248    }
249
250    /// Runs a command at the entry's heading, asking what it asks.
251    private func run(_ id: String, on item: AgendaItem, answers: [String: String] = [:]) {
252        guard let command = Commands.command(id) else { return }
253        run(command, on: item, answers: answers)
254    }
255
256    private func run(_ command: any OrgCommand, on item: AgendaItem, answers: [String: String] = [:]) {
257        guard let (path, offset) = locate(item), let line = item.headingLine else { return }
258        switch session.perform(command, path: path, at: offset, expect: (offset, line), answers: answers) {
259        case .prompt(let request):
260            prompt = EditorScreen.PromptRequest(prompt: request) { answer in
261                guard let answer else { return }
262                run(command, on: item, answers: answers.merging([request.key: answer]) { $1 })
263            }
264        case .failed(let text):
265            message = text
266        case .external(let request):
267            message = MobileEditor.unavailable(request, title: command.title)
268        case .commit:
269            message = nil
270        }
271        tick += 1
272    }
273
274    /// The first done keyword of the item's TODO sequence.
275    private func doneKeyword(for item: AgendaItem) -> String? {
276        guard let todo = item.todo, let path = item.path, let settings = session.settings(for: path) else { return nil }
277        return settings.todoSequences.first { ($0.active + $0.done).contains { $0.name == todo } }?.done.first?.name
278    }
279
280    private func markDone(_ item: AgendaItem) {
281        guard let keyword = doneKeyword(for: item) else { return }
282        run(SetTodo(keyword: keyword), on: item)
283    }
284
285    private func refile(_ item: AgendaItem, to target: RefileTarget) {
286        guard let (path, offset) = locate(item) else { return }
287        // Targets in the entry's own file come from its text now.
288        let document = session.openDocuments.first { $0.path == path }?.document
289            ?? FileManager.default.contents(atPath: path).map { DocumentState(bytes: [UInt8]($0)) }
290        guard let current = workspace.refileTargets(openPath: path, document: document).first(where: { $0.label == target.label }) else {
291            message = "Can't find \(target.label) any more."
292            return
293        }
294        message = session.refile(to: current, from: (path, offset))
295        tick += 1
296    }
297
298    private func archive(_ item: AgendaItem) {
299        guard let (path, offset) = locate(item) else { return }
300        message = session.archive(from: (path, offset))
301        tick += 1
302    }
303}
304
305extension AgendaItem: @retroactive Identifiable {
306    public var id: String { "\(path ?? "")|\(headingOffset ?? -1)|\(kind.rawValue)|\(text)" }
307
308    /// An entry, not a time-grid line.
309    var isEntry: Bool { kind != .timeGrid && kind != .currentTime }
310}
311
312/// Org's agenda filter: tags and categories of the entries shown, kept or left out, or the
313/// filter typed as `/` takes it.
314struct FilterSheet: View {
315    @Binding var filter: AgendaFilter
316    let items: [AgendaItem]
317    @State private var typed = ""
318    @Environment(\.dismiss) private var dismiss
319
320    var body: some View {
321        let tags = Set(items.flatMap(\.tags)).sorted()
322        let categories = Set(items.filter { $0.path != nil }.map(\.category)).sorted()
323        NavigationStack {
324            Form {
325                Section {
326                    TextField("+work-home", text: $typed)
327                        .autocorrectionDisabled()
328                        .textInputAutocapitalization(.never)
329                        .onSubmit(apply)
330                } footer: {
331                    Text("As Org's / filter: +keep and -drop tags or categories, <0:30 for effort, /regexp/.")
332                }
333                if !tags.isEmpty {
334                    Section("Tags") { ForEach(tags, id: \.self) { term($0, in: \.tags) } }
335                }
336                if !categories.isEmpty {
337                    Section("Categories") { ForEach(categories, id: \.self) { term($0, in: \.categories) } }
338                }
339            }
340            .navigationTitle("Filter")
341            .navigationBarTitleDisplayMode(.inline)
342            .toolbar {
343                ToolbarItem(placement: .cancellationAction) { Button("Clear") { filter = AgendaFilter(); dismiss() } }
344                ToolbarItem(placement: .confirmationAction) { Button("Done") { apply(); dismiss() } }
345            }
346        }
347        .onAppear { typed = filter.description }
348    }
349
350    /// Keep, leave out, or no filter on `name`.
351    private func term(_ name: String, in list: WritableKeyPath<AgendaFilter, [String]>) -> some View {
352        let state = filter[keyPath: list].contains("+" + name) ? 1 : filter[keyPath: list].contains("-" + name) ? -1 : 0
353        return Button {
354            var terms = filter[keyPath: list].filter { $0.dropFirst() != Substring(name) }
355            if state == 0 { terms.append("+" + name) } else if state == 1 { terms.append("-" + name) }
356            filter[keyPath: list] = terms
357            typed = filter.description
358        } label: {
359            HStack {
360                Text(name).foregroundStyle(.primary)
361                Spacer()
362                Text(state == 1 ? "Keep" : state == -1 ? "Leave out" : "").foregroundStyle(.secondary)
363            }
364        }
365    }
366
367    private func apply() {
368        guard typed != filter.description else { return }
369        let tags = Set(items.flatMap(\.tags))
370        let categories = Set(items.map(\.category))
371        filter = AgendaFilter().parse(typed, tags: tags, categories: categories).filter
372    }
373}
374
375/// Where to refile: the targets `org-refile` offers, searchable.
376struct RefileSheet: View {
377    let targets: [RefileTarget]
378    let choose: (RefileTarget?) -> Void
379    @State private var query = ""
380
381    var body: some View {
382        NavigationStack {
383            List(targets.filter { query.isEmpty || $0.label.localizedCaseInsensitiveContains(query) }, id: \.self) { target in
384                Button(target.label) { choose(target) }.foregroundStyle(.primary)
385            }
386            .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always))
387            .navigationTitle("Refile to")
388            .navigationBarTitleDisplayMode(.inline)
389            .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { choose(nil) } } }
390        }
391    }
392}
393#endif