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. |
| 134 | struct PaletteView: View { |
134 | struct 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. |
| |
246 | struct 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. |
| 239 | struct AgendaMenuItem: View { |
263 | struct 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) |