Commit 397c9ad69a
Verified · cmc
Layout: unified · split
Sources/OrgApp/KeymapLoader.swift added +21
| @@ -0,0 +1,21 @@ | ||
| 1 | import Foundation | |
| 2 | import OrgCore | |
| 3 | ||
| 4 | @MainActor | |
| 5 | public enum KeymapLoader { | |
| 6 | /// `<data dir>/keymap.toml`. | |
| 7 | public static var userFile: URL { WorkspaceModel.defaultDirectory.appendingPathComponent("keymap.toml") } | |
| 8 | ||
| 9 | /// The preset with the user's file on top, and the problems found in the file. A missing | |
| 10 | /// file adds nothing; a file that isn't valid TOML adds nothing and is reported. | |
| 11 | public static func load(_ preset: KeymapPreset, userFile: URL = userFile) -> (keymap: Keymap, problems: [String]) { | |
| 12 | guard let text = try? String(contentsOf: userFile, encoding: .utf8) else { return (preset.keymap, []) } | |
| 13 | var problems: [String] = [] | |
| 14 | do { | |
| 15 | let user = try Keymap(toml: text, problems: &problems) | |
| 16 | return (.layered([preset.keymap, user]), problems) | |
| 17 | } catch { | |
| 18 | return (preset.keymap, ["\(error)"]) | |
| 19 | } | |
| 20 | } | |
| 21 | } | |
Sources/Orgstar/ContentView.swift +40 −1
| @@ -1,4 +1,6 @@ | ||
| 1 | 1 | import OrgApp |
| 2 | import OrgCore | |
| 3 | import OrgEditorAppKit | |
| 2 | 4 | import OrgIndex |
| 3 | 5 | import SwiftUI |
| 4 | 6 | |
| @@ -7,6 +9,9 @@ struct ContentView: View { | ||
| 7 | 9 | let session: DocumentSession |
| 8 | 10 | @Bindable var commands: AppCommands |
| 9 | 11 | @AppStorage("saveMode") private var saveMode = SaveMode.automatic |
| 12 | @AppStorage("keymap") private var preset = KeymapPreset.emacs | |
| 13 | @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue | |
| 14 | @State private var keymap = KeymapPreset.emacs.keymap | |
| 10 | 15 | @State private var selectedFile: String? |
| 11 | 16 | @State private var query = "" |
| 12 | 17 | @FocusState private var searchFocused: Bool |
| @@ -30,7 +35,8 @@ struct ContentView: View { | ||
| 30 | 35 | .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360) |
| 31 | 36 | } detail: { |
| 32 | 37 | if session.path != nil { |
| 33 | EditorView(session: session) | |
| 38 | EditorView(session: session, commands: commands, keymap: keymap, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), onAppCommand: perform) | |
| 39 | .safeAreaInset(edge: .bottom, spacing: 0) { EchoArea(commands: commands) } | |
| 34 | 40 | } else { |
| 35 | 41 | ContentUnavailableView( |
| 36 | 42 | workspace.roots.isEmpty ? "Add a folder of org files" : "Choose a file", |
| @@ -59,6 +65,14 @@ struct ContentView: View { | ||
| 59 | 65 | } |
| 60 | 66 | } |
| 61 | 67 | .onChange(of: saveMode, initial: true) { _, mode in session.saveMode = mode } |
| 68 | .onChange(of: preset, initial: true) { loadKeymap() } | |
| 69 | .sheet(isPresented: $commands.showingPalette) { | |
| 70 | PaletteView(keymap: keymap) { id in | |
| 71 | commands.showingPalette = false | |
| 72 | // After the sheet closes, so app commands can present their own. | |
| 73 | DispatchQueue.main.async { perform(id) } | |
| 74 | } | |
| 75 | } | |
| 62 | 76 | .onChange(of: session.conflict) { _, conflict in |
| 63 | 77 | if conflict != nil { commands.showingConflict = true } |
| 64 | 78 | } |
| @@ -106,6 +120,31 @@ struct ContentView: View { | ||
| 106 | 120 | } |
| 107 | 121 | } |
| 108 | 122 | |
| 123 | private func loadKeymap() { | |
| 124 | let loaded = KeymapLoader.load(preset) | |
| 125 | keymap = loaded.keymap | |
| 126 | if let first = loaded.problems.first { | |
| 127 | let more = loaded.problems.count > 1 ? " (and \(loaded.problems.count - 1) more)" : "" | |
| 128 | commands.show("keymap.toml: \(first)\(more)") | |
| 129 | } | |
| 130 | } | |
| 131 | ||
| 132 | /// Runs a command from a key, the palette or the Org menu. | |
| 133 | private func perform(_ id: String) { | |
| 134 | switch id { | |
| 135 | case "app.save": | |
| 136 | if !session.save(), session.conflict != nil { commands.showingConflict = true } | |
| 137 | case "app.quick-open": commands.showingQuickOpen = true | |
| 138 | case "app.palette": commands.showingPalette = true | |
| 139 | case "app.search": commands.focusSearch = true | |
| 140 | case "app.reload-keymap": | |
| 141 | loadKeymap() | |
| 142 | commands.show("Keymap reloaded") | |
| 143 | default: | |
| 144 | if let editor = commands.editor { editor.run(id) } else { commands.show("No file is open") } | |
| 145 | } | |
| 146 | } | |
| 147 | ||
| 109 | 148 | private var errorShown: Binding<Bool> { |
| 110 | 149 | Binding(get: { workspace.lastError != nil || session.lastError != nil }, set: { _ in }) |
| 111 | 150 | } |
Sources/Orgstar/EditorView.swift +16
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import AppKit |
| 2 | 2 | import OrgApp |
| 3 | import OrgCore | |
| 3 | 4 | import OrgEditorAppKit |
| 4 | 5 | import SwiftUI |
| 5 | 6 | |
| @@ -7,6 +8,10 @@ import SwiftUI | ||
| 7 | 8 | /// session's buffer; reveal requests go to the existing one. |
| 8 | 9 | struct EditorView: NSViewRepresentable { |
| 9 | 10 | let session: DocumentSession |
| 11 | let commands: AppCommands | |
| 12 | let keymap: Keymap | |
| 13 | let optionAsMeta: OptionAsMeta | |
| 14 | let onAppCommand: (String) -> Void | |
| 10 | 15 | |
| 11 | 16 | final class Coordinator { |
| 12 | 17 | var editor: OrgEditor? |
| @@ -29,6 +34,13 @@ struct EditorView: NSViewRepresentable { | ||
| 29 | 34 | if let document = session.document { |
| 30 | 35 | let editor = OrgEditor(document: document, frame: container.bounds, editable: document.isEditable) |
| 31 | 36 | editor.onChange = { [weak session] in session?.bufferChanged() } |
| 37 | editor.onMessage = { [weak commands] in commands?.show($0) } | |
| 38 | editor.onKeysPending = { [weak commands] keys, next in | |
| 39 | commands?.pendingKeys = keys | |
| 40 | commands?.keyHints = next.map { KeyHint(key: $0.key, title: $0.title) } | |
| 41 | } | |
| 42 | editor.onAppCommand = onAppCommand | |
| 43 | commands.editor = editor | |
| 32 | 44 | let scroll = editor.makeScrollView() |
| 33 | 45 | scroll.frame = container.bounds |
| 34 | 46 | scroll.autoresizingMask = [.width, .height] |
| @@ -38,6 +50,10 @@ struct EditorView: NSViewRepresentable { | ||
| 38 | 50 | session.attach(editor) |
| 39 | 51 | } |
| 40 | 52 | } |
| 53 | if let editor = coordinator.editor { | |
| 54 | if editor.keymap != keymap { editor.keymap = keymap } | |
| 55 | editor.optionAsMeta = optionAsMeta | |
| 56 | } | |
| 41 | 57 | container.window?.isDocumentEdited = session.isDirty |
| 42 | 58 | if let request = session.revealRequest, request.id != coordinator.revealID { |
| 43 | 59 | coordinator.revealID = request.id |
Sources/Orgstar/Keys.swift added +93
| @@ -0,0 +1,93 @@ | ||
| 1 | import OrgApp | |
| 2 | import OrgCore | |
| 3 | import OrgEditorAppKit | |
| 4 | import SwiftUI | |
| 5 | ||
| 6 | struct KeyHint: Hashable { | |
| 7 | let key: String | |
| 8 | let title: String | |
| 9 | } | |
| 10 | ||
| 11 | /// The minibuffer line: a pending prefix, or the last message for a few seconds. After a | |
| 12 | /// short pause on a prefix, the keys that can follow it. | |
| 13 | struct EchoArea: View { | |
| 14 | let commands: AppCommands | |
| 15 | @State private var showingHints = false | |
| 16 | ||
| 17 | var body: some View { | |
| 18 | VStack(alignment: .leading, spacing: 0) { | |
| 19 | if showingHints, commands.pendingKeys != nil, !commands.keyHints.isEmpty { | |
| 20 | Divider() | |
| 21 | LazyVGrid(columns: [GridItem(.adaptive(minimum: 220), alignment: .leading)], alignment: .leading, spacing: 4) { | |
| 22 | ForEach(commands.keyHints, id: \.self) { hint in | |
| 23 | HStack(spacing: 8) { | |
| 24 | Text(hint.key).font(.system(.body, design: .monospaced)).foregroundStyle(.tint) | |
| 25 | Text(hint.title).foregroundStyle(hint.title == "+prefix" ? .secondary : .primary).lineLimit(1) | |
| 26 | } | |
| 27 | } | |
| 28 | } | |
| 29 | .padding(10) | |
| 30 | } | |
| 31 | if let text = commands.pendingKeys.map({ $0 + "-" }) ?? commands.message { | |
| 32 | Divider() | |
| 33 | Text(text) | |
| 34 | .font(.system(.callout, design: .monospaced)) | |
| 35 | .lineLimit(1) | |
| 36 | .padding(.horizontal, 10) | |
| 37 | .padding(.vertical, 4) | |
| 38 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 39 | } | |
| 40 | } | |
| 41 | .background(.bar) | |
| 42 | .task(id: commands.pendingKeys) { | |
| 43 | showingHints = false | |
| 44 | guard commands.pendingKeys != nil else { return } | |
| 45 | try? await Task.sleep(for: .milliseconds(600)) | |
| 46 | if !Task.isCancelled { showingHints = true } | |
| 47 | } | |
| 48 | } | |
| 49 | } | |
| 50 | ||
| 51 | /// M-x and ⇧⌘P: every command by title, with its keys. | |
| 52 | struct PaletteView: View { | |
| 53 | let keymap: Keymap | |
| 54 | let run: (String) -> Void | |
| 55 | @State private var query = "" | |
| 56 | @Environment(\.dismiss) private var dismiss | |
| 57 | ||
| 58 | private var matches: [EditorCommand] { | |
| 59 | let scored: [(command: EditorCommand, score: Int)] = EditorCommands.all.filter(\.inPalette).compactMap { command in | |
| 60 | guard let score = FuzzyMatch.score(query, command.title) else { return nil } | |
| 61 | return (command, score) | |
| 62 | } | |
| 63 | return scored.sorted { $0.score != $1.score ? $0.score > $1.score : $0.command.title < $1.command.title }.map(\.command) | |
| 64 | } | |
| 65 | ||
| 66 | var body: some View { | |
| 67 | let matches = matches | |
| 68 | VStack(spacing: 0) { | |
| 69 | TextField("Run command", text: $query) | |
| 70 | .textFieldStyle(.plain) | |
| 71 | .font(.title3) | |
| 72 | .padding(12) | |
| 73 | .onSubmit { if let first = matches.first { run(first.id) } } | |
| 74 | Divider() | |
| 75 | List(matches, id: \.id) { command in | |
| 76 | Button { | |
| 77 | run(command.id) | |
| 78 | } label: { | |
| 79 | HStack { | |
| 80 | Text(command.title) | |
| 81 | Spacer() | |
| 82 | if let keys = keymap.keys(for: command.id) { | |
| 83 | Text(KeySequence.format(keys)).font(.system(.caption, design: .monospaced)).foregroundStyle(.secondary) | |
| 84 | } | |
| 85 | } | |
| 86 | } | |
| 87 | .buttonStyle(.plain) | |
| 88 | } | |
| 89 | } | |
| 90 | .frame(width: 520, height: 360) | |
| 91 | .onExitCommand { dismiss() } | |
| 92 | } | |
| 93 | } | |
Sources/Orgstar/OrgstarApp.swift +46
| @@ -1,5 +1,7 @@ | ||
| 1 | 1 | import AppKit |
| 2 | 2 | import OrgApp |
| 3 | import OrgCore | |
| 4 | import OrgEditorAppKit | |
| 3 | 5 | import SwiftUI |
| 4 | 6 | |
| 5 | 7 | /// Window-level requests the menu bar makes. |
| @@ -10,6 +12,25 @@ final class AppCommands { | ||
| 10 | 12 | var showingFolderPicker = false |
| 11 | 13 | var focusSearch = false |
| 12 | 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 | /// The last message from a command or the keymap, cleared after a few seconds. | |
| 22 | private(set) var message: String? | |
| 23 | private var messageID = 0 | |
| 24 | ||
| 25 | func show(_ text: String) { | |
| 26 | messageID += 1 | |
| 27 | let id = messageID | |
| 28 | message = text | |
| 29 | Task { | |
| 30 | try? await Task.sleep(for: .seconds(4)) | |
| 31 | if messageID == id { message = nil } | |
| 32 | } | |
| 33 | } | |
| 13 | 34 | } |
| 14 | 35 | |
| 15 | 36 | @MainActor |
| @@ -70,6 +91,8 @@ struct OrgstarApp: App { | ||
| 70 | 91 | .keyboardShortcut("o", modifiers: [.command, .shift]) |
| 71 | 92 | Button("Quick Open…") { commands.showingQuickOpen = true } |
| 72 | 93 | .keyboardShortcut("p") |
| 94 | Button("Command Palette…") { commands.showingPalette = true } | |
| 95 | .keyboardShortcut("p", modifiers: [.command, .shift]) | |
| 73 | 96 | } |
| 74 | 97 | CommandGroup(replacing: .saveItem) { |
| 75 | 98 | Button("Save") { |
| @@ -83,6 +106,14 @@ struct OrgstarApp: App { | ||
| 83 | 106 | .keyboardShortcut("f", modifiers: [.command, .shift]) |
| 84 | 107 | } |
| 85 | 108 | } |
| 109 | .commands { | |
| 110 | CommandMenu("Org") { | |
| 111 | ForEach(EditorCommands.all.filter { $0.id.hasPrefix("org.") }, id: \.id) { command in | |
| 112 | Button(command.title) { commands.editor?.run(command.id) } | |
| 113 | .disabled(commands.editor == nil) | |
| 114 | } | |
| 115 | } | |
| 116 | } | |
| 86 | 117 | Settings { |
| 87 | 118 | SettingsView() |
| 88 | 119 | } |
| @@ -91,6 +122,8 @@ struct OrgstarApp: App { | ||
| 91 | 122 | |
| 92 | 123 | struct SettingsView: View { |
| 93 | 124 | @AppStorage("saveMode") private var saveMode = SaveMode.automatic |
| 125 | @AppStorage("keymap") private var preset = KeymapPreset.emacs | |
| 126 | @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue | |
| 94 | 127 | |
| 95 | 128 | var body: some View { |
| 96 | 129 | Form { |
| @@ -99,6 +132,19 @@ struct SettingsView: View { | ||
| 99 | 132 | Text("Only with File ▸ Save (⌘S)").tag(SaveMode.explicit) |
| 100 | 133 | } |
| 101 | 134 | .pickerStyle(.radioGroup) |
| 135 | Picker("Keys:", selection: $preset) { | |
| 136 | ForEach(KeymapPreset.allCases, id: \.self) { Text($0.title).tag($0) } | |
| 137 | } | |
| 138 | Picker("Option as Meta:", selection: $optionAsMeta) { | |
| 139 | Text("Left Option").tag(OptionAsMeta.left.rawValue) | |
| 140 | Text("Right Option").tag(OptionAsMeta.right.rawValue) | |
| 141 | Text("Both").tag(OptionAsMeta([.left, .right]).rawValue) | |
| 142 | Text("Neither").tag(0) | |
| 143 | } | |
| 144 | Text("Your own bindings go in \(KeymapLoader.userFile.path), on top of the preset.") | |
| 145 | .font(.caption) | |
| 146 | .foregroundStyle(.secondary) | |
| 147 | .textSelection(.enabled) | |
| 102 | 148 | } |
| 103 | 149 | .padding(20) |
| 104 | 150 | .frame(width: 420) |
Tests/OrgAppTests/AppTests.swift +17
| @@ -209,3 +209,20 @@ struct SessionSavingTests { | ||
| 209 | 209 | #expect(workspace.search("unsaved", overlay: overlay).map(\.title) == ["Unsaved heading"]) |
| 210 | 210 | } |
| 211 | 211 | } |
| 212 | ||
| 213 | @MainActor | |
| 214 | struct KeymapLoaderTests { | |
| 215 | @Test func layersTheUserFile() throws { | |
| 216 | let folder = try Folder() | |
| 217 | let missing = KeymapLoader.load(.emacs, userFile: folder.url.appendingPathComponent("none.toml")) | |
| 218 | #expect(missing.keymap == KeymapPreset.emacs.keymap && missing.problems.isEmpty) | |
| 219 | ||
| 220 | let file = try folder.write("keymap.toml", "[[bind]]\nkeys = \"C-c a\"\ncommand = \"app.palette\"\n[[bind]]\nkeys = \"Q-q\"\ncommand = \"x\"\n") | |
| 221 | let loaded = KeymapLoader.load(.emacs, userFile: file) | |
| 222 | #expect(loaded.keymap.candidates(for: KeySequence.parse("C-c a")!).first?.command == "app.palette") | |
| 223 | #expect(loaded.problems == ["line 4: bad or missing keys"]) | |
| 224 | ||
| 225 | let broken = try folder.write("broken.toml", "keys = \"x") | |
| 226 | #expect(KeymapLoader.load(.mac, userFile: broken).problems == ["line 1: unclosed string"]) | |
| 227 | } | |
| 228 | } | |