Sources/Orgstar/CaptureView.swift
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}