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