krz/orgstar

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

Sources/OrgstarMobile/CaptureScreen.swift

16086b4cf2caff5328774b2cd5ae3ffe1ab65ca4
orgstar/Sources/OrgstarMobile/CaptureScreen.swift history · blame · raw

186 lines · 7920 bytes

  1#if os(iOS)
  2import OrgApp
  3import OrgCore
  4import SwiftUI
  5
  6/// Capture with a template: its questions (`%^{…}`, `%^g`, `%^t`…), then its text to edit,
  7/// filed where the template says, as `org-capture` does.
  8struct CaptureScreen: View {
  9    let workspace: WorkspaceModel
 10    let session: DocumentSession
 11    let request: CaptureRequest
 12    @Environment(\.dismiss) private var dismiss
 13    @Environment(ClockModel.self) private var clock
 14    @Environment(MobileModel.self) private var model
 15    @State private var templates: [CaptureTemplate] = []
 16    @State private var key: String?
 17    @State private var context = CaptureContext()
 18    @State private var questions: [Capture.Question] = []
 19    @State private var answers: [String] = []
 20    @State private var editing = false
 21    @State private var text = ""
 22    @State private var selection: TextSelection?
 23    @State private var problem: String?
 24    @FocusState private var focused: Bool
 25
 26    private var template: CaptureTemplate? { templates.first { $0.key == key } }
 27
 28    var body: some View {
 29        NavigationStack {
 30            Form {
 31                Picker("Template", selection: $key) {
 32                    ForEach(templates) { Text($0.name).tag(Optional($0.key)) }
 33                }
 34                if editing {
 35                    Section {
 36                        TextEditor(text: $text, selection: $selection)
 37                            .font(.body.monospaced())
 38                            .frame(minHeight: 200)
 39                            .focused($focused)
 40                    } footer: {
 41                        if let problem { Text(problem).foregroundStyle(.red) }
 42                    }
 43                } else {
 44                    ForEach(questions.indices, id: \.self) { index in
 45                        QuestionField(question: questions[index], answer: $answers[index])
 46                    }
 47                    if let problem { Text(problem).foregroundStyle(.red) }
 48                }
 49            }
 50            .navigationTitle("Capture")
 51            .navigationBarTitleDisplayMode(.inline)
 52            .toolbar {
 53                ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
 54                ToolbarItem(placement: .confirmationAction) {
 55                    if editing {
 56                        Button("File", action: file).disabled(template == nil)
 57                    } else {
 58                        Button("Continue", action: fill)
 59                    }
 60                }
 61            }
 62        }
 63        .onAppear {
 64            let loaded = CaptureTemplates.load()
 65            templates = loaded.templates
 66            problem = loaded.problems.first
 67            key = request.template.flatMap { wanted in templates.first { $0.key == wanted }?.key } ?? templates.first?.key
 68            if let wanted = request.template, !templates.contains(where: { $0.key == wanted }) { problem = "No capture template \"\(wanted)\"" }
 69        }
 70        .onChange(of: key, initial: true) { choose() }
 71    }
 72
 73    /// The template's questions, asked before its text; none, and the text shows at once.
 74    private func choose() {
 75        guard let template else { return }
 76        context = CaptureContext(
 77            initial: request.properties["initial"] ?? "", annotation: request.properties["annotation"] ?? "",
 78            allTags: (try? workspace.index.tags()) ?? []
 79        )
 80        context.linkProperties = request.properties
 81        // `%^g` offers the target file's tags.
 82        let path = DocumentSession.resolve(template.target.file, root: workspace.rootPaths.first)
 83        if !template.target.file.isEmpty,
 84           let source = session.openTexts[path] ?? (try? String(contentsOfFile: path, encoding: .utf8)) {
 85            var tags: [String] = []
 86            for heading in DocumentModel(tree: OrgParser.parse(source)).headings { for tag in heading.localTags where !tags.contains(tag) { tags.append(tag) } }
 87            context.fileTags = tags
 88        }
 89        questions = Capture.questions(template.template, context: context)
 90        answers = questions.map { _ in "" }
 91        editing = false
 92        if questions.isEmpty { fill() }
 93    }
 94
 95    /// The template expanded with the answers; the caret where `%?` was.
 96    private func fill() {
 97        guard let template else { return }
 98        context.now = Date()
 99        switch Capture.fill(template.template, context: context, answers: answers) {
100        case .success(let filled):
101            text = filled.text
102            selection = TextSelection(insertionPoint: String.Index(utf16Offset: filled.cursor, in: filled.text))
103            problem = nil
104            editing = true
105            focused = true
106            if template.options.immediateFinish { file() }
107        case .failure(.message(let message)):
108            problem = message
109        }
110    }
111
112    private func file() {
113        guard let template else { return }
114        let now = Date()
115        let placed = template.resolved(workspace: workspace, session: session, clock: clock).flatMap { resolved in
116            session.capture(text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file, now: now)
117        }
118        switch placed {
119        case .failure(let error):
120            problem = error.message
121            editing = true
122        case .success(let entry):
123            // Clocked from when capture began.
124            let paths = workspace.files.filter { $0.kind == .org }.map(\.path)
125            Task { [clock, session, opened = context.now] in
126                _ = await clock.captured(entry, options: template.options, session: session, paths: paths, ask: model.clockPrompts.ask, opened: opened, now: now)
127            }
128            if template.options.jumpToCaptured { model.openRequest = .offset(entry.path, entry.offset) }
129            dismiss()
130        }
131    }
132}
133
134/// One of a template's questions: text with its choices, tags, or a date.
135struct QuestionField: View {
136    let question: Capture.Question
137    @Binding var answer: String
138    @State private var date = Date()
139
140    var body: some View {
141        Section {
142            TextField(placeholder, text: $answer)
143                .autocorrectionDisabled()
144                .textInputAutocapitalization(.never)
145            if case .date(let withTime, _) = question.kind {
146                DatePicker("Date", selection: $date, displayedComponents: withTime ? [.date, .hourAndMinute] : [.date])
147                    .onChange(of: date) { answer = Self.stamp(date, withTime: withTime) }
148            }
149            if !question.choices.isEmpty {
150                ScrollView(.horizontal, showsIndicators: false) {
151                    HStack {
152                        ForEach(question.choices.prefix(30), id: \.self) { choice in
153                            Button(choice) { pick(choice) }.buttonStyle(.bordered)
154                        }
155                    }
156                }
157            }
158        } header: {
159            Text(question.prompt)
160        }
161    }
162
163    /// What an empty answer means.
164    private var placeholder: String {
165        switch question.kind {
166        case .text, .property, .clipboard: question.defaultValue
167        case .tags: "tag:tag"
168        case .date(let withTime, _): withTime ? "now; or +2d, fri 14:00" : "today; or +2d, fri"
169        }
170    }
171
172    /// Tags add to what's typed; other choices replace it.
173    private func pick(_ choice: String) {
174        guard question.kind == .tags else { return answer = choice }
175        let tags = answer.split(separator: ":").map(String.init)
176        if !tags.contains(choice) { answer = (tags + [choice]).joined(separator: ":") }
177    }
178
179    static func stamp(_ date: Date, withTime: Bool) -> String {
180        let formatter = DateFormatter()
181        formatter.locale = Locale(identifier: "en_US_POSIX")
182        formatter.dateFormat = withTime ? "yyyy-MM-dd HH:mm" : "yyyy-MM-dd"
183        return formatter.string(from: date)
184    }
185}
186#endif