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 | import OrgApp | 1 | import OrgApp |
| 2 | import OrgCore | ||
| 3 | import OrgEditorAppKit | ||
| 2 | import OrgIndex | 4 | import OrgIndex |
| 3 | import SwiftUI | 5 | import SwiftUI |
| 4 | 6 | ||
| @@ -7,6 +9,9 @@ struct ContentView: View { | |||
| 7 | let session: DocumentSession | 9 | let session: DocumentSession |
| 8 | @Bindable var commands: AppCommands | 10 | @Bindable var commands: AppCommands |
| 9 | @AppStorage("saveMode") private var saveMode = SaveMode.automatic | 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 | @State private var selectedFile: String? | 15 | @State private var selectedFile: String? |
| 11 | @State private var query = "" | 16 | @State private var query = "" |
| 12 | @FocusState private var searchFocused: Bool | 17 | @FocusState private var searchFocused: Bool |
| @@ -30,7 +35,8 @@ struct ContentView: View { | |||
| 30 | .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360) | 35 | .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360) |
| 31 | } detail: { | 36 | } detail: { |
| 32 | if session.path != nil { | 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 | } else { | 40 | } else { |
| 35 | ContentUnavailableView( | 41 | ContentUnavailableView( |
| 36 | workspace.roots.isEmpty ? "Add a folder of org files" : "Choose a file", | 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 | .onChange(of: saveMode, initial: true) { _, mode in session.saveMode = mode } | 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 | .onChange(of: session.conflict) { _, conflict in | 76 | .onChange(of: session.conflict) { _, conflict in |
| 63 | if conflict != nil { commands.showingConflict = true } | 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 | private var errorShown: Binding<Bool> { | 148 | private var errorShown: Binding<Bool> { |
| 110 | Binding(get: { workspace.lastError != nil || session.lastError != nil }, set: { _ in }) | 149 | Binding(get: { workspace.lastError != nil || session.lastError != nil }, set: { _ in }) |
| 111 | } | 150 | } |
Sources/Orgstar/EditorView.swift +16
| @@ -1,5 +1,6 @@ | |||
| 1 | import AppKit | 1 | import AppKit |
| 2 | import OrgApp | 2 | import OrgApp |
| 3 | import OrgCore | ||
| 3 | import OrgEditorAppKit | 4 | import OrgEditorAppKit |
| 4 | import SwiftUI | 5 | import SwiftUI |
| 5 | 6 | ||
| @@ -7,6 +8,10 @@ import SwiftUI | |||
| 7 | /// session's buffer; reveal requests go to the existing one. | 8 | /// session's buffer; reveal requests go to the existing one. |
| 8 | struct EditorView: NSViewRepresentable { | 9 | struct EditorView: NSViewRepresentable { |
| 9 | let session: DocumentSession | 10 | let session: DocumentSession |
| 11 | let commands: AppCommands | ||
| 12 | let keymap: Keymap | ||
| 13 | let optionAsMeta: OptionAsMeta | ||
| 14 | let onAppCommand: (String) -> Void | ||
| 10 | 15 | ||
| 11 | final class Coordinator { | 16 | final class Coordinator { |
| 12 | var editor: OrgEditor? | 17 | var editor: OrgEditor? |
| @@ -29,6 +34,13 @@ struct EditorView: NSViewRepresentable { | |||
| 29 | if let document = session.document { | 34 | if let document = session.document { |
| 30 | let editor = OrgEditor(document: document, frame: container.bounds, editable: document.isEditable) | 35 | let editor = OrgEditor(document: document, frame: container.bounds, editable: document.isEditable) |
| 31 | editor.onChange = { [weak session] in session?.bufferChanged() } | 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 | let scroll = editor.makeScrollView() | 44 | let scroll = editor.makeScrollView() |
| 33 | scroll.frame = container.bounds | 45 | scroll.frame = container.bounds |
| 34 | scroll.autoresizingMask = [.width, .height] | 46 | scroll.autoresizingMask = [.width, .height] |
| @@ -38,6 +50,10 @@ struct EditorView: NSViewRepresentable { | |||
| 38 | session.attach(editor) | 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 | container.window?.isDocumentEdited = session.isDirty | 57 | container.window?.isDocumentEdited = session.isDirty |
| 42 | if let request = session.revealRequest, request.id != coordinator.revealID { | 58 | if let request = session.revealRequest, request.id != coordinator.revealID { |
| 43 | coordinator.revealID = request.id | 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 | import AppKit | 1 | import AppKit |
| 2 | import OrgApp | 2 | import OrgApp |
| 3 | import OrgCore | ||
| 4 | import OrgEditorAppKit | ||
| 3 | import SwiftUI | 5 | import SwiftUI |
| 4 | 6 | ||
| 5 | /// Window-level requests the menu bar makes. | 7 | /// Window-level requests the menu bar makes. |
| @@ -10,6 +12,25 @@ final class AppCommands { | |||
| 10 | var showingFolderPicker = false | 12 | var showingFolderPicker = false |
| 11 | var focusSearch = false | 13 | var focusSearch = false |
| 12 | var showingConflict = 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 | /// 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 | @MainActor | 36 | @MainActor |
| @@ -70,6 +91,8 @@ struct OrgstarApp: App { | |||
| 70 | .keyboardShortcut("o", modifiers: [.command, .shift]) | 91 | .keyboardShortcut("o", modifiers: [.command, .shift]) |
| 71 | Button("Quick Open…") { commands.showingQuickOpen = true } | 92 | Button("Quick Open…") { commands.showingQuickOpen = true } |
| 72 | .keyboardShortcut("p") | 93 | .keyboardShortcut("p") |
| 94 | Button("Command Palette…") { commands.showingPalette = true } | ||
| 95 | .keyboardShortcut("p", modifiers: [.command, .shift]) | ||
| 73 | } | 96 | } |
| 74 | CommandGroup(replacing: .saveItem) { | 97 | CommandGroup(replacing: .saveItem) { |
| 75 | Button("Save") { | 98 | Button("Save") { |
| @@ -83,6 +106,14 @@ struct OrgstarApp: App { | |||
| 83 | .keyboardShortcut("f", modifiers: [.command, .shift]) | 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 | Settings { | 117 | Settings { |
| 87 | SettingsView() | 118 | SettingsView() |
| 88 | } | 119 | } |
| @@ -91,6 +122,8 @@ struct OrgstarApp: App { | |||
| 91 | 122 | ||
| 92 | struct SettingsView: View { | 123 | struct SettingsView: View { |
| 93 | @AppStorage("saveMode") private var saveMode = SaveMode.automatic | 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 | var body: some View { | 128 | var body: some View { |
| 96 | Form { | 129 | Form { |
| @@ -99,6 +132,19 @@ struct SettingsView: View { | |||
| 99 | Text("Only with File ▸ Save (⌘S)").tag(SaveMode.explicit) | 132 | Text("Only with File ▸ Save (⌘S)").tag(SaveMode.explicit) |
| 100 | } | 133 | } |
| 101 | .pickerStyle(.radioGroup) | 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 | .padding(20) | 149 | .padding(20) |
| 104 | .frame(width: 420) | 150 | .frame(width: 420) |
Tests/OrgAppTests/AppTests.swift +17
| @@ -209,3 +209,20 @@ struct SessionSavingTests { | |||
| 209 | #expect(workspace.search("unsaved", overlay: overlay).map(\.title) == ["Unsaved heading"]) | 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 | } | ||