SPB Git

spb/zyquo-local Public MIT

Native macOS AI chat that runs LLMs 100% locally on Apple Silicon with MLX — no cloud, no API keys.

Swift 97.2% Shell 1.8% Makefile 1%
11.1 KB · 311 lines swift
Raw Blame History
1//2//  MarkdownText.swift3//  Zyquo Local4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import Markdown10import SwiftUI1112/// Full Markdown rendering: headings, lists, quotes, tables (as text),13/// inline styles via AttributedString, and syntax-highlighted code blocks14/// with a copy button.15struct MarkdownText: View {16    let markdown: String1718    init(_ markdown: String) {19        self.markdown = markdown20    }2122    var body: some View {23        let blocks = MarkdownBlockParser.parse(markdown)24        VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xs) {25            ForEach(blocks) { block in26                switch block.kind {27                case .paragraph(let text):28                    InlineMarkdown(text: text)29                case .heading(let text, let level):30                    InlineMarkdown(text: text, font: headingFont(level))31                case .code(let code, let language):32                    CodeBlockView(code: code, language: language)33                case .quote(let text):34                    HStack(alignment: .top, spacing: ZyquoTheme.Spacing.xs) {35                        RoundedRectangle(cornerRadius: 2)36                            .fill(ZyquoTheme.accent.opacity(0.5))37                            .frame(width: 3)38                        InlineMarkdown(text: text, color: ZyquoTheme.textSecondary)39                    }40                case .listItem(let text, let marker):41                    HStack(alignment: .top, spacing: ZyquoTheme.Spacing.xs) {42                        Text(marker)43                            .font(ZyquoTheme.chatBody.monospacedDigit())44                            .foregroundStyle(ZyquoTheme.textSecondary)45                        InlineMarkdown(text: text)46                    }47                case .rule:48                    Rectangle()49                        .fill(ZyquoTheme.border)50                        .frame(height: ZyquoTheme.hairline)51                        .padding(.vertical, ZyquoTheme.Spacing.xxs)52                }53            }54        }55    }5657    private func headingFont(_ level: Int) -> Font {58        switch level {59        case 1: .system(size: ZyquoTheme.chatFontSize + 6, weight: .semibold)60        case 2: .system(size: ZyquoTheme.chatFontSize + 4, weight: .semibold)61        default: .system(size: ZyquoTheme.chatFontSize + 2, weight: .semibold)62        }63    }64}6566/// Inline markdown (bold/italic/code/links) via AttributedString.67private struct InlineMarkdown: View {68    let text: String69    var font: Font?70    var color: Color = ZyquoTheme.textPrimary7172    var body: some View {73        Text(attributed)74            .font(font ?? ZyquoTheme.chatBody)75            .foregroundStyle(color)76            .lineSpacing(ZyquoTheme.lineSpacing(fontSize: ZyquoTheme.chatFontSize) * 0.55)77            .tint(ZyquoTheme.accent)78            .frame(maxWidth: .infinity, alignment: .leading)79    }8081    private var attributed: AttributedString {82        (try? AttributedString(83            markdown: text,84            options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)85        )) ?? AttributedString(text)86    }87}8889/// Code block: language label, copy button, lightweight syntax highlighting.90struct CodeBlockView: View {91    let code: String92    let language: String?93    @State private var copied = false9495    var body: some View {96        VStack(alignment: .leading, spacing: 0) {97            HStack {98                Text(language?.isEmpty == false ? language! : "code")99                    .font(ZyquoTheme.caption)100                    .foregroundStyle(ZyquoTheme.textTertiary)101                Spacer()102                Button {103                    NSPasteboard.general.clearContents()104                    NSPasteboard.general.setString(code, forType: .string)105                    copied = true106                    Task {107                        try? await Task.sleep(for: .seconds(1.4))108                        copied = false109                    }110                } label: {111                    Label(copied ? "Copied" : "Copy", systemImage: copied ? "checkmark" : "doc.on.doc")112                        .font(ZyquoTheme.caption)113                        .foregroundStyle(copied ? ZyquoTheme.success : ZyquoTheme.textSecondary)114                }115                .buttonStyle(.plain)116            }117            .padding(.horizontal, ZyquoTheme.Spacing.s)118            .padding(.vertical, ZyquoTheme.Spacing.xxs)119120            Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline)121122            ScrollView(.horizontal, showsIndicators: false) {123                Text(SyntaxHighlighter.highlight(code, language: language))124                    .font(ZyquoTheme.chatCode)125                    .lineSpacing(2.5)126                    .textSelection(.enabled)127                    .padding(ZyquoTheme.Spacing.s)128            }129        }130        .background(ZyquoTheme.surfaceSecondary, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s))131        .overlay(132            RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s)133                .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline)134        )135    }136}137138// MARK: - Block parsing (swift-markdown → renderable blocks)139140struct MarkdownBlock: Identifiable {141    enum Kind {142        case paragraph(String)143        case heading(String, Int)144        case code(String, String?)145        case quote(String)146        case listItem(String, marker: String)147        case rule148    }149150    let id: Int151    let kind: Kind152}153154enum MarkdownBlockParser {155    static func parse(_ text: String) -> [MarkdownBlock] {156        let document = Document(parsing: text)157        var blocks: [MarkdownBlock] = []158        var counter = 0159        func add(_ kind: MarkdownBlock.Kind) {160            blocks.append(MarkdownBlock(id: counter, kind: kind))161            counter += 1162        }163164        func visit(_ markup: Markup) {165            switch markup {166            case let heading as Heading:167                add(.heading(heading.plainInlineText, heading.level))168            case let code as CodeBlock:169                add(.code(code.code.trimmingCharacters(in: .newlines), code.language))170            case let quote as BlockQuote:171                let inner = quote.children172                    .compactMap { ($0 as? Paragraph)?.plainInlineText }173                    .joined(separator: "\n")174                add(.quote(inner))175            case let list as UnorderedList:176                for item in list.listItems {177                    add(.listItem(item.inlineText, marker: "•"))178                }179            case let list as OrderedList:180                for (i, item) in list.listItems.enumerated() {181                    add(.listItem(item.inlineText, marker: "\(Int(list.startIndex) + i)."))182                }183            case is ThematicBreak:184                add(.rule)185            case let paragraph as Paragraph:186                add(.paragraph(paragraph.plainInlineText))187            case let table as Markdown.Table:188                // Render tables as monospace text rows.189                var lines: [String] = []190                let head = table.head.cells.map { $0.plainText }.joined(separator: " | ")191                lines.append(head)192                lines.append(String(repeating: "—", count: max(8, head.count)))193                for row in table.body.rows {194                    lines.append(row.cells.map { $0.plainText }.joined(separator: " | "))195                }196                add(.code(lines.joined(separator: "\n"), nil))197            default:198                for child in markup.children { visit(child) }199            }200        }201        for child in document.children { visit(child) }202        return blocks203    }204}205206extension Markup {207    /// Re-serializes inline children so AttributedString can restyle them.208    var plainInlineText: String {209        children.compactMap { ($0 as? InlineMarkup)?.format() }.joined()210    }211}212213extension ListItem {214    var inlineText: String {215        children216            .compactMap { child -> String? in217                (child as? Paragraph)?.plainInlineText218            }219            .joined(separator: "\n")220    }221}222223extension Markdown.Table.Cell {224    var plainText: String {225        children.compactMap { ($0 as? InlineMarkup)?.plainText }.joined()226    }227}228229// MARK: - Lightweight syntax highlighting230231enum SyntaxHighlighter {232    private static let keywords: Set<String> = [233        // Swift / general234        "func", "let", "var", "if", "else", "for", "while", "return", "import",235        "struct", "class", "enum", "protocol", "extension", "guard", "switch",236        "case", "default", "break", "continue", "in", "actor", "await", "async",237        "throws", "throw", "try", "catch", "static", "private", "public",238        // Python / JS / others239        "def", "elif", "lambda", "None", "True", "False", "self", "pass",240        "const", "function", "=>", "new", "this", "null", "undefined",241        "true", "false", "nil", "typeof", "instanceof", "void", "int",242        "float", "double", "bool", "string", "match", "impl", "fn", "mut",243    ]244245    static func highlight(_ code: String, language: String?) -> AttributedString {246        var result = AttributedString()247        for (index, rawLine) in code.split(separator: "\n", omittingEmptySubsequences: false).enumerated() {248            if index > 0 { result += AttributedString("\n") }249            result += highlightLine(String(rawLine))250        }251        return result252    }253254    private static func highlightLine(_ line: String) -> AttributedString {255        // Whole-line comments256        let trimmed = line.trimmingCharacters(in: .whitespaces)257        if trimmed.hasPrefix("//") || trimmed.hasPrefix("#") || trimmed.hasPrefix("--") {258            var comment = AttributedString(line)259            comment.foregroundColor = ZyquoTheme.Syntax.comment260            return comment261        }262263        var result = AttributedString()264        var current = ""265        var inString: Character? = nil266267        func flushWord(_ word: String) {268            var part = AttributedString(word)269            if keywords.contains(word) {270                part.foregroundColor = ZyquoTheme.Syntax.keyword271            } else if word.first?.isNumber == true, Double(word) != nil {272                part.foregroundColor = ZyquoTheme.Syntax.number273            }274            result += part275        }276277        func flush() {278            guard !current.isEmpty else { return }279            if inString != nil {280                var part = AttributedString(current)281                part.foregroundColor = ZyquoTheme.Syntax.string282                result += part283            } else {284                flushWord(current)285            }286            current = ""287        }288289        for char in line {290            if let quote = inString {291                current.append(char)292                if char == quote {293                    flush()294                    inString = nil295                }296            } else if char == "\"" || char == "'" {297                flush()298                inString = char299                current.append(char)300            } else if char.isLetter || char.isNumber || char == "_" || char == "." {301                current.append(char)302            } else {303                flush()304                result += AttributedString(String(char))305            }306        }307        flush()308        return result309    }310}311