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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ val color = CmykColor(cyan = 0.3f, magenta = 0.6f, yellow = 0.1f, key = 0.2f)
CmykColor.fromInt(cyan = 30, magenta = 60, yellow = 10, key = 20)
```

The naive conversion, with no colour profile. It round-trips on screen and is not what a press will print — real CMYK is device dependent, its gamut is not sRGB's, and crossing between them needs an ICC profile and a rendering intent. Treat these as a screen-space parameterisation rather than ink.

### LAB

```kotlin
Expand Down Expand Up @@ -590,6 +592,8 @@ Color space conversions are inherently lossy when values are quantized to intege

`ColorPickerState` tracks which color space was last written to (the *origin*). When you read a different space, it converts forward once from the origin. The origin value is never re-derived from a conversion.

That covers the space being written to. Read a *different* space and you get a conversion, which cannot invent what the colour does not carry — grey, black and white have no hue, so a hue read off one would be red. Because someone who dragged lightness to zero did not choose red, the last hue actually chosen is kept and handed back, as a painting tool does. HSL's hue angle and Oklab's are separate quantities and are remembered separately. Saturation is not treated this way: a grey really is unsaturated, where its hue is only unknown.

```
User drags Red slider
-> the authoritative color is written as RGB (origin = RGB, zero conversions)
Expand Down
11 changes: 8 additions & 3 deletions colorpicker/api/colorpicker.klib.api

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions colorpicker/api/jvm/colorpicker.api

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ import codes.side.colorpicker.model.CmykColor
import codes.side.colorpicker.model.HslColor
import codes.side.colorpicker.model.RgbColor

/** Converts this CMYK color to RGB. Alpha is carried over unchanged. */
/**
* Converts this CMYK color to RGB. Alpha is carried over unchanged.
*
* The naive conversion — the arithmetic below and no colour profile. It is a reversible way to
* put four numbers on screen, and it is not what a press will print: real CMYK is device
* dependent, its gamut is not sRGB's, and getting from one to the other means an ICC profile
* and a rendering intent. Treat these values as a screen-space parameterisation rather than ink.
*/
public fun CmykColor.toRgb(): RgbColor {
val r = (1f - cyan) * (1f - key)
val g = (1f - magenta) * (1f - key)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,12 @@ public class HslColor(
/**
* Creates an [HslColor] from integer channels: [hue] in `0..360` degrees,
* [saturation] and [lightness] in `0..100` percent, [alpha] in `0..255`.
* Unlike the constructor, out-of-range values are clamped instead of throwing.
* Unlike the constructor, out-of-range values are clamped instead of throwing — except
* [hue], which wraps, since an angle has no ends: `370` is `10` and `-10` is `350`.
*/
public fun fromInt(hue: Int, saturation: Int, lightness: Int, alpha: Int = 255): HslColor =
HslColor(
hue = hue.toFloat().coerceIn(0f, 360f),
hue = wrapHue(hue),
saturation = (saturation / 100f).coerceIn(0f, 1f),
lightness = (lightness / 100f).coerceIn(0f, 1f),
alpha = (alpha / 255f).coerceIn(0f, 1f),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package codes.side.colorpicker.model

/**
* This hue in `0..<360`, wrapped rather than clamped.
*
* Hue is an angle, so `370` is `10` and `-10` is `350`. Clamping would answer red to both,
* which is a plausible-looking wrong colour rather than a rejected one — the failure a caller
* stepping a hue past the end would be least likely to notice.
*/
internal fun wrapHue(degrees: Int): Float {
val wrapped = degrees % 360
return (if (wrapped < 0) wrapped + 360 else wrapped).toFloat()
}
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,16 @@ public class OkhslColor(
/**
* 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.
* constructor, out-of-range values are clamped instead of throwing — except [hue],
* which wraps, since an angle has no ends: `370` is `10` and `-10` is `350`.
*/
public fun fromInt(
hue: Int,
saturation: Int,
lightness: Int,
alpha: Int = 255,
): OkhslColor = OkhslColor(
hue = hue.toFloat().coerceIn(0f, 360f),
hue = wrapHue(hue),
saturation = (saturation / 100f).coerceIn(0f, 1f),
lightness = (lightness / 100f).coerceIn(0f, 1f),
alpha = (alpha / 255f).coerceIn(0f, 1f),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,15 +108,16 @@ public class OkhsvColor(
/**
* 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.
* out-of-range values are clamped instead of throwing — except [hue], which wraps,
* since an angle has no ends: `370` is `10` and `-10` is `350`.
*/
public fun fromInt(
hue: Int,
saturation: Int,
value: Int,
alpha: Int = 255,
): OkhsvColor = OkhsvColor(
hue = hue.toFloat().coerceIn(0f, 360f),
hue = wrapHue(hue),
saturation = (saturation / 100f).coerceIn(0f, 1f),
value = (value / 100f).coerceIn(0f, 1f),
alpha = (alpha / 255f).coerceIn(0f, 1f),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,13 @@ public class OklchColor(
* 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.
* instead of throwing — except [hue], which wraps, since an angle has no ends:
* `370` is `10` and `-10` is `350`.
*/
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),
hue = wrapHue(hue),
alpha = (alpha / 255f).coerceIn(0f, 1f),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,16 @@ import codes.side.colorpicker.model.RgbColor
* observed in the other spaces are conversions and carry ordinary conversion
* rounding.
*
* The guarantee covers the space being written to, and nothing else. A colour written from one
* space and read in another is a conversion, and a conversion cannot invent what the colour
* does not carry: grey, black and white have no hue, so reading a hue off one would report red.
* Since a user dragging lightness to zero has not chosen red, the last hue that was actually
* chosen is kept and handed back for those, the way a painting tool does. HSL's hue angle and
* Oklab's are different quantities and are remembered separately. Saturation is not treated the
* same way: a grey really is unsaturated, whereas its hue is merely unknown.
*
* Backed by Compose snapshot state: reads are safe from any thread, but writes
* (the `update*` methods and [isInteracting]) are expected on the main thread,
* (the `update*` methods) are expected on the main thread,
* like other Compose UI state. Updates are idempotent — writing a value equal to
* the current one produces no observable state change.
*
Expand All @@ -55,18 +63,67 @@ public class ColorPickerState(initialColor: PickerColor = HslColor()) {

// The single authoritative value. Its runtime type is the origin space —
// whichever space was last written to.
private var authoritative by mutableStateOf<PickerColor>(initialColor)
private var authoritativeColor by mutableStateOf<PickerColor>(initialColor)

// Grey, black and white have no hue to convert, so a cylindrical view of one reports zero
// — red. Origin tracking already keeps the hue while the cylindrical space is the one being
// written to; this is for when another space writes the neutral, which is where it would
// otherwise be lost. HSL's hue and Oklab's are different angles, so they are kept apart;
// Okhsl, Okhsv and OkLCh all share the second.
private var rememberedHslHue by mutableStateOf(0f)
private var rememberedOkHue by mutableStateOf(0f)

private var authoritative: PickerColor
get() = authoritativeColor
set(value) {
authoritativeColor = value
rememberHueOf(value)
}

/**
* True while the user is actively dragging one of the library's sliders or planes —
* Records the hue of a colour that has one, taken from the colour itself rather than
* converted, so a write costs nothing beyond a type check.
*/
private fun rememberHueOf(color: PickerColor) {
when (color) {
is HslColor -> if (color.saturation > 0f) rememberedHslHue = color.hue
is OkhslColor -> if (color.saturation > 0f) rememberedOkHue = color.hue
is OkhsvColor -> if (color.saturation > 0f) rememberedOkHue = color.hue
is OklchColor -> if (color.chroma > 0f) rememberedOkHue = color.hue
else -> Unit
}
}

init {
rememberHueOf(initialColor)
}

// How many components are mid-gesture, not whether any is. A touch screen can drag two
// sliders at once, and a single flag would go false when the first of them finished while
// the second was still moving.
private var interactions by mutableStateOf(0)

/**
* True while the user is actively dragging any 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.
* [codes.side.colorpicker.ui.OkhsvPlane] — set on the first value change, cleared when the
* last gesture finishes or the interacting components leave composition mid-drag. Useful
* for deferring expensive work until the interaction ends. Programmatic `update*` calls do
* not affect it.
*
* Stays true while any one of several simultaneous drags is still going.
*/
public var isInteracting: Boolean by mutableStateOf(false)
internal set
public val isInteracting: Boolean get() = interactions > 0

/** Counts one component into [isInteracting]; balanced by [endInteraction]. */
internal fun beginInteraction() {
interactions++
}

/** Counts one component back out of [isInteracting]. */
internal fun endInteraction() {
if (interactions > 0) interactions--
}

// ---- Derived spaces (pure computation, no writes on read) ----

Expand All @@ -82,14 +139,28 @@ public class ColorPickerState(initialColor: PickerColor = HslColor()) {
color as? T ?: convert(color.toRgbColor())
}

private val hslDerived = derivedSpace { it.toHsl() }
// A converted neutral reports hue zero because there is no hue in it to find, not because
// red was chosen. Where the conversion had nothing to say, the last hue that did is used.
private val hslDerived = derivedStateOf {
val hsl = authoritative as? HslColor ?: authoritative.toRgbColor().toHsl()
if (hsl.saturation == 0f && hsl.hue == 0f) hsl.copy(hue = rememberedHslHue) else hsl
}
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() }
private val oklchDerived = derivedStateOf {
val oklch = authoritative as? OklchColor ?: authoritative.toRgbColor().toOklch()
if (oklch.chroma == 0f && oklch.hue == 0f) oklch.copy(hue = rememberedOkHue) else oklch
}
private val okhslDerived = derivedStateOf {
val okhsl = authoritative as? OkhslColor ?: authoritative.toRgbColor().toOkhsl()
if (okhsl.saturation == 0f && okhsl.hue == 0f) okhsl.copy(hue = rememberedOkHue) else okhsl
}
private val okhsvDerived = derivedStateOf {
val okhsv = authoritative as? OkhsvColor ?: authoritative.toRgbColor().toOkhsv()
if (okhsv.saturation == 0f && okhsv.hue == 0f) okhsv.copy(hue = rememberedOkHue) else okhsv
}

// ---- Public read access ----

Expand Down
Loading