Files
hapi/ios/Packages/HapiKit/Sources/HapiUI/Code/CodeBlockView.swift
T
weishu 10042f79c6 feat(ios): refine chat typography and reading layout
Use unified 16pt Dynamic Type body text, clearer Markdown spacing, and a shared reading column. Preserve transcript anchors across typography changes and bound tool command previews to two lines. Add layout and typography regression tests.
2026-09-10 16:22:36 +08:00

92 lines
3.4 KiB
Swift

import SwiftUI
/// Fenced code block: rounded container with a header (language chip + copy
/// button) and horizontally scrollable monospaced body. Highlighting runs off
/// the main thread through the injected `SyntaxHighlighting` engine and is
/// applied when ready; until then (or when the engine declines) the code
/// renders as plain text.
///
/// Unsupported fence languages the web renders specially (mermaid, math)
/// deliberately fall back to this view per the v1 plan.
public struct CodeBlockView: View {
public let language: String?
public let code: String
@Environment(\.hapiTheme) private var theme
@Environment(\.hapiTypography) private var typography
@Environment(\.hapiSyntaxHighlighter) private var highlighter
@Environment(\.hapiPasteboard) private var pasteboard
@State private var highlighted: AttributedString?
@State private var showCopied = false
public init(language: String?, code: String) {
self.language = language
self.code = code
}
private var displayLanguage: String {
guard let language, !language.isEmpty else { return "text" }
return language.lowercased()
}
/// Recompute highlighting when content or palette changes.
private var highlightID: String {
"\(displayLanguage)|\(theme.isDark ? "d" : "l")|\(code.count)|\(code.hashValue)"
}
public var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
ScrollView(.horizontal, showsIndicators: false) {
Text(highlighted ?? AttributedString(code))
.font(typography.codeFont)
.lineSpacing(typography.codeLineSpacing)
.foregroundStyle(theme.textPrimary)
.textSelection(.enabled)
.padding(12)
}
}
.background(theme.codeBackground)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.task(id: highlightID) {
highlighted = await highlighter.highlightAsync(
code,
language: language,
dark: theme.isDark
)
}
}
private var header: some View {
HStack(spacing: 8) {
Text(displayLanguage)
.font(typography.captionMonoFont)
.foregroundStyle(theme.textSecondary)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 12)
Button {
// Explicit MainActor hop: keeps the @MainActor pasteboard
// call valid regardless of the SDK's Button-action isolation.
let pasteboard = pasteboard
let code = code
Task { @MainActor in
pasteboard.copy(code)
showCopied = true
try? await Task.sleep(nanoseconds: 1_500_000_000)
showCopied = false
}
} label: {
Image(systemName: showCopied ? "checkmark" : "doc.on.doc")
.font(typography.captionFont)
.frame(minWidth: 44, minHeight: 44)
.foregroundStyle(showCopied ? theme.success : theme.textSecondary)
}
.buttonStyle(.plain)
.accessibilityLabel(Text(showCopied ? "Copied" : "Copy code"))
}
.padding(.horizontal, 12)
.background(theme.codeHeaderBackground)
}
}