Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial Kotlin Multiplatform Support #76

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
32 changes: 32 additions & 0 deletions kotlin/lib/androidMain/kotlin/ExtendTheme.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import android.graphics.Bitmap
import quantize.QuantizerCelebi
import score.Score
import theme.CustomColor
import theme.Theme
import utils.ThemeUtils

// Slow but copyPixelsToBuffer didn't work as expected
fun ThemeUtils.themeFromImage(image: Bitmap, vararg customColors: CustomColor = emptyArray()): Theme {
val pixelColors: MutableList<Int> = mutableListOf()

for (y in 0 until image.height) {
for (x in 0 until image.width) {
val pixel = image.getPixel(x, y)
var argb = 0
argb += pixel and 0xff shl 24
argb += pixel and 0xff
argb += pixel and 0xff shl 8
argb += pixel and 0xff shl 16

pixelColors.add(argb)
}
}

val result = QuantizerCelebi.quantize(pixelColors.toIntArray(), 128)
val ranked = Score.score(result)
val top = ranked[0]

return themeFromSourceColor(top, *customColors)
}

fun Bitmap.createTheme(vararg customColors: CustomColor = emptyArray()) = ThemeUtils.themeFromImage(this, *customColors)
72 changes: 72 additions & 0 deletions kotlin/lib/commonMain/kotlin/blend/Blend.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package blend

import hct.Cam16
import hct.Hct
import utils.ColorUtils.lstarFromArgb
import utils.MathUtils.differenceDegrees
import utils.MathUtils.rotationDirection
import utils.MathUtils.sanitizeDegreesDouble

/** Functions for blending in HCT and CAM16. */
object Blend {
/**
* Blend the design color's HCT hue towards the key color's HCT hue, in a way that leaves the
* original color recognizable and recognizably shifted towards the key color.
*
* @param designColor ARGB representation of an arbitrary color.
* @param sourceColor ARGB representation of the main theme color.
* @return The design color with a hue shifted towards the system's color, a slightly
* warmer/cooler variant of the design color's hue.
*/
fun harmonize(designColor: Int, sourceColor: Int): Int {
val fromHct = Hct.fromInt(designColor)
val toHct = Hct.fromInt(sourceColor)
val differenceDegrees = differenceDegrees(fromHct.hue, toHct.hue)
val rotationDegrees = (differenceDegrees * 0.5).coerceAtMost(15.0)
val outputHue = sanitizeDegreesDouble(
fromHct.hue
+ rotationDegrees * rotationDirection(fromHct.hue, toHct.hue)
)
return Hct.from(outputHue, fromHct.chroma, fromHct.tone).toInt()
}

/**
* Blends hue from one color into another. The chroma and tone of the original color are
* maintained.
*
* @param from ARGB representation of color
* @param to ARGB representation of color
* @param amount how much blending to perform; 0.0 >= and <= 1.0
* @return from, with a hue blended towards to. Chroma and tone are constant.
*/
fun hctHue(from: Int, to: Int, amount: Double): Int {
val ucs = cam16Ucs(from, to, amount)
val ucsCam = Cam16.fromInt(ucs)
val fromCam = Cam16.fromInt(from)
val blended = Hct.from(ucsCam.hue, fromCam.chroma, lstarFromArgb(from))
return blended.toInt()
}

/**
* Blend in CAM16-UCS space.
*
* @param from ARGB representation of color
* @param to ARGB representation of color
* @param amount how much blending to perform; 0.0 >= and <= 1.0
* @return from, blended towards to. Hue, chroma, and tone will change.
*/
fun cam16Ucs(from: Int, to: Int, amount: Double): Int {
val fromCam = Cam16.fromInt(from)
val toCam = Cam16.fromInt(to)
val fromJ = fromCam.jstar
val fromA = fromCam.astar
val fromB = fromCam.bstar
val toJ = toCam.jstar
val toA = toCam.astar
val toB = toCam.bstar
val jstar = fromJ + (toJ - fromJ) * amount
val astar = fromA + (toA - fromA) * amount
val bstar = fromB + (toB - fromB) * amount
return Cam16.fromUcs(jstar, astar, bstar).toInt()
}
}
10 changes: 10 additions & 0 deletions kotlin/lib/commonMain/kotlin/common/BiFunction.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package common

internal fun interface BiFunction<T, U, R> {

fun apply(t: T, u: U): R

fun <V> andThen(after: Function<in R, out V>): BiFunction<T, U, V> {
return BiFunction { t: T, u: U -> after.apply(apply(t, u)) }
}
}
17 changes: 17 additions & 0 deletions kotlin/lib/commonMain/kotlin/common/Function.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package common

