Editor and Vim differences from Emacs !164
11 files changed, +357 −96
Layout: unified · split
Sources/OrgCore/Commands/Fill.swift +74 −16
| @@ -302,8 +302,9 @@ extension EmacsBuffer { | |||
| 302 | } | 302 | } |
| 303 | } | 303 | } |
| 304 | 304 | ||
| 305 | /// `fill-region-as-paragraph` with left justification and adaptive fill. | 305 | /// `fill-region-as-paragraph` with left justification and adaptive fill. With `nosqueeze`, |
| 306 | func fillRegionAsParagraph(_ fromOffset: Int, _ toOffset: Int, prefix fixedPrefix: String? = nil) { | 306 | /// spaces between words and at the end stay. |
| 307 | func fillRegionAsParagraph(_ fromOffset: Int, _ toOffset: Int, prefix fixedPrefix: String? = nil, nosqueeze: Bool = false) { | ||
| 307 | point = min(fromOffset, toOffset) | 308 | point = min(fromOffset, toOffset) |
| 308 | let to = max(fromOffset, toOffset) | 309 | let to = max(fromOffset, toOffset) |
| 309 | skipForward(Self.whitespace) | 310 | skipForward(Self.whitespace) |
| @@ -349,13 +350,15 @@ extension EmacsBuffer { | |||
| 349 | if character(at: at) == 10 { replace(at..<(at + 1), with: " ") } | 350 | if character(at: at) == 10 { replace(at..<(at + 1), with: " ") } |
| 350 | at += 1 | 351 | at += 1 |
| 351 | } | 352 | } |
| 352 | point = from | 353 | if !nosqueeze { |
| 353 | skipForward(Self.blanks) | 354 | point = from |
| 354 | canonicallySpace(point, toMarker.position) | 355 | skipForward(Self.blanks) |
| 355 | point = toMarker.position | 356 | canonicallySpace(point, toMarker.position) |
| 356 | let end = point | 357 | point = toMarker.position |
| 357 | skipBackward(Self.blanks) | 358 | let end = point |
| 358 | if point < end { deleteRegion(point, end) } | 359 | skipBackward(Self.blanks) |
| 360 | if point < end { deleteRegion(point, end) } | ||
| 361 | } | ||
| 359 | point = from | 362 | point = from |
| 360 | while point < toMarker.position { | 363 | while point < toMarker.position { |
| 361 | let lineBegin = point | 364 | let lineBegin = point |
| @@ -467,9 +470,51 @@ extension EmacsBuffer { | |||
| 467 | } | 470 | } |
| 468 | } | 471 | } |
| 469 | 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 | |||
| 470 | /// `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 |
| 471 | /// paragraph. Other elements stay. | 516 | /// paragraph. Other elements stay. |
| 472 | func fillRegion(_ from: Int, _ to: Int) { | 517 | func fillRegion(_ from: Int, _ to: Int, nosqueeze: Bool = false) { |
| 473 | var spans: [Range<Int>] = [] | 518 | var spans: [Range<Int>] = [] |
| 474 | var line = lineStart(from) | 519 | var line = lineStart(from) |
| 475 | while line < to, line < length { | 520 | while line < to, line < length { |
| @@ -486,7 +531,7 @@ extension EmacsBuffer { | |||
| 486 | line = next | 531 | line = next |
| 487 | } | 532 | } |
| 488 | // Back to front, so the spans before stay where they are. | 533 | // Back to front, so the spans before stay where they are. |
| 489 | for span in spans.reversed() { fillRegionAsParagraph(span.lowerBound, span.upperBound) } | 534 | for span in spans.reversed() { fillRegionAsParagraph(span.lowerBound, span.upperBound, nosqueeze: nosqueeze) } |
| 490 | } | 535 | } |
| 491 | 536 | ||
| 492 | /// Where a paragraph's text starts: past affiliated keywords, and for a paragraph that | 537 | /// Where a paragraph's text starts: past affiliated keywords, and for a paragraph that |
| @@ -496,18 +541,31 @@ extension EmacsBuffer { | |||
| 496 | } | 541 | } |
| 497 | } | 542 | } |
| 498 | 543 | ||
| 499 | /// 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. | ||
| 500 | public struct FillParagraph: OrgCommand { | 546 | public struct FillParagraph: OrgCommand { |
| 501 | public init() {} | 547 | public let point: Int? |
| 548 | |||
| 549 | public init(point: Int? = nil) { self.point = point } | ||
| 502 | public var id: String { "org.fill-paragraph" } | 550 | public var id: String { "org.fill-paragraph" } |
| 503 | public var title: String { "Fill Paragraph" } | 551 | public var title: String { "Fill Paragraph" } |
| 504 | public func applies(in context: EditContext) -> Bool { true } | 552 | public func applies(in context: EditContext) -> Bool { true } |
| 505 | 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 | } | ||
| 506 | } | 563 | } |
| 507 | 564 | ||
| 508 | /// 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`. |
| 509 | /// With `keep` (`gw`) the caret stays there; without (`gq`) it goes to the first non-blank | 566 | /// With `keep` (`gw`) the caret stays there; without (`gq`) it goes to the first non-blank |
| 510 | /// of the range's last line. | 567 | /// of the range's last line. Spaces are not squeezed, as Doom's `+evil--no-squeeze-on-fill-a` |
| 568 | /// has it. | ||
| 511 | public struct FillRegion: OrgCommand { | 569 | public struct FillRegion: OrgCommand { |
| 512 | public let range: Range<Int> | 570 | public let range: Range<Int> |
| 513 | public let keep: Int? | 571 | public let keep: Int? |
| @@ -524,7 +582,7 @@ public struct FillRegion: OrgCommand { | |||
| 524 | commitBuffer(context) { buffer in | 582 | commitBuffer(context) { buffer in |
| 525 | let target = buffer.marker(keep ?? max(range.lowerBound, range.upperBound - 1)) | 583 | let target = buffer.marker(keep ?? max(range.lowerBound, range.upperBound - 1)) |
| 526 | defer { buffer.release(target) } | 584 | defer { buffer.release(target) } |
| 527 | buffer.fillRegion(range.lowerBound, range.upperBound) | 585 | buffer.fillRegion(range.lowerBound, range.upperBound, nosqueeze: true) |
| 528 | buffer.point = target.position | 586 | buffer.point = target.position |
| 529 | if keep == nil { | 587 | if keep == nil { |
| 530 | buffer.beginningOfLine() | 588 | buffer.beginningOfLine() |
Sources/OrgCore/Commands/TimestampCommands.swift +12 −9
| @@ -14,12 +14,17 @@ extension EmacsBuffer { | |||
| 14 | 14 | ||
| 15 | /// `org-timestamp`: replaces the timestamp at point, keeping its repeater, or inserts one. | 15 | /// `org-timestamp`: replaces the timestamp at point, keeping its repeater, or inserts one. |
| 16 | func timestampCommand(_ answer: String, inactive: Bool) { | 16 | func timestampCommand(_ answer: String, inactive: Bool) { |
| 17 | let existing = atTimestamp() | 17 | let stamp = timestampForUpdate() |
| 18 | let stamp = existing.map { substring($0.match[0]!.lowerBound, $0.match[0]!.upperBound) } | ||
| 19 | let defaultDate = stamp.flatMap(Self.parseTimeString).map(date) | 18 | let defaultDate = stamp.flatMap(Self.parseTimeString).map(date) |
| 20 | let repeater = stamp.flatMap { $0.firstMatch(of: try! Regex("([.+-]+[0-9]+[hdwmy] ?)+")).map { String($0.output[0].substring!) } } | 19 | let repeater = stamp.flatMap { $0.firstMatch(of: try! Regex("([.+-]+[0-9]+[hdwmy] ?)+")).map { String($0.output[0].substring!) } } |
| 21 | let result = readDate(answer, default: defaultDate) | 20 | let result = readDate(answer, default: defaultDate) |
| 22 | let components = result.components(calendar) | 21 | let components = result.components(calendar) |
| 22 | // Between a range's dashes, the end is replaced. | ||
| 23 | var existing = atTimestamp() | ||
| 24 | if stamp != nil, existing == nil { | ||
| 25 | skipForward([45]) | ||
| 26 | existing = atTimestamp() | ||
| 27 | } | ||
| 23 | if let existing { | 28 | if let existing { |
| 24 | replace(existing.match[0]!, with: "") | 29 | replace(existing.match[0]!, with: "") |
| 25 | insertTimestamp(components, withTime: result.timeGiven, inactive: inactive, endTime: result.endTime) | 30 | insertTimestamp(components, withTime: result.timeGiven, inactive: inactive, endTime: result.endTime) |
| @@ -95,8 +100,8 @@ private func atTimestamp(_ context: EditContext) -> Bool { | |||
| 95 | /// The prompt's starting text: the time of the timestamp at the caret, as org pre-fills it. | 100 | /// The prompt's starting text: the time of the timestamp at the caret, as org pre-fills it. |
| 96 | private func timeOfDayAtCaret(_ context: EditContext) -> String { | 101 | private func timeOfDayAtCaret(_ context: EditContext) -> String { |
| 97 | let buffer = EmacsBuffer(context.text, point: context.caret) | 102 | let buffer = EmacsBuffer(context.text, point: context.caret) |
| 98 | guard let match = buffer.atTimestamp()?.match[0] else { return "" } | 103 | guard let stamp = buffer.timestampForUpdate() else { return "" } |
| 99 | return EmacsBuffer.compactTimeOfDay(buffer.substring(match.lowerBound, match.upperBound)) ?? "" | 104 | return EmacsBuffer.compactTimeOfDay(stamp) ?? "" |
| 100 | } | 105 | } |
| 101 | 106 | ||
| 102 | /// C-c . and C-c !: `org-timestamp`. | 107 | /// C-c . and C-c !: `org-timestamp`. |
| @@ -110,16 +115,14 @@ public struct InsertTimestamp: OrgCommand { | |||
| 110 | guard let answer = context.answers["date"] else { | 115 | guard let answer = context.answers["date"] else { |
| 111 | let buffer = EmacsBuffer(context.text, point: context.caret) | 116 | let buffer = EmacsBuffer(context.text, point: context.caret) |
| 112 | buffer.calendar = context.calendar | 117 | buffer.calendar = context.calendar |
| 113 | let stamp = buffer.atTimestamp().map { buffer.substring($0.match[0]!.lowerBound, $0.match[0]!.upperBound) } | ||
| 114 | let repeated = context.lastCommand == "org.timestamp.active" || context.lastCommand == "org.timestamp.inactive" | ||
| 115 | return .prompt(Prompt(key: "date", message: "Date+time:", initial: timeOfDayAtCaret(context), | 118 | return .prompt(Prompt(key: "date", message: "Date+time:", initial: timeOfDayAtCaret(context), |
| 116 | date: .init(defaultDate: repeated ? nil : buffer.dateDefault(stamp), inactive: inactive))) | 119 | date: .init(defaultDate: buffer.dateDefault(buffer.timestampForUpdate()), inactive: inactive))) |
| 117 | } | 120 | } |
| 118 | // Twice in a row on a timestamp: a range to a second one. | 121 | // Twice in a row on a timestamp: a range to a second one. |
| 119 | let repeated = context.lastCommand == "org.timestamp.active" || context.lastCommand == "org.timestamp.inactive" | 122 | let repeated = context.lastCommand == "org.timestamp.active" || context.lastCommand == "org.timestamp.inactive" |
| 120 | if repeated, EmacsBuffer(context.text, point: context.caret).atTimestamp() != nil { | 123 | if repeated, EmacsBuffer(context.text, point: context.caret).timestampForUpdate() != nil { |
| 121 | return timestampCommand(context) { buffer in | 124 | return timestampCommand(context) { buffer in |
| 122 | let result = buffer.readDate(answer, default: nil) | 125 | let result = buffer.readDate(answer, default: buffer.dateDefault(buffer.timestampForUpdate())) |
| 123 | buffer.insert("--") | 126 | buffer.insert("--") |
| 124 | buffer.insertTimestamp(result.components(buffer.calendar), withTime: result.timeGiven, inactive: inactive) | 127 | buffer.insertTimestamp(result.components(buffer.calendar), withTime: result.timeGiven, inactive: inactive) |
| 125 | } | 128 | } |
Sources/OrgCore/Commands/Timestamps.swift +25
| @@ -66,6 +66,31 @@ extension EmacsBuffer { | |||
| 66 | return (part, match) | 66 | return (part, match) |
| 67 | } | 67 | } |
| 68 | 68 | ||
| 69 | /// `org-at-date-range-p` with INACTIVE-OK: the range around point, groups 1 and 2 the insides | ||
| 70 | /// of its two timestamps. | ||
| 71 | func atDateRange() -> [Range<Int>?]? { | ||
| 72 | let pattern = "[\\[<](" + AgendaSource.tsInternal + ")[\\]>]--?-?[\\[<](" + AgendaSource.tsInternal + ")[\\]>]" | ||
| 73 | return saveExcursion { | ||
| 74 | let pos = point | ||
| 75 | for _ in 0..<2 { | ||
| 76 | while let c = character(at: point - 1), ![91, 60, 13, 10].contains(c) { point -= 1 } | ||
| 77 | skipBackward([60, 91]) | ||
| 78 | if let match = lookingAt(pattern), match[0]!.upperBound >= pos { return match } | ||
| 79 | } | ||
| 80 | return nil | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | /// The timestamp `org-timestamp` takes its defaults from: in a date range the start before | ||
| 85 | /// the dashes and the end from the second-to-last dash on, otherwise the one at point. | ||
| 86 | func timestampForUpdate() -> String? { | ||
| 87 | if let range = atDateRange() { | ||
| 88 | let stamp = point < range[2]!.lowerBound - 2 ? range[1]! : range[2]! | ||
| 89 | return substring(stamp.lowerBound, stamp.upperBound) | ||
| 90 | } | ||
| 91 | return atTimestamp().map { substring($0.match[0]!.lowerBound, $0.match[0]!.upperBound) } | ||
| 92 | } | ||
| 93 | |||
| 69 | /// `org-parse-time-string`: date and time of an Org time string, time 0:00 if absent. | 94 | /// `org-parse-time-string`: date and time of an Org time string, time 0:00 if absent. |
| 70 | static func parseTimeString(_ text: String) -> DateComponents? { | 95 | static func parseTimeString(_ text: String) -> DateComponents? { |
| 71 | guard let match = text.firstMatch(of: try! Regex(timeStringPattern)) else { return nil } | 96 | guard let match = text.firstMatch(of: try! Regex(timeStringPattern)) else { return nil } |
Sources/OrgCore/Keymap/Vim.swift +76 −66
| @@ -88,10 +88,11 @@ public struct Vim: Sendable { | |||
| 88 | public var clipboardRecord: (text: String, linewise: Bool)? | 88 | public var clipboardRecord: (text: String, linewise: Bool)? |
| 89 | var anchor = 0 | 89 | var anchor = 0 |
| 90 | var goalColumn: Int? | 90 | var goalColumn: Int? |
| 91 | var lastFind: (kind: Character, char: String)? | 91 | /// The last snipe: s, S, f, F, t or T. `consume` lands on the match (s, f, F) rather |
| 92 | var lastSnipe: (forward: Bool, chars: String, count: Int)? | 92 | /// than next to it (t, T); `keys` repeat it right after it, forward and back. |
| 93 | /// `;` and `,` repeat a snipe when one came after the last f, F, t or T. | 93 | var lastSnipe: (forward: Bool, chars: String, count: Int, consume: Bool, keys: (forward: KeyChord, backward: KeyChord))? |
| 94 | var lastWasSnipe = false | 94 | /// Set after a snipe: its keys repeat it as the next key, as evil-snipe's transient map has it. |
| 95 | var snipeRepeatKeys: (forward: KeyChord, backward: KeyChord)? | ||
| 95 | var lastSearch: (pattern: String, forward: Bool)? | 96 | var lastSearch: (pattern: String, forward: Bool)? |
| 96 | /// The keys and inserted text of the last change, for `.`. | 97 | /// The keys and inserted text of the last change, for `.`. |
| 97 | var lastChange: (keys: [KeyChord], inserted: String?)? | 98 | var lastChange: (keys: [KeyChord], inserted: String?)? |
| @@ -129,6 +130,11 @@ public struct Vim: Sendable { | |||
| 129 | case .insert: | 130 | case .insert: |
| 130 | return insertKey(key, context) | 131 | return insertKey(key, context) |
| 131 | case .normal, .visual, .visualLine, .visualBlock: | 132 | case .normal, .visual, .visualLine, .visualBlock: |
| 133 | var key = key | ||
| 134 | if pending.isEmpty, let repeatKeys = snipeRepeatKeys { | ||
| 135 | if key == repeatKeys.forward { key = KeyChord(";") } else if key == repeatKeys.backward { key = KeyChord(",") } | ||
| 136 | } | ||
| 137 | if pending.isEmpty { snipeRepeatKeys = nil } | ||
| 132 | pending.append(key) | 138 | pending.append(key) |
| 133 | let keys = pending | 139 | let keys = pending |
| 134 | caretForApply = context.caret | 140 | caretForApply = context.caret |
| @@ -611,36 +617,23 @@ public struct Vim: Sendable { | |||
| 611 | return .invalid | 617 | return .invalid |
| 612 | } | 618 | } |
| 613 | case ("f", []), ("F", []), ("t", []), ("T", []): | 619 | case ("f", []), ("F", []), ("t", []), ("T", []): |
| 620 | // evil-snipe-override-mode: one-character snipes. | ||
| 614 | guard keys.count >= 2 else { return .incomplete } | 621 | guard keys.count >= 2 else { return .incomplete } |
| 615 | let char = keys[1].key == "SPC" ? " " : keys[1].key | 622 | let char = keys[1].key == "SPC" ? " " : keys[1].key == "TAB" ? "\t" : keys[1].key |
| 616 | guard char.count == 1 else { return .invalid } | 623 | guard char.count == 1 else { return .invalid } |
| 617 | lastFind = (Character(key.key), char) | 624 | let forward = key.key == "f" || key.key == "t" |
| 618 | lastWasSnipe = false | 625 | lastSnipe = (forward, char, n, key.key == "f" || key.key == "F", (KeyChord(key.key.lowercased()), KeyChord(key.key.uppercased()))) |
| 619 | guard let p = find(Character(key.key), char, buffer, c, count: n) else { return .invalid } | 626 | return snipe(forward: forward, count: n, buffer, c, operatorPending: operatorPending, repeating: false) |
| 620 | return .moved(p, key.key == "f" || key.key == "t" ? .inclusive : .exclusive) | 627 | case (";", []), (",", []): |
| 621 | case (";", []) where lastWasSnipe, (",", []) where lastWasSnipe: | ||
| 622 | // evil-snipe-repeat, with Doom's `evil-snipe-repeat-scope` visible. | 628 | // evil-snipe-repeat, with Doom's `evil-snipe-repeat-scope` visible. |
| 623 | guard let last = lastSnipe else { return .invalid } | 629 | guard let last = lastSnipe else { return .invalid } |
| 624 | let forward = key.key == ";" ? last.forward : !last.forward | 630 | let forward = key.key == ";" ? last.forward : !last.forward |
| 625 | let scope = contextVisible.map { min($0.lowerBound, buffer.length)..<min($0.upperBound, buffer.length) } | 631 | return snipe(forward: forward, count: n * last.count, buffer, c, operatorPending: operatorPending, repeating: true) |
| 626 | guard let p = snipe(forward: forward, last.chars, buffer, c, count: n * last.count, scope: scope) else { return .invalid } | ||
| 627 | return .moved(p, .exclusive) | ||
| 628 | case (";", []), (",", []): | ||
| 629 | guard let last = lastFind else { return .invalid } | ||
| 630 | var kind = last.kind | ||
| 631 | if key.key == "," { | ||
| 632 | kind = ["f": "F", "F": "f", "t": "T", "T": "t"][kind]! | ||
| 633 | } | ||
| 634 | guard let p = find(kind, last.char, buffer, c, count: n, repeating: true) else { return .invalid } | ||
| 635 | return .moved(p, kind == "f" || kind == "t" ? .inclusive : .exclusive) | ||
| 636 | case ("s", []) where !operatorPending, ("S", []) where !operatorPending: | 632 | case ("s", []) where !operatorPending, ("S", []) where !operatorPending: |
| 637 | // evil-snipe with `evil-snipe-scope` line. | ||
| 638 | guard keys.count >= 3 else { return .incomplete } | 633 | guard keys.count >= 3 else { return .incomplete } |
| 639 | let chars = keys[1].key + keys[2].key | 634 | let chars = keys[1].key + keys[2].key |
| 640 | lastSnipe = (key.key == "s", chars, n) | 635 | lastSnipe = (key.key == "s", chars, n, true, (KeyChord("s"), KeyChord("S"))) |
| 641 | lastWasSnipe = true | 636 | return snipe(forward: key.key == "s", count: n, buffer, c, operatorPending: operatorPending, repeating: false) |
| 642 | guard let p = snipe(forward: key.key == "s", chars, buffer, c, count: n) else { return .invalid } | ||
| 643 | return .moved(p, .exclusive) | ||
| 644 | case ("%", []): | 637 | case ("%", []): |
| 645 | guard let p = buffer.matchingBracket(c) else { return .invalid } | 638 | guard let p = buffer.matchingBracket(c) else { return .invalid } |
| 646 | return .moved(p, .inclusive) | 639 | return .moved(p, .inclusive) |
| @@ -686,51 +679,68 @@ public struct Vim: Sendable { | |||
| 686 | } | 679 | } |
| 687 | } | 680 | } |
| 688 | 681 | ||
| 689 | func find(_ kind: Character, _ char: String, _ buffer: Buffer, _ c: Int, count n: Int, repeating: Bool = false) -> Int? { | 682 | /// `evil-snipe--seek` for the last snipe: the `n`th match from `c` in the line after (or |
| 690 | let target = char.utf16.first! | 683 | /// before) the search start, or for a repeat in the visible text, with smart case and |
| 691 | let forward = kind == "f" || kind == "t" | 684 | /// `evil-snipe-skip-leading-whitespace`. |
| 692 | var p = c | 685 | mutating func snipe(forward: Bool, count n: Int, _ buffer: Buffer, _ c: Int, operatorPending: Bool, repeating: Bool) -> MotionResult { |
| 693 | for i in 0..<n { | 686 | guard let last = lastSnipe else { return .invalid } |
| 694 | var q = p | 687 | // f, s and F search from the next or this character, t and T one further. |
| 695 | // Repeating t/T starts past the character it stopped before. | 688 | let start = forward ? c + (last.consume ? 1 : 2) : c - (last.consume ? 0 : 1) |
| 696 | let skip = repeating && i == 0 && (kind == "t" || kind == "T") ? 1 : 0 | 689 | guard start >= 0, start <= buffer.length else { return .invalid } |
| 697 | if forward { | 690 | var bound = forward ? buffer.lineEnd(start) : buffer.lineStart(start) |
| 698 | q += 1 + skip | 691 | if repeating, let visible = contextVisible { |
| 699 | while q < buffer.lineEnd(c), buffer.char(q) != target { q += 1 } | 692 | bound = forward ? min(visible.upperBound, buffer.length) - 1 : min(visible.lowerBound, start) |
| 700 | guard q < buffer.lineEnd(c) else { return nil } | 693 | } |
| 701 | } else { | 694 | let blankKeys = last.chars.first == " " || last.chars.first == "\t" |
| 702 | q -= 1 + skip | 695 | var pattern = NSRegularExpression.escapedPattern(for: last.chars) |
| 703 | while q >= buffer.lineStart(c), buffer.char(q) != target { q -= 1 } | 696 | if blankKeys, let ch = buffer.char(start), Buffer.isBlank(ch) { pattern += "[^ \t]" } |
| 704 | guard q >= buffer.lineStart(c) else { return nil } | 697 | let regex = try! NSRegularExpression(pattern: pattern, options: last.chars == last.chars.lowercased() ? .caseInsensitive : []) |
| 698 | var match: NSRange? | ||
| 699 | if forward, start <= bound { | ||
| 700 | var from = start | ||
| 701 | for _ in 0..<n { | ||
| 702 | match = regex.firstMatch(in: buffer.ns as String, range: NSRange(location: from, length: bound - from))?.range | ||
| 703 | guard let found = match else { break } | ||
| 704 | from = NSMaxRange(found) | ||
| 705 | } | ||
| 706 | } else if !forward { | ||
| 707 | var from = start | ||
| 708 | for _ in 0..<n { | ||
| 709 | match = nil | ||
| 710 | var p = from - 1 | ||
| 711 | while p >= bound, match == nil { | ||
| 712 | match = regex.firstMatch(in: buffer.ns as String, options: .anchored, range: NSRange(location: p, length: from - p))?.range | ||
| 713 | p -= 1 | ||
| 714 | } | ||
| 715 | guard let found = match else { break } | ||
| 716 | from = found.location | ||
| 705 | } | 717 | } |
| 706 | p = q | ||
| 707 | } | 718 | } |
| 708 | if kind == "t" { return p - 1 } | 719 | guard let found = match else { |
| 709 | if kind == "T" { return p + 1 } | 720 | if repeating { snipeRepeatKeys = last.keys } |
| 710 | return p | 721 | return .invalid |
| 711 | } | 722 | } |
| 712 | 723 | if !operatorPending { snipeRepeatKeys = last.keys } | |
| 713 | /// Searches the caret's line, or `scope` when given. | 724 | var end = NSMaxRange(found) |
| 714 | func snipe(forward: Bool, _ chars: String, _ buffer: Buffer, _ c: Int, count n: Int, scope: Range<Int>? = nil) -> Int? { | 725 | var length = found.length |
| 715 | let range = scope ?? buffer.lineStart(c)..<buffer.lineEnd(c) | 726 | if blankKeys { |
| 716 | let line = buffer.substring(range) as NSString | 727 | end -= 1 |
| 717 | let start = range.lowerBound | 728 | length -= 1 |
| 718 | guard range.lowerBound <= c, c <= range.upperBound else { return nil } | 729 | } |
| 719 | let smart = chars == chars.lowercased() | 730 | let visual = mode == .visual || mode == .visualLine || mode == .visualBlock |
| 720 | var p = c - start | 731 | var p: Int |
| 721 | for _ in 0..<n { | 732 | if !forward { |
| 722 | let range: NSRange | 733 | p = last.consume ? found.location : end |
| 723 | if forward { | 734 | } else { |
| 724 | guard p + 1 < line.length else { return nil } | 735 | p = visual ? (last.consume ? end : found.location) : operatorPending ? end : found.location |
| 725 | range = line.range(of: chars, options: smart ? .caseInsensitive : [], range: NSRange(location: p + 1, length: line.length - p - 1)) | 736 | if last.consume { |
| 737 | if visual { p -= 1 } | ||
| 726 | } else { | 738 | } else { |
| 727 | guard p > 0 else { return nil } | 739 | p -= length |
| 728 | range = line.range(of: chars, options: smart ? [.caseInsensitive, .backwards] : .backwards, range: NSRange(location: 0, length: p)) | 740 | if length > 1, !operatorPending { p += 1 } |
| 729 | } | 741 | } |
| 730 | guard range.location != NSNotFound else { return nil } | ||
| 731 | p = range.location | ||
| 732 | } | 742 | } |
| 733 | return start + p | 743 | return .moved(p, .exclusive) |
| 734 | } | 744 | } |
| 735 | 745 | ||
| 736 | // MARK: - Text objects | 746 | // MARK: - Text objects |
Sources/OrgCore/Keymap/VimExtras.swift +1 −1
| @@ -11,7 +11,7 @@ extension Vim { | |||
| 11 | /// Motions evil records in the jump list. | 11 | /// Motions evil records in the jump list. |
| 12 | static func isJump(_ keys: [KeyChord]) -> Bool { | 12 | static func isJump(_ keys: [KeyChord]) -> Bool { |
| 13 | guard let key = keys.first, key.modifiers.isEmpty else { return false } | 13 | guard let key = keys.first, key.modifiers.isEmpty else { return false } |
| 14 | if ["G", "%", "{", "}", "n", "N", "*", "#", "'", "`", "H", "M", "L"].contains(key.key) { return true } | 14 | if ["G", "%", "{", "}", "n", "N", "*", "#", "'", "`", "H", "M", "L", "s", "S", "f", "F", "t", "T"].contains(key.key) { return true } |
| 15 | return key.key == "g" && keys.count > 1 && keys[1].key == "g" | 15 | return key.key == "g" && keys.count > 1 && keys[1].key == "g" |
| 16 | } | 16 | } |
| 17 | 17 | ||
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/OrgCoreTests/TimestampCommandTests.swift +46 −2
| @@ -6,10 +6,10 @@ struct TimestampCommandTests { | |||
| 6 | static let now = ReadDateTests.now | 6 | static let now = ReadDateTests.now |
| 7 | static let calendar = ReadDateTests.utc | 7 | static let calendar = ReadDateTests.utc |
| 8 | 8 | ||
| 9 | func run(_ command: any OrgCommand, _ text: String, caret: Int, answer: String? = nil) -> (text: String, caret: Int, failure: String?) { | 9 | func run(_ command: any OrgCommand, _ text: String, caret: Int, answer: String? = nil, lastCommand: String? = nil) -> (text: String, caret: Int, failure: String?) { |
| 10 | let context = EditContext( | 10 | let context = EditContext( |
| 11 | revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], | 11 | revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], |
| 12 | now: Self.now, calendar: Self.calendar, answers: answer.map { ["date": $0] } ?? [:] | 12 | now: Self.now, calendar: Self.calendar, answers: answer.map { ["date": $0] } ?? [:], lastCommand: lastCommand |
| 13 | ) | 13 | ) |
| 14 | switch command.run(in: context) { | 14 | switch command.run(in: context) { |
| 15 | case .commit(let result): | 15 | case .commit(let result): |
| @@ -100,6 +100,50 @@ struct TimestampCommandTests { | |||
| 100 | #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ") | 100 | #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ") |
| 101 | } | 101 | } |
| 102 | 102 | ||
| 103 | /// `org-timestamp` in and around date ranges: the start before the dashes, the end from the | ||
| 104 | /// second dash on, and twice in a row a new range wherever there is a stamp to default from. | ||
| 105 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | ||
| 106 | func timestampsInRangesMatchEmacs() throws { | ||
| 107 | try #require(EmacsOracle.isAvailable) | ||
| 108 | let texts = [ | ||
| 109 | "* a\n:LOGBOOK:\nCLOCK: [2026-09-01 Tue 10:00]--[2026-09-01 Tue 11:30] => 1:30\n:END:\n", | ||
| 110 | "x <2026-09-01 Tue>--<2026-09-03 Thu> y\n", | ||
| 111 | "[2026-09-01 Tue]-[2026-09-02 Wed]\n", | ||
| 112 | "<2026-09-01 Tue 10:00>---<2026-09-01 Tue 12:00 +1w>\n", | ||
| 113 | ] | ||
| 114 | var cases: [EmacsOracle.Case] = [] | ||
| 115 | var ours: [(label: String, text: String, caret: Int, failed: Bool)] = [] | ||
| 116 | for text in texts { | ||
| 117 | for caret in EmacsOracle.positions(text) { | ||
| 118 | for (command, form) in [(InsertTimestamp(), "(org-timestamp nil)"), (InsertTimestamp(inactive: true), "(org-timestamp nil t)")] { | ||
| 119 | for answer in ["", "+1", "10:00", "++2d"] { | ||
| 120 | for repeated in [false, true] { | ||
| 121 | let commands = repeated ? "(last-command 'org-timestamp) (this-command 'org-timestamp)" : "" | ||
| 122 | let wrapped = frozenClock(1_789_000_000, """ | ||
| 123 | (cl-letf (((symbol-function 'read-string) (lambda (&rest _) "\(answer)"))) | ||
| 124 | (let ((org-read-date-popup-calendar nil) \(commands)) \(form))) | ||
| 125 | """) | ||
| 126 | cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: wrapped)) | ||
| 127 | let result = run(command, text, caret: caret, answer: answer, lastCommand: repeated ? "org.timestamp.active" : nil) | ||
| 128 | ours.append(("\(command.id) \(answer.debugDescription)\(repeated ? " repeated" : "") at \(caret) in \(text.debugDescription)", result.text, result.caret, result.failure != nil)) | ||
| 129 | } | ||
| 130 | } | ||
| 131 | } | ||
| 132 | } | ||
| 133 | } | ||
| 134 | var mismatches = 0 | ||
| 135 | for (mine, emacs) in zip(ours, try EmacsOracle.run(cases)) { | ||
| 136 | let failed = !emacs.error.isEmpty | ||
| 137 | let caret = EmacsOracle.offset(emacs.point, in: emacs.text) | ||
| 138 | guard mine.failed != failed || (!failed && (mine.text != emacs.text || mine.caret != caret)) else { continue } | ||
| 139 | mismatches += 1 | ||
| 140 | if mismatches <= 12 { | ||
| 141 | Issue.record("\(mine.label)\n ours: \(mine.failed ? "failed" : "\(mine.text.debugDescription) @\(mine.caret)")\n emacs: \(failed ? emacs.error : "\(emacs.text.debugDescription) @\(caret)")") | ||
| 142 | } | ||
| 143 | } | ||
| 144 | #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ") | ||
| 145 | } | ||
| 146 | |||
| 103 | /// Shifting a timestamp on a CLOCK line rewrites the duration (`org-clock-update-time-maybe`). | 147 | /// Shifting a timestamp on a CLOCK line rewrites the duration (`org-clock-update-time-maybe`). |
| 104 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | 148 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) |
| 105 | func clockShiftsMatchEmacs() throws { | 149 | func clockShiftsMatchEmacs() throws { |
Tests/OrgCoreTests/VimExtrasTests.swift +8
| @@ -165,6 +165,8 @@ struct VimExtrasTests { | |||
| 165 | ("a\nb\nc\nd\n", 2, "g q j"), | 165 | ("a\nb\nc\nd\n", 2, "g q j"), |
| 166 | ("a\nb\nc\n", 0, "V j g q"), | 166 | ("a\nb\nc\n", 0, "V j g q"), |
| 167 | ("a\nb\n\nc\nd\n", 0, "g q j j ."), | 167 | ("a\nb\n\nc\nd\n", 0, "g q j j ."), |
| 168 | ("one two three\nfour five \nsix\n", 0, "g q G"), | ||
| 169 | ("End. Next word, " + long + " \nmore\n", 3, "g w j"), | ||
| 168 | ] | 170 | ] |
| 169 | }() | 171 | }() |
| 170 | 172 | ||
| @@ -201,6 +203,12 @@ struct VimExtrasTests { | |||
| 201 | (dolist (d '("evil" "goto-chg" "evil-surround" "evil-org-mode")) (add-to-list 'load-path (concat \(lispString(repos.path)) "/" d))) | 203 | (dolist (d '("evil" "goto-chg" "evil-surround" "evil-org-mode")) (add-to-list 'load-path (concat \(lispString(repos.path)) "/" d))) |
| 202 | (require 'evil) | 204 | (require 'evil) |
| 203 | (require 'evil-surround) | 205 | (require 'evil-surround) |
| 206 | (defun oracle-no-squeeze (fn &rest args) | ||
| 207 | (cl-letf* ((fill (symbol-function 'fill-region)) | ||
| 208 | ((symbol-function 'fill-region) (lambda (from to &optional justify _nosqueeze to-eop) (funcall fill from to justify t to-eop)))) | ||
| 209 | (apply fn args))) | ||
| 210 | (advice-add 'evil-fill :around #'oracle-no-squeeze) | ||
| 211 | (advice-add 'evil-fill-and-move :around #'oracle-no-squeeze) | ||
| 204 | (setq kill-ring nil) | 212 | (setq kill-ring nil) |
| 205 | (switch-to-buffer (current-buffer)) | 213 | (switch-to-buffer (current-buffer)) |
| 206 | \(org ? "(org-mode) (require 'evil-org) (evil-org-set-key-theme '(textobjects navigation)) (evil-org-mode 1)" : "") | 214 | \(org ? "(org-mode) (require 'evil-org) (evil-org-set-key-theme '(textobjects navigation)) (evil-org-mode 1)" : "") |
Tests/OrgCoreTests/VimTests.swift +34 −1
| @@ -193,6 +193,36 @@ struct VimTests { | |||
| 193 | ("one two three\n", 0, "3 l v l d"), | 193 | ("one two three\n", 0, "3 l v l d"), |
| 194 | ("one two three\n", 8, "v b d"), | 194 | ("one two three\n", 8, "v b d"), |
| 195 | ("one two three\n", 0, "v 2 e c X ESC"), | 195 | ("one two three\n", 0, "v 2 e c X ESC"), |
| 196 | // evil-snipe-override-mode: f, F, t and T snipe one character, ; and , repeat over | ||
| 197 | // the visible text, and the snipe's own keys repeat it right after. | ||
| 198 | ("abc abc\nxbz abc\n", 0, "f c ; ; x"), | ||
| 199 | ("abc abc\nxbz abc\n", 0, "f c f f x"), | ||
| 200 | ("abc abc\nxbz abc\n", 0, "f c f F x"), | ||
| 201 | ("abc abc abc\n", 10, "F b f x"), | ||
| 202 | ("abc abc abc abc\n", 10, "F b F x"), | ||
| 203 | ("abc abc\nxbz abc\n", 14, "T a ; x"), | ||
| 204 | ("abc abc\nxbz abc\n", 0, "t c t x"), | ||
| 205 | ("abc abc\nxbz abc\n", 0, "f c l f c x"), | ||
| 206 | ("aAa\n", 0, "f a x"), | ||
| 207 | ("aAa\n", 0, "f A x"), | ||
| 208 | ("abab\n", 0, "t b x"), | ||
| 209 | ("ab ab\n", 4, "T a x"), | ||
| 210 | ("abc\nxbz\n", 2, "t z x"), | ||
| 211 | ("a b c\n", 0, "f SPC x"), | ||
| 212 | ("a b\n", 0, "d f SPC"), | ||
| 213 | ("a b c\n", 0, "t SPC x"), | ||
| 214 | ("one two three\n", 0, "d t e"), | ||
| 215 | ("one two three\n", 0, "c f o X ESC"), | ||
| 216 | ("one two three\n", 12, "d F o"), | ||
| 217 | ("one two three\n", 12, "d T o"), | ||
| 218 | ("one two three\n", 0, "v f e d"), | ||
| 219 | ("one two three\n", 0, "v t e d"), | ||
| 220 | ("one two three\n", 0, "d f e ."), | ||
| 221 | ("a1a2a3a4a5a6\n", 0, "2 f a ; x"), | ||
| 222 | ("ab ab ab\n", 0, "s a b s S x"), | ||
| 223 | ("ab ab ab\n", 0, "s a b d ;"), | ||
| 224 | ("ab ab ab\n", 0, "v s a b d"), | ||
| 225 | ("ab x\ncd ab\nab\n", 5, "s a b ; , x"), | ||
| 196 | ] | 226 | ] |
| 197 | 227 | ||
| 198 | /// Thirty lines of seven characters: line `i` starts at `7 * i`. | 228 | /// Thirty lines of seven characters: line `i` starts at `7 * i`. |
| @@ -272,11 +302,12 @@ struct VimTests { | |||
| 272 | (add-to-list 'load-path "\(evil.path)/evil-snipe") | 302 | (add-to-list 'load-path "\(evil.path)/evil-snipe") |
| 273 | (require 'evil) | 303 | (require 'evil) |
| 274 | (require 'evil-snipe) | 304 | (require 'evil-snipe) |
| 275 | (setq evil-snipe-smart-case t evil-snipe-scope 'line) | 305 | (setq evil-snipe-smart-case t evil-snipe-scope 'line evil-snipe-repeat-scope 'visible evil-snipe-char-fold t) |
| 276 | (setq kill-ring nil) | 306 | (setq kill-ring nil) |
| 277 | (switch-to-buffer (current-buffer)) | 307 | (switch-to-buffer (current-buffer)) |
| 278 | (evil-local-mode 1) | 308 | (evil-local-mode 1) |
| 279 | (evil-snipe-local-mode 1) | 309 | (evil-snipe-local-mode 1) |
| 310 | (evil-snipe-override-local-mode 1) | ||
| 280 | (evil-normal-state) | 311 | (evil-normal-state) |
| 281 | (execute-kbd-macro (kbd "\(keys.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"").replacingOccurrences(of: "ESC", with: "<escape>").replacingOccurrences(of: "DEL", with: "DEL"))")) | 312 | (execute-kbd-macro (kbd "\(keys.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"").replacingOccurrences(of: "ESC", with: "<escape>").replacingOccurrences(of: "DEL", with: "DEL"))")) |
| 282 | (let ((p (point)) (s evil-state)) | 313 | (let ((p (point)) (s evil-state)) |
| @@ -288,6 +319,8 @@ struct VimTests { | |||
| 288 | let results = try EmacsOracle.run(forms) | 319 | let results = try EmacsOracle.run(forms) |
| 289 | for ((text, point, keys), result) in zip(Self.cases, results) { | 320 | for ((text, point, keys), result) in zip(Self.cases, results) { |
| 290 | var driver = VimDriver(text, caret: point) | 321 | var driver = VimDriver(text, caret: point) |
| 322 | // A batch Emacs window shows the whole buffer. | ||
| 323 | driver.visible = 0..<text.utf16.count | ||
| 291 | driver.type(Self.keys(keys)) | 324 | driver.type(Self.keys(keys)) |
| 292 | guard let marker = result.text.range(of: "\n@@STATE ") else { | 325 | guard let marker = result.text.range(of: "\n@@STATE ") else { |
| 293 | Issue.record("\(keys): \(result.error)") | 326 | Issue.record("\(keys): \(result.error)") |
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] = [] |