├── Android.bp ├── src └── main │ └── kotlin │ └── dev │ └── kdrag0n │ └── monet │ └── theme │ ├── ColorScheme.kt │ ├── MaterialYouTargets.kt │ └── DynamicColorScheme.kt └── LICENSE /Android.bp: -------------------------------------------------------------------------------- 1 | java_library { 2 | name: "themelib", 3 | static_libs: [ 4 | "colorkt", 5 | ], 6 | srcs: [ 7 | "src/main/**/*.kt", 8 | ], 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/kdrag0n/monet/theme/ColorScheme.kt: -------------------------------------------------------------------------------- 1 | package dev.kdrag0n.monet.theme 2 | 3 | import dev.kdrag0n.colorkt.Color 4 | 5 | typealias ColorSwatch = Map 6 | 7 | abstract class ColorScheme { 8 | abstract val neutral1: ColorSwatch 9 | abstract val neutral2: ColorSwatch 10 | 11 | abstract val accent1: ColorSwatch 12 | abstract val accent2: ColorSwatch 13 | abstract val accent3: ColorSwatch 14 | 15 | // Helpers 16 | val neutralColors: List 17 | get() = listOf(neutral1, neutral2) 18 | val accentColors: List 19 | get() = listOf(accent1, accent2, accent3) 20 | } 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Danny Lin 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 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/kdrag0n/monet/theme/MaterialYouTargets.kt: -------------------------------------------------------------------------------- 1 | package dev.kdrag0n.monet.theme 2 | 3 | import dev.kdrag0n.colorkt.Color 4 | import dev.kdrag0n.colorkt.cam.Zcam 5 | import dev.kdrag0n.colorkt.cam.Zcam.Companion.toZcam 6 | import dev.kdrag0n.colorkt.rgb.LinearSrgb.Companion.toLinear 7 | import dev.kdrag0n.colorkt.rgb.Srgb 8 | import dev.kdrag0n.colorkt.tristimulus.CieXyz.Companion.toXyz 9 | import dev.kdrag0n.colorkt.tristimulus.CieXyzAbs.Companion.toAbs 10 | import dev.kdrag0n.colorkt.ucs.lab.CieLab 11 | 12 | /* 13 | * Default target colors, conforming to Material You standards. 14 | * 15 | * Derived from AOSP and Pixel defaults. 16 | */ 17 | class MaterialYouTargets( 18 | private val chromaFactor: Double = 1.0, 19 | useLinearLightness: Boolean, 20 | val cond: Zcam.ViewingConditions, 21 | ) : ColorScheme() { 22 | companion object { 23 | // Linear ZCAM lightness 24 | private val LINEAR_LIGHTNESS_MAP = mapOf( 25 | 0 to 100.0, 26 | 10 to 99.0, 27 | 20 to 98.0, 28 | 50 to 95.0, 29 | 100 to 90.0, 30 | 200 to 80.0, 31 | 300 to 70.0, 32 | 400 to 60.0, 33 | 500 to 50.0, 34 | 600 to 40.0, 35 | 650 to 35.0, 36 | 700 to 30.0, 37 | 800 to 20.0, 38 | 900 to 10.0, 39 | 950 to 5.0, 40 | 1000 to 0.0, 41 | ) 42 | 43 | // CIELAB lightness from AOSP defaults 44 | private val CIELAB_LIGHTNESS_MAP = LINEAR_LIGHTNESS_MAP 45 | .map { it.key to if (it.value == 50.0) 49.6 else it.value } 46 | .toMap() 47 | 48 | // Accent colors from Pixel defaults 49 | private val REF_ACCENT1_COLORS = listOf( 50 | 0xd3e3fd, 51 | 0xa8c7fa, 52 | 0x7cacf8, 53 | 0x4c8df6, 54 | 0x1b6ef3, 55 | 0x0b57d0, 56 | 0x0842a0, 57 | 0x062e6f, 58 | 0x041e49, 59 | ) 60 | 61 | private const val ACCENT1_REF_CHROMA_FACTOR = 1.2 62 | } 63 | 64 | override val neutral1: ColorSwatch 65 | override val neutral2: ColorSwatch 66 | 67 | override val accent1: ColorSwatch 68 | override val accent2: ColorSwatch 69 | override val accent3: ColorSwatch 70 | 71 | init { 72 | val lightnessMap = if (useLinearLightness) { 73 | LINEAR_LIGHTNESS_MAP 74 | } else { 75 | CIELAB_LIGHTNESS_MAP 76 | .map { it.key to cielabL(it.value) } 77 | .toMap() 78 | } 79 | 80 | // Accent chroma from Pixel defaults 81 | // We use the most chromatic color as the reference 82 | // A-1 chroma = avg(default Pixel Blue shades 100-900) 83 | // Excluding very bright variants (10, 50) to avoid light bias 84 | // A-1 > A-3 > A-2 85 | val accent1Chroma = calcAccent1Chroma() * ACCENT1_REF_CHROMA_FACTOR 86 | val accent2Chroma = accent1Chroma / 3 87 | val accent3Chroma = accent2Chroma * 2 88 | 89 | // Custom neutral chroma 90 | val neutral1Chroma = accent1Chroma / 8 91 | val neutral2Chroma = accent1Chroma / 5 92 | 93 | neutral1 = shadesWithChroma(neutral1Chroma, lightnessMap) 94 | neutral2 = shadesWithChroma(neutral2Chroma, lightnessMap) 95 | 96 | accent1 = shadesWithChroma(accent1Chroma, lightnessMap) 97 | accent2 = shadesWithChroma(accent2Chroma, lightnessMap) 98 | accent3 = shadesWithChroma(accent3Chroma, lightnessMap) 99 | } 100 | 101 | private fun cielabL(l: Double) = CieLab( 102 | L = l, 103 | a = 0.0, 104 | b = 0.0, 105 | ).toXyz().toAbs(cond.referenceWhite.y).toZcam(cond, include2D = false).lightness 106 | 107 | private fun calcAccent1Chroma() = REF_ACCENT1_COLORS 108 | .map { Srgb(it).toLinear().toXyz().toAbs(cond.referenceWhite.y).toZcam(cond, include2D = false).chroma } 109 | .average() 110 | 111 | private fun shadesWithChroma( 112 | chroma: Double, 113 | lightnessMap: Map, 114 | ): Map { 115 | // Adjusted chroma 116 | val chromaAdj = chroma * chromaFactor 117 | 118 | return lightnessMap.map { 119 | it.key to Zcam( 120 | lightness = it.value, 121 | chroma = chromaAdj, 122 | hue = 0.0, 123 | viewingConditions = cond, 124 | ) 125 | }.toMap() 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/kdrag0n/monet/theme/DynamicColorScheme.kt: -------------------------------------------------------------------------------- 1 | package dev.kdrag0n.monet.theme 2 | 3 | import android.util.Log 4 | import dev.kdrag0n.colorkt.Color 5 | import dev.kdrag0n.colorkt.cam.Zcam 6 | import dev.kdrag0n.colorkt.cam.Zcam.Companion.toZcam 7 | import dev.kdrag0n.colorkt.conversion.ConversionGraph.convert 8 | import dev.kdrag0n.colorkt.gamut.LchGamut 9 | import dev.kdrag0n.colorkt.gamut.LchGamut.clipToLinearSrgb 10 | import dev.kdrag0n.colorkt.rgb.Srgb 11 | import dev.kdrag0n.colorkt.tristimulus.CieXyz 12 | import dev.kdrag0n.colorkt.tristimulus.CieXyzAbs.Companion.toAbs 13 | 14 | class DynamicColorScheme( 15 | targets: ColorScheme, 16 | seedColor: Color, 17 | chromaFactor: Double = 1.0, 18 | private val cond: Zcam.ViewingConditions, 19 | private val accurateShades: Boolean = true, 20 | ) : ColorScheme() { 21 | private val seedNeutral = seedColor.convert().toAbs(cond.referenceWhite.y).toZcam(cond, include2D = false).let { lch -> 22 | lch.copy(chroma = lch.chroma * chromaFactor) 23 | } 24 | private val seedAccent = seedNeutral 25 | 26 | init { 27 | Log.i(TAG, "Seed color: ${seedColor.convert().toHex()} => $seedNeutral") 28 | } 29 | 30 | // Main accent color. Generally, this is close to the seed color. 31 | override val accent1 = transformSwatch(targets.accent1, seedAccent, targets.accent1) 32 | // Secondary accent color. Darker shades of accent1. 33 | override val accent2 = transformSwatch(targets.accent2, seedAccent, targets.accent1) 34 | // Tertiary accent color. Seed color shifted to the next secondary color via hue offset. 35 | override val accent3 = transformSwatch( 36 | swatch = targets.accent3, 37 | seed = seedAccent.copy(hue = seedAccent.hue + ACCENT3_HUE_SHIFT_DEGREES), 38 | referenceSwatch = targets.accent1 39 | ) 40 | 41 | // Main background color. Tinted with the seed color. 42 | override val neutral1 = transformSwatch(targets.neutral1, seedNeutral, targets.neutral1) 43 | // Secondary background color. Slightly tinted with the seed color. 44 | override val neutral2 = transformSwatch(targets.neutral2, seedNeutral, targets.neutral1) 45 | 46 | private fun transformSwatch( 47 | swatch: ColorSwatch, 48 | seed: Zcam, 49 | referenceSwatch: ColorSwatch, 50 | ): ColorSwatch { 51 | return swatch.map { (shade, color) -> 52 | val target = color as? Zcam 53 | ?: color.convert().toAbs(cond.referenceWhite.y).toZcam(cond, include2D = false) 54 | val reference = referenceSwatch[shade]!! as? Zcam 55 | ?: color.convert().toAbs(cond.referenceWhite.y).toZcam(cond, include2D = false) 56 | val newLch = transformColor(target, seed, reference) 57 | val newSrgb = newLch.convert() 58 | 59 | Log.d(TAG, "Transform: [$shade] $target => $newLch => ${newSrgb.toHex()}") 60 | shade to newSrgb 61 | }.toMap() 62 | } 63 | 64 | private fun transformColor(target: Zcam, seed: Zcam, reference: Zcam): Color { 65 | // Keep target lightness. 66 | val lightness = target.lightness 67 | // Allow colorless gray and low-chroma colors by clamping. 68 | // To preserve chroma ratios, scale chroma by the reference (A-1 / N-1). 69 | val scaleC = if (reference.chroma == 0.0) { 70 | // Zero reference chroma won't have chroma anyway, so use 0 to avoid a divide-by-zero 71 | 0.0 72 | } else { 73 | // Non-zero reference chroma = possible chroma scale 74 | seed.chroma.coerceIn(0.0, reference.chroma) / reference.chroma 75 | } 76 | val chroma = target.chroma * scaleC 77 | // Use the seed color's hue, since it's the most prominent feature of the theme. 78 | val hue = seed.hue 79 | 80 | val newColor = Zcam( 81 | lightness = lightness, 82 | chroma = chroma, 83 | hue = hue, 84 | viewingConditions = cond, 85 | ) 86 | return if (accurateShades) { 87 | newColor.clipToLinearSrgb(LchGamut.ClipMethod.PRESERVE_LIGHTNESS) 88 | } else { 89 | newColor.clipToLinearSrgb(LchGamut.ClipMethod.ADAPTIVE_TOWARDS_MID, alpha = 5.0) 90 | } 91 | } 92 | 93 | companion object { 94 | private const val TAG = "DynamicColorScheme" 95 | 96 | // Hue shift for the tertiary accent color (accent3), in degrees. 97 | // 60 degrees = shifting by a secondary color 98 | private const val ACCENT3_HUE_SHIFT_DEGREES = 60.0 99 | } 100 | } 101 | --------------------------------------------------------------------------------