Sources/OrgstarMobile/EditorScreen.swift
465 lines · 20659 bytes
18 symbols in this file
1#if os(iOS)
2import OrgApp
3import OrgCore
4import OrgDocument
5import SwiftUI
6import UIKit
7
8/// Editing a file: the text with Org's folding and styling, a bar of Org keys over the keyboard,
9/// and every command in a palette. The editor is the open file's live state, so autosave and
10/// merging go through the session as on the Mac.
11struct EditorScreen: View {
12 let path: String
13 let offset: Int?
14 let workspace: WorkspaceModel
15 let session: DocumentSession
16 @State private var editor: MobileEditor?
17 @State private var prompt: PromptRequest?
18 @State private var message: String?
19 @State private var sheet: Sheet?
20 @State private var trust = TrustStore()
21 /// The command chosen in the palette, run once the palette is gone.
22 @State private var chosen: String?
23 @Environment(ClockModel.self) private var clock
24 @Environment(MobileModel.self) private var model
25 @Environment(\.openURL) private var openURL
26
27 struct PromptRequest: Identifiable {
28 let id = UUID()
29 let prompt: Prompt
30 let reply: (String?) -> Void
31 }
32
33 /// One sheet at a time: two presenters on this screen interfere.
34 enum Sheet: Identifiable {
35 case palette, conflict, syncCopies, recovery
36 case block(BlockEdit)
37
38 var id: String {
39 switch self {
40 case .palette: "palette"
41 case .conflict: "conflict"
42 case .syncCopies: "syncCopies"
43 case .recovery: "recovery"
44 case .block(let edit): "block \(edit.range)"
45 }
46 }
47 }
48
49 var body: some View {
50 Group {
51 if let editor {
52 EditorTextView(editor: editor, palette: { sheet = .palette })
53 .ignoresSafeArea(.container, edges: .bottom)
54 } else {
55 ProgressView()
56 }
57 }
58 .navigationTitle(session.bufferName(path) + (session.dirtyPaths.contains(path) ? " •" : ""))
59 .navigationBarTitleDisplayMode(.inline)
60 .toolbar(.hidden, for: .tabBar)
61 .toolbar {
62 ToolbarItemGroup(placement: .primaryAction) {
63 if session.conflict != nil {
64 Button("Conflict", systemImage: "exclamationmark.triangle") { sheet = .conflict }
65 }
66 Button("Undo", systemImage: "arrow.uturn.backward") { editor?.run("edit.undo") }
67 Menu("More", systemImage: "ellipsis.circle") {
68 Button("Commands", systemImage: "command") { sheet = .palette }
69 Button("Clock In", systemImage: "stopwatch") { editor?.run("app.clock.in") }
70 Toggle(isOn: Binding(get: { model.showMarkup }, set: { model.showMarkup = $0 })) {
71 Label("Show Markup", systemImage: "chevron.left.forwardslash.chevron.right")
72 }
73 RecentClockMenu(clock: clock) { editor?.run($0) }
74 if let text = session.document?.text, session.path == path { ExportMenu(path: path, text: text) }
75 Button("Sync Conflict Copies", systemImage: "doc.on.doc") { sheet = .syncCopies }
76 Button("Recovered Versions", systemImage: "clock.arrow.circlepath") { sheet = .recovery }
77 }
78 }
79 }
80 .safeAreaInset(edge: .top) {
81 if let message {
82 Text(message).font(.callout).padding(8).frame(maxWidth: .infinity).background(.bar)
83 .onTapGesture { self.message = nil }
84 .task(id: message) {
85 try? await Task.sleep(for: .seconds(4))
86 if self.message == message { self.message = nil }
87 }
88 }
89 }
90 .sheet(item: $prompt) { request in
91 PromptSheet(prompt: request.prompt) { answer in
92 prompt = nil
93 request.reply(answer)
94 }
95 }
96 .sheet(item: $sheet, onDismiss: {
97 // After the sheet has gone, so a command's own prompt can show.
98 if let id = chosen {
99 chosen = nil
100 editor?.run(id)
101 }
102 }) { sheet in
103 switch sheet {
104 case .palette: if let editor { CommandPalette(editor: editor) { chosen = $0 } }
105 case .conflict: ConflictSheet(session: session)
106 case .syncCopies: SyncConflictSheet(session: session) { message = $0 }
107 case .recovery: RecoverySheet(session: session) { message = $0 }
108 case .block(let edit):
109 BlockEditSheet(edit: edit) { content in
110 self.sheet = nil
111 if let content, let editor { _ = editor.perform(ReplaceBlockLines(edit: edit, content: content)) }
112 }
113 }
114 }
115 .onChange(of: session.conflict) { _, conflict in
116 if conflict != nil, session.path == path { sheet = .conflict }
117 }
118 .onChange(of: session.lastError) { _, error in
119 if let error { message = error; session.lastError = nil }
120 }
121 .onAppear(perform: start)
122 .onChange(of: session.revealRequest?.id) {
123 if let offset = session.revealRequest?.offset, session.path == path { editor?.reveal(offset) }
124 }
125 .onChange(of: model.settingsVersion) {
126 editor?.options = session.options
127 editor?.themeSpec = model.theme
128 }
129 .onChange(of: model.showMarkup) { editor?.showMarkup = model.showMarkup }
130 .onDisappear {
131 // Typing the reader shows next, and a save, don't wait for the autosave delay.
132 if session.dirtyPaths.contains(path) { _ = session.save(path) }
133 }
134 }
135
136 /// Store Link, Store ID Link and Insert Link, with the links stored while the app runs.
137 private func link(_ id: String) {
138 guard let editor else { return }
139 switch id {
140 case "org.link.store":
141 message = StoredLink.add(Links.store(in: editor.editContext(), path: Links.abbreviate(path, home: NSHomeDirectory())), to: &model.storedLinks)
142 case "org.id.store-link":
143 if case .commit = editor.perform(CreateID()),
144 let link = Links.storeID(in: editor.editContext(), fileName: (path as NSString).lastPathComponent) {
145 message = StoredLink.add(link, to: &model.storedLinks)
146 }
147 default:
148 _ = editor.perform(InsertLink(stored: model.storedLinks, file: path))
149 }
150 }
151
152 /// Runs a block once it's trusted, asking as `org-confirm-babel-evaluate` does.
153 private func run(_ job: BabelJob) {
154 let go = {
155 message = "Running \(job.language) block…"
156 session.startBabel(job) { message = $0 }
157 }
158 if !job.alwaysAsk, trust.isTrusted(path: path, content: job.blockText) { return go() }
159 let choices = job.alwaysAsk ? ["yes", "no"] : ["yes", "no", "always"]
160 prompt = PromptRequest(prompt: Prompt(key: "confirm", message: "Run this \(job.language) block?", choices: choices)) { answer in
161 switch answer {
162 case "always":
163 trust.trust(path: path, content: job.blockText)
164 go()
165 case "yes": go()
166 default: message = "Not run"
167 }
168 }
169 }
170
171 private func start() {
172 if session.path != path { session.open(path) }
173 if editor == nil, let document = session.document {
174 let editor = MobileEditor(
175 document: document, editable: document.isEditable, options: session.options, theme: model.theme,
176 showMarkup: model.showMarkup, baseDirectory: URL(fileURLWithPath: path).deletingLastPathComponent()
177 )
178 editor.knownTags = { [index = workspace.index] in (try? index.tags()) ?? [] }
179 editor.onChange = { [weak session] in session?.bufferChanged(path) }
180 editor.onMessage = { message = $0 }
181 editor.onPrompt = { prompt, reply in self.prompt = PromptRequest(prompt: prompt, reply: reply) }
182 editor.onLink = { link in
183 switch session.follow(link, workspace: workspace, isText: { _ in false }) {
184 case .shown: message = session.path == path ? nil : "Opened \((session.path.map { ($0 as NSString).lastPathComponent }) ?? "")"
185 case .openExternally(let url): openURL(url)
186 case .message(let text): message = text
187 }
188 }
189 editor.onEditBlock = { edit in sheet = .block(edit) }
190 editor.onTangle = { scope in message = session.tangle(scope) }
191 editor.onBabel = { job in run(job) }
192 editor.onRefreshSetup = { session.refreshSetup() }
193 editor.onAgenda = { start, count in model.agendaRequest = .init(start: start, count: count) }
194 editor.onAppCommand = { id in
195 if id == "app.toggle-markup" {
196 model.showMarkup.toggle()
197 } else if MobileEditor.linkCommands.contains(id) {
198 link(id)
199 } else if !MobileClock.run(id, model: model, workspace: workspace, show: { message = $0 }) {
200 message = "Not available on iOS yet"
201 }
202 }
203 self.editor = editor
204 }
205 if let editor { session.attach(editor) }
206 if let offset { editor?.reveal(offset) }
207 if session.conflict != nil {
208 sheet = .conflict
209 } else if case let copies = session.syncConflictCopies().count, copies > 0 {
210 message = "\(copies) sync conflict \(copies == 1 ? "copy" : "copies") of this file; More › Sync Conflict Copies compares them."
211 }
212 }
213}
214
215/// The editor's text view, with the Org key bar over the keyboard.
216struct EditorTextView: UIViewRepresentable {
217 let editor: MobileEditor
218 let palette: () -> Void
219
220 func makeUIView(context: Context) -> UITextView {
221 let bar = UIHostingController(rootView: KeyBar(editor: editor, palette: palette))
222 bar.view.frame = CGRect(x: 0, y: 0, width: 400, height: 48)
223 bar.view.backgroundColor = .clear
224 bar.sizingOptions = []
225 editor.textView.inputAccessoryView = bar.view
226 context.coordinator.bar = bar
227 return editor.textView
228 }
229
230 func updateUIView(_ view: UITextView, context: Context) {}
231
232 func makeCoordinator() -> Coordinator { Coordinator() }
233
234 final class Coordinator {
235 var bar: UIViewController?
236 }
237}
238
239/// Org's keys for a touch keyboard: each button does what its key does at the caret, as the
240/// Emacs keymap binds it (M-→ demotes a heading, indents an item, moves a table column).
241struct KeyBar: View {
242 let editor: MobileEditor
243 let palette: () -> Void
244
245 private static let keys: [(label: String, symbol: String?, keys: String)] = [
246 ("Fold", "arrow.right.to.line", "TAB"),
247 ("Overview", "arrow.left.to.line", "S-TAB"),
248 ("Promote", "arrow.left", "M-<left>"),
249 ("Demote", "arrow.right", "M-<right>"),
250 ("Move up", "arrow.up", "M-<up>"),
251 ("Move down", "arrow.down", "M-<down>"),
252 ("New heading or item", "plus", "M-RET"),
253 ("TODO", nil, "C-c C-t"),
254 ("Act at point", "checkmark.square", "C-c C-c"),
255 ("Schedule", "calendar", "C-c C-s"),
256 ("Deadline", "calendar.badge.exclamationmark", "C-c C-d"),
257 ("Tags", "tag", "C-c C-q"),
258 ("Open link", "link", "C-c C-o"),
259 ]
260
261 var body: some View {
262 ScrollView(.horizontal, showsIndicators: false) {
263 HStack(spacing: 4) {
264 Button("Commands", systemImage: "command", action: palette)
265 ForEach(Self.keys, id: \.keys) { key in
266 Button {
267 editor.run(keys: key.keys)
268 } label: {
269 if let symbol = key.symbol { Label(key.label, systemImage: symbol) } else { Text(key.label).font(.callout.weight(.semibold)) }
270 }
271 .accessibilityLabel(key.label)
272 }
273 Button("Hide keyboard", systemImage: "keyboard.chevron.compact.down") { editor.textView.resignFirstResponder() }
274 }
275 .labelStyle(.iconOnly)
276 .buttonStyle(.bordered)
277 .padding(.horizontal, 8)
278 .padding(.vertical, 6)
279 }
280 .background(.bar)
281 }
282}
283
284/// A command's question: text with choices, a date, or fast selection by keys.
285struct PromptSheet: View {
286 let prompt: Prompt
287 let answer: (String?) -> Void
288 @State private var text = ""
289 @State private var date = Date()
290 @State private var keys: [FastSelection.Input] = []
291
292 var body: some View {
293 NavigationStack {
294 Form {
295 if let fast = prompt.fast {
296 fastSection(fast)
297 } else {
298 Section {
299 TextField(prompt.message, text: $text)
300 .autocorrectionDisabled()
301 .textInputAutocapitalization(.never)
302 .onSubmit { answer(text) }
303 } footer: {
304 if prompt.date != nil { Text("Org's date syntax works too: +2d, fri, 14:00") }
305 }
306 if prompt.date != nil {
307 DatePicker("Date", selection: $date, displayedComponents: [.date])
308 .datePickerStyle(.graphical)
309 .onChange(of: date) { text = Self.stamp(date) }
310 }
311 if !prompt.choices.isEmpty {
312 let matching = prompt.choices.filter { lastPart.isEmpty || $0.localizedCaseInsensitiveContains(lastPart) }
313 Section("Choices") {
314 ForEach(matching.prefix(50), id: \.self) { choice in
315 Button(choice) { choose(choice) }
316 }
317 }
318 }
319 }
320 }
321 .navigationTitle(prompt.message.trimmingCharacters(in: CharacterSet(charactersIn: ": ")))
322 .navigationBarTitleDisplayMode(.inline)
323 .toolbar {
324 ToolbarItem(placement: .cancellationAction) { Button("Cancel") { answer(nil) } }
325 if prompt.fast == nil || prompt.fast?.multiple == true {
326 ToolbarItem(placement: .confirmationAction) {
327 Button("Done") { answer(prompt.fast == nil ? text : FastSelection.encode(keys)) }
328 .disabled(prompt.requireMatch && prompt.fast == nil && !prompt.choices.isEmpty && !prompt.choices.contains(text))
329 }
330 }
331 }
332 }
333 .onAppear {
334 text = prompt.initial
335 if let day = prompt.date?.defaultDate { date = day }
336 }
337 }
338
339 /// With a separator (tags), what's typed after the last one.
340 private var lastPart: String {
341 guard let separator = prompt.completionSeparator else { return text }
342 return String(text.split(separator: separator, omittingEmptySubsequences: false).last ?? "")
343 }
344
345 private func choose(_ choice: String) {
346 guard let separator = prompt.completionSeparator else {
347 text = choice
348 if prompt.date == nil { answer(choice) }
349 return
350 }
351 var parts = text.split(separator: separator, omittingEmptySubsequences: false).map(String.init)
352 if parts.isEmpty { parts = [""] }
353 parts[parts.count - 1] = choice
354 text = parts.joined(separator: String(separator)) + String(separator)
355 }
356
357 @ViewBuilder
358 private func fastSection(_ fast: FastSelection) -> some View {
359 let current = fast.multiple ? fast.tags(after: keys) ?? [] : []
360 Section {
361 ForEach(Array(fast.options.enumerated()), id: \.offset) { _, option in
362 Button {
363 if fast.multiple { keys.append(.key(option.key)) } else { answer(String(option.key)) }
364 } label: {
365 HStack {
366 Text(option.name).foregroundStyle(.primary)
367 Spacer()
368 if current.contains(option.name) { Image(systemName: "checkmark") }
369 Text(String(option.key)).font(.caption.monospaced()).foregroundStyle(.secondary)
370 }
371 }
372 }
373 if !fast.multiple {
374 Button("No keyword") { answer(" ") }
375 }
376 } footer: {
377 if fast.multiple, !fast.inherited.isEmpty { Text("Inherited: :" + fast.inherited.joined(separator: ":") + ":") }
378 }
379 }
380
381 static func stamp(_ date: Date) -> String {
382 let formatter = DateFormatter()
383 formatter.locale = Locale(identifier: "en_US_POSIX")
384 formatter.dateFormat = "yyyy-MM-dd"
385 return formatter.string(from: date)
386 }
387}
388
389/// `org-edit-special` and `org-table-edit-field`: the text edited apart, put back with Save.
390struct BlockEditSheet: View {
391 let edit: BlockEdit
392 let finish: (String?) -> Void
393 @State private var text = ""
394 @FocusState private var focused: Bool
395
396 var body: some View {
397 NavigationStack {
398 TextEditor(text: $text)
399 .font(.body.monospaced())
400 .autocorrectionDisabled()
401 .textInputAutocapitalization(.never)
402 .focused($focused)
403 .padding(.horizontal, 8)
404 .navigationTitle(edit.title.replacingOccurrences(of: "Editing ", with: "").capitalized(with: nil))
405 .navigationBarTitleDisplayMode(.inline)
406 .toolbar {
407 ToolbarItem(placement: .cancellationAction) { Button("Cancel") { finish(nil) } }
408 ToolbarItem(placement: .confirmationAction) { Button("Save") { finish(text) } }
409 }
410 }
411 .onAppear {
412 text = edit.content
413 focused = true
414 }
415 }
416}
417
418/// Every command that applies at the caret, by name.
419struct CommandPalette: View {
420 let editor: MobileEditor
421 /// Called with the chosen command before the palette closes.
422 let choose: (String) -> Void
423 @State private var query = ""
424 @Environment(\.dismiss) private var dismiss
425
426 private static let viewCommands: [(id: String, title: String)] = [
427 ("org.cycle", "Cycle Visibility"), ("org.cycle-global", "Cycle Global Visibility"),
428 ("edit.undo", "Undo"), ("edit.redo", "Redo"),
429 ("app.clock.in", "Clock In"), ("app.clock.out", "Clock Out"), ("app.clock.cancel", "Cancel Clock"),
430 ("app.clock.goto", "Go to Clocked Entry"), ("app.clock.in-recent", "Clock In to Recent Entry…"),
431 ("app.clock.in-last", "Clock In to Last Entry"), ("app.clock.goto-recent", "Go to Recent Clocked Entry…"),
432 ("app.clock.default", "Mark as Default Clock Task"), ("app.clock.resolve", "Resolve Open Clocks…"),
433 ("app.toggle-markup", "Show or Hide Markup"),
434 ("org.toggle-inline-images", "Show or Hide Inline Images"), ("editor.toggle-truncate-lines", "Truncate or Wrap Long Lines"),
435 ("org.table.toggle-column-width", "Shrink or Expand Table Column"), ("org.table.shrink", "Shrink Table Columns with Widths"),
436 ("org.table.expand", "Expand Table Columns"),
437 ]
438
439 var body: some View {
440 let context = editor.editContext()
441 let all = Self.viewCommands + Commands.everything.map { ($0.id, $0.title) }
442 let shown = all.filter { editor.applies($0.id, in: context) && (query.isEmpty || $0.title.localizedCaseInsensitiveContains(query)) }
443 NavigationStack {
444 List(shown, id: \.id) { command in
445 Button {
446 choose(command.id)
447 dismiss()
448 } label: {
449 HStack {
450 Text(command.title).foregroundStyle(.primary)
451 Spacer()
452 if let keys = editor.keymap.keyLabels(for: command.id).first {
453 Text(keys).font(.caption.monospaced()).foregroundStyle(.secondary)
454 }
455 }
456 }
457 }
458 .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always))
459 .navigationTitle("Commands")
460 .navigationBarTitleDisplayMode(.inline)
461 .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Close") { dismiss() } } }
462 }
463 }
464}
465#endif