import SwiftUI /// Draws Markdown as native text: the blocks here, and inline marks by /// Foundation's own parser. What it doesn't know reads as plain text. struct MarkdownView: View { let source: String /// It made into HTML by the server, which alone knows how to colour its code. var html: String? /// Where it's written, to find the pictures it names. var place: MarkdownPlace? /// Its author's, to tick a task, which is by its number in `source`. Without /// it the boxes only say how each stands. var tick: ((Int, Bool) -> Void)? /// The pulls and issues it names, read from `html`. private let references: MarkdownReferences @Environment(\.openRoute) private var openRoute init(source: String, html: String? = nil, place: MarkdownPlace? = nil, tick: ((Int, Bool) -> Void)? = nil) { self.source = source self.html = html self.place = place self.tick = tick references = html.map { MarkdownReferences(html: $0, source: source) } ?? MarkdownReferences() } enum Block: Hashable { case heading(Int, String) case paragraph(String) case bullet(String, marker: String, depth: Int) case quote(String) /// A fenced block, and the language its fence names. case code(String, language: String?) case rule case table(MarkdownTable) case task(String, number: Int, done: Bool, depth: Int) /// A part folded away, as `
` is: what its summary says, the /// blocks inside, and whether it starts open. case fold(String, blocks: [Block], open: Bool) } var body: some View { let colours = html.map(MarkdownFences.ranges) ?? [:] VStack(alignment: .leading, spacing: Space.s2_5) { ForEach(Array(Self.parse(source).enumerated()), id: \.offset) { _, block in view(block, colours) } } .frame(maxWidth: .infinity, alignment: .leading) .environment(\.openURL, OpenURLAction(handler: open)) } /// A pull or issue of its repository opens here, as the app's own screen. private func open(_ url: URL) -> OpenURLAction.Result { guard let repo = place?.repo, let uri = MarkdownReferences.thread(url, in: repo) else { return url.scheme == MarkdownReferences.scheme ? .discarded : .systemAction } guard let openRoute else { return .systemAction } openRoute(.thread(repo, uri: uri)) return .handled } @ViewBuilder private func view(_ block: Block, _ colours: [String: String]) -> some View { switch block { case .heading(let level, let text): inline(text).font(level == 1 ? .title2.bold() : level == 2 ? .title3.bold() : .headline).padding(.top, Space.s1) case .paragraph(let text): rich(text) case .bullet(let text, let marker, let depth): HStack(alignment: .firstTextBaseline, spacing: Space.s2) { Text(marker).foregroundStyle(.secondary) rich(text) } .padding(.leading, CGFloat(depth) * 16) case .quote(let text): inline(text).foregroundStyle(.secondary).padding(.leading, Space.s3) case .code(let text, "mermaid"): DiagramView(source: text) { code(text, colours) } case .code(let text, _): code(text, colours) case .rule: Divider() case .table(let table): self.table(table) case .task(let text, let number, let done, let depth): HStack(alignment: .firstTextBaseline, spacing: Space.s2) { box(number: number, done: done, about: text) rich(text) .strikethrough(done) .foregroundStyle(done ? Color.mute : Color.fg) } .padding(.leading, CGFloat(depth) * 16) case .fold(let summary, let blocks, let open): FoldedMarkdown(summary: inline(summary), open: open) { ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in // Erased, as a fold can hold folds. AnyView(view(block, colours)) } } } } private func code(_ text: String, _ colours: [String: String]) -> some View { ScrollView(.horizontal, showsIndicators: false) { Text(Self.painted(text, colours[text])).font(.footnote.monospaced()).textSelection(.enabled).padding(Space.s2_5) } .background(Color.fill, in: RoundedRectangle(cornerRadius: Radius.medium)) } private func box(number: Int, done: Bool, about text: String) -> some View { Button { tick?(number, !done) } label: { Image(systemName: done ? "checkmark.square.fill" : "square") .foregroundStyle(tick == nil ? Color.mute : done ? Color.accent : Color.mute) // A finger's room round it, which moves nothing around it. .padding(Space.s2) .contentShape(Rectangle()) .padding(-Space.s2) } .buttonStyle(.plain) .disabled(tick == nil) .accessibilityLabel(Self.plain(text)) .accessibilityValue(done ? "Done" : "Not done") .accessibilityAddTraits(.isToggle) .accessibilityIdentifier("task-\(number)") } /// As wide as its cells, scrolling sideways if that's wider than the page, as /// a block of code does. A long cell wraps, so that one doesn't make it all wide. private func table(_ table: MarkdownTable) -> some View { ScrollView(.horizontal, showsIndicators: false) { Grid(alignment: .leading, horizontalSpacing: 0, verticalSpacing: 0) { GridRow { ForEach(Array(table.header.enumerated()), id: \.offset) { at, cell in // On one line, so that each is as tall and the fill runs along the row. self.cell(cell, table.alignments[at], wraps: false).fontWeight(.semibold).background(Color.fill) } } ForEach(Array(table.rows.enumerated()), id: \.offset) { _, row in Divider().gridCellUnsizedAxes(.horizontal) GridRow { ForEach(Array(row.enumerated()), id: \.offset) { at, cell in self.cell(cell, table.alignments[at]) } } } } .overlay(RoundedRectangle(cornerRadius: Radius.medium).stroke(Color.line)) .clipShape(RoundedRectangle(cornerRadius: Radius.medium)) // Room for its border, which the scroll view would cut. .padding(Space.s0_5) } } private func cell(_ text: String, _ alignment: MarkdownTable.Alignment, wraps: Bool = true) -> some View { let (frame, line): (SwiftUI.Alignment, TextAlignment) = switch alignment { case .leading: (.leading, .leading) case .center: (.center, .center) case .trailing: (.trailing, .trailing) } // A width of its own for one that wraps, as a grid measures a cell's height // before any limit on its width, which would leave its row a line tall. let wide = wraps && Self.plain(text).count > Size.tableCellLetters return rich(text) .font(.subheadline) .multilineTextAlignment(line) .frame(width: wide ? Size.tableCell : nil, alignment: frame) .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, Space.s2_5) .padding(.vertical, Space.s2) .frame(maxWidth: .infinity, alignment: frame) } private static func painted(_ text: String, _ ranges: String?) -> AttributedString { var whole = AttributedString() for (at, line) in Highlight.paint(lines: text.components(separatedBy: "\n"), ranges: ranges).enumerated() { if at > 0 { whole.append(AttributedString("\n")) } whole.append(line) } return whole } /// Text, with the pictures it names drawn in their turn. Those with nothing /// between them share a row. @ViewBuilder private func rich(_ text: String) -> some View { let pieces = Self.pieces(text) if pieces.count == 1, case .text(let only) = pieces[0] { inline(only) } else { VStack(alignment: .leading, spacing: Space.s2) { ForEach(Array(pieces.enumerated()), id: \.offset) { _, piece in switch piece { case .text(let words): inline(words) case .pictures(let pictures): FlowRow { ForEach(Array(pictures.enumerated()), id: \.offset) { _, picture in self.picture(picture) } } } } } } } @ViewBuilder private func picture(_ picture: Picture) -> some View { let shown = MarkdownImage(urls: place?.pictures(picture.source) ?? URL(string: picture.source).map { [$0] } ?? [], alt: picture.alt) if let link = picture.link, let url = URL(string: link), url.scheme != nil { Link(destination: url) { shown } } else { shown } } struct Picture: Hashable { let alt: String let source: String var link: String? } enum Piece: Hashable { case text(String) case pictures([Picture]) } /// `![alt](source)`, or that as a link, split out of the words around it. /// One inside inline code is left as words. static func pieces(_ text: String) -> [Piece] { let image = #/\[!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)\]\(([^)\s]+)(?:\s+"[^"]*")?\)|!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/# var pieces: [Piece] = [] var done = text.startIndex for match in text.matches(of: image) { let before = text[done.. Text { let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) if var marked = try? AttributedString(markdown: references.linked(text), options: options) { for run in marked.runs where run.inlinePresentationIntent?.contains(.code) == true { marked[run.range].font = .callout.monospaced() marked[run.range].backgroundColor = Color.raised } return Text(marked) } return Text(text) } /// One line of Markdown with its marks gone, for a preview. static func plain(_ line: String) -> String { let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) return (try? AttributedString(markdown: line, options: options)).map { String($0.characters) } ?? line } static func parse(_ source: String) -> [Block] { let lines = source.components(separatedBy: "\n") let tasks = Dictionary(MarkdownTasks.all(source).map { ($0.line, $0) }) { first, _ in first } return parse(lines, lines.indices, tasks) } /// The blocks of `lines` in `range`, `tasks` by the line each is on. private static func parse(_ lines: [String], _ range: Range, _ tasks: [Int: MarkdownTasks.Task]) -> [Block] { var blocks: [Block] = [] var paragraph: [String] = [] var code: [String]? var language: String? var table: MarkdownTable? func flush() { if !paragraph.isEmpty { blocks.append(.paragraph(paragraph.joined(separator: " "))) } paragraph = [] if let table { blocks.append(.table(table)) } table = nil } var at = range.lowerBound while at < range.upperBound { let raw = lines[at] defer { at += 1 } let trimmed = raw.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("```") { if let lines = code { blocks.append(.code(lines.joined(separator: "\n"), language: language)) code = nil } else { flush() code = [] let info = trimmed.dropFirst(3).split(separator: " ").first.map { $0.lowercased() } language = info?.isEmpty == false ? info : nil } continue } if code != nil { code?.append(raw) continue } if table != nil { if trimmed.contains("|") { table?.add(trimmed) continue } flush() } if let fold = MarkdownFolds.fold(lines, at: at, end: range.upperBound) { flush() blocks.append(.fold(fold.summary, blocks: parse(lines, fold.inside, tasks), open: fold.open)) // Past its `
`, as the loop steps on by one. at = fold.next - 1 } else if let task = tasks[at] { flush() let depth = raw.prefix { $0 == " " }.count / 2 blocks.append(.task(MarkdownTasks.words(raw, task), number: task.number, done: task.done, depth: depth)) } else if let header = paragraph.last, let started = MarkdownTable(header: header, dashes: trimmed) { // A line above it is a paragraph of its own. paragraph.removeLast() flush() table = started } else if trimmed.isEmpty { flush() } else if let hashes = trimmed.firstRange(of: #/^#{1,6} /#) { flush() blocks.append(.heading(trimmed.distance(from: hashes.lowerBound, to: hashes.upperBound) - 1, String(trimmed[hashes.upperBound...]))) } else if ["---", "***", "___"].contains(trimmed) { flush() blocks.append(.rule) } else if trimmed.hasPrefix("> ") { flush() blocks.append(.quote(String(trimmed.dropFirst(2)))) } else if let match = trimmed.firstMatch(of: #/^([-*+]|\d+\.) /#) { flush() let isBullet = !(match.1.last == ".") let depth = raw.prefix { $0 == " " }.count / 2 blocks.append(.bullet(String(trimmed[match.range.upperBound...]), marker: isBullet ? "•" : String(match.1), depth: depth)) } else if case .bullet(let text, let marker, let depth)? = blocks.last, paragraph.isEmpty, raw.hasPrefix(" ") { blocks[blocks.count - 1] = .bullet(text + " " + trimmed, marker: marker, depth: depth) } else if case .task(let text, let number, let done, let depth)? = blocks.last, paragraph.isEmpty, raw.hasPrefix(" ") { blocks[blocks.count - 1] = .task(text + " " + trimmed, number: number, done: done, depth: depth) } else { paragraph.append(trimmed) } } if let code { blocks.append(.code(code.joined(separator: "\n"), language: language)) } flush() return blocks } } /// A part of Markdown folded away, as `
` is: what its summary says, /// its inline marks drawn, beside an arrow, and what's inside once that's /// tapped. private struct FoldedMarkdown: View { let summary: Text let content: Content @State private var open: Bool init(summary: Text, open: Bool, @ViewBuilder content: () -> Content) { self.summary = summary self.content = content() _open = State(initialValue: open) } var body: some View { VStack(alignment: .leading, spacing: Space.s2_5) { Button { open.toggle() } label: { HStack(spacing: Space.s2) { Oct(name: "chevron-down", size: Icon.sm) .rotationEffect(open ? .zero : .degrees(-90)) .foregroundStyle(.secondary) summary.foregroundStyle(Color.fg).multilineTextAlignment(.leading) } .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityValue(open ? "Open" : "Closed") if open { content } } } } /// Markdown its author can tick: each tick shows at once and is saved behind it, /// one write at a time as each says the whole body, and a late one mustn't undo /// a newer one. Once all are saved the thread is read anew. One that fails puts /// the boxes back. `limit` draws only the start of it, as a folded one does, /// while a tick still writes the whole. struct TickableMarkdown: View { let source: String var html: String? var place: MarkdownPlace? let repo: RepoRef let subject: String let author: String var limit: Int? let changed: () -> Void @Environment(Account.self) private var account @State private var shown: String? @State private var saved: String? @State private var saving = false @State private var failure: String? var body: some View { MarkdownView(source: drawn, html: html, place: place, tick: ticking) .onChange(of: source) { guard !saving else { return } shown = nil saved = nil } .alert("That didn't work", isPresented: Binding(get: { failure != nil }, set: { if !$0 { failure = nil } })) { Button("OK", role: .cancel) {} } message: { Text(failure ?? "") } } private var drawn: String { let whole = shown ?? source guard let limit else { return whole } return String(whole.prefix(limit)) } private var ticking: ((Int, Bool) -> Void)? { guard account.person?.did == author else { return nil } return { tick($0, $1) } } private func tick(_ number: Int, _ done: Bool) { guard let next = MarkdownTasks.ticked(shown ?? source, number, done: done) else { return } shown = next guard !saving else { return } saving = true Task { defer { saving = false } while let body = shown, body != (saved ?? source) { do { try await Client.shared.send(Writes.edit(repo: repo, subject: subject, body: body)) saved = body } catch { failure = error.localizedDescription shown = saved return } } changed() } } }