krz/orgstar

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

Sources/OrgPresentation/Display.swift

b98a6509c5ae75e5172dd333d1b6105fd6ceee0e
orgstar/Sources/OrgPresentation/Display.swift history · blame · raw

153 lines · 7520 bytes

9 symbols in this file
  1import Foundation
  2import OrgCore
  3
  4/// How a file shows when it opens, from the display settings and its `#+STARTUP` words.
  5public struct StartupDisplay: Sendable, Equatable {
  6    /// Headings, drawers and blocks folded.
  7    public var folds: FoldState
  8    /// The folded drawers and blocks.
  9    public var wrappers: [Wrapper]
 10    /// `org-indent-mode` (`indent`, `noindent`).
 11    public var indented: Bool
 12    /// `org-hide-leading-stars` (`hidestars`, `showstars`).
 13    public var hideStars: Bool
 14    /// `org-startup-with-inline-images` (`inlineimages`, `noinlineimages`).
 15    public var inlineImages: Bool
 16    /// `org-startup-align-all-tables` (`align`, `noalign`).
 17    public var alignTables: Bool
 18    /// `org-startup-truncated`.
 19    public var truncated: Bool
 20}
 21
 22/// Org's display, shared by the Mac and iOS editors.
 23extension Presentation {
 24    public static func startupDisplay(_ tree: OrgTree, options: EditingOptions) -> StartupDisplay {
 25        let words = startupOptions(tree).map { $0.lowercased() }
 26        var folds = startupFoldState(tree) ?? FoldState()
 27        // VISIBILITY properties and archived trees, unless everything shows.
 28        if startupFoldState(tree) != nil, !words.contains("showeverything") {
 29            folds = applyVisibilityProperties(folds, in: tree)
 30        }
 31        let wrappers = startupWrappers(tree, hideDrawers: options.hideDrawerStartup, hideBlocks: options.hideBlockStartup)
 32        folds.wrapperFolds = Set(wrappers.map(\.start))
 33        return StartupDisplay(
 34            folds: folds, wrappers: wrappers,
 35            indented: words.contains("indent") ? true : words.contains("noindent") ? false : options.startupIndented,
 36            hideStars: words.contains("hidestars") ? true : words.contains("showstars") ? false : options.hideLeadingStars,
 37            inlineImages: InlineImages.startup(tree) ?? options.startupWithInlineImages,
 38            alignTables: words.contains("align") || (options.startupAlignAllTables && !words.contains("noalign")),
 39            truncated: options.startupTruncated
 40        )
 41    }
 42
 43    /// Whether `role` hides while markup is hidden: link brackets and targets as with
 44    /// `org-link-descriptive`; emphasis markers only when Emacs hides them too.
 45    public static func hidesWithMarkup(_ role: StyleRole, options: EditingOptions) -> Bool {
 46        switch role {
 47        case .linkMarkup: true
 48        case .markup: options.hideEmphasisMarkers
 49        default: false
 50        }
 51    }
 52
 53    /// Whether a style run is skipped: leading stars show as the heading's color unless the
 54    /// view is indented or hides them.
 55    public static func skipsLeadingStars(_ role: StyleRole, indented: Bool, hideStars: Bool) -> Bool {
 56        role == .leadingStars && !indented && !hideStars
 57    }
 58
 59    /// One line's indentation under org-indent, in columns.
 60    public struct LineIndent: Sendable, Equatable {
 61        public let range: Range<Int>
 62        public let firstLine: Int
 63        public let wrapped: Int
 64    }
 65
 66    /// org-indent's indentation for the lines in `range`: each run of `indents`, with body lines
 67    /// wrapping under their own text (past leading blanks, a list bullet and a checkbox) as
 68    /// adaptive-wrap does in Emacs.
 69    public static func lineIndents(_ tree: OrgTree, in range: Range<Int>) -> [LineIndent] {
 70        let text = tree.text as NSString
 71        var out: [LineIndent] = []
 72        for run in indents(tree, in: range) {
 73            let clipped = run.range.clamped(to: range)
 74            guard !clipped.isEmpty else { continue }
 75            guard run.firstLine == run.wrapped else {
 76                out.append(LineIndent(range: clipped, firstLine: run.firstLine, wrapped: run.wrapped))
 77                continue
 78            }
 79            var location = clipped.lowerBound
 80            while location < clipped.upperBound {
 81                let line = text.lineRange(for: NSRange(location: location, length: 0))
 82                let hanging = hangingColumns(text.substring(with: line))
 83                let lineRange = max(line.location, clipped.lowerBound)..<min(NSMaxRange(line), clipped.upperBound)
 84                out.append(LineIndent(range: lineRange, firstLine: run.firstLine, wrapped: run.wrapped + hanging))
 85                location = NSMaxRange(line)
 86            }
 87        }
 88        return out
 89    }
 90
 91    /// Columns a wrapped continuation of `line` should start past the line's own start.
 92    public static func hangingColumns(_ line: String) -> Int {
 93        var columns = 0
 94        var rest = Substring(line)
 95        while let c = rest.first, c == " " || c == "\t" {
 96            columns = c == "\t" ? (columns / 8 + 1) * 8 : columns + 1
 97            rest = rest.dropFirst()
 98        }
 99        // A bullet (`-`, `+`, `*` when indented, `1.`, `1)`) and its space, then a checkbox.
100        var bullet = 0
101        if let first = rest.first, first == "-" || first == "+" || (first == "*" && columns > 0) {
102            bullet = 1
103        } else {
104            let digits = rest.prefix { $0.isASCII && $0.isNumber }
105            if !digits.isEmpty, let separator = rest.dropFirst(digits.count).first, separator == "." || separator == ")" {
106                bullet = digits.count + 1
107            }
108        }
109        guard bullet > 0, rest.dropFirst(bullet).first == " " else { return columns }
110        columns += bullet + 1
111        let afterBullet = rest.dropFirst(bullet + 1)
112        if afterBullet.count >= 4, afterBullet.first == "[", "X x-".contains(afterBullet.dropFirst().first!),
113           afterBullet.dropFirst(2).first == "]", afterBullet.dropFirst(3).first == " " {
114            columns += 4
115        }
116        return columns
117    }
118
119    /// `org-pretty-entities` for the lines in `range`, with each run's line start: not on the
120    /// line starting at `revealedLine`, in comments or in blocks; sub/superscripts not in
121    /// `scriptExclusions` (emphasis, links, keywords), as `org-raise-scripts` leaves them.
122    public static func prettyRuns(
123        _ text: NSString, in range: Range<Int>, revealedLine: Int?, inBlock: (Int) -> Bool,
124        scriptExclusions: [Range<Int>], subSuperscriptsNeedBraces: Bool
125    ) -> [(lineStart: Int, run: PrettyEntities.Run)] {
126        var out: [(lineStart: Int, run: PrettyEntities.Run)] = []
127        var location = range.lowerBound
128        while location < range.upperBound {
129            let line = text.lineRange(for: NSRange(location: location, length: 0))
130            guard line.length > 0 else { break }
131            location = NSMaxRange(line)
132            if line.location == revealedLine || inBlock(line.location) { continue }
133            let content = text.substring(with: line)
134            if content.range(of: "^[ \\t]*#([ +]|$)", options: .regularExpression) != nil { continue }
135            for run in PrettyEntities.runs(in: content, subSuperscriptsNeedBraces: subSuperscriptsNeedBraces) {
136                if case .script(_, let body, _) = run {
137                    let bodyStart = line.location + body.lowerBound
138                    if body.isEmpty || scriptExclusions.contains(where: { $0.contains(bodyStart) }) { continue }
139                }
140                out.append((line.location, run))
141            }
142        }
143        return out
144    }
145
146    /// Roles whose text keeps sub/superscript markers as written.
147    public static func excludesScripts(_ role: StyleRole) -> Bool {
148        switch role {
149        case .bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .linkMarkup, .keyword: true
150        default: false
151        }
152    }
153}