Sources/OrgstarMobile/ReaderScreen.swift
282 lines · 12891 bytes
16 symbols in this file
1#if os(iOS)
2import OrgApp
3import OrgCore
4import OrgIndex
5import OrgPresentation
6import SwiftUI
7import UIKit
8
9/// What to read: a file, and where in it.
10enum ReaderRoute: Hashable {
11 case file(String)
12 case offset(String, Int)
13 /// A search result, found again if the file changed since it was indexed.
14 case heading(HeadingLocation)
15}
16
17/// Editing a file, from the reader.
18struct EditorRoute: Hashable {
19 let path: String
20 let offset: Int?
21}
22
23/// A file to read: headings that fold and unfold with a tap, as TAB cycles them, text styled as
24/// in the editor, and links to follow.
25struct ReaderScreen: View {
26 let route: ReaderRoute
27 let workspace: WorkspaceModel
28 let session: DocumentSession
29 @State private var fold = FoldState()
30 @State private var folded: [Range<Int>] = []
31 @State private var loadedPath: String?
32 @State private var showingOutline = false
33 @State private var message: String?
34 @Environment(\.openURL) private var openURL
35 @Environment(MobileModel.self) private var model
36
37 private var outline: [OutlineEntry] { session.outline }
38
39 var body: some View {
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)
44 ScrollViewReader { proxy in
45 ScrollView {
46 LazyVStack(alignment: .leading, spacing: 8) {
47 if let tree = session.document?.tree {
48 let preambleEnd = outline.first?.start ?? (tree.text as NSString).length
49 if preambleEnd > 0 { textView(tree, 0..<preambleEnd, theme) }
50 ForEach(Array(outline.enumerated()), id: \.element.start) { index, entry in
51 if isShown(index) {
52 heading(tree, entry, theme).id(entry.start)
53 if !fold.folds.contains(entry.start), !fold.bodyFolds.contains(entry.start) {
54 let body = bodyRange(index, tree)
55 if !body.isEmpty { textView(tree, body, theme).padding(.leading, indent(entry)) }
56 }
57 }
58 }
59 }
60 }
61 .padding()
62 .frame(maxWidth: .infinity, alignment: .leading)
63 }
64 .background(theme.swiftColor("background") ?? Color(uiColor: .systemBackground))
65 .onChange(of: session.revealRequest?.id) {
66 guard let offset = session.revealRequest?.offset else { return }
67 reveal(offset, proxy)
68 }
69 }
70 .navigationTitle(((session.path ?? "") as NSString).lastPathComponent)
71 .navigationBarTitleDisplayMode(.inline)
72 .toolbar {
73 ToolbarItemGroup(placement: .primaryAction) {
74 Button("Outline", systemImage: "list.bullet.indent") { showingOutline = true }.disabled(outline.isEmpty)
75 if let path = session.path {
76 if let text = session.document?.text { ExportMenu(path: path, text: text) }
77 NavigationLink(value: EditorRoute(path: path, offset: nil)) { Label("Edit", systemImage: "square.and.pencil") }
78 }
79 }
80 }
81 .sheet(isPresented: $showingOutline) { outlineSheet }
82 .safeAreaInset(edge: .bottom) {
83 if let message {
84 Text(message).font(.callout).padding(8).frame(maxWidth: .infinity).background(.bar)
85 .onTapGesture { self.message = nil }
86 }
87 }
88 .environment(\.openURL, OpenURLAction { url in follow(url) })
89 .onAppear(perform: open)
90 .onChange(of: session.path) { loadFolds() }
91 .onChange(of: session.editVersion) { loadFolds() }
92 }
93
94 // MARK: - Content
95
96 private func indent(_ entry: OutlineEntry) -> CGFloat { CGFloat(entry.level - 1) * 14 }
97
98 /// Hidden under a folded ancestor.
99 private func isShown(_ index: Int) -> Bool {
100 var parent = outline[index].parent
101 while let p = parent {
102 if fold.folds.contains(outline[p].start) { return false }
103 parent = outline[p].parent
104 }
105 return true
106 }
107
108 /// From after the heading line to the first child, or the section's end.
109 private func bodyRange(_ index: Int, _ tree: OrgTree) -> Range<Int> {
110 let entry = outline[index]
111 let ns = tree.text as NSString
112 let lineEnd = NSMaxRange(ns.lineRange(for: NSRange(location: entry.start, length: 0)))
113 let firstChild = outline[(index + 1)...].first { $0.parent == index }?.start ?? entry.end
114 return min(lineEnd, firstChild)..<firstChild
115 }
116
117 private func heading(_ tree: OrgTree, _ entry: OutlineEntry, _ theme: MobileTheme) -> some View {
118 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)))
124 let foldedMark = fold.folds.contains(entry.start) && hasContent
125 return HStack(alignment: .firstTextBaseline, spacing: 6) {
126 Image(systemName: foldedMark ? "chevron.right" : "chevron.down")
127 .font(.caption.weight(.semibold)).foregroundStyle(theme.swiftColor("markup") ?? Color(uiColor: .tertiaryLabel))
128 .opacity(hasContent ? 1 : 0)
129 Text(text.trimmingTrailingNewlines()).lineSpacing(CGFloat(max(0, theme.spec.lineSpacing)))
130 .frame(maxWidth: .infinity, alignment: .leading)
131 }
132 .padding(.leading, indent(entry))
133 .padding(.top, entry.level == 1 ? 6 : 0)
134 .contentShape(Rectangle())
135 .onTapGesture { fold = Presentation.cycle(entry.start, in: tree, fold) }
136 .accessibilityAddTraits(.isButton)
137 .accessibilityHint(foldedMark ? "Unfolds the heading" : "Folds the heading")
138 }
139
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)))
143 .textSelection(.enabled)
144 .frame(maxWidth: .infinity, alignment: .leading)
145 }
146
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 {
150 var result = AttributedString()
151 for segment in segments {
152 var part = AttributedString(segment.text)
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) }) {
158 switch role {
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)
166 case .underline: part.underlineStyle = .single
167 case .strikeThrough: part.strikethroughStyle = .single
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
183 }
184 }
185 if segment.link != nil {
186 // Found again by position when followed.
187 part.link = URL(string: "orgstar-link:\(segment.offset)")
188 }
189 part.font = Font(font as CTFont)
190 part.foregroundColor = Color(uiColor: color)
191 result += part
192 }
193 return result
194 }
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
206 // MARK: - Actions
207
208 private func open() {
209 switch route {
210 case .file(let path): if session.path != path { session.open(path) }
211 case .offset(let path, let offset):
212 if session.path != path { session.open(path) }
213 session.reveal(offset)
214 case .heading(let location): session.show(location)
215 }
216 loadFolds()
217 }
218
219 /// The file's startup visibility, kept while the same file is open.
220 private func loadFolds() {
221 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)
225 guard loadedPath != session.path else { return }
226 loadedPath = session.path
227 fold = Presentation.applyVisibilityProperties(Presentation.startupFoldState(tree) ?? FoldState(), in: tree)
228 }
229
230 /// Unfolds the way to the heading containing `offset` and scrolls to it.
231 private func reveal(_ offset: Int, _ proxy: ScrollViewProxy) {
232 guard let index = outline.lastIndex(where: { $0.start <= offset }) else { return }
233 var parent = outline[index].parent
234 while let p = parent {
235 fold.folds.remove(outline[p].start)
236 parent = outline[p].parent
237 }
238 let start = outline[index].start
239 DispatchQueue.main.async { withAnimation { proxy.scrollTo(start, anchor: .top) } }
240 }
241
242 private func follow(_ url: URL) -> OpenURLAction.Result {
243 guard url.scheme == "orgstar-link", let offset = Int(url.absoluteString.dropFirst("orgstar-link:".count)),
244 let tree = session.document?.tree, let link = Links.link(at: offset, text: tree.text, tree: tree)?.link else {
245 return .systemAction
246 }
247 switch session.follow(link, workspace: workspace, isText: { _ in false }) {
248 case .shown: message = nil
249 case .openExternally(let target): openURL(target)
250 case .message(let text): message = text
251 }
252 return .handled
253 }
254
255 private var outlineSheet: some View {
256 NavigationStack {
257 List(outline, id: \.start) { entry in
258 Button {
259 showingOutline = false
260 session.reveal(entry.start)
261 } label: {
262 Text(entry.title.isEmpty ? "(untitled)" : entry.title)
263 .padding(.leading, indent(entry))
264 .foregroundStyle(.primary)
265 }
266 }
267 .navigationTitle("Outline")
268 .navigationBarTitleDisplayMode(.inline)
269 .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Done") { showingOutline = false } } }
270 }
271 }
272}
273
274private extension AttributedString {
275 /// Without the final newline, which `Text` would show as an empty line.
276 func trimmingTrailingNewlines() -> AttributedString {
277 var copy = self
278 while let last = copy.characters.last, last.isNewline { copy.characters.removeLast() }
279 return copy
280 }
281}
282#endif