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