Sources/Orgstar/OrgstarApp.swift
379 lines · 15467 bytes
1import AppKit
2import OrgApp
3import OrgCore
4import OrgEditorAppKit
5import SwiftUI
6import UserNotifications
7
8/// Window-level requests the menu bar makes.
9@MainActor
10@Observable
11final class AppCommands {
12 var showingQuickOpen = false
13 var showingFolderPicker = false
14 var focusSearch = false
15 var showingConflict = false
16 var showingPalette = false
17 /// A location another window asked the main window to show.
18 private(set) var jumpRequest: (path: String, offset: Int, id: Int)?
19 private var jumpID = 0
20 /// The editor showing the open file, for menus and the palette.
21 var editor: OrgEditor?
22 /// The pending key prefix, and what can follow it.
23 var pendingKeys: String?
24 /// The editor's evil state in the Doom preset.
25 var editorState: String?
26 /// The editor's selection, for the modeline.
27 var selection = NSRange(location: 0, length: 0)
28 var keyHints: [KeyHint] = []
29 /// A question a command asked, answered in the echo area.
30 private(set) var prompt: PromptRequest?
31 /// The last message from a command or the keymap, cleared after a few seconds.
32 private(set) var message: String?
33 private var messageID = 0
34
35 func ask(_ prompt: Prompt, reply: @escaping (String?) -> Void) {
36 self.prompt = PromptRequest(prompt: prompt, reply: reply)
37 }
38
39 /// Ends the prompt with an answer, or nil to cancel, and gives the editor the keys back.
40 func answer(_ text: String?) {
41 guard let request = prompt else { return }
42 prompt = nil
43 if let textView = editor?.textView { textView.window?.makeFirstResponder(textView) }
44 request.reply(text)
45 }
46
47 /// An app command from a menu, run by the main window.
48 private(set) var runRequest: (id: String, serial: Int)?
49 private var runSerial = 0
50
51 func run(_ id: String) {
52 runSerial += 1
53 runRequest = (id, runSerial)
54 }
55
56 func jump(path: String, offset: Int) {
57 jumpID += 1
58 jumpRequest = (path, offset, jumpID)
59 }
60
61 func show(_ text: String) {
62 messageID += 1
63 let id = messageID
64 message = text
65 Task {
66 try? await Task.sleep(for: .seconds(4))
67 if messageID == id { message = nil }
68 }
69 }
70}
71
72struct PromptRequest: Identifiable {
73 let id = UUID()
74 let prompt: Prompt
75 let reply: (String?) -> Void
76}
77
78@MainActor
79final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
80 var session: DocumentSession?
81 var commands: AppCommands?
82
83 func applicationDidFinishLaunching(_ notification: Notification) {
84 if SystemReminderCenter.isAvailable { UNUserNotificationCenter.current().delegate = self }
85 }
86
87 /// Reminders show while Orgstar is in front too.
88 nonisolated func userNotificationCenter(
89 _ center: UNUserNotificationCenter, willPresent notification: UNNotification
90 ) async -> UNNotificationPresentationOptions {
91 [.banner, .sound, .list]
92 }
93
94 /// Clicking a reminder shows its entry.
95 nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
96 let info = response.notification.request.content.userInfo
97 guard let path = info["path"] as? String, let offset = info["offset"] as? Int else { return }
98 await MainActor.run {
99 NSApp.activate()
100 commands?.jump(path: path, offset: offset)
101 }
102 }
103
104 func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
105
106 func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
107 guard let session else { return .terminateNow }
108 return UnsavedChanges.resolve(session) ? .terminateNow : .terminateCancel
109 }
110}
111
112/// Unsaved edits when leaving a file or quitting.
113@MainActor
114enum UnsavedChanges {
115 /// Whether the open file can be left: saved in automatic mode, or as the user chooses in
116 /// explicit mode. A save that fails or conflicts keeps the file open.
117 static func resolve(_ session: DocumentSession) -> Bool {
118 guard session.isDirty else { return true }
119 if session.saveMode == .automatic { return session.save() }
120 let alert = NSAlert()
121 alert.messageText = "Save changes to \(session.path.map { ($0 as NSString).lastPathComponent } ?? "this file")?"
122 alert.informativeText = "Your changes will be lost if you don't save them."
123 alert.addButton(withTitle: "Save")
124 alert.addButton(withTitle: "Don't Save")
125 alert.addButton(withTitle: "Cancel")
126 switch alert.runModal() {
127 case .alertFirstButtonReturn: return session.save()
128 case .alertSecondButtonReturn: return true
129 default: return false
130 }
131 }
132}
133
134@main
135struct OrgstarApp: App {
136 @NSApplicationDelegateAdaptor private var delegate: AppDelegate
137 @State private var workspace = Result {
138 try WorkspaceModel(directory: WorkspaceModel.defaultDirectory, settings: .with(todoKeywords: Preferences.orgSettings))
139 }
140 @State private var commands = AppCommands()
141 @State private var clock = ClockModel()
142 @State private var scheduler = SystemReminderCenter.isAvailable ? ReminderScheduler(center: SystemReminderCenter()) : nil
143 @State private var session: DocumentSession = {
144 let session = DocumentSession()
145 session.defaults = Preferences.orgSettings
146 return session
147 }()
148
149 var body: some Scene {
150 Window("Orgstar", id: "main") {
151 switch workspace {
152 case .success(let model):
153 ContentView(workspace: model, session: session, commands: commands, scheduler: scheduler, clock: clock)
154 .onAppear {
155 delegate.session = session
156 delegate.commands = commands
157 }
158 case .failure(let error):
159 ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription))
160 }
161 }
162 .defaultSize(width: 1280, height: 860)
163 Window("Agenda", id: "agenda") {
164 if case .success(let model) = workspace {
165 AgendaView(workspace: model, session: session, commands: commands, scheduler: scheduler)
166 }
167 }
168 .defaultSize(width: 760, height: 720)
169 Window("Board", id: "board") {
170 if case .success(let model) = workspace {
171 BoardView(workspace: model, session: session, commands: commands)
172 }
173 }
174 .defaultSize(width: 1100, height: 700)
175 Window("Clock Report", id: "clock-report") {
176 if case .success(let model) = workspace {
177 ClockReportView(workspace: model)
178 }
179 }
180 .defaultSize(width: 860, height: 560)
181 Window("Capture", id: "capture") {
182 if case .success(let model) = workspace {
183 CaptureView(workspace: model, session: session, commands: commands)
184 }
185 }
186 .windowResizability(.contentSize)
187 .windowLevel(.floating)
188 .commands {
189 CommandGroup(after: .newItem) {
190 Button("Add Folder…") { commands.showingFolderPicker = true }
191 .keyboardShortcut("o", modifiers: [.command, .shift])
192 Button("Quick Open…") { commands.showingQuickOpen = true }
193 .keyboardShortcut("p")
194 Button("Command Palette…") { commands.showingPalette = true }
195 .keyboardShortcut("p", modifiers: [.command, .shift])
196 }
197 CommandGroup(after: .saveItem) {
198 Menu("Export") {
199 ForEach(EditorCommands.all.filter { $0.id.hasPrefix("app.export.") }, id: \.id) { command in
200 Button(command.title) { commands.run(command.id) }
201 }
202 }
203 .disabled(session.path == nil)
204 }
205 CommandGroup(replacing: .saveItem) {
206 Button("Save") {
207 if !session.save(), session.conflict != nil { commands.showingConflict = true }
208 }
209 .keyboardShortcut("s")
210 .disabled(session.path == nil || session.isReadOnly)
211 }
212 CommandGroup(after: .textEditing) {
213 Button("Search Notes") { commands.focusSearch = true }
214 .keyboardShortcut("f", modifiers: [.command, .shift])
215 }
216 CommandGroup(before: .windowList) {
217 AgendaMenuItem()
218 }
219 CommandGroup(after: .newItem) {
220 Button("Capture…") { commands.run("app.capture") }
221 .keyboardShortcut("n", modifiers: [.command, .shift])
222 }
223 }
224 .commands {
225 CommandMenu("Org") {
226 ForEach(EditorCommands.all.filter { $0.id.hasPrefix("org.") }, id: \.id) { command in
227 Button(command.title) { commands.editor?.run(command.id) }
228 .disabled(commands.editor == nil)
229 }
230 }
231 }
232 Settings {
233 SettingsView()
234 }
235 }
236}
237
238/// Window ▸ Agenda.
239struct AgendaMenuItem: View {
240 @Environment(\.openWindow) private var openWindow
241
242 var body: some View {
243 Button("Agenda") { openWindow(id: "agenda") }
244 .keyboardShortcut("a", modifiers: [.command, .shift])
245 Button("Board") { openWindow(id: "board") }
246 .keyboardShortcut("b", modifiers: [.command, .shift])
247 }
248}
249
250struct SettingsView: View {
251 @AppStorage("saveMode") private var saveMode = SaveMode.automatic
252 @AppStorage("keymap") private var preset = KeymapPreset.emacs
253 @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue
254 @AppStorage("tagsColumn") private var tagsColumn = -77
255 @AppStorage("headingAfterSubtree") private var headingAfterSubtree = true
256 @AppStorage("splitLine") private var splitLine = false
257 @AppStorage("alphabeticalLists") private var alphabeticalLists = true
258 @AppStorage("fillColumn") private var fillColumn = 80
259 @AppStorage("hiddenMarkers") private var hiddenMarkers = true
260 @AppStorage("prettyEntities") private var prettyEntities = true
261 @AppStorage("todoKeywords") private var todoKeywords = Preferences.defaultTodoKeywords
262 @AppStorage("agendaSpan") private var agendaSpan = 10
263 @AppStorage("agendaStartOffset") private var agendaStartOffset = -3
264 @AppStorage("agendaSubfolders") private var agendaSubfolders = false
265 @AppStorage("reminders") private var reminders = true
266 @AppStorage("reminderLead") private var reminderLead = 12
267 @AppStorage("globalCapture") private var globalCapture = true
268 @AppStorage("settingsTab") private var tab = "general"
269
270 var body: some View {
271 TabView(selection: $tab) {
272 Tab("General", systemImage: "gearshape", value: "general") { general }
273 Tab("Editing", systemImage: "text.alignleft", value: "editing") { editing }
274 Tab("Agenda", systemImage: "calendar", value: "agenda") { agenda }
275 Tab("Capture", systemImage: "tray.and.arrow.down", value: "capture") { capture }
276 }
277 .frame(width: 600)
278 }
279
280 private var general: some View {
281 Form {
282 Section {
283 Picker("Save files", selection: $saveMode) {
284 Text("Automatically, when typing stops").tag(SaveMode.automatic)
285 Text("Only with File ▸ Save (⌘S)").tag(SaveMode.explicit)
286 }
287 .pickerStyle(.radioGroup)
288 }
289 Section {
290 Picker("Keys", selection: $preset) {
291 ForEach(KeymapPreset.allCases, id: \.self) { Text($0.title).tag($0) }
292 }
293 Picker("Option as Meta", selection: $optionAsMeta) {
294 Text("Left Option").tag(OptionAsMeta.left.rawValue)
295 Text("Right Option").tag(OptionAsMeta.right.rawValue)
296 Text("Both").tag(OptionAsMeta([.left, .right]).rawValue)
297 Text("Neither").tag(0)
298 }
299 } footer: {
300 Text("Your own bindings go in \(KeymapLoader.userFile.path), on top of the preset.")
301 .settingsFooter()
302 }
303 }
304 .formStyle(.grouped)
305 .frame(height: 250)
306 }
307
308 private var editing: some View {
309 Form {
310 Section {
311 Picker("Tags", selection: $tagsColumn) {
312 Text("Aligned to end at column 77").tag(-77)
313 Text("One space after the title").tag(0)
314 }
315 Toggle("M-RET adds the new heading after the subtree", isOn: $headingAfterSubtree)
316 Toggle("M-RET splits the line at the caret", isOn: $splitLine)
317 Toggle("Lists can use letters (a. b. c.)", isOn: $alphabeticalLists)
318 Stepper("M-q fills to column \(fillColumn)", value: $fillColumn, in: 40...200)
319 }
320 Section {
321 Toggle("Emacs hides emphasis markers (org-hide-emphasis-markers)", isOn: $hiddenMarkers)
322 Toggle("Emacs shows entities as characters (org-pretty-entities)", isOn: $prettyEntities)
323 } footer: {
324 Text("Tags, tables and M-q measure text as your Emacs displays it, so both write the same file.")
325 .settingsFooter()
326 }
327 Section {
328 TextField("Default TODO keywords", text: $todoKeywords)
329 } footer: {
330 Text("For files without #+TODO, in #+TODO syntax. Takes effect when Orgstar restarts.")
331 .settingsFooter()
332 }
333 }
334 .formStyle(.grouped)
335 .frame(height: 450)
336 }
337
338 private var agenda: some View {
339 Form {
340 Section {
341 Stepper("Agenda shows \(agendaSpan) days", value: $agendaSpan, in: 1...31)
342 Stepper(agendaStartOffset == 0 ? "Agenda starts today" : "Agenda starts \(-agendaStartOffset) days before today", value: $agendaStartOffset, in: -14...0)
343 Toggle("Include files in subfolders", isOn: $agendaSubfolders)
344 }
345 Section {
346 Toggle("Notify before timed agenda entries", isOn: $reminders)
347 Stepper("\(reminderLead) minutes before", value: $reminderLead, in: 0...120)
348 .disabled(!reminders)
349 } footer: {
350 Text("An entry's APPT_WARNTIME property overrides the lead time.")
351 .settingsFooter()
352 }
353 }
354 .formStyle(.grouped)
355 .frame(height: 290)
356 }
357
358 private var capture: some View {
359 Form {
360 Section {
361 Toggle("⌃⌥Space opens Capture from any app", isOn: $globalCapture)
362 } footer: {
363 Text("Templates go in \(CaptureTemplates.userFile.path).")
364 .settingsFooter()
365 }
366 }
367 .formStyle(.grouped)
368 .frame(height: 160)
369 }
370}
371
372private extension Text {
373 func settingsFooter() -> some View {
374 font(.caption)
375 .foregroundStyle(.secondary)
376 .textSelection(.enabled)
377 .frame(maxWidth: .infinity, alignment: .leading)
378 }
379}