krz/orgstar

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

Sources/OrgstarMobile/AgendaScreen.swift

778058aafc7ddacee4a613b1da87c60aa0c8e113
orgstar/Sources/OrgstarMobile/AgendaScreen.swift history · blame · raw

380 lines · 16669 bytes

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