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])
}
/// ``, 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()
}
}
}