Commit 37493404c7
Verified · cmc
Layout: unified · split
Sources/OrgCore/Commands/Fill.swift +57 −3
| @@ -470,6 +470,48 @@ extension EmacsBuffer { | ||
| 470 | 470 | } |
| 471 | 471 | } |
| 472 | 472 | |
| 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 | ||
| 473 | 515 | /// `fill-region` over whole lines: each paragraph's part of `from..<to` filled as a |
| 474 | 516 | /// paragraph. Other elements stay. |
| 475 | 517 | func fillRegion(_ from: Int, _ to: Int, nosqueeze: Bool = false) { |
| @@ -499,13 +541,25 @@ extension EmacsBuffer { | ||
| 499 | 541 | } |
| 500 | 542 | } |
| 501 | 543 | |
| 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. | |
| 503 | 546 | public struct FillParagraph: OrgCommand { |
| 504 | public init() {} | |
| 547 | public let point: Int? | |
| 548 | ||
| 549 | public init(point: Int? = nil) { self.point = point } | |
| 505 | 550 | public var id: String { "org.fill-paragraph" } |
| 506 | 551 | public var title: String { "Fill Paragraph" } |
| 507 | 552 | 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 | } | |
| 509 | 563 | } |
| 510 | 564 | |
| 511 | 565 | /// 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 { | ||
| 34 | 34 | if keys.count > 1 { onKeysPending?(nil, []) } |
| 35 | 35 | let context = editContext() |
| 36 | 36 | 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 | } | |
| 38 | 42 | updateCursor() |
| 39 | 43 | return true |
| 40 | 44 | } |
| @@ -53,6 +57,23 @@ extension OrgEditor { | ||
| 53 | 57 | } |
| 54 | 58 | } |
| 55 | 59 | |
| 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 | ||
| 56 | 77 | /// Plays keys into the engine. False hands a single key in insert state to the text |
| 57 | 78 | /// system. |
| 58 | 79 | func feedVim(_ keys: [KeyChord]) -> Bool { |
Tests/OrgCoreTests/FillTests.swift +40
| @@ -37,6 +37,46 @@ struct FillTests { | ||
| 37 | 37 | try compareWithEmacs(inputs, commands, options: doomOptions) |
| 38 | 38 | } |
| 39 | 39 | |
| 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 | ||
| 40 | 80 | /// Real paragraphs: `ORGSTAR_ORACLE_CORPUS=<folder>` fills the first 30 paragraph-like lines |
| 41 | 81 | /// of up to 60 files. |
| 42 | 82 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil)) |
Tests/OrgEditorAppKitTests/ModalTests.swift +19
| @@ -142,6 +142,25 @@ struct ModalTests { | ||
| 142 | 142 | #expect(h.editor.document.text == "short\nlines\n\nnext\n") |
| 143 | 143 | } |
| 144 | 144 | |
| 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 | ||
| 145 | 164 | @Test func zzSavesAndClosesAsWq() { |
| 146 | 165 | let h = doom("text\n") |
| 147 | 166 | var app: [String] = [] |