diff --git a/README.md b/README.md
index 718f65f..de1d4f1 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,8 @@ Kotlin Multiplatform color picker library for Android, iOS, Desktop (JVM), and W
- Compose Multiplatform (Android, iOS, Desktop/JVM, Web/Wasm)
- Material 3 theming via `ColorPickerDefaults`
- HSL, RGB, CMYK, and LAB color models
+- Perceptual color: Okhsl and Okhsv pickers, with Oklab and OkLCh for interchange and manipulation
+- CSS Color 4 gamut mapping, so an out-of-gamut Oklab or OkLCh color keeps its lightness and hue
- Alpha channel support
- Zero-drift editing: `ColorPickerState` keeps the authoritative color in the space you edited, so edit-in-X-read-X is always exact (conversions themselves are float-based)
- Unidirectional data flow with `ColorPickerState`
@@ -43,12 +45,14 @@ Every picker takes a `ColoringMode`. `Independent` shows each channel's full ran
`Contextual` previews the resulting color at every slider position. `HslColorPicker`
defaults to `Independent`; the others default to `Contextual`.
-| Model | Independent | Contextual |
-|-------------------------------|-------------------------------------------------------|-----------------------------------------------------|
-| **HSL**
`HslColorPicker` |  |  |
-| **RGB**
`RgbColorPicker` |  |  |
-| **CMYK**
`CmykColorPicker` |  |  |
-| **LAB**
`LabColorPicker` |  |  |
+| Model | Independent | Contextual |
+|---------------------------------|---------------------------------------------------------|-------------------------------------------------------|
+| **HSL**
`HslColorPicker` |  |  |
+| **RGB**
`RgbColorPicker` |  |  |
+| **CMYK**
`CmykColorPicker` |  |  |
+| **LAB**
`LabColorPicker` |  |  |
+| **Okhsl**
`OkhslColorPicker` |  |  |
+| **Okhsv**
`OkhsvColorPicker` |  |  |
`ColorSwatch` draws the color over a transparency checkerboard, so alpha reads correctly:
@@ -129,6 +133,73 @@ val color = LabColor(l = 53.23f, a = 80.11f, b = 67.22f)
LabColor.fromInt(l = 53, a = 80, b = 67)
```
+### Okhsl
+
+```kotlin
+val color = OkhslColor(hue = 29.2f, saturation = 1f, lightness = 0.57f)
+// hue: [0, 360), saturation: [0, 1], lightness: [0, 1], alpha: [0, 1]
+
+OkhslColor.fromInt(hue = 29, saturation = 100, lightness = 57)
+```
+
+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.
+
+### Okhsv
+
+```kotlin
+val color = OkhsvColor(hue = 29.2f, saturation = 1f, value = 1f)
+// hue: [0, 360), saturation: [0, 1], value: [0, 1], alpha: [0, 1]
+
+OkhsvColor.fromInt(hue = 29, saturation = 100, value = 100)
+```
+
+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
+
+```kotlin
+val color = OklabColor(l = 0.63f, a = 0.22f, b = 0.13f)
+// l: [0, 1], a: [-0.4, 0.4], b: [-0.4, 0.4], alpha: [0, 1]
+```
+
+The perceptual space the two above are built on, and the one to interpolate, compare or
+blend in — equal numeric steps are close to equal perceived steps, and moving `l` does not
+drag the perceived hue with it. Note `l` runs `0..1`, not CIELAB's `0..100`, and the `a`
+and `b` bounds are the reference range CSS Color 4 gives `oklab()`.
+
+Oklab is not a space to put sliders on: `a` and `b` are not bounded by the display gamut,
+so most of their range is unreachable, exactly as with LAB. Use Okhsl or Okhsv for that.
+
+### OkLCh
+
+```kotlin
+val color = OklchColor(l = 0.63f, chroma = 0.26f, hue = 29.2f)
+// l: [0, 1], chroma: [0, 0.4], hue: [0, 360), alpha: [0, 1]
+```
+
+Oklab in cylindrical form, and the space CSS exposes as `oklch()` — use it to move values
+in and out of stylesheets, or to change one of lightness, chroma and hue while holding the
+others.
+
+### Gamut mapping
+
+Oklab and OkLCh can describe colors sRGB cannot show. Converting one to RGB 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, chroma
+pays, and the result matches what a browser would render.
+
+Okhsl and Okhsv never need this — their coordinates are normalized against the gamut, so
+they are inside it by construction.
+
## 🔄 Conversions
Conversions are extension functions. They operate on floats end to end — nothing is quantized to integers until you explicitly ask for an ARGB `Int` or a hex string. Like any color space conversion, a cross-space round trip is not guaranteed to be bit-exact; the zero-drift guarantee comes from `ColorPickerState`'s origin tracking (see [Architecture](#architecture-zero-drift-color-conversions)).
@@ -140,12 +211,19 @@ val cmyk = rgb.toCmyk()
val lab = rgb.toLab()
val argb = rgb.toArgbInt()
+// Perceptual spaces
+val oklab = rgb.toOklab()
+val oklch = rgb.toOklch()
+val okhsl = rgb.toOkhsl()
+val okhsv = rgb.toOkhsv()
+
// Compose interop, both ways
val composeColor: Color = hsl.toComposeColor()
val backToHsl: HslColor = composeColor.toHslColor()
val backToRgb: RgbColor = composeColor.toRgbColor()
val backToCmyk: CmykColor = composeColor.toCmykColor()
val backToLab: LabColor = composeColor.toLabColor()
+val backToOkhsl: OkhslColor = composeColor.toOkhslColor()
```
### Hex strings
@@ -183,6 +261,8 @@ HslColorPicker(
RgbColorPicker(state = state, showAlpha = true)
CmykColorPicker(state = state, showAlpha = true)
LabColorPicker(state = state, showAlpha = true)
+OkhslColorPicker(state = state, showAlpha = true)
+OkhsvColorPicker(state = state, showAlpha = true)
```
`ColoringMode` controls the slider gradients: `Independent` shows each channel's full range regardless of the other channels, `Contextual` previews the actual resulting color at each position.
@@ -213,6 +293,16 @@ LightnessLabSlider(state = state)
LabASlider(state = state)
LabBSlider(state = state)
+// Okhsl
+OkhslHueSlider(state = state)
+OkhslSaturationSlider(state = state)
+OkhslLightnessSlider(state = state)
+
+// Okhsv
+OkhsvHueSlider(state = state)
+OkhsvSaturationSlider(state = state)
+OkhsvValueSlider(state = state)
+
// Alpha (works with any origin space)
AlphaSlider(state = state)
```
diff --git a/colorpicker/api/colorpicker.klib.api b/colorpicker/api/colorpicker.klib.api
index 6944d2a..12f1b3f 100644
--- a/colorpicker/api/colorpicker.klib.api
+++ b/colorpicker/api/colorpicker.klib.api
@@ -133,6 +133,146 @@ final class codes.side.colorpicker.model/LabColor : codes.side.colorpicker.model
}
}
+final class codes.side.colorpicker.model/OkhslColor : codes.side.colorpicker.model/PickerColor { // codes.side.colorpicker.model/OkhslColor|null[0]
+ constructor (kotlin/Float = ..., kotlin/Float = ..., kotlin/Float = ..., kotlin/Float = ...) // codes.side.colorpicker.model/OkhslColor.|(kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float){}[0]
+
+ final val alpha // codes.side.colorpicker.model/OkhslColor.alpha|{}alpha[0]
+ final fun (): kotlin/Float // codes.side.colorpicker.model/OkhslColor.alpha.|(){}[0]
+ final val hue // codes.side.colorpicker.model/OkhslColor.hue|{}hue[0]
+ final fun (): kotlin/Float // codes.side.colorpicker.model/OkhslColor.hue.|(){}[0]
+ final val intAlpha // codes.side.colorpicker.model/OkhslColor.intAlpha|{}intAlpha[0]
+ final fun (): kotlin/Int // codes.side.colorpicker.model/OkhslColor.intAlpha.|(){}[0]
+ final val intHue // codes.side.colorpicker.model/OkhslColor.intHue|{}intHue[0]
+ final fun (): kotlin/Int // codes.side.colorpicker.model/OkhslColor.intHue.|(){}[0]
+ final val intLightness // codes.side.colorpicker.model/OkhslColor.intLightness|{}intLightness[0]
+ final fun (): kotlin/Int // codes.side.colorpicker.model/OkhslColor.intLightness.|(){}[0]
+ final val intSaturation // codes.side.colorpicker.model/OkhslColor.intSaturation|{}intSaturation[0]
+ final fun (): kotlin/Int // codes.side.colorpicker.model/OkhslColor.intSaturation.|(){}[0]
+ final val lightness // codes.side.colorpicker.model/OkhslColor.lightness|{}lightness[0]
+ final fun (): kotlin/Float // codes.side.colorpicker.model/OkhslColor.lightness.|(){}[0]
+ final val saturation // codes.side.colorpicker.model/OkhslColor.saturation|{}saturation[0]
+ final fun (): kotlin/Float // codes.side.colorpicker.model/OkhslColor.saturation.|(){}[0]
+
+ final fun copy(kotlin/Float = ..., kotlin/Float = ..., kotlin/Float = ..., kotlin/Float = ...): codes.side.colorpicker.model/OkhslColor // codes.side.colorpicker.model/OkhslColor.copy|copy(kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float){}[0]
+ final fun equals(kotlin/Any?): kotlin/Boolean // codes.side.colorpicker.model/OkhslColor.equals|equals(kotlin.Any?){}[0]
+ final fun hashCode(): kotlin/Int // codes.side.colorpicker.model/OkhslColor.hashCode|hashCode(){}[0]
+ final fun toString(): kotlin/String // codes.side.colorpicker.model/OkhslColor.toString|toString(){}[0]
+
+ final object Companion { // codes.side.colorpicker.model/OkhslColor.Companion|null[0]
+ final val Black // codes.side.colorpicker.model/OkhslColor.Companion.Black|{}Black[0]
+ final fun (): codes.side.colorpicker.model/OkhslColor // codes.side.colorpicker.model/OkhslColor.Companion.Black.|(){}[0]
+ final val White // codes.side.colorpicker.model/OkhslColor.Companion.White|{}White[0]
+ final fun (): codes.side.colorpicker.model/OkhslColor // codes.side.colorpicker.model/OkhslColor.Companion.White.|(){}[0]
+
+ final fun fromInt(kotlin/Int, kotlin/Int, kotlin/Int, kotlin/Int = ...): codes.side.colorpicker.model/OkhslColor // codes.side.colorpicker.model/OkhslColor.Companion.fromInt|fromInt(kotlin.Int;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
+ }
+}
+
+final class codes.side.colorpicker.model/OkhsvColor : codes.side.colorpicker.model/PickerColor { // codes.side.colorpicker.model/OkhsvColor|null[0]
+ constructor (kotlin/Float = ..., kotlin/Float = ..., kotlin/Float = ..., kotlin/Float = ...) // codes.side.colorpicker.model/OkhsvColor.|(kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float){}[0]
+
+ final val alpha // codes.side.colorpicker.model/OkhsvColor.alpha|{}alpha[0]
+ final fun (): kotlin/Float // codes.side.colorpicker.model/OkhsvColor.alpha.|(){}[0]
+ final val hue // codes.side.colorpicker.model/OkhsvColor.hue|{}hue[0]
+ final fun (): kotlin/Float // codes.side.colorpicker.model/OkhsvColor.hue.|(){}[0]
+ final val intAlpha // codes.side.colorpicker.model/OkhsvColor.intAlpha|{}intAlpha[0]
+ final fun (): kotlin/Int // codes.side.colorpicker.model/OkhsvColor.intAlpha.|(){}[0]
+ final val intHue // codes.side.colorpicker.model/OkhsvColor.intHue|{}intHue[0]
+ final fun (): kotlin/Int // codes.side.colorpicker.model/OkhsvColor.intHue.|(){}[0]
+ final val intSaturation // codes.side.colorpicker.model/OkhsvColor.intSaturation|{}intSaturation[0]
+ final fun (): kotlin/Int // codes.side.colorpicker.model/OkhsvColor.intSaturation.|(){}[0]
+ final val intValue // codes.side.colorpicker.model/OkhsvColor.intValue|{}intValue[0]
+ final fun (): kotlin/Int // codes.side.colorpicker.model/OkhsvColor.intValue.|(){}[0]
+ final val saturation // codes.side.colorpicker.model/OkhsvColor.saturation|{}saturation[0]
+ final fun (): kotlin/Float // codes.side.colorpicker.model/OkhsvColor.saturation.|(){}[0]
+ final val value // codes.side.colorpicker.model/OkhsvColor.value|{}value[0]
+ final fun (): kotlin/Float // codes.side.colorpicker.model/OkhsvColor.value.|(){}[0]
+
+ final fun copy(kotlin/Float = ..., kotlin/Float = ..., kotlin/Float = ..., kotlin/Float = ...): codes.side.colorpicker.model/OkhsvColor // codes.side.colorpicker.model/OkhsvColor.copy|copy(kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float){}[0]
+ final fun equals(kotlin/Any?): kotlin/Boolean // codes.side.colorpicker.model/OkhsvColor.equals|equals(kotlin.Any?){}[0]
+ final fun hashCode(): kotlin/Int // codes.side.colorpicker.model/OkhsvColor.hashCode|hashCode(){}[0]
+ final fun toString(): kotlin/String // codes.side.colorpicker.model/OkhsvColor.toString|toString(){}[0]
+
+ final object Companion { // codes.side.colorpicker.model/OkhsvColor.Companion|null[0]
+ final val Black // codes.side.colorpicker.model/OkhsvColor.Companion.Black|{}Black[0]
+ final fun (): codes.side.colorpicker.model/OkhsvColor // codes.side.colorpicker.model/OkhsvColor.Companion.Black.|(){}[0]
+ final val White // codes.side.colorpicker.model/OkhsvColor.Companion.White|{}White[0]
+ final fun (): codes.side.colorpicker.model/OkhsvColor // codes.side.colorpicker.model/OkhsvColor.Companion.White.|(){}[0]
+
+ final fun fromInt(kotlin/Int, kotlin/Int, kotlin/Int, kotlin/Int = ...): codes.side.colorpicker.model/OkhsvColor // codes.side.colorpicker.model/OkhsvColor.Companion.fromInt|fromInt(kotlin.Int;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
+ }
+}
+
+final class codes.side.colorpicker.model/OklabColor : codes.side.colorpicker.model/PickerColor { // codes.side.colorpicker.model/OklabColor|null[0]
+ constructor (kotlin/Float = ..., kotlin/Float = ..., kotlin/Float = ..., kotlin/Float = ...) // codes.side.colorpicker.model/OklabColor.|(kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float){}[0]
+
+ final val a // codes.side.colorpicker.model/OklabColor.a|{}a[0]
+ final fun (): kotlin/Float // codes.side.colorpicker.model/OklabColor.a.|(){}[0]
+ final val alpha // codes.side.colorpicker.model/OklabColor.alpha|{}alpha[0]
+ final fun (): kotlin/Float // codes.side.colorpicker.model/OklabColor.alpha.|(){}[0]
+ final val b // codes.side.colorpicker.model/OklabColor.b|{}b[0]
+ final fun (): kotlin/Float // codes.side.colorpicker.model/OklabColor.b.|(){}[0]
+ final val intA // codes.side.colorpicker.model/OklabColor.intA|{}intA[0]
+ final fun (): kotlin/Int // codes.side.colorpicker.model/OklabColor.intA.|(){}[0]
+ final val intAlpha // codes.side.colorpicker.model/OklabColor.intAlpha|{}intAlpha[0]
+ final fun (): kotlin/Int // codes.side.colorpicker.model/OklabColor.intAlpha.|(){}[0]
+ final val intB // codes.side.colorpicker.model/OklabColor.intB|{}intB[0]
+ final fun (): kotlin/Int // codes.side.colorpicker.model/OklabColor.intB.|(){}[0]
+ final val intL // codes.side.colorpicker.model/OklabColor.intL|{}intL[0]
+ final fun (): kotlin/Int // codes.side.colorpicker.model/OklabColor.intL.|(){}[0]
+ final val l // codes.side.colorpicker.model/OklabColor.l|{}l[0]
+ final fun (): kotlin/Float // codes.side.colorpicker.model/OklabColor.l.|(){}[0]
+
+ final fun copy(kotlin/Float = ..., kotlin/Float = ..., kotlin/Float = ..., kotlin/Float = ...): codes.side.colorpicker.model/OklabColor // codes.side.colorpicker.model/OklabColor.copy|copy(kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float){}[0]
+ final fun equals(kotlin/Any?): kotlin/Boolean // codes.side.colorpicker.model/OklabColor.equals|equals(kotlin.Any?){}[0]
+ final fun hashCode(): kotlin/Int // codes.side.colorpicker.model/OklabColor.hashCode|hashCode(){}[0]
+ final fun toString(): kotlin/String // codes.side.colorpicker.model/OklabColor.toString|toString(){}[0]
+
+ final object Companion { // codes.side.colorpicker.model/OklabColor.Companion|null[0]
+ final val Black // codes.side.colorpicker.model/OklabColor.Companion.Black|{}Black[0]
+ final fun (): codes.side.colorpicker.model/OklabColor // codes.side.colorpicker.model/OklabColor.Companion.Black.|(){}[0]
+ final val White // codes.side.colorpicker.model/OklabColor.Companion.White|{}White[0]
+ final fun (): codes.side.colorpicker.model/OklabColor // codes.side.colorpicker.model/OklabColor.Companion.White.|(){}[0]
+
+ final fun fromInt(kotlin/Int, kotlin/Int, kotlin/Int, kotlin/Int = ...): codes.side.colorpicker.model/OklabColor // codes.side.colorpicker.model/OklabColor.Companion.fromInt|fromInt(kotlin.Int;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
+ }
+}
+
+final class codes.side.colorpicker.model/OklchColor : codes.side.colorpicker.model/PickerColor { // codes.side.colorpicker.model/OklchColor|null[0]
+ constructor (kotlin/Float = ..., kotlin/Float = ..., kotlin/Float = ..., kotlin/Float = ...) // codes.side.colorpicker.model/OklchColor.|(kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float){}[0]
+
+ final val alpha // codes.side.colorpicker.model/OklchColor.alpha|{}alpha[0]
+ final fun (): kotlin/Float // codes.side.colorpicker.model/OklchColor.alpha.|(){}[0]
+ final val chroma // codes.side.colorpicker.model/OklchColor.chroma|{}chroma[0]
+ final fun (): kotlin/Float // codes.side.colorpicker.model/OklchColor.chroma.|(){}[0]
+ final val hue // codes.side.colorpicker.model/OklchColor.hue|{}hue[0]
+ final fun (): kotlin/Float // codes.side.colorpicker.model/OklchColor.hue.|(){}[0]
+ final val intAlpha // codes.side.colorpicker.model/OklchColor.intAlpha|{}intAlpha[0]
+ final fun (): kotlin/Int // codes.side.colorpicker.model/OklchColor.intAlpha.|(){}[0]
+ final val intChroma // codes.side.colorpicker.model/OklchColor.intChroma|{}intChroma[0]
+ final fun (): kotlin/Int // codes.side.colorpicker.model/OklchColor.intChroma.|(){}[0]
+ final val intHue // codes.side.colorpicker.model/OklchColor.intHue|{}intHue[0]
+ final fun (): kotlin/Int // codes.side.colorpicker.model/OklchColor.intHue.|(){}[0]
+ final val intL // codes.side.colorpicker.model/OklchColor.intL|{}intL[0]
+ final fun (): kotlin/Int // codes.side.colorpicker.model/OklchColor.intL.|(){}[0]
+ final val l // codes.side.colorpicker.model/OklchColor.l|{}l[0]
+ final fun (): kotlin/Float // codes.side.colorpicker.model/OklchColor.l.|(){}[0]
+
+ final fun copy(kotlin/Float = ..., kotlin/Float = ..., kotlin/Float = ..., kotlin/Float = ...): codes.side.colorpicker.model/OklchColor // codes.side.colorpicker.model/OklchColor.copy|copy(kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float){}[0]
+ final fun equals(kotlin/Any?): kotlin/Boolean // codes.side.colorpicker.model/OklchColor.equals|equals(kotlin.Any?){}[0]
+ final fun hashCode(): kotlin/Int // codes.side.colorpicker.model/OklchColor.hashCode|hashCode(){}[0]
+ final fun toString(): kotlin/String // codes.side.colorpicker.model/OklchColor.toString|toString(){}[0]
+
+ final object Companion { // codes.side.colorpicker.model/OklchColor.Companion|null[0]
+ final val Black // codes.side.colorpicker.model/OklchColor.Companion.Black|{}Black[0]
+ final fun (): codes.side.colorpicker.model/OklchColor // codes.side.colorpicker.model/OklchColor.Companion.Black.|(){}[0]
+ final val White // codes.side.colorpicker.model/OklchColor.Companion.White|{}White[0]
+ final fun (): codes.side.colorpicker.model/OklchColor // codes.side.colorpicker.model/OklchColor.Companion.White.|(){}[0]
+
+ final fun fromInt(kotlin/Int, kotlin/Int, kotlin/Int, kotlin/Int = ...): codes.side.colorpicker.model/OklchColor // codes.side.colorpicker.model/OklchColor.Companion.fromInt|fromInt(kotlin.Int;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
+ }
+}
+
final class codes.side.colorpicker.model/RgbColor : codes.side.colorpicker.model/PickerColor { // codes.side.colorpicker.model/RgbColor|null[0]
constructor (kotlin/Float = ..., kotlin/Float = ..., kotlin/Float = ..., kotlin/Float = ...) // codes.side.colorpicker.model/RgbColor.|(kotlin.Float;kotlin.Float;kotlin.Float;kotlin.Float){}[0]
@@ -185,6 +325,14 @@ final class codes.side.colorpicker.state/ColorPickerState { // codes.side.colorp
final fun (): codes.side.colorpicker.model/HslColor // codes.side.colorpicker.state/ColorPickerState.hslColor.|(){}[0]
final val labColor // codes.side.colorpicker.state/ColorPickerState.labColor|{}labColor[0]
final fun (): codes.side.colorpicker.model/LabColor // codes.side.colorpicker.state/ColorPickerState.labColor.|(){}[0]
+ final val okhslColor // codes.side.colorpicker.state/ColorPickerState.okhslColor|{}okhslColor[0]
+ final fun (): codes.side.colorpicker.model/OkhslColor // codes.side.colorpicker.state/ColorPickerState.okhslColor.|(){}[0]
+ final val okhsvColor // codes.side.colorpicker.state/ColorPickerState.okhsvColor|{}okhsvColor[0]
+ final fun (): codes.side.colorpicker.model/OkhsvColor // codes.side.colorpicker.state/ColorPickerState.okhsvColor.|(){}[0]
+ final val oklabColor // codes.side.colorpicker.state/ColorPickerState.oklabColor|{}oklabColor[0]
+ final fun (): codes.side.colorpicker.model/OklabColor // codes.side.colorpicker.state/ColorPickerState.oklabColor.|(){}[0]
+ final val oklchColor // codes.side.colorpicker.state/ColorPickerState.oklchColor|{}oklchColor[0]
+ final fun (): codes.side.colorpicker.model/OklchColor // codes.side.colorpicker.state/ColorPickerState.oklchColor.|(){}[0]
final val pickerColor // codes.side.colorpicker.state/ColorPickerState.pickerColor|{}pickerColor[0]
final fun (): codes.side.colorpicker.model/PickerColor // codes.side.colorpicker.state/ColorPickerState.pickerColor.|(){}[0]
final val rgbColor // codes.side.colorpicker.state/ColorPickerState.rgbColor|{}rgbColor[0]
@@ -200,6 +348,10 @@ final class codes.side.colorpicker.state/ColorPickerState { // codes.side.colorp
final fun updateFromCmyk(codes.side.colorpicker.model/CmykColor) // codes.side.colorpicker.state/ColorPickerState.updateFromCmyk|updateFromCmyk(codes.side.colorpicker.model.CmykColor){}[0]
final fun updateFromHsl(codes.side.colorpicker.model/HslColor) // codes.side.colorpicker.state/ColorPickerState.updateFromHsl|updateFromHsl(codes.side.colorpicker.model.HslColor){}[0]
final fun updateFromLab(codes.side.colorpicker.model/LabColor) // codes.side.colorpicker.state/ColorPickerState.updateFromLab|updateFromLab(codes.side.colorpicker.model.LabColor){}[0]
+ final fun updateFromOkhsl(codes.side.colorpicker.model/OkhslColor) // codes.side.colorpicker.state/ColorPickerState.updateFromOkhsl|updateFromOkhsl(codes.side.colorpicker.model.OkhslColor){}[0]
+ final fun updateFromOkhsv(codes.side.colorpicker.model/OkhsvColor) // codes.side.colorpicker.state/ColorPickerState.updateFromOkhsv|updateFromOkhsv(codes.side.colorpicker.model.OkhsvColor){}[0]
+ final fun updateFromOklab(codes.side.colorpicker.model/OklabColor) // codes.side.colorpicker.state/ColorPickerState.updateFromOklab|updateFromOklab(codes.side.colorpicker.model.OklabColor){}[0]
+ final fun updateFromOklch(codes.side.colorpicker.model/OklchColor) // codes.side.colorpicker.state/ColorPickerState.updateFromOklch|updateFromOklch(codes.side.colorpicker.model.OklchColor){}[0]
final fun updateFromRgb(codes.side.colorpicker.model/RgbColor) // codes.side.colorpicker.state/ColorPickerState.updateFromRgb|updateFromRgb(codes.side.colorpicker.model.RgbColor){}[0]
final fun updateGreen(kotlin/Float) // codes.side.colorpicker.state/ColorPickerState.updateGreen|updateGreen(kotlin.Float){}[0]
final fun updateHue(kotlin/Float) // codes.side.colorpicker.state/ColorPickerState.updateHue|updateHue(kotlin.Float){}[0]
@@ -209,6 +361,18 @@ final class codes.side.colorpicker.state/ColorPickerState { // codes.side.colorp
final fun updateLabLightness(kotlin/Float) // codes.side.colorpicker.state/ColorPickerState.updateLabLightness|updateLabLightness(kotlin.Float){}[0]
final fun updateLightness(kotlin/Float) // codes.side.colorpicker.state/ColorPickerState.updateLightness|updateLightness(kotlin.Float){}[0]
final fun updateMagenta(kotlin/Float) // codes.side.colorpicker.state/ColorPickerState.updateMagenta|updateMagenta(kotlin.Float){}[0]
+ final fun updateOkhslHue(kotlin/Float) // codes.side.colorpicker.state/ColorPickerState.updateOkhslHue|updateOkhslHue(kotlin.Float){}[0]
+ final fun updateOkhslLightness(kotlin/Float) // codes.side.colorpicker.state/ColorPickerState.updateOkhslLightness|updateOkhslLightness(kotlin.Float){}[0]
+ final fun updateOkhslSaturation(kotlin/Float) // codes.side.colorpicker.state/ColorPickerState.updateOkhslSaturation|updateOkhslSaturation(kotlin.Float){}[0]
+ final fun updateOkhsvHue(kotlin/Float) // codes.side.colorpicker.state/ColorPickerState.updateOkhsvHue|updateOkhsvHue(kotlin.Float){}[0]
+ final fun updateOkhsvSaturation(kotlin/Float) // codes.side.colorpicker.state/ColorPickerState.updateOkhsvSaturation|updateOkhsvSaturation(kotlin.Float){}[0]
+ final fun updateOkhsvValue(kotlin/Float) // codes.side.colorpicker.state/ColorPickerState.updateOkhsvValue|updateOkhsvValue(kotlin.Float){}[0]
+ final fun updateOklabA(kotlin/Float) // codes.side.colorpicker.state/ColorPickerState.updateOklabA|updateOklabA(kotlin.Float){}[0]
+ final fun updateOklabB(kotlin/Float) // codes.side.colorpicker.state/ColorPickerState.updateOklabB|updateOklabB(kotlin.Float){}[0]
+ final fun updateOklabLightness(kotlin/Float) // codes.side.colorpicker.state/ColorPickerState.updateOklabLightness|updateOklabLightness(kotlin.Float){}[0]
+ final fun updateOklchChroma(kotlin/Float) // codes.side.colorpicker.state/ColorPickerState.updateOklchChroma|updateOklchChroma(kotlin.Float){}[0]
+ final fun updateOklchHue(kotlin/Float) // codes.side.colorpicker.state/ColorPickerState.updateOklchHue|updateOklchHue(kotlin.Float){}[0]
+ final fun updateOklchLightness(kotlin/Float) // codes.side.colorpicker.state/ColorPickerState.updateOklchLightness|updateOklchLightness(kotlin.Float){}[0]
final fun updateRed(kotlin/Float) // codes.side.colorpicker.state/ColorPickerState.updateRed|updateRed(kotlin.Float){}[0]
final fun updateSaturation(kotlin/Float) // codes.side.colorpicker.state/ColorPickerState.updateSaturation|updateSaturation(kotlin.Float){}[0]
final fun updateYellow(kotlin/Float) // codes.side.colorpicker.state/ColorPickerState.updateYellow|updateYellow(kotlin.Float){}[0]
@@ -263,6 +427,10 @@ final object codes.side.colorpicker.theme/ColorPickerDefaults { // codes.side.co
final val codes.side.colorpicker.model/codes_side_colorpicker_model_CmykColor$stableprop // codes.side.colorpicker.model/codes_side_colorpicker_model_CmykColor$stableprop|#static{}codes_side_colorpicker_model_CmykColor$stableprop[0]
final val codes.side.colorpicker.model/codes_side_colorpicker_model_HslColor$stableprop // codes.side.colorpicker.model/codes_side_colorpicker_model_HslColor$stableprop|#static{}codes_side_colorpicker_model_HslColor$stableprop[0]
final val codes.side.colorpicker.model/codes_side_colorpicker_model_LabColor$stableprop // codes.side.colorpicker.model/codes_side_colorpicker_model_LabColor$stableprop|#static{}codes_side_colorpicker_model_LabColor$stableprop[0]
+final val codes.side.colorpicker.model/codes_side_colorpicker_model_OkhslColor$stableprop // codes.side.colorpicker.model/codes_side_colorpicker_model_OkhslColor$stableprop|#static{}codes_side_colorpicker_model_OkhslColor$stableprop[0]
+final val codes.side.colorpicker.model/codes_side_colorpicker_model_OkhsvColor$stableprop // codes.side.colorpicker.model/codes_side_colorpicker_model_OkhsvColor$stableprop|#static{}codes_side_colorpicker_model_OkhsvColor$stableprop[0]
+final val codes.side.colorpicker.model/codes_side_colorpicker_model_OklabColor$stableprop // codes.side.colorpicker.model/codes_side_colorpicker_model_OklabColor$stableprop|#static{}codes_side_colorpicker_model_OklabColor$stableprop[0]
+final val codes.side.colorpicker.model/codes_side_colorpicker_model_OklchColor$stableprop // codes.side.colorpicker.model/codes_side_colorpicker_model_OklchColor$stableprop|#static{}codes_side_colorpicker_model_OklchColor$stableprop[0]
final val codes.side.colorpicker.model/codes_side_colorpicker_model_RgbColor$stableprop // codes.side.colorpicker.model/codes_side_colorpicker_model_RgbColor$stableprop|#static{}codes_side_colorpicker_model_RgbColor$stableprop[0]
final val codes.side.colorpicker.state/codes_side_colorpicker_state_ColorPickerState$stableprop // codes.side.colorpicker.state/codes_side_colorpicker_state_ColorPickerState$stableprop|#static{}codes_side_colorpicker_state_ColorPickerState$stableprop[0]
final val codes.side.colorpicker.theme/codes_side_colorpicker_theme_ColorPickerColors$stableprop // codes.side.colorpicker.theme/codes_side_colorpicker_theme_ColorPickerColors$stableprop|#static{}codes_side_colorpicker_theme_ColorPickerColors$stableprop[0]
@@ -272,6 +440,10 @@ final val codes.side.colorpicker.theme/codes_side_colorpicker_theme_ColorPickerS
final fun (androidx.compose.ui.graphics/Color).codes.side.colorpicker.conversion/toCmykColor(): codes.side.colorpicker.model/CmykColor // codes.side.colorpicker.conversion/toCmykColor|toCmykColor@androidx.compose.ui.graphics.Color(){}[0]
final fun (androidx.compose.ui.graphics/Color).codes.side.colorpicker.conversion/toHslColor(): codes.side.colorpicker.model/HslColor // codes.side.colorpicker.conversion/toHslColor|toHslColor@androidx.compose.ui.graphics.Color(){}[0]
final fun (androidx.compose.ui.graphics/Color).codes.side.colorpicker.conversion/toLabColor(): codes.side.colorpicker.model/LabColor // codes.side.colorpicker.conversion/toLabColor|toLabColor@androidx.compose.ui.graphics.Color(){}[0]
+final fun (androidx.compose.ui.graphics/Color).codes.side.colorpicker.conversion/toOkhslColor(): codes.side.colorpicker.model/OkhslColor // codes.side.colorpicker.conversion/toOkhslColor|toOkhslColor@androidx.compose.ui.graphics.Color(){}[0]
+final fun (androidx.compose.ui.graphics/Color).codes.side.colorpicker.conversion/toOkhsvColor(): codes.side.colorpicker.model/OkhsvColor // codes.side.colorpicker.conversion/toOkhsvColor|toOkhsvColor@androidx.compose.ui.graphics.Color(){}[0]
+final fun (androidx.compose.ui.graphics/Color).codes.side.colorpicker.conversion/toOklabColor(): codes.side.colorpicker.model/OklabColor // codes.side.colorpicker.conversion/toOklabColor|toOklabColor@androidx.compose.ui.graphics.Color(){}[0]
+final fun (androidx.compose.ui.graphics/Color).codes.side.colorpicker.conversion/toOklchColor(): codes.side.colorpicker.model/OklchColor // codes.side.colorpicker.conversion/toOklchColor|toOklchColor@androidx.compose.ui.graphics.Color(){}[0]
final fun (androidx.compose.ui.graphics/Color).codes.side.colorpicker.conversion/toRgbColor(): codes.side.colorpicker.model/RgbColor // codes.side.colorpicker.conversion/toRgbColor|toRgbColor@androidx.compose.ui.graphics.Color(){}[0]
final fun (codes.side.colorpicker.model/CmykColor).codes.side.colorpicker.conversion/toArgbInt(): kotlin/Int // codes.side.colorpicker.conversion/toArgbInt|toArgbInt@codes.side.colorpicker.model.CmykColor(){}[0]
final fun (codes.side.colorpicker.model/CmykColor).codes.side.colorpicker.conversion/toComposeColor(): androidx.compose.ui.graphics/Color // codes.side.colorpicker.conversion/toComposeColor|toComposeColor@codes.side.colorpicker.model.CmykColor(){}[0]
@@ -281,27 +453,61 @@ final fun (codes.side.colorpicker.model/HslColor).codes.side.colorpicker.convers
final fun (codes.side.colorpicker.model/HslColor).codes.side.colorpicker.conversion/toCmyk(): codes.side.colorpicker.model/CmykColor // codes.side.colorpicker.conversion/toCmyk|toCmyk@codes.side.colorpicker.model.HslColor(){}[0]
final fun (codes.side.colorpicker.model/HslColor).codes.side.colorpicker.conversion/toComposeColor(): androidx.compose.ui.graphics/Color // codes.side.colorpicker.conversion/toComposeColor|toComposeColor@codes.side.colorpicker.model.HslColor(){}[0]
final fun (codes.side.colorpicker.model/HslColor).codes.side.colorpicker.conversion/toLab(): codes.side.colorpicker.model/LabColor // codes.side.colorpicker.conversion/toLab|toLab@codes.side.colorpicker.model.HslColor(){}[0]
+final fun (codes.side.colorpicker.model/HslColor).codes.side.colorpicker.conversion/toOkhsl(): codes.side.colorpicker.model/OkhslColor // codes.side.colorpicker.conversion/toOkhsl|toOkhsl@codes.side.colorpicker.model.HslColor(){}[0]
+final fun (codes.side.colorpicker.model/HslColor).codes.side.colorpicker.conversion/toOkhsv(): codes.side.colorpicker.model/OkhsvColor // codes.side.colorpicker.conversion/toOkhsv|toOkhsv@codes.side.colorpicker.model.HslColor(){}[0]
+final fun (codes.side.colorpicker.model/HslColor).codes.side.colorpicker.conversion/toOklab(): codes.side.colorpicker.model/OklabColor // codes.side.colorpicker.conversion/toOklab|toOklab@codes.side.colorpicker.model.HslColor(){}[0]
+final fun (codes.side.colorpicker.model/HslColor).codes.side.colorpicker.conversion/toOklch(): codes.side.colorpicker.model/OklchColor // codes.side.colorpicker.conversion/toOklch|toOklch@codes.side.colorpicker.model.HslColor(){}[0]
final fun (codes.side.colorpicker.model/HslColor).codes.side.colorpicker.conversion/toRgb(): codes.side.colorpicker.model/RgbColor // codes.side.colorpicker.conversion/toRgb|toRgb@codes.side.colorpicker.model.HslColor(){}[0]
final fun (codes.side.colorpicker.model/LabColor).codes.side.colorpicker.conversion/toArgbInt(): kotlin/Int // codes.side.colorpicker.conversion/toArgbInt|toArgbInt@codes.side.colorpicker.model.LabColor(){}[0]
final fun (codes.side.colorpicker.model/LabColor).codes.side.colorpicker.conversion/toComposeColor(): androidx.compose.ui.graphics/Color // codes.side.colorpicker.conversion/toComposeColor|toComposeColor@codes.side.colorpicker.model.LabColor(){}[0]
final fun (codes.side.colorpicker.model/LabColor).codes.side.colorpicker.conversion/toHsl(): codes.side.colorpicker.model/HslColor // codes.side.colorpicker.conversion/toHsl|toHsl@codes.side.colorpicker.model.LabColor(){}[0]
final fun (codes.side.colorpicker.model/LabColor).codes.side.colorpicker.conversion/toRgb(): codes.side.colorpicker.model/RgbColor // codes.side.colorpicker.conversion/toRgb|toRgb@codes.side.colorpicker.model.LabColor(){}[0]
+final fun (codes.side.colorpicker.model/OkhslColor).codes.side.colorpicker.conversion/toArgbInt(): kotlin/Int // codes.side.colorpicker.conversion/toArgbInt|toArgbInt@codes.side.colorpicker.model.OkhslColor(){}[0]
+final fun (codes.side.colorpicker.model/OkhslColor).codes.side.colorpicker.conversion/toComposeColor(): androidx.compose.ui.graphics/Color // codes.side.colorpicker.conversion/toComposeColor|toComposeColor@codes.side.colorpicker.model.OkhslColor(){}[0]
+final fun (codes.side.colorpicker.model/OkhslColor).codes.side.colorpicker.conversion/toHsl(): codes.side.colorpicker.model/HslColor // codes.side.colorpicker.conversion/toHsl|toHsl@codes.side.colorpicker.model.OkhslColor(){}[0]
+final fun (codes.side.colorpicker.model/OkhslColor).codes.side.colorpicker.conversion/toRgb(): codes.side.colorpicker.model/RgbColor // codes.side.colorpicker.conversion/toRgb|toRgb@codes.side.colorpicker.model.OkhslColor(){}[0]
+final fun (codes.side.colorpicker.model/OkhsvColor).codes.side.colorpicker.conversion/toArgbInt(): kotlin/Int // codes.side.colorpicker.conversion/toArgbInt|toArgbInt@codes.side.colorpicker.model.OkhsvColor(){}[0]
+final fun (codes.side.colorpicker.model/OkhsvColor).codes.side.colorpicker.conversion/toComposeColor(): androidx.compose.ui.graphics/Color // codes.side.colorpicker.conversion/toComposeColor|toComposeColor@codes.side.colorpicker.model.OkhsvColor(){}[0]
+final fun (codes.side.colorpicker.model/OkhsvColor).codes.side.colorpicker.conversion/toHsl(): codes.side.colorpicker.model/HslColor // codes.side.colorpicker.conversion/toHsl|toHsl@codes.side.colorpicker.model.OkhsvColor(){}[0]
+final fun (codes.side.colorpicker.model/OkhsvColor).codes.side.colorpicker.conversion/toRgb(): codes.side.colorpicker.model/RgbColor // codes.side.colorpicker.conversion/toRgb|toRgb@codes.side.colorpicker.model.OkhsvColor(){}[0]
+final fun (codes.side.colorpicker.model/OklabColor).codes.side.colorpicker.conversion/toArgbInt(): kotlin/Int // codes.side.colorpicker.conversion/toArgbInt|toArgbInt@codes.side.colorpicker.model.OklabColor(){}[0]
+final fun (codes.side.colorpicker.model/OklabColor).codes.side.colorpicker.conversion/toComposeColor(): androidx.compose.ui.graphics/Color // codes.side.colorpicker.conversion/toComposeColor|toComposeColor@codes.side.colorpicker.model.OklabColor(){}[0]
+final fun (codes.side.colorpicker.model/OklabColor).codes.side.colorpicker.conversion/toHsl(): codes.side.colorpicker.model/HslColor // codes.side.colorpicker.conversion/toHsl|toHsl@codes.side.colorpicker.model.OklabColor(){}[0]
+final fun (codes.side.colorpicker.model/OklabColor).codes.side.colorpicker.conversion/toOklch(): codes.side.colorpicker.model/OklchColor // codes.side.colorpicker.conversion/toOklch|toOklch@codes.side.colorpicker.model.OklabColor(){}[0]
+final fun (codes.side.colorpicker.model/OklabColor).codes.side.colorpicker.conversion/toRgb(): codes.side.colorpicker.model/RgbColor // codes.side.colorpicker.conversion/toRgb|toRgb@codes.side.colorpicker.model.OklabColor(){}[0]
+final fun (codes.side.colorpicker.model/OklchColor).codes.side.colorpicker.conversion/toArgbInt(): kotlin/Int // codes.side.colorpicker.conversion/toArgbInt|toArgbInt@codes.side.colorpicker.model.OklchColor(){}[0]
+final fun (codes.side.colorpicker.model/OklchColor).codes.side.colorpicker.conversion/toComposeColor(): androidx.compose.ui.graphics/Color // codes.side.colorpicker.conversion/toComposeColor|toComposeColor@codes.side.colorpicker.model.OklchColor(){}[0]
+final fun (codes.side.colorpicker.model/OklchColor).codes.side.colorpicker.conversion/toHsl(): codes.side.colorpicker.model/HslColor // codes.side.colorpicker.conversion/toHsl|toHsl@codes.side.colorpicker.model.OklchColor(){}[0]
+final fun (codes.side.colorpicker.model/OklchColor).codes.side.colorpicker.conversion/toOklab(): codes.side.colorpicker.model/OklabColor // codes.side.colorpicker.conversion/toOklab|toOklab@codes.side.colorpicker.model.OklchColor(){}[0]
+final fun (codes.side.colorpicker.model/OklchColor).codes.side.colorpicker.conversion/toRgb(): codes.side.colorpicker.model/RgbColor // codes.side.colorpicker.conversion/toRgb|toRgb@codes.side.colorpicker.model.OklchColor(){}[0]
final fun (codes.side.colorpicker.model/PickerColor).codes.side.colorpicker.conversion/toHexString(kotlin/Boolean = ...): kotlin/String // codes.side.colorpicker.conversion/toHexString|toHexString@codes.side.colorpicker.model.PickerColor(kotlin.Boolean){}[0]
final fun (codes.side.colorpicker.model/RgbColor).codes.side.colorpicker.conversion/toArgbInt(): kotlin/Int // codes.side.colorpicker.conversion/toArgbInt|toArgbInt@codes.side.colorpicker.model.RgbColor(){}[0]
final fun (codes.side.colorpicker.model/RgbColor).codes.side.colorpicker.conversion/toCmyk(): codes.side.colorpicker.model/CmykColor // codes.side.colorpicker.conversion/toCmyk|toCmyk@codes.side.colorpicker.model.RgbColor(){}[0]
final fun (codes.side.colorpicker.model/RgbColor).codes.side.colorpicker.conversion/toComposeColor(): androidx.compose.ui.graphics/Color // codes.side.colorpicker.conversion/toComposeColor|toComposeColor@codes.side.colorpicker.model.RgbColor(){}[0]
final fun (codes.side.colorpicker.model/RgbColor).codes.side.colorpicker.conversion/toHsl(): codes.side.colorpicker.model/HslColor // codes.side.colorpicker.conversion/toHsl|toHsl@codes.side.colorpicker.model.RgbColor(){}[0]
final fun (codes.side.colorpicker.model/RgbColor).codes.side.colorpicker.conversion/toLab(): codes.side.colorpicker.model/LabColor // codes.side.colorpicker.conversion/toLab|toLab@codes.side.colorpicker.model.RgbColor(){}[0]
+final fun (codes.side.colorpicker.model/RgbColor).codes.side.colorpicker.conversion/toOkhsl(): codes.side.colorpicker.model/OkhslColor // codes.side.colorpicker.conversion/toOkhsl|toOkhsl@codes.side.colorpicker.model.RgbColor(){}[0]
+final fun (codes.side.colorpicker.model/RgbColor).codes.side.colorpicker.conversion/toOkhsv(): codes.side.colorpicker.model/OkhsvColor // codes.side.colorpicker.conversion/toOkhsv|toOkhsv@codes.side.colorpicker.model.RgbColor(){}[0]
+final fun (codes.side.colorpicker.model/RgbColor).codes.side.colorpicker.conversion/toOklab(): codes.side.colorpicker.model/OklabColor // codes.side.colorpicker.conversion/toOklab|toOklab@codes.side.colorpicker.model.RgbColor(){}[0]
+final fun (codes.side.colorpicker.model/RgbColor).codes.side.colorpicker.conversion/toOklch(): codes.side.colorpicker.model/OklchColor // codes.side.colorpicker.conversion/toOklch|toOklch@codes.side.colorpicker.model.RgbColor(){}[0]
final fun (kotlin/Int).codes.side.colorpicker.conversion/toCmykColor(): codes.side.colorpicker.model/CmykColor // codes.side.colorpicker.conversion/toCmykColor|toCmykColor@kotlin.Int(){}[0]
final fun (kotlin/Int).codes.side.colorpicker.conversion/toHexColorString(kotlin/Boolean = ...): kotlin/String // codes.side.colorpicker.conversion/toHexColorString|toHexColorString@kotlin.Int(kotlin.Boolean){}[0]
final fun (kotlin/Int).codes.side.colorpicker.conversion/toHslColor(): codes.side.colorpicker.model/HslColor // codes.side.colorpicker.conversion/toHslColor|toHslColor@kotlin.Int(){}[0]
final fun (kotlin/Int).codes.side.colorpicker.conversion/toLabColor(): codes.side.colorpicker.model/LabColor // codes.side.colorpicker.conversion/toLabColor|toLabColor@kotlin.Int(){}[0]
+final fun (kotlin/Int).codes.side.colorpicker.conversion/toOkhslColor(): codes.side.colorpicker.model/OkhslColor // codes.side.colorpicker.conversion/toOkhslColor|toOkhslColor@kotlin.Int(){}[0]
+final fun (kotlin/Int).codes.side.colorpicker.conversion/toOkhsvColor(): codes.side.colorpicker.model/OkhsvColor // codes.side.colorpicker.conversion/toOkhsvColor|toOkhsvColor@kotlin.Int(){}[0]
+final fun (kotlin/Int).codes.side.colorpicker.conversion/toOklabColor(): codes.side.colorpicker.model/OklabColor // codes.side.colorpicker.conversion/toOklabColor|toOklabColor@kotlin.Int(){}[0]
+final fun (kotlin/Int).codes.side.colorpicker.conversion/toOklchColor(): codes.side.colorpicker.model/OklchColor // codes.side.colorpicker.conversion/toOklchColor|toOklchColor@kotlin.Int(){}[0]
final fun (kotlin/Int).codes.side.colorpicker.conversion/toRgbColor(): codes.side.colorpicker.model/RgbColor // codes.side.colorpicker.conversion/toRgbColor|toRgbColor@kotlin.Int(){}[0]
final fun (kotlin/String).codes.side.colorpicker.conversion/toRgbColor(): codes.side.colorpicker.model/RgbColor // codes.side.colorpicker.conversion/toRgbColor|toRgbColor@kotlin.String(){}[0]
final fun (kotlin/String).codes.side.colorpicker.conversion/toRgbColorOrNull(): codes.side.colorpicker.model/RgbColor? // codes.side.colorpicker.conversion/toRgbColorOrNull|toRgbColorOrNull@kotlin.String(){}[0]
final fun codes.side.colorpicker.model/codes_side_colorpicker_model_CmykColor$stableprop_getter(): kotlin/Int // codes.side.colorpicker.model/codes_side_colorpicker_model_CmykColor$stableprop_getter|codes_side_colorpicker_model_CmykColor$stableprop_getter(){}[0]
final fun codes.side.colorpicker.model/codes_side_colorpicker_model_HslColor$stableprop_getter(): kotlin/Int // codes.side.colorpicker.model/codes_side_colorpicker_model_HslColor$stableprop_getter|codes_side_colorpicker_model_HslColor$stableprop_getter(){}[0]
final fun codes.side.colorpicker.model/codes_side_colorpicker_model_LabColor$stableprop_getter(): kotlin/Int // codes.side.colorpicker.model/codes_side_colorpicker_model_LabColor$stableprop_getter|codes_side_colorpicker_model_LabColor$stableprop_getter(){}[0]
+final fun codes.side.colorpicker.model/codes_side_colorpicker_model_OkhslColor$stableprop_getter(): kotlin/Int // codes.side.colorpicker.model/codes_side_colorpicker_model_OkhslColor$stableprop_getter|codes_side_colorpicker_model_OkhslColor$stableprop_getter(){}[0]
+final fun codes.side.colorpicker.model/codes_side_colorpicker_model_OkhsvColor$stableprop_getter(): kotlin/Int // codes.side.colorpicker.model/codes_side_colorpicker_model_OkhsvColor$stableprop_getter|codes_side_colorpicker_model_OkhsvColor$stableprop_getter(){}[0]
+final fun codes.side.colorpicker.model/codes_side_colorpicker_model_OklabColor$stableprop_getter(): kotlin/Int // codes.side.colorpicker.model/codes_side_colorpicker_model_OklabColor$stableprop_getter|codes_side_colorpicker_model_OklabColor$stableprop_getter(){}[0]
+final fun codes.side.colorpicker.model/codes_side_colorpicker_model_OklchColor$stableprop_getter(): kotlin/Int // codes.side.colorpicker.model/codes_side_colorpicker_model_OklchColor$stableprop_getter|codes_side_colorpicker_model_OklchColor$stableprop_getter(){}[0]
final fun codes.side.colorpicker.model/codes_side_colorpicker_model_RgbColor$stableprop_getter(): kotlin/Int // codes.side.colorpicker.model/codes_side_colorpicker_model_RgbColor$stableprop_getter|codes_side_colorpicker_model_RgbColor$stableprop_getter(){}[0]
final fun codes.side.colorpicker.state/codes_side_colorpicker_state_ColorPickerState$stableprop_getter(): kotlin/Int // codes.side.colorpicker.state/codes_side_colorpicker_state_ColorPickerState$stableprop_getter|codes_side_colorpicker_state_ColorPickerState$stableprop_getter(){}[0]
final fun codes.side.colorpicker.state/rememberColorPickerState(codes.side.colorpicker.model/PickerColor?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): codes.side.colorpicker.state/ColorPickerState // codes.side.colorpicker.state/rememberColorPickerState|rememberColorPickerState(codes.side.colorpicker.model.PickerColor?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
@@ -326,6 +532,14 @@ final fun codes.side.colorpicker.ui/LabColorPicker(codes.side.colorpicker.state/
final fun codes.side.colorpicker.ui/LightnessLabSlider(codes.side.colorpicker.state/ColorPickerState, androidx.compose.ui/Modifier?, codes.side.colorpicker.state/ColoringMode?, kotlin/Function2?, kotlin/Function2?, kotlin/String?, kotlin/String?, codes.side.colorpicker.theme/ColorPickerColors?, codes.side.colorpicker.theme/ColorPickerShapes?, kotlin/Function3?, androidx.compose.ui.unit/Dp, androidx.compose.ui.unit/Dp, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // codes.side.colorpicker.ui/LightnessLabSlider|LightnessLabSlider(codes.side.colorpicker.state.ColorPickerState;androidx.compose.ui.Modifier?;codes.side.colorpicker.state.ColoringMode?;kotlin.Function2?;kotlin.Function2?;kotlin.String?;kotlin.String?;codes.side.colorpicker.theme.ColorPickerColors?;codes.side.colorpicker.theme.ColorPickerShapes?;kotlin.Function3?;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
final fun codes.side.colorpicker.ui/LightnessSlider(codes.side.colorpicker.state/ColorPickerState, androidx.compose.ui/Modifier?, codes.side.colorpicker.state/ColoringMode?, kotlin/Function2?, kotlin/Function2?, kotlin/String?, kotlin/String?, codes.side.colorpicker.theme/ColorPickerColors?, codes.side.colorpicker.theme/ColorPickerShapes?, kotlin/Function3?, androidx.compose.ui.unit/Dp, androidx.compose.ui.unit/Dp, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // codes.side.colorpicker.ui/LightnessSlider|LightnessSlider(codes.side.colorpicker.state.ColorPickerState;androidx.compose.ui.Modifier?;codes.side.colorpicker.state.ColoringMode?;kotlin.Function2?;kotlin.Function2?;kotlin.String?;kotlin.String?;codes.side.colorpicker.theme.ColorPickerColors?;codes.side.colorpicker.theme.ColorPickerShapes?;kotlin.Function3?;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
final fun codes.side.colorpicker.ui/MagentaSlider(codes.side.colorpicker.state/ColorPickerState, androidx.compose.ui/Modifier?, codes.side.colorpicker.state/ColoringMode?, kotlin/Function2?, kotlin/Function2?, kotlin/String?, kotlin/String?, codes.side.colorpicker.theme/ColorPickerColors?, codes.side.colorpicker.theme/ColorPickerShapes?, kotlin/Function3?, androidx.compose.ui.unit/Dp, androidx.compose.ui.unit/Dp, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // codes.side.colorpicker.ui/MagentaSlider|MagentaSlider(codes.side.colorpicker.state.ColorPickerState;androidx.compose.ui.Modifier?;codes.side.colorpicker.state.ColoringMode?;kotlin.Function2?;kotlin.Function2?;kotlin.String?;kotlin.String?;codes.side.colorpicker.theme.ColorPickerColors?;codes.side.colorpicker.theme.ColorPickerShapes?;kotlin.Function3?;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
+final fun codes.side.colorpicker.ui/OkhslColorPicker(codes.side.colorpicker.state/ColorPickerState, androidx.compose.ui/Modifier?, kotlin/Boolean, codes.side.colorpicker.state/ColoringMode?, codes.side.colorpicker.theme/ColorPickerColors?, codes.side.colorpicker.theme/ColorPickerShapes?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // codes.side.colorpicker.ui/OkhslColorPicker|OkhslColorPicker(codes.side.colorpicker.state.ColorPickerState;androidx.compose.ui.Modifier?;kotlin.Boolean;codes.side.colorpicker.state.ColoringMode?;codes.side.colorpicker.theme.ColorPickerColors?;codes.side.colorpicker.theme.ColorPickerShapes?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
+final fun codes.side.colorpicker.ui/OkhslHueSlider(codes.side.colorpicker.state/ColorPickerState, androidx.compose.ui/Modifier?, codes.side.colorpicker.state/ColoringMode?, kotlin/Function2?, kotlin/Function2?, kotlin/String?, kotlin/String?, codes.side.colorpicker.theme/ColorPickerColors?, codes.side.colorpicker.theme/ColorPickerShapes?, kotlin/Function3?, androidx.compose.ui.unit/Dp, androidx.compose.ui.unit/Dp, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // codes.side.colorpicker.ui/OkhslHueSlider|OkhslHueSlider(codes.side.colorpicker.state.ColorPickerState;androidx.compose.ui.Modifier?;codes.side.colorpicker.state.ColoringMode?;kotlin.Function2?;kotlin.Function2?;kotlin.String?;kotlin.String?;codes.side.colorpicker.theme.ColorPickerColors?;codes.side.colorpicker.theme.ColorPickerShapes?;kotlin.Function3?;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
+final fun codes.side.colorpicker.ui/OkhslLightnessSlider(codes.side.colorpicker.state/ColorPickerState, androidx.compose.ui/Modifier?, codes.side.colorpicker.state/ColoringMode?, kotlin/Function2?, kotlin/Function2?, kotlin/String?, kotlin/String?, codes.side.colorpicker.theme/ColorPickerColors?, codes.side.colorpicker.theme/ColorPickerShapes?, kotlin/Function3?, androidx.compose.ui.unit/Dp, androidx.compose.ui.unit/Dp, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // codes.side.colorpicker.ui/OkhslLightnessSlider|OkhslLightnessSlider(codes.side.colorpicker.state.ColorPickerState;androidx.compose.ui.Modifier?;codes.side.colorpicker.state.ColoringMode?;kotlin.Function2?;kotlin.Function2?;kotlin.String?;kotlin.String?;codes.side.colorpicker.theme.ColorPickerColors?;codes.side.colorpicker.theme.ColorPickerShapes?;kotlin.Function3?;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
+final fun codes.side.colorpicker.ui/OkhslSaturationSlider(codes.side.colorpicker.state/ColorPickerState, androidx.compose.ui/Modifier?, codes.side.colorpicker.state/ColoringMode?, kotlin/Function2?, kotlin/Function2?, kotlin/String?, kotlin/String?, codes.side.colorpicker.theme/ColorPickerColors?, codes.side.colorpicker.theme/ColorPickerShapes?, kotlin/Function3?, androidx.compose.ui.unit/Dp, androidx.compose.ui.unit/Dp, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // codes.side.colorpicker.ui/OkhslSaturationSlider|OkhslSaturationSlider(codes.side.colorpicker.state.ColorPickerState;androidx.compose.ui.Modifier?;codes.side.colorpicker.state.ColoringMode?;kotlin.Function2?;kotlin.Function2?;kotlin.String?;kotlin.String?;codes.side.colorpicker.theme.ColorPickerColors?;codes.side.colorpicker.theme.ColorPickerShapes?;kotlin.Function3?;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
+final fun codes.side.colorpicker.ui/OkhsvColorPicker(codes.side.colorpicker.state/ColorPickerState, androidx.compose.ui/Modifier?, kotlin/Boolean, codes.side.colorpicker.state/ColoringMode?, codes.side.colorpicker.theme/ColorPickerColors?, codes.side.colorpicker.theme/ColorPickerShapes?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // codes.side.colorpicker.ui/OkhsvColorPicker|OkhsvColorPicker(codes.side.colorpicker.state.ColorPickerState;androidx.compose.ui.Modifier?;kotlin.Boolean;codes.side.colorpicker.state.ColoringMode?;codes.side.colorpicker.theme.ColorPickerColors?;codes.side.colorpicker.theme.ColorPickerShapes?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
+final fun codes.side.colorpicker.ui/OkhsvHueSlider(codes.side.colorpicker.state/ColorPickerState, androidx.compose.ui/Modifier?, codes.side.colorpicker.state/ColoringMode?, kotlin/Function2?, kotlin/Function2?, kotlin/String?, kotlin/String?, codes.side.colorpicker.theme/ColorPickerColors?, codes.side.colorpicker.theme/ColorPickerShapes?, kotlin/Function3?, androidx.compose.ui.unit/Dp, androidx.compose.ui.unit/Dp, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // codes.side.colorpicker.ui/OkhsvHueSlider|OkhsvHueSlider(codes.side.colorpicker.state.ColorPickerState;androidx.compose.ui.Modifier?;codes.side.colorpicker.state.ColoringMode?;kotlin.Function2?;kotlin.Function2?;kotlin.String?;kotlin.String?;codes.side.colorpicker.theme.ColorPickerColors?;codes.side.colorpicker.theme.ColorPickerShapes?;kotlin.Function3?;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
+final fun codes.side.colorpicker.ui/OkhsvSaturationSlider(codes.side.colorpicker.state/ColorPickerState, androidx.compose.ui/Modifier?, codes.side.colorpicker.state/ColoringMode?, kotlin/Function2?, kotlin/Function2?, kotlin/String?, kotlin/String?, codes.side.colorpicker.theme/ColorPickerColors?, codes.side.colorpicker.theme/ColorPickerShapes?, kotlin/Function3?, androidx.compose.ui.unit/Dp, androidx.compose.ui.unit/Dp, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // codes.side.colorpicker.ui/OkhsvSaturationSlider|OkhsvSaturationSlider(codes.side.colorpicker.state.ColorPickerState;androidx.compose.ui.Modifier?;codes.side.colorpicker.state.ColoringMode?;kotlin.Function2?;kotlin.Function2?;kotlin.String?;kotlin.String?;codes.side.colorpicker.theme.ColorPickerColors?;codes.side.colorpicker.theme.ColorPickerShapes?;kotlin.Function3?;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
+final fun codes.side.colorpicker.ui/OkhsvValueSlider(codes.side.colorpicker.state/ColorPickerState, androidx.compose.ui/Modifier?, codes.side.colorpicker.state/ColoringMode?, kotlin/Function2?, kotlin/Function2?, kotlin/String?, kotlin/String?, codes.side.colorpicker.theme/ColorPickerColors?, codes.side.colorpicker.theme/ColorPickerShapes?, kotlin/Function3?, androidx.compose.ui.unit/Dp, androidx.compose.ui.unit/Dp, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // codes.side.colorpicker.ui/OkhsvValueSlider|OkhsvValueSlider(codes.side.colorpicker.state.ColorPickerState;androidx.compose.ui.Modifier?;codes.side.colorpicker.state.ColoringMode?;kotlin.Function2?;kotlin.Function2?;kotlin.String?;kotlin.String?;codes.side.colorpicker.theme.ColorPickerColors?;codes.side.colorpicker.theme.ColorPickerShapes?;kotlin.Function3?;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
final fun codes.side.colorpicker.ui/RedSlider(codes.side.colorpicker.state/ColorPickerState, androidx.compose.ui/Modifier?, codes.side.colorpicker.state/ColoringMode?, kotlin/Function2?, kotlin/Function2?, kotlin/String?, kotlin/String?, codes.side.colorpicker.theme/ColorPickerColors?, codes.side.colorpicker.theme/ColorPickerShapes?, kotlin/Function3?, androidx.compose.ui.unit/Dp, androidx.compose.ui.unit/Dp, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // codes.side.colorpicker.ui/RedSlider|RedSlider(codes.side.colorpicker.state.ColorPickerState;androidx.compose.ui.Modifier?;codes.side.colorpicker.state.ColoringMode?;kotlin.Function2?;kotlin.Function2?;kotlin.String?;kotlin.String?;codes.side.colorpicker.theme.ColorPickerColors?;codes.side.colorpicker.theme.ColorPickerShapes?;kotlin.Function3?;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
final fun codes.side.colorpicker.ui/RgbColorPicker(codes.side.colorpicker.state/ColorPickerState, androidx.compose.ui/Modifier?, kotlin/Boolean, codes.side.colorpicker.state/ColoringMode?, codes.side.colorpicker.theme/ColorPickerColors?, codes.side.colorpicker.theme/ColorPickerShapes?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // codes.side.colorpicker.ui/RgbColorPicker|RgbColorPicker(codes.side.colorpicker.state.ColorPickerState;androidx.compose.ui.Modifier?;kotlin.Boolean;codes.side.colorpicker.state.ColoringMode?;codes.side.colorpicker.theme.ColorPickerColors?;codes.side.colorpicker.theme.ColorPickerShapes?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
final fun codes.side.colorpicker.ui/SaturationSlider(codes.side.colorpicker.state/ColorPickerState, androidx.compose.ui/Modifier?, codes.side.colorpicker.state/ColoringMode?, kotlin/Function2?, kotlin/Function2?, kotlin/String?, kotlin/String?, codes.side.colorpicker.theme/ColorPickerColors?, codes.side.colorpicker.theme/ColorPickerShapes?, kotlin/Function3?, androidx.compose.ui.unit/Dp, androidx.compose.ui.unit/Dp, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // codes.side.colorpicker.ui/SaturationSlider|SaturationSlider(codes.side.colorpicker.state.ColorPickerState;androidx.compose.ui.Modifier?;codes.side.colorpicker.state.ColoringMode?;kotlin.Function2?;kotlin.Function2?;kotlin.String?;kotlin.String?;codes.side.colorpicker.theme.ColorPickerColors?;codes.side.colorpicker.theme.ColorPickerShapes?;kotlin.Function3?;androidx.compose.ui.unit.Dp;androidx.compose.ui.unit.Dp;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
diff --git a/colorpicker/api/jvm/colorpicker.api b/colorpicker/api/jvm/colorpicker.api
index 9dab9c8..89e2ea6 100644
--- a/colorpicker/api/jvm/colorpicker.api
+++ b/colorpicker/api/jvm/colorpicker.api
@@ -17,9 +17,17 @@ public final class codes/side/colorpicker/conversion/ComposeColorExtKt {
public static final fun toComposeColor (Lcodes/side/colorpicker/model/CmykColor;)J
public static final fun toComposeColor (Lcodes/side/colorpicker/model/HslColor;)J
public static final fun toComposeColor (Lcodes/side/colorpicker/model/LabColor;)J
+ public static final fun toComposeColor (Lcodes/side/colorpicker/model/OkhslColor;)J
+ public static final fun toComposeColor (Lcodes/side/colorpicker/model/OkhsvColor;)J
+ public static final fun toComposeColor (Lcodes/side/colorpicker/model/OklabColor;)J
+ public static final fun toComposeColor (Lcodes/side/colorpicker/model/OklchColor;)J
public static final fun toComposeColor (Lcodes/side/colorpicker/model/RgbColor;)J
public static final fun toHslColor-8_81llA (J)Lcodes/side/colorpicker/model/HslColor;
public static final fun toLabColor-8_81llA (J)Lcodes/side/colorpicker/model/LabColor;
+ public static final fun toOkhslColor-8_81llA (J)Lcodes/side/colorpicker/model/OkhslColor;
+ public static final fun toOkhsvColor-8_81llA (J)Lcodes/side/colorpicker/model/OkhsvColor;
+ public static final fun toOklabColor-8_81llA (J)Lcodes/side/colorpicker/model/OklabColor;
+ public static final fun toOklchColor-8_81llA (J)Lcodes/side/colorpicker/model/OklchColor;
public static final fun toRgbColor-8_81llA (J)Lcodes/side/colorpicker/model/RgbColor;
}
@@ -48,6 +56,41 @@ public final class codes/side/colorpicker/conversion/LabConversionsKt {
public static final fun toRgb (Lcodes/side/colorpicker/model/LabColor;)Lcodes/side/colorpicker/model/RgbColor;
}
+public final class codes/side/colorpicker/conversion/OkhslConversionsKt {
+ public static final fun toArgbInt (Lcodes/side/colorpicker/model/OkhslColor;)I
+ public static final fun toHsl (Lcodes/side/colorpicker/model/OkhslColor;)Lcodes/side/colorpicker/model/HslColor;
+ public static final fun toOkhsl (Lcodes/side/colorpicker/model/HslColor;)Lcodes/side/colorpicker/model/OkhslColor;
+ public static final fun toOkhsl (Lcodes/side/colorpicker/model/RgbColor;)Lcodes/side/colorpicker/model/OkhslColor;
+ public static final fun toOkhslColor (I)Lcodes/side/colorpicker/model/OkhslColor;
+ public static final fun toRgb (Lcodes/side/colorpicker/model/OkhslColor;)Lcodes/side/colorpicker/model/RgbColor;
+}
+
+public final class codes/side/colorpicker/conversion/OkhsvConversionsKt {
+ public static final fun toArgbInt (Lcodes/side/colorpicker/model/OkhsvColor;)I
+ public static final fun toHsl (Lcodes/side/colorpicker/model/OkhsvColor;)Lcodes/side/colorpicker/model/HslColor;
+ public static final fun toOkhsv (Lcodes/side/colorpicker/model/HslColor;)Lcodes/side/colorpicker/model/OkhsvColor;
+ public static final fun toOkhsv (Lcodes/side/colorpicker/model/RgbColor;)Lcodes/side/colorpicker/model/OkhsvColor;
+ public static final fun toOkhsvColor (I)Lcodes/side/colorpicker/model/OkhsvColor;
+ public static final fun toRgb (Lcodes/side/colorpicker/model/OkhsvColor;)Lcodes/side/colorpicker/model/RgbColor;
+}
+
+public final class codes/side/colorpicker/conversion/OklabConversionsKt {
+ public static final fun toArgbInt (Lcodes/side/colorpicker/model/OklabColor;)I
+ public static final fun toArgbInt (Lcodes/side/colorpicker/model/OklchColor;)I
+ public static final fun toHsl (Lcodes/side/colorpicker/model/OklabColor;)Lcodes/side/colorpicker/model/HslColor;
+ public static final fun toHsl (Lcodes/side/colorpicker/model/OklchColor;)Lcodes/side/colorpicker/model/HslColor;
+ public static final fun toOklab (Lcodes/side/colorpicker/model/HslColor;)Lcodes/side/colorpicker/model/OklabColor;
+ public static final fun toOklab (Lcodes/side/colorpicker/model/OklchColor;)Lcodes/side/colorpicker/model/OklabColor;
+ public static final fun toOklab (Lcodes/side/colorpicker/model/RgbColor;)Lcodes/side/colorpicker/model/OklabColor;
+ public static final fun toOklabColor (I)Lcodes/side/colorpicker/model/OklabColor;
+ public static final fun toOklch (Lcodes/side/colorpicker/model/HslColor;)Lcodes/side/colorpicker/model/OklchColor;
+ public static final fun toOklch (Lcodes/side/colorpicker/model/OklabColor;)Lcodes/side/colorpicker/model/OklchColor;
+ public static final fun toOklch (Lcodes/side/colorpicker/model/RgbColor;)Lcodes/side/colorpicker/model/OklchColor;
+ public static final fun toOklchColor (I)Lcodes/side/colorpicker/model/OklchColor;
+ public static final fun toRgb (Lcodes/side/colorpicker/model/OklabColor;)Lcodes/side/colorpicker/model/RgbColor;
+ public static final fun toRgb (Lcodes/side/colorpicker/model/OklchColor;)Lcodes/side/colorpicker/model/RgbColor;
+}
+
public final class codes/side/colorpicker/model/CmykColor : codes/side/colorpicker/model/PickerColor {
public static final field $stable I
public static final field Companion Lcodes/side/colorpicker/model/CmykColor$Companion;
@@ -135,6 +178,118 @@ public final class codes/side/colorpicker/model/LabColor$Companion {
public final fun getWhite ()Lcodes/side/colorpicker/model/LabColor;
}
+public final class codes/side/colorpicker/model/OkhslColor : codes/side/colorpicker/model/PickerColor {
+ public static final field $stable I
+ public static final field Companion Lcodes/side/colorpicker/model/OkhslColor$Companion;
+ public fun ()V
+ public fun (FFFF)V
+ public synthetic fun (FFFFILkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public final fun copy (FFFF)Lcodes/side/colorpicker/model/OkhslColor;
+ public static synthetic fun copy$default (Lcodes/side/colorpicker/model/OkhslColor;FFFFILjava/lang/Object;)Lcodes/side/colorpicker/model/OkhslColor;
+ public fun equals (Ljava/lang/Object;)Z
+ public fun getAlpha ()F
+ public final fun getHue ()F
+ public final fun getIntAlpha ()I
+ public final fun getIntHue ()I
+ public final fun getIntLightness ()I
+ public final fun getIntSaturation ()I
+ public final fun getLightness ()F
+ public final fun getSaturation ()F
+ public fun hashCode ()I
+ public fun toString ()Ljava/lang/String;
+}
+
+public final class codes/side/colorpicker/model/OkhslColor$Companion {
+ public final fun fromInt (IIII)Lcodes/side/colorpicker/model/OkhslColor;
+ public static synthetic fun fromInt$default (Lcodes/side/colorpicker/model/OkhslColor$Companion;IIIIILjava/lang/Object;)Lcodes/side/colorpicker/model/OkhslColor;
+ public final fun getBlack ()Lcodes/side/colorpicker/model/OkhslColor;
+ public final fun getWhite ()Lcodes/side/colorpicker/model/OkhslColor;
+}
+
+public final class codes/side/colorpicker/model/OkhsvColor : codes/side/colorpicker/model/PickerColor {
+ public static final field $stable I
+ public static final field Companion Lcodes/side/colorpicker/model/OkhsvColor$Companion;
+ public fun ()V
+ public fun (FFFF)V
+ public synthetic fun (FFFFILkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public final fun copy (FFFF)Lcodes/side/colorpicker/model/OkhsvColor;
+ public static synthetic fun copy$default (Lcodes/side/colorpicker/model/OkhsvColor;FFFFILjava/lang/Object;)Lcodes/side/colorpicker/model/OkhsvColor;
+ public fun equals (Ljava/lang/Object;)Z
+ public fun getAlpha ()F
+ public final fun getHue ()F
+ public final fun getIntAlpha ()I
+ public final fun getIntHue ()I
+ public final fun getIntSaturation ()I
+ public final fun getIntValue ()I
+ public final fun getSaturation ()F
+ public final fun getValue ()F
+ public fun hashCode ()I
+ public fun toString ()Ljava/lang/String;
+}
+
+public final class codes/side/colorpicker/model/OkhsvColor$Companion {
+ public final fun fromInt (IIII)Lcodes/side/colorpicker/model/OkhsvColor;
+ public static synthetic fun fromInt$default (Lcodes/side/colorpicker/model/OkhsvColor$Companion;IIIIILjava/lang/Object;)Lcodes/side/colorpicker/model/OkhsvColor;
+ public final fun getBlack ()Lcodes/side/colorpicker/model/OkhsvColor;
+ public final fun getWhite ()Lcodes/side/colorpicker/model/OkhsvColor;
+}
+
+public final class codes/side/colorpicker/model/OklabColor : codes/side/colorpicker/model/PickerColor {
+ public static final field $stable I
+ public static final field Companion Lcodes/side/colorpicker/model/OklabColor$Companion;
+ public fun ()V
+ public fun (FFFF)V
+ public synthetic fun (FFFFILkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public final fun copy (FFFF)Lcodes/side/colorpicker/model/OklabColor;
+ public static synthetic fun copy$default (Lcodes/side/colorpicker/model/OklabColor;FFFFILjava/lang/Object;)Lcodes/side/colorpicker/model/OklabColor;
+ public fun equals (Ljava/lang/Object;)Z
+ public final fun getA ()F
+ public fun getAlpha ()F
+ public final fun getB ()F
+ public final fun getIntA ()I
+ public final fun getIntAlpha ()I
+ public final fun getIntB ()I
+ public final fun getIntL ()I
+ public final fun getL ()F
+ public fun hashCode ()I
+ public fun toString ()Ljava/lang/String;
+}
+
+public final class codes/side/colorpicker/model/OklabColor$Companion {
+ public final fun fromInt (IIII)Lcodes/side/colorpicker/model/OklabColor;
+ public static synthetic fun fromInt$default (Lcodes/side/colorpicker/model/OklabColor$Companion;IIIIILjava/lang/Object;)Lcodes/side/colorpicker/model/OklabColor;
+ public final fun getBlack ()Lcodes/side/colorpicker/model/OklabColor;
+ public final fun getWhite ()Lcodes/side/colorpicker/model/OklabColor;
+}
+
+public final class codes/side/colorpicker/model/OklchColor : codes/side/colorpicker/model/PickerColor {
+ public static final field $stable I
+ public static final field Companion Lcodes/side/colorpicker/model/OklchColor$Companion;
+ public fun ()V
+ public fun (FFFF)V
+ public synthetic fun (FFFFILkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public final fun copy (FFFF)Lcodes/side/colorpicker/model/OklchColor;
+ public static synthetic fun copy$default (Lcodes/side/colorpicker/model/OklchColor;FFFFILjava/lang/Object;)Lcodes/side/colorpicker/model/OklchColor;
+ public fun equals (Ljava/lang/Object;)Z
+ public fun getAlpha ()F
+ public final fun getChroma ()F
+ public final fun getHue ()F
+ public final fun getIntAlpha ()I
+ public final fun getIntChroma ()I
+ public final fun getIntHue ()I
+ public final fun getIntL ()I
+ public final fun getL ()F
+ public fun hashCode ()I
+ public fun toString ()Ljava/lang/String;
+}
+
+public final class codes/side/colorpicker/model/OklchColor$Companion {
+ public final fun fromInt (IIII)Lcodes/side/colorpicker/model/OklchColor;
+ public static synthetic fun fromInt$default (Lcodes/side/colorpicker/model/OklchColor$Companion;IIIIILjava/lang/Object;)Lcodes/side/colorpicker/model/OklchColor;
+ public final fun getBlack ()Lcodes/side/colorpicker/model/OklchColor;
+ public final fun getWhite ()Lcodes/side/colorpicker/model/OklchColor;
+}
+
public abstract interface class codes/side/colorpicker/model/PickerColor {
public abstract fun getAlpha ()F
}
@@ -179,6 +334,10 @@ public final class codes/side/colorpicker/state/ColorPickerState {
public final fun getCmykColor ()Lcodes/side/colorpicker/model/CmykColor;
public final fun getHslColor ()Lcodes/side/colorpicker/model/HslColor;
public final fun getLabColor ()Lcodes/side/colorpicker/model/LabColor;
+ public final fun getOkhslColor ()Lcodes/side/colorpicker/model/OkhslColor;
+ public final fun getOkhsvColor ()Lcodes/side/colorpicker/model/OkhsvColor;
+ public final fun getOklabColor ()Lcodes/side/colorpicker/model/OklabColor;
+ public final fun getOklchColor ()Lcodes/side/colorpicker/model/OklchColor;
public final fun getPickerColor ()Lcodes/side/colorpicker/model/PickerColor;
public final fun getRgbColor ()Lcodes/side/colorpicker/model/RgbColor;
public final fun isInteracting ()Z
@@ -189,6 +348,10 @@ public final class codes/side/colorpicker/state/ColorPickerState {
public final fun updateFromCmyk (Lcodes/side/colorpicker/model/CmykColor;)V
public final fun updateFromHsl (Lcodes/side/colorpicker/model/HslColor;)V
public final fun updateFromLab (Lcodes/side/colorpicker/model/LabColor;)V
+ public final fun updateFromOkhsl (Lcodes/side/colorpicker/model/OkhslColor;)V
+ public final fun updateFromOkhsv (Lcodes/side/colorpicker/model/OkhsvColor;)V
+ public final fun updateFromOklab (Lcodes/side/colorpicker/model/OklabColor;)V
+ public final fun updateFromOklch (Lcodes/side/colorpicker/model/OklchColor;)V
public final fun updateFromRgb (Lcodes/side/colorpicker/model/RgbColor;)V
public final fun updateGreen (F)V
public final fun updateHue (F)V
@@ -198,6 +361,18 @@ public final class codes/side/colorpicker/state/ColorPickerState {
public final fun updateLabLightness (F)V
public final fun updateLightness (F)V
public final fun updateMagenta (F)V
+ public final fun updateOkhslHue (F)V
+ public final fun updateOkhslLightness (F)V
+ public final fun updateOkhslSaturation (F)V
+ public final fun updateOkhsvHue (F)V
+ public final fun updateOkhsvSaturation (F)V
+ public final fun updateOkhsvValue (F)V
+ public final fun updateOklabA (F)V
+ public final fun updateOklabB (F)V
+ public final fun updateOklabLightness (F)V
+ public final fun updateOklchChroma (F)V
+ public final fun updateOklchHue (F)V
+ public final fun updateOklchLightness (F)V
public final fun updateRed (F)V
public final fun updateSaturation (F)V
public final fun updateYellow (F)V
@@ -316,6 +491,22 @@ public final class codes/side/colorpicker/ui/ComposableSingletons$LabSlidersKt {
public final fun getLambda$1980484084$codes_side_colorpicker ()Lkotlin/jvm/functions/Function2;
}
+public final class codes/side/colorpicker/ui/ComposableSingletons$OkhslSlidersKt {
+ public static final field INSTANCE Lcodes/side/colorpicker/ui/ComposableSingletons$OkhslSlidersKt;
+ public fun ()V
+ public final fun getLambda$1151294969$codes_side_colorpicker ()Lkotlin/jvm/functions/Function2;
+ public final fun getLambda$501007118$codes_side_colorpicker ()Lkotlin/jvm/functions/Function2;
+ public final fun getLambda$879958294$codes_side_colorpicker ()Lkotlin/jvm/functions/Function2;
+}
+
+public final class codes/side/colorpicker/ui/ComposableSingletons$OkhsvSlidersKt {
+ public static final field INSTANCE Lcodes/side/colorpicker/ui/ComposableSingletons$OkhsvSlidersKt;
+ public fun ()V
+ public final fun getLambda$-2089135351$codes_side_colorpicker ()Lkotlin/jvm/functions/Function2;
+ public final fun getLambda$-378521514$codes_side_colorpicker ()Lkotlin/jvm/functions/Function2;
+ public final fun getLambda$-702366814$codes_side_colorpicker ()Lkotlin/jvm/functions/Function2;
+}
+
public final class codes/side/colorpicker/ui/ComposableSingletons$RgbSlidersKt {
public static final field INSTANCE Lcodes/side/colorpicker/ui/ComposableSingletons$RgbSlidersKt;
public fun ()V
@@ -344,6 +535,26 @@ public final class codes/side/colorpicker/ui/LabSlidersKt {
public static final fun LightnessLabSlider-7LhMbNw (Lcodes/side/colorpicker/state/ColorPickerState;Landroidx/compose/ui/Modifier;Lcodes/side/colorpicker/state/ColoringMode;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Ljava/lang/String;Ljava/lang/String;Lcodes/side/colorpicker/theme/ColorPickerColors;Lcodes/side/colorpicker/theme/ColorPickerShapes;Lkotlin/jvm/functions/Function3;FFLandroidx/compose/runtime/Composer;III)V
}
+public final class codes/side/colorpicker/ui/OkhslColorPickerKt {
+ public static final fun OkhslColorPicker (Lcodes/side/colorpicker/state/ColorPickerState;Landroidx/compose/ui/Modifier;ZLcodes/side/colorpicker/state/ColoringMode;Lcodes/side/colorpicker/theme/ColorPickerColors;Lcodes/side/colorpicker/theme/ColorPickerShapes;Landroidx/compose/runtime/Composer;II)V
+}
+
+public final class codes/side/colorpicker/ui/OkhslSlidersKt {
+ public static final fun OkhslHueSlider-7LhMbNw (Lcodes/side/colorpicker/state/ColorPickerState;Landroidx/compose/ui/Modifier;Lcodes/side/colorpicker/state/ColoringMode;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Ljava/lang/String;Ljava/lang/String;Lcodes/side/colorpicker/theme/ColorPickerColors;Lcodes/side/colorpicker/theme/ColorPickerShapes;Lkotlin/jvm/functions/Function3;FFLandroidx/compose/runtime/Composer;III)V
+ public static final fun OkhslLightnessSlider-7LhMbNw (Lcodes/side/colorpicker/state/ColorPickerState;Landroidx/compose/ui/Modifier;Lcodes/side/colorpicker/state/ColoringMode;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Ljava/lang/String;Ljava/lang/String;Lcodes/side/colorpicker/theme/ColorPickerColors;Lcodes/side/colorpicker/theme/ColorPickerShapes;Lkotlin/jvm/functions/Function3;FFLandroidx/compose/runtime/Composer;III)V
+ public static final fun OkhslSaturationSlider-7LhMbNw (Lcodes/side/colorpicker/state/ColorPickerState;Landroidx/compose/ui/Modifier;Lcodes/side/colorpicker/state/ColoringMode;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Ljava/lang/String;Ljava/lang/String;Lcodes/side/colorpicker/theme/ColorPickerColors;Lcodes/side/colorpicker/theme/ColorPickerShapes;Lkotlin/jvm/functions/Function3;FFLandroidx/compose/runtime/Composer;III)V
+}
+
+public final class codes/side/colorpicker/ui/OkhsvColorPickerKt {
+ public static final fun OkhsvColorPicker (Lcodes/side/colorpicker/state/ColorPickerState;Landroidx/compose/ui/Modifier;ZLcodes/side/colorpicker/state/ColoringMode;Lcodes/side/colorpicker/theme/ColorPickerColors;Lcodes/side/colorpicker/theme/ColorPickerShapes;Landroidx/compose/runtime/Composer;II)V
+}
+
+public final class codes/side/colorpicker/ui/OkhsvSlidersKt {
+ public static final fun OkhsvHueSlider-7LhMbNw (Lcodes/side/colorpicker/state/ColorPickerState;Landroidx/compose/ui/Modifier;Lcodes/side/colorpicker/state/ColoringMode;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Ljava/lang/String;Ljava/lang/String;Lcodes/side/colorpicker/theme/ColorPickerColors;Lcodes/side/colorpicker/theme/ColorPickerShapes;Lkotlin/jvm/functions/Function3;FFLandroidx/compose/runtime/Composer;III)V
+ public static final fun OkhsvSaturationSlider-7LhMbNw (Lcodes/side/colorpicker/state/ColorPickerState;Landroidx/compose/ui/Modifier;Lcodes/side/colorpicker/state/ColoringMode;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Ljava/lang/String;Ljava/lang/String;Lcodes/side/colorpicker/theme/ColorPickerColors;Lcodes/side/colorpicker/theme/ColorPickerShapes;Lkotlin/jvm/functions/Function3;FFLandroidx/compose/runtime/Composer;III)V
+ public static final fun OkhsvValueSlider-7LhMbNw (Lcodes/side/colorpicker/state/ColorPickerState;Landroidx/compose/ui/Modifier;Lcodes/side/colorpicker/state/ColoringMode;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Ljava/lang/String;Ljava/lang/String;Lcodes/side/colorpicker/theme/ColorPickerColors;Lcodes/side/colorpicker/theme/ColorPickerShapes;Lkotlin/jvm/functions/Function3;FFLandroidx/compose/runtime/Composer;III)V
+}
+
public final class codes/side/colorpicker/ui/RgbColorPickerKt {
public static final fun RgbColorPicker (Lcodes/side/colorpicker/state/ColorPickerState;Landroidx/compose/ui/Modifier;ZLcodes/side/colorpicker/state/ColoringMode;Lcodes/side/colorpicker/theme/ColorPickerColors;Lcodes/side/colorpicker/theme/ColorPickerShapes;Landroidx/compose/runtime/Composer;II)V
}
diff --git a/colorpicker/build.gradle.kts b/colorpicker/build.gradle.kts
index 892bfd0..6f5f104 100644
--- a/colorpicker/build.gradle.kts
+++ b/colorpicker/build.gradle.kts
@@ -1,6 +1,5 @@
import com.vanniktech.maven.publish.JavadocJar
import com.vanniktech.maven.publish.KotlinMultiplatform
-import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
@@ -40,7 +39,6 @@ kotlin {
withHostTest {}
- @OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/ComposeColorExt.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/ComposeColorExt.kt
index 156699b..92c8164 100644
--- a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/ComposeColorExt.kt
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/ComposeColorExt.kt
@@ -5,6 +5,10 @@ import androidx.compose.ui.graphics.colorspace.ColorSpaces
import codes.side.colorpicker.model.CmykColor
import codes.side.colorpicker.model.HslColor
import codes.side.colorpicker.model.LabColor
+import codes.side.colorpicker.model.OkhslColor
+import codes.side.colorpicker.model.OkhsvColor
+import codes.side.colorpicker.model.OklabColor
+import codes.side.colorpicker.model.OklchColor
import codes.side.colorpicker.model.RgbColor
/** Converts this color to a Compose [Color] in the sRGB color space, preserving alpha. */
@@ -20,6 +24,24 @@ public fun CmykColor.toComposeColor(): Color = toRgb().toComposeColor()
/** Converts this color to a Compose [Color] in the sRGB color space, preserving alpha. */
public fun LabColor.toComposeColor(): Color = toRgb().toComposeColor()
+/**
+ * Converts this color to a Compose [Color] in the sRGB color space, preserving alpha.
+ * Colors outside the display gamut are mapped, not clipped; see [OklabColor.toRgb].
+ */
+public fun OklabColor.toComposeColor(): Color = toRgb().toComposeColor()
+
+/**
+ * Converts this color to a Compose [Color] in the sRGB color space, preserving alpha.
+ * Colors outside the display gamut are mapped, not clipped; see [OklchColor.toRgb].
+ */
+public fun OklchColor.toComposeColor(): Color = toRgb().toComposeColor()
+
+/** Converts this color to a Compose [Color] in the sRGB color space, preserving alpha. */
+public fun OkhslColor.toComposeColor(): Color = toRgb().toComposeColor()
+
+/** Converts this color to a Compose [Color] in the sRGB color space, preserving alpha. */
+public fun OkhsvColor.toComposeColor(): Color = toRgb().toComposeColor()
+
/**
* Converts this Compose [Color] to an [RgbColor], converting to the sRGB color space
* first if needed (clamping any out-of-gamut channels to `0..1`).
@@ -45,3 +67,15 @@ public fun Color.toCmykColor(): CmykColor = toRgbColor().toCmyk()
/** Converts this Compose [Color] to a [LabColor]; see [Color.toRgbColor] for sRGB handling. */
public fun Color.toLabColor(): LabColor = toRgbColor().toLab()
+
+/** Converts this Compose [Color] to an [OklabColor]; see [Color.toRgbColor] for sRGB handling. */
+public fun Color.toOklabColor(): OklabColor = toRgbColor().toOklab()
+
+/** Converts this Compose [Color] to an [OklchColor]; see [Color.toRgbColor] for sRGB handling. */
+public fun Color.toOklchColor(): OklchColor = toRgbColor().toOklch()
+
+/** Converts this Compose [Color] to an [OkhslColor]; see [Color.toRgbColor] for sRGB handling. */
+public fun Color.toOkhslColor(): OkhslColor = toRgbColor().toOkhsl()
+
+/** Converts this Compose [Color] to an [OkhsvColor]; see [Color.toRgbColor] for sRGB handling. */
+public fun Color.toOkhsvColor(): OkhsvColor = toRgbColor().toOkhsv()
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/GamutMapping.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/GamutMapping.kt
new file mode 100644
index 0000000..f53f618
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/GamutMapping.kt
@@ -0,0 +1,75 @@
+package codes.side.colorpicker.conversion
+
+import kotlin.math.hypot
+
+/**
+ * The CSS Color 4 gamut mapping algorithm: binary search on chroma with local-MINDE
+ * clipping. See https://www.w3.org/TR/css-color-4/#binsearch.
+ *
+ * Two simpler approaches are wrong in opposite directions, which is why this one is
+ * neither. Clipping each RGB channel on its own shifts lightness and hue as a side
+ * effect — push only a* out of range and all three coordinates come back changed.
+ * Reducing chroma until the color fits, holding lightness and hue, fixes that but
+ * over-corrects badly on yellows, where the gamut surface curves away from the search
+ * line and the color arrives visibly washed out.
+ *
+ * So chroma is searched down, and at each step the candidate is compared against its own
+ * clipped version. Once those two are within a just-noticeable difference, clipping has
+ * become perceptually free and the clipped color wins — which keeps most of the chroma
+ * that pure reduction would have thrown away.
+ */
+
+// Just-noticeable difference in ΔE-OK, and the search's stopping width. Both from the
+// spec; JND is what makes the clipped and searched colors interchangeable.
+private const val JND = 0.02
+private const val EPSILON = 0.0001
+
+/**
+ * Maps [origin] to the closest color sRGB can display, in linear light. Colors already
+ * inside the gamut pass through untouched.
+ */
+internal fun gamutMapToSrgb(origin: OkLab): LinearRgb {
+ if (origin.l >= 1.0) return LinearRgb(1.0, 1.0, 1.0)
+ if (origin.l <= 0.0) return LinearRgb(0.0, 0.0, 0.0)
+
+ val direct = oklabToLinearSrgb(origin)
+ if (direct.isInGamut()) return direct.clipToUnit()
+
+ val originChroma = hypot(origin.a, origin.b)
+ val aUnit = origin.a / originChroma
+ val bUnit = origin.b / originChroma
+
+ var clipped = direct.clipToUnit()
+ if (deltaEOk(linearSrgbToOklab(clipped), origin) < JND) return clipped
+
+ var min = 0.0
+ var max = originChroma
+ var minInGamut = true
+
+ while (max - min > EPSILON) {
+ val chroma = (min + max) / 2.0
+ val current = OkLab(origin.l, aUnit * chroma, bUnit * chroma)
+ val currentRgb = oklabToLinearSrgb(current)
+
+ if (minInGamut && currentRgb.isInGamut()) {
+ min = chroma
+ continue
+ }
+
+ clipped = currentRgb.clipToUnit()
+ val error = deltaEOk(linearSrgbToOklab(clipped), current)
+
+ when {
+ error >= JND -> max = chroma
+ JND - error < EPSILON -> return clipped
+ else -> {
+ // Close enough that clipping is nearly free, but there is still chroma to
+ // recover; keep searching upward with clipping now allowed.
+ minInGamut = false
+ min = chroma
+ }
+ }
+ }
+
+ return clipped
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/HexConversions.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/HexConversions.kt
index 2e34406..b2b8c7d 100644
--- a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/HexConversions.kt
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/HexConversions.kt
@@ -1,8 +1,5 @@
package codes.side.colorpicker.conversion
-import codes.side.colorpicker.model.CmykColor
-import codes.side.colorpicker.model.HslColor
-import codes.side.colorpicker.model.LabColor
import codes.side.colorpicker.model.PickerColor
import codes.side.colorpicker.model.RgbColor
@@ -13,12 +10,8 @@ import codes.side.colorpicker.model.RgbColor
* alpha channel comes first: the result is `#AARRGGBB` when [includeAlpha] is `true`
* (the default) and `#RRGGBB` otherwise.
*/
-public fun PickerColor.toHexString(includeAlpha: Boolean = true): String = when (this) {
- is RgbColor -> toArgbInt()
- is HslColor -> toArgbInt()
- is CmykColor -> toArgbInt()
- is LabColor -> toArgbInt()
-}.toHexColorString(includeAlpha = includeAlpha)
+public fun PickerColor.toHexString(includeAlpha: Boolean = true): String =
+ toRgbColor().toArgbInt().toHexColorString(includeAlpha = includeAlpha)
/**
* Formats this packed ARGB [Int] as an uppercase hex string with a leading `#`.
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/LabConversions.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/LabConversions.kt
index 8ee0ce6..70178cf 100644
--- a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/LabConversions.kt
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/LabConversions.kt
@@ -83,7 +83,7 @@ private fun labF(t: Double): Double =
internal fun linearize(c: Double): Double =
if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
-private fun delinearize(c: Double): Double =
+internal fun delinearize(c: Double): Double =
if (c <= 0.0031308) c * 12.92 else 1.055 * c.pow(1.0 / 2.4) - 0.055
/** Converts this CIELAB color to HSL by way of RGB. Alpha is carried over unchanged. */
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/OkGamut.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/OkGamut.kt
new file mode 100644
index 0000000..2e4d816
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/OkGamut.kt
@@ -0,0 +1,318 @@
+package codes.side.colorpicker.conversion
+
+import kotlin.math.cbrt
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.math.sqrt
+
+/**
+ * The Oklab machinery shared by the Ok* color spaces: the Oklab transform itself, and
+ * the sRGB gamut boundary that Okhsl, Okhsv and gamut mapping are all defined against.
+ *
+ * Transcribed from Björn Ottosson's reference implementation (`ok_color.h`, MIT), with
+ * the arithmetic widened to [Double]. The polynomial coefficients below were produced by
+ * an optimization process against the sRGB gamut and are not derivable by hand — treat
+ * them as data, not as expressions to simplify.
+ *
+ * See https://bottosson.github.io/posts/oklab/ and
+ * https://bottosson.github.io/posts/gamutclipping/.
+ */
+
+/** A color in Oklab, in the space's own `0..1` lightness scale. */
+internal data class OkLab(val l: Double, val a: Double, val b: Double)
+
+/** A color in linear-light sRGB, unclamped so out-of-gamut values survive. */
+internal data class LinearRgb(val r: Double, val g: Double, val b: Double)
+
+/** The lightness and chroma of a hue's cusp — its most colorful point in sRGB. */
+internal data class Cusp(val l: Double, val c: Double)
+
+/** The cusp expressed as saturation and "tint" gradients, per Ottosson's `to_ST`. */
+internal data class SaturationTint(val s: Double, val t: Double)
+
+/** The three chroma anchors Okhsl interpolates between at a given lightness. */
+internal data class ChromaAnchors(val c0: Double, val cMid: Double, val cMax: Double)
+
+internal fun linearSrgbToOklab(c: LinearRgb): OkLab {
+ val l = 0.4122214708 * c.r + 0.5363325363 * c.g + 0.0514459929 * c.b
+ val m = 0.2119034982 * c.r + 0.6806995451 * c.g + 0.1073969566 * c.b
+ val s = 0.0883024619 * c.r + 0.2817188376 * c.g + 0.6299787005 * c.b
+
+ val lRoot = cbrt(l)
+ val mRoot = cbrt(m)
+ val sRoot = cbrt(s)
+
+ return OkLab(
+ l = 0.2104542553 * lRoot + 0.7936177850 * mRoot - 0.0040720468 * sRoot,
+ a = 1.9779984951 * lRoot - 2.4285922050 * mRoot + 0.4505937099 * sRoot,
+ b = 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.8086757660 * sRoot,
+ )
+}
+
+internal fun oklabToLinearSrgb(c: OkLab): LinearRgb {
+ val lRoot = c.l + 0.3963377774 * c.a + 0.2158037573 * c.b
+ val mRoot = c.l - 0.1055613458 * c.a - 0.0638541728 * c.b
+ val sRoot = c.l - 0.0894841775 * c.a - 1.2914855480 * c.b
+
+ val l = lRoot * lRoot * lRoot
+ val m = mRoot * mRoot * mRoot
+ val s = sRoot * sRoot * sRoot
+
+ return LinearRgb(
+ r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
+ g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
+ b = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
+ )
+}
+
+// Okhsl's lightness is Oklab's L passed through this curve, which stretches the dark end
+// so that mid-gray lands near 0.5 the way HSL users expect.
+private const val TOE_K1 = 0.206
+private const val TOE_K2 = 0.03
+private const val TOE_K3 = (1.0 + TOE_K1) / (1.0 + TOE_K2)
+
+internal fun toe(x: Double): Double {
+ val inner = TOE_K3 * x - TOE_K1
+ return 0.5 * (inner + sqrt(inner * inner + 4.0 * TOE_K2 * TOE_K3 * x))
+}
+
+internal fun toeInv(x: Double): Double = (x * x + TOE_K1 * x) / (TOE_K3 * (x + TOE_K2))
+
+/**
+ * The saturation at which the hue `(aNorm, bNorm)` leaves the sRGB gamut, for the
+ * channel that goes out first. A polynomial approximation refined by one Halley step,
+ * which lands within a rounding error of the true boundary.
+ */
+/**
+ * Per-channel coefficients for [computeMaxSaturation]: `k0`..`k4` fit the saturation at
+ * which that channel clips, and `wl`/`wm`/`ws` are its row of the Oklab-to-linear matrix.
+ */
+private class MaxSaturationFit(
+ val k0: Double,
+ val k1: Double,
+ val k2: Double,
+ val k3: Double,
+ val k4: Double,
+ val wl: Double,
+ val wm: Double,
+ val ws: Double,
+)
+
+private val RedClipsFirst = MaxSaturationFit(
+ k0 = 1.19086277, k1 = 1.76576728, k2 = 0.59662641, k3 = 0.75515197, k4 = 0.56771245,
+ wl = 4.0767416621, wm = -3.3077115913, ws = 0.2309699292,
+)
+
+private val GreenClipsFirst = MaxSaturationFit(
+ k0 = 0.73956515, k1 = -0.45954404, k2 = 0.08285427, k3 = 0.12541070, k4 = 0.14503204,
+ wl = -1.2684380046, wm = 2.6097574011, ws = -0.3413193965,
+)
+
+private val BlueClipsFirst = MaxSaturationFit(
+ k0 = 1.35733652, k1 = -0.00915799, k2 = -1.15130210, k3 = -0.50559606, k4 = 0.00692167,
+ wl = -0.0041960863, wm = -0.7034186147, ws = 1.7076147010,
+)
+
+internal fun computeMaxSaturation(aNorm: Double, bNorm: Double): Double {
+ val fit = when {
+ -1.88170328 * aNorm - 0.80936493 * bNorm > 1.0 -> RedClipsFirst
+ 1.81444104 * aNorm - 1.19445276 * bNorm > 1.0 -> GreenClipsFirst
+ else -> BlueClipsFirst
+ }
+
+ val approx = with(fit) {
+ k0 + k1 * aNorm + k2 * bNorm + k3 * aNorm * aNorm + k4 * aNorm * bNorm
+ }
+
+ val kL = 0.3963377774 * aNorm + 0.2158037573 * bNorm
+ val kM = -0.1055613458 * aNorm - 0.0638541728 * bNorm
+ val kS = -0.0894841775 * aNorm - 1.2914855480 * bNorm
+
+ val lRoot = 1.0 + approx * kL
+ val mRoot = 1.0 + approx * kM
+ val sRoot = 1.0 + approx * kS
+
+ val l = lRoot * lRoot * lRoot
+ val m = mRoot * mRoot * mRoot
+ val s = sRoot * sRoot * sRoot
+
+ val lds = 3.0 * kL * lRoot * lRoot
+ val mds = 3.0 * kM * mRoot * mRoot
+ val sds = 3.0 * kS * sRoot * sRoot
+
+ val lds2 = 6.0 * kL * kL * lRoot
+ val mds2 = 6.0 * kM * kM * mRoot
+ val sds2 = 6.0 * kS * kS * sRoot
+
+ val f = fit.wl * l + fit.wm * m + fit.ws * s
+ val f1 = fit.wl * lds + fit.wm * mds + fit.ws * sds
+ val f2 = fit.wl * lds2 + fit.wm * mds2 + fit.ws * sds2
+
+ return approx - f * f1 / (f1 * f1 - 0.5 * f * f2)
+}
+
+/** The most colorful point of a hue in sRGB, where two channels are at their limit. */
+internal fun findCusp(aNorm: Double, bNorm: Double): Cusp {
+ val sCusp = computeMaxSaturation(aNorm, bNorm)
+ val rgbAtMax = oklabToLinearSrgb(OkLab(1.0, sCusp * aNorm, sCusp * bNorm))
+ val lCusp = cbrt(1.0 / max(max(rgbAtMax.r, rgbAtMax.g), rgbAtMax.b))
+ return Cusp(l = lCusp, c = lCusp * sCusp)
+}
+
+internal fun Cusp.toSaturationTint(): SaturationTint = SaturationTint(s = c / l, t = c / (1.0 - l))
+
+/**
+ * How far along the segment from `(l0, 0)` to `(l1, c1)` the sRGB gamut boundary lies,
+ * as a fraction in `0..1`. Below the cusp the boundary is a straight line and the answer
+ * is exact; above it the surface curves, so the linear guess is refined by one Halley
+ * step per channel and the nearest crossing wins.
+ */
+internal fun findGamutIntersection(
+ aNorm: Double,
+ bNorm: Double,
+ l1: Double,
+ c1: Double,
+ l0: Double,
+ cusp: Cusp,
+): Double {
+ if ((l1 - l0) * cusp.c - (cusp.l - l0) * c1 <= 0.0) {
+ return cusp.c * l0 / (c1 * cusp.l + cusp.c * (l0 - l1))
+ }
+
+ var t = cusp.c * (l0 - 1.0) / (c1 * (cusp.l - 1.0) + cusp.c * (l0 - l1))
+
+ val dL = l1 - l0
+ val dC = c1
+ val kL = 0.3963377774 * aNorm + 0.2158037573 * bNorm
+ val kM = -0.1055613458 * aNorm - 0.0638541728 * bNorm
+ val kS = -0.0894841775 * aNorm - 1.2914855480 * bNorm
+
+ val lDt = dL + dC * kL
+ val mDt = dL + dC * kM
+ val sDt = dL + dC * kS
+
+ val l = l0 * (1.0 - t) + t * l1
+ val c = t * c1
+
+ val lRoot = l + c * kL
+ val mRoot = l + c * kM
+ val sRoot = l + c * kS
+
+ val lCubed = lRoot * lRoot * lRoot
+ val mCubed = mRoot * mRoot * mRoot
+ val sCubed = sRoot * sRoot * sRoot
+
+ val ldt = 3.0 * lDt * lRoot * lRoot
+ val mdt = 3.0 * mDt * mRoot * mRoot
+ val sdt = 3.0 * sDt * sRoot * sRoot
+
+ val ldt2 = 6.0 * lDt * lDt * lRoot
+ val mdt2 = 6.0 * mDt * mDt * mRoot
+ val sdt2 = 6.0 * sDt * sDt * sRoot
+
+ val r = 4.0767416621 * lCubed - 3.3077115913 * mCubed + 0.2309699292 * sCubed - 1.0
+ val r1 = 4.0767416621 * ldt - 3.3077115913 * mdt + 0.2309699292 * sdt
+ val r2 = 4.0767416621 * ldt2 - 3.3077115913 * mdt2 + 0.2309699292 * sdt2
+ val uR = r1 / (r1 * r1 - 0.5 * r * r2)
+ val tR = if (uR >= 0.0) -r * uR else Double.MAX_VALUE
+
+ val g = -1.2684380046 * lCubed + 2.6097574011 * mCubed - 0.3413193965 * sCubed - 1.0
+ val g1 = -1.2684380046 * ldt + 2.6097574011 * mdt - 0.3413193965 * sdt
+ val g2 = -1.2684380046 * ldt2 + 2.6097574011 * mdt2 - 0.3413193965 * sdt2
+ val uG = g1 / (g1 * g1 - 0.5 * g * g2)
+ val tG = if (uG >= 0.0) -g * uG else Double.MAX_VALUE
+
+ val b = -0.0041960863 * lCubed - 0.7034186147 * mCubed + 1.7076147010 * sCubed - 1.0
+ val b1 = -0.0041960863 * ldt - 0.7034186147 * mdt + 1.7076147010 * sdt
+ val b2 = -0.0041960863 * ldt2 - 0.7034186147 * mdt2 + 1.7076147010 * sdt2
+ val uB = b1 / (b1 * b1 - 0.5 * b * b2)
+ val tB = if (uB >= 0.0) -b * uB else Double.MAX_VALUE
+
+ t += min(tR, min(tG, tB))
+ return t
+}
+
+/**
+ * A smooth approximation of the cusp's location. Deliberately biased low — `S_mid` stays
+ * under `S_max` and `T_mid` under `T_max` — so Okhsl's interpolation never overshoots the
+ * gamut.
+ */
+internal fun getSaturationTintMid(aNorm: Double, bNorm: Double): SaturationTint {
+ val s = 0.11516993 + 1.0 / (
+ 7.44778970 + 4.15901240 * bNorm +
+ aNorm * (
+ -2.19557347 + 1.75198401 * bNorm +
+ aNorm * (
+ -2.13704948 - 10.02301043 * bNorm +
+ aNorm * (-4.24894561 + 5.38770819 * bNorm + 4.69891013 * aNorm)
+ )
+ )
+ )
+
+ val t = 0.11239642 + 1.0 / (
+ 1.61320320 - 0.68124379 * bNorm +
+ aNorm * (
+ 0.40370612 + 0.90148123 * bNorm +
+ aNorm * (
+ -0.27087943 + 0.61223990 * bNorm +
+ aNorm * (0.00299215 - 0.45399568 * bNorm - 0.14661872 * aNorm)
+ )
+ )
+ )
+
+ return SaturationTint(s = s, t = t)
+}
+
+/**
+ * The chroma at three reference saturations for a given lightness and hue: neutral-ish
+ * `c0`, the smooth midpoint `cMid`, and the gamut boundary `cMax`. Okhsl's saturation is
+ * a two-piece interpolation between them.
+ */
+internal fun getChromaAnchors(l: Double, aNorm: Double, bNorm: Double): ChromaAnchors {
+ val cusp = findCusp(aNorm, bNorm)
+ val cMax = findGamutIntersection(aNorm, bNorm, l, 1.0, l, cusp)
+ val stMax = cusp.toSaturationTint()
+
+ // Compensates for the curvature of the gamut surface, which the triangle below ignores.
+ val k = cMax / min(l * stMax.s, (1.0 - l) * stMax.t)
+
+ val stMid = getSaturationTintMid(aNorm, bNorm)
+ val midA = l * stMid.s
+ val midB = (1.0 - l) * stMid.t
+ // A soft minimum rather than a sharp triangle corner, so chroma varies smoothly.
+ val cMid = 0.9 * k * sqrt(sqrt(1.0 / (1.0 / (midA * midA * midA * midA) + 1.0 / (midB * midB * midB * midB))))
+
+ // The c0 shape is hue-independent, so these stand in for the average S and T.
+ val zeroA = l * 0.4
+ val zeroB = (1.0 - l) * 0.8
+ val c0 = sqrt(1.0 / (1.0 / (zeroA * zeroA) + 1.0 / (zeroB * zeroB)))
+
+ return ChromaAnchors(c0 = c0, cMid = cMid, cMax = cMax)
+}
+
+/**
+ * Below this chroma a color is a gray for every purpose that matters: it is five orders
+ * of magnitude under a just-noticeable difference, and it is where the Okhsl and Okhsv
+ * constructions divide by quantities that have gone to zero.
+ */
+internal const val ACHROMATIC_CHROMA = 1e-6
+
+/** True when every channel of [rgb] is within `0..1`, allowing for float slop. */
+internal fun LinearRgb.isInGamut(epsilon: Double = 1e-6): Boolean =
+ r >= -epsilon && r <= 1.0 + epsilon &&
+ g >= -epsilon && g <= 1.0 + epsilon &&
+ b >= -epsilon && b <= 1.0 + epsilon
+
+/** Perceptual distance between two Oklab colors, the ΔE the CSS gamut mapping uses. */
+internal fun deltaEOk(first: OkLab, second: OkLab): Double {
+ val dL = first.l - second.l
+ val dA = first.a - second.a
+ val dB = first.b - second.b
+ return sqrt(dL * dL + dA * dA + dB * dB)
+}
+
+internal fun LinearRgb.clipToUnit(): LinearRgb = LinearRgb(
+ r = r.coerceIn(0.0, 1.0),
+ g = g.coerceIn(0.0, 1.0),
+ b = b.coerceIn(0.0, 1.0),
+)
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/OkhslConversions.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/OkhslConversions.kt
new file mode 100644
index 0000000..cee9bf4
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/OkhslConversions.kt
@@ -0,0 +1,125 @@
+package codes.side.colorpicker.conversion
+
+import codes.side.colorpicker.model.HslColor
+import codes.side.colorpicker.model.OkhslColor
+import codes.side.colorpicker.model.RgbColor
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.hypot
+import kotlin.math.sin
+
+// Okhsl's saturation is piecewise: below this it interpolates between the neutral and
+// mid chroma anchors, above it between mid and the gamut boundary. 0.8 is Ottosson's
+// split, chosen so the two pieces meet with matching slope.
+private const val SATURATION_SPLIT = 0.8
+private const val SATURATION_SPLIT_INV = 1.25
+
+private const val DEGREES_PER_RADIAN = 180.0 / kotlin.math.PI
+
+/**
+ * Converts this Okhsl color to sRGB. Every Okhsl coordinate is inside the gamut by
+ * construction, so nothing is clipped or mapped. Alpha is carried over unchanged.
+ */
+public fun OkhslColor.toRgb(): RgbColor {
+ if (lightness >= 1f) return RgbColor(1f, 1f, 1f, alpha)
+ if (lightness <= 0f) return RgbColor(0f, 0f, 0f, alpha)
+
+ val radians = hue.toDouble() / DEGREES_PER_RADIAN
+ val aUnit = cos(radians)
+ val bUnit = sin(radians)
+ val okLightness = toeInv(lightness.toDouble())
+
+ val anchors = getChromaAnchors(okLightness, aUnit, bUnit)
+ val chroma = chromaForSaturation(saturation.toDouble(), anchors)
+
+ val linear = oklabToLinearSrgb(OkLab(okLightness, chroma * aUnit, chroma * bUnit))
+
+ return RgbColor(
+ red = delinearize(linear.r).toFloat().coerceIn(0f, 1f),
+ green = delinearize(linear.g).toFloat().coerceIn(0f, 1f),
+ blue = delinearize(linear.b).toFloat().coerceIn(0f, 1f),
+ alpha = alpha,
+ )
+}
+
+/**
+ * Converts this sRGB color to Okhsl. Grays have no hue to report and come back at hue
+ * `0` with zero saturation. Alpha is carried over unchanged.
+ */
+public fun RgbColor.toOkhsl(): OkhslColor {
+ val lab = linearSrgbToOklab(
+ LinearRgb(
+ r = linearize(red.toDouble()),
+ g = linearize(green.toDouble()),
+ b = linearize(blue.toDouble()),
+ ),
+ )
+
+ val chroma = hypot(lab.a, lab.b)
+ val lightness = toe(lab.l).toFloat().coerceIn(0f, 1f)
+ val gray = OkhslColor(hue = 0f, saturation = 0f, lightness = lightness, alpha = alpha)
+
+ // At the black and white poles the chroma anchors below collapse to zero and the
+ // interpolation divides by them, so grays are answered before that can happen.
+ if (chroma < ACHROMATIC_CHROMA || lab.l <= 0.0 || lab.l >= 1.0) return gray
+
+ val aUnit = lab.a / chroma
+ val bUnit = lab.b / chroma
+ val degrees = atan2(lab.b, lab.a) * DEGREES_PER_RADIAN
+
+ val anchors = getChromaAnchors(lab.l, aUnit, bUnit)
+ val saturation = saturationForChroma(chroma, anchors)
+
+ if (!saturation.isFinite()) return gray
+
+ return OkhslColor(
+ hue = ((degrees % 360.0 + 360.0) % 360.0).toFloat().coerceIn(0f, 360f),
+ saturation = saturation.toFloat().coerceIn(0f, 1f),
+ lightness = lightness,
+ alpha = alpha,
+ )
+}
+
+private fun chromaForSaturation(saturation: Double, anchors: ChromaAnchors): Double {
+ if (saturation < SATURATION_SPLIT) {
+ val t = SATURATION_SPLIT_INV * saturation
+ val k1 = SATURATION_SPLIT * anchors.c0
+ val k2 = 1.0 - k1 / anchors.cMid
+ return t * k1 / (1.0 - k2 * t)
+ }
+
+ val t = (saturation - SATURATION_SPLIT) / (1.0 - SATURATION_SPLIT)
+ val k0 = anchors.cMid
+ val k1 = (1.0 - SATURATION_SPLIT) * anchors.cMid * anchors.cMid *
+ SATURATION_SPLIT_INV * SATURATION_SPLIT_INV / anchors.c0
+ val k2 = 1.0 - k1 / (anchors.cMax - anchors.cMid)
+ return k0 + t * k1 / (1.0 - k2 * t)
+}
+
+private fun saturationForChroma(chroma: Double, anchors: ChromaAnchors): Double {
+ if (chroma < anchors.cMid) {
+ val k1 = SATURATION_SPLIT * anchors.c0
+ val k2 = 1.0 - k1 / anchors.cMid
+ val t = chroma / (k1 + k2 * chroma)
+ return t * SATURATION_SPLIT
+ }
+
+ val k0 = anchors.cMid
+ val k1 = (1.0 - SATURATION_SPLIT) * anchors.cMid * anchors.cMid *
+ SATURATION_SPLIT_INV * SATURATION_SPLIT_INV / anchors.c0
+ val k2 = 1.0 - k1 / (anchors.cMax - anchors.cMid)
+ val t = (chroma - k0) / (k1 + k2 * (chroma - k0))
+ return SATURATION_SPLIT + (1.0 - SATURATION_SPLIT) * t
+}
+
+/** Converts this Okhsl color to HSL by way of RGB. Alpha is carried over unchanged. */
+public fun OkhslColor.toHsl(): HslColor = toRgb().toHsl()
+
+/** Converts this HSL color to Okhsl by way of RGB. Alpha is carried over unchanged. */
+public fun HslColor.toOkhsl(): OkhslColor = toRgb().toOkhsl()
+
+/** Packs this Okhsl color into an ARGB [Int] (`0xAARRGGBB`); see [RgbColor.toArgbInt]. */
+public fun OkhslColor.toArgbInt(): Int = toRgb().toArgbInt()
+
+/** Unpacks this ARGB [Int] (`0xAARRGGBB`) into an [OkhslColor]; see [Int.toRgbColor]. */
+public fun Int.toOkhslColor(): OkhslColor = toRgbColor().toOkhsl()
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/OkhsvConversions.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/OkhsvConversions.kt
new file mode 100644
index 0000000..72df68a
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/OkhsvConversions.kt
@@ -0,0 +1,139 @@
+package codes.side.colorpicker.conversion
+
+import codes.side.colorpicker.model.HslColor
+import codes.side.colorpicker.model.OkhsvColor
+import codes.side.colorpicker.model.RgbColor
+import kotlin.math.atan2
+import kotlin.math.cbrt
+import kotlin.math.cos
+import kotlin.math.hypot
+import kotlin.math.max
+import kotlin.math.sin
+
+// The saturation the cusp is normalized against. Okhsv treats the gamut as a triangle
+// anchored at this value and then corrects for the real surface's curvature.
+private const val S0 = 0.5
+
+private const val DEGREES_PER_RADIAN = 180.0 / kotlin.math.PI
+
+/**
+ * Converts this Okhsv color to sRGB. Every Okhsv coordinate is inside the gamut by
+ * construction, so nothing is clipped or mapped. Alpha is carried over unchanged.
+ */
+public fun OkhsvColor.toRgb(): RgbColor {
+ if (value <= 0f) return RgbColor(0f, 0f, 0f, alpha)
+
+ val radians = hue.toDouble() / DEGREES_PER_RADIAN
+ val aUnit = cos(radians)
+ val bUnit = sin(radians)
+
+ val st = findCusp(aUnit, bUnit).toSaturationTint()
+ val k = 1.0 - S0 / st.s
+ val s = saturation.toDouble()
+ val v = value.toDouble()
+
+ // Lightness and chroma at full value, treating the gamut as a triangle.
+ val denominator = S0 + st.t - st.t * k * s
+ val lAtFullValue = 1.0 - s * S0 / denominator
+ val chromaAtFullValue = s * st.t * S0 / denominator
+
+ var lightness = v * lAtFullValue
+ var chroma = v * chromaAtFullValue
+
+ // Undo the toe, then rescale so the triangle meets the gamut's actual curved top.
+ val lToed = toeInv(lAtFullValue)
+ val chromaToed = chromaAtFullValue * lToed / lAtFullValue
+
+ val lightnessToed = toeInv(lightness)
+ chroma *= lightnessToed / lightness
+ lightness = lightnessToed
+
+ val scaleRgb = oklabToLinearSrgb(OkLab(lToed, aUnit * chromaToed, bUnit * chromaToed))
+ val scale = cbrt(1.0 / max(max(scaleRgb.r, scaleRgb.g), max(scaleRgb.b, 0.0)))
+
+ lightness *= scale
+ chroma *= scale
+
+ val linear = oklabToLinearSrgb(OkLab(lightness, chroma * aUnit, chroma * bUnit))
+
+ return RgbColor(
+ red = delinearize(linear.r).toFloat().coerceIn(0f, 1f),
+ green = delinearize(linear.g).toFloat().coerceIn(0f, 1f),
+ blue = delinearize(linear.b).toFloat().coerceIn(0f, 1f),
+ alpha = alpha,
+ )
+}
+
+/**
+ * Converts this sRGB color to Okhsv. Grays have no hue to report and come back at hue
+ * `0` with zero saturation. Alpha is carried over unchanged.
+ */
+public fun RgbColor.toOkhsv(): OkhsvColor {
+ val lab = linearSrgbToOklab(
+ LinearRgb(
+ r = linearize(red.toDouble()),
+ g = linearize(green.toDouble()),
+ b = linearize(blue.toDouble()),
+ ),
+ )
+
+ val chroma = hypot(lab.a, lab.b)
+ val gray = OkhsvColor(
+ hue = 0f,
+ saturation = 0f,
+ value = toe(lab.l).toFloat().coerceIn(0f, 1f),
+ alpha = alpha,
+ )
+
+ // At the black and white poles the construction below divides by quantities that
+ // have gone to zero, so grays are answered before that can happen.
+ if (chroma < ACHROMATIC_CHROMA || lab.l <= 0.0 || lab.l >= 1.0) return gray
+
+ val aUnit = lab.a / chroma
+ val bUnit = lab.b / chroma
+ val degrees = atan2(lab.b, lab.a) * DEGREES_PER_RADIAN
+
+ val st = findCusp(aUnit, bUnit).toSaturationTint()
+ val k = 1.0 - S0 / st.s
+
+ val t = st.t / (chroma + lab.l * st.t)
+ val lAtFullValue = t * lab.l
+ val chromaAtFullValue = t * chroma
+
+ val lToed = toeInv(lAtFullValue)
+ val chromaToed = chromaAtFullValue * lToed / lAtFullValue
+
+ val scaleRgb = oklabToLinearSrgb(OkLab(lToed, aUnit * chromaToed, bUnit * chromaToed))
+ val scale = cbrt(1.0 / max(max(scaleRgb.r, scaleRgb.g), max(scaleRgb.b, 0.0)))
+
+ val lightness = lab.l / scale
+ val value = toe(lightness) / lAtFullValue
+ // Saturation is read off the full-value chroma, not the rescaled one: the scaling
+ // above exists to place the color on the gamut's curved top, and folding it in here
+ // would count that correction twice.
+ val saturation = (S0 + st.t) * chromaAtFullValue /
+ (st.t * S0 + st.t * k * chromaAtFullValue)
+
+ // The construction collapses at the black pole, where neither saturation nor value
+ // has a value to take; the arithmetic goes non-finite rather than wrong.
+ if (!saturation.isFinite() || !value.isFinite()) return gray
+
+ return OkhsvColor(
+ hue = ((degrees % 360.0 + 360.0) % 360.0).toFloat().coerceIn(0f, 360f),
+ saturation = saturation.toFloat().coerceIn(0f, 1f),
+ value = value.toFloat().coerceIn(0f, 1f),
+ alpha = alpha,
+ )
+}
+
+/** Converts this Okhsv color to HSL by way of RGB. Alpha is carried over unchanged. */
+public fun OkhsvColor.toHsl(): HslColor = toRgb().toHsl()
+
+/** Converts this HSL color to Okhsv by way of RGB. Alpha is carried over unchanged. */
+public fun HslColor.toOkhsv(): OkhsvColor = toRgb().toOkhsv()
+
+/** Packs this Okhsv color into an ARGB [Int] (`0xAARRGGBB`); see [RgbColor.toArgbInt]. */
+public fun OkhsvColor.toArgbInt(): Int = toRgb().toArgbInt()
+
+/** Unpacks this ARGB [Int] (`0xAARRGGBB`) into an [OkhsvColor]; see [Int.toRgbColor]. */
+public fun Int.toOkhsvColor(): OkhsvColor = toRgbColor().toOkhsv()
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/OklabConversions.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/OklabConversions.kt
new file mode 100644
index 0000000..526bc74
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/OklabConversions.kt
@@ -0,0 +1,109 @@
+package codes.side.colorpicker.conversion
+
+import codes.side.colorpicker.model.HslColor
+import codes.side.colorpicker.model.OKLAB_AB_RANGE
+import codes.side.colorpicker.model.OklabColor
+import codes.side.colorpicker.model.OklchColor
+import codes.side.colorpicker.model.RgbColor
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.hypot
+import kotlin.math.sin
+
+private const val DEGREES_PER_RADIAN = 180.0 / kotlin.math.PI
+
+/** Converts this sRGB color to Oklab. Alpha is carried over unchanged. */
+public fun RgbColor.toOklab(): OklabColor {
+ val lab = linearSrgbToOklab(
+ LinearRgb(
+ r = linearize(red.toDouble()),
+ g = linearize(green.toDouble()),
+ b = linearize(blue.toDouble()),
+ ),
+ )
+
+ return OklabColor(
+ l = lab.l.toFloat().coerceIn(0f, 1f),
+ a = lab.a.toFloat().coerceIn(-OKLAB_AB_RANGE, OKLAB_AB_RANGE),
+ b = lab.b.toFloat().coerceIn(-OKLAB_AB_RANGE, OKLAB_AB_RANGE),
+ alpha = alpha,
+ )
+}
+
+/**
+ * Converts this Oklab color to sRGB. Colors outside the display gamut are mapped by the
+ * CSS Color 4 algorithm, which holds lightness and hue and gives up chroma — see
+ * [gamutMapToSrgb]. Alpha is carried over unchanged.
+ */
+public fun OklabColor.toRgb(): RgbColor {
+ val linear = gamutMapToSrgb(OkLab(l.toDouble(), a.toDouble(), b.toDouble()))
+
+ return RgbColor(
+ red = delinearize(linear.r).toFloat().coerceIn(0f, 1f),
+ green = delinearize(linear.g).toFloat().coerceIn(0f, 1f),
+ blue = delinearize(linear.b).toFloat().coerceIn(0f, 1f),
+ alpha = alpha,
+ )
+}
+
+/**
+ * Converts this Oklab color to its cylindrical form. A neutral color has no meaningful
+ * hue angle and reports `0`. Alpha is carried over unchanged.
+ */
+public fun OklabColor.toOklch(): OklchColor {
+ val chroma = hypot(a.toDouble(), b.toDouble())
+ val degrees = atan2(b.toDouble(), a.toDouble()) * DEGREES_PER_RADIAN
+
+ return OklchColor(
+ l = l,
+ chroma = chroma.toFloat().coerceIn(0f, OKLAB_AB_RANGE),
+ hue = ((degrees % 360.0 + 360.0) % 360.0).toFloat().coerceIn(0f, 360f),
+ alpha = alpha,
+ )
+}
+
+/** Converts this OkLCh color to its rectangular form. Alpha is carried over unchanged. */
+public fun OklchColor.toOklab(): OklabColor {
+ val radians = hue.toDouble() / DEGREES_PER_RADIAN
+ val chroma = this.chroma.toDouble()
+
+ return OklabColor(
+ l = l,
+ a = (chroma * cos(radians)).toFloat().coerceIn(-OKLAB_AB_RANGE, OKLAB_AB_RANGE),
+ b = (chroma * sin(radians)).toFloat().coerceIn(-OKLAB_AB_RANGE, OKLAB_AB_RANGE),
+ alpha = alpha,
+ )
+}
+
+/** Converts this sRGB color to OkLCh. Alpha is carried over unchanged. */
+public fun RgbColor.toOklch(): OklchColor = toOklab().toOklch()
+
+/**
+ * Converts this OkLCh color to sRGB, gamut-mapping colors the display cannot show. Alpha
+ * is carried over unchanged.
+ */
+public fun OklchColor.toRgb(): RgbColor = toOklab().toRgb()
+
+/** Converts this Oklab color to HSL by way of RGB. Alpha is carried over unchanged. */
+public fun OklabColor.toHsl(): HslColor = toRgb().toHsl()
+
+/** Converts this HSL color to Oklab by way of RGB. Alpha is carried over unchanged. */
+public fun HslColor.toOklab(): OklabColor = toRgb().toOklab()
+
+/** Converts this OkLCh color to HSL by way of RGB. Alpha is carried over unchanged. */
+public fun OklchColor.toHsl(): HslColor = toRgb().toHsl()
+
+/** Converts this HSL color to OkLCh by way of RGB. Alpha is carried over unchanged. */
+public fun HslColor.toOklch(): OklchColor = toRgb().toOklch()
+
+/** Packs this Oklab color into an ARGB [Int] (`0xAARRGGBB`); see [RgbColor.toArgbInt]. */
+public fun OklabColor.toArgbInt(): Int = toRgb().toArgbInt()
+
+/** Unpacks this ARGB [Int] (`0xAARRGGBB`) into an [OklabColor]; see [Int.toRgbColor]. */
+public fun Int.toOklabColor(): OklabColor = toRgbColor().toOklab()
+
+/** Packs this OkLCh color into an ARGB [Int] (`0xAARRGGBB`); see [RgbColor.toArgbInt]. */
+public fun OklchColor.toArgbInt(): Int = toRgb().toArgbInt()
+
+/** Unpacks this ARGB [Int] (`0xAARRGGBB`) into an [OklchColor]; see [Int.toRgbColor]. */
+public fun Int.toOklchColor(): OklchColor = toRgbColor().toOklch()
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/PickerColorConversions.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/PickerColorConversions.kt
new file mode 100644
index 0000000..9e87d79
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/PickerColorConversions.kt
@@ -0,0 +1,41 @@
+package codes.side.colorpicker.conversion
+
+import codes.side.colorpicker.model.CmykColor
+import codes.side.colorpicker.model.HslColor
+import codes.side.colorpicker.model.LabColor
+import codes.side.colorpicker.model.OkhslColor
+import codes.side.colorpicker.model.OkhsvColor
+import codes.side.colorpicker.model.OklabColor
+import codes.side.colorpicker.model.OklchColor
+import codes.side.colorpicker.model.PickerColor
+import codes.side.colorpicker.model.RgbColor
+
+/**
+ * Converts any [PickerColor] to sRGB, the hub every other space converts through.
+ *
+ * With eight spaces, writing each pair out would mean sixty-four branches to keep in
+ * step. Routing through RGB costs one extra conversion when neither end is RGB, which is
+ * what the pairwise code did anyway.
+ */
+internal fun PickerColor.toRgbColor(): RgbColor = when (this) {
+ is RgbColor -> this
+ is HslColor -> toRgb()
+ is CmykColor -> toRgb()
+ is LabColor -> toRgb()
+ is OklabColor -> toRgb()
+ is OklchColor -> toRgb()
+ is OkhslColor -> toRgb()
+ is OkhsvColor -> toRgb()
+}
+
+/** Returns a copy of this color with [alpha] replaced, keeping its space. */
+internal fun PickerColor.withAlpha(alpha: Float): PickerColor = when (this) {
+ is RgbColor -> copy(alpha = alpha)
+ is HslColor -> copy(alpha = alpha)
+ is CmykColor -> copy(alpha = alpha)
+ is LabColor -> copy(alpha = alpha)
+ is OklabColor -> copy(alpha = alpha)
+ is OklchColor -> copy(alpha = alpha)
+ is OkhslColor -> copy(alpha = alpha)
+ is OkhsvColor -> copy(alpha = alpha)
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/OkhslColor.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/OkhslColor.kt
new file mode 100644
index 0000000..64ab299
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/OkhslColor.kt
@@ -0,0 +1,125 @@
+package codes.side.colorpicker.model
+
+import androidx.compose.runtime.Immutable
+import kotlin.math.roundToInt
+
+/**
+ * An immutable color in the Okhsl color space — Björn Ottosson's perceptual
+ * replacement for [HslColor], built on [OklabColor].
+ *
+ * [hue] is in degrees; the constructor accepts `0..360` but `360` is normalized to `0`,
+ * so the stored value is always in `0..360` (exclusive). [saturation], [lightness] and
+ * [alpha] are in `0..1`. The constructor throws [IllegalArgumentException] for
+ * out-of-range or NaN values; use [fromInt] for a clamping alternative.
+ *
+ * Two properties distinguish it from [HslColor]. [lightness] tracks perceived
+ * lightness, so a blue and a yellow at the same value look equally light, which is not
+ * true of HSL. And [saturation] is normalized against the sRGB gamut: `1` means as
+ * colorful as this hue and lightness can be on the display, so every coordinate in the
+ * cube maps to a color that actually exists. Nothing here needs gamut mapping, and no
+ * part of a slider's travel is unreachable.
+ *
+ * The normalization follows Ottosson's reference implementation, which approximates the
+ * gamut below the cusp with a straight line to black. About 4% of sRGB — the most
+ * saturated blues and violets — sits marginally outside that line, and converting such a
+ * color in and back out again loses up to 4 steps of 255. Reproducing the reference
+ * exactly is the deliberate trade: these coordinates mean the same thing here as they do
+ * anywhere else that implements the space.
+ *
+ * The no-argument constructor `OkhslColor()` is opaque red (hue 0, full saturation, mid
+ * lightness), matching `HslColor()`. Note that each model's default is intentionally its
+ * space's most natural origin, so defaults differ per model: `RgbColor()` = black,
+ * `HslColor()` = red, `CmykColor()` = white, `LabColor()` = mid-gray.
+ */
+@Immutable
+public class OkhslColor(
+ hue: Float = 0f,
+ saturation: Float = 1f,
+ lightness: Float = 0.5f,
+ alpha: Float = 1f,
+) : PickerColor {
+
+ init {
+ require(hue in 0f..360f) { "Hue must be in 0..360, was $hue" }
+ require(saturation in 0f..1f) { "Saturation must be in 0..1, was $saturation" }
+ require(lightness in 0f..1f) { "Lightness must be in 0..1, was $lightness" }
+ require(alpha in 0f..1f) { "Alpha must be in 0..1, was $alpha" }
+ }
+
+ // 360f is accepted for compatibility but stored as the equivalent 0f;
+ // "+ 0f" normalizes -0.0f to 0.0f so equality can't split on signed zero.
+ /** Hue in degrees, in `0..360` (exclusive) after normalization. */
+ public val hue: Float = if (hue == 360f) 0f else hue + 0f
+
+ /** Saturation in `0..1`, where `1` is the most colorful this hue and lightness can be in sRGB. */
+ public val saturation: Float = saturation + 0f
+
+ /** Perceived lightness in `0..1`. */
+ public val lightness: Float = lightness + 0f
+ override val alpha: Float = alpha + 0f
+
+ /** [hue] in degrees, rounded to the nearest integer. */
+ public val intHue: Int get() = hue.roundToInt()
+
+ /** [saturation] scaled to `0..100` percent and rounded to the nearest integer. */
+ public val intSaturation: Int get() = (saturation * 100f).roundToInt()
+
+ /** [lightness] scaled to `0..100` percent and rounded to the nearest integer. */
+ public val intLightness: Int get() = (lightness * 100f).roundToInt()
+
+ /** [alpha] scaled to `0..255` and rounded to the nearest integer. */
+ public val intAlpha: Int get() = (alpha * 255f).roundToInt()
+
+ /** Returns a copy of this color, replacing only the channels passed explicitly. */
+ public fun copy(
+ hue: Float = this.hue,
+ saturation: Float = this.saturation,
+ lightness: Float = this.lightness,
+ alpha: Float = this.alpha,
+ ): OkhslColor = OkhslColor(hue = hue, saturation = saturation, lightness = lightness, alpha = alpha)
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is OkhslColor) return false
+ return hue == other.hue &&
+ saturation == other.saturation &&
+ lightness == other.lightness &&
+ alpha == other.alpha
+ }
+
+ override fun hashCode(): Int {
+ var result = hue.hashCode()
+ result = 31 * result + saturation.hashCode()
+ result = 31 * result + lightness.hashCode()
+ result = 31 * result + alpha.hashCode()
+ return result
+ }
+
+ override fun toString(): String =
+ "OkhslColor(hue=$hue, saturation=$saturation, lightness=$lightness, alpha=$alpha)"
+
+ public companion object {
+ /** Opaque black. */
+ public val Black: OkhslColor = OkhslColor(hue = 0f, saturation = 0f, lightness = 0f)
+
+ /** Opaque white. */
+ public val White: OkhslColor = OkhslColor(hue = 0f, saturation = 0f, lightness = 1f)
+
+ /**
+ * Creates an [OkhslColor] from integer channels: [hue] in degrees, [saturation]
+ * and [lightness] in `0..100` percent, [alpha] in `0..255`. Unlike the
+ * constructor, out-of-range values are clamped instead of throwing.
+ */
+ public fun fromInt(
+ hue: Int,
+ saturation: Int,
+ lightness: Int,
+ alpha: Int = 255,
+ ): OkhslColor = OkhslColor(
+ hue = hue.toFloat().coerceIn(0f, 360f),
+ saturation = (saturation / 100f).coerceIn(0f, 1f),
+ lightness = (lightness / 100f).coerceIn(0f, 1f),
+ alpha = (alpha / 255f).coerceIn(0f, 1f),
+ )
+ }
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/OkhsvColor.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/OkhsvColor.kt
new file mode 100644
index 0000000..7aff512
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/OkhsvColor.kt
@@ -0,0 +1,124 @@
+package codes.side.colorpicker.model
+
+import androidx.compose.runtime.Immutable
+import kotlin.math.roundToInt
+
+/**
+ * An immutable color in the Okhsv color space — Björn Ottosson's perceptual HSV, built
+ * on [OklabColor].
+ *
+ * [hue] is in degrees; the constructor accepts `0..360` but `360` is normalized to `0`,
+ * so the stored value is always in `0..360` (exclusive). [saturation], [value] and
+ * [alpha] are in `0..1`. The constructor throws [IllegalArgumentException] for
+ * out-of-range or NaN values; use [fromInt] for a clamping alternative.
+ *
+ * Like [OkhslColor], the coordinates are normalized against the sRGB gamut, so every
+ * point in the cube is a color the display can show and nothing needs gamut mapping.
+ * Okhsv keeps the HSV arrangement artists expect — [value] `1` with [saturation] `1` is
+ * the most vivid form of the hue, and dropping [value] darkens toward black — whereas
+ * [OkhslColor] centres lightness so that `0.5` is a mid tone.
+ *
+ * The normalization follows Ottosson's reference implementation, which approximates the
+ * gamut below the cusp with a straight line to black. About 4% of sRGB — the most
+ * saturated blues and violets — sits marginally outside that line, and converting such a
+ * color in and back out again loses up to 4 steps of 255. Reproducing the reference
+ * exactly is the deliberate trade: these coordinates mean the same thing here as they do
+ * anywhere else that implements the space.
+ *
+ * The no-argument constructor `OkhsvColor()` is opaque red (hue 0, full saturation, full
+ * value). Note that each model's default is intentionally its space's most natural
+ * origin, so defaults differ per model: `RgbColor()` = black, `HslColor()` = red,
+ * `CmykColor()` = white, `LabColor()` = mid-gray.
+ */
+@Immutable
+public class OkhsvColor(
+ hue: Float = 0f,
+ saturation: Float = 1f,
+ value: Float = 1f,
+ alpha: Float = 1f,
+) : PickerColor {
+
+ init {
+ require(hue in 0f..360f) { "Hue must be in 0..360, was $hue" }
+ require(saturation in 0f..1f) { "Saturation must be in 0..1, was $saturation" }
+ require(value in 0f..1f) { "Value must be in 0..1, was $value" }
+ require(alpha in 0f..1f) { "Alpha must be in 0..1, was $alpha" }
+ }
+
+ // 360f is accepted for compatibility but stored as the equivalent 0f;
+ // "+ 0f" normalizes -0.0f to 0.0f so equality can't split on signed zero.
+ /** Hue in degrees, in `0..360` (exclusive) after normalization. */
+ public val hue: Float = if (hue == 360f) 0f else hue + 0f
+
+ /** Saturation in `0..1`, where `1` is the most colorful this hue and value can be in sRGB. */
+ public val saturation: Float = saturation + 0f
+
+ /** Value in `0..1`, where `0` is black. */
+ public val value: Float = value + 0f
+ override val alpha: Float = alpha + 0f
+
+ /** [hue] in degrees, rounded to the nearest integer. */
+ public val intHue: Int get() = hue.roundToInt()
+
+ /** [saturation] scaled to `0..100` percent and rounded to the nearest integer. */
+ public val intSaturation: Int get() = (saturation * 100f).roundToInt()
+
+ /** [value] scaled to `0..100` percent and rounded to the nearest integer. */
+ public val intValue: Int get() = (value * 100f).roundToInt()
+
+ /** [alpha] scaled to `0..255` and rounded to the nearest integer. */
+ public val intAlpha: Int get() = (alpha * 255f).roundToInt()
+
+ /** Returns a copy of this color, replacing only the channels passed explicitly. */
+ public fun copy(
+ hue: Float = this.hue,
+ saturation: Float = this.saturation,
+ value: Float = this.value,
+ alpha: Float = this.alpha,
+ ): OkhsvColor = OkhsvColor(hue = hue, saturation = saturation, value = value, alpha = alpha)
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is OkhsvColor) return false
+ return hue == other.hue &&
+ saturation == other.saturation &&
+ value == other.value &&
+ alpha == other.alpha
+ }
+
+ override fun hashCode(): Int {
+ var result = hue.hashCode()
+ result = 31 * result + saturation.hashCode()
+ result = 31 * result + value.hashCode()
+ result = 31 * result + alpha.hashCode()
+ return result
+ }
+
+ override fun toString(): String =
+ "OkhsvColor(hue=$hue, saturation=$saturation, value=$value, alpha=$alpha)"
+
+ public companion object {
+ /** Opaque black. */
+ public val Black: OkhsvColor = OkhsvColor(hue = 0f, saturation = 0f, value = 0f)
+
+ /** Opaque white. */
+ public val White: OkhsvColor = OkhsvColor(hue = 0f, saturation = 0f, value = 1f)
+
+ /**
+ * Creates an [OkhsvColor] from integer channels: [hue] in degrees, [saturation]
+ * and [value] in `0..100` percent, [alpha] in `0..255`. Unlike the constructor,
+ * out-of-range values are clamped instead of throwing.
+ */
+ public fun fromInt(
+ hue: Int,
+ saturation: Int,
+ value: Int,
+ alpha: Int = 255,
+ ): OkhsvColor = OkhsvColor(
+ hue = hue.toFloat().coerceIn(0f, 360f),
+ saturation = (saturation / 100f).coerceIn(0f, 1f),
+ value = (value / 100f).coerceIn(0f, 1f),
+ alpha = (alpha / 255f).coerceIn(0f, 1f),
+ )
+ }
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/OklabColor.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/OklabColor.kt
new file mode 100644
index 0000000..501df6e
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/OklabColor.kt
@@ -0,0 +1,120 @@
+package codes.side.colorpicker.model
+
+import androidx.compose.runtime.Immutable
+import kotlin.math.roundToInt
+
+/**
+ * The reference range of the [OklabColor.a] and [OklabColor.b] axes, matching the
+ * `100%` value CSS Color 4 assigns them in `oklab()`. It comfortably contains the
+ * sRGB gamut, which reaches about `0.28` on a and `0.20` on b.
+ */
+internal const val OKLAB_AB_RANGE: Float = 0.4f
+
+/**
+ * An immutable color in the Oklab perceptual color space.
+ *
+ * [l] (lightness) is in `0..1` — Oklab's own scale, not CIELAB's `0..100`. [a]
+ * (green-red axis) and [b] (blue-yellow axis) are in `-0.4..0.4`, the reference range
+ * CSS Color 4 uses for `oklab()`. [alpha] is in `0..1`. The constructor throws
+ * [IllegalArgumentException] for out-of-range or NaN values; use [fromInt] for a
+ * clamping alternative.
+ *
+ * Oklab is perceptually uniform in a way [LabColor] is not: equal numeric steps
+ * correspond to more nearly equal perceived differences, and changing [l] does not
+ * drag the perceived hue along with it. That makes it the space to interpolate,
+ * compare and gamut-map in. It is not a space to put sliders on — [a] and [b] are
+ * unbounded by the display gamut, so most of their range is unreachable. Use
+ * [OkhslColor] or [OkhsvColor] for that.
+ *
+ * The no-argument constructor `OklabColor()` is opaque mid-gray (L = 0.5 on the
+ * neutral axis). Note that each model's default is intentionally its space's most
+ * natural origin, so defaults differ per model: `RgbColor()` = black,
+ * `HslColor()` = red, `CmykColor()` = white, `LabColor()` = mid-gray.
+ */
+@Immutable
+public class OklabColor(
+ l: Float = 0.5f,
+ a: Float = 0f,
+ b: Float = 0f,
+ alpha: Float = 1f,
+) : PickerColor {
+
+ init {
+ require(l in 0f..1f) { "L must be in 0..1, was $l" }
+ require(a in -OKLAB_AB_RANGE..OKLAB_AB_RANGE) { "A must be in -0.4..0.4, was $a" }
+ require(b in -OKLAB_AB_RANGE..OKLAB_AB_RANGE) { "B must be in -0.4..0.4, was $b" }
+ require(alpha in 0f..1f) { "Alpha must be in 0..1, was $alpha" }
+ }
+
+ // "+ 0f" normalizes -0.0f to 0.0f so equality can't split on signed zero.
+ /** Lightness in `0..1`. */
+ public val l: Float = l + 0f
+
+ /** Green-red axis in `-0.4..0.4`; negative is green, positive is red. */
+ public val a: Float = a + 0f
+
+ /** Blue-yellow axis in `-0.4..0.4`; negative is blue, positive is yellow. */
+ public val b: Float = b + 0f
+ override val alpha: Float = alpha + 0f
+
+ /** [l] scaled to `0..100` percent and rounded to the nearest integer. */
+ public val intL: Int get() = (l * 100f).roundToInt()
+
+ /** [a] as a percentage of its reference range, in `-100..100`, per CSS `oklab()`. */
+ public val intA: Int get() = (a / OKLAB_AB_RANGE * 100f).roundToInt()
+
+ /** [b] as a percentage of its reference range, in `-100..100`, per CSS `oklab()`. */
+ public val intB: Int get() = (b / OKLAB_AB_RANGE * 100f).roundToInt()
+
+ /** [alpha] scaled to `0..255` and rounded to the nearest integer. */
+ public val intAlpha: Int get() = (alpha * 255f).roundToInt()
+
+ /** Returns a copy of this color, replacing only the channels passed explicitly. */
+ public fun copy(
+ l: Float = this.l,
+ a: Float = this.a,
+ b: Float = this.b,
+ alpha: Float = this.alpha,
+ ): OklabColor = OklabColor(l = l, a = a, b = b, alpha = alpha)
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is OklabColor) return false
+ return l == other.l &&
+ a == other.a &&
+ b == other.b &&
+ alpha == other.alpha
+ }
+
+ override fun hashCode(): Int {
+ var result = l.hashCode()
+ result = 31 * result + a.hashCode()
+ result = 31 * result + b.hashCode()
+ result = 31 * result + alpha.hashCode()
+ return result
+ }
+
+ override fun toString(): String =
+ "OklabColor(l=$l, a=$a, b=$b, alpha=$alpha)"
+
+ public companion object {
+ /** Opaque black. */
+ public val Black: OklabColor = OklabColor(l = 0f, a = 0f, b = 0f)
+
+ /** Opaque white. */
+ public val White: OklabColor = OklabColor(l = 1f, a = 0f, b = 0f)
+
+ /**
+ * Creates an [OklabColor] from integer channels: [l] in `0..100` percent, [a]
+ * and [b] in `-100..100` percent of the `-0.4..0.4` reference range, [alpha] in
+ * `0..255`. Unlike the constructor, out-of-range values are clamped instead of
+ * throwing.
+ */
+ public fun fromInt(l: Int, a: Int, b: Int, alpha: Int = 255): OklabColor = OklabColor(
+ l = (l / 100f).coerceIn(0f, 1f),
+ a = (a / 100f * OKLAB_AB_RANGE).coerceIn(-OKLAB_AB_RANGE, OKLAB_AB_RANGE),
+ b = (b / 100f * OKLAB_AB_RANGE).coerceIn(-OKLAB_AB_RANGE, OKLAB_AB_RANGE),
+ alpha = (alpha / 255f).coerceIn(0f, 1f),
+ )
+ }
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/OklchColor.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/OklchColor.kt
new file mode 100644
index 0000000..039d1b8
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/OklchColor.kt
@@ -0,0 +1,114 @@
+package codes.side.colorpicker.model
+
+import androidx.compose.runtime.Immutable
+import kotlin.math.roundToInt
+
+/**
+ * An immutable color in the OkLCh color space — the cylindrical form of [OklabColor],
+ * and the space CSS exposes as `oklch()`.
+ *
+ * [l] (lightness) is in `0..1`. [chroma] is in `0..0.4`, the reference range CSS Color 4
+ * assigns `100%` in `oklch()`. [hue] is in degrees; the constructor accepts `0..360` but
+ * `360` is normalized to `0`, so the stored value is always in `0..360` (exclusive).
+ * [alpha] is in `0..1`. The constructor throws [IllegalArgumentException] for
+ * out-of-range or NaN values; use [fromInt] for a clamping alternative.
+ *
+ * Chroma is not bounded by the display gamut: how much of the `0..0.4` range is
+ * reachable depends on [l] and [hue], and the rest converts to the nearest displayable
+ * color. That makes OkLCh a poor space to put a raw chroma slider on — use
+ * [OkhslColor] or [OkhsvColor], whose saturation is normalized against the gamut, and
+ * keep OkLCh for interchange with CSS and for hue-preserving manipulation.
+ *
+ * The no-argument constructor `OklchColor()` is opaque mid-gray (L = 0.5, no chroma).
+ * Note that each model's default is intentionally its space's most natural origin, so
+ * defaults differ per model: `RgbColor()` = black, `HslColor()` = red,
+ * `CmykColor()` = white, `LabColor()` = mid-gray.
+ */
+@Immutable
+public class OklchColor(
+ l: Float = 0.5f,
+ chroma: Float = 0f,
+ hue: Float = 0f,
+ alpha: Float = 1f,
+) : PickerColor {
+
+ init {
+ require(l in 0f..1f) { "L must be in 0..1, was $l" }
+ require(chroma in 0f..OKLAB_AB_RANGE) { "Chroma must be in 0..0.4, was $chroma" }
+ require(hue in 0f..360f) { "Hue must be in 0..360, was $hue" }
+ require(alpha in 0f..1f) { "Alpha must be in 0..1, was $alpha" }
+ }
+
+ // "+ 0f" normalizes -0.0f to 0.0f so equality can't split on signed zero.
+ /** Lightness in `0..1`. */
+ public val l: Float = l + 0f
+
+ /** Chroma in `0..0.4`; `0` is neutral gray. */
+ public val chroma: Float = chroma + 0f
+
+ // 360f is accepted for compatibility but stored as the equivalent 0f.
+ /** Hue in degrees, in `0..360` (exclusive) after normalization. */
+ public val hue: Float = if (hue == 360f) 0f else hue + 0f
+ override val alpha: Float = alpha + 0f
+
+ /** [l] scaled to `0..100` percent and rounded to the nearest integer. */
+ public val intL: Int get() = (l * 100f).roundToInt()
+
+ /** [chroma] as a percentage of its reference range, in `0..100`, per CSS `oklch()`. */
+ public val intChroma: Int get() = (chroma / OKLAB_AB_RANGE * 100f).roundToInt()
+
+ /** [hue] in degrees, rounded to the nearest integer. */
+ public val intHue: Int get() = hue.roundToInt()
+
+ /** [alpha] scaled to `0..255` and rounded to the nearest integer. */
+ public val intAlpha: Int get() = (alpha * 255f).roundToInt()
+
+ /** Returns a copy of this color, replacing only the channels passed explicitly. */
+ public fun copy(
+ l: Float = this.l,
+ chroma: Float = this.chroma,
+ hue: Float = this.hue,
+ alpha: Float = this.alpha,
+ ): OklchColor = OklchColor(l = l, chroma = chroma, hue = hue, alpha = alpha)
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is OklchColor) return false
+ return l == other.l &&
+ chroma == other.chroma &&
+ hue == other.hue &&
+ alpha == other.alpha
+ }
+
+ override fun hashCode(): Int {
+ var result = l.hashCode()
+ result = 31 * result + chroma.hashCode()
+ result = 31 * result + hue.hashCode()
+ result = 31 * result + alpha.hashCode()
+ return result
+ }
+
+ override fun toString(): String =
+ "OklchColor(l=$l, chroma=$chroma, hue=$hue, alpha=$alpha)"
+
+ public companion object {
+ /** Opaque black. */
+ public val Black: OklchColor = OklchColor(l = 0f, chroma = 0f, hue = 0f)
+
+ /** Opaque white. */
+ public val White: OklchColor = OklchColor(l = 1f, chroma = 0f, hue = 0f)
+
+ /**
+ * Creates an [OklchColor] from integer channels: [l] in `0..100` percent,
+ * [chroma] in `0..100` percent of the `0..0.4` reference range, [hue] in degrees,
+ * [alpha] in `0..255`. Unlike the constructor, out-of-range values are clamped
+ * instead of throwing.
+ */
+ public fun fromInt(l: Int, chroma: Int, hue: Int, alpha: Int = 255): OklchColor = OklchColor(
+ l = (l / 100f).coerceIn(0f, 1f),
+ chroma = (chroma / 100f * OKLAB_AB_RANGE).coerceIn(0f, OKLAB_AB_RANGE),
+ hue = hue.toFloat().coerceIn(0f, 360f),
+ alpha = (alpha / 255f).coerceIn(0f, 1f),
+ )
+ }
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/PickerColor.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/PickerColor.kt
index f4859a3..e98d2c9 100644
--- a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/PickerColor.kt
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/PickerColor.kt
@@ -4,7 +4,7 @@ import androidx.compose.runtime.Immutable
/**
* An immutable color in one of the supported color spaces: [RgbColor], [HslColor],
- * [CmykColor], or [LabColor].
+ * [CmykColor], [LabColor], [OklabColor], [OklchColor], [OkhslColor], or [OkhsvColor].
*
* All implementations validate their channels on construction (the constructor throws
* [IllegalArgumentException] for out-of-range or NaN values) and share an [alpha] channel.
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/state/ColorPickerState.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/state/ColorPickerState.kt
index 1ffe90d..c90afb0 100644
--- a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/state/ColorPickerState.kt
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/state/ColorPickerState.kt
@@ -1,6 +1,7 @@
package codes.side.colorpicker.state
import androidx.compose.runtime.Stable
+import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -9,11 +10,20 @@ import codes.side.colorpicker.conversion.toArgbInt
import codes.side.colorpicker.conversion.toCmyk
import codes.side.colorpicker.conversion.toHsl
import codes.side.colorpicker.conversion.toLab
-import codes.side.colorpicker.conversion.toRgb
+import codes.side.colorpicker.conversion.toOkhsl
+import codes.side.colorpicker.conversion.toOkhsv
+import codes.side.colorpicker.conversion.toOklab
+import codes.side.colorpicker.conversion.toOklch
import codes.side.colorpicker.conversion.toRgbColor
+import codes.side.colorpicker.conversion.withAlpha
import codes.side.colorpicker.model.CmykColor
import codes.side.colorpicker.model.HslColor
import codes.side.colorpicker.model.LabColor
+import codes.side.colorpicker.model.OKLAB_AB_RANGE
+import codes.side.colorpicker.model.OkhslColor
+import codes.side.colorpicker.model.OkhsvColor
+import codes.side.colorpicker.model.OklabColor
+import codes.side.colorpicker.model.OklchColor
import codes.side.colorpicker.model.PickerColor
import codes.side.colorpicker.model.RgbColor
@@ -58,41 +68,26 @@ public class ColorPickerState(initialColor: PickerColor = HslColor()) {
// ---- Derived spaces (pure computation, no writes on read) ----
- private val hslDerived = derivedStateOf {
- when (val c = authoritative) {
- is HslColor -> c
- is RgbColor -> c.toHsl().copy(alpha = c.alpha)
- is CmykColor -> c.toRgb().toHsl().copy(alpha = c.alpha)
- is LabColor -> c.toRgb().toHsl().copy(alpha = c.alpha)
- }
- }
-
- private val rgbDerived = derivedStateOf {
- when (val c = authoritative) {
- is HslColor -> c.toRgb()
- is RgbColor -> c
- is CmykColor -> c.toRgb()
- is LabColor -> c.toRgb()
- }
- }
-
- private val cmykDerived = derivedStateOf {
- when (val c = authoritative) {
- is HslColor -> c.toRgb().toCmyk()
- is RgbColor -> c.toCmyk()
- is CmykColor -> c
- is LabColor -> c.toRgb().toCmyk()
- }
+ /**
+ * A view of the authoritative color in one space: itself when that space is the
+ * origin, and [convert] applied to its RGB form otherwise. Routing every pair through
+ * RGB is what keeps this from being sixty-four hand-written conversions.
+ */
+ private inline fun derivedSpace(
+ crossinline convert: (RgbColor) -> T,
+ ): State = derivedStateOf {
+ val color = authoritative
+ color as? T ?: convert(color.toRgbColor())
}
- private val labDerived = derivedStateOf {
- when (val c = authoritative) {
- is HslColor -> c.toRgb().toLab()
- is RgbColor -> c.toLab()
- is CmykColor -> c.toRgb().toLab()
- is LabColor -> c
- }
- }
+ private val hslDerived = derivedSpace { it.toHsl() }
+ private val rgbDerived = derivedStateOf { authoritative.toRgbColor() }
+ private val cmykDerived = derivedSpace { it.toCmyk() }
+ private val labDerived = derivedSpace { it.toLab() }
+ private val oklabDerived = derivedSpace { it.toOklab() }
+ private val oklchDerived = derivedSpace { it.toOklch() }
+ private val okhslDerived = derivedSpace { it.toOkhsl() }
+ private val okhsvDerived = derivedSpace { it.toOkhsv() }
// ---- Public read access ----
@@ -108,6 +103,18 @@ public class ColorPickerState(initialColor: PickerColor = HslColor()) {
/** The current color as CIELAB; a derived conversion unless LAB is the origin space. */
public val labColor: LabColor get() = labDerived.value
+ /** The current color as Oklab; a derived conversion unless Oklab is the origin space. */
+ public val oklabColor: OklabColor get() = oklabDerived.value
+
+ /** The current color as OkLCh; a derived conversion unless OkLCh is the origin space. */
+ public val oklchColor: OklchColor get() = oklchDerived.value
+
+ /** The current color as Okhsl; a derived conversion unless Okhsl is the origin space. */
+ public val okhslColor: OkhslColor get() = okhslDerived.value
+
+ /** The current color as Okhsv; a derived conversion unless Okhsv is the origin space. */
+ public val okhsvColor: OkhsvColor get() = okhsvDerived.value
+
/** The current color as a packed ARGB [Int] (`0xAARRGGBB`). */
public val argbInt: Int get() = rgbColor.toArgbInt()
@@ -224,18 +231,124 @@ public class ColorPickerState(initialColor: PickerColor = HslColor()) {
authoritative = lab
}
+ // ---- Oklab updates ----
+
+ /** Updates the lightness channel. NaN is ignored; values are clamped to 0..1. */
+ public fun updateOklabLightness(l: Float) {
+ if (l.isNaN()) return
+ authoritative = oklabColor.copy(l = l.coerceIn(0f, 1f))
+ }
+
+ /** Updates the a axis. NaN is ignored; values are clamped to -0.4..0.4. */
+ public fun updateOklabA(a: Float) {
+ if (a.isNaN()) return
+ authoritative = oklabColor.copy(a = a.coerceIn(-OKLAB_AB_RANGE, OKLAB_AB_RANGE))
+ }
+
+ /** Updates the b axis. NaN is ignored; values are clamped to -0.4..0.4. */
+ public fun updateOklabB(b: Float) {
+ if (b.isNaN()) return
+ authoritative = oklabColor.copy(b = b.coerceIn(-OKLAB_AB_RANGE, OKLAB_AB_RANGE))
+ }
+
+ /** Sets [oklab] as the authoritative color; Oklab becomes the origin space. */
+ public fun updateFromOklab(oklab: OklabColor) {
+ authoritative = oklab
+ }
+
+ // ---- OkLCh updates ----
+
+ /** Updates the lightness channel. NaN is ignored; values are clamped to 0..1. */
+ public fun updateOklchLightness(l: Float) {
+ if (l.isNaN()) return
+ authoritative = oklchColor.copy(l = l.coerceIn(0f, 1f))
+ }
+
+ /** Updates the chroma channel. NaN is ignored; values are clamped to 0..0.4. */
+ public fun updateOklchChroma(chroma: Float) {
+ if (chroma.isNaN()) return
+ authoritative = oklchColor.copy(chroma = chroma.coerceIn(0f, OKLAB_AB_RANGE))
+ }
+
+ /**
+ * Updates the hue channel. NaN is ignored; values are clamped to 0..360, and 360 is
+ * stored as the equivalent 0 (see [OklchColor]), so the observable range is 0..360
+ * (exclusive).
+ */
+ public fun updateOklchHue(hue: Float) {
+ if (hue.isNaN()) return
+ authoritative = oklchColor.copy(hue = hue.coerceIn(0f, 360f))
+ }
+
+ /** Sets [oklch] as the authoritative color; OkLCh becomes the origin space. */
+ public fun updateFromOklch(oklch: OklchColor) {
+ authoritative = oklch
+ }
+
+ // ---- Okhsl updates ----
+
+ /**
+ * Updates the hue channel. NaN is ignored; values are clamped to 0..360, and 360 is
+ * stored as the equivalent 0 (see [OkhslColor]), so the observable range is 0..360
+ * (exclusive).
+ */
+ public fun updateOkhslHue(hue: Float) {
+ if (hue.isNaN()) return
+ authoritative = okhslColor.copy(hue = hue.coerceIn(0f, 360f))
+ }
+
+ /** Updates the saturation channel. NaN is ignored; values are clamped to 0..1. */
+ public fun updateOkhslSaturation(saturation: Float) {
+ if (saturation.isNaN()) return
+ authoritative = okhslColor.copy(saturation = saturation.coerceIn(0f, 1f))
+ }
+
+ /** Updates the lightness channel. NaN is ignored; values are clamped to 0..1. */
+ public fun updateOkhslLightness(lightness: Float) {
+ if (lightness.isNaN()) return
+ authoritative = okhslColor.copy(lightness = lightness.coerceIn(0f, 1f))
+ }
+
+ /** Sets [okhsl] as the authoritative color; Okhsl becomes the origin space. */
+ public fun updateFromOkhsl(okhsl: OkhslColor) {
+ authoritative = okhsl
+ }
+
+ // ---- Okhsv updates ----
+
+ /**
+ * Updates the hue channel. NaN is ignored; values are clamped to 0..360, and 360 is
+ * stored as the equivalent 0 (see [OkhsvColor]), so the observable range is 0..360
+ * (exclusive).
+ */
+ public fun updateOkhsvHue(hue: Float) {
+ if (hue.isNaN()) return
+ authoritative = okhsvColor.copy(hue = hue.coerceIn(0f, 360f))
+ }
+
+ /** Updates the saturation channel. NaN is ignored; values are clamped to 0..1. */
+ public fun updateOkhsvSaturation(saturation: Float) {
+ if (saturation.isNaN()) return
+ authoritative = okhsvColor.copy(saturation = saturation.coerceIn(0f, 1f))
+ }
+
+ /** Updates the value channel. NaN is ignored; values are clamped to 0..1. */
+ public fun updateOkhsvValue(value: Float) {
+ if (value.isNaN()) return
+ authoritative = okhsvColor.copy(value = value.coerceIn(0f, 1f))
+ }
+
+ /** Sets [okhsv] as the authoritative color; Okhsv becomes the origin space. */
+ public fun updateFromOkhsv(okhsv: OkhsvColor) {
+ authoritative = okhsv
+ }
+
// ---- Alpha update (origin space unchanged) ----
/** Updates the alpha channel of the authoritative color. NaN is ignored; values are clamped to 0..1. */
public fun updateAlpha(alpha: Float) {
if (alpha.isNaN()) return
- val clamped = alpha.coerceIn(0f, 1f)
- authoritative = when (val c = authoritative) {
- is HslColor -> c.copy(alpha = clamped)
- is RgbColor -> c.copy(alpha = clamped)
- is CmykColor -> c.copy(alpha = clamped)
- is LabColor -> c.copy(alpha = clamped)
- }
+ authoritative = authoritative.withAlpha(alpha.coerceIn(0f, 1f))
}
// ---- ARGB Int update ----
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/state/SaveableColorPickerState.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/state/SaveableColorPickerState.kt
index d52c1da..b6e3113 100644
--- a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/state/SaveableColorPickerState.kt
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/state/SaveableColorPickerState.kt
@@ -6,6 +6,10 @@ import androidx.compose.runtime.saveable.rememberSaveable
import codes.side.colorpicker.model.CmykColor
import codes.side.colorpicker.model.HslColor
import codes.side.colorpicker.model.LabColor
+import codes.side.colorpicker.model.OkhslColor
+import codes.side.colorpicker.model.OkhsvColor
+import codes.side.colorpicker.model.OklabColor
+import codes.side.colorpicker.model.OklchColor
import codes.side.colorpicker.model.PickerColor
import codes.side.colorpicker.model.RgbColor
@@ -15,6 +19,10 @@ private const val SPACE_KEY_HSL = 0f
private const val SPACE_KEY_RGB = 1f
private const val SPACE_KEY_CMYK = 2f
private const val SPACE_KEY_LAB = 3f
+private const val SPACE_KEY_OKLAB = 4f
+private const val SPACE_KEY_OKLCH = 5f
+private const val SPACE_KEY_OKHSL = 6f
+private const val SPACE_KEY_OKHSV = 7f
private const val SAVED_ARRAY_SIZE = 6
@@ -22,11 +30,16 @@ private const val SAVED_ARRAY_SIZE = 6
* Encodes the authoritative space and its native components in a single FloatArray.
*
* Layout: `[spaceKey, c0, c1, c2, c3, c4]`
- * - `spaceKey` = stable space key (0=HSL, 1=RGB, 2=CMYK, 3=LAB)
- * - HSL: c0=hue, c1=saturation, c2=lightness, c3=alpha, c4 unused
- * - RGB: c0=red, c1=green, c2=blue, c3=alpha, c4 unused
- * - CMYK: c0=cyan, c1=magenta, c2=yellow, c3=key, c4=alpha
- * - LAB: c0=l, c1=a, c2=b, c3=alpha, c4 unused
+ * - `spaceKey` = stable space key (0=HSL, 1=RGB, 2=CMYK, 3=LAB, 4=Oklab, 5=OkLCh,
+ * 6=Okhsl, 7=Okhsv)
+ * - HSL: c0=hue, c1=saturation, c2=lightness, c3=alpha, c4 unused
+ * - RGB: c0=red, c1=green, c2=blue, c3=alpha, c4 unused
+ * - CMYK: c0=cyan, c1=magenta, c2=yellow, c3=key, c4=alpha
+ * - LAB: c0=l, c1=a, c2=b, c3=alpha, c4 unused
+ * - Oklab: c0=l, c1=a, c2=b, c3=alpha, c4 unused
+ * - OkLCh: c0=l, c1=chroma, c2=hue, c3=alpha, c4 unused
+ * - Okhsl: c0=hue, c1=saturation, c2=lightness, c3=alpha, c4 unused
+ * - Okhsv: c0=hue, c1=saturation, c2=value, c3=alpha, c4 unused
*
* This preserves the authoritative space across process death so the user's
* "origin" choice survives rotation, not just the visible color.
@@ -56,6 +69,26 @@ internal val ColorPickerStateSaver = Saver(
SPACE_KEY_LAB,
color.l, color.a, color.b, color.alpha, 0f,
)
+
+ is OklabColor -> floatArrayOf(
+ SPACE_KEY_OKLAB,
+ color.l, color.a, color.b, color.alpha, 0f,
+ )
+
+ is OklchColor -> floatArrayOf(
+ SPACE_KEY_OKLCH,
+ color.l, color.chroma, color.hue, color.alpha, 0f,
+ )
+
+ is OkhslColor -> floatArrayOf(
+ SPACE_KEY_OKHSL,
+ color.hue, color.saturation, color.lightness, color.alpha, 0f,
+ )
+
+ is OkhsvColor -> floatArrayOf(
+ SPACE_KEY_OKHSV,
+ color.hue, color.saturation, color.value, color.alpha, 0f,
+ )
}
},
restore = { array ->
@@ -93,6 +126,34 @@ internal val ColorPickerStateSaver = Saver(
alpha = array[4],
)
+ SPACE_KEY_OKLAB -> OklabColor(
+ l = array[1],
+ a = array[2],
+ b = array[3],
+ alpha = array[4],
+ )
+
+ SPACE_KEY_OKLCH -> OklchColor(
+ l = array[1],
+ chroma = array[2],
+ hue = array[3],
+ alpha = array[4],
+ )
+
+ SPACE_KEY_OKHSL -> OkhslColor(
+ hue = array[1],
+ saturation = array[2],
+ lightness = array[3],
+ alpha = array[4],
+ )
+
+ SPACE_KEY_OKHSV -> OkhsvColor(
+ hue = array[1],
+ saturation = array[2],
+ value = array[3],
+ alpha = array[4],
+ )
+
else -> null
}
} catch (_: IllegalArgumentException) {
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/OkhslColorPicker.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/OkhslColorPicker.kt
new file mode 100644
index 0000000..c919675
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/OkhslColorPicker.kt
@@ -0,0 +1,60 @@
+package codes.side.colorpicker.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import codes.side.colorpicker.state.ColorPickerState
+import codes.side.colorpicker.state.ColoringMode
+import codes.side.colorpicker.theme.ColorPickerColors
+import codes.side.colorpicker.theme.ColorPickerDefaults
+import codes.side.colorpicker.theme.ColorPickerShapes
+
+/**
+ * Complete Okhsl picker: hue, saturation, and lightness sliders, plus an optional alpha
+ * slider.
+ *
+ * The perceptual counterpart to [HslColorPicker]. Lightness here means perceived
+ * lightness, so the middle of the track looks equally light at every hue, and saturation
+ * is measured against the display gamut, so `100%` is reachable at every hue and
+ * lightness rather than running off the end of what the screen can show.
+ *
+ * @param showAlpha whether to include the [AlphaSlider].
+ * @param coloringMode defaults to [ColoringMode.Independent], for the same reason as
+ * [HslColorPicker]: a contextual hue track collapses into a near-uniform strip at low
+ * saturation or extreme lightness, and stops being navigable.
+ * @param colors checkerboard colors; see [ColorPickerDefaults.colors].
+ * @param shapes track shape; see [ColorPickerDefaults.shapes].
+ */
+@Composable
+public fun OkhslColorPicker(
+ state: ColorPickerState,
+ modifier: Modifier = Modifier,
+ showAlpha: Boolean = true,
+ coloringMode: ColoringMode = ColoringMode.Independent,
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+) {
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ OkhslHueSlider(state = state, coloringMode = coloringMode, colors = colors, shapes = shapes)
+ OkhslSaturationSlider(
+ state = state,
+ coloringMode = coloringMode,
+ colors = colors,
+ shapes = shapes,
+ )
+ OkhslLightnessSlider(
+ state = state,
+ coloringMode = coloringMode,
+ colors = colors,
+ shapes = shapes,
+ )
+ if (showAlpha) {
+ AlphaSlider(state = state, colors = colors, shapes = shapes)
+ }
+ }
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/OkhslSliders.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/OkhslSliders.kt
new file mode 100644
index 0000000..f5fa3ae
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/OkhslSliders.kt
@@ -0,0 +1,263 @@
+package codes.side.colorpicker.ui
+
+import androidx.compose.foundation.interaction.InteractionSource
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.Dp
+import codes.side.colorpicker.conversion.toComposeColor
+import codes.side.colorpicker.model.OkhslColor
+import codes.side.colorpicker.state.ColorPickerState
+import codes.side.colorpicker.state.ColoringMode
+import codes.side.colorpicker.theme.ColorPickerColors
+import codes.side.colorpicker.theme.ColorPickerDefaults
+import codes.side.colorpicker.theme.ColorPickerShapes
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+
+/**
+ * Saturation the Independent hue track is drawn at, rather than the 1.0 the HSL picker
+ * uses.
+ *
+ * At saturation 1 an Okhsl hue sweep runs along the sRGB gamut surface, which turns a
+ * corner wherever the sweep crosses an edge of the RGB cube — around hue 142, red falls
+ * to zero as blue starts to rise. A gradient interpolates straight through such a corner,
+ * and no practical number of stops fixes it: the error stalls near 9 of 255 even at 256
+ * stops. Backing off the boundary makes the sweep smooth, and 0.85 is the most colorful
+ * setting that stays under one perceptible step at [OK_HUE_STOPS].
+ */
+private const val HUE_TRACK_SATURATION = 0.85f
+
+// Stops for the hue track. Okhsl hue is not piecewise linear in sRGB the way HSL's is,
+// so there are no breakpoints to land on; 32 measures 1.9 of 255 against the true sweep,
+// and doubling it buys nothing.
+private const val OK_HUE_STOPS = 32
+
+// The saturation, lightness and value tracks are smooth, and 16 holds them under half a
+// step of 255.
+private const val OK_CHANNEL_STOPS = 16
+
+internal inline fun buildOkGradient(stops: Int, color: (Float) -> Color): ImmutableList =
+ (0..stops).map { color(it.toFloat() / stops) }.toImmutableList()
+
+/**
+ * Slider for the Okhsl hue channel of [state], in degrees `0..360`.
+ *
+ * @param coloringMode with [ColoringMode.Independent] (the default) the track shows the
+ * full spectrum at a fixed saturation and lightness; with [ColoringMode.Contextual] it is
+ * rendered at the current saturation and lightness.
+ * @param semanticLabel accessibility description of the slider; pass a localized string
+ * to replace the English default, or `null` to omit.
+ * @param semanticValueText accessibility announcement of the current value in degrees.
+ */
+@Composable
+public fun OkhslHueSlider(
+ state: ColorPickerState,
+ modifier: Modifier = Modifier,
+ coloringMode: ColoringMode = ColoringMode.Independent,
+ label: (@Composable () -> Unit)? = { SliderLabel("Hue") },
+ valueLabel: (@Composable () -> Unit)? = { SliderValueLabel("${state.okhslColor.intHue}°") },
+ semanticLabel: String? = "Hue",
+ semanticValueText: String? = "${state.okhslColor.intHue}°",
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+ thumb: (@Composable (InteractionSource) -> Unit)? = null,
+ thumbWidth: Dp = ColorPickerDefaults.ThumbWidth,
+ thumbTrackGap: Dp = ColorPickerDefaults.ThumbTrackGap,
+) {
+ val okhsl = state.okhslColor
+ // The two coloring modes differ only in which saturation and lightness the strip is
+ // drawn at, so they pick the pair rather than each building a gradient of its own.
+ val trackSaturation = when (coloringMode) {
+ ColoringMode.Independent -> HUE_TRACK_SATURATION
+ ColoringMode.Contextual -> okhsl.saturation
+ }
+ val trackLightness = when (coloringMode) {
+ ColoringMode.Independent -> 0.5f
+ ColoringMode.Contextual -> okhsl.lightness
+ }
+ val gradientColors = remember(trackSaturation, trackLightness) {
+ buildOkGradient(OK_HUE_STOPS) { fraction ->
+ OkhslColor(
+ hue = hueFromFraction(fraction),
+ saturation = trackSaturation,
+ lightness = trackLightness,
+ ).toComposeColor()
+ }
+ }
+ // Matches the track so the thumb never disagrees with the strip under it.
+ val thumbColor = remember(okhsl.hue, trackSaturation, trackLightness) {
+ OkhslColor(
+ hue = okhsl.hue,
+ saturation = trackSaturation,
+ lightness = trackLightness,
+ ).toComposeColor()
+ }
+
+ val interaction = remember(state) { SliderInteractionGuard(state) }
+ ColorSlider(
+ value = okhsl.hue / 360f,
+ onValueChange = {
+ interaction.begin()
+ state.updateOkhslHue(hueFromFraction(it))
+ },
+ gradientColors = gradientColors,
+ thumbColor = thumbColor,
+ label = label,
+ valueLabel = valueLabel,
+ semanticLabel = semanticLabel,
+ semanticValueText = semanticValueText,
+ colors = colors,
+ shapes = shapes,
+ modifier = modifier,
+ onValueChangeFinished = { interaction.end() },
+ thumb = thumb,
+ thumbWidth = thumbWidth,
+ thumbTrackGap = thumbTrackGap,
+ )
+}
+
+/**
+ * Slider for the Okhsl saturation channel of [state], in `0..1`, from gray to the most
+ * colorful the hue and lightness allow on the display.
+ *
+ * @param coloringMode with [ColoringMode.Independent] (the default) the track is drawn at
+ * mid lightness; with [ColoringMode.Contextual] it is drawn at the current lightness.
+ * @param semanticLabel accessibility description of the slider; pass a localized string
+ * to replace the English default, or `null` to omit.
+ * @param semanticValueText accessibility announcement of the current value in percent.
+ */
+@Composable
+public fun OkhslSaturationSlider(
+ state: ColorPickerState,
+ modifier: Modifier = Modifier,
+ coloringMode: ColoringMode = ColoringMode.Independent,
+ label: (@Composable () -> Unit)? = { SliderLabel("Saturation") },
+ valueLabel: (@Composable () -> Unit)? = { SliderValueLabel("${state.okhslColor.intSaturation}%") },
+ semanticLabel: String? = "Saturation",
+ semanticValueText: String? = "${state.okhslColor.intSaturation}%",
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+ thumb: (@Composable (InteractionSource) -> Unit)? = null,
+ thumbWidth: Dp = ColorPickerDefaults.ThumbWidth,
+ thumbTrackGap: Dp = ColorPickerDefaults.ThumbTrackGap,
+) {
+ val okhsl = state.okhslColor
+ val trackLightness = when (coloringMode) {
+ ColoringMode.Independent -> 0.5f
+ ColoringMode.Contextual -> okhsl.lightness
+ }
+ val gradientColors = remember(okhsl.hue, trackLightness) {
+ buildOkGradient(OK_CHANNEL_STOPS) { fraction ->
+ OkhslColor(
+ hue = okhsl.hue,
+ saturation = fraction,
+ lightness = trackLightness,
+ ).toComposeColor()
+ }
+ }
+ val thumbColor = remember(okhsl, trackLightness) {
+ OkhslColor(
+ hue = okhsl.hue,
+ saturation = okhsl.saturation,
+ lightness = trackLightness,
+ ).toComposeColor()
+ }
+
+ val interaction = remember(state) { SliderInteractionGuard(state) }
+ ColorSlider(
+ value = okhsl.saturation,
+ onValueChange = {
+ interaction.begin()
+ state.updateOkhslSaturation(it)
+ },
+ gradientColors = gradientColors,
+ thumbColor = thumbColor,
+ label = label,
+ valueLabel = valueLabel,
+ semanticLabel = semanticLabel,
+ semanticValueText = semanticValueText,
+ colors = colors,
+ shapes = shapes,
+ modifier = modifier,
+ onValueChangeFinished = { interaction.end() },
+ thumb = thumb,
+ thumbWidth = thumbWidth,
+ thumbTrackGap = thumbTrackGap,
+ )
+}
+
+/**
+ * Slider for the Okhsl lightness channel of [state], in `0..1`, from black through the
+ * hue to white.
+ *
+ * Unlike [LightnessSlider], the value here is perceived lightness: the midpoint of this
+ * track looks equally light at every hue, which is the whole reason to prefer Okhsl over
+ * HSL.
+ *
+ * @param coloringMode with [ColoringMode.Independent] (the default) the track is drawn at
+ * full saturation; with [ColoringMode.Contextual] it is drawn at the current saturation.
+ * @param semanticLabel accessibility description of the slider; pass a localized string
+ * to replace the English default, or `null` to omit.
+ * @param semanticValueText accessibility announcement of the current value in percent.
+ */
+@Composable
+public fun OkhslLightnessSlider(
+ state: ColorPickerState,
+ modifier: Modifier = Modifier,
+ coloringMode: ColoringMode = ColoringMode.Independent,
+ label: (@Composable () -> Unit)? = { SliderLabel("Lightness") },
+ valueLabel: (@Composable () -> Unit)? = { SliderValueLabel("${state.okhslColor.intLightness}%") },
+ semanticLabel: String? = "Lightness",
+ semanticValueText: String? = "${state.okhslColor.intLightness}%",
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+ thumb: (@Composable (InteractionSource) -> Unit)? = null,
+ thumbWidth: Dp = ColorPickerDefaults.ThumbWidth,
+ thumbTrackGap: Dp = ColorPickerDefaults.ThumbTrackGap,
+) {
+ val okhsl = state.okhslColor
+ val trackSaturation = when (coloringMode) {
+ ColoringMode.Independent -> HUE_TRACK_SATURATION
+ ColoringMode.Contextual -> okhsl.saturation
+ }
+ val gradientColors = remember(okhsl.hue, trackSaturation) {
+ buildOkGradient(OK_CHANNEL_STOPS) { fraction ->
+ OkhslColor(
+ hue = okhsl.hue,
+ saturation = trackSaturation,
+ lightness = fraction,
+ ).toComposeColor()
+ }
+ }
+ val thumbColor = remember(okhsl, trackSaturation) {
+ OkhslColor(
+ hue = okhsl.hue,
+ saturation = trackSaturation,
+ lightness = okhsl.lightness,
+ ).toComposeColor()
+ }
+
+ val interaction = remember(state) { SliderInteractionGuard(state) }
+ ColorSlider(
+ value = okhsl.lightness,
+ onValueChange = {
+ interaction.begin()
+ state.updateOkhslLightness(it)
+ },
+ gradientColors = gradientColors,
+ thumbColor = thumbColor,
+ label = label,
+ valueLabel = valueLabel,
+ semanticLabel = semanticLabel,
+ semanticValueText = semanticValueText,
+ colors = colors,
+ shapes = shapes,
+ modifier = modifier,
+ onValueChangeFinished = { interaction.end() },
+ thumb = thumb,
+ thumbWidth = thumbWidth,
+ thumbTrackGap = thumbTrackGap,
+ )
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/OkhsvColorPicker.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/OkhsvColorPicker.kt
new file mode 100644
index 0000000..ee07213
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/OkhsvColorPicker.kt
@@ -0,0 +1,61 @@
+package codes.side.colorpicker.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import codes.side.colorpicker.state.ColorPickerState
+import codes.side.colorpicker.state.ColoringMode
+import codes.side.colorpicker.theme.ColorPickerColors
+import codes.side.colorpicker.theme.ColorPickerDefaults
+import codes.side.colorpicker.theme.ColorPickerShapes
+
+/**
+ * Complete Okhsv picker: hue, saturation, and value sliders, plus an optional alpha
+ * slider.
+ *
+ * Shares Okhsl's perceptual hue and gamut-relative saturation, but keeps the HSV
+ * arrangement: full saturation at full value is the most vivid form of a hue, and pulling
+ * value down darkens toward black. Pick this over [OkhslColorPicker] when users expect
+ * the shape of a paint-program picker; pick Okhsl when the midpoint of the lightness
+ * track should be a mid tone.
+ *
+ * @param showAlpha whether to include the [AlphaSlider].
+ * @param coloringMode defaults to [ColoringMode.Independent], for the same reason as
+ * [HslColorPicker]: a contextual hue track collapses into a near-uniform strip at low
+ * saturation or value, and stops being navigable.
+ * @param colors checkerboard colors; see [ColorPickerDefaults.colors].
+ * @param shapes track shape; see [ColorPickerDefaults.shapes].
+ */
+@Composable
+public fun OkhsvColorPicker(
+ state: ColorPickerState,
+ modifier: Modifier = Modifier,
+ showAlpha: Boolean = true,
+ coloringMode: ColoringMode = ColoringMode.Independent,
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+) {
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ OkhsvHueSlider(state = state, coloringMode = coloringMode, colors = colors, shapes = shapes)
+ OkhsvSaturationSlider(
+ state = state,
+ coloringMode = coloringMode,
+ colors = colors,
+ shapes = shapes,
+ )
+ OkhsvValueSlider(
+ state = state,
+ coloringMode = coloringMode,
+ colors = colors,
+ shapes = shapes,
+ )
+ if (showAlpha) {
+ AlphaSlider(state = state, colors = colors, shapes = shapes)
+ }
+ }
+}
diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/OkhsvSliders.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/OkhsvSliders.kt
new file mode 100644
index 0000000..636bf5c
--- /dev/null
+++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/OkhsvSliders.kt
@@ -0,0 +1,234 @@
+package codes.side.colorpicker.ui
+
+import androidx.compose.foundation.interaction.InteractionSource
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
+import codes.side.colorpicker.conversion.toComposeColor
+import codes.side.colorpicker.model.OkhsvColor
+import codes.side.colorpicker.state.ColorPickerState
+import codes.side.colorpicker.state.ColoringMode
+import codes.side.colorpicker.theme.ColorPickerColors
+import codes.side.colorpicker.theme.ColorPickerDefaults
+import codes.side.colorpicker.theme.ColorPickerShapes
+
+// Okhsv's hue track meets the same gamut corners as Okhsl's, so it is drawn just off the
+// boundary for the same reason; see HUE_TRACK_SATURATION in OkhslSliders.kt.
+private const val HUE_TRACK_SATURATION = 0.85f
+
+private const val OK_HUE_STOPS = 32
+private const val OK_CHANNEL_STOPS = 16
+
+/**
+ * Slider for the Okhsv hue channel of [state], in degrees `0..360`.
+ *
+ * @param coloringMode with [ColoringMode.Independent] (the default) the track shows the
+ * full spectrum at a fixed saturation and value; with [ColoringMode.Contextual] it is
+ * rendered at the current saturation and value.
+ * @param semanticLabel accessibility description of the slider; pass a localized string
+ * to replace the English default, or `null` to omit.
+ * @param semanticValueText accessibility announcement of the current value in degrees.
+ */
+@Composable
+public fun OkhsvHueSlider(
+ state: ColorPickerState,
+ modifier: Modifier = Modifier,
+ coloringMode: ColoringMode = ColoringMode.Independent,
+ label: (@Composable () -> Unit)? = { SliderLabel("Hue") },
+ valueLabel: (@Composable () -> Unit)? = { SliderValueLabel("${state.okhsvColor.intHue}°") },
+ semanticLabel: String? = "Hue",
+ semanticValueText: String? = "${state.okhsvColor.intHue}°",
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+ thumb: (@Composable (InteractionSource) -> Unit)? = null,
+ thumbWidth: Dp = ColorPickerDefaults.ThumbWidth,
+ thumbTrackGap: Dp = ColorPickerDefaults.ThumbTrackGap,
+) {
+ val okhsv = state.okhsvColor
+ // The two coloring modes differ only in which saturation and value the strip is drawn
+ // at, so they pick the pair rather than each building a gradient of its own.
+ val trackSaturation = when (coloringMode) {
+ ColoringMode.Independent -> HUE_TRACK_SATURATION
+ ColoringMode.Contextual -> okhsv.saturation
+ }
+ val trackValue = when (coloringMode) {
+ ColoringMode.Independent -> 1f
+ ColoringMode.Contextual -> okhsv.value
+ }
+ val gradientColors = remember(trackSaturation, trackValue) {
+ buildOkGradient(OK_HUE_STOPS) { fraction ->
+ OkhsvColor(
+ hue = hueFromFraction(fraction),
+ saturation = trackSaturation,
+ value = trackValue,
+ ).toComposeColor()
+ }
+ }
+ // Matches the track so the thumb never disagrees with the strip under it.
+ val thumbColor = remember(okhsv.hue, trackSaturation, trackValue) {
+ OkhsvColor(
+ hue = okhsv.hue,
+ saturation = trackSaturation,
+ value = trackValue,
+ ).toComposeColor()
+ }
+
+ val interaction = remember(state) { SliderInteractionGuard(state) }
+ ColorSlider(
+ value = okhsv.hue / 360f,
+ onValueChange = {
+ interaction.begin()
+ state.updateOkhsvHue(hueFromFraction(it))
+ },
+ gradientColors = gradientColors,
+ thumbColor = thumbColor,
+ label = label,
+ valueLabel = valueLabel,
+ semanticLabel = semanticLabel,
+ semanticValueText = semanticValueText,
+ colors = colors,
+ shapes = shapes,
+ modifier = modifier,
+ onValueChangeFinished = { interaction.end() },
+ thumb = thumb,
+ thumbWidth = thumbWidth,
+ thumbTrackGap = thumbTrackGap,
+ )
+}
+
+/**
+ * Slider for the Okhsv saturation channel of [state], in `0..1`, from gray to the most
+ * colorful the hue and value allow on the display.
+ *
+ * @param coloringMode with [ColoringMode.Independent] (the default) the track is drawn at
+ * full value; with [ColoringMode.Contextual] it is drawn at the current value.
+ * @param semanticLabel accessibility description of the slider; pass a localized string
+ * to replace the English default, or `null` to omit.
+ * @param semanticValueText accessibility announcement of the current value in percent.
+ */
+@Composable
+public fun OkhsvSaturationSlider(
+ state: ColorPickerState,
+ modifier: Modifier = Modifier,
+ coloringMode: ColoringMode = ColoringMode.Independent,
+ label: (@Composable () -> Unit)? = { SliderLabel("Saturation") },
+ valueLabel: (@Composable () -> Unit)? = { SliderValueLabel("${state.okhsvColor.intSaturation}%") },
+ semanticLabel: String? = "Saturation",
+ semanticValueText: String? = "${state.okhsvColor.intSaturation}%",
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+ thumb: (@Composable (InteractionSource) -> Unit)? = null,
+ thumbWidth: Dp = ColorPickerDefaults.ThumbWidth,
+ thumbTrackGap: Dp = ColorPickerDefaults.ThumbTrackGap,
+) {
+ val okhsv = state.okhsvColor
+ val trackValue = when (coloringMode) {
+ ColoringMode.Independent -> 1f
+ ColoringMode.Contextual -> okhsv.value
+ }
+ val gradientColors = remember(okhsv.hue, trackValue) {
+ buildOkGradient(OK_CHANNEL_STOPS) { fraction ->
+ OkhsvColor(hue = okhsv.hue, saturation = fraction, value = trackValue).toComposeColor()
+ }
+ }
+ val thumbColor = remember(okhsv, trackValue) {
+ OkhsvColor(
+ hue = okhsv.hue,
+ saturation = okhsv.saturation,
+ value = trackValue,
+ ).toComposeColor()
+ }
+
+ val interaction = remember(state) { SliderInteractionGuard(state) }
+ ColorSlider(
+ value = okhsv.saturation,
+ onValueChange = {
+ interaction.begin()
+ state.updateOkhsvSaturation(it)
+ },
+ gradientColors = gradientColors,
+ thumbColor = thumbColor,
+ label = label,
+ valueLabel = valueLabel,
+ semanticLabel = semanticLabel,
+ semanticValueText = semanticValueText,
+ colors = colors,
+ shapes = shapes,
+ modifier = modifier,
+ onValueChangeFinished = { interaction.end() },
+ thumb = thumb,
+ thumbWidth = thumbWidth,
+ thumbTrackGap = thumbTrackGap,
+ )
+}
+
+/**
+ * Slider for the Okhsv value channel of [state], in `0..1`, from black to the full
+ * brightness of the current hue.
+ *
+ * @param coloringMode with [ColoringMode.Independent] (the default) the track is drawn at
+ * full saturation; with [ColoringMode.Contextual] it is drawn at the current saturation.
+ * @param semanticLabel accessibility description of the slider; pass a localized string
+ * to replace the English default, or `null` to omit.
+ * @param semanticValueText accessibility announcement of the current value in percent.
+ */
+@Composable
+public fun OkhsvValueSlider(
+ state: ColorPickerState,
+ modifier: Modifier = Modifier,
+ coloringMode: ColoringMode = ColoringMode.Independent,
+ label: (@Composable () -> Unit)? = { SliderLabel("Value") },
+ valueLabel: (@Composable () -> Unit)? = { SliderValueLabel("${state.okhsvColor.intValue}%") },
+ semanticLabel: String? = "Value",
+ semanticValueText: String? = "${state.okhsvColor.intValue}%",
+ colors: ColorPickerColors = ColorPickerDefaults.colors(),
+ shapes: ColorPickerShapes = ColorPickerDefaults.shapes(),
+ thumb: (@Composable (InteractionSource) -> Unit)? = null,
+ thumbWidth: Dp = ColorPickerDefaults.ThumbWidth,
+ thumbTrackGap: Dp = ColorPickerDefaults.ThumbTrackGap,
+) {
+ val okhsv = state.okhsvColor
+ val trackSaturation = when (coloringMode) {
+ ColoringMode.Independent -> HUE_TRACK_SATURATION
+ ColoringMode.Contextual -> okhsv.saturation
+ }
+ val gradientColors = remember(okhsv.hue, trackSaturation) {
+ buildOkGradient(OK_CHANNEL_STOPS) { fraction ->
+ OkhsvColor(
+ hue = okhsv.hue,
+ saturation = trackSaturation,
+ value = fraction,
+ ).toComposeColor()
+ }
+ }
+ val thumbColor = remember(okhsv, trackSaturation) {
+ OkhsvColor(
+ hue = okhsv.hue,
+ saturation = trackSaturation,
+ value = okhsv.value,
+ ).toComposeColor()
+ }
+
+ val interaction = remember(state) { SliderInteractionGuard(state) }
+ ColorSlider(
+ value = okhsv.value,
+ onValueChange = {
+ interaction.begin()
+ state.updateOkhsvValue(it)
+ },
+ gradientColors = gradientColors,
+ thumbColor = thumbColor,
+ label = label,
+ valueLabel = valueLabel,
+ semanticLabel = semanticLabel,
+ semanticValueText = semanticValueText,
+ colors = colors,
+ shapes = shapes,
+ modifier = modifier,
+ onValueChangeFinished = { interaction.end() },
+ thumb = thumb,
+ thumbWidth = thumbWidth,
+ thumbTrackGap = thumbTrackGap,
+ )
+}
diff --git a/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/OkConversionsTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/OkConversionsTest.kt
new file mode 100644
index 0000000..6ef52e0
--- /dev/null
+++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/OkConversionsTest.kt
@@ -0,0 +1,365 @@
+package codes.side.colorpicker.conversion
+
+import codes.side.colorpicker.model.OkhslColor
+import codes.side.colorpicker.model.OkhsvColor
+import codes.side.colorpicker.model.OklchColor
+import codes.side.colorpicker.model.RgbColor
+import kotlin.math.abs
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class OkConversionsTest {
+
+ private fun assertNear(expected: Float, actual: Float, tolerance: Float, msg: String = "") {
+ assertTrue(
+ abs(expected - actual) <= tolerance,
+ "$msg expected=$expected actual=$actual diff=${abs(expected - actual)}",
+ )
+ }
+
+ // ===========================================================
+ // Oklab against Björn Ottosson's published values
+ // ===========================================================
+
+ @Test
+ fun oklabWhite() {
+ val lab = RgbColor(1f, 1f, 1f).toOklab()
+ assertNear(1f, lab.l, 1e-4f, "L")
+ assertNear(0f, lab.a, 1e-4f, "a")
+ assertNear(0f, lab.b, 1e-4f, "b")
+ }
+
+ @Test
+ fun oklabBlack() {
+ val lab = RgbColor(0f, 0f, 0f).toOklab()
+ assertNear(0f, lab.l, 1e-4f, "L")
+ assertNear(0f, lab.a, 1e-4f, "a")
+ assertNear(0f, lab.b, 1e-4f, "b")
+ }
+
+ @Test
+ fun oklabRed() {
+ val lab = RgbColor(1f, 0f, 0f).toOklab()
+ assertNear(0.6279f, lab.l, 1e-3f, "L")
+ assertNear(0.2249f, lab.a, 1e-3f, "a")
+ assertNear(0.1258f, lab.b, 1e-3f, "b")
+ }
+
+ @Test
+ fun oklabGreen() {
+ val lab = RgbColor(0f, 1f, 0f).toOklab()
+ assertNear(0.8664f, lab.l, 1e-3f, "L")
+ assertNear(-0.2339f, lab.a, 1e-3f, "a")
+ assertNear(0.1795f, lab.b, 1e-3f, "b")
+ }
+
+ @Test
+ fun oklabBlue() {
+ val lab = RgbColor(0f, 0f, 1f).toOklab()
+ assertNear(0.4520f, lab.l, 1e-3f, "L")
+ assertNear(-0.0324f, lab.a, 1e-3f, "a")
+ assertNear(-0.3115f, lab.b, 1e-3f, "b")
+ }
+
+ @Test
+ fun oklabGrayHasNoChroma() {
+ val lab = RgbColor(0.5f, 0.5f, 0.5f).toOklab()
+ assertNear(0f, lab.a, 1e-5f, "a")
+ assertNear(0f, lab.b, 1e-5f, "b")
+ }
+
+ // ===========================================================
+ // Round trips through sRGB
+ // ===========================================================
+
+ private val sweep: List = buildList {
+ for (r in 0..255 step 17) {
+ for (g in 0..255 step 17) {
+ for (b in 0..255 step 17) {
+ add(RgbColor(r / 255f, g / 255f, b / 255f))
+ }
+ }
+ }
+ }
+
+ private fun assertSurvivesRoundTrip(
+ tolerance: Float,
+ label: String,
+ convert: (RgbColor) -> RgbColor,
+ ) {
+ var worst = 0f
+ var worstColor = ""
+ for (rgb in sweep) {
+ val back = convert(rgb)
+ val delta = maxOf(
+ abs(back.red - rgb.red),
+ abs(back.green - rgb.green),
+ abs(back.blue - rgb.blue),
+ )
+ if (delta > worst) {
+ worst = delta
+ worstColor = "$rgb -> $back"
+ }
+ }
+ assertTrue(worst <= tolerance, "$label worst delta $worst exceeds $tolerance at $worstColor")
+ }
+
+ @Test
+ fun oklabRoundTrip() {
+ assertSurvivesRoundTrip(1e-4f, "Oklab") { it.toOklab().toRgb() }
+ }
+
+ @Test
+ fun oklchRoundTrip() {
+ assertSurvivesRoundTrip(1e-4f, "OkLCh") { it.toOklch().toRgb() }
+ }
+
+ // Okhsl and Okhsv are looser than Oklab on purpose. Below the cusp the reference
+ // implementation approximates the gamut with a straight line to black, and about 4%
+ // of sRGB — the most saturated blues and violets — lies just outside it, by at most
+ // 2.6% of the boundary chroma. Ottosson lets saturation exceed 1 there; a 0..1
+ // channel cannot, so those colours come back with slightly less chroma. Measured
+ // worst case over the full cube is 4 steps of 255. Matching the reference matters
+ // more than closing that gap: an Okhsl value has to mean the same here as it does
+ // in colorjs or a browser.
+
+ @Test
+ fun okhslRoundTrip() {
+ assertSurvivesRoundTrip(0.02f, "Okhsl") { it.toOkhsl().toRgb() }
+ }
+
+ @Test
+ fun okhsvRoundTrip() {
+ assertSurvivesRoundTrip(0.02f, "Okhsv") { it.toOkhsv().toRgb() }
+ }
+
+ @Test
+ fun okhslRoundTripIsExactAwayFromTheGamutBoundary() {
+ // Away from the boundary the triangle approximation does not bind, so the round
+ // trip has to be tight. This is the test that would catch a real regression.
+ var worst = 0f
+ for (rgb in sweep) {
+ val okhsl = rgb.toOkhsl()
+ if (okhsl.saturation > 0.9f) continue
+ val back = okhsl.toRgb()
+ worst = maxOf(
+ worst,
+ abs(back.red - rgb.red),
+ abs(back.green - rgb.green),
+ abs(back.blue - rgb.blue),
+ )
+ }
+ assertTrue(worst <= 1e-3f, "Okhsl round trip below saturation 0.9 drifted by $worst")
+ }
+
+ // ===========================================================
+ // Okhsl / Okhsv structure
+ // ===========================================================
+
+ @Test
+ fun okhslFullSaturationStaysInGamut() {
+ // s = 1 means the gamut boundary, so the conversion must not need clamping:
+ // going out and back has to return the same coordinates.
+ var hue = 0f
+ while (hue < 360f) {
+ var lightness = 0.1f
+ while (lightness <= 0.9f) {
+ val original = OkhslColor(hue = hue, saturation = 1f, lightness = lightness)
+ val back = original.toRgb().toOkhsl()
+ assertNear(1f, back.saturation, 2e-2f, "saturation at hue=$hue l=$lightness")
+ lightness += 0.1f
+ }
+ hue += 15f
+ }
+ }
+
+ @Test
+ fun okhslZeroSaturationIsGray() {
+ val rgb = OkhslColor(hue = 200f, saturation = 0f, lightness = 0.5f).toRgb()
+ assertNear(rgb.red, rgb.green, 1e-4f, "red vs green")
+ assertNear(rgb.green, rgb.blue, 1e-4f, "green vs blue")
+ }
+
+ @Test
+ fun okhslLightnessIsPerceptual() {
+ // The complaint about HSL: blue and yellow at the same lightness look nothing
+ // alike. In Okhsl the same lightness has to mean the same perceived lightness,
+ // so their Oklab L must agree even though their hues do not.
+ val blue = OkhslColor(hue = 264f, saturation = 1f, lightness = 0.5f).toRgb().toOklab()
+ val yellow = OkhslColor(hue = 110f, saturation = 1f, lightness = 0.5f).toRgb().toOklab()
+ assertNear(blue.l, yellow.l, 1e-2f, "perceived lightness")
+ }
+
+ @Test
+ fun hslLightnessIsNotPerceptual() {
+ // The contrast case for the test above, pinning why Okhsl is worth having.
+ val blue = codes.side.colorpicker.model.HslColor(240f, 1f, 0.5f).toRgb().toOklab()
+ val yellow = codes.side.colorpicker.model.HslColor(60f, 1f, 0.5f).toRgb().toOklab()
+ assertTrue(
+ abs(blue.l - yellow.l) > 0.3f,
+ "HSL blue and yellow should differ widely in perceived lightness, got ${blue.l} and ${yellow.l}",
+ )
+ }
+
+ @Test
+ fun okhsvFullValueFullSaturationSitsOnTheGamutBoundary() {
+ // Okhsv hue is Oklab's hue angle, so hue 0 is not sRGB red. What s=1, v=1 does
+ // promise at every hue is the most vivid color the display can reach, which
+ // means one channel pinned at its maximum.
+ var hue = 0f
+ while (hue < 360f) {
+ val rgb = OkhsvColor(hue = hue, saturation = 1f, value = 1f).toRgb()
+ val brightest = maxOf(rgb.red, rgb.green, rgb.blue)
+ assertNear(1f, brightest, 1e-2f, "brightest channel at hue=$hue")
+ hue += 15f
+ }
+ }
+
+ @Test
+ fun okhsvZeroValueIsBlack() {
+ val rgb = OkhsvColor(hue = 123f, saturation = 0.7f, value = 0f).toRgb()
+ assertEquals(0f, rgb.red)
+ assertEquals(0f, rgb.green)
+ assertEquals(0f, rgb.blue)
+ }
+
+ // ===========================================================
+ // Alpha and degenerate inputs
+ // ===========================================================
+
+ @Test
+ fun alphaSurvivesEverySpace() {
+ val rgb = RgbColor(0.2f, 0.6f, 0.9f, alpha = 0.37f)
+ assertEquals(0.37f, rgb.toOklab().alpha, "oklab")
+ assertEquals(0.37f, rgb.toOklch().alpha, "oklch")
+ assertEquals(0.37f, rgb.toOkhsl().alpha, "okhsl")
+ assertEquals(0.37f, rgb.toOkhsv().alpha, "okhsv")
+ assertEquals(0.37f, rgb.toOklab().toRgb().alpha, "oklab back")
+ assertEquals(0.37f, rgb.toOkhsl().toRgb().alpha, "okhsl back")
+ assertEquals(0.37f, rgb.toOkhsv().toRgb().alpha, "okhsv back")
+ }
+
+ @Test
+ fun grayHasNoHueAndNoSaturation() {
+ for (level in listOf(0f, 0.25f, 0.5f, 0.75f, 1f)) {
+ val gray = RgbColor(level, level, level)
+ val okhsl = gray.toOkhsl()
+ val okhsv = gray.toOkhsv()
+ assertNear(0f, okhsl.saturation, 1e-5f, "okhsl saturation at $level")
+ assertNear(0f, okhsv.saturation, 1e-5f, "okhsv saturation at $level")
+ }
+ }
+
+ @Test
+ fun everyOkhslCoordinateProducesAFiniteColor() {
+ var hue = 0f
+ while (hue < 360f) {
+ var s = 0f
+ while (s <= 1f) {
+ var l = 0f
+ while (l <= 1f) {
+ val rgb = OkhslColor(hue, s, l).toRgb()
+ assertTrue(rgb.red.isFinite(), "red at ($hue, $s, $l)")
+ assertTrue(rgb.green.isFinite(), "green at ($hue, $s, $l)")
+ assertTrue(rgb.blue.isFinite(), "blue at ($hue, $s, $l)")
+ l += 0.125f
+ }
+ s += 0.125f
+ }
+ hue += 30f
+ }
+ }
+
+ @Test
+ fun everyOkhsvCoordinateProducesAFiniteColor() {
+ var hue = 0f
+ while (hue < 360f) {
+ var s = 0f
+ while (s <= 1f) {
+ var v = 0f
+ while (v <= 1f) {
+ val rgb = OkhsvColor(hue, s, v).toRgb()
+ assertTrue(rgb.red.isFinite(), "red at ($hue, $s, $v)")
+ assertTrue(rgb.green.isFinite(), "green at ($hue, $s, $v)")
+ assertTrue(rgb.blue.isFinite(), "blue at ($hue, $s, $v)")
+ v += 0.125f
+ }
+ s += 0.125f
+ }
+ hue += 30f
+ }
+ }
+
+ // ===========================================================
+ // Gamut mapping
+ // ===========================================================
+
+ @Test
+ fun inGamutColorsPassThroughUntouched() {
+ for (rgb in sweep) {
+ val lab = rgb.toOklab()
+ val back = lab.toRgb()
+ assertNear(rgb.red, back.red, 1e-4f, "red for $rgb")
+ assertNear(rgb.green, back.green, 1e-4f, "green for $rgb")
+ assertNear(rgb.blue, back.blue, 1e-4f, "blue for $rgb")
+ }
+ }
+
+ @Test
+ fun gamutMappingGivesUpChromaFirst() {
+ // An Oklab chroma this high is outside sRGB at every hue. What must survive is
+ // lightness and hue; chroma is the channel that pays.
+ var hue = 0f
+ while (hue < 360f) {
+ val original = OklchColor(l = 0.5f, chroma = 0.4f, hue = hue)
+ val mapped = original.toRgb().toOklch()
+ assertNear(0.5f, mapped.l, 3e-2f, "lightness at hue=$hue")
+ assertTrue(
+ mapped.chroma < original.chroma,
+ "chroma should have been reduced at hue=$hue",
+ )
+ hue += 15f
+ }
+ }
+
+ @Test
+ fun gamutMappingBeatsPerChannelClamp() {
+ // The comparison the CSS algorithm exists to win. Local-MINDE finishes on a clip,
+ // so it does not hold lightness and hue exactly — but it has to hold them far
+ // better than clamping each channel independently, which is what the LAB path
+ // does and what CSS Color 4 rejects.
+ var mappingWins = 0
+ var total = 0
+ var hue = 0f
+ while (hue < 360f) {
+ val original = OklchColor(l = 0.5f, chroma = 0.4f, hue = hue)
+ val lab = original.toOklab()
+
+ val mapped = original.toRgb().toOklch()
+
+ val clampedLinear = oklabToLinearSrgb(
+ OkLab(lab.l.toDouble(), lab.a.toDouble(), lab.b.toDouble()),
+ ).clipToUnit()
+ val clamped = RgbColor(
+ red = delinearize(clampedLinear.r).toFloat().coerceIn(0f, 1f),
+ green = delinearize(clampedLinear.g).toFloat().coerceIn(0f, 1f),
+ blue = delinearize(clampedLinear.b).toFloat().coerceIn(0f, 1f),
+ ).toOklch()
+
+ fun drift(candidate: OklchColor): Float {
+ val dL = abs(candidate.l - original.l)
+ val dHue = abs(candidate.hue - original.hue).let { minOf(it, 360f - it) } / 360f
+ return dL + dHue
+ }
+
+ total++
+ if (drift(mapped) <= drift(clamped)) mappingWins++
+ hue += 15f
+ }
+ assertTrue(
+ mappingWins * 4 >= total * 3,
+ "gamut mapping should beat per-channel clamping on most hues, won $mappingWins of $total",
+ )
+ }
+}
diff --git a/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/state/OkColorPickerStateTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/state/OkColorPickerStateTest.kt
new file mode 100644
index 0000000..13d480c
--- /dev/null
+++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/state/OkColorPickerStateTest.kt
@@ -0,0 +1,219 @@
+package codes.side.colorpicker.state
+
+import androidx.compose.runtime.saveable.SaverScope
+import codes.side.colorpicker.model.HslColor
+import codes.side.colorpicker.model.LabColor
+import codes.side.colorpicker.model.OkhslColor
+import codes.side.colorpicker.model.OkhsvColor
+import codes.side.colorpicker.model.OklabColor
+import codes.side.colorpicker.model.OklchColor
+import codes.side.colorpicker.model.RgbColor
+import kotlin.math.abs
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+
+// The Saver contract asks whether a value can be persisted; nothing here is rejected.
+private val AlwaysSaveable = SaverScope { true }
+
+class OkColorPickerStateTest {
+
+ private fun assertNear(expected: Float, actual: Float, tolerance: Float, msg: String = "") {
+ assertTrue(
+ abs(expected - actual) <= tolerance,
+ "$msg expected=$expected actual=$actual diff=${abs(expected - actual)}",
+ )
+ }
+
+ // ---- Origin space ----
+
+ @Test
+ fun pickerColorTracksEveryNewSpace() {
+ val state = ColorPickerState()
+ assertIs(state.pickerColor)
+ state.updateOklabLightness(0.6f)
+ assertIs(state.pickerColor)
+ state.updateOklchChroma(0.1f)
+ assertIs(state.pickerColor)
+ state.updateOkhslSaturation(0.7f)
+ assertIs(state.pickerColor)
+ state.updateOkhsvValue(0.8f)
+ assertIs(state.pickerColor)
+ state.updateRed(0.1f)
+ assertIs(state.pickerColor)
+ }
+
+ @Test
+ fun updateAlphaPreservesEveryNewOriginSpace() {
+ for (color in listOf(
+ OklabColor(0.5f, 0.1f, 0.1f),
+ OklchColor(0.5f, 0.1f, 120f),
+ OkhslColor(120f, 0.5f, 0.5f),
+ OkhsvColor(120f, 0.5f, 0.5f),
+ )) {
+ val state = ColorPickerState(color)
+ state.updateAlpha(0.25f)
+ assertEquals(color::class, state.pickerColor::class, "origin space for $color")
+ assertEquals(0.25f, state.pickerColor.alpha, "alpha for $color")
+ }
+ }
+
+ // ---- Zero drift within each new space ----
+
+ @Test
+ fun updateFromReadsBackTheExactInstance() {
+ val oklab = OklabColor(0.42f, -0.13f, 0.07f, alpha = 0.6f)
+ ColorPickerState().let {
+ it.updateFromOklab(oklab)
+ assertSame(oklab, it.pickerColor)
+ assertSame(oklab, it.oklabColor)
+ }
+
+ val oklch = OklchColor(0.42f, 0.13f, 217f, alpha = 0.6f)
+ ColorPickerState().let {
+ it.updateFromOklch(oklch)
+ assertSame(oklch, it.pickerColor)
+ assertSame(oklch, it.oklchColor)
+ }
+
+ val okhsl = OkhslColor(217f, 0.63f, 0.42f, alpha = 0.6f)
+ ColorPickerState().let {
+ it.updateFromOkhsl(okhsl)
+ assertSame(okhsl, it.pickerColor)
+ assertSame(okhsl, it.okhslColor)
+ }
+
+ val okhsv = OkhsvColor(217f, 0.63f, 0.42f, alpha = 0.6f)
+ ColorPickerState().let {
+ it.updateFromOkhsv(okhsv)
+ assertSame(okhsv, it.pickerColor)
+ assertSame(okhsv, it.okhsvColor)
+ }
+ }
+
+ @Test
+ fun editingOneOkhslChannelLeavesTheOthersUntouched() {
+ val state = ColorPickerState(OkhslColor(hue = 200f, saturation = 0.8f, lightness = 0.4f))
+ state.updateOkhslHue(275f)
+ assertEquals(275f, state.okhslColor.hue)
+ assertEquals(0.8f, state.okhslColor.saturation)
+ assertEquals(0.4f, state.okhslColor.lightness)
+ }
+
+ @Test
+ fun editingOneOklchChannelLeavesTheOthersUntouched() {
+ val state = ColorPickerState(OklchColor(l = 0.4f, chroma = 0.12f, hue = 200f))
+ state.updateOklchHue(275f)
+ assertEquals(275f, state.oklchColor.hue)
+ assertEquals(0.4f, state.oklchColor.l)
+ assertEquals(0.12f, state.oklchColor.chroma)
+ }
+
+ // ---- Clamping ----
+
+ @Test
+ fun channelsClampRatherThanThrow() {
+ val state = ColorPickerState()
+ state.updateOklabA(5f)
+ assertEquals(0.4f, state.oklabColor.a)
+ state.updateOklabB(-5f)
+ assertEquals(-0.4f, state.oklabColor.b)
+ state.updateOklchChroma(5f)
+ assertEquals(0.4f, state.oklchColor.chroma)
+ state.updateOkhslSaturation(5f)
+ assertEquals(1f, state.okhslColor.saturation)
+ state.updateOkhsvValue(-5f)
+ assertEquals(0f, state.okhsvColor.value)
+ }
+
+ @Test
+ fun nanIsIgnoredOnEveryNewChannel() {
+ val state = ColorPickerState(OkhslColor(hue = 200f, saturation = 0.8f, lightness = 0.4f))
+ val before = state.pickerColor
+ state.updateOkhslHue(Float.NaN)
+ state.updateOkhslSaturation(Float.NaN)
+ state.updateOkhslLightness(Float.NaN)
+ state.updateOkhsvHue(Float.NaN)
+ state.updateOkhsvSaturation(Float.NaN)
+ state.updateOkhsvValue(Float.NaN)
+ state.updateOklabLightness(Float.NaN)
+ state.updateOklabA(Float.NaN)
+ state.updateOklabB(Float.NaN)
+ state.updateOklchLightness(Float.NaN)
+ state.updateOklchChroma(Float.NaN)
+ state.updateOklchHue(Float.NaN)
+ assertSame(before, state.pickerColor)
+ }
+
+ // ---- Derived views agree with each other ----
+
+ @Test
+ fun everySpaceDescribesTheSameColor() {
+ val state = ColorPickerState(RgbColor(0.2f, 0.6f, 0.85f))
+ val argb = state.argbInt
+
+ for (color in listOf(
+ state.oklabColor,
+ state.oklchColor,
+ state.okhslColor,
+ state.okhsvColor,
+ )) {
+ val roundTripped = ColorPickerState(color).argbInt
+ assertEquals(argb, roundTripped, "argb via $color")
+ }
+ }
+
+ @Test
+ fun switchingOriginSpaceKeepsTheColorStable() {
+ val state = ColorPickerState(OkhslColor(hue = 217f, saturation = 0.7f, lightness = 0.45f))
+ val before = state.rgbColor
+
+ // Touch each space in turn without changing the color it represents.
+ state.updateFromOklab(state.oklabColor)
+ state.updateFromOklch(state.oklchColor)
+ state.updateFromOkhsv(state.okhsvColor)
+ state.updateFromLab(state.labColor)
+
+ val after = state.rgbColor
+ assertNear(before.red, after.red, 2e-2f, "red")
+ assertNear(before.green, after.green, 2e-2f, "green")
+ assertNear(before.blue, after.blue, 2e-2f, "blue")
+ }
+
+ // ---- Saved state ----
+
+ @Test
+ fun everyNewSpaceSurvivesSaveAndRestore() {
+ for (color in listOf(
+ OklabColor(0.42f, -0.13f, 0.07f, alpha = 0.6f),
+ OklchColor(0.42f, 0.13f, 217f, alpha = 0.6f),
+ OkhslColor(217f, 0.63f, 0.42f, alpha = 0.6f),
+ OkhsvColor(217f, 0.63f, 0.42f, alpha = 0.6f),
+ )) {
+ val saved = with(ColorPickerStateSaver) {
+ AlwaysSaveable.save(ColorPickerState(color))
+ }
+ val restored = ColorPickerStateSaver.restore(saved!!)
+ assertEquals(color, restored?.pickerColor, "restored $color")
+ }
+ }
+
+ @Test
+ fun theSpaceKeysOfTheExistingSpacesAreUnchanged() {
+ // The saved format is persisted across process death, so these keys are a
+ // compatibility surface: a state saved by 1.x has to restore under 2.x.
+ val cases = listOf(
+ 0f to HslColor(200f, 0.5f, 0.5f),
+ 1f to RgbColor(0.1f, 0.2f, 0.3f),
+ 3f to LabColor(50f, 10f, -10f),
+ )
+ for ((key, color) in cases) {
+ val saved = with(ColorPickerStateSaver) {
+ AlwaysSaveable.save(ColorPickerState(color))
+ }
+ assertEquals(key, saved!![0], "space key for $color")
+ }
+ }
+}
diff --git a/docs/images/okhsl-contextual.png b/docs/images/okhsl-contextual.png
new file mode 100644
index 0000000..08a8972
Binary files /dev/null and b/docs/images/okhsl-contextual.png differ
diff --git a/docs/images/okhsl-independent.png b/docs/images/okhsl-independent.png
new file mode 100644
index 0000000..a394ee7
Binary files /dev/null and b/docs/images/okhsl-independent.png differ
diff --git a/docs/images/okhsv-contextual.png b/docs/images/okhsv-contextual.png
new file mode 100644
index 0000000..154575b
Binary files /dev/null and b/docs/images/okhsv-contextual.png differ
diff --git a/docs/images/okhsv-independent.png b/docs/images/okhsv-independent.png
new file mode 100644
index 0000000..3696fdd
Binary files /dev/null and b/docs/images/okhsv-independent.png differ
diff --git a/docs/index.md b/docs/index.md
index a74a993..2413435 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -9,6 +9,8 @@ Kotlin Multiplatform color picker library for Android, iOS, Desktop (JVM), and W
- Compose Multiplatform (Android, iOS, Desktop/JVM, Web/Wasm)
- Material 3 theming via `ColorPickerDefaults`
- HSL, RGB, CMYK, and LAB color models
+- Perceptual color: Okhsl and Okhsv pickers, with Oklab and OkLCh for interchange and manipulation
+- CSS Color 4 gamut mapping, so an out-of-gamut Oklab or OkLCh color keeps its lightness and hue
- Alpha channel support
- Zero-drift editing: `ColorPickerState` keeps the authoritative color in the space you edited, so edit-in-X-read-X is always exact (conversions themselves are float-based)
- Unidirectional data flow with `ColorPickerState`
@@ -43,12 +45,14 @@ Every picker takes a `ColoringMode`. `Independent` shows each channel's full ran
`Contextual` previews the resulting color at every slider position. `HslColorPicker`
defaults to `Independent`; the others default to `Contextual`.
-| Model | Independent | Contextual |
-|-------------------------------|-------------------------------------------------------|-----------------------------------------------------|
-| **HSL**
`HslColorPicker` |  |  |
-| **RGB**
`RgbColorPicker` |  |  |
-| **CMYK**
`CmykColorPicker` |  |  |
-| **LAB**
`LabColorPicker` |  |  |
+| Model | Independent | Contextual |
+|---------------------------------|----------------------------------------------------|--------------------------------------------------|
+| **HSL**
`HslColorPicker` |  |  |
+| **RGB**
`RgbColorPicker` |  |  |
+| **CMYK**
`CmykColorPicker` |  |  |
+| **LAB**
`LabColorPicker` |  |  |
+| **Okhsl**
`OkhslColorPicker` |  |  |
+| **Okhsv**
`OkhsvColorPicker` |  |  |
`ColorSwatch` draws the color over a transparency checkerboard, so alpha reads correctly:
@@ -129,6 +133,73 @@ val color = LabColor(l = 53.23f, a = 80.11f, b = 67.22f)
LabColor.fromInt(l = 53, a = 80, b = 67)
```
+### Okhsl
+
+```kotlin
+val color = OkhslColor(hue = 29.2f, saturation = 1f, lightness = 0.57f)
+// hue: [0, 360), saturation: [0, 1], lightness: [0, 1], alpha: [0, 1]
+
+OkhslColor.fromInt(hue = 29, saturation = 100, lightness = 57)
+```
+
+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.
+
+### Okhsv
+
+```kotlin
+val color = OkhsvColor(hue = 29.2f, saturation = 1f, value = 1f)
+// hue: [0, 360), saturation: [0, 1], value: [0, 1], alpha: [0, 1]
+
+OkhsvColor.fromInt(hue = 29, saturation = 100, value = 100)
+```
+
+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
+
+```kotlin
+val color = OklabColor(l = 0.63f, a = 0.22f, b = 0.13f)
+// l: [0, 1], a: [-0.4, 0.4], b: [-0.4, 0.4], alpha: [0, 1]
+```
+
+The perceptual space the two above are built on, and the one to interpolate, compare or
+blend in — equal numeric steps are close to equal perceived steps, and moving `l` does not
+drag the perceived hue with it. Note `l` runs `0..1`, not CIELAB's `0..100`, and the `a`
+and `b` bounds are the reference range CSS Color 4 gives `oklab()`.
+
+Oklab is not a space to put sliders on: `a` and `b` are not bounded by the display gamut,
+so most of their range is unreachable, exactly as with LAB. Use Okhsl or Okhsv for that.
+
+### OkLCh
+
+```kotlin
+val color = OklchColor(l = 0.63f, chroma = 0.26f, hue = 29.2f)
+// l: [0, 1], chroma: [0, 0.4], hue: [0, 360), alpha: [0, 1]
+```
+
+Oklab in cylindrical form, and the space CSS exposes as `oklch()` — use it to move values
+in and out of stylesheets, or to change one of lightness, chroma and hue while holding the
+others.
+
+### Gamut mapping
+
+Oklab and OkLCh can describe colors sRGB cannot show. Converting one to RGB 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, chroma
+pays, and the result matches what a browser would render.
+
+Okhsl and Okhsv never need this — their coordinates are normalized against the gamut, so
+they are inside it by construction.
+
## 🔄 Conversions
Conversions are extension functions. They operate on floats end to end — nothing is quantized to integers until you explicitly ask for an ARGB `Int` or a hex string. Like any color space conversion, a cross-space round trip is not guaranteed to be bit-exact; the zero-drift guarantee comes from `ColorPickerState`'s origin tracking (see [Architecture](#architecture-zero-drift-color-conversions)).
@@ -140,12 +211,19 @@ val cmyk = rgb.toCmyk()
val lab = rgb.toLab()
val argb = rgb.toArgbInt()
+// Perceptual spaces
+val oklab = rgb.toOklab()
+val oklch = rgb.toOklch()
+val okhsl = rgb.toOkhsl()
+val okhsv = rgb.toOkhsv()
+
// Compose interop, both ways
val composeColor: Color = hsl.toComposeColor()
val backToHsl: HslColor = composeColor.toHslColor()
val backToRgb: RgbColor = composeColor.toRgbColor()
val backToCmyk: CmykColor = composeColor.toCmykColor()
val backToLab: LabColor = composeColor.toLabColor()
+val backToOkhsl: OkhslColor = composeColor.toOkhslColor()
```
### Hex strings
@@ -183,6 +261,8 @@ HslColorPicker(
RgbColorPicker(state = state, showAlpha = true)
CmykColorPicker(state = state, showAlpha = true)
LabColorPicker(state = state, showAlpha = true)
+OkhslColorPicker(state = state, showAlpha = true)
+OkhsvColorPicker(state = state, showAlpha = true)
```
`ColoringMode` controls the slider gradients: `Independent` shows each channel's full range regardless of the other channels, `Contextual` previews the actual resulting color at each position.
@@ -213,6 +293,16 @@ LightnessLabSlider(state = state)
LabASlider(state = state)
LabBSlider(state = state)
+// Okhsl
+OkhslHueSlider(state = state)
+OkhslSaturationSlider(state = state)
+OkhslLightnessSlider(state = state)
+
+// Okhsv
+OkhsvHueSlider(state = state)
+OkhsvSaturationSlider(state = state)
+OkhsvValueSlider(state = state)
+
// Alpha (works with any origin space)
AlphaSlider(state = state)
```
diff --git a/sample/shared/src/commonMain/kotlin/codes/side/colorpicker/sample/SampleApp.kt b/sample/shared/src/commonMain/kotlin/codes/side/colorpicker/sample/SampleApp.kt
index 6d29e05..feaefae 100644
--- a/sample/shared/src/commonMain/kotlin/codes/side/colorpicker/sample/SampleApp.kt
+++ b/sample/shared/src/commonMain/kotlin/codes/side/colorpicker/sample/SampleApp.kt
@@ -60,6 +60,12 @@ import codes.side.colorpicker.ui.LabBSlider
import codes.side.colorpicker.ui.LightnessLabSlider
import codes.side.colorpicker.ui.LightnessSlider
import codes.side.colorpicker.ui.MagentaSlider
+import codes.side.colorpicker.ui.OkhslHueSlider
+import codes.side.colorpicker.ui.OkhslLightnessSlider
+import codes.side.colorpicker.ui.OkhslSaturationSlider
+import codes.side.colorpicker.ui.OkhsvHueSlider
+import codes.side.colorpicker.ui.OkhsvSaturationSlider
+import codes.side.colorpicker.ui.OkhsvValueSlider
import codes.side.colorpicker.ui.RedSlider
import codes.side.colorpicker.ui.SaturationSlider
import codes.side.colorpicker.ui.YellowSlider
@@ -166,10 +172,22 @@ fun SampleApp() {
val ll = lab.intL.pad(3)
val la = lab.intA.pad(3)
val lb = lab.intB.pad(3)
- Readout("HSL H:$h S:$s L:$l A:$ha")
- Readout("RGB R:$r G:$g B:$b A:$ra")
- Readout("CMYK C:$c M:$m Y:$y K:$k")
- Readout("LAB L:$ll a:$la b:$lb")
+ val okhsl = state.okhslColor
+ val okhsv = state.okhsvColor
+ val oklch = state.oklchColor
+ Readout("HSL H:$h S:$s L:$l A:$ha")
+ Readout("RGB R:$r G:$g B:$b A:$ra")
+ Readout("CMYK C:$c M:$m Y:$y K:$k")
+ Readout("LAB L:$ll a:$la b:$lb")
+ Readout(
+ "OKHSL H:${okhsl.intHue.pad(3)} S:${okhsl.intSaturation.pad(3)} L:${okhsl.intLightness.pad(3)}",
+ )
+ Readout(
+ "OKHSV H:${okhsv.intHue.pad(3)} S:${okhsv.intSaturation.pad(3)} V:${okhsv.intValue.pad(3)}",
+ )
+ Readout(
+ "OKLCH L:${oklch.intL.pad(3)} C:${oklch.intChroma.pad(3)} H:${oklch.intHue.pad(3)}",
+ )
}
// HSL section
@@ -205,6 +223,23 @@ fun SampleApp() {
item { HorizontalDivider() }
+ // Okhsl section. Drag the lightness slider here and then the HSL one
+ // above at the same hue: only this one holds its apparent brightness.
+ item { SectionHeader("Okhsl") }
+ item { OkhslHueSlider(state = state, coloringMode = coloringMode) }
+ item { OkhslSaturationSlider(state = state, coloringMode = coloringMode) }
+ item { OkhslLightnessSlider(state = state, coloringMode = coloringMode) }
+
+ item { HorizontalDivider() }
+
+ // Okhsv section
+ item { SectionHeader("Okhsv") }
+ item { OkhsvHueSlider(state = state, coloringMode = coloringMode) }
+ item { OkhsvSaturationSlider(state = state, coloringMode = coloringMode) }
+ item { OkhsvValueSlider(state = state, coloringMode = coloringMode) }
+
+ item { HorizontalDivider() }
+
// Alpha section
item { SectionHeader("Alpha") }
item { AlphaSlider(state = state) }
diff --git a/screenshot-tests/build.gradle.kts b/screenshot-tests/build.gradle.kts
index eaf83f1..4a97321 100644
--- a/screenshot-tests/build.gradle.kts
+++ b/screenshot-tests/build.gradle.kts
@@ -49,6 +49,10 @@ val readmeGoldens = mapOf(
"CmykContextualPreview" to "cmyk-contextual",
"LabIndependentPreview" to "lab-independent",
"LabContextualPreview" to "lab-contextual",
+ "OkhslIndependentPreview" to "okhsl-independent",
+ "OkhslContextualPreview" to "okhsl-contextual",
+ "OkhsvIndependentPreview" to "okhsv-independent",
+ "OkhsvContextualPreview" to "okhsv-contextual",
"CustomThumbPreview" to "custom-thumb",
"SwatchPreview" to "color-swatch",
)
diff --git a/screenshot-tests/src/screenshotTest/kotlin/codes/side/colorpicker/screenshot/ColorPickerPreviews.kt b/screenshot-tests/src/screenshotTest/kotlin/codes/side/colorpicker/screenshot/ColorPickerPreviews.kt
index 5be57ae..e662af1 100644
--- a/screenshot-tests/src/screenshotTest/kotlin/codes/side/colorpicker/screenshot/ColorPickerPreviews.kt
+++ b/screenshot-tests/src/screenshotTest/kotlin/codes/side/colorpicker/screenshot/ColorPickerPreviews.kt
@@ -36,6 +36,8 @@ import codes.side.colorpicker.ui.ColorSwatch
import codes.side.colorpicker.ui.HslColorPicker
import codes.side.colorpicker.ui.HueSlider
import codes.side.colorpicker.ui.LabColorPicker
+import codes.side.colorpicker.ui.OkhslColorPicker
+import codes.side.colorpicker.ui.OkhsvColorPicker
import codes.side.colorpicker.ui.RgbColorPicker
import com.android.tools.screenshot.PreviewTest
@@ -129,6 +131,34 @@ fun LabContextualPreview() = Frame {
LabColorPicker(state = state(), coloringMode = ColoringMode.Contextual)
}
+@PreviewTest
+@Preview(name = "Okhsl independent", widthDp = 440, heightDp = PICKER_HEIGHT_DP)
+@Composable
+fun OkhslIndependentPreview() = Frame {
+ OkhslColorPicker(state = state(), coloringMode = ColoringMode.Independent)
+}
+
+@PreviewTest
+@Preview(name = "Okhsl contextual", widthDp = 440, heightDp = PICKER_HEIGHT_DP)
+@Composable
+fun OkhslContextualPreview() = Frame {
+ OkhslColorPicker(state = state(), coloringMode = ColoringMode.Contextual)
+}
+
+@PreviewTest
+@Preview(name = "Okhsv independent", widthDp = 440, heightDp = PICKER_HEIGHT_DP)
+@Composable
+fun OkhsvIndependentPreview() = Frame {
+ OkhsvColorPicker(state = state(), coloringMode = ColoringMode.Independent)
+}
+
+@PreviewTest
+@Preview(name = "Okhsv contextual", widthDp = 440, heightDp = PICKER_HEIGHT_DP)
+@Composable
+fun OkhsvContextualPreview() = Frame {
+ OkhsvColorPicker(state = state(), coloringMode = ColoringMode.Contextual)
+}
+
// Kept character-for-character identical to SquareThumb in the sample app, so the image
// in the README is the thing the sample actually runs.
@Composable
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/OkhslContextualPreview_Okhsl contextual_f9158ec4_0.png b/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/OkhslContextualPreview_Okhsl contextual_f9158ec4_0.png
new file mode 100644
index 0000000..08a8972
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/OkhslContextualPreview_Okhsl contextual_f9158ec4_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/OkhslIndependentPreview_Okhsl independent_89c53e9f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/OkhslIndependentPreview_Okhsl independent_89c53e9f_0.png
new file mode 100644
index 0000000..a394ee7
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/OkhslIndependentPreview_Okhsl independent_89c53e9f_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/OkhsvContextualPreview_Okhsv contextual_42b873fe_0.png b/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/OkhsvContextualPreview_Okhsv contextual_42b873fe_0.png
new file mode 100644
index 0000000..154575b
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/OkhsvContextualPreview_Okhsv contextual_42b873fe_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/OkhsvIndependentPreview_Okhsv independent_b9c3f979_0.png b/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/OkhsvIndependentPreview_Okhsv independent_b9c3f979_0.png
new file mode 100644
index 0000000..3696fdd
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/OkhsvIndependentPreview_Okhsv independent_b9c3f979_0.png differ