krz/orgstar

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

Commit 4ce0c2a1ce

4ce0c2a1ce199ed62560176a97251fbafcec2b71

parent: c17f1e91fb

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 04:06 UTC

Add the gq and gw fill operators

gq{motion} and gw{motion} fill the paragraphs in the lines the motion
covers, as evil-fill-and-move and evil-fill with fill-region: gq leaves
the caret on the last line's first non-blank, gw where it was. gqq and
gww take the current line; both work in visual state and repeat with .

Layout: unified · split

Sources/OrgCore/Commands/Fill.swift +51
@@ -467,6 +467,28 @@ extension EmacsBuffer {
467467 }
468468 }
469469
470 /// `fill-region` over whole lines: each paragraph's part of `from..<to` filled as a
471 /// paragraph. Other elements stay.
472 func fillRegion(_ from: Int, _ to: Int) {
473 var spans: [Range<Int>] = []
474 var line = lineStart(from)
475 while line < to, line < length {
476 var next = lineEnd(line) + 1
477 let blank = substring(line, lineEnd(line)).allSatisfy { $0 == " " || $0 == "\t" }
478 if !blank, let element = element(at: lineEnd(line)) {
479 if element.kind == .paragraph {
480 let span = max(paragraphContentsBegin(element), from)..<min(contentsEnd(element), to)
481 if !span.isEmpty { spans.append(span) }
482 }
483 next = max(next, lineStart(min(length, max(element.range.lowerBound, contentsEnd(element)))))
484 if next <= line { next = lineEnd(line) + 1 }
485 }
486 line = next
487 }
488 // Back to front, so the spans before stay where they are.
489 for span in spans.reversed() { fillRegionAsParagraph(span.lowerBound, span.upperBound) }
490 }
491
470492 /// Where a paragraph's text starts: past affiliated keywords, and for a paragraph that
471493 /// opens an item, past the bullet.
472494 func paragraphContentsBegin(_ node: Element) -> Int {
@@ -482,3 +504,32 @@ public struct FillParagraph: OrgCommand {
482504 public func applies(in context: EditContext) -> Bool { true }
483505 public func run(in context: EditContext) -> CommandStep { commitBuffer(context) { $0.fillParagraph() } }
484506}
507
508/// evil's `gq` and `gw` operators: the paragraphs in `range`'s lines filled, as `fill-region`.
509/// With `keep` (`gw`) the caret stays there; without (`gq`) it goes to the first non-blank
510/// of the range's last line.
511public struct FillRegion: OrgCommand {
512 public let range: Range<Int>
513 public let keep: Int?
514
515 public init(range: Range<Int>, keep: Int?) {
516 self.range = range
517 self.keep = keep
518 }
519
520 public var id: String { "org.fill-region" }
521 public var title: String { "Fill Region" }
522 public func applies(in context: EditContext) -> Bool { true }
523 public func run(in context: EditContext) -> CommandStep {
524 commitBuffer(context) { buffer in
525 let target = buffer.marker(keep ?? max(range.lowerBound, range.upperBound - 1))
526 defer { buffer.release(target) }
527 buffer.fillRegion(range.lowerBound, range.upperBound)
528 buffer.point = target.position
529 if keep == nil {
530 buffer.beginningOfLine()
531 buffer.skipForward(EmacsBuffer.blanks)
532 }
533 }
534 }
535}
Sources/OrgCore/Keymap/Vim.swift +10 −1
@@ -32,6 +32,9 @@ public struct Vim: Sendable {
3232 case setClipboard(String)
3333 /// Ask for a search pattern (`/`, `?`) or an ex command (`:`).
3434 case prompt(String)
35 /// `gq` and `gw`: fill these lines' paragraphs (`FillRegion`); the caret stays at
36 /// `keep`, or without it goes to the last line's first non-blank.
37 case fill(Range<Int>, keep: Int?)
3538 /// An ex command to run, as if typed after `:` (`ZZ` is `:wq`, `ZQ` is `:q!`).
3639 case ex(String)
3740 case message(String)
@@ -271,7 +274,7 @@ public struct Vim: Sendable {
271274 op = o
272275 } else if first.key == "g", first.modifiers.isEmpty {
273276 guard let second = rest.dropFirst().first else { return .incomplete }
274 if ["~", "u", "U", "c"].contains(second.key), second.modifiers.isEmpty {
277 if ["~", "u", "U", "c", "q", "w"].contains(second.key), second.modifiers.isEmpty {
275278 op = "g" + second.key
276279 afterOp = rest.dropFirst(2)
277280 }
@@ -876,6 +879,12 @@ public struct Vim: Sendable {
876879 case "gc":
877880 lastChange = (wasVisual ? [] : keys, nil)
878881 return [.edit(Self.toggleComments(buffer, range), caret: buffer.lineStart(range.lowerBound))]
882 case "gq", "gw":
883 // Whole lines, as evil's fill operators take them.
884 lastChange = (wasVisual ? [] : keys, nil)
885 let end = buffer.lineEnd(max(range.lowerBound, range.upperBound - 1))
886 let lines = buffer.lineStart(range.lowerBound)..<min(buffer.length, end + 1)
887 return [.fill(lines, keep: op == "gw" ? caretForApply : nil)]
879888 default:
880889 let transformed: String
881890 switch op {
Sources/OrgEditorAppKit/Modal.swift +5
@@ -205,6 +205,11 @@ extension OrgEditor {
205205 prompt(kind)
206206 case .ex(let command):
207207 ex(command)
208 case .fill(let range, let keep):
209 visualCaret = nil
210 textView.setSelectedRange(NSRange(location: textView.selectedRange().location, length: 0))
211 _ = perform(FillRegion(range: range, keep: keep))
212 clampCaret()
208213 case .message(let text):
209214 onMessage?(text)
210215 case .setMark(let name, let offset):
Tests/OrgCoreTests/VimExtrasTests.swift +30 −1
@@ -38,6 +38,16 @@ struct VimExtrasTests {
3838 #expect(run("abcdef\n", 1, "v l l ESC 0 g v d").text == "aef\n")
3939 }
4040
41 @Test func fillOperators() {
42 let text = "short\nlines\njoin here\n\nnext para\n"
43 #expect(run(text, 0, "g q a p").text == "short lines join here\n\nnext para\n")
44 #expect(run(text, 8, "g w a p") == ("short lines join here\n\nnext para\n", 8))
45 #expect(run("a\nb\nc\n", 2, "g q q").text == "a\nb\nc\n")
46 #expect(run("a\nb\nc\n", 0, "V j g q") == ("a b\nc\n", 0))
47 let doom = KeymapPreset.doom.keymap
48 #expect(Set(["normal", "insert", "visual"]).allSatisfy { doom.candidates(for: [KeyChord("q", .meta)], mode: $0).first?.command == "org.fill-paragraph" })
49 }
50
4151 @Test func elementMotionsStop() {
4252 let text = "intro\n\n* H\npara one\n* Next\nend\n"
4353 func moved(_ caret: Int, _ keys: String, org: Bool = true) -> Int {
@@ -136,7 +146,26 @@ struct VimExtrasTests {
136146 ("a =code= b\n", 3, "d i e"),
137147 ("a =code= b\n", 3, "d a e"),
138148 ("a [[https://x.org]] b\n", 5, "d i e"),
139 ] + elementCases
149 ] + elementCases + fillCases
150 }()
151
152 /// gq and gw: evil-fill-and-move and evil-fill.
153 static let fillCases: [(String, Int, String)] = {
154 let long = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
155 return [
156 (long + "\n", 0, "g q q"),
157 (long + "\n", 10, "g w w"),
158 ("short\nlines\njoin here\n\nnext para\n", 0, "g q a p"),
159 ("short\nlines\njoin here\n\nnext para\n", 2, "g w a p"),
160 ("one\ntwo\n\nthree\nfour\n", 0, "g q G"),
161 ("- item " + long + "\n- b\n", 0, "g q q"),
162 ("* H\n" + long + "\n", 4, "g q q"),
163 ("* H\n" + long + "\n", 0, "g q j"),
164 ("a\nb\nc\n", 2, "g q q"),
165 ("a\nb\nc\nd\n", 2, "g q j"),
166 ("a\nb\nc\n", 0, "V j g q"),
167 ("a\nb\n\nc\nd\n", 0, "g q j j ."),
168 ]
140169 }()
141170
142171 /// evil-org's gj and gk: org-forward-element and org-backward-element.
Tests/OrgCoreTests/VimTests.swift +4
@@ -48,6 +48,10 @@ struct VimDriver {
4848 case .move(let to): caret = to
4949 case .setClipboard(let s): clipboard = s
5050 case .command(let id): commands.append(id)
51 case .fill(let range, let keep):
52 let result = runCommand(FillRegion(range: range, keep: keep), text, caret: caret)
53 text = result.text
54 caret = result.caret
5155 default: break
5256 }
5357 }
Tests/OrgEditorAppKitTests/ModalTests.swift +10
@@ -132,6 +132,16 @@ struct ModalTests {
132132 #expect(h.editor.foldState.folds == [9])
133133 }
134134
135 @Test func fillOperatorsInTheEditor() {
136 let h = doom("short\nlines\n\nnext\n")
137 h.caret(at: 7)
138 type(h, "g w a p")
139 #expect(h.editor.document.text == "short lines\n\nnext\n")
140 #expect(h.textView.selectedRange().location == 7)
141 type(h, "u")
142 #expect(h.editor.document.text == "short\nlines\n\nnext\n")
143 }
144
135145 @Test func zzSavesAndClosesAsWq() {
136146 let h = doom("text\n")
137147 var app: [String] = []