krz/orgstar

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

Sources/Orgstar/CaptureView.swift

16086b4cf2caff5328774b2cd5ae3ffe1ab65ca4
orgstar/Sources/Orgstar/CaptureView.swift history · blame · raw

263 lines · 11255 bytes

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