Sources/OrgstarMobile/EditorScreen.swift
445 lines · 19588 bytes
17 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 /// Runs a block once it's trusted, asking as `org-confirm-babel-evaluate` does.
137 private func run(_ job: BabelJob) {
138 let go = {
139 message = "Running \(job.language) block…"
140 session.startBabel(job) { message = $0 }
141 }
142 if !job.alwaysAsk, trust.isTrusted(path: path, content: job.blockText) { return go() }
143 let choices = job.alwaysAsk ? ["yes", "no"] : ["yes", "no", "always"]
144 prompt = PromptRequest(prompt: Prompt(key: "confirm", message: "Run this \(job.language) block?", choices: choices)) { answer in
145 switch answer {
146 case "always":
147 trust.trust(path: path, content: job.blockText)
148 go()
149 case "yes": go()
150 default: message = "Not run"
151 }
152 }
153 }
154
155 private func start() {
156 if session.path != path { session.open(path) }
157 if editor == nil, let document = session.document {
158 let editor = MobileEditor(
159 document: document, editable: document.isEditable, options: session.options, theme: model.theme,
160 showMarkup: model.showMarkup, baseDirectory: URL(fileURLWithPath: path).deletingLastPathComponent()
161 )
162 editor.knownTags = { [index = workspace.index] in (try? index.tags()) ?? [] }
163 editor.onChange = { [weak session] in session?.bufferChanged(path) }
164 editor.onMessage = { message = $0 }
165 editor.onPrompt = { prompt, reply in self.prompt = PromptRequest(prompt: prompt, reply: reply) }
166 editor.onLink = { link in
167 switch session.follow(link, workspace: workspace, isText: { _ in false }) {
168 case .shown: message = session.path == path ? nil : "Opened \((session.path.map { ($0 as NSString).lastPathComponent }) ?? "")"
169 case .openExternally(let url): openURL(url)
170 case .message(let text): message = text
171 }
172 }
173 editor.onEditBlock = { edit in sheet = .block(edit) }
174 editor.onTangle = { scope in message = session.tangle(scope) }
175 editor.onBabel = { job in run(job) }
176 editor.onRefreshSetup = { session.refreshSetup() }
177 editor.onAgenda = { start, count in model.agendaRequest = .init(start: start, count: count) }
178 editor.onAppCommand = { id in
179 if id == "app.toggle-markup" {
180 model.showMarkup.toggle()
181 } else if !MobileClock.run(id, model: model, workspace: workspace, show: { message = $0 }) {
182 message = "Not available on iOS yet"
183 }
184 }
185 self.editor = editor
186 }
187 if let editor { session.attach(editor) }
188 if let offset { editor?.reveal(offset) }
189 if session.conflict != nil {
190 sheet = .conflict
191 } else if case let copies = session.syncConflictCopies().count, copies > 0 {
192 message = "\(copies) sync conflict \(copies == 1 ? "copy" : "copies") of this file; More › Sync Conflict Copies compares them."
193 }
194 }
195}
196
197/// The editor's text view, with the Org key bar over the keyboard.
198struct EditorTextView: UIViewRepresentable {
199 let editor: MobileEditor
200 let palette: () -> Void
201
202 func makeUIView(context: Context) -> UITextView {
203 let bar = UIHostingController(rootView: KeyBar(editor: editor, palette: palette))
204 bar.view.frame = CGRect(x: 0, y: 0, width: 400, height: 48)
205 bar.view.backgroundColor = .clear
206 bar.sizingOptions = []
207 editor.textView.inputAccessoryView = bar.view
208 context.coordinator.bar = bar
209 return editor.textView
210 }
211
212 func updateUIView(_ view: UITextView, context: Context) {}
213
214 func makeCoordinator() -> Coordinator { Coordinator() }
215
216 final class Coordinator {
217 var bar: UIViewController?
218 }
219}
220
221/// Org's keys for a touch keyboard: each button does what its key does at the caret, as the
222/// Emacs keymap binds it (M-→ demotes a heading, indents an item, moves a table column).
223struct KeyBar: View {
224 let editor: MobileEditor
225 let palette: () -> Void
226
227 private static let keys: [(label: String, symbol: String?, keys: String)] = [
228 ("Fold", "arrow.right.to.line", "TAB"),
229 ("Overview", "arrow.left.to.line", "S-TAB"),
230 ("Promote", "arrow.left", "M-<left>"),
231 ("Demote", "arrow.right", "M-<right>"),
232 ("Move up", "arrow.up", "M-<up>"),
233 ("Move down", "arrow.down", "M-<down>"),
234 ("New heading or item", "plus", "M-RET"),
235 ("TODO", nil, "C-c C-t"),
236 ("Act at point", "checkmark.square", "C-c C-c"),
237 ("Schedule", "calendar", "C-c C-s"),
238 ("Deadline", "calendar.badge.exclamationmark", "C-c C-d"),
239 ("Tags", "tag", "C-c C-q"),
240 ("Open link", "link", "C-c C-o"),
241 ]
242
243 var body: some View {
244 ScrollView(.horizontal, showsIndicators: false) {
245 HStack(spacing: 4) {
246 Button("Commands", systemImage: "command", action: palette)
247 ForEach(Self.keys, id: \.keys) { key in
248 Button {
249 editor.run(keys: key.keys)
250 } label: {
251 if let symbol = key.symbol { Label(key.label, systemImage: symbol) } else { Text(key.label).font(.callout.weight(.semibold)) }
252 }
253 .accessibilityLabel(key.label)
254 }
255 Button("Hide keyboard", systemImage: "keyboard.chevron.compact.down") { editor.textView.resignFirstResponder() }
256 }
257 .labelStyle(.iconOnly)
258 .buttonStyle(.bordered)
259 .padding(.horizontal, 8)
260 .padding(.vertical, 6)
261 }
262 .background(.bar)
263 }
264}
265
266/// A command's question: text with choices, a date, or fast selection by keys.
267struct PromptSheet: View {
268 let prompt: Prompt
269 let answer: (String?) -> Void
270 @State private var text = ""
271 @State private var date = Date()
272 @State private var keys: [FastSelection.Input] = []
273
274 var body: some View {
275 NavigationStack {
276 Form {
277 if let fast = prompt.fast {
278 fastSection(fast)
279 } else {
280 Section {
281 TextField(prompt.message, text: $text)
282 .autocorrectionDisabled()
283 .textInputAutocapitalization(.never)
284 .onSubmit { answer(text) }
285 } footer: {
286 if prompt.date != nil { Text("Org's date syntax works too: +2d, fri, 14:00") }
287 }
288 if prompt.date != nil {
289 DatePicker("Date", selection: $date, displayedComponents: [.date])
290 .datePickerStyle(.graphical)
291 .onChange(of: date) { text = Self.stamp(date) }
292 }
293 if !prompt.choices.isEmpty {
294 let matching = prompt.choices.filter { lastPart.isEmpty || $0.localizedCaseInsensitiveContains(lastPart) }
295 Section("Choices") {
296 ForEach(matching.prefix(50), id: \.self) { choice in
297 Button(choice) { choose(choice) }
298 }
299 }
300 }
301 }
302 }
303 .navigationTitle(prompt.message.trimmingCharacters(in: CharacterSet(charactersIn: ": ")))
304 .navigationBarTitleDisplayMode(.inline)
305 .toolbar {
306 ToolbarItem(placement: .cancellationAction) { Button("Cancel") { answer(nil) } }
307 if prompt.fast == nil || prompt.fast?.multiple == true {
308 ToolbarItem(placement: .confirmationAction) {
309 Button("Done") { answer(prompt.fast == nil ? text : FastSelection.encode(keys)) }
310 .disabled(prompt.requireMatch && prompt.fast == nil && !prompt.choices.isEmpty && !prompt.choices.contains(text))
311 }
312 }
313 }
314 }
315 .onAppear {
316 text = prompt.initial
317 if let day = prompt.date?.defaultDate { date = day }
318 }
319 }
320
321 /// With a separator (tags), what's typed after the last one.
322 private var lastPart: String {
323 guard let separator = prompt.completionSeparator else { return text }
324 return String(text.split(separator: separator, omittingEmptySubsequences: false).last ?? "")
325 }
326
327 private func choose(_ choice: String) {
328 guard let separator = prompt.completionSeparator else {
329 text = choice
330 if prompt.date == nil { answer(choice) }
331 return
332 }
333 var parts = text.split(separator: separator, omittingEmptySubsequences: false).map(String.init)
334 if parts.isEmpty { parts = [""] }
335 parts[parts.count - 1] = choice
336 text = parts.joined(separator: String(separator)) + String(separator)
337 }
338
339 @ViewBuilder
340 private func fastSection(_ fast: FastSelection) -> some View {
341 let current = fast.multiple ? fast.tags(after: keys) ?? [] : []
342 Section {
343 ForEach(Array(fast.options.enumerated()), id: \.offset) { _, option in
344 Button {
345 if fast.multiple { keys.append(.key(option.key)) } else { answer(String(option.key)) }
346 } label: {
347 HStack {
348 Text(option.name).foregroundStyle(.primary)
349 Spacer()
350 if current.contains(option.name) { Image(systemName: "checkmark") }
351 Text(String(option.key)).font(.caption.monospaced()).foregroundStyle(.secondary)
352 }
353 }
354 }
355 if !fast.multiple {
356 Button("No keyword") { answer(" ") }
357 }
358 } footer: {
359 if fast.multiple, !fast.inherited.isEmpty { Text("Inherited: :" + fast.inherited.joined(separator: ":") + ":") }
360 }
361 }
362
363 static func stamp(_ date: Date) -> String {
364 let formatter = DateFormatter()
365 formatter.locale = Locale(identifier: "en_US_POSIX")
366 formatter.dateFormat = "yyyy-MM-dd"
367 return formatter.string(from: date)
368 }
369}
370
371/// `org-edit-special` and `org-table-edit-field`: the text edited apart, put back with Save.
372struct BlockEditSheet: View {
373 let edit: BlockEdit
374 let finish: (String?) -> Void
375 @State private var text = ""
376 @FocusState private var focused: Bool
377
378 var body: some View {
379 NavigationStack {
380 TextEditor(text: $text)
381 .font(.body.monospaced())
382 .autocorrectionDisabled()
383 .textInputAutocapitalization(.never)
384 .focused($focused)
385 .padding(.horizontal, 8)
386 .navigationTitle(edit.title.replacingOccurrences(of: "Editing ", with: "").capitalized(with: nil))
387 .navigationBarTitleDisplayMode(.inline)
388 .toolbar {
389 ToolbarItem(placement: .cancellationAction) { Button("Cancel") { finish(nil) } }
390 ToolbarItem(placement: .confirmationAction) { Button("Save") { finish(text) } }
391 }
392 }
393 .onAppear {
394 text = edit.content
395 focused = true
396 }
397 }
398}
399
400/// Every command that applies at the caret, by name.
401struct CommandPalette: View {
402 let editor: MobileEditor
403 /// Called with the chosen command before the palette closes.
404 let choose: (String) -> Void
405 @State private var query = ""
406 @Environment(\.dismiss) private var dismiss
407
408 private static let viewCommands: [(id: String, title: String)] = [
409 ("org.cycle", "Cycle Visibility"), ("org.cycle-global", "Cycle Global Visibility"),
410 ("edit.undo", "Undo"), ("edit.redo", "Redo"),
411 ("app.clock.in", "Clock In"), ("app.clock.out", "Clock Out"), ("app.clock.cancel", "Cancel Clock"),
412 ("app.clock.goto", "Go to Clocked Entry"), ("app.clock.in-recent", "Clock In to Recent Entry…"),
413 ("app.clock.in-last", "Clock In to Last Entry"), ("app.clock.goto-recent", "Go to Recent Clocked Entry…"),
414 ("app.clock.default", "Mark as Default Clock Task"), ("app.clock.resolve", "Resolve Open Clocks…"),
415 ("app.toggle-markup", "Show or Hide Markup"),
416 ("org.toggle-inline-images", "Show or Hide Inline Images"), ("editor.toggle-truncate-lines", "Truncate or Wrap Long Lines"),
417 ]
418
419 var body: some View {
420 let context = editor.editContext()
421 let all = Self.viewCommands + Commands.everything.map { ($0.id, $0.title) }
422 let shown = all.filter { editor.applies($0.id, in: context) && (query.isEmpty || $0.title.localizedCaseInsensitiveContains(query)) }
423 NavigationStack {
424 List(shown, id: \.id) { command in
425 Button {
426 choose(command.id)
427 dismiss()
428 } label: {
429 HStack {
430 Text(command.title).foregroundStyle(.primary)
431 Spacer()
432 if let keys = editor.keymap.keyLabels(for: command.id).first {
433 Text(keys).font(.caption.monospaced()).foregroundStyle(.secondary)
434 }
435 }
436 }
437 }
438 .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always))
439 .navigationTitle("Commands")
440 .navigationBarTitleDisplayMode(.inline)
441 .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Close") { dismiss() } } }
442 }
443 }
444}
445#endif