Files
oai-swift/oAI/Views/Main/MarkdownContentView.swift
T
runeandClaude Sonnet 5 bd686873c4 Update commercial licensing contact URL to oai.pm
Consolidates the mac.oai.pm subdomain references (introduced in the
PolyForm Noncommercial relicense) to the root oai.pm domain, across
the LICENSE file, README, and all Swift source file headers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 11:33:53 +02:00

366 lines
13 KiB
Swift

//
// MarkdownContentView.swift
// oAI
//
// Renders markdown content with syntax-highlighted code blocks
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling oAI or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>.
import SwiftUI
import MarkdownUI
#if canImport(AppKit)
import AppKit
#endif
struct MarkdownContentView: View {
let content: String
let fontSize: Double
var body: some View {
let segments = parseSegments(content)
VStack(alignment: .leading, spacing: 12) {
ForEach(segments.indices, id: \.self) { index in
switch segments[index] {
case .text(let text):
if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
markdownText(text)
}
case .codeBlock(let language, let code):
CodeBlockView(language: language, code: code, fontSize: fontSize)
case .table(let tableText):
TableView(content: tableText, fontSize: fontSize)
}
}
}
}
@ViewBuilder
private func markdownText(_ text: String) -> some View {
Markdown(text)
.markdownTextStyle {
FontSize(fontSize)
ForegroundColor(.primary)
}
.markdownBlockStyle(\.paragraph) { configuration in
configuration.label
.markdownMargin(top: 0, bottom: 8)
// MarkdownUI builds mixed-style paragraphs (bold/italic runs alongside
// plain text) as concatenated Text(+) segments, which on macOS report
// their ideal (unwrapped, single-line) size instead of wrapping to the
// width actually available — truncating with "…" mid-word. Forcing the
// height to be recomputed for the given (flexible) width fixes it.
.fixedSize(horizontal: false, vertical: true)
}
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: - Parsing
enum Segment {
case text(String)
case codeBlock(language: String?, code: String)
case table(String)
}
private func parseSegments(_ content: String) -> [Segment] {
var segments: [Segment] = []
let lines = content.components(separatedBy: "\n")
var currentText = ""
var inCodeBlock = false
var codeLanguage: String? = nil
var codeContent = ""
var inTable = false
var tableLines: [String] = []
for line in lines {
if !inCodeBlock && line.hasPrefix("```") {
// Start of code block
if inTable {
segments.append(.table(tableLines.joined(separator: "\n")))
tableLines = []
inTable = false
}
if !currentText.isEmpty {
segments.append(.text(currentText))
currentText = ""
}
inCodeBlock = true
let lang = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)
codeLanguage = lang.isEmpty ? nil : lang
codeContent = ""
} else if inCodeBlock && line.hasPrefix("```") {
// End of code block
if codeContent.hasSuffix("\n") {
codeContent = String(codeContent.dropLast())
}
segments.append(.codeBlock(language: codeLanguage, code: codeContent))
inCodeBlock = false
codeLanguage = nil
codeContent = ""
} else if inCodeBlock {
codeContent += line + "\n"
} else if line.contains("|") && (line.hasPrefix("|") || line.filter({ $0 == "|" }).count >= 2) {
// Markdown table line
if !inTable {
// Start of table
if !currentText.isEmpty {
segments.append(.text(currentText))
currentText = ""
}
inTable = true
}
tableLines.append(line)
} else {
// Regular text
if inTable {
// End of table
segments.append(.table(tableLines.joined(separator: "\n")))
tableLines = []
inTable = false
}
currentText += line + "\n"
}
}
// Handle unclosed code block
if inCodeBlock {
if codeContent.hasSuffix("\n") {
codeContent = String(codeContent.dropLast())
}
segments.append(.codeBlock(language: codeLanguage, code: codeContent))
}
// Handle unclosed table
if inTable {
segments.append(.table(tableLines.joined(separator: "\n")))
}
// Remaining text
if !currentText.isEmpty {
if currentText.hasSuffix("\n") {
currentText = String(currentText.dropLast())
}
if !currentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
segments.append(.text(currentText))
}
}
return segments
}
}
// MARK: - Table View
struct TableView: View {
let content: String
let fontSize: Double
private struct TableData {
let headers: [String]
let alignments: [TextAlignment]
let rows: [[String]]
}
private var tableData: TableData {
parseTable(content)
}
var body: some View {
let data = tableData
guard !data.headers.isEmpty else {
return AnyView(EmptyView())
}
return AnyView(
VStack(alignment: .leading, spacing: 0) {
// Headers
HStack(spacing: 0) {
ForEach(data.headers.indices, id: \.self) { index in
if index < data.headers.count {
Text(data.headers[index].trimmingCharacters(in: .whitespaces))
.font(.system(size: fontSize, weight: .semibold))
.foregroundColor(.oaiPrimary)
.frame(maxWidth: .infinity, alignment: alignmentFor(
index < data.alignments.count ? data.alignments[index] : .leading
))
.padding(8)
.background(Color.oaiSecondary.opacity(0.1))
if index < data.headers.count - 1 {
Divider()
}
}
}
}
.overlay(
Rectangle()
.stroke(Color.oaiSecondary.opacity(0.3), lineWidth: 1)
)
// Rows
ForEach(data.rows.indices, id: \.self) { rowIndex in
if rowIndex < data.rows.count {
HStack(spacing: 0) {
ForEach(0..<data.headers.count, id: \.self) { colIndex in
let cellContent = colIndex < data.rows[rowIndex].count ? data.rows[rowIndex][colIndex] : ""
let alignment = colIndex < data.alignments.count ? data.alignments[colIndex] : .leading
Text(cellContent.trimmingCharacters(in: .whitespaces))
.font(.system(size: fontSize))
.foregroundColor(.oaiPrimary)
.frame(maxWidth: .infinity, alignment: alignmentFor(alignment))
.padding(8)
if colIndex < data.headers.count - 1 {
Divider()
}
}
}
.background(rowIndex % 2 == 0 ? Color.clear : Color.oaiSecondary.opacity(0.05))
.overlay(
Rectangle()
.stroke(Color.oaiSecondary.opacity(0.3), lineWidth: 1)
)
}
}
}
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color.oaiSecondary.opacity(0.3), lineWidth: 1)
)
)
}
private func alignmentFor(_ textAlignment: TextAlignment) -> Alignment {
switch textAlignment {
case .leading: return .leading
case .center: return .center
case .trailing: return .trailing
}
}
private func parseTable(_ content: String) -> TableData {
let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty }
guard lines.count >= 2 else {
return TableData(headers: [], alignments: [], rows: [])
}
// Parse headers (first line)
let headers = lines[0].components(separatedBy: "|")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
// Parse alignment row (second line with dashes)
let alignmentLine = lines[1]
let alignments = alignmentLine.components(separatedBy: "|")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
.map { cell -> TextAlignment in
let trimmed = cell.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix(":") && trimmed.hasSuffix(":") {
return .center
} else if trimmed.hasSuffix(":") {
return .trailing
} else {
return .leading
}
}
// Parse data rows (remaining lines)
let rows = lines.dropFirst(2).map { line in
line.components(separatedBy: "|")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
}
return TableData(headers: headers, alignments: alignments, rows: rows)
}
}
// MARK: - Code Block View
struct CodeBlockView: View {
let language: String?
let code: String
let fontSize: Double
@State private var copied = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header bar with language label and copy button
HStack {
if let lang = language, !lang.isEmpty {
Text(lang)
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(Color(hex: "#888888"))
}
Spacer()
Button(action: copyCode) {
HStack(spacing: 4) {
Image(systemName: copied ? "checkmark" : "doc.on.doc")
.font(.system(size: 11))
if copied {
Text("Copied!")
.font(.system(size: 11))
}
}
.foregroundColor(copied ? .green : Color(hex: "#888888"))
}
.buttonStyle(.plain)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color(hex: "#2d2d2d"))
// Code content
ScrollView(.horizontal, showsIndicators: true) {
Text(SyntaxHighlighter.highlight(code: code, language: language))
.font(.system(size: fontSize - 1, design: .monospaced))
.textSelection(.enabled)
.padding(12)
}
}
.background(Color(hex: "#1e1e1e"))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color(hex: "#3e3e3e"), lineWidth: 1)
)
}
private func copyCode() {
#if os(macOS)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(code, forType: .string)
#endif
withAnimation {
copied = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation {
copied = false
}
}
}
}