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