krz/orgstar

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

Sources/Orgstar/CaptureView.swift

176fc78498d9ba962fa963bbeca0dd433d9e0bab
orgstar/Sources/Orgstar/CaptureView.swift history · blame · raw

173 lines · 6665 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    @State private var templates = CaptureTemplates.defaults
 14    @State private var problem: String?
 15    @State private var template: CaptureTemplate?
 16    @State private var questions: [Capture.Question] = []
 17    @State private var answers: [String] = []
 18    @State private var text = ""
 19    @State private var selection: TextSelection?
 20    @State private var context = CaptureContext()
 21    @State private var editing = false
 22    @FocusState private var focused: Bool
 23    @Environment(\.dismissWindow) private var dismissWindow
 24
 25    var body: some View {
 26        VStack(alignment: .leading, spacing: 12) {
 27            if let template, editing {
 28                Text("\(template.name) → \(target(template))").font(.headline)
 29                TextEditor(text: $text, selection: $selection)
 30                    .font(.body.monospaced())
 31                    .focused($focused)
 32                    .frame(minHeight: 140)
 33                HStack {
 34                    Spacer()
 35                    Button("Cancel", role: .cancel) { close() }
 36                        .keyboardShortcut(.cancelAction)
 37                    Button("File It") { finish() }
 38                        .keyboardShortcut(.return, modifiers: .command)
 39                }
 40            } else if let template, !questions.isEmpty {
 41                Text(template.name).font(.headline)
 42                Form {
 43                    ForEach(questions.indices, id: \.self) { index in
 44                        TextField(questions[index].prompt, text: $answers[index], prompt: Text(questions[index].defaultValue))
 45                    }
 46                }
 47                HStack {
 48                    Spacer()
 49                    Button("Cancel", role: .cancel) { close() }
 50                        .keyboardShortcut(.cancelAction)
 51                    Button("Continue") { expand() }
 52                        .keyboardShortcut(.defaultAction)
 53                }
 54            } else {
 55                Text("Capture").font(.headline)
 56                ForEach(templates) { template in
 57                    Button {
 58                        choose(template)
 59                    } label: {
 60                        HStack {
 61                            Text("[\(template.key)]").monospaced().foregroundStyle(.secondary)
 62                            Text(template.name)
 63                            Spacer()
 64                            Text(target(template)).foregroundStyle(.secondary).font(.caption)
 65                        }
 66                    }
 67                    .buttonStyle(.plain)
 68                }
 69                if let problem { Text(problem).font(.caption).foregroundStyle(.red) }
 70            }
 71        }
 72        .padding(16)
 73        .frame(width: 520)
 74        .onKeyPress(phases: .down) { press in
 75            guard template == nil, let match = templates.first(where: { $0.key == press.characters }) else { return .ignored }
 76            choose(match)
 77            return .handled
 78        }
 79        .onAppear(perform: reset)
 80    }
 81
 82    private func target(_ template: CaptureTemplate) -> String {
 83        let file = (template.target.file as NSString).lastPathComponent
 84        switch template.target {
 85        case .file: return file
 86        case .headline(_, let headline): return "\(file) / \(headline)"
 87        case .outline(_, let path): return ([file] + path).joined(separator: " / ")
 88        }
 89    }
 90
 91    private func reset() {
 92        let loaded = CaptureTemplates.load()
 93        templates = loaded.templates
 94        problem = loaded.problems.first
 95        template = nil
 96        editing = false
 97        context = CaptureContext(
 98            initial: commands.editor?.selectedText ?? "",
 99            annotation: session.captureAnnotation(),
100            clipboard: NSPasteboard.general.string(forType: .string) ?? "",
101            file: session.path ?? "",
102            userName: NSFullUserName()
103        )
104    }
105
106    private func choose(_ chosen: CaptureTemplate) {
107        template = chosen
108        questions = Capture.questions(chosen.template)
109        answers = questions.map { _ in "" }
110        if questions.isEmpty { expand() }
111    }
112
113    private func expand() {
114        guard let template else { return }
115        context.now = Date()
116        switch Capture.fill(template.template, context: context, answers: answers) {
117        case .failure(.message(let message)):
118            problem = message
119            self.template = nil
120        case .success(let filled):
121            text = filled.text
122            let index = String.Index(utf16Offset: filled.cursor, in: text)
123            selection = TextSelection(insertionPoint: index)
124            editing = true
125            focused = true
126        }
127    }
128
129    private func finish() {
130        guard let template else { return }
131        if let message = session.capture(text, template: template, root: workspace.rootPaths.first) {
132            problem = message
133            return
134        }
135        commands.show("Captured to \(target(template))")
136        close()
137    }
138
139    private func close() {
140        reset()
141        dismissWindow(id: "capture")
142    }
143}
144
145/// A system-wide shortcut, registered with Carbon so it needs no accessibility permission.
146@MainActor
147final class GlobalHotKey {
148    private var reference: EventHotKeyRef?
149    private var handler: EventHandlerRef?
150    private let action: () -> Void
151
152    /// ⌃⌥Space.
153    init(action: @escaping () -> Void) {
154        self.action = action
155        var spec = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
156        let me = Unmanaged.passUnretained(self).toOpaque()
157        InstallEventHandler(GetApplicationEventTarget(), { _, _, data in
158            guard let data else { return noErr }
159            let hotKey = Unmanaged<GlobalHotKey>.fromOpaque(data).takeUnretainedValue()
160            MainActor.assumeIsolated { hotKey.action() }
161            return noErr
162        }, 1, &spec, me, &handler)
163        let id = EventHotKeyID(signature: OSType(0x4F52_4753), id: 1)
164        RegisterEventHotKey(UInt32(kVK_Space), UInt32(controlKey | optionKey), id, GetApplicationEventTarget(), 0, &reference)
165    }
166
167    func unregister() {
168        if let reference { UnregisterEventHotKey(reference) }
169        if let handler { RemoveEventHandler(handler) }
170        reference = nil
171        handler = nil
172    }
173}