krz/orgstar

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

Commit 397c9ad69a

397c9ad69a596cfff75844141c5e537217661eb3

parent: 10c3366127

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 03:57 UTC

Keymaps, echo area and palette in the app

Layout: unified · split

Sources/OrgApp/KeymapLoader.swift added +21
@@ -0,0 +1,21 @@
1import Foundation
2import OrgCore
3
4@MainActor
5public 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 @@
11import OrgApp
2import OrgCore
3import OrgEditorAppKit
24import OrgIndex
35import SwiftUI
46
@@ -7,6 +9,9 @@ struct ContentView: View {
79 let session: DocumentSession
810 @Bindable var commands: AppCommands
911 @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
1015 @State private var selectedFile: String?
1116 @State private var query = ""
1217 @FocusState private var searchFocused: Bool
@@ -30,7 +35,8 @@ struct ContentView: View {
3035 .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360)
3136 } detail: {
3237 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) }
3440 } else {
3541 ContentUnavailableView(
3642 workspace.roots.isEmpty ? "Add a folder of org files" : "Choose a file",
@@ -59,6 +65,14 @@ struct ContentView: View {
5965 }
6066 }
6167 .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 }
6276 .onChange(of: session.conflict) { _, conflict in
6377 if conflict != nil { commands.showingConflict = true }
6478 }
@@ -106,6 +120,31 @@ struct ContentView: View {
106120 }
107121 }
108122
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
109148 private var errorShown: Binding<Bool> {
110149 Binding(get: { workspace.lastError != nil || session.lastError != nil }, set: { _ in })
111150 }
Sources/Orgstar/EditorView.swift +16
@@ -1,5 +1,6 @@
11import AppKit
22import OrgApp
3import OrgCore
34import OrgEditorAppKit
45import SwiftUI
56
@@ -7,6 +8,10 @@ import SwiftUI
78/// session's buffer; reveal requests go to the existing one.
89struct EditorView: NSViewRepresentable {
910 let session: DocumentSession
11 let commands: AppCommands
12 let keymap: Keymap
13 let optionAsMeta: OptionAsMeta
14 let onAppCommand: (String) -> Void
1015
1116 final class Coordinator {
1217 var editor: OrgEditor?
@@ -29,6 +34,13 @@ struct EditorView: NSViewRepresentable {
2934 if let document = session.document {
3035 let editor = OrgEditor(document: document, frame: container.bounds, editable: document.isEditable)
3136 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
3244 let scroll = editor.makeScrollView()
3345 scroll.frame = container.bounds
3446 scroll.autoresizingMask = [.width, .height]
@@ -38,6 +50,10 @@ struct EditorView: NSViewRepresentable {
3850 session.attach(editor)
3951 }
4052 }
53 if let editor = coordinator.editor {
54 if editor.keymap != keymap { editor.keymap = keymap }
55 editor.optionAsMeta = optionAsMeta
56 }
4157 container.window?.isDocumentEdited = session.isDirty
4258 if let request = session.revealRequest, request.id != coordinator.revealID {
4359 coordinator.revealID = request.id
Sources/Orgstar/Keys.swift added +93
@@ -0,0 +1,93 @@
1import OrgApp
2import OrgCore
3import OrgEditorAppKit
4import SwiftUI
5
6struct 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.
13struct 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.
52struct 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 @@
11import AppKit
22import OrgApp
3import OrgCore
4import OrgEditorAppKit
35import SwiftUI
46
57/// Window-level requests the menu bar makes.
@@ -10,6 +12,25 @@ final class AppCommands {
1012 var showingFolderPicker = false
1113 var focusSearch = false
1214 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 }
1334}
1435
1536@MainActor
@@ -70,6 +91,8 @@ struct OrgstarApp: App {
7091 .keyboardShortcut("o", modifiers: [.command, .shift])
7192 Button("Quick Open…") { commands.showingQuickOpen = true }
7293 .keyboardShortcut("p")
94 Button("Command Palette…") { commands.showingPalette = true }
95 .keyboardShortcut("p", modifiers: [.command, .shift])
7396 }
7497 CommandGroup(replacing: .saveItem) {
7598 Button("Save") {
@@ -83,6 +106,14 @@ struct OrgstarApp: App {
83106 .keyboardShortcut("f", modifiers: [.command, .shift])
84107 }
85108 }
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 }
86117 Settings {
87118 SettingsView()
88119 }
@@ -91,6 +122,8 @@ struct OrgstarApp: App {
91122
92123struct SettingsView: View {
93124 @AppStorage("saveMode") private var saveMode = SaveMode.automatic
125 @AppStorage("keymap") private var preset = KeymapPreset.emacs
126 @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue
94127
95128 var body: some View {
96129 Form {
@@ -99,6 +132,19 @@ struct SettingsView: View {
99132 Text("Only with File ▸ Save (⌘S)").tag(SaveMode.explicit)
100133 }
101134 .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)
102148 }
103149 .padding(20)
104150 .frame(width: 420)
Tests/OrgAppTests/AppTests.swift +17
@@ -209,3 +209,20 @@ struct SessionSavingTests {
209209 #expect(workspace.search("unsaved", overlay: overlay).map(\.title) == ["Unsaved heading"])
210210 }
211211}
212
213@MainActor
214struct 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}