krz/orgstar

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

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