diff options
| author | skydoves <skydoves2@gmail.com> | 2026-09-16 12:09:15 +0300 |
|---|---|---|
| committer | skydoves <skydoves2@gmail.com> | 2026-09-16 12:09:24 +0300 |
| commit | b412754c65e7cbf76aa77401e7ce57324a7aeb0c (patch) | |
| tree | bec651dd01587552877a1e7bd377cc932a984c2d | |
| parent | 2dbf80738e26b025dde78935e61cebf5b9cf5ea6 (diff) | |
| parent | 696a410a1d31eecca8805a2822a17fa62ffa1eb6 (diff) | |
| download | colorpicker-compose-b412754c65e7cbf76aa77401e7ce57324a7aeb0c.tar.xz | |
Merge main into the desktop demo
Resolutions:
- Took main for the Spotless bump, the ktlint reformats, the version
catalog and the wheel bitmap property, all of which landed separately.
- Kept the benchmark build type and the baselineProfile dependency on
:demo:androidApp, which the baseline-profile workflow needs.
- ObserveColorChanges now uses rememberCoroutineScope as well, so the
picker and the sliders collect the same way, and ColorPicker just
delegates to it.
- Tests build the controller without a scope, and the ones that read a
callback wait for idle first, since a color change now arrives on the
composition's own scope rather than Dispatchers.Main.
48 files changed, 2855 insertions, 180 deletions
diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index dd41ce1..c3ba29c 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -3,8 +3,8 @@ name: Android CI on: push: branches: [ main ] + # No branch filter, so a pull request stacked on another one gets checked too. pull_request: - branches: [ main ] jobs: lint: @@ -12,10 +12,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup JDK - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: distribution: temurin java-version: 21 @@ -28,10 +28,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup JDK - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: distribution: temurin java-version: 21 @@ -39,14 +39,45 @@ jobs: - name: API Check run: ./gradlew apiCheck + test: + name: Unit Tests + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v7 + + - name: Setup JDK + uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: 21 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Grant Execute Permission for gradlew + run: chmod +x gradlew + + # desktopTest runs commonTest plus the composition tests. The web targets cannot run them, + # and the Apple ones need a macOS runner. + - name: Test + run: ./gradlew :colorpicker-compose:desktopTest --stacktrace + + - name: Upload Test Reports + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-reports + path: '**/build/reports/tests/**' + build: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup JDK - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: distribution: temurin java-version: 21 diff --git a/.github/workflows/baseline-profile.yml b/.github/workflows/baseline-profile.yml index 6819725..8d80df7 100644 --- a/.github/workflows/baseline-profile.yml +++ b/.github/workflows/baseline-profile.yml @@ -36,11 +36,11 @@ jobs: # Checks your code out on the machine - name: Checkout Code - uses: actions/checkout@v6 + uses: actions/checkout@v7 # Sets java up - name: Setup JDK - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: distribution: temurin java-version: 21 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 19419af..c387c02 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -26,10 +26,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: distribution: 'temurin' java-version: '21' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7b4c75d..c8edf56 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,10 +11,10 @@ jobs: runs-on: macos-latest steps: - name: Checkout Code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup JDK 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: distribution: temurin java-version: 21 @@ -112,7 +112,7 @@ ImageColorPicker( ) ``` -With the [modernstorage](https://github.com/google/modernstorage)'s [Photo Picker](https://google.github.io/modernstorage/photopicker/), you can set an desired image as the palette like the below: +With the [modernstorage](https://github.com/google/modernstorage)'s [Photo Picker](https://google.github.io/modernstorage/photopicker/), you can set a desired image as the palette like the below: ```kotlin val context = LocalContext.current @@ -169,6 +169,23 @@ To initialize the color picker with a specific color, pass the color to the `ini The `onStart` / `onFinish` callbacks are also available on `ImageColorPicker`, `AlphaSlider`, `BrightnessSlider`, `HueSlider`, and `SaturationSlider`. Use them to bracket gesture-driven work — for example to show a press indicator, snapshot state when the gesture begins, or commit/throttle work when it ends. +#### onColorPickingFinished + +`onColorChanged` fires for every step of a drag, which is more often than you want for saving a choice or logging one. `onColorPickingFinished` fires once, when the gesture is over, and both `HsvColorPicker` and `ImageColorPicker` accept it: + +```kotlin +HsvColorPicker( + modifier = Modifier.fillMaxWidth().height(450.dp), + controller = controller, + onColorChanged = { colorEnvelope: ColorEnvelope -> + preview(colorEnvelope.color) // every step of the drag + }, + onColorPickingFinished = { colorEnvelope: ColorEnvelope -> + save(colorEnvelope.color) // once, when the finger lifts + }, +) +``` + ### ColorEnvelope **ColorEnvelope** is a data transfer object that includes updated color factors. If you pass the **onColorChanged** lambda function to the `ImageColorPicker` or `HsvColorPicker`, the lambda receives **ColorEnvelope**. @@ -255,7 +272,7 @@ AlphaSlider( ) ``` -You can customize the border of the sider with the following parameters: +You can customize the border of the slider with the following parameters: ```kotlin AlphaSlider( @@ -266,7 +283,7 @@ AlphaSlider( ) ``` -You can customize the wheel of the sider with the following parameters: +You can customize the wheel of the slider with the following parameters: ```kotlin AlphaSlider( @@ -289,6 +306,38 @@ AlphaSlider( ) ``` +#### Vertical Sliders + +Every slider runs left to right by default. Pass `orientation` to stand one up instead, and it will +fill the height it is given with the lowest value at the bottom: + +```kotlin +BrightnessSlider( + modifier = Modifier + .width(35.dp) + .height(300.dp), + controller = controller, + orientation = SliderOrientation.Vertical, +) +``` + +#### Sliders Without a Picker + +`AlphaSlider`, `BrightnessSlider` and `SaturationSlider` take an `onColorChanged` callback, so a +slider works on its own when there is no palette to show. A lone `BrightnessSlider` picks greys: + +```kotlin +BrightnessSlider( + modifier = Modifier + .fillMaxWidth() + .height(35.dp), + controller = controller, + onColorChanged = { colorEnvelope: ColorEnvelope -> + // a grey from black to white + }, +) +``` + <img src="preview/preview4.gif" width="270" align="right"> ### BrightnessSlider @@ -307,7 +356,7 @@ BrightnessSlider( ) ``` -You can customize the wheel of the sider with the following parameters: +You can customize the wheel of the slider with the following parameters: ```kotlin BrightnessSlider( diff --git a/build.gradle.kts b/build.gradle.kts index 8cda5ce..263c59e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -24,12 +24,12 @@ subprojects { configure<com.diffplug.gradle.spotless.SpotlessExtension> { kotlin { target("**/*.kt") - targetExclude("$buildDir/**/*.kt") + targetExclude("${layout.buildDirectory.get()}/**/*.kt") ktlint().editorConfigOverride( mapOf( "indent_size" to "2", "continuation_indent_size" to "2", - // Composable functions conventionally use PascalCase; exempt them from function-naming. + // Composables are named like types by convention, which the naming rule does not know. "ktlint_function_naming_ignore_when_annotated_with" to "Composable" ) ) diff --git a/buildSrc/src/main/kotlin/com/github/skydoves/colorpicker/compose/Configuration.kt b/buildSrc/src/main/kotlin/com/github/skydoves/colorpicker/compose/Configuration.kt index 160b010..085e3c6 100644 --- a/buildSrc/src/main/kotlin/com/github/skydoves/colorpicker/compose/Configuration.kt +++ b/buildSrc/src/main/kotlin/com/github/skydoves/colorpicker/compose/Configuration.kt @@ -1,7 +1,7 @@ package com.github.skydoves.colorpicker.compose object Configuration { - const val compileSdk = 36 + const val compileSdk = 37 const val targetSdk = 36 const val minSdk = 21 const val demoMinSdk = 24 diff --git a/colorpicker-compose/api/desktop/colorpicker-compose.api b/colorpicker-compose/api/desktop/colorpicker-compose.api index 047641c..5961eba 100644 --- a/colorpicker-compose/api/desktop/colorpicker-compose.api +++ b/colorpicker-compose/api/desktop/colorpicker-compose.api @@ -1,5 +1,5 @@ public final class com/github/skydoves/colorpicker/compose/AlphaSliderKt { - public static final fun AlphaSlider-sowkUpU (Landroidx/compose/ui/Modifier;Lcom/github/skydoves/colorpicker/compose/ColorPickerController;FFJLandroidx/compose/ui/graphics/ImageBitmap;FJFLandroidx/compose/ui/graphics/Paint;JJFLandroidx/compose/ui/graphics/Color;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;III)V + public static final fun AlphaSlider-8UqweSM (Landroidx/compose/ui/Modifier;Lcom/github/skydoves/colorpicker/compose/ColorPickerController;FFJLandroidx/compose/ui/graphics/ImageBitmap;FJFLandroidx/compose/ui/graphics/Paint;JJFLandroidx/compose/ui/graphics/Color;Lcom/github/skydoves/colorpicker/compose/SliderOrientation;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;III)V } public final class com/github/skydoves/colorpicker/compose/AlphaTileKt { @@ -7,7 +7,7 @@ public final class com/github/skydoves/colorpicker/compose/AlphaTileKt { } public final class com/github/skydoves/colorpicker/compose/BrightnessSliderKt { - public static final fun BrightnessSlider-NG3wrtk (Landroidx/compose/ui/Modifier;Lcom/github/skydoves/colorpicker/compose/ColorPickerController;FFJLandroidx/compose/ui/graphics/ImageBitmap;FJFLandroidx/compose/ui/graphics/Paint;Landroidx/compose/ui/graphics/Color;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;III)V + public static final fun BrightnessSlider-wqNA738 (Landroidx/compose/ui/Modifier;Lcom/github/skydoves/colorpicker/compose/ColorPickerController;FFJLandroidx/compose/ui/graphics/ImageBitmap;FJFLandroidx/compose/ui/graphics/Paint;Landroidx/compose/ui/graphics/Color;Lcom/github/skydoves/colorpicker/compose/SliderOrientation;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;III)V } public final class com/github/skydoves/colorpicker/compose/ColorChangeSource : java/lang/Enum { @@ -87,15 +87,15 @@ public final class com/github/skydoves/colorpicker/compose/DrawScopeExtensionsKt } public final class com/github/skydoves/colorpicker/compose/HsvColorPickerKt { - public static final fun HsvColorPicker-vD7qDfE (Landroidx/compose/ui/Modifier;Lcom/github/skydoves/colorpicker/compose/ColorPickerController;Landroidx/compose/ui/graphics/ImageBitmap;Lkotlin/jvm/functions/Function1;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/graphics/Color;Landroidx/compose/runtime/Composer;II)V + public static final fun HsvColorPicker-MHSKBV4 (Landroidx/compose/ui/Modifier;Lcom/github/skydoves/colorpicker/compose/ColorPickerController;Landroidx/compose/ui/graphics/ImageBitmap;Lkotlin/jvm/functions/Function1;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/graphics/Color;Landroidx/compose/runtime/Composer;II)V } public final class com/github/skydoves/colorpicker/compose/HueSliderKt { - public static final fun HueSlider-qYw_WyY (Landroidx/compose/ui/Modifier;Lcom/github/skydoves/colorpicker/compose/ColorPickerController;FFJLandroidx/compose/ui/graphics/ImageBitmap;FJFLandroidx/compose/ui/graphics/Paint;Landroidx/compose/ui/graphics/Color;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;III)V + public static final fun HueSlider-wqNA738 (Landroidx/compose/ui/Modifier;Lcom/github/skydoves/colorpicker/compose/ColorPickerController;FFJLandroidx/compose/ui/graphics/ImageBitmap;FJFLandroidx/compose/ui/graphics/Paint;Landroidx/compose/ui/graphics/Color;Lcom/github/skydoves/colorpicker/compose/SliderOrientation;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;III)V } public final class com/github/skydoves/colorpicker/compose/ImageColorPickerKt { - public static final fun ImageColorPicker (Landroidx/compose/ui/Modifier;Lcom/github/skydoves/colorpicker/compose/ColorPickerController;Landroidx/compose/ui/graphics/ImageBitmap;Landroidx/compose/ui/graphics/ImageBitmap;Lkotlin/jvm/functions/Function1;ZLcom/github/skydoves/colorpicker/compose/PaletteContentScale;Landroidx/compose/ui/graphics/painter/Painter;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;III)V + public static final fun ImageColorPicker (Landroidx/compose/ui/Modifier;Lcom/github/skydoves/colorpicker/compose/ColorPickerController;Landroidx/compose/ui/graphics/ImageBitmap;Landroidx/compose/ui/graphics/ImageBitmap;Lkotlin/jvm/functions/Function1;ZLcom/github/skydoves/colorpicker/compose/PaletteContentScale;Landroidx/compose/ui/graphics/painter/Painter;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;III)V } public final class com/github/skydoves/colorpicker/compose/PaletteContentScale : java/lang/Enum { @@ -107,6 +107,14 @@ public final class com/github/skydoves/colorpicker/compose/PaletteContentScale : } public final class com/github/skydoves/colorpicker/compose/SaturationSliderKt { - public static final fun SaturationSlider-NG3wrtk (Landroidx/compose/ui/Modifier;Lcom/github/skydoves/colorpicker/compose/ColorPickerController;FFJLandroidx/compose/ui/graphics/ImageBitmap;FJFLandroidx/compose/ui/graphics/Paint;Landroidx/compose/ui/graphics/Color;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;III)V + public static final fun SaturationSlider-wqNA738 (Landroidx/compose/ui/Modifier;Lcom/github/skydoves/colorpicker/compose/ColorPickerController;FFJLandroidx/compose/ui/graphics/ImageBitmap;FJFLandroidx/compose/ui/graphics/Paint;Landroidx/compose/ui/graphics/Color;Lcom/github/skydoves/colorpicker/compose/SliderOrientation;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;III)V +} + +public final class com/github/skydoves/colorpicker/compose/SliderOrientation : java/lang/Enum { + public static final field Horizontal Lcom/github/skydoves/colorpicker/compose/SliderOrientation; + public static final field Vertical Lcom/github/skydoves/colorpicker/compose/SliderOrientation; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/github/skydoves/colorpicker/compose/SliderOrientation; + public static fun values ()[Lcom/github/skydoves/colorpicker/compose/SliderOrientation; } diff --git a/colorpicker-compose/build.gradle.kts b/colorpicker-compose/build.gradle.kts index bfbe096..f5ef177 100644 --- a/colorpicker-compose/build.gradle.kts +++ b/colorpicker-compose/build.gradle.kts @@ -53,12 +53,15 @@ kotlin { iosArm64() iosSimulatorArm64() macosArm64() + // The web targets do not run commonTest. ColorPickerController builds a Paint up front, and on + // Kotlin/JS that reaches for Skiko, whose WebAssembly binary a bare Node or headless browser test + // never loads. The same sources are covered by desktopTest instead. js(IR) { - browser() - nodejs() + browser { testTask { enabled = false } } + nodejs { testTask { enabled = false } } } wasmJs { - browser() + browser { testTask { enabled = false } } binaries.library() } @@ -101,6 +104,25 @@ kotlin { implementation(libs.compose.ui) } } + + val commonTest by getting { + languageSettings.optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") + dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + } + + // Tests that need a real composition or an ImageBitmap live here. runComposeUiTest wants a + // window, and compose.desktop.currentOs is what supplies the Skiko backend that draws into it. + val desktopTest by getting { + languageSettings.optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") + languageSettings.optIn("androidx.compose.ui.test.ExperimentalTestApi") + dependencies { + implementation(libs.compose.ui.test) + implementation(compose.desktop.currentOs) + } + } } explicitApi() @@ -117,12 +139,6 @@ baselineProfile { } } -tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> { - compilerOptions { - freeCompilerArgs.add("-Xexplicit-api=strict") - } -} - tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile>().configureEach { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) diff --git a/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/AlphaSlider.kt b/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/AlphaSlider.kt index b8f5f7d..0d2bd81 100644 --- a/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/AlphaSlider.kt +++ b/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/AlphaSlider.kt @@ -35,15 +35,18 @@ import androidx.compose.ui.unit.dp * @param borderColor [Color] of the border. * @param wheelImageBitmap [ImageBitmap] to draw the wheel. * @param wheelRadius Radius of the wheel. - * @param wheelColor [Color] of th wheel. + * @param wheelColor [Color] of the wheel. * @param wheelPaint [Paint] to draw the wheel. * @param tileOddColor Color of the odd tiles. * @param tileEvenColor Color of the even tiles. * @param tileSize DP size of tiles. * @param initialColor [Color] of the initial state. This property works for [HsvColorPicker] and + * it will be selected on rightmost of slider if you give null value. + * @param orientation Whether the slider runs left to right or bottom to top. + * @param onColorChanged Callback invoked with the color the controller ends up on. Useful when the + * slider is used without a picker beside it. * @param onStart Callback invoked when user interaction with the slider starts. * @param onFinish Callback invoked when user interaction with the slider ends. - * it will be selected on rightmost of slider if you give null value. */ @Composable public fun AlphaSlider( @@ -64,6 +67,8 @@ public fun AlphaSlider( tileEvenColor: Color = defaultTileEvenColor, tileSize: Dp = 12.dp, initialColor: Color? = null, + orientation: SliderOrientation = SliderOrientation.Horizontal, + onColorChanged: (colorEnvelope: ColorEnvelope) -> Unit = {}, onStart: () -> Unit = {}, onFinish: () -> Unit = {}, ) { @@ -90,6 +95,8 @@ public fun AlphaSlider( wheelAlpha = wheelAlpha, wheelPaint = wheelPaint, initialColor = initialColor, + orientation = orientation, + onColorChanged = onColorChanged, drawBackground = { drawRoundRect(it, borderRadius.value, paint) }, diff --git a/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/AlphaTile.kt b/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/AlphaTile.kt index 2579400..537467f 100644 --- a/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/AlphaTile.kt +++ b/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/AlphaTile.kt @@ -18,6 +18,10 @@ package com.github.skydoves.colorpicker.compose import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap @@ -26,6 +30,7 @@ import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp /** @@ -48,22 +53,29 @@ public fun AlphaTile( tileSize: Dp = 12.dp, ) { val density = LocalDensity.current - var background: ImageBitmap? = null + var canvasSize by remember { mutableStateOf(IntSize.Zero) } val colorPaint = Paint().apply { color = controller?.selectedColor?.value ?: selectedColor } - val paint = alphaTilePaint( - tileSize = with(density) { tileSize.toPx() }, - tileOddColor = tileOddColor, - tileEvenColor = tileEvenColor, - ) + // The tiles only change when the canvas or the tiling does, so they are not worth redrawing on + // every color change. + val background = remember(canvasSize, tileSize, tileOddColor, tileEvenColor, density) { + canvasSize.takeIf { it.width != 0 && it.height != 0 }?.let { size -> + val paint = alphaTilePaint( + tileSize = with(density) { tileSize.toPx() }, + tileOddColor = tileOddColor, + tileEvenColor = tileEvenColor, + ) + ImageBitmap.fromPaint(paint, size) + } + } Canvas( modifier .fillMaxSize() .onSizeChanged { size -> if (size.width != 0 && size.height != 0) { - background = ImageBitmap.fromPaint(paint, size) + canvasSize = size } }, ) { diff --git a/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/BrightnessSlider.kt b/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/BrightnessSlider.kt index dd99ce8..0f8f0d2 100644 --- a/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/BrightnessSlider.kt +++ b/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/BrightnessSlider.kt @@ -34,10 +34,15 @@ import androidx.compose.ui.unit.dp * @param borderColor [Color] of the border. * @param wheelImageBitmap [ImageBitmap] to draw the wheel. * @param wheelRadius Radius of the wheel. - * @param wheelColor [Color] of th wheel. + * @param wheelColor [Color] of the wheel. * @param wheelPaint [Paint] to draw the wheel. * @param initialColor [Color] of the initial state. This property works for [HsvColorPicker] and * it will be selected on rightmost of slider if you give null value. + * @param orientation Whether the slider runs left to right or bottom to top. + * @param onColorChanged Callback invoked with the color the controller ends up on. Useful when the + * slider is used without a picker beside it, such as picking a grey. + * @param onStart Callback invoked when user interaction with the slider starts. + * @param onFinish Callback invoked when user interaction with the slider ends. */ @Composable public fun BrightnessSlider( @@ -55,6 +60,8 @@ public fun BrightnessSlider( alpha = wheelAlpha }, initialColor: Color? = null, + orientation: SliderOrientation = SliderOrientation.Horizontal, + onColorChanged: (colorEnvelope: ColorEnvelope) -> Unit = {}, onStart: () -> Unit = {}, onFinish: () -> Unit = {}, ) { @@ -74,14 +81,18 @@ public fun BrightnessSlider( wheelAlpha = wheelAlpha, wheelPaint = wheelPaint, initialColor = initialColor, + orientation = orientation, + onColorChanged = onColorChanged, getValue = { brightness.value }, setValue = ColorPickerController::setBrightness, onStart = onStart, onFinish = onFinish, computeInitial = { maxOf(it.red, it.green, it.blue) }, getGradientColors = { - val h = pureSelectedColor.value.toHSV().first - val s = if (controller.isAttachedSaturationSlider) saturation.value else 1f + val (h, selectedSaturation, _) = pureSelectedColor.value.toHSV() + // Assuming full saturation painted a red ramp for a controller with nothing selected yet, + // which is what a standalone slider picking a grey looks like. + val s = if (controller.isAttachedSaturationSlider) saturation.value else selectedSaturation listOf( Color.Black, Color.hsv(h, s, 1f), diff --git a/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/ColorPicker.kt b/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/ColorPicker.kt index f95c70c..51b8feb 100644 --- a/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/ColorPicker.kt +++ b/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/ColorPicker.kt @@ -20,10 +20,10 @@ import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -37,7 +37,6 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.toSize -import kotlinx.coroutines.launch /** * ColorPicker allows you to get colors from a palette by tapping on the desired color. @@ -49,6 +48,7 @@ import kotlinx.coroutines.launch * @param drawOnPosSelected to draw anything on the canvas when [ColorPickerController.selectedPoint] changes * @param drawDefaultWheelIndicator should the indicator be drawn on the canvas. Defaults to false if either [wheelImageBitmap] or [drawOnPosSelected] are not null. * @param onColorChanged Color changed listener. + * @param onColorPickingFinished Invoked once when a gesture ends, with the color it settled on. */ @Composable internal fun ColorPicker( @@ -58,6 +58,7 @@ internal fun ColorPicker( drawOnPosSelected: (DrawScope.() -> Unit)? = null, drawDefaultWheelIndicator: Boolean = wheelImageBitmap == null && drawOnPosSelected == null, onColorChanged: (colorEnvelope: ColorEnvelope) -> Unit = {}, + onColorPickingFinished: (colorEnvelope: ColorEnvelope) -> Unit = {}, onStart: () -> Unit = {}, onFinish: () -> Unit = {}, sizeChanged: (IntSize) -> Unit = { _ -> }, @@ -66,18 +67,17 @@ internal fun ColorPicker( ) { var initialized by remember { mutableStateOf(false) } + // The wheel used to be handed over once, on the first layout pass, so a caller that rebuilt it + // per frame kept seeing the very first one. + SideEffect { + controller.wheelBitmap = wheelImageBitmap + } + val debounceDuration = controller.debounceDuration - val coroutineScope = rememberCoroutineScope() - DisposableEffect(key1 = controller, key2 = debounceDuration) { - val job = coroutineScope.launch { - controller.getColorFlow(debounceDuration ?: 0).collect { - onColorChanged(it) - } - } - onDispose { - job.cancel() - controller.releaseBitmap() - } + controller.ObserveColorChanges(onColorChanged) + + DisposableEffect(key1 = controller) { + onDispose { controller.releaseBitmap() } } Canvas( @@ -87,7 +87,6 @@ internal fun ColorPicker( sizeChanged(size) controller.canvasSize = size.toSize() if (!initialized) { - controller.wheelBitmap = wheelImageBitmap controller.setup() initialized = true } @@ -101,14 +100,22 @@ internal fun ColorPicker( fromUser = true, source = ColorChangeSource.Tap, ) + // A tap is a whole gesture on its own, so it finishes as soon as it lands. + onColorPickingFinished(controller.currentEnvelope(ColorChangeSource.Tap)) }, ) } .pointerInput(key1 = controller, key2 = debounceDuration) { detectDragGestures( onDragStart = { onStart() }, - onDragEnd = { onFinish() }, - onDragCancel = { onFinish() }, + onDragEnd = { + onFinish() + onColorPickingFinished(controller.currentEnvelope(ColorChangeSource.Drag)) + }, + onDragCancel = { + onFinish() + onColorPickingFinished(controller.currentEnvelope(ColorChangeSource.Drag)) + }, ) { change, _ -> controller.selectByCoordinate( point = change.position, diff --git a/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/ColorPickerController.kt b/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/ColorPickerController.kt index fafd1f4..f643f67 100644 --- a/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/ColorPickerController.kt +++ b/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/ColorPickerController.kt @@ -55,16 +55,28 @@ public fun rememberColorPickerController(): ColorPickerController = remember { public class ColorPickerController { internal var canvasSize: Size = Size.Zero set(value) { - if (value == field) { + val previous = field + if (value == previous) { return } - val cur = _selectedPoint.value - _selectedPoint.value = Offset( - // TODO: check this for aspect ratio preservation - cur.x * value.width / field.width, - cur.y * value.height / field.height, - ) field = value + + // Nothing has been laid out yet, so there is no old point to carry over. Scaling by the zero + // size used to produce Offset(NaN, NaN), which reads as Offset.Unspecified, and the canvas + // threw as soon as it tried to draw the wheel there. + if (previous.width == 0f || previous.height == 0f) { + _selectedPoint.value = value.center + return + } + + val current = _selectedPoint.value + val scaled = Offset( + current.x * value.width / previous.width, + current.y * value.height / previous.height, + ) + // Scaling each axis on its own distorts anything round, so hand the point back to the picker + // and let it say where that actually lands on the new canvas. + _selectedPoint.value = coordToColor?.invoke(scaled)?.second ?: scaled } private val _selectedPoint: MutableState<Offset> = mutableStateOf(Offset.Zero) @@ -93,8 +105,14 @@ public class ColorPickerController { private var _paletteBitmap: MutableStateFlow<ImageBitmap?> = MutableStateFlow(null) public val paletteBitmap: StateFlow<ImageBitmap?> = _paletteBitmap + private val _wheelBitmap: MutableState<ImageBitmap?> = mutableStateOf(null) + /** An [ImageBitmap] to be drawn on the canvas as a wheel. */ - public var wheelBitmap: ImageBitmap? = null + public var wheelBitmap: ImageBitmap? + get() = _wheelBitmap.value + set(value) { + _wheelBitmap.value = value + } private val _debounceDuration: MutableState<Long?> = mutableStateOf(null) @@ -155,29 +173,50 @@ public class ColorPickerController { internal val reviseTick = mutableIntStateOf(0) - private val colorFlow = MutableStateFlow<ColorEnvelope?>(null) + private val colorEvents = MutableStateFlow<ColorEnvelope?>(null) @OptIn(FlowPreview::class) public fun getColorFlow(debounceDuration: Long = 0): Flow<ColorEnvelope> = - colorFlow.filterNotNull().debounce(this.debounceDuration ?: debounceDuration) + colorEvents.filterNotNull().debounce(this.debounceDuration ?: debounceDuration) // Function that takes a coordinate and obtains a color - // Also returns an adjusted coordinate if appropriate - private var coordToColor: ((Offset) -> Pair<Color, Offset>)? = null + // Also returns an adjusted coordinate if appropriate, or null when the coordinate names nothing + // selectable, such as the band beside a letterboxed palette or a transparent pixel + private var coordToColor: ((Offset) -> Pair<Color, Offset>?)? = null + + /** True once a picker has registered itself and the canvas has a size to work with. */ + private val isReady: Boolean + get() = coordToColor != null && canvasSize != Size.Zero + + /** Set on the first [setup] call, so later ones leave the current selection alone. */ + private var isSetUp: Boolean = false + + /** A color asked for before the picker was ready, replayed once [setup] runs. */ + private var pendingColor: Color? = null /** * Setup the controller for use by a picker. The initial position is the * initial value selected by the picker. The coordinateToColor function * is used to get the color at a given coordinate. The function should * return the color at the coordinate and the adjusted coordinate if - * the coordinate was out of bounds. + * the coordinate was out of bounds, or null if the coordinate has no + * color to offer. + * + * A picker re-runs this whenever its palette changes, and a palette rebuilt inside the + * composition changes on every recomposition, so only the first call gets to move the selection. */ internal fun setup( initialPosition: Offset = canvasSize.center, - coordinateToColor: (Offset) -> Pair<Color, Offset>, + coordinateToColor: (Offset) -> Pair<Color, Offset>?, ) { this.coordToColor = coordinateToColor - selectByCoordinate(initialPosition, fromUser = false) + val position = if (isSetUp) _selectedPoint.value else initialPosition + isSetUp = true + selectByCoordinate(position, fromUser = false) + pendingColor?.let { color -> + pendingColor = null + selectByColor(color, fromUser = false) + } reviseTick.intValue++ } @@ -250,6 +289,12 @@ public class ColorPickerController { * @param fromUser Represents this event is triggered by user or not. */ public fun selectByHsv(h: Float, s: Float, v: Float, alpha: Float, fromUser: Boolean) { + // Callers reach for this from a LaunchedEffect, which runs before the picker has been laid out. + // Without a canvas there is no coordinate to map the color onto, so hold it until there is one. + if (!isReady) { + pendingColor = Color.hsv(h, s, v, alpha) + return + } var changed = selectByCoordinate(hsvToCoord(h, s, canvasSize.center)) changed = setAlpha(alpha) || changed changed = setBrightness(v) || changed @@ -302,13 +347,19 @@ public class ColorPickerController { } } + /** An envelope for the color showing right now, for reporting the end of a gesture. */ + internal fun currentEnvelope(source: ColorChangeSource): ColorEnvelope { + val color = _selectedColor.value + return ColorEnvelope(color, color.hexCode, fromUser = true, source = source) + } + /** Notify color changes to the color picker and other subcomponents. */ private fun notifyColorChanged( fromUser: Boolean, source: ColorChangeSource = ColorChangeSource.Programmatic, ) { val color = _selectedColor.value - colorFlow.value = ColorEnvelope(color, color.hexCode, fromUser, source) + colorEvents.value = ColorEnvelope(color, color.hexCode, fromUser, source) } /** @@ -321,7 +372,7 @@ public class ColorPickerController { private fun selectByCoordinate(point: Offset): Boolean { val coordToColor = coordToColor if (!enabled || coordToColor == null) return false - val (color, newPoint) = coordToColor(point) + val (color, newPoint) = coordToColor(point) ?: return false _selectedPoint.value = newPoint if (pureSelectedColor.value == color) return false _selectedColor.value = applyHSVFactors(color) diff --git a/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/HsvColorPicker.kt b/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/HsvColorPicker.kt index 82da348..0e01e1d 100644 --- a/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/HsvColorPicker.kt +++ b/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/HsvColorPicker.kt @@ -37,7 +37,9 @@ import kotlin.math.min * @param wheelImageBitmap [ImageBitmap] to draw the wheel. * @param drawOnPosSelected to draw anything on the canvas when [ColorPickerController.selectedPoint] changes * @param drawDefaultWheelIndicator should the indicator be drawn on the canvas. Defaults to false if either [wheelImageBitmap] or [drawOnPosSelected] are not null. - * @param onColorChanged Color changed listener. + * @param onColorChanged Color changed listener. Fires for every step of a gesture. + * @param onColorPickingFinished Invoked once when the user lifts their finger, with the color the + * pick settled on. Use it for work that should not run on every step, such as saving the choice. * @param onStart Callback invoked when user interaction with the picker starts. * @param onFinish Callback invoked when user interaction with the picker ends. * @param initialColor [Color] of the initial state. This property works for [HsvColorPicker] and @@ -51,6 +53,7 @@ public fun HsvColorPicker( drawOnPosSelected: (DrawScope.() -> Unit)? = null, drawDefaultWheelIndicator: Boolean = wheelImageBitmap == null && drawOnPosSelected == null, onColorChanged: (colorEnvelope: ColorEnvelope) -> Unit = {}, + onColorPickingFinished: (colorEnvelope: ColorEnvelope) -> Unit = {}, onStart: () -> Unit = {}, onFinish: () -> Unit = {}, initialColor: Color? = null, @@ -66,6 +69,7 @@ public fun HsvColorPicker( drawOnPosSelected = drawOnPosSelected, drawDefaultWheelIndicator = drawDefaultWheelIndicator, onColorChanged = onColorChanged, + onColorPickingFinished = onColorPickingFinished, onStart = onStart, onFinish = onFinish, sizeChanged = { @@ -92,6 +96,9 @@ public fun HsvColorPicker( Color.hsv(0f, 0f, 0f) to newPoint } } + // The palette only carries hue and saturation, so a coordinate alone reports the initial + // color at full brightness. Replaying the color puts its value and alpha back. + initialColor?.let { controller.selectByColor(it, fromUser = false) } }, draw = { drawHsvColorGradient(controller.canvasSize) }, ) diff --git a/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/HueSlider.kt b/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/HueSlider.kt index 180c8a5..54d58f6 100644 --- a/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/HueSlider.kt +++ b/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/HueSlider.kt @@ -38,7 +38,8 @@ import androidx.compose.ui.unit.dp * @param wheelPaint [Paint] used to draw the wheel. * @param initialColor [Color] of the initial state. If null, the slider uses the controller's current color. * @param onColorChanged Callback invoked when the hue value changes. - * Provides [ColorChangeSource] and the updated hue value in range [0f..360f]. + * Provides [ColorChangeSource] and the new position along the slider, in range `0f..1f`. + * @param orientation Whether the slider runs left to right or bottom to top. * @param onStart Callback invoked when user interaction with the slider starts. * @param onFinish Callback invoked when user interaction with the slider ends. */ @@ -58,6 +59,7 @@ public fun HueSlider( alpha = wheelAlpha }, initialColor: Color? = null, + orientation: SliderOrientation = SliderOrientation.Horizontal, onColorChanged: (ColorChangeSource, Float) -> Unit = { _, _ -> }, onStart: () -> Unit = {}, onFinish: () -> Unit = {}, @@ -74,9 +76,10 @@ public fun HueSlider( wheelAlpha = wheelAlpha, wheelPaint = wheelPaint, initialColor = initialColor, + orientation = orientation, getValue = { pureSelectedColor.value.toHSV().first / 360f }, setValue = ColorPickerController::setHue, - onColorChanged = onColorChanged, + onValueChanged = onColorChanged, onStart = onStart, onFinish = onFinish, computeInitial = { it.toHSV().first / 360f }, diff --git a/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/ImageColorPicker.kt b/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/ImageColorPicker.kt index ee871ca..e2b9f6d 100644 --- a/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/ImageColorPicker.kt +++ b/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/ImageColorPicker.kt @@ -26,15 +26,20 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.unit.IntSize +import kotlin.math.floor /** * ImageColorPicker allows you to get colors from any images by tapping on the desired color. * + * A palette that does not fill its canvas leaves bands beside it, and an image can carry + * transparent pixels. Neither has a color to offer, so a tap there leaves the selection as it is. + * * @param modifier [Modifier] to decorate the internal Canvas. * @param controller Allows you to control and interacts with color pickers and all relevant subcomponents. * @param paletteImageBitmap [ImageBitmap] to draw the palette. @@ -43,7 +48,9 @@ import androidx.compose.ui.unit.IntSize * @param drawDefaultWheelIndicator should the indicator be drawn on the canvas. Defaults to false if either [wheelImageBitmap] or [drawOnPosSelected] are not null. * @param paletteContentScale Represents a rule to apply to scale a source rectangle to be inscribed into a destination. * @param previewImagePainter Display an image instead of the palette on the inspection preview mode on Android Studio. - * @param onColorChanged Color changed listener. + * @param onColorChanged Color changed listener. Fires for every step of a gesture. + * @param onColorPickingFinished Invoked once when the user lifts their finger, with the color the + * pick settled on. Use it for work that should not run on every step, such as saving the choice. */ @Composable public fun ImageColorPicker( @@ -56,6 +63,7 @@ public fun ImageColorPicker( paletteContentScale: PaletteContentScale = PaletteContentScale.FIT, previewImagePainter: Painter? = null, onColorChanged: (colorEnvelope: ColorEnvelope) -> Unit = {}, + onColorPickingFinished: (colorEnvelope: ColorEnvelope) -> Unit = {}, onStart: () -> Unit = {}, onFinish: () -> Unit = {}, ) { @@ -78,20 +86,23 @@ public fun ImageColorPicker( var offset by remember { mutableStateOf(Offset.Zero) } var scale by remember { mutableStateOf(1f) } - LaunchedEffect(key1 = imageBitmap) { - controller.setup { point -> - val origPoint = (point - offset) / scale - val imPoint = Offset( - origPoint.x.coerceIn(0f, width - 1f), - origPoint.y.coerceIn(0f, height - 1f), - ) - // TODO: transparent pixel handling - val px = imageBitmap.getPixel(imPoint.roundToInt()) - val newPoint = imPoint * scale + offset - px to newPoint + val paletteColorAt: (Offset) -> Pair<Color, Offset>? = { point -> + val origPoint = (point - offset) / scale + val x = floor(origPoint.x).toInt() + val y = floor(origPoint.y).toInt() + when { + // A palette that does not fill its canvas leaves bands beside it. Those used to report the + // nearest edge pixel, so a tap well outside the image still came back with a color. + x !in 0 until width || y !in 0 until height -> null + + else -> imageBitmap.getPixel(x, y).takeIf { it.alpha != 0f }?.let { it to point } } } + LaunchedEffect(key1 = imageBitmap) { + controller.setup(coordinateToColor = paletteColorAt) + } + ColorPicker( modifier = modifier, controller = controller, @@ -99,6 +110,7 @@ public fun ImageColorPicker( drawOnPosSelected = drawOnPosSelected, drawDefaultWheelIndicator = drawDefaultWheelIndicator, onColorChanged = onColorChanged, + onColorPickingFinished = onColorPickingFinished, onStart = onStart, onFinish = onFinish, sizeChanged = { size -> @@ -115,17 +127,7 @@ public fun ImageColorPicker( offset = metrics.second }, setup = { - controller.setup { point -> - val origPoint = (point - offset) / scale - val imPoint = Offset( - origPoint.x.coerceIn(0f, width - 1f), - origPoint.y.coerceIn(0f, height - 1f), - ) - // TODO: transparent pixel handling - val px = imageBitmap.getPixel(imPoint.roundToInt()) - val newPoint = imPoint * scale + offset - px to newPoint - } + controller.setup(coordinateToColor = paletteColorAt) }, draw = { drawImageRect( diff --git a/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/ObserveColorChanges.kt b/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/ObserveColorChanges.kt new file mode 100644 index 0000000..0a41f50 --- /dev/null +++ b/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/ObserveColorChanges.kt @@ -0,0 +1,43 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.skydoves.colorpicker.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import kotlinx.coroutines.launch + +/** + * Reports this controller's color changes to [onColorChanged] while the caller is composed. + * + * Sliders need this as much as the pickers do. A slider used on its own, to pick a grey by + * brightness for example, has no picker alongside it to do the reporting. + */ +@Composable +internal fun ColorPickerController.ObserveColorChanges( + onColorChanged: ((ColorEnvelope) -> Unit)?, +) { + val callback = rememberUpdatedState(onColorChanged) + val scope = rememberCoroutineScope() + val debounceDuration = debounceDuration + DisposableEffect(key1 = this, key2 = debounceDuration) { + val job = scope.launch { + getColorFlow(debounceDuration ?: 0).collect { callback.value?.invoke(it) } + } + onDispose { job.cancel() } + } +} diff --git a/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/PaletteContentScale.kt b/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/PaletteContentScale.kt index ea7675f..b288eb0 100644 --- a/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/PaletteContentScale.kt +++ b/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/PaletteContentScale.kt @@ -25,16 +25,12 @@ public enum class PaletteContentScale { FIT, /** - * Crop ths source the corresponding dimension of the target size. + * Crop the source the corresponding dimension of the target size. * so that if the dimensions (width and height) source is bigger than the target size, * it will be cut off from the center. */ CROP, - /* - * Scale the source with maintaining the source's aspect ratio - * so that if both dimensions (width and height) of the source is smaller than the target size, - * it will not be scaled. - */ -// INSIDE, + // INSIDE would scale the source keeping its aspect ratio, so that a source smaller than the + // target in both dimensions is left alone. Not implemented yet. } diff --git a/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/SaturationSlider.kt b/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/SaturationSlider.kt index 4add763..32b9586 100644 --- a/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/SaturationSlider.kt +++ b/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/SaturationSlider.kt @@ -38,6 +38,9 @@ import androidx.compose.ui.unit.dp * @param wheelAlpha Alpha value applied to the wheel. * @param wheelPaint [Paint] used to draw the wheel. * @param initialColor [Color] of the initial state. If null, the slider uses the controller's current color. + * @param orientation Whether the slider runs left to right or bottom to top. + * @param onColorChanged Callback invoked with the color the controller ends up on. Useful when the + * slider is used without a picker beside it. * @param onStart Callback invoked when user interaction with the slider starts. * @param onFinish Callback invoked when user interaction with the slider ends. */ @@ -57,6 +60,8 @@ public fun SaturationSlider( alpha = wheelAlpha }, initialColor: Color? = null, + orientation: SliderOrientation = SliderOrientation.Horizontal, + onColorChanged: (colorEnvelope: ColorEnvelope) -> Unit = {}, onStart: () -> Unit = {}, onFinish: () -> Unit = {}, ) { @@ -76,6 +81,8 @@ public fun SaturationSlider( wheelAlpha = wheelAlpha, wheelPaint = wheelPaint, initialColor = initialColor, + orientation = orientation, + onColorChanged = onColorChanged, drawBackground = {}, getValue = { saturation.value }, setValue = ColorPickerController::setSaturation, diff --git a/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/Slider.kt b/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/Slider.kt index 01adb5e..fdb6cb3 100644 --- a/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/Slider.kt +++ b/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/Slider.kt @@ -18,6 +18,8 @@ package com.github.skydoves.colorpicker.compose import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable @@ -44,7 +46,7 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp /** - * Slider allows you to adjust the a value of the selected color from color pickers. + * Slider allows you to adjust the value of the selected color from color pickers. * See [AlphaSlider] and [BrightnessSlider] for concrete versions. * * @param modifier [Modifier] to decorate the internal Canvas. @@ -54,16 +56,18 @@ import androidx.compose.ui.unit.dp * @param borderColor [Color] of the border. * @param wheelImageBitmap [ImageBitmap] to draw the wheel. * @param wheelRadius Radius of the wheel. - * @param wheelColor [Color] of th wheel. + * @param wheelColor [Color] of the wheel. * @param wheelPaint [Paint] to draw the wheel. * @param initialColor [Color] of the initial state. This property works for [HsvColorPicker] and * it will be selected on rightmost of slider if you give null value. + * @param orientation Whether the slider runs left to right or bottom to top. * * @param drawBackground optional function to draw anything on the canvas * @param getValue function to get the current value from the controller * @param setValue function to set the current value on the controller - * @param onColorChanged Callback invoked when value changes. + * @param onValueChanged Callback invoked when value changes, with the new value in `0f..1f`. * Provides [ColorChangeSource] the update trigger source. + * @param onColorChanged Callback invoked with the color the controller ends up on. * @param onStart Callback invoked when user interaction with the slider starts. * @param onFinish Callback invoked when user interaction with the slider ends. * @param computeInitial function to compute the initial value from the initial color @@ -85,117 +89,162 @@ internal fun Slider( alpha = wheelAlpha }, initialColor: Color? = null, + orientation: SliderOrientation = SliderOrientation.Horizontal, drawBackground: Canvas.(IntSize) -> Unit = {}, getValue: ColorPickerController.() -> Float, setValue: ColorPickerController.(Float, fromUser: Boolean, source: ColorChangeSource) -> Unit, - onColorChanged: (ColorChangeSource, Float) -> Unit = { _, _ -> }, + onValueChanged: (ColorChangeSource, Float) -> Unit = { _, _ -> }, + onColorChanged: ((ColorEnvelope) -> Unit)? = null, onStart: () -> Unit = {}, onFinish: () -> Unit = {}, computeInitial: (Color) -> Float, getGradientColors: ColorPickerController.() -> List<Color>, ) { val density = LocalDensity.current + val isVertical = orientation == SliderOrientation.Vertical val debounceDuration = controller.debounceDuration - var background: ImageBitmap? = null + + var canvasSize by remember { mutableStateOf(IntSize.Zero) } + var isInitialized by remember { mutableStateOf(false) } + val borderPaint = Paint().apply { style = PaintingStyle.Stroke strokeWidth = with(density) { borderSize.toPx() } color = borderColor } - val colorPaint = Paint().apply { - color = controller.pureSelectedColor.value - } + // The shader below supplies every color this paint draws. Taking one from the controller here + // also took its alpha, and a controller with nothing selected yet is fully transparent, which + // left a standalone slider drawing nothing at all. + val colorPaint = Paint() val wheelRadiusPx = with(density) { wheelRadius.toPx() } + // Half a thumb at each end, so it sits inside the track instead of being clipped by it. + val thumbInset = wheelImageBitmap + ?.let { (if (isVertical) it.height else it.width) * 0.5f } + ?: wheelRadiusPx - var isInitialized by remember { mutableStateOf(false) } + val background = canvasSize.takeIf { it.width != 0 && it.height != 0 }?.let { size -> + ImageBitmap.fromDrawing(size) { + drawBackground(size) + drawRoundRect(size, borderRadius.value, borderPaint) + } + } - fun setValue(wheelPoint: Float, source: ColorChangeSource) { - val position = background?.width?.toFloat()?.let { wheelPoint / it } ?: 0f - controller.setValue(position.coerceIn(0f, 1f), true, source) - onColorChanged.invoke(source, position) + controller.ObserveColorChanges(onColorChanged) + + fun setValue(point: Float, source: ColorChangeSource) { + val size = canvasSize.takeIf { it.width != 0 && it.height != 0 } ?: return + val travel = travelOf(size, isVertical, thumbInset) + val fraction = (point - thumbInset) / travel + // Vertical sliders run bottom to top, the way anyone expects a fader to. + val position = (if (isVertical) 1f - fraction else fraction).coerceIn(0f, 1f) + controller.setValue(position, true, source) + onValueChanged(source, position) } Canvas( modifier = modifier - .fillMaxWidth() + .then(if (isVertical) Modifier.fillMaxHeight() else Modifier.fillMaxWidth()) .clip(RoundedCornerShape(borderRadius)) .onSizeChanged { size -> if (size.width != 0 && size.height != 0) { - background = ImageBitmap.fromDrawing(size) { - drawBackground(size) - drawRoundRect(size, borderRadius.value, borderPaint) + canvasSize = size + if (initialColor != null && !isInitialized) { + isInitialized = true + controller.setValue(computeInitial(initialColor), false, ColorChangeSource.Programmatic) } } } - .pointerInput(Unit) { + .pointerInput(key1 = controller, key2 = orientation) { detectTapGestures( onTap = { offset -> - setValue(offset.x, ColorChangeSource.Tap) + setValue(if (isVertical) offset.y else offset.x, ColorChangeSource.Tap) }, ) } - .pointerInput(key1 = controller, key2 = debounceDuration) { - detectHorizontalDragGestures( - onDragStart = { onStart() }, - onDragEnd = { onFinish() }, - onDragCancel = { onFinish() }, - ) { change, _ -> - setValue(change.position.x, ColorChangeSource.Drag) + .pointerInput(controller, debounceDuration, orientation) { + if (isVertical) { + detectVerticalDragGestures( + onDragStart = { onStart() }, + onDragEnd = { onFinish() }, + onDragCancel = { onFinish() }, + ) { change, _ -> setValue(change.position.y, ColorChangeSource.Drag) } + } else { + detectHorizontalDragGestures( + onDragStart = { onStart() }, + onDragEnd = { onFinish() }, + onDragCancel = { onFinish() }, + ) { change, _ -> setValue(change.position.x, ColorChangeSource.Drag) } } }, ) { drawIntoCanvas { canvas -> background?.let { - val (width, height) = it.size + val size = it.size // draw background bitmap. canvas.drawImage(it) // draw a linear gradient color shader. - val halfHeight = height * 0.5f colorPaint.shader = LinearGradientShader( colors = controller.getGradientColors(), - from = Offset(0f, halfHeight), - to = Offset(width.toFloat(), halfHeight), + from = gradientStart(size, isVertical), + to = gradientEnd(size, isVertical), tileMode = TileMode.Clamp, ) - canvas.drawRoundRect(it.size, borderRadius.value, colorPaint) + canvas.drawRoundRect(size, borderRadius.value, colorPaint) // draw wheel bitmap on the canvas. canvas.drawWheel( - position = controller.getValue(), - width = width, - height = height, + center = thumbCenter(size, isVertical, thumbInset, controller.getValue()), wheelImageBitmap = wheelImageBitmap, wheelRadius = wheelRadiusPx, - wheelColor = wheelColor, - wheelAlpha = wheelAlpha, wheelPaint = wheelPaint, ) } - - if (initialColor != null && !isInitialized) { - isInitialized = true - controller.setValue(computeInitial(initialColor), false, ColorChangeSource.Programmatic) - } } } } -private fun Canvas.drawWheel( +/** How far the thumb center can travel, once a radius is reserved at each end. */ +private fun travelOf(size: IntSize, isVertical: Boolean, thumbInset: Float): Float { + val length = (if (isVertical) size.height else size.width).toFloat() + return (length - 2f * thumbInset).coerceAtLeast(1f) +} + +private fun thumbCenter( + size: IntSize, + isVertical: Boolean, + thumbInset: Float, position: Float, - width: Int, - height: Int, +): Offset { + val fraction = position.coerceIn(0f, 1f) + val along = thumbInset + (if (isVertical) 1f - fraction else fraction) * + travelOf(size, isVertical, thumbInset) + return if (isVertical) { + Offset(size.width * 0.5f, along) + } else { + Offset(along, size.height * 0.5f) + } +} + +private fun gradientStart(size: IntSize, isVertical: Boolean): Offset = if (isVertical) { + Offset(size.width * 0.5f, size.height.toFloat()) +} else { + Offset(0f, size.height * 0.5f) +} + +private fun gradientEnd(size: IntSize, isVertical: Boolean): Offset = if (isVertical) { + Offset(size.width * 0.5f, 0f) +} else { + Offset(size.width.toFloat(), size.height * 0.5f) +} + +private fun Canvas.drawWheel( + center: Offset, wheelImageBitmap: ImageBitmap?, wheelRadius: Float, - wheelColor: Color = Color.White, - wheelAlpha: Float = 1.0f, - wheelPaint: Paint = Paint().apply { - color = wheelColor - alpha = wheelAlpha - }, + wheelPaint: Paint, ) { - val center = Offset(position.coerceIn(0f, 1f) * width, height * 0.5f) if (wheelImageBitmap == null) { drawCircle(center, wheelRadius, wheelPaint) } else { diff --git a/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/SliderOrientation.kt b/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/SliderOrientation.kt new file mode 100644 index 0000000..7b12739 --- /dev/null +++ b/colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/SliderOrientation.kt @@ -0,0 +1,26 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.skydoves.colorpicker.compose + +/** Which way a slider runs, and therefore which way a drag moves its value. */ +public enum class SliderOrientation { + + /** Left to right, the default. The slider fills the width it is given. */ + Horizontal, + + /** Bottom to top, so the lowest value sits at the bottom. The slider fills the height. */ + Vertical, +} diff --git a/colorpicker-compose/src/commonTest/kotlin/com/github/skydoves/colorpicker/compose/ColorEnvelopeTest.kt b/colorpicker-compose/src/commonTest/kotlin/com/github/skydoves/colorpicker/compose/ColorEnvelopeTest.kt new file mode 100644 index 0000000..b9e4843 --- /dev/null +++ b/colorpicker-compose/src/commonTest/kotlin/com/github/skydoves/colorpicker/compose/ColorEnvelopeTest.kt @@ -0,0 +1,48 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.skydoves.colorpicker.compose + +import androidx.compose.ui.graphics.Color +import kotlin.test.Test +import kotlin.test.assertEquals + +class ColorEnvelopeTest { + + @Test + fun anEnvelopeDefaultsToAProgrammaticSource() { + val envelope = ColorEnvelope(Color.Red, Color.Red.hexCode, fromUser = false) + + assertEquals(ColorChangeSource.Programmatic, envelope.source) + } + + @Test + fun envelopesWithTheSameContentAreEqual() { + val first = ColorEnvelope(Color.Red, "ffff0000", true, ColorChangeSource.Tap) + val second = ColorEnvelope(Color.Red, "ffff0000", true, ColorChangeSource.Tap) + + assertEquals(first, second) + assertEquals(first.hashCode(), second.hashCode()) + } + + @Test + fun theSourceIsPartOfTheIdentity() { + val tapped = ColorEnvelope(Color.Red, "ffff0000", true, ColorChangeSource.Tap) + val dragged = tapped.copy(source = ColorChangeSource.Drag) + + assertEquals(ColorChangeSource.Drag, dragged.source) + assertEquals(tapped.color, dragged.color) + } +} diff --git a/colorpicker-compose/src/commonTest/kotlin/com/github/skydoves/colorpicker/compose/ColorExtensionsTest.kt b/colorpicker-compose/src/commonTest/kotlin/com/github/skydoves/colorpicker/compose/ColorExtensionsTest.kt new file mode 100644 index 0000000..1800003 --- /dev/null +++ b/colorpicker-compose/src/commonTest/kotlin/com/github/skydoves/colorpicker/compose/ColorExtensionsTest.kt @@ -0,0 +1,112 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.skydoves.colorpicker.compose + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ColorExtensionsTest { + + @Test + fun toHsvReadsTheHueOfEveryPrimaryAndSecondary() { + assertEquals(0f, Color.Red.toHSV().first) + assertEquals(60f, Color.Yellow.toHSV().first) + assertEquals(120f, Color.Green.toHSV().first) + assertEquals(180f, Color.Cyan.toHSV().first) + assertEquals(240f, Color.Blue.toHSV().first) + assertEquals(300f, Color.Magenta.toHSV().first) + } + + @Test + fun toHsvReportsFullSaturationAndValueForPrimaries() { + val (_, s, v) = Color.Red.toHSV() + assertEquals(1f, s) + assertEquals(1f, v) + } + + @Test + fun toHsvReportsNoSaturationForGreys() { + assertEquals(Triple(0f, 0f, 1f), Color.White.toHSV()) + assertEquals(Triple(0f, 0f, 0f), Color.Black.toHSV()) + + val (h, s, v) = Color(0xFF808080).toHSV() + assertEquals(0f, h) + assertEquals(0f, s) + assertTrue(abs(v - 0.5019608f) < 1e-5f, "expected a mid value but was $v") + } + + @Test + fun toHsvKeepsTheValueOfADarkColor() { + val (h, s, v) = Color(0xFF000080).toHSV() + assertEquals(240f, h) + assertEquals(1f, s) + assertTrue(abs(v - 0.5019608f) < 1e-5f, "expected a halved value but was $v") + } + + @Test + fun hexCodeIsArgbAndAlwaysEightCharacters() { + assertEquals("ffff0000", Color.Red.hexCode) + assertEquals("ff00ff00", Color.Green.hexCode) + assertEquals("ff0000ff", Color.Blue.hexCode) + assertEquals("ff000000", Color.Black.hexCode) + assertEquals("00000000", Color.Transparent.hexCode) + assertEquals("80ff0000", Color(0x80FF0000).hexCode) + } + + @Test + fun angleToHueAndHueToAngleAreInverses() { + for (hue in 0 until 360 step 15) { + val roundTripped = angleToHue(hueToAngle(hue.toFloat())) + assertTrue( + abs(roundTripped - hue) < 1e-3f, + "hue $hue came back as $roundTripped", + ) + } + } + + @Test + fun hsvToCoordPutsRedToTheRightOfTheCenter() { + val center = Offset(100f, 100f) + val point = hsvToCoord(h = 0f, s = 1f, center = center) + assertTrue(abs(point.x - 200f) < 1e-3f, "expected the right edge but was ${point.x}") + assertTrue(abs(point.y - 100f) < 1e-3f, "expected the vertical middle but was ${point.y}") + } + + @Test + fun hsvToCoordPutsCyanToTheLeftOfTheCenter() { + val center = Offset(100f, 100f) + val point = hsvToCoord(h = 180f, s = 1f, center = center) + assertTrue(abs(point.x) < 1e-3f, "expected the left edge but was ${point.x}") + assertTrue(abs(point.y - 100f) < 1e-3f, "expected the vertical middle but was ${point.y}") + } + + @Test + fun hsvToCoordCollapsesToTheCenterWhenSaturationIsZero() { + val center = Offset(100f, 100f) + assertEquals(center, hsvToCoord(h = 210f, s = 0f, center = center)) + } + + @Test + fun hsvToCoordScalesWithSaturation() { + val center = Offset(100f, 100f) + val half = hsvToCoord(h = 0f, s = 0.5f, center = center) + assertTrue(abs(half.x - 150f) < 1e-3f, "expected half the radius but was ${half.x}") + } +} diff --git a/colorpicker-compose/src/commonTest/kotlin/com/github/skydoves/colorpicker/compose/ColorPickerControllerTest.kt b/colorpicker-compose/src/commonTest/kotlin/com/github/skydoves/colorpicker/compose/ColorPickerControllerTest.kt new file mode 100644 index 0000000..0928e7e --- /dev/null +++ b/colorpicker-compose/src/commonTest/kotlin/com/github/skydoves/colorpicker/compose/ColorPickerControllerTest.kt @@ -0,0 +1,287 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.skydoves.colorpicker.compose + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.isSpecified +import androidx.compose.ui.graphics.Color +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ColorPickerControllerTest { + + private fun TestScope.collectEnvelopes(controller: ColorPickerController): List<ColorEnvelope> = + mutableListOf<ColorEnvelope>().also { sink -> + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + controller.getColorFlow().toList(sink) + } + } + + @Test + fun selectingTheRightEdgeOfThePalettePicksRed() = runTest { + val controller = ColorPickerController() + controller.setupHsvPalette() + + controller.selectByCoordinate(Offset(200f, 100f), fromUser = true) + + assertColorEquals(Color.Red, controller.selectedColor.value) + } + + @Test + fun selectingTheLeftEdgeOfThePalettePicksCyan() = runTest { + val controller = ColorPickerController() + controller.setupHsvPalette() + + controller.selectByCoordinate(Offset(0f, 100f), fromUser = true) + + assertColorEquals(Color.Cyan, controller.selectedColor.value) + } + + @Test + fun selectingTheCenterOfThePalettePicksWhite() = runTest { + val controller = ColorPickerController() + controller.setupHsvPalette() + + controller.selectCenter(fromUser = true) + + assertColorEquals(Color.White, controller.selectedColor.value) + assertOffsetEquals(Offset(100f, 100f), controller.selectedPoint.value) + } + + @Test + fun aPointOutsideTheWheelIsSnappedOntoItsEdge() = runTest { + val controller = ColorPickerController() + controller.setupHsvPalette() + + controller.selectByCoordinate(Offset(400f, 100f), fromUser = true) + + assertOffsetEquals(Offset(200f, 100f), controller.selectedPoint.value) + assertColorEquals(Color.Red, controller.selectedColor.value) + } + + @Test + fun selectByColorRoundTripsAHueThroughTheCoordinateSpace() = runTest { + val controller = ColorPickerController() + controller.setupHsvPalette() + + controller.selectByColor(Color.Magenta, fromUser = false) + + assertColorEquals(Color.Magenta, controller.selectedColor.value) + } + + @Test + fun selectByHsvAcceptsAFloatArray() = runTest { + val controller = ColorPickerController() + controller.setupHsvPalette() + + controller.selectByHsv(floatArrayOf(120f, 1f, 1f), alpha = 1f, fromUser = false) + + assertColorEquals(Color.Green, controller.selectedColor.value) + } + + @Test + fun setAlphaAppliesToTheSelectedColor() = runTest { + val controller = ColorPickerController() + controller.setupHsvPalette() + controller.selectByCoordinate(Offset(200f, 100f), fromUser = false) + + controller.setAlpha(0.5f, fromUser = true) + + assertColorEquals(Color.Red.copy(alpha = 0.5f), controller.selectedColor.value) + } + + @Test + fun setBrightnessDarkensTheSelectedColor() = runTest { + val controller = ColorPickerController() + controller.setupHsvPalette() + controller.selectByCoordinate(Offset(200f, 100f), fromUser = false) + + controller.setBrightness(0.5f, fromUser = true) + + assertColorEquals(Color(0xFF800000), controller.selectedColor.value) + } + + @Test + fun setSaturationWashesOutTheSelectedColor() = runTest { + val controller = ColorPickerController() + controller.setupHsvPalette() + controller.selectByCoordinate(Offset(200f, 100f), fromUser = false) + + controller.setSaturation(0f, fromUser = true) + + assertColorEquals(Color.White, controller.selectedColor.value) + } + + @Test + fun setHueRotatesTheSelectedColor() = runTest { + val controller = ColorPickerController() + controller.setupHsvPalette() + + controller.setHue(240f / 360f, fromUser = true) + + assertColorEquals(Color.Blue, controller.selectedColor.value) + } + + @Test + fun theColorFlowReportsEveryChangeWithItsSource() = runTest { + val controller = ColorPickerController() + controller.setupHsvPalette() + val envelopes = collectEnvelopes(controller) + + controller.selectByCoordinate(Offset(200f, 100f), true, ColorChangeSource.Tap) + controller.selectByCoordinate(Offset(100f, 0f), true, ColorChangeSource.Drag) + + assertEquals(ColorChangeSource.Tap, envelopes[envelopes.lastIndex - 1].source) + assertEquals(ColorChangeSource.Drag, envelopes.last().source) + assertTrue(envelopes.last().fromUser) + } + + @Test + fun theColorFlowCarriesTheHexCodeOfTheSelectedColor() = runTest { + val controller = ColorPickerController() + controller.setupHsvPalette() + val envelopes = collectEnvelopes(controller) + + controller.selectByCoordinate(Offset(200f, 100f), fromUser = true) + + assertEquals("ffff0000", envelopes.last().hexCode) + } + + @Test + fun programmaticChangesAreNotReportedAsComingFromTheUser() = runTest { + val controller = ColorPickerController() + controller.setupHsvPalette() + val envelopes = collectEnvelopes(controller) + + controller.selectByCoordinate(Offset(200f, 100f), fromUser = false) + + assertFalse(envelopes.last().fromUser) + assertEquals(ColorChangeSource.Programmatic, envelopes.last().source) + } + + @Test + fun reselectingTheSameColorDoesNotEmitAgain() = runTest { + val controller = ColorPickerController() + controller.setupHsvPalette() + val envelopes = collectEnvelopes(controller) + controller.selectByCoordinate(Offset(200f, 100f), fromUser = true) + val count = envelopes.size + + controller.selectByCoordinate(Offset(200f, 100f), fromUser = true) + + assertEquals(count, envelopes.size) + } + + @Test + fun aDisabledControllerIgnoresSelection() = runTest { + val controller = ColorPickerController() + controller.setupHsvPalette() + controller.selectCenter(fromUser = false) + controller.enabled = false + + controller.selectByCoordinate(Offset(200f, 100f), fromUser = true) + + assertColorEquals(Color.White, controller.selectedColor.value) + } + + @Test + fun aDisabledControllerStartsAcceptingSelectionAgainOnceReenabled() = runTest { + val controller = ColorPickerController() + controller.setupHsvPalette() + controller.enabled = false + controller.selectByCoordinate(Offset(200f, 100f), fromUser = true) + + controller.enabled = true + controller.selectByCoordinate(Offset(200f, 100f), fromUser = true) + + assertColorEquals(Color.Red, controller.selectedColor.value) + } + + @Test + fun growingTheCanvasKeepsTheSelectionInTheSameRelativeSpot() = runTest { + val controller = ColorPickerController() + val palette = controller.setupHsvPalette() + controller.selectByCoordinate(Offset(200f, 100f), fromUser = false) + + palette.resize(Size(400f, 400f)) + + assertOffsetEquals(Offset(400f, 200f), controller.selectedPoint.value) + } + + @Test + fun aNonUniformResizeKeepsTheIndicatorOnTheWheel() = runTest { + val controller = ColorPickerController() + val palette = controller.setupHsvPalette() + controller.selectByCoordinate(Offset(200f, 100f), fromUser = false) + + palette.resize(Size(400f, 200f)) + + // Scaling each axis on its own would have parked it at (400, 100), well off a wheel that now + // has a radius of 100 around (200, 100). + assertOffsetEquals(Offset(300f, 100f), controller.selectedPoint.value) + } + + @Test + fun theFirstCanvasSizeNeverLeavesAnUnspecifiedPoint() = runTest { + val controller = ColorPickerController() + + controller.canvasSize = Size(200f, 200f) + + assertTrue( + controller.selectedPoint.value.isSpecified, + "the indicator sat at ${controller.selectedPoint.value}", + ) + } + + @Test + fun aSecondSetupLeavesTheCurrentSelectionAlone() = runTest { + val controller = ColorPickerController() + val palette = controller.setupHsvPalette() + controller.selectByCoordinate(Offset(200f, 100f), fromUser = true) + + palette.attach(Size(200f, 200f), initialColor = null) + + assertColorEquals(Color.Red, controller.selectedColor.value) + assertOffsetEquals(Offset(200f, 100f), controller.selectedPoint.value) + } + + @Test + fun aColorChosenBeforeSetupIsAppliedWhenThePickerArrives() = runTest { + val controller = ColorPickerController() + + controller.selectByColor(Color.Cyan, fromUser = false) + controller.setupHsvPalette() + + assertColorEquals(Color.Cyan, controller.selectedColor.value) + } + + @Test + fun theDebounceDurationIsReadBack() = runTest { + val controller = ColorPickerController() + + controller.debounceDuration = 300L + + assertEquals(300L, controller.debounceDuration) + } +} diff --git a/colorpicker-compose/src/commonTest/kotlin/com/github/skydoves/colorpicker/compose/ColorPickerTestFixtures.kt b/colorpicker-compose/src/commonTest/kotlin/com/github/skydoves/colorpicker/compose/ColorPickerTestFixtures.kt new file mode 100644 index 0000000..f26a4f7 --- /dev/null +++ b/colorpicker-compose/src/commonTest/kotlin/com/github/skydoves/colorpicker/compose/ColorPickerTestFixtures.kt @@ -0,0 +1,76 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.skydoves.colorpicker.compose + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.Color +import kotlin.math.abs +import kotlin.math.min +import kotlin.test.assertTrue + +/** + * The same hue/saturation mapping [HsvColorPicker] installs, without needing a composition. Hue + * runs counter clockwise from the right of the canvas and saturation grows with the distance from + * the center. + */ +internal class HsvPalette(private val controller: ColorPickerController) { + + private var center: Offset = Offset.Zero + private var radius: Float = 0f + + /** Mirrors a layout pass: the picker takes its new geometry first, then the controller hears. */ + fun resize(size: Size) { + center = size.center + radius = size.minDimension * 0.5f + controller.canvasSize = size + } + + fun attach(size: Size, initialColor: Color?) { + resize(size) + val initialPosition = initialColor?.let { + val (h, s, _) = it.toHSV() + hsvToCoord(h, s, center) + } ?: center + controller.setup(initialPosition) { point -> + val vector = point - center + val angle = vector.angle() + val sat = min(vector.length() / radius, 1f) + Color.hsv(angleToHue(angle), sat, 1f) to Offset.fromAngle(angle, sat * radius) + center + } + initialColor?.let { controller.selectByColor(it, fromUser = false) } + } +} + +internal fun ColorPickerController.setupHsvPalette( + size: Size = Size(200f, 200f), + initialColor: Color? = null, +): HsvPalette = HsvPalette(this).also { it.attach(size, initialColor) } + +/** Floats coming out of the hue/saturation round trip land a hair off, so compare with a margin. */ +internal fun assertColorEquals(expected: Color, actual: Color, tolerance: Float = 0.01f) { + val matches = abs(expected.red - actual.red) <= tolerance && + abs(expected.green - actual.green) <= tolerance && + abs(expected.blue - actual.blue) <= tolerance && + abs(expected.alpha - actual.alpha) <= tolerance + assertTrue(matches, "expected $expected but was $actual") +} + +internal fun assertOffsetEquals(expected: Offset, actual: Offset, tolerance: Float = 0.01f) { + val matches = abs(expected.x - actual.x) <= tolerance && abs(expected.y - actual.y) <= tolerance + assertTrue(matches, "expected $expected but was $actual") +} diff --git a/colorpicker-compose/src/commonTest/kotlin/com/github/skydoves/colorpicker/compose/GeometryExtensionsTest.kt b/colorpicker-compose/src/commonTest/kotlin/com/github/skydoves/colorpicker/compose/GeometryExtensionsTest.kt new file mode 100644 index 0000000..60de7a8 --- /dev/null +++ b/colorpicker-compose/src/commonTest/kotlin/com/github/skydoves/colorpicker/compose/GeometryExtensionsTest.kt @@ -0,0 +1,105 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.skydoves.colorpicker.compose + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import kotlin.math.PI +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class GeometryExtensionsTest { + + @Test + fun degreesAndRadiansConvertBothWays() { + assertTrue(abs(180f.toRadians() - PI_F) < 1e-5f) + assertTrue(abs(PI_F.toDegrees() - 180f) < 1e-3f) + assertTrue(abs(90f.toRadians().toDegrees() - 90f) < 1e-3f) + } + + @Test + fun intSizeCenterIsHalfOfEachSide() { + assertEquals(Offset(50f, 30f), IntSize(100, 60).center) + } + + @Test + fun intSizeRadiusFollowsTheShorterSide() { + assertEquals(30f, IntSize(100, 60).radius) + assertEquals(30f, IntSize(60, 100).radius) + } + + @Test + fun intSizeScalesAndRounds() { + assertEquals(IntSize(50, 30), IntSize(100, 60) * 0.5f) + assertEquals(IntSize(33, 20), IntSize(100, 60) * 0.333f) + } + + @Test + fun minCoordinateTakesTheSmallerAxis() { + assertEquals(3f, Offset(3f, 7f).minCoordinate) + assertEquals(3f, Offset(7f, 3f).minCoordinate) + } + + @Test + fun distanceToMeasuresAStraightLine() { + assertEquals(5f, Offset.Zero.distanceTo(Offset(3f, 4f))) + assertEquals(0f, Offset(2f, 2f).distanceTo(Offset(2f, 2f))) + } + + @Test + fun midpointSitsHalfwayBetweenTwoPoints() { + assertEquals(Offset(5f, 10f), Offset.Zero.midpoint(Offset(10f, 20f))) + } + + @Test + fun lengthIsTheDistanceFromTheOrigin() { + assertEquals(5f, Offset(3f, 4f).length()) + } + + @Test + fun angleIsMeasuredCounterClockwiseFromTheXAxis() { + assertEquals(0f, Offset(1f, 0f).angle()) + assertTrue(abs(Offset(0f, 1f).angle() - PI_F / 2f) < 1e-5f) + assertTrue(abs(abs(Offset(-1f, 0f).angle()) - PI_F) < 1e-5f) + } + + @Test + fun roundToIntRoundsBothAxes() { + assertEquals(IntOffset(3, 5), Offset(2.6f, 4.5f).roundToInt()) + assertEquals(IntOffset(2, 4), Offset(2.4f, 4.4f).roundToInt()) + } + + @Test + fun fromAngleBuildsAVectorOfTheGivenLength() { + val point = Offset.fromAngle((PI / 2).toFloat(), 10f) + assertTrue(abs(point.x) < 1e-5f, "expected no horizontal component but was ${point.x}") + assertTrue(abs(point.y - 10f) < 1e-5f, "expected the full length but was ${point.y}") + assertTrue(abs(point.length() - 10f) < 1e-5f) + } + + @Test + fun fromAngleAndAngleAreInverses() { + for (degrees in 0 until 360 step 30) { + val radians = degrees.toFloat().toRadians() + val recovered = Offset.fromAngle(radians, 5f).angle() + val delta = abs(recovered - radians).let { if (it > PI_F) 2 * PI_F - it else it } + assertTrue(delta < 1e-4f, "angle $radians came back as $recovered") + } + } +} diff --git a/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/AlphaTilePaintTest.kt b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/AlphaTilePaintTest.kt new file mode 100644 index 0000000..5e9911b --- /dev/null +++ b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/AlphaTilePaintTest.kt @@ -0,0 +1,53 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.skydoves.colorpicker.compose + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.unit.IntSize +import kotlin.test.Test +import kotlin.test.assertEquals + +class AlphaTilePaintTest { + + @Test + fun tilesAlternateLikeACheckerboard() { + val paint = alphaTilePaint( + tileSize = 4f, + tileOddColor = Color.White, + tileEvenColor = Color.Black, + ) + val bitmap = ImageBitmap.fromPaint(paint, IntSize(8, 8)) + + assertEquals(Color.White, bitmap.getPixel(1, 1)) + assertEquals(Color.Black, bitmap.getPixel(5, 1)) + assertEquals(Color.Black, bitmap.getPixel(1, 5)) + assertEquals(Color.White, bitmap.getPixel(5, 5)) + } + + @Test + fun thePatternRepeatsBeyondTheFirstTwoTiles() { + val paint = alphaTilePaint( + tileSize = 4f, + tileOddColor = Color.White, + tileEvenColor = Color.Black, + ) + val bitmap = ImageBitmap.fromPaint(paint, IntSize(16, 16)) + + assertEquals(Color.White, bitmap.getPixel(9, 9)) + assertEquals(Color.Black, bitmap.getPixel(13, 9)) + } +} diff --git a/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/AlphaTileTest.kt b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/AlphaTileTest.kt new file mode 100644 index 0000000..6eb1052 --- /dev/null +++ b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/AlphaTileTest.kt @@ -0,0 +1,51 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.skydoves.colorpicker.compose + +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import kotlin.test.Test + +class AlphaTileTest { + + @Test + fun theTileRendersWithoutAController() = runColorPickerUiTest { + setContent { + AlphaTile( + modifier = Modifier.size(60.dp).testTag("tile"), + selectedColor = Color.Red.copy(alpha = 0.5f), + ) + } + + onNodeWithTag("tile").assertExists() + } + + @Test + fun theTileRendersWithAController() = runColorPickerUiTest { + setContent { + AlphaTile( + modifier = Modifier.size(60.dp).testTag("tile"), + controller = rememberColorPickerController(), + ) + } + + onNodeWithTag("tile").assertExists() + } +} diff --git a/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/ColorPickerUiTest.kt b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/ColorPickerUiTest.kt new file mode 100644 index 0000000..e52ac93 --- /dev/null +++ b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/ColorPickerUiTest.kt @@ -0,0 +1,32 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.skydoves.colorpicker.compose + +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.v2.runComposeUiTest + +/** + * Runs [block] inside a headless Compose scene. + * + * The scene runs at a density of 1, so a `Modifier.size(200.dp)` node is exactly 200px and gesture + * coordinates can be written as plain numbers. + * + * A picker reports color changes from the composition's own coroutine scope, so a callback lands on + * the next idle rather than inside the gesture. Tests that read one call [waitForIdle] first. + */ +internal fun runColorPickerUiTest(block: suspend ComposeUiTest.() -> Unit) { + runComposeUiTest { block() } +} diff --git a/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/ColorPickingFinishedTest.kt b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/ColorPickingFinishedTest.kt new file mode 100644 index 0000000..82ea129 --- /dev/null +++ b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/ColorPickingFinishedTest.kt @@ -0,0 +1,159 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.skydoves.colorpicker.compose + +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.click +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipe +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +private const val SIDE = 200 + +/** + * onColorChanged fires for every step of a drag, which is too often for saving a choice or logging + * one. onColorPickingFinished fires once, when the gesture is over. + */ +class ColorPickingFinishedTest { + + @Test + fun aTapFinishesThePickOnce() = runColorPickerUiTest { + val finished = mutableListOf<ColorEnvelope>() + setContent { + HsvColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = rememberColorPickerController(), + onColorPickingFinished = { finished += it }, + ) + } + + onNodeWithTag("picker").performTouchInput { click(Offset(199f, 100f)) } + + assertEquals(1, finished.size) + assertColorEquals(Color.Red, finished.single().color, tolerance = 0.02f) + assertEquals(ColorChangeSource.Tap, finished.single().source) + assertTrue(finished.single().fromUser) + } + + @Test + fun aDragFinishesThePickOnceAtTheEnd() = runColorPickerUiTest { + val changed = mutableListOf<ColorEnvelope>() + val finished = mutableListOf<ColorEnvelope>() + setContent { + HsvColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = rememberColorPickerController(), + onColorChanged = { changed += it }, + onColorPickingFinished = { finished += it }, + ) + } + + onNodeWithTag("picker").performTouchInput { + swipe(start = Offset(100f, 100f), end = Offset(199f, 100f)) + } + + assertTrue(changed.size > 1, "the drag only reported ${changed.size} steps") + assertEquals(1, finished.size) + assertColorEquals(Color.Red, finished.single().color, tolerance = 0.05f) + assertEquals(ColorChangeSource.Drag, finished.single().source) + } + + @Test + fun theFinishedColorIsTheOneTheGestureSettledOn() = runColorPickerUiTest { + lateinit var controller: ColorPickerController + val finished = mutableListOf<ColorEnvelope>() + setContent { + controller = rememberColorPickerController() + HsvColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = controller, + onColorPickingFinished = { finished += it }, + ) + } + + onNodeWithTag("picker").performTouchInput { + swipe(start = Offset(199f, 100f), end = Offset(1f, 100f)) + } + + assertEquals(controller.selectedColor.value, finished.single().color) + assertEquals(controller.selectedColor.value.hexCode, finished.single().hexCode) + } + + @Test + fun aProgrammaticSelectionNeverFinishesAPick() = runColorPickerUiTest { + lateinit var controller: ColorPickerController + val finished = mutableListOf<ColorEnvelope>() + setContent { + controller = rememberColorPickerController() + HsvColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = controller, + onColorPickingFinished = { finished += it }, + ) + } + + controller.selectByColor(Color.Green, fromUser = false) + waitForIdle() + + assertTrue(finished.isEmpty(), "a programmatic change reported ${finished.size} picks") + } + + @Test + fun theImagePickerFinishesAPickToo() = runColorPickerUiTest { + val palette = quadrantBitmap() + val finished = mutableListOf<ColorEnvelope>() + setContent { + ImageColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = rememberColorPickerController(), + paletteImageBitmap = palette, + onColorPickingFinished = { finished += it }, + ) + } + + onNodeWithTag("picker").performTouchInput { click(Offset(50f, 150f)) } + + assertEquals(1, finished.size) + assertEquals(Color.Blue, finished.single().color) + } + + @Test + fun eachTapFinishesItsOwnPick() = runColorPickerUiTest { + val finished = mutableListOf<ColorEnvelope>() + setContent { + HsvColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = rememberColorPickerController(), + onColorPickingFinished = { finished += it }, + ) + } + + onNodeWithTag("picker").performTouchInput { click(Offset(199f, 100f)) } + onNodeWithTag("picker").performTouchInput { click(Offset(1f, 100f)) } + + assertEquals(2, finished.size) + assertColorEquals(Color.Red, finished.first().color, tolerance = 0.02f) + assertColorEquals(Color.Cyan, finished.last().color, tolerance = 0.02f) + } +} diff --git a/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/HsvColorPickerTest.kt b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/HsvColorPickerTest.kt new file mode 100644 index 0000000..f2c4314 --- /dev/null +++ b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/HsvColorPickerTest.kt @@ -0,0 +1,186 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.skydoves.colorpicker.compose + +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.click +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipe +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** The test scene runs at a density of 1, so this picker is exactly 200px on each side. */ +private const val SIDE = 200 + +class HsvColorPickerTest { + + @Test + fun thePickerRenders() = runColorPickerUiTest { + setContent { + HsvColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = rememberColorPickerController(), + ) + } + + onNodeWithTag("picker").assertExists() + } + + @Test + fun tappingTheCenterPicksWhite() = runColorPickerUiTest { + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + HsvColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = controller, + ) + } + + onNodeWithTag("picker").performTouchInput { click(Offset(100f, 100f)) } + + assertColorEquals(Color.White, controller.selectedColor.value) + } + + @Test + fun tappingTheRightEdgePicksRed() = runColorPickerUiTest { + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + HsvColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = controller, + ) + } + + onNodeWithTag("picker").performTouchInput { click(Offset(199f, 100f)) } + + assertColorEquals(Color.Red, controller.selectedColor.value, tolerance = 0.02f) + } + + @Test + fun tappingTheLeftEdgePicksCyan() = runColorPickerUiTest { + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + HsvColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = controller, + ) + } + + onNodeWithTag("picker").performTouchInput { click(Offset(1f, 100f)) } + + assertColorEquals(Color.Cyan, controller.selectedColor.value, tolerance = 0.02f) + } + + @Test + fun aTapIsReportedAsComingFromTheUser() = runColorPickerUiTest { + val envelopes = mutableListOf<ColorEnvelope>() + setContent { + HsvColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = rememberColorPickerController(), + onColorChanged = { envelopes += it }, + ) + } + + onNodeWithTag("picker").performTouchInput { click(Offset(199f, 100f)) } + waitForIdle() + + assertTrue(envelopes.last().fromUser) + assertEquals(ColorChangeSource.Tap, envelopes.last().source) + } + + @Test + fun aDragIsReportedAsADrag() = runColorPickerUiTest { + val envelopes = mutableListOf<ColorEnvelope>() + setContent { + HsvColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = rememberColorPickerController(), + onColorChanged = { envelopes += it }, + ) + } + + onNodeWithTag("picker").performTouchInput { + swipe(start = Offset(100f, 100f), end = Offset(190f, 100f)) + } + + assertEquals(ColorChangeSource.Drag, envelopes.last().source) + } + + @Test + fun onStartAndOnFinishBracketADrag() = runColorPickerUiTest { + var started = 0 + var finished = 0 + setContent { + HsvColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = rememberColorPickerController(), + onStart = { started++ }, + onFinish = { finished++ }, + ) + } + + onNodeWithTag("picker").performTouchInput { + swipe(start = Offset(100f, 100f), end = Offset(190f, 100f)) + } + + assertEquals(1, started) + assertEquals(1, finished) + } + + @Test + fun theSelectedPointFollowsTheTap() = runColorPickerUiTest { + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + HsvColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = controller, + ) + } + + onNodeWithTag("picker").performTouchInput { click(Offset(150f, 100f)) } + + assertOffsetEquals(Offset(150f, 100f), controller.selectedPoint.value, tolerance = 0.5f) + } + + @Test + fun aTapOutsideTheWheelIsSnappedOntoItsEdge() = runColorPickerUiTest { + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + HsvColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = controller, + ) + } + + onNodeWithTag("picker").performTouchInput { click(Offset(199f, 199f)) } + + val distance = controller.selectedPoint.value.distanceTo(Offset(100f, 100f)) + assertTrue(distance <= 100.5f, "the indicator escaped the wheel, it sat $distance away") + } +} diff --git a/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/ImageBitmapExtensionsTest.kt b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/ImageBitmapExtensionsTest.kt new file mode 100644 index 0000000..f1f92f1 --- /dev/null +++ b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/ImageBitmapExtensionsTest.kt @@ -0,0 +1,66 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.skydoves.colorpicker.compose + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import kotlin.test.Test +import kotlin.test.assertEquals + +class ImageBitmapExtensionsTest { + + @Test + fun sizeMirrorsTheBitmapDimensions() { + assertEquals(IntSize(6, 4), solidBitmap(IntSize(6, 4), Color.Red).size) + } + + @Test + fun fromDrawingProducesABitmapOfTheRequestedSize() { + val bitmap = solidBitmap(IntSize(8, 3), Color.Blue) + + assertEquals(8, bitmap.width) + assertEquals(3, bitmap.height) + } + + @Test + fun getPixelReadsBackTheColorThatWasDrawn() { + val bitmap = quadrantBitmap(side = 4) + + assertEquals(Color.Red, bitmap.getPixel(0, 0)) + assertEquals(Color.Green, bitmap.getPixel(3, 0)) + assertEquals(Color.Blue, bitmap.getPixel(0, 3)) + assertEquals(Color.Yellow, bitmap.getPixel(3, 3)) + } + + @Test + fun getPixelAcceptsAnIntOffset() { + val bitmap = quadrantBitmap(side = 4) + + assertEquals(Color.Yellow, bitmap.getPixel(IntOffset(2, 2))) + } + + @Test + fun fromPaintFillsTheWholeBitmap() { + val paint = + alphaTilePaint(tileSize = 2f, tileOddColor = Color.White, tileEvenColor = Color.Gray) + + val bitmap = ImageBitmap.fromPaint(paint, IntSize(4, 4)) + + assertEquals(IntSize(4, 4), bitmap.size) + } +} diff --git a/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/ImageBitmapFixtures.kt b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/ImageBitmapFixtures.kt new file mode 100644 index 0000000..55b242d --- /dev/null +++ b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/ImageBitmapFixtures.kt @@ -0,0 +1,72 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.skydoves.colorpicker.compose + +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.unit.IntSize + +/** + * A square bitmap split into four solid quadrants, so a test can tap a spot and name the color it + * expects back. + */ +internal fun quadrantBitmap( + side: Int = 4, + topLeft: Color = Color.Red, + topRight: Color = Color.Green, + bottomLeft: Color = Color.Blue, + bottomRight: Color = Color.Yellow, +): ImageBitmap = ImageBitmap.fromDrawing(IntSize(side, side)) { + val half = side / 2f + val full = side.toFloat() + fill(Rect(0f, 0f, half, half), topLeft) + fill(Rect(half, 0f, full, half), topRight) + fill(Rect(0f, half, half, full), bottomLeft) + fill(Rect(half, half, full, full), bottomRight) +} + +/** A bitmap split down the middle, for checking how a non square palette is scaled into a canvas. */ +internal fun horizontalSplitBitmap( + width: Int, + height: Int, + left: Color, + right: Color, +): ImageBitmap = ImageBitmap.fromDrawing(IntSize(width, height)) { + val half = width / 2f + fill(Rect(0f, 0f, half, height.toFloat()), left) + fill(Rect(half, 0f, width.toFloat(), height.toFloat()), right) +} + +/** A bitmap whose right half is punched out, for checking what a transparent pixel reports. */ +internal fun halfTransparentBitmap(side: Int = 4, color: Color = Color.Red): ImageBitmap = + ImageBitmap.fromDrawing(IntSize(side, side)) { + val full = side.toFloat() + fill(Rect(0f, 0f, full, full), color) + drawRect( + Rect(side / 2f, 0f, full, full), + Paint().apply { blendMode = BlendMode.Clear }, + ) + } + +internal fun solidBitmap(size: IntSize, color: Color): ImageBitmap = ImageBitmap.fromDrawing(size) { + fill(Rect(0f, 0f, size.width.toFloat(), size.height.toFloat()), color) +} + +private fun androidx.compose.ui.graphics.Canvas.fill(rect: Rect, color: Color) = + drawRect(rect, Paint().apply { this.color = color }) diff --git a/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/ImageColorPickerTest.kt b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/ImageColorPickerTest.kt new file mode 100644 index 0000000..6095672 --- /dev/null +++ b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/ImageColorPickerTest.kt @@ -0,0 +1,165 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.skydoves.colorpicker.compose + +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.click +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +private const val SIDE = 200 + +class ImageColorPickerTest { + + @Test + fun thePickerRenders() = runColorPickerUiTest { + val palette = quadrantBitmap() + setContent { + ImageColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = rememberColorPickerController(), + paletteImageBitmap = palette, + ) + } + + onNodeWithTag("picker").assertExists() + } + + @Test + fun tappingAQuadrantPicksThatQuadrantsColor() = runColorPickerUiTest { + val palette = quadrantBitmap() + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + ImageColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = controller, + paletteImageBitmap = palette, + ) + } + + onNodeWithTag("picker").performTouchInput { click(Offset(50f, 50f)) } + assertEquals(Color.Red, controller.selectedColor.value) + + onNodeWithTag("picker").performTouchInput { click(Offset(150f, 50f)) } + assertEquals(Color.Green, controller.selectedColor.value) + + onNodeWithTag("picker").performTouchInput { click(Offset(50f, 150f)) } + assertEquals(Color.Blue, controller.selectedColor.value) + + onNodeWithTag("picker").performTouchInput { click(Offset(150f, 150f)) } + assertEquals(Color.Yellow, controller.selectedColor.value) + } + + @Test + fun fitLetterboxesAWidePaletteAndStillPicksTheRightHalves() = runColorPickerUiTest { + val palette = horizontalSplitBitmap(4, 2, Color.Red, Color.Green) + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + ImageColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = controller, + paletteImageBitmap = palette, + paletteContentScale = PaletteContentScale.FIT, + ) + } + + onNodeWithTag("picker").performTouchInput { click(Offset(50f, 100f)) } + assertEquals(Color.Red, controller.selectedColor.value) + + onNodeWithTag("picker").performTouchInput { click(Offset(150f, 100f)) } + assertEquals(Color.Green, controller.selectedColor.value) + } + + @Test + fun cropFillsTheCanvasWithAWidePalette() = runColorPickerUiTest { + val palette = horizontalSplitBitmap(4, 2, Color.Red, Color.Green) + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + ImageColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = controller, + paletteImageBitmap = palette, + paletteContentScale = PaletteContentScale.CROP, + ) + } + + onNodeWithTag("picker").performTouchInput { click(Offset(10f, 100f)) } + assertEquals(Color.Red, controller.selectedColor.value) + + onNodeWithTag("picker").performTouchInput { click(Offset(190f, 100f)) } + assertEquals(Color.Green, controller.selectedColor.value) + } + + @Test + fun aTapIsReportedThroughOnColorChanged() = runColorPickerUiTest { + val palette = quadrantBitmap() + val envelopes = mutableListOf<ColorEnvelope>() + setContent { + ImageColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = rememberColorPickerController(), + paletteImageBitmap = palette, + onColorChanged = { envelopes += it }, + ) + } + + // The palette centers on the bottom right quadrant, so tap a different one to get an event: + // the controller drops a selection that lands on the color already showing. + onNodeWithTag("picker").performTouchInput { click(Offset(150f, 50f)) } + waitForIdle() + + assertTrue(envelopes.last().fromUser) + assertEquals(ColorChangeSource.Tap, envelopes.last().source) + assertEquals("ff00ff00", envelopes.last().hexCode) + } + + @Test + fun settingAPaletteOnTheControllerReplacesTheOne() = runColorPickerUiTest { + val palette = quadrantBitmap() + val magenta = quadrantBitmap( + topLeft = Color.Magenta, + topRight = Color.Magenta, + bottomLeft = Color.Magenta, + bottomRight = Color.Magenta, + ) + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + ImageColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = controller, + paletteImageBitmap = palette, + ) + } + + controller.setPaletteImageBitmap(magenta) + waitForIdle() + onNodeWithTag("picker").performTouchInput { click(Offset(50f, 50f)) } + + assertEquals(Color.Magenta, controller.selectedColor.value) + } +} diff --git a/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/ImagePaletteBoundsTest.kt b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/ImagePaletteBoundsTest.kt new file mode 100644 index 0000000..2c3d813 --- /dev/null +++ b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/ImagePaletteBoundsTest.kt @@ -0,0 +1,158 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.skydoves.colorpicker.compose + +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.click +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipe +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +private const val SIDE = 200 + +/** + * A palette rarely covers its whole canvas, and an image can carry holes. Neither has a color to + * report, so the picker leaves the selection where it was. + */ +class ImagePaletteBoundsTest { + + @Test + fun aTapInTheLetterboxBandIsIgnored() = runColorPickerUiTest { + // A 4x2 palette scaled to fit a square canvas leaves a 50px band above and below it. + val palette = horizontalSplitBitmap(4, 2, Color.Red, Color.Green) + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + ImageColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = controller, + paletteImageBitmap = palette, + paletteContentScale = PaletteContentScale.FIT, + ) + } + onNodeWithTag("picker").performTouchInput { click(Offset(50f, 100f)) } + val before = controller.selectedPoint.value + + onNodeWithTag("picker").performTouchInput { click(Offset(150f, 10f)) } + + assertEquals(Color.Red, controller.selectedColor.value) + assertEquals(before, controller.selectedPoint.value) + } + + @Test + fun aTapInTheLetterboxBandReportsNothing() = runColorPickerUiTest { + val palette = horizontalSplitBitmap(4, 2, Color.Red, Color.Green) + val envelopes = mutableListOf<ColorEnvelope>() + setContent { + ImageColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = rememberColorPickerController(), + paletteImageBitmap = palette, + paletteContentScale = PaletteContentScale.FIT, + onColorChanged = { envelopes += it }, + ) + } + // Land on the red half first, so the band above the green half would be a visible change. + onNodeWithTag("picker").performTouchInput { click(Offset(50f, 100f)) } + waitForIdle() + val before = envelopes.size + + onNodeWithTag("picker").performTouchInput { click(Offset(150f, 10f)) } + waitForIdle() + + assertEquals(before, envelopes.size) + } + + @Test + fun aTapOnATransparentPixelIsIgnored() = runColorPickerUiTest { + val palette = halfTransparentBitmap(side = 4, color = Color.Red) + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + ImageColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = controller, + paletteImageBitmap = palette, + ) + } + onNodeWithTag("picker").performTouchInput { click(Offset(50f, 100f)) } + + onNodeWithTag("picker").performTouchInput { click(Offset(150f, 100f)) } + + assertEquals(Color.Red, controller.selectedColor.value) + } + + @Test + fun theFixtureReallyHasATransparentHalf() { + val palette = halfTransparentBitmap(side = 4, color = Color.Red) + + assertEquals(Color.Red, palette.getPixel(0, 0)) + assertEquals(0f, palette.getPixel(3, 0).alpha) + } + + @Test + fun draggingOffThePaletteHoldsTheLastColorItCovered() = runColorPickerUiTest { + val palette = horizontalSplitBitmap(4, 2, Color.Red, Color.Green) + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + ImageColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = controller, + paletteImageBitmap = palette, + paletteContentScale = PaletteContentScale.FIT, + ) + } + + onNodeWithTag("picker").performTouchInput { + swipe(start = Offset(150f, 100f), end = Offset(150f, 5f)) + } + + assertEquals(Color.Green, controller.selectedColor.value) + assertTrue( + controller.selectedPoint.value.y >= 50f, + "the indicator left the palette at ${controller.selectedPoint.value}", + ) + } + + @Test + fun aTapInsideTheCroppedPaletteStillWorks() = runColorPickerUiTest { + val palette = horizontalSplitBitmap(4, 2, Color.Red, Color.Green) + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + ImageColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = controller, + paletteImageBitmap = palette, + paletteContentScale = PaletteContentScale.CROP, + ) + } + + // CROP fills the canvas, so every corner names a real pixel. + onNodeWithTag("picker").performTouchInput { click(Offset(2f, 2f)) } + + assertEquals(Color.Red, controller.selectedColor.value) + } +} diff --git a/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/PickerSelectionStateTest.kt b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/PickerSelectionStateTest.kt new file mode 100644 index 0000000..0001c03 --- /dev/null +++ b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/PickerSelectionStateTest.kt @@ -0,0 +1,205 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.skydoves.colorpicker.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.isSpecified +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.click +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +private const val SIDE = 200 + +/** Regressions for the ways a picker used to lose, or never take, the selection it was given. */ +class PickerSelectionStateTest { + + @Test + fun aPickerDisabledBeforeItsFirstLayoutStillDraws() = runColorPickerUiTest { + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + controller.enabled = false + HsvColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = controller, + ) + } + + // Drawing the wheel at an unspecified offset is what used to take the app down on launch. + onNodeWithTag("picker").captureToImage() + + assertTrue( + controller.selectedPoint.value.isSpecified, + "the indicator sat at ${controller.selectedPoint.value}", + ) + } + + @Test + fun aDisabledPickerStillIgnoresTaps() = runColorPickerUiTest { + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + controller.enabled = false + HsvColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = controller, + ) + } + val before = controller.selectedColor.value + + onNodeWithTag("picker").performTouchInput { click(Offset(199f, 100f)) } + + assertEquals(before, controller.selectedColor.value) + } + + @Test + fun aPaletteRebuiltOnEveryRecompositionKeepsTheSelection() = runColorPickerUiTest { + lateinit var controller: ColorPickerController + var preview by mutableStateOf(Color.Transparent) + setContent { + controller = rememberColorPickerController() + Column { + ImageColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = controller, + // A fresh instance every recomposition, which is what reading a Bitmap inline does. + paletteImageBitmap = quadrantBitmap(), + onColorChanged = { preview = it.color }, + ) + Box(Modifier.size(20.dp).background(preview)) + } + } + + onNodeWithTag("picker").performTouchInput { click(Offset(150f, 50f)) } + waitForIdle() + + assertEquals(Color.Green, controller.selectedColor.value) + assertOffsetEquals(Offset(150f, 50f), controller.selectedPoint.value, tolerance = 1f) + } + + @Test + fun anInitialColorKeepsItsBrightness() = runColorPickerUiTest { + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + HsvColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = controller, + initialColor = Color(0xFF000080), + ) + } + + assertColorEquals(Color(0xFF000080), controller.selectedColor.value, tolerance = 0.02f) + } + + @Test + fun anInitialColorKeepsItsAlpha() = runColorPickerUiTest { + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + Column { + HsvColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = controller, + initialColor = Color.Red.copy(alpha = 0.5f), + ) + AlphaSlider(modifier = Modifier.size(SIDE.dp, 30.dp), controller = controller) + } + } + + assertColorEquals(Color.Red.copy(alpha = 0.5f), controller.selectedColor.value, 0.02f) + } + + @Test + fun aColorChosenBeforeTheFirstLayoutIsAppliedOnceThePickerIsReady() = runColorPickerUiTest { + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + LaunchedEffect(Unit) { controller.selectByColor(Color.Cyan, fromUser = false) } + HsvColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = controller, + ) + } + waitForIdle() + + assertColorEquals(Color.Cyan, controller.selectedColor.value, tolerance = 0.02f) + } + + @Test + fun aNewWheelBitmapIsDrawn() = runColorPickerUiTest { + var wheelColor by mutableStateOf(Color.Blue) + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + HsvColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = controller, + wheelImageBitmap = solidBitmap(IntSize(40, 40), wheelColor), + ) + } + val before = onNodeWithTag("picker").captureToImage().getPixel(100, 100) + + wheelColor = Color.Green + waitForIdle() + val after = onNodeWithTag("picker").captureToImage().getPixel(100, 100) + + assertEquals(Color.Blue, before) + assertEquals(Color.Green, after) + assertNotEquals(before, after) + } + + @Test + fun theControllerHoldsTheWheelBitmapItWasLastGiven() = runColorPickerUiTest { + val first = solidBitmap(IntSize(40, 40), Color.Blue) + val second = solidBitmap(IntSize(40, 40), Color.Green) + var wheel by mutableStateOf(first) + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + HsvColorPicker( + modifier = Modifier.size(SIDE.dp).testTag("picker"), + controller = controller, + wheelImageBitmap = wheel, + ) + } + assertSame(first, controller.wheelBitmap) + + wheel = second + waitForIdle() + + assertSame(second, controller.wheelBitmap) + } +} diff --git a/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/SliderTest.kt b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/SliderTest.kt new file mode 100644 index 0000000..5547f82 --- /dev/null +++ b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/SliderTest.kt @@ -0,0 +1,181 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.skydoves.colorpicker.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.click +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipe +import androidx.compose.ui.unit.dp +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** The sliders are 200px wide and 30px tall in the test scene, which runs at a density of 1. */ +private const val WIDTH = 200 +private const val HEIGHT = 30 +private val CENTER = Offset(WIDTH / 2f, HEIGHT / 2f) +private val LEFT_END = Offset(0f, HEIGHT / 2f) + +class SliderTest { + + @Test + fun everySliderRenders() = runColorPickerUiTest { + setContent { + val controller = rememberColorPickerController() + Column { + AlphaSlider(modifier = sliderModifier("alpha"), controller = controller) + BrightnessSlider(modifier = sliderModifier("brightness"), controller = controller) + HueSlider(modifier = sliderModifier("hue"), controller = controller) + SaturationSlider(modifier = sliderModifier("saturation"), controller = controller) + } + } + + onNodeWithTag("alpha").assertExists() + onNodeWithTag("brightness").assertExists() + onNodeWithTag("hue").assertExists() + onNodeWithTag("saturation").assertExists() + } + + @Test + fun tappingTheMiddleOfTheAlphaSliderHalvesTheAlpha() = runColorPickerUiTest { + val controller = withPickerAndSlider { AlphaSlider(sliderModifier("slider"), it) } + + onNodeWithTag("slider").performTouchInput { click(CENTER) } + + assertNear(0.5f, controller.alpha.value) + } + + @Test + fun tappingTheStartOfTheAlphaSliderClearsTheAlpha() = runColorPickerUiTest { + val controller = withPickerAndSlider { AlphaSlider(sliderModifier("slider"), it) } + + onNodeWithTag("slider").performTouchInput { click(LEFT_END) } + + assertEquals(0f, controller.alpha.value) + } + + @Test + fun tappingTheMiddleOfTheBrightnessSliderHalvesTheBrightness() = runColorPickerUiTest { + val controller = withPickerAndSlider { BrightnessSlider(sliderModifier("slider"), it) } + + onNodeWithTag("slider").performTouchInput { click(CENTER) } + + assertNear(0.5f, controller.brightness.value) + } + + @Test + fun tappingTheStartOfTheBrightnessSliderTurnsTheColorBlack() = runColorPickerUiTest { + val controller = withPickerAndSlider { BrightnessSlider(sliderModifier("slider"), it) } + + onNodeWithTag("slider").performTouchInput { click(LEFT_END) } + + assertEquals(0f, controller.brightness.value) + assertColorEquals(Color.Black, controller.selectedColor.value) + } + + @Test + fun tappingTheMiddleOfTheSaturationSliderHalvesTheSaturation() = runColorPickerUiTest { + val controller = withPickerAndSlider { SaturationSlider(sliderModifier("slider"), it) } + + onNodeWithTag("slider").performTouchInput { click(CENTER) } + + assertNear(0.5f, controller.saturation.value) + } + + @Test + fun tappingTheMiddleOfTheHueSliderLandsOnCyan() = runColorPickerUiTest { + val controller = withPickerAndSlider { HueSlider(sliderModifier("slider"), it) } + + onNodeWithTag("slider").performTouchInput { click(CENTER) } + + assertColorEquals(Color.Cyan, controller.selectedColor.value, tolerance = 0.02f) + } + + @Test + fun draggingTowardsTheEndRaisesTheValue() = runColorPickerUiTest { + val controller = withPickerAndSlider { AlphaSlider(sliderModifier("slider"), it) } + onNodeWithTag("slider").performTouchInput { click(LEFT_END) } + + onNodeWithTag("slider").performTouchInput { + swipe(start = LEFT_END, end = Offset(WIDTH * 0.8f, HEIGHT / 2f)) + } + + assertTrue( + controller.alpha.value > 0.5f, + "expected the drag to raise the alpha but it was ${controller.alpha.value}", + ) + } + + @Test + fun onStartAndOnFinishBracketADrag() = runColorPickerUiTest { + var started = 0 + var finished = 0 + setContent { + BrightnessSlider( + modifier = sliderModifier("slider"), + controller = rememberColorPickerController(), + onStart = { started++ }, + onFinish = { finished++ }, + ) + } + + onNodeWithTag("slider").performTouchInput { + swipe(start = LEFT_END, end = Offset(WIDTH * 0.8f, HEIGHT / 2f)) + } + + assertEquals(1, started) + assertEquals(1, finished) + } + + /** Composes a red picker with [slider] underneath and hands back the shared controller. */ + private fun ComposeUiTest.withPickerAndSlider( + slider: @Composable (ColorPickerController) -> Unit, + ): ColorPickerController { + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + Column { + HsvColorPicker( + modifier = Modifier.size(200.dp).testTag("picker"), + controller = controller, + initialColor = Color.Red, + ) + slider(controller) + } + } + onNodeWithTag("picker").performTouchInput { click(Offset(199f, 100f)) } + return controller + } +} + +private fun sliderModifier(tag: String) = Modifier.width(WIDTH.dp).height(HEIGHT.dp).testTag(tag) + +private fun assertNear(expected: Float, actual: Float, tolerance: Float = 0.02f) = assertTrue( + abs(expected - actual) <= tolerance, + "expected $expected but was $actual", +) diff --git a/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/SliderThumbTest.kt b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/SliderThumbTest.kt new file mode 100644 index 0000000..495806c --- /dev/null +++ b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/SliderThumbTest.kt @@ -0,0 +1,125 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.skydoves.colorpicker.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.click +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.unit.dp +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** The slider is 200x40 and the thumb has the default 12dp radius, at a density of 1. */ +private const val LENGTH = 200 +private const val THICKNESS = 40 +private const val THUMB_RADIUS = 12 +private const val MIDDLE = THICKNESS / 2f + +class SliderThumbTest { + + @Test + fun theThumbStaysInsideTheTrackAtTheEnd() = runColorPickerUiTest { + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + Column { + HsvColorPicker( + modifier = Modifier.size(200.dp).testTag("picker"), + controller = controller, + initialColor = Color.Red, + ) + AlphaSlider( + modifier = Modifier.width(LENGTH.dp).height(THICKNESS.dp).testTag("slider"), + controller = controller, + ) + } + } + + onNodeWithTag("slider").performTouchInput { click(Offset(LENGTH - 1f, MIDDLE)) } + + // The thumb center belongs a radius in from the end, so this pixel is under it. It used to sit + // on the very edge with half of it clipped away, leaving the gradient showing here. + val pixel = onNodeWithTag("slider").captureToImage() + .getPixel(LENGTH - THUMB_RADIUS * 2 + 4, MIDDLE.toInt()) + assertColorEquals(Color.White, pixel, tolerance = 0.05f) + } + + @Test + fun theThumbStaysInsideTheTrackAtTheStart() = runColorPickerUiTest { + setContent { + HueSlider( + modifier = Modifier.width(LENGTH.dp).height(THICKNESS.dp).testTag("slider"), + controller = rememberColorPickerController(), + ) + } + + onNodeWithTag("slider").performTouchInput { click(Offset(0f, MIDDLE)) } + + val pixel = onNodeWithTag("slider").captureToImage() + .getPixel(THUMB_RADIUS * 2 - 4, MIDDLE.toInt()) + assertColorEquals(Color.White, pixel, tolerance = 0.05f) + } + + @Test + fun theTrackRunsFromOneThumbRadiusToTheOther() = runColorPickerUiTest { + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + AlphaSlider( + modifier = Modifier.width(LENGTH.dp).height(THICKNESS.dp).testTag("slider"), + controller = controller, + ) + } + + onNodeWithTag("slider").performTouchInput { click(Offset(THUMB_RADIUS.toFloat(), MIDDLE)) } + assertEquals(0f, controller.alpha.value) + + onNodeWithTag("slider").performTouchInput { + click(Offset(LENGTH - THUMB_RADIUS.toFloat(), MIDDLE)) + } + assertEquals(1f, controller.alpha.value) + } + + @Test + fun theMiddleOfTheTrackIsStillTheMiddleOfTheRange() = runColorPickerUiTest { + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + AlphaSlider( + modifier = Modifier.width(LENGTH.dp).height(THICKNESS.dp).testTag("slider"), + controller = controller, + ) + } + + onNodeWithTag("slider").performTouchInput { click(Offset(LENGTH / 2f, MIDDLE)) } + + assertTrue( + abs(controller.alpha.value - 0.5f) < 0.02f, + "expected the middle of the range but was ${controller.alpha.value}", + ) + } +} diff --git a/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/StandaloneSliderTest.kt b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/StandaloneSliderTest.kt new file mode 100644 index 0000000..b603649 --- /dev/null +++ b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/StandaloneSliderTest.kt @@ -0,0 +1,120 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.skydoves.colorpicker.compose + +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.click +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.unit.dp +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +private const val LENGTH = 200 +private const val THICKNESS = 40 +private const val MIDDLE = THICKNESS / 2f + +/** A slider on its own, with no picker beside it, which is how a grey gets picked. */ +class StandaloneSliderTest { + + @Test + fun aBrightnessSliderOnItsOwnReportsGreys() = runColorPickerUiTest { + val envelopes = mutableListOf<ColorEnvelope>() + setContent { + BrightnessSlider( + modifier = Modifier.width(LENGTH.dp).height(THICKNESS.dp).testTag("slider"), + controller = rememberColorPickerController(), + onColorChanged = { envelopes += it }, + ) + } + + onNodeWithTag("slider").performTouchInput { click(Offset(LENGTH / 2f, MIDDLE)) } + waitForIdle() + + val picked = envelopes.last() + assertTrue(picked.fromUser) + assertEquals(ColorChangeSource.Tap, picked.source) + assertColorEquals(Color(0xFF808080), picked.color, tolerance = 0.02f) + } + + @Test + fun aBrightnessSliderOnItsOwnPaintsAGreyRamp() = runColorPickerUiTest { + setContent { + BrightnessSlider( + modifier = Modifier.width(LENGTH.dp).height(THICKNESS.dp).testTag("slider"), + controller = rememberColorPickerController(), + ) + } + + val image = onNodeWithTag("slider").captureToImage() + + // The track used to inherit the alpha of a controller with nothing selected, which is zero, so + // the whole slider came out invisible. It also assumed full saturation and painted red. + val dark = image.getPixel(10, MIDDLE.toInt()) + val light = image.getPixel(160, MIDDLE.toInt()) + assertEquals(1f, dark.alpha) + assertEquals(1f, light.alpha) + assertTrue(dark.red < 0.3f, "the dark end was $dark") + assertTrue(light.red > 0.6f, "the light end was $light") + assertTrue(abs(light.red - light.blue) < 0.05f, "the ramp was not grey, it was $light") + } + + @Test + fun anAlphaSliderOnItsOwnReportsChanges() = runColorPickerUiTest { + val envelopes = mutableListOf<ColorEnvelope>() + setContent { + AlphaSlider( + modifier = Modifier.width(LENGTH.dp).height(THICKNESS.dp).testTag("slider"), + controller = rememberColorPickerController(), + onColorChanged = { envelopes += it }, + ) + } + + onNodeWithTag("slider").performTouchInput { click(Offset(LENGTH / 2f, MIDDLE)) } + waitForIdle() + + assertTrue(envelopes.isNotEmpty()) + assertTrue(abs(envelopes.last().color.alpha - 0.5f) < 0.02f) + } + + @Test + fun aSaturationSliderOnItsOwnReportsChanges() = runColorPickerUiTest { + val envelopes = mutableListOf<ColorEnvelope>() + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + SaturationSlider( + modifier = Modifier.width(LENGTH.dp).height(THICKNESS.dp).testTag("slider"), + controller = controller, + onColorChanged = { envelopes += it }, + ) + } + + onNodeWithTag("slider").performTouchInput { click(Offset(LENGTH / 2f, MIDDLE)) } + waitForIdle() + + assertTrue(envelopes.isNotEmpty()) + assertTrue(abs(controller.saturation.value - 0.5f) < 0.02f) + } +} diff --git a/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/VerticalSliderTest.kt b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/VerticalSliderTest.kt new file mode 100644 index 0000000..5ef473c --- /dev/null +++ b/colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/VerticalSliderTest.kt @@ -0,0 +1,134 @@ +/* + * Designed and developed by 2022 skydoves (Jaewoong Eum) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.skydoves.colorpicker.compose + +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.click +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipe +import androidx.compose.ui.unit.dp +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +private const val LENGTH = 200 +private const val THICKNESS = 40 +private const val MIDDLE = THICKNESS / 2f +private const val THUMB_RADIUS = 12 + +class VerticalSliderTest { + + @Test + fun theBottomOfAVerticalSliderIsTheLowestValue() = runColorPickerUiTest { + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + BrightnessSlider( + modifier = Modifier.width(THICKNESS.dp).height(LENGTH.dp).testTag("slider"), + controller = controller, + orientation = SliderOrientation.Vertical, + ) + } + + onNodeWithTag("slider").performTouchInput { click(Offset(MIDDLE, LENGTH - 1f)) } + + assertEquals(0f, controller.brightness.value) + } + + @Test + fun theTopOfAVerticalSliderIsTheHighestValue() = runColorPickerUiTest { + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + BrightnessSlider( + modifier = Modifier.width(THICKNESS.dp).height(LENGTH.dp).testTag("slider"), + controller = controller, + orientation = SliderOrientation.Vertical, + ) + } + onNodeWithTag("slider").performTouchInput { click(Offset(MIDDLE, LENGTH - 1f)) } + + onNodeWithTag("slider").performTouchInput { click(Offset(MIDDLE, 0f)) } + + assertEquals(1f, controller.brightness.value) + } + + @Test + fun theMiddleOfAVerticalSliderIsTheMiddleOfTheRange() = runColorPickerUiTest { + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + AlphaSlider( + modifier = Modifier.width(THICKNESS.dp).height(LENGTH.dp).testTag("slider"), + controller = controller, + orientation = SliderOrientation.Vertical, + ) + } + + onNodeWithTag("slider").performTouchInput { click(Offset(MIDDLE, LENGTH / 2f)) } + + assertTrue( + abs(controller.alpha.value - 0.5f) < 0.02f, + "expected the middle of the range but was ${controller.alpha.value}", + ) + } + + @Test + fun aVerticalDragMovesTheValue() = runColorPickerUiTest { + lateinit var controller: ColorPickerController + setContent { + controller = rememberColorPickerController() + BrightnessSlider( + modifier = Modifier.width(THICKNESS.dp).height(LENGTH.dp).testTag("slider"), + controller = controller, + orientation = SliderOrientation.Vertical, + ) + } + + onNodeWithTag("slider").performTouchInput { + swipe(start = Offset(MIDDLE, 20f), end = Offset(MIDDLE, 180f)) + } + + assertTrue( + controller.brightness.value < 0.2f, + "dragging to the bottom should have lowered it but it was ${controller.brightness.value}", + ) + } + + @Test + fun theThumbOfAVerticalSliderSitsAtTheBottomForTheLowestValue() = runColorPickerUiTest { + setContent { + BrightnessSlider( + modifier = Modifier.width(THICKNESS.dp).height(LENGTH.dp).testTag("slider"), + controller = rememberColorPickerController(), + orientation = SliderOrientation.Vertical, + ) + } + + onNodeWithTag("slider").performTouchInput { click(Offset(MIDDLE, LENGTH - 1f)) } + + val pixel = onNodeWithTag("slider").captureToImage() + .getPixel(MIDDLE.toInt(), LENGTH - THUMB_RADIUS * 2 + 4) + assertColorEquals(androidx.compose.ui.graphics.Color.White, pixel, tolerance = 0.05f) + } +} diff --git a/demo/androidApp/build.gradle.kts b/demo/androidApp/build.gradle.kts index 1c76a8e..4c324db 100644 --- a/demo/androidApp/build.gradle.kts +++ b/demo/androidApp/build.gradle.kts @@ -18,6 +18,7 @@ import com.github.skydoves.colorpicker.compose.Configuration plugins { alias(libs.plugins.android.application) alias(libs.plugins.compose.compiler) + alias(libs.plugins.baseline.profile) } android { @@ -51,6 +52,15 @@ android { lint { abortOnError = false } + + buildTypes { + create("benchmark") { + initWith(buildTypes.getByName("release")) + signingConfig = signingConfigs.getByName("debug") + matchingFallbacks += listOf("release") + isDebuggable = false + } + } } dependencies { @@ -60,6 +70,7 @@ dependencies { implementation(libs.compose.material) implementation(libs.compose.ui) implementation(libs.filekit.dialogs.compose) + baselineProfile(project(":benchmark")) } kotlin { diff --git a/demo/shared/src/iosMain/kotlin/MainViewController.kt b/demo/shared/src/iosMain/kotlin/MainViewController.kt index ede3402..ee1656c 100644 --- a/demo/shared/src/iosMain/kotlin/MainViewController.kt +++ b/demo/shared/src/iosMain/kotlin/MainViewController.kt @@ -16,7 +16,6 @@ import androidx.compose.ui.window.ComposeUIViewController import com.github.skydoves.colorpickercomposedemo.MainScreen -// Entry point consumed by iosApp.xcodeproj (Swift: MainViewControllerKt.MainViewController()), -// so the PascalCase name is required and not subject to the function-naming convention. +// Named after the type it builds, which is how iOS expects to find it. @Suppress("ktlint:standard:function-naming") fun MainViewController() = ComposeUIViewController { MainScreen() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 87b2241..ac834ef 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,10 +1,10 @@ [versions] -agp = "9.2.1" +agp = "9.4.0" dokka = "2.2.0" -nexusPlugin = "0.36.0" -kotlin = "2.4.0" -kotlinBinaryCompatibility = "0.18.1" -compose-plugin = "1.11.1" +nexusPlugin = "0.37.0" +kotlin = "2.4.20" +kotlinBinaryCompatibility = "0.18.2" +compose-plugin = "1.12.0" # Compose Multiplatform ships these on independent release tracks (CMP plugin no longer pins them). composeMaterial3 = "1.9.0" composeMaterialIconsExtended = "1.7.3" @@ -14,9 +14,10 @@ androidxTest = "1.7.0" androidxNavigation = "2.9.2" filekit = "0.14.1" profileInstaller = "1.4.1" -uiAutomator = "2.3.0" -spotless = "8.6.0" -androidxMacroBenchmark = "1.5.0-alpha06" +uiAutomator = "2.4.0" +spotless = "8.10.2" +androidxMacroBenchmark = "1.5.0" +coroutines = "1.11.0" [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -48,7 +49,11 @@ compose-material3 = { module = "org.jetbrains.compose.material3:material3", vers compose-material-icons-extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "composeMaterialIconsExtended" } compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "compose-plugin" } -# unit test +# test +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +compose-ui-test = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "compose-plugin" } + androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTest" } androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstaller", version.ref = "profileInstaller" } androidx-benchmark-macro = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "androidxMacroBenchmark" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar Binary files differindex b1b8ef5..eddabd2 100644 --- a/gradle/wrapper/gradle-wrapper.jar +++ b/gradle/wrapper/gradle-wrapper.jar diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index df6a6ad..ad7845b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: diff --git a/gradlew.bat b/gradlew.bat index aa5f10b..8508ef6 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -19,7 +19,7 @@ @if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
-@rem Gradle startup script for Windows
+@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@@ -72,7 +72,7 @@ echo location of your Java installation. 1>&2 -@rem Execute Gradle
+@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
