diff --git a/README.md b/README.md index 718f65f9..ebecdf79 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,15 @@ 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 LAB, 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` - Hex string parsing and formatting - Color picker dialog -- Accessibility semantics and RTL layout support +- Two-dimensional color planes β€” saturation paired with lightness or value β€” alongside the single-channel sliders +- Accessibility semantics, and RTL layout support everywhere but the planes, which map a color space rather than showing progress ## πŸ“¦ Setup @@ -40,15 +43,18 @@ Published targets: `android`, `jvm`, `iosArm64`, `iosSimulatorArm64`, `wasmJs`. ## 🎨 Gallery Every picker takes a `ColoringMode`. `Independent` shows each channel's full range; -`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) | +`Contextual` previews the resulting color at every slider position. `HslColorPicker`, +`OkhslColorPicker` and `OkhsvColorPicker` default to `Independent`; the RGB, CMYK and LAB +pickers 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) | +| **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: @@ -123,12 +129,91 @@ CmykColor.fromInt(cyan = 30, magenta = 60, yellow = 10, key = 20) ### LAB ```kotlin -val color = LabColor(l = 53.23f, a = 80.11f, b = 67.22f) +val color = LabColor(l = 54.29f, a = 80.81f, b = 69.89f) // l: [0, 100], a: [-128, 127], b: [-128, 127], alpha: [0, 1] -LabColor.fromInt(l = 53, a = 80, b = 67) +LabColor.fromInt(l = 54, a = 81, b = 70) +``` + +CIELAB with a D50 reference white, which is what CSS `lab()`, Photoshop and Compose's +`ColorSpaces.CieLab` all quote, so a value copied from any of them means here what it +meant there. About an eighth of the `a`/`b` box is inside sRGB: over half the travel on +either slider is outside it and renders as the nearest color the display can show. + +### 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 + +LAB, 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. + +The search runs in Oklab whatever space the color came from, as CSS specifies, so what +survives is Oklab's lightness and hue, not CIELAB's. L* lands within about 3 where clipping +each channel moved it by 7.6. CIELAB's own hue angle can still swing, and swings worst where +the two spaces disagree most: `lab(50% 0 -128)` sits at 270Β° in CIELAB but 221Β° in Oklab, so +holding the latter moves the former by 38Β°. That is CIELAB's blue axis being non-uniform +rather than the mapping misbehaving β€” Oklab's hue is the one that tracks what you see. + +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 +225,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,10 +275,61 @@ 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. +### Color Planes + +`HslPlane` picks both channels at once for the hue currently in `state`, +leaving hue and alpha untouched, so it composes with a `HueSlider` into a full picker. +`OkhslPlane` and `OkhsvPlane` are the same idea over Okhsl and Okhsv, composing with an +`OkhslHueSlider` or `OkhsvHueSlider` instead. + +| Model | Preview | +|---------------------------|-------------------------------------------------------------------------------| +| **HSL**
`HslPlane` | ![Saturation and lightness plane](docs/images/saturation-lightness-plane.png) | +| **Okhsl**
`OkhslPlane` | ![Okhsl plane](docs/images/okhsl-plane.png) | +| **Okhsv**
`OkhsvPlane` | ![Okhsv plane](docs/images/okhsv-plane.png) | + +```kotlin +val state = rememberColorPickerState(HslColor(hue = 68f, saturation = 0.72f, lightness = 0.62f)) + +HslPlane(state = state, modifier = Modifier.fillMaxWidth().height(220.dp)) +HueSlider(state = state) +``` + +Saturation always runs left to right, so the left edge is grey and the right edge the most +colorful the hue can be. The vertical axis runs from black at the bottom in every plane, but +reads differently at the top: lightness for `HslPlane` and `OkhslPlane`, so the top edge is +white, and value for `OkhsvPlane`, so the top edge is each column's own hue at full +brightness β€” the arrangement artists expect from a color picker, and the one Okhsv was +designed for. + +`HslPlane`'s surface is a horizontal grey-to-hue ramp under a white / transparent / black +overlay, which reproduces HSL exactly rather than approximately β€” the colour at lightness L +is the mid-lightness colour blended toward white by `2L-1` above the middle and toward black +by `1-2L` below it, which is what compositing the overlay computes. Okhsl and Okhsv have no +such identity, so `OkhslPlane` and `OkhsvPlane` sample their surface on a grid fine enough +that the difference is invisible instead. + +The surface is **not** mirrored in right-to-left layouts, unlike the sliders. It maps a +colour space rather than showing progress, and mirroring it would have saturation growing +leftwards here while it still grows rightwards on the hue slider beside it. Screen readers +get a label and both values, but a two-dimensional drag has no linear equivalent, so the +sliders remain the accessible path to the same channels. + +`thumb` replaces the position indicator, and receives the plane's `InteractionSource`: + +```kotlin +HslPlane( + state = state, + thumb = { source -> MyIndicator(source) }, +) +``` + ### Individual Sliders Every channel is available as a standalone slider. Compose any subset against a shared state: @@ -213,6 +356,21 @@ 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) + +// Planes β€” two channels at once, to compose with a hue slider +HslPlane(state = state) +OkhslPlane(state = state) +OkhsvPlane(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 6944d2a7..58c610bb 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] @@ -231,8 +395,10 @@ final class codes.side.colorpicker.theme/ColorPickerColors { // codes.side.color } final class codes.side.colorpicker.theme/ColorPickerShapes { // codes.side.colorpicker.theme/ColorPickerShapes|null[0] - constructor (androidx.compose.ui.graphics/Shape, androidx.compose.ui.graphics/Shape) // codes.side.colorpicker.theme/ColorPickerShapes.|(androidx.compose.ui.graphics.Shape;androidx.compose.ui.graphics.Shape){}[0] + constructor (androidx.compose.ui.graphics/Shape, androidx.compose.ui.graphics/Shape, androidx.compose.ui.graphics/Shape) // codes.side.colorpicker.theme/ColorPickerShapes.|(androidx.compose.ui.graphics.Shape;androidx.compose.ui.graphics.Shape;androidx.compose.ui.graphics.Shape){}[0] + final val planeShape // codes.side.colorpicker.theme/ColorPickerShapes.planeShape|{}planeShape[0] + final fun (): androidx.compose.ui.graphics/Shape // codes.side.colorpicker.theme/ColorPickerShapes.planeShape.|(){}[0] final val swatchShape // codes.side.colorpicker.theme/ColorPickerShapes.swatchShape|{}swatchShape[0] final fun (): androidx.compose.ui.graphics/Shape // codes.side.colorpicker.theme/ColorPickerShapes.swatchShape.|(){}[0] final val trackShape // codes.side.colorpicker.theme/ColorPickerShapes.trackShape|{}trackShape[0] @@ -240,13 +406,18 @@ final class codes.side.colorpicker.theme/ColorPickerShapes { // codes.side.color final fun component1(): androidx.compose.ui.graphics/Shape // codes.side.colorpicker.theme/ColorPickerShapes.component1|component1(){}[0] final fun component2(): androidx.compose.ui.graphics/Shape // codes.side.colorpicker.theme/ColorPickerShapes.component2|component2(){}[0] - final fun copy(androidx.compose.ui.graphics/Shape = ..., androidx.compose.ui.graphics/Shape = ...): codes.side.colorpicker.theme/ColorPickerShapes // codes.side.colorpicker.theme/ColorPickerShapes.copy|copy(androidx.compose.ui.graphics.Shape;androidx.compose.ui.graphics.Shape){}[0] + final fun component3(): androidx.compose.ui.graphics/Shape // codes.side.colorpicker.theme/ColorPickerShapes.component3|component3(){}[0] + final fun copy(androidx.compose.ui.graphics/Shape = ..., androidx.compose.ui.graphics/Shape = ..., androidx.compose.ui.graphics/Shape = ...): codes.side.colorpicker.theme/ColorPickerShapes // codes.side.colorpicker.theme/ColorPickerShapes.copy|copy(androidx.compose.ui.graphics.Shape;androidx.compose.ui.graphics.Shape;androidx.compose.ui.graphics.Shape){}[0] final fun equals(kotlin/Any?): kotlin/Boolean // codes.side.colorpicker.theme/ColorPickerShapes.equals|equals(kotlin.Any?){}[0] final fun hashCode(): kotlin/Int // codes.side.colorpicker.theme/ColorPickerShapes.hashCode|hashCode(){}[0] final fun toString(): kotlin/String // codes.side.colorpicker.theme/ColorPickerShapes.toString|toString(){}[0] } final object codes.side.colorpicker.theme/ColorPickerDefaults { // codes.side.colorpicker.theme/ColorPickerDefaults|null[0] + final val PlaneMinSize // codes.side.colorpicker.theme/ColorPickerDefaults.PlaneMinSize|{}PlaneMinSize[0] + final fun (): androidx.compose.ui.unit/Dp // codes.side.colorpicker.theme/ColorPickerDefaults.PlaneMinSize.|(){}[0] + final val PlaneThumbSize // codes.side.colorpicker.theme/ColorPickerDefaults.PlaneThumbSize|{}PlaneThumbSize[0] + final fun (): androidx.compose.ui.unit/Dp // codes.side.colorpicker.theme/ColorPickerDefaults.PlaneThumbSize.|(){}[0] final val SwatchSize // codes.side.colorpicker.theme/ColorPickerDefaults.SwatchSize|{}SwatchSize[0] final fun (): androidx.compose.ui.unit/Dp // codes.side.colorpicker.theme/ColorPickerDefaults.SwatchSize.|(){}[0] final val ThumbTrackGap // codes.side.colorpicker.theme/ColorPickerDefaults.ThumbTrackGap|{}ThumbTrackGap[0] @@ -257,12 +428,16 @@ final object codes.side.colorpicker.theme/ColorPickerDefaults { // codes.side.co final fun (): androidx.compose.ui.unit/Dp // codes.side.colorpicker.theme/ColorPickerDefaults.TrackHeight.|(){}[0] final fun colors(androidx.compose.ui.graphics/Color, androidx.compose.ui.graphics/Color, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): codes.side.colorpicker.theme/ColorPickerColors // codes.side.colorpicker.theme/ColorPickerDefaults.colors|colors(androidx.compose.ui.graphics.Color;androidx.compose.ui.graphics.Color;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] - final fun shapes(androidx.compose.ui.graphics/Shape?, androidx.compose.ui.graphics/Shape?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): codes.side.colorpicker.theme/ColorPickerShapes // codes.side.colorpicker.theme/ColorPickerDefaults.shapes|shapes(androidx.compose.ui.graphics.Shape?;androidx.compose.ui.graphics.Shape?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] + final fun shapes(androidx.compose.ui.graphics/Shape?, androidx.compose.ui.graphics/Shape?, androidx.compose.ui.graphics/Shape?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): codes.side.colorpicker.theme/ColorPickerShapes // codes.side.colorpicker.theme/ColorPickerDefaults.shapes|shapes(androidx.compose.ui.graphics.Shape?;androidx.compose.ui.graphics.Shape?;androidx.compose.ui.graphics.Shape?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] } 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 +447,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 +460,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] @@ -313,11 +526,13 @@ final fun codes.side.colorpicker.ui/AlphaSlider(codes.side.colorpicker.state/Col final fun codes.side.colorpicker.ui/BlueSlider(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/BlueSlider|BlueSlider(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/CmykColorPicker(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/CmykColorPicker|CmykColorPicker(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/ColorPickerDialog(kotlin/Function1, kotlin/Function0, androidx.compose.ui/Modifier?, codes.side.colorpicker.model/HslColor?, kotlin/String?, kotlin/String?, kotlin/String?, kotlin/Boolean, codes.side.colorpicker.theme/ColorPickerColors?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // codes.side.colorpicker.ui/ColorPickerDialog|ColorPickerDialog(kotlin.Function1;kotlin.Function0;androidx.compose.ui.Modifier?;codes.side.colorpicker.model.HslColor?;kotlin.String?;kotlin.String?;kotlin.String?;kotlin.Boolean;codes.side.colorpicker.theme.ColorPickerColors?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] +final fun codes.side.colorpicker.ui/ColorPlane(kotlin/Float, kotlin/Float, kotlin/Function2, kotlin/Function1, androidx.compose.ui/Modifier?, kotlin/Function0?, kotlin/String?, kotlin/String?, codes.side.colorpicker.theme/ColorPickerShapes?, kotlin/Function3?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // codes.side.colorpicker.ui/ColorPlane|ColorPlane(kotlin.Float;kotlin.Float;kotlin.Function2;kotlin.Function1;androidx.compose.ui.Modifier?;kotlin.Function0?;kotlin.String?;kotlin.String?;codes.side.colorpicker.theme.ColorPickerShapes?;kotlin.Function3?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] final fun codes.side.colorpicker.ui/ColorSlider(kotlin/Float, kotlin/Function1, kotlinx.collections.immutable/ImmutableList, androidx.compose.ui.graphics/Color, androidx.compose.ui/Modifier?, kotlin/Function2?, kotlin/Function2?, kotlin/Function0?, androidx.compose.ui.unit/Dp, kotlin/Boolean, 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/ColorSlider|ColorSlider(kotlin.Float;kotlin.Function1;kotlinx.collections.immutable.ImmutableList;androidx.compose.ui.graphics.Color;androidx.compose.ui.Modifier?;kotlin.Function2?;kotlin.Function2?;kotlin.Function0?;androidx.compose.ui.unit.Dp;kotlin.Boolean;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/ColorSwatch(androidx.compose.ui.graphics/Color, androidx.compose.ui/Modifier?, androidx.compose.ui.graphics/Shape?, kotlin/String?, codes.side.colorpicker.theme/ColorPickerColors?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // codes.side.colorpicker.ui/ColorSwatch|ColorSwatch(androidx.compose.ui.graphics.Color;androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.Shape?;kotlin.String?;codes.side.colorpicker.theme.ColorPickerColors?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] final fun codes.side.colorpicker.ui/CyanSlider(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/CyanSlider|CyanSlider(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/GreenSlider(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/GreenSlider|GreenSlider(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/HslColorPicker(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/HslColorPicker|HslColorPicker(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/HslPlane(codes.side.colorpicker.state/ColorPickerState, androidx.compose.ui/Modifier?, kotlin/String?, kotlin/String?, codes.side.colorpicker.theme/ColorPickerShapes?, kotlin/Function3?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // codes.side.colorpicker.ui/HslPlane|HslPlane(codes.side.colorpicker.state.ColorPickerState;androidx.compose.ui.Modifier?;kotlin.String?;kotlin.String?;codes.side.colorpicker.theme.ColorPickerShapes?;kotlin.Function3?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] final fun codes.side.colorpicker.ui/HueSlider(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/HueSlider|HueSlider(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/KeySlider(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/KeySlider|KeySlider(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/LabASlider(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/LabASlider|LabASlider(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] @@ -326,6 +541,16 @@ 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/OkhslPlane(codes.side.colorpicker.state/ColorPickerState, androidx.compose.ui/Modifier?, kotlin/String?, kotlin/String?, codes.side.colorpicker.theme/ColorPickerShapes?, kotlin/Function3?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // codes.side.colorpicker.ui/OkhslPlane|OkhslPlane(codes.side.colorpicker.state.ColorPickerState;androidx.compose.ui.Modifier?;kotlin.String?;kotlin.String?;codes.side.colorpicker.theme.ColorPickerShapes?;kotlin.Function3?;androidx.compose.runtime.Composer?;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/OkhsvPlane(codes.side.colorpicker.state/ColorPickerState, androidx.compose.ui/Modifier?, kotlin/String?, kotlin/String?, codes.side.colorpicker.theme/ColorPickerShapes?, kotlin/Function3?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // codes.side.colorpicker.ui/OkhsvPlane|OkhsvPlane(codes.side.colorpicker.state.ColorPickerState;androidx.compose.ui.Modifier?;kotlin.String?;kotlin.String?;codes.side.colorpicker.theme.ColorPickerShapes?;kotlin.Function3?;androidx.compose.runtime.Composer?;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 9dab9c8a..1bbb8909 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 @@ -237,21 +412,25 @@ public final class codes/side/colorpicker/theme/ColorPickerDefaults { public static final field $stable I public static final field INSTANCE Lcodes/side/colorpicker/theme/ColorPickerDefaults; public final fun colors-dgg9oW8 (JJLandroidx/compose/runtime/Composer;II)Lcodes/side/colorpicker/theme/ColorPickerColors; + public final fun getPlaneMinSize-D9Ej5fM ()F + public final fun getPlaneThumbSize-D9Ej5fM ()F public final fun getSwatchSize-D9Ej5fM ()F public final fun getThumbTrackGap-D9Ej5fM ()F public final fun getThumbWidth-D9Ej5fM ()F public final fun getTrackHeight-D9Ej5fM ()F - public final fun shapes (Landroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Shape;Landroidx/compose/runtime/Composer;II)Lcodes/side/colorpicker/theme/ColorPickerShapes; + public final fun shapes (Landroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Shape;Landroidx/compose/runtime/Composer;II)Lcodes/side/colorpicker/theme/ColorPickerShapes; } public final class codes/side/colorpicker/theme/ColorPickerShapes { public static final field $stable I - public fun (Landroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Shape;)V + public fun (Landroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Shape;)V public final fun component1 ()Landroidx/compose/ui/graphics/Shape; public final fun component2 ()Landroidx/compose/ui/graphics/Shape; - public final fun copy (Landroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Shape;)Lcodes/side/colorpicker/theme/ColorPickerShapes; - public static synthetic fun copy$default (Lcodes/side/colorpicker/theme/ColorPickerShapes;Landroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Shape;ILjava/lang/Object;)Lcodes/side/colorpicker/theme/ColorPickerShapes; + public final fun component3 ()Landroidx/compose/ui/graphics/Shape; + public final fun copy (Landroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Shape;)Lcodes/side/colorpicker/theme/ColorPickerShapes; + public static synthetic fun copy$default (Lcodes/side/colorpicker/theme/ColorPickerShapes;Landroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Shape;ILjava/lang/Object;)Lcodes/side/colorpicker/theme/ColorPickerShapes; public fun equals (Ljava/lang/Object;)Z + public final fun getPlaneShape ()Landroidx/compose/ui/graphics/Shape; public final fun getSwatchShape ()Landroidx/compose/ui/graphics/Shape; public final fun getTrackShape ()Landroidx/compose/ui/graphics/Shape; public fun hashCode ()I @@ -277,6 +456,10 @@ public final class codes/side/colorpicker/ui/ColorPickerDialogKt { public static final fun ColorPickerDialog (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Lcodes/side/colorpicker/model/HslColor;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLcodes/side/colorpicker/theme/ColorPickerColors;Landroidx/compose/runtime/Composer;II)V } +public final class codes/side/colorpicker/ui/ColorPlaneKt { + public static final fun ColorPlane (FFLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Ljava/lang/String;Ljava/lang/String;Lcodes/side/colorpicker/theme/ColorPickerShapes;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V +} + public final class codes/side/colorpicker/ui/ColorSliderKt { public static final fun ColorSlider-pxA9Vh4 (FLkotlin/jvm/functions/Function1;Lkotlinx/collections/immutable/ImmutableList;JLandroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;FZLjava/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 } @@ -316,6 +499,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 @@ -328,6 +527,10 @@ public final class codes/side/colorpicker/ui/HslColorPickerKt { public static final fun HslColorPicker (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/HslPlaneKt { + public static final fun HslPlane (Lcodes/side/colorpicker/state/ColorPickerState;Landroidx/compose/ui/Modifier;Ljava/lang/String;Ljava/lang/String;Lcodes/side/colorpicker/theme/ColorPickerShapes;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V +} + public final class codes/side/colorpicker/ui/HslSlidersKt { public static final fun HueSlider-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 LightnessSlider-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 @@ -344,6 +547,34 @@ 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/OkhslPlaneKt { + public static final fun OkhslPlane (Lcodes/side/colorpicker/state/ColorPickerState;Landroidx/compose/ui/Modifier;Ljava/lang/String;Ljava/lang/String;Lcodes/side/colorpicker/theme/ColorPickerShapes;Lkotlin/jvm/functions/Function3;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/OkhsvPlaneKt { + public static final fun OkhsvPlane (Lcodes/side/colorpicker/state/ColorPickerState;Landroidx/compose/ui/Modifier;Ljava/lang/String;Ljava/lang/String;Lcodes/side/colorpicker/theme/ColorPickerShapes;Lkotlin/jvm/functions/Function3;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 892bfd00..c965bd8e 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) } @@ -59,6 +57,19 @@ kotlin { } sourceSets { + // Everything but Android draws through Skiko, so the one platform-specific thing the + // library needs β€” handing a pixel array to the toolkit as an image β€” has a single + // implementation there and an Android one beside it. + val skikoMain by creating { + dependsOn(commonMain.get()) + } + // Named one target at a time rather than through iosMain: the hierarchy template owns + // that node, and hanging another parent off it leaves the iOS compilations seeing only + // commonMain, so the actual below goes missing at link time rather than at configuration. + listOf("jvmMain", "wasmJsMain", "iosArm64Main", "iosSimulatorArm64Main").forEach { + getByName(it).dependsOn(skikoMain) + } + commonMain.dependencies { // api: types from these modules appear in the library's public API // (@Composable/@Immutable/@Stable from runtime; Modifier, Color, Shape, diff --git a/colorpicker/karma.config.d/mocha-timeout.js b/colorpicker/karma.config.d/mocha-timeout.js new file mode 100644 index 00000000..57458b15 --- /dev/null +++ b/colorpicker/karma.config.d/mocha-timeout.js @@ -0,0 +1,12 @@ +// The plane budget tests sweep millions of conversions to measure where a rasterized +// surface drifts from the true one, which takes longer than Mocha's 2 second default on +// the browser runner. That density is what makes the budgets mean anything β€” a coarser +// grid steps over the needle the error peaks in β€” so the limit moves rather than the +// sampling. +config.set({ + client: { + mocha: { + timeout: 120000, + }, + }, +}); diff --git a/colorpicker/src/androidMain/kotlin/codes/side/colorpicker/ui/PlanePixels.android.kt b/colorpicker/src/androidMain/kotlin/codes/side/colorpicker/ui/PlanePixels.android.kt new file mode 100644 index 00000000..71b8e63a --- /dev/null +++ b/colorpicker/src/androidMain/kotlin/codes/side/colorpicker/ui/PlanePixels.android.kt @@ -0,0 +1,13 @@ +package codes.side.colorpicker.ui + +import android.graphics.Bitmap +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap + +internal actual fun imageBitmapFromPixels( + pixels: IntArray, + width: Int, + height: Int, +): ImageBitmap = Bitmap + .createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888) + .asImageBitmap() 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 156699b1..92c8164d 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 00000000..f53f6188 --- /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 2e344065..b2b8c7d7 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 8ee0ce68..68f96ccc 100644 --- a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/LabConversions.kt +++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/LabConversions.kt @@ -6,18 +6,30 @@ import codes.side.colorpicker.model.RgbColor import kotlin.math.cbrt import kotlin.math.pow -// D65 reference white matching the sRGB conversion matrices below. -private const val XN = 0.95047 +// D50 reference white. CIELAB is quoted at D50 wherever it travels between tools β€” CSS +// lab(), Photoshop and Compose's own ColorSpaces.CieLab all use it β€” and reading a D50 +// number as D65 costs a median Ξ”E of 2.7, past the threshold where a difference shows. +// +// The sRGB matrices below carry the Bradford adaptation to D50 folded in rather than +// applied as a separate step. That is what keeps the neutral axis exact: each matrix row +// sums to its own component of this white, so white lands on a* = b* = 0 by construction. +// Adapting as a second step with a matrix rounded against a different D50 leaves the two +// disagreeing in the last digits, and white picks up a visible b* of its own. +private const val XN = 0.96422 private const val YN = 1.0 -private const val ZN = 1.08883 +private const val ZN = 0.82521 // CIE standard constants: EPSILON = (6/29)^3, KAPPA = (29/3)^3. private const val EPSILON = 216.0 / 24389.0 private const val KAPPA = 24389.0 / 27.0 /** - * Converts this CIELAB color (D65 reference white) to sRGB. Out-of-gamut results are - * clamped channel-wise into `0..1`. Alpha is carried over unchanged. + * Converts this CIELAB color (D50 reference white) 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. + * + * Only about an eighth of the `L* 0..100`, `a*`/`b* -128..127` box is inside sRGB, so + * most of the space this type can hold arrives mapped. */ public fun LabColor.toRgb(): RgbColor { val lD = l.toDouble() @@ -39,27 +51,37 @@ public fun LabColor.toRgb(): RgbColor { val y = yr * YN val z = zr * ZN - val rLinear = 3.2404542 * x - 1.5371385 * y - 0.4985314 * z - val gLinear = -0.9692660 * x + 1.8760108 * y + 0.0415560 * z - val bLinear = 0.0556434 * x - 0.2040259 * y + 1.0572252 * z + val linear = LinearRgb( + r = 3.1338561 * x - 1.6168667 * y - 0.4906146 * z, + g = -0.9787684 * x + 1.9161415 * y + 0.0334540 * z, + b = 0.0719453 * x - 0.2289914 * y + 1.4052427 * z, + ) + + // Colors sRGB can already show keep the arithmetic above exactly; only the rest pay + // for the trip through Oklab that the mapping is defined in. + val shown = if (linear.isInGamut()) { + linear.clipToUnit() + } else { + gamutMapToSrgb(linearSrgbToOklab(linear)) + } return RgbColor( - red = delinearize(rLinear).toFloat().coerceIn(0f, 1f), - green = delinearize(gLinear).toFloat().coerceIn(0f, 1f), - blue = delinearize(bLinear).toFloat().coerceIn(0f, 1f), + red = delinearize(shown.r).toFloat().coerceIn(0f, 1f), + green = delinearize(shown.g).toFloat().coerceIn(0f, 1f), + blue = delinearize(shown.b).toFloat().coerceIn(0f, 1f), alpha = alpha, ) } -/** Converts this sRGB color to CIELAB (D65 reference white). Alpha is carried over unchanged. */ +/** Converts this sRGB color to CIELAB (D50 reference white). Alpha is carried over unchanged. */ public fun RgbColor.toLab(): LabColor { val rLinear = linearize(red.toDouble()) val gLinear = linearize(green.toDouble()) val bLinear = linearize(blue.toDouble()) - val x = 0.4124564 * rLinear + 0.3575761 * gLinear + 0.1804375 * bLinear - val y = 0.2126729 * rLinear + 0.7151522 * gLinear + 0.0721750 * bLinear - val z = 0.0193339 * rLinear + 0.1191920 * gLinear + 0.9503041 * bLinear + val x = 0.4360747 * rLinear + 0.3850649 * gLinear + 0.1430804 * bLinear + val y = 0.2225045 * rLinear + 0.7168786 * gLinear + 0.0606169 * bLinear + val z = 0.0139322 * rLinear + 0.0971045 * gLinear + 0.7141733 * bLinear val fx = labF(x / XN) val fy = labF(y / YN) @@ -83,7 +105,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 00000000..e838ce81 --- /dev/null +++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/OkGamut.kt @@ -0,0 +1,349 @@ +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 = + oklabToLinearSrgb(c.l, c.a, c.b) { r, g, b -> LinearRgb(r, g, b) } + +/** + * [oklabToLinearSrgb] handing its three components to [use] rather than boxing them into a + * [LinearRgb]. + * + * Inline, so a loop calling it neither allocates nor calls. Rasterizing a plane is where + * that shows: a JVM removes the object by escape analysis, and on Android the objects and + * the per-pixel indirection together cost 9.6 ms of a 17.8 ms plane. + */ +internal inline fun oklabToLinearSrgb( + l: Double, + a: Double, + b: Double, + use: (r: Double, g: Double, b: Double) -> T, +): T { + val lRoot = l + 0.3963377774 * a + 0.2158037573 * b + val mRoot = l - 0.1055613458 * a - 0.0638541728 * b + val sRoot = l - 0.0894841775 * a - 1.2914855480 * b + + val lCubed = lRoot * lRoot * lRoot + val mCubed = mRoot * mRoot * mRoot + val sCubed = sRoot * sRoot * sRoot + + return use( + 4.0767416621 * lCubed - 3.3077115913 * mCubed + 0.2309699292 * sCubed, + -1.2684380046 * lCubed + 2.6097574011 * mCubed - 0.3413193965 * sCubed, + -0.0041960863 * lCubed - 0.7034186147 * mCubed + 1.7076147010 * sCubed, + ) +} + +// 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)) + +/** + * 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, +) + +/** + * 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. + */ +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) +} + +/** + * A hue, with everything an Ok* conversion can settle from the hue alone already settled. + * + * The cusp costs a polynomial fit and a Halley step, and the mid tint a second pair of + * polynomials. A caller that holds one hue across many colors β€” a plane, whose whole surface + * is one hue β€” pays for them once by keeping this rather than calling [getChromaAnchors]. + */ +internal class OkHue(private val aNorm: Double, private val bNorm: Double) { + + private val cusp: Cusp = findCusp(aNorm, bNorm) + private val saturationTint: SaturationTint = cusp.toSaturationTint() + private val saturationTintMid: SaturationTint = getSaturationTintMid(aNorm, bNorm) + + /** + * The chroma at three reference saturations for a given lightness: neutral-ish `c0`, the + * smooth midpoint `cMid`, and the gamut boundary `cMax`. Okhsl's saturation is a two-piece + * interpolation between them. + */ + fun chromaAnchorsAt(l: Double): ChromaAnchors { + val cMax = findGamutIntersection(aNorm, bNorm, l, 1.0, l, cusp) + + // Compensates for the curvature of the gamut surface, which the triangle below ignores. + val k = cMax / min(l * saturationTint.s, (1.0 - l) * saturationTint.t) + + val midA = l * saturationTintMid.s + val midB = (1.0 - l) * saturationTintMid.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) + } +} + +/** [OkHue.chromaAnchorsAt] for a caller converting one color and keeping nothing. */ +internal fun getChromaAnchors(l: Double, aNorm: Double, bNorm: Double): ChromaAnchors = + OkHue(aNorm, bNorm).chromaAnchorsAt(l) + +/** + * 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 is within `0..1`, allowing for float slop. */ +internal fun LinearRgb.isInGamut(epsilon: Double = 1e-6): Boolean = + r in -epsilon..1.0 + epsilon && + g in -epsilon..1.0 + epsilon && + b in -epsilon..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 00000000..db887608 --- /dev/null +++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/OkhslConversions.kt @@ -0,0 +1,161 @@ +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, + ) +} + +internal 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() + +/** + * Fills one row of a plane's pixels at a fixed hue: [saturations] across, one packed + * `0xFFRRGGBB` per entry, written into [pixels] from [offset]. + * + * The arithmetic is [OkhslColor.toRgb]'s, in its order, so a pixel matches converting that + * coordinate on its own β€” [codes.side.colorpicker.ui.PlaneFieldTest] holds it to that. What + * differs is that nothing here allocates and nothing is reached through a per-pixel lambda, + * which together were 9.6 ms of a 17.8 ms plane on Android, and that the encoding skips the + * pow that cost another 5.1 ms. + */ +internal fun okhslRowFiller(hue: Float): (lightness: Float, saturations: FloatArray, pixels: IntArray, offset: Int) -> Unit { + val radians = hue.toDouble() / DEGREES_PER_RADIAN + val aUnit = cos(radians) + val bUnit = sin(radians) + val okHue = OkHue(aUnit, bUnit) + + return { lightness, saturations, pixels, offset -> + if (lightness >= 1f) { + for (i in saturations.indices) pixels[offset + i] = OPAQUE_WHITE + } else if (lightness <= 0f) { + for (i in saturations.indices) pixels[offset + i] = OPAQUE_BLACK + } else { + val okLightness = toeInv(lightness.toDouble()) + val anchors = okHue.chromaAnchorsAt(okLightness) + for (i in saturations.indices) { + val chroma = chromaForSaturation(saturations[i].toDouble(), anchors) + pixels[offset + i] = oklabToLinearSrgb( + okLightness, + chroma * aUnit, + chroma * bUnit, + ) { r, g, b -> packOpaque(r, g, b) } + } + } + } +} 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 00000000..f6f6faf2 --- /dev/null +++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/OkhsvConversions.kt @@ -0,0 +1,188 @@ +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() + +/** Fills one row of a plane's pixels at a fixed hue; see [okhslRowFiller] for the shape. */ +internal fun okhsvRowFiller(hue: Float): (value: Float, saturations: FloatArray, pixels: IntArray, offset: Int) -> Unit { + 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 + + return { value, saturations, pixels, offset -> + if (value <= 0f) { + for (i in saturations.indices) pixels[offset + i] = OPAQUE_BLACK + } else { + val v = value.toDouble() + for (i in saturations.indices) { + val s = saturations[i].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 scale = oklabToLinearSrgb(lToed, aUnit * chromaToed, bUnit * chromaToed) { r, g, b -> + cbrt(1.0 / max(max(r, g), max(b, 0.0))) + } + + lightness *= scale + chroma *= scale + + pixels[offset + i] = oklabToLinearSrgb( + lightness, + chroma * aUnit, + chroma * bUnit, + ) { r, g, b -> packOpaque(r, g, b) } + } + } + } +} 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 00000000..f3a572ff --- /dev/null +++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/OklabConversions.kt @@ -0,0 +1,113 @@ +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. + * + * Chroma is clamped to `0.4`. [OklabColor]'s a and b are bounded individually, so their + * square reaches `0.566` at the corners, which [OklchColor]'s radius cannot hold; those + * colors are far outside sRGB and render the same either way. + */ +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/PackedPixel.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/PackedPixel.kt new file mode 100644 index 00000000..4ed87624 --- /dev/null +++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/PackedPixel.kt @@ -0,0 +1,14 @@ +package codes.side.colorpicker.conversion + +/** Opaque white as a packed `0xAARRGGBB` pixel. */ +internal const val OPAQUE_WHITE: Int = -0x1 + +/** Opaque black as a packed `0xAARRGGBB` pixel. */ +internal const val OPAQUE_BLACK: Int = -0x1000000 + +/** Linear-light sRGB as one opaque packed `0xAARRGGBB` pixel, clamped into the gamut. */ +internal fun packOpaque(red: Double, green: Double, blue: Double): Int = + (0xFF shl 24) or + (linearToSrgbByte(red) shl 16) or + (linearToSrgbByte(green) shl 8) or + linearToSrgbByte(blue) 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 00000000..9e87d792 --- /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/conversion/SrgbByte.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/SrgbByte.kt new file mode 100644 index 00000000..693a8e8e --- /dev/null +++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/conversion/SrgbByte.kt @@ -0,0 +1,29 @@ +package codes.side.colorpicker.conversion + +/** + * The linear value at which each encoded byte takes over: byte `b` wins from + * `linearize((b + 0.5) / 255)` upward, because that is the linear value whose encoding + * rounds to `b` from below. [linearize] is [delinearize]'s inverse, so these are the exact + * boundaries rather than a sampling of the curve. + */ +private val THRESHOLDS = DoubleArray(255) { linearize((it + 0.5) / 255.0) } + +/** + * Linear-light sRGB in `0..1` as the `0..255` byte a packed pixel holds. + * + * Identical in every case to rounding [delinearize], and reached by locating [linear] + * between the boundaries above instead of raising it to a power. Encoding a plane means + * three of these per pixel β€” 49152 for an [codes.side.colorpicker.ui.OkhslPlane] β€” and on + * Android `pow` costs 5.1 ms of a 17.8 ms rasterization where this costs a tenth of that. + */ +internal fun linearToSrgbByte(linear: Double): Int { + if (linear <= THRESHOLDS[0]) return 0 + if (linear >= THRESHOLDS[254]) return 255 + var low = 0 + var high = 254 + while (low < high) { + val mid = (low + high) ushr 1 + if (linear < THRESHOLDS[mid]) high = mid else low = mid + 1 + } + return low +} diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/LabColor.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/LabColor.kt index 3dffa919..cd7ba3f6 100644 --- a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/LabColor.kt +++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/LabColor.kt @@ -4,12 +4,18 @@ import androidx.compose.runtime.Immutable import kotlin.math.roundToInt /** - * An immutable color in the CIELAB (L*a*b*) color space, D65 reference white. + * An immutable color in the CIELAB (L*a*b*) color space, D50 reference white β€” the one + * CSS `lab()`, Photoshop and Compose's `ColorSpaces.CieLab` all quote, so values copied + * from any of them mean here what they meant there. * * [l] (lightness) is in `0..100`; [a] (green-red axis) and [b] (blue-yellow axis) are * in `-128..127`; [alpha] is in `0..1`. The constructor throws [IllegalArgumentException] * for out-of-range or NaN values; use [fromInt] for a clamping alternative. * + * Roughly an eighth of that box is inside sRGB. The rest is still a valid color and is + * held exactly here, but a display can only show the nearest one it has β€” see + * [codes.side.colorpicker.conversion.toRgb]. + * * The no-argument constructor `LabColor()` is opaque mid-gray (L = 50 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, 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 00000000..4adea77b --- /dev/null +++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/OkhslColor.kt @@ -0,0 +1,126 @@ +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 1.5% 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 hue 0 at full saturation and mid + * lightness β€” a deep pink, not red: [hue] is Oklab's hue angle, where sRGB red sits at + * 29.2Β°. 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 00000000..bf3780b1 --- /dev/null +++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/model/OkhsvColor.kt @@ -0,0 +1,125 @@ +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 1.5% 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 5 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 hue 0 at full saturation and full value β€” + * a hot pink, not red: [hue] is Oklab's hue angle, where sRGB red sits at 29.2Β°. 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 00000000..501df6e5 --- /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 00000000..039d1b8d --- /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 f4859a38..e98d2c9e 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 1ffe90d2..246ffa5a 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 @@ -48,51 +58,38 @@ public class ColorPickerState(initialColor: PickerColor = HslColor()) { private var authoritative by mutableStateOf(initialColor) /** - * True while the user is actively dragging one of the library's sliders (set on the - * first value change, cleared when the gesture finishes or the interacting slider - * leaves composition mid-drag). Useful for deferring expensive work until the - * interaction ends. Programmatic `update*` calls do not affect this flag. + * True while the user is actively dragging one of the library's sliders or planes β€” + * [codes.side.colorpicker.ui.HslPlane], [codes.side.colorpicker.ui.OkhslPlane] or + * [codes.side.colorpicker.ui.OkhsvPlane] β€” set on the first value change, cleared when + * the gesture finishes or the interacting component leaves composition mid-drag. Useful + * for deferring expensive work until the interaction ends. Programmatic `update*` calls + * do not affect this flag. */ public var isInteracting: Boolean by mutableStateOf(false) internal set // ---- 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 +105,28 @@ 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. + * + * Derived views route through sRGB, so an origin the display cannot show arrives + * gamut-mapped. Oklab and OkLCh are the one pair where that costs something β€” they + * describe the same color exactly β€” and reading either from the other reports the + * mapped chroma rather than the origin's. + */ + public val oklabColor: OklabColor get() = oklabDerived.value + + /** + * The current color as OkLCh; a derived conversion unless OkLCh is the origin space. + * Out-of-gamut origins arrive gamut-mapped, as for [oklabColor]. + */ + 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 +243,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 d52c1da8..b6e3113a 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/theme/ColorPickerDefaults.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/theme/ColorPickerDefaults.kt index 0d7c6463..c89f8c37 100644 --- a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/theme/ColorPickerDefaults.kt +++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/theme/ColorPickerDefaults.kt @@ -32,6 +32,12 @@ public object ColorPickerDefaults { /** Clearance left between the thumb and each end of the track. */ public val ThumbTrackGap: Dp = 6.dp + /** Size a [codes.side.colorpicker.ui.ColorPlane] falls back to. */ + public val PlaneMinSize: Dp = 200.dp + + /** Diameter of a [codes.side.colorpicker.ui.ColorPlane]'s position indicator. */ + public val PlaneThumbSize: Dp = 24.dp + // surfaceBright/surfaceDim keep visible checkerboard contrast in both // light and dark color schemes. /** @@ -56,8 +62,10 @@ public object ColorPickerDefaults { public fun shapes( trackShape: Shape = CircleShape, swatchShape: Shape = MaterialTheme.shapes.small, + planeShape: Shape = MaterialTheme.shapes.medium, ): ColorPickerShapes = ColorPickerShapes( trackShape = trackShape, swatchShape = swatchShape, + planeShape = planeShape, ) } diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/theme/ColorPickerShapes.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/theme/ColorPickerShapes.kt index a7c1aabf..e35dc40b 100644 --- a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/theme/ColorPickerShapes.kt +++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/theme/ColorPickerShapes.kt @@ -9,9 +9,12 @@ import androidx.compose.ui.graphics.Shape * * @property trackShape outer shape of a slider's gradient track. * @property swatchShape shape of a [codes.side.colorpicker.ui.ColorSwatch]. + * @property planeShape shape of a [codes.side.colorpicker.ui.ColorPlane]'s + * surface. The position indicator is drawn outside it, so it stays whole at the edges. */ @Immutable public data class ColorPickerShapes( public val trackShape: Shape, public val swatchShape: Shape, + public val planeShape: Shape, ) diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/ColorPlane.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/ColorPlane.kt new file mode 100644 index 00000000..2aba33eb --- /dev/null +++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/ColorPlane.kt @@ -0,0 +1,175 @@ +package codes.side.colorpicker.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.drag +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import codes.side.colorpicker.theme.ColorPickerDefaults +import codes.side.colorpicker.theme.ColorPickerShapes +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +/** The fraction across a plane [width] pixels wide that a pointer at [x] sits at. */ +internal fun planeXFraction(x: Float, width: Int): Float = + if (width <= 0) 0f else (x / width).coerceIn(0f, 1f) + +/** The fraction up a plane [height] pixels tall that a pointer at [y] sits at. */ +internal fun planeYFraction(y: Float, height: Int): Float = + if (height <= 0) 0.5f else (1f - y / height).coerceIn(0f, 1f) + +/** + * Two-dimensional picker over a pair of colour channels, both in `0..1`. + * + * [xValue] runs left to right and [yValue] bottom to top, so `yValue = 1f` is the top edge. + * Dragging reports both at once, which is what lets a caller write two channels in a single + * update and leave the rest of the colour alone. + * + * Unlike the sliders, the surface is not mirrored in right-to-left layouts. It is a map of a + * colour space rather than a progress control, and mirroring it would make the x channel grow + * leftwards here while it still grows rightwards on the hue slider beside it. + * + * @param surface paints the field, filling the whole drawing area. It is drawn under the + * position indicator and clipped to [ColorPickerShapes.planeShape]. + * @param semanticLabel accessibility description of the surface; pass a localized string to + * replace the English default, or `null` to omit. + * @param semanticValueText accessibility announcement of the current pair of values. + * @param thumb optional replacement for the position indicator, receiving the surface's + * [InteractionSource] so it can react to being dragged. The plane centres whatever it is + * given on the current pair of values at whatever size that composable measures to, and + * draws it outside the clipped surface so that it stays whole at the edges; the composable + * only has to draw itself. + */ +@Composable +public fun ColorPlane( + xValue: Float, + yValue: Float, + onValueChange: (x: Float, y: Float) -> Unit, + surface: DrawScope.() -> Unit, + modifier: Modifier = Modifier, + onValueChangeFinished: (() -> Unit)? = null, + semanticLabel: String? = null, + semanticValueText: String? = null, + shapes: ColorPickerShapes = ColorPickerDefaults.shapes(), + thumb: (@Composable (InteractionSource) -> Unit)? = null, +) { + val interactionSource = remember { MutableInteractionSource() } + val scope = rememberCoroutineScope() + // The gesture handler outlives any one composition, so it reads the callbacks and the + // painter through these rather than capturing the values it was built with. + val currentOnValueChange by rememberUpdatedState(onValueChange) + val currentOnFinished by rememberUpdatedState(onValueChangeFinished) + val currentSurface by rememberUpdatedState(surface) + + Layout( + content = { + // The shape clips the surface alone. Clipping the whole plane would take the + // indicator with it, and at a corner the rounding leaves almost none of the + // ring behind β€” the one place a picker has to show where the colour came from. + Box( + modifier = Modifier + .clip(shapes.planeShape) + .drawBehind { currentSurface() }, + ) + // A bare Box, so the indicator measures to its own size. Giving the wrapper a + // size instead squeezes a larger custom thumb into the default diameter and + // strands a smaller one in the corner of it, off the value it marks. + Box { if (thumb != null) thumb(interactionSource) else PlaneThumb() } + }, + modifier = modifier + .defaultMinSize(ColorPickerDefaults.PlaneMinSize, ColorPickerDefaults.PlaneMinSize) + .semantics { + semanticLabel?.let { contentDescription = it } + semanticValueText?.let { stateDescription = it } + } + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val press = DragInteraction.Start() + scope.launch { interactionSource.emit(press) } + currentOnValueChange( + planeXFraction(down.position.x, size.width), + planeYFraction(down.position.y, size.height), + ) + down.consume() + + val completed = drag(down.id) { change -> + currentOnValueChange( + planeXFraction(change.position.x, size.width), + planeYFraction(change.position.y, size.height), + ) + change.consume() + } + + currentOnFinished?.invoke() + scope.launch { + interactionSource.emit( + if (completed) DragInteraction.Stop(press) else DragInteraction.Cancel(press), + ) + } + } + }, + ) { measurables, constraints -> + // defaultMinSize has already raised a loose minimum to PlaneMinSize, so the minimum + // is the surface's size either way: the exact size a caller asked for, or the + // fallback when the parent passes unbounded space down. + val width = constraints.minWidth + val height = constraints.minHeight + val surfacePlaceable = measurables[0].measure(Constraints.fixed(width, height)) + val indicator = measurables[1].measure(Constraints(maxWidth = width, maxHeight = height)) + + layout(width, height) { + surfacePlaceable.place(0, 0) + // place, not placeRelative: the surface, the field and the pointer mapping are + // all unmirrored, so an indicator that flipped in right-to-left layouts would + // sit on the opposite colour from the one it points at. + indicator.place( + x = (xValue * width - indicator.width / 2f).roundToInt(), + y = ((1f - yValue) * height - indicator.height / 2f).roundToInt(), + ) + } + } +} + +/** Default position indicator, sized [ColorPickerDefaults.PlaneThumbSize]. */ +@Composable +private fun PlaneThumb() { + Canvas(Modifier.size(ColorPickerDefaults.PlaneThumbSize)) { + val radius = size.minDimension / 2f - 2.dp.toPx() + // A dark halo under a white ring keeps the indicator readable at both + // ends of the surface, where a single-colour ring vanishes. + drawCircle( + color = Color.Black.copy(alpha = 0.35f), + radius = radius, + style = Stroke(width = 4.dp.toPx()), + ) + drawCircle( + color = Color.White, + radius = radius, + style = Stroke(width = 2.dp.toPx()), + ) + } +} diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/HslPlane.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/HslPlane.kt new file mode 100644 index 00000000..f53a6613 --- /dev/null +++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/HslPlane.kt @@ -0,0 +1,76 @@ +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.Brush +import androidx.compose.ui.graphics.Color +import codes.side.colorpicker.conversion.toComposeColor +import codes.side.colorpicker.model.HslColor +import codes.side.colorpicker.state.ColorPickerState +import codes.side.colorpicker.theme.ColorPickerDefaults +import codes.side.colorpicker.theme.ColorPickerShapes + +/** + * Two-dimensional saturation and lightness picker for the hue currently held by [state]. + * + * Saturation runs left to right and lightness bottom to top, so the surface reads as white + * along the top edge, black along the bottom, grey down the left, and the pure hue at the + * right of the middle row. Dragging writes both channels at once; hue and alpha are left + * alone, so a [HueSlider] and an [AlphaSlider] compose with this to make a full picker. + * + * @param semanticLabel accessibility description of the surface; pass a localized string to + * replace the English default, or `null` to omit. + * @param semanticValueText accessibility announcement of the current pair of values. + * @param thumb optional replacement for the position indicator; see [ColorPlane]. + */ +@Composable +public fun HslPlane( + state: ColorPickerState, + modifier: Modifier = Modifier, + semanticLabel: String? = "Saturation and lightness", + semanticValueText: String? = + "${state.hslColor.intSaturation}% saturation, ${state.hslColor.intLightness}% lightness", + shapes: ColorPickerShapes = ColorPickerDefaults.shapes(), + thumb: (@Composable (InteractionSource) -> Unit)? = null, +) { + val hsl = state.hslColor + val interaction = remember(state) { SliderInteractionGuard(state) } + + // The horizontal ramp is the hue at mid lightness, from fully desaturated to pure. The + // vertical overlay then takes it to white and to black. That pair reproduces HSL + // exactly rather than approximately: for any hue and saturation, the colour at + // lightness L is the mid-lightness colour blended with white by 2L-1 above the middle + // and with black by 1-2L below it, which is what alpha compositing the overlay does. + val ramp = remember(hsl.hue) { + Brush.horizontalGradient( + listOf( + HslColor(hue = hsl.hue, saturation = 0f, lightness = 0.5f).toComposeColor(), + HslColor(hue = hsl.hue, saturation = 1f, lightness = 0.5f).toComposeColor(), + ), + ) + } + val shading = remember { + Brush.verticalGradient(listOf(Color.White, Color.Transparent, Color.Black)) + } + + ColorPlane( + xValue = hsl.saturation, + yValue = hsl.lightness, + onValueChange = { x, y -> + interaction.begin() + state.updateFromHsl(state.hslColor.copy(saturation = x, lightness = y)) + }, + surface = { + drawRect(ramp) + drawRect(shading) + }, + modifier = modifier, + onValueChangeFinished = { interaction.end() }, + semanticLabel = semanticLabel, + semanticValueText = semanticValueText, + shapes = shapes, + thumb = thumb, + ) +} diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/LabSliders.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/LabSliders.kt index 41436a25..da2d8469 100644 --- a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/LabSliders.kt +++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/LabSliders.kt @@ -4,6 +4,7 @@ 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.LabColor @@ -12,22 +13,47 @@ 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 +/** + * Stops for the L*, a* and b* tracks. + * + * HSL's hue track has breakpoints to land on and the Ok* tracks stay inside the gamut, + * but a LAB track has neither going for it: over half of an a* or b* sweep is outside + * sRGB, and out there the mapped color runs along the gamut surface, turning a corner at + * every edge of the RGB cube it crosses. Stops cannot follow a corner, so the error + * flattens out instead of falling β€” the Independent a* track measures 36 of 255 at 10 + * stops, 7.4 at 64 and still 5.5 at 128. 64 is where the return goes flat, and it is what + * the Ok* tracks already use. + */ +internal const val LAB_CHANNEL_STOPS = 64 + +/** The a* or b* value a track reaches at [fraction] of its travel; both run `-128..127`. */ +internal fun labAxisFromFraction(fraction: Float): Float = + (-128f + fraction * 255f).coerceIn(-128f, 127f) + +internal fun buildLabLightnessGradient(a: Float, b: Float): ImmutableList = + (0..LAB_CHANNEL_STOPS).map { i -> + LabColor(l = i * 100f / LAB_CHANNEL_STOPS, a = a, b = b).toComposeColor() + }.toImmutableList() + +internal fun buildLabAGradient(l: Float, b: Float): ImmutableList = + (0..LAB_CHANNEL_STOPS).map { i -> + LabColor(l = l, a = labAxisFromFraction(i.toFloat() / LAB_CHANNEL_STOPS), b = b).toComposeColor() + }.toImmutableList() + +internal fun buildLabBGradient(l: Float, a: Float): ImmutableList = + (0..LAB_CHANNEL_STOPS).map { i -> + LabColor(l = l, a = a, b = labAxisFromFraction(i.toFloat() / LAB_CHANNEL_STOPS)).toComposeColor() + }.toImmutableList() + // Pre-computed independent (a=0, b=0) gradients -private val LightnessIndependentGradient = (0..10).map { i -> - LabColor(l = i * 10f, a = 0f, b = 0f).toComposeColor() -}.toImmutableList() +private val LightnessIndependentGradient = buildLabLightnessGradient(a = 0f, b = 0f) -private val AAxisIndependentGradient = (0..10).map { i -> - val a = -128f + (i * 255f / 10f) - LabColor(l = 50f, a = a.coerceIn(-128f, 127f), b = 0f).toComposeColor() -}.toImmutableList() +private val AAxisIndependentGradient = buildLabAGradient(l = 50f, b = 0f) -private val BAxisIndependentGradient = (0..10).map { i -> - val b = -128f + (i * 255f / 10f) - LabColor(l = 50f, a = 0f, b = b.coerceIn(-128f, 127f)).toComposeColor() -}.toImmutableList() +private val BAxisIndependentGradient = buildLabBGradient(l = 50f, a = 0f) /** * Slider for the CIELAB lightness (L*) channel of [state], in `0..100`. @@ -58,9 +84,7 @@ public fun LightnessLabSlider( val gradientColors = remember(lab.a, lab.b, coloringMode) { when (coloringMode) { ColoringMode.Independent -> LightnessIndependentGradient - ColoringMode.Contextual -> (0..10).map { i -> - LabColor(l = i * 10f, a = lab.a, b = lab.b).toComposeColor() - }.toImmutableList() + ColoringMode.Contextual -> buildLabLightnessGradient(a = lab.a, b = lab.b) } } val thumbColor = remember(lab, coloringMode) { @@ -122,10 +146,7 @@ public fun LabASlider( val gradientColors = remember(lab.l, lab.b, coloringMode) { when (coloringMode) { ColoringMode.Independent -> AAxisIndependentGradient - ColoringMode.Contextual -> (0..10).map { i -> - val a = -128f + (i * 255f / 10f) - LabColor(l = lab.l, a = a.coerceIn(-128f, 127f), b = lab.b).toComposeColor() - }.toImmutableList() + ColoringMode.Contextual -> buildLabAGradient(l = lab.l, b = lab.b) } } val thumbColor = remember(lab, coloringMode) { @@ -187,10 +208,7 @@ public fun LabBSlider( val gradientColors = remember(lab.l, lab.a, coloringMode) { when (coloringMode) { ColoringMode.Independent -> BAxisIndependentGradient - ColoringMode.Contextual -> (0..10).map { i -> - val b = -128f + (i * 255f / 10f) - LabColor(l = lab.l, a = lab.a, b = b.coerceIn(-128f, 127f)).toComposeColor() - }.toImmutableList() + ColoringMode.Contextual -> buildLabBGradient(l = lab.l, a = lab.a) } } val thumbColor = remember(lab, coloringMode) { 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 00000000..c9196751 --- /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/OkhslPlane.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/OkhslPlane.kt new file mode 100644 index 00000000..3e9ac4ab --- /dev/null +++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/OkhslPlane.kt @@ -0,0 +1,72 @@ +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 codes.side.colorpicker.conversion.okhslRowFiller +import codes.side.colorpicker.conversion.toComposeColor +import codes.side.colorpicker.state.ColorPickerState +import codes.side.colorpicker.theme.ColorPickerDefaults +import codes.side.colorpicker.theme.ColorPickerShapes + +/** + * Two-dimensional saturation and lightness picker for the hue currently held by [state], in + * Okhsl. + * + * The perceptual counterpart to [HslPlane], and the arrangement Okhsl was designed for. + * Lightness is perceived lightness, so a row of this surface looks equally light all the way + * across, and saturation is measured against the display gamut, so the right edge is the most + * colourful the hue can reach at that lightness rather than a region that runs off what the + * screen can show. Dragging writes both channels at once; hue and alpha are left alone, so an + * [OkhslHueSlider] and an [AlphaSlider] compose with this to make a full picker. + * + * The field is sampled rather than interpolated from a pair of gradients, because Okhsl is + * linear in neither axis. What survives sampling is a seam along the lightness where the + * gamut turns its corner, worst about 35 of 255 over roughly an eighth of a percent of the + * surface; see the row count this plane is rasterized at. The hue slider makes a related + * trade for the same reason: its independent track is drawn at a fixed saturation, off the + * gamut boundary. + * + * @param semanticLabel accessibility description of the surface; pass a localized string to + * replace the English default, or `null` to omit. + * @param semanticValueText accessibility announcement of the current pair of values. + * @param thumb optional replacement for the position indicator; see [ColorPlane]. + */ +@Composable +public fun OkhslPlane( + state: ColorPickerState, + modifier: Modifier = Modifier, + semanticLabel: String? = "Saturation and lightness", + semanticValueText: String? = + "${state.okhslColor.intSaturation}% saturation, ${state.okhslColor.intLightness}% lightness", + shapes: ColorPickerShapes = ColorPickerDefaults.shapes(), + thumb: (@Composable (InteractionSource) -> Unit)? = null, +) { + val okhsl = state.okhslColor + val interaction = remember(state) { SliderInteractionGuard(state) } + val field = remember(okhsl.hue) { okhslRowFiller(okhsl.hue) } + val bitmap = rememberPlaneBitmap( + key = okhsl.hue, + width = OK_PLANE_COLUMNS, + height = OKHSL_PLANE_ROWS, + fillRow = field, + ) + + ColorPlane( + xValue = okhsl.saturation, + yValue = okhsl.lightness, + onValueChange = { x, y -> + interaction.begin() + state.updateFromOkhsl(state.okhslColor.copy(saturation = x, lightness = y)) + }, + surface = { drawPlaneBitmap(bitmap) }, + modifier = modifier, + onValueChangeFinished = { interaction.end() }, + semanticLabel = semanticLabel, + semanticValueText = semanticValueText, + shapes = shapes, + thumb = thumb, + ) +} 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 00000000..856a5764 --- /dev/null +++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/OkhslSliders.kt @@ -0,0 +1,266 @@ +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 every Independent Ok* 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 two steps of 255 at [OK_HUE_STOPS]. The lightness and value + * tracks hold hue fixed and cross no corner, but take the same saturation so a picker's + * tracks agree with one another. + */ +internal const val INDEPENDENT_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. +internal const val OK_HUE_STOPS = 32 + +// The saturation, lightness and value tracks curve hardest near the gamut edge, where a +// 16-stop strip drifts 13 of 255 from the true sweep. 64 holds it under 3.5. +internal const val OK_CHANNEL_STOPS = 64 + +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 -> INDEPENDENT_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 + * just off the gamut boundary, at saturation 0.85; 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 -> INDEPENDENT_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 00000000..ee07213e --- /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/OkhsvPlane.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/OkhsvPlane.kt new file mode 100644 index 00000000..a979263b --- /dev/null +++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/OkhsvPlane.kt @@ -0,0 +1,70 @@ +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 codes.side.colorpicker.conversion.okhsvRowFiller +import codes.side.colorpicker.conversion.toComposeColor +import codes.side.colorpicker.state.ColorPickerState +import codes.side.colorpicker.theme.ColorPickerDefaults +import codes.side.colorpicker.theme.ColorPickerShapes + +/** + * Two-dimensional saturation and value picker for the hue currently held by [state], in + * Okhsv. + * + * The arrangement artists expect from a colour picker, with the perceptual behaviour Okhsv + * gives it: the top right is the most vivid form of the hue, pulling down the surface darkens + * toward black, and every point in the square is a colour the display can actually show. + * Prefer [OkhslPlane] when the middle of the vertical axis should be a mid tone rather than a + * bright one. Dragging writes both channels at once; hue and alpha are left alone, so an + * [OkhsvHueSlider] and an [AlphaSlider] compose with this to make a full picker. + * + * The field is sampled rather than interpolated from a pair of gradients, because Okhsv is + * linear in neither axis. Okhsv puts its cusp on the corner of the square instead of running + * it through the middle, so it needs a quarter of the rows [OkhslPlane] does and lands at + * about 2.3 of 255; see the row count this plane is rasterized at. + * + * @param semanticLabel accessibility description of the surface; pass a localized string to + * replace the English default, or `null` to omit. + * @param semanticValueText accessibility announcement of the current pair of values. + * @param thumb optional replacement for the position indicator; see [ColorPlane]. + */ +@Composable +public fun OkhsvPlane( + state: ColorPickerState, + modifier: Modifier = Modifier, + semanticLabel: String? = "Saturation and value", + semanticValueText: String? = + "${state.okhsvColor.intSaturation}% saturation, ${state.okhsvColor.intValue}% value", + shapes: ColorPickerShapes = ColorPickerDefaults.shapes(), + thumb: (@Composable (InteractionSource) -> Unit)? = null, +) { + val okhsv = state.okhsvColor + val interaction = remember(state) { SliderInteractionGuard(state) } + val field = remember(okhsv.hue) { okhsvRowFiller(okhsv.hue) } + val bitmap = rememberPlaneBitmap( + key = okhsv.hue, + width = OK_PLANE_COLUMNS, + height = OKHSV_PLANE_ROWS, + fillRow = field, + ) + + ColorPlane( + xValue = okhsv.saturation, + yValue = okhsv.value, + onValueChange = { x, y -> + interaction.begin() + state.updateFromOkhsv(state.okhsvColor.copy(saturation = x, value = y)) + }, + surface = { drawPlaneBitmap(bitmap) }, + modifier = modifier, + onValueChangeFinished = { interaction.end() }, + semanticLabel = semanticLabel, + semanticValueText = semanticValueText, + shapes = shapes, + thumb = thumb, + ) +} 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 00000000..6bf9e052 --- /dev/null +++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/OkhsvSliders.kt @@ -0,0 +1,228 @@ +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 + +/** + * 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 -> INDEPENDENT_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 current + * hue at full value. + * + * @param coloringMode with [ColoringMode.Independent] (the default) the track is drawn + * just off the gamut boundary, at saturation 0.85; 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 -> INDEPENDENT_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/commonMain/kotlin/codes/side/colorpicker/ui/PlanePixels.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/PlanePixels.kt new file mode 100644 index 00000000..04b972a1 --- /dev/null +++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/PlanePixels.kt @@ -0,0 +1,13 @@ +package codes.side.colorpicker.ui + +import androidx.compose.ui.graphics.ImageBitmap + +/** + * Wraps [pixels] β€” packed `0xAARRGGBB`, row-major from the top β€” as an [ImageBitmap]. + * + * The only thing in the library a platform has to answer for itself. Filling a bitmap by + * drawing one rectangle per pixel costs a call into the toolkit per pixel, which on a plane + * is over sixteen thousand of them and, on the web, that many crossings out of Wasm. Every + * toolkit can take the whole array at once instead; none of them agree on how to ask. + */ +internal expect fun imageBitmapFromPixels(pixels: IntArray, width: Int, height: Int): ImageBitmap diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/PlaneRaster.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/PlaneRaster.kt new file mode 100644 index 00000000..685604b0 --- /dev/null +++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/PlaneRaster.kt @@ -0,0 +1,102 @@ +package codes.side.colorpicker.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize + +/** + * Columns every Ok* plane is rasterized at. + * + * The error a scaled bitmap leaves behind is a crease along the cusp lightness, where the + * sRGB gamut turns a corner. It runs along the x axis, so columns do not move it: at the + * 256 rows [OkhslPlane] ships at, 64, 128 and 256 columns all measure the same worst error, + * 35.10 of 255 β€” extra columns cost up to four times as much to rasterize for no accuracy + * gain. + */ +internal const val OK_PLANE_COLUMNS = 64 + +/** + * Rows an [OkhslPlane] is rasterized at. Rows are what cross the crease: 64 of them measure + * 78.30 of 255 at worst, 128 measure 39.23 β€” nearly halving it β€” and 256 measure 35.10, a + * much smaller further gain. Averaged over the full range that is still roughly 1.5x per + * doubling, well short of the 4x a smooth surface would give. + */ +internal const val OKHSL_PLANE_ROWS = 256 + +/** + * Rows an [OkhsvPlane] is rasterized at. Okhsv puts its cusp on the corner of the square + * instead of running it through the middle, so there is no crease to resolve and 64 rows + * already measure 2.28 of 255. + */ +internal const val OKHSV_PLANE_ROWS = 64 + +/** + * The axis value sample [index] of [count] stands for. + * + * Endpoint-aligned, so the first and last samples are exactly `0` and `1`. Sampling at texel + * centres instead leaves the outer half-texel to be clamped, which renders the plane's + * full-saturation edge at `0.992` β€” measured at 23.7 of 255 with 64 columns, right where + * the most colourful colours are picked. + */ +internal fun planeSample(index: Int, count: Int): Float = + if (count <= 1) 0f else index / (count - 1f) + +/** + * Rasterizes a plane's field. Row `0` is the top of the surface, where the y axis reads `1`. + * + * [fillRow] writes a whole row at a time rather than returning a colour per pixel. Handing + * back one value per pixel means an object and a call for each, and on Android that pair + * cost 9.6 ms of a 17.8 ms plane β€” more than the arithmetic they carried. A row also holds + * the hue and the lightness still, so whatever an Ok* conversion settles from those, cusp + * and chroma anchors included, is settled once for the row instead of 64 times. + * + * The x samples are the same for every row, so they are worked out once and passed in. + */ +internal fun buildPlaneBitmap( + width: Int, + height: Int, + fillRow: (y: Float, xs: FloatArray, pixels: IntArray, offset: Int) -> Unit, +): ImageBitmap { + val xs = FloatArray(width) { planeSample(it, width) } + val pixels = IntArray(width * height) + for (row in 0 until height) { + fillRow(1f - planeSample(row, height), xs, pixels, row * width) + } + return imageBitmapFromPixels(pixels, width, height) +} + +/** + * Draws [bitmap] stretched over the whole drawing area. + * + * A ShaderBrush will not do it: an ImageShader paints the bitmap at its own size and clamps + * outwards, so an 80-pixel box filled from a 4-pixel ramp comes back the ramp's last colour + * across almost all of it. Scaling needs a destination size, and the low filter quality is + * the bilinear read the grid sizes above are measured against. + */ +internal fun DrawScope.drawPlaneBitmap(bitmap: ImageBitmap) { + drawImage( + image = bitmap, + srcOffset = IntOffset.Zero, + srcSize = IntSize(bitmap.width, bitmap.height), + dstOffset = IntOffset.Zero, + dstSize = IntSize(size.width.toInt(), size.height.toInt()), + filterQuality = FilterQuality.Low, + ) +} + +/** + * Rasterizes once per change of [key] β€” the hue, for every caller here. Dragging inside a + * plane never changes it, so the common gesture rebuilds nothing. + */ +@Composable +internal fun rememberPlaneBitmap( + key: Any?, + width: Int, + height: Int, + fillRow: (y: Float, xs: FloatArray, pixels: IntArray, offset: Int) -> Unit, +): ImageBitmap = remember(key, width, height) { buildPlaneBitmap(width, height, fillRow) } diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/SliderInteractionGuard.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/SliderInteractionGuard.kt index 059b6fb4..f879e767 100644 --- a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/SliderInteractionGuard.kt +++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/SliderInteractionGuard.kt @@ -4,26 +4,27 @@ import androidx.compose.runtime.RememberObserver import codes.side.colorpicker.state.ColorPickerState /** - * Manages [ColorPickerState.isInteracting] for a single slider's drag gesture. + * Manages [ColorPickerState.isInteracting] for one component's drag gesture β€” a + * [ColorSlider], [HslPlane], [OkhslPlane] or [OkhsvPlane]. * - * M3 Slider does not invoke `onValueChangeFinished` when its node is removed from - * composition mid-gesture (the pointer-input coroutine is simply cancelled), which - * would leave the flag stuck at `true` on a state that outlives the slider (e.g. - * hoisted in a view model). Remembering this guard makes the flag self-healing: - * [onForgotten] clears it if β€” and only if β€” this slider set it and the gesture - * never finished. + * None of them report the end of a gesture when its node is removed from composition + * mid-drag: M3 Slider does not invoke `onValueChangeFinished`, and a raw pointer-input + * coroutine is simply cancelled. Either would leave the flag stuck at `true` on a state + * that outlives the component (e.g. hoisted in a view model). Remembering this guard makes + * the flag self-healing: [onForgotten] clears it if β€” and only if β€” this component set it + * and the gesture never finished. */ internal class SliderInteractionGuard(private val state: ColorPickerState) : RememberObserver { private var active = false - /** Call from `onValueChange`: marks the gesture active. */ + /** Call from `onValueChange`, or when a pointer goes down: marks the gesture active. */ fun begin() { active = true state.isInteracting = true } - /** Call from `onValueChangeFinished`: marks the gesture finished. */ + /** Call from `onValueChangeFinished`, or when the drag ends: marks the gesture finished. */ fun end() { active = false state.isInteracting = false diff --git a/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/LabConversionsTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/LabConversionsTest.kt index 4ad3df7c..4036c850 100644 --- a/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/LabConversionsTest.kt +++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/LabConversionsTest.kt @@ -61,7 +61,8 @@ class LabConversionsTest { @Test fun rgbWhiteToLab() { - // With XN/ZN matching the sRGB matrices' D65 white point, white maps to a*=0, b*=0 + // Each sRGB matrix row sums to its component of the D50 white, so white lands on + // the neutral axis exactly rather than near it. val rgb = RgbColor(1f, 1f, 1f) val lab = rgb.toLab() assertNear(100f, lab.l, tolerance = 0.01f, msg = "L") @@ -98,12 +99,54 @@ class LabConversionsTest { fun rgbRedToLab() { val rgb = RgbColor(1f, 0f, 0f) val lab = rgb.toLab() - // Red in Lab is approximately L=53.23, a=80.11, b=67.22 assertTrue(lab.l > 50f && lab.l < 56f, "L for red: ${lab.l}") assertTrue(lab.a > 75f, "a for red: ${lab.a}") assertTrue(lab.b > 60f, "b for red: ${lab.b}") } + // ---- Interchange with other tools ---- + + @Test + fun redMatchesTheLabValueOtherToolsReport() { + val lab = RgbColor(1f, 0f, 0f).toLab() + assertNear(54.29f, lab.l, tolerance = 0.05f, msg = "L") + assertNear(80.81f, lab.a, tolerance = 0.05f, msg = "a") + assertNear(69.89f, lab.b, tolerance = 0.05f, msg = "b") + } + + @Test + fun blueMatchesTheLabValueOtherToolsReport() { + val lab = RgbColor(0f, 0f, 1f).toLab() + assertNear(29.56f, lab.l, tolerance = 0.05f, msg = "L") + assertNear(68.28f, lab.a, tolerance = 0.05f, msg = "a") + assertNear(-112.02f, lab.b, tolerance = 0.05f, msg = "b") + } + + // ---- Colors sRGB cannot show ---- + + // The mapping holds Oklab's lightness, not CIELAB's, so L* arrives close rather than + // unchanged. Clipping each channel on its own moves it by 7.6 over the same rows. + private val mappedLightnessEps = 3.5f + + @Test + fun outOfGamutBlueKeepsItsLightness() { + // Every b* here is past the sRGB boundary at L* 50, and the further out it goes the + // more lightness channel-wise clipping invents. Giving up chroma instead is what + // keeps the swatch near the lightness the numbers claim. + for (b in -128..-80 step 4) { + val shown = LabColor(l = 50f, a = 0f, b = b.toFloat()).toRgb().toLab() + assertNear(50f, shown.l, tolerance = mappedLightnessEps, msg = "L* at b*=$b") + } + } + + @Test + fun outOfGamutGreenKeepsItsLightness() { + for (a in -128..-90 step 4) { + val shown = LabColor(l = 50f, a = a.toFloat(), b = 0f).toRgb().toLab() + assertNear(50f, shown.l, tolerance = mappedLightnessEps, msg = "L* at a*=$a") + } + } + // ---- Round-trip ---- @Test 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 00000000..7d647647 --- /dev/null +++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/conversion/OkConversionsTest.kt @@ -0,0 +1,366 @@ +package codes.side.colorpicker.conversion + +import codes.side.colorpicker.model.HslColor +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 1.5% + // 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 cube is 3.8 steps of 255 for Okhsl and 4.3 for Okhsv. 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 = HslColor(240f, 1f, 0.5f).toRgb().toOklab() + val yellow = 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/model/OkColorModelTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/model/OkColorModelTest.kt new file mode 100644 index 00000000..29eedc08 --- /dev/null +++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/model/OkColorModelTest.kt @@ -0,0 +1,234 @@ +package codes.side.colorpicker.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals + +class OkColorModelTest { + + // ---- OklabColor ---- + + @Test + fun oklabColorDefaultValues() { + val oklab = OklabColor() + assertEquals(0.5f, oklab.l) + assertEquals(0f, oklab.a) + assertEquals(0f, oklab.b) + assertEquals(1f, oklab.alpha) + } + + @Test + fun oklabColorRejectsInvalidValues() { + assertFailsWith { OklabColor(l = -0.01f) } + assertFailsWith { OklabColor(l = 1.01f) } + assertFailsWith { OklabColor(a = 0.41f) } + assertFailsWith { OklabColor(a = -0.41f) } + assertFailsWith { OklabColor(b = 0.41f) } + assertFailsWith { OklabColor(alpha = 1.01f) } + } + + @Test + fun oklabColorRejectsNan() { + assertFailsWith { OklabColor(l = Float.NaN) } + assertFailsWith { OklabColor(a = Float.NaN) } + assertFailsWith { OklabColor(b = Float.NaN) } + assertFailsWith { OklabColor(alpha = Float.NaN) } + } + + @Test + fun oklabIntAccessors() { + val oklab = OklabColor(l = 0.63f, a = 0.2f, b = -0.1f, alpha = 0.5f) + assertEquals(63, oklab.intL) + // a and b report a percentage of the -0.4..0.4 reference range, per CSS oklab(). + assertEquals(50, oklab.intA) + assertEquals(-25, oklab.intB) + assertEquals(128, oklab.intAlpha) + } + + @Test + fun oklabFromIntClampsInsteadOfThrowing() { + val oklab = OklabColor.fromInt(l = 150, a = 200, b = -200, alpha = 300) + assertEquals(1f, oklab.l) + assertEquals(0.4f, oklab.a) + assertEquals(-0.4f, oklab.b) + assertEquals(1f, oklab.alpha) + } + + @Test + fun oklabCopyCreatesNewInstance() { + val original = OklabColor(l = 0.4f, a = 0.1f, b = -0.1f) + val copy = original.copy(l = 0.8f) + assertNotEquals(original, copy) + assertEquals(0.8f, copy.l) + assertEquals(0.1f, copy.a) + } + + // ---- OklchColor ---- + + @Test + fun oklchColorDefaultValues() { + val oklch = OklchColor() + assertEquals(0.5f, oklch.l) + assertEquals(0f, oklch.chroma) + assertEquals(0f, oklch.hue) + assertEquals(1f, oklch.alpha) + } + + @Test + fun oklchColorRejectsInvalidValues() { + assertFailsWith { OklchColor(l = 1.01f) } + assertFailsWith { OklchColor(chroma = -0.01f) } + assertFailsWith { OklchColor(chroma = 0.41f) } + assertFailsWith { OklchColor(hue = -0.1f) } + assertFailsWith { OklchColor(hue = 360.1f) } + assertFailsWith { OklchColor(alpha = -0.01f) } + } + + @Test + fun oklchNormalizesHue360ToZero() { + assertEquals(0f, OklchColor(hue = 360f).hue) + assertEquals(OklchColor(hue = 0f), OklchColor(hue = 360f)) + } + + @Test + fun oklchIntAccessors() { + val oklch = OklchColor(l = 0.63f, chroma = 0.2f, hue = 29.6f, alpha = 1f) + assertEquals(63, oklch.intL) + assertEquals(50, oklch.intChroma) + assertEquals(30, oklch.intHue) + assertEquals(255, oklch.intAlpha) + } + + @Test + fun oklchFromIntClampsInsteadOfThrowing() { + val oklch = OklchColor.fromInt(l = -10, chroma = 500, hue = 400, alpha = -1) + assertEquals(0f, oklch.l) + assertEquals(0.4f, oklch.chroma) + assertEquals(0f, oklch.hue) // 400 clamps to 360, which normalizes to 0 + assertEquals(0f, oklch.alpha) + } + + // ---- OkhslColor ---- + + @Test + fun okhslColorDefaultValues() { + val okhsl = OkhslColor() + assertEquals(0f, okhsl.hue) + assertEquals(1f, okhsl.saturation) + assertEquals(0.5f, okhsl.lightness) + assertEquals(1f, okhsl.alpha) + } + + @Test + fun okhslColorRejectsInvalidValues() { + assertFailsWith { OkhslColor(hue = -0.1f) } + assertFailsWith { OkhslColor(hue = 360.1f) } + assertFailsWith { OkhslColor(saturation = 1.01f) } + assertFailsWith { OkhslColor(lightness = -0.01f) } + assertFailsWith { OkhslColor(alpha = 1.01f) } + } + + @Test + fun okhslColorRejectsNan() { + assertFailsWith { OkhslColor(hue = Float.NaN) } + assertFailsWith { OkhslColor(saturation = Float.NaN) } + assertFailsWith { OkhslColor(lightness = Float.NaN) } + } + + @Test + fun okhslNormalizesHue360ToZero() { + assertEquals(0f, OkhslColor(hue = 360f).hue) + assertEquals(OkhslColor(hue = 0f), OkhslColor(hue = 360f)) + } + + @Test + fun okhslIntAccessors() { + val okhsl = OkhslColor(hue = 180f, saturation = 0.5f, lightness = 0.25f, alpha = 0.5f) + assertEquals(180, okhsl.intHue) + assertEquals(50, okhsl.intSaturation) + assertEquals(25, okhsl.intLightness) + assertEquals(128, okhsl.intAlpha) + } + + @Test + fun okhslFromIntClampsInsteadOfThrowing() { + val okhsl = OkhslColor.fromInt(hue = -20, saturation = 150, lightness = -5, alpha = 999) + assertEquals(0f, okhsl.hue) + assertEquals(1f, okhsl.saturation) + assertEquals(0f, okhsl.lightness) + assertEquals(1f, okhsl.alpha) + } + + // ---- OkhsvColor ---- + + @Test + fun okhsvColorDefaultValues() { + val okhsv = OkhsvColor() + assertEquals(0f, okhsv.hue) + assertEquals(1f, okhsv.saturation) + assertEquals(1f, okhsv.value) + assertEquals(1f, okhsv.alpha) + } + + @Test + fun okhsvColorRejectsInvalidValues() { + assertFailsWith { OkhsvColor(hue = 360.1f) } + assertFailsWith { OkhsvColor(saturation = -0.01f) } + assertFailsWith { OkhsvColor(value = 1.01f) } + assertFailsWith { OkhsvColor(alpha = -0.01f) } + } + + @Test + fun okhsvNormalizesHue360ToZero() { + assertEquals(0f, OkhsvColor(hue = 360f).hue) + assertEquals(OkhsvColor(hue = 0f), OkhsvColor(hue = 360f)) + } + + @Test + fun okhsvIntAccessors() { + val okhsv = OkhsvColor(hue = 180f, saturation = 0.5f, value = 0.25f, alpha = 0.5f) + assertEquals(180, okhsv.intHue) + assertEquals(50, okhsv.intSaturation) + assertEquals(25, okhsv.intValue) + assertEquals(128, okhsv.intAlpha) + } + + @Test + fun okhsvFromIntClampsInsteadOfThrowing() { + val okhsv = OkhsvColor.fromInt(hue = 400, saturation = -50, value = 150, alpha = -1) + assertEquals(0f, okhsv.hue) // 400 clamps to 360, which normalizes to 0 + assertEquals(0f, okhsv.saturation) + assertEquals(1f, okhsv.value) + assertEquals(0f, okhsv.alpha) + } + + // ---- Shared contracts ---- + + @Test + fun negativeZeroDoesNotSplitEquality() { + assertEquals(OklabColor(a = 0f), OklabColor(a = -0f)) + assertEquals(OklabColor(a = 0f).hashCode(), OklabColor(a = -0f).hashCode()) + assertEquals(OkhslColor(hue = 0f), OkhslColor(hue = -0f)) + assertEquals(OkhsvColor(hue = 0f), OkhsvColor(hue = -0f)) + assertEquals(OklchColor(hue = 0f), OklchColor(hue = -0f)) + } + + @Test + fun blackAndWhiteConstants() { + assertEquals(OklabColor(l = 0f, a = 0f, b = 0f), OklabColor.Black) + assertEquals(OklabColor(l = 1f, a = 0f, b = 0f), OklabColor.White) + assertEquals(OklchColor(l = 0f, chroma = 0f, hue = 0f), OklchColor.Black) + assertEquals(OklchColor(l = 1f, chroma = 0f, hue = 0f), OklchColor.White) + assertEquals(OkhslColor(hue = 0f, saturation = 0f, lightness = 0f), OkhslColor.Black) + assertEquals(OkhslColor(hue = 0f, saturation = 0f, lightness = 1f), OkhslColor.White) + assertEquals(OkhsvColor(hue = 0f, saturation = 0f, value = 0f), OkhsvColor.Black) + assertEquals(OkhsvColor(hue = 0f, saturation = 0f, value = 1f), OkhsvColor.White) + } + + @Test + fun equalsRejectsOtherSpacesWithTheSameChannels() { + assertNotEquals(OkhslColor(0f, 1f, 0.5f), OkhsvColor(0f, 1f, 0.5f)) + assertNotEquals(OklabColor(0.5f, 0f, 0f), OklchColor(0.5f, 0f, 0f)) + } +} 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 00000000..77444dc8 --- /dev/null +++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/state/OkColorPickerStateTest.kt @@ -0,0 +1,224 @@ +package codes.side.colorpicker.state + +import androidx.compose.runtime.saveable.SaverScope +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 +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), + 2f to CmykColor(0.1f, 0.2f, 0.3f, 0.4f), + 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") + // CMYK spends c4 on alpha where the others leave it unused, so the key alone + // is not enough: the channel layout behind it has to be unchanged too. + assertEquals(color, ColorPickerStateSaver.restore(saved)?.pickerColor, "restored $color") + } + } +} diff --git a/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/ui/LabGradientTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/ui/LabGradientTest.kt new file mode 100644 index 00000000..4f8cb46e --- /dev/null +++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/ui/LabGradientTest.kt @@ -0,0 +1,129 @@ +package codes.side.colorpicker.ui + +import androidx.compose.ui.graphics.Color +import codes.side.colorpicker.conversion.toComposeColor +import codes.side.colorpicker.model.LabColor +import kotlin.math.abs +import kotlin.math.roundToInt +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * The LAB tracks are drawn by interpolating between evenly spaced stops while the + * Contextual thumb above them is painted with the true color, so the two disagree by + * whatever the strip cannot follow. + * + * These are budgets, not correctness bounds. A LAB track spends over half its travel + * outside sRGB, where the mapped color runs along the gamut surface and turns a corner at + * every edge of the RGB cube it crosses; a linear strip cuts those corners at any stop + * count. The figures below are what [LAB_CHANNEL_STOPS] measures, rounded up β€” they exist + * to catch a regression, not to certify the track is accurate. + */ +class LabGradientTest { + + /** What [buildLabAGradient] and friends hold to with a fixed context. */ + private val independentBudget = 12f + + /** The contextual tracks, over the contexts a picker actually reaches. */ + private val contextualBudget = 26f + + /** What `Brush.horizontalGradient` draws at [fraction] for evenly spaced [stops]. */ + private fun sampleGradient(stops: List, fraction: Float): Color { + val segments = stops.size - 1 + val t = (fraction * segments).coerceIn(0f, segments.toFloat()) + val index = t.toInt().coerceAtMost(segments - 1) + val local = t - index + val a = stops[index] + val b = stops[index + 1] + return Color( + red = a.red + (b.red - a.red) * local, + green = a.green + (b.green - a.green) * local, + blue = a.blue + (b.blue - a.blue) * local, + ) + } + + /** The worst channel error of [stops] against [color], in steps of 255. */ + private fun worstChannelError(stops: List, color: (Float) -> LabColor): Float { + var worst = 0f + for (i in 0..400) { + val fraction = i / 400f + val drawn = sampleGradient(stops, fraction) + val actual = color(fraction).toComposeColor() + val error = maxOf( + abs(drawn.red - actual.red), + abs(drawn.green - actual.green), + abs(drawn.blue - actual.blue), + ) + if (error > worst) worst = error + } + return worst * 255f + } + + private fun assertWithin(budget: Float, track: String, context: String, error: Float) { + assertTrue( + error <= budget, + "$track track at $context is off by ${error.roundToInt()}/255, over the ${budget.roundToInt()} budget", + ) + } + + @Test + fun independentLightnessTrackMatchesTheTrueColor() { + val error = worstChannelError(buildLabLightnessGradient(a = 0f, b = 0f)) { fraction -> + LabColor(l = fraction * 100f, a = 0f, b = 0f) + } + assertWithin(independentBudget, "L*", "a=0 b=0", error) + } + + @Test + fun independentATrackMatchesTheTrueColor() { + val error = worstChannelError(buildLabAGradient(l = 50f, b = 0f)) { fraction -> + LabColor(l = 50f, a = labAxisFromFraction(fraction), b = 0f) + } + assertWithin(independentBudget, "a*", "L=50 b=0", error) + } + + @Test + fun independentBTrackMatchesTheTrueColor() { + val error = worstChannelError(buildLabBGradient(l = 50f, a = 0f)) { fraction -> + LabColor(l = 50f, a = 0f, b = labAxisFromFraction(fraction)) + } + assertWithin(independentBudget, "b*", "L=50 a=0", error) + } + + /** + * L* 0 and 100 are left out: the only in-gamut color on either row is black or white, + * and the mapping steps onto it across a just-noticeable difference, which no gradient + * can follow. + */ + @Test + fun contextualTracksMatchTheTrueColor() { + for (l in 20..80 step 10) { + for (v in -60..60 step 20) { + assertWithin( + contextualBudget, + "a*", + "L=$l b=$v", + worstChannelError(buildLabAGradient(l = l.toFloat(), b = v.toFloat())) { fraction -> + LabColor(l = l.toFloat(), a = labAxisFromFraction(fraction), b = v.toFloat()) + }, + ) + assertWithin( + contextualBudget, + "b*", + "L=$l a=$v", + worstChannelError(buildLabBGradient(l = l.toFloat(), a = v.toFloat())) { fraction -> + LabColor(l = l.toFloat(), a = v.toFloat(), b = labAxisFromFraction(fraction)) + }, + ) + assertWithin( + contextualBudget, + "L*", + "a=$v b=$v", + worstChannelError(buildLabLightnessGradient(a = v.toFloat(), b = v.toFloat())) { fraction -> + LabColor(l = fraction * 100f, a = v.toFloat(), b = v.toFloat()) + }, + ) + } + } + } +} diff --git a/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/ui/PlaneFieldTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/ui/PlaneFieldTest.kt new file mode 100644 index 00000000..f2e6e56f --- /dev/null +++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/ui/PlaneFieldTest.kt @@ -0,0 +1,98 @@ +package codes.side.colorpicker.ui + +import codes.side.colorpicker.conversion.delinearize +import codes.side.colorpicker.conversion.linearToSrgbByte +import codes.side.colorpicker.conversion.okhslRowFiller +import codes.side.colorpicker.conversion.okhsvRowFiller +import codes.side.colorpicker.conversion.toRgb +import codes.side.colorpicker.model.OkhslColor +import codes.side.colorpicker.model.OkhsvColor +import codes.side.colorpicker.model.RgbColor +import kotlin.math.roundToInt +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A row filler runs the conversion flat: no objects, no call per pixel, and the encoding + * reached by locating the value between byte boundaries rather than by a pow. All three are + * only sound if what it writes is still what the public conversion says, so every pixel is + * compared against it β€” as a packed value, exactly, since that is what reaches the screen. + */ +class PlaneFieldTest { + + /** The public conversion's answer for one coordinate, packed the way a plane packs it. */ + private fun expected(color: RgbColor): Int = + (0xFF shl 24) or + (byteOf(color.red) shl 16) or + (byteOf(color.green) shl 8) or + byteOf(color.blue) + + private fun byteOf(channel: Float): Int = (channel * 255f).roundToInt().coerceIn(0, 255) + + /** + * Within one step of 255, not exact: the public conversion hands back [Float] channels, + * so its own encoding rounds a value that has already lost precision, while the filler + * encodes the [Double] it computed. They part company only where that lost precision + * straddles a byte boundary, and there the filler is the one that is right. + */ + private fun assertRowsMatch( + rows: Int, + fillRow: (Float, FloatArray, IntArray, Int) -> Unit, + color: (x: Float, y: Float) -> RgbColor, + ) { + val xs = FloatArray(OK_PLANE_COLUMNS) { planeSample(it, OK_PLANE_COLUMNS) } + val pixels = IntArray(OK_PLANE_COLUMNS) + for (row in 0 until rows) { + val y = 1f - planeSample(row, rows) + fillRow(y, xs, pixels, 0) + for (column in xs.indices) { + val want = expected(color(xs[column], y)) + val got = pixels[column] + for (shift in listOf(16, 8, 0)) { + val off = ((want shr shift) and 0xFF) - ((got shr shift) and 0xFF) + assertTrue( + off in -1..1, + "channel at $shift is $off steps out at x=${xs[column]} y=$y", + ) + } + assertEquals(0xFF, (got ushr 24) and 0xFF, "alpha at x=${xs[column]} y=$y") + } + } + } + + @Test + fun theOkhslFillerWritesWhatTheConversionReturns() { + for (hue in 0 until 360 step 10) { + assertRowsMatch(OKHSL_PLANE_ROWS, okhslRowFiller(hue.toFloat())) { x, y -> + OkhslColor(hue = hue.toFloat(), saturation = x, lightness = y).toRgb() + } + } + } + + @Test + fun theOkhsvFillerWritesWhatTheConversionReturns() { + for (hue in 0 until 360 step 10) { + assertRowsMatch(OKHSV_PLANE_ROWS, okhsvRowFiller(hue.toFloat())) { x, y -> + OkhsvColor(hue = hue.toFloat(), saturation = x, value = y).toRgb() + } + } + } + + /** + * The encoder locates a linear value between the boundaries where each byte takes over + * rather than raising it to a power, which has to agree with the pow everywhere and not + * merely nearly everywhere β€” a single step out is a visible seam on a gradient. + */ + @Test + fun theEncoderAgreesWithTheCurveItReplaces() { + for (i in 0..200_000) { + val linear = i / 200_000.0 + assertEquals( + (delinearize(linear) * 255.0).roundToInt().coerceIn(0, 255), + linearToSrgbByte(linear), + "at linear $linear", + ) + } + } +} diff --git a/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/ui/PlaneMappingTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/ui/PlaneMappingTest.kt new file mode 100644 index 00000000..445897d9 --- /dev/null +++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/ui/PlaneMappingTest.kt @@ -0,0 +1,37 @@ +package codes.side.colorpicker.ui + +import kotlin.test.Test +import kotlin.test.assertEquals + +class PlaneMappingTest { + + @Test + fun theXAxisRunsLeftToRight() { + assertEquals(0f, planeXFraction(0f, 200)) + assertEquals(0.5f, planeXFraction(100f, 200)) + assertEquals(1f, planeXFraction(200f, 200)) + } + + @Test + fun theYAxisRunsBottomToTop() { + assertEquals(1f, planeYFraction(0f, 200), "the top edge reads 1") + assertEquals(0.5f, planeYFraction(100f, 200)) + assertEquals(0f, planeYFraction(200f, 200), "the bottom edge reads 0") + } + + @Test + fun pointersOutsideTheSurfaceAreClamped() { + // A drag that starts inside and leaves keeps reporting the nearest edge rather than + // running the channels past their range. + assertEquals(0f, planeXFraction(-40f, 200)) + assertEquals(1f, planeXFraction(260f, 200)) + assertEquals(1f, planeYFraction(-40f, 200)) + assertEquals(0f, planeYFraction(260f, 200)) + } + + @Test + fun anUnmeasuredSurfaceDoesNotDivideByZero() { + assertEquals(0f, planeXFraction(10f, 0)) + assertEquals(0.5f, planeYFraction(10f, 0)) + } +} diff --git a/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/ui/PlaneRasterTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/ui/PlaneRasterTest.kt new file mode 100644 index 00000000..89418774 --- /dev/null +++ b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/ui/PlaneRasterTest.kt @@ -0,0 +1,148 @@ +package codes.side.colorpicker.ui + +import codes.side.colorpicker.conversion.toRgb +import codes.side.colorpicker.model.OkhslColor +import codes.side.colorpicker.model.OkhsvColor +import codes.side.colorpicker.model.RgbColor +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PlaneRasterTest { + + @Test + fun samplesAreEndpointAligned() { + // Texel-centre sampling would put the last column at 0.992, and the plane's + // full-saturation edge β€” the whole reason to reach for Okhsl β€” would not be on it. + assertEquals(0f, planeSample(0, 64)) + assertEquals(1f, planeSample(63, 64)) + assertEquals(0.5f, planeSample(32, 65)) + } + + @Test + fun aSingleSampleDoesNotDivideByZero() { + assertEquals(0f, planeSample(0, 1)) + } + + /** + * Bilinear read of the grid, matching what drawImage does when it scales the bitmap up + * with FilterQuality.Low. + */ + private fun readGrid( + grid: Array>, + columns: Int, + rows: Int, + x: Float, + y: Float, + ): FloatArray { + val fx = (x * (columns - 1)).coerceIn(0f, columns - 1f) + val fy = ((1f - y) * (rows - 1)).coerceIn(0f, rows - 1f) + val x0 = fx.toInt().coerceAtMost(columns - 1) + val x1 = (x0 + 1).coerceAtMost(columns - 1) + val y0 = fy.toInt().coerceAtMost(rows - 1) + val y1 = (y0 + 1).coerceAtMost(rows - 1) + val tx = fx - x0 + val ty = fy - y0 + fun channel(pick: (RgbColor) -> Float): Float { + val top = pick(grid[y0][x0]) * (1 - tx) + pick(grid[y0][x1]) * tx + val bottom = pick(grid[y1][x0]) * (1 - tx) + pick(grid[y1][x1]) * tx + return top * (1 - ty) + bottom * ty + } + return floatArrayOf(channel { it.red }, channel { it.green }, channel { it.blue }) + } + + private fun grid( + columns: Int, + rows: Int, + color: (Float, Float) -> RgbColor, + ): Array> = Array(rows) { row -> + val y = 1f - planeSample(row, rows) + Array(columns) { column -> color(planeSample(column, columns), y) } + } + + /** + * Worst error at one hue; [color] has that hue already bound. + * + * 121 points per axis, plus every row midpoint explicitly. A row midpoint is where + * bilinear interpolation is least accurate, and a fixed 121-point y grid can straddle + * one without ever sampling near it. The error peaks in a narrow needle hugging the + * gamut cusp rather than spread across a broad region, so a sample that only ever falls + * near the grid's own rows understates the worst a plane actually shows between them. + */ + private fun worstError( + columns: Int, + rows: Int, + color: (Float, Float) -> RgbColor, + ): Double { + val g = grid(columns, rows, color) + val ySamples = buildList { + for (j in 0..120) add(j / 120f) + for (row in 0 until rows - 1) { + val top = 1f - planeSample(row, rows) + val bottom = 1f - planeSample(row + 1, rows) + add((top + bottom) / 2f) + } + } + var worst = 0.0 + for (i in 0..120) { + val x = i / 120f + for (y in ySamples) { + val truth = color(x, y) + val drawn = readGrid(g, columns, rows, x, y) + val d = maxOf( + abs(truth.red - drawn[0]).toDouble(), + abs(truth.green - drawn[1]).toDouble(), + abs(truth.blue - drawn[2]).toDouble(), + ) * 255.0 + if (d > worst) worst = d + } + } + return worst + } + + @Test + fun theOkhslGridHoldsItsBudget() { + // The residual sits on a crease at the cusp lightness, where the gamut boundary + // turns a corner and no amount of linear interpolation crosses it cleanly. It + // peaks in a needle about a degree wide around hue 110, where the sRGB channel + // that clips first changes, so the sweep is dense through there and coarser + // elsewhere β€” a uniform 20-degree step sails straight over the needle. + fun errorAt(hue: Float) = worstError(OK_PLANE_COLUMNS, OKHSL_PLANE_ROWS) { x, y -> + OkhslColor(hue = hue, saturation = x, lightness = y).toRgb() + } + var worst = 0.0 + var hue = 100f + while (hue <= 120f) { + worst = maxOf(worst, errorAt(hue)) + hue += 0.2f + } + hue = 0f + while (hue < 360f) { + if (hue !in 100f..120f) worst = maxOf(worst, errorAt(hue)) + hue += 20f + } + assertTrue(worst <= 42.0, "Okhsl plane grid drifted by $worst of 255") + } + + @Test + fun theOkhsvGridHoldsItsBudget() { + // Okhsv's cusp lands on the corner of the square rather than crossing it, so it has + // no crease and needs a fraction of the rows; its own peak sits around hue 264. + fun errorAt(hue: Float) = worstError(OK_PLANE_COLUMNS, OKHSV_PLANE_ROWS) { x, y -> + OkhsvColor(hue = hue, saturation = x, value = y).toRgb() + } + var worst = 0.0 + var hue = 250f + while (hue <= 280f) { + worst = maxOf(worst, errorAt(hue)) + hue += 0.2f + } + hue = 0f + while (hue < 360f) { + if (hue !in 250f..280f) worst = maxOf(worst, errorAt(hue)) + hue += 20f + } + assertTrue(worst <= 3.5, "Okhsv plane grid drifted by $worst of 255") + } +} diff --git a/colorpicker/src/jvmTest/kotlin/codes/side/colorpicker/ui/ComponentRenderingTest.kt b/colorpicker/src/jvmTest/kotlin/codes/side/colorpicker/ui/ComponentRenderingTest.kt index 12e6316e..57d1a781 100644 --- a/colorpicker/src/jvmTest/kotlin/codes/side/colorpicker/ui/ComponentRenderingTest.kt +++ b/colorpicker/src/jvmTest/kotlin/codes/side/colorpicker/ui/ComponentRenderingTest.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -13,17 +14,24 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toPixelMap import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertHeightIsAtLeast import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.v2.runComposeUiTest import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.width import codes.side.colorpicker.model.HslColor +import codes.side.colorpicker.model.OkhslColor +import codes.side.colorpicker.model.OkhsvColor import codes.side.colorpicker.state.ColorPickerState import codes.side.colorpicker.state.ColoringMode +import kotlin.math.abs import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -141,4 +149,244 @@ class ComponentRenderingTest { } assertTrue(sawMarker, "the custom thumb was not painted β€” the slot is ignored") } + + @Test + fun thePlaneRendersHslExactlyAtItsCorners() = runComposeUiTest { + // Hue 0 with the thumb parked at the top right, so no sample point sits under it. + setContent { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + HslPlane( + state = ColorPickerState( + HslColor(hue = 0f, saturation = 1f, lightness = 1f) + ), + modifier = Modifier.size(200.dp).testTag("plane"), + ) + } + } + + val px = onNodeWithTag("plane").captureToImage().toPixelMap() + fun at(fx: Float, fy: Float) = px[ + (fx * (px.width - 1)).toInt(), + (fy * (px.height - 1)).toInt(), + ] + + // A horizontal grey-to-hue ramp under a white-to-transparent-to-black overlay + // reproduces HSL only if every one of these lands. + val topRight = at(0.75f, 0.02f) + assertTrue( + topRight.red > 0.9f && topRight.green > 0.9f && topRight.blue > 0.9f, + "top edge should be white, was $topRight", + ) + val bottomRight = at(0.75f, 0.98f) + assertTrue( + bottomRight.red < 0.1f && bottomRight.green < 0.1f && bottomRight.blue < 0.1f, + "bottom edge should be black, was $bottomRight", + ) + val midRight = at(0.98f, 0.5f) + assertTrue( + midRight.red > 0.9f && midRight.green < 0.1f && midRight.blue < 0.1f, + "the pure hue belongs at full saturation, mid lightness, was $midRight", + ) + val midLeft = at(0.02f, 0.5f) + assertTrue( + midLeft.red in 0.4f..0.6f && midLeft.green in 0.4f..0.6f && midLeft.blue in 0.4f..0.6f, + "zero saturation at mid lightness is grey, was $midLeft", + ) + } + + /** Centre x of the indicator's white ring on the plane's middle row, in pixels. */ + private fun ComposeUiTest.indicatorCentreOnMiddleRow(): Float { + val px = onNodeWithTag("plane").captureToImage().toPixelMap() + val row = px.height / 2 + val ring = (0 until px.width).filter { + val c = px[it, row] + c.red > 0.95f && c.green > 0.95f && c.blue > 0.95f + } + assertTrue(ring.isNotEmpty(), "no indicator ring on the middle row") + return (ring.first() + ring.last()) / 2f + } + + @Test + fun thePlaneIndicatorStaysOnItsColorInRightToLeft() { + // The surface, the gradient and the pointer mapping are all unmirrored. An + // indicator placed relative to the layout direction lands on the opposite side, + // pointing at grey while the state reads full saturation. + fun centre(direction: LayoutDirection): Float { + var centre = 0f + runComposeUiTest { + setContent { + CompositionLocalProvider(LocalLayoutDirection provides direction) { + HslPlane( + state = ColorPickerState( + HslColor(hue = 240f, saturation = 0.9f, lightness = 0.5f) + ), + modifier = Modifier.size(200.dp).testTag("plane"), + ) + } + } + centre = indicatorCentreOnMiddleRow() + } + return centre + } + + val ltr = centre(LayoutDirection.Ltr) + val rtl = centre(LayoutDirection.Rtl) + assertTrue(ltr > 150f, "saturation 0.9 belongs near the right edge, was $ltr") + assertEquals(ltr, rtl, 1f, "the indicator moved between layout directions") + } + + @Test + fun aCustomPlaneThumbKeepsTheSizeItMeasuresTo() = runComposeUiTest { + // Bigger than PlaneThumbSize: a plane that squeezed the slot into its own default + // diameter would report 24dp here. + setContent { + HslPlane( + state = ColorPickerState(HslColor(hue = 0f, saturation = 0.5f, lightness = 0.5f)), + modifier = Modifier.size(200.dp).testTag("plane"), + thumb = { Box(Modifier.size(48.dp).testTag("thumb")) }, + ) + } + + val thumb = onNodeWithTag("thumb").getUnclippedBoundsInRoot() + assertEquals(48f, thumb.width.value, 0.5f, "the custom thumb was resized") + // Centred on saturation 0.5, lightness 0.5 of a 200dp plane. + assertEquals(100f, (thumb.left + thumb.right).value / 2f, 0.5f) + assertEquals(100f, (thumb.top + thumb.bottom).value / 2f, 0.5f) + } + + @Test + fun aSmallCustomPlaneThumbIsCentredOnItsValue() = runComposeUiTest { + setContent { + HslPlane( + state = ColorPickerState(HslColor(hue = 0f, saturation = 0.5f, lightness = 0.5f)), + modifier = Modifier.size(200.dp).testTag("plane"), + thumb = { Box(Modifier.size(8.dp).testTag("thumb")) }, + ) + } + + val thumb = onNodeWithTag("thumb").getUnclippedBoundsInRoot() + assertEquals(8f, thumb.width.value, 0.5f) + // A thumb positioned by the default diameter instead of its own would sit at 88dp. + assertEquals(96f, thumb.left.value, 0.5f, "the thumb is offset from the value it marks") + } + + @Test + fun thePlaneIndicatorSurvivesTheCorner() = runComposeUiTest { + // Zero saturation at full lightness parks the indicator on the top left corner, + // where a shape clip covering the whole plane rounds most of it away. A solid + // marker in a padded frame counts the half that hangs off the surface too. + setContent { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + // The tag goes before the padding, so the captured node is the whole + // frame and not just the content inside it. + Box(Modifier.testTag("frame").background(Color.White).padding(24.dp)) { + HslPlane( + state = ColorPickerState(HslColor(hue = 0f, saturation = 0f, lightness = 1f)), + modifier = Modifier.size(200.dp), + thumb = { Box(Modifier.size(24.dp).background(Color.Magenta)) }, + ) + } + } + } + + val px = onNodeWithTag("frame").captureToImage().toPixelMap() + var marker = 0 + for (x in 0 until 50) { + for (y in 0 until 50) { + val c = px[x, y] + if (c.red > 0.9f && c.blue > 0.9f && c.green < 0.1f) marker++ + } + } + // 24dp square, so 576px whole. Clipped to the surface it would be the quarter + // inside the corner, and the shape's rounding takes a bite out of even that. + assertTrue(marker > 500, "the indicator is clipped away in the corner, $marker px") + } + + @Test + fun theOkhslPlaneRendersItsCorners() = runComposeUiTest { + // Lightness 1 parks the indicator on the top edge, clear of the sample points below. + setContent { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + OkhslPlane( + state = ColorPickerState( + OkhslColor(hue = 29.2f, saturation = 1f, lightness = 1f), + ), + modifier = Modifier.size(200.dp).testTag("okhslPlane"), + ) + } + } + + val px = onNodeWithTag("okhslPlane").captureToImage().toPixelMap() + fun at(fx: Float, fy: Float) = px[ + (fx * (px.width - 1)).toInt(), + (fy * (px.height - 1)).toInt(), + ] + + val bottom = at(0.5f, 0.99f) + assertTrue( + bottom.red < 0.06f && bottom.green < 0.06f && bottom.blue < 0.06f, + "lightness 0 is black at every saturation, was $bottom", + ) + // Not literally the corner: the default plane shape clips a true (0.01, 0.01) + // sample away. Lightness 1 is white regardless of saturation by construction, so + // mid saturation clears the clip and exercises the same code path. + val top = at(0.5f, 0.01f) + assertTrue( + top.red > 0.94f && top.green > 0.94f && top.blue > 0.94f, + "lightness 1 is white at every saturation, was $top", + ) + val midLeft = at(0.01f, 0.5f) + assertTrue( + abs(midLeft.red - midLeft.green) < 0.02f && abs(midLeft.green - midLeft.blue) < 0.02f, + "zero saturation is grey at every lightness, was $midLeft", + ) + // Hue 29.2 at full saturation is where sRGB red lives. + val midRight = at(0.99f, 0.44f) + assertTrue( + midRight.red > midRight.green && midRight.green > midRight.blue, + "the right edge should be the most colourful the hue reaches, was $midRight", + ) + } + + @Test + fun theOkhsvPlaneRendersItsCorners() = runComposeUiTest { + // Value 1, saturation 0 parks the indicator in the top left, clear of the samples. + setContent { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + OkhsvPlane( + state = ColorPickerState( + OkhsvColor(hue = 29.2f, saturation = 0f, value = 1f), + ), + modifier = Modifier.size(200.dp).testTag("okhsvPlane"), + ) + } + } + + val px = onNodeWithTag("okhsvPlane").captureToImage().toPixelMap() + fun at(fx: Float, fy: Float) = px[ + (fx * (px.width - 1)).toInt(), + (fy * (px.height - 1)).toInt(), + ] + + val bottom = at(0.5f, 0.99f) + assertTrue( + bottom.red < 0.06f && bottom.green < 0.06f && bottom.blue < 0.06f, + "value 0 is black at every saturation, was $bottom", + ) + // Not literally the corner: the default plane shape clips a true (0.99, 0.01) + // sample away. This point is still high in both saturation and value, clear of + // the clip. Unlike Okhsl, the top row runs grey to vivid rather than white to + // white, so it should read as a clearly dominant, saturated red rather than a + // pale tint. + val topRight = at(0.92f, 0.08f) + assertTrue( + topRight.red > 0.85f && topRight.red - topRight.green > 0.4f && topRight.blue < 0.3f, + "high saturation and value is the most vivid form of the hue, was $topRight", + ) + val midLeft = at(0.01f, 0.5f) + assertTrue( + abs(midLeft.red - midLeft.green) < 0.02f && abs(midLeft.green - midLeft.blue) < 0.02f, + "zero saturation is grey at every value, was $midLeft", + ) + } } diff --git a/colorpicker/src/skikoMain/kotlin/codes/side/colorpicker/ui/PlanePixels.skiko.kt b/colorpicker/src/skikoMain/kotlin/codes/side/colorpicker/ui/PlanePixels.skiko.kt new file mode 100644 index 00000000..e1aaf72c --- /dev/null +++ b/colorpicker/src/skikoMain/kotlin/codes/side/colorpicker/ui/PlanePixels.skiko.kt @@ -0,0 +1,33 @@ +package codes.side.colorpicker.ui + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asComposeImageBitmap +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ColorType +import org.jetbrains.skia.ImageInfo + +internal actual fun imageBitmapFromPixels( + pixels: IntArray, + width: Int, + height: Int, +): ImageBitmap { + // Skia reads the buffer in byte order, so an 0xAARRGGBB int lands as BGRA little-endian. + val bytes = ByteArray(pixels.size * 4) + for (i in pixels.indices) { + val pixel = pixels[i] + val offset = i * 4 + bytes[offset] = (pixel and 0xFF).toByte() + bytes[offset + 1] = ((pixel shr 8) and 0xFF).toByte() + bytes[offset + 2] = ((pixel shr 16) and 0xFF).toByte() + bytes[offset + 3] = ((pixel shr 24) and 0xFF).toByte() + } + + val bitmap = Bitmap() + bitmap.allocPixels( + ImageInfo(width, height, ColorType.BGRA_8888, ColorAlphaType.UNPREMUL), + ) + bitmap.installPixels(bytes) + bitmap.setImmutable() + return bitmap.asComposeImageBitmap() +} diff --git a/docs/_config.yml b/docs/_config.yml index b7b3dd62..2bd6e778 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -1,4 +1,8 @@ plugins: - jemoji -theme: jekyll-theme-minimal \ No newline at end of file +theme: jekyll-theme-minimal + +# Internal design docs live in the repo, not on the site. +exclude: + - superpowers diff --git a/docs/images/lab-contextual.png b/docs/images/lab-contextual.png index b7867eea..f1ac07fd 100644 Binary files a/docs/images/lab-contextual.png and b/docs/images/lab-contextual.png differ diff --git a/docs/images/lab-independent.png b/docs/images/lab-independent.png index f7a4c2d5..df284097 100644 Binary files a/docs/images/lab-independent.png and b/docs/images/lab-independent.png differ diff --git a/docs/images/okhsl-contextual.png b/docs/images/okhsl-contextual.png new file mode 100644 index 00000000..386ee0a2 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 00000000..bf48b490 Binary files /dev/null and b/docs/images/okhsl-independent.png differ diff --git a/docs/images/okhsl-plane.png b/docs/images/okhsl-plane.png new file mode 100644 index 00000000..0cbf944b Binary files /dev/null and b/docs/images/okhsl-plane.png differ diff --git a/docs/images/okhsv-contextual.png b/docs/images/okhsv-contextual.png new file mode 100644 index 00000000..910f0f33 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 00000000..8960cdfa Binary files /dev/null and b/docs/images/okhsv-independent.png differ diff --git a/docs/images/okhsv-plane.png b/docs/images/okhsv-plane.png new file mode 100644 index 00000000..ea2049fe Binary files /dev/null and b/docs/images/okhsv-plane.png differ diff --git a/docs/images/saturation-lightness-plane.png b/docs/images/saturation-lightness-plane.png new file mode 100644 index 00000000..d66815db Binary files /dev/null and b/docs/images/saturation-lightness-plane.png differ diff --git a/docs/index.md b/docs/index.md index a74a9933..38bd1019 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,12 +9,15 @@ 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 LAB, 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` - Hex string parsing and formatting - Color picker dialog -- Accessibility semantics and RTL layout support +- Two-dimensional color planes β€” saturation paired with lightness or value β€” alongside the single-channel sliders +- Accessibility semantics, and RTL layout support everywhere but the planes, which map a color space rather than showing progress ## πŸ“¦ Setup @@ -40,15 +43,18 @@ Published targets: `android`, `jvm`, `iosArm64`, `iosSimulatorArm64`, `wasmJs`. ## 🎨 Gallery Every picker takes a `ColoringMode`. `Independent` shows each channel's full range; -`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) | +`Contextual` previews the resulting color at every slider position. `HslColorPicker`, +`OkhslColorPicker` and `OkhsvColorPicker` default to `Independent`; the RGB, CMYK and LAB +pickers 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) | +| **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: @@ -123,12 +129,91 @@ CmykColor.fromInt(cyan = 30, magenta = 60, yellow = 10, key = 20) ### LAB ```kotlin -val color = LabColor(l = 53.23f, a = 80.11f, b = 67.22f) +val color = LabColor(l = 54.29f, a = 80.81f, b = 69.89f) // l: [0, 100], a: [-128, 127], b: [-128, 127], alpha: [0, 1] -LabColor.fromInt(l = 53, a = 80, b = 67) +LabColor.fromInt(l = 54, a = 81, b = 70) +``` + +CIELAB with a D50 reference white, which is what CSS `lab()`, Photoshop and Compose's +`ColorSpaces.CieLab` all quote, so a value copied from any of them means here what it +meant there. About an eighth of the `a`/`b` box is inside sRGB: over half the travel on +either slider is outside it and renders as the nearest color the display can show. + +### 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 + +LAB, 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. + +The search runs in Oklab whatever space the color came from, as CSS specifies, so what +survives is Oklab's lightness and hue, not CIELAB's. L* lands within about 3 where clipping +each channel moved it by 7.6. CIELAB's own hue angle can still swing, and swings worst where +the two spaces disagree most: `lab(50% 0 -128)` sits at 270Β° in CIELAB but 221Β° in Oklab, so +holding the latter moves the former by 38Β°. That is CIELAB's blue axis being non-uniform +rather than the mapping misbehaving β€” Oklab's hue is the one that tracks what you see. + +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 +225,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,10 +275,61 @@ 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. +### Color Planes + +`HslPlane` picks both channels at once for the hue currently in `state`, +leaving hue and alpha untouched, so it composes with a `HueSlider` into a full picker. +`OkhslPlane` and `OkhsvPlane` are the same idea over Okhsl and Okhsv, composing with an +`OkhslHueSlider` or `OkhsvHueSlider` instead. + +| Model | Preview | +|---------------------------|--------------------------------------------------------------------------| +| **HSL**
`HslPlane` | ![Saturation and lightness plane](images/saturation-lightness-plane.png) | +| **Okhsl**
`OkhslPlane` | ![Okhsl plane](images/okhsl-plane.png) | +| **Okhsv**
`OkhsvPlane` | ![Okhsv plane](images/okhsv-plane.png) | + +```kotlin +val state = rememberColorPickerState(HslColor(hue = 68f, saturation = 0.72f, lightness = 0.62f)) + +HslPlane(state = state, modifier = Modifier.fillMaxWidth().height(220.dp)) +HueSlider(state = state) +``` + +Saturation always runs left to right, so the left edge is grey and the right edge the most +colorful the hue can be. The vertical axis runs from black at the bottom in every plane, but +reads differently at the top: lightness for `HslPlane` and `OkhslPlane`, so the top edge is +white, and value for `OkhsvPlane`, so the top edge is each column's own hue at full +brightness β€” the arrangement artists expect from a color picker, and the one Okhsv was +designed for. + +`HslPlane`'s surface is a horizontal grey-to-hue ramp under a white / transparent / black +overlay, which reproduces HSL exactly rather than approximately β€” the colour at lightness L +is the mid-lightness colour blended toward white by `2L-1` above the middle and toward black +by `1-2L` below it, which is what compositing the overlay computes. Okhsl and Okhsv have no +such identity, so `OkhslPlane` and `OkhsvPlane` sample their surface on a grid fine enough +that the difference is invisible instead. + +The surface is **not** mirrored in right-to-left layouts, unlike the sliders. It maps a +colour space rather than showing progress, and mirroring it would have saturation growing +leftwards here while it still grows rightwards on the hue slider beside it. Screen readers +get a label and both values, but a two-dimensional drag has no linear equivalent, so the +sliders remain the accessible path to the same channels. + +`thumb` replaces the position indicator, and receives the plane's `InteractionSource`: + +```kotlin +HslPlane( + state = state, + thumb = { source -> MyIndicator(source) }, +) +``` + ### Individual Sliders Every channel is available as a standalone slider. Compose any subset against a shared state: @@ -213,6 +356,21 @@ 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) + +// Planes β€” two channels at once, to compose with a hue slider +HslPlane(state = state) +OkhslPlane(state = state) +OkhsvPlane(state = state) + // Alpha (works with any origin space) AlphaSlider(state = state) ``` diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index 0379cb2a..74a186a4 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -1,4 +1,3 @@ -import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.dsl.JvmTarget @@ -21,7 +20,6 @@ kotlin { compileSdk = 37 minSdk = 24 - @OptIn(ExperimentalKotlinGradlePluginApi::class) compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } 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 6d29e059..d74e8b9c 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 @@ -53,6 +53,7 @@ import codes.side.colorpicker.ui.ColorPickerDialog import codes.side.colorpicker.ui.ColorSwatch import codes.side.colorpicker.ui.CyanSlider import codes.side.colorpicker.ui.GreenSlider +import codes.side.colorpicker.ui.HslPlane import codes.side.colorpicker.ui.HueSlider import codes.side.colorpicker.ui.KeySlider import codes.side.colorpicker.ui.LabASlider @@ -60,6 +61,14 @@ 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.OkhslPlane +import codes.side.colorpicker.ui.OkhslSaturationSlider +import codes.side.colorpicker.ui.OkhsvHueSlider +import codes.side.colorpicker.ui.OkhsvPlane +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,12 +175,55 @@ 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)}", + ) + } + + // Saturation / lightness plane, driven by the same state as everything else + item { SectionHeader("Saturation / Lightness") } + item { + HslPlane( + state = state, + modifier = Modifier.fillMaxWidth().height(220.dp), + ) + } + + item { HorizontalDivider() } + + item { SectionHeader("Okhsl plane") } + item { + OkhslPlane( + state = state, + modifier = Modifier.fillMaxWidth().height(220.dp), + ) } + item { HorizontalDivider() } + + item { SectionHeader("Okhsv plane") } + item { + OkhsvPlane( + state = state, + modifier = Modifier.fillMaxWidth().height(220.dp), + ) + } + + item { HorizontalDivider() } + // HSL section item { SectionHeader("HSL") } item { HueSlider(state = state, coloringMode = coloringMode) } @@ -205,6 +257,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 eaf83f16..a4de4e27 100644 --- a/screenshot-tests/build.gradle.kts +++ b/screenshot-tests/build.gradle.kts @@ -49,7 +49,14 @@ 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", + "HslPlanePreview" to "saturation-lightness-plane", + "OkhslPlanePreview" to "okhsl-plane", + "OkhsvPlanePreview" to "okhsv-plane", "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 5be57ae8..487b967e 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 @@ -34,8 +34,15 @@ import codes.side.colorpicker.ui.AlphaSlider import codes.side.colorpicker.ui.CmykColorPicker import codes.side.colorpicker.ui.ColorSwatch import codes.side.colorpicker.ui.HslColorPicker +import codes.side.colorpicker.ui.HslPlane import codes.side.colorpicker.ui.HueSlider import codes.side.colorpicker.ui.LabColorPicker +import codes.side.colorpicker.ui.OkhslColorPicker +import codes.side.colorpicker.ui.OkhslHueSlider +import codes.side.colorpicker.ui.OkhslPlane +import codes.side.colorpicker.ui.OkhsvColorPicker +import codes.side.colorpicker.ui.OkhsvHueSlider +import codes.side.colorpicker.ui.OkhsvPlane import codes.side.colorpicker.ui.RgbColorPicker import com.android.tools.screenshot.PreviewTest @@ -129,6 +136,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 @@ -178,6 +213,42 @@ fun CustomThumbPreview() = Frame { ) } +@PreviewTest +@Preview(name = "Saturation lightness plane", widthDp = 440, heightDp = 420) +@Composable +fun HslPlanePreview() = Frame { + val state = state() + HslPlane( + state = state, + modifier = Modifier.fillMaxWidth().height(260.dp), + ) + HueSlider(state = state) +} + +@PreviewTest +@Preview(name = "Okhsl plane", widthDp = 440, heightDp = 420) +@Composable +fun OkhslPlanePreview() = Frame { + val state = state() + OkhslPlane( + state = state, + modifier = Modifier.fillMaxWidth().height(260.dp), + ) + OkhslHueSlider(state = state) +} + +@PreviewTest +@Preview(name = "Okhsv plane", widthDp = 440, heightDp = 420) +@Composable +fun OkhsvPlanePreview() = Frame { + val state = state() + OkhsvPlane( + state = state, + modifier = Modifier.fillMaxWidth().height(260.dp), + ) + OkhsvHueSlider(state = state) +} + @PreviewTest @Preview(name = "Swatch translucent", widthDp = 440, heightDp = 112) @Composable diff --git a/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/HslPlanePreview_Saturation lightness plane_f5af5506_0.png b/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/HslPlanePreview_Saturation lightness plane_f5af5506_0.png new file mode 100644 index 00000000..d66815db Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/HslPlanePreview_Saturation lightness plane_f5af5506_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/LabContextualPreview_LAB contextual_525c1e73_0.png b/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/LabContextualPreview_LAB contextual_525c1e73_0.png index b7867eea..f1ac07fd 100644 Binary files a/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/LabContextualPreview_LAB contextual_525c1e73_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/LabContextualPreview_LAB contextual_525c1e73_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/LabIndependentPreview_LAB independent_3ae8c929_0.png b/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/LabIndependentPreview_LAB independent_3ae8c929_0.png index f7a4c2d5..df284097 100644 Binary files a/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/LabIndependentPreview_LAB independent_3ae8c929_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/LabIndependentPreview_LAB independent_3ae8c929_0.png differ 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 00000000..386ee0a2 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 00000000..bf48b490 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/OkhslPlanePreview_Okhsl plane_8543cbfb_0.png b/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/OkhslPlanePreview_Okhsl plane_8543cbfb_0.png new file mode 100644 index 00000000..0cbf944b Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/OkhslPlanePreview_Okhsl plane_8543cbfb_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 00000000..910f0f33 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 00000000..8960cdfa Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/OkhsvIndependentPreview_Okhsv independent_b9c3f979_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/OkhsvPlanePreview_Okhsv plane_326c5a5c_0.png b/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/OkhsvPlanePreview_Okhsv plane_326c5a5c_0.png new file mode 100644 index 00000000..ea2049fe Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/OkhsvPlanePreview_Okhsv plane_326c5a5c_0.png differ