krz/orgstar

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

Commit 4c75f0eea5

4c75f0eea5342f7f5a56227654fb6f40d0ef56e6

parent: 1dda0291d2

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-09 01:27 UTC

Fill at the cursor with M-q in visual block state

Evil turns transient-mark-mode off in block state, so org-fill-paragraph sees no region and fills the paragraph at the cursor; with no change the block stays. Block state now reads its cursor from the last motion as the other visual states do, so motions past the second line extend the block.

Layout: unified · split

Sources/OrgEditorAppKit/Modal.swift +14 −8
@@ -34,7 +34,7 @@ extension OrgEditor {
34 if keys.count > 1 { onKeysPending?(nil, []) } 34 if keys.count > 1 { onKeysPending?(nil, []) }
35 let context = editContext() 35 let context = editContext()
36 if let binding = candidates.first(where: { modalRuns($0, in: context) }) { 36 if let binding = candidates.first(where: { modalRuns($0, in: context) }) {
37 if binding.command == "org.fill-paragraph", vim.mode == .visual || vim.mode == .visualLine { 37 if binding.command == "org.fill-paragraph", vim.mode == .visual || vim.mode == .visualLine || vim.mode == .visualBlock {
38 fillVisual() 38 fillVisual()
39 } else { 39 } else {
40 run(binding.command) 40 run(binding.command)
@@ -57,15 +57,21 @@ extension OrgEditor {
57 } 57 }
58 } 58 }
59 59
60 /// M-q in visual states: `org-fill-paragraph` on the region, the cursor kept. A change 60 /// M-q in visual states: `org-fill-paragraph` on the region, the cursor kept. Evil turns
61 /// deactivates the region, which leaves visual state. 61 /// `transient-mark-mode` off in block state, so there it fills the paragraph at the cursor.
62 /// A change deactivates the region, which leaves visual state.
62 private func fillVisual() { 63 private func fillVisual() {
63 let selected = textView.selectedRange() 64 let selected = textView.selectedRanges
64 let cursor = visualCaret ?? selected.location 65 let cursor = visualCaret ?? textView.selectedRange().location
65 let text = document.text 66 let text = document.text
66 perform(FillParagraph(point: cursor)) 67 if vim.mode == .visualBlock {
68 textView.setSelectedRange(NSRange(location: cursor, length: 0))
69 perform(FillParagraph())
70 } else {
71 perform(FillParagraph(point: cursor))
72 }
67 if document.text == text { 73 if document.text == text {
68 textView.setSelectedRange(selected) 74 textView.selectedRanges = selected
69 visualCaret = cursor 75 visualCaret = cursor
70 } else { 76 } else {
71 vim.reset() 77 vim.reset()
@@ -91,7 +97,7 @@ extension OrgEditor {
91 } 97 }
92 } 98 }
93 let wasInsert = vim.mode == .insert 99 let wasInsert = vim.mode == .insert
94 let visual = vim.mode == .visual || vim.mode == .visualLine 100 let visual = vim.mode == .visual || vim.mode == .visualLine || vim.mode == .visualBlock
95 let caret = visual ? (visualCaret ?? textView.selectedRange().location) : textView.selectedRange().location 101 let caret = visual ? (visualCaret ?? textView.selectedRange().location) : textView.selectedRange().location
96 if jumpToFileMark(key) { continue } 102 if jumpToFileMark(key) { continue }
97 let clipboard = NSPasteboard.general.string(forType: .string) 103 let clipboard = NSPasteboard.general.string(forType: .string)
Tests/OrgEditorAppKitTests/ModalTests.swift +29
@@ -161,6 +161,35 @@ struct ModalTests {
161 #expect(lines.editor.vim.mode == .normal) 161 #expect(lines.editor.vim.mode == .normal)
162 } 162 }
163 163
164 /// Evil turns `transient-mark-mode` off in visual block state, so M-q there fills the
165 /// paragraph at the cursor (checked against evil with `org-fill-paragraph`).
166 @Test func fillParagraphInVisualBlockFillsAtTheCursor() {
167 let h = doom("aaa\nbbb\n\nccc\nddd\n\neee\nfff\n")
168 h.caret(at: 0)
169 type(h, "C-v j j j")
170 #expect(h.textView.selectedRanges.first?.rangeValue == NSRange(location: 0, length: 1) && h.textView.selectedRanges.last?.rangeValue == NSRange(location: 9, length: 1))
171 KeyTests().press(h, "q", option: KeyEvents.leftOption, typed: "œ")
172 #expect(h.editor.document.text == "aaa\nbbb\n\nccc ddd\n\neee\nfff\n")
173 #expect(h.textView.selectedRange() == NSRange(location: 9, length: 0))
174 #expect(h.editor.vim.mode == .normal)
175 let up = doom("aaa\nbbb\nccc\n\nx\ny\n")
176 up.caret(at: 8)
177 type(up, "C-v k")
178 KeyTests().press(up, "q", option: KeyEvents.leftOption, typed: "œ")
179 #expect(up.editor.document.text == "aaa bbb ccc\n\nx\ny\n")
180 #expect(up.textView.selectedRange() == NSRange(location: 4, length: 0))
181 #expect(up.editor.vim.mode == .normal)
182 // Nothing to fill: the block stays.
183 let filled = doom("aaa bbb\n\nccc\n")
184 filled.caret(at: 0)
185 type(filled, "C-v l")
186 let block = filled.textView.selectedRanges
187 KeyTests().press(filled, "q", option: KeyEvents.leftOption, typed: "œ")
188 #expect(filled.editor.document.text == "aaa bbb\n\nccc\n")
189 #expect(filled.textView.selectedRanges == block)
190 #expect(filled.editor.vim.mode == .visualBlock)
191 }
192
164 @Test func zzSavesAndClosesAsWq() { 193 @Test func zzSavesAndClosesAsWq() {
165 let h = doom("text\n") 194 let h = doom("text\n")
166 var app: [String] = [] 195 var app: [String] = []