Commit 4ce0c2a1ce
Verified · cmc
Layout: unified · split
Sources/OrgCore/Commands/Fill.swift +51
| @@ -467,6 +467,28 @@ extension EmacsBuffer { | ||
| 467 | 467 | } |
| 468 | 468 | } |
| 469 | 469 | |
| 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 | ||
| 470 | 492 | /// Where a paragraph's text starts: past affiliated keywords, and for a paragraph that |
| 471 | 493 | /// opens an item, past the bullet. |
| 472 | 494 | func paragraphContentsBegin(_ node: Element) -> Int { |
| @@ -482,3 +504,32 @@ public struct FillParagraph: OrgCommand { | ||
| 482 | 504 | public func applies(in context: EditContext) -> Bool { true } |
| 483 | 505 | public func run(in context: EditContext) -> CommandStep { commitBuffer(context) { $0.fillParagraph() } } |
| 484 | 506 | } |
| 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. | |
| 511 | public 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 { | ||
| 32 | 32 | case setClipboard(String) |
| 33 | 33 | /// Ask for a search pattern (`/`, `?`) or an ex command (`:`). |
| 34 | 34 | 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?) | |
| 35 | 38 | /// An ex command to run, as if typed after `:` (`ZZ` is `:wq`, `ZQ` is `:q!`). |
| 36 | 39 | case ex(String) |
| 37 | 40 | case message(String) |
| @@ -271,7 +274,7 @@ public struct Vim: Sendable { | ||
| 271 | 274 | op = o |
| 272 | 275 | } else if first.key == "g", first.modifiers.isEmpty { |
| 273 | 276 | 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 { | |
| 275 | 278 | op = "g" + second.key |
| 276 | 279 | afterOp = rest.dropFirst(2) |
| 277 | 280 | } |
| @@ -876,6 +879,12 @@ public struct Vim: Sendable { | ||
| 876 | 879 | case "gc": |
| 877 | 880 | lastChange = (wasVisual ? [] : keys, nil) |
| 878 | 881 | 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)] | |
| 879 | 888 | default: |
| 880 | 889 | let transformed: String |
| 881 | 890 | switch op { |
Sources/OrgEditorAppKit/Modal.swift +5
| @@ -205,6 +205,11 @@ extension OrgEditor { | ||
| 205 | 205 | prompt(kind) |
| 206 | 206 | case .ex(let command): |
| 207 | 207 | 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() | |
| 208 | 213 | case .message(let text): |
| 209 | 214 | onMessage?(text) |
| 210 | 215 | case .setMark(let name, let offset): |
Tests/OrgCoreTests/VimExtrasTests.swift +30 −1
| @@ -38,6 +38,16 @@ struct VimExtrasTests { | ||
| 38 | 38 | #expect(run("abcdef\n", 1, "v l l ESC 0 g v d").text == "aef\n") |
| 39 | 39 | } |
| 40 | 40 | |
| 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 | ||
| 41 | 51 | @Test func elementMotionsStop() { |
| 42 | 52 | let text = "intro\n\n* H\npara one\n* Next\nend\n" |
| 43 | 53 | func moved(_ caret: Int, _ keys: String, org: Bool = true) -> Int { |
| @@ -136,7 +146,26 @@ struct VimExtrasTests { | ||
| 136 | 146 | ("a =code= b\n", 3, "d i e"), |
| 137 | 147 | ("a =code= b\n", 3, "d a e"), |
| 138 | 148 | ("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 | ] | |
| 140 | 169 | }() |
| 141 | 170 | |
| 142 | 171 | /// evil-org's gj and gk: org-forward-element and org-backward-element. |
Tests/OrgCoreTests/VimTests.swift +4
| @@ -48,6 +48,10 @@ struct VimDriver { | ||
| 48 | 48 | case .move(let to): caret = to |
| 49 | 49 | case .setClipboard(let s): clipboard = s |
| 50 | 50 | 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 | |
| 51 | 55 | default: break |
| 52 | 56 | } |
| 53 | 57 | } |
Tests/OrgEditorAppKitTests/ModalTests.swift +10
| @@ -132,6 +132,16 @@ struct ModalTests { | ||
| 132 | 132 | #expect(h.editor.foldState.folds == [9]) |
| 133 | 133 | } |
| 134 | 134 | |
| 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 | ||
| 135 | 145 | @Test func zzSavesAndClosesAsWq() { |
| 136 | 146 | let h = doom("text\n") |
| 137 | 147 | var app: [String] = [] |