diff --git a/apps/android/app/src/main/java/com/litter/android/ui/LitterThemeManager.kt b/apps/android/app/src/main/java/com/litter/android/ui/LitterThemeManager.kt index d76af0a8d..35debba89 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/LitterThemeManager.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/LitterThemeManager.kt @@ -19,6 +19,8 @@ private const val APPEARANCE_MODE_KEY = "appearance_mode" private const val DARK_MODE_KEY = "dark_mode_enabled" private const val FONT_MONO_KEY = "font_family_mono" private const val FONT_FAMILY_KEY = "font_family" +private const val DEFAULT_LIGHT_THEME_SLUG = "studio-light" +private const val DEFAULT_DARK_THEME_SLUG = "studio-dark" enum class LitterAppearanceMode( val storageValue: String, @@ -46,7 +48,7 @@ enum class LitterFontFamilyOption( val displayName: String, ) { BERKELEY_MONO("mono", "Berkeley Mono"), - CHATGPT("system", "ChatGPT (System)"), + CHATGPT("system", "System UI"), SYSTEM_MONO("system-mono", "System Mono"), SERIF("serif", "Reader Serif"); @@ -279,10 +281,10 @@ object LitterThemeManager { get() = themeIndex.filter { it.type == LitterColorThemeType.DARK } val selectedLightSlug: String - get() = preferences?.getString(SELECTED_LIGHT_THEME_KEY, null) ?: "codex-light" + get() = preferences?.getString(SELECTED_LIGHT_THEME_KEY, null) ?: DEFAULT_LIGHT_THEME_SLUG val selectedDarkSlug: String - get() = preferences?.getString(SELECTED_DARK_THEME_KEY, null) ?: "chatgpt-dark" + get() = preferences?.getString(SELECTED_DARK_THEME_KEY, null) ?: DEFAULT_DARK_THEME_SLUG private val preferences get() = appContext?.getSharedPreferences(UI_PREFERENCES_NAME, Context.MODE_PRIVATE) diff --git a/apps/android/app/src/main/java/com/litter/android/ui/conversation/SelectableConversationText.kt b/apps/android/app/src/main/java/com/litter/android/ui/conversation/SelectableConversationText.kt index 96714de28..df7005d91 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/conversation/SelectableConversationText.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/conversation/SelectableConversationText.kt @@ -69,12 +69,24 @@ internal fun SelectableMarkdownText( LitterFontFamilyOption.SERIF -> android.graphics.Typeface.SERIF } } + val codeTypeface = remember(context, selectedFontFamily) { + if (selectedFontFamily == LitterFontFamilyOption.BERKELEY_MONO) { + runCatching { + androidx.core.content.res.ResourcesCompat.getFont( + context, + com.sigkitten.litter.android.R.font.berkeley_mono_regular, + ) + }.getOrNull() ?: android.graphics.Typeface.MONOSPACE + } else { + android.graphics.Typeface.MONOSPACE + } + } val markdownTextSizePx = remember(context, resolvedTextSize, usePhysicalDpTextSize) { resolvedTextSize.toTextSizePx(context, usePhysicalDpTextSize) } val markwon = rememberConversationMarkwon( context = context, - typeface = typeface, + codeTypeface = codeTypeface, markdownTextSizePx = markdownTextSizePx, textColor = textColor, ) @@ -110,6 +122,7 @@ internal fun SelectableMarkdownText( textColor = textColor, textSizePx = markdownTextSizePx, typeface = typeface, + codeTypeface = codeTypeface, ) if (tv.tag != renderTag) { tv.tag = renderTag @@ -125,6 +138,7 @@ private data class MarkdownRenderTag( val textColor: Int, val textSizePx: Float, val typeface: android.graphics.Typeface?, + val codeTypeface: android.graphics.Typeface?, ) internal fun configureSelectableMarkdownTextView( @@ -148,6 +162,7 @@ internal fun configureSelectableMarkdownTextView( textView.movementMethod = LinkMovementMethod.getInstance() textView.setLinkTextColor(linkColor) textView.setTextIsSelectable(selectable) + textView.setLineSpacing(0f, 1.32f) textView.customSelectionActionModeCallback = if (selectable) { RunInTerminalSelectionMenu(textView) } else { @@ -216,16 +231,20 @@ private class RunInTerminalSelectionMenu( @Composable private fun rememberConversationMarkwon( context: android.content.Context, - typeface: android.graphics.Typeface?, + codeTypeface: android.graphics.Typeface?, markdownTextSizePx: Float, textColor: Int, -): Markwon = remember(context, typeface, markdownTextSizePx, textColor) { +): Markwon = remember(context, codeTypeface, markdownTextSizePx, textColor) { try { val prism4j = Prism4j(com.litter.android.ui.Prism4jGrammarLocator()) Markwon.builder(context) .usePlugin(object : AbstractMarkwonPlugin() { override fun configureTheme(builder: MarkwonTheme.Builder) { - typeface?.let { builder.codeTypeface(it) } + codeTypeface?.let { builder.codeTypeface(it) } + codeTypeface?.let { builder.codeBlockTypeface(it) } + val codeTextSize = markdownTextSizePx.times(0.94f).toInt() + builder.codeTextSize(codeTextSize) + builder.codeBlockTextSize(codeTextSize) } }) .usePlugin( diff --git a/apps/ios/Sources/Litter/Extensions.swift b/apps/ios/Sources/Litter/Extensions.swift index b615df8f3..63f880bc0 100644 --- a/apps/ios/Sources/Litter/Extensions.swift +++ b/apps/ios/Sources/Litter/Extensions.swift @@ -122,7 +122,7 @@ enum FontFamilyOption: String, CaseIterable, Identifiable { var displayName: String { switch self { case .mono: return "Berkeley Mono" - case .system: return "ChatGPT (System)" + case .system: return "System UI" case .systemMono: return "System Mono" case .serif: return "Reader Serif" } @@ -311,6 +311,13 @@ enum LitterFont { max(UIFont.preferredFont(forTextStyle: .body).pointSize - 1, 15) } + static var conversationCodePointSize: CGFloat { + // Code is supporting material in a conversation. A one-step smaller + // monospaced face keeps prose as the primary reading surface without + // making commands or snippets hard to inspect. + max(UIFont.preferredFont(forTextStyle: .callout).pointSize - 1, 14) + } + static var conversationDiffPointSize: CGFloat { UIFont.preferredFont(forTextStyle: .caption1).pointSize } diff --git a/apps/ios/Sources/Litter/Models/ThemeManager.swift b/apps/ios/Sources/Litter/Models/ThemeManager.swift index 8adb8a137..60a2d2730 100644 --- a/apps/ios/Sources/Litter/Models/ThemeManager.swift +++ b/apps/ios/Sources/Litter/Models/ThemeManager.swift @@ -68,6 +68,8 @@ final class ThemeManager { private static let appGroupSuite = LitterPalette.appGroupSuite private static let appearanceModeKey = "appearanceMode" + private static let defaultLightThemeSlug = "studio-light" + private static let defaultDarkThemeSlug = "studio-dark" private(set) var lightTheme: ResolvedTheme = .defaultLight private(set) var darkTheme: ResolvedTheme = .defaultDark @@ -77,12 +79,12 @@ final class ThemeManager { private var systemColorScheme: ColorScheme = .dark var selectedLightSlug: String { - get { UserDefaults.standard.string(forKey: "selectedLightTheme") ?? "codex-light" } + get { UserDefaults.standard.string(forKey: "selectedLightTheme") ?? Self.defaultLightThemeSlug } set { UserDefaults.standard.set(newValue, forKey: "selectedLightTheme") } } var selectedDarkSlug: String { - get { UserDefaults.standard.string(forKey: "selectedDarkTheme") ?? "chatgpt-dark" } + get { UserDefaults.standard.string(forKey: "selectedDarkTheme") ?? Self.defaultDarkThemeSlug } set { UserDefaults.standard.set(newValue, forKey: "selectedDarkTheme") } } diff --git a/apps/ios/Sources/Litter/Views/CodeBlockView.swift b/apps/ios/Sources/Litter/Views/CodeBlockView.swift index 5fb82a389..b6c0e7b23 100644 --- a/apps/ios/Sources/Litter/Views/CodeBlockView.swift +++ b/apps/ios/Sources/Litter/Views/CodeBlockView.swift @@ -3,7 +3,7 @@ import SwiftUI struct CodeBlockView: View { let language: String let code: String - var fontSize: CGFloat = LitterFont.conversationBodyPointSize + var fontSize: CGFloat = LitterFont.conversationCodePointSize var body: some View { ScrollView(.horizontal, showsIndicators: false) { @@ -20,8 +20,12 @@ struct CodeBlockView: View { .litterMonoFont(size: fontSize) .foregroundColor(LitterTheme.textBody) .textSelection(.enabled) + // Let the text keep its natural width inside the horizontal + // scroller. A max-width frame here asks SwiftUI to squeeze + // long source lines, which produces ellipses instead of a + // scrollable code block. + .fixedSize(horizontal: true, vertical: true) .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) } } .background(LitterTheme.codeBackground.opacity(0.8)) diff --git a/apps/ios/Sources/Litter/Views/ConversationDisplayUITestHarnessView.swift b/apps/ios/Sources/Litter/Views/ConversationDisplayUITestHarnessView.swift index 5a0a31160..fa8ebc84f 100644 --- a/apps/ios/Sources/Litter/Views/ConversationDisplayUITestHarnessView.swift +++ b/apps/ios/Sources/Litter/Views/ConversationDisplayUITestHarnessView.swift @@ -126,7 +126,16 @@ struct ConversationDisplayUITestHarnessView: View { ConversationItem( id: "ui-test-assistant", content: .assistant(ConversationAssistantMessageData( - text: "UITEST_ASSISTANT_MESSAGE", + text: """ + ## UITEST_ASSISTANT_MESSAGE + + Here is a short answer with `inline code`, readable prose, and a second paragraph so typography and spacing are visible in screenshots. + + ```swift + let greeting = "UITEST_CODE_BLOCK" + print(greeting) + ``` + """, agentNickname: nil, agentRole: nil, phase: nil diff --git a/apps/ios/Sources/Litter/Views/MessageBubbleView.swift b/apps/ios/Sources/Litter/Views/MessageBubbleView.swift index d9778c368..3749d0289 100644 --- a/apps/ios/Sources/Litter/Views/MessageBubbleView.swift +++ b/apps/ios/Sources/Litter/Views/MessageBubbleView.swift @@ -46,7 +46,7 @@ struct LitterMarkdownView: View { let markdown: String var style: LitterMarkdownStyleVariant = .content var bodySize: CGFloat = LitterFont.conversationBodyPointSize - var codeSize: CGFloat = LitterFont.conversationBodyPointSize + var codeSize: CGFloat = LitterFont.conversationCodePointSize var selectionEnabled = true @State private var debugSettings = DebugSettings.shared @@ -99,7 +99,7 @@ struct InlineSelectableMarkdownMessage: View { let markdown: String var style: LitterMarkdownStyleVariant = .content var bodySize: CGFloat = LitterFont.conversationBodyPointSize - var codeSize: CGFloat = LitterFont.conversationBodyPointSize + var codeSize: CGFloat = LitterFont.conversationCodePointSize @ViewBuilder let content: () -> Content var body: some View { @@ -292,7 +292,7 @@ struct AssistantBubble: View, Equatable { markdown: markdownString, style: .content, bodySize: contentFontSize, - codeSize: contentFontSize + codeSize: LitterFont.conversationCodePointSize ) { bubbleContent } @@ -316,7 +316,7 @@ struct AssistantBubble: View, Equatable { markdown: markdownString, style: .content, bodySize: contentFontSize, - codeSize: contentFontSize + codeSize: LitterFont.conversationCodePointSize ) .fixedSize(horizontal: false, vertical: true) .transaction { $0.animation = nil } @@ -361,7 +361,7 @@ struct AssistantBlocksBubble: View { markdown: content, style: .content, bodySize: contentFontSize, - codeSize: contentFontSize + codeSize: LitterFont.conversationCodePointSize ) .frame(maxWidth: .infinity, alignment: .leading) .id(identity) @@ -374,7 +374,7 @@ struct AssistantBlocksBubble: View { CodeBlockView( language: language ?? "", code: code, - fontSize: contentFontSize + fontSize: LitterFont.conversationCodePointSize ) .id(identity) } @@ -406,7 +406,7 @@ private struct LitterMathBlockView: View { var body: some View { ScrollView(.horizontal, showsIndicators: true) { LatexBlockView(content: latex) - .fixedSize(horizontal: true, vertical: false) + .fixedSize(horizontal: true, vertical: true) } .frame(maxWidth: .infinity, alignment: .leading) } @@ -497,7 +497,7 @@ struct StreamingAssistantBubble: View { .revealGranularity(typingConfig.effectiveGranularity) .litterContentMarkdown( bodySize: contentFontSize, - codeSize: contentFontSize, + codeSize: LitterFont.conversationCodePointSize, selectionEnabled: !isStreaming ) .transaction { $0.animation = nil } @@ -506,7 +506,7 @@ struct StreamingAssistantBubble: View { markdown: text, style: .content, bodySize: contentFontSize, - codeSize: contentFontSize + codeSize: LitterFont.conversationCodePointSize ) .fixedSize(horizontal: false, vertical: true) .tokenReveal(.disabled) @@ -529,8 +529,12 @@ private func litterContentTheme(bodySize: CGFloat, codeSize: CGFloat) -> Markdow // primary foreground rather than the muted metadata color so long replies // retain contrast on dark themes. theme.foregroundColor = LitterTheme.textPrimary - theme.paragraphSpacing = 8 - theme.blockSpacing = 8 + // Hairball's default 1.6 multiplier reads overly airy in a long mobile + // transcript. Keep enough leading for scanning while making consecutive + // paragraphs feel like one answer rather than isolated cards. + theme.lineSpacing = 1.36 + theme.paragraphSpacing = 10 + theme.blockSpacing = 10 theme.headingStyleSet = HeadingStyleSet( h1: HeadingStyle(font: LitterFont.markdownHeadingFont(size: bodySize * 1.43, weight: .bold), fontSize: bodySize * 1.43, weight: .bold, @@ -603,8 +607,9 @@ private func litterSystemTheme(bodySize: CGFloat, codeSize: CGFloat) -> Markdown theme.bodyFont = LitterFont.markdownBodyFont(size: bodySize) theme.bodyFontSize = bodySize theme.foregroundColor = LitterTheme.textSystem - theme.paragraphSpacing = 6 - theme.blockSpacing = 6 + theme.lineSpacing = 1.32 + theme.paragraphSpacing = 8 + theme.blockSpacing = 8 theme.headingStyleSet = HeadingStyleSet( h1: HeadingStyle(font: LitterFont.markdownHeadingFont(size: bodySize * 1.31, weight: .bold), fontSize: bodySize * 1.31, weight: .bold, @@ -704,7 +709,29 @@ struct LitterCodeBlockRenderer: CodeBlockRenderer { .modifier(GlassRectModifier(cornerRadius: 8)) .modifier(CodeBlockTerminalContextMenu(code: configuration.code)) } else { - DefaultCodeBlockRenderer().makeBody(configuration: configuration) + VStack(alignment: .leading, spacing: 0) { + if configuration.hasLanguage { + HStack { + Text(configuration.languageDisplayName) + .litterMonoFont(size: 11, weight: .semibold) + .foregroundColor(LitterTheme.textSecondary) + Spacer() + } + .padding(.horizontal, 12) + .padding(.top, 8) + .padding(.bottom, 4) + } + + ScrollView(.horizontal, showsIndicators: false) { + Text(configuration.highlightedCode) + .font(configuration.theme.codeBlock.font) + .foregroundColor(configuration.theme.codeBlock.textColor) + .fixedSize(horizontal: true, vertical: true) + .padding(configuration.theme.codeBlock.padding) + } + } + .background(configuration.theme.codeBlock.backgroundColor) + .clipShape(RoundedRectangle(cornerRadius: configuration.theme.codeBlock.cornerRadius)) .modifier(GlassRectModifier(cornerRadius: 8)) .modifier(CodeBlockTerminalContextMenu(code: configuration.code)) } @@ -895,7 +922,7 @@ private struct ScaledSystemMarkdownModifier: ViewModifier { extension View { func litterContentMarkdown( bodySize: CGFloat = LitterFont.conversationBodyPointSize, - codeSize: CGFloat = LitterFont.conversationBodyPointSize, + codeSize: CGFloat = LitterFont.conversationCodePointSize, selectionEnabled: Bool = true ) -> some View { modifier( @@ -909,7 +936,7 @@ extension View { func litterSystemMarkdown( bodySize: CGFloat = LitterFont.conversationBodyPointSize, - codeSize: CGFloat = LitterFont.conversationBodyPointSize, + codeSize: CGFloat = LitterFont.conversationCodePointSize, selectionEnabled: Bool = true ) -> some View { modifier( diff --git a/apps/ios/Tests/LitterTests/LitterAppearanceModeTests.swift b/apps/ios/Tests/LitterTests/LitterAppearanceModeTests.swift index bfa5ae61a..1b9133369 100644 --- a/apps/ios/Tests/LitterTests/LitterAppearanceModeTests.swift +++ b/apps/ios/Tests/LitterTests/LitterAppearanceModeTests.swift @@ -34,12 +34,12 @@ final class FontFamilyOptionTests: XCTestCase { ]) } - func testChatGPTAndReaderChoicesUseProportionalFamilies() { + func testSystemAndReaderChoicesUseProportionalFamilies() { XCTAssertFalse(FontFamilyOption.system.isMono) XCTAssertFalse(FontFamilyOption.serif.isMono) XCTAssertTrue(FontFamilyOption.mono.isMono) XCTAssertTrue(FontFamilyOption.systemMono.isMono) - XCTAssertEqual(FontFamilyOption.system.displayName, "ChatGPT (System)") + XCTAssertEqual(FontFamilyOption.system.displayName, "System UI") } func testFontPreferenceObserverAdvancesRevision() { diff --git a/apps/ios/Tests/LitterUITests/LitterUITests.swift b/apps/ios/Tests/LitterUITests/LitterUITests.swift index 915c0cea4..4e0d2b01a 100644 --- a/apps/ios/Tests/LitterUITests/LitterUITests.swift +++ b/apps/ios/Tests/LitterUITests/LitterUITests.swift @@ -25,6 +25,7 @@ final class LitterUITests: XCTestCase { XCTAssertTrue(app.staticTexts["UITEST_USER_MESSAGE"].waitForExistence(timeout: 10)) XCTAssertTrue(app.staticTexts["UITEST_ASSISTANT_MESSAGE"].exists) + XCTAssertTrue(app.staticTexts["UITEST_CODE_BLOCK"].exists) XCTAssertTrue(app.staticTexts["UITEST_REASONING_DETAIL"].exists) XCTAssertTrue(app.staticTexts["UITEST_COMMAND_OUTPUT"].exists) XCTAssertTrue(app.staticTexts["UITEST_TOOL_DETAIL"].exists)