krz/orgstar

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

Sources/OrgstarMobile/ReaderScreen.swift

b98a6509c5ae75e5172dd333d1b6105fd6ceee0e
orgstar/Sources/OrgstarMobile/ReaderScreen.swift history · blame · raw

357 lines · 17062 bytes

21 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    /// The file's display: indentation, leading stars, inline images, truncation.
 33    @State private var startup: StartupDisplay?
 34    @State private var showingOutline = false
 35    @State private var message: String?
 36    @Environment(\.openURL) private var openURL
 37    @Environment(MobileModel.self) private var model
 38
 39    private var outline: [OutlineEntry] { session.outline }
 40
 41    var body: some View {
 42        let _ = session.editVersion
 43        // Read again when settings or the Dynamic Type size change.
 44        let _ = model.settingsVersion
 45        let theme = MobileTheme(model.theme)
 46        let truncated = startup?.truncated ?? false
 47        ScrollViewReader { proxy in
 48            ScrollView(truncated ? [.vertical, .horizontal] : .vertical) {
 49                LazyVStack(alignment: .leading, spacing: 8) {
 50                    if let tree = session.document?.tree {
 51                        let preambleEnd = outline.first?.start ?? (tree.text as NSString).length
 52                        if preambleEnd > 0 { textView(tree, 0..<preambleEnd, theme) }
 53                        ForEach(Array(outline.enumerated()), id: \.element.start) { index, entry in
 54                            if isShown(index) {
 55                                heading(tree, entry, theme).id(entry.start)
 56                                if !fold.folds.contains(entry.start), !fold.bodyFolds.contains(entry.start) {
 57                                    let body = bodyRange(index, tree)
 58                                    if !body.isEmpty { textView(tree, body, theme).padding(.leading, bodyIndent(entry, theme)) }
 59                                }
 60                            }
 61                        }
 62                    }
 63                }
 64                .padding()
 65                .frame(maxWidth: truncated ? nil : .infinity, alignment: .leading)
 66            }
 67            // Narrower than the view while truncated, the text still starts at the top left.
 68            .defaultScrollAnchor(truncated ? .topLeading : nil)
 69            .background(theme.swiftColor("background") ?? Color(uiColor: .systemBackground))
 70            .onChange(of: session.revealRequest?.id) {
 71                guard let offset = session.revealRequest?.offset else { return }
 72                reveal(offset, proxy)
 73            }
 74        }
 75        .navigationTitle(((session.path ?? "") as NSString).lastPathComponent)
 76        .navigationBarTitleDisplayMode(.inline)
 77        .toolbar {
 78            ToolbarItemGroup(placement: .primaryAction) {
 79                Button("Outline", systemImage: "list.bullet.indent") { showingOutline = true }.disabled(outline.isEmpty)
 80                if let path = session.path {
 81                    if let text = session.document?.text { ExportMenu(path: path, text: text) }
 82                    NavigationLink(value: EditorRoute(path: path, offset: nil)) { Label("Edit", systemImage: "square.and.pencil") }
 83                }
 84            }
 85        }
 86        .sheet(isPresented: $showingOutline) { outlineSheet }
 87        .safeAreaInset(edge: .bottom) {
 88            if let message {
 89                Text(message).font(.callout).padding(8).frame(maxWidth: .infinity).background(.bar)
 90                    .onTapGesture { self.message = nil }
 91            }
 92        }
 93        .environment(\.openURL, OpenURLAction { url in follow(url) })
 94        .onAppear(perform: open)
 95        .onChange(of: session.path) { loadFolds() }
 96        .onChange(of: session.editVersion) { loadFolds() }
 97        .onChange(of: model.settingsVersion) { loadFolds() }
 98    }
 99
