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 { |
| 95 | 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 | 117 | /// The first live binding of `command`, for showing its keys. |
| 99 | 118 | public func keys(for command: String, mode: String? = nil) -> [KeyChord]? { |
| 100 | 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 | 124 | .onChange(of: options, initial: true) { _, options in session.options = options } |
| 125 | 125 | .onChange(of: preset, initial: true) { loadKeymap() } |
| 126 | 126 | .sheet(isPresented: $commands.showingPalette) { |
| 127 | | PaletteView(keymap: keymap) { id in |
| 127 | PaletteView(commands: commands) { id in |
| 128 | 128 | commands.showingPalette = false |
| 129 | 129 | // After the sheet closes, so app commands can present their own. |
| 130 | 130 | DispatchQueue.main.async { perform(id) } |
| @@ -325,6 +325,8 @@ struct ContentView: View { |
| 325 | 325 | private func loadKeymap() { |
| 326 | 326 | let loaded = KeymapLoader.load(preset) |
| 327 | 327 | keymap = loaded.keymap |
| 328 | commands.keymap = loaded.keymap |
| 329 | commands.modal = preset.isModal |
| 328 | 330 | if let first = loaded.problems.first { |
| 329 | 331 | let more = loaded.problems.count > 1 ? " (and \(loaded.problems.count - 1) more)" : "" |
| 330 | 332 | commands.show("keymap.toml: \(first)\(more)") |
Sources/Orgstar/Keys.swift
+3 −3
| @@ -132,7 +132,7 @@ extension EchoArea { |
| 132 | 132 | |
| 133 | 133 | /// M-x and ⇧⌘P: every command by title, with its keys. |
| 134 | 134 | struct PaletteView: View { |
| 135 | | let keymap: Keymap |
| 135 | let commands: AppCommands |
| 136 | 136 | let run: (String) -> Void |
| 137 | 137 | @State private var query = "" |
| 138 | 138 | @Environment(\.dismiss) private var dismiss |
| @@ -161,8 +161,8 @@ struct PaletteView: View { |
| 161 | 161 | HStack { |
| 162 | 162 | Text(command.title) |
| 163 | 163 | 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) |
| 166 | 166 | } |
| 167 | 167 | } |
| 168 | 168 | } |
Sources/Orgstar/OrgstarApp.swift
+28 −4
| @@ -23,6 +23,16 @@ final class AppCommands { |
| 23 | 23 | var pendingKeys: String? |
| 24 | 24 | /// The editor's evil state in the Doom preset. |
| 25 | 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 | 36 | /// The editor's selection, for the modeline. |
| 27 | 37 | var selection = NSRange(location: 0, length: 0) |
| 28 | 38 | var keyHints: [KeyHint] = [] |
| @@ -223,10 +233,7 @@ struct OrgstarApp: App { |
| 223 | 233 | } |
| 224 | 234 | .commands { |
| 225 | 235 | 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) |
| 230 | 237 | } |
| 231 | 238 | } |
| 232 | 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 | 262 | /// Window ▸ Agenda. |
| 239 | 263 | struct AgendaMenuItem: View { |
| 240 | 264 | @Environment(\.openWindow) private var openWindow |
Tests/OrgCoreTests/KeymapTests.swift
+10
| @@ -87,6 +87,16 @@ struct KeymapTests { |
| 87 | 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 | 100 | @Test func dispatchesPrefixes() { |
| 91 | 101 | var dispatcher = KeyDispatcher(keymap: KeymapPreset.emacs.keymap) |
| 92 | 102 | let c = KeyChord("c", .control), t = KeyChord("t", .control), z = KeyChord("z", .control) |