SPB Git

spb/zyquo-cloud Public MIT

Native macOS AI chat client for 12 cloud providers — your keys, every cloud model, one beautiful chat.

Swift 97.4% Shell 1.7% Makefile 1%
17.9 KB · 482 lines swift
Raw Blame History
1//2//  MarkdownView.swift3//  Zyquo Cloud4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Full Markdown rendering for chat messages per the Phase 4 spec: the9//  swift-markdown AST is walked once into a lightweight [MarkdownBlock] model10//  (memoized by text hash — streaming deltas re-parse only the changed text),11//  then rendered as SwiftUI views built entirely from ZyquoTheme tokens.12//  Links use the AttributedString .link attribute, so SwiftUI.Text routes13//  clicks through the environment's openURL automatically. Parsing is14//  resilient to incomplete Markdown (unterminated fences etc.) — swift-markdown15//  degrades gracefully, so streaming partial text never crashes.16//1718import SwiftUI19import Markdown2021// MARK: - MarkdownView2223struct MarkdownView: View {24    let fontSize: Double25    private let blocks: [MarkdownBlock]2627    init(text: String, fontSize: Double) {28        self.fontSize = fontSize29        self.blocks = MarkdownBlockParser.parse(text: text, fontSize: fontSize)30    }3132    var body: some View {33        VStack(alignment: .leading, spacing: ZyquoSpacing.sm) {34            ForEach(blocks) { block in35                MarkdownBlockView(block: block, fontSize: fontSize)36            }37        }38        .foregroundStyle(ZyquoColor.textPrimary)39    }40}4142// MARK: - Block model4344/// One rendered Markdown block. Ids are assigned in document order at parse45/// time so `ForEach` stays stable within a parse.46struct MarkdownBlock: Identifiable {47    let id: Int48    let kind: Kind4950    enum Kind {51        case paragraph(AttributedString)52        case heading(AttributedString, level: Int)53        case code(String, language: String?)54        case quote([MarkdownBlock])55        case list(ListData)56        case table(TableData)57        case thematicBreak58    }5960    struct ListData {61        let ordered: Bool62        let start: Int63        let items: [ListItemData]64    }6566    struct ListItemData: Identifiable {67        let id: Int68        /// nil = plain item; true/false = task-list checkbox state.69        let checked: Bool?70        let blocks: [MarkdownBlock]71    }7273    struct TableData {74        let alignments: [TextAlignment]75        let header: [AttributedString]76        let rows: [[AttributedString]]7778        func alignment(forColumn column: Int) -> TextAlignment {79            column < alignments.count ? alignments[column] : .leading80        }81    }82}8384// MARK: - Parser8586/// Walks the swift-markdown `Document` AST into `[MarkdownBlock]`. Results are87/// memoized by (text, fontSize) so re-renders during streaming only re-parse88/// when the text actually changes.89enum MarkdownBlockParser {90    /// Heading sizes scale off the user's chat font size.91    private static func headingSize(level: Int, base: Double) -> Double {92        switch level {93        case 1: return base * 1.5594        case 2: return base * 1.3595        case 3: return base * 1.296        default: return base * 1.0597        }98    }99100    static func parse(text: String, fontSize: Double) -> [MarkdownBlock] {101        let key = CacheKey(textHash: text.hashValue, length: text.count, fontBits: fontSize.bitPattern)102        cacheLock.lock()103        if let hit = cache[key] {104            cacheLock.unlock()105            return hit106        }107        cacheLock.unlock()108109        let document = Document(parsing: text)110        var counter = 0111        let blocks = convertBlocks(of: document, fontSize: fontSize, counter: &counter)112113        cacheLock.lock()114        if cache.count > cacheCapacity { cache.removeAll(keepingCapacity: true) }115        cache[key] = blocks116        cacheLock.unlock()117        return blocks118    }119120    // MARK: Cache121122    private struct CacheKey: Hashable {123        let textHash: Int124        let length: Int125        let fontBits: UInt64126    }127128    private static let cacheLock = NSLock()129    private static let cacheCapacity = 32130    private static var cache: [CacheKey: [MarkdownBlock]] = [:]131132    // MARK: Block conversion133134    private static func convertBlocks(of parent: Markup, fontSize: Double, counter: inout Int) -> [MarkdownBlock] {135        parent.children.compactMap { convertBlock($0, fontSize: fontSize, counter: &counter) }136    }137138    private static func convertBlock(_ markup: Markup, fontSize: Double, counter: inout Int) -> MarkdownBlock? {139        counter += 1140        let id = counter141142        switch markup {143        case let heading as Heading:144            let font = Font.system(size: headingSize(level: heading.level, base: fontSize), weight: .semibold)145            let content = inlineText(of: heading, fontSize: fontSize, baseFont: font)146            return MarkdownBlock(id: id, kind: .heading(content, level: heading.level))147148        case let paragraph as Paragraph:149            let content = inlineText(of: paragraph, fontSize: fontSize, baseFont: ZyquoFont.body(size: fontSize))150            guard !content.characters.isEmpty else { return nil }151            return MarkdownBlock(id: id, kind: .paragraph(content))152153        case let codeBlock as CodeBlock:154            var code = codeBlock.code155            if code.hasSuffix("\n") { code.removeLast() }156            return MarkdownBlock(id: id, kind: .code(code, language: codeBlock.language))157158        case let quote as BlockQuote:159            return MarkdownBlock(id: id, kind: .quote(convertBlocks(of: quote, fontSize: fontSize, counter: &counter)))160161        case let list as UnorderedList:162            let items = convertListItems(of: list, fontSize: fontSize, counter: &counter)163            return MarkdownBlock(id: id, kind: .list(.init(ordered: false, start: 1, items: items)))164165        case let list as OrderedList:166            let items = convertListItems(of: list, fontSize: fontSize, counter: &counter)167            return MarkdownBlock(id: id, kind: .list(.init(ordered: true, start: Int(list.startIndex), items: items)))168169        case let table as Markdown.Table:170            return MarkdownBlock(id: id, kind: .table(convertTable(table, fontSize: fontSize)))171172        case is ThematicBreak:173            return MarkdownBlock(id: id, kind: .thematicBreak)174175        case let html as HTMLBlock:176            var raw = html.rawHTML177            if raw.hasSuffix("\n") { raw.removeLast() }178            return MarkdownBlock(id: id, kind: .code(raw, language: "html"))179180        default:181            // Unknown block: fall back to its re-formatted Markdown source.182            let source = markup.format().trimmingCharacters(in: .whitespacesAndNewlines)183            guard !source.isEmpty else { return nil }184            var content = AttributedString(source)185            content.font = ZyquoFont.body(size: fontSize)186            return MarkdownBlock(id: id, kind: .paragraph(content))187        }188    }189190    private static func convertListItems(of list: Markup, fontSize: Double, counter: inout Int) -> [MarkdownBlock.ListItemData] {191        list.children.compactMap { child in192            guard let item = child as? Markdown.ListItem else { return nil }193            counter += 1194            let id = counter195            let checked: Bool?196            switch item.checkbox {197            case .checked: checked = true198            case .unchecked: checked = false199            case nil: checked = nil200            }201            return MarkdownBlock.ListItemData(202                id: id,203                checked: checked,204                blocks: convertBlocks(of: item, fontSize: fontSize, counter: &counter)205            )206        }207    }208209    private static func convertTable(_ table: Markdown.Table, fontSize: Double) -> MarkdownBlock.TableData {210        let alignments: [TextAlignment] = table.columnAlignments.map { alignment in211            switch alignment {212            case .center: return .center213            case .right: return .trailing214            default: return .leading215            }216        }217        let headerFont = ZyquoFont.bodyEmphasis(size: fontSize)218        let header = table.head.children.compactMap { cell -> AttributedString? in219            guard let cell = cell as? Markdown.Table.Cell else { return nil }220            return inlineText(of: cell, fontSize: fontSize, baseFont: headerFont)221        }222        let bodyFont = ZyquoFont.body(size: fontSize)223        let rows = table.body.children.compactMap { row -> [AttributedString]? in224            guard let row = row as? Markdown.Table.Row else { return nil }225            return row.children.compactMap { cell -> AttributedString? in226                guard let cell = cell as? Markdown.Table.Cell else { return nil }227                return inlineText(of: cell, fontSize: fontSize, baseFont: bodyFont)228            }229        }230        return MarkdownBlock.TableData(alignments: alignments, header: header, rows: rows)231    }232233    // MARK: Inline conversion234235    private static func inlineText(236        of parent: Markup,237        fontSize: Double,238        baseFont: Font,239        bold: Bool = false,240        italic: Bool = false241    ) -> AttributedString {242        var result = AttributedString()243        for child in parent.children {244            result += inlineFragment(child, fontSize: fontSize, baseFont: baseFont, bold: bold, italic: italic)245        }246        return result247    }248249    private static func inlineFragment(250        _ markup: Markup,251        fontSize: Double,252        baseFont: Font,253        bold: Bool,254        italic: Bool255    ) -> AttributedString {256        switch markup {257        case let text as Markdown.Text:258            return styled(text.string, baseFont: baseFont, bold: bold, italic: italic)259260        case let emphasis as Emphasis:261            return inlineText(of: emphasis, fontSize: fontSize, baseFont: baseFont, bold: bold, italic: true)262263        case let strong as Strong:264            return inlineText(of: strong, fontSize: fontSize, baseFont: baseFont, bold: true, italic: italic)265266        case let code as InlineCode:267            var segment = AttributedString(code.code)268            segment.font = ZyquoFont.code(size: max(fontSize - 1, 1))269            segment.backgroundColor = ZyquoColor.surfaceSecondary270            return segment271272        case let link as Markdown.Link:273            var segment = inlineText(of: link, fontSize: fontSize, baseFont: baseFont, bold: bold, italic: italic)274            segment.foregroundColor = ZyquoColor.accent275            if let destination = link.destination, let url = URL(string: destination) {276                segment.link = url277            }278            return segment279280        case let image as Markdown.Image:281            // No inline image loading in chat transcripts: render the alt text282            // (or the source) as a link to the image.283            var segment = inlineText(of: image, fontSize: fontSize, baseFont: baseFont, bold: bold, italic: italic)284            if segment.characters.isEmpty, let source = image.source {285                segment = styled(source, baseFont: baseFont, bold: bold, italic: italic)286            }287            segment.foregroundColor = ZyquoColor.accent288            if let source = image.source, let url = URL(string: source) {289                segment.link = url290            }291            return segment292293        case let strike as Strikethrough:294            var segment = inlineText(of: strike, fontSize: fontSize, baseFont: baseFont, bold: bold, italic: italic)295            segment[AttributeScopes.SwiftUIAttributes.StrikethroughStyleAttribute.self] = .single296            return segment297298        case is SoftBreak:299            return styled(" ", baseFont: baseFont, bold: bold, italic: italic)300301        case is LineBreak:302            return styled("\n", baseFont: baseFont, bold: bold, italic: italic)303304        case let html as InlineHTML:305            return styled(html.rawHTML, baseFont: baseFont, bold: bold, italic: italic)306307        default:308            return styled(markup.format(), baseFont: baseFont, bold: bold, italic: italic)309        }310    }311312    private static func styled(_ string: String, baseFont: Font, bold: Bool, italic: Bool) -> AttributedString {313        var segment = AttributedString(string)314        var font = baseFont315        if bold { font = font.bold() }316        if italic { font = font.italic() }317        segment.font = font318        return segment319    }320}321322// MARK: - Block rendering323324private struct MarkdownBlockView: View {325    let block: MarkdownBlock326    let fontSize: Double327328    /// Blockquote accent bar width (Phase 4 spec: 3pt accent left bar).329    private static let quoteBarWidth: CGFloat = 3330331    var body: some View {332        switch block.kind {333        case .paragraph(let content):334            SwiftUI.Text(content)335                .lineSpacing(fontSize * ZyquoFont.bodyLineSpacingFactor)336                .textSelection(.enabled)337                .fixedSize(horizontal: false, vertical: true)338339        case .heading(let content, _):340            SwiftUI.Text(content)341                .textSelection(.enabled)342                .fixedSize(horizontal: false, vertical: true)343                .padding(.top, ZyquoSpacing.xxs)344345        case .code(let code, let language):346            CodeBlockView(code: code, language: language, fontSize: fontSize)347348        case .quote(let children):349            HStack(alignment: .top, spacing: ZyquoSpacing.sm) {350                RoundedRectangle(cornerRadius: Self.quoteBarWidth / 2, style: .continuous)351                    .fill(ZyquoColor.accent)352                    .frame(width: Self.quoteBarWidth)353                VStack(alignment: .leading, spacing: ZyquoSpacing.xs) {354                    ForEach(children) { child in355                        MarkdownBlockView(block: child, fontSize: fontSize)356                    }357                }358                .foregroundStyle(ZyquoColor.textSecondary)359            }360361        case .list(let data):362            listView(data)363364        case .table(let data):365            tableView(data)366367        case .thematicBreak:368            ZyquoHairline()369                .padding(.vertical, ZyquoSpacing.xxs)370        }371    }372373    // MARK: Lists374375    private func listView(_ data: MarkdownBlock.ListData) -> some View {376        VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {377            ForEach(Array(data.items.enumerated()), id: \.element.id) { offset, item in378                HStack(alignment: .firstTextBaseline, spacing: ZyquoSpacing.xs) {379                    marker(for: item, ordinal: data.start + offset, ordered: data.ordered)380                    VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {381                        ForEach(item.blocks) { child in382                            MarkdownBlockView(block: child, fontSize: fontSize)383                        }384                    }385                }386            }387        }388    }389390    @ViewBuilder391    private func marker(for item: MarkdownBlock.ListItemData, ordinal: Int, ordered: Bool) -> some View {392        if let checked = item.checked {393            Image(systemName: checked ? "checkmark.square.fill" : "square")394                .font(ZyquoFont.body(size: fontSize))395                .foregroundStyle(checked ? ZyquoColor.accent : ZyquoColor.textSecondary)396        } else if ordered {397            SwiftUI.Text("\(ordinal).")398                .font(ZyquoFont.body(size: fontSize).monospacedDigit())399                .foregroundStyle(ZyquoColor.textSecondary)400                .frame(minWidth: ZyquoSpacing.lg, alignment: .trailing)401        } else {402            SwiftUI.Text("•")403                .font(ZyquoFont.body(size: fontSize))404                .foregroundStyle(ZyquoColor.textSecondary)405        }406    }407408    // MARK: Tables409410    private func tableView(_ data: MarkdownBlock.TableData) -> some View {411        Grid(alignment: .topLeading, horizontalSpacing: 0, verticalSpacing: 0) {412            GridRow {413                ForEach(data.header.indices, id: \.self) { column in414                    tableCell(415                        data.header[column],416                        data: data,417                        column: column,418                        tinted: true,419                        isLastRow: data.rows.isEmpty420                    )421                }422            }423            ForEach(data.rows.indices, id: \.self) { rowIndex in424                GridRow {425                    ForEach(data.rows[rowIndex].indices, id: \.self) { column in426                        tableCell(427                            data.rows[rowIndex][column],428                            data: data,429                            column: column,430                            tinted: rowIndex % 2 == 1,431                            isLastRow: rowIndex == data.rows.count - 1432                        )433                    }434                }435            }436        }437        .clipShape(RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous))438        .overlay(439            RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)440                .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)441        )442    }443444    private func tableCell(445        _ content: AttributedString,446        data: MarkdownBlock.TableData,447        column: Int,448        tinted: Bool,449        isLastRow: Bool450    ) -> some View {451        let alignment = data.alignment(forColumn: column)452        let columnCount = max(data.header.count, data.rows.map(\.count).max() ?? 0)453        let isLastColumn = column == columnCount - 1454        return SwiftUI.Text(content)455            .multilineTextAlignment(alignment)456            .textSelection(.enabled)457            .fixedSize(horizontal: false, vertical: true)458            .padding(.horizontal, ZyquoSpacing.sm)459            .padding(.vertical, ZyquoSpacing.xs)460            .frame(maxWidth: .infinity, alignment: frameAlignment(for: alignment))461            .background(tinted ? ZyquoColor.surfaceSecondary : Color.clear)462            .overlay(alignment: .bottom) {463                if !isLastRow { ZyquoHairline() }464            }465            .overlay(alignment: .trailing) {466                if !isLastColumn {467                    Rectangle()468                        .fill(ZyquoColor.border)469                        .frame(width: ZyquoMetrics.hairline)470                }471            }472    }473474    private func frameAlignment(for alignment: TextAlignment) -> Alignment {475        switch alignment {476        case .leading: return .leading477        case .center: return .center478        case .trailing: return .trailing479        }480    }481}482