krz/orgstar

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

Sources/Orgstar/OrgstarApp.swift

85c633a83a4744bef9dde8b52462f9c2a1c037e4
orgstar/Sources/Orgstar/OrgstarApp.swift history · blame · raw

172 lines · 6700 bytes

  1import AppKit
  2import OrgApp
  3import OrgCore
  4import OrgEditorAppKit
  5import SwiftUI
  6
  7/// Window-level requests the menu bar makes.
  8@MainActor
  9@Observable
 10final class AppCommands {
 11    var showingQuickOpen = false
 12    var showingFolderPicker = false
 13    var focusSearch = false
 14    var showingConflict = false
 15    var showingPalette = false
 16    /// The editor showing the open file, for menus and the palette.
 17    var editor: OrgEditor?
 18    /// The pending key prefix, and what can follow it.
 19    var pendingKeys: String?
 20    var keyHints: [KeyHint] = []
 21    /// A question a command asked, answered in the echo area.
 22    private(set) var prompt: PromptRequest?
 23    /// The last message from a command or the keymap, cleared after a few seconds.
 24    private(set) var message: String?
 25    private var messageID = 0
 26
 27    func ask(_ prompt: Prompt, reply: @escaping (String?) -> Void) {
 28        self.prompt = PromptRequest(prompt: prompt, reply: reply)
 29    }
 30
 31    /// Ends the prompt with an answer, or nil to cancel, and gives the editor the keys back.
 32    func answer(_ text: String?) {
 33        guard let request = prompt else { return }
 34        prompt = nil
 35        if let textView = editor?.textView { textView.window?.makeFirstResponder(textView) }
 36        request.reply(text)
 37    }
 38
 39    func show(_ text: String) {
 40        messageID += 1
 41        let id = messageID
 42        message = text
 43        Task {
 44            try? await Task.sleep(for: .seconds(4))
 45            if messageID == id { message = nil }
 46        }
 47    }
 48}
 49
 50struct PromptRequest: Identifiable {
 51    let id = UUID()
 52    let prompt: Prompt
 53    let reply: (String?) -> Void
 54}
 55
 56@MainActor
 57final class AppDelegate: NSObject, NSApplicationDelegate {
 58    var session: DocumentSession?
 59
 60    func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
 61
 62    func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
 63        guard let session else { return .terminateNow }
 64        return UnsavedChanges.resolve(session) ? .terminateNow : .terminateCancel
 65    }
 66}
 67
 68/// Unsaved edits when leaving a file or quitting.
 69@MainActor
 70enum UnsavedChanges {
 71    /// Whether the open file can be left: saved in automatic mode, or as the user chooses in
 72    /// explicit mode. A save that fails or conflicts keeps the file open.
 73    static func resolve(_ session: DocumentSession) -> Bool {
 74        guard session.isDirty else { return true }
 75        if session.saveMode == .automatic { return session.save() }
 76        let alert = NSAlert()
 77        alert.messageText = "Save changes to \(session.path.map { ($0 as NSString).lastPathComponent } ?? "this file")?"
 78        alert.informativeText = "Your changes will be lost if you don't save them."
 79        alert.addButton(withTitle: "Save")
 80        alert.addButton(withTitle: "Don't Save")
 81        alert.addButton(withTitle: "Cancel")
 82        switch alert.runModal() {
 83        case .alertFirstButtonReturn: return session.save()
 84        case .alertSecondButtonReturn: return true
 85        default: return false
 86        }
 87    }
 88}
 89
 90@main
 91struct OrgstarApp: App {
 92    @NSApplicationDelegateAdaptor private var delegate: AppDelegate
 93    @State private var workspace = Result { try WorkspaceModel(directory: WorkspaceModel.defaultDirectory) }
 94    @State private var commands = AppCommands()
 95    @State private var session = DocumentSession()
 96
 97    var body: some Scene {
 98        Window("Orgstar", id: "main") {
 99            switch workspace {
100            case .success(let model):
101                ContentView(workspace: model, session: session, commands: commands)
102                    .onAppear { delegate.session = session }
103            case .failure(let error):
104                ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription))
105            }
106        }
107        .defaultSize(width: 1280, height: 860)
108        .commands {
109            CommandGroup(after: .newItem) {
110                Button("Add Folder…") { commands.showingFolderPicker = true }
111                    .keyboardShortcut("o", modifiers: [.command, .shift])
112                Button("Quick Open…") { commands.showingQuickOpen = true }
113                    .keyboardShortcut("p")
114                Button("Command Palette…") { commands.showingPalette = true }
115                    .keyboardShortcut("p", modifiers: [.command, .shift])
116            }
117            CommandGroup(replacing: .saveItem) {
118                Button("Save") {
119                    if !session.save(), session.conflict != nil { commands.showingConflict = true }
120                }
121                .keyboardShortcut("s")
122                .disabled(session.path == nil || session.isReadOnly)
123            }
124            CommandGroup(after: .textEditing) {
125                Button("Search Notes") { commands.focusSearch = true }
126                    .keyboardShortcut("f", modifiers: [.command, .shift])
127            }
128        }
129        .commands {
130            CommandMenu("Org") {
131                ForEach(EditorCommands.all.filter { $0.id.hasPrefix("org.") }, id: \.id) { command in
132                    Button(command.title) { commands.editor?.run(command.id) }
133                        .disabled(commands.editor == nil)
134                }
135            }
136        }
137        Settings {
138            SettingsView()
139        }
140    }
141}
142
143struct SettingsView: View {
144    @AppStorage("saveMode") private var saveMode = SaveMode.automatic
145    @AppStorage("keymap") private var preset = KeymapPreset.emacs
146    @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue
147
148    var body: some View {
149        Form {
150            Picker("Save files:", selection: $saveMode) {
151                Text("Automatically, when typing stops").tag(SaveMode.automatic)
152                Text("Only with File ▸ Save (⌘S)").tag(SaveMode.explicit)
153            }
154            .pickerStyle(.radioGroup)
155            Picker("Keys:", selection: $preset) {
156                ForEach(KeymapPreset.allCases, id: \.self) { Text($0.title).tag($0) }
157            }
158            Picker("Option as Meta:", selection: $optionAsMeta) {
159                Text("Left Option").tag(OptionAsMeta.left.rawValue)
160                Text("Right Option").tag(OptionAsMeta.right.rawValue)
161                Text("Both").tag(OptionAsMeta([.left, .right]).rawValue)
162                Text("Neither").tag(0)
163            }
164            Text("Your own bindings go in \(KeymapLoader.userFile.path), on top of the preset.")
165                .font(.caption)
166                .foregroundStyle(.secondary)
167                .textSelection(.enabled)
168        }
169        .padding(20)
170        .frame(width: 420)
171    }
172}