krz/orgstar

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

Sources/Orgstar/OrgstarApp.swift

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

783 lines · 35604 bytes

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