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 {
76 public internal(set) var mode: Mode = .normal 76 public internal(set) var mode: Mode = .normal
77 /// Keys of the command being typed. 77 /// Keys of the command being typed.
78 public private(set) var pending: [KeyChord] = [] 78 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)] = [:]
80 /// What the clipboard held when this engine last wrote it, and whether that was whole lines. 81 /// 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)?
82 var anchor = 0 83 var anchor = 0
83 var goalColumn: Int? 84 var goalColumn: Int?
84 var lastFind: (kind: Character, char: String)? 85 var lastFind: (kind: Character, char: String)?
Sources/OrgEditorAppKit/Modal.swift +39 −6
@@ -3,6 +3,11 @@ import AppKit
3import OrgCore 3import OrgCore
4import OrgDocument 4import OrgDocument
5 5
6/// An editor a file mark is in, gone when its buffer closes.
7struct WeakEditor {
8 weak var editor: OrgEditor?
9}
10
6/// Modal editing: keys go to the keymap for the current evil state first (leader keys, 11/// Modal editing: keys go to the keymap for the current evil state first (leader keys,
7/// evil-org's org keys), then to `Vim`. 12/// evil-org's org keys), then to `Vim`.
8extension OrgEditor { 13extension OrgEditor {
@@ -67,10 +72,16 @@ extension OrgEditor {
67 let wasInsert = vim.mode == .insert 72 let wasInsert = vim.mode == .insert
68 let visual = vim.mode == .visual || vim.mode == .visualLine 73 let visual = vim.mode == .visual || vim.mode == .visualLine
69 let caret = visual ? (visualCaret ?? textView.selectedRange().location) : textView.selectedRange().location 74 let caret = visual ? (visualCaret ?? textView.selectedRange().location) : textView.selectedRange().location
75 if jumpToFileMark(key) { continue }
70 let clipboard = NSPasteboard.general.string(forType: .string) 76 let clipboard = NSPasteboard.general.string(forType: .string)
71 var marks = vimMarks 77 var marks = vimMarks
78 for (name, mark) in Self.fileMarks where mark.owner.editor === self { marks[name] = mark.offset }
72 if jumpIndex > 0, jumpIndex <= jumpList.count { marks["'"] = jumpList[jumpIndex - 1] } 79 if jumpIndex > 0, jumpIndex <= jumpList.count { marks["'"] = jumpList[jumpIndex - 1] }
80 vim.registers = Self.vimRegisters
81 vim.clipboardRecord = Self.vimClipboardRecord
73 let actions = vim.feed(key, Vim.Context(text: document.text, caret: caret, clipboard: clipboard, halfPage: halfPage, marks: marks, visible: visibleText)) 82 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
74 apply(actions) 85 apply(actions)
75 if !wasInsert, vim.mode == .insert { 86 if !wasInsert, vim.mode == .insert {
76 insertSnapshot = (document.text, textView.selectedRange().location) 87 insertSnapshot = (document.text, textView.selectedRange().location)
@@ -83,13 +94,35 @@ extension OrgEditor {
83 return true 94 return true
84 } 95 }
85 96
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
86 /// Moves marks and jump positions through an edit. 115 /// Moves marks and jump positions through an edit.
87 func mapVimPositions(through edit: TextEdit) { 116 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 }
89 let names = Array(vimMarks.keys) 119 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
91 for (i, name) in names.enumerated() { vimMarks[name] = mapped[i].lowerBound } 123 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 }
93 } 126 }
94 127
95 func insertTyped(_ key: KeyChord) { 128 func insertTyped(_ key: KeyChord) {
@@ -175,7 +208,7 @@ extension OrgEditor {
175 case .message(let text): 208 case .message(let text):
176 onMessage?(text) 209 onMessage?(text)
177 case .setMark(let name, let offset): 210 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 }
179 case .jumped(let from): 212 case .jumped(let from):
180 // A new jump drops the ones C-o went back past. 213 // A new jump drops the ones C-o went back past.
181 jumpList = Array(jumpList.prefix(jumpIndex)) 214 jumpList = Array(jumpList.prefix(jumpIndex))
@@ -202,12 +235,12 @@ extension OrgEditor {
202 onMessage?("Defining keyboard macro…") 235 onMessage?("Defining keyboard macro…")
203 } else if let register = recordingRegister { 236 } else if let register = recordingRegister {
204 // Without the `q` that ended it. 237 // Without the `q` that ended it.
205 macros[register] = Array(recordedKeys.dropLast()) 238 Self.macros[register] = Array(recordedKeys.dropLast())
206 recordingRegister = nil 239 recordingRegister = nil
207 onMessage?("Keyboard macro defined") 240 onMessage?("Keyboard macro defined")
208 } 241 }
209 case .playMacro(let name, let count): 242 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 {
211 onMessage?("No macro in register \(name)") 244 onMessage?("No macro in register \(name)")
212 continue 245 continue
213 } 246 }
Sources/OrgEditorAppKit/OrgEditor.swift +10 −2
@@ -365,15 +365,23 @@ public final class OrgEditor: NSObject {
365 var insertSnapshot: (text: String, caret: Int)? 365 var insertSnapshot: (text: String, caret: Int)?
366 /// The evil cursor in visual states, where the selection's start isn't it. 366 /// The evil cursor in visual states, where the selection's start isn't it.
367 var visualCaret: Int? 367 var visualCaret: Int?
368 /// Vim marks, mapped through edits. 368 /// Vim's lowercase marks, mapped through edits.
369 var vimMarks: [Character: Int] = [:] 369 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)?
370 /// Jump positions, oldest first, mapped through edits; `jumpIndex` is where C-o and C-i are. 379 /// Jump positions, oldest first, mapped through edits; `jumpIndex` is where C-o and C-i are.
371 var jumpList: [Int] = [] 380 var jumpList: [Int] = []
372 var jumpIndex = 0 381 var jumpIndex = 0
373 /// The register keys go into while `q` records, and its keys so far. 382 /// The register keys go into while `q` records, and its keys so far.
374 var recordingRegister: Character? 383 var recordingRegister: Character?
375 var recordedKeys: [KeyChord] = [] 384 var recordedKeys: [KeyChord] = []
376 var macros: [Character: [KeyChord]] = [:]
377 var replayingMacro = 0 385 var replayingMacro = 0
378 /// A region is active: movement extends the selection, as after C-SPC in Emacs. 386 /// A region is active: movement extends the selection, as after C-SPC in Emacs.
379 private(set) var markActive = false 387 private(set) var markActive = false
Sources/Orgstar/EditorView.swift +7 −2
@@ -51,7 +51,7 @@ struct EditorView: NSViewRepresentable {
51 coordinator.path = session.path 51 coordinator.path = session.path
52 container.subviews.forEach { $0.removeFromSuperview() } 52 container.subviews.forEach { $0.removeFromSuperview() }
53 coordinator.editor = nil 53 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) {
55 coordinator.editors[path] = shown 55 coordinator.editors[path] = shown
56 shown.scroll.frame = container.bounds 56 shown.scroll.frame = container.bounds
57 container.addSubview(shown.scroll) 57 container.addSubview(shown.scroll)
@@ -80,7 +80,7 @@ struct EditorView: NSViewRepresentable {
80 } 80 }
81 81
82 /// An editor for the current buffer, which becomes the buffer's live state. 82 /// 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)? {
84 guard let document = session.document else { return nil } 84 guard let document = session.document else { return nil }
85 let mode: OrgEditor.Mode = session.isOrg ? .org : .plain(language: session.path.flatMap(FileTypes.language)) 85 let mode: OrgEditor.Mode = session.isOrg ? .org : .plain(language: session.path.flatMap(FileTypes.language))
86 let editor = OrgEditor(document: document, frame: container.bounds, editable: document.isEditable, highlighter: Self.highlighter, mode: mode, options: options) 86 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 {
105 } 105 }
106 session?.open(target) 106 session?.open(target)
107 } 107 }
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 }
108 editor.onExternal = onExternal 113 editor.onExternal = onExternal
109 editor.knownTags = knownTags 114 editor.knownTags = knownTags
110 editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) } 115 editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) }
Tests/OrgEditorAppKitTests/ModalTests.swift +35
@@ -72,6 +72,41 @@ struct ModalTests {
72 #expect(h.editor.vim.mode == .normal) 72 #expect(h.editor.vim.mode == .normal)
73 } 73 }
74 74
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
75 @Test func zzSavesAndClosesAsWq() { 110 @Test func zzSavesAndClosesAsWq() {
76 let h = doom("text\n") 111 let h = doom("text\n")
77 var app: [String] = [] 112 var app: [String] = []