krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

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.
98struct 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
94extension EchoArea { 127extension 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}