summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorskydoves <skydoves2@gmail.com>2026-09-16 12:09:15 +0300
committerskydoves <skydoves2@gmail.com>2026-09-16 12:09:24 +0300
commitb412754c65e7cbf76aa77401e7ce57324a7aeb0c (patch)
treebec651dd01587552877a1e7bd377cc932a984c2d
parent2dbf80738e26b025dde78935e61cebf5b9cf5ea6 (diff)
parent696a410a1d31eecca8805a2822a17fa62ffa1eb6 (diff)
downloadcolorpicker-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.
-rw-r--r--.github/workflows/android.yml45
-rw-r--r--.github/workflows/baseline-profile.yml4
-rw-r--r--.github/workflows/docs.yml4
-rw-r--r--.github/workflows/publish.yml4
-rw-r--r--README.md57
-rw-r--r--build.gradle.kts4
-rw-r--r--buildSrc/src/main/kotlin/com/github/skydoves/colorpicker/compose/Configuration.kt2
-rw-r--r--colorpicker-compose/api/desktop/colorpicker-compose.api20
-rw-r--r--colorpicker-compose/build.gradle.kts34
-rw-r--r--colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/AlphaSlider.kt11
-rw-r--r--colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/AlphaTile.kt26
-rw-r--r--colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/BrightnessSlider.kt17
-rw-r--r--colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/ColorPicker.kt39
-rw-r--r--colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/ColorPickerController.kt85
-rw-r--r--colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/HsvColorPicker.kt9
-rw-r--r--colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/HueSlider.kt7
-rw-r--r--colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/ImageColorPicker.kt48
-rw-r--r--colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/ObserveColorChanges.kt43
-rw-r--r--colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/PaletteContentScale.kt10
-rw-r--r--colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/SaturationSlider.kt7
-rw-r--r--colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/Slider.kt151
-rw-r--r--colorpicker-compose/src/commonMain/kotlin/com/github/skydoves/colorpicker/compose/SliderOrientation.kt26
-rw-r--r--colorpicker-compose/src/commonTest/kotlin/com/github/skydoves/colorpicker/compose/ColorEnvelopeTest.kt48
-rw-r--r--colorpicker-compose/src/commonTest/kotlin/com/github/skydoves/colorpicker/compose/ColorExtensionsTest.kt112
-rw-r--r--colorpicker-compose/src/commonTest/kotlin/com/github/skydoves/colorpicker/compose/ColorPickerControllerTest.kt287
-rw-r--r--colorpicker-compose/src/commonTest/kotlin/com/github/skydoves/colorpicker/compose/ColorPickerTestFixtures.kt76
-rw-r--r--colorpicker-compose/src/commonTest/kotlin/com/github/skydoves/colorpicker/compose/GeometryExtensionsTest.kt105
-rw-r--r--colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/AlphaTilePaintTest.kt53
-rw-r--r--colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/AlphaTileTest.kt51
-rw-r--r--colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/ColorPickerUiTest.kt32
-rw-r--r--colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/ColorPickingFinishedTest.kt159
-rw-r--r--colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/HsvColorPickerTest.kt186
-rw-r--r--colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/ImageBitmapExtensionsTest.kt66
-rw-r--r--colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/ImageBitmapFixtures.kt72
-rw-r--r--colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/ImageColorPickerTest.kt165
-rw-r--r--colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/ImagePaletteBoundsTest.kt158
-rw-r--r--colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/PickerSelectionStateTest.kt205
-rw-r--r--colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/SliderTest.kt181
-rw-r--r--colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/SliderThumbTest.kt125
-rw-r--r--colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/StandaloneSliderTest.kt120
-rw-r--r--colorpicker-compose/src/desktopTest/kotlin/com/github/skydoves/colorpicker/compose/VerticalSliderTest.kt134
-rw-r--r--demo/androidApp/build.gradle.kts11
-rw-r--r--demo/shared/src/iosMain/kotlin/MainViewController.kt3
-rw-r--r--gradle/libs.versions.toml23
-rw-r--r--gradle/wrapper/gradle-wrapper.jarbin48462 -> 47505 bytes
-rw-r--r--gradle/wrapper/gradle-wrapper.properties2
-rwxr-xr-xgradlew4
-rw-r--r--gradlew.bat4
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
diff --git a/README.md b/README.md
index b38226b..e7bac02 100644
--- a/README.md
+++ b/README.md
@@ -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
index b1b8ef5..eddabd2 100644
--- a/gradle/wrapper/gradle-wrapper.jar
+++ b/gradle/wrapper/gradle-wrapper.jar
Binary files differ
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
diff --git a/gradlew b/gradlew
index b9bb139..249efbb 100755
--- a/gradlew
+++ b/gradlew
@@ -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