krz/orgstar

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

Sources/OrgstarMobile/AgendaScreen.swift

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

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