krz/orgstar

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

Commit 37493404c7

37493404c71ba2489005560d0503efdf38864d6c

parent: f71bf44f5c

Verified · cmc

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

Fill the region's paragraphs with M-q

org-fill-paragraph with an active region fills each paragraph from the region's end back to its start. In Doom's visual states M-q fills the visual region, keeps the cursor and returns to normal state when the text changed.

Layout: unified · split

Sources/OrgCore/Commands/Fill.swift +57 −3
@@ -470,6 +470,48 @@ extension EmacsBuffer {
470470 }
471471 }
472472
473 /// `org-fill-paragraph` with an active region: from the region's end back to `start`, M-q at
474 /// the end of each paragraph's text, moving back a paragraph at a time.
475 func fillParagraphs(_ start: Int, _ end: Int) {
476 saveExcursion {
477 point = end
478 skipBackward(Self.whitespace)
479 while point > start {
480 let before = point
481 fillParagraph()
482 point = paragraphBackwardStop()
483 skipBackward(Self.whitespace)
484 if point >= before { break }
485 }
486 }
487 }
488
489 /// Where `org--backward-paragraph-once` goes from the end of a line's text.
490 private func paragraphBackwardStop() -> Int {
491 guard let element = element(at: point) else { return lineStart(point) }
492 switch element.kind {
493 case .block where ["comment", "example", "export", "src", "verse"].contains(element.blockType ?? ""):
494 // Between empty lines in the block.
495 let contentsStart = lineEnd(element.range.lowerBound) + 1
496 guard point > contentsStart else { return element.range.lowerBound }
497 let contentsEnd = saveExcursion { () -> Int in
498 point = element.range.upperBound
499 skipBackward(Self.whitespace)
500 return lineStart(point)
501 }
502 if point > contentsEnd { return contentsEnd }
503 return saveExcursion {
504 skipBackward(Self.whitespace, bound: element.range.lowerBound)
505 return searchBackward("^[ \t]*\n", bound: contentsStart, caseInsensitive: false)?[0]!.lowerBound ?? contentsStart
506 }
507 case .block, .drawer, .propertyDrawer, .dynamicBlock:
508 // On the closing line: inside, to the contents' end.
509 return lineStart(point) > element.range.lowerBound ? lineStart(point) : element.range.lowerBound
510 default:
511 return lineStart(element.range.lowerBound)
512 }
513 }
514
473515 /// `fill-region` over whole lines: each paragraph's part of `from..<to` filled as a
474516 /// paragraph. Other elements stay.
475517 func fillRegion(_ from: Int, _ to: Int, nosqueeze: Bool = false) {
@@ -499,13 +541,25 @@ extension EmacsBuffer {
499541 }
500542}
501543
502/// M-q: `org-fill-paragraph`.
544/// M-q: `org-fill-paragraph`. With a selection, as with an active region, every paragraph
545/// in it; `point` is where the caret is in it, and stays.
503546public struct FillParagraph: OrgCommand {
504 public init() {}
547 public let point: Int?
548
549 public init(point: Int? = nil) { self.point = point }
505550 public var id: String { "org.fill-paragraph" }
506551 public var title: String { "Fill Paragraph" }
507552 public func applies(in context: EditContext) -> Bool { true }
508 public func run(in context: EditContext) -> CommandStep { commitBuffer(context) { $0.fillParagraph() } }
553 public func run(in context: EditContext) -> CommandStep {
554 commitBuffer(context) { buffer in
555 if let selection = context.selection.first, !selection.isEmpty {
556 if let point { buffer.point = point }
557 buffer.fillParagraphs(selection.lowerBound, selection.upperBound)
558 } else {
559 buffer.fillParagraph()
560 }
561 }
562 }
509563}
510564
511565/// evil's `gq` and `gw` operators: the paragraphs in `range`'s lines filled, as `fill-region`.
Sources/OrgEditorAppKit/Modal.swift +22 −1
@@ -34,7 +34,11 @@ extension OrgEditor {
3434 if keys.count > 1 { onKeysPending?(nil, []) }
3535 let context = editContext()
3636 if let binding = candidates.first(where: { modalRuns($0, in: context) }) {
37 run(binding.command)
37 if binding.command == "org.fill-paragraph", vim.mode == .visual || vim.mode == .visualLine {
38 fillVisual()
39 } else {
40 run(binding.command)
41 }
3842 updateCursor()
3943 return true
4044 }
@@ -53,6 +57,23 @@ extension OrgEditor {
5357 }
5458 }
5559
60 /// M-q in visual states: `org-fill-paragraph` on the region, the cursor kept. A change
61 /// deactivates the region, which leaves visual state.
62 private func fillVisual() {
63 let selected = textView.selectedRange()
64 let cursor = visualCaret ?? selected.location
65 let text = document.text
66 perform(FillParagraph(point: cursor))
67 if document.text == text {
68 textView.setSelectedRange(selected)
69 visualCaret = cursor
70 } else {
71 vim.reset()
72 visualCaret = nil
73 clampCaret()
74 }
75 }
76
5677 /// Plays keys into the engine. False hands a single key in insert state to the text
5778 /// system.
5879 func feedVim(_ keys: [KeyChord]) -> Bool {
Tests/OrgCoreTests/FillTests.swift +40
@@ -37,6 +37,46 @@ struct FillTests {
3737 try compareWithEmacs(inputs, commands, options: doomOptions)
3838 }
3939
40 /// M-q with an active region: `org-fill-paragraph` called interactively.
41 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
42 func regionFillMatchesEmacs() throws {
43 try #require(EmacsOracle.isAvailable)
44 let texts = [
45 "Short\nlines\n\n" + Self.long + "\nmore\n\nlast\npara\n",
46 "* H\nintro\ntext\n- item " + Self.long + "\n- b\n c\n# comment\n# lines\n#\n# again\n",
47 "a\nb\n#+begin_comment\none\ntwo\n\nthree\nfour\n#+end_comment\n:NOTES:\nx\ny\n:END:\n#+begin_quote\nq\nr\n#+end_quote\nz\nw\n",
48 "Words with extra spaces\n\n and more\n",
49 ]
50 var cases: [EmacsOracle.Case] = []
51 var ours: [(label: String, text: String, caret: Int)] = []
52 for text in texts {
53 let positions = EmacsOracle.positions(text)
54 for (i, from) in positions.enumerated() where i % 3 == 0 {
55 for to in positions where to > from && (to - from) % 5 == 1 {
56 let form = "(progn (transient-mark-mode 1) (set-mark \(EmacsOracle.point(to, in: text))) (activate-mark) (org-fill-paragraph nil t))"
57 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(from, in: text), form: withOptions(.org, form)))
58 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [from..<to], options: .org)
59 guard case .commit(let result) = FillParagraph().run(in: context) else {
60 ours.append(("\(from)..<\(to) failed", text, -1))
61 continue
62 }
63 let new = result.edits.sorted { $0.range.lowerBound > $1.range.lowerBound }.reduce(text) { $1.apply(to: $0) }
64 ours.append(("\(from)..<\(to) in \(text.debugDescription)", new, result.selection?.first?.lowerBound ?? from))
65 }
66 }
67 }
68 var mismatches = 0
69 for (mine, emacs) in zip(ours, try EmacsOracle.run(cases)) {
70 let caret = EmacsOracle.offset(emacs.point, in: emacs.text)
71 guard !emacs.error.isEmpty || mine.text != emacs.text || mine.caret != caret else { continue }
72 mismatches += 1
73 if mismatches <= 12 {
74 Issue.record("\(mine.label)\n ours: \(mine.text.debugDescription) @\(mine.caret)\n emacs: \(emacs.error.isEmpty ? "\(emacs.text.debugDescription) @\(caret)" : emacs.error)")
75 }
76 }
77 #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ")
78 }
79
4080 /// Real paragraphs: `ORGSTAR_ORACLE_CORPUS=<folder>` fills the first 30 paragraph-like lines
4181 /// of up to 60 files.
4282 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil))
Tests/OrgEditorAppKitTests/ModalTests.swift +19
@@ -142,6 +142,25 @@ struct ModalTests {
142142 #expect(h.editor.document.text == "short\nlines\n\nnext\n")
143143 }
144144
145 /// M-q in visual states fills the region's paragraphs and leaves visual state, as in
146 /// Doom (checked against evil with `org-fill-paragraph`).
147 @Test func fillParagraphFillsTheVisualRegion() {
148 let h = doom("aaa\nbbb\n\nccc\nddd\n\neee\nfff\n")
149 h.caret(at: 0)
150 type(h, "v j j j")
151 KeyTests().press(h, "q", option: KeyEvents.leftOption, typed: "œ")
152 #expect(h.editor.document.text == "aaa bbb\n\nccc ddd\n\neee\nfff\n")
153 #expect(h.textView.selectedRange() == NSRange(location: 9, length: 0))
154 #expect(h.editor.vim.mode == .normal)
155 let lines = doom("aaa\nbbb\nccc\n\nx\ny\n")
156 lines.caret(at: 8)
157 type(lines, "v k")
158 KeyTests().press(lines, "q", option: KeyEvents.leftOption, typed: "œ")
159 #expect(lines.editor.document.text == "aaa bbb ccc\n\nx\ny\n")
160 #expect(lines.textView.selectedRange() == NSRange(location: 4, length: 0))
161 #expect(lines.editor.vim.mode == .normal)
162 }
163
145164 @Test func zzSavesAndClosesAsWq() {
146165 let h = doom("text\n")
147166 var app: [String] = []