Commit 9a511fe327
9a511fe327c1d8fb2a0d773d34e47e3a8ee09d7b
parent: 1ee4104d21
Verified · cmc
cmc <hello@cleberg.net> · 2026-10-05 16:23 UTC
Doom: insert-state TAB, state tag, SPC q q
In insert state TAB and S-TAB follow Doom's +org-indent-maybe-h:
demote/promote headings, indent/outdent item trees, move between
table fields; S-TAB elsewhere cycles global visibility. The echo
line shows the evil state. SPC q q quits.
Layout: unified · split
Sources/OrgCore/Keymap/Presets.swift
+49
| @@ -75,6 +75,10 @@ public enum KeymapPreset: String, Sendable, CaseIterable { |
| 75 | 75 | command = "app.quick-open" |
| 76 | 76 | mode = "normal" |
| 77 | 77 | [[bind]] |
| 78 | keys = "SPC q q" |
| 79 | command = "app.quit" |
| 80 | mode = "normal" |
| 81 | [[bind]] |
| 78 | 82 | keys = "SPC ." |
| 79 | 83 | command = "app.quick-open" |
| 80 | 84 | mode = "normal" |
| @@ -315,6 +319,51 @@ public enum KeymapPreset: String, Sendable, CaseIterable { |
| 315 | 319 | command = "org.table.next-row" |
| 316 | 320 | when = "table" |
| 317 | 321 | mode = "insert" |
| 322 | [[bind]] |
| 323 | keys = "TAB" |
| 324 | command = "org.cycle" |
| 325 | mode = "visual" |
| 326 | [[bind]] |
| 327 | keys = "S-TAB" |
| 328 | command = "org.cycle-global" |
| 329 | mode = "visual" |
| 330 | |
| 331 | # Insert state: org-cycle and org-shifttab with Doom's +org-indent-maybe-h, which |
| 332 | # demotes or promotes a heading and indents or outdents an item tree. |
| 333 | [[bind]] |
| 334 | keys = "S-TAB" |
| 335 | command = "org.cycle-global" |
| 336 | mode = "insert" |
| 337 | [[bind]] |
| 338 | keys = "TAB" |
| 339 | command = "org.table.next-field" |
| 340 | when = "table" |
| 341 | mode = "insert" |
| 342 | [[bind]] |
| 343 | keys = "S-TAB" |
| 344 | command = "org.table.previous-field" |
| 345 | when = "table" |
| 346 | mode = "insert" |
| 347 | [[bind]] |
| 348 | keys = "TAB" |
| 349 | command = "org.item.indent-tree" |
| 350 | when = "item" |
| 351 | mode = "insert" |
| 352 | [[bind]] |
| 353 | keys = "S-TAB" |
| 354 | command = "org.item.outdent-tree" |
| 355 | when = "item" |
| 356 | mode = "insert" |
| 357 | [[bind]] |
| 358 | keys = "TAB" |
| 359 | command = "org.heading.demote" |
| 360 | when = "heading" |
| 361 | mode = "insert" |
| 362 | [[bind]] |
| 363 | keys = "S-TAB" |
| 364 | command = "org.heading.promote" |
| 365 | when = "heading" |
| 366 | mode = "insert" |
| 318 | 367 | |
| 319 | 368 | """ |
| 320 | 369 | |
Sources/OrgEditorAppKit/EditorCommands.swift
+1
| @@ -36,6 +36,7 @@ public enum EditorCommands { |
| 36 | 36 | EditorCommand(id: "org.cycle-global", title: "Cycle Global Visibility", action: .view), |
| 37 | 37 | |
| 38 | 38 | EditorCommand(id: "app.save", title: "Save", action: .app), |
| 39 | EditorCommand(id: "app.quit", title: "Quit Orgstar", action: .app), |
| 39 | 40 | EditorCommand(id: "app.quick-open", title: "Quick Open…", action: .app), |
| 40 | 41 | EditorCommand(id: "app.palette", title: "Command Palette…", action: .app), |
| 41 | 42 | EditorCommand(id: "app.search", title: "Search Notes", action: .app), |
Sources/OrgEditorAppKit/Modal.swift
+5
| @@ -224,6 +224,11 @@ extension OrgEditor { |
| 224 | 224 | let block = modal && vim.mode != .insert |
| 225 | 225 | (textView as? OrgTextView)?.blockCursor = block |
| 226 | 226 | dispatcher.mode = modal ? vim.mode.keymapMode : nil |
| 227 | let state = modal ? vim.mode.rawValue : nil |
| 228 | if state != reportedMode { |
| 229 | reportedMode = state |
| 230 | onModeChange?(state) |
| 231 | } |
| 227 | 232 | } |
| 228 | 233 | |
| 229 | 234 | /// A mouse click leaves visual state, as in evil. |
Sources/OrgEditorAppKit/OrgEditor.swift
+3
| @@ -182,6 +182,9 @@ public final class OrgEditor: NSObject { |
| 182 | 182 | /// Called with messages commands report, such as why one couldn't run here. |
| 183 | 183 | public var onMessage: ((String) -> Void)? |
| 184 | 184 | /// Called with the pending prefix and what can follow it, and with nil when it ends. |
| 185 | /// The evil state when editing modally (`normal`, `insert`, `visual`, `visual-line`), nil otherwise. |
| 186 | public var onModeChange: ((String?) -> Void)? |
| 187 | var reportedMode: String? |
| 185 | 188 | public var onKeysPending: ((_ keys: String?, _ next: [(key: String, title: String)]) -> Void)? |
| 186 | 189 | /// Called with `app.*` commands, which the app carries out. |
| 187 | 190 | public var onAppCommand: ((String) -> Void)? |
Sources/Orgstar/ContentView.swift
+1
| @@ -320,6 +320,7 @@ struct ContentView: View { |
| 320 | 320 | switch id { |
| 321 | 321 | case "app.save": |
| 322 | 322 | if !session.save(), session.conflict != nil { commands.showingConflict = true } |
| 323 | case "app.quit": NSApp.terminate(nil) |
| 323 | 324 | case "app.quick-open": commands.showingQuickOpen = true |
| 324 | 325 | case "app.palette": commands.showingPalette = true |
| 325 | 326 | case "app.search": commands.focusSearch = true |
Sources/Orgstar/EditorView.swift
+1
| @@ -46,6 +46,7 @@ struct EditorView: NSViewRepresentable { |
| 46 | 46 | commands?.pendingKeys = keys |
| 47 | 47 | commands?.keyHints = next.map { KeyHint(key: $0.key, title: $0.title) } |
| 48 | 48 | } |
| 49 | editor.onModeChange = { [weak commands] in commands?.editorState = $0 } |
| 49 | 50 | editor.onAppCommand = onAppCommand |
| 50 | 51 | editor.onExternal = onExternal |
| 51 | 52 | editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) } |
Sources/Orgstar/Keys.swift
+40 −7
| @@ -71,14 +71,17 @@ struct EchoArea: View { |
| 71 | 71 | answerFocused = true |
| 72 | 72 | } |
| 73 | 73 | .id(request.id) |
| 74 | | } else if let text = commands.pendingKeys.map({ $0 + "-" }) ?? commands.message { |
| 74 | } else if commands.editorState != nil || commands.pendingKeys != nil || commands.message != nil { |
| 75 | 75 | Divider() |
| 76 | | Text(text) |
| 77 | | .font(.system(.callout, design: .monospaced)) |
| 78 | | .lineLimit(1) |
| 79 | | .padding(.horizontal, 10) |
| 80 | | .padding(.vertical, 4) |
| 81 | | .frame(maxWidth: .infinity, alignment: .leading) |
| 76 | HStack(spacing: 8) { |
| 77 | if let state = commands.editorState { StateTag(state: state) } |
| 78 | Text(commands.pendingKeys.map { $0 + "-" } ?? commands.message ?? "") |
| 79 | .font(.system(.callout, design: .monospaced)) |
| 80 | .lineLimit(1) |
| 81 | } |
| 82 | .padding(.horizontal, 10) |
| 83 | .padding(.vertical, 4) |
| 84 | .frame(maxWidth: .infinity, alignment: .leading) |
| 82 | 85 | } |
| 83 | 86 | } |
| 84 | 87 | .background(.bar) |
| @@ -91,6 +94,36 @@ struct EchoArea: View { |
| 91 | 94 | } |
| 92 | 95 | } |
| 93 | 96 | |
| 97 | /// The evil state, as Doom's modeline shows it. |
| 98 | struct StateTag: View { |
| 99 | let state: String |
| 100 | |
| 101 | var body: some View { |
| 102 | Text(label) |
| 103 | .font(.system(.caption, design: .monospaced).bold()) |
| 104 | .foregroundStyle(.white) |
| 105 | .padding(.horizontal, 6) |
| 106 | .padding(.vertical, 1) |
| 107 | .background(color, in: RoundedRectangle(cornerRadius: 4)) |
| 108 | .accessibilityLabel("\(label.capitalized) state") |
| 109 | } |
| 110 | |
| 111 | private var label: String { |
| 112 | switch state { |
| 113 | case "visual-line": "V-LINE" |
| 114 | default: state.uppercased() |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | private var color: Color { |
| 119 | switch state { |
| 120 | case "insert": .green |
| 121 | case "visual", "visual-line": .orange |
| 122 | default: .blue |
| 123 | } |
| 124 | } |
| 125 | } |
| 126 | |
| 94 | 127 | extension EchoArea { |
| 95 | 128 | /// Choices matching what's typed, best first. |
| 96 | 129 | func filteredChoices(_ choices: [String]) -> [String] { |
Sources/Orgstar/OrgstarApp.swift
+2
| @@ -21,6 +21,8 @@ final class AppCommands { |
| 21 | 21 | var editor: OrgEditor? |
| 22 | 22 | /// The pending key prefix, and what can follow it. |
| 23 | 23 | var pendingKeys: String? |
| 24 | /// The editor's evil state in the Doom preset. |
| 25 | var editorState: String? |
| 24 | 26 | var keyHints: [KeyHint] = [] |
| 25 | 27 | /// A question a command asked, answered in the echo area. |
| 26 | 28 | private(set) var prompt: PromptRequest? |
Tests/OrgEditorAppKitTests/ModalTests.swift
+47
| @@ -76,4 +76,51 @@ struct ModalTests { |
| 76 | 76 | #expect(h.editor.document.text == " two three\n") |
| 77 | 77 | #expect(h.textView.selectedRange().length == 0) |
| 78 | 78 | } |
| 79 | |
| 80 | func shiftTab(_ h: Harness) { |
| 81 | KeyTests().send(h, characters: "\u{19}", ignoring: "\u{19}", code: 48, flags: .shift) |
| 82 | } |
| 83 | |
| 84 | @Test func insertStateTabFollowsDoom() { |
| 85 | let h = doom("* Top\n** Sub\n- one\n- item\n - child\nbody\n") |
| 86 | h.caret(at: 8) |
| 87 | type(h, "i") |
| 88 | #expect(h.editor.vim.mode == .insert) |
| 89 | shiftTab(h) |
| 90 | #expect(h.editor.document.text.hasPrefix("* Top\n* Sub\n")) |
| 91 | type(h, "TAB") |
| 92 | #expect(h.editor.document.text.hasPrefix("* Top\n** Sub\n")) |
| 93 | let item = (h.editor.document.text as NSString).range(of: "- item").location |
| 94 | h.caret(at: item + 3) |
| 95 | type(h, "TAB") |
| 96 | #expect(h.editor.document.text.contains("- one\n - item\n - child\n")) |
| 97 | #expect(h.editor.vim.mode == .insert) |
| 98 | } |
| 99 | |
| 100 | @Test func shiftTabCyclesInNormalState() { |
| 101 | let h = doom("* Top\nbody\n") |
| 102 | h.caret(at: 7) |
| 103 | let before = h.editor.document.text |
| 104 | shiftTab(h) |
| 105 | #expect(h.editor.document.text == before) |
| 106 | #expect(h.editor.vim.mode == .normal) |
| 107 | } |
| 108 | |
| 109 | @Test func reportsStateAndQuits() { |
| 110 | let h = doom("text\n") |
| 111 | var states: [String?] = [] |
| 112 | var app: [String] = [] |
| 113 | h.editor.onModeChange = { states.append($0) } |
| 114 | h.editor.onAppCommand = { app.append($0) } |
| 115 | h.caret(at: 0) |
| 116 | type(h, "i") |
| 117 | type(h, "ESC") |
| 118 | type(h, "V") |
| 119 | type(h, "ESC") |
| 120 | type(h, "SPC q q") |
| 121 | #expect(states == ["insert", "normal", "visual-line", "normal"]) |
| 122 | #expect(app == ["app.quit"]) |
| 123 | h.editor.modal = false |
| 124 | #expect(states.last == .some(nil)) |
| 125 | } |
| 79 | 126 | } |