krz/orgstar

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

Sources/Orgstar/OrgstarApp.swift

c1295824f55c7bba681fca8338bf08167e110105
orgstar/Sources/Orgstar/OrgstarApp.swift history · blame · raw

719 lines · 31588 bytes

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