mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
fix(chat): scale rich chat content with the font-size slider (#68)
The chat font-size slider only set `\.dynamicTypeSize` on the chat
root, but ScarfFont tokens are fixed-point (`Font.system(size: 14, …)`)
so dynamic type didn't reach bubble text, reasoning, tool chips, code
blocks, or markdown headings. Slider moved between 85%–130% with
little visible effect.
Plumb a separate `\.chatFontScale: Double` env value from
`RichChatView` and have the chat content views read it:
- `RichMessageBubble` — user bubble body, reasoning (disclosure +
inline), REASONING label, token chip, tool-chip name, metadata
footer.
- `MarkdownContentView` — paragraphs (now pinned to a scaled body
font instead of inheriting), headings (1..5), inline-rendered code
blocks, code-language label.
- `CodeBlockView` — code body and language label.
`ChatFontScale.{body, callout, caption, captionStrong, caption2,
mono, monoSmall, codeBlock, codeInline}(_ scale:)` helpers mirror
`ScarfFont`'s base sizes so scale = 1.0 is byte-for-byte identical
to today's UI; the slider now actually moves the visible chat text.
Other surfaces (settings, sidebar, etc.) still use the static
ScarfFont tokens — chat scaling stays scoped to the chat surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,12 +3,22 @@ import SwiftUI
|
||||
struct MarkdownContentView: View {
|
||||
let content: String
|
||||
|
||||
/// Chat font scale plumbed from `RichChatView` (issue #68). Defaults
|
||||
/// to 1.0 when this view is used outside the chat surface so other
|
||||
/// callers see the un-scaled rendering.
|
||||
@Environment(\.chatFontScale) private var chatFontScale: Double
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(Array(parseBlocks().enumerated()), id: \.offset) { _, block in
|
||||
blockView(block)
|
||||
}
|
||||
}
|
||||
// Paragraphs are rendered as plain `Text(AttributedString)` and
|
||||
// inherit whatever font is set on the enclosing scope. Pin the
|
||||
// scope to the scaled body font so the chat slider actually
|
||||
// moves the visible text.
|
||||
.font(ChatFontScale.body(chatFontScale))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -37,15 +47,19 @@ struct MarkdownContentView: View {
|
||||
// MARK: - Block Views
|
||||
|
||||
private func headingView(level: Int, text: String) -> some View {
|
||||
let font: Font = switch level {
|
||||
case 1: .title.bold()
|
||||
case 2: .title2.bold()
|
||||
case 3: .title3.bold()
|
||||
case 4: .headline
|
||||
default: .subheadline.bold()
|
||||
// Heading sizes scale with `chatFontScale` (issue #68). Bases
|
||||
// mirror the SwiftUI semantic tokens we used previously
|
||||
// (`.title` ≈ 28, `.title2` ≈ 22, `.title3` ≈ 20, `.headline`
|
||||
// ≈ 17, `.subheadline` ≈ 15) so 100% matches today's UI.
|
||||
let baseSize: CGFloat = switch level {
|
||||
case 1: 28
|
||||
case 2: 22
|
||||
case 3: 20
|
||||
case 4: 17
|
||||
default: 15
|
||||
}
|
||||
return Text(MarkdownRenderer.inlineAttributedString(text))
|
||||
.font(font)
|
||||
.font(.system(size: baseSize * chatFontScale, weight: .semibold))
|
||||
.textSelection(.enabled)
|
||||
.padding(.top, level <= 2 ? 8 : 4)
|
||||
}
|
||||
@@ -54,11 +68,11 @@ struct MarkdownContentView: View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if let lang = language, !lang.isEmpty {
|
||||
Text(lang)
|
||||
.font(.caption2.bold())
|
||||
.font(ChatFontScale.caption2(chatFontScale).bold())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(code)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.font(ChatFontScale.codeInline(chatFontScale))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
@@ -106,4 +106,74 @@ enum ChatFontScale {
|
||||
let pct = Int((scale * 100).rounded())
|
||||
return "\(pct)%"
|
||||
}
|
||||
|
||||
// MARK: - Scaled font helpers
|
||||
//
|
||||
// ScarfFont's tokens are fixed-point (`Font.system(size: 14, …)`),
|
||||
// so `.environment(\.dynamicTypeSize, …)` doesn't reach them — the
|
||||
// Mac chat slider had no visible effect on bubbles, reasoning,
|
||||
// tool chips, or code blocks (issue #68). These helpers mirror the
|
||||
// ScarfFont base sizes, multiplied by the user's chat scale, and
|
||||
// are used by `RichMessageBubble`, `MarkdownContentView`, and
|
||||
// `CodeBlockView` in place of the static tokens. At scale = 1.0
|
||||
// they're byte-for-byte identical to ScarfFont so the default UI
|
||||
// is unchanged.
|
||||
|
||||
static func body(_ scale: Double) -> Font {
|
||||
.system(size: 14 * scale, weight: .regular)
|
||||
}
|
||||
|
||||
static func bodyEmph(_ scale: Double) -> Font {
|
||||
.system(size: 14 * scale, weight: .medium)
|
||||
}
|
||||
|
||||
static func callout(_ scale: Double) -> Font {
|
||||
.system(size: 15 * scale, weight: .regular)
|
||||
}
|
||||
|
||||
static func caption(_ scale: Double) -> Font {
|
||||
.system(size: 12 * scale, weight: .regular)
|
||||
}
|
||||
|
||||
static func captionStrong(_ scale: Double) -> Font {
|
||||
.system(size: 12 * scale, weight: .semibold)
|
||||
}
|
||||
|
||||
static func caption2(_ scale: Double) -> Font {
|
||||
.system(size: 10 * scale, weight: .medium)
|
||||
}
|
||||
|
||||
static func mono(_ scale: Double) -> Font {
|
||||
.system(size: 13 * scale, weight: .regular, design: .monospaced)
|
||||
}
|
||||
|
||||
static func monoSmall(_ scale: Double) -> Font {
|
||||
.system(size: 12 * scale, weight: .regular, design: .monospaced)
|
||||
}
|
||||
|
||||
/// Code-block body — matches `CodeBlockView`'s 12pt mono.
|
||||
static func codeBlock(_ scale: Double) -> Font {
|
||||
.system(size: 12 * scale, weight: .regular, design: .monospaced)
|
||||
}
|
||||
|
||||
/// Inline code in markdown paragraphs — `.callout` (15pt) mono.
|
||||
static func codeInline(_ scale: Double) -> Font {
|
||||
.system(size: 15 * scale, weight: .regular, design: .monospaced)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Environment plumbing
|
||||
|
||||
private struct ChatFontScaleKey: EnvironmentKey {
|
||||
static let defaultValue: Double = ChatFontScale.default
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// Multiplier applied to chat content fonts. Set once on
|
||||
/// `RichChatView`'s root so message bubbles, markdown paragraphs,
|
||||
/// and code blocks scale together. Default 1.0 = today's UI.
|
||||
var chatFontScale: Double {
|
||||
get { self[ChatFontScaleKey.self] }
|
||||
set { self[ChatFontScaleKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,16 @@ struct CodeBlockView: View {
|
||||
|
||||
@State private var copied = false
|
||||
|
||||
/// Chat font scale plumbed from `RichChatView` (issue #68). Defaults
|
||||
/// to 1.0 outside the chat surface.
|
||||
@Environment(\.chatFontScale) private var chatFontScale: Double
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if let language, !language.isEmpty {
|
||||
HStack {
|
||||
Text(language)
|
||||
.font(.caption2.bold())
|
||||
.font(ChatFontScale.caption2(chatFontScale).bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
copyButton
|
||||
@@ -31,7 +35,7 @@ struct CodeBlockView: View {
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
Text(code)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.font(ChatFontScale.codeBlock(chatFontScale))
|
||||
.foregroundStyle(Color(nsColor: NSColor(red: 0.85, green: 0.87, blue: 0.91, alpha: 1.0)))
|
||||
.textSelection(.enabled)
|
||||
.padding(.horizontal, 10)
|
||||
|
||||
@@ -64,6 +64,11 @@ struct RichChatView: View {
|
||||
}
|
||||
.frame(minHeight: 0, idealHeight: 500, maxHeight: .infinity)
|
||||
.environment(\.dynamicTypeSize, ChatFontScale.dynamicTypeSize(for: fontScale))
|
||||
// ScarfFont tokens are fixed-point so dynamicTypeSize alone
|
||||
// doesn't move bubble / markdown / code-block text. Plumb the
|
||||
// raw scale via `\.chatFontScale` so chat content views can
|
||||
// read it and scale their explicit sizes too (issue #68).
|
||||
.environment(\.chatFontScale, fontScale)
|
||||
// Animate side-pane shows/hides so the transcript reflows
|
||||
// smoothly rather than snapping. ~180ms feels responsive
|
||||
// without being jarring.
|
||||
|
||||
@@ -14,6 +14,11 @@ struct RichMessageBubble: View, Equatable {
|
||||
|
||||
@Environment(ChatViewModel.self) private var chatViewModel
|
||||
|
||||
/// Chat-only font scale set on `RichChatView`. Chat content uses
|
||||
/// these multiplied sizes (issue #68); other surfaces still see
|
||||
/// the static ScarfFont tokens at scale = 1.0.
|
||||
@Environment(\.chatFontScale) private var chatFontScale: Double
|
||||
|
||||
/// Scarf-local chat density preferences (issues #47 / #48). All
|
||||
/// three default to today's UI. Read here so the reasoning + tool-
|
||||
/// call switches don't have to thread the values through every
|
||||
@@ -68,7 +73,7 @@ struct RichMessageBubble: View, Equatable {
|
||||
HStack {
|
||||
Spacer(minLength: 80)
|
||||
Text(message.content)
|
||||
.scarfStyle(.body)
|
||||
.font(ChatFontScale.body(chatFontScale))
|
||||
.foregroundStyle(ScarfColor.onAccent)
|
||||
.textSelection(.enabled)
|
||||
.padding(.horizontal, 14)
|
||||
@@ -91,7 +96,7 @@ struct RichMessageBubble: View, Equatable {
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(ScarfColor.success)
|
||||
Text(time, style: .time)
|
||||
.font(ScarfFont.caption2)
|
||||
.font(ChatFontScale.caption2(chatFontScale))
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
}
|
||||
.padding(.trailing, 4)
|
||||
@@ -183,7 +188,7 @@ struct RichMessageBubble: View, Equatable {
|
||||
private var reasoningDisclosure: some View {
|
||||
DisclosureGroup {
|
||||
Text(message.preferredReasoning ?? "")
|
||||
.font(ScarfFont.monoSmall)
|
||||
.font(ChatFontScale.monoSmall(chatFontScale))
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.italic()
|
||||
.textSelection(.enabled)
|
||||
@@ -194,11 +199,11 @@ struct RichMessageBubble: View, Equatable {
|
||||
Image(systemName: "brain")
|
||||
.font(.system(size: 11))
|
||||
Text("REASONING")
|
||||
.scarfStyle(.captionStrong)
|
||||
.font(ChatFontScale.captionStrong(chatFontScale))
|
||||
.tracking(0.5)
|
||||
if let tokens = message.tokenCount, tokens > 0 {
|
||||
Text("· \(tokens) tok")
|
||||
.font(ScarfFont.monoSmall)
|
||||
.font(ChatFontScale.monoSmall(chatFontScale))
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
}
|
||||
}
|
||||
@@ -222,7 +227,7 @@ struct RichMessageBubble: View, Equatable {
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(ScarfColor.warning)
|
||||
Text(message.preferredReasoning ?? "")
|
||||
.font(ScarfFont.caption)
|
||||
.font(ChatFontScale.caption(chatFontScale))
|
||||
.italic()
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
.textSelection(.enabled)
|
||||
@@ -281,7 +286,7 @@ struct RichMessageBubble: View, Equatable {
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(color)
|
||||
Text(call.functionName)
|
||||
.font(ScarfFont.monoSmall)
|
||||
.font(ChatFontScale.monoSmall(chatFontScale))
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
.lineLimit(1)
|
||||
@@ -341,25 +346,26 @@ struct RichMessageBubble: View, Equatable {
|
||||
HStack(spacing: 8) {
|
||||
if let tokens = message.tokenCount, tokens > 0 {
|
||||
Text("\(tokens) tok")
|
||||
.font(ScarfFont.monoSmall)
|
||||
.font(ChatFontScale.monoSmall(chatFontScale))
|
||||
}
|
||||
if let reason = message.finishReason, !reason.isEmpty {
|
||||
Text("·")
|
||||
Text(reason)
|
||||
.scarfStyle(.caption)
|
||||
.font(ChatFontScale.caption(chatFontScale))
|
||||
}
|
||||
if let time = message.timestamp {
|
||||
Text("·")
|
||||
Text(time, style: .time)
|
||||
.scarfStyle(.caption)
|
||||
.font(ChatFontScale.caption(chatFontScale))
|
||||
}
|
||||
if let seconds = turnDuration {
|
||||
Text("·")
|
||||
Text(RichChatViewModel.formatTurnDuration(seconds))
|
||||
.font(ScarfFont.monoSmall)
|
||||
.font(ChatFontScale.monoSmall(chatFontScale))
|
||||
.help("Wall-clock duration of this turn")
|
||||
}
|
||||
}
|
||||
.font(ChatFontScale.caption(chatFontScale))
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user