krz/orgstar

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

Commit 2c9d41fe76

2c9d41fe76b77b2a438183ed86c564ba5fe0c5c5

parent: 441daa361a

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 03:48 UTC

Share Vim registers, macros and uppercase marks across buffers

Named registers, the clipboard's linewise record and keyboard macros
are kept once for every editor. Uppercase marks are file marks: a jump
to one set in another buffer shows that buffer at the mark. Lowercase
marks and the jump list stay per buffer.

Layout: unified · split

Sources/OrgCore/Keymap/Vim.swift +3 −2
@@ -76,9 +76,10 @@ public struct Vim: Sendable {
7676 public internal(set) var mode: Mode = .normal
7777 /// Keys of the command being typed.
7878 public private(set) var pending: [KeyChord] = []
79 var registers: [Character: (text: String, linewise: Bool)] = [:]
79 /// Named registers. Evil's are global: an editor with several engines shares them.
80 public var registers: [Character: (text: String, linewise: Bool)] = [:]
8081 /// What the clipboard held when this engine last wrote it, and whether that was whole lines.
81 var clipboardRecord: (text: String, linewise: Bool)?
82 public var clipboardRecord: (text: String, linewise: Bool)?
8283 var anchor = 0
8384 var goalColumn: Int?
8485 var lastFind: (kind: Character, char: String)?
Sources/OrgEditorAppKit/Modal.swift +39 −6
@@ -3,6 +3,11 @@ import AppKit
33import OrgCore
44import OrgDocument
55
6/// An editor a file mark is in, gone when its buffer closes.
7struct WeakEditor {
8 weak var editor: OrgEditor?
9}
10
611/// Modal editing: keys go to the keymap for the current evil state first (leader keys,
712/// evil-org's org keys), then to `Vim`.
813extension OrgEditor {
@@ -67,10 +72,16 @@ extension OrgEditor {
6772 let wasInsert = vim.mode == .insert
6873 let visual = vim.mode == .visual || vim.mode == .visualLine
6974 let caret = visual ? (visualCaret ?? textView.selectedRange().location) : textView.selectedRange().location
75 if jumpToFileMark(key) { continue }
7076 let clipboard = NSPasteboard.general.string(forType: .string)
7177 var marks = vimMarks
78 for (name, mark) in Self.fileMarks where mark.owner.editor === self { marks[name] = mark.offset }
7279 if jumpIndex > 0, jumpIndex <= jumpList.count { marks["'"] = jumpList[jumpIndex - 1] }
80 vim.registers = Self.vimRegisters
81 vim.clipboardRecord = Self.vimClipboardRecord
7382 let actions = vim.feed(key, Vim.Context(text: document.text, caret: caret, clipboard: clipboard, halfPage: halfPage, marks: marks, visible: visibleText))
83 Self.vimRegisters = vim.registers
84 Self.vimClipboardRecord = vim.clipboardRecord
7485 apply(actions)
7586 if !wasInsert, vim.mode == .insert {
7687 insertSnapshot = (document.text, textView.selectedRange().location)
@@ -83,13 +94,35 @@ extension OrgEditor {
8394 return true
8495 }
8596
97 /// `'A` or `` `A `` for a file mark in another editor: that editor's buffer shows at the
98 /// mark. True when the key went there.
99 private func jumpToFileMark(_ key: KeyChord) -> Bool {
100 guard vim.mode == .normal, vim.pending.count == 1, let quote = vim.pending.first, quote == KeyChord("'") || quote == KeyChord("`"),
101 key.modifiers.isEmpty, key.key.count == 1, let name = key.key.first, name.isUppercase,
102 let mark = Self.fileMarks[name], let owner = mark.owner.editor, owner !== self else { return false }
103 vim.reset()
104 let ns = owner.document.text as NSString
105 var target = min(mark.offset, ns.length)
106 if quote == KeyChord("'") {
107 // The line's first non-blank.
108 target = ns.lineRange(for: NSRange(location: target, length: 0)).location
109 while target < ns.length, ns.character(at: target) == 32 || ns.character(at: target) == 9 { target += 1 }
110 }
111 onJumpToEditor?(owner, target)
112 return true
113 }
114
86115 /// Moves marks and jump positions through an edit.
87116 func mapVimPositions(through edit: TextEdit) {
88 guard !vimMarks.isEmpty || !jumpList.isEmpty else { return }
117 let owned = Self.fileMarks.filter { $0.value.owner.editor === self }
118 guard !vimMarks.isEmpty || !jumpList.isEmpty || !owned.isEmpty else { return }
89119 let names = Array(vimMarks.keys)
90 let mapped = ViewState(selection: names.map { vimMarks[$0]!..<vimMarks[$0]! } + jumpList.map { $0..<$0 }).mapped(through: [edit]).selection
120 let fileNames = Array(owned.keys)
121 let positions = names.map { vimMarks[$0]! } + fileNames.map { owned[$0]!.offset } + jumpList
122 let mapped = ViewState(selection: positions.map { $0..<$0 }).mapped(through: [edit]).selection
91123 for (i, name) in names.enumerated() { vimMarks[name] = mapped[i].lowerBound }
92 jumpList = mapped.dropFirst(names.count).map { $0.lowerBound }
124 for (i, name) in fileNames.enumerated() { Self.fileMarks[name]?.offset = mapped[names.count + i].lowerBound }
125 jumpList = mapped.dropFirst(names.count + fileNames.count).map { $0.lowerBound }
93126 }
94127
95128 func insertTyped(_ key: KeyChord) {
@@ -175,7 +208,7 @@ extension OrgEditor {
175208 case .message(let text):
176209 onMessage?(text)
177210 case .setMark(let name, let offset):
178 vimMarks[name] = offset
211 if name.isUppercase { Self.fileMarks[name] = (WeakEditor(editor: self), offset) } else { vimMarks[name] = offset }
179212 case .jumped(let from):
180213 // A new jump drops the ones C-o went back past.
181214 jumpList = Array(jumpList.prefix(jumpIndex))
@@ -202,12 +235,12 @@ extension OrgEditor {
202235 onMessage?("Defining keyboard macro…")
203236 } else if let register = recordingRegister {
204237 // Without the `q` that ended it.
205 macros[register] = Array(recordedKeys.dropLast())
238 Self.macros[register] = Array(recordedKeys.dropLast())
206239 recordingRegister = nil
207240 onMessage?("Keyboard macro defined")
208241 }
209242 case .playMacro(let name, let count):
210 guard let keys = macros[name], !keys.isEmpty else {
243 guard let keys = Self.macros[name], !keys.isEmpty else {
211244 onMessage?("No macro in register \(name)")
212245 continue
213246 }
Sources/OrgEditorAppKit/OrgEditor.swift +10 −2
@@ -365,15 +365,23 @@ public final class OrgEditor: NSObject {
365365 var insertSnapshot: (text: String, caret: Int)?
366366 /// The evil cursor in visual states, where the selection's start isn't it.
367367 var visualCaret: Int?
368 /// Vim marks, mapped through edits.
368 /// Vim's lowercase marks, mapped through edits.
369369 var vimMarks: [Character: Int] = [:]
370 /// Uppercase marks, with the editor each is in: global, as evil's file marks are.
371 static var fileMarks: [Character: (owner: WeakEditor, offset: Int)] = [:]
372 /// Registers and keyboard macros, shared by every editor as evil's are global.
373 static var vimRegisters: [Character: (text: String, linewise: Bool)] = [:]
374 static var vimClipboardRecord: (text: String, linewise: Bool)?
375 static var macros: [Character: [KeyChord]] = [:]
376 /// Shows `editor`'s buffer with the caret at the offset, for a jump to a file mark set
377 /// there.
378 public var onJumpToEditor: ((OrgEditor, Int) -> Void)?
370379 /// Jump positions, oldest first, mapped through edits; `jumpIndex` is where C-o and C-i are.
371380 var jumpList: [Int] = []
372381 var jumpIndex = 0
373382 /// The register keys go into while `q` records, and its keys so far.
374383 var recordingRegister: Character?
375384 var recordedKeys: [KeyChord] = []
376 var macros: [Character: [KeyChord]] = [:]
377385 var replayingMacro = 0
378386 /// A region is active: movement extends the selection, as after C-SPC in Emacs.
379387 private(set) var markActive = false
Sources/Orgstar/EditorView.swift +7 −2
@@ -51,7 +51,7 @@ struct EditorView: NSViewRepresentable {
5151 coordinator.path = session.path
5252 container.subviews.forEach { $0.removeFromSuperview() }
5353 coordinator.editor = nil
54 if let path = session.path, let shown = coordinator.editors[path] ?? makeEditor(container) {
54 if let path = session.path, let shown = coordinator.editors[path] ?? makeEditor(container, coordinator) {
5555 coordinator.editors[path] = shown
5656 shown.scroll.frame = container.bounds
5757 container.addSubview(shown.scroll)
@@ -80,7 +80,7 @@ struct EditorView: NSViewRepresentable {
8080 }
8181
8282 /// An editor for the current buffer, which becomes the buffer's live state.
83 private func makeEditor(_ container: NSView) -> (editor: OrgEditor, scroll: NSScrollView)? {
83 private func makeEditor(_ container: NSView, _ coordinator: Coordinator) -> (editor: OrgEditor, scroll: NSScrollView)? {
8484 guard let document = session.document else { return nil }
8585 let mode: OrgEditor.Mode = session.isOrg ? .org : .plain(language: session.path.flatMap(FileTypes.language))
8686 let editor = OrgEditor(document: document, frame: container.bounds, editable: document.isEditable, highlighter: Self.highlighter, mode: mode, options: options)
@@ -105,6 +105,11 @@ struct EditorView: NSViewRepresentable {
105105 }
106106 session?.open(target)
107107 }
108 editor.onJumpToEditor = { [weak session, weak coordinator] other, offset in
109 guard let session, let path = coordinator?.editors.first(where: { $0.value.editor === other })?.key else { return }
110 session.open(path)
111 session.reveal(offset)
112 }
108113 editor.onExternal = onExternal
109114 editor.knownTags = knownTags
110115 editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) }
Tests/OrgEditorAppKitTests/ModalTests.swift +35
@@ -72,6 +72,41 @@ struct ModalTests {
7272 #expect(h.editor.vim.mode == .normal)
7373 }
7474
75 @Test func registersAndMacrosAreShared() {
76 let a = doom("alpha beta\n")
77 let b = doom("one\ntwo\n")
78 a.caret(at: 0)
79 type(a, "\" r y w")
80 b.caret(at: 0)
81 type(b, "\" r P")
82 #expect(b.editor.document.text == "alpha one\ntwo\n")
83 type(a, "q m A ! ESC q")
84 type(b, "j @ m")
85 #expect(b.editor.document.text == "alpha one\ntwo!\n")
86 }
87
88 @Test func uppercaseMarksAreGlobal() {
89 let a = doom("first\n second\n")
90 let b = doom("other\ntext\n")
91 var jumps: [(OrgEditor, Int)] = []
92 b.editor.onJumpToEditor = { jumps.append(($0, $1)) }
93 a.caret(at: 10)
94 type(a, "m Q m q")
95 b.caret(at: 0)
96 type(b, "' Q")
97 #expect(jumps.count == 1 && jumps.first?.0 === a.editor && jumps.first?.1 == 8)
98 type(b, "` Q")
99 #expect(jumps.last?.1 == 10)
100 // Lowercase marks stay in their buffer.
101 type(b, "j ' q")
102 #expect(b.textView.selectedRange().location == 6)
103 // In its own buffer the mark is an ordinary jump, and follows edits.
104 type(a, "g g O")
105 a.textView.insertText("new", replacementRange: a.textView.selectedRange())
106 type(a, "ESC ` Q")
107 #expect(a.textView.selectedRange().location == 14)
108 }
109
75110 @Test func zzSavesAndClosesAsWq() {
76111 let h = doom("text\n")
77112 var app: [String] = []