mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-09 19:29:41 +00:00
feat(ios): HapiUI rendering foundation — markdown, code, diff, theme (A-M2e)
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
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(\.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(theme.codeFont)
|
||||
.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(.system(size: theme.captionSize, design: .monospaced))
|
||||
.foregroundStyle(theme.textSecondary)
|
||||
.lineLimit(1)
|
||||
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(.system(size: theme.captionSize))
|
||||
.foregroundStyle(showCopied ? theme.success : theme.textSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text(showCopied ? "Copied" : "Copy code"))
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(theme.codeHeaderBackground)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user