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