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
44import OrgIndex
55import OrgPresentation
66import SwiftUI
7import UIKit
78
89/// What to read: a file, and where in it.
910enum ReaderRoute: Hashable {
@@ -31,23 +32,27 @@ struct ReaderScreen: View {
3132 @State private var showingOutline = false
3233 @State private var message: String?
3334 @Environment(\.openURL) private var openURL
35 @Environment(MobileModel.self) private var model
3436
3537 private var outline: [OutlineEntry] { session.outline }
3638
3739 var body: some View {
3840 let _ = session.editVersion
41 // Read again when settings or the Dynamic Type size change.
42 let _ = model.settingsVersion
43 let theme = MobileTheme(model.theme)
3944 ScrollViewReader { proxy in
4045 ScrollView {
4146 LazyVStack(alignment: .leading, spacing: 8) {
4247 if let tree = session.document?.tree {
4348 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) }
4550 ForEach(Array(outline.enumerated()), id: \.element.start) { index, entry in
4651 if isShown(index) {
47 heading(tree, entry).id(entry.start)
52 heading(tree, entry, theme).id(entry.start)
4853 if !fold.folds.contains(entry.start), !fold.bodyFolds.contains(entry.start) {
4954 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)) }
5156 }
5257 }
5358 }
@@ -56,6 +61,7 @@ struct ReaderScreen: View {
5661 .padding()
5762 .frame(maxWidth: .infinity, alignment: .leading)
5863 }
64 .background(theme.swiftColor("background") ?? Color(uiColor: .systemBackground))
5965 .onChange(of: session.revealRequest?.id) {
6066 guard let offset = session.revealRequest?.offset else { return }
6167 reveal(offset, proxy)
@@ -108,19 +114,20 @@ struct ReaderScreen: View {
108114 return min(lineEnd, firstChild)..<firstChild
109115 }
110116
111 private func heading(_ tree: OrgTree, _ entry: OutlineEntry) -> some View {
117 private func heading(_ tree: OrgTree, _ entry: OutlineEntry, _ theme: MobileTheme) -> some View {
112118 let ns = tree.text as NSString
113119 var line = ns.lineRange(for: NSRange(location: entry.start, length: 0))
114120 let title = min(entry.start + entry.level + 1, NSMaxRange(line))
115121 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)
117123 let hasContent = entry.end > NSMaxRange(ns.lineRange(for: NSRange(location: entry.start, length: 0)))
118124 let foldedMark = fold.folds.contains(entry.start) && hasContent
119125 return HStack(alignment: .firstTextBaseline, spacing: 6) {
120126 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))
122128 .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)
124131 }
125132 .padding(.leading, indent(entry))
126133 .padding(.top, entry.level == 1 ? 6 : 0)
@@ -130,50 +137,72 @@ struct ReaderScreen: View {
130137 .accessibilityHint(foldedMark ? "Unfolds the heading" : "Folds the heading")
131138 }
132139
133 private func textView(_ tree: OrgTree, _ range: Range<Int>) -> some View {
134 Text(attributed(ReadingText.segments(tree, in: range, folded: folded), heading: nil).trimmingTrailingNewlines())
140 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())
142 .lineSpacing(CGFloat(max(0, theme.spec.lineSpacing)))
135143 .textSelection(.enabled)
136144 .frame(maxWidth: .infinity, alignment: .leading)
137145 }
138146
139 /// Segments as styled text; links get `orgstar-link:` URLs to follow.
140 private func attributed(_ segments: [ReadingText.Segment], heading level: Int?) -> AttributedString {
147 /// Segments as styled text in the theme's font and colors, as the editor styles them;
148 /// links get `orgstar-link:` URLs to follow.
149 private func attributed(_ segments: [ReadingText.Segment], heading level: Int?, _ theme: MobileTheme) -> AttributedString {
141150 var result = AttributedString()
142151 for segment in segments {
143152 var part = AttributedString(segment.text)
144 if let level {
145 part.font = level == 1 ? .title3.weight(.semibold) : level == 2 ? .headline : .body.weight(.semibold)
146 }
147 for role in segment.roles {
153 var font = level.map(theme.headingFont) ?? theme.font
154 var color = level.flatMap { theme.color("heading-\(min(max($0, 1), 8))") } ?? theme.foreground
155 func fg(_ key: String) { if let c = theme.color(key) { color = c } }
156 // Outer roles first, so inner ones win.
157 for role in segment.roles.sorted(by: { Self.depth($0) < Self.depth($1) }) {
148158 switch role {
149 case .todo: part.foregroundColor = .red; part.font = (part.font ?? .body).weight(.semibold)
150 case .done: part.foregroundColor = .green
151 case .priority: part.foregroundColor = .orange
152 case .tags: part.foregroundColor = .secondary; part.font = .caption
153 case .bold: part.inlinePresentationIntent = .stronglyEmphasized
154 case .italic: part.inlinePresentationIntent = .emphasized
159 case .todo, .done:
160 font = MobileTheme.adding(.traitBold, to: font)
161 if let c = theme.keywordColor(segment.text, done: role == .done) { color = c }
162 case .priority: fg("priority")
163 case .tags: fg("tags")
164 case .bold: font = MobileTheme.adding(.traitBold, to: font)
165 case .italic: font = MobileTheme.adding(.traitItalic, to: font)
155166 case .underline: part.underlineStyle = .single
156167 case .strikeThrough: part.strikethroughStyle = .single
157 case .verbatim, .code, .inlineSource: part.font = .body.monospaced()
158 case .markup, .leadingStars: part.foregroundColor = .secondary
159 case .timestamp: part.foregroundColor = .purple
160 case .metadata, .keyword, .comment, .blockDelimiter:
161 part.foregroundColor = .secondary
162 part.font = .footnote.monospaced()
163 case .block, .table, .syntax: part.font = .callout.monospaced()
164 default: break
168 case .verbatim, .code, .inlineSource:
169 if let background = theme.color("inline-background") { part.backgroundColor = Color(uiColor: background) }
170 fg(role == .verbatim ? "verbatim" : "code")
171 case .markup, .linkMarkup, .leadingStars: fg("markup")
172 case .timestamp: fg("timestamp")
173 case .comment: fg("comment")
174 case .keyword: fg("keyword")
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
165183 }
166184 }
167185 if segment.link != nil {
168186 // Found again by position when followed.
169187 part.link = URL(string: "orgstar-link:\(segment.offset)")
170 part.foregroundColor = .accentColor
171188 }
189 part.font = Font(font as CTFont)
190 part.foregroundColor = Color(uiColor: color)
172191 result += part
173192 }
174193 return result
175194 }
176195
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
177206 // MARK: - Actions
178207
179208 private func open() {
@@ -190,7 +219,9 @@ struct ReaderScreen: View {
190219 /// The file's startup visibility, kept while the same file is open.
191220 private func loadFolds() {
192221 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)
194225 guard loadedPath != session.path else { return }
195226 loadedPath = session.path
196227 fold = Presentation.applyVisibilityProperties(Presentation.startupFoldState(tree) ?? FoldState(), in: tree)
Sources/OrgstarMobile/SettingsScreen.swift +11
@@ -2,6 +2,7 @@
22import OrgApp
33import OrgCore
44import SwiftUI
5import UIKit
56
67/// Where settings come from: a folder with the Mac's `config.toml` and `capture.toml`, kept in
78/// sync by iCloud Drive or another app.
@@ -29,6 +30,8 @@ struct SettingsScreen: View {
2930 LabeledContent("Agenda", value: agenda)
3031 LabeledContent("Reminders", value: OrgPreferences.reminders() ? "\(OrgPreferences.reminderLead()) min before" : "Off")
3132 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)
3235 }
3336 }
3437 .navigationTitle("Settings")
@@ -45,6 +48,14 @@ struct SettingsScreen: View {
4548 return "\(days(OrgPreferences.agendaSpan())), starting \(start)"
4649 }
4750
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
4859 private var todoKeywords: String {
4960 OrgPreferences.orgSettings().todoSequences.map { ($0.active.map(\.name) + ["|"] + $0.done.map(\.name)).joined(separator: " ") }
5061 .joined(separator: "; ")