diff --git a/scarf/Scarf iOS/Chat/ChatContentFormatter.swift b/scarf/Scarf iOS/Chat/ChatContentFormatter.swift new file mode 100644 index 0000000..304186e --- /dev/null +++ b/scarf/Scarf iOS/Chat/ChatContentFormatter.swift @@ -0,0 +1,92 @@ +import Foundation + +/// Splits a chat message body into alternating text + fenced-code +/// segments so ChatView can render each part appropriately. Text +/// gets the existing AttributedString(markdown:) path; code gets a +/// horizontally-scrollable monospaced block (pass-1 UX: long lines +/// wrapped onto 4–5 visual rows each, which ate vertical space and +/// made code unreadable on an iPhone). +/// +/// Keeps the parser deliberately simple: we recognise the common +/// fenced form (```\n...\n``` and ```lang\n...\n```) and leave +/// everything else in the .text bucket. Inline `backticks` stay in +/// the text segment — AttributedString handles those fine. +enum ChatContentFormatter { + + enum Segment: Equatable { + case text(String) + case code(language: String?, body: String) + } + + /// Split the given message body into an ordered list of segments. + /// A body with no fenced code yields a single `.text` segment. + static func segments(for body: String) -> [Segment] { + // Fast path: no fences at all. + guard body.contains("```") else { return [.text(body)] } + + var result: [Segment] = [] + var pending = "" + var i = body.startIndex + + while i < body.endIndex { + // Try to match a fence opening at this position. + if body[i...].hasPrefix("```") { + // Flush the accumulated text. + if !pending.isEmpty { + result.append(.text(pending)) + pending = "" + } + + // Parse the optional language token up to the first newline. + let afterFence = body.index(i, offsetBy: 3) + var j = afterFence + while j < body.endIndex, body[j] != "\n" { + j = body.index(after: j) + } + let lang = String(body[afterFence.. Text { + if let attributed = try? AttributedString( + markdown: body, + options: AttributedString.MarkdownParsingOptions( + interpretedSyntax: .inlineOnlyPreservingWhitespace + ) + ) { + return Text(attributed) + } + return Text(body) + } +} + +/// Horizontally-scrollable fenced code block. ~240pt max height +/// collapsed (Expand button reveals full height). Monospaced +/// .footnote font keeps the bubble narrow enough to still show +/// adjacent text on the same screen. Language label is a tiny +/// header when present. +private struct CodeBlockView: View { + let language: String? + let code: String + @State private var expanded = false + + private let collapsedMaxHeight: CGFloat = 240 + + init(language: String?, body: String) { + self.language = language + self.code = body + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + if let lang = language, !lang.isEmpty { + Text(lang.uppercased()) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + } + Spacer() + Button(expanded ? "Collapse" : "Expand") { + withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() } + } + .font(.caption2) + .buttonStyle(.plain) + .foregroundStyle(.tint) + Button { + UIPasteboard.general.string = code + } label: { + Image(systemName: "doc.on.doc") + .font(.caption2) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + + ScrollView(.horizontal, showsIndicators: true) { + Text(code) + .font(.footnote.monospaced()) + .textSelection(.enabled) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .fixedSize(horizontal: true, vertical: false) + } + .frame(maxHeight: expanded ? nil : collapsedMaxHeight) + .background(Color(.tertiarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } } }