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 @@
1import OrgApp 1import OrgApp
2import OrgCore
3import OrgEditorAppKit
2import OrgIndex 4import OrgIndex
3import SwiftUI 5import 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 @@
1import AppKit 1import AppKit
2import OrgApp 2import OrgApp
3import OrgCore
3import OrgEditorAppKit 4import OrgEditorAppKit
4import SwiftUI 5import 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.
8struct EditorView: NSViewRepresentable { 9struct 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 @@
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 @@
1import AppKit 1import AppKit
2import OrgApp 2import OrgApp
3import OrgCore
4import OrgEditorAppKit
3import SwiftUI 5import 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
92struct SettingsView: View { 123struct 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
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}