Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -110,6 +122,7 @@ internal fun SelectableMarkdownText(
textColor = textColor,
textSizePx = markdownTextSizePx,
typeface = typeface,
codeTypeface = codeTypeface,
)
if (tv.tag != renderTag) {
tv.tag = renderTag
Expand All @@ -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(
Expand All @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down
9 changes: 8 additions & 1 deletion apps/ios/Sources/Litter/Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down Expand Up @@ -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
}
Expand Down
6 changes: 4 additions & 2 deletions apps/ios/Sources/Litter/Models/ThemeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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") }
}

Expand Down
8 changes: 6 additions & 2 deletions apps/ios/Sources/Litter/Views/CodeBlockView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 43 additions & 16 deletions apps/ios/Sources/Litter/Views/MessageBubbleView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -99,7 +99,7 @@ struct InlineSelectableMarkdownMessage<Content: View>: 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 {
Expand Down Expand Up @@ -292,7 +292,7 @@ struct AssistantBubble: View, Equatable {
markdown: markdownString,
style: .content,
bodySize: contentFontSize,
codeSize: contentFontSize
codeSize: LitterFont.conversationCodePointSize
) {
bubbleContent
}
Expand All @@ -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 }
Expand Down Expand Up @@ -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)
Expand All @@ -374,7 +374,7 @@ struct AssistantBlocksBubble: View {
CodeBlockView(
language: language ?? "",
code: code,
fontSize: contentFontSize
fontSize: LitterFont.conversationCodePointSize
)
.id(identity)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -497,7 +497,7 @@ struct StreamingAssistantBubble: View {
.revealGranularity(typingConfig.effectiveGranularity)
.litterContentMarkdown(
bodySize: contentFontSize,
codeSize: contentFontSize,
codeSize: LitterFont.conversationCodePointSize,
selectionEnabled: !isStreaming
)
.transaction { $0.animation = nil }
Expand All @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions apps/ios/Tests/LitterTests/LitterAppearanceModeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions apps/ios/Tests/LitterUITests/LitterUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading