import { createContext, use, useCallback, useLayoutEffect, useMemo, type ReactNode } from "react";import { useColorScheme } from "react-native"; import { useAtomSet, useAtomValue } from "@effect/atom-react";import { AsyncResult } from "effect/unstable/reactivity"; import { Uniwind } from "uniwind"; import { resolveAppearance, resolveAppearancePreferences, resolveTextScaleVariables, type ResolvedAppearance,} from "../../../lib/appearancePreferences";import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences";import type { Preferences } from "../../../persistence/mobile-preferences";import { createMobileThemePairPatch, createMobileThemeSelectionPatch, getMobileThemeVariables, normalizeMobileThemeMode, resolveMobileThemeIds, type MobileThemeAppearance, type MobileThemeId, type MobileThemeIds, type MobileThemeMode,} from "../../../lib/mobileTheme";import { cacheTerminalFontSize } from "../../terminal/terminalUiState"; interface AppearancePreferencesContextValue { /** Effective values with base-size derivation applied. Use this for rendering. */ readonly appearance: ResolvedAppearance; readonly themeId: MobileThemeId; readonly themeIds: MobileThemeIds; readonly themeMode: MobileThemeMode; readonly themeAppearance: MobileThemeAppearance; readonly isReady: boolean; readonly setThemeIdForAppearance: ( appearance: MobileThemeAppearance, value: MobileThemeId, ) => void; readonly setThemeIdForBothAppearances: (value: MobileThemeId) => void; readonly setThemeMode: (value: MobileThemeMode) => void; readonly setBaseFontSize: (value: number) => void; /** Pass null to clear the override and follow the base font size. */ readonly setTerminalFontSize: (value: number | null) => void; /** Pass null to clear the override and follow the base font size. */ readonly setCodeFontSize: (value: number | null) => void; readonly setCodeWordBreak: (value: boolean) => void;} const AppearancePreferencesContext = createContext<AppearancePreferencesContextValue | null>(null); /** * Injects palette and text-scale variables into both adaptive stylesheets. * Updating the active sheet last lets the visible app settle in one pass. */function applyAppearanceVariables(baseFontSize: number, themeIds: MobileThemeIds) { const textVariables = resolveTextScaleVariables(baseFontSize); const currentTheme = Uniwind.currentTheme; const activeAppearance = currentTheme === "light" || currentTheme === "dark" ? currentTheme : null; for (const theme of ["light", "dark"] as const) { const variables = { ...getMobileThemeVariables(themeIds[theme], theme), ...textVariables }; if (theme !== activeAppearance) { Uniwind.updateCSSVariables(theme, variables); } } if (activeAppearance !== null) { Uniwind.updateCSSVariables(activeAppearance, { ...getMobileThemeVariables(themeIds[activeAppearance], activeAppearance), ...textVariables, }); }} export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) { const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const systemColorScheme = useColorScheme() === "dark" ? "dark" : "light"; const storedPreferences = AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : null; const preferences = useMemo( () => resolveAppearancePreferences(storedPreferences), [storedPreferences], ); const themeMode = normalizeMobileThemeMode(storedPreferences?.themeMode); const themeAppearance = themeMode === "system" ? systemColorScheme : themeMode; const themeIds = useMemo( () => resolveMobileThemeIds(storedPreferences ?? {}), [storedPreferences], ); const themeId = themeIds[themeAppearance]; const isReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting; useLayoutEffect(() => { applyAppearanceVariables(preferences.baseFontSize, themeIds); Uniwind.setTheme(themeMode); cacheTerminalFontSize(resolveAppearance(preferences).terminalFontSize); }, [preferences, themeIds, themeMode]); const updatePreferences = useCallback( (patch: Partial<Preferences>) => { savePreferences(patch); }, [savePreferences], ); const setThemeIdForAppearance = useCallback( (appearance: MobileThemeAppearance, value: MobileThemeId) => { updatePreferences( createMobileThemeSelectionPatch(themeIds, themeAppearance, appearance, value), ); }, [themeAppearance, themeIds, updatePreferences], ); const setThemeIdForBothAppearances = useCallback( (value: MobileThemeId) => { updatePreferences(createMobileThemePairPatch(value)); }, [updatePreferences], ); const setThemeMode = useCallback( (value: MobileThemeMode) => { updatePreferences({ themeMode: value }); }, [updatePreferences], ); const setBaseFontSize = useCallback( (value: number) => { updatePreferences({ baseFontSize: value }); }, [updatePreferences], ); const setTerminalFontSize = useCallback( (value: number | null) => { updatePreferences({ terminalFontSize: value }); }, [updatePreferences], ); const setCodeFontSize = useCallback( (value: number | null) => { updatePreferences({ codeFontSize: value }); }, [updatePreferences], ); const setCodeWordBreak = useCallback( (value: boolean) => { updatePreferences({ codeWordBreak: value }); }, [updatePreferences], ); const value = useMemo( (): AppearancePreferencesContextValue => ({ appearance: resolveAppearance(preferences), themeId, themeIds, themeMode, themeAppearance, isReady, setThemeIdForAppearance, setThemeIdForBothAppearances, setThemeMode, setBaseFontSize, setTerminalFontSize, setCodeFontSize, setCodeWordBreak, }), [ preferences, themeId, themeIds, themeMode, themeAppearance, isReady, setThemeIdForAppearance, setThemeIdForBothAppearances, setThemeMode, setBaseFontSize, setTerminalFontSize, setCodeFontSize, setCodeWordBreak, ], ); return ( <AppearancePreferencesContext.Provider value={value}> {props.children} </AppearancePreferencesContext.Provider> );} export function useAppearancePreferences(): AppearancePreferencesContextValue { const context = use(AppearancePreferencesContext); if (!context) { throw new Error("useAppearancePreferences must be used within AppearancePreferencesProvider"); } return context;}
import { createContext, use, useCallback, useLayoutEffect, useMemo, type ReactNode } from "react";
import { useColorScheme } from "react-native";
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
import { Uniwind } from "uniwind";
import {
resolveAppearance,
resolveAppearancePreferences,
resolveTextScaleVariables,
type ResolvedAppearance,
} from "../../../lib/appearancePreferences";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences";
import type { Preferences } from "../../../persistence/mobile-preferences";
import {
createMobileThemePairPatch,
createMobileThemeSelectionPatch,
getMobileThemeVariables,
normalizeMobileThemeMode,
resolveMobileThemeIds,
type MobileThemeAppearance,
type MobileThemeId,
type MobileThemeIds,
type MobileThemeMode,
} from "../../../lib/mobileTheme";
import { cacheTerminalFontSize } from "../../terminal/terminalUiState";
interface AppearancePreferencesContextValue {
/** Effective values with base-size derivation applied. Use this for rendering. */
readonly appearance: ResolvedAppearance;
readonly themeId: MobileThemeId;
readonly themeIds: MobileThemeIds;
readonly themeMode: MobileThemeMode;
readonly themeAppearance: MobileThemeAppearance;
readonly isReady: boolean;
readonly setThemeIdForAppearance: (
appearance: MobileThemeAppearance,
value: MobileThemeId,
) => void;
readonly setThemeIdForBothAppearances: (value: MobileThemeId) => void;
readonly setThemeMode: (value: MobileThemeMode) => void;
readonly setBaseFontSize: (value: number) => void;
/** Pass null to clear the override and follow the base font size. */
readonly setTerminalFontSize: (value: number | null) => void;
/** Pass null to clear the override and follow the base font size. */
readonly setCodeFontSize: (value: number | null) => void;
readonly setCodeWordBreak: (value: boolean) => void;
}
const AppearancePreferencesContext = createContext<AppearancePreferencesContextValue | null>(null);
/**
* Injects palette and text-scale variables into both adaptive stylesheets.
* Updating the active sheet last lets the visible app settle in one pass.
*/
function applyAppearanceVariables(baseFontSize: number, themeIds: MobileThemeIds) {
const textVariables = resolveTextScaleVariables(baseFontSize);
const currentTheme = Uniwind.currentTheme;
const activeAppearance =
currentTheme === "light" || currentTheme === "dark" ? currentTheme : null;
for (const theme of ["light", "dark"] as const) {
const variables = { ...getMobileThemeVariables(themeIds[theme], theme), ...textVariables };
if (theme !== activeAppearance) {
Uniwind.updateCSSVariables(theme, variables);
}
}
if (activeAppearance !== null) {
Uniwind.updateCSSVariables(activeAppearance, {
...getMobileThemeVariables(themeIds[activeAppearance], activeAppearance),
...textVariables,
});
}
}
export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) {
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const systemColorScheme = useColorScheme() === "dark" ? "dark" : "light";
const storedPreferences = AsyncResult.isSuccess(preferencesResult)
? preferencesResult.value
: null;
const preferences = useMemo(
() => resolveAppearancePreferences(storedPreferences),
[storedPreferences],
);
const themeMode = normalizeMobileThemeMode(storedPreferences?.themeMode);
const themeAppearance = themeMode === "system" ? systemColorScheme : themeMode;
const themeIds = useMemo(
() => resolveMobileThemeIds(storedPreferences ?? {}),
[storedPreferences],
);
const themeId = themeIds[themeAppearance];
const isReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting;
useLayoutEffect(() => {
applyAppearanceVariables(preferences.baseFontSize, themeIds);
Uniwind.setTheme(themeMode);
cacheTerminalFontSize(resolveAppearance(preferences).terminalFontSize);
}, [preferences, themeIds, themeMode]);
const updatePreferences = useCallback(
(patch: Partial<Preferences>) => {
savePreferences(patch);
},
[savePreferences],
);
const setThemeIdForAppearance = useCallback(
(appearance: MobileThemeAppearance, value: MobileThemeId) => {
updatePreferences(
createMobileThemeSelectionPatch(themeIds, themeAppearance, appearance, value),
);
},
[themeAppearance, themeIds, updatePreferences],
);
const setThemeIdForBothAppearances = useCallback(
(value: MobileThemeId) => {
updatePreferences(createMobileThemePairPatch(value));
},
[updatePreferences],
);
const setThemeMode = useCallback(
(value: MobileThemeMode) => {
updatePreferences({ themeMode: value });
},
[updatePreferences],
);
const setBaseFontSize = useCallback(
(value: number) => {
updatePreferences({ baseFontSize: value });
},
[updatePreferences],
);
const setTerminalFontSize = useCallback(
(value: number | null) => {
updatePreferences({ terminalFontSize: value });
},
[updatePreferences],
);
const setCodeFontSize = useCallback(
(value: number | null) => {
updatePreferences({ codeFontSize: value });
},
[updatePreferences],
);
const setCodeWordBreak = useCallback(
(value: boolean) => {
updatePreferences({ codeWordBreak: value });
},
[updatePreferences],
);
const value = useMemo(
(): AppearancePreferencesContextValue => ({
appearance: resolveAppearance(preferences),
themeId,
themeIds,
themeMode,
themeAppearance,
isReady,
setThemeIdForAppearance,
setThemeIdForBothAppearances,
setThemeMode,
setBaseFontSize,
setTerminalFontSize,
setCodeFontSize,
setCodeWordBreak,
}),
[
preferences,
themeId,
themeIds,
themeMode,
themeAppearance,
isReady,
setThemeIdForAppearance,
setThemeIdForBothAppearances,
setThemeMode,
setBaseFontSize,
setTerminalFontSize,
setCodeFontSize,
setCodeWordBreak,
],
);
return (
<AppearancePreferencesContext.Provider value={value}>
{props.children}
</AppearancePreferencesContext.Provider>
);
}
export function useAppearancePreferences(): AppearancePreferencesContextValue {
const context = use(AppearancePreferencesContext);
if (!context) {
throw new Error("useAppearancePreferences must be used within AppearancePreferencesProvider");
}
return context;
}