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 | /// `fill-region` over whole lines: each paragraph's part of `from..<to` filled as a | 515 | /// `fill-region` over whole lines: each paragraph's part of `from..<to` filled as a |
| 474 | /// paragraph. Other elements stay. | 516 | /// paragraph. Other elements stay. |
| 475 | func fillRegion(_ from: Int, _ to: Int, nosqueeze: Bool = false) { | 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 | public struct FillParagraph: OrgCommand { | 546 | public struct FillParagraph: OrgCommand { |
| 504 | public init() {} | 547 | public let point: Int? |
| 548 | |||
| 549 | public init(point: Int? = nil) { self.point = point } | ||
| 505 | public var id: String { "org.fill-paragraph" } | 550 | public var id: String { "org.fill-paragraph" } |
| 506 | public var title: String { "Fill Paragraph" } | 551 | public var title: String { "Fill Paragraph" } |
| 507 | public func applies(in context: EditContext) -> Bool { true } | 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 | /// evil's `gq` and `gw` operators: the paragraphs in `range`'s lines filled, as `fill-region`. | 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 | 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 | 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 | updateCursor() | 42 | updateCursor() |
| 39 | return true | 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 | /// Plays keys into the engine. False hands a single key in insert state to the text | 77 | /// Plays keys into the engine. False hands a single key in insert state to the text |
| 57 | /// system. | 78 | /// system. |
| 58 | func feedVim(_ keys: [KeyChord]) -> Bool { | 79 | func feedVim(_ keys: [KeyChord]) -> Bool { |
Tests/OrgCoreTests/FillTests.swift +40
| @@ -37,6 +37,46 @@ struct FillTests { | |||
| 37 | try compareWithEmacs(inputs, commands, options: doomOptions) | 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 | /// Real paragraphs: `ORGSTAR_ORACLE_CORPUS=<folder>` fills the first 30 paragraph-like lines | 80 | /// Real paragraphs: `ORGSTAR_ORACLE_CORPUS=<folder>` fills the first 30 paragraph-like lines |
| 41 | /// of up to 60 files. | 81 | /// of up to 60 files. |
| 42 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil)) | 82 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil)) |
Tests/OrgEditorAppKitTests/ModalTests.swift +19
| @@ -142,6 +142,25 @@ struct ModalTests { | |||
| 142 | #expect(h.editor.document.text == "short\nlines\n\nnext\n") | 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 | @Test func zzSavesAndClosesAsWq() { | 164 | @Test func zzSavesAndClosesAsWq() { |
| 146 | let h = doom("text\n") | 165 | let h = doom("text\n") |
| 147 | var app: [String] = [] | 166 | var app: [String] = [] |