Sparse trees !88

merged merged by cmc on 2026-10-06 04:19 UTC · krz/orgstar:sparse-trees into main

8 files changed, +469 −3

Layout: unified · split

Sources/OrgCore/Commands/Command.swift +4 −1
@@ -163,6 +163,9 @@ public enum Effect: Sendable, Equatable {
163163 case storedLinkUsed(String)
164164 /// Text for the clipboard (the kill ring).
165165 case copy(String)
166 /// Show a sparse tree: the context of each point, the rest folded, and highlights until
167 /// the next edit or C-c C-c.
168 case sparseTree(show: [Int], highlights: [Range<Int>])
166169}
167170
168171public struct EditResult: Sendable, Equatable {
@@ -232,7 +235,7 @@ public enum Commands {
232235 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
233236 OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(),
234237 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(), GotoHeading(),
238 UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(), EvaluateTimeRange(), FootnoteAction(), FootnoteAction(special: true), TableEvalFormula(named: false), TableEvalFormula(named: true), SortTableLines(), TransposeTable(), GotoHeading(), SparseTreeCommand(),
236239 ]
237240
238241 public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) }
Sources/OrgCore/Commands/SparseTree.swift added +277
@@ -0,0 +1,277 @@
1import Foundation
2
3// Sparse trees (`org-sparse-tree`, C-c /, Org 9.8.7): `org-occur` and the trees built on it,
4// and `org-match-sparse-tree`. Matches are shown as `org-fold-show-context` does with the
5// default `ancestors` detail, after `org-cycle-overview`.
6
7public enum SparseTree {
8 /// Where each match leaves point (org shows the context there), and the text to highlight.
9 public struct Matches: Sendable, Equatable {
10 public var points: [Int] = []
11 public var highlights: [Range<Int>] = []
12 }
13
14 /// `org-occur`: matches of `pattern` (ICU), each kept when `keep` says so.
15 static func occur(_ buffer: EmacsBuffer, _ pattern: String, caseInsensitive: Bool, keep: ((EmacsBuffer, [Range<Int>?]) -> Bool)? = nil) -> Matches {
16 var matches = Matches()
17 buffer.point = 0
18 while buffer.point <= buffer.length, let match = buffer.searchForward(pattern, caseInsensitive: caseInsensitive) {
19 let whole = match[0]!
20 buffer.point = whole.upperBound
21 let kept = buffer.saveExcursion { keep?(buffer, match) ?? true }
22 if kept {
23 matches.points.append(whole.upperBound)
24 matches.highlights.append(whole)
25 }
26 if whole.isEmpty {
27 guard buffer.point < buffer.length else { break }
28 buffer.point += 1
29 }
30 }
31 return matches
32 }
33
34 /// `org-occur` with a regexp in Emacs syntax.
35 public static func occur(_ regexp: String, in text: String) throws -> Matches {
36 guard !regexp.isEmpty else { throw EmacsBuffer.UserError(message: "Regexp cannot be empty") }
37 let pattern = EmacsRegex.translate(regexp)
38 guard (try? NSRegularExpression(pattern: pattern)) != nil else { throw EmacsBuffer.UserError(message: "Invalid regexp") }
39 return occur(EmacsBuffer(text, point: 0), pattern, caseInsensitive: true)
40 }
41
42 /// `org-show-todo-tree`: headings with an unfinished keyword, or with `keywords`
43 /// (`KWD1|KWD2`).
44 public static func todo(_ keywords: String?, in text: String, settings: OrgSettings) -> Matches {
45 let buffer = EmacsBuffer(text, point: 0, settings: settings)
46 let words: [String]
47 let after: String
48 if let keywords {
49 words = keywords.split(separator: "|").map(String.init).filter { !$0.isEmpty }
50 after = "(?:[ \\t]|$)"
51 } else {
52 words = buffer.keywordsInOrder.filter { !settings.isDone($0) }
53 after = "\\b\\s"
54 }
55 guard !words.isEmpty else { return Matches() }
56 let alternation = words.map(NSRegularExpression.escapedPattern(for:)).joined(separator: "|")
57 let pattern = "^\\*+ " + " *" + (keywords == nil ? "\\b(" + alternation + ")" : "(" + alternation + ")") + after
58 return occur(buffer, pattern, caseInsensitive: false)
59 }
60
61 /// `org-match-sparse-tree`: headings matching a tags/property match, their stars
62 /// highlighted.
63 public static func match(_ match: String, in text: String, now: Date, calendar: Calendar) -> Matches {
64 let source = AgendaSource(path: "", text: text)
65 let matcher = TagsMatcher(match)
66 var matches = Matches()
67 for heading in source.headings where matcher.matches(heading, in: source, now: now, calendar: calendar) {
68 let stars = (text as NSString).substring(from: heading.start).prefix { $0 == "*" }.utf16.count
69 matches.points.append(heading.start)
70 matches.highlights.append(heading.start..<(heading.start + stars))
71 }
72 return matches
73 }
74
75 /// The day of an Org time string, as days since the epoch in `calendar`.
76 static func day(_ stamp: String, calendar: Calendar) -> Int? {
77 guard let c = EmacsBuffer.parseTimeString(stamp), let date = calendar.date(from: DateComponents(year: c.year, month: c.month, day: c.day)) else { return nil }
78 return calendar.dateComponents([.day], from: Date(timeIntervalSince1970: 0), to: date).day
79 }
80
81 /// `org-time-string-to-time`.
82 static func time(_ stamp: String, calendar: Calendar) -> Date? {
83 guard let c = EmacsBuffer.parseTimeString(stamp) else { return nil }
84 return calendar.date(from: c)
85 }
86
87 /// `org-check-deadlines`: deadlines past due or due within `days`, in entries not done.
88 public static func deadlines(within days: Int, in text: String, settings: OrgSettings, now: Date, calendar: Calendar) -> Matches {
89 let buffer = EmacsBuffer(text, point: 0, settings: settings)
90 let today = calendar.dateComponents([.day], from: Date(timeIntervalSince1970: 0), to: calendar.startOfDay(for: now)).day ?? 0
91 return occur(buffer, "\\bDEADLINE: *<([^>]+)>", caseInsensitive: false) { buffer, match in
92 let stamp = buffer.substring(match[1]!.lowerBound, match[1]!.upperBound)
93 guard let day = day(stamp, calendar: calendar), day - today <= days else { return false }
94 return !entryIsDone(buffer, settings: settings)
95 }
96 }
97
98 /// `org-entry-is-done-p`.
99 static func entryIsDone(_ buffer: EmacsBuffer, settings: OrgSettings) -> Bool {
100 guard (try? buffer.backToHeading()) != nil, let m = buffer.lookingAt("\\*+ +(\\S+)") else { return false }
101 return settings.isDone(buffer.substring(m[1]!.lowerBound, m[1]!.upperBound))
102 }
103
104 /// `org-check-before-date`, `org-check-after-date` and `org-check-dates-range` on
105 /// SCHEDULED and DEADLINE: planning times `from` or later and before `to`.
106 public static func planning(from: String?, to: String?, in text: String, calendar: Calendar) -> Matches {
107 let buffer = EmacsBuffer(text, point: 0)
108 let low = from.flatMap { time($0, calendar: calendar) }
109 let high = to.flatMap { time($0, calendar: calendar) }
110 return occur(buffer, "\\b(?:DEADLINE|SCHEDULED): *<([^>]+)>", caseInsensitive: false) { buffer, match in
111 guard buffer.lookingAtLine("^[ \\t]*(?:SCHEDULED|DEADLINE|CLOSED):"),
112 let at = time(buffer.substring(match[1]!.lowerBound, match[1]!.upperBound), calendar: calendar) else { return false }
113 if let low, at < low { return false }
114 if let high, !(at < high) { return false }
115 return true
116 }
117 }
118
119 /// What stays hidden: `org-cycle-overview`, then each point's context shown (its heading
120 /// line, or its entry, and its ancestors' heading lines), then archived subtrees folded.
121 public static func hidden(_ points: [Int], in text: String, tree: OrgTree) -> [Range<Int>] {
122 let ns = text as NSString
123 var lineStarts: [Int] = []
124 var offset = 0
125 while offset < ns.length {
126 lineStarts.append(offset)
127 offset = NSMaxRange(ns.lineRange(for: NSRange(location: offset, length: 0)))
128 }
129 if lineStarts.isEmpty { return [] }
130 func lineIndex(_ position: Int) -> Int {
131 var low = 0, high = lineStarts.count - 1
132 while low < high {
133 let mid = (low + high + 1) / 2
134 if lineStarts[mid] <= position { low = mid } else { high = mid - 1 }
135 }
136 return low
137 }
138 let model = DocumentModel(tree: tree)
139 let headings = model.headings
140 var visible = [Bool](repeating: false, count: lineStarts.count)
141 let firstHeadingLine = headings.first.map { lineIndex($0.headingRange.lowerBound) } ?? lineStarts.count
142 for i in 0..<firstHeadingLine { visible[i] = true }
143 for heading in headings where heading.level == 1 { visible[lineIndex(heading.headingRange.lowerBound)] = true }
144 let headingStarts = headings.map(\.headingRange.lowerBound)
145 func headingIndex(at position: Int) -> Int? {
146 headingStarts.lastIndex { $0 <= position }
147 }
148 for point in points {
149 let line = lineIndex(min(point, ns.length))
150 guard let h = headingIndex(at: lineStarts[line]) else { continue }
151 if lineStarts[line] == headingStarts[h] {
152 visible[line] = true
153 } else {
154 let bodyEnd = h + 1 < headings.count ? headingStarts[h + 1] : ns.length
155 for i in lineIndex(headingStarts[h])..<lineStarts.count where lineStarts[i] < bodyEnd { visible[i] = true }
156 }
157 var parent = headings[h].parent
158 while let p = parent {
159 visible[lineIndex(headingStarts[p])] = true
160 parent = headings[p].parent
161 }
162 }
163 for (index, heading) in headings.enumerated() where heading.localTags.contains("ARCHIVE") {
164 let end = headings[(index + 1)...].first { $0.level <= heading.level }?.headingRange.lowerBound ?? ns.length
165 let first = lineIndex(heading.headingRange.lowerBound) + 1
166 for i in first..<lineStarts.count where lineStarts[i] < end { visible[i] = false }
167 }
168 var ranges: [Range<Int>] = []
169 var start: Int?
170 for i in lineStarts.indices {
171 if !visible[i], start == nil { start = lineStarts[i] }
172 if visible[i], let s = start {
173 ranges.append(s..<lineStarts[i])
174 start = nil
175 }
176 }
177 if let s = start { ranges.append(s..<ns.length) }
178 return ranges
179 }
180}
181
182/// C-c /: `org-sparse-tree`. The editor shows the result.
183public struct SparseTreeCommand: OrgCommand {
184 public init() {}
185 public var id: String { "org.sparse-tree" }
186 public var title: String { "Sparse Tree…" }
187 public func applies(in context: EditContext) -> Bool { true }
188
189 static let menu = FastSelection(items: [
190 .option(key: "r", name: "regexp"), .option(key: "t", name: "todo"), .option(key: "T", name: "todo keyword"),
191 .option(key: "m", name: "match"), .option(key: "p", name: "property"), .newline,
192 .option(key: "d", name: "deadlines"), .option(key: "b", name: "before date"), .option(key: "a", name: "after date"),
193 .option(key: "D", name: "dates range"),
194 ], multiple: false)
195
196 public func run(in context: EditContext) -> CommandStep {
197 guard let kind = context.answers["sparse"] else {
198 return .prompt(Prompt(key: "sparse", message: "Sparse tree: [r]egexp [t]odo [T]odo-kwd [m]atch [p]roperty [d]eadlines [b]efore-date [a]fter-date [D]ates range", fast: Self.menu))
199 }
200 let text = context.text
201 let settings = context.tree.settings
202 func ask(_ key: String, _ message: String, choices: [String] = [], date: Bool = false) -> CommandStep? {
203 context.answers[key] == nil ? .prompt(Prompt(key: key, message: message, choices: choices, requireMatch: false, date: date ? .init(defaultDate: nil) : nil)) : nil
204 }
205 func dateString(_ answer: String) -> String {
206 let read = ReadDate.read(answer, default: nil, now: context.now, calendar: context.calendar)
207 let base = String(format: "%04d-%02d-%02d", read.year, read.month, read.day)
208 return read.timeGiven ? base + String(format: " %02d:%02d", read.hour, read.minute) : base
209 }
210 let matches: SparseTree.Matches
211 let message: String?
212 switch kind {
213 case "r", "R", "/":
214 if let step = ask("regexp", "Regexp:") { return step }
215 do {
216 matches = try SparseTree.occur(context.answers["regexp"]!, in: text)
217 } catch let error as EmacsBuffer.UserError {
218 return .failed(error.message)
219 } catch {
220 return .failed("\(error)")
221 }
222 message = "\(matches.points.count) match(es) for regexp \(context.answers["regexp"]!)"
223 case "t":
224 matches = SparseTree.todo(nil, in: text, settings: settings)
225 message = "\(matches.points.count) TODO entries found"
226 case "T":
227 let buffer = EmacsBuffer(text, point: 0, settings: settings)
228 if let step = ask("keyword", "Keyword (or KWD1|KWD2|...):", choices: buffer.keywordsInOrder) { return step }
229 matches = SparseTree.todo(context.answers["keyword"]!, in: text, settings: settings)
230 message = "\(matches.points.count) TODO entries found"
231 case "m":
232 if let step = ask("match", "Match:") { return step }
233 matches = SparseTree.match(context.answers["match"]!, in: text, now: context.now, calendar: context.calendar)
234 message = nil
235 case "p", "P":
236 if let step = ask("property", "Property:", choices: SortEntries.propertyKeys(text)) { return step }
237 let name = context.answers["property"]!
238 let values = SparseTreeCommand.propertyValues(name, in: text)
239 if let step = ask("value", "Value:", choices: values) { return step }
240 var value = context.answers["value"]!
241 if value.range(of: "^\\{.*\\}$", options: .regularExpression) == nil { value = "\"" + value + "\"" }
242 matches = SparseTree.match(name.replacingOccurrences(of: "-", with: "\\-") + "=" + value, in: text, now: context.now, calendar: context.calendar)
243 message = nil
244 case "d":
245 matches = SparseTree.deadlines(within: 14, in: text, settings: settings, now: context.now, calendar: context.calendar)
246 message = "\(matches.points.count) deadlines past-due or due within 14 days"
247 case "b", "a":
248 if let step = ask("date", "Date:", date: true) { return step }
249 let date = dateString(context.answers["date"]!)
250 matches = kind == "b" ? SparseTree.planning(from: nil, to: date, in: text, calendar: context.calendar)
251 : SparseTree.planning(from: date, to: nil, in: text, calendar: context.calendar)
252 message = "\(matches.points.count) entries \(kind == "b" ? "before" : "after") \(date)"
253 case "D":
254 if let step = ask("start", "Range starts:", date: true) { return step }
255 if let step = ask("end", "Range end:", date: true) { return step }
256 let start = dateString(context.answers["start"]!)
257 let end = dateString(context.answers["end"]!)
258 matches = SparseTree.planning(from: start, to: end, in: text, calendar: context.calendar)
259 message = "\(matches.points.count) entries between \(start) and \(end)"
260 default:
261 return .failed("No such sparse tree command \"\(kind)\"")
262 }
263 let effects: [Effect] = [.sparseTree(show: matches.points, highlights: matches.highlights)] + (message.map { [.message($0)] } ?? [])
264 return .commit(EditResult(baseRevision: context.revision, edits: [], effects: effects))
265 }
266
267 /// `org-property-values`: the values `name` has in the text.
268 static func propertyValues(_ name: String, in text: String) -> [String] {
269 let pattern = "(?mi)^[ \\t]*:" + NSRegularExpression.escapedPattern(for: name) + ":[ \\t]+(.*?)[ \\t]*$"
270 var values: [String] = []
271 for m in (try! NSRegularExpression(pattern: pattern)).matches(in: text, range: NSRange(location: 0, length: (text as NSString).length)) {
272 let value = (text as NSString).substring(with: m.range(at: 1))
273 if !values.contains(value) { values.append(value) }
274 }
275 return values
276 }
277}
Sources/OrgCore/Keymap/Presets.swift +7
@@ -210,6 +210,10 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
210210 command = "org.narrow.subtree"
211211 mode = "normal"
212212 [[bind]]
213 keys = "SPC m s s"
214 command = "org.sparse-tree"
215 mode = "normal"
216 [[bind]]
213217 keys = "SPC m s N"
214218 command = "org.widen"
215219 mode = "normal"
@@ -675,6 +679,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
675679 keys = "C-c C-j"
676680 command = "org.goto"
677681 [[bind]]
682 keys = "C-c /"
683 command = "org.sparse-tree"
684 [[bind]]
678685 keys = "C-c C-x C-r"
679686 command = "org.clock.report"
680687 [[bind]]
Sources/OrgEditorAppKit/OrgEditor.swift +96 −1
@@ -392,6 +392,11 @@ public final class OrgEditor: NSObject {
392392 onMessage?("Not an org file")
393393 return
394394 }
395 // `org-ctrl-c-ctrl-c` first removes sparse tree highlights.
396 if id == "org.ctrl-c-ctrl-c", !highlights.isEmpty {
397 clearHighlights()
398 return
399 }
395400 var keepMark = false
396401 switch command.action {
397402 case .text(let text):
@@ -452,7 +457,11 @@ public final class OrgEditor: NSObject {
452457 if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) }
453458 }
454459 for effect in result.effects {
455 if case .message(let text) = effect { onMessage?(text) } else { onEffect?(effect) }
460 switch effect {
461 case .message(let text): onMessage?(text)
462 case .sparseTree(let points, let highlights): showSparseTree(points, highlights: highlights)
463 default: onEffect?(effect)
464 }
456465 }
457466 case .failed(let message):
458467 onMessage?(message)
@@ -494,6 +503,7 @@ public final class OrgEditor: NSObject {
494503
495504 /// `wrappers`, when given, holds every folded drawer and block.
496505 private func setFolds(_ state: FoldState, wrappers: [Wrapper]?) {
506 sparseHidden = nil
497507 view.folds = state.folds
498508 view.bodyFolds = state.bodyFolds
499509 view = view.pruned(to: document.tree, wrappers: false)
@@ -516,10 +526,84 @@ public final class OrgEditor: NSObject {
516526 private var wrapperHidden: [Int: Range<Int>] = [:]
517527
518528 private func updateHidden() {
529 if let sparseHidden {
530 let ns = textView.string as NSString
531 // Headings whose next line is hidden show the fold ellipsis.
532 let headings = Presentation.outline(document.tree).map(\.start).filter { start in
533 let next = NSMaxRange(ns.lineRange(for: NSRange(location: start, length: 0)))
534 return sparseHidden.contains { $0.contains(next) } && !sparseHidden.contains { $0.contains(start) }
535 }
536 folded.set(Set(headings).union(view.wrapperFolds))
537 hidden.set(sparseHidden + Array(wrapperHidden.values) + narrowHidden)
538 return
539 }
519540 folded.set(view.folds.union(view.bodyFolds).union(view.wrapperFolds))
520541 hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds, bodyFolds: view.bodyFolds, wrapperRanges: Array(wrapperHidden.values)) + narrowHidden)
521542 }
522543
544 // MARK: - Sparse trees
545
546 /// What a sparse tree (`org-sparse-tree`) hides, replacing heading folds until the next
547 /// fold change. Mapped through edits.
548 private var sparseHidden: [Range<Int>]?
549 /// Sparse tree matches, highlighted until the next edit or C-c C-c.
550 private var highlights: [Range<Int>] = []
551
552 private func showSparseTree(_ points: [Int], highlights: [Range<Int>]) {
553 let text = textView.string
554 var state = foldState
555 for start in state.wrapperFolds where points.contains(where: { Wrappers.starting(at: start, in: document.tree)?.hidden.contains($0) == true }) {
556 state.wrapperFolds.remove(start)
557 }
558 sparseHidden = nil
559 setFolds(state)
560 sparseHidden = SparseTree.hidden(points, in: text, tree: document.tree)
561 self.highlights = highlights
562 updateHidden()
563 restyleOutsideEditing(0..<utf16Length)
564 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
565 orgTextView?.redrawLineNumbers()
566 textView.needsDisplay = true
567 let caret = textView.selectedRange().location
568 if let range = hidden.containing(caret) { setCaret(max(0, range.lowerBound - 1)) }
569 }
570
571 /// Leaves a sparse tree for ordinary folds: headings with something shown below them
572 /// open, the others folded.
573 private func leaveSparseTree() {
574 guard let sparse = sparseHidden else { return }
575 sparseHidden = nil
576 let outline = Presentation.outline(document.tree)
577 let ns = textView.string as NSString
578 var open: Set<Int> = []
579 for entry in outline where !sparse.contains(where: { $0.contains(entry.start) }) {
580 var parent = entry.parent
581 while let p = parent {
582 open.insert(outline[p].start)
583 parent = outline[p].parent
584 }
585 let next = NSMaxRange(ns.lineRange(for: NSRange(location: entry.start, length: 0)))
586 if next < entry.end, !sparse.contains(where: { $0.contains(next) }) { open.insert(entry.start) }
587 }
588 var state = foldState
589 state.folds = Set(outline.map(\.start)).subtracting(open)
590 state.bodyFolds = []
591 setFolds(state)
592 }
593
594 private func clearHighlights() {
595 guard !highlights.isEmpty else { return }
596 let old = highlights
597 highlights = []
598 for range in old where range.upperBound <= utf16Length { restyleOutsideEditing(range) }
599 }
600
601 private func applyHighlights(_ range: Range<Int>, in storage: NSTextStorage) {
602 for highlight in highlights where highlight.overlaps(range) && highlight.upperBound <= storage.length {
603 storage.addAttribute(.backgroundColor, value: NSColor.findHighlightColor.withAlphaComponent(0.45), range: NSRange(highlight.clamped(to: range)))
604 }
605 }
606
523607 // MARK: - Narrowing
524608
525609 /// The text shown while narrowed (`org-narrow-to-subtree`, `org-narrow-to-block`); the
@@ -689,6 +773,7 @@ public final class OrgEditor: NSObject {
689773 /// cycles folded, children, subtree. Returns false elsewhere.
690774 @discardableResult
691775 public func cycleAtCaret() -> Bool {
776 leaveSparseTree()
692777 let caret = textView.selectedRange().location
693778 guard let state = Presentation.cycle(at: caret, in: document.tree, foldState) else { return false }
694779 setFolds(state)
@@ -699,6 +784,7 @@ public final class OrgEditor: NSObject {
699784
700785 /// Shift-TAB: overview, contents, show all.
701786 public func cycleGlobal() {
787 leaveSparseTree()
702788 let (visibility, state) = Presentation.cycleGlobal(from: globalVisibility, in: document.tree, wrapperFolds: view.wrapperFolds)
703789 globalVisibility = visibility
704790 setFolds(state)
@@ -715,6 +801,7 @@ public final class OrgEditor: NSObject {
715801
716802 /// Unfolds the headings whose folds hide `offset`, leaving the selection alone.
717803 func unfold(around offset: Int) {
804 leaveSparseTree()
718805 var state = foldState
719806 for entry in Presentation.outline(document.tree) where entry.start < offset && offset < entry.end {
720807 state.folds.remove(entry.start)
@@ -804,6 +891,7 @@ public final class OrgEditor: NSObject {
804891 }
805892 if !showMarkup, options.prettyEntities { prettify(range, in: storage, revealed: revealed, scriptExclusions: scriptExclusions) }
806893 applyShrunk(range, in: storage)
894 applyHighlights(range, in: storage)
807895 }
808896
809897 /// `org-pretty-entities`: entities drawn as their character over the entity's last
@@ -992,9 +1080,16 @@ public final class OrgEditor: NSObject {
9921080 view = view.mapped(through: [edit]).pruned(to: document.tree, wrappers: false)
9931081 mapWrapperFolds(through: edit, near: min(oldMapped.lowerBound, newAffected.lowerBound)..<max(oldMapped.upperBound, newAffected.upperBound))
9941082 if let narrowed { self.narrowed = ViewState(selection: [narrowed]).mapped(through: [edit]).selection[0] }
1083 if let sparse = sparseHidden { sparseHidden = ViewState(selection: sparse).mapped(through: [edit]).selection.filter { !$0.isEmpty } }
1084 let oldHighlights = ViewState(selection: highlights).mapped(through: [edit]).selection
1085 highlights = []
9951086 updateHidden()
9961087 restyle(min(oldMapped.lowerBound, newAffected.lowerBound)..<min(max(oldMapped.upperBound, newAffected.upperBound), newText.length), in: storage)
9971088 for table in mapShrunk(through: edit) { restyle(table.lowerBound..<min(table.upperBound, newText.length), in: storage) }
1089 for range in oldHighlights where range.upperBound <= newText.length {
1090 let lines = newText.lineRange(for: NSRange(range))
1091 restyle(lines.location..<NSMaxRange(lines), in: storage)
1092 }
9981093 let oldShaded = shaded.all
9991094 if isOrg {
10001095 // Walking the tree for blocks costs more than typing; only an edit on a line that
Sources/Orgstar/ContentView.swift +4
@@ -314,6 +314,10 @@ struct ContentView: View {
314314 // The file opened above needs its editor first.
315315 for _ in 0..<20 where DevelopmentHooks.fileToOpen != nil && commands.editor == nil { try? await Task.sleep(for: .milliseconds(100)) }
316316 for command in commandList.split(separator: ",") { perform(String(command)) }
317 for answer in DevelopmentHooks.answers {
318 for _ in 0..<20 where commands.prompt == nil { try? await Task.sleep(for: .milliseconds(100)) }
319 commands.answer(answer)
320 }
317321 }
318322 if let id = DevelopmentHooks.windowToOpen {
319323 if id == "settings" { openSettings() } else { openWindow(id: id) }
Sources/Orgstar/DevelopmentHooks.swift +2
@@ -7,6 +7,7 @@ import AppKit
77/// - `ORGSTAR_WINDOW`: a window to open at launch (`agenda`, `settings`); the snapshot renders it.
88/// - `ORGSTAR_COMMAND`: commands to run at launch, separated by commas (`app.import-emacs`); a
99/// sheet or prompt they open is what the snapshot renders.
10/// - `ORGSTAR_ANSWERS`: answers to the prompts those commands ask, in order, separated by commas.
1011/// - `ORGSTAR_APPEARANCE`: `dark` or `light`, instead of the system's.
1112/// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main
1213/// window to this PNG path and quit. Rendering from inside the app needs no screen-recording
@@ -16,6 +17,7 @@ enum DevelopmentHooks {
1617 static var rootToAdd: String? { ProcessInfo.processInfo.environment["ORGSTAR_ROOT"] }
1718 static var windowToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_WINDOW"] }
1819 static var commandToRun: String? { ProcessInfo.processInfo.environment["ORGSTAR_COMMAND"] }
20 static var answers: [String] { ProcessInfo.processInfo.environment["ORGSTAR_ANSWERS"]?.split(separator: ",").map(String.init) ?? [] }
1921
2022 @MainActor
2123 static func scheduleSnapshot() {
Sources/Orgstar/EditorView.swift +1 −1
@@ -103,7 +103,7 @@ struct EditorView: NSViewRepresentable {
103103 case .copy(let text):
104104 NSPasteboard.general.clearContents()
105105 NSPasteboard.general.setString(text, forType: .string)
106 case .message: break
106 case .message, .sparseTree: break
107107 }
108108 }
109109 let scroll = editor.makeScrollView()
Tests/OrgCoreTests/SparseTreeTests.swift added +78
@@ -0,0 +1,78 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct SparseTreeTests {
6 static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil
7 static let seconds = 1_789_000_000
8
9 static let text = """
10 Preamble text
11 * TODO Alpha :work:
12 alpha body with foo
13 ** DONE Beta
14 beta body
15 ** TODO Gamma :home:
16 DEADLINE: <2026-09-15 Tue>
17 gamma Foo
18 *** Delta
19 SCHEDULED: <2026-09-01 Tue>
20 delta
21 **** Deep
22 deep text
23 * Epsilon :ARCHIVE:
24 ** Zeta foo
25 * DONE Eta
26 DEADLINE: <2026-08-01 Sat>
27 * Theta
28 SCHEDULED: <2026-10-01 Thu 10:00>
29 theta
30
31 * TODO Iota
32 DEADLINE: <2026-11-30 Mon>
33 """ + "\n"
34
35 @Test(.enabled(if: oracle))
36 func buildsSparseTreesLikeOrg() throws {
37 var utc = Calendar(identifier: .gregorian)
38 utc.timeZone = TimeZone(identifier: "UTC")!
39 let now = Date(timeIntervalSince1970: Double(Self.seconds))
40 let tree = OrgParser.parse(Self.text)
41 let cases: [(String, SparseTree.Matches)] = [
42 ("(org-occur \"foo\")", try SparseTree.occur("foo", in: Self.text)),
43 ("(org-occur \"^\\\\*+ .*a$\")", try SparseTree.occur("^\\*+ .*a$", in: Self.text)),
44 ("(org-show-todo-tree nil)", SparseTree.todo(nil, in: Self.text, settings: tree.settings)),
45 ("(org-match-sparse-tree nil \"home|work\")", SparseTree.match("home|work", in: Self.text, now: now, calendar: utc)),
46 ("(org-match-sparse-tree nil \"LEVEL>2\")", SparseTree.match("LEVEL>2", in: Self.text, now: now, calendar: utc)),
47 ("(org-check-deadlines nil)", SparseTree.deadlines(within: 14, in: Self.text, settings: tree.settings, now: now, calendar: utc)),
48 ("(org-check-before-date \"2026-09-20\")", SparseTree.planning(from: nil, to: "2026-09-20", in: Self.text, calendar: utc)),
49 ("(org-check-after-date \"2026-09-20\")", SparseTree.planning(from: "2026-09-20", to: nil, in: Self.text, calendar: utc)),
50 ("(org-check-dates-range \"2026-09-01\" \"2026-10-01 10:00\")", SparseTree.planning(from: "2026-09-01", to: "2026-10-01 10:00", in: Self.text, calendar: utc)),
51 ]
52 let forms = cases.map { form, _ in
53 frozenClock(Self.seconds, """
54 (with-current-buffer (find-file-noselect "oracle.org")
55 (org-fold-show-all) \(form)
56 (list (mapconcat (lambda (o) (format "%d-%d" (1- (overlay-start o)) (1- (overlay-end o))))
57 (sort (copy-sequence org-occur-highlights) (lambda (a b) (< (overlay-start a) (overlay-start b)))) " ")
58 (let (vis) (goto-char (point-min))
59 (while (not (eobp)) (push (if (if (eolp) (and (not (bobp)) (org-invisible-p (1- (point)))) (org-invisible-p (point))) "0" "1") vis) (forward-line 1))
60 (apply #'concat (nreverse vis)))))
61 """)
62 }
63 let emacs = try EmacsOracle.evaluate(Self.text, "(apply (function append) (list \(forms.joined(separator: " "))))", prelude: "(setenv \"TZ\" \"UTC0\")")
64 for (i, (form, matches)) in cases.enumerated() {
65 let hidden = SparseTree.hidden(matches.points, in: Self.text, tree: tree)
66 let ns = Self.text as NSString
67 var visibility = ""
68 var offset = 0
69 while offset < ns.length {
70 visibility += hidden.contains { $0.contains(offset) } ? "0" : "1"
71 offset = NSMaxRange(ns.lineRange(for: NSRange(location: offset, length: 0)))
72 }
73 let highlights = matches.highlights.map { "\($0.lowerBound)-\($0.upperBound)" }.joined(separator: " ")
74 #expect(highlights == emacs[2 * i], "\(form) highlights")
75 #expect(visibility == emacs[2 * i + 1], "\(form) visibility")
76 }
77 }
78}