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