├── .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 |
5 |
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 |
9 |
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 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/.idea/appInsightsSettings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
21 |
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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
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 | 
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 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
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 |
--------------------------------------------------------------------------------