krz/orgstar

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

Sources/OrgstarMobile/EditorScreen.swift

3a3dedba062790e1580c66d38606eda4b02cb501
orgstar/Sources/OrgstarMobile/EditorScreen.swift history · blame · raw

447 lines · 19776 bytes

  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        ("org.table.toggle-column-width", "Shrink or Expand Table Column"), ("org.table.shrink", "Shrink Table Columns with Widths"),
418        ("org.table.expand", "Expand Table Columns"),
419    ]
420
421    var body: some View {
422        let context = editor.editContext()
423        let all = Self.viewCommands + Commands.everything.map { ($0.id, $0.title) }
424        let shown = all.filter { editor.applies($0.id, in: context) && (query.isEmpty || $0.title.localizedCaseInsensitiveContains(query)) }
425        NavigationStack {
426            List(shown, id: \.id) { command in
427                Button {
428                    choose(command.id)
429                    dismiss()
430                } label: {
431                    HStack {
432                        Text(command.title).foregroundStyle(.primary)
433                        Spacer()
434                        if let keys = editor.keymap.keyLabels(for: command.id).first {
435                            Text(keys).font(.caption.monospaced()).foregroundStyle(.secondary)
436                        }
437                    }
438                }
439            }
440            .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always))
441            .navigationTitle("Commands")
442            .navigationBarTitleDisplayMode(.inline)
443            .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Close") { dismiss() } } }
444        }
445    }
446}
447#endif