diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/GuardedPreference.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/GuardedPreference.kt index 0bd49cc8d..bb9e76d90 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/GuardedPreference.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/GuardedPreference.kt @@ -29,7 +29,7 @@ fun GuardedPreference( modifier = Modifier .fillMaxWidth() .clip(MaterialTheme.shapes.extraSmall) - .background(MaterialTheme.colorScheme.surface) + .background(MaterialTheme.colorScheme.surfaceBright) ) { if (locked) { Banner( diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/ListPreference.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/ListPreference.kt index 03dd171b7..2639e70bf 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/ListPreference.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/ListPreference.kt @@ -23,7 +23,7 @@ fun ListPreference( items: List>, value: T, summary: String? = items.firstOrNull { value == it.value }?.label, - containerColor: Color = MaterialTheme.colorScheme.surface, + containerColor: Color = MaterialTheme.colorScheme.surfaceBright, onValueChanged: (T) -> Unit, enabled: Boolean = true, itemLabel: @Composable RowScope.(item: ListPreferenceItem) -> Unit = { diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/Preference.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/Preference.kt index 313caaed4..75328430c 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/Preference.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/Preference.kt @@ -29,7 +29,7 @@ fun Preference( onClick: () -> Unit = {}, controls: @Composable (() -> Unit)? = null, enabled: Boolean = true, - containerColor: Color = MaterialTheme.colorScheme.surface, + containerColor: Color = MaterialTheme.colorScheme.surfaceBright, ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -92,7 +92,7 @@ fun Preference( onClick: () -> Unit = {}, controls: @Composable (() -> Unit)? = null, enabled: Boolean = true, - containerColor: Color = MaterialTheme.colorScheme.surface, + containerColor: Color = MaterialTheme.colorScheme.surfaceBright, ) { Preference( title = { @@ -121,7 +121,7 @@ fun Preference( onClick: () -> Unit = {}, controls: @Composable (() -> Unit)? = null, enabled: Boolean = true, - containerColor: Color = MaterialTheme.colorScheme.surface, + containerColor: Color = MaterialTheme.colorScheme.surfaceBright, ) { Preference( title, diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/PreferenceScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/PreferenceScreen.kt index 1b4ccf49e..12593f32b 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/PreferenceScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/PreferenceScreen.kt @@ -28,13 +28,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -157,8 +153,8 @@ fun PreferenceScreen( content = content, verticalArrangement = verticalArrangement, contentPadding = PaddingValues( - top = it.calculateTopPadding(), - bottom = it.calculateBottomPadding() + 4.dp, + top = it.calculateTopPadding() + 12.dp, + bottom = it.calculateBottomPadding() + 12.dp, start = 12.dp, end = 12.dp ) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/PreferenceWithSwitch.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/PreferenceWithSwitch.kt index 40fef8f63..95a44d1da 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/PreferenceWithSwitch.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/PreferenceWithSwitch.kt @@ -30,7 +30,7 @@ fun PreferenceWithSwitch( ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.extraSmall) + modifier = Modifier.background(MaterialTheme.colorScheme.surfaceBright, MaterialTheme.shapes.extraSmall) ) { Box( modifier = Modifier.weight(1f) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/SliderPreference.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/SliderPreference.kt index 5f47f8544..de3a9b168 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/SliderPreference.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/SliderPreference.kt @@ -38,7 +38,7 @@ fun SliderPreference( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.extraSmall) + .background(MaterialTheme.colorScheme.surfaceBright, MaterialTheme.shapes.extraSmall) .padding( start = if (icon != null || iconPadding) 8.dp else 16.dp, end = 16.dp, diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/SwitchPreference.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/SwitchPreference.kt index 1313c9635..111908688 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/SwitchPreference.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/component/preferences/SwitchPreference.kt @@ -21,7 +21,7 @@ fun SwitchPreference( value: Boolean, onValueChanged: (Boolean) -> Unit, enabled: Boolean = true, - containerColor: Color = MaterialTheme.colorScheme.surface, + containerColor: Color = MaterialTheme.colorScheme.surfaceBright, ) { Preference( title = title, diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Color.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Color.kt index a43e540a5..9bf14873a 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Color.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Color.kt @@ -24,7 +24,7 @@ fun Color.Companion.hct(hue: Float, chroma: Float, tone: Float): Color { fun Color.atTone(tone: Int): Color { return Color( Hct.fromInt(this.toArgb()).apply { - this.tone = tone.toDouble() + this.setTone(tone.toDouble()) }.toInt() ) } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/ColorSchemePreferenceCategory.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/ColorSchemePreferenceCategory.kt index 5c9d87fd2..68424df21 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/ColorSchemePreferenceCategory.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/ColorSchemePreferenceCategory.kt @@ -90,7 +90,7 @@ fun ColorSchemePreferenceCategory( modifier = Modifier .fillMaxWidth() .background( - MaterialTheme.colorScheme.surfaceContainerLowest, + MaterialTheme.colorScheme.surfaceContainerLow, MaterialTheme.shapes.extraSmall ) .horizontalScroll(rememberScrollState()) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/ColorSchemeSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/ColorSchemeSettingsScreen.kt index 2cc444519..7061d585c 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/ColorSchemeSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/ColorSchemeSettingsScreen.kt @@ -65,15 +65,15 @@ import de.mm20.launcher2.ui.locals.LocalDarkTheme import de.mm20.launcher2.ui.theme.colorscheme.darkColorSchemeOf import de.mm20.launcher2.ui.theme.colorscheme.lightColorSchemeOf import de.mm20.launcher2.ui.theme.colorscheme.systemCorePalette +import hct.Hct import kotlinx.serialization.Serializable -import palettes.CorePalette +import scheme.SchemeExpressive import java.util.UUID -import kotlin.uuid.Uuid @Serializable data class ColorSchemeSettingsRoute( @Serializable(with = UUIDSerializer::class) val id: UUID -): NavKey +) : NavKey @Composable fun ColorSchemeSettingsScreen(themeId: UUID) { @@ -91,7 +91,7 @@ fun ColorSchemeSettingsScreen(themeId: UUID) { val previewColorScheme = theme?.let { if (previewDarkTheme) darkColorSchemeOf(it) else lightColorSchemeOf(it) } - val systemPalette = systemCorePalette() + val systemPalette = systemCorePalette(dark) val mergedCorePalette by remember(theme?.corePalette, systemPalette) { derivedStateOf { @@ -175,7 +175,9 @@ fun ColorSchemeSettingsScreen(themeId: UUID) { defaultValue = systemPalette.secondary, autoGenerate = { theme!!.corePalette.primary?.let { - CorePalette.of(it).a2.keyColor.toInt() + SchemeExpressive( + Hct.fromInt(it), false, 0.0, + ).secondaryPaletteKeyColor } }, ) @@ -194,7 +196,9 @@ fun ColorSchemeSettingsScreen(themeId: UUID) { defaultValue = systemPalette.tertiary, autoGenerate = { theme!!.corePalette.primary?.let { - CorePalette.of(it).a3.keyColor.toInt() + SchemeExpressive( + Hct.fromInt(it), false, 0.0, + ).tertiaryPaletteKeyColor } }, ) @@ -213,7 +217,9 @@ fun ColorSchemeSettingsScreen(themeId: UUID) { defaultValue = systemPalette.neutral, autoGenerate = { theme!!.corePalette.primary?.let { - CorePalette.of(it).n1.keyColor.toInt() + SchemeExpressive( + Hct.fromInt(it), false, 0.0, + ).neutralPaletteKeyColor } }, ) @@ -232,7 +238,9 @@ fun ColorSchemeSettingsScreen(themeId: UUID) { defaultValue = systemPalette.neutralVariant, autoGenerate = { theme!!.corePalette.primary?.let { - CorePalette.of(it).n2.keyColor.toInt() + SchemeExpressive( + Hct.fromInt(it), false, 0.0, + ).neutralVariantPaletteKeyColor } }, ) @@ -251,7 +259,9 @@ fun ColorSchemeSettingsScreen(themeId: UUID) { defaultValue = systemPalette.error, autoGenerate = { theme!!.corePalette.primary?.let { - CorePalette.of(it).error.keyColor.toInt() + SchemeExpressive( + Hct.fromInt(it), false, 0.0, + ).errorPalette.keyColor.toInt() } }, ) @@ -790,7 +800,11 @@ fun ColorSchemeSettingsScreen(themeId: UUID) { modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { - Icon(painterResource(R.drawable.search_24px), null, tint = MaterialTheme.colorScheme.onSurface) + Icon( + painterResource(R.drawable.search_24px), + null, + tint = MaterialTheme.colorScheme.onSurface + ) } } } @@ -1080,7 +1094,7 @@ fun ColorSchemeSettingsScreen(themeId: UUID) { } IconButton( - onClick = { }, + onClick = { }, modifier = Modifier .padding(end = 16.dp) .align(Alignment.CenterVertically), diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/ColorSwatch.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/ColorSwatch.kt index 9d121a311..e17ba360f 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/ColorSwatch.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/ColorSwatch.kt @@ -35,13 +35,13 @@ fun ColorSwatch( else 40f } it.apply { - this.tone = tone.toDouble() + this.setTone(tone.toDouble()) }.toInt() }) val borderColor = Color(Hct.fromInt(color.toArgb()).let { val tone = if (darkTheme) 30f else 80f it.apply { - this.tone = tone.toDouble() + this.setTone(tone.toDouble()) }.toInt() }) Box( diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/CorePaletteColorPreference.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/CorePaletteColorPreference.kt index 3e6762ef7..ea5054b7a 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/CorePaletteColorPreference.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/CorePaletteColorPreference.kt @@ -58,7 +58,7 @@ fun CorePaletteColorPreference( .clickable( onClick = { showDialog = true }, ) - .background(MaterialTheme.colorScheme.surface) + .background(MaterialTheme.colorScheme.surfaceBright) .padding(16.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/ThemeColorPreference.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/ThemeColorPreference.kt index f077be017..898ecb1d1 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/ThemeColorPreference.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/colorscheme/ThemeColorPreference.kt @@ -81,7 +81,7 @@ fun ThemeColorPreference( .clickable( onClick = { showDialog = true }, ) - .background(MaterialTheme.colorScheme.surface) + .background(MaterialTheme.colorScheme.surfaceBright) .padding(16.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/crashreporter/CrashReportScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/crashreporter/CrashReportScreen.kt index a76a1d82b..12a1fcf31 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/crashreporter/CrashReportScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/crashreporter/CrashReportScreen.kt @@ -57,7 +57,7 @@ fun CrashReportScreen(fileName: String) { modifier = Modifier .fillMaxWidth() .clip(MaterialTheme.shapes.medium) - .background(MaterialTheme.colorScheme.surface) + .background(MaterialTheme.colorScheme.surfaceBright) .horizontalScroll( rememberScrollState() ), @@ -80,7 +80,7 @@ fun CrashReportScreen(fileName: String) { Column( modifier = Modifier .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceBright, MaterialTheme.shapes.medium) .padding(12.dp), ) { Text(text = "Device Information", style = MaterialTheme.typography.titleMedium) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/icons/IconsSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/icons/IconsSettingsScreen.kt index 21b65b176..2a8939a68 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/icons/IconsSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/icons/IconsSettingsScreen.kt @@ -154,7 +154,7 @@ fun IconsSettingsScreen() { Row( modifier = Modifier .background( - MaterialTheme.colorScheme.surfaceContainerLowest, + MaterialTheme.colorScheme.surfaceContainerLow, MaterialTheme.shapes.extraSmall ) .padding(vertical = 24.dp, horizontal = 8.dp) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/shapes/ShapeSchemeSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/shapes/ShapeSchemeSettingsScreen.kt index a1279c3f4..492649217 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/shapes/ShapeSchemeSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/shapes/ShapeSchemeSettingsScreen.kt @@ -414,7 +414,7 @@ fun ShapePreference( modifier = Modifier .fillMaxWidth() .clip(MaterialTheme.shapes.extraSmall) - .background(MaterialTheme.colorScheme.surface) + .background(MaterialTheme.colorScheme.surfaceBright) .clickable( onClick = { showDialog = true }, ) @@ -733,7 +733,7 @@ private fun ShapePreview( modifier = Modifier .fillMaxWidth() .clip(MaterialTheme.shapes.extraSmall) - .background(MaterialTheme.colorScheme.surfaceContainerLowest) + .background(MaterialTheme.colorScheme.surfaceContainerLow) .horizontalScroll(rememberScrollState()) .padding(16.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/transparencies/TransparencySchemeSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/transparencies/TransparencySchemeSettingsScreen.kt index 0e9fe4385..727fdaf2d 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/transparencies/TransparencySchemeSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/transparencies/TransparencySchemeSettingsScreen.kt @@ -232,7 +232,7 @@ private fun TransparencyPreference( modifier = Modifier .fillMaxWidth() .clip(MaterialTheme.shapes.extraSmall) - .background(MaterialTheme.colorScheme.surface) + .background(MaterialTheme.colorScheme.surfaceBright) .padding(16.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/typography/TypographySettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/typography/TypographySettingsScreen.kt index ae6f00bfd..44e065ca7 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/typography/TypographySettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/typography/TypographySettingsScreen.kt @@ -777,7 +777,7 @@ private fun FontPreference( Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.background( - MaterialTheme.colorScheme.surface, + MaterialTheme.colorScheme.surfaceBright, MaterialTheme.shapes.extraSmall ) ) { @@ -1309,7 +1309,7 @@ private fun TypographyPreview( modifier = Modifier .fillMaxWidth() .clip(MaterialTheme.shapes.extraSmall) - .background(MaterialTheme.colorScheme.surfaceContainerLowest) + .background(MaterialTheme.colorScheme.surfaceContainerLow) .horizontalScroll(rememberScrollState()) .padding(16.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/theme/colorscheme/ColorScheme.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/theme/colorscheme/ColorScheme.kt index b8df9dfdd..7b3173790 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/theme/colorscheme/ColorScheme.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/theme/colorscheme/ColorScheme.kt @@ -20,21 +20,27 @@ import de.mm20.launcher2.themes.colors.Colors as ThemeColors import de.mm20.launcher2.themes.colors.get import de.mm20.launcher2.themes.colors.merge import de.mm20.launcher2.ui.locals.LocalWallpaperColors +import dynamiccolor.ColorSpec +import hct.Hct import org.koin.compose.koinInject +import scheme.SchemeContent +import scheme.SchemeExpressive +import scheme.SchemeNeutral +import scheme.SchemeTonalSpot @Composable fun lightColorSchemeOf(colors: ThemeColors): ColorScheme { - return colorSchemeOf(colors.lightColorScheme.merge(DefaultLightColorScheme), colors.corePalette) + return colorSchemeOf(false, colors.lightColorScheme.merge(DefaultLightColorScheme), colors.corePalette) } @Composable fun darkColorSchemeOf(colors: ThemeColors): ColorScheme { - return colorSchemeOf(colors.darkColorScheme.merge(DefaultDarkColorScheme), colors.corePalette) + return colorSchemeOf(true, colors.darkColorScheme.merge(DefaultDarkColorScheme), colors.corePalette) } @Composable -fun colorSchemeOf(colorScheme: FullColorScheme, corePalette: PartialCorePalette): ColorScheme { - val defaultPalette = systemCorePalette() +fun colorSchemeOf(dark: Boolean, colorScheme: FullColorScheme, corePalette: PartialCorePalette): ColorScheme { + val defaultPalette = systemCorePalette(dark) return remember(colorScheme, corePalette, defaultPalette) { val mergedCorePalette = corePalette.merge(defaultPalette) ColorScheme( @@ -79,7 +85,7 @@ fun colorSchemeOf(colorScheme: FullColorScheme, corePalette: PartialCorePalette) } @Composable -fun systemCorePalette(): CorePalette { +fun systemCorePalette(dark: Boolean): CorePalette { val uiSettings: UiSettings = koinInject() val compatModeColors by remember { uiSettings.compatModeColors @@ -98,14 +104,20 @@ fun systemCorePalette(): CorePalette { } val wallpaperColors = LocalWallpaperColors.current return remember(wallpaperColors) { - val corePalette = palettes.CorePalette.of(wallpaperColors.primary.toArgb()) + val corePalette = SchemeTonalSpot( + Hct.fromInt( + wallpaperColors.primary.toArgb()), + dark, + 0.0, + ColorSpec.SpecVersion.SPEC_2025, + ) CorePalette( - primary = corePalette.a1.tone(40), - secondary = corePalette.a2.tone(40), - tertiary = corePalette.a3.tone(40), - neutral = corePalette.n1.tone(40), - neutralVariant = corePalette.n2.tone(40), - error = corePalette.error.tone(40), + primary = corePalette.primaryPaletteKeyColor, + secondary = corePalette.secondaryPaletteKeyColor, + tertiary = corePalette.tertiaryPaletteKeyColor, + neutral = corePalette.neutralPaletteKeyColor, + neutralVariant = corePalette.neutralVariantPaletteKeyColor, + error = corePalette.errorPalette.keyColor.toInt(), ) } } diff --git a/data/themes/src/main/java/de/mm20/launcher2/themes/colors/Defaults.kt b/data/themes/src/main/java/de/mm20/launcher2/themes/colors/Defaults.kt index fb7f62c7f..e090ee1de 100644 --- a/data/themes/src/main/java/de/mm20/launcher2/themes/colors/Defaults.kt +++ b/data/themes/src/main/java/de/mm20/launcher2/themes/colors/Defaults.kt @@ -58,7 +58,7 @@ val DefaultDarkColorScheme = ColorScheme( onErrorContainer = ColorRef(CorePaletteColor.Error, 90), surfaceDim = ColorRef(CorePaletteColor.Neutral, 6), surface = ColorRef(CorePaletteColor.Neutral, 6), - surfaceBright = ColorRef(CorePaletteColor.Neutral, 24), + surfaceBright = ColorRef(CorePaletteColor.Neutral, 18), surfaceContainerLowest = ColorRef(CorePaletteColor.Neutral, 4), surfaceContainerLow = ColorRef(CorePaletteColor.Neutral, 10), surfaceContainer = ColorRef(CorePaletteColor.Neutral, 12), @@ -136,7 +136,7 @@ val HighContrastDarkColorScheme = ColorScheme( onErrorContainer = ColorRef(CorePaletteColor.Error, 0), surfaceDim = ColorRef(CorePaletteColor.Neutral, 6), surface = ColorRef(CorePaletteColor.Neutral, 6), - surfaceBright = ColorRef(CorePaletteColor.Neutral, 24), + surfaceBright = ColorRef(CorePaletteColor.Neutral, 18), surfaceContainerLowest = ColorRef(CorePaletteColor.Neutral, 4), surfaceContainerLow = ColorRef(CorePaletteColor.Neutral, 10), surfaceContainer = ColorRef(CorePaletteColor.Neutral, 12), diff --git a/libs/material-color-utilities/src/main/java/LICENSE b/libs/material-color-utilities/src/main/java/LICENSE new file mode 100644 index 000000000..2ea2d16f8 --- /dev/null +++ b/libs/material-color-utilities/src/main/java/LICENSE @@ -0,0 +1,191 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025 Google LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/libs/material-color-utilities/src/main/java/blend/Blend.java b/libs/material-color-utilities/src/main/java/blend/Blend.java deleted file mode 100644 index 545e2ff1b..000000000 --- a/libs/material-color-utilities/src/main/java/blend/Blend.java +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// This file is automatically generated. Do not modify it. - -package blend; - -import hct.Cam16; -import hct.Hct; -import utils.ColorUtils; -import utils.MathUtils; - -/** Functions for blending in HCT and CAM16. */ -public class Blend { - private Blend() {} - - /** - * Blend the design color's HCT hue towards the key color's HCT hue, in a way that leaves the - * original color recognizable and recognizably shifted towards the key color. - * - * @param designColor ARGB representation of an arbitrary color. - * @param sourceColor ARGB representation of the main theme color. - * @return The design color with a hue shifted towards the system's color, a slightly - * warmer/cooler variant of the design color's hue. - */ - public static int harmonize(int designColor, int sourceColor) { - Hct fromHct = Hct.fromInt(designColor); - Hct toHct = Hct.fromInt(sourceColor); - double differenceDegrees = MathUtils.differenceDegrees(fromHct.getHue(), toHct.getHue()); - double rotationDegrees = Math.min(differenceDegrees * 0.5, 15.0); - double outputHue = - MathUtils.sanitizeDegreesDouble( - fromHct.getHue() - + rotationDegrees * MathUtils.rotationDirection(fromHct.getHue(), toHct.getHue())); - return Hct.from(outputHue, fromHct.getChroma(), fromHct.getTone()).toInt(); - } - - /** - * Blends hue from one color into another. The chroma and tone of the original color are - * maintained. - * - * @param from ARGB representation of color - * @param to ARGB representation of color - * @param amount how much blending to perform; 0.0 >= and <= 1.0 - * @return from, with a hue blended towards to. Chroma and tone are constant. - */ - public static int hctHue(int from, int to, double amount) { - int ucs = cam16Ucs(from, to, amount); - Cam16 ucsCam = Cam16.fromInt(ucs); - Cam16 fromCam = Cam16.fromInt(from); - Hct blended = Hct.from(ucsCam.getHue(), fromCam.getChroma(), ColorUtils.lstarFromArgb(from)); - return blended.toInt(); - } - - /** - * Blend in CAM16-UCS space. - * - * @param from ARGB representation of color - * @param to ARGB representation of color - * @param amount how much blending to perform; 0.0 >= and <= 1.0 - * @return from, blended towards to. Hue, chroma, and tone will change. - */ - public static int cam16Ucs(int from, int to, double amount) { - Cam16 fromCam = Cam16.fromInt(from); - Cam16 toCam = Cam16.fromInt(to); - double fromJ = fromCam.getJstar(); - double fromA = fromCam.getAstar(); - double fromB = fromCam.getBstar(); - double toJ = toCam.getJstar(); - double toA = toCam.getAstar(); - double toB = toCam.getBstar(); - double jstar = fromJ + (toJ - fromJ) * amount; - double astar = fromA + (toA - fromA) * amount; - double bstar = fromB + (toB - fromB) * amount; - return Cam16.fromUcs(jstar, astar, bstar).toInt(); - } -} - diff --git a/libs/material-color-utilities/src/main/java/blend/Blend.kt b/libs/material-color-utilities/src/main/java/blend/Blend.kt new file mode 100644 index 000000000..8c190f8af --- /dev/null +++ b/libs/material-color-utilities/src/main/java/blend/Blend.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package blend + +import hct.Cam16 +import hct.Hct +import utils.ColorUtils +import utils.MathUtils +import kotlin.math.min + +/** Functions for blending in HCT and CAM16. */ +object Blend { + /** + * Blend the design color's HCT hue towards the key color's HCT hue, in a way that leaves the + * original color recognizable and recognizably shifted towards the key color. + * + * @param designColor ARGB representation of an arbitrary color. + * @param sourceColor ARGB representation of the main theme color. + * @return The design color with a hue shifted towards the system's color, a slightly + * warmer/cooler variant of the design color's hue. + */ + @JvmStatic + fun harmonize(designColor: Int, sourceColor: Int): Int { + val fromHct = Hct.fromInt(designColor) + val toHct = Hct.fromInt(sourceColor) + val differenceDegrees = MathUtils.differenceDegrees(fromHct.hue, toHct.hue) + val rotationDegrees = min(differenceDegrees * 0.5, 15.0) + val outputHue = + MathUtils.sanitizeDegreesDouble( + fromHct.hue + rotationDegrees * MathUtils.rotationDirection(fromHct.hue, toHct.hue) + ) + return Hct.from(outputHue, fromHct.chroma, fromHct.tone).toInt() + } + + /** + * Blends hue from one color into another. The chroma and tone of the original color are + * maintained. + * + * @param from ARGB representation of color + * @param to ARGB representation of color + * @param amount how much blending to perform; 0.0 >= and <= 1.0 + * @return from, with a hue blended towards to. Chroma and tone are constant. + */ + @JvmStatic + fun hctHue(from: Int, to: Int, amount: Double): Int { + val ucs = cam16Ucs(from, to, amount) + val ucsCam = Cam16.fromInt(ucs) + val fromCam = Cam16.fromInt(from) + val blended = Hct.from(ucsCam.hue, fromCam.chroma, ColorUtils.lstarFromArgb(from)) + return blended.toInt() + } + + /** + * Blend in CAM16-UCS space. + * + * @param from ARGB representation of color + * @param to ARGB representation of color + * @param amount how much blending to perform; 0.0 >= and <= 1.0 + * @return from, blended towards to. Hue, chroma, and tone will change. + */ + @JvmStatic + fun cam16Ucs(from: Int, to: Int, amount: Double): Int { + val fromCam = Cam16.fromInt(from) + val toCam = Cam16.fromInt(to) + val jstar = MathUtils.lerp(fromCam.jstar, toCam.jstar, amount) + val astar = MathUtils.lerp(fromCam.astar, toCam.astar, amount) + val bstar = MathUtils.lerp(fromCam.bstar, toCam.bstar, amount) + return Cam16.fromUcs(jstar, astar, bstar).toInt() + } +} diff --git a/libs/material-color-utilities/src/main/java/contrast/Contrast.kt b/libs/material-color-utilities/src/main/java/contrast/Contrast.kt new file mode 100644 index 000000000..993093820 --- /dev/null +++ b/libs/material-color-utilities/src/main/java/contrast/Contrast.kt @@ -0,0 +1,214 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package contrast + +import utils.ColorUtils +import kotlin.math.abs +import kotlin.math.max + +/** + * Color science for contrast utilities. + * + * Utility methods for calculating contrast given two colors, or calculating a color given one color + * and a contrast ratio. + * + * Contrast ratio is calculated using XYZ's Y. When linearized to match human perception, Y becomes + * HCT's tone and L*a*b*'s' L*. + */ +object Contrast { + /** + * The minimum contrast ratio of two colors. Contrast ratio equation = (lighter + 5) / (darker + + * 5). If lighter == darker, ratio == 1. + */ + const val RATIO_MIN = 1.0 + + /** + * The maximum contrast ratio of two colors. Contrast ratio equation = (lighter + 5) / (darker + + * 5). If lighter == 100 and darker = 0, ratio == 21. + */ + const val RATIO_MAX = 21.0 + const val RATIO_30 = 3.0 + const val RATIO_45 = 4.5 + const val RATIO_70 = 7.0 + + // Given a color and a contrast ratio to reach, the luminance of a color that reaches that ratio + // with the color can be calculated. However, that luminance may not contrast as desired, i.e. the + // contrast ratio of the input color and the returned luminance may not reach the contrast ratio + // asked for. + // + // When the desired contrast ratio and the result contrast ratio differ by more than this amount, + // an error value should be returned, or the method should be documented as 'unsafe', meaning, + // it will return a valid luminance but that luminance may not meet the requested contrast ratio. + // + // 0.04 selected because it ensures the resulting ratio rounds to the same tenth. + private const val CONTRAST_RATIO_EPSILON = 0.04 + + // Color spaces that measure luminance, such as Y in XYZ, L* in L*a*b*, or T in HCT, are known as + // perceptually accurate color spaces. + // + // To be displayed, they must gamut map to a "display space", one that has a defined limit on the + // number of colors. Display spaces include sRGB, more commonly understood as RGB/HSL/HSV/HSB. + // Gamut mapping is undefined and not defined by the color space. Any gamut mapping algorithm must + // choose how to sacrifice accuracy in hue, saturation, and/or lightness. + // + // A principled solution is to maintain lightness, thus maintaining contrast/a11y, maintain hue, + // thus maintaining aesthetic intent, and reduce chroma until the color is in gamut. + // + // HCT chooses this solution, but, that doesn't mean it will _exactly_ matched desired lightness, + // if only because RGB is quantized: RGB is expressed as a set of integers: there may be an RGB + // color with, for example, 47.892 lightness, but not 47.891. + // + // To allow for this inherent incompatibility between perceptually accurate color spaces and + // display color spaces, methods that take a contrast ratio and luminance, and return a luminance + // that reaches that contrast ratio for the input luminance, purposefully darken/lighten their + // result such that the desired contrast ratio will be reached even if inaccuracy is introduced. + // + // 0.4 is generous, ex. HCT requires much less delta. It was chosen because it provides a rough + // guarantee that as long as a perceptual color space gamut maps lightness such that the resulting + // lightness rounds to the same as the requested, the desired contrast ratio will be reached. + private const val LUMINANCE_GAMUT_MAP_TOLERANCE = 0.4 + + /** + * Contrast ratio is a measure of legibility, its used to compare the lightness of two colors. + * This method is used commonly in industry due to its use by WCAG. + * + * To compare lightness, the colors are expressed in the XYZ color space, where Y is lightness, + * also known as relative luminance. + * + * The equation is ratio = lighter Y + 5 / darker Y + 5. + */ + @JvmStatic + fun ratioOfYs(y1: Double, y2: Double): Double { + val lighter = max(y1, y2) + val darker = if (lighter == y2) y1 else y2 + return (lighter + 5.0) / (darker + 5.0) + } + + /** + * Contrast ratio of two tones. T in HCT, L* in L*a*b*. Also known as luminance or perpectual + * luminance. + * + * Contrast ratio is defined using Y in XYZ, relative luminance. However, relative luminance is + * linear to number of photons, not to perception of lightness. Perceptual luminance, L* in + * L*a*b*, T in HCT, is. Designers prefer color spaces with perceptual luminance since they're + * accurate to the eye. + * + * Y and L* are pure functions of each other, so it possible to use perceptually accurate color + * spaces, and measure contrast, and measure contrast in a much more understandable way: instead + * of a ratio, a linear difference. This allows a designer to determine what they need to adjust a + * color's lightness to in order to reach their desired contrast, instead of guessing & checking + * with hex codes. + */ + @JvmStatic + fun ratioOfTones(t1: Double, t2: Double): Double { + return ratioOfYs(ColorUtils.yFromLstar(t1), ColorUtils.yFromLstar(t2)) + } + + /** + * Returns T in HCT, L* in L*a*b* >= tone parameter that ensures ratio with input T/L*. Returns + * null if ratio cannot be achieved. + * + * @param tone Tone return value must contrast with. + * @param ratio Desired contrast ratio of return value and tone parameter. + */ + @JvmStatic + fun lighter(tone: Double, ratio: Double): Double? { + if (tone < 0.0 || tone > 100.0) { + return null + } + // Invert the contrast ratio equation to determine lighter Y given a ratio and darker Y. + val darkY = ColorUtils.yFromLstar(tone) + val lightY = ratio * (darkY + 5.0) - 5.0 + if (lightY < 0.0 || lightY > 100.0) { + return null + } + val realContrast = ratioOfYs(lightY, darkY) + val delta = abs(realContrast - ratio) + if (realContrast < ratio && delta > CONTRAST_RATIO_EPSILON) { + return null + } + + val returnValue = ColorUtils.lstarFromY(lightY) + LUMINANCE_GAMUT_MAP_TOLERANCE + // NOMUTANTS--important validation step; functions it is calling may change implementation. + return if (returnValue < 0.0 || returnValue > 100.0) { + null + } else { + returnValue + } + } + + /** + * Tone >= tone parameter that ensures ratio. 100 if ratio cannot be achieved. + * + * This method is unsafe because the returned value is guaranteed to be in bounds, but, the in + * bounds return value may not reach the desired ratio. + * + * @param tone Tone return value must contrast with. + * @param ratio Desired contrast ratio of return value and tone parameter. + */ + @JvmStatic + fun lighterUnsafe(tone: Double, ratio: Double): Double { + return lighter(tone, ratio) ?: 100.0 + } + + /** + * Returns T in HCT, L* in L*a*b* <= tone parameter that ensures ratio with input T/L*. Returns + * null if ratio cannot be achieved. + * + * @param tone Tone return value must contrast with. + * @param ratio Desired contrast ratio of return value and tone parameter. + */ + @JvmStatic + fun darker(tone: Double, ratio: Double): Double? { + if (tone < 0.0 || tone > 100.0) { + return null + } + // Invert the contrast ratio equation to determine darker Y given a ratio and lighter Y. + val lightY = ColorUtils.yFromLstar(tone) + val darkY = (lightY + 5.0) / ratio - 5.0 + if (darkY < 0.0 || darkY > 100.0) { + return null + } + val realContrast = ratioOfYs(lightY, darkY) + val delta = abs(realContrast - ratio) + if (realContrast < ratio && delta > CONTRAST_RATIO_EPSILON) { + return null + } + + // For information on 0.4 constant, see comment in lighter(tone, ratio). + val returnValue = ColorUtils.lstarFromY(darkY) - LUMINANCE_GAMUT_MAP_TOLERANCE + // NOMUTANTS--important validation step; functions it is calling may change implementation. + return if (returnValue < 0.0 || returnValue > 100.0) { + null + } else { + returnValue + } + } + + /** + * Tone <= tone parameter that ensures ratio. 0 if ratio cannot be achieved. + * + * This method is unsafe because the returned value is guaranteed to be in bounds, but, the in + * bounds return value may not reach the desired ratio. + * + * @param tone Tone return value must contrast with. + * @param ratio Desired contrast ratio of return value and tone parameter. + */ + @JvmStatic + fun darkerUnsafe(tone: Double, ratio: Double): Double { + return darker(tone, ratio) ?: 0.0 + } +} diff --git a/libs/material-color-utilities/src/main/java/dislike/DislikeAnalyzer.kt b/libs/material-color-utilities/src/main/java/dislike/DislikeAnalyzer.kt new file mode 100644 index 000000000..5c7ad579b --- /dev/null +++ b/libs/material-color-utilities/src/main/java/dislike/DislikeAnalyzer.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dislike + +import hct.Hct +import kotlin.math.round + +/** + * Check and/or fix universally disliked colors. + * + * Color science studies of color preference indicate universal distaste for dark yellow-greens, and + * also show this is correlated to distate for biological waste and rotting food. + * + * See Palmer and Schloss, 2010 or Schloss and Palmer's Chapter 21 in Handbook of Color Psychology + * (2015). + */ +object DislikeAnalyzer { + /** + * Returns true if color is disliked. + * + * Disliked is defined as a dark yellow-green that is not neutral. + */ + @JvmStatic + fun isDisliked(hct: Hct): Boolean { + val huePasses = round(hct.hue) >= 90.0 && round(hct.hue) <= 111.0 + val chromaPasses = round(hct.chroma) > 16.0 + val tonePasses = round(hct.tone) < 65.0 + return huePasses && chromaPasses && tonePasses + } + + /** If color is disliked, lighten it to make it likable. */ + @JvmStatic + fun fixIfDisliked(hct: Hct): Hct = + if (isDisliked(hct)) { + Hct.from(hct.hue, hct.chroma, 70.0) + } else { + hct + } +} diff --git a/libs/material-color-utilities/src/main/java/dynamiccolor/ColorSpec.kt b/libs/material-color-utilities/src/main/java/dynamiccolor/ColorSpec.kt new file mode 100644 index 000000000..5004920ab --- /dev/null +++ b/libs/material-color-utilities/src/main/java/dynamiccolor/ColorSpec.kt @@ -0,0 +1,187 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dynamiccolor + +import dynamiccolor.DynamicScheme.Platform +import hct.Hct +import palettes.TonalPalette + +/** An interface defining all the necessary methods that could be different between specs. */ +interface ColorSpec { + /** All available spec versions. */ + enum class SpecVersion { + SPEC_2021, + SPEC_2025, + } + + // //////////////////////////////////////////////////////////////// + // Main Palettes // + // //////////////////////////////////////////////////////////////// + val primaryPaletteKeyColor: DynamicColor + val secondaryPaletteKeyColor: DynamicColor + val tertiaryPaletteKeyColor: DynamicColor + val neutralPaletteKeyColor: DynamicColor + val neutralVariantPaletteKeyColor: DynamicColor + val errorPaletteKeyColor: DynamicColor + + // //////////////////////////////////////////////////////////////// + // Surfaces [S] // + // //////////////////////////////////////////////////////////////// + val background: DynamicColor + val onBackground: DynamicColor + val surface: DynamicColor + val surfaceDim: DynamicColor + val surfaceBright: DynamicColor + val surfaceContainerLowest: DynamicColor + val surfaceContainerLow: DynamicColor + val surfaceContainer: DynamicColor + val surfaceContainerHigh: DynamicColor + val surfaceContainerHighest: DynamicColor + val onSurface: DynamicColor + val surfaceVariant: DynamicColor + val onSurfaceVariant: DynamicColor + val inverseSurface: DynamicColor + val inverseOnSurface: DynamicColor + val outline: DynamicColor + val outlineVariant: DynamicColor + val shadow: DynamicColor + val scrim: DynamicColor + val surfaceTint: DynamicColor + + // //////////////////////////////////////////////////////////////// + // Primaries [P] // + // //////////////////////////////////////////////////////////////// + val primary: DynamicColor + val primaryDim: DynamicColor? + val onPrimary: DynamicColor + val primaryContainer: DynamicColor + val onPrimaryContainer: DynamicColor + val inversePrimary: DynamicColor + + // //////////////////////////////////////////////////////////////// + // Secondaries [Q] // + // //////////////////////////////////////////////////////////////// + val secondary: DynamicColor + val secondaryDim: DynamicColor? + val onSecondary: DynamicColor + val secondaryContainer: DynamicColor + val onSecondaryContainer: DynamicColor + + // //////////////////////////////////////////////////////////////// + // Tertiaries [T] // + // //////////////////////////////////////////////////////////////// + val tertiary: DynamicColor + val tertiaryDim: DynamicColor? + val onTertiary: DynamicColor + val tertiaryContainer: DynamicColor + val onTertiaryContainer: DynamicColor + + // //////////////////////////////////////////////////////////////// + // Errors [E] // + // //////////////////////////////////////////////////////////////// + val error: DynamicColor + val errorDim: DynamicColor? + val onError: DynamicColor + val errorContainer: DynamicColor + val onErrorContainer: DynamicColor + + // //////////////////////////////////////////////////////////////// + // Primary Fixed Colors [PF] // + // //////////////////////////////////////////////////////////////// + val primaryFixed: DynamicColor + val primaryFixedDim: DynamicColor + val onPrimaryFixed: DynamicColor + val onPrimaryFixedVariant: DynamicColor + + // //////////////////////////////////////////////////////////////// + // Secondary Fixed Colors [QF] // + // //////////////////////////////////////////////////////////////// + val secondaryFixed: DynamicColor + val secondaryFixedDim: DynamicColor + val onSecondaryFixed: DynamicColor + val onSecondaryFixedVariant: DynamicColor + + // //////////////////////////////////////////////////////////////// + // Tertiary Fixed Colors [TF] // + // //////////////////////////////////////////////////////////////// + val tertiaryFixed: DynamicColor + val tertiaryFixedDim: DynamicColor + val onTertiaryFixed: DynamicColor + val onTertiaryFixedVariant: DynamicColor + + // //////////////////////////////////////////////////////////////// + // Other // + // //////////////////////////////////////////////////////////////// + fun highestSurface(scheme: DynamicScheme): DynamicColor + + // //////////////////////////////////////////////////////////////// + // Color value calculations // + // //////////////////////////////////////////////////////////////// + fun getHct(scheme: DynamicScheme, color: DynamicColor): Hct + + fun getTone(scheme: DynamicScheme, color: DynamicColor): Double + + // //////////////////////////////////////////////////////////////// + // Scheme Palettes // + // //////////////////////////////////////////////////////////////// + fun getPrimaryPalette( + variant: Variant, + sourceColorHct: Hct, + isDark: Boolean, + platform: Platform, + contrastLevel: Double, + ): TonalPalette + + fun getSecondaryPalette( + variant: Variant, + sourceColorHct: Hct, + isDark: Boolean, + platform: Platform, + contrastLevel: Double, + ): TonalPalette + + fun getTertiaryPalette( + variant: Variant, + sourceColorHct: Hct, + isDark: Boolean, + platform: Platform, + contrastLevel: Double, + ): TonalPalette + + fun getNeutralPalette( + variant: Variant, + sourceColorHct: Hct, + isDark: Boolean, + platform: Platform, + contrastLevel: Double, + ): TonalPalette + + fun getNeutralVariantPalette( + variant: Variant, + sourceColorHct: Hct, + isDark: Boolean, + platform: Platform, + contrastLevel: Double, + ): TonalPalette + + fun getErrorPalette( + variant: Variant, + sourceColorHct: Hct, + isDark: Boolean, + platform: Platform, + contrastLevel: Double, + ): TonalPalette +} diff --git a/libs/material-color-utilities/src/main/java/dynamiccolor/ColorSpec2021.kt b/libs/material-color-utilities/src/main/java/dynamiccolor/ColorSpec2021.kt new file mode 100644 index 000000000..69f6f4483 --- /dev/null +++ b/libs/material-color-utilities/src/main/java/dynamiccolor/ColorSpec2021.kt @@ -0,0 +1,1286 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dynamiccolor + +import contrast.Contrast +import dislike.DislikeAnalyzer +import dynamiccolor.DynamicScheme.Platform +import dynamiccolor.ToneDeltaPair.DeltaConstraint +import dynamiccolor.ToneDeltaPair.TonePolarity +import hct.Hct +import palettes.TonalPalette +import temperature.TemperatureCache +import utils.MathUtils +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +/** [ColorSpec] implementation for the 2021 spec. */ +class ColorSpec2021 : ColorSpec { + // //////////////////////////////////////////////////////////////// + // Main Palettes // + // //////////////////////////////////////////////////////////////// + override val primaryPaletteKeyColor: DynamicColor + get() = + DynamicColor( + name = "primary_palette_key_color", + palette = { scheme -> scheme.primaryPalette }, + tone = { scheme -> scheme.primaryPalette.keyColor.tone }, + ) + + override val secondaryPaletteKeyColor: DynamicColor + get() = + DynamicColor( + name = "secondary_palette_key_color", + palette = { scheme -> scheme.secondaryPalette }, + tone = { scheme -> scheme.secondaryPalette.keyColor.tone }, + ) + + override val tertiaryPaletteKeyColor: DynamicColor + get() = + DynamicColor( + name = "tertiary_palette_key_color", + palette = { scheme -> scheme.tertiaryPalette }, + tone = { scheme -> scheme.tertiaryPalette.keyColor.tone }, + ) + + override val neutralPaletteKeyColor: DynamicColor + get() = + DynamicColor( + name = "neutral_palette_key_color", + palette = { scheme -> scheme.neutralPalette }, + tone = { scheme -> scheme.neutralPalette.keyColor.tone }, + ) + + override val neutralVariantPaletteKeyColor: DynamicColor + get() = + DynamicColor( + name = "neutral_variant_palette_key_color", + palette = { scheme -> scheme.neutralVariantPalette }, + tone = { scheme -> scheme.neutralVariantPalette.keyColor.tone }, + ) + + override val errorPaletteKeyColor: DynamicColor + get() = + DynamicColor( + name = "error_palette_key_color", + palette = { scheme -> scheme.errorPalette }, + tone = { scheme -> scheme.errorPalette.keyColor.tone }, + ) + + // //////////////////////////////////////////////////////////////// + // Surfaces [S] // + // //////////////////////////////////////////////////////////////// + override val background: DynamicColor + get() = + DynamicColor( + name = "background", + palette = { scheme -> scheme.neutralPalette }, + tone = { scheme -> if (scheme.isDark) 6.0 else 98.0 }, + isBackground = true, + ) + + override val onBackground: DynamicColor + get() = + DynamicColor( + name = "on_background", + palette = { scheme -> scheme.neutralPalette }, + tone = { scheme -> if (scheme.isDark) 90.0 else 10.0 }, + background = { background }, + contrastCurve = { ContrastCurve(3.0, 3.0, 4.5, 7.0) }, + ) + + override val surface: DynamicColor + get() = + DynamicColor( + name = "surface", + palette = { scheme -> scheme.neutralPalette }, + tone = { scheme -> if (scheme.isDark) 6.0 else 98.0 }, + isBackground = true, + ) + + override val surfaceDim: DynamicColor + get() = + DynamicColor( + name = "surface_dim", + palette = { scheme -> scheme.neutralPalette }, + tone = { scheme -> + if (scheme.isDark) { + 6.0 + } else { + ContrastCurve(87.0, 87.0, 80.0, 75.0).get(scheme.contrastLevel) + } + }, + isBackground = true, + ) + + override val surfaceBright: DynamicColor + get() = + DynamicColor( + name = "surface_bright", + palette = { scheme -> scheme.neutralPalette }, + tone = { scheme -> + if (scheme.isDark) { + ContrastCurve(24.0, 24.0, 29.0, 34.0).get(scheme.contrastLevel) + } else { + 98.0 + } + }, + isBackground = true, + ) + + override val surfaceContainerLowest: DynamicColor + get() = + DynamicColor( + name = "surface_container_lowest", + palette = { scheme -> scheme.neutralPalette }, + tone = { scheme -> + if (scheme.isDark) ContrastCurve(4.0, 4.0, 2.0, 0.0).get(scheme.contrastLevel) else 100.0 + }, + isBackground = true, + ) + + override val surfaceContainerLow: DynamicColor + get() = + DynamicColor( + name = "surface_container_low", + palette = { scheme -> scheme.neutralPalette }, + tone = { scheme -> + if (scheme.isDark) { + ContrastCurve(10.0, 10.0, 11.0, 12.0).get(scheme.contrastLevel) + } else { + ContrastCurve(96.0, 96.0, 96.0, 95.0).get(scheme.contrastLevel) + } + }, + isBackground = true, + ) + + override val surfaceContainer: DynamicColor + get() = + DynamicColor( + name = "surface_container", + palette = { scheme -> scheme.neutralPalette }, + tone = { scheme -> + if (scheme.isDark) { + ContrastCurve(12.0, 12.0, 16.0, 20.0).get(scheme.contrastLevel) + } else { + ContrastCurve(94.0, 94.0, 92.0, 90.0).get(scheme.contrastLevel) + } + }, + isBackground = true, + ) + + override val surfaceContainerHigh: DynamicColor + get() = + DynamicColor( + name = "surface_container_high", + palette = { scheme -> scheme.neutralPalette }, + tone = { scheme -> + if (scheme.isDark) { + ContrastCurve(17.0, 17.0, 21.0, 25.0).get(scheme.contrastLevel) + } else { + ContrastCurve(92.0, 92.0, 88.0, 85.0).get(scheme.contrastLevel) + } + }, + isBackground = true, + ) + + override val surfaceContainerHighest: DynamicColor + get() = + DynamicColor( + name = "surface_container_highest", + palette = { scheme -> scheme.neutralPalette }, + tone = { scheme -> + if (scheme.isDark) { + ContrastCurve(22.0, 22.0, 26.0, 30.0).get(scheme.contrastLevel) + } else { + ContrastCurve(90.0, 90.0, 84.0, 80.0).get(scheme.contrastLevel) + } + }, + isBackground = true, + ) + + override val onSurface: DynamicColor + get() = + DynamicColor( + name = "on_surface", + palette = { scheme -> scheme.neutralPalette }, + tone = { scheme -> if (scheme.isDark) 90.0 else 10.0 }, + background = this::highestSurface, + contrastCurve = { ContrastCurve(4.5, 7.0, 11.0, 21.0) }, + ) + + override val surfaceVariant: DynamicColor + get() = + DynamicColor( + name = "surface_variant", + palette = { scheme -> scheme.neutralVariantPalette }, + tone = { scheme -> if (scheme.isDark) 30.0 else 90.0 }, + isBackground = true, + ) + + override val onSurfaceVariant: DynamicColor + get() = + DynamicColor( + name = "on_surface_variant", + palette = { scheme -> scheme.neutralVariantPalette }, + tone = { scheme -> if (scheme.isDark) 80.0 else 30.0 }, + background = this::highestSurface, + contrastCurve = { ContrastCurve(3.0, 4.5, 7.0, 11.0) }, + ) + + override val inverseSurface: DynamicColor + get() = + DynamicColor( + name = "inverse_surface", + palette = { scheme -> scheme.neutralPalette }, + tone = { scheme -> if (scheme.isDark) 90.0 else 20.0 }, + isBackground = true, + ) + + override val inverseOnSurface: DynamicColor + get() = + DynamicColor( + name = "inverse_on_surface", + palette = { scheme -> scheme.neutralPalette }, + tone = { scheme -> if (scheme.isDark) 20.0 else 95.0 }, + background = { inverseSurface }, + contrastCurve = { ContrastCurve(4.5, 7.0, 11.0, 21.0) }, + ) + + override val outline: DynamicColor + get() = + DynamicColor( + name = "outline", + palette = { scheme -> scheme.neutralVariantPalette }, + tone = { scheme -> if (scheme.isDark) 60.0 else 50.0 }, + background = this::highestSurface, + contrastCurve = { ContrastCurve(1.5, 3.0, 4.5, 7.0) }, + ) + + override val outlineVariant: DynamicColor + get() = + DynamicColor( + name = "outline_variant", + palette = { scheme -> scheme.neutralVariantPalette }, + tone = { scheme -> if (scheme.isDark) 30.0 else 80.0 }, + background = this::highestSurface, + contrastCurve = { ContrastCurve(1.0, 1.0, 3.0, 4.5) }, + ) + + override val shadow: DynamicColor + get() = + DynamicColor(name = "shadow", palette = { scheme -> scheme.neutralPalette }, tone = { 0.0 }) + + override val scrim: DynamicColor + get() = + DynamicColor(name = "scrim", palette = { scheme -> scheme.neutralPalette }, tone = { 0.0 }) + + override val surfaceTint: DynamicColor + get() = + DynamicColor( + name = "surface_tint", + palette = { scheme -> scheme.primaryPalette }, + tone = { scheme -> if (scheme.isDark) 80.0 else 40.0 }, + isBackground = true, + ) + + // //////////////////////////////////////////////////////////////// + // Primaries [P] // + // //////////////////////////////////////////////////////////////// + override val primary: DynamicColor + get() = + DynamicColor( + name = "primary", + palette = { scheme -> scheme.primaryPalette }, + tone = { scheme -> + if (isMonochrome(scheme)) { + if (scheme.isDark) 100.0 else 0.0 + } else { + if (scheme.isDark) 80.0 else 40.0 + } + }, + isBackground = true, + background = this::highestSurface, + contrastCurve = { ContrastCurve(3.0, 4.5, 7.0, 7.0) }, + toneDeltaPair = { + ToneDeltaPair( + roleA = primaryContainer, + roleB = primary, + delta = 10.0, + polarity = TonePolarity.RELATIVE_LIGHTER, + stayTogether = false, + constraint = DeltaConstraint.NEARER, + ) + }, + ) + + override val primaryDim: DynamicColor? + get() = null + + override val onPrimary: DynamicColor + get() = + DynamicColor( + name = "on_primary", + palette = { scheme -> scheme.primaryPalette }, + tone = { scheme -> + if (isMonochrome(scheme)) { + if (scheme.isDark) 10.0 else 90.0 + } else { + if (scheme.isDark) 20.0 else 100.0 + } + }, + background = { primary }, + contrastCurve = { ContrastCurve(4.5, 7.0, 11.0, 21.0) }, + ) + + override val primaryContainer: DynamicColor + get() = + DynamicColor( + name = "primary_container", + palette = { scheme -> scheme.primaryPalette }, + tone = { scheme -> + if (isFidelity(scheme)) { + scheme.sourceColorHct.tone + } else if (isMonochrome(scheme)) { + if (scheme.isDark) 85.0 else 25.0 + } else { + if (scheme.isDark) 30.0 else 90.0 + } + }, + isBackground = true, + background = this::highestSurface, + contrastCurve = { ContrastCurve(1.0, 1.0, 3.0, 4.5) }, + toneDeltaPair = { + ToneDeltaPair( + roleA = primaryContainer, + roleB = primary, + delta = 10.0, + polarity = TonePolarity.RELATIVE_LIGHTER, + stayTogether = false, + constraint = DeltaConstraint.NEARER, + ) + }, + ) + + override val onPrimaryContainer: DynamicColor + get() = + DynamicColor( + name = "on_primary_container", + palette = { scheme -> scheme.primaryPalette }, + tone = { scheme -> + if (isFidelity(scheme)) { + DynamicColor.foregroundTone(primaryContainer.tone.invoke(scheme), 4.5) + } else if (isMonochrome(scheme)) { + if (scheme.isDark) 0.0 else 100.0 + } else { + if (scheme.isDark) 90.0 else 30.0 + } + }, + background = { primaryContainer }, + contrastCurve = { ContrastCurve(3.0, 4.5, 7.0, 11.0) }, + ) + + override val inversePrimary: DynamicColor + get() = + DynamicColor( + name = "inverse_primary", + palette = { scheme -> scheme.primaryPalette }, + tone = { scheme -> if (scheme.isDark) 40.0 else 80.0 }, + background = { inverseSurface }, + contrastCurve = { ContrastCurve(3.0, 4.5, 7.0, 7.0) }, + ) + + // //////////////////////////////////////////////////////////////// + // Secondaries [Q] // + // //////////////////////////////////////////////////////////////// + override val secondary: DynamicColor + get() = + DynamicColor( + name = "secondary", + palette = { scheme -> scheme.secondaryPalette }, + tone = { scheme -> if (scheme.isDark) 80.0 else 40.0 }, + isBackground = true, + background = this::highestSurface, + contrastCurve = { ContrastCurve(3.0, 4.5, 7.0, 7.0) }, + toneDeltaPair = { + ToneDeltaPair( + roleA = secondaryContainer, + roleB = secondary, + delta = 10.0, + polarity = TonePolarity.RELATIVE_LIGHTER, + stayTogether = false, + constraint = DeltaConstraint.NEARER, + ) + }, + ) + + override val secondaryDim: DynamicColor? + get() = null + + override val onSecondary: DynamicColor + get() = + DynamicColor( + name = "on_secondary", + palette = { scheme -> scheme.secondaryPalette }, + tone = { scheme -> + if (isMonochrome(scheme)) { + if (scheme.isDark) 10.0 else 100.0 + } else { + if (scheme.isDark) 20.0 else 100.0 + } + }, + background = { secondary }, + contrastCurve = { ContrastCurve(4.5, 7.0, 11.0, 21.0) }, + ) + + override val secondaryContainer: DynamicColor + get() = + DynamicColor( + name = "secondary_container", + palette = { scheme -> scheme.secondaryPalette }, + tone = { scheme -> + val initialTone = if (scheme.isDark) 30.0 else 90.0 + if (isMonochrome(scheme)) { + if (scheme.isDark) 30.0 else 85.0 + } else if (!isFidelity(scheme)) { + initialTone + } else { + findDesiredChromaByTone( + scheme.secondaryPalette.hue, + scheme.secondaryPalette.chroma, + initialTone, + !scheme.isDark, + ) + } + }, + isBackground = true, + background = this::highestSurface, + contrastCurve = { ContrastCurve(1.0, 1.0, 3.0, 4.5) }, + toneDeltaPair = { + ToneDeltaPair( + roleA = secondaryContainer, + roleB = secondary, + delta = 10.0, + polarity = TonePolarity.RELATIVE_LIGHTER, + stayTogether = false, + constraint = DeltaConstraint.NEARER, + ) + }, + ) + + override val onSecondaryContainer: DynamicColor + get() = + DynamicColor( + name = "on_secondary_container", + palette = { scheme -> scheme.secondaryPalette }, + tone = { scheme -> + if (isMonochrome(scheme)) { + if (scheme.isDark) 90.0 else 10.0 + } else if (!isFidelity(scheme)) { + if (scheme.isDark) 90.0 else 30.0 + } else { + DynamicColor.foregroundTone(secondaryContainer.tone.invoke(scheme), 4.5) + } + }, + background = { secondaryContainer }, + contrastCurve = { ContrastCurve(3.0, 4.5, 7.0, 11.0) }, + ) + + // //////////////////////////////////////////////////////////////// + // Tertiaries [T] // + // //////////////////////////////////////////////////////////////// + override val tertiary: DynamicColor + get() = + DynamicColor( + name = "tertiary", + palette = { scheme -> scheme.tertiaryPalette }, + tone = { scheme -> + if (isMonochrome(scheme)) { + if (scheme.isDark) 90.0 else 25.0 + } else { + if (scheme.isDark) 80.0 else 40.0 + } + }, + isBackground = true, + background = this::highestSurface, + contrastCurve = { ContrastCurve(3.0, 4.5, 7.0, 7.0) }, + toneDeltaPair = { + ToneDeltaPair( + roleA = tertiaryContainer, + roleB = tertiary, + delta = 10.0, + polarity = TonePolarity.RELATIVE_LIGHTER, + stayTogether = false, + constraint = DeltaConstraint.NEARER, + ) + }, + ) + + override val tertiaryDim: DynamicColor? + get() = null + + override val onTertiary: DynamicColor + get() = + DynamicColor( + name = "on_tertiary", + palette = { scheme -> scheme.tertiaryPalette }, + tone = { scheme -> + if (isMonochrome(scheme)) { + if (scheme.isDark) 10.0 else 90.0 + } else { + if (scheme.isDark) 20.0 else 100.0 + } + }, + background = { tertiary }, + contrastCurve = { ContrastCurve(4.5, 7.0, 11.0, 21.0) }, + ) + + override val tertiaryContainer: DynamicColor + get() = + DynamicColor( + name = "tertiary_container", + palette = { scheme -> scheme.tertiaryPalette }, + tone = { scheme -> + if (isMonochrome(scheme)) { + if (scheme.isDark) 60.0 else 49.0 + } else if (!isFidelity(scheme)) { + if (scheme.isDark) 30.0 else 90.0 + } else { + val proposedHct = scheme.tertiaryPalette.getHct(scheme.sourceColorHct.tone) + DislikeAnalyzer.fixIfDisliked(proposedHct).tone + } + }, + isBackground = true, + background = this::highestSurface, + contrastCurve = { ContrastCurve(1.0, 1.0, 3.0, 4.5) }, + toneDeltaPair = { + ToneDeltaPair( + roleA = tertiaryContainer, + roleB = tertiary, + delta = 10.0, + polarity = TonePolarity.RELATIVE_LIGHTER, + stayTogether = false, + constraint = DeltaConstraint.NEARER, + ) + }, + ) + + override val onTertiaryContainer: DynamicColor + get() = + DynamicColor( + name = "on_tertiary_container", + palette = { scheme -> scheme.tertiaryPalette }, + tone = { scheme -> + if (isMonochrome(scheme)) { + if (scheme.isDark) 0.0 else 100.0 + } else if (!isFidelity(scheme)) { + if (scheme.isDark) 90.0 else 30.0 + } else { + DynamicColor.foregroundTone(tertiaryContainer.tone.invoke(scheme), 4.5) + } + }, + background = { tertiaryContainer }, + contrastCurve = { ContrastCurve(3.0, 4.5, 7.0, 11.0) }, + ) + + // //////////////////////////////////////////////////////////////// + // Errors [E] // + // //////////////////////////////////////////////////////////////// + override val error: DynamicColor + get() = + DynamicColor( + name = "error", + palette = { scheme -> scheme.errorPalette }, + tone = { scheme -> if (scheme.isDark) 80.0 else 40.0 }, + isBackground = true, + background = this::highestSurface, + contrastCurve = { ContrastCurve(3.0, 4.5, 7.0, 7.0) }, + toneDeltaPair = { + ToneDeltaPair( + roleA = errorContainer, + roleB = error, + delta = 10.0, + polarity = TonePolarity.RELATIVE_LIGHTER, + stayTogether = false, + constraint = DeltaConstraint.NEARER, + ) + }, + ) + + override val errorDim: DynamicColor? + get() = null + + override val onError: DynamicColor + get() = + DynamicColor( + name = "on_error", + palette = { scheme -> scheme.errorPalette }, + tone = { scheme -> if (scheme.isDark) 20.0 else 100.0 }, + background = { error }, + contrastCurve = { ContrastCurve(4.5, 7.0, 11.0, 21.0) }, + ) + + override val errorContainer: DynamicColor + get() = + DynamicColor( + name = "error_container", + palette = { scheme -> scheme.errorPalette }, + tone = { scheme -> if (scheme.isDark) 30.0 else 90.0 }, + isBackground = true, + background = this::highestSurface, + contrastCurve = { ContrastCurve(1.0, 1.0, 3.0, 4.5) }, + toneDeltaPair = { + ToneDeltaPair( + roleA = errorContainer, + roleB = error, + delta = 10.0, + polarity = TonePolarity.RELATIVE_LIGHTER, + stayTogether = false, + constraint = DeltaConstraint.NEARER, + ) + }, + ) + + override val onErrorContainer: DynamicColor + get() = + DynamicColor( + name = "on_error_container", + palette = { scheme -> scheme.errorPalette }, + tone = { scheme -> + if (isMonochrome(scheme)) { + if (scheme.isDark) 90.0 else 10.0 + } else { + if (scheme.isDark) 90.0 else 30.0 + } + }, + background = { errorContainer }, + contrastCurve = { ContrastCurve(3.0, 4.5, 7.0, 11.0) }, + ) + + // //////////////////////////////////////////////////////////////// + // Primary Fixed Colors [PF] // + // //////////////////////////////////////////////////////////////// + override val primaryFixed: DynamicColor + get() = + DynamicColor( + name = "primary_fixed", + palette = { scheme -> scheme.primaryPalette }, + tone = { scheme -> if (isMonochrome(scheme)) 40.0 else 90.0 }, + isBackground = true, + background = this::highestSurface, + contrastCurve = { ContrastCurve(1.0, 1.0, 3.0, 4.5) }, + toneDeltaPair = { + ToneDeltaPair( + roleA = primaryFixed, + roleB = primaryFixedDim, + delta = 10.0, + polarity = TonePolarity.LIGHTER, + stayTogether = true, + ) + }, + ) + + override val primaryFixedDim: DynamicColor + get() = + DynamicColor( + name = "primary_fixed_dim", + palette = { scheme -> scheme.primaryPalette }, + tone = { scheme -> if (isMonochrome(scheme)) 30.0 else 80.0 }, + isBackground = true, + background = this::highestSurface, + contrastCurve = { ContrastCurve(1.0, 1.0, 3.0, 4.5) }, + toneDeltaPair = { + ToneDeltaPair( + roleA = primaryFixed, + roleB = primaryFixedDim, + delta = 10.0, + polarity = TonePolarity.LIGHTER, + stayTogether = true, + ) + }, + ) + + override val onPrimaryFixed: DynamicColor + get() = + DynamicColor( + name = "on_primary_fixed", + palette = { scheme -> scheme.primaryPalette }, + tone = { scheme -> if (isMonochrome(scheme)) 100.0 else 10.0 }, + background = { primaryFixedDim }, + secondBackground = { primaryFixed }, + contrastCurve = { ContrastCurve(4.5, 7.0, 11.0, 21.0) }, + ) + + override val onPrimaryFixedVariant: DynamicColor + get() = + DynamicColor( + name = "on_primary_fixed_variant", + palette = { scheme -> scheme.primaryPalette }, + tone = { scheme -> if (isMonochrome(scheme)) 90.0 else 30.0 }, + background = { primaryFixedDim }, + secondBackground = { primaryFixed }, + contrastCurve = { ContrastCurve(3.0, 4.5, 7.0, 11.0) }, + ) + + // //////////////////////////////////////////////////////////////// + // Secondary Fixed Colors [QF] // + // //////////////////////////////////////////////////////////////// + override val secondaryFixed: DynamicColor + get() = + DynamicColor( + name = "secondary_fixed", + palette = { scheme -> scheme.secondaryPalette }, + tone = { scheme -> if (isMonochrome(scheme)) 80.0 else 90.0 }, + isBackground = true, + background = this::highestSurface, + contrastCurve = { ContrastCurve(1.0, 1.0, 3.0, 4.5) }, + toneDeltaPair = { + ToneDeltaPair( + roleA = secondaryFixed, + roleB = secondaryFixedDim, + delta = 10.0, + polarity = TonePolarity.LIGHTER, + stayTogether = true, + ) + }, + ) + + override val secondaryFixedDim: DynamicColor + get() = + DynamicColor( + name = "secondary_fixed_dim", + palette = { scheme -> scheme.secondaryPalette }, + tone = { scheme -> if (isMonochrome(scheme)) 70.0 else 80.0 }, + isBackground = true, + background = this::highestSurface, + contrastCurve = { ContrastCurve(1.0, 1.0, 3.0, 4.5) }, + toneDeltaPair = { + ToneDeltaPair( + roleA = secondaryFixed, + roleB = secondaryFixedDim, + delta = 10.0, + polarity = TonePolarity.LIGHTER, + stayTogether = true, + ) + }, + ) + + override val onSecondaryFixed: DynamicColor + get() = + DynamicColor( + name = "on_secondary_fixed", + palette = { scheme -> scheme.secondaryPalette }, + tone = { 10.0 }, + background = { secondaryFixedDim }, + secondBackground = { secondaryFixed }, + contrastCurve = { ContrastCurve(4.5, 7.0, 11.0, 21.0) }, + ) + + override val onSecondaryFixedVariant: DynamicColor + get() = + DynamicColor( + name = "on_secondary_fixed_variant", + palette = { scheme -> scheme.secondaryPalette }, + tone = { scheme -> if (isMonochrome(scheme)) 25.0 else 30.0 }, + background = { secondaryFixedDim }, + secondBackground = { secondaryFixed }, + contrastCurve = { ContrastCurve(3.0, 4.5, 7.0, 11.0) }, + ) + + // //////////////////////////////////////////////////////////////// + // Tertiary Fixed Colors [TF] // + // //////////////////////////////////////////////////////////////// + override val tertiaryFixed: DynamicColor + get() = + DynamicColor( + name = "tertiary_fixed", + palette = { scheme -> scheme.tertiaryPalette }, + tone = { scheme -> if (isMonochrome(scheme)) 40.0 else 90.0 }, + isBackground = true, + background = this::highestSurface, + contrastCurve = { ContrastCurve(1.0, 1.0, 3.0, 4.5) }, + toneDeltaPair = { + ToneDeltaPair( + roleA = tertiaryFixed, + roleB = tertiaryFixedDim, + delta = 10.0, + polarity = TonePolarity.LIGHTER, + stayTogether = true, + ) + }, + ) + + override val tertiaryFixedDim: DynamicColor + get() = + DynamicColor( + name = "tertiary_fixed_dim", + palette = { scheme -> scheme.tertiaryPalette }, + tone = { scheme -> if (isMonochrome(scheme)) 30.0 else 80.0 }, + isBackground = true, + background = this::highestSurface, + contrastCurve = { ContrastCurve(1.0, 1.0, 3.0, 4.5) }, + toneDeltaPair = { + ToneDeltaPair( + roleA = tertiaryFixed, + roleB = tertiaryFixedDim, + delta = 10.0, + polarity = TonePolarity.LIGHTER, + stayTogether = true, + ) + }, + ) + + override val onTertiaryFixed: DynamicColor + get() = + DynamicColor( + name = "on_tertiary_fixed", + palette = { scheme -> scheme.tertiaryPalette }, + tone = { scheme -> if (isMonochrome(scheme)) 100.0 else 10.0 }, + background = { tertiaryFixedDim }, + secondBackground = { tertiaryFixed }, + contrastCurve = { ContrastCurve(4.5, 7.0, 11.0, 21.0) }, + ) + + override val onTertiaryFixedVariant: DynamicColor + get() = + DynamicColor( + name = "on_tertiary_fixed_variant", + palette = { scheme -> scheme.tertiaryPalette }, + tone = { scheme -> if (isMonochrome(scheme)) 90.0 else 30.0 }, + background = { tertiaryFixedDim }, + secondBackground = { tertiaryFixed }, + contrastCurve = { ContrastCurve(3.0, 4.5, 7.0, 11.0) }, + ) + + // //////////////////////////////////////////////////////////////// + // Other // + // //////////////////////////////////////////////////////////////// + override fun highestSurface(scheme: DynamicScheme): DynamicColor { + return if (scheme.isDark) surfaceBright else surfaceDim + } + + // /////////////////////////////////////////////////////////////// + // Color value calculations // + // /////////////////////////////////////////////////////////////// + override fun getHct(scheme: DynamicScheme, color: DynamicColor): Hct { + // This is crucial for aesthetics: we aren't simply the taking the standard color + // and changing its tone for contrast. Rather, we find the tone for contrast, then + // use the specified chroma from the palette to construct a new color. + // + // For example, this enables colors with standard tone of T90, which has limited chroma, to + // "recover" intended chroma as contrast increases. + val tone = getTone(scheme, color) + return color.palette.invoke(scheme).getHct(tone) + } + + override fun getTone(scheme: DynamicScheme, color: DynamicColor): Double { + val decreasingContrast = scheme.contrastLevel < 0 + val toneDeltaPair = color.toneDeltaPair?.invoke(scheme) + + // Case 1: dual foreground, pair of colors with delta constraint. + if (toneDeltaPair != null) { + val roleA = toneDeltaPair.roleA + val roleB = toneDeltaPair.roleB + val delta = toneDeltaPair.delta + val polarity = toneDeltaPair.polarity + val stayTogether = toneDeltaPair.stayTogether + val aIsNearer = + (toneDeltaPair.constraint == DeltaConstraint.NEARER || + (polarity == TonePolarity.LIGHTER && !scheme.isDark) || + (polarity == TonePolarity.DARKER && !scheme.isDark)) + val nearer = if (aIsNearer) roleA else roleB + val farther = if (aIsNearer) roleB else roleA + val amNearer = color.name == nearer.name + val expansionDir = if (scheme.isDark) 1 else -1 + var nTone = nearer.tone.invoke(scheme) + var fTone = farther.tone.invoke(scheme) + + // 1st round: solve to min, each + val background = color.background + val nContrastCurve = nearer.contrastCurve?.invoke(scheme) + val fContrastCurve = farther.contrastCurve?.invoke(scheme) + if ( + background != null && + nearer.contrastCurve != null && + farther.contrastCurve != null && + nContrastCurve != null && + fContrastCurve != null + ) { + val bg = background.invoke(scheme) + if (bg != null) { + val nContrast = nContrastCurve.get(scheme.contrastLevel) + val fContrast = fContrastCurve.get(scheme.contrastLevel) + val bgTone = bg.getTone(scheme) + + // If a color is good enough, it is not adjusted. + // Initial and adjusted tones for `nearer` + if (Contrast.ratioOfTones(bgTone, nTone) < nContrast) { + nTone = DynamicColor.foregroundTone(bgTone, nContrast) + } + // Initial and adjusted tones for `farther` + if (Contrast.ratioOfTones(bgTone, fTone) < fContrast) { + fTone = DynamicColor.foregroundTone(bgTone, fContrast) + } + if (decreasingContrast) { + // If decreasing contrast, adjust color to the "bare minimum" + // that satisfies contrast. + nTone = DynamicColor.foregroundTone(bgTone, nContrast) + fTone = DynamicColor.foregroundTone(bgTone, fContrast) + } + } + } + + // If constraint is not satisfied, try another round. + if ((fTone - nTone) * expansionDir < delta) { + // 2nd round: expand farther to match delta. + fTone = (nTone + delta * expansionDir).coerceIn(0.0, 100.0) + // If constraint is not satisfied, try another round. + if ((fTone - nTone) * expansionDir < delta) { + // 3rd round: contract nearer to match delta. + nTone = (fTone - delta * expansionDir).coerceIn(0.0, 100.0) + } + } + + // Avoids the 50-59 awkward zone. + if (50 <= nTone && nTone < 60) { + // If `nearer` is in the awkward zone, move it away, together with + // `farther`. + if (expansionDir > 0) { + nTone = 60.0 + fTone = max(fTone, nTone + delta * expansionDir) + } else { + nTone = 49.0 + fTone = min(fTone, nTone + delta * expansionDir) + } + } else if (50 <= fTone && fTone < 60) { + if (stayTogether) { + // Fixes both, to avoid two colors on opposite sides of the "awkward + // zone". + if (expansionDir > 0) { + nTone = 60.0 + fTone = max(fTone, nTone + delta * expansionDir) + } else { + nTone = 49.0 + fTone = min(fTone, nTone + delta * expansionDir) + } + } else { + // Not required to stay together; fixes just one. + if (expansionDir > 0) { + fTone = 60.0 + } else { + fTone = 49.0 + } + } + } + + // Returns `nTone` if this color is `nearer`, otherwise `fTone`. + return if (amNearer) nTone else fTone + } else { + // Case 2: No contrast pair; just solve for itself. + var answer = color.tone.invoke(scheme) + val background = color.background?.invoke(scheme) + val contrastCurve = color.contrastCurve?.invoke(scheme) + if (background == null || contrastCurve == null) { + return answer // No adjustment for colors with no background. + } + val bgTone = background.getTone(scheme) + val desiredRatio = contrastCurve.get(scheme.contrastLevel) + if (Contrast.ratioOfTones(bgTone, answer) >= desiredRatio) { + // Don't "improve" what's good enough. + } else { + // Rough improvement. + answer = DynamicColor.foregroundTone(bgTone, desiredRatio) + } + if (decreasingContrast) { + answer = DynamicColor.foregroundTone(bgTone, desiredRatio) + } + if (color.isBackground && 50 <= answer && answer < 60) { + // Must adjust + answer = + if (Contrast.ratioOfTones(49.0, bgTone) >= desiredRatio) { + 49.0 + } else { + 60.0 + } + } + val secondBackground = color.secondBackground?.invoke(scheme) + if (secondBackground == null) { + return answer + } + + // Case 3: Adjust for dual backgrounds. + val bgTone1 = background.getTone(scheme) + val bgTone2 = secondBackground.getTone(scheme) + val upper = max(bgTone1, bgTone2) + val lower = min(bgTone1, bgTone2) + if ( + Contrast.ratioOfTones(upper, answer) >= desiredRatio && + Contrast.ratioOfTones(lower, answer) >= desiredRatio + ) { + return answer + } + + // The darkest light tone that satisfies the desired ratio, + // or -1 if such ratio cannot be reached. + val lightOption = Contrast.lighter(upper, desiredRatio) + + // The lightest dark tone that satisfies the desired ratio, + // or -1 if such ratio cannot be reached. + val darkOption = Contrast.darker(lower, desiredRatio) + + // Tones suitable for the foreground. + val availables = mutableListOf() + if (lightOption != null) { + availables.add(lightOption) + } + if (darkOption != null) { + availables.add(darkOption) + } + val prefersLight = + DynamicColor.tonePrefersLightForeground(bgTone1) || + DynamicColor.tonePrefersLightForeground(bgTone2) + if (prefersLight) { + return lightOption ?: 100.0 + } + return if (availables.size == 1) { + availables[0] + } else if (darkOption == null) { + 0.0 + } else { + darkOption + } + } + } + + // //////////////////////////////////////////////////////////////// + // Scheme Palettes // + // //////////////////////////////////////////////////////////////// + override fun getPrimaryPalette( + variant: Variant, + sourceColorHct: Hct, + isDark: Boolean, + platform: Platform, + contrastLevel: Double, + ): TonalPalette { + return when (variant) { + Variant.CONTENT, + Variant.FIDELITY -> TonalPalette.fromHueAndChroma(sourceColorHct.hue, sourceColorHct.chroma) + Variant.FRUIT_SALAD -> + TonalPalette.fromHueAndChroma( + MathUtils.sanitizeDegreesDouble(sourceColorHct.hue - 50.0), + 48.0, + ) + Variant.MONOCHROME -> TonalPalette.fromHueAndChroma(sourceColorHct.hue, 0.0) + Variant.NEUTRAL -> TonalPalette.fromHueAndChroma(sourceColorHct.hue, 12.0) + Variant.RAINBOW -> TonalPalette.fromHueAndChroma(sourceColorHct.hue, 48.0) + Variant.TONAL_SPOT -> TonalPalette.fromHueAndChroma(sourceColorHct.hue, 36.0) + Variant.EXPRESSIVE -> + TonalPalette.fromHueAndChroma( + MathUtils.sanitizeDegreesDouble(sourceColorHct.hue + 240), + 40.0, + ) + Variant.VIBRANT -> TonalPalette.fromHueAndChroma(sourceColorHct.hue, 200.0) + } + } + + override fun getSecondaryPalette( + variant: Variant, + sourceColorHct: Hct, + isDark: Boolean, + platform: Platform, + contrastLevel: Double, + ): TonalPalette { + return when (variant) { + Variant.CONTENT, + Variant.FIDELITY -> + TonalPalette.fromHueAndChroma( + sourceColorHct.hue, + max(sourceColorHct.chroma - 32.0, sourceColorHct.chroma * 0.5), + ) + Variant.FRUIT_SALAD -> + TonalPalette.fromHueAndChroma( + MathUtils.sanitizeDegreesDouble(sourceColorHct.hue - 50.0), + 36.0, + ) + Variant.MONOCHROME -> TonalPalette.fromHueAndChroma(sourceColorHct.hue, 0.0) + Variant.NEUTRAL -> TonalPalette.fromHueAndChroma(sourceColorHct.hue, 8.0) + Variant.RAINBOW -> TonalPalette.fromHueAndChroma(sourceColorHct.hue, 16.0) + Variant.TONAL_SPOT -> TonalPalette.fromHueAndChroma(sourceColorHct.hue, 16.0) + Variant.EXPRESSIVE -> + TonalPalette.fromHueAndChroma( + DynamicScheme.getRotatedHue( + sourceColorHct, + doubleArrayOf(0.0, 21.0, 51.0, 121.0, 151.0, 191.0, 271.0, 321.0, 360.0), + doubleArrayOf(45.0, 95.0, 45.0, 20.0, 45.0, 90.0, 45.0, 45.0, 45.0), + ), + 24.0, + ) + Variant.VIBRANT -> + TonalPalette.fromHueAndChroma( + DynamicScheme.getRotatedHue( + sourceColorHct, + doubleArrayOf(0.0, 41.0, 61.0, 101.0, 131.0, 181.0, 251.0, 301.0, 360.0), + doubleArrayOf(18.0, 15.0, 10.0, 12.0, 15.0, 18.0, 15.0, 12.0, 12.0), + ), + 24.0, + ) + } + } + + override fun getTertiaryPalette( + variant: Variant, + sourceColorHct: Hct, + isDark: Boolean, + platform: Platform, + contrastLevel: Double, + ): TonalPalette { + return when (variant) { + Variant.CONTENT -> + TonalPalette.fromHct( + DislikeAnalyzer.fixIfDisliked( + TemperatureCache(sourceColorHct).getAnalogousColors(count = 3, divisions = 6)[2] + ) + ) + Variant.FIDELITY -> + TonalPalette.fromHct( + DislikeAnalyzer.fixIfDisliked(TemperatureCache(sourceColorHct).complement) + ) + Variant.FRUIT_SALAD -> TonalPalette.fromHueAndChroma(sourceColorHct.hue, 36.0) + Variant.MONOCHROME -> TonalPalette.fromHueAndChroma(sourceColorHct.hue, 0.0) + Variant.NEUTRAL -> TonalPalette.fromHueAndChroma(sourceColorHct.hue, 16.0) + Variant.RAINBOW, + Variant.TONAL_SPOT -> + TonalPalette.fromHueAndChroma( + MathUtils.sanitizeDegreesDouble(sourceColorHct.hue + 60.0), + 24.0, + ) + Variant.EXPRESSIVE -> + TonalPalette.fromHueAndChroma( + DynamicScheme.getRotatedHue( + sourceColorHct, + doubleArrayOf(0.0, 21.0, 51.0, 121.0, 151.0, 191.0, 271.0, 321.0, 360.0), + doubleArrayOf(120.0, 120.0, 20.0, 45.0, 20.0, 15.0, 20.0, 120.0, 120.0), + ), + 32.0, + ) + Variant.VIBRANT -> + TonalPalette.fromHueAndChroma( + DynamicScheme.getRotatedHue( + sourceColorHct, + doubleArrayOf(0.0, 41.0, 61.0, 101.0, 131.0, 181.0, 251.0, 301.0, 360.0), + doubleArrayOf(35.0, 30.0, 20.0, 25.0, 30.0, 35.0, 30.0, 25.0, 25.0), + ), + 32.0, + ) + } + } + + override fun getNeutralPalette( + variant: Variant, + sourceColorHct: Hct, + isDark: Boolean, + platform: Platform, + contrastLevel: Double, + ): TonalPalette { + return when (variant) { + Variant.CONTENT, + Variant.FIDELITY -> + TonalPalette.fromHueAndChroma(sourceColorHct.hue, sourceColorHct.chroma / 8.0) + Variant.FRUIT_SALAD -> TonalPalette.fromHueAndChroma(sourceColorHct.hue, 10.0) + Variant.MONOCHROME -> TonalPalette.fromHueAndChroma(sourceColorHct.hue, 0.0) + Variant.NEUTRAL -> TonalPalette.fromHueAndChroma(sourceColorHct.hue, 2.0) + Variant.RAINBOW -> TonalPalette.fromHueAndChroma(sourceColorHct.hue, 0.0) + Variant.TONAL_SPOT -> TonalPalette.fromHueAndChroma(sourceColorHct.hue, 6.0) + Variant.EXPRESSIVE -> + TonalPalette.fromHueAndChroma(MathUtils.sanitizeDegreesDouble(sourceColorHct.hue + 15), 8.0) + Variant.VIBRANT -> TonalPalette.fromHueAndChroma(sourceColorHct.hue, 10.0) + } + } + + override fun getNeutralVariantPalette( + variant: Variant, + sourceColorHct: Hct, + isDark: Boolean, + platform: Platform, + contrastLevel: Double, + ): TonalPalette { + return when (variant) { + Variant.CONTENT, + Variant.FIDELITY -> + TonalPalette.fromHueAndChroma(sourceColorHct.hue, (sourceColorHct.chroma / 8.0) + 4.0) + Variant.FRUIT_SALAD -> TonalPalette.fromHueAndChroma(sourceColorHct.hue, 16.0) + Variant.MONOCHROME -> TonalPalette.fromHueAndChroma(sourceColorHct.hue, 0.0) + Variant.NEUTRAL -> TonalPalette.fromHueAndChroma(sourceColorHct.hue, 2.0) + Variant.RAINBOW -> TonalPalette.fromHueAndChroma(sourceColorHct.hue, 0.0) + Variant.TONAL_SPOT -> TonalPalette.fromHueAndChroma(sourceColorHct.hue, 8.0) + Variant.EXPRESSIVE -> + TonalPalette.fromHueAndChroma( + MathUtils.sanitizeDegreesDouble(sourceColorHct.hue + 15), + 12.0, + ) + Variant.VIBRANT -> TonalPalette.fromHueAndChroma(sourceColorHct.hue, 12.0) + } + } + + override fun getErrorPalette( + variant: Variant, + sourceColorHct: Hct, + isDark: Boolean, + platform: Platform, + contrastLevel: Double, + ): TonalPalette { + return TonalPalette.fromHueAndChroma(25.0, 84.0) + } + + private fun isFidelity(scheme: DynamicScheme): Boolean { + return scheme.variant == Variant.FIDELITY || scheme.variant == Variant.CONTENT + } + + private fun isMonochrome(scheme: DynamicScheme): Boolean { + return scheme.variant == Variant.MONOCHROME + } + + private fun findDesiredChromaByTone( + hue: Double, + chroma: Double, + tone: Double, + byDecreasingTone: Boolean, + ): Double { + var answer = tone + var closestToChroma = Hct.from(hue, chroma, tone) + if (closestToChroma.chroma < chroma) { + var chromaPeak = closestToChroma.chroma + while (closestToChroma.chroma < chroma) { + answer += if (byDecreasingTone) -1.0 else 1.0 + val potentialSolution = Hct.from(hue, chroma, answer) + if (chromaPeak > potentialSolution.chroma) { + break + } + if (abs(potentialSolution.chroma - chroma) < 0.4) { + break + } + val potentialDelta = abs(potentialSolution.chroma - chroma) + val currentDelta = abs(closestToChroma.chroma - chroma) + if (potentialDelta < currentDelta) { + closestToChroma = potentialSolution + } + chromaPeak = max(chromaPeak, potentialSolution.chroma) + } + } + return answer + } +} diff --git a/libs/material-color-utilities/src/main/java/dynamiccolor/ColorSpec2025.kt b/libs/material-color-utilities/src/main/java/dynamiccolor/ColorSpec2025.kt new file mode 100644 index 000000000..493d306ad --- /dev/null +++ b/libs/material-color-utilities/src/main/java/dynamiccolor/ColorSpec2025.kt @@ -0,0 +1,2044 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dynamiccolor + +import contrast.Contrast +import dynamiccolor.DynamicScheme.Platform +import dynamiccolor.ToneDeltaPair.DeltaConstraint +import dynamiccolor.ToneDeltaPair.TonePolarity +import hct.Hct +import palettes.TonalPalette +import kotlin.math.max +import kotlin.math.min + +/** [ColorSpec] implementation for the 2025 spec. */ +class ColorSpec2025(private val baseSpec: ColorSpec2021 = ColorSpec2021()) : ColorSpec by baseSpec { + // //////////////////////////////////////////////////////////////// + // Surfaces [S] // + // //////////////////////////////////////////////////////////////// + override val background: DynamicColor + get() { + // Remapped to surface for 2025 spec. + val color2025 = surface.copy(name = "background") + return baseSpec.background.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val onBackground: DynamicColor + get() { + // Remapped to onSurface for 2025 spec. + val color2025 = + onSurface.copy( + name = "on_background", + tone = { scheme -> + if (scheme.platform == Platform.WATCH) 100.0 else onSurface.getTone(scheme) + }, + ) + return baseSpec.onBackground.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val surface: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "surface", + palette = { scheme -> scheme.neutralPalette }, + tone = { scheme -> + if (scheme.platform == Platform.PHONE) { + if (scheme.isDark) { + 4.0 + } else { + if (Hct.isYellow(scheme.neutralPalette.hue)) { + 99.0 + } else if (scheme.variant == Variant.VIBRANT) { + 97.0 + } else { + 98.0 + } + } + } else { + 0.0 + } + }, + isBackground = true, + ) + return baseSpec.surface.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val surfaceDim: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "surface_dim", + palette = { scheme -> scheme.neutralPalette }, + tone = { scheme -> + if (scheme.isDark) { + 4.0 + } else { + if (Hct.isYellow(scheme.neutralPalette.hue)) { + 90.0 + } else if (scheme.variant == Variant.VIBRANT) { + 85.0 + } else { + 87.0 + } + } + }, + isBackground = true, + chromaMultiplier = { scheme -> + if (!scheme.isDark) { + when (scheme.variant) { + Variant.NEUTRAL -> 2.5 + Variant.TONAL_SPOT -> 1.7 + Variant.EXPRESSIVE -> if (Hct.isYellow(scheme.neutralPalette.hue)) 2.7 else 1.75 + Variant.VIBRANT -> 1.36 + else -> 1.0 + } + } else { + 1.0 + } + }, + ) + return baseSpec.surfaceDim.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val surfaceBright: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "surface_bright", + palette = { scheme -> scheme.neutralPalette }, + tone = { scheme -> + if (scheme.isDark) { + 18.0 + } else { + if (Hct.isYellow(scheme.neutralPalette.hue)) { + 99.0 + } else if (scheme.variant == Variant.VIBRANT) { + 97.0 + } else { + 98.0 + } + } + }, + isBackground = true, + chromaMultiplier = { scheme -> + if (scheme.isDark) { + when (scheme.variant) { + Variant.NEUTRAL -> 2.5 + Variant.TONAL_SPOT -> 1.7 + Variant.EXPRESSIVE -> if (Hct.isYellow(scheme.neutralPalette.hue)) 2.7 else 1.75 + Variant.VIBRANT -> 1.36 + else -> 1.0 + } + } else { + 1.0 + } + }, + ) + return baseSpec.surfaceBright.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val surfaceContainerLowest: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "surface_container_lowest", + palette = { scheme -> scheme.neutralPalette }, + tone = { scheme -> if (scheme.isDark) 0.0 else 100.0 }, + isBackground = true, + ) + return baseSpec.surfaceContainerLowest.extendSpecVersion( + ColorSpec.SpecVersion.SPEC_2025, + color2025, + ) + } + + override val surfaceContainerLow: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "surface_container_low", + palette = { scheme -> scheme.neutralPalette }, + tone = { scheme -> + if (scheme.platform == Platform.PHONE) { + if (scheme.isDark) { + 6.0 + } else { + if (Hct.isYellow(scheme.neutralPalette.hue)) { + 98.0 + } else if (scheme.variant == Variant.VIBRANT) { + 95.0 + } else { + 96.0 + } + } + } else { + 15.0 + } + }, + isBackground = true, + chromaMultiplier = { scheme -> + if (scheme.platform == Platform.PHONE) { + when (scheme.variant) { + Variant.NEUTRAL -> 1.3 + Variant.TONAL_SPOT -> 1.25 + Variant.EXPRESSIVE -> if (Hct.isYellow(scheme.neutralPalette.hue)) 1.3 else 1.15 + Variant.VIBRANT -> 1.08 + else -> 1.0 + } + } else { + 1.0 + } + }, + ) + return baseSpec.surfaceContainerLow.extendSpecVersion( + ColorSpec.SpecVersion.SPEC_2025, + color2025, + ) + } + + override val surfaceContainer: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "surface_container", + palette = { scheme -> scheme.neutralPalette }, + tone = { scheme -> + if (scheme.platform == Platform.PHONE) { + if (scheme.isDark) { + 9.0 + } else { + if (Hct.isYellow(scheme.neutralPalette.hue)) { + 96.0 + } else if (scheme.variant == Variant.VIBRANT) { + 92.0 + } else { + 94.0 + } + } + } else { + 20.0 + } + }, + isBackground = true, + chromaMultiplier = { scheme -> + if (scheme.platform == Platform.PHONE) { + when (scheme.variant) { + Variant.NEUTRAL -> 1.6 + Variant.TONAL_SPOT -> 1.4 + Variant.EXPRESSIVE -> if (Hct.isYellow(scheme.neutralPalette.hue)) 1.6 else 1.3 + Variant.VIBRANT -> 1.15 + else -> 1.0 + } + } else { + 1.0 + } + }, + ) + return baseSpec.surfaceContainer.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val surfaceContainerHigh: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "surface_container_high", + palette = { scheme -> scheme.neutralPalette }, + tone = { scheme -> + if (scheme.platform == Platform.PHONE) { + if (scheme.isDark) { + 12.0 + } else { + if (Hct.isYellow(scheme.neutralPalette.hue)) { + 94.0 + } else if (scheme.variant == Variant.VIBRANT) { + 90.0 + } else { + 92.0 + } + } + } else { + 25.0 + } + }, + isBackground = true, + chromaMultiplier = { scheme -> + if (scheme.platform == Platform.PHONE) { + when (scheme.variant) { + Variant.NEUTRAL -> 1.9 + Variant.TONAL_SPOT -> 1.5 + Variant.EXPRESSIVE -> if (Hct.isYellow(scheme.neutralPalette.hue)) 1.95 else 1.45 + Variant.VIBRANT -> 1.22 + else -> 1.0 + } + } else { + 1.0 + } + }, + ) + return baseSpec.surfaceContainerHigh.extendSpecVersion( + ColorSpec.SpecVersion.SPEC_2025, + color2025, + ) + } + + override val surfaceContainerHighest: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "surface_container_highest", + palette = { scheme -> scheme.neutralPalette }, + tone = { scheme -> + if (scheme.isDark) { + 15.0 + } else { + if (Hct.isYellow(scheme.neutralPalette.hue)) { + 92.0 + } else if (scheme.variant == Variant.VIBRANT) { + 88.0 + } else { + 90.0 + } + } + }, + isBackground = true, + chromaMultiplier = { scheme -> + when (scheme.variant) { + Variant.NEUTRAL -> 2.2 + Variant.TONAL_SPOT -> 1.7 + Variant.EXPRESSIVE -> if (Hct.isYellow(scheme.neutralPalette.hue)) 2.3 else 1.6 + Variant.VIBRANT -> 1.29 + else -> 1.0 + } + }, + ) + return baseSpec.surfaceContainerHighest.extendSpecVersion( + ColorSpec.SpecVersion.SPEC_2025, + color2025, + ) + } + + override val onSurface: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "on_surface", + palette = { scheme -> scheme.neutralPalette }, + tone = { scheme -> + if (scheme.variant == Variant.VIBRANT) { + tMaxC(scheme.neutralPalette, 0.0, 100.0, 1.1) + } else { + DynamicColor.getInitialToneFromBackground { scheme: DynamicScheme -> + if (scheme.platform == Platform.PHONE) { + if (scheme.isDark) surfaceBright else surfaceDim + } else { + surfaceContainerHigh + } + } + .invoke(scheme) + } + }, + chromaMultiplier = { scheme -> + if (scheme.platform == Platform.PHONE) { + when (scheme.variant) { + Variant.NEUTRAL -> 2.2 + Variant.TONAL_SPOT -> 1.7 + Variant.EXPRESSIVE -> { + if (Hct.isYellow(scheme.neutralPalette.hue)) { + (if (scheme.isDark) 3.0 else 2.3) + } else { + 1.6 + } + } + else -> 1.0 + } + } else { + 1.0 + } + }, + background = { scheme -> + if (scheme.platform == Platform.PHONE) { + if (scheme.isDark) surfaceBright else surfaceDim + } else { + surfaceContainerHigh + } + }, + contrastCurve = { scheme -> + if (scheme.isDark && scheme.platform == Platform.PHONE) { + getContrastCurve(11.0) + } else { + getContrastCurve(9.0) + } + }, + ) + return baseSpec.onSurface.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val surfaceVariant: DynamicColor + get() { + // Remapped to surfaceContainerHighest for 2025 spec. + val color2025 = surfaceContainerHighest.copy(name = "surface_variant") + return baseSpec.surfaceVariant.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val onSurfaceVariant: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "on_surface_variant", + palette = { scheme -> scheme.neutralPalette }, + chromaMultiplier = { scheme -> + if (scheme.platform == Platform.PHONE) { + when (scheme.variant) { + Variant.NEUTRAL -> 2.2 + Variant.TONAL_SPOT -> 1.7 + Variant.EXPRESSIVE -> { + if (Hct.isYellow(scheme.neutralPalette.hue)) { + (if (scheme.isDark) 3.0 else 2.3) + } else { + 1.6 + } + } + else -> 1.0 + } + } else { + 1.0 + } + }, + background = { scheme -> + if (scheme.platform == Platform.PHONE) { + if (scheme.isDark) surfaceBright else surfaceDim + } else { + surfaceContainerHigh + } + }, + contrastCurve = { scheme -> + if (scheme.platform == Platform.PHONE) { + (if (scheme.isDark) getContrastCurve(6.0) else getContrastCurve(4.5)) + } else { + getContrastCurve(7.0) + } + }, + ) + return baseSpec.onSurfaceVariant.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val inverseSurface: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "inverse_surface", + palette = { scheme -> scheme.neutralPalette }, + tone = { scheme -> if (scheme.isDark) 98.0 else 4.0 }, + isBackground = true, + ) + return baseSpec.inverseSurface.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val inverseOnSurface: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "inverse_on_surface", + palette = { scheme -> scheme.neutralPalette }, + background = { inverseSurface }, + contrastCurve = { getContrastCurve(7.0) }, + ) + return baseSpec.inverseOnSurface.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val outline: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "outline", + palette = { scheme -> scheme.neutralPalette }, + chromaMultiplier = { scheme -> + if (scheme.platform == Platform.PHONE) { + when (scheme.variant) { + Variant.NEUTRAL -> 2.2 + Variant.TONAL_SPOT -> 1.7 + Variant.EXPRESSIVE -> { + if (Hct.isYellow(scheme.neutralPalette.hue)) { + (if (scheme.isDark) 3.0 else 2.3) + } else { + 1.6 + } + } + else -> 1.0 + } + } else { + 1.0 + } + }, + background = { scheme -> + if (scheme.platform == Platform.PHONE) { + if (scheme.isDark) surfaceBright else surfaceDim + } else { + surfaceContainerHigh + } + }, + contrastCurve = { scheme -> + if (scheme.platform == Platform.PHONE) getContrastCurve(3.0) else getContrastCurve(4.5) + }, + ) + return baseSpec.outline.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val outlineVariant: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "outline_variant", + palette = { scheme -> scheme.neutralPalette }, + chromaMultiplier = { scheme -> + if (scheme.platform == Platform.PHONE) { + when (scheme.variant) { + Variant.NEUTRAL -> 2.2 + Variant.TONAL_SPOT -> 1.7 + Variant.EXPRESSIVE -> { + if (Hct.isYellow(scheme.neutralPalette.hue)) { + (if (scheme.isDark) 3.0 else 2.3) + } else { + 1.6 + } + } + else -> 1.0 + } + } else { + 1.0 + } + }, + background = { scheme -> + if (scheme.platform == Platform.PHONE) { + if (scheme.isDark) surfaceBright else surfaceDim + } else { + surfaceContainerHigh + } + }, + contrastCurve = { scheme -> + if (scheme.platform == Platform.PHONE) getContrastCurve(1.5) else getContrastCurve(3.0) + }, + ) + return baseSpec.outlineVariant.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val surfaceTint: DynamicColor + get() { + // Remapped to primary for 2025 spec. + val color2025 = primary.copy(name = "surface_tint") + return baseSpec.surfaceTint.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + // //////////////////////////////////////////////////////////////// + // Primaries [P] // + // //////////////////////////////////////////////////////////////// + override val primary: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "primary", + palette = { scheme -> scheme.primaryPalette }, + tone = { scheme -> + when { + scheme.variant == Variant.NEUTRAL -> { + if (scheme.platform == Platform.PHONE) { + if (scheme.isDark) 80.0 else 40.0 + } else { + 90.0 + } + } + scheme.variant == Variant.TONAL_SPOT -> { + if (scheme.platform == Platform.PHONE) { + if (scheme.isDark) { + 80.0 + } else { + tMaxC(scheme.primaryPalette) + } + } else { + tMaxC(scheme.primaryPalette, 0.0, 90.0) + } + } + scheme.variant == Variant.EXPRESSIVE -> { + if (scheme.platform == Platform.PHONE) { + tMaxC( + scheme.primaryPalette, + 0.0, + if (Hct.isYellow(scheme.primaryPalette.hue)) { + 25.0 + } else if (Hct.isCyan(scheme.primaryPalette.hue)) { + 88.0 + } else { + 98.0 + }, + ) + } else { // WATCH + tMaxC(scheme.primaryPalette) + } + } + else -> { // VIBRANT + if (scheme.platform == Platform.PHONE) { + tMaxC( + scheme.primaryPalette, + 0.0, + if (Hct.isCyan(scheme.primaryPalette.hue)) 88.0 else 98.0, + ) + } else { // WATCH + tMaxC(scheme.primaryPalette) + } + } + } + }, + isBackground = true, + background = { scheme -> + if (scheme.platform == Platform.PHONE) { + if (scheme.isDark) surfaceBright else surfaceDim + } else { + surfaceContainerHigh + } + }, + contrastCurve = { scheme -> + if (scheme.platform == Platform.PHONE) getContrastCurve(4.5) else getContrastCurve(7.0) + }, + toneDeltaPair = { scheme -> + if (scheme.platform == Platform.PHONE) { + ToneDeltaPair( + roleA = primaryContainer, + roleB = primary, + delta = 5.0, + polarity = TonePolarity.RELATIVE_LIGHTER, + constraint = DeltaConstraint.FARTHER, + ) + } else { + null + } + }, + ) + return baseSpec.primary.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val primaryDim: DynamicColor + get() { + return DynamicColor( + name = "primary_dim", + palette = { scheme -> scheme.primaryPalette }, + tone = { scheme -> + when (scheme.variant) { + Variant.NEUTRAL -> 85.0 + Variant.TONAL_SPOT -> tMaxC(scheme.primaryPalette, 0.0, 90.0) + else -> tMaxC(scheme.primaryPalette) + } + }, + isBackground = true, + background = { surfaceContainerHigh }, + contrastCurve = { getContrastCurve(4.5) }, + toneDeltaPair = { + ToneDeltaPair( + roleA = primaryDim, + roleB = primary, + delta = 5.0, + polarity = TonePolarity.DARKER, + constraint = DeltaConstraint.FARTHER, + ) + }, + ) + } + + override val onPrimary: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "on_primary", + palette = { scheme -> scheme.primaryPalette }, + background = { scheme -> if (scheme.platform == Platform.PHONE) primary else primaryDim }, + contrastCurve = { scheme -> + if (scheme.platform == Platform.PHONE) getContrastCurve(6.0) else getContrastCurve(7.0) + }, + ) + return baseSpec.onPrimary.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val primaryContainer: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "primary_container", + palette = { scheme -> scheme.primaryPalette }, + tone = { scheme -> + when { + scheme.platform == Platform.WATCH -> 30.0 + scheme.variant == Variant.NEUTRAL -> if (scheme.isDark) 30.0 else 90.0 + scheme.variant == Variant.TONAL_SPOT -> + if (scheme.isDark) { + tMinC(scheme.primaryPalette, 35.0, 93.0) + } else { + tMaxC(scheme.primaryPalette, 0.0, 90.0) + } + scheme.variant == Variant.EXPRESSIVE -> + if (scheme.isDark) { + tMaxC(scheme.primaryPalette, 30.0, 93.0) + } else { + tMaxC( + scheme.primaryPalette, + 78.0, + if (Hct.isCyan(scheme.primaryPalette.hue)) 88.0 else 90.0, + ) + } + else -> { // VIBRANT + if (scheme.isDark) { + tMinC(scheme.primaryPalette, 66.0, 93.0) + } else { + tMaxC( + scheme.primaryPalette, + 66.0, + if (Hct.isCyan(scheme.primaryPalette.hue)) 88.0 else 93.0, + ) + } + } + } + }, + isBackground = true, + background = { scheme -> + if (scheme.platform == Platform.PHONE) { + if (scheme.isDark) surfaceBright else surfaceDim + } else { + null + } + }, + toneDeltaPair = { scheme -> + if (scheme.platform == Platform.WATCH) { + ToneDeltaPair( + roleA = primaryContainer, + roleB = primaryDim, + delta = 10.0, + polarity = TonePolarity.DARKER, + constraint = DeltaConstraint.FARTHER, + ) + } else { + null + } + }, + contrastCurve = { scheme -> + if (scheme.platform == Platform.PHONE && scheme.contrastLevel > 0) { + getContrastCurve(1.5) + } else { + null + } + }, + ) + return baseSpec.primaryContainer.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val onPrimaryContainer: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "on_primary_container", + palette = { scheme -> scheme.primaryPalette }, + background = { primaryContainer }, + contrastCurve = { scheme -> + if (scheme.platform == Platform.PHONE) getContrastCurve(6.0) else getContrastCurve(7.0) + }, + ) + return baseSpec.onPrimaryContainer.extendSpecVersion( + ColorSpec.SpecVersion.SPEC_2025, + color2025, + ) + } + + override val inversePrimary: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "inverse_primary", + palette = { scheme -> scheme.primaryPalette }, + tone = { scheme -> tMaxC(scheme.primaryPalette) }, + background = { inverseSurface }, + contrastCurve = { scheme -> + if (scheme.platform == Platform.PHONE) getContrastCurve(6.0) else getContrastCurve(7.0) + }, + ) + return baseSpec.inversePrimary.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + // //////////////////////////////////////////////////////////////// + // Secondaries [Q] // + // //////////////////////////////////////////////////////////////// + override val secondary: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "secondary", + palette = { scheme -> scheme.secondaryPalette }, + tone = { scheme -> + when { + scheme.platform == Platform.WATCH -> + if (scheme.variant == Variant.NEUTRAL) { + 90.0 + } else { + tMaxC(scheme.secondaryPalette, 0.0, 90.0) + } + scheme.variant == Variant.NEUTRAL -> + if (scheme.isDark) { + tMinC(scheme.secondaryPalette, 0.0, 98.0) + } else { + tMaxC(scheme.secondaryPalette) + } + scheme.variant == Variant.VIBRANT -> + tMaxC(scheme.secondaryPalette, 0.0, if (scheme.isDark) 90.0 else 98.0) + else -> { // EXPRESSIVE and TONAL_SPOT + if (scheme.isDark) 80.0 else tMaxC(scheme.secondaryPalette) + } + } + }, + isBackground = true, + background = { scheme -> + if (scheme.platform == Platform.PHONE) { + if (scheme.isDark) surfaceBright else surfaceDim + } else { + surfaceContainerHigh + } + }, + contrastCurve = { scheme -> + if (scheme.platform == Platform.PHONE) getContrastCurve(4.5) else getContrastCurve(7.0) + }, + toneDeltaPair = { scheme -> + if (scheme.platform == Platform.PHONE) { + ToneDeltaPair( + roleA = secondaryContainer, + roleB = secondary, + delta = 5.0, + polarity = TonePolarity.RELATIVE_LIGHTER, + constraint = DeltaConstraint.FARTHER, + ) + } else { + null + } + }, + ) + return baseSpec.secondary.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val secondaryDim: DynamicColor? + get() { + return DynamicColor( + name = "secondary_dim", + palette = { scheme -> scheme.secondaryPalette }, + tone = { scheme -> + if (scheme.variant == Variant.NEUTRAL) { + 85.0 + } else { + tMaxC(scheme.secondaryPalette, 0.0, 90.0) + } + }, + isBackground = true, + background = { surfaceContainerHigh }, + contrastCurve = { getContrastCurve(4.5) }, + toneDeltaPair = { + ToneDeltaPair( + roleA = secondaryDim!!, + roleB = secondary, + delta = 5.0, + polarity = TonePolarity.DARKER, + constraint = DeltaConstraint.FARTHER, + ) + }, + ) + } + + override val onSecondary: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "on_secondary", + palette = { scheme -> scheme.secondaryPalette }, + background = { scheme -> + if (scheme.platform == Platform.PHONE) secondary else secondaryDim + }, + contrastCurve = { scheme -> + if (scheme.platform == Platform.PHONE) getContrastCurve(6.0) else getContrastCurve(7.0) + }, + ) + return baseSpec.onSecondary.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val secondaryContainer: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "secondary_container", + palette = { scheme -> scheme.secondaryPalette }, + tone = { scheme -> + when { + scheme.platform == Platform.WATCH -> 30.0 + scheme.variant == Variant.VIBRANT -> + if (scheme.isDark) { + tMinC(scheme.secondaryPalette, 30.0, 40.0) + } else { + tMaxC(scheme.secondaryPalette, 84.0, 90.0) + } + scheme.variant == Variant.EXPRESSIVE -> + if (scheme.isDark) 15.0 else tMaxC(scheme.secondaryPalette, 90.0, 95.0) + else -> if (scheme.isDark) 25.0 else 90.0 + } + }, + isBackground = true, + background = { scheme -> + if (scheme.platform == Platform.PHONE) { + if (scheme.isDark) surfaceBright else surfaceDim + } else { + null + } + }, + toneDeltaPair = { scheme -> + if (scheme.platform == Platform.WATCH) { + ToneDeltaPair( + roleA = secondaryContainer, + roleB = secondaryDim!!, + delta = 10.0, + polarity = TonePolarity.DARKER, + constraint = DeltaConstraint.FARTHER, + ) + } else { + null + } + }, + contrastCurve = { scheme -> + if (scheme.platform == Platform.PHONE && scheme.contrastLevel > 0) { + getContrastCurve(1.5) + } else { + null + } + }, + ) + return baseSpec.secondaryContainer.extendSpecVersion( + ColorSpec.SpecVersion.SPEC_2025, + color2025, + ) + } + + override val onSecondaryContainer: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "on_secondary_container", + palette = { scheme -> scheme.secondaryPalette }, + background = { secondaryContainer }, + contrastCurve = { scheme -> + if (scheme.platform == Platform.PHONE) getContrastCurve(6.0) else getContrastCurve(7.0) + }, + ) + return baseSpec.onSecondaryContainer.extendSpecVersion( + ColorSpec.SpecVersion.SPEC_2025, + color2025, + ) + } + + // //////////////////////////////////////////////////////////////// + // Tertiaries [T] // + // //////////////////////////////////////////////////////////////// + override val tertiary: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "tertiary", + palette = { scheme -> scheme.tertiaryPalette }, + tone = { scheme -> + when { + scheme.platform == Platform.WATCH -> + if (scheme.variant == Variant.TONAL_SPOT) { + tMaxC(scheme.tertiaryPalette, 0.0, 90.0) + } else { + tMaxC(scheme.tertiaryPalette) + } + scheme.variant == Variant.EXPRESSIVE || scheme.variant == Variant.VIBRANT -> + tMaxC( + scheme.tertiaryPalette, + lowerBound = 0.0, + upperBound = + if (Hct.isCyan(scheme.tertiaryPalette.hue)) { + 88.0 + } else if (scheme.isDark) { + 98.0 + } else { + 100.0 + }, + ) + else -> { // NEUTRAL and TONAL_SPOT + if (scheme.isDark) { + tMaxC(scheme.tertiaryPalette, 0.0, 98.0) + } else { + tMaxC(scheme.tertiaryPalette) + } + } + } + }, + isBackground = true, + background = { scheme -> + if (scheme.platform == Platform.PHONE) { + if (scheme.isDark) surfaceBright else surfaceDim + } else { + surfaceContainerHigh + } + }, + contrastCurve = { scheme -> + if (scheme.platform == Platform.PHONE) getContrastCurve(4.5) else getContrastCurve(7.0) + }, + toneDeltaPair = { scheme -> + if (scheme.platform == Platform.PHONE) { + ToneDeltaPair( + roleA = tertiaryContainer, + roleB = tertiary, + delta = 5.0, + polarity = TonePolarity.RELATIVE_LIGHTER, + constraint = DeltaConstraint.FARTHER, + ) + } else { + null + } + }, + ) + return baseSpec.tertiary.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val tertiaryDim: DynamicColor? + get() { + return DynamicColor( + name = "tertiary_dim", + palette = { scheme -> scheme.tertiaryPalette }, + tone = { scheme -> + if (scheme.variant == Variant.TONAL_SPOT) { + tMaxC(scheme.tertiaryPalette, 0.0, 90.0) + } else { + tMaxC(scheme.tertiaryPalette) + } + }, + isBackground = true, + background = { surfaceContainerHigh }, + contrastCurve = { getContrastCurve(4.5) }, + toneDeltaPair = { + ToneDeltaPair( + roleA = tertiaryDim!!, + roleB = tertiary, + delta = 5.0, + polarity = TonePolarity.DARKER, + constraint = DeltaConstraint.FARTHER, + ) + }, + ) + } + + override val onTertiary: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "on_tertiary", + palette = { scheme -> scheme.tertiaryPalette }, + background = { scheme -> + if (scheme.platform == Platform.PHONE) tertiary else tertiaryDim + }, + contrastCurve = { scheme -> + if (scheme.platform == Platform.PHONE) getContrastCurve(6.0) else getContrastCurve(7.0) + }, + ) + return baseSpec.onTertiary.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val tertiaryContainer: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "tertiary_container", + palette = { scheme -> scheme.tertiaryPalette }, + tone = { scheme -> + when { + scheme.platform == Platform.WATCH -> + if (scheme.variant == Variant.TONAL_SPOT) { + tMaxC(scheme.tertiaryPalette, 0.0, 90.0) + } else { + tMaxC(scheme.tertiaryPalette) + } + scheme.variant == Variant.NEUTRAL -> + if (scheme.isDark) { + tMaxC(scheme.tertiaryPalette, 0.0, 93.0) + } else { + tMaxC(scheme.tertiaryPalette, 0.0, 96.0) + } + scheme.variant == Variant.TONAL_SPOT -> + tMaxC(scheme.tertiaryPalette, 0.0, if (scheme.isDark) 93.0 else 100.0) + scheme.variant == Variant.EXPRESSIVE -> + tMaxC( + scheme.tertiaryPalette, + lowerBound = 75.0, + upperBound = + if (Hct.isCyan(scheme.tertiaryPalette.hue)) { + 88.0 + } else if (scheme.isDark) { + 93.0 + } else { + 100.0 + }, + ) + else -> { // VIBRANT + if (scheme.isDark) { + tMaxC(scheme.tertiaryPalette, 0.0, 93.0) + } else { + tMaxC(scheme.tertiaryPalette, 72.0, 100.0) + } + } + } + }, + isBackground = true, + background = { scheme -> + if (scheme.platform == Platform.PHONE) { + if (scheme.isDark) surfaceBright else surfaceDim + } else { + null + } + }, + toneDeltaPair = { scheme -> + if (scheme.platform == Platform.WATCH) { + ToneDeltaPair( + roleA = tertiaryContainer, + roleB = tertiaryDim!!, + delta = 10.0, + polarity = TonePolarity.DARKER, + constraint = DeltaConstraint.FARTHER, + ) + } else { + null + } + }, + contrastCurve = { scheme -> + if (scheme.platform == Platform.PHONE && scheme.contrastLevel > 0) { + getContrastCurve(1.5) + } else { + null + } + }, + ) + return baseSpec.tertiaryContainer.extendSpecVersion( + ColorSpec.SpecVersion.SPEC_2025, + color2025, + ) + } + + override val onTertiaryContainer: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "on_tertiary_container", + palette = { scheme -> scheme.tertiaryPalette }, + background = { tertiaryContainer }, + contrastCurve = { scheme -> + if (scheme.platform == Platform.PHONE) getContrastCurve(6.0) else getContrastCurve(7.0) + }, + ) + return baseSpec.onTertiaryContainer.extendSpecVersion( + ColorSpec.SpecVersion.SPEC_2025, + color2025, + ) + } + + // //////////////////////////////////////////////////////////////// + // Errors [E] // + // //////////////////////////////////////////////////////////////// + override val error: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "error", + palette = { scheme -> scheme.errorPalette }, + tone = { scheme -> + if (scheme.platform == Platform.PHONE) { + if (scheme.isDark) { + tMinC(scheme.errorPalette, 0.0, 98.0) + } else { + tMaxC(scheme.errorPalette) + } + } else { + tMinC(scheme.errorPalette) + } + }, + isBackground = true, + background = { scheme -> + if (scheme.platform == Platform.PHONE) { + if (scheme.isDark) surfaceBright else surfaceDim + } else { + surfaceContainerHigh + } + }, + contrastCurve = { scheme -> + if (scheme.platform == Platform.PHONE) getContrastCurve(4.5) else getContrastCurve(7.0) + }, + toneDeltaPair = { scheme -> + if (scheme.platform == Platform.PHONE) { + ToneDeltaPair( + roleA = errorContainer, + roleB = error, + delta = 5.0, + polarity = TonePolarity.RELATIVE_LIGHTER, + constraint = DeltaConstraint.FARTHER, + ) + } else { + null + } + }, + ) + return baseSpec.error.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val errorDim: DynamicColor? + get() { + return DynamicColor( + name = "error_dim", + palette = { scheme -> scheme.errorPalette }, + tone = { scheme -> tMinC(scheme.errorPalette) }, + isBackground = true, + background = { surfaceContainerHigh }, + contrastCurve = { getContrastCurve(4.5) }, + toneDeltaPair = { + ToneDeltaPair( + roleA = errorDim!!, + roleB = error, + delta = 5.0, + polarity = TonePolarity.DARKER, + constraint = DeltaConstraint.FARTHER, + ) + }, + ) + } + + override val onError: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "on_error", + palette = { scheme -> scheme.errorPalette }, + background = { scheme -> if (scheme.platform == Platform.PHONE) error else errorDim }, + contrastCurve = { scheme -> + if (scheme.platform == Platform.PHONE) getContrastCurve(6.0) else getContrastCurve(7.0) + }, + ) + return baseSpec.onError.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val errorContainer: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "error_container", + palette = { scheme -> scheme.errorPalette }, + tone = { scheme -> + if (scheme.platform == Platform.WATCH) { + 30.0 + } else { + if (scheme.isDark) { + tMinC(scheme.errorPalette, 30.0, 93.0) + } else { + tMaxC(scheme.errorPalette, 0.0, 90.0) + } + } + }, + isBackground = true, + background = { scheme -> + if (scheme.platform == Platform.PHONE) { + if (scheme.isDark) surfaceBright else surfaceDim + } else { + null + } + }, + toneDeltaPair = { scheme -> + if (scheme.platform == Platform.WATCH) { + ToneDeltaPair( + roleA = errorContainer, + roleB = errorDim!!, + delta = 10.0, + polarity = TonePolarity.DARKER, + constraint = DeltaConstraint.FARTHER, + ) + } else { + null + } + }, + contrastCurve = { scheme -> + if (scheme.platform == Platform.PHONE && scheme.contrastLevel > 0) { + getContrastCurve(1.5) + } else { + null + } + }, + ) + return baseSpec.errorContainer.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val onErrorContainer: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "on_error_container", + palette = { scheme -> scheme.errorPalette }, + background = { errorContainer }, + contrastCurve = { scheme -> + if (scheme.platform == Platform.PHONE) getContrastCurve(4.5) else getContrastCurve(7.0) + }, + ) + return baseSpec.onErrorContainer.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + // //////////////////////////////////////////////////////////////// + // Primary Fixed Colors [PF] // + // //////////////////////////////////////////////////////////////// + override val primaryFixed: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "primary_fixed", + palette = { scheme -> scheme.primaryPalette }, + tone = { scheme -> + val tempS = DynamicScheme.from(scheme, isDark = false, contrastLevel = 0.0) + primaryContainer.getTone(tempS) + }, + isBackground = true, + background = { scheme -> + if (scheme.platform == Platform.PHONE) { + if (scheme.isDark) surfaceBright else surfaceDim + } else { + null + } + }, + contrastCurve = { scheme -> + if (scheme.platform == Platform.PHONE && scheme.contrastLevel > 0) { + getContrastCurve(1.5) + } else { + null + } + }, + ) + return baseSpec.primaryFixed.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val primaryFixedDim: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "primary_fixed_dim", + palette = { scheme -> scheme.primaryPalette }, + tone = { scheme -> primaryFixed.getTone(scheme) }, + isBackground = true, + toneDeltaPair = { + ToneDeltaPair( + roleA = primaryFixedDim, + roleB = primaryFixed, + delta = 5.0, + polarity = TonePolarity.DARKER, + constraint = DeltaConstraint.EXACT, + ) + }, + ) + return baseSpec.primaryFixedDim.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val onPrimaryFixed: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "on_primary_fixed", + palette = { scheme -> scheme.primaryPalette }, + background = { primaryFixedDim }, + contrastCurve = { getContrastCurve(7.0) }, + ) + return baseSpec.onPrimaryFixed.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val onPrimaryFixedVariant: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "on_primary_fixed_variant", + palette = { scheme -> scheme.primaryPalette }, + background = { primaryFixedDim }, + contrastCurve = { getContrastCurve(4.5) }, + ) + return baseSpec.onPrimaryFixedVariant.extendSpecVersion( + ColorSpec.SpecVersion.SPEC_2025, + color2025, + ) + } + + // //////////////////////////////////////////////////////////////// + // Secondary Fixed Colors [QF] // + // //////////////////////////////////////////////////////////////// + override val secondaryFixed: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "secondary_fixed", + palette = { scheme -> scheme.secondaryPalette }, + tone = { scheme -> + val tempS = DynamicScheme.from(scheme, isDark = false, contrastLevel = 0.0) + secondaryContainer.getTone(tempS) + }, + isBackground = true, + background = { scheme -> + if (scheme.platform == Platform.PHONE) { + if (scheme.isDark) surfaceBright else surfaceDim + } else { + null + } + }, + contrastCurve = { scheme -> + if (scheme.platform == Platform.PHONE && scheme.contrastLevel > 0) { + getContrastCurve(1.5) + } else { + null + } + }, + ) + return baseSpec.secondaryFixed.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val secondaryFixedDim: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "secondary_fixed_dim", + palette = { scheme -> scheme.secondaryPalette }, + tone = { scheme -> secondaryFixed.getTone(scheme) }, + isBackground = true, + toneDeltaPair = { + ToneDeltaPair( + roleA = secondaryFixedDim, + roleB = secondaryFixed, + delta = 5.0, + polarity = TonePolarity.DARKER, + constraint = DeltaConstraint.EXACT, + ) + }, + ) + return baseSpec.secondaryFixedDim.extendSpecVersion( + ColorSpec.SpecVersion.SPEC_2025, + color2025, + ) + } + + override val onSecondaryFixed: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "on_secondary_fixed", + palette = { scheme -> scheme.secondaryPalette }, + background = { secondaryFixedDim }, + contrastCurve = { getContrastCurve(7.0) }, + ) + return baseSpec.onSecondaryFixed.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val onSecondaryFixedVariant: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "on_secondary_fixed_variant", + palette = { scheme -> scheme.secondaryPalette }, + background = { secondaryFixedDim }, + contrastCurve = { getContrastCurve(4.5) }, + ) + return baseSpec.onSecondaryFixedVariant.extendSpecVersion( + ColorSpec.SpecVersion.SPEC_2025, + color2025, + ) + } + + // //////////////////////////////////////////////////////////////// + // Tertiary Fixed Colors [TF] // + // //////////////////////////////////////////////////////////////// + override val tertiaryFixed: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "tertiary_fixed", + palette = { scheme -> scheme.tertiaryPalette }, + tone = { scheme -> + val tempS = DynamicScheme.from(scheme, isDark = false, contrastLevel = 0.0) + tertiaryContainer.getTone(tempS) + }, + isBackground = true, + background = { scheme -> + if (scheme.platform == Platform.PHONE) { + if (scheme.isDark) surfaceBright else surfaceDim + } else { + null + } + }, + contrastCurve = { scheme -> + if (scheme.platform == Platform.PHONE && scheme.contrastLevel > 0) { + getContrastCurve(1.5) + } else { + null + } + }, + ) + return baseSpec.tertiaryFixed.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val tertiaryFixedDim: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "tertiary_fixed_dim", + palette = { scheme -> scheme.tertiaryPalette }, + tone = { scheme -> tertiaryFixed.getTone(scheme) }, + isBackground = true, + toneDeltaPair = { + ToneDeltaPair( + roleA = tertiaryFixedDim, + roleB = tertiaryFixed, + delta = 5.0, + polarity = TonePolarity.DARKER, + constraint = DeltaConstraint.EXACT, + ) + }, + ) + return baseSpec.tertiaryFixedDim.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val onTertiaryFixed: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "on_tertiary_fixed", + palette = { scheme -> scheme.tertiaryPalette }, + background = { tertiaryFixedDim }, + contrastCurve = { getContrastCurve(7.0) }, + ) + return baseSpec.onTertiaryFixed.extendSpecVersion(ColorSpec.SpecVersion.SPEC_2025, color2025) + } + + override val onTertiaryFixedVariant: DynamicColor + get() { + val color2025 = + DynamicColor( + name = "on_tertiary_fixed_variant", + palette = { scheme -> scheme.tertiaryPalette }, + background = { tertiaryFixedDim }, + contrastCurve = { getContrastCurve(4.5) }, + ) + return baseSpec.onTertiaryFixedVariant.extendSpecVersion( + ColorSpec.SpecVersion.SPEC_2025, + color2025, + ) + } + + // ///////////////////////////////////////////////////////////////// + // Color value calculations // + // ///////////////////////////////////////////////////////////////// + override fun getHct(scheme: DynamicScheme, color: DynamicColor): Hct { + // This is crucial for aesthetics: we aren't simply the taking the standard color + // and changing its tone for contrast. Rather, we find the tone for contrast, then + // use the specified chroma from the palette to construct a new color. + // + // For example, this enables colors with standard tone of T90, which has limited chroma, to + // "recover" intended chroma as contrast increases. + val palette = color.palette.invoke(scheme) + val tone = getTone(scheme, color) + val hue = palette.hue + val chromaMultiplier = color.chromaMultiplier?.invoke(scheme) ?: 1.0 + val chroma = palette.chroma * chromaMultiplier + return Hct.from(hue, chroma, tone) + } + + override fun getTone(scheme: DynamicScheme, color: DynamicColor): Double { + val toneDeltaPair = color.toneDeltaPair?.invoke(scheme) + + // Case 0: tone delta pair. + if (toneDeltaPair != null) { + val roleA = toneDeltaPair.roleA + val roleB = toneDeltaPair.roleB + val polarity = toneDeltaPair.polarity + val constraint = toneDeltaPair.constraint + val absoluteDelta = + if ( + polarity == TonePolarity.DARKER || + (polarity == TonePolarity.RELATIVE_LIGHTER && scheme.isDark) || + (polarity == TonePolarity.RELATIVE_DARKER && !scheme.isDark) + ) { + -toneDeltaPair.delta + } else { + toneDeltaPair.delta + } + val amRoleA = color.name == roleA.name + val selfRole = if (amRoleA) roleA else roleB + val referenceRole = if (amRoleA) roleB else roleA + var selfTone = selfRole.tone.invoke(scheme) + val referenceTone = referenceRole.getTone(scheme) + val relativeDelta = absoluteDelta * (if (amRoleA) 1 else -1) + when (constraint) { + DeltaConstraint.EXACT -> selfTone = (referenceTone + relativeDelta).coerceIn(0.0, 100.0) + DeltaConstraint.NEARER -> + if (relativeDelta > 0) { + selfTone = + selfTone.coerceIn(referenceTone, referenceTone + relativeDelta).coerceIn(0.0, 100.0) + } else { + selfTone = + selfTone.coerceIn(referenceTone + relativeDelta, referenceTone).coerceIn(0.0, 100.0) + } + DeltaConstraint.FARTHER -> + if (relativeDelta > 0) { + selfTone = selfTone.coerceIn(referenceTone + relativeDelta, 100.0) + } else { + selfTone = selfTone.coerceIn(0.0, referenceTone + relativeDelta) + } + } + val background = color.background?.invoke(scheme) + val contrastCurve = color.contrastCurve?.invoke(scheme) + if (background != null && contrastCurve != null) { + val bgTone = background.getTone(scheme) + val selfContrast = contrastCurve.get(scheme.contrastLevel) + selfTone = + if ( + Contrast.ratioOfTones(bgTone, selfTone) >= selfContrast && scheme.contrastLevel >= 0 + ) { + selfTone + } else { + DynamicColor.foregroundTone(bgTone, selfContrast) + } + } + + // This can avoid the awkward tones for background colors including the access fixed colors. + // Accent fixed dim colors should not be adjusted. + if (color.isBackground && !color.name.endsWith("_fixed_dim")) { + selfTone = + if (selfTone >= 57) { + selfTone.coerceIn(65.0, 100.0) + } else { + selfTone.coerceIn(0.0, 49.0) + } + } + return selfTone + } else { + // Case 1: No tone delta pair; just solve for itself. + var answer = color.tone.invoke(scheme) + val background = color.background?.invoke(scheme) + val contrastCurve = color.contrastCurve?.invoke(scheme) + if (background == null || contrastCurve == null) { + return answer // No adjustment for colors with no background. + } + val bgTone = background.getTone(scheme) + val desiredRatio = contrastCurve.get(scheme.contrastLevel) + + // Recalculate the tone from desired contrast ratio if the current + // contrast ratio is not enough or desired contrast level is decreasing + // (<0). + answer = + if (Contrast.ratioOfTones(bgTone, answer) >= desiredRatio && scheme.contrastLevel >= 0) { + answer + } else { + DynamicColor.foregroundTone(bgTone, desiredRatio) + } + + // This can avoid the awkward tones for background colors including the access fixed colors. + // Accent fixed dim colors should not be adjusted. + if (color.isBackground && !color.name.endsWith("_fixed_dim")) { + answer = + if (answer >= 57) { + answer.coerceIn(65.0, 100.0) + } else { + answer.coerceIn(0.0, 49.0) + } + } + val secondBackground = color.secondBackground?.invoke(scheme) + if (secondBackground == null) { + return answer + } + + // Case 2: Adjust for dual backgrounds. + val bgTone1 = background.getTone(scheme) + val bgTone2 = secondBackground.getTone(scheme) + val upper = max(bgTone1, bgTone2) + val lower = min(bgTone1, bgTone2) + if ( + Contrast.ratioOfTones(upper, answer) >= desiredRatio && + Contrast.ratioOfTones(lower, answer) >= desiredRatio + ) { + return answer + } + + // The darkest light tone that satisfies the desired ratio, + // or -1 if such ratio cannot be reached. + val lightOption = Contrast.lighter(upper, desiredRatio) + + // The lightest dark tone that satisfies the desired ratio, + // or -1 if such ratio cannot be reached. + val darkOption = Contrast.darker(lower, desiredRatio) + + // Tones suitable for the foreground. + val availables = mutableListOf() + if (lightOption != null) { + availables.add(lightOption) + } + if (darkOption != null) { + availables.add(darkOption) + } + val prefersLight = + DynamicColor.tonePrefersLightForeground(bgTone1) || + DynamicColor.tonePrefersLightForeground(bgTone2) + if (prefersLight) { + return lightOption ?: 100.0 + } + return if (availables.size == 1) { + availables[0] + } else if (darkOption == null) { + 0.0 + } else { + darkOption + } + } + } + + // //////////////////////////////////////////////////////////////// + // Scheme Palettes // + // //////////////////////////////////////////////////////////////// + override fun getPrimaryPalette( + variant: Variant, + sourceColorHct: Hct, + isDark: Boolean, + platform: Platform, + contrastLevel: Double, + ): TonalPalette { + return when (variant) { + Variant.NEUTRAL -> + TonalPalette.fromHueAndChroma( + sourceColorHct.hue, + if (platform == Platform.PHONE) { + (if (Hct.isBlue(sourceColorHct.hue)) 12.0 else 8.0) + } else if (Hct.isBlue(sourceColorHct.hue)) { + 16.0 + } else { + 12.0 + }, + ) + Variant.TONAL_SPOT -> + TonalPalette.fromHueAndChroma( + sourceColorHct.hue, + if (platform == Platform.PHONE && isDark) 26.0 else 32.0, + ) + Variant.EXPRESSIVE -> + TonalPalette.fromHueAndChroma( + sourceColorHct.hue, + if (platform == Platform.PHONE) { + if (isDark) 36.0 else 48.0 + } else { + 40.0 + }, + ) + Variant.VIBRANT -> + TonalPalette.fromHueAndChroma( + sourceColorHct.hue, + if (platform == Platform.PHONE) 74.0 else 56.0, + ) + else -> baseSpec.getPrimaryPalette(variant, sourceColorHct, isDark, platform, contrastLevel) + } + } + + override fun getSecondaryPalette( + variant: Variant, + sourceColorHct: Hct, + isDark: Boolean, + platform: Platform, + contrastLevel: Double, + ): TonalPalette { + return when (variant) { + Variant.NEUTRAL -> + TonalPalette.fromHueAndChroma( + sourceColorHct.hue, + if (platform == Platform.PHONE) { + (if (Hct.isBlue(sourceColorHct.hue)) 6.0 else 4.0) + } else if (Hct.isBlue(sourceColorHct.hue)) { + 10.0 + } else { + 6.0 + }, + ) + Variant.TONAL_SPOT -> TonalPalette.fromHueAndChroma(sourceColorHct.hue, 16.0) + Variant.EXPRESSIVE -> + TonalPalette.fromHueAndChroma( + DynamicScheme.getRotatedHue( + sourceColorHct, + doubleArrayOf(0.0, 105.0, 140.0, 204.0, 253.0, 278.0, 300.0, 333.0, 360.0), + doubleArrayOf(-160.0, 155.0, -100.0, 96.0, -96.0, -156.0, -165.0, -160.0), + ), + if (platform == Platform.PHONE) { + if (isDark) 16.0 else 24.0 + } else { + 24.0 + }, + ) + Variant.VIBRANT -> + TonalPalette.fromHueAndChroma( + DynamicScheme.getRotatedHue( + sourceColorHct, + doubleArrayOf(0.0, 38.0, 105.0, 140.0, 333.0, 360.0), + doubleArrayOf(-14.0, 10.0, -14.0, 10.0, -14.0), + ), + if (platform == Platform.PHONE) 56.0 else 36.0, + ) + else -> baseSpec.getSecondaryPalette(variant, sourceColorHct, isDark, platform, contrastLevel) + } + } + + override fun getTertiaryPalette( + variant: Variant, + sourceColorHct: Hct, + isDark: Boolean, + platform: Platform, + contrastLevel: Double, + ): TonalPalette { + return when (variant) { + Variant.NEUTRAL -> + TonalPalette.fromHueAndChroma( + DynamicScheme.getRotatedHue( + sourceColorHct, + doubleArrayOf(0.0, 38.0, 105.0, 161.0, 204.0, 278.0, 333.0, 360.0), + doubleArrayOf(-32.0, 26.0, 10.0, -39.0, 24.0, -15.0, -32.0), + ), + if (platform == Platform.PHONE) 20.0 else 36.0, + ) + Variant.TONAL_SPOT -> + TonalPalette.fromHueAndChroma( + DynamicScheme.getRotatedHue( + sourceColorHct, + doubleArrayOf(0.0, 20.0, 71.0, 161.0, 333.0, 360.0), + doubleArrayOf(-40.0, 48.0, -32.0, 40.0, -32.0), + ), + if (platform == Platform.PHONE) 28.0 else 32.0, + ) + Variant.EXPRESSIVE -> + TonalPalette.fromHueAndChroma( + DynamicScheme.getRotatedHue( + sourceColorHct, + doubleArrayOf(0.0, 105.0, 140.0, 204.0, 253.0, 278.0, 300.0, 333.0, 360.0), + doubleArrayOf(-165.0, 160.0, -105.0, 101.0, -101.0, -160.0, -170.0, -165.0), + ), + 48.0, + ) + Variant.VIBRANT -> + TonalPalette.fromHueAndChroma( + DynamicScheme.getRotatedHue( + sourceColorHct, + doubleArrayOf(0.0, 38.0, 71.0, 105.0, 140.0, 161.0, 253.0, 333.0, 360.0), + doubleArrayOf(-72.0, 35.0, 24.0, -24.0, 62.0, 50.0, 62.0, -72.0), + ), + 56.0, + ) + else -> baseSpec.getTertiaryPalette(variant, sourceColorHct, isDark, platform, contrastLevel) + } + } + + override fun getNeutralPalette( + variant: Variant, + sourceColorHct: Hct, + isDark: Boolean, + platform: Platform, + contrastLevel: Double, + ): TonalPalette { + return when (variant) { + Variant.NEUTRAL -> + TonalPalette.fromHueAndChroma( + sourceColorHct.hue, + if (platform == Platform.PHONE) 1.4 else 6.0, + ) + Variant.TONAL_SPOT -> + TonalPalette.fromHueAndChroma( + sourceColorHct.hue, + if (platform == Platform.PHONE) 5.0 else 10.0, + ) + Variant.EXPRESSIVE -> + TonalPalette.fromHueAndChroma( + getExpressiveNeutralHue(sourceColorHct), + getExpressiveNeutralChroma(sourceColorHct, isDark, platform), + ) + Variant.VIBRANT -> + TonalPalette.fromHueAndChroma( + getVibrantNeutralHue(sourceColorHct), + getVibrantNeutralChroma(sourceColorHct, platform), + ) + else -> baseSpec.getNeutralPalette(variant, sourceColorHct, isDark, platform, contrastLevel) + } + } + + override fun getNeutralVariantPalette( + variant: Variant, + sourceColorHct: Hct, + isDark: Boolean, + platform: Platform, + contrastLevel: Double, + ): TonalPalette { + when (variant) { + Variant.NEUTRAL -> + return TonalPalette.fromHueAndChroma( + sourceColorHct.hue, + (if (platform == Platform.PHONE) 1.4 else 6.0) * 2.2, + ) + Variant.TONAL_SPOT -> + return TonalPalette.fromHueAndChroma( + sourceColorHct.hue, + (if (platform == Platform.PHONE) 5.0 else 10.0) * 1.7, + ) + Variant.EXPRESSIVE -> { + val expressiveNeutralHue = getExpressiveNeutralHue(sourceColorHct) + val expressiveNeutralChroma = getExpressiveNeutralChroma(sourceColorHct, isDark, platform) + return TonalPalette.fromHueAndChroma( + expressiveNeutralHue, + expressiveNeutralChroma * + if (expressiveNeutralHue >= 105 && expressiveNeutralHue < 125) 1.6 else 2.3, + ) + } + Variant.VIBRANT -> { + val vibrantNeutralHue = getVibrantNeutralHue(sourceColorHct) + val vibrantNeutralChroma = getVibrantNeutralChroma(sourceColorHct, platform) + return TonalPalette.fromHueAndChroma(vibrantNeutralHue, vibrantNeutralChroma * 1.29) + } + else -> + return baseSpec.getNeutralVariantPalette( + variant, + sourceColorHct, + isDark, + platform, + contrastLevel, + ) + } + } + + override fun getErrorPalette( + variant: Variant, + sourceColorHct: Hct, + isDark: Boolean, + platform: Platform, + contrastLevel: Double, + ): TonalPalette { + val errorHue = + DynamicScheme.getPiecewiseValue( + sourceColorHct, + doubleArrayOf(0.0, 3.0, 13.0, 23.0, 33.0, 43.0, 153.0, 273.0, 360.0), + doubleArrayOf(12.0, 22.0, 32.0, 12.0, 22.0, 32.0, 22.0, 12.0), + ) + return when (variant) { + Variant.NEUTRAL -> + TonalPalette.fromHueAndChroma(errorHue, if (platform == Platform.PHONE) 50.0 else 40.0) + Variant.TONAL_SPOT -> + TonalPalette.fromHueAndChroma(errorHue, if (platform == Platform.PHONE) 60.0 else 48.0) + Variant.EXPRESSIVE -> + TonalPalette.fromHueAndChroma(errorHue, if (platform == Platform.PHONE) 64.0 else 48.0) + Variant.VIBRANT -> + TonalPalette.fromHueAndChroma(errorHue, if (platform == Platform.PHONE) 80.0 else 60.0) + else -> baseSpec.getErrorPalette(variant, sourceColorHct, isDark, platform, contrastLevel) + } + } + + private fun getExpressiveNeutralHue(sourceColorHct: Hct): Double { + return DynamicScheme.getRotatedHue( + sourceColorHct, + doubleArrayOf(0.0, 71.0, 124.0, 253.0, 278.0, 300.0, 360.0), + doubleArrayOf(10.0, 0.0, 10.0, 0.0, 10.0, 0.0), + ) + } + + private fun getExpressiveNeutralChroma( + sourceColorHct: Hct, + isDark: Boolean, + platform: Platform, + ): Double { + val neutralHue = getExpressiveNeutralHue(sourceColorHct) + return if (platform == Platform.PHONE) { + if (isDark) { + if (Hct.isYellow(neutralHue)) 6.0 else 14.0 + } else { + 18.0 + } + } else { + 12.0 + } + } + + private fun getVibrantNeutralHue(sourceColorHct: Hct): Double { + return DynamicScheme.getRotatedHue( + sourceColorHct, + doubleArrayOf(0.0, 38.0, 105.0, 140.0, 333.0, 360.0), + doubleArrayOf(-14.0, 10.0, -14.0, 10.0, -14.0), + ) + } + + private fun getVibrantNeutralChroma(sourceColorHct: Hct, platform: Platform): Double { + val neutralHue = getVibrantNeutralHue(sourceColorHct) + return if (platform == Platform.PHONE) 28.0 else if (Hct.isBlue(neutralHue)) 28.0 else 20.0 + } + + private fun tMaxC( + palette: TonalPalette, + lowerBound: Double = 0.0, + upperBound: Double = 100.0, + chromaMultiplier: Double = 1.0, + ): Double { + val answer = findBestToneForChroma(palette.hue, palette.chroma * chromaMultiplier, 100.0, true) + return answer.coerceIn(lowerBound, upperBound) + } + + private fun tMinC( + palette: TonalPalette, + lowerBound: Double = 0.0, + upperBound: Double = 100.0, + ): Double { + val answer = findBestToneForChroma(palette.hue, palette.chroma, 0.0, false) + return answer.coerceIn(lowerBound, upperBound) + } + + private fun findBestToneForChroma( + hue: Double, + chroma: Double, + tone: Double, + byDecreasingTone: Boolean, + ): Double { + var tone = tone + var answer = tone + var bestCandidate = Hct.from(hue, chroma, answer) + while (bestCandidate.chroma < chroma) { + if (tone < 0 || tone > 100) { + break + } + tone += if (byDecreasingTone) -1.0 else 1.0 + val newCandidate = Hct.from(hue, chroma, tone) + if (bestCandidate.chroma < newCandidate.chroma) { + bestCandidate = newCandidate + answer = tone + } + } + return answer + } + + private fun getContrastCurve(defaultContrast: Double): ContrastCurve { + return when (defaultContrast) { + 1.5 -> ContrastCurve(1.5, 1.5, 3.0, 5.5) + 3.0 -> ContrastCurve(3.0, 3.0, 4.5, 7.0) + 4.5 -> ContrastCurve(4.5, 4.5, 7.0, 11.0) + 6.0 -> ContrastCurve(6.0, 6.0, 7.0, 11.0) + 7.0 -> ContrastCurve(7.0, 7.0, 11.0, 21.0) + 9.0 -> ContrastCurve(9.0, 9.0, 11.0, 21.0) + 11.0 -> ContrastCurve(11.0, 11.0, 21.0, 21.0) + 21.0 -> ContrastCurve(21.0, 21.0, 21.0, 21.0) + else -> ContrastCurve(defaultContrast, defaultContrast, 7.0, 21.0) + } + } +} diff --git a/libs/material-color-utilities/src/main/java/dynamiccolor/ColorSpecs.kt b/libs/material-color-utilities/src/main/java/dynamiccolor/ColorSpecs.kt new file mode 100644 index 000000000..91962609c --- /dev/null +++ b/libs/material-color-utilities/src/main/java/dynamiccolor/ColorSpecs.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dynamiccolor + +import dynamiccolor.ColorSpec.SpecVersion + +/** A utility class to get the correct color spec for a given spec version. */ +object ColorSpecs { + private val SPEC_2021: ColorSpec = ColorSpec2021() + private val SPEC_2025: ColorSpec = ColorSpec2025() + + @JvmStatic + fun get(): ColorSpec { + return get(SpecVersion.SPEC_2021) + } + + @JvmStatic + fun get(specVersion: SpecVersion): ColorSpec { + return get(specVersion, false) + } + + @JvmStatic + fun get(specVersion: SpecVersion, isExtendedFidelity: Boolean): ColorSpec { + return if (specVersion == SpecVersion.SPEC_2025) SPEC_2025 else SPEC_2021 + } +} diff --git a/libs/material-color-utilities/src/main/java/dynamiccolor/ContrastCurve.kt b/libs/material-color-utilities/src/main/java/dynamiccolor/ContrastCurve.kt new file mode 100644 index 000000000..e31320a0f --- /dev/null +++ b/libs/material-color-utilities/src/main/java/dynamiccolor/ContrastCurve.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dynamiccolor + +import utils.MathUtils + +/** + * A class containing a value that changes with the contrast level. + * + * Usually represents the contrast requirements for a dynamic color on its background. The four + * values correspond to values for contrast levels -1.0, 0.0, 0.5, and 1.0, respectively. + * + * @property low Value for contrast level -1.0 + * @property normal Value for contrast level 0.0 + * @property medium Value for contrast level 0.5 + * @property high Value for contrast level 1.0 + */ +class ContrastCurve(val low: Double, val normal: Double, val medium: Double, val high: Double) { + /** + * Returns the value at a given contrast level. + * + * @param contrastLevel The contrast level. 0.0 is the default (normal); -1.0 is the lowest; 1.0 + * is the highest. + * @return The value. For contrast ratios, a number between 1.0 and 21.0. + */ + fun get(contrastLevel: Double): Double { + return when { + contrastLevel <= -1.0 -> low + contrastLevel < 0.0 -> MathUtils.lerp(low, normal, contrastLevel + 1.0) + contrastLevel < 0.5 -> MathUtils.lerp(normal, medium, contrastLevel / 0.5) + contrastLevel < 1.0 -> MathUtils.lerp(medium, high, (contrastLevel - 0.5) / 0.5) + else -> high + } + } +} diff --git a/libs/material-color-utilities/src/main/java/dynamiccolor/DynamicColor.kt b/libs/material-color-utilities/src/main/java/dynamiccolor/DynamicColor.kt new file mode 100644 index 000000000..da68e9df1 --- /dev/null +++ b/libs/material-color-utilities/src/main/java/dynamiccolor/DynamicColor.kt @@ -0,0 +1,287 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dynamiccolor + +import contrast.Contrast +import dynamiccolor.ColorSpec.SpecVersion +import hct.Hct +import palettes.TonalPalette +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * A color that adjusts itself based on UI state, represented by DynamicScheme. + * + * This color automatically adjusts to accommodate a desired contrast level, or other adjustments + * such as differing in light mode versus dark mode, or what the theme is, or what the color that + * produced the theme is, etc. + * + * Colors without backgrounds do not change tone when contrast changes. Colors with backgrounds + * become closer to their background as contrast lowers, and further when contrast increases. + * + * For example, the default behavior of adjust tone at max contrast to be at a 7.0 ratio with its + * background is principled and matches accessibility guidance. That does not mean it's the desired + * approach for _every_ design system, and every color pairing, always, in every case. + * + * Ultimately, each component necessary for calculating a color, adjusting it for a desired contrast + * level, and ensuring it has a certain lightness/tone difference from another color, is provided by + * a function that takes a DynamicScheme and returns a value. This ensures ultimate flexibility, any + * desired behavior of a color for any design system, but is usually unnecessary. + * + * @param name The name of the dynamic color. + * @param palette Function that provides a TonalPalette given DynamicScheme. A TonalPalette is + * defined by a hue and chroma, so this replaces the need to specify hue/chroma. By providing a + * tonal palette, when contrast adjustments are made, intended chroma can be preserved. + * @param isBackground Whether this dynamic color is a background, with some other color as the + * foreground. + * @param chromaMultiplier Function that provides a chroma multiplier, given a DynamicScheme. + * @param background Function that provides a background color, given a DynamicScheme. + * @param tone Function that provides a tone, given a DynamicScheme. + * @param secondBackground Function that provides a second background color, given a DynamicScheme. + * @param contrastCurve Function that provides a contrast curve, given a DynamicScheme. + * @param toneDeltaPair Function that provides a tone delta pair, given a DynamicScheme. + * @param opacity Function that provides an opacity percentage, given a DynamicScheme. + */ +data class DynamicColor( + val name: String, + val palette: (DynamicScheme) -> TonalPalette, + val isBackground: Boolean = false, + val chromaMultiplier: ((DynamicScheme) -> Double)? = null, + val background: ((DynamicScheme) -> DynamicColor?)? = null, + val tone: (DynamicScheme) -> Double = getInitialToneFromBackground(background), + val secondBackground: ((DynamicScheme) -> DynamicColor?)? = null, + val contrastCurve: ((DynamicScheme) -> ContrastCurve?)? = null, + val toneDeltaPair: ((DynamicScheme) -> ToneDeltaPair?)? = null, + val opacity: ((DynamicScheme) -> Double?)? = null, +) { + init { + if (background == null && secondBackground != null) { + throw IllegalArgumentException( + "Color $name has secondBackground defined, but background is not defined." + ) + } + if (background == null && contrastCurve != null) { + throw IllegalArgumentException( + "Color $name has contrastCurve defined, but background is not defined." + ) + } + if (background != null && contrastCurve == null) { + throw IllegalArgumentException( + "Color $name has background defined, but contrastCurve is not defined." + ) + } + } + + private val hctCache = mutableMapOf() + + /** + * Returns an ARGB integer (i.e. a hex code). + * + * @param scheme Defines the conditions of the user interface, for example, whether or not it is + * dark mode or light mode, and what the desired contrast level is. + */ + fun getArgb(scheme: DynamicScheme): Int { + val argb = getHct(scheme).toInt() + val opacityPercentage = opacity?.invoke(scheme) + return if (opacityPercentage == null) { + argb + } else { + val alpha = (opacityPercentage * 255).roundToInt().coerceIn(0, 255) + (argb and 0x00ffffff) or (alpha shl 24) + } + } + + /** + * Returns an HCT object. + * + * @param scheme Defines the conditions of the user interface, for example, whether or not it is + * dark mode or light mode, and what the desired contrast level is. + */ + fun getHct(scheme: DynamicScheme): Hct { + val cachedAnswer = hctCache[scheme] + if (cachedAnswer != null) { + return cachedAnswer + } + val answer = ColorSpecs.get(scheme.specVersion).getHct(scheme, this) + // NOMUTANTS--trivial test with onerous dependency injection requirement. + if (hctCache.size > 4) { + hctCache.clear() + } + // NOMUTANTS--trivial test with onerous dependency injection requirement. + hctCache[scheme] = answer + return answer + } + + /** Returns the tone in HCT, ranging from 0 to 100, of the resolved color given scheme. */ + fun getTone(scheme: DynamicScheme): Double { + return ColorSpecs.get(scheme.specVersion).getTone(scheme, this) + } + + companion object { + /** + * Create a DynamicColor from a hex code. + * + * Result has no background; thus no support for increasing/decreasing contrast for a11y. + * + * @param name The name of the dynamic color. + * @param argb The source color from which to extract the hue and chroma. + */ + @JvmStatic + fun fromArgb(name: String, argb: Int): DynamicColor { + val hct = Hct.fromInt(argb) + val palette = TonalPalette.fromInt(argb) + return DynamicColor(name = name, palette = { palette }, tone = { hct.tone }) + } + + /** + * Given a background tone, find a foreground tone, while ensuring they reach a contrast ratio + * that is as close to ratio as possible. + */ + @JvmStatic + fun foregroundTone(bgTone: Double, ratio: Double): Double { + val lighterTone = Contrast.lighterUnsafe(bgTone, ratio) + val darkerTone = Contrast.darkerUnsafe(bgTone, ratio) + val lighterRatio = Contrast.ratioOfTones(lighterTone, bgTone) + val darkerRatio = Contrast.ratioOfTones(darkerTone, bgTone) + val preferLighter = tonePrefersLightForeground(bgTone) + if (preferLighter) { + // "Neglible difference" handles an edge case where the initial contrast ratio is high + // (ex. 13.0), and the ratio passed to the function is that high ratio, and both the lighter + // and darker ratio fails to pass that ratio. + // + // This was observed with Tonal Spot's On Primary Container turning black momentarily + // between + // high and max contrast in light mode. PC's standard tone was T90, OPC's was T10, it was + // light mode, and the contrast level was 0.6568521221032331. + val negligibleDifference = + abs(lighterRatio - darkerRatio) < 0.1 && lighterRatio < ratio && darkerRatio < ratio + return if (lighterRatio >= ratio || lighterRatio >= darkerRatio || negligibleDifference) { + lighterTone + } else { + darkerTone + } + } else { + return if (darkerRatio >= ratio || darkerRatio >= lighterRatio) darkerTone else lighterTone + } + } + + /** + * Adjust a tone down such that white has 4.5 contrast, if the tone is reasonably close to + * supporting it. + */ + @JvmStatic + fun enableLightForeground(tone: Double): Double { + return if (tonePrefersLightForeground(tone) && !toneAllowsLightForeground(tone)) { + 49.0 + } else { + tone + } + } + + /** + * People prefer white foregrounds on ~T60-70. Observed over time, and also by Andrew Somers + * during research for APCA. + * + * T60 used as to create the smallest discontinuity possible when skipping down to T49 in order + * to ensure light foregrounds. + * + * Since `tertiaryContainer` in dark monochrome scheme requires a tone of 60, it should not be + * adjusted. Therefore, 60 is excluded here. + */ + @JvmStatic + fun tonePrefersLightForeground(tone: Double): Boolean { + return tone.roundToInt() < 60 + } + + /** Tones less than ~T50 always permit white at 4.5 contrast. */ + @JvmStatic + fun toneAllowsLightForeground(tone: Double): Boolean { + return tone.roundToInt() <= 49 + } + + @JvmStatic + fun getInitialToneFromBackground( + background: ((DynamicScheme) -> DynamicColor?)? + ): (DynamicScheme) -> Double { + if (background == null) { + return { 50.0 } + } + return { scheme -> background(scheme)?.getTone(scheme) ?: 50.0 } + } + } +} + +fun DynamicColor.extendSpecVersion( + specVersion: SpecVersion, + extendedColor: DynamicColor, +): DynamicColor { + validateExtendedColor(specVersion, extendedColor) + return copy( + palette = { scheme -> + (if (scheme.specVersion == specVersion) extendedColor.palette else this.palette).invoke( + scheme + ) + }, + tone = { scheme -> + (if (scheme.specVersion == specVersion) extendedColor.tone else this.tone).invoke(scheme) + }, + chromaMultiplier = { scheme -> + (if (scheme.specVersion == specVersion) { + extendedColor.chromaMultiplier + } else { + this.chromaMultiplier + }) + ?.invoke(scheme) ?: 1.0 + }, + background = { scheme -> + (if (scheme.specVersion == specVersion) extendedColor.background else this.background) + ?.invoke(scheme) + }, + secondBackground = { scheme -> + (if (scheme.specVersion == specVersion) { + extendedColor.secondBackground + } else { + this.secondBackground + }) + ?.invoke(scheme) + }, + contrastCurve = { scheme -> + (if (scheme.specVersion == specVersion) extendedColor.contrastCurve else this.contrastCurve) + ?.invoke(scheme) + }, + toneDeltaPair = { scheme -> + (if (scheme.specVersion == specVersion) extendedColor.toneDeltaPair else this.toneDeltaPair) + ?.invoke(scheme) + }, + opacity = { scheme -> + (if (scheme.specVersion == specVersion) extendedColor.opacity else this.opacity)?.invoke( + scheme + ) + }, + ) +} + +private fun DynamicColor.validateExtendedColor( + specVersion: SpecVersion, + extendedColor: DynamicColor, +) { + require(this.name == extendedColor.name) { + "Attempting to extend color $name with color ${extendedColor.name} of different name for spec version $specVersion." + } + require(this.isBackground == extendedColor.isBackground) { + "Attempting to extend color $name as a ${if (isBackground) "background" else "foreground"} with color ${extendedColor.name} as a ${if (extendedColor.isBackground) "background" else "foreground"} for spec version $specVersion." + } +} diff --git a/libs/material-color-utilities/src/main/java/dynamiccolor/DynamicScheme.kt b/libs/material-color-utilities/src/main/java/dynamiccolor/DynamicScheme.kt new file mode 100644 index 000000000..23b76a24f --- /dev/null +++ b/libs/material-color-utilities/src/main/java/dynamiccolor/DynamicScheme.kt @@ -0,0 +1,373 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dynamiccolor + +import dynamiccolor.ColorSpec.SpecVersion +import hct.Hct +import palettes.TonalPalette +import utils.MathUtils +import java.text.DecimalFormat +import java.util.Locale +import kotlin.math.min + +/** + * Provides important settings for creating colors dynamically, and 6 color palettes. Requires: 1. A + * color. (source color) 2. A theme. (Variant) 3. Whether or not its dark mode. 4. Contrast level. + * (-1 to 1, currently contrast ratio 3.0 and 7.0) + */ +open class DynamicScheme( + /** The source color of the scheme in HCT format. */ + val sourceColorHct: Hct, + /** The variant of the scheme. */ + val variant: Variant, + /** Whether or not the scheme is dark mode. */ + val isDark: Boolean, + /** + * Value from -1 to 1. -1 represents minimum contrast. 0 represents standard (i.e. the design as + * spec'd), and 1 represents maximum contrast. + */ + val contrastLevel: Double, + /** The platform on which this scheme is intended to be used. */ + val platform: Platform = DEFAULT_PLATFORM, + /** The spec version of the scheme. */ + specVersion: SpecVersion = DEFAULT_SPEC_VERSION, + val primaryPalette: TonalPalette, + val secondaryPalette: TonalPalette, + val tertiaryPalette: TonalPalette, + val neutralPalette: TonalPalette, + val neutralVariantPalette: TonalPalette, + val errorPalette: TonalPalette, +) { + + /** The spec version of the scheme. */ + val specVersion: SpecVersion + + init { + this.specVersion = maybeFallbackSpecVersion(specVersion, variant) + } + + /** The source color of the scheme in ARGB format. */ + val sourceColorArgb: Int = sourceColorHct.toInt() + + /** The platform on which this scheme is intended to be used. */ + enum class Platform { + PHONE, + WATCH, + } + + fun getHct(dynamicColor: DynamicColor): Hct { + return dynamicColor.getHct(this) + } + + fun getArgb(dynamicColor: DynamicColor): Int { + return dynamicColor.getArgb(this) + } + + override fun toString(): String { + return "Scheme: variant=${variant.name}, mode=${if (isDark) "dark" else "light"}, platform=${platform.name.lowercase( + Locale.ENGLISH + )}, contrastLevel=${DecimalFormat("0.0").format(contrastLevel)}, seed=$sourceColorHct, specVersion=$specVersion" + } + + private val dynamicColors = MaterialDynamicColors() + + val primaryPaletteKeyColor: Int + get() = getArgb(dynamicColors.primaryPaletteKeyColor) + + val secondaryPaletteKeyColor: Int + get() = getArgb(dynamicColors.secondaryPaletteKeyColor) + + val tertiaryPaletteKeyColor: Int + get() = getArgb(dynamicColors.tertiaryPaletteKeyColor) + + val neutralPaletteKeyColor: Int + get() = getArgb(dynamicColors.neutralPaletteKeyColor) + + val neutralVariantPaletteKeyColor: Int + get() = getArgb(dynamicColors.neutralVariantPaletteKeyColor) + + val background: Int + get() = getArgb(dynamicColors.background) + + val onBackground: Int + get() = getArgb(dynamicColors.onBackground) + + val surface: Int + get() = getArgb(dynamicColors.surface) + + val surfaceDim: Int + get() = getArgb(dynamicColors.surfaceDim) + + val surfaceBright: Int + get() = getArgb(dynamicColors.surfaceBright) + + val surfaceContainerLowest: Int + get() = getArgb(dynamicColors.surfaceContainerLowest) + + val surfaceContainerLow: Int + get() = getArgb(dynamicColors.surfaceContainerLow) + + val surfaceContainer: Int + get() = getArgb(dynamicColors.surfaceContainer) + + val surfaceContainerHigh: Int + get() = getArgb(dynamicColors.surfaceContainerHigh) + + val surfaceContainerHighest: Int + get() = getArgb(dynamicColors.surfaceContainerHighest) + + val onSurface: Int + get() = getArgb(dynamicColors.onSurface) + + val surfaceVariant: Int + get() = getArgb(dynamicColors.surfaceVariant) + + val onSurfaceVariant: Int + get() = getArgb(dynamicColors.onSurfaceVariant) + + val inverseSurface: Int + get() = getArgb(dynamicColors.inverseSurface) + + val inverseOnSurface: Int + get() = getArgb(dynamicColors.inverseOnSurface) + + val outline: Int + get() = getArgb(dynamicColors.outline) + + val outlineVariant: Int + get() = getArgb(dynamicColors.outlineVariant) + + val shadow: Int + get() = getArgb(dynamicColors.shadow) + + val scrim: Int + get() = getArgb(dynamicColors.scrim) + + val surfaceTint: Int + get() = getArgb(dynamicColors.surfaceTint) + + val primary: Int + get() = getArgb(dynamicColors.primary) + + val onPrimary: Int + get() = getArgb(dynamicColors.onPrimary) + + val primaryContainer: Int + get() = getArgb(dynamicColors.primaryContainer) + + val onPrimaryContainer: Int + get() = getArgb(dynamicColors.onPrimaryContainer) + + val inversePrimary: Int + get() = getArgb(dynamicColors.inversePrimary) + + val secondary: Int + get() = getArgb(dynamicColors.secondary) + + val onSecondary: Int + get() = getArgb(dynamicColors.onSecondary) + + val secondaryContainer: Int + get() = getArgb(dynamicColors.secondaryContainer) + + val onSecondaryContainer: Int + get() = getArgb(dynamicColors.onSecondaryContainer) + + val tertiary: Int + get() = getArgb(dynamicColors.tertiary) + + val onTertiary: Int + get() = getArgb(dynamicColors.onTertiary) + + val tertiaryContainer: Int + get() = getArgb(dynamicColors.tertiaryContainer) + + val onTertiaryContainer: Int + get() = getArgb(dynamicColors.onTertiaryContainer) + + val error: Int + get() = getArgb(dynamicColors.error) + + val onError: Int + get() = getArgb(dynamicColors.onError) + + val errorContainer: Int + get() = getArgb(dynamicColors.errorContainer) + + val onErrorContainer: Int + get() = getArgb(dynamicColors.onErrorContainer) + + val primaryFixed: Int + get() = getArgb(dynamicColors.primaryFixed) + + val primaryFixedDim: Int + get() = getArgb(dynamicColors.primaryFixedDim) + + val onPrimaryFixed: Int + get() = getArgb(dynamicColors.onPrimaryFixed) + + val onPrimaryFixedVariant: Int + get() = getArgb(dynamicColors.onPrimaryFixedVariant) + + val secondaryFixed: Int + get() = getArgb(dynamicColors.secondaryFixed) + + val secondaryFixedDim: Int + get() = getArgb(dynamicColors.secondaryFixedDim) + + val onSecondaryFixed: Int + get() = getArgb(dynamicColors.onSecondaryFixed) + + val onSecondaryFixedVariant: Int + get() = getArgb(dynamicColors.onSecondaryFixedVariant) + + val tertiaryFixed: Int + get() = getArgb(dynamicColors.tertiaryFixed) + + val tertiaryFixedDim: Int + get() = getArgb(dynamicColors.tertiaryFixedDim) + + val onTertiaryFixed: Int + get() = getArgb(dynamicColors.onTertiaryFixed) + + val onTertiaryFixedVariant: Int + get() = getArgb(dynamicColors.onTertiaryFixedVariant) + + companion object { + val DEFAULT_SPEC_VERSION = SpecVersion.SPEC_2021 + val DEFAULT_PLATFORM = Platform.PHONE + + @JvmStatic + fun from(other: DynamicScheme, isDark: Boolean): DynamicScheme { + return from(other, isDark, other.contrastLevel) + } + + @JvmStatic + fun from(other: DynamicScheme, isDark: Boolean, contrastLevel: Double): DynamicScheme { + return DynamicScheme( + other.sourceColorHct, + other.variant, + isDark, + contrastLevel, + other.platform, + other.specVersion, + other.primaryPalette, + other.secondaryPalette, + other.tertiaryPalette, + other.neutralPalette, + other.neutralVariantPalette, + other.errorPalette, + ) + } + + /** + * Returns a new hue based on a piecewise function and input color hue. + * + * For example, for the following function: + * ``` + * result = 26, if 0 <= hue < 101; + * result = 39, if 101 <= hue < 210; + * result = 28, if 210 <= hue < 360. + * ``` + * + * call the function as: + * ``` + * double[] hueBreakpoints = {0, 101, 210, 360}; + * double[] hues = {26, 39, 28}; + * double result = scheme.piecewise(sourceColor, hueBreakpoints, hues); + * ``` + * + * @param sourceColorHct The input value. + * @param hueBreakpoints The breakpoints, in sorted order. No default lower or upper bounds are + * assumed. + * @param hues The hues that should be applied when source color's hue is >= the same index in + * hueBreakpoints array, and < the hue at the next index in hueBreakpoints array. Otherwise, + * the source color's hue is returned. + */ + @JvmStatic + fun getPiecewiseValue( + sourceColorHct: Hct, + hueBreakpoints: DoubleArray, + hues: DoubleArray, + ): Double { + val size = min(hueBreakpoints.size - 1, hues.size) + val sourceHue = sourceColorHct.hue + for (i in 0 until size) { + if (sourceHue >= hueBreakpoints[i] && sourceHue < hueBreakpoints[i + 1]) { + return MathUtils.sanitizeDegreesDouble(hues[i]) + } + } + // No condition matched, return the source value. + return sourceHue + } + + /** + * Returns a shifted hue based on a piecewise function and input color hue. + * + * For example, for the following function: + * ``` + * result = hue + 26, if 0 <= hue < 101; + * result = hue - 39, if 101 <= hue < 210; + * result = hue + 28, if 210 <= hue < 360. + * ``` + * + * call the function as: + * ``` + * double[] hueBreakpoints = {0, 101, 210, 360}; + * double[] rotations = {26, -39, 28}; + * double result = scheme.getRotatedHue(sourceColor, hueBreakpoints, rotations); + * ``` + * + * @param sourceColorHct the source color of the theme, in HCT. + * @param hueBreakpoints The "breakpoints", i.e. the hues at which a rotation should be apply. + * No default lower or upper bounds are assumed. + * @param rotations The rotation that should be applied when source color's hue is >= the same + * index in hues array, and < the hue at the next index in hues array. Otherwise, the source + * color's hue is returned. + */ + @JvmStatic + fun getRotatedHue( + sourceColorHct: Hct, + hueBreakpoints: DoubleArray, + rotations: DoubleArray, + ): Double { + var rotation = getPiecewiseValue(sourceColorHct, hueBreakpoints, rotations) + if (min(hueBreakpoints.size - 1, rotations.size) <= 0) { + // No condition matched, return the source hue. + rotation = 0.0 + } + return MathUtils.sanitizeDegreesDouble(sourceColorHct.hue + rotation) + } + + /** + * Returns the spec version to use for the given variant. If the variant is not supported by the + * given spec version, the fallback spec version is returned. + */ + private fun maybeFallbackSpecVersion(specVersion: SpecVersion, variant: Variant): SpecVersion { + return when (variant) { + Variant.EXPRESSIVE, + Variant.VIBRANT, + Variant.TONAL_SPOT, + Variant.NEUTRAL -> specVersion + Variant.MONOCHROME, + Variant.FIDELITY, + Variant.CONTENT, + Variant.RAINBOW, + Variant.FRUIT_SALAD -> SpecVersion.SPEC_2021 + } + } + } +} diff --git a/libs/material-color-utilities/src/main/java/dynamiccolor/MaterialDynamicColors.kt b/libs/material-color-utilities/src/main/java/dynamiccolor/MaterialDynamicColors.kt new file mode 100644 index 000000000..5e26dce19 --- /dev/null +++ b/libs/material-color-utilities/src/main/java/dynamiccolor/MaterialDynamicColors.kt @@ -0,0 +1,295 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dynamiccolor + +/** Named colors, otherwise known as tokens, or roles, in the Material Design system. */ +class MaterialDynamicColors { + private val colorSpec: ColorSpec = ColorSpec2025() + + fun highestSurface(scheme: DynamicScheme): DynamicColor = colorSpec.highestSurface(scheme) + + // //////////////////////////////////////////////////////////////// + // Main Palettes // + // //////////////////////////////////////////////////////////////// + val primaryPaletteKeyColor: DynamicColor + get() = colorSpec.primaryPaletteKeyColor + + val secondaryPaletteKeyColor: DynamicColor + get() = colorSpec.secondaryPaletteKeyColor + + val tertiaryPaletteKeyColor: DynamicColor + get() = colorSpec.tertiaryPaletteKeyColor + + val neutralPaletteKeyColor: DynamicColor + get() = colorSpec.neutralPaletteKeyColor + + val neutralVariantPaletteKeyColor: DynamicColor + get() = colorSpec.neutralVariantPaletteKeyColor + + val errorPaletteKeyColor: DynamicColor + get() = colorSpec.errorPaletteKeyColor + + // //////////////////////////////////////////////////////////////// + // Surfaces [S] // + // //////////////////////////////////////////////////////////////// + val background: DynamicColor + get() = colorSpec.background + + val onBackground: DynamicColor + get() = colorSpec.onBackground + + val surface: DynamicColor + get() = colorSpec.surface + + val surfaceDim: DynamicColor + get() = colorSpec.surfaceDim + + val surfaceBright: DynamicColor + get() = colorSpec.surfaceBright + + val surfaceContainerLowest: DynamicColor + get() = colorSpec.surfaceContainerLowest + + val surfaceContainerLow: DynamicColor + get() = colorSpec.surfaceContainerLow + + val surfaceContainer: DynamicColor + get() = colorSpec.surfaceContainer + + val surfaceContainerHigh: DynamicColor + get() = colorSpec.surfaceContainerHigh + + val surfaceContainerHighest: DynamicColor + get() = colorSpec.surfaceContainerHighest + + val onSurface: DynamicColor + get() = colorSpec.onSurface + + val surfaceVariant: DynamicColor + get() = colorSpec.surfaceVariant + + val onSurfaceVariant: DynamicColor + get() = colorSpec.onSurfaceVariant + + val inverseSurface: DynamicColor + get() = colorSpec.inverseSurface + + val inverseOnSurface: DynamicColor + get() = colorSpec.inverseOnSurface + + val outline: DynamicColor + get() = colorSpec.outline + + val outlineVariant: DynamicColor + get() = colorSpec.outlineVariant + + val shadow: DynamicColor + get() = colorSpec.shadow + + val scrim: DynamicColor + get() = colorSpec.scrim + + val surfaceTint: DynamicColor + get() = colorSpec.surfaceTint + + // //////////////////////////////////////////////////////////////// + // Primaries [P] // + // //////////////////////////////////////////////////////////////// + val primary: DynamicColor + get() = colorSpec.primary + + val primaryDim: DynamicColor? + get() = colorSpec.primaryDim + + val onPrimary: DynamicColor + get() = colorSpec.onPrimary + + val primaryContainer: DynamicColor + get() = colorSpec.primaryContainer + + val onPrimaryContainer: DynamicColor + get() = colorSpec.onPrimaryContainer + + val inversePrimary: DynamicColor + get() = colorSpec.inversePrimary + + // /////////////////////////////////////////////////////////////// + // Primary Fixed Colors [PF] // + // /////////////////////////////////////////////////////////////// + val primaryFixed: DynamicColor + get() = colorSpec.primaryFixed + + val primaryFixedDim: DynamicColor + get() = colorSpec.primaryFixedDim + + val onPrimaryFixed: DynamicColor + get() = colorSpec.onPrimaryFixed + + val onPrimaryFixedVariant: DynamicColor + get() = colorSpec.onPrimaryFixedVariant + + // //////////////////////////////////////////////////////////////// + // Secondaries [Q] // + // //////////////////////////////////////////////////////////////// + val secondary: DynamicColor + get() = colorSpec.secondary + + val secondaryDim: DynamicColor? + get() = colorSpec.secondaryDim + + val onSecondary: DynamicColor + get() = colorSpec.onSecondary + + val secondaryContainer: DynamicColor + get() = colorSpec.secondaryContainer + + val onSecondaryContainer: DynamicColor + get() = colorSpec.onSecondaryContainer + + // /////////////////////////////////////////////////////////////// + // Secondary Fixed Colors [QF] // + // /////////////////////////////////////////////////////////////// + val secondaryFixed: DynamicColor + get() = colorSpec.secondaryFixed + + val secondaryFixedDim: DynamicColor + get() = colorSpec.secondaryFixedDim + + val onSecondaryFixed: DynamicColor + get() = colorSpec.onSecondaryFixed + + val onSecondaryFixedVariant: DynamicColor + get() = colorSpec.onSecondaryFixedVariant + + // //////////////////////////////////////////////////////////////// + // Tertiaries [T] // + // //////////////////////////////////////////////////////////////// + val tertiary: DynamicColor + get() = colorSpec.tertiary + + val tertiaryDim: DynamicColor? + get() = colorSpec.tertiaryDim + + val onTertiary: DynamicColor + get() = colorSpec.onTertiary + + val tertiaryContainer: DynamicColor + get() = colorSpec.tertiaryContainer + + val onTertiaryContainer: DynamicColor + get() = colorSpec.onTertiaryContainer + + // /////////////////////////////////////////////////////////////// + // Tertiary Fixed Colors [TF] // + // /////////////////////////////////////////////////////////////// + val tertiaryFixed: DynamicColor + get() = colorSpec.tertiaryFixed + + val tertiaryFixedDim: DynamicColor + get() = colorSpec.tertiaryFixedDim + + val onTertiaryFixed: DynamicColor + get() = colorSpec.onTertiaryFixed + + val onTertiaryFixedVariant: DynamicColor + get() = colorSpec.onTertiaryFixedVariant + + // //////////////////////////////////////////////////////////////// + // Errors [E] // + // //////////////////////////////////////////////////////////////// + val error: DynamicColor + get() = colorSpec.error + + val errorDim: DynamicColor? + get() = colorSpec.errorDim + + val onError: DynamicColor + get() = colorSpec.onError + + val errorContainer: DynamicColor + get() = colorSpec.errorContainer + + val onErrorContainer: DynamicColor + get() = colorSpec.onErrorContainer + + // //////////////////////////////////////////////////////////////// + // All Colors // + // //////////////////////////////////////////////////////////////// + /** All dynamic colors in Material Design system. */ + val allDynamicColors: List<() -> DynamicColor?> by lazy { + listOf( + this::primaryPaletteKeyColor, + this::secondaryPaletteKeyColor, + this::tertiaryPaletteKeyColor, + this::neutralPaletteKeyColor, + this::neutralVariantPaletteKeyColor, + this::errorPaletteKeyColor, + this::background, + this::onBackground, + this::surface, + this::surfaceDim, + this::surfaceBright, + this::surfaceContainerLowest, + this::surfaceContainerLow, + this::surfaceContainer, + this::surfaceContainerHigh, + this::surfaceContainerHighest, + this::onSurface, + this::surfaceVariant, + this::onSurfaceVariant, + this::outline, + this::outlineVariant, + this::inverseSurface, + this::inverseOnSurface, + this::shadow, + this::scrim, + this::surfaceTint, + this::primary, + this::primaryDim, + this::onPrimary, + this::primaryContainer, + this::onPrimaryContainer, + this::primaryFixed, + this::primaryFixedDim, + this::onPrimaryFixed, + this::onPrimaryFixedVariant, + this::inversePrimary, + this::secondary, + this::secondaryDim, + this::onSecondary, + this::secondaryContainer, + this::onSecondaryContainer, + this::secondaryFixed, + this::secondaryFixedDim, + this::onSecondaryFixed, + this::onSecondaryFixedVariant, + this::tertiary, + this::tertiaryDim, + this::onTertiary, + this::tertiaryContainer, + this::onTertiaryContainer, + this::tertiaryFixed, + this::tertiaryFixedDim, + this::onTertiaryFixed, + this::onTertiaryFixedVariant, + this::error, + this::errorDim, + this::onError, + this::errorContainer, + this::onErrorContainer, + ) + } +} diff --git a/libs/material-color-utilities/src/main/java/dynamiccolor/ToneDeltaPair.kt b/libs/material-color-utilities/src/main/java/dynamiccolor/ToneDeltaPair.kt new file mode 100644 index 000000000..e9f229578 --- /dev/null +++ b/libs/material-color-utilities/src/main/java/dynamiccolor/ToneDeltaPair.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dynamiccolor + +/** + * Documents a constraint between two DynamicColors, in which their tones must have a certain + * distance from each other. + * + * The polarity is an adjective that describes "A", compared to "B". For instance, ToneDeltaPair(A, + * B, 15, 'darker') states that A's tone should be at least 15 darker than B's. + * + * Prefer a DynamicColor with a background, this is for special cases when designers want tonal + * distance, literally contrast, between two colors that don't have a background / foreground + * relationship or a contrast guarantee. + * + * @param roleA The first role in a pair. + * @param roleB The second role in a pair. + * @param delta Required difference between tones. Absolute value, negative values have undefined + * behavior. + * @param polarity The relative relation between tones of roleA and roleB, as described above. + * @param stayTogether Whether these two roles should stay on the same side of the "awkward zone" + * (T50-59). This is necessary for certain cases where one role has two backgrounds. + * @param constraint How to fulfill the tone delta pair constraint. + */ +data class ToneDeltaPair( + val roleA: DynamicColor, + val roleB: DynamicColor, + val delta: Double, + val polarity: TonePolarity, + val stayTogether: Boolean = true, + val constraint: DeltaConstraint = DeltaConstraint.EXACT, +) { + /** + * Describes how to fulfill a tone delta pair constraint. + * + * Determines if the delta is a minimum, maximum, or exact tonal distance that must be maintained. + */ + enum class DeltaConstraint { + // The tone of roleA must be an exact delta away from the tone of roleB. + EXACT, + // The tonal distance of roleA and roleB must be at most delta. + NEARER, + // The tonal distance of roleA and roleB must be at least delta. + FARTHER, + } + + /** + * Describes the relationship in lightness between two colors. + * + * 'relative_darker' and 'relative_lighter' describes the tone adjustment relative to the surface + * color trend (white in light mode; black in dark mode). For instance, ToneDeltaPair(A, B, 10, + * 'relative_lighter', 'farther') states that A should be at least 10 lighter than B in light + * mode, and at least 10 darker than B in dark mode. + */ + enum class TonePolarity { + // The tone of roleA is always darker than the tone of roleB. + DARKER, + // The tone of roleA is always lighter than the tone of roleB. + LIGHTER, + // The tone of roleA is darker than the tone of roleB in light mode, and lighter than the tone + // ofroleB in dark mode. + RELATIVE_DARKER, + // The tone of roleA is lighter than the tone of roleB in light mode, and darker than the tone + // ofroleB in dark mode. + RELATIVE_LIGHTER, + } +} diff --git a/libs/material-color-utilities/src/main/java/dynamiccolor/Variant.kt b/libs/material-color-utilities/src/main/java/dynamiccolor/Variant.kt new file mode 100644 index 000000000..f12dcdb03 --- /dev/null +++ b/libs/material-color-utilities/src/main/java/dynamiccolor/Variant.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dynamiccolor + +/** Themes for Dynamic Color. */ +enum class Variant { + MONOCHROME, + NEUTRAL, + TONAL_SPOT, + VIBRANT, + EXPRESSIVE, + FIDELITY, + CONTENT, + RAINBOW, + FRUIT_SALAD, +} diff --git a/libs/material-color-utilities/src/main/java/hct/Cam16.java b/libs/material-color-utilities/src/main/java/hct/Cam16.java deleted file mode 100644 index 9b6fb1fbd..000000000 --- a/libs/material-color-utilities/src/main/java/hct/Cam16.java +++ /dev/null @@ -1,439 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package hct; - -import static java.lang.Math.max; - -import utils.ColorUtils; - -/** - * CAM16, a color appearance model. Colors are not just defined by their hex code, but rather, a hex - * code and viewing conditions. - * - *

CAM16 instances also have coordinates in the CAM16-UCS space, called J*, a*, b*, or jstar, - * astar, bstar in code. CAM16-UCS is included in the CAM16 specification, and should be used when - * measuring distances between colors. - * - *

In traditional color spaces, a color can be identified solely by the observer's measurement of - * the color. Color appearance models such as CAM16 also use information about the environment where - * the color was observed, known as the viewing conditions. - * - *

For example, white under the traditional assumption of a midday sun white point is accurately - * measured as a slightly chromatic blue by CAM16. (roughly, hue 203, chroma 3, lightness 100) - */ -public final class Cam16 { - // Transforms XYZ color space coordinates to 'cone'/'RGB' responses in CAM16. - static final double[][] XYZ_TO_CAM16RGB = { - {0.401288, 0.650173, -0.051461}, - {-0.250268, 1.204414, 0.045854}, - {-0.002079, 0.048952, 0.953127} - }; - - // Transforms 'cone'/'RGB' responses in CAM16 to XYZ color space coordinates. - static final double[][] CAM16RGB_TO_XYZ = { - {1.8620678, -1.0112547, 0.14918678}, - {0.38752654, 0.62144744, -0.00897398}, - {-0.01584150, -0.03412294, 1.0499644} - }; - - // CAM16 color dimensions, see getters for documentation. - private final double hue; - private final double chroma; - private final double j; - private final double q; - private final double m; - private final double s; - - // Coordinates in UCS space. Used to determine color distance, like delta E equations in L*a*b*. - private final double jstar; - private final double astar; - private final double bstar; - - // Avoid allocations during conversion by pre-allocating an array. - private final double[] tempArray = new double[] {0.0, 0.0, 0.0}; - - /** - * CAM16 instances also have coordinates in the CAM16-UCS space, called J*, a*, b*, or jstar, - * astar, bstar in code. CAM16-UCS is included in the CAM16 specification, and is used to measure - * distances between colors. - */ - double distance(Cam16 other) { - double dJ = getJstar() - other.getJstar(); - double dA = getAstar() - other.getAstar(); - double dB = getBstar() - other.getBstar(); - double dEPrime = Math.sqrt(dJ * dJ + dA * dA + dB * dB); - double dE = 1.41 * Math.pow(dEPrime, 0.63); - return dE; - } - - /** Hue in CAM16 */ - public double getHue() { - return hue; - } - - /** Chroma in CAM16 */ - public double getChroma() { - return chroma; - } - - /** Lightness in CAM16 */ - public double getJ() { - return j; - } - - /** - * Brightness in CAM16. - * - *

Prefer lightness, brightness is an absolute quantity. For example, a sheet of white paper is - * much brighter viewed in sunlight than in indoor light, but it is the lightest object under any - * lighting. - */ - public double getQ() { - return q; - } - - /** - * Colorfulness in CAM16. - * - *

Prefer chroma, colorfulness is an absolute quantity. For example, a yellow toy car is much - * more colorful outside than inside, but it has the same chroma in both environments. - */ - public double getM() { - return m; - } - - /** - * Saturation in CAM16. - * - *

Colorfulness in proportion to brightness. Prefer chroma, saturation measures colorfulness - * relative to the color's own brightness, where chroma is colorfulness relative to white. - */ - public double getS() { - return s; - } - - /** Lightness coordinate in CAM16-UCS */ - public double getJstar() { - return jstar; - } - - /** a* coordinate in CAM16-UCS */ - public double getAstar() { - return astar; - } - - /** b* coordinate in CAM16-UCS */ - public double getBstar() { - return bstar; - } - - /** - * All of the CAM16 dimensions can be calculated from 3 of the dimensions, in the following - * combinations: - {j or q} and {c, m, or s} and hue - jstar, astar, bstar Prefer using a static - * method that constructs from 3 of those dimensions. This constructor is intended for those - * methods to use to return all possible dimensions. - * - * @param hue for example, red, orange, yellow, green, etc. - * @param chroma informally, colorfulness / color intensity. like saturation in HSL, except - * perceptually accurate. - * @param j lightness - * @param q brightness; ratio of lightness to white point's lightness - * @param m colorfulness - * @param s saturation; ratio of chroma to white point's chroma - * @param jstar CAM16-UCS J coordinate - * @param astar CAM16-UCS a coordinate - * @param bstar CAM16-UCS b coordinate - */ - private Cam16( - double hue, - double chroma, - double j, - double q, - double m, - double s, - double jstar, - double astar, - double bstar) { - this.hue = hue; - this.chroma = chroma; - this.j = j; - this.q = q; - this.m = m; - this.s = s; - this.jstar = jstar; - this.astar = astar; - this.bstar = bstar; - } - - /** - * Create a CAM16 color from a color, assuming the color was viewed in default viewing conditions. - * - * @param argb ARGB representation of a color. - */ - public static Cam16 fromInt(int argb) { - return fromIntInViewingConditions(argb, ViewingConditions.DEFAULT); - } - - /** - * Create a CAM16 color from a color in defined viewing conditions. - * - * @param argb ARGB representation of a color. - * @param viewingConditions Information about the environment where the color was observed. - */ - // The RGB => XYZ conversion matrix elements are derived scientific constants. While the values - // may differ at runtime due to floating point imprecision, keeping the values the same, and - // accurate, across implementations takes precedence. - @SuppressWarnings("FloatingPointLiteralPrecision") - static Cam16 fromIntInViewingConditions(int argb, ViewingConditions viewingConditions) { - // Transform ARGB int to XYZ - int red = (argb & 0x00ff0000) >> 16; - int green = (argb & 0x0000ff00) >> 8; - int blue = (argb & 0x000000ff); - double redL = ColorUtils.linearized(red); - double greenL = ColorUtils.linearized(green); - double blueL = ColorUtils.linearized(blue); - double x = 0.41233895 * redL + 0.35762064 * greenL + 0.18051042 * blueL; - double y = 0.2126 * redL + 0.7152 * greenL + 0.0722 * blueL; - double z = 0.01932141 * redL + 0.11916382 * greenL + 0.95034478 * blueL; - - return fromXyzInViewingConditions(x, y, z, viewingConditions); - } - - static Cam16 fromXyzInViewingConditions( - double x, double y, double z, ViewingConditions viewingConditions) { - // Transform XYZ to 'cone'/'rgb' responses - double[][] matrix = XYZ_TO_CAM16RGB; - double rT = (x * matrix[0][0]) + (y * matrix[0][1]) + (z * matrix[0][2]); - double gT = (x * matrix[1][0]) + (y * matrix[1][1]) + (z * matrix[1][2]); - double bT = (x * matrix[2][0]) + (y * matrix[2][1]) + (z * matrix[2][2]); - - // Discount illuminant - double rD = viewingConditions.getRgbD()[0] * rT; - double gD = viewingConditions.getRgbD()[1] * gT; - double bD = viewingConditions.getRgbD()[2] * bT; - - // Chromatic adaptation - double rAF = Math.pow(viewingConditions.getFl() * Math.abs(rD) / 100.0, 0.42); - double gAF = Math.pow(viewingConditions.getFl() * Math.abs(gD) / 100.0, 0.42); - double bAF = Math.pow(viewingConditions.getFl() * Math.abs(bD) / 100.0, 0.42); - double rA = Math.signum(rD) * 400.0 * rAF / (rAF + 27.13); - double gA = Math.signum(gD) * 400.0 * gAF / (gAF + 27.13); - double bA = Math.signum(bD) * 400.0 * bAF / (bAF + 27.13); - - // redness-greenness - double a = (11.0 * rA + -12.0 * gA + bA) / 11.0; - // yellowness-blueness - double b = (rA + gA - 2.0 * bA) / 9.0; - - // auxiliary components - double u = (20.0 * rA + 20.0 * gA + 21.0 * bA) / 20.0; - double p2 = (40.0 * rA + 20.0 * gA + bA) / 20.0; - - // hue - double atan2 = Math.atan2(b, a); - double atanDegrees = Math.toDegrees(atan2); - double hue = - atanDegrees < 0 - ? atanDegrees + 360.0 - : atanDegrees >= 360 ? atanDegrees - 360.0 : atanDegrees; - double hueRadians = Math.toRadians(hue); - - // achromatic response to color - double ac = p2 * viewingConditions.getNbb(); - - // CAM16 lightness and brightness - double j = - 100.0 - * Math.pow( - ac / viewingConditions.getAw(), - viewingConditions.getC() * viewingConditions.getZ()); - double q = - 4.0 - / viewingConditions.getC() - * Math.sqrt(j / 100.0) - * (viewingConditions.getAw() + 4.0) - * viewingConditions.getFlRoot(); - - // CAM16 chroma, colorfulness, and saturation. - double huePrime = (hue < 20.14) ? hue + 360 : hue; - double eHue = 0.25 * (Math.cos(Math.toRadians(huePrime) + 2.0) + 3.8); - double p1 = 50000.0 / 13.0 * eHue * viewingConditions.getNc() * viewingConditions.getNcb(); - double t = p1 * Math.hypot(a, b) / (u + 0.305); - double alpha = - Math.pow(1.64 - Math.pow(0.29, viewingConditions.getN()), 0.73) * Math.pow(t, 0.9); - // CAM16 chroma, colorfulness, saturation - double c = alpha * Math.sqrt(j / 100.0); - double m = c * viewingConditions.getFlRoot(); - double s = - 50.0 * Math.sqrt((alpha * viewingConditions.getC()) / (viewingConditions.getAw() + 4.0)); - - // CAM16-UCS components - double jstar = (1.0 + 100.0 * 0.007) * j / (1.0 + 0.007 * j); - double mstar = 1.0 / 0.0228 * Math.log1p(0.0228 * m); - double astar = mstar * Math.cos(hueRadians); - double bstar = mstar * Math.sin(hueRadians); - - return new Cam16(hue, c, j, q, m, s, jstar, astar, bstar); - } - - /** - * @param j CAM16 lightness - * @param c CAM16 chroma - * @param h CAM16 hue - */ - static Cam16 fromJch(double j, double c, double h) { - return fromJchInViewingConditions(j, c, h, ViewingConditions.DEFAULT); - } - - /** - * @param j CAM16 lightness - * @param c CAM16 chroma - * @param h CAM16 hue - * @param viewingConditions Information about the environment where the color was observed. - */ - private static Cam16 fromJchInViewingConditions( - double j, double c, double h, ViewingConditions viewingConditions) { - double q = - 4.0 - / viewingConditions.getC() - * Math.sqrt(j / 100.0) - * (viewingConditions.getAw() + 4.0) - * viewingConditions.getFlRoot(); - double m = c * viewingConditions.getFlRoot(); - double alpha = c / Math.sqrt(j / 100.0); - double s = - 50.0 * Math.sqrt((alpha * viewingConditions.getC()) / (viewingConditions.getAw() + 4.0)); - - double hueRadians = Math.toRadians(h); - double jstar = (1.0 + 100.0 * 0.007) * j / (1.0 + 0.007 * j); - double mstar = 1.0 / 0.0228 * Math.log1p(0.0228 * m); - double astar = mstar * Math.cos(hueRadians); - double bstar = mstar * Math.sin(hueRadians); - return new Cam16(h, c, j, q, m, s, jstar, astar, bstar); - } - - /** - * Create a CAM16 color from CAM16-UCS coordinates. - * - * @param jstar CAM16-UCS lightness. - * @param astar CAM16-UCS a dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the Y - * axis. - * @param bstar CAM16-UCS b dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the X - * axis. - */ - public static Cam16 fromUcs(double jstar, double astar, double bstar) { - - return fromUcsInViewingConditions(jstar, astar, bstar, ViewingConditions.DEFAULT); - } - - /** - * Create a CAM16 color from CAM16-UCS coordinates in defined viewing conditions. - * - * @param jstar CAM16-UCS lightness. - * @param astar CAM16-UCS a dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the Y - * axis. - * @param bstar CAM16-UCS b dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the X - * axis. - * @param viewingConditions Information about the environment where the color was observed. - */ - public static Cam16 fromUcsInViewingConditions( - double jstar, double astar, double bstar, ViewingConditions viewingConditions) { - - double m = Math.hypot(astar, bstar); - double m2 = Math.expm1(m * 0.0228) / 0.0228; - double c = m2 / viewingConditions.getFlRoot(); - double h = Math.atan2(bstar, astar) * (180.0 / Math.PI); - if (h < 0.0) { - h += 360.0; - } - double j = jstar / (1. - (jstar - 100.) * 0.007); - return fromJchInViewingConditions(j, c, h, viewingConditions); - } - - /** - * ARGB representation of the color. Assumes the color was viewed in default viewing conditions, - * which are near-identical to the default viewing conditions for sRGB. - */ - public int toInt() { - return viewed(ViewingConditions.DEFAULT); - } - - /** - * ARGB representation of the color, in defined viewing conditions. - * - * @param viewingConditions Information about the environment where the color will be viewed. - * @return ARGB representation of color - */ - int viewed(ViewingConditions viewingConditions) { - double[] xyz = xyzInViewingConditions(viewingConditions, tempArray); - return ColorUtils.argbFromXyz(xyz[0], xyz[1], xyz[2]); - } - - double[] xyzInViewingConditions(ViewingConditions viewingConditions, double[] returnArray) { - double alpha = - (getChroma() == 0.0 || getJ() == 0.0) ? 0.0 : getChroma() / Math.sqrt(getJ() / 100.0); - - double t = - Math.pow( - alpha / Math.pow(1.64 - Math.pow(0.29, viewingConditions.getN()), 0.73), 1.0 / 0.9); - double hRad = Math.toRadians(getHue()); - - double eHue = 0.25 * (Math.cos(hRad + 2.0) + 3.8); - double ac = - viewingConditions.getAw() - * Math.pow(getJ() / 100.0, 1.0 / viewingConditions.getC() / viewingConditions.getZ()); - double p1 = eHue * (50000.0 / 13.0) * viewingConditions.getNc() * viewingConditions.getNcb(); - double p2 = (ac / viewingConditions.getNbb()); - - double hSin = Math.sin(hRad); - double hCos = Math.cos(hRad); - - double gamma = 23.0 * (p2 + 0.305) * t / (23.0 * p1 + 11.0 * t * hCos + 108.0 * t * hSin); - double a = gamma * hCos; - double b = gamma * hSin; - double rA = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0; - double gA = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0; - double bA = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0; - - double rCBase = max(0, (27.13 * Math.abs(rA)) / (400.0 - Math.abs(rA))); - double rC = - Math.signum(rA) * (100.0 / viewingConditions.getFl()) * Math.pow(rCBase, 1.0 / 0.42); - double gCBase = max(0, (27.13 * Math.abs(gA)) / (400.0 - Math.abs(gA))); - double gC = - Math.signum(gA) * (100.0 / viewingConditions.getFl()) * Math.pow(gCBase, 1.0 / 0.42); - double bCBase = max(0, (27.13 * Math.abs(bA)) / (400.0 - Math.abs(bA))); - double bC = - Math.signum(bA) * (100.0 / viewingConditions.getFl()) * Math.pow(bCBase, 1.0 / 0.42); - double rF = rC / viewingConditions.getRgbD()[0]; - double gF = gC / viewingConditions.getRgbD()[1]; - double bF = bC / viewingConditions.getRgbD()[2]; - - double[][] matrix = CAM16RGB_TO_XYZ; - double x = (rF * matrix[0][0]) + (gF * matrix[0][1]) + (bF * matrix[0][2]); - double y = (rF * matrix[1][0]) + (gF * matrix[1][1]) + (bF * matrix[1][2]); - double z = (rF * matrix[2][0]) + (gF * matrix[2][1]) + (bF * matrix[2][2]); - - if (returnArray != null) { - returnArray[0] = x; - returnArray[1] = y; - returnArray[2] = z; - return returnArray; - } else { - return new double[] {x, y, z}; - } - } -} diff --git a/libs/material-color-utilities/src/main/java/hct/Cam16.kt b/libs/material-color-utilities/src/main/java/hct/Cam16.kt new file mode 100644 index 000000000..049bd9ea7 --- /dev/null +++ b/libs/material-color-utilities/src/main/java/hct/Cam16.kt @@ -0,0 +1,366 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package hct + +import utils.ColorUtils +import utils.MathUtils +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.expm1 +import kotlin.math.hypot +import kotlin.math.ln1p +import kotlin.math.max +import kotlin.math.pow +import kotlin.math.sign +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * CAM16, a color appearance model. Colors are not just defined by their hex code, but rather, a hex + * code and viewing conditions. + * + * CAM16 instances also have coordinates in the CAM16-UCS space, called J*, a*, b*, or jstar, astar, + * bstar in code. CAM16-UCS is included in the CAM16 specification, and should be used when + * measuring distances between colors. + * + * In traditional color spaces, a color can be identified solely by the observer's measurement of + * the color. Color appearance models such as CAM16 also use information about the environment where + * the color was observed, known as the viewing conditions. + * + * For example, white under the traditional assumption of a midday sun white point is accurately + * measured as a slightly chromatic blue by CAM16. (roughly, hue 203, chroma 3, lightness 100) + */ +@ConsistentCopyVisibility +data class Cam16 +private constructor( + /** Hue in CAM16 */ + val hue: Double, + /** Chroma in CAM16 */ + val chroma: Double, + /** Lightness in CAM16 */ + val j: Double, + /** + * Brightness in CAM16. + * + * Prefer lightness, brightness is an absolute quantity. For example, a sheet of white paper is + * much brighter viewed in sunlight than in indoor light, but it is the lightest object under any + * lighting. + */ + val q: Double, + /** + * Colorfulness in CAM16. + * + * Prefer chroma, colorfulness is an absolute quantity. For example, a yellow toy car is much more + * colorful outside than inside, but it has the same chroma in both environments. + */ + val m: Double, + /** + * Saturation in CAM16. + * + * Colorfulness in proportion to brightness. Prefer chroma, saturation measures colorfulness + * relative to the color's own brightness, where chroma is colorfulness relative to white. + */ + val s: Double, + /** Lightness coordinate in CAM16-UCS */ + val jstar: Double, + /** a* coordinate in CAM16-UCS */ + val astar: Double, + /** b* coordinate in CAM16-UCS */ + val bstar: Double, +) { + + /** + * CAM16 instances also have coordinates in the CAM16-UCS space, called J*, a*, b*, or jstar, + * astar, bstar in code. CAM16-UCS is included in the CAM16 specification, and is used to measure + * distances between colors. + */ + fun distance(other: Cam16): Double { + val dJ = jstar - other.jstar + val dA = astar - other.astar + val dB = bstar - other.bstar + val dEPrime = sqrt(dJ * dJ + dA * dA + dB * dB) + val dE = 1.41 * dEPrime.pow(0.63) + return dE + } + + /** + * ARGB representation of the color. Assumes the color was viewed in default viewing conditions, + * which are near-identical to the default viewing conditions for sRGB. + */ + fun toInt(): Int { + return viewed(ViewingConditions.DEFAULT) + } + + /** + * ARGB representation of the color, in defined viewing conditions. + * + * @param viewingConditions Information about the environment where the color will be viewed. + * @return ARGB representation of color + */ + internal fun viewed(viewingConditions: ViewingConditions): Int { + val xyz = xyzInViewingConditions(viewingConditions) + return ColorUtils.argbFromXyz(xyz[0], xyz[1], xyz[2]) + } + + internal fun xyzInViewingConditions( + viewingConditions: ViewingConditions, + returnArray: DoubleArray? = null, + ): DoubleArray { + val alpha = if (chroma == 0.0 || j == 0.0) 0.0 else chroma / sqrt(j / 100.0) + val t = (alpha / (1.64 - 0.29.pow(viewingConditions.n)).pow(0.73)).pow(1.0 / 0.9) + val hRad = Math.toRadians(hue) + val eHue = 0.25 * (cos(hRad + 2.0) + 3.8) + val ac = viewingConditions.aw * (j / 100.0).pow(1.0 / viewingConditions.c / viewingConditions.z) + val p1 = eHue * (50000.0 / 13.0) * viewingConditions.nc * viewingConditions.ncb + val p2 = ac / viewingConditions.nbb + val hSin = sin(hRad) + val hCos = cos(hRad) + val gamma = 23.0 * (p2 + 0.305) * t / (23.0 * p1 + 11.0 * t * hCos + 108.0 * t * hSin) + val a = gamma * hCos + val b = gamma * hSin + val rA = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0 + val gA = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0 + val bA = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0 + val rCBase = max(0.0, 27.13 * abs(rA) / (400.0 - abs(rA))) + val rC = sign(rA) * (100.0 / viewingConditions.fl) * (rCBase).pow(1.0 / 0.42) + val gCBase = max(0.0, 27.13 * abs(gA) / (400.0 - abs(gA))) + val gC = sign(gA) * (100.0 / viewingConditions.fl) * (gCBase).pow(1.0 / 0.42) + val bCBase = max(0.0, 27.13 * abs(bA) / (400.0 - abs(bA))) + val bC = sign(bA) * (100.0 / viewingConditions.fl) * (bCBase).pow(1.0 / 0.42) + val rF = rC / viewingConditions.rgbD[0] + val gF = gC / viewingConditions.rgbD[1] + val bF = bC / viewingConditions.rgbD[2] + val matrix = CAM16RGB_TO_XYZ + val x = rF * matrix[0][0] + gF * matrix[0][1] + bF * matrix[0][2] + val y = rF * matrix[1][0] + gF * matrix[1][1] + bF * matrix[1][2] + val z = rF * matrix[2][0] + gF * matrix[2][1] + bF * matrix[2][2] + return if (returnArray != null) { + returnArray[0] = x + returnArray[1] = y + returnArray[2] = z + returnArray + } else { + doubleArrayOf(x, y, z) + } + } + + companion object { + // Transforms XYZ color space coordinates to 'cone'/'RGB' responses in CAM16. + internal val XYZ_TO_CAM16RGB = + arrayOf( + doubleArrayOf(0.401288, 0.650173, -0.051461), + doubleArrayOf(-0.250268, 1.204414, 0.045854), + doubleArrayOf(-0.002079, 0.048952, 0.953127), + ) + + // Transforms 'cone'/'RGB' responses in CAM16 to XYZ color space coordinates. + internal val CAM16RGB_TO_XYZ = + arrayOf( + doubleArrayOf(1.8620678, -1.0112547, 0.14918678), + doubleArrayOf(0.38752654, 0.62144744, -0.00897398), + doubleArrayOf(-0.0158415, -0.03412294, 1.0499644), + ) + + /** + * Create a CAM16 color from a color, assuming the color was viewed in default viewing + * conditions. + * + * @param argb ARGB representation of a color. + */ + @JvmStatic + fun fromInt(argb: Int): Cam16 { + return fromIntInViewingConditions(argb, ViewingConditions.DEFAULT) + } + + /** + * Create a CAM16 color from a color in defined viewing conditions. + * + * @param argb ARGB representation of a color. + * @param viewingConditions Information about the environment where the color was observed. + */ + // The RGB => XYZ conversion matrix elements are derived scientific constants. While the values + // may differ at runtime due to floating point imprecision, keeping the values the same, and + // accurate, across implementations takes precedence. + @SuppressWarnings("FloatingPointLiteralPrecision") + internal fun fromIntInViewingConditions( + argb: Int, + viewingConditions: ViewingConditions, + ): Cam16 { + // Transform ARGB int to XYZ + val red = argb and 0x00ff0000 shr 16 + val green = argb and 0x0000ff00 shr 8 + val blue = argb and 0x000000ff + val redL = ColorUtils.linearized(red) + val greenL = ColorUtils.linearized(green) + val blueL = ColorUtils.linearized(blue) + val x = 0.41233895 * redL + 0.35762064 * greenL + 0.18051042 * blueL + val y = 0.2126 * redL + 0.7152 * greenL + 0.0722 * blueL + val z = 0.01932141 * redL + 0.11916382 * greenL + 0.95034478 * blueL + return fromXyzInViewingConditions(x, y, z, viewingConditions) + } + + internal fun fromXyzInViewingConditions( + x: Double, + y: Double, + z: Double, + viewingConditions: ViewingConditions, + ): Cam16 { + // Transform XYZ to 'cone'/'rgb' responses + val matrix = XYZ_TO_CAM16RGB + val rT = x * matrix[0][0] + y * matrix[0][1] + z * matrix[0][2] + val gT = x * matrix[1][0] + y * matrix[1][1] + z * matrix[1][2] + val bT = x * matrix[2][0] + y * matrix[2][1] + z * matrix[2][2] + + // Discount illuminant + val rD = viewingConditions.rgbD[0] * rT + val gD = viewingConditions.rgbD[1] * gT + val bD = viewingConditions.rgbD[2] * bT + + // Chromatic adaptation + val rAF = (viewingConditions.fl * abs(rD) / 100.0).pow(0.42) + val gAF = (viewingConditions.fl * abs(gD) / 100.0).pow(0.42) + val bAF = (viewingConditions.fl * abs(bD) / 100.0).pow(0.42) + val rA = sign(rD) * 400.0 * rAF / (rAF + 27.13) + val gA = sign(gD) * 400.0 * gAF / (gAF + 27.13) + val bA = sign(bD) * 400.0 * bAF / (bAF + 27.13) + + // redness-greenness + val a = (11.0 * rA + -12.0 * gA + bA) / 11.0 + // yellowness-blueness + val b = (rA + gA - 2.0 * bA) / 9.0 + + // auxiliary components + val u = (20.0 * rA + 20.0 * gA + 21.0 * bA) / 20.0 + val p2 = (40.0 * rA + 20.0 * gA + bA) / 20.0 + + // hue + val atan2 = atan2(b, a) + val atanDegrees = Math.toDegrees(atan2) + val hue = MathUtils.sanitizeDegreesDouble(atanDegrees) + val hueRadians = Math.toRadians(hue) + + // achromatic response to color + val ac = p2 * viewingConditions.nbb + + // CAM16 lightness and brightness + val j = 100.0 * (ac / viewingConditions.aw).pow(viewingConditions.c * viewingConditions.z) + val q = + 4.0 / viewingConditions.c * + sqrt(j / 100.0) * + (viewingConditions.aw + 4.0) * + viewingConditions.flRoot + + // CAM16 chroma, colorfulness, and saturation. + val huePrime = if (hue < 20.14) hue + 360 else hue + val eHue = 0.25 * (cos(Math.toRadians(huePrime) + 2.0) + 3.8) + val p1 = 50000.0 / 13.0 * eHue * viewingConditions.nc * viewingConditions.ncb + val t = p1 * hypot(a, b) / (u + 0.305) + val alpha = (1.64 - 0.29.pow(viewingConditions.n)).pow(0.73) * t.pow(0.9) + // CAM16 chroma, colorfulness, saturation + val c = alpha * sqrt(j / 100.0) + val m = c * viewingConditions.flRoot + val s = 50.0 * sqrt(alpha * viewingConditions.c / (viewingConditions.aw + 4.0)) + + // CAM16-UCS components + val jstar = (1.0 + 100.0 * 0.007) * j / (1.0 + 0.007 * j) + val mstar = 1.0 / 0.0228 * ln1p(0.0228 * m) + val astar = mstar * cos(hueRadians) + val bstar = mstar * sin(hueRadians) + return Cam16(hue, c, j, q, m, s, jstar, astar, bstar) + } + + /** + * @param j CAM16 lightness + * @param c CAM16 chroma + * @param h CAM16 hue + */ + internal fun fromJch(j: Double, c: Double, h: Double): Cam16 { + return fromJchInViewingConditions(j, c, h, ViewingConditions.DEFAULT) + } + + /** + * @param j CAM16 lightness + * @param c CAM16 chroma + * @param h CAM16 hue + * @param viewingConditions Information about the environment where the color was observed. + */ + private fun fromJchInViewingConditions( + j: Double, + c: Double, + h: Double, + viewingConditions: ViewingConditions, + ): Cam16 { + val q = + 4.0 / viewingConditions.c * + sqrt(j / 100.0) * + (viewingConditions.aw + 4.0) * + viewingConditions.flRoot + val m = c * viewingConditions.flRoot + val alpha = c / sqrt(j / 100.0) + val s = 50.0 * sqrt(alpha * viewingConditions.c / (viewingConditions.aw + 4.0)) + val hueRadians = Math.toRadians(h) + val jstar = (1.0 + 100.0 * 0.007) * j / (1.0 + 0.007 * j) + val mstar = 1.0 / 0.0228 * ln1p(0.0228 * m) + val astar = mstar * cos(hueRadians) + val bstar = mstar * sin(hueRadians) + return Cam16(h, c, j, q, m, s, jstar, astar, bstar) + } + + /** + * Create a CAM16 color from CAM16-UCS coordinates. + * + * @param jstar CAM16-UCS lightness. + * @param astar CAM16-UCS a dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the Y + * axis. + * @param bstar CAM16-UCS b dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the X + * axis. + */ + @JvmStatic + fun fromUcs(jstar: Double, astar: Double, bstar: Double): Cam16 { + return fromUcsInViewingConditions(jstar, astar, bstar, ViewingConditions.DEFAULT) + } + + /** + * Create a CAM16 color from CAM16-UCS coordinates in defined viewing conditions. + * + * @param jstar CAM16-UCS lightness. + * @param astar CAM16-UCS a dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the Y + * axis. + * @param bstar CAM16-UCS b dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the X + * axis. + * @param viewingConditions Information about the environment where the color was observed. + */ + @JvmStatic + fun fromUcsInViewingConditions( + jstar: Double, + astar: Double, + bstar: Double, + viewingConditions: ViewingConditions, + ): Cam16 { + val m = hypot(astar, bstar) + val m2 = expm1(m * 0.0228) / 0.0228 + val c = m2 / viewingConditions.flRoot + var h = atan2(bstar, astar) * (180.0 / PI) + if (h < 0.0) { + h += 360.0 + } + val j = jstar / (1.0 - (jstar - 100.0) * 0.007) + return fromJchInViewingConditions(j, c, h, viewingConditions) + } + } +} diff --git a/libs/material-color-utilities/src/main/java/hct/Hct.java b/libs/material-color-utilities/src/main/java/hct/Hct.java deleted file mode 100644 index 93806b9bc..000000000 --- a/libs/material-color-utilities/src/main/java/hct/Hct.java +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package hct; - -import utils.ColorUtils; - -/** - * A color system built using CAM16 hue and chroma, and L* from L*a*b*. - * - *

Using L* creates a link between the color system, contrast, and thus accessibility. Contrast - * ratio depends on relative luminance, or Y in the XYZ color space. L*, or perceptual luminance can - * be calculated from Y. - * - *

Unlike Y, L* is linear to human perception, allowing trivial creation of accurate color tones. - * - *

Unlike contrast ratio, measuring contrast in L* is linear, and simple to calculate. A - * difference of 40 in HCT tone guarantees a contrast ratio >= 3.0, and a difference of 50 - * guarantees a contrast ratio >= 4.5. - */ - -/** - * HCT, hue, chroma, and tone. A color system that provides a perceptually accurate color - * measurement system that can also accurately render what colors will appear as in different - * lighting environments. - */ -public final class Hct { - private double hue; - private double chroma; - private double tone; - private int argb; - - /** - * Create an HCT color from hue, chroma, and tone. - * - * @param hue 0 <= hue < 360; invalid values are corrected. - * @param chroma 0 <= chroma < ?; Informally, colorfulness. The color returned may be lower than - * the requested chroma. Chroma has a different maximum for any given hue and tone. - * @param tone 0 <= tone <= 100; invalid values are corrected. - * @return HCT representation of a color in default viewing conditions. - */ - public static Hct from(double hue, double chroma, double tone) { - int argb = HctSolver.solveToInt(hue, chroma, tone); - return new Hct(argb); - } - - /** - * Create an HCT color from a color. - * - * @param argb ARGB representation of a color. - * @return HCT representation of a color in default viewing conditions - */ - public static Hct fromInt(int argb) { - return new Hct(argb); - } - - private Hct(int argb) { - setInternalState(argb); - } - - public double getHue() { - return hue; - } - - public double getChroma() { - return chroma; - } - - public double getTone() { - return tone; - } - - public int toInt() { - return argb; - } - - /** - * Set the hue of this color. Chroma may decrease because chroma has a different maximum for any - * given hue and tone. - * - * @param newHue 0 <= newHue < 360; invalid values are corrected. - */ - public void setHue(double newHue) { - setInternalState(HctSolver.solveToInt(newHue, chroma, tone)); - } - - /** - * Set the chroma of this color. Chroma may decrease because chroma has a different maximum for - * any given hue and tone. - * - * @param newChroma 0 <= newChroma < ? - */ - public void setChroma(double newChroma) { - setInternalState(HctSolver.solveToInt(hue, newChroma, tone)); - } - - /** - * Set the tone of this color. Chroma may decrease because chroma has a different maximum for any - * given hue and tone. - * - * @param newTone 0 <= newTone <= 100; invalid valids are corrected. - */ - public void setTone(double newTone) { - setInternalState(HctSolver.solveToInt(hue, chroma, newTone)); - } - - /** - * Translate a color into different ViewingConditions. - * - *

Colors change appearance. They look different with lights on versus off, the same color, as - * in hex code, on white looks different when on black. This is called color relativity, most - * famously explicated by Josef Albers in Interaction of Color. - * - *

In color science, color appearance models can account for this and calculate the appearance - * of a color in different settings. HCT is based on CAM16, a color appearance model, and uses it - * to make these calculations. - * - *

See ViewingConditions.make for parameters affecting color appearance. - */ - public Hct inViewingConditions(ViewingConditions vc) { - // 1. Use CAM16 to find XYZ coordinates of color in specified VC. - Cam16 cam16 = Cam16.fromInt(toInt()); - double[] viewedInVc = cam16.xyzInViewingConditions(vc, null); - - // 2. Create CAM16 of those XYZ coordinates in default VC. - Cam16 recastInVc = - Cam16.fromXyzInViewingConditions( - viewedInVc[0], viewedInVc[1], viewedInVc[2], ViewingConditions.DEFAULT); - - // 3. Create HCT from: - // - CAM16 using default VC with XYZ coordinates in specified VC. - // - L* converted from Y in XYZ coordinates in specified VC. - return Hct.from( - recastInVc.getHue(), recastInVc.getChroma(), ColorUtils.lstarFromY(viewedInVc[1])); - } - - private void setInternalState(int argb) { - this.argb = argb; - Cam16 cam = Cam16.fromInt(argb); - hue = cam.getHue(); - chroma = cam.getChroma(); - this.tone = ColorUtils.lstarFromArgb(argb); - } -} diff --git a/libs/material-color-utilities/src/main/java/hct/Hct.kt b/libs/material-color-utilities/src/main/java/hct/Hct.kt new file mode 100644 index 000000000..f1325eac0 --- /dev/null +++ b/libs/material-color-utilities/src/main/java/hct/Hct.kt @@ -0,0 +1,177 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package hct + +import utils.ColorUtils +import kotlin.math.roundToInt + +/** + * A color system built using CAM16 hue and chroma, and L* from L*a*b*. + * + * Using L* creates a link between the color system, contrast, and thus accessibility. Contrast + * ratio depends on relative luminance, or Y in the XYZ color space. L*, or perceptual luminance can + * be calculated from Y. + * + * Unlike Y, L* is linear to human perception, allowing trivial creation of accurate color tones. + * + * Unlike contrast ratio, measuring contrast in L* is linear, and simple to calculate. A difference + * of 40 in HCT tone guarantees a contrast ratio >= 3.0, and a difference of 50 guarantees a + * contrast ratio >= 4.5. + */ + +/** + * HCT, hue, chroma, and tone. A color system that provides a perceptually accurate color + * measurement system that can also accurately render what colors will appear as in different + * lighting environments. + */ +class Hct private constructor(argb: Int) { + var hue = 0.0 + private set + + var chroma = 0.0 + private set + + var tone = 0.0 + private set + + private var argb = 0 + + init { + setInternalState(argb) + } + + fun toInt(): Int { + return argb + } + + /** + * Set the hue of this color. Chroma may decrease because chroma has a different maximum for any + * given hue and tone. + * + * @param newHue 0 <= newHue < 360; invalid values are corrected. + */ + fun setHue(newHue: Double) { + setInternalState(HctSolver.solveToInt(newHue, chroma, tone)) + } + + /** + * Set the chroma of this color. Chroma may decrease because chroma has a different maximum for + * any given hue and tone. + * + * @param newChroma 0 <= newChroma < ? + */ + fun setChroma(newChroma: Double) { + setInternalState(HctSolver.solveToInt(hue, newChroma, tone)) + } + + /** + * Set the tone of this color. Chroma may decrease because chroma has a different maximum for any + * given hue and tone. + * + * @param newTone 0 <= newTone <= 100; invalid valids are corrected. + */ + fun setTone(newTone: Double) { + setInternalState(HctSolver.solveToInt(hue, chroma, newTone)) + } + + override fun toString(): String { + return "HCT(${hue.roundToInt()}, ${chroma.roundToInt()}, ${tone.roundToInt()})" + } + + /** + * Translate a color into different ViewingConditions. + * + * Colors change appearance. They look different with lights on versus off, the same color, as in + * hex code, on white looks different when on black. This is called color relativity, most + * famously explicated by Josef Albers in Interaction of Color. + * + * In color science, color appearance models can account for this and calculate the appearance of + * a color in different settings. HCT is based on CAM16, a color appearance model, and uses it to + * make these calculations. + * + * See ViewingConditions.make for parameters affecting color appearance. + */ + fun inViewingConditions(vc: ViewingConditions): Hct { + // 1. Use CAM16 to find XYZ coordinates of color in specified VC. + val cam16 = Cam16.fromInt(toInt()) + val viewedInVc = cam16.xyzInViewingConditions(vc, null) + + // 2. Create CAM16 of those XYZ coordinates in default VC. + val recastInVc = + Cam16.fromXyzInViewingConditions( + viewedInVc[0], + viewedInVc[1], + viewedInVc[2], + ViewingConditions.DEFAULT, + ) + + // 3. Create HCT from: + // - CAM16 using default VC with XYZ coordinates in specified VC. + // - L* converted from Y in XYZ coordinates in specified VC. + return from(recastInVc.hue, recastInVc.chroma, ColorUtils.lstarFromY(viewedInVc[1])) + } + + private fun setInternalState(argb: Int) { + this.argb = argb + val cam = Cam16.fromInt(argb) + hue = cam.hue + chroma = cam.chroma + tone = ColorUtils.lstarFromArgb(argb) + } + + companion object { + /** + * Create an HCT color from hue, chroma, and tone. + * + * @param hue 0 <= hue < 360; invalid values are corrected. + * @param chroma 0 <= chroma < ?; Informally, colorfulness. The color returned may be lower than + * the requested chroma. Chroma has a different maximum for any given hue and tone. + * @param tone 0 <= tone <= 100; invalid values are corrected. + * @return HCT representation of a color in default viewing conditions. + */ + @JvmStatic + fun from(hue: Double, chroma: Double, tone: Double): Hct { + val argb = HctSolver.solveToInt(hue, chroma, tone) + return Hct(argb) + } + + /** + * Create an HCT color from a color. + * + * @param argb ARGB representation of a color. + * @return HCT representation of a color in default viewing conditions + */ + @JvmStatic + fun fromInt(argb: Int): Hct { + return Hct(argb) + } + + @JvmStatic + fun isBlue(hue: Double): Boolean { + return hue >= 250 && hue < 270 + } + + @JvmStatic + fun isYellow(hue: Double): Boolean { + return hue >= 105 && hue < 125 + } + + @JvmStatic + fun isCyan(hue: Double): Boolean { + return hue >= 170 && hue < 207 + } + } +} diff --git a/libs/material-color-utilities/src/main/java/hct/HctSolver.java b/libs/material-color-utilities/src/main/java/hct/HctSolver.java deleted file mode 100644 index 955080c1c..000000000 --- a/libs/material-color-utilities/src/main/java/hct/HctSolver.java +++ /dev/null @@ -1,672 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// This file is automatically generated. Do not modify it. - -package hct; - -import utils.ColorUtils; -import utils.MathUtils; - -/** A class that solves the HCT equation. */ -public class HctSolver { - private HctSolver() {} - - static final double[][] SCALED_DISCOUNT_FROM_LINRGB = - new double[][] { - new double[] { - 0.001200833568784504, 0.002389694492170889, 0.0002795742885861124, - }, - new double[] { - 0.0005891086651375999, 0.0029785502573438758, 0.0003270666104008398, - }, - new double[] { - 0.00010146692491640572, 0.0005364214359186694, 0.0032979401770712076, - }, - }; - - static final double[][] LINRGB_FROM_SCALED_DISCOUNT = - new double[][] { - new double[] { - 1373.2198709594231, -1100.4251190754821, -7.278681089101213, - }, - new double[] { - -271.815969077903, 559.6580465940733, -32.46047482791194, - }, - new double[] { - 1.9622899599665666, -57.173814538844006, 308.7233197812385, - }, - }; - - static final double[] Y_FROM_LINRGB = new double[] {0.2126, 0.7152, 0.0722}; - - static final double[] CRITICAL_PLANES = - new double[] { - 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 small enough angle in radians. - * - * @param angle An angle in radians; must not deviate too much from 0. - * @return A coterminal angle between 0 and 2pi. - */ - static double sanitizeRadians(double angle) { - return (angle + Math.PI * 8) % (Math.PI * 2); - } - - /** - * Delinearizes an RGB component, returning a floating-point number. - * - * @param rgbComponent 0.0 <= rgb_component <= 100.0, represents linear R/G/B channel - * @return 0.0 <= output <= 255.0, color channel converted to regular RGB space - */ - static double trueDelinearized(double rgbComponent) { - double normalized = rgbComponent / 100.0; - double delinearized = 0.0; - if (normalized <= 0.0031308) { - delinearized = normalized * 12.92; - } else { - delinearized = 1.055 * Math.pow(normalized, 1.0 / 2.4) - 0.055; - } - return delinearized * 255.0; - } - - static double chromaticAdaptation(double component) { - double af = Math.pow(Math.abs(component), 0.42); - return MathUtils.signum(component) * 400.0 * af / (af + 27.13); - } - - /** - * Returns the hue of a linear RGB color in CAM16. - * - * @param linrgb The linear RGB coordinates of a color. - * @return The hue of the color in CAM16, in radians. - */ - static double hueOf(double[] linrgb) { - double[] scaledDiscount = MathUtils.matrixMultiply(linrgb, SCALED_DISCOUNT_FROM_LINRGB); - double rA = chromaticAdaptation(scaledDiscount[0]); - double gA = chromaticAdaptation(scaledDiscount[1]); - double bA = chromaticAdaptation(scaledDiscount[2]); - // redness-greenness - double a = (11.0 * rA + -12.0 * gA + bA) / 11.0; - // yellowness-blueness - double b = (rA + gA - 2.0 * bA) / 9.0; - return Math.atan2(b, a); - } - - static boolean areInCyclicOrder(double a, double b, double c) { - double deltaAB = sanitizeRadians(b - a); - double deltaAC = sanitizeRadians(c - a); - return deltaAB < deltaAC; - } - - /** - * Solves the lerp equation. - * - * @param source The starting number. - * @param mid The number in the middle. - * @param target The ending number. - * @return A number t such that lerp(source, target, t) = mid. - */ - static double intercept(double source, double mid, double target) { - return (mid - source) / (target - source); - } - - static double[] lerpPoint(double[] source, double t, double[] target) { - return new double[] { - source[0] + (target[0] - source[0]) * t, - source[1] + (target[1] - source[1]) * t, - source[2] + (target[2] - source[2]) * t, - }; - } - - /** - * 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) - * @return The intersection point of the segment AB with the plane R=coordinate, G=coordinate, or - * B=coordinate - */ - static double[] setCoordinate(double[] source, double coordinate, double[] target, int axis) { - double t = intercept(source[axis], coordinate, target[axis]); - return lerpPoint(source, t, target); - } - - static boolean isBounded(double x) { - return 0.0 <= x && x <= 100.0; - } - - /** - * 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. - * @return The nth possible vertex of the polygonal intersection of the y plane and the RGB cube, - * in linear RGB coordinates, if it exists. If this possible vertex lies outside of the cube, - * [-1.0, -1.0, -1.0] is returned. - */ - static double[] nthVertex(double y, int n) { - double kR = Y_FROM_LINRGB[0]; - double kG = Y_FROM_LINRGB[1]; - double kB = Y_FROM_LINRGB[2]; - double coordA = n % 4 <= 1 ? 0.0 : 100.0; - double coordB = n % 2 == 0 ? 0.0 : 100.0; - if (n < 4) { - double g = coordA; - double b = coordB; - double r = (y - g * kG - b * kB) / kR; - if (isBounded(r)) { - return new double[] {r, g, b}; - } else { - return new double[] {-1.0, -1.0, -1.0}; - } - } else if (n < 8) { - double b = coordA; - double r = coordB; - double g = (y - r * kR - b * kB) / kG; - if (isBounded(g)) { - return new double[] {r, g, b}; - } else { - return new double[] {-1.0, -1.0, -1.0}; - } - } else { - double r = coordA; - double g = coordB; - double b = (y - r * kR - g * kG) / kB; - if (isBounded(b)) { - return new double[] {r, g, b}; - } else { - return new double[] {-1.0, -1.0, -1.0}; - } - } - } - - /** - * Finds the segment containing the desired color. - * - * @param y The Y value of the color. - * @param targetHue The hue of the color. - * @return A list of two sets of linear RGB coordinates, each corresponding to an endpoint of the - * segment containing the desired color. - */ - static double[][] bisectToSegment(double y, double targetHue) { - double[] left = new double[] {-1.0, -1.0, -1.0}; - double[] right = left; - double leftHue = 0.0; - double rightHue = 0.0; - boolean initialized = false; - boolean uncut = true; - for (int n = 0; n < 12; n++) { - double[] mid = nthVertex(y, n); - if (mid[0] < 0) { - continue; - } - double midHue = hueOf(mid); - if (!initialized) { - left = mid; - right = mid; - leftHue = midHue; - rightHue = midHue; - initialized = true; - continue; - } - if (uncut || areInCyclicOrder(leftHue, midHue, rightHue)) { - uncut = false; - if (areInCyclicOrder(leftHue, targetHue, midHue)) { - right = mid; - rightHue = midHue; - } else { - left = mid; - leftHue = midHue; - } - } - } - return new double[][] {left, right}; - } - - static double[] midpoint(double[] a, double[] b) { - return new double[] { - (a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2, - }; - } - - static int criticalPlaneBelow(double x) { - return (int) Math.floor(x - 0.5); - } - - static int criticalPlaneAbove(double x) { - return (int) Math.ceil(x - 0.5); - } - - /** - * 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. - * @return The desired color, in linear RGB coordinates. - */ - static double[] bisectToLimit(double y, double targetHue) { - double[][] segment = bisectToSegment(y, targetHue); - double[] left = segment[0]; - double leftHue = hueOf(left); - double[] right = segment[1]; - for (int axis = 0; axis < 3; axis++) { - if (left[axis] != right[axis]) { - int lPlane = -1; - int rPlane = 255; - if (left[axis] < right[axis]) { - lPlane = criticalPlaneBelow(trueDelinearized(left[axis])); - rPlane = criticalPlaneAbove(trueDelinearized(right[axis])); - } else { - lPlane = criticalPlaneAbove(trueDelinearized(left[axis])); - rPlane = criticalPlaneBelow(trueDelinearized(right[axis])); - } - for (int i = 0; i < 8; i++) { - if (Math.abs(rPlane - lPlane) <= 1) { - break; - } else { - int mPlane = (int) Math.floor((lPlane + rPlane) / 2.0); - double midPlaneCoordinate = CRITICAL_PLANES[mPlane]; - double[] mid = setCoordinate(left, midPlaneCoordinate, right, axis); - double midHue = hueOf(mid); - if (areInCyclicOrder(leftHue, targetHue, midHue)) { - right = mid; - rPlane = mPlane; - } else { - left = mid; - leftHue = midHue; - lPlane = mPlane; - } - } - } - } - } - return midpoint(left, right); - } - - static double inverseChromaticAdaptation(double adapted) { - double adaptedAbs = Math.abs(adapted); - double base = Math.max(0, 27.13 * adaptedAbs / (400.0 - adaptedAbs)); - return MathUtils.signum(adapted) * Math.pow(base, 1.0 / 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. - * @return The desired color as a hexadecimal integer, if found; 0 otherwise. - */ - static int findResultByJ(double hueRadians, double chroma, double y) { - // Initial estimate of j. - double j = Math.sqrt(y) * 11.0; - // =========================================================== - // Operations inlined from Cam16 to avoid repeated calculation - // =========================================================== - ViewingConditions viewingConditions = ViewingConditions.DEFAULT; - double tInnerCoeff = 1 / Math.pow(1.64 - Math.pow(0.29, viewingConditions.getN()), 0.73); - double eHue = 0.25 * (Math.cos(hueRadians + 2.0) + 3.8); - double p1 = eHue * (50000.0 / 13.0) * viewingConditions.getNc() * viewingConditions.getNcb(); - double hSin = Math.sin(hueRadians); - double hCos = Math.cos(hueRadians); - for (int iterationRound = 0; iterationRound < 5; iterationRound++) { - // =========================================================== - // Operations inlined from Cam16 to avoid repeated calculation - // =========================================================== - double jNormalized = j / 100.0; - double alpha = chroma == 0.0 || j == 0.0 ? 0.0 : chroma / Math.sqrt(jNormalized); - double t = Math.pow(alpha * tInnerCoeff, 1.0 / 0.9); - double ac = - viewingConditions.getAw() - * Math.pow(jNormalized, 1.0 / viewingConditions.getC() / viewingConditions.getZ()); - double p2 = ac / viewingConditions.getNbb(); - double gamma = 23.0 * (p2 + 0.305) * t / (23.0 * p1 + 11 * t * hCos + 108.0 * t * hSin); - double a = gamma * hCos; - double b = gamma * hSin; - double rA = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0; - double gA = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0; - double bA = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0; - double rCScaled = inverseChromaticAdaptation(rA); - double gCScaled = inverseChromaticAdaptation(gA); - double bCScaled = inverseChromaticAdaptation(bA); - double[] linrgb = - MathUtils.matrixMultiply( - new double[] {rCScaled, gCScaled, bCScaled}, LINRGB_FROM_SCALED_DISCOUNT); - // =========================================================== - // Operations inlined from Cam16 to avoid repeated calculation - // =========================================================== - if (linrgb[0] < 0 || linrgb[1] < 0 || linrgb[2] < 0) { - return 0; - } - double kR = Y_FROM_LINRGB[0]; - double kG = Y_FROM_LINRGB[1]; - double kB = Y_FROM_LINRGB[2]; - double fnj = kR * linrgb[0] + kG * linrgb[1] + kB * linrgb[2]; - if (fnj <= 0) { - return 0; - } - if (iterationRound == 4 || Math.abs(fnj - y) < 0.002) { - if (linrgb[0] > 100.01 || linrgb[1] > 100.01 || linrgb[2] > 100.01) { - return 0; - } - return ColorUtils.argbFromLinrgb(linrgb); - } - // 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*. - * @return 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. - */ - public static int solveToInt(double hueDegrees, double chroma, double lstar) { - if (chroma < 0.0001 || lstar < 0.0001 || lstar > 99.9999) { - return ColorUtils.argbFromLstar(lstar); - } - hueDegrees = MathUtils.sanitizeDegreesDouble(hueDegrees); - double hueRadians = hueDegrees / 180 * Math.PI; - double y = ColorUtils.yFromLstar(lstar); - int exactAnswer = findResultByJ(hueRadians, chroma, y); - if (exactAnswer != 0) { - return exactAnswer; - } - double[] linrgb = bisectToLimit(y, hueRadians); - return ColorUtils.argbFromLinrgb(linrgb); - } - - /** - * 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*. - * @return An CAM16 object 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. - */ - public static Cam16 solveToCam(double hueDegrees, double chroma, double lstar) { - return Cam16.fromInt(solveToInt(hueDegrees, chroma, lstar)); - } -} - diff --git a/libs/material-color-utilities/src/main/java/hct/HctSolver.kt b/libs/material-color-utilities/src/main/java/hct/HctSolver.kt new file mode 100644 index 000000000..e037ded85 --- /dev/null +++ b/libs/material-color-utilities/src/main/java/hct/HctSolver.kt @@ -0,0 +1,670 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// This file is automatically generated. Do not modify it. +package hct + +import utils.ColorUtils +import utils.MathUtils +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.ceil +import kotlin.math.cos +import kotlin.math.floor +import kotlin.math.pow +import kotlin.math.sign +import kotlin.math.sin +import kotlin.math.sqrt + +/** A class that solves the HCT equation. */ +object HctSolver { + private val SCALED_DISCOUNT_FROM_LINRGB = + arrayOf( + doubleArrayOf(0.001200833568784504, 0.002389694492170889, 0.0002795742885861124), + doubleArrayOf(0.0005891086651375999, 0.0029785502573438758, 0.0003270666104008398), + doubleArrayOf(0.00010146692491640572, 0.0005364214359186694, 0.0032979401770712076), + ) + private val LINRGB_FROM_SCALED_DISCOUNT = + arrayOf( + doubleArrayOf(1373.2198709594231, -1100.4251190754821, -7.278681089101213), + doubleArrayOf(-271.815969077903, 559.6580465940733, -32.46047482791194), + doubleArrayOf(1.9622899599665666, -57.173814538844006, 308.7233197812385), + ) + private val Y_FROM_LINRGB = doubleArrayOf(0.2126, 0.7152, 0.0722) + private val CRITICAL_PLANES = + doubleArrayOf( + 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 small enough angle in radians. + * + * @param angle An angle in radians; must not deviate too much from 0. + * @return A coterminal angle between 0 and 2pi. + */ + internal fun sanitizeRadians(angle: Double): Double { + return (angle + PI * 8) % (PI * 2) + } + + /** + * Delinearizes an RGB component, returning a floating-point number. + * + * @param rgbComponent 0.0 <= rgb_component <= 100.0, represents linear R/G/B channel + * @return 0.0 <= output <= 255.0, color channel converted to regular RGB space + */ + internal fun trueDelinearized(rgbComponent: Double): Double { + val normalized = rgbComponent / 100.0 + val delinearized: Double = + if (normalized <= 0.0031308) { + normalized * 12.92 + } else { + 1.055 * normalized.pow(1.0 / 2.4) - 0.055 + } + return delinearized * 255.0 + } + + internal fun chromaticAdaptation(component: Double): Double { + val af = abs(component).pow(0.42) + return sign(component) * 400.0 * af / (af + 27.13) + } + + /** + * Returns the hue of a linear RGB color in CAM16. + * + * @param linrgb The linear RGB coordinates of a color. + * @return The hue of the color in CAM16, in radians. + */ + internal fun hueOf(linrgb: DoubleArray): Double { + val scaledDiscount = MathUtils.matrixMultiply(linrgb, SCALED_DISCOUNT_FROM_LINRGB) + val rA = chromaticAdaptation(scaledDiscount[0]) + val gA = chromaticAdaptation(scaledDiscount[1]) + val bA = chromaticAdaptation(scaledDiscount[2]) + // redness-greenness + val a = (11.0 * rA + -12.0 * gA + bA) / 11.0 + // yellowness-blueness + val b = (rA + gA - 2.0 * bA) / 9.0 + return atan2(b, a) + } + + internal fun areInCyclicOrder(a: Double, b: Double, c: Double): Boolean { + val deltaAB = sanitizeRadians(b - a) + val deltaAC = sanitizeRadians(c - a) + return deltaAB < deltaAC + } + + /** + * Solves the lerp equation. + * + * @param source The starting number. + * @param mid The number in the middle. + * @param target The ending number. + * @return A number t such that lerp(source, target, t) = mid. + */ + internal fun intercept(source: Double, mid: Double, target: Double): Double { + return (mid - source) / (target - source) + } + + internal fun lerpPoint(source: DoubleArray, t: Double, target: DoubleArray): DoubleArray { + return doubleArrayOf( + source[0] + (target[0] - source[0]) * t, + source[1] + (target[1] - source[1]) * t, + source[2] + (target[2] - source[2]) * t, + ) + } + + /** + * 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) + * @return The intersection point of the segment AB with the plane R=coordinate, G=coordinate, or + * B=coordinate + */ + internal fun setCoordinate( + source: DoubleArray, + coordinate: Double, + target: DoubleArray, + axis: Int, + ): DoubleArray { + val t = intercept(source[axis], coordinate, target[axis]) + return lerpPoint(source, t, target) + } + + internal fun isBounded(x: Double): Boolean { + return 0.0 <= x && x <= 100.0 + } + + /** + * 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. + * @return The nth possible vertex of the polygonal intersection of the y plane and the RGB cube, + * in linear RGB coordinates, if it exists. If this possible vertex lies outside of the cube, + * null is returned. + */ + internal fun nthVertex(y: Double, n: Int): DoubleArray? { + val kR = Y_FROM_LINRGB[0] + val kG = Y_FROM_LINRGB[1] + val kB = Y_FROM_LINRGB[2] + val coordA = if (n % 4 <= 1) 0.0 else 100.0 + val coordB = if (n % 2 == 0) 0.0 else 100.0 + return when { + n < 4 -> { + val g = coordA + val b = coordB + val r = (y - g * kG - b * kB) / kR + if (isBounded(r)) { + doubleArrayOf(r, g, b) + } else { + null + } + } + n < 8 -> { + val b = coordA + val r = coordB + val g = (y - r * kR - b * kB) / kG + if (isBounded(g)) { + doubleArrayOf(r, g, b) + } else { + null + } + } + else -> { + val r = coordA + val g = coordB + val b = (y - r * kR - g * kG) / kB + if (isBounded(b)) { + doubleArrayOf(r, g, b) + } else { + null + } + } + } + } + + /** + * Finds the segment containing the desired color. + * + * @param y The Y value of the color. + * @param targetHue The hue of the color. + * @return A list of two sets of linear RGB coordinates, each corresponding to an endpoint of the + * segment containing the desired color. + */ + internal fun bisectToSegment(y: Double, targetHue: Double): Array { + var left: DoubleArray? = null + var right: DoubleArray? = null + var leftHue = 0.0 + var rightHue = 0.0 + var initialized = false + var uncut = true + for (n in 0..11) { + val mid = nthVertex(y, n) + if (mid != null) { + val midHue = hueOf(mid) + if (!initialized) { + left = mid + right = mid + leftHue = midHue + rightHue = midHue + initialized = true + } else if (uncut || areInCyclicOrder(leftHue, midHue, rightHue)) { + uncut = false + if (areInCyclicOrder(leftHue, targetHue, midHue)) { + right = mid + rightHue = midHue + } else { + left = mid + leftHue = midHue + } + } + } + } + return arrayOf(left!!, right!!) + } + + internal fun midpoint(a: DoubleArray, b: DoubleArray): DoubleArray { + return doubleArrayOf((a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2) + } + + internal fun criticalPlaneBelow(x: Double): Int { + return floor(x - 0.5).toInt() + } + + internal fun criticalPlaneAbove(x: Double): Int { + return ceil(x - 0.5).toInt() + } + + /** + * 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. + * @return The desired color, in linear RGB coordinates. + */ + internal fun bisectToLimit(y: Double, targetHue: Double): DoubleArray { + val segment = bisectToSegment(y, targetHue) + var left = segment[0] + var leftHue = hueOf(left) + var right = segment[1] + for (axis in 0..2) { + if (left[axis] != right[axis]) { + var lPlane = + if (left[axis] < right[axis]) { + criticalPlaneBelow(trueDelinearized(left[axis])) + } else { + criticalPlaneAbove(trueDelinearized(left[axis])) + } + var rPlane = + if (left[axis] < right[axis]) { + criticalPlaneAbove(trueDelinearized(right[axis])) + } else { + criticalPlaneBelow(trueDelinearized(right[axis])) + } + for (i in 0..7) { + if (abs((rPlane - lPlane).toDouble()) <= 1) { + break + } else { + val mPlane = floor((lPlane + rPlane) / 2.0).toInt() + val midPlaneCoordinate = CRITICAL_PLANES[mPlane] + val mid = setCoordinate(left, midPlaneCoordinate, right, axis) + val midHue = hueOf(mid) + if (areInCyclicOrder(leftHue, targetHue, midHue)) { + right = mid + rPlane = mPlane + } else { + left = mid + leftHue = midHue + lPlane = mPlane + } + } + } + } + } + return midpoint(left, right) + } + + internal fun inverseChromaticAdaptation(adapted: Double): Double { + val adaptedAbs = abs(adapted) + val base = Math.max(0.0, 27.13 * adaptedAbs / (400.0 - adaptedAbs)) + return sign(adapted) * base.pow(1.0 / 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. + * @return The desired color as a hexadecimal integer, if found; 0 otherwise. + */ + internal fun findResultByJ(hueRadians: Double, chroma: Double, y: Double): Int { + // Initial estimate of j. + var j = sqrt(y) * 11.0 + // =========================================================== + // Operations inlined from Cam16 to avoid repeated calculation + // =========================================================== + val viewingConditions = ViewingConditions.DEFAULT + val tInnerCoeff = 1 / (1.64 - 0.29.pow(viewingConditions.n)).pow(0.73) + val eHue = 0.25 * (cos(hueRadians + 2.0) + 3.8) + val p1 = eHue * (50000.0 / 13.0) * viewingConditions.nc * viewingConditions.ncb + val hSin = sin(hueRadians) + val hCos = cos(hueRadians) + for (iterationRound in 0..4) { + // =========================================================== + // Operations inlined from Cam16 to avoid repeated calculation + // =========================================================== + val jNormalized = j / 100.0 + val alpha = if (chroma == 0.0 || j == 0.0) 0.0 else chroma / sqrt(jNormalized) + val t = (alpha * tInnerCoeff).pow(1.0 / 0.9) + val ac = + viewingConditions.aw * jNormalized.pow(1.0 / viewingConditions.c / viewingConditions.z) + val p2 = ac / viewingConditions.nbb + val gamma = 23.0 * (p2 + 0.305) * t / (23.0 * p1 + 11 * t * hCos + 108.0 * t * hSin) + val a = gamma * hCos + val b = gamma * hSin + val rA = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0 + val gA = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0 + val bA = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0 + val rCScaled = inverseChromaticAdaptation(rA) + val gCScaled = inverseChromaticAdaptation(gA) + val bCScaled = inverseChromaticAdaptation(bA) + val linrgb = + MathUtils.matrixMultiply( + doubleArrayOf(rCScaled, gCScaled, bCScaled), + LINRGB_FROM_SCALED_DISCOUNT, + ) + // =========================================================== + // Operations inlined from Cam16 to avoid repeated calculation + // =========================================================== + if (linrgb[0] < 0 || linrgb[1] < 0 || linrgb[2] < 0) { + return 0 + } + val kR = Y_FROM_LINRGB[0] + val kG = Y_FROM_LINRGB[1] + val kB = Y_FROM_LINRGB[2] + val fnj = kR * linrgb[0] + kG * linrgb[1] + kB * linrgb[2] + if (fnj <= 0) { + return 0 + } + if (iterationRound == 4 || abs(fnj - y) < 0.002) { + return if (linrgb[0] > 100.01 || linrgb[1] > 100.01 || linrgb[2] > 100.01) { + 0 + } else { + ColorUtils.argbFromLinrgb(linrgb) + } + } + // Iterates with Newton method, + // Using 2 * fn(j) / j as the approximation of fn'(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*. + * @return 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. + */ + fun solveToInt(hueDegrees: Double, chroma: Double, lstar: Double): Int { + if (chroma < 0.0001 || lstar < 0.0001 || lstar > 99.9999) { + return ColorUtils.argbFromLstar(lstar) + } + val hueRadians = MathUtils.sanitizeDegreesDouble(hueDegrees) / 180 * PI + val y = ColorUtils.yFromLstar(lstar) + val exactAnswer = findResultByJ(hueRadians, chroma, y) + if (exactAnswer != 0) { + return exactAnswer + } + val linrgb = bisectToLimit(y, hueRadians) + return ColorUtils.argbFromLinrgb(linrgb) + } + + /** + * 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*. + * @return A CAM16 object 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. + */ + fun solveToCam(hueDegrees: Double, chroma: Double, lstar: Double): Cam16 { + return Cam16.fromInt(solveToInt(hueDegrees, chroma, lstar)) + } +} diff --git a/libs/material-color-utilities/src/main/java/hct/ViewingConditions.java b/libs/material-color-utilities/src/main/java/hct/ViewingConditions.java deleted file mode 100644 index 3e452de07..000000000 --- a/libs/material-color-utilities/src/main/java/hct/ViewingConditions.java +++ /dev/null @@ -1,205 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package hct; - -import utils.ColorUtils; -import utils.MathUtils; - -/** - * In traditional color spaces, a color can be identified solely by the observer's measurement of - * the color. Color appearance models such as CAM16 also use information about the environment where - * the color was observed, known as the viewing conditions. - * - *

For example, white under the traditional assumption of a midday sun white point is accurately - * measured as a slightly chromatic blue by CAM16. (roughly, hue 203, chroma 3, lightness 100) - * - *

This class caches intermediate values of the CAM16 conversion process that depend only on - * viewing conditions, enabling speed ups. - */ -public final class ViewingConditions { - /** sRGB-like viewing conditions. */ - public static final ViewingConditions DEFAULT = - ViewingConditions.defaultWithBackgroundLstar(50.0); - - private final double aw; - private final double nbb; - private final double ncb; - private final double c; - private final double nc; - private final double n; - private final double[] rgbD; - private final double fl; - private final double flRoot; - private final double z; - - public double getAw() { - return aw; - } - - public double getN() { - return n; - } - - public double getNbb() { - return nbb; - } - - double getNcb() { - return ncb; - } - - double getC() { - return c; - } - - double getNc() { - return nc; - } - - public double[] getRgbD() { - return rgbD; - } - - double getFl() { - return fl; - } - - public double getFlRoot() { - return flRoot; - } - - double getZ() { - return z; - } - - /** - * Create ViewingConditions from a simple, physically relevant, set of parameters. - * - * @param whitePoint White point, measured in the XYZ color space. default = D65, or sunny day - * afternoon - * @param adaptingLuminance The luminance of the adapting field. Informally, how bright it is in - * the room where the color is viewed. Can be calculated from lux by multiplying lux by - * 0.0586. default = 11.72, or 200 lux. - * @param backgroundLstar The lightness of the area surrounding the color. measured by L* in - * L*a*b*. default = 50.0 - * @param surround A general description of the lighting surrounding the color. 0 is pitch dark, - * like watching a movie in a theater. 1.0 is a dimly light room, like watching TV at home at - * night. 2.0 means there is no difference between the lighting on the color and around it. - * default = 2.0 - * @param discountingIlluminant Whether the eye accounts for the tint of the ambient lighting, - * such as knowing an apple is still red in green light. default = false, the eye does not - * perform this process on self-luminous objects like displays. - */ - public static ViewingConditions make( - double[] whitePoint, - double adaptingLuminance, - double backgroundLstar, - double surround, - boolean discountingIlluminant) { - // A background of pure black is non-physical and leads to infinities that represent the idea - // that any color viewed in pure black can't be seen. - backgroundLstar = Math.max(0.1, backgroundLstar); - // Transform white point XYZ to 'cone'/'rgb' responses - double[][] matrix = Cam16.XYZ_TO_CAM16RGB; - double[] xyz = whitePoint; - double rW = (xyz[0] * matrix[0][0]) + (xyz[1] * matrix[0][1]) + (xyz[2] * matrix[0][2]); - double gW = (xyz[0] * matrix[1][0]) + (xyz[1] * matrix[1][1]) + (xyz[2] * matrix[1][2]); - double bW = (xyz[0] * matrix[2][0]) + (xyz[1] * matrix[2][1]) + (xyz[2] * matrix[2][2]); - double f = 0.8 + (surround / 10.0); - double c = - (f >= 0.9) - ? MathUtils.lerp(0.59, 0.69, ((f - 0.9) * 10.0)) - : MathUtils.lerp(0.525, 0.59, ((f - 0.8) * 10.0)); - double d = - discountingIlluminant - ? 1.0 - : f * (1.0 - ((1.0 / 3.6) * Math.exp((-adaptingLuminance - 42.0) / 92.0))); - d = MathUtils.clampDouble(0.0, 1.0, d); - double nc = f; - double[] rgbD = - new double[] { - d * (100.0 / rW) + 1.0 - d, d * (100.0 / gW) + 1.0 - d, d * (100.0 / bW) + 1.0 - d - }; - double k = 1.0 / (5.0 * adaptingLuminance + 1.0); - double k4 = k * k * k * k; - double k4F = 1.0 - k4; - double fl = (k4 * adaptingLuminance) + (0.1 * k4F * k4F * Math.cbrt(5.0 * adaptingLuminance)); - double n = (ColorUtils.yFromLstar(backgroundLstar) / whitePoint[1]); - double z = 1.48 + Math.sqrt(n); - double nbb = 0.725 / Math.pow(n, 0.2); - double ncb = nbb; - double[] rgbAFactors = - new double[] { - Math.pow(fl * rgbD[0] * rW / 100.0, 0.42), - Math.pow(fl * rgbD[1] * gW / 100.0, 0.42), - Math.pow(fl * rgbD[2] * bW / 100.0, 0.42) - }; - - double[] rgbA = - new double[] { - (400.0 * rgbAFactors[0]) / (rgbAFactors[0] + 27.13), - (400.0 * rgbAFactors[1]) / (rgbAFactors[1] + 27.13), - (400.0 * rgbAFactors[2]) / (rgbAFactors[2] + 27.13) - }; - - double aw = ((2.0 * rgbA[0]) + rgbA[1] + (0.05 * rgbA[2])) * nbb; - return new ViewingConditions(n, aw, nbb, ncb, c, nc, rgbD, fl, Math.pow(fl, 0.25), z); - } - - /** - * Create sRGB-like viewing conditions with a custom background lstar. - * - *

Default viewing conditions have a lstar of 50, midgray. - */ - public static ViewingConditions defaultWithBackgroundLstar(double lstar) { - return ViewingConditions.make( - ColorUtils.whitePointD65(), - (200.0 / Math.PI * ColorUtils.yFromLstar(50.0) / 100.f), - lstar, - 2.0, - false); - } - - /** - * Parameters are intermediate values of the CAM16 conversion process. Their names are shorthand - * for technical color science terminology, this class would not benefit from documenting them - * individually. A brief overview is available in the CAM16 specification, and a complete overview - * requires a color science textbook, such as Fairchild's Color Appearance Models. - */ - private ViewingConditions( - double n, - double aw, - double nbb, - double ncb, - double c, - double nc, - double[] rgbD, - double fl, - double flRoot, - double z) { - this.n = n; - this.aw = aw; - this.nbb = nbb; - this.ncb = ncb; - this.c = c; - this.nc = nc; - this.rgbD = rgbD; - this.fl = fl; - this.flRoot = flRoot; - this.z = z; - } -} diff --git a/libs/material-color-utilities/src/main/java/hct/ViewingConditions.kt b/libs/material-color-utilities/src/main/java/hct/ViewingConditions.kt new file mode 100644 index 000000000..eacb01c06 --- /dev/null +++ b/libs/material-color-utilities/src/main/java/hct/ViewingConditions.kt @@ -0,0 +1,159 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package hct + +import utils.ColorUtils +import utils.MathUtils +import kotlin.math.PI +import kotlin.math.cbrt +import kotlin.math.exp +import kotlin.math.max +import kotlin.math.pow +import kotlin.math.sqrt + +/** + * In traditional color spaces, a color can be identified solely by the observer's measurement of + * the color. Color appearance models such as CAM16 also use information about the environment where + * the color was observed, known as the viewing conditions. + * + * For example, white under the traditional assumption of a midday sun white point is accurately + * measured as a slightly chromatic blue by CAM16. (roughly, hue 203, chroma 3, lightness 100) + * + * This class caches intermediate values of the CAM16 conversion process that depend only on viewing + * conditions, enabling speed ups. + */ +@ConsistentCopyVisibility +data class ViewingConditions +/** + * Parameters are intermediate values of the CAM16 conversion process. Their names are shorthand for + * technical color science terminology, this class would not benefit from documenting them + * individually. A brief overview is available in the CAM16 specification, and a complete overview + * requires a color science textbook, such as Fairchild's Color Appearance Models. + */ +private constructor( + val n: Double, + val aw: Double, + val nbb: Double, + internal val ncb: Double, + internal val c: Double, + internal val nc: Double, + val rgbD: DoubleArray, + internal val fl: Double, + val flRoot: Double, + internal val z: Double, +) { + + companion object { + /** sRGB-like viewing conditions. */ + val DEFAULT = defaultWithBackgroundLstar(50.0) + + /** + * Create ViewingConditions from a simple, physically relevant, set of parameters. + * + * @param whitePoint White point, measured in the XYZ color space. default = D65, or sunny day + * afternoon + * @param adaptingLuminance The luminance of the adapting field. Informally, how bright it is in + * the room where the color is viewed. Can be calculated from lux by multiplying lux by + * 0.0586. default = 11.72, or 200 lux. + * @param backgroundLstar The lightness of the area surrounding the color. measured by L* in + * L*a*b*. default = 50.0 + * @param surround A general description of the lighting surrounding the color. 0 is pitch dark, + * like watching a movie in a theater. 1.0 is a dimly light room, like watching TV at home at + * night. 2.0 means there is no difference between the lighting on the color and around it. + * default = 2.0 + * @param discountingIlluminant Whether the eye accounts for the tint of the ambient lighting, + * such as knowing an apple is still red in green light. default = false, the eye does not + * perform this process on self-luminous objects like displays. + */ + @JvmStatic + fun make( + whitePoint: DoubleArray, + adaptingLuminance: Double, + backgroundLstar: Double, + surround: Double, + discountingIlluminant: Boolean, + ): ViewingConditions { + // A background of pure black is non-physical and leads to infinities that represent the idea + // that any color viewed in pure black can't be seen. + val backgroundLstar = max(0.1, backgroundLstar) + // Transform white point XYZ to 'cone'/'rgb' responses + val matrix = Cam16.XYZ_TO_CAM16RGB + val xyz = whitePoint + val rW = xyz[0] * matrix[0][0] + xyz[1] * matrix[0][1] + xyz[2] * matrix[0][2] + val gW = xyz[0] * matrix[1][0] + xyz[1] * matrix[1][1] + xyz[2] * matrix[1][2] + val bW = xyz[0] * matrix[2][0] + xyz[1] * matrix[2][1] + xyz[2] * matrix[2][2] + val f = 0.8 + surround / 10.0 + val c = + if (f >= 0.9) { + MathUtils.lerp(0.59, 0.69, (f - 0.9) * 10.0) + } else { + MathUtils.lerp(0.525, 0.59, (f - 0.8) * 10.0) + } + var d = + if (discountingIlluminant) { + 1.0 + } else { + f * (1.0 - 1.0 / 3.6 * exp((-adaptingLuminance - 42.0) / 92.0)) + } + d = d.coerceIn(0.0, 1.0) + val nc = f + val rgbD = + doubleArrayOf( + d * (100.0 / rW) + 1.0 - d, + d * (100.0 / gW) + 1.0 - d, + d * (100.0 / bW) + 1.0 - d, + ) + val k = 1.0 / (5.0 * adaptingLuminance + 1.0) + val k4 = k * k * k * k + val k4F = 1.0 - k4 + val fl = k4 * adaptingLuminance + 0.1 * k4F * k4F * cbrt(5.0 * adaptingLuminance) + val n = ColorUtils.yFromLstar(backgroundLstar) / whitePoint[1] + val z = 1.48 + sqrt(n) + val nbb = 0.725 / n.pow(0.2) + val ncb = nbb + val rgbAFactors = + doubleArrayOf( + (fl * rgbD[0] * rW / 100.0).pow(0.42), + (fl * rgbD[1] * gW / 100.0).pow(0.42), + (fl * rgbD[2] * bW / 100.0).pow(0.42), + ) + val rgbA = + doubleArrayOf( + 400.0 * rgbAFactors[0] / (rgbAFactors[0] + 27.13), + 400.0 * rgbAFactors[1] / (rgbAFactors[1] + 27.13), + 400.0 * rgbAFactors[2] / (rgbAFactors[2] + 27.13), + ) + val aw = (2.0 * rgbA[0] + rgbA[1] + 0.05 * rgbA[2]) * nbb + return ViewingConditions(n, aw, nbb, ncb, c, nc, rgbD, fl, fl.pow(0.25), z) + } + + /** + * Create sRGB-like viewing conditions with a custom background lstar. + * + * Default viewing conditions have a lstar of 50, midgray. + */ + @JvmStatic + fun defaultWithBackgroundLstar(lstar: Double): ViewingConditions { + return make( + ColorUtils.whitePointD65(), + 200.0 / PI * ColorUtils.yFromLstar(50.0) / 100f, + lstar, + 2.0, + false, + ) + } + } +} diff --git a/libs/material-color-utilities/src/main/java/palettes/CorePalette.java b/libs/material-color-utilities/src/main/java/palettes/CorePalette.java deleted file mode 100644 index 6095ae3b4..000000000 --- a/libs/material-color-utilities/src/main/java/palettes/CorePalette.java +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package palettes; - -import static java.lang.Math.max; -import static java.lang.Math.min; - -import hct.Hct; - -/** - * An intermediate concept between the key color for a UI theme, and a full color scheme. 5 sets of - * tones are generated, all except one use the same hue as the key color, and all vary in chroma. - */ -public final class CorePalette { - public TonalPalette a1; - public TonalPalette a2; - public TonalPalette a3; - public TonalPalette n1; - public TonalPalette n2; - public TonalPalette error; - - /** - * Create key tones from a color. - * - * @param argb ARGB representation of a color - */ - public static CorePalette of(int argb) { - return new CorePalette(argb, false); - } - - /** - * Create content key tones from a color. - * - * @param argb ARGB representation of a color - */ - public static CorePalette contentOf(int argb) { - return new CorePalette(argb, true); - } - - public CorePalette(TonalPalette a1, TonalPalette a2, TonalPalette a3, TonalPalette n1, TonalPalette n2, TonalPalette error) { - this.a1 = a1; - this.a2 = a2; - this.a3 = a3; - this.n1 = n1; - this.n2 = n2; - this.error = error; - } - - private CorePalette(int argb, boolean isContent) { - Hct hct = Hct.fromInt(argb); - double hue = hct.getHue(); - double chroma = hct.getChroma(); - if (isContent) { - this.a1 = TonalPalette.fromHueAndChroma(hue, chroma); - this.a2 = TonalPalette.fromHueAndChroma(hue, chroma / 3.); - this.a3 = TonalPalette.fromHueAndChroma(hue + 60., chroma / 2.); - this.n1 = TonalPalette.fromHueAndChroma(hue, min(chroma / 12., 4.)); - this.n2 = TonalPalette.fromHueAndChroma(hue, min(chroma / 6., 8.)); - } else { - this.a1 = TonalPalette.fromHueAndChroma(hue, max(48., chroma)); - this.a2 = TonalPalette.fromHueAndChroma(hue, 16.); - this.a3 = TonalPalette.fromHueAndChroma(hue + 60., 24.); - this.n1 = TonalPalette.fromHueAndChroma(hue, 4.); - this.n2 = TonalPalette.fromHueAndChroma(hue, 8.); - } - this.error = TonalPalette.fromHueAndChroma(25, 84.); - } -} diff --git a/libs/material-color-utilities/src/main/java/palettes/CorePalettes.kt b/libs/material-color-utilities/src/main/java/palettes/CorePalettes.kt new file mode 100644 index 000000000..7b1af35ea --- /dev/null +++ b/libs/material-color-utilities/src/main/java/palettes/CorePalettes.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package palettes + +/** + * Comprises foundational palettes to build a color scheme. + * + * Generated from a source color, these palettes will then be part of a [DynamicScheme] together + * with appearance preferences. + */ +data class CorePalettes( + val primary: TonalPalette, + val secondary: TonalPalette, + val tertiary: TonalPalette, + val neutral: TonalPalette, + val neutralVariant: TonalPalette, +) diff --git a/libs/material-color-utilities/src/main/java/palettes/TonalPalette.java b/libs/material-color-utilities/src/main/java/palettes/TonalPalette.java deleted file mode 100644 index 3347c78e7..000000000 --- a/libs/material-color-utilities/src/main/java/palettes/TonalPalette.java +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package palettes; - -import hct.Hct; -import java.util.HashMap; -import java.util.Map; - -/** - * A convenience class for retrieving colors that are constant in hue and chroma, but vary in tone. - */ -public final class TonalPalette { - Map cache; - Hct keyColor; - double hue; - double chroma; - - /** - * Create tones using the HCT hue and chroma from a color. - * - * @param argb ARGB representation of a color - * @return Tones matching that color's hue and chroma. - */ - public static TonalPalette fromInt(int argb) { - return fromHct(Hct.fromInt(argb)); - } - - /** - * Create tones using a HCT color. - * - * @param hct HCT representation of a color. - * @return Tones matching that color's hue and chroma. - */ - public static TonalPalette fromHct(Hct hct) { - return new TonalPalette(hct.getHue(), hct.getChroma(), hct); - } - - /** - * Create tones from a defined HCT hue and chroma. - * - * @param hue HCT hue - * @param chroma HCT chroma - * @return Tones matching hue and chroma. - */ - public static TonalPalette fromHueAndChroma(double hue, double chroma) { - return new TonalPalette(hue, chroma, createKeyColor(hue, chroma)); - } - - private TonalPalette(double hue, double chroma, Hct keyColor) { - cache = new HashMap<>(); - this.hue = hue; - this.chroma = chroma; - this.keyColor = keyColor; - } - - /** The key color is the first tone, starting from T50, matching the given hue and chroma. */ - private static Hct createKeyColor(double hue, double chroma) { - double startTone = 50.0; - Hct smallestDeltaHct = Hct.from(hue, chroma, startTone); - double smallestDelta = Math.abs(smallestDeltaHct.getChroma() - chroma); - // Starting from T50, check T+/-delta to see if they match the requested - // chroma. - // - // Starts from T50 because T50 has the most chroma available, on - // average. Thus it is most likely to have a direct answer and minimize - // iteration. - for (double delta = 1.0; delta < 50.0; delta += 1.0) { - // Termination condition rounding instead of minimizing delta to avoid - // case where requested chroma is 16.51, and the closest chroma is 16.49. - // Error is minimized, but when rounded and displayed, requested chroma - // is 17, key color's chroma is 16. - if (Math.round(chroma) == Math.round(smallestDeltaHct.getChroma())) { - return smallestDeltaHct; - } - - final Hct hctAdd = Hct.from(hue, chroma, startTone + delta); - final double hctAddDelta = Math.abs(hctAdd.getChroma() - chroma); - if (hctAddDelta < smallestDelta) { - smallestDelta = hctAddDelta; - smallestDeltaHct = hctAdd; - } - - final Hct hctSubtract = Hct.from(hue, chroma, startTone - delta); - final double hctSubtractDelta = Math.abs(hctSubtract.getChroma() - chroma); - if (hctSubtractDelta < smallestDelta) { - smallestDelta = hctSubtractDelta; - smallestDeltaHct = hctSubtract; - } - } - - return smallestDeltaHct; - } - - /** - * Create an ARGB color with HCT hue and chroma of this Tones instance, and the provided HCT tone. - * - * @param tone HCT tone, measured from 0 to 100. - * @return ARGB representation of a color with that tone. - */ - // AndroidJdkLibsChecker is higher priority than ComputeIfAbsentUseValue (b/119581923) - @SuppressWarnings("ComputeIfAbsentUseValue") - public int tone(int tone) { - Integer color = cache.get(tone); - if (color == null) { - color = Hct.from(this.hue, this.chroma, tone).toInt(); - cache.put(tone, color); - } - return color; - } - - /** Given a tone, use hue and chroma of palette to create a color, and return it as HCT. */ - public Hct getHct(double tone) { - return Hct.from(this.hue, this.chroma, tone); - } - - /** The chroma of the Tonal Palette, in HCT. Ranges from 0 to ~130 (for sRGB gamut). */ - public double getChroma() { - return this.chroma; - } - - /** The hue of the Tonal Palette, in HCT. Ranges from 0 to 360. */ - public double getHue() { - return this.hue; - } - - /** The key color is the first tone, starting from T50, that matches the palette's chroma. */ - public Hct getKeyColor() { - return this.keyColor; - } -} diff --git a/libs/material-color-utilities/src/main/java/palettes/TonalPalette.kt b/libs/material-color-utilities/src/main/java/palettes/TonalPalette.kt new file mode 100644 index 000000000..9f62b8d1b --- /dev/null +++ b/libs/material-color-utilities/src/main/java/palettes/TonalPalette.kt @@ -0,0 +1,174 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package palettes + +import hct.Hct +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * A convenience class for retrieving colors that are constant in hue and chroma, but vary in tone. + * + * TonalPalette is intended for use in a single thread due to its stateful caching. + */ +class TonalPalette +private constructor( + /** The hue of the Tonal Palette, in HCT. Ranges from 0 to 360. */ + val hue: Double, + /** The chroma of the Tonal Palette, in HCT. Ranges from 0 to ~130 (for sRGB gamut). */ + val chroma: Double, + /** The key color is the first tone, starting from T50, that matches the palette's chroma. */ + val keyColor: Hct, +) { + var cache: MutableMap = mutableMapOf() + + /** + * Create an ARGB color with HCT hue and chroma of this Tones instance, and the provided HCT tone. + * + * @param tone HCT tone, measured from 0 to 100. + * @return ARGB representation of a color with that tone. + */ + fun tone(tone: Int): Int { + var color = cache[tone] + if (color == null) { + color = + if (tone == 99 && Hct.isYellow(hue)) { + averageArgb(tone(98), tone(100)) + } else { + Hct.from(hue, chroma, tone.toDouble()).toInt() + } + cache[tone] = color + } + return color + } + + /** Given a tone, use hue and chroma of palette to create a color, and return it as HCT. */ + fun getHct(tone: Double): Hct { + return Hct.from(hue, chroma, tone) + } + + private fun averageArgb(argb1: Int, argb2: Int): Int { + val red1 = argb1 ushr 16 and 0xff + val green1 = argb1 ushr 8 and 0xff + val blue1 = argb1 and 0xff + val red2 = argb2 ushr 16 and 0xff + val green2 = argb2 ushr 8 and 0xff + val blue2 = argb2 and 0xff + val red = ((red1 + red2) / 2f).roundToInt() + val green = ((green1 + green2) / 2f).roundToInt() + val blue = ((blue1 + blue2) / 2f).roundToInt() + return 255 shl 24 or (red and 255) shl 16 or (green and 255) shl 8 or (blue and 255) + } + + /** Key color is a color that represents the hue and chroma of a tonal palette. */ + private class KeyColor(private val hue: Double, private val requestedChroma: Double) { + // Cache that maps tone to max chroma to avoid duplicated HCT calculation. + private val chromaCache: MutableMap = mutableMapOf() + + /** + * Creates a key color from a [hue] and a [chroma]. The key color is the first tone, starting + * from T50, matching the given hue and chroma. + * + * @return Key color [Hct] + */ + fun create(): Hct { + // Pivot around T50 because T50 has the most chroma available, on + // average. Thus it is most likely to have a direct answer. + val pivotTone = 50 + val toneStepSize = 1 + // Epsilon to accept values slightly higher than the requested chroma. + val epsilon = 0.01 + + // Binary search to find the tone that can provide a chroma that is closest + // to the requested chroma. + var lowerTone = 0 + var upperTone = 100 + while (lowerTone < upperTone) { + val midTone = (lowerTone + upperTone) / 2 + val isAscending = maxChroma(midTone) < maxChroma(midTone + toneStepSize) + val sufficientChroma = maxChroma(midTone) >= requestedChroma - epsilon + if (sufficientChroma) { + // Either range [lowerTone, midTone] or [midTone, upperTone] has + // the answer, so search in the range that is closer the pivot tone. + if (abs((lowerTone - pivotTone).toDouble()) < abs((upperTone - pivotTone).toDouble())) { + upperTone = midTone + } else { + if (lowerTone == midTone) { + return Hct.from(hue, requestedChroma, lowerTone.toDouble()) + } + lowerTone = midTone + } + } else { + // As there is no sufficient chroma in the midTone, follow the direction to the chroma + // peak. + if (isAscending) { + lowerTone = midTone + toneStepSize + } else { + // Keep midTone for potential chroma peak. + upperTone = midTone + } + } + } + return Hct.from(hue, requestedChroma, lowerTone.toDouble()) + } + + // Find the maximum chroma for a given tone + private fun maxChroma(tone: Int): Double { + return chromaCache.getOrPut(tone) { Hct.from(hue, MAX_CHROMA_VALUE, tone.toDouble()).chroma } + } + + companion object { + private const val MAX_CHROMA_VALUE = 200.0 + } + } + + companion object { + /** + * Create tones using the HCT hue and chroma from a color. + * + * @param argb ARGB representation of a color + * @return Tones matching that color's hue and chroma. + */ + @JvmStatic + fun fromInt(argb: Int): TonalPalette { + return fromHct(Hct.fromInt(argb)) + } + + /** + * Create tones using a HCT color. + * + * @param hct HCT representation of a color. + * @return Tones matching that color's hue and chroma. + */ + @JvmStatic + fun fromHct(hct: Hct): TonalPalette { + return TonalPalette(hct.hue, hct.chroma, hct) + } + + /** + * Create tones from a defined HCT hue and chroma. + * + * @param hue HCT hue + * @param chroma HCT chroma + * @return Tones matching hue and chroma. + */ + @JvmStatic + fun fromHueAndChroma(hue: Double, chroma: Double): TonalPalette { + val keyColor = KeyColor(hue, chroma).create() + return TonalPalette(hue, chroma, keyColor) + } + } +} diff --git a/libs/material-color-utilities/src/main/java/quantize/PointProvider.kt b/libs/material-color-utilities/src/main/java/quantize/PointProvider.kt new file mode 100644 index 000000000..5947fe284 --- /dev/null +++ b/libs/material-color-utilities/src/main/java/quantize/PointProvider.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package quantize + +/** An interface to allow use of different color spaces by quantizers. */ +interface PointProvider { + /** The four components in the color space of an sRGB color. */ + fun fromInt(argb: Int): DoubleArray + + /** The ARGB (i.e. hex code) representation of this color. */ + fun toInt(point: DoubleArray): Int + + /** + * Squared distance between two colors. Distance is defined by scientific color spaces and + * referred to as delta E. + */ + fun distance(a: DoubleArray, b: DoubleArray): Double +} diff --git a/libs/material-color-utilities/src/main/java/quantize/PointProviderLab.kt b/libs/material-color-utilities/src/main/java/quantize/PointProviderLab.kt new file mode 100644 index 000000000..81b17ac72 --- /dev/null +++ b/libs/material-color-utilities/src/main/java/quantize/PointProviderLab.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package quantize + +import utils.ColorUtils + +/** + * Provides conversions needed for K-Means quantization. Converting input to points, and converting + * the final state of the K-Means algorithm to colors. + */ +class PointProviderLab : PointProvider { + /** + * Convert a color represented in ARGB to a 3-element array of L*a*b* coordinates of the color. + */ + override fun fromInt(argb: Int): DoubleArray { + val lab = ColorUtils.labFromArgb(argb) + return doubleArrayOf(lab[0], lab[1], lab[2]) + } + + /** Convert a 3-element array to a color represented in ARGB. */ + override fun toInt(point: DoubleArray): Int { + return ColorUtils.argbFromLab(point[0], point[1], point[2]) + } + + /** + * Standard CIE 1976 delta E formula also takes the square root, unneeded here. This method is + * used by quantization algorithms to compare distance, and the relative ordering is the same, + * with or without a square root. + * + * This relatively minor optimization is helpful because this method is called at least once for + * each pixel in an image. + */ + override fun distance(a: DoubleArray, b: DoubleArray): Double { + val dL = a[0] - b[0] + val dA = a[1] - b[1] + val dB = a[2] - b[2] + return dL * dL + dA * dA + dB * dB + } +} diff --git a/libs/material-color-utilities/src/main/java/quantize/Quantizer.kt b/libs/material-color-utilities/src/main/java/quantize/Quantizer.kt new file mode 100644 index 000000000..c2f51b313 --- /dev/null +++ b/libs/material-color-utilities/src/main/java/quantize/Quantizer.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package quantize + +internal interface Quantizer { + fun quantize(pixels: IntArray, maxColors: Int): QuantizerResult +} diff --git a/libs/material-color-utilities/src/main/java/quantize/QuantizerCelebi.kt b/libs/material-color-utilities/src/main/java/quantize/QuantizerCelebi.kt new file mode 100644 index 000000000..6700fd1b2 --- /dev/null +++ b/libs/material-color-utilities/src/main/java/quantize/QuantizerCelebi.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package quantize + +/** + * An image quantizer that improves on the quality of a standard K-Means algorithm by setting the + * K-Means initial state to the output of a Wu quantizer, instead of random centroids. Improves on + * speed by several optimizations, as implemented in Wsmeans, or Weighted Square Means, K-Means with + * those optimizations. + * + * This algorithm was designed by M. Emre Celebi, and was found in their 2011 paper, Improving the + * Performance of K-Means for Color Quantization. https://arxiv.org/abs/1101.0395 + */ +object QuantizerCelebi { + /** + * Reduce the number of colors needed to represented the input, minimizing the difference between + * the original image and the recolored image. + * + * @param pixels Colors in ARGB format. + * @param maxColors The number of colors to divide the image into. A lower number of colors may be + * returned. + * @return Map with keys of colors in ARGB format, and values of number of pixels in the original + * image that correspond to the color in the quantized image. + */ + fun quantize(pixels: IntArray, maxColors: Int): Map { + val wu = QuantizerWu() + val wuResult = wu.quantize(pixels, maxColors) + val wuClustersAsObjects = wuResult.colorToCount.keys + var index = 0 + val wuClusters = IntArray(wuClustersAsObjects.size) + for (argb in wuClustersAsObjects) { + wuClusters[index++] = argb + } + return QuantizerWsmeans.quantize(pixels, wuClusters, maxColors) + } +} diff --git a/libs/material-color-utilities/src/main/java/quantize/QuantizerMap.kt b/libs/material-color-utilities/src/main/java/quantize/QuantizerMap.kt new file mode 100644 index 000000000..97ce2e4b8 --- /dev/null +++ b/libs/material-color-utilities/src/main/java/quantize/QuantizerMap.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package quantize + +import java.util.LinkedHashMap + +/** Creates a dictionary with keys of colors, and values of count of the color */ +class QuantizerMap : Quantizer { + var colorToCount: MutableMap? = null + private set + + override fun quantize(pixels: IntArray, maxColors: Int): QuantizerResult { + val pixelByCount: MutableMap = LinkedHashMap() + for (pixel in pixels) { + val currentPixelCount = pixelByCount[pixel] + val newPixelCount = if (currentPixelCount == null) 1 else currentPixelCount + 1 + pixelByCount[pixel] = newPixelCount + } + colorToCount = pixelByCount + return QuantizerResult(pixelByCount) + } +} diff --git a/libs/material-color-utilities/src/main/java/quantize/QuantizerResult.kt b/libs/material-color-utilities/src/main/java/quantize/QuantizerResult.kt new file mode 100644 index 000000000..573432566 --- /dev/null +++ b/libs/material-color-utilities/src/main/java/quantize/QuantizerResult.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package quantize + +/** Represents result of a quantizer run */ +data class QuantizerResult(val colorToCount: Map) diff --git a/libs/material-color-utilities/src/main/java/quantize/QuantizerWsmeans.kt b/libs/material-color-utilities/src/main/java/quantize/QuantizerWsmeans.kt new file mode 100644 index 000000000..cb816476b --- /dev/null +++ b/libs/material-color-utilities/src/main/java/quantize/QuantizerWsmeans.kt @@ -0,0 +1,188 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package quantize + +import java.util.Arrays +import java.util.LinkedHashMap +import java.util.Random +import kotlin.math.abs +import kotlin.math.min +import kotlin.math.sqrt + +/** + * An image quantizer that improves on the speed of a standard K-Means algorithm by implementing + * several optimizations, including deduping identical pixels and a triangle inequality rule that + * reduces the number of comparisons needed to identify which cluster a point should be moved to. + * + * Wsmeans stands for Weighted Square Means. + * + * This algorithm was designed by M. Emre Celebi, and was found in their 2011 paper, Improving the + * Performance of K-Means for Color Quantization. https://arxiv.org/abs/1101.0395 + */ +object QuantizerWsmeans { + private class Distance() : Comparable { + var index = -1 + var distance = -1.0 + + override fun compareTo(other: Distance): Int { + return distance.compareTo(other.distance) + } + } + + private const val MAX_ITERATIONS = 10 + private const val MIN_MOVEMENT_DISTANCE = 3.0 + + /** + * Reduce the number of colors needed to represented the input, minimizing the difference between + * the original image and the recolored image. + * + * @param inputPixels Colors in ARGB format. + * @param startingClusters Defines the initial state of the quantizer. Passing an empty array is + * fine, the implementation will create its own initial state that leads to reproducible results + * for the same inputs. Passing an array that is the result of Wu quantization leads to higher + * quality results. + * @param maxColors The number of colors to divide the image into. A lower number of colors may be + * returned. + * @return Map with keys of colors in ARGB format, values of how many of the input pixels belong + * to the color. + */ + fun quantize(inputPixels: IntArray, startingClusters: IntArray, maxColors: Int): Map { + // Uses a seeded random number generator to ensure consistent results. + val random = Random(0x42688L) + val pixelToCount: MutableMap = LinkedHashMap() + val points = arrayOfNulls(inputPixels.size) + val pixels = IntArray(inputPixels.size) + val pointProvider: PointProvider = PointProviderLab() + var pointCount = 0 + for (i in inputPixels.indices) { + val inputPixel = inputPixels[i] + val pixelCount = pixelToCount[inputPixel] + if (pixelCount == null) { + points[pointCount] = pointProvider.fromInt(inputPixel) + pixels[pointCount] = inputPixel + pointCount++ + pixelToCount[inputPixel] = 1 + } else { + pixelToCount[inputPixel] = pixelCount + 1 + } + } + val counts = IntArray(pointCount) + for (i in 0 until pointCount) { + val pixel = pixels[i] + val count = pixelToCount[pixel]!! + counts[i] = count + } + var clusterCount = min(maxColors, pointCount) + if (startingClusters.isNotEmpty()) { + clusterCount = min(clusterCount, startingClusters.size) + } + val clusters = arrayOfNulls(clusterCount) + var clustersCreated = 0 + for (i in startingClusters.indices) { + clusters[i] = pointProvider.fromInt(startingClusters[i]) + clustersCreated++ + } + val additionalClustersNeeded = clusterCount - clustersCreated + if (additionalClustersNeeded > 0) { + for (i in 0 until additionalClustersNeeded) {} + } + val clusterIndices = IntArray(pointCount) { random.nextInt(clusterCount) } + val indexMatrix = Array(clusterCount) { IntArray(clusterCount) } + val distanceToIndexMatrix = Array(clusterCount) { Array(clusterCount) { Distance() } } + val pixelCountSums = IntArray(clusterCount) + for (iteration in 0 until MAX_ITERATIONS) { + for (i in 0 until clusterCount) { + for (j in i + 1 until clusterCount) { + val distance = pointProvider.distance(clusters[i]!!, clusters[j]!!) + distanceToIndexMatrix[j][i].distance = distance + distanceToIndexMatrix[j][i].index = i + distanceToIndexMatrix[i][j].distance = distance + distanceToIndexMatrix[i][j].index = j + } + Arrays.sort(distanceToIndexMatrix[i]) + for (j in 0 until clusterCount) { + indexMatrix[i][j] = distanceToIndexMatrix[i][j].index + } + } + var pointsMoved = 0 + for (i in 0 until pointCount) { + val point = points[i]!! + val previousClusterIndex = clusterIndices[i] + val previousCluster = clusters[previousClusterIndex]!! + val previousDistance = pointProvider.distance(point, previousCluster) + var minimumDistance = previousDistance + var newClusterIndex = -1 + for (j in 0 until clusterCount) { + if (distanceToIndexMatrix[previousClusterIndex][j].distance >= 4 * previousDistance) { + continue + } + val distance = pointProvider.distance(point, clusters[j]!!) + if (distance < minimumDistance) { + minimumDistance = distance + newClusterIndex = j + } + } + if (newClusterIndex != -1) { + val distanceChange = abs(sqrt(minimumDistance) - sqrt(previousDistance)) + if (distanceChange > MIN_MOVEMENT_DISTANCE) { + pointsMoved++ + clusterIndices[i] = newClusterIndex + } + } + } + if (pointsMoved == 0 && iteration != 0) { + break + } + val componentASums = DoubleArray(clusterCount) + val componentBSums = DoubleArray(clusterCount) + val componentCSums = DoubleArray(clusterCount) + pixelCountSums.fill(0) + for (i in 0 until pointCount) { + val clusterIndex = clusterIndices[i] + val point = points[i]!! + val count = counts[i] + pixelCountSums[clusterIndex] += count + componentASums[clusterIndex] += point[0] * count + componentBSums[clusterIndex] += point[1] * count + componentCSums[clusterIndex] += point[2] * count + } + for (i in 0 until clusterCount) { + val count = pixelCountSums[i] + if (count == 0) { + clusters[i] = doubleArrayOf(0.0, 0.0, 0.0) + continue + } + val a = componentASums[i] / count + val b = componentBSums[i] / count + val c = componentCSums[i] / count + clusters[i] = doubleArrayOf(a, b, c) + } + } + val argbToPopulation: MutableMap = LinkedHashMap() + for (i in 0 until clusterCount) { + val count = pixelCountSums[i] + if (count == 0) { + continue + } + val possibleNewCluster = pointProvider.toInt(clusters[i]!!) + if (argbToPopulation.containsKey(possibleNewCluster)) { + continue + } + argbToPopulation[possibleNewCluster] = count + } + return argbToPopulation + } +} diff --git a/libs/material-color-utilities/src/main/java/quantize/QuantizerWu.kt b/libs/material-color-utilities/src/main/java/quantize/QuantizerWu.kt new file mode 100644 index 000000000..36873e8d6 --- /dev/null +++ b/libs/material-color-utilities/src/main/java/quantize/QuantizerWu.kt @@ -0,0 +1,363 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package quantize + +import utils.ColorUtils +import java.util.ArrayList +import java.util.LinkedHashMap + +/** + * An image quantizer that divides the image's pixels into clusters by recursively cutting an RGB + * cube, based on the weight of pixels in each area of the cube. + * + * The algorithm was described by Xiaolin Wu in Graphic Gems II, published in 1991. + */ +class QuantizerWu : Quantizer { + private lateinit var weights: IntArray + private lateinit var momentsR: IntArray + private lateinit var momentsG: IntArray + private lateinit var momentsB: IntArray + private lateinit var moments: DoubleArray + private lateinit var cubes: Array + + override fun quantize(pixels: IntArray, maxColors: Int): QuantizerResult { + val mapResult = QuantizerMap().quantize(pixels, maxColors) + constructHistogram(mapResult.colorToCount) + createMoments() + val createBoxesResult = createBoxes(maxColors) + val colors = createResult(createBoxesResult.resultCount) + val resultMap: MutableMap = LinkedHashMap() + for (color in colors) { + resultMap[color] = 0 + } + return QuantizerResult(resultMap) + } + + fun constructHistogram(pixels: Map) { + weights = IntArray(TOTAL_SIZE) + momentsR = IntArray(TOTAL_SIZE) + momentsG = IntArray(TOTAL_SIZE) + momentsB = IntArray(TOTAL_SIZE) + moments = DoubleArray(TOTAL_SIZE) + for ((pixel, count) in pixels) { + val red = ColorUtils.redFromArgb(pixel) + val green = ColorUtils.greenFromArgb(pixel) + val blue = ColorUtils.blueFromArgb(pixel) + val bitsToRemove = 8 - INDEX_BITS + val iR = (red shr bitsToRemove) + 1 + val iG = (green shr bitsToRemove) + 1 + val iB = (blue shr bitsToRemove) + 1 + val index = getIndex(iR, iG, iB) + weights[index] += count + momentsR[index] += red * count + momentsG[index] += green * count + momentsB[index] += blue * count + moments[index] += count * (red * red + green * green + blue * blue).toDouble() + } + } + + fun createMoments() { + for (r in 1 until INDEX_COUNT) { + val area = IntArray(INDEX_COUNT) + val areaR = IntArray(INDEX_COUNT) + val areaG = IntArray(INDEX_COUNT) + val areaB = IntArray(INDEX_COUNT) + val area2 = DoubleArray(INDEX_COUNT) + for (g in 1 until INDEX_COUNT) { + var line = 0 + var lineR = 0 + var lineG = 0 + var lineB = 0 + var line2 = 0.0 + for (b in 1 until INDEX_COUNT) { + val index = getIndex(r, g, b) + line += weights[index] + lineR += momentsR[index] + lineG += momentsG[index] + lineB += momentsB[index] + line2 += moments[index] + area[b] += line + areaR[b] += lineR + areaG[b] += lineG + areaB[b] += lineB + area2[b] += line2 + val previousIndex = getIndex(r - 1, g, b) + weights[index] = weights[previousIndex] + area[b] + momentsR[index] = momentsR[previousIndex] + areaR[b] + momentsG[index] = momentsG[previousIndex] + areaG[b] + momentsB[index] = momentsB[previousIndex] + areaB[b] + moments[index] = moments[previousIndex] + area2[b] + } + } + } + } + + internal fun createBoxes(maxColorCount: Int): CreateBoxesResult { + cubes = Array(maxColorCount) { Box() } + val volumeVariance = DoubleArray(maxColorCount) + val firstBox = cubes[0] + firstBox.r1 = INDEX_COUNT - 1 + firstBox.g1 = INDEX_COUNT - 1 + firstBox.b1 = INDEX_COUNT - 1 + var generatedColorCount = maxColorCount + var next = 0 + var i = 1 + while (i < maxColorCount) { + if (cut(cubes[next], cubes[i])) { + volumeVariance[next] = if (cubes[next].vol > 1) variance(cubes[next]) else 0.0 + volumeVariance[i] = if (cubes[i].vol > 1) variance(cubes[i]) else 0.0 + } else { + volumeVariance[next] = 0.0 + i-- + } + next = 0 + var temp = volumeVariance[0] + for (j in 1..i) { + if (volumeVariance[j] > temp) { + temp = volumeVariance[j] + next = j + } + } + if (temp <= 0.0) { + generatedColorCount = i + 1 + break + } + i++ + } + return CreateBoxesResult(maxColorCount, generatedColorCount) + } + + fun createResult(colorCount: Int): List { + val colors: MutableList = ArrayList() + for (i in 0 until colorCount) { + val cube = cubes[i] + val weight = volume(cube, weights) + if (weight > 0) { + val r = volume(cube, momentsR) / weight + val g = volume(cube, momentsG) / weight + val b = volume(cube, momentsB) / weight + val color = 255 shl 24 or (r and 0x0ff shl 16) or (g and 0x0ff shl 8) or (b and 0x0ff) + colors.add(color) + } + } + return colors + } + + internal fun variance(cube: Box): Double { + val dr = volume(cube, momentsR) + val dg = volume(cube, momentsG) + val db = volume(cube, momentsB) + val xx = + (moments[getIndex(cube.r1, cube.g1, cube.b1)] - + moments[getIndex(cube.r1, cube.g1, cube.b0)] - + moments[getIndex(cube.r1, cube.g0, cube.b1)] + + moments[getIndex(cube.r1, cube.g0, cube.b0)] - + moments[getIndex(cube.r0, cube.g1, cube.b1)] + + moments[getIndex(cube.r0, cube.g1, cube.b0)] + + moments[getIndex(cube.r0, cube.g0, cube.b1)] - moments[getIndex(cube.r0, cube.g0, cube.b0)]) + val hypotenuse = dr * dr + dg * dg + db * db + val volume = volume(cube, weights) + return xx - hypotenuse / volume.toDouble() + } + + internal fun cut(one: Box, two: Box): Boolean { + val wholeR = volume(one, momentsR) + val wholeG = volume(one, momentsG) + val wholeB = volume(one, momentsB) + val wholeW = volume(one, weights) + val maxRResult = + maximize(one, Direction.RED, one.r0 + 1, one.r1, wholeR, wholeG, wholeB, wholeW) + val maxGResult = + maximize(one, Direction.GREEN, one.g0 + 1, one.g1, wholeR, wholeG, wholeB, wholeW) + val maxBResult = + maximize(one, Direction.BLUE, one.b0 + 1, one.b1, wholeR, wholeG, wholeB, wholeW) + val maxR = maxRResult.maximum + val maxG = maxGResult.maximum + val maxB = maxBResult.maximum + val cutDirection = + when { + maxR >= maxG && maxR >= maxB -> { + if (maxRResult.cutLocation < 0) { + return false + } + Direction.RED + } + maxG >= maxR && maxG >= maxB -> Direction.GREEN + else -> Direction.BLUE + } + two.r1 = one.r1 + two.g1 = one.g1 + two.b1 = one.b1 + when (cutDirection) { + Direction.RED -> { + one.r1 = maxRResult.cutLocation + two.r0 = one.r1 + two.g0 = one.g0 + two.b0 = one.b0 + } + Direction.GREEN -> { + one.g1 = maxGResult.cutLocation + two.r0 = one.r0 + two.g0 = one.g1 + two.b0 = one.b0 + } + Direction.BLUE -> { + one.b1 = maxBResult.cutLocation + two.r0 = one.r0 + two.g0 = one.g0 + two.b0 = one.b1 + } + } + one.vol = (one.r1 - one.r0) * (one.g1 - one.g0) * (one.b1 - one.b0) + two.vol = (two.r1 - two.r0) * (two.g1 - two.g0) * (two.b1 - two.b0) + return true + } + + internal fun maximize( + cube: Box, + direction: Direction, + first: Int, + last: Int, + wholeR: Int, + wholeG: Int, + wholeB: Int, + wholeW: Int, + ): MaximizeResult { + val bottomR = bottom(cube, direction, momentsR) + val bottomG = bottom(cube, direction, momentsG) + val bottomB = bottom(cube, direction, momentsB) + val bottomW = bottom(cube, direction, weights) + var max = 0.0 + var cut = -1 + var halfR: Int + var halfG: Int + var halfB: Int + var halfW: Int + for (i in first until last) { + halfR = bottomR + top(cube, direction, i, momentsR) + halfG = bottomG + top(cube, direction, i, momentsG) + halfB = bottomB + top(cube, direction, i, momentsB) + halfW = bottomW + top(cube, direction, i, weights) + if (halfW == 0) { + continue + } + var tempNumerator = (halfR * halfR + halfG * halfG + halfB * halfB).toDouble() + var tempDenominator = halfW.toDouble() + var temp = tempNumerator / tempDenominator + halfR = wholeR - halfR + halfG = wholeG - halfG + halfB = wholeB - halfB + halfW = wholeW - halfW + if (halfW == 0) { + continue + } + tempNumerator = (halfR * halfR + halfG * halfG + halfB * halfB).toDouble() + tempDenominator = halfW.toDouble() + temp += tempNumerator / tempDenominator + if (temp > max) { + max = temp + cut = i + } + } + return MaximizeResult(cut, max) + } + + internal enum class Direction { + RED, + GREEN, + BLUE, + } + + internal class MaximizeResult( + // < 0 if cut impossible + var cutLocation: Int, + var maximum: Double, + ) + + internal class CreateBoxesResult(var requestedCount: Int, var resultCount: Int) + + internal class Box { + var r0 = 0 + var r1 = 0 + var g0 = 0 + var g1 = 0 + var b0 = 0 + var b1 = 0 + var vol = 0 + } + + companion object { + // A histogram of all the input colors is constructed. It has the shape of a cube. The cube + // would be too large if it contained all 16 million colors: historical best practice is to use + // 5 bits of the 8 in each channel, reducing the histogram to a volume of ~32,000. + private const val INDEX_BITS = 5 + private const val INDEX_COUNT = 33 // ((1 << INDEX_BITS) + 1) + private const val TOTAL_SIZE = 35937 // INDEX_COUNT * INDEX_COUNT * INDEX_COUNT + + fun getIndex(r: Int, g: Int, b: Int): Int { + return (r shl (INDEX_BITS * 2)) + (r shl (INDEX_BITS + 1)) + r + (g shl INDEX_BITS) + g + b + } + + internal fun volume(cube: Box, moment: IntArray): Int { + return (moment[getIndex(cube.r1, cube.g1, cube.b1)] - + moment[getIndex(cube.r1, cube.g1, cube.b0)] - + moment[getIndex(cube.r1, cube.g0, cube.b1)] + moment[getIndex(cube.r1, cube.g0, cube.b0)] - + moment[getIndex(cube.r0, cube.g1, cube.b1)] + + moment[getIndex(cube.r0, cube.g1, cube.b0)] + + moment[getIndex(cube.r0, cube.g0, cube.b1)] - moment[getIndex(cube.r0, cube.g0, cube.b0)]) + } + + internal fun bottom(cube: Box, direction: Direction, moment: IntArray): Int { + return when (direction) { + Direction.RED -> + -moment[getIndex(cube.r0, cube.g1, cube.b1)] + + moment[getIndex(cube.r0, cube.g1, cube.b0)] + + moment[getIndex(cube.r0, cube.g0, cube.b1)] - + moment[getIndex(cube.r0, cube.g0, cube.b0)] + Direction.GREEN -> + -moment[getIndex(cube.r1, cube.g0, cube.b1)] + + moment[getIndex(cube.r1, cube.g0, cube.b0)] + + moment[getIndex(cube.r0, cube.g0, cube.b1)] - + moment[getIndex(cube.r0, cube.g0, cube.b0)] + Direction.BLUE -> + -moment[getIndex(cube.r1, cube.g1, cube.b0)] + + moment[getIndex(cube.r1, cube.g0, cube.b0)] + + moment[getIndex(cube.r0, cube.g1, cube.b0)] - + moment[getIndex(cube.r0, cube.g0, cube.b0)] + } + } + + internal fun top(cube: Box, direction: Direction, position: Int, moment: IntArray): Int { + return when (direction) { + Direction.RED -> + (moment[getIndex(position, cube.g1, cube.b1)] - + moment[getIndex(position, cube.g1, cube.b0)] - + moment[getIndex(position, cube.g0, cube.b1)] + + moment[getIndex(position, cube.g0, cube.b0)]) + Direction.GREEN -> + (moment[getIndex(cube.r1, position, cube.b1)] - + moment[getIndex(cube.r1, position, cube.b0)] - + moment[getIndex(cube.r0, position, cube.b1)] + + moment[getIndex(cube.r0, position, cube.b0)]) + Direction.BLUE -> + (moment[getIndex(cube.r1, cube.g1, position)] - + moment[getIndex(cube.r1, cube.g0, position)] - + moment[getIndex(cube.r0, cube.g1, position)] + + moment[getIndex(cube.r0, cube.g0, position)]) + } + } + } +} diff --git a/libs/material-color-utilities/src/main/java/scheme/Scheme.java b/libs/material-color-utilities/src/main/java/scheme/Scheme.java deleted file mode 100644 index ac2909973..000000000 --- a/libs/material-color-utilities/src/main/java/scheme/Scheme.java +++ /dev/null @@ -1,953 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// This file is automatically generated. Do not modify it. - -package scheme; - -import palettes.CorePalette; - -/** - * Represents a Material color scheme, a mapping of color roles to colors. - */ -public class Scheme { - private int primary; - private int onPrimary; - private int primaryContainer; - private int onPrimaryContainer; - private int secondary; - private int onSecondary; - private int secondaryContainer; - private int onSecondaryContainer; - private int tertiary; - private int onTertiary; - private int tertiaryContainer; - private int onTertiaryContainer; - private int error; - private int onError; - private int errorContainer; - private int onErrorContainer; - private int background; - private int onBackground; - private int surface; - private int onSurface; - private int surfaceVariant; - private int onSurfaceVariant; - private int outline; - private int outlineVariant; - private int shadow; - private int scrim; - private int inverseSurface; - private int inverseOnSurface; - private int inversePrimary; - private int surfaceDim; - private int surfaceBright; - private int surfaceContainerLowest; - private int surfaceContainerLow; - private int surfaceContainer; - private int surfaceContainerHigh; - private int surfaceContainerHighest; - - public Scheme() { - } - - public Scheme( - int primary, - int onPrimary, - int primaryContainer, - int onPrimaryContainer, - int secondary, - int onSecondary, - int secondaryContainer, - int onSecondaryContainer, - int tertiary, - int onTertiary, - int tertiaryContainer, - int onTertiaryContainer, - int error, - int onError, - int errorContainer, - int onErrorContainer, - int background, - int onBackground, - int surface, - int onSurface, - int surfaceVariant, - int onSurfaceVariant, - int outline, - int outlineVariant, - int shadow, - int scrim, - int inverseSurface, - int inverseOnSurface, - int inversePrimary, - int surfaceDim, - int surfaceBright, - int surfaceContainerLowest, - int surfaceContainerLow, - int surfaceContainer, - int surfaceContainerHigh, - int surfaceContainerHighest - ) { - super(); - this.primary = primary; - this.onPrimary = onPrimary; - this.primaryContainer = primaryContainer; - this.onPrimaryContainer = onPrimaryContainer; - this.secondary = secondary; - this.onSecondary = onSecondary; - this.secondaryContainer = secondaryContainer; - this.onSecondaryContainer = onSecondaryContainer; - this.tertiary = tertiary; - this.onTertiary = onTertiary; - this.tertiaryContainer = tertiaryContainer; - this.onTertiaryContainer = onTertiaryContainer; - this.error = error; - this.onError = onError; - this.errorContainer = errorContainer; - this.onErrorContainer = onErrorContainer; - this.background = background; - this.onBackground = onBackground; - this.surface = surface; - this.onSurface = onSurface; - this.surfaceVariant = surfaceVariant; - this.onSurfaceVariant = onSurfaceVariant; - this.outline = outline; - this.outlineVariant = outlineVariant; - this.shadow = shadow; - this.scrim = scrim; - this.inverseSurface = inverseSurface; - this.inverseOnSurface = inverseOnSurface; - this.inversePrimary = inversePrimary; - this.surfaceDim = surfaceDim; - this.surfaceBright = surfaceBright; - this.surfaceContainerLowest = surfaceContainerLowest; - this.surfaceContainerLow = surfaceContainerLow; - this.surfaceContainer = surfaceContainer; - this.surfaceContainerHigh = surfaceContainerHigh; - this.surfaceContainerHighest = surfaceContainerHighest; - } - - public static Scheme light(int argb) { - return lightFromCorePalette(CorePalette.of(argb)); - } - - public static Scheme dark(int argb) { - return darkFromCorePalette(CorePalette.of(argb)); - } - - public static Scheme lightContent(int argb) { - return lightFromCorePalette(CorePalette.contentOf(argb)); - } - - public static Scheme darkContent(int argb) { - return darkFromCorePalette(CorePalette.contentOf(argb)); - } - - public static Scheme lightFromCorePalette(CorePalette core) { - return new Scheme() - .withPrimary(core.a1.tone(40)) - .withOnPrimary(core.a1.tone(100)) - .withPrimaryContainer(core.a1.tone(90)) - .withOnPrimaryContainer(core.a1.tone(10)) - .withSecondary(core.a2.tone(40)) - .withOnSecondary(core.a2.tone(100)) - .withSecondaryContainer(core.a2.tone(90)) - .withOnSecondaryContainer(core.a2.tone(10)) - .withTertiary(core.a3.tone(40)) - .withOnTertiary(core.a3.tone(100)) - .withTertiaryContainer(core.a3.tone(90)) - .withOnTertiaryContainer(core.a3.tone(10)) - .withError(core.error.tone(40)) - .withOnError(core.error.tone(100)) - .withErrorContainer(core.error.tone(90)) - .withOnErrorContainer(core.error.tone(10)) - .withBackground(core.n1.tone(99)) - .withOnBackground(core.n1.tone(10)) - .withSurface(core.n1.tone(98)) - .withOnSurface(core.n1.tone(10)) - .withSurfaceVariant(core.n2.tone(90)) - .withOnSurfaceVariant(core.n2.tone(30)) - .withOutline(core.n2.tone(50)) - .withOutlineVariant(core.n2.tone(80)) - .withShadow(core.n1.tone(0)) - .withScrim(core.n1.tone(0)) - .withInverseSurface(core.n1.tone(20)) - .withInverseOnSurface(core.n1.tone(95)) - .withInversePrimary(core.a1.tone(80)) - .withSurfaceDim(core.n1.tone(87)) - .withSurfaceBright(core.n1.tone(98)) - .withSurfaceContainerLowest(core.n1.tone(100)) - .withSurfaceContainerLow(core.n1.tone(96)) - .withSurfaceContainer(core.n1.tone(94)) - .withSurfaceContainerHigh(core.n1.tone(92)) - .withSurfaceContainerHighest(core.n1.tone(90)); - } - - public static Scheme darkFromCorePalette(CorePalette core) { - return new Scheme() - .withPrimary(core.a1.tone(80)) - .withOnPrimary(core.a1.tone(20)) - .withPrimaryContainer(core.a1.tone(30)) - .withOnPrimaryContainer(core.a1.tone(90)) - .withSecondary(core.a2.tone(80)) - .withOnSecondary(core.a2.tone(20)) - .withSecondaryContainer(core.a2.tone(30)) - .withOnSecondaryContainer(core.a2.tone(90)) - .withTertiary(core.a3.tone(80)) - .withOnTertiary(core.a3.tone(20)) - .withTertiaryContainer(core.a3.tone(30)) - .withOnTertiaryContainer(core.a3.tone(90)) - .withError(core.error.tone(80)) - .withOnError(core.error.tone(20)) - .withErrorContainer(core.error.tone(30)) - .withOnErrorContainer(core.error.tone(80)) - .withBackground(core.n1.tone(10)) - .withOnBackground(core.n1.tone(90)) - .withSurface(core.n1.tone(6)) - .withOnSurface(core.n1.tone(90)) - .withSurfaceVariant(core.n2.tone(30)) - .withOnSurfaceVariant(core.n2.tone(80)) - .withOutline(core.n2.tone(60)) - .withOutlineVariant(core.n2.tone(30)) - .withShadow(core.n1.tone(0)) - .withScrim(core.n1.tone(0)) - .withInverseSurface(core.n1.tone(90)) - .withInverseOnSurface(core.n1.tone(20)) - .withInversePrimary(core.a1.tone(40)) - .withSurfaceDim(core.n1.tone(6)) - .withSurfaceBright(core.n1.tone(24)) - .withSurfaceContainerLowest(core.n1.tone(4)) - .withSurfaceContainerLow(core.n1.tone(10)) - .withSurfaceContainer(core.n1.tone(12)) - .withSurfaceContainerHigh(core.n1.tone(17)) - .withSurfaceContainerHighest(core.n1.tone(22)); - } - - public int getPrimary() { - return primary; - } - - public void setPrimary(int primary) { - this.primary = primary; - } - - public Scheme withPrimary(int primary) { - this.primary = primary; - return this; - } - - public int getOnPrimary() { - return onPrimary; - } - - public void setOnPrimary(int onPrimary) { - this.onPrimary = onPrimary; - } - - public Scheme withOnPrimary(int onPrimary) { - this.onPrimary = onPrimary; - return this; - } - - public int getPrimaryContainer() { - return primaryContainer; - } - - public void setPrimaryContainer(int primaryContainer) { - this.primaryContainer = primaryContainer; - } - - public Scheme withPrimaryContainer(int primaryContainer) { - this.primaryContainer = primaryContainer; - return this; - } - - public int getOnPrimaryContainer() { - return onPrimaryContainer; - } - - public void setOnPrimaryContainer(int onPrimaryContainer) { - this.onPrimaryContainer = onPrimaryContainer; - } - - public Scheme withOnPrimaryContainer(int onPrimaryContainer) { - this.onPrimaryContainer = onPrimaryContainer; - return this; - } - - public int getSecondary() { - return secondary; - } - - public void setSecondary(int secondary) { - this.secondary = secondary; - } - - public Scheme withSecondary(int secondary) { - this.secondary = secondary; - return this; - } - - public int getOnSecondary() { - return onSecondary; - } - - public void setOnSecondary(int onSecondary) { - this.onSecondary = onSecondary; - } - - public Scheme withOnSecondary(int onSecondary) { - this.onSecondary = onSecondary; - return this; - } - - public int getSecondaryContainer() { - return secondaryContainer; - } - - public void setSecondaryContainer(int secondaryContainer) { - this.secondaryContainer = secondaryContainer; - } - - public Scheme withSecondaryContainer(int secondaryContainer) { - this.secondaryContainer = secondaryContainer; - return this; - } - - public int getOnSecondaryContainer() { - return onSecondaryContainer; - } - - public void setOnSecondaryContainer(int onSecondaryContainer) { - this.onSecondaryContainer = onSecondaryContainer; - } - - public Scheme withOnSecondaryContainer(int onSecondaryContainer) { - this.onSecondaryContainer = onSecondaryContainer; - return this; - } - - public int getTertiary() { - return tertiary; - } - - public void setTertiary(int tertiary) { - this.tertiary = tertiary; - } - - public Scheme withTertiary(int tertiary) { - this.tertiary = tertiary; - return this; - } - - public int getOnTertiary() { - return onTertiary; - } - - public void setOnTertiary(int onTertiary) { - this.onTertiary = onTertiary; - } - - public Scheme withOnTertiary(int onTertiary) { - this.onTertiary = onTertiary; - return this; - } - - public int getTertiaryContainer() { - return tertiaryContainer; - } - - public void setTertiaryContainer(int tertiaryContainer) { - this.tertiaryContainer = tertiaryContainer; - } - - public Scheme withTertiaryContainer(int tertiaryContainer) { - this.tertiaryContainer = tertiaryContainer; - return this; - } - - public int getOnTertiaryContainer() { - return onTertiaryContainer; - } - - public void setOnTertiaryContainer(int onTertiaryContainer) { - this.onTertiaryContainer = onTertiaryContainer; - } - - public Scheme withOnTertiaryContainer(int onTertiaryContainer) { - this.onTertiaryContainer = onTertiaryContainer; - return this; - } - - public int getError() { - return error; - } - - public void setError(int error) { - this.error = error; - } - - public Scheme withError(int error) { - this.error = error; - return this; - } - - public int getOnError() { - return onError; - } - - public void setOnError(int onError) { - this.onError = onError; - } - - public Scheme withOnError(int onError) { - this.onError = onError; - return this; - } - - public int getErrorContainer() { - return errorContainer; - } - - public void setErrorContainer(int errorContainer) { - this.errorContainer = errorContainer; - } - - public Scheme withErrorContainer(int errorContainer) { - this.errorContainer = errorContainer; - return this; - } - - public int getOnErrorContainer() { - return onErrorContainer; - } - - public void setOnErrorContainer(int onErrorContainer) { - this.onErrorContainer = onErrorContainer; - } - - public Scheme withOnErrorContainer(int onErrorContainer) { - this.onErrorContainer = onErrorContainer; - return this; - } - - public int getBackground() { - return background; - } - - public void setBackground(int background) { - this.background = background; - } - - public Scheme withBackground(int background) { - this.background = background; - return this; - } - - public int getOnBackground() { - return onBackground; - } - - public void setOnBackground(int onBackground) { - this.onBackground = onBackground; - } - - public Scheme withOnBackground(int onBackground) { - this.onBackground = onBackground; - return this; - } - - public int getSurface() { - return surface; - } - - public void setSurface(int surface) { - this.surface = surface; - } - - public Scheme withSurface(int surface) { - this.surface = surface; - return this; - } - - public int getOnSurface() { - return onSurface; - } - - public void setOnSurface(int onSurface) { - this.onSurface = onSurface; - } - - public Scheme withOnSurface(int onSurface) { - this.onSurface = onSurface; - return this; - } - - public int getSurfaceVariant() { - return surfaceVariant; - } - - public void setSurfaceVariant(int surfaceVariant) { - this.surfaceVariant = surfaceVariant; - } - - public Scheme withSurfaceVariant(int surfaceVariant) { - this.surfaceVariant = surfaceVariant; - return this; - } - - public int getOnSurfaceVariant() { - return onSurfaceVariant; - } - - public void setOnSurfaceVariant(int onSurfaceVariant) { - this.onSurfaceVariant = onSurfaceVariant; - } - - public Scheme withOnSurfaceVariant(int onSurfaceVariant) { - this.onSurfaceVariant = onSurfaceVariant; - return this; - } - - public int getOutline() { - return outline; - } - - public void setOutline(int outline) { - this.outline = outline; - } - - public Scheme withOutline(int outline) { - this.outline = outline; - return this; - } - - public int getOutlineVariant() { - return outlineVariant; - } - - public void setOutlineVariant(int outlineVariant) { - this.outlineVariant = outlineVariant; - } - - public Scheme withOutlineVariant(int outlineVariant) { - this.outlineVariant = outlineVariant; - return this; - } - - public int getShadow() { - return shadow; - } - - public void setShadow(int shadow) { - this.shadow = shadow; - } - - public Scheme withShadow(int shadow) { - this.shadow = shadow; - return this; - } - - public int getScrim() { - return scrim; - } - - public void setScrim(int scrim) { - this.scrim = scrim; - } - - public Scheme withScrim(int scrim) { - this.scrim = scrim; - return this; - } - - public int getInverseSurface() { - return inverseSurface; - } - - public void setInverseSurface(int inverseSurface) { - this.inverseSurface = inverseSurface; - } - - public Scheme withInverseSurface(int inverseSurface) { - this.inverseSurface = inverseSurface; - return this; - } - - public int getInverseOnSurface() { - return inverseOnSurface; - } - - public void setInverseOnSurface(int inverseOnSurface) { - this.inverseOnSurface = inverseOnSurface; - } - - public Scheme withInverseOnSurface(int inverseOnSurface) { - this.inverseOnSurface = inverseOnSurface; - return this; - } - - public int getInversePrimary() { - return inversePrimary; - } - - public void setInversePrimary(int inversePrimary) { - this.inversePrimary = inversePrimary; - } - - public Scheme withInversePrimary(int inversePrimary) { - this.inversePrimary = inversePrimary; - return this; - } - - public int getSurfaceDim() { - return surfaceDim; - } - - public void setSurfaceDim(int surfaceDim) { - this.surfaceDim = surfaceDim; - } - - public Scheme withSurfaceDim(int surfaceDim) { - this.surfaceDim = surfaceDim; - return this; - } - - public int getSurfaceBright() { - return surfaceBright; - } - - public void setSurfaceBright(int surfaceBright) { - this.surfaceBright = surfaceBright; - } - - public Scheme withSurfaceBright(int surfaceBright) { - this.surfaceBright = surfaceBright; - return this; - } - - public int getSurfaceContainerLowest() { - return surfaceContainerLowest; - } - - public void setSurfaceContainerLowest(int surfaceContainerLowest) { - this.surfaceContainerLowest = surfaceContainerLowest; - } - - public Scheme withSurfaceContainerLowest(int surfaceContainerLowest) { - this.surfaceContainerLowest = surfaceContainerLowest; - return this; - } - - public int getSurfaceContainerLow() { - return surfaceContainerLow; - } - - public void setSurfaceContainerLow(int surfaceContainerLow) { - this.surfaceContainerLow = surfaceContainerLow; - } - - public Scheme withSurfaceContainerLow(int surfaceContainerLow) { - this.surfaceContainerLow = surfaceContainerLow; - return this; - } - - public int getSurfaceContainer() { - return surfaceContainer; - } - - public void setSurfaceContainer(int surfaceContainer) { - this.surfaceContainer = surfaceContainer; - } - - public Scheme withSurfaceContainer(int surfaceContainer) { - this.surfaceContainer = surfaceContainer; - return this; - } - - public int getSurfaceContainerHigh() { - return surfaceContainerHigh; - } - - public void setSurfaceContainerHigh(int surfaceContainerHigh) { - this.surfaceContainerHigh = surfaceContainerHigh; - } - - public Scheme withSurfaceContainerHigh(int surfaceContainerHigh) { - this.surfaceContainerHigh = surfaceContainerHigh; - return this; - } - - public int getSurfaceContainerHighest() { - return surfaceContainerHighest; - } - - public void setSurfaceContainerHighest(int surfaceContainerHighest) { - this.surfaceContainerHighest = surfaceContainerHighest; - } - - public Scheme withSurfaceContainerHighest(int surfaceContainerHighest) { - this.surfaceContainerHighest = surfaceContainerHighest; - return this; - } - - @Override - public String toString() { - return "Scheme{" - + "primary=" - + primary - + ", onPrimary=" - + onPrimary - + ", primaryContainer=" - + primaryContainer - + ", onPrimaryContainer=" - + onPrimaryContainer - + ", secondary=" - + secondary - + ", onSecondary=" - + onSecondary - + ", secondaryContainer=" - + secondaryContainer - + ", onSecondaryContainer=" - + onSecondaryContainer - + ", tertiary=" - + tertiary - + ", onTertiary=" - + onTertiary - + ", tertiaryContainer=" - + tertiaryContainer - + ", onTertiaryContainer=" - + onTertiaryContainer - + ", error=" - + error - + ", onError=" - + onError - + ", errorContainer=" - + errorContainer - + ", onErrorContainer=" - + onErrorContainer - + ", background=" - + background - + ", onBackground=" - + onBackground - + ", surface=" - + surface - + ", onSurface=" - + onSurface - + ", surfaceVariant=" - + surfaceVariant - + ", onSurfaceVariant=" - + onSurfaceVariant - + ", outline=" - + outline - + ", outlineVariant=" - + outlineVariant - + ", shadow=" - + shadow - + ", scrim=" - + scrim - + ", inverseSurface=" - + inverseSurface - + ", inverseOnSurface=" - + inverseOnSurface - + ", inversePrimary=" - + inversePrimary - + ", surfaceDim=" - + surfaceDim - + ", surfaceBright=" - + surfaceBright - + ", surfaceContainerLowest=" - + surfaceContainerLowest - + ", surfaceContainerLow=" - + surfaceContainerLow - + ", surfaceContainer=" - + surfaceContainer - + ", surfaceContainerHigh=" - + surfaceContainerHigh - + ", surfaceContainerHighest=" - + surfaceContainerHighest - + '}'; - } - - @Override - public boolean equals(Object object) { - if (this == object) { - return true; - } - if (!(object instanceof Scheme)) { - return false; - } - if (!super.equals(object)) { - return false; - } - - Scheme scheme = (Scheme) object; - - if (primary != scheme.primary) { - return false; - } - if (onPrimary != scheme.onPrimary) { - return false; - } - if (primaryContainer != scheme.primaryContainer) { - return false; - } - if (onPrimaryContainer != scheme.onPrimaryContainer) { - return false; - } - if (secondary != scheme.secondary) { - return false; - } - if (onSecondary != scheme.onSecondary) { - return false; - } - if (secondaryContainer != scheme.secondaryContainer) { - return false; - } - if (onSecondaryContainer != scheme.onSecondaryContainer) { - return false; - } - if (tertiary != scheme.tertiary) { - return false; - } - if (onTertiary != scheme.onTertiary) { - return false; - } - if (tertiaryContainer != scheme.tertiaryContainer) { - return false; - } - if (onTertiaryContainer != scheme.onTertiaryContainer) { - return false; - } - if (error != scheme.error) { - return false; - } - if (onError != scheme.onError) { - return false; - } - if (errorContainer != scheme.errorContainer) { - return false; - } - if (onErrorContainer != scheme.onErrorContainer) { - return false; - } - if (background != scheme.background) { - return false; - } - if (onBackground != scheme.onBackground) { - return false; - } - if (surface != scheme.surface) { - return false; - } - if (onSurface != scheme.onSurface) { - return false; - } - if (surfaceVariant != scheme.surfaceVariant) { - return false; - } - if (onSurfaceVariant != scheme.onSurfaceVariant) { - return false; - } - if (outline != scheme.outline) { - return false; - } - if (outlineVariant != scheme.outlineVariant) { - return false; - } - if (shadow != scheme.shadow) { - return false; - } - if (scrim != scheme.scrim) { - return false; - } - if (inverseSurface != scheme.inverseSurface) { - return false; - } - if (inverseOnSurface != scheme.inverseOnSurface) { - return false; - } - if (inversePrimary != scheme.inversePrimary) { - return false; - } - if (surfaceDim != scheme.surfaceDim) { - return false; - } - if (surfaceBright != scheme.surfaceBright) { - return false; - } - if (surfaceContainerLowest != scheme.surfaceContainerLowest) { - return false; - } - if (surfaceContainerLow != scheme.surfaceContainerLow) { - return false; - } - if (surfaceContainer != scheme.surfaceContainer) { - return false; - } - if (surfaceContainerHigh != scheme.surfaceContainerHigh) { - return false; - } - if (surfaceContainerHighest != scheme.surfaceContainerHighest) { - return false; - } - - return true; - } - - @Override - public int hashCode() { - int result = super.hashCode(); - result = 31 * result + primary; - result = 31 * result + onPrimary; - result = 31 * result + primaryContainer; - result = 31 * result + onPrimaryContainer; - result = 31 * result + secondary; - result = 31 * result + onSecondary; - result = 31 * result + secondaryContainer; - result = 31 * result + onSecondaryContainer; - result = 31 * result + tertiary; - result = 31 * result + onTertiary; - result = 31 * result + tertiaryContainer; - result = 31 * result + onTertiaryContainer; - result = 31 * result + error; - result = 31 * result + onError; - result = 31 * result + errorContainer; - result = 31 * result + onErrorContainer; - result = 31 * result + background; - result = 31 * result + onBackground; - result = 31 * result + surface; - result = 31 * result + onSurface; - result = 31 * result + surfaceVariant; - result = 31 * result + onSurfaceVariant; - result = 31 * result + outline; - result = 31 * result + outlineVariant; - result = 31 * result + shadow; - result = 31 * result + scrim; - result = 31 * result + inverseSurface; - result = 31 * result + inverseOnSurface; - result = 31 * result + inversePrimary; - result = 31 * result + surfaceDim; - result = 31 * result + surfaceBright; - result = 31 * result + surfaceContainerLowest; - result = 31 * result + surfaceContainerLow; - result = 31 * result + surfaceContainer; - result = 31 * result + surfaceContainerHigh; - result = 31 * result + surfaceContainerHighest; - return result; - } -} diff --git a/libs/material-color-utilities/src/main/java/scheme/SchemeContent.kt b/libs/material-color-utilities/src/main/java/scheme/SchemeContent.kt new file mode 100644 index 000000000..37538a0eb --- /dev/null +++ b/libs/material-color-utilities/src/main/java/scheme/SchemeContent.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package scheme + +import dynamiccolor.ColorSpec.SpecVersion +import dynamiccolor.ColorSpecs +import dynamiccolor.DynamicScheme +import dynamiccolor.Variant +import hct.Hct + +/** + * A scheme that places the source color in Scheme.primaryContainer. + * + * Primary Container is the source color, adjusted for color relativity. It maintains constant + * appearance in light mode and dark mode. This adds ~5 tone in light mode, and subtracts ~5 tone in + * dark mode. + * + * Tertiary Container is an analogous color, specifically, the analog of a color wheel divided into + * 6, and the precise analog is the one found by increasing hue. This is a scientifically grounded + * equivalent to rotating hue clockwise by 60 degrees. It also maintains constant appearance. + */ +class SchemeContent( + sourceColorHct: Hct, + isDark: Boolean, + contrastLevel: Double, + specVersion: SpecVersion = DEFAULT_SPEC_VERSION, + platform: Platform = DEFAULT_PLATFORM, +) : + DynamicScheme( + sourceColorHct, + Variant.CONTENT, + isDark, + contrastLevel, + platform, + specVersion, + ColorSpecs.get(specVersion) + .getPrimaryPalette(Variant.CONTENT, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getSecondaryPalette(Variant.CONTENT, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getTertiaryPalette(Variant.CONTENT, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getNeutralPalette(Variant.CONTENT, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getNeutralVariantPalette(Variant.CONTENT, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getErrorPalette(Variant.CONTENT, sourceColorHct, isDark, platform, contrastLevel), + ) diff --git a/libs/material-color-utilities/src/main/java/scheme/SchemeExpressive.kt b/libs/material-color-utilities/src/main/java/scheme/SchemeExpressive.kt new file mode 100644 index 000000000..e054705e9 --- /dev/null +++ b/libs/material-color-utilities/src/main/java/scheme/SchemeExpressive.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package scheme + +import dynamiccolor.ColorSpec.SpecVersion +import dynamiccolor.ColorSpecs +import dynamiccolor.DynamicScheme +import dynamiccolor.Variant +import hct.Hct + +/** A playful theme - the source color's hue does not appear in the theme. */ +class SchemeExpressive( + sourceColorHct: Hct, + isDark: Boolean, + contrastLevel: Double, + specVersion: SpecVersion = DEFAULT_SPEC_VERSION, + platform: Platform = DEFAULT_PLATFORM, +) : + DynamicScheme( + sourceColorHct, + Variant.EXPRESSIVE, + isDark, + contrastLevel, + platform, + specVersion, + ColorSpecs.get(specVersion) + .getPrimaryPalette(Variant.EXPRESSIVE, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getSecondaryPalette(Variant.EXPRESSIVE, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getTertiaryPalette(Variant.EXPRESSIVE, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getNeutralPalette(Variant.EXPRESSIVE, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getNeutralVariantPalette( + Variant.EXPRESSIVE, + sourceColorHct, + isDark, + platform, + contrastLevel, + ), + ColorSpecs.get(specVersion) + .getErrorPalette(Variant.EXPRESSIVE, sourceColorHct, isDark, platform, contrastLevel), + ) diff --git a/libs/material-color-utilities/src/main/java/scheme/SchemeFidelity.kt b/libs/material-color-utilities/src/main/java/scheme/SchemeFidelity.kt new file mode 100644 index 000000000..b0911bc31 --- /dev/null +++ b/libs/material-color-utilities/src/main/java/scheme/SchemeFidelity.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package scheme + +import dynamiccolor.ColorSpec.SpecVersion +import dynamiccolor.ColorSpecs +import dynamiccolor.DynamicScheme +import dynamiccolor.Variant +import hct.Hct + +/** + * A scheme that places the source color in Scheme.primaryContainer. + * + * Primary Container is the source color, adjusted for color relativity. It maintains constant + * appearance in light mode and dark mode. This adds ~5 tone in light mode, and subtracts ~5 tone in + * dark mode. + * + * Tertiary Container is the complement to the source color, using TemperatureCache. It also + * maintains constant appearance. + */ +class SchemeFidelity( + sourceColorHct: Hct, + isDark: Boolean, + contrastLevel: Double, + specVersion: SpecVersion = DEFAULT_SPEC_VERSION, + platform: Platform = DEFAULT_PLATFORM, +) : + DynamicScheme( + sourceColorHct, + Variant.FIDELITY, + isDark, + contrastLevel, + platform, + specVersion, + ColorSpecs.get(specVersion) + .getPrimaryPalette(Variant.FIDELITY, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getSecondaryPalette(Variant.FIDELITY, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getTertiaryPalette(Variant.FIDELITY, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getNeutralPalette(Variant.FIDELITY, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getNeutralVariantPalette(Variant.FIDELITY, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getErrorPalette(Variant.FIDELITY, sourceColorHct, isDark, platform, contrastLevel), + ) diff --git a/libs/material-color-utilities/src/main/java/scheme/SchemeFruitSalad.kt b/libs/material-color-utilities/src/main/java/scheme/SchemeFruitSalad.kt new file mode 100644 index 000000000..42cf7d04a --- /dev/null +++ b/libs/material-color-utilities/src/main/java/scheme/SchemeFruitSalad.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package scheme + +import dynamiccolor.ColorSpec.SpecVersion +import dynamiccolor.ColorSpecs +import dynamiccolor.DynamicScheme +import dynamiccolor.Variant +import hct.Hct + +/** A playful theme - the source color's hue does not appear in the theme. */ +class SchemeFruitSalad( + sourceColorHct: Hct, + isDark: Boolean, + contrastLevel: Double, + specVersion: SpecVersion = DEFAULT_SPEC_VERSION, + platform: Platform = DEFAULT_PLATFORM, +) : + DynamicScheme( + sourceColorHct, + Variant.FRUIT_SALAD, + isDark, + contrastLevel, + platform, + specVersion, + ColorSpecs.get(specVersion) + .getPrimaryPalette(Variant.FRUIT_SALAD, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getSecondaryPalette(Variant.FRUIT_SALAD, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getTertiaryPalette(Variant.FRUIT_SALAD, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getNeutralPalette(Variant.FRUIT_SALAD, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getNeutralVariantPalette( + Variant.FRUIT_SALAD, + sourceColorHct, + isDark, + platform, + contrastLevel, + ), + ColorSpecs.get(specVersion) + .getErrorPalette(Variant.FRUIT_SALAD, sourceColorHct, isDark, platform, contrastLevel), + ) diff --git a/libs/material-color-utilities/src/main/java/scheme/SchemeMonochrome.kt b/libs/material-color-utilities/src/main/java/scheme/SchemeMonochrome.kt new file mode 100644 index 000000000..0c3436d77 --- /dev/null +++ b/libs/material-color-utilities/src/main/java/scheme/SchemeMonochrome.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package scheme + +import dynamiccolor.ColorSpec.SpecVersion +import dynamiccolor.ColorSpecs +import dynamiccolor.DynamicScheme +import dynamiccolor.Variant +import hct.Hct + +/** A monochrome theme, colors are purely black / white / gray. */ +class SchemeMonochrome( + sourceColorHct: Hct, + isDark: Boolean, + contrastLevel: Double, + specVersion: SpecVersion = DEFAULT_SPEC_VERSION, + platform: Platform = DEFAULT_PLATFORM, +) : + DynamicScheme( + sourceColorHct, + Variant.MONOCHROME, + isDark, + contrastLevel, + platform, + specVersion, + ColorSpecs.get(specVersion) + .getPrimaryPalette(Variant.MONOCHROME, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getSecondaryPalette(Variant.MONOCHROME, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getTertiaryPalette(Variant.MONOCHROME, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getNeutralPalette(Variant.MONOCHROME, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getNeutralVariantPalette( + Variant.MONOCHROME, + sourceColorHct, + isDark, + platform, + contrastLevel, + ), + ColorSpecs.get(specVersion) + .getErrorPalette(Variant.MONOCHROME, sourceColorHct, isDark, platform, contrastLevel), + ) diff --git a/libs/material-color-utilities/src/main/java/scheme/SchemeNeutral.kt b/libs/material-color-utilities/src/main/java/scheme/SchemeNeutral.kt new file mode 100644 index 000000000..8b7aa3959 --- /dev/null +++ b/libs/material-color-utilities/src/main/java/scheme/SchemeNeutral.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package scheme + +import dynamiccolor.ColorSpec.SpecVersion +import dynamiccolor.ColorSpecs +import dynamiccolor.DynamicScheme +import dynamiccolor.Variant +import hct.Hct + +/** A theme that's slightly more chromatic than monochrome, which is purely black / white / gray. */ +class SchemeNeutral( + sourceColorHct: Hct, + isDark: Boolean, + contrastLevel: Double, + specVersion: SpecVersion = DEFAULT_SPEC_VERSION, + platform: Platform = DEFAULT_PLATFORM, +) : + DynamicScheme( + sourceColorHct, + Variant.NEUTRAL, + isDark, + contrastLevel, + platform, + specVersion, + ColorSpecs.get(specVersion) + .getPrimaryPalette(Variant.NEUTRAL, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getSecondaryPalette(Variant.NEUTRAL, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getTertiaryPalette(Variant.NEUTRAL, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getNeutralPalette(Variant.NEUTRAL, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getNeutralVariantPalette(Variant.NEUTRAL, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getErrorPalette(Variant.NEUTRAL, sourceColorHct, isDark, platform, contrastLevel), + ) diff --git a/libs/material-color-utilities/src/main/java/scheme/SchemeRainbow.kt b/libs/material-color-utilities/src/main/java/scheme/SchemeRainbow.kt new file mode 100644 index 000000000..842b6ecbc --- /dev/null +++ b/libs/material-color-utilities/src/main/java/scheme/SchemeRainbow.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package scheme + +import dynamiccolor.ColorSpec.SpecVersion +import dynamiccolor.ColorSpecs +import dynamiccolor.DynamicScheme +import dynamiccolor.Variant +import hct.Hct + +/** A playful theme - the source color's hue does not appear in the theme. */ +class SchemeRainbow( + sourceColorHct: Hct, + isDark: Boolean, + contrastLevel: Double, + specVersion: SpecVersion = DEFAULT_SPEC_VERSION, + platform: Platform = DEFAULT_PLATFORM, +) : + DynamicScheme( + sourceColorHct, + Variant.RAINBOW, + isDark, + contrastLevel, + platform, + specVersion, + ColorSpecs.get(specVersion) + .getPrimaryPalette(Variant.RAINBOW, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getSecondaryPalette(Variant.RAINBOW, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getTertiaryPalette(Variant.RAINBOW, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getNeutralPalette(Variant.RAINBOW, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getNeutralVariantPalette(Variant.RAINBOW, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getErrorPalette(Variant.RAINBOW, sourceColorHct, isDark, platform, contrastLevel), + ) diff --git a/libs/material-color-utilities/src/main/java/scheme/SchemeTonalSpot.kt b/libs/material-color-utilities/src/main/java/scheme/SchemeTonalSpot.kt new file mode 100644 index 000000000..9105e31c3 --- /dev/null +++ b/libs/material-color-utilities/src/main/java/scheme/SchemeTonalSpot.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package scheme + +import dynamiccolor.ColorSpec.SpecVersion +import dynamiccolor.ColorSpecs +import dynamiccolor.DynamicScheme +import dynamiccolor.Variant +import hct.Hct + +/** A calm theme, sedated colors that aren't particularly chromatic. */ +class SchemeTonalSpot( + sourceColorHct: Hct, + isDark: Boolean, + contrastLevel: Double, + specVersion: SpecVersion = DEFAULT_SPEC_VERSION, + platform: Platform = DEFAULT_PLATFORM, +) : + DynamicScheme( + sourceColorHct, + Variant.TONAL_SPOT, + isDark, + contrastLevel, + platform, + specVersion, + ColorSpecs.get(specVersion) + .getPrimaryPalette(Variant.TONAL_SPOT, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getSecondaryPalette(Variant.TONAL_SPOT, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getTertiaryPalette(Variant.TONAL_SPOT, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getNeutralPalette(Variant.TONAL_SPOT, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getNeutralVariantPalette( + Variant.TONAL_SPOT, + sourceColorHct, + isDark, + platform, + contrastLevel, + ), + ColorSpecs.get(specVersion) + .getErrorPalette(Variant.TONAL_SPOT, sourceColorHct, isDark, platform, contrastLevel), + ) diff --git a/libs/material-color-utilities/src/main/java/scheme/SchemeVibrant.kt b/libs/material-color-utilities/src/main/java/scheme/SchemeVibrant.kt new file mode 100644 index 000000000..b159c288b --- /dev/null +++ b/libs/material-color-utilities/src/main/java/scheme/SchemeVibrant.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package scheme + +import dynamiccolor.ColorSpec.SpecVersion +import dynamiccolor.ColorSpecs +import dynamiccolor.DynamicScheme +import dynamiccolor.Variant +import hct.Hct + +/** A loud theme, colorfulness is maximum for Primary palette, increased for others. */ +class SchemeVibrant( + sourceColorHct: Hct, + isDark: Boolean, + contrastLevel: Double, + specVersion: SpecVersion = DEFAULT_SPEC_VERSION, + platform: Platform = DEFAULT_PLATFORM, +) : + DynamicScheme( + sourceColorHct, + Variant.VIBRANT, + isDark, + contrastLevel, + platform, + specVersion, + ColorSpecs.get(specVersion) + .getPrimaryPalette(Variant.VIBRANT, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getSecondaryPalette(Variant.VIBRANT, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getTertiaryPalette(Variant.VIBRANT, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getNeutralPalette(Variant.VIBRANT, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getNeutralVariantPalette(Variant.VIBRANT, sourceColorHct, isDark, platform, contrastLevel), + ColorSpecs.get(specVersion) + .getErrorPalette(Variant.VIBRANT, sourceColorHct, isDark, platform, contrastLevel), + ) diff --git a/libs/material-color-utilities/src/main/java/score/Score.kt b/libs/material-color-utilities/src/main/java/score/Score.kt new file mode 100644 index 000000000..c3918ca7a --- /dev/null +++ b/libs/material-color-utilities/src/main/java/score/Score.kt @@ -0,0 +1,164 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package score + +import hct.Hct +import utils.MathUtils +import java.util.ArrayList +import java.util.Collections +import kotlin.math.floor +import kotlin.math.roundToInt + +/** + * Given a large set of colors, remove colors that are unsuitable for a UI theme, and rank the rest + * based on suitability. + * + * Enables use of a high cluster count for image quantization, thus ensuring colors aren't muddied, + * while curating the high cluster count to a much smaller number of appropriate choices. + */ +object Score { + private const val TARGET_CHROMA = 48.0 // A1 Chroma + private const val WEIGHT_PROPORTION = 0.7 + private const val WEIGHT_CHROMA_ABOVE = 0.3 + private const val WEIGHT_CHROMA_BELOW = 0.1 + private const val CUTOFF_CHROMA = 5.0 + private const val CUTOFF_EXCITED_PROPORTION = 0.01 + + @JvmStatic + fun score(colorsToPopulation: Map): List { + // Fallback color is Google Blue. + return score(colorsToPopulation, 4, 0xff4285f4.toInt(), true) + } + + @JvmStatic + fun score(colorsToPopulation: Map, desired: Int): List { + return score(colorsToPopulation, desired, 0xff4285f4.toInt(), true) + } + + @JvmStatic + fun score(colorsToPopulation: Map, desired: Int, fallbackColorArgb: Int): List { + return score(colorsToPopulation, desired, fallbackColorArgb, true) + } + + /** + * Given a map with keys of colors and values of how often the color appears, rank the colors + * based on suitability for being used for a UI theme. + * + * @param colorsToPopulation map with keys of colors and values of how often the color appears, + * usually from a source image. + * @param desired max count of colors to be returned in the list. + * @param fallbackColorArgb color to be returned if no other options available. + * @param filter whether to filter out undesireable combinations. + * @return Colors sorted by suitability for a UI theme. The most suitable color is the first item, + * the least suitable is the last. There will always be at least one color returned. If all the + * input colors were not suitable for a theme, a default fallback color will be provided, Google + * Blue. + */ + @JvmStatic + fun score( + colorsToPopulation: Map, + desired: Int, + fallbackColorArgb: Int, + filter: Boolean, + ): List { + + // Get the HCT color for each Argb value, while finding the per hue count and + // total count. + val colorsHct: MutableList = ArrayList() + val huePopulation = IntArray(360) + var populationSum = 0.0 + for ((key, value) in colorsToPopulation) { + val hct = Hct.fromInt(key) + colorsHct.add(hct) + val hue = floor(hct.hue).toInt() + huePopulation[hue] += value + populationSum += value.toDouble() + } + + // Hues with more usage in neighboring 30 degree slice get a larger number. + val hueExcitedProportions = DoubleArray(360) + for (hue in 0..359) { + val proportion = huePopulation[hue] / populationSum + for (i in hue - 14 until hue + 16) { + val neighborHue = MathUtils.sanitizeDegreesInt(i) + hueExcitedProportions[neighborHue] += proportion + } + } + + // Scores each HCT color based on usage and chroma, while optionally + // filtering out values that do not have enough chroma or usage. + val scoredHcts: MutableList = ArrayList() + for (hct in colorsHct) { + val hue = MathUtils.sanitizeDegreesInt(hct.hue.roundToInt()) + val proportion = hueExcitedProportions[hue] + if (filter && (hct.chroma < CUTOFF_CHROMA || proportion <= CUTOFF_EXCITED_PROPORTION)) { + continue + } + val proportionScore = proportion * 100.0 * WEIGHT_PROPORTION + val chromaWeight = + if (hct.chroma < TARGET_CHROMA) WEIGHT_CHROMA_BELOW else WEIGHT_CHROMA_ABOVE + val chromaScore = (hct.chroma - TARGET_CHROMA) * chromaWeight + val score = proportionScore + chromaScore + scoredHcts.add(ScoredHCT(hct, score)) + } + // Sorted so that colors with higher scores come first. + Collections.sort(scoredHcts, ScoredComparator()) + + // Iterates through potential hue differences in degrees in order to select + // the colors with the largest distribution of hues possible. Starting at + // 90 degrees(maximum difference for 4 colors) then decreasing down to a + // 15 degree minimum. + val chosenColors: MutableList = ArrayList() + for (differenceDegrees in 90 downTo 15) { + chosenColors.clear() + for (entry in scoredHcts) { + val hct = entry.hct + var hasDuplicateHue = false + for (chosenHct in chosenColors) { + if (MathUtils.differenceDegrees(hct.hue, chosenHct.hue) < differenceDegrees) { + hasDuplicateHue = true + break + } + } + if (!hasDuplicateHue) { + chosenColors.add(hct) + } + if (chosenColors.size >= desired) { + break + } + } + if (chosenColors.size >= desired) { + break + } + } + val colors: MutableList = ArrayList() + if (chosenColors.isEmpty()) { + colors.add(fallbackColorArgb) + } + for (chosenHct in chosenColors) { + colors.add(chosenHct.toInt()) + } + return colors + } + + private class ScoredHCT(val hct: Hct, val score: Double) + + private class ScoredComparator() : Comparator { + override fun compare(entry1: ScoredHCT, entry2: ScoredHCT): Int { + return entry2.score.compareTo(entry1.score) + } + } +} diff --git a/libs/material-color-utilities/src/main/java/temperature/TemperatureCache.kt b/libs/material-color-utilities/src/main/java/temperature/TemperatureCache.kt new file mode 100644 index 000000000..b0f49fa64 --- /dev/null +++ b/libs/material-color-utilities/src/main/java/temperature/TemperatureCache.kt @@ -0,0 +1,302 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package temperature + +import hct.Hct +import utils.ColorUtils +import utils.MathUtils +import java.util.ArrayList +import java.util.Collections +import java.util.HashMap +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.floor +import kotlin.math.hypot +import kotlin.math.pow +import kotlin.math.roundToInt + +/** + * Design utilities using color temperature theory. + * + * Analogous colors, complementary color, and cache to efficiently, lazily, generate data for + * calculations when needed. + */ +class TemperatureCache(val input: Hct) { + private var _precomputedComplement: Hct? = null + private var _precomputedHctsByTemp: List? = null + private var _precomputedHctsByHue: List? = null + private var _precomputedTempsByHct: Map? = null + + /** + * A color that complements the input color aesthetically. + * + * In art, this is usually described as being across the color wheel. History of this shows intent + * as a color that is just as cool-warm as the input color is warm-cool. + */ + val complement: Hct + get() { + if (_precomputedComplement != null) { + return _precomputedComplement!! + } + val coldestHue = coldest.hue + val coldestTemp = tempsByHct[coldest]!! + val warmestHue = warmest.hue + val warmestTemp = tempsByHct[warmest]!! + val range = warmestTemp - coldestTemp + val startHueIsColdestToWarmest = isBetween(input.hue, coldestHue, warmestHue) + val startHue = if (startHueIsColdestToWarmest) warmestHue else coldestHue + val endHue = if (startHueIsColdestToWarmest) coldestHue else warmestHue + val directionOfRotation = 1.0 + var smallestError = 1000.0 + var answer = hctsByHue[input.hue.roundToInt()] + val complementRelativeTemp = 1.0 - getRelativeTemperature(input) + // Find the color in the other section, closest to the inverse percentile + // of the input color. This is the complement. + var hueAddend = 0.0 + while (hueAddend <= 360.0) { + val hue = MathUtils.sanitizeDegreesDouble(startHue + directionOfRotation * hueAddend) + if (!isBetween(hue, startHue, endHue)) { + hueAddend += 1.0 + continue + } + val possibleAnswer = hctsByHue[hue.roundToInt()] + val relativeTemp = (tempsByHct[possibleAnswer]!! - coldestTemp) / range + val error = abs(complementRelativeTemp - relativeTemp) + if (error < smallestError) { + smallestError = error + answer = possibleAnswer + } + hueAddend += 1.0 + } + _precomputedComplement = answer + return _precomputedComplement!! + } + + /** + * 5 colors that pair well with the input color. + * + * The colors are equidistant in temperature and adjacent in hue. + */ + fun getAnalogousColors(): List { + return getAnalogousColors(5, 12) + } + + /** + * A set of colors with differing hues, equidistant in temperature. + * + * In art, this is usually described as a set of 5 colors on a color wheel divided into 12 + * sections. This method allows provision of either of those values. + * + * Behavior is undefined when count or divisions is 0. When divisions < count, colors repeat. + * + * @param count The number of colors to return, includes the input color. + * @param divisions The number of divisions on the color wheel. + */ + fun getAnalogousColors(count: Int, divisions: Int): List { + // The starting hue is the hue of the input color. + val startHue = input.hue.roundToInt() + val startHct = hctsByHue[startHue] + var lastTemp = getRelativeTemperature(startHct) + val allColors: MutableList = ArrayList() + allColors.add(startHct) + var absoluteTotalTempDelta = 0f + for (i in 0..359) { + val hue = MathUtils.sanitizeDegreesInt(startHue + i) + val hct = hctsByHue[hue] + val temp = getRelativeTemperature(hct) + val tempDelta = abs(temp - lastTemp) + lastTemp = temp + absoluteTotalTempDelta += tempDelta.toFloat() + } + var hueAddend = 1 + val tempStep = absoluteTotalTempDelta / divisions.toDouble() + var totalTempDelta = 0.0 + lastTemp = getRelativeTemperature(startHct) + while (allColors.size < divisions) { + val hue = MathUtils.sanitizeDegreesInt(startHue + hueAddend) + val hct = hctsByHue[hue] + val temp = getRelativeTemperature(hct) + val tempDelta = abs(temp - lastTemp) + totalTempDelta += tempDelta + var desiredTotalTempDeltaForIndex = allColors.size * tempStep + var indexSatisfied = totalTempDelta >= desiredTotalTempDeltaForIndex + var indexAddend = 1 + // Keep adding this hue to the answers until its temperature is + // insufficient. This ensures consistent behavior when there aren't + // `divisions` discrete steps between 0 and 360 in hue with `tempStep` + // delta in temperature between them. + // + // For example, white and black have no analogues: there are no other + // colors at T100/T0. Therefore, they should just be added to the array + // as answers. + while (indexSatisfied && allColors.size < divisions) { + allColors.add(hct) + desiredTotalTempDeltaForIndex = (allColors.size + indexAddend) * tempStep + indexSatisfied = totalTempDelta >= desiredTotalTempDeltaForIndex + indexAddend++ + } + lastTemp = temp + hueAddend++ + if (hueAddend > 360) { + while (allColors.size < divisions) { + allColors.add(hct) + } + break + } + } + val answers: MutableList = ArrayList() + answers.add(input) + val ccwCount = floor(((count - 1.0) / 2.0)).toInt() + for (i in 1 until ccwCount + 1) { + var index = 0 - i + while (index < 0) { + index = allColors.size + index + } + if (index >= allColors.size) { + index %= allColors.size + } + answers.add(0, allColors[index]) + } + val cwCount = count - ccwCount - 1 + for (i in 1 until cwCount + 1) { + var index = i + while (index < 0) { + index = allColors.size + index + } + if (index >= allColors.size) { + index %= allColors.size + } + answers.add(allColors[index]) + } + return answers + } + + /** + * Temperature relative to all colors with the same chroma and tone. + * + * @param hct HCT to find the relative temperature of. + * @return Value on a scale from 0 to 1. + */ + fun getRelativeTemperature(hct: Hct): Double { + val range = tempsByHct[warmest]!! - tempsByHct[coldest]!! + val differenceFromColdest = tempsByHct[hct]!! - tempsByHct[coldest]!! + // Handle when there's no difference in temperature between warmest and + // coldest: for example, at T100, only one color is available, white. + return if (range == 0.0) { + 0.5 + } else { + differenceFromColdest / range + } + } + + /** Coldest color with same chroma and tone as input. */ + private val coldest: Hct + get() = hctsByTemp[0] + + /** + * HCTs for all colors with the same chroma/tone as the input. + * + * Sorted by hue, ex. index 0 is hue 0. + */ + private val hctsByHue: List + get() { + if (_precomputedHctsByHue != null) { + return _precomputedHctsByHue!! + } + val hcts: MutableList = ArrayList() + var hue = 0.0 + while (hue <= 360.0) { + val colorAtHue = Hct.from(hue, input.chroma, input.tone) + hcts.add(colorAtHue) + hue += 1.0 + } + val unmodifiableList = Collections.unmodifiableList(hcts) + _precomputedHctsByHue = unmodifiableList + return unmodifiableList + } + + /** + * HCTs for all colors with the same chroma/tone as the input. + * + * Sorted from coldest first to warmest last. + */ + private val hctsByTemp: List + get() { + if (_precomputedHctsByTemp != null) { + return _precomputedHctsByTemp!! + } + val hcts: MutableList = ArrayList(hctsByHue) + hcts.add(input) + hcts.sortWith(compareBy { tempsByHct[it]!! }) + _precomputedHctsByTemp = hcts + return hcts + } + + /** Keys of HCTs in getHctsByTemp, values of raw temperature. */ + private val tempsByHct: Map + get() { + if (_precomputedTempsByHct != null) { + return _precomputedTempsByHct!! + } + val allHcts: MutableList = ArrayList(hctsByHue) + allHcts.add(input) + val temperaturesByHct: MutableMap = HashMap() + for (hct in allHcts) { + temperaturesByHct[hct] = rawTemperature(hct) + } + _precomputedTempsByHct = temperaturesByHct + return temperaturesByHct + } + + /** Warmest color with same chroma and tone as input. */ + private val warmest: Hct + get() = hctsByTemp[hctsByTemp.size - 1] + + companion object { + /** + * Value representing cool-warm factor of a color. Values below 0 are considered cool, above, + * warm. + * + * Color science has researched emotion and harmony, which art uses to select colors. Warm-cool + * is the foundation of analogous and complementary colors. See: + * - Li-Chen Ou's Chapter 19 in Handbook of Color Psychology (2015). + * - Josef Albers' Interaction of Color chapters 19 and 21. + * + * Implementation of Ou, Woodcock and Wright's algorithm, which uses Lab/LCH color space. Return + * value has these properties: + * - Values below 0 are cool, above 0 are warm. + * - Lower bound: -9.66. Chroma is infinite. Assuming max of Lab chroma 130. + * - Upper bound: 8.61. Chroma is infinite. Assuming max of Lab chroma 130. + */ + fun rawTemperature(color: Hct): Double { + val lab = ColorUtils.labFromArgb(color.toInt()) + val hue = MathUtils.sanitizeDegreesDouble(Math.toDegrees(atan2(lab[2], lab[1]))) + val chroma = hypot(lab[1], lab[2]) + return -0.5 + + 0.02 * chroma.pow(1.07) * cos(Math.toRadians(MathUtils.sanitizeDegreesDouble(hue - 50.0))) + } + + /** Determines if an angle is between two other angles, rotating clockwise. */ + private fun isBetween(angle: Double, a: Double, b: Double): Boolean { + return if (a < b) { + a <= angle && angle <= b + } else { + a <= angle || angle <= b + } + } + } +} diff --git a/libs/material-color-utilities/src/main/java/utils/ColorUtils.java b/libs/material-color-utilities/src/main/java/utils/ColorUtils.java deleted file mode 100644 index 27430ad8a..000000000 --- a/libs/material-color-utilities/src/main/java/utils/ColorUtils.java +++ /dev/null @@ -1,267 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// This file is automatically generated. Do not modify it. - -package utils; - -/** - * Color science utilities. - * - *

Utility methods for color science constants and color space conversions that aren't HCT or - * CAM16. - */ -public class ColorUtils { - private ColorUtils() {} - - static final double[][] SRGB_TO_XYZ = - new double[][] { - new double[] {0.41233895, 0.35762064, 0.18051042}, - new double[] {0.2126, 0.7152, 0.0722}, - new double[] {0.01932141, 0.11916382, 0.95034478}, - }; - - static final double[][] XYZ_TO_SRGB = - new double[][] { - new double[] { - 3.2413774792388685, -1.5376652402851851, -0.49885366846268053, - }, - new double[] { - -0.9691452513005321, 1.8758853451067872, 0.04156585616912061, - }, - new double[] { - 0.05562093689691305, -0.20395524564742123, 1.0571799111220335, - }, - }; - - static final double[] WHITE_POINT_D65 = new double[] {95.047, 100.0, 108.883}; - - /** Converts a color from RGB components to ARGB format. */ - public static int argbFromRgb(int red, int green, int blue) { - return (255 << 24) | ((red & 255) << 16) | ((green & 255) << 8) | (blue & 255); - } - - /** Converts a color from linear RGB components to ARGB format. */ - public static int argbFromLinrgb(double[] linrgb) { - int r = delinearized(linrgb[0]); - int g = delinearized(linrgb[1]); - int b = delinearized(linrgb[2]); - return argbFromRgb(r, g, b); - } - - /** Returns the alpha component of a color in ARGB format. */ - public static int alphaFromArgb(int argb) { - return (argb >> 24) & 255; - } - - /** Returns the red component of a color in ARGB format. */ - public static int redFromArgb(int argb) { - return (argb >> 16) & 255; - } - - /** Returns the green component of a color in ARGB format. */ - public static int greenFromArgb(int argb) { - return (argb >> 8) & 255; - } - - /** Returns the blue component of a color in ARGB format. */ - public static int blueFromArgb(int argb) { - return argb & 255; - } - - /** Returns whether a color in ARGB format is opaque. */ - public static boolean isOpaque(int argb) { - return alphaFromArgb(argb) >= 255; - } - - /** Converts a color from ARGB to XYZ. */ - public static int argbFromXyz(double x, double y, double z) { - double[][] matrix = XYZ_TO_SRGB; - double linearR = matrix[0][0] * x + matrix[0][1] * y + matrix[0][2] * z; - double linearG = matrix[1][0] * x + matrix[1][1] * y + matrix[1][2] * z; - double linearB = matrix[2][0] * x + matrix[2][1] * y + matrix[2][2] * z; - int r = delinearized(linearR); - int g = delinearized(linearG); - int b = delinearized(linearB); - return argbFromRgb(r, g, b); - } - - /** Converts a color from XYZ to ARGB. */ - public static double[] xyzFromArgb(int argb) { - double r = linearized(redFromArgb(argb)); - double g = linearized(greenFromArgb(argb)); - double b = linearized(blueFromArgb(argb)); - return MathUtils.matrixMultiply(new double[] {r, g, b}, SRGB_TO_XYZ); - } - - /** Converts a color represented in Lab color space into an ARGB integer. */ - public static int argbFromLab(double l, double a, double b) { - double[] whitePoint = WHITE_POINT_D65; - double fy = (l + 16.0) / 116.0; - double fx = a / 500.0 + fy; - double fz = fy - b / 200.0; - double xNormalized = labInvf(fx); - double yNormalized = labInvf(fy); - double zNormalized = labInvf(fz); - double x = xNormalized * whitePoint[0]; - double y = yNormalized * whitePoint[1]; - double z = zNormalized * whitePoint[2]; - return argbFromXyz(x, y, z); - } - - /** - * Converts a color from ARGB representation to L*a*b* representation. - * - * @param argb the ARGB representation of a color - * @return a Lab object representing the color - */ - public static double[] labFromArgb(int argb) { - double linearR = linearized(redFromArgb(argb)); - double linearG = linearized(greenFromArgb(argb)); - double linearB = linearized(blueFromArgb(argb)); - double[][] matrix = SRGB_TO_XYZ; - double x = matrix[0][0] * linearR + matrix[0][1] * linearG + matrix[0][2] * linearB; - double y = matrix[1][0] * linearR + matrix[1][1] * linearG + matrix[1][2] * linearB; - double z = matrix[2][0] * linearR + matrix[2][1] * linearG + matrix[2][2] * linearB; - double[] whitePoint = WHITE_POINT_D65; - double xNormalized = x / whitePoint[0]; - double yNormalized = y / whitePoint[1]; - double zNormalized = z / whitePoint[2]; - double fx = labF(xNormalized); - double fy = labF(yNormalized); - double fz = labF(zNormalized); - double l = 116.0 * fy - 16; - double a = 500.0 * (fx - fy); - double b = 200.0 * (fy - fz); - return new double[] {l, a, b}; - } - - /** - * Converts an L* value to an ARGB representation. - * - * @param lstar L* in L*a*b* - * @return ARGB representation of grayscale color with lightness matching L* - */ - public static int argbFromLstar(double lstar) { - double y = yFromLstar(lstar); - int component = delinearized(y); - return argbFromRgb(component, component, component); - } - - /** - * Computes the L* value of a color in ARGB representation. - * - * @param argb ARGB representation of a color - * @return L*, from L*a*b*, coordinate of the color - */ - public static double lstarFromArgb(int argb) { - double y = xyzFromArgb(argb)[1]; - return 116.0 * labF(y / 100.0) - 16.0; - } - - /** - * 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* - * @return Y in XYZ - */ - public static double yFromLstar(double lstar) { - return 100.0 * labInvf((lstar + 16.0) / 116.0); - } - - /** - * Converts a Y value to an L* 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 y Y in XYZ - * @return L* in L*a*b* - */ - public static double lstarFromY(double y) { - return labF(y / 100.0) * 116.0 - 16.0; - } - - /** - * Linearizes an RGB component. - * - * @param rgbComponent 0 <= rgb_component <= 255, represents R/G/B channel - * @return 0.0 <= output <= 100.0, color channel converted to linear RGB space - */ - public static double linearized(int rgbComponent) { - double normalized = rgbComponent / 255.0; - if (normalized <= 0.040449936) { - return normalized / 12.92 * 100.0; - } else { - return Math.pow((normalized + 0.055) / 1.055, 2.4) * 100.0; - } - } - - /** - * Delinearizes an RGB component. - * - * @param rgbComponent 0.0 <= rgb_component <= 100.0, represents linear R/G/B channel - * @return 0 <= output <= 255, color channel converted to regular RGB space - */ - public static int delinearized(double rgbComponent) { - double normalized = rgbComponent / 100.0; - double delinearized = 0.0; - if (normalized <= 0.0031308) { - delinearized = normalized * 12.92; - } else { - delinearized = 1.055 * Math.pow(normalized, 1.0 / 2.4) - 0.055; - } - return MathUtils.clampInt(0, 255, (int) Math.round(delinearized * 255.0)); - } - - /** - * Returns the standard white point; white on a sunny day. - * - * @return The white point - */ - public static double[] whitePointD65() { - return WHITE_POINT_D65; - } - - static double labF(double t) { - double e = 216.0 / 24389.0; - double kappa = 24389.0 / 27.0; - if (t > e) { - return Math.pow(t, 1.0 / 3.0); - } else { - return (kappa * t + 16) / 116; - } - } - - static double labInvf(double ft) { - double e = 216.0 / 24389.0; - double kappa = 24389.0 / 27.0; - double ft3 = ft * ft * ft; - if (ft3 > e) { - return ft3; - } else { - return (116 * ft - 16) / kappa; - } - } -} - diff --git a/libs/material-color-utilities/src/main/java/utils/ColorUtils.kt b/libs/material-color-utilities/src/main/java/utils/ColorUtils.kt new file mode 100644 index 000000000..4d198bd57 --- /dev/null +++ b/libs/material-color-utilities/src/main/java/utils/ColorUtils.kt @@ -0,0 +1,256 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// This file is automatically generated. Do not modify it. +package utils + +import kotlin.math.pow +import kotlin.math.roundToInt + +/** + * Color science utilities. + * + * Utility methods for color science constants and color space conversions that aren't HCT or CAM16. + */ +object ColorUtils { + private val SRGB_TO_XYZ = + arrayOf( + doubleArrayOf(0.41233895, 0.35762064, 0.18051042), + doubleArrayOf(0.2126, 0.7152, 0.0722), + doubleArrayOf(0.01932141, 0.11916382, 0.95034478), + ) + private val XYZ_TO_SRGB = + arrayOf( + doubleArrayOf(3.2413774792388685, -1.5376652402851851, -0.49885366846268053), + doubleArrayOf(-0.9691452513005321, 1.8758853451067872, 0.04156585616912061), + doubleArrayOf(0.05562093689691305, -0.20395524564742123, 1.0571799111220335), + ) + private val WHITE_POINT_D65 = doubleArrayOf(95.047, 100.0, 108.883) + + /** Converts a color from RGB components to ARGB format. */ + fun argbFromRgb(red: Int, green: Int, blue: Int): Int { + return 255 shl 24 or (red and 255 shl 16) or (green and 255 shl 8) or (blue and 255) + } + + /** Converts a color from linear RGB components to ARGB format. */ + fun argbFromLinrgb(linrgb: DoubleArray): Int { + val r = delinearized(linrgb[0]) + val g = delinearized(linrgb[1]) + val b = delinearized(linrgb[2]) + return argbFromRgb(r, g, b) + } + + /** Returns the alpha component of a color in ARGB format. */ + fun alphaFromArgb(argb: Int): Int { + return argb shr 24 and 255 + } + + /** Returns the red component of a color in ARGB format. */ + fun redFromArgb(argb: Int): Int { + return argb shr 16 and 255 + } + + /** Returns the green component of a color in ARGB format. */ + fun greenFromArgb(argb: Int): Int { + return argb shr 8 and 255 + } + + /** Returns the blue component of a color in ARGB format. */ + fun blueFromArgb(argb: Int): Int { + return argb and 255 + } + + /** Returns whether a color in ARGB format is opaque. */ + fun isOpaque(argb: Int): Boolean { + return alphaFromArgb(argb) >= 255 + } + + /** Converts a color from ARGB to XYZ. */ + fun argbFromXyz(x: Double, y: Double, z: Double): Int { + val matrix = XYZ_TO_SRGB + val linearR = matrix[0][0] * x + matrix[0][1] * y + matrix[0][2] * z + val linearG = matrix[1][0] * x + matrix[1][1] * y + matrix[1][2] * z + val linearB = matrix[2][0] * x + matrix[2][1] * y + matrix[2][2] * z + val r = delinearized(linearR) + val g = delinearized(linearG) + val b = delinearized(linearB) + return argbFromRgb(r, g, b) + } + + /** Converts a color from XYZ to ARGB. */ + fun xyzFromArgb(argb: Int): DoubleArray { + val r = linearized(redFromArgb(argb)) + val g = linearized(greenFromArgb(argb)) + val b = linearized(blueFromArgb(argb)) + return MathUtils.matrixMultiply(doubleArrayOf(r, g, b), SRGB_TO_XYZ) + } + + /** Converts a color represented in Lab color space into an ARGB integer. */ + fun argbFromLab(l: Double, a: Double, b: Double): Int { + val whitePoint = WHITE_POINT_D65 + val fy = (l + 16.0) / 116.0 + val fx = a / 500.0 + fy + val fz = fy - b / 200.0 + val xNormalized = labInvf(fx) + val yNormalized = labInvf(fy) + val zNormalized = labInvf(fz) + val x = xNormalized * whitePoint[0] + val y = yNormalized * whitePoint[1] + val z = zNormalized * whitePoint[2] + return argbFromXyz(x, y, z) + } + + /** + * Converts a color from ARGB representation to L*a*b* representation. + * + * @param argb the ARGB representation of a color + * @return a Lab object representing the color + */ + fun labFromArgb(argb: Int): DoubleArray { + val linearR = linearized(redFromArgb(argb)) + val linearG = linearized(greenFromArgb(argb)) + val linearB = linearized(blueFromArgb(argb)) + val matrix = SRGB_TO_XYZ + val x = matrix[0][0] * linearR + matrix[0][1] * linearG + matrix[0][2] * linearB + val y = matrix[1][0] * linearR + matrix[1][1] * linearG + matrix[1][2] * linearB + val z = matrix[2][0] * linearR + matrix[2][1] * linearG + matrix[2][2] * linearB + val whitePoint = WHITE_POINT_D65 + val xNormalized = x / whitePoint[0] + val yNormalized = y / whitePoint[1] + val zNormalized = z / whitePoint[2] + val fx = labF(xNormalized) + val fy = labF(yNormalized) + val fz = labF(zNormalized) + val l = 116.0 * fy - 16 + val a = 500.0 * (fx - fy) + val b = 200.0 * (fy - fz) + return doubleArrayOf(l, a, b) + } + + /** + * Converts an L* value to an ARGB representation. + * + * @param lstar L* in L*a*b* + * @return ARGB representation of grayscale color with lightness matching L* + */ + fun argbFromLstar(lstar: Double): Int { + val y = yFromLstar(lstar) + val component = delinearized(y) + return argbFromRgb(component, component, component) + } + + /** + * Computes the L* value of a color in ARGB representation. + * + * @param argb ARGB representation of a color + * @return L*, from L*a*b*, coordinate of the color + */ + fun lstarFromArgb(argb: Int): Double { + val y = xyzFromArgb(argb)[1] + return 116.0 * labF(y / 100.0) - 16.0 + } + + /** + * 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* + * @return Y in XYZ + */ + fun yFromLstar(lstar: Double): Double { + return 100.0 * labInvf((lstar + 16.0) / 116.0) + } + + /** + * Converts a Y value to an L* 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 y Y in XYZ + * @return L* in L*a*b* + */ + fun lstarFromY(y: Double): Double { + return labF(y / 100.0) * 116.0 - 16.0 + } + + /** + * Linearizes an RGB component. + * + * @param rgbComponent 0 <= rgb_component <= 255, represents R/G/B channel + * @return 0.0 <= output <= 100.0, color channel converted to linear RGB space + */ + fun linearized(rgbComponent: Int): Double { + val normalized = rgbComponent / 255.0 + return if (normalized <= 0.040449936) { + normalized / 12.92 * 100.0 + } else { + ((normalized + 0.055) / 1.055).pow(2.4) * 100.0 + } + } + + /** + * Delinearizes an RGB component. + * + * @param rgbComponent 0.0 <= rgb_component <= 100.0, represents linear R/G/B channel + * @return 0 <= output <= 255, color channel converted to regular RGB space + */ + fun delinearized(rgbComponent: Double): Int { + val normalized = rgbComponent / 100.0 + val delinearized: Double = + if (normalized <= 0.0031308) { + normalized * 12.92 + } else { + 1.055 * normalized.pow(1.0 / 2.4) - 0.055 + } + return (delinearized * 255.0).roundToInt().coerceIn(0, 255) + } + + /** + * Returns the standard white point; white on a sunny day. + * + * @return The white point + */ + fun whitePointD65(): DoubleArray { + return WHITE_POINT_D65 + } + + fun labF(t: Double): Double { + val e = 216.0 / 24389.0 + val kappa = 24389.0 / 27.0 + return if (t > e) { + t.pow(1.0 / 3.0) + } else { + (kappa * t + 16) / 116 + } + } + + fun labInvf(ft: Double): Double { + val e = 216.0 / 24389.0 + val kappa = 24389.0 / 27.0 + val ft3 = ft * ft * ft + return if (ft3 > e) { + ft3 + } else { + (116 * ft - 16) / kappa + } + } +} diff --git a/libs/material-color-utilities/src/main/java/utils/MathUtils.java b/libs/material-color-utilities/src/main/java/utils/MathUtils.java deleted file mode 100644 index a1de23497..000000000 --- a/libs/material-color-utilities/src/main/java/utils/MathUtils.java +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// This file is automatically generated. Do not modify it. - -package utils; - -/** Utility methods for mathematical operations. */ -public class MathUtils { - private MathUtils() {} - - /** - * The signum function. - * - * @return 1 if num > 0, -1 if num < 0, and 0 if num = 0 - */ - public static int signum(double num) { - if (num < 0) { - return -1; - } else if (num == 0) { - return 0; - } else { - return 1; - } - } - - /** - * The linear interpolation function. - * - * @return start if amount = 0 and stop if amount = 1 - */ - public static double lerp(double start, double stop, double amount) { - return (1.0 - amount) * start + amount * stop; - } - - /** - * Clamps an integer between two integers. - * - * @return input when min <= input <= max, and either min or max otherwise. - */ - public static int clampInt(int min, int max, int input) { - if (input < min) { - return min; - } else if (input > max) { - return max; - } - - return input; - } - - /** - * Clamps an integer between two floating-point numbers. - * - * @return input when min <= input <= max, and either min or max otherwise. - */ - public static double clampDouble(double min, double max, double input) { - if (input < min) { - return min; - } else if (input > max) { - return max; - } - - return input; - } - - /** - * Sanitizes a degree measure as an integer. - * - * @return a degree measure between 0 (inclusive) and 360 (exclusive). - */ - public static int sanitizeDegreesInt(int degrees) { - degrees = degrees % 360; - if (degrees < 0) { - degrees = degrees + 360; - } - return degrees; - } - - /** - * Sanitizes a degree measure as a floating-point number. - * - * @return a degree measure between 0.0 (inclusive) and 360.0 (exclusive). - */ - public static double sanitizeDegreesDouble(double degrees) { - degrees = degrees % 360.0; - if (degrees < 0) { - degrees = degrees + 360.0; - } - return degrees; - } - - /** - * Sign of direction change needed to travel from one angle to another. - * - *

For angles that are 180 degrees apart from each other, both directions have the same travel - * distance, so either direction is shortest. The value 1.0 is returned in this case. - * - * @param from The angle travel starts from, in degrees. - * @param to The angle travel ends at, in degrees. - * @return -1 if decreasing from leads to the shortest travel distance, 1 if increasing from leads - * to the shortest travel distance. - */ - public static double rotationDirection(double from, double to) { - double increasingDifference = sanitizeDegreesDouble(to - from); - return increasingDifference <= 180.0 ? 1.0 : -1.0; - } - - /** Distance of two points on a circle, represented using degrees. */ - public static double differenceDegrees(double a, double b) { - return 180.0 - Math.abs(Math.abs(a - b) - 180.0); - } - - /** Multiplies a 1x3 row vector with a 3x3 matrix. */ - public static double[] matrixMultiply(double[] row, double[][] matrix) { - double a = row[0] * matrix[0][0] + row[1] * matrix[0][1] + row[2] * matrix[0][2]; - double b = row[0] * matrix[1][0] + row[1] * matrix[1][1] + row[2] * matrix[1][2]; - double c = row[0] * matrix[2][0] + row[1] * matrix[2][1] + row[2] * matrix[2][2]; - return new double[] {a, b, c}; - } -} - diff --git a/libs/material-color-utilities/src/main/java/utils/MathUtils.kt b/libs/material-color-utilities/src/main/java/utils/MathUtils.kt new file mode 100644 index 000000000..c85827840 --- /dev/null +++ b/libs/material-color-utilities/src/main/java/utils/MathUtils.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// This file is automatically generated. Do not modify it. +package utils + +import kotlin.math.abs + +/** Utility methods for mathematical operations. */ +object MathUtils { + /** + * The linear interpolation function. + * + * @return start if amount = 0 and stop if amount = 1 + */ + fun lerp(start: Double, stop: Double, amount: Double): Double { + return (1.0 - amount) * start + amount * stop + } + + /** + * Sanitizes a degree measure as an integer. + * + * @return a degree measure between 0 (inclusive) and 360 (exclusive). + */ + fun sanitizeDegreesInt(degrees: Int): Int { + var degrees = degrees % 360 + if (degrees < 0) { + degrees += 360 + } + return degrees + } + + /** + * Sanitizes a degree measure as a floating-point number. + * + * @return a degree measure between 0.0 (inclusive) and 360.0 (exclusive). + */ + fun sanitizeDegreesDouble(degrees: Double): Double { + var degrees = degrees % 360.0 + if (degrees < 0) { + degrees += 360.0 + } + return degrees + } + + /** + * Sign of direction change needed to travel from one angle to another. + * + * For angles that are 180 degrees apart from each other, both directions have the same travel + * distance, so either direction is shortest. The value 1.0 is returned in this case. + * + * @param from The angle travel starts from, in degrees. + * @param to The angle travel ends at, in degrees. + * @return -1 if decreasing from leads to the shortest travel distance, 1 if increasing from leads + * to the shortest travel distance. + */ + fun rotationDirection(from: Double, to: Double): Double { + val increasingDifference = sanitizeDegreesDouble(to - from) + return if (increasingDifference <= 180.0) 1.0 else -1.0 + } + + /** Distance of two points on a circle, represented using degrees. */ + fun differenceDegrees(a: Double, b: Double): Double { + return 180.0 - abs(abs(a - b) - 180.0) + } + + /** Multiplies a 1x3 row vector with a 3x3 matrix. */ + fun matrixMultiply(row: DoubleArray, matrix: Array): DoubleArray { + val a = row[0] * matrix[0][0] + row[1] * matrix[0][1] + row[2] * matrix[0][2] + val b = row[0] * matrix[1][0] + row[1] * matrix[1][1] + row[2] * matrix[1][2] + val c = row[0] * matrix[2][0] + row[1] * matrix[2][1] + row[2] * matrix[2][2] + return doubleArrayOf(a, b, c) + } +} diff --git a/libs/material-color-utilities/src/main/java/utils/StringUtils.kt b/libs/material-color-utilities/src/main/java/utils/StringUtils.kt new file mode 100644 index 000000000..7e2156d1f --- /dev/null +++ b/libs/material-color-utilities/src/main/java/utils/StringUtils.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package utils + +/** Utility methods for string representations of colors. */ +internal object StringUtils { + /** + * Hex string representing color, ex. #ff0000 for red. + * + * @param argb ARGB representation of a color. + */ + fun hexFromArgb(argb: Int): String { + val red = ColorUtils.redFromArgb(argb) + val blue = ColorUtils.blueFromArgb(argb) + val green = ColorUtils.greenFromArgb(argb) + return String.format("#%02x%02x%02x", red, green, blue) + } +}