krz/orgstar

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

Sources/Orgstar/OrgstarApp.swift

176fc78498d9ba962fa963bbeca0dd433d9e0bab
orgstar/Sources/Orgstar/OrgstarApp.swift history · blame · raw

379 lines · 15467 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    /// The editor showing the open file, for menus and the palette.
 21    var editor: OrgEditor?
 22    /// The pending key prefix, and what can follow it.
 23    var pendingKeys: String?
 24    /// The editor's evil state in the Doom preset.
 25    var editorState: String?
 26    /// The editor's selection, for the modeline.
 27    var selection = NSRange(location: 0, length: 0)
 28    var keyHints: [KeyHint] = []
 29    /// A question a command asked, answered in the echo area.
 30    private(set) var prompt: PromptRequest?
 31    /// The last message from a command or the keymap, cleared after a few seconds.
 32    private(set) var message: String?
 33    private var messageID = 0
 34
 35    func ask(_ prompt: Prompt, reply: @escaping (String?) -> Void) {
 36        self.prompt = PromptRequest(prompt: prompt, reply: reply)
 37    }
 38
 39    /// Ends the prompt with an answer, or nil to cancel, and gives the editor the keys back.
 40    func answer(_ text: String?) {
 41        guard let request = prompt else { return }
 42        prompt = nil
 43        if let textView = editor?.textView { textView.window?.makeFirstResponder(textView) }
 44        request.reply(text)
 45    }
 46
 47    /// An app command from a menu, run by the main window.
 48    private(set) var runRequest: (id: String, serial: Int)?
 49    private var runSerial = 0
 50
 51    func run(_ id: String) {
 52        runSerial += 1
 53        runRequest = (id, runSerial)
 54    }
 55
 56    func jump(path: String, offset: Int) {
 57        jumpID += 1
 58        jumpRequest = (path, offset, jumpID)
 59    }
 60
 61    func show(_ text: String) {
 62        messageID += 1
 63        let id = messageID
 64        message = text
 65        Task {
 66            try? await Task.sleep(for: .seconds(4))
 67            if messageID == id { message = nil }
 68        }
 69    }
 70}
 71
 72struct PromptRequest: Identifiable {
 73    let id = UUID()
 74    let prompt: Prompt
 75    let reply: (String?) -> Void
 76}
 77
 78@MainActor
 79final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
 80    var session: DocumentSession?
 81    var commands: AppCommands?
 82
 83    func applicationDidFinishLaunching(_ notification: Notification) {
 84        if SystemReminderCenter.isAvailable { UNUserNotificationCenter.current().delegate = self }
 85    }
 86
 87    /// Reminders show while Orgstar is in front too.
 88    nonisolated func userNotificationCenter(
 89        _ center: UNUserNotificationCenter, willPresent notification: UNNotification
 90    ) async -> UNNotificationPresentationOptions {
 91        [.banner, .sound, .list]
 92    }
 93
 94    /// Clicking a reminder shows its entry.
 95    nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
 96        let info = response.notification.request.content.userInfo
 97        guard let path = info["path"] as? String, let offset = info["offset"] as? Int else { return }
 98        await MainActor.run {
 99            NSApp.activate()
100            commands?.jump(path: path, offset: offset)
101        }
102    }
103
104    func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
105
106    func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
107        guard let session else { return .terminateNow }
108        return UnsavedChanges.resolve(session) ? .terminateNow : .terminateCancel
109    }
110}
111
112/// Unsaved edits when leaving a file or quitting.
113@MainActor
114enum UnsavedChanges {
115    /// Whether the open file can be left: saved in automatic mode, or as the user chooses in
116    /// explicit mode. A save that fails or conflicts keeps the file open.
117    static func resolve(_ session: DocumentSession) -> Bool {
118        guard session.isDirty else { return true }
119        if session.saveMode == .automatic { return session.save() }
120        let alert = NSAlert()
121        alert.messageText = "Save changes to \(session.path.map { ($0 as NSString).lastPathComponent } ?? "this file")?"
122        alert.informativeText = "Your changes will be lost if you don't save them."
123        alert.addButton(withTitle: "Save")
124        alert.addButton(withTitle: "Don't Save")
125        alert.addButton(withTitle: "Cancel")
126        switch alert.runModal() {
127        case .alertFirstButtonReturn: return session.save()
128        case .alertSecondButtonReturn: return true
129        default: return false
130        }
131    }
132}
133
134@main
135struct OrgstarApp: App {
136    @NSApplicationDelegateAdaptor private var delegate: AppDelegate
137    @State private var workspace = Result {
138        try WorkspaceModel(directory: WorkspaceModel.defaultDirectory, settings: .with(todoKeywords: Preferences.orgSettings))
139    }
140    @State private var commands = AppCommands()
141    @State private var clock = ClockModel()
142    @State private var scheduler = SystemReminderCenter.isAvailable ? ReminderScheduler(center: SystemReminderCenter()) : nil
143    @State private var session: DocumentSession = {
144        let session = DocumentSession()
145        session.defaults = Preferences.orgSettings
146        return session
147    }()
148
149    var body: some Scene {
150        Window("Orgstar", id: "main") {
151            switch workspace {
152            case .success(let model):
153                ContentView(workspace: model, session: session, commands: commands, scheduler: scheduler, clock: clock)
154                    .onAppear {
155                        delegate.session = session
156                        delegate.commands = commands
157                    }
158            case .failure(let error):
159                ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription))
160            }
161        }
162        .defaultSize(width: 1280, height: 860)
163        Window("Agenda", id: "agenda") {
164            if case .success(let model) = workspace {
165                AgendaView(workspace: model, session: session, commands: commands, scheduler: scheduler)
166            }
167        }
168        .defaultSize(width: 760, height: 720)
169        Window("Board", id: "board") {
170            if case .success(let model) = workspace {
171                BoardView(workspace: model, session: session, commands: commands)
172            }
173        }
174        .defaultSize(width: 1100, height: 700)
175        Window("Clock Report", id: "clock-report") {
176            if case .success(let model) = workspace {
177                ClockReportView(workspace: model)
178            }
179        }
180        .defaultSize(width: 860, height: 560)
181        Window("Capture", id: "capture") {
182            if case .success(let model) = workspace {
183                CaptureView(workspace: model, session: session, commands: commands)
184            }
185        }
186        .windowResizability(.contentSize)
187        .windowLevel(.floating)
188        .commands {
189            CommandGroup(after: .newItem) {
190                Button("Add Folder…") { commands.showingFolderPicker = true }
191                    .keyboardShortcut("o", modifiers: [.command, .shift])
192                Button("Quick Open…") { commands.showingQuickOpen = true }
193                    .keyboardShortcut("p")
194                Button("Command Palette…") { commands.showingPalette = true }
195                    .keyboardShortcut("p", modifiers: [.command, .shift])
196            }
197            CommandGroup(after: .saveItem) {
198                Menu("Export") {
199                    ForEach(EditorCommands.all.filter { $0.id.hasPrefix("app.export.") }, id: \.id) { command in
200                        Button(command.title) { commands.run(command.id) }
201                    }
202                }
203                .disabled(session.path == nil)
204            }
205            CommandGroup(replacing: .saveItem) {
206                Button("Save") {
207                    if !session.save(), session.conflict != nil { commands.showingConflict = true }
208                }
209                .keyboardShortcut("s")
210                .disabled(session.path == nil || session.isReadOnly)
211            }
212            CommandGroup(after: .textEditing) {
213                Button("Search Notes") { commands.focusSearch = true }
214                    .keyboardShortcut("f", modifiers: [.command, .shift])
215            }
216            CommandGroup(before: .windowList) {
217                AgendaMenuItem()
218            }
219            CommandGroup(after: .newItem) {
220                Button("Capture…") { commands.run("app.capture") }
221                    .keyboardShortcut("n", modifiers: [.command, .shift])
222            }
223        }
224        .commands {
225            CommandMenu("Org") {
226                ForEach(EditorCommands.all.filter { $0.id.hasPrefix("org.") }, id: \.id) { command in
227                    Button(command.title) { commands.editor?.run(command.id) }
228                        .disabled(commands.editor == nil)
229                }
230            }
231        }
232        Settings {
233            SettingsView()
234        }
235    }
236}
237
238/// Window ▸ Agenda.
239struct AgendaMenuItem: View {
240    @Environment(\.openWindow) private var openWindow
241
242    var body: some View {
243        Button("Agenda") { openWindow(id: "agenda") }
244            .keyboardShortcut("a", modifiers: [.command, .shift])
245        Button("Board") { openWindow(id: "board") }
246            .keyboardShortcut("b", modifiers: [.command, .shift])
247    }
248}
249
250struct SettingsView: View {
251    @AppStorage("saveMode") private var saveMode = SaveMode.automatic
252    @AppStorage("keymap") private var preset = KeymapPreset.emacs
253    @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue
254    @AppStorage("tagsColumn") private var tagsColumn = -77
255    @AppStorage("headingAfterSubtree") private var headingAfterSubtree = true
256    @AppStorage("splitLine") private var splitLine = false
257    @AppStorage("alphabeticalLists") private var alphabeticalLists = true
258    @AppStorage("fillColumn") private var fillColumn = 80
259    @AppStorage("hiddenMarkers") private var hiddenMarkers = true
260    @AppStorage("prettyEntities") private var prettyEntities = true
261    @AppStorage("todoKeywords") private var todoKeywords = Preferences.defaultTodoKeywords
262    @AppStorage("agendaSpan") private var agendaSpan = 10
263    @AppStorage("agendaStartOffset") private var agendaStartOffset = -3
264    @AppStorage("agendaSubfolders") private var agendaSubfolders = false
265    @AppStorage("reminders") private var reminders = true
266    @AppStorage("reminderLead") private var reminderLead = 12
267    @AppStorage("globalCapture") private var globalCapture = true
268    @AppStorage("settingsTab") private var tab = "general"
269
270    var body: some View {
271        TabView(selection: $tab) {
272            Tab("General", systemImage: "gearshape", value: "general") { general }
273            Tab("Editing", systemImage: "text.alignleft", value: "editing") { editing }
274            Tab("Agenda", systemImage: "calendar", value: "agenda") { agenda }
275            Tab("Capture", systemImage: "tray.and.arrow.down", value: "capture") { capture }
276        }
277        .frame(width: 600)
278    }
279
280    private var general: some View {
281        Form {
282            Section {
283                Picker("Save files", selection: $saveMode) {
284                    Text("Automatically, when typing stops").tag(SaveMode.automatic)
285                    Text("Only with File ▸ Save (⌘S)").tag(SaveMode.explicit)
286                }
287                .pickerStyle(.radioGroup)
288            }
289            Section {
290                Picker("Keys", selection: $preset) {
291                    ForEach(KeymapPreset.allCases, id: \.self) { Text($0.title).tag($0) }
292                }
293                Picker("Option as Meta", selection: $optionAsMeta) {
294                    Text("Left Option").tag(OptionAsMeta.left.rawValue)
295                    Text("Right Option").tag(OptionAsMeta.right.rawValue)
296                    Text("Both").tag(OptionAsMeta([.left, .right]).rawValue)
297                    Text("Neither").tag(0)
298                }
299            } footer: {
300                Text("Your own bindings go in \(KeymapLoader.userFile.path), on top of the preset.")
301                    .settingsFooter()
302            }
303        }
304        .formStyle(.grouped)
305        .frame(height: 250)
306    }
307
308    private var editing: some View {
309        Form {
310            Section {
311                Picker("Tags", selection: $tagsColumn) {
312                    Text("Aligned to end at column 77").tag(-77)
313                    Text("One space after the title").tag(0)
314                }
315                Toggle("M-RET adds the new heading after the subtree", isOn: $headingAfterSubtree)
316                Toggle("M-RET splits the line at the caret", isOn: $splitLine)
317                Toggle("Lists can use letters (a. b. c.)", isOn: $alphabeticalLists)
318                Stepper("M-q fills to column \(fillColumn)", value: $fillColumn, in: 40...200)
319            }
320            Section {
321                Toggle("Emacs hides emphasis markers (org-hide-emphasis-markers)", isOn: $hiddenMarkers)
322                Toggle("Emacs shows entities as characters (org-pretty-entities)", isOn: $prettyEntities)
323            } footer: {
324                Text("Tags, tables and M-q measure text as your Emacs displays it, so both write the same file.")
325                    .settingsFooter()
326            }
327            Section {
328                TextField("Default TODO keywords", text: $todoKeywords)
329            } footer: {
330                Text("For files without #+TODO, in #+TODO syntax. Takes effect when Orgstar restarts.")
331                    .settingsFooter()
332            }
333        }
334        .formStyle(.grouped)
335        .frame(height: 450)
336    }
337
338    private var agenda: some View {
339        Form {
340            Section {
341                Stepper("Agenda shows \(agendaSpan) days", value: $agendaSpan, in: 1...31)
342                Stepper(agendaStartOffset == 0 ? "Agenda starts today" : "Agenda starts \(-agendaStartOffset) days before today", value: $agendaStartOffset, in: -14...0)
343                Toggle("Include files in subfolders", isOn: $agendaSubfolders)
344            }
345            Section {
346                Toggle("Notify before timed agenda entries", isOn: $reminders)
347                Stepper("\(reminderLead) minutes before", value: $reminderLead, in: 0...120)
348                    .disabled(!reminders)
349            } footer: {
350                Text("An entry's APPT_WARNTIME property overrides the lead time.")
351                    .settingsFooter()
352            }
353        }
354        .formStyle(.grouped)
355        .frame(height: 290)
356    }
357
358    private var capture: some View {
359        Form {
360            Section {
361                Toggle("⌃⌥Space opens Capture from any app", isOn: $globalCapture)
362            } footer: {
363                Text("Templates go in \(CaptureTemplates.userFile.path).")
364                    .settingsFooter()
365            }
366        }
367        .formStyle(.grouped)
368        .frame(height: 160)
369    }
370}
371
372private extension Text {
373    func settingsFooter() -> some View {
374        font(.caption)
375            .foregroundStyle(.secondary)
376            .textSelection(.enabled)
377            .frame(maxWidth: .infinity, alignment: .leading)
378    }
379}