krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/Orgstar/OrgstarApp.swift

191ddb506f3a91b93c755ba6e8123a4e8ac4f90d
orgstar/Sources/Orgstar/OrgstarApp.swift history · blame · raw

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}