Sparse trees !88
8 files changed, +469 −3
Layout: unified · split
Sources/OrgCore/Commands/Command.swift +4 −1
| @@ -163,6 +163,9 @@ public enum Effect: Sendable, Equatable { | ||
| 163 | 163 | case storedLinkUsed(String) |
| 164 | 164 | /// Text for the clipboard (the kill ring). |
| 165 | 165 | 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>]) | |
| 166 | 169 | } |
| 167 | 170 | |
| 168 | 171 | public struct EditResult: Sendable, Equatable { |
| @@ -232,7 +235,7 @@ public enum Commands { | ||
| 232 | 235 | ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), |
| 233 | 236 | OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(), |
| 234 | 237 | 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(), | |
| 236 | 239 | ] |
| 237 | 240 | |
| 238 | 241 | public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) } |
Sources/OrgCore/Commands/SparseTree.swift added +277
| @@ -0,0 +1,277 @@ | ||
| 1 | import 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 | ||
| 7 | public 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. | |
| 183 | public 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 { | ||
| 210 | 210 | command = "org.narrow.subtree" |
| 211 | 211 | mode = "normal" |
| 212 | 212 | [[bind]] |
| 213 | keys = "SPC m s s" | |
| 214 | command = "org.sparse-tree" | |
| 215 | mode = "normal" | |
| 216 | [[bind]] | |
| 213 | 217 | keys = "SPC m s N" |
| 214 | 218 | command = "org.widen" |
| 215 | 219 | mode = "normal" |
| @@ -675,6 +679,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | ||
| 675 | 679 | keys = "C-c C-j" |
| 676 | 680 | command = "org.goto" |
| 677 | 681 | [[bind]] |
| 682 | keys = "C-c /" | |
| 683 | command = "org.sparse-tree" | |
| 684 | [[bind]] | |
| 678 | 685 | keys = "C-c C-x C-r" |
| 679 | 686 | command = "org.clock.report" |
| 680 | 687 | [[bind]] |
Sources/OrgEditorAppKit/OrgEditor.swift +96 −1
| @@ -392,6 +392,11 @@ public final class OrgEditor: NSObject { | ||
| 392 | 392 | onMessage?("Not an org file") |
| 393 | 393 | return |
| 394 | 394 | } |
| 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 | } | |
| 395 | 400 | var keepMark = false |
| 396 | 401 | switch command.action { |
| 397 | 402 | case .text(let text): |
| @@ -452,7 +457,11 @@ public final class OrgEditor: NSObject { | ||
| 452 | 457 | if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) } |
| 453 | 458 | } |
| 454 | 459 | 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 | } | |
| 456 | 465 | } |
| 457 | 466 | case .failed(let message): |
| 458 | 467 | onMessage?(message) |
| @@ -494,6 +503,7 @@ public final class OrgEditor: NSObject { | ||
| 494 | 503 | |
| 495 | 504 | /// `wrappers`, when given, holds every folded drawer and block. |
| 496 | 505 | private func setFolds(_ state: FoldState, wrappers: [Wrapper]?) { |
| 506 | sparseHidden = nil | |
| 497 | 507 | view.folds = state.folds |
| 498 | 508 | view.bodyFolds = state.bodyFolds |
| 499 | 509 | view = view.pruned(to: document.tree, wrappers: false) |
| @@ -516,10 +526,84 @@ public final class OrgEditor: NSObject { | ||
| 516 | 526 | private var wrapperHidden: [Int: Range<Int>] = [:] |
| 517 | 527 | |
| 518 | 528 | 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 | } | |
| 519 | 540 | folded.set(view.folds.union(view.bodyFolds).union(view.wrapperFolds)) |
| 520 | 541 | hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds, bodyFolds: view.bodyFolds, wrapperRanges: Array(wrapperHidden.values)) + narrowHidden) |
| 521 | 542 | } |
| 522 | 543 | |
| 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 | ||
| 523 | 607 | // MARK: - Narrowing |
| 524 | 608 | |
| 525 | 609 | /// The text shown while narrowed (`org-narrow-to-subtree`, `org-narrow-to-block`); the |
| @@ -689,6 +773,7 @@ public final class OrgEditor: NSObject { | ||
| 689 | 773 | /// cycles folded, children, subtree. Returns false elsewhere. |
| 690 | 774 | @discardableResult |
| 691 | 775 | public func cycleAtCaret() -> Bool { |
| 776 | leaveSparseTree() | |
| 692 | 777 | let caret = textView.selectedRange().location |
| 693 | 778 | guard let state = Presentation.cycle(at: caret, in: document.tree, foldState) else { return false } |
| 694 | 779 | setFolds(state) |
| @@ -699,6 +784,7 @@ public final class OrgEditor: NSObject { | ||
| 699 | 784 | |
| 700 | 785 | /// Shift-TAB: overview, contents, show all. |
| 701 | 786 | public func cycleGlobal() { |
| 787 | leaveSparseTree() | |
| 702 | 788 | let (visibility, state) = Presentation.cycleGlobal(from: globalVisibility, in: document.tree, wrapperFolds: view.wrapperFolds) |
| 703 | 789 | globalVisibility = visibility |
| 704 | 790 | setFolds(state) |
| @@ -715,6 +801,7 @@ public final class OrgEditor: NSObject { | ||
| 715 | 801 | |
| 716 | 802 | /// Unfolds the headings whose folds hide `offset`, leaving the selection alone. |
| 717 | 803 | func unfold(around offset: Int) { |
| 804 | leaveSparseTree() | |
| 718 | 805 | var state = foldState |
| 719 | 806 | for entry in Presentation.outline(document.tree) where entry.start < offset && offset < entry.end { |
| 720 | 807 | state.folds.remove(entry.start) |
| @@ -804,6 +891,7 @@ public final class OrgEditor: NSObject { | ||
| 804 | 891 | } |
| 805 | 892 | if !showMarkup, options.prettyEntities { prettify(range, in: storage, revealed: revealed, scriptExclusions: scriptExclusions) } |
| 806 | 893 | applyShrunk(range, in: storage) |
| 894 | applyHighlights(range, in: storage) | |
| 807 | 895 | } |
| 808 | 896 | |
| 809 | 897 | /// `org-pretty-entities`: entities drawn as their character over the entity's last |
| @@ -992,9 +1080,16 @@ public final class OrgEditor: NSObject { | ||
| 992 | 1080 | view = view.mapped(through: [edit]).pruned(to: document.tree, wrappers: false) |
| 993 | 1081 | mapWrapperFolds(through: edit, near: min(oldMapped.lowerBound, newAffected.lowerBound)..<max(oldMapped.upperBound, newAffected.upperBound)) |
| 994 | 1082 | 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 = [] | |
| 995 | 1086 | updateHidden() |
| 996 | 1087 | restyle(min(oldMapped.lowerBound, newAffected.lowerBound)..<min(max(oldMapped.upperBound, newAffected.upperBound), newText.length), in: storage) |
| 997 | 1088 | 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 | } | |
| 998 | 1093 | let oldShaded = shaded.all |
| 999 | 1094 | if isOrg { |
| 1000 | 1095 | // 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 { | ||
| 314 | 314 | // The file opened above needs its editor first. |
| 315 | 315 | for _ in 0..<20 where DevelopmentHooks.fileToOpen != nil && commands.editor == nil { try? await Task.sleep(for: .milliseconds(100)) } |
| 316 | 316 | 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 | } | |
| 317 | 321 | } |
| 318 | 322 | if let id = DevelopmentHooks.windowToOpen { |
| 319 | 323 | if id == "settings" { openSettings() } else { openWindow(id: id) } |
Sources/Orgstar/DevelopmentHooks.swift +2
| @@ -7,6 +7,7 @@ import AppKit | ||
| 7 | 7 | /// - `ORGSTAR_WINDOW`: a window to open at launch (`agenda`, `settings`); the snapshot renders it. |
| 8 | 8 | /// - `ORGSTAR_COMMAND`: commands to run at launch, separated by commas (`app.import-emacs`); a |
| 9 | 9 | /// 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. | |
| 10 | 11 | /// - `ORGSTAR_APPEARANCE`: `dark` or `light`, instead of the system's. |
| 11 | 12 | /// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main |
| 12 | 13 | /// window to this PNG path and quit. Rendering from inside the app needs no screen-recording |
| @@ -16,6 +17,7 @@ enum DevelopmentHooks { | ||
| 16 | 17 | static var rootToAdd: String? { ProcessInfo.processInfo.environment["ORGSTAR_ROOT"] } |
| 17 | 18 | static var windowToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_WINDOW"] } |
| 18 | 19 | static var commandToRun: String? { ProcessInfo.processInfo.environment["ORGSTAR_COMMAND"] } |
| 20 | static var answers: [String] { ProcessInfo.processInfo.environment["ORGSTAR_ANSWERS"]?.split(separator: ",").map(String.init) ?? [] } | |
| 19 | 21 | |
| 20 | 22 | @MainActor |
| 21 | 23 | static func scheduleSnapshot() { |
Sources/Orgstar/EditorView.swift +1 −1
| @@ -103,7 +103,7 @@ struct EditorView: NSViewRepresentable { | ||
| 103 | 103 | case .copy(let text): |
| 104 | 104 | NSPasteboard.general.clearContents() |
| 105 | 105 | NSPasteboard.general.setString(text, forType: .string) |
| 106 | case .message: break | |
| 106 | case .message, .sparseTree: break | |
| 107 | 107 | } |
| 108 | 108 | } |
| 109 | 109 | let scroll = editor.makeScrollView() |
Tests/OrgCoreTests/SparseTreeTests.swift added +78
| @@ -0,0 +1,78 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgCore | |
| 4 | ||
| 5 | struct 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 | } | |