Files
hapi/ios/Packages/HapiKit/Sources/HapiUI/Theme/HapiTypography.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.5 KiB
Swift

import SwiftUI
/// Resolved once at the presentation root; never scale these values again.
/// Separate from color palettes so light, dark and OLED have identical metrics.
public struct HapiTypography: Equatable, Sendable {
public var bodyScale: CGFloat
public var codeScale: CGFloat
public var captionScale: CGFloat
public var boldText: Bool
public init(bodyScale: CGFloat = 1, codeScale: CGFloat = 1,
captionScale: CGFloat = 1, boldText: Bool = false) {
self.bodyScale = bodyScale
self.codeScale = codeScale
self.captionScale = captionScale
self.boldText = boldText
}
public var bodySize: CGFloat { 16 * bodyScale }
public var codeSize: CGFloat { 14 * codeScale }
public var captionSize: CGFloat { 12 * captionScale }
public var inlineCodeSize: CGFloat { 15 * bodyScale }
public var bodyLineSpacing: CGFloat { 3 * bodyScale }
public var codeLineSpacing: CGFloat { 2 * codeScale }
public var bodyFont: Font { .system(size: bodySize, weight: regularWeight) }
public var codeFont: Font { .system(size: codeSize, weight: regularWeight, design: .monospaced) }
public var captionFont: Font { .system(size: captionSize, weight: regularWeight) }
public var inlineCodeFont: Font { .system(size: inlineCodeSize, weight: regularWeight, design: .monospaced) }
public var captionMonoFont: Font { .system(size: captionSize, weight: regularWeight, design: .monospaced) }
private var regularWeight: Font.Weight { boldText ? .semibold : .regular }
public func headingSize(_ level: Int) -> CGFloat {
let base: CGFloat = switch level {
case 1: 24
case 2: 20
case 3: 18
default: 17
}
return base * bodyScale
}
public func headingFont(_ level: Int) -> Font {
.system(size: headingSize(level), weight: boldText ? .bold : .semibold)
}
}
private struct HapiTypographyKey: EnvironmentKey {
static let defaultValue = HapiTypography()
}
public extension EnvironmentValues {
var hapiTypography: HapiTypography {
get { self[HapiTypographyKey.self] }
set { self[HapiTypographyKey.self] = newValue }
}
}
private struct HapiTypographyModifier: ViewModifier {
@ScaledMetric(relativeTo: .body) private var bodyScale: CGFloat = 1
@ScaledMetric(relativeTo: .footnote) private var codeScale: CGFloat = 1
@ScaledMetric(relativeTo: .caption) private var captionScale: CGFloat = 1
@Environment(\.legibilityWeight) private var legibilityWeight
func body(content: Content) -> some View {
content.environment(\.hapiTypography, HapiTypography(
bodyScale: bodyScale, codeScale: codeScale, captionScale: captionScale,
boldText: legibilityWeight == .bold
))
}
}
public enum HapiReadingLayout {
public static let horizontalInset: CGFloat = 16
public static let maximumWidth: CGFloat = 720
public static func contentWidth(in availableWidth: CGFloat) -> CGFloat {
max(1, min(maximumWidth, availableWidth - 2 * horizontalInset))
}
}
public extension View {
/// Install outside the chat list so hosted rows inherit resolved metrics.
func hapiTypography() -> some View { modifier(HapiTypographyModifier()) }
func hapiReadingColumn(horizontalInset: CGFloat = HapiReadingLayout.horizontalInset) -> some View {
frame(maxWidth: HapiReadingLayout.maximumWidth, alignment: .leading)
.frame(maxWidth: .infinity)
.padding(.horizontal, horizontalInset)
}
}