diff --git a/rspack.config.ts b/rspack.config.ts index afc123987..ac1c3632f 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -3,7 +3,7 @@ import rspack from '@rspack/core' import {RspackManifestPlugin} from 'rspack-manifest-plugin' import {sentryWebpackPlugin} from '@sentry/webpack-plugin' import {version} from './package.json' -import { existsSync, readdirSync } from 'node:fs' +import {existsSync, readdirSync} from 'node:fs' const GENERATE_STATS = process.env.GENERATE_STATS === '1' const isProduction = process.env.NODE_ENV === 'production' diff --git a/src/alf/index.tsx b/src/alf/index.tsx index f44ecf819..7afa0b37f 100644 --- a/src/alf/index.tsx +++ b/src/alf/index.tsx @@ -17,16 +17,18 @@ import { deerscheme, evergardenscheme, kittyscheme, - material3scheme, type Palette, reddwarfscheme, themes, witchskyscheme, zeppelinscheme, } from '#/alf/themes' -import {IS_ANDROID} from '#/env' import {type Device} from '#/storage' -import {MaterialYouPaletteProvider} from './util/materialYou' +import {getMaterial3Colors} from './util/material3Theme' +import { + MaterialYouPaletteProvider, + useMaterialYouPalette, +} from './util/materialYou' export { type TextStyleProp, @@ -153,42 +155,57 @@ export function hueShifter(scheme: SchemeType, hueShift: number): SchemeType { } } -export function selectScheme(colorScheme: string | undefined): SchemeType { - switch (colorScheme) { - case 'witchsky': - return witchskyscheme - case 'bluesky': - return blueskyscheme - case 'blacksky': - return blackskyscheme - case 'deer': - return deerscheme - case 'zeppelin': - return zeppelinscheme - case 'kitty': - return kittyscheme - case 'reddwarf': - return reddwarfscheme - case 'catppuccin': - return catppuccinscheme - case 'evergarden': - return evergardenscheme - case 'material3': - if (IS_ANDROID) { - return material3scheme - } - return witchskyscheme - default: - return themes - } +export function useScheme(): SchemeType { + const {hue, colorScheme} = useThemePrefs() + const palette = useMaterialYouPalette() + + return useMemo(() => { + let currentScheme = themes + switch (colorScheme) { + case 'witchsky': + currentScheme = witchskyscheme + break + case 'bluesky': + currentScheme = blueskyscheme + break + case 'blacksky': + currentScheme = blackskyscheme + break + case 'deer': + currentScheme = deerscheme + break + case 'zeppelin': + currentScheme = zeppelinscheme + break + case 'kitty': + currentScheme = kittyscheme + break + case 'reddwarf': + currentScheme = reddwarfscheme + break + case 'catppuccin': + currentScheme = catppuccinscheme + break + case 'evergarden': + currentScheme = evergardenscheme + break + case 'material3': + currentScheme = getMaterial3Colors(palette).scheme + break + default: + currentScheme = themes + break + } + + return hueShifter(currentScheme, hue) + }, [colorScheme, hue, palette]) } -export function ThemeProvider({ +function ThemeProviderInner({ children, theme: themeName, }: React.PropsWithChildren<{theme: ThemeName}>) { - const {colorScheme, hue} = useThemePrefs() - const currentScheme = selectScheme(colorScheme) + const currentScheme = useScheme() const [fontScale, setFontScale] = useState(() => getFontScale(), ) @@ -215,12 +232,10 @@ export function ThemeProvider({ ) const value = useMemo(() => { - const shiftedThemes = hueShifter(currentScheme, hue) - return { - themes: shiftedThemes, + themes: currentScheme, themeName: themeName, - theme: shiftedThemes[themeName], + theme: currentScheme[themeName], fonts: { scale: fontScale, scaleMultiplier: fontScaleMultiplier, @@ -232,7 +247,6 @@ export function ThemeProvider({ } }, [ currentScheme, - hue, themeName, fontScale, fontScaleMultiplier, @@ -241,9 +255,17 @@ export function ThemeProvider({ setFontFamilyAndPersist, ]) + return {children} +} + +export function ThemeProvider({ + children, + theme: themeName, +}: React.PropsWithChildren<{theme: ThemeName}>) { + const {material3Accent, material3Style} = useThemePrefs() return ( - - {children} + + {children} ) } diff --git a/src/alf/themes.ts b/src/alf/themes.ts index 69b8d3bb1..70011a7d5 100644 --- a/src/alf/themes.ts +++ b/src/alf/themes.ts @@ -24,10 +24,6 @@ import { GREEN_HUE as DEER_GREEN_HUE, RED_HUE as DEER_RED_HUE, } from '#/alf/util/deerColorGeneration' -import { - getMaterialYouColor, - onMaterialYouPaletteChange, -} from './util/materialYou' export type Palette = { white: string @@ -95,7 +91,7 @@ export type Palette = { negative_975: string } -const STATIC_VALUES = { +export const STATIC_VALUES = { white: '#FEFBFB', black: '#000000', pink: '#EC4899', @@ -1391,181 +1387,6 @@ export const evergardenscheme = { dim: EVERGARDEN_THEMES.dim, } -function getMaterial3ColorScheme() { - const MATERIAL_3_PALETTE: Palette = { - white: STATIC_VALUES.white, - black: STATIC_VALUES.black, - pink: STATIC_VALUES.pink, - yellow: STATIC_VALUES.yellow, - like: STATIC_VALUES.pink, - - contrast_0: getMaterialYouColor('system_neutral1', 0), - contrast_25: getMaterialYouColor('system_neutral1', 10), - contrast_50: getMaterialYouColor('system_neutral1', 50), - contrast_100: getMaterialYouColor('system_neutral1', 100), - contrast_200: getMaterialYouColor('system_neutral1', 200), - contrast_300: getMaterialYouColor('system_neutral1', 300), - contrast_400: getMaterialYouColor('system_neutral1', 400), - contrast_500: getMaterialYouColor('system_neutral1', 500), - contrast_600: getMaterialYouColor('system_neutral1', 600), - contrast_700: getMaterialYouColor('system_neutral1', 700), - contrast_800: getMaterialYouColor('system_neutral1', 800), - contrast_900: getMaterialYouColor('system_neutral1', 900), - contrast_950: getMaterialYouColor('system_neutral1', 900), - contrast_975: getMaterialYouColor('system_neutral1', 900), - contrast_1000: getMaterialYouColor('system_neutral1', 1000), - - primary_25: getMaterialYouColor('system_accent1', 10), - primary_50: getMaterialYouColor('system_accent1', 50), - primary_100: getMaterialYouColor('system_accent1', 100), - primary_200: getMaterialYouColor('system_accent1', 200), - primary_300: getMaterialYouColor('system_accent1', 300), - primary_400: getMaterialYouColor('system_accent1', 400), - primary_500: getMaterialYouColor('system_accent1', 500), - primary_600: getMaterialYouColor('system_accent1', 600), - primary_700: getMaterialYouColor('system_accent1', 700), - primary_800: getMaterialYouColor('system_accent1', 800), - primary_900: getMaterialYouColor('system_accent1', 900), - primary_950: getMaterialYouColor('system_accent1', 900), - primary_975: getMaterialYouColor('system_accent1', 900), - - positive_25: getMaterialYouColor('system_accent3', 10), - positive_50: getMaterialYouColor('system_accent3', 50), - positive_100: getMaterialYouColor('system_accent3', 100), - positive_200: getMaterialYouColor('system_accent3', 200), - positive_300: getMaterialYouColor('system_accent3', 300), - positive_400: getMaterialYouColor('system_accent3', 400), - positive_500: getMaterialYouColor('system_accent3', 500), - positive_600: getMaterialYouColor('system_accent3', 600), - positive_700: getMaterialYouColor('system_accent3', 700), - positive_800: getMaterialYouColor('system_accent3', 800), - positive_900: getMaterialYouColor('system_accent3', 900), - positive_950: getMaterialYouColor('system_accent3', 900), - positive_975: getMaterialYouColor('system_accent3', 900), - - negative_25: '#FFF5F7', - negative_50: '#FEE7EC', - negative_100: '#FDD3DD', - negative_200: '#FBBBCA', - negative_300: '#F891A9', - negative_400: '#F65A7F', - negative_500: '#E91646', - negative_600: '#CA123D', - negative_700: '#A71134', - negative_800: '#7F0B26', - negative_900: '#5F071C', - negative_950: '#430413', - negative_975: '#30030D', - } - - const MATERIAL_3_SUBDUED_PALETTE: Palette = { - white: STATIC_VALUES.white, - black: STATIC_VALUES.black, - pink: STATIC_VALUES.pink, - yellow: STATIC_VALUES.yellow, - like: STATIC_VALUES.pink, - - contrast_0: getMaterialYouColor('system_neutral2', 0), - contrast_25: getMaterialYouColor('system_neutral2', 10), - contrast_50: getMaterialYouColor('system_neutral2', 50), - contrast_100: getMaterialYouColor('system_neutral2', 100), - contrast_200: getMaterialYouColor('system_neutral2', 200), - contrast_300: getMaterialYouColor('system_neutral2', 300), - contrast_400: getMaterialYouColor('system_neutral2', 400), - contrast_500: getMaterialYouColor('system_neutral2', 500), - contrast_600: getMaterialYouColor('system_neutral2', 600), - contrast_700: getMaterialYouColor('system_neutral2', 700), - contrast_800: getMaterialYouColor('system_neutral2', 800), - contrast_900: getMaterialYouColor('system_neutral2', 800), - contrast_950: getMaterialYouColor('system_neutral2', 800), - contrast_975: getMaterialYouColor('system_neutral2', 800), - contrast_1000: getMaterialYouColor('system_neutral2', 900), - - primary_25: getMaterialYouColor('system_accent2', 10), - primary_50: getMaterialYouColor('system_accent2', 10), - primary_100: getMaterialYouColor('system_accent2', 50), - primary_200: getMaterialYouColor('system_accent2', 100), - primary_300: getMaterialYouColor('system_accent2', 200), - primary_400: getMaterialYouColor('system_accent2', 300), - primary_500: getMaterialYouColor('system_accent2', 400), - primary_600: getMaterialYouColor('system_accent2', 500), - primary_700: getMaterialYouColor('system_accent2', 600), - primary_800: getMaterialYouColor('system_accent2', 700), - primary_900: getMaterialYouColor('system_accent2', 800), - primary_950: getMaterialYouColor('system_accent2', 900), - primary_975: getMaterialYouColor('system_accent2', 900), - - positive_25: getMaterialYouColor('system_accent3', 10), - positive_50: getMaterialYouColor('system_accent3', 50), - positive_100: getMaterialYouColor('system_accent3', 100), - positive_200: getMaterialYouColor('system_accent3', 200), - positive_300: getMaterialYouColor('system_accent3', 300), - positive_400: getMaterialYouColor('system_accent3', 400), - positive_500: getMaterialYouColor('system_accent3', 500), - positive_600: getMaterialYouColor('system_accent3', 600), - positive_700: getMaterialYouColor('system_accent3', 700), - positive_800: getMaterialYouColor('system_accent3', 800), - positive_900: getMaterialYouColor('system_accent3', 900), - positive_950: getMaterialYouColor('system_accent3', 900), - positive_975: getMaterialYouColor('system_accent3', 900), - - negative_25: '#FFF5F7', - negative_50: '#FEEBEF', - negative_100: '#FDD8E1', - negative_200: '#FCC0CE', - negative_300: '#F99AB0', - negative_400: '#F76486', - negative_500: '#EB2452', - negative_600: '#D81341', - negative_700: '#BA1239', - negative_800: '#910D2C', - negative_900: '#6F0B22', - negative_950: '#500B1C', - negative_975: '#3E0915', - } - - const MATERIAL_3_THEMES = createThemes({ - defaultPalette: MATERIAL_3_PALETTE, - subduedPalette: MATERIAL_3_SUBDUED_PALETTE, - }) - - // special case for disabled (primary) button text. we have a hack for primary_subtle in Button.tsx - MATERIAL_3_THEMES.dark.atoms.text_inverted.color = - MATERIAL_3_THEMES.dark.palette.primary_400 - MATERIAL_3_THEMES.dim.atoms.text_inverted.color = - MATERIAL_3_THEMES.dim.palette.primary_400 - - const material3scheme = { - lightPalette: MATERIAL_3_THEMES.light.palette, - darkPalette: MATERIAL_3_THEMES.dark.palette, - dimPalette: MATERIAL_3_THEMES.dim.palette, - light: MATERIAL_3_THEMES.light, - dark: MATERIAL_3_THEMES.dark, - dim: MATERIAL_3_THEMES.dim, - } - - return { - regular: MATERIAL_3_PALETTE, - subdued: MATERIAL_3_SUBDUED_PALETTE, - scheme: material3scheme, - } -} - -let material3colorSchemeData = getMaterial3ColorScheme() - -export let MATERIAL_3_PALETTE: Palette = material3colorSchemeData.regular -export let MATERIAL_3_SUBDUED_PALETTE: Palette = - material3colorSchemeData.subdued -export let material3scheme = material3colorSchemeData.scheme - -onMaterialYouPaletteChange(() => { - material3colorSchemeData = getMaterial3ColorScheme() - - MATERIAL_3_PALETTE = material3colorSchemeData.regular - MATERIAL_3_SUBDUED_PALETTE = material3colorSchemeData.subdued - material3scheme = material3colorSchemeData.scheme -}) - /** * @deprecated use ALF and access palette from `useTheme()` */ diff --git a/src/alf/util/material3/Cam/Cam.ts b/src/alf/util/material3/Cam/Cam.ts new file mode 100644 index 000000000..cbb13fd91 --- /dev/null +++ b/src/alf/util/material3/Cam/Cam.ts @@ -0,0 +1,980 @@ +/* + * This code was originally written in Java and has been converted to JavaScript. + * + * Original Java code source: [https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/java/com/android/internal/graphics/cam] + * + * Copyright (C) 2021 The Android Open Source Project. + * The original code is licensed under the Apache License, Version 2.0. + * + * This JavaScript version is licensed under the MIT License. + * + * See the respective licenses for the specific terms, permissions, and limitations. + */ + +import Color from '../Utils/Color' +import ColorUtils from '../Utils/ColorUtils' +import MathUtils from '../Utils/MathUtils' +import HctSolver from './HctSolver' + +/** + * A color appearance model, based on CAM16, extended to use L* as the lightness dimension, and coupled to a gamut mapping + * algorithm. Creates a color system, enables a digital design system. + */ +export class Cam { + // When the delta between the floor & ceiling of a binary search for chroma is less than this, + // the binary search terminates. + static CHROMA_SEARCH_ENDPOINT = 0.4 + + // When the delta between the floor & ceiling of a binary search for J, lightness in CAM16, + // is less than this, the binary search terminates. + static LIGHTNESS_SEARCH_ENDPOINT = 0.01 + + // The maximum difference between the requested L* and the L* returned. + static DL_MAX = 0.2 + + // The maximum color distance, in CAM16-UCS, between a requested color and the color returned. + static DE_MAX = 1 + + /** The maximum difference between the requested L* and the L* returned. */ + DL_MAX = 0.2 + /** When the delta between the floor & ceiling of a binary search for chroma is less than this, the binary search terminates. */ + CHROMA_SEARCH_ENDPOINT = 0.4 + /** + * When the delta between the floor & ceiling of a binary search for J, lightness in CAM16, is less than this, the binary search + * terminates. + */ + LIGHTNESS_SEARCH_ENDPOINT = 0.01 + /** + * SRGB specification has D65 whitepoint - Stokes, Anderson, Chandrasekar, Motta - A Standard Default Color Space for the + * Internet: sRGB, 1996 + */ + WHITE_POINT_D65: [number, number, number] = [95.047, 100, 108.883] + + // CAM16 color dimensions, see getters for documentation. + mHue: number + mChroma: number + mJ: number + mQ: number + mM: number + mS: number + + // Coordinates in UCS space. Used to determine color distance, like delta E equations in L*a*b*. + mJstar: number + mAstar: number + mBstar: number + + constructor( + hue: number, + chroma: number, + j: number, + q: number, + m: number, + s: number, + jstar: number, + astar: number, + bstar: number, + ) { + this.mHue = hue + this.mChroma = chroma + this.mJ = j + this.mQ = q + this.mM = m + this.mS = s + this.mJstar = jstar + this.mAstar = astar + this.mBstar = bstar + } + + /** Hue in CAM16 */ + getHue() { + return this.mHue + } + + /** Chroma in CAM16 */ + getChroma() { + return this.mChroma + } + + /** Lightness in CAM16 */ + getJ() { + return this.mJ + } + + /** A* coordinate in CAM16-UCS */ + getAstar() { + return this.mAstar + } + + /** Lightness coordinate in CAM16-UCS */ + getJstar() { + return this.mJstar + } + + /** B* coordinate in CAM16-UCS */ + getBstar() { + return this.mBstar + } + + /** + * Create a CAM from lightness, chroma, and hue coordinates. It is assumed those coordinates were measured in the sRGB standard + * frame. + */ + static fromJch(j: number, c: number, h: number) { + return Cam.fromJchInFrame(j, c, h) + } + + /** Create a CAM from lightness, chroma, and hue coordinates, and also specify the frame in which the color is being viewed. */ + static fromJchInFrame(j: number, c: number, h: number) { + const q = + (4 / Frame.DEFAULT.getC()) * + Math.sqrt(j / 100) * + (Frame.DEFAULT.getAw() + 4) * + Frame.DEFAULT.getFlRoot() + const m = c * Frame.DEFAULT.getFlRoot() + const alpha = c / Math.sqrt(j / 100) + const s = + 50 * + Math.sqrt((alpha * Frame.DEFAULT.getC()) / (Frame.DEFAULT.getAw() + 4)) + + const hueRadians = (h * Math.PI) / 180 + const jstar = ((1 + 100 * 0.007) * j) / (1 + 0.007 * j) + const mstar = (1 / 0.0228) * Math.log(1 + 0.0228 * m) + const astar = mstar * Math.cos(hueRadians) + const bstar = mstar * Math.sin(hueRadians) + return new Cam(h, c, j, q, m, s, jstar, astar, bstar) + } + + /** Returns perceived color as an ARGB integer, as viewed in standard sRGB frame. */ + viewedInSrgb() { + return this.viewed(Frame.DEFAULT) + } + + /** Returns color perceived in a frame as an ARGB integer. */ + viewed(frame: Frame) { + const alpha = + this.getChroma() === 0 || this.getJ() === 0 + ? 0 + : this.getChroma() / Math.sqrt(this.getJ() / 100) + + const t = Math.pow( + alpha / Math.pow(1.64 - Math.pow(0.29, frame.getN()), 0.73), + 1 / 0.9, + ), + hRad = (this.getHue() * Math.PI) / 180 + + const eHue = 0.25 * (Math.cos(hRad + 2) + 3.8), + ac = + frame.getAw() * + Math.pow(this.getJ() / 100, 1 / frame.getC() / frame.getZ()), + p1 = eHue * (50000 / 13) * frame.getNc() * frame.getNcb(), + p2 = ac / frame.getNbb() + + const hSin = Math.sin(hRad), + hCos = Math.cos(hRad) + + const gamma = + (23 * (p2 + 0.305) * t) / (23 * p1 + 11 * t * hCos + 108 * t * hSin), + a = gamma * hCos, + b = gamma * hSin, + rA = (460 * p2 + 451 * a + 288 * b) / 1403, + gA = (460 * p2 - 891 * a - 261 * b) / 1403, + bA = (460 * p2 - 220 * a - 6300 * b) / 1403 + + const rCBase = Math.max(0, (27.13 * Math.abs(rA)) / (400 - Math.abs(rA))), + rC = Math.sign(rA) * (100 / frame.getFl()) * Math.pow(rCBase, 1 / 0.42), + gCBase = Math.max(0, (27.13 * Math.abs(gA)) / (400 - Math.abs(gA))), + gC = Math.sign(gA) * (100 / frame.getFl()) * Math.pow(gCBase, 1 / 0.42), + bCBase = Math.max(0, (27.13 * Math.abs(bA)) / (400 - Math.abs(bA))), + bC = Math.sign(bA) * (100 / frame.getFl()) * Math.pow(bCBase, 1 / 0.42), + rF = rC / frame.getRgbD()[0], + gF = gC / frame.getRgbD()[1], + bF = bC / frame.getRgbD()[2] + + const matrix = CamUtils.CAM16RGB_TO_XYZ, + x = rF * matrix[0][0] + gF * matrix[0][1] + bF * matrix[0][2], + y = rF * matrix[1][0] + gF * matrix[1][1] + bF * matrix[1][2], + z = rF * matrix[2][0] + gF * matrix[2][1] + bF * matrix[2][2] + + return ColorUtils.XYZToColor(x, y, z) + } + + /** + * Distance in CAM16-UCS space between two colors. + * + * Much like L_a_b* was designed to measure distance between colors, the CAM16 standard defined a color space called CAM16-UCS + * to measure distance between CAM16 colors. + */ + distance(other: Cam) { + const dJ = this.getJstar() - other.getJstar(), + dA = this.getAstar() - other.getAstar(), + dB = this.getBstar() - other.getBstar(), + dEPrime = Math.sqrt(dJ * dJ + dA * dA + dB * dB), + dE = 1.41 * Math.pow(dEPrime, 0.63) + return dE + } + + /** + * Find J, lightness in CAM16 color space, that creates a color with L* = `lstar` in the L_a_b* color space. Returns null if no + * J could be found that generated a color with L* `lstar`. + */ + static findCamByJ(hue: number, chroma: number, lstar: number) { + let low = 0, + high = 100, + mid, + bestdL = 1000, + bestdE = 1000 + + let bestCam: Cam | null = null + while (Math.abs(low - high) > Cam.LIGHTNESS_SEARCH_ENDPOINT) { + mid = low + (high - low) / 2 + + // Create the intended CAM color + const camBeforeClip = Cam.fromJch(mid, chroma, hue) + + // Convert the CAM color to RGB. If the color didn't fit in RGB, during the conversion, + // the initial RGB values will be outside 0 to 255. The final RGB values are clipped to + // 0 to 255, distorting the intended color. + const clipped = camBeforeClip.viewedInSrgb() + const clippedLstar = CamUtils.lstarFromInt(clipped) + const dL = Math.abs(lstar - clippedLstar) + + // If the clipped color's L* is within error margin... + if (dL < Cam.DL_MAX) { + // ...check if the CAM equivalent of the clipped color is far away from intended CAM + // color. For the intended color, use lightness and chroma from the clipped color, + // and the intended hue. Callers are wondering what the lightness is, they know + // chroma may be distorted, so the only concern here is if the hue slipped too far. + const camClipped = Cam.fromInt(clipped) + const dE = camClipped.distance( + Cam.fromJch(camClipped.getJ(), camClipped.getChroma(), hue), + ) + if (dE <= Cam.DE_MAX) { + bestdL = dL + bestdE = dE + bestCam = camClipped + } + } + + // If there's no error at all, there's no need to search more. + // + // Note: this happens much more frequently than expected, but this is a very delicate + // property which relies on extremely precise sRGB <=> XYZ calculations, as well as fine + // tuning of the constants that determine error margins and when the binary search can + // terminate. + if (bestdL === 0 && bestdE === 0) { + break + } + + if (clippedLstar < lstar) { + low = mid + } else { + high = mid + } + } + + return bestCam + } + + /** + * Given a hue & chroma in CAM16, L* in L_a_b*, return an ARGB integer. The chroma of the color returned may, and frequently + * will, be lower than requested. Assumes the color is viewed in the frame defined by the sRGB standard. + */ + static getInt(hue: number, chroma: number, lstar: number) { + return Cam.getInt_(hue, chroma, lstar, Frame.DEFAULT) + } + + /** + * Given a hue & chroma in CAM16, L* in L_a_b*, and the frame in which the color will be viewed, return an ARGB integer. + * + * The chroma of the color returned may, and frequently will, be lower than requested. This is a fundamental property of color + * that cannot be worked around by engineering. For example, a red hue, with high chroma, and high L* does not exist: red hues + * have a maximum chroma below 10 in light shades, creating pink. + */ + static getInt_(hue: number, chroma: number, lstar: number, frame: Frame) { + // This is a crucial routine for building a color system, CAM16 itself is not sufficient. + // + // * Why these dimensions? + // Hue and chroma from CAM16 are used because they're the most accurate measures of those + // quantities. L* from L*a*b* is used because it correlates with luminance, luminance is + // used to measure contrast for a11y purposes, thus providing a key constraint on what + // colors + // can be used. + // + // * Why is this routine required to build a color system? + // In all perceptually accurate color spaces (i.e. L*a*b* and later), `chroma` may be + // impossible for a given `hue` and `lstar`. + // For example, a high chroma light red does not exist - chroma is limited to below 10 at + // light red shades, we call that pink. High chroma light green does exist, but not dark + // Also, when converting from another color space to RGB, the color may not be able to be + // represented in RGB. In those cases, the conversion process ends with RGB values + // outside 0-255 + // The vast majority of color libraries surveyed simply round to 0 to 255. That is not an + // option for this library, as it distorts the expected luminance, and thus the expected + // contrast needed for a11y + // + // * What does this routine do? + // Dealing with colors in one color space not fitting inside RGB is, loosely referred to as + // gamut mapping or tone mapping. These algorithms are traditionally idiosyncratic, there is + // no universal answer. However, because the intent of this library is to build a system for + // digital design, and digital design uses luminance to measure contrast/a11y, we have one + // very important constraint that leads to an objective algorithm: the L* of the returned + // color _must_ match the requested L*. + // + // Intuitively, if the color must be distorted to fit into the RGB gamut, and the L* + // requested *must* be fulfilled, than the hue or chroma of the returned color will need + // to be different from the requested hue/chroma. + // + // After exploring both options, it was more intuitive that if the requested chroma could + // not be reached, it used the highest possible chroma. The alternative was finding the + // closest hue where the requested chroma could be reached, but that is not nearly as + // intuitive, as the requested hue is so fundamental to the color description. + + // If the color doesn't have meaningful chroma, return a gray with the requested Lstar. + // + // Yellows are very chromatic at L = 100, and blues are very chromatic at L = 0. All the + // other hues are white at L = 100, and black at L = 0. To preserve consistency for users of + // this system, it is better to simply return white at L* > 99, and black and L* < 0. + if (frame === Frame.DEFAULT) { + // If the viewing conditions are the same as the default sRGB-like viewing conditions, + // skip to using HctSolver: it uses geometrical insights to find the closest in-gamut + // match to hue/chroma/lstar. + return HctSolver.solveToInt(hue, chroma, lstar) + } + + if (chroma < 1 || Math.round(lstar) <= 0 || Math.round(lstar) >= 100) { + return CamUtils.intFromLstar(lstar) + } + + hue = hue < 0 ? 0 : Math.min(360, hue) + + // The highest chroma possible. Updated as binary search proceeds. + let high = chroma + + // The guess for the current binary search iteration. Starts off at the highest chroma, + // thus, if a color is possible at the requested chroma, the search can stop after one try. + let mid = chroma + let low = 0 + let isFirstLoop = true + + let answer: Cam | null = null + + while (Math.abs(low - high) >= Cam.CHROMA_SEARCH_ENDPOINT) { + // Given the current chroma guess, mid, and the desired hue, find J, lightness in + // CAM16 color space, that creates a color with L* = `lstar` in the L*a*b* color space. + const possibleAnswer = Cam.findCamByJ(hue, mid, lstar) + + if (isFirstLoop) { + if (possibleAnswer != null) { + return possibleAnswer.viewed(frame) + } else { + // If this binary search iteration was the first iteration, and this point + // has been reached, it means the requested chroma was not available at the + // requested hue and L*. + // Proceed to a traditional binary search that starts at the midpoint between + // the requested chroma and 0. + isFirstLoop = false + mid = low + (high - low) / 2 + continue + } + } + + if (possibleAnswer == null) { + // There isn't a CAM16 J that creates a color with L* `lstar`. Try a lower chroma. + high = mid + } else { + answer = possibleAnswer + // It is possible to create a color. Try higher chroma. + low = mid + } + + mid = low + (high - low) / 2 + } + + // There was no answer: meaning, for the desired hue, there was no chroma low enough to + // generate a color with the desired L*. + // All values of L* are possible when there is 0 chroma. Return a color with 0 chroma, i.e. + // a shade of gray, with the desired L*. + if (answer == null) { + return CamUtils.intFromLstar(lstar) + } + + return answer.viewed(frame) + } + + static intFromLstar(lstar: number) { + if (lstar < 1) { + return 0xff000000 + } else if (lstar > 99) { + return 0xffffffff + } + + // XYZ to LAB conversion routine, assume a and b are 0. + const fy = (lstar + 16) / 116 + + // fz = fx = fy because a and b are 0 + const fz = fy, + fx = fy + + const kappa = 24389 / 27, + epsilon = 216 / 24389, + lExceedsEpsilonKappa = lstar > 8, + yT = lExceedsEpsilonKappa ? fy * fy * fy : lstar / kappa, + cubeExceedEpsilon = fy * fy * fy > epsilon, + xT = cubeExceedEpsilon ? fx * fx * fx : (116 * fx - 16) / kappa, + zT = cubeExceedEpsilon ? fz * fz * fz : (116 * fx - 16) / kappa + + return ColorUtils.XYZToColor( + xT * CamUtils.WHITE_POINT_D65[0], + yT * CamUtils.WHITE_POINT_D65[1], + zT * CamUtils.WHITE_POINT_D65[2], + ) + } + + static fromIntInFrame(argb: number, frame: Frame) { + // Transform ARGB int to XYZ + const xyz: [number, number, number] = CamUtils.xyzFromInt(argb) + + // Transform XYZ to 'cone'/'rgb' responses + const matrix = CamUtils.XYZ_TO_CAM16RGB, + rT: number = + xyz[0] * matrix[0][0] + xyz[1] * matrix[0][1] + xyz[2] * matrix[0][2], + gT: number = + xyz[0] * matrix[1][0] + xyz[1] * matrix[1][1] + xyz[2] * matrix[1][2], + bT: number = + xyz[0] * matrix[2][0] + xyz[1] * matrix[2][1] + xyz[2] * matrix[2][2] + + // Discount illuminant + const rD = frame.getRgbD()[0] * rT, + gD = frame.getRgbD()[1] * gT, + bD = frame.getRgbD()[2] * bT + + // Chromatic adaptation + const rAF = Math.pow((frame.getFl() * Math.abs(rD)) / 100, 0.42), + gAF = Math.pow((frame.getFl() * Math.abs(gD)) / 100, 0.42), + bAF = Math.pow((frame.getFl() * Math.abs(bD)) / 100, 0.42), + rA = (Math.sign(rD) * 400 * rAF) / (rAF + 27.13), + gA = (Math.sign(gD) * 400 * gAF) / (gAF + 27.13), + bA = (Math.sign(bD) * 400 * bAF) / (bAF + 27.13) + + // redness-greennes + const a = (11 * rA + -12 * gA + bA) / 11 + // yellowness-blueness + const b = (rA + gA - 2 * bA) / 9 + + // auxiliary components + const u = (20 * rA + 20 * gA + 21 * bA) / 20, + p2 = (40 * rA + 20 * gA + bA) / 20 + + // hue + const atan2 = Math.atan2(b, a), + atanDegrees = (atan2 * 180) / Math.PI, + hue = + atanDegrees < 0 + ? atanDegrees + 360 + : atanDegrees >= 360 + ? atanDegrees - 360 + : atanDegrees, + hueRadians = (hue * Math.PI) / 180 + + // achromatic response to color + const ac = p2 * frame.getNbb() + + // CAM16 lightness and brightness + const j = 100 * Math.pow(ac / frame.getAw(), frame.getC() * frame.getZ()) + const q = + (4 / frame.getC()) * + Math.sqrt(j / 100) * + (frame.getAw() + 4) * + frame.getFlRoot() + + // CAM16 chroma, colorfulness, and saturation. + const huePrime = hue < 20.14 ? hue + 360 : hue, + eHue = 0.25 * (Math.cos((huePrime * Math.PI) / 180 + 2) + 3.8), + p1 = (50000 / 13) * eHue * frame.getNc() * frame.getNcb(), + t = (p1 * Math.sqrt(a * a + b * b)) / (u + 0.305), + alpha = + Math.pow(t, 0.9) * Math.pow(1.64 - Math.pow(0.29, frame.getN()), 0.73) + + // CAM16 chroma, colorfulness, saturation + const c = alpha * Math.sqrt(j / 100), + m = c * frame.getFlRoot(), + s = 50 * Math.sqrt((alpha * frame.getC()) / (frame.getAw() + 4)) + + // CAM16-UCS components + const jstar = ((1 + 100 * 0.007) * j) / (1 + 0.007 * j), + mstar = (1 / 0.0228) * Math.log(1 + 0.0228 * m), + astar = mstar * Math.cos(hueRadians), + bstar = mstar * Math.sin(hueRadians) + + return new Cam(hue, c, j, q, m, s, jstar, astar, bstar) + } + + /** + * Create a color appearance model from a ARGB integer representing a color. It is assumed the color was viewed in the frame + * defined in the sRGB standard. + */ + static fromInt(argb: number) { + return Cam.fromIntInFrame(argb, Frame.DEFAULT) + } +} + +/** + * Collection of methods for transforming between color spaces. + * + * Methods are named $xFrom$Y. For example, lstarFromInt() returns L* from an ARGB integer. + * + * These methods, generally, convert colors between the L_a_b*, XYZ, and sRGB spaces. + * + * L_a_b* is a perceptually accurate color space. This is particularly important in the L* dimension: it measures luminance and + * unlike lightness measures traditionally used in UI work via RGB or HSL, this luminance transitions smoothly, permitting + * creation of pleasing shades of a color, and more pleasing transitions between colors. + * + * XYZ is commonly used as an intermediate color space for converting between one color space to another. For example, to convert + * RGB to L_a_b*, first RGB is converted to XYZ, then XYZ is converted to L_a_b*. + * + * SRGB is a "specification originated from work in 1990s through cooperation by Hewlett-Packard and Microsoft, and it was + * designed to be a standard definition of RGB for the internet, which it indeed became...The standard is based on a sampling of + * computer monitors at the time...The whole idea of sRGB is that if everyone assumed that RGB meant the same thing, then the + * results would be consistent, and reasonably good. It worked." - Fairchild, Color Models and Systems: Handbook of Color + * Psychology, 2015 + */ +export class CamUtils { + /** + * This is a more precise sRGB to XYZ transformation matrix than traditionally used. It was derived using Schlomer's technique + * of transforming the xyY primaries to XYZ, then applying a correction to ensure mapping from sRGB 1, 1, 1 to the reference + * white point, D65. + */ + static SRGB_TO_XYZ: [ + [number, number, number], + [number, number, number], + [number, number, number], + ] = [ + [0.41233895, 0.35762064, 0.18051042], + [0.2126, 0.7152, 0.0722], + [0.01932141, 0.11916382, 0.95034478], + ] + + /** Transforms XYZ color space coordinates to 'cone'/'RGB' responses in CAM16. */ + static XYZ_TO_CAM16RGB: [ + [number, number, number], + [number, number, number], + [number, number, number], + ] = [ + [0.401288, 0.650173, -0.051461], + [-0.250268, 1.204414, 0.045854], + [-0.002079, 0.048952, 0.953127], + ] + + static XYZ_TO_SRGB: [ + [number, number, number], + [number, number, number], + [number, number, number], + ] = [ + [3.2413774792388685, -1.5376652402851851, -0.49885366846268053], + [-0.9691452513005321, 1.8758853451067872, 0.04156585616912061], + [0.05562093689691305, -0.20395524564742123, 1.0571799111220335], + ] + + /** Transforms 'cone'/'RGB' responses in CAM16 to XYZ color space coordinates. */ + static CAM16RGB_TO_XYZ: [ + [number, number, number], + [number, number, number], + [number, number, number], + ] = [ + [1.86206786, -1.01125463, 0.14918677], + [0.38752654, 0.62144744, -0.00897398], + [-0.0158415, -0.03412294, 1.04996444], + ] + + /** + * SRGB specification has D65 whitepoint - Stokes, Anderson, Chandrasekar, Motta - A Standard Default Color Space for the + * Internet: sRGB, 1996 + */ + static WHITE_POINT_D65: [number, number, number] = [95.047, 100, 108.883] + + /** Returns L* from L_a_b*, perceptual luminance, from an ARGB integer (ColorInt). */ + static lstarFromInt(argb: number) { + return CamUtils.lstarFromY(CamUtils.yFromInt(argb)) + } + + static lstarFromY(y: number) { + y = y / 100 + const e = 216 / 24389 + let yIntermediate + if (y <= e) { + return (24389 / 27) * y + } else { + yIntermediate = Math.cbrt(y) + } + return 116 * yIntermediate - 16 + } + + static yFromInt(argb: number) { + const r = CamUtils.linearized(Color.red(argb)), + g = CamUtils.linearized(Color.green(argb)), + b = CamUtils.linearized(Color.blue(argb)), + matrix = CamUtils.SRGB_TO_XYZ, + y = r * matrix[1][0] + g * matrix[1][1] + b * matrix[1][2] + return y + } + + static xyzFromInt(argb: number): [number, number, number] { + const r = CamUtils.linearized(Color.red(argb)), + g = CamUtils.linearized(Color.green(argb)), + b = CamUtils.linearized(Color.blue(argb)) + + const matrix = CamUtils.SRGB_TO_XYZ, + x = r * matrix[0][0] + g * matrix[0][1] + b * matrix[0][2], + y = r * matrix[1][0] + g * matrix[1][1] + b * matrix[1][2], + z = r * matrix[2][0] + g * matrix[2][1] + b * matrix[2][2] + + return [x, y, z] + } + + static linearized(rgbComponent: number): number { + const normalized = rgbComponent / 255 + + if (normalized <= 0.04045) { + return (normalized / 12.92) * 100 + } else { + return Math.pow((normalized + 0.055) / 1.055, 2.4) * 100 + } + } + + /** + * Converts an L* value to a Y value. + * + * L* in L_a_b* and Y in XYZ measure the same quantity, luminance. + * + * L* measures perceptual luminance, a linear scale. Y in XYZ measures relative luminance, a logarithmic scale. + * + * @param lstar L* in L_a_b* + * @returns Y in XYZ + */ + static yFromLstar(lstar: number) { + const ke = 8 + if (lstar > ke) { + return Math.pow((lstar + 16) / 116, 3) * 100 + } else { + return (lstar / (24389 / 27)) * 100 + } + } + + /** + * Clamps an integer between two integers. + * + * @returns Input when min <= input <= max, and either min or max otherwise. + */ + static clampInt(min: number, max: number, input: number) { + if (input < min) { + return min + } else if (input > max) { + return max + } + + return input + } + + /** + * Delinearizes an RGB component. + * + * @param rgbComponent 0 <= rgb_component <= 100, represents linear R/G/B channel + * @returns 0 <= output <= 255, color channel converted to regular RGB space + */ + static delinearized(rgbComponent: number) { + const normalized = rgbComponent / 100 + let delinearized = 0 + if (normalized <= 0.0031308) { + delinearized = normalized * 12.92 + } else { + delinearized = 1.055 * Math.pow(normalized, 1 / 2.4) - 0.055 + } + return CamUtils.clampInt(0, 255, Math.round(delinearized * 255)) + } + + /** Converts a color from RGB components to ARGB format. */ + static argbFromRgb(red: number, green: number, blue: number) { + return ( + (255 << 24) | ((red & 255) << 16) | ((green & 255) << 8) | (blue & 255) + ) + } + + /** Converts a color from ARGB to XYZ. */ + static argbFromXyz(x: number, y: number, z: number) { + const matrix = CamUtils.XYZ_TO_SRGB, + linearR = matrix[0][0] * x + matrix[0][1] * y + matrix[0][2] * z, + linearG = matrix[1][0] * x + matrix[1][1] * y + matrix[1][2] * z, + linearB = matrix[2][0] * x + matrix[2][1] * y + matrix[2][2] * z, + r = CamUtils.delinearized(linearR), + g = CamUtils.delinearized(linearG), + b = CamUtils.delinearized(linearB) + + return CamUtils.argbFromRgb(r, g, b) + } + + /** + * Convert a color appearance model representation to an ARGB color. + * + * Note: the returned color may have a lower chroma than requested. Whether a chroma is available depends on luminance. For + * example, there's no such thing as a high chroma light red, due to the limitations of our eyes and/or physics. If the + * requested chroma is unavailable, the highest possible chroma at the requested luminance is returned. + * + * @param hue Hue, in degrees, in CAM coordinates + * @param chroma Chroma in CAM coordinates. + * @param lstar Perceptual luminance, L* in L_a_b* + */ + static CAMToColor(hue: number, chroma: number, lstar: number) { + return Cam.getInt(hue, chroma, lstar) + } + + /** + * Converts an L* value to an ARGB representation. + * + * @param lstar L* in L_a_b* + * @returns ARGB representation of grayscale color with lightness matching L* + */ + static argbFromLstar(lstar: number) { + const fy = (lstar + 16) / 116, + fz = fy, + fx = fy, + kappa = 24389 / 27, + epsilon = 216 / 24389, + lExceedsEpsilonKappa = lstar > 8, + y = lExceedsEpsilonKappa ? fy * fy * fy : lstar / kappa, + cubeExceedEpsilon = fy * fy * fy > epsilon, + x = cubeExceedEpsilon ? fx * fx * fx : lstar / kappa, + z = cubeExceedEpsilon ? fz * fz * fz : lstar / kappa, + whitePoint = CamUtils.WHITE_POINT_D65 + + return CamUtils.argbFromXyz( + x * whitePoint[0], + y * whitePoint[1], + z * whitePoint[2], + ) + } + + /** Converts a color from linear RGB components to ARGB format. */ + static argbFromLinrgbComponents(r: number, g: number, b: number) { + return CamUtils.argbFromRgb( + CamUtils.delinearized(r), + CamUtils.delinearized(g), + CamUtils.delinearized(b), + ) + } + + /** + * The signum function. + * + * @returns 1 if num > 0, -1 if num < 0, and 0 if num = 0 + */ + static signum(num: number) { + if (num < 0) { + return -1 + } else if (num === 0) { + return 0 + } else { + return 1 + } + } + + static intFromLstar(lstar: number) { + if (lstar < 1) { + return 0xff000000 + } else if (lstar > 99) { + return 0xffffffff + } + + // XYZ to LAB conversion routine, assume a and b are 0. + const fy = (lstar + 16) / 116 + + // fz = fx = fy because a and b are 0 + const fz = fy + const fx = fy + + const kappa = 24389 / 27 + const epsilon = 216 / 24389 + const lExceedsEpsilonKappa = lstar > 8 + const yT = lExceedsEpsilonKappa ? fy * fy * fy : lstar / kappa + const cubeExceedEpsilon = fy * fy * fy > epsilon + const xT = cubeExceedEpsilon ? fx * fx * fx : (116 * fx - 16) / kappa + const zT = cubeExceedEpsilon ? fz * fz * fz : (116 * fx - 16) / kappa + + return ColorUtils.XYZToColor( + xT * CamUtils.WHITE_POINT_D65[0], + yT * CamUtils.WHITE_POINT_D65[1], + zT * CamUtils.WHITE_POINT_D65[2], + ) + } +} + +/** + * The frame, or viewing conditions, where a color was seen. Used, along with a color, to create a color appearance model + * representing the color. + * + * To convert a traditional color to a color appearance model, it requires knowing what conditions the color was observed in. Our + * perception of color depends on, for example, the tone of the light illuminating the color, how bright that light was, etc. + * + * This class is modelled separately from the color appearance model itself because there are a number of calculations during the + * color => CAM conversion process that depend only on the viewing conditions. Caching those calculations in a Frame instance + * saves a significant amount of time. + */ +export class Frame { + mN: number + mAw: number + mNbb: number + mNcb: number + mC: number + mNc: number + mRgbD: [number, number, number] + mFl: number + mFlRoot: number + mZ: number + + constructor( + n: number, + aw: number, + nbb: number, + ncb: number, + c: number, + nc: number, + rgbD: [number, number, number], + fl: number, + fLRoot: number, + z: number, + ) { + this.mN = n + this.mAw = aw + this.mNbb = nbb + this.mNcb = ncb + this.mC = c + this.mNc = nc + this.mRgbD = rgbD + this.mFl = fl + this.mFlRoot = fLRoot + this.mZ = z + } + + getRgbD() { + return this.mRgbD + } + getFl() { + return this.mFl + } + getNbb() { + return this.mNbb + } + getAw() { + return this.mAw + } + getC() { + return this.mC + } + getFlRoot() { + return this.mFlRoot + } + getNc() { + return this.mNc + } + getNcb() { + return this.mNcb + } + getZ() { + return this.mZ + } + getN() { + return this.mN + } + + static make( + whitepoint: [number, number, number], + adaptingLuminance: number, + backgroundLstar: number, + surround: number, + discountingIlluminant: boolean, + ) { + // Transform white point XYZ to 'cone'/'rgb' responses + const matrix = CamUtils.XYZ_TO_CAM16RGB, + xyz = whitepoint, + rW = + xyz[0] * matrix[0][0] + xyz[1] * matrix[0][1] + xyz[2] * matrix[0][2], + gW = + xyz[0] * matrix[1][0] + xyz[1] * matrix[1][1] + xyz[2] * matrix[1][2], + bW = xyz[0] * matrix[2][0] + xyz[1] * matrix[2][1] + xyz[2] * matrix[2][2] + + // Scale input surround, domain (0, 2), to CAM16 surround, domain (0.8, 1) + const f = 0.8 + surround / 10 + // "Exponential non-linearity" + const c = + f >= 0.9 + ? MathUtils.lerp(0.59, 0.69, (f - 0.9) * 10) + : MathUtils.lerp(0.525, 0.59, (f - 0.8) * 10) + // Calculate degree of adaptation to illuminant + let d = discountingIlluminant + ? 1 + : f * (1 - (1 / 3.6) * Math.exp((-adaptingLuminance - 42) / 92)) + // Per Li et al, if D is greater than 1 or less than 0, set it to 1 or 0. + d = d > 1 ? 1 : d < 0 ? 0 : d + // Chromatic induction factor + const nc = f + + // Cone responses to the whitepoint, adjusted for illuminant discounting. + // + // Why use 100 instead of the white point's relative luminance? + // + // Some papers and implementations, for both CAM02 and CAM16, use the Y + // value of the reference white instead of 100. Fairchild's Color Appearance + // Models (3rd edition) notes that this is in error: it was included in the + // CIE 2004a report on CIECAM02, but, later parts of the conversion process + // account for scaling of appearance relative to the white point relative + // luminance. This part should simply use 100 as luminance. + const rgbD = [ + d * (100 / rW) + 1 - d, + d * (100 / gW) + 1 - d, + d * (100 / bW) + 1 - d, + ] as [number, number, number] + // Luminance-level adaptation factor + const k = 1 / (5 * adaptingLuminance + 1) + const k4 = k * k * k * k + const k4F = 1 - k4 + const fl = + k4 * adaptingLuminance + + 0.1 * k4F * k4F * Math.cbrt(5 * adaptingLuminance) + + // Intermediate factor, ratio of background relative luminance to white relative luminance + const n = CamUtils.yFromLstar(backgroundLstar) / whitepoint[1] + + // Base exponential nonlinearity + // note Schlomer 2018 has a typo and uses 1.58, the correct factor is 1.48 + const z = 1.48 + Math.sqrt(n) + + // Luminance-level induction factors + const nbb = 0.725 / Math.pow(n, 0.2) + const ncb = nbb + + // Discounted cone responses to the white point, adjusted for post-chromatic + // adaptation perceptual nonlinearities. + const rgbAFactors = [ + Math.pow((fl * rgbD[0] * rW) / 100, 0.42), + Math.pow((fl * rgbD[1] * gW) / 100, 0.42), + Math.pow((fl * rgbD[2] * bW) / 100, 0.42), + ] as const + + const rgbA = [ + (400 * rgbAFactors[0]) / (rgbAFactors[0] + 27.13), + (400 * rgbAFactors[1]) / (rgbAFactors[1] + 27.13), + (400 * rgbAFactors[2]) / (rgbAFactors[2] + 27.13), + ] as const + + const aw = (2 * rgbA[0] + rgbA[1] + 0.05 * rgbA[2]) * nbb + + return new Frame(n, aw, nbb, ncb, c, nc, rgbD, fl, Math.pow(fl, 0.25), z) + } + + static DEFAULT = Frame.make( + CamUtils.WHITE_POINT_D65, + ((200 / Math.PI) * CamUtils.yFromLstar(50)) / 100, + 50, + 2, + false, + ) +} diff --git a/src/alf/util/material3/Cam/HctSolver.ts b/src/alf/util/material3/Cam/HctSolver.ts new file mode 100644 index 000000000..a64657b24 --- /dev/null +++ b/src/alf/util/material3/Cam/HctSolver.ts @@ -0,0 +1,574 @@ +/* + * This code was originally written in Java and has been converted to JavaScript. + * + * Original Java code source: [https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/java/com/android/internal/graphics/cam] + * + * Copyright (C) 2021 The Android Open Source Project. + * The original code is licensed under the Apache License, Version 2.0. + * + * This JavaScript version is licensed under the MIT License. + * + * See the respective licenses for the specific terms, permissions, and limitations. + */ + +import MathUtils from '../Utils/MathUtils' +import {CamUtils, Frame} from './Cam' + +export default class HctSolver { + /** Weights for transforming a set of linear RGB coordinates to Y in XYZ. */ + static Y_FROM_LINRGB: [number, number, number] = [0.2126, 0.7152, 0.0722] + + // Matrix used when converting from CAM16 to linear RGB. + static LINRGB_FROM_SCALED_DISCOUNT: [ + [number, number, number], + [number, number, number], + [number, number, number], + ] = [ + [1373.2198709594231, -1100.4251190754821, -7.278681089101213], + [-271.815969077903, 559.6580465940733, -32.46047482791194], + [1.9622899599665666, -57.173814538844006, 308.7233197812385], + ] + + /** Matrix used when converting from linear RGB to CAM16. */ + static SCALED_DISCOUNT_FROM_LINRGB: [ + [number, number, number], + [number, number, number], + [number, number, number], + ] = [ + [0.001200833568784504, 0.002389694492170889, 0.0002795742885861124], + [0.0005891086651375999, 0.0029785502573438758, 0.0003270666104008398], + [0.00010146692491640572, 0.0005364214359186694, 0.0032979401770712076], + ] + + /** + * Lookup table for plane in XYZ's Y axis (relative luminance) that corresponds to a given L* in L_a_b*. HCT's T is L*, and + * XYZ's Y is directly correlated to linear RGB, this table allows us to thus find the intersection between HCT and RGB, giving + * a solution to the RGB coordinates that correspond to a given set of HCT coordinates. + */ + static CRITICAL_PLANES = [ + 0.015176349177441876, 0.045529047532325624, 0.07588174588720938, + 0.10623444424209313, 0.13658714259697685, 0.16693984095186062, + 0.19729253930674434, 0.2276452376616281, 0.2579979360165119, + 0.28835063437139563, 0.3188300904430532, 0.350925934958123, + 0.3848314933096426, 0.42057480301049466, 0.458183274052838, + 0.4976837250274023, 0.5391024159806381, 0.5824650784040898, + 0.6277969426914107, 0.6751227633498623, 0.7244668422128921, + 0.775853049866786, 0.829304845476233, 0.8848452951698498, 0.942497089126609, + 1.0022825574869039, 1.0642236851973577, 1.1283421258858297, + 1.1946592148522128, 1.2631959812511864, 1.3339731595349034, + 1.407011200216447, 1.4823302800086415, 1.5599503113873272, + 1.6398909516233677, 1.7221716113234105, 1.8068114625156377, + 1.8938294463134073, 1.9832442801866852, 2.075074464868551, + 2.1693382909216234, 2.2660538449872063, 2.36523901573795, + 2.4669114995532007, 2.5710888059345764, 2.6777882626779785, + 2.7870270208169257, 2.898822059350997, 3.0131901897720907, + 3.1301480604002863, 3.2497121605402226, 3.3718988244681087, + 3.4967242352587946, 3.624204428461639, 3.754355295633311, 3.887192587735158, + 4.022731918402185, 4.160988767090289, 4.301978482107941, 4.445716283538092, + 4.592217266055746, 4.741496401646282, 4.893568542229298, 5.048448422192488, + 5.20615066083972, 5.3666897647573375, 5.5300801301023865, 5.696336044816294, + 5.865471690767354, 6.037501145825082, 6.212438385869475, 6.390297286737924, + 6.571091626112461, 6.7548350853498045, 6.941541251256611, 7.131223617812143, + 7.323895587840543, 7.5195704746346665, 7.7182615035334345, + 7.919981813454504, 8.124744458384042, 8.332562408825165, 8.543448553206703, + 8.757415699253682, 8.974476575321063, 9.194643831691977, 9.417930041841839, + 9.644347703669503, 9.873909240696694, 10.106627003236781, + 10.342513269534024, 10.58158024687427, 10.8238400726681, 11.069304815507364, + 11.317986476196008, 11.569896988756009, 11.825048221409341, + 12.083451977536606, 12.345119996613247, 12.610063955123938, + 12.878295467455942, 13.149826086772048, 13.42466730586372, + 13.702830557985108, 13.984327217668513, 14.269168601521828, + 14.55736596900856, 14.848930523210871, 15.143873411576273, + 15.44220572664832, 15.743938506781891, 16.04908273684337, 16.35764934889634, + 16.66964922287304, 16.985093187232053, 17.30399201960269, 17.62635644741625, + 17.95219714852476, 18.281524751807332, 18.614349837764564, + 18.95068293910138, 19.290534541298456, 19.633915083172692, + 19.98083495742689, 20.331304511189067, 20.685334046541502, + 21.042933821039977, 21.404114048223256, 21.76888489811322, + 22.137256497705877, 22.50923893145328, 22.884842241736916, + 23.264076429332462, 23.6469514538663, 24.033477234264016, 24.42366364919083, + 24.817520537484558, 25.21505769858089, 25.61628489293138, + 26.021211842414342, 26.429848230738664, 26.842203703840827, + 27.258287870275353, 27.678110301598522, 28.10168053274597, + 28.529008062403893, 28.96010235337422, 29.39497283293396, 29.83362889318845, + 30.276079891419332, 30.722335150426627, 31.172403958865512, + 31.62629557157785, 32.08401920991837, 32.54558406207592, 33.010999283389665, + 33.4802739966603, 33.953417292456834, 34.430438229418264, + 34.911345834551085, 35.39614910352207, 35.88485700094671, 36.37747846067349, + 36.87402238606382, 37.37449765026789, 37.87891309649659, 38.38727753828926, + 38.89959975977785, 39.41588851594697, 39.93615253289054, 40.460400508064545, + 40.98864111053629, 41.520882981230194, 42.05713473317016, + 42.597404951718396, 43.141702194811224, 43.6900349931913, 44.24241185063697, + 44.798841244188324, 45.35933162437017, 45.92389141541209, 46.49252901546552, + 47.065252796817916, 47.64207110610409, 48.22299226451468, + 48.808024568002054, 49.3971762874833, 49.9904556690408, 50.587870934119984, + 51.189430279724725, 51.79514187861014, 52.40501387947288, 53.0190544071392, + 53.637271562750364, 54.259673423945976, 54.88626804504493, + 55.517063457223934, 56.15206766869424, 56.79128866487574, 57.43473440856916, + 58.08241284012621, 58.734331877617365, 59.39049941699807, 60.05092333227251, + 60.715611475655585, 61.38457167773311, 62.057811747619894, 62.7353394731159, + 63.417162620860914, 64.10328893648692, 64.79372614476921, 65.48848194977529, + 66.18756403501224, 66.89098006357258, 67.59873767827808, 68.31084450182222, + 69.02730813691093, 69.74813616640164, 70.47333615344107, 71.20291564160104, + 71.93688215501312, 72.67524319850172, 73.41800625771542, 74.16517879925733, + 74.9167682708136, 75.67278210128072, 76.43322770089146, 77.1981124613393, + 77.96744375590167, 78.74122893956174, 79.51947534912904, 80.30219030335869, + 81.08938110306934, 81.88105503125999, 82.67721935322541, 83.4778813166706, + 84.28304815182372, 85.09272707154808, 85.90692527145302, 86.72564993000343, + 87.54890820862819, 88.3767072518277, 89.2090541872801, 90.04595612594655, + 90.88742016217518, 91.73345337380438, 92.58406282226491, 93.43925555268066, + 94.29903859396902, 95.16341895893969, 96.03240364439274, 96.9059996312159, + 97.78421388448044, 98.6670533535366, 99.55452497210776, + ] + + /** + * Sanitizes a degree measure as a floating-point number. + * + * @returns A degree measure between 0.0 (inclusive) and 360.0 (exclusive). + */ + static sanitizeDegreesDouble(degrees: number) { + degrees = degrees % 360.0 + if (degrees < 0) { + degrees = degrees + 360.0 + } + return degrees + } + + /** Equation used in CAM16 conversion that removes the effect of chromatic adaptation. */ + static inverseChromaticAdaptation(adapted: number) { + const adaptedAbs = Math.abs(adapted) + const base = Math.max(0, (27.13 * adaptedAbs) / (400 - adaptedAbs)) + + return CamUtils.signum(adapted) * Math.pow(base, 1 / 0.42) + } + + /** + * Finds a color with the given hue, chroma, and Y. + * + * @param hueRadians The desired hue in radians. + * @param chroma The desired chroma. + * @param y The desired Y. + * @returns The desired color as a hexadecimal integer, if found; 0 otherwise. + */ + static findResultByJ(hueRadians: number, chroma: number, y: number) { + // Initial estimate of j. + let j = Math.sqrt(y) * 11.0 + + // =========================================================== + // Operations inlined from Cam16 to avoid repeated calculation + // =========================================================== + const viewingConditions = Frame.DEFAULT, + tInnerCoeff = + 1 / Math.pow(1.64 - Math.pow(0.29, viewingConditions.getN()), 0.73), + eHue = 0.25 * (Math.cos(hueRadians + 2.0) + 3.8), + p1 = + eHue * + (50000 / 13) * + viewingConditions.getNc() * + viewingConditions.getNcb(), + hSin = Math.sin(hueRadians), + hCos = Math.cos(hueRadians) + + for (let iterationRound = 0; iterationRound < 5; iterationRound++) { + // =========================================================== + // Operations inlined from Cam16 to avoid repeated calculation + // =========================================================== + const jNormalized = j / 100.0, + alpha = chroma === 0 || j === 0 ? 0 : chroma / Math.sqrt(jNormalized), + t = Math.pow(alpha * tInnerCoeff, 1 / 0.9), + acExponent = 1 / viewingConditions.getC() / viewingConditions.getZ(), + ac = viewingConditions.getAw() * Math.pow(jNormalized, acExponent), + p2 = ac / viewingConditions.getNbb(), + gamma = + (23 * (p2 + 0.305) * t) / (23 * p1 + 11 * t * hCos + 108 * t * hSin), + a = gamma * hCos, + b = gamma * hSin, + rA = (460 * p2 + 451 * a + 288 * b) / 1403.0, + gA = (460 * p2 - 891 * a - 261 * b) / 1403.0, + bA = (460 * p2 - 220 * a - 6300 * b) / 1403.0, + rCScaled = HctSolver.inverseChromaticAdaptation(rA), + gCScaled = HctSolver.inverseChromaticAdaptation(gA), + bCScaled = HctSolver.inverseChromaticAdaptation(bA), + matrix = HctSolver.LINRGB_FROM_SCALED_DISCOUNT, + linrgbR = + rCScaled * matrix[0][0] + + gCScaled * matrix[0][1] + + bCScaled * matrix[0][2], + linrgbG = + rCScaled * matrix[1][0] + + gCScaled * matrix[1][1] + + bCScaled * matrix[1][2], + linrgbB = + rCScaled * matrix[2][0] + + gCScaled * matrix[2][1] + + bCScaled * matrix[2][2] + + // =========================================================== + // Operations inlined from Cam16 to avoid repeated calculation + // =========================================================== + if (linrgbR < 0 || linrgbG < 0 || linrgbB < 0) { + return 0 + } + + const kR = HctSolver.Y_FROM_LINRGB[0], + kG = HctSolver.Y_FROM_LINRGB[1], + kB = HctSolver.Y_FROM_LINRGB[2], + fnj = kR * linrgbR + kG * linrgbG + kB * linrgbB + + if (fnj <= 0) { + return 0 + } + if (iterationRound === 4 || Math.abs(fnj - y) < 0.002) { + if (linrgbR > 100.01 || linrgbG > 100.01 || linrgbB > 100.01) { + return 0 + } + return CamUtils.argbFromLinrgbComponents(linrgbR, linrgbG, linrgbB) + } + + // Iterates with Newton method, + // Using 2 * fn(j) / j as the approximation of fn'(j) + j = j - ((fnj - y) * j) / (2 * fnj) + } + + return 0 + } + + /** + * Finds an sRGB color with the given hue, chroma, and L*, if possible. + * + * @param hueDegrees The desired hue, in degrees. + * @param chroma The desired chroma. + * @param lstar The desired L*. + * @returns A hexadecimal representing the sRGB color. The color has sufficiently close hue, chroma, and L* to the desired + * values, if possible; otherwise, the hue and L* will be sufficiently close, and chroma will be maximized. + */ + static solveToInt(hueDegrees: number, chroma: number, lstar: number) { + if (chroma < 0.0001 || lstar < 0.0001 || lstar > 99.9999) { + return CamUtils.argbFromLstar(lstar) + } + + hueDegrees = HctSolver.sanitizeDegreesDouble(hueDegrees) + const hueRadians = MathUtils.toRadians(hueDegrees) + const y = CamUtils.yFromLstar(lstar) + + const exactAnswer = HctSolver.findResultByJ(hueRadians, chroma, y) + + if (exactAnswer !== 0) { + return exactAnswer + } + + return HctSolver.bisectToLimit(y, hueRadians) + } + + /** Ensure X is between 0 and 100. */ + static isBounded(x: number) { + return x >= 0 && x <= 100 + } + + /** + * Returns the nth possible vertex of the polygonal intersection. + * + * @param y The Y value of the plane. + * @param n The zero-based index of the point. 0 <= n <= 11. + * @returns The nth possible vertex of the polygonal intersection of the y plane and the RGB cube in linear RGB coordinates, if + * it exists. If the possible vertex lies outside of the cube, [-1.0, -1.0, -1.0] is returned. + */ + static nthVertex(y: number, n: number): [number, number, number] { + const kR = HctSolver.Y_FROM_LINRGB[0], + kG = HctSolver.Y_FROM_LINRGB[1], + kB = HctSolver.Y_FROM_LINRGB[2], + coordA = n % 4 <= 1 ? 0 : 100, + coordB = n % 2 === 0 ? 0 : 100 + + if (n < 4) { + const g = coordA + const b = coordB + const r = (y - g * kG - b * kB) / kR + if (HctSolver.isBounded(r)) { + return [r, g, b] + } else { + return [-1.0, -1.0, -1.0] + } + } else if (n < 8) { + const b = coordA + const r = coordB + const g = (y - r * kR - b * kB) / kG + if (HctSolver.isBounded(g)) { + return [r, g, b] + } else { + return [-1.0, -1.0, -1.0] + } + } else { + const r = coordA + const g = coordB + const b = (y - r * kR - g * kG) / kB + if (HctSolver.isBounded(b)) { + return [r, g, b] + } else { + return [-1.0, -1.0, -1.0] + } + } + } + + static chromaticAdaptation(component: number) { + const af = Math.pow(Math.abs(component), 0.42) + return (CamUtils.signum(component) * 400 * af) / (af + 27.13) + } + + /** + * Returns the hue of a linear RGB color in CAM16. + * + * @param linrgb The linear RGB coordinates of a color. + * @returns The hue of the color in CAM16, in radians. + */ + static hueOf(linrgb: [number, number, number]) { + // Calculate scaled discount components using in-lined matrix multiplication to avoid + // an array allocation. + const matrix = HctSolver.SCALED_DISCOUNT_FROM_LINRGB, + row = linrgb, + rD = + linrgb[0] * matrix[0][0] + + row[1] * matrix[0][1] + + row[2] * matrix[0][2], + gD = + linrgb[0] * matrix[1][0] + + row[1] * matrix[1][1] + + row[2] * matrix[1][2], + bD = + linrgb[0] * matrix[2][0] + row[1] * matrix[2][1] + row[2] * matrix[2][2] + + const rA = HctSolver.chromaticAdaptation(rD), + gA = HctSolver.chromaticAdaptation(gD), + bA = HctSolver.chromaticAdaptation(bD) + + // redness-greenness + const a = (11 * rA + -12 * gA + bA) / 11 + + // yellowness-blueness + const b = (rA + gA - 2 * bA) / 9 + + return Math.atan2(b, a) + } + + /** + * Sanitizes a small enough angle in radians. + * + * @param angle An angle in radians; must not deviate too much from 0. + * @returns A coterminal angle between 0 and 2pi. + */ + static sanitizeRadians(angle: number) { + return (angle + Math.PI * 8) % (Math.PI * 2) + } + + /** + * Cyclic order is the idea that 330° → 5° → 200° is in order, but, 180° → 270° → 210° is not. Visually, A B and C are angles, + * and they are in cyclic order if travelling from A to C in a way that increases angle (ex. counter-clockwise if +x axis = 0 + * degrees and +y = 90) means you must cross B. + * + * @param a First angle in possibly cyclic triplet + * @param b Second angle in possibly cyclic triplet + * @param c Third angle in possibly cyclic triplet + * @returns True if B is between A and C + */ + static areInCyclicOrder(a: number, b: number, c: number) { + const deltaAB = HctSolver.sanitizeRadians(b - a) + const deltaAC = HctSolver.sanitizeRadians(c - a) + + return deltaAB < deltaAC + } + + /** + * Finds the segment containing the desired color. + * + * @param y The Y value of the color. + * @param targetHue The hue of the color. + * @returns A list of two sets of linear RGB coordinates, each corresponding to an endpoint of the segment containing the + * desired color. + */ + static bisectToSegment(y: number, targetHue: number) { + let left: [number, number, number] = [-1.0, -1.0, -1.0], + right = left, + leftHue = 0.0, + rightHue = 0.0, + initialized = false, + uncut = true + + for (let n = 0; n < 12; n++) { + const mid = HctSolver.nthVertex(y, n) + if (mid[0] < 0) { + continue + } + + const midHue = HctSolver.hueOf(mid) + if (!initialized) { + left = mid + right = mid + leftHue = midHue + rightHue = midHue + initialized = true + continue + } + + if (uncut || HctSolver.areInCyclicOrder(leftHue, midHue, rightHue)) { + uncut = false + if (HctSolver.areInCyclicOrder(leftHue, targetHue, midHue)) { + right = mid + rightHue = midHue + } else { + left = mid + leftHue = midHue + } + } + } + + return [left, right] as [[number, number, number], [number, number, number]] + } + + static criticalPlaneBelow(x: number) { + return Math.floor(x - 0.5) + } + + static criticalPlaneAbove(x: number) { + return Math.ceil(x - 0.5) + } + + /** + * Delinearizes an RGB component, returning a floating-point number. + * + * @param rgbComponent 0.0 <= rgb_component <= 100.0, represents linear R/G/B channel + * @returns 0.0 <= output <= 255.0, color channel converted to regular RGB space + */ + static trueDelinearized(rgbComponent: number) { + const normalized = rgbComponent / 100.0 + + let delinearized + if (normalized <= 0.0031308) { + delinearized = normalized * 12.92 + } else { + delinearized = 1.055 * Math.pow(normalized, 1 / 2.4) - 0.055 + } + + return delinearized * 255.0 + } + + /** + * Find an intercept using linear interpolation. + * + * @param source The starting number. + * @param mid The number in the middle. + * @param target The ending number. + * @returns A number t such that lerp(source, target, t) = mid. + */ + static intercept(source: number, mid: number, target: number) { + if (target === source) return target + + return (mid - source) / (target - source) + } + + /** + * Linearly interpolate between two points in three dimensions. + * + * @param source Three dimensions representing the starting point + * @param t The percentage to travel between source and target, from 0 to 1 + * @param target Three dimensions representing the end point + * @returns Three dimensions representing the point t percent from source to target. + */ + static lerpPoint( + source: [number, number, number], + t: number, + target: [number, number, number], + ) { + return [ + source[0] + (target[0] - source[0]) * t, + source[1] + (target[1] - source[1]) * t, + source[2] + (target[2] - source[2]) * t, + ] as [number, number, number] + } + + /** + * Intersects a segment with a plane. + * + * @param source The coordinates of point A. + * @param coordinate The R-, G-, or B-coordinate of the plane. + * @param target The coordinates of point B. + * @param axis The axis the plane is perpendicular with. (0: R, 1: G, 2: B) + * @returns The intersection point of the segment AB with the plane R=coordinate, G=coordinate, or B=coordinate + */ + static setCoordinate( + source: [number, number, number], + coordinate: number, + target: [number, number, number], + axis: 0 | 1 | 2, + ) { + const t = HctSolver.intercept(source[axis], coordinate, target[axis]) + + return HctSolver.lerpPoint(source, t, target) + } + + /** + * Finds a color with the given Y and hue on the boundary of the cube. + * + * @param y The Y value of the color. + * @param targetHue The hue of the color. + * @returns The desired color, in linear RGB coordinates. + */ + static bisectToLimit(y: number, targetHue: number) { + const segment = HctSolver.bisectToSegment(y, targetHue) + + let left = segment[0], + leftHue = HctSolver.hueOf(left), + right = segment[1] + + for (let axis = 0 as 0 | 1 | 2; axis < 3; axis++) { + if (left[axis] !== right[axis]) { + let lPlane = -1 + let rPlane = 255 + if (left[axis] < right[axis]) { + lPlane = HctSolver.criticalPlaneBelow( + HctSolver.trueDelinearized(left[axis]), + ) + rPlane = HctSolver.criticalPlaneAbove( + HctSolver.trueDelinearized(right[axis]), + ) + } else { + lPlane = HctSolver.criticalPlaneAbove( + HctSolver.trueDelinearized(left[axis]), + ) + rPlane = HctSolver.criticalPlaneBelow( + HctSolver.trueDelinearized(right[axis]), + ) + } + for (let i = 0; i < 8; i++) { + if (Math.abs(rPlane - lPlane) <= 1) { + break + } else { + const mPlane = Math.floor((lPlane + rPlane) / 2.0) + const midPlaneCoordinate = HctSolver.CRITICAL_PLANES[mPlane] ?? 0 + + const mid: [number, number, number] = HctSolver.setCoordinate( + left, + midPlaneCoordinate, + right, + axis, + ) + const midHue = HctSolver.hueOf(mid) + if (HctSolver.areInCyclicOrder(leftHue, targetHue, midHue)) { + right = mid + rPlane = mPlane + } else { + left = mid + leftHue = midHue + lPlane = mPlane + } + } + } + } + } + + return CamUtils.argbFromLinrgbComponents( + (left[0] + right[0]) / 2, + (left[1] + right[1]) / 2, + (left[2] + right[2]) / 2, + ) + } +} diff --git a/src/alf/util/material3/Monet/Palette.ts b/src/alf/util/material3/Monet/Palette.ts new file mode 100644 index 000000000..562a97cdf --- /dev/null +++ b/src/alf/util/material3/Monet/Palette.ts @@ -0,0 +1,246 @@ +/* + * This code was originally written in Java and has been converted to JavaScript. + * + * Original Java code source: [https://android.googlesource.com/platform/frameworks/base/+/6844741fb8351f3aa82b96ce64a1bd83ea7989bd/packages/SystemUI/monet/src/com/android/systemui/monet?autodive=0%2F] + * + * Copyright (C) 2021 The Android Open Source Project. + * The original code is licensed under the Apache License, Version 2.0. + * + * This JavaScript version is licensed under the MIT License. + * + * See the respective licenses for the specific terms, permissions, and limitations. + */ + +import {Cam} from '../Cam/Cam' +import {type GenerationStyle, type MaterialYouPalette} from '../Types' +import Color from '../Utils/Color' +import Shades from './Shades' + +type StyleType = Record< + GenerationStyle, + Record<'a1' | 'a2' | 'a3' | 'n1' | 'n2', [number, number]> +> + +export default class Palette { + static ACCENT1_CHROMA = 48 + static ACCENT2_CHROMA = 16 + static ACCENT3_CHROMA = 32 + static ACCENT3_HUE_SHIFT = 60 + static NEUTRAL1_CHROMA = 4 + static NEUTRAL2_CHROMA = 8 + static GOOGLE_BLUE = 0xff1b6ef3 + + static getStyle(hue: number, chroma: number): StyleType { + return { + SPRITZ: { + a1: [hue, 12], + a2: [hue, 8], + a3: [hue, 16], + n1: [hue, 2], + n2: [hue, 2], + }, + TONAL_SPOT: { + a1: [hue, 36], + a2: [hue, 16], + a3: [Palette.HueAdd(hue, 60), 24], + n1: [hue, 4], + n2: [hue, 8], + }, + VIBRANT: { + a1: [hue, 130], + a2: [Palette.HueVibrantSecondary(hue), 24], + a3: [Palette.HueVibrantTertiary(hue), 32], + n1: [hue, 10], + n2: [hue, 12], + }, + EXPRESSIVE: { + a1: [Palette.HueAdd(hue, 240), 40], + a2: [Palette.HueExpressiveSecondary(hue), 24], + a3: [Palette.HueExpressiveTertiary(hue), 32], + n1: [Palette.HueAdd(hue, 15), 8], + n2: [Palette.HueAdd(hue, 15), 12], + }, + RAINBOW: { + a1: [hue, 48], + a2: [hue, 16], + a3: [Palette.HueAdd(hue, 60), 24], + n1: [hue, 0], + n2: [hue, 0], + }, + FRUIT_SALAD: { + a1: [Palette.HueSubtract(hue, 50), 48], + a2: [Palette.HueSubtract(hue, 50), 36], + a3: [hue, 36], + n1: [hue, 10], + n2: [hue, 16], + }, + CONTENT: { + a1: [hue, chroma], + a2: [hue, Palette.ChromaMultiple(chroma, 0.33)], + a3: [hue, Palette.ChromaMultiple(chroma, 0.66)], + n1: [hue, Palette.ChromaMultiple(chroma, 0.0833)], + n2: [hue, Palette.ChromaMultiple(chroma, 0.1666)], + }, + MONOCHROMATIC: { + a1: [hue, 0], + a2: [hue, 0], + a3: [hue, 0], + n1: [hue, 0], + n2: [hue, 0], + }, + } + } + + static HueAdd(hue: number, amountDegrees: number): number { + return Palette.wrapDegreesDouble(hue + amountDegrees) + } + + static HueSubtract(hue: number, amountDegrees: number): number { + return Palette.wrapDegreesDouble(hue - amountDegrees) + } + + static ChromaMultiple(chroma: number, multiple: number): number { + return chroma * multiple + } + + static getHueRotation( + sourceHue: number, + hueAndRotations: Array<[number, number]>, + ): number { + const sanitizedSourceHue = sourceHue < 0 || sourceHue >= 360 ? 0 : sourceHue + + for (let i = 0; i < hueAndRotations.length - 1; i++) { + const thisHue = hueAndRotations[i][0] + const nextHue = hueAndRotations[i + 1][0] + + if (thisHue <= sanitizedSourceHue && sanitizedSourceHue < nextHue) { + return Palette.wrapDegreesDouble( + sanitizedSourceHue + hueAndRotations[i][1], + ) + } + } + + // If this statement executes, something is wrong, there should have been a rotation + // found using the arrays. + return sourceHue + } + + static HueVibrantSecondary(hue: number) { + const hueToRotations: Array<[number, number]> = [ + [0, 18], + [41, 15], + [61, 10], + [101, 12], + [131, 15], + [181, 18], + [251, 15], + [301, 12], + [360, 12], + ] + + return Palette.getHueRotation(hue, hueToRotations) + } + + static HueVibrantTertiary(hue: number) { + const hueToRotations: Array<[number, number]> = [ + [0, 35], + [41, 30], + [61, 20], + [101, 25], + [131, 30], + [181, 35], + [251, 30], + [301, 25], + [360, 25], + ] + + return Palette.getHueRotation(hue, hueToRotations) + } + + static HueExpressiveSecondary(hue: number) { + const hueToRotations: Array<[number, number]> = [ + [0, 45], + [21, 95], + [51, 45], + [121, 20], + [151, 45], + [191, 90], + [271, 45], + [321, 45], + [360, 45], + ] + + return Palette.getHueRotation(hue, hueToRotations) + } + + static HueExpressiveTertiary(hue: number) { + const hueToRotations: Array<[number, number]> = [ + [0, 120], + [21, 120], + [51, 20], + [121, 45], + [151, 20], + [191, 15], + [271, 20], + [321, 120], + [360, 120], + ] + + return Palette.getHueRotation(hue, hueToRotations) + } + + static wrapDegreesDouble(degrees: number): number { + if (degrees < 0) { + return (degrees % 360) + 360 + } else if (degrees >= 360) { + return degrees % 360 + } else { + return degrees + } + } + + static generate( + seed: string, + style: GenerationStyle = 'TONAL_SPOT', + ): MaterialYouPalette { + // Parse the HEX seed color string into an integer + seed = seed.toUpperCase().substring(1, 7) + const colorInt = parseInt('0xff' + seed, 16) + + let seedArgb + if (colorInt === Color.TRANSPARENT) { + seedArgb = Palette.GOOGLE_BLUE + } else { + seedArgb = colorInt + } + + const camSeed = Cam.fromInt(seedArgb), + hue = camSeed.getHue(), + chroma = Math.max(camSeed.getChroma(), Palette.ACCENT1_CHROMA) + + const {a1, a2, a3, n1, n2} = Palette.getStyle(hue, chroma)[style] + + const accent1 = Shades.of(a1[0], a1[1]), + accent2 = Shades.of(a2[0], a2[1]), + accent3 = Shades.of(a3[0], a3[1]), + neutral1 = Shades.of(n1[0], n1[1]), + neutral2 = Shades.of(n2[0], n2[1]) + + const numberToHexColor = (colorNumber: number) => { + return ( + '#' + + (colorNumber & 0xffffff).toString(16).padStart(6, '0').toUpperCase() + ) + } + + const results = { + system_accent1: accent1.map(numberToHexColor), + system_accent2: accent2.map(numberToHexColor), + system_accent3: accent3.map(numberToHexColor), + system_neutral1: neutral1.map(numberToHexColor), + system_neutral2: neutral2.map(numberToHexColor), + } + + return results as MaterialYouPalette + } +} diff --git a/src/alf/util/material3/Monet/Shades.ts b/src/alf/util/material3/Monet/Shades.ts new file mode 100644 index 000000000..f25cab65e --- /dev/null +++ b/src/alf/util/material3/Monet/Shades.ts @@ -0,0 +1,58 @@ +/* + * This code was originally written in Java and has been converted to JavaScript. + * + * Original Java code source: [https://android.googlesource.com/platform/frameworks/base/+/6844741fb8351f3aa82b96ce64a1bd83ea7989bd/packages/SystemUI/monet/src/com/android/systemui/monet?autodive=0%2F] + * + * Copyright (C) 2021 The Android Open Source Project. + * The original code is licensed under the Apache License, Version 2.0. + * + * This JavaScript version is licensed under the MIT License. + * + * See the respective licenses for the specific terms, permissions, and limitations. + */ + +import ColorUtils from '../Utils/ColorUtils' + +export default class Shades { + /** + * Combining the ability to convert between relative luminance and perceptual luminance with contrast leads to a design system + * that can be based on a linear value to determine contrast, rather than a ratio. + * + * This codebase implements a design system that has that property, and as a result, we can guarantee that any shades 5 steps + * from each other have a contrast ratio of at least 4.5. 4.5 is the requirement for smaller text contrast in WCAG 2.1 and + * earlier. + * + * However, lstar 50 does _not_ have a contrast ratio >= 4.5 with lstar 100. lstar 49.6 is the smallest lstar that will lead to + * a contrast ratio >= 4.5 with lstar 100, and it also contrasts >= 4.5 with lstar 100. + */ + static MIDDLE_LSTAR = 49.6 + + /** + * Generate shades of a color. Ordered in lightness _descending_. The first shade will be at 95% lightness, the next at 90, 80, + * etc. through 0. + * + * @param hue Hue in CAM16 color space + * @param chroma Chroma in CAM16 color space + * @returns Shades of a color, as argb integers. Ordered by lightness descending. + */ + static of(hue: number, chroma: number) { + const shades: number[] = [] + + // At tone 90 and above, blue and yellow hues can reach a much higher chroma. + // To preserve a consistent appearance across all hues, use a maximum chroma of 40. + shades[0] = ColorUtils.CAMToColor(hue, Math.min(40, chroma), 99) + shades[1] = ColorUtils.CAMToColor(hue, Math.min(40, chroma), 95) + + for (let i = 2; i < 12; i++) { + const lStar = i === 6 ? Shades.MIDDLE_LSTAR : 100 - 10 * (i - 1) + if (lStar >= 90) { + chroma = Math.min(40, chroma) + } + shades[i] = ColorUtils.CAMToColor(hue, chroma, lStar) + } + + shades.unshift(16777215) // first color is always pure white + + return shades + } +} diff --git a/src/alf/util/material3/Types.ts b/src/alf/util/material3/Types.ts new file mode 100644 index 000000000..cce33d61b --- /dev/null +++ b/src/alf/util/material3/Types.ts @@ -0,0 +1,106 @@ +type ShadesArr = [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, +] + +export type MaterialYouPalette = { + /** An array with `13` shades. */ + system_accent1: ShadesArr + /** An array with `13` shades. */ + system_accent2: ShadesArr + /** An array with `13` shades. */ + system_accent3: ShadesArr + /** An array with `13` shades. */ + system_neutral1: ShadesArr + /** An array with `13` shades. */ + system_neutral2: ShadesArr +} + +export type GenerationStyle = + | 'SPRITZ' + | 'TONAL_SPOT' + | 'VIBRANT' + | 'EXPRESSIVE' + | 'RAINBOW' + | 'FRUIT_SALAD' + | 'CONTENT' + | 'MONOCHROMATIC' + +export type ColorScheme = 'light' | 'dark' | 'auto' + +export type MapPaletteToThemeType = (palette: MaterialYouPalette) => { + light: Record + dark: Record +} + +export type ThemeProviderProps = { + /** + * Specifies the initial color scheme for your app. + * + * `"auto" | "dark" | "light"` + */ + colorScheme?: ColorScheme + /** + * This is used to generate a fallback palette in case the platform does not support Material You colors. + * + * **Note:** provide a color only in HEX format + */ + fallbackColor?: string + /** + * If set to "auto", it tries to get the palette from the device, falling back to the provided color if unsupported. If set to a + * color (HEX only), it generates a new palette without device retrieval. + */ + seedColor?: 'auto' | (string & NonNullable) + /** + * Palette generation style. The style that dictates how the palette will be generated. + * + * `"SPRITZ"| "TONAL_SPOT"| "VIBRANT"| "EXPRESSIVE"| "RAINBOW"| "FRUIT_SALAD"| "CONTENT"| "MONOCHROMATIC"` + */ + generationStyle?: GenerationStyle + children?: React.ReactNode +} + +export type MaterialYouThemeContext = { + /** Switch between themes (`dark` or `light`) or set to `auto` to follow system color scheme preference. */ + setColorScheme: (value: ColorScheme) => void + /** + * Generate a new Material You palette and set it as the current theme. + * + * @param {string} seed + * + * - The seed color. It can be `"auto"` to follow the system theme if supported; otherwise, it will generate a palette using the + * `fallbackColor` prop. If a HEX color is provided, it will generate a new palette using that seed color. + * + * @param {string} style - The style that dictates how the palette will be generated. + */ + setMaterialYouColor: ( + seed: 'auto' | (string & NonNullable), + style?: GenerationStyle, + ) => void + /** + * Change the palette generation style and set it as the current theme. + * + * **Disclaimer**: If the current Material You palette is set to `"auto"` (following the system theme), a new palette will be + * generated using the `fallbackColor` prop. + * + * @param {string} style - The style that dictates how the palette will be generated. + */ + setPaletteStyle: (style: GenerationStyle) => void + /** The current seed color used to generate the palette. If the palette follows the system theme, it will be `"auto"`. */ + seedColor: 'auto' | (string & NonNullable) + /** The current generation style used to generate the palette */ + style: GenerationStyle + /** The current palette. */ + palette: MaterialYouPalette +} diff --git a/src/alf/util/material3/Utils/Color.ts b/src/alf/util/material3/Utils/Color.ts new file mode 100644 index 000000000..a5a64f6e2 --- /dev/null +++ b/src/alf/util/material3/Utils/Color.ts @@ -0,0 +1,18 @@ +export default class Color { + static TRANSPARENT = 0 + + static clampRGB = (value: number) => Color.clamp(Math.round(value), 0, 255) + static clamp = (value: number, min: number, max: number) => + Math.max(min, Math.min(value, max)) + + static red = (color: number) => (color >> 16) & 0xff + static green = (color: number) => (color >> 8) & 0xff + static blue = (color: number) => color & 0xff + + static rgb(red: number, green: number, blue: number) { + red = Color.clampRGB(red) + green = Color.clampRGB(green) + blue = Color.clampRGB(blue) + return 0xff000000 | (red << 16) | (green << 8) | blue + } +} diff --git a/src/alf/util/material3/Utils/ColorUtils.ts b/src/alf/util/material3/Utils/ColorUtils.ts new file mode 100644 index 000000000..2bfb0187a --- /dev/null +++ b/src/alf/util/material3/Utils/ColorUtils.ts @@ -0,0 +1,57 @@ +import {Cam} from '../Cam/Cam' +import Color from './Color' + +export default class ColorUtils { + static XYZ_WHITE_REFERENCE_X = 95.047 + static XYZ_WHITE_REFERENCE_Y = 100 + static XYZ_WHITE_REFERENCE_Z = 108.883 + + static constrain(amount: number, low: number, high: number) { + return amount < low ? low : amount > high ? high : amount + } + + /** + * Converts a color from CIE XYZ to its RGB representation. + * + * This method expects the XYZ representation to use the D65 illuminant and the CIE 2° Standard Observer (1931). + * + * @param x X component value [0...95.047) + * @param y Y component value [0...100) + * @param z Z component value [0...108.883) + * @returns Int containing the RGB representation + */ + static XYZToColor(x: number, y: number, z: number) { + x = Color.clamp(x, 0, ColorUtils.XYZ_WHITE_REFERENCE_X) + y = Color.clamp(y, 0, ColorUtils.XYZ_WHITE_REFERENCE_Y) + z = Color.clamp(z, 0, ColorUtils.XYZ_WHITE_REFERENCE_Z) + + let r = (x * 3.2406 + y * -1.5372 + z * -0.4986) / 100 + let g = (x * -0.9689 + y * 1.8758 + z * 0.0415) / 100 + let b = (x * 0.0557 + y * -0.204 + z * 1.057) / 100 + + r = r > 0.0031308 ? 1.055 * Math.pow(r, 1 / 2.4) - 0.055 : 12.92 * r + g = g > 0.0031308 ? 1.055 * Math.pow(g, 1 / 2.4) - 0.055 : 12.92 * g + b = b > 0.0031308 ? 1.055 * Math.pow(b, 1 / 2.4) - 0.055 : 12.92 * b + + return Color.rgb( + ColorUtils.constrain(Math.round(r * 255), 0, 255), + ColorUtils.constrain(Math.round(g * 255), 0, 255), + ColorUtils.constrain(Math.round(b * 255), 0, 255), + ) + } + + /** + * Convert a color appearance model representation to an ARGB color. + * + * Note: the returned color may have a lower chroma than requested. Whether a chroma is available depends on luminance. For + * example, there's no such thing as a high chroma light red, due to the limitations of our eyes and/or physics. If the + * requested chroma is unavailable, the highest possible chroma at the requested luminance is returned. + * + * @param hue Hue, in degrees, in CAM coordinates + * @param chroma Chroma in CAM coordinates. + * @param lstar Perceptual luminance, L* in L_a_b* + */ + static CAMToColor(hue: number, chroma: number, lstar: number) { + return Cam.getInt(hue, chroma, lstar) + } +} diff --git a/src/alf/util/material3/Utils/MathUtils.ts b/src/alf/util/material3/Utils/MathUtils.ts new file mode 100644 index 000000000..e52adc97e --- /dev/null +++ b/src/alf/util/material3/Utils/MathUtils.ts @@ -0,0 +1,11 @@ +class MathUtils { + static lerp(start: number, stop: number, amount: number) { + return start + (stop - start) * amount + } + + static toRadians(degrees: number) { + return degrees * (Math.PI / 180) + } +} + +export default MathUtils diff --git a/src/alf/util/material3/index.tsx b/src/alf/util/material3/index.tsx new file mode 100644 index 000000000..a1bf3cb83 --- /dev/null +++ b/src/alf/util/material3/index.tsx @@ -0,0 +1,32 @@ +// https://github.com/alabsi91/react-native-material-you-colors +/*! +MIT License + +Copyright (c) 2023 Ahmed ALABSI +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +import Palette from './Monet/Palette' +import {type GenerationStyle} from './Types' + +export type {GenerationStyle} +export const generatePaletteFromColor = ( + colorSeed: string, + style?: GenerationStyle, +) => Palette.generate(colorSeed, style) diff --git a/src/alf/util/material3Theme.ts b/src/alf/util/material3Theme.ts new file mode 100644 index 000000000..9f82ff751 --- /dev/null +++ b/src/alf/util/material3Theme.ts @@ -0,0 +1,175 @@ +import {type MaterialYouPalette} from '@assembless/react-native-material-you' +import {createThemes} from '@bsky.app/alf' + +import {type Palette, STATIC_VALUES} from '../themes' +import {getMaterialYouColor} from './materialYou' + +export function getMaterial3Colors(palette: MaterialYouPalette) { + const MATERIAL_3_PALETTE: Palette = { + white: getMaterialYouColor(palette, 'system_neutral1', 0), + black: getMaterialYouColor(palette, 'system_neutral1', 1000), + pink: getMaterialYouColor(palette, 'system_accent3', 500), + yellow: getMaterialYouColor( + palette, + 'system_error', + 200, + STATIC_VALUES.yellow, + ), + like: getMaterialYouColor(palette, 'system_accent3', 500), + + contrast_0: getMaterialYouColor(palette, 'system_neutral1', 0), + contrast_25: getMaterialYouColor(palette, 'system_neutral1', 10), + contrast_50: getMaterialYouColor(palette, 'system_neutral1', 50), + contrast_100: getMaterialYouColor(palette, 'system_neutral1', 100), + contrast_200: getMaterialYouColor(palette, 'system_neutral1', 200), + contrast_300: getMaterialYouColor(palette, 'system_neutral1', 300), + contrast_400: getMaterialYouColor(palette, 'system_neutral1', 400), + contrast_500: getMaterialYouColor(palette, 'system_neutral1', 500), + contrast_600: getMaterialYouColor(palette, 'system_neutral1', 600), + contrast_700: getMaterialYouColor(palette, 'system_neutral1', 700), + contrast_800: getMaterialYouColor(palette, 'system_neutral1', 800), + contrast_900: getMaterialYouColor(palette, 'system_neutral1', 900), + contrast_950: getMaterialYouColor(palette, 'system_neutral1', 900), + contrast_975: getMaterialYouColor(palette, 'system_neutral1', 900), + contrast_1000: getMaterialYouColor(palette, 'system_neutral1', 1000), + + primary_25: getMaterialYouColor(palette, 'system_accent1', 10), + primary_50: getMaterialYouColor(palette, 'system_accent1', 50), + primary_100: getMaterialYouColor(palette, 'system_accent1', 100), + primary_200: getMaterialYouColor(palette, 'system_accent1', 200), + primary_300: getMaterialYouColor(palette, 'system_accent1', 300), + primary_400: getMaterialYouColor(palette, 'system_accent1', 400), + primary_500: getMaterialYouColor(palette, 'system_accent1', 500), + primary_600: getMaterialYouColor(palette, 'system_accent1', 600), + primary_700: getMaterialYouColor(palette, 'system_accent1', 700), + primary_800: getMaterialYouColor(palette, 'system_accent1', 800), + primary_900: getMaterialYouColor(palette, 'system_accent1', 900), + primary_950: getMaterialYouColor(palette, 'system_accent1', 900), + primary_975: getMaterialYouColor(palette, 'system_accent1', 1000), + + positive_25: getMaterialYouColor(palette, 'system_accent2', 10), + positive_50: getMaterialYouColor(palette, 'system_accent2', 50), + positive_100: getMaterialYouColor(palette, 'system_accent2', 100), + positive_200: getMaterialYouColor(palette, 'system_accent2', 200), + positive_300: getMaterialYouColor(palette, 'system_accent2', 300), + positive_400: getMaterialYouColor(palette, 'system_accent2', 400), + positive_500: getMaterialYouColor(palette, 'system_accent2', 500), + positive_600: getMaterialYouColor(palette, 'system_accent2', 600), + positive_700: getMaterialYouColor(palette, 'system_accent2', 700), + positive_800: getMaterialYouColor(palette, 'system_accent2', 800), + positive_900: getMaterialYouColor(palette, 'system_accent2', 900), + positive_950: getMaterialYouColor(palette, 'system_accent2', 900), + positive_975: getMaterialYouColor(palette, 'system_accent2', 1000), + + negative_25: getMaterialYouColor(palette, 'system_error', 10, '#FFF5F7'), + negative_50: getMaterialYouColor(palette, 'system_error', 50, '#FEEBEF'), + negative_100: getMaterialYouColor(palette, 'system_error', 100, '#FDD8E1'), + negative_200: getMaterialYouColor(palette, 'system_error', 200, '#FCC0CE'), + negative_300: getMaterialYouColor(palette, 'system_error', 300, '#F99AB0'), + negative_400: getMaterialYouColor(palette, 'system_error', 400, '#F76486'), + negative_500: getMaterialYouColor(palette, 'system_error', 500, '#EB2452'), + negative_600: getMaterialYouColor(palette, 'system_error', 600, '#D81341'), + negative_700: getMaterialYouColor(palette, 'system_error', 700, '#BA1239'), + negative_800: getMaterialYouColor(palette, 'system_error', 800, '#910D2C'), + negative_900: getMaterialYouColor(palette, 'system_error', 900, '#6F0B22'), + negative_950: getMaterialYouColor(palette, 'system_error', 900, '#500B1C'), + negative_975: getMaterialYouColor(palette, 'system_error', 1000, '#3E0915'), + } + + const MATERIAL_3_SUBDUED_PALETTE: Palette = { + white: getMaterialYouColor(palette, 'system_neutral1', 50), + black: getMaterialYouColor(palette, 'system_neutral1', 900), + pink: getMaterialYouColor(palette, 'system_accent3', 500), + yellow: getMaterialYouColor( + palette, + 'system_error', + 200, + STATIC_VALUES.yellow, + ), + like: getMaterialYouColor(palette, 'system_accent3', 500), + + contrast_0: getMaterialYouColor(palette, 'system_neutral1', 50), + contrast_25: getMaterialYouColor(palette, 'system_neutral1', 50), + contrast_50: getMaterialYouColor(palette, 'system_neutral1', 50), + contrast_100: getMaterialYouColor(palette, 'system_neutral1', 100), + contrast_200: getMaterialYouColor(palette, 'system_neutral1', 100), + contrast_300: getMaterialYouColor(palette, 'system_neutral1', 200), + contrast_400: getMaterialYouColor(palette, 'system_neutral1', 300), + contrast_500: getMaterialYouColor(palette, 'system_neutral1', 400), + contrast_600: getMaterialYouColor(palette, 'system_neutral1', 400), + contrast_700: getMaterialYouColor(palette, 'system_neutral1', 500), + contrast_800: getMaterialYouColor(palette, 'system_neutral1', 600), + contrast_900: getMaterialYouColor(palette, 'system_neutral1', 700), + contrast_950: getMaterialYouColor(palette, 'system_neutral1', 800), + contrast_975: getMaterialYouColor(palette, 'system_neutral1', 800), + contrast_1000: getMaterialYouColor(palette, 'system_neutral1', 900), + + primary_25: getMaterialYouColor(palette, 'system_accent1', 10), + primary_50: getMaterialYouColor(palette, 'system_accent1', 50), + primary_100: getMaterialYouColor(palette, 'system_accent1', 100), + primary_200: getMaterialYouColor(palette, 'system_accent1', 200), + primary_300: getMaterialYouColor(palette, 'system_accent1', 300), + primary_400: getMaterialYouColor(palette, 'system_accent1', 400), + primary_500: getMaterialYouColor(palette, 'system_accent1', 400), + primary_600: getMaterialYouColor(palette, 'system_accent1', 500), + primary_700: getMaterialYouColor(palette, 'system_accent1', 600), + primary_800: getMaterialYouColor(palette, 'system_accent1', 700), + primary_900: getMaterialYouColor(palette, 'system_accent1', 800), + primary_950: getMaterialYouColor(palette, 'system_accent1', 800), + primary_975: getMaterialYouColor(palette, 'system_accent1', 900), + + positive_25: getMaterialYouColor(palette, 'system_accent2', 10), + positive_50: getMaterialYouColor(palette, 'system_accent2', 50), + positive_100: getMaterialYouColor(palette, 'system_accent2', 100), + positive_200: getMaterialYouColor(palette, 'system_accent2', 200), + positive_300: getMaterialYouColor(palette, 'system_accent2', 300), + positive_400: getMaterialYouColor(palette, 'system_accent2', 400), + positive_500: getMaterialYouColor(palette, 'system_accent2', 400), + positive_600: getMaterialYouColor(palette, 'system_accent2', 500), + positive_700: getMaterialYouColor(palette, 'system_accent2', 600), + positive_800: getMaterialYouColor(palette, 'system_accent2', 700), + positive_900: getMaterialYouColor(palette, 'system_accent2', 800), + positive_950: getMaterialYouColor(palette, 'system_accent2', 900), + positive_975: getMaterialYouColor(palette, 'system_accent2', 900), + + negative_25: getMaterialYouColor(palette, 'system_error', 10, '#FFF5F7'), + negative_50: getMaterialYouColor(palette, 'system_error', 50, '#FEEBEF'), + negative_100: getMaterialYouColor(palette, 'system_error', 100, '#FDD8E1'), + negative_200: getMaterialYouColor(palette, 'system_error', 200, '#FCC0CE'), + negative_300: getMaterialYouColor(palette, 'system_error', 300, '#F99AB0'), + negative_400: getMaterialYouColor(palette, 'system_error', 400, '#F76486'), + negative_500: getMaterialYouColor(palette, 'system_error', 400, '#EB2452'), + negative_600: getMaterialYouColor(palette, 'system_error', 500, '#D81341'), + negative_700: getMaterialYouColor(palette, 'system_error', 600, '#BA1239'), + negative_800: getMaterialYouColor(palette, 'system_error', 700, '#910D2C'), + negative_900: getMaterialYouColor(palette, 'system_error', 800, '#6F0B22'), + negative_950: getMaterialYouColor(palette, 'system_error', 800, '#500B1C'), + negative_975: getMaterialYouColor(palette, 'system_error', 900, '#3E0915'), + } + + const MATERIAL_3_THEMES = createThemes({ + defaultPalette: MATERIAL_3_PALETTE, + subduedPalette: MATERIAL_3_SUBDUED_PALETTE, + }) + + // special case for disabled (primary) button text. we have a hack for primary_subtle in Button.tsx + MATERIAL_3_THEMES.dark.atoms.text_inverted.color = + MATERIAL_3_THEMES.dark.palette.primary_400 + MATERIAL_3_THEMES.dim.atoms.text_inverted.color = + MATERIAL_3_THEMES.dim.palette.primary_400 + + const material3scheme = { + lightPalette: MATERIAL_3_THEMES.light.palette, + darkPalette: MATERIAL_3_THEMES.dark.palette, + dimPalette: MATERIAL_3_THEMES.dim.palette, + light: MATERIAL_3_THEMES.light, + dark: MATERIAL_3_THEMES.dark, + dim: MATERIAL_3_THEMES.dim, + } + + return { + regular: MATERIAL_3_PALETTE, + subdued: MATERIAL_3_SUBDUED_PALETTE, + scheme: material3scheme, + } +} diff --git a/src/alf/util/materialYou.android.tsx b/src/alf/util/materialYou.android.tsx index f040d9be8..9dc5685ad 100644 --- a/src/alf/util/materialYou.android.tsx +++ b/src/alf/util/materialYou.android.tsx @@ -1,4 +1,4 @@ -import {createContext, useEffect, useState} from 'react' +import {createContext, use, useEffect, useState} from 'react' import {AppState} from 'react-native' import { getPalette, @@ -7,15 +7,19 @@ import { } from '@assembless/react-native-material-you' let palette = getPaletteSync() as MaterialYouPalette + export function getMaterialYouColor( + palette: MaterialYouPalette, color: [ 'system_accent1', 'system_accent2', 'system_accent3', 'system_neutral1', 'system_neutral2', + 'system_error', ][number], shade: [0, 10, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000][number], + fallback: string = '#000000', ): string { const shades = [0, 10, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000] let shadeIndex = shades.findIndex(s => s === shade) @@ -25,7 +29,7 @@ export function getMaterialYouColor( ) } - return palette[color][shadeIndex] + return palette[color]?.[shadeIndex] || fallback } const colorsChangedCallbacks = new Set<() => void>() @@ -36,8 +40,8 @@ AppState.addEventListener('focus', () => { // check if colors changed const colorsChanged = Object.keys(newPalette).some(key => { const colorKey = key as keyof MaterialYouPalette - return newPalette[colorKey].some( - (color, index) => color !== palette[colorKey][index], + return newPalette[colorKey]?.some( + (color, index) => color !== palette[colorKey]?.[index], ) }) if (colorsChanged) { @@ -52,7 +56,7 @@ AppState.addEventListener('focus', () => { }) }) -export function onMaterialYouPaletteChange(callback: () => void) { +function onMaterialYouPaletteChange(callback: () => void) { colorsChangedCallbacks.add(callback) return () => { colorsChangedCallbacks.delete(callback) @@ -61,23 +65,25 @@ export function onMaterialYouPaletteChange(callback: () => void) { const PaletteProvider = createContext(palette) -// context forces a rerender when palette changes even if nothing uses it (because unfortunately, we're forced to use it -// via Alf) export function MaterialYouPaletteProvider({ children, }: React.PropsWithChildren) { - const [_palette, setPalette] = useState(palette) + const [thePalette, setThePalette] = useState(palette) useEffect(() => { const unsubscribe = onMaterialYouPaletteChange(() => { - setPalette(() => palette) + setThePalette(() => palette) }) return unsubscribe }, []) return ( - + {children} ) } + +export function useMaterialYouPalette() { + return use(PaletteProvider) +} diff --git a/src/alf/util/materialYou.tsx b/src/alf/util/materialYou.tsx index b54e67433..5320aa898 100644 --- a/src/alf/util/materialYou.tsx +++ b/src/alf/util/materialYou.tsx @@ -1,36 +1,55 @@ -import {type JSX} from 'react' +import {createContext, type JSX, use, useMemo} from 'react' +import {type MaterialYouPalette} from '@assembless/react-native-material-you' + +import {generatePaletteFromColor, type GenerationStyle} from './material3' export function getMaterialYouColor( - _color: [ + palette: MaterialYouPalette, + color: [ 'system_accent1', 'system_accent2', 'system_accent3', 'system_neutral1', 'system_neutral2', + 'system_error', ][number], - _shade: [ - 0, - 10, - 50, - 100, - 200, - 300, - 400, - 500, - 600, - 700, - 800, - 900, - 1000, - ][number], + shade: [0, 10, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000][number], + fallback: string = '#000000', ): string { - return '#000000' + const shades = [0, 10, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000] + let shadeIndex = shades.findIndex(s => s === shade) + if (shadeIndex === -1) { + throw new Error( + `Invalid shade: ${shade}. Valid shades are: ${shades.join(', ')}`, + ) + } + + return palette[color]?.[shadeIndex] || fallback } -export function onMaterialYouPaletteChange(_callback: () => void) {} +const PaletteProvider = createContext( + generatePaletteFromColor('#000000', 'TONAL_SPOT'), +) export function MaterialYouPaletteProvider({ + accent, + style, children, -}: React.PropsWithChildren): JSX.Element { - return <>{children} +}: React.PropsWithChildren<{ + accent: string + style?: GenerationStyle +}>): JSX.Element { + const value = useMemo(() => { + return generatePaletteFromColor(accent, style) + }, [accent, style]) + + return ( + + {children} + + ) } + +export function useMaterialYouPalette() { + return use(PaletteProvider) +} \ No newline at end of file diff --git a/src/lib/ThemeContext.tsx b/src/lib/ThemeContext.tsx index 158cf0749..7aaea6e0a 100644 --- a/src/lib/ThemeContext.tsx +++ b/src/lib/ThemeContext.tsx @@ -3,8 +3,7 @@ import {createContext, useContext} from 'react' import {type TextStyle, type ViewStyle} from 'react-native' import {type ThemeName} from '@bsky.app/alf' -import {useThemePrefs} from '#/state/shell/color-mode' -import {hueShifter, type SchemeType, selectScheme} from '#/alf' +import {type SchemeType, useScheme} from '#/alf' import {themes} from '#/alf/themes' import {darkTheme, defaultTheme, dimTheme} from './themes' @@ -124,12 +123,11 @@ export const ThemeProvider: React.FC = ({ theme, children, }) => { - const {colorScheme, hue} = useThemePrefs() + const currentScheme = useScheme() const themeValue = useMemo(() => { - const currentScheme = hueShifter(selectScheme(colorScheme), hue) return getTheme(theme, currentScheme) - }, [theme, colorScheme, hue]) + }, [theme, currentScheme]) return ( {children} diff --git a/src/screens/Settings/AppearanceSettings.tsx b/src/screens/Settings/AppearanceSettings.tsx index 2fd25fca9..ea2614e64 100644 --- a/src/screens/Settings/AppearanceSettings.tsx +++ b/src/screens/Settings/AppearanceSettings.tsx @@ -1,4 +1,4 @@ -import {useCallback} from 'react' +import {useCallback, useMemo} from 'react' import {Pressable, View} from 'react-native' import Animated, { FadeInUp, @@ -14,6 +14,7 @@ import { type CommonNavigatorParams, type NativeStackScreenProps, } from '#/lib/routes/types' +import {type Schema} from '#/state/persisted' import { useEnableSquareAvatars, useSetEnableSquareAvatars, @@ -34,10 +35,11 @@ import { DEFAULT_PALETTE, EVERGARDEN_PALETTE, KITTY_PALETTE, - MATERIAL_3_PALETTE, REDDWARF_PALETTE, ZEPPELIN_PALETTE, } from '#/alf/themes' +import {getMaterial3Colors} from '#/alf/util/material3Theme' +import {useMaterialYouPalette} from '#/alf/util/materialYou' import * as SegmentedControl from '#/components/forms/SegmentedControl' import {Slider} from '#/components/forms/Slider' import * as Toggle from '#/components/forms/Toggle' @@ -83,9 +85,22 @@ export function AppearanceSettingsScreen({}: Props) { const {fonts} = useAlf() const t = useTheme() - const {colorMode, colorScheme, darkTheme, hue} = useThemePrefs() - const {setColorMode, setColorScheme, setDarkTheme, setHue} = - useSetThemePrefs() + const { + colorMode, + colorScheme, + darkTheme, + hue, + material3Accent, + material3Style, + } = useThemePrefs() + const { + setColorMode, + setColorScheme, + setDarkTheme, + setHue, + setMaterial3Accent, + setMaterial3Style, + } = useSetThemePrefs() const kawaiiMode = useKawaiiMode() const setKawaiiMode = useSetKawaiiMode() @@ -96,6 +111,12 @@ export function AppearanceSettingsScreen({}: Props) { const enableSquareButtons = useEnableSquareButtons() const setEnableSquareButtons = useSetEnableSquareButtons() + const material3Palette = useMaterialYouPalette() + const cachedScheme = useMemo( + () => getMaterial3Colors(material3Palette), + [material3Palette], + ) + const onChangeAppearance = useCallback( (value: 'light' | 'system' | 'dark') => { setColorMode(value) @@ -177,15 +198,11 @@ export function AppearanceSettingsScreen({}: Props) { label: _(msg`Evergarden`), primary: EVERGARDEN_PALETTE.primary_500, }, - ...(IS_ANDROID - ? [ - { - name: 'material3', - label: _(msg`Material You`), - primary: MATERIAL_3_PALETTE.primary_500, - } satisfies ColorSchemeOption, - ] - : []), + { + name: 'material3', + label: _(msg`Material You`), + primary: cachedScheme.regular.primary_500, + }, ] return ( @@ -260,17 +277,83 @@ export function AppearanceSettingsScreen({}: Props) { selectedScheme={colorScheme} onSchemeChange={onChangeScheme} /> - - Hue shift the colors: - - + {colorScheme === 'material3' && !IS_ANDROID && ( + <> + + Accent hue: + + { + setMaterial3Accent(hueToHex(v)) + setHue(0) + }} + minimumValue={0} + maximumValue={360} + step={1} + debounceFull={true} + /> + + + Style: + + + {MATERIAL3_STYLE_OPTIONS.map(({name, label}) => { + const isSelected = material3Style === name + return ( + setMaterial3Style(name)} + style={[ + a.flex_1, + a.rounded_sm, + a.align_center, + a.px_sm, + a.py_sm, + a.border, + {minWidth: '22%'}, + { + borderColor: isSelected + ? t.palette.primary_500 + : t.atoms.border_contrast_low.borderColor, + borderWidth: 2, + backgroundColor: isSelected + ? t.palette.primary_100 + : t.atoms.bg.backgroundColor, + }, + ]}> + + {label} + + + ) + })} + + + )} + {colorScheme !== 'material3' && ( + <> + + Hue shift the colors: + + + + )} @@ -419,7 +502,7 @@ function ColorSchemeGrid({ borderColor: isSelected ? primary : t.atoms.border_contrast_low.borderColor, - borderWidth: isSelected ? 2 : 1, + borderWidth: 2, }, ]}> ({ ) } + +const MATERIAL3_STYLE_OPTIONS: { + name: Schema['material3Style'] + label: string +}[] = [ + {name: 'TONAL_SPOT', label: 'Tonal Spot'}, + {name: 'VIBRANT', label: 'Vibrant'}, + {name: 'EXPRESSIVE', label: 'Expressive'}, + {name: 'SPRITZ', label: 'Spritz'}, + {name: 'RAINBOW', label: 'Rainbow'}, + {name: 'FRUIT_SALAD', label: 'Fruit Salad'}, + {name: 'CONTENT', label: 'Content'}, + {name: 'MONOCHROMATIC', label: 'Mono'}, +] + +function hueToHex(hue: number): string { + const h = hue / 60 + const x = 1 - Math.abs((h % 2) - 1) + let r = 0, + g = 0, + b = 0 + if (h < 1) { + r = 1 + g = x + } else if (h < 2) { + r = x + g = 1 + } else if (h < 3) { + g = 1 + b = x + } else if (h < 4) { + g = x + b = 1 + } else if (h < 5) { + r = x + b = 1 + } else { + r = 1 + b = x + } + const toHex = (v: number) => + Math.round(v * 255) + .toString(16) + .padStart(2, '0') + return `#${toHex(r)}${toHex(g)}${toHex(b)}` +} + +function hexToHue(hex: string): number { + const r = parseInt(hex.slice(1, 3), 16) / 255 + const g = parseInt(hex.slice(3, 5), 16) / 255 + const b = parseInt(hex.slice(5, 7), 16) / 255 + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + const d = max - min + if (d === 0) return 0 + let h = 0 + if (max === r) h = ((g - b) / d) % 6 + else if (max === g) h = (b - r) / d + 2 + else h = (r - g) / d + 4 + h = Math.round(h * 60) + return h < 0 ? h + 360 : h +} diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index faa32279e..3e8fb2d8b 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -64,6 +64,25 @@ const schema = z.object({ 'material3', ]), hue: z.number(), + material3Accent: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/, { + message: + 'Invalid color format. Must be a 7-character hex code (e.g., #RRGGBB).', + }) + .default('#ee6300'), + material3Style: z + .enum([ + 'SPRITZ', + 'TONAL_SPOT', + 'VIBRANT', + 'EXPRESSIVE', + 'RAINBOW', + 'FRUIT_SALAD', + 'CONTENT', + 'MONOCHROMATIC', + ]) + .default('TONAL_SPOT'), session: z.object({ accounts: z.array(accountSchema), currentAccount: currentAccountSchema.optional(), @@ -225,6 +244,7 @@ export const defaults: Schema = { darkTheme: 'dim', colorScheme: 'witchsky', hue: 0, + material3Accent: '#ee6300', session: { accounts: [], currentAccount: undefined, diff --git a/src/state/shell/color-mode.tsx b/src/state/shell/color-mode.tsx index 507033a79..9c06b1afe 100644 --- a/src/state/shell/color-mode.tsx +++ b/src/state/shell/color-mode.tsx @@ -14,12 +14,16 @@ type StateContext = { darkTheme: persisted.Schema['darkTheme'] colorScheme: persisted.Schema['colorScheme'] hue: persisted.Schema['hue'] + material3Accent: persisted.Schema['material3Accent'] + material3Style: persisted.Schema['material3Style'] } 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 + setMaterial3Accent: (v: persisted.Schema['material3Accent']) => void + setMaterial3Style: (v: persisted.Schema['material3Style']) => void } const stateContext = createContext({ @@ -27,6 +31,8 @@ const stateContext = createContext({ darkTheme: 'dark', colorScheme: 'witchsky', hue: 0, + material3Accent: '#ee6300', + material3Style: 'TONAL_SPOT', }) stateContext.displayName = 'ColorModeStateContext' const setContext = createContext({} as SetContext) @@ -35,8 +41,16 @@ setContext.displayName = 'ColorModeSetContext' export function Provider({children}: PropsWithChildren<{}>) { const [colorMode, setColorMode] = useState(() => persisted.get('colorMode')) const [darkTheme, setDarkTheme] = useState(() => persisted.get('darkTheme')) - const [colorScheme, setColorScheme] = useState(persisted.get('colorScheme')) - const [hue, setHue] = useState(persisted.get('hue')) + const [colorScheme, setColorScheme] = useState(() => + persisted.get('colorScheme'), + ) + const [hue, setHue] = useState(() => persisted.get('hue')) + const [material3Accent, setMaterial3Accent] = useState( + () => persisted.get('material3Accent') ?? '#ee6300', + ) + const [material3Style, setMaterial3Style] = useState( + () => persisted.get('material3Style') ?? 'TONAL_SPOT', + ) const stateContextValue = useMemo( () => ({ @@ -44,27 +58,39 @@ export function Provider({children}: PropsWithChildren<{}>) { darkTheme, colorScheme, hue, + material3Accent, + material3Style, }), - [colorMode, darkTheme, colorScheme, hue], + [colorMode, darkTheme, colorScheme, hue, material3Accent, material3Style], ) const setContextValue = useMemo( () => ({ setColorMode: (_colorMode: persisted.Schema['colorMode']) => { setColorMode(_colorMode) - persisted.write('colorMode', _colorMode) + void persisted.write('colorMode', _colorMode) }, setDarkTheme: (_darkTheme: persisted.Schema['darkTheme']) => { setDarkTheme(_darkTheme) - persisted.write('darkTheme', _darkTheme) + void persisted.write('darkTheme', _darkTheme) }, setColorScheme: (_colorScheme: persisted.Schema['colorScheme']) => { setColorScheme(_colorScheme) - persisted.write('colorScheme', _colorScheme) + void persisted.write('colorScheme', _colorScheme) }, setHue: (_hue: persisted.Schema['hue']) => { setHue(_hue) - persisted.write('hue', _hue) + void persisted.write('hue', _hue) + }, + setMaterial3Accent: ( + _material3Accent: persisted.Schema['material3Accent'], + ) => { + setMaterial3Accent(_material3Accent) + void persisted.write('material3Accent', _material3Accent) + }, + setMaterial3Style: (_material3Style: persisted.Schema['material3Style']) => { + setMaterial3Style(_material3Style) + void persisted.write('material3Style', _material3Style) }, }), [], @@ -83,11 +109,25 @@ export function Provider({children}: PropsWithChildren<{}>) { const unsub4 = persisted.onUpdate('hue', nextHue => { setHue(nextHue) }) + const unsub5 = persisted.onUpdate( + 'material3Accent', + nextMaterial3Accent => { + setMaterial3Accent(nextMaterial3Accent) + }, + ) + const unsub6 = persisted.onUpdate( + 'material3Style', + nextMaterial3Style => { + setMaterial3Style(nextMaterial3Style) + }, + ) return () => { unsub1() unsub2() unsub3() unsub4() + unsub5() + unsub6() } }, [])