krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgPresentation/Presentation.swift

0271e942ff3d2f72a407b3d9a6463fab8e4b48cf
orgstar/Sources/OrgPresentation/Presentation.swift history · blame · raw

170 lines · 7349 bytes

  1import OrgCore
  2
  3/// What a range of text is, for styling. Platform adapters map roles to attributes.
  4public enum StyleRole: Hashable, Sendable {
  5    /// A whole heading line.
  6    case heading(level: Int)
  7    /// All stars of a heading but the last, hidden as in `org-hide-leading-stars`.
  8    case leadingStars
  9    case todo, done, priority, tags
 10    case bold, italic, underline, strikeThrough, verbatim, code
 11    /// Emphasis markers.
 12    case markup
 13    case link
 14    /// Brackets and the target of a described link.
 15    case linkMarkup
 16    case timestamp, footnote, cookie, target, macro, latex, inlineSource
 17    case comment, keyword
 18    /// Planning lines, drawers, property drawers, clock lines.
 19    case metadata
 20    case block, blockDelimiter, table, rule
 21}
 22
 23public struct StyleRun: Sendable, Equatable {
 24    public let range: Range<Int>
 25    public let role: StyleRole
 26}
 27
 28/// org-indent: how many columns a run of whole lines starts in.
 29public struct IndentRun: Sendable, Equatable {
 30    public let range: Range<Int>
 31    public let firstLine: Int
 32    /// Continuation lines of a wrapped paragraph.
 33    public let wrapped: Int
 34}
 35
 36public enum Presentation {
 37    /// Style runs for nodes and tokens overlapping `range`, outer before inner, so applying
 38    /// them in order lets inner roles win.
 39    public static func styles(_ tree: OrgTree, in range: Range<Int>) -> [StyleRun] {
 40        var runs: [StyleRun] = []
 41        visit(tree.root, range, tree.settings, &runs)
 42        return runs
 43    }
 44
 45    private static func visit(_ node: SyntaxNode, _ range: Range<Int>, _ settings: OrgSettings, _ runs: inout [StyleRun]) {
 46        if let role = role(of: node) { runs.append(StyleRun(range: node.range, role: role)) }
 47        switch node.kind {
 48        case .heading:
 49            for token in node.tokens {
 50                switch token.kind {
 51                case .stars where token.range.count > 1:
 52                    runs.append(StyleRun(range: token.range.lowerBound..<(token.range.upperBound - 1), role: .leadingStars))
 53                case .todoKeyword:
 54                    runs.append(StyleRun(range: token.range, role: settings.isDone(token.text) ? .done : .todo))
 55                case .priority:
 56                    runs.append(StyleRun(range: token.range, role: .priority))
 57                case .tags:
 58                    runs.append(StyleRun(range: token.range, role: .tags))
 59                default:
 60                    break
 61                }
 62            }
 63        case .link:
 64            let described = node.children.contains { $0.kind == .linkDescription }
 65            for token in node.tokens where token.kind == .marker || (described && token.kind == .linkPath) {
 66                runs.append(StyleRun(range: token.range, role: .linkMarkup))
 67            }
 68        case .bold, .italic, .underline, .strikeThrough, .verbatim, .code:
 69            for token in node.tokens where token.kind == .marker {
 70                runs.append(StyleRun(range: token.range, role: .markup))
 71            }
 72        case .block, .dynamicBlock:
 73            let lines = node.tokens.filter { $0.kind == .text }
 74            for token in [lines.first, lines.count > 1 ? lines.last : nil].compactMap({ $0 }) {
 75                runs.append(StyleRun(range: token.range, role: .blockDelimiter))
 76            }
 77        default:
 78            break
 79        }
 80        for child in node.children(overlapping: range) {
 81            visit(child, range, settings, &runs)
 82        }
 83    }
 84
 85    static func role(of node: SyntaxNode) -> StyleRole? {
 86        switch node.kind {
 87        case .heading: .heading(level: node.tokens.first { $0.kind == .stars }?.text.count ?? 1)
 88        case .bold: .bold
 89        case .italic: .italic
 90        case .underline: .underline
 91        case .strikeThrough: .strikeThrough
 92        case .verbatim: .verbatim
 93        case .code: .code
 94        case .link: .link
 95        case .timestamp: .timestamp
 96        case .footnoteReference, .footnoteDefinition: .footnote
 97        case .statisticsCookie: .cookie
 98        case .target: .target
 99        case .macro: .macro
100        case .latexFragment: .latex
101        case .inlineSourceBlock: .inlineSource
102        case .comment: .comment
103        case .keyword, .affiliatedKeyword: .keyword
104        case .planning, .propertyDrawer, .drawer, .clock: .metadata
105        case .block, .dynamicBlock, .fixedWidth: .block
106        case .table: .table
107        case .horizontalRule: .rule
108        default: nil
109        }
110    }
111
112    /// Heading lines start at column 0, with wrapped title lines under the title; a section's
113    /// own body starts one column past its stars.
114    public static func indents(_ tree: OrgTree, in range: Range<Int>) -> [IndentRun] {
115        var runs: [IndentRun] = []
116        collectIndents(tree.root, range, &runs)
117        return runs
118    }
119
120    private static func collectIndents(_ node: SyntaxNode, _ range: Range<Int>, _ runs: inout [IndentRun]) {
121        for section in node.children(overlapping: range) where section.kind == .section {
122            guard let heading = section.firstChild(.heading) else { continue }
123            let level = heading.tokens.first { $0.kind == .stars }?.text.count ?? 1
124            runs.append(IndentRun(range: heading.range, firstLine: 0, wrapped: level + 1))
125            let bodyEnd = section.firstChild(.section)?.range.lowerBound ?? section.range.upperBound
126            if heading.range.upperBound < bodyEnd {
127                runs.append(IndentRun(range: heading.range.upperBound..<bodyEnd, firstLine: level + 1, wrapped: level + 1))
128            }
129            collectIndents(section, range, &runs)
130        }
131    }
132
133    /// Ranges to hide for folded headings, given by their start offsets: from the end of the
134    /// heading line to the end of its section. Sorted, without nested duplicates.
135    public static func hiddenRanges(_ tree: OrgTree, folds: Set<Int>) -> [Range<Int>] {
136        guard !folds.isEmpty else { return [] }
137        var ranges: [Range<Int>] = []
138        collectHidden(tree.root, folds, &ranges)
139        return ranges
140    }
141
142    private static func collectHidden(_ node: SyntaxNode, _ folds: Set<Int>, _ ranges: inout [Range<Int>]) {
143        for section in node.children where section.kind == .section {
144            guard let heading = section.children.first(where: { $0.kind == .heading }) else { continue }
145            if folds.contains(heading.range.lowerBound) {
146                if heading.range.upperBound < section.range.upperBound {
147                    ranges.append(heading.range.upperBound..<section.range.upperBound)
148                }
149            } else {
150                collectHidden(section, folds, &ranges)
151            }
152        }
153    }
154
155    /// Start offset of the heading whose line contains `offset`.
156    public static func heading(containing offset: Int, in tree: OrgTree) -> Int? {
157        var node = tree.root
158        while let child = node.child(containing: offset) {
159            if child.kind == .heading { return child.range.lowerBound }
160            guard child.kind == .section || child.kind == .zerothSection else { return nil }
161            node = child
162        }
163        return nil
164    }
165
166    /// Start offsets of every heading, for folding everything.
167    public static func headings(_ tree: OrgTree) -> [Int] {
168        tree.root.descendants().filter { $0.kind == .heading }.map(\.range.lowerBound)
169    }
170}