Sources/OrgPresentation/Display.swift
153 lines · 7520 bytes
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}