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 {
1212 public var link: OrgLink?
1313 /// Where the text starts in the file.
1414 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 }
1533 }
1634
1735 /// `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] {
1937 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)
2139 // A fold hides from the end of the opening line through the closing line, so the
2240 // marker sits on the opening line.
2341 let folds = folded.compactMap { r -> Range<Int>? in
@@ -25,16 +43,40 @@ public enum ReadingText {
2543 let end = r.upperBound > r.lowerBound && r.upperBound <= text.length && text.character(at: r.upperBound - 1) == 10 ? r.upperBound - 1 : r.upperBound
2644 return (r.lowerBound - 1)..<min(end, range.upperBound)
2745 }
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 }
2969 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) {
3172 bounds.insert(max(range.lowerBound, min(range.upperBound, r.lowerBound)))
3273 bounds.insert(max(range.lowerBound, min(range.upperBound, r.upperBound)))
3374 }
3475 let points = bounds.sorted()
3576 var segments: [Segment] = []
3677 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 {
3880 last.text += segment.text
3981 segments[segments.count - 1] = last
4082 } else {
@@ -48,7 +90,10 @@ public enum ReadingText {
4890 if hidden.contains(where: { $0.lowerBound <= a && b <= $0.upperBound }) { continue }
4991 let roles = Set(runs.filter { $0.range.lowerBound <= a && b <= $0.range.upperBound }.map(\.role))
5092 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) }
5297 }
5398 return segments
5499 }
Sources/OrgstarMobile/ReaderScreen.swift +95 −22
@@ -29,6 +29,8 @@ struct ReaderScreen: View {
2929 @State private var fold = FoldState()
3030 @State private var folded: [Range<Int>] = []
3131 @State private var loadedPath: String?
32 /// The file's display: indentation, leading stars, inline images, truncation.
33 @State private var startup: StartupDisplay?
3234 @State private var showingOutline = false
3335 @State private var message: String?
3436 @Environment(\.openURL) private var openURL
@@ -41,8 +43,9 @@ struct ReaderScreen: View {
4143 // Read again when settings or the Dynamic Type size change.
4244 let _ = model.settingsVersion
4345 let theme = MobileTheme(model.theme)
46 let truncated = startup?.truncated ?? false
4447 ScrollViewReader { proxy in
45 ScrollView {
48 ScrollView(truncated ? [.vertical, .horizontal] : .vertical) {
4649 LazyVStack(alignment: .leading, spacing: 8) {
4750 if let tree = session.document?.tree {
4851 let preambleEnd = outline.first?.start ?? (tree.text as NSString).length
@@ -52,14 +55,14 @@ struct ReaderScreen: View {
5255 heading(tree, entry, theme).id(entry.start)
5356 if !fold.folds.contains(entry.start), !fold.bodyFolds.contains(entry.start) {
5457 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)) }
5659 }
5760 }
5861 }
5962 }
6063 }
6164 .padding()
62 .frame(maxWidth: .infinity, alignment: .leading)
65 .frame(maxWidth: truncated ? nil : .infinity, alignment: .leading)
6366 }
6467 .background(theme.swiftColor("background") ?? Color(uiColor: .systemBackground))
6568 .onChange(of: session.revealRequest?.id) {
@@ -89,12 +92,31 @@ struct ReaderScreen: View {
8992 .onAppear(perform: open)
9093 .onChange(of: session.path) { loadFolds() }
9194 .onChange(of: session.editVersion) { loadFolds() }
95 .onChange(of: model.settingsVersion) { loadFolds() }
9296 }
9397
9498 // MARK: - Content
9599
96100 private func indent(_ entry: OutlineEntry) -> CGFloat { CGFloat(entry.level - 1) * 14 }
97101
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
98120 /// Hidden under a folded ancestor.
99121 private func isShown(_ index: Int) -> Bool {
100122 var parent = outline[index].parent
@@ -116,20 +138,19 @@ struct ReaderScreen: View {
116138
117139 private func heading(_ tree: OrgTree, _ entry: OutlineEntry, _ theme: MobileTheme) -> some View {
118140 let ns = tree.text as NSString
119 var line = ns.lineRange(for: NSRange(location: entry.start, length: 0))
120 let title = min(entry.start + entry.level + 1, NSMaxRange(line))
121 line.length = NSMaxRange(line) - title
122 let text = attributed(ReadingText.segments(tree, in: title..<(title + line.length)), heading: entry.level, theme)
123 let hasContent = entry.end > NSMaxRange(ns.lineRange(for: NSRange(location: entry.start, length: 0)))
141 let line = ns.lineRange(for: NSRange(location: entry.start, length: 0))
142 let start = indented ? min(entry.start + entry.level + 1, NSMaxRange(line)) : entry.start
143 let text = attributed(ReadingText.segments(tree, in: start..<NSMaxRange(line), display: display), heading: entry.level, theme)
144 let hasContent = entry.end > NSMaxRange(line)
124145 let foldedMark = fold.folds.contains(entry.start) && hasContent
125 return HStack(alignment: .firstTextBaseline, spacing: 6) {
146 return HStack(alignment: .firstTextBaseline, spacing: 0) {
126147 Image(systemName: foldedMark ? "chevron.right" : "chevron.down")
127148 .font(.caption.weight(.semibold)).foregroundStyle(theme.swiftColor("markup") ?? Color(uiColor: .tertiaryLabel))
128149 .opacity(hasContent ? 1 : 0)
129 Text(text.trimmingTrailingNewlines()).lineSpacing(CGFloat(max(0, theme.spec.lineSpacing)))
130 .frame(maxWidth: .infinity, alignment: .leading)
150 .frame(width: 2 * theme.column, alignment: .leading)
151 lineText(text.trimmingTrailingNewlines(), theme)
131152 }
132 .padding(.leading, indent(entry))
153 .padding(.leading, headingIndent(entry, theme))
133154 .padding(.top, entry.level == 1 ? 6 : 0)
134155 .contentShape(Rectangle())
135156 .onTapGesture { fold = Presentation.cycle(entry.start, in: tree, fold) }
@@ -138,10 +159,52 @@ struct ReaderScreen: View {
138159 }
139160
140161 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)
142179 .lineSpacing(CGFloat(max(0, theme.spec.lineSpacing)))
143 .textSelection(.enabled)
144 .frame(maxWidth: .infinity, alignment: .leading)
180 .fixedSize(horizontal: startup?.truncated ?? false, vertical: false)
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
145208 }
146209
147210 /// Segments as styled text in the theme's font and colors, as the editor styles them;
@@ -168,7 +231,11 @@ struct ReaderScreen: View {
168231 case .verbatim, .code, .inlineSource:
169232 if let background = theme.color("inline-background") { part.backgroundColor = Color(uiColor: background) }
170233 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) }
172239 case .timestamp: fg("timestamp")
173240 case .comment: fg("comment")
174241 case .keyword: fg("keyword")
@@ -179,13 +246,18 @@ struct ReaderScreen: View {
179246 case .link:
180247 part.underlineStyle = .single
181248 fg("link")
182 case .heading, .block, .syntax: break
249 case .heading, .block: break
183250 }
184251 }
185252 if segment.link != nil {
186253 // Found again by position when followed.
187254 part.link = URL(string: "orgstar-link:\(segment.offset)")
188255 }
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 }
189261 part.font = Font(font as CTFont)
190262 part.foregroundColor = Color(uiColor: color)
191263 result += part
@@ -216,15 +288,16 @@ struct ReaderScreen: View {
216288 loadFolds()
217289 }
218290
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.
220293 private func loadFolds() {
221294 guard let tree = session.document?.tree else { return }
222 folded = Presentation.startupWrappers(
223 tree, hideDrawers: session.options.hideDrawerStartup, hideBlocks: session.options.hideBlockStartup
224 ).map(\.hidden)
295 let display = Presentation.startupDisplay(tree, options: session.options)
296 startup = display
297 folded = display.wrappers.map(\.hidden)
225298 guard loadedPath != session.path else { return }
226299 loadedPath = session.path
227 fold = Presentation.applyVisibilityProperties(Presentation.startupFoldState(tree) ?? FoldState(), in: tree)
300 fold = display.folds
228301 }
229302
230303 /// Unfolds the way to the heading containing `offset` and scrolls to it.
Tests/OrgPresentationTests/ReadingTextTests.swift +36
@@ -18,4 +18,40 @@ struct ReadingTextTests {
1818 // Offsets point into the file.
1919 #expect(site.map { (text as NSString).substring(with: NSRange(location: $0.offset, length: 8)) } == "the site")
2020 }
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 }
2157}