diff --git a/README.md b/README.md index 718f65f..3e50d25 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ Kotlin Multiplatform color picker library for Android, iOS, Desktop (JVM), and W - Unidirectional data flow with `ColorPickerState` - Hex string parsing and formatting - Color picker dialog -- Accessibility semantics and RTL layout support +- Two-dimensional saturation / lightness plane alongside the single-channel sliders +- Accessibility semantics, and RTL layout support everywhere but the plane, which maps a color space rather than showing progress ## 📦 Setup @@ -187,6 +188,42 @@ LabColorPicker(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. +### Saturation / Lightness Plane + +`SaturationLightnessPlane` 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. + +![Saturation and lightness plane](docs/images/saturation-lightness-plane.png) + +```kotlin +val state = rememberColorPickerState(HslColor(hue = 68f, saturation = 0.72f, lightness = 0.62f)) + +SaturationLightnessPlane(state = state, modifier = Modifier.fillMaxWidth().height(220.dp)) +HueSlider(state = state) +``` + +Saturation runs left to right and lightness bottom to top: white along the top edge, black +along the bottom, grey down the left, the pure hue at the right of the middle row. That +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. + +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 +SaturationLightnessPlane( + state = state, + thumb = { source -> MyIndicator(source) }, +) +``` + ### Individual Sliders Every channel is available as a standalone slider. Compose any subset against a shared state: diff --git a/colorpicker/api/colorpicker.klib.api b/colorpicker/api/colorpicker.klib.api index 6944d2a..b779742 100644 --- a/colorpicker/api/colorpicker.klib.api +++ b/colorpicker/api/colorpicker.klib.api @@ -231,8 +231,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 +242,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,7 +264,7 @@ 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] @@ -328,6 +335,7 @@ final fun codes.side.colorpicker.ui/LightnessSlider(codes.side.colorpicker.state 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/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/SaturationLightnessPlane(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/SaturationLightnessPlane|SaturationLightnessPlane(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/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] final fun codes.side.colorpicker.ui/SliderLabel(kotlin/String, androidx.compose.runtime/Composer?, kotlin/Int) // codes.side.colorpicker.ui/SliderLabel|SliderLabel(kotlin.String;androidx.compose.runtime.Composer?;kotlin.Int){}[0] final fun codes.side.colorpicker.ui/SliderValueLabel(kotlin/String, androidx.compose.runtime/Composer?, kotlin/Int) // codes.side.colorpicker.ui/SliderValueLabel|SliderValueLabel(kotlin.String;androidx.compose.runtime.Composer?;kotlin.Int){}[0] diff --git a/colorpicker/api/jvm/colorpicker.api b/colorpicker/api/jvm/colorpicker.api index 9dab9c8..4406e2e 100644 --- a/colorpicker/api/jvm/colorpicker.api +++ b/colorpicker/api/jvm/colorpicker.api @@ -237,21 +237,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 @@ -354,6 +358,10 @@ public final class codes/side/colorpicker/ui/RgbSlidersKt { public static final fun RedSlider-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/SaturationLightnessPlaneKt { + public static final fun SaturationLightnessPlane (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/SliderLabelDefaultsKt { public static final fun SliderLabel (Ljava/lang/String;Landroidx/compose/runtime/Composer;I)V public static final fun SliderValueLabel (Ljava/lang/String;Landroidx/compose/runtime/Composer;I)V diff --git a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/state/ColorPickerState.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/state/ColorPickerState.kt index 1ffe90d..e897274 100644 --- a/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/state/ColorPickerState.kt +++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/state/ColorPickerState.kt @@ -48,10 +48,11 @@ 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 the + * [codes.side.colorpicker.ui.SaturationLightnessPlane] (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 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 0d7c646..a3af9c9 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.SaturationLightnessPlane] falls back to. */ + public val PlaneMinSize: Dp = 200.dp + + /** Diameter of the plane'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 a7c1aab..8b73b16 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.SaturationLightnessPlane]'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/SaturationLightnessPlane.kt b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/SaturationLightnessPlane.kt new file mode 100644 index 0000000..e87509c --- /dev/null +++ b/colorpicker/src/commonMain/kotlin/codes/side/colorpicker/ui/SaturationLightnessPlane.kt @@ -0,0 +1,194 @@ +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.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +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.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 +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +/** Saturation for a pointer at [x] across a plane [width] pixels wide. */ +internal fun saturationAt(x: Float, width: Int): Float = + if (width <= 0) 0f else (x / width).coerceIn(0f, 1f) + +/** Lightness for a pointer at [y] down a plane [height] pixels tall; the top is white. */ +internal fun lightnessAt(y: Float, height: Int): Float = + if (height <= 0) 0.5f else (1f - y / height).coerceIn(0f, 1f) + +/** + * 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. + * + * 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 saturation grow + * leftwards here while it still grows rightwards on the hue slider beside it. + * + * @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 SaturationLightnessPlane( + 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 interactionSource = remember { MutableInteractionSource() } + val guard = remember(state) { SliderInteractionGuard(state) } + val scope = rememberCoroutineScope() + + // 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 desaturated = remember(hsl.hue) { + HslColor(hue = hsl.hue, saturation = 0f, lightness = 0.5f).toComposeColor() + } + val pure = remember(hsl.hue) { + HslColor(hue = hsl.hue, saturation = 1f, lightness = 0.5f).toComposeColor() + } + + 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 { + drawRect(Brush.horizontalGradient(listOf(desaturated, pure))) + drawRect( + Brush.verticalGradient( + listOf(Color.White, Color.Transparent, Color.Black), + ), + ) + }, + ) + // 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(state) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val press = DragInteraction.Start() + scope.launch { interactionSource.emit(press) } + guard.begin() + state.commitPlane(down.position, size.width, size.height) + down.consume() + + val completed = drag(down.id) { change -> + state.commitPlane(change.position, size.width, size.height) + change.consume() + } + + guard.end() + 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 surface = measurables[0].measure(Constraints.fixed(width, height)) + val indicator = measurables[1].measure(Constraints(maxWidth = width, maxHeight = height)) + + layout(width, height) { + surface.place(0, 0) + // place, not placeRelative: the surface, the gradient 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 = (hsl.saturation * width - indicator.width / 2f).roundToInt(), + y = ((1f - hsl.lightness) * 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()), + ) + } +} + +/** Writes both channels in one update, so hue and alpha survive the gesture untouched. */ +private fun ColorPickerState.commitPlane(position: Offset, width: Int, height: Int) { + updateFromHsl( + hslColor.copy( + saturation = saturationAt(position.x, width), + lightness = lightnessAt(position.y, height), + ), + ) +} 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 059b6fb..486c187 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] or the [SaturationLightnessPlane]. * - * 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. + * Neither reports 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/ui/PlaneMappingTest.kt b/colorpicker/src/commonTest/kotlin/codes/side/colorpicker/ui/PlaneMappingTest.kt new file mode 100644 index 0000000..2862da5 --- /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 saturationRunsLeftToRight() { + assertEquals(0f, saturationAt(0f, 200)) + assertEquals(0.5f, saturationAt(100f, 200)) + assertEquals(1f, saturationAt(200f, 200)) + } + + @Test + fun lightnessRunsBottomToTop() { + assertEquals(1f, lightnessAt(0f, 200), "the top edge is white") + assertEquals(0.5f, lightnessAt(100f, 200)) + assertEquals(0f, lightnessAt(200f, 200), "the bottom edge is black") + } + + @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, saturationAt(-40f, 200)) + assertEquals(1f, saturationAt(260f, 200)) + assertEquals(1f, lightnessAt(-40f, 200)) + assertEquals(0f, lightnessAt(260f, 200)) + } + + @Test + fun anUnmeasuredSurfaceDoesNotDivideByZero() { + assertEquals(0f, saturationAt(10f, 0)) + assertEquals(0.5f, lightnessAt(10f, 0)) + } +} 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 12e6316..91e5ec2 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,21 @@ 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.state.ColorPickerState import codes.side.colorpicker.state.ColoringMode import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -141,4 +146,156 @@ 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) { + SaturationLightnessPlane( + 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) { + SaturationLightnessPlane( + 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 { + SaturationLightnessPlane( + 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 { + SaturationLightnessPlane( + 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)) { + SaturationLightnessPlane( + 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") + } } diff --git a/docs/images/saturation-lightness-plane.png b/docs/images/saturation-lightness-plane.png new file mode 100644 index 0000000..d66815d Binary files /dev/null and b/docs/images/saturation-lightness-plane.png differ diff --git a/docs/index.md b/docs/index.md index a74a993..28ea89f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,7 +14,8 @@ Kotlin Multiplatform color picker library for Android, iOS, Desktop (JVM), and W - Unidirectional data flow with `ColorPickerState` - Hex string parsing and formatting - Color picker dialog -- Accessibility semantics and RTL layout support +- Two-dimensional saturation / lightness plane alongside the single-channel sliders +- Accessibility semantics, and RTL layout support everywhere but the plane, which maps a color space rather than showing progress ## 📦 Setup @@ -187,6 +188,42 @@ LabColorPicker(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. +### Saturation / Lightness Plane + +`SaturationLightnessPlane` 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. + +![Saturation and lightness plane](images/saturation-lightness-plane.png) + +```kotlin +val state = rememberColorPickerState(HslColor(hue = 68f, saturation = 0.72f, lightness = 0.62f)) + +SaturationLightnessPlane(state = state, modifier = Modifier.fillMaxWidth().height(220.dp)) +HueSlider(state = state) +``` + +Saturation runs left to right and lightness bottom to top: white along the top edge, black +along the bottom, grey down the left, the pure hue at the right of the middle row. That +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. + +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 +SaturationLightnessPlane( + state = state, + thumb = { source -> MyIndicator(source) }, +) +``` + ### Individual Sliders Every channel is available as a standalone slider. Compose any subset against a shared state: diff --git a/sample/shared/src/commonMain/kotlin/codes/side/colorpicker/sample/SampleApp.kt b/sample/shared/src/commonMain/kotlin/codes/side/colorpicker/sample/SampleApp.kt index 6d29e05..4de32fd 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 @@ -61,6 +61,7 @@ import codes.side.colorpicker.ui.LightnessLabSlider import codes.side.colorpicker.ui.LightnessSlider import codes.side.colorpicker.ui.MagentaSlider import codes.side.colorpicker.ui.RedSlider +import codes.side.colorpicker.ui.SaturationLightnessPlane import codes.side.colorpicker.ui.SaturationSlider import codes.side.colorpicker.ui.YellowSlider import codes.side.colorpicker.util.randomHslColor @@ -172,6 +173,17 @@ fun SampleApp() { Readout("LAB L:$ll a:$la b:$lb") } + // Saturation / lightness plane, driven by the same state as everything else + item { SectionHeader("Saturation / Lightness") } + item { + SaturationLightnessPlane( + state = state, + modifier = Modifier.fillMaxWidth().height(220.dp), + ) + } + + item { HorizontalDivider() } + // HSL section item { SectionHeader("HSL") } item { HueSlider(state = state, coloringMode = coloringMode) } diff --git a/screenshot-tests/build.gradle.kts b/screenshot-tests/build.gradle.kts index eaf83f1..ed51365 100644 --- a/screenshot-tests/build.gradle.kts +++ b/screenshot-tests/build.gradle.kts @@ -50,6 +50,7 @@ val readmeGoldens = mapOf( "LabIndependentPreview" to "lab-independent", "LabContextualPreview" to "lab-contextual", "CustomThumbPreview" to "custom-thumb", + "PlanePreview" to "saturation-lightness-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 5be57ae..d8944b1 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 @@ -37,6 +37,7 @@ import codes.side.colorpicker.ui.HslColorPicker import codes.side.colorpicker.ui.HueSlider import codes.side.colorpicker.ui.LabColorPicker import codes.side.colorpicker.ui.RgbColorPicker +import codes.side.colorpicker.ui.SaturationLightnessPlane import com.android.tools.screenshot.PreviewTest /** @@ -178,6 +179,18 @@ fun CustomThumbPreview() = Frame { ) } +@PreviewTest +@Preview(name = "Saturation lightness plane", widthDp = 440, heightDp = 420) +@Composable +fun PlanePreview() = Frame { + val state = state() + SaturationLightnessPlane( + state = state, + modifier = Modifier.fillMaxWidth().height(260.dp), + ) + HueSlider(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/PlanePreview_Saturation lightness plane_f5af5506_0.png b/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/PlanePreview_Saturation lightness plane_f5af5506_0.png new file mode 100644 index 0000000..d66815d Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/codes/side/colorpicker/screenshot/ColorPickerPreviewsKt/PlanePreview_Saturation lightness plane_f5af5506_0.png differ