Sources/Orgstar/OrgstarApp.swift
790 lines · 35930 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("enforceTodoDependencies") private var enforceTodoDependencies = false
521 @AppStorage("enforceTodoCheckboxDependencies") private var enforceTodoCheckboxDependencies = false
522 @AppStorage("agendaSpan") private var agendaSpan = 10
523 @AppStorage("agendaStartOffset") private var agendaStartOffset = -3
524 @AppStorage("agendaSubfolders") private var agendaSubfolders = false
525 @AppStorage("agendaShowAllDates") private var agendaShowAllDates = false
526 @AppStorage("agendaDimBlockedTasks") private var agendaDimBlockedTasks = "t"
527 @AppStorage("calendarEvents") private var calendarEvents = false
528 @AppStorage("calendarEventCalendars") private var calendarEventCalendars = ""
529 @State private var calendars: [(id: String, title: String, color: RGBA?)] = []
530 @AppStorage("reminders") private var reminders = true
531 @AppStorage("reminderLead") private var reminderLead = 12
532 @AppStorage("clockIdleTime") private var clockIdleTime = 0
533 @AppStorage("clockHistoryLength") private var clockHistoryLength = 5
534 @AppStorage("globalCapture") private var globalCapture = true
535 @AppStorage("settingsTab") private var tab = "general"
536 @AppStorage("showHiddenFiles") private var showHiddenFiles = true
537 @AppStorage("themeFont") private var themeFont = ""
538 @AppStorage("themeFontSize") private var themeFontSize = 13
539 @AppStorage("themeLineSpacing") private var themeLineSpacing = 2
540 @AppStorage("themeHeadingStep") private var themeHeadingStep = 1
541
542 /// Installed monospaced font families.
543 private static let monospacedFamilies: [String] = NSFontManager.shared.availableFontFamilies.filter { family in
544 NSFontManager.shared.font(withFamily: family, traits: [], weight: 5, size: 13)?.isFixedPitch == true
545 }
546
547 var body: some View {
548 TabView(selection: $tab) {
549 Tab("General", systemImage: "gearshape", value: "general") { general }
550 Tab("Editing", systemImage: "text.alignleft", value: "editing") { editing }
551 Tab("Appearance", systemImage: "paintpalette", value: "appearance") { appearance }
552 Tab("Agenda", systemImage: "calendar", value: "agenda") { agenda }
553 Tab("Capture", systemImage: "tray.and.arrow.down", value: "capture") { capture }
554 }
555 .frame(width: 600)
556 }
557
558 private func editConfig() {
559 ConfigSync.shared.start()
560 openWindow(id: "main")
561 commands.jump(path: UserConfig.configFile.path, offset: 0)
562 }
563
564 private var general: some View {
565 Form {
566 Section {
567 LabeledContent("config.toml") {
568 HStack {
569 Button("Edit in Orgstar") { editConfig() }
570 Button("Open with Default App") { NSWorkspace.shared.open(UserConfig.configFile) }
571 Button("Show in Finder") { NSWorkspace.shared.activateFileViewerSelecting([UserConfig.configFile]) }
572 }
573 }
574 LabeledContent("Emacs") {
575 Button("Import from Emacs…") { importing = true }
576 }
577 } footer: {
578 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.")
579 .settingsFooter()
580 }
581 Section {
582 Picker("Save files", selection: $saveMode) {
583 Text("Automatically, when typing stops").tag(SaveMode.automatic)
584 Text("Only with File ▸ Save (⌘S)").tag(SaveMode.explicit)
585 }
586 .pickerStyle(.radioGroup)
587 }
588 Section {
589 Picker("Keys", selection: $preset) {
590 ForEach(KeymapPreset.allCases, id: \.self) { Text($0.title).tag($0) }
591 }
592 Toggle("Show hidden files and folders", isOn: $showHiddenFiles)
593 Picker("Option as Meta", selection: $optionAsMeta) {
594 Text("Left Option").tag(OptionAsMeta.left.rawValue)
595 Text("Right Option").tag(OptionAsMeta.right.rawValue)
596 Text("Both").tag(OptionAsMeta([.left, .right]).rawValue)
597 Text("Neither").tag(0)
598 }
599 } footer: {
600 Text("Your own bindings go in \(KeymapLoader.userFile.path), on top of the preset.")
601 .settingsFooter()
602 }
603 }
604 .formStyle(.grouped)
605 .frame(height: 330)
606 .sheet(isPresented: $importing) { EmacsImportView(workspace: workspace) }
607 }
608
609 private var editing: some View {
610 Form {
611 Section {
612 Picker("Tags", selection: $tagsColumn) {
613 Text("Aligned to end at column 77").tag(-77)
614 Text("One space after the title").tag(0)
615 }
616 Toggle("M-RET adds the new heading after the subtree", isOn: $headingAfterSubtree)
617 Toggle("M-RET splits the line at the caret", isOn: $splitLine)
618 Toggle("Lists can use letters (a. b. c.)", isOn: $alphabeticalLists)
619 Stepper("M-q fills to column \(fillColumn)", value: $fillColumn, in: 40...200)
620 }
621 Section {
622 Toggle("Long lines run off the edge instead of wrapping (org-startup-truncated)", isOn: $startupTruncated)
623 } footer: {
624 Text("Applies to files as they open. View ▸ Truncate or Wrap Long Lines switches the current file.")
625 .settingsFooter()
626 }
627 Section {
628 Toggle("Emacs hides emphasis markers (org-hide-emphasis-markers)", isOn: $hiddenMarkers)
629 Toggle("Emacs shows entities as characters (org-pretty-entities)", isOn: $prettyEntities)
630 } footer: {
631 Text("Tags, tables and M-q measure text as your Emacs displays it, so both write the same file.")
632 .settingsFooter()
633 }
634 Section {
635 TextField("Default TODO keywords", text: $todoKeywords, axis: .vertical)
636 } footer: {
637 Text("For files without #+TODO, in #+TODO syntax, one sequence per line. Takes effect when Orgstar restarts.")
638 .settingsFooter()
639 }
640 Section {
641 Toggle("Entries can't be done before their TODO children (org-enforce-todo-dependencies)", isOn: $enforceTodoDependencies)
642 Toggle("Entries can't be done with unchecked boxes (org-enforce-todo-checkbox-dependencies)", isOn: $enforceTodoCheckboxDependencies)
643 } footer: {
644 Text("Under a parent with the ORDERED property (Toggle ORDERED Property, C-c C-x o), TODO children are also done in order.")
645 .settingsFooter()
646 }
647 }
648 .formStyle(.grouped)
649 .frame(height: 640)
650 }
651
652 private var appearance: some View {
653 Form {
654 Section {
655 Picker("Font", selection: $themeFont) {
656 Text("System monospaced").tag("")
657 Divider()
658 ForEach(Self.monospacedFamilies, id: \.self) { Text($0).tag($0) }
659 if !themeFont.isEmpty, !Self.monospacedFamilies.contains(themeFont) { Text(themeFont).tag(themeFont) }
660 }
661 Stepper("Size: \(themeFontSize) pt", value: $themeFontSize, in: 8...36)
662 Stepper("Line spacing: \(themeLineSpacing) pt", value: $themeLineSpacing, in: 0...16)
663 Stepper("Headings grow by \(themeHeadingStep) pt a level", value: $themeHeadingStep, in: 0...8)
664 } footer: {
665 Text("Tags and tables line up in columns, so a monospaced font keeps them straight.")
666 .settingsFooter()
667 }
668 Section {
669 LabeledContent("Colors") {
670 HStack {
671 Button("Edit in config.toml") { editConfig() }
672 Button("Show Default Theme") {
673 openWindow(id: "main")
674 commands.jump(path: UserConfig.directory.appendingPathComponent("default-theme.toml").path, offset: 0)
675 }
676 }
677 }
678 } footer: {
679 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.")
680 .settingsFooter()
681 }
682 }
683 .formStyle(.grouped)
684 .frame(height: 380)
685 }
686
687 private var agenda: some View {
688 Form {
689 Section {
690 Stepper("Agenda shows \(agendaSpan) days", value: $agendaSpan, in: 1...366)
691 Stepper(agendaStartOffset == 0 ? "Agenda starts today" : "Agenda starts \(-agendaStartOffset) days before today", value: $agendaStartOffset, in: -14...0)
692 Toggle("Include files in subfolders", isOn: $agendaSubfolders)
693 Toggle("Show days without entries", isOn: $agendaShowAllDates)
694 Picker("Blocked entries", selection: $agendaDimBlockedTasks) {
695 Text("Dimmed").tag("t")
696 Text("Hidden").tag("invisible")
697 Text("Shown as usual").tag("nil")
698 }
699 }
700 Section {
701 Toggle("Show events from Calendar", isOn: $calendarEvents)
702 if calendarEvents {
703 if calendars.isEmpty {
704 Text(CalendarEvents.isAuthorized ? "No calendars." : "Allow Orgstar to read your calendars in System Settings ▸ Privacy & Security ▸ Calendars.")
705 .foregroundStyle(.secondary)
706 }
707 ForEach(calendars, id: \.id) { calendar in
708 Toggle(isOn: calendarChosen(calendar)) {
709 HStack(spacing: 6) {
710 Circle()
711 .fill(calendar.color.map { Color(.sRGB, red: $0.red, green: $0.green, blue: $0.blue, opacity: $0.alpha) } ?? .secondary)
712 .frame(width: 8, height: 8)
713 Text(calendar.title)
714 }
715 }
716 }
717 }
718 } footer: {
719 Text("Events show in the agenda read-only; double-click one to open it in Calendar. New calendars show while every calendar is checked.")
720 .settingsFooter()
721 }
722 .task(id: calendarEvents) {
723 let allowed = calendarEvents ? await CalendarAccess.events.requestAccess() : false
724 calendars = allowed ? CalendarAccess.events.calendars() : []
725 }
726 Section {
727 Toggle("Notify before timed agenda entries", isOn: $reminders)
728 Stepper("\(reminderLead) minutes before", value: $reminderLead, in: 0...120)
729 .disabled(!reminders)
730 } footer: {
731 Text("An entry's APPT_WARNTIME property overrides the lead time.")
732 .settingsFooter()
733 }
734 Section {
735 Toggle("Ask what to do with idle time while clocked in", isOn: Binding {
736 clockIdleTime > 0
737 } set: { on in
738 clockIdleTime = on ? 15 : 0
739 })
740 Stepper("After \(max(clockIdleTime, 1)) minutes without keyboard or mouse input", value: $clockIdleTime, in: 1...240)
741 .disabled(clockIdleTime == 0)
742 Stepper("Remember \(clockHistoryLength) recently clocked entries", value: $clockHistoryLength, in: 1...35)
743 } footer: {
744 Text("org-clock-idle-time and org-clock-history-length. Org ▸ Clock lists the recent entries.")
745 .settingsFooter()
746 }
747 }
748 .formStyle(.grouped)
749 .frame(height: 560)
750 }
751
752 /// `calendar-event-calendars`: empty for every calendar, else the titles checked.
753 private func calendarChosen(_ calendar: (id: String, title: String, color: RGBA?)) -> Binding<Bool> {
754 let chosen = calendarEventCalendars.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
755 return Binding {
756 CalendarEvents.chooses(id: calendar.id, title: calendar.title, chosen: chosen)
757 } set: { on in
758 var titles = calendars.map(\.title).filter { title in
759 chosen.isEmpty || chosen.contains(title) || chosen.contains(calendars.first { $0.title == title }?.id ?? "")
760 }
761 titles.removeAll { $0 == calendar.title }
762 if on { titles.append(calendar.title) }
763 // An empty list means every calendar; turning events off hides them all.
764 guard !titles.isEmpty else { return }
765 calendarEventCalendars = Set(titles) == Set(calendars.map(\.title)) ? "" : titles.joined(separator: ", ")
766 }
767 }
768
769 private var capture: some View {
770 Form {
771 Section {
772 Toggle("⌃⌥Space opens Capture from any app", isOn: $globalCapture)
773 } footer: {
774 Text("Templates go in \(CaptureTemplates.userFile.path).")
775 .settingsFooter()
776 }
777 }
778 .formStyle(.grouped)
779 .frame(height: 160)
780 }
781}
782
783private extension Text {
784 func settingsFooter() -> some View {
785 font(.caption)
786 .foregroundStyle(.secondary)
787 .textSelection(.enabled)
788 .frame(maxWidth: .infinity, alignment: .leading)
789 }
790}