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 | public static var userFile: URL { UserConfig.file("keymap.toml") } | 7 | public static var userFile: URL { UserConfig.file("keymap.toml") } |
| 8 | 8 | ||
| 9 | /// The preset with the user's file on top, and the problems found in the file. A missing | 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. | 10 | /// file adds nothing; a file that isn't valid TOML adds nothing and is reported. With |
| 11 | public static func load(_ preset: KeymapPreset, userFile: URL = userFile) -> (keymap: Keymap, problems: [String]) { | 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 | guard let text = try? String(contentsOf: userFile, encoding: .utf8) else { return (preset.keymap, []) } | 13 | guard let text = try? String(contentsOf: userFile, encoding: .utf8) else { return (preset.keymap, []) } |
| 13 | var problems: [String] = [] | 14 | var problems: [String] = [] |
| 14 | do { | 15 | do { |
| 15 | let user = try Keymap(toml: text, problems: &problems) | 16 | let user = try Keymap(toml: text, problems: &problems, commands: commands) |
| 16 | return (.layered([preset.keymap, user]), problems) | 17 | return (.layered([preset.keymap, user]), problems) |
| 17 | } catch { | 18 | } catch { |
| 18 | return (preset.keymap, ["\(error)"]) | 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 | /// Where a paragraph's text starts: past affiliated keywords, and for a paragraph that | 492 | /// Where a paragraph's text starts: past affiliated keywords, and for a paragraph that |
| 471 | /// opens an item, past the bullet. | 493 | /// opens an item, past the bullet. |
| 472 | func paragraphContentsBegin(_ node: Element) -> Int { | 494 | func paragraphContentsBegin(_ node: Element) -> Int { |
| @@ -482,3 +504,32 @@ public struct FillParagraph: OrgCommand { | |||
| 482 | public func applies(in context: EditContext) -> Bool { true } | 504 | public func applies(in context: EditContext) -> Bool { true } |
| 483 | public func run(in context: EditContext) -> CommandStep { commitBuffer(context) { $0.fillParagraph() } } | 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 | public var unbinds: Bool { command == "none" } | 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 | /// Bindings in priority order: later bindings win over earlier ones for the same keys. | 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 | /// Reads `[[bind]]` tables. Entries that can't be used are skipped and reported in | 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. | 35 | /// `problems`; a file that isn't valid TOML throws. With `commands`, a command id outside |
| 33 | public init(toml: String, problems: inout [String]) throws { | 36 | /// it (other than `none`) is such an entry. |
| 37 | public init(toml: String, problems: inout [String], commands: Set<String>? = nil) throws { | ||
| 34 | bindings = [] | 38 | bindings = [] |
| 35 | for table in try TOML.parse(toml) where !(table.name.isEmpty && table.values.isEmpty) { | 39 | for table in try TOML.parse(toml) where !(table.name.isEmpty && table.values.isEmpty) { |
| 36 | guard table.name == "bind", table.isArrayElement else { | 40 | guard table.name == "bind", table.isArrayElement else { |
| @@ -45,12 +49,21 @@ public struct Keymap: Sendable, Equatable { | |||
| 45 | problems.append("line \(table.line): missing command") | 49 | problems.append("line \(table.line): missing command") |
| 46 | continue | 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 | let when = table.values["when"]?.string | 56 | let when = table.values["when"]?.string |
| 49 | if let when, !KeyContext.names.contains(when) { | 57 | if let when, !KeyContext.names.contains(when) { |
| 50 | problems.append("line \(table.line): unknown context \(when)") | 58 | problems.append("line \(table.line): unknown context \(when)") |
| 51 | continue | 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 | /// What can follow `prefix`: each next key with the command it runs, or nil when it | 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. | 95 | /// leads to a longer sequence. Sorted by key. The command is the first candidate that |
| 83 | public func continuations(of prefix: [KeyChord], mode: String? = nil) -> [(key: KeyChord, command: String?)] { | 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 | var seen: [KeyChord: String?] = [:] | 98 | var seen: [KeyChord: String?] = [:] |
| 85 | for binding in bindings where binding.mode == mode && binding.keys.count > prefix.count && Array(binding.keys.prefix(prefix.count)) == prefix { | 99 | for binding in bindings where binding.mode == mode && binding.keys.count > prefix.count && Array(binding.keys.prefix(prefix.count)) == prefix { |
| 86 | let next = binding.keys[prefix.count] | 100 | let next = binding.keys[prefix.count] |
| @@ -88,8 +102,9 @@ public struct Keymap: Sendable, Equatable { | |||
| 88 | guard seen[next] == nil else { continue } | 102 | guard seen[next] == nil else { continue } |
| 89 | if isPrefix(sequence, mode: mode) { | 103 | if isPrefix(sequence, mode: mode) { |
| 90 | seen[next] = .some(nil) | 104 | seen[next] = .some(nil) |
| 91 | } else if let command = candidates(for: sequence, mode: mode).first?.command { | 105 | } else { |
| 92 | seen[next] = .some(command) | 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 | return seen.map { (key: $0.key, command: $0.value) }.sorted { $0.key.description < $1.key.description } | 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 | || (first.modifiers.contains(.meta) && ["<left>", "<right>", "<up>", "<down>", "RET"].contains(first.key)) | 67 | || (first.modifiers.contains(.meta) && ["<left>", "<right>", "<up>", "<down>", "RET"].contains(first.key)) |
| 68 | || (first.modifiers.contains(.shift) && ["<left>", "<right>", "<up>", "<down>"].contains(first.key)) | 68 | || (first.modifiers.contains(.shift) && ["<left>", "<right>", "<up>", "<down>"].contains(first.key)) |
| 69 | || (first.modifiers == [.control] && first.key == "RET") | 69 | || (first.modifiers == [.control] && first.key == "RET") |
| 70 | || keys == "M-x" | 70 | || keys == "M-x" || keys == "M-q" |
| 71 | guard orgKey else { continue } | 71 | guard orgKey else { continue } |
| 72 | for mode in ["normal", "insert", "visual"] { bind(keys, binding.command, when: binding.when, mode: mode) } | 72 | for mode in ["normal", "insert", "visual"] { bind(keys, binding.command, when: binding.when, mode: mode) } |
| 73 | // evil-org: M-h/j/k/l and M-H/J/K/L in normal, visual and insert. | 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 | mode = "normal" | 441 | mode = "normal" |
| 442 | [[bind]] | 442 | [[bind]] |
| 443 | keys = "z a" | 443 | keys = "z a" |
| 444 | command = "org.cycle" | 444 | command = "org.fold.toggle" |
| 445 | mode = "normal" | 445 | mode = "normal" |
| 446 | [[bind]] | 446 | [[bind]] |
| 447 | keys = "z c" | 447 | keys = "z c" |
| 448 | command = "org.cycle" | 448 | command = "org.fold.close" |
| 449 | mode = "normal" | 449 | mode = "normal" |
| 450 | [[bind]] | 450 | [[bind]] |
| 451 | keys = "z o" | 451 | keys = "z o" |
| 452 | command = "org.cycle" | 452 | command = "org.fold.open" |
| 453 | mode = "normal" | 453 | mode = "normal" |
| 454 | [[bind]] | 454 | [[bind]] |
| 455 | keys = "z A" | 455 | keys = "z A" |
| @@ -460,14 +460,6 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | |||
| 460 | command = "org.heading.up" | 460 | command = "org.heading.up" |
| 461 | mode = "normal" | 461 | mode = "normal" |
| 462 | [[bind]] | 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 | keys = "] h" | 463 | keys = "] h" |
| 472 | command = "org.heading.forward-same-level" | 464 | command = "org.heading.forward-same-level" |
| 473 | mode = "normal" | 465 | mode = "normal" |
Sources/OrgCore/Keymap/Vim.swift +73 −14
| @@ -32,6 +32,11 @@ public struct Vim: Sendable { | |||
| 32 | case setClipboard(String) | 32 | case setClipboard(String) |
| 33 | /// Ask for a search pattern (`/`, `?`) or an ex command (`:`). | 33 | /// Ask for a search pattern (`/`, `?`) or an ex command (`:`). |
| 34 | case prompt(String) | 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 | case message(String) | 40 | case message(String) |
| 36 | /// `m`: a mark at this offset, for the editor to keep through edits. | 41 | /// `m`: a mark at this offset, for the editor to keep through edits. |
| 37 | case setMark(Character, Int) | 42 | case setMark(Character, Int) |
| @@ -58,26 +63,35 @@ public struct Vim: Sendable { | |||
| 58 | public var halfPage: Int | 63 | public var halfPage: Int |
| 59 | /// Marks where the editor keeps them now; `'` and `` ` `` are the last jump's start. | 64 | /// Marks where the editor keeps them now; `'` and `` ` `` are the last jump's start. |
| 60 | public var marks: [Character: Int] | 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 | self.text = text | 73 | self.text = text |
| 64 | self.caret = caret | 74 | self.caret = caret |
| 65 | self.clipboard = clipboard | 75 | self.clipboard = clipboard |
| 66 | self.halfPage = halfPage | 76 | self.halfPage = halfPage |
| 67 | self.marks = marks | 77 | self.marks = marks |
| 78 | self.visible = visible | ||
| 68 | } | 79 | } |
| 69 | } | 80 | } |
| 70 | 81 | ||
| 71 | public internal(set) var mode: Mode = .normal | 82 | public internal(set) var mode: Mode = .normal |
| 72 | /// Keys of the command being typed. | 83 | /// Keys of the command being typed. |
| 73 | public private(set) var pending: [KeyChord] = [] | 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 | /// What the clipboard held when this engine last wrote it, and whether that was whole lines. | 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 | var anchor = 0 | 89 | var anchor = 0 |
| 78 | var goalColumn: Int? | 90 | var goalColumn: Int? |
| 79 | var lastFind: (kind: Character, char: String)? | 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 | var lastSearch: (pattern: String, forward: Bool)? | 95 | var lastSearch: (pattern: String, forward: Bool)? |
| 82 | /// The keys and inserted text of the last change, for `.`. | 96 | /// The keys and inserted text of the last change, for `.`. |
| 83 | var lastChange: (keys: [KeyChord], inserted: String?)? | 97 | var lastChange: (keys: [KeyChord], inserted: String?)? |
| @@ -96,6 +110,10 @@ public struct Vim: Sendable { | |||
| 96 | var blockInsert: (lines: [Int], column: Int, pad: Bool)? | 110 | var blockInsert: (lines: [Int], column: Int, pad: Bool)? |
| 97 | /// The editor's marks for this key. | 111 | /// The editor's marks for this key. |
| 98 | var contextMarks: [Character: Int] = [:] | 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 | /// Keys are going into a macro register. | 117 | /// Keys are going into a macro register. |
| 100 | public internal(set) var recording = false | 118 | public internal(set) var recording = false |
| 101 | var lastMacro: Character? | 119 | var lastMacro: Character? |
| @@ -115,6 +133,9 @@ public struct Vim: Sendable { | |||
| 115 | let keys = pending | 133 | let keys = pending |
| 116 | caretForApply = context.caret | 134 | caretForApply = context.caret |
| 117 | contextMarks = context.marks | 135 | contextMarks = context.marks |
| 136 | contextHalfPage = context.halfPage | ||
| 137 | contextVisible = context.visible | ||
| 138 | contextOrg = context.org | ||
| 118 | var buffer = Buffer(context) | 139 | var buffer = Buffer(context) |
| 119 | switch parse(keys, &buffer, context) { | 140 | switch parse(keys, &buffer, context) { |
| 120 | case .incomplete: | 141 | case .incomplete: |
| @@ -253,7 +274,7 @@ public struct Vim: Sendable { | |||
| 253 | op = o | 274 | op = o |
| 254 | } else if first.key == "g", first.modifiers.isEmpty { | 275 | } else if first.key == "g", first.modifiers.isEmpty { |
| 255 | guard let second = rest.dropFirst().first else { return .incomplete } | 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 | op = "g" + second.key | 278 | op = "g" + second.key |
| 258 | afterOp = rest.dropFirst(2) | 279 | afterOp = rest.dropFirst(2) |
| 259 | } | 280 | } |
| @@ -455,8 +476,8 @@ public struct Vim: Sendable { | |||
| 455 | case ("Z", []): | 476 | case ("Z", []): |
| 456 | guard rest.count >= 2 else { return .incomplete } | 477 | guard rest.count >= 2 else { return .incomplete } |
| 457 | switch rest[rest.startIndex + 1].key { | 478 | switch rest[rest.startIndex + 1].key { |
| 458 | case "Z": return .done([.command("app.save")]) | 479 | case "Z": return .done([.ex("wq")]) |
| 459 | case "Q": return .done([.message("ZQ isn't available; use the window's close button")]) | 480 | case "Q": return .done([.ex("q!")]) |
| 460 | default: return .invalid | 481 | default: return .invalid |
| 461 | } | 482 | } |
| 462 | default: | 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 | return .done([.visualLines(rest.last == KeyChord("j") ? n : -n)]) | 489 | return .done([.visualLines(rest.last == KeyChord("j") ? n : -n)]) |
| 469 | } | 490 | } |
| 470 | 491 | ||
| @@ -505,7 +526,7 @@ public struct Vim: Sendable { | |||
| 505 | return .moved(min(limit, c + n), .exclusive) | 526 | return .moved(min(limit, c + n), .exclusive) |
| 506 | case ("j", []), ("k", []), ("<down>", []), ("<up>", []), ("d", [.control]), ("u", [.control]), ("+", []), ("-", []), ("RET", []): | 527 | case ("j", []), ("k", []), ("<down>", []), ("<up>", []), ("d", [.control]), ("u", [.control]), ("+", []), ("-", []), ("RET", []): |
| 507 | let down = ["j", "<down>", "+", "RET"].contains(key.key) || key == KeyChord("d", .control) | 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 | let goal = goalColumn ?? column(c) | 530 | let goal = goalColumn ?? column(c) |
| 510 | var line = buffer.lineStart(c) | 531 | var line = buffer.lineStart(c) |
| 511 | var moved = 0 | 532 | var moved = 0 |
| @@ -572,6 +593,11 @@ public struct Vim: Sendable { | |||
| 572 | var p = c | 593 | var p = c |
| 573 | for _ in 0..<n { p = buffer.wordEndBackward(p, bigWord: keys[1].key == "E") } | 594 | for _ in 0..<n { p = buffer.wordEndBackward(p, bigWord: keys[1].key == "E") } |
| 574 | return .moved(p, .inclusive) | 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 | case "j": | 601 | case "j": |
| 576 | return motion([KeyChord("j")], &buffer, &caret, count: n, operatorPending: operatorPending) | 602 | return motion([KeyChord("j")], &buffer, &caret, count: n, operatorPending: operatorPending) |
| 577 | case "k": | 603 | case "k": |
| @@ -589,8 +615,16 @@ public struct Vim: Sendable { | |||
| 589 | let char = keys[1].key == "SPC" ? " " : keys[1].key | 615 | let char = keys[1].key == "SPC" ? " " : keys[1].key |
| 590 | guard char.count == 1 else { return .invalid } | 616 | guard char.count == 1 else { return .invalid } |
| 591 | lastFind = (Character(key.key), char) | 617 | lastFind = (Character(key.key), char) |
| 618 | lastWasSnipe = false | ||
| 592 | guard let p = find(Character(key.key), char, buffer, c, count: n) else { return .invalid } | 619 | guard let p = find(Character(key.key), char, buffer, c, count: n) else { return .invalid } |
| 593 | return .moved(p, key.key == "f" || key.key == "t" ? .inclusive : .exclusive) | 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 | case (";", []), (",", []): | 628 | case (";", []), (",", []): |
| 595 | guard let last = lastFind else { return .invalid } | 629 | guard let last = lastFind else { return .invalid } |
| 596 | var kind = last.kind | 630 | var kind = last.kind |
| @@ -603,7 +637,8 @@ public struct Vim: Sendable { | |||
| 603 | // evil-snipe with `evil-snipe-scope` line. | 637 | // evil-snipe with `evil-snipe-scope` line. |
| 604 | guard keys.count >= 3 else { return .incomplete } | 638 | guard keys.count >= 3 else { return .incomplete } |
| 605 | let chars = keys[1].key + keys[2].key | 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 | guard let p = snipe(forward: key.key == "s", chars, buffer, c, count: n) else { return .invalid } | 642 | guard let p = snipe(forward: key.key == "s", chars, buffer, c, count: n) else { return .invalid } |
| 608 | return .moved(p, .exclusive) | 643 | return .moved(p, .exclusive) |
| 609 | case ("%", []): | 644 | case ("%", []): |
| @@ -631,6 +666,21 @@ public struct Vim: Sendable { | |||
| 631 | lastSearch = (pattern, key.key == "*") | 666 | lastSearch = (pattern, key.key == "*") |
| 632 | guard let p = buffer.search(pattern, from: buffer.wordStart(c), forward: key.key == "*", count: n) else { return .invalid } | 667 | guard let p = buffer.search(pattern, from: buffer.wordStart(c), forward: key.key == "*", count: n) else { return .invalid } |
| 633 | return .moved(p, .exclusive) | 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 | default: | 684 | default: |
| 635 | return .invalid | 685 | return .invalid |
| 636 | } | 686 | } |
| @@ -660,9 +710,12 @@ public struct Vim: Sendable { | |||
| 660 | return p | 710 | return p |
| 661 | } | 711 | } |
| 662 | 712 | ||
| 663 | func snipe(forward: Bool, _ chars: String, _ buffer: Buffer, _ c: Int, count n: Int) -> Int? { | 713 | /// Searches the caret's line, or `scope` when given. |
| 664 | let line = buffer.substring(buffer.lineStart(c)..<buffer.lineEnd(c)) as NSString | 714 | func snipe(forward: Bool, _ chars: String, _ buffer: Buffer, _ c: Int, count n: Int, scope: Range<Int>? = nil) -> Int? { |
| 665 | let start = buffer.lineStart(c) | 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 | let smart = chars == chars.lowercased() | 719 | let smart = chars == chars.lowercased() |
| 667 | var p = c - start | 720 | var p = c - start |
| 668 | for _ in 0..<n { | 721 | for _ in 0..<n { |
| @@ -826,6 +879,12 @@ public struct Vim: Sendable { | |||
| 826 | case "gc": | 879 | case "gc": |
| 827 | lastChange = (wasVisual ? [] : keys, nil) | 880 | lastChange = (wasVisual ? [] : keys, nil) |
| 828 | return [.edit(Self.toggleComments(buffer, range), caret: buffer.lineStart(range.lowerBound))] | 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 | default: | 888 | default: |
| 830 | let transformed: String | 889 | let transformed: String |
| 831 | switch op { | 890 | switch op { |
| @@ -914,7 +973,7 @@ public struct Vim: Sendable { | |||
| 914 | caret += inserted.utf16.count | 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 | for action in produced { | 977 | for action in produced { |
| 919 | switch action { | 978 | switch action { |
| 920 | case .edit(let edits, let to): | 979 | case .edit(let edits, let to): |
Sources/OrgCore/Keymap/VimExtras.swift +56 −5
| @@ -253,14 +253,23 @@ extension Vim { | |||
| 253 | var node: SyntaxNode? | 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 | let tree = OrgParser.parse(buffer.ns as String) | 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 | var chain: [SyntaxNode] = [] | 262 | var chain: [SyntaxNode] = [] |
| 259 | var node = tree.root | 263 | func look(at p: Int) { |
| 260 | while let child = node.child(containing: c) { | 264 | c = p |
| 261 | chain.append(child) | 265 | chain = [] |
| 262 | node = child | 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 | /// The end of the line ending at or after `p`, without trailing blank lines. | 273 | /// The end of the line ending at or after `p`, without trailing blank lines. |
| 265 | func trimmed(_ lower: Int, _ upper: Int) -> Int { | 274 | func trimmed(_ lower: Int, _ upper: Int) -> Int { |
| 266 | var end = upper | 275 | var end = upper |
| @@ -353,7 +362,49 @@ extension Vim { | |||
| 353 | if let contents = e.contents { return contents } | 362 | if let contents = e.contents { return contents } |
| 354 | return e.begin..<max(e.begin, trimmed(e.begin, e.end) - (buffer.char(trimmed(e.begin, e.end) - 1) == 10 ? 1 : 0)) | 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 | switch key { | 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 | case "R": | 408 | case "R": |
| 358 | guard let headline = chain.last(where: { $0.kind == .section }).map(element) else { return nil } | 409 | guard let headline = chain.last(where: { $0.kind == .section }).map(element) else { return nil } |
| 359 | return (inner ? (headline.contents ?? headline.begin..<headline.begin) : headline.begin..<headline.end, true) | 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 | public static let all: [EditorCommand] = Commands.everything.map { EditorCommand(id: $0.id, title: $0.title, action: .text($0)) } + [ | 37 | public static let all: [EditorCommand] = Commands.everything.map { EditorCommand(id: $0.id, title: $0.title, action: .text($0)) } + [ |
| 38 | EditorCommand(id: "org.cycle", title: "Cycle Visibility", action: .view), | 38 | EditorCommand(id: "org.cycle", title: "Cycle Visibility", action: .view), |
| 39 | EditorCommand(id: "org.cycle-global", title: "Cycle Global Visibility", action: .view), | 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 | EditorCommand(id: "org.narrow.subtree", title: "Narrow to Subtree", action: .view), | 43 | EditorCommand(id: "org.narrow.subtree", title: "Narrow to Subtree", action: .view), |
| 41 | EditorCommand(id: "org.narrow.block", title: "Narrow to Block", action: .view), | 44 | EditorCommand(id: "org.narrow.block", title: "Narrow to Block", action: .view), |
| 42 | EditorCommand(id: "org.widen", title: "Widen", action: .view), | 45 | EditorCommand(id: "org.widen", title: "Widen", action: .view), |
Sources/OrgEditorAppKit/Modal.swift +73 −11
| @@ -3,6 +3,11 @@ import AppKit | |||
| 3 | import OrgCore | 3 | import OrgCore |
| 4 | import OrgDocument | 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 | /// Modal editing: keys go to the keymap for the current evil state first (leader keys, | 11 | /// Modal editing: keys go to the keymap for the current evil state first (leader keys, |
| 7 | /// evil-org's org keys), then to `Vim`. | 12 | /// evil-org's org keys), then to `Vim`. |
| 8 | extension OrgEditor { | 13 | extension OrgEditor { |
| @@ -16,7 +21,8 @@ extension OrgEditor { | |||
| 16 | } | 21 | } |
| 17 | switch dispatcher.feed(chord) { | 22 | switch dispatcher.feed(chord) { |
| 18 | case .pending(let keys): | 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 | (key: item.key.description, title: item.command.map { EditorCommands.command($0)?.title ?? $0 } ?? "+prefix") | 26 | (key: item.key.description, title: item.command.map { EditorCommands.command($0)?.title ?? $0 } ?? "+prefix") |
| 21 | } | 27 | } |
| 22 | onKeysPending?(KeySequence.format(keys), next) | 28 | onKeysPending?(KeySequence.format(keys), next) |
| @@ -27,9 +33,7 @@ extension OrgEditor { | |||
| 27 | case .complete(let keys, let candidates): | 33 | case .complete(let keys, let candidates): |
| 28 | if keys.count > 1 { onKeysPending?(nil, []) } | 34 | if keys.count > 1 { onKeysPending?(nil, []) } |
| 29 | let context = editContext() | 35 | let context = editContext() |
| 30 | if let binding = candidates.first(where: { binding in | 36 | if let binding = candidates.first(where: { modalRuns($0, in: context) }) { |
| 31 | (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && commandApplies(binding.command, in: context) | ||
| 32 | }) { | ||
| 33 | run(binding.command) | 37 | run(binding.command) |
| 34 | updateCursor() | 38 | updateCursor() |
| 35 | return true | 39 | return true |
| @@ -37,6 +41,12 @@ extension OrgEditor { | |||
| 37 | return feedVim(keys) | 41 | return feedVim(keys) |
| 38 | case .undefined(let keys): | 42 | case .undefined(let keys): |
| 39 | onKeysPending?(nil, []) | 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 | return feedVim(keys) | 50 | return feedVim(keys) |
| 41 | case .unbound: | 51 | case .unbound: |
| 42 | return feedVim([chord]) | 52 | return feedVim([chord]) |
| @@ -62,10 +72,16 @@ extension OrgEditor { | |||
| 62 | let wasInsert = vim.mode == .insert | 72 | let wasInsert = vim.mode == .insert |
| 63 | let visual = vim.mode == .visual || vim.mode == .visualLine | 73 | let visual = vim.mode == .visual || vim.mode == .visualLine |
| 64 | let caret = visual ? (visualCaret ?? textView.selectedRange().location) : textView.selectedRange().location | 74 | let caret = visual ? (visualCaret ?? textView.selectedRange().location) : textView.selectedRange().location |
| 75 | if jumpToFileMark(key) { continue } | ||
| 65 | let clipboard = NSPasteboard.general.string(forType: .string) | 76 | let clipboard = NSPasteboard.general.string(forType: .string) |
| 66 | var marks = vimMarks | 77 | var marks = vimMarks |
| 78 | for (name, mark) in Self.fileMarks where mark.owner.editor === self { marks[name] = mark.offset } | ||
| 67 | if jumpIndex > 0, jumpIndex <= jumpList.count { marks["'"] = jumpList[jumpIndex - 1] } | 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 | apply(actions) | 85 | apply(actions) |
| 70 | if !wasInsert, vim.mode == .insert { | 86 | if !wasInsert, vim.mode == .insert { |
| 71 | insertSnapshot = (document.text, textView.selectedRange().location) | 87 | insertSnapshot = (document.text, textView.selectedRange().location) |
| @@ -78,13 +94,35 @@ extension OrgEditor { | |||
| 78 | return true | 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 | /// Moves marks and jump positions through an edit. | 115 | /// Moves marks and jump positions through an edit. |
| 82 | func mapVimPositions(through edit: TextEdit) { | 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 | let names = Array(vimMarks.keys) | 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 | for (i, name) in names.enumerated() { vimMarks[name] = mapped[i].lowerBound } | 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 | func insertTyped(_ key: KeyChord) { | 128 | func insertTyped(_ key: KeyChord) { |
| @@ -99,11 +137,28 @@ extension OrgEditor { | |||
| 99 | return max(1, Int(scroll.contentView.bounds.height / lineHeight / 2)) | 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 | private func editContext() -> EditContext { | 153 | private func editContext() -> EditContext { |
| 103 | let selected = textView.selectedRange() | 154 | let selected = textView.selectedRange() |
| 104 | return EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: [selected.location..<NSMaxRange(selected)], options: options) | 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 | private func commandApplies(_ id: String, in context: EditContext) -> Bool { | 162 | private func commandApplies(_ id: String, in context: EditContext) -> Bool { |
| 108 | if !isOrg, id.hasPrefix("org.") { return false } | 163 | if !isOrg, id.hasPrefix("org.") { return false } |
| 109 | return switch EditorCommands.command(id)?.action { | 164 | return switch EditorCommands.command(id)?.action { |
| @@ -148,10 +203,17 @@ extension OrgEditor { | |||
| 148 | NSPasteboard.general.setString(text, forType: .string) | 203 | NSPasteboard.general.setString(text, forType: .string) |
| 149 | case .prompt(let kind): | 204 | case .prompt(let kind): |
| 150 | prompt(kind) | 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 | case .message(let text): | 213 | case .message(let text): |
| 152 | onMessage?(text) | 214 | onMessage?(text) |
| 153 | case .setMark(let name, let offset): | 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 | case .jumped(let from): | 217 | case .jumped(let from): |
| 156 | // A new jump drops the ones C-o went back past. | 218 | // A new jump drops the ones C-o went back past. |
| 157 | jumpList = Array(jumpList.prefix(jumpIndex)) | 219 | jumpList = Array(jumpList.prefix(jumpIndex)) |
| @@ -178,12 +240,12 @@ extension OrgEditor { | |||
| 178 | onMessage?("Defining keyboard macro…") | 240 | onMessage?("Defining keyboard macro…") |
| 179 | } else if let register = recordingRegister { | 241 | } else if let register = recordingRegister { |
| 180 | // Without the `q` that ended it. | 242 | // Without the `q` that ended it. |
| 181 | macros[register] = Array(recordedKeys.dropLast()) | 243 | Self.macros[register] = Array(recordedKeys.dropLast()) |
| 182 | recordingRegister = nil | 244 | recordingRegister = nil |
| 183 | onMessage?("Keyboard macro defined") | 245 | onMessage?("Keyboard macro defined") |
| 184 | } | 246 | } |
| 185 | case .playMacro(let name, let count): | 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 | onMessage?("No macro in register \(name)") | 249 | onMessage?("No macro in register \(name)") |
| 188 | continue | 250 | continue |
| 189 | } | 251 | } |
Sources/OrgEditorAppKit/OrgEditor.swift +36 −6
| @@ -365,15 +365,23 @@ public final class OrgEditor: NSObject { | |||
| 365 | var insertSnapshot: (text: String, caret: Int)? | 365 | var insertSnapshot: (text: String, caret: Int)? |
| 366 | /// The evil cursor in visual states, where the selection's start isn't it. | 366 | /// The evil cursor in visual states, where the selection's start isn't it. |
| 367 | var visualCaret: Int? | 367 | var visualCaret: Int? |
| 368 | /// Vim marks, mapped through edits. | 368 | /// Vim's lowercase marks, mapped through edits. |
| 369 | var vimMarks: [Character: Int] = [:] | 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 | /// Jump positions, oldest first, mapped through edits; `jumpIndex` is where C-o and C-i are. | 379 | /// Jump positions, oldest first, mapped through edits; `jumpIndex` is where C-o and C-i are. |
| 371 | var jumpList: [Int] = [] | 380 | var jumpList: [Int] = [] |
| 372 | var jumpIndex = 0 | 381 | var jumpIndex = 0 |
| 373 | /// The register keys go into while `q` records, and its keys so far. | 382 | /// The register keys go into while `q` records, and its keys so far. |
| 374 | var recordingRegister: Character? | 383 | var recordingRegister: Character? |
| 375 | var recordedKeys: [KeyChord] = [] | 384 | var recordedKeys: [KeyChord] = [] |
| 376 | var macros: [Character: [KeyChord]] = [:] | ||
| 377 | var replayingMacro = 0 | 385 | var replayingMacro = 0 |
| 378 | /// A region is active: movement extends the selection, as after C-SPC in Emacs. | 386 | /// A region is active: movement extends the selection, as after C-SPC in Emacs. |
| 379 | private(set) var markActive = false | 387 | private(set) var markActive = false |
| @@ -395,7 +403,8 @@ public final class OrgEditor: NSObject { | |||
| 395 | if modal { return handleModal(chord) } | 403 | if modal { return handleModal(chord) } |
| 396 | switch dispatcher.feed(chord) { | 404 | switch dispatcher.feed(chord) { |
| 397 | case .pending(let keys): | 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 | (key: item.key.description, title: item.command.map { EditorCommands.command($0)?.title ?? $0 } ?? "+prefix") | 408 | (key: item.key.description, title: item.command.map { EditorCommands.command($0)?.title ?? $0 } ?? "+prefix") |
| 400 | } | 409 | } |
| 401 | onKeysPending?(KeySequence.format(keys), next) | 410 | onKeysPending?(KeySequence.format(keys), next) |
| @@ -413,9 +422,7 @@ public final class OrgEditor: NSObject { | |||
| 413 | case .complete(let keys, let candidates): | 422 | case .complete(let keys, let candidates): |
| 414 | if keys.count > 1 { onKeysPending?(nil, []) } | 423 | if keys.count > 1 { onKeysPending?(nil, []) } |
| 415 | let context = context() | 424 | let context = context() |
| 416 | let runnable = candidates.first { binding in | 425 | let runnable = candidates.first { runs($0, in: context) } |
| 417 | (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && applies(binding.command, in: context) | ||
| 418 | } | ||
| 419 | if let binding = runnable ?? (keys.count > 1 ? candidates.first : nil) { | 426 | if let binding = runnable ?? (keys.count > 1 ? candidates.first : nil) { |
| 420 | run(binding.command) | 427 | run(binding.command) |
| 421 | return true | 428 | return true |
| @@ -435,6 +442,11 @@ public final class OrgEditor: NSObject { | |||
| 435 | return true | 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 | private func applies(_ id: String, in context: EditContext) -> Bool { | 450 | private func applies(_ id: String, in context: EditContext) -> Bool { |
| 439 | if !isOrg, id.hasPrefix("org.") { return false } | 451 | if !isOrg, id.hasPrefix("org.") { return false } |
| 440 | return switch EditorCommands.command(id)?.action { | 452 | return switch EditorCommands.command(id)?.action { |
| @@ -474,6 +486,7 @@ public final class OrgEditor: NSObject { | |||
| 474 | switch id { | 486 | switch id { |
| 475 | case "org.cycle": cycleAtCaret() | 487 | case "org.cycle": cycleAtCaret() |
| 476 | case "org.cycle-global": cycleGlobal() | 488 | case "org.cycle-global": cycleGlobal() |
| 489 | case "org.fold.toggle", "org.fold.open", "org.fold.close": fold(id) | ||
| 477 | case "org.narrow.subtree", "org.narrow.block", "org.narrow.toggle", "org.widen": narrow(id) | 490 | case "org.narrow.subtree", "org.narrow.block", "org.narrow.toggle", "org.widen": narrow(id) |
| 478 | case "org.toggle-inline-images": toggleImages() | 491 | case "org.toggle-inline-images": toggleImages() |
| 479 | case "editor.toggle-spell-check": toggleSpellCheck() | 492 | case "editor.toggle-spell-check": toggleSpellCheck() |
| @@ -940,6 +953,23 @@ public final class OrgEditor: NSObject { | |||
| 940 | return true | 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 | /// Shift-TAB: overview, contents, show all. | 973 | /// Shift-TAB: overview, contents, show all. |
| 944 | public func cycleGlobal() { | 974 | public func cycleGlobal() { |
| 945 | leaveSparseTree() | 975 | leaveSparseTree() |
Sources/OrgPresentation/Folding.swift +36
| @@ -87,6 +87,42 @@ extension Presentation { | |||
| 87 | return next | 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 | /// TAB at `offset` (`org-cycle`): on the first or last line of a drawer or block, folds or | 126 | /// TAB at `offset` (`org-cycle`): on the first or last line of a drawer or block, folds or |
| 91 | /// unfolds it; on a heading, cycles it. Nil elsewhere. | 127 | /// unfolds it; on a heading, cycles it. Nil elsewhere. |
| 92 | public static func cycle(at offset: Int, in tree: OrgTree, _ state: FoldState) -> FoldState? { | 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 | private func loadKeymap() { | 500 | private func loadKeymap() { |
| 501 | let loaded = KeymapLoader.load(preset) | 501 | let loaded = KeymapLoader.load(preset, commands: Set(EditorCommands.all.map(\.id))) |
| 502 | keymap = loaded.keymap | 502 | keymap = loaded.keymap |
| 503 | commands.keymap = loaded.keymap | 503 | commands.keymap = loaded.keymap |
| 504 | commands.modal = preset.isModal | 504 | commands.modal = preset.isModal |
Sources/Orgstar/EditorView.swift +7 −2
| @@ -51,7 +51,7 @@ struct EditorView: NSViewRepresentable { | |||
| 51 | coordinator.path = session.path | 51 | coordinator.path = session.path |
| 52 | container.subviews.forEach { $0.removeFromSuperview() } | 52 | container.subviews.forEach { $0.removeFromSuperview() } |
| 53 | coordinator.editor = nil | 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 | coordinator.editors[path] = shown | 55 | coordinator.editors[path] = shown |
| 56 | shown.scroll.frame = container.bounds | 56 | shown.scroll.frame = container.bounds |
| 57 | container.addSubview(shown.scroll) | 57 | container.addSubview(shown.scroll) |
| @@ -80,7 +80,7 @@ struct EditorView: NSViewRepresentable { | |||
| 80 | } | 80 | } |
| 81 | 81 | ||
| 82 | /// An editor for the current buffer, which becomes the buffer's live state. | 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 | guard let document = session.document else { return nil } | 84 | guard let document = session.document else { return nil } |
| 85 | let mode: OrgEditor.Mode = session.isOrg ? .org : .plain(language: session.path.flatMap(FileTypes.language)) | 85 | let mode: OrgEditor.Mode = session.isOrg ? .org : .plain(language: session.path.flatMap(FileTypes.language)) |
| 86 | let editor = OrgEditor(document: document, frame: container.bounds, editable: document.isEditable, highlighter: Self.highlighter, mode: mode, options: options) | 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 | session?.open(target) | 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 | editor.onExternal = onExternal | 113 | editor.onExternal = onExternal |
| 109 | editor.knownTags = knownTags | 114 | editor.knownTags = knownTags |
| 110 | editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) } | 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 | #expect(problems.count == 3) | 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 | @Test func laterLayersWinAndCanUnbind() throws { | 101 | @Test func laterLayersWinAndCanUnbind() throws { |
| 75 | let preset = KeymapPreset.emacs.keymap | 102 | let preset = KeymapPreset.emacs.keymap |
| 76 | let (user, _) = try map(""" | 103 | let (user, _) = try map(""" |
| @@ -167,6 +194,19 @@ struct KeymapTests { | |||
| 167 | #expect(keymap.continuations(of: []).contains { $0.key == KeyChord("c", .control) && $0.command == nil }) | 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 | @Test func contexts() { | 210 | @Test func contexts() { |
| 171 | let text = "* a\n| x |\n- item\n" | 211 | let text = "* a\n| x |\n- item\n" |
| 172 | let tree = OrgParser.parse(text) | 212 | let tree = OrgParser.parse(text) |
Tests/OrgCoreTests/VimExtrasTests.swift +72 −2
| @@ -38,6 +38,35 @@ struct VimExtrasTests { | |||
| 38 | #expect(run("abcdef\n", 1, "v l l ESC 0 g v d").text == "aef\n") | 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 | @Test func blockVisual() { | 70 | @Test func blockVisual() { |
| 42 | #expect(run("abc\ndef\nghi\n", 0, "C-v j l d").text == "c\nf\nghi\n") | 71 | #expect(run("abc\ndef\nghi\n", 0, "C-v j l d").text == "c\nf\nghi\n") |
| 43 | #expect(run("abc\ndef\nghi\n", 0, "C-v j l y j j P").text == "abc\ndef\nabghi\n" || true) | 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 | ("a =code= b\n", 3, "d i e"), | 146 | ("a =code= b\n", 3, "d i e"), |
| 118 | ("a =code= b\n", 3, "d a e"), | 147 | ("a =code= b\n", 3, "d a e"), |
| 119 | ("a [[https://x.org]] b\n", 5, "d i e"), | 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 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | 192 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) |
| @@ -134,7 +203,7 @@ struct VimExtrasTests { | |||
| 134 | (require 'evil-surround) | 203 | (require 'evil-surround) |
| 135 | (setq kill-ring nil) | 204 | (setq kill-ring nil) |
| 136 | (switch-to-buffer (current-buffer)) | 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 | (evil-local-mode 1) | 207 | (evil-local-mode 1) |
| 139 | (evil-surround-mode 1) | 208 | (evil-surround-mode 1) |
| 140 | (evil-normal-state) | 209 | (evil-normal-state) |
| @@ -148,9 +217,10 @@ struct VimExtrasTests { | |||
| 148 | """) | 217 | """) |
| 149 | } | 218 | } |
| 150 | let results = try EmacsOracle.run(forms) | 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 | let (text, point, keys) = c | 221 | let (text, point, keys) = c |
| 153 | var driver = VimDriver(text, caret: point) | 222 | var driver = VimDriver(text, caret: point) |
| 223 | driver.org = org | ||
| 154 | driver.type(KeySequence.parse(keys)!) | 224 | driver.type(KeySequence.parse(keys)!) |
| 155 | guard let marker = result.text.range(of: "\n@@STATE ") else { | 225 | guard let marker = result.text.range(of: "\n@@STATE ") else { |
| 156 | Issue.record("\(keys): \(result.error)") | 226 | Issue.record("\(keys): \(result.error)") |
Tests/OrgCoreTests/VimTests.swift +76 −1
| @@ -10,6 +10,9 @@ struct VimDriver { | |||
| 10 | var caret: Int | 10 | var caret: Int |
| 11 | var clipboard: String? | 11 | var clipboard: String? |
| 12 | var commands: [String] = [] | 12 | var commands: [String] = [] |
| 13 | var halfPage = 15 | ||
| 14 | var visible: Range<Int>? | ||
| 15 | var org = false | ||
| 13 | 16 | ||
| 14 | init(_ text: String, caret: Int) { | 17 | init(_ text: String, caret: Int) { |
| 15 | self.text = text | 18 | self.text = text |
| @@ -36,7 +39,7 @@ struct VimDriver { | |||
| 36 | } | 39 | } |
| 37 | continue | 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 | for action in actions { | 43 | for action in actions { |
| 41 | switch action { | 44 | switch action { |
| 42 | case .edit(let edits, let to): | 45 | case .edit(let edits, let to): |
| @@ -45,6 +48,10 @@ struct VimDriver { | |||
| 45 | case .move(let to): caret = to | 48 | case .move(let to): caret = to |
| 46 | case .setClipboard(let s): clipboard = s | 49 | case .setClipboard(let s): clipboard = s |
| 47 | case .command(let id): commands.append(id) | 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 | default: break | 55 | default: break |
| 49 | } | 56 | } |
| 50 | } | 57 | } |
| @@ -131,6 +138,11 @@ struct VimTests { | |||
| 131 | ("long line here\nab\nlonger line\n", 10, "j j x"), | 138 | ("long line here\nab\nlonger line\n", 10, "j j x"), |
| 132 | ("one two three\n", 0, "s t h x"), | 139 | ("one two three\n", 0, "s t h x"), |
| 133 | ("one two three\n", 12, "S t w x"), | 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 | ("one two three\n", 0, "\" a y w w \" a P"), | 146 | ("one two three\n", 0, "\" a y w w \" a P"), |
| 135 | ("one two three\n", 0, "\" _ d w"), | 147 | ("one two three\n", 0, "\" _ d w"), |
| 136 | (" one\n", 2, "o x ESC"), | 148 | (" one\n", 2, "o x ESC"), |
| @@ -183,6 +195,69 @@ struct VimTests { | |||
| 183 | ("one two three\n", 0, "v 2 e c X ESC"), | 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 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | 261 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) |
| 187 | func matchesEvil() throws { | 262 | func matchesEvil() throws { |
| 188 | let evil = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/emacs/.local/straight/repos") | 263 | let evil = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/emacs/.local/straight/repos") |
Tests/OrgEditorAppKitTests/ModalTests.swift +138
| @@ -1,5 +1,6 @@ | |||
| 1 | import AppKit | 1 | import AppKit |
| 2 | import OrgCore | 2 | import OrgCore |
| 3 | import OrgPresentation | ||
| 3 | import Testing | 4 | import Testing |
| 4 | @testable import OrgEditorAppKit | 5 | @testable import OrgEditorAppKit |
| 5 | 6 | ||
| @@ -72,6 +73,131 @@ struct ModalTests { | |||
| 72 | #expect(h.editor.vim.mode == .normal) | 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 | @Test func visualSelection() { | 201 | @Test func visualSelection() { |
| 76 | let h = doom("one two three\n") | 202 | let h = doom("one two three\n") |
| 77 | h.caret(at: 0) | 203 | h.caret(at: 0) |
| @@ -160,3 +286,15 @@ struct ModalTests { | |||
| 160 | #expect(h.textView.selectedRange().location == 23) | 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 | import AppKit | 1 | import AppKit |
| 2 | import OrgCore | 2 | import OrgCore |
| 3 | import OrgDocument | ||
| 3 | import Testing | 4 | import Testing |
| 4 | @testable import OrgEditorAppKit | 5 | @testable import OrgEditorAppKit |
| 5 | 6 | ||
| @@ -44,9 +45,9 @@ struct ViewToggleTests { | |||
| 44 | 45 | ||
| 45 | @Test func gjMovesByScreenLines() { | 46 | @Test func gjMovesByScreenLines() { |
| 46 | let long = Array(repeating: "word", count: 60).joined(separator: " ") | 47 | let long = Array(repeating: "word", count: 60).joined(separator: " ") |
| 47 | let h = Harness(long + "\nnext\n") | 48 | // In Org files gj is evil-org's element motion; elsewhere it's screen lines. |
| 48 | // Doom binds `g j` to the next heading; without that binding it's the engine's. | 49 | let h = Harness(DocumentState(bytes: Array((long + "\nnext\n").utf8)), mode: .plain(language: nil)) |
| 49 | h.editor.keymap = KeymapPreset.emacs.keymap | 50 | h.editor.keymap = KeymapPreset.doom.keymap |
| 50 | h.editor.modal = true | 51 | h.editor.modal = true |
| 51 | h.caret(at: 0) | 52 | h.caret(at: 0) |
| 52 | h.caret(at: 0) | 53 | h.caret(at: 0) |