From 7f04fcee9ef60d125903efbba71a415a7b7111ec Mon Sep 17 00:00:00 2001 From: "whey.party" Date: Mon, 1 Dec 2025 10:35:06 +0100 Subject: [PATCH] hue shift slider --- package.json | 2 + src/alf/index.tsx | 86 ++++++++- src/components/forms/Slider.tsx | 187 ++++++++++++++++++++ src/lib/ThemeContext.tsx | 8 +- src/screens/Settings/AppearanceSettings.tsx | 18 +- src/state/persisted/schema.ts | 2 + src/state/shell/color-mode.tsx | 15 +- yarn.lock | 10 ++ 8 files changed, 315 insertions(+), 13 deletions(-) create mode 100644 src/components/forms/Slider.tsx diff --git a/package.json b/package.json index 40f730829..90befd7c7 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ "babel-plugin-transform-remove-console": "^6.9.4", "bcp-47": "^2.1.0", "bcp-47-match": "^2.0.3", + "culori": "^4.0.2", "date-fns": "^2.30.0", "email-validator": "^2.0.4", "emoji-mart": "^5.5.2", @@ -235,6 +236,7 @@ "@sentry/webpack-plugin": "^3.2.2", "@testing-library/jest-native": "^5.4.3", "@testing-library/react-native": "^13.2.0", + "@types/culori": "^4.0.1", "@types/jest": "29.5.14", "@types/lodash.chunk": "^4.2.7", "@types/lodash.debounce": "^4.0.7", diff --git a/src/alf/index.tsx b/src/alf/index.tsx index d48a2f3d2..2c6a9c316 100644 --- a/src/alf/index.tsx +++ b/src/alf/index.tsx @@ -1,5 +1,6 @@ import React from 'react' -import {type Theme, type ThemeName} from '@bsky.app/alf' +import {createTheme, type Theme, type ThemeName} from '@bsky.app/alf' +import {formatHex, modeOklch, useMode as utilMode} from 'culori' import {useThemePrefs} from '#/state/shell/color-mode' import { @@ -14,6 +15,7 @@ import { blueskyscheme, deerscheme, kittyscheme, + type Palette, reddwarfscheme, themes, witchskyscheme, @@ -68,6 +70,77 @@ Context.displayName = 'AlfContext' export type SchemeType = typeof themes +function changeHue(color: string, hueShift: number) { + if (!hueShift || hueShift === 0) return color + + let lablch = utilMode(modeOklch) + const parsed = lablch(color) + + if (!parsed) return color + + const {l, c, h} = parsed as {l: number; c: number; h: number | undefined} + + const currentHue = h || 0 + + const newHue = (currentHue + hueShift + 360) % 360 + + return formatHex({mode: 'oklch', l, c, h: newHue}) +} + +export function shiftPalette(palette: Palette, hueShift: number): Palette { + const newPalette = {...palette} + const keys = Object.keys(newPalette) as Array + + keys.forEach(key => { + newPalette[key] = changeHue(newPalette[key], hueShift) + }) + + return newPalette +} + +export function hueShifter(scheme: SchemeType, hueShift: number): SchemeType { + if (!hueShift || hueShift === 0) { + return scheme + } + + const lightPalette = shiftPalette(scheme.lightPalette, hueShift) + const darkPalette = shiftPalette(scheme.darkPalette, hueShift) + const dimPalette = shiftPalette(scheme.dimPalette, hueShift) + + const light = createTheme({ + scheme: 'light', + name: 'light', + palette: lightPalette, + }) + + const dark = createTheme({ + scheme: 'dark', + name: 'dark', + palette: darkPalette, + options: { + shadowOpacity: 0.4, + }, + }) + + const dim = createTheme({ + scheme: 'dark', + name: 'dim', + palette: dimPalette, + options: { + shadowOpacity: 0.4, + }, + }) + + return { + lightPalette, + darkPalette, + dimPalette, + light, + dark, + dim, + } +} + export function selectScheme(colorScheme: string | undefined): SchemeType { switch (colorScheme) { case 'witchsky': @@ -93,7 +166,7 @@ export function ThemeProvider({ children, theme: themeName, }: React.PropsWithChildren<{theme: ThemeName}>) { - const {colorScheme} = useThemePrefs() + const {colorScheme, hue} = useThemePrefs() const currentScheme = selectScheme(colorScheme) const [fontScale, setFontScale] = React.useState(() => getFontScale(), @@ -126,9 +199,9 @@ export function ThemeProvider({ const value = React.useMemo( () => ({ - themes: currentScheme, + themes: hueShifter(currentScheme, hue), themeName: themeName, - theme: currentScheme[themeName], + theme: hueShifter(currentScheme, hue)[themeName], fonts: { scale: fontScale, scaleMultiplier: fontScaleMultiplier, @@ -140,12 +213,13 @@ export function ThemeProvider({ }), [ currentScheme, + hue, themeName, fontScale, - setFontScaleAndPersist, + fontScaleMultiplier, fontFamily, + setFontScaleAndPersist, setFontFamilyAndPersist, - fontScaleMultiplier, ], ) diff --git a/src/components/forms/Slider.tsx b/src/components/forms/Slider.tsx new file mode 100644 index 000000000..401e4e842 --- /dev/null +++ b/src/components/forms/Slider.tsx @@ -0,0 +1,187 @@ +import {useCallback, useEffect, useRef, useState} from 'react' +import {type StyleProp, View, type ViewStyle} from 'react-native' +import {Gesture, GestureDetector} from 'react-native-gesture-handler' +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated' + +import {useHaptics} from '#/lib/haptics' +import {atoms as a, platform, useTheme} from '#/alf' + +export interface SliderProps { + value: number + onValueChange: (value: number) => void + min?: number + max?: number + step?: number + label?: string + accessibilityHint?: string + style?: StyleProp + debounce?: number +} + +export function Slider({ + value, + onValueChange, + min = 0, + max = 100, + step = 1, + label, + accessibilityHint, + style, + debounce, +}: SliderProps) { + const t = useTheme() + const playHaptic = useHaptics() + const timerRef = useRef(undefined) + + const [width, setWidth] = useState(0) + + const progress = useSharedValue(0) + const isPressed = useSharedValue(false) + + useEffect(() => { + if (!isPressed.value) { + const clamped = Math.min(Math.max(value, min), max) + const normalized = (clamped - min) / (max - min) + progress.value = withSpring(normalized, {overshootClamping: true}) + } + }, [value, min, max, progress, isPressed]) + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + } + }, []) + + const updateValueJS = useCallback( + (val: number) => { + if (debounce && debounce > 0) { + if (timerRef.current) { + clearTimeout(timerRef.current) + } + timerRef.current = setTimeout(() => { + onValueChange(val) + }, debounce) + } else { + onValueChange(val) + } + }, + [onValueChange, debounce], + ) + + const handleValueChange = useCallback( + (newProgress: number) => { + 'worklet' + const rawValue = min + newProgress * (max - min) + + const steppedValue = Math.round(rawValue / step) * step + const clamped = Math.min(Math.max(steppedValue, min), max) + + runOnJS(updateValueJS)(clamped) + }, + [min, max, step, updateValueJS], + ) + + const pan = Gesture.Pan() + .onBegin(e => { + isPressed.value = true + + if (width > 0) { + const newProgress = Math.min(Math.max(e.x / width, 0), 1) + progress.value = newProgress + handleValueChange(newProgress) + } + }) + .onUpdate(e => { + if (width === 0) return + const newProgress = Math.min(Math.max(e.x / width, 0), 1) + progress.value = newProgress + handleValueChange(newProgress) + }) + .onFinalize(() => { + isPressed.value = false + runOnJS(playHaptic)('Light') + }) + + const thumbAnimatedStyle = useAnimatedStyle(() => { + const translateX = progress.value * width + return { + transform: [ + {translateX: translateX - 12}, + {scale: isPressed.value ? 1.1 : 1}, + ], + } + }) + + const trackAnimatedStyle = useAnimatedStyle(() => { + return { + width: `${progress.value * 100}%`, + } + }) + + return ( + + + setWidth(e.nativeEvent.layout.width)}> + + + + + + + + + ) +} diff --git a/src/lib/ThemeContext.tsx b/src/lib/ThemeContext.tsx index e51f6a19d..158cf0749 100644 --- a/src/lib/ThemeContext.tsx +++ b/src/lib/ThemeContext.tsx @@ -4,7 +4,7 @@ import {type TextStyle, type ViewStyle} from 'react-native' import {type ThemeName} from '@bsky.app/alf' import {useThemePrefs} from '#/state/shell/color-mode' -import {type SchemeType, selectScheme} from '#/alf' +import {hueShifter, type SchemeType, selectScheme} from '#/alf' import {themes} from '#/alf/themes' import {darkTheme, defaultTheme, dimTheme} from './themes' @@ -124,12 +124,12 @@ export const ThemeProvider: React.FC = ({ theme, children, }) => { - const {colorScheme} = useThemePrefs() + const {colorScheme, hue} = useThemePrefs() const themeValue = useMemo(() => { - const currentScheme = selectScheme(colorScheme) + const currentScheme = hueShifter(selectScheme(colorScheme), hue) return getTheme(theme, currentScheme) - }, [theme, colorScheme]) + }, [theme, colorScheme, hue]) return ( {children} diff --git a/src/screens/Settings/AppearanceSettings.tsx b/src/screens/Settings/AppearanceSettings.tsx index 5bd0a7b02..de049fcee 100644 --- a/src/screens/Settings/AppearanceSettings.tsx +++ b/src/screens/Settings/AppearanceSettings.tsx @@ -18,6 +18,7 @@ import {useSetThemePrefs, useThemePrefs} from '#/state/shell' import {SettingsListItem as AppIconSettingsListItem} from '#/screens/Settings/AppIconSettings/SettingsListItem' import {type Alf, atoms as a, native, useAlf, useTheme} from '#/alf' import * as SegmentedControl from '#/components/forms/SegmentedControl' +import {Slider} from '#/components/forms/Slider' import * as Toggle from '#/components/forms/Toggle' import {type Props as SVGIconProps} from '#/components/icons/common' import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon' @@ -36,8 +37,9 @@ export function AppearanceSettingsScreen({}: Props) { const {fonts} = useAlf() const t = useTheme() - const {colorMode, colorScheme, darkTheme} = useThemePrefs() - const {setColorMode, setColorScheme, setDarkTheme} = useSetThemePrefs() + const {colorMode, colorScheme, darkTheme, hue} = useThemePrefs() + const {setColorMode, setColorScheme, setDarkTheme, setHue} = + useSetThemePrefs() const onChangeAppearance = useCallback( (value: 'light' | 'system' | 'dark') => { @@ -178,6 +180,18 @@ export function AppearanceSettingsScreen({}: Props) { ))} + + Hue shift the colors: + + diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 30c0e01d6..1fff40550 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -58,6 +58,7 @@ const schema = z.object({ 'kitty', 'reddwarf', ]), + hue: z.number(), session: z.object({ accounts: z.array(accountSchema), currentAccount: currentAccountSchema.optional(), @@ -174,6 +175,7 @@ export const defaults: Schema = { colorMode: 'system', darkTheme: 'dim', colorScheme: 'witchsky', + hue: 0, session: { accounts: [], currentAccount: undefined, diff --git a/src/state/shell/color-mode.tsx b/src/state/shell/color-mode.tsx index 05c7e7ee9..31d80ca74 100644 --- a/src/state/shell/color-mode.tsx +++ b/src/state/shell/color-mode.tsx @@ -6,17 +6,20 @@ type StateContext = { colorMode: persisted.Schema['colorMode'] darkTheme: persisted.Schema['darkTheme'] colorScheme: persisted.Schema['colorScheme'] + hue: persisted.Schema['hue'] } type SetContext = { setColorMode: (v: persisted.Schema['colorMode']) => void setDarkTheme: (v: persisted.Schema['darkTheme']) => void setColorScheme: (v: persisted.Schema['colorScheme']) => void + setHue: (v: persisted.Schema['hue']) => void } const stateContext = React.createContext({ colorMode: 'system', darkTheme: 'dark', colorScheme: 'witchsky', + hue: 0, }) stateContext.displayName = 'ColorModeStateContext' const setContext = React.createContext({} as SetContext) @@ -28,14 +31,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const [colorScheme, setColorScheme] = React.useState( persisted.get('colorScheme'), ) + const [hue, setHue] = React.useState(persisted.get('hue')) const stateContextValue = React.useMemo( () => ({ colorMode, darkTheme, colorScheme, + hue, }), - [colorMode, darkTheme, colorScheme], + [colorMode, darkTheme, colorScheme, hue], ) const setContextValue = React.useMemo( @@ -52,6 +57,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) { setColorScheme(_colorScheme) persisted.write('colorScheme', _colorScheme) }, + setHue: (_hue: persisted.Schema['hue']) => { + setHue(_hue) + persisted.write('hue', _hue) + }, }), [], ) @@ -66,10 +75,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const unsub3 = persisted.onUpdate('colorScheme', nextColorScheme => { setColorScheme(nextColorScheme) }) + const unsub4 = persisted.onUpdate('hue', nextHue => { + setHue(nextHue) + }) return () => { unsub1() unsub2() unsub3() + unsub4() } }, []) diff --git a/yarn.lock b/yarn.lock index 67ac38084..7e1954909 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7442,6 +7442,11 @@ dependencies: "@types/node" "*" +"@types/culori@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@types/culori/-/culori-4.0.1.tgz#39ed095e0ef7107342d9091b1707ae8fb8681297" + integrity sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ== + "@types/elliptic@^6.4.9": version "6.4.18" resolved "https://registry.yarnpkg.com/@types/elliptic/-/elliptic-6.4.18.tgz#bc96e26e1ccccbabe8b6f0e409c85898635482e1" @@ -9975,6 +9980,11 @@ csstype@^3.0.2: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b" integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ== +culori@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/culori/-/culori-4.0.2.tgz#fbb28dbeb8d13d0eeab7520191f74ab822a8ca71" + integrity sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw== + data-urls@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-3.0.2.tgz#9cf24a477ae22bcef5cd5f6f0bfbc1d2d3be9143" -- 2.47.3