Keymap and Vim fixes from the manual review !162
17 files changed, +696 −67
Layout: unified · split
Sources/OrgApp/KeymapLoader.swift +4 −3
| @@ -7,12 +7,13 @@ public enum KeymapLoader { | ||
| 7 | 7 | public static var userFile: URL { UserConfig.file("keymap.toml") } |
| 8 | 8 | |
| 9 | 9 | /// 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]) { | |
| 12 | 13 | guard let text = try? String(contentsOf: userFile, encoding: .utf8) else { return (preset.keymap, []) } |
| 13 | 14 | var problems: [String] = [] |
| 14 | 15 | do { |
| 15 | let user = try Keymap(toml: text, problems: &problems) | |
| 16 | let user = try Keymap(toml: text, problems: &problems, commands: commands) | |
| 16 | 17 | return (.layered([preset.keymap, user]), problems) |
| 17 | 18 | } catch { |
| 18 | 19 | return (preset.keymap, ["\(error)"]) |
Sources/OrgCore/Commands/Fill.swift +51
| @@ -467,6 +467,28 @@ extension EmacsBuffer { | ||
| 467 | 467 | } |
| 468 | 468 | } |
| 469 | 469 | |
| 470 | /// `fill-region` over whole lines: each paragraph's part of `from..<to` filled as a | |
| 471 | /// paragraph. Other elements stay. | |
| 472 | func fillRegion(_ from: Int, _ to: Int) { | |
| 473 | var spans: [Range<Int>] = [] | |
| 474 | var line = lineStart(from) | |
| 475 | while line < to, line < length { | |
| 476 | var next = lineEnd(line) + 1 | |
| 477 | let blank = substring(line, lineEnd(line)).allSatisfy { $0 == " " || $0 == "\t" } | |
| 478 | if !blank, let element = element(at: lineEnd(line)) { | |
| 479 | if element.kind == .paragraph { | |
| 480 | let span = max(paragraphContentsBegin(element), from)..<min(contentsEnd(element), to) | |
| 481 | if !span.isEmpty { spans.append(span) } | |
| 482 | } | |
| 483 | next = max(next, lineStart(min(length, max(element.range.lowerBound, contentsEnd(element))))) | |
| 484 | if next <= line { next = lineEnd(line) + 1 } | |
| 485 | } | |
| 486 | line = next | |
| 487 | } | |
| 488 | // Back to front, so the spans before stay where they are. | |
| 489 | for span in spans.reversed() { fillRegionAsParagraph(span.lowerBound, span.upperBound) } | |
| 490 | } | |
| 491 | ||
| 470 | 492 | /// Where a paragraph's text starts: past affiliated keywords, and for a paragraph that |
| 471 | 493 | /// opens an item, past the bullet. |
| 472 | 494 | func paragraphContentsBegin(_ node: Element) -> Int { |
| @@ -482,3 +504,32 @@ public struct FillParagraph: OrgCommand { | ||
| 482 | 504 | public func applies(in context: EditContext) -> Bool { true } |
| 483 | 505 | public func run(in context: EditContext) -> CommandStep { commitBuffer(context) { $0.fillParagraph() } } |
| 484 | 506 | } |
| 507 | ||
| 508 | /// evil's `gq` and `gw` operators: the paragraphs in `range`'s lines filled, as `fill-region`. | |
| 509 | /// With `keep` (`gw`) the caret stays there; without (`gq`) it goes to the first non-blank | |
| 510 | /// of the range's last line. | |
| 511 | public struct FillRegion: OrgCommand { | |
| 512 | public let range: Range<Int> | |
| 513 | public let keep: Int? | |
| 514 | ||
| 515 | public init(range: Range<Int>, keep: Int?) { | |
| 516 | self.range = range | |
| 517 | self.keep = keep | |
| 518 | } | |
| 519 | ||
| 520 | public var id: String { "org.fill-region" } | |
| 521 | public var title: String { "Fill Region" } | |
| 522 | public func applies(in context: EditContext) -> Bool { true } | |
| 523 | public func run(in context: EditContext) -> CommandStep { | |
| 524 | commitBuffer(context) { buffer in | |
| 525 | let target = buffer.marker(keep ?? max(range.lowerBound, range.upperBound - 1)) | |
| 526 | defer { buffer.release(target) } | |
| 527 | buffer.fillRegion(range.lowerBound, range.upperBound) | |
| 528 | buffer.point = target.position | |
| 529 | if keep == nil { | |
| 530 | buffer.beginningOfLine() | |
| 531 | buffer.skipForward(EmacsBuffer.blanks) | |
| 532 | } | |
| 533 | } | |
| 534 | } | |
| 535 | } | |
Sources/OrgCore/Keymap/Keymap.swift +22 −7
| @@ -17,6 +17,9 @@ public struct KeyBinding: Sendable, Equatable { | ||
| 17 | 17 | } |
| 18 | 18 | |
| 19 | 19 | 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"] | |
| 20 | 23 | } |
| 21 | 24 | |
| 22 | 25 | /// Bindings in priority order: later bindings win over earlier ones for the same keys. |
| @@ -29,8 +32,9 @@ public struct Keymap: Sendable, Equatable { | ||
| 29 | 32 | } |
| 30 | 33 | |
| 31 | 34 | /// 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 { | |
| 34 | 38 | bindings = [] |
| 35 | 39 | for table in try TOML.parse(toml) where !(table.name.isEmpty && table.values.isEmpty) { |
| 36 | 40 | guard table.name == "bind", table.isArrayElement else { |
| @@ -45,12 +49,21 @@ public struct Keymap: Sendable, Equatable { | ||
| 45 | 49 | problems.append("line \(table.line): missing command") |
| 46 | 50 | continue |
| 47 | 51 | } |
| 52 | if let commands, command != "none", !commands.contains(command) { | |
| 53 | problems.append("line \(table.line): unknown command \(command)") | |
| 54 | continue | |
| 55 | } | |
| 48 | 56 | let when = table.values["when"]?.string |
| 49 | 57 | if let when, !KeyContext.names.contains(when) { |
| 50 | 58 | problems.append("line \(table.line): unknown context \(when)") |
| 51 | 59 | continue |
| 52 | 60 | } |
| 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)) | |
| 54 | 67 | } |
| 55 | 68 | } |
| 56 | 69 | |
| @@ -79,8 +92,9 @@ public struct Keymap: Sendable, Equatable { | ||
| 79 | 92 | } |
| 80 | 93 | |
| 81 | 94 | /// 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?)] { | |
| 84 | 98 | var seen: [KeyChord: String?] = [:] |
| 85 | 99 | for binding in bindings where binding.mode == mode && binding.keys.count > prefix.count && Array(binding.keys.prefix(prefix.count)) == prefix { |
| 86 | 100 | let next = binding.keys[prefix.count] |
| @@ -88,8 +102,9 @@ public struct Keymap: Sendable, Equatable { | ||
| 88 | 102 | guard seen[next] == nil else { continue } |
| 89 | 103 | if isPrefix(sequence, mode: mode) { |
| 90 | 104 | 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) } | |
| 93 | 108 | } |
| 94 | 109 | } |
| 95 | 110 | 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 { | ||
| 67 | 67 | || (first.modifiers.contains(.meta) && ["<left>", "<right>", "<up>", "<down>", "RET"].contains(first.key)) |
| 68 | 68 | || (first.modifiers.contains(.shift) && ["<left>", "<right>", "<up>", "<down>"].contains(first.key)) |
| 69 | 69 | || (first.modifiers == [.control] && first.key == "RET") |
| 70 | || keys == "M-x" | |
| 70 | || keys == "M-x" || keys == "M-q" | |
| 71 | 71 | guard orgKey else { continue } |
| 72 | 72 | for mode in ["normal", "insert", "visual"] { bind(keys, binding.command, when: binding.when, mode: mode) } |
| 73 | 73 | // 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 { | ||
| 441 | 441 | mode = "normal" |
| 442 | 442 | [[bind]] |
| 443 | 443 | keys = "z a" |
| 444 | command = "org.cycle" | |
| 444 | command = "org.fold.toggle" | |
| 445 | 445 | mode = "normal" |
| 446 | 446 | [[bind]] |
| 447 | 447 | keys = "z c" |
| 448 | command = "org.cycle" | |
| 448 | command = "org.fold.close" | |
| 449 | 449 | mode = "normal" |
| 450 | 450 | [[bind]] |
| 451 | 451 | keys = "z o" |
| 452 | command = "org.cycle" | |
| 452 | command = "org.fold.open" | |
| 453 | 453 | mode = "normal" |
| 454 | 454 | [[bind]] |
| 455 | 455 | keys = "z A" |
| @@ -460,14 +460,6 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | ||
| 460 | 460 | command = "org.heading.up" |
| 461 | 461 | mode = "normal" |
| 462 | 462 | [[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]] | |
| 471 | 463 | keys = "] h" |
| 472 | 464 | command = "org.heading.forward-same-level" |
| 473 | 465 | mode = "normal" |
Sources/OrgCore/Keymap/Vim.swift +73 −14
| @@ -32,6 +32,11 @@ public struct Vim: Sendable { | ||
| 32 | 32 | case setClipboard(String) |
| 33 | 33 | /// Ask for a search pattern (`/`, `?`) or an ex command (`:`). |
| 34 | 34 | case prompt(String) |
| 35 | /// `gq` and `gw`: fill these lines' paragraphs (`FillRegion`); the caret stays at | |
| 36 | /// `keep`, or without it goes to the last line's first non-blank. | |
| 37 | case fill(Range<Int>, keep: Int?) | |
| 38 | /// An ex command to run, as if typed after `:` (`ZZ` is `:wq`, `ZQ` is `:q!`). | |
| 39 | case ex(String) | |
| 35 | 40 | case message(String) |
| 36 | 41 | /// `m`: a mark at this offset, for the editor to keep through edits. |
| 37 | 42 | case setMark(Character, Int) |
| @@ -58,26 +63,35 @@ public struct Vim: Sendable { | ||
| 58 | 63 | public var halfPage: Int |
| 59 | 64 | /// Marks where the editor keeps them now; `'` and `` ` `` are the last jump's start. |
| 60 | 65 | 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 | |
| 61 | 70 | |
| 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 | |
| 63 | 73 | self.text = text |
| 64 | 74 | self.caret = caret |
| 65 | 75 | self.clipboard = clipboard |
| 66 | 76 | self.halfPage = halfPage |
| 67 | 77 | self.marks = marks |
| 78 | self.visible = visible | |
| 68 | 79 | } |
| 69 | 80 | } |
| 70 | 81 | |
| 71 | 82 | public internal(set) var mode: Mode = .normal |
| 72 | 83 | /// Keys of the command being typed. |
| 73 | 84 | 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)] = [:] | |
| 75 | 87 | /// 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)? | |
| 77 | 89 | var anchor = 0 |
| 78 | 90 | var goalColumn: Int? |
| 79 | 91 | 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 | |
| 81 | 95 | var lastSearch: (pattern: String, forward: Bool)? |
| 82 | 96 | /// The keys and inserted text of the last change, for `.`. |
| 83 | 97 | var lastChange: (keys: [KeyChord], inserted: String?)? |
| @@ -96,6 +110,10 @@ public struct Vim: Sendable { | ||
| 96 | 110 | var blockInsert: (lines: [Int], column: Int, pad: Bool)? |
| 97 | 111 | /// The editor's marks for this key. |
| 98 | 112 | 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 | |
| 99 | 117 | /// Keys are going into a macro register. |
| 100 | 118 | public internal(set) var recording = false |
| 101 | 119 | var lastMacro: Character? |
| @@ -115,6 +133,9 @@ public struct Vim: Sendable { | ||
| 115 | 133 | let keys = pending |
| 116 | 134 | caretForApply = context.caret |
| 117 | 135 | contextMarks = context.marks |
| 136 | contextHalfPage = context.halfPage | |
| 137 | contextVisible = context.visible | |
| 138 | contextOrg = context.org | |
| 118 | 139 | var buffer = Buffer(context) |
| 119 | 140 | switch parse(keys, &buffer, context) { |
| 120 | 141 | case .incomplete: |
| @@ -253,7 +274,7 @@ public struct Vim: Sendable { | ||
| 253 | 274 | op = o |
| 254 | 275 | } else if first.key == "g", first.modifiers.isEmpty { |
| 255 | 276 | 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 { | |
| 257 | 278 | op = "g" + second.key |
| 258 | 279 | afterOp = rest.dropFirst(2) |
| 259 | 280 | } |
| @@ -455,8 +476,8 @@ public struct Vim: Sendable { | ||
| 455 | 476 | case ("Z", []): |
| 456 | 477 | guard rest.count >= 2 else { return .incomplete } |
| 457 | 478 | 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!")]) | |
| 460 | 481 | default: return .invalid |
| 461 | 482 | } |
| 462 | 483 | default: |
| @@ -464,7 +485,7 @@ public struct Vim: Sendable { | ||
| 464 | 485 | } |
| 465 | 486 | } |
| 466 | 487 | |
| 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") { | |
| 468 | 489 | return .done([.visualLines(rest.last == KeyChord("j") ? n : -n)]) |
| 469 | 490 | } |
| 470 | 491 | |
| @@ -505,7 +526,7 @@ public struct Vim: Sendable { | ||
| 505 | 526 | return .moved(min(limit, c + n), .exclusive) |
| 506 | 527 | case ("j", []), ("k", []), ("<down>", []), ("<up>", []), ("d", [.control]), ("u", [.control]), ("+", []), ("-", []), ("RET", []): |
| 507 | 528 | 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 | |
| 509 | 530 | let goal = goalColumn ?? column(c) |
| 510 | 531 | var line = buffer.lineStart(c) |
| 511 | 532 | var moved = 0 |
| @@ -572,6 +593,11 @@ public struct Vim: Sendable { | ||
| 572 | 593 | var p = c |
| 573 | 594 | for _ in 0..<n { p = buffer.wordEndBackward(p, bigWord: keys[1].key == "E") } |
| 574 | 595 | 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) | |
| 575 | 601 | case "j": |
| 576 | 602 | return motion([KeyChord("j")], &buffer, &caret, count: n, operatorPending: operatorPending) |
| 577 | 603 | case "k": |
| @@ -589,8 +615,16 @@ public struct Vim: Sendable { | ||
| 589 | 615 | let char = keys[1].key == "SPC" ? " " : keys[1].key |
| 590 | 616 | guard char.count == 1 else { return .invalid } |
| 591 | 617 | lastFind = (Character(key.key), char) |
| 618 | lastWasSnipe = false | |
| 592 | 619 | guard let p = find(Character(key.key), char, buffer, c, count: n) else { return .invalid } |
| 593 | 620 | 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) | |
| 594 | 628 | case (";", []), (",", []): |
| 595 | 629 | guard let last = lastFind else { return .invalid } |
| 596 | 630 | var kind = last.kind |
| @@ -603,7 +637,8 @@ public struct Vim: Sendable { | ||
| 603 | 637 | // evil-snipe with `evil-snipe-scope` line. |
| 604 | 638 | guard keys.count >= 3 else { return .incomplete } |
| 605 | 639 | let chars = keys[1].key + keys[2].key |
| 606 | lastSnipe = (key.key == "s", chars) | |
| 640 | lastSnipe = (key.key == "s", chars, n) | |
| 641 | lastWasSnipe = true | |
| 607 | 642 | guard let p = snipe(forward: key.key == "s", chars, buffer, c, count: n) else { return .invalid } |
| 608 | 643 | return .moved(p, .exclusive) |
| 609 | 644 | case ("%", []): |
| @@ -631,6 +666,21 @@ public struct Vim: Sendable { | ||
| 631 | 666 | lastSearch = (pattern, key.key == "*") |
| 632 | 667 | guard let p = buffer.search(pattern, from: buffer.wordStart(c), forward: key.key == "*", count: n) else { return .invalid } |
| 633 | 668 | 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) | |
| 634 | 684 | default: |
| 635 | 685 | return .invalid |
| 636 | 686 | } |
| @@ -660,9 +710,12 @@ public struct Vim: Sendable { | ||
| 660 | 710 | return p |
| 661 | 711 | } |
| 662 | 712 | |
| 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 } | |
| 666 | 719 | let smart = chars == chars.lowercased() |
| 667 | 720 | var p = c - start |
| 668 | 721 | for _ in 0..<n { |
| @@ -826,6 +879,12 @@ public struct Vim: Sendable { | ||
| 826 | 879 | case "gc": |
| 827 | 880 | lastChange = (wasVisual ? [] : keys, nil) |
| 828 | 881 | return [.edit(Self.toggleComments(buffer, range), caret: buffer.lineStart(range.lowerBound))] |
| 882 | case "gq", "gw": | |
| 883 | // Whole lines, as evil's fill operators take them. | |
| 884 | lastChange = (wasVisual ? [] : keys, nil) | |
| 885 | let end = buffer.lineEnd(max(range.lowerBound, range.upperBound - 1)) | |
| 886 | let lines = buffer.lineStart(range.lowerBound)..<min(buffer.length, end + 1) | |
| 887 | return [.fill(lines, keep: op == "gw" ? caretForApply : nil)] | |
| 829 | 888 | default: |
| 830 | 889 | let transformed: String |
| 831 | 890 | switch op { |
| @@ -914,7 +973,7 @@ public struct Vim: Sendable { | ||
| 914 | 973 | caret += inserted.utf16.count |
| 915 | 974 | } |
| 916 | 975 | } |
| 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)) | |
| 918 | 977 | for action in produced { |
| 919 | 978 | switch action { |
| 920 | 979 | case .edit(let edits, let to): |
Sources/OrgCore/Keymap/VimExtras.swift +56 −5
| @@ -253,14 +253,23 @@ extension Vim { | ||
| 253 | 253 | var node: SyntaxNode? |
| 254 | 254 | } |
| 255 | 255 | |
| 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)? { | |
| 257 | 259 | 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 | |
| 258 | 262 | 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 | } | |
| 263 | 271 | } |
| 272 | look(at: position) | |
| 264 | 273 | /// The end of the line ending at or after `p`, without trailing blank lines. |
| 265 | 274 | func trimmed(_ lower: Int, _ upper: Int) -> Int { |
| 266 | 275 | var end = upper |
| @@ -353,7 +362,49 @@ extension Vim { | ||
| 353 | 362 | if let contents = e.contents { return contents } |
| 354 | 363 | return e.begin..<max(e.begin, trimmed(e.begin, e.end) - (buffer.char(trimmed(e.begin, e.end) - 1) == 10 ? 1 : 0)) |
| 355 | 364 | } |
| 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) } | |
| 356 | 372 | 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) | |
| 357 | 408 | case "R": |
| 358 | 409 | guard let headline = chain.last(where: { $0.kind == .section }).map(element) else { return nil } |
| 359 | 410 | 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 { | ||
| 37 | 37 | public static let all: [EditorCommand] = Commands.everything.map { EditorCommand(id: $0.id, title: $0.title, action: .text($0)) } + [ |
| 38 | 38 | EditorCommand(id: "org.cycle", title: "Cycle Visibility", action: .view), |
| 39 | 39 | 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), | |
| 40 | 43 | EditorCommand(id: "org.narrow.subtree", title: "Narrow to Subtree", action: .view), |
| 41 | 44 | EditorCommand(id: "org.narrow.block", title: "Narrow to Block", action: .view), |
| 42 | 45 | EditorCommand(id: "org.widen", title: "Widen", action: .view), |
Sources/OrgEditorAppKit/Modal.swift +73 −11
| @@ -3,6 +3,11 @@ import AppKit | ||
| 3 | 3 | import OrgCore |
| 4 | 4 | import OrgDocument |
| 5 | 5 | |
| 6 | /// An editor a file mark is in, gone when its buffer closes. | |
| 7 | struct WeakEditor { | |
| 8 | weak var editor: OrgEditor? | |
| 9 | } | |
| 10 | ||
| 6 | 11 | /// Modal editing: keys go to the keymap for the current evil state first (leader keys, |
| 7 | 12 | /// evil-org's org keys), then to `Vim`. |
| 8 | 13 | extension OrgEditor { |
| @@ -16,7 +21,8 @@ extension OrgEditor { | ||
| 16 | 21 | } |
| 17 | 22 | switch dispatcher.feed(chord) { |
| 18 | 23 | 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 | |
| 20 | 26 | (key: item.key.description, title: item.command.map { EditorCommands.command($0)?.title ?? $0 } ?? "+prefix") |
| 21 | 27 | } |
| 22 | 28 | onKeysPending?(KeySequence.format(keys), next) |
| @@ -27,9 +33,7 @@ extension OrgEditor { | ||
| 27 | 33 | case .complete(let keys, let candidates): |
| 28 | 34 | if keys.count > 1 { onKeysPending?(nil, []) } |
| 29 | 35 | 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) }) { | |
| 33 | 37 | run(binding.command) |
| 34 | 38 | updateCursor() |
| 35 | 39 | return true |
| @@ -37,6 +41,12 @@ extension OrgEditor { | ||
| 37 | 41 | return feedVim(keys) |
| 38 | 42 | case .undefined(let keys): |
| 39 | 43 | 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 | } | |
| 40 | 50 | return feedVim(keys) |
| 41 | 51 | case .unbound: |
| 42 | 52 | return feedVim([chord]) |
| @@ -62,10 +72,16 @@ extension OrgEditor { | ||
| 62 | 72 | let wasInsert = vim.mode == .insert |
| 63 | 73 | let visual = vim.mode == .visual || vim.mode == .visualLine |
| 64 | 74 | let caret = visual ? (visualCaret ?? textView.selectedRange().location) : textView.selectedRange().location |
| 75 | if jumpToFileMark(key) { continue } | |
| 65 | 76 | let clipboard = NSPasteboard.general.string(forType: .string) |
| 66 | 77 | var marks = vimMarks |
| 78 | for (name, mark) in Self.fileMarks where mark.owner.editor === self { marks[name] = mark.offset } | |
| 67 | 79 | 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 | |
| 69 | 85 | apply(actions) |
| 70 | 86 | if !wasInsert, vim.mode == .insert { |
| 71 | 87 | insertSnapshot = (document.text, textView.selectedRange().location) |
| @@ -78,13 +94,35 @@ extension OrgEditor { | ||
| 78 | 94 | return true |
| 79 | 95 | } |
| 80 | 96 | |
| 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 | ||
| 81 | 115 | /// Moves marks and jump positions through an edit. |
| 82 | 116 | 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 } | |
| 84 | 119 | 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 | |
| 86 | 123 | 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 } | |
| 88 | 126 | } |
| 89 | 127 | |
| 90 | 128 | func insertTyped(_ key: KeyChord) { |
| @@ -99,11 +137,28 @@ extension OrgEditor { | ||
| 99 | 137 | return max(1, Int(scroll.contentView.bounds.height / lineHeight / 2)) |
| 100 | 138 | } |
| 101 | 139 | |
| 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 | ||
| 102 | 153 | private func editContext() -> EditContext { |
| 103 | 154 | let selected = textView.selectedRange() |
| 104 | 155 | return EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: [selected.location..<NSMaxRange(selected)], options: options) |
| 105 | 156 | } |
| 106 | 157 | |
| 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 | ||
| 107 | 162 | private func commandApplies(_ id: String, in context: EditContext) -> Bool { |
| 108 | 163 | if !isOrg, id.hasPrefix("org.") { return false } |
| 109 | 164 | return switch EditorCommands.command(id)?.action { |
| @@ -148,10 +203,17 @@ extension OrgEditor { | ||
| 148 | 203 | NSPasteboard.general.setString(text, forType: .string) |
| 149 | 204 | case .prompt(let kind): |
| 150 | 205 | 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() | |
| 151 | 213 | case .message(let text): |
| 152 | 214 | onMessage?(text) |
| 153 | 215 | 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 } | |
| 155 | 217 | case .jumped(let from): |
| 156 | 218 | // A new jump drops the ones C-o went back past. |
| 157 | 219 | jumpList = Array(jumpList.prefix(jumpIndex)) |
| @@ -178,12 +240,12 @@ extension OrgEditor { | ||
| 178 | 240 | onMessage?("Defining keyboard macro…") |
| 179 | 241 | } else if let register = recordingRegister { |
| 180 | 242 | // Without the `q` that ended it. |
| 181 | macros[register] = Array(recordedKeys.dropLast()) | |
| 243 | Self.macros[register] = Array(recordedKeys.dropLast()) | |
| 182 | 244 | recordingRegister = nil |
| 183 | 245 | onMessage?("Keyboard macro defined") |
| 184 | 246 | } |
| 185 | 247 | 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 { | |
| 187 | 249 | onMessage?("No macro in register \(name)") |
| 188 | 250 | continue |
| 189 | 251 | } |
Sources/OrgEditorAppKit/OrgEditor.swift +36 −6
| @@ -365,15 +365,23 @@ public final class OrgEditor: NSObject { | ||
| 365 | 365 | var insertSnapshot: (text: String, caret: Int)? |
| 366 | 366 | /// The evil cursor in visual states, where the selection's start isn't it. |
| 367 | 367 | var visualCaret: Int? |
| 368 | /// Vim marks, mapped through edits. | |
| 368 | /// Vim's lowercase marks, mapped through edits. | |
| 369 | 369 | 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)? | |
| 370 | 379 | /// Jump positions, oldest first, mapped through edits; `jumpIndex` is where C-o and C-i are. |
| 371 | 380 | var jumpList: [Int] = [] |
| 372 | 381 | var jumpIndex = 0 |
| 373 | 382 | /// The register keys go into while `q` records, and its keys so far. |
| 374 | 383 | var recordingRegister: Character? |
| 375 | 384 | var recordedKeys: [KeyChord] = [] |
| 376 | var macros: [Character: [KeyChord]] = [:] | |
| 377 | 385 | var replayingMacro = 0 |
| 378 | 386 | /// A region is active: movement extends the selection, as after C-SPC in Emacs. |
| 379 | 387 | private(set) var markActive = false |
| @@ -395,7 +403,8 @@ public final class OrgEditor: NSObject { | ||
| 395 | 403 | if modal { return handleModal(chord) } |
| 396 | 404 | switch dispatcher.feed(chord) { |
| 397 | 405 | 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 | |
| 399 | 408 | (key: item.key.description, title: item.command.map { EditorCommands.command($0)?.title ?? $0 } ?? "+prefix") |
| 400 | 409 | } |
| 401 | 410 | onKeysPending?(KeySequence.format(keys), next) |
| @@ -413,9 +422,7 @@ public final class OrgEditor: NSObject { | ||
| 413 | 422 | case .complete(let keys, let candidates): |
| 414 | 423 | if keys.count > 1 { onKeysPending?(nil, []) } |
| 415 | 424 | 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) } | |
| 419 | 426 | if let binding = runnable ?? (keys.count > 1 ? candidates.first : nil) { |
| 420 | 427 | run(binding.command) |
| 421 | 428 | return true |
| @@ -435,6 +442,11 @@ public final class OrgEditor: NSObject { | ||
| 435 | 442 | return true |
| 436 | 443 | } |
| 437 | 444 | |
| 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 | ||
| 438 | 450 | private func applies(_ id: String, in context: EditContext) -> Bool { |
| 439 | 451 | if !isOrg, id.hasPrefix("org.") { return false } |
| 440 | 452 | return switch EditorCommands.command(id)?.action { |
| @@ -474,6 +486,7 @@ public final class OrgEditor: NSObject { | ||
| 474 | 486 | switch id { |
| 475 | 487 | case "org.cycle": cycleAtCaret() |
| 476 | 488 | case "org.cycle-global": cycleGlobal() |
| 489 | case "org.fold.toggle", "org.fold.open", "org.fold.close": fold(id) | |
| 477 | 490 | case "org.narrow.subtree", "org.narrow.block", "org.narrow.toggle", "org.widen": narrow(id) |
| 478 | 491 | case "org.toggle-inline-images": toggleImages() |
| 479 | 492 | case "editor.toggle-spell-check": toggleSpellCheck() |
| @@ -940,6 +953,23 @@ public final class OrgEditor: NSObject { | ||
| 940 | 953 | return true |
| 941 | 954 | } |
| 942 | 955 | |
| 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 | ||
| 943 | 973 | /// Shift-TAB: overview, contents, show all. |
| 944 | 974 | public func cycleGlobal() { |
| 945 | 975 | leaveSparseTree() |
Sources/OrgPresentation/Folding.swift +36
| @@ -87,6 +87,42 @@ extension Presentation { | ||
| 87 | 87 | return next |
| 88 | 88 | } |
| 89 | 89 | |
| 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 | ||
| 90 | 126 | /// TAB at `offset` (`org-cycle`): on the first or last line of a drawer or block, folds or |
| 91 | 127 | /// unfolds it; on a heading, cycles it. Nil elsewhere. |
| 92 | 128 | 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 { | ||
| 498 | 498 | } |
| 499 | 499 | |
| 500 | 500 | private func loadKeymap() { |
| 501 | let loaded = KeymapLoader.load(preset) | |
| 501 | let loaded = KeymapLoader.load(preset, commands: Set(EditorCommands.all.map(\.id))) | |
| 502 | 502 | keymap = loaded.keymap |
| 503 | 503 | commands.keymap = loaded.keymap |
| 504 | 504 | commands.modal = preset.isModal |
Sources/Orgstar/EditorView.swift +7 −2
| @@ -51,7 +51,7 @@ struct EditorView: NSViewRepresentable { | ||
| 51 | 51 | coordinator.path = session.path |
| 52 | 52 | container.subviews.forEach { $0.removeFromSuperview() } |
| 53 | 53 | 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) { | |
| 55 | 55 | coordinator.editors[path] = shown |
| 56 | 56 | shown.scroll.frame = container.bounds |
| 57 | 57 | container.addSubview(shown.scroll) |
| @@ -80,7 +80,7 @@ struct EditorView: NSViewRepresentable { | ||
| 80 | 80 | } |
| 81 | 81 | |
| 82 | 82 | /// 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)? { | |
| 84 | 84 | guard let document = session.document else { return nil } |
| 85 | 85 | let mode: OrgEditor.Mode = session.isOrg ? .org : .plain(language: session.path.flatMap(FileTypes.language)) |
| 86 | 86 | 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 { | ||
| 105 | 105 | } |
| 106 | 106 | session?.open(target) |
| 107 | 107 | } |
| 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 | } | |
| 108 | 113 | editor.onExternal = onExternal |
| 109 | 114 | editor.knownTags = knownTags |
| 110 | 115 | editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) } |
Tests/OrgCoreTests/KeymapTests.swift +40
| @@ -71,6 +71,33 @@ struct KeymapTests { | ||
| 71 | 71 | #expect(problems.count == 3) |
| 72 | 72 | } |
| 73 | 73 | |
| 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 | ||
| 74 | 101 | @Test func laterLayersWinAndCanUnbind() throws { |
| 75 | 102 | let preset = KeymapPreset.emacs.keymap |
| 76 | 103 | let (user, _) = try map(""" |
| @@ -167,6 +194,19 @@ struct KeymapTests { | ||
| 167 | 194 | #expect(keymap.continuations(of: []).contains { $0.key == KeyChord("c", .control) && $0.command == nil }) |
| 168 | 195 | } |
| 169 | 196 | |
| 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 | ||
| 170 | 210 | @Test func contexts() { |
| 171 | 211 | let text = "* a\n| x |\n- item\n" |
| 172 | 212 | let tree = OrgParser.parse(text) |
Tests/OrgCoreTests/VimExtrasTests.swift +72 −2
| @@ -38,6 +38,35 @@ struct VimExtrasTests { | ||
| 38 | 38 | #expect(run("abcdef\n", 1, "v l l ESC 0 g v d").text == "aef\n") |
| 39 | 39 | } |
| 40 | 40 | |
| 41 | @Test func fillOperators() { | |
| 42 | let text = "short\nlines\njoin here\n\nnext para\n" | |
| 43 | #expect(run(text, 0, "g q a p").text == "short lines join here\n\nnext para\n") | |
| 44 | #expect(run(text, 8, "g w a p") == ("short lines join here\n\nnext para\n", 8)) | |
| 45 | #expect(run("a\nb\nc\n", 2, "g q q").text == "a\nb\nc\n") | |
| 46 | #expect(run("a\nb\nc\n", 0, "V j g q") == ("a b\nc\n", 0)) | |
| 47 | let doom = KeymapPreset.doom.keymap | |
| 48 | #expect(Set(["normal", "insert", "visual"]).allSatisfy { doom.candidates(for: [KeyChord("q", .meta)], mode: $0).first?.command == "org.fill-paragraph" }) | |
| 49 | } | |
| 50 | ||
| 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 | ||
| 41 | 70 | @Test func blockVisual() { |
| 42 | 71 | #expect(run("abc\ndef\nghi\n", 0, "C-v j l d").text == "c\nf\nghi\n") |
| 43 | 72 | #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 { | ||
| 117 | 146 | ("a =code= b\n", 3, "d i e"), |
| 118 | 147 | ("a =code= b\n", 3, "d a e"), |
| 119 | 148 | ("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"), | |
| 120 | 188 | ] |
| 189 | return cases | |
| 121 | 190 | }() |
| 122 | 191 | |
| 123 | 192 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) |
| @@ -134,7 +203,7 @@ struct VimExtrasTests { | ||
| 134 | 203 | (require 'evil-surround) |
| 135 | 204 | (setq kill-ring nil) |
| 136 | 205 | (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)" : "") | |
| 138 | 207 | (evil-local-mode 1) |
| 139 | 208 | (evil-surround-mode 1) |
| 140 | 209 | (evil-normal-state) |
| @@ -148,9 +217,10 @@ struct VimExtrasTests { | ||
| 148 | 217 | """) |
| 149 | 218 | } |
| 150 | 219 | let results = try EmacsOracle.run(forms) |
| 151 | for ((c, _), result) in zip(all, results) { | |
| 220 | for ((c, org), result) in zip(all, results) { | |
| 152 | 221 | let (text, point, keys) = c |
| 153 | 222 | var driver = VimDriver(text, caret: point) |
| 223 | driver.org = org | |
| 154 | 224 | driver.type(KeySequence.parse(keys)!) |
| 155 | 225 | guard let marker = result.text.range(of: "\n@@STATE ") else { |
| 156 | 226 | Issue.record("\(keys): \(result.error)") |
Tests/OrgCoreTests/VimTests.swift +76 −1
| @@ -10,6 +10,9 @@ struct VimDriver { | ||
| 10 | 10 | var caret: Int |
| 11 | 11 | var clipboard: String? |
| 12 | 12 | var commands: [String] = [] |
| 13 | var halfPage = 15 | |
| 14 | var visible: Range<Int>? | |
| 15 | var org = false | |
| 13 | 16 | |
| 14 | 17 | init(_ text: String, caret: Int) { |
| 15 | 18 | self.text = text |
| @@ -36,7 +39,7 @@ struct VimDriver { | ||
| 36 | 39 | } |
| 37 | 40 | continue |
| 38 | 41 | } |
| 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)) | |
| 40 | 43 | for action in actions { |
| 41 | 44 | switch action { |
| 42 | 45 | case .edit(let edits, let to): |
| @@ -45,6 +48,10 @@ struct VimDriver { | ||
| 45 | 48 | case .move(let to): caret = to |
| 46 | 49 | case .setClipboard(let s): clipboard = s |
| 47 | 50 | case .command(let id): commands.append(id) |
| 51 | case .fill(let range, let keep): | |
| 52 | let result = runCommand(FillRegion(range: range, keep: keep), text, caret: caret) | |
| 53 | text = result.text | |
| 54 | caret = result.caret | |
| 48 | 55 | default: break |
| 49 | 56 | } |
| 50 | 57 | } |
| @@ -131,6 +138,11 @@ struct VimTests { | ||
| 131 | 138 | ("long line here\nab\nlonger line\n", 10, "j j x"), |
| 132 | 139 | ("one two three\n", 0, "s t h x"), |
| 133 | 140 | ("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"), | |
| 134 | 146 | ("one two three\n", 0, "\" a y w w \" a P"), |
| 135 | 147 | ("one two three\n", 0, "\" _ d w"), |
| 136 | 148 | (" one\n", 2, "o x ESC"), |
| @@ -183,6 +195,69 @@ struct VimTests { | ||
| 183 | 195 | ("one two three\n", 0, "v 2 e c X ESC"), |
| 184 | 196 | ] |
| 185 | 197 | |
| 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 | ||
| 186 | 261 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) |
| 187 | 262 | func matchesEvil() throws { |
| 188 | 263 | let evil = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/emacs/.local/straight/repos") |
Tests/OrgEditorAppKitTests/ModalTests.swift +138
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import AppKit |
| 2 | 2 | import OrgCore |
| 3 | import OrgPresentation | |
| 3 | 4 | import Testing |
| 4 | 5 | @testable import OrgEditorAppKit |
| 5 | 6 | |
| @@ -72,6 +73,131 @@ struct ModalTests { | ||
| 72 | 73 | #expect(h.editor.vim.mode == .normal) |
| 73 | 74 | } |
| 74 | 75 | |
| 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 | ||
| 75 | 201 | @Test func visualSelection() { |
| 76 | 202 | let h = doom("one two three\n") |
| 77 | 203 | h.caret(at: 0) |
| @@ -160,3 +286,15 @@ struct ModalTests { | ||
| 160 | 286 | #expect(h.textView.selectedRange().location == 23) |
| 161 | 287 | } |
| 162 | 288 | } |
| 289 | ||
| 290 | struct 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 @@ | ||
| 1 | 1 | import AppKit |
| 2 | 2 | import OrgCore |
| 3 | import OrgDocument | |
| 3 | 4 | import Testing |
| 4 | 5 | @testable import OrgEditorAppKit |
| 5 | 6 | |
| @@ -44,9 +45,9 @@ struct ViewToggleTests { | ||
| 44 | 45 | |
| 45 | 46 | @Test func gjMovesByScreenLines() { |
| 46 | 47 | 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 | |
| 50 | 51 | h.editor.modal = true |
| 51 | 52 | h.caret(at: 0) |
| 52 | 53 | h.caret(at: 0) |