From 5f88b2233562394f75cfc7a400bd2ba34f44a5b6 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 11 Nov 2026 22:28:34 +0000 Subject: [PATCH] feat(zbugs-swift): show Markdown images, code blocks, lists, quotes, and tables Issue descriptions and comments went through Text, which shows only inline Markdown: an image showed as its alt text and a code block ran together. Foundation's full parser now splits them into blocks, each a view of its own, and images load from the web at their own size. Amp-Thread-ID: https://ampcode.com/threads/T-7f9226a6-efbb-4638-918a-2912e07339b1 Co-authored-by: tmm --- .../Sources/ZBugsUI/IssueDetail.swift | 11 +- .../Sources/ZBugsUI/Markdown.swift | 309 ++++++++++++++++++ 2 files changed, 311 insertions(+), 9 deletions(-) create mode 100644 apps/zbugs-swift/Sources/ZBugsUI/Markdown.swift diff --git a/apps/zbugs-swift/Sources/ZBugsUI/IssueDetail.swift b/apps/zbugs-swift/Sources/ZBugsUI/IssueDetail.swift index d957b63c6a..e7f7b487a7 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/IssueDetail.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/IssueDetail.swift @@ -70,7 +70,7 @@ VStack(alignment: .leading, spacing: 16) { header if !issue.description.isEmpty { - Text(markdown(issue.description)).textSelection(.enabled) + MarkdownView(issue.description) } Divider() } @@ -171,7 +171,7 @@ .foregroundStyle(.secondary) } .font(.subheadline) - Text(markdown(comment.body)).textSelection(.enabled) + MarkdownView(comment.body) } .frame(maxWidth: .infinity, alignment: .leading) .padding(12) @@ -235,13 +235,6 @@ } } - /// Markdown as `Text` shows it, or the plain text when it isn't. - func markdown(_ s: String) -> AttributedString { - (try? AttributedString( - markdown: s, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) - ?? AttributedString(s) - } - /// Milliseconds since 1970, zbugs' timestamps. func now() -> Double { (Date().timeIntervalSince1970 * 1000).rounded() diff --git a/apps/zbugs-swift/Sources/ZBugsUI/Markdown.swift b/apps/zbugs-swift/Sources/ZBugsUI/Markdown.swift new file mode 100644 index 0000000000..1f9c48605a --- /dev/null +++ b/apps/zbugs-swift/Sources/ZBugsUI/Markdown.swift @@ -0,0 +1,309 @@ +#if canImport(SwiftUI) + import Foundation + import SwiftUI + + /// Markdown as zbugs shows it, with GitHub's syntax: paragraphs, headings, + /// lists, quotes, code blocks, tables, and images from the web. `Text` + /// shows only inline Markdown, so each block Foundation finds is a view of + /// its own. + struct MarkdownView: View { + private let blocks: [MarkdownBlock] + + init(_ text: String) { + blocks = MarkdownBlock.parse(text) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(blocks.indices, id: \.self) { i in + MarkdownBlockView(block: blocks[i]) + .padding(.top, i == 0 ? 0 : blocks[i].spacing(after: blocks[i - 1])) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + } + + /// A block of Markdown, where lists and quotes put it. + private struct MarkdownBlock { + enum Content { + /// A paragraph, or a heading of a level from 1. + case text(AttributedString, heading: Int?) + case image(URL, alt: String) + case code(String) + case rule + /// The rows, the header first. + case table([[AttributedString]], id: Int) + } + + var content: Content + /// How deeply lists nest it. + var depth = 0 + /// Its list item's bullet or number, on the item's first block. + var marker: String? + var quoted = false + /// The outermost list it's in. + var list: Int? + + /// The space above it: less between the items of a list. + func spacing(after previous: MarkdownBlock) -> CGFloat { + list != nil && list == previous.list ? 4 : 12 + } + + static func parse(_ text: String) -> [MarkdownBlock] { + guard + let document = try? AttributedString( + markdown: text, + options: .init( + interpretedSyntax: .full, failurePolicy: .returnPartiallyParsedIfPossible)) + else { return [MarkdownBlock(content: .text(AttributedString(text), heading: nil))] } + + var blocks: [MarkdownBlock] = [] + var markedItems: Set = [] + var lastRow: Int? + // The text by the block it's in. A block's components are it, then + // the blocks around it, innermost first. + for (intent, range) in document.runs[\.presentationIntent] { + let components = intent?.components ?? [] + let slice = AttributedString(document[range]) + var block = MarkdownBlock(content: .rule) + for (i, component) in components.enumerated() { + switch component.kind { + case .orderedList, .unorderedList: + block.depth += 1 + block.list = component.identity + case .listItem(let ordinal): + if block.marker == nil, markedItems.insert(component.identity).inserted { + let ordered = components.dropFirst(i + 1).first?.kind == .orderedList + block.marker = ordered ? "\(ordinal)." : "•" + } + case .blockQuote: + block.quoted = true + default: + break + } + } + func add(_ content: Content) { + block.content = content + blocks.append(block) + block.marker = nil + } + + switch components.first?.kind { + case .codeBlock: + var code = String(slice.characters) + while code.hasSuffix("\n") { code.removeLast() } + add(.code(code)) + case .thematicBreak: + add(.rule) + case .tableCell: + // A cell, then its row, then its table. Cells come row by row. + let row = components.dropFirst().first?.identity + let table = components.dropFirst(2).first?.identity ?? 0 + if case .table(var rows, let id)? = blocks.last?.content, id == table { + if row != lastRow { rows.append([]) } + rows[rows.count - 1].append(slice) + blocks[blocks.count - 1].content = .table(rows, id: id) + } else { + add(.table([[slice]], id: table)) + } + lastRow = row + default: + var heading: Int? + if case .header(let level)? = components.first?.kind { heading = level } + // Each image on its own, between the text before and after it. + var start = slice.startIndex + func text(to end: AttributedString.Index) { + let part = trimmed(AttributedString(slice[start.. AttributedString { + let characters = s.characters + guard let first = characters.firstIndex(where: { !$0.isWhitespace }), + let last = characters.lastIndex(where: { !$0.isWhitespace }) + else { return AttributedString() } + return AttributedString(s[first...last]) + } + + private struct MarkdownBlockView: View { + let block: MarkdownBlock + + var body: some View { + let row = HStack(alignment: .firstTextBaseline, spacing: 6) { + if block.depth > 0 { + Text(block.marker ?? "") + .monospacedDigit() + .foregroundStyle(.secondary) + .frame(minWidth: 18, alignment: .trailing) + } + content + } + .padding(.leading, CGFloat(max(block.depth - 1, 0)) * 24) + if block.quoted { + row + .padding(.leading, 12) + .overlay(alignment: .leading) { Capsule().fill(.tertiary).frame(width: 3) } + .foregroundStyle(.secondary) + } else { + row + } + } + + @ViewBuilder private var content: some View { + switch block.content { + case .text(let text, let heading): + Text(text).font(heading.map(Self.font) ?? .body) + case .image(let url, let alt): + MarkdownImage(url: url, alt: alt) + case .code(let code): + let text = Text(code).font(.system(.callout, design: .monospaced)).padding(10) + Group { + #if os(macOS) + // Long lines wrap: a Mac's horizontal ScrollView keeps the scroll + // wheel's vertical scrolling from the page. + text + #else + // Long lines scroll, as zbugs' do. + ScrollView(.horizontal) { text.fixedSize() } + #endif + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.6), in: RoundedRectangle(cornerRadius: 6)) + case .rule: + // Across, not down the row as in an HStack. + VStack { Divider() } + case .table(let rows, _): + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) { + ForEach(rows.indices, id: \.self) { r in + GridRow { + ForEach(rows[r].indices, id: \.self) { c in + Text(rows[r][c]).bold(r == 0) + } + } + if r == 0 { Divider() } + } + } + } + } + + private static func font(_ level: Int) -> Font { + switch level { + case 1: .title2.bold() + case 2: .title3.bold() + case 3: .headline + default: .subheadline.bold() + } + } + } + + /// An image from the web at its own size, or narrower to fit. Until it + /// arrives, a placeholder; when it can't be shown, a link to it. + private struct MarkdownImage: View { + let url: URL + let alt: String + @State private var image: WebImages.Loaded? + @State private var failed = false + + init(url: URL, alt: String) { + self.url = url + self.alt = alt + // A row scrolled back into view shows it at once, at its height. + _image = State(initialValue: WebImages.cached(url)) + } + + var body: some View { + Group { + if let image { + image.image + .resizable() + .aspectRatio(image.size, contentMode: .fit) + .frame(maxWidth: image.size.width) + .accessibilityLabel(alt) + } else if failed { + Link(alt.isEmpty ? url.absoluteString : alt, destination: url) + } else { + RoundedRectangle(cornerRadius: 6) + .fill(.quaternary.opacity(0.6)) + .frame(width: 240, height: 120) + .overlay { ProgressView().controlSize(.small) } + } + } + .task(id: url) { + guard image == nil else { return } + image = await WebImages.load(url) + failed = image == nil + } + } + } + + /// Images from the web, each fetched once and kept while there's room. + @MainActor + private enum WebImages { + struct Loaded { + let image: Image + let size: CGSize + } + + /// Wraps a `Loaded` for `NSCache`. + private final class Entry { + let loaded: Loaded + init(_ loaded: Loaded) { self.loaded = loaded } + } + + private static let cache: NSCache = { + let cache = NSCache() + cache.countLimit = 100 + return cache + }() + private static var loading: [URL: Task] = [:] + + static func cached(_ url: URL) -> Loaded? { + cache.object(forKey: url as NSURL)?.loaded + } + + /// The image at `url`, or nil when it isn't one. A failure isn't kept, + /// so the next view of it tries again. + static func load(_ url: URL) async -> Loaded? { + if let loaded = cached(url) { return loaded } + let task = + loading[url] + ?? Task { + defer { loading[url] = nil } + guard ["http", "https"].contains(url.scheme?.lowercased()), + let fetched = try? await URLSession.shared.data(from: url), + (fetched.1 as? HTTPURLResponse)?.statusCode == 200, + let loaded = decode(fetched.0) + else { return nil } + cache.setObject(Entry(loaded), forKey: url as NSURL) + return loaded + } + loading[url] = task + return await task.value + } + + private static func decode(_ data: Data) -> Loaded? { + #if os(macOS) + guard let image = NSImage(data: data), image.size.width > 0 else { return nil } + return Loaded(image: Image(nsImage: image), size: image.size) + #else + guard let image = UIImage(data: data), image.size.width > 0 else { return nil } + return Loaded(image: Image(uiImage: image), size: image.size) + #endif + } + } +#endif