Commit 6ee5ec510b
Verified · cmc
Layout: unified · split
Sources/OrgstarMobile/ReaderScreen.swift +62 −31
| @@ -4,6 +4,7 @@ import OrgCore | |||
| 4 | import OrgIndex | 4 | import OrgIndex |
| 5 | import OrgPresentation | 5 | import OrgPresentation |
| 6 | import SwiftUI | 6 | import SwiftUI |
| 7 | import UIKit | ||
| 7 | 8 | ||
| 8 | /// What to read: a file, and where in it. | 9 | /// What to read: a file, and where in it. |
| 9 | enum ReaderRoute: Hashable { | 10 | enum 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 @@ | |||
| 2 | import OrgApp | 2 | import OrgApp |
| 3 | import OrgCore | 3 | import OrgCore |
| 4 | import SwiftUI | 4 | import SwiftUI |
| 5 | import 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: "; ") |