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 @@
1#if os(iOS) 1#if os(iOS)
2import OrgApp 2import OrgApp
3import OrgCore 3import OrgCore
4import OrgDocument
4import SwiftUI 5import SwiftUI
5 6
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.
7struct AgendaScreen: View { 10struct AgendaScreen: View {
8 let workspace: WorkspaceModel 11 let workspace: WorkspaceModel
9 let session: DocumentSession 12 let session: DocumentSession
@@ -11,15 +14,19 @@ struct AgendaScreen: View {
11 let scheduler: ReminderScheduler 14 let scheduler: ReminderScheduler
12 @State private var message: String? 15 @State private var message: String?
13 @State private var tick = 0 16 @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?
14 25
15 private enum View: String, CaseIterable { case agenda = "Agenda", todo = "TODO" } 26 /// A match typed for the tag and property view.
16 27 private enum Asking: Identifiable {
17 private var view: Binding<View> { 28 case match
18 Binding { 29 var id: Int { 0 }
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 }
23 } 30 }
24 31
25 private struct Key: Equatable { 32 private struct Key: Equatable {
@@ -27,19 +34,19 @@ struct AgendaScreen: View {
27 var changed: Int? 34 var changed: Int?
28 var edits: Int 35 var edits: Int
29 var tick: Int 36 var tick: Int
30 var view: String 37 var mode: AgendaMode
31 var start: Int? 38 var start: Int?
32 var span: Int 39 var span: Int
33 var startOffset: Int 40 var startOffset: Int
34 var subfolders: Bool 41 var subfolders: Bool
35 } 42 }
36 43
37 var body: some SwiftUI.View { 44 var body: some View {
38 List { 45 List {
39 if case .agenda = agenda.mode { 46 if case .agenda = agenda.mode {
40 ForEach(agenda.days, id: \.day) { day in 47 ForEach(agenda.days, id: \.day) { day in
41 Section { 48 Section {
42 let items = day.items.filter(\.isEntry) 49 let items = day.items.filter(\.isEntry).filter(filter.keeps)
43 ForEach(items.indices, id: \.self) { i in row(items[i]) } 50 ForEach(items.indices, id: \.self) { i in row(items[i]) }
44 } header: { 51 } header: {
45 Text(day.header.split(separator: " ", omittingEmptySubsequences: true).prefix(4).joined(separator: " ")) 52 Text(day.header.split(separator: " ", omittingEmptySubsequences: true).prefix(4).joined(separator: " "))
@@ -47,53 +54,107 @@ struct AgendaScreen: View {
47 } 54 }
48 } 55 }
49 } else { 56 } 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]) }
51 } 60 }
52 if let reminderStatus { 61 if let reminderStatus {
53 Section { } footer: { Text(reminderStatus) } 62 Section { } footer: { Text(reminderStatus) }
54 } 63 }
55 } 64 }
56 .listStyle(.insetGrouped) 65 .listStyle(.insetGrouped)
57 .navigationTitle(agenda.isComputing && agenda.days.isEmpty ? "Agenda…" : "Agenda") 66 .navigationTitle(viewName)
58 .overlay { 67 .overlay {
59 if workspace.rootPaths.isEmpty { 68 if workspace.rootPaths.isEmpty {
60 ContentUnavailableView("No folders", systemImage: "folder", description: Text("Add a folder of org files under Folders.")) 69 ContentUnavailableView("No folders", systemImage: "folder", description: Text("Add a folder of org files under Folders."))
61 } 70 }
62 } 71 }
63 .safeAreaInset(edge: .bottom) { 72 .safeAreaInset(edge: .bottom) {
64 if let message { 73 if let status = message ?? (filter.isEmpty ? nil : "Filter: " + filter.description) {
65 Text(message).font(.callout).padding(8).frame(maxWidth: .infinity).background(.bar) 74 Text(status).font(.callout).padding(8).frame(maxWidth: .infinity).background(.bar)
75 .onTapGesture { message = nil }
66 } 76 }
67 } 77 }
68 .toolbar { 78 .toolbar {
69 ToolbarItem(placement: .principal) { 79 ToolbarItem(placement: .topBarLeading) {
70 Picker("View", selection: view) { 80 Menu {
71 ForEach(View.allCases, id: \.self) { Text($0.rawValue) } 81 ForEach(views) { definition in
72 } 82 Button(definition.name) { show(definition) }
73 .pickerStyle(.segmented) 83 }
74 .fixedSize() 84 Button("Tags and Properties…") { asking = .match }
75 } 85 if case .agenda = agenda.mode {
76 if case .agenda = agenda.mode { 86 Divider()
77 ToolbarItem(placement: .topBarLeading) {
78 Menu("Days", systemImage: "calendar") {
79 Button("Today", systemImage: "calendar.circle") { agenda.showToday() } 87 Button("Today", systemImage: "calendar.circle") { agenda.showToday() }
80 Button("Earlier", systemImage: "chevron.left") { agenda.shift(forward: false) } 88 Button("Earlier", systemImage: "chevron.left") { agenda.shift(forward: false) }
81 Button("Later", systemImage: "chevron.right") { agenda.shift(forward: true) } 89 Button("Later", systemImage: "chevron.right") { agenda.shift(forward: true) }
82 } 90 }
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
83 } 98 }
84 } 99 }
85 } 100 }
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 }
86 .refreshable { 132 .refreshable {
87 await workspace.refresh() 133 await workspace.refresh()
88 await agenda.refresh(open: session.openTexts) 134 await agenda.refresh(open: session.openTexts)
89 } 135 }
136 .task {
137 let loaded = AgendaViewLoader.load()
138 views = loaded.views
139 if let problem = loaded.problems.first { message = problem }
140 }
90 .task(id: Key(files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, tick: tick, 141 .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,
92 subfolders: agenda.includeSubfolders)) { 143 subfolders: agenda.includeSubfolders)) {
93 await agenda.refresh(open: session.openTexts) 144 await agenda.refresh(open: session.openTexts)
94 } 145 }
95 } 146 }
96 147
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
97 /// How far ahead notifications are set, as the design asks the agenda to show: iOS keeps 158 /// How far ahead notifications are set, as the design asks the agenda to show: iOS keeps
98 /// at most 64, and sets no more while the app isn't opened. 159 /// at most 64, and sets no more while the app isn't opened.
99 private var reminderStatus: String? { 160 private var reminderStatus: String? {
@@ -104,7 +165,9 @@ struct AgendaScreen: View {
104 + ". Open Orgstar to set later ones." 165 + ". Open Orgstar to set later ones."
105 } 166 }
106 167
107 private func row(_ item: AgendaItem) -> some SwiftUI.View { 168 // MARK: - Rows
169
170 private func row(_ item: AgendaItem) -> some View {
108 Group { 171 Group {
109 if let path = item.path, let offset = item.headingOffset { 172 if let path = item.path, let offset = item.headingOffset {
110 NavigationLink(value: ReaderRoute.offset(path, offset)) { entry(item) } 173 NavigationLink(value: ReaderRoute.offset(path, offset)) { entry(item) }
@@ -117,9 +180,21 @@ struct AgendaScreen: View {
117 Button("Done", systemImage: "checkmark") { markDone(item) }.tint(.green) 180 Button("Done", systemImage: "checkmark") { markDone(item) }.tint(.green)
118 } 181 }
119 } 182 }
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 }
120 } 195 }
121 196
122 private func entry(_ item: AgendaItem) -> some SwiftUI.View { 197 private func entry(_ item: AgendaItem) -> some View {
123 VStack(alignment: .leading, spacing: 2) { 198 VStack(alignment: .leading, spacing: 2) {
124 HStack(alignment: .firstTextBaseline, spacing: 6) { 199 HStack(alignment: .firstTextBaseline, spacing: 6) {
125 if let time = item.time.firstMatch(of: /[0-9]{1,2}:[0-9]{2}(-[0-9]{1,2}:[0-9]{2})?/) { 200 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 {
138 } 213 }
139 } 214 }
140 215
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
141 /// The first done keyword of the item's TODO sequence. 253 /// The first done keyword of the item's TODO sequence.
142 private func doneKeyword(for item: AgendaItem) -> String? { 254 private func doneKeyword(for item: AgendaItem) -> String? {
143 guard let todo = item.todo, let path = item.path else { return nil } 255 guard let todo = item.todo, let path = item.path else { return nil }
@@ -147,18 +259,116 @@ struct AgendaScreen: View {
147 } 259 }
148 260
149 private func markDone(_ item: AgendaItem) { 261 private func markDone(_ item: AgendaItem) {
150 guard let path = item.path, let start = item.headingOffset, let keyword = doneKeyword(for: item) else { return } 262 guard let keyword = doneKeyword(for: item) else { return }
151 switch session.perform(SetTodo(keyword: keyword), path: path, at: start, expect: item.headingLine.map { (start, $0) }) { 263 run(SetTodo(keyword: keyword), on: item)
152 case .failed(let text): message = text 264 }
153 case .prompt: message = "This state change asks for a note; make it on the Mac" 265
154 default: message = nil 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
155 } 274 }
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))
156 tick += 1 282 tick += 1
157 } 283 }
158} 284}
159 285
160extension AgendaItem { 286extension AgendaItem: Identifiable {
287 public var id: String { "\(path ?? "")|\(headingOffset ?? -1)|\(kind.rawValue)|\(text)" }
288
161 /// An entry, not a time-grid line. 289 /// An entry, not a time-grid line.
162 var isEntry: Bool { kind != .timeGrid && kind != .currentTime } 290 var isEntry: Bool { kind != .timeGrid && kind != .currentTime }
163} 291}
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}
164#endif 374#endif