internal fun interface Function<T, R> {
fun apply(t: T): R

fun <V> compose(before: Function<in V, out T>): Function<V, R> {
return Function { v: V -> apply(before.apply(v)) }
}

fun <V> andThen(after: Function<in R, out V>): Function<T, V> {
return Function { t: T -> after.apply(apply(t)) }
}

fun <T> identity(): Function<T, T> {
return Function { t: T -> t }
}
}
194 changes: 194 additions & 0 deletions kotlin/lib/commonMain/kotlin/contrast/Contrast.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package contrast

import utils.ColorUtils.lstarFromY
import utils.ColorUtils.yFromLstar
import kotlin.math.abs
import kotlin.math.max

/**
* Color science for contrast utilities.
*
* <p>Utility methods for calculating contrast given two colors, or calculating a color given one
* color and a contrast ratio.
*
* <p>Contrast ratio is calculated using XYZ's Y. When linearized to match human perception, Y
* becomes HCT's tone and L*a*b*'s' L*.
*/
object Contrast {
// The minimum contrast ratio of two colors.
// Contrast ratio equation = lighter + 5 / darker + 5, if lighter == darker, ratio == 1.
const val RATIO_MIN = 1.0

// The maximum contrast ratio of two colors.
// Contrast ratio equation = lighter + 5 / darker + 5. Lighter and darker scale from 0 to 100.
// If lighter == 100, darker = 0, ratio == 21.
const val RATIO_MAX = 21.0
const val RATIO_30 = 3.0
const val RATIO_45 = 4.5
const val RATIO_70 = 7.0

// Given a color and a contrast ratio to reach, the luminance of a color that reaches that ratio
// with the color can be calculated. However, that luminance may not contrast as desired, i.e. the
// contrast ratio of the input color and the returned luminance may not reach the contrast ratio
// asked for.
//
// When the desired contrast ratio and the result contrast ratio differ by more than this amount,
// an error value should be returned, or the method should be documented as 'unsafe', meaning,
// it will return a valid luminance but that luminance may not meet the requested contrast ratio.
//
// 0.04 selected because it ensures the resulting ratio rounds to the same tenth.
private const val CONTRAST_RATIO_EPSILON = 0.04

// Color spaces that measure luminance, such as Y in XYZ, L* in L*a*b*, or T in HCT, are known as
// perceptual accurate color spaces.
//
// To be displayed, they must gamut map to a "display space", one that has a defined limit on the
// number of colors. Display spaces include sRGB, more commonly understood as RGB/HSL/HSV/HSB.
// Gamut mapping is undefined and not defined by the color space. Any gamut mapping algorithm must
// choose how to sacrifice accuracy in hue, saturation, and/or lightness.
//
// A principled solution is to maintain lightness, thus maintaining contrast/a11y, maintain hue,
// thus maintaining aesthetic intent, and reduce chroma until the color is in gamut.
//
// HCT chooses this solution, but, that doesn't mean it will _exactly_ matched desired lightness,
// if only because RGB is quantized: RGB is expressed as a set of integers: there may be an RGB
// color with, for example, 47.892 lightness, but not 47.891.
//
// To allow for this inherent incompatibility between perceptually accurate color spaces and
// display color spaces, methods that take a contrast ratio and luminance, and return a luminance
// that reaches that contrast ratio for the input luminance, purposefully darken/lighten their
// result such that the desired contrast ratio will be reached even if inaccuracy is introduced.
//
// 0.4 is generous, ex. HCT requires much less delta. It was chosen because it provides a rough
// guarantee that as long as a percetual color space gamut maps lightness such that the resulting
// lightness rounds to the same as the requested, the desired contrast ratio will be reached.
private const val LUMINANCE_GAMUT_MAP_TOLERANCE = 0.4

/**
* Contrast ratio is a measure of legibility, its used to compare the lightness of two colors.
* This method is used commonly in industry due to its use by WCAG.
*
*
* To compare lightness, the colors are expressed in the XYZ color space, where Y is lightness,
* also known as relative luminance.
*
*
* The equation is ratio = lighter Y + 5 / darker Y + 5.
*/
fun ratioOfYs(y1: Double, y2: Double): Double {
val lighter: Double = max(y1, y2)
val darker = if (lighter == y2) y1 else y2
return (lighter + 5.0) / (darker + 5.0)
}

/**
* Contrast ratio of two tones. T in HCT, L* in L*a*b*. Also known as luminance or perpectual
* luminance.
*
*
* Contrast ratio is defined using Y in XYZ, relative luminance. However, relative luminance is
* linear to number of photons, not to perception of lightness. Perceptual luminance, L* in
* L*a*b*, T in HCT, is. Designers prefer color spaces with perceptual luminance since they're
* accurate to the eye.
*
*
* Y and L* are pure functions of each other, so it possible to use perceptually accurate color
* spaces, and measure contrast, and measure contrast in a much more understandable way: instead
* of a ratio, a linear difference. This allows a designer to determine what they need to adjust a
* color's lightness to in order to reach their desired contrast, instead of guessing & checking
* with hex codes.
*/
fun ratioOfTones(t1: Double, t2: Double): Double {
return ratioOfYs(yFromLstar(t1), yFromLstar(t2))
}

/**
* Returns T in HCT, L* in L*a*b* >= tone parameter that ensures ratio with input T/L*. Returns -1
* if ratio cannot be achieved.
*
* @param tone Tone return value must contrast with.
* @param ratio Desired contrast ratio of return value and tone parameter.
*/
fun lighter(tone: Double, ratio: Double): Double {
if (tone < 0.0 || tone > 100.0) {
return -1.0
}
// Invert the contrast ratio equation to determine lighter Y given a ratio and darker Y.
val darkY = yFromLstar(tone)
val lightY = ratio * (darkY + 5.0) - 5.0
if (lightY < 0.0 || lightY > 100.0) {
return -1.0
}
val realContrast = ratioOfYs(lightY, darkY)
val delta = abs(realContrast - ratio)
if (realContrast < ratio && delta > CONTRAST_RATIO_EPSILON) {
return -1.0
}
val returnValue = lstarFromY(lightY) + LUMINANCE_GAMUT_MAP_TOLERANCE
// NOMUTANTS--important validation step; functions it is calling may change implementation.
return if (returnValue < 0 || returnValue > 100) {
-1.0
} else returnValue
}

/**
* Tone >= tone parameter that ensures ratio. 100 if ratio cannot be achieved.
*
*
* This method is unsafe because the returned value is guaranteed to be in bounds, but, the in
* bounds return value may not reach the desired ratio.
*
* @param tone Tone return value must contrast with.
* @param ratio Desired contrast ratio of return value and tone parameter.
*/
fun lighterUnsafe(tone: Double, ratio: Double): Double {
val lighterSafe = lighter(tone, ratio)
return if (lighterSafe < 0.0) 100.0 else lighterSafe
}

/**
* Returns T in HCT, L* in L*a*b* <= tone parameter that ensures ratio with input T/L*. Returns -1
* if ratio cannot be achieved.
*
* @param tone Tone return value must contrast with.
* @param ratio Desired contrast ratio of return value and tone parameter.
*/
fun darker(tone: Double, ratio: Double): Double {
if (tone < 0.0 || tone > 100.0) {
return -1.0
}
// Invert the contrast ratio equation to determine darker Y given a ratio and lighter Y.
val lightY = yFromLstar(tone)
val darkY = (lightY + 5.0) / ratio - 5.0
if (darkY < 0.0 || darkY > 100.0) {
return -1.0
}
val realContrast = ratioOfYs(lightY, darkY)
val delta = abs(realContrast - ratio)
if (realContrast < ratio && delta > CONTRAST_RATIO_EPSILON) {
return -1.0
}

// For information on 0.4 constant, see comment in lighter(tone, ratio).
val returnValue = lstarFromY(darkY) - LUMINANCE_GAMUT_MAP_TOLERANCE
// NOMUTANTS--important validation step; functions it is calling may change implementation.
return if (returnValue < 0 || returnValue > 100) {
-1.0
} else returnValue
}

/**
* Tone <= tone parameter that ensures ratio. 0 if ratio cannot be achieved.
*
*
* This method is unsafe because the returned value is guaranteed to be in bounds, but, the in
* bounds return value may not reach the desired ratio.
*
* @param tone Tone return value must contrast with.
* @param ratio Desired contrast ratio of return value and tone parameter.
*/
fun darkerUnsafe(tone: Double, ratio: Double): Double {
val darkerSafe = darker(tone, ratio)
return max(0.0, darkerSafe)
}
}
36 changes: 36 additions & 0 deletions kotlin/lib/commonMain/kotlin/dislike/DislikeAnalyzer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package dislike

import hct.Hct
import kotlin.math.roundToInt

/**
* Check and/or fix universally disliked colors.
*
* <p>Color science studies of color preference indicate universal distaste for dark yellow-greens,
* and also show this is correlated to distate for biological waste and rotting food.
*
* <p>See Palmer and Schloss, 2010 or Schloss and Palmer's Chapter 21 in Handbook of Color
* Psychology (2015).
*/
object DislikeAnalyzer {

/**
* Returns true if color is disliked.
*
*
* Disliked is defined as a dark yellow-green that is not neutral.
*/
fun isDisliked(hct: Hct): Boolean {
val huePasses = hct.hue.roundToInt() >= 90.0 && hct.hue.roundToInt() <= 111.0
val chromaPasses = hct.chroma.roundToInt() > 16.0
val tonePasses = hct.tone.roundToInt() < 70.0
return huePasses && chromaPasses && tonePasses
}

/** If color is disliked, lighten it to make it likable. */
fun fixIfDisliked(hct: Hct): Hct {
return if (isDisliked(hct)) {
Hct.from(hct.hue, hct.chroma, 70.0)
} else hct
}
}