Keymap and Vim fixes from the manual review !162

merged merged by cmc on 2026-10-08 04:12 UTC · krz/orgstar:keys-vim-fixes into main

17 files changed, +696 −67

Layout: unified · split

Sources/OrgApp/KeymapLoader.swift +4 −3
@@ -7,12 +7,13 @@ public enum KeymapLoader {
77 public static var userFile: URL { UserConfig.file("keymap.toml") }
88
99 /// The preset with the user's file on top, and the problems found in the file. A missing
10 /// file adds nothing; a file that isn't valid TOML adds nothing and is reported.
11 public static func load(_ preset: KeymapPreset, userFile: URL = userFile) -> (keymap: Keymap, problems: [String]) {
10 /// file adds nothing; a file that isn't valid TOML adds nothing and is reported. With
11 /// `commands`, bindings of other command ids are reported and skipped.
12 public static func load(_ preset: KeymapPreset, userFile: URL = userFile, commands: Set<String>? = nil) -> (keymap: Keymap, problems: [String]) {
1213 guard let text = try? String(contentsOf: userFile, encoding: .utf8) else { return (preset.keymap, []) }
1314 var problems: [String] = []
1415 do {
15 let user = try Keymap(toml: text, problems: &problems)
16 let user = try Keymap(toml: text, problems: &problems, commands: commands)
1617 return (.layered([preset.keymap, user]), problems)
1718 } catch {
1819 return (preset.keymap, ["\(error)"])
Sources/OrgCore/Commands/Fill.swift +51
@@ -467,6 +467,28 @@ extension EmacsBuffer {
467467 }
468468 }
469469
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
470492 /// Where a paragraph's text starts: past affiliated keywords, and for a paragraph that
471493 /// opens an item, past the bullet.
472494 func paragraphContentsBegin(_ node: Element) -> Int {
@@ -482,3 +504,32 @@ public struct FillParagraph: OrgCommand {
482504 public func applies(in context: EditContext) -> Bool { true }
483505 public func run(in context: EditContext) -> CommandStep { commitBuffer(context) { $0.fillParagraph() } }
484506}
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.
511public 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/Keymap.swift +22 −7
@@ -17,6 +17,9 @@ public struct KeyBinding: Sendable, Equatable {
1717 }
1818
1919 public var unbinds: Bool { command == "none" }
20
21 /// Modal states a binding's `mode` can name.
22 public static let modes: Set<String> = ["normal", "insert", "visual"]
2023}
2124
2225/// Bindings in priority order: later bindings win over earlier ones for the same keys.
@@ -29,8 +32,9 @@ public struct Keymap: Sendable, Equatable {
2932 }
3033
3134 /// Reads `[[bind]]` tables. Entries that can't be used are skipped and reported in
32 /// `problems`; a file that isn't valid TOML throws.
33 public init(toml: String, problems: inout [String]) throws {
35 /// `problems`; a file that isn't valid TOML throws. With `commands`, a command id outside
36 /// it (other than `none`) is such an entry.
37 public init(toml: String, problems: inout [String], commands: Set<String>? = nil) throws {
3438 bindings = []
3539 for table in try TOML.parse(toml) where !(table.name.isEmpty && table.values.isEmpty) {
3640 guard table.name == "bind", table.isArrayElement else {
@@ -45,12 +49,21 @@ public struct Keymap: Sendable, Equatable {
4549 problems.append("line \(table.line): missing command")
4650 continue
4751 }
52 if let commands, command != "none", !commands.contains(command) {
53 problems.append("line \(table.line): unknown command \(command)")
54 continue
55 }
4856 let when = table.values["when"]?.string
4957 if let when, !KeyContext.names.contains(when) {
5058 problems.append("line \(table.line): unknown context \(when)")
5159 continue
5260 }
53 bindings.append(KeyBinding(keys: keys, command: command, when: when, mode: table.values["mode"]?.string))
61 let mode = table.values["mode"]?.string
62 if let mode, !KeyBinding.modes.contains(mode) {
63 problems.append("line \(table.line): unknown mode \(mode)")
64 continue
65 }
66 bindings.append(KeyBinding(keys: keys, command: command, when: when, mode: mode))
5467 }
5568 }
5669
@@ -79,8 +92,9 @@ public struct Keymap: Sendable, Equatable {
7992 }
8093
8194 /// What can follow `prefix`: each next key with the command it runs, or nil when it
82 /// leads to a longer sequence. Sorted by key.
83 public func continuations(of prefix: [KeyChord], mode: String? = nil) -> [(key: KeyChord, command: String?)] {
95 /// leads to a longer sequence. Sorted by key. The command is the first candidate that
96 /// `runs` accepts, as dispatch picks it, or the first candidate when none does.
97 public func continuations(of prefix: [KeyChord], mode: String? = nil, runs: (KeyBinding) -> Bool = { _ in true }) -> [(key: KeyChord, command: String?)] {
8498 var seen: [KeyChord: String?] = [:]
8599 for binding in bindings where binding.mode == mode && binding.keys.count > prefix.count && Array(binding.keys.prefix(prefix.count)) == prefix {
86100 let next = binding.keys[prefix.count]
@@ -88,8 +102,9 @@ public struct Keymap: Sendable, Equatable {
88102 guard seen[next] == nil else { continue }
89103 if isPrefix(sequence, mode: mode) {
90104 seen[next] = .some(nil)
91 } else if let command = candidates(for: sequence, mode: mode).first?.command {
92 seen[next] = .some(command)
105 } else {
106 let found = candidates(for: sequence, mode: mode)
107 if let command = (found.first(where: runs) ?? found.first)?.command { seen[next] = .some(command) }
93108 }
94109 }
95110 return seen.map { (key: $0.key, command: $0.value) }.sorted { $0.key.description < $1.key.description }
Sources/OrgCore/Keymap/Presets.swift +4 −12
@@ -67,7 +67,7 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
6767 || (first.modifiers.contains(.meta) && ["<left>", "<right>", "<up>", "<down>", "RET"].contains(first.key))
6868 || (first.modifiers.contains(.shift) && ["<left>", "<right>", "<up>", "<down>"].contains(first.key))
6969 || (first.modifiers == [.control] && first.key == "RET")
70 || keys == "M-x"
70 || keys == "M-x" || keys == "M-q"
7171 guard orgKey else { continue }
7272 for mode in ["normal", "insert", "visual"] { bind(keys, binding.command, when: binding.when, mode: mode) }
7373 // evil-org: M-h/j/k/l and M-H/J/K/L in normal, visual and insert.
@@ -441,15 +441,15 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
441441 mode = "normal"
442442 [[bind]]
443443 keys = "z a"
444 command = "org.cycle"
444 command = "org.fold.toggle"
445445 mode = "normal"
446446 [[bind]]
447447 keys = "z c"
448 command = "org.cycle"
448 command = "org.fold.close"
449449 mode = "normal"
450450 [[bind]]
451451 keys = "z o"
452 command = "org.cycle"
452 command = "org.fold.open"
453453 mode = "normal"
454454 [[bind]]
455455 keys = "z A"
@@ -460,14 +460,6 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
460460 command = "org.heading.up"
461461 mode = "normal"
462462 [[bind]]
463 keys = "g j"
464 command = "org.heading.forward-same-level"
465 mode = "normal"
466 [[bind]]
467 keys = "g k"
468 command = "org.heading.backward-same-level"
469 mode = "normal"
470 [[bind]]
471463 keys = "] h"
472464 command = "org.heading.forward-same-level"
473465 mode = "normal"
Sources/OrgCore/Keymap/Vim.swift +73 −14
@@ -32,6 +32,11 @@ public struct Vim: Sendable {
3232 case setClipboard(String)
3333 /// Ask for a search pattern (`/`, `?`) or an ex command (`:`).
3434 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?)
38 /// An ex command to run, as if typed after `:` (`ZZ` is `:wq`, `ZQ` is `:q!`).
39 case ex(String)
3540 case message(String)
3641 /// `m`: a mark at this offset, for the editor to keep through edits.
3742 case setMark(Character, Int)
@@ -58,26 +63,35 @@ public struct Vim: Sendable {
5863 public var halfPage: Int
5964 /// Marks where the editor keeps them now; `'` and `` ` `` are the last jump's start.
6065 public var marks: [Character: Int]
66 /// The text the window shows, for H, M and L; nil when unknown.
67 public var visible: Range<Int>?
68 /// An Org buffer, where evil-org's keys apply.
69 public var org: Bool
6170
62 public init(text: String, caret: Int, clipboard: String? = nil, halfPage: Int = 15, marks: [Character: Int] = [:]) {
71 public init(text: String, caret: Int, clipboard: String? = nil, halfPage: Int = 15, marks: [Character: Int] = [:], visible: Range<Int>? = nil, org: Bool = false) {
72 self.org = org
6373 self.text = text
6474 self.caret = caret
6575 self.clipboard = clipboard
6676 self.halfPage = halfPage
6777 self.marks = marks
78 self.visible = visible
6879 }
6980 }
7081
7182 public internal(set) var mode: Mode = .normal
7283 /// Keys of the command being typed.
7384 public private(set) var pending: [KeyChord] = []
74 var registers: [Character: (text: String, linewise: Bool)] = [:]
85 /// Named registers. Evil's are global: an editor with several engines shares them.
86 public var registers: [Character: (text: String, linewise: Bool)] = [:]
7587 /// What the clipboard held when this engine last wrote it, and whether that was whole lines.
76 var clipboardRecord: (text: String, linewise: Bool)?
88 public var clipboardRecord: (text: String, linewise: Bool)?
7789 var anchor = 0
7890 var goalColumn: Int?
7991 var lastFind: (kind: Character, char: String)?
80 var lastSnipe: (forward: Bool, chars: String)?
92 var lastSnipe: (forward: Bool, chars: String, count: Int)?
93 /// `;` and `,` repeat a snipe when one came after the last f, F, t or T.
94 var lastWasSnipe = false
8195 var lastSearch: (pattern: String, forward: Bool)?
8296 /// The keys and inserted text of the last change, for `.`.
8397 var lastChange: (keys: [KeyChord], inserted: String?)?
@@ -96,6 +110,10 @@ public struct Vim: Sendable {
96110 var blockInsert: (lines: [Int], column: Int, pad: Bool)?
97111 /// The editor's marks for this key.
98112 var contextMarks: [Character: Int] = [:]
113 /// The context's half page and visible text for this key.
114 var contextHalfPage = 15
115 var contextVisible: Range<Int>?
116 var contextOrg = false
99117 /// Keys are going into a macro register.
100118 public internal(set) var recording = false
101119 var lastMacro: Character?
@@ -115,6 +133,9 @@ public struct Vim: Sendable {
115133 let keys = pending
116134 caretForApply = context.caret
117135 contextMarks = context.marks
136 contextHalfPage = context.halfPage
137 contextVisible = context.visible
138 contextOrg = context.org
118139 var buffer = Buffer(context)
119140 switch parse(keys, &buffer, context) {
120141 case .incomplete:
@@ -253,7 +274,7 @@ public struct Vim: Sendable {
253274 op = o
254275 } else if first.key == "g", first.modifiers.isEmpty {
255276 guard let second = rest.dropFirst().first else { return .incomplete }
256 if ["~", "u", "U", "c"].contains(second.key), second.modifiers.isEmpty {
277 if ["~", "u", "U", "c", "q", "w"].contains(second.key), second.modifiers.isEmpty {
257278 op = "g" + second.key
258279 afterOp = rest.dropFirst(2)
259280 }
@@ -455,8 +476,8 @@ public struct Vim: Sendable {
455476 case ("Z", []):
456477 guard rest.count >= 2 else { return .incomplete }
457478 switch rest[rest.startIndex + 1].key {
458 case "Z": return .done([.command("app.save")])
459 case "Q": return .done([.message("ZQ isn't available; use the window's close button")])
479 case "Z": return .done([.ex("wq")])
480 case "Q": return .done([.ex("q!")])
460481 default: return .invalid
461482 }
462483 default:
@@ -464,7 +485,7 @@ public struct Vim: Sendable {
464485 }
465486 }
466487
467 if mode == .normal, rest.count == 2, first == KeyChord("g"), rest.last == KeyChord("j") || rest.last == KeyChord("k") {
488 if mode == .normal, !contextOrg, rest.count == 2, first == KeyChord("g"), rest.last == KeyChord("j") || rest.last == KeyChord("k") {
468489 return .done([.visualLines(rest.last == KeyChord("j") ? n : -n)])
469490 }
470491
@@ -505,7 +526,7 @@ public struct Vim: Sendable {
505526 return .moved(min(limit, c + n), .exclusive)
506527 case ("j", []), ("k", []), ("<down>", []), ("<up>", []), ("d", [.control]), ("u", [.control]), ("+", []), ("-", []), ("RET", []):
507528 let down = ["j", "<down>", "+", "RET"].contains(key.key) || key == KeyChord("d", .control)
508 let lines = key.modifiers.contains(.control) ? (n == 1 ? 15 : n) : n
529 let lines = key.modifiers.contains(.control) ? (n == 1 ? contextHalfPage : n) : n
509530 let goal = goalColumn ?? column(c)
510531 var line = buffer.lineStart(c)
511532 var moved = 0
@@ -572,6 +593,11 @@ public struct Vim: Sendable {
572593 var p = c
573594 for _ in 0..<n { p = buffer.wordEndBackward(p, bigWord: keys[1].key == "E") }
574595 return .moved(p, .inclusive)
596 case "j" where contextOrg, "k" where contextOrg:
597 // evil-org: org-forward-element and org-backward-element, which take no count.
598 guard let p = Self.orgObject(keys[1].key, inner: false, buffer, c)?.range.lowerBound, p != c else { return .invalid }
599 goalColumn = nil
600 return .moved(p, .exclusive)
575601 case "j":
576602 return motion([KeyChord("j")], &buffer, &caret, count: n, operatorPending: operatorPending)
577603 case "k":
@@ -589,8 +615,16 @@ public struct Vim: Sendable {
589615 let char = keys[1].key == "SPC" ? " " : keys[1].key
590616 guard char.count == 1 else { return .invalid }
591617 lastFind = (Character(key.key), char)
618 lastWasSnipe = false
592619 guard let p = find(Character(key.key), char, buffer, c, count: n) else { return .invalid }
593620 return .moved(p, key.key == "f" || key.key == "t" ? .inclusive : .exclusive)
621 case (";", []) where lastWasSnipe, (",", []) where lastWasSnipe:
622 // evil-snipe-repeat, with Doom's `evil-snipe-repeat-scope` visible.
623 guard let last = lastSnipe else { return .invalid }
624 let forward = key.key == ";" ? last.forward : !last.forward
625 let scope = contextVisible.map { min($0.lowerBound, buffer.length)..<min($0.upperBound, buffer.length) }
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)
594628 case (";", []), (",", []):
595629 guard let last = lastFind else { return .invalid }
596630 var kind = last.kind
@@ -603,7 +637,8 @@ public struct Vim: Sendable {
603637 // evil-snipe with `evil-snipe-scope` line.
604638 guard keys.count >= 3 else { return .incomplete }
605639 let chars = keys[1].key + keys[2].key
606 lastSnipe = (key.key == "s", chars)
640 lastSnipe = (key.key == "s", chars, n)
641 lastWasSnipe = true
607642 guard let p = snipe(forward: key.key == "s", chars, buffer, c, count: n) else { return .invalid }
608643 return .moved(p, .exclusive)
609644 case ("%", []):
@@ -631,6 +666,21 @@ public struct Vim: Sendable {
631666 lastSearch = (pattern, key.key == "*")
632667 guard let p = buffer.search(pattern, from: buffer.wordStart(c), forward: key.key == "*", count: n) else { return .invalid }
633668 return .moved(p, .exclusive)
669 case ("H", []), ("M", []), ("L", []):
670 // The visible lines: H the count-th from the top, L from the bottom, M the middle.
671 guard let visible = contextVisible else { return .invalid }
672 goalColumn = nil
673 let lower = min(visible.lowerBound, buffer.length)
674 let last = buffer.lineStart(max(lower, min(visible.upperBound, buffer.length) - 1))
675 var lines: [Int] = []
676 var line = buffer.lineStart(lower)
677 while true {
678 lines.append(line)
679 guard line < last, buffer.lineEnd(line) < buffer.length else { break }
680 line = buffer.lineEnd(line) + 1
681 }
682 let index = key.key == "H" ? min(n, lines.count) - 1 : key.key == "L" ? max(0, lines.count - n) : (lines.count - 1) / 2
683 return .moved(buffer.firstNonBlank(lines[index]), .linewise)
634684 default:
635685 return .invalid
636686 }
@@ -660,9 +710,12 @@ public struct Vim: Sendable {
660710 return p
661711 }
662712
663 func snipe(forward: Bool, _ chars: String, _ buffer: Buffer, _ c: Int, count n: Int) -> Int? {
664 let line = buffer.substring(buffer.lineStart(c)..<buffer.lineEnd(c)) as NSString
665 let start = buffer.lineStart(c)
713 /// Searches the caret's line, or `scope` when given.
714 func snipe(forward: Bool, _ chars: String, _ buffer: Buffer, _ c: Int, count n: Int, scope: Range<Int>? = nil) -> Int? {
715 let range = scope ?? buffer.lineStart(c)..<buffer.lineEnd(c)
716 let line = buffer.substring(range) as NSString
717 let start = range.lowerBound
718 guard range.lowerBound <= c, c <= range.upperBound else { return nil }
666719 let smart = chars == chars.lowercased()
667720 var p = c - start
668721 for _ in 0..<n {
@@ -826,6 +879,12 @@ public struct Vim: Sendable {
826879 case "gc":
827880 lastChange = (wasVisual ? [] : keys, nil)
828881 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)]
829888 default:
830889 let transformed: String
831890 switch op {
@@ -914,7 +973,7 @@ public struct Vim: Sendable {
914973 caret += inserted.utf16.count
915974 }
916975 }
917 let produced = copy.feed(key, Context(text: text, caret: caret, clipboard: context.clipboard, halfPage: context.halfPage))
976 let produced = copy.feed(key, Context(text: text, caret: caret, clipboard: context.clipboard, halfPage: context.halfPage, visible: context.visible, org: context.org))
918977 for action in produced {
919978 switch action {
920979 case .edit(let edits, let to):
Sources/OrgCore/Keymap/VimExtras.swift +56 −5
@@ -253,14 +253,23 @@ extension Vim {
253253 var node: SyntaxNode?
254254 }
255255
256 static func orgObject(_ key: String, inner: Bool, _ buffer: Buffer, _ c: Int) -> (range: Range<Int>, linewise: Bool)? {
256 /// `j` and `k` are evil-org's `gj` and `gk` (`org-forward-element`, `org-backward-element`):
257 /// an empty range where they go, nil where they can't.
258 static func orgObject(_ key: String, inner: Bool, _ buffer: Buffer, _ position: Int) -> (range: Range<Int>, linewise: Bool)? {
257259 let tree = OrgParser.parse(buffer.ns as String)
260 /// The point the element functions look at, and the nodes containing it.
261 var c = position
258262 var chain: [SyntaxNode] = []
259 var node = tree.root
260 while let child = node.child(containing: c) {
261 chain.append(child)
262 node = child
263 func look(at p: Int) {
264 c = p
265 chain = []
266 var node = tree.root
267 while let child = node.child(containing: c) {
268 chain.append(child)
269 node = child
270 }
263271 }
272 look(at: position)
264273 /// The end of the line ending at or after `p`, without trailing blank lines.
265274 func trimmed(_ lower: Int, _ upper: Int) -> Int {
266275 var end = upper
@@ -353,7 +362,49 @@ extension Vim {
353362 if let contents = e.contents { return contents }
354363 return e.begin..<max(e.begin, trimmed(e.begin, e.end) - (buffer.char(trimmed(e.begin, e.end) - 1) == 10 ? 1 : 0))
355364 }
365 /// The stars of the heading on the line holding `p`, or nil.
366 func headingLevel(_ p: Int) -> Int? {
367 let line = buffer.substring(buffer.lineStart(p)..<buffer.lineEnd(p))
368 guard let stars = line.range(of: "^\\*+(?=[ \\t]|$)", options: .regularExpression) else { return nil }
369 return line.distance(from: stars.lowerBound, to: stars.upperBound)
370 }
371 func at(_ p: Int) -> (range: Range<Int>, linewise: Bool) { (p..<p, false) }
356372 switch key {
373 case "j":
374 guard position < buffer.length else { return nil }
375 if headingLevel(position) != nil {
376 // To the end of the subtree, when a heading is there.
377 guard let section = chain.last(where: { $0.kind == .section }) else { return nil }
378 let end = section.range.upperBound
379 return end < buffer.length && headingLevel(end) != nil ? at(end) : nil
380 }
381 guard let e = atPoint() else { return nil }
382 if let up = parent(e), up.contents?.upperBound == e.end { return at(up.end) }
383 return at(e.end)
384 case "k":
385 guard position > 0 else { return nil }
386 if let level = headingLevel(position) {
387 // The previous heading at this level, or else the parent.
388 var line = buffer.lineStart(position)
389 while line > 0 {
390 line = buffer.lineStart(line - 1)
391 if let other = headingLevel(line), other <= level { return at(line) }
392 }
393 return nil
394 }
395 guard let e = atPoint() else { return nil }
396 if position != e.begin { return at(e.begin) }
397 var p = e.begin
398 while p > 0, let ch = buffer.char(p - 1), Buffer.isSpace(ch) { p -= 1 }
399 guard p > 0 else { return at(0) }
400 look(at: p)
401 guard var prev = atPoint() else { return at(e.begin) }
402 var target = prev.begin
403 while let up = parent(prev), up.end <= e.begin {
404 target = up.begin
405 prev = up
406 }
407 return at(target)
357408 case "R":
358409 guard let headline = chain.last(where: { $0.kind == .section }).map(element) else { return nil }
359410 return (inner ? (headline.contents ?? headline.begin..<headline.begin) : headline.begin..<headline.end, true)
Sources/OrgEditorAppKit/EditorCommands.swift +3
@@ -37,6 +37,9 @@ public enum EditorCommands {
3737 public static let all: [EditorCommand] = Commands.everything.map { EditorCommand(id: $0.id, title: $0.title, action: .text($0)) } + [
3838 EditorCommand(id: "org.cycle", title: "Cycle Visibility", action: .view),
3939 EditorCommand(id: "org.cycle-global", title: "Cycle Global Visibility", action: .view),
40 EditorCommand(id: "org.fold.toggle", title: "Toggle Fold", action: .view),
41 EditorCommand(id: "org.fold.open", title: "Open Fold", action: .view),
42 EditorCommand(id: "org.fold.close", title: "Close Fold", action: .view),
4043 EditorCommand(id: "org.narrow.subtree", title: "Narrow to Subtree", action: .view),
4144 EditorCommand(id: "org.narrow.block", title: "Narrow to Block", action: .view),
4245 EditorCommand(id: "org.widen", title: "Widen", action: .view),
Sources/OrgEditorAppKit/Modal.swift +73 −11
@@ -3,6 +3,11 @@ import AppKit
33import OrgCore
44import OrgDocument
55
6/// An editor a file mark is in, gone when its buffer closes.
7struct WeakEditor {
8 weak var editor: OrgEditor?
9}
10
611/// Modal editing: keys go to the keymap for the current evil state first (leader keys,
712/// evil-org's org keys), then to `Vim`.
813extension OrgEditor {
@@ -16,7 +21,8 @@ extension OrgEditor {
1621 }
1722 switch dispatcher.feed(chord) {
1823 case .pending(let keys):
19 let next = keymap.continuations(of: keys, mode: dispatcher.mode).map { item in
24 let context = editContext()
25 let next = keymap.continuations(of: keys, mode: dispatcher.mode) { modalRuns($0, in: context) }.map { item in
2026 (key: item.key.description, title: item.command.map { EditorCommands.command($0)?.title ?? $0 } ?? "+prefix")
2127 }
2228 onKeysPending?(KeySequence.format(keys), next)
@@ -27,9 +33,7 @@ extension OrgEditor {
2733 case .complete(let keys, let candidates):
2834 if keys.count > 1 { onKeysPending?(nil, []) }
2935 let context = editContext()
30 if let binding = candidates.first(where: { binding in
31 (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && commandApplies(binding.command, in: context)
32 }) {
36 if let binding = candidates.first(where: { modalRuns($0, in: context) }) {
3337 run(binding.command)
3438 updateCursor()
3539 return true
@@ -37,6 +41,12 @@ extension OrgEditor {
3741 return feedVim(keys)
3842 case .undefined(let keys):
3943 onKeysPending?(nil, [])
44 // The leader and Emacs prefixes (C-c, C-x) mean nothing to evil: the sequence is
45 // undefined, as in Doom. Other prefixes (g, z, [, ]) are evil's too.
46 if let first = keys.first, first == KeyChord("SPC") || !first.modifiers.isDisjoint(with: [.control, .meta]) {
47 onMessage?("\(KeySequence.format(keys)) is undefined")
48 return true
49 }
4050 return feedVim(keys)
4151 case .unbound:
4252 return feedVim([chord])
@@ -62,10 +72,16 @@ extension OrgEditor {
6272 let wasInsert = vim.mode == .insert
6373 let visual = vim.mode == .visual || vim.mode == .visualLine
6474 let caret = visual ? (visualCaret ?? textView.selectedRange().location) : textView.selectedRange().location
75 if jumpToFileMark(key) { continue }
6576 let clipboard = NSPasteboard.general.string(forType: .string)
6677 var marks = vimMarks
78 for (name, mark) in Self.fileMarks where mark.owner.editor === self { marks[name] = mark.offset }
6779 if jumpIndex > 0, jumpIndex <= jumpList.count { marks["'"] = jumpList[jumpIndex - 1] }
68 let actions = vim.feed(key, Vim.Context(text: document.text, caret: caret, clipboard: clipboard, halfPage: halfPage, marks: marks))
80 vim.registers = Self.vimRegisters
81 vim.clipboardRecord = Self.vimClipboardRecord
82 let actions = vim.feed(key, Vim.Context(text: document.text, caret: caret, clipboard: clipboard, halfPage: halfPage, marks: marks, visible: visibleText, org: isOrg))
83 Self.vimRegisters = vim.registers
84 Self.vimClipboardRecord = vim.clipboardRecord
6985 apply(actions)
7086 if !wasInsert, vim.mode == .insert {
7187 insertSnapshot = (document.text, textView.selectedRange().location)
@@ -78,13 +94,35 @@ extension OrgEditor {
7894 return true
7995 }
8096
97 /// `'A` or `` `A `` for a file mark in another editor: that editor's buffer shows at the
98 /// mark. True when the key went there.
99 private func jumpToFileMark(_ key: KeyChord) -> Bool {
100 guard vim.mode == .normal, vim.pending.count == 1, let quote = vim.pending.first, quote == KeyChord("'") || quote == KeyChord("`"),
101 key.modifiers.isEmpty, key.key.count == 1, let name = key.key.first, name.isUppercase,
102 let mark = Self.fileMarks[name], let owner = mark.owner.editor, owner !== self else { return false }
103 vim.reset()
104 let ns = owner.document.text as NSString
105 var target = min(mark.offset, ns.length)
106 if quote == KeyChord("'") {
107 // The line's first non-blank.
108 target = ns.lineRange(for: NSRange(location: target, length: 0)).location
109 while target < ns.length, ns.character(at: target) == 32 || ns.character(at: target) == 9 { target += 1 }
110 }
111 onJumpToEditor?(owner, target)
112 return true
113 }
114
81115 /// Moves marks and jump positions through an edit.
82116 func mapVimPositions(through edit: TextEdit) {
83 guard !vimMarks.isEmpty || !jumpList.isEmpty else { return }
117 let owned = Self.fileMarks.filter { $0.value.owner.editor === self }
118 guard !vimMarks.isEmpty || !jumpList.isEmpty || !owned.isEmpty else { return }
84119 let names = Array(vimMarks.keys)
85 let mapped = ViewState(selection: names.map { vimMarks[$0]!..<vimMarks[$0]! } + jumpList.map { $0..<$0 }).mapped(through: [edit]).selection
120 let fileNames = Array(owned.keys)
121 let positions = names.map { vimMarks[$0]! } + fileNames.map { owned[$0]!.offset } + jumpList
122 let mapped = ViewState(selection: positions.map { $0..<$0 }).mapped(through: [edit]).selection
86123 for (i, name) in names.enumerated() { vimMarks[name] = mapped[i].lowerBound }
87 jumpList = mapped.dropFirst(names.count).map { $0.lowerBound }
124 for (i, name) in fileNames.enumerated() { Self.fileMarks[name]?.offset = mapped[names.count + i].lowerBound }
125 jumpList = mapped.dropFirst(names.count + fileNames.count).map { $0.lowerBound }
88126 }
89127
90128 func insertTyped(_ key: KeyChord) {
@@ -99,11 +137,28 @@ extension OrgEditor {
99137 return max(1, Int(scroll.contentView.bounds.height / lineHeight / 2))
100138 }
101139
140 /// The text the window shows, from the line at its top to the line at its bottom.
141 var visibleText: Range<Int>? {
142 guard let layout = textView.textLayoutManager, let content = layout.textContentManager, let scroll = textView.enclosingScrollView else { return nil }
143 let bounds = scroll.contentView.bounds
144 let origin = textView.textContainerOrigin
145 let start = content.documentRange.location
146 guard let top = layout.textLayoutFragment(for: CGPoint(x: 0, y: max(0, bounds.minY - origin.y))) else { return nil }
147 let lower = content.offset(from: start, to: top.rangeInElement.location)
148 let bottom = layout.textLayoutFragment(for: CGPoint(x: 0, y: max(0, bounds.maxY - origin.y - 1)))
149 let upper = bottom.map { content.offset(from: start, to: $0.rangeInElement.endLocation) } ?? utf16Length
150 return lower..<max(lower, upper)
151 }
152
102153 private func editContext() -> EditContext {
103154 let selected = textView.selectedRange()
104155 return EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: [selected.location..<NSMaxRange(selected)], options: options)
105156 }
106157
158 private func modalRuns(_ binding: KeyBinding, in context: EditContext) -> Bool {
159 (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && commandApplies(binding.command, in: context)
160 }
161
107162 private func commandApplies(_ id: String, in context: EditContext) -> Bool {
108163 if !isOrg, id.hasPrefix("org.") { return false }
109164 return switch EditorCommands.command(id)?.action {
@@ -148,10 +203,17 @@ extension OrgEditor {
148203 NSPasteboard.general.setString(text, forType: .string)
149204 case .prompt(let kind):
150205 prompt(kind)
206 case .ex(let command):
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()
151213 case .message(let text):
152214 onMessage?(text)
153215 case .setMark(let name, let offset):
154 vimMarks[name] = offset
216 if name.isUppercase { Self.fileMarks[name] = (WeakEditor(editor: self), offset) } else { vimMarks[name] = offset }
155217 case .jumped(let from):
156218 // A new jump drops the ones C-o went back past.
157219 jumpList = Array(jumpList.prefix(jumpIndex))
@@ -178,12 +240,12 @@ extension OrgEditor {
178240 onMessage?("Defining keyboard macro…")
179241 } else if let register = recordingRegister {
180242 // Without the `q` that ended it.
181 macros[register] = Array(recordedKeys.dropLast())
243 Self.macros[register] = Array(recordedKeys.dropLast())
182244 recordingRegister = nil
183245 onMessage?("Keyboard macro defined")
184246 }
185247 case .playMacro(let name, let count):
186 guard let keys = macros[name], !keys.isEmpty else {
248 guard let keys = Self.macros[name], !keys.isEmpty else {
187249 onMessage?("No macro in register \(name)")
188250 continue
189251 }
Sources/OrgEditorAppKit/OrgEditor.swift +36 −6
@@ -365,15 +365,23 @@ public final class OrgEditor: NSObject {
365365 var insertSnapshot: (text: String, caret: Int)?
366366 /// The evil cursor in visual states, where the selection's start isn't it.
367367 var visualCaret: Int?
368 /// Vim marks, mapped through edits.
368 /// Vim's lowercase marks, mapped through edits.
369369 var vimMarks: [Character: Int] = [:]
370 /// Uppercase marks, with the editor each is in: global, as evil's file marks are.
371 static var fileMarks: [Character: (owner: WeakEditor, offset: Int)] = [:]
372 /// Registers and keyboard macros, shared by every editor as evil's are global.
373 static var vimRegisters: [Character: (text: String, linewise: Bool)] = [:]
374 static var vimClipboardRecord: (text: String, linewise: Bool)?
375 static var macros: [Character: [KeyChord]] = [:]
376 /// Shows `editor`'s buffer with the caret at the offset, for a jump to a file mark set
377 /// there.
378 public var onJumpToEditor: ((OrgEditor, Int) -> Void)?
370379 /// Jump positions, oldest first, mapped through edits; `jumpIndex` is where C-o and C-i are.
371380 var jumpList: [Int] = []
372381 var jumpIndex = 0
373382 /// The register keys go into while `q` records, and its keys so far.
374383 var recordingRegister: Character?
375384 var recordedKeys: [KeyChord] = []
376 var macros: [Character: [KeyChord]] = [:]
377385 var replayingMacro = 0
378386 /// A region is active: movement extends the selection, as after C-SPC in Emacs.
379387 private(set) var markActive = false
@@ -395,7 +403,8 @@ public final class OrgEditor: NSObject {
395403 if modal { return handleModal(chord) }
396404 switch dispatcher.feed(chord) {
397405 case .pending(let keys):
398 let next = keymap.continuations(of: keys).map { item in
406 let context = context()
407 let next = keymap.continuations(of: keys) { runs($0, in: context) }.map { item in
399408 (key: item.key.description, title: item.command.map { EditorCommands.command($0)?.title ?? $0 } ?? "+prefix")
400409 }
401410 onKeysPending?(KeySequence.format(keys), next)
@@ -413,9 +422,7 @@ public final class OrgEditor: NSObject {
413422 case .complete(let keys, let candidates):
414423 if keys.count > 1 { onKeysPending?(nil, []) }
415424 let context = context()
416 let runnable = candidates.first { binding in
417 (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && applies(binding.command, in: context)
418 }
425 let runnable = candidates.first { runs($0, in: context) }
419426 if let binding = runnable ?? (keys.count > 1 ? candidates.first : nil) {
420427 run(binding.command)
421428 return true
@@ -435,6 +442,11 @@ public final class OrgEditor: NSObject {
435442 return true
436443 }
437444
445 /// Whether a key's `binding` runs in `context`: its context holds and its command applies.
446 private func runs(_ binding: KeyBinding, in context: EditContext) -> Bool {
447 (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && applies(binding.command, in: context)
448 }
449
438450 private func applies(_ id: String, in context: EditContext) -> Bool {
439451 if !isOrg, id.hasPrefix("org.") { return false }
440452 return switch EditorCommands.command(id)?.action {
@@ -474,6 +486,7 @@ public final class OrgEditor: NSObject {
474486 switch id {
475487 case "org.cycle": cycleAtCaret()
476488 case "org.cycle-global": cycleGlobal()
489 case "org.fold.toggle", "org.fold.open", "org.fold.close": fold(id)
477490 case "org.narrow.subtree", "org.narrow.block", "org.narrow.toggle", "org.widen": narrow(id)
478491 case "org.toggle-inline-images": toggleImages()
479492 case "editor.toggle-spell-check": toggleSpellCheck()
@@ -940,6 +953,23 @@ public final class OrgEditor: NSObject {
940953 return true
941954 }
942955
956 /// Doom's `za`, `zo` (on a heading line) and `zc` (anywhere in a subtree).
957 func fold(_ id: String) {
958 leaveSparseTree()
959 let caret = textView.selectedRange().location
960 let tree = document.tree
961 let next: FoldState?
962 if id == "org.fold.close" {
963 next = Presentation.outline(tree).last { $0.start <= caret && (caret < $0.end || $0.end == utf16Length) }
964 .map { Presentation.hideSubtree($0.start, foldState) }
965 } else {
966 next = Presentation.heading(containing: caret, in: tree).map {
967 id == "org.fold.open" ? Presentation.openFold($0, in: tree, foldState) : Presentation.toggleFold($0, in: tree, foldState)
968 }
969 }
970 if let next, next != foldState { setFolds(next) }
971 }
972
943973 /// Shift-TAB: overview, contents, show all.
944974 public func cycleGlobal() {
945975 leaveSparseTree()
Sources/OrgPresentation/Folding.swift +36
@@ -87,6 +87,42 @@ extension Presentation {
8787 return next
8888 }
8989
90 /// Doom's `za` (`+org/toggle-fold`) on a heading: folded, it opens one level (its body and
91 /// child headings, folded); otherwise it folds.
92 public static func toggleFold(_ heading: Int, in tree: OrgTree, _ state: FoldState) -> FoldState {
93 isFolded(heading, state) ? showChildren(heading, in: tree, state) : hideSubtree(heading, state)
94 }
95
96 /// Doom's `zo` (`+org/open-fold`): a folded heading opens one level; an open one stays.
97 public static func openFold(_ heading: Int, in tree: OrgTree, _ state: FoldState) -> FoldState {
98 isFolded(heading, state) ? showChildren(heading, in: tree, state) : state
99 }
100
101 /// `zc` (`outline-hide-subtree`): the heading's subtree folded.
102 public static func hideSubtree(_ heading: Int, _ state: FoldState) -> FoldState {
103 var next = state
104 next.folds.insert(heading)
105 return next
106 }
107
108 /// Whether the heading's body is hidden.
109 private static func isFolded(_ heading: Int, _ state: FoldState) -> Bool {
110 state.folds.contains(heading) || state.bodyFolds.contains(heading)
111 }
112
113 /// org's children view of a heading: its body and child headings show, their subtrees folded.
114 private static func showChildren(_ heading: Int, in tree: OrgTree, _ state: FoldState) -> FoldState {
115 let outline = outline(tree)
116 guard let index = outline.firstIndex(where: { $0.start == heading }) else { return state }
117 let entry = outline[index]
118 let subtree = outline.filter { $0.start >= entry.start && $0.start < entry.end }.map(\.start)
119 var next = state
120 next.folds.subtract(subtree)
121 next.bodyFolds.subtract(subtree)
122 next.folds.formUnion(outline.enumerated().filter { $0.element.parent == index }.map(\.element.start))
123 return next
124 }
125
90126 /// TAB at `offset` (`org-cycle`): on the first or last line of a drawer or block, folds or
91127 /// unfolds it; on a heading, cycles it. Nil elsewhere.
92128 public static func cycle(at offset: Int, in tree: OrgTree, _ state: FoldState) -> FoldState? {
Sources/Orgstar/ContentView.swift +1 −1
@@ -498,7 +498,7 @@ struct ContentView: View {
498498 }
499499
500500 private func loadKeymap() {
501 let loaded = KeymapLoader.load(preset)
501 let loaded = KeymapLoader.load(preset, commands: Set(EditorCommands.all.map(\.id)))
502502 keymap = loaded.keymap
503503 commands.keymap = loaded.keymap
504504 commands.modal = preset.isModal
Sources/Orgstar/EditorView.swift +7 −2
@@ -51,7 +51,7 @@ struct EditorView: NSViewRepresentable {
5151 coordinator.path = session.path
5252 container.subviews.forEach { $0.removeFromSuperview() }
5353 coordinator.editor = nil
54 if let path = session.path, let shown = coordinator.editors[path] ?? makeEditor(container) {
54 if let path = session.path, let shown = coordinator.editors[path] ?? makeEditor(container, coordinator) {
5555 coordinator.editors[path] = shown
5656 shown.scroll.frame = container.bounds
5757 container.addSubview(shown.scroll)
@@ -80,7 +80,7 @@ struct EditorView: NSViewRepresentable {
8080 }
8181
8282 /// An editor for the current buffer, which becomes the buffer's live state.
83 private func makeEditor(_ container: NSView) -> (editor: OrgEditor, scroll: NSScrollView)? {
83 private func makeEditor(_ container: NSView, _ coordinator: Coordinator) -> (editor: OrgEditor, scroll: NSScrollView)? {
8484 guard let document = session.document else { return nil }
8585 let mode: OrgEditor.Mode = session.isOrg ? .org : .plain(language: session.path.flatMap(FileTypes.language))
8686 let editor = OrgEditor(document: document, frame: container.bounds, editable: document.isEditable, highlighter: Self.highlighter, mode: mode, options: options)
@@ -105,6 +105,11 @@ struct EditorView: NSViewRepresentable {
105105 }
106106 session?.open(target)
107107 }
108 editor.onJumpToEditor = { [weak session, weak coordinator] other, offset in
109 guard let session, let path = coordinator?.editors.first(where: { $0.value.editor === other })?.key else { return }
110 session.open(path)
111 session.reveal(offset)
112 }
108113 editor.onExternal = onExternal
109114 editor.knownTags = knownTags
110115 editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) }
Tests/OrgCoreTests/KeymapTests.swift +40
@@ -71,6 +71,33 @@ struct KeymapTests {
7171 #expect(problems.count == 3)
7272 }
7373
74 @Test func reportsUnknownModesAndCommands() throws {
75 var problems: [String] = []
76 let keymap = try Keymap(toml: """
77 [[bind]]
78 keys = "g x"
79 command = "org.todo.cycle"
80 mode = "visual-line"
81 [[bind]]
82 keys = "g y"
83 command = "org.todo.cycel"
84 mode = "normal"
85 [[bind]]
86 keys = "g z"
87 command = "none"
88 mode = "visual"
89 [[bind]]
90 keys = "C-c x"
91 command = "org.todo.cycle"
92 """, problems: &problems, commands: ["org.todo.cycle"])
93 #expect(problems == ["line 1: unknown mode visual-line", "line 5: unknown command org.todo.cycel"])
94 #expect(keymap.bindings.map(\.command) == ["none", "org.todo.cycle"])
95 // Without a command list, ids aren't checked.
96 problems = []
97 _ = try Keymap(toml: "[[bind]]\nkeys = \"x\"\ncommand = \"anything\"\nmode = \"insert\"\n", problems: &problems)
98 #expect(problems.isEmpty)
99 }
100
74101 @Test func laterLayersWinAndCanUnbind() throws {
75102 let preset = KeymapPreset.emacs.keymap
76103 let (user, _) = try map("""
@@ -167,6 +194,19 @@ struct KeymapTests {
167194 #expect(keymap.continuations(of: []).contains { $0.key == KeyChord("c", .control) && $0.command == nil })
168195 }
169196
197 @Test func continuationsFollowContext() {
198 let keymap = KeymapPreset.emacs.keymap
199 let text = "* a\n#+begin_src sh\necho\n#+end_src\n"
200 func hint(at caret: Int) -> String?? {
201 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret])
202 return keymap.continuations(of: [KeyChord("c", .control)]) { binding in
203 binding.when.map { KeyContext.holds($0, in: context) } ?? true
204 }.first { $0.key == KeyChord("c", .control) }?.command
205 }
206 #expect(hint(at: 1) == "org.ctrl-c-ctrl-c")
207 #expect(hint(at: 20) == "org.babel.execute")
208 }
209
170210 @Test func contexts() {
171211 let text = "* a\n| x |\n- item\n"
172212 let tree = OrgParser.parse(text)
Tests/OrgCoreTests/VimExtrasTests.swift +72 −2
@@ -38,6 +38,35 @@ struct VimExtrasTests {
3838 #expect(run("abcdef\n", 1, "v l l ESC 0 g v d").text == "aef\n")
3939 }
4040
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
51 @Test func elementMotionsStop() {
52 let text = "intro\n\n* H\npara one\n* Next\nend\n"
53 func moved(_ caret: Int, _ keys: String, org: Bool = true) -> Int {
54 var driver = VimDriver(text, caret: caret)
55 driver.org = org
56 driver.type(KeySequence.parse(keys)!)
57 return driver.caret
58 }
59 #expect(moved(0, "g k") == 0)
60 #expect(moved(7, "g k") == 7)
61 #expect(moved(20, "g j") == 20)
62 // A count doesn't repeat them: org-forward-element takes no argument.
63 #expect(moved(0, "2 g j") == 7)
64 // Outside Org, gj and gk are screen lines, which the editor moves.
65 var vim = Vim()
66 _ = vim.feed(KeyChord("g"), Vim.Context(text: text, caret: 0))
67 #expect(vim.feed(KeyChord("j"), Vim.Context(text: text, caret: 0)) == [.visualLines(1)])
68 }
69
4170 @Test func blockVisual() {
4271 #expect(run("abc\ndef\nghi\n", 0, "C-v j l d").text == "c\nf\nghi\n")
4372 #expect(run("abc\ndef\nghi\n", 0, "C-v j l y j j P").text == "abc\ndef\nabghi\n" || true)
@@ -117,7 +146,47 @@ struct VimExtrasTests {
117146 ("a =code= b\n", 3, "d i e"),
118147 ("a =code= b\n", 3, "d a e"),
119148 ("a [[https://x.org]] b\n", 5, "d i e"),
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 ]
169 }()
170
171 /// evil-org's gj and gk: org-forward-element and org-backward-element.
172 static let elementCases: [(String, Int, String)] = {
173 let text = "intro\n\n* H\npara one\nstill\n\n- a\n- b\n\nafter\n** Sub\nsub body\n** Two\n* Next\nend\n"
174 let at = { (s: String) in (text as NSString).range(of: s).location }
175 var cases: [(String, Int, String)] = []
176 // Org signals an error, and evil stays, for g k at 0 and on the first heading, and g j on
177 // the last one: see `elementMotionsStop`.
178 for start in [0, 3, at("* H"), at("para"), at("still") + 2, at("- a"), at("- b"), at("after"), at("** Sub"), at("sub body"), at("** Two"), at("* Next"), at("end")] {
179 if start != at("* Next") { cases.append((text, start, "g j")) }
180 if start != 0, start != at("* H") { cases.append((text, start, "g k")) }
181 }
182 cases += [
183 (text, at("para"), "g j g j g j"),
184 (text, at("end"), "g k g k"),
185 (text, at("para"), "3 g j"),
186 (text, at("para"), "d g j"),
187 (text, at("* H"), "d g j"),
120188 ]
189 return cases
121190 }()
122191
123192 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
@@ -134,7 +203,7 @@ struct VimExtrasTests {
134203 (require 'evil-surround)
135204 (setq kill-ring nil)
136205 (switch-to-buffer (current-buffer))
137 \(org ? "(org-mode) (require 'evil-org) (evil-org-set-key-theme '(textobjects)) (evil-org-mode 1)" : "")
206 \(org ? "(org-mode) (require 'evil-org) (evil-org-set-key-theme '(textobjects navigation)) (evil-org-mode 1)" : "")
138207 (evil-local-mode 1)
139208 (evil-surround-mode 1)
140209 (evil-normal-state)
@@ -148,9 +217,10 @@ struct VimExtrasTests {
148217 """)
149218 }
150219 let results = try EmacsOracle.run(forms)
151 for ((c, _), result) in zip(all, results) {
220 for ((c, org), result) in zip(all, results) {
152221 let (text, point, keys) = c
153222 var driver = VimDriver(text, caret: point)
223 driver.org = org
154224 driver.type(KeySequence.parse(keys)!)
155225 guard let marker = result.text.range(of: "\n@@STATE ") else {
156226 Issue.record("\(keys): \(result.error)")
Tests/OrgCoreTests/VimTests.swift +76 −1
@@ -10,6 +10,9 @@ struct VimDriver {
1010 var caret: Int
1111 var clipboard: String?
1212 var commands: [String] = []
13 var halfPage = 15
14 var visible: Range<Int>?
15 var org = false
1316
1417 init(_ text: String, caret: Int) {
1518 self.text = text
@@ -36,7 +39,7 @@ struct VimDriver {
3639 }
3740 continue
3841 }
39 let actions = vim.feed(key, Vim.Context(text: text, caret: caret, clipboard: clipboard))
42 let actions = vim.feed(key, Vim.Context(text: text, caret: caret, clipboard: clipboard, halfPage: halfPage, visible: visible, org: org))
4043 for action in actions {
4144 switch action {
4245 case .edit(let edits, let to):
@@ -45,6 +48,10 @@ struct VimDriver {
4548 case .move(let to): caret = to
4649 case .setClipboard(let s): clipboard = s
4750 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
4855 default: break
4956 }
5057 }
@@ -131,6 +138,11 @@ struct VimTests {
131138 ("long line here\nab\nlonger line\n", 10, "j j x"),
132139 ("one two three\n", 0, "s t h x"),
133140 ("one two three\n", 12, "S t w x"),
141 ("ab ab ab ab\n", 0, "s a b ; ; x"),
142 ("ab ab ab ab\n", 0, "s a b ; ; , x"),
143 ("ab ab ab ab\n", 10, "S a b ; x"),
144 ("ab ab ab ab ab ab\n", 0, "2 s a b ; x"),
145 ("one tw tw tw\n", 0, "f w s t w ; x"),
134146 ("one two three\n", 0, "\" a y w w \" a P"),
135147 ("one two three\n", 0, "\" _ d w"),
136148 (" one\n", 2, "o x ESC"),
@@ -183,6 +195,69 @@ struct VimTests {
183195 ("one two three\n", 0, "v 2 e c X ESC"),
184196 ]
185197
198 /// Thirty lines of seven characters: line `i` starts at `7 * i`.
199 static let lines = (0..<30).map { String(format: "line%02d\n", $0) }.joined()
200
201 func driven(_ caret: Int, _ keys: String, halfPage: Int? = nil, visible: Range<Int>? = nil) -> Int {
202 var driver = VimDriver(Self.lines, caret: caret)
203 if let halfPage { driver.halfPage = halfPage }
204 driver.visible = visible
205 driver.type(Self.keys(keys))
206 return driver.caret
207 }
208
209 @Test func zzAndZqRunExCommands() {
210 var vim = Vim()
211 let context = Vim.Context(text: "a\n", caret: 0)
212 #expect(vim.feed(KeyChord("Z"), context) == [])
213 #expect(vim.feed(KeyChord("Z"), context) == [.ex("wq")])
214 _ = vim.feed(KeyChord("Z"), context)
215 #expect(vim.feed(KeyChord("Q"), context) == [.ex("q!")])
216 }
217
218 @Test func snipeRepeatsOverTheVisibleText() {
219 let text = "ab x\ncd ab\nab\n"
220 func caret(_ keys: String, visible: Range<Int>?) -> Int {
221 var driver = VimDriver(text, caret: 0)
222 driver.visible = visible
223 driver.type(Self.keys(keys))
224 return driver.caret
225 }
226 // The snipe itself keeps to the line; repeats search what the window shows.
227 #expect(caret("s a b ;", visible: nil) == 0)
228 #expect(caret("s a b ;", visible: 0..<14) == 8)
229 #expect(caret("s a b ; ;", visible: 0..<14) == 11)
230 #expect(caret("s a b ; ; ,", visible: 0..<14) == 8)
231 // After f, ; repeats the f.
232 #expect(caret("s a b f x ;", visible: 0..<14) == 3)
233 }
234
235 @Test func halfPageScrollsFollowTheWindow() {
236 #expect(driven(0, "C-d", halfPage: 5) == 35)
237 #expect(driven(70, "C-u", halfPage: 5) == 35)
238 #expect(driven(0, "C-d") == 105)
239 #expect(driven(0, "3 C-d", halfPage: 5) == 21)
240 }
241
242 @Test func windowLines() {
243 let shown = 70..<140
244 #expect(driven(0, "H", visible: shown) == 70)
245 #expect(driven(0, "3 H", visible: shown) == 84)
246 #expect(driven(0, "2 0 H", visible: shown) == 133)
247 #expect(driven(0, "L", visible: shown) == 133)
248 #expect(driven(0, "2 L", visible: shown) == 126)
249 #expect(driven(0, "M", visible: shown) == 98)
250 #expect(driven(3, "H") == 3)
251 // Linewise with an operator, and a jump.
252 var driver = VimDriver(Self.lines, caret: 70)
253 driver.visible = shown
254 driver.type(Self.keys("d L"))
255 #expect(driver.text == Self.lines.replacingOccurrences(of: (10..<20).map { String(format: "line%02d\n", $0) }.joined(), with: ""))
256 var vim = Vim()
257 let actions = vim.feed(KeyChord("L"), Vim.Context(text: Self.lines, caret: 70, visible: shown))
258 #expect(actions == [.jumped(from: 70), .move(caret: 133)])
259 }
260
186261 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
187262 func matchesEvil() throws {
188263 let evil = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/emacs/.local/straight/repos")
Tests/OrgEditorAppKitTests/ModalTests.swift +138
@@ -1,5 +1,6 @@
11import AppKit
22import OrgCore
3import OrgPresentation
34import Testing
45@testable import OrgEditorAppKit
56
@@ -72,6 +73,131 @@ struct ModalTests {
7273 #expect(h.editor.vim.mode == .normal)
7374 }
7475
76 @Test func registersAndMacrosAreShared() {
77 let a = doom("alpha beta\n")
78 let b = doom("one\ntwo\n")
79 a.caret(at: 0)
80 type(a, "\" r y w")
81 b.caret(at: 0)
82 type(b, "\" r P")
83 #expect(b.editor.document.text == "alpha one\ntwo\n")
84 type(a, "q m A ! ESC q")
85 type(b, "j @ m")
86 #expect(b.editor.document.text == "alpha one\ntwo!\n")
87 }
88
89 @Test func uppercaseMarksAreGlobal() {
90 let a = doom("first\n second\n")
91 let b = doom("other\ntext\n")
92 var jumps: [(OrgEditor, Int)] = []
93 b.editor.onJumpToEditor = { jumps.append(($0, $1)) }
94 a.caret(at: 10)
95 type(a, "m Q m q")
96 b.caret(at: 0)
97 type(b, "' Q")
98 #expect(jumps.count == 1 && jumps.first?.0 === a.editor && jumps.first?.1 == 8)
99 type(b, "` Q")
100 #expect(jumps.last?.1 == 10)
101 // Lowercase marks stay in their buffer.
102 type(b, "j ' q")
103 #expect(b.textView.selectedRange().location == 6)
104 // In its own buffer the mark is an ordinary jump, and follows edits.
105 type(a, "g g O")
106 a.textView.insertText("new", replacementRange: a.textView.selectedRange())
107 type(a, "ESC ` Q")
108 #expect(a.textView.selectedRange().location == 14)
109 }
110
111 @Test func foldKeysToggleOpenAndClose() {
112 let h = doom("* A\nbody\n** B\nb body\n* C\n")
113 h.caret(at: 0)
114 type(h, "z c")
115 #expect(h.editor.foldState.folds == [0])
116 type(h, "z o")
117 #expect(h.editor.foldState.folds == [9])
118 type(h, "z o")
119 #expect(h.editor.foldState.folds == [9])
120 type(h, "z a")
121 #expect(h.editor.foldState.folds.contains(0))
122 type(h, "z a")
123 #expect(h.editor.foldState.folds == [9])
124 // za and zo act on a heading line; zc closes the subtree around the caret.
125 h.editor.setFolds(FoldState())
126 h.caret(at: 5)
127 type(h, "z a")
128 type(h, "z o")
129 #expect(h.editor.foldState.folds.isEmpty)
130 h.caret(at: 15)
131 type(h, "z c")
132 #expect(h.editor.foldState.folds == [9])
133 }
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
145 @Test func zzSavesAndClosesAsWq() {
146 let h = doom("text\n")
147 var app: [String] = []
148 h.editor.onAppCommand = { app.append($0) }
149 h.caret(at: 0)
150 type(h, "Z Q")
151 #expect(app.isEmpty)
152 type(h, "Z Z")
153 #expect(app == ["app.save"])
154 }
155
156 @Test func windowLinesInTheEditor() {
157 let h = doom("one\ntwo\nthree\nfour\nfive\n")
158 h.caret(at: 0)
159 type(h, "L")
160 #expect(h.textView.selectedRange().location == 19)
161 type(h, "M")
162 #expect(h.textView.selectedRange().location == 8)
163 type(h, "H")
164 #expect(h.textView.selectedRange().location == 0)
165 }
166
167 @Test func undefinedLeaderSequencesDoNothing() {
168 let h = doom("* Task\nbody text\n")
169 var messages: [String] = []
170 h.editor.onMessage = { messages.append($0) }
171 h.caret(at: 0)
172 type(h, "SPC m z")
173 #expect(messages.last == "SPC m z is undefined")
174 #expect(h.textView.selectedRange().location == 0)
175 // No mark z was set: a jump to it fails and the caret stays.
176 type(h, "j ' z")
177 #expect(h.textView.selectedRange().location == 7)
178 type(h, "C-c z")
179 #expect(messages.last == "C-c z is undefined")
180 #expect(h.editor.document.text == "* Task\nbody text\n")
181 // Evil's own prefixes still reach it.
182 type(h, "g g")
183 #expect(h.textView.selectedRange().location == 0)
184 }
185
186 @Test func keyHintsFollowContext() {
187 let h = doom("* a\n#+begin_src sh\necho\n#+end_src\n")
188 var hints: [(key: String, title: String)] = []
189 h.editor.onKeysPending = { _, next in if !next.isEmpty { hints = next } }
190 func hint() -> String? { hints.first { $0.key == "C-c" }?.title }
191 h.caret(at: 1)
192 type(h, "C-c")
193 #expect(hint() != nil && hint() != "Run Source Block")
194 type(h, "C-g")
195 h.caret(at: 20)
196 type(h, "C-c")
197 #expect(hint() == "Run Source Block")
198 type(h, "C-g")
199 }
200
75201 @Test func visualSelection() {
76202 let h = doom("one two three\n")
77203 h.caret(at: 0)
@@ -160,3 +286,15 @@ struct ModalTests {
160286 #expect(h.textView.selectedRange().location == 23)
161287 }
162288}
289
290struct PresetCommandTests {
291 /// Every command a preset binds is one the editor runs, and every mode is a state.
292 @Test func presetsBindKnownCommands() throws {
293 let known = Set(EditorCommands.all.map(\.id))
294 for preset in KeymapPreset.allCases {
295 var problems: [String] = []
296 _ = try Keymap(toml: preset.toml, problems: &problems, commands: known)
297 #expect(problems.isEmpty, "\(preset): \(problems)")
298 }
299 }
300}
Tests/OrgEditorAppKitTests/ViewToggleTests.swift +4 −3
@@ -1,5 +1,6 @@
11import AppKit
22import OrgCore
3import OrgDocument
34import Testing
45@testable import OrgEditorAppKit
56
@@ -44,9 +45,9 @@ struct ViewToggleTests {
4445
4546 @Test func gjMovesByScreenLines() {
4647 let long = Array(repeating: "word", count: 60).joined(separator: " ")
47 let h = Harness(long + "\nnext\n")
48 // Doom binds `g j` to the next heading; without that binding it's the engine's.
49 h.editor.keymap = KeymapPreset.emacs.keymap
48 // In Org files gj is evil-org's element motion; elsewhere it's screen lines.
49 let h = Harness(DocumentState(bytes: Array((long + "\nnext\n").utf8)), mode: .plain(language: nil))
50 h.editor.keymap = KeymapPreset.doom.keymap
5051 h.editor.modal = true
5152 h.caret(at: 0)
5253 h.caret(at: 0)