Keys in the Org menu and palette !38

merged merged by cmc on 2026-10-05 18:19 UTC · krz/orgstar:menu-keys into main

5 files changed, +63 −8

Layout: unified · split

Sources/OrgCore/Keymap/Keymap.swift +19
@@ -95,6 +95,25 @@ public struct Keymap: Sendable, Equatable {
9595 return seen.map { (key: $0.key, command: $0.value) }.sorted { $0.key.description < $1.key.description }
9696 }
9797
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
98117 /// The first live binding of `command`, for showing its keys.
99118 public func keys(for command: String, mode: String? = nil) -> [KeyChord]? {
100119 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 {
124124 .onChange(of: options, initial: true) { _, options in session.options = options }
125125 .onChange(of: preset, initial: true) { loadKeymap() }
126126 .sheet(isPresented: $commands.showingPalette) {
127 PaletteView(keymap: keymap) { id in
127 PaletteView(commands: commands) { id in
128128 commands.showingPalette = false
129129 // After the sheet closes, so app commands can present their own.
130130 DispatchQueue.main.async { perform(id) }
@@ -325,6 +325,8 @@ struct ContentView: View {
325325 private func loadKeymap() {
326326 let loaded = KeymapLoader.load(preset)
327327 keymap = loaded.keymap
328 commands.keymap = loaded.keymap
329 commands.modal = preset.isModal
328330 if let first = loaded.problems.first {
329331 let more = loaded.problems.count > 1 ? " (and \(loaded.problems.count - 1) more)" : ""
330332 commands.show("keymap.toml: \(first)\(more)")
Sources/Orgstar/Keys.swift +3 −3
@@ -132,7 +132,7 @@ extension EchoArea {
132132
133133/// M-x and ⇧⌘P: every command by title, with its keys.
134134struct PaletteView: View {
135 let keymap: Keymap
135 let commands: AppCommands
136136 let run: (String) -> Void
137137 @State private var query = ""
138138 @Environment(\.dismiss) private var dismiss
@@ -161,8 +161,8 @@ struct PaletteView: View {
161161 HStack {
162162 Text(command.title)
163163 Spacer()
164 if let keys = keymap.keys(for: command.id) {
165 Text(KeySequence.format(keys)).font(.system(.caption, design: .monospaced)).foregroundStyle(.secondary)
164 if let keys = commands.keyLabel(for: command.id) {
165 Text(keys).font(.system(.caption, design: .monospaced)).foregroundStyle(.secondary)
166166 }
167167 }
168168 }
Sources/Orgstar/OrgstarApp.swift +28 −4
@@ -23,6 +23,16 @@ final class AppCommands {
2323 var pendingKeys: String?
2424 /// The editor's evil state in the Doom preset.
2525 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
2636 /// The editor's selection, for the modeline.
2737 var selection = NSRange(location: 0, length: 0)
2838 var keyHints: [KeyHint] = []
@@ -223,10 +233,7 @@ struct OrgstarApp: App {
223233 }
224234 .commands {
225235 CommandMenu("Org") {
226 ForEach(EditorCommands.all.filter { $0.id.hasPrefix("org.") }, id: \.id) { command in
227 Button(command.title) { commands.editor?.run(command.id) }
228 .disabled(commands.editor == nil)
229 }
236 OrgMenuItems(commands: commands)
230237 }
231238 }
232239 Settings {
@@ -235,6 +242,23 @@ struct OrgstarApp: App {
235242 }
236243}
237244
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
238262/// Window ▸ Agenda.
239263struct AgendaMenuItem: View {
240264 @Environment(\.openWindow) private var openWindow
Tests/OrgCoreTests/KeymapTests.swift +10
@@ -87,6 +87,16 @@ struct KeymapTests {
8787 #expect(keymap.keys(for: "org.todo.cycle").map(KeySequence.format) == "S-<right>")
8888 }
8989
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
90100 @Test func dispatchesPrefixes() {
91101 var dispatcher = KeyDispatcher(keymap: KeymapPreset.emacs.keymap)
92102 let c = KeyChord("c", .control), t = KeyChord("t", .control), z = KeyChord("z", .control)