Sources/Orgstar/OrgstarApp.swift
775 lines · 34884 bytes
1import AppKit
2import OrgApp
3import OrgCore
4import OrgEditorAppKit
5import OrgPresentation
6import SwiftUI
7import UserNotifications
8
9/// Window-level requests the menu bar makes.
10@MainActor
11@Observable
12final class AppCommands {
13 var showingQuickOpen = false
14 var showingFolderPicker = false
15 var focusSearch = false
16 var showingConflict = false
17 var showingPalette = false
18 /// A location another window asked the main window to show.
19 private(set) var jumpRequest: (path: String, offset: Int, id: Int)?
20 private var jumpID = 0
21 /// Days the agenda window should show, from a followed timestamp.
22 var agendaRequest: (start: Int, days: Int)?
23 /// The editor showing the open file, for menus and the palette.
24 var editor: OrgEditor?
25 /// The pending key prefix, and what can follow it.
26 var pendingKeys: String?
27 /// The editor's evil state in the Doom preset.
28 var editorState: String?
29 /// The keymap in use, for showing keys in menus and the palette.
30 var keymap = KeymapPreset.emacs.keymap
31 var modal = false
32 /// `org-stored-links`, most recent first; kept for the session, as in Emacs.
33 var storedLinks: [StoredLink] = []
34
35 /// `org-link--add-to-stored-links`: puts `link` first and says what happened.
36 func storeLink(_ link: StoredLink) {
37 if storedLinks.first == link {
38 show("This link has already been stored")
39 } else {
40 let moved = storedLinks.contains(link)
41 storedLinks.removeAll { $0 == link }
42 storedLinks.insert(link, at: 0)
43 show((moved ? "Link moved to front: " : "Stored: ") + (link.description ?? link.link))
44 }
45 }
46
47 /// The keys that run `command`, as menus and the palette show them.
48 func keyLabel(for command: String) -> String? {
49 let labels = keymap.keyLabels(for: command, modes: modal ? ["normal", "insert"] : [nil])
50 return labels.isEmpty ? nil : labels.joined(separator: ", ")
51 }
52
53 /// The editor's selection, for the modeline.
54 var selection = NSRange(location: 0, length: 0)
55 var keyHints: [KeyHint] = []
56 /// A question a command asked, answered in the echo area.
57 private(set) var prompt: PromptRequest?
58 /// The last message from a command or the keymap, cleared after a few seconds.
59 private(set) var message: String?
60 private var messageID = 0
61
62 func ask(_ prompt: Prompt, reply: @escaping (String?) -> Void) {
63 self.prompt = PromptRequest(prompt: prompt, reply: reply)
64 }
65
66 /// Clock questions in the echo area.
67 var askClock: ClockModel.Ask {
68 { [self] prompt in
69 await withCheckedContinuation { continuation in
70 ask(prompt) { continuation.resume(returning: $0) }
71 }
72 }
73 }
74
75 /// Ends the prompt with an answer, or nil to cancel, and gives the editor the keys back.
76 func answer(_ text: String?) {
77 guard let request = prompt else { return }
78 prompt = nil
79 if let textView = editor?.textView { textView.window?.makeFirstResponder(textView) }
80 request.reply(text)
81 }
82
83 /// An export or table recalculation running in Emacs, for Cancel Running Task.
84 private(set) var emacsTask: Task<Void, Never>?
85 private var emacsSerial = 0
86
87 /// Runs `work` as the Emacs task, cancelling the one before.
88 func runInEmacs(_ work: @escaping @MainActor () async -> Void) {
89 emacsTask?.cancel()
90 emacsSerial += 1
91 let serial = emacsSerial
92 emacsTask = Task {
93 await work()
94 if emacsSerial == serial { emacsTask = nil }
95 }
96 }
97
98 /// An app command from a menu, run by the main window.
99 private(set) var runRequest: (id: String, serial: Int)?
100 private var runSerial = 0
101
102 func run(_ id: String) {
103 runSerial += 1
104 runRequest = (id, runSerial)
105 }
106
107 /// A capture waiting for the capture window: the template key and the link properties for
108 /// its escapes (org-protocol), or the agenda day to schedule the entry on.
109 var captureRequest: (template: String?, properties: [String: String], day: Int?, serial: Int)?
110 private var captureSerial = 0
111
112 /// Capture from an agenda day, as `org-agenda-capture`: that day is the capture's date, and
113 /// the entry is scheduled on it. The caller opens the capture window.
114 func capture(on day: Int) {
115 captureSerial += 1
116 captureRequest = (nil, [:], day, captureSerial)
117 }
118
119 /// `org-protocol://capture` and `store-link`.
120 func handle(protocolURL url: String) {
121 switch OrgProtocol.parse(url) {
122 case .capture(let template, let properties):
123 captureSerial += 1
124 captureRequest = (template, properties, nil, captureSerial)
125 run("app.capture")
126 case .storeLink(let link, let title):
127 storeLink(StoredLink(link: link, description: title))
128 NSPasteboard.general.clearContents()
129 NSPasteboard.general.setString(link, forType: .string)
130 case nil:
131 show("Orgstar handles org-protocol capture and store-link")
132 }
133 }
134
135 func jump(path: String, offset: Int) {
136 jumpID += 1
137 jumpRequest = (path, offset, jumpID)
138 }
139
140 func show(_ text: String) {
141 messageID += 1
142 let id = messageID
143 message = text
144 Task {
145 try? await Task.sleep(for: .seconds(4))
146 if messageID == id { message = nil }
147 }
148 }
149}
150
151struct PromptRequest: Identifiable {
152 let id = UUID()
153 let prompt: Prompt
154 let reply: (String?) -> Void
155}
156
157@MainActor
158final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
159 var session: DocumentSession?
160 var commands: AppCommands?
161
162 func applicationDidFinishLaunching(_ notification: Notification) {
163 if SystemReminderCenter.isAvailable { UNUserNotificationCenter.current().delegate = self }
164 }
165
166 /// `:session` interpreters end with the app.
167 func applicationWillTerminate(_ notification: Notification) {
168 BabelSessions.shared.stopAll()
169 }
170
171 /// Reminders show while Orgstar is in front too.
172 nonisolated func userNotificationCenter(
173 _ center: UNUserNotificationCenter, willPresent notification: UNNotification
174 ) async -> UNNotificationPresentationOptions {
175 [.banner, .sound, .list]
176 }
177
178 /// Clicking a reminder shows its entry.
179 nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
180 let info = response.notification.request.content.userInfo
181 guard let path = info["path"] as? String, let offset = info["offset"] as? Int else { return }
182 await MainActor.run {
183 NSApp.activate()
184 commands?.jump(path: path, offset: offset)
185 }
186 }
187
188 /// Files opened from Finder, `open`, or a drop on the Dock icon: each opens as a buffer.
189 /// Ones that arrive before the window is ready wait for it (`pendingOpens`).
190 static var pendingOpens: [String] = []
191
192 func application(_ application: NSApplication, open urls: [URL]) {
193 let paths = urls.filter(\.isFileURL).map(\.path)
194 let links = urls.filter { $0.scheme == "org-protocol" }.map(\.absoluteString)
195 guard let commands, let session else {
196 Self.pendingOpens += paths
197 Self.pendingLinks += links
198 return
199 }
200 NSApp.activate()
201 for path in paths { session.open(path) }
202 for link in links { commands.handle(protocolURL: link) }
203 }
204
205 /// org-protocol links that arrive before the window is ready.
206 static var pendingLinks: [String] = []
207
208 func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
209
210 func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
211 guard let session else { return .terminateNow }
212 BufferRestore.save(session)
213 return UnsavedChanges.resolve(session, paths: session.openPaths) ? .terminateNow : .terminateCancel
214 }
215}
216
217/// Unsaved edits when leaving a file or quitting.
218@MainActor
219enum UnsavedChanges {
220 /// Whether the open file can be left: saved in automatic mode, or as the user chooses in
221 /// explicit mode. A save that fails or conflicts keeps the file open.
222 static func resolve(_ session: DocumentSession) -> Bool {
223 guard session.isDirty else { return true }
224 if session.saveMode == .automatic { return session.save() }
225 let alert = NSAlert()
226 alert.messageText = "Save changes to \(session.path.map { ($0 as NSString).lastPathComponent } ?? "this file")?"
227 alert.informativeText = "Your changes will be lost if you don't save them."
228 alert.addButton(withTitle: "Save")
229 alert.addButton(withTitle: "Don't Save")
230 alert.addButton(withTitle: "Cancel")
231 switch alert.runModal() {
232 case .alertFirstButtonReturn: return session.save()
233 case .alertSecondButtonReturn: return true
234 default: return false
235 }
236 }
237
238 /// Whether the buffers of `paths` can be closed: saved in automatic mode, or as the user
239 /// chooses for all of them in explicit mode.
240 static func resolve(_ session: DocumentSession, paths: [String]) -> Bool {
241 let dirty = paths.filter(session.dirtyPaths.contains)
242 guard !dirty.isEmpty else { return true }
243 if session.saveMode == .automatic { return dirty.map(session.save).allSatisfy { $0 } }
244 if dirty == [session.path] { return resolve(session) }
245 let alert = NSAlert()
246 alert.messageText = "Save changes to \(dirty.count) files?"
247 alert.informativeText = dirty.map { ($0 as NSString).lastPathComponent }.joined(separator: ", ")
248 alert.addButton(withTitle: "Save All")
249 alert.addButton(withTitle: "Don't Save")
250 alert.addButton(withTitle: "Cancel")
251 switch alert.runModal() {
252 case .alertFirstButtonReturn: return dirty.map(session.save).allSatisfy { $0 }
253 case .alertSecondButtonReturn: return true
254 default: return false
255 }
256 }
257}
258
259/// The open buffers, their order and carets, kept for the next launch.
260@MainActor
261enum BufferRestore {
262 private static let key = "openBuffers"
263
264 struct State: Codable {
265 var paths: [String]
266 var current: String?
267 var carets: [String: Int]
268 }
269
270 static func save(_ session: DocumentSession, defaults: UserDefaults = .standard) {
271 var carets: [String: Int] = [:]
272 for path in session.openPaths { carets[path] = session.caret(of: path) }
273 let state = State(paths: session.openPaths, current: session.path, carets: carets)
274 if let data = try? JSONEncoder().encode(state) { defaults.set(data, forKey: key) }
275 }
276
277 /// Opens the buffers that were open, in order, and returns the carets to show them at.
278 static func restore(_ session: DocumentSession, defaults: UserDefaults = .standard) -> [String: Int] {
279 guard let data = defaults.data(forKey: key), let state = try? JSONDecoder().decode(State.self, from: data) else { return [:] }
280 let existing = state.paths.filter { FileManager.default.fileExists(atPath: $0) }
281 for path in existing { session.open(path) }
282 if let current = state.current, existing.contains(current) { session.open(current) }
283 return state.carets
284 }
285}
286
287@main
288struct OrgstarApp: App {
289 @NSApplicationDelegateAdaptor private var delegate: AppDelegate
290 @State private var workspace = Result {
291 try WorkspaceModel(directory: WorkspaceModel.defaultDirectory, settings: .with(todoKeywords: Preferences.orgSettings))
292 }
293 @State private var commands = AppCommands()
294 @State private var clock = ClockModel()
295 @State private var scheduler = SystemReminderCenter.isAvailable ? ReminderScheduler(center: SystemReminderCenter()) : nil
296 @State private var session: DocumentSession = {
297 let session = DocumentSession()
298 session.defaults = Preferences.orgSettings
299 return session
300 }()
301
302 var body: some Scene {
303 Window("Orgstar", id: "main") {
304 switch workspace {
305 case .success(let model):
306 ContentView(workspace: model, session: session, commands: commands, scheduler: scheduler, clock: clock)
307 .environment(\.orgTheme, ConfigSync.shared.theme)
308 .onAppear {
309 delegate.session = session
310 delegate.commands = commands
311 CaptureIntent.environment = (model, session, clock, commands)
312 }
313 case .failure(let error):
314 ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription))
315 }
316 }
317 .defaultSize(width: 1280, height: 860)
318 Window("Agenda", id: "agenda") {
319 if case .success(let model) = workspace {
320 AgendaView(workspace: model, session: session, commands: commands, scheduler: scheduler, clock: clock)
321 .environment(\.orgTheme, ConfigSync.shared.theme)
322 }
323 }
324 .defaultSize(width: 760, height: 720)
325 Window("Board", id: "board") {
326 if case .success(let model) = workspace {
327 BoardView(workspace: model, session: session, commands: commands)
328 .environment(\.orgTheme, ConfigSync.shared.theme)
329 }
330 }
331 .defaultSize(width: 1100, height: 700)
332 Window("Clock Report", id: "clock-report") {
333 if case .success(let model) = workspace {
334 ClockReportView(workspace: model)
335 }
336 }
337 .defaultSize(width: 860, height: 560)
338 Window("Capture", id: "capture") {
339 if case .success(let model) = workspace {
340 CaptureView(workspace: model, session: session, commands: commands, clock: clock)
341 }
342 }
343 .windowResizability(.contentSize)
344 .windowLevel(.floating)
345 .commands {
346 CommandGroup(after: .newItem) {
347 Button("Add Folder…") { commands.showingFolderPicker = true }
348 .keyboardShortcut("o", modifiers: [.command, .shift])
349 Button("Quick Open…") { commands.showingQuickOpen = true }
350 .keyboardShortcut("p")
351 Button("Command Palette…") { commands.showingPalette = true }
352 .keyboardShortcut("p", modifiers: [.command, .shift])
353 }
354 CommandGroup(after: .saveItem) {
355 Menu("Export") {
356 ForEach(EditorCommands.all.filter { $0.id.hasPrefix("app.export.") }, id: \.id) { command in
357 Button(command.title) { commands.run(command.id) }
358 }
359 }
360 .disabled(session.path == nil)
361 }
362 CommandGroup(replacing: .saveItem) {
363 Button("Close Buffer") {
364 // ⌘W closes the buffer in the main window, and other windows themselves.
365 if let key = NSApp.keyWindow, key.identifier?.rawValue.contains("main") != true {
366 key.performClose(nil)
367 } else {
368 commands.run("app.buffer.kill")
369 }
370 }
371 .keyboardShortcut("w")
372 Button("Close Window") { NSApp.keyWindow?.performClose(nil) }
373 .keyboardShortcut("w", modifiers: [.command, .shift])
374 Divider()
375 Button("Save") {
376 if !session.save(), session.conflict != nil { commands.showingConflict = true }
377 }
378 .keyboardShortcut("s")
379 .disabled(session.path == nil || session.isReadOnly)
380 Button("Save All") { commands.run("app.save-all") }
381 .keyboardShortcut("s", modifiers: [.command, .option])
382 .disabled(session.dirtyPaths.isEmpty)
383 }
384 CommandGroup(after: .sidebar) {
385 Button("Show or Hide Outline") { commands.run("app.toggle-outline") }
386 .keyboardShortcut("o", modifiers: [.command, .option])
387 Button("Show or Hide Backlinks") { commands.run("app.toggle-backlinks") }
388 Button("Show or Hide Columns and Clock") { commands.run("app.toggle-inspector") }
389 .keyboardShortcut("i", modifiers: [.command, .option])
390 MarkupToggle()
391 Button("Truncate or Wrap Long Lines") { commands.run("editor.toggle-truncate-lines") }
392 }
393 CommandGroup(after: .pasteboard) {
394 Divider()
395 FindMenu(commands: commands)
396 }
397 CommandGroup(after: .textEditing) {
398 Button("Cancel Running Task") { commands.run("app.babel.cancel") }
399 .keyboardShortcut(".")
400 .disabled(!session.isRunningBabel && commands.emacsTask == nil)
401 Button("Search Notes") { commands.focusSearch = true }
402 .keyboardShortcut("f", modifiers: [.command, .shift])
403 }
404 CommandGroup(before: .windowList) {
405 AgendaMenuItem()
406 Divider()
407 Button("Switch to Buffer…") { commands.run("app.buffer.switch") }
408 Button("Next Buffer") { commands.run("app.buffer.next") }
409 .keyboardShortcut("]", modifiers: [.command, .shift])
410 Button("Previous Buffer") { commands.run("app.buffer.previous") }
411 .keyboardShortcut("[", modifiers: [.command, .shift])
412 Divider()
413 }
414 CommandGroup(after: .newItem) {
415 Button("Capture…") { commands.run("app.capture") }
416 .keyboardShortcut("n", modifiers: [.command, .shift])
417 }
418 }
419 .commands {
420 CommandMenu("Org") {
421 ClockMenu(commands: commands, clock: clock)
422 Divider()
423 OrgMenuItems(commands: commands)
424 }
425 }
426 .commands {
427 CommandGroup(after: .appSettings) {
428 Button("Edit Config File") { commands.jump(path: UserConfig.configFile.path, offset: 0) }
429 .keyboardShortcut(",", modifiers: [.command, .option])
430 }
431 }
432 Settings {
433 SettingsView(commands: commands, workspace: try? workspace.get())
434 }
435 }
436}
437
438/// The Org menu: every org command, with the keys that run it in the current keymap.
439struct OrgMenuItems: View {
440 let commands: AppCommands
441
442 var body: some View {
443 ForEach(EditorCommands.all.filter { $0.id.hasPrefix("org.") }, id: \.id) { command in
444 Button {
445 commands.editor?.run(command.id)
446 } label: {
447 Text(command.title)
448 if let keys = commands.keyLabel(for: command.id) { Text(keys) }
449 }
450 .disabled(commands.editor == nil)
451 }
452 }
453}
454
455/// View ▸ Show Markup (link brackets and targets, emphasis markers) and Show Line Numbers.
456struct MarkupToggle: View {
457 @AppStorage("showTabBar") private var showTabBar = false
458 @AppStorage("showMarkup") private var showMarkup = false
459 @AppStorage("showLineNumbers") private var showLineNumbers = true
460
461 var body: some View {
462 Toggle("Show Markup", isOn: $showMarkup)
463 .keyboardShortcut("m", modifiers: [.command, .shift])
464 Toggle("Show Line Numbers", isOn: $showLineNumbers)
465 .keyboardShortcut("l", modifiers: [.command, .shift])
466 Toggle("Show Tab Bar", isOn: $showTabBar)
467 }
468}
469
470/// Edit ▸ Find: the editor's find bar.
471struct FindMenu: View {
472 let commands: AppCommands
473
474 var body: some View {
475 Menu("Find") {
476 item("Find…", "edit.find", "f")
477 item("Find and Replace…", "edit.replace", "f", [.command, .option])
478 item("Find Next", "edit.find-next", "g")
479 item("Find Previous", "edit.find-previous", "g", [.command, .shift])
480 item("Use Selection for Find", "edit.use-selection-for-find", "e")
481 }
482 }
483
484 private func item(_ title: String, _ id: String, _ key: KeyEquivalent, _ modifiers: EventModifiers = .command) -> some View {
485 Button(title) { commands.editor?.run(id) }
486 .keyboardShortcut(key, modifiers: modifiers)
487 .disabled(commands.editor == nil)
488 }
489}
490
491/// Window ▸ Agenda.
492struct AgendaMenuItem: View {
493 @Environment(\.openWindow) private var openWindow
494
495 var body: some View {
496 Button("Agenda") { openWindow(id: "agenda") }
497 .keyboardShortcut("a", modifiers: [.command, .shift])
498 Button("Board") { openWindow(id: "board") }
499 .keyboardShortcut("b", modifiers: [.command, .shift])
500 }
501}
502
503struct SettingsView: View {
504 let commands: AppCommands
505 let workspace: WorkspaceModel?
506 @State private var importing = false
507 @Environment(\.openWindow) private var openWindow
508 @AppStorage("saveMode") private var saveMode = SaveMode.automatic
509 @AppStorage("keymap") private var preset = KeymapPreset.emacs
510 @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue
511 @AppStorage("tagsColumn") private var tagsColumn = -77
512 @AppStorage("headingAfterSubtree") private var headingAfterSubtree = true
513 @AppStorage("splitLine") private var splitLine = false
514 @AppStorage("alphabeticalLists") private var alphabeticalLists = true
515 @AppStorage("fillColumn") private var fillColumn = 80
516 @AppStorage("hiddenMarkers") private var hiddenMarkers = true
517 @AppStorage("prettyEntities") private var prettyEntities = true
518 @AppStorage("startupTruncated") private var startupTruncated = false
519 @AppStorage("todoKeywords") private var todoKeywords = Preferences.defaultTodoKeywords
520 @AppStorage("agendaSpan") private var agendaSpan = 10
521 @AppStorage("agendaStartOffset") private var agendaStartOffset = -3
522 @AppStorage("agendaSubfolders") private var agendaSubfolders = false
523 @AppStorage("agendaShowAllDates") private var agendaShowAllDates = false
524 @AppStorage("calendarEvents") private var calendarEvents = false
525 @AppStorage("calendarEventCalendars") private var calendarEventCalendars = ""
526 @State private var calendars: [(id: String, title: String, color: RGBA?)] = []
527 @AppStorage("reminders") private var reminders = true
528 @AppStorage("reminderLead") private var reminderLead = 12
529 @AppStorage("clockIdleTime") private var clockIdleTime = 0
530 @AppStorage("clockHistoryLength") private var clockHistoryLength = 5
531 @AppStorage("globalCapture") private var globalCapture = true
532 @AppStorage("settingsTab") private var tab = "general"
533 @AppStorage("showHiddenFiles") private var showHiddenFiles = true
534 @AppStorage("themeFont") private var themeFont = ""
535 @AppStorage("themeFontSize") private var themeFontSize = 13
536 @AppStorage("themeLineSpacing") private var themeLineSpacing = 2
537 @AppStorage("themeHeadingStep") private var themeHeadingStep = 1
538
539 /// Installed monospaced font families.
540 private static let monospacedFamilies: [String] = NSFontManager.shared.availableFontFamilies.filter { family in
541 NSFontManager.shared.font(withFamily: family, traits: [], weight: 5, size: 13)?.isFixedPitch == true
542 }
543
544 var body: some View {
545 TabView(selection: $tab) {
546 Tab("General", systemImage: "gearshape", value: "general") { general }
547 Tab("Editing", systemImage: "text.alignleft", value: "editing") { editing }
548 Tab("Appearance", systemImage: "paintpalette", value: "appearance") { appearance }
549 Tab("Agenda", systemImage: "calendar", value: "agenda") { agenda }
550 Tab("Capture", systemImage: "tray.and.arrow.down", value: "capture") { capture }
551 }
552 .frame(width: 600)
553 }
554
555 private func editConfig() {
556 ConfigSync.shared.start()
557 openWindow(id: "main")
558 commands.jump(path: UserConfig.configFile.path, offset: 0)
559 }
560
561 private var general: some View {
562 Form {
563 Section {
564 LabeledContent("config.toml") {
565 HStack {
566 Button("Edit in Orgstar") { editConfig() }
567 Button("Open with Default App") { NSWorkspace.shared.open(UserConfig.configFile) }
568 Button("Show in Finder") { NSWorkspace.shared.activateFileViewerSelecting([UserConfig.configFile]) }
569 }
570 }
571 LabeledContent("Emacs") {
572 Button("Import from Emacs…") { importing = true }
573 }
574 } footer: {
575 Text("Every setting here is also in \(UserConfig.configFile.path), which you can edit, share or keep with your dotfiles. Changes in either place show up in the other.")
576 .settingsFooter()
577 }
578 Section {
579 Picker("Save files", selection: $saveMode) {
580 Text("Automatically, when typing stops").tag(SaveMode.automatic)
581 Text("Only with File ▸ Save (⌘S)").tag(SaveMode.explicit)
582 }
583 .pickerStyle(.radioGroup)
584 }
585 Section {
586 Picker("Keys", selection: $preset) {
587 ForEach(KeymapPreset.allCases, id: \.self) { Text($0.title).tag($0) }
588 }
589 Toggle("Show hidden files and folders", isOn: $showHiddenFiles)
590 Picker("Option as Meta", selection: $optionAsMeta) {
591 Text("Left Option").tag(OptionAsMeta.left.rawValue)
592 Text("Right Option").tag(OptionAsMeta.right.rawValue)
593 Text("Both").tag(OptionAsMeta([.left, .right]).rawValue)
594 Text("Neither").tag(0)
595 }
596 } footer: {
597 Text("Your own bindings go in \(KeymapLoader.userFile.path), on top of the preset.")
598 .settingsFooter()
599 }
600 }
601 .formStyle(.grouped)
602 .frame(height: 330)
603 .sheet(isPresented: $importing) { EmacsImportView(workspace: workspace) }
604 }
605
606 private var editing: some View {
607 Form {
608 Section {
609 Picker("Tags", selection: $tagsColumn) {
610 Text("Aligned to end at column 77").tag(-77)
611 Text("One space after the title").tag(0)
612 }
613 Toggle("M-RET adds the new heading after the subtree", isOn: $headingAfterSubtree)
614 Toggle("M-RET splits the line at the caret", isOn: $splitLine)
615 Toggle("Lists can use letters (a. b. c.)", isOn: $alphabeticalLists)
616 Stepper("M-q fills to column \(fillColumn)", value: $fillColumn, in: 40...200)
617 }
618 Section {
619 Toggle("Long lines run off the edge instead of wrapping (org-startup-truncated)", isOn: $startupTruncated)
620 } footer: {
621 Text("Applies to files as they open. View ▸ Truncate or Wrap Long Lines switches the current file.")
622 .settingsFooter()
623 }
624 Section {
625 Toggle("Emacs hides emphasis markers (org-hide-emphasis-markers)", isOn: $hiddenMarkers)
626 Toggle("Emacs shows entities as characters (org-pretty-entities)", isOn: $prettyEntities)
627 } footer: {
628 Text("Tags, tables and M-q measure text as your Emacs displays it, so both write the same file.")
629 .settingsFooter()
630 }
631 Section {
632 TextField("Default TODO keywords", text: $todoKeywords, axis: .vertical)
633 } footer: {
634 Text("For files without #+TODO, in #+TODO syntax, one sequence per line. Takes effect when Orgstar restarts.")
635 .settingsFooter()
636 }
637 }
638 .formStyle(.grouped)
639 .frame(height: 520)
640 }
641
642 private var appearance: some View {
643 Form {
644 Section {
645 Picker("Font", selection: $themeFont) {
646 Text("System monospaced").tag("")
647 Divider()
648 ForEach(Self.monospacedFamilies, id: \.self) { Text($0).tag($0) }
649 if !themeFont.isEmpty, !Self.monospacedFamilies.contains(themeFont) { Text(themeFont).tag(themeFont) }
650 }
651 Stepper("Size: \(themeFontSize) pt", value: $themeFontSize, in: 8...36)
652 Stepper("Line spacing: \(themeLineSpacing) pt", value: $themeLineSpacing, in: 0...16)
653 Stepper("Headings grow by \(themeHeadingStep) pt a level", value: $themeHeadingStep, in: 0...8)
654 } footer: {
655 Text("Tags and tables line up in columns, so a monospaced font keeps them straight.")
656 .settingsFooter()
657 }
658 Section {
659 LabeledContent("Colors") {
660 HStack {
661 Button("Edit in config.toml") { editConfig() }
662 Button("Show Default Theme") {
663 openWindow(id: "main")
664 commands.jump(path: UserConfig.directory.appendingPathComponent("default-theme.toml").path, offset: 0)
665 }
666 }
667 }
668 } footer: {
669 Text("Set colors under [theme] (both appearances), [theme.light] or [theme.dark] in config.toml, and TODO keyword colors under [theme.todo]. default-theme.toml lists every color with the default theme's values.")
670 .settingsFooter()
671 }
672 }
673 .formStyle(.grouped)
674 .frame(height: 380)
675 }
676
677 private var agenda: some View {
678 Form {
679 Section {
680 Stepper("Agenda shows \(agendaSpan) days", value: $agendaSpan, in: 1...366)
681 Stepper(agendaStartOffset == 0 ? "Agenda starts today" : "Agenda starts \(-agendaStartOffset) days before today", value: $agendaStartOffset, in: -14...0)
682 Toggle("Include files in subfolders", isOn: $agendaSubfolders)
683 Toggle("Show days without entries", isOn: $agendaShowAllDates)
684 }
685 Section {
686 Toggle("Show events from Calendar", isOn: $calendarEvents)
687 if calendarEvents {
688 if calendars.isEmpty {
689 Text(CalendarEvents.isAuthorized ? "No calendars." : "Allow Orgstar to read your calendars in System Settings ▸ Privacy & Security ▸ Calendars.")
690 .foregroundStyle(.secondary)
691 }
692 ForEach(calendars, id: \.id) { calendar in
693 Toggle(isOn: calendarChosen(calendar)) {
694 HStack(spacing: 6) {
695 Circle()
696 .fill(calendar.color.map { Color(.sRGB, red: $0.red, green: $0.green, blue: $0.blue, opacity: $0.alpha) } ?? .secondary)
697 .frame(width: 8, height: 8)
698 Text(calendar.title)
699 }
700 }
701 }
702 }
703 } footer: {
704 Text("Events show in the agenda read-only; double-click one to open it in Calendar. New calendars show while every calendar is checked.")
705 .settingsFooter()
706 }
707 .task(id: calendarEvents) {
708 let allowed = calendarEvents ? await CalendarAccess.events.requestAccess() : false
709 calendars = allowed ? CalendarAccess.events.calendars() : []
710 }
711 Section {
712 Toggle("Notify before timed agenda entries", isOn: $reminders)
713 Stepper("\(reminderLead) minutes before", value: $reminderLead, in: 0...120)
714 .disabled(!reminders)
715 } footer: {
716 Text("An entry's APPT_WARNTIME property overrides the lead time.")
717 .settingsFooter()
718 }
719 Section {
720 Toggle("Ask what to do with idle time while clocked in", isOn: Binding {
721 clockIdleTime > 0
722 } set: { on in
723 clockIdleTime = on ? 15 : 0
724 })
725 Stepper("After \(max(clockIdleTime, 1)) minutes without keyboard or mouse input", value: $clockIdleTime, in: 1...240)
726 .disabled(clockIdleTime == 0)
727 Stepper("Remember \(clockHistoryLength) recently clocked entries", value: $clockHistoryLength, in: 1...35)
728 } footer: {
729 Text("org-clock-idle-time and org-clock-history-length. Org ▸ Clock lists the recent entries.")
730 .settingsFooter()
731 }
732 }
733 .formStyle(.grouped)
734 .frame(height: 560)
735 }
736
737 /// `calendar-event-calendars`: empty for every calendar, else the titles checked.
738 private func calendarChosen(_ calendar: (id: String, title: String, color: RGBA?)) -> Binding<Bool> {
739 let chosen = calendarEventCalendars.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
740 return Binding {
741 CalendarEvents.chooses(id: calendar.id, title: calendar.title, chosen: chosen)
742 } set: { on in
743 var titles = calendars.map(\.title).filter { title in
744 chosen.isEmpty || chosen.contains(title) || chosen.contains(calendars.first { $0.title == title }?.id ?? "")
745 }
746 titles.removeAll { $0 == calendar.title }
747 if on { titles.append(calendar.title) }
748 // An empty list means every calendar; turning events off hides them all.
749 guard !titles.isEmpty else { return }
750 calendarEventCalendars = Set(titles) == Set(calendars.map(\.title)) ? "" : titles.joined(separator: ", ")
751 }
752 }
753
754 private var capture: some View {
755 Form {
756 Section {
757 Toggle("⌃⌥Space opens Capture from any app", isOn: $globalCapture)
758 } footer: {
759 Text("Templates go in \(CaptureTemplates.userFile.path).")
760 .settingsFooter()
761 }
762 }
763 .formStyle(.grouped)
764 .frame(height: 160)
765 }
766}
767
768private extension Text {
769 func settingsFooter() -> some View {
770 font(.caption)
771 .foregroundStyle(.secondary)
772 .textSelection(.enabled)
773 .frame(maxWidth: .infinity, alignment: .leading)
774 }
775}