krz/orgstar

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

Sources/OrgstarMobile/CaptureScreen.swift

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

208 lines · 9132 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], defaultDate: request.day.map(Self.date))
 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 = request.day.map(Self.date) ?? Date()
 99        switch Capture.fill(template.template, context: context, answers: answers) {
100        case .success(let filled):
101            let (filledText, cursor) = request.day.map { Capture.scheduling(filled.text, cursor: filled.cursor, on: $0) } ?? (filled.text, filled.cursor)
102            text = filledText
103            selection = TextSelection(insertionPoint: String.Index(utf16Offset: cursor, in: filledText))
104            problem = nil
105            editing = true
106            focused = true
107            if template.options.immediateFinish { file() }
108        case .failure(.message(let message)):
109            problem = message
110        }
111    }
112
113    /// Now's time of day on `day` (a `Days` number): the default time of an agenda day's
114    /// capture, as `org-agenda-capture` sets `org-overriding-default-time`.
115    static func date(_ day: Int) -> Date {
116        let calendar = Calendar.current
117        let d = Days.date(day)
118        var parts = calendar.dateComponents([.hour, .minute, .second], from: Date())
119        (parts.year, parts.month, parts.day) = (d.year, d.month, d.day)
120        return calendar.date(from: parts) ?? Date()
121    }
122
123    private func file() {
124        guard let template else { return }
125        let now = Date()
126        let placed = template.resolved(workspace: workspace, session: session, clock: clock).flatMap { resolved in
127            session.capture(text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file, now: request.day.map(Self.date) ?? now)
128        }
129        switch placed {
130        case .failure(let error):
131            problem = error.message
132            editing = true
133        case .success(let entry):
134            // Clocked from when capture began.
135            let paths = workspace.files.filter { $0.kind == .org }.map(\.path)
136            Task { [clock, session, opened = context.now] in
137                _ = await clock.captured(entry, options: template.options, session: session, paths: paths, ask: model.clockPrompts.ask, opened: opened, now: now)
138            }
139            if template.options.jumpToCaptured { model.openRequest = .offset(entry.path, entry.offset) }
140            dismiss()
141        }
142    }
143}
144
145/// One of a template's questions: text with its choices, tags, or a date.
146struct QuestionField: View {
147    let question: Capture.Question
148    @Binding var answer: String
149    /// What an empty date answer means: now, or the agenda day capture started from.
150    let defaultDate: Date?
151    @State private var date: Date
152
153    init(question: Capture.Question, answer: Binding<String>, defaultDate: Date? = nil) {
154        self.question = question
155        _answer = answer
156        self.defaultDate = defaultDate
157        _date = State(initialValue: defaultDate ?? Date())
158    }
159
160    var body: some View {
161        Section {
162            TextField(placeholder, text: $answer)
163                .autocorrectionDisabled()
164                .textInputAutocapitalization(.never)
165            if case .date(let withTime, _) = question.kind {
166                DatePicker("Date", selection: $date, displayedComponents: withTime ? [.date, .hourAndMinute] : [.date])
167                    .onChange(of: date) { answer = Self.stamp(date, withTime: withTime) }
168            }
169            if !question.choices.isEmpty {
170                ScrollView(.horizontal, showsIndicators: false) {
171                    HStack {
172                        ForEach(question.choices.prefix(30), id: \.self) { choice in
173                            Button(choice) { pick(choice) }.buttonStyle(.bordered)
174                        }
175                    }
176                }
177            }
178        } header: {
179            Text(question.prompt)
180        }
181    }
182
183    /// What an empty answer means.
184    private var placeholder: String {
185        switch question.kind {
186        case .text, .property, .clipboard: question.defaultValue
187        case .tags: "tag:tag"
188        case .date(let withTime, _):
189            defaultDate.map { Self.stamp($0, withTime: withTime) + "; or +2d, fri" }
190                ?? (withTime ? "now; or +2d, fri 14:00" : "today; or +2d, fri")
191        }
192    }
193
194    /// Tags add to what's typed; other choices replace it.
195    private func pick(_ choice: String) {
196        guard question.kind == .tags else { return answer = choice }
197        let tags = answer.split(separator: ":").map(String.init)
198        if !tags.contains(choice) { answer = (tags + [choice]).joined(separator: ":") }
199    }
200
201    static func stamp(_ date: Date, withTime: Bool) -> String {
202        let formatter = DateFormatter()
203        formatter.locale = Locale(identifier: "en_US_POSIX")
204        formatter.dateFormat = withTime ? "yyyy-MM-dd HH:mm" : "yyyy-MM-dd"
205        return formatter.string(from: date)
206    }
207}
208#endif