Narrowing and org-goto !87
6 files changed, +226 −2
Layout: unified · split
Sources/OrgCore/Commands/Command.swift +1 −1
| @@ -232,7 +232,7 @@ public enum Commands { | ||
| 232 | 232 | ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), |
| 233 | 233 | OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(), |
| 234 | 234 | CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), InsertStructureTemplate(), EditSpecial(), |
| 235 | UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(), EvaluateTimeRange(), FootnoteAction(), FootnoteAction(special: true), TableEvalFormula(named: false), TableEvalFormula(named: true), SortTableLines(), TransposeTable(), | |
| 235 | UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(), EvaluateTimeRange(), FootnoteAction(), FootnoteAction(special: true), TableEvalFormula(named: false), TableEvalFormula(named: true), SortTableLines(), TransposeTable(), GotoHeading(), | |
| 236 | 236 | ] |
| 237 | 237 | |
| 238 | 238 | public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) } |
Sources/OrgCore/Commands/Narrowing.swift added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// The regions `org-narrow-to-subtree` and `org-narrow-to-block` (Org 9.8.7) narrow to. The | |
| 4 | /// editor shows only that text until `widen`. | |
| 5 | public enum Narrowing { | |
| 6 | public struct Failure: Error, Equatable { | |
| 7 | public let message: String | |
| 8 | } | |
| 9 | ||
| 10 | /// `org-narrow-to-subtree`: the heading at or above `offset` and its subtree, without the | |
| 11 | /// newline before the next heading. | |
| 12 | public static func subtree(at offset: Int, in text: String) throws -> Range<Int> { | |
| 13 | let buffer = EmacsBuffer(text, point: offset) | |
| 14 | guard (try? buffer.backToHeading()) != nil else { throw Failure(message: "Before first headline") } | |
| 15 | let start = buffer.point | |
| 16 | buffer.endOfSubtree(toHeading: true) | |
| 17 | if buffer.atHeading, !buffer.eobp { buffer.point -= 1 } | |
| 18 | return start..<buffer.point | |
| 19 | } | |
| 20 | ||
| 21 | /// `org-narrow-to-block` through `org-narrow-to-element`: a src, example, export, comment | |
| 22 | /// or verse block with the blank lines after it; for other blocks, from their opening or | |
| 23 | /// closing line, the lines between. | |
| 24 | public static func block(at offset: Int, in text: String) throws -> Range<Int> { | |
| 25 | let tree = OrgParser.parse(text) | |
| 26 | let buffer = EmacsBuffer(text, point: offset) | |
| 27 | var probe = offset | |
| 28 | // Blank lines after a block belong to it. | |
| 29 | if buffer.lookingAtLine("^[ \\t]*$") { | |
| 30 | buffer.point = buffer.lineStart(offset) | |
| 31 | buffer.skipBackward([32, 9, 10, 13]) | |
| 32 | probe = buffer.lineStart(buffer.point) | |
| 33 | } | |
| 34 | let objects: Set<SyntaxKind> = [.bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .linkDescription, .timestamp, | |
| 35 | .footnoteReference, .statisticsCookie, .target, .macro, .inlineSourceBlock, .latexFragment, .lineBreak, .superscript] | |
| 36 | var chain: [SyntaxNode] = [] | |
| 37 | var node = tree.root | |
| 38 | while let child = node.child(containing: probe) { | |
| 39 | chain.append(child) | |
| 40 | node = child | |
| 41 | } | |
| 42 | guard let block = chain.last(where: { !objects.contains($0.kind) }), block.kind == .block, | |
| 43 | let wrapper = Wrappers.starting(at: block.range.lowerBound, in: tree) else { throw Failure(message: "Not in a block") } | |
| 44 | let opening = buffer.substring(block.range.lowerBound, buffer.lineEnd(block.range.lowerBound)) | |
| 45 | let name = opening.firstMatch(of: /(?i)#\+begin_(\S*)/).map { $0.1.lowercased() } ?? "" | |
| 46 | let contentsBegin = min(buffer.length, buffer.lineEnd(block.range.lowerBound) + 1) | |
| 47 | if ["src", "example", "export", "comment", "verse"].contains(name) { | |
| 48 | buffer.point = wrapper.closeLine | |
| 49 | buffer.forwardLine(1) | |
| 50 | while !buffer.eobp, buffer.lookingAt("[ \\t]*$") != nil, buffer.forwardLine(1) == 0 {} | |
| 51 | return block.range.lowerBound..<buffer.point | |
| 52 | } | |
| 53 | guard probe < contentsBegin || probe >= wrapper.closeLine, contentsBegin < wrapper.closeLine else { throw Failure(message: "Not in a block") } | |
| 54 | return contentsBegin..<wrapper.closeLine | |
| 55 | } | |
| 56 | } | |
| 57 | ||
| 58 | /// C-c C-j: `org-goto` with `outline-path-completion`: a heading of this file by its path. | |
| 59 | public struct GotoHeading: OrgCommand { | |
| 60 | public init() {} | |
| 61 | public var id: String { "org.goto" } | |
| 62 | public var title: String { "Go to Heading…" } | |
| 63 | public func applies(in context: EditContext) -> Bool { true } | |
| 64 | ||
| 65 | static func targets(_ context: EditContext) -> [(path: String, start: Int)] { | |
| 66 | let model = DocumentModel(tree: context.tree) | |
| 67 | var seen: Set<String> = [] | |
| 68 | return model.headings.indices.compactMap { index in | |
| 69 | let path = model.outlinePath(of: index).map(Self.plainTitle).joined(separator: "/") | |
| 70 | guard seen.insert(path).inserted else { return nil } | |
| 71 | return (path, model.headings[index].headingRange.lowerBound) | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | /// The title as completion shows it: links as their descriptions, no trailing blanks. | |
| 76 | static func plainTitle(_ title: String) -> String { | |
| 77 | Links.displayFormat(title).trimmingCharacters(in: .whitespaces) | |
| 78 | } | |
| 79 | ||
| 80 | public func run(in context: EditContext) -> CommandStep { | |
| 81 | let targets = Self.targets(context) | |
| 82 | guard !targets.isEmpty else { return .failed("No headings") } | |
| 83 | guard let answer = context.answers["goto"] else { | |
| 84 | return .prompt(Prompt(key: "goto", message: "Goto:", choices: targets.map(\.path))) | |
| 85 | } | |
| 86 | guard let target = targets.first(where: { $0.path == answer }) else { return .failed("No such heading") } | |
| 87 | return .commit(EditResult(baseRevision: context.revision, edits: [], selection: [target.start..<target.start])) | |
| 88 | } | |
| 89 | } | |
Sources/OrgCore/Keymap/Presets.swift +24
| @@ -206,6 +206,18 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | ||
| 206 | 206 | command = "org.sort" |
| 207 | 207 | mode = "normal" |
| 208 | 208 | [[bind]] |
| 209 | keys = "SPC m s n" | |
| 210 | command = "org.narrow.subtree" | |
| 211 | mode = "normal" | |
| 212 | [[bind]] | |
| 213 | keys = "SPC m s N" | |
| 214 | command = "org.widen" | |
| 215 | mode = "normal" | |
| 216 | [[bind]] | |
| 217 | keys = "SPC m ." | |
| 218 | command = "org.goto" | |
| 219 | mode = "normal" | |
| 220 | [[bind]] | |
| 209 | 221 | keys = "SPC m h" |
| 210 | 222 | command = "org.heading.toggle" |
| 211 | 223 | mode = "normal" |
| @@ -651,6 +663,18 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | ||
| 651 | 663 | command = "org.table.toggle-column-width" |
| 652 | 664 | when = "table" |
| 653 | 665 | [[bind]] |
| 666 | keys = "C-x n s" | |
| 667 | command = "org.narrow.subtree" | |
| 668 | [[bind]] | |
| 669 | keys = "C-x n b" | |
| 670 | command = "org.narrow.block" | |
| 671 | [[bind]] | |
| 672 | keys = "C-x n w" | |
| 673 | command = "org.widen" | |
| 674 | [[bind]] | |
| 675 | keys = "C-c C-j" | |
| 676 | command = "org.goto" | |
| 677 | [[bind]] | |
| 654 | 678 | keys = "C-c C-x C-r" |
| 655 | 679 | command = "org.clock.report" |
| 656 | 680 | [[bind]] |
Sources/OrgEditorAppKit/EditorCommands.swift +3
| @@ -37,6 +37,9 @@ public enum EditorCommands { | ||
| 37 | 37 | public static let all: [EditorCommand] = Commands.everything.map { EditorCommand(id: $0.id, title: $0.title, action: .text($0)) } + [ |
| 38 | 38 | EditorCommand(id: "org.cycle", title: "Cycle Visibility", action: .view), |
| 39 | 39 | EditorCommand(id: "org.cycle-global", title: "Cycle Global Visibility", action: .view), |
| 40 | EditorCommand(id: "org.narrow.subtree", title: "Narrow to Subtree", action: .view), | |
| 41 | EditorCommand(id: "org.narrow.block", title: "Narrow to Block", action: .view), | |
| 42 | EditorCommand(id: "org.widen", title: "Widen", action: .view), | |
| 40 | 43 | EditorCommand(id: "org.table.toggle-column-width", title: "Shrink or Expand Table Column", action: .view), |
| 41 | 44 | EditorCommand(id: "org.table.shrink", title: "Shrink Table Columns with Widths", action: .view), |
| 42 | 45 | EditorCommand(id: "org.table.expand", title: "Expand Table Columns", action: .view), |
Sources/OrgEditorAppKit/OrgEditor.swift +54 −1
| @@ -400,6 +400,7 @@ public final class OrgEditor: NSObject { | ||
| 400 | 400 | switch id { |
| 401 | 401 | case "org.cycle": cycleAtCaret() |
| 402 | 402 | case "org.cycle-global": cycleGlobal() |
| 403 | case "org.narrow.subtree", "org.narrow.block", "org.widen": narrow(id) | |
| 403 | 404 | default: tableColumnWidth(id) |
| 404 | 405 | } |
| 405 | 406 | case .edit(let selector, let extending): |
| @@ -446,6 +447,7 @@ public final class OrgEditor: NSObject { | ||
| 446 | 447 | applyEdits(result.edits) |
| 447 | 448 | commandEditing = false |
| 448 | 449 | if let selection = result.selection?.first { |
| 450 | if hidden.containing(selection.lowerBound) != nil { unfold(around: selection.lowerBound) } | |
| 449 | 451 | setCaret(selection.lowerBound) |
| 450 | 452 | if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) } |
| 451 | 453 | } |
| @@ -515,7 +517,51 @@ public final class OrgEditor: NSObject { | ||
| 515 | 517 | |
| 516 | 518 | private func updateHidden() { |
| 517 | 519 | folded.set(view.folds.union(view.bodyFolds).union(view.wrapperFolds)) |
| 518 | hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds, bodyFolds: view.bodyFolds, wrapperRanges: Array(wrapperHidden.values))) | |
| 520 | hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds, bodyFolds: view.bodyFolds, wrapperRanges: Array(wrapperHidden.values)) + narrowHidden) | |
| 521 | } | |
| 522 | ||
| 523 | // MARK: - Narrowing | |
| 524 | ||
| 525 | /// The text shown while narrowed (`org-narrow-to-subtree`, `org-narrow-to-block`); the | |
| 526 | /// rest is hidden until `widen`. Mapped through edits. | |
| 527 | public private(set) var narrowed: Range<Int>? | |
| 528 | ||
| 529 | private func narrow(_ id: String) { | |
| 530 | let caret = textView.selectedRange().location | |
| 531 | do { | |
| 532 | switch id { | |
| 533 | case "org.narrow.subtree": narrowed = try Narrowing.subtree(at: caret, in: textView.string) | |
| 534 | case "org.narrow.block": narrowed = try Narrowing.block(at: caret, in: textView.string) | |
| 535 | default: narrowed = nil | |
| 536 | } | |
| 537 | } catch let failure as Narrowing.Failure { | |
| 538 | onMessage?(failure.message) | |
| 539 | return | |
| 540 | } catch { | |
| 541 | return | |
| 542 | } | |
| 543 | updateHidden() | |
| 544 | textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange) | |
| 545 | orgTextView?.redrawLineNumbers() | |
| 546 | textView.needsDisplay = true | |
| 547 | if let narrowed { | |
| 548 | setCaret(max(narrowed.lowerBound, min(caret, narrowed.upperBound))) | |
| 549 | textView.scrollRangeToVisible(NSRange(location: narrowed.lowerBound, length: 0)) | |
| 550 | } else { | |
| 551 | textView.scrollRangeToVisible(NSRange(location: caret, length: 0)) | |
| 552 | } | |
| 553 | } | |
| 554 | ||
| 555 | /// What narrowing hides: the text before the region, and the lines after it. | |
| 556 | private var narrowHidden: [Range<Int>] { | |
| 557 | guard let narrowed else { return [] } | |
| 558 | let length = utf16Length | |
| 559 | var ranges: [Range<Int>] = [] | |
| 560 | if narrowed.lowerBound > 0 { ranges.append(0..<narrowed.lowerBound) } | |
| 561 | let ns = textView.string as NSString | |
| 562 | let tail = narrowed.upperBound < length && ns.character(at: narrowed.upperBound) == 10 ? narrowed.upperBound + 1 : narrowed.upperBound | |
| 563 | if tail < length { ranges.append(tail..<length) } | |
| 564 | return ranges | |
| 519 | 565 | } |
| 520 | 566 | |
| 521 | 567 | // MARK: - Shrunk table columns |
| @@ -661,6 +707,7 @@ public final class OrgEditor: NSObject { | ||
| 661 | 707 | /// Unfolds whatever hides `offset`, puts the caret there and scrolls to it. |
| 662 | 708 | public func reveal(_ offset: Int) { |
| 663 | 709 | let target = max(0, min(offset, utf16Length)) |
| 710 | if let narrowed, !(narrowed.lowerBound...narrowed.upperBound).contains(target) { narrow("org.widen") } | |
| 664 | 711 | unfold(around: target) |
| 665 | 712 | setCaret(target) |
| 666 | 713 | textView.scrollRangeToVisible(NSRange(location: target, length: 0)) |
| @@ -944,6 +991,7 @@ public final class OrgEditor: NSObject { | ||
| 944 | 991 | let oldMapped = oldAffected.lowerBound..<(oldAffected.upperBound >= edit.range.upperBound ? oldAffected.upperBound + delta : newEnd) |
| 945 | 992 | view = view.mapped(through: [edit]).pruned(to: document.tree, wrappers: false) |
| 946 | 993 | mapWrapperFolds(through: edit, near: min(oldMapped.lowerBound, newAffected.lowerBound)..<max(oldMapped.upperBound, newAffected.upperBound)) |
| 994 | if let narrowed { self.narrowed = ViewState(selection: [narrowed]).mapped(through: [edit]).selection[0] } | |
| 947 | 995 | updateHidden() |
| 948 | 996 | restyle(min(oldMapped.lowerBound, newAffected.lowerBound)..<min(max(oldMapped.upperBound, newAffected.upperBound), newText.length), in: storage) |
| 949 | 997 | for table in mapShrunk(through: edit) { restyle(table.lowerBound..<min(table.upperBound, newText.length), in: storage) } |
| @@ -1024,6 +1072,11 @@ public final class OrgEditor: NSObject { | ||
| 1024 | 1072 | onSelectionChange?(selection) |
| 1025 | 1073 | revealCaretLine() |
| 1026 | 1074 | orgTextView?.redrawLineNumbers() |
| 1075 | // Narrowing keeps the caret in the region. | |
| 1076 | if let narrowed, selection.length == 0, !(narrowed.lowerBound...narrowed.upperBound).contains(selection.location) { | |
| 1077 | setCaret(max(narrowed.lowerBound, min(selection.location, narrowed.upperBound))) | |
| 1078 | return | |
| 1079 | } | |
| 1027 | 1080 | // A selection reaching into folded text, as a find match does, unfolds it. |
| 1028 | 1081 | if selection.length > 0 { |
| 1029 | 1082 | if hidden.containing(selection.location) != nil || hidden.containing(NSMaxRange(selection) - 1) != nil { |
Tests/OrgCoreTests/NarrowingTests.swift added +55
| @@ -0,0 +1,55 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgCore | |
| 4 | ||
| 5 | struct NarrowingTests { | |
| 6 | static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil | |
| 7 | ||
| 8 | static let text = """ | |
| 9 | Intro line | |
| 10 | * One | |
| 11 | Body of one | |
| 12 | #+begin_src sh | |
| 13 | echo hi | |
| 14 | #+end_src | |
| 15 | ||
| 16 | ||
| 17 | ** Child | |
| 18 | child body | |
| 19 | * Two [[https://x.org][link]] | |
| 20 | #+BEGIN_QUOTE | |
| 21 | quoted | |
| 22 | #+END_QUOTE | |
| 23 | last | |
| 24 | #+begin_example | |
| 25 | tail | |
| 26 | #+end_example | |
| 27 | """ | |
| 28 | ||
| 29 | @Test(.enabled(if: oracle)) | |
| 30 | func narrowsLikeOrg() throws { | |
| 31 | let ns = Self.text as NSString | |
| 32 | let offsets = ["Intro", "Body", "echo", "child body", "** Child", "quoted", "last", "#+end_src", "#+BEGIN_QUOTE", "#+END_QUOTE", "\n\n**", "tail"].map { ns.range(of: $0).location + 1 } | |
| 33 | for (form, ours) in [("(org-narrow-to-subtree)", Narrowing.subtree), ("(org-narrow-to-block)", Narrowing.block)] { | |
| 34 | let points = offsets.map { String(EmacsOracle.point($0, in: Self.text)) }.joined(separator: " ") | |
| 35 | let emacs = try EmacsOracle.evaluate(Self.text, """ | |
| 36 | (with-current-buffer (find-file-noselect "oracle.org") | |
| 37 | (mapcar (lambda (p) (widen) (goto-char p) | |
| 38 | (condition-case e (progn \(form) (format "%d %d" (1- (point-min)) (1- (point-max)))) (error "error"))) | |
| 39 | '(\(points)))) | |
| 40 | """) | |
| 41 | let mine = offsets.map { offset -> String in | |
| 42 | guard let r = try? ours(offset, Self.text) else { return "error" } | |
| 43 | return "\(r.lowerBound) \(r.upperBound)" | |
| 44 | } | |
| 45 | #expect(mine == emacs, "\(form)") | |
| 46 | } | |
| 47 | } | |
| 48 | ||
| 49 | @Test func goesToHeadingsByPath() { | |
| 50 | let context = EditContext(revision: 0, text: Self.text, tree: OrgParser.parse(Self.text), selection: [0..<0]) | |
| 51 | #expect(GotoHeading.targets(context).map(\.path) == ["One", "One/Child", "Two link"]) | |
| 52 | let caret = runCommand(GotoHeading(), Self.text, caret: 0, answers: ["goto": "One/Child"]).caret | |
| 53 | #expect(caret == (Self.text as NSString).range(of: "** Child").location) | |
| 54 | } | |
| 55 | } | |