krz/orgstar

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

Commit f095381f67

f095381f674d2b6644376d22bf3e0832f69740a8

parent: 3605fe1062

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-09 01:28 UTC

iOS reader honours the display settings

Hidden markup, hidden emphasis markers and pretty entities follow Show
Markup and the settings; src blocks are highlighted; org-indent,
hidestars, inline images and truncation follow #+STARTUP and the
settings; VISIBILITY properties apply only when #+STARTUP sets a
visibility, as in the editors.

Layout: unified · split

Sources/OrgPresentation/ReadingText.swift +51 −6
@@ -12,12 +12,30 @@ public enum ReadingText {
12 public var link: OrgLink? 12 public var link: OrgLink?
13 /// Where the text starts in the file. 13 /// Where the text starts in the file.
14 public var offset: Int 14 public var offset: Int
15 /// A pretty sub/superscript's body: true raised, false lowered.
16 public var superscript: Bool?
17 }
18
19 /// How the text shows, as the editor's display settings make it.
20 public struct Display: Sendable {
21 /// Link brackets and targets, and emphasis markers, shown; no pretty entities.
22 public var showMarkup = false
23 /// `org-hide-emphasis-markers`, `org-pretty-entities` and its braces.
24 public var options = EditingOptions.org
25 /// Colors code in src blocks.
26 public var highlighter: (any CodeHighlighter)?
27
28 public init(showMarkup: Bool = false, options: EditingOptions = .org, highlighter: (any CodeHighlighter)? = nil) {
29 self.showMarkup = showMarkup
30 self.options = options
31 self.highlighter = highlighter
32 }
15 } 33 }
16 34
17 /// `folded` are drawers' and blocks' hidden ranges (`Wrapper.hidden`). 35 /// `folded` are drawers' and blocks' hidden ranges (`Wrapper.hidden`).
18 public static func segments(_ tree: OrgTree, in range: Range<Int>, folded: [Range<Int>] = []) -> [Segment] { 36 public static func segments(_ tree: OrgTree, in range: Range<Int>, folded: [Range<Int>] = [], display: Display = Display()) -> [Segment] {
19 let text = tree.text as NSString 37 let text = tree.text as NSString
20 let runs = Presentation.styles(tree, in: range) 38 let runs = Presentation.styles(tree, in: range, highlighter: display.highlighter)
21 // A fold hides from the end of the opening line through the closing line, so the 39 // A fold hides from the end of the opening line through the closing line, so the
22 // marker sits on the opening line. 40 // marker sits on the opening line.
23 let folds = folded.compactMap { r -> Range<Int>? in 41 let folds = folded.compactMap { r -> Range<Int>? in
@@ -25,16 +43,40 @@ public enum ReadingText {
25 let end = r.upperBound > r.lowerBound && r.upperBound <= text.length && text.character(at: r.upperBound - 1) == 10 ? r.upperBound - 1 : r.upperBound 43 let end = r.upperBound > r.lowerBound && r.upperBound <= text.length && text.character(at: r.upperBound - 1) == 10 ? r.upperBound - 1 : r.upperBound
26 return (r.lowerBound - 1)..<min(end, range.upperBound) 44 return (r.lowerBound - 1)..<min(end, range.upperBound)
27 } 45 }
28 let hidden = runs.filter { $0.role == .linkMarkup }.map(\.range) + folds 46 var hidden = runs.filter { !display.showMarkup && Presentation.hidesWithMarkup($0.role, options: display.options) }.map(\.range) + folds
47 // `org-pretty-entities`: an entity's last character shows its character, the rest hidden.
48 var replaced: [Int: String] = [:]
49 var scripts: [(range: Range<Int>, superscript: Bool)] = []
50 if !display.showMarkup, display.options.prettyEntities {
51 let blocks = Presentation.shadedBlocks(tree)
52 let pretty = Presentation.prettyRuns(
53 text, in: range, revealedLine: nil, inBlock: { offset in blocks.contains { $0.contains(offset) } },
54 scriptExclusions: runs.filter { Presentation.excludesScripts($0.role) }.map(\.range),
55 subSuperscriptsNeedBraces: display.options.subSuperscriptsNeedBraces
56 )
57 for (lineStart, run) in pretty {
58 switch run {
59 case .entity(let r, let shown):
60 let last = lineStart + r.upperBound - 1
61 hidden.append((lineStart + r.lowerBound)..<last)
62 replaced[last] = shown
63 case .script(let markers, let body, let superscript):
64 hidden += markers.map { (lineStart + $0.lowerBound)..<(lineStart + $0.upperBound) }
65 scripts.append(((lineStart + body.lowerBound)..<(lineStart + body.upperBound), superscript))
66 }
67 }
68 }
29 var bounds: Set<Int> = [range.lowerBound, range.upperBound] 69 var bounds: Set<Int> = [range.lowerBound, range.upperBound]
30 for r in runs.map(\.range) + hidden { 70 let replacedRanges = replaced.keys.map { $0..<($0 + 1) }
71 for r in runs.map(\.range) + hidden + replacedRanges + scripts.map(\.range) {
31 bounds.insert(max(range.lowerBound, min(range.upperBound, r.lowerBound))) 72 bounds.insert(max(range.lowerBound, min(range.upperBound, r.lowerBound)))
32 bounds.insert(max(range.lowerBound, min(range.upperBound, r.upperBound))) 73 bounds.insert(max(range.lowerBound, min(range.upperBound, r.upperBound)))
33 } 74 }
34 let points = bounds.sorted() 75 let points = bounds.sorted()
35 var segments: [Segment] = [] 76 var segments: [Segment] = []
36 func append(_ segment: Segment) { 77 func append(_ segment: Segment) {
37 if var last = segments.last, last.roles == segment.roles, last.link == segment.link, last.offset + (last.text as NSString).length == segment.offset { 78 if var last = segments.last, last.roles == segment.roles, last.link == segment.link, last.superscript == segment.superscript,
79 last.offset + (last.text as NSString).length == segment.offset {
38 last.text += segment.text 80 last.text += segment.text
39 segments[segments.count - 1] = last 81 segments[segments.count - 1] = last
40 } else { 82 } else {
@@ -48,7 +90,10 @@ public enum ReadingText {
48 if hidden.contains(where: { $0.lowerBound <= a && b <= $0.upperBound }) { continue } 90 if hidden.contains(where: { $0.lowerBound <= a && b <= $0.upperBound }) { continue }
49 let roles = Set(runs.filter { $0.range.lowerBound <= a && b <= $0.range.upperBound }.map(\.role)) 91 let roles = Set(runs.filter { $0.range.lowerBound <= a && b <= $0.range.upperBound }.map(\.role))
50 let link = roles.contains(.link) ? Links.link(at: a, text: tree.text, tree: tree)?.link : nil 92 let link = roles.contains(.link) ? Links.link(at: a, text: tree.text, tree: tree)?.link : nil
51 append(Segment(text: text.substring(with: NSRange(location: a, length: b - a)), roles: roles, link: link, offset: a)) 93 let superscript = scripts.first { $0.range.lowerBound <= a && b <= $0.range.upperBound }?.superscript
94 let shown = replaced[a] ?? text.substring(with: NSRange(location: a, length: b - a))
95 let segment = Segment(text: shown, roles: roles, link: link, offset: a, superscript: superscript)
96 if replaced[a] != nil { segments.append(segment) } else { append(segment) }
52 } 97 }
53 return segments 98 return segments
54 } 99 }
Sources/OrgstarMobile/ReaderScreen.swift +95 −22
@@ -29,6 +29,8 @@ struct ReaderScreen: View {
29 @State private var fold = FoldState() 29 @State private var fold = FoldState()
30 @State private var folded: [Range<Int>] = [] 30 @State private var folded: [Range<Int>] = []
31 @State private var loadedPath: String? 31 @State private var loadedPath: String?
32 /// The file's display: indentation, leading stars, inline images, truncation.
33 @State private var startup: StartupDisplay?
32 @State private var showingOutline = false 34 @State private var showingOutline = false
33 @State private var message: String? 35 @State private var message: String?
34 @Environment(\.openURL) private var openURL 36 @Environment(\.openURL) private var openURL
@@ -41,8 +43,9 @@ struct ReaderScreen: View {
41 // Read again when settings or the Dynamic Type size change. 43 // Read again when settings or the Dynamic Type size change.
42 let _ = model.settingsVersion 44 let _ = model.settingsVersion
43 let theme = MobileTheme(model.theme) 45 let theme = MobileTheme(model.theme)
46 let truncated = startup?.truncated ?? false
44 ScrollViewReader { proxy in 47 ScrollViewReader { proxy in
45 ScrollView { 48 ScrollView(truncated ? [.vertical, .horizontal] : .vertical) {
46 LazyVStack(alignment: .leading, spacing: 8) { 49 LazyVStack(alignment: .leading, spacing: 8) {
47 if let tree = session.document?.tree { 50 if let tree = session.document?.tree {
48 let preambleEnd = outline.first?.start ?? (tree.text as NSString).length 51 let preambleEnd = outline.first?.start ?? (tree.text as NSString).length
@@ -52,14 +55,14 @@ struct ReaderScreen: View {
52 heading(tree, entry, theme).id(entry.start) 55 heading(tree, entry, theme).id(entry.start)
53 if !fold.folds.contains(entry.start), !fold.bodyFolds.contains(entry.start) { 56 if !fold.folds.contains(entry.start), !fold.bodyFolds.contains(entry.start) {
54 let body = bodyRange(index, tree) 57 let body = bodyRange(index, tree)
55 if !body.isEmpty { textView(tree, body, theme).padding(.leading, indent(entry)) } 58 if !body.isEmpty { textView(tree, body, theme).padding(.leading, bodyIndent(entry, theme)) }
56 } 59 }
57 } 60 }
58 } 61 }
59 } 62 }
60 } 63 }
61 .padding() 64 .padding()
62 .frame(maxWidth: .infinity, alignment: .leading) 65 .frame(maxWidth: truncated ? nil : .infinity, alignment: .leading)
63 } 66 }
64 .background(theme.swiftColor("background") ?? Color(uiColor: .systemBackground)) 67 .background(theme.swiftColor("background") ?? Color(uiColor: .systemBackground))
65 .onChange(of: session.revealRequest?.id) { 68 .onChange(of: session.revealRequest?.id) {
@@ -89,12 +92,31 @@ struct ReaderScreen: View {
89 .onAppear(perform: open) 92 .onAppear(perform: open)
90 .onChange(of: session.path) { loadFolds() } 93 .onChange(of: session.path) { loadFolds() }
91 .onChange(of: session.editVersion) { loadFolds() } 94 .onChange(of: session.editVersion) { loadFolds() }
95 .onChange(of: model.settingsVersion) { loadFolds() }
92 } 96 }
93 97
94 // MARK: - Content 98 // MARK: - Content
95 99
96 private func indent(_ entry: OutlineEntry) -> CGFloat { CGFloat(entry.level - 1) * 14 } 100 private func indent(_ entry: OutlineEntry) -> CGFloat { CGFloat(entry.level - 1) * 14 }
97 101
102 private var indented: Bool { startup?.indented ?? true }
103
104 /// The text as the editor's display settings show it.
105 private var display: ReadingText.Display {
106 ReadingText.Display(showMarkup: model.showMarkup, options: session.options, highlighter: MobileEditor.highlighter)
107 }
108
109 /// org-indent: a heading's text and its body start past its stars, at level + 1 columns,
110 /// the fold mark standing where the last star and its space would. Without it nothing is
111 /// indented and the stars show.
112 private func headingIndent(_ entry: OutlineEntry, _ theme: MobileTheme) -> CGFloat {
113 indented ? CGFloat(entry.level - 1) * theme.column : 0
114 }
115
116 private func bodyIndent(_ entry: OutlineEntry, _ theme: MobileTheme) -> CGFloat {
117 indented ? CGFloat(entry.level + 1) * theme.column : 0
118 }
119
98 /// Hidden under a folded ancestor. 120 /// Hidden under a folded ancestor.
99 private func isShown(_ index: Int) -> Bool { 121 private func isShown(_ index: Int) -> Bool {
100 var parent = outline[index].parent 122 var parent = outline[index].parent
@@ -116,20 +138,19 @@ struct ReaderScreen: View {
116 138
117 private func heading(_ tree: OrgTree, _ entry: OutlineEntry, _ theme: MobileTheme) -> some View { 139 private func heading(_ tree: OrgTree, _ entry: OutlineEntry, _ theme: MobileTheme) -> some View {
118 let ns = tree.text as NSString 140 let ns = tree.text as NSString
119 var line = ns.lineRange(for: NSRange(location: entry.start, length: 0)) 141 let line = ns.lineRange(for: NSRange(location: entry.start, length: 0))
120 let title = min(entry.start + entry.level + 1, NSMaxRange(line)) 142 let start = indented ? min(entry.start + entry.level + 1, NSMaxRange(line)) : entry.start
121 line.length = NSMaxRange(line) - title 143 let text = attributed(ReadingText.segments(tree, in: start..<NSMaxRange(line), display: display), heading: entry.level, theme)
122 let text = attributed(ReadingText.segments(tree, in: title..<(title + line.length)), heading: entry.level, theme) 144 let hasContent = entry.end > NSMaxRange(line)
123 let hasContent = entry.end > NSMaxRange(ns.lineRange(for: NSRange(location: entry.start, length: 0)))
124 let foldedMark = fold.folds.contains(entry.start) && hasContent 145 let foldedMark = fold.folds.contains(entry.start) && hasContent
125 return HStack(alignment: .firstTextBaseline, spacing: 6) { 146 return HStack(alignment: .firstTextBaseline, spacing: 0) {
126 Image(systemName: foldedMark ? "chevron.right" : "chevron.down") 147 Image(systemName: foldedMark ? "chevron.right" : "chevron.down")
127 .font(.caption.weight(.semibold)).foregroundStyle(theme.swiftColor("markup") ?? Color(uiColor: .tertiaryLabel)) 148 .font(.caption.weight(.semibold)).foregroundStyle(theme.swiftColor("markup") ?? Color(uiColor: .tertiaryLabel))
128 .opacity(hasContent ? 1 : 0) 149 .opacity(hasContent ? 1 : 0)
129 Text(text.trimmingTrailingNewlines()).lineSpacing(CGFloat(max(0, theme.spec.lineSpacing))) 150 .frame(width: 2 * theme.column, alignment: .leading)
130 .frame(maxWidth: .infinity, alignment: .leading) 151 lineText(text.trimmingTrailingNewlines(), theme)
131 } 152 }
132 .padding(.leading, indent(entry)) 153 .padding(.leading, headingIndent(entry, theme))
133 .padding(.top, entry.level == 1 ? 6 : 0) 154 .padding(.top, entry.level == 1 ? 6 : 0)
134 .contentShape(Rectangle()) 155 .contentShape(Rectangle())
135 .onTapGesture { fold = Presentation.cycle(entry.start, in: tree, fold) } 156 .onTapGesture { fold = Presentation.cycle(entry.start, in: tree, fold) }
@@ -138,10 +159,52 @@ struct ReaderScreen: View {
138 } 159 }
139 160
140 private func textView(_ tree: OrgTree, _ range: Range<Int>, _ theme: MobileTheme) -> some View { 161 private func textView(_ tree: OrgTree, _ range: Range<Int>, _ theme: MobileTheme) -> some View {
141 Text(attributed(ReadingText.segments(tree, in: range, folded: folded), heading: nil, theme).trimmingTrailingNewlines()) 162 VStack(alignment: .leading, spacing: 4) {
163 ForEach(parts(tree, range), id: \.range.lowerBound) { part in
164 if let image = part.image {
165 Image(uiImage: image.picture).resizable().scaledToFit()
166 .frame(maxWidth: image.width.map { CGFloat($0) } ?? image.picture.size.width, alignment: .leading)
167 .accessibilityLabel((image.path as NSString).lastPathComponent)
168 } else {
169 lineText(attributed(ReadingText.segments(tree, in: part.range, folded: folded, display: display), heading: nil, theme).trimmingTrailingNewlines(), theme)
170 .textSelection(.enabled)
171 }
172 }
173 }
174 }
175
176 /// Wrapped to the view's width, or on one line each, scrolling sideways, while truncated.
177 private func lineText(_ text: AttributedString, _ theme: MobileTheme) -> some View {
178 Text(text)
142 .lineSpacing(CGFloat(max(0, theme.spec.lineSpacing))) 179 .lineSpacing(CGFloat(max(0, theme.spec.lineSpacing)))
143 .textSelection(.enabled) 180 .fixedSize(horizontal: startup?.truncated ?? false, vertical: false)
144 .frame(maxWidth: .infinity, alignment: .leading) 181 .frame(maxWidth: startup?.truncated == true ? nil : .infinity, alignment: .leading)
182 }
183
184 private struct Part {
185 let range: Range<Int>
186 var image: (picture: UIImage, path: String, width: Int?)?
187 }
188
189 /// Text, and with inline images on, lines that are only an image link shown as the image.
190 private func parts(_ tree: OrgTree, _ range: Range<Int>) -> [Part] {
191 guard startup?.inlineImages == true, let path = session.path else { return [Part(range: range)] }
192 let base = URL(fileURLWithPath: path).deletingLastPathComponent()
193 let ns = tree.text as NSString
194 var parts: [Part] = []
195 var textStart = range.lowerBound
196 var location = range.lowerBound
197 while location < range.upperBound {
198 let line = ns.lineRange(for: NSRange(location: location, length: 0))
199 location = NSMaxRange(line)
200 guard folded.allSatisfy({ !$0.contains(line.location) }), let image = InlineImages.image(atLine: line.location, in: ns),
201 let picture = MobileEditor.loadImage(image.path, relativeTo: base) else { continue }
202 if textStart < line.location { parts.append(Part(range: textStart..<line.location)) }
203 parts.append(Part(range: line.location..<min(NSMaxRange(line), range.upperBound), image: (picture, image.path, image.width)))
204 textStart = min(NSMaxRange(line), range.upperBound)
205 }
206 if textStart < range.upperBound { parts.append(Part(range: textStart..<range.upperBound)) }
207 return parts
145 } 208 }
146 209
147 /// Segments as styled text in the theme's font and colors, as the editor styles them; 210 /// Segments as styled text in the theme's font and colors, as the editor styles them;
@@ -168,7 +231,11 @@ struct ReaderScreen: View {
168 case .verbatim, .code, .inlineSource: 231 case .verbatim, .code, .inlineSource:
169 if let background = theme.color("inline-background") { part.backgroundColor = Color(uiColor: background) } 232 if let background = theme.color("inline-background") { part.backgroundColor = Color(uiColor: background) }
170 fg(role == .verbatim ? "verbatim" : "code") 233 fg(role == .verbatim ? "verbatim" : "code")
171 case .markup, .linkMarkup, .leadingStars: fg("markup") 234 case .markup, .linkMarkup: fg("markup")
235 case .leadingStars:
236 // As the editor hides them; shown in the heading's color unless hidden.
237 if !Presentation.skipsLeadingStars(role, indented: indented, hideStars: startup?.hideStars ?? false) { color = .clear }
238 case .syntax(let category): if let key = category.themeKey { fg(key) }
172 case .timestamp: fg("timestamp") 239 case .timestamp: fg("timestamp")
173 case .comment: fg("comment") 240 case .comment: fg("comment")
174 case .keyword: fg("keyword") 241 case .keyword: fg("keyword")
@@ -179,13 +246,18 @@ struct ReaderScreen: View {
179 case .link: 246 case .link:
180 part.underlineStyle = .single 247 part.underlineStyle = .single
181 fg("link") 248 fg("link")
182 case .heading, .block, .syntax: break 249 case .heading, .block: break
183 } 250 }
184 } 251 }
185 if segment.link != nil { 252 if segment.link != nil {
186 // Found again by position when followed. 253 // Found again by position when followed.
187 part.link = URL(string: "orgstar-link:\(segment.offset)") 254 part.link = URL(string: "orgstar-link:\(segment.offset)")
188 } 255 }
256 if let superscript = segment.superscript {
257 // Raised or lowered as the editor draws `org-pretty-entities` scripts.
258 part.baselineOffset = (superscript ? 0.3 : -0.3) * font.pointSize
259 font = font.withSize(font.pointSize * 0.7)
260 }
189 part.font = Font(font as CTFont) 261 part.font = Font(font as CTFont)
190 part.foregroundColor = Color(uiColor: color) 262 part.foregroundColor = Color(uiColor: color)
191 result += part 263 result += part
@@ -216,15 +288,16 @@ struct ReaderScreen: View {
216 loadFolds() 288 loadFolds()
217 } 289 }
218 290
219 /// The file's startup visibility, kept while the same file is open. 291 /// The file's startup display, as the editor opens it; its heading folds are kept while
292 /// the same file is open.
220 private func loadFolds() { 293 private func loadFolds() {
221 guard let tree = session.document?.tree else { return } 294 guard let tree = session.document?.tree else { return }
222 folded = Presentation.startupWrappers( 295 let display = Presentation.startupDisplay(tree, options: session.options)
223 tree, hideDrawers: session.options.hideDrawerStartup, hideBlocks: session.options.hideBlockStartup 296 startup = display
224 ).map(\.hidden) 297 folded = display.wrappers.map(\.hidden)
225 guard loadedPath != session.path else { return } 298 guard loadedPath != session.path else { return }
226 loadedPath = session.path 299 loadedPath = session.path
227 fold = Presentation.applyVisibilityProperties(Presentation.startupFoldState(tree) ?? FoldState(), in: tree) 300 fold = display.folds
228 } 301 }
229 302
230 /// Unfolds the way to the heading containing `offset` and scrolls to it. 303 /// Unfolds the way to the heading containing `offset` and scrolls to it.
Tests/OrgPresentationTests/ReadingTextTests.swift +36
@@ -18,4 +18,40 @@ struct ReadingTextTests {
18 // Offsets point into the file. 18 // Offsets point into the file.
19 #expect(site.map { (text as NSString).substring(with: NSRange(location: $0.offset, length: 8)) } == "the site") 19 #expect(site.map { (text as NSString).substring(with: NSRange(location: $0.offset, length: 8)) } == "the site")
20 } 20 }
21
22 static func shown(_ text: String, _ display: ReadingText.Display) -> [ReadingText.Segment] {
23 ReadingText.segments(OrgParser.parse(text), in: 0..<(text as NSString).length, display: display)
24 }
25
26 @Test func showMarkupShowsLinkBracketsAndEmphasisMarkers() {
27 var options = EditingOptions.org
28 options.hideEmphasisMarkers = true
29 let text = "a *b* [[c.org][d]]\n"
30 #expect(Self.shown(text, .init(options: options)).map(\.text).joined() == "a b d\n")
31 #expect(Self.shown(text, .init(showMarkup: false)).map(\.text).joined() == "a *b* d\n")
32 #expect(Self.shown(text, .init(showMarkup: true, options: options)).map(\.text).joined() == text)
33 }
34
35 @Test func prettyEntitiesShowTheirCharactersAndRaiseScripts() {
36 var options = EditingOptions.org
37 options.prettyEntities = true
38 options.subSuperscriptsNeedBraces = true
39 let text = "\\alpha and x^{2}\n#+begin_example\n\\beta\n#+end_example\n"
40 let segments = Self.shown(text, .init(options: options))
41 #expect(segments.map(\.text).joined() == "α and x2\n#+begin_example\n\\beta\n#+end_example\n")
42 #expect(segments.first { $0.text == "2" }?.superscript == true)
43 #expect(Self.shown(text, .init(showMarkup: true, options: options)).map(\.text).joined() == text)
44 }
45
46 struct Words: CodeHighlighter {
47 func highlights(language: String, code: String) -> [(range: Range<Int>, category: SyntaxCategory)] {
48 [(0..<4, .keyword)]
49 }
50 }
51
52 @Test func highlightsSrcBlocks() {
53 let text = "#+begin_src sh\necho hi\n#+end_src\n"
54 let segments = Self.shown(text, .init(highlighter: Words()))
55 #expect(segments.first { $0.text == "echo" }?.roles.contains(.syntax(.keyword)) == true)
56 }
21} 57}