Narrowing and org-goto !87

merged merged by cmc on 2026-10-06 04:11 UTC · krz/orgstar:narrowing into main

6 files changed, +226 −2

Layout: unified · split

Sources/OrgCore/Commands/Command.swift +1 −1
@@ -232,7 +232,7 @@ public enum Commands {
232232 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
233233 OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(),
234234 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(),
236236 ]
237237
238238 public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) }
Sources/OrgCore/Commands/Narrowing.swift added +89
@@ -0,0 +1,89 @@
1import 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`.
5public 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.
59public 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 {
206206 command = "org.sort"
207207 mode = "normal"
208208 [[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]]
209221 keys = "SPC m h"
210222 command = "org.heading.toggle"
211223 mode = "normal"
@@ -651,6 +663,18 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
651663 command = "org.table.toggle-column-width"
652664 when = "table"
653665 [[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]]
654678 keys = "C-c C-x C-r"
655679 command = "org.clock.report"
656680 [[bind]]
Sources/OrgEditorAppKit/EditorCommands.swift +3
@@ -37,6 +37,9 @@ public enum EditorCommands {
3737 public static let all: [EditorCommand] = Commands.everything.map { EditorCommand(id: $0.id, title: $0.title, action: .text($0)) } + [
3838 EditorCommand(id: "org.cycle", title: "Cycle Visibility", action: .view),
3939 EditorCommand(id: "org.cycle-global", title: "Cycle Global Visibility", action: .view),
40 EditorCommand(id: "org.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),
4043 EditorCommand(id: "org.table.toggle-column-width", title: "Shrink or Expand Table Column", action: .view),
4144 EditorCommand(id: "org.table.shrink", title: "Shrink Table Columns with Widths", action: .view),
4245 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 {
400400 switch id {
401401 case "org.cycle": cycleAtCaret()
402402 case "org.cycle-global": cycleGlobal()
403 case "org.narrow.subtree", "org.narrow.block", "org.widen": narrow(id)
403404 default: tableColumnWidth(id)
404405 }
405406 case .edit(let selector, let extending):
@@ -446,6 +447,7 @@ public final class OrgEditor: NSObject {
446447 applyEdits(result.edits)
447448 commandEditing = false
448449 if let selection = result.selection?.first {
450 if hidden.containing(selection.lowerBound) != nil { unfold(around: selection.lowerBound) }
449451 setCaret(selection.lowerBound)
450452 if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) }
451453 }
@@ -515,7 +517,51 @@ public final class OrgEditor: NSObject {
515517
516518 private func updateHidden() {
517519 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
519565 }
520566
521567 // MARK: - Shrunk table columns
@@ -661,6 +707,7 @@ public final class OrgEditor: NSObject {
661707 /// Unfolds whatever hides `offset`, puts the caret there and scrolls to it.
662708 public func reveal(_ offset: Int) {
663709 let target = max(0, min(offset, utf16Length))
710 if let narrowed, !(narrowed.lowerBound...narrowed.upperBound).contains(target) { narrow("org.widen") }
664711 unfold(around: target)
665712 setCaret(target)
666713 textView.scrollRangeToVisible(NSRange(location: target, length: 0))
@@ -944,6 +991,7 @@ public final class OrgEditor: NSObject {
944991 let oldMapped = oldAffected.lowerBound..<(oldAffected.upperBound >= edit.range.upperBound ? oldAffected.upperBound + delta : newEnd)
945992 view = view.mapped(through: [edit]).pruned(to: document.tree, wrappers: false)
946993 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] }
947995 updateHidden()
948996 restyle(min(oldMapped.lowerBound, newAffected.lowerBound)..<min(max(oldMapped.upperBound, newAffected.upperBound), newText.length), in: storage)
949997 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 {
10241072 onSelectionChange?(selection)
10251073 revealCaretLine()
10261074 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 }
10271080 // A selection reaching into folded text, as a find match does, unfolds it.
10281081 if selection.length > 0 {
10291082 if hidden.containing(selection.location) != nil || hidden.containing(NSMaxRange(selection) - 1) != nil {
Tests/OrgCoreTests/NarrowingTests.swift added +55
@@ -0,0 +1,55 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct 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}