krz/orgstar

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

Commit ed6e31776e

ed6e31776e956b9ee4d2e06de012dd6e01c8eb75

parent: 81f64216be

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-07 00:01 UTC

iOS agenda: views, filters and entry actions

The views menu has the built-in views, those in views.toml, and a tag and
property match. The filter sheet keeps or leaves out the tags and categories
shown, or takes Org's / filter syntax. A long press on an entry changes its
TODO state, schedule, deadline, tags or priority (asking as the commands ask),
refiles it to a searchable target, or archives it; the entry is found again
by its heading line first, as on the Mac.

Layout: unified · split

Sources/OrgstarMobile/AgendaScreen.swift +245 −35
@@ -1,9 +1,12 @@
11#if os(iOS)
22import OrgApp
33import OrgCore
4import OrgDocument
45import SwiftUI
56
6/// The day/week agenda and the TODO list over the workspace's agenda files.
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.
710struct AgendaScreen: View {
811 let workspace: WorkspaceModel
912 let session: DocumentSession
@@ -11,15 +14,19 @@ struct AgendaScreen: View {
1114 let scheduler: ReminderScheduler
1215 @State private var message: String?
1316 @State private var tick = 0
17 @State private var views = AgendaViewLoader.builtIn
18 @State private var viewName = "Agenda"
19 @State private var filter = AgendaFilter()
20 @State private var showingFilter = false
21 @State private var asking: Asking?
22 @State private var prompt: EditorScreen.PromptRequest?
23 @State private var refiling: AgendaItem?
24 @State private var archiving: AgendaItem?
1425
15 private enum View: String, CaseIterable { case agenda = "Agenda", todo = "TODO" }
16
17 private var view: Binding<View> {
18 Binding {
19 if case .todo = agenda.mode { .todo } else { .agenda }
20 } set: { value in
21 agenda.mode = value == .todo ? .todo(keywords: nil) : .agenda(span: nil, startOffset: nil)
22 }
26 /// A match typed for the tag and property view.
27 private enum Asking: Identifiable {
28 case match
29 var id: Int { 0 }
2330 }
2431
2532 private struct Key: Equatable {
@@ -27,19 +34,19 @@ struct AgendaScreen: View {
2734 var changed: Int?
2835 var edits: Int
2936 var tick: Int
30 var view: String
37 var mode: AgendaMode
3138 var start: Int?
3239 var span: Int
3340 var startOffset: Int
3441 var subfolders: Bool
3542 }
3643
37 var body: some SwiftUI.View {
44 var body: some View {
3845 List {
3946 if case .agenda = agenda.mode {
4047 ForEach(agenda.days, id: \.day) { day in
4148 Section {
42 let items = day.items.filter(\.isEntry)
49 let items = day.items.filter(\.isEntry).filter(filter.keeps)
4350 ForEach(items.indices, id: \.self) { i in row(items[i]) }
4451 } header: {
4552 Text(day.header.split(separator: " ", omittingEmptySubsequences: true).prefix(4).joined(separator: " "))
@@ -47,53 +54,107 @@ struct AgendaScreen: View {
4754 }
4855 }
4956 } else {
50 ForEach(agenda.list.indices, id: \.self) { i in row(agenda.list[i]) }
57 let items = agenda.list.filter(filter.keeps)
58 if items.isEmpty, !agenda.isComputing { Text("No entries").foregroundStyle(.secondary) }
59 ForEach(items.indices, id: \.self) { i in row(items[i]) }
5160 }
5261 if let reminderStatus {
5362 Section { } footer: { Text(reminderStatus) }
5463 }
5564 }
5665 .listStyle(.insetGrouped)
57 .navigationTitle(agenda.isComputing && agenda.days.isEmpty ? "Agenda…" : "Agenda")
66 .navigationTitle(viewName)
5867 .overlay {
5968 if workspace.rootPaths.isEmpty {
6069 ContentUnavailableView("No folders", systemImage: "folder", description: Text("Add a folder of org files under Folders."))
6170 }
6271 }
6372 .safeAreaInset(edge: .bottom) {
64 if let message {
65 Text(message).font(.callout).padding(8).frame(maxWidth: .infinity).background(.bar)
73 if let status = message ?? (filter.isEmpty ? nil : "Filter: " + filter.description) {
74 Text(status).font(.callout).padding(8).frame(maxWidth: .infinity).background(.bar)
75 .onTapGesture { message = nil }
6676 }
6777 }
6878 .toolbar {
69 ToolbarItem(placement: .principal) {
70 Picker("View", selection: view) {
71 ForEach(View.allCases, id: \.self) { Text($0.rawValue) }
72 }
73 .pickerStyle(.segmented)
74 .fixedSize()
75 }
76 if case .agenda = agenda.mode {
77 ToolbarItem(placement: .topBarLeading) {
78 Menu("Days", systemImage: "calendar") {
79 ToolbarItem(placement: .topBarLeading) {
80 Menu {
81 ForEach(views) { definition in
82 Button(definition.name) { show(definition) }
83 }
84 Button("Tags and Properties…") { asking = .match }
85 if case .agenda = agenda.mode {
86 Divider()
7987 Button("Today", systemImage: "calendar.circle") { agenda.showToday() }
8088 Button("Earlier", systemImage: "chevron.left") { agenda.shift(forward: false) }
8189 Button("Later", systemImage: "chevron.right") { agenda.shift(forward: true) }
8290 }
91 } label: {
92 Label("Views", systemImage: "calendar")
93 }
94 }
95 ToolbarItem(placement: .topBarLeading) {
96 Button("Filter", systemImage: filter.isEmpty ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill") {
97 showingFilter = true
8398 }
8499 }
85100 }
101 .sheet(isPresented: $showingFilter) {
102 FilterSheet(filter: $filter, items: shownItems)
103 }
104 .sheet(item: $asking) { _ in
105 PromptSheet(prompt: Prompt(key: "match", message: "Match: tags and properties, as +work-home or TODO=\"WAIT\"")) { answer in
106 asking = nil
107 guard let answer, !answer.isEmpty else { return }
108 agenda.mode = .match(answer, todoOnly: false)
109 viewName = answer
110 }
111 }
112 .sheet(item: $prompt) { request in
113 PromptSheet(prompt: request.prompt) { answer in
114 prompt = nil
115 request.reply(answer)
116 }
117 }
118 .sheet(item: $refiling) { item in
119 RefileSheet(targets: workspace.refileTargets(openPath: session.path, document: session.document)) { target in
120 refiling = nil
121 if let target { refile(item, to: target) }
122 }
123 }
124 .confirmationDialog("Archive this entry?", isPresented: Binding { archiving != nil } set: { if !$0 { archiving = nil } }, titleVisibility: .visible) {
125 Button("Archive", role: .destructive) {
126 if let item = archiving { archive(item) }
127 archiving = nil
128 }
129 } message: {
130 Text(archiving?.text ?? "")
131 }
86132 .refreshable {
87133 await workspace.refresh()
88134 await agenda.refresh(open: session.openTexts)
89135 }
136 .task {
137 let loaded = AgendaViewLoader.load()
138 views = loaded.views
139 if let problem = loaded.problems.first { message = problem }
140 }
90141 .task(id: Key(files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, tick: tick,
91 view: view.wrappedValue.rawValue, start: agenda.start, span: agenda.span, startOffset: agenda.startOffset,
142 mode: agenda.mode, start: agenda.start, span: agenda.span, startOffset: agenda.startOffset,
92143 subfolders: agenda.includeSubfolders)) {
93144 await agenda.refresh(open: session.openTexts)
94145 }
95146 }
96147
148 private var shownItems: [AgendaItem] {
149 if case .agenda = agenda.mode { return agenda.days.flatMap(\.items).filter(\.isEntry) }
150 return agenda.list
151 }
152
153 private func show(_ definition: AgendaViewDefinition) {
154 viewName = definition.name
155 agenda.mode = definition.mode
156 }
157
97158 /// How far ahead notifications are set, as the design asks the agenda to show: iOS keeps
98159 /// at most 64, and sets no more while the app isn't opened.
99160 private var reminderStatus: String? {
@@ -104,7 +165,9 @@ struct AgendaScreen: View {
104165 + ". Open Orgstar to set later ones."
105166 }
106167
107 private func row(_ item: AgendaItem) -> some SwiftUI.View {
168 // MARK: - Rows
169
170 private func row(_ item: AgendaItem) -> some View {
108171 Group {
109172 if let path = item.path, let offset = item.headingOffset {
110173 NavigationLink(value: ReaderRoute.offset(path, offset)) { entry(item) }
@@ -117,9 +180,21 @@ struct AgendaScreen: View {
117180 Button("Done", systemImage: "checkmark") { markDone(item) }.tint(.green)
118181 }
119182 }
183 .contextMenu {
184 if item.path != nil {
185 Button("TODO State…", systemImage: "checkmark.circle") { run("org.todo.cycle", on: item) }
186 Button("Schedule…", systemImage: "calendar") { run("org.schedule", on: item) }
187 Button("Deadline…", systemImage: "calendar.badge.exclamationmark") { run("org.deadline", on: item) }
188 Button("Tags…", systemImage: "tag") { run("org.tags.set", on: item) }
189 Button("Priority…", systemImage: "exclamationmark.circle") { run("org.priority.set", on: item) }
190 Divider()
191 Button("Refile…", systemImage: "arrow.turn.down.right") { refiling = item }
192 Button("Archive…", systemImage: "archivebox") { archiving = item }
193 }
194 }
120195 }
121196
122 private func entry(_ item: AgendaItem) -> some SwiftUI.View {
197 private func entry(_ item: AgendaItem) -> some View {
123198 VStack(alignment: .leading, spacing: 2) {
124199 HStack(alignment: .firstTextBaseline, spacing: 6) {
125200 if let time = item.time.firstMatch(of: /[0-9]{1,2}:[0-9]{2}(-[0-9]{1,2}:[0-9]{2})?/) {
@@ -138,6 +213,43 @@ struct AgendaScreen: View {
138213 }
139214 }
140215
216 // MARK: - Actions
217
218 /// The heading of `item` now, found again by its line in case the file changed.
219 private func locate(_ item: AgendaItem) -> (path: String, offset: Int)? {
220 guard let path = item.path, let line = item.headingLine, let near = item.headingOffset,
221 let offset = session.locateHeading(path: path, line: line, near: near) else {
222 message = "The entry changed; the agenda has been refreshed."
223 tick += 1
224 return nil
225 }
226 return (path, offset)
227 }
228
229 /// Runs a command at the entry's heading, asking what it asks.
230 private func run(_ id: String, on item: AgendaItem, answers: [String: String] = [:]) {
231 guard let command = Commands.command(id) else { return }
232 run(command, on: item, answers: answers)
233 }
234
235 private func run(_ command: any OrgCommand, on item: AgendaItem, answers: [String: String] = [:]) {
236 guard let (path, offset) = locate(item), let line = item.headingLine else { return }
237 switch session.perform(command, path: path, at: offset, expect: (offset, line), answers: answers) {
238 case .prompt(let request):
239 prompt = EditorScreen.PromptRequest(prompt: request) { answer in
240 guard let answer else { return }
241 run(command, on: item, answers: answers.merging([request.key: answer]) { $1 })
242 }
243 case .failed(let text):
244 message = text
245 case .external:
246 message = "\(command.title) needs Emacs, which runs on the Mac."
247 case .commit:
248 message = nil
249 }
250 tick += 1
251 }
252
141253 /// The first done keyword of the item's TODO sequence.
142254 private func doneKeyword(for item: AgendaItem) -> String? {
143255 guard let todo = item.todo, let path = item.path else { return nil }
@@ -147,18 +259,116 @@ struct AgendaScreen: View {
147259 }
148260
149261 private func markDone(_ item: AgendaItem) {
150 guard let path = item.path, let start = item.headingOffset, let keyword = doneKeyword(for: item) else { return }
151 switch session.perform(SetTodo(keyword: keyword), path: path, at: start, expect: item.headingLine.map { (start, $0) }) {
152 case .failed(let text): message = text
153 case .prompt: message = "This state change asks for a note; make it on the Mac"
154 default: message = nil
262 guard let keyword = doneKeyword(for: item) else { return }
263 run(SetTodo(keyword: keyword), on: item)
264 }
265
266 private func refile(_ item: AgendaItem, to target: RefileTarget) {
267 guard let (path, offset) = locate(item) else { return }
268 // Targets in the entry's own file come from its text now.
269 let document = session.openDocuments.first { $0.path == path }?.document
270 ?? FileManager.default.contents(atPath: path).map { DocumentState(bytes: [UInt8]($0)) }
271 guard let current = workspace.refileTargets(openPath: path, document: document).first(where: { $0.label == target.label }) else {
272 message = "Can't find \(target.label) any more."
273 return
155274 }
275 message = session.refile(to: current, from: (path, offset))
276 tick += 1
277 }
278
279 private func archive(_ item: AgendaItem) {
280 guard let (path, offset) = locate(item) else { return }
281 message = session.archive(from: (path, offset))
156282 tick += 1
157283 }
158284}
159285
160extension AgendaItem {
286extension AgendaItem: Identifiable {
287 public var id: String { "\(path ?? "")|\(headingOffset ?? -1)|\(kind.rawValue)|\(text)" }
288
161289 /// An entry, not a time-grid line.
162290 var isEntry: Bool { kind != .timeGrid && kind != .currentTime }
163291}
292
293/// Org's agenda filter: tags and categories of the entries shown, kept or left out, or the
294/// filter typed as `/` takes it.
295struct FilterSheet: View {
296 @Binding var filter: AgendaFilter
297 let items: [AgendaItem]
298 @State private var typed = ""
299 @Environment(\.dismiss) private var dismiss
300
301 var body: some View {
302 let tags = Set(items.flatMap(\.tags)).sorted()
303 let categories = Set(items.filter { $0.path != nil }.map(\.category)).sorted()
304 NavigationStack {
305 Form {
306 Section {
307 TextField("+work-home", text: $typed)
308 .autocorrectionDisabled()
309 .textInputAutocapitalization(.never)
310 .onSubmit(apply)
311 } footer: {
312 Text("As Org's / filter: +keep and -drop tags or categories, <0:30 for effort, /regexp/.")
313 }
314 if !tags.isEmpty {
315 Section("Tags") { ForEach(tags, id: \.self) { term($0, in: \.tags) } }
316 }
317 if !categories.isEmpty {
318 Section("Categories") { ForEach(categories, id: \.self) { term($0, in: \.categories) } }
319 }
320 }
321 .navigationTitle("Filter")
322 .navigationBarTitleDisplayMode(.inline)
323 .toolbar {
324 ToolbarItem(placement: .cancellationAction) { Button("Clear") { filter = AgendaFilter(); dismiss() } }
325 ToolbarItem(placement: .confirmationAction) { Button("Done") { apply(); dismiss() } }
326 }
327 }
328 .onAppear { typed = filter.description }
329 }
330
331 /// Keep, leave out, or no filter on `name`.
332 private func term(_ name: String, in list: WritableKeyPath<AgendaFilter, [String]>) -> some View {
333 let state = filter[keyPath: list].contains("+" + name) ? 1 : filter[keyPath: list].contains("-" + name) ? -1 : 0
334 return Button {
335 var terms = filter[keyPath: list].filter { $0.dropFirst() != Substring(name) }
336 if state == 0 { terms.append("+" + name) } else if state == 1 { terms.append("-" + name) }
337 filter[keyPath: list] = terms
338 typed = filter.description
339 } label: {
340 HStack {
341 Text(name).foregroundStyle(.primary)
342 Spacer()
343 Text(state == 1 ? "Keep" : state == -1 ? "Leave out" : "").foregroundStyle(.secondary)
344 }
345 }
346 }
347
348 private func apply() {
349 guard typed != filter.description else { return }
350 let tags = Set(items.flatMap(\.tags))
351 let categories = Set(items.map(\.category))
352 filter = AgendaFilter().parse(typed, tags: tags, categories: categories).filter
353 }
354}
355
356/// Where to refile: the targets `org-refile` offers, searchable.
357struct RefileSheet: View {
358 let targets: [RefileTarget]
359 let choose: (RefileTarget?) -> Void
360 @State private var query = ""
361
362 var body: some View {
363 NavigationStack {
364 List(targets.filter { query.isEmpty || $0.label.localizedCaseInsensitiveContains(query) }, id: \.self) { target in
365 Button(target.label) { choose(target) }.foregroundStyle(.primary)
366 }
367 .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always))
368 .navigationTitle("Refile to")
369 .navigationBarTitleDisplayMode(.inline)
370 .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { choose(nil) } } }
371 }
372 }
373}
164374#endif