| 1 | import { |
| 2 | createContext, |
| 3 | startTransition, |
| 4 | use, |
| 5 | useCallback, |
| 6 | useEffect, |
| 7 | useState, |
| 8 | useLayoutEffect, |
| 9 | useMemo, |
| 10 | useRef, |
| 11 | type ReactNode, |
| 12 | } from "react"; |
| 13 | import { AppState, Appearance, Platform, useColorScheme } from "react-native"; |
| 14 | |
| 15 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 16 | import { AsyncResult } from "effect/reactivity"; |
| 17 | |
| 18 | import { ScopedTheme, ScopedVariables, Uniwind } from "uniwind"; |
| 19 | |
| 20 | import { |
| 21 | resolveAppearance, |
| 22 | resolveAppearancePreferences, |
| 23 | type ResolvedAppearance, |
| 24 | } from "../../../lib/appearancePreferences"; |
| 25 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; |
| 26 | import type { Preferences } from "../../../persistence/mobile-preferences"; |
| 27 | import { isSystemColorsAvailable, readSystemColorPalettes } from "../../../lib/materialYouPalette"; |
| 28 | import { materialYouPaletteToMobileThemeVariables } from "../../../lib/materialYouTheme"; |
| 29 | import { getMobileThemeRuntimeVariables } from "../../../lib/mobileThemeVariables"; |
| 30 | import type { MobileThemeVariables } from "../../../lib/mobileTheme"; |
| 31 | import { |
| 32 | createMobileThemePairPatch, |
| 33 | createMobileThemeSelectionPatch, |
| 34 | normalizeMobileThemeMode, |
| 35 | resolveMobileThemeIds, |
| 36 | type MobileThemeAppearance, |
| 37 | type MobileThemeId, |
| 38 | type MobileThemeIds, |
| 39 | type MobileThemeMode, |
| 40 | } from "../../../lib/mobileTheme"; |
| 41 | import { |
| 42 | createMobileThemeRuntimeOperations, |
| 43 | getMobileUniwindThemeName, |
| 44 | type MobileThemeRuntimeState, |
| 45 | } from "../../../lib/mobileThemeRuntime"; |
| 46 | |
| 47 | interface AppearancePreferencesContextValue { |
| 48 | /** Effective values with base-size derivation applied. Use this for rendering. */ |
| 49 | readonly appearance: ResolvedAppearance; |
| 50 | readonly themeId: MobileThemeId; |
| 51 | readonly themeIds: MobileThemeIds; |
| 52 | readonly themeMode: MobileThemeMode; |
| 53 | readonly themeAppearance: MobileThemeAppearance; |
| 54 | readonly systemColorsAvailable: boolean; |
| 55 | readonly systemColorsActive: boolean; |
| 56 | readonly themeVariables: MobileThemeVariables; |
| 57 | readonly themeVariablesByAppearance: Readonly< |
| 58 | Record<MobileThemeAppearance, MobileThemeVariables> |
| 59 | >; |
| 60 | readonly systemColorPalettes: ReturnType<typeof readSystemColorPalettes>; |
| 61 | readonly isReady: boolean; |
| 62 | readonly setThemeIdForAppearance: ( |
| 63 | appearance: MobileThemeAppearance, |
| 64 | value: MobileThemeId, |
| 65 | ) => void; |
| 66 | readonly setThemeIdForBothAppearances: (value: MobileThemeId) => void; |
| 67 | readonly setThemeMode: (value: MobileThemeMode) => void; |
| 68 | readonly setBaseFontSize: (value: number) => void; |
| 69 | /** Pass null to clear the override and follow the base font size. */ |
| 70 | readonly setTerminalFontSize: (value: number | null) => void; |
| 71 | /** Pass null to clear the override and follow the base font size. */ |
| 72 | readonly setCodeFontSize: (value: number | null) => void; |
| 73 | readonly setCodeWordBreak: (value: boolean) => void; |
| 74 | } |
| 75 | |
| 76 | const AppearancePreferencesContext = createContext<AppearancePreferencesContextValue | null>(null); |
| 77 | |
| 78 | export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) { |
| 79 | const preferencesResult = useAtomValue(mobilePreferencesAtom); |
| 80 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); |
| 81 | const systemColorScheme = useColorScheme() === "dark" ? "dark" : "light"; |
| 82 | const storedPreferences = AsyncResult.isSuccess(preferencesResult) |
| 83 | ? preferencesResult.value |
| 84 | : null; |
| 85 | const preferences = useMemo( |
| 86 | () => resolveAppearancePreferences(storedPreferences), |
| 87 | [storedPreferences], |
| 88 | ); |
| 89 | const themeMode = normalizeMobileThemeMode(storedPreferences?.themeMode); |
| 90 | const themeAppearance = themeMode === "system" ? systemColorScheme : themeMode; |
| 91 | const resolvedThemeIds = resolveMobileThemeIds(storedPreferences ?? {}); |
| 92 | const themeIds = useMemo<MobileThemeIds>( |
| 93 | () => ({ light: resolvedThemeIds.light, dark: resolvedThemeIds.dark }), |
| 94 | [resolvedThemeIds.dark, resolvedThemeIds.light], |
| 95 | ); |
| 96 | const themeId = themeIds[themeAppearance]; |
| 97 | const systemColorsActive = themeId === "material-you" && isSystemColorsAvailable; |
| 98 | const [systemColorPalettes, setSystemColorPalettes] = useState(readSystemColorPalettes); |
| 99 | useEffect(() => { |
| 100 | if (!isSystemColorsAvailable) return; |
| 101 | const refresh = () => { |
| 102 | const next = readSystemColorPalettes(); |
| 103 | setSystemColorPalettes((previous) => |
| 104 | JSON.stringify(previous) === JSON.stringify(next) ? previous : next, |
| 105 | ); |
| 106 | }; |
| 107 | const subscription = AppState.addEventListener("change", (state) => { |
| 108 | if (state === "active") refresh(); |
| 109 | }); |
| 110 | const focusSubscription = AppState.addEventListener("focus", refresh); |
| 111 | return () => { |
| 112 | subscription.remove(); |
| 113 | focusSubscription.remove(); |
| 114 | }; |
| 115 | }, []); |
| 116 | const themeVariablesByAppearance = useMemo(() => { |
| 117 | const resolve = (appearance: MobileThemeAppearance) => { |
| 118 | const base = getMobileThemeRuntimeVariables(themeIds[appearance], appearance, Platform.OS); |
| 119 | return themeIds[appearance] === "material-you" && systemColorPalettes |
| 120 | ? materialYouPaletteToMobileThemeVariables( |
| 121 | systemColorPalettes[appearance], |
| 122 | appearance, |
| 123 | base, |
| 124 | ) |
| 125 | : base; |
| 126 | }; |
| 127 | return { light: resolve("light"), dark: resolve("dark") }; |
| 128 | }, [themeIds, systemColorPalettes]); |
| 129 | const themeVariables = themeVariablesByAppearance[themeAppearance]; |
| 130 | const activeThemeName = getMobileUniwindThemeName(themeId, themeAppearance); |
| 131 | const { baseFontSize, codeFontSize, codeWordBreak, terminalFontSize } = preferences; |
| 132 | const appearance = useMemo( |
| 133 | () => resolveAppearance({ baseFontSize, codeFontSize, codeWordBreak, terminalFontSize }), |
| 134 | [baseFontSize, codeFontSize, codeWordBreak, terminalFontSize], |
| 135 | ); |
| 136 | // Preference patches are optimistic. Keep controls interactive while a save is |
| 137 | // in flight so rapid theme choices can supersede one another immediately. |
| 138 | const isReady = AsyncResult.isSuccess(preferencesResult); |
| 139 | const runtimeState = useMemo<MobileThemeRuntimeState>( |
| 140 | () => ({ |
| 141 | baseFontSize, |
| 142 | themeAppearance, |
| 143 | themeMode, |
| 144 | }), |
| 145 | [baseFontSize, themeAppearance, themeMode], |
| 146 | ); |
| 147 | const appliedRuntimeStateRef = useRef<MobileThemeRuntimeState | null>(null); |
| 148 | const selectedThemeIdsRef = useRef(themeIds); |
| 149 | |
| 150 | const applyThemeRuntime = useCallback((next: MobileThemeRuntimeState) => { |
| 151 | const operations = createMobileThemeRuntimeOperations(appliedRuntimeStateRef.current, next); |
| 152 | for (const operation of operations) { |
| 153 | if (operation.kind === "update-text-variables") { |
| 154 | Uniwind.updateCSSVariables(operation.themeName, operation.variables); |
| 155 | continue; |
| 156 | } |
| 157 | if (operation.kind === "set-appearance-mode") { |
| 158 | Appearance.setColorScheme( |
| 159 | operation.themeMode === "system" ? "unspecified" : operation.appearance, |
| 160 | ); |
| 161 | } |
| 162 | } |
| 163 | appliedRuntimeStateRef.current = next; |
| 164 | }, []); |
| 165 | |
| 166 | const syncThemeRuntime = useCallback( |
| 167 | (next: MobileThemeRuntimeState) => applyThemeRuntime(next), |
| 168 | [applyThemeRuntime], |
| 169 | ); |
| 170 | |
| 171 | const updatePreferences = useCallback( |
| 172 | (patch: Partial<Preferences>) => { |
| 173 | startTransition(() => savePreferences(patch)); |
| 174 | }, |
| 175 | [savePreferences], |
| 176 | ); |
| 177 | |
| 178 | const updateThemePreferences = useCallback( |
| 179 | (patch: Partial<Preferences>) => { |
| 180 | // Theme selection owns the visible root ScopedTheme value. Keep its |
| 181 | // optimistic atom update urgent so the first frame after a press is the |
| 182 | // complete new palette rather than a deferred transition render. |
| 183 | savePreferences(patch); |
| 184 | }, |
| 185 | [savePreferences], |
| 186 | ); |
| 187 | |
| 188 | useLayoutEffect(() => { |
| 189 | selectedThemeIdsRef.current = themeIds; |
| 190 | syncThemeRuntime(runtimeState); |
| 191 | }, [runtimeState, syncThemeRuntime, themeIds]); |
| 192 | |
| 193 | const setThemeIdForAppearance = useCallback( |
| 194 | (appearance: MobileThemeAppearance, value: MobileThemeId) => { |
| 195 | const patch = createMobileThemeSelectionPatch( |
| 196 | selectedThemeIdsRef.current, |
| 197 | themeAppearance, |
| 198 | appearance, |
| 199 | value, |
| 200 | ); |
| 201 | selectedThemeIdsRef.current = resolveMobileThemeIds(patch); |
| 202 | updateThemePreferences(patch); |
| 203 | }, |
| 204 | [themeAppearance, updateThemePreferences], |
| 205 | ); |
| 206 | |
| 207 | const setThemeIdForBothAppearances = useCallback( |
| 208 | (value: MobileThemeId) => { |
| 209 | const patch = createMobileThemePairPatch(value); |
| 210 | selectedThemeIdsRef.current = resolveMobileThemeIds(patch); |
| 211 | updateThemePreferences(patch); |
| 212 | }, |
| 213 | [updateThemePreferences], |
| 214 | ); |
| 215 | |
| 216 | const setThemeMode = useCallback( |
| 217 | (value: MobileThemeMode) => { |
| 218 | const current = appliedRuntimeStateRef.current ?? runtimeState; |
| 219 | |
| 220 | // Clear a forced native appearance before publishing System. The |
| 221 | // resulting useColorScheme notification still sees the previous forced |
| 222 | // preference, so React batches the actual system palette into the one |
| 223 | // urgent preference commit below. |
| 224 | if (value === "system") { |
| 225 | Appearance.setColorScheme("unspecified"); |
| 226 | } |
| 227 | const nextAppearance = |
| 228 | value === "system" ? (Appearance.getColorScheme() === "dark" ? "dark" : "light") : value; |
| 229 | const next = { |
| 230 | ...current, |
| 231 | themeAppearance: nextAppearance, |
| 232 | themeMode: value, |
| 233 | }; |
| 234 | |
| 235 | updateThemePreferences({ themeMode: value }); |
| 236 | if (value === "system") { |
| 237 | appliedRuntimeStateRef.current = next; |
| 238 | } else { |
| 239 | syncThemeRuntime(next); |
| 240 | } |
| 241 | }, |
| 242 | [runtimeState, syncThemeRuntime, updateThemePreferences], |
| 243 | ); |
| 244 | |
| 245 | const setBaseFontSize = useCallback( |
| 246 | (value: number) => { |
| 247 | const current = appliedRuntimeStateRef.current ?? runtimeState; |
| 248 | syncThemeRuntime({ ...current, baseFontSize: value }); |
| 249 | updatePreferences({ baseFontSize: value }); |
| 250 | }, |
| 251 | [runtimeState, syncThemeRuntime, updatePreferences], |
| 252 | ); |
| 253 | |
| 254 | const setTerminalFontSize = useCallback( |
| 255 | (value: number | null) => { |
| 256 | updatePreferences({ terminalFontSize: value }); |
| 257 | }, |
| 258 | [updatePreferences], |
| 259 | ); |
| 260 | |
| 261 | const setCodeFontSize = useCallback( |
| 262 | (value: number | null) => { |
| 263 | updatePreferences({ codeFontSize: value }); |
| 264 | }, |
| 265 | [updatePreferences], |
| 266 | ); |
| 267 | |
| 268 | const setCodeWordBreak = useCallback( |
| 269 | (value: boolean) => { |
| 270 | updatePreferences({ codeWordBreak: value }); |
| 271 | }, |
| 272 | [updatePreferences], |
| 273 | ); |
| 274 | |
| 275 | const value = useMemo( |
| 276 | (): AppearancePreferencesContextValue => ({ |
| 277 | appearance, |
| 278 | themeId, |
| 279 | themeIds, |
| 280 | themeMode, |
| 281 | themeAppearance, |
| 282 | systemColorsAvailable: isSystemColorsAvailable, |
| 283 | systemColorsActive, |
| 284 | themeVariables, |
| 285 | themeVariablesByAppearance, |
| 286 | systemColorPalettes, |
| 287 | isReady, |
| 288 | setThemeIdForAppearance, |
| 289 | setThemeIdForBothAppearances, |
| 290 | setThemeMode, |
| 291 | setBaseFontSize, |
| 292 | setTerminalFontSize, |
| 293 | setCodeFontSize, |
| 294 | setCodeWordBreak, |
| 295 | }), |
| 296 | [ |
| 297 | appearance, |
| 298 | themeId, |
| 299 | themeIds, |
| 300 | themeMode, |
| 301 | themeAppearance, |
| 302 | systemColorsActive, |
| 303 | themeVariables, |
| 304 | themeVariablesByAppearance, |
| 305 | systemColorPalettes, |
| 306 | isReady, |
| 307 | setThemeIdForAppearance, |
| 308 | setThemeIdForBothAppearances, |
| 309 | setThemeMode, |
| 310 | setBaseFontSize, |
| 311 | setTerminalFontSize, |
| 312 | setCodeFontSize, |
| 313 | setCodeWordBreak, |
| 314 | ], |
| 315 | ); |
| 316 | |
| 317 | return ( |
| 318 | <AppearancePreferencesContext.Provider value={value}> |
| 319 | <ScopedTheme theme={activeThemeName}> |
| 320 | <ScopedVariables variables={themeVariables}>{props.children}</ScopedVariables> |
| 321 | </ScopedTheme> |
| 322 | </AppearancePreferencesContext.Provider> |
| 323 | ); |
| 324 | } |
| 325 | |
| 326 | export function useAppearancePreferences(): AppearancePreferencesContextValue { |
| 327 | const context = use(AppearancePreferencesContext); |
| 328 | if (!context) { |
| 329 | throw new Error("useAppearancePreferences must be used within AppearancePreferencesProvider"); |
| 330 | } |
| 331 | return context; |
| 332 | } |