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` | ![HSL independent](docs/images/hsl-independent.png) | ![HSL contextual](docs/images/hsl-contextual.png) | -| **RGB**
`RgbColorPicker` | ![RGB independent](docs/images/rgb-independent.png) | ![RGB contextual](docs/images/rgb-contextual.png) | -| **CMYK**
`CmykColorPicker` | ![CMYK independent](docs/images/cmyk-independent.png) | ![CMYK contextual](docs/images/cmyk-contextual.png) | -| **LAB**
`LabColorPicker` | ![LAB independent](docs/images/lab-independent.png) | ![LAB contextual](docs/images/lab-contextual.png) | +| Model | Independent | Contextual | +|---------------------------------|---------------------------------------------------------|-------------------------------------------------------| +| **HSL**
`HslColorPicker` | ![HSL independent](docs/images/hsl-independent.png) | ![HSL contextual](docs/images/hsl-contextual.png) | +| **RGB**
`RgbColorPicker` | ![RGB independent](docs/images/rgb-independent.png) | ![RGB contextual](docs/images/rgb-contextual.png) | +| **CMYK**
`CmykColorPicker` | ![CMYK independent](docs/images/cmyk-independent.png) | ![CMYK contextual](docs/images/cmyk-contextual.png) | +| **LAB**
`LabColorPicker` | ![LAB independent](docs/images/lab-independent.png) | ![LAB contextual](docs/images/lab-contextual.png) | +| **Okhsl**
`OkhslColorPicker` | ![Okhsl independent](docs/images/okhsl-independent.png) | ![Okhsl contextual](docs/images/okhsl-contextual.png) | +| **Okhsv**
`OkhsvColorPicker` | ![Okhsv independent](docs/images/okhsv-independent.png) | ![Okhsv contextual](docs/images/okhsv-contextual.png) | `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` | ![HSL independent](images/hsl-independent.png) | ![HSL contextual](images/hsl-contextual.png) | -| **RGB**
`RgbColorPicker` | ![RGB independent](images/rgb-independent.png) | ![RGB contextual](images/rgb-contextual.png) | -| **CMYK**
`CmykColorPicker` | ![CMYK independent](images/cmyk-independent.png) | ![CMYK contextual](images/cmyk-contextual.png) | -| **LAB**
`LabColorPicker` | ![LAB independent](images/lab-independent.png) | ![LAB contextual](images/lab-contextual.png) | +| Model | Independent | Contextual | +|---------------------------------|----------------------------------------------------|--------------------------------------------------| +| **HSL**
`HslColorPicker` | ![HSL independent](images/hsl-independent.png) | ![HSL contextual](images/hsl-contextual.png) | +| **RGB**
`RgbColorPicker` | ![RGB independent](images/rgb-independent.png) | ![RGB contextual](images/rgb-contextual.png) | +| **CMYK**
`CmykColorPicker` | ![CMYK independent](images/cmyk-independent.png) | ![CMYK contextual](images/cmyk-contextual.png) | +| **LAB**
`LabColorPicker` | ![LAB independent](images/lab-independent.png) | ![LAB contextual](images/lab-contextual.png) | +| **Okhsl**
`OkhslColorPicker` | ![Okhsl independent](images/okhsl-independent.png) | ![Okhsl contextual](images/okhsl-contextual.png) | +| **Okhsv**
`OkhsvColorPicker` | ![Okhsv independent](images/okhsv-independent.png) | ![Okhsv contextual](images/okhsv-contextual.png) | `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