krz/orgstar

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

Sources/OrgstarMobile/EditorScreen.swift

b98a6509c5ae75e5172dd333d1b6105fd6ceee0e
orgstar/Sources/OrgstarMobile/EditorScreen.swift history · blame · raw

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