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 | command = "app.quick-open" |
75 | command = "app.quick-open" |
| 76 | mode = "normal" |
76 | mode = "normal" |
| 77 | [[bind]] |
77 | [[bind]] |
| |
78 | keys = "SPC q q" |
| |
79 | command = "app.quit" |
| |
80 | mode = "normal" |
| |
81 | [[bind]] |
| 78 | keys = "SPC ." |
82 | keys = "SPC ." |
| 79 | command = "app.quick-open" |
83 | command = "app.quick-open" |
| 80 | mode = "normal" |
84 | mode = "normal" |
| @@ -315,6 +319,51 @@ public enum KeymapPreset: String, Sendable, CaseIterable { |
| 315 | command = "org.table.next-row" |
319 | command = "org.table.next-row" |
| 316 | when = "table" |
320 | when = "table" |
| 317 | mode = "insert" |
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 | EditorCommand(id: "org.cycle-global", title: "Cycle Global Visibility", action: .view), |
36 | EditorCommand(id: "org.cycle-global", title: "Cycle Global Visibility", action: .view), |
| 37 | |
37 | |
| 38 | EditorCommand(id: "app.save", title: "Save", action: .app), |
38 | EditorCommand(id: "app.save", title: "Save", action: .app), |
| |
39 | EditorCommand(id: "app.quit", title: "Quit Orgstar", action: .app), |
| 39 | EditorCommand(id: "app.quick-open", title: "Quick Open…", action: .app), |
40 | EditorCommand(id: "app.quick-open", title: "Quick Open…", action: .app), |
| 40 | EditorCommand(id: "app.palette", title: "Command Palette…", action: .app), |
41 | EditorCommand(id: "app.palette", title: "Command Palette…", action: .app), |
| 41 | EditorCommand(id: "app.search", title: "Search Notes", action: .app), |
42 | EditorCommand(id: "app.search", title: "Search Notes", action: .app), |
Sources/OrgEditorAppKit/Modal.swift
+5
| @@ -224,6 +224,11 @@ extension OrgEditor { |
| 224 | let block = modal && vim.mode != .insert |
224 | let block = modal && vim.mode != .insert |
| 225 | (textView as? OrgTextView)?.blockCursor = block |
225 | (textView as? OrgTextView)?.blockCursor = block |
| 226 | dispatcher.mode = modal ? vim.mode.keymapMode : nil |
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 | /// A mouse click leaves visual state, as in evil. |
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 | /// Called with messages commands report, such as why one couldn't run here. |
182 | /// Called with messages commands report, such as why one couldn't run here. |
| 183 | public var onMessage: ((String) -> Void)? |
183 | public var onMessage: ((String) -> Void)? |
| 184 | /// Called with the pending prefix and what can follow it, and with nil when it ends. |
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 | public var onKeysPending: ((_ keys: String?, _ next: [(key: String, title: String)]) -> Void)? |
188 | public var onKeysPending: ((_ keys: String?, _ next: [(key: String, title: String)]) -> Void)? |
| 186 | /// Called with `app.*` commands, which the app carries out. |
189 | /// Called with `app.*` commands, which the app carries out. |
| 187 | public var onAppCommand: ((String) -> Void)? |
190 | public var onAppCommand: ((String) -> Void)? |
Sources/Orgstar/ContentView.swift
+1
| @@ -320,6 +320,7 @@ struct ContentView: View { |
| 320 | switch id { |
320 | switch id { |
| 321 | case "app.save": |
321 | case "app.save": |
| 322 | if !session.save(), session.conflict != nil { commands.showingConflict = true } |
322 | if !session.save(), session.conflict != nil { commands.showingConflict = true } |
| |
323 | case "app.quit": NSApp.terminate(nil) |
| 323 | case "app.quick-open": commands.showingQuickOpen = true |
324 | case "app.quick-open": commands.showingQuickOpen = true |
| 324 | case "app.palette": commands.showingPalette = true |
325 | case "app.palette": commands.showingPalette = true |
| 325 | case "app.search": commands.focusSearch = true |
326 | case "app.search": commands.focusSearch = true |
Sources/Orgstar/EditorView.swift
+1
| @@ -46,6 +46,7 @@ struct EditorView: NSViewRepresentable { |
| 46 | commands?.pendingKeys = keys |
46 | commands?.pendingKeys = keys |
| 47 | commands?.keyHints = next.map { KeyHint(key: $0.key, title: $0.title) } |
47 | commands?.keyHints = next.map { KeyHint(key: $0.key, title: $0.title) } |
| 48 | } |
48 | } |
| |
49 | editor.onModeChange = { [weak commands] in commands?.editorState = $0 } |
| 49 | editor.onAppCommand = onAppCommand |
50 | editor.onAppCommand = onAppCommand |
| 50 | editor.onExternal = onExternal |
51 | editor.onExternal = onExternal |
| 51 | editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) } |
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 | answerFocused = true |
71 | answerFocused = true |
| 72 | } |
72 | } |
| 73 | .id(request.id) |
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 | Divider() |
75 | Divider() |
| 76 | Text(text) |
76 | HStack(spacing: 8) { |
| 77 | .font(.system(.callout, design: .monospaced)) |
77 | if let state = commands.editorState { StateTag(state: state) } |
| 78 | .lineLimit(1) |
78 | Text(commands.pendingKeys.map { $0 + "-" } ?? commands.message ?? "") |
| 79 | .padding(.horizontal, 10) |
79 | .font(.system(.callout, design: .monospaced)) |
| 80 | .padding(.vertical, 4) |
80 | .lineLimit(1) |
| 81 | .frame(maxWidth: .infinity, alignment: .leading) |
81 | } |
| |
82 | .padding(.horizontal, 10) |
| |
83 | .padding(.vertical, 4) |
| |
84 | .frame(maxWidth: .infinity, alignment: .leading) |
| 82 | } |
85 | } |
| 83 | } |
86 | } |
| 84 | .background(.bar) |
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 | extension EchoArea { |
127 | extension EchoArea { |
| 95 | /// Choices matching what's typed, best first. |
128 | /// Choices matching what's typed, best first. |
| 96 | func filteredChoices(_ choices: [String]) -> [String] { |
129 | func filteredChoices(_ choices: [String]) -> [String] { |
Sources/Orgstar/OrgstarApp.swift
+2
| @@ -21,6 +21,8 @@ final class AppCommands { |
| 21 | var editor: OrgEditor? |
21 | var editor: OrgEditor? |
| 22 | /// The pending key prefix, and what can follow it. |
22 | /// The pending key prefix, and what can follow it. |
| 23 | var pendingKeys: String? |
23 | var pendingKeys: String? |
| |
24 | /// The editor's evil state in the Doom preset. |
| |
25 | var editorState: String? |
| 24 | var keyHints: [KeyHint] = [] |
26 | var keyHints: [KeyHint] = [] |
| 25 | /// A question a command asked, answered in the echo area. |
27 | /// A question a command asked, answered in the echo area. |
| 26 | private(set) var prompt: PromptRequest? |
28 | private(set) var prompt: PromptRequest? |
Tests/OrgEditorAppKitTests/ModalTests.swift
+47
| @@ -76,4 +76,51 @@ struct ModalTests { |
| 76 | #expect(h.editor.document.text == " two three\n") |
76 | #expect(h.editor.document.text == " two three\n") |
| 77 | #expect(h.textView.selectedRange().length == 0) |
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 | } |