krz/orgstar

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

Commit 6ee5ec510b

6ee5ec510b048f2f6bfb80c5daa30530ccda403d

parent: 4823cf7980

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 07:14 UTC

Theme the iOS reader; show theme and font in Settings

The reader uses the theme's colors, font, line spacing and heading step, and
folds drawers and blocks per org-cycle-hide-*-startup.

Layout: unified · split

Sources/OrgstarMobile/ReaderScreen.swift +62 −31
@@ -4,6 +4,7 @@ import OrgCore
4import OrgIndex 4import OrgIndex
5import OrgPresentation 5import OrgPresentation
6import SwiftUI 6import SwiftUI
7import UIKit
7 8
8/// What to read: a file, and where in it. 9/// What to read: a file, and where in it.
9enum ReaderRoute: Hashable { 10enum ReaderRoute: Hashable {
@@ -31,23 +32,27 @@ struct ReaderScreen: View {
31 @State private var showingOutline = false 32 @State private var showingOutline = false
32 @State private var message: String? 33 @State private var message: String?
33 @Environment(\.openURL) private var openURL 34 @Environment(\.openURL) private var openURL
35 @Environment(MobileModel.self) private var model
34 36
35 private var outline: [OutlineEntry] { session.outline } 37 private var outline: [OutlineEntry] { session.outline }
36 38
37 var body: some View { 39 var body: some View {
38 let _ = session.editVersion 40 let _ = session.editVersion
41 // Read again when settings or the Dynamic Type size change.
42 let _ = model.settingsVersion
43 let theme = MobileTheme(model.theme)
39 ScrollViewReader { proxy in 44 ScrollViewReader { proxy in
40 ScrollView { 45 ScrollView {
41 LazyVStack(alignment: .leading, spacing: 8) { 46 LazyVStack(alignment: .leading, spacing: 8) {
42 if let tree = session.document?.tree { 47 if let tree = session.document?.tree {
43 let preambleEnd = outline.first?.start ?? (tree.text as NSString).length 48 let preambleEnd = outline.first?.start ?? (tree.text as NSString).length
44 if preambleEnd > 0 { textView(tree, 0..<preambleEnd) } 49 if preambleEnd > 0 { textView(tree, 0..<preambleEnd, theme) }
45 ForEach(Array(outline.enumerated()), id: \.element.start) { index, entry in 50 ForEach(Array(outline.enumerated()), id: \.element.start) { index, entry in
46 if isShown(index) { 51 if isShown(index) {
47 heading(tree, entry).id(entry.start) 52 heading(tree, entry, theme).id(entry.start)
48 if !fold.folds.contains(entry.start), !fold.bodyFolds.contains(entry.start) { 53 if !fold.folds.contains(entry.start), !fold.bodyFolds.contains(entry.start) {
49 let body = bodyRange(index, tree) 54 let body = bodyRange(index, tree)
50 if !body.isEmpty { textView(tree, body).padding(.leading, indent(entry)) } 55 if !body.isEmpty { textView(tree, body, theme).padding(.leading, indent(entry)) }
51 } 56 }
52 } 57 }
53 } 58 }
@@ -56,6 +61,7 @@ struct ReaderScreen: View {
56 .padding() 61 .padding()
57 .frame(maxWidth: .infinity, alignment: .leading) 62 .frame(maxWidth: .infinity, alignment: .leading)
58 } 63 }
64 .background(theme.swiftColor("background") ?? Color(uiColor: .systemBackground))
59 .onChange(of: session.revealRequest?.id) { 65 .onChange(of: session.revealRequest?.id) {
60 guard let offset = session.revealRequest?.offset else { return } 66 guard let offset = session.revealRequest?.offset else { return }
61 reveal(offset, proxy) 67 reveal(offset, proxy)
@@ -108,19 +114,20 @@ struct ReaderScreen: View {
108 return min(lineEnd, firstChild)..<firstChild 114 return min(lineEnd, firstChild)..<firstChild
109 } 115 }
110 116
111 private func heading(_ tree: OrgTree, _ entry: OutlineEntry) -> some View { 117 private func heading(_ tree: OrgTree, _ entry: OutlineEntry, _ theme: MobileTheme) -> some View {
112 let ns = tree.text as NSString 118 let ns = tree.text as NSString
113 var line = ns.lineRange(for: NSRange(location: entry.start, length: 0)) 119 var line = ns.lineRange(for: NSRange(location: entry.start, length: 0))
114 let title = min(entry.start + entry.level + 1, NSMaxRange(line)) 120 let title = min(entry.start + entry.level + 1, NSMaxRange(line))
115 line.length = NSMaxRange(line) - title 121 line.length = NSMaxRange(line) - title
116 let text = attributed(ReadingText.segments(tree, in: title..<(title + line.length)), heading: entry.level) 122 let text = attributed(ReadingText.segments(tree, in: title..<(title + line.length)), heading: entry.level, theme)
117 let hasContent = entry.end > NSMaxRange(ns.lineRange(for: NSRange(location: entry.start, length: 0))) 123 let hasContent = entry.end > NSMaxRange(ns.lineRange(for: NSRange(location: entry.start, length: 0)))
118 let foldedMark = fold.folds.contains(entry.start) && hasContent 124 let foldedMark = fold.folds.contains(entry.start) && hasContent
119 return HStack(alignment: .firstTextBaseline, spacing: 6) { 125 return HStack(alignment: .firstTextBaseline, spacing: 6) {
120 Image(systemName: foldedMark ? "chevron.right" : "chevron.down") 126 Image(systemName: foldedMark ? "chevron.right" : "chevron.down")
121 .font(.caption.weight(.semibold)).foregroundStyle(.tertiary) 127 .font(.caption.weight(.semibold)).foregroundStyle(theme.swiftColor("markup") ?? Color(uiColor: .tertiaryLabel))
122 .opacity(hasContent ? 1 : 0) 128 .opacity(hasContent ? 1 : 0)
123 Text(text.trimmingTrailingNewlines()).frame(maxWidth: .infinity, alignment: .leading) 129 Text(text.trimmingTrailingNewlines()).lineSpacing(CGFloat(max(0, theme.spec.lineSpacing)))
130 .frame(maxWidth: .infinity, alignment: .leading)
124 } 131 }
125 .padding(.leading, indent(entry)) 132 .padding(.leading, indent(entry))
126 .padding(.top, entry.level == 1 ? 6 : 0) 133 .padding(.top, entry.level == 1 ? 6 : 0)
@@ -130,50 +137,72 @@ struct ReaderScreen: View {
130 .accessibilityHint(foldedMark ? "Unfolds the heading" : "Folds the heading") 137 .accessibilityHint(foldedMark ? "Unfolds the heading" : "Folds the heading")
131 } 138 }
132 139
133 private func textView(_ tree: OrgTree, _ range: Range<Int>) -> some View { 140 private func textView(_ tree: OrgTree, _ range: Range<Int>, _ theme: MobileTheme) -> some View {
134 Text(attributed(ReadingText.segments(tree, in: range, folded: folded), heading: nil).trimmingTrailingNewlines()) 141 Text(attributed(ReadingText.segments(tree, in: range, folded: folded), heading: nil, theme).trimmingTrailingNewlines())
142 .lineSpacing(CGFloat(max(0, theme.spec.lineSpacing)))
135 .textSelection(.enabled) 143 .textSelection(.enabled)
136 .frame(maxWidth: .infinity, alignment: .leading) 144 .frame(maxWidth: .infinity, alignment: .leading)
137 } 145 }
138 146
139 /// Segments as styled text; links get `orgstar-link:` URLs to follow. 147 /// Segments as styled text in the theme's font and colors, as the editor styles them;
140 private func attributed(_ segments: [ReadingText.Segment], heading level: Int?) -> AttributedString { 148 /// links get `orgstar-link:` URLs to follow.
149 private func attributed(_ segments: [ReadingText.Segment], heading level: Int?, _ theme: MobileTheme) -> AttributedString {
141 var result = AttributedString() 150 var result = AttributedString()
142 for segment in segments { 151 for segment in segments {
143 var part = AttributedString(segment.text) 152 var part = AttributedString(segment.text)
144 if let level { 153 var font = level.map(theme.headingFont) ?? theme.font
145 part.font = level == 1 ? .title3.weight(.semibold) : level == 2 ? .headline : .body.weight(.semibold) 154 var color = level.flatMap { theme.color("heading-\(min(max($0, 1), 8))") } ?? theme.foreground
146 } 155 func fg(_ key: String) { if let c = theme.color(key) { color = c } }
147 for role in segment.roles { 156 // Outer roles first, so inner ones win.
157 for role in segment.roles.sorted(by: { Self.depth($0) < Self.depth($1) }) {
148 switch role { 158 switch role {
149 case .todo: part.foregroundColor = .red; part.font = (part.font ?? .body).weight(.semibold) 159 case .todo, .done:
150 case .done: part.foregroundColor = .green 160 font = MobileTheme.adding(.traitBold, to: font)
151 case .priority: part.foregroundColor = .orange 161 if let c = theme.keywordColor(segment.text, done: role == .done) { color = c }
152 case .tags: part.foregroundColor = .secondary; part.font = .caption 162 case .priority: fg("priority")
153 case .bold: part.inlinePresentationIntent = .stronglyEmphasized 163 case .tags: fg("tags")
154 case .italic: part.inlinePresentationIntent = .emphasized 164 case .bold: font = MobileTheme.adding(.traitBold, to: font)
165 case .italic: font = MobileTheme.adding(.traitItalic, to: font)
155 case .underline: part.underlineStyle = .single 166 case .underline: part.underlineStyle = .single
156 case .strikeThrough: part.strikethroughStyle = .single 167 case .strikeThrough: part.strikethroughStyle = .single
157 case .verbatim, .code, .inlineSource: part.font = .body.monospaced() 168 case .verbatim, .code, .inlineSource:
158 case .markup, .leadingStars: part.foregroundColor = .secondary 169 if let background = theme.color("inline-background") { part.backgroundColor = Color(uiColor: background) }
159 case .timestamp: part.foregroundColor = .purple 170 fg(role == .verbatim ? "verbatim" : "code")
160 case .metadata, .keyword, .comment, .blockDelimiter: 171 case .markup, .linkMarkup, .leadingStars: fg("markup")
161 part.foregroundColor = .secondary 172 case .timestamp: fg("timestamp")
162 part.font = .footnote.monospaced() 173 case .comment: fg("comment")
163 case .block, .table, .syntax: part.font = .callout.monospaced() 174 case .keyword: fg("keyword")
164 default: break 175 case .metadata: fg("metadata")
176 case .blockDelimiter: fg("block-delimiter")
177 case .footnote, .cookie, .target, .macro, .latex: fg("special")
178 case .table, .rule: fg("table")
179 case .link:
180 part.underlineStyle = .single
181 fg("link")
182 case .heading, .block, .syntax: break
165 } 183 }
166 } 184 }
167 if segment.link != nil { 185 if segment.link != nil {
168 // Found again by position when followed. 186 // Found again by position when followed.
169 part.link = URL(string: "orgstar-link:\(segment.offset)") 187 part.link = URL(string: "orgstar-link:\(segment.offset)")
170 part.foregroundColor = .accentColor
171 } 188 }
189 part.font = Font(font as CTFont)
190 part.foregroundColor = Color(uiColor: color)
172 result += part 191 result += part
173 } 192 }
174 return result 193 return result
175 } 194 }
176 195
196 /// How deep a role's text usually nests: elements, then objects, then their parts.
197 private static func depth(_ role: StyleRole) -> Int {
198 switch role {
199 case .heading: 0
200 case .metadata, .keyword, .comment, .block, .table, .rule: 1
201 case .blockDelimiter, .link, .bold, .italic, .underline, .strikeThrough, .timestamp, .footnote, .target, .macro, .latex: 2
202 default: 3
203 }
204 }
205
177 // MARK: - Actions 206 // MARK: - Actions
178 207
179 private func open() { 208 private func open() {
@@ -190,7 +219,9 @@ struct ReaderScreen: View {
190 /// The file's startup visibility, kept while the same file is open. 219 /// The file's startup visibility, kept while the same file is open.
191 private func loadFolds() { 220 private func loadFolds() {
192 guard let tree = session.document?.tree else { return } 221 guard let tree = session.document?.tree else { return }
193 folded = Presentation.startupWrappers(tree).map(\.hidden) 222 folded = Presentation.startupWrappers(
223 tree, hideDrawers: session.options.hideDrawerStartup, hideBlocks: session.options.hideBlockStartup
224 ).map(\.hidden)
194 guard loadedPath != session.path else { return } 225 guard loadedPath != session.path else { return }
195 loadedPath = session.path 226 loadedPath = session.path
196 fold = Presentation.applyVisibilityProperties(Presentation.startupFoldState(tree) ?? FoldState(), in: tree) 227 fold = Presentation.applyVisibilityProperties(Presentation.startupFoldState(tree) ?? FoldState(), in: tree)
Sources/OrgstarMobile/SettingsScreen.swift +11
@@ -2,6 +2,7 @@
2import OrgApp 2import OrgApp
3import OrgCore 3import OrgCore
4import SwiftUI 4import SwiftUI
5import UIKit
5 6
6/// Where settings come from: a folder with the Mac's `config.toml` and `capture.toml`, kept in 7/// Where settings come from: a folder with the Mac's `config.toml` and `capture.toml`, kept in
7/// sync by iCloud Drive or another app. 8/// sync by iCloud Drive or another app.
@@ -29,6 +30,8 @@ struct SettingsScreen: View {
29 LabeledContent("Agenda", value: agenda) 30 LabeledContent("Agenda", value: agenda)
30 LabeledContent("Reminders", value: OrgPreferences.reminders() ? "\(OrgPreferences.reminderLead()) min before" : "Off") 31 LabeledContent("Reminders", value: OrgPreferences.reminders() ? "\(OrgPreferences.reminderLead()) min before" : "Off")
31 LabeledContent("Capture templates", value: CaptureTemplates.load().templates.map(\.key).joined(separator: " ")) 32 LabeledContent("Capture templates", value: CaptureTemplates.load().templates.map(\.key).joined(separator: " "))
33 LabeledContent("Theme", value: UserDefaults.standard.string(forKey: "themeFile").flatMap { $0.isEmpty ? nil : $0 } ?? "Default")
34 LabeledContent("Font", value: font)
32 } 35 }
33 } 36 }
34 .navigationTitle("Settings") 37 .navigationTitle("Settings")
@@ -45,6 +48,14 @@ struct SettingsScreen: View {
45 return "\(days(OrgPreferences.agendaSpan())), starting \(start)" 48 return "\(days(OrgPreferences.agendaSpan())), starting \(start)"
46 } 49 }
47 50
51 /// The theme's family, or the system's monospaced font when it's unset or not on the device.
52 private var font: String {
53 let spec = model.theme
54 let family = spec.font.isEmpty || UIFont.fontNames(forFamilyName: spec.font).isEmpty ? "System monospaced" : spec.font
55 let missing = !spec.font.isEmpty && family != spec.font ? " (\(spec.font) isn't installed)" : ""
56 return "\(family), \(spec.fontSize) pt\(missing)"
57 }
58
48 private var todoKeywords: String { 59 private var todoKeywords: String {
49 OrgPreferences.orgSettings().todoSequences.map { ($0.active.map(\.name) + ["|"] + $0.done.map(\.name)).joined(separator: " ") } 60 OrgPreferences.orgSettings().todoSequences.map { ($0.active.map(\.name) + ["|"] + $0.done.map(\.name)).joined(separator: " ") }
50 .joined(separator: "; ") 61 .joined(separator: "; ")