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