krz/orgstar

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

Commit 99566e2d13

99566e2d13b805e89149c6338d59d0b6729549ec

parent: e28aeb4bea

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 18:19 UTC

Keys in the Org menu and palette

Each Org menu item shows the keys that run it as a subtitle, and the
palette shows them beside each command, for the current preset: in
Doom, normal-state keys before insert-state ones. Keymap.keyLabels
ranks bindings that hold everywhere before contextual ones.

Layout: unified · split

Sources/OrgCore/Keymap/Keymap.swift +19
@@ -95,6 +95,25 @@ public struct Keymap: Sendable, Equatable {
95 return seen.map { (key: $0.key, command: $0.value) }.sorted { $0.key.description < $1.key.description } 95 return seen.map { (key: $0.key, command: $0.value) }.sorted { $0.key.description < $1.key.description }
96 } 96 }
97 97
98 /// Live key sequences that run `command`, formatted, at most `limit`: what menus and the
99 /// palette show. Earlier `modes` first, then bindings that hold everywhere before those
100 /// with a `when`, then shorter ones.
101 public func keyLabels(for command: String, modes: [String?] = [nil], limit: Int = 2) -> [String] {
102 var found: [(keys: [KeyChord], rank: [Int])] = []
103 for (order, mode) in modes.enumerated() {
104 for binding in bindings.reversed() where binding.mode == mode && binding.command == command
105 && candidates(for: binding.keys, mode: mode).contains(binding) {
106 found.append((binding.keys, [order, binding.when == nil ? 0 : 1, binding.keys.count, found.count]))
107 }
108 }
109 var seen: Set<String> = []
110 return found.sorted { $0.rank.lexicographicallyPrecedes($1.rank) }
111 .map { KeySequence.format($0.keys) }
112 .filter { seen.insert($0).inserted }
113 .prefix(limit)
114 .map { $0 }
115 }
116
98 /// The first live binding of `command`, for showing its keys. 117 /// The first live binding of `command`, for showing its keys.
99 public func keys(for command: String, mode: String? = nil) -> [KeyChord]? { 118 public func keys(for command: String, mode: String? = nil) -> [KeyChord]? {
100 bindings.reversed().first { $0.mode == mode && $0.command == command && candidates(for: $0.keys, mode: mode).contains($0) }?.keys 119 bindings.reversed().first { $0.mode == mode && $0.command == command && candidates(for: $0.keys, mode: mode).contains($0) }?.keys
Sources/Orgstar/ContentView.swift +3 −1
@@ -124,7 +124,7 @@ struct ContentView: View {
124 .onChange(of: options, initial: true) { _, options in session.options = options } 124 .onChange(of: options, initial: true) { _, options in session.options = options }
125 .onChange(of: preset, initial: true) { loadKeymap() } 125 .onChange(of: preset, initial: true) { loadKeymap() }
126 .sheet(isPresented: $commands.showingPalette) { 126 .sheet(isPresented: $commands.showingPalette) {
127 PaletteView(keymap: keymap) { id in 127 PaletteView(commands: commands) { id in
128 commands.showingPalette = false 128 commands.showingPalette = false
129 // After the sheet closes, so app commands can present their own. 129 // After the sheet closes, so app commands can present their own.
130 DispatchQueue.main.async { perform(id) } 130 DispatchQueue.main.async { perform(id) }
@@ -325,6 +325,8 @@ struct ContentView: View {
325 private func loadKeymap() { 325 private func loadKeymap() {
326 let loaded = KeymapLoader.load(preset) 326 let loaded = KeymapLoader.load(preset)
327 keymap = loaded.keymap 327 keymap = loaded.keymap
328 commands.keymap = loaded.keymap
329 commands.modal = preset.isModal
328 if let first = loaded.problems.first { 330 if let first = loaded.problems.first {
329 let more = loaded.problems.count > 1 ? " (and \(loaded.problems.count - 1) more)" : "" 331 let more = loaded.problems.count > 1 ? " (and \(loaded.problems.count - 1) more)" : ""
330 commands.show("keymap.toml: \(first)\(more)") 332 commands.show("keymap.toml: \(first)\(more)")
Sources/Orgstar/Keys.swift +3 −3
@@ -132,7 +132,7 @@ extension EchoArea {
132 132
133/// M-x and ⇧⌘P: every command by title, with its keys. 133/// M-x and ⇧⌘P: every command by title, with its keys.
134struct PaletteView: View { 134struct PaletteView: View {
135 let keymap: Keymap 135 let commands: AppCommands
136 let run: (String) -> Void 136 let run: (String) -> Void
137 @State private var query = "" 137 @State private var query = ""
138 @Environment(\.dismiss) private var dismiss 138 @Environment(\.dismiss) private var dismiss
@@ -161,8 +161,8 @@ struct PaletteView: View {
161 HStack { 161 HStack {
162 Text(command.title) 162 Text(command.title)
163 Spacer() 163 Spacer()
164 if let keys = keymap.keys(for: command.id) { 164 if let keys = commands.keyLabel(for: command.id) {
165 Text(KeySequence.format(keys)).font(.system(.caption, design: .monospaced)).foregroundStyle(.secondary) 165 Text(keys).font(.system(.caption, design: .monospaced)).foregroundStyle(.secondary)
166 } 166 }
167 } 167 }
168 } 168 }
Sources/Orgstar/OrgstarApp.swift +28 −4
@@ -23,6 +23,16 @@ final class AppCommands {
23 var pendingKeys: String? 23 var pendingKeys: String?
24 /// The editor's evil state in the Doom preset. 24 /// The editor's evil state in the Doom preset.
25 var editorState: String? 25 var editorState: String?
26 /// The keymap in use, for showing keys in menus and the palette.
27 var keymap = KeymapPreset.emacs.keymap
28 var modal = false
29
30 /// The keys that run `command`, as menus and the palette show them.
31 func keyLabel(for command: String) -> String? {
32 let labels = keymap.keyLabels(for: command, modes: modal ? ["normal", "insert"] : [nil])
33 return labels.isEmpty ? nil : labels.joined(separator: ", ")
34 }
35
26 /// The editor's selection, for the modeline. 36 /// The editor's selection, for the modeline.
27 var selection = NSRange(location: 0, length: 0) 37 var selection = NSRange(location: 0, length: 0)
28 var keyHints: [KeyHint] = [] 38 var keyHints: [KeyHint] = []
@@ -223,10 +233,7 @@ struct OrgstarApp: App {
223 } 233 }
224 .commands { 234 .commands {
225 CommandMenu("Org") { 235 CommandMenu("Org") {
226 ForEach(EditorCommands.all.filter { $0.id.hasPrefix("org.") }, id: \.id) { command in 236 OrgMenuItems(commands: commands)
227 Button(command.title) { commands.editor?.run(command.id) }
228 .disabled(commands.editor == nil)
229 }
230 } 237 }
231 } 238 }
232 Settings { 239 Settings {
@@ -235,6 +242,23 @@ struct OrgstarApp: App {
235 } 242 }
236} 243}
237 244
245/// The Org menu: every org command, with the keys that run it in the current keymap.
246struct OrgMenuItems: View {
247 let commands: AppCommands
248
249 var body: some View {
250 ForEach(EditorCommands.all.filter { $0.id.hasPrefix("org.") }, id: \.id) { command in
251 Button {
252 commands.editor?.run(command.id)
253 } label: {
254 Text(command.title)
255 if let keys = commands.keyLabel(for: command.id) { Text(keys) }
256 }
257 .disabled(commands.editor == nil)
258 }
259 }
260}
261
238/// Window ▸ Agenda. 262/// Window ▸ Agenda.
239struct AgendaMenuItem: View { 263struct AgendaMenuItem: View {
240 @Environment(\.openWindow) private var openWindow 264 @Environment(\.openWindow) private var openWindow
Tests/OrgCoreTests/KeymapTests.swift +10
@@ -87,6 +87,16 @@ struct KeymapTests {
87 #expect(keymap.keys(for: "org.todo.cycle").map(KeySequence.format) == "S-<right>") 87 #expect(keymap.keys(for: "org.todo.cycle").map(KeySequence.format) == "S-<right>")
88 } 88 }
89 89
90 @Test func keyLabelsForMenus() {
91 let emacs = KeymapPreset.emacs.keymap
92 #expect(emacs.keyLabels(for: "org.todo.cycle") == ["C-c C-t", "S-<right>"])
93 #expect(emacs.keyLabels(for: "org.schedule") == ["C-c C-s"])
94 let doom = KeymapPreset.doom.keymap
95 let labels = doom.keyLabels(for: "org.todo.cycle", modes: ["normal", "insert"])
96 #expect(labels == ["C-c C-t", "SPC m t"])
97 #expect(doom.keyLabels(for: "app.quit", modes: ["normal", "insert"]) == ["SPC q q"])
98 }
99
90 @Test func dispatchesPrefixes() { 100 @Test func dispatchesPrefixes() {
91 var dispatcher = KeyDispatcher(keymap: KeymapPreset.emacs.keymap) 101 var dispatcher = KeyDispatcher(keymap: KeymapPreset.emacs.keymap)
92 let c = KeyChord("c", .control), t = KeyChord("t", .control), z = KeyChord("z", .control) 102 let c = KeyChord("c", .control), t = KeyChord("t", .control), z = KeyChord("z", .control)