diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 97fe5cf977acc..3d2fad198cb13 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -264,6 +264,7 @@ const config: ExpoConfig = { `applinks:${HOSTED_APP_HOST}`, ], entitlements: { + "com.apple.developer.user-fonts": ["app-usage"], "keychain-access-groups": [`$(AppIdentifierPrefix)${variant.iosBundleIdentifier}`], }, infoPlist: { diff --git a/apps/mobile/global.css b/apps/mobile/global.css index 7a4cd4eff5046..9886ef4a3cc1f 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -46,10 +46,12 @@ /* ─── Custom utilities ──────────────────────────────────────────────── */ @utility font-t3-medium { font-family: var(--font-medium); + font-weight: 500; } @utility font-t3-bold { font-family: var(--font-bold); + font-weight: 700; } @utility font-t3-extrabold { diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index 2dbbad891ae1f..f24b3f054820c 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -284,11 +284,11 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( } fun setFontFamily(fontFamily: String) { - editor.typeface = if (fontFamily.contains("Mono", ignoreCase = true)) { - Typeface.MONOSPACE - } else { - ReactFontManager.getInstance().getTypeface(fontFamily, Typeface.NORMAL, context.assets) - } + editor.typeface = ReactFontManager.getInstance().getTypeface( + fontFamily, + Typeface.NORMAL, + context.assets, + ) applyLineHeight() editor.applyPlaceholder() } diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index 87029ef29fdaf..ba18fec6c724c 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -1151,9 +1151,21 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro return nil } + private func resolvedComposerFont() -> UIFont { + if let font = UIFont(name: fontFamily, size: fontSize) { + return font + } + // Expo registers aliases through the family-name lookup, not UIFont(name:). + for name in UIFont.fontNames(forFamilyName: fontFamily) { + if let font = UIFont(name: name, size: fontSize) { + return font + } + } + return UIFont.systemFont(ofSize: fontSize) + } + private func baseAttributes() -> [NSAttributedString.Key: Any] { - let font = UIFont(name: fontFamily, size: fontSize) - ?? UIFont.systemFont(ofSize: fontSize) + let font = resolvedComposerFont() let paragraph = NSMutableParagraphStyle() paragraph.minimumLineHeight = lineHeight paragraph.maximumLineHeight = lineHeight @@ -1165,8 +1177,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } private func applyTypography() { - let font = UIFont(name: fontFamily, size: fontSize) - ?? UIFont.systemFont(ofSize: fontSize) + let font = resolvedComposerFont() textView.font = font restoreBaseTypingAttributes() placeholderLabel.font = font diff --git a/apps/mobile/modules/t3-native-controls/ios/T3InstalledFonts.swift b/apps/mobile/modules/t3-native-controls/ios/T3InstalledFonts.swift new file mode 100644 index 0000000000000..e1a47ba4e7f3d --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3InstalledFonts.swift @@ -0,0 +1,100 @@ +import CoreText +import UIKit + +@MainActor +enum T3InstalledFonts { + static func describe(_ font: UIFont, name: String? = nil) -> [String: Any] { + func variant(_ weight: UIFont.Weight) -> String { + let descriptor = UIFontDescriptor(fontAttributes: [ + .family: font.familyName, + .traits: [UIFontDescriptor.TraitKey.weight: weight], + ]) + let candidate = UIFont(descriptor: descriptor, size: font.pointSize) + return candidate.familyName == font.familyName ? candidate.fontName : font.fontName + } + // CoreText reports medium as 0.20 while UIKit requests 0.23. + var emphasisFaces: [String: String] = [:] + for (key, weight) in [("500", UIFont.Weight.medium), ("600", .semibold), ("700", .bold)] { + let name = variant(weight) + guard let candidate = UIFont(name: name, size: font.pointSize), + let traits = candidate.fontDescriptor.object(forKey: .traits) as? [UIFontDescriptor.TraitKey: Any], + let actual = traits[.weight] as? NSNumber, + abs(actual.doubleValue - Double(weight.rawValue)) < 0.04 else { continue } + emphasisFaces[key] = name + } + return [ + "name": name ?? font.fontName, + "label": font.familyName, + "regular": font.fontName, + "medium": variant(.medium), + "bold": variant(.bold), + "emphasisFaces": emphasisFaces, + ] + } + + static func resolve(_ names: [String], completion: @escaping ([[String: Any]]) -> Void) { + guard !names.isEmpty else { completion([]); return } + // Provider fonts must be requested again for each process. Persist names, + // not file URLs: iOS owns the files and the picker grants family access. + let descriptors = names.map { CTFontDescriptorCreateWithAttributes([kCTFontNameAttribute: $0] as CFDictionary) } + CTFontManagerRequestFonts(descriptors as CFArray) { _ in + DispatchQueue.main.async { + completion(names.compactMap { name in + guard let font = UIFont(name: name, size: 16) else { return nil } + return describe(font, name: name) + }) + } + } + } +} + +@MainActor +final class T3FontPickerPresentation: NSObject, UIFontPickerViewControllerDelegate, UIAdaptivePresentationControllerDelegate { + private let completion: ([String: Any]?) -> Void + private var picker: UIFontPickerViewController? + private var finished = false + + init(completion: @escaping ([String: Any]?) -> Void) { + self.completion = completion + } + + func present(from presenter: UIViewController, selectedName: String?) { + let configuration = UIFontPickerViewController.Configuration() + configuration.includeFaces = false + let picker = UIFontPickerViewController(configuration: configuration) + if let selectedName, let font = UIFont(name: selectedName, size: 16) { + picker.selectedFontDescriptor = font.fontDescriptor + } + picker.delegate = self + self.picker = picker + presenter.present(picker, animated: true) + picker.presentationController?.delegate = self + } + + func fontPickerViewControllerDidPickFont(_ viewController: UIFontPickerViewController) { + guard let descriptor = viewController.selectedFontDescriptor else { dismiss(); return } + let result = T3InstalledFonts.describe(UIFont(descriptor: descriptor, size: 16)) + viewController.dismiss(animated: true) { self.finish(result) } + } + + func fontPickerViewControllerDidCancel(_ viewController: UIFontPickerViewController) { + dismiss() + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + finish(nil) + } + + func dismiss() { + guard let picker else { finish(nil); return } + picker.dismiss(animated: true) { self.finish(nil) } + } + + private func finish(_ result: [String: Any]?) { + guard !finished else { return } + finished = true + picker?.delegate = nil + picker = nil + completion(result) + } +} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 5b3d84aedcee3..50fb0fcde2ce3 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -10,6 +10,7 @@ public final class T3NativeControlsModule: Module { private let presentationSources = T3PresentationSources() private var videoPresentation: T3NativeVideoPresentation? private var filePresentation: T3NativeFilePresentation? + private var fontPresentation: T3FontPickerPresentation? /// The last file preview that closed. Written on main, read synchronously from JS. private let filePreviewActivity = NSLock() private var finishedFilePreview: String? @@ -66,6 +67,29 @@ public final class T3NativeControlsModule: Module { try T3DpopKeys.sign(privateKey: privateKey, signingInput: signingInput) } + AsyncFunction("pickInstalledFont") { (selectedName: String?, promise: Promise) in + MainActor.assumeIsolated { + guard self.fontPresentation == nil, + let presenter = self.appContext?.utilities?.currentViewController() + else { + promise.reject("ERR_FONT_PICKER", "Cannot present the font picker right now.") + return + } + let presentation = T3FontPickerPresentation { [weak self] result in + self?.fontPresentation = nil + promise.resolve(result) + } + self.fontPresentation = presentation + presentation.present(from: presenter, selectedName: selectedName) + } + }.runOnQueue(.main) + + AsyncFunction("resolveInstalledFonts") { (names: [String], promise: Promise) in + MainActor.assumeIsolated { + T3InstalledFonts.resolve(names) { promise.resolve($0) } + } + }.runOnQueue(.main) + AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in try self.presentVideo( url: url, @@ -111,9 +135,11 @@ public final class T3NativeControlsModule: Module { OnDestroy { let presentation = self.videoPresentation let file = self.filePresentation + let font = self.fontPresentation DispatchQueue.main.async { presentation?.dismiss() file?.dismiss() + font?.dismiss() } } diff --git a/apps/mobile/src/components/ComposerEditor.ios.test.tsx b/apps/mobile/src/components/ComposerEditor.ios.test.tsx index 029f454b902f7..6901aec6894b7 100644 --- a/apps/mobile/src/components/ComposerEditor.ios.test.tsx +++ b/apps/mobile/src/components/ComposerEditor.ios.test.tsx @@ -130,6 +130,12 @@ vi.mock("../lib/contextChipIconSource", () => ({ contextChipIconUri: () => "" }) vi.mock("../features/settings/appearance/useScaledTextRole", () => ({ useComposerTextRole: () => ({ fontSize: 16, lineHeight: 22 }), })); +vi.mock("../features/settings/appearance/AppearancePreferencesProvider", () => ({ + useAppearancePreferences: () => ({ + fonts: { composer: { regular: "System" } }, + composerFontSize: 16, + }), +})); vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid", randomHex: () => "0000" })); vi.mock("../state/assets", () => ({ assetEnvironment: {} })); vi.mock("../state/attachments", () => ({ attachmentEnvironment: {} })); diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx index 2d0de616bc698..273b45e84fec3 100644 --- a/apps/mobile/src/features/files/FileMarkdownPreview.tsx +++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx @@ -9,7 +9,6 @@ import { ScrollView, View } from "react-native"; import { RefreshControl } from "../../components/ui/RefreshControl"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; -import { FONT_FAMILIES } from "../../lib/fontFamilies"; import { resolveNativeMarkdownTypography } from "../../lib/appearancePreferences"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { @@ -40,8 +39,8 @@ interface MarkdownPreviewStyles { } export function useMarkdownPreviewStyles(): MarkdownPreviewStyles { - const { appearance } = useAppearancePreferences(); - const nativeMarkdownTypography = resolveNativeMarkdownTypography(appearance.baseFontSize); + const { fonts, replyFontSize, replyEmphasisFontFamily } = useAppearancePreferences(); + const nativeMarkdownTypography = resolveNativeMarkdownTypography(replyFontSize); const theme = useUniwindTheme(); const body = theme["--color-md-body"]; const strong = theme["--color-md-strong"]; @@ -50,8 +49,8 @@ export function useMarkdownPreviewStyles(): MarkdownPreviewStyles { const codeBackground = theme["--color-md-code-bg"]; const codeText = theme["--color-md-code-text"]; const horizontalRule = theme["--color-md-hr"]; - const regularFontFamily = FONT_FAMILIES.regular; - const boldFontFamily = FONT_FAMILIES.bold; + const regularFontFamily = fonts.reply.regular; + const boldFontFamily = fonts.reply.bold; return { nativeTextStyle: { @@ -69,9 +68,10 @@ export function useMarkdownPreviewStyles(): MarkdownPreviewStyles { fontSize: nativeMarkdownTypography.fontSize, lineHeight: nativeMarkdownTypography.lineHeight, headingFontSizes: nativeMarkdownTypography.headingFontSizes, + codeFontFamily: fonts.code.regular, fontFamily: regularFontFamily, headingFontFamily: boldFontFamily, - boldFontFamily, + boldFontFamily: replyEmphasisFontFamily, }, }; } diff --git a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx index 717e71362a593..610e53786c037 100644 --- a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx @@ -6,6 +6,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SettingsScreen } from "./components/SettingsScreen"; import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection"; import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection"; +import { FontAppearanceSection } from "./appearance/sections/FontAppearanceSection"; import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection"; import { ThemeAppearanceSection } from "./appearance/sections/ThemeAppearanceSection"; import { ToolCallsAppearanceSection } from "./appearance/sections/ToolCallsAppearanceSection"; @@ -27,6 +28,7 @@ export function SettingsAppearanceRouteScreen() { {Platform.OS === "android" ? : null} + diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index 26e17f45afb13..74d650181f46b 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -1,4 +1,13 @@ -import { createContext, startTransition, use, useMemo, type ReactNode } from "react"; +import { + createContext, + startTransition, + use, + useCallback, + useMemo, + useState, + useEffect, + type ReactNode, +} from "react"; import { AppState, Appearance, Platform, useColorScheme } from "react-native"; import { manufacturer } from "expo-device"; @@ -19,6 +28,19 @@ import { } from "../../../lib/appearancePreferences"; import { appAtomRegistry } from "../../../state/atom-registry"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; +import { + normalizeFontSelections, + reconcileInstalledFonts, + normalizeReplyEmphasisWeights, + resolveReplyEmphasis, + type ReplyEmphasisWeight, + resolveMobileFonts, + type MobileFontRole, + type MobileFontSelections, + type InstalledMobileFont, +} from "../../../lib/mobileFonts"; +import { resolveInstalledFonts } from "../../../native/installed-fonts"; +import { normalizeBaseFontSize } from "../../../lib/appearancePreferences"; import type { Preferences } from "../../../persistence/mobile-preferences"; import { isSystemColorsAvailable, @@ -45,6 +67,20 @@ import { } from "../../../lib/mobileThemeRuntime"; interface AppearancePreferencesContextValue { + readonly fonts: ReturnType; + readonly fontSelections: MobileFontSelections; + readonly installedFonts: ReadonlyMap; + readonly unavailableFonts: readonly string[]; + readonly replyFontSize: number; + readonly replyEmphasisWeight: ReplyEmphasisWeight | undefined; + readonly replyEmphasisFontFamily: string; + readonly setReplyEmphasisWeight: (weight: ReplyEmphasisWeight | undefined) => void; + readonly composerFontSize: number; + readonly customReplyFontSize: boolean; + readonly customComposerFontSize: boolean; + readonly setFontSelection: (role: MobileFontRole, selection: string) => void; + readonly setReplyFontSize: (size: number | null) => void; + readonly setComposerFontSize: (size: number | null) => void; readonly appearance: ResolvedAppearance; readonly themeId: MobileThemeId; readonly themeIds: MobileThemeIds; @@ -222,6 +258,84 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN () => resolveAppearance({ baseFontSize, codeFontSize, codeWordBreak, terminalFontSize }), [baseFontSize, codeFontSize, codeWordBreak, terminalFontSize], ); + const fontSelections = useMemo( + () => normalizeFontSelections(storedPreferences?.fontSelections), + [storedPreferences?.fontSelections], + ); + const selectedFontNames = useMemo( + () => [ + ...new Set( + Object.values(fontSelections) + .filter((selection) => selection.startsWith("installed:")) + .map((selection) => selection.slice(10)), + ), + ], + [fontSelections], + ); + const [installedFonts, setInstalledFonts] = useState>( + new Map(), + ); + const [unavailableFonts, setUnavailableFonts] = useState([]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reloads native installed font faces when selections change or the app returns to the foreground + useEffect(() => { + let cancelled = false; + let generation = 0; + const updateUnavailableFonts = (missing: readonly string[]) => { + setUnavailableFonts((previous) => + previous.length === missing.length && + previous.every((name, index) => name === missing[index]) + ? previous + : missing, + ); + }; + const refresh = () => { + generation = generation + 1; + const request = generation; + void resolveInstalledFonts(selectedFontNames) + .then((resolved) => { + if (cancelled || request !== generation) return; + const available = new Map(resolved.map((font) => [font.name, font])); + setInstalledFonts((previous) => reconcileInstalledFonts(previous, available)); + updateUnavailableFonts(selectedFontNames.filter((name) => !available.has(name))); + }) + .catch(() => { + if (cancelled || request !== generation) return; + setInstalledFonts((previous) => (previous.size ? new Map() : previous)); + updateUnavailableFonts(selectedFontNames); + }); + }; + refresh(); + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") refresh(); + }); + return () => { + cancelled = true; + subscription.remove(); + }; + }, [selectedFontNames]); + const fonts = useMemo( + () => resolveMobileFonts(fontSelections, installedFonts, Platform.OS), + [fontSelections, installedFonts], + ); + const replyEmphasisWeight = normalizeReplyEmphasisWeights( + storedPreferences?.replyEmphasisWeights, + )[fonts.reply.regular]; + const replyEmphasisFontFamily = resolveReplyEmphasis(fonts.reply, replyEmphasisWeight); + const fontVariables = useMemo( + () => ({ + ...themeVariables, + "--font-sans": fonts.interface.regular, + "--font-medium": fonts.interface.medium, + "--font-bold": fonts.interface.bold, + }), + [themeVariables, fonts.interface], + ); + const replyFontSize = normalizeBaseFontSize(storedPreferences?.replyFontSize ?? baseFontSize); + const composerFontSize = normalizeBaseFontSize( + storedPreferences?.composerFontSize ?? baseFontSize, + ); + const customReplyFontSize = storedPreferences?.replyFontSize != null; + const customComposerFontSize = storedPreferences?.composerFontSize != null; const isReady = AsyncResult.isSuccess(preferencesResult); const updatePreferences = (patch: Partial) => { startTransition(() => savePreferences(patch)); @@ -290,12 +404,61 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN }; const mathRenderingEnabled = storedPreferences?.mathRenderingEnabled === true; - const setMathRenderingEnabled = (value: boolean) => - updatePreferences({ mathRenderingEnabled: value }); + const setMathRenderingEnabled = useCallback( + (value: boolean) => updatePreferences({ mathRenderingEnabled: value }), + [updatePreferences], + ); + const setFontSelection = useCallback( + (role: MobileFontRole, selection: string) => { + savePreferences({ + transform: (current) => ({ + fontSelections: { ...normalizeFontSelections(current.fontSelections), [role]: selection }, + }), + }); + }, + [savePreferences], + ); + const setReplyEmphasisWeight = useCallback( + (weight: ReplyEmphasisWeight | undefined) => { + savePreferences({ + transform: (current) => { + const next = { ...normalizeReplyEmphasisWeights(current.replyEmphasisWeights) }; + if (weight === undefined) delete next[fonts.reply.regular]; + else next[fonts.reply.regular] = weight; + return { replyEmphasisWeights: next }; + }, + }); + }, + [fonts.reply.regular, savePreferences], + ); + const setReplyFontSize = useCallback( + (size: number | null) => + updatePreferences({ replyFontSize: size === null ? null : normalizeBaseFontSize(size) }), + [updatePreferences], + ); + const setComposerFontSize = useCallback( + (size: number | null) => + updatePreferences({ composerFontSize: size === null ? null : normalizeBaseFontSize(size) }), + [updatePreferences], + ); const value = useMemo( (): AppearancePreferencesContextValue => ({ appearance, + fonts, + fontSelections, + installedFonts, + unavailableFonts, + replyFontSize, + replyEmphasisWeight, + replyEmphasisFontFamily, + setReplyEmphasisWeight, + composerFontSize, + customReplyFontSize, + customComposerFontSize, + setFontSelection, + setReplyFontSize, + setComposerFontSize, themeId, themeIds, themeMode, @@ -320,6 +483,20 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN }), [ appearance, + fonts, + fontSelections, + installedFonts, + unavailableFonts, + replyFontSize, + replyEmphasisWeight, + replyEmphasisFontFamily, + setReplyEmphasisWeight, + composerFontSize, + customReplyFontSize, + customComposerFontSize, + setFontSelection, + setReplyFontSize, + setComposerFontSize, themeId, themeIds, themeMode, @@ -346,7 +523,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN return ( - {props.children} + {props.children} ); diff --git a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx index afcf4ba30d9a1..96d396e7c5b57 100644 --- a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx +++ b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx @@ -20,12 +20,17 @@ export function AppearancePreviewSeparator() { export function TextAppearancePreview(props: { readonly fontSize: number }) { const sizes = resolveMarkdownFontSizes(props.fontSize); + const { fonts } = useAppearancePreferences(); return ( The quick brown fox jumps over the lazy dog. diff --git a/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx new file mode 100644 index 0000000000000..f8b42bf860ec0 --- /dev/null +++ b/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx @@ -0,0 +1,236 @@ +import { useState } from "react"; +import { Alert, View } from "react-native"; +import { useAtomSet } from "@effect/atom-react"; +import { AppText as Text } from "../../../../components/ui/AppText"; +import { ListRowLayout } from "../../../../components/ui/ListRowLayout"; +import { Menu } from "../../../../components/ui/Menu"; +import { SettingsSection } from "../../components/SettingsSection"; +import { ListActionRow as SettingsActionRow } from "../../../../components/ui/ListActionRow"; +import { SettingsSwitchRow } from "../../components/SettingsSwitchRow"; +import { FontSizeSliderRow } from "../components/FontSizeSliderRow"; +import { useAppearancePreferences } from "../AppearancePreferencesProvider"; +import { updateMobilePreferencesAtom } from "../../../../state/preferences"; +import { canPickInstalledFont, pickInstalledFont } from "../../../../native/installed-fonts"; +import { + BUILT_IN_FONT_OPTIONS, + REPLY_EMPHASIS_OPTIONS, + DEFAULT_FONT_SELECTIONS, + type MobileFontRole, +} from "../../../../lib/mobileFonts"; +import { MIN_BASE_FONT_SIZE, MAX_BASE_FONT_SIZE } from "../../../../lib/appearancePreferences"; + +const ROLES: readonly { role: MobileFontRole; label: string }[] = [ + { role: "interface", label: "Interface font" }, + { role: "reply", label: "Reply font" }, + { role: "composer", label: "Composer font" }, + { role: "code", label: "Chat code font" }, +]; + +export function FontAppearanceSection() { + const { + isReady, + fontSelections, + installedFonts, + unavailableFonts, + fonts, + replyFontSize, + replyEmphasisWeight, + replyEmphasisFontFamily, + setReplyEmphasisWeight, + composerFontSize, + customReplyFontSize, + customComposerFontSize, + setFontSelection, + setReplyFontSize, + setComposerFontSize, + } = useAppearancePreferences(); + const savePreferences = useAtomSet(updateMobilePreferencesAtom, { mode: "promise" }); + const [busy, setBusy] = useState(false); + const options = [ + ...BUILT_IN_FONT_OPTIONS, + ...[...installedFonts.values()].map((font) => ({ + id: `installed:${font.name}`, + label: font.label, + })), + ]; + const report = (error: unknown) => + Alert.alert( + "Custom fonts", + error instanceof Error ? error.message : "Could not update fonts. Please try again.", + ); + + const chooseInstalledFont = (role: MobileFontRole) => { + setBusy(true); + const selection = fontSelections[role]; + void pickInstalledFont(selection.startsWith("installed:") ? selection.slice(10) : null) + .then((font) => { + if (font) setFontSelection(role, `installed:${font.name}`); + }) + .catch(report) + .finally(() => setBusy(false)); + }; + + return ( + + + + A reply in your selected font.{" "} + + Emphasized text. + + + + Defaults follow the interface. These choices stay on this device. + + + {ROLES.map(({ role, label }) => ( + ({ + id: option.id || "default", + title: option.label, + state: fontSelections[role] === option.id ? ("on" as const) : ("off" as const), + attributes: { disabled: !isReady || busy }, + })), + ...(canPickInstalledFont + ? [ + { + id: "choose-installed", + title: "Choose installed font…", + attributes: { disabled: !isReady || busy }, + }, + ] + : []), + ]} + onPressAction={({ nativeEvent }) => + nativeEvent.event === "choose-installed" + ? chooseInstalledFont(role) + : setFontSelection(role, nativeEvent.event === "default" ? "" : nativeEvent.event) + } + > + + option.id === fontSelections[role])?.label ?? "Unavailable" + } + /> + + + ))} + ({ + id: option.id, + title: option.label, + state: replyEmphasisWeight === option.id ? ("on" as const) : ("off" as const), + attributes: { disabled: !isReady || busy || !fonts.reply.emphasisFaces?.[option.id] }, + })), + ]} + onPressAction={({ nativeEvent }) => + setReplyEmphasisWeight( + REPLY_EMPHASIS_OPTIONS.find((option) => option.id === nativeEvent.event)?.id, + ) + } + > + + option.id === replyEmphasisWeight)?.label ?? + "Default" + } + /> + + + + + Emphasis is remembered for this reply font. Unavailable weights are disabled. + + + setReplyFontSize(enabled ? replyFontSize : null)} + /> + {customReplyFontSize ? ( + + ) : null} + setComposerFontSize(enabled ? composerFontSize : null)} + /> + {customComposerFontSize ? ( + + ) : null} + {unavailableFonts.length ? ( + + + Unavailable fonts: {unavailableFonts.join(", ")}. Using defaults. Choose an installed + font again or select Default. + + + ) : null} + + void savePreferences({ + fontSelections: DEFAULT_FONT_SELECTIONS, + replyEmphasisWeights: {}, + replyFontSize: null, + composerFontSize: null, + }).catch(report) + } + /> + + ); +} diff --git a/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts b/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts index c71298a89822a..ba87cd2d268f3 100644 --- a/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts +++ b/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts @@ -16,7 +16,8 @@ export function useScaledTextRole(role: keyof typeof MOBILE_TYPOGRAPHY): ScaledT } export function useComposerTextRole(): ScaledTextRole { - const body = useScaledTextRole("body"); + const { composerFontSize } = useAppearancePreferences(); + const body = resolveScaledTextRole("body", composerFontSize); const { fontScale } = useWindowDimensions(); return resolveComposerTextRole(body, fontScale, Platform.OS); } diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index fdd6b414a46e5..f7ba19fa557ff 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -431,7 +431,7 @@ export const THREAD_COMPOSER_BOTTOM_GUTTER = 6; export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps) { const project = useProject(scopeProjectRef(props.environmentId, props.selectedThread.projectId)); - const { themeVariables: materialTheme } = useAppearancePreferences(); + const { themeVariables: materialTheme, fonts } = useAppearancePreferences(); const composerPanel = materialTheme["--color-composer-panel"]; const navigation = useNavigation(); const foregroundColor = useUniwindTheme()["--color-foreground"]; @@ -1109,6 +1109,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps {...compactEditor.editorProps} textStyle={{ ...bodyText, + fontFamily: fonts.composer.regular, color: foregroundColor, }} /> diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 6419c7dc7778a..045512d296212 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -146,7 +146,6 @@ import { PresentationSource } from "../../components/ui/NativePresentation"; import { NativeScrollViewMarker } from "../../native/NativeScrollViewMarker"; import Animated, { FadeIn, FadeInDown, type SharedValue } from "react-native-reanimated"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; -import { FONT_FAMILIES } from "../../lib/fontFamilies"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; @@ -1297,8 +1296,15 @@ function MarkdownDetailsFold(props: { } function useMarkdownStyles(): MarkdownStyleSets { - const { appearance } = useAppearancePreferences(); - const nativeMarkdownTypography = resolveNativeMarkdownTypography(appearance.baseFontSize); + const { appearance, fonts, replyFontSize, replyEmphasisFontFamily } = useAppearancePreferences(); + const replyTypography = useMemo( + () => resolveNativeMarkdownTypography(replyFontSize), + [replyFontSize], + ); + const nativeMarkdownTypography = useMemo( + () => resolveNativeMarkdownTypography(appearance.baseFontSize), + [appearance.baseFontSize], + ); const theme = useUniwindTheme(); const markdownBodyColor = theme["--color-md-body"]; const markdownStrongColor = theme["--color-md-strong"]; @@ -1318,55 +1324,81 @@ function useMarkdownStyles(): MarkdownStyleSets { const markdownUserCodeText = theme["--color-md-user-code-text"]; const markdownUserInlineCodeText = theme["--color-user-bubble-foreground-muted"]; const markdownUserFenceBg = theme["--color-md-user-fence-bg"]; - const regularFontFamily = FONT_FAMILIES.regular; - const boldFontFamily = FONT_FAMILIES.bold; - - return { - user: { - nativeTextStyle: { - color: markdownUserBodyColor, - strongColor: markdownUserBodyColor, - mutedColor: markdownUserBodyColor, - linkColor: markdownUserBodyColor, - inlineCodeColor: markdownUserInlineCodeText, - codeColor: markdownUserCodeText, - codeBackgroundColor: markdownUserCodeBg, - codeBlockBackgroundColor: markdownUserFenceBg, - fileTextColor: markdownUserBodyColor, - quoteMarkerColor: markdownUserBodyColor, - dividerColor: markdownUserBodyColor, - contextChipBorderColor, - fontSize: nativeMarkdownTypography.fontSize, - lineHeight: nativeMarkdownTypography.lineHeight, - headingFontSizes: nativeMarkdownTypography.headingFontSizes, - fontFamily: regularFontFamily, - headingFontFamily: boldFontFamily, - boldFontFamily, + const regularFontFamily = fonts.interface.regular; + const boldFontFamily = fonts.interface.bold; + + return useMemo(() => { + return { + user: { + nativeTextStyle: { + color: markdownUserBodyColor, + strongColor: markdownUserBodyColor, + mutedColor: markdownUserBodyColor, + linkColor: markdownUserBodyColor, + inlineCodeColor: markdownUserInlineCodeText, + codeColor: markdownUserCodeText, + codeBackgroundColor: markdownUserCodeBg, + codeBlockBackgroundColor: markdownUserFenceBg, + fileTextColor: markdownUserBodyColor, + quoteMarkerColor: markdownUserBodyColor, + dividerColor: markdownUserBodyColor, + contextChipBorderColor, + fontSize: nativeMarkdownTypography.fontSize, + lineHeight: nativeMarkdownTypography.lineHeight, + headingFontSizes: nativeMarkdownTypography.headingFontSizes, + codeFontFamily: fonts.code.regular, + fontFamily: regularFontFamily, + headingFontFamily: boldFontFamily, + boldFontFamily, + }, }, - }, - assistant: { - nativeTextStyle: { - color: markdownBodyColor, - strongColor: markdownStrongColor, - mutedColor: markdownBodyColor, - linkColor: markdownLinkColor, - inlineCodeColor: markdownInlineCodeText, - codeColor: markdownCodeText, - codeBackgroundColor: markdownCodeBg, - codeBlockBackgroundColor: markdownCodeBg, - fileTextColor: markdownCodeText, - quoteMarkerColor: markdownBlockquoteBorder, - dividerColor: markdownHrColor, - contextChipBorderColor, - fontSize: nativeMarkdownTypography.fontSize, - lineHeight: nativeMarkdownTypography.lineHeight, - headingFontSizes: nativeMarkdownTypography.headingFontSizes, - fontFamily: regularFontFamily, - headingFontFamily: boldFontFamily, - boldFontFamily, + assistant: { + nativeTextStyle: { + color: markdownBodyColor, + strongColor: markdownStrongColor, + mutedColor: markdownBodyColor, + linkColor: markdownLinkColor, + inlineCodeColor: markdownInlineCodeText, + codeColor: markdownCodeText, + codeBackgroundColor: markdownCodeBg, + codeBlockBackgroundColor: markdownCodeBg, + fileTextColor: markdownCodeText, + quoteMarkerColor: markdownBlockquoteBorder, + dividerColor: markdownHrColor, + contextChipBorderColor, + fontSize: replyTypography.fontSize, + lineHeight: replyTypography.lineHeight, + headingFontSizes: replyTypography.headingFontSizes, + codeFontFamily: fonts.code.regular, + fontFamily: fonts.reply.regular, + headingFontFamily: fonts.reply.bold, + boldFontFamily: replyEmphasisFontFamily, + }, }, - }, - }; + }; + }, [ + fonts.reply, + replyEmphasisFontFamily, + fonts.code.regular, + replyTypography, + boldFontFamily, + contextChipBorderColor, + markdownBlockquoteBorder, + markdownBodyColor, + markdownCodeBg, + markdownCodeText, + markdownHrColor, + markdownInlineCodeText, + markdownLinkColor, + markdownStrongColor, + markdownUserBodyColor, + markdownUserCodeBg, + markdownUserCodeText, + markdownUserFenceBg, + markdownUserInlineCodeText, + nativeMarkdownTypography, + regularFontFamily, + ]); } /** Messages from other agents fold to one line so the user's own messages stay easy to find. */ @@ -2765,11 +2797,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const copyFeedbackTimeoutRef = useRef | null>(null); const headerMaterialVisibleRef = useRef(false); const { width: windowWidth, fontScale } = useWindowDimensions(); - const { appearance } = useAppearancePreferences(); + const { appearance, fonts, replyFontSize, replyEmphasisFontFamily } = useAppearancePreferences(); const workRowSizing = deriveThreadWorkLogSizing({ baseFontSize: appearance.baseFontSize, fontScale, }); + const textLayoutKey = `${workRowSizing.textSizeKey}:${replyFontSize}:${fonts.interface.regular}:${fonts.interface.bold}:${fonts.reply.regular}:${fonts.reply.bold}:${replyEmphasisFontFamily}:${fonts.code.regular}`; const settledPaneWidth = useWorkspaceSettledContentWidth(); const splitLayout = (props.layoutVariant ?? "compact") !== "compact"; const [measuredViewportWidth, setViewportWidth] = useState(() => (splitLayout ? 0 : windowWidth)); @@ -3703,7 +3736,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // reads getFixedItemSize only when it renders, so its size cache is cleared // after the commit that hands it rows at the new geometry; clearing earlier // re-caches old sizes. - const { textSizeKey } = workRowSizing; + const textSizeKey = textLayoutKey; const hasHtmlRenders = presentedFeed.some((entry) => entry.type === "html-render"); const previousRowGeometry = useRef({ textSizeKey, contentWidth }); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears LegendList size caches after the commit that brings new row geometry diff --git a/apps/mobile/src/lib/filePreview.test.ts b/apps/mobile/src/lib/filePreview.test.ts index 8363b0ba5c958..629bd8e56c9af 100644 --- a/apps/mobile/src/lib/filePreview.test.ts +++ b/apps/mobile/src/lib/filePreview.test.ts @@ -19,6 +19,7 @@ import { acquireAudioSession, activateAudioSession, releaseAudioSession } from " import { ThreadFeed } from "../features/threads/ThreadFeed"; import type { ThreadFeedEntry } from "./threadActivity"; import type { MarkdownImageRenderer } from "../native/SelectableMarkdownText"; +import { DEFAULT_FONT_SELECTIONS, resolveMobileFonts } from "./mobileFonts"; const audio = vi.hoisted(() => ({ status: { @@ -268,7 +269,13 @@ vi.mock("../features/layout/workspace-content-width", () => ({ useWorkspaceSettledContentWidth: () => null, })); vi.mock("../features/settings/appearance/AppearancePreferencesProvider", () => ({ - useAppearancePreferences: () => ({ appearance: { baseFontSize: 14 }, themeAppearance: "dark" }), + useAppearancePreferences: () => ({ + appearance: { baseFontSize: 14 }, + themeAppearance: "dark", + fonts: resolveMobileFonts(DEFAULT_FONT_SELECTIONS, new Map(), "ios"), + replyFontSize: 14, + replyEmphasisFontFamily: "DMSans-Bold", + }), })); vi.mock("./useUniwindTheme", () => ({ useUniwindTheme: () => new Proxy({}, { get: () => "#000000" }), diff --git a/apps/mobile/src/lib/fontFamilies.ts b/apps/mobile/src/lib/fontFamilies.ts index 38602ec7c9892..4c7a237df3767 100644 --- a/apps/mobile/src/lib/fontFamilies.ts +++ b/apps/mobile/src/lib/fontFamilies.ts @@ -2,4 +2,5 @@ export const FONT_FAMILIES = { regular: "DMSans-Regular", medium: "DMSans-Medium", bold: "DMSans-Bold", + emphasisFaces: { "500": "DMSans-Medium", "700": "DMSans-Bold" }, } as const; diff --git a/apps/mobile/src/lib/mobileFonts.test.ts b/apps/mobile/src/lib/mobileFonts.test.ts new file mode 100644 index 0000000000000..c29c3f3fef1d7 --- /dev/null +++ b/apps/mobile/src/lib/mobileFonts.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + DEFAULT_FONT_SELECTIONS, + normalizeFontSelections, + reconcileInstalledFonts, + normalizeReplyEmphasisWeights, + resolveReplyEmphasis, + resolveMobileFonts, +} from "./mobileFonts"; + +const serif = { + name: "ExampleSerif-Regular", + label: "Example Serif", + regular: "ExampleSerif-Regular", + medium: "ExampleSerif-Medium", + bold: "ExampleSerif-Bold", + emphasisFaces: { + "500": "ExampleSerif-Medium", + "600": "ExampleSerif-Semibold", + "700": "ExampleSerif-Bold", + }, +}; + +describe("installed mobile fonts", () => { + it("preserves installed font names and resets removed import choices and invalid preferences", () => { + expect( + normalizeFontSelections({ + interface: "serif", + reply: "installed:ExampleSerif-Regular", + composer: "imported:Old Family", + code: "installed:", + }), + ).toEqual({ + interface: "serif", + reply: "installed:ExampleSerif-Regular", + composer: "", + code: "", + }); + expect( + normalizeFontSelections({ reply: 42, interface: "installed:" + "x".repeat(251) }), + ).toEqual(DEFAULT_FONT_SELECTIONS); + }); + + it("uses the selected family's native weight faces independently for each role", () => { + const fonts = resolveMobileFonts( + { + ...DEFAULT_FONT_SELECTIONS, + interface: "serif", + reply: `installed:${serif.name}`, + code: "monospace", + }, + new Map([[serif.name, serif]]), + "ios", + ); + expect(fonts.interface.regular).toBe("Georgia"); + expect(fonts.composer).toEqual(fonts.interface); + expect(fonts.reply).toMatchObject({ + regular: serif.regular, + medium: serif.medium, + bold: serif.bold, + }); + expect(fonts.code.regular).toBe("Menlo-Regular"); + }); + + it("falls back while resolving or after an installed font is removed", () => { + const fonts = resolveMobileFonts( + { ...DEFAULT_FONT_SELECTIONS, reply: `installed:${serif.name}` }, + new Map(), + "ios", + ); + expect(fonts.reply).toEqual(fonts.interface); + }); + + it("keeps Android's system family choices without a file import dependency", () => { + const fonts = resolveMobileFonts( + { interface: "system", reply: "serif", composer: "monospace", code: "" }, + new Map(), + "android", + ); + expect(fonts.interface.regular).toBe("sans-serif"); + expect(fonts.reply.regular).toBe("serif"); + expect(fonts.composer.regular).toBe("monospace"); + expect(fonts.code.regular).toBe("monospace"); + }); +}); + +describe("reply emphasis", () => { + it("preserves the default bold face and only changes emphasis for a selected weight", () => { + const standard = resolveMobileFonts(DEFAULT_FONT_SELECTIONS, new Map(), "ios"); + expect(resolveReplyEmphasis(standard.reply)).toBe("DMSans-Bold"); + expect(resolveReplyEmphasis(standard.reply, "500")).toBe("DMSans-Medium"); + }); + + it("selects a real installed semibold face and falls back when that weight is unavailable", () => { + expect(resolveReplyEmphasis(serif, "600")).toBe("ExampleSerif-Semibold"); + expect( + resolveReplyEmphasis( + { + regular: "Example-Regular", + medium: "Example-Regular", + bold: "Example-Bold", + emphasisFaces: { "700": "Example-Bold" }, + }, + "600", + ), + ).toBe("Example-Bold"); + }); + + it("remembers different weights per font and drops invalid saved values", () => { + const saved = normalizeReplyEmphasisWeights({ + "FirstSerif-Regular": "700", + "SecondSerif-Regular": "600", + invalid: 600, + bad: "900", + "": "500", + }); + expect(saved).toEqual({ "FirstSerif-Regular": "700", "SecondSerif-Regular": "600" }); + expect(normalizeReplyEmphasisWeights(null)).toEqual({}); + expect(resolveReplyEmphasis(serif, saved["SecondSerif-Regular"])).toBe("ExampleSerif-Semibold"); + }); +}); + +describe("font refresh identity", () => { + it("keeps the map and font references when native descriptors are unchanged or reordered", () => { + const second = { ...serif, name: "Other-Regular", regular: "Other-Regular" }; + const current = new Map([ + [serif.name, serif], + [second.name, second], + ]); + const refreshed = new Map([ + [second.name, { ...second }], + [serif.name, { ...serif, emphasisFaces: { ...serif.emphasisFaces } }], + ]); + expect(reconcileInstalledFonts(current, refreshed)).toBe(current); + }); + + it("applies changed faces and removals while keeping unrelated font references", () => { + const second = { ...serif, name: "Other-Regular", regular: "Other-Regular" }; + const current = new Map([ + [serif.name, serif], + [second.name, second], + ]); + const changed = { + ...serif, + emphasisFaces: { ...serif.emphasisFaces, "600": "Replacement-Semibold" }, + }; + const next = reconcileInstalledFonts( + current, + new Map([ + [serif.name, changed], + [second.name, { ...second }], + ]), + ); + expect(next).not.toBe(current); + expect(next.get(serif.name)?.emphasisFaces?.["600"]).toBe("Replacement-Semibold"); + expect(next.get(second.name)).toBe(second); + expect(current.get(serif.name)?.emphasisFaces?.["600"]).toBe("ExampleSerif-Semibold"); + const removed = reconcileInstalledFonts(next, new Map([[second.name, second]])); + expect(removed.has(serif.name)).toBe(false); + const empty = reconcileInstalledFonts(removed, new Map()); + expect(empty.size).toBe(0); + expect(reconcileInstalledFonts(empty, new Map())).toBe(empty); + }); + + it.each(["ios", "android"])( + "reuses built-in family objects across unrelated %s font refreshes", + (platform) => { + const selections = { + interface: "system", + reply: "serif", + composer: "monospace", + code: "monospace", + }; + const before = resolveMobileFonts(selections, new Map(), platform); + const after = resolveMobileFonts(selections, new Map([[serif.name, serif]]), platform); + for (const role of ["interface", "reply", "composer", "code"] as const) + expect(after[role]).toBe(before[role]); + }, + ); +}); diff --git a/apps/mobile/src/lib/mobileFonts.ts b/apps/mobile/src/lib/mobileFonts.ts new file mode 100644 index 0000000000000..0d15d6a4475f7 --- /dev/null +++ b/apps/mobile/src/lib/mobileFonts.ts @@ -0,0 +1,138 @@ +import { FONT_FAMILIES } from "./fontFamilies"; + +export const MOBILE_FONT_ROLES = ["interface", "reply", "composer", "code"] as const; +export type MobileFontRole = (typeof MOBILE_FONT_ROLES)[number]; +export type MobileFontSelections = Readonly>; +export interface MobileFontFamily { + readonly regular: string; + readonly medium: string; + readonly bold: string; + readonly emphasisFaces?: Readonly>>; +} +export interface InstalledMobileFont extends MobileFontFamily { + readonly name: string; + readonly label: string; +} +export const DEFAULT_FONT_SELECTIONS: MobileFontSelections = { + interface: "", + reply: "", + composer: "", + code: "", +}; +export const BUILT_IN_FONT_OPTIONS = [ + { id: "", label: "Default" }, + { id: "system", label: "System sans" }, + { id: "serif", label: "Serif" }, + { id: "monospace", label: "Monospace" }, +] as const; + +const BUILT_IN_FAMILIES = { + ios: { + system: { regular: "System", medium: "System", bold: "System" }, + serif: { + regular: "Georgia", + medium: "Georgia", + bold: "Georgia-Bold", + emphasisFaces: { "700": "Georgia-Bold" }, + }, + mono: { + regular: "Menlo-Regular", + medium: "Menlo-Regular", + bold: "Menlo-Bold", + emphasisFaces: { "700": "Menlo-Bold" }, + }, + }, + android: { + system: { regular: "sans-serif", medium: "sans-serif-medium", bold: "sans-serif" }, + serif: { regular: "serif", medium: "serif", bold: "serif" }, + mono: { regular: "monospace", medium: "monospace", bold: "monospace" }, + }, +} as const; + +export function reconcileInstalledFonts( + previous: ReadonlyMap, + available: ReadonlyMap, +): ReadonlyMap { + let changed = previous.size !== available.size; + const next = new Map(); + for (const [name, font] of available) { + const prior = previous.get(name); + const same = + prior && + (["name", "label", "regular", "medium", "bold"] as const).every( + (key) => prior[key] === font[key], + ) && + REPLY_EMPHASIS_OPTIONS.every( + ({ id }) => prior.emphasisFaces?.[id] === font.emphasisFaces?.[id], + ); + if (!same) changed = true; + next.set(name, same ? prior : font); + } + return changed ? next : previous; +} + +export function normalizeFontSelections(value: unknown): MobileFontSelections { + const record = + typeof value === "object" && value !== null ? (value as Record) : {}; + return Object.fromEntries( + MOBILE_FONT_ROLES.map((role) => { + const candidate = record[role]; + return [ + role, + typeof candidate === "string" && + candidate.length <= 250 && + (BUILT_IN_FONT_OPTIONS.some((option) => option.id === candidate) || + (candidate.startsWith("installed:") && candidate.slice(10).trim().length > 0)) + ? candidate + : "", + ]; + }), + ) as unknown as MobileFontSelections; +} + +export function resolveMobileFonts( + selections: MobileFontSelections, + installed: ReadonlyMap, + platform: string, +): Readonly> { + const defaults = FONT_FAMILIES; + const { system, serif, mono } = BUILT_IN_FAMILIES[platform === "ios" ? "ios" : "android"]; + const resolve = (selection: string, fallback: MobileFontFamily): MobileFontFamily => { + if (selection === "system") return system; + if (selection === "serif") return serif; + if (selection === "monospace") return mono; + if (!selection.startsWith("installed:")) return fallback; + return installed.get(selection.slice(10)) ?? fallback; + }; + const interfaceFont = resolve(selections.interface, defaults); + return { + interface: interfaceFont, + reply: resolve(selections.reply, interfaceFont), + composer: resolve(selections.composer, interfaceFont), + code: resolve(selections.code, mono), + }; +} + +export const REPLY_EMPHASIS_OPTIONS = [ + { id: "500", label: "Medium" }, + { id: "600", label: "Semibold" }, + { id: "700", label: "Bold" }, +] as const; +export type ReplyEmphasisWeight = (typeof REPLY_EMPHASIS_OPTIONS)[number]["id"]; +export type ReplyEmphasisWeights = Readonly>; + +export function normalizeReplyEmphasisWeights(value: unknown): ReplyEmphasisWeights { + if (typeof value !== "object" || value === null) return {}; + return Object.fromEntries( + Object.entries(value).filter( + (entry): entry is [string, ReplyEmphasisWeight] => + entry[0].length > 0 && + entry[0].length <= 250 && + REPLY_EMPHASIS_OPTIONS.some((option) => option.id === entry[1]), + ), + ); +} + +export function resolveReplyEmphasis(font: MobileFontFamily, weight?: ReplyEmphasisWeight) { + return (weight && font.emphasisFaces?.[weight]) || font.bold; +} diff --git a/apps/mobile/src/lib/useMobileNavigationTheme.ts b/apps/mobile/src/lib/useMobileNavigationTheme.ts index 54412e14cb2a8..580b1dfa6184d 100644 --- a/apps/mobile/src/lib/useMobileNavigationTheme.ts +++ b/apps/mobile/src/lib/useMobileNavigationTheme.ts @@ -5,12 +5,20 @@ import { useAppearancePreferences } from "../features/settings/appearance/Appear import { useUniwindTheme } from "./useUniwindTheme"; export function useMobileNavigationTheme(surface: "screen" | "sidebar" = "screen"): Theme { - const { themeAppearance: appearance } = useAppearancePreferences(); + const { themeAppearance: appearance, fonts, fontSelections } = useAppearancePreferences(); const variables = useUniwindTheme(); return useMemo(() => { const base = appearance === "dark" ? DarkTheme : DefaultTheme; return { ...base, + fonts: fontSelections.interface + ? { + regular: { fontFamily: fonts.interface.regular, fontWeight: "400" }, + medium: { fontFamily: fonts.interface.medium, fontWeight: "500" }, + bold: { fontFamily: fonts.interface.bold, fontWeight: "700" }, + heavy: { fontFamily: fonts.interface.bold, fontWeight: "800" }, + } + : base.fonts, colors: { ...base.colors, primary: variables["--color-primary-text"], @@ -24,5 +32,5 @@ export function useMobileNavigationTheme(surface: "screen" | "sidebar" = "screen notification: variables["--color-danger-foreground"], }, }; - }, [appearance, surface, variables]); + }, [appearance, fonts.interface, fontSelections.interface, surface, variables]); } diff --git a/apps/mobile/src/native/EnrichedMarkdownText.tsx b/apps/mobile/src/native/EnrichedMarkdownText.tsx index 86bf8021bdd0f..9e2c1c926c621 100644 --- a/apps/mobile/src/native/EnrichedMarkdownText.tsx +++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx @@ -45,7 +45,7 @@ function enrichedStyle( dark: boolean, fontScale: number, ): MarkdownStyle { - const monospace = Platform.OS === "ios" ? "Menlo" : "monospace"; + const monospace = style.codeFontFamily ?? (Platform.OS === "ios" ? "Menlo" : "monospace"); // Keep the radius modest: iOS clips square table-cell borders without stroking the corner arcs. const block = { marginTop: 4, @@ -90,7 +90,11 @@ function enrichedStyle( h4: heading(4), h5: heading(5), h6: heading(6), - strong: { color: style.strongColor, fontFamily: style.boldFontFamily, fontWeight: "normal" }, + strong: { + color: style.strongColor, + fontFamily: style.boldFontFamily, + fontWeight: style.boldFontFamily === style.fontFamily ? "bold" : "normal", + }, link: { color: style.linkColor, underline: false }, code: { fontFamily: monospace, diff --git a/apps/mobile/src/native/SelectableMarkdownText.types.ts b/apps/mobile/src/native/SelectableMarkdownText.types.ts index 63f8429c386fd..5f5371d4eac0e 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.types.ts +++ b/apps/mobile/src/native/SelectableMarkdownText.types.ts @@ -15,6 +15,7 @@ export interface NativeMarkdownTextStyle { readonly contextChipBorderColor?: string; readonly fontSize: number; readonly lineHeight: number; + readonly codeFontFamily?: string; readonly fontFamily: string; readonly headingFontFamily: string; readonly boldFontFamily: string; diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 56f80be2e7be8..cae9c11e96b5a 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -1,3 +1,4 @@ +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES } from "@t3tools/client-runtime/text-paste"; import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; @@ -26,7 +27,6 @@ import { import { contextChipIconUri, modelChipIcon, modelChipIconUri } from "../lib/contextChipIconSource"; import { useUniwindTheme } from "../lib/useUniwindTheme"; import { createNativeComposerTheme } from "../lib/nativeComposerTheme"; -import { FONT_FAMILIES } from "../lib/fontFamilies"; import { useComposerTextRole } from "../features/settings/appearance/useScaledTextRole"; import { acknowledgeComposerNativeEvent, @@ -178,7 +178,8 @@ export function ComposerEditor({ } const bodyText = useComposerTextRole(); const theme = useUniwindTheme(); - const fontFamily = FONT_FAMILIES.regular; + const { fonts } = useAppearancePreferences(); + const fontFamily = fonts.composer.regular; const skillLabels = useMemo(() => composerSkillLabels(skills), [skills]); const { modelsBySource, modelTokens } = useComposerModelTokens(props.value, models); diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 5aabd8e0d6071..112e740dde9a8 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -26,9 +26,7 @@ import { contextRecordFileName, } from "../lib/composerChipPresentation"; import { contextChipIconUri, modelChipIcon, modelChipIconUri } from "../lib/contextChipIconSource"; -import { MOBILE_TYPOGRAPHY } from "../lib/typography"; import { useNativePaste } from "../lib/useNativePaste"; -import { FONT_FAMILIES } from "../lib/fontFamilies"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { useUniwindTheme } from "../lib/useUniwindTheme"; import { createNativeComposerTheme } from "../lib/nativeComposerTheme"; @@ -306,13 +304,13 @@ export function ComposerEditor({ }, [nativeEventSnapshotsRef], ); - const { systemColorsActive } = useAppearancePreferences(); + const { systemColorsActive, fonts, composerFontSize } = useAppearancePreferences(); const themeJson = JSON.stringify({ ...createNativeComposerTheme(theme), selection: Platform.OS === "android" || systemColorsActive ? theme["--color-focus"] : null, }); const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {}; - const regularFontFamily = FONT_FAMILIES.regular; + const regularFontFamily = fonts.composer.regular; return ( (null); const bodyText = useScaledTextRole("body"); - const fontFamily = FONT_FAMILIES.regular; + const { fonts, composerFontSize } = useAppearancePreferences(); + const fontFamily = fonts.composer.regular; const handlePaste = useNativePaste((uris) => onPasteImages?.(uris)); useImperativeHandle( @@ -58,6 +59,8 @@ export function ComposerEditor({ minHeight: 0, fontFamily, ...bodyText, + fontSize: composerFontSize, + lineHeight: Math.round((composerFontSize * 23) / 16), paddingVertical: contentInsetVertical, }, textStyle, diff --git a/apps/mobile/src/native/installed-fonts.test.ts b/apps/mobile/src/native/installed-fonts.test.ts new file mode 100644 index 0000000000000..6970024aca5e6 --- /dev/null +++ b/apps/mobile/src/native/installed-fonts.test.ts @@ -0,0 +1,61 @@ +import { expect, it, vi } from "vite-plus/test"; +import type { InstalledMobileFont } from "../lib/mobileFonts"; + +const native = vi.hoisted(() => ({ + resolveInstalledFonts: vi.fn<(names: readonly string[]) => Promise>(), + pickInstalledFont: vi.fn<() => Promise>(), +})); + +vi.mock("expo", () => ({ requireOptionalNativeModule: () => native })); +vi.mock("react-native", () => ({ Platform: { OS: "ios" } })); + +async function setup() { + vi.resetModules(); + native.resolveInstalledFonts.mockReset(); + native.pickInstalledFont.mockReset(); + return import("./installed-fonts"); +} + +it("does not request a missing font again until the user selects it in the picker", async () => { + const fonts = await setup(); + const font = { + name: "Removed-Regular", + label: "Removed", + regular: "Removed-Regular", + medium: "Removed-Regular", + bold: "Removed-Regular", + }; + native.resolveInstalledFonts.mockResolvedValue([]); + expect(await fonts.resolveInstalledFonts([font.name])).toEqual([]); + expect(await fonts.resolveInstalledFonts([font.name])).toEqual([]); + expect(native.resolveInstalledFonts).toHaveBeenCalledTimes(1); + + native.pickInstalledFont.mockResolvedValue(null); + await fonts.pickInstalledFont(font.name); + expect(await fonts.resolveInstalledFonts([font.name])).toEqual([]); + expect(native.resolveInstalledFonts).toHaveBeenCalledTimes(1); + + native.pickInstalledFont.mockResolvedValue(font); + await fonts.pickInstalledFont(font.name); + native.resolveInstalledFonts.mockResolvedValue([font]); + expect(await fonts.resolveInstalledFonts([font.name])).toEqual([font]); + native.resolveInstalledFonts.mockResolvedValue([]); + expect(await fonts.resolveInstalledFonts([font.name])).toEqual([]); + expect(await fonts.resolveInstalledFonts([font.name])).toEqual([]); + expect(native.resolveInstalledFonts).toHaveBeenCalledTimes(3); +}); + +it("shares an in-flight request and permits another attempt after a native failure", async () => { + const fonts = await setup(); + const pending = Promise.withResolvers(); + native.resolveInstalledFonts.mockReturnValue(pending.promise); + const first = fonts.resolveInstalledFonts(["Removed-Regular"]); + const second = fonts.resolveInstalledFonts(["Removed-Regular"]); + const settled = Promise.allSettled([first, second]); + pending.reject(new Error("Native request failed")); + expect((await settled).map((result) => result.status)).toEqual(["rejected", "rejected"]); + expect(native.resolveInstalledFonts).toHaveBeenCalledTimes(1); + native.resolveInstalledFonts.mockResolvedValue([]); + expect(await fonts.resolveInstalledFonts(["Removed-Regular"])).toEqual([]); + expect(native.resolveInstalledFonts).toHaveBeenCalledTimes(2); +}); diff --git a/apps/mobile/src/native/installed-fonts.ts b/apps/mobile/src/native/installed-fonts.ts new file mode 100644 index 0000000000000..41b9a6579722c --- /dev/null +++ b/apps/mobile/src/native/installed-fonts.ts @@ -0,0 +1,52 @@ +import { requireOptionalNativeModule } from "expo"; +import { Platform } from "react-native"; +import type { InstalledMobileFont } from "../lib/mobileFonts"; + +const nativeControls = requireOptionalNativeModule<{ + pickInstalledFont?: (selectedName: string | null) => Promise; + resolveInstalledFonts?: (names: readonly string[]) => Promise; +}>("T3NativeControls"); + +export const canPickInstalledFont = + Platform.OS === "ios" && typeof nativeControls?.pickInstalledFont === "function"; + +// CoreText can show a system dialog for missing fonts, including on foreground refresh. +const missingFontNames = new Set(); +const pendingResolutions = new Map>(); + +export async function pickInstalledFont(selectedName: string | null) { + if (!canPickInstalledFont || !nativeControls?.pickInstalledFont) + throw new Error("Installed font selection requires an updated iOS app."); + const font = await nativeControls.pickInstalledFont(selectedName); + if (font) { + missingFontNames.delete(font.name); + pendingResolutions.delete(font.name); + } + return font; +} + +export async function resolveInstalledFonts(names: readonly string[]) { + if (!names.length || !nativeControls?.resolveInstalledFonts) return []; + const requestedNames = [...new Set(names)].filter( + (name) => !missingFontNames.has(name) && !pendingResolutions.has(name), + ); + if (requestedNames.length) { + const request = nativeControls.resolveInstalledFonts(requestedNames); + for (const name of requestedNames) { + const resolution = request + .then((fonts) => { + const font = fonts.find((candidate) => candidate.name === name); + if (!font && pendingResolutions.get(name) === resolution) missingFontNames.add(name); + return font; + }) + .finally(() => { + if (pendingResolutions.get(name) === resolution) pendingResolutions.delete(name); + }); + pendingResolutions.set(name, resolution); + } + } + const resolved = await Promise.all( + [...new Set(names)].map((name) => pendingResolutions.get(name)), + ); + return resolved.filter((font) => font !== undefined); +} diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index f29fbc8c119b4..b1c2b99d8b43f 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -24,6 +24,13 @@ import * as MobileDatabase from "./mobile-database"; import * as MobileSecureStorage from "./mobile-secure-storage"; import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-storage"; +import { + normalizeFontSelections, + normalizeReplyEmphasisWeights, + type MobileFontSelections, + type ReplyEmphasisWeights, +} from "../lib/mobileFonts"; + const PREFERENCES_KEY = "t3code.preferences"; const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback"; const encodeJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); @@ -99,6 +106,10 @@ export interface Preferences { readonly darkThemeId?: MobileThemeId; readonly themeMode?: MobileThemeMode; readonly androidNavigationStyle?: AndroidNavigationStyle; + readonly fontSelections?: MobileFontSelections; + readonly replyFontSize?: number | null; + readonly replyEmphasisWeights?: ReplyEmphasisWeights; + readonly composerFontSize?: number | null; readonly baseFontSize?: number; readonly terminalFontSize?: number | null; readonly markdownFontSize?: number; @@ -188,6 +199,10 @@ function sanitizePreferences(parsed: Preferences): Preferences { darkThemeId?: MobileThemeId; themeMode?: MobileThemeMode; androidNavigationStyle?: AndroidNavigationStyle; + fontSelections?: MobileFontSelections; + replyFontSize?: number | null; + replyEmphasisWeights?: ReplyEmphasisWeights; + composerFontSize?: number | null; baseFontSize?: number; terminalFontSize?: number | null; markdownFontSize?: number; @@ -268,6 +283,14 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (parsed.androidNavigationStyle === "one-ui" || parsed.androidNavigationStyle === "material") { preferences.androidNavigationStyle = parsed.androidNavigationStyle; } + if (parsed.fontSelections !== undefined) + preferences.fontSelections = normalizeFontSelections(parsed.fontSelections); + if (parsed.replyEmphasisWeights !== undefined) + preferences.replyEmphasisWeights = normalizeReplyEmphasisWeights(parsed.replyEmphasisWeights); + if (typeof parsed.replyFontSize === "number" || parsed.replyFontSize === null) + preferences.replyFontSize = parsed.replyFontSize; + if (typeof parsed.composerFontSize === "number" || parsed.composerFontSize === null) + preferences.composerFontSize = parsed.composerFontSize; if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize; if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) { preferences.terminalFontSize = parsed.terminalFontSize; diff --git a/docs/user/appearance.md b/docs/user/appearance.md index c37855fdf3c87..29447e9370048 100644 --- a/docs/user/appearance.md +++ b/docs/user/appearance.md @@ -14,6 +14,20 @@ those modes. Customize these shortcuts under **Settings → Keybindings**. On mobile, open **Settings → Appearance**. Mobile has its own themes and text, code, and terminal preferences. It does not follow environment themes or defaults. +Under mobile **Appearance → Fonts**, choose fonts independently for the interface, replies, +composer, and code in messages or Markdown previews. Reply and composer sizes can follow Text size +or use their own size. Diffs and the terminal keep their existing fonts. + +On iOS, choose **Choose installed font…** from a font menu to open the system font picker. +iOS manages each family's matching styles. Installed fonts must be available to apps through iOS; +if a selected font becomes unavailable, the app uses its default. Android offers the built-in +system sans, serif, and monospace choices. + +Use **Reply emphasis weight** to choose an available Medium, Semibold, or Bold face for emphasized +reply text. Default keeps the font's existing emphasis. The choice is remembered for each reply +font, so changing fonts restores that font's choice. Unavailable weights are disabled. Reset font +choices and message sizes to clear these selections. Fonts and preferences stay on this device. + On Android 12 or newer, choose the **Material You** theme in Appearance to use colors from your wallpaper. Selecting another theme replaces those colors. Like other themes, Material You can be selected separately for light and dark appearances.