krz/orgstar

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

Sources/Orgstar/CaptureView.swift

b98a6509c5ae75e5172dd333d1b6105fd6ceee0e
orgstar/Sources/Orgstar/CaptureView.swift history · blame · raw

278 lines · 12197 bytes

12 symbols in this file
  1import AppKit
  2import Carbon.HIToolbox
  3import OrgApp
  4import OrgCore
  5import SwiftUI
  6
  7/// The capture window: pick a template by its key, answer its prompts, edit the entry, and
  8/// ⌘Return files it where the template says, as `org-capture` does.
  9struct CaptureView: View {
 10    let workspace: WorkspaceModel
 11    let session: DocumentSession
 12    let commands: AppCommands
 13    let clock: ClockModel
 14    @State private var templates = CaptureTemplates.defaults
 15    @State private var problem: String?
 16    @State private var template: CaptureTemplate?
 17    @State private var questions: [Capture.Question] = []
 18    @State private var answers: [String] = []
 19    @State private var text = ""
 20    @State private var selection: TextSelection?
 21    @State private var context = CaptureContext()
 22    @State private var editing = false
 23    /// The template keys typed so far, for multi-key templates such as "wb".
 24    @State private var keys = ""
 25    /// The agenda day the entry is captured for, as `Days` numbers it.
 26    @State private var day: Int?
 27    @FocusState private var focused: Bool
 28    @Environment(\.dismissWindow) private var dismissWindow
 29
 30    var body: some View {
 31        VStack(alignment: .leading, spacing: 12) {
 32            if let template, editing {
 33                Text("\(template.name) → \(Self.target(template))").font(.headline)
 34                TextEditor(text: $text, selection: $selection)
 35                    .font(.body.monospaced())
 36                    .focused($focused)
 37                    .frame(minHeight: 140)
 38                HStack {
 39                    Spacer()
 40                    Button("Cancel", role: .cancel) { close() }
 41                        .keyboardShortcut(.cancelAction)
 42                    Button("File It") { finish() }
 43                        .keyboardShortcut(.return, modifiers: .command)
 44                }
 45            } else if let template, !questions.isEmpty {
 46                Text(template.name).font(.headline)
 47                Form {
 48                    ForEach(questions.indices, id: \.self) { index in
 49                        let question = questions[index]
 50                        TextField(question.prompt, text: $answers[index], prompt: Text(placeholder(question)))
 51                        if !question.choices.isEmpty {
 52                            Text(question.choices.prefix(12).joined(separator: "  ")).font(.caption).foregroundStyle(.secondary)
 53                        }
 54                    }
 55                }
 56                HStack {
 57                    Spacer()
 58                    Button("Cancel", role: .cancel) { close() }
 59                        .keyboardShortcut(.cancelAction)
 60                    Button("Continue") { expand() }
 61                        .keyboardShortcut(.defaultAction)
 62                }
 63            } else {
 64                Text((keys.isEmpty ? "Capture" : "Capture: \(keys)") + (day == nil ? "" : ", scheduled " + captureDate(Date()).formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated))))
 65                    .font(.headline)
 66                ForEach(templates.filter { $0.key.hasPrefix(keys) }) { template in
 67                    Button {
 68                        choose(template)
 69                    } label: {
 70                        HStack {
 71                            Text("[\(template.key)]").monospaced().foregroundStyle(.secondary)
 72                            Text(template.name)
 73                            Spacer()
 74                            Text(Self.target(template)).foregroundStyle(.secondary).font(.caption)
 75                        }
 76                    }
 77                    .buttonStyle(.plain)
 78                }
 79            }
 80            if let problem { Text(problem).font(.caption).foregroundStyle(.red) }
 81        }
 82        .padding(16)
 83        .frame(width: 520)
 84        .onKeyPress(phases: .down) { press in
 85            guard template == nil else { return .ignored }
 86            if press.key == .delete, !keys.isEmpty {
 87                keys.removeLast()
 88                return .handled
 89            }
 90            // Keys narrow the list until they name a template, as `org-capture` selects one.
 91            let typed = keys + press.characters
 92            if let match = templates.first(where: { $0.key == typed }) {
 93                choose(match)
 94            } else if !press.characters.isEmpty, templates.contains(where: { $0.key.hasPrefix(typed) }) {
 95                keys = typed
 96            } else {
 97                return .ignored
 98            }
 99            return .handled
100        }
101        .onAppear {
102            reset()
103            takeRequest()
104        }
105        .onChange(of: commands.captureRequest?.serial) {
106            guard commands.captureRequest != nil else { return }
107            reset()
108            takeRequest()
109        }
110    }
111
112    /// What an empty answer means.
113    private func placeholder(_ question: Capture.Question) -> String {
114        switch question.kind {
115        case .text, .property, .clipboard: question.defaultValue
116        case .tags: "tag:tag"
117        case .date(let withTime, _): withTime ? "now; or +2d, fri 14:00" : "today; or +2d, fri"
118        }
119    }
120
121    static func target(_ template: CaptureTemplate) -> String {
122        let file = (template.target.file as NSString).lastPathComponent
123        switch template.target {
124        case .file, .heading: return file
125        case .headline(_, let headline): return "\(file) / \(headline)"
126        case .outline(_, let path): return ([file] + path).joined(separator: " / ")
127        case .datetree(_, let path, _): return ([file] + path + ["date tree"]).joined(separator: " / ")
128        case .id(let id): return "ID \(id)"
129        case .clock: return "clocked entry"
130        }
131    }
132
133    private func reset() {
134        let loaded = CaptureTemplates.load()
135        templates = loaded.templates
136        problem = loaded.problems.first
137        template = nil
138        editing = false
139        keys = ""
140        day = nil
141        let clocked = clock.running
142        let clockFile = clocked.map { Links.abbreviate($0.path, home: NSHomeDirectory()) }
143        context = CaptureContext(
144            initial: commands.editor?.selectedText ?? "",
145            annotation: session.captureAnnotation(),
146            clipboard: NSPasteboard.general.string(forType: .string) ?? "",
147            file: session.path ?? "",
148            userName: NSFullUserName(),
149            clockHeading: clocked?.heading ?? "",
150            clockLink: clocked.map { "[[file:\(clockFile!)::*\($0.heading)][\($0.heading)]]" } ?? "",
151            allTags: (try? workspace.index.tags()) ?? [],
152            tagsColumn: UserDefaults.standard.object(forKey: "tagsColumn") as? Int ?? -77
153        )
154    }
155
156    /// An org-protocol capture: its link properties fill `%a`, `%i` and `%:…`, and its
157    /// template, when it names one, is chosen.
158    private func takeRequest() {
159        guard let request = commands.captureRequest else { return }
160        commands.captureRequest = nil
161        day = request.day
162        context.initial = request.properties["initial"] ?? ""
163        context.annotation = request.properties["annotation"] ?? ""
164        context.linkProperties = request.properties
165        if let key = request.template {
166            if let match = templates.first(where: { $0.key == key }) { choose(match) } else { problem = "No capture template \"\(key)\"" }
167        }
168    }
169
170    private func choose(_ chosen: CaptureTemplate) {
171        template = chosen
172        keys = ""
173        // `%^g` offers the target file's tags.
174        let path = DocumentSession.resolve(chosen.target.file, root: workspace.rootPaths.first)
175        if !chosen.target.file.isEmpty, let text = session.openDocuments.first(where: { $0.path == path })?.document.text ?? (try? String(contentsOfFile: path, encoding: .utf8)) {
176            var tags: [String] = []
177            for heading in DocumentModel(tree: OrgParser.parse(text)).headings { for tag in heading.localTags where !tags.contains(tag) { tags.append(tag) } }
178            context.fileTags = tags
179        }
180        questions = Capture.questions(chosen.template, context: context)
181        answers = questions.map { _ in "" }
182        if questions.isEmpty { expand() }
183    }
184
185    private func expand() {
186        guard let template else { return }
187        context.now = captureDate(Date())
188        switch Capture.fill(template.template, context: context, answers: answers) {
189        case .failure(.message(let message)):
190            problem = message
191            self.template = nil
192        case .success(var filled):
193            if let day { filled = Capture.scheduling(filled.text, cursor: filled.cursor, on: day) }
194            text = filled.text
195            if template.options.immediateFinish {
196                finish()
197                return
198            }
199            let index = String.Index(utf16Offset: filled.cursor, in: text)
200            selection = TextSelection(insertionPoint: index)
201            editing = true
202            focused = true
203        }
204    }
205
206    /// `now` on the agenda day captured for, as `org-overriding-default-time` sets `%t`, `%U`
207    /// and the date tree's day.
208    private func captureDate(_ now: Date) -> Date {
209        guard let day else { return now }
210        let d = Days.date(day)
211        let time = Calendar.current.dateComponents([.hour, .minute, .second], from: now)
212        return Calendar.current.date(from: DateComponents(year: d.year, month: d.month, day: d.day, hour: time.hour, minute: time.minute, second: time.second)) ?? now
213    }
214
215    private func finish() {
216        guard let template else { return }
217        let opened = context.now
218        let now = Date()
219        let placed = template.resolved(workspace: workspace, session: session, clock: clock).flatMap { resolved in
220            session.capture(text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file, now: captureDate(now))
221        }
222        switch placed {
223        case .failure(let error):
224            problem = error.message
225            if template.options.immediateFinish { editing = true }
226            return
227        case .success(let entry):
228            commands.show("Captured to \(Self.target(template))")
229            let options = template.options
230            let paths = workspace.files.filter { $0.kind == .org }.map(\.path)
231            Task { [commands, clock, session] in
232                let outcome = await clock.captured(entry, options: options, session: session, paths: paths, ask: commands.askClock, opened: opened, now: now)
233                for message in outcome.messages { commands.show(message) }
234                if let jump = outcome.jump {
235                    commands.jump(path: jump.path, offset: jump.offset)
236                } else if options.jumpToCaptured {
237                    commands.jump(path: entry.path, offset: entry.offset)
238                }
239            }
240        }
241        close()
242    }
243
244    private func close() {
245        reset()
246        dismissWindow(id: "capture")
247    }
248}
249
250/// A system-wide shortcut, registered with Carbon so it needs no accessibility permission.
251@MainActor
252final class GlobalHotKey {
253    private var reference: EventHotKeyRef?
254    private var handler: EventHandlerRef?
255    private let action: () -> Void
256
257    /// ⌃⌥Space.
258    init(action: @escaping () -> Void) {
259        self.action = action
260        var spec = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
261        let me = Unmanaged.passUnretained(self).toOpaque()
262        InstallEventHandler(GetApplicationEventTarget(), { _, _, data in
263            guard let data else { return noErr }
264            let hotKey = Unmanaged<GlobalHotKey>.fromOpaque(data).takeUnretainedValue()
265            MainActor.assumeIsolated { hotKey.action() }
266            return noErr
267        }, 1, &spec, me, &handler)
268        let id = EventHotKeyID(signature: OSType(0x4F52_4753), id: 1)
269        RegisterEventHotKey(UInt32(kVK_Space), UInt32(controlKey | optionKey), id, GetApplicationEventTarget(), 0, &reference)
270    }
271
272    func unregister() {
273        if let reference { UnregisterEventHotKey(reference) }
274        if let handler { RemoveEventHandler(handler) }
275        reference = nil
276        handler = nil
277    }
278}