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%
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