~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
463 lines · 19.5 KB Raw History
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 `<details>` 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..<match.range.lowerBound]
            if text[..<match.range.lowerBound].filter({ $0 == "`" }).count % 2 == 1 { continue }
            let picture = match.output.2 != nil
                ? Picture(alt: String(match.output.1 ?? ""), source: String(match.output.2 ?? ""), link: match.output.3.map(String.init))
                : Picture(alt: String(match.output.4 ?? ""), source: String(match.output.5 ?? ""))
            let words = before.trimmingCharacters(in: .whitespaces)
            if !words.isEmpty {
                pieces.append(.text(words))
            }
            if case .pictures(let row)? = pieces.last {
                pieces[pieces.count - 1] = .pictures(row + [picture])
            } else {
                pieces.append(.pictures([picture]))
            }
            done = match.range.upperBound
        }
        let rest = text[done...].trimmingCharacters(in: .whitespaces)
        if !rest.isEmpty || pieces.isEmpty { pieces.append(.text(rest)) }
        return pieces
    }

    private func inline(_ text: String) -> 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<Int>, _ 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 `</details>`, 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 `<details>` is: what its summary says,
/// its inline marks drawn, beside an arrow, and what's inside once that's
/// tapped.
private struct FoldedMarkdown<Content: View>: 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()
        }
    }
}