Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 43 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Kotlin Multiplatform color picker library for Android, iOS, Desktop (JVM), and Web (Wasm), built with Compose Multiplatform: ready-made Material 3 pickers, and the same pickers without Material for a design system of your own.

> **This README describes 2.0, which is not released yet.** The latest release is 1.2.1, documented at the [v1.2.1 tag](https://github.com/side-codes/anyColorPicker/tree/v1.2.1#readme). [Migrating from 1.x](#-migrating-from-1x) maps one API onto the other.
> **This README describes 2.0, which is not released yet.** For published versions and their documentation, see [releases](https://github.com/side-codes/anyColorPicker/releases). [Migrating from 1.x](#-migrating-from-1x) maps one API onto the other.

## ✨ Features

Expand All @@ -13,7 +13,7 @@ Kotlin Multiplatform color picker library for Android, iOS, Desktop (JVM), and W
- Eleven ready-made pickers, each over a `ColorPickerState`, a `ColorValue` or a Compose `Color`
- A color model built on CSS Color 4: a `ColorValue` keeps the space it was written in, its `none` components and any value outside sRGB
- CSS color strings and hex, both ways
- CSS Color 4 gamut mapping, so a color outside sRGB is drawn with its lightness and hue intact
- CSS Color 4 gamut mapping, so a color outside sRGB is drawn with its lightness and hue held to within a just-noticeable difference
- A grey keeps its hue: dragged to grey and back, a color returns in the hue it had rather than red
- Two-dimensional planes over any two channels of a space
- Alpha channel support
Expand Down Expand Up @@ -53,10 +53,21 @@ Each artifact brings the ones below it, and each works on its own:
| `codes.side:colorpicker-material3` | The Material 3 pickers, sliders, planes, swatch, dialog and theme, in `codes.side.colorpicker.material3`. |
| `codes.side:colorpicker-foundation` | `ColorPickerState`, in `codes.side.colorpicker.state`; the `Basic*` components and `ColorPickerStrings`, in `codes.side.colorpicker.foundation`. No Material. |
| `codes.side:color-compose` | `ColorValue.toComposeColor()` and `Color.toColorValue()`. |
| `codes.side:color` | `ColorValue`, the color spaces, conversion, gamut mapping, CSS strings and hex. No Compose. |
| `codes.side:color` | `ColorValue`, the color spaces, conversion, gamut mapping, CSS strings and hex. No Compose runtime or UI. |

Published targets: `android`, `jvm`, `iosArm64`, `iosSimulatorArm64`, `wasmJs`.

### Requirements

Built with Kotlin 2.4.20, Compose Multiplatform 1.12.1 and Material 3 1.9.0. Android needs
`minSdk` 24 and is built against `compileSdk` 37; the desktop jars are Java 17 bytecode. Building
the repository itself needs JDK 21.

Until 2.0.0 is published, `./gradlew publishToMavenLocal` puts `2.0.0-SNAPSHOT` in `mavenLocal()`.

The 1.x `codes.side:colorpicker` artifact cannot share a classpath with 2.x: both ship
`codes.side.colorpicker.state.ColorPickerState`.

## 🎨 Gallery

Every picker takes a `ColoringMode`. `Independent` shows each channel's full range;
Expand All @@ -82,17 +93,16 @@ with a hue defaults to `Independent`; the RGB, Lab, Oklab and CMYK pickers defau

![Color swatch](docs/images/color-swatch.png)

These images are the Compose Preview Screenshot Testing references, rendered from the
library's own components and re-checked on every CI run, so they cannot drift from what
the code actually draws. Regenerate them with
`./gradlew :screenshot-tests:updateDebugScreenshotTest`.
These images are copies of the Compose Preview Screenshot Testing references, rendered from the
library's own components. CI checks the references, not these copies: after
`./gradlew :screenshot-tests:updateDebugScreenshotTest`, copy the changed ones here.

## 🚀 Quick Start

```kotlin
@Composable
fun MyScreen() {
val state = rememberColorPickerState(Okhsl(250.0, 0.8, 0.6))
val state = rememberSaveableColorPickerState(Okhsl(250.0, 0.8, 0.6))

Column {
ColorPicker(state)
Expand All @@ -102,7 +112,8 @@ fun MyScreen() {
```

`ColorPicker` is an Okhsl picker unless given a `space`: its lightness is perceived lightness and
its saturation is measured against the display, so every position is a color the screen shows.
its saturation is measured against sRGB, so every position is a color sRGB shows, bar a sliver just
past pure blue.
`ColorPicker(state, space = Oklch)` picks in any other space, and the named pickers,
`HslColorPicker` to `CmykColorPicker`, fix one.

Expand Down Expand Up @@ -153,22 +164,23 @@ The units are CSS's, so a number copied from a stylesheet or a design tool means

- **Okhsl** is Björn Ottosson's perceptual replacement for HSL, and the one to reach for if you
are choosing between the two. Lightness is perceived lightness, so a blue and a yellow at `0.5`
look equally light; in HSL they differ by more than half the scale. Saturation is measured
against the sRGB gamut, so `1` is as colorful as the display can go at that hue and lightness —
every coordinate is a real color and no part of a slider is dead travel.
look about equally light; in HSL they differ by more than half the scale. Saturation is measured
against the sRGB gamut, so `1` is as colorful as sRGB goes at that hue and lightness — every
coordinate is a real color, bar a sliver just past pure blue, and no part of a slider is dead
travel.
- **Okhsv** has Okhsl's perceptual hue and gamut-relative saturation in the HSV arrangement
artists expect: full saturation at full value is the most vivid form of a hue, and pulling value
down darkens toward black. Prefer Okhsl when the middle of the lightness track should be a mid
tone.
- **Oklab** is the perceptual space the two above are built on, and the one to interpolate,
compare or blend in: equal steps are close to equal perceived steps, and moving `L` does not drag
- **Oklab** is the perceptual space the two above are built on, and the one to interpolate and
compare colors in: equal steps are close to equal perceived steps, and moving `L` does not drag
the perceived hue with it. **OkLCh** is its cylindrical form, CSS's `oklch()`, for changing one
of lightness, chroma and hue while holding the others. Neither is bounded by the display, so most
of their range lies outside sRGB and is drawn as the nearest color sRGB holds.
of their range lies outside sRGB and is gamut-mapped into it to be drawn.
- **Lab** and **LCH** are CIELAB with a D50 white, which is what CSS `lab()`, Photoshop and
Compose's `ColorSpaces.CieLab` all quote, so a value copied from any of them means here what it
meant there. About an eighth of the a–b square is inside sRGB.
- **HSL**, **HSV** and **HWB** are CSS's formulas over sRGB.
- **HSL** and **HWB** follow CSS formulas over sRGB; **HSV** is the conventional sRGB hexcone model.
- **CMYK** is the naive conversion, with no color profile. It round-trips on screen and is not
what a press will print — real CMYK is device dependent, its gamut is not sRGB's, and crossing
between them needs an ICC profile and a rendering intent. Treat it as a screen-space
Expand All @@ -191,22 +203,28 @@ among those it is given.
### Gamut mapping

Drawing a color outside sRGB does not clamp each channel independently, which would shift
lightness and hue as a side effect. It runs the [CSS Color 4 algorithm](https://www.w3.org/TR/css-color-4/#gamut-mapping):
binary search down the chroma axis, comparing each candidate against its clipped form, and stop
once the two are within a just-noticeable difference. Lightness and hue survive and chroma pays.
lightness and hue as a side effect. It runs CSS Color 4's
[binary search with local MINDE](https://www.w3.org/TR/css-color-4/#GMA-Binary-local-MINDE), one of
the three algorithms it allows: binary search down the chroma axis at constant lightness and
hue, comparing each candidate against its clipped form, and return the clipped form once the two are
within a just-noticeable difference. Chroma pays; lightness and hue move by less than that
difference.

```kotlin
val vivid = Oklch(0.7, 0.3, 150.0)

vivid.isInGamut(Srgb.gamut) // false
vivid.toGamut(Srgb.gamut) // the same lightness and hue, less chroma
vivid.toGamut(Srgb.gamut) // less chroma, the same lightness and hue
vivid.toGamut(Srgb.gamut, GamutMapping.Clip) // each channel clipped, when that is what you want
vivid.toComposeColor() // mapped as toGamut maps it
```

The search runs in Oklab whatever space the color came from, as CSS specifies, so what survives is
Oklab's lightness and hue, not CIELAB's. Okhsl and Okhsv never need it: their saturation is
measured against the gamut, so they are inside it by construction.
Oklab's lightness and hue, not CIELAB's. `GamutMapping.ChromaReduction()` solves for the gamut's
edge instead, and keeps lightness and hue to rounding. Okhsl and Okhsv rarely
need either: their saturation is measured against sRGB, so they are inside it by construction,
except in a sliver just past pure blue (264.05–264.21°), where they stray by under 0.001 of a
linear channel.

### CSS and hex

Expand All @@ -217,7 +235,7 @@ Okhsl(120.0, 0.5, 0.25).toCssString() // "color(--okhsl 120 0.5 0.25)"
Hsl(120.0, 50.0, null).toCssString() // "hsl(120 50% none)"

ColorValue.parseCss("oklch(70% 0.15 140 / 50%)") // an OkLCh value at half alpha
ColorValue.parseCssOrNull("not a color") // null, never throws
ColorValue.parseCssOrNull("not a color") // null for invalid color text
```

A value is written in its own space and never mapped into a gamut, so a Display P3 red stays
Expand Down Expand Up @@ -420,7 +438,8 @@ grey-to-hue ramp under a white / transparent / black overlay: the color at light
mid-lightness color blended toward white by `2L-1` above the middle and toward black by `1-2L`
below it, which is what compositing the overlay computes. Every other pair has no such identity,
so it is sampled on a grid, measured for each of the library's planes, and drawn scaled. The grid
is rebuilt off the main thread when a held channel changes.
is rebuilt off the main thread when a held channel changes, except on the web, where it shares the
one thread with input and yields to it every few rows.

`LocalPlaneRendering` decides how. `PlaneRendering.Fast`, the default, shares the rows among up to
four threads, builds Okhsl's S × L as two smaller grids that meet on its crease, and draws each
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ class ComposeBridgeTest {
fun parametricTransferMatchesSrgbAndMirrors() {
// Either side of d but not at it: ICC takes d itself on the power segment, CSS's sRGB on the
// linear one, 2.3e-9 apart.
val curve = ParametricTransfer(checkNotNull((ComposeSpaces.Srgb as Rgb).transferParameters))
val curve = ParametricTransfer(checkNotNull(ComposeSpaces.Srgb.transferParameters))
for (x in listOf(0.02, 0.04, 0.041, 0.2, 0.5, 1.0, 1.5)) {
assertEquals(TransferFunction.Srgb.decode(x), curve.decode(x), 1e-12, "decode $x")
assertEquals(-curve.decode(x), curve.decode(-x), "decode -$x")
Expand Down
6 changes: 4 additions & 2 deletions color/src/commonMain/kotlin/codes/side/color/CmykSpace.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package codes.side.color
import kotlin.math.max

/**
* Naive CMYK over [Srgb]: K = 1 − max(R, G, B), with no color profile. A reversible way to put four
* numbers on screen, not what a press prints, which takes an ICC profile.
* Naive CMYK over [Srgb]: K = 1 − max(R, G, B), with no color profile. An RGB color round-trips
* unless its highest channel is exactly 0 and another is below it, which is singular and comes back
* black. Many CMYK values give one RGB, so a trip through RGB need not keep the ink amounts. What a
* press prints takes an ICC profile.
*/
@OptIn(ExperimentalColorSpaceApi::class)
public object Cmyk : ColorSpace(
Expand Down
2 changes: 1 addition & 1 deletion color/src/commonMain/kotlin/codes/side/color/ColorValue.kt
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ public class ColorValue internal constructor(
else -> value
}

private fun wrapHue(degrees: Double): Double {
internal fun wrapHue(degrees: Double): Double {
var wrapped = degrees % 360.0
if (wrapped < 0.0) wrapped += 360.0
return if (wrapped >= 360.0) 0.0 else wrapped
Expand Down
5 changes: 4 additions & 1 deletion color/src/commonMain/kotlin/codes/side/color/GamutMapping.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import kotlin.math.sqrt
/**
* How a color outside an [RgbGamut] is brought inside. [Css] and [ChromaReduction] reduce chroma at
* constant OkLCh lightness and hue, as CSS Color 4 §14.2 has it, and give white at lightness 1 or
* more and black at 0 or less; [Clip] clamps each channel.
* more and black at 0 or less. [Css] returns a clipped color once it is within a just-noticeable
* difference of the reduced one, so lightness and hue can move by up to that difference;
* [ChromaReduction] solves for the edge, and its clip moves them only by rounding. [Clip] clamps each
* channel.
*/
public abstract class GamutMapping internal constructor() {

Expand Down
10 changes: 9 additions & 1 deletion color/src/commonMain/kotlin/codes/side/color/RgbColorSpace.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,15 @@ public class RgbPrimaries(
greenX == other.greenX && greenY == other.greenY &&
blueX == other.blueX && blueY == other.blueY

override fun hashCode(): Int = listOf(redX, redY, greenX, greenY, blueX, blueY).hashCode()
override fun hashCode(): Int {
// Equality treats the two signs of zero alike; their hashes must agree too.
var result = (redX + 0.0).hashCode()
result = 31 * result + (redY + 0.0).hashCode()
result = 31 * result + (greenX + 0.0).hashCode()
result = 31 * result + (greenY + 0.0).hashCode()
result = 31 * result + (blueX + 0.0).hashCode()
return 31 * result + (blueY + 0.0).hashCode()
}

override fun toString(): String = "RgbPrimaries(R $redX,$redY G $greenX,$greenY B $blueX,$blueY)"

Expand Down
8 changes: 6 additions & 2 deletions color/src/commonMain/kotlin/codes/side/color/RgbGamut.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public class RgbGamut internal constructor(
public fun maxChroma(lightness: Double, hue: Double): Double {
require(lightness.isFinite() && hue.isFinite()) { "Lightness and hue must be finite, were $lightness and $hue" }
if (lightness <= 0.0 || lightness >= 1.0) return 0.0
val radians = hue * PI / 180.0
val radians = hueRadians(hue)
val a = cos(radians)
val b = sin(radians)
val cusp = gamutMemo().cusp(lmsToLinear, a, b)
Expand All @@ -44,7 +44,7 @@ public class RgbGamut internal constructor(
/** The most colorful color of [hue], in degrees, that this gamut holds, in [Oklch]. */
public fun cusp(hue: Double): ColorValue {
require(hue.isFinite()) { "Hue must be finite, was $hue" }
val radians = hue * PI / 180.0
val radians = hueRadians(hue)
val a = cos(radians)
val b = sin(radians)
val cusp = gamutMemo().cusp(lmsToLinear, a, b)
Expand All @@ -61,3 +61,7 @@ public class RgbGamut internal constructor(

override fun toString(): String = "RgbGamut(${space.id})"
}

// The hue wrapped as ColorValue stores it, before trigonometry: multiplying a huge finite angle
// first can overflow, and even smaller angles lose the turn's position during argument reduction.
private fun hueRadians(hue: Double): Double = ColorValue.wrapHue(hue) * PI / 180.0
11 changes: 11 additions & 0 deletions color/src/commonTest/kotlin/codes/side/color/GamutGeometryTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,17 @@ class GamutGeometryTest {
assertNear(330.0, Srgb.gamut.cusp(-30.0)[Oklch.H]!!, 1e-9)
}

@Test
fun hugeFiniteHuesAnswerAsTheSameAngleStoredInAColor() {
for (hue in listOf(1e20, -1e20, Double.MAX_VALUE, -Double.MAX_VALUE)) {
val wrapped = Oklch(0.6, 0.1, hue)[Oklch.H]!!
for (gamut in gamuts) {
assertEquals(gamut.maxChroma(0.6, wrapped), gamut.maxChroma(0.6, hue), 1e-12, "$gamut at $hue")
assertComponents(gamut.cusp(wrapped).components(), gamut.cusp(hue), 1e-12)
}
}
}

@Test
fun theCuspSitsOnTheGamutsEdge() {
for (gamut in gamuts) {
Expand Down
9 changes: 9 additions & 0 deletions color/src/commonTest/kotlin/codes/side/color/RgbSpacesTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ class RgbSpacesTest {
}
}

@Test
fun equalPrimariesWithSignedZeroWorkAsMapKeys() {
val positive = RgbPrimaries(0.0, 0.33, 0.30, 0.60, 0.15, 0.06)
val negative = RgbPrimaries(-0.0, 0.33, 0.30, 0.60, 0.15, 0.06)
assertEquals(positive, negative)
assertEquals(positive.hashCode(), negative.hashCode())
assertEquals("primaries", mapOf(positive to "primaries")[negative])
}

@Test
fun anAppSpaceCannotTakeALibraryId() {
// Spaces are equal by id, so one called srgb would be taken for Srgb and never converted.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ package codes.side.color.internal

private val memos = ThreadLocal.withInitial(::GamutMemo)

internal actual fun gamutMemo(): GamutMemo = memos.get()
internal actual fun gamutMemo(): GamutMemo = checkNotNull(memos.get())
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,18 @@ import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawscope.DrawScope
Expand Down Expand Up @@ -90,7 +93,7 @@ public sealed interface ColorPlaneScope {
*
* [xValue] runs left to right and [yValue] bottom to top, so `yValue = 1f` is the top edge. Dragging
* reports both at once, which is what lets a caller write two channels in a single update and leave
* the rest of the colour alone. A value outside `0..1` is drawn at the nearer edge.
* the rest of the colour alone. A value outside `0..1` is drawn at the nearer edge, and NaN at 0.
*
* Unlike the sliders, the surface is not mirrored in right-to-left layouts. It is a map of a colour
* space rather than a progress control, and mirroring it would make the x channel grow leftwards here
Expand Down Expand Up @@ -135,18 +138,28 @@ public fun BasicColorPlane(
thumb: @Composable ColorPlaneScope.() -> Unit,
) {
val currentOnValueChange by rememberUpdatedState(onValueChange)
val currentPosition by rememberUpdatedState(Offset(sliderFraction(xValue), sliderFraction(yValue)))
// Like the basic slider, accumulate steps until composition answers them. Keep both axes
// together so changing direction before recomposition does not undo the previous step.
val unanswered = remember { mutableStateOf<Offset?>(null) }
val stepping = unanswered.value != null
SideEffect { if (stepping) unanswered.value = null }
BasicColorPlaneImpl(
xValue = xValue,
yValue = yValue,
onValueChange = onValueChange,
onStep = { dx, dy, coarse ->
val step = if (coarse) PlaneCoarseKeyStep else PlaneKeyStep
val newX = (xValue + dx * step).coerceIn(0f, 1f)
val newY = (yValue + dy * step).coerceIn(0f, 1f)
if (newX == xValue && newY == yValue) {
val from = unanswered.value ?: currentPosition
val next = Offset(
(from.x + dx * step).coerceIn(0f, 1f),
(from.y + dy * step).coerceIn(0f, 1f),
)
if (next == from) {
false
} else {
currentOnValueChange(newX, newY)
unanswered.value = next
currentOnValueChange(next.x, next.y)
true
}
},
Expand Down
Loading
Loading