Commit 72065ee289
Verified · cmc
Layout: unified · split
Sources/OrgCore/Keymap/Presets.swift −8
| @@ -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 +13 −3
| @@ -62,8 +62,11 @@ public struct Vim: Sendable { | |||
| 62 | public var marks: [Character: Int] | 62 | public var marks: [Character: Int] |
| 63 | /// The text the window shows, for H, M and L; nil when unknown. | 63 | /// The text the window shows, for H, M and L; nil when unknown. |
| 64 | public var visible: Range<Int>? | 64 | public var visible: Range<Int>? |
| 65 | /// An Org buffer, where evil-org's keys apply. | ||
| 66 | public var org: Bool | ||
| 65 | 67 | ||
| 66 | public init(text: String, caret: Int, clipboard: String? = nil, halfPage: Int = 15, marks: [Character: Int] = [:], visible: Range<Int>? = nil) { | 68 | public init(text: String, caret: Int, clipboard: String? = nil, halfPage: Int = 15, marks: [Character: Int] = [:], visible: Range<Int>? = nil, org: Bool = false) { |
| 69 | self.org = org | ||
| 67 | self.text = text | 70 | self.text = text |
| 68 | self.caret = caret | 71 | self.caret = caret |
| 69 | self.clipboard = clipboard | 72 | self.clipboard = clipboard |
| @@ -105,6 +108,7 @@ public struct Vim: Sendable { | |||
| 105 | /// The context's half page and visible text for this key. | 108 | /// The context's half page and visible text for this key. |
| 106 | var contextHalfPage = 15 | 109 | var contextHalfPage = 15 |
| 107 | var contextVisible: Range<Int>? | 110 | var contextVisible: Range<Int>? |
| 111 | var contextOrg = false | ||
| 108 | /// Keys are going into a macro register. | 112 | /// Keys are going into a macro register. |
| 109 | public internal(set) var recording = false | 113 | public internal(set) var recording = false |
| 110 | var lastMacro: Character? | 114 | var lastMacro: Character? |
| @@ -126,6 +130,7 @@ public struct Vim: Sendable { | |||
| 126 | contextMarks = context.marks | 130 | contextMarks = context.marks |
| 127 | contextHalfPage = context.halfPage | 131 | contextHalfPage = context.halfPage |
| 128 | contextVisible = context.visible | 132 | contextVisible = context.visible |
| 133 | contextOrg = context.org | ||
| 129 | var buffer = Buffer(context) | 134 | var buffer = Buffer(context) |
| 130 | switch parse(keys, &buffer, context) { | 135 | switch parse(keys, &buffer, context) { |
| 131 | case .incomplete: | 136 | case .incomplete: |
| @@ -475,7 +480,7 @@ public struct Vim: Sendable { | |||
| 475 | } | 480 | } |
| 476 | } | 481 | } |
| 477 | 482 | ||
| 478 | if mode == .normal, rest.count == 2, first == KeyChord("g"), rest.last == KeyChord("j") || rest.last == KeyChord("k") { | 483 | if mode == .normal, !contextOrg, rest.count == 2, first == KeyChord("g"), rest.last == KeyChord("j") || rest.last == KeyChord("k") { |
| 479 | return .done([.visualLines(rest.last == KeyChord("j") ? n : -n)]) | 484 | return .done([.visualLines(rest.last == KeyChord("j") ? n : -n)]) |
| 480 | } | 485 | } |
| 481 | 486 | ||
| @@ -583,6 +588,11 @@ public struct Vim: Sendable { | |||
| 583 | var p = c | 588 | var p = c |
| 584 | for _ in 0..<n { p = buffer.wordEndBackward(p, bigWord: keys[1].key == "E") } | 589 | for _ in 0..<n { p = buffer.wordEndBackward(p, bigWord: keys[1].key == "E") } |
| 585 | return .moved(p, .inclusive) | 590 | return .moved(p, .inclusive) |
| 591 | case "j" where contextOrg, "k" where contextOrg: | ||
| 592 | // evil-org: org-forward-element and org-backward-element, which take no count. | ||
| 593 | guard let p = Self.orgObject(keys[1].key, inner: false, buffer, c)?.range.lowerBound, p != c else { return .invalid } | ||
| 594 | goalColumn = nil | ||
| 595 | return .moved(p, .exclusive) | ||
| 586 | case "j": | 596 | case "j": |
| 587 | return motion([KeyChord("j")], &buffer, &caret, count: n, operatorPending: operatorPending) | 597 | return motion([KeyChord("j")], &buffer, &caret, count: n, operatorPending: operatorPending) |
| 588 | case "k": | 598 | case "k": |
| @@ -940,7 +950,7 @@ public struct Vim: Sendable { | |||
| 940 | caret += inserted.utf16.count | 950 | caret += inserted.utf16.count |
| 941 | } | 951 | } |
| 942 | } | 952 | } |
| 943 | let produced = copy.feed(key, Context(text: text, caret: caret, clipboard: context.clipboard, halfPage: context.halfPage, visible: context.visible)) | 953 | let produced = copy.feed(key, Context(text: text, caret: caret, clipboard: context.clipboard, halfPage: context.halfPage, visible: context.visible, org: context.org)) |
| 944 | for action in produced { | 954 | for action in produced { |
| 945 | switch action { | 955 | switch action { |
| 946 | case .edit(let edits, let to): | 956 | 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/Modal.swift +1 −1
| @@ -79,7 +79,7 @@ extension OrgEditor { | |||
| 79 | if jumpIndex > 0, jumpIndex <= jumpList.count { marks["'"] = jumpList[jumpIndex - 1] } | 79 | if jumpIndex > 0, jumpIndex <= jumpList.count { marks["'"] = jumpList[jumpIndex - 1] } |
| 80 | vim.registers = Self.vimRegisters | 80 | vim.registers = Self.vimRegisters |
| 81 | vim.clipboardRecord = Self.vimClipboardRecord | 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)) | 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 | 83 | Self.vimRegisters = vim.registers |
| 84 | Self.vimClipboardRecord = vim.clipboardRecord | 84 | Self.vimClipboardRecord = vim.clipboardRecord |
| 85 | apply(actions) | 85 | apply(actions) |
Tests/OrgCoreTests/VimExtrasTests.swift +43 −2
| @@ -38,6 +38,25 @@ 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 elementMotionsStop() { | ||
| 42 | let text = "intro\n\n* H\npara one\n* Next\nend\n" | ||
| 43 | func moved(_ caret: Int, _ keys: String, org: Bool = true) -> Int { | ||
| 44 | var driver = VimDriver(text, caret: caret) | ||
| 45 | driver.org = org | ||
| 46 | driver.type(KeySequence.parse(keys)!) | ||
| 47 | return driver.caret | ||
| 48 | } | ||
| 49 | #expect(moved(0, "g k") == 0) | ||
| 50 | #expect(moved(7, "g k") == 7) | ||
| 51 | #expect(moved(20, "g j") == 20) | ||
| 52 | // A count doesn't repeat them: org-forward-element takes no argument. | ||
| 53 | #expect(moved(0, "2 g j") == 7) | ||
| 54 | // Outside Org, gj and gk are screen lines, which the editor moves. | ||
| 55 | var vim = Vim() | ||
| 56 | _ = vim.feed(KeyChord("g"), Vim.Context(text: text, caret: 0)) | ||
| 57 | #expect(vim.feed(KeyChord("j"), Vim.Context(text: text, caret: 0)) == [.visualLines(1)]) | ||
| 58 | } | ||
| 59 | |||
| 41 | @Test func blockVisual() { | 60 | @Test func blockVisual() { |
| 42 | #expect(run("abc\ndef\nghi\n", 0, "C-v j l d").text == "c\nf\nghi\n") | 61 | #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) | 62 | #expect(run("abc\ndef\nghi\n", 0, "C-v j l y j j P").text == "abc\ndef\nabghi\n" || true) |
| @@ -117,7 +136,28 @@ struct VimExtrasTests { | |||
| 117 | ("a =code= b\n", 3, "d i e"), | 136 | ("a =code= b\n", 3, "d i e"), |
| 118 | ("a =code= b\n", 3, "d a e"), | 137 | ("a =code= b\n", 3, "d a e"), |
| 119 | ("a [[https://x.org]] b\n", 5, "d i e"), | 138 | ("a [[https://x.org]] b\n", 5, "d i e"), |
| 139 | ] + elementCases | ||
| 140 | }() | ||
| 141 | |||
| 142 | /// evil-org's gj and gk: org-forward-element and org-backward-element. | ||
| 143 | static let elementCases: [(String, Int, String)] = { | ||
| 144 | 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" | ||
| 145 | let at = { (s: String) in (text as NSString).range(of: s).location } | ||
| 146 | var cases: [(String, Int, String)] = [] | ||
| 147 | // Org signals an error, and evil stays, for g k at 0 and on the first heading, and g j on | ||
| 148 | // the last one: see `elementMotionsStop`. | ||
| 149 | 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")] { | ||
| 150 | if start != at("* Next") { cases.append((text, start, "g j")) } | ||
| 151 | if start != 0, start != at("* H") { cases.append((text, start, "g k")) } | ||
| 152 | } | ||
| 153 | cases += [ | ||
| 154 | (text, at("para"), "g j g j g j"), | ||
| 155 | (text, at("end"), "g k g k"), | ||
| 156 | (text, at("para"), "3 g j"), | ||
| 157 | (text, at("para"), "d g j"), | ||
| 158 | (text, at("* H"), "d g j"), | ||
| 120 | ] | 159 | ] |
| 160 | return cases | ||
| 121 | }() | 161 | }() |
| 122 | 162 | ||
| 123 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | 163 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) |
| @@ -134,7 +174,7 @@ struct VimExtrasTests { | |||
| 134 | (require 'evil-surround) | 174 | (require 'evil-surround) |
| 135 | (setq kill-ring nil) | 175 | (setq kill-ring nil) |
| 136 | (switch-to-buffer (current-buffer)) | 176 | (switch-to-buffer (current-buffer)) |
| 137 | \(org ? "(org-mode) (require 'evil-org) (evil-org-set-key-theme '(textobjects)) (evil-org-mode 1)" : "") | 177 | \(org ? "(org-mode) (require 'evil-org) (evil-org-set-key-theme '(textobjects navigation)) (evil-org-mode 1)" : "") |
| 138 | (evil-local-mode 1) | 178 | (evil-local-mode 1) |
| 139 | (evil-surround-mode 1) | 179 | (evil-surround-mode 1) |
| 140 | (evil-normal-state) | 180 | (evil-normal-state) |
| @@ -148,9 +188,10 @@ struct VimExtrasTests { | |||
| 148 | """) | 188 | """) |
| 149 | } | 189 | } |
| 150 | let results = try EmacsOracle.run(forms) | 190 | let results = try EmacsOracle.run(forms) |
| 151 | for ((c, _), result) in zip(all, results) { | 191 | for ((c, org), result) in zip(all, results) { |
| 152 | let (text, point, keys) = c | 192 | let (text, point, keys) = c |
| 153 | var driver = VimDriver(text, caret: point) | 193 | var driver = VimDriver(text, caret: point) |
| 194 | driver.org = org | ||
| 154 | driver.type(KeySequence.parse(keys)!) | 195 | driver.type(KeySequence.parse(keys)!) |
| 155 | guard let marker = result.text.range(of: "\n@@STATE ") else { | 196 | guard let marker = result.text.range(of: "\n@@STATE ") else { |
| 156 | Issue.record("\(keys): \(result.error)") | 197 | Issue.record("\(keys): \(result.error)") |
Tests/OrgCoreTests/VimTests.swift +2 −1
| @@ -12,6 +12,7 @@ struct VimDriver { | |||
| 12 | var commands: [String] = [] | 12 | var commands: [String] = [] |
| 13 | var halfPage = 15 | 13 | var halfPage = 15 |
| 14 | var visible: Range<Int>? | 14 | var visible: Range<Int>? |
| 15 | var org = false | ||
| 15 | 16 | ||
| 16 | init(_ text: String, caret: Int) { | 17 | init(_ text: String, caret: Int) { |
| 17 | self.text = text | 18 | self.text = text |
| @@ -38,7 +39,7 @@ struct VimDriver { | |||
| 38 | } | 39 | } |
| 39 | continue | 40 | continue |
| 40 | } | 41 | } |
| 41 | let actions = vim.feed(key, Vim.Context(text: text, caret: caret, clipboard: clipboard, halfPage: halfPage, visible: visible)) | 42 | let actions = vim.feed(key, Vim.Context(text: text, caret: caret, clipboard: clipboard, halfPage: halfPage, visible: visible, org: org)) |
| 42 | for action in actions { | 43 | for action in actions { |
| 43 | switch action { | 44 | switch action { |
| 44 | case .edit(let edits, let to): | 45 | case .edit(let edits, let to): |