100    // MARK: - Content
101
102    private func indent(_ entry: OutlineEntry) -> CGFloat { CGFloat(entry.level - 1) * 14 }
103
104    private var indented: Bool { startup?.indented ?? true }
105
106    /// The text as the editor's display settings show it.
107    private var display: ReadingText.Display {
108        ReadingText.Display(showMarkup: model.showMarkup, options: session.options, highlighter: MobileEditor.highlighter)
109    }
110
111    /// org-indent: a heading's text and its body start past its stars, at level + 1 columns,
112    /// the fold mark standing where the last star and its space would. Without it nothing is
113    /// indented and the stars show.
114    private func headingIndent(_ entry: OutlineEntry, _ theme: MobileTheme) -> CGFloat {
115        indented ? CGFloat(entry.level - 1) * theme.column : 0
116    }
117
118    private func bodyIndent(_ entry: OutlineEntry, _ theme: MobileTheme) -> CGFloat {
119        indented ? CGFloat(entry.level + 1) * theme.column : 0
120    }
121
122    /// Hidden under a folded ancestor.
123    private func isShown(_ index: Int) -> Bool {
124        var parent = outline[index].parent
125        while let p = parent {
126            if fold.folds.contains(outline[p].start) { return false }
127            parent = outline[p].parent
128        }
129        return true
130    }
131
132    /// From after the heading line to the first child, or the section's end.
133    private func bodyRange(_ index: Int, _ tree: OrgTree) -> Range<Int> {
134        let entry = outline[index]
135        let ns = tree.text as NSString
136        let lineEnd = NSMaxRange(ns.lineRange(for: NSRange(location: entry.start, length: 0)))
137        let firstChild = outline[(index + 1)...].first { $0.parent == index }?.start ?? entry.end
138        return min(lineEnd, firstChild)..<firstChild
139    }
140
141    private func heading(_ tree: OrgTree, _ entry: OutlineEntry, _ theme: MobileTheme) -> some View {
142        let ns = tree.text as NSString
143        let line = ns.lineRange(for: NSRange(location: entry.start, length: 0))
144        let start = indented ? min(entry.start + entry.level + 1, NSMaxRange(line)) : entry.start
145        let text = attributed(ReadingText.segments(tree, in: start..<NSMaxRange(line), display: display), heading: entry.level, theme)
146        let hasContent = entry.end > NSMaxRange(line)
147        let foldedMark = fold.folds.contains(entry.start) && hasContent
148        return HStack(alignment: .firstTextBaseline, spacing: 0) {
149            Image(systemName: foldedMark ? "chevron.right" : "chevron.down")
150                .font(.caption.weight(.semibold)).foregroundStyle(theme.swiftColor("markup") ?? Color(uiColor: .tertiaryLabel))
151                .opacity(hasContent ? 1 : 0)
152                .frame(width: 2 * theme.column, alignment: .leading)
153            lineText(text.trimmingTrailingNewlines(), theme)
154        }
155        .padding(.leading, headingIndent(entry, theme))
156        .padding(.top, entry.level == 1 ? 6 : 0)
157        .contentShape(Rectangle())
158        .onTapGesture { fold = Presentation.cycle(entry.start, in: tree, fold) }
159        .accessibilityAddTraits(.isButton)
160        .accessibilityHint(foldedMark ? "Unfolds the heading" : "Folds the heading")
161    }
162
163    private func textView(_ tree: OrgTree, _ range: Range<Int>, _ theme: MobileTheme) -> some View {
164        VStack(alignment: .leading, spacing: 4) {
165            ForEach(parts(tree, range), id: \.range.lowerBound) { part in
166                if let image = part.image {
167                    Image(uiImage: image.picture).resizable().scaledToFit()
168                        .frame(maxWidth: image.width.map { CGFloat($0) } ?? image.picture.size.width, alignment: .leading)
169                        .accessibilityLabel((image.path as NSString).lastPathComponent)
170                } else {
171                    lineText(attributed(ReadingText.segments(tree, in: part.range, folded: folded, display: display), heading: nil, theme).trimmingTrailingNewlines(), theme)
172                        .textSelection(.enabled)
173                }
174            }
175        }
176    }
177
178    /// Wrapped to the view's width, or on one line each, scrolling sideways, while truncated.
179    private func lineText(_ text: AttributedString, _ theme: MobileTheme) -> some View {
180        Text(text)
181            .lineSpacing(CGFloat(max(0, theme.spec.lineSpacing)))
182            .fixedSize(horizontal: startup?.truncated ?? false, vertical: false)
183            .frame(maxWidth: startup?.truncated == true ? nil : .infinity, alignment: .leading)
184    }
185
186    private struct Part {
187        let range: Range<Int>
188        var image: (picture: UIImage, path: String, width: Int?)?
189    }
190
191    /// Text, and with inline images on, lines that are only an image link shown as the image.
192    private func parts(_ tree: OrgTree, _ range: Range<Int>) -> [Part] {
193        guard startup?.inlineImages == true, let path = session.path else { return [Part(range: range)] }
194        let base = URL(fileURLWithPath: path).deletingLastPathComponent()
195        let ns = tree.text as NSString
196        var parts: [Part] = []
197        var textStart = range.lowerBound
198        var location = range.lowerBound
199        while location < range.upperBound {
200            let line = ns.lineRange(for: NSRange(location: location, length: 0))
201            location = NSMaxRange(line)
202            guard folded.allSatisfy({ !$0.contains(line.location) }), let image = InlineImages.image(atLine: line.location, in: ns),
203                  let picture = MobileEditor.loadImage(image.path, relativeTo: base) else { continue }
204            if textStart < line.location { parts.append(Part(range: textStart..<line.location)) }
205            parts.append(Part(range: line.location..<min(NSMaxRange(line), range.upperBound), image: (picture, image.path, image.width)))
206            textStart = min(NSMaxRange(line), range.upperBound)
207        }
208        if textStart < range.upperBound { parts.append(Part(range: textStart..<range.upperBound)) }
209        return parts
210    }
211
212    /// Segments as styled text in the theme's font and colors, as the editor styles them;
213    /// links get `orgstar-link:` URLs to follow.
214    private func attributed(_ segments: [ReadingText.Segment], heading level: Int?, _ theme: MobileTheme) -> AttributedString {
215        var result = AttributedString()
216        for segment in segments {
217            var part = AttributedString(segment.text)
218            var font = level.map(theme.headingFont) ?? theme.font
219            var color = level.flatMap { theme.color("heading-\(min(max($0, 1), 8))") } ?? theme.foreground
220            func fg(_ key: String) { if let c = theme.color(key) { color = c } }
221            // Outer roles first, so inner ones win.
222            for role in segment.roles.sorted(by: { Self.depth($0) < Self.depth($1) }) {
223                switch role {
224                case .todo, .done:
225                    font = MobileTheme.adding(.traitBold, to: font)
226                    if let c = theme.keywordColor(segment.text, done: role == .done) { color = c }
227                case .priority: fg("priority")
228                case .tags: fg("tags")
229                case .bold: font = MobileTheme.adding(.traitBold, to: font)
230                case .italic: font = MobileTheme.adding(.traitItalic, to: font)
231                case .underline: part.underlineStyle = .single
232                case .strikeThrough: part.strikethroughStyle = .single
233                case .verbatim, .code, .inlineSource:
234                    if let background = theme.color("inline-background") { part.backgroundColor = Color(uiColor: background) }
235                    fg(role == .verbatim ? "verbatim" : "code")
236                case .markup, .linkMarkup: fg("markup")
237                case .leadingStars:
238                    // As the editor hides them; shown in the heading's color unless hidden.
239                    if !Presentation.skipsLeadingStars(role, indented: indented, hideStars: startup?.hideStars ?? false) { color = .clear }
240                case .syntax(let category): if let key = category.themeKey { fg(key) }
241                case .timestamp: fg("timestamp")
242                case .comment: fg("comment")
243                case .keyword: fg("keyword")
244                case .metadata: fg("metadata")
245                case .blockDelimiter: fg("block-delimiter")
246                case .footnote, .cookie, .target, .macro, .latex: fg("special")
247                case .table, .rule: fg("table")
248                case .link:
249                    part.underlineStyle = .single
250                    fg("link")
251                case .heading, .block: break
252                }
253            }
254            if segment.link != nil {
255                // Found again by position when followed.
256                part.link = URL(string: "orgstar-link:\(segment.offset)")
257            }
258            if let superscript = segment.superscript {
259                // Raised or lowered as the editor draws `org-pretty-entities` scripts.
260                part.baselineOffset = (superscript ? 0.3 : -0.3) * font.pointSize
261                font = font.withSize(font.pointSize * 0.7)
262            }
263            part.font = Font(font as CTFont)
264            part.foregroundColor = Color(uiColor: color)
265            result += part
266        }
267        return result
268    }
269
270    /// How deep a role's text usually nests: elements, then objects, then their parts.
271    private static func depth(_ role: StyleRole) -> Int {
272        switch role {
273        case .heading: 0
274        case .metadata, .keyword, .comment, .block, .table, .rule: 1
275        case .blockDelimiter, .link, .bold, .italic, .underline, .strikeThrough, .timestamp, .footnote, .target, .macro, .latex: 2
276        default: 3
277        }
278    }
279
280    // MARK: - Actions
281
282    private func open() {
283        switch route {
284        case .file(let path): if session.path != path { session.open(path) }
285        case .offset(let path, let offset):
286            if session.path != path { session.open(path) }
287            session.reveal(offset)
288        case .heading(let location): session.show(location)
289        }
290        loadFolds()
291    }
292
293    /// The file's startup display, as the editor opens it; its heading folds are kept while
294    /// the same file is open.
295    private func loadFolds() {
296        guard let tree = session.document?.tree else { return }
297        let display = Presentation.startupDisplay(tree, options: session.options)
298        startup = display
299        folded = display.wrappers.map(\.hidden)
300        guard loadedPath != session.path else { return }
301        loadedPath = session.path
302        fold = display.folds
303    }
304
305    /// Unfolds the way to the heading containing `offset` and scrolls to it.
306    private func reveal(_ offset: Int, _ proxy: ScrollViewProxy) {
307        guard let index = outline.lastIndex(where: { $0.start <= offset }) else { return }
308        var parent = outline[index].parent
309        while let p = parent {
310            fold.folds.remove(outline[p].start)
311            parent = outline[p].parent
312        }
313        let start = outline[index].start
314        DispatchQueue.main.async { withAnimation { proxy.scrollTo(start, anchor: .top) } }
315    }
316
317    private func follow(_ url: URL) -> OpenURLAction.Result {
318        guard url.scheme == "orgstar-link", let offset = Int(url.absoluteString.dropFirst("orgstar-link:".count)),
319              let tree = session.document?.tree, let link = Links.link(at: offset, text: tree.text, tree: tree)?.link else {
320            return .systemAction
321        }
322        switch session.follow(link, workspace: workspace, isText: { _ in false }) {
323        case .shown: message = nil
324        case .openExternally(let target): openURL(target)
325        case .message(let text): message = text
326        }
327        return .handled
328    }
329
330    private var outlineSheet: some View {
331        NavigationStack {
332            List(outline, id: \.start) { entry in
333                Button {
334                    showingOutline = false
335                    session.reveal(entry.start)
336                } label: {
337                    Text(entry.title.isEmpty ? "(untitled)" : entry.title)
338                        .padding(.leading, indent(entry))
339                        .foregroundStyle(.primary)
340                }
341            }
342            .navigationTitle("Outline")
343            .navigationBarTitleDisplayMode(.inline)
344            .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Done") { showingOutline = false } } }
345        }
346    }
347}
348
349private extension AttributedString {
350    /// Without the final newline, which `Text` would show as an empty line.
351    func trimmingTrailingNewlines() -> AttributedString {
352        var copy = self
353        while let last = copy.characters.last, last.isNewline { copy.characters.removeLast() }
354        return copy
355    }
356}
357#endif