├── .idea ├── .gitignore ├── compiler.xml ├── vcs.xml ├── AndroidProjectSystem.xml ├── migrations.xml ├── misc.xml ├── gradle.xml ├── appInsightsSettings.xml ├── runConfigurations.xml └── inspectionProfiles │ └── Project_Default.xml ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── library ├── src │ ├── commonMain │ │ └── kotlin │ │ │ └── dev │ │ │ └── zwander │ │ │ └── compose │ │ │ ├── libmonet │ │ │ ├── quantize │ │ │ │ ├── Quantizer.kt │ │ │ │ ├── QuantizerResult.kt │ │ │ │ ├── PointProvider.kt │ │ │ │ ├── QuantizerMap.kt │ │ │ │ ├── PointProviderLab.kt │ │ │ │ ├── QuantizerCelebi.kt │ │ │ │ └── QuantizerWsmeans.kt │ │ │ ├── dynamiccolor │ │ │ │ ├── Variant.kt │ │ │ │ ├── TonePolarity.kt │ │ │ │ ├── ToneDeltaPair.kt │ │ │ │ ├── ContrastCurve.kt │ │ │ │ └── DynamicScheme.kt │ │ │ ├── scheme │ │ │ │ ├── SchemeMonochrome.kt │ │ │ │ ├── SchemeNeutral.kt │ │ │ │ ├── SchemeRainbow.kt │ │ │ │ ├── SchemeTonalSpot.kt │ │ │ │ ├── SchemeFruitSalad.kt │ │ │ │ ├── ColorScheme.kt │ │ │ │ ├── SchemeVibrant.kt │ │ │ │ ├── SchemeFidelity.kt │ │ │ │ ├── SchemeExpressive.kt │ │ │ │ └── SchemeContent.kt │ │ │ ├── dislike │ │ │ │ └── DislikeAnalyzer.kt │ │ │ ├── palettes │ │ │ │ ├── CorePalette.kt │ │ │ │ └── TonalPalette.kt │ │ │ ├── blend │ │ │ │ └── Blend.kt │ │ │ ├── utils │ │ │ │ ├── MathUtils.kt │ │ │ │ ├── SchemeUtils.kt │ │ │ │ └── ColorUtils.kt │ │ │ ├── hct │ │ │ │ ├── Hct.kt │ │ │ │ └── ViewingConditions.kt │ │ │ ├── score │ │ │ │ └── Score.kt │ │ │ ├── contrast │ │ │ │ └── Contrast.kt │ │ │ └── temperature │ │ │ │ └── TemperatureCache.kt │ │ │ ├── monet │ │ │ ├── MathUtils.kt │ │ │ ├── Shades.kt │ │ │ ├── ColorUtils.kt │ │ │ ├── Frame.kt │ │ │ ├── CamUtils.kt │ │ │ └── WallpaperColors.kt │ │ │ ├── util │ │ │ ├── BridgeUtils.kt │ │ │ └── UserDefaults.kt │ │ │ └── ThemeInfo.kt │ ├── jsAndWasmMain │ │ └── kotlin │ │ │ └── dev │ │ │ └── zwander │ │ │ └── compose │ │ │ └── ThemeInfo.jsAndWasm.kt │ ├── iosMain │ │ └── kotlin │ │ │ └── dev │ │ │ └── zwander │ │ │ └── compose │ │ │ ├── util │ │ │ └── TraitEffect.kt │ │ │ └── ThemeInfo.ios.kt │ ├── androidMain │ │ └── kotlin │ │ │ └── dev │ │ │ └── zwander │ │ │ └── compose │ │ │ └── ThemeInfo.android.kt │ ├── macosMain │ │ └── kotlin │ │ │ └── dev │ │ │ └── zwander │ │ │ └── compose │ │ │ └── ThemeInfo.macos.kt │ └── jvmMain │ │ └── kotlin │ │ └── dev │ │ └── zwander │ │ └── compose │ │ ├── util │ │ └── LinuxAccentColorGetter.kt │ │ └── ThemeInfo.jvm.kt └── build.gradle.kts ├── CHANGELOG.md ├── .gitignore ├── settings.gradle.kts ├── .github └── workflows │ └── publish.yaml ├── LICENSE ├── gradle.properties ├── README.md ├── gradlew.bat └── gradlew /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | /caches/ 5 | /artifacts/ 6 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zacharee/MultiplatformMaterialYou/main/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/quantize/Quantizer.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.quantize 2 | 3 | internal interface Quantizer { 4 | fun quantize(pixels: IntArray?, maxColors: Int): QuantizerResult 5 | } 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.idea/AndroidProjectSystem.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/quantize/QuantizerResult.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.quantize 2 | 3 | /** Represents result of a quantizer run */ 4 | class QuantizerResult internal constructor(val colorToCount: Map) 5 | -------------------------------------------------------------------------------- /.idea/migrations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sun Mar 16 17:49:56 EDT 2025 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip 5 | networkTimeout=10000 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/monet/MathUtils.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.monet 2 | 3 | import kotlin.math.pow 4 | 5 | fun pow(b: Double, p: Double): Double { 6 | return b.pow(p) 7 | } 8 | 9 | fun lerp(start: Double, stop: Double, amount: Double): Double { 10 | return start + (stop - start) * amount 11 | } 12 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/dynamiccolor/Variant.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.dynamiccolor 2 | 3 | /** Themes for Dynamic Color. */ 4 | enum class Variant { 5 | MONOCHROME, 6 | NEUTRAL, 7 | TONAL_SPOT, 8 | VIBRANT, 9 | EXPRESSIVE, 10 | FIDELITY, 11 | CONTENT, 12 | RAINBOW, 13 | FRUIT_SALAD 14 | } 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.2.9 2 | - Allow overriding dark mode detection. 3 | 4 | # 0.2.8 5 | - Fix JFA. 6 | 7 | # 0.2.7 8 | - Move away from using korlibs. 9 | 10 | # 0.2.6 11 | - Implement accent color fallbacks on Windows. 12 | 13 | # 0.2.5 14 | - Update palette generation to use AOSP's new libmonet module. 15 | - The old monet module is still available, but marked as deprecated. 16 | - `DynamicMaterialTheme` uses libmonet by default now. 17 | 18 | # 0.1.0 19 | - Initial release. 20 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project exclude paths 2 | /.gradle/ 3 | /build/ 4 | /local.properties 5 | /.kotlin/ 6 | 7 | # Project exclude paths 8 | /library/build/ 9 | /library/build/classes/kotlin/iosArm64/main/klib/ 10 | /library/build/classes/kotlin/iosSimulatorArm64/main/klib/ 11 | /library/build/classes/kotlin/iosX64/main/klib/ 12 | /library/build/classes/kotlin/js/main/ 13 | /library/build/classes/kotlin/jvm/main/ 14 | /library/build/classes/kotlin/macosArm64/main/klib/ 15 | /library/build/classes/kotlin/macosX64/main/klib/ 16 | /.idea/codeStyles/ 17 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/util/BridgeUtils.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.util 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | fun Color.Companion.alpha(color: Int): Int { 6 | return (Color(color).alpha * 255).toInt() 7 | } 8 | 9 | fun Color.Companion.red(color: Int): Int { 10 | return (Color(color).red * 255).toInt() 11 | } 12 | 13 | fun Color.Companion.green(color: Int): Int { 14 | return (Color(color).green * 255).toInt() 15 | } 16 | 17 | fun Color.Companion.blue(color: Int): Int { 18 | return (Color(color).blue * 255).toInt() 19 | } 20 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/dynamiccolor/TonePolarity.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.dynamiccolor 2 | 3 | /** 4 | * Describes the relationship in lightness between two colors. 5 | * 6 | * 7 | * 'nearer' and 'farther' describes closeness to the surface roles. For instance, 8 | * ToneDeltaPair(A, B, 10, 'nearer', stayTogether) states that A should be 10 lighter than B in 9 | * light mode, and 10 darker than B in dark mode. 10 | * 11 | * 12 | * See `ToneDeltaPair` for details. 13 | */ 14 | enum class TonePolarity { 15 | DARKER, 16 | LIGHTER, 17 | NEARER, 18 | FARTHER 19 | } 20 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/quantize/PointProvider.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.quantize 2 | 3 | /** An interface to allow use of different color spaces by quantizers. */ 4 | interface PointProvider { 5 | /** The four components in the color space of an sRGB color. */ 6 | fun fromInt(argb: Int): DoubleArray 7 | 8 | /** The ARGB (i.e. hex code) representation of this color. */ 9 | fun toInt(point: DoubleArray): Int 10 | 11 | /** 12 | * Squared distance between two colors. Distance is defined by scientific color spaces and 13 | * referred to as delta E. 14 | */ 15 | fun distance(a: DoubleArray, b: DoubleArray): Double 16 | } 17 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/quantize/QuantizerMap.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.quantize 2 | 3 | 4 | /** Creates a dictionary with keys of colors, and values of count of the color */ 5 | class QuantizerMap : Quantizer { 6 | var colorToCount: Map? = null 7 | 8 | override fun quantize(pixels: IntArray?, colorCount: Int): QuantizerResult { 9 | val pixelByCount: MutableMap = LinkedHashMap() 10 | for (pixel in pixels!!) { 11 | val currentPixelCount = pixelByCount[pixel] 12 | val newPixelCount = if (currentPixelCount == null) 1 else currentPixelCount + 1 13 | pixelByCount[pixel] = newPixelCount 14 | } 15 | colorToCount = pixelByCount 16 | return QuantizerResult(pixelByCount) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 19 | 20 | -------------------------------------------------------------------------------- /.idea/appInsightsSettings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 23 | -------------------------------------------------------------------------------- /library/src/jsAndWasmMain/kotlin/dev/zwander/compose/ThemeInfo.jsAndWasm.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.compositionLocalOf 5 | import androidx.compose.ui.graphics.Color 6 | import androidx.compose.ui.graphics.toArgb 7 | import dev.zwander.compose.libmonet.scheme.ColorScheme 8 | 9 | val LocalAccentColor = compositionLocalOf { Color(red = 208, green = 188, blue = 255) } 10 | 11 | @Composable 12 | actual fun isSystemInDarkTheme(): Boolean { 13 | return androidx.compose.foundation.isSystemInDarkTheme() 14 | } 15 | 16 | @Composable 17 | actual fun rememberThemeInfo(isDarkMode: Boolean): ThemeInfo { 18 | return ThemeInfo( 19 | isDarkMode = isDarkMode, 20 | colors = ColorScheme(LocalAccentColor.current.toArgb(), isDarkMode).toComposeColorScheme(), 21 | seedColor = LocalAccentColor.current, 22 | ) 23 | } -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/ThemeInfo.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose 2 | 3 | import androidx.compose.material3.ColorScheme 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.graphics.Color 7 | 8 | data class ThemeInfo( 9 | val isDarkMode: Boolean, 10 | val colors: ColorScheme, 11 | val seedColor: Color, 12 | ) 13 | 14 | @Composable 15 | expect fun rememberThemeInfo(isDarkMode: Boolean = isSystemInDarkTheme()): ThemeInfo 16 | 17 | @Composable 18 | expect fun isSystemInDarkTheme(): Boolean 19 | 20 | @Suppress("unused") 21 | @Composable 22 | fun DynamicMaterialTheme( 23 | isDarkMode: Boolean = isSystemInDarkTheme(), 24 | content: @Composable () -> Unit, 25 | ) { 26 | val themeInfo = rememberThemeInfo(isDarkMode = isDarkMode) 27 | 28 | MaterialTheme( 29 | content = content, 30 | colorScheme = themeInfo.colors, 31 | ) 32 | } -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | pluginManagement { 4 | repositories { 5 | gradlePluginPortal() 6 | mavenCentral() 7 | google() 8 | 9 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 10 | maven("https://maven.pkg.jetbrains.space/kotlin/p/kotlin/dev/") 11 | } 12 | } 13 | 14 | dependencyResolutionManagement { 15 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 16 | repositories { 17 | mavenCentral() 18 | google() 19 | 20 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 21 | maven("https://maven.pkg.jetbrains.space/kotlin/p/kotlin/dev/") 22 | maven("https://maven.pkg.jetbrains.space/public/p/ktor/eap/") 23 | maven("https://jitpack.io") 24 | maven("https://s01.oss.sonatype.org/content/repositories/snapshots/") 25 | } 26 | } 27 | 28 | rootProject.name = "MultiplatformMaterialYou" 29 | include(":library") 30 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 17 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/scheme/SchemeMonochrome.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.scheme 2 | 3 | import dev.zwander.compose.libmonet.dynamiccolor.DynamicScheme 4 | import dev.zwander.compose.libmonet.dynamiccolor.Variant 5 | import dev.zwander.compose.libmonet.hct.Hct 6 | import dev.zwander.compose.libmonet.palettes.TonalPalette 7 | 8 | 9 | /** A monochrome theme, colors are purely black / white / gray. */ 10 | class SchemeMonochrome(sourceColorHct: Hct, isDark: Boolean, contrastLevel: Double) : 11 | DynamicScheme( 12 | sourceColorHct, 13 | Variant.MONOCHROME, 14 | isDark, 15 | contrastLevel, 16 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), 0.0), 17 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), 0.0), 18 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), 0.0), 19 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), 0.0), 20 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), 0.0) 21 | ) 22 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/scheme/SchemeNeutral.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.scheme 2 | 3 | import dev.zwander.compose.libmonet.dynamiccolor.DynamicScheme 4 | import dev.zwander.compose.libmonet.dynamiccolor.Variant 5 | import dev.zwander.compose.libmonet.hct.Hct 6 | import dev.zwander.compose.libmonet.palettes.TonalPalette 7 | 8 | 9 | /** A theme that's slightly more chromatic than monochrome, which is purely black / white / gray. */ 10 | class SchemeNeutral(sourceColorHct: Hct, isDark: Boolean, contrastLevel: Double) : 11 | DynamicScheme( 12 | sourceColorHct, 13 | Variant.NEUTRAL, 14 | isDark, 15 | contrastLevel, 16 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), 12.0), 17 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), 8.0), 18 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), 16.0), 19 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), 2.0), 20 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), 2.0) 21 | ) 22 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | publish: 8 | name: Publish to Sonatype 9 | runs-on: macos-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | 14 | - name: Configure JDK 15 | uses: actions/setup-java@v4 16 | with: 17 | distribution: 'corretto' 18 | java-version: '21' 19 | 20 | - name: Setup Gradle 21 | uses: gradle/actions/setup-gradle@v3 22 | 23 | - name: Upload Artifacts 24 | run: ./gradlew publishAndReleaseToMavenCentral --no-configuration-cache 25 | env: 26 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} 27 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} 28 | ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_IN_MEMORY_KEY }} 29 | ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNING_IN_MEMORY_KEY_ID }} 30 | ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_IN_MEMORY_KEY_PASSWORD }} -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Zachary Wander 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/scheme/SchemeRainbow.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.scheme 2 | 3 | import dev.zwander.compose.libmonet.dynamiccolor.DynamicScheme 4 | import dev.zwander.compose.libmonet.dynamiccolor.Variant 5 | import dev.zwander.compose.libmonet.hct.Hct 6 | import dev.zwander.compose.libmonet.palettes.TonalPalette 7 | import dev.zwander.compose.libmonet.utils.MathUtils.sanitizeDegreesDouble 8 | 9 | 10 | /** A playful theme - the source color's hue does not appear in the theme. */ 11 | class SchemeRainbow(sourceColorHct: Hct, isDark: Boolean, contrastLevel: Double) : 12 | DynamicScheme( 13 | sourceColorHct, 14 | Variant.RAINBOW, 15 | isDark, 16 | contrastLevel, 17 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), 48.0), 18 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), 16.0), 19 | TonalPalette.fromHueAndChroma( 20 | sanitizeDegreesDouble(sourceColorHct.getHue() + 60.0), 24.0 21 | ), 22 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), 0.0), 23 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), 0.0) 24 | ) -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/scheme/SchemeTonalSpot.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.scheme 2 | 3 | import dev.zwander.compose.libmonet.dynamiccolor.DynamicScheme 4 | import dev.zwander.compose.libmonet.dynamiccolor.Variant 5 | import dev.zwander.compose.libmonet.hct.Hct 6 | import dev.zwander.compose.libmonet.palettes.TonalPalette 7 | import dev.zwander.compose.libmonet.utils.MathUtils.sanitizeDegreesDouble 8 | 9 | 10 | /** A calm theme, sedated colors that aren't particularly chromatic. */ 11 | class SchemeTonalSpot(sourceColorHct: Hct, isDark: Boolean, contrastLevel: Double) : 12 | DynamicScheme( 13 | sourceColorHct, 14 | Variant.TONAL_SPOT, 15 | isDark, 16 | contrastLevel, 17 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), 36.0), 18 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), 16.0), 19 | TonalPalette.fromHueAndChroma( 20 | sanitizeDegreesDouble(sourceColorHct.getHue() + 60.0), 24.0 21 | ), 22 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), 6.0), 23 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), 8.0) 24 | ) 25 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | android-gradle = "8.13.2" 3 | compose = "1.9.3" 4 | jfa = "715dbc9dc5" 5 | jna = "5.18.1" 6 | jsystemthemedetector = "7dde337429" 7 | kotlin = "2.2.21" 8 | mavenPublish = "0.35.0" 9 | 10 | [libraries] 11 | jfa = { module = "com.github.zacharee:jfa", version.ref = "jfa" } 12 | jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } 13 | jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" } 14 | jsystemthemedetector = { module = "com.github.zacharee:jSystemThemeDetector", version.ref = "jsystemthemedetector" } 15 | kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } 16 | kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } 17 | 18 | [plugins] 19 | android-library = { id = "com.android.library", version.ref = "android-gradle" } 20 | compose = { id = "org.jetbrains.compose", version.ref = "compose" } 21 | kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 22 | kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } 23 | maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } 24 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/scheme/SchemeFruitSalad.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.scheme 2 | 3 | import dev.zwander.compose.libmonet.dynamiccolor.DynamicScheme 4 | import dev.zwander.compose.libmonet.dynamiccolor.Variant 5 | import dev.zwander.compose.libmonet.hct.Hct 6 | import dev.zwander.compose.libmonet.palettes.TonalPalette 7 | import dev.zwander.compose.libmonet.utils.MathUtils.sanitizeDegreesDouble 8 | 9 | 10 | /** A playful theme - the source color's hue does not appear in the theme. */ 11 | class SchemeFruitSalad(sourceColorHct: Hct, isDark: Boolean, contrastLevel: Double) : 12 | DynamicScheme( 13 | sourceColorHct, 14 | Variant.FRUIT_SALAD, 15 | isDark, 16 | contrastLevel, 17 | TonalPalette.fromHueAndChroma( 18 | sanitizeDegreesDouble(sourceColorHct.getHue() - 50.0), 48.0 19 | ), 20 | TonalPalette.fromHueAndChroma( 21 | sanitizeDegreesDouble(sourceColorHct.getHue() - 50.0), 36.0 22 | ), 23 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), 36.0), 24 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), 10.0), 25 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), 16.0) 26 | ) -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/dynamiccolor/ToneDeltaPair.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.dynamiccolor 2 | 3 | 4 | /** 5 | * Documents a constraint between two DynamicColors, in which their tones must have a certain 6 | * distance from each other. 7 | * 8 | * 9 | * Prefer a DynamicColor with a background, this is for special cases when designers want tonal 10 | * distance, literally contrast, between two colors that don't have a background / foreground 11 | * relationship or a contrast guarantee. 12 | */ 13 | class ToneDeltaPair( 14 | /** The first role in a pair. */ 15 | val roleA: DynamicColor, 16 | /** The second role in a pair. */ 17 | val roleB: DynamicColor, 18 | /** Required difference between tones. Absolute value, negative values have undefined behavior. */ 19 | val delta: Double, 20 | /** The relative relation between tones of roleA and roleB, as described above. */ 21 | private val polarity: TonePolarity, 22 | /** 23 | * Whether these two roles should stay on the same side of the "awkward zone" (T50-59). This is 24 | * necessary for certain cases where one role has two backgrounds. 25 | */ 26 | val stayTogether: Boolean 27 | ) { 28 | 29 | fun getPolarity(): TonePolarity { 30 | return polarity 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/dislike/DislikeAnalyzer.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.dislike 2 | 3 | import dev.zwander.compose.libmonet.hct.Hct 4 | import kotlin.math.round 5 | 6 | /** 7 | * Check and/or fix universally disliked colors. 8 | * 9 | * 10 | * Color science studies of color preference indicate universal distaste for dark yellow-greens, 11 | * and also show this is correlated to distate for biological waste and rotting food. 12 | * 13 | * 14 | * See Palmer and Schloss, 2010 or Schloss and Palmer's Chapter 21 in Handbook of Color 15 | * Psychology (2015). 16 | */ 17 | object DislikeAnalyzer { 18 | /** 19 | * Returns true if color is disliked. 20 | * 21 | * 22 | * Disliked is defined as a dark yellow-green that is not neutral. 23 | */ 24 | fun isDisliked(hct: Hct): Boolean { 25 | val huePasses = round(hct.getHue()) in 90.0..111.0 26 | val chromaPasses: Boolean = round(hct.getChroma()) > 16.0 27 | val tonePasses: Boolean = round(hct.getTone()) < 65.0 28 | 29 | return huePasses && chromaPasses && tonePasses 30 | } 31 | 32 | /** If color is disliked, lighten it to make it likable. */ 33 | fun fixIfDisliked(hct: Hct): Hct { 34 | if (isDisliked(hct)) { 35 | return Hct.from(hct.getHue(), hct.getChroma(), 70.0) 36 | } 37 | 38 | return hct 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/scheme/ColorScheme.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.scheme 2 | 3 | import dev.zwander.compose.libmonet.hct.Hct 4 | import dev.zwander.compose.libmonet.utils.toComposeColorScheme 5 | 6 | enum class Style { 7 | SPRITZ, 8 | TONAL_SPOT, 9 | VIBRANT, 10 | EXPRESSIVE, 11 | RAINBOW, 12 | FRUIT_SALAD, 13 | CONTENT, 14 | MONOCHROMATIC, 15 | FIDELITY, 16 | } 17 | 18 | class ColorScheme( 19 | val seedColor: Int, 20 | val isDark: Boolean, 21 | val style: Style = Style.TONAL_SPOT, 22 | // -1 to 1 23 | val contrast: Double = 0.0, 24 | ) { 25 | val sourceColorHct = Hct.fromInt(seedColor) 26 | val scheme = when (style) { 27 | Style.SPRITZ -> SchemeNeutral(sourceColorHct, isDark, contrast) 28 | Style.TONAL_SPOT -> SchemeTonalSpot(sourceColorHct, isDark, contrast) 29 | Style.VIBRANT -> SchemeVibrant(sourceColorHct, isDark, contrast) 30 | Style.EXPRESSIVE -> SchemeExpressive(sourceColorHct, isDark, contrast) 31 | Style.RAINBOW -> SchemeRainbow(sourceColorHct, isDark, contrast) 32 | Style.FRUIT_SALAD -> SchemeFruitSalad(sourceColorHct, isDark, contrast) 33 | Style.CONTENT -> SchemeContent(sourceColorHct, isDark, contrast) 34 | Style.MONOCHROMATIC -> SchemeMonochrome(sourceColorHct, isDark, contrast) 35 | Style.FIDELITY -> SchemeFidelity(sourceColorHct, isDark, contrast) 36 | } 37 | 38 | fun toComposeColorScheme() = scheme.toComposeColorScheme() 39 | } 40 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/scheme/SchemeVibrant.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.scheme 2 | 3 | import dev.zwander.compose.libmonet.dynamiccolor.DynamicScheme 4 | import dev.zwander.compose.libmonet.dynamiccolor.Variant 5 | import dev.zwander.compose.libmonet.hct.Hct 6 | import dev.zwander.compose.libmonet.palettes.TonalPalette 7 | 8 | 9 | /** A loud theme, colorfulness is maximum for Primary palette, increased for others. */ 10 | class SchemeVibrant(sourceColorHct: Hct, isDark: Boolean, contrastLevel: Double) : 11 | DynamicScheme( 12 | sourceColorHct, 13 | Variant.VIBRANT, 14 | isDark, 15 | contrastLevel, 16 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), 200.0), 17 | TonalPalette.fromHueAndChroma( 18 | getRotatedHue(sourceColorHct, HUES, SECONDARY_ROTATIONS), 24.0 19 | ), 20 | TonalPalette.fromHueAndChroma( 21 | getRotatedHue(sourceColorHct, HUES, TERTIARY_ROTATIONS), 32.0 22 | ), 23 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), 10.0), 24 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), 12.0) 25 | ) { 26 | companion object { 27 | private val HUES = doubleArrayOf(0.0, 41.0, 61.0, 101.0, 131.0, 181.0, 251.0, 301.0, 360.0) 28 | private val SECONDARY_ROTATIONS = 29 | doubleArrayOf(18.0, 15.0, 10.0, 12.0, 15.0, 18.0, 15.0, 12.0, 12.0) 30 | private val TERTIARY_ROTATIONS = 31 | doubleArrayOf(35.0, 30.0, 20.0, 25.0, 30.0, 35.0, 30.0, 25.0, 25.0) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/quantize/PointProviderLab.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.quantize 2 | 3 | import dev.zwander.compose.libmonet.utils.ColorUtils 4 | 5 | /** 6 | * Provides conversions needed for K-Means quantization. Converting input to points, and converting 7 | * the final state of the K-Means algorithm to colors. 8 | */ 9 | class PointProviderLab : PointProvider { 10 | /** 11 | * Convert a color represented in ARGB to a 3-element array of L*a*b* coordinates of the color. 12 | */ 13 | override fun fromInt(argb: Int): DoubleArray { 14 | val lab: DoubleArray = ColorUtils.labFromArgb(argb) 15 | return doubleArrayOf(lab[0], lab[1], lab[2]) 16 | } 17 | 18 | /** Convert a 3-element array to a color represented in ARGB. */ 19 | override fun toInt(point: DoubleArray): Int { 20 | return ColorUtils.argbFromLab(point[0], point[1], point[2]) 21 | } 22 | 23 | /** 24 | * Standard CIE 1976 delta E formula also takes the square root, unneeded here. This method is 25 | * used by quantization algorithms to compare distance, and the relative ordering is the same, 26 | * with or without a square root. 27 | * 28 | * 29 | * This relatively minor optimization is helpful because this method is called at least once 30 | * for each pixel in an image. 31 | */ 32 | override fun distance(one: DoubleArray, two: DoubleArray): Double { 33 | val dL = (one[0] - two[0]) 34 | val dA = (one[1] - two[1]) 35 | val dB = (one[2] - two[2]) 36 | return (dL * dL + dA * dA + dB * dB) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /library/src/iosMain/kotlin/dev/zwander/compose/util/TraitEffect.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.util 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.DisposableEffect 5 | import androidx.compose.ui.interop.LocalUIViewController 6 | import kotlinx.cinterop.BetaInteropApi 7 | import kotlinx.cinterop.ExperimentalForeignApi 8 | import kotlinx.cinterop.ExportObjCClass 9 | import kotlinx.cinterop.readValue 10 | import platform.CoreGraphics.CGRectZero 11 | import platform.UIKit.UITraitCollection 12 | import platform.UIKit.UIView 13 | 14 | @Composable 15 | fun TraitEffect( 16 | key: Any? = Unit, 17 | onTraitsChanged: () -> Unit, 18 | ) { 19 | val viewController = LocalUIViewController.current 20 | 21 | DisposableEffect(key) { 22 | val view: UIView = viewController.view 23 | val traitView = TraitView(onTraitsChanged) 24 | 25 | traitView.onCreate(view) 26 | 27 | onDispose { 28 | traitView.onDestroy() 29 | } 30 | } 31 | } 32 | 33 | // https://github.com/JetBrains/compose-multiplatform/issues/3213#issuecomment-1572378546 34 | @ExportObjCClass 35 | @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) 36 | private class TraitView( 37 | private val onTraitChanged: () -> Unit, 38 | ) : UIView(frame = CGRectZero.readValue()) { 39 | override fun traitCollectionDidChange(previousTraitCollection: UITraitCollection?) { 40 | super.traitCollectionDidChange(previousTraitCollection) 41 | onTraitChanged() 42 | } 43 | 44 | fun onCreate(parent: UIView) { 45 | parent.addSubview(this) 46 | } 47 | 48 | fun onDestroy() { 49 | removeFromSuperview() 50 | } 51 | } -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/quantize/QuantizerCelebi.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.quantize 2 | 3 | /** 4 | * An image quantizer that improves on the quality of a standard K-Means algorithm by setting the 5 | * K-Means initial state to the output of a Wu quantizer, instead of random centroids. Improves on 6 | * speed by several optimizations, as implemented in Wsmeans, or Weighted Square Means, K-Means with 7 | * those optimizations. 8 | * 9 | * 10 | * This algorithm was designed by M. Emre Celebi, and was found in their 2011 paper, Improving 11 | * the Performance of K-Means for Color Quantization. https://arxiv.org/abs/1101.0395 12 | */ 13 | object QuantizerCelebi { 14 | /** 15 | * Reduce the number of colors needed to represented the input, minimizing the difference between 16 | * the original image and the recolored image. 17 | * 18 | * @param pixels Colors in ARGB format. 19 | * @param maxColors The number of colors to divide the image into. A lower number of colors may be 20 | * returned. 21 | * @return Map with keys of colors in ARGB format, and values of number of pixels in the original 22 | * image that correspond to the color in the quantized image. 23 | */ 24 | fun quantize(pixels: IntArray, maxColors: Int): Map { 25 | val wu: QuantizerWu = QuantizerWu() 26 | val wuResult: QuantizerResult = wu.quantize(pixels, maxColors) 27 | 28 | val wuClustersAsObjects: Set = wuResult.colorToCount.keys 29 | var index = 0 30 | val wuClusters = IntArray(wuClustersAsObjects.size) 31 | for (argb in wuClustersAsObjects) { 32 | wuClusters[index++] = argb 33 | } 34 | 35 | return QuantizerWsmeans.quantize(pixels, wuClusters, maxColors) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/scheme/SchemeFidelity.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.scheme 2 | 3 | import dev.zwander.compose.libmonet.dislike.DislikeAnalyzer.fixIfDisliked 4 | import dev.zwander.compose.libmonet.dynamiccolor.DynamicScheme 5 | import dev.zwander.compose.libmonet.dynamiccolor.Variant 6 | import dev.zwander.compose.libmonet.hct.Hct 7 | import dev.zwander.compose.libmonet.palettes.TonalPalette 8 | import dev.zwander.compose.libmonet.temperature.TemperatureCache 9 | import kotlin.math.max 10 | 11 | 12 | /** 13 | * A scheme that places the source color in Scheme.primaryContainer. 14 | * 15 | * 16 | * Primary Container is the source color, adjusted for color relativity. It maintains constant 17 | * appearance in light mode and dark mode. This adds ~5 tone in light mode, and subtracts ~5 tone in 18 | * dark mode. 19 | * 20 | * 21 | * Tertiary Container is the complement to the source color, using TemperatureCache. It also 22 | * maintains constant appearance. 23 | */ 24 | class SchemeFidelity(sourceColorHct: Hct, isDark: Boolean, contrastLevel: Double) : 25 | DynamicScheme( 26 | sourceColorHct, 27 | Variant.FIDELITY, 28 | isDark, 29 | contrastLevel, 30 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), sourceColorHct.getChroma()), 31 | TonalPalette.fromHueAndChroma( 32 | sourceColorHct.getHue(), 33 | max(sourceColorHct.getChroma() - 32.0, sourceColorHct.getChroma() * 0.5) 34 | ), 35 | TonalPalette.fromHct( 36 | fixIfDisliked(TemperatureCache(sourceColorHct).complement) 37 | ), 38 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), sourceColorHct.getChroma() / 8.0), 39 | TonalPalette.fromHueAndChroma( 40 | sourceColorHct.getHue(), (sourceColorHct.getChroma() / 8.0) + 4.0 41 | ) 42 | ) 43 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/scheme/SchemeExpressive.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.scheme 2 | 3 | import dev.zwander.compose.libmonet.dynamiccolor.DynamicScheme 4 | import dev.zwander.compose.libmonet.dynamiccolor.Variant 5 | import dev.zwander.compose.libmonet.hct.Hct 6 | import dev.zwander.compose.libmonet.palettes.TonalPalette 7 | import dev.zwander.compose.libmonet.utils.MathUtils.sanitizeDegreesDouble 8 | 9 | 10 | /** A playful theme - the source color's hue does not appear in the theme. */ 11 | class SchemeExpressive(sourceColorHct: Hct, isDark: Boolean, contrastLevel: Double) : 12 | DynamicScheme( 13 | sourceColorHct, 14 | Variant.EXPRESSIVE, 15 | isDark, 16 | contrastLevel, 17 | TonalPalette.fromHueAndChroma( 18 | sanitizeDegreesDouble(sourceColorHct.getHue() + 240.0), 40.0 19 | ), 20 | TonalPalette.fromHueAndChroma( 21 | getRotatedHue(sourceColorHct, HUES, SECONDARY_ROTATIONS), 24.0 22 | ), 23 | TonalPalette.fromHueAndChroma( 24 | getRotatedHue(sourceColorHct, HUES, TERTIARY_ROTATIONS), 32.0 25 | ), 26 | TonalPalette.fromHueAndChroma( 27 | sanitizeDegreesDouble(sourceColorHct.getHue() + 15.0), 8.0 28 | ), 29 | TonalPalette.fromHueAndChroma( 30 | sanitizeDegreesDouble(sourceColorHct.getHue() + 15.0), 12.0 31 | ) 32 | ) { 33 | companion object { 34 | // NOMUTANTS--arbitrary increments/decrements, correctly, still passes tests. 35 | private val HUES = doubleArrayOf(0.0, 21.0, 51.0, 121.0, 151.0, 191.0, 271.0, 321.0, 360.0) 36 | private val SECONDARY_ROTATIONS = 37 | doubleArrayOf(45.0, 95.0, 45.0, 20.0, 45.0, 90.0, 45.0, 45.0, 45.0) 38 | private val TERTIARY_ROTATIONS = 39 | doubleArrayOf(120.0, 120.0, 20.0, 45.0, 20.0, 15.0, 20.0, 120.0, 120.0) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/dynamiccolor/ContrastCurve.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.dynamiccolor 2 | 3 | import dev.zwander.compose.libmonet.utils.MathUtils.lerp 4 | 5 | 6 | /** 7 | * A class containing a value that changes with the contrast level. 8 | * 9 | * 10 | * Usually represents the contrast requirements for a dynamic color on its background. The four 11 | * values correspond to values for contrast levels -1.0, 0.0, 0.5, and 1.0, respectively. 12 | */ 13 | class ContrastCurve 14 | /** 15 | * Creates a `ContrastCurve` object. 16 | * 17 | * @param low Value for contrast level -1.0 18 | * @param normal Value for contrast level 0.0 19 | * @param medium Value for contrast level 0.5 20 | * @param high Value for contrast level 1.0 21 | */( 22 | /** Value for contrast level -1.0 */ 23 | private val low: Double, 24 | /** Value for contrast level 0.0 */ 25 | private val normal: Double, 26 | /** Value for contrast level 0.5 */ 27 | private val medium: Double, 28 | /** Value for contrast level 1.0 */ 29 | private val high: Double 30 | ) { 31 | /** 32 | * Returns the value at a given contrast level. 33 | * 34 | * @param contrastLevel The contrast level. 0.0 is the default (normal); -1.0 is the lowest; 1.0 35 | * is the highest. 36 | * @return The value. For contrast ratios, a number between 1.0 and 21.0. 37 | */ 38 | fun get(contrastLevel: Double): Double { 39 | return if (contrastLevel <= -1.0) { 40 | low 41 | } else if (contrastLevel < 0.0) { 42 | lerp(this.low, this.normal, (contrastLevel - -1) / 1) 43 | } else if (contrastLevel < 0.5) { 44 | lerp(this.normal, this.medium, (contrastLevel - 0) / 0.5) 45 | } else if (contrastLevel < 1.0) { 46 | lerp(this.medium, this.high, (contrastLevel - 0.5) / 0.5) 47 | } else { 48 | high 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | ## For more details on how to configure your build environment visit 2 | # http://www.gradle.org/docs/current/userguide/build_environment.html 3 | # 4 | # Specifies the JVM arguments used for the daemon process. 5 | # The setting is particularly useful for tweaking memory settings. 6 | # Default value: -Xmx1024m -XX:MaxPermSize=256m 7 | # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 8 | # 9 | # When configured, Gradle will run in incubating parallel mode. 10 | # This option should only be used with decoupled projects. More details, visit 11 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 12 | # org.gradle.parallel=true 13 | 14 | org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M" 15 | org.gradle.parallel=true 16 | org.gradle.caching=true 17 | 18 | android.useAndroidX=true 19 | 20 | kotlin.code.style=official 21 | kotlin.mpp.applyDefaultHierarchyTemplate=false 22 | kotlin.mpp.androidGradlePluginCompatibility.nowarn=true 23 | kotlin.mpp.enableResourcesPublication=false 24 | 25 | org.jetbrains.compose.experimental.jscanvas.enabled=true 26 | org.jetbrains.compose.experimental.macos.enabled=true 27 | 28 | SONATYPE_HOST=CENTRAL_PORTAL 29 | RELEASE_SIGNING_ENABLED=true 30 | 31 | GROUP=dev.zwander 32 | POM_ARTIFACT_ID=materialyou 33 | VERSION_NAME=0.2.10 34 | 35 | POM_NAME=ComposeDialog 36 | POM_DESCRIPTION=Compose Multiplatform theme wrapper that supports dynamic color palettes on multiple platforms. 37 | POM_INCEPTION_YEAR=2024 38 | POM_URL=https://github.com/zacharee/MultiplatformMaterialYou 39 | 40 | POM_LICENSE_NAME=MIT License 41 | POM_LICENSE_URL=https://github.com/zacharee/MultiplatformMaterialYou/blob/main/LICENSE 42 | POM_LICENSE_DIST=repo 43 | 44 | POM_SCM_URL=https://github.com/zacharee/MultiplatformMaterialYou 45 | POM_SCM_CONNECTION=scm:git:git://github.com/zacharee/MultiplatformMaterialYou.git 46 | POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com:zacharee/MultiplatformMaterialYou.git 47 | 48 | POM_DEVELOPER_ID=zacharee 49 | POM_DEVELOPER_NAME=Zachary Wander 50 | POM_DEVELOPER_EMAIL=zachary@zwander.dev 51 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/util/UserDefaults.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.util 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | fun macOsColorKeyToColor(key: Int?): Color { 6 | return when (key) { 7 | -2 -> MacOSColors.ACCENT_BLUE 8 | -1 -> MacOSColors.ACCENT_GRAPHITE 9 | 0 -> MacOSColors.ACCENT_RED 10 | 1 -> MacOSColors.ACCENT_ORANGE 11 | 2 -> MacOSColors.ACCENT_YELLOW 12 | 3 -> MacOSColors.ACCENT_GREEN 13 | 4 -> MacOSColors.ACCENT_LILAC 14 | 5 -> MacOSColors.ACCENT_ROSE 15 | else -> MacOSColors.ACCENT_BLUE 16 | } 17 | } 18 | 19 | /** 20 | * https://github.com/weisJ/darklaf/blob/master/macos/src/main/java/com/github/weisj/darklaf/platform/macos/theme/MacOSColors.java#L28 21 | */ 22 | object MacOSColors { 23 | // 0.000000 0.478431 1.000000 24 | val ACCENT_BLUE = color(0.000000f, 0.478431f, 1.000000f) 25 | 26 | // 0.584314 0.239216 0.588235 27 | val ACCENT_LILAC = color(0.584314f, 0.239216f, 0.588235f) 28 | 29 | // 0.968627 0.309804 0.619608 30 | val ACCENT_ROSE = color(0.968627f, 0.309804f, 0.619608f) 31 | 32 | // 0.878431 0.219608 0.243137 33 | val ACCENT_RED = color(0.878431f, 0.219608f, 0.243137f) 34 | 35 | // 0.968627 0.509804 0.105882 36 | val ACCENT_ORANGE = color(0.968627f, 0.509804f, 0.105882f) 37 | 38 | // 0.988235 0.721569 0.152941 39 | val ACCENT_YELLOW = color(0.988235f, 0.721569f, 0.152941f) 40 | 41 | // 0.384314 0.729412 0.274510 42 | val ACCENT_GREEN = color(0.384314f, 0.729412f, 0.274510f) 43 | 44 | // 0.596078 0.596078 0.596078 45 | val ACCENT_GRAPHITE = color(0.596078f, 0.596078f, 0.596078f) 46 | 47 | private fun color(r: Float, g: Float, b: Float): Color { 48 | /* 49 | * For consistency with the native code we mirror the implementation of the float to int conversion 50 | * of the Color class. 51 | */ 52 | return Color( 53 | red = (r * 255 + 0.5).toInt(), 54 | green = (g * 255 + 0.5).toInt(), 55 | blue = (b * 255 + 0.5).toInt(), 56 | alpha = 255 57 | ) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/scheme/SchemeContent.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.scheme 2 | 3 | import dev.zwander.compose.libmonet.dislike.DislikeAnalyzer.fixIfDisliked 4 | import dev.zwander.compose.libmonet.dynamiccolor.DynamicScheme 5 | import dev.zwander.compose.libmonet.dynamiccolor.Variant 6 | import dev.zwander.compose.libmonet.hct.Hct 7 | import dev.zwander.compose.libmonet.palettes.TonalPalette 8 | import dev.zwander.compose.libmonet.temperature.TemperatureCache 9 | import kotlin.math.max 10 | 11 | 12 | /** 13 | * A scheme that places the source color in Scheme.primaryContainer. 14 | * 15 | * 16 | * Primary Container is the source color, adjusted for color relativity. It maintains constant 17 | * appearance in light mode and dark mode. This adds ~5 tone in light mode, and subtracts ~5 tone in 18 | * dark mode. 19 | * 20 | * 21 | * Tertiary Container is an analogous color, specifically, the analog of a color wheel divided 22 | * into 6, and the precise analog is the one found by increasing hue. This is a scientifically 23 | * grounded equivalent to rotating hue clockwise by 60 degrees. It also maintains constant 24 | * appearance. 25 | */ 26 | class SchemeContent(sourceColorHct: Hct, isDark: Boolean, contrastLevel: Double) : 27 | DynamicScheme( 28 | sourceColorHct, 29 | Variant.CONTENT, 30 | isDark, 31 | contrastLevel, 32 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), sourceColorHct.getChroma()), 33 | TonalPalette.fromHueAndChroma( 34 | sourceColorHct.getHue(), 35 | max(sourceColorHct.getChroma() - 32.0, sourceColorHct.getChroma() * 0.5) 36 | ), 37 | TonalPalette.fromHct( 38 | fixIfDisliked( 39 | TemperatureCache(sourceColorHct) 40 | .getAnalogousColors( /* count= */3, /* divisions= */6)[2] 41 | ) 42 | ), 43 | TonalPalette.fromHueAndChroma(sourceColorHct.getHue(), sourceColorHct.getChroma() / 8.0), 44 | TonalPalette.fromHueAndChroma( 45 | sourceColorHct.getHue(), (sourceColorHct.getChroma() / 8.0) + 4.0 46 | ) 47 | ) 48 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/monet/Shades.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.monet 2 | 3 | import kotlin.math.min 4 | 5 | 6 | /** 7 | * Generate sets of colors that are shades of the same color 8 | */ 9 | object Shades { 10 | /** 11 | * Combining the ability to convert between relative luminance and perceptual luminance with 12 | * contrast leads to a design system that can be based on a linear value to determine contrast, 13 | * rather than a ratio. 14 | * 15 | * This codebase implements a design system that has that property, and as a result, we can 16 | * guarantee that any shades 5 steps from each other have a contrast ratio of at least 4.5. 17 | * 4.5 is the requirement for smaller text contrast in WCAG 2.1 and earlier. 18 | * 19 | * However, lstar 50 does _not_ have a contrast ratio >= 4.5 with lstar 100. 20 | * lstar 49.6 is the smallest lstar that will lead to a contrast ratio >= 4.5 with lstar 100, 21 | * and it also contrasts >= 4.5 with lstar 100. 22 | */ 23 | private const val MIDDLE_LSTAR = 49.6 24 | 25 | /** 26 | * Generate shades of a color. Ordered in lightness _descending_. 27 | * 28 | * 29 | * The first shade will be at 95% lightness, the next at 90, 80, etc. through 0. 30 | * 31 | * @param hue hue in CAM16 color space 32 | * @param chroma chroma in CAM16 color space 33 | * @return shades of a color, as argb integers. Ordered by lightness descending. 34 | */ 35 | fun of(hue: Double, chroma: Double): IntArray { 36 | val shades = IntArray(12) 37 | // At tone 90 and above, blue and yellow hues can reach a much higher chroma. 38 | // To preserve a consistent appearance across all hues, use a maximum chroma of 40. 39 | shades[0] = ColorUtils.CAMToColor(hue, min(40.0, chroma), 99.0) 40 | shades[1] = ColorUtils.CAMToColor(hue, min(40.0, chroma), 95.0) 41 | for (i in 2..11) { 42 | val lStar = if (i == 6) MIDDLE_LSTAR else (100 - 10 * (i - 1)).toDouble() 43 | shades[i] = ColorUtils.CAMToColor(hue, chroma, lStar) 44 | } 45 | return shades 46 | } 47 | } -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/palettes/CorePalette.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.palettes 2 | 3 | import dev.zwander.compose.libmonet.hct.Hct 4 | import kotlin.math.max 5 | import kotlin.math.min 6 | 7 | 8 | /** 9 | * An intermediate concept between the key color for a UI theme, and a full color scheme. 5 sets of 10 | * tones are generated, all except one use the same hue as the key color, and all vary in chroma. 11 | */ 12 | class CorePalette private constructor(argb: Int, isContent: Boolean) { 13 | val a1: TonalPalette 14 | val a2: TonalPalette 15 | val a3: TonalPalette 16 | val n1: TonalPalette 17 | val n2: TonalPalette 18 | val error: TonalPalette 19 | 20 | init { 21 | val hct = Hct.fromInt(argb) 22 | val hue = hct.getHue() 23 | val chroma = hct.getChroma() 24 | if (isContent) { 25 | this.a1 = TonalPalette.fromHueAndChroma(hue, chroma) 26 | this.a2 = TonalPalette.fromHueAndChroma(hue, chroma / 3.0) 27 | this.a3 = TonalPalette.fromHueAndChroma(hue + 60.0, chroma / 2.0) 28 | this.n1 = TonalPalette.fromHueAndChroma(hue, min(chroma / 12.0, 4.0)) 29 | this.n2 = TonalPalette.fromHueAndChroma(hue, min(chroma / 6.0, 8.0)) 30 | } else { 31 | this.a1 = TonalPalette.fromHueAndChroma(hue, max(48.0, chroma)) 32 | this.a2 = TonalPalette.fromHueAndChroma(hue, 16.0) 33 | this.a3 = TonalPalette.fromHueAndChroma(hue + 60.0, 24.0) 34 | this.n1 = TonalPalette.fromHueAndChroma(hue, 4.0) 35 | this.n2 = TonalPalette.fromHueAndChroma(hue, 8.0) 36 | } 37 | this.error = TonalPalette.fromHueAndChroma(25.0, 84.0) 38 | } 39 | 40 | companion object { 41 | /** 42 | * Create key tones from a color. 43 | * 44 | * @param argb ARGB representation of a color 45 | */ 46 | fun of(argb: Int): CorePalette { 47 | return CorePalette(argb, false) 48 | } 49 | 50 | /** 51 | * Create content key tones from a color. 52 | * 53 | * @param argb ARGB representation of a color 54 | */ 55 | fun contentOf(argb: Int): CorePalette { 56 | return CorePalette(argb, true) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /library/src/androidMain/kotlin/dev/zwander/compose/ThemeInfo.android.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE", "EXPOSED_PARAMETER_TYPE") 2 | @file:JvmName("ThemeInfoAndroid") 3 | 4 | package dev.zwander.compose 5 | 6 | import android.os.Build 7 | import androidx.compose.material3.darkColorScheme 8 | import androidx.compose.material3.dynamicDarkColorScheme 9 | import androidx.compose.material3.dynamicLightColorScheme 10 | import androidx.compose.material3.dynamicTonalPalette 11 | import androidx.compose.material3.dynamicDarkColorScheme31 12 | import androidx.compose.material3.dynamicLightColorScheme31 13 | import androidx.compose.material3.lightColorScheme 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.remember 16 | import androidx.compose.ui.platform.LocalContext 17 | 18 | @Composable 19 | actual fun isSystemInDarkTheme(): Boolean { 20 | return androidx.compose.foundation.isSystemInDarkTheme() 21 | } 22 | 23 | @Composable 24 | actual fun rememberThemeInfo(isDarkMode: Boolean): ThemeInfo { 25 | val context = LocalContext.current 26 | 27 | val isAndroid12 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S 28 | val isOneUI = remember { 29 | context.packageManager.hasSystemFeature("com.samsung.feature.samsung_experience_mobile") || 30 | context.packageManager.hasSystemFeature("com.samsung.feature.samsung_experience_mobile_lite") 31 | } 32 | val isOneUIUPre611 = isOneUI && 33 | Build.VERSION.SDK_INT == Build.VERSION_CODES.UPSIDE_DOWN_CAKE && 34 | (Class.forName("android.os.SystemProperties").getMethod("getInt", String::class.java, Int::class.java).invoke(null, "ro.build.version.oneui", 0) as Int) < 60101 35 | val colorScheme = remember(isDarkMode, isAndroid12) { 36 | if (isDarkMode) { 37 | if (isAndroid12) { 38 | if (isOneUIUPre611) { 39 | dynamicDarkColorScheme31(dynamicTonalPalette(context)) 40 | } else { 41 | dynamicDarkColorScheme(context) 42 | } 43 | } else { 44 | darkColorScheme() 45 | } 46 | } else { 47 | if (isAndroid12) { 48 | if (isOneUIUPre611) { 49 | dynamicLightColorScheme31(dynamicTonalPalette(context)) 50 | } else { 51 | dynamicLightColorScheme(context) 52 | } 53 | } else { 54 | lightColorScheme() 55 | } 56 | } 57 | } 58 | 59 | return remember(colorScheme) { 60 | ThemeInfo( 61 | isDarkMode = isDarkMode, 62 | colors = colorScheme, 63 | seedColor = colorScheme.primary, 64 | ) 65 | } 66 | } -------------------------------------------------------------------------------- /library/src/macosMain/kotlin/dev/zwander/compose/ThemeInfo.macos.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.DisposableEffect 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.runtime.mutableStateOf 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.runtime.setValue 9 | import androidx.compose.ui.graphics.toArgb 10 | import dev.zwander.compose.libmonet.scheme.ColorScheme 11 | import dev.zwander.compose.util.macOsColorKeyToColor 12 | import platform.Foundation.NSUserDefaults 13 | import platform.Foundation.NSDistributedNotificationCenter 14 | import platform.Foundation.NSNotification 15 | 16 | @Composable 17 | actual fun isSystemInDarkTheme(): Boolean { 18 | var isDark by remember { 19 | mutableStateOf( 20 | NSUserDefaults.standardUserDefaults.objectForKey("AppleInterfaceStyle") == "Dark" 21 | ) 22 | } 23 | 24 | DisposableEffect(null) { 25 | val observer = { _: NSNotification? -> 26 | isDark = NSUserDefaults.standardUserDefaults.objectForKey("AppleInterfaceStyle") == "Dark" 27 | } 28 | 29 | NSDistributedNotificationCenter.defaultCenter.addObserverForName( 30 | "AppleInterfaceThemeChangedNotification", 31 | null, 32 | null, 33 | observer, 34 | ) 35 | 36 | onDispose { 37 | NSDistributedNotificationCenter.defaultCenter.removeObserver(observer) 38 | } 39 | } 40 | 41 | return isDark 42 | } 43 | 44 | @Composable 45 | actual fun rememberThemeInfo(isDarkMode: Boolean): ThemeInfo { 46 | var accentColor by remember { 47 | mutableStateOf( 48 | macOsColorKeyToColor( 49 | NSUserDefaults.standardUserDefaults.objectForKey("AppleAccentColor")?.toString()?.toIntOrNull(), 50 | ) 51 | ) 52 | } 53 | 54 | DisposableEffect(null) { 55 | val observer = { _: NSNotification? -> 56 | accentColor = macOsColorKeyToColor( 57 | NSUserDefaults.standardUserDefaults.objectForKey("AppleAccentColor")?.toString()?.toIntOrNull(), 58 | ) 59 | } 60 | 61 | NSDistributedNotificationCenter.defaultCenter.addObserverForName( 62 | "AppleInterfaceThemeChangedNotification", 63 | null, 64 | null, 65 | observer, 66 | ) 67 | 68 | onDispose { 69 | NSDistributedNotificationCenter.defaultCenter.removeObserver(observer) 70 | } 71 | } 72 | 73 | return remember(accentColor, isDarkMode) { 74 | ThemeInfo( 75 | isDarkMode = isDarkMode, 76 | colors = ColorScheme(accentColor.toArgb(), isDarkMode).toComposeColorScheme(), 77 | seedColor = accentColor, 78 | ) 79 | } 80 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Multiplatform Material You 2 | A port of Android's Monet color palette to Compose Multiplatform, with built-in functionality for retrieving the system accent color on most platforms. 3 | 4 | ## Compatibility 5 | Multiplatform Material You targets the following platforms: 6 | - Android 7 | - JVM 8 | - Windows 10 and later 9 | - macOS Mojave and later 10 | - Linux (KDE, LXDE, partial support for GNOME) 11 | - iOS 12 | - macOS native (untested) 13 | - JS (partial support) 14 | - Web Assembly (partial support) 15 | 16 | ## Installation 17 | ![Maven Central Version](https://img.shields.io/maven-central/v/dev.zwander/materialyou) 18 | 19 | Add the dependency to your `commonMain` source set: 20 | 21 | ```kotlin 22 | sourceSets { 23 | val commonMain by getting { 24 | dependencies { 25 | implementation("dev.zwander:materialyou:VERSION") 26 | } 27 | } 28 | } 29 | ``` 30 | 31 | ## Usage 32 | The most basic usage simply involves replacing usages of `MaterialTheme {}` with `DynamicMaterialTheme {}` in your code. 33 | 34 | ```kotlin 35 | @Composable 36 | fun App() { 37 | DynamicMaterialTheme { 38 | Surface { 39 | //... 40 | } 41 | } 42 | } 43 | ``` 44 | 45 | The library will attempt to automatically retrieve the system accent color and dark mode status and generate a theme from those values. 46 | 47 | ### JS/Web Assembly 48 | The JS and Web Assembly targets assume a web environment, which doesn't allow direct retrieval of the system accent color due to fingerprinting concerns. 49 | This means Multiplatform Material You can't automatically determine which color to use. As a workaround, you can specify `LocalAccentColor` on these platforms yourself. 50 | 51 | ```kotlin 52 | @Composable 53 | fun Main() { 54 | val accentColor = // Hardcoded, from a user preference, etc. 55 | 56 | CompositionLocalProvider( 57 | LocalAccentColor provides accentColor, 58 | ) { 59 | App() 60 | } 61 | } 62 | ``` 63 | 64 | ### Advanced Usage 65 | The Composable function `rememberThemeInfo()` is also available in the common target to let you directly retrieve the base color scheme and whether the system is in dark mode. 66 | 67 | If you want even more control, you can create an instance of `ColorScheme()` directly yourself, providing an ARGB color integer and whether you want dark mode or not. You can also optionally provide a `Style` to `ColorScheme` to change how it generates the palette. 68 | 69 | ```kotlin 70 | val seedColor = Color(100, 255, 0).toArgb() 71 | val isDark = isSystemInDarkTheme() 72 | 73 | val colorScheme = ColorScheme( 74 | seedColor = seedColor, 75 | isDark = isDark, 76 | style = Style.SPRITZ, // optional 77 | contrast = 0.0 // optional, between -1 and 1 78 | ) 79 | 80 | val composeColorScheme = colorScheme.toComposeColorScheme() 81 | ``` 82 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 45 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/blend/Blend.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.blend 2 | 3 | import dev.zwander.compose.libmonet.hct.Cam16 4 | import dev.zwander.compose.libmonet.hct.Hct 5 | import dev.zwander.compose.libmonet.utils.ColorUtils 6 | import dev.zwander.compose.libmonet.utils.MathUtils.differenceDegrees 7 | import dev.zwander.compose.libmonet.utils.MathUtils.rotationDirection 8 | import dev.zwander.compose.libmonet.utils.MathUtils.sanitizeDegreesDouble 9 | import kotlin.math.min 10 | 11 | /** Functions for blending in HCT and CAM16. */ 12 | object Blend { 13 | /** 14 | * Blend the design color's HCT hue towards the key color's HCT hue, in a way that leaves the 15 | * original color recognizable and recognizably shifted towards the key color. 16 | * 17 | * @param designColor ARGB representation of an arbitrary color. 18 | * @param sourceColor ARGB representation of the main theme color. 19 | * @return The design color with a hue shifted towards the system's color, a slightly 20 | * warmer/cooler variant of the design color's hue. 21 | */ 22 | fun harmonize(designColor: Int, sourceColor: Int): Int { 23 | val fromHct: Hct = Hct.fromInt(designColor) 24 | val toHct: Hct = Hct.fromInt(sourceColor) 25 | val differenceDegrees: Double = 26 | differenceDegrees(fromHct.getHue(), toHct.getHue()) 27 | val rotationDegrees = min(differenceDegrees * 0.5, 15.0) 28 | val outputHue: Double = 29 | sanitizeDegreesDouble( 30 | fromHct.getHue() 31 | + rotationDegrees * rotationDirection( 32 | fromHct.getHue(), 33 | toHct.getHue() 34 | ) 35 | ) 36 | return Hct.from(outputHue, fromHct.getChroma(), fromHct.getTone()).toInt() 37 | } 38 | 39 | /** 40 | * Blends hue from one color into another. The chroma and tone of the original color are 41 | * maintained. 42 | * 43 | * @param from ARGB representation of color 44 | * @param to ARGB representation of color 45 | * @param amount how much blending to perform; 0.0 >= and <= 1.0 46 | * @return from, with a hue blended towards to. Chroma and tone are constant. 47 | */ 48 | fun hctHue(from: Int, to: Int, amount: Double): Int { 49 | val ucs = cam16Ucs(from, to, amount) 50 | val ucsCam: Cam16 = Cam16.fromInt(ucs) 51 | val fromCam: Cam16 = Cam16.fromInt(from) 52 | val blended: Hct = 53 | Hct.from(ucsCam.hue, fromCam.chroma, ColorUtils.lstarFromArgb(from)) 54 | return blended.toInt() 55 | } 56 | 57 | /** 58 | * Blend in CAM16-UCS space. 59 | * 60 | * @param from ARGB representation of color 61 | * @param to ARGB representation of color 62 | * @param amount how much blending to perform; 0.0 >= and <= 1.0 63 | * @return from, blended towards to. Hue, chroma, and tone will change. 64 | */ 65 | fun cam16Ucs(from: Int, to: Int, amount: Double): Int { 66 | val fromCam: Cam16 = Cam16.fromInt(from) 67 | val toCam: Cam16 = Cam16.fromInt(to) 68 | val fromJ: Double = fromCam.jstar 69 | val fromA: Double = fromCam.astar 70 | val fromB: Double = fromCam.bstar 71 | val toJ: Double = toCam.jstar 72 | val toA: Double = toCam.astar 73 | val toB: Double = toCam.bstar 74 | val jstar = fromJ + (toJ - fromJ) * amount 75 | val astar = fromA + (toA - fromA) * amount 76 | val bstar = fromB + (toB - fromB) * amount 77 | return Cam16.fromUcs(jstar, astar, bstar).toInt() 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /library/src/jvmMain/kotlin/dev/zwander/compose/util/LinuxAccentColorGetter.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.util 2 | 3 | import androidx.compose.ui.graphics.Color 4 | import java.io.File 5 | 6 | object LinuxAccentColorGetter { 7 | fun getAccentColor(): Color? { 8 | return try { 9 | val environmentValue = System.getenv()["XDG_SESSION_DESKTOP"] 10 | 11 | DESpecificGetter.findGetter(environmentValue)?.getAccentColor() 12 | } catch (e: Throwable) { 13 | e.printStackTrace() 14 | null 15 | } 16 | } 17 | } 18 | 19 | sealed class DESpecificGetter(val sessionValue: String) { 20 | companion object { 21 | fun findGetter(de: String?): DESpecificGetter? { 22 | return DESpecificGetter::class.sealedSubclasses.firstNotNullOfOrNull { 23 | val obj = it.objectInstance 24 | 25 | if (obj?.sessionValue?.lowercase() == de?.lowercase()) { 26 | obj 27 | } else { 28 | null 29 | } 30 | } 31 | } 32 | } 33 | 34 | protected val runtime: Runtime? by lazy { Runtime.getRuntime() } 35 | 36 | abstract fun getAccentColor(): Color? 37 | 38 | data object KDE : DESpecificGetter("KDE") { 39 | override fun getAccentColor(): Color? { 40 | val rgb = runtime?.run { 41 | try { 42 | getLinesFromCommand(arrayOf("kreadconfig5", "--key", "AccentColor", "--group", "General")) 43 | } catch (e: Exception) { 44 | getLinesFromCommand(arrayOf("kreadconfig6", "--key", "AccentColor", "--group", "General")) 45 | } 46 | }?.firstOrNull() 47 | 48 | if (rgb.isNullOrBlank()) { 49 | return null 50 | } 51 | 52 | val (r, g, b) = rgb.split(",") 53 | 54 | return Color(r.toInt(), g.toInt(), b.toInt()) 55 | } 56 | } 57 | 58 | data object LXDE : DESpecificGetter("LXDE") { 59 | override fun getAccentColor(): Color? { 60 | val file = File("${System.getProperty("user.home")}/.config/lxsession/LXDE/desktop.conf") 61 | val line = file.useLines { lines -> 62 | lines.find { it.startsWith("sGtk/ColorScheme") } 63 | } 64 | 65 | if (line.isNullOrBlank()) { 66 | return null 67 | } 68 | 69 | val value = line.split("=").getOrNull(1) ?: return null 70 | val selectedBgColor = value.split("\\n").find { it.startsWith("selected_bg_color") } ?: return null 71 | 72 | val colorValue = selectedBgColor.split(":#").getOrNull(1) ?: return null 73 | val realColor = if (colorValue.length == 6) { 74 | colorValue 75 | } else { 76 | // LXDE sets a 12-character color with the 6-char color (sort of) interleaved. 77 | // It isn't always exactly accurate, but the format makes no sense, and this is close enough. 78 | "${colorValue.slice(0..1)}${colorValue.slice(4..5)}${colorValue.slice(8..9)}" 79 | } 80 | 81 | return try { 82 | Color(java.awt.Color.decode("#${realColor}").rgb) 83 | } catch (e: Exception) { 84 | null 85 | } 86 | } 87 | } 88 | } 89 | 90 | fun Runtime.getLinesFromCommand(command: Array): List? { 91 | val proc = exec(command) 92 | 93 | proc?.inputStream?.bufferedReader()?.use { input -> 94 | return input.readLines() 95 | } 96 | 97 | proc?.waitFor() 98 | 99 | return null 100 | } -------------------------------------------------------------------------------- /library/src/iosMain/kotlin/dev/zwander/compose/ThemeInfo.ios.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.derivedStateOf 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.runtime.mutableStateOf 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.runtime.setValue 9 | import androidx.compose.ui.graphics.Color 10 | import androidx.compose.ui.graphics.toArgb 11 | import androidx.compose.ui.uikit.LocalUIViewController 12 | import dev.zwander.compose.libmonet.scheme.ColorScheme 13 | import dev.zwander.compose.util.MacOSColors 14 | import dev.zwander.compose.util.TraitEffect 15 | import kotlinx.cinterop.ExperimentalForeignApi 16 | import kotlinx.cinterop.alloc 17 | import kotlinx.cinterop.memScoped 18 | import kotlinx.cinterop.ptr 19 | import kotlinx.cinterop.value 20 | import platform.CoreGraphics.CGFloat 21 | import platform.CoreGraphics.CGFloatVar 22 | import platform.UIKit.UIColor 23 | import platform.UIKit.UITraitCollection 24 | import platform.UIKit.UIUserInterfaceStyle 25 | import platform.UIKit.currentTraitCollection 26 | 27 | @Composable 28 | actual fun isSystemInDarkTheme(): Boolean { 29 | var style: UIUserInterfaceStyle by remember { 30 | mutableStateOf(UITraitCollection.currentTraitCollection.userInterfaceStyle) 31 | } 32 | 33 | val dark by remember { 34 | derivedStateOf { style == UIUserInterfaceStyle.UIUserInterfaceStyleDark } 35 | } 36 | 37 | TraitEffect { 38 | style = UITraitCollection.currentTraitCollection.userInterfaceStyle 39 | } 40 | 41 | return dark 42 | } 43 | 44 | @OptIn(ExperimentalForeignApi::class) 45 | @Composable 46 | actual fun rememberThemeInfo(isDarkMode: Boolean): ThemeInfo { 47 | val controller = LocalUIViewController.current 48 | val rootViewController = controller.view.window?.rootViewController 49 | 50 | val rootTint = rootViewController?.view?.tintColor 51 | 52 | val (red, green, blue, alpha) = remember(rootTint) { 53 | rootTint?.run { 54 | memScoped { 55 | val red = alloc() 56 | val green = alloc() 57 | val blue = alloc() 58 | val alpha = alloc() 59 | 60 | val success = getRed(red.ptr, green.ptr, blue.ptr, alpha.ptr) 61 | 62 | if (success) { 63 | arrayOf( 64 | red.value, 65 | green.value, 66 | blue.value, 67 | alpha.value, 68 | ) 69 | } else { 70 | arrayOfNulls(4) 71 | } 72 | } 73 | } ?: arrayOfNulls(4) 74 | } 75 | 76 | 77 | 78 | val seedColor = if (red != null && green != null && blue != null && alpha != null) { 79 | Color(red.toFloat(), green.toFloat(), blue.toFloat(), alpha.toFloat()) 80 | } else { 81 | MacOSColors.ACCENT_BLUE 82 | }.toArgb() 83 | 84 | val colorScheme = ColorScheme( 85 | seedColor, 86 | isDarkMode, 87 | ).toComposeColorScheme() 88 | 89 | val colors = ThemeInfo( 90 | isDarkMode = isDarkMode, 91 | colors = colorScheme, 92 | seedColor = Color(seedColor), 93 | ) 94 | 95 | val backgroundColor = colorScheme.background 96 | val uiColor = UIColor.colorWithRed( 97 | backgroundColor.red.toDouble(), 98 | backgroundColor.green.toDouble(), 99 | backgroundColor.blue.toDouble(), 100 | backgroundColor.alpha.toDouble(), 101 | ) 102 | 103 | val rv = controller.view.window?.rootViewController?.view 104 | rv?.backgroundColor = uiColor 105 | 106 | return colors 107 | } -------------------------------------------------------------------------------- /library/src/jvmMain/kotlin/dev/zwander/compose/ThemeInfo.jvm.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.DisposableEffect 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.runtime.mutableStateOf 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.runtime.setValue 9 | import androidx.compose.ui.graphics.Color 10 | import androidx.compose.ui.graphics.toArgb 11 | import com.jthemedetecor.OsThemeDetector 12 | import com.sun.jna.platform.win32.Advapi32Util 13 | import com.sun.jna.platform.win32.WinReg 14 | import de.jangassen.jfa.appkit.NSUserDefaults 15 | import dev.zwander.compose.libmonet.scheme.ColorScheme 16 | import dev.zwander.compose.util.LinuxAccentColorGetter 17 | import dev.zwander.compose.util.macOsColorKeyToColor 18 | import org.jetbrains.skiko.OS 19 | import org.jetbrains.skiko.hostOs 20 | import java.util.function.Consumer 21 | 22 | @Composable 23 | actual fun isSystemInDarkTheme(): Boolean { 24 | val (osThemeDetector, isSupported) = remember { 25 | OsThemeDetector.detector to OsThemeDetector.isSupported 26 | } 27 | 28 | var dark by remember { 29 | mutableStateOf(isSupported && osThemeDetector.isDark) 30 | } 31 | 32 | DisposableEffect(osThemeDetector, isSupported) { 33 | val listener = Consumer { darkMode: Boolean -> 34 | dark = darkMode 35 | } 36 | 37 | if (isSupported) { 38 | osThemeDetector.registerListener(listener) 39 | } 40 | 41 | onDispose { 42 | if (isSupported) { 43 | osThemeDetector.removeListener(listener) 44 | } 45 | } 46 | } 47 | 48 | return dark 49 | } 50 | 51 | @Composable 52 | actual fun rememberThemeInfo(isDarkMode: Boolean): ThemeInfo { 53 | val accentColor = remember { 54 | val defaultColor = Color(red = 208, green = 188, blue = 255) 55 | 56 | when (hostOs) { 57 | OS.Windows -> { 58 | try { 59 | java.awt.Color( 60 | Advapi32Util.registryGetIntValue( 61 | WinReg.HKEY_CURRENT_USER, 62 | "Software\\Microsoft\\Windows\\DWM", 63 | "AccentColor", 64 | ) 65 | ).let { 66 | // AccentColor is ABGR so we need to swap blue and red. 67 | Color(it.blue, it.green, it.red).toArgb() 68 | } 69 | } catch (_: Throwable) { 70 | try { 71 | Color( 72 | Advapi32Util.registryGetIntValue( 73 | WinReg.HKEY_CURRENT_USER, 74 | "Software\\Microsoft\\Windows\\DWM", 75 | "ColorizationColor", 76 | ) 77 | ).toArgb() 78 | } catch (_: Throwable) { 79 | println("Unable to retrieve Windows accent color.") 80 | defaultColor.toArgb() 81 | } 82 | } 83 | } 84 | OS.MacOS -> { 85 | macOsColorKeyToColor(NSUserDefaults.standardUserDefaults().objectForKey("AppleAccentColor")?.toString()?.toIntOrNull()).toArgb() 86 | } 87 | OS.Linux -> { 88 | (LinuxAccentColorGetter.getAccentColor() ?: defaultColor).toArgb() 89 | } 90 | else -> { 91 | defaultColor.toArgb() 92 | } 93 | } 94 | } 95 | 96 | val composeColorScheme = remember(accentColor, isDarkMode) { 97 | ColorScheme(accentColor, isDarkMode).toComposeColorScheme() 98 | } 99 | 100 | return remember(composeColorScheme) { 101 | ThemeInfo( 102 | isDarkMode = isDarkMode, 103 | colors = composeColorScheme, 104 | seedColor = Color(accentColor), 105 | ) 106 | } 107 | } -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/utils/MathUtils.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.utils 2 | 3 | import kotlin.math.abs 4 | 5 | /** Utility methods for mathematical operations. */ 6 | object MathUtils { 7 | /** 8 | * The signum function. 9 | * 10 | * @return 1 if num > 0, -1 if num < 0, and 0 if num = 0 11 | */ 12 | fun signum(num: Double): Int { 13 | return if (num < 0) { 14 | -1 15 | } else if (num == 0.0) { 16 | 0 17 | } else { 18 | 1 19 | } 20 | } 21 | 22 | /** 23 | * The linear interpolation function. 24 | * 25 | * @return start if amount = 0 and stop if amount = 1 26 | */ 27 | fun lerp(start: Double, stop: Double, amount: Double): Double { 28 | return (1.0 - amount) * start + amount * stop 29 | } 30 | 31 | /** 32 | * Clamps an integer between two integers. 33 | * 34 | * @return input when min <= input <= max, and either min or max otherwise. 35 | */ 36 | fun clampInt(min: Int, max: Int, input: Int): Int { 37 | if (input < min) { 38 | return min 39 | } else if (input > max) { 40 | return max 41 | } 42 | 43 | return input 44 | } 45 | 46 | /** 47 | * Clamps an integer between two floating-point numbers. 48 | * 49 | * @return input when min <= input <= max, and either min or max otherwise. 50 | */ 51 | fun clampDouble(min: Double, max: Double, input: Double): Double { 52 | if (input < min) { 53 | return min 54 | } else if (input > max) { 55 | return max 56 | } 57 | 58 | return input 59 | } 60 | 61 | /** 62 | * Sanitizes a degree measure as an integer. 63 | * 64 | * @return a degree measure between 0 (inclusive) and 360 (exclusive). 65 | */ 66 | fun sanitizeDegreesInt(degrees: Int): Int { 67 | var degreesTmp = degrees 68 | degreesTmp %= 360 69 | if (degreesTmp < 0) { 70 | degreesTmp += 360 71 | } 72 | return degreesTmp 73 | } 74 | 75 | /** 76 | * Sanitizes a degree measure as a floating-point number. 77 | * 78 | * @return a degree measure between 0.0 (inclusive) and 360.0 (exclusive). 79 | */ 80 | fun sanitizeDegreesDouble(degrees: Double): Double { 81 | var degreesTmp = degrees 82 | degreesTmp %= 360.0 83 | if (degreesTmp < 0) { 84 | degreesTmp += 360.0 85 | } 86 | return degreesTmp 87 | } 88 | 89 | /** 90 | * Sign of direction change needed to travel from one angle to another. 91 | * 92 | * 93 | * For angles that are 180 degrees apart from each other, both directions have the same travel 94 | * distance, so either direction is shortest. The value 1.0 is returned in this case. 95 | * 96 | * @param from The angle travel starts from, in degrees. 97 | * @param to The angle travel ends at, in degrees. 98 | * @return -1 if decreasing from leads to the shortest travel distance, 1 if increasing from leads 99 | * to the shortest travel distance. 100 | */ 101 | fun rotationDirection(from: Double, to: Double): Double { 102 | val increasingDifference = sanitizeDegreesDouble(to - from) 103 | return if (increasingDifference <= 180.0) 1.0 else -1.0 104 | } 105 | 106 | /** Distance of two points on a circle, represented using degrees. */ 107 | fun differenceDegrees(a: Double, b: Double): Double { 108 | return 180.0 - abs(abs(a - b) - 180.0) 109 | } 110 | 111 | /** Multiplies a 1x3 row vector with a 3x3 matrix. */ 112 | fun matrixMultiply(row: DoubleArray, matrix: Array): DoubleArray { 113 | val a = row[0] * matrix[0][0] + row[1] * matrix[0][1] + row[2] * matrix[0][2] 114 | val b = row[0] * matrix[1][0] + row[1] * matrix[1][1] + row[2] * matrix[1][2] 115 | val c = row[0] * matrix[2][0] + row[1] * matrix[2][1] + row[2] * matrix[2][2] 116 | return doubleArrayOf(a, b, c) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /library/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl 2 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 3 | 4 | plugins { 5 | alias(libs.plugins.android.library) 6 | alias(libs.plugins.compose) 7 | alias(libs.plugins.kotlin.compose) 8 | alias(libs.plugins.kotlin.multiplatform) 9 | alias(libs.plugins.maven.publish) 10 | } 11 | 12 | group = "dev.zwander.compose.materialyou" 13 | 14 | kotlin.sourceSets.all { 15 | languageSettings.optIn("kotlin.RequiresOptIn") 16 | } 17 | 18 | val javaVersionEnum: JavaVersion = JavaVersion.VERSION_21 19 | 20 | kotlin { 21 | jvmToolchain(javaVersionEnum.toString().toInt()) 22 | 23 | androidTarget { 24 | compilations.all { 25 | compileTaskProvider.configure { 26 | compilerOptions { 27 | freeCompilerArgs.addAll("-opt-in=kotlin.RequiresOptIn", "-Xdont-warn-on-error-suppression") 28 | jvmTarget = JvmTarget.fromTarget(javaVersionEnum.toString()) 29 | } 30 | } 31 | } 32 | } 33 | 34 | jvm { 35 | compilations.all { 36 | compileTaskProvider.configure { 37 | compilerOptions { 38 | jvmTarget = JvmTarget.fromTarget(javaVersionEnum.toString()) 39 | } 40 | } 41 | } 42 | } 43 | 44 | listOf( 45 | iosX64(), 46 | iosArm64(), 47 | iosSimulatorArm64(), 48 | macosX64(), 49 | macosArm64(), 50 | ).forEach { 51 | it.binaries.framework { 52 | baseName = "MultiplatformMaterialYou" 53 | isStatic = true 54 | } 55 | } 56 | 57 | @OptIn(ExperimentalWasmDsl::class) 58 | listOf( 59 | js(IR), 60 | wasmJs(), 61 | ).forEach { 62 | it.outputModuleName.set("MultiplatformMaterialYou") 63 | it.browser() 64 | } 65 | 66 | targets.all { 67 | compilations.all { 68 | compileTaskProvider.configure { 69 | compilerOptions { 70 | freeCompilerArgs.addAll("-Xexpect-actual-classes", "-Xdont-warn-on-error-suppression") 71 | } 72 | } 73 | } 74 | } 75 | 76 | sourceSets { 77 | val commonMain by getting { 78 | dependencies { 79 | api(compose.foundation) 80 | api(compose.material3) 81 | api(compose.runtime) 82 | api(compose.ui) 83 | api(libs.kotlin.stdlib) 84 | api(libs.kotlin.reflect) 85 | } 86 | } 87 | 88 | val jvmMain by getting { 89 | dependsOn(commonMain) 90 | 91 | dependencies { 92 | api(libs.jsystemthemedetector) 93 | api(libs.jna) 94 | api(libs.jna.platform) 95 | api(libs.jfa) 96 | } 97 | } 98 | 99 | val androidMain by getting { 100 | dependsOn(commonMain) 101 | } 102 | 103 | val iosMain by creating { 104 | dependsOn(commonMain) 105 | } 106 | 107 | val iosX64Main by getting { 108 | dependsOn(iosMain) 109 | } 110 | 111 | val iosArm64Main by getting { 112 | dependsOn(iosMain) 113 | } 114 | 115 | val iosSimulatorArm64Main by getting { 116 | dependsOn(iosMain) 117 | } 118 | 119 | val macosMain by creating { 120 | dependsOn(commonMain) 121 | } 122 | 123 | val macosArm64Main by getting { 124 | dependsOn(macosMain) 125 | } 126 | 127 | val macosX64Main by getting { 128 | dependsOn(macosMain) 129 | } 130 | 131 | val jsAndWasmMain by creating { 132 | dependsOn(commonMain) 133 | } 134 | 135 | val jsMain by getting { 136 | dependsOn(jsAndWasmMain) 137 | } 138 | 139 | val wasmJsMain by getting { 140 | dependsOn(jsAndWasmMain) 141 | } 142 | } 143 | } 144 | 145 | android { 146 | this.compileSdk = 36 147 | 148 | defaultConfig { 149 | this.minSdk = 21 150 | } 151 | 152 | namespace = "dev.zwander.compose.materialyou" 153 | 154 | compileOptions { 155 | sourceCompatibility = javaVersionEnum 156 | targetCompatibility = javaVersionEnum 157 | } 158 | 159 | buildFeatures { 160 | aidl = true 161 | } 162 | 163 | sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") 164 | sourceSets["main"].res.srcDirs("src/androidMain/res") 165 | } 166 | 167 | tasks.withType { 168 | duplicatesStrategy = DuplicatesStrategy.EXCLUDE 169 | } 170 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/palettes/TonalPalette.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.palettes 2 | 3 | import dev.zwander.compose.libmonet.hct.Hct 4 | import kotlin.math.abs 5 | import kotlin.math.round 6 | 7 | 8 | /** 9 | * A convenience class for retrieving colors that are constant in hue and chroma, but vary in tone. 10 | */ 11 | class TonalPalette private constructor( 12 | /** The hue of the Tonal Palette, in HCT. Ranges from 0 to 360. */ 13 | var hue: Double, 14 | /** The chroma of the Tonal Palette, in HCT. Ranges from 0 to ~130 (for sRGB gamut). */ 15 | var chroma: Double, 16 | /** The key color is the first tone, starting from T50, that matches the palette's chroma. */ 17 | var keyColor: Hct 18 | ) { 19 | var cache: MutableMap = HashMap() 20 | 21 | /** 22 | * Create an ARGB color with HCT hue and chroma of this Tones instance, and the provided HCT tone. 23 | * 24 | * @param tone HCT tone, measured from 0 to 100. 25 | * @return ARGB representation of a color with that tone. 26 | */ 27 | // AndroidJdkLibsChecker is higher priority than ComputeIfAbsentUseValue (b/119581923) 28 | fun tone(tone: Int): Int { 29 | var color = cache[tone] 30 | if (color == null) { 31 | color = Hct.from(this.hue, this.chroma, tone.toDouble()).toInt() 32 | cache[tone] = color 33 | } 34 | return color 35 | } 36 | 37 | fun shade(shade: Int): Int { 38 | return tone(((1000.0 - shade) / 10.0).toInt()) 39 | } 40 | 41 | /** Given a tone, use hue and chroma of palette to create a color, and return it as HCT. */ 42 | fun getHct(tone: Double): Hct { 43 | return Hct.from(this.hue, this.chroma, tone) 44 | } 45 | 46 | companion object { 47 | /** 48 | * Create tones using the HCT hue and chroma from a color. 49 | * 50 | * @param argb ARGB representation of a color 51 | * @return Tones matching that color's hue and chroma. 52 | */ 53 | fun fromInt(argb: Int): TonalPalette { 54 | return fromHct(Hct.fromInt(argb)) 55 | } 56 | 57 | /** 58 | * Create tones using a HCT color. 59 | * 60 | * @param hct HCT representation of a color. 61 | * @return Tones matching that color's hue and chroma. 62 | */ 63 | fun fromHct(hct: Hct): TonalPalette { 64 | return TonalPalette(hct.getHue(), hct.getChroma(), hct) 65 | } 66 | 67 | /** 68 | * Create tones from a defined HCT hue and chroma. 69 | * 70 | * @param hue HCT hue 71 | * @param chroma HCT chroma 72 | * @return Tones matching hue and chroma. 73 | */ 74 | fun fromHueAndChroma(hue: Double, chroma: Double): TonalPalette { 75 | return TonalPalette(hue, chroma, createKeyColor(hue, chroma)) 76 | } 77 | 78 | /** The key color is the first tone, starting from T50, matching the given hue and chroma. */ 79 | private fun createKeyColor(hue: Double, chroma: Double): Hct { 80 | val startTone = 50.0 81 | var smallestDeltaHct = Hct.from(hue, chroma, startTone) 82 | var smallestDelta = abs(smallestDeltaHct.getChroma() - chroma) 83 | // Starting from T50, check T+/-delta to see if they match the requested 84 | // chroma. 85 | // 86 | // Starts from T50 because T50 has the most chroma available, on 87 | // average. Thus it is most likely to have a direct answer and minimize 88 | // iteration. 89 | var delta = 1.0 90 | while (delta < 50.0) { 91 | // Termination condition rounding instead of minimizing delta to avoid 92 | // case where requested chroma is 16.51, and the closest chroma is 16.49. 93 | // Error is minimized, but when rounded and displayed, requested chroma 94 | // is 17, key color's chroma is 16. 95 | if (round(chroma) == round(smallestDeltaHct.getChroma())) { 96 | return smallestDeltaHct 97 | } 98 | 99 | val hctAdd = Hct.from(hue, chroma, startTone + delta) 100 | val hctAddDelta = abs(hctAdd.getChroma() - chroma) 101 | if (hctAddDelta < smallestDelta) { 102 | smallestDelta = hctAddDelta 103 | smallestDeltaHct = hctAdd 104 | } 105 | 106 | val hctSubtract = Hct.from(hue, chroma, startTone - delta) 107 | val hctSubtractDelta = abs(hctSubtract.getChroma() - chroma) 108 | if (hctSubtractDelta < smallestDelta) { 109 | smallestDelta = hctSubtractDelta 110 | smallestDeltaHct = hctSubtract 111 | } 112 | delta += 1.0 113 | } 114 | 115 | return smallestDeltaHct 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/hct/Hct.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.hct 2 | 3 | import dev.zwander.compose.libmonet.utils.ColorUtils 4 | 5 | 6 | /** 7 | * A color system built using CAM16 hue and chroma, and L* from L*a*b*. 8 | * 9 | * 10 | * Using L* creates a link between the color system, contrast, and thus accessibility. Contrast 11 | * ratio depends on relative luminance, or Y in the XYZ color space. L*, or perceptual luminance can 12 | * be calculated from Y. 13 | * 14 | * 15 | * Unlike Y, L* is linear to human perception, allowing trivial creation of accurate color tones. 16 | * 17 | * 18 | * Unlike contrast ratio, measuring contrast in L* is linear, and simple to calculate. A 19 | * difference of 40 in HCT tone guarantees a contrast ratio >= 3.0, and a difference of 50 20 | * guarantees a contrast ratio >= 4.5. 21 | */ 22 | /** 23 | * HCT, hue, chroma, and tone. A color system that provides a perceptually accurate color 24 | * measurement system that can also accurately render what colors will appear as in different 25 | * lighting environments. 26 | */ 27 | class Hct private constructor(argb: Int) { 28 | private var hue = 0.0 29 | private var chroma = 0.0 30 | private var tone = 0.0 31 | private var argb = 0 32 | 33 | init { 34 | setInternalState(argb) 35 | } 36 | 37 | fun getHue(): Double { 38 | return hue 39 | } 40 | 41 | fun getChroma(): Double { 42 | return chroma 43 | } 44 | 45 | fun getTone(): Double { 46 | return tone 47 | } 48 | 49 | fun toInt(): Int { 50 | return argb 51 | } 52 | 53 | /** 54 | * Set the hue of this color. Chroma may decrease because chroma has a different maximum for any 55 | * given hue and tone. 56 | * 57 | * @param newHue 0 <= newHue < 360; invalid values are corrected. 58 | */ 59 | fun setHue(newHue: Double) { 60 | setInternalState(HctSolver.solveToInt(newHue, chroma, tone)) 61 | } 62 | 63 | /** 64 | * Set the chroma of this color. Chroma may decrease because chroma has a different maximum for 65 | * any given hue and tone. 66 | * 67 | * @param newChroma 0 <= newChroma < ? 68 | */ 69 | fun setChroma(newChroma: Double) { 70 | setInternalState(HctSolver.solveToInt(hue, newChroma, tone)) 71 | } 72 | 73 | /** 74 | * Set the tone of this color. Chroma may decrease because chroma has a different maximum for any 75 | * given hue and tone. 76 | * 77 | * @param newTone 0 <= newTone <= 100; invalid valids are corrected. 78 | */ 79 | fun setTone(newTone: Double) { 80 | setInternalState(HctSolver.solveToInt(hue, chroma, newTone)) 81 | } 82 | 83 | /** 84 | * Translate a color into different ViewingConditions. 85 | * 86 | * 87 | * Colors change appearance. They look different with lights on versus off, the same color, as 88 | * in hex code, on white looks different when on black. This is called color relativity, most 89 | * famously explicated by Josef Albers in Interaction of Color. 90 | * 91 | * 92 | * In color science, color appearance models can account for this and calculate the appearance 93 | * of a color in different settings. HCT is based on CAM16, a color appearance model, and uses it 94 | * to make these calculations. 95 | * 96 | * 97 | * See ViewingConditions.make for parameters affecting color appearance. 98 | */ 99 | fun inViewingConditions(vc: ViewingConditions): Hct { 100 | // 1. Use CAM16 to find XYZ coordinates of color in specified VC. 101 | val cam16 = Cam16.fromInt(toInt()) 102 | val viewedInVc = cam16.xyzInViewingConditions(vc, null) 103 | 104 | // 2. Create CAM16 of those XYZ coordinates in default VC. 105 | val recastInVc = 106 | Cam16.fromXyzInViewingConditions( 107 | viewedInVc[0], viewedInVc[1], viewedInVc[2], ViewingConditions.DEFAULT 108 | ) 109 | 110 | // 3. Create HCT from: 111 | // - CAM16 using default VC with XYZ coordinates in specified VC. 112 | // - L* converted from Y in XYZ coordinates in specified VC. 113 | return from( 114 | recastInVc.hue, recastInVc.chroma, ColorUtils.lstarFromY(viewedInVc[1]) 115 | ) 116 | } 117 | 118 | private fun setInternalState(argb: Int) { 119 | this.argb = argb 120 | val cam = Cam16.fromInt(argb) 121 | hue = cam.hue 122 | chroma = cam.chroma 123 | this.tone = ColorUtils.lstarFromArgb(argb) 124 | } 125 | 126 | companion object { 127 | /** 128 | * Create an HCT color from hue, chroma, and tone. 129 | * 130 | * @param hue 0 <= hue < 360; invalid values are corrected. 131 | * @param chroma 0 <= chroma < ?; Informally, colorfulness. The color returned may be lower than 132 | * the requested chroma. Chroma has a different maximum for any given hue and tone. 133 | * @param tone 0 <= tone <= 100; invalid values are corrected. 134 | * @return HCT representation of a color in default viewing conditions. 135 | */ 136 | fun from(hue: Double, chroma: Double, tone: Double): Hct { 137 | val argb: Int = HctSolver.solveToInt(hue, chroma, tone) 138 | return Hct(argb) 139 | } 140 | 141 | /** 142 | * Create an HCT color from a color. 143 | * 144 | * @param argb ARGB representation of a color. 145 | * @return HCT representation of a color in default viewing conditions 146 | */ 147 | fun fromInt(argb: Int): Hct { 148 | return Hct(argb) 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/score/Score.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.score 2 | 3 | import dev.zwander.compose.libmonet.hct.Hct 4 | import dev.zwander.compose.libmonet.utils.MathUtils.differenceDegrees 5 | import dev.zwander.compose.libmonet.utils.MathUtils.sanitizeDegreesInt 6 | import kotlin.jvm.JvmOverloads 7 | import kotlin.math.floor 8 | import kotlin.math.round 9 | 10 | 11 | /** 12 | * Given a large set of colors, remove colors that are unsuitable for a UI theme, and rank the rest 13 | * based on suitability. 14 | * 15 | * 16 | * Enables use of a high cluster count for image quantization, thus ensuring colors aren't 17 | * muddied, while curating the high cluster count to a much smaller number of appropriate choices. 18 | */ 19 | object Score { 20 | private const val TARGET_CHROMA = 48.0 // A1 Chroma 21 | private const val WEIGHT_PROPORTION = 0.7 22 | private const val WEIGHT_CHROMA_ABOVE = 0.3 23 | private const val WEIGHT_CHROMA_BELOW = 0.1 24 | private const val CUTOFF_CHROMA = 5.0 25 | private const val CUTOFF_EXCITED_PROPORTION = 0.01 26 | 27 | /** 28 | * Given a map with keys of colors and values of how often the color appears, rank the colors 29 | * based on suitability for being used for a UI theme. 30 | * 31 | * @param colorsToPopulation map with keys of colors and values of how often the color appears, 32 | * usually from a source image. 33 | * @param desired max count of colors to be returned in the list. 34 | * @param fallbackColorArgb color to be returned if no other options available. 35 | * @param filter whether to filter out undesireable combinations. 36 | * @return Colors sorted by suitability for a UI theme. The most suitable color is the first item, 37 | * the least suitable is the last. There will always be at least one color returned. If all 38 | * the input colors were not suitable for a theme, a default fallback color will be provided, 39 | * Google Blue. 40 | */ 41 | @JvmOverloads 42 | fun score( 43 | colorsToPopulation: Map, 44 | desired: Int = 4, 45 | fallbackColorArgb: Int = -0xbd7a0c, 46 | filter: Boolean = true 47 | ): List { 48 | // Get the HCT color for each Argb value, while finding the per hue count and 49 | // total count. 50 | 51 | val colorsHct: MutableList = ArrayList() 52 | val huePopulation = IntArray(360) 53 | var populationSum = 0.0 54 | for ((key, value) in colorsToPopulation) { 55 | val hct = Hct.fromInt(key!!) 56 | colorsHct.add(hct) 57 | val hue = floor(hct.getHue()).toInt() 58 | huePopulation[hue] += value 59 | populationSum += value.toDouble() 60 | } 61 | 62 | // Hues with more usage in neighboring 30 degree slice get a larger number. 63 | val hueExcitedProportions = DoubleArray(360) 64 | for (hue in 0..359) { 65 | val proportion = huePopulation[hue] / populationSum 66 | for (i in hue - 14 until hue + 16) { 67 | val neighborHue: Int = sanitizeDegreesInt(i) 68 | hueExcitedProportions[neighborHue] += proportion 69 | } 70 | } 71 | 72 | // Scores each HCT color based on usage and chroma, while optionally 73 | // filtering out values that do not have enough chroma or usage. 74 | val scoredHcts: MutableList = ArrayList() 75 | for (hct in colorsHct) { 76 | val hue: Int = sanitizeDegreesInt(round(hct.getHue()).toInt()) 77 | val proportion = hueExcitedProportions[hue] 78 | if (filter && (hct.getChroma() < CUTOFF_CHROMA || proportion <= CUTOFF_EXCITED_PROPORTION)) { 79 | continue 80 | } 81 | 82 | val proportionScore = proportion * 100.0 * WEIGHT_PROPORTION 83 | val chromaWeight = 84 | if (hct.getChroma() < TARGET_CHROMA) WEIGHT_CHROMA_BELOW else WEIGHT_CHROMA_ABOVE 85 | val chromaScore = (hct.getChroma() - TARGET_CHROMA) * chromaWeight 86 | val score = proportionScore + chromaScore 87 | scoredHcts.add(ScoredHCT(hct, score)) 88 | } 89 | // Sorted so that colors with higher scores come first. 90 | scoredHcts.sortWith(ScoredComparator()) 91 | 92 | // Iterates through potential hue differences in degrees in order to select 93 | // the colors with the largest distribution of hues possible. Starting at 94 | // 90 degrees(maximum difference for 4 colors) then decreasing down to a 95 | // 15 degree minimum. 96 | val chosenColors: MutableList = ArrayList() 97 | for (differenceDegrees in 90 downTo 15) { 98 | chosenColors.clear() 99 | for (entry in scoredHcts) { 100 | val hct = entry.hct 101 | var hasDuplicateHue = false 102 | for (chosenHct in chosenColors) { 103 | if (differenceDegrees( 104 | hct.getHue(), 105 | chosenHct.getHue() 106 | ) < differenceDegrees 107 | ) { 108 | hasDuplicateHue = true 109 | break 110 | } 111 | } 112 | if (!hasDuplicateHue) { 113 | chosenColors.add(hct) 114 | } 115 | if (chosenColors.size >= desired) { 116 | break 117 | } 118 | } 119 | if (chosenColors.size >= desired) { 120 | break 121 | } 122 | } 123 | val colors: MutableList = ArrayList() 124 | if (chosenColors.isEmpty()) { 125 | colors.add(fallbackColorArgb) 126 | } 127 | for (chosenHct in chosenColors) { 128 | colors.add(chosenHct.toInt()) 129 | } 130 | return colors 131 | } 132 | 133 | private class ScoredHCT(val hct: Hct, val score: Double) 134 | 135 | private class ScoredComparator : Comparator { 136 | override fun compare(a: ScoredHCT, b: ScoredHCT): Int { 137 | return b.score.compareTo(a.score) 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/utils/SchemeUtils.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.utils 2 | 3 | import androidx.compose.material3.ColorScheme 4 | import androidx.compose.material3.darkColorScheme 5 | import androidx.compose.material3.lightColorScheme 6 | import androidx.compose.ui.graphics.Color 7 | import androidx.compose.ui.graphics.colorspace.ColorSpaces 8 | import dev.zwander.compose.libmonet.dynamiccolor.DynamicScheme 9 | import kotlin.math.pow 10 | import kotlin.math.roundToInt 11 | 12 | fun DynamicScheme.toComposeColorScheme(): ColorScheme { 13 | val accent1 = primaryPalette 14 | val accent2 = secondaryPalette 15 | val accent3 = tertiaryPalette 16 | val neutral1 = neutralPalette 17 | val neutral2 = neutralVariantPalette 18 | 19 | return if (isDark) { 20 | darkColorScheme( 21 | primary = accent1.shade(200).toColor(), 22 | onPrimary = accent1.shade(800).toColor(), 23 | primaryContainer = accent1.shade(700).toColor(), 24 | onPrimaryContainer = accent1.shade(100).toColor(), 25 | inversePrimary = accent1.shade(600).toColor(), 26 | secondary = accent2.shade(200).toColor(), 27 | onSecondary = accent2.shade(800).toColor(), 28 | secondaryContainer = accent2.shade(700).toColor(), 29 | onSecondaryContainer = accent2.shade(100).toColor(), 30 | tertiary = accent3.shade(200).toColor(), 31 | onTertiary = accent3.shade(800).toColor(), 32 | tertiaryContainer = accent3.shade(700).toColor(), 33 | onTertiaryContainer = accent3.shade(100).toColor(), 34 | background = neutral1.shade(900).toColor(), 35 | onBackground = neutral1.shade(100).toColor(), 36 | surface = neutral1.shade(900).toColor(), 37 | onSurface = neutral1.shade(100).toColor(), 38 | surfaceVariant = neutral2.shade(700).toColor(), 39 | onSurfaceVariant = neutral2.shade(200).toColor(), 40 | inverseSurface = neutral1.shade(100).toColor(), 41 | inverseOnSurface = neutral1.shade(800).toColor(), 42 | outline = neutral2.shade(400).toColor(), 43 | surfaceContainerLowest = neutral2.shade(600).toColor(), 44 | surfaceContainer = neutral2.shade(600).toColor().setLuminance(4f), 45 | surfaceContainerLow = neutral2.shade(900).toColor(), 46 | surfaceContainerHigh = neutral2.shade(600).toColor().setLuminance(17f), 47 | surfaceContainerHighest = neutral2.shade(600).toColor().setLuminance(22f), 48 | outlineVariant = neutral2.shade(700).toColor(), 49 | scrim = neutral2.shade(1000).toColor(), 50 | surfaceBright = neutral2.shade(600).toColor().setLuminance(98f), 51 | surfaceDim = neutral2.shade(600).toColor().setLuminance(6f), 52 | surfaceTint = accent1.shade(200).toColor(), 53 | ) 54 | } else { 55 | lightColorScheme( 56 | primary = accent1.shade(600).toColor(), 57 | onPrimary = accent1.shade(0).toColor(), 58 | primaryContainer = accent1.shade(100).toColor(), 59 | onPrimaryContainer = accent1.shade(900).toColor(), 60 | inversePrimary = accent1.shade(200).toColor(), 61 | secondary = accent2.shade(600).toColor(), 62 | onSecondary = accent2.shade(0).toColor(), 63 | secondaryContainer = accent2.shade(100).toColor(), 64 | onSecondaryContainer = accent2.shade(900).toColor(), 65 | tertiary = accent3.shade(600).toColor(), 66 | onTertiary = accent3.shade(0).toColor(), 67 | tertiaryContainer = accent3.shade(100).toColor(), 68 | onTertiaryContainer = accent3.shade(900).toColor(), 69 | background = neutral1.shade(10).toColor(), 70 | onBackground = neutral1.shade(900).toColor(), 71 | surface = neutral1.shade(10).toColor(), 72 | onSurface = neutral1.shade(900).toColor(), 73 | surfaceVariant = neutral2.shade(100).toColor(), 74 | onSurfaceVariant = neutral2.shade(700).toColor(), 75 | inverseSurface = neutral1.shade(800).toColor(), 76 | inverseOnSurface = neutral1.shade(50).toColor(), 77 | outline = neutral2.shade(500).toColor(), 78 | surfaceContainerLowest = neutral2.shade(0).toColor(), 79 | surfaceContainer = neutral2.shade(600).toColor().setLuminance(94f), 80 | surfaceContainerLow = neutral2.shade(600).toColor().setLuminance(96f), 81 | surfaceContainerHigh = neutral2.shade(600).toColor().setLuminance(92f), 82 | surfaceContainerHighest = neutral2.shade(10-0).toColor(), 83 | outlineVariant = neutral2.shade(200).toColor(), 84 | scrim = neutral2.shade(1000).toColor(), 85 | surfaceBright = neutral2.shade(600).toColor().setLuminance(98f), 86 | surfaceDim = neutral2.shade(600).toColor().setLuminance(87f), 87 | surfaceTint = accent1.shade(600).toColor(), 88 | ) 89 | } 90 | } 91 | 92 | private fun Int.toColor(): Color { 93 | return Color(this) 94 | } 95 | 96 | internal fun Color.setLuminance(newLuminance: Float): Color { 97 | if ((newLuminance < 0.0001) or (newLuminance > 99.9999)) { 98 | // aRGBFromLstar() from monet ColorUtil.java 99 | val y = 100 * labInvf((newLuminance + 16) / 116) 100 | val component = delinearized(y) 101 | return Color( 102 | /* red = */ component, 103 | /* green = */ component, 104 | /* blue = */ component, 105 | ) 106 | } 107 | 108 | val sLAB = this.convert(ColorSpaces.CieLab) 109 | return Color( 110 | /* luminance = */ newLuminance, 111 | /* a = */ sLAB.component2(), 112 | /* b = */ sLAB.component3(), 113 | colorSpace = ColorSpaces.CieLab 114 | ) 115 | .convert(ColorSpaces.Srgb) 116 | } 117 | 118 | private fun labInvf(ft: Float): Float { 119 | val e = 216f / 24389f 120 | val kappa = 24389f / 27f 121 | val ft3 = ft * ft * ft 122 | return if (ft3 > e) { 123 | ft3 124 | } else { 125 | (116 * ft - 16) / kappa 126 | } 127 | } 128 | 129 | private fun delinearized(rgbComponent: Float): Int { 130 | val normalized = rgbComponent / 100 131 | val delinearized = 132 | if (normalized <= 0.0031308) { 133 | normalized * 12.92 134 | } else { 135 | 1.055 * normalized.toDouble().pow(1.0 / 2.4) - 0.055 136 | } 137 | return (delinearized * 255.0).roundToInt().coerceAtLeast(0).coerceAtMost(255) 138 | } 139 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/hct/ViewingConditions.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.hct 2 | 3 | import dev.zwander.compose.libmonet.utils.ColorUtils 4 | import dev.zwander.compose.libmonet.utils.MathUtils.clampDouble 5 | import dev.zwander.compose.libmonet.utils.MathUtils.lerp 6 | import kotlin.math.PI 7 | import kotlin.math.cbrt 8 | import kotlin.math.exp 9 | import kotlin.math.max 10 | import kotlin.math.pow 11 | import kotlin.math.sqrt 12 | 13 | 14 | /** 15 | * In traditional color spaces, a color can be identified solely by the observer's measurement of 16 | * the color. Color appearance models such as CAM16 also use information about the environment where 17 | * the color was observed, known as the viewing conditions. 18 | * 19 | * 20 | * For example, white under the traditional assumption of a midday sun white point is accurately 21 | * measured as a slightly chromatic blue by CAM16. (roughly, hue 203, chroma 3, lightness 100) 22 | * 23 | * 24 | * This class caches intermediate values of the CAM16 conversion process that depend only on 25 | * viewing conditions, enabling speed ups. 26 | */ 27 | class ViewingConditions 28 | /** 29 | * Parameters are intermediate values of the CAM16 conversion process. Their names are shorthand 30 | * for technical color science terminology, this class would not benefit from documenting them 31 | * individually. A brief overview is available in the CAM16 specification, and a complete overview 32 | * requires a color science textbook, such as Fairchild's Color Appearance Models. 33 | */ private constructor( 34 | val n: Double, 35 | val aw: Double, 36 | val nbb: Double, 37 | val ncb: Double, 38 | val c: Double, 39 | val nc: Double, 40 | val rgbD: DoubleArray, 41 | val fl: Double, 42 | val flRoot: Double, 43 | val z: Double 44 | ) { 45 | companion object { 46 | /** sRGB-like viewing conditions. */ 47 | val DEFAULT: ViewingConditions = defaultWithBackgroundLstar(50.0) 48 | 49 | /** 50 | * Create ViewingConditions from a simple, physically relevant, set of parameters. 51 | * 52 | * @param whitePoint White point, measured in the XYZ color space. default = D65, or sunny day 53 | * afternoon 54 | * @param adaptingLuminance The luminance of the adapting field. Informally, how bright it is in 55 | * the room where the color is viewed. Can be calculated from lux by multiplying lux by 56 | * 0.0586. default = 11.72, or 200 lux. 57 | * @param backgroundLstar The lightness of the area surrounding the color. measured by L* in 58 | * L*a*b*. default = 50.0 59 | * @param surround A general description of the lighting surrounding the color. 0 is pitch dark, 60 | * like watching a movie in a theater. 1.0 is a dimly light room, like watching TV at home at 61 | * night. 2.0 means there is no difference between the lighting on the color and around it. 62 | * default = 2.0 63 | * @param discountingIlluminant Whether the eye accounts for the tint of the ambient lighting, 64 | * such as knowing an apple is still red in green light. default = false, the eye does not 65 | * perform this process on self-luminous objects like displays. 66 | */ 67 | fun make( 68 | whitePoint: DoubleArray, 69 | adaptingLuminance: Double, 70 | backgroundLstar: Double, 71 | surround: Double, 72 | discountingIlluminant: Boolean 73 | ): ViewingConditions { 74 | // A background of pure black is non-physical and leads to infinities that represent the idea 75 | // that any color viewed in pure black can't be seen. 76 | var backgroundLstar = backgroundLstar 77 | backgroundLstar = max(0.1, backgroundLstar) 78 | // Transform white point XYZ to 'cone'/'rgb' responses 79 | val matrix = Cam16.XYZ_TO_CAM16RGB 80 | val xyz = whitePoint 81 | val rW = (xyz[0] * matrix[0][0]) + (xyz[1] * matrix[0][1]) + (xyz[2] * matrix[0][2]) 82 | val gW = (xyz[0] * matrix[1][0]) + (xyz[1] * matrix[1][1]) + (xyz[2] * matrix[1][2]) 83 | val bW = (xyz[0] * matrix[2][0]) + (xyz[1] * matrix[2][1]) + (xyz[2] * matrix[2][2]) 84 | val f = 0.8 + (surround / 10.0) 85 | val c: Double = 86 | (if ((f >= 0.9)) 87 | lerp(0.59, 0.69, ((f - 0.9) * 10.0)) 88 | else 89 | lerp(0.525, 0.59, ((f - 0.8) * 10.0))).toDouble() 90 | var d = 91 | if (discountingIlluminant) 92 | 1.0 93 | else 94 | f * (1.0 - ((1.0 / 3.6) * exp((-adaptingLuminance - 42.0) / 92.0))) 95 | d = clampDouble(0.0, 1.0, d) 96 | val nc = f 97 | val rgbD = 98 | doubleArrayOf( 99 | d * (100.0 / rW) + 1.0 - d, 100 | d * (100.0 / gW) + 1.0 - d, 101 | d * (100.0 / bW) + 1.0 - d 102 | ) 103 | val k = 1.0 / (5.0 * adaptingLuminance + 1.0) 104 | val k4 = k * k * k * k 105 | val k4F = 1.0 - k4 106 | val fl = (k4 * adaptingLuminance) + (0.1 * k4F * k4F * cbrt(5.0 * adaptingLuminance)) 107 | val n: Double = (ColorUtils.yFromLstar(backgroundLstar) / whitePoint[1]) 108 | val z = 1.48 + sqrt(n) 109 | val nbb = 0.725 / n.pow(0.2) 110 | val ncb = nbb 111 | val rgbAFactors = 112 | doubleArrayOf( 113 | (fl * rgbD[0] * rW / 100.0).pow(0.42), 114 | (fl * rgbD[1] * gW / 100.0).pow(0.42), 115 | (fl * rgbD[2] * bW / 100.0).pow(0.42) 116 | ) 117 | 118 | val rgbA = 119 | doubleArrayOf( 120 | (400.0 * rgbAFactors[0]) / (rgbAFactors[0] + 27.13), 121 | (400.0 * rgbAFactors[1]) / (rgbAFactors[1] + 27.13), 122 | (400.0 * rgbAFactors[2]) / (rgbAFactors[2] + 27.13) 123 | ) 124 | 125 | val aw = ((2.0 * rgbA[0]) + rgbA[1] + (0.05 * rgbA[2])) * nbb 126 | return ViewingConditions(n, aw, nbb, ncb, c, nc, rgbD, fl, fl.pow(0.25), z) 127 | } 128 | 129 | /** 130 | * Create sRGB-like viewing conditions with a custom background lstar. 131 | * 132 | * 133 | * Default viewing conditions have a lstar of 50, midgray. 134 | */ 135 | fun defaultWithBackgroundLstar(lstar: Double): ViewingConditions { 136 | return make( 137 | ColorUtils.whitePointD65(), 138 | (200.0 / PI * ColorUtils.yFromLstar(50.0) / 100f), 139 | lstar, 140 | 2.0, 141 | false 142 | ) 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/monet/ColorUtils.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.monet 2 | 3 | import androidx.compose.ui.graphics.Color 4 | import androidx.compose.ui.graphics.toArgb 5 | import kotlin.math.abs 6 | import kotlin.math.max 7 | import kotlin.math.min 8 | import kotlin.math.pow 9 | import kotlin.math.round 10 | 11 | 12 | @Suppress("FunctionName", "MemberVisibilityCanBePrivate") 13 | object ColorUtils { 14 | fun XYZToColor( 15 | x: Double, 16 | y: Double, 17 | z: Double 18 | ): Int { 19 | var r = (x * 3.2406 + y * -1.5372 + z * -0.4986) / 100 20 | var g = (x * -0.9689 + y * 1.8758 + z * 0.0415) / 100 21 | var b = (x * 0.0557 + y * -0.2040 + z * 1.0570) / 100 22 | r = if (r > 0.0031308) 1.055 * r.pow(1 / 2.4) - 0.055 else 12.92 * r 23 | g = if (g > 0.0031308) 1.055 * g.pow(1 / 2.4) - 0.055 else 12.92 * g 24 | b = if (b > 0.0031308) 1.055 * b.pow(1 / 2.4) - 0.055 else 12.92 * b 25 | 26 | return Color( 27 | red = r.coerceIn(0.0, 1.0).toFloat(), 28 | green = g.coerceIn(0.0, 1.0).toFloat(), 29 | blue = b.coerceIn(0.0, 1.0).toFloat(), 30 | ).toArgb() 31 | } 32 | 33 | /** 34 | * Convert a color appearance model representation to an ARGB color. 35 | * 36 | * Note: the returned color may have a lower chroma than requested. Whether a chroma is 37 | * available depends on luminance. For example, there's no such thing as a high chroma light 38 | * red, due to the limitations of our eyes and/or physics. If the requested chroma is 39 | * unavailable, the highest possible chroma at the requested luminance is returned. 40 | * 41 | * @param hue hue, in degrees, in CAM coordinates 42 | * @param chroma chroma in CAM coordinates. 43 | * @param lstar perceptual luminance, L* in L*a*b* 44 | */ 45 | fun CAMToColor(hue: Double, chroma: Double, lstar: Double): Int { 46 | return Cam.getInt(hue, chroma, lstar) 47 | } 48 | 49 | /** 50 | * Convert the ARGB color to its HSL (hue-saturation-lightness) components. 51 | * 52 | * * outHsl[0] is Hue [0 .. 360) 53 | * * outHsl[1] is Saturation [0...1] 54 | * * outHsl[2] is Lightness [0...1] 55 | * 56 | * 57 | * @param color the ARGB color to convert. The alpha component is ignored 58 | * @param outHsl 3-element array which holds the resulting HSL components 59 | */ 60 | fun colorToHSL(color: Int, outHsl: DoubleArray) { 61 | val c = Color(color) 62 | 63 | RGBToHSL( 64 | (c.red * 255).toInt(), 65 | (c.green * 255).toInt(), 66 | (c.blue * 255).toInt(), 67 | outHsl 68 | ) 69 | } 70 | 71 | /** 72 | * Convert RGB components to HSL (hue-saturation-lightness). 73 | * 74 | * * outHsl[0] is Hue [0 .. 360) 75 | * * outHsl[1] is Saturation [0...1] 76 | * * outHsl[2] is Lightness [0...1] 77 | * 78 | * 79 | * @param r red component value [0..255] 80 | * @param g green component value [0..255] 81 | * @param b blue component value [0..255] 82 | * @param outHsl 3-element array which holds the resulting HSL components 83 | */ 84 | fun RGBToHSL( 85 | r: Int, 86 | g: Int, 87 | b: Int, 88 | outHsl: DoubleArray 89 | ) { 90 | val rf = r / 255.0 91 | val gf = g / 255.0 92 | val bf = b / 255.0 93 | val max: Double = max(rf, max(gf, bf)) 94 | val min: Double = min(rf, min(gf, bf)) 95 | val deltaMaxMin = max - min 96 | var h: Double 97 | val s: Double 98 | val l = (max + min) / 2f 99 | if (max == min) { 100 | // Monochromatic 101 | s = 0.0 102 | h = s 103 | } else { 104 | h = when (max) { 105 | rf -> { 106 | (gf - bf) / deltaMaxMin % 6f 107 | } 108 | gf -> { 109 | (bf - rf) / deltaMaxMin + 2f 110 | } 111 | else -> { 112 | (rf - gf) / deltaMaxMin + 4f 113 | } 114 | } 115 | s = deltaMaxMin / (1f - abs(2f * l - 1f)) 116 | } 117 | h = h * 60f % 360f 118 | if (h < 0) { 119 | h += 360f 120 | } 121 | outHsl[0] = h.coerceIn(0.0, 360.0) 122 | outHsl[1] = s.coerceIn(0.0, 1.0) 123 | outHsl[2] = l.coerceIn(0.0, 1.0) 124 | } 125 | 126 | /** 127 | * Set the alpha component of `color` to be `alpha`. 128 | */ 129 | fun setAlphaComponent( 130 | color: Int, 131 | alpha: Int 132 | ): Int { 133 | if (alpha < 0 || alpha > 255) { 134 | throw IllegalArgumentException("alpha must be between 0 and 255.") 135 | } 136 | return color and 0x00ffffff or (alpha shl 24) 137 | } 138 | 139 | /** 140 | * Convert HSL (hue-saturation-lightness) components to a RGB color. 141 | * 142 | * * hsl[0] is Hue [0 .. 360) 143 | * * hsl[1] is Saturation [0...1] 144 | * * hsl[2] is Lightness [0...1] 145 | * 146 | * If hsv values are out of range, they are pinned. 147 | * 148 | * @param hsl 3-element array which holds the input HSL components 149 | * @return the resulting RGB color 150 | */ 151 | fun HSLToColor(hsl: DoubleArray): Int { 152 | val h = hsl[0] 153 | val s = hsl[1] 154 | val l = hsl[2] 155 | 156 | val c = ((1f - abs((2 * l - 1f))) * s).toFloat() 157 | val m = l - 0.5f * c 158 | val x = (c * (1f - abs(((h / 60f % 2f) - 1f)))).toFloat() 159 | 160 | val hueSegment = h.toInt() / 60 161 | 162 | var r = 0 163 | var g = 0 164 | var b = 0 165 | 166 | when (hueSegment) { 167 | 0 -> { 168 | r = round(255 * (c + m)).toInt() 169 | g = round(255 * (x + m)).toInt() 170 | b = round(255 * m).toInt() 171 | } 172 | 173 | 1 -> { 174 | r = round(255 * (x + m)).toInt() 175 | g = round(255 * (c + m)).toInt() 176 | b = round(255 * m).toInt() 177 | } 178 | 179 | 2 -> { 180 | r = round(255 * m).toInt() 181 | g = round(255 * (c + m)).toInt() 182 | b = round(255 * (x + m)).toInt() 183 | } 184 | 185 | 3 -> { 186 | r = round(255 * m).toInt() 187 | g = round(255 * (x + m)).toInt() 188 | b = round(255 * (c + m)).toInt() 189 | } 190 | 191 | 4 -> { 192 | r = round(255 * (x + m)).toInt() 193 | g = round(255 * m).toInt() 194 | b = round(255 * (c + m)).toInt() 195 | } 196 | 197 | 5, 6 -> { 198 | r = round(255 * (c + m)).toInt() 199 | g = round(255 * m).toInt() 200 | b = round(255 * (x + m)).toInt() 201 | } 202 | } 203 | 204 | r = r.coerceIn(0..255) 205 | g = g.coerceIn(0..255) 206 | b = b.coerceIn(0..255) 207 | 208 | return Color(r, g, b).toArgb() 209 | } 210 | 211 | } -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/monet/Frame.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.monet 2 | 3 | import kotlin.math.PI 4 | import kotlin.math.cbrt 5 | import kotlin.math.exp 6 | import kotlin.math.sqrt 7 | 8 | /** 9 | * The frame, or viewing conditions, where a color was seen. Used, along with a color, to create a 10 | * color appearance model representing the color. 11 | * 12 | * 13 | * To convert a traditional color to a color appearance model, it requires knowing what 14 | * conditions the color was observed in. Our perception of color depends on, for example, the tone 15 | * of the light illuminating the color, how bright that light was, etc. 16 | * 17 | * 18 | * This class is modelled separately from the color appearance model itself because there are a 19 | * number of calculations during the color => CAM conversion process that depend only on the viewing 20 | * conditions. Caching those calculations in a Frame instance saves a significant amount of time. 21 | */ 22 | class Frame private constructor( 23 | val n: Double, 24 | val aw: Double, 25 | val nbb: Double, 26 | val ncb: Double, 27 | val c: Double, 28 | val nc: Double, 29 | val rgbD: DoubleArray, 30 | val fl: Double, 31 | val flRoot: Double, 32 | val z: Double 33 | ) { 34 | 35 | companion object { 36 | // Standard viewing conditions assumed in RGB specification - Stokes, Anderson, Chandrasekar, 37 | // Motta - A Standard Default Color Space for the Internet: sRGB, 1996. 38 | // 39 | // White point = D65 40 | // Luminance of adapting field: 200 / Pi / 5, units are cd/m^2. 41 | // sRGB ambient illuminance = 64 lux (per sRGB spec). However, the spec notes this is 42 | // artificially low and based on monitors in 1990s. Use 200, the sRGB spec says this is the 43 | // real average, and a survey of lux values on Wikipedia confirms this is a comfortable 44 | // default: somewhere between a very dark overcast day and office lighting. 45 | // Per CAM16 introduction paper (Li et al, 2017) Ew = pi * lw, and La = lw * Yb/Yw 46 | // Ew = ambient environment luminance, in lux. 47 | // Yb/Yw is taken to be midgray, ~20% relative luminance (XYZ Y 18.4, CIELAB L* 50). 48 | // Therefore La = (Ew / pi) * .184 49 | // La = 200 / pi * .184 50 | // Image surround to 10 degrees = ~20% relative luminance = CIELAB L* 50 51 | // 52 | // Not from sRGB standard: 53 | // Surround = average, 2.0. 54 | // Discounting illuminant = false, doesn't occur for self-luminous displays 55 | val DEFAULT = make( 56 | CamUtils.WHITE_POINT_D65, 57 | (200.0 / PI * CamUtils.yFromLstar(50.0) / 100.0), 58 | 50.0, 59 | 2.0, 60 | false 61 | ) 62 | 63 | /** Create a custom frame. */ 64 | fun make( 65 | whitepoint: DoubleArray, adaptingLuminance: Double, 66 | backgroundLstar: Double, surround: Double, discountingIlluminant: Boolean 67 | ): Frame { 68 | // Transform white point XYZ to 'cone'/'rgb' responses 69 | val matrix = CamUtils.XYZ_TO_CAM16RGB 70 | val rW = 71 | whitepoint[0] * matrix[0][0] + whitepoint[1] * matrix[0][1] + whitepoint[2] * matrix[0][2] 72 | val gW = 73 | whitepoint[0] * matrix[1][0] + whitepoint[1] * matrix[1][1] + whitepoint[2] * matrix[1][2] 74 | val bW = 75 | whitepoint[0] * matrix[2][0] + whitepoint[1] * matrix[2][1] + whitepoint[2] * matrix[2][2] 76 | 77 | // Scale input surround, domain (0, 2), to CAM16 surround, domain (0.8, 1.0) 78 | val f = 0.8 + surround / 10.0 79 | // "Exponential non-linearity" 80 | val c: Double = if (f >= 0.9) lerp( 81 | 0.59, 0.69, 82 | (f - 0.9) * 10.0 83 | ) else lerp( 84 | 0.525, 0.59, (f - 0.8) * 10.0 85 | ) 86 | // Calculate degree of adaptation to illuminant 87 | var d = 88 | if (discountingIlluminant) 1.0 else f * (1.0 - 1.0f / 3.6 * exp( 89 | ((-adaptingLuminance - 42.0) / 92.0) 90 | )) 91 | // Per Li et al, if D is greater than 1 or less than 0, set it to 1 or 0. 92 | d = if (d > 1.0) 1.0 else if (d < 0.0) 0.0 else d 93 | // Chromatic induction factor 94 | 95 | // Cone responses to the whitepoint, adjusted for illuminant discounting. 96 | // 97 | // Why use 100.0 instead of the white point's relative luminance? 98 | // 99 | // Some papers and implementations, for both CAM02 and CAM16, use the Y 100 | // value of the reference white instead of 100. Fairchild's Color Appearance 101 | // Models (3rd edition) notes that this is in error: it was included in the 102 | // CIE 2004a report on CIECAM02, but, later parts of the conversion process 103 | // account for scaling of appearance relative to the white point relative 104 | // luminance. This part should simply use 100 as luminance. 105 | val rgbD = doubleArrayOf( 106 | d * (100.0 / rW) + 1.0 - d, d * (100.0 / gW) + 1.0 - d, 107 | d * (100.0 / bW) + 1.0 - d 108 | ) 109 | // Luminance-level adaptation factor 110 | val k = 1.0 / (5.0 * adaptingLuminance + 1.0) 111 | val k4 = k * k * k * k 112 | val k4F = 1.0 - k4 113 | val fl: Double = k4 * adaptingLuminance + 0.1 * k4F * k4F * cbrt( 114 | 5.0 * adaptingLuminance 115 | ) 116 | 117 | // Intermediate factor, ratio of background relative luminance to white relative luminance 118 | val n = CamUtils.yFromLstar(backgroundLstar) / whitepoint[1] 119 | 120 | // Base exponential nonlinearity 121 | // note Schlomer 2018 has a typo and uses 1.58, the correct factor is 1.48 122 | val z: Double = 1.48 + sqrt(n) 123 | 124 | // Luminance-level induction factors 125 | val nbb: Double = 0.725 / pow(n, 0.2) 126 | 127 | // Discounted cone responses to the white point, adjusted for post-chromatic 128 | // adaptation perceptual nonlinearities. 129 | val rgbAFactors = doubleArrayOf( 130 | pow(fl * rgbD[0] * rW / 100.0, 0.42), 131 | pow(fl * rgbD[1] * gW / 100.0, 0.42), 132 | pow( 133 | fl * rgbD[2] * bW / 100.0, 0.42 134 | ), 135 | ) 136 | val rgbA = doubleArrayOf( 137 | 400.0 * rgbAFactors[0] / (rgbAFactors[0] + 27.13), 138 | 400.0 * rgbAFactors[1] / (rgbAFactors[1] + 27.13), 139 | 400.0 * rgbAFactors[2] / (rgbAFactors[2] + 27.13) 140 | ) 141 | val aw = (2.0 * rgbA[0] + rgbA[1] + 0.05 * rgbA[2]) * nbb 142 | return Frame( 143 | n, 144 | aw, 145 | nbb, 146 | nbb, 147 | c, 148 | f, 149 | rgbD, 150 | fl, 151 | pow(fl, 0.25), 152 | z 153 | ) 154 | } 155 | } 156 | } -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/quantize/QuantizerWsmeans.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.quantize 2 | 3 | import kotlin.math.abs 4 | import kotlin.math.min 5 | import kotlin.math.sqrt 6 | import kotlin.random.Random 7 | 8 | 9 | /** 10 | * An image quantizer that improves on the speed of a standard K-Means algorithm by implementing 11 | * several optimizations, including deduping identical pixels and a triangle inequality rule that 12 | * reduces the number of comparisons needed to identify which cluster a point should be moved to. 13 | * 14 | * 15 | * Wsmeans stands for Weighted Square Means. 16 | * 17 | * 18 | * This algorithm was designed by M. Emre Celebi, and was found in their 2011 paper, Improving 19 | * the Performance of K-Means for Color Quantization. https://arxiv.org/abs/1101.0395 20 | */ 21 | object QuantizerWsmeans { 22 | private const val MAX_ITERATIONS = 10 23 | private const val MIN_MOVEMENT_DISTANCE = 3.0 24 | 25 | /** 26 | * Reduce the number of colors needed to represented the input, minimizing the difference between 27 | * the original image and the recolored image. 28 | * 29 | * @param inputPixels Colors in ARGB format. 30 | * @param startingClusters Defines the initial state of the quantizer. Passing an empty array is 31 | * fine, the implementation will create its own initial state that leads to reproducible 32 | * results for the same inputs. Passing an array that is the result of Wu quantization leads 33 | * to higher quality results. 34 | * @param maxColors The number of colors to divide the image into. A lower number of colors may be 35 | * returned. 36 | * @return Map with keys of colors in ARGB format, values of how many of the input pixels belong 37 | * to the color. 38 | */ 39 | fun quantize( 40 | inputPixels: IntArray, startingClusters: IntArray, maxColors: Int 41 | ): Map { 42 | // Uses a seeded random number generator to ensure consistent results. 43 | val random = Random(0x42688) 44 | 45 | val pixelToCount: MutableMap = LinkedHashMap() 46 | val points = arrayOfNulls(inputPixels.size) 47 | val pixels = IntArray(inputPixels.size) 48 | val pointProvider: PointProvider = PointProviderLab() 49 | 50 | var pointCount = 0 51 | for (i in inputPixels.indices) { 52 | val inputPixel = inputPixels[i] 53 | val pixelCount = pixelToCount[inputPixel] 54 | if (pixelCount == null) { 55 | points[pointCount] = pointProvider.fromInt(inputPixel) 56 | pixels[pointCount] = inputPixel 57 | pointCount++ 58 | 59 | pixelToCount[inputPixel] = 1 60 | } else { 61 | pixelToCount[inputPixel] = pixelCount + 1 62 | } 63 | } 64 | 65 | val counts = IntArray(pointCount) 66 | for (i in 0 until pointCount) { 67 | val pixel = pixels[i] 68 | val count = pixelToCount[pixel]!! 69 | counts[i] = count 70 | } 71 | 72 | var clusterCount: Int = min(maxColors, pointCount) 73 | if (startingClusters.isNotEmpty()) { 74 | clusterCount = min(clusterCount, startingClusters.size) 75 | } 76 | 77 | val clusters = arrayOfNulls(clusterCount) 78 | var clustersCreated = 0 79 | for (i in startingClusters.indices) { 80 | clusters[i] = pointProvider.fromInt(startingClusters[i]) 81 | clustersCreated++ 82 | } 83 | 84 | val additionalClustersNeeded = clusterCount - clustersCreated 85 | if (additionalClustersNeeded > 0) { 86 | for (i in 0 until additionalClustersNeeded) {} 87 | } 88 | 89 | val clusterIndices = IntArray(pointCount) 90 | for (i in 0 until pointCount) { 91 | clusterIndices[i] = random.nextInt(clusterCount) 92 | } 93 | 94 | val indexMatrix = arrayOfNulls(clusterCount) 95 | for (i in 0 until clusterCount) { 96 | indexMatrix[i] = IntArray(clusterCount) 97 | } 98 | 99 | val distanceToIndexMatrix: Array?> = arrayOfNulls(clusterCount) 100 | for (i in 0 until clusterCount) { 101 | distanceToIndexMatrix[i] = arrayOfNulls(clusterCount) 102 | for (j in 0 until clusterCount) { 103 | distanceToIndexMatrix[i]!![j] = Distance() 104 | } 105 | } 106 | 107 | val pixelCountSums = IntArray(clusterCount) 108 | for (iteration in 0 until MAX_ITERATIONS) { 109 | for (i in 0 until clusterCount) { 110 | for (j in i + 1 until clusterCount) { 111 | val distance = pointProvider.distance(clusters[i]!!, clusters[j]!!) 112 | distanceToIndexMatrix[j]!![i]!!.distance = distance 113 | distanceToIndexMatrix[j]!![i]!!.index = i 114 | distanceToIndexMatrix[i]!![j]!!.distance = distance 115 | distanceToIndexMatrix[i]!![j]!!.index = j 116 | } 117 | distanceToIndexMatrix[i] = distanceToIndexMatrix[i]?.filterNotNull()?.sorted()?.toTypedArray() 118 | for (j in 0 until clusterCount) { 119 | indexMatrix[i]!![j] = distanceToIndexMatrix[i]!![j]!!.index 120 | } 121 | } 122 | 123 | var pointsMoved = 0 124 | for (i in 0 until pointCount) { 125 | val point = points[i]!! 126 | val previousClusterIndex = clusterIndices[i] 127 | val previousCluster = clusters[previousClusterIndex]!! 128 | val previousDistance = pointProvider.distance(point, previousCluster) 129 | 130 | var minimumDistance = previousDistance 131 | var newClusterIndex = -1 132 | for (j in 0 until clusterCount) { 133 | if (distanceToIndexMatrix[previousClusterIndex]!![j]!!.distance >= 4 * previousDistance) { 134 | continue 135 | } 136 | val distance = pointProvider.distance(point, clusters[j]!!) 137 | if (distance < minimumDistance) { 138 | minimumDistance = distance 139 | newClusterIndex = j 140 | } 141 | } 142 | if (newClusterIndex != -1) { 143 | val distanceChange = abs(sqrt(minimumDistance) - sqrt(previousDistance)) 144 | if (distanceChange > MIN_MOVEMENT_DISTANCE) { 145 | pointsMoved++ 146 | clusterIndices[i] = newClusterIndex 147 | } 148 | } 149 | } 150 | 151 | if (pointsMoved == 0 && iteration != 0) { 152 | break 153 | } 154 | 155 | val componentASums = DoubleArray(clusterCount) 156 | val componentBSums = DoubleArray(clusterCount) 157 | val componentCSums = DoubleArray(clusterCount) 158 | pixelCountSums.fill(0) 159 | for (i in 0 until pointCount) { 160 | val clusterIndex = clusterIndices[i] 161 | val point = points[i] 162 | val count = counts[i] 163 | pixelCountSums[clusterIndex] += count 164 | componentASums[clusterIndex] += (point!![0] * count) 165 | componentBSums[clusterIndex] += (point[1] * count) 166 | componentCSums[clusterIndex] += (point[2] * count) 167 | } 168 | 169 | for (i in 0 until clusterCount) { 170 | val count = pixelCountSums[i] 171 | if (count == 0) { 172 | clusters[i] = doubleArrayOf(0.0, 0.0, 0.0) 173 | continue 174 | } 175 | val a = componentASums[i] / count 176 | val b = componentBSums[i] / count 177 | val c = componentCSums[i] / count 178 | clusters[i]!![0] = a 179 | clusters[i]!![1] = b 180 | clusters[i]!![2] = c 181 | } 182 | } 183 | 184 | val argbToPopulation: MutableMap = LinkedHashMap() 185 | for (i in 0 until clusterCount) { 186 | val count = pixelCountSums[i] 187 | if (count == 0) { 188 | continue 189 | } 190 | 191 | val possibleNewCluster = pointProvider.toInt(clusters[i]!!) 192 | if (argbToPopulation.containsKey(possibleNewCluster)) { 193 | continue 194 | } 195 | 196 | argbToPopulation[possibleNewCluster] = count 197 | } 198 | 199 | return argbToPopulation 200 | } 201 | 202 | private class Distance : Comparable { 203 | var index: Int = -1 204 | var distance: Double = -1.0 205 | 206 | override fun compareTo(other: Distance): Int { 207 | return distance.compareTo(other.distance) 208 | } 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/utils/ColorUtils.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.utils 2 | 3 | import dev.zwander.compose.libmonet.utils.MathUtils.clampInt 4 | import dev.zwander.compose.libmonet.utils.MathUtils.matrixMultiply 5 | import kotlin.math.pow 6 | import kotlin.math.round 7 | 8 | 9 | /** 10 | * Color science utilities. 11 | * 12 | * 13 | * Utility methods for color science constants and color space conversions that aren't HCT or 14 | * CAM16. 15 | */ 16 | object ColorUtils { 17 | val SRGB_TO_XYZ: Array = arrayOf( 18 | doubleArrayOf(0.41233895, 0.35762064, 0.18051042), 19 | doubleArrayOf(0.2126, 0.7152, 0.0722), 20 | doubleArrayOf(0.01932141, 0.11916382, 0.95034478), 21 | ) 22 | 23 | val XYZ_TO_SRGB: Array = arrayOf( 24 | doubleArrayOf( 25 | 3.2413774792388685, -1.5376652402851851, -0.49885366846268053, 26 | ), 27 | doubleArrayOf( 28 | -0.9691452513005321, 1.8758853451067872, 0.04156585616912061, 29 | ), 30 | doubleArrayOf( 31 | 0.05562093689691305, -0.20395524564742123, 1.0571799111220335, 32 | ), 33 | ) 34 | 35 | val WHITE_POINT_D65: DoubleArray = doubleArrayOf(95.047, 100.0, 108.883) 36 | 37 | /** Converts a color from RGB components to ARGB format. */ 38 | fun argbFromRgb(red: Int, green: Int, blue: Int): Int { 39 | return (255 shl 24) or ((red and 255) shl 16) or ((green and 255) shl 8) or (blue and 255) 40 | } 41 | 42 | /** Converts a color from linear RGB components to ARGB format. */ 43 | fun argbFromLinrgb(linrgb: DoubleArray): Int { 44 | val r = delinearized(linrgb[0]) 45 | val g = delinearized(linrgb[1]) 46 | val b = delinearized(linrgb[2]) 47 | return argbFromRgb(r, g, b) 48 | } 49 | 50 | /** Returns the alpha component of a color in ARGB format. */ 51 | fun alphaFromArgb(argb: Int): Int { 52 | return (argb shr 24) and 255 53 | } 54 | 55 | /** Returns the red component of a color in ARGB format. */ 56 | fun redFromArgb(argb: Int): Int { 57 | return (argb shr 16) and 255 58 | } 59 | 60 | /** Returns the green component of a color in ARGB format. */ 61 | fun greenFromArgb(argb: Int): Int { 62 | return (argb shr 8) and 255 63 | } 64 | 65 | /** Returns the blue component of a color in ARGB format. */ 66 | fun blueFromArgb(argb: Int): Int { 67 | return argb and 255 68 | } 69 | 70 | /** Returns whether a color in ARGB format is opaque. */ 71 | fun isOpaque(argb: Int): Boolean { 72 | return alphaFromArgb(argb) >= 255 73 | } 74 | 75 | /** Converts a color from ARGB to XYZ. */ 76 | fun argbFromXyz(x: Double, y: Double, z: Double): Int { 77 | val matrix = XYZ_TO_SRGB 78 | val linearR = matrix[0][0] * x + matrix[0][1] * y + matrix[0][2] * z 79 | val linearG = matrix[1][0] * x + matrix[1][1] * y + matrix[1][2] * z 80 | val linearB = matrix[2][0] * x + matrix[2][1] * y + matrix[2][2] * z 81 | val r = delinearized(linearR) 82 | val g = delinearized(linearG) 83 | val b = delinearized(linearB) 84 | return argbFromRgb(r, g, b) 85 | } 86 | 87 | /** Converts a color from XYZ to ARGB. */ 88 | fun xyzFromArgb(argb: Int): DoubleArray { 89 | val r = linearized(redFromArgb(argb)) 90 | val g = linearized(greenFromArgb(argb)) 91 | val b = linearized(blueFromArgb(argb)) 92 | return matrixMultiply(doubleArrayOf(r, g, b), SRGB_TO_XYZ) 93 | } 94 | 95 | /** Converts a color represented in Lab color space into an ARGB integer. */ 96 | fun argbFromLab(l: Double, a: Double, b: Double): Int { 97 | val whitePoint = WHITE_POINT_D65 98 | val fy = (l + 16.0) / 116.0 99 | val fx = a / 500.0 + fy 100 | val fz = fy - b / 200.0 101 | val xNormalized = labInvf(fx) 102 | val yNormalized = labInvf(fy) 103 | val zNormalized = labInvf(fz) 104 | val x = xNormalized * whitePoint[0] 105 | val y = yNormalized * whitePoint[1] 106 | val z = zNormalized * whitePoint[2] 107 | return argbFromXyz(x, y, z) 108 | } 109 | 110 | /** 111 | * Converts a color from ARGB representation to L*a*b* representation. 112 | * 113 | * @param argb the ARGB representation of a color 114 | * @return a Lab object representing the color 115 | */ 116 | fun labFromArgb(argb: Int): DoubleArray { 117 | val linearR = linearized(redFromArgb(argb)) 118 | val linearG = linearized(greenFromArgb(argb)) 119 | val linearB = linearized(blueFromArgb(argb)) 120 | val matrix = SRGB_TO_XYZ 121 | val x = matrix[0][0] * linearR + matrix[0][1] * linearG + matrix[0][2] * linearB 122 | val y = matrix[1][0] * linearR + matrix[1][1] * linearG + matrix[1][2] * linearB 123 | val z = matrix[2][0] * linearR + matrix[2][1] * linearG + matrix[2][2] * linearB 124 | val whitePoint = WHITE_POINT_D65 125 | val xNormalized = x / whitePoint[0] 126 | val yNormalized = y / whitePoint[1] 127 | val zNormalized = z / whitePoint[2] 128 | val fx = labF(xNormalized) 129 | val fy = labF(yNormalized) 130 | val fz = labF(zNormalized) 131 | val l = 116.0 * fy - 16 132 | val a = 500.0 * (fx - fy) 133 | val b = 200.0 * (fy - fz) 134 | return doubleArrayOf(l, a, b) 135 | } 136 | 137 | /** 138 | * Converts an L* value to an ARGB representation. 139 | * 140 | * @param lstar L* in L*a*b* 141 | * @return ARGB representation of grayscale color with lightness matching L* 142 | */ 143 | fun argbFromLstar(lstar: Double): Int { 144 | val y = yFromLstar(lstar) 145 | val component = delinearized(y) 146 | return argbFromRgb(component, component, component) 147 | } 148 | 149 | /** 150 | * Computes the L* value of a color in ARGB representation. 151 | * 152 | * @param argb ARGB representation of a color 153 | * @return L*, from L*a*b*, coordinate of the color 154 | */ 155 | fun lstarFromArgb(argb: Int): Double { 156 | val y = xyzFromArgb(argb)[1] 157 | return 116.0 * labF(y / 100.0) - 16.0 158 | } 159 | 160 | /** 161 | * Converts an L* value to a Y value. 162 | * 163 | * 164 | * L* in L*a*b* and Y in XYZ measure the same quantity, luminance. 165 | * 166 | * 167 | * L* measures perceptual luminance, a linear scale. Y in XYZ measures relative luminance, a 168 | * logarithmic scale. 169 | * 170 | * @param lstar L* in L*a*b* 171 | * @return Y in XYZ 172 | */ 173 | fun yFromLstar(lstar: Double): Double { 174 | return 100.0 * labInvf((lstar + 16.0) / 116.0) 175 | } 176 | 177 | /** 178 | * Converts a Y value to an L* value. 179 | * 180 | * 181 | * L* in L*a*b* and Y in XYZ measure the same quantity, luminance. 182 | * 183 | * 184 | * L* measures perceptual luminance, a linear scale. Y in XYZ measures relative luminance, a 185 | * logarithmic scale. 186 | * 187 | * @param y Y in XYZ 188 | * @return L* in L*a*b* 189 | */ 190 | fun lstarFromY(y: Double): Double { 191 | return labF(y / 100.0) * 116.0 - 16.0 192 | } 193 | 194 | /** 195 | * Linearizes an RGB component. 196 | * 197 | * @param rgbComponent 0 <= rgb_component <= 255, represents R/G/B channel 198 | * @return 0.0 <= output <= 100.0, color channel converted to linear RGB space 199 | */ 200 | fun linearized(rgbComponent: Int): Double { 201 | val normalized = rgbComponent / 255.0 202 | return if (normalized <= 0.040449936) { 203 | normalized / 12.92 * 100.0 204 | } else { 205 | ((normalized + 0.055) / 1.055).pow(2.4) * 100.0 206 | } 207 | } 208 | 209 | /** 210 | * Delinearizes an RGB component. 211 | * 212 | * @param rgbComponent 0.0 <= rgb_component <= 100.0, represents linear R/G/B channel 213 | * @return 0 <= output <= 255, color channel converted to regular RGB space 214 | */ 215 | fun delinearized(rgbComponent: Double): Int { 216 | val normalized = rgbComponent / 100.0 217 | val delinearized = if (normalized <= 0.0031308) { 218 | normalized * 12.92 219 | } else { 220 | 1.055 * normalized.pow(1.0 / 2.4) - 0.055 221 | } 222 | return clampInt( 223 | 0, 224 | 255, 225 | round(delinearized * 255.0).toInt(), 226 | ) 227 | } 228 | 229 | /** 230 | * Returns the standard white point; white on a sunny day. 231 | * 232 | * @return The white point 233 | */ 234 | fun whitePointD65(): DoubleArray { 235 | return WHITE_POINT_D65 236 | } 237 | 238 | fun labF(t: Double): Double { 239 | val e = 216.0 / 24389.0 240 | val kappa = 24389.0 / 27.0 241 | return if (t > e) { 242 | t.pow(1.0 / 3.0) 243 | } else { 244 | (kappa * t + 16) / 116 245 | } 246 | } 247 | 248 | fun labInvf(ft: Double): Double { 249 | val e = 216.0 / 24389.0 250 | val kappa = 24389.0 / 27.0 251 | val ft3 = ft * ft * ft 252 | return if (ft3 > e) { 253 | ft3 254 | } else { 255 | (116 * ft - 16) / kappa 256 | } 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 147 | # shellcheck disable=SC3045 148 | MAX_FD=$( ulimit -H -n ) || 149 | warn "Could not query maximum file descriptor limit" 150 | esac 151 | case $MAX_FD in #( 152 | '' | soft) :;; #( 153 | *) 154 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 155 | # shellcheck disable=SC3045 156 | ulimit -n "$MAX_FD" || 157 | warn "Could not set maximum file descriptor limit to $MAX_FD" 158 | esac 159 | fi 160 | 161 | # Collect all arguments for the java command, stacking in reverse order: 162 | # * args from the command line 163 | # * the main class name 164 | # * -classpath 165 | # * -D...appname settings 166 | # * --module-path (only if needed) 167 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 168 | 169 | # For Cygwin or MSYS, switch paths to Windows format before running java 170 | if "$cygwin" || "$msys" ; then 171 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 172 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 173 | 174 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 175 | 176 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 177 | for arg do 178 | if 179 | case $arg in #( 180 | -*) false ;; # don't mess with options #( 181 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 182 | [ -e "$t" ] ;; #( 183 | *) false ;; 184 | esac 185 | then 186 | arg=$( cygpath --path --ignore --mixed "$arg" ) 187 | fi 188 | # Roll the args list around exactly as many times as the number of 189 | # args, so each arg winds up back in the position where it started, but 190 | # possibly modified. 191 | # 192 | # NB: a `for` loop captures its iteration list before it begins, so 193 | # changing the positional parameters here affects neither the number of 194 | # iterations, nor the values presented in `arg`. 195 | shift # remove old arg 196 | set -- "$@" "$arg" # push replacement arg 197 | done 198 | fi 199 | 200 | # Collect all arguments for the java command; 201 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 202 | # shell script including quotes and variable substitutions, so put them in 203 | # double quotes to make sure that they get re-expanded; and 204 | # * put everything else in single quotes, so that it's not re-expanded. 205 | 206 | set -- \ 207 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 208 | -classpath "$CLASSPATH" \ 209 | org.gradle.wrapper.GradleWrapperMain \ 210 | "$@" 211 | 212 | # Stop when "xargs" is not available. 213 | if ! command -v xargs >/dev/null 2>&1 214 | then 215 | die "xargs is not available" 216 | fi 217 | 218 | # Use "xargs" to parse quoted args. 219 | # 220 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 221 | # 222 | # In Bash we could simply go: 223 | # 224 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 225 | # set -- "${ARGS[@]}" "$@" 226 | # 227 | # but POSIX shell has neither arrays nor command substitution, so instead we 228 | # post-process each arg (as a line of input to sed) to backslash-escape any 229 | # character that might be a shell metacharacter, then use eval to reverse 230 | # that process (while maintaining the separation between arguments), and wrap 231 | # the whole thing up as a single "set" statement. 232 | # 233 | # This will of course break if any of these variables contains a newline or 234 | # an unmatched quote. 235 | # 236 | 237 | eval "set -- $( 238 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 239 | xargs -n1 | 240 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 241 | tr '\n' ' ' 242 | )" '"$@"' 243 | 244 | exec "$JAVACMD" "$@" 245 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/dynamiccolor/DynamicScheme.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.dynamiccolor 2 | 3 | import dev.zwander.compose.libmonet.hct.Hct 4 | import dev.zwander.compose.libmonet.palettes.TonalPalette 5 | import dev.zwander.compose.libmonet.utils.MathUtils.sanitizeDegreesDouble 6 | 7 | 8 | /** 9 | * Provides important settings for creating colors dynamically, and 6 color palettes. Requires: 1. A 10 | * color. (source color) 2. A theme. (Variant) 3. Whether or not its dark mode. 4. Contrast level. 11 | * (-1 to 1, currently contrast ratio 3.0 and 7.0) 12 | */ 13 | open class DynamicScheme( 14 | val sourceColorHct: Hct, 15 | val variant: Variant, 16 | val isDark: Boolean, 17 | val contrastLevel: Double, 18 | val primaryPalette: TonalPalette, 19 | val secondaryPalette: TonalPalette, 20 | val tertiaryPalette: TonalPalette, 21 | val neutralPalette: TonalPalette, 22 | val neutralVariantPalette: TonalPalette 23 | ) { 24 | val sourceColorArgb: Int = sourceColorHct.toInt() 25 | 26 | val errorPalette: TonalPalette = 27 | TonalPalette.fromHueAndChroma(25.0, 84.0) 28 | 29 | fun getHct(dynamicColor: DynamicColor): Hct { 30 | return dynamicColor.getHct(this) 31 | } 32 | 33 | fun getArgb(dynamicColor: DynamicColor): Int { 34 | return dynamicColor.getArgb(this) 35 | } 36 | 37 | val primaryPaletteKeyColor: Int 38 | get() = getArgb(MaterialDynamicColors().primaryPaletteKeyColor()) 39 | 40 | val secondaryPaletteKeyColor: Int 41 | get() = getArgb(MaterialDynamicColors().secondaryPaletteKeyColor()) 42 | 43 | val tertiaryPaletteKeyColor: Int 44 | get() = getArgb(MaterialDynamicColors().tertiaryPaletteKeyColor()) 45 | 46 | val neutralPaletteKeyColor: Int 47 | get() = getArgb(MaterialDynamicColors().neutralPaletteKeyColor()) 48 | 49 | val neutralVariantPaletteKeyColor: Int 50 | get() = getArgb(MaterialDynamicColors().neutralVariantPaletteKeyColor()) 51 | 52 | val background: Int 53 | get() = getArgb(MaterialDynamicColors().background()) 54 | 55 | val onBackground: Int 56 | get() = getArgb(MaterialDynamicColors().onBackground()) 57 | 58 | val surface: Int 59 | get() = getArgb(MaterialDynamicColors().surface()) 60 | 61 | val surfaceDim: Int 62 | get() = getArgb(MaterialDynamicColors().surfaceDim()) 63 | 64 | val surfaceBright: Int 65 | get() = getArgb(MaterialDynamicColors().surfaceBright()) 66 | 67 | val surfaceContainerLowest: Int 68 | get() = getArgb(MaterialDynamicColors().surfaceContainerLowest()) 69 | 70 | val surfaceContainerLow: Int 71 | get() = getArgb(MaterialDynamicColors().surfaceContainerLow()) 72 | 73 | val surfaceContainer: Int 74 | get() = getArgb(MaterialDynamicColors().surfaceContainer()) 75 | 76 | val surfaceContainerHigh: Int 77 | get() = getArgb(MaterialDynamicColors().surfaceContainerHigh()) 78 | 79 | val surfaceContainerHighest: Int 80 | get() = getArgb(MaterialDynamicColors().surfaceContainerHighest()) 81 | 82 | val onSurface: Int 83 | get() = getArgb(MaterialDynamicColors().onSurface()) 84 | 85 | val surfaceVariant: Int 86 | get() = getArgb(MaterialDynamicColors().surfaceVariant()) 87 | 88 | val onSurfaceVariant: Int 89 | get() = getArgb(MaterialDynamicColors().onSurfaceVariant()) 90 | 91 | val inverseSurface: Int 92 | get() = getArgb(MaterialDynamicColors().inverseSurface()) 93 | 94 | val inverseOnSurface: Int 95 | get() = getArgb(MaterialDynamicColors().inverseOnSurface()) 96 | 97 | val outline: Int 98 | get() = getArgb(MaterialDynamicColors().outline()) 99 | 100 | val outlineVariant: Int 101 | get() = getArgb(MaterialDynamicColors().outlineVariant()) 102 | 103 | val shadow: Int 104 | get() = getArgb(MaterialDynamicColors().shadow()) 105 | 106 | val scrim: Int 107 | get() = getArgb(MaterialDynamicColors().scrim()) 108 | 109 | val surfaceTint: Int 110 | get() = getArgb(MaterialDynamicColors().surfaceTint()) 111 | 112 | val primary: Int 113 | get() = getArgb(MaterialDynamicColors().primary()) 114 | 115 | val onPrimary: Int 116 | get() = getArgb(MaterialDynamicColors().onPrimary()) 117 | 118 | val primaryContainer: Int 119 | get() = getArgb(MaterialDynamicColors().primaryContainer()) 120 | 121 | val onPrimaryContainer: Int 122 | get() = getArgb(MaterialDynamicColors().onPrimaryContainer()) 123 | 124 | val inversePrimary: Int 125 | get() = getArgb(MaterialDynamicColors().inversePrimary()) 126 | 127 | val secondary: Int 128 | get() = getArgb(MaterialDynamicColors().secondary()) 129 | 130 | val onSecondary: Int 131 | get() = getArgb(MaterialDynamicColors().onSecondary()) 132 | 133 | val secondaryContainer: Int 134 | get() = getArgb(MaterialDynamicColors().secondaryContainer()) 135 | 136 | val onSecondaryContainer: Int 137 | get() = getArgb(MaterialDynamicColors().onSecondaryContainer()) 138 | 139 | val tertiary: Int 140 | get() = getArgb(MaterialDynamicColors().tertiary()) 141 | 142 | val onTertiary: Int 143 | get() = getArgb(MaterialDynamicColors().onTertiary()) 144 | 145 | val tertiaryContainer: Int 146 | get() = getArgb(MaterialDynamicColors().tertiaryContainer()) 147 | 148 | val onTertiaryContainer: Int 149 | get() = getArgb(MaterialDynamicColors().onTertiaryContainer()) 150 | 151 | val error: Int 152 | get() = getArgb(MaterialDynamicColors().error()) 153 | 154 | val onError: Int 155 | get() = getArgb(MaterialDynamicColors().onError()) 156 | 157 | val errorContainer: Int 158 | get() = getArgb(MaterialDynamicColors().errorContainer()) 159 | 160 | val onErrorContainer: Int 161 | get() = getArgb(MaterialDynamicColors().onErrorContainer()) 162 | 163 | val primaryFixed: Int 164 | get() = getArgb(MaterialDynamicColors().primaryFixed()) 165 | 166 | val primaryFixedDim: Int 167 | get() = getArgb(MaterialDynamicColors().primaryFixedDim()) 168 | 169 | val onPrimaryFixed: Int 170 | get() = getArgb(MaterialDynamicColors().onPrimaryFixed()) 171 | 172 | val onPrimaryFixedVariant: Int 173 | get() = getArgb(MaterialDynamicColors().onPrimaryFixedVariant()) 174 | 175 | val secondaryFixed: Int 176 | get() = getArgb(MaterialDynamicColors().secondaryFixed()) 177 | 178 | val secondaryFixedDim: Int 179 | get() = getArgb(MaterialDynamicColors().secondaryFixedDim()) 180 | 181 | val onSecondaryFixed: Int 182 | get() = getArgb(MaterialDynamicColors().onSecondaryFixed()) 183 | 184 | val onSecondaryFixedVariant: Int 185 | get() = getArgb(MaterialDynamicColors().onSecondaryFixedVariant()) 186 | 187 | val tertiaryFixed: Int 188 | get() = getArgb(MaterialDynamicColors().tertiaryFixed()) 189 | 190 | val tertiaryFixedDim: Int 191 | get() = getArgb(MaterialDynamicColors().tertiaryFixedDim()) 192 | 193 | val onTertiaryFixed: Int 194 | get() = getArgb(MaterialDynamicColors().onTertiaryFixed()) 195 | 196 | val onTertiaryFixedVariant: Int 197 | get() = getArgb(MaterialDynamicColors().onTertiaryFixedVariant()) 198 | 199 | val controlActivated: Int 200 | get() = getArgb(MaterialDynamicColors().controlActivated()) 201 | 202 | val controlNormal: Int 203 | get() = getArgb(MaterialDynamicColors().controlNormal()) 204 | 205 | val controlHighlight: Int 206 | get() = getArgb(MaterialDynamicColors().controlHighlight()) 207 | 208 | val textPrimaryInverse: Int 209 | get() = getArgb(MaterialDynamicColors().textPrimaryInverse()) 210 | 211 | val textSecondaryAndTertiaryInverse: Int 212 | get() = getArgb(MaterialDynamicColors().textSecondaryAndTertiaryInverse()) 213 | 214 | val textPrimaryInverseDisableOnly: Int 215 | get() = getArgb(MaterialDynamicColors().textPrimaryInverseDisableOnly()) 216 | 217 | val textSecondaryAndTertiaryInverseDisabled: Int 218 | get() = getArgb(MaterialDynamicColors().textSecondaryAndTertiaryInverseDisabled()) 219 | 220 | val textHintInverse: Int 221 | get() = getArgb(MaterialDynamicColors().textHintInverse()) 222 | 223 | companion object { 224 | /** 225 | * Given a set of hues and set of hue rotations, locate which hues the source color's hue is 226 | * between, apply the rotation at the same index as the first hue in the range, and return the 227 | * rotated hue. 228 | * 229 | * @param sourceColorHct The color whose hue should be rotated. 230 | * @param hues A set of hues. 231 | * @param rotations A set of hue rotations. 232 | * @return Color's hue with a rotation applied. 233 | */ 234 | fun getRotatedHue(sourceColorHct: Hct, hues: DoubleArray, rotations: DoubleArray): Double { 235 | val sourceHue: Double = sourceColorHct.getHue() 236 | if (rotations.size == 1) { 237 | return sanitizeDegreesDouble(sourceHue + rotations[0]) 238 | } 239 | val size = hues.size 240 | for (i in 0..(size - 2)) { 241 | val thisHue = hues[i] 242 | val nextHue = hues[i + 1] 243 | if (thisHue < sourceHue && sourceHue < nextHue) { 244 | return sanitizeDegreesDouble(sourceHue + rotations[i]) 245 | } 246 | } 247 | // If this statement executes, something is wrong, there should have been a rotation 248 | // found using the arrays. 249 | return sourceHue 250 | } 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/contrast/Contrast.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.contrast 2 | 3 | import dev.zwander.compose.libmonet.utils.ColorUtils 4 | import kotlin.math.abs 5 | import kotlin.math.max 6 | 7 | /** 8 | * Color science for contrast utilities. 9 | * 10 | * 11 | * Utility methods for calculating contrast given two colors, or calculating a color given one 12 | * color and a contrast ratio. 13 | * 14 | * 15 | * Contrast ratio is calculated using XYZ's Y. When linearized to match human perception, Y 16 | * becomes HCT's tone and L*a*b*'s' L*. 17 | */ 18 | object Contrast { 19 | // The minimum contrast ratio of two colors. 20 | // Contrast ratio equation = lighter + 5 / darker + 5, if lighter == darker, ratio == 1. 21 | const val RATIO_MIN: Double = 1.0 22 | 23 | // The maximum contrast ratio of two colors. 24 | // Contrast ratio equation = lighter + 5 / darker + 5. Lighter and darker scale from 0 to 100. 25 | // If lighter == 100, darker = 0, ratio == 21. 26 | const val RATIO_MAX: Double = 21.0 27 | const val RATIO_30: Double = 3.0 28 | const val RATIO_45: Double = 4.5 29 | const val RATIO_70: Double = 7.0 30 | 31 | // Given a color and a contrast ratio to reach, the luminance of a color that reaches that ratio 32 | // with the color can be calculated. However, that luminance may not contrast as desired, i.e. the 33 | // contrast ratio of the input color and the returned luminance may not reach the contrast ratio 34 | // asked for. 35 | // 36 | // When the desired contrast ratio and the result contrast ratio differ by more than this amount, 37 | // an error value should be returned, or the method should be documented as 'unsafe', meaning, 38 | // it will return a valid luminance but that luminance may not meet the requested contrast ratio. 39 | // 40 | // 0.04 selected because it ensures the resulting ratio rounds to the same tenth. 41 | private const val CONTRAST_RATIO_EPSILON = 0.04 42 | 43 | // Color spaces that measure luminance, such as Y in XYZ, L* in L*a*b*, or T in HCT, are known as 44 | // perceptually accurate color spaces. 45 | // 46 | // To be displayed, they must gamut map to a "display space", one that has a defined limit on the 47 | // number of colors. Display spaces include sRGB, more commonly understood as RGB/HSL/HSV/HSB. 48 | // Gamut mapping is undefined and not defined by the color space. Any gamut mapping algorithm must 49 | // choose how to sacrifice accuracy in hue, saturation, and/or lightness. 50 | // 51 | // A principled solution is to maintain lightness, thus maintaining contrast/a11y, maintain hue, 52 | // thus maintaining aesthetic intent, and reduce chroma until the color is in gamut. 53 | // 54 | // HCT chooses this solution, but, that doesn't mean it will _exactly_ matched desired lightness, 55 | // if only because RGB is quantized: RGB is expressed as a set of integers: there may be an RGB 56 | // color with, for example, 47.892 lightness, but not 47.891. 57 | // 58 | // To allow for this inherent incompatibility between perceptually accurate color spaces and 59 | // display color spaces, methods that take a contrast ratio and luminance, and return a luminance 60 | // that reaches that contrast ratio for the input luminance, purposefully darken/lighten their 61 | // result such that the desired contrast ratio will be reached even if inaccuracy is introduced. 62 | // 63 | // 0.4 is generous, ex. HCT requires much less delta. It was chosen because it provides a rough 64 | // guarantee that as long as a perceptual color space gamut maps lightness such that the resulting 65 | // lightness rounds to the same as the requested, the desired contrast ratio will be reached. 66 | private const val LUMINANCE_GAMUT_MAP_TOLERANCE = 0.4 67 | 68 | /** 69 | * Contrast ratio is a measure of legibility, its used to compare the lightness of two colors. 70 | * This method is used commonly in industry due to its use by WCAG. 71 | * 72 | * 73 | * To compare lightness, the colors are expressed in the XYZ color space, where Y is lightness, 74 | * also known as relative luminance. 75 | * 76 | * 77 | * The equation is ratio = lighter Y + 5 / darker Y + 5. 78 | */ 79 | fun ratioOfYs(y1: Double, y2: Double): Double { 80 | val lighter: Double = max(y1, y2) 81 | val darker = if ((lighter == y2)) y1 else y2 82 | return (lighter + 5.0) / (darker + 5.0) 83 | } 84 | 85 | /** 86 | * Contrast ratio of two tones. T in HCT, L* in L*a*b*. Also known as luminance or perpectual 87 | * luminance. 88 | * 89 | * 90 | * Contrast ratio is defined using Y in XYZ, relative luminance. However, relative luminance is 91 | * linear to number of photons, not to perception of lightness. Perceptual luminance, L* in 92 | * L*a*b*, T in HCT, is. Designers prefer color spaces with perceptual luminance since they're 93 | * accurate to the eye. 94 | * 95 | * 96 | * Y and L* are pure functions of each other, so it possible to use perceptually accurate color 97 | * spaces, and measure contrast, and measure contrast in a much more understandable way: instead 98 | * of a ratio, a linear difference. This allows a designer to determine what they need to adjust a 99 | * color's lightness to in order to reach their desired contrast, instead of guessing & checking 100 | * with hex codes. 101 | */ 102 | fun ratioOfTones(t1: Double, t2: Double): Double { 103 | return ratioOfYs(ColorUtils.yFromLstar(t1), ColorUtils.yFromLstar(t2)) 104 | } 105 | 106 | /** 107 | * Returns T in HCT, L* in L*a*b* >= tone parameter that ensures ratio with input T/L*. Returns -1 108 | * if ratio cannot be achieved. 109 | * 110 | * @param tone Tone return value must contrast with. 111 | * @param ratio Desired contrast ratio of return value and tone parameter. 112 | */ 113 | fun lighter(tone: Double, ratio: Double): Double { 114 | if (tone < 0.0 || tone > 100.0) { 115 | return -1.0 116 | } 117 | // Invert the contrast ratio equation to determine lighter Y given a ratio and darker Y. 118 | val darkY: Double = ColorUtils.yFromLstar(tone) 119 | val lightY = ratio * (darkY + 5.0) - 5.0 120 | if (lightY < 0.0 || lightY > 100.0) { 121 | return -1.0 122 | } 123 | val realContrast = ratioOfYs(lightY, darkY) 124 | val delta = abs(realContrast - ratio) 125 | if (realContrast < ratio && delta > CONTRAST_RATIO_EPSILON) { 126 | return -1.0 127 | } 128 | 129 | val returnValue: Double = ColorUtils.lstarFromY(lightY) + LUMINANCE_GAMUT_MAP_TOLERANCE 130 | // NOMUTANTS--important validation step; functions it is calling may change implementation. 131 | if (returnValue < 0 || returnValue > 100) { 132 | return -1.0 133 | } 134 | return returnValue 135 | } 136 | 137 | /** 138 | * Tone >= tone parameter that ensures ratio. 100 if ratio cannot be achieved. 139 | * 140 | * 141 | * This method is unsafe because the returned value is guaranteed to be in bounds, but, the in 142 | * bounds return value may not reach the desired ratio. 143 | * 144 | * @param tone Tone return value must contrast with. 145 | * @param ratio Desired contrast ratio of return value and tone parameter. 146 | */ 147 | fun lighterUnsafe(tone: Double, ratio: Double): Double { 148 | val lighterSafe = lighter(tone, ratio) 149 | return if (lighterSafe < 0.0) 100.0 else lighterSafe 150 | } 151 | 152 | /** 153 | * Returns T in HCT, L* in L*a*b* <= tone parameter that ensures ratio with input T/L*. Returns -1 154 | * if ratio cannot be achieved. 155 | * 156 | * @param tone Tone return value must contrast with. 157 | * @param ratio Desired contrast ratio of return value and tone parameter. 158 | */ 159 | fun darker(tone: Double, ratio: Double): Double { 160 | if (tone < 0.0 || tone > 100.0) { 161 | return -1.0 162 | } 163 | // Invert the contrast ratio equation to determine darker Y given a ratio and lighter Y. 164 | val lightY: Double = ColorUtils.yFromLstar(tone) 165 | val darkY = ((lightY + 5.0) / ratio) - 5.0 166 | if (darkY < 0.0 || darkY > 100.0) { 167 | return -1.0 168 | } 169 | val realContrast = ratioOfYs(lightY, darkY) 170 | val delta = abs(realContrast - ratio) 171 | if (realContrast < ratio && delta > CONTRAST_RATIO_EPSILON) { 172 | return -1.0 173 | } 174 | 175 | // For information on 0.4 constant, see comment in lighter(tone, ratio). 176 | val returnValue: Double = ColorUtils.lstarFromY(darkY) - LUMINANCE_GAMUT_MAP_TOLERANCE 177 | // NOMUTANTS--important validation step; functions it is calling may change implementation. 178 | if (returnValue < 0 || returnValue > 100) { 179 | return -1.0 180 | } 181 | return returnValue 182 | } 183 | 184 | /** 185 | * Tone <= tone parameter that ensures ratio. 0 if ratio cannot be achieved. 186 | * 187 | * 188 | * This method is unsafe because the returned value is guaranteed to be in bounds, but, the in 189 | * bounds return value may not reach the desired ratio. 190 | * 191 | * @param tone Tone return value must contrast with. 192 | * @param ratio Desired contrast ratio of return value and tone parameter. 193 | */ 194 | fun darkerUnsafe(tone: Double, ratio: Double): Double { 195 | val darkerSafe = darker(tone, ratio) 196 | return max(0.0, darkerSafe) 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/monet/CamUtils.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.monet 2 | 3 | import androidx.compose.ui.graphics.Color 4 | import dev.zwander.compose.util.blue 5 | import dev.zwander.compose.util.green 6 | import dev.zwander.compose.util.red 7 | import kotlin.math.cbrt 8 | import kotlin.math.pow 9 | import kotlin.math.round 10 | 11 | 12 | /** 13 | * Collection of methods for transforming between color spaces. 14 | * 15 | * 16 | * Methods are named $xFrom$Y. For example, lstarFromInt() returns L* from an ARGB integer. 17 | * 18 | * 19 | * These methods, generally, convert colors between the L*a*b*, XYZ, and sRGB spaces. 20 | * 21 | * 22 | * L*a*b* is a perceptually accurate color space. This is particularly important in the L* 23 | * dimension: it measures luminance and unlike lightness measures traditionally used in UI work via 24 | * RGB or HSL, this luminance transitions smoothly, permitting creation of pleasing shades of a 25 | * color, and more pleasing transitions between colors. 26 | * 27 | * 28 | * XYZ is commonly used as an intermediate color space for converting between one color space to 29 | * another. For example, to convert RGB to L*a*b*, first RGB is converted to XYZ, then XYZ is 30 | * convered to L*a*b*. 31 | * 32 | * 33 | * sRGB is a "specification originated from work in 1990s through cooperation by Hewlett-Packard 34 | * and Microsoft, and it was designed to be a standard definition of RGB for the internet, which it 35 | * indeed became...The standard is based on a sampling of computer monitors at the time...The whole 36 | * idea of sRGB is that if everyone assumed that RGB meant the same thing, then the results would be 37 | * consistent, and reasonably good. It worked." - Fairchild, Color Models and Systems: Handbook of 38 | * Color Psychology, 2015 39 | */ 40 | @Suppress("unused", "MemberVisibilityCanBePrivate") 41 | object CamUtils { 42 | // Transforms XYZ color space coordinates to 'cone'/'RGB' responses in CAM16. 43 | val XYZ_TO_CAM16RGB = arrayOf( 44 | doubleArrayOf(0.401288, 0.650173, -0.051461), 45 | doubleArrayOf(-0.250268, 1.204414, 0.045854), 46 | doubleArrayOf(-0.002079, 0.048952, 0.953127) 47 | ) 48 | 49 | // Transforms 'cone'/'RGB' responses in CAM16 to XYZ color space coordinates. 50 | val CAM16RGB_TO_XYZ = arrayOf( 51 | doubleArrayOf(1.86206786, -1.01125463, 0.14918677), 52 | doubleArrayOf(0.38752654, 0.62144744, -0.00897398), 53 | doubleArrayOf(-0.01584150, -0.03412294, 1.04996444) 54 | ) 55 | 56 | // Need this, XYZ coordinates in internal ColorUtils are private 57 | // sRGB specification has D65 whitepoint - Stokes, Anderson, Chandrasekar, Motta - A Standard 58 | // Default Color Space for the Internet: sRGB, 1996 59 | val WHITE_POINT_D65 = doubleArrayOf(95.047, 100.0, 108.883) 60 | 61 | // This is a more precise sRGB to XYZ transformation matrix than traditionally 62 | // used. It was derived using Schlomer's technique of transforming the xyY 63 | // primaries to XYZ, then applying a correction to ensure mapping from sRGB 64 | // 1, 1, 1 to the reference white point, D65. 65 | private val SRGB_TO_XYZ = arrayOf( 66 | doubleArrayOf(0.41233895, 0.35762064, 0.18051042), 67 | doubleArrayOf(0.2126, 0.7152, 0.0722), 68 | doubleArrayOf(0.01932141, 0.11916382, 0.95034478) 69 | ) 70 | private val XYZ_TO_SRGB = arrayOf( 71 | doubleArrayOf( 72 | 3.2413774792388685, -1.5376652402851851, -0.49885366846268053 73 | ), doubleArrayOf( 74 | -0.9691452513005321, 1.8758853451067872, 0.04156585616912061 75 | ), doubleArrayOf( 76 | 0.05562093689691305, -0.20395524564742123, 1.0571799111220335 77 | ) 78 | ) 79 | 80 | /** 81 | * The signum function. 82 | * 83 | * @return 1 if num > 0, -1 if num < 0, and 0 if num = 0 84 | */ 85 | fun signum(num: Double): Int { 86 | return if (num < 0) { 87 | -1 88 | } else if (num == 0.0) { 89 | 0 90 | } else { 91 | 1 92 | } 93 | } 94 | 95 | /** 96 | * Converts an L* value to an ARGB representation. 97 | * 98 | * @param lstar L* in L*a*b* 99 | * @return ARGB representation of grayscale color with lightness matching L* 100 | */ 101 | fun argbFromLstar(lstar: Double): Int { 102 | val fy = (lstar + 16.0) / 116.0 103 | val kappa = 24389.0 / 27.0 104 | val epsilon = 216.0 / 24389.0 105 | val lExceedsEpsilonKappa = lstar > 8.0 106 | val y = if (lExceedsEpsilonKappa) fy * fy * fy else lstar / kappa 107 | val cubeExceedEpsilon = fy * fy * fy > epsilon 108 | val x = if (cubeExceedEpsilon) fy * fy * fy else lstar / kappa 109 | val z = if (cubeExceedEpsilon) fy * fy * fy else lstar / kappa 110 | val whitePoint = WHITE_POINT_D65 111 | return argbFromXyz(x * whitePoint[0], y * whitePoint[1], z * whitePoint[2]) 112 | } 113 | 114 | /** Converts a color from ARGB to XYZ. */ 115 | fun argbFromXyz(x: Double, y: Double, z: Double): Int { 116 | val matrix = XYZ_TO_SRGB 117 | val linearR = matrix[0][0] * x + matrix[0][1] * y + matrix[0][2] * z 118 | val linearG = matrix[1][0] * x + matrix[1][1] * y + matrix[1][2] * z 119 | val linearB = matrix[2][0] * x + matrix[2][1] * y + matrix[2][2] * z 120 | val r = delinearized(linearR) 121 | val g = delinearized(linearG) 122 | val b = delinearized(linearB) 123 | return argbFromRgb(r, g, b) 124 | } 125 | 126 | /** Converts a color from linear RGB components to ARGB format. */ 127 | fun argbFromLinrgb(linrgb: DoubleArray): Int { 128 | val r = delinearized(linrgb[0]) 129 | val g = delinearized(linrgb[1]) 130 | val b = delinearized(linrgb[2]) 131 | return argbFromRgb(r, g, b) 132 | } 133 | 134 | /** Converts a color from linear RGB components to ARGB format. */ 135 | fun argbFromLinrgbComponents(r: Double, g: Double, b: Double): Int { 136 | return argbFromRgb(delinearized(r), delinearized(g), delinearized(b)) 137 | } 138 | 139 | /** 140 | * Delinearizes an RGB component. 141 | * 142 | * @param rgbComponent 0.0 <= rgb_component <= 100.0, represents linear R/G/B channel 143 | * @return 0 <= output <= 255, color channel converted to regular RGB space 144 | */ 145 | fun delinearized(rgbComponent: Double): Int { 146 | val normalized = rgbComponent / 100.0 147 | val delinearized = if (normalized <= 0.0031308) { 148 | normalized * 12.92 149 | } else { 150 | 1.055 * normalized.pow(1.0 / 2.4) - 0.055 151 | } 152 | return clampInt(0, 255, round(delinearized * 255.0).toInt()) 153 | } 154 | 155 | /** 156 | * Clamps an integer between two integers. 157 | * 158 | * @return input when min <= input <= max, and either min or max otherwise. 159 | */ 160 | fun clampInt(min: Int, max: Int, input: Int): Int { 161 | if (input < min) { 162 | return min 163 | } else if (input > max) { 164 | return max 165 | } 166 | return input 167 | } 168 | 169 | /** Converts a color from RGB components to ARGB format. */ 170 | fun argbFromRgb(red: Int, green: Int, blue: Int): Int { 171 | return 255 shl 24 or (red and 255 shl 16) or (green and 255 shl 8) or (blue and 255) 172 | } 173 | 174 | fun intFromLstar(lstar: Double): Int { 175 | if (lstar < 1) { 176 | return -0x1000000 177 | } else if (lstar > 99) { 178 | return -0x1 179 | } 180 | 181 | // XYZ to LAB conversion routine, assume a and b are 0. 182 | val fy = (lstar + 16.0) / 116.0 183 | 184 | // fz = fx = fy because a and b are 0 185 | val kappa = 24389f / 27f 186 | val epsilon = 216f / 24389f 187 | val lExceedsEpsilonKappa = lstar > 8.0f 188 | val yT = if (lExceedsEpsilonKappa) fy * fy * fy else lstar / kappa 189 | val cubeExceedEpsilon = fy * fy * fy > epsilon 190 | val xT = if (cubeExceedEpsilon) fy * fy * fy else (116f * fy - 16f) / kappa 191 | val zT = if (cubeExceedEpsilon) fy * fy * fy else (116f * fy - 16f) / kappa 192 | return ColorUtils.XYZToColor( 193 | (xT * WHITE_POINT_D65[0]), 194 | (yT * WHITE_POINT_D65[1]), (zT * WHITE_POINT_D65[2]) 195 | ) 196 | } 197 | 198 | /** Returns L* from L*a*b*, perceptual luminance, from an ARGB integer (ColorInt). */ 199 | fun lstarFromInt(argb: Int): Double { 200 | return lstarFromY(yFromInt(argb)) 201 | } 202 | 203 | fun lstarFromY(y: Double): Double { 204 | val yMutable = y / 100.0 205 | val e = 216.0 / 24389.0 206 | val yIntermediate = if (yMutable <= e) { 207 | return 24389.0 / 27.0 * yMutable 208 | } else { 209 | cbrt(yMutable) 210 | } 211 | return 116.0 * yIntermediate - 16.0 212 | } 213 | 214 | fun yFromInt(argb: Int): Double { 215 | val r = linearized(Color.red(argb)) 216 | val g = linearized(Color.green(argb)) 217 | val b = linearized(Color.blue(argb)) 218 | val matrix = SRGB_TO_XYZ 219 | val y = r * matrix[1][0] + g * matrix[1][1] + b * matrix[1][2] 220 | return y 221 | } 222 | 223 | fun xyzFromInt(argb: Int): DoubleArray { 224 | val r = linearized(Color.red(argb)) 225 | val g = linearized(Color.green(argb)) 226 | val b = linearized(Color.blue(argb)) 227 | val matrix = SRGB_TO_XYZ 228 | val x = r * matrix[0][0] + g * matrix[0][1] + b * matrix[0][2] 229 | val y = r * matrix[1][0] + g * matrix[1][1] + b * matrix[1][2] 230 | val z = r * matrix[2][0] + g * matrix[2][1] + b * matrix[2][2] 231 | return doubleArrayOf(x, y, z) 232 | } 233 | 234 | /** 235 | * Converts an L* value to a Y value. 236 | * 237 | * 238 | * L* in L*a*b* and Y in XYZ measure the same quantity, luminance. 239 | * 240 | * 241 | * L* measures perceptual luminance, a linear scale. Y in XYZ measures relative luminance, a 242 | * logarithmic scale. 243 | * 244 | * @param lstar L* in L*a*b* 245 | * @return Y in XYZ 246 | */ 247 | fun yFromLstar(lstar: Double): Double { 248 | val ke = 8.0 249 | return if (lstar > ke) { 250 | ((lstar + 16.0) / 116.0).pow(3.0) * 100.0 251 | } else { 252 | lstar / (24389.0 / 27.0) * 100.0 253 | } 254 | } 255 | 256 | fun linearized(rgbComponent: Int): Double { 257 | val normalized = rgbComponent.toDouble() / 255.0 258 | return if (normalized <= 0.04045) { 259 | normalized / 12.92 * 100.0 260 | } else { 261 | ((normalized + 0.055) / 1.055).pow(2.4) * 100.0 262 | } 263 | } 264 | } -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/monet/WallpaperColors.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.monet 2 | 3 | import androidx.compose.ui.graphics.Color 4 | import androidx.compose.ui.graphics.toArgb 5 | import kotlin.math.abs 6 | import kotlin.math.round 7 | 8 | 9 | /** 10 | * Provides information about the colors of a wallpaper. 11 | * 12 | * 13 | * Exposes the 3 most visually representative colors of a wallpaper. Can be either 14 | * [WallpaperColors.primaryColor], [WallpaperColors.secondaryColor] 15 | * or [WallpaperColors.tertiaryColor]. 16 | */ 17 | @Suppress("unused", "MemberVisibilityCanBePrivate") 18 | class WallpaperColors { 19 | annotation class ColorsHints 20 | 21 | private val mMainColors: MutableList 22 | private val mAllColors: MutableMap 23 | 24 | /** 25 | * Returns the color hints for this instance. 26 | * @return The color hints. 27 | */ 28 | @get:ColorsHints 29 | var colorHints: Int 30 | private set 31 | 32 | /** 33 | * Constructs a new object from three colors. 34 | * 35 | * @param primaryColor Primary color. 36 | * @param secondaryColor Secondary color. 37 | * @param tertiaryColor Tertiary color. 38 | */ 39 | constructor( 40 | primaryColor: Color, secondaryColor: Color?, 41 | tertiaryColor: Color? 42 | ) : this(primaryColor, secondaryColor, tertiaryColor, 0) { 43 | 44 | // Calculate dark theme support based on primary color. 45 | val tmpHsl = DoubleArray(3) 46 | ColorUtils.colorToHSL(primaryColor.toArgb(), tmpHsl) 47 | val luminance = tmpHsl[2] 48 | if (luminance < DARK_THEME_MEAN_LUMINANCE) { 49 | colorHints = colorHints or HINT_SUPPORTS_DARK_THEME 50 | } 51 | } 52 | 53 | /** 54 | * Constructs a new object from three colors, where hints can be specified. 55 | * 56 | * @param primaryColor Primary color. 57 | * @param secondaryColor Secondary color. 58 | * @param tertiaryColor Tertiary color. 59 | * @param colorHints A combination of color hints. 60 | */ 61 | constructor( 62 | primaryColor: Color, secondaryColor: Color?, 63 | tertiaryColor: Color?, @ColorsHints colorHints: Int 64 | ) { 65 | mMainColors = ArrayList(3) 66 | mAllColors = HashMap() 67 | mMainColors.add(primaryColor) 68 | mAllColors[primaryColor.toArgb()] = 0 69 | if (secondaryColor != null) { 70 | mMainColors.add(secondaryColor) 71 | mAllColors[secondaryColor.toArgb()] = 0 72 | } 73 | if (tertiaryColor != null) { 74 | if (secondaryColor == null) { 75 | throw IllegalArgumentException( 76 | "tertiaryColor can't be specified when " 77 | + "secondaryColor is null" 78 | ) 79 | } 80 | mMainColors.add(tertiaryColor) 81 | mAllColors[tertiaryColor.toArgb()] = 0 82 | } 83 | this.colorHints = colorHints 84 | } 85 | 86 | /** 87 | * Constructs a new object from a set of colors, where hints can be specified. 88 | * 89 | * @param colorToPopulation Map with keys of colors, and value representing the number of 90 | * occurrences of color in the wallpaper. 91 | * @param colorHints A combination of color hints. 92 | * @hide 93 | * @see WallpaperColors.HINT_SUPPORTS_DARK_TEXT 94 | */ 95 | constructor( 96 | colorToPopulation: MutableMap, 97 | @ColorsHints colorHints: Int 98 | ) { 99 | mAllColors = colorToPopulation 100 | val colorToCam: MutableMap = HashMap() 101 | for (color: Int in colorToPopulation.keys) { 102 | colorToCam[color] = Cam.fromInt(color) 103 | } 104 | val hueProportions = hueProportions(colorToCam, colorToPopulation) 105 | val colorToHueProportion = colorToHueProportion( 106 | colorToPopulation.keys, colorToCam, hueProportions 107 | ) 108 | val colorToScore: MutableMap = HashMap() 109 | for (mapEntry: Map.Entry in colorToHueProportion.entries) { 110 | val color = mapEntry.key 111 | val proportion = mapEntry.value 112 | val score = score(colorToCam[color], proportion) 113 | colorToScore[color] = score 114 | } 115 | val mapEntries: ArrayList> = 116 | ArrayList(colorToScore.entries) 117 | mapEntries.sortWith { a: Map.Entry, b: Map.Entry -> 118 | b.value.compareTo( 119 | (a.value)!! 120 | ) 121 | } 122 | val colorsByScoreDescending: MutableList = ArrayList() 123 | for (colorToScoreEntry: Map.Entry in mapEntries) { 124 | colorsByScoreDescending.add(colorToScoreEntry.key) 125 | } 126 | val mainColorInts: MutableList = ArrayList() 127 | findSeedColorLoop@ for (color: Int in colorsByScoreDescending) { 128 | val cam = colorToCam[color] 129 | for (otherColor: Int in mainColorInts) { 130 | val otherCam = colorToCam[otherColor] 131 | if (hueDiff(cam, otherCam) < 15) { 132 | continue@findSeedColorLoop 133 | } 134 | } 135 | mainColorInts.add(color) 136 | } 137 | val mainColors: MutableList = 138 | ArrayList() 139 | for (colorInt: Int in mainColorInts) { 140 | mainColors.add(Color(colorInt)) 141 | } 142 | mMainColors = mainColors 143 | this.colorHints = colorHints 144 | } 145 | 146 | val primaryColor: Color 147 | /** 148 | * Gets the most visually representative color of the wallpaper. 149 | * "Visually representative" means easily noticeable in the image, 150 | * probably happening at high frequency. 151 | * 152 | * @return A color. 153 | */ 154 | get() = mMainColors[0] 155 | 156 | val secondaryColor: Color? 157 | /** 158 | * Gets the second most preeminent color of the wallpaper. Can be null. 159 | * 160 | * @return A color, may be null. 161 | */ 162 | get() = if (mMainColors.size < 2) null else mMainColors[1] 163 | 164 | val tertiaryColor: Color? 165 | /** 166 | * Gets the third most preeminent color of the wallpaper. Can be null. 167 | * 168 | * @return A color, may be null. 169 | */ 170 | get() = if (mMainColors.size < 3) null else mMainColors[2] 171 | val mainColors: List 172 | /** 173 | * List of most preeminent colors, sorted by importance. 174 | * 175 | * @return List of colors. 176 | * @hide 177 | */ 178 | get() = mMainColors.toList() 179 | val allColors: Map 180 | /** 181 | * Map of all colors. Key is rgb integer, value is importance of color. 182 | * 183 | * @return List of colors. 184 | * @hide 185 | */ 186 | get() = mAllColors.toMap() 187 | 188 | override fun equals(other: Any?): Boolean { 189 | return other is WallpaperColors && 190 | ((mMainColors == other.mMainColors) && 191 | (mAllColors == other.mAllColors) && 192 | (colorHints == other.colorHints)) 193 | } 194 | 195 | override fun hashCode(): Int { 196 | return (31 * mMainColors.hashCode() * mAllColors.hashCode()) + colorHints 197 | } 198 | 199 | @OptIn(ExperimentalStdlibApi::class) 200 | override fun toString(): String { 201 | val colors = StringBuilder() 202 | for (i in mMainColors.indices) { 203 | colors.append(mMainColors[i].toArgb().toHexString()).append(" ") 204 | } 205 | return "[WallpaperColors: " + colors.toString() + "h: " + colorHints + "]" 206 | } 207 | 208 | companion object { 209 | private const val DEBUG_DARK_PIXELS = false 210 | 211 | /** 212 | * Specifies that dark text is preferred over the current wallpaper for best presentation. 213 | * 214 | * 215 | * eg. A launcher may set its text color to black if this flag is specified. 216 | */ 217 | const val HINT_SUPPORTS_DARK_TEXT = 1 shl 0 218 | 219 | /** 220 | * Specifies that dark theme is preferred over the current wallpaper for best presentation. 221 | * 222 | * 223 | * eg. A launcher may set its drawer color to black if this flag is specified. 224 | */ 225 | const val HINT_SUPPORTS_DARK_THEME = 1 shl 1 226 | 227 | /** 228 | * Specifies that this object was generated by extracting colors from a bitmap. 229 | * @hide 230 | */ 231 | const val HINT_FROM_BITMAP = 1 shl 2 232 | 233 | // Maximum size that a bitmap can have to keep our calculations valid 234 | private const val MAX_BITMAP_SIZE = 112 235 | 236 | // Even though we have a maximum size, we'll mainly match bitmap sizes 237 | // using the area instead. This way our comparisons are aspect ratio independent. 238 | private const val MAX_WALLPAPER_EXTRACTION_AREA = MAX_BITMAP_SIZE * MAX_BITMAP_SIZE 239 | 240 | // When extracting the main colors, only consider colors 241 | // present in at least MIN_COLOR_OCCURRENCE of the image 242 | private const val MIN_COLOR_OCCURRENCE = 0.05f 243 | 244 | // Decides when dark theme is optimal for this wallpaper 245 | private const val DARK_THEME_MEAN_LUMINANCE = 0.3f 246 | 247 | // Minimum mean luminosity that an image needs to have to support dark text 248 | private const val BRIGHT_IMAGE_MEAN_LUMINANCE = 0.7f 249 | 250 | // We also check if the image has dark pixels in it, 251 | // to avoid bright images with some dark spots. 252 | private const val DARK_PIXEL_CONTRAST = 5.5f 253 | private const val MAX_DARK_AREA = 0.05f 254 | 255 | private fun hueDiff(a: Cam?, b: Cam?): Double { 256 | return (180f - abs(abs(a!!.hue - b!!.hue) - 180f)) 257 | } 258 | 259 | private fun score(cam: Cam?, proportion: Double): Double { 260 | return cam!!.chroma + (proportion * 100) 261 | } 262 | 263 | private fun colorToHueProportion( 264 | colors: Set, 265 | colorToCam: Map, hueProportions: DoubleArray 266 | ): Map { 267 | val colorToHueProportion: MutableMap = HashMap() 268 | for (color: Int in colors) { 269 | val hue = wrapDegrees( 270 | round( 271 | colorToCam[color]!!.hue 272 | ).toInt() 273 | ) 274 | var proportion = 0.0 275 | for (i in hue - 15 until (hue + 15)) { 276 | proportion += hueProportions[wrapDegrees(i)] 277 | } 278 | colorToHueProportion[color] = proportion 279 | } 280 | return colorToHueProportion 281 | } 282 | 283 | private fun wrapDegrees(degrees: Int): Int { 284 | return if (degrees < 0) { 285 | (degrees % 360) + 360 286 | } else if (degrees >= 360) { 287 | degrees % 360 288 | } else { 289 | degrees 290 | } 291 | } 292 | 293 | private fun hueProportions( 294 | colorToCam: Map, 295 | colorToPopulation: Map 296 | ): DoubleArray { 297 | val proportions = DoubleArray(360) 298 | var totalPopulation = 0.0 299 | for (entry: Map.Entry in colorToPopulation.entries) { 300 | totalPopulation += entry.value.toDouble() 301 | } 302 | for (entry: Map.Entry in colorToPopulation.entries) { 303 | val color = entry.key 304 | val population = (colorToPopulation[color])!! 305 | val cam = colorToCam[color] 306 | val hue = wrapDegrees( 307 | round( 308 | cam!!.hue 309 | ).toInt() 310 | ) 311 | proportions[hue] = proportions[hue] + (population.toDouble() / totalPopulation) 312 | } 313 | return proportions 314 | } 315 | } 316 | } -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/dev/zwander/compose/libmonet/temperature/TemperatureCache.kt: -------------------------------------------------------------------------------- 1 | package dev.zwander.compose.libmonet.temperature 2 | 3 | import dev.zwander.compose.libmonet.hct.Hct 4 | import dev.zwander.compose.libmonet.utils.ColorUtils 5 | import dev.zwander.compose.libmonet.utils.MathUtils.sanitizeDegreesDouble 6 | import dev.zwander.compose.libmonet.utils.MathUtils.sanitizeDegreesInt 7 | import kotlin.math.PI 8 | import kotlin.math.abs 9 | import kotlin.math.atan2 10 | import kotlin.math.cos 11 | import kotlin.math.floor 12 | import kotlin.math.hypot 13 | import kotlin.math.pow 14 | import kotlin.math.round 15 | 16 | 17 | /** 18 | * Design utilities using color temperature theory. 19 | * 20 | * 21 | * Analogous colors, complementary color, and cache to efficiently, lazily, generate data for 22 | * calculations when needed. 23 | */ 24 | class TemperatureCache 25 | /** 26 | * Create a cache that allows calculation of ex. complementary and analogous colors. 27 | * 28 | * @param input Color to find complement/analogous colors of. Any colors will have the same tone, 29 | * and chroma as the input color, modulo any restrictions due to the other hues having lower 30 | * limits on chroma. 31 | */(private val input: Hct) { 32 | 33 | private var precomputedComplement: Hct? = null 34 | private var precomputedHctsByTemp: List? = null 35 | private var precomputedHctsByHue: List? = null 36 | private var precomputedTempsByHct: Map? = null 37 | 38 | val complement: Hct 39 | /** 40 | * A color that complements the input color aesthetically. 41 | * 42 | * 43 | * In art, this is usually described as being across the color wheel. History of this shows 44 | * intent as a color that is just as cool-warm as the input color is warm-cool. 45 | */ 46 | get() { 47 | if (precomputedComplement != null) { 48 | return precomputedComplement!! 49 | } 50 | 51 | val coldestHue = coldest.getHue() 52 | val coldestTemp = tempsByHct!![coldest]!! 53 | 54 | val warmestHue = warmest.getHue() 55 | val warmestTemp = tempsByHct!![warmest]!! 56 | val range = warmestTemp - coldestTemp 57 | val startHueIsColdestToWarmest = isBetween(input.getHue(), coldestHue, warmestHue) 58 | val startHue = if (startHueIsColdestToWarmest) warmestHue else coldestHue 59 | val endHue = if (startHueIsColdestToWarmest) coldestHue else warmestHue 60 | val directionOfRotation = 1.0 61 | var smallestError = 1000.0 62 | var answer: Hct? = hctsByHue[round(input.getHue()).toInt()] 63 | 64 | val complementRelativeTemp = (1.0 - getRelativeTemperature(input)) 65 | // Find the color in the other section, closest to the inverse percentile 66 | // of the input color. This is the complement. 67 | var hueAddend = 0.0 68 | while (hueAddend <= 360.0) { 69 | val hue: Double = sanitizeDegreesDouble( 70 | startHue + directionOfRotation * hueAddend 71 | ) 72 | if (!isBetween(hue, startHue, endHue)) { 73 | hueAddend += 1.0 74 | continue 75 | } 76 | val possibleAnswer = hctsByHue[round(hue).toInt()] 77 | val relativeTemp = 78 | (tempsByHct!![possibleAnswer]!! - coldestTemp) / range 79 | val error = abs(complementRelativeTemp - relativeTemp) 80 | if (error < smallestError) { 81 | smallestError = error 82 | answer = possibleAnswer 83 | } 84 | hueAddend += 1.0 85 | } 86 | precomputedComplement = answer 87 | return precomputedComplement!! 88 | } 89 | 90 | val analogousColors: List 91 | /** 92 | * 5 colors that pair well with the input color. 93 | * 94 | * 95 | * The colors are equidistant in temperature and adjacent in hue. 96 | */ 97 | get() = getAnalogousColors(5, 12) 98 | 99 | /** 100 | * A set of colors with differing hues, equidistant in temperature. 101 | * 102 | * 103 | * In art, this is usually described as a set of 5 colors on a color wheel divided into 12 104 | * sections. This method allows provision of either of those values. 105 | * 106 | * 107 | * Behavior is undefined when count or divisions is 0. When divisions < count, colors repeat. 108 | * 109 | * @param count The number of colors to return, includes the input color. 110 | * @param divisions The number of divisions on the color wheel. 111 | */ 112 | fun getAnalogousColors(count: Int, divisions: Int): List { 113 | // The starting hue is the hue of the input color. 114 | val startHue: Int = round(input.getHue()).toInt() 115 | val startHct = hctsByHue[startHue] 116 | var lastTemp = getRelativeTemperature(startHct) 117 | 118 | val allColors: MutableList = ArrayList() 119 | allColors.add(startHct) 120 | 121 | var absoluteTotalTempDelta = 0.0 122 | for (i in 0..359) { 123 | val hue: Int = sanitizeDegreesInt(startHue + i) 124 | val hct = hctsByHue[hue] 125 | val temp = getRelativeTemperature(hct) 126 | val tempDelta = abs(temp - lastTemp) 127 | lastTemp = temp 128 | absoluteTotalTempDelta += tempDelta 129 | } 130 | 131 | var hueAddend = 1 132 | val tempStep = absoluteTotalTempDelta / divisions.toDouble() 133 | var totalTempDelta = 0.0 134 | lastTemp = getRelativeTemperature(startHct) 135 | while (allColors.size < divisions) { 136 | val hue: Int = sanitizeDegreesInt(startHue + hueAddend) 137 | val hct = hctsByHue[hue] 138 | val temp = getRelativeTemperature(hct) 139 | val tempDelta = abs(temp - lastTemp) 140 | totalTempDelta += tempDelta 141 | 142 | var desiredTotalTempDeltaForIndex = (allColors.size * tempStep) 143 | var indexSatisfied = totalTempDelta >= desiredTotalTempDeltaForIndex 144 | var indexAddend = 1 145 | // Keep adding this hue to the answers until its temperature is 146 | // insufficient. This ensures consistent behavior when there aren't 147 | // `divisions` discrete steps between 0 and 360 in hue with `tempStep` 148 | // delta in temperature between them. 149 | // 150 | // For example, white and black have no analogues: there are no other 151 | // colors at T100/T0. Therefore, they should just be added to the array 152 | // as answers. 153 | while (indexSatisfied && allColors.size < divisions) { 154 | allColors.add(hct) 155 | desiredTotalTempDeltaForIndex = ((allColors.size + indexAddend) * tempStep) 156 | indexSatisfied = totalTempDelta >= desiredTotalTempDeltaForIndex 157 | indexAddend++ 158 | } 159 | lastTemp = temp 160 | hueAddend++ 161 | 162 | if (hueAddend > 360) { 163 | while (allColors.size < divisions) { 164 | allColors.add(hct) 165 | } 166 | break 167 | } 168 | } 169 | 170 | val answers: MutableList = ArrayList() 171 | answers.add(input) 172 | 173 | val ccwCount = floor((count.toDouble() - 1.0) / 2.0).toInt() 174 | for (i in 1 until (ccwCount + 1)) { 175 | var index = 0 - i 176 | while (index < 0) { 177 | index += allColors.size 178 | } 179 | if (index >= allColors.size) { 180 | index %= allColors.size 181 | } 182 | answers.add(0, allColors[index]) 183 | } 184 | 185 | val cwCount = count - ccwCount - 1 186 | for (i in 1 until (cwCount + 1)) { 187 | var index = i 188 | while (index < 0) { 189 | index += allColors.size 190 | } 191 | if (index >= allColors.size) index = index % allColors.size 192 | answers.add(allColors[index]) 193 | } 194 | 195 | return answers 196 | } 197 | 198 | /** 199 | * Temperature relative to all colors with the same chroma and tone. 200 | * 201 | * @param hct HCT to find the relative temperature of. 202 | * @return Value on a scale from 0 to 1. 203 | */ 204 | fun getRelativeTemperature(hct: Hct): Double { 205 | val range = tempsByHct!![warmest]!! - tempsByHct!![coldest]!! 206 | val differenceFromColdest = 207 | tempsByHct!![hct]!! - tempsByHct!![coldest]!! 208 | // Handle when there's no difference in temperature between warmest and 209 | // coldest: for example, at T100, only one color is available, white. 210 | if (range == 0.0) { 211 | return 0.5 212 | } 213 | return differenceFromColdest / range 214 | } 215 | 216 | private val coldest: Hct 217 | /** Coldest color with same chroma and tone as input. */ 218 | get() = hctsByTemp!![0] 219 | 220 | private val hctsByHue: List 221 | /** 222 | * HCTs for all colors with the same chroma/tone as the input. 223 | * 224 | * 225 | * Sorted by hue, ex. index 0 is hue 0. 226 | */ 227 | get() { 228 | if (precomputedHctsByHue != null) { 229 | return precomputedHctsByHue!! 230 | } 231 | val hcts: MutableList = ArrayList() 232 | var hue = 0.0 233 | while (hue <= 360.0) { 234 | val colorAtHue = Hct.from(hue, input.getChroma(), input.getTone()) 235 | hcts.add(colorAtHue) 236 | hue += 1.0 237 | } 238 | precomputedHctsByHue = hcts.toList() 239 | return precomputedHctsByHue!! 240 | } 241 | 242 | private val hctsByTemp: List? 243 | /** 244 | * HCTs for all colors with the same chroma/tone as the input. 245 | * 246 | * 247 | * Sorted from coldest first to warmest last. 248 | */ 249 | get() { 250 | if (precomputedHctsByTemp != null) { 251 | return precomputedHctsByTemp 252 | } 253 | 254 | val hcts: MutableList = ArrayList(hctsByHue) 255 | hcts.add(input) 256 | hcts.sortWith { hct1, hct2 -> 257 | val hct1Double = tempsByHct!![hct1] 258 | val hct2Double = tempsByHct!![hct2] 259 | 260 | hct1Double!!.compareTo(hct2Double!!) 261 | } 262 | precomputedHctsByTemp = hcts 263 | return precomputedHctsByTemp 264 | } 265 | 266 | private val tempsByHct: Map? 267 | /** Keys of HCTs in getHctsByTemp, values of raw temperature. */ 268 | get() { 269 | if (precomputedTempsByHct != null) { 270 | return precomputedTempsByHct 271 | } 272 | 273 | val allHcts: MutableList = ArrayList(hctsByHue) 274 | allHcts.add(input) 275 | 276 | val temperaturesByHct: MutableMap = HashMap() 277 | for (hct in allHcts) { 278 | temperaturesByHct[hct] = rawTemperature(hct) 279 | } 280 | 281 | precomputedTempsByHct = temperaturesByHct 282 | return precomputedTempsByHct 283 | } 284 | 285 | private val warmest: Hct 286 | /** Warmest color with same chroma and tone as input. */ 287 | get() = hctsByTemp!![hctsByTemp!!.size - 1] 288 | 289 | companion object { 290 | /** 291 | * Value representing cool-warm factor of a color. Values below 0 are considered cool, above, 292 | * warm. 293 | * 294 | * 295 | * Color science has researched emotion and harmony, which art uses to select colors. Warm-cool 296 | * is the foundation of analogous and complementary colors. See: - Li-Chen Ou's Chapter 19 in 297 | * Handbook of Color Psychology (2015). - Josef Albers' Interaction of Color chapters 19 and 21. 298 | * 299 | * 300 | * Implementation of Ou, Woodcock and Wright's algorithm, which uses Lab/LCH color space. 301 | * Return value has these properties:

302 | * - Values below 0 are cool, above 0 are warm.

303 | * - Lower bound: -9.66. Chroma is infinite. Assuming max of Lab chroma 130.

304 | * - Upper bound: 8.61. Chroma is infinite. Assuming max of Lab chroma 130. 305 | */ 306 | fun rawTemperature(color: Hct): Double { 307 | val lab: DoubleArray = ColorUtils.labFromArgb(color.toInt()) 308 | val hue: Double = sanitizeDegreesDouble( 309 | atan2( 310 | lab[2], lab[1] 311 | ) * 180.0 / PI 312 | ) 313 | val chroma = hypot(lab[1], lab[2]) 314 | return (-0.5 315 | + (0.02 316 | * chroma.pow(1.07) * cos( 317 | sanitizeDegreesDouble( 318 | hue - 50.0 319 | ) * PI / 180.0 320 | ))) 321 | } 322 | 323 | /** Determines if an angle is between two other angles, rotating clockwise. */ 324 | private fun isBetween(angle: Double, a: Double, b: Double): Boolean { 325 | if (a < b) { 326 | return angle in a..b 327 | } 328 | return a <= angle || angle <= b 329 | } 330 | } 331 | } 332 | --------------------------------------------------------------------